diff --git a/apps/api-gateway/cmd/main.go b/apps/api-gateway/cmd/main.go index ac72729..3649d6d 100644 --- a/apps/api-gateway/cmd/main.go +++ b/apps/api-gateway/cmd/main.go @@ -81,6 +81,13 @@ func main() { StripPrefix: false, Timeout: 60, }, + { + // 工具管理 API - 列表、创建、详情、更新、删除 + Path: "/api/tools", + ServiceName: "agent-service", + StripPrefix: false, + Timeout: 10, + }, } proxyManager.LoadRoutes(routes) diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma deleted file mode 100644 index 82399cc..0000000 --- a/apps/web/prisma/schema.prisma +++ /dev/null @@ -1,74 +0,0 @@ -// This is your Prisma schema file, -// learn more about it in the docs: https://pris.ly/d/prisma-schema - -generator client { - provider = "prisma-client-js" -} - -datasource db { - provider = "postgresql" -} - -// Better-Auth user model -model user { - id String @id @default(cuid()) - email String? @unique - emailVerified Boolean? @default(false) - name String? - image String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - accounts account[] - sessions session[] -} - -// Better-Auth account model (for OAuth providers) -model account { - id String @id @default(cuid()) - userId String - providerId String - accountId String - accessToken String? @db.Text - refreshToken String? @db.Text - idToken String? @db.Text - accessTokenExpiresAt DateTime? - refreshTokenExpiresAt DateTime? - scope String? - password String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - user user @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@unique([providerId, accountId]) - @@index([userId]) -} - -// Better-Auth session model -model session { - id String @id @default(cuid()) - userId String - token String @unique - expiresAt DateTime - ipAddress String? - userAgent String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - user user @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@index([userId]) -} - -// Better-Auth verification model -model verification { - id String @id @default(cuid()) - identifier String - value String - expiresAt DateTime - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@unique([identifier, value]) -} diff --git a/apps/web/src/app/[locale]/(dashboard)/agents/components/AgentCard.tsx b/apps/web/src/app/[locale]/(dashboard)/agents/components/AgentCard.tsx index 88f506f..dfff6b1 100644 --- a/apps/web/src/app/[locale]/(dashboard)/agents/components/AgentCard.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/agents/components/AgentCard.tsx @@ -20,10 +20,19 @@ import { Badge, } from '@/components/atoms' import { agentService, type Agent } from '@/service/agent' -import { Edit2, Trash2, Sparkles, Lock, Globe, Bot } from 'lucide-react' +import { + Edit2, + Trash2, + Sparkles, + Lock, + Globe, + Bot, + Settings, +} from 'lucide-react' import { toast } from 'sonner' import { cn } from '@/lib/utils' import { formatDistanceToNow } from 'date-fns' +import { ConfigureToolsModal } from './ConfigureToolsModal' interface AgentCardProps { agent: Agent @@ -33,6 +42,7 @@ interface AgentCardProps { export function AgentCard({ agent, onUpdate }: AgentCardProps) { const t = useTranslations('Agent') const [showDeleteDialog, setShowDeleteDialog] = useState(false) + const [showToolsModal, setShowToolsModal] = useState(false) const [isDeleting, setIsDeleting] = useState(false) const handleDelete = async () => { @@ -134,6 +144,15 @@ export function AgentCard({ agent, onUpdate }: AgentCardProps) { {/* Actions */}
+ {canEdit && ( +
+ ) + }) + )} + + + {/* 分类说明 */} +
+

+ {t('toolCategories')} +

+
+ +
+ Web + + +
+ API + + +
+ Database + + +
+ Custom + +
+
+
+ )} + + {/* 底部按钮 */} +
+ + +
+ + + ) +} diff --git a/apps/web/src/app/[locale]/(dashboard)/chat/ChatView.tsx b/apps/web/src/app/[locale]/(dashboard)/chat/ChatView.tsx index 93edb9e..0d511e2 100644 --- a/apps/web/src/app/[locale]/(dashboard)/chat/ChatView.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/chat/ChatView.tsx @@ -1,10 +1,13 @@ 'use client' import { useState, useRef, useEffect, useMemo } from 'react' -import { agentService, type Agent } from '@/service/agent' +import { Wrench } from 'lucide-react' +import { agentService, type Agent, type ToolCall } from '@/service/agent' import { useTranslations } from 'next-intl' import { ChatContainer, type Message } from '@/components/organisms' +import { ChatInputAction } from '@/components/atoms' import type { SuggestionPrompt } from '@/components/atoms' +import { type StreamChunk } from '@/service/agent' export function ChatView() { const t = useTranslations('Chat') @@ -16,6 +19,7 @@ export function ChatView() { const [lastUserMessage, setLastUserMessage] = useState('') const scrollRef = useRef(null) const textareaRef = useRef(null) + const [enableTools, setEnableTools] = useState(true) // 工具调用开关 const suggestionPrompts = useMemo( (): SuggestionPrompt[] => [ @@ -49,6 +53,48 @@ export function ChatView() { } }, [messages]) + // 处理工具调用事件 + const handleToolCallEvent = ( + assistantMessage: Message, + chunk: StreamChunk, + setMessages: React.Dispatch> + ) => { + if (!chunk.toolCall) return + + setMessages(prev => { + return prev.map(m => { + if (m.id !== assistantMessage.id) return m + + // 判断工具调用应该放在前面还是后面 + // 如果消息内容已经存在,说明工具调用是在内容之后产生的 + const hasContent = m.content.length > 0 + const targetKey = hasContent ? 'toolCallsAfter' : 'toolCallsBefore' + + // 获取或初始化工具调用列表 + const currentToolCalls = m[targetKey] || [] + const existingToolCallIndex = currentToolCalls.findIndex( + tc => tc.id === chunk.toolCall!.id + ) + + let updatedToolCalls: ToolCall[] + + if (existingToolCallIndex >= 0) { + // 更新现有工具调用 + updatedToolCalls = [...currentToolCalls] + updatedToolCalls[existingToolCallIndex] = { + ...updatedToolCalls[existingToolCallIndex], + ...chunk.toolCall, + } + } else { + // 添加新工具调用 + updatedToolCalls = [...currentToolCalls, chunk.toolCall!] + } + + return { ...m, [targetKey]: updatedToolCalls } + }) + }) + } + const handleSend = async (messageContent?: string) => { const shouldUseMessageContent = messageContent && typeof messageContent === 'string' @@ -74,6 +120,8 @@ export function ChatView() { role: 'assistant', content: '', timestamp: new Date(), + toolCallsBefore: [], + toolCallsAfter: [], } setMessages(prev => [...prev, assistantMessage]) @@ -82,6 +130,11 @@ export function ChatView() { await agentService.chatStream( contentToSend, chunk => { + // 处理工具调用事件 + if (chunk.type?.startsWith('tool_call')) { + handleToolCallEvent(assistantMessage, chunk, setMessages) + } + if (chunk.done) { setIsLoading(false) textareaRef.current?.focus() @@ -108,7 +161,7 @@ export function ChatView() { ) ) }, - selectedAgent?.id + { agentId: selectedAgent?.id, enableTools } ) } catch (error) { console.error('Chat error:', error) @@ -138,6 +191,18 @@ export function ChatView() { setMessages([]) } + // 输入框操作区域 - 工具调用开关 + const inputActions = ( + } + label={t('actions.tools') || '工具调用'} + checked={enableTools} + onToggle={() => setEnableTools(!enableTools)} + variant='toggle' + size='sm' + /> + ) + return ( ) } diff --git a/apps/web/src/app/[locale]/(dashboard)/tools/components/CreateToolModal.tsx b/apps/web/src/app/[locale]/(dashboard)/tools/components/CreateToolModal.tsx new file mode 100644 index 0000000..f05148d --- /dev/null +++ b/apps/web/src/app/[locale]/(dashboard)/tools/components/CreateToolModal.tsx @@ -0,0 +1,759 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useTranslations } from 'next-intl' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/atoms' +import { Button } from '@/components/atoms' +import { Input } from '@/components/atoms' +import { Label } from '@/components/atoms' +import { Textarea } from '@/components/atoms' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/atoms' +import { Switch } from '@/components/atoms' +import { Badge } from '@/components/atoms' +import { Plus, X } from 'lucide-react' +import { + toolService, + type CreateToolRequest, + type ParameterDef, + type ToolType, + type AuthType, +} from '@/service/tool' +import { Loader2 } from 'lucide-react' +import { toast } from 'sonner' + +interface CreateToolModalProps { + onClose: () => void + onSuccess: () => void +} + +export function CreateToolModal({ onClose, onSuccess }: CreateToolModalProps) { + const t = useTranslations('Tool') + const [isLoading, setIsLoading] = useState(false) + const [step, setStep] = useState(1) + + // 基本信息 + const [formData, setFormData] = useState<{ + id: string + name: string + display_name: string + description: string + category: string + type: ToolType + enabled: boolean + }>({ + id: '', + name: '', + display_name: '', + description: '', + category: 'custom', + type: 'invokable', + enabled: true, + }) + + // 端点配置 + const [endpoint, setEndpoint] = useState<{ + url_template: string + method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' + headers: { key: string; value: string }[] + auth_type: AuthType + token_env: string + timeout: number + }>({ + url_template: '', + method: 'GET', + headers: [], + auth_type: 'none', + token_env: '', + timeout: 30, + }) + + // 参数定义 + const [parameters, setParameters] = useState>({}) + const [newParam, setNewParam] = useState({ + name: '', + type: 'string', + description: '', + required: false, + }) + + // 响应转换 + const [responseTransform, setResponseTransform] = useState({ + extract: '$', + format: 'json' as const, + }) + + // 添加请求头 + const addHeader = () => { + setEndpoint({ + ...endpoint, + headers: [...endpoint.headers, { key: '', value: '' }], + }) + } + + // 删除请求头 + const removeHeader = (index: number) => { + setEndpoint({ + ...endpoint, + headers: endpoint.headers.filter((_, i) => i !== index), + }) + } + + // 更新请求头 + const updateHeader = ( + index: number, + field: 'key' | 'value', + value: string + ) => { + const newHeaders = [...endpoint.headers] + newHeaders[index][field] = value + setEndpoint({ ...endpoint, headers: newHeaders }) + } + + // 添加参数 + const addParameter = () => { + if (!newParam.name.trim()) return + + setParameters({ + ...parameters, + [newParam.name]: { + type: newParam.type, + description: newParam.description, + required: newParam.required, + }, + }) + + setNewParam({ name: '', type: 'string', description: '', required: false }) + } + + // 删除参数 + const removeParameter = (name: string) => { + const newParams = { ...parameters } + delete newParams[name] + setParameters(newParams) + } + + // 验证第一步 + const validateStep1 = () => { + if (!formData.id.trim()) { + toast.error(t('validation.idRequired')) + return false + } + if (!formData.name.trim()) { + toast.error(t('validation.nameRequired')) + return false + } + if (!formData.display_name.trim()) { + toast.error(t('validation.displayNameRequired')) + return false + } + return true + } + + // 验证第二步 + const validateStep2 = () => { + if (!endpoint.url_template.trim()) { + toast.error(t('validation.urlRequired')) + return false + } + return true + } + + // 提交创建 + const handleSubmit = async () => { + if (!validateStep1() || !validateStep2()) return + + setIsLoading(true) + try { + const toolData: CreateToolRequest = { + id: formData.id.trim(), + name: formData.name.trim(), + display_name: formData.display_name.trim(), + description: formData.description.trim(), + category: formData.category, + type: formData.type, + enabled: formData.enabled, + endpoint: { + url_template: endpoint.url_template, + method: endpoint.method, + headers: Object.fromEntries( + endpoint.headers + .filter(h => h.key && h.value) + .map(h => [h.key, h.value]) + ), + timeout: endpoint.timeout, + auth: + endpoint.auth_type !== 'none' + ? { + type: endpoint.auth_type, + token_env: endpoint.token_env || undefined, + } + : undefined, + }, + parameters: { + type: 'object', + properties: parameters, + required: Object.entries(parameters) + .filter(([_, p]) => p.required) + .map(([name]) => name), + }, + response_transform: responseTransform, + version: '1.0.0', + } + + await toolService.createTool(toolData) + toast.success(t('messages.created')) + onSuccess() + } catch (error) { + toast.error( + error instanceof Error ? error.message : t('messages.createFailed') + ) + } finally { + setIsLoading(false) + } + } + + return ( + + + + {t('createModal.title')} + + {step === 1 && t('createModal.step1Desc')} + {step === 2 && t('createModal.step2Desc')} + {step === 3 && t('createModal.step3Desc')} + + + +
+ {/* 进度指示 */} +
+
= 1 ? 'bg-primary' : 'bg-muted'}`} + /> +
= 2 ? 'bg-primary' : 'bg-muted'}`} + /> +
= 3 ? 'bg-primary' : 'bg-muted'}`} + /> +
+ + {step === 1 && ( + <> + {/* 基本信息 */} +
+

+ {t('createModal.basicInfo')} +

+ +
+
+ + + setFormData({ + ...formData, + id: e.target.value.toLowerCase().replace(/\s+/g, '-'), + }) + } + /> +

+ {t('fields.idHint')} +

+
+ +
+ + + setFormData({ + ...formData, + name: e.target.value + .toLowerCase() + .replace(/\s+/g, '_'), + }) + } + /> +

+ {t('fields.nameHint')} +

+
+ +
+ + + setFormData({ + ...formData, + display_name: e.target.value, + }) + } + /> +
+ +
+ +