♻️ refactor(ai-chat): 拆分会话标题生成逻辑

- 将自动标题生成从 AIChatPanel 抽到独立 hook

- 补充标题生成、清洗和稳定回调测试
This commit is contained in:
Syngnat
2026-06-09 17:24:26 +08:00
parent acfa112415
commit 9fab48e64f
4 changed files with 171 additions and 25 deletions

View File

@@ -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\'');
});

View File

@@ -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<AIChatPanelProps> = ({
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<HTMLDivElement>) => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
@@ -625,6 +603,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
availableTools,
buildSystemContextMessages,
dynamicModels,
generateTitleForSession,
getCurrentJVMPlanContext,
getCurrentJVMDiagnosticPlanContext,
loadingModels,

View File

@@ -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<void>) | 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(<TitleGeneratorHarness updateAISessionTitle={updateAISessionTitle} />);
});
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(<TitleGeneratorHarness updateAISessionTitle={updateAISessionTitle} />);
});
const firstGenerator = currentGenerator;
await act(async () => {
renderer?.update(<TitleGeneratorHarness updateAISessionTitle={updateAISessionTitle} />);
});
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(<TitleGeneratorHarness updateAISessionTitle={updateAISessionTitle} />);
});
await act(async () => {
await currentGenerator?.(SESSION_ID);
});
expect(updateAISessionTitle).not.toHaveBeenCalled();
});
});

View File

@@ -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],
);
};