feat: tool message & wiki tool schema

This commit is contained in:
lin onetwo 2025-09-12 02:23:29 +08:00
parent 49515f74a1
commit a398c600cb
25 changed files with 769 additions and 134 deletions

View file

@ -541,6 +541,56 @@
"WorkspaceName": "Workspace name", "WorkspaceName": "Workspace name",
"WorkspaceNameTitle": "" "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: <tool_use name=\"wiki-operation\">{\"workspaceName\": \"My Knowledge Base\", \"operation\": \"addTiddler\", \"title\": \"New Note\", \"text\": \"This is note content\", \"extraMeta\": \"{\\\"tags\\\":[\\\"tag1\\\",\\\"tag2\\\"]}\"}</tool_use>",
"set": "Set text: <tool_use name=\"wiki-operation\">{\"workspaceName\": \"My Knowledge Base\", \"operation\": \"setTiddlerText\", \"title\": \"Existing Note\", \"text\": \"Updated content\"}</tool_use>",
"delete": "Delete note: <tool_use name=\"wiki-operation\">{\"workspaceName\": \"My Knowledge Base\", \"operation\": \"deleteTiddler\", \"title\": \"Note to Delete\"}</tool_use>"
}
}
},
"WikiSearch": { "WikiSearch": {
"Description": "Search for content in TiddlyWiki workspaces using filter expressions", "Description": "Search for content in TiddlyWiki workspaces using filter expressions",
"Filter": "", "Filter": "",
@ -584,5 +634,36 @@
"NewTab": "New Tab", "NewTab": "New Tab",
"NewWeb": "Create a new webpage" "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"
}
} }
} }

View file

@ -192,6 +192,7 @@
"Description": "此智能体的外部 API 调用调试日志。在偏好设置中启用 '外部 API 调试' 以开始记录。", "Description": "此智能体的外部 API 调用调试日志。在偏好设置中启用 '外部 API 调试' 以开始记录。",
"CurrentAgent": "显示智能体日志: {{agentId}}", "CurrentAgent": "显示智能体日志: {{agentId}}",
"NoLogs": "未找到此智能体的 API 日志", "NoLogs": "未找到此智能体的 API 日志",
"NoResponse": "无响应",
"StatusStart": "已开始", "StatusStart": "已开始",
"StatusUpdate": "处理中", "StatusUpdate": "处理中",
"StatusDone": "已完成", "StatusDone": "已完成",
@ -561,8 +562,70 @@
"ToolListPositionTitle": "工具列表位置", "ToolListPositionTitle": "工具列表位置",
"ToolResultDuration": "工具执行结果在对话中保持可见的轮数,超过此轮数后结果将变灰显示", "ToolResultDuration": "工具执行结果在对话中保持可见的轮数,超过此轮数后结果将变灰显示",
"ToolResultDurationTitle": "工具结果持续轮数", "ToolResultDurationTitle": "工具结果持续轮数",
"Tool": {
"Parameters": {
"workspaceName": {
"Title": "工作空间名称",
"Description": "要搜索的工作空间名称或ID"
},
"filter": {
"Title": "过滤器",
"Description": "TiddlyWiki 过滤器表达式"
}
}
},
"WorkspaceName": "要搜索的 TiddlyWiki 工作区名称", "WorkspaceName": "要搜索的 TiddlyWiki 工作区名称",
"WorkspaceNameTitle": "工作区名称" "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": "添加笔记: <tool_use name=\"wiki-operation\">{\"workspaceName\": \"我的知识库\", \"operation\": \"addTiddler\", \"title\": \"新笔记\", \"text\": \"这是笔记内容\", \"extraMeta\": \"{\\\"tags\\\":[\\\"标签1\\\",\\\"标签2\\\"]}\"}</tool_use>",
"set": "设置文本: <tool_use name=\"wiki-operation\">{\"workspaceName\": \"我的知识库\", \"operation\": \"setTiddlerText\", \"title\": \"现有笔记\", \"text\": \"更新后的内容\"}</tool_use>",
"delete": "删除笔记: <tool_use name=\"wiki-operation\">{\"workspaceName\": \"我的知识库\", \"operation\": \"deleteTiddler\", \"title\": \"要删除的笔记\"}</tool_use>"
}
}
} }
}, },
"Search": { "Search": {
@ -584,5 +647,36 @@
"NewTab": "新建标签页", "NewTab": "新建标签页",
"NewWeb": "新建网页" "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": "使用示例"
}
} }
} }

View file

@ -25,7 +25,6 @@ const LogHeader = styled(Box)`
const LogContent = styled(Box)` const LogContent = styled(Box)`
font-family: 'Monaco', 'Consolas', 'Courier New', monospace; font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
font-size: 12px; font-size: 12px;
background-color: ${props => props.theme.palette.grey[100]};
padding: 12px; padding: 12px;
border-radius: 4px; border-radius: 4px;
overflow-x: auto; overflow-x: auto;
@ -220,17 +219,17 @@ export const APILogsDialog: React.FC<APILogsDialogProps> = ({
</LogContent> </LogContent>
</Box> </Box>
{/* Response Content */} {/* Response Content: always show block; display placeholder when missing */}
{log.responseContent && (
<Box> <Box>
<Typography variant='subtitle2' gutterBottom> <Typography variant='subtitle2' gutterBottom>
{t('APILogs.ResponseContent')} {t('APILogs.ResponseContent')}
</Typography> </Typography>
<LogContent> <LogContent>
{log.responseContent} {log.responseContent && String(log.responseContent).length > 0
? log.responseContent
: t('APILogs.NoResponse')}
</LogContent> </LogContent>
</Box> </Box>
)}
{/* Response Metadata */} {/* Response Metadata */}
{log.responseMetadata && ( {log.responseMetadata && (

View file

@ -10,28 +10,28 @@ import { useAgentChatStore } from '../../Agent/store/agentChatStore/index';
import { MessageRenderer } from './MessageRenderer'; import { MessageRenderer } from './MessageRenderer';
const BubbleContainer = styled(Box, { const BubbleContainer = styled(Box, {
shouldForwardProp: (property) => property !== '$user' && property !== '$expired', shouldForwardProp: (property) => property !== '$user' && property !== '$tool' && property !== '$expired',
})<{ $user: boolean; $expired?: boolean }>` })<{ $user: boolean; $tool: boolean; $expired?: boolean }>`
display: flex; display: flex;
gap: 12px; gap: 12px;
max-width: 80%; 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}; opacity: ${props => props.$expired ? 0.5 : 1};
transition: opacity 0.3s ease-in-out; transition: opacity 0.3s ease-in-out;
`; `;
const MessageAvatar = styled(Avatar, { const MessageAvatar = styled(Avatar, {
shouldForwardProp: (property) => property !== '$user' && property !== '$expired', shouldForwardProp: (property) => property !== '$user' && property !== '$tool' && property !== '$expired',
})<{ $user: boolean; $expired?: boolean }>` })<{ $user: boolean; $tool: boolean; $expired?: boolean }>`
background-color: ${props => props.$user ? props.theme.palette.primary.main : props.theme.palette.secondary.main}; 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.$user ? props.theme.palette.primary.contrastText : props.theme.palette.secondary.contrastText}; 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}; opacity: ${props => props.$expired ? 0.7 : 1};
transition: opacity 0.3s ease-in-out; transition: opacity 0.3s ease-in-out;
`; `;
const MessageContent = styled(Box, { const MessageContent = styled(Box, {
shouldForwardProp: (property) => property !== '$user' && property !== '$streaming' && property !== '$expired', shouldForwardProp: (property) => property !== '$user' && property !== '$tool' && property !== '$streaming' && property !== '$expired',
})<{ $user: boolean; $streaming?: boolean; $expired?: boolean }>` })<{ $user: boolean; $tool: boolean; $streaming?: boolean; $expired?: boolean }>`
background-color: ${props => props.$user ? props.theme.palette.primary.light : props.theme.palette.background.paper}; 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}; color: ${props => props.$user ? props.theme.palette.primary.contrastText : props.theme.palette.text.primary};
padding: 12px 16px; padding: 12px 16px;
@ -94,6 +94,7 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({ messageId }) => {
if (!message) return null; if (!message) return null;
const isUser = message.role === 'user'; const isUser = message.role === 'user';
const isTool = message.role === 'tool';
// Calculate if message is expired for AI context // Calculate if message is expired for AI context
const messageIndex = orderedMessageIds.indexOf(messageId); const messageIndex = orderedMessageIds.indexOf(messageId);
@ -101,15 +102,16 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({ messageId }) => {
const isExpired = isMessageExpiredForAI(message, messageIndex, totalMessages); const isExpired = isMessageExpiredForAI(message, messageIndex, totalMessages);
return ( return (
<BubbleContainer $user={isUser} $expired={isExpired} data-testid='message-bubble'> <BubbleContainer $user={isUser} $tool={isTool} $expired={isExpired} data-testid='message-bubble'>
{!isUser && ( {!isUser && !isTool && (
<MessageAvatar $user={isUser} $expired={isExpired}> <MessageAvatar $user={isUser} $tool={isTool} $expired={isExpired}>
<SmartToyIcon /> <SmartToyIcon />
</MessageAvatar> </MessageAvatar>
)} )}
<MessageContent <MessageContent
$user={isUser} $user={isUser}
$tool={isTool}
$streaming={isStreaming} $streaming={isStreaming}
$expired={isExpired} $expired={isExpired}
data-testid={!isUser ? (isStreaming ? 'assistant-streaming-text' : 'assistant-message') : undefined} data-testid={!isUser ? (isStreaming ? 'assistant-streaming-text' : 'assistant-message') : undefined}
@ -118,7 +120,7 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({ messageId }) => {
</MessageContent> </MessageContent>
{isUser && ( {isUser && (
<MessageAvatar $user={isUser} $expired={isExpired}> <MessageAvatar $user={isUser} $tool={isTool} $expired={isExpired}>
<PersonIcon /> <PersonIcon />
</MessageAvatar> </MessageAvatar>
)} )}

View file

@ -287,8 +287,8 @@ describe('PromptPreviewDialog - Tool Information Rendering', () => {
} }
expect(wikiSearchElement).toBeDefined(); expect(wikiSearchElement).toBeDefined();
const wikiSearchText = `${wikiSearchElement?.caption ?? ''} ${wikiSearchElement?.text ?? ''}`; const wikiSearchText = `${wikiSearchElement?.caption ?? ''} ${wikiSearchElement?.text ?? ''}`;
expect(wikiSearchText).toContain('Available Tools:'); expect(wikiSearchText).toContain('Wiki search tool');
expect(wikiSearchText).toContain('Tool ID: wiki-search'); expect(wikiSearchText).toContain('## wiki-search');
// Verify the order: before-tool -> workspaces -> wiki-operation -> wiki-search -> post-tool // 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'); const postToolElement: IPrompt | undefined = toolsSection?.children?.find((c: IPrompt) => c.id === 'default-post-tool');

View file

@ -333,4 +333,92 @@ describe('MessageBubble - Duration-based Graying', () => {
screen.getByText('Message 3 - duration 1').parentElement?.parentElement; screen.getByText('Message 3 - duration 1').parentElement?.parentElement;
expect(bubbleContainer).toHaveStyle({ opacity: '0.5' }); // Should be grayed out 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: '<functions_result>Tool: wiki-search\nResult: Found some content</functions_result>',
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(
<TestWrapper>
<MessageBubble messageId='tool-msg' />
</TestWrapper>,
);
// 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: '<functions_result>Tool result</functions_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(
<TestWrapper>
<MessageBubble messageId='assistant-msg' />
</TestWrapper>,
);
const assistantContent = screen.getByText('This is an assistant response');
const assistantBackgroundColor = window.getComputedStyle(assistantContent.parentElement!).backgroundColor;
// Render tool message
rerender(
<TestWrapper>
<MessageBubble messageId='tool-msg' />
</TestWrapper>,
);
const toolContent = screen.getByText(/Tool result/);
const toolBackgroundColor = window.getComputedStyle(toolContent.parentElement!).backgroundColor;
// Both should have the same background color
expect(assistantBackgroundColor).toBe(toolBackgroundColor);
});
}); });

View file

@ -13,11 +13,13 @@ export function useInitialPage() {
hasInitialized.current = true; hasInitialized.current = true;
const activeWorkspace = workspacesList.find(workspace => workspace.active); const activeWorkspace = workspacesList.find(workspace => workspace.active);
if (!activeWorkspace) { 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) { } else if (activeWorkspace.pageType) {
// Don't navigate to add page, fallback to guide instead // Don't navigate to add page, fallback to guide instead
if (activeWorkspace.pageType === PageType.add) { if (activeWorkspace.pageType === PageType.add) {
setLocation(`/${PageType.guide}`); setLocation(`/`);
} else { } else {
setLocation(`/${activeWorkspace.pageType}`); setLocation(`/${activeWorkspace.pageType}`);
} }

View file

@ -11,7 +11,9 @@ import { AgentDefinitionServiceIPCDescriptor, IAgentDefinitionService } from '@s
import { AgentInstanceServiceIPCDescriptor, IAgentInstanceService } from '@services/agentInstance/interface'; import { AgentInstanceServiceIPCDescriptor, IAgentInstanceService } from '@services/agentInstance/interface';
import { AuthenticationServiceIPCDescriptor, IAuthenticationService } from '@services/auth/interface'; import { AuthenticationServiceIPCDescriptor, IAuthenticationService } from '@services/auth/interface';
import { ContextServiceIPCDescriptor, IContextService } from '@services/context/interface'; import { ContextServiceIPCDescriptor, IContextService } from '@services/context/interface';
import { DatabaseServiceIPCDescriptor, IDatabaseService } from '@services/database/interface';
import { DeepLinkServiceIPCDescriptor, IDeepLinkService } from '@services/deepLink/interface'; import { DeepLinkServiceIPCDescriptor, IDeepLinkService } from '@services/deepLink/interface';
import { ExternalAPIServiceIPCDescriptor, IExternalAPIService } from '@services/externalAPI/interface';
import { GitServiceIPCDescriptor, IGitService } from '@services/git/interface'; import { GitServiceIPCDescriptor, IGitService } from '@services/git/interface';
import { IMenuService, MenuServiceIPCDescriptor } from '@services/menu/interface'; import { IMenuService, MenuServiceIPCDescriptor } from '@services/menu/interface';
import { INativeService, NativeServiceIPCDescriptor } from '@services/native/interface'; import { INativeService, NativeServiceIPCDescriptor } from '@services/native/interface';
@ -28,7 +30,6 @@ import { IWikiGitWorkspaceService, WikiGitWorkspaceServiceIPCDescriptor } from '
import { IWindowService, WindowServiceIPCDescriptor } from '@services/windows/interface'; import { IWindowService, WindowServiceIPCDescriptor } from '@services/windows/interface';
import { IWorkspaceService, WorkspaceServiceIPCDescriptor } from '@services/workspaces/interface'; import { IWorkspaceService, WorkspaceServiceIPCDescriptor } from '@services/workspaces/interface';
import { IWorkspaceViewService, WorkspaceViewServiceIPCDescriptor } from '@services/workspacesView/interface'; import { IWorkspaceViewService, WorkspaceViewServiceIPCDescriptor } from '@services/workspacesView/interface';
import { ExternalAPIServiceIPCDescriptor, IExternalAPIService } from '../../services/externalAPI/interface';
export const agentBrowser = createProxy<AsyncifyProxy<IAgentBrowserService>>(AgentBrowserServiceIPCDescriptor); export const agentBrowser = createProxy<AsyncifyProxy<IAgentBrowserService>>(AgentBrowserServiceIPCDescriptor);
export const agentDefinition = createProxy<AsyncifyProxy<IAgentDefinitionService>>(AgentDefinitionServiceIPCDescriptor); export const agentDefinition = createProxy<AsyncifyProxy<IAgentDefinitionService>>(AgentDefinitionServiceIPCDescriptor);
@ -37,6 +38,7 @@ export const auth = createProxy<IAuthenticationService>(AuthenticationServiceIPC
export const context = createProxy<IContextService>(ContextServiceIPCDescriptor); export const context = createProxy<IContextService>(ContextServiceIPCDescriptor);
export const deepLink = createProxy<IDeepLinkService>(DeepLinkServiceIPCDescriptor); export const deepLink = createProxy<IDeepLinkService>(DeepLinkServiceIPCDescriptor);
export const externalAPI = createProxy<IExternalAPIService>(ExternalAPIServiceIPCDescriptor); export const externalAPI = createProxy<IExternalAPIService>(ExternalAPIServiceIPCDescriptor);
export const database = createProxy<IDatabaseService>(DatabaseServiceIPCDescriptor);
export const git = createProxy<IGitService>(GitServiceIPCDescriptor); export const git = createProxy<IGitService>(GitServiceIPCDescriptor);
export const menu = createProxy<IMenuService>(MenuServiceIPCDescriptor); export const menu = createProxy<IMenuService>(MenuServiceIPCDescriptor);
export const native = createProxy<INativeService>(NativeServiceIPCDescriptor); export const native = createProxy<INativeService>(NativeServiceIPCDescriptor);
@ -78,4 +80,5 @@ export const descriptors = {
workspace: WorkspaceServiceIPCDescriptor, workspace: WorkspaceServiceIPCDescriptor,
workspaceView: WorkspaceViewServiceIPCDescriptor, workspaceView: WorkspaceViewServiceIPCDescriptor,
externalAPI: ExternalAPIServiceIPCDescriptor, externalAPI: ExternalAPIServiceIPCDescriptor,
database: DatabaseServiceIPCDescriptor,
}; };

View file

@ -210,7 +210,7 @@ describe('WikiSearch Plugin Integration & YieldNextRound Mechanism', () => {
// Verify tool result message was added to agent history // Verify tool result message was added to agent history
expect(responseContext.handlerContext.agent.messages.length).toBeGreaterThan(0); expect(responseContext.handlerContext.agent.messages.length).toBeGreaterThan(0);
const toolResultMessage = responseContext.handlerContext.agent.messages[responseContext.handlerContext.agent.messages.length - 1] as AgentInstanceMessage; 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('<functions_result>'); expect(toolResultMessage.content).toContain('<functions_result>');
expect(toolResultMessage.content).toContain('Tool: wiki-search'); expect(toolResultMessage.content).toContain('Tool: wiki-search');
expect(toolResultMessage.content).toContain('Important Note 1'); 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 // Verify error message was added to agent history
expect(responseContext.handlerContext.agent.messages.length).toBeGreaterThan(0); expect(responseContext.handlerContext.agent.messages.length).toBeGreaterThan(0);
const errorResultMessage = responseContext.handlerContext.agent.messages[responseContext.handlerContext.agent.messages.length - 1] as AgentInstanceMessage; 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('Error:');
expect(errorResultMessage.content).toContain('does not exist'); expect(errorResultMessage.content).toContain('does not exist');
}); });
@ -352,7 +352,7 @@ describe('WikiSearch Plugin Integration & YieldNextRound Mechanism', () => {
// Verify that tool result message was added // Verify that tool result message was added
const toolResultMessage = context.agent.messages.find(m => m.metadata?.isToolResult); const toolResultMessage = context.agent.messages.find(m => m.metadata?.isToolResult);
expect(toolResultMessage).toBeTruthy(); expect(toolResultMessage).toBeTruthy();
expect(toolResultMessage?.role).toBe('assistant'); expect(toolResultMessage?.role).toBe('tool');
expect(toolResultMessage?.content).toContain('<functions_result>'); expect(toolResultMessage?.content).toContain('<functions_result>');
// Verify that there are multiple responses (initial tool call + final explanation) // Verify that there are multiple responses (initial tool call + final explanation)

View file

@ -22,6 +22,34 @@ import type { AIResponseContext, PluginActions, PromptConcatHookContext } from '
import { wikiOperationPlugin } from '../wikiOperationPlugin'; import { wikiOperationPlugin } from '../wikiOperationPlugin';
import { workspacesListPlugin } from '../workspacesListPlugin'; import { workspacesListPlugin } from '../workspacesListPlugin';
// Mock i18n
vi.mock('@services/libs/i18n', () => ({
i18n: {
t: vi.fn((key: string, options?: Record<string, unknown>) => {
const translations: Record<string, string> = {
'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 // Helper to construct a complete AgentHandlerContext for tests
const makeHandlerContext = (agentId = 'test-agent'): AgentHandlerContext => ({ const makeHandlerContext = (agentId = 'test-agent'): AgentHandlerContext => ({
agent: { agent: {
@ -203,7 +231,7 @@ describe('wikiOperationPlugin', () => {
expect(toolResultMessage).toBeTruthy(); expect(toolResultMessage).toBeTruthy();
expect(toolResultMessage?.content).toContain('<functions_result>'); expect(toolResultMessage?.content).toContain('<functions_result>');
// Check for general success wording and tiddler title // 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?.content).toContain('Test Note');
expect(toolResultMessage?.metadata?.isToolResult).toBe(true); expect(toolResultMessage?.metadata?.isToolResult).toBe(true);
expect(toolResultMessage?.metadata?.toolId).toBe('wiki-operation'); expect(toolResultMessage?.metadata?.toolId).toBe('wiki-operation');
@ -265,7 +293,7 @@ describe('wikiOperationPlugin', () => {
// Check general update success wording and tiddler title // Check general update success wording and tiddler title
const updateResult = handlerContext.agent.messages.find(m => m.metadata?.isToolResult); const updateResult = handlerContext.agent.messages.find(m => m.metadata?.isToolResult);
expect(updateResult).toBeTruthy(); expect(updateResult).toBeTruthy();
expect(updateResult?.content).toContain('Successfully'); expect(updateResult?.content).toContain('成功在Wiki工作空间');
expect(updateResult?.content).toContain('Existing Note'); expect(updateResult?.content).toContain('Existing Note');
}); });
@ -323,7 +351,7 @@ describe('wikiOperationPlugin', () => {
const deleteResult = handlerContext.agent.messages.find(m => m.metadata?.isToolResult); const deleteResult = handlerContext.agent.messages.find(m => m.metadata?.isToolResult);
expect(deleteResult).toBeTruthy(); 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 () => { it('should handle workspace not found error', async () => {
@ -374,7 +402,7 @@ describe('wikiOperationPlugin', () => {
const errResult = handlerContext.agent.messages.find(m => m.metadata?.isToolResult); const errResult = handlerContext.agent.messages.find(m => m.metadata?.isToolResult);
expect(errResult).toBeTruthy(); 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 // Ensure control is yielded to self on error so AI gets the next round
expect(respCtx4.actions?.yieldNextRoundTo).toBe('self'); expect(respCtx4.actions?.yieldNextRoundTo).toBe('self');
}); });

View file

@ -23,6 +23,34 @@ import { createHandlerHooks, PromptConcatHookContext } from '../index';
import { messageManagementPlugin } from '../messageManagementPlugin'; import { messageManagementPlugin } from '../messageManagementPlugin';
import { wikiSearchPlugin } from '../wikiSearchPlugin'; import { wikiSearchPlugin } from '../wikiSearchPlugin';
// Mock i18n
vi.mock('@services/libs/i18n', () => ({
i18n: {
t: vi.fn((key: string, options?: Record<string, unknown>) => {
const translations: Record<string, string> = {
'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 // Use the real agent config
const exampleAgent = defaultAgents[0]; const exampleAgent = defaultAgents[0];
const handlerConfig = exampleAgent.handlerConfig as AgentPromptDescription['handlerConfig']; 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 // Verify tool result message was added to agent history with correct settings
expect(handlerContext.agent.messages.length).toBe(3); // user + ai + tool_result expect(handlerContext.agent.messages.length).toBe(3); // user + ai + tool_result
const toolResultMessage = handlerContext.agent.messages[2] as AgentInstanceMessage; 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('<functions_result>'); expect(toolResultMessage.content).toContain('<functions_result>');
expect(toolResultMessage.content).toContain('Tool: wiki-search'); expect(toolResultMessage.content).toContain('Tool: wiki-search');
expect(toolResultMessage.content).toContain('Important Note 1'); 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 // Verify error message was added to agent history
expect(handlerContext.agent.messages.length).toBe(2); // tool_call + error_result expect(handlerContext.agent.messages.length).toBe(2); // tool_call + error_result
const errorResultMessage = handlerContext.agent.messages[1] as AgentInstanceMessage; 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('<functions_result>'); expect(errorResultMessage.content).toContain('<functions_result>');
expect(errorResultMessage.content).toContain('Error:'); 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?.isToolResult).toBe(true);
expect(errorResultMessage.metadata?.isError).toBe(true); expect(errorResultMessage.metadata?.isError).toBe(true);
expect(errorResultMessage.metadata?.isPersisted).toBe(false); // Should be false initially expect(errorResultMessage.metadata?.isPersisted).toBe(false); // Should be false initially

View file

@ -3,10 +3,20 @@
* Handles wiki operation tool list injection, tool calling detection and response processing * Handles wiki operation tool list injection, tool calling detection and response processing
* Supports creating, updating, and deleting tiddlers in wiki workspaces * 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'; import { z } from 'zod/v4';
import type { AgentInstanceMessage } from '../interface';
const t = identity; import { findPromptById } from '../promptConcat/promptConcat';
import { schemaToToolContent } from '../utilities/schemaToToolContent';
import type { PromptConcatPlugin } from './types';
/** /**
* Wiki Operation Parameter Schema * Wiki Operation Parameter Schema
@ -48,29 +58,44 @@ export function getWikiOperationParameterSchema() {
return WikiOperationParameterSchema; 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 * Parameter schema for Wiki operation tool
*/ */
const WikiOperationToolParameterSchema = z.object({ const WikiOperationToolParameterSchema = z.object({
workspaceName: z.string().describe('Name or ID of the workspace to operate on'), workspaceName: z.string().meta({
operation: z.enum([WikiChannel.addTiddler, WikiChannel.deleteTiddler, WikiChannel.setTiddlerText]).describe('Type of wiki operation to perform'), title: t('Schema.WikiOperation.Tool.Parameters.workspaceName.Title'),
title: z.string().describe('Title of the tiddler'), description: t('Schema.WikiOperation.Tool.Parameters.workspaceName.Description'),
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.)'), operation: z.enum([WikiChannel.addTiddler, WikiChannel.deleteTiddler, WikiChannel.setTiddlerText]).meta({
options: z.string().optional().default('{}').describe('JSON string of operation options'), 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 * Wiki Operation plugin - Prompt processing
@ -105,22 +130,8 @@ export const wikiOperationPlugin: PromptConcatPlugin = (hooks) => {
// Get available wikis - now handled by workspacesListPlugin // Get available wikis - now handled by workspacesListPlugin
// The workspaces list will be injected separately by workspacesListPlugin // The workspaces list will be injected separately by workspacesListPlugin
const wikiOperationToolContent = ` // Build tool content using shared utility (schema contains title/examples meta)
## wiki-operation const wikiOperationToolContent = schemaToToolContent(WikiOperationToolParameterSchema);
****: 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字符串"{}"
**使**:
- : <tool_use name="wiki-operation">{"workspaceName": "我的知识库", "operation": "${WikiChannel.addTiddler}", "title": "新笔记", "text": "这是笔记内容", "extraMeta": "{\\"tags\\":[\\"1\\",\\"2\\"]}"}</tool_use>
- : <tool_use name="wiki-operation">{"workspaceName": "我的知识库", "operation": "${WikiChannel.setTiddlerText}", "title": "现有笔记", "text": "更新后的内容"}</tool_use>
- : <tool_use name="wiki-operation">{"workspaceName": "我的知识库", "operation": "${WikiChannel.deleteTiddler}", "title": "要删除的笔记"}</tool_use>
`;
// Insert the tool content based on position // Insert the tool content based on position
if (toolListPosition.position === 'after') { if (toolListPosition.position === 'after') {
@ -224,12 +235,17 @@ export const wikiOperationPlugin: PromptConcatPlugin = (hooks) => {
const workspaces = await workspaceService.getWorkspacesAsList(); const workspaces = await workspaceService.getWorkspacesAsList();
const targetWorkspace = workspaces.find(ws => ws.name === workspaceName || ws.id === workspaceName); const targetWorkspace = workspaces.find(ws => ws.name === workspaceName || ws.id === workspaceName);
if (!targetWorkspace) { 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; const workspaceID = targetWorkspace.id;
if (!await workspaceService.exists(workspaceID)) { 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', { logger.debug('Executing wiki operation', {
@ -251,19 +267,19 @@ export const wikiOperationPlugin: PromptConcatPlugin = (hooks) => {
extraMeta || '{}', extraMeta || '{}',
JSON.stringify({ withDate: true, ...options }), JSON.stringify({ withDate: true, ...options }),
]); ]);
result = `Successfully added tiddler "${title}" in wiki workspace "${workspaceName}".`; result = i18n.t('Tool.WikiOperation.Success.Added', { title, workspaceName });
break; break;
} }
case WikiChannel.deleteTiddler: { case WikiChannel.deleteTiddler: {
await wikiService.wikiOperationInServer(WikiChannel.deleteTiddler, workspaceID, [title]); 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; break;
} }
case WikiChannel.setTiddlerText: { case WikiChannel.setTiddlerText: {
await wikiService.wikiOperationInServer(WikiChannel.setTiddlerText, workspaceID, [title, text || '']); 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; break;
} }
@ -302,7 +318,7 @@ export const wikiOperationPlugin: PromptConcatPlugin = (hooks) => {
const toolResultMessage: AgentInstanceMessage = { const toolResultMessage: AgentInstanceMessage = {
id: `tool-result-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, id: `tool-result-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
agentId: handlerContext.agent.id, agentId: handlerContext.agent.id,
role: 'assistant', // Changed from 'user' to 'assistant' to avoid user confusion role: 'tool', // Tool result message
content: toolResultText, content: toolResultText,
modified: toolResultTime, modified: toolResultTime,
duration: toolResultDuration, // Use configurable duration - default 1 round for tool results 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 = { const errorResultMessage: AgentInstanceMessage = {
id: `tool-error-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, id: `tool-error-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
agentId: handlerContext.agent.id, agentId: handlerContext.agent.id,
role: 'assistant', // Changed from 'user' to 'assistant' to avoid user confusion role: 'tool', // Tool error message
content: errorMessage, content: errorMessage,
modified: errorResultTime, modified: errorResultTime,
duration: 2, // Error messages are visible to AI for 2 rounds: immediate + next round to allow explanation duration: 2, // Error messages are visible to AI for 2 rounds: immediate + next round to allow explanation

View file

@ -2,10 +2,22 @@
* Wiki Search plugin * Wiki Search plugin
* Handles wiki search tool list injection, tool calling detection and response processing * 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'; import { z } from 'zod/v4';
import type { AgentInstanceMessage, IAgentInstanceService } from '../interface';
const t = identity; 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 * Wiki Search Parameter Schema
@ -63,26 +75,24 @@ export function getWikiSearchParameterSchema() {
return WikiSearchParameterSchema; 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 * Parameter schema for Wiki search tool
*/ */
const WikiSearchToolParameterSchema = z.object({ const WikiSearchToolParameterSchema = z.object({
workspaceName: z.string().describe('Name or ID of the workspace to search'), workspaceName: z.string().meta({
filter: z.string().describe('TiddlyWiki filter expression'), 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<typeof WikiSearchToolParameterSchema>; type WikiSearchToolParameter = z.infer<typeof WikiSearchToolParameterSchema>;
@ -108,7 +118,10 @@ async function executeWikiSearchTool(
if (!targetWorkspace) { if (!targetWorkspace) {
return { return {
success: false, 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)) { if (!await workspaceService.exists(workspaceID)) {
return { return {
success: false, 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) { if (tiddlerTitles.length === 0) {
return { return {
success: true, success: true,
data: `No results found for filter "${filter}" in wiki workspace "${workspaceName}".`, data: i18n.t('Tool.WikiSearch.Success.NoResults', { filter, workspaceName }),
metadata: { metadata: {
filter, filter,
workspaceID, workspaceID,
@ -144,7 +157,6 @@ async function executeWikiSearchTool(
}; };
} }
// Retrieve full tiddler content if requested
// Retrieve full tiddler content for each tiddler // Retrieve full tiddler content for each tiddler
const results: Array<{ title: string; text?: string; fields?: ITiddlerFields }> = []; const results: Array<{ title: string; text?: string; fields?: ITiddlerFields }> = [];
for (const title of tiddlerTitles) { for (const title of tiddlerTitles) {
@ -168,7 +180,10 @@ async function executeWikiSearchTool(
} }
// Format results as text with content // 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) { for (const result of results) {
content += `**Tiddler: ${result.title}**\n\n`; content += `**Tiddler: ${result.title}**\n\n`;
@ -180,7 +195,6 @@ async function executeWikiSearchTool(
content += '(Content not available)\n\n'; content += '(Content not available)\n\n';
} }
} }
return { return {
success: true, success: true,
data: content, data: content,
@ -200,7 +214,9 @@ async function executeWikiSearchTool(
return { return {
success: false, 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 // Get available wikis - now handled by workspacesListPlugin
// The workspaces list will be injected separately by workspacesListPlugin // The workspaces list will be injected separately by workspacesListPlugin
const toolPromptContent = const toolPromptContent = schemaToToolContent(WikiSearchToolParameterSchema);
`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 toolPrompt: IPrompt = { const toolPrompt: IPrompt = {
id: `wiki-tool-list-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, 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 = { const toolResultMessage: AgentInstanceMessage = {
id: `tool-result-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, id: `tool-result-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
agentId: handlerContext.agent.id, agentId: handlerContext.agent.id,
role: 'assistant', // Changed from 'user' to 'assistant' to avoid user confusion role: 'tool', // Tool result message
content: toolResultText, content: toolResultText,
modified: toolResultTime, modified: toolResultTime,
duration: toolResultDuration, // Use configurable duration - default 1 round for tool results 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 = { const errorResultMessage: AgentInstanceMessage = {
id: `tool-error-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, id: `tool-error-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
agentId: handlerContext.agent.id, agentId: handlerContext.agent.id,
role: 'assistant', // Changed from 'user' to 'assistant' to avoid user confusion role: 'tool', // Tool error message
content: errorMessage, content: errorMessage,
modified: errorResultTime, modified: errorResultTime,
duration: 2, // Error messages are visible to AI for 2 rounds: immediate + next round to allow explanation duration: 2, // Error messages are visible to AI for 2 rounds: immediate + next round to allow explanation

View file

@ -1,14 +1,11 @@
import { createDynamicPromptConcatPluginSchema } from '@services/agentInstance/plugins/schemaRegistry'; import { createDynamicPromptConcatPluginSchema } from '@services/agentInstance/plugins/schemaRegistry';
import { identity } from 'lodash'; import { t } from '@services/libs/i18n/placeholder';
import { z } from 'zod/v4'; import { z } from 'zod/v4';
import { ModelParametersSchema, ProviderModelSchema } from './modelParameters'; import { ModelParametersSchema, ProviderModelSchema } from './modelParameters';
import { PromptSchema } from './prompts'; import { PromptSchema } from './prompts';
import { ResponseSchema } from './response'; import { ResponseSchema } from './response';
import { HANDLER_CONFIG_UI_SCHEMA } from './uiSchema'; import { HANDLER_CONFIG_UI_SCHEMA } from './uiSchema';
/** Placeholder to trigger VSCode i18nAlly extension to show translated text. */
const t = identity;
/** /**
* Base API configuration schema * Base API configuration schema
* Contains common fields shared between AIConfigSchema and AgentConfigSchema * Contains common fields shared between AIConfigSchema and AgentConfigSchema

View file

@ -1,9 +1,6 @@
import { identity } from 'lodash'; import { t } from '@services/libs/i18n/placeholder';
import { z } from 'zod/v4'; import { z } from 'zod/v4';
/** Placeholder to trigger VSCode i18nAlly extension to show translated text. */
const t = identity;
/** /**
* Provider and model selection schema * Provider and model selection schema
*/ */

View file

@ -1,9 +1,6 @@
import { identity } from 'lodash'; import { t } from '@services/libs/i18n/placeholder';
import { z } from 'zod/v4'; import { z } from 'zod/v4';
/** Placeholder to trigger VSCode i18nAlly extension to show translated text. */
const t = identity;
/** /**
* Complete prompt configuration schema * Complete prompt configuration schema
* Defines a prompt with its metadata and content structure * Defines a prompt with its metadata and content structure

View file

@ -1,9 +1,6 @@
import { identity } from 'lodash'; import { t } from '@services/libs/i18n/placeholder';
import { z } from 'zod/v4'; import { z } from 'zod/v4';
/** Placeholder to trigger VSCode i18nAlly extension to show translated text. */
const t = identity;
/** /**
* Basic response configuration schema * Basic response configuration schema
* Defines identifiers for AI responses that can be referenced by response modifications * Defines identifiers for AI responses that can be referenced by response modifications

View file

@ -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<string, string> = {
'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('<tool_use name="test-tool">{"name":"John","age":25}</tool_use>');
expect(result).toContain('<tool_use name="test-tool">{"name":"Jane"}</tool_use>');
});
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")');
});
});

View file

@ -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<string, unknown>;
schemaTitle = s.title && typeof s.title === 'string'
? s.title
: '';
schemaDescription = s.description && typeof s.description === 'string'
? s.description
: '';
const props = s.properties as Record<string, unknown> | 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<string, unknown> | 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<string, unknown>).title)
? String((schemaUnknown as Record<string, unknown>).title)
: 'tool';
let exampleSection = '';
if (schemaUnknown && typeof schemaUnknown === 'object' && schemaUnknown !== null) {
const s = schemaUnknown as Record<string, unknown>;
const ex = s.examples;
if (Array.isArray(ex)) {
exampleSection = ex
.map(exampleItem => `- <tool_use name="${toolId}">${JSON.stringify(exampleItem)}</tool_use>`)
.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;
}

View file

@ -242,6 +242,52 @@ export class DatabaseService implements IDatabaseService {
return this.dataSources.get(key)!; 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<void> {
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 * Close database connection for a given key
*/ */

View file

@ -61,6 +61,16 @@ export interface IDatabaseService {
* Close database connection * Close database connection
*/ */
closeAppDatabase(key: string, drop?: boolean): void; 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<void>;
} }
export const DatabaseServiceIPCDescriptor = { export const DatabaseServiceIPCDescriptor = {
@ -70,5 +80,7 @@ export const DatabaseServiceIPCDescriptor = {
initializeForApp: ProxyPropertyType.Function, initializeForApp: ProxyPropertyType.Function,
getDatabase: ProxyPropertyType.Function, getDatabase: ProxyPropertyType.Function,
closeAppDatabase: ProxyPropertyType.Function, closeAppDatabase: ProxyPropertyType.Function,
getDatabaseInfo: ProxyPropertyType.Function,
deleteDatabase: ProxyPropertyType.Function,
}, },
}; };

View file

@ -77,7 +77,16 @@ export function streamFromProvider(
return streamText({ return streamText({
model: client(model), model: client(model),
system: systemPrompt, 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, temperature,
abortSignal: signal, abortSignal: signal,
}); });

View file

@ -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;

View file

@ -87,6 +87,11 @@ export function DeveloperTools(props: ISectionProps): React.JSX.Element {
disabled={preference === undefined} disabled={preference === undefined}
onChange={async () => { onChange={async () => {
await window.service.preference.set('externalAPIDebug', !preference?.externalAPIDebug); 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' name='externalAPIDebug'
/> />

View file

@ -138,8 +138,6 @@ export function Search(props: SearchProps): React.JSX.Element {
// Get AI config from external API service // Get AI config from external API service
const aiConfig = await window.service.externalAPI.getAIConfig(); const aiConfig = await window.service.externalAPI.getAIConfig();
// DEBUG: console aiConfig
console.log(`aiConfig`, aiConfig);
if (!aiConfig.api.provider) { if (!aiConfig.api.provider) {
showInfoSnackbar({ showInfoSnackbar({
message: t('Preference.SearchEmbeddingNoAIConfigError'), message: t('Preference.SearchEmbeddingNoAIConfigError'),