diff --git a/package.json b/package.json index 71999897bc..2734e2104f 100644 --- a/package.json +++ b/package.json @@ -345,6 +345,7 @@ "oxlint-tsgolint": "^0.2.0", "p-queue": "^8.1.0", "pako": "1.0.11", + "partial-json": "0.1.7", "pdf-lib": "^1.17.1", "pdf-parse": "^1.1.1", "prosemirror-model": "1.25.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be9d098dea..89033cde4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -914,6 +914,9 @@ importers: pako: specifier: 1.0.11 version: 1.0.11 + partial-json: + specifier: 0.1.7 + version: 0.1.7 pdf-lib: specifier: ^1.17.1 version: 1.17.1 @@ -9736,6 +9739,9 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + partial-json@0.1.7: + resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -17666,11 +17672,11 @@ snapshots: - utf-8-validate - vite - '@vitest/browser@3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(playwright@1.57.0)(rolldown-vite@7.3.0(@types/node@22.17.2)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4)': + '@vitest/browser@3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(playwright@1.57.0)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4)': dependencies: '@testing-library/dom': 10.4.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) - '@vitest/mocker': 3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(rolldown-vite@7.3.0(@types/node@22.17.2)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/utils': 3.2.4 magic-string: 0.30.21 sirv: 3.0.2 @@ -17724,14 +17730,14 @@ snapshots: msw: 2.12.7(@types/node@22.17.2)(typescript@5.8.3) vite: rolldown-vite@7.3.0(@types/node@22.17.2)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/mocker@3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(rolldown-vite@7.3.0(@types/node@22.17.2)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.7(@types/node@24.10.4)(typescript@5.8.3) - vite: rolldown-vite@7.3.0(@types/node@22.17.2)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) + vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) '@vitest/pretty-format@3.2.4': dependencies: @@ -17762,7 +17768,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.17.2)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(esbuild@0.25.12)(jiti@2.6.1)(jsdom@26.1.0)(msw@2.12.7(@types/node@22.17.2)(typescript@5.8.3))(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(esbuild@0.25.12)(jiti@2.6.1)(jsdom@26.1.0)(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(tsx@4.21.0)(yaml@2.8.2) '@vitest/utils@3.2.4': dependencies: @@ -22557,6 +22563,8 @@ snapshots: parseurl@1.3.3: {} + partial-json@0.1.7: {} + path-browserify@1.0.1: {} path-data-parser@0.1.0: {} @@ -25127,7 +25135,7 @@ snapshots: dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(rolldown-vite@7.3.0(@types/node@22.17.2)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -25151,7 +25159,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 24.10.4 - '@vitest/browser': 3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(playwright@1.57.0)(rolldown-vite@7.3.0(@types/node@22.17.2)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4) + '@vitest/browser': 3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(playwright@1.57.0)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4) '@vitest/ui': 3.2.4(vitest@3.2.4) jsdom: 26.1.0 transitivePeerDependencies: diff --git a/src/main/services/agents/services/claudecode/index.ts b/src/main/services/agents/services/claudecode/index.ts index 69266f5a61..23bed50518 100644 --- a/src/main/services/agents/services/claudecode/index.ts +++ b/src/main/services/agents/services/claudecode/index.ts @@ -16,6 +16,7 @@ import { loggerService } from '@logger' import { config as apiConfigService } from '@main/apiServer/config' import { validateModelId } from '@main/apiServer/utils' import { isWin } from '@main/constant' +import { configManager } from '@main/services/ConfigManager' import { autoDiscoverGitBash } from '@main/utils/process' import getLoginShellEnvironment from '@main/utils/shell-env' import { withoutTrailingApiVersion } from '@shared/utils' @@ -34,6 +35,9 @@ const DEFAULT_AUTO_ALLOW_TOOLS = new Set(['Read', 'Glob', 'Grep']) const shouldAutoApproveTools = process.env.CHERRY_AUTO_ALLOW_TOOLS === '1' const NO_RESUME_COMMANDS = ['/clear'] +const getLanguageInstruction = () => + `IMPORTANT: You MUST use ${configManager.getLanguage()} language for ALL your outputs, including: (1) text responses, (2) tool call parameters like "description" fields, and (3) any user-facing content. Never use English unless the content is code, file paths, or technical identifiers.` + type UserInputMessage = { type: 'user' parent_tool_use_id: string | null @@ -255,9 +259,13 @@ class ClaudeCodeService implements AgentServiceInterface { ? { type: 'preset', preset: 'claude_code', - append: session.instructions + append: `${session.instructions}\n\n${getLanguageInstruction()}` } - : { type: 'preset', preset: 'claude_code' }, + : { + type: 'preset', + preset: 'claude_code', + append: getLanguageInstruction() + }, settingSources: ['project'], includePartialMessages: true, permissionMode: session.configuration?.permission_mode, diff --git a/src/main/services/agents/services/claudecode/transform.ts b/src/main/services/agents/services/claudecode/transform.ts index 00be683ba8..f13f537e57 100644 --- a/src/main/services/agents/services/claudecode/transform.ts +++ b/src/main/services/agents/services/claudecode/transform.ts @@ -77,7 +77,10 @@ const generateMessageId = (): string => `msg_${uuidv4().replace(/-/g, '')}` * Removes any local command stdout/stderr XML wrappers that should never surface to the UI. */ export const stripLocalCommandTags = (text: string): string => { - return text.replace(/(.*?)<\/local-command-\1>/gs, '$2') + return text + .replace(/(.*?)<\/local-command-\1>/gs, '$2') + .replace('(no content)', '') + .trim() } /** @@ -321,7 +324,46 @@ function handleUserMessage( const chunks: AgentStreamPart[] = [] const providerMetadata = sdkMessageToProviderMetadata(message) const content = message.message.content - const isSynthetic = message.isSynthetic ?? false + + // Check if content contains tool_result blocks (synthetic tool result messages) + // This handles both SDK-flagged messages and standard tool_result content + const contentArray = Array.isArray(content) ? content : [] + const hasToolResults = contentArray.some((block: any) => block.type === 'tool_result') + + if (hasToolResults || message.tool_use_result || message.parent_tool_use_id) { + if (!Array.isArray(content)) { + return chunks + } + for (const block of content) { + if (block.type === 'tool_result') { + const toolResult = block as ToolResultContent + const pendingCall = state.consumePendingToolCall(toolResult.tool_use_id) + const toolCallId = pendingCall?.toolCallId ?? state.getNamespacedToolCallId(toolResult.tool_use_id) + if (toolResult.is_error) { + chunks.push({ + type: 'tool-error', + toolCallId, + toolName: pendingCall?.toolName ?? 'unknown', + input: pendingCall?.input, + error: toolResult.content, + providerExecuted: true + } as AgentStreamPart) + } else { + chunks.push({ + type: 'tool-result', + toolCallId, + toolName: pendingCall?.toolName ?? 'unknown', + input: pendingCall?.input, + output: toolResult.content, + providerExecuted: true + }) + } + } + } + return chunks + } + + // For non-synthetic messages (user-initiated content), render text content if (typeof content === 'string') { if (!content) { return chunks @@ -352,39 +394,12 @@ function handleUserMessage( return chunks } - if (!Array.isArray(content)) { - return chunks - } - + // For non-synthetic array content, render text blocks for (const block of content) { - if (block.type === 'tool_result') { - const toolResult = block as ToolResultContent - const pendingCall = state.consumePendingToolCall(toolResult.tool_use_id) - const toolCallId = pendingCall?.toolCallId ?? state.getNamespacedToolCallId(toolResult.tool_use_id) - if (toolResult.is_error) { - chunks.push({ - type: 'tool-error', - toolCallId, - toolName: pendingCall?.toolName ?? 'unknown', - input: pendingCall?.input, - error: toolResult.content, - providerExecuted: true - } as AgentStreamPart) - } else { - chunks.push({ - type: 'tool-result', - toolCallId, - toolName: pendingCall?.toolName ?? 'unknown', - input: pendingCall?.input, - output: toolResult.content, - providerExecuted: true - }) - } - } else if (block.type === 'text' && !isSynthetic) { + if (block.type === 'text') { const rawText = (block as { text: string }).text const filteredText = filterCommandTags(rawText) - // Only push text chunks if there's content after filtering if (filteredText) { const id = message.uuid?.toString() || generateMessageId() chunks.push({ @@ -404,8 +419,6 @@ function handleUserMessage( providerMetadata }) } - } else { - logger.warn('Unhandled user content block', { type: (block as any).type }) } } diff --git a/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts b/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts index 5d418de08b..78f5d6b36c 100644 --- a/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts +++ b/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts @@ -238,15 +238,16 @@ export class AiSdkToChunkAdapter { // === 工具调用相关事件(原始 AI SDK 事件,如果没有被中间件处理) === - // case 'tool-input-start': - // case 'tool-input-delta': - // case 'tool-input-end': - // this.toolCallHandler.handleToolCallCreated(chunk) - // break + case 'tool-input-start': + this.toolCallHandler.handleToolInputStart(chunk) + break + case 'tool-input-delta': + this.toolCallHandler.handleToolInputDelta(chunk) + break + case 'tool-input-end': + this.toolCallHandler.handleToolInputEnd(chunk) + break - // case 'tool-input-delta': - // this.toolCallHandler.handleToolCallCreated(chunk) - // break case 'tool-call': this.toolCallHandler.handleToolCall(chunk) break diff --git a/src/renderer/src/aiCore/chunk/handleToolCallChunk.ts b/src/renderer/src/aiCore/chunk/handleToolCallChunk.ts index b5acbb690b..61ec029957 100644 --- a/src/renderer/src/aiCore/chunk/handleToolCallChunk.ts +++ b/src/renderer/src/aiCore/chunk/handleToolCallChunk.ts @@ -5,18 +5,12 @@ */ import { loggerService } from '@logger' +import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js' import { processKnowledgeReferences } from '@renderer/services/KnowledgeService' -import type { - BaseTool, - MCPCallToolResponse, - MCPTool, - MCPToolResponse, - MCPToolResultContent, - NormalToolResponse -} from '@renderer/types' +import type { BaseTool, MCPTool, MCPToolResponse, NormalToolResponse } from '@renderer/types' import type { Chunk } from '@renderer/types/chunk' import { ChunkType } from '@renderer/types/chunk' -import type { ToolSet, TypedToolCall, TypedToolError, TypedToolResult } from 'ai' +import type { ProviderMetadata, ToolSet, TypedToolCall, TypedToolError, TypedToolResult } from 'ai' const logger = loggerService.withContext('ToolCallChunkHandler') @@ -26,6 +20,8 @@ export type ToolcallsMap = { args: any // mcpTool 现在可以是 MCPTool 或我们为 Provider 工具创建的通用类型 tool: BaseTool + // Streaming arguments buffer + streamingArgs?: string } /** * 工具调用处理器类 @@ -71,110 +67,169 @@ export class ToolCallChunkHandler { return ToolCallChunkHandler.addActiveToolCallImpl(toolCallId, map) } - // /** - // * 设置 onChunk 回调 - // */ - // public setOnChunk(callback: (chunk: Chunk) => void): void { - // this.onChunk = callback - // } + /** + * 根据工具名称确定工具类型 + */ + private determineToolType(toolName: string, toolCallId: string): BaseTool { + let mcpTool: MCPTool | undefined + if (toolName.startsWith('builtin_')) { + return { + id: toolCallId, + name: toolName, + description: toolName, + type: 'builtin' + } as BaseTool + } else if ((mcpTool = this.mcpTools.find((t) => t.id === toolName) as MCPTool)) { + return mcpTool + } else { + return { + id: toolCallId, + name: toolName, + description: toolName, + type: 'provider' + } + } + } - // handleToolCallCreated( - // chunk: - // | { - // type: 'tool-input-start' - // id: string - // toolName: string - // providerMetadata?: ProviderMetadata - // providerExecuted?: boolean - // } - // | { - // type: 'tool-input-end' - // id: string - // providerMetadata?: ProviderMetadata - // } - // | { - // type: 'tool-input-delta' - // id: string - // delta: string - // providerMetadata?: ProviderMetadata - // } - // ): void { - // switch (chunk.type) { - // case 'tool-input-start': { - // // 能拿到说明是mcpTool - // // if (this.activeToolCalls.get(chunk.id)) return + /** + * 处理工具输入开始事件 - 流式参数开始 + */ + public handleToolInputStart(chunk: { + type: 'tool-input-start' + id: string + toolName: string + providerMetadata?: ProviderMetadata + providerExecuted?: boolean + }): void { + const { id: toolCallId, toolName, providerExecuted } = chunk - // const tool: BaseTool | MCPTool = { - // id: chunk.id, - // name: chunk.toolName, - // description: chunk.toolName, - // type: chunk.toolName.startsWith('builtin_') ? 'builtin' : 'provider' - // } - // this.activeToolCalls.set(chunk.id, { - // toolCallId: chunk.id, - // toolName: chunk.toolName, - // args: '', - // tool - // }) - // const toolResponse: MCPToolResponse | NormalToolResponse = { - // id: chunk.id, - // tool: tool, - // arguments: {}, - // status: 'pending', - // toolCallId: chunk.id - // } - // this.onChunk({ - // type: ChunkType.MCP_TOOL_PENDING, - // responses: [toolResponse] - // }) - // break - // } - // case 'tool-input-delta': { - // const toolCall = this.activeToolCalls.get(chunk.id) - // if (!toolCall) { - // logger.warn(`🔧 [ToolCallChunkHandler] Tool call not found: ${chunk.id}`) - // return - // } - // toolCall.args += chunk.delta - // break - // } - // case 'tool-input-end': { - // const toolCall = this.activeToolCalls.get(chunk.id) - // this.activeToolCalls.delete(chunk.id) - // if (!toolCall) { - // logger.warn(`🔧 [ToolCallChunkHandler] Tool call not found: ${chunk.id}`) - // return - // } - // // const toolResponse: ToolCallResponse = { - // // id: toolCall.toolCallId, - // // tool: toolCall.tool, - // // arguments: toolCall.args, - // // status: 'pending', - // // toolCallId: toolCall.toolCallId - // // } - // // logger.debug('toolResponse', toolResponse) - // // this.onChunk({ - // // type: ChunkType.MCP_TOOL_PENDING, - // // responses: [toolResponse] - // // }) - // break - // } - // } - // // if (!toolCall) { - // // Logger.warn(`🔧 [ToolCallChunkHandler] Tool call not found: ${chunk.id}`) - // // return - // // } - // // this.onChunk({ - // // type: ChunkType.MCP_TOOL_CREATED, - // // tool_calls: [ - // // { - // // id: chunk.id, - // // name: chunk.toolName, - // // status: 'pending' - // // } - // // ] - // // }) - // } + if (!toolCallId || !toolName) { + logger.warn(`🔧 [ToolCallChunkHandler] Invalid tool-input-start chunk: missing id or toolName`) + return + } + + // 如果已存在,跳过 + if (this.activeToolCalls.has(toolCallId)) { + return + } + + let tool: BaseTool + if (providerExecuted) { + tool = { + id: toolCallId, + name: toolName, + description: toolName, + type: 'provider' + } as BaseTool + } else { + tool = this.determineToolType(toolName, toolCallId) + } + + // 初始化流式工具调用 + this.addActiveToolCall(toolCallId, { + toolCallId, + toolName, + args: undefined, + tool, + streamingArgs: '' + }) + + logger.info(`🔧 [ToolCallChunkHandler] Tool input streaming started: ${toolName} (${toolCallId})`) + + // 发送初始 streaming chunk + const toolResponse: MCPToolResponse | NormalToolResponse = { + id: toolCallId, + tool: tool, + arguments: undefined, + status: 'streaming', + toolCallId: toolCallId, + partialArguments: '' + } + + this.onChunk({ + type: ChunkType.MCP_TOOL_STREAMING, + responses: [toolResponse] + }) + } + + /** + * 处理工具输入增量事件 - 流式参数片段 + */ + public handleToolInputDelta(chunk: { + type: 'tool-input-delta' + id: string + delta: string + providerMetadata?: ProviderMetadata + }): void { + const { id: toolCallId, delta } = chunk + + const toolCall = this.activeToolCalls.get(toolCallId) + if (!toolCall) { + logger.warn(`🔧 [ToolCallChunkHandler] Tool call not found for delta: ${toolCallId}`) + return + } + + // 累积流式参数 + toolCall.streamingArgs = (toolCall.streamingArgs || '') + delta + + // 发送 streaming chunk 更新 + const toolResponse: MCPToolResponse | NormalToolResponse = { + id: toolCallId, + tool: toolCall.tool, + arguments: undefined, + status: 'streaming', + toolCallId: toolCallId, + partialArguments: toolCall.streamingArgs + } + + this.onChunk({ + type: ChunkType.MCP_TOOL_STREAMING, + responses: [toolResponse] + }) + } + + /** + * 处理工具输入结束事件 - 流式参数完成 + */ + public handleToolInputEnd(chunk: { type: 'tool-input-end'; id: string; providerMetadata?: ProviderMetadata }): void { + const { id: toolCallId } = chunk + + const toolCall = this.activeToolCalls.get(toolCallId) + if (!toolCall) { + logger.warn(`🔧 [ToolCallChunkHandler] Tool call not found for end: ${toolCallId}`) + return + } + + // 尝试解析完整的 JSON 参数 + let parsedArgs: any = undefined + if (toolCall.streamingArgs) { + try { + parsedArgs = JSON.parse(toolCall.streamingArgs) + toolCall.args = parsedArgs + } catch (e) { + logger.warn(`🔧 [ToolCallChunkHandler] Failed to parse streaming args for ${toolCallId}:`, e as Error) + // 保留原始字符串 + toolCall.args = toolCall.streamingArgs + } + } + + logger.info(`🔧 [ToolCallChunkHandler] Tool input streaming completed: ${toolCall.toolName} (${toolCallId})`) + + // 发送 streaming 完成 chunk + const toolResponse: MCPToolResponse | NormalToolResponse = { + id: toolCallId, + tool: toolCall.tool, + arguments: parsedArgs, + status: 'pending', + toolCallId: toolCallId, + partialArguments: toolCall.streamingArgs + } + + this.onChunk({ + type: ChunkType.MCP_TOOL_STREAMING, + responses: [toolResponse] + }) + } /** * 处理工具调用事件 @@ -191,6 +246,15 @@ export class ToolCallChunkHandler { return } + // Check if this tool call was already processed via streaming events + const existingToolCall = this.activeToolCalls.get(toolCallId) + if (existingToolCall?.streamingArgs !== undefined) { + // Tool call was already processed via streaming events (tool-input-start/delta/end) + // Update args if needed, but don't emit duplicate pending chunk + existingToolCall.args = args + return + } + let tool: BaseTool let mcpTool: MCPTool | undefined // 根据 providerExecuted 标志区分处理逻辑 @@ -216,11 +280,6 @@ export class ToolCallChunkHandler { // 如果是客户端执行的 MCP 工具,沿用现有逻辑 // toolName is mcpTool.id (registered with id as key in convertMcpToolsToAiSdkTools) logger.info(`[ToolCallChunkHandler] Handling client-side MCP tool: ${toolName}`) - // mcpTool = this.mcpTools.find((t) => t.name === toolName) as MCPTool - // if (!mcpTool) { - // logger.warn(`[ToolCallChunkHandler] MCP tool not found: ${toolName}`) - // return - // } tool = mcpTool } else { tool = { @@ -357,40 +416,20 @@ export class ToolCallChunkHandler { export const addActiveToolCall = ToolCallChunkHandler.addActiveToolCall.bind(ToolCallChunkHandler) +/** + * 从工具输出中提取图片(使用 MCP SDK 类型安全验证) + */ function extractImagesFromToolOutput(output: unknown): string[] { if (!output) { return [] } - const contents: unknown[] = [] - - if (isMcpCallToolResponse(output)) { - contents.push(...output.content) - } else if (Array.isArray(output)) { - contents.push(...output) - } else if (hasContentArray(output)) { - contents.push(...output.content) + const result = CallToolResultSchema.safeParse(output) + if (result.success) { + return result.data.content + .filter((c) => c.type === 'image') + .map((content) => `data:${content.mimeType ?? 'image/png'};base64,${content.data}`) } - return contents - .filter(isMcpImageContent) - .map((content) => `data:${content.mimeType ?? 'image/png'};base64,${content.data}`) -} - -function isMcpCallToolResponse(value: unknown): value is MCPCallToolResponse { - return typeof value === 'object' && value !== null && Array.isArray((value as MCPCallToolResponse).content) -} - -function hasContentArray(value: unknown): value is { content: unknown[] } { - return typeof value === 'object' && value !== null && Array.isArray((value as { content?: unknown }).content) -} - -function isMcpImageContent(content: unknown): content is MCPToolResultContent & { data: string } { - if (typeof content !== 'object' || content === null) { - return false - } - - const resultContent = content as MCPToolResultContent - - return resultContent.type === 'image' && typeof resultContent.data === 'string' + return [] } diff --git a/src/renderer/src/hooks/useAppInit.ts b/src/renderer/src/hooks/useAppInit.ts index 5fe164f2c2..3a4b7fdd86 100644 --- a/src/renderer/src/hooks/useAppInit.ts +++ b/src/renderer/src/hooks/useAppInit.ts @@ -185,17 +185,13 @@ export function useAppInit() { suggestionCount: payload.suggestions.length, autoApprove: payload.autoApprove }) - dispatch(toolPermissionsActions.requestReceived(payload)) - // Auto-approve if requested if (payload.autoApprove) { logger.debug('Auto-approving tool permission request', { requestId: payload.requestId, toolName: payload.toolName }) - dispatch(toolPermissionsActions.submissionSent({ requestId: payload.requestId, behavior: 'allow' })) - try { const response = await window.api.agentTools.respondToPermission({ requestId: payload.requestId, @@ -214,9 +210,13 @@ export function useAppInit() { }) } catch (error) { logger.error('Failed to send auto-approval response', error as Error) - dispatch(toolPermissionsActions.submissionFailed({ requestId: payload.requestId })) + // Fall through to add to store for manual approval + dispatch(toolPermissionsActions.requestReceived(payload)) } + return } + + dispatch(toolPermissionsActions.requestReceived(payload)) } const resultListener = (_event: Electron.IpcRendererEvent, payload: ToolPermissionResultPayload) => { diff --git a/src/renderer/src/pages/home/Messages/Blocks/ToolBlockGroup.tsx b/src/renderer/src/pages/home/Messages/Blocks/ToolBlockGroup.tsx new file mode 100644 index 0000000000..534ac96779 --- /dev/null +++ b/src/renderer/src/pages/home/Messages/Blocks/ToolBlockGroup.tsx @@ -0,0 +1,292 @@ +import type { MCPToolResponseStatus } from '@renderer/types' +import type { ToolMessageBlock } from '@renderer/types/newMessage' +import { Collapse, type CollapseProps } from 'antd' +import { Wrench } from 'lucide-react' +import { AnimatePresence, motion } from 'motion/react' +import React, { useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +import { useToolApproval } from '../Tools/hooks/useToolApproval' +import MessageTools from '../Tools/MessageTools' +import ToolApprovalActionsComponent from '../Tools/ToolApprovalActions' +import ToolHeader from '../Tools/ToolHeader' + +// ============ Styled Components ============ + +const Container = styled.div` + width: fit-content; + max-width: 100%; + + /* Only style the direct group collapse, not nested tool collapses */ + > .ant-collapse { + background: transparent; + border: none; + + > .ant-collapse-item { + border: none !important; + + > .ant-collapse-header { + padding: 8px 12px !important; + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 0.75rem !important; + display: flex; + align-items: center; + + .ant-collapse-expand-icon { + padding: 0 !important; + margin-left: 8px; + height: auto !important; + } + } + + > .ant-collapse-content { + border: none; + background: transparent; + + > .ant-collapse-content-box { + padding: 4px 0 0 0 !important; + display: flex; + flex-direction: column; + gap: 4px; + } + } + } + } +` + +const GroupHeader = styled.div` + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 500; + + .tool-icon { + color: var(--color-primary); + } + + .tool-count { + color: var(--color-text-1); + } +` + +const ScrollableToolList = styled.div` + max-height: 300px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 4px; +` + +const ToolItem = styled.div<{ $isCompleted: boolean }>` + opacity: ${(props) => (props.$isCompleted ? 0.7 : 1)}; + transition: opacity 0.2s; +` + +const AnimatedHeaderWrapper = styled(motion.div)` + display: inline-block; +` + +const HeaderWithActions = styled.div` + display: flex; + align-items: center; + gap: 8px; + width: 100%; + justify-content: space-between; +` + +// ============ Types & Helpers ============ + +interface Props { + blocks: ToolMessageBlock[] +} + +function isCompletedStatus(status: MCPToolResponseStatus | undefined): boolean { + return status === 'done' || status === 'error' || status === 'cancelled' +} + +function isWaitingStatus(status: MCPToolResponseStatus | undefined): boolean { + return status === 'pending' +} + +// Animation variants for smooth header transitions +const headerVariants = { + enter: { x: 20, opacity: 0 }, + center: { x: 0, opacity: 1, transition: { duration: 0.2, ease: 'easeOut' as const } }, + exit: { x: -20, opacity: 0, transition: { duration: 0.15 } } +} + +// ============ Sub-Components ============ + +// Component for rendering a block with approval actions +interface WaitingToolHeaderProps { + block: ToolMessageBlock +} + +const WaitingToolHeader = React.memo(({ block }: WaitingToolHeaderProps) => { + const approval = useToolApproval(block) + + return ( + + + {(approval.isWaiting || approval.isExecuting) && } + + ) +}) +WaitingToolHeader.displayName = 'WaitingToolHeader' + +interface GroupHeaderContentProps { + blocks: ToolMessageBlock[] + allCompleted: boolean +} + +const GroupHeaderContent = React.memo(({ blocks, allCompleted }: GroupHeaderContentProps) => { + const { t } = useTranslation() + + if (allCompleted) { + return ( + + + {t('message.tools.groupHeader', { count: blocks.length })} + + ) + } + + // Find blocks needing approval (pending status) + const waitingBlocks = blocks.filter((block) => { + const status = block.metadata?.rawMcpToolResponse?.status + return isWaitingStatus(status) + }) + + // Prioritize showing waiting blocks that need approval + const lastWaitingBlock = waitingBlocks[waitingBlocks.length - 1] + if (lastWaitingBlock) { + return ( + + + + + + ) + } + + const runningBlocks = blocks.filter((block) => { + const status = block.metadata?.rawMcpToolResponse?.status + return !isCompletedStatus(status) && !isWaitingStatus(status) + }) + + // Get the last running block (most recent) and render with animation + const lastRunningBlock = runningBlocks[runningBlocks.length - 1] + if (lastRunningBlock) { + return ( + + + + + + ) + } + + // Fallback + return ( + + + {t('message.tools.groupHeader', { count: blocks.length })} + + ) +}) +GroupHeaderContent.displayName = 'GroupHeaderContent' + +// Component for tool list content with auto-scroll +interface ToolListContentProps { + blocks: ToolMessageBlock[] + scrollRef: React.RefObject +} + +const ToolListContent = React.memo(({ blocks, scrollRef }: ToolListContentProps) => ( + + {blocks.map((block) => { + const status = block.metadata?.rawMcpToolResponse?.status + const isCompleted = isCompletedStatus(status) + return ( + + + + ) + })} + +)) +ToolListContent.displayName = 'ToolListContent' + +// ============ Main Component ============ + +const ToolBlockGroup: React.FC = ({ blocks }) => { + const [activeKey, setActiveKey] = useState([]) + const scrollRef = useRef(null) + const userExpandedRef = useRef(false) + + const allCompleted = useMemo(() => { + return blocks.every((block) => { + const status = block.metadata?.rawMcpToolResponse?.status + return isCompletedStatus(status) + }) + }, [blocks]) + + const currentRunningBlock = useMemo(() => { + return blocks.find((block) => { + const status = block.metadata?.rawMcpToolResponse?.status + return !isCompletedStatus(status) + }) + }, [blocks]) + + useEffect(() => { + if (activeKey.includes('tool-group') && currentRunningBlock && scrollRef.current) { + const element = scrollRef.current.querySelector(`[data-block-id="${currentRunningBlock.id}"]`) + element?.scrollIntoView({ behavior: 'smooth', block: 'center' }) + } + }, [activeKey, currentRunningBlock]) + + const handleChange = (keys: string | string[]) => { + const keyArray = Array.isArray(keys) ? keys : [keys] + const isExpanding = keyArray.includes('tool-group') + userExpandedRef.current = isExpanding + setActiveKey(keyArray) + } + + const items: CollapseProps['items'] = useMemo(() => { + return [ + { + key: 'tool-group', + label: , + children: + } + ] + }, [blocks, allCompleted]) + + return ( + + + + ) +} + +export default React.memo(ToolBlockGroup) diff --git a/src/renderer/src/pages/home/Messages/Blocks/index.tsx b/src/renderer/src/pages/home/Messages/Blocks/index.tsx index d2771b36f6..349faf53e1 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/index.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/index.tsx @@ -3,7 +3,7 @@ import type { RootState } from '@renderer/store' import { messageBlocksSelectors } from '@renderer/store/messageBlock' import type { ImageMessageBlock, Message, MessageBlock } from '@renderer/types/newMessage' import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage' -import { isMainTextBlock, isMessageProcessing, isVideoBlock } from '@renderer/utils/messageUtils/is' +import { isMainTextBlock, isMessageProcessing, isToolBlock, isVideoBlock } from '@renderer/utils/messageUtils/is' import { AnimatePresence, motion, type Variants } from 'motion/react' import React, { useMemo } from 'react' import { useSelector } from 'react-redux' @@ -18,6 +18,7 @@ import MainTextBlock from './MainTextBlock' import PlaceholderBlock from './PlaceholderBlock' import ThinkingBlock from './ThinkingBlock' import ToolBlock from './ToolBlock' +import ToolBlockGroup from './ToolBlockGroup' import TranslationBlock from './TranslationBlock' import VideoBlock from './VideoBlock' @@ -94,6 +95,14 @@ const groupSimilarBlocks = (blocks: MessageBlock[]): (MessageBlock[] | MessageBl } else { acc.push([currentBlock]) } + } else if (currentBlock.type === MessageBlockType.TOOL) { + // 对于TOOL类型,按连续分组 + const prevGroup = acc[acc.length - 1] + if (Array.isArray(prevGroup) && prevGroup[0].type === MessageBlockType.TOOL) { + prevGroup.push(currentBlock) + } else { + acc.push([currentBlock]) + } } else { acc.push(currentBlock) } @@ -147,6 +156,29 @@ const MessageBlockRenderer: React.FC = ({ blocks, message }) => { ) + } else if (block[0].type === MessageBlockType.TOOL) { + // 对于连续的TOOL,使用分组显示 + if (block.length === 1) { + // 单个工具调用,直接渲染 + if (!isToolBlock(block[0])) { + logger.warn('Expected tool block but got different type', block[0]) + return null + } + return ( + + + + ) + } + // 多个工具调用,使用分组组件 + const toolBlocks = block.filter(isToolBlock) + // Use first block ID as stable key to prevent remounting when new blocks are added + const stableGroupKey = `tool-group-${toolBlocks[0].id}` + return ( + + + + ) } return null } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/BashOutputTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/BashOutputTool.tsx index 39d72abcf8..ed99ecc632 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/BashOutputTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/BashOutputTool.tsx @@ -1,8 +1,10 @@ import type { CollapseProps } from 'antd' import { Tag } from 'antd' import { CheckCircle, Terminal, XCircle } from 'lucide-react' +import { useTranslation } from 'react-i18next' -import { ToolTitle } from './GenericTools' +import { truncateOutput } from '../shared/truncateOutput' +import { ToolHeader, TruncatedIndicator } from './GenericTools' import type { BashOutputToolInput, BashOutputToolOutput } from './types' import { AgentToolsType } from './types' @@ -44,34 +46,6 @@ const parseBashOutput = (output?: BashOutputToolOutput): ParsedBashOutput | null } } -const getStatusConfig = (parsedOutput: ParsedBashOutput | null) => { - if (!parsedOutput) return null - - if (parsedOutput.tool_use_error) { - return { - color: 'danger', - icon: , - text: 'Error' - } as const - } - - const isCompleted = parsedOutput.status === 'completed' - const isSuccess = parsedOutput.exit_code === 0 - - return { - color: isCompleted && isSuccess ? 'success' : isCompleted && !isSuccess ? 'danger' : 'warning', - icon: - isCompleted && isSuccess ? ( - - ) : isCompleted && !isSuccess ? ( - - ) : ( - - ), - text: isCompleted ? (isSuccess ? 'Success' : 'Failed') : 'Running' - } as const -} - export function BashOutputTool({ input, output @@ -79,15 +53,62 @@ export function BashOutputTool({ input?: BashOutputToolInput output?: BashOutputToolOutput }): NonNullable[number] { + const { t } = useTranslation() const parsedOutput = parseBashOutput(output) + + const getStatusConfig = (parsed: ParsedBashOutput | null) => { + if (!parsed) return null + + if (parsed.tool_use_error) { + return { + color: 'danger', + icon: , + text: t('message.tools.status.error') + } as const + } + + const isCompleted = parsed.status === 'completed' + const isSuccess = parsed.exit_code === 0 + + if (isCompleted && isSuccess) { + return { + color: 'success', + icon: , + text: t('message.tools.status.success') + } as const + } + + if (isCompleted) { + return { + color: 'danger', + icon: , + text: t('message.tools.status.failed') + } as const + } + + return { + color: 'warning', + icon: , + text: t('message.tools.status.running') + } as const + } + const statusConfig = getStatusConfig(parsedOutput) + // Truncate stdout and stderr separately + const truncatedStdout = truncateOutput(parsedOutput?.stdout) + const truncatedStderr = truncateOutput(parsedOutput?.stderr) + const truncatedError = truncateOutput(parsedOutput?.tool_use_error) + const truncatedRawOutput = truncateOutput(output) + const children = parsedOutput ? (
{/* Status Info */}
{parsedOutput.exit_code !== undefined && ( - Exit Code: {parsedOutput.exit_code} + + {t('message.tools.sections.exitCode')}: {parsedOutput.exit_code} + )} {parsedOutput.timestamp && ( {new Date(parsedOutput.timestamp).toLocaleString()} @@ -95,73 +116,78 @@ export function BashOutputTool({
{/* Standard Output */} - {parsedOutput.stdout && ( + {truncatedStdout.data && (
-
stdout:
+
{t('message.tools.sections.stdout')}:
-            {parsedOutput.stdout}
+            {truncatedStdout.data}
           
+ {truncatedStdout.isTruncated && }
)} {/* Standard Error */} - {parsedOutput.stderr && ( + {truncatedStderr.data && (
-
stderr:
+
{t('message.tools.sections.stderr')}:
-            {parsedOutput.stderr}
+            {truncatedStderr.data}
           
+ {truncatedStderr.isTruncated && }
)} {/* Tool Use Error */} - {parsedOutput.tool_use_error && ( + {truncatedError.data && (
- Error: + {t('message.tools.status.error')}:
-            {parsedOutput.tool_use_error}
+            {truncatedError.data}
           
+ {truncatedError.isTruncated && }
)}
) : ( // 原始输出(如果解析失败或非 XML 格式) - output && ( + truncatedRawOutput.data && (
-
{output}
+
+          {truncatedRawOutput.data}
+        
+ {truncatedRawOutput.isTruncated && }
) ) return { key: AgentToolsType.BashOutput, label: ( - <> - } - label="Bash Output" - params={ -
- {input?.bash_id} - {statusConfig && ( - - {statusConfig.text} - - )} -
- } - /> - + + {input?.bash_id} + {statusConfig && ( + + {statusConfig.text} + + )} + + } + variant="collapse-label" + showStatus={false} + /> ), children: children diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/BashTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/BashTool.tsx index 798807d4d6..f3d56a6067 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/BashTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/BashTool.tsx @@ -1,9 +1,13 @@ import type { CollapseProps } from 'antd' -import { Popover, Tag } from 'antd' -import { Terminal } from 'lucide-react' +import { useTranslation } from 'react-i18next' -import { ToolTitle } from './GenericTools' -import type { BashToolInput as BashToolInputType, BashToolOutput as BashToolOutputType } from './types' +import { truncateOutput } from '../shared/truncateOutput' +import { SkeletonValue, ToolHeader, TruncatedIndicator } from './GenericTools' +import { + AgentToolsType, + type BashToolInput as BashToolInputType, + type BashToolOutput as BashToolOutputType +} from './types' export function BashTool({ input, @@ -12,33 +16,45 @@ export function BashTool({ input?: BashToolInputType output?: BashToolOutputType }): NonNullable[number] { - // 如果有输出,计算输出行数 - const outputLines = output ? output.split('\n').length : 0 - - // 处理命令字符串,添加空值检查 - const command = input?.command ?? '' - - const tagContent = {command} + const { t } = useTranslation() + const command = input?.command + const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(output) return { key: 'tool', label: ( - <> - } - label="Bash" - params={input?.description} - stats={output ? `${outputLines} ${outputLines === 1 ? 'line' : 'lines'}` : undefined} - /> -
- {command}
} - trigger="hover"> - {tagContent} - - - + } + variant="collapse-label" + showStatus={false} + /> ), - children:
{output}
+ children: ( +
+ {/* Command 输入区域 */} + {command && ( +
+
{t('message.tools.sections.command')}
+
+ {command} +
+
+ )} + + {/* Output 输出区域 */} + {truncatedOutput ? ( +
+
{t('message.tools.sections.output')}
+
+
{truncatedOutput}
+
+ {isTruncated && } +
+ ) : ( + + )} +
+ ) } } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/EditTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/EditTool.tsx index 3eff8118ef..c01aae2cd6 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/EditTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/EditTool.tsx @@ -1,7 +1,6 @@ import type { CollapseProps } from 'antd' -import { FileEdit } from 'lucide-react' -import { ToolTitle } from './GenericTools' +import { ToolHeader } from './GenericTools' import type { EditToolInput, EditToolOutput } from './types' import { AgentToolsType } from './types' @@ -37,7 +36,14 @@ export function EditTool({ }): NonNullable[number] { return { key: AgentToolsType.Edit, - label: } label="Edit" params={input?.file_path} />, + label: ( + + ), children: ( <> {/* Diff View */} diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ExitPlanModeTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ExitPlanModeTool.tsx index f92116478d..e4609b3ee8 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ExitPlanModeTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ExitPlanModeTool.tsx @@ -1,8 +1,9 @@ import type { CollapseProps } from 'antd' -import { DoorOpen } from 'lucide-react' +import { useTranslation } from 'react-i18next' import ReactMarkdown from 'react-markdown' -import { ToolTitle } from './GenericTools' +import { truncateOutput } from '../shared/truncateOutput' +import { ToolHeader, TruncatedIndicator } from './GenericTools' import type { ExitPlanModeToolInput, ExitPlanModeToolOutput } from './types' import { AgentToolsType } from './types' @@ -13,16 +14,27 @@ export function ExitPlanModeTool({ input?: ExitPlanModeToolInput output?: ExitPlanModeToolOutput }): NonNullable[number] { + const { t } = useTranslation() const plan = input?.plan ?? '' + const combinedContent = plan + '\n\n' + (output ?? '') + const { data: truncatedContent, isTruncated, originalLength } = truncateOutput(combinedContent) + const planCount = plan.split('\n\n').length + return { key: AgentToolsType.ExitPlanMode, label: ( - } - label="ExitPlanMode" - stats={`${plan.split('\n\n').length} plans`} + ), - children: {plan + '\n\n' + (output ?? '')} + children: ( +
+ {truncatedContent} + {isTruncated && } +
+ ) } } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GenericTools.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GenericTools.tsx index 2245730ce7..5b5775fbd7 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GenericTools.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GenericTools.tsx @@ -1,31 +1,60 @@ // 通用工具组件 - 减少重复代码 -import type { ReactNode } from 'react' +import { LoadingIcon } from '@renderer/components/Icons' +import type { MCPToolResponseStatus } from '@renderer/types' +import { formatFileSize } from '@renderer/utils/file' +import { Skeleton } from 'antd' +import { Check, Ellipsis, TriangleAlert, X } from 'lucide-react' +import { createContext, type ReactNode, use } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' -// 生成 AccordionItem 的标题 -export function ToolTitle({ - icon, - label, - params, - stats, - className = 'text-sm' -}: { - icon?: ReactNode - label: string - params?: string | ReactNode - stats?: string | ReactNode - className?: string -}) { +export { default as ToolHeader, type ToolHeaderProps } from '../ToolHeader' + +// Streaming context - 用于传递流式状态给子组件 +export const StreamingContext = createContext(false) +export const useIsStreaming = () => use(StreamingContext) + +export function SkeletonSpan({ width = '60px' }: { width?: string }) { return ( -
- {icon && {icon}} - {label && {label}} - {params && {params}} - {stats && {stats}} -
+ ) } +/** + * SkeletonValue - 流式时显示 skeleton,否则显示值 + */ +export function SkeletonValue({ + value, + width = '60px', + fallback +}: { + value: ReactNode + width?: string + fallback?: ReactNode +}) { + const isStreaming = useIsStreaming() + + if (value !== undefined && value !== null && value !== '') { + return <>{value} + } + + if (isStreaming) { + return + } + + return <>{fallback ?? ''} +} + // 纯字符串输入工具 (Task, Bash, Search) export function StringInputTool({ input, @@ -93,3 +122,112 @@ export function StringOutputTool({ ) } + +// ToolStatus extends MCPToolResponseStatus with UI-derived statuses +// 'waiting' is a UI status derived from 'pending' + needs approval +export type ToolStatus = MCPToolResponseStatus | 'waiting' + +/** + * Convert raw data layer status to UI display status + * @param status - Raw status from MCPToolResponseStatus + * @param isWaiting - Whether the tool is waiting for user approval + * @returns The effective UI status + */ +export function getEffectiveStatus(status: MCPToolResponseStatus | undefined, isWaiting: boolean): ToolStatus { + if (status === 'pending') { + return isWaiting ? 'waiting' : 'invoking' + } + return status ?? 'pending' +} + +// 工具状态指示器 - 显示在 Collapse 标题右侧 +export function ToolStatusIndicator({ status, hasError = false }: { status: ToolStatus; hasError?: boolean }) { + const { t } = useTranslation() + + const getStatusInfo = (): { label: string; icon: ReactNode; color: StatusColor } | null => { + switch (status) { + case 'streaming': + return { label: t('message.tools.streaming', 'Streaming'), icon: , color: 'primary' } + case 'waiting': + return { label: t('message.tools.pending', 'Awaiting Approval'), icon: , color: 'warning' } + case 'pending': + case 'invoking': + return { label: t('message.tools.invoking'), icon: , color: 'primary' } + case 'cancelled': + return { + label: t('message.tools.cancelled'), + icon: , + color: 'error' + } + case 'done': + return hasError + ? { + label: t('message.tools.error'), + icon: , + color: 'error' + } + : { + label: t('message.tools.completed'), + icon: , + color: 'success' + } + case 'error': + return { + label: t('message.tools.error'), + icon: , + color: 'error' + } + default: + return null + } + } + + const info = getStatusInfo() + if (!info) return null + + return ( + + {info.label} + {info.icon} + + ) +} + +export type StatusColor = 'primary' | 'success' | 'warning' | 'error' + +function getStatusColor(color: StatusColor): string { + switch (color) { + case 'primary': + case 'success': + return 'var(--color-primary)' + case 'warning': + return 'var(--color-status-warning, #faad14)' + case 'error': + return 'var(--color-status-error, #ff4d4f)' + default: + return 'var(--color-text)' + } +} + +export const StatusIndicatorContainer = styled.span<{ $color: StatusColor }>` + font-size: 12px; + display: inline-flex; + align-items: center; + gap: 4px; + opacity: 0.85; + color: ${(props) => getStatusColor(props.$color)}; +` + +export function TruncatedIndicator({ originalLength }: { originalLength: number }) { + const { t } = useTranslation() + const sizeStr = formatFileSize(originalLength) + + return ( +
+ + + {t('message.tools.truncated', { defaultValue: sizeStr, size: sizeStr })} + +
+ ) +} diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GlobTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GlobTool.tsx index b70d6da40e..0efcfe03a8 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GlobTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GlobTool.tsx @@ -1,8 +1,13 @@ import type { CollapseProps } from 'antd' -import { FolderSearch } from 'lucide-react' +import { useTranslation } from 'react-i18next' -import { ToolTitle } from './GenericTools' -import type { GlobToolInput as GlobToolInputType, GlobToolOutput as GlobToolOutputType } from './types' +import { countLines, truncateOutput } from '../shared/truncateOutput' +import { ToolHeader, TruncatedIndicator } from './GenericTools' +import { + AgentToolsType, + type GlobToolInput as GlobToolInputType, + type GlobToolOutput as GlobToolOutputType +} from './types' export function GlobTool({ input, @@ -11,19 +16,31 @@ export function GlobTool({ input?: GlobToolInputType output?: GlobToolOutputType }): NonNullable[number] { + const { t } = useTranslation() // 如果有输出,计算文件数量 - const lineCount = output ? output.split('\n').filter((line) => line.trim()).length : 0 + const lineCount = countLines(output) + const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(output) return { key: 'tool', label: ( - } - label="Glob" + ), - children:
{output}
+ children: ( +
+
{truncatedOutput}
+ {isTruncated && } +
+ ) } } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GrepTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GrepTool.tsx index 16149549df..c537d1a7ab 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GrepTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GrepTool.tsx @@ -1,8 +1,9 @@ import type { CollapseProps } from 'antd' -import { FileSearch } from 'lucide-react' +import { useTranslation } from 'react-i18next' -import { ToolTitle } from './GenericTools' -import type { GrepToolInput, GrepToolOutput } from './types' +import { countLines, truncateOutput } from '../shared/truncateOutput' +import { ToolHeader, TruncatedIndicator } from './GenericTools' +import { AgentToolsType, type GrepToolInput, type GrepToolOutput } from './types' export function GrepTool({ input, @@ -11,24 +12,36 @@ export function GrepTool({ input?: GrepToolInput output?: GrepToolOutput }): NonNullable[number] { + const { t } = useTranslation() // 如果有输出,计算结果行数 - const resultLines = output ? output.split('\n').filter((line) => line.trim()).length : 0 + const resultLines = countLines(output) + const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(output) return { key: 'tool', label: ( - } - label="Grep" + {input?.pattern} {input?.output_mode && ({input.output_mode})} } - stats={output ? `${resultLines} ${resultLines === 1 ? 'line' : 'lines'}` : undefined} + stats={ + output + ? `${resultLines} ${t(resultLines === 1 ? 'message.tools.units.line' : 'message.tools.units.lines')}` + : undefined + } + variant="collapse-label" + showStatus={false} /> ), - children:
{output}
+ children: ( +
+
{truncatedOutput}
+ {isTruncated && } +
+ ) } } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/MultiEditTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/MultiEditTool.tsx index 00922126e7..dd9c0f18ab 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/MultiEditTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/MultiEditTool.tsx @@ -1,8 +1,7 @@ import type { CollapseProps } from 'antd' -import { FileText } from 'lucide-react' import { renderCodeBlock } from './EditTool' -import { ToolTitle } from './GenericTools' +import { ToolHeader } from './GenericTools' import type { MultiEditToolInput, MultiEditToolOutput } from './types' import { AgentToolsType } from './types' @@ -15,7 +14,14 @@ export function MultiEditTool({ const edits = Array.isArray(input?.edits) ? input.edits : [] return { key: AgentToolsType.MultiEdit, - label: } label="MultiEdit" params={input?.file_path} />, + label: ( + + ), children: (
{edits.map((edit, index) => ( diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/NotebookEditTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/NotebookEditTool.tsx index fe0638f3c9..c3db3bded7 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/NotebookEditTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/NotebookEditTool.tsx @@ -1,9 +1,9 @@ import type { CollapseProps } from 'antd' import { Tag } from 'antd' -import { FileText } from 'lucide-react' import ReactMarkdown from 'react-markdown' -import { ToolTitle } from './GenericTools' +import { truncateOutput } from '../shared/truncateOutput' +import { ToolHeader, TruncatedIndicator } from './GenericTools' import type { NotebookEditToolInput, NotebookEditToolOutput } from './types' import { AgentToolsType } from './types' @@ -14,16 +14,21 @@ export function NotebookEditTool({ input?: NotebookEditToolInput output?: NotebookEditToolOutput }): NonNullable[number] { + const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(output) + return { key: AgentToolsType.NotebookEdit, label: ( - <> - } label="NotebookEdit" /> - - {input?.notebook_path}{' '} - - +
+ + {input?.notebook_path} +
), - children: {output ?? ''} + children: ( +
+ {truncatedOutput} + {isTruncated && } +
+ ) } } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ReadTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ReadTool.tsx index 30ae162276..7a17e952af 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ReadTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ReadTool.tsx @@ -1,8 +1,10 @@ +import { formatFileSize } from '@renderer/utils/file' import type { CollapseProps } from 'antd' -import { FileText } from 'lucide-react' +import { useTranslation } from 'react-i18next' import ReactMarkdown from 'react-markdown' -import { ToolTitle } from './GenericTools' +import { truncateOutput } from '../shared/truncateOutput' +import { SkeletonValue, ToolHeader, TruncatedIndicator } from './GenericTools' import type { ReadToolInput as ReadToolInputType, ReadToolOutput as ReadToolOutputType, TextOutput } from './types' import { AgentToolsType } from './types' @@ -28,17 +30,9 @@ const normalizeOutputString = (output?: ReadToolOutputType): string | null => { const getOutputStats = (outputString: string | null) => { if (!outputString) return null - const bytes = new Blob([outputString]).size - const formatSize = (size: number) => { - if (size < 1024) return `${size} B` - if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB` - return `${(size / (1024 * 1024)).toFixed(1)} MB` - } - return { lineCount: outputString.split('\n').length, - fileSize: bytes, - formatSize + fileSize: new Blob([outputString]).size } } @@ -49,19 +43,34 @@ export function ReadTool({ input?: ReadToolInputType output?: ReadToolOutputType }): NonNullable[number] { + const { t } = useTranslation() const outputString = normalizeOutputString(output) const stats = getOutputStats(outputString) + const filename = input?.file_path?.split('/').pop() + const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(outputString) return { key: AgentToolsType.Read, label: ( - } - label="Read File" - params={input?.file_path?.split('/').pop()} - stats={stats ? `${stats.lineCount} lines, ${stats.formatSize(stats.fileSize)}` : undefined} + } + stats={ + stats + ? `${stats.lineCount} ${t(stats.lineCount === 1 ? 'message.tools.units.line' : 'message.tools.units.lines')}, ${formatFileSize(stats.fileSize)}` + : undefined + } + variant="collapse-label" + showStatus={false} /> ), - children: outputString ? {outputString} : null + children: truncatedOutput ? ( +
+ {truncatedOutput} + {isTruncated && } +
+ ) : ( + + ) } } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/SearchTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/SearchTool.tsx index 66bf28c671..261e876c57 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/SearchTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/SearchTool.tsx @@ -1,8 +1,13 @@ import type { CollapseProps } from 'antd' -import { Search } from 'lucide-react' +import { useTranslation } from 'react-i18next' -import { StringInputTool, StringOutputTool, ToolTitle } from './GenericTools' -import type { SearchToolInput as SearchToolInputType, SearchToolOutput as SearchToolOutputType } from './types' +import { countLines, truncateOutput } from '../shared/truncateOutput' +import { StringInputTool, StringOutputTool, ToolHeader, TruncatedIndicator } from './GenericTools' +import { + AgentToolsType, + type SearchToolInput as SearchToolInputType, + type SearchToolOutput as SearchToolOutputType +} from './types' export function SearchTool({ input, @@ -11,25 +16,37 @@ export function SearchTool({ input?: SearchToolInputType output?: SearchToolOutputType }): NonNullable[number] { + const { t } = useTranslation() // 如果有输出,计算结果数量 - const resultCount = output ? output.split('\n').filter((line) => line.trim()).length : 0 + const resultCount = countLines(output) + const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(output) return { key: 'tool', label: ( - } - label="Search" + ), children: (
- {input && } - {output && ( + {input && } + {truncatedOutput && (
- + + {isTruncated && }
)}
diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/SkillTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/SkillTool.tsx index 6127984676..b5baabd330 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/SkillTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/SkillTool.tsx @@ -1,8 +1,8 @@ import type { CollapseProps } from 'antd' -import { PencilRuler } from 'lucide-react' -import { ToolTitle } from './GenericTools' -import type { SkillToolInput, SkillToolOutput } from './types' +import { truncateOutput } from '../shared/truncateOutput' +import { ToolHeader, TruncatedIndicator } from './GenericTools' +import { AgentToolsType, type SkillToolInput, type SkillToolOutput } from './types' export function SkillTool({ input, @@ -11,9 +11,18 @@ export function SkillTool({ input?: SkillToolInput output?: SkillToolOutput }): NonNullable[number] { + const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(output) + return { key: 'tool', - label: } label="Skill" params={input?.command} />, - children:
{output}
+ label: ( + + ), + children: ( +
+
{truncatedOutput}
+ {isTruncated && } +
+ ) } } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/TaskTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/TaskTool.tsx index 18117590c7..575815f9e9 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/TaskTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/TaskTool.tsx @@ -1,9 +1,15 @@ import type { CollapseProps } from 'antd' -import { Bot } from 'lucide-react' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' import Markdown from 'react-markdown' -import { ToolTitle } from './GenericTools' -import type { TaskToolInput as TaskToolInputType, TaskToolOutput as TaskToolOutputType } from './types' +import { truncateOutput } from '../shared/truncateOutput' +import { SkeletonValue, ToolHeader, TruncatedIndicator } from './GenericTools' +import { + AgentToolsType, + type TaskToolInput as TaskToolInputType, + type TaskToolOutput as TaskToolOutputType +} from './types' export function TaskTool({ input, @@ -12,17 +18,51 @@ export function TaskTool({ input?: TaskToolInputType output?: TaskToolOutputType }): NonNullable[number] { + const { t } = useTranslation() + const hasOutput = Array.isArray(output) && output.length > 0 + + // Combine all text outputs and truncate + const { truncatedText, isTruncated, originalLength } = useMemo(() => { + if (!hasOutput) return { truncatedText: '', isTruncated: false, originalLength: 0 } + const combinedText = output!.map((item) => item.text).join('\n\n') + const result = truncateOutput(combinedText) + return { truncatedText: result.data, isTruncated: result.isTruncated, originalLength: result.originalLength } + }, [output, hasOutput]) + return { key: 'tool', - label: } label="Task" params={input?.description} />, + label: ( + } + variant="collapse-label" + showStatus={false} + /> + ), children: ( -
- {Array.isArray(output) && - output.map((item) => ( -
-
{item.type === 'text' ? {item.text} : item.text}
+
+ {/* Prompt 输入区域 */} + {input?.prompt && ( +
+
{t('message.tools.sections.prompt')}
+
+ {input.prompt}
- ))} +
+ )} + + {/* Output 输出区域 */} + {hasOutput ? ( +
+
{t('message.tools.sections.output')}
+
+ {truncatedText} + {isTruncated && } +
+
+ ) : ( + + )}
) } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/TodoWriteTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/TodoWriteTool.tsx index a81de46dcd..7f01f0fccc 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/TodoWriteTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/TodoWriteTool.tsx @@ -1,8 +1,9 @@ import type { CollapseProps } from 'antd' import { Card } from 'antd' -import { CheckCircle, Circle, Clock, ListTodo } from 'lucide-react' +import { CheckCircle, Circle, Clock } from 'lucide-react' +import { useTranslation } from 'react-i18next' -import { ToolTitle } from './GenericTools' +import { ToolHeader } from './GenericTools' import type { TodoItem, TodoWriteToolInput as TodoWriteToolInputType } from './types' import { AgentToolsType } from './types' @@ -20,12 +21,6 @@ const getStatusConfig = (status: TodoItem['status']) => { opacity: 0.9, icon: } - case 'pending': - return { - color: 'var(--color-border)', - opacity: 0.4, - icon: - } default: return { color: 'var(--color-border)', @@ -40,17 +35,19 @@ export function TodoWriteTool({ }: { input?: TodoWriteToolInputType }): NonNullable[number] { + const { t } = useTranslation() const todos = Array.isArray(input?.todos) ? input.todos : [] const doneCount = todos.filter((todo) => todo.status === 'completed').length return { key: AgentToolsType.TodoWrite, label: ( - } - label="Todo Write" - params={`${doneCount} Done`} - stats={`${todos.length} ${todos.length === 1 ? 'item' : 'items'}`} + ), children: ( diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/UnknownToolRenderer.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/UnknownToolRenderer.tsx index 8a6965b6f6..7ec5e8b67e 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/UnknownToolRenderer.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/UnknownToolRenderer.tsx @@ -1,9 +1,9 @@ -import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import type { CollapseProps } from 'antd' import { Wrench } from 'lucide-react' -import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' -import { ToolTitle } from './GenericTools' +import { ToolArgsTable } from '../shared/ArgsTable' +import { ToolHeader } from './GenericTools' interface UnknownToolProps { toolName: string @@ -21,75 +21,54 @@ const getToolDisplayName = (name: string) => { return name } -const getToolDescription = (toolName: string) => { - if (toolName.startsWith('mcp__')) { - return 'MCP Server Tool' - } - return 'Tool' -} - -const UnknownToolContent = ({ input, output }: { input?: unknown; output?: unknown }) => { - const { highlightCode } = useCodeStyle() - const [inputHtml, setInputHtml] = useState('') - const [outputHtml, setOutputHtml] = useState('') - - useEffect(() => { - if (input !== undefined) { - const inputStr = JSON.stringify(input, null, 2) - highlightCode(inputStr, 'json').then(setInputHtml) - } - }, [input, highlightCode]) - - useEffect(() => { - if (output !== undefined) { - const outputStr = JSON.stringify(output, null, 2) - highlightCode(outputStr, 'json').then(setOutputHtml) - } - }, [output, highlightCode]) - - if (input === undefined && output === undefined) { - return
No data available for this tool
- } - - return ( -
- {input !== undefined && ( -
-
Input:
-
-
- )} - - {output !== undefined && ( -
-
Output:
-
-
- )} -
- ) -} - +/** + * Fallback renderer for unknown tool types + * Uses shared ArgsTable for consistent styling with MCP tools + */ export function UnknownToolRenderer({ toolName = '', input, output }: UnknownToolProps): NonNullable[number] { + const { t } = useTranslation() + + const getToolDescription = (name: string) => { + if (name.startsWith('mcp__')) { + return t('message.tools.labels.mcpServerTool') + } + return t('message.tools.labels.tool') + } + + // Normalize input/output for table display + const normalizeArgs = (value: unknown): Record | unknown[] | null => { + if (value === undefined || value === null) return null + if (typeof value === 'object') return value as Record | unknown[] + // Wrap primitive values + return { value } + } + + const normalizedInput = normalizeArgs(input) + const normalizedOutput = normalizeArgs(output) + return { key: 'unknown-tool', label: ( - } - label={getToolDisplayName(toolName)} params={getToolDescription(toolName)} + variant="collapse-label" + showStatus={false} /> ), - children: + children: ( +
+ {normalizedInput && } + {normalizedOutput && } + {!normalizedInput && !normalizedOutput && ( +
{t('message.tools.noData')}
+ )} +
+ ) } } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WebFetchTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WebFetchTool.tsx index f8bd27df5f..6d09a54510 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WebFetchTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WebFetchTool.tsx @@ -1,8 +1,8 @@ import type { CollapseProps } from 'antd' -import { Globe } from 'lucide-react' -import { ToolTitle } from './GenericTools' -import type { WebFetchToolInput, WebFetchToolOutput } from './types' +import { truncateOutput } from '../shared/truncateOutput' +import { ToolHeader, TruncatedIndicator } from './GenericTools' +import { AgentToolsType, type WebFetchToolInput, type WebFetchToolOutput } from './types' export function WebFetchTool({ input, @@ -11,9 +11,18 @@ export function WebFetchTool({ input?: WebFetchToolInput output?: WebFetchToolOutput }): NonNullable[number] { + const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(output) + return { key: 'tool', - label: } label="Web Fetch" params={input?.url} />, - children:
{output}
+ label: ( + + ), + children: ( +
+
{truncatedOutput}
+ {isTruncated && } +
+ ) } } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WebSearchTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WebSearchTool.tsx index 4f50839cc9..079c9a9ce3 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WebSearchTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WebSearchTool.tsx @@ -1,8 +1,9 @@ import type { CollapseProps } from 'antd' -import { Globe } from 'lucide-react' +import { useTranslation } from 'react-i18next' -import { ToolTitle } from './GenericTools' -import type { WebSearchToolInput, WebSearchToolOutput } from './types' +import { countLines, truncateOutput } from '../shared/truncateOutput' +import { ToolHeader, TruncatedIndicator } from './GenericTools' +import { AgentToolsType, type WebSearchToolInput, type WebSearchToolOutput } from './types' export function WebSearchTool({ input, @@ -11,19 +12,31 @@ export function WebSearchTool({ input?: WebSearchToolInput output?: WebSearchToolOutput }): NonNullable[number] { + const { t } = useTranslation() // 如果有输出,计算结果数量 - const resultCount = output ? output.split('\n').filter((line) => line.trim()).length : 0 + const resultCount = countLines(output) + const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(output) return { key: 'tool', label: ( - } - label="Web Search" + ), - children:
{output}
+ children: ( +
+
{truncatedOutput}
+ {isTruncated && } +
+ ) } } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WriteTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WriteTool.tsx index fd0d637f50..2e4846ec49 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WriteTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WriteTool.tsx @@ -1,8 +1,7 @@ import type { CollapseProps } from 'antd' -import { FileText } from 'lucide-react' -import { ToolTitle } from './GenericTools' -import type { WriteToolInput, WriteToolOutput } from './types' +import { ToolHeader } from './GenericTools' +import { AgentToolsType, type WriteToolInput, type WriteToolOutput } from './types' export function WriteTool({ input @@ -12,7 +11,14 @@ export function WriteTool({ }): NonNullable[number] { return { key: 'tool', - label: } label="Write" params={input?.file_path} />, + label: ( + + ), children:
{input?.content}
} } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/index.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/index.tsx index e523305277..eed581bedc 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/index.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/index.tsx @@ -1,10 +1,10 @@ -import { loggerService } from '@logger' import { useAppSelector } from '@renderer/store' import { selectPendingPermission } from '@renderer/store/toolPermissions' import type { NormalToolResponse } from '@renderer/types' import type { CollapseProps } from 'antd' -import { Collapse, Spin } from 'antd' -import { useTranslation } from 'react-i18next' +import { Collapse } from 'antd' +import { parse as parsePartialJson } from 'partial-json' +import { useMemo } from 'react' // 导出所有类型 export * from './types' @@ -15,6 +15,7 @@ import { BashOutputTool } from './BashOutputTool' import { BashTool } from './BashTool' import { EditTool } from './EditTool' import { ExitPlanModeTool } from './ExitPlanModeTool' +import { getEffectiveStatus, StreamingContext, type ToolStatus, ToolStatusIndicator } from './GenericTools' import { GlobTool } from './GlobTool' import { GrepTool } from './GrepTool' import { MultiEditTool } from './MultiEditTool' @@ -31,9 +32,7 @@ import { WebFetchTool } from './WebFetchTool' import { WebSearchTool } from './WebSearchTool' import { WriteTool } from './WriteTool' -const logger = loggerService.withContext('MessageAgentTools') - -// 创建工具渲染器映射,这样就实现了完全的类型安全 +// 创建工具渲染器映射 export const toolRenderers = { [AgentToolsType.Read]: ReadTool, [AgentToolsType.Task]: TaskTool, @@ -51,76 +50,116 @@ export const toolRenderers = { [AgentToolsType.NotebookEdit]: NotebookEditTool, [AgentToolsType.ExitPlanMode]: ExitPlanModeTool, [AgentToolsType.Skill]: SkillTool -} as const +} + +/** + * Type-safe tool renderer invocation function. + * Use this function to call a tool renderer with proper type checking, + * avoiding the need for `as any` type assertions at call sites. + * + * @param toolName - The name of the tool (must be a valid AgentToolsType) + * @param input - The input for the tool (accepts various input formats) + * @param output - Optional output from the tool + * @returns The rendered collapse item + */ +export function renderTool( + toolName: AgentToolsType, + input: ToolInput | Record | string | undefined, + output?: ToolOutput | unknown +): NonNullable[number] { + const renderer = toolRenderers[toolName] as (props: { + input?: unknown + output?: unknown + }) => NonNullable[number] + return renderer({ input, output }) +} // 类型守卫函数 export function isValidAgentToolsType(toolName: unknown): toolName is AgentToolsType { return typeof toolName === 'string' && Object.values(AgentToolsType).includes(toolName as AgentToolsType) } -// 统一的渲染组件 -function ToolContent({ toolName, input, output }: { toolName: AgentToolsType; input: ToolInput; output?: ToolOutput }) { - const Renderer = toolRenderers[toolName] - const renderedItem = Renderer - ? Renderer({ input: input as any, output: output as any }) - : UnknownToolRenderer({ input: input as any, output: output as any, toolName }) +function ToolContent({ + toolName, + input, + output, + isStreaming = false, + status, + hasError = false +}: { + toolName?: string + input?: ToolInput | Record + output?: ToolOutput | unknown + isStreaming?: boolean + status?: ToolStatus + hasError?: boolean +}) { + const renderedItem = isValidAgentToolsType(toolName) + ? renderTool(toolName, (input ?? {}) as Record, output) + : UnknownToolRenderer({ toolName: toolName ?? 'Tool', input, output }) const toolContentItem: NonNullable[number] = { ...renderedItem, + label: ( +
+
{renderedItem.label}
+ {status && ( +
+ +
+ )} +
+ ), classNames: { - body: 'bg-foreground-50 p-2 text-foreground-900 dark:bg-foreground-100 max-h-96 p-2 overflow-scroll' + body: 'bg-foreground-50 p-2 text-foreground-900 dark:bg-foreground-100 max-h-96 overflow-scroll' } } return ( - + + + ) } // 统一的组件渲染入口 export function MessageAgentTools({ toolResponse }: { toolResponse: NormalToolResponse }) { - const { arguments: args, response, tool, status } = toolResponse - logger.debug('Rendering agent tool response', { - tool: tool, - arguments: args, - status, - response - }) + const { arguments: args, response, tool, status, partialArguments } = toolResponse const pendingPermission = useAppSelector((state) => selectPendingPermission(state.toolPermissions, toolResponse.toolCallId) ) - if (status === 'pending') { - if (pendingPermission) { - return + const parsedPartialArgs = useMemo(() => { + if (!partialArguments) return undefined + try { + return parsePartialJson(partialArguments) + } catch { + return undefined } - return + }, [partialArguments]) + + const effectiveStatus = getEffectiveStatus(status, !!pendingPermission) + + if (effectiveStatus === 'waiting') { + return } + const isLoading = effectiveStatus === 'streaming' || effectiveStatus === 'invoking' return ( - - ) -} - -function ToolPendingIndicator({ toolName, description }: { toolName?: string; description?: string }) { - const { t } = useTranslation() - const label = toolName || t('agent.toolPermission.toolPendingFallback', 'Tool') - const detail = description?.trim() || t('agent.toolPermission.executing') - - return ( -
- -
- {label} - {detail} -
-
+ ) } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/types.ts b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/types.ts index f4271b3a2e..7396f8ed00 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/types.ts +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/types.ts @@ -1,3 +1,5 @@ +import type { CollapseProps } from 'antd' + export enum AgentToolsType { Skill = 'Skill', Read = 'Read', @@ -386,3 +388,52 @@ export type ToolOutput = export interface ToolRenderer { render: (props: { input: ToolInput; output?: ToolOutput }) => React.ReactElement } + +// 工具类型到输入类型的映射(用于文档和类型提示) +export interface ToolInputMap { + [AgentToolsType.Skill]: SkillToolInput + [AgentToolsType.Read]: ReadToolInput + [AgentToolsType.Task]: TaskToolInput + [AgentToolsType.Bash]: BashToolInput + [AgentToolsType.Search]: SearchToolInput + [AgentToolsType.Glob]: GlobToolInput + [AgentToolsType.TodoWrite]: TodoWriteToolInput + [AgentToolsType.WebSearch]: WebSearchToolInput + [AgentToolsType.Grep]: GrepToolInput + [AgentToolsType.Write]: WriteToolInput + [AgentToolsType.WebFetch]: WebFetchToolInput + [AgentToolsType.Edit]: EditToolInput + [AgentToolsType.MultiEdit]: MultiEditToolInput + [AgentToolsType.BashOutput]: BashOutputToolInput + [AgentToolsType.NotebookEdit]: NotebookEditToolInput + [AgentToolsType.ExitPlanMode]: ExitPlanModeToolInput +} + +// 工具类型到输出类型的映射 +export interface ToolOutputMap { + [AgentToolsType.Skill]: SkillToolOutput + [AgentToolsType.Read]: ReadToolOutput + [AgentToolsType.Task]: TaskToolOutput + [AgentToolsType.Bash]: BashToolOutput + [AgentToolsType.Search]: SearchToolOutput + [AgentToolsType.Glob]: GlobToolOutput + [AgentToolsType.TodoWrite]: TodoWriteToolOutput + [AgentToolsType.WebSearch]: WebSearchToolOutput + [AgentToolsType.Grep]: GrepToolOutput + [AgentToolsType.Write]: WriteToolOutput + [AgentToolsType.WebFetch]: WebFetchToolOutput + [AgentToolsType.Edit]: EditToolOutput + [AgentToolsType.MultiEdit]: MultiEditToolOutput + [AgentToolsType.BashOutput]: BashOutputToolOutput + [AgentToolsType.NotebookEdit]: NotebookEditToolOutput + [AgentToolsType.ExitPlanMode]: ExitPlanModeToolOutput +} + +// 通用工具渲染器函数类型 - 接受宽松的输入类型 +export type ToolRendererFn = (props: { + input?: ToolInput | Record | string + output?: ToolOutput | unknown +}) => NonNullable[number] + +// 工具渲染器映射类型 +export type ToolRenderersMap = Record diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageMcpTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageMcpTool.tsx index 455e64d05d..328d1ab9a6 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageMcpTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageMcpTool.tsx @@ -1,107 +1,66 @@ import { loggerService } from '@logger' -import { CopyIcon, LoadingIcon } from '@renderer/components/Icons' +import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js' +import { CopyIcon } from '@renderer/components/Icons' import { useCodeStyle } from '@renderer/context/CodeStyleProvider' -import { useMCPServers } from '@renderer/hooks/useMCPServers' import { useSettings } from '@renderer/hooks/useSettings' import { useTimer } from '@renderer/hooks/useTimer' import type { MCPToolResponse } from '@renderer/types' import type { ToolMessageBlock } from '@renderer/types/newMessage' import { isToolAutoApproved } from '@renderer/utils/mcp-tools' -import { cancelToolAction, confirmToolAction } from '@renderer/utils/userConfirmation' import type { MCPProgressEvent } from '@shared/config/types' import { IpcChannel } from '@shared/IpcChannel' -import { - Button, - Collapse, - ConfigProvider, - Dropdown, - Flex, - message as antdMessage, - Modal, - Progress, - Tabs, - Tooltip -} from 'antd' +import { Collapse, ConfigProvider, Flex, message as antdMessage, Progress, Tooltip } from 'antd' import { message } from 'antd' -import { - Check, - ChevronDown, - ChevronRight, - CirclePlay, - CircleX, - Maximize, - PauseCircle, - ShieldCheck, - TriangleAlert, - X -} from 'lucide-react' +import { Check, ChevronRight, ShieldCheck } from 'lucide-react' +import { parse as parsePartialJson } from 'partial-json' import type { FC } from 'react' -import { memo, useEffect, useMemo, useRef, useState } from 'react' +import { memo, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' +import { useMcpToolApproval } from './hooks/useMcpToolApproval' +import { + getEffectiveStatus, + SkeletonSpan, + ToolStatusIndicator, + TruncatedIndicator +} from './MessageAgentTools/GenericTools' +import { + ArgKey, + ArgsSection, + ArgsSectionTitle, + ArgsTable, + ArgValue, + formatArgValue, + ResponseSection +} from './shared/ArgsTable' +import { truncateOutput } from './shared/truncateOutput' +import ToolApprovalActionsComponent from './ToolApprovalActions' + interface Props { block: ToolMessageBlock } const logger = loggerService.withContext('MessageTools') -const COUNTDOWN_TIME = 30 - const MessageMcpTool: FC = ({ block }) => { const [activeKeys, setActiveKeys] = useState([]) const [copiedMap, setCopiedMap] = useState>({}) - const [countdown, setCountdown] = useState(COUNTDOWN_TIME) const { t } = useTranslation() const { messageFont, fontSize } = useSettings() - const { mcpServers, updateMCPServer } = useMCPServers() - const [expandedResponse, setExpandedResponse] = useState<{ content: string; title: string } | null>(null) const [progress, setProgress] = useState(0) const { setTimeoutTimer } = useTimer() + // Use the unified approval hook + const approval = useMcpToolApproval(block) + const toolResponse = block.metadata?.rawMcpToolResponse as MCPToolResponse - const { id, tool, status, response } = toolResponse as MCPToolResponse + const { id, tool, status, response, partialArguments } = toolResponse as MCPToolResponse const isPending = status === 'pending' const isDone = status === 'done' const isError = status === 'error' - - const isAutoApproved = useMemo( - () => - isToolAutoApproved( - tool, - mcpServers.find((s) => s.id === tool.serverId) - ), - [tool, mcpServers] - ) - - // 增加本地状态来跟踪用户确认 - const [isConfirmed, setIsConfirmed] = useState(isAutoApproved) - - // 判断不同的UI状态 - const isWaitingConfirmation = isPending && !isAutoApproved && !isConfirmed - const isExecuting = isPending && (isAutoApproved || isConfirmed) - - const timer = useRef(null) - useEffect(() => { - if (!isWaitingConfirmation) return - - if (countdown > 0) { - timer.current = setTimeout(() => { - logger.debug(`countdown: ${countdown}`) - setCountdown((prev) => prev - 1) - }, 1000) - } else if (countdown === 0) { - setIsConfirmed(true) - confirmToolAction(id) - } - - return () => { - if (timer.current) { - clearTimeout(timer.current) - } - } - }, [countdown, id, isWaitingConfirmation]) + const isStreaming = status === 'streaming' useEffect(() => { const removeListener = window.electron.ipcRenderer.on( @@ -119,33 +78,16 @@ const MessageMcpTool: FC = ({ block }) => { } }, [id]) - const cancelCountdown = () => { - if (timer.current) { - clearTimeout(timer.current) + // Auto-expand when streaming, auto-collapse when done + useEffect(() => { + if (isStreaming) { + // Expand when streaming starts + setActiveKeys((prev) => (prev.includes(id) ? prev : [...prev, id])) + } else if (isDone || isError) { + // Collapse when streaming ends + setActiveKeys((prev) => prev.filter((key) => key !== id)) } - } - - const argsString = useMemo(() => { - if (toolResponse?.arguments) { - return JSON.stringify(toolResponse.arguments, null, 2) - } - return 'No arguments' - }, [toolResponse]) - - const resultString = useMemo(() => { - try { - return JSON.stringify( - { - params: toolResponse?.arguments, - response: toolResponse?.response - }, - null, - 2 - ) - } catch (e) { - return 'Invalid Result' - } - }, [toolResponse]) + }, [isStreaming, isDone, isError, id]) if (!toolResponse) { return null @@ -162,17 +104,6 @@ const MessageMcpTool: FC = ({ block }) => { setActiveKeys(Array.isArray(keys) ? keys : [keys]) } - const handleConfirmTool = () => { - cancelCountdown() - setIsConfirmed(true) - confirmToolAction(id) - } - - const handleCancelTool = () => { - cancelCountdown() - cancelToolAction(id) - } - const handleAbortTool = async () => { if (toolResponse?.id) { try { @@ -189,75 +120,8 @@ const MessageMcpTool: FC = ({ block }) => { } } - const handleAutoApprove = async () => { - cancelCountdown() - - if (!tool || !tool.name) { - return - } - - const server = mcpServers.find((s) => s.id === tool.serverId) - if (!server) { - return - } - - let disabledAutoApproveTools = [...(server.disabledAutoApproveTools || [])] - - // Remove tool from disabledAutoApproveTools to enable auto-approve - disabledAutoApproveTools = disabledAutoApproveTools.filter((name) => name !== tool.name) - - const updatedServer = { - ...server, - disabledAutoApproveTools - } - - updateMCPServer(updatedServer) - - // Also confirm the current tool - setIsConfirmed(true) - confirmToolAction(id) - - window.toast.success(t('message.tools.autoApproveEnabled', 'Auto-approve enabled for this tool')) - } - - const renderStatusIndicator = (status: string, hasError: boolean) => { - let label = '' - let icon: React.ReactNode | null = null - - if (status === 'pending') { - if (isWaitingConfirmation) { - label = t('message.tools.pending', 'Awaiting Approval') - icon = - } else if (isExecuting) { - label = t('message.tools.invoking') - icon = - } - } else if (status === 'cancelled') { - label = t('message.tools.cancelled') - icon = - } else if (status === 'done') { - if (hasError) { - label = t('message.tools.error') - icon = - } else { - label = t('message.tools.completed') - icon = - } - } else if (status === 'error') { - label = t('message.tools.error') - icon = - } - - return ( - - {label} - {icon} - - ) - } - // Format tool responses for collapse items - const getCollapseItems = () => { + const getCollapseItems = (): { key: string; label: React.ReactNode; children: React.ReactNode }[] => { const items: { key: string; label: React.ReactNode; children: React.ReactNode }[] = [] const hasError = response?.isError === true const result = { @@ -282,22 +146,8 @@ const MessageMcpTool: FC = ({ block }) => { {progress > 0 ? ( ) : ( - renderStatusIndicator(status, hasError) + )} - - { - e.stopPropagation() - setExpandedResponse({ - content: JSON.stringify(response, null, 2), - title: tool.name - }) - }} - aria-label={t('common.expand')}> - - - {!isPending && ( = ({ block }) => { ), - children: - (isDone || isError) && result ? ( - - - - ) : argsString ? ( - <> - - - - - ) : null + children: ( + + + + ) }) return items } - const renderPreview = (content: string) => { - if (!content) return null - - try { - logger.debug(`renderPreview: ${content}`) - const parsedResult = JSON.parse(content) - switch (parsedResult.content[0]?.type) { - case 'text': - try { - return ( - - ) - } catch (e) { - return ( - - ) - } - - default: - return - } - } catch (e) { - logger.error('failed to render the preview of mcp results:', e as Error) - return ( - - ) - } - } - return ( <> = ({ block }) => { {isPending && ( - {isWaitingConfirmation + {approval.isWaiting ? t('settings.mcp.tools.autoApprove.tooltip.confirm') : t('message.tools.invoking')} - - {isWaitingConfirmation && ( - - )} - {isExecuting && toolResponse?.id ? ( - - ) : ( - isWaitingConfirmation && ( - } - onClick={() => { - handleConfirmTool() - }} - menu={{ - items: [ - { - key: 'autoApprove', - label: t('settings.mcp.tools.autoApprove.label'), - onClick: () => { - handleAutoApprove() - } - } - ] - }}> - - - {t('settings.mcp.tools.run', 'Run')} ({countdown}s) - - - ) - )} - + )} - setExpandedResponse(null)} - footer={null} - width="80%" - centered - transitionName="animation-move-down" - styles={{ body: { maxHeight: '80vh', overflow: 'auto' } }}> - {expandedResponse && ( - - { - navigator.clipboard.writeText( - typeof expandedResponse.content === 'string' - ? expandedResponse.content - : JSON.stringify(expandedResponse.content, null, 2) - ) - antdMessage.success({ content: t('message.copied'), key: 'copy-expanded' }) - }} - aria-label={t('common.copy')}> - - - } - items={[ - { - key: 'preview', - label: t('message.tools.preview'), - children: renderPreview(expandedResponse.content) - }, - { - key: 'raw', - label: t('message.tools.raw'), - children: ( - - ) - } - ]} - /> - - )} - ) } -// New component to handle collapsed content -const CollapsedContent: FC<{ isExpanded: boolean; resultString: string }> = ({ isExpanded, resultString }) => { - const { highlightCode } = useCodeStyle() - const [styledResult, setStyledResult] = useState('') +/** + * Extract preview content from MCP tool response using SDK schema + */ +const extractPreviewContent = (response: unknown): string => { + if (!response) return '' - useEffect(() => { - if (!isExpanded) { - return + const result = CallToolResultSchema.safeParse(response) + if (result.success) { + const contents = result.data.content + if (contents.length === 0) return '' + + const textParts: string[] = [] + for (const content of contents) { + switch (content.type) { + case 'text': + if (content.text) { + try { + const parsed = JSON.parse(content.text) + textParts.push(JSON.stringify(parsed, null, 2)) + } catch { + textParts.push(content.text) + } + } + break + case 'image': + textParts.push(`[Image: ${content.mimeType ?? 'image/png'}]`) + break + case 'resource': + textParts.push(`[Resource: ${content.resource?.uri ?? 'unknown'}]`) + break + } } + return textParts.join('\n\n') + } + + // Fallback: return JSON string for unknown format + return JSON.stringify(response, null, 2) +} + +// Unified tool response content component +const ToolResponseContent: FC<{ + isExpanded: boolean + args: string | Record | Record[] | undefined + isStreaming: boolean + response?: unknown +}> = ({ isExpanded, args, isStreaming, response }) => { + const { highlightCode } = useCodeStyle() + const [highlightedResponse, setHighlightedResponse] = useState('') + const [isTruncated, setIsTruncated] = useState(false) + const [originalLength, setOriginalLength] = useState(0) + + // Parse args if it's a string (streaming partial JSON) + const parsedArgs = useMemo(() => { + if (!args) return null + if (typeof args === 'string') { + try { + return parsePartialJson(args) + } catch { + return null + } + } + return args + }, [args]) + + // Extract and highlight response when available + useEffect(() => { + if (!isExpanded || !response) return const highlight = async () => { - const result = await highlightCode(resultString, 'json') - setStyledResult(result) + const previewContent = extractPreviewContent(response) + const { + data: truncatedContent, + isTruncated: wasTruncated, + originalLength: origLen + } = truncateOutput(previewContent) + setIsTruncated(wasTruncated) + setOriginalLength(origLen) + const result = await highlightCode(truncatedContent, 'json') + setHighlightedResponse(result) } const timer = setTimeout(highlight, 0) - return () => clearTimeout(timer) - }, [isExpanded, resultString, highlightCode]) + }, [isExpanded, response, highlightCode]) - if (!isExpanded) { - return null + if (!isExpanded) return null + + // Handle both object and array args - for arrays, show as single entry + const getEntries = (): Array<[string, unknown]> => { + if (!parsedArgs || typeof parsedArgs !== 'object') return [] + if (Array.isArray(parsedArgs)) { + return [['arguments', parsedArgs]] + } + return Object.entries(parsedArgs) + } + const entries = getEntries() + + const renderArgsTable = (): React.ReactNode => { + if (entries.length === 0) return null + return ( + + Arguments + + + {entries.map(([key, value]) => ( + + {key} + {formatArgValue(value)} + + ))} + {isStreaming && ( + + + + + + + + + )} + + + + ) } - return + return ( +
+ {/* Arguments Table */} + {renderArgsTable()} + + {/* Response */} + {response !== undefined && response !== null && highlightedResponse && ( + + Response + + {isTruncated && } + + )} +
+ ) } const ToolContentWrapper = styled.div` @@ -586,22 +407,6 @@ const ActionLabel = styled.div` white-space: nowrap; ` -const ActionButtonsGroup = styled.div` - display: flex; - gap: 10px; -` - -const CountdownText = styled.span` - width: 65px; - text-align: left; -` - -const StyledDropdownButton = styled(Dropdown.Button)` - .ant-btn-group { - border-radius: 6px; - } -` - const ExpandIcon = styled(ChevronRight)<{ $isActive?: boolean }>` transition: transform 0.2s; transform: ${({ $isActive }) => ($isActive ? 'rotate(90deg)' : 'rotate(0deg)')}; @@ -670,31 +475,6 @@ const ToolName = styled(Flex)` font-size: 13px; ` -const StatusIndicator = styled.span<{ status: string; hasError?: boolean }>` - color: ${(props) => { - switch (props.status) { - case 'pending': - return 'var(--status-color-warning)' - case 'invoking': - return 'var(--status-color-invoking)' - case 'cancelled': - return 'var(--status-color-error)' - case 'done': - return props.hasError ? 'var(--status-color-error)' : 'var(--status-color-success)' - case 'error': - return 'var(--status-color-error)' - default: - return 'var(--color-text)' - } - }}; - font-size: 11px; - font-weight: ${(props) => (props.status === 'pending' ? '600' : '400')}; - display: flex; - align-items: center; - opacity: ${(props) => (props.status === 'pending' ? '1' : '0.85')}; - padding-left: 12px; -` - const ActionButtonsContainer = styled.div` display: flex; gap: 6px; @@ -752,27 +532,4 @@ const ToolResponseContainer = styled.div` position: relative; ` -const ExpandedResponseContainer = styled.div` - background: var(--color-bg-1); - border-radius: 8px; - padding: 16px; - position: relative; - - .copy-expanded-button { - position: absolute; - top: 10px; - right: 10px; - background-color: var(--color-bg-2); - border-radius: 4px; - z-index: 1; - } - - pre { - margin: 0; - white-space: pre-wrap; - word-break: break-word; - color: var(--color-text); - } -` - export default memo(MessageMcpTool) diff --git a/src/renderer/src/pages/home/Messages/Tools/ToolApprovalActions.tsx b/src/renderer/src/pages/home/Messages/Tools/ToolApprovalActions.tsx new file mode 100644 index 0000000000..a012a0762a --- /dev/null +++ b/src/renderer/src/pages/home/Messages/Tools/ToolApprovalActions.tsx @@ -0,0 +1,165 @@ +import { LoadingIcon } from '@renderer/components/Icons' +import { Button, Dropdown } from 'antd' +import { ChevronDown, CirclePlay, CircleX, ShieldCheck } from 'lucide-react' +import type { FC, MouseEvent } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +import type { ToolApprovalActions, ToolApprovalState } from './hooks/useToolApproval' + +export interface ToolApprovalActionsProps extends ToolApprovalState, ToolApprovalActions { + /** Compact mode for use in headers */ + compact?: boolean + /** Show abort button when executing */ + showAbort?: boolean + /** Abort handler */ + onAbort?: () => void +} + +/** + * Unified tool approval action buttons + * Used in both MessageMcpTool and ToolPermissionRequestCard + */ +export const ToolApprovalActionsComponent: FC = ({ + isWaiting, + isExecuting, + remainingSeconds, + isExpired, + isSubmitting, + confirm, + cancel, + autoApprove, + compact = false, + showAbort = false, + onAbort +}) => { + const { t } = useTranslation() + + // Stop event propagation to prevent collapse toggle + const handleClick = (e: MouseEvent, handler: () => void) => { + e.stopPropagation() + handler() + } + + // Nothing to show if not waiting and not executing + if (!isWaiting && !isExecuting) return null + + // Expired state for agent tools + if (isExpired && !isExecuting) { + return ( + e.stopPropagation()}> + {t('agent.toolPermission.expired')} + + ) + } + + // Executing state - show loading or abort button + if (isExecuting) { + if (showAbort && onAbort) { + return ( + e.stopPropagation()}> + + + ) + } + return ( + e.stopPropagation()}> + + + {!compact && {t('message.tools.invoking')}} + + + ) + } + + // Waiting state - show confirm/cancel buttons + return ( + e.stopPropagation()}> + + + {autoApprove ? ( + } + onClick={(e) => handleClick(e, confirm)} + menu={{ + items: [ + { + key: 'autoApprove', + label: t('settings.mcp.tools.autoApprove.label'), + icon: , + onClick: () => autoApprove() + } + ] + }}> + + + {compact ? `${remainingSeconds}s` : `${t('settings.mcp.tools.run', 'Run')} (${remainingSeconds}s)`} + + + ) : ( + + )} + + ) +} + +// Styled components + +const ActionsContainer = styled.div<{ $compact: boolean }>` + display: flex; + align-items: center; + gap: ${(props) => (props.$compact ? '4px' : '8px')}; + + .ant-btn-sm { + height: ${(props) => (props.$compact ? '24px' : '28px')}; + padding: ${(props) => (props.$compact ? '0 6px' : '0 8px')}; + font-size: ${(props) => (props.$compact ? '12px' : '13px')}; + } +` + +const ExpiredBadge = styled.span<{ $compact: boolean }>` + font-size: ${(props) => (props.$compact ? '11px' : '12px')}; + color: var(--color-status-error, #ff4d4f); + padding: ${(props) => (props.$compact ? '2px 6px' : '4px 8px')}; + background: var(--color-status-error-bg, rgba(255, 77, 79, 0.1)); + border-radius: 4px; +` + +const LoadingIndicator = styled.div` + display: flex; + align-items: center; + gap: 6px; + color: var(--color-primary); + font-size: 12px; +` + +const CountdownText = styled.span<{ $compact: boolean }>` + min-width: ${(props) => (props.$compact ? '24px' : '65px')}; + text-align: left; +` + +const StyledDropdownButton = styled(Dropdown.Button)` + .ant-btn-group { + border-radius: 6px; + } +` + +export default ToolApprovalActionsComponent diff --git a/src/renderer/src/pages/home/Messages/Tools/ToolHeader.tsx b/src/renderer/src/pages/home/Messages/Tools/ToolHeader.tsx new file mode 100644 index 0000000000..ab0a769d11 --- /dev/null +++ b/src/renderer/src/pages/home/Messages/Tools/ToolHeader.tsx @@ -0,0 +1,272 @@ +import type { MCPTool, MCPToolResponse, NormalToolResponse } from '@renderer/types' +import type { ToolMessageBlock } from '@renderer/types/newMessage' +import { isToolAutoApproved } from '@renderer/utils/mcp-tools' +import { Flex, Tooltip } from 'antd' +import { + Bot, + DoorOpen, + FileEdit, + FileSearch, + FileText, + FolderSearch, + Globe, + ListTodo, + NotebookPen, + PencilRuler, + Search, + ShieldCheck, + Terminal, + Wrench +} from 'lucide-react' +import type { FC, ReactNode } from 'react' +import { memo } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +import { type ToolStatus, ToolStatusIndicator } from './MessageAgentTools/GenericTools' +import { AgentToolsType } from './MessageAgentTools/types' + +export interface ToolHeaderProps { + block?: ToolMessageBlock + + toolName?: string + icon?: ReactNode + params?: ReactNode + stats?: ReactNode + + // Common config + status?: ToolStatus + hasError?: boolean + showStatus?: boolean // default true + + // Style variant + variant?: 'standalone' | 'collapse-label' +} + +const getAgentToolIcon = (toolName: string): ReactNode => { + switch (toolName) { + case AgentToolsType.Read: + return + case AgentToolsType.Task: + return + case AgentToolsType.Bash: + case AgentToolsType.BashOutput: + return + case AgentToolsType.Search: + return + case AgentToolsType.Glob: + return + case AgentToolsType.Grep: + return + case AgentToolsType.Write: + return + case AgentToolsType.Edit: + return + case AgentToolsType.MultiEdit: + return + case AgentToolsType.WebSearch: + case AgentToolsType.WebFetch: + return + case AgentToolsType.NotebookEdit: + return + case AgentToolsType.TodoWrite: + return + case AgentToolsType.ExitPlanMode: + return + case AgentToolsType.Skill: + return + default: + return + } +} + +const getAgentToolLabel = (toolName: string, t: (key: string) => string): string => { + switch (toolName) { + case AgentToolsType.Read: + return t('message.tools.labels.readFile') + case AgentToolsType.Task: + return t('message.tools.labels.task') + case AgentToolsType.Bash: + return t('message.tools.labels.bash') + case AgentToolsType.BashOutput: + return t('message.tools.labels.bashOutput') + case AgentToolsType.Search: + return t('message.tools.labels.search') + case AgentToolsType.Glob: + return t('message.tools.labels.glob') + case AgentToolsType.Grep: + return t('message.tools.labels.grep') + case AgentToolsType.Write: + return t('message.tools.labels.write') + case AgentToolsType.Edit: + return t('message.tools.labels.edit') + case AgentToolsType.MultiEdit: + return t('message.tools.labels.multiEdit') + case AgentToolsType.WebSearch: + return t('message.tools.labels.webSearch') + case AgentToolsType.WebFetch: + return t('message.tools.labels.webFetch') + case AgentToolsType.NotebookEdit: + return t('message.tools.labels.notebookEdit') + case AgentToolsType.TodoWrite: + return t('message.tools.labels.todoWrite') + case AgentToolsType.ExitPlanMode: + return t('message.tools.labels.exitPlanMode') + case AgentToolsType.Skill: + return t('message.tools.labels.skill') + default: + return toolName + } +} + +const getToolDescription = (toolResponse?: MCPToolResponse | NormalToolResponse): string | undefined => { + if (!toolResponse) return undefined + const args = toolResponse.arguments + if (!args || typeof args !== 'object' || Array.isArray(args)) return undefined + + // Common description fields + return ( + (args as Record).description || + (args as Record).file_path || + (args as Record).pattern || + (args as Record).query || + (args as Record).command || + (args as Record).url + )?.toString() +} + +// ============ Styled Components ============ + +const HeaderContainer = styled.div` + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + padding: 8px 12px; + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 0.75rem; + min-width: 0; +` + +// Label variant: no border/padding, for use inside Collapse header +const LabelContainer = styled.div` + display: flex; + align-items: center; + gap: 4px; + font-size: 14px; + min-width: 0; +` + +const ToolName = styled(Flex)` + font-weight: 500; + color: var(--color-text); + flex-shrink: 0; + + .tool-icon { + color: var(--color-primary); + } + + .name { + white-space: nowrap; + } +` + +const Description = styled.span` + color: var(--color-text-2); + font-weight: 400; + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + flex: 1; + max-width: 300px; +` + +const Stats = styled.span` + color: var(--color-text-2); + font-weight: 400; + font-size: 12px; + white-space: nowrap; + flex-shrink: 0; +` + +const StatusWrapper = styled.div` + display: flex; + align-items: center; + flex-shrink: 0; + margin-left: auto; +` + +// ============ Main Component ============ + +const ToolHeader: FC = ({ + block, + toolName: propToolName, + icon: propIcon, + params, + stats, + status: propStatus, + hasError: propHasError, + showStatus = true, + variant = 'standalone' +}) => { + const { t } = useTranslation() + + const toolResponse = block?.metadata?.rawMcpToolResponse + const tool = toolResponse?.tool + + const toolName = propToolName || tool?.name || 'Tool' + + const status = propStatus || (toolResponse?.status as ToolStatus) + const hasError = propHasError ?? toolResponse?.response?.isError === true + + const description = params ?? getToolDescription(toolResponse) + + const Container = variant === 'standalone' ? HeaderContainer : LabelContainer + + if (block && tool?.type === 'mcp') { + const mcpTool = tool as MCPTool + return ( + + + + + {mcpTool.serverName} : {mcpTool.name} + + {isToolAutoApproved(mcpTool) && ( + + + + )} + + {description && {description}} + {stats && {stats}} + {showStatus && status && ( + + + + )} + + ) + } + + return ( + + + {propIcon || getAgentToolIcon(toolName)} + {getAgentToolLabel(toolName, t)} + + {description && {description}} + {stats && {stats}} + {showStatus && status && ( + + + + )} + + ) +} + +export default memo(ToolHeader) diff --git a/src/renderer/src/pages/home/Messages/Tools/ToolPermissionRequestCard.tsx b/src/renderer/src/pages/home/Messages/Tools/ToolPermissionRequestCard.tsx index 0e0ba211f6..497f235588 100644 --- a/src/renderer/src/pages/home/Messages/Tools/ToolPermissionRequestCard.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/ToolPermissionRequestCard.tsx @@ -1,14 +1,16 @@ -import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk' -import { loggerService } from '@logger' -import { useAppDispatch, useAppSelector } from '@renderer/store' -import { selectPendingPermission, toolPermissionsActions } from '@renderer/store/toolPermissions' +import { LoadingIcon } from '@renderer/components/Icons' import type { NormalToolResponse } from '@renderer/types' -import { Button, Spin } from 'antd' -import { ChevronDown, CirclePlay, CircleX } from 'lucide-react' -import { useCallback, useEffect, useMemo, useState } from 'react' +import type { CollapseProps } from 'antd' +import { Collapse } from 'antd' +import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' +import styled from 'styled-components' -const logger = loggerService.withContext('ToolPermissionRequestCard') +import { useAgentToolApproval } from './hooks/useAgentToolApproval' +import { type StatusColor, StatusIndicatorContainer, StreamingContext } from './MessageAgentTools/GenericTools' +import { isValidAgentToolsType, renderTool } from './MessageAgentTools/index' +import { UnknownToolRenderer } from './MessageAgentTools/UnknownToolRenderer' +import ToolApprovalActionsComponent from './ToolApprovalActions' interface Props { toolResponse: NormalToolResponse @@ -16,250 +18,115 @@ interface Props { export function ToolPermissionRequestCard({ toolResponse }: Props) { const { t } = useTranslation() - const dispatch = useAppDispatch() - const request = useAppSelector((state) => selectPendingPermission(state.toolPermissions, toolResponse.toolCallId)) - const [now, setNow] = useState(() => Date.now()) - const [showDetails, setShowDetails] = useState(false) - useEffect(() => { - if (!request) return + const approval = useAgentToolApproval(null, { toolCallId: toolResponse.toolCallId }) - logger.debug('Rendering inline tool permission card', { - requestId: request.requestId, - toolName: request.toolName, - expiresAt: request.expiresAt - }) - - setNow(Date.now()) - - const interval = window.setInterval(() => { - setNow(Date.now()) - }, 500) - - return () => { - window.clearInterval(interval) + const statusInfo = useMemo((): { color: StatusColor; text: string; showLoading: boolean } => { + if (approval.isExecuting) { + return { color: 'primary', text: t('message.tools.invoking'), showLoading: true } } - }, [request]) + if (approval.isExpired) { + return { color: 'error', text: t('agent.toolPermission.expired'), showLoading: false } + } + return { + color: 'warning', + text: t('agent.toolPermission.pending', { seconds: approval.remainingSeconds }), + showLoading: true + } + }, [approval.isExecuting, approval.isExpired, approval.remainingSeconds, t]) - const remainingMs = useMemo(() => { - if (!request) return 0 - return Math.max(0, request.expiresAt - now) - }, [request, now]) + const renderToolContent = useCallback((): React.ReactNode => { + const toolName = toolResponse.tool?.name ?? '' + const input = (approval.input ?? toolResponse.arguments) as Record | undefined - const remainingSeconds = useMemo(() => Math.ceil(remainingMs / 1000), [remainingMs]) - const isExpired = remainingMs <= 0 + const renderedItem = isValidAgentToolsType(toolName) + ? renderTool(toolName, input) + : UnknownToolRenderer({ input, toolName }) - const isSubmittingAllow = request?.status === 'submitting-allow' - const isSubmittingDeny = request?.status === 'submitting-deny' - const isSubmitting = isSubmittingAllow || isSubmittingDeny - const isInvoking = request?.status === 'invoking' - - const handleDecision = useCallback( - async ( - behavior: 'allow' | 'deny', - extra?: { - updatedInput?: Record - updatedPermissions?: PermissionUpdate[] - message?: string - } - ) => { - if (!request) return - - logger.debug('Submitting inline tool permission decision', { - requestId: request.requestId, - toolName: request.toolName, - behavior - }) - - dispatch(toolPermissionsActions.submissionSent({ requestId: request.requestId, behavior })) - - try { - const payload = { - requestId: request.requestId, - behavior, - ...(behavior === 'allow' - ? { - updatedInput: extra?.updatedInput ?? request.input, - updatedPermissions: extra?.updatedPermissions - } - : { - message: extra?.message ?? t('agent.toolPermission.defaultDenyMessage') - }) - } - - const response = await window.api.agentTools.respondToPermission(payload) - - if (!response?.success) { - throw new Error('Renderer response rejected by main process') - } - - logger.debug('Tool permission decision acknowledged by main process', { - requestId: request.requestId, - behavior - }) - } catch (error) { - logger.error('Failed to send tool permission response', error as Error) - window.toast?.error?.(t('agent.toolPermission.error.sendFailed')) - dispatch(toolPermissionsActions.submissionFailed({ requestId: request.requestId })) - } - }, - [dispatch, request, t] - ) - - if (!request) { - return ( -
- {t('agent.toolPermission.waiting')} -
+ const statusIndicator = ( + + {statusInfo.text} + {statusInfo.showLoading && } + ) - } - if (isInvoking) { - return ( -
-
-
-
- -
-
{request.toolName}
-
{t('agent.toolPermission.executing')}
-
-
- {request.inputPreview && ( -
-
- )} -
- - {showDetails && request.inputPreview && ( -
-
-

- {t('agent.toolPermission.inputPreview')} -

-
-
{request.inputPreview}
-
-
-
- )} + const toolContentItem: NonNullable[number] = { + ...renderedItem, + label: ( +
+
{renderedItem.label}
+
{statusIndicator}
-
+ ), + classNames: { + body: 'bg-foreground-50 p-2 text-foreground-900 dark:bg-foreground-100 max-h-60 overflow-auto' + } + } + + return ( + + + ) - } + }, [toolResponse.tool?.name, approval.input, toolResponse.arguments, statusInfo]) return ( -
-
-
-
-
{request.toolName}
-
- {request.description?.trim() || t('agent.toolPermission.defaultDescription')} -
-
+ + {/* Tool content area with status in header */} + {renderToolContent()} -
-
- {isExpired - ? t('agent.toolPermission.expired') - : t('agent.toolPermission.pending', { seconds: remainingSeconds })} -
+ {/* Bottom action bar - only show when not invoking */} + {!approval.isExecuting && ( + + + + )} -
- - - - -
-
+ {approval.isExpired && !approval.isSubmitting && !approval.isExecuting && ( +
+ {t('agent.toolPermission.permissionExpired')}
- - {showDetails && ( -
-
- {t('agent.toolPermission.confirmation')} -
- -
-

- {t('agent.toolPermission.inputPreview')} -

-
-
{request.inputPreview}
-
-
- - {request.requiresPermissions && ( -
- {t('agent.toolPermission.requiresElevatedPermissions')} -
- )} - - {request.suggestions.length > 0 && ( -
- {request.suggestions.length === 1 - ? t('agent.toolPermission.suggestion.permissionUpdateSingle') - : t('agent.toolPermission.suggestion.permissionUpdateMultiple')} -
- )} -
- )} - - {isExpired && !isSubmitting && ( -
{t('agent.toolPermission.permissionExpired')}
- )} -
-
+ )} + ) } +const Container = styled.div` + width: 100%; + max-width: 36rem; + border-radius: 0.75rem; + border: 1px solid var(--color-border); + background-color: var(--color-background-soft); + overflow: hidden; + + .ant-collapse { + border: none; + border-radius: 0; + background: transparent; + } + + .ant-collapse-item { + border: none; + } + + .ant-collapse-header { + padding: 8px 12px !important; + } +` + +const ActionsBar = styled.div` + display: flex; + align-items: center; + justify-content: flex-end; + padding: 8px 12px; + border-top: 1px solid var(--color-border); + background-color: var(--color-background); +` + export default ToolPermissionRequestCard diff --git a/src/renderer/src/pages/home/Messages/Tools/__tests__/MessageAgentTools.test.tsx b/src/renderer/src/pages/home/Messages/Tools/__tests__/MessageAgentTools.test.tsx new file mode 100644 index 0000000000..5f48306fb9 --- /dev/null +++ b/src/renderer/src/pages/home/Messages/Tools/__tests__/MessageAgentTools.test.tsx @@ -0,0 +1,376 @@ +import type { NormalToolResponse } from '@renderer/types' +import { render, screen } from '@testing-library/react' +import { parse as parsePartialJson } from 'partial-json' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { isValidAgentToolsType, MessageAgentTools } from '../MessageAgentTools' + +vi.mock('@renderer/services/AssistantService', () => ({ + getDefaultAssistant: vi.fn(() => ({ + id: 'test-assistant', + name: 'Test Assistant', + settings: {} + })), + getDefaultTopic: vi.fn(() => ({ + id: 'test-topic', + assistantId: 'test-assistant', + createdAt: new Date().toISOString() + })) +})) + +// Mock dependencies +const mockUseAppSelector = vi.fn() +const mockUseTranslation = vi.fn() + +vi.mock('@renderer/store', () => ({ + useAppSelector: (selector: any) => mockUseAppSelector(selector) +})) + +vi.mock('@renderer/store/toolPermissions', () => ({ + selectPendingPermission: vi.fn() +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => mockUseTranslation(), + initReactI18next: { + type: '3rdParty', + init: vi.fn() + } +})) + +vi.mock('@logger', () => ({ + loggerService: { + withContext: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn() + }) + } +})) + +// Mock antd components +vi.mock('antd', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + Collapse: ({ items, defaultActiveKey, className }: any) => ( +
+ {items?.map((item: any) => ( +
+
{item.label}
+
{item.children}
+
+ ))} +
+ ), + Spin: ({ size }: any) =>
, + Skeleton: { + Input: ({ style }: any) => + }, + Tag: ({ children, className }: any) => ( + + {children} + + ), + Popover: ({ children }: any) => <>{children}, + Card: ({ children, className }: any) => ( +
+ {children} +
+ ), + Button: ({ children, onClick, type, size, icon, disabled }: any) => ( + + ) + } +}) + +// Mock lucide-react icons +vi.mock('lucide-react', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + Loader2: ({ className }: any) => , + FileText: () => , + Terminal: () => , + ListTodo: () => , + Circle: () => , + CheckCircle: () => , + Clock: () => , + Check: () => , + TriangleAlert: () => , + X: () => , + Wrench: () => , + ImageIcon: () => + } +}) + +// Mock LoadingIcon +vi.mock('@renderer/components/Icons', () => ({ + LoadingIcon: () => +})) + +// Mock ToolPermissionRequestCard +vi.mock('../ToolPermissionRequestCard', () => ({ + default: () =>
Permission Required
+})) + +describe('MessageAgentTools', () => { + // Mock translations for tools + const mockTranslations: Record = { + 'message.tools.labels.bash': 'Bash', + 'message.tools.labels.readFile': 'Read File', + 'message.tools.labels.todoWrite': 'Todo Write', + 'message.tools.labels.edit': 'Edit', + 'message.tools.labels.write': 'Write', + 'message.tools.labels.grep': 'Grep', + 'message.tools.labels.glob': 'Glob', + 'message.tools.labels.webSearch': 'Web Search', + 'message.tools.labels.webFetch': 'Web Fetch', + 'message.tools.labels.skill': 'Skill', + 'message.tools.labels.task': 'Task', + 'message.tools.labels.search': 'Search', + 'message.tools.labels.exitPlanMode': 'ExitPlanMode', + 'message.tools.labels.multiEdit': 'MultiEdit', + 'message.tools.labels.notebookEdit': 'NotebookEdit', + 'message.tools.labels.mcpServerTool': 'MCP Server Tool', + 'message.tools.labels.tool': 'Tool', + 'message.tools.sections.command': 'Command', + 'message.tools.sections.output': 'Output', + 'message.tools.sections.prompt': 'Prompt', + 'message.tools.sections.input': 'Input', + 'message.tools.status.done': 'Done', + 'message.tools.units.item': 'item', + 'message.tools.units.items': 'items', + 'message.tools.units.line': 'line', + 'message.tools.units.lines': 'lines', + 'message.tools.units.file': 'file', + 'message.tools.units.files': 'files', + 'message.tools.units.result': 'result', + 'message.tools.units.results': 'results' + } + + beforeEach(() => { + mockUseAppSelector.mockReturnValue(null) // No pending permission + mockUseTranslation.mockReturnValue({ + t: (key: string, fallback?: string) => mockTranslations[key] ?? fallback ?? key + }) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + // Helper to create tool response + const createToolResponse = (overrides: Partial = {}): NormalToolResponse => ({ + id: 'test-tool-1', + tool: { + id: 'Read', + name: 'Read', + description: 'Read a file', + type: 'provider' + }, + arguments: undefined, + status: 'pending', + toolCallId: 'call-123', + ...overrides + }) + + describe('isValidAgentToolsType', () => { + it('should return true for valid tool types', () => { + expect(isValidAgentToolsType('Read')).toBe(true) + expect(isValidAgentToolsType('Bash')).toBe(true) + expect(isValidAgentToolsType('TodoWrite')).toBe(true) + }) + + it('should return false for invalid tool types', () => { + expect(isValidAgentToolsType('InvalidTool')).toBe(false) + expect(isValidAgentToolsType('')).toBe(false) + expect(isValidAgentToolsType(null)).toBe(false) + expect(isValidAgentToolsType(undefined)).toBe(false) + }) + }) + + describe('partial-json parsing', () => { + it('should parse partial JSON correctly', () => { + // Test partial-json library behavior + const partialJson = '{"file_path": "/test.ts"' + const parsed = parsePartialJson(partialJson) + expect(parsed).toEqual({ file_path: '/test.ts' }) + }) + + it('should parse nested partial JSON', () => { + const partialJson = '{"todos": [{"content": "Task 1", "status": "pending"' + const parsed = parsePartialJson(partialJson) + expect(parsed).toEqual({ + todos: [{ content: 'Task 1', status: 'pending' }] + }) + }) + + it('should handle empty partial JSON', () => { + const partialJson = '{' + const parsed = parsePartialJson(partialJson) + expect(parsed).toEqual({}) + }) + }) + + describe('streaming tool rendering', () => { + it('should render dedicated tool renderer with partial arguments during streaming', () => { + const toolResponse = createToolResponse({ + tool: { id: 'Read', name: 'Read', description: 'Read a file', type: 'provider' }, + status: 'streaming', + partialArguments: '{"file_path": "/test.ts"' + }) + + render() + + // Should render the DEDICATED ReadTool component, not StreamingToolContent + // ReadTool uses 'Read File' as label, not just 'Read' + expect(screen.getByText('Read File')).toBeInTheDocument() + // Should show the filename from partial args + expect(screen.getByText('test.ts')).toBeInTheDocument() + }) + + it('should pass parsed partial arguments to dedicated tool renderer', () => { + const toolResponse = createToolResponse({ + tool: { id: 'Read', name: 'Read', description: 'Read a file', type: 'provider' }, + status: 'streaming', + partialArguments: '{"file_path": "/path/to/myfile.ts", "offset": 10' + }) + + render() + + // Should use dedicated ReadTool renderer + expect(screen.getByText('Read File')).toBeInTheDocument() + // Should show the filename extracted by ReadTool + expect(screen.getByText('myfile.ts')).toBeInTheDocument() + }) + + it('should update dedicated renderer as more arguments stream in', () => { + const initialResponse = createToolResponse({ + tool: { id: 'Read', name: 'Read', description: 'Read a file', type: 'provider' }, + status: 'streaming', + partialArguments: '{"file_path": "/test/partial' + }) + + const { rerender } = render() + + // Should use dedicated renderer even with partial path + expect(screen.getByText('Read File')).toBeInTheDocument() + + // Update with status changed to pending when arguments complete + const updatedResponse = createToolResponse({ + tool: { id: 'Read', name: 'Read', description: 'Read a file', type: 'provider' }, + status: 'pending', + partialArguments: '{"file_path": "/test/complete.ts", "limit": 100}' + }) + + rerender() + + // When pending with no permission, shows ToolStatusIndicator with loading icon + expect(screen.getByTestId('loading-icon')).toBeInTheDocument() + }) + }) + + describe('completed tool rendering', () => { + it('should render tool with full arguments when done', () => { + const toolResponse = createToolResponse({ + tool: { id: 'Read', name: 'Read', description: 'Read a file', type: 'provider' }, + status: 'done', + arguments: { file_path: '/test.ts', limit: 100 }, + response: 'file content here' + }) + + render() + + // Should render the complete tool with output + expect(screen.getByText('Read File')).toBeInTheDocument() + }) + + it('should render error state correctly', () => { + const toolResponse = createToolResponse({ + tool: { id: 'Read', name: 'Read', description: 'Read a file', type: 'provider' }, + status: 'error', + arguments: { file_path: '/nonexistent.ts' }, + response: 'File not found' + }) + + render() + + // Should still render the tool component + expect(screen.getByText('Read File')).toBeInTheDocument() + }) + }) + + describe('pending without streaming', () => { + it('should show permission card when pending permission exists', () => { + mockUseAppSelector.mockReturnValue({ toolCallId: 'call-123' }) // Has pending permission + + const toolResponse = createToolResponse({ + status: 'pending', + partialArguments: undefined + }) + + render() + + expect(screen.getByTestId('permission-card')).toBeInTheDocument() + }) + + it('should show pending indicator when no streaming and no permission', () => { + const toolResponse = createToolResponse({ + status: 'pending', + partialArguments: undefined + }) + + render() + + // Should show the ToolStatusIndicator with loading icon + expect(screen.getByTestId('loading-icon')).toBeInTheDocument() + }) + }) + + describe('TodoWrite streaming', () => { + it('should render TodoWrite dedicated renderer with partial todos during streaming', () => { + const toolResponse = createToolResponse({ + tool: { id: 'TodoWrite', name: 'TodoWrite', description: 'Write todos', type: 'provider' }, + status: 'streaming', + partialArguments: + '{"todos": [{"content": "First task", "status": "pending", "activeForm": "Working on first task"}' + }) + + render() + + // Should render the DEDICATED TodoWriteTool component, not StreamingToolContent + // TodoWriteTool uses 'Todo Write' (with space) as label + expect(screen.getByText('Todo Write')).toBeInTheDocument() + // The partial todo content should be visible in the dedicated renderer + expect(screen.getByText(/First task/)).toBeInTheDocument() + }) + }) + + describe('Bash streaming', () => { + it('should render Bash dedicated renderer with partial command during streaming', () => { + const toolResponse = createToolResponse({ + tool: { id: 'Bash', name: 'Bash', description: 'Execute command', type: 'provider' }, + status: 'streaming', + partialArguments: '{"command": "npm install",' + }) + + render() + + // Should render the DEDICATED BashTool component + expect(screen.getByText('Bash')).toBeInTheDocument() + // Command should be visible in the dedicated renderer + expect(screen.getByText(/npm install/)).toBeInTheDocument() + }) + }) +}) diff --git a/src/renderer/src/pages/home/Messages/Tools/hooks/index.ts b/src/renderer/src/pages/home/Messages/Tools/hooks/index.ts new file mode 100644 index 0000000000..034926b4d2 --- /dev/null +++ b/src/renderer/src/pages/home/Messages/Tools/hooks/index.ts @@ -0,0 +1,12 @@ +// Tool approval hooks - unified abstraction for MCP and Agent tool approval +export { + isBlockWaitingApproval, + type ToolApprovalActions, + type ToolApprovalState, + useAgentToolApproval, + type UseAgentToolApprovalOptions, + useMcpToolApproval, + type UseMcpToolApprovalOptions, + useToolApproval, + type UseToolApprovalOptions +} from './useToolApproval' diff --git a/src/renderer/src/pages/home/Messages/Tools/hooks/useAgentToolApproval.ts b/src/renderer/src/pages/home/Messages/Tools/hooks/useAgentToolApproval.ts new file mode 100644 index 0000000000..df244cdc2b --- /dev/null +++ b/src/renderer/src/pages/home/Messages/Tools/hooks/useAgentToolApproval.ts @@ -0,0 +1,163 @@ +import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk' +import { loggerService } from '@logger' +import { useAppDispatch, useAppSelector } from '@renderer/store' +import { selectPendingPermission, toolPermissionsActions } from '@renderer/store/toolPermissions' +import type { NormalToolResponse } from '@renderer/types' +import type { ToolMessageBlock } from '@renderer/types/newMessage' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import type { ToolApprovalActions, ToolApprovalState } from './useToolApproval' + +const logger = loggerService.withContext('useAgentToolApproval') + +export interface UseAgentToolApprovalOptions { + /** Direct toolCallId (alternative to extracting from block) */ + toolCallId?: string +} + +/** + * Hook for Agent tool approval logic + * Can be used with: + * - A ToolMessageBlock (extracts toolCallId from metadata) + * - A direct toolCallId via options + */ +export function useAgentToolApproval( + block?: ToolMessageBlock | null, + options: UseAgentToolApprovalOptions = {} +): ToolApprovalState & ToolApprovalActions { + const { t } = useTranslation() + const dispatch = useAppDispatch() + + const toolResponse = block?.metadata?.rawMcpToolResponse as NormalToolResponse | undefined + const toolCallId = options.toolCallId ?? toolResponse?.toolCallId ?? '' + + const request = useAppSelector((state) => selectPendingPermission(state.toolPermissions, toolCallId)) + + const [now, setNow] = useState(() => Date.now()) + + // Update time every 500ms to track expiration + useEffect(() => { + if (!request) return + + logger.debug('Tracking agent tool permission', { + requestId: request.requestId, + toolName: request.toolName, + expiresAt: request.expiresAt + }) + + setNow(Date.now()) + + const interval = window.setInterval(() => { + setNow(Date.now()) + }, 500) + + return () => { + window.clearInterval(interval) + } + }, [request]) + + const remainingMs = useMemo(() => { + if (!request) return 0 + return Math.max(0, request.expiresAt - now) + }, [request, now]) + + const remainingSeconds = useMemo(() => Math.ceil(remainingMs / 1000), [remainingMs]) + const isExpired = remainingMs <= 0 + + const isSubmittingAllow = request?.status === 'submitting-allow' + const isSubmittingDeny = request?.status === 'submitting-deny' + const isSubmitting = isSubmittingAllow || isSubmittingDeny + const isInvoking = request?.status === 'invoking' + const isPending = request?.status === 'pending' + + const handleDecision = useCallback( + async ( + behavior: 'allow' | 'deny', + extra?: { + updatedInput?: Record + updatedPermissions?: PermissionUpdate[] + message?: string + } + ) => { + if (!request) return + + logger.debug('Submitting agent tool permission decision', { + requestId: request.requestId, + toolName: request.toolName, + behavior + }) + + dispatch(toolPermissionsActions.submissionSent({ requestId: request.requestId, behavior })) + + try { + const payload = { + requestId: request.requestId, + behavior, + ...(behavior === 'allow' + ? { + updatedInput: extra?.updatedInput ?? request.input, + updatedPermissions: extra?.updatedPermissions + } + : { + message: extra?.message ?? t('agent.toolPermission.defaultDenyMessage') + }) + } + + const response = await window.api.agentTools.respondToPermission(payload) + + if (!response?.success) { + throw new Error('Renderer response rejected by main process') + } + + logger.debug('Tool permission decision acknowledged by main process', { + requestId: request.requestId, + behavior + }) + } catch (error) { + logger.error('Failed to send tool permission response', error as Error) + window.toast?.error?.(t('agent.toolPermission.error.sendFailed')) + dispatch(toolPermissionsActions.submissionFailed({ requestId: request.requestId })) + } + }, + [dispatch, request, t] + ) + + const confirm = useCallback(() => { + handleDecision('allow') + }, [handleDecision]) + + const cancel = useCallback(() => { + handleDecision('deny') + }, [handleDecision]) + + // Auto-approve with suggestions if available + const autoApprove = useCallback(() => { + if (request?.suggestions?.length) { + handleDecision('allow', { updatedPermissions: request.suggestions }) + } + }, [handleDecision, request?.suggestions]) + + // Determine isWaiting - only when pending and not expired + const isWaiting = !!request && isPending && !isExpired + // isExecuting - when invoking or submitting allow + const isExecuting = isInvoking || isSubmittingAllow + + return { + // State + isWaiting, + isExecuting, + countdown: undefined, + expiresAt: request?.expiresAt, + remainingSeconds, + isExpired: !!request && isExpired, + isSubmitting, + // Agent-specific: input from permission request + input: request?.input, + + // Actions + confirm, + cancel, + autoApprove: request?.suggestions?.length ? autoApprove : undefined + } +} diff --git a/src/renderer/src/pages/home/Messages/Tools/hooks/useMcpToolApproval.ts b/src/renderer/src/pages/home/Messages/Tools/hooks/useMcpToolApproval.ts new file mode 100644 index 0000000000..52cac75860 --- /dev/null +++ b/src/renderer/src/pages/home/Messages/Tools/hooks/useMcpToolApproval.ts @@ -0,0 +1,139 @@ +import { loggerService } from '@logger' +import { useMCPServers } from '@renderer/hooks/useMCPServers' +import { useTimer } from '@renderer/hooks/useTimer' +import type { MCPToolResponse } from '@renderer/types' +import type { ToolMessageBlock } from '@renderer/types/newMessage' +import { isToolAutoApproved } from '@renderer/utils/mcp-tools' +import { cancelToolAction, confirmToolAction } from '@renderer/utils/userConfirmation' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import type { ToolApprovalActions, ToolApprovalState } from './useToolApproval' + +const logger = loggerService.withContext('useMcpToolApproval') + +const COUNTDOWN_TIME = 30 + +export interface UseMcpToolApprovalOptions { + /** Disable countdown auto-approve */ + disableCountdown?: boolean +} + +/** + * Hook for MCP tool approval logic + * Extracts approval state management from MessageMcpTool + */ +export function useMcpToolApproval( + block: ToolMessageBlock, + options: UseMcpToolApprovalOptions = {} +): ToolApprovalState & ToolApprovalActions { + const { disableCountdown = false } = options + const { t } = useTranslation() + const { mcpServers, updateMCPServer } = useMCPServers() + const { setTimeoutTimer, clearTimeoutTimer } = useTimer() + + const toolResponse = block.metadata?.rawMcpToolResponse as MCPToolResponse | undefined + const tool = toolResponse?.tool + const id = toolResponse?.id ?? '' + const status = toolResponse?.status + + const isPending = status === 'pending' + + const isAutoApproved = useMemo(() => { + if (!tool) return false + return isToolAutoApproved( + tool, + mcpServers.find((s) => s.id === tool.serverId) + ) + }, [tool, mcpServers]) + + const [countdown, setCountdown] = useState(COUNTDOWN_TIME) + const [isConfirmed, setIsConfirmed] = useState(isAutoApproved) + + // Compute approval states + const isWaiting = isPending && !isAutoApproved && !isConfirmed + const isExecuting = isPending && (isAutoApproved || isConfirmed) + + // Countdown timer effect + useEffect(() => { + if (!isWaiting || disableCountdown) return + + if (countdown > 0) { + setTimeoutTimer( + `countdown-${id}`, + () => { + logger.debug(`countdown: ${countdown}`) + setCountdown((prev) => prev - 1) + }, + 1000 + ) + } else if (countdown === 0) { + setIsConfirmed(true) + confirmToolAction(id) + } + + return () => clearTimeoutTimer(`countdown-${id}`) + }, [countdown, id, isWaiting, disableCountdown, setTimeoutTimer, clearTimeoutTimer]) + + const cancelCountdown = useCallback(() => { + clearTimeoutTimer(`countdown-${id}`) + }, [clearTimeoutTimer, id]) + + const confirm = useCallback(() => { + cancelCountdown() + setIsConfirmed(true) + confirmToolAction(id) + }, [cancelCountdown, id]) + + const cancel = useCallback(() => { + cancelCountdown() + cancelToolAction(id) + }, [cancelCountdown, id]) + + const autoApprove = useCallback(async () => { + cancelCountdown() + + if (!tool || !tool.name) { + return + } + + const server = mcpServers.find((s) => s.id === tool.serverId) + if (!server) { + return + } + + let disabledAutoApproveTools = [...(server.disabledAutoApproveTools || [])] + + // Remove tool from disabledAutoApproveTools to enable auto-approve + disabledAutoApproveTools = disabledAutoApproveTools.filter((name) => name !== tool.name) + + const updatedServer = { + ...server, + disabledAutoApproveTools + } + + updateMCPServer(updatedServer) + + // Also confirm the current tool + setIsConfirmed(true) + confirmToolAction(id) + + window.toast.success(t('message.tools.autoApproveEnabled', 'Auto-approve enabled for this tool')) + }, [cancelCountdown, tool, mcpServers, updateMCPServer, id, t]) + + return { + // State + isWaiting, + isExecuting, + countdown, + remainingSeconds: countdown, + isExpired: false, // MCP tools don't expire, they auto-confirm + isSubmitting: false, + input: undefined, // MCP tools get input from toolResponse.arguments + + // Actions + confirm, + cancel, + autoApprove: isWaiting ? autoApprove : undefined + } +} diff --git a/src/renderer/src/pages/home/Messages/Tools/hooks/useToolApproval.ts b/src/renderer/src/pages/home/Messages/Tools/hooks/useToolApproval.ts new file mode 100644 index 0000000000..e7121dbaa8 --- /dev/null +++ b/src/renderer/src/pages/home/Messages/Tools/hooks/useToolApproval.ts @@ -0,0 +1,77 @@ +import type { ToolMessageBlock } from '@renderer/types/newMessage' + +import { useAgentToolApproval } from './useAgentToolApproval' +import { useMcpToolApproval, type UseMcpToolApprovalOptions } from './useMcpToolApproval' + +/** + * Unified tool approval state + */ +export interface ToolApprovalState { + /** Whether the tool is waiting for user confirmation */ + isWaiting: boolean + /** Whether the tool is currently executing after approval */ + isExecuting: boolean + /** Countdown seconds (MCP only) */ + countdown?: number + /** Expiration timestamp (Agent only) */ + expiresAt?: number + /** Remaining seconds until auto-confirm (MCP) or expiration (Agent) */ + remainingSeconds: number + /** Whether the request has expired (Agent only) */ + isExpired: boolean + /** Whether a submission is in progress (Agent only) */ + isSubmitting: boolean + /** Tool input from permission request (Agent only) */ + input?: Record +} + +/** + * Unified tool approval actions + */ +export interface ToolApprovalActions { + /** Confirm/approve the tool execution */ + confirm: () => void | Promise + /** Cancel/deny the tool execution */ + cancel: () => void | Promise + /** Auto-approve this tool for future calls (if available) */ + autoApprove?: () => void | Promise +} + +export interface UseToolApprovalOptions extends UseMcpToolApprovalOptions { + /** Force a specific approval type */ + forceType?: 'mcp' | 'agent' +} + +/** + * Unified hook for tool approval - automatically selects between MCP and Agent approval + * based on the tool type in the block metadata. + * + * @param block - The tool message block + * @param options - Optional configuration + * @returns Unified approval state and actions + */ +export function useToolApproval( + block: ToolMessageBlock, + options: UseToolApprovalOptions = {} +): ToolApprovalState & ToolApprovalActions { + const { forceType, ...mcpOptions } = options + + const toolResponse = block.metadata?.rawMcpToolResponse + const tool = toolResponse?.tool + + const isMcpTool = forceType === 'mcp' || (forceType !== 'agent' && tool?.type === 'mcp') + const mcpApproval = useMcpToolApproval(block, mcpOptions) + const agentApproval = useAgentToolApproval(block) + + return isMcpTool ? mcpApproval : agentApproval +} + +/** + * Determine if a block needs approval (either MCP or Agent) + */ +export function isBlockWaitingApproval(block: ToolMessageBlock): boolean { + return block.metadata?.rawMcpToolResponse?.status === 'pending' +} + +export { useAgentToolApproval, type UseAgentToolApprovalOptions } from './useAgentToolApproval' +export { useMcpToolApproval, type UseMcpToolApprovalOptions } from './useMcpToolApproval' diff --git a/src/renderer/src/pages/home/Messages/Tools/shared/ArgsTable.tsx b/src/renderer/src/pages/home/Messages/Tools/shared/ArgsTable.tsx new file mode 100644 index 0000000000..8053d0986a --- /dev/null +++ b/src/renderer/src/pages/home/Messages/Tools/shared/ArgsTable.tsx @@ -0,0 +1,104 @@ +import styled from 'styled-components' + +import { SkeletonSpan } from '../MessageAgentTools/GenericTools' + +/** + * Format argument value for display in table + */ +export const formatArgValue = (value: unknown): string => { + if (value === null) return 'null' + if (value === undefined) return '' + if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'boolean') return String(value) + return JSON.stringify(value) +} + +/** + * Shared argument table component for displaying tool parameters + * Used by both MCP tools and Agent tools + */ +export function ToolArgsTable({ + args, + title, + isStreaming = false +}: { + args: Record | unknown[] | null | undefined + title?: string + isStreaming?: boolean +}) { + if (!args) return null + + // Handle both object and array args + const entries: Array<[string, unknown]> = Array.isArray(args) ? [['arguments', args]] : Object.entries(args) + + if (entries.length === 0 && !isStreaming) return null + + return ( + + {title && {title}} + + + {entries.map(([key, value]) => ( + + {key} + {formatArgValue(value)} + + ))} + {isStreaming && ( + + + + + + + + + )} + + + + ) +} + +// Styled components extracted from MessageMcpTool + +export const ArgsSection = styled.div` + padding: 8px 12px; + font-family: var(--font-family-mono, monospace); + font-size: 12px; + line-height: 1.5; +` + +export const ArgsSectionTitle = styled.div` + font-size: 11px; + font-weight: 600; + color: var(--color-text-3); + text-transform: uppercase; + margin-bottom: 8px; +` + +export const ArgsTable = styled.table` + width: 100%; + border-collapse: collapse; +` + +export const ArgKey = styled.td` + color: var(--color-primary); + padding: 4px 8px 4px 0; + white-space: nowrap; + vertical-align: top; + font-weight: 500; + width: 1%; +` + +export const ArgValue = styled.td` + color: var(--color-text); + padding: 4px 0; + word-break: break-all; + white-space: pre-wrap; +` + +export const ResponseSection = styled.div` + padding: 8px 12px; + border-top: 1px solid var(--color-border); +` diff --git a/src/renderer/src/pages/home/Messages/Tools/shared/truncateOutput.ts b/src/renderer/src/pages/home/Messages/Tools/shared/truncateOutput.ts new file mode 100644 index 0000000000..4d417e520b --- /dev/null +++ b/src/renderer/src/pages/home/Messages/Tools/shared/truncateOutput.ts @@ -0,0 +1,44 @@ +/** + * Truncate output string to prevent UI performance issues + * Tries to truncate at a newline boundary to avoid cutting in the middle of a line + */ + +const MAX_OUTPUT_LENGTH = 50000 + +/** + * Count non-empty lines in a string + */ +export function countLines(output: string | undefined | null): number { + if (!output) return 0 + return output.split('\n').filter((line) => line.trim()).length +} + +export interface TruncateResult { + data: string + isTruncated: boolean + originalLength: number +} + +export function truncateOutput( + output: string | undefined | null, + maxLength: number = MAX_OUTPUT_LENGTH +): TruncateResult { + if (!output) { + return { data: '', isTruncated: false, originalLength: 0 } + } + + const originalLength = output.length + + if (output.length <= maxLength) { + return { data: output, isTruncated: false, originalLength } + } + + // Truncate and try to find a newline boundary + const truncated = output.slice(0, maxLength) + const lastNewline = truncated.lastIndexOf('\n') + + // Only use newline boundary if it's reasonably close to maxLength (within 20%) + const data = lastNewline > maxLength * 0.8 ? truncated.slice(0, lastNewline) : truncated + + return { data, isTruncated: true, originalLength } +} diff --git a/src/renderer/src/services/StreamProcessingService.ts b/src/renderer/src/services/StreamProcessingService.ts index 7e80672d5d..bf54172ae9 100644 --- a/src/renderer/src/services/StreamProcessingService.ts +++ b/src/renderer/src/services/StreamProcessingService.ts @@ -1,5 +1,11 @@ import { loggerService } from '@logger' -import type { ExternalToolResult, GenerateImageResponse, MCPToolResponse, WebSearchResponse } from '@renderer/types' +import type { + ExternalToolResult, + GenerateImageResponse, + MCPToolResponse, + NormalToolResponse, + WebSearchResponse +} from '@renderer/types' import type { Chunk } from '@renderer/types/chunk' import { ChunkType } from '@renderer/types/chunk' import type { Response } from '@renderer/types/newMessage' @@ -23,9 +29,11 @@ export interface StreamProcessorCallbacks { onThinkingChunk?: (text: string, thinking_millsec?: number) => void onThinkingComplete?: (text: string, thinking_millsec?: number) => void // A tool call response chunk (from MCP) - onToolCallPending?: (toolResponse: MCPToolResponse) => void - onToolCallInProgress?: (toolResponse: MCPToolResponse) => void - onToolCallComplete?: (toolResponse: MCPToolResponse) => void + onToolCallPending?: (toolResponse: MCPToolResponse | NormalToolResponse) => void + onToolCallInProgress?: (toolResponse: MCPToolResponse | NormalToolResponse) => void + onToolCallComplete?: (toolResponse: MCPToolResponse | NormalToolResponse) => void + // Tool argument streaming (partial arguments during streaming) + onToolArgumentStreaming?: (toolResponse: MCPToolResponse | NormalToolResponse) => void // External tool call in progress onExternalToolInProgress?: () => void // Citation data received (e.g., from Internet and Knowledge Base) @@ -109,6 +117,12 @@ export function createStreamProcessor(callbacks: StreamProcessorCallbacks = {}) } break } + case ChunkType.MCP_TOOL_STREAMING: { + if (callbacks.onToolArgumentStreaming) { + data.responses.forEach((toolResp) => callbacks.onToolArgumentStreaming!(toolResp)) + } + break + } case ChunkType.EXTERNEL_TOOL_IN_PROGRESS: { if (callbacks.onExternalToolInProgress) callbacks.onExternalToolInProgress() break diff --git a/src/renderer/src/services/messageStreaming/callbacks/toolCallbacks.ts b/src/renderer/src/services/messageStreaming/callbacks/toolCallbacks.ts index 74d854d665..6261cdfd1d 100644 --- a/src/renderer/src/services/messageStreaming/callbacks/toolCallbacks.ts +++ b/src/renderer/src/services/messageStreaming/callbacks/toolCallbacks.ts @@ -1,7 +1,7 @@ import { loggerService } from '@logger' import type { AppDispatch } from '@renderer/store' import { toolPermissionsActions } from '@renderer/store/toolPermissions' -import type { MCPToolResponse } from '@renderer/types' +import type { MCPToolResponse, NormalToolResponse } from '@renderer/types' import { WebSearchSource } from '@renderer/types' import type { ToolMessageBlock } from '@renderer/types/newMessage' import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage' @@ -11,6 +11,8 @@ import type { BlockManager } from '../BlockManager' const logger = loggerService.withContext('ToolCallbacks') +type ToolResponse = MCPToolResponse | NormalToolResponse + interface ToolCallbacksDependencies { blockManager: BlockManager assistantMsgId: string @@ -26,7 +28,7 @@ export const createToolCallbacks = (deps: ToolCallbacksDependencies) => { let citationBlockId: string | null = null return { - onToolCallPending: (toolResponse: MCPToolResponse) => { + onToolCallPending: (toolResponse: ToolResponse) => { logger.debug('onToolCallPending', toolResponse) if (blockManager.hasInitialPlaceholder) { @@ -55,7 +57,46 @@ export const createToolCallbacks = (deps: ToolCallbacksDependencies) => { } }, - onToolCallComplete: (toolResponse: MCPToolResponse) => { + onToolArgumentStreaming: (toolResponse: ToolResponse) => { + // Find or create the tool block for streaming updates + let existingBlockId = toolCallIdToBlockIdMap.get(toolResponse.id) + + if (!existingBlockId) { + // Create a new tool block if one doesn't exist yet + if (blockManager.hasInitialPlaceholder) { + const changes = { + type: MessageBlockType.TOOL, + status: MessageBlockStatus.PENDING, + toolName: toolResponse.tool.name, + metadata: { rawMcpToolResponse: toolResponse } + } + toolBlockId = blockManager.initialPlaceholderBlockId! + blockManager.smartBlockUpdate(toolBlockId, changes, MessageBlockType.TOOL) + toolCallIdToBlockIdMap.set(toolResponse.id, toolBlockId) + existingBlockId = toolBlockId + } else { + const toolBlock = createToolBlock(assistantMsgId, toolResponse.id, { + toolName: toolResponse.tool.name, + status: MessageBlockStatus.PENDING, + metadata: { rawMcpToolResponse: toolResponse } + }) + toolBlockId = toolBlock.id + blockManager.handleBlockTransition(toolBlock, MessageBlockType.TOOL) + toolCallIdToBlockIdMap.set(toolResponse.id, toolBlock.id) + existingBlockId = toolBlock.id + } + } + + // Update the tool block with streaming arguments + const changes: Partial = { + status: MessageBlockStatus.PENDING, + metadata: { rawMcpToolResponse: toolResponse } + } + + blockManager.smartBlockUpdate(existingBlockId, changes, MessageBlockType.TOOL) + }, + + onToolCallComplete: (toolResponse: ToolResponse) => { if (toolResponse?.id) { dispatch(toolPermissionsActions.removeByToolCallId({ toolCallId: toolResponse.id })) } diff --git a/src/renderer/src/types/chunk.ts b/src/renderer/src/types/chunk.ts index 345d8a385c..c6e79fe337 100644 --- a/src/renderer/src/types/chunk.ts +++ b/src/renderer/src/types/chunk.ts @@ -24,6 +24,7 @@ export enum ChunkType { MCP_TOOL_PENDING = 'mcp_tool_pending', MCP_TOOL_IN_PROGRESS = 'mcp_tool_in_progress', MCP_TOOL_COMPLETE = 'mcp_tool_complete', + MCP_TOOL_STREAMING = 'mcp_tool_streaming', // NEW: Streaming tool arguments EXTERNEL_TOOL_COMPLETE = 'externel_tool_complete', LLM_RESPONSE_CREATED = 'llm_response_created', LLM_RESPONSE_IN_PROGRESS = 'llm_response_in_progress', @@ -329,6 +330,20 @@ export interface MCPToolCompleteChunk { type: ChunkType.MCP_TOOL_COMPLETE } +/** + * Streaming tool arguments chunk - emitted during tool-input-delta events + */ +export interface MCPToolStreamingChunk { + /** + * The type of the chunk + */ + type: ChunkType.MCP_TOOL_STREAMING + /** + * The tool responses with streaming arguments + */ + responses: (MCPToolResponse | NormalToolResponse)[] +} + export interface LLMResponseCompleteChunk { /** * The response @@ -438,6 +453,7 @@ export type Chunk = | MCPToolPendingChunk // MCP工具调用等待中 | MCPToolInProgressChunk // MCP工具调用中 | MCPToolCompleteChunk // MCP工具调用完成 + | MCPToolStreamingChunk // MCP工具参数流式传输中 | ExternalToolCompleteChunk // 外部工具调用完成,外部工具包含搜索互联网,知识库,MCP服务器 | LLMResponseCreatedChunk // 大模型响应创建,返回即将创建的块类型 | LLMResponseInProgressChunk // 大模型响应进行中 diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 3138a55188..20b30bba87 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -820,7 +820,7 @@ export interface MCPConfig { isBunInstalled: boolean } -export type MCPToolResponseStatus = 'pending' | 'cancelled' | 'invoking' | 'done' | 'error' +export type MCPToolResponseStatus = 'pending' | 'streaming' | 'cancelled' | 'invoking' | 'done' | 'error' interface BaseToolResponse { id: string // unique id @@ -828,6 +828,8 @@ interface BaseToolResponse { arguments: Record | Record[] | string | undefined status: MCPToolResponseStatus response?: any + // Streaming arguments support + partialArguments?: string // Accumulated partial JSON string during streaming } export interface ToolUseResponse extends BaseToolResponse { @@ -844,11 +846,13 @@ export interface MCPToolResponse extends Omit { tool: BaseTool toolCallId: string + parentToolUseId?: string } export interface MCPToolResultContent {