♻️ refactor(ai-chat): 拆分流式消息订阅 Hook

This commit is contained in:
Syngnat
2026-06-09 03:05:00 +08:00
parent 9dde59a6c7
commit f7648413ed
3 changed files with 344 additions and 227 deletions

View File

@@ -7,6 +7,7 @@ const conversationViewSource = readFileSync(new URL('./ai/AIChatPanelConversatio
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 streamSubscriptionSource = readFileSync(new URL('./ai/useAIChatStreamSubscription.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');
@@ -52,12 +53,17 @@ 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("import { dispatchAIChatPayload } from './ai/aiChatPayloadDispatch';");
expect(source).toContain("import { useAIChatStreamSubscription } from './ai/useAIChatStreamSubscription';");
expect(source).toContain('compressContextIfNeeded, getDynamicMaxContextChars');
expect(source).toContain('useAIChatStreamSubscription({');
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(streamSubscriptionSource).toContain('EventsOn(eventName, handler);');
expect(streamSubscriptionSource).toContain('请直接使用 function call 调用工具执行操作');
expect(streamSubscriptionSource).toContain('executeLocalTools(existing.tool_calls!, doneAssistantId)');
expect(runtimeSource).toContain('⚙️ 对话已超载,正在启动记忆压缩');
});

View File

@@ -1,7 +1,6 @@
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { useStore, loadAISessionsFromBackend, loadAISessionFromBackend } from '../store';
import { EventsOn, EventsOff } from '../../wailsjs/runtime';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import type {
AIChatMessage,
@@ -15,6 +14,7 @@ import { AIChatHeader } from './ai/AIChatHeader';
import { AIChatInput } from './ai/AIChatInput';
import { AIHistoryDrawer } from './ai/AIHistoryDrawer';
import AIChatPanelConversationView from './ai/AIChatPanelConversationView';
import { useAIChatStreamSubscription } from './ai/useAIChatStreamSubscription';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import {
buildIncompleteProviderNotice,
@@ -23,7 +23,7 @@ import {
} from '../utils/aiComposerNotice';
import { consumeAIChatSendShortcutOnKeyDown } from '../utils/aiChatSendShortcut';
import { toAIRequestMessage } from '../utils/aiMessagePayload';
import { compressContextIfNeeded, getDynamicMaxContextChars, sanitizeErrorMsg } from '../utils/aiChatRuntime';
import { compressContextIfNeeded, getDynamicMaxContextChars } from '../utils/aiChatRuntime';
import { getShortcutPlatform, resolveShortcutBinding } from '../utils/shortcuts';
import { isMacLikePlatform } from '../utils/appearance';
import { buildAvailableAIChatTools } from '../utils/aiToolRegistry';
@@ -285,231 +285,6 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
return () => window.removeEventListener('gonavi:ai:inject-prompt', handler);
}, []);
useEffect(() => {
const eventName = `ai:stream:${sid}`;
let assistantMsgId = '';
let isFirstCompletion = false;
// 新增:利用 requestAnimationFrame 缓冲高频事件,避免 React 重绘阻塞导致感官吞吐变慢
const streamBuffer = { thinking: '', reasoningContent: '', content: '' };
let flushPending = false;
const flushStreamBuffer = () => {
if (!assistantMsgId) return;
const current = useStore.getState().aiChatHistory[sid];
const existing = current?.find(m => m.id === assistantMsgId);
if (!existing) return;
const updates: any = {};
if (streamBuffer.thinking) {
updates.thinking = (existing.thinking || '') + streamBuffer.thinking;
updates.phase = 'thinking';
streamBuffer.thinking = '';
}
if (streamBuffer.reasoningContent) {
updates.reasoning_content = (existing.reasoning_content || '') + streamBuffer.reasoningContent;
streamBuffer.reasoningContent = '';
}
if (streamBuffer.content) {
updates.content = (existing.content || '') + streamBuffer.content;
updates.phase = 'generating';
streamBuffer.content = '';
}
if (Object.keys(updates).length > 0) {
updateAIChatMessage(sid, assistantMsgId, updates);
}
flushPending = false;
};
const handler = (data: { content?: string; thinking?: string; reasoning_content?: string; tool_calls?: AIToolCall[]; done?: boolean; error?: string }) => {
// Find connecting message if there's no active assistant string
if (!assistantMsgId) {
const history = useStore.getState().aiChatHistory[sid] || [];
const lastMsg = history[history.length - 1];
if (lastMsg && lastMsg.role === 'assistant' && lastMsg.loading && lastMsg.phase === 'connecting') {
assistantMsgId = lastMsg.id;
// 【关键】接管 connecting 消息时,立即清空其过渡文案,防止泄漏到 AI 回复正文
updateAIChatMessage(sid, assistantMsgId, { content: '' });
}
}
if (data.error) {
const cleanErr = sanitizeErrorMsg(data.error);
const rawErr = cleanErr !== data.error ? data.error : undefined;
if (assistantMsgId) {
updateAIChatMessage(sid, assistantMsgId, { content: `❌ 错误: ${cleanErr}`, phase: 'idle', loading: false, rawError: rawErr });
} else {
addAIChatMessage(sid, {
id: genId(),
role: 'assistant',
phase: 'idle',
content: `❌ 错误: ${cleanErr}`,
rawError: rawErr,
timestamp: Date.now(),
jvmPlanContext: pendingJVMPlanContextRef.current,
jvmDiagnosticPlanContext: pendingJVMDiagnosticPlanContextRef.current,
});
}
assistantMsgId = '';
setSending(false);
return;
}
if (data.tool_calls && data.tool_calls.length > 0) {
if (assistantMsgId) {
updateAIChatMessage(sid, assistantMsgId, { tool_calls: data.tool_calls, phase: 'tool_calling' });
} else {
assistantMsgId = genId();
addAIChatMessage(sid, {
id: assistantMsgId,
role: 'assistant',
phase: 'tool_calling',
content: '',
tool_calls: data.tool_calls,
timestamp: Date.now(),
loading: true,
jvmPlanContext: pendingJVMPlanContextRef.current,
jvmDiagnosticPlanContext: pendingJVMDiagnosticPlanContextRef.current,
});
}
}
// 处理 thinking模型思考过程
const displayThinking = data.thinking || data.reasoning_content || '';
if (displayThinking || data.reasoning_content) {
if (!assistantMsgId) {
assistantMsgId = genId();
addAIChatMessage(sid, {
id: assistantMsgId,
role: 'assistant',
phase: 'thinking',
content: '',
thinking: displayThinking || undefined,
reasoning_content: data.reasoning_content || undefined,
timestamp: Date.now(),
loading: true,
jvmPlanContext: pendingJVMPlanContextRef.current,
jvmDiagnosticPlanContext: pendingJVMDiagnosticPlanContextRef.current,
});
if (sending) setSending(false);
} else {
streamBuffer.thinking += displayThinking;
if (data.reasoning_content) {
streamBuffer.reasoningContent += data.reasoning_content;
}
if (sending) setSending(false);
}
}
if (data.content) {
if (!assistantMsgId) {
assistantMsgId = genId();
addAIChatMessage(sid, {
id: assistantMsgId,
role: 'assistant',
phase: 'generating',
content: data.content,
timestamp: Date.now(),
loading: true,
jvmPlanContext: pendingJVMPlanContextRef.current,
jvmDiagnosticPlanContext: pendingJVMDiagnosticPlanContextRef.current,
});
setSending(false);
const currentHistory = useStore.getState().aiChatHistory[sid] || [];
if (currentHistory.length <= 1) isFirstCompletion = true;
} else {
streamBuffer.content += data.content;
if (sending) setSending(false);
}
}
if (streamBuffer.thinking || streamBuffer.reasoningContent || streamBuffer.content) {
if (!flushPending) {
flushPending = true;
requestAnimationFrame(flushStreamBuffer);
}
}
if (data.done) {
// 如果有残留未 flush 的 buffer立刻推入状态树
if (streamBuffer.thinking || streamBuffer.reasoningContent || streamBuffer.content) {
flushStreamBuffer();
}
const doneAssistantId = assistantMsgId;
const doneIsFirst = isFirstCompletion;
assistantMsgId = '';
setTimeout(() => {
// 🔧 清除所有残留的 connecting 过渡气泡的 loading 状态
const currentMsgs = useStore.getState().aiChatHistory[sid] || [];
for (const msg of currentMsgs) {
if (msg.id !== doneAssistantId && msg.loading && msg.phase === 'connecting') {
updateAIChatMessage(sid, msg.id, { loading: false, phase: 'idle' });
}
}
if (doneAssistantId) {
const current = useStore.getState().aiChatHistory[sid];
const existing = current?.find(m => m.id === doneAssistantId);
if (existing && existing.tool_calls && existing.tool_calls.length > 0) {
// 【关键】保持 loading:true 和 phase:'tool_calling',让 UI 能实时展示工具执行进度
nudgeCountRef.current = 0;
setTimeout(() => executeLocalTools(existing.tool_calls!, doneAssistantId), 50);
return;
}
// 自动催促:模型描述了要调用工具但没有 function call
if (existing && nudgeCountRef.current < 2 &&
/(?:让我|我先|我来|现在|接下来|下面).*(?:查询|查找|获取|查看|检查|调用)|(?:获取|查询|查找|查看).*(?:信息|字段|列表|数据)[:]?\s*$/.test(existing.content || '')) {
nudgeCountRef.current += 1;
// 🔧 关闭当前消息的 loading 状态,消除闪烁光标
updateAIChatMessage(sid, doneAssistantId, { loading: false, phase: 'idle' });
// 注入 system 催促并重发
(async () => {
try {
const currentHistory = useStore.getState().aiChatHistory[sid] || [];
const messagesPayload = currentHistory.map(toAIRequestMessage);
const sysMessages = await buildSystemContextMessages(
existing.jvmPlanContext,
existing.jvmDiagnosticPlanContext,
);
// 追加催促消息
messagesPayload.push({ role: 'user', content: '请直接使用 function call 调用工具执行操作,不要只用文字描述计划。' });
const allMsg = [...sysMessages, ...messagesPayload];
const Service = (window as any).go?.aiservice?.Service;
if (Service?.AIChatStream) await Service.AIChatStream(sid, allMsg, availableTools);
} catch (e) {
console.error('Nudge failed', e);
setSending(false);
}
})();
return;
}
if (doneIsFirst) generateTitleForSession(sid);
// 正常完成:关闭 loading消除闪烁光标
const hasContent = !!existing?.content?.trim();
const hasThinking = !!existing?.thinking?.trim();
const hasTools = !!(existing?.tool_calls?.length);
if (!hasContent && !hasThinking && !hasTools) {
updateAIChatMessage(sid, doneAssistantId, { content: '❌ 模型未能成功响应任何内容,可能遭遇频控、上下文超载或理解拒绝。', loading: false, phase: 'idle' });
} else {
updateAIChatMessage(sid, doneAssistantId, { loading: false, phase: 'idle' });
}
} else {
addAIChatMessage(sid, { id: genId(), role: 'assistant', content: '❌ 请求中断:未收到任何具体回复。', timestamp: Date.now(), loading: false });
}
setSending(false);
}, 50);
}
};
EventsOn(eventName, handler);
return () => { EventsOff(eventName); };
}, [addAIChatMessage, updateAIChatMessage, sid]);
const generateTitleForSession = async (currentSid: string) => {
try {
const Service = (window as any).go?.aiservice?.Service;
@@ -800,6 +575,22 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
}
}, [availableTools, buildSystemContextMessages, dynamicModels, mcpTools, sid, skills]);
useAIChatStreamSubscription({
sid,
sending,
setSending,
availableTools,
addAIChatMessage,
updateAIChatMessage,
buildSystemContextMessages,
executeLocalTools,
generateTitleForSession,
nextMessageId: genId,
nudgeCountRef,
pendingJVMPlanContextRef,
pendingJVMDiagnosticPlanContextRef,
});
const handleSend = useCallback(async () => {
const text = input.trim();
if ((!text && draftImages.length === 0) || sending) return;

View File

@@ -0,0 +1,320 @@
import { useEffect, useRef } from 'react';
import type { MutableRefObject } from 'react';
import { EventsOn, EventsOff } from '../../../wailsjs/runtime';
import { useStore } from '../../store';
import type {
AIChatMessage,
AIToolCall,
JVMAIPlanContext,
JVMDiagnosticPlanContext,
} from '../../types';
import { sanitizeErrorMsg } from '../../utils/aiChatRuntime';
import { toAIRequestMessage } from '../../utils/aiMessagePayload';
import type { AIChatToolDefinition } from '../../utils/aiToolRegistry';
interface AIChatStreamChunk {
content?: string;
thinking?: string;
reasoning_content?: string;
tool_calls?: AIToolCall[];
done?: boolean;
error?: string;
}
interface UseAIChatStreamSubscriptionOptions {
sid: string;
sending: boolean;
setSending: (sending: boolean) => void;
availableTools: AIChatToolDefinition[];
addAIChatMessage: (sid: string, message: AIChatMessage) => void;
updateAIChatMessage: (
sid: string,
messageId: string,
patch: Partial<AIChatMessage>,
) => void;
buildSystemContextMessages: (
overrideJVMPlanContext?: JVMAIPlanContext,
overrideJVMDiagnosticPlanContext?: JVMDiagnosticPlanContext,
) => any[] | Promise<any[]>;
executeLocalTools: (toolCalls: AIToolCall[], currentAsstMsgId: string) => void | Promise<void>;
generateTitleForSession: (sid: string) => void | Promise<void>;
nextMessageId: () => string;
nudgeCountRef: MutableRefObject<number>;
pendingJVMPlanContextRef: MutableRefObject<JVMAIPlanContext | undefined>;
pendingJVMDiagnosticPlanContextRef: MutableRefObject<JVMDiagnosticPlanContext | undefined>;
}
export const useAIChatStreamSubscription = ({
sid,
sending,
setSending,
availableTools,
addAIChatMessage,
updateAIChatMessage,
buildSystemContextMessages,
executeLocalTools,
generateTitleForSession,
nextMessageId,
nudgeCountRef,
pendingJVMPlanContextRef,
pendingJVMDiagnosticPlanContextRef,
}: UseAIChatStreamSubscriptionOptions) => {
const sendingRef = useRef(sending);
useEffect(() => {
sendingRef.current = sending;
}, [sending]);
useEffect(() => {
const eventName = `ai:stream:${sid}`;
let assistantMsgId = '';
let isFirstCompletion = false;
// 缓冲高频 token避免把流式吞吐直接转成同步重绘风暴
const streamBuffer = { thinking: '', reasoningContent: '', content: '' };
let flushPending = false;
const flushStreamBuffer = () => {
if (!assistantMsgId) return;
const current = useStore.getState().aiChatHistory[sid];
const existing = current?.find((message) => message.id === assistantMsgId);
if (!existing) return;
const updates: Partial<AIChatMessage> = {};
if (streamBuffer.thinking) {
updates.thinking = (existing.thinking || '') + streamBuffer.thinking;
updates.phase = 'thinking';
streamBuffer.thinking = '';
}
if (streamBuffer.reasoningContent) {
updates.reasoning_content = (existing.reasoning_content || '') + streamBuffer.reasoningContent;
streamBuffer.reasoningContent = '';
}
if (streamBuffer.content) {
updates.content = (existing.content || '') + streamBuffer.content;
updates.phase = 'generating';
streamBuffer.content = '';
}
if (Object.keys(updates).length > 0) {
updateAIChatMessage(sid, assistantMsgId, updates);
}
flushPending = false;
};
const handler = (data: AIChatStreamChunk) => {
if (!assistantMsgId) {
const history = useStore.getState().aiChatHistory[sid] || [];
const lastMsg = history[history.length - 1];
if (lastMsg && lastMsg.role === 'assistant' && lastMsg.loading && lastMsg.phase === 'connecting') {
assistantMsgId = lastMsg.id;
updateAIChatMessage(sid, assistantMsgId, { content: '' });
}
}
if (data.error) {
const cleanErr = sanitizeErrorMsg(data.error);
const rawErr = cleanErr !== data.error ? data.error : undefined;
if (assistantMsgId) {
updateAIChatMessage(sid, assistantMsgId, {
content: `❌ 错误: ${cleanErr}`,
phase: 'idle',
loading: false,
rawError: rawErr,
});
} else {
addAIChatMessage(sid, {
id: nextMessageId(),
role: 'assistant',
phase: 'idle',
content: `❌ 错误: ${cleanErr}`,
rawError: rawErr,
timestamp: Date.now(),
jvmPlanContext: pendingJVMPlanContextRef.current,
jvmDiagnosticPlanContext: pendingJVMDiagnosticPlanContextRef.current,
});
}
assistantMsgId = '';
setSending(false);
return;
}
if (data.tool_calls && data.tool_calls.length > 0) {
if (assistantMsgId) {
updateAIChatMessage(sid, assistantMsgId, { tool_calls: data.tool_calls, phase: 'tool_calling' });
} else {
assistantMsgId = nextMessageId();
addAIChatMessage(sid, {
id: assistantMsgId,
role: 'assistant',
phase: 'tool_calling',
content: '',
tool_calls: data.tool_calls,
timestamp: Date.now(),
loading: true,
jvmPlanContext: pendingJVMPlanContextRef.current,
jvmDiagnosticPlanContext: pendingJVMDiagnosticPlanContextRef.current,
});
}
}
const displayThinking = data.thinking || data.reasoning_content || '';
if (displayThinking || data.reasoning_content) {
if (!assistantMsgId) {
assistantMsgId = nextMessageId();
addAIChatMessage(sid, {
id: assistantMsgId,
role: 'assistant',
phase: 'thinking',
content: '',
thinking: displayThinking || undefined,
reasoning_content: data.reasoning_content || undefined,
timestamp: Date.now(),
loading: true,
jvmPlanContext: pendingJVMPlanContextRef.current,
jvmDiagnosticPlanContext: pendingJVMDiagnosticPlanContextRef.current,
});
if (sendingRef.current) setSending(false);
} else {
streamBuffer.thinking += displayThinking;
if (data.reasoning_content) {
streamBuffer.reasoningContent += data.reasoning_content;
}
if (sendingRef.current) setSending(false);
}
}
if (data.content) {
if (!assistantMsgId) {
assistantMsgId = nextMessageId();
addAIChatMessage(sid, {
id: assistantMsgId,
role: 'assistant',
phase: 'generating',
content: data.content,
timestamp: Date.now(),
loading: true,
jvmPlanContext: pendingJVMPlanContextRef.current,
jvmDiagnosticPlanContext: pendingJVMDiagnosticPlanContextRef.current,
});
setSending(false);
const currentHistory = useStore.getState().aiChatHistory[sid] || [];
if (currentHistory.length <= 1) isFirstCompletion = true;
} else {
streamBuffer.content += data.content;
if (sendingRef.current) setSending(false);
}
}
if (streamBuffer.thinking || streamBuffer.reasoningContent || streamBuffer.content) {
if (!flushPending) {
flushPending = true;
requestAnimationFrame(flushStreamBuffer);
}
}
if (data.done) {
if (streamBuffer.thinking || streamBuffer.reasoningContent || streamBuffer.content) {
flushStreamBuffer();
}
const doneAssistantId = assistantMsgId;
const doneIsFirst = isFirstCompletion;
assistantMsgId = '';
setTimeout(() => {
const currentMsgs = useStore.getState().aiChatHistory[sid] || [];
for (const msg of currentMsgs) {
if (msg.id !== doneAssistantId && msg.loading && msg.phase === 'connecting') {
updateAIChatMessage(sid, msg.id, { loading: false, phase: 'idle' });
}
}
if (doneAssistantId) {
const current = useStore.getState().aiChatHistory[sid];
const existing = current?.find((message) => message.id === doneAssistantId);
if (existing && existing.tool_calls && existing.tool_calls.length > 0) {
nudgeCountRef.current = 0;
setTimeout(() => executeLocalTools(existing.tool_calls!, doneAssistantId), 50);
return;
}
if (
existing &&
nudgeCountRef.current < 2 &&
/(?:让我|我先|我来|现在|接下来|下面).*(?:查询|查找|获取|查看|检查|调用)|(?:获取|查询|查找|查看).*(?:信息|字段|列表|数据)[:]?\s*$/.test(existing.content || '')
) {
nudgeCountRef.current += 1;
updateAIChatMessage(sid, doneAssistantId, { loading: false, phase: 'idle' });
(async () => {
try {
const currentHistory = useStore.getState().aiChatHistory[sid] || [];
const messagesPayload = currentHistory.map(toAIRequestMessage);
const sysMessages = await buildSystemContextMessages(
existing.jvmPlanContext,
existing.jvmDiagnosticPlanContext,
);
messagesPayload.push({
role: 'user',
content: '请直接使用 function call 调用工具执行操作,不要只用文字描述计划。',
});
const allMsg = [...sysMessages, ...messagesPayload];
const service = (window as any).go?.aiservice?.Service;
if (service?.AIChatStream) {
await service.AIChatStream(sid, allMsg, availableTools);
}
} catch (error) {
console.error('Nudge failed', error);
setSending(false);
}
})();
return;
}
if (doneIsFirst) generateTitleForSession(sid);
const hasContent = !!existing?.content?.trim();
const hasThinking = !!existing?.thinking?.trim();
const hasTools = !!existing?.tool_calls?.length;
if (!hasContent && !hasThinking && !hasTools) {
updateAIChatMessage(sid, doneAssistantId, {
content: '❌ 模型未能成功响应任何内容,可能遭遇频控、上下文超载或理解拒绝。',
loading: false,
phase: 'idle',
});
} else {
updateAIChatMessage(sid, doneAssistantId, { loading: false, phase: 'idle' });
}
} else {
addAIChatMessage(sid, {
id: nextMessageId(),
role: 'assistant',
content: '❌ 请求中断:未收到任何具体回复。',
timestamp: Date.now(),
loading: false,
});
}
setSending(false);
}, 50);
}
};
EventsOn(eventName, handler);
return () => {
EventsOff(eventName);
};
}, [
addAIChatMessage,
availableTools,
buildSystemContextMessages,
executeLocalTools,
generateTitleForSession,
nextMessageId,
nudgeCountRef,
pendingJVMDiagnosticPlanContextRef,
pendingJVMPlanContextRef,
setSending,
sid,
updateAIChatMessage,
]);
};