diff --git a/src/main/presenter/agentPresenter/loop/toolCallHandler.ts b/src/main/presenter/agentPresenter/loop/toolCallHandler.ts index 7c99e346d..544eedd1a 100644 --- a/src/main/presenter/agentPresenter/loop/toolCallHandler.ts +++ b/src/main/presenter/agentPresenter/loop/toolCallHandler.ts @@ -134,6 +134,29 @@ export class ToolCallHandler { state.pendingToolCall = undefined } + async processToolCallError( + state: GeneratingMessageState, + event: LLMAgentEventData + ): Promise { + const toolCallBlock = state.message.content.find( + (block) => + block.type === 'tool_call' && + block.tool_call?.id === event.tool_call_id && + block.status === 'loading' + ) + + if (toolCallBlock && toolCallBlock.type === 'tool_call') { + toolCallBlock.status = 'error' + if (toolCallBlock.tool_call) { + toolCallBlock.tool_call.response = event.tool_call_response || '' + } + } + + this.searchingMessages.delete(event.eventId) + state.isSearching = false + state.pendingToolCall = undefined + } + async processToolCallPermission( state: GeneratingMessageState, event: LLMAgentEventData, @@ -414,7 +437,6 @@ export class ToolCallHandler { const lastBlock = state.message.content[state.message.content.length - 1] if (lastBlock && lastBlock.type === 'tool_call' && lastBlock.tool_call) { - lastBlock.status = 'success' } this.finalizeLastBlock(state) diff --git a/src/main/presenter/agentPresenter/permission/permissionHandler.ts b/src/main/presenter/agentPresenter/permission/permissionHandler.ts index 8578a8a47..812103054 100644 --- a/src/main/presenter/agentPresenter/permission/permissionHandler.ts +++ b/src/main/presenter/agentPresenter/permission/permissionHandler.ts @@ -1,5 +1,3 @@ -import { eventBus, SendTarget } from '@/eventbus' -import { STREAM_EVENTS } from '@/events' import { presenter } from '@/presenter' import type { AssistantMessage, AssistantMessageBlock } from '@shared/chat' import type { @@ -133,12 +131,6 @@ export class PermissionHandler extends BaseHandler { } : undefined } - } else { - generatingState.message.content = content.map((block) => ({ - ...block, - extra: block.extra ? { ...block.extra } : undefined, - tool_call: block.tool_call ? { ...block.tool_call } : undefined - })) } } @@ -165,9 +157,9 @@ export class PermissionHandler extends BaseHandler { } const signature = this.commandPermissionHandler.extractCommandSignature(command) this.commandPermissionHandler.approve(conversationId, signature, remember) - await this.restartAgentLoopAfterPermission(messageId) + await this.restartAgentLoopAfterPermission(messageId, toolCallId) } else { - await this.continueAfterPermissionDenied(messageId) + await this.continueAfterPermissionDenied(messageId, permissionBlock) } return } @@ -187,9 +179,9 @@ export class PermissionHandler extends BaseHandler { throw error } - await this.restartAgentLoopAfterPermission(messageId) + await this.restartAgentLoopAfterPermission(messageId, toolCallId) } else { - await this.continueAfterPermissionDenied(messageId) + await this.continueAfterPermissionDenied(messageId, permissionBlock) } } catch (error) { console.error('[PermissionHandler] Failed to handle permission response:', error) @@ -207,7 +199,7 @@ export class PermissionHandler extends BaseHandler { } } - async restartAgentLoopAfterPermission(messageId: string): Promise { + async restartAgentLoopAfterPermission(messageId: string, toolCallId?: string): Promise { console.log('[PermissionHandler] Restarting agent loop after permission', messageId) try { @@ -219,17 +211,14 @@ export class PermissionHandler extends BaseHandler { const conversationId = message.conversationId await presenter.sessionManager.startLoop(conversationId, messageId) const content = message.content as AssistantMessageBlock[] + const permissionBlock = content.find( (block) => block.type === 'action' && block.action_type === 'tool_call_permission' && - block.status === 'granted' + block.tool_call?.id === toolCallId ) - if (!permissionBlock) { - throw new Error(`No granted permission block found (${messageId})`) - } - if (permissionBlock?.extra?.serverName) { try { const servers = await this.ctx.configPresenter.getMcpServers() @@ -240,8 +229,25 @@ export class PermissionHandler extends BaseHandler { } } + if (!permissionBlock) { + console.warn('[PermissionHandler] Granted permission block missing; continuing', messageId) + } + + const pendingToolCallFromPermission = + this.buildPendingToolCallFromPermissionBlock(permissionBlock) + const pendingToolCallFromId = toolCallId + ? this.buildPendingToolCallFromToolCallId(content, toolCallId) + : undefined + const fallbackPendingToolCall = + pendingToolCallFromPermission ?? + pendingToolCallFromId ?? + this.findPendingToolCallAfterPermission(content) + const state = this.generatingMessages.get(messageId) if (state) { + if (!state.pendingToolCall && fallbackPendingToolCall) { + state.pendingToolCall = fallbackPendingToolCall + } if (state.pendingToolCall) { await this.resumeAfterPermissionWithPendingToolCall( state, @@ -264,7 +270,7 @@ export class PermissionHandler extends BaseHandler { reasoningStartTime: null, reasoningEndTime: null, lastReasoningTime: null, - pendingToolCall: this.findPendingToolCallAfterPermission(assistantMessage.content) + pendingToolCall: fallbackPendingToolCall }) await this.streamGenerationHandler.startStreamCompletion(conversationId, messageId) @@ -282,7 +288,10 @@ export class PermissionHandler extends BaseHandler { } } - async continueAfterPermissionDenied(messageId: string): Promise { + async continueAfterPermissionDenied( + messageId: string, + resolvedPermissionBlock?: AssistantMessageBlock + ): Promise { console.log('[PermissionHandler] Continuing after permission denied', messageId) try { @@ -294,12 +303,14 @@ export class PermissionHandler extends BaseHandler { const conversationId = message.conversationId await presenter.sessionManager.startLoop(conversationId, messageId) const content = message.content as AssistantMessageBlock[] - const deniedPermissionBlock = content.find( - (block) => - block.type === 'action' && - block.action_type === 'tool_call_permission' && - block.status === 'denied' - ) + const deniedPermissionBlock = + resolvedPermissionBlock || + content.find( + (block) => + block.type === 'action' && + block.action_type === 'tool_call_permission' && + block.status === 'denied' + ) if (!deniedPermissionBlock?.tool_call) { console.warn('[PermissionHandler] No denied permission block for', messageId) @@ -307,21 +318,7 @@ export class PermissionHandler extends BaseHandler { } const toolCall = deniedPermissionBlock.tool_call - const errorMessage = `Tool execution failed: Permission denied by user for ${ - toolCall.name || 'this tool' - }` - - eventBus.sendToRenderer(STREAM_EVENTS.RESPONSE, SendTarget.ALL_WINDOWS, { - eventId: messageId, - tool_call: 'end', - tool_call_id: toolCall.id, - tool_call_name: toolCall.name, - tool_call_params: toolCall.params, - tool_call_response: errorMessage, - tool_call_server_name: toolCall.server_name, - tool_call_server_icons: toolCall.server_icons, - tool_call_server_description: toolCall.server_description - }) + const errorMessage = 'User denied the request.' let state = this.generatingMessages.get(messageId) if (!state) { @@ -340,6 +337,18 @@ export class PermissionHandler extends BaseHandler { state.pendingToolCall = undefined + await this.llmEventHandler.handleLLMAgentResponse({ + eventId: messageId, + tool_call: 'error', + tool_call_id: toolCall.id, + tool_call_name: toolCall.name, + tool_call_params: toolCall.params, + tool_call_response: errorMessage, + tool_call_server_name: toolCall.server_name, + tool_call_server_icons: toolCall.server_icons, + tool_call_server_description: toolCall.server_description + } as any) + const { conversation, contextMessages, userMessage } = await this.streamGenerationHandler.prepareConversationContext(conversationId, messageId) @@ -738,16 +747,46 @@ export class PermissionHandler extends BaseHandler { block.status === 'granted' ) - if (!grantedPermissionBlock?.tool_call) { + return this.buildPendingToolCallFromPermissionBlock(grantedPermissionBlock) + } + private buildPendingToolCallFromPermissionBlock( + block?: AssistantMessageBlock + ): PendingToolCall | undefined { + if (!block?.tool_call) { return undefined } - const { id, name, params } = grantedPermissionBlock.tool_call + const { id, name, params } = block.tool_call if (!id || !name || !params) { - console.warn( - '[PermissionHandler] Incomplete tool call info:', - grantedPermissionBlock.tool_call - ) + console.warn('[PermissionHandler] Incomplete tool call info:', block.tool_call) + return undefined + } + + return { + id, + name, + params, + serverName: block.tool_call.server_name, + serverIcons: block.tool_call.server_icons, + serverDescription: block.tool_call.server_description + } + } + + private buildPendingToolCallFromToolCallId( + content: AssistantMessageBlock[], + toolCallId: string + ): PendingToolCall | undefined { + const toolCallBlock = content.find( + (block) => block.type === 'tool_call' && block.tool_call?.id === toolCallId + ) + + if (!toolCallBlock || toolCallBlock.type !== 'tool_call' || !toolCallBlock.tool_call) { + return undefined + } + + const { id, name, params } = toolCallBlock.tool_call + if (!id || !name || !params) { + console.warn('[PermissionHandler] Incomplete tool call info:', toolCallBlock.tool_call) return undefined } @@ -755,9 +794,9 @@ export class PermissionHandler extends BaseHandler { id, name, params, - serverName: grantedPermissionBlock.tool_call.server_name, - serverIcons: grantedPermissionBlock.tool_call.server_icons, - serverDescription: grantedPermissionBlock.tool_call.server_description + serverName: toolCallBlock.tool_call.server_name, + serverIcons: toolCallBlock.tool_call.server_icons, + serverDescription: toolCallBlock.tool_call.server_description } } diff --git a/src/main/presenter/agentPresenter/streaming/llmEventHandler.ts b/src/main/presenter/agentPresenter/streaming/llmEventHandler.ts index 41e139aed..2211f17da 100644 --- a/src/main/presenter/agentPresenter/streaming/llmEventHandler.ts +++ b/src/main/presenter/agentPresenter/streaming/llmEventHandler.ts @@ -143,6 +143,9 @@ export class LLMEventHandler { case 'continue': await this.toolCallHandler.processToolCallPermission(state, msg, currentTime) break + case 'error': + await this.toolCallHandler.processToolCallError(state, msg) + break case 'end': await this.toolCallHandler.processToolCallEnd(state, msg) break @@ -279,7 +282,9 @@ export class LLMEventHandler { !(block.type === 'action' && block.action_type === 'tool_call_permission') && block.status === 'loading' ) { - block.status = 'success' + if (block.type !== 'tool_call') { + block.status = 'success' + } } }) await this.messageManager.editMessage(eventId, JSON.stringify(state.message.content)) diff --git a/src/renderer/src/components/message/MessageBlockThink.vue b/src/renderer/src/components/message/MessageBlockThink.vue index df492f8ff..d93943bd0 100644 --- a/src/renderer/src/components/message/MessageBlockThink.vue +++ b/src/renderer/src/components/message/MessageBlockThink.vue @@ -14,6 +14,7 @@ import { ThinkContent } from '@/components/think-content' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { usePresenter } from '@/composables/usePresenter' import { AssistantMessageBlock } from '@shared/chat' +import { useThrottleFn } from '@vueuse/core' const props = defineProps<{ block: AssistantMessageBlock usage: { @@ -101,8 +102,10 @@ watch( } ) -watch( - () => [props.block.status, props.block.reasoning_time?.start], +const statusWatchSource = () => + [props.block.status, props.block.reasoning_time?.start, props.block.reasoning_time?.end] as const + +const handleStatusChange = useThrottleFn( () => { updateDisplayedSeconds() if (props.block.status === 'loading') { @@ -111,6 +114,16 @@ watch( stopTimer() } }, + 500, + true, + true +) + +watch( + statusWatchSource, + () => { + handleStatusChange() + }, { immediate: true } ) diff --git a/src/renderer/src/components/message/MessageBlockToolCall.vue b/src/renderer/src/components/message/MessageBlockToolCall.vue index 2b4d77a0c..d001436dd 100644 --- a/src/renderer/src/components/message/MessageBlockToolCall.vue +++ b/src/renderer/src/components/message/MessageBlockToolCall.vue @@ -25,43 +25,56 @@ v-if="isExpanded" class="rounded-lg border bg-muted text-card-foreground px-2 py-3 mt-2 mb-4 max-w-full sm:max-w-2xl" > -
+
-
-
- - {{ t('toolCall.params') }} -
-
- +
+
+
+ + {{ t('toolCall.params') }} +
+
+
{{ paramsText }}
-
- - -
-
- - {{ t('toolCall.responseData') }} -
-
- +
+ + +
+
+
+ + {{ isTerminalTool ? t('toolCall.terminalOutput') : t('toolCall.responseData') }} +
+
-
- -
- - -
-
- - {{ t('toolCall.terminalOutput') }} -
-
+
{{ responseText }}
@@ -73,10 +86,7 @@ import { Icon } from '@iconify/vue' import { useI18n } from 'vue-i18n' import { AssistantMessageBlock } from '@shared/chat' -import { computed, ref, nextTick, watch, onBeforeUnmount } from 'vue' -import { JsonObject } from '@/components/json-viewer' -import { Terminal } from '@xterm/xterm' -import '@xterm/xterm/css/xterm.css' +import { computed, ref } from 'vue' const keyMap = { 'toolCall.calling': '工具调用中', @@ -88,15 +98,16 @@ const keyMap = { 'toolCall.functionName': '函数名称', 'toolCall.params': '参数', 'toolCall.responseData': '响应数据', - 'toolCall.terminalOutput': 'Terminal output' + 'toolCall.terminalOutput': 'Terminal output', + 'common.copy': '复制', + 'common.copySuccess': '已复制' } -// 创建一个安全的翻译函数 + const t = (() => { try { const { t } = useI18n() return t } catch (e) { - // 如果 i18n 未初始化,提供默认翻译 return (key: string) => keyMap[key] || key } })() @@ -160,115 +171,56 @@ const statusIconClass = computed(() => { } }) -// 解析JSON为对象;解析失败时回退原文 -const parseJson = (jsonStr: string) => { - if (!jsonStr) return {} - try { - const parsed = JSON.parse(jsonStr) - if (parsed && (typeof parsed === 'object' || Array.isArray(parsed))) { - return parsed - } - return { raw: parsed ?? jsonStr } - } catch (e) { - return { raw: jsonStr } - } -} +const paramsText = computed(() => props.block.tool_call?.params ?? '') +const responseText = computed(() => props.block.tool_call?.response ?? '') +const hasParams = computed(() => paramsText.value.trim().length > 0) +const hasResponse = computed(() => responseText.value.trim().length > 0) -const parseTerminalOutput = (response: string) => { - if (!response) return '' - try { - const parsed = JSON.parse(response) - if (typeof parsed === 'string') return parsed - if (parsed && typeof parsed === 'object') { - if (typeof parsed.output === 'string') return parsed.output - if (typeof parsed.stdout === 'string') return parsed.stdout - } - } catch { - // Fallback to raw response - } - return response -} - -// Terminal detection const isTerminalTool = computed(() => { const name = props.block.tool_call?.name?.toLowerCase() || '' const serverName = props.block.tool_call?.server_name?.toLowerCase() || '' - if (name == 'run_shell_command' && serverName === 'powerpack') { + if (name === 'run_shell_command' && serverName === 'powerpack') { return false } return name.includes('terminal') || name.includes('command') || name.includes('exec') }) -// Terminal rendering -const terminalContainer = ref(null) -let terminal: Terminal | null = null - -const parsedParams = computed(() => parseJson(props.block.tool_call?.params ?? '')) -const hasParams = computed(() => { - const data = parsedParams.value as unknown - if (Array.isArray(data)) return data.length > 0 - if (data && typeof data === 'object') return Object.keys(data).length > 0 - if (typeof data === 'string') return data.trim().length > 0 - return false -}) - -const initTerminal = () => { - if (!terminalContainer.value || !isTerminalTool.value) return +const paramsCopyText = ref(t('common.copy')) +const responseCopyText = ref(t('common.copy')) - // Clean up any existing terminal before creating a new one - if (terminal) { - try { - terminal.dispose() - } catch (error) { - console.warn('[MessageBlockToolCall] Failed to dispose existing terminal:', error) +const copyParams = async () => { + if (!hasParams.value) return + try { + if (window.api?.copyText) { + window.api.copyText(paramsText.value) + } else { + await navigator.clipboard.writeText(paramsText.value) } - terminal = null - } - // Clear previous terminal DOM content - terminalContainer.value.innerHTML = '' - - terminal = new Terminal({ - convertEol: true, - fontSize: 12, - fontFamily: 'Menlo, Monaco, "Courier New", monospace', - theme: { - background: '#000000', - foreground: '#ffffff' - }, - cursorStyle: 'bar', - scrollback: 1000, - disableStdin: true // Read-only - }) - - terminal.open(terminalContainer.value) - - // Write terminal output from response - const response = props.block.tool_call?.response ?? '' - const output = parseTerminalOutput(response) - if (output) { - terminal.write(output.replace(/\n/g, '\r\n')) + paramsCopyText.value = t('common.copySuccess') + setTimeout(() => { + paramsCopyText.value = t('common.copy') + }, 2000) + } catch (error) { + console.error('[MessageBlockToolCall] Failed to copy params:', error) } } -// Watch for expanded state and initialize terminal -watch( - [isExpanded, () => props.block.tool_call?.response], - () => { - if (isExpanded.value && isTerminalTool.value) { - nextTick(() => { - initTerminal() - }) +const copyResponse = async () => { + if (!hasResponse.value) return + try { + if (window.api?.copyText) { + window.api.copyText(responseText.value) + } else { + await navigator.clipboard.writeText(responseText.value) } - }, - { immediate: true } -) - -onBeforeUnmount(() => { - if (terminal) { - terminal.dispose() - terminal = null + responseCopyText.value = t('common.copySuccess') + setTimeout(() => { + responseCopyText.value = t('common.copy') + }, 2000) + } catch (error) { + console.error('[MessageBlockToolCall] Failed to copy response:', error) } -}) +}