From f7648413ed8d0a34a5e8d638a86b5c7ec7efd10e Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 9 Jun 2026 03:05:00 +0800 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(ai-chat):=20?= =?UTF-8?q?=E6=8B=86=E5=88=86=E6=B5=81=E5=BC=8F=E6=B6=88=E6=81=AF=E8=AE=A2?= =?UTF-8?q?=E9=98=85=20Hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AIChatPanel.message-boundary.test.tsx | 6 + frontend/src/components/AIChatPanel.tsx | 245 +------------- .../ai/useAIChatStreamSubscription.ts | 320 ++++++++++++++++++ 3 files changed, 344 insertions(+), 227 deletions(-) create mode 100644 frontend/src/components/ai/useAIChatStreamSubscription.ts diff --git a/frontend/src/components/AIChatPanel.message-boundary.test.tsx b/frontend/src/components/AIChatPanel.message-boundary.test.tsx index d5520be..19641fb 100644 --- a/frontend/src/components/AIChatPanel.message-boundary.test.tsx +++ b/frontend/src/components/AIChatPanel.message-boundary.test.tsx @@ -7,6 +7,7 @@ const conversationViewSource = readFileSync(new URL('./ai/AIChatPanelConversatio const derivedStateSource = readFileSync(new URL('./ai/aiChatPanelDerivedState.ts', import.meta.url), 'utf8'); const payloadDispatchSource = readFileSync(new URL('./ai/aiChatPayloadDispatch.ts', import.meta.url), 'utf8'); const runtimeResourcesSource = readFileSync(new URL('./ai/useAIChatRuntimeResources.ts', import.meta.url), 'utf8'); +const streamSubscriptionSource = readFileSync(new URL('./ai/useAIChatStreamSubscription.ts', import.meta.url), 'utf8'); const systemContextSource = readFileSync(new URL('./ai/aiSystemContextMessages.ts', import.meta.url), 'utf8'); const runtimeSource = readFileSync(new URL('../utils/aiChatRuntime.ts', import.meta.url), 'utf8'); @@ -52,12 +53,17 @@ describe('AIChatPanel message render isolation', () => { it('extracts chat runtime helpers so context compression and error cleanup stay out of the panel file', () => { expect(source).toContain("import { dispatchAIChatPayload } from './ai/aiChatPayloadDispatch';"); + expect(source).toContain("import { useAIChatStreamSubscription } from './ai/useAIChatStreamSubscription';"); expect(source).toContain('compressContextIfNeeded, getDynamicMaxContextChars'); + expect(source).toContain('useAIChatStreamSubscription({'); expect(runtimeSource).toContain('export const getDynamicMaxContextChars'); expect(runtimeSource).toContain('export const compressContextIfNeeded'); expect(runtimeSource).toContain('export const sanitizeErrorMsg'); expect(payloadDispatchSource).toContain('export const dispatchAIChatPayload'); expect(payloadDispatchSource).toContain('sanitizeErrorMsg'); + expect(streamSubscriptionSource).toContain('EventsOn(eventName, handler);'); + expect(streamSubscriptionSource).toContain('请直接使用 function call 调用工具执行操作'); + expect(streamSubscriptionSource).toContain('executeLocalTools(existing.tool_calls!, doneAssistantId)'); expect(runtimeSource).toContain('⚙️ 对话已超载,正在启动记忆压缩'); }); diff --git a/frontend/src/components/AIChatPanel.tsx b/frontend/src/components/AIChatPanel.tsx index 569a539..e1ad4f0 100644 --- a/frontend/src/components/AIChatPanel.tsx +++ b/frontend/src/components/AIChatPanel.tsx @@ -1,7 +1,6 @@ import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { createPortal } from 'react-dom'; import { useStore, loadAISessionsFromBackend, loadAISessionFromBackend } from '../store'; -import { EventsOn, EventsOff } from '../../wailsjs/runtime'; import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; import type { AIChatMessage, @@ -15,6 +14,7 @@ import { AIChatHeader } from './ai/AIChatHeader'; import { AIChatInput } from './ai/AIChatInput'; import { AIHistoryDrawer } from './ai/AIHistoryDrawer'; import AIChatPanelConversationView from './ai/AIChatPanelConversationView'; +import { useAIChatStreamSubscription } from './ai/useAIChatStreamSubscription'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; import { buildIncompleteProviderNotice, @@ -23,7 +23,7 @@ import { } from '../utils/aiComposerNotice'; import { consumeAIChatSendShortcutOnKeyDown } from '../utils/aiChatSendShortcut'; import { toAIRequestMessage } from '../utils/aiMessagePayload'; -import { compressContextIfNeeded, getDynamicMaxContextChars, sanitizeErrorMsg } from '../utils/aiChatRuntime'; +import { compressContextIfNeeded, getDynamicMaxContextChars } from '../utils/aiChatRuntime'; import { getShortcutPlatform, resolveShortcutBinding } from '../utils/shortcuts'; import { isMacLikePlatform } from '../utils/appearance'; import { buildAvailableAIChatTools } from '../utils/aiToolRegistry'; @@ -285,231 +285,6 @@ export const AIChatPanel: React.FC = ({ return () => window.removeEventListener('gonavi:ai:inject-prompt', handler); }, []); - useEffect(() => { - const eventName = `ai:stream:${sid}`; - let assistantMsgId = ''; - let isFirstCompletion = false; - - // 新增:利用 requestAnimationFrame 缓冲高频事件,避免 React 重绘阻塞导致感官吞吐变慢 - const streamBuffer = { thinking: '', reasoningContent: '', content: '' }; - let flushPending = false; - - const flushStreamBuffer = () => { - if (!assistantMsgId) return; - const current = useStore.getState().aiChatHistory[sid]; - const existing = current?.find(m => m.id === assistantMsgId); - if (!existing) return; - - const updates: any = {}; - if (streamBuffer.thinking) { - updates.thinking = (existing.thinking || '') + streamBuffer.thinking; - updates.phase = 'thinking'; - streamBuffer.thinking = ''; - } - if (streamBuffer.reasoningContent) { - updates.reasoning_content = (existing.reasoning_content || '') + streamBuffer.reasoningContent; - streamBuffer.reasoningContent = ''; - } - if (streamBuffer.content) { - updates.content = (existing.content || '') + streamBuffer.content; - updates.phase = 'generating'; - streamBuffer.content = ''; - } - - if (Object.keys(updates).length > 0) { - updateAIChatMessage(sid, assistantMsgId, updates); - } - flushPending = false; - }; - - const handler = (data: { content?: string; thinking?: string; reasoning_content?: string; tool_calls?: AIToolCall[]; done?: boolean; error?: string }) => { - // Find connecting message if there's no active assistant string - if (!assistantMsgId) { - const history = useStore.getState().aiChatHistory[sid] || []; - const lastMsg = history[history.length - 1]; - if (lastMsg && lastMsg.role === 'assistant' && lastMsg.loading && lastMsg.phase === 'connecting') { - assistantMsgId = lastMsg.id; - // 【关键】接管 connecting 消息时,立即清空其过渡文案,防止泄漏到 AI 回复正文 - updateAIChatMessage(sid, assistantMsgId, { content: '' }); - } - } - - if (data.error) { - const cleanErr = sanitizeErrorMsg(data.error); - const rawErr = cleanErr !== data.error ? data.error : undefined; - if (assistantMsgId) { - updateAIChatMessage(sid, assistantMsgId, { content: `❌ 错误: ${cleanErr}`, phase: 'idle', loading: false, rawError: rawErr }); - } else { - addAIChatMessage(sid, { - id: genId(), - role: 'assistant', - phase: 'idle', - content: `❌ 错误: ${cleanErr}`, - rawError: rawErr, - timestamp: Date.now(), - jvmPlanContext: pendingJVMPlanContextRef.current, - jvmDiagnosticPlanContext: pendingJVMDiagnosticPlanContextRef.current, - }); - } - assistantMsgId = ''; - setSending(false); - return; - } - - if (data.tool_calls && data.tool_calls.length > 0) { - if (assistantMsgId) { - updateAIChatMessage(sid, assistantMsgId, { tool_calls: data.tool_calls, phase: 'tool_calling' }); - } else { - assistantMsgId = genId(); - addAIChatMessage(sid, { - id: assistantMsgId, - role: 'assistant', - phase: 'tool_calling', - content: '', - tool_calls: data.tool_calls, - timestamp: Date.now(), - loading: true, - jvmPlanContext: pendingJVMPlanContextRef.current, - jvmDiagnosticPlanContext: pendingJVMDiagnosticPlanContextRef.current, - }); - } - } - - // 处理 thinking(模型思考过程) - const displayThinking = data.thinking || data.reasoning_content || ''; - if (displayThinking || data.reasoning_content) { - if (!assistantMsgId) { - assistantMsgId = genId(); - addAIChatMessage(sid, { - id: assistantMsgId, - role: 'assistant', - phase: 'thinking', - content: '', - thinking: displayThinking || undefined, - reasoning_content: data.reasoning_content || undefined, - timestamp: Date.now(), - loading: true, - jvmPlanContext: pendingJVMPlanContextRef.current, - jvmDiagnosticPlanContext: pendingJVMDiagnosticPlanContextRef.current, - }); - if (sending) setSending(false); - } else { - streamBuffer.thinking += displayThinking; - if (data.reasoning_content) { - streamBuffer.reasoningContent += data.reasoning_content; - } - if (sending) setSending(false); - } - } - - if (data.content) { - if (!assistantMsgId) { - assistantMsgId = genId(); - addAIChatMessage(sid, { - id: assistantMsgId, - role: 'assistant', - phase: 'generating', - content: data.content, - timestamp: Date.now(), - loading: true, - jvmPlanContext: pendingJVMPlanContextRef.current, - jvmDiagnosticPlanContext: pendingJVMDiagnosticPlanContextRef.current, - }); - setSending(false); - const currentHistory = useStore.getState().aiChatHistory[sid] || []; - if (currentHistory.length <= 1) isFirstCompletion = true; - } else { - streamBuffer.content += data.content; - if (sending) setSending(false); - } - } - - if (streamBuffer.thinking || streamBuffer.reasoningContent || streamBuffer.content) { - if (!flushPending) { - flushPending = true; - requestAnimationFrame(flushStreamBuffer); - } - } - - if (data.done) { - // 如果有残留未 flush 的 buffer,立刻推入状态树 - if (streamBuffer.thinking || streamBuffer.reasoningContent || streamBuffer.content) { - flushStreamBuffer(); - } - const doneAssistantId = assistantMsgId; - const doneIsFirst = isFirstCompletion; - assistantMsgId = ''; - setTimeout(() => { - // 🔧 清除所有残留的 connecting 过渡气泡的 loading 状态 - const currentMsgs = useStore.getState().aiChatHistory[sid] || []; - for (const msg of currentMsgs) { - if (msg.id !== doneAssistantId && msg.loading && msg.phase === 'connecting') { - updateAIChatMessage(sid, msg.id, { loading: false, phase: 'idle' }); - } - } - - if (doneAssistantId) { - const current = useStore.getState().aiChatHistory[sid]; - const existing = current?.find(m => m.id === doneAssistantId); - if (existing && existing.tool_calls && existing.tool_calls.length > 0) { - // 【关键】保持 loading:true 和 phase:'tool_calling',让 UI 能实时展示工具执行进度 - nudgeCountRef.current = 0; - setTimeout(() => executeLocalTools(existing.tool_calls!, doneAssistantId), 50); - return; - } - - // 自动催促:模型描述了要调用工具但没有 function call - if (existing && nudgeCountRef.current < 2 && - /(?:让我|我先|我来|现在|接下来|下面).*(?:查询|查找|获取|查看|检查|调用)|(?:获取|查询|查找|查看).*(?:信息|字段|列表|数据)[::]?\s*$/.test(existing.content || '')) { - nudgeCountRef.current += 1; - // 🔧 关闭当前消息的 loading 状态,消除闪烁光标 - updateAIChatMessage(sid, doneAssistantId, { loading: false, phase: 'idle' }); - // 注入 system 催促并重发 - (async () => { - try { - const currentHistory = useStore.getState().aiChatHistory[sid] || []; - const messagesPayload = currentHistory.map(toAIRequestMessage); - const sysMessages = await buildSystemContextMessages( - existing.jvmPlanContext, - existing.jvmDiagnosticPlanContext, - ); - // 追加催促消息 - messagesPayload.push({ role: 'user', content: '请直接使用 function call 调用工具执行操作,不要只用文字描述计划。' }); - const allMsg = [...sysMessages, ...messagesPayload]; - const Service = (window as any).go?.aiservice?.Service; - if (Service?.AIChatStream) await Service.AIChatStream(sid, allMsg, availableTools); - } catch (e) { - console.error('Nudge failed', e); - setSending(false); - } - })(); - return; - } - - if (doneIsFirst) generateTitleForSession(sid); - - // 正常完成:关闭 loading,消除闪烁光标 - const hasContent = !!existing?.content?.trim(); - const hasThinking = !!existing?.thinking?.trim(); - const hasTools = !!(existing?.tool_calls?.length); - - if (!hasContent && !hasThinking && !hasTools) { - updateAIChatMessage(sid, doneAssistantId, { content: '❌ 模型未能成功响应任何内容,可能遭遇频控、上下文超载或理解拒绝。', loading: false, phase: 'idle' }); - } else { - updateAIChatMessage(sid, doneAssistantId, { loading: false, phase: 'idle' }); - } - } else { - addAIChatMessage(sid, { id: genId(), role: 'assistant', content: '❌ 请求中断:未收到任何具体回复。', timestamp: Date.now(), loading: false }); - } - setSending(false); - }, 50); - } - }; - - EventsOn(eventName, handler); - return () => { EventsOff(eventName); }; - }, [addAIChatMessage, updateAIChatMessage, sid]); - const generateTitleForSession = async (currentSid: string) => { try { const Service = (window as any).go?.aiservice?.Service; @@ -800,6 +575,22 @@ export const AIChatPanel: React.FC = ({ } }, [availableTools, buildSystemContextMessages, dynamicModels, mcpTools, sid, skills]); + useAIChatStreamSubscription({ + sid, + sending, + setSending, + availableTools, + addAIChatMessage, + updateAIChatMessage, + buildSystemContextMessages, + executeLocalTools, + generateTitleForSession, + nextMessageId: genId, + nudgeCountRef, + pendingJVMPlanContextRef, + pendingJVMDiagnosticPlanContextRef, + }); + const handleSend = useCallback(async () => { const text = input.trim(); if ((!text && draftImages.length === 0) || sending) return; diff --git a/frontend/src/components/ai/useAIChatStreamSubscription.ts b/frontend/src/components/ai/useAIChatStreamSubscription.ts new file mode 100644 index 0000000..99f6d28 --- /dev/null +++ b/frontend/src/components/ai/useAIChatStreamSubscription.ts @@ -0,0 +1,320 @@ +import { useEffect, useRef } from 'react'; +import type { MutableRefObject } from 'react'; + +import { EventsOn, EventsOff } from '../../../wailsjs/runtime'; +import { useStore } from '../../store'; +import type { + AIChatMessage, + AIToolCall, + JVMAIPlanContext, + JVMDiagnosticPlanContext, +} from '../../types'; +import { sanitizeErrorMsg } from '../../utils/aiChatRuntime'; +import { toAIRequestMessage } from '../../utils/aiMessagePayload'; +import type { AIChatToolDefinition } from '../../utils/aiToolRegistry'; + +interface AIChatStreamChunk { + content?: string; + thinking?: string; + reasoning_content?: string; + tool_calls?: AIToolCall[]; + done?: boolean; + error?: string; +} + +interface UseAIChatStreamSubscriptionOptions { + sid: string; + sending: boolean; + setSending: (sending: boolean) => void; + availableTools: AIChatToolDefinition[]; + addAIChatMessage: (sid: string, message: AIChatMessage) => void; + updateAIChatMessage: ( + sid: string, + messageId: string, + patch: Partial, + ) => void; + buildSystemContextMessages: ( + overrideJVMPlanContext?: JVMAIPlanContext, + overrideJVMDiagnosticPlanContext?: JVMDiagnosticPlanContext, + ) => any[] | Promise; + executeLocalTools: (toolCalls: AIToolCall[], currentAsstMsgId: string) => void | Promise; + generateTitleForSession: (sid: string) => void | Promise; + nextMessageId: () => string; + nudgeCountRef: MutableRefObject; + pendingJVMPlanContextRef: MutableRefObject; + pendingJVMDiagnosticPlanContextRef: MutableRefObject; +} + +export const useAIChatStreamSubscription = ({ + sid, + sending, + setSending, + availableTools, + addAIChatMessage, + updateAIChatMessage, + buildSystemContextMessages, + executeLocalTools, + generateTitleForSession, + nextMessageId, + nudgeCountRef, + pendingJVMPlanContextRef, + pendingJVMDiagnosticPlanContextRef, +}: UseAIChatStreamSubscriptionOptions) => { + const sendingRef = useRef(sending); + + useEffect(() => { + sendingRef.current = sending; + }, [sending]); + + useEffect(() => { + const eventName = `ai:stream:${sid}`; + let assistantMsgId = ''; + let isFirstCompletion = false; + + // 缓冲高频 token,避免把流式吞吐直接转成同步重绘风暴 + const streamBuffer = { thinking: '', reasoningContent: '', content: '' }; + let flushPending = false; + + const flushStreamBuffer = () => { + if (!assistantMsgId) return; + const current = useStore.getState().aiChatHistory[sid]; + const existing = current?.find((message) => message.id === assistantMsgId); + if (!existing) return; + + const updates: Partial = {}; + if (streamBuffer.thinking) { + updates.thinking = (existing.thinking || '') + streamBuffer.thinking; + updates.phase = 'thinking'; + streamBuffer.thinking = ''; + } + if (streamBuffer.reasoningContent) { + updates.reasoning_content = (existing.reasoning_content || '') + streamBuffer.reasoningContent; + streamBuffer.reasoningContent = ''; + } + if (streamBuffer.content) { + updates.content = (existing.content || '') + streamBuffer.content; + updates.phase = 'generating'; + streamBuffer.content = ''; + } + + if (Object.keys(updates).length > 0) { + updateAIChatMessage(sid, assistantMsgId, updates); + } + flushPending = false; + }; + + const handler = (data: AIChatStreamChunk) => { + if (!assistantMsgId) { + const history = useStore.getState().aiChatHistory[sid] || []; + const lastMsg = history[history.length - 1]; + if (lastMsg && lastMsg.role === 'assistant' && lastMsg.loading && lastMsg.phase === 'connecting') { + assistantMsgId = lastMsg.id; + updateAIChatMessage(sid, assistantMsgId, { content: '' }); + } + } + + if (data.error) { + const cleanErr = sanitizeErrorMsg(data.error); + const rawErr = cleanErr !== data.error ? data.error : undefined; + if (assistantMsgId) { + updateAIChatMessage(sid, assistantMsgId, { + content: `❌ 错误: ${cleanErr}`, + phase: 'idle', + loading: false, + rawError: rawErr, + }); + } else { + addAIChatMessage(sid, { + id: nextMessageId(), + role: 'assistant', + phase: 'idle', + content: `❌ 错误: ${cleanErr}`, + rawError: rawErr, + timestamp: Date.now(), + jvmPlanContext: pendingJVMPlanContextRef.current, + jvmDiagnosticPlanContext: pendingJVMDiagnosticPlanContextRef.current, + }); + } + assistantMsgId = ''; + setSending(false); + return; + } + + if (data.tool_calls && data.tool_calls.length > 0) { + if (assistantMsgId) { + updateAIChatMessage(sid, assistantMsgId, { tool_calls: data.tool_calls, phase: 'tool_calling' }); + } else { + assistantMsgId = nextMessageId(); + addAIChatMessage(sid, { + id: assistantMsgId, + role: 'assistant', + phase: 'tool_calling', + content: '', + tool_calls: data.tool_calls, + timestamp: Date.now(), + loading: true, + jvmPlanContext: pendingJVMPlanContextRef.current, + jvmDiagnosticPlanContext: pendingJVMDiagnosticPlanContextRef.current, + }); + } + } + + const displayThinking = data.thinking || data.reasoning_content || ''; + if (displayThinking || data.reasoning_content) { + if (!assistantMsgId) { + assistantMsgId = nextMessageId(); + addAIChatMessage(sid, { + id: assistantMsgId, + role: 'assistant', + phase: 'thinking', + content: '', + thinking: displayThinking || undefined, + reasoning_content: data.reasoning_content || undefined, + timestamp: Date.now(), + loading: true, + jvmPlanContext: pendingJVMPlanContextRef.current, + jvmDiagnosticPlanContext: pendingJVMDiagnosticPlanContextRef.current, + }); + if (sendingRef.current) setSending(false); + } else { + streamBuffer.thinking += displayThinking; + if (data.reasoning_content) { + streamBuffer.reasoningContent += data.reasoning_content; + } + if (sendingRef.current) setSending(false); + } + } + + if (data.content) { + if (!assistantMsgId) { + assistantMsgId = nextMessageId(); + addAIChatMessage(sid, { + id: assistantMsgId, + role: 'assistant', + phase: 'generating', + content: data.content, + timestamp: Date.now(), + loading: true, + jvmPlanContext: pendingJVMPlanContextRef.current, + jvmDiagnosticPlanContext: pendingJVMDiagnosticPlanContextRef.current, + }); + setSending(false); + const currentHistory = useStore.getState().aiChatHistory[sid] || []; + if (currentHistory.length <= 1) isFirstCompletion = true; + } else { + streamBuffer.content += data.content; + if (sendingRef.current) setSending(false); + } + } + + if (streamBuffer.thinking || streamBuffer.reasoningContent || streamBuffer.content) { + if (!flushPending) { + flushPending = true; + requestAnimationFrame(flushStreamBuffer); + } + } + + if (data.done) { + if (streamBuffer.thinking || streamBuffer.reasoningContent || streamBuffer.content) { + flushStreamBuffer(); + } + const doneAssistantId = assistantMsgId; + const doneIsFirst = isFirstCompletion; + assistantMsgId = ''; + setTimeout(() => { + const currentMsgs = useStore.getState().aiChatHistory[sid] || []; + for (const msg of currentMsgs) { + if (msg.id !== doneAssistantId && msg.loading && msg.phase === 'connecting') { + updateAIChatMessage(sid, msg.id, { loading: false, phase: 'idle' }); + } + } + + if (doneAssistantId) { + const current = useStore.getState().aiChatHistory[sid]; + const existing = current?.find((message) => message.id === doneAssistantId); + if (existing && existing.tool_calls && existing.tool_calls.length > 0) { + nudgeCountRef.current = 0; + setTimeout(() => executeLocalTools(existing.tool_calls!, doneAssistantId), 50); + return; + } + + if ( + existing && + nudgeCountRef.current < 2 && + /(?:让我|我先|我来|现在|接下来|下面).*(?:查询|查找|获取|查看|检查|调用)|(?:获取|查询|查找|查看).*(?:信息|字段|列表|数据)[::]?\s*$/.test(existing.content || '') + ) { + nudgeCountRef.current += 1; + updateAIChatMessage(sid, doneAssistantId, { loading: false, phase: 'idle' }); + (async () => { + try { + const currentHistory = useStore.getState().aiChatHistory[sid] || []; + const messagesPayload = currentHistory.map(toAIRequestMessage); + const sysMessages = await buildSystemContextMessages( + existing.jvmPlanContext, + existing.jvmDiagnosticPlanContext, + ); + messagesPayload.push({ + role: 'user', + content: '请直接使用 function call 调用工具执行操作,不要只用文字描述计划。', + }); + const allMsg = [...sysMessages, ...messagesPayload]; + const service = (window as any).go?.aiservice?.Service; + if (service?.AIChatStream) { + await service.AIChatStream(sid, allMsg, availableTools); + } + } catch (error) { + console.error('Nudge failed', error); + setSending(false); + } + })(); + return; + } + + if (doneIsFirst) generateTitleForSession(sid); + + const hasContent = !!existing?.content?.trim(); + const hasThinking = !!existing?.thinking?.trim(); + const hasTools = !!existing?.tool_calls?.length; + + if (!hasContent && !hasThinking && !hasTools) { + updateAIChatMessage(sid, doneAssistantId, { + content: '❌ 模型未能成功响应任何内容,可能遭遇频控、上下文超载或理解拒绝。', + loading: false, + phase: 'idle', + }); + } else { + updateAIChatMessage(sid, doneAssistantId, { loading: false, phase: 'idle' }); + } + } else { + addAIChatMessage(sid, { + id: nextMessageId(), + role: 'assistant', + content: '❌ 请求中断:未收到任何具体回复。', + timestamp: Date.now(), + loading: false, + }); + } + setSending(false); + }, 50); + } + }; + + EventsOn(eventName, handler); + return () => { + EventsOff(eventName); + }; + }, [ + addAIChatMessage, + availableTools, + buildSystemContextMessages, + executeLocalTools, + generateTitleForSession, + nextMessageId, + nudgeCountRef, + pendingJVMDiagnosticPlanContextRef, + pendingJVMPlanContextRef, + setSending, + sid, + updateAIChatMessage, + ]); +};