mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-14 18:39:54 +08:00
♻️ refactor(ai-chat): 拆分会话标题生成逻辑
- 将自动标题生成从 AIChatPanel 抽到独立 hook - 补充标题生成、清洗和稳定回调测试
This commit is contained in:
@@ -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\'');
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
41
frontend/src/components/ai/useAIChatSessionTitleGenerator.ts
Normal file
41
frontend/src/components/ai/useAIChatSessionTitleGenerator.ts
Normal 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],
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user