mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-02-05 02:21:12 +08:00
refactor(agent): improve tool call render ui/ux (#12540)
* refactor: Tool Permission Request Card and Streaming Tool Functionality - Refactor ToolPermissionRequestCard to improve rendering of tool content based on tool type. - Introduce a new ArgsTable component for displaying tool parameters. - Implement streaming support for tool arguments in StreamProcessingService and related callbacks. - Add new chunk type for streaming tool arguments in chunk types. - Update MCPToolResponseStatus to include 'streaming' state. - Create comprehensive tests for MessageAgentTools to cover various tool states and argument streaming. * feat: 添加工具状态指示器组件并更新相关工具渲染逻辑 * refactor: 优化 BashTool 组件,移除多余的输出行数计算和标签展示 * feat: 添加输出截断功能以优化工具输出显示 * feat(i18n): add translations for tool labels and sections in multiple languages - Updated Portuguese (pt-pt), Romanian (ro-ro), and Russian (ru-ru) translation files to include new labels and sections for various tools. - Integrated translation functionality into BashOutputTool, BashTool, EditTool, ExitPlanModeTool, GlobTool, GrepTool, MultiEditTool, NotebookEditTool, ReadTool, SearchTool, SkillTool, TaskTool, TodoWriteTool, UnknownToolRenderer, WebFetchTool, and WebSearchTool components. - Replaced hardcoded strings with translation keys for better localization support. * chore(i18n): clarify * refactor: 更新 ClaudeCodeService 和 transform 函数以增强工具调用语言提示和处理工具结果消息 * refactor: language instruction * feat(ui): group consecutive tool calls for better readability - Add ToolBlockGroup component to display multiple consecutive tool calls in a collapsible group - Modify groupSimilarBlocks to group consecutive TOOL blocks together - Add i18n translations for group header (e.g., "36 tool calls") - Style group header consistent with individual tool blocks Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(ui): group consecutive tool calls for better readability - Add ToolBlockGroup component to display multiple consecutive tool calls in a collapsible group - Modify groupSimilarBlocks to group consecutive TOOL blocks together - Add i18n translations for group header (e.g., "36 tool calls") - Style group header consistent with individual tool blocks Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: 更新 truncateOutput 函数以统一返回结构,并调整相关工具的输出处理 * refactor: simplify code * refactor(ToolBlockGroup): tool block rendering with memoization and auto-expand feature * fix(ui): use direct child selectors to prevent style leakage to nested tools Use > selectors in ToolBlockGroup to only style the group collapse, not the nested tool collapses inside it. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(i18n): clean hardcoded ui string add ci * refactor: add toolblockgroup * refactor: simplify code * fix: lint * fix: lint 2 * fix: test * fix: test2 --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
0255cb8443
commit
2a3e157ee7
@ -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",
|
||||
|
||||
22
pnpm-lock.yaml
generated
22
pnpm-lock.yaml
generated
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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-(stdout|stderr)>(.*?)<\/local-command-\1>/gs, '$2')
|
||||
return text
|
||||
.replace(/<local-command-(stdout|stderr)>(.*?)<\/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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 []
|
||||
}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
292
src/renderer/src/pages/home/Messages/Blocks/ToolBlockGroup.tsx
Normal file
292
src/renderer/src/pages/home/Messages/Blocks/ToolBlockGroup.tsx
Normal file
@ -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 (
|
||||
<HeaderWithActions>
|
||||
<ToolHeader block={block} variant="collapse-label" showStatus={false} />
|
||||
{(approval.isWaiting || approval.isExecuting) && <ToolApprovalActionsComponent {...approval} compact />}
|
||||
</HeaderWithActions>
|
||||
)
|
||||
})
|
||||
WaitingToolHeader.displayName = 'WaitingToolHeader'
|
||||
|
||||
interface GroupHeaderContentProps {
|
||||
blocks: ToolMessageBlock[]
|
||||
allCompleted: boolean
|
||||
}
|
||||
|
||||
const GroupHeaderContent = React.memo(({ blocks, allCompleted }: GroupHeaderContentProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (allCompleted) {
|
||||
return (
|
||||
<GroupHeader>
|
||||
<Wrench size={14} className="tool-icon" />
|
||||
<span className="tool-count">{t('message.tools.groupHeader', { count: blocks.length })}</span>
|
||||
</GroupHeader>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<AnimatePresence mode="wait">
|
||||
<AnimatedHeaderWrapper
|
||||
key={lastWaitingBlock.id}
|
||||
variants={headerVariants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit">
|
||||
<WaitingToolHeader block={lastWaitingBlock} />
|
||||
</AnimatedHeaderWrapper>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<AnimatePresence mode="wait">
|
||||
<AnimatedHeaderWrapper
|
||||
key={lastRunningBlock.id}
|
||||
variants={headerVariants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit">
|
||||
<ToolHeader block={lastRunningBlock} variant="collapse-label" />
|
||||
</AnimatedHeaderWrapper>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return (
|
||||
<GroupHeader>
|
||||
<Wrench size={14} className="tool-icon" />
|
||||
<span className="tool-count">{t('message.tools.groupHeader', { count: blocks.length })}</span>
|
||||
</GroupHeader>
|
||||
)
|
||||
})
|
||||
GroupHeaderContent.displayName = 'GroupHeaderContent'
|
||||
|
||||
// Component for tool list content with auto-scroll
|
||||
interface ToolListContentProps {
|
||||
blocks: ToolMessageBlock[]
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
const ToolListContent = React.memo(({ blocks, scrollRef }: ToolListContentProps) => (
|
||||
<ScrollableToolList ref={scrollRef}>
|
||||
{blocks.map((block) => {
|
||||
const status = block.metadata?.rawMcpToolResponse?.status
|
||||
const isCompleted = isCompletedStatus(status)
|
||||
return (
|
||||
<ToolItem key={block.id} data-block-id={block.id} $isCompleted={isCompleted}>
|
||||
<MessageTools block={block} />
|
||||
</ToolItem>
|
||||
)
|
||||
})}
|
||||
</ScrollableToolList>
|
||||
))
|
||||
ToolListContent.displayName = 'ToolListContent'
|
||||
|
||||
// ============ Main Component ============
|
||||
|
||||
const ToolBlockGroup: React.FC<Props> = ({ blocks }) => {
|
||||
const [activeKey, setActiveKey] = useState<string[]>([])
|
||||
const scrollRef = useRef<HTMLDivElement>(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: <GroupHeaderContent blocks={blocks} allCompleted={allCompleted} />,
|
||||
children: <ToolListContent blocks={blocks} scrollRef={scrollRef} />
|
||||
}
|
||||
]
|
||||
}, [blocks, allCompleted])
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Collapse
|
||||
ghost
|
||||
size="small"
|
||||
expandIconPosition="end"
|
||||
activeKey={activeKey}
|
||||
onChange={handleChange}
|
||||
items={items}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ToolBlockGroup)
|
||||
@ -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<Props> = ({ blocks, message }) => {
|
||||
<VideoBlock key={firstVideoBlock.id} block={firstVideoBlock} />
|
||||
</AnimatedBlockWrapper>
|
||||
)
|
||||
} 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 (
|
||||
<AnimatedBlockWrapper key={groupKey} enableAnimation={message.status.includes('ing')}>
|
||||
<ToolBlock key={block[0].id} block={block[0]} />
|
||||
</AnimatedBlockWrapper>
|
||||
)
|
||||
}
|
||||
// 多个工具调用,使用分组组件
|
||||
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 (
|
||||
<AnimatedBlockWrapper key={stableGroupKey} enableAnimation={message.status.includes('ing')}>
|
||||
<ToolBlockGroup blocks={toolBlocks} />
|
||||
</AnimatedBlockWrapper>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@ -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: <XCircle className="h-3.5 w-3.5" />,
|
||||
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 ? (
|
||||
<CheckCircle className="h-3.5 w-3.5" />
|
||||
) : isCompleted && !isSuccess ? (
|
||||
<XCircle className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
),
|
||||
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<CollapseProps['items']>[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: <XCircle className="h-3.5 w-3.5" />,
|
||||
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: <CheckCircle className="h-3.5 w-3.5" />,
|
||||
text: t('message.tools.status.success')
|
||||
} as const
|
||||
}
|
||||
|
||||
if (isCompleted) {
|
||||
return {
|
||||
color: 'danger',
|
||||
icon: <XCircle className="h-3.5 w-3.5" />,
|
||||
text: t('message.tools.status.failed')
|
||||
} as const
|
||||
}
|
||||
|
||||
return {
|
||||
color: 'warning',
|
||||
icon: <Terminal className="h-3.5 w-3.5" />,
|
||||
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 ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Status Info */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{parsedOutput.exit_code !== undefined && (
|
||||
<Tag color={parsedOutput.exit_code === 0 ? 'success' : 'danger'}>Exit Code: {parsedOutput.exit_code}</Tag>
|
||||
<Tag color={parsedOutput.exit_code === 0 ? 'success' : 'danger'}>
|
||||
{t('message.tools.sections.exitCode')}: {parsedOutput.exit_code}
|
||||
</Tag>
|
||||
)}
|
||||
{parsedOutput.timestamp && (
|
||||
<Tag className="py-0 font-mono text-xs">{new Date(parsedOutput.timestamp).toLocaleString()}</Tag>
|
||||
@ -95,73 +116,78 @@ export function BashOutputTool({
|
||||
</div>
|
||||
|
||||
{/* Standard Output */}
|
||||
{parsedOutput.stdout && (
|
||||
{truncatedStdout.data && (
|
||||
<div>
|
||||
<div className="mb-2 font-medium text-default-600 text-xs">stdout:</div>
|
||||
<div className="mb-2 font-medium text-default-600 text-xs">{t('message.tools.sections.stdout')}:</div>
|
||||
<pre className="whitespace-pre-wrap font-mono text-default-700 text-xs dark:text-default-300">
|
||||
{parsedOutput.stdout}
|
||||
{truncatedStdout.data}
|
||||
</pre>
|
||||
{truncatedStdout.isTruncated && <TruncatedIndicator originalLength={truncatedStdout.originalLength} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Standard Error */}
|
||||
{parsedOutput.stderr && (
|
||||
{truncatedStderr.data && (
|
||||
<div className="border border-danger-200">
|
||||
<div className="mb-2 font-medium text-danger-600 text-xs">stderr:</div>
|
||||
<div className="mb-2 font-medium text-danger-600 text-xs">{t('message.tools.sections.stderr')}:</div>
|
||||
<pre className="whitespace-pre-wrap font-mono text-danger-600 text-xs dark:text-danger-400">
|
||||
{parsedOutput.stderr}
|
||||
{truncatedStderr.data}
|
||||
</pre>
|
||||
{truncatedStderr.isTruncated && <TruncatedIndicator originalLength={truncatedStderr.originalLength} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tool Use Error */}
|
||||
{parsedOutput.tool_use_error && (
|
||||
{truncatedError.data && (
|
||||
<div className="border border-danger-200">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<XCircle className="h-4 w-4 text-danger" />
|
||||
<span className="font-medium text-danger-600 text-xs">Error:</span>
|
||||
<span className="font-medium text-danger-600 text-xs">{t('message.tools.status.error')}:</span>
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap font-mono text-danger-600 text-xs dark:text-danger-400">
|
||||
{parsedOutput.tool_use_error}
|
||||
{truncatedError.data}
|
||||
</pre>
|
||||
{truncatedError.isTruncated && <TruncatedIndicator originalLength={truncatedError.originalLength} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// 原始输出(如果解析失败或非 XML 格式)
|
||||
output && (
|
||||
truncatedRawOutput.data && (
|
||||
<div>
|
||||
<pre className="whitespace-pre-wrap font-mono text-default-700 text-xs dark:text-default-300">{output}</pre>
|
||||
<pre className="whitespace-pre-wrap font-mono text-default-700 text-xs dark:text-default-300">
|
||||
{truncatedRawOutput.data}
|
||||
</pre>
|
||||
{truncatedRawOutput.isTruncated && <TruncatedIndicator originalLength={truncatedRawOutput.originalLength} />}
|
||||
</div>
|
||||
)
|
||||
)
|
||||
return {
|
||||
key: AgentToolsType.BashOutput,
|
||||
label: (
|
||||
<>
|
||||
<ToolTitle
|
||||
icon={<Terminal className="h-4 w-4" />}
|
||||
label="Bash Output"
|
||||
params={
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="py-0 font-mono text-xs">{input?.bash_id}</Tag>
|
||||
{statusConfig && (
|
||||
<Tag
|
||||
color={statusConfig.color}
|
||||
icon={statusConfig.icon}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: '2px'
|
||||
}}>
|
||||
{statusConfig.text}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
<ToolHeader
|
||||
toolName={AgentToolsType.BashOutput}
|
||||
params={
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="py-0 font-mono text-xs">{input?.bash_id}</Tag>
|
||||
{statusConfig && (
|
||||
<Tag
|
||||
color={statusConfig.color}
|
||||
icon={statusConfig.icon}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: '2px'
|
||||
}}>
|
||||
{statusConfig.text}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
variant="collapse-label"
|
||||
showStatus={false}
|
||||
/>
|
||||
),
|
||||
|
||||
children: children
|
||||
|
||||
@ -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<CollapseProps['items']>[number] {
|
||||
// 如果有输出,计算输出行数
|
||||
const outputLines = output ? output.split('\n').length : 0
|
||||
|
||||
// 处理命令字符串,添加空值检查
|
||||
const command = input?.command ?? ''
|
||||
|
||||
const tagContent = <Tag className="!m-0 max-w-full truncate font-mono">{command}</Tag>
|
||||
const { t } = useTranslation()
|
||||
const command = input?.command
|
||||
const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(output)
|
||||
|
||||
return {
|
||||
key: 'tool',
|
||||
label: (
|
||||
<>
|
||||
<ToolTitle
|
||||
icon={<Terminal className="h-4 w-4" />}
|
||||
label="Bash"
|
||||
params={input?.description}
|
||||
stats={output ? `${outputLines} ${outputLines === 1 ? 'line' : 'lines'}` : undefined}
|
||||
/>
|
||||
<div className="mt-1 max-w-full">
|
||||
<Popover
|
||||
content={<div className="max-w-xl whitespace-pre-wrap break-all font-mono text-xs">{command}</div>}
|
||||
trigger="hover">
|
||||
{tagContent}
|
||||
</Popover>
|
||||
</div>
|
||||
</>
|
||||
<ToolHeader
|
||||
toolName={AgentToolsType.Bash}
|
||||
params={<SkeletonValue value={input?.description} width="150px" />}
|
||||
variant="collapse-label"
|
||||
showStatus={false}
|
||||
/>
|
||||
),
|
||||
children: <div className="whitespace-pre-line">{output}</div>
|
||||
children: (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Command 输入区域 */}
|
||||
{command && (
|
||||
<div>
|
||||
<div className="mb-1 font-medium text-muted-foreground text-xs">{t('message.tools.sections.command')}</div>
|
||||
<div className="max-h-40 overflow-y-auto rounded-md bg-muted/50 p-2">
|
||||
<code className="whitespace-pre-wrap break-all font-mono text-xs">{command}</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output 输出区域 */}
|
||||
{truncatedOutput ? (
|
||||
<div>
|
||||
<div className="mb-1 font-medium text-muted-foreground text-xs">{t('message.tools.sections.output')}</div>
|
||||
<div className="max-h-60 overflow-y-auto rounded-md bg-muted/30 p-2">
|
||||
<pre className="whitespace-pre-wrap font-mono text-xs">{truncatedOutput}</pre>
|
||||
</div>
|
||||
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
|
||||
</div>
|
||||
) : (
|
||||
<SkeletonValue value={null} width="100%" fallback={null} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<CollapseProps['items']>[number] {
|
||||
return {
|
||||
key: AgentToolsType.Edit,
|
||||
label: <ToolTitle icon={<FileEdit className="h-4 w-4" />} label="Edit" params={input?.file_path} />,
|
||||
label: (
|
||||
<ToolHeader
|
||||
toolName={AgentToolsType.Edit}
|
||||
params={input?.file_path}
|
||||
variant="collapse-label"
|
||||
showStatus={false}
|
||||
/>
|
||||
),
|
||||
children: (
|
||||
<>
|
||||
{/* Diff View */}
|
||||
|
||||
@ -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<CollapseProps['items']>[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: (
|
||||
<ToolTitle
|
||||
icon={<DoorOpen className="h-4 w-4" />}
|
||||
label="ExitPlanMode"
|
||||
stats={`${plan.split('\n\n').length} plans`}
|
||||
<ToolHeader
|
||||
toolName={AgentToolsType.ExitPlanMode}
|
||||
stats={`${planCount} ${t(planCount === 1 ? 'message.tools.units.plan' : 'message.tools.units.plans')}`}
|
||||
variant="collapse-label"
|
||||
showStatus={false}
|
||||
/>
|
||||
),
|
||||
children: <ReactMarkdown>{plan + '\n\n' + (output ?? '')}</ReactMarkdown>
|
||||
children: (
|
||||
<div>
|
||||
<ReactMarkdown>{truncatedContent}</ReactMarkdown>
|
||||
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<boolean>(false)
|
||||
export const useIsStreaming = () => use(StreamingContext)
|
||||
|
||||
export function SkeletonSpan({ width = '60px' }: { width?: string }) {
|
||||
return (
|
||||
<div className={`flex items-center gap-1 ${className}`}>
|
||||
{icon && <span className="flex flex-shrink-0">{icon}</span>}
|
||||
{label && <span className="flex-shrink-0 font-medium text-sm">{label}</span>}
|
||||
{params && <span className="min-w-0 truncate text-muted-foreground text-xs">{params}</span>}
|
||||
{stats && <span className="flex-shrink-0 text-muted-foreground text-xs">{stats}</span>}
|
||||
</div>
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="small"
|
||||
style={{
|
||||
width,
|
||||
minWidth: width,
|
||||
height: '1em',
|
||||
verticalAlign: 'middle'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <SkeletonSpan width={width} />
|
||||
}
|
||||
|
||||
return <>{fallback ?? ''}</>
|
||||
}
|
||||
|
||||
// 纯字符串输入工具 (Task, Bash, Search)
|
||||
export function StringInputTool({
|
||||
input,
|
||||
@ -93,3 +122,112 @@ export function StringOutputTool({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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: <LoadingIcon />, color: 'primary' }
|
||||
case 'waiting':
|
||||
return { label: t('message.tools.pending', 'Awaiting Approval'), icon: <LoadingIcon />, color: 'warning' }
|
||||
case 'pending':
|
||||
case 'invoking':
|
||||
return { label: t('message.tools.invoking'), icon: <LoadingIcon />, color: 'primary' }
|
||||
case 'cancelled':
|
||||
return {
|
||||
label: t('message.tools.cancelled'),
|
||||
icon: <X size={13} className="lucide-custom" />,
|
||||
color: 'error'
|
||||
}
|
||||
case 'done':
|
||||
return hasError
|
||||
? {
|
||||
label: t('message.tools.error'),
|
||||
icon: <TriangleAlert size={13} className="lucide-custom" />,
|
||||
color: 'error'
|
||||
}
|
||||
: {
|
||||
label: t('message.tools.completed'),
|
||||
icon: <Check size={13} className="lucide-custom" />,
|
||||
color: 'success'
|
||||
}
|
||||
case 'error':
|
||||
return {
|
||||
label: t('message.tools.error'),
|
||||
icon: <TriangleAlert size={13} className="lucide-custom" />,
|
||||
color: 'error'
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const info = getStatusInfo()
|
||||
if (!info) return null
|
||||
|
||||
return (
|
||||
<StatusIndicatorContainer $color={info.color}>
|
||||
{info.label}
|
||||
{info.icon}
|
||||
</StatusIndicatorContainer>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mt-2 flex items-center gap-1 text-muted-foreground text-xs">
|
||||
<Ellipsis size={14} />
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 font-mono">
|
||||
{t('message.tools.truncated', { defaultValue: sizeStr, size: sizeStr })}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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<CollapseProps['items']>[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: (
|
||||
<ToolTitle
|
||||
icon={<FolderSearch className="h-4 w-4" />}
|
||||
label="Glob"
|
||||
<ToolHeader
|
||||
toolName={AgentToolsType.Glob}
|
||||
params={input?.pattern}
|
||||
stats={output ? `${lineCount} ${lineCount === 1 ? 'file' : 'files'}` : undefined}
|
||||
stats={
|
||||
output
|
||||
? `${lineCount} ${t(lineCount === 1 ? 'message.tools.units.file' : 'message.tools.units.files')}`
|
||||
: undefined
|
||||
}
|
||||
variant="collapse-label"
|
||||
showStatus={false}
|
||||
/>
|
||||
),
|
||||
children: <div>{output}</div>
|
||||
children: (
|
||||
<div>
|
||||
<div>{truncatedOutput}</div>
|
||||
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<CollapseProps['items']>[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: (
|
||||
<ToolTitle
|
||||
icon={<FileSearch className="h-4 w-4" />}
|
||||
label="Grep"
|
||||
<ToolHeader
|
||||
toolName={AgentToolsType.Grep}
|
||||
params={
|
||||
<>
|
||||
{input?.pattern}
|
||||
{input?.output_mode && <span className="ml-1">({input.output_mode})</span>}
|
||||
</>
|
||||
}
|
||||
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: <div>{output}</div>
|
||||
children: (
|
||||
<div>
|
||||
<div>{truncatedOutput}</div>
|
||||
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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: <ToolTitle icon={<FileText className="h-4 w-4" />} label="MultiEdit" params={input?.file_path} />,
|
||||
label: (
|
||||
<ToolHeader
|
||||
toolName={AgentToolsType.MultiEdit}
|
||||
params={input?.file_path}
|
||||
variant="collapse-label"
|
||||
showStatus={false}
|
||||
/>
|
||||
),
|
||||
children: (
|
||||
<div>
|
||||
{edits.map((edit, index) => (
|
||||
|
||||
@ -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<CollapseProps['items']>[number] {
|
||||
const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(output)
|
||||
|
||||
return {
|
||||
key: AgentToolsType.NotebookEdit,
|
||||
label: (
|
||||
<>
|
||||
<ToolTitle icon={<FileText className="h-4 w-4" />} label="NotebookEdit" />
|
||||
<Tag className="mt-1" color="blue">
|
||||
{input?.notebook_path}{' '}
|
||||
</Tag>
|
||||
</>
|
||||
<div className="flex items-center gap-2">
|
||||
<ToolHeader toolName={AgentToolsType.NotebookEdit} variant="collapse-label" showStatus={false} />
|
||||
<Tag color="blue">{input?.notebook_path}</Tag>
|
||||
</div>
|
||||
),
|
||||
children: <ReactMarkdown>{output ?? ''}</ReactMarkdown>
|
||||
children: (
|
||||
<div>
|
||||
<ReactMarkdown>{truncatedOutput}</ReactMarkdown>
|
||||
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<CollapseProps['items']>[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: (
|
||||
<ToolTitle
|
||||
icon={<FileText className="h-4 w-4" />}
|
||||
label="Read File"
|
||||
params={input?.file_path?.split('/').pop()}
|
||||
stats={stats ? `${stats.lineCount} lines, ${stats.formatSize(stats.fileSize)}` : undefined}
|
||||
<ToolHeader
|
||||
toolName={AgentToolsType.Read}
|
||||
params={<SkeletonValue value={filename} width="120px" />}
|
||||
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 ? <ReactMarkdown>{outputString}</ReactMarkdown> : null
|
||||
children: truncatedOutput ? (
|
||||
<div>
|
||||
<ReactMarkdown>{truncatedOutput}</ReactMarkdown>
|
||||
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
|
||||
</div>
|
||||
) : (
|
||||
<SkeletonValue value={null} width="100%" fallback={null} />
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<CollapseProps['items']>[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: (
|
||||
<ToolTitle
|
||||
icon={<Search className="h-4 w-4" />}
|
||||
label="Search"
|
||||
<ToolHeader
|
||||
toolName={AgentToolsType.Search}
|
||||
params={input ? `"${input}"` : undefined}
|
||||
stats={output ? `${resultCount} ${resultCount === 1 ? 'result' : 'results'}` : undefined}
|
||||
stats={
|
||||
output
|
||||
? `${resultCount} ${t(resultCount === 1 ? 'message.tools.units.result' : 'message.tools.units.results')}`
|
||||
: undefined
|
||||
}
|
||||
variant="collapse-label"
|
||||
showStatus={false}
|
||||
/>
|
||||
),
|
||||
children: (
|
||||
<div>
|
||||
{input && <StringInputTool input={input} label="Search Query" />}
|
||||
{output && (
|
||||
{input && <StringInputTool input={input} label={t('message.tools.sections.searchQuery')} />}
|
||||
{truncatedOutput && (
|
||||
<div>
|
||||
<StringOutputTool output={output} label="Search Results" textColor="text-yellow-600 dark:text-yellow-400" />
|
||||
<StringOutputTool
|
||||
output={truncatedOutput}
|
||||
label={t('message.tools.sections.searchResults')}
|
||||
textColor="text-yellow-600 dark:text-yellow-400"
|
||||
/>
|
||||
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -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<CollapseProps['items']>[number] {
|
||||
const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(output)
|
||||
|
||||
return {
|
||||
key: 'tool',
|
||||
label: <ToolTitle icon={<PencilRuler className="h-4 w-4" />} label="Skill" params={input?.command} />,
|
||||
children: <div>{output}</div>
|
||||
label: (
|
||||
<ToolHeader toolName={AgentToolsType.Skill} params={input?.command} variant="collapse-label" showStatus={false} />
|
||||
),
|
||||
children: (
|
||||
<div>
|
||||
<div>{truncatedOutput}</div>
|
||||
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<CollapseProps['items']>[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: <ToolTitle icon={<Bot className="h-4 w-4" />} label="Task" params={input?.description} />,
|
||||
label: (
|
||||
<ToolHeader
|
||||
toolName={AgentToolsType.Task}
|
||||
params={<SkeletonValue value={input?.description} width="150px" />}
|
||||
variant="collapse-label"
|
||||
showStatus={false}
|
||||
/>
|
||||
),
|
||||
children: (
|
||||
<div>
|
||||
{Array.isArray(output) &&
|
||||
output.map((item) => (
|
||||
<div key={item.type}>
|
||||
<div>{item.type === 'text' ? <Markdown>{item.text}</Markdown> : item.text}</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Prompt 输入区域 */}
|
||||
{input?.prompt && (
|
||||
<div>
|
||||
<div className="mb-1 font-medium text-muted-foreground text-xs">{t('message.tools.sections.prompt')}</div>
|
||||
<div className="max-h-40 overflow-y-auto rounded-md bg-muted/50 p-2 text-sm">
|
||||
<Markdown>{input.prompt}</Markdown>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output 输出区域 */}
|
||||
{hasOutput ? (
|
||||
<div>
|
||||
<div className="mb-1 font-medium text-muted-foreground text-xs">{t('message.tools.sections.output')}</div>
|
||||
<div className="rounded-md bg-muted/30 p-2">
|
||||
<Markdown>{truncatedText}</Markdown>
|
||||
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<SkeletonValue value={null} width="100%" fallback={null} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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: <Clock className="h-4 w-4" strokeWidth={2.5} />
|
||||
}
|
||||
case 'pending':
|
||||
return {
|
||||
color: 'var(--color-border)',
|
||||
opacity: 0.4,
|
||||
icon: <Circle className="h-4 w-4" strokeWidth={2.5} />
|
||||
}
|
||||
default:
|
||||
return {
|
||||
color: 'var(--color-border)',
|
||||
@ -40,17 +35,19 @@ export function TodoWriteTool({
|
||||
}: {
|
||||
input?: TodoWriteToolInputType
|
||||
}): NonNullable<CollapseProps['items']>[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: (
|
||||
<ToolTitle
|
||||
icon={<ListTodo className="h-4 w-4" />}
|
||||
label="Todo Write"
|
||||
params={`${doneCount} Done`}
|
||||
stats={`${todos.length} ${todos.length === 1 ? 'item' : 'items'}`}
|
||||
<ToolHeader
|
||||
toolName={AgentToolsType.TodoWrite}
|
||||
params={`${doneCount} ${t('message.tools.status.done')}`}
|
||||
stats={`${todos.length} ${t(todos.length === 1 ? 'message.tools.units.item' : 'message.tools.units.items')}`}
|
||||
variant="collapse-label"
|
||||
showStatus={false}
|
||||
/>
|
||||
),
|
||||
children: (
|
||||
|
||||
@ -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<string>('')
|
||||
const [outputHtml, setOutputHtml] = useState<string>('')
|
||||
|
||||
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 <div className="text-foreground-500 text-xs">No data available for this tool</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{input !== undefined && (
|
||||
<div>
|
||||
<div className="mb-1 font-semibold text-foreground-600 text-xs dark:text-foreground-400">Input:</div>
|
||||
<div
|
||||
className="overflow-x-auto rounded bg-gray-50 dark:bg-gray-900"
|
||||
dangerouslySetInnerHTML={{ __html: inputHtml }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{output !== undefined && (
|
||||
<div>
|
||||
<div className="mb-1 font-semibold text-foreground-600 text-xs dark:text-foreground-400">Output:</div>
|
||||
<div
|
||||
className="rounded bg-gray-50 dark:bg-gray-900 [&>*]:whitespace-pre-line"
|
||||
dangerouslySetInnerHTML={{ __html: outputHtml }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback renderer for unknown tool types
|
||||
* Uses shared ArgsTable for consistent styling with MCP tools
|
||||
*/
|
||||
export function UnknownToolRenderer({
|
||||
toolName = '',
|
||||
input,
|
||||
output
|
||||
}: UnknownToolProps): NonNullable<CollapseProps['items']>[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<string, unknown> | unknown[] | null => {
|
||||
if (value === undefined || value === null) return null
|
||||
if (typeof value === 'object') return value as Record<string, unknown> | unknown[]
|
||||
// Wrap primitive values
|
||||
return { value }
|
||||
}
|
||||
|
||||
const normalizedInput = normalizeArgs(input)
|
||||
const normalizedOutput = normalizeArgs(output)
|
||||
|
||||
return {
|
||||
key: 'unknown-tool',
|
||||
label: (
|
||||
<ToolTitle
|
||||
<ToolHeader
|
||||
toolName={getToolDisplayName(toolName)}
|
||||
icon={<Wrench className="h-4 w-4" />}
|
||||
label={getToolDisplayName(toolName)}
|
||||
params={getToolDescription(toolName)}
|
||||
variant="collapse-label"
|
||||
showStatus={false}
|
||||
/>
|
||||
),
|
||||
children: <UnknownToolContent input={input} output={output} />
|
||||
children: (
|
||||
<div className="space-y-1">
|
||||
{normalizedInput && <ToolArgsTable args={normalizedInput} title={t('message.tools.sections.input')} />}
|
||||
{normalizedOutput && <ToolArgsTable args={normalizedOutput} title={t('message.tools.sections.output')} />}
|
||||
{!normalizedInput && !normalizedOutput && (
|
||||
<div className="p-3 text-foreground-500 text-xs">{t('message.tools.noData')}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<CollapseProps['items']>[number] {
|
||||
const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(output)
|
||||
|
||||
return {
|
||||
key: 'tool',
|
||||
label: <ToolTitle icon={<Globe className="h-4 w-4" />} label="Web Fetch" params={input?.url} />,
|
||||
children: <div>{output}</div>
|
||||
label: (
|
||||
<ToolHeader toolName={AgentToolsType.WebFetch} params={input?.url} variant="collapse-label" showStatus={false} />
|
||||
),
|
||||
children: (
|
||||
<div>
|
||||
<div>{truncatedOutput}</div>
|
||||
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<CollapseProps['items']>[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: (
|
||||
<ToolTitle
|
||||
icon={<Globe className="h-4 w-4" />}
|
||||
label="Web Search"
|
||||
<ToolHeader
|
||||
toolName={AgentToolsType.WebSearch}
|
||||
params={input?.query}
|
||||
stats={output ? `${resultCount} ${resultCount === 1 ? 'result' : 'results'}` : undefined}
|
||||
stats={
|
||||
output
|
||||
? `${resultCount} ${t(resultCount === 1 ? 'message.tools.units.result' : 'message.tools.units.results')}`
|
||||
: undefined
|
||||
}
|
||||
variant="collapse-label"
|
||||
showStatus={false}
|
||||
/>
|
||||
),
|
||||
children: <div>{output}</div>
|
||||
children: (
|
||||
<div>
|
||||
<div>{truncatedOutput}</div>
|
||||
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<CollapseProps['items']>[number] {
|
||||
return {
|
||||
key: 'tool',
|
||||
label: <ToolTitle icon={<FileText className="h-4 w-4" />} label="Write" params={input?.file_path} />,
|
||||
label: (
|
||||
<ToolHeader
|
||||
toolName={AgentToolsType.Write}
|
||||
params={input?.file_path}
|
||||
variant="collapse-label"
|
||||
showStatus={false}
|
||||
/>
|
||||
),
|
||||
children: <div>{input?.content}</div>
|
||||
}
|
||||
}
|
||||
|
||||
@ -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, unknown> | string | undefined,
|
||||
output?: ToolOutput | unknown
|
||||
): NonNullable<CollapseProps['items']>[number] {
|
||||
const renderer = toolRenderers[toolName] as (props: {
|
||||
input?: unknown
|
||||
output?: unknown
|
||||
}) => NonNullable<CollapseProps['items']>[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<string, unknown>
|
||||
output?: ToolOutput | unknown
|
||||
isStreaming?: boolean
|
||||
status?: ToolStatus
|
||||
hasError?: boolean
|
||||
}) {
|
||||
const renderedItem = isValidAgentToolsType(toolName)
|
||||
? renderTool(toolName, (input ?? {}) as Record<string, unknown>, output)
|
||||
: UnknownToolRenderer({ toolName: toolName ?? 'Tool', input, output })
|
||||
|
||||
const toolContentItem: NonNullable<CollapseProps['items']>[number] = {
|
||||
...renderedItem,
|
||||
label: (
|
||||
<div className="flex w-full items-start justify-between gap-2">
|
||||
<div className="min-w-0">{renderedItem.label}</div>
|
||||
{status && (
|
||||
<div className="shrink-0">
|
||||
<ToolStatusIndicator status={status} hasError={hasError} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
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 (
|
||||
<Collapse
|
||||
className="w-max max-w-full"
|
||||
expandIconPosition="end"
|
||||
size="small"
|
||||
defaultActiveKey={toolName === AgentToolsType.TodoWrite ? [AgentToolsType.TodoWrite] : []}
|
||||
items={[toolContentItem]}
|
||||
/>
|
||||
<StreamingContext value={isStreaming}>
|
||||
<Collapse
|
||||
className="w-max max-w-full"
|
||||
expandIconPosition="end"
|
||||
size="small"
|
||||
defaultActiveKey={toolName === AgentToolsType.TodoWrite ? [AgentToolsType.TodoWrite] : []}
|
||||
items={[toolContentItem]}
|
||||
/>
|
||||
</StreamingContext>
|
||||
)
|
||||
}
|
||||
|
||||
// 统一的组件渲染入口
|
||||
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 <ToolPermissionRequestCard toolResponse={toolResponse} />
|
||||
const parsedPartialArgs = useMemo(() => {
|
||||
if (!partialArguments) return undefined
|
||||
try {
|
||||
return parsePartialJson(partialArguments)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
return <ToolPendingIndicator toolName={tool?.name} description={tool?.description} />
|
||||
}, [partialArguments])
|
||||
|
||||
const effectiveStatus = getEffectiveStatus(status, !!pendingPermission)
|
||||
|
||||
if (effectiveStatus === 'waiting') {
|
||||
return <ToolPermissionRequestCard toolResponse={toolResponse} />
|
||||
}
|
||||
|
||||
const isLoading = effectiveStatus === 'streaming' || effectiveStatus === 'invoking'
|
||||
return (
|
||||
<ToolContent toolName={tool.name as AgentToolsType} input={args as ToolInput} output={response as ToolOutput} />
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex w-full max-w-xl items-center gap-3 rounded-xl border border-default-200 bg-default-100 px-4 py-3 shadow-sm">
|
||||
<Spin size="small" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-semibold text-default-700 text-sm">{label}</span>
|
||||
<span className="text-default-500 text-xs">{detail}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ToolContent
|
||||
toolName={tool?.name}
|
||||
input={args ?? parsedPartialArgs}
|
||||
output={isLoading ? undefined : response}
|
||||
isStreaming={isLoading}
|
||||
status={effectiveStatus}
|
||||
hasError={status === 'error'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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, unknown> | string
|
||||
output?: ToolOutput | unknown
|
||||
}) => NonNullable<CollapseProps['items']>[number]
|
||||
|
||||
// 工具渲染器映射类型
|
||||
export type ToolRenderersMap = Record<AgentToolsType, ToolRendererFn>
|
||||
|
||||
@ -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<Props> = ({ block }) => {
|
||||
const [activeKeys, setActiveKeys] = useState<string[]>([])
|
||||
const [copiedMap, setCopiedMap] = useState<Record<string, boolean>>({})
|
||||
const [countdown, setCountdown] = useState<number>(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<number>(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<NodeJS.Timeout | null>(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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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 = <LoadingIcon style={{ marginLeft: 6, color: 'var(--status-color-warning)' }} />
|
||||
} else if (isExecuting) {
|
||||
label = t('message.tools.invoking')
|
||||
icon = <LoadingIcon style={{ marginLeft: 6 }} />
|
||||
}
|
||||
} else if (status === 'cancelled') {
|
||||
label = t('message.tools.cancelled')
|
||||
icon = <X size={13} style={{ marginLeft: 6 }} className="lucide-custom" />
|
||||
} else if (status === 'done') {
|
||||
if (hasError) {
|
||||
label = t('message.tools.error')
|
||||
icon = <TriangleAlert size={13} style={{ marginLeft: 6 }} className="lucide-custom" />
|
||||
} else {
|
||||
label = t('message.tools.completed')
|
||||
icon = <Check size={13} style={{ marginLeft: 6 }} className="lucide-custom" />
|
||||
}
|
||||
} else if (status === 'error') {
|
||||
label = t('message.tools.error')
|
||||
icon = <TriangleAlert size={13} style={{ marginLeft: 6 }} className="lucide-custom" />
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusIndicator status={status} hasError={hasError}>
|
||||
{label}
|
||||
{icon}
|
||||
</StatusIndicator>
|
||||
)
|
||||
}
|
||||
|
||||
// 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<Props> = ({ block }) => {
|
||||
{progress > 0 ? (
|
||||
<Progress type="circle" size={14} percent={Number((progress * 100)?.toFixed(0))} />
|
||||
) : (
|
||||
renderStatusIndicator(status, hasError)
|
||||
<ToolStatusIndicator status={getEffectiveStatus(status, approval.isWaiting)} hasError={hasError} />
|
||||
)}
|
||||
<Tooltip title={t('common.expand')} mouseEnterDelay={0.5}>
|
||||
<ActionButton
|
||||
className="message-action-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setExpandedResponse({
|
||||
content: JSON.stringify(response, null, 2),
|
||||
title: tool.name
|
||||
})
|
||||
}}
|
||||
aria-label={t('common.expand')}>
|
||||
<Maximize size={14} />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
{!isPending && (
|
||||
<Tooltip title={t('common.copy')} mouseEnterDelay={0.5}>
|
||||
<ActionButton
|
||||
@ -315,65 +165,25 @@ const MessageMcpTool: FC<Props> = ({ block }) => {
|
||||
</ActionButtonsContainer>
|
||||
</MessageTitleLabel>
|
||||
),
|
||||
children:
|
||||
(isDone || isError) && result ? (
|
||||
<ToolResponseContainer
|
||||
style={{
|
||||
fontFamily: messageFont === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)',
|
||||
fontSize
|
||||
}}>
|
||||
<CollapsedContent isExpanded={activeKeys.includes(id)} resultString={resultString} />
|
||||
</ToolResponseContainer>
|
||||
) : argsString ? (
|
||||
<>
|
||||
<ToolResponseContainer>
|
||||
<CollapsedContent isExpanded={activeKeys.includes(id)} resultString={argsString} />
|
||||
</ToolResponseContainer>
|
||||
</>
|
||||
) : null
|
||||
children: (
|
||||
<ToolResponseContainer
|
||||
style={{
|
||||
fontFamily: messageFont === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)',
|
||||
fontSize
|
||||
}}>
|
||||
<ToolResponseContent
|
||||
isExpanded={activeKeys.includes(id)}
|
||||
args={isStreaming ? partialArguments : toolResponse.arguments}
|
||||
isStreaming={!!isStreaming}
|
||||
response={isDone || isError ? toolResponse.response : undefined}
|
||||
/>
|
||||
</ToolResponseContainer>
|
||||
)
|
||||
})
|
||||
|
||||
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 (
|
||||
<CollapsedContent
|
||||
isExpanded={true}
|
||||
resultString={JSON.stringify(JSON.parse(parsedResult.content[0].text), null, 2)}
|
||||
/>
|
||||
)
|
||||
} catch (e) {
|
||||
return (
|
||||
<CollapsedContent
|
||||
isExpanded={true}
|
||||
resultString={JSON.stringify(parsedResult.content[0].text, null, 2)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
default:
|
||||
return <CollapsedContent isExpanded={true} resultString={JSON.stringify(parsedResult, null, 2)} />
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('failed to render the preview of mcp results:', e as Error)
|
||||
return (
|
||||
<CollapsedContent
|
||||
isExpanded={true}
|
||||
resultString={e instanceof Error ? e.message : JSON.stringify(e, null, 2)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfigProvider
|
||||
@ -401,155 +211,166 @@ const MessageMcpTool: FC<Props> = ({ block }) => {
|
||||
{isPending && (
|
||||
<ActionsBar>
|
||||
<ActionLabel>
|
||||
{isWaitingConfirmation
|
||||
{approval.isWaiting
|
||||
? t('settings.mcp.tools.autoApprove.tooltip.confirm')
|
||||
: t('message.tools.invoking')}
|
||||
</ActionLabel>
|
||||
|
||||
<ActionButtonsGroup>
|
||||
{isWaitingConfirmation && (
|
||||
<Button
|
||||
color="danger"
|
||||
variant="filled"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
handleCancelTool()
|
||||
}}>
|
||||
<CircleX size={15} className="lucide-custom" />
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
{isExecuting && toolResponse?.id ? (
|
||||
<Button
|
||||
size="small"
|
||||
color="danger"
|
||||
variant="solid"
|
||||
className="abort-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleAbortTool()
|
||||
}}>
|
||||
<PauseCircle size={14} className="lucide-custom" />
|
||||
{t('chat.input.pause')}
|
||||
</Button>
|
||||
) : (
|
||||
isWaitingConfirmation && (
|
||||
<StyledDropdownButton
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<ChevronDown size={14} />}
|
||||
onClick={() => {
|
||||
handleConfirmTool()
|
||||
}}
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'autoApprove',
|
||||
label: t('settings.mcp.tools.autoApprove.label'),
|
||||
onClick: () => {
|
||||
handleAutoApprove()
|
||||
}
|
||||
}
|
||||
]
|
||||
}}>
|
||||
<CirclePlay size={15} className="lucide-custom" />
|
||||
<CountdownText>
|
||||
{t('settings.mcp.tools.run', 'Run')} ({countdown}s)
|
||||
</CountdownText>
|
||||
</StyledDropdownButton>
|
||||
)
|
||||
)}
|
||||
</ActionButtonsGroup>
|
||||
<ToolApprovalActionsComponent
|
||||
{...approval}
|
||||
showAbort={approval.isExecuting && !!toolResponse?.id}
|
||||
onAbort={handleAbortTool}
|
||||
/>
|
||||
</ActionsBar>
|
||||
)}
|
||||
</ToolContentWrapper>
|
||||
</ToolContainer>
|
||||
</ConfigProvider>
|
||||
<Modal
|
||||
title={expandedResponse?.title}
|
||||
open={!!expandedResponse}
|
||||
onCancel={() => setExpandedResponse(null)}
|
||||
footer={null}
|
||||
width="80%"
|
||||
centered
|
||||
transitionName="animation-move-down"
|
||||
styles={{ body: { maxHeight: '80vh', overflow: 'auto' } }}>
|
||||
{expandedResponse && (
|
||||
<ExpandedResponseContainer
|
||||
style={{
|
||||
fontFamily: messageFont === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)',
|
||||
fontSize
|
||||
}}>
|
||||
<Tabs
|
||||
tabBarExtraContent={
|
||||
<ActionButton
|
||||
className="copy-expanded-button"
|
||||
onClick={() => {
|
||||
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')}>
|
||||
<i className="iconfont icon-copy"></i>
|
||||
</ActionButton>
|
||||
}
|
||||
items={[
|
||||
{
|
||||
key: 'preview',
|
||||
label: t('message.tools.preview'),
|
||||
children: renderPreview(expandedResponse.content)
|
||||
},
|
||||
{
|
||||
key: 'raw',
|
||||
label: t('message.tools.raw'),
|
||||
children: (
|
||||
<CollapsedContent
|
||||
isExpanded={true}
|
||||
resultString={
|
||||
typeof expandedResponse.content === 'string'
|
||||
? expandedResponse.content
|
||||
: JSON.stringify(expandedResponse.content, null, 2)
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</ExpandedResponseContainer>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// New component to handle collapsed content
|
||||
const CollapsedContent: FC<{ isExpanded: boolean; resultString: string }> = ({ isExpanded, resultString }) => {
|
||||
const { highlightCode } = useCodeStyle()
|
||||
const [styledResult, setStyledResult] = useState<string>('')
|
||||
/**
|
||||
* 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<string, unknown> | Record<string, unknown>[] | undefined
|
||||
isStreaming: boolean
|
||||
response?: unknown
|
||||
}> = ({ isExpanded, args, isStreaming, response }) => {
|
||||
const { highlightCode } = useCodeStyle()
|
||||
const [highlightedResponse, setHighlightedResponse] = useState<string>('')
|
||||
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 (
|
||||
<ArgsSection>
|
||||
<ArgsSectionTitle>Arguments</ArgsSectionTitle>
|
||||
<ArgsTable>
|
||||
<tbody>
|
||||
{entries.map(([key, value]) => (
|
||||
<tr key={key}>
|
||||
<ArgKey>{key}</ArgKey>
|
||||
<ArgValue>{formatArgValue(value)}</ArgValue>
|
||||
</tr>
|
||||
))}
|
||||
{isStreaming && (
|
||||
<tr>
|
||||
<ArgKey>
|
||||
<SkeletonSpan width="60px" />
|
||||
</ArgKey>
|
||||
<ArgValue>
|
||||
<SkeletonSpan width="120px" />
|
||||
</ArgValue>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</ArgsTable>
|
||||
</ArgsSection>
|
||||
)
|
||||
}
|
||||
|
||||
return <MarkdownContainer className="markdown" dangerouslySetInnerHTML={{ __html: styledResult }} />
|
||||
return (
|
||||
<div>
|
||||
{/* Arguments Table */}
|
||||
{renderArgsTable()}
|
||||
|
||||
{/* Response */}
|
||||
{response !== undefined && response !== null && highlightedResponse && (
|
||||
<ResponseSection>
|
||||
<ArgsSectionTitle>Response</ArgsSectionTitle>
|
||||
<MarkdownContainer className="markdown" dangerouslySetInnerHTML={{ __html: highlightedResponse }} />
|
||||
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
|
||||
</ResponseSection>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@ -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<ToolApprovalActionsProps> = ({
|
||||
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 (
|
||||
<ExpiredBadge $compact={compact} onClick={(e) => e.stopPropagation()}>
|
||||
{t('agent.toolPermission.expired')}
|
||||
</ExpiredBadge>
|
||||
)
|
||||
}
|
||||
|
||||
// Executing state - show loading or abort button
|
||||
if (isExecuting) {
|
||||
if (showAbort && onAbort) {
|
||||
return (
|
||||
<ActionsContainer $compact={compact} onClick={(e) => e.stopPropagation()}>
|
||||
<Button size="small" color="danger" variant="solid" onClick={(e) => handleClick(e, onAbort)}>
|
||||
{t('chat.input.pause')}
|
||||
</Button>
|
||||
</ActionsContainer>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ActionsContainer $compact={compact} onClick={(e) => e.stopPropagation()}>
|
||||
<LoadingIndicator>
|
||||
<LoadingIcon />
|
||||
{!compact && <span>{t('message.tools.invoking')}</span>}
|
||||
</LoadingIndicator>
|
||||
</ActionsContainer>
|
||||
)
|
||||
}
|
||||
|
||||
// Waiting state - show confirm/cancel buttons
|
||||
return (
|
||||
<ActionsContainer $compact={compact} onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
size="small"
|
||||
color="danger"
|
||||
variant={compact ? 'text' : 'outlined'}
|
||||
disabled={isSubmitting}
|
||||
onClick={(e) => handleClick(e, cancel)}>
|
||||
<CircleX size={compact ? 13 : 14} className="lucide-custom" />
|
||||
{!compact && t('common.cancel')}
|
||||
</Button>
|
||||
|
||||
{autoApprove ? (
|
||||
<StyledDropdownButton
|
||||
size="small"
|
||||
type="primary"
|
||||
disabled={isSubmitting}
|
||||
icon={<ChevronDown size={compact ? 12 : 14} />}
|
||||
onClick={(e) => handleClick(e, confirm)}
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'autoApprove',
|
||||
label: t('settings.mcp.tools.autoApprove.label'),
|
||||
icon: <ShieldCheck size={14} />,
|
||||
onClick: () => autoApprove()
|
||||
}
|
||||
]
|
||||
}}>
|
||||
<CirclePlay size={compact ? 13 : 15} className="lucide-custom" />
|
||||
<CountdownText $compact={compact}>
|
||||
{compact ? `${remainingSeconds}s` : `${t('settings.mcp.tools.run', 'Run')} (${remainingSeconds}s)`}
|
||||
</CountdownText>
|
||||
</StyledDropdownButton>
|
||||
) : (
|
||||
<Button size="small" type="primary" disabled={isSubmitting} onClick={(e) => handleClick(e, confirm)}>
|
||||
<CirclePlay size={compact ? 13 : 15} className="lucide-custom" />
|
||||
<CountdownText $compact={compact}>
|
||||
{compact ? `${remainingSeconds}s` : `${t('settings.mcp.tools.run', 'Run')} (${remainingSeconds}s)`}
|
||||
</CountdownText>
|
||||
</Button>
|
||||
)}
|
||||
</ActionsContainer>
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
272
src/renderer/src/pages/home/Messages/Tools/ToolHeader.tsx
Normal file
272
src/renderer/src/pages/home/Messages/Tools/ToolHeader.tsx
Normal file
@ -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 <FileText size={14} />
|
||||
case AgentToolsType.Task:
|
||||
return <Bot size={14} />
|
||||
case AgentToolsType.Bash:
|
||||
case AgentToolsType.BashOutput:
|
||||
return <Terminal size={14} />
|
||||
case AgentToolsType.Search:
|
||||
return <Search size={14} />
|
||||
case AgentToolsType.Glob:
|
||||
return <FolderSearch size={14} />
|
||||
case AgentToolsType.Grep:
|
||||
return <FileSearch size={14} />
|
||||
case AgentToolsType.Write:
|
||||
return <FileText size={14} />
|
||||
case AgentToolsType.Edit:
|
||||
return <FileEdit size={14} />
|
||||
case AgentToolsType.MultiEdit:
|
||||
return <FileText size={14} />
|
||||
case AgentToolsType.WebSearch:
|
||||
case AgentToolsType.WebFetch:
|
||||
return <Globe size={14} />
|
||||
case AgentToolsType.NotebookEdit:
|
||||
return <NotebookPen size={14} />
|
||||
case AgentToolsType.TodoWrite:
|
||||
return <ListTodo size={14} />
|
||||
case AgentToolsType.ExitPlanMode:
|
||||
return <DoorOpen size={14} />
|
||||
case AgentToolsType.Skill:
|
||||
return <PencilRuler size={14} />
|
||||
default:
|
||||
return <Wrench size={14} />
|
||||
}
|
||||
}
|
||||
|
||||
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<string, unknown>).description ||
|
||||
(args as Record<string, unknown>).file_path ||
|
||||
(args as Record<string, unknown>).pattern ||
|
||||
(args as Record<string, unknown>).query ||
|
||||
(args as Record<string, unknown>).command ||
|
||||
(args as Record<string, unknown>).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<ToolHeaderProps> = ({
|
||||
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 (
|
||||
<Container>
|
||||
<ToolName align="center" gap={6}>
|
||||
<Wrench size={14} className="tool-icon" />
|
||||
<span className="name">
|
||||
{mcpTool.serverName} : {mcpTool.name}
|
||||
</span>
|
||||
{isToolAutoApproved(mcpTool) && (
|
||||
<Tooltip title={t('message.tools.autoApproveEnabled')} mouseLeaveDelay={0}>
|
||||
<ShieldCheck size={14} color="var(--color-primary)" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</ToolName>
|
||||
{description && <Description>{description}</Description>}
|
||||
{stats && <Stats>{stats}</Stats>}
|
||||
{showStatus && status && (
|
||||
<StatusWrapper>
|
||||
<ToolStatusIndicator status={status} hasError={hasError} />
|
||||
</StatusWrapper>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<ToolName align="center" gap={6}>
|
||||
<span className="tool-icon">{propIcon || getAgentToolIcon(toolName)}</span>
|
||||
<span className="name">{getAgentToolLabel(toolName, t)}</span>
|
||||
</ToolName>
|
||||
{description && <Description>{description}</Description>}
|
||||
{stats && <Stats>{stats}</Stats>}
|
||||
{showStatus && status && (
|
||||
<StatusWrapper>
|
||||
<ToolStatusIndicator status={status} hasError={hasError} />
|
||||
</StatusWrapper>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ToolHeader)
|
||||
@ -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<string, unknown> | 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<string, unknown>
|
||||
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 (
|
||||
<div className="rounded-xl border border-default-200 bg-default-100 px-4 py-3 text-default-500 text-sm">
|
||||
{t('agent.toolPermission.waiting')}
|
||||
</div>
|
||||
const statusIndicator = (
|
||||
<StatusIndicatorContainer $color={statusInfo.color}>
|
||||
{statusInfo.text}
|
||||
{statusInfo.showLoading && <LoadingIcon />}
|
||||
</StatusIndicatorContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (isInvoking) {
|
||||
return (
|
||||
<div className="w-full max-w-xl rounded-xl border border-default-200 bg-default-100 px-4 py-3 shadow-sm">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Spin size="small" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="font-semibold text-default-700 text-sm">{request.toolName}</div>
|
||||
<div className="text-default-500 text-xs">{t('agent.toolPermission.executing')}</div>
|
||||
</div>
|
||||
</div>
|
||||
{request.inputPreview && (
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
aria-label={
|
||||
showDetails
|
||||
? t('agent.toolPermission.aria.hideDetails')
|
||||
: t('agent.toolPermission.aria.showDetails')
|
||||
}
|
||||
className="h-8 text-default-600 transition-colors hover:bg-default-200/50 hover:text-default-800"
|
||||
onClick={() => setShowDetails((value) => !value)}
|
||||
icon={<ChevronDown className={`transition-transform ${showDetails ? 'rotate-180' : ''}`} size={16} />}
|
||||
variant="text"
|
||||
style={{ backgroundColor: 'transparent' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showDetails && request.inputPreview && (
|
||||
<div className="flex flex-col gap-3 border-default-200 border-t pt-3">
|
||||
<div className="rounded-md border border-default-200 bg-default-100 p-3">
|
||||
<p className="mb-2 font-medium text-default-400 text-xs uppercase tracking-wide">
|
||||
{t('agent.toolPermission.inputPreview')}
|
||||
</p>
|
||||
<div className="max-h-[192px] overflow-auto font-mono text-xs">
|
||||
<pre className="whitespace-pre-wrap break-all p-2 text-left">{request.inputPreview}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
const toolContentItem: NonNullable<CollapseProps['items']>[number] = {
|
||||
...renderedItem,
|
||||
label: (
|
||||
<div className="flex w-full items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">{renderedItem.label}</div>
|
||||
<div className="shrink-0 pt-px">{statusIndicator}</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
classNames: {
|
||||
body: 'bg-foreground-50 p-2 text-foreground-900 dark:bg-foreground-100 max-h-60 overflow-auto'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<StreamingContext value={false}>
|
||||
<Collapse
|
||||
className="w-full"
|
||||
expandIconPosition="end"
|
||||
size="small"
|
||||
defaultActiveKey={[String(renderedItem.key ?? toolName)]}
|
||||
items={[toolContentItem]}
|
||||
/>
|
||||
</StreamingContext>
|
||||
)
|
||||
}
|
||||
}, [toolResponse.tool?.name, approval.input, toolResponse.arguments, statusInfo])
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-xl rounded-xl border border-default-200 bg-default-100 px-4 py-3 shadow-sm">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="font-semibold text-default-700 text-sm">{request.toolName}</div>
|
||||
<div className="text-default-500 text-xs">
|
||||
{request.description?.trim() || t('agent.toolPermission.defaultDescription')}
|
||||
</div>
|
||||
</div>
|
||||
<Container>
|
||||
{/* Tool content area with status in header */}
|
||||
{renderToolContent()}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<div
|
||||
className={`rounded px-2 py-0.5 font-medium text-xs ${
|
||||
isExpired ? 'text-[var(--color-error)]' : 'text-[var(--color-status-warning)]'
|
||||
}`}>
|
||||
{isExpired
|
||||
? t('agent.toolPermission.expired')
|
||||
: t('agent.toolPermission.pending', { seconds: remainingSeconds })}
|
||||
</div>
|
||||
{/* Bottom action bar - only show when not invoking */}
|
||||
{!approval.isExecuting && (
|
||||
<ActionsBar>
|
||||
<ToolApprovalActionsComponent {...approval} />
|
||||
</ActionsBar>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
aria-label={t('agent.toolPermission.aria.denyRequest')}
|
||||
className="h-8"
|
||||
color="danger"
|
||||
disabled={isSubmitting || isExpired}
|
||||
loading={isSubmittingDeny}
|
||||
onClick={() => handleDecision('deny')}
|
||||
icon={<CircleX size={16} />}
|
||||
iconPosition={'start'}
|
||||
variant="outlined">
|
||||
{t('agent.toolPermission.button.cancel')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
aria-label={t('agent.toolPermission.aria.allowRequest')}
|
||||
className="h-8 px-3"
|
||||
color="primary"
|
||||
disabled={isSubmitting || isExpired}
|
||||
loading={isSubmittingAllow}
|
||||
onClick={() => handleDecision('allow')}
|
||||
icon={<CirclePlay size={16} />}
|
||||
iconPosition={'start'}
|
||||
variant="solid">
|
||||
{t('agent.toolPermission.button.run')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
aria-label={
|
||||
showDetails ? t('agent.toolPermission.aria.hideDetails') : t('agent.toolPermission.aria.showDetails')
|
||||
}
|
||||
className="h-8 text-default-600 transition-colors hover:bg-default-200/50 hover:text-default-800"
|
||||
onClick={() => setShowDetails((value) => !value)}
|
||||
icon={<ChevronDown className={`transition-transform ${showDetails ? 'rotate-180' : ''}`} size={16} />}
|
||||
variant="text"
|
||||
style={{ backgroundColor: 'transparent' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{approval.isExpired && !approval.isSubmitting && !approval.isExecuting && (
|
||||
<div className="px-3 pb-2 text-center text-danger-500 text-xs">
|
||||
{t('agent.toolPermission.permissionExpired')}
|
||||
</div>
|
||||
|
||||
{showDetails && (
|
||||
<div className="flex flex-col gap-3 border-default-200 border-t pt-3">
|
||||
<div className="rounded-lg bg-default-200/60 px-3 py-2 text-default-600 text-sm">
|
||||
{t('agent.toolPermission.confirmation')}
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-default-200 bg-default-100 p-3">
|
||||
<p className="mb-2 font-medium text-default-400 text-xs uppercase tracking-wide">
|
||||
{t('agent.toolPermission.inputPreview')}
|
||||
</p>
|
||||
<div className="max-h-[192px] overflow-auto font-mono text-xs">
|
||||
<pre className="whitespace-pre-wrap break-all p-2 text-left">{request.inputPreview}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{request.requiresPermissions && (
|
||||
<div className="rounded-md border border-warning-300 bg-warning-50 p-3 text-warning-700 text-xs">
|
||||
{t('agent.toolPermission.requiresElevatedPermissions')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{request.suggestions.length > 0 && (
|
||||
<div className="rounded-md border border-default-200 bg-default-50 p-3 text-default-500 text-xs">
|
||||
{request.suggestions.length === 1
|
||||
? t('agent.toolPermission.suggestion.permissionUpdateSingle')
|
||||
: t('agent.toolPermission.suggestion.permissionUpdateMultiple')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isExpired && !isSubmitting && (
|
||||
<div className="text-center text-danger-500 text-xs">{t('agent.toolPermission.permissionExpired')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@ -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<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
Collapse: ({ items, defaultActiveKey, className }: any) => (
|
||||
<div data-testid="collapse" className={className} data-active-key={JSON.stringify(defaultActiveKey)}>
|
||||
{items?.map((item: any) => (
|
||||
<div key={item.key} data-testid={`collapse-item-${item.key}`}>
|
||||
<div data-testid={`collapse-header-${item.key}`}>{item.label}</div>
|
||||
<div data-testid={`collapse-content-${item.key}`}>{item.children}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
Spin: ({ size }: any) => <div data-testid="spin" data-size={size} />,
|
||||
Skeleton: {
|
||||
Input: ({ style }: any) => <span data-testid="skeleton-input" style={style} />
|
||||
},
|
||||
Tag: ({ children, className }: any) => (
|
||||
<span data-testid="tag" className={className}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
Popover: ({ children }: any) => <>{children}</>,
|
||||
Card: ({ children, className }: any) => (
|
||||
<div data-testid="card" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Button: ({ children, onClick, type, size, icon, disabled }: any) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="button"
|
||||
onClick={onClick}
|
||||
data-type={type}
|
||||
data-size={size}
|
||||
disabled={disabled}>
|
||||
{icon}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock('lucide-react', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
Loader2: ({ className }: any) => <span data-testid="loader-icon" className={className} />,
|
||||
FileText: () => <span data-testid="file-icon" />,
|
||||
Terminal: () => <span data-testid="terminal-icon" />,
|
||||
ListTodo: () => <span data-testid="list-icon" />,
|
||||
Circle: () => <span data-testid="circle-icon" />,
|
||||
CheckCircle: () => <span data-testid="check-circle-icon" />,
|
||||
Clock: () => <span data-testid="clock-icon" />,
|
||||
Check: () => <span data-testid="check-icon" />,
|
||||
TriangleAlert: () => <span data-testid="triangle-alert-icon" />,
|
||||
X: () => <span data-testid="x-icon" />,
|
||||
Wrench: () => <span data-testid="wrench-icon" />,
|
||||
ImageIcon: () => <span data-testid="image-icon" />
|
||||
}
|
||||
})
|
||||
|
||||
// Mock LoadingIcon
|
||||
vi.mock('@renderer/components/Icons', () => ({
|
||||
LoadingIcon: () => <span data-testid="loading-icon" />
|
||||
}))
|
||||
|
||||
// Mock ToolPermissionRequestCard
|
||||
vi.mock('../ToolPermissionRequestCard', () => ({
|
||||
default: () => <div data-testid="permission-card">Permission Required</div>
|
||||
}))
|
||||
|
||||
describe('MessageAgentTools', () => {
|
||||
// Mock translations for tools
|
||||
const mockTranslations: Record<string, string> = {
|
||||
'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> = {}): 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(<MessageAgentTools toolResponse={toolResponse} />)
|
||||
|
||||
// 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(<MessageAgentTools toolResponse={toolResponse} />)
|
||||
|
||||
// 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(<MessageAgentTools toolResponse={initialResponse} />)
|
||||
|
||||
// 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(<MessageAgentTools toolResponse={updatedResponse} />)
|
||||
|
||||
// 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(<MessageAgentTools toolResponse={toolResponse} />)
|
||||
|
||||
// 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(<MessageAgentTools toolResponse={toolResponse} />)
|
||||
|
||||
// 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(<MessageAgentTools toolResponse={toolResponse} />)
|
||||
|
||||
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(<MessageAgentTools toolResponse={toolResponse} />)
|
||||
|
||||
// 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(<MessageAgentTools toolResponse={toolResponse} />)
|
||||
|
||||
// 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(<MessageAgentTools toolResponse={toolResponse} />)
|
||||
|
||||
// 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
12
src/renderer/src/pages/home/Messages/Tools/hooks/index.ts
Normal file
12
src/renderer/src/pages/home/Messages/Tools/hooks/index.ts
Normal file
@ -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'
|
||||
@ -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<string, unknown>
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -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<number>(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
|
||||
}
|
||||
}
|
||||
@ -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<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified tool approval actions
|
||||
*/
|
||||
export interface ToolApprovalActions {
|
||||
/** Confirm/approve the tool execution */
|
||||
confirm: () => void | Promise<void>
|
||||
/** Cancel/deny the tool execution */
|
||||
cancel: () => void | Promise<void>
|
||||
/** Auto-approve this tool for future calls (if available) */
|
||||
autoApprove?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
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'
|
||||
104
src/renderer/src/pages/home/Messages/Tools/shared/ArgsTable.tsx
Normal file
104
src/renderer/src/pages/home/Messages/Tools/shared/ArgsTable.tsx
Normal file
@ -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<string, unknown> | 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 (
|
||||
<ArgsSection>
|
||||
{title && <ArgsSectionTitle>{title}</ArgsSectionTitle>}
|
||||
<ArgsTable>
|
||||
<tbody>
|
||||
{entries.map(([key, value]) => (
|
||||
<tr key={key}>
|
||||
<ArgKey>{key}</ArgKey>
|
||||
<ArgValue>{formatArgValue(value)}</ArgValue>
|
||||
</tr>
|
||||
))}
|
||||
{isStreaming && (
|
||||
<tr>
|
||||
<ArgKey>
|
||||
<SkeletonSpan width="60px" />
|
||||
</ArgKey>
|
||||
<ArgValue>
|
||||
<SkeletonSpan width="120px" />
|
||||
</ArgValue>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</ArgsTable>
|
||||
</ArgsSection>
|
||||
)
|
||||
}
|
||||
|
||||
// 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);
|
||||
`
|
||||
@ -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 }
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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<ToolMessageBlock> = {
|
||||
status: MessageBlockStatus.PENDING,
|
||||
metadata: { rawMcpToolResponse: toolResponse }
|
||||
}
|
||||
|
||||
blockManager.smartBlockUpdate(existingBlockId, changes, MessageBlockType.TOOL)
|
||||
},
|
||||
|
||||
onToolCallComplete: (toolResponse: ToolResponse) => {
|
||||
if (toolResponse?.id) {
|
||||
dispatch(toolPermissionsActions.removeByToolCallId({ toolCallId: toolResponse.id }))
|
||||
}
|
||||
|
||||
@ -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 // 大模型响应进行中
|
||||
|
||||
@ -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<string, unknown> | Record<string, unknown>[] | 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<ToolUseResponse | ToolCallResponse
|
||||
tool: MCPTool
|
||||
toolCallId?: string
|
||||
toolUseId?: string
|
||||
parentToolUseId?: string
|
||||
}
|
||||
|
||||
export interface NormalToolResponse extends Omit<ToolCallResponse, 'tool'> {
|
||||
tool: BaseTool
|
||||
toolCallId: string
|
||||
parentToolUseId?: string
|
||||
}
|
||||
|
||||
export interface MCPToolResultContent {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user