From 8533ca555d5961d90b13b4006ca2112e9148225c Mon Sep 17 00:00:00 2001 From: LeviLiu Date: Fri, 23 Jan 2026 09:46:19 +0800 Subject: [PATCH 1/3] feat: implement AI Agent tool calling system Backend (agent-service): - Add data-driven tool plugin system with PostgreSQL storage - Implement ReAct Agent integration using Cloudwego Eino - Support HTTP tools with various auth types (bearer/api_key/basic) - Add internal test tools (calculator, time) for testing - Create GenericToolExecutor for tool execution - Add tool management API endpoints Frontend: - Add tools management page with CRUD operations - Add CreateToolModal and EditToolModal for tool configuration - Update ChatView to display tool call events in chat - Add Navbar link for tools management - Add i18n translations for all supported languages Documentation: - Add TOOL_CALLING_SYSTEM.md design document Co-Authored-By: Claude Opus 4.5 --- apps/api-gateway/cmd/main.go | 7 + .../agents/components/AgentCard.tsx | 34 +- .../agents/components/ConfigureToolsModal.tsx | 295 ++++++ .../[locale]/(dashboard)/chat/ChatView.tsx | 46 +- .../tools/components/CreateToolModal.tsx | 759 +++++++++++++++ .../tools/components/EditToolModal.tsx | 590 ++++++++++++ .../(dashboard)/tools/components/ToolCard.tsx | 249 +++++ .../app/[locale]/(dashboard)/tools/page.tsx | 181 ++++ apps/web/src/components/atoms/chat-input.tsx | 24 +- .../web/src/components/atoms/chat-message.tsx | 143 ++- .../organisms/Chat/ChatContainer.tsx | 5 +- apps/web/src/components/organisms/Navbar.tsx | 3 +- apps/web/src/lang/de.json | 124 ++- apps/web/src/lang/en.json | 152 ++- apps/web/src/lang/ja.json | 124 ++- apps/web/src/lang/ko.json | 124 ++- apps/web/src/lang/ru.json | 124 ++- apps/web/src/lang/tw.json | 124 ++- apps/web/src/lang/zh.json | 117 ++- apps/web/src/lang/zh.lock.json | 98 +- apps/web/src/service/agent.ts | 24 + apps/web/src/service/index.ts | 2 + apps/web/src/service/tool.ts | 248 +++++ docs/AGENT_TOOL_PLUGIN_DESIGN.md | 900 ++++++++++++++++++ docs/TOOL_CALLING_SYSTEM.md | 304 ++++++ services/agent-service/cmd/main.go | 167 +++- .../agent-service/internal/handler/chat.go | 54 +- .../agent-service/internal/handler/tool.go | 214 +++++ .../internal/model/agent_tool.go | 41 + services/agent-service/internal/model/tool.go | 297 ++++++ .../internal/model/tool_execution.go | 48 + .../internal/pkg/tool/dynamic_wrapper.go | 104 ++ .../internal/pkg/tool/generic_executor.go | 417 ++++++++ .../internal/repository/agent_tool.go | 100 ++ .../agent-service/internal/repository/tool.go | 135 +++ .../internal/repository/tool_execution.go | 107 +++ .../agent-service/internal/service/chat.go | 190 +++- .../agent-service/internal/service/tool.go | 183 ++++ .../001_create_tools_tables.down.sql | 6 + .../migrations/001_create_tools_tables.up.sql | 145 +++ .../migrations/002_add_test_tool.up.sql | 79 ++ test-tools-api.sh | 233 +++++ test-tools-direct.sh | 182 ++++ 43 files changed, 7422 insertions(+), 81 deletions(-) create mode 100644 apps/web/src/app/[locale]/(dashboard)/agents/components/ConfigureToolsModal.tsx create mode 100644 apps/web/src/app/[locale]/(dashboard)/tools/components/CreateToolModal.tsx create mode 100644 apps/web/src/app/[locale]/(dashboard)/tools/components/EditToolModal.tsx create mode 100644 apps/web/src/app/[locale]/(dashboard)/tools/components/ToolCard.tsx create mode 100644 apps/web/src/app/[locale]/(dashboard)/tools/page.tsx create mode 100644 apps/web/src/service/tool.ts create mode 100644 docs/AGENT_TOOL_PLUGIN_DESIGN.md create mode 100644 docs/TOOL_CALLING_SYSTEM.md create mode 100644 services/agent-service/internal/handler/tool.go create mode 100644 services/agent-service/internal/model/agent_tool.go create mode 100644 services/agent-service/internal/model/tool.go create mode 100644 services/agent-service/internal/model/tool_execution.go create mode 100644 services/agent-service/internal/pkg/tool/dynamic_wrapper.go create mode 100644 services/agent-service/internal/pkg/tool/generic_executor.go create mode 100644 services/agent-service/internal/repository/agent_tool.go create mode 100644 services/agent-service/internal/repository/tool.go create mode 100644 services/agent-service/internal/repository/tool_execution.go create mode 100644 services/agent-service/internal/service/tool.go create mode 100644 services/agent-service/migrations/001_create_tools_tables.down.sql create mode 100644 services/agent-service/migrations/001_create_tools_tables.up.sql create mode 100644 services/agent-service/migrations/002_add_test_tool.up.sql create mode 100755 test-tools-api.sh create mode 100755 test-tools-direct.sh 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/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..767dc28 100644 --- a/apps/web/src/app/[locale]/(dashboard)/chat/ChatView.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/chat/ChatView.tsx @@ -1,10 +1,11 @@ 'use client' import { useState, useRef, useEffect, useMemo } from 'react' -import { agentService, type Agent } from '@/service/agent' +import { agentService, type Agent, type ToolCall } from '@/service/agent' import { useTranslations } from 'next-intl' import { ChatContainer, type Message } from '@/components/organisms' import type { SuggestionPrompt } from '@/components/atoms' +import { type StreamChunk } from '@/service/agent' export function ChatView() { const t = useTranslations('Chat') @@ -49,6 +50,43 @@ 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 currentToolCalls = m.toolCalls || [] + 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, toolCalls: updatedToolCalls } + }) + }) + } + const handleSend = async (messageContent?: string) => { const shouldUseMessageContent = messageContent && typeof messageContent === 'string' @@ -74,6 +112,7 @@ export function ChatView() { role: 'assistant', content: '', timestamp: new Date(), + toolCalls: [], } setMessages(prev => [...prev, assistantMessage]) @@ -82,6 +121,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() 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, + }) + } + /> +
+ +
+ +