♻️ refactor(ai-chat): 抽离聊天消息分发助手

This commit is contained in:
Syngnat
2026-06-09 02:21:52 +08:00
parent 747cabe447
commit f7c20f6d79
4 changed files with 271 additions and 101 deletions

View File

@@ -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('⚙️ 对话已超载,正在启动记忆压缩');
});

View File

@@ -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,

View 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);
});
});

View 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';
}
};