mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-14 18:39:54 +08:00
♻️ refactor(ai-chat): 抽离聊天消息分发助手
This commit is contained in:
@@ -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('⚙️ 对话已超载,正在启动记忆压缩');
|
||||
});
|
||||
|
||||
|
||||
@@ -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<AIChatPanelProps> = ({
|
||||
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<AIChatPanelProps> = ({
|
||||
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<AIChatPanelProps> = ({
|
||||
// 【过渡状态 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,
|
||||
|
||||
134
frontend/src/components/ai/aiChatPayloadDispatch.test.ts
Normal file
134
frontend/src/components/ai/aiChatPayloadDispatch.test.ts
Normal file
@@ -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('<html><title>502 Bad Gateway</title></html>'));
|
||||
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: '<html><title>502 Bad Gateway</title></html>',
|
||||
}));
|
||||
expect(setSending).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
99
frontend/src/components/ai/aiChatPayloadDispatch.ts
Normal file
99
frontend/src/components/ai/aiChatPayloadDispatch.ts
Normal file
@@ -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<any>;
|
||||
AIChatSend?: (messages: any[], tools: AIChatToolDefinition[]) => Promise<any>;
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user