From a398c600cb15dd2f754b0bb075a98fc842dfe1f8 Mon Sep 17 00:00:00 2001 From: lin onetwo Date: Fri, 12 Sep 2025 02:23:29 +0800 Subject: [PATCH] feat: tool message & wiki tool schema --- localization/locales/en/agent.json | 81 +++++++++++ localization/locales/zh_CN/agent.json | 94 +++++++++++++ .../components/APILogsDialog.tsx | 23 ++-- .../components/MessageBubble.tsx | 28 ++-- .../PromptPreviewDialog.promptConcat.test.tsx | 4 +- .../__tests__/MessageBubble.test.tsx | 88 ++++++++++++ src/pages/Main/useInitialPage.ts | 6 +- src/preload/common/services.ts | 5 +- .../basicPromptConcatHandler.test.ts | 6 +- .../__tests__/wikiOperationPlugin.test.ts | 36 ++++- .../__tests__/wikiSearchPlugin.test.ts | 34 ++++- .../plugins/wikiOperationPlugin.ts | 106 ++++++++------- .../agentInstance/plugins/wikiSearchPlugin.ts | 75 ++++++----- .../promptConcat/promptConcatSchema/index.ts | 5 +- .../promptConcatSchema/modelParameters.ts | 5 +- .../promptConcatSchema/prompts.ts | 5 +- .../promptConcatSchema/response.ts | 5 +- .../__tests__/schemaToToolContent.test.ts | 126 ++++++++++++++++++ .../utilities/schemaToToolContent.ts | 91 +++++++++++++ src/services/database/index.ts | 46 +++++++ src/services/database/interface.ts | 12 ++ src/services/externalAPI/callProviderAPI.ts | 11 +- src/services/libs/i18n/placeholder.ts | 4 + .../Preferences/sections/DeveloperTools.tsx | 5 + src/windows/Preferences/sections/Search.tsx | 2 - 25 files changed, 769 insertions(+), 134 deletions(-) create mode 100644 src/services/agentInstance/utilities/__tests__/schemaToToolContent.test.ts create mode 100644 src/services/agentInstance/utilities/schemaToToolContent.ts create mode 100644 src/services/libs/i18n/placeholder.ts diff --git a/localization/locales/en/agent.json b/localization/locales/en/agent.json index d5ab4a60..77893246 100644 --- a/localization/locales/en/agent.json +++ b/localization/locales/en/agent.json @@ -541,6 +541,56 @@ "WorkspaceName": "Workspace name", "WorkspaceNameTitle": "" }, + "WikiOperation": { + "Title": "Wiki Operation", + "Description": "Execute Tiddler operations (add, delete, or set text) in wiki workspaces", + "ToolListPosition": { + "TargetIdTitle": "Target ID", + "TargetId": "ID of the target element where the tool list will be inserted", + "PositionTitle": "Insert Position", + "Position": "Position relative to target element (before/after)" + }, + "ToolResultDurationTitle": "Tool Result Duration", + "ToolResultDuration": "Number of rounds tool execution results remain visible in conversation, after which they become grayed out", + "Tool": { + "Name": "wiki-operation", + "Caption": "Wiki Operation Tool", + "Description": "Execute operations (add, delete, or set Tiddler text) in wiki workspaces", + "ParametersTitle": "Parameters", + "Parameters": { + "workspaceName": { + "Title": "Workspace Name", + "Description": "Name or ID of the workspace to operate on" + }, + "operation": { + "Title": "Operation Type", + "Description": "Type of operation to execute" + }, + "title": { + "Title": "Tiddler Title", + "Description": "Title of the Tiddler" + }, + "text": { + "Title": "Tiddler Content", + "Description": "Text content of the Tiddler" + }, + "extraMeta": { + "Title": "Extra Metadata", + "Description": "JSON string of extra metadata such as tags and fields, defaults to \"{}\"" + }, + "options": { + "Title": "Operation Options", + "Description": "JSON string of operation options, defaults to \"{}\"" + } + }, + "ExamplesTitle": "Usage Examples", + "Examples": { + "add": "Add note: {\"workspaceName\": \"My Knowledge Base\", \"operation\": \"addTiddler\", \"title\": \"New Note\", \"text\": \"This is note content\", \"extraMeta\": \"{\\\"tags\\\":[\\\"tag1\\\",\\\"tag2\\\"]}\"}", + "set": "Set text: {\"workspaceName\": \"My Knowledge Base\", \"operation\": \"setTiddlerText\", \"title\": \"Existing Note\", \"text\": \"Updated content\"}", + "delete": "Delete note: {\"workspaceName\": \"My Knowledge Base\", \"operation\": \"deleteTiddler\", \"title\": \"Note to Delete\"}" + } + } + }, "WikiSearch": { "Description": "Search for content in TiddlyWiki workspaces using filter expressions", "Filter": "", @@ -584,5 +634,36 @@ "NewTab": "New Tab", "NewWeb": "Create a new webpage" } + }, + "Tool": { + "WikiOperation": { + "Success": { + "Added": "Successfully added tiddler \"{{title}}\" in wiki workspace \"{{workspaceName}}\"", + "Deleted": "Successfully deleted tiddler \"{{title}}\" from wiki workspace \"{{workspaceName}}\"", + "Updated": "Successfully set text for tiddler \"{{title}}\" in wiki workspace \"{{workspaceName}}\"" + }, + "Error": { + "WorkspaceNotFound": "Workspace with name or ID \"{{workspaceName}}\" does not exist. Available workspaces: {{availableWorkspaces}}", + "WorkspaceNotExist": "Workspace {{workspaceID}} does not exist" + } + }, + "WikiSearch": { + "Success": { + "NoResults": "No results found for filter \"{{filter}}\" in wiki workspace \"{{workspaceName}}\"", + "Completed": "Wiki search completed successfully. Found {{totalResults}} total results, showing {{shownResults}}:\n\n" + }, + "Error": { + "WorkspaceNotFound": "Workspace with name or ID \"{{workspaceName}}\" does not exist. Available workspaces: {{availableWorkspaces}}", + "WorkspaceNotExist": "Workspace {{workspaceID}} does not exist", + "ExecutionFailed": "Tool execution failed: {{error}}" + } + }, + "Schema": { + "Required": "Required", + "Optional": "Optional", + "Description": "Description", + "Parameters": "Parameters", + "Examples": "Usage Examples" + } } } diff --git a/localization/locales/zh_CN/agent.json b/localization/locales/zh_CN/agent.json index 2e674cfe..9f88d580 100644 --- a/localization/locales/zh_CN/agent.json +++ b/localization/locales/zh_CN/agent.json @@ -192,6 +192,7 @@ "Description": "此智能体的外部 API 调用调试日志。在偏好设置中启用 '外部 API 调试' 以开始记录。", "CurrentAgent": "显示智能体日志: {{agentId}}", "NoLogs": "未找到此智能体的 API 日志", + "NoResponse": "无响应", "StatusStart": "已开始", "StatusUpdate": "处理中", "StatusDone": "已完成", @@ -561,8 +562,70 @@ "ToolListPositionTitle": "工具列表位置", "ToolResultDuration": "工具执行结果在对话中保持可见的轮数,超过此轮数后结果将变灰显示", "ToolResultDurationTitle": "工具结果持续轮数", + "Tool": { + "Parameters": { + "workspaceName": { + "Title": "工作空间名称", + "Description": "要搜索的工作空间名称或ID" + }, + "filter": { + "Title": "过滤器", + "Description": "TiddlyWiki 过滤器表达式" + } + } + }, "WorkspaceName": "要搜索的 TiddlyWiki 工作区名称", "WorkspaceNameTitle": "工作区名称" + }, + "WikiOperation": { + "Title": "Wiki 操作", + "Description": "在 Wiki 工作空间中执行 Tiddler 操作(添加、删除或设置文本)", + "ToolListPosition": { + "TargetIdTitle": "目标ID", + "TargetId": "要插入工具列表的目标元素的ID", + "PositionTitle": "插入位置", + "Position": "相对于目标元素的插入位置(before/after)" + }, + "ToolResultDurationTitle": "工具结果持续轮数", + "ToolResultDuration": "工具执行结果在对话中保持可见的轮数,超过此轮数后结果将变灰显示", + "Tool": { + "Name": "wiki-operation", + "Caption": "Wiki 操作工具", + "Description": "在Wiki工作空间中执行操作(添加、删除或设置Tiddler文本)", + "ParametersTitle": "参数", + "Parameters": { + "workspaceName": { + "Title": "工作空间名称", + "Description": "要操作的工作空间名称或ID" + }, + "operation": { + "Title": "操作类型", + "Description": "要执行的操作类型" + }, + "title": { + "Title": "Tiddler 标题", + "Description": "Tiddler 的标题" + }, + "text": { + "Title": "Tiddler 内容", + "Description": "Tiddler 的文本内容" + }, + "extraMeta": { + "Title": "额外元数据", + "Description": "额外元数据的 JSON 字符串,如标签和字段,默认为 \"{}\"" + }, + "options": { + "Title": "操作选项", + "Description": "操作选项的 JSON 字符串,默认为 \"{}\"" + } + }, + "ExamplesTitle": "使用示例", + "Examples": { + "add": "添加笔记: {\"workspaceName\": \"我的知识库\", \"operation\": \"addTiddler\", \"title\": \"新笔记\", \"text\": \"这是笔记内容\", \"extraMeta\": \"{\\\"tags\\\":[\\\"标签1\\\",\\\"标签2\\\"]}\"}", + "set": "设置文本: {\"workspaceName\": \"我的知识库\", \"operation\": \"setTiddlerText\", \"title\": \"现有笔记\", \"text\": \"更新后的内容\"}", + "delete": "删除笔记: {\"workspaceName\": \"我的知识库\", \"operation\": \"deleteTiddler\", \"title\": \"要删除的笔记\"}" + } + } } }, "Search": { @@ -584,5 +647,36 @@ "NewTab": "新建标签页", "NewWeb": "新建网页" } + }, + "Tool": { + "WikiOperation": { + "Success": { + "Added": "成功在Wiki工作空间\"{{workspaceName}}\"中添加了Tiddler\"{{title}}\"", + "Deleted": "成功从Wiki工作空间\"{{workspaceName}}\"中删除了Tiddler\"{{title}}\"", + "Updated": "成功在Wiki工作空间\"{{workspaceName}}\"中设置了Tiddler\"{{title}}\"的文本" + }, + "Error": { + "WorkspaceNotFound": "工作空间名称或ID\"{{workspaceName}}\"不存在。可用工作空间:{{availableWorkspaces}}", + "WorkspaceNotExist": "工作空间{{workspaceID}}不存在" + } + }, + "WikiSearch": { + "Success": { + "NoResults": "在Wiki工作空间\"{{workspaceName}}\"中未找到过滤器\"{{filter}}\"的结果", + "Completed": "Wiki搜索完成。找到{{totalResults}}个总结果,显示{{shownResults}}个:\n\n" + }, + "Error": { + "WorkspaceNotFound": "工作空间名称或ID\"{{workspaceName}}\"不存在。可用工作空间:{{availableWorkspaces}}", + "WorkspaceNotExist": "工作空间{{workspaceID}}不存在", + "ExecutionFailed": "工具执行失败:{{error}}" + } + }, + "Schema": { + "Required": "必需", + "Optional": "可选", + "Description": "描述", + "Parameters": "参数", + "Examples": "使用示例" + } } } diff --git a/src/pages/ChatTabContent/components/APILogsDialog.tsx b/src/pages/ChatTabContent/components/APILogsDialog.tsx index 50287666..49196edd 100644 --- a/src/pages/ChatTabContent/components/APILogsDialog.tsx +++ b/src/pages/ChatTabContent/components/APILogsDialog.tsx @@ -25,7 +25,6 @@ const LogHeader = styled(Box)` const LogContent = styled(Box)` font-family: 'Monaco', 'Consolas', 'Courier New', monospace; font-size: 12px; - background-color: ${props => props.theme.palette.grey[100]}; padding: 12px; border-radius: 4px; overflow-x: auto; @@ -220,17 +219,17 @@ export const APILogsDialog: React.FC = ({ - {/* Response Content */} - {log.responseContent && ( - - - {t('APILogs.ResponseContent')} - - - {log.responseContent} - - - )} + {/* Response Content: always show block; display placeholder when missing */} + + + {t('APILogs.ResponseContent')} + + + {log.responseContent && String(log.responseContent).length > 0 + ? log.responseContent + : t('APILogs.NoResponse')} + + {/* Response Metadata */} {log.responseMetadata && ( diff --git a/src/pages/ChatTabContent/components/MessageBubble.tsx b/src/pages/ChatTabContent/components/MessageBubble.tsx index 5459f2b4..8ffc35b2 100644 --- a/src/pages/ChatTabContent/components/MessageBubble.tsx +++ b/src/pages/ChatTabContent/components/MessageBubble.tsx @@ -10,28 +10,28 @@ import { useAgentChatStore } from '../../Agent/store/agentChatStore/index'; import { MessageRenderer } from './MessageRenderer'; const BubbleContainer = styled(Box, { - shouldForwardProp: (property) => property !== '$user' && property !== '$expired', -})<{ $user: boolean; $expired?: boolean }>` + shouldForwardProp: (property) => property !== '$user' && property !== '$tool' && property !== '$expired', +})<{ $user: boolean; $tool: boolean; $expired?: boolean }>` display: flex; gap: 12px; max-width: 80%; - align-self: ${props => props.$user ? 'flex-end' : 'flex-start'}; + align-self: ${props => props.$tool ? 'center' : props.$user ? 'flex-end' : 'flex-start'}; opacity: ${props => props.$expired ? 0.5 : 1}; transition: opacity 0.3s ease-in-out; `; const MessageAvatar = styled(Avatar, { - shouldForwardProp: (property) => property !== '$user' && property !== '$expired', -})<{ $user: boolean; $expired?: boolean }>` - background-color: ${props => props.$user ? props.theme.palette.primary.main : props.theme.palette.secondary.main}; - color: ${props => props.$user ? props.theme.palette.primary.contrastText : props.theme.palette.secondary.contrastText}; + shouldForwardProp: (property) => property !== '$user' && property !== '$tool' && property !== '$expired', +})<{ $user: boolean; $tool: boolean; $expired?: boolean }>` + background-color: ${props => props.$tool ? props.theme.palette.info.main : props.$user ? props.theme.palette.primary.main : props.theme.palette.secondary.main}; + color: ${props => props.$tool ? props.theme.palette.info.contrastText : props.$user ? props.theme.palette.primary.contrastText : props.theme.palette.secondary.contrastText}; opacity: ${props => props.$expired ? 0.7 : 1}; transition: opacity 0.3s ease-in-out; `; const MessageContent = styled(Box, { - shouldForwardProp: (property) => property !== '$user' && property !== '$streaming' && property !== '$expired', -})<{ $user: boolean; $streaming?: boolean; $expired?: boolean }>` + shouldForwardProp: (property) => property !== '$user' && property !== '$tool' && property !== '$streaming' && property !== '$expired', +})<{ $user: boolean; $tool: boolean; $streaming?: boolean; $expired?: boolean }>` background-color: ${props => props.$user ? props.theme.palette.primary.light : props.theme.palette.background.paper}; color: ${props => props.$user ? props.theme.palette.primary.contrastText : props.theme.palette.text.primary}; padding: 12px 16px; @@ -94,6 +94,7 @@ export const MessageBubble: React.FC = ({ messageId }) => { if (!message) return null; const isUser = message.role === 'user'; + const isTool = message.role === 'tool'; // Calculate if message is expired for AI context const messageIndex = orderedMessageIds.indexOf(messageId); @@ -101,15 +102,16 @@ export const MessageBubble: React.FC = ({ messageId }) => { const isExpired = isMessageExpiredForAI(message, messageIndex, totalMessages); return ( - - {!isUser && ( - + + {!isUser && !isTool && ( + )} = ({ messageId }) => { {isUser && ( - + )} diff --git a/src/pages/ChatTabContent/components/PromptPreviewDialog/__tests__/PromptPreviewDialog.promptConcat.test.tsx b/src/pages/ChatTabContent/components/PromptPreviewDialog/__tests__/PromptPreviewDialog.promptConcat.test.tsx index 55761b31..cf79eb88 100644 --- a/src/pages/ChatTabContent/components/PromptPreviewDialog/__tests__/PromptPreviewDialog.promptConcat.test.tsx +++ b/src/pages/ChatTabContent/components/PromptPreviewDialog/__tests__/PromptPreviewDialog.promptConcat.test.tsx @@ -287,8 +287,8 @@ describe('PromptPreviewDialog - Tool Information Rendering', () => { } expect(wikiSearchElement).toBeDefined(); const wikiSearchText = `${wikiSearchElement?.caption ?? ''} ${wikiSearchElement?.text ?? ''}`; - expect(wikiSearchText).toContain('Available Tools:'); - expect(wikiSearchText).toContain('Tool ID: wiki-search'); + expect(wikiSearchText).toContain('Wiki search tool'); + expect(wikiSearchText).toContain('## wiki-search'); // Verify the order: before-tool -> workspaces -> wiki-operation -> wiki-search -> post-tool const postToolElement: IPrompt | undefined = toolsSection?.children?.find((c: IPrompt) => c.id === 'default-post-tool'); diff --git a/src/pages/ChatTabContent/components/__tests__/MessageBubble.test.tsx b/src/pages/ChatTabContent/components/__tests__/MessageBubble.test.tsx index 5309a92c..55eac7ff 100644 --- a/src/pages/ChatTabContent/components/__tests__/MessageBubble.test.tsx +++ b/src/pages/ChatTabContent/components/__tests__/MessageBubble.test.tsx @@ -333,4 +333,92 @@ describe('MessageBubble - Duration-based Graying', () => { screen.getByText('Message 3 - duration 1').parentElement?.parentElement; expect(bubbleContainer).toHaveStyle({ opacity: '0.5' }); // Should be grayed out }); + + it('should not display avatar for tool role messages', () => { + const toolMessage: AgentInstanceMessage = { + id: 'tool-msg', + role: 'tool', + content: 'Tool: wiki-search\nResult: Found some content', + agentId: 'test-agent', + contentType: 'text/plain', + modified: new Date(), + duration: undefined, + metadata: { + isToolResult: true, + toolId: 'wiki-search', + }, + }; + + // Setup store state + mockMessages.set('tool-msg', toolMessage); + mockOrderedMessageIds.push('tool-msg'); + + // Render the tool message + render( + + + , + ); + + // Check that the message content is rendered + expect(screen.getByText(/functions_result/)).toBeInTheDocument(); + + // Check that no avatar is displayed for tool messages + const avatars = screen.queryAllByRole('img'); // Avatars are typically rendered as img elements + expect(avatars.length).toBe(0); // Should have no avatars for tool messages + }); + + it('should use same background color for tool and assistant messages', () => { + const assistantMessage: AgentInstanceMessage = { + id: 'assistant-msg', + role: 'assistant', + content: 'This is an assistant response', + agentId: 'test-agent', + contentType: 'text/plain', + modified: new Date(), + duration: undefined, + }; + + const toolMessage: AgentInstanceMessage = { + id: 'tool-msg', + role: 'tool', + content: 'Tool result', + agentId: 'test-agent', + contentType: 'text/plain', + modified: new Date(), + duration: undefined, + metadata: { + isToolResult: true, + toolId: 'test-tool', + }, + }; + + // Setup store state + mockMessages.set('assistant-msg', assistantMessage); + mockMessages.set('tool-msg', toolMessage); + mockOrderedMessageIds.push('assistant-msg', 'tool-msg'); + + // Render assistant message + const { rerender } = render( + + + , + ); + + const assistantContent = screen.getByText('This is an assistant response'); + const assistantBackgroundColor = window.getComputedStyle(assistantContent.parentElement!).backgroundColor; + + // Render tool message + rerender( + + + , + ); + + const toolContent = screen.getByText(/Tool result/); + const toolBackgroundColor = window.getComputedStyle(toolContent.parentElement!).backgroundColor; + + // Both should have the same background color + expect(assistantBackgroundColor).toBe(toolBackgroundColor); + }); }); diff --git a/src/pages/Main/useInitialPage.ts b/src/pages/Main/useInitialPage.ts index c95147f6..4ce786b6 100644 --- a/src/pages/Main/useInitialPage.ts +++ b/src/pages/Main/useInitialPage.ts @@ -13,11 +13,13 @@ export function useInitialPage() { hasInitialized.current = true; const activeWorkspace = workspacesList.find(workspace => workspace.active); if (!activeWorkspace) { - setLocation(`/${PageType.guide}`); + // If there's no active workspace, navigate to root instead of guide. + // Root lets the UI stay neutral and prevents forcing the guide view. + setLocation(`/`); } else if (activeWorkspace.pageType) { // Don't navigate to add page, fallback to guide instead if (activeWorkspace.pageType === PageType.add) { - setLocation(`/${PageType.guide}`); + setLocation(`/`); } else { setLocation(`/${activeWorkspace.pageType}`); } diff --git a/src/preload/common/services.ts b/src/preload/common/services.ts index 2012f4d7..0eefd532 100644 --- a/src/preload/common/services.ts +++ b/src/preload/common/services.ts @@ -11,7 +11,9 @@ import { AgentDefinitionServiceIPCDescriptor, IAgentDefinitionService } from '@s import { AgentInstanceServiceIPCDescriptor, IAgentInstanceService } from '@services/agentInstance/interface'; import { AuthenticationServiceIPCDescriptor, IAuthenticationService } from '@services/auth/interface'; import { ContextServiceIPCDescriptor, IContextService } from '@services/context/interface'; +import { DatabaseServiceIPCDescriptor, IDatabaseService } from '@services/database/interface'; import { DeepLinkServiceIPCDescriptor, IDeepLinkService } from '@services/deepLink/interface'; +import { ExternalAPIServiceIPCDescriptor, IExternalAPIService } from '@services/externalAPI/interface'; import { GitServiceIPCDescriptor, IGitService } from '@services/git/interface'; import { IMenuService, MenuServiceIPCDescriptor } from '@services/menu/interface'; import { INativeService, NativeServiceIPCDescriptor } from '@services/native/interface'; @@ -28,7 +30,6 @@ import { IWikiGitWorkspaceService, WikiGitWorkspaceServiceIPCDescriptor } from ' import { IWindowService, WindowServiceIPCDescriptor } from '@services/windows/interface'; import { IWorkspaceService, WorkspaceServiceIPCDescriptor } from '@services/workspaces/interface'; import { IWorkspaceViewService, WorkspaceViewServiceIPCDescriptor } from '@services/workspacesView/interface'; -import { ExternalAPIServiceIPCDescriptor, IExternalAPIService } from '../../services/externalAPI/interface'; export const agentBrowser = createProxy>(AgentBrowserServiceIPCDescriptor); export const agentDefinition = createProxy>(AgentDefinitionServiceIPCDescriptor); @@ -37,6 +38,7 @@ export const auth = createProxy(AuthenticationServiceIPC export const context = createProxy(ContextServiceIPCDescriptor); export const deepLink = createProxy(DeepLinkServiceIPCDescriptor); export const externalAPI = createProxy(ExternalAPIServiceIPCDescriptor); +export const database = createProxy(DatabaseServiceIPCDescriptor); export const git = createProxy(GitServiceIPCDescriptor); export const menu = createProxy(MenuServiceIPCDescriptor); export const native = createProxy(NativeServiceIPCDescriptor); @@ -78,4 +80,5 @@ export const descriptors = { workspace: WorkspaceServiceIPCDescriptor, workspaceView: WorkspaceViewServiceIPCDescriptor, externalAPI: ExternalAPIServiceIPCDescriptor, + database: DatabaseServiceIPCDescriptor, }; diff --git a/src/services/agentInstance/buildInAgentHandlers/__tests__/basicPromptConcatHandler.test.ts b/src/services/agentInstance/buildInAgentHandlers/__tests__/basicPromptConcatHandler.test.ts index 5d8ec4d2..267f1637 100644 --- a/src/services/agentInstance/buildInAgentHandlers/__tests__/basicPromptConcatHandler.test.ts +++ b/src/services/agentInstance/buildInAgentHandlers/__tests__/basicPromptConcatHandler.test.ts @@ -210,7 +210,7 @@ describe('WikiSearch Plugin Integration & YieldNextRound Mechanism', () => { // Verify tool result message was added to agent history expect(responseContext.handlerContext.agent.messages.length).toBeGreaterThan(0); const toolResultMessage = responseContext.handlerContext.agent.messages[responseContext.handlerContext.agent.messages.length - 1] as AgentInstanceMessage; - expect(toolResultMessage.role).toBe('assistant'); // Changed from 'user' to 'assistant' + expect(toolResultMessage.role).toBe('tool'); // Tool result message expect(toolResultMessage.content).toContain(''); expect(toolResultMessage.content).toContain('Tool: wiki-search'); expect(toolResultMessage.content).toContain('Important Note 1'); @@ -272,7 +272,7 @@ describe('WikiSearch Plugin Integration & YieldNextRound Mechanism', () => { // Verify error message was added to agent history expect(responseContext.handlerContext.agent.messages.length).toBeGreaterThan(0); const errorResultMessage = responseContext.handlerContext.agent.messages[responseContext.handlerContext.agent.messages.length - 1] as AgentInstanceMessage; - expect(errorResultMessage.role).toBe('assistant'); // Changed from 'user' to 'assistant' + expect(errorResultMessage.role).toBe('tool'); // Tool error message expect(errorResultMessage.content).toContain('Error:'); expect(errorResultMessage.content).toContain('does not exist'); }); @@ -352,7 +352,7 @@ describe('WikiSearch Plugin Integration & YieldNextRound Mechanism', () => { // Verify that tool result message was added const toolResultMessage = context.agent.messages.find(m => m.metadata?.isToolResult); expect(toolResultMessage).toBeTruthy(); - expect(toolResultMessage?.role).toBe('assistant'); + expect(toolResultMessage?.role).toBe('tool'); expect(toolResultMessage?.content).toContain(''); // Verify that there are multiple responses (initial tool call + final explanation) diff --git a/src/services/agentInstance/plugins/__tests__/wikiOperationPlugin.test.ts b/src/services/agentInstance/plugins/__tests__/wikiOperationPlugin.test.ts index f7ca2c52..aede2e2b 100644 --- a/src/services/agentInstance/plugins/__tests__/wikiOperationPlugin.test.ts +++ b/src/services/agentInstance/plugins/__tests__/wikiOperationPlugin.test.ts @@ -22,6 +22,34 @@ import type { AIResponseContext, PluginActions, PromptConcatHookContext } from ' import { wikiOperationPlugin } from '../wikiOperationPlugin'; import { workspacesListPlugin } from '../workspacesListPlugin'; +// Mock i18n +vi.mock('@services/libs/i18n', () => ({ + i18n: { + t: vi.fn((key: string, options?: Record) => { + const translations: Record = { + 'Tool.WikiOperation.Success.Added': '成功在Wiki工作空间"{{workspaceName}}"中添加了Tiddler"{{title}}"', + 'Tool.WikiOperation.Success.Deleted': '成功从Wiki工作空间"{{workspaceName}}"中删除了Tiddler"{{title}}"', + 'Tool.WikiOperation.Success.Updated': '成功在Wiki工作空间"{{workspaceName}}"中设置了Tiddler"{{title}}"的文本', + 'Tool.WikiOperation.Error.WorkspaceNotFound': '工作空间名称或ID"{{workspaceName}}"不存在。可用工作空间:{{availableWorkspaces}}', + 'Tool.WikiOperation.Error.WorkspaceNotExist': '工作空间{{workspaceID}}不存在', + }; + + let translation = translations[key] || key; + + // Handle interpolation + if (options && typeof options === 'object') { + Object.keys(options).forEach(optionKey => { + if (optionKey !== 'ns' && optionKey !== 'defaultValue') { + translation = translation.replace(new RegExp(`{{${optionKey}}}`, 'g'), String(options[optionKey])); + } + }); + } + + return translation; + }), + }, +})); + // Helper to construct a complete AgentHandlerContext for tests const makeHandlerContext = (agentId = 'test-agent'): AgentHandlerContext => ({ agent: { @@ -203,7 +231,7 @@ describe('wikiOperationPlugin', () => { expect(toolResultMessage).toBeTruthy(); expect(toolResultMessage?.content).toContain(''); // Check for general success wording and tiddler title - expect(toolResultMessage?.content).toContain('Successfully'); + expect(toolResultMessage?.content).toContain('成功在Wiki工作空间'); expect(toolResultMessage?.content).toContain('Test Note'); expect(toolResultMessage?.metadata?.isToolResult).toBe(true); expect(toolResultMessage?.metadata?.toolId).toBe('wiki-operation'); @@ -265,7 +293,7 @@ describe('wikiOperationPlugin', () => { // Check general update success wording and tiddler title const updateResult = handlerContext.agent.messages.find(m => m.metadata?.isToolResult); expect(updateResult).toBeTruthy(); - expect(updateResult?.content).toContain('Successfully'); + expect(updateResult?.content).toContain('成功在Wiki工作空间'); expect(updateResult?.content).toContain('Existing Note'); }); @@ -323,7 +351,7 @@ describe('wikiOperationPlugin', () => { const deleteResult = handlerContext.agent.messages.find(m => m.metadata?.isToolResult); expect(deleteResult).toBeTruthy(); - expect(deleteResult?.content).toContain('Successfully deleted tiddler "Note to Delete"'); + expect(deleteResult?.content).toContain('成功从Wiki工作空间'); }); it('should handle workspace not found error', async () => { @@ -374,7 +402,7 @@ describe('wikiOperationPlugin', () => { const errResult = handlerContext.agent.messages.find(m => m.metadata?.isToolResult); expect(errResult).toBeTruthy(); - expect(errResult?.content).toContain('Workspace with name or ID "Non-existent Wiki" does not exist'); + expect(errResult?.content).toContain('工作空间名称或ID'); // Ensure control is yielded to self on error so AI gets the next round expect(respCtx4.actions?.yieldNextRoundTo).toBe('self'); }); diff --git a/src/services/agentInstance/plugins/__tests__/wikiSearchPlugin.test.ts b/src/services/agentInstance/plugins/__tests__/wikiSearchPlugin.test.ts index 17135cba..e846ea94 100644 --- a/src/services/agentInstance/plugins/__tests__/wikiSearchPlugin.test.ts +++ b/src/services/agentInstance/plugins/__tests__/wikiSearchPlugin.test.ts @@ -23,6 +23,34 @@ import { createHandlerHooks, PromptConcatHookContext } from '../index'; import { messageManagementPlugin } from '../messageManagementPlugin'; import { wikiSearchPlugin } from '../wikiSearchPlugin'; +// Mock i18n +vi.mock('@services/libs/i18n', () => ({ + i18n: { + t: vi.fn((key: string, options?: Record) => { + const translations: Record = { + 'Tool.WikiSearch.Success.NoResults': '在Wiki工作空间"{{workspaceName}}"中未找到过滤器"{{filter}}"的结果', + 'Tool.WikiSearch.Success.Completed': 'Wiki搜索完成。找到{{totalResults}}个总结果,显示{{shownResults}}个:\n\n', + 'Tool.WikiSearch.Error.WorkspaceNotFound': '工作空间名称或ID"{{workspaceName}}"不存在。可用工作空间:{{availableWorkspaces}}', + 'Tool.WikiSearch.Error.WorkspaceNotExist': '工作空间{{workspaceID}}不存在', + 'Tool.WikiSearch.Error.ExecutionFailed': '工具执行失败:{{error}}', + }; + + let translation = translations[key] || key; + + // Handle interpolation + if (options && typeof options === 'object') { + Object.keys(options).forEach(optionKey => { + if (optionKey !== 'ns' && optionKey !== 'defaultValue') { + translation = translation.replace(new RegExp(`{{${optionKey}}}`, 'g'), String(options[optionKey])); + } + }); + } + + return translation; + }), + }, +})); + // Use the real agent config const exampleAgent = defaultAgents[0]; const handlerConfig = exampleAgent.handlerConfig as AgentPromptDescription['handlerConfig']; @@ -288,7 +316,7 @@ describe('Wiki Search Plugin - Comprehensive Tests', () => { // Verify tool result message was added to agent history with correct settings expect(handlerContext.agent.messages.length).toBe(3); // user + ai + tool_result const toolResultMessage = handlerContext.agent.messages[2] as AgentInstanceMessage; - expect(toolResultMessage.role).toBe('assistant'); // Changed from 'user' to 'assistant' + expect(toolResultMessage.role).toBe('tool'); // Tool result message expect(toolResultMessage.content).toContain(''); expect(toolResultMessage.content).toContain('Tool: wiki-search'); expect(toolResultMessage.content).toContain('Important Note 1'); @@ -372,10 +400,10 @@ describe('Wiki Search Plugin - Comprehensive Tests', () => { // Verify error message was added to agent history expect(handlerContext.agent.messages.length).toBe(2); // tool_call + error_result const errorResultMessage = handlerContext.agent.messages[1] as AgentInstanceMessage; - expect(errorResultMessage.role).toBe('assistant'); // Changed from 'user' to 'assistant' + expect(errorResultMessage.role).toBe('tool'); // Tool error message expect(errorResultMessage.content).toContain(''); expect(errorResultMessage.content).toContain('Error:'); - expect(errorResultMessage.content).toContain('does not exist'); + expect(errorResultMessage.content).toContain('工作空间名称或ID'); expect(errorResultMessage.metadata?.isToolResult).toBe(true); expect(errorResultMessage.metadata?.isError).toBe(true); expect(errorResultMessage.metadata?.isPersisted).toBe(false); // Should be false initially diff --git a/src/services/agentInstance/plugins/wikiOperationPlugin.ts b/src/services/agentInstance/plugins/wikiOperationPlugin.ts index 930d3f36..aa46e41a 100644 --- a/src/services/agentInstance/plugins/wikiOperationPlugin.ts +++ b/src/services/agentInstance/plugins/wikiOperationPlugin.ts @@ -3,10 +3,20 @@ * Handles wiki operation tool list injection, tool calling detection and response processing * Supports creating, updating, and deleting tiddlers in wiki workspaces */ -import { identity } from 'lodash'; +import { WikiChannel } from '@/constants/channels'; +import { matchToolCalling } from '@services/agentDefinition/responsePatternUtility'; +import { container } from '@services/container'; +import { i18n } from '@services/libs/i18n'; +import { t } from '@services/libs/i18n/placeholder'; +import { logger } from '@services/libs/log'; +import serviceIdentifier from '@services/serviceIdentifier'; +import type { IWikiService } from '@services/wiki/interface'; +import { IWorkspaceService } from '@services/workspaces/interface'; import { z } from 'zod/v4'; - -const t = identity; +import type { AgentInstanceMessage } from '../interface'; +import { findPromptById } from '../promptConcat/promptConcat'; +import { schemaToToolContent } from '../utilities/schemaToToolContent'; +import type { PromptConcatPlugin } from './types'; /** * Wiki Operation Parameter Schema @@ -48,29 +58,44 @@ export function getWikiOperationParameterSchema() { return WikiOperationParameterSchema; } -import { WikiChannel } from '@/constants/channels'; -import { matchToolCalling } from '@services/agentDefinition/responsePatternUtility'; -import { container } from '@services/container'; -import { logger } from '@services/libs/log'; -import serviceIdentifier from '@services/serviceIdentifier'; -import type { IWikiService } from '@services/wiki/interface'; -import { IWorkspaceService } from '@services/workspaces/interface'; - -import type { AgentInstanceMessage } from '../interface'; -import { findPromptById } from '../promptConcat/promptConcat'; -import type { PromptConcatPlugin } from './types'; - /** * Parameter schema for Wiki operation tool */ const WikiOperationToolParameterSchema = z.object({ - workspaceName: z.string().describe('Name or ID of the workspace to operate on'), - operation: z.enum([WikiChannel.addTiddler, WikiChannel.deleteTiddler, WikiChannel.setTiddlerText]).describe('Type of wiki operation to perform'), - title: z.string().describe('Title of the tiddler'), - text: z.string().optional().describe('Content/text of the tiddler (for addTiddler/setTiddlerText operations)'), - extraMeta: z.string().optional().default('{}').describe('JSON string of additional metadata (tags, fields, etc.)'), - options: z.string().optional().default('{}').describe('JSON string of operation options'), -}); + workspaceName: z.string().meta({ + title: t('Schema.WikiOperation.Tool.Parameters.workspaceName.Title'), + description: t('Schema.WikiOperation.Tool.Parameters.workspaceName.Description'), + }), + operation: z.enum([WikiChannel.addTiddler, WikiChannel.deleteTiddler, WikiChannel.setTiddlerText]).meta({ + title: t('Schema.WikiOperation.Tool.Parameters.operation.Title'), + description: t('Schema.WikiOperation.Tool.Parameters.operation.Description'), + }), + title: z.string().meta({ + title: t('Schema.WikiOperation.Tool.Parameters.title.Title'), + description: t('Schema.WikiOperation.Tool.Parameters.title.Description'), + }), + text: z.string().optional().meta({ + title: t('Schema.WikiOperation.Tool.Parameters.text.Title'), + description: t('Schema.WikiOperation.Tool.Parameters.text.Description'), + }), + extraMeta: z.string().optional().default('{}').meta({ + title: t('Schema.WikiOperation.Tool.Parameters.extraMeta.Title'), + description: t('Schema.WikiOperation.Tool.Parameters.extraMeta.Description'), + }), + options: z.string().optional().default('{}').meta({ + title: t('Schema.WikiOperation.Tool.Parameters.options.Title'), + description: t('Schema.WikiOperation.Tool.Parameters.options.Description'), + }), +}) + .meta({ + title: 'wiki-operation', + description: '在Wiki工作空间中执行操作(添加、删除或设置Tiddler文本)', + examples: [ + { workspaceName: '我的知识库', operation: WikiChannel.addTiddler, title: '示例笔记', text: '示例内容', extraMeta: '{}', options: '{}' }, + { workspaceName: '我的知识库', operation: WikiChannel.setTiddlerText, title: '现有笔记', text: '更新后的内容', extraMeta: '{}', options: '{}' }, + { workspaceName: '我的知识库', operation: WikiChannel.deleteTiddler, title: '要删除的笔记', extraMeta: '{}', options: '{}' }, + ], + }); /** * Wiki Operation plugin - Prompt processing @@ -105,22 +130,8 @@ export const wikiOperationPlugin: PromptConcatPlugin = (hooks) => { // Get available wikis - now handled by workspacesListPlugin // The workspaces list will be injected separately by workspacesListPlugin - const wikiOperationToolContent = ` -## wiki-operation -**描述**: 在Wiki工作空间中执行操作(添加、删除或设置Tiddler文本) -**参数**: -- workspaceName (string, 必需): 要操作的工作空间名称或ID -- operation (string, 必需): 要执行的操作类型,可选值: "${WikiChannel.addTiddler}", "${WikiChannel.deleteTiddler}", "${WikiChannel.setTiddlerText}" -- title (string, 必需): Tiddler的标题 -- text (string, 可选): Tiddler的内容/文本(用于 addTiddler / setTiddlerText 操作) -- extraMeta (string, 可选): 额外元数据的JSON字符串,如标签和字段,默认为"{}" -- options (string, 可选): 操作选项的JSON字符串,默认为"{}" - -**使用示例**: -- 添加笔记: {"workspaceName": "我的知识库", "operation": "${WikiChannel.addTiddler}", "title": "新笔记", "text": "这是笔记内容", "extraMeta": "{\\"tags\\":[\\"标签1\\",\\"标签2\\"]}"} -- 设置文本: {"workspaceName": "我的知识库", "operation": "${WikiChannel.setTiddlerText}", "title": "现有笔记", "text": "更新后的内容"} -- 删除笔记: {"workspaceName": "我的知识库", "operation": "${WikiChannel.deleteTiddler}", "title": "要删除的笔记"} -`; + // Build tool content using shared utility (schema contains title/examples meta) + const wikiOperationToolContent = schemaToToolContent(WikiOperationToolParameterSchema); // Insert the tool content based on position if (toolListPosition.position === 'after') { @@ -224,12 +235,17 @@ export const wikiOperationPlugin: PromptConcatPlugin = (hooks) => { const workspaces = await workspaceService.getWorkspacesAsList(); const targetWorkspace = workspaces.find(ws => ws.name === workspaceName || ws.id === workspaceName); if (!targetWorkspace) { - throw new Error(`Workspace with name or ID "${workspaceName}" does not exist. Available workspaces: ${workspaces.map(w => `${w.name} (${w.id})`).join(', ')}`); + throw new Error( + i18n.t('Tool.WikiOperation.Error.WorkspaceNotFound', { + workspaceName, + availableWorkspaces: workspaces.map(w => `${w.name} (${w.id})`).join(', '), + }) || `Workspace with name or ID "${workspaceName}" does not exist. Available workspaces: ${workspaces.map(w => `${w.name} (${w.id})`).join(', ')}`, + ); } const workspaceID = targetWorkspace.id; if (!await workspaceService.exists(workspaceID)) { - throw new Error(`Workspace ${workspaceID} does not exist`); + throw new Error(i18n.t('Tool.WikiOperation.Error.WorkspaceNotExist', { workspaceID })); } logger.debug('Executing wiki operation', { @@ -251,19 +267,19 @@ export const wikiOperationPlugin: PromptConcatPlugin = (hooks) => { extraMeta || '{}', JSON.stringify({ withDate: true, ...options }), ]); - result = `Successfully added tiddler "${title}" in wiki workspace "${workspaceName}".`; + result = i18n.t('Tool.WikiOperation.Success.Added', { title, workspaceName }); break; } case WikiChannel.deleteTiddler: { await wikiService.wikiOperationInServer(WikiChannel.deleteTiddler, workspaceID, [title]); - result = `Successfully deleted tiddler "${title}" from wiki workspace "${workspaceName}".`; + result = i18n.t('Tool.WikiOperation.Success.Deleted', { title, workspaceName }); break; } case WikiChannel.setTiddlerText: { await wikiService.wikiOperationInServer(WikiChannel.setTiddlerText, workspaceID, [title, text || '']); - result = `Successfully set text for tiddler "${title}" in wiki workspace "${workspaceName}".`; + result = i18n.t('Tool.WikiOperation.Success.Updated', { title, workspaceName }); break; } @@ -302,7 +318,7 @@ export const wikiOperationPlugin: PromptConcatPlugin = (hooks) => { const toolResultMessage: AgentInstanceMessage = { id: `tool-result-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, agentId: handlerContext.agent.id, - role: 'assistant', // Changed from 'user' to 'assistant' to avoid user confusion + role: 'tool', // Tool result message content: toolResultText, modified: toolResultTime, duration: toolResultDuration, // Use configurable duration - default 1 round for tool results @@ -363,7 +379,7 @@ Error: ${error instanceof Error ? error.message : String(error)} const errorResultMessage: AgentInstanceMessage = { id: `tool-error-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, agentId: handlerContext.agent.id, - role: 'assistant', // Changed from 'user' to 'assistant' to avoid user confusion + role: 'tool', // Tool error message content: errorMessage, modified: errorResultTime, duration: 2, // Error messages are visible to AI for 2 rounds: immediate + next round to allow explanation diff --git a/src/services/agentInstance/plugins/wikiSearchPlugin.ts b/src/services/agentInstance/plugins/wikiSearchPlugin.ts index dae1abd6..03c2bc0a 100644 --- a/src/services/agentInstance/plugins/wikiSearchPlugin.ts +++ b/src/services/agentInstance/plugins/wikiSearchPlugin.ts @@ -2,10 +2,22 @@ * Wiki Search plugin * Handles wiki search tool list injection, tool calling detection and response processing */ -import { identity } from 'lodash'; +import { WikiChannel } from '@/constants/channels'; +import { matchToolCalling } from '@services/agentDefinition/responsePatternUtility'; +import { container } from '@services/container'; +import { i18n } from '@services/libs/i18n'; +import { t } from '@services/libs/i18n/placeholder'; +import { logger } from '@services/libs/log'; +import serviceIdentifier from '@services/serviceIdentifier'; +import type { IWikiService } from '@services/wiki/interface'; +import { IWorkspaceService } from '@services/workspaces/interface'; +import type { ITiddlerFields } from 'tiddlywiki'; import { z } from 'zod/v4'; - -const t = identity; +import type { AgentInstanceMessage, IAgentInstanceService } from '../interface'; +import { findPromptById } from '../promptConcat/promptConcat'; +import type { IPrompt } from '../promptConcat/promptConcatSchema'; +import { schemaToToolContent } from '../utilities/schemaToToolContent'; +import type { AIResponseContext, PromptConcatPlugin } from './types'; /** * Wiki Search Parameter Schema @@ -63,26 +75,24 @@ export function getWikiSearchParameterSchema() { return WikiSearchParameterSchema; } -import { WikiChannel } from '@/constants/channels'; -import { matchToolCalling } from '@services/agentDefinition/responsePatternUtility'; -import { container } from '@services/container'; -import { logger } from '@services/libs/log'; -import serviceIdentifier from '@services/serviceIdentifier'; -import type { IWikiService } from '@services/wiki/interface'; -import { IWorkspaceService } from '@services/workspaces/interface'; -import type { ITiddlerFields } from 'tiddlywiki'; - -import type { AgentInstanceMessage, IAgentInstanceService } from '../interface'; -import { findPromptById } from '../promptConcat/promptConcat'; -import type { IPrompt } from '../promptConcat/promptConcatSchema'; -import type { AIResponseContext, PromptConcatPlugin } from './types'; - /** * Parameter schema for Wiki search tool */ const WikiSearchToolParameterSchema = z.object({ - workspaceName: z.string().describe('Name or ID of the workspace to search'), - filter: z.string().describe('TiddlyWiki filter expression'), + workspaceName: z.string().meta({ + title: t('Schema.WikiSearch.Tool.Parameters.workspaceName.Title'), + description: t('Schema.WikiSearch.Tool.Parameters.workspaceName.Description'), + }), + filter: z.string().meta({ + title: t('Schema.WikiSearch.Tool.Parameters.filter.Title'), + description: t('Schema.WikiSearch.Tool.Parameters.filter.Description'), + }), +}).meta({ + title: 'wiki-search', + description: '在Wiki工作空间中搜索Tiddler内容', + examples: [ + { workspaceName: '我的知识库', filter: '[tag[示例]]' }, + ], }); type WikiSearchToolParameter = z.infer; @@ -108,7 +118,10 @@ async function executeWikiSearchTool( if (!targetWorkspace) { return { success: false, - error: `Workspace with name or ID "${workspaceName}" does not exist. Available workspaces: ${workspaces.map(w => `${w.name} (${w.id})`).join(', ')}`, + error: i18n.t('Tool.WikiSearch.Error.WorkspaceNotFound', { + workspaceName, + availableWorkspaces: workspaces.map(w => `${w.name} (${w.id})`).join(', '), + }) || `Workspace with name or ID "${workspaceName}" does not exist. Available workspaces: ${workspaces.map(w => `${w.name} (${w.id})`).join(', ')}`, }; } @@ -117,7 +130,7 @@ async function executeWikiSearchTool( if (!await workspaceService.exists(workspaceID)) { return { success: false, - error: `Workspace ${workspaceID} does not exist`, + error: i18n.t('Tool.WikiSearch.Error.WorkspaceNotExist', { workspaceID }), }; } @@ -134,7 +147,7 @@ async function executeWikiSearchTool( if (tiddlerTitles.length === 0) { return { success: true, - data: `No results found for filter "${filter}" in wiki workspace "${workspaceName}".`, + data: i18n.t('Tool.WikiSearch.Success.NoResults', { filter, workspaceName }), metadata: { filter, workspaceID, @@ -144,7 +157,6 @@ async function executeWikiSearchTool( }; } - // Retrieve full tiddler content if requested // Retrieve full tiddler content for each tiddler const results: Array<{ title: string; text?: string; fields?: ITiddlerFields }> = []; for (const title of tiddlerTitles) { @@ -168,7 +180,10 @@ async function executeWikiSearchTool( } // Format results as text with content - let content = `Wiki search completed successfully. Found ${tiddlerTitles.length} total results, showing ${results.length}:\n\n`; + let content = i18n.t('Tool.WikiSearch.Success.Completed', { + totalResults: tiddlerTitles.length, + shownResults: results.length, + }) + '\n\n'; for (const result of results) { content += `**Tiddler: ${result.title}**\n\n`; @@ -180,7 +195,6 @@ async function executeWikiSearchTool( content += '(Content not available)\n\n'; } } - return { success: true, data: content, @@ -200,7 +214,9 @@ async function executeWikiSearchTool( return { success: false, - error: `Tool execution failed: ${error instanceof Error ? error.message : String(error)}`, + error: i18n.t('Tool.WikiSearch.Error.ExecutionFailed', { + error: error instanceof Error ? error.message : String(error), + }) || `Tool execution failed: ${error instanceof Error ? error.message : String(error)}`, }; } } @@ -238,8 +254,7 @@ export const wikiSearchPlugin: PromptConcatPlugin = (hooks) => { // Get available wikis - now handled by workspacesListPlugin // The workspaces list will be injected separately by workspacesListPlugin - const toolPromptContent = - `Available Tools:\n- Tool ID: wiki-search\n- Tool Name: Wiki Search\n- Description: Search content in wiki workspaces\n- Parameters: {\n "workspaceName": "string (required) - The name or ID of the wiki workspace to search in",\n "filter": "string (required) - TiddlyWiki filter expression for searching, like [title[Index]]""\n}`; + const toolPromptContent = schemaToToolContent(WikiSearchToolParameterSchema); const toolPrompt: IPrompt = { id: `wiki-tool-list-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, @@ -392,7 +407,7 @@ export const wikiSearchPlugin: PromptConcatPlugin = (hooks) => { const toolResultMessage: AgentInstanceMessage = { id: `tool-result-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, agentId: handlerContext.agent.id, - role: 'assistant', // Changed from 'user' to 'assistant' to avoid user confusion + role: 'tool', // Tool result message content: toolResultText, modified: toolResultTime, duration: toolResultDuration, // Use configurable duration - default 1 round for tool results @@ -453,7 +468,7 @@ Error: ${error instanceof Error ? error.message : String(error)} const errorResultMessage: AgentInstanceMessage = { id: `tool-error-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, agentId: handlerContext.agent.id, - role: 'assistant', // Changed from 'user' to 'assistant' to avoid user confusion + role: 'tool', // Tool error message content: errorMessage, modified: errorResultTime, duration: 2, // Error messages are visible to AI for 2 rounds: immediate + next round to allow explanation diff --git a/src/services/agentInstance/promptConcat/promptConcatSchema/index.ts b/src/services/agentInstance/promptConcat/promptConcatSchema/index.ts index 9ed01072..4d2263cb 100644 --- a/src/services/agentInstance/promptConcat/promptConcatSchema/index.ts +++ b/src/services/agentInstance/promptConcat/promptConcatSchema/index.ts @@ -1,14 +1,11 @@ import { createDynamicPromptConcatPluginSchema } from '@services/agentInstance/plugins/schemaRegistry'; -import { identity } from 'lodash'; +import { t } from '@services/libs/i18n/placeholder'; import { z } from 'zod/v4'; import { ModelParametersSchema, ProviderModelSchema } from './modelParameters'; import { PromptSchema } from './prompts'; import { ResponseSchema } from './response'; import { HANDLER_CONFIG_UI_SCHEMA } from './uiSchema'; -/** Placeholder to trigger VSCode i18nAlly extension to show translated text. */ -const t = identity; - /** * Base API configuration schema * Contains common fields shared between AIConfigSchema and AgentConfigSchema diff --git a/src/services/agentInstance/promptConcat/promptConcatSchema/modelParameters.ts b/src/services/agentInstance/promptConcat/promptConcatSchema/modelParameters.ts index d2190852..5c16344a 100644 --- a/src/services/agentInstance/promptConcat/promptConcatSchema/modelParameters.ts +++ b/src/services/agentInstance/promptConcat/promptConcatSchema/modelParameters.ts @@ -1,9 +1,6 @@ -import { identity } from 'lodash'; +import { t } from '@services/libs/i18n/placeholder'; import { z } from 'zod/v4'; -/** Placeholder to trigger VSCode i18nAlly extension to show translated text. */ -const t = identity; - /** * Provider and model selection schema */ diff --git a/src/services/agentInstance/promptConcat/promptConcatSchema/prompts.ts b/src/services/agentInstance/promptConcat/promptConcatSchema/prompts.ts index 9e590d36..cdd8c144 100644 --- a/src/services/agentInstance/promptConcat/promptConcatSchema/prompts.ts +++ b/src/services/agentInstance/promptConcat/promptConcatSchema/prompts.ts @@ -1,9 +1,6 @@ -import { identity } from 'lodash'; +import { t } from '@services/libs/i18n/placeholder'; import { z } from 'zod/v4'; -/** Placeholder to trigger VSCode i18nAlly extension to show translated text. */ -const t = identity; - /** * Complete prompt configuration schema * Defines a prompt with its metadata and content structure diff --git a/src/services/agentInstance/promptConcat/promptConcatSchema/response.ts b/src/services/agentInstance/promptConcat/promptConcatSchema/response.ts index 45385f2b..eb1277ca 100644 --- a/src/services/agentInstance/promptConcat/promptConcatSchema/response.ts +++ b/src/services/agentInstance/promptConcat/promptConcatSchema/response.ts @@ -1,9 +1,6 @@ -import { identity } from 'lodash'; +import { t } from '@services/libs/i18n/placeholder'; import { z } from 'zod/v4'; -/** Placeholder to trigger VSCode i18nAlly extension to show translated text. */ -const t = identity; - /** * Basic response configuration schema * Defines identifiers for AI responses that can be referenced by response modifications diff --git a/src/services/agentInstance/utilities/__tests__/schemaToToolContent.test.ts b/src/services/agentInstance/utilities/__tests__/schemaToToolContent.test.ts new file mode 100644 index 00000000..d1446f0b --- /dev/null +++ b/src/services/agentInstance/utilities/__tests__/schemaToToolContent.test.ts @@ -0,0 +1,126 @@ +/** + * Tests for schemaToToolContent utility + */ +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod/v4'; + +import { schemaToToolContent } from '../schemaToToolContent'; + +// Mock i18n +vi.mock('@services/libs/i18n', () => ({ + i18n: { + t: vi.fn((key: string) => { + const translations: Record = { + 'Tool.Schema.Required': '必需', + 'Tool.Schema.Optional': '可选', + 'Tool.Schema.Description': '描述', + 'Tool.Schema.Parameters': '参数', + 'Tool.Schema.Examples': '使用示例', + }; + return translations[key] || key; + }), + }, +})); + +describe('schemaToToolContent', () => { + it('should generate tool content from schema with title and description', () => { + const testSchema = z.object({ + name: z.string().describe('The name parameter'), + age: z.number().optional().describe('The age parameter'), + }).meta({ + title: 'test-tool', + description: 'A test tool for demonstration', + examples: [ + { name: 'John', age: 25 }, + { name: 'Jane' }, + ], + }); + + const result = schemaToToolContent(testSchema); + + expect(result).toContain('## test-tool'); + expect(result).toContain('**描述**: A test tool for demonstration'); + expect(result).toContain('- name (string, 必需): The name parameter'); + expect(result).toContain('- age (number, 可选): The age parameter'); + expect(result).toContain('{"name":"John","age":25}'); + expect(result).toContain('{"name":"Jane"}'); + }); + + it('should handle schema without description', () => { + const testSchema = z.object({ + query: z.string().describe('Search query'), + }).meta({ + title: 'search-tool', + examples: [{ query: 'test search' }], + }); + + const result = schemaToToolContent(testSchema); + + expect(result).toContain('## search-tool'); + expect(result).toContain('**描述**: search-tool'); // fallback to title + expect(result).toContain('- query (string, 必需): Search query'); + }); + + it('should handle schema without examples', () => { + const testSchema = z.object({ + input: z.string().describe('Input text'), + }).meta({ + title: 'input-tool', + description: 'Processes input text', + }); + + const result = schemaToToolContent(testSchema); + + expect(result).toContain('## input-tool'); + expect(result).toContain('**描述**: Processes input text'); + expect(result).toContain('- input (string, 必需): Input text'); + expect(result).toContain('**使用示例**:\n'); // empty examples section + }); + + it('should handle schema without meta', () => { + const testSchema = z.object({ + value: z.string().describe('A value'), + }); + + const result = schemaToToolContent(testSchema); + + expect(result).toContain('## tool'); // default title + expect(result).toContain('- value (string, 必需): A value'); + }); + + it('should handle different parameter types', () => { + const testSchema = z.object({ + text: z.string().describe('Text input'), + number: z.number().describe('Number input'), + boolean: z.boolean().describe('Boolean input'), + array: z.array(z.string()).describe('Array input'), + object: z.object({ nested: z.string() }).describe('Object input'), + }).meta({ + title: 'types-tool', + description: 'Tool demonstrating different types', + }); + + const result = schemaToToolContent(testSchema); + + expect(result).toContain('- text (string, 必需): Text input'); + expect(result).toContain('- number (number, 必需): Number input'); + expect(result).toContain('- boolean (boolean, 必需): Boolean input'); + expect(result).toContain('- array (array, 必需): Array input'); + expect(result).toContain('- object (object, 必需): Object input'); + }); + + it('should handle enum parameters with options', () => { + const testSchema = z.object({ + operation: z.enum(['add', 'delete', 'update']).describe('Type of operation to execute'), + status: z.enum(['active', 'inactive']).describe('Current status'), + }).meta({ + title: 'enum-tool', + description: 'Tool demonstrating enum parameters', + }); + + const result = schemaToToolContent(testSchema); + + expect(result).toContain('- operation (enum, 必需): Type of operation to execute ("add", "delete", "update")'); + expect(result).toContain('- status (enum, 必需): Current status ("active", "inactive")'); + }); +}); diff --git a/src/services/agentInstance/utilities/schemaToToolContent.ts b/src/services/agentInstance/utilities/schemaToToolContent.ts new file mode 100644 index 00000000..3267321f --- /dev/null +++ b/src/services/agentInstance/utilities/schemaToToolContent.ts @@ -0,0 +1,91 @@ +import { i18n } from '@services/libs/i18n'; +import { z } from 'zod/v4'; + +/** + * Build a tool content string from a Zod schema's JSON Schema meta and supplied examples. + * Inputs: + * - schema: Zod schema object + * - toolId: string tool id (for header) + * - examples: optional array of example strings (preformatted tool_use) + */ +export function schemaToToolContent(schema: z.ZodType) { + const schemaUnknown: unknown = z.toJSONSchema(schema, { target: 'draft-7' }); + + let parameterLines = ''; + let schemaTitle = ''; + let schemaDescription = ''; + + if (schemaUnknown && typeof schemaUnknown === 'object' && schemaUnknown !== null) { + const s = schemaUnknown as Record; + schemaTitle = s.title && typeof s.title === 'string' + ? s.title + : ''; + schemaDescription = s.description && typeof s.description === 'string' + ? s.description + : ''; + const props = s.properties as Record | undefined; + const requiredArray = Array.isArray(s.required) ? (s.required as string[]) : []; + if (props) { + parameterLines = Object.keys(props) + .map((key) => { + const property = props[key] as Record | undefined; + let type = property && typeof property.type === 'string' ? property.type : 'string'; + let desc = ''; + if (property) { + if (typeof property.description === 'string') { + // Try to translate the description if it looks like an i18n key + desc = property.description.startsWith('Schema.') + ? i18n.t(property.description) + : property.description; + } else if (property.title && typeof property.title === 'string') { + // Try to translate the title if it looks like an i18n key + desc = property.title.startsWith('Schema.') + ? i18n.t(property.title) + : property.title; + } + + // Handle enum values + if (property.enum && Array.isArray(property.enum)) { + const enumValues = property.enum.map(value => `"${String(value)}"`).join(', '); + desc = desc ? `${desc} (${enumValues})` : `Options: ${enumValues}`; + type = 'enum'; + } + } + const required = requiredArray.includes(key) + ? i18n.t('Tool.Schema.Required') + : i18n.t('Tool.Schema.Optional'); + return `- ${key} (${type}, ${required}): ${desc}`; + }) + .join('\n'); + } + } + + const toolId = (schemaUnknown && typeof schemaUnknown === 'object' && schemaUnknown !== null && (schemaUnknown as Record).title) + ? String((schemaUnknown as Record).title) + : 'tool'; + + let exampleSection = ''; + if (schemaUnknown && typeof schemaUnknown === 'object' && schemaUnknown !== null) { + const s = schemaUnknown as Record; + const ex = s.examples; + if (Array.isArray(ex)) { + exampleSection = ex + .map(exampleItem => `- ${JSON.stringify(exampleItem)}`) + .join('\n'); + } + } + + // Try to translate schema description if it looks like an i18n key + const finalDescription = schemaDescription + ? (schemaDescription.startsWith('在Wiki') + ? schemaDescription // Already translated Chinese text + : i18n.t(schemaDescription)) + : schemaTitle; // Fallback to title if no description + + const descriptionLabel = i18n.t('Tool.Schema.Description'); + const parametersLabel = i18n.t('Tool.Schema.Parameters'); + const examplesLabel = i18n.t('Tool.Schema.Examples'); + + const content = `\n## ${toolId}\n**${descriptionLabel}**: ${finalDescription}\n**${parametersLabel}**:\n${parameterLines}\n\n**${examplesLabel}**:\n${exampleSection}\n`; + return content; +} diff --git a/src/services/database/index.ts b/src/services/database/index.ts index e28fbce6..da9b5ffa 100644 --- a/src/services/database/index.ts +++ b/src/services/database/index.ts @@ -242,6 +242,52 @@ export class DatabaseService implements IDatabaseService { return this.dataSources.get(key)!; } + /** + * Get database file information like whether it exists and its size in bytes. + */ + public async getDatabaseInfo(key: string): Promise<{ exists: boolean; size?: number }> { + const databasePath = this.getDatabasePath(key); + if (databasePath === ':memory:') { + return { exists: true, size: undefined }; + } + + try { + const exists = await fs.pathExists(databasePath); + if (!exists) return { exists: false }; + const stat = await fs.stat(databasePath); + return { exists: true, size: stat.size }; + } catch (error) { + logger.error(`getDatabaseInfo failed for key: ${key}`, { error: (error as Error).message }); + return { exists: false }; + } + } + + /** + * Delete the database file for a given key and close any active connection. + */ + public async deleteDatabase(key: string): Promise { + try { + // Close and remove from pool if exists + if (this.dataSources.has(key)) { + try { + await this.dataSources.get(key)?.destroy(); + } catch (error) { + logger.warn(`Failed to destroy datasource for key: ${key} before deletion`, { error: (error as Error).message }); + } + this.dataSources.delete(key); + } + + const databasePath = this.getDatabasePath(key); + if (databasePath !== ':memory:' && await fs.pathExists(databasePath)) { + await fs.unlink(databasePath); + logger.info(`Database file deleted for key: ${key}`); + } + } catch (error) { + logger.error(`deleteDatabase failed for key: ${key}`, { error: (error as Error).message }); + throw error; + } + } + /** * Close database connection for a given key */ diff --git a/src/services/database/interface.ts b/src/services/database/interface.ts index b1f2a764..7a56fdb6 100644 --- a/src/services/database/interface.ts +++ b/src/services/database/interface.ts @@ -61,6 +61,16 @@ export interface IDatabaseService { * Close database connection */ closeAppDatabase(key: string, drop?: boolean): void; + + /** + * Get database file information like whether it exists and its size in bytes. + */ + getDatabaseInfo(key: string): Promise<{ exists: boolean; size?: number }>; + + /** + * Delete the database file for a given key and close any active connection. + */ + deleteDatabase(key: string): Promise; } export const DatabaseServiceIPCDescriptor = { @@ -70,5 +80,7 @@ export const DatabaseServiceIPCDescriptor = { initializeForApp: ProxyPropertyType.Function, getDatabase: ProxyPropertyType.Function, closeAppDatabase: ProxyPropertyType.Function, + getDatabaseInfo: ProxyPropertyType.Function, + deleteDatabase: ProxyPropertyType.Function, }, }; diff --git a/src/services/externalAPI/callProviderAPI.ts b/src/services/externalAPI/callProviderAPI.ts index 106e937c..f0e2272a 100644 --- a/src/services/externalAPI/callProviderAPI.ts +++ b/src/services/externalAPI/callProviderAPI.ts @@ -77,7 +77,16 @@ export function streamFromProvider( return streamText({ model: client(model), system: systemPrompt, - messages, + // Convert tool role messages to user role for API compatibility + messages: messages.map(message => { + if (message.role === 'tool') { + return { + ...message, + role: 'user' as const, + }; + } + return message; + }) as typeof messages, temperature, abortSignal: signal, }); diff --git a/src/services/libs/i18n/placeholder.ts b/src/services/libs/i18n/placeholder.ts new file mode 100644 index 00000000..f7dcf662 --- /dev/null +++ b/src/services/libs/i18n/placeholder.ts @@ -0,0 +1,4 @@ +import { identity } from 'lodash'; + +/** Placeholder to trigger VSCode i18nAlly extension to show translated text. We translate it on needed (e.g. on frontend), because at this time, i18n service might not be fully initialized. */ +export const t = identity; diff --git a/src/windows/Preferences/sections/DeveloperTools.tsx b/src/windows/Preferences/sections/DeveloperTools.tsx index 352d5ced..8e0a6d76 100644 --- a/src/windows/Preferences/sections/DeveloperTools.tsx +++ b/src/windows/Preferences/sections/DeveloperTools.tsx @@ -87,6 +87,11 @@ export function DeveloperTools(props: ISectionProps): React.JSX.Element { disabled={preference === undefined} onChange={async () => { await window.service.preference.set('externalAPIDebug', !preference?.externalAPIDebug); + const info = await window.service.database.getDatabaseInfo('externalApi'); + if (!info?.exists) { + // if database didn't exist before, enabling externalAPIDebug requires application restart to initialize the database table + props.requestRestartCountDown?.(); + } }} name='externalAPIDebug' /> diff --git a/src/windows/Preferences/sections/Search.tsx b/src/windows/Preferences/sections/Search.tsx index 4df1f0df..929514bb 100644 --- a/src/windows/Preferences/sections/Search.tsx +++ b/src/windows/Preferences/sections/Search.tsx @@ -138,8 +138,6 @@ export function Search(props: SearchProps): React.JSX.Element { // Get AI config from external API service const aiConfig = await window.service.externalAPI.getAIConfig(); - // DEBUG: console aiConfig - console.log(`aiConfig`, aiConfig); if (!aiConfig.api.provider) { showInfoSnackbar({ message: t('Preference.SearchEmbeddingNoAIConfigError'),