From 9fab48e64fa06ef2e3a606057103c6d3288e182c Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 9 Jun 2026 17:24:26 +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=E4=BC=9A=E8=AF=9D=E6=A0=87=E9=A2=98=E7=94=9F?= =?UTF-8?q?=E6=88=90=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将自动标题生成从 AIChatPanel 抽到独立 hook - 补充标题生成、清洗和稳定回调测试 --- .../AIChatPanel.message-boundary.test.tsx | 6 +- frontend/src/components/AIChatPanel.tsx | 27 +--- .../useAIChatSessionTitleGenerator.test.tsx | 122 ++++++++++++++++++ .../ai/useAIChatSessionTitleGenerator.ts | 41 ++++++ 4 files changed, 171 insertions(+), 25 deletions(-) create mode 100644 frontend/src/components/ai/useAIChatSessionTitleGenerator.test.tsx create mode 100644 frontend/src/components/ai/useAIChatSessionTitleGenerator.ts diff --git a/frontend/src/components/AIChatPanel.message-boundary.test.tsx b/frontend/src/components/AIChatPanel.message-boundary.test.tsx index 008baa7..9e42bd3 100644 --- a/frontend/src/components/AIChatPanel.message-boundary.test.tsx +++ b/frontend/src/components/AIChatPanel.message-boundary.test.tsx @@ -11,6 +11,7 @@ const planContextSource = readFileSync(new URL('./ai/useAIChatPlanContexts.ts', const resizeSource = readFileSync(new URL('./ai/useAIChatPanelResize.ts', import.meta.url), 'utf8'); const runtimeResourcesSource = readFileSync(new URL('./ai/useAIChatRuntimeResources.ts', import.meta.url), 'utf8'); const sessionStateSource = readFileSync(new URL('./ai/useAIChatSessionState.ts', import.meta.url), 'utf8'); +const titleGeneratorSource = readFileSync(new URL('./ai/useAIChatSessionTitleGenerator.ts', import.meta.url), 'utf8'); const streamSubscriptionSource = readFileSync(new URL('./ai/useAIChatStreamSubscription.ts', import.meta.url), 'utf8'); const inspectionGuidanceSource = readFileSync(new URL('./ai/aiSystemInspectionGuidance.ts', import.meta.url), 'utf8'); const systemContextSource = readFileSync(new URL('./ai/aiSystemContextMessages.ts', import.meta.url), 'utf8'); @@ -86,14 +87,17 @@ describe('AIChatPanel message render isolation', () => { expect(source).toContain('sessions={panelHistorySessions}'); }); - it('extracts plan-context, auto-context, and resize hooks so the panel file stays focused on orchestration', () => { + it('extracts plan-context, auto-context, title, and resize hooks so the panel file stays focused on orchestration', () => { expect(source).toContain("import { useAIChatPlanContexts } from './ai/useAIChatPlanContexts';"); expect(source).toContain("import { useAIChatAutoContext } from './ai/useAIChatAutoContext';"); + expect(source).toContain("import { useAIChatSessionTitleGenerator } from './ai/useAIChatSessionTitleGenerator';"); expect(source).toContain("import { useAIChatPanelResize } from './ai/useAIChatPanelResize';"); expect(planContextSource).toContain('export const useAIChatPlanContexts'); expect(planContextSource).toContain('pendingJVMPlanContextRef'); expect(autoContextSource).toContain('export const useAIChatAutoContext'); expect(autoContextSource).toContain('DBShowCreateTable'); + expect(titleGeneratorSource).toContain('export const useAIChatSessionTitleGenerator'); + expect(titleGeneratorSource).toContain('Failed to auto-generate title'); expect(resizeSource).toContain('export const useAIChatPanelResize'); expect(resizeSource).toContain('document.body.style.pointerEvents = \'none\''); }); diff --git a/frontend/src/components/AIChatPanel.tsx b/frontend/src/components/AIChatPanel.tsx index 2fd84fd..834e6d5 100644 --- a/frontend/src/components/AIChatPanel.tsx +++ b/frontend/src/components/AIChatPanel.tsx @@ -48,6 +48,7 @@ import { useAIChatAutoContext } from './ai/useAIChatAutoContext'; import { useAIChatPanelResize } from './ai/useAIChatPanelResize'; import { useAIChatPlanContexts } from './ai/useAIChatPlanContexts'; import { useAIChatSessionState } from './ai/useAIChatSessionState'; +import { useAIChatSessionTitleGenerator } from './ai/useAIChatSessionTitleGenerator'; interface AIChatPanelProps { width?: number; @@ -199,30 +200,7 @@ export const AIChatPanel: React.FC = ({ return () => window.removeEventListener('gonavi:ai:inject-prompt', handler); }, []); - const generateTitleForSession = async (currentSid: string) => { - try { - const Service = (window as any).go?.aiservice?.Service; - const historyLocal = useStore.getState().aiChatHistory[currentSid] || []; - if (!Service?.AIChatSend || historyLocal.length < 2) return; - - const firstUserMsg = historyLocal.find(m => m.role === 'user'); - if (firstUserMsg) { - // 取用前 50 个字符截断,防止太长的查询消耗过多 Token - const snippet = firstUserMsg.content.slice(0, 50); - const titleReq = [ - { role: 'system', content: 'You are a summarizer. Provide a short 3-6 word title for this prompt. Do not use quotes, punctuation, or explain. Just the title in the same language as the prompt.' }, - { role: 'user', content: snippet } - ]; - const res = await Service.AIChatSend(titleReq); - if (res?.success && res.content) { - const cleanTitle = res.content.trim().replace(/^["']|["']$/g, ''); - updateAISessionTitle(currentSid, cleanTitle); - } - } - } catch (e) { - console.warn('Failed to auto-generate title', e); - } - }; + const generateTitleForSession = useAIChatSessionTitleGenerator({ updateAISessionTitle }); const handleScrollMessages = useCallback((e: React.UIEvent) => { const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; @@ -625,6 +603,7 @@ export const AIChatPanel: React.FC = ({ availableTools, buildSystemContextMessages, dynamicModels, + generateTitleForSession, getCurrentJVMPlanContext, getCurrentJVMDiagnosticPlanContext, loadingModels, diff --git a/frontend/src/components/ai/useAIChatSessionTitleGenerator.test.tsx b/frontend/src/components/ai/useAIChatSessionTitleGenerator.test.tsx new file mode 100644 index 0000000..eda3db0 --- /dev/null +++ b/frontend/src/components/ai/useAIChatSessionTitleGenerator.test.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, create, type ReactTestRenderer } from 'react-test-renderer'; + +import { useStore } from '../../store'; +import { useAIChatSessionTitleGenerator } from './useAIChatSessionTitleGenerator'; + +const SESSION_ID = 'session-title'; + +let currentGenerator: ((sessionId: string) => Promise) | undefined; + +const TitleGeneratorHarness = ({ + updateAISessionTitle, +}: { + updateAISessionTitle: (sessionId: string, title: string) => void; +}) => { + currentGenerator = useAIChatSessionTitleGenerator({ updateAISessionTitle }); + return null; +}; + +describe('useAIChatSessionTitleGenerator', () => { + beforeEach(() => { + currentGenerator = undefined; + useStore.setState({ + aiChatHistory: { + [SESSION_ID]: [ + { + id: 'user-1', + role: 'user', + content: '帮我分析当前连接失败原因,并给出下一步排查建议,内容很长需要被截断', + timestamp: 1, + }, + { + id: 'assistant-1', + role: 'assistant', + content: '可以', + timestamp: 2, + }, + ], + }, + aiChatSessions: [{ id: SESSION_ID, title: '新的对话', updatedAt: 1 }], + aiActiveSessionId: SESSION_ID, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + useStore.setState({ + aiChatHistory: {}, + aiChatSessions: [], + aiActiveSessionId: null, + }); + }); + + it('generates a cleaned session title from the first user message', async () => { + const AIChatSend = vi.fn().mockResolvedValue({ success: true, content: '"连接失败排查"' }); + vi.stubGlobal('window', { + go: { + aiservice: { + Service: { AIChatSend }, + }, + }, + }); + const updateAISessionTitle = vi.fn(); + + await act(async () => { + create(); + }); + await act(async () => { + await currentGenerator?.(SESSION_ID); + }); + + expect(AIChatSend).toHaveBeenCalledTimes(1); + expect(AIChatSend.mock.calls[0][0]).toEqual([ + { + role: 'system', + content: + 'You are a summarizer. Provide a short 3-6 word title for this prompt. Do not use quotes, punctuation, or explain. Just the title in the same language as the prompt.', + }, + { + role: 'user', + content: '帮我分析当前连接失败原因,并给出下一步排查建议,内容很长需要被截断'.slice(0, 50), + }, + ]); + expect(updateAISessionTitle).toHaveBeenCalledWith(SESSION_ID, '连接失败排查'); + }); + + it('keeps the generator stable when the title updater is stable', async () => { + vi.stubGlobal('window', {}); + const updateAISessionTitle = vi.fn(); + let renderer: ReactTestRenderer | undefined; + + await act(async () => { + renderer = create(); + }); + const firstGenerator = currentGenerator; + + await act(async () => { + renderer?.update(); + }); + + expect(currentGenerator).toBe(firstGenerator); + await act(async () => { + renderer?.unmount(); + }); + }); + + it('skips generation when the Wails AI service is unavailable', async () => { + vi.stubGlobal('window', {}); + const updateAISessionTitle = vi.fn(); + + await act(async () => { + create(); + }); + await act(async () => { + await currentGenerator?.(SESSION_ID); + }); + + expect(updateAISessionTitle).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/ai/useAIChatSessionTitleGenerator.ts b/frontend/src/components/ai/useAIChatSessionTitleGenerator.ts new file mode 100644 index 0000000..4915d6f --- /dev/null +++ b/frontend/src/components/ai/useAIChatSessionTitleGenerator.ts @@ -0,0 +1,41 @@ +import { useCallback } from 'react'; + +import { useStore } from '../../store'; + +interface UseAIChatSessionTitleGeneratorOptions { + updateAISessionTitle: (sessionId: string, title: string) => void; +} + +const TITLE_PROMPT = + 'You are a summarizer. Provide a short 3-6 word title for this prompt. Do not use quotes, punctuation, or explain. Just the title in the same language as the prompt.'; + +export const useAIChatSessionTitleGenerator = ({ + updateAISessionTitle, +}: UseAIChatSessionTitleGeneratorOptions) => { + return useCallback( + async (currentSid: string) => { + try { + const Service = (window as any).go?.aiservice?.Service; + const historyLocal = useStore.getState().aiChatHistory[currentSid] || []; + if (!Service?.AIChatSend || historyLocal.length < 2) return; + + const firstUserMsg = historyLocal.find((message) => message.role === 'user'); + if (!firstUserMsg) return; + + const snippet = firstUserMsg.content.slice(0, 50); + const titleReq = [ + { role: 'system', content: TITLE_PROMPT }, + { role: 'user', content: snippet }, + ]; + const response = await Service.AIChatSend(titleReq); + if (response?.success && response.content) { + const cleanTitle = response.content.trim().replace(/^["']|["']$/g, ''); + updateAISessionTitle(currentSid, cleanTitle); + } + } catch (error) { + console.warn('Failed to auto-generate title', error); + } + }, + [updateAISessionTitle], + ); +};