From f7c20f6d79d2f7be7933eb11a15b125b36ce8b74 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 9 Jun 2026 02:21:52 +0800 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(ai-chat):=20?= =?UTF-8?q?=E6=8A=BD=E7=A6=BB=E8=81=8A=E5=A4=A9=E6=B6=88=E6=81=AF=E5=88=86?= =?UTF-8?q?=E5=8F=91=E5=8A=A9=E6=89=8B?= 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 | 133 +++++------------ .../ai/aiChatPayloadDispatch.test.ts | 134 ++++++++++++++++++ .../components/ai/aiChatPayloadDispatch.ts | 99 +++++++++++++ 4 files changed, 271 insertions(+), 101 deletions(-) create mode 100644 frontend/src/components/ai/aiChatPayloadDispatch.test.ts create mode 100644 frontend/src/components/ai/aiChatPayloadDispatch.ts diff --git a/frontend/src/components/AIChatPanel.message-boundary.test.tsx b/frontend/src/components/AIChatPanel.message-boundary.test.tsx index e23e468..d5520be 100644 --- a/frontend/src/components/AIChatPanel.message-boundary.test.tsx +++ b/frontend/src/components/AIChatPanel.message-boundary.test.tsx @@ -5,6 +5,7 @@ const source = readFileSync(new URL('./AIChatPanel.tsx', import.meta.url), 'utf8 const boundarySource = readFileSync(new URL('./ai/AIMessageRenderBoundary.tsx', import.meta.url), 'utf8'); const conversationViewSource = readFileSync(new URL('./ai/AIChatPanelConversationView.tsx', import.meta.url), 'utf8'); 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 systemContextSource = readFileSync(new URL('./ai/aiSystemContextMessages.ts', import.meta.url), 'utf8'); const runtimeSource = readFileSync(new URL('../utils/aiChatRuntime.ts', import.meta.url), 'utf8'); @@ -50,10 +51,13 @@ 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('compressContextIfNeeded, getDynamicMaxContextChars, sanitizeErrorMsg'); + expect(source).toContain("import { dispatchAIChatPayload } from './ai/aiChatPayloadDispatch';"); + expect(source).toContain('compressContextIfNeeded, getDynamicMaxContextChars'); 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(runtimeSource).toContain('⚙️ 对话已超载,正在启动记忆压缩'); }); diff --git a/frontend/src/components/AIChatPanel.tsx b/frontend/src/components/AIChatPanel.tsx index 4ae5494..569a539 100644 --- a/frontend/src/components/AIChatPanel.tsx +++ b/frontend/src/components/AIChatPanel.tsx @@ -40,6 +40,7 @@ import { inferAIChatConnectionContext, resolveAIChatPanelMode, } from './ai/aiChatPanelDerivedState'; +import { dispatchAIChatPayload } from './ai/aiChatPayloadDispatch'; import { buildAIChatReadinessSnapshot } from './ai/aiChatReadiness'; import { buildAISystemContextMessages } from './ai/aiSystemContextMessages'; import { useAIChatRuntimeResources } from './ai/useAIChatRuntimeResources'; @@ -598,40 +599,17 @@ export const AIChatPanel: React.FC = ({ retryJVMDiagnosticPlanContext, ); const allMessages = [...sysMessages, ...messagesPayload]; - - const Service = (window as any).go?.aiservice?.Service; - if (Service?.AIChatStream) { - await Service.AIChatStream(sid, allMessages, availableTools); - } else if (Service?.AIChatSend) { - const result = await Service.AIChatSend(allMessages, availableTools); - const errRaw = result?.error || '未知错误'; - const errClean = sanitizeErrorMsg(errRaw); - addAIChatMessage(sid, { - id: genId(), role: 'assistant', - content: result?.success ? result.content : `❌ ${errClean}`, - thinking: result?.success ? result.reasoning_content : undefined, - reasoning_content: result?.success ? result.reasoning_content : undefined, - rawError: (!result?.success && errClean !== errRaw) ? errRaw : undefined, - timestamp: Date.now(), - jvmPlanContext: retryJVMPlanContext, - jvmDiagnosticPlanContext: retryJVMDiagnosticPlanContext, - }); - setSending(false); - } else { - setSending(false); - } - } catch(e: any) { - const rawE = e?.message || String(e); - const cleanE = sanitizeErrorMsg(rawE); - addAIChatMessage(sid, { - id: genId(), - role: 'assistant', - content: `❌ 发送失败: ${cleanE}`, - rawError: cleanE !== rawE ? rawE : undefined, - timestamp: Date.now(), + await dispatchAIChatPayload({ + sid, + messages: allMessages, + tools: availableTools, + addAIChatMessage, + setSending, + nextMessageId: genId, jvmPlanContext: retryJVMPlanContext, jvmDiagnosticPlanContext: retryJVMDiagnosticPlanContext, }); + } catch { setSending(false); } } @@ -806,25 +784,16 @@ export const AIChatPanel: React.FC = ({ const SOFT_LIMIT_ROUNDS = 10; const chainTools = totalToolRoundRef.current >= SOFT_LIMIT_ROUNDS ? [] : availableTools; - const Service = (window as any).go?.aiservice?.Service; - if (Service?.AIChatStream) { - await Service.AIChatStream(sid, allMessages, chainTools); - } else if (Service?.AIChatSend) { - const result = await Service.AIChatSend(allMessages, chainTools); - const errR = result?.error || '未知错误'; - const errC = sanitizeErrorMsg(errR); - useStore.getState().addAIChatMessage(sid, { - id: genId(), role: 'assistant', - content: result?.success ? result.content : `❌ ${errC}`, - thinking: result?.success ? result.reasoning_content : undefined, - reasoning_content: result?.success ? result.reasoning_content : undefined, - rawError: (!result?.success && errC !== errR) ? errR : undefined, - timestamp: Date.now(), - jvmPlanContext: inheritedJVMPlanContext, - jvmDiagnosticPlanContext: inheritedJVMDiagnosticPlanContext, - }); - setSending(false); - } + await dispatchAIChatPayload({ + sid, + messages: allMessages, + tools: chainTools, + addAIChatMessage: (sessionId, message) => useStore.getState().addAIChatMessage(sessionId, message), + setSending, + nextMessageId: genId, + jvmPlanContext: inheritedJVMPlanContext, + jvmDiagnosticPlanContext: inheritedJVMDiagnosticPlanContext, + }); } catch (e) { console.error('Failed to chain tool call', e); setSending(false); @@ -923,56 +892,20 @@ export const AIChatPanel: React.FC = ({ // 【过渡状态 4】最后一步,等待第一字节返回 updateAIChatMessage(sid, connectingMsg.id, { content: '等待模型响应' }); - try { - const Service = (window as any).go?.aiservice?.Service; - if (Service?.AIChatStream) { - await Service.AIChatStream(sid, allMessages, availableTools); - } else if (Service?.AIChatSend) { - const result = await Service.AIChatSend(allMessages, availableTools); - const errR2 = result?.error || '未知错误'; - const errC2 = sanitizeErrorMsg(errR2); - const assistantMsg: AIChatMessage = { - id: genId(), role: 'assistant', - content: result?.success ? result.content : `❌ ${errC2}`, - thinking: result?.success ? result.reasoning_content : undefined, - reasoning_content: result?.success ? result.reasoning_content : undefined, - rawError: (!result?.success && errC2 !== errR2) ? errR2 : undefined, - timestamp: Date.now(), - jvmPlanContext: currentJVMPlanContext, - jvmDiagnosticPlanContext: currentJVMDiagnosticPlanContext, - }; - addAIChatMessage(sid, assistantMsg); - setSending(false); - - // auto-generate title fallback for non-stream - if (messages.length === 0) { - generateTitleForSession(sid); - } - } else { - addAIChatMessage(sid, { - id: genId(), - role: 'assistant', - content: '❌ AI Service 未就绪', - timestamp: Date.now(), - jvmPlanContext: currentJVMPlanContext, - jvmDiagnosticPlanContext: currentJVMDiagnosticPlanContext, - }); - setSending(false); - } - } catch (e: any) { - const rawE2 = e?.message || String(e); - const cleanE2 = sanitizeErrorMsg(rawE2); - addAIChatMessage(sid, { - id: genId(), - role: 'assistant', - content: `❌ 发送失败: ${cleanE2}`, - rawError: cleanE2 !== rawE2 ? rawE2 : undefined, - timestamp: Date.now(), - jvmPlanContext: currentJVMPlanContext, - jvmDiagnosticPlanContext: currentJVMDiagnosticPlanContext, - }); - setSending(false); - } + await dispatchAIChatPayload({ + sid, + messages: allMessages, + tools: availableTools, + addAIChatMessage, + setSending, + nextMessageId: genId, + jvmPlanContext: currentJVMPlanContext, + jvmDiagnosticPlanContext: currentJVMDiagnosticPlanContext, + unavailableContent: '❌ AI Service 未就绪', + onNonStreamSuccess: messages.length === 0 + ? () => generateTitleForSession(sid) + : undefined, + }); }, [ input, draftImages, diff --git a/frontend/src/components/ai/aiChatPayloadDispatch.test.ts b/frontend/src/components/ai/aiChatPayloadDispatch.test.ts new file mode 100644 index 0000000..db4d9b6 --- /dev/null +++ b/frontend/src/components/ai/aiChatPayloadDispatch.test.ts @@ -0,0 +1,134 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { dispatchAIChatPayload } from './aiChatPayloadDispatch'; + +describe('aiChatPayloadDispatch', () => { + const originalWindow = (globalThis as any).window; + + afterEach(() => { + (globalThis as any).window = originalWindow; + vi.restoreAllMocks(); + }); + + it('prefers streaming when AIChatStream is available', async () => { + const AIChatStream = vi.fn().mockResolvedValue(undefined); + const addAIChatMessage = vi.fn(); + const setSending = vi.fn(); + + (globalThis as any).window = { + go: { + aiservice: { + Service: { AIChatStream }, + }, + }, + }; + + const result = await dispatchAIChatPayload({ + sid: 'session-1', + messages: [{ role: 'user', content: 'hello' }], + tools: [], + addAIChatMessage, + setSending, + nextMessageId: () => 'msg-stream', + }); + + expect(result).toBe('stream'); + expect(AIChatStream).toHaveBeenCalledWith('session-1', [{ role: 'user', content: 'hello' }], []); + expect(addAIChatMessage).not.toHaveBeenCalled(); + expect(setSending).not.toHaveBeenCalled(); + }); + + it('appends a non-stream assistant message when only AIChatSend is available', async () => { + const AIChatSend = vi.fn().mockResolvedValue({ + success: true, + content: 'done', + reasoning_content: 'thinking', + }); + const addAIChatMessage = vi.fn(); + const setSending = vi.fn(); + const onNonStreamSuccess = vi.fn(); + + (globalThis as any).window = { + go: { + aiservice: { + Service: { AIChatSend }, + }, + }, + }; + + const result = await dispatchAIChatPayload({ + sid: 'session-1', + messages: [{ role: 'user', content: 'hello' }], + tools: [], + addAIChatMessage, + setSending, + nextMessageId: () => 'msg-send', + onNonStreamSuccess, + }); + + expect(result).toBe('send'); + expect(addAIChatMessage).toHaveBeenCalledWith('session-1', expect.objectContaining({ + id: 'msg-send', + role: 'assistant', + content: 'done', + thinking: 'thinking', + reasoning_content: 'thinking', + })); + expect(setSending).toHaveBeenCalledWith(false); + expect(onNonStreamSuccess).toHaveBeenCalled(); + }); + + it('emits the unavailable message when the AI service is missing', async () => { + const addAIChatMessage = vi.fn(); + const setSending = vi.fn(); + (globalThis as any).window = {}; + + const result = await dispatchAIChatPayload({ + sid: 'session-1', + messages: [{ role: 'user', content: 'hello' }], + tools: [], + addAIChatMessage, + setSending, + nextMessageId: () => 'msg-unavailable', + unavailableContent: '❌ AI Service 未就绪', + }); + + expect(result).toBe('unavailable'); + expect(addAIChatMessage).toHaveBeenCalledWith('session-1', expect.objectContaining({ + id: 'msg-unavailable', + content: '❌ AI Service 未就绪', + })); + expect(setSending).toHaveBeenCalledWith(false); + }); + + it('sanitizes thrown errors and preserves the raw error when the cleaned text changes', async () => { + const AIChatSend = vi.fn().mockRejectedValue(new Error('502 Bad Gateway')); + const addAIChatMessage = vi.fn(); + const setSending = vi.fn(); + + (globalThis as any).window = { + go: { + aiservice: { + Service: { AIChatSend }, + }, + }, + }; + + const result = await dispatchAIChatPayload({ + sid: 'session-1', + messages: [{ role: 'user', content: 'hello' }], + tools: [], + addAIChatMessage, + setSending, + nextMessageId: () => 'msg-error', + }); + + expect(result).toBe('error'); + expect(addAIChatMessage).toHaveBeenCalledWith('session-1', expect.objectContaining({ + id: 'msg-error', + content: '❌ 发送失败: HTTP 502: 502 Bad Gateway', + rawError: '502 Bad Gateway', + })); + expect(setSending).toHaveBeenCalledWith(false); + }); +}); diff --git a/frontend/src/components/ai/aiChatPayloadDispatch.ts b/frontend/src/components/ai/aiChatPayloadDispatch.ts new file mode 100644 index 0000000..d429977 --- /dev/null +++ b/frontend/src/components/ai/aiChatPayloadDispatch.ts @@ -0,0 +1,99 @@ +import type { + AIChatMessage, + JVMAIPlanContext, + JVMDiagnosticPlanContext, +} from '../../types'; +import type { AIChatToolDefinition } from '../../utils/aiToolRegistry'; +import { sanitizeErrorMsg } from '../../utils/aiChatRuntime'; + +interface AIChatService { + AIChatStream?: (sid: string, messages: any[], tools: AIChatToolDefinition[]) => Promise; + AIChatSend?: (messages: any[], tools: AIChatToolDefinition[]) => Promise; +} + +interface DispatchAIChatPayloadOptions { + sid: string; + messages: any[]; + tools: AIChatToolDefinition[]; + addAIChatMessage: (sid: string, message: AIChatMessage) => void; + setSending: (sending: boolean) => void; + nextMessageId: () => string; + jvmPlanContext?: JVMAIPlanContext; + jvmDiagnosticPlanContext?: JVMDiagnosticPlanContext; + unavailableContent?: string; + onNonStreamSuccess?: () => void; +} + +const getAIChatService = (): AIChatService | undefined => + (window as any)?.go?.aiservice?.Service; + +export const dispatchAIChatPayload = async ({ + sid, + messages, + tools, + addAIChatMessage, + setSending, + nextMessageId, + jvmPlanContext, + jvmDiagnosticPlanContext, + unavailableContent, + onNonStreamSuccess, +}: DispatchAIChatPayloadOptions): Promise<'stream' | 'send' | 'unavailable' | 'error'> => { + try { + const service = getAIChatService(); + if (service?.AIChatStream) { + await service.AIChatStream(sid, messages, tools); + return 'stream'; + } + + if (service?.AIChatSend) { + const result = await service.AIChatSend(messages, tools); + const rawError = result?.error || '未知错误'; + const cleanError = sanitizeErrorMsg(rawError); + + addAIChatMessage(sid, { + id: nextMessageId(), + role: 'assistant', + content: result?.success ? result.content : `❌ ${cleanError}`, + thinking: result?.success ? result.reasoning_content : undefined, + reasoning_content: result?.success ? result.reasoning_content : undefined, + rawError: !result?.success && cleanError !== rawError ? rawError : undefined, + timestamp: Date.now(), + jvmPlanContext, + jvmDiagnosticPlanContext, + }); + setSending(false); + if (result?.success) { + onNonStreamSuccess?.(); + } + return 'send'; + } + + if (unavailableContent) { + addAIChatMessage(sid, { + id: nextMessageId(), + role: 'assistant', + content: unavailableContent, + timestamp: Date.now(), + jvmPlanContext, + jvmDiagnosticPlanContext, + }); + } + setSending(false); + return 'unavailable'; + } catch (error: any) { + const rawError = error?.message || String(error); + const cleanError = sanitizeErrorMsg(rawError); + addAIChatMessage(sid, { + id: nextMessageId(), + role: 'assistant', + content: `❌ 发送失败: ${cleanError}`, + rawError: cleanError !== rawError ? rawError : undefined, + timestamp: Date.now(), + jvmPlanContext, + jvmDiagnosticPlanContext, + }); + setSending(false); + return 'error'; + } +};