mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-30 06:11:22 +08:00
⚡️ perf(ai-chat): 降低流式思考输出渲染开销
- 合并 AI 流式 token 刷新,减少高频状态写入 - 避免纯流式更新重排会话列表,并收窄当前会话订阅 - 补充 thinking 合并刷新和会话列表稳定性回归测试
This commit is contained in:
@@ -10,6 +10,16 @@ import { buildOverlayWorkbenchTheme } from '../../../utils/overlayWorkbenchTheme
|
||||
|
||||
vi.mock('antd', () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => children,
|
||||
Modal: Object.assign(
|
||||
({ children, open }: { children?: React.ReactNode; open?: boolean }) => (open ? <div>{children}</div> : null),
|
||||
{
|
||||
info: vi.fn(),
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
confirm: vi.fn(),
|
||||
},
|
||||
),
|
||||
message: { error: vi.fn() },
|
||||
}));
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
loadAISessionsFromBackend,
|
||||
useStore,
|
||||
} from '../../store';
|
||||
import type { AIChatMessage } from '../../types';
|
||||
|
||||
interface UseAIChatSessionStateOptions {
|
||||
aiActiveSessionId: string | null;
|
||||
@@ -12,13 +13,16 @@ interface UseAIChatSessionStateOptions {
|
||||
createNewAISession: () => void;
|
||||
}
|
||||
|
||||
const EMPTY_AI_CHAT_MESSAGES: AIChatMessage[] = [];
|
||||
|
||||
export const useAIChatSessionState = ({
|
||||
aiActiveSessionId,
|
||||
aiPanelVisible,
|
||||
createNewAISession,
|
||||
}: UseAIChatSessionStateOptions) => {
|
||||
const aiChatHistory = useStore((state) => state.aiChatHistory);
|
||||
const aiChatSessions = useStore((state) => state.aiChatSessions);
|
||||
const sid = aiActiveSessionId || 'session-fallback';
|
||||
const messages = useStore((state) => state.aiChatHistory[sid] || EMPTY_AI_CHAT_MESSAGES);
|
||||
|
||||
useEffect(() => {
|
||||
if (!aiActiveSessionId) {
|
||||
@@ -26,9 +30,6 @@ export const useAIChatSessionState = ({
|
||||
}
|
||||
}, [aiActiveSessionId, createNewAISession]);
|
||||
|
||||
const sid = aiActiveSessionId || 'session-fallback';
|
||||
const messages = aiChatHistory[sid] || [];
|
||||
|
||||
const sessionsLoadedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!aiPanelVisible || sessionsLoadedRef.current) {
|
||||
|
||||
@@ -41,6 +41,7 @@ const translate = (
|
||||
params?: Record<string, string | number | boolean | null | undefined>,
|
||||
) => (translatedCopy[key] || key).replace(/\{\{(\w+)\}\}/g, (_match, name) => String(params?.[name] ?? ''));
|
||||
let nextId = 0;
|
||||
let patchMessageCalls = 0;
|
||||
|
||||
const emitStreamChunk = async (data: any) => {
|
||||
const handler = runtimeMock.handlers.get(`ai:stream:${SESSION_ID}`);
|
||||
@@ -71,6 +72,7 @@ const patchMessage = (
|
||||
messageId: string,
|
||||
patch: Parameters<ReturnType<typeof useStore.getState>['updateAIChatMessage']>[2],
|
||||
) => {
|
||||
patchMessageCalls += 1;
|
||||
useStore.setState((state) => {
|
||||
const messages = state.aiChatHistory[sessionId];
|
||||
if (!messages) {
|
||||
@@ -132,6 +134,7 @@ describe('useAIChatStreamSubscription', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
nextId = 0;
|
||||
patchMessageCalls = 0;
|
||||
aiChatStreamMock.mockClear();
|
||||
generateTitleForSessionMock.mockClear();
|
||||
runtimeMock.handlers.clear();
|
||||
@@ -185,6 +188,7 @@ describe('useAIChatStreamSubscription', () => {
|
||||
});
|
||||
|
||||
it('keeps streamed chunks in the same assistant message after a parent rerender', async () => {
|
||||
vi.useFakeTimers();
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
|
||||
await act(async () => {
|
||||
@@ -193,6 +197,9 @@ describe('useAIChatStreamSubscription', () => {
|
||||
|
||||
await emitStreamChunk({ content: 'Hello' });
|
||||
await emitStreamChunk({ content: ' world' });
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(90);
|
||||
});
|
||||
|
||||
const messages = useStore.getState().aiChatHistory[SESSION_ID] || [];
|
||||
const assistantMessages = messages.filter((message) => message.role === 'assistant');
|
||||
@@ -210,6 +217,41 @@ describe('useAIChatStreamSubscription', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('coalesces high-frequency thinking chunks before writing them to the store', async () => {
|
||||
vi.useFakeTimers();
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(<StreamHarness />);
|
||||
});
|
||||
|
||||
await emitStreamChunk({ thinking: 'A' });
|
||||
const callsAfterScheduling = patchMessageCalls;
|
||||
await emitStreamChunk({ thinking: 'B' });
|
||||
await emitStreamChunk({ thinking: 'C' });
|
||||
|
||||
expect(patchMessageCalls).toBe(callsAfterScheduling);
|
||||
expect(
|
||||
(useStore.getState().aiChatHistory[SESSION_ID] || []).find((message) => message.id === 'assistant-connecting'),
|
||||
).not.toHaveProperty('thinking');
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(90);
|
||||
});
|
||||
|
||||
expect(
|
||||
(useStore.getState().aiChatHistory[SESSION_ID] || []).find((message) => message.id === 'assistant-connecting'),
|
||||
).toMatchObject({
|
||||
thinking: 'ABC',
|
||||
phase: 'thinking',
|
||||
});
|
||||
expect(patchMessageCalls).toBe(callsAfterScheduling + 1);
|
||||
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('resends a localized force-tool-call nudge when the model only describes the next action', async () => {
|
||||
vi.useFakeTimers();
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
|
||||
@@ -57,14 +57,18 @@ interface AIChatStreamState {
|
||||
content: string;
|
||||
};
|
||||
flushPending: boolean;
|
||||
lastFlushAt: number | null;
|
||||
}
|
||||
|
||||
const AI_CHAT_STREAM_FLUSH_INTERVAL_MS = 80;
|
||||
|
||||
const createAIChatStreamState = (sid: string): AIChatStreamState => ({
|
||||
sid,
|
||||
assistantMsgId: '',
|
||||
isFirstCompletion: false,
|
||||
streamBuffer: { thinking: '', reasoningContent: '', content: '' },
|
||||
flushPending: false,
|
||||
lastFlushAt: null,
|
||||
});
|
||||
|
||||
const resetAIChatStreamProgress = (state: AIChatStreamState) => {
|
||||
@@ -74,6 +78,7 @@ const resetAIChatStreamProgress = (state: AIChatStreamState) => {
|
||||
state.streamBuffer.reasoningContent = '';
|
||||
state.streamBuffer.content = '';
|
||||
state.flushPending = false;
|
||||
state.lastFlushAt = null;
|
||||
};
|
||||
|
||||
const translatePanelCopy = (
|
||||
@@ -131,9 +136,25 @@ export const useAIChatStreamSubscription = ({
|
||||
|
||||
// 缓冲高频 token,避免把流式吞吐直接转成同步重绘风暴
|
||||
const streamBuffer = streamState.streamBuffer;
|
||||
let flushTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||
let flushFrameId: number | null = null;
|
||||
|
||||
const cancelScheduledFlush = () => {
|
||||
if (flushTimerId !== null) {
|
||||
clearTimeout(flushTimerId);
|
||||
flushTimerId = null;
|
||||
}
|
||||
if (flushFrameId !== null && typeof cancelAnimationFrame === 'function') {
|
||||
cancelAnimationFrame(flushFrameId);
|
||||
}
|
||||
flushFrameId = null;
|
||||
streamState.flushPending = false;
|
||||
};
|
||||
|
||||
const flushStreamBuffer = () => {
|
||||
streamState.flushPending = false;
|
||||
flushTimerId = null;
|
||||
flushFrameId = null;
|
||||
if (!streamState.assistantMsgId) return;
|
||||
const current = useStore.getState().aiChatHistory[sid];
|
||||
const existing = current?.find((message) => message.id === streamState.assistantMsgId);
|
||||
@@ -157,9 +178,43 @@ export const useAIChatStreamSubscription = ({
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
updateAIChatMessage(sid, streamState.assistantMsgId, updates);
|
||||
streamState.lastFlushAt = Date.now();
|
||||
}
|
||||
};
|
||||
|
||||
const requestFlushFrame = () => {
|
||||
if (typeof requestAnimationFrame !== 'function') {
|
||||
flushStreamBuffer();
|
||||
return;
|
||||
}
|
||||
|
||||
let completedSynchronously = false;
|
||||
const frameId = requestAnimationFrame(() => {
|
||||
completedSynchronously = true;
|
||||
flushFrameId = null;
|
||||
flushStreamBuffer();
|
||||
});
|
||||
flushFrameId = completedSynchronously ? null : frameId;
|
||||
};
|
||||
|
||||
const scheduleStreamFlush = () => {
|
||||
if (streamState.flushPending) return;
|
||||
streamState.flushPending = true;
|
||||
|
||||
const lastFlushAt = streamState.lastFlushAt;
|
||||
const delay =
|
||||
lastFlushAt === null
|
||||
? 0
|
||||
: Math.max(0, AI_CHAT_STREAM_FLUSH_INTERVAL_MS - (Date.now() - lastFlushAt));
|
||||
|
||||
if (delay > 0) {
|
||||
flushTimerId = setTimeout(requestFlushFrame, delay);
|
||||
return;
|
||||
}
|
||||
|
||||
requestFlushFrame();
|
||||
};
|
||||
|
||||
const handler = (data: AIChatStreamChunk) => {
|
||||
if (!streamState.assistantMsgId) {
|
||||
const history = useStore.getState().aiChatHistory[sid] || [];
|
||||
@@ -202,6 +257,7 @@ export const useAIChatStreamSubscription = ({
|
||||
jvmDiagnosticPlanContext: pendingJVMDiagnosticPlanContextRef.current,
|
||||
});
|
||||
}
|
||||
cancelScheduledFlush();
|
||||
resetAIChatStreamProgress(streamState);
|
||||
setSending(false);
|
||||
return;
|
||||
@@ -275,14 +331,12 @@ export const useAIChatStreamSubscription = ({
|
||||
}
|
||||
|
||||
if (streamBuffer.thinking || streamBuffer.reasoningContent || streamBuffer.content) {
|
||||
if (!streamState.flushPending) {
|
||||
streamState.flushPending = true;
|
||||
requestAnimationFrame(flushStreamBuffer);
|
||||
}
|
||||
scheduleStreamFlush();
|
||||
}
|
||||
|
||||
if (data.done) {
|
||||
if (streamBuffer.thinking || streamBuffer.reasoningContent || streamBuffer.content) {
|
||||
cancelScheduledFlush();
|
||||
flushStreamBuffer();
|
||||
}
|
||||
const doneAssistantId = streamState.assistantMsgId;
|
||||
@@ -380,6 +434,7 @@ export const useAIChatStreamSubscription = ({
|
||||
|
||||
EventsOn(eventName, handler);
|
||||
return () => {
|
||||
cancelScheduledFlush();
|
||||
EventsOff(eventName);
|
||||
};
|
||||
}, [
|
||||
|
||||
@@ -1117,6 +1117,55 @@ describe('store appearance persistence', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps streaming-only AI message patches from reordering the session list', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { useStore } = await importStore();
|
||||
useStore.setState({
|
||||
aiChatSessions: [
|
||||
{ id: 'session-other', title: 'other', updatedAt: 20 },
|
||||
{ id: 'session-stream', title: 'stream', updatedAt: 10 },
|
||||
],
|
||||
aiChatHistory: {
|
||||
'session-stream': [
|
||||
{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
phase: 'connecting',
|
||||
content: '',
|
||||
timestamp: 1,
|
||||
loading: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const sessionsBeforeStreamingPatch = useStore.getState().aiChatSessions;
|
||||
useStore.getState().updateAIChatMessage('session-stream', 'assistant-1', {
|
||||
thinking: 'planning',
|
||||
phase: 'thinking',
|
||||
});
|
||||
|
||||
expect(useStore.getState().aiChatSessions).toBe(sessionsBeforeStreamingPatch);
|
||||
expect(useStore.getState().aiChatSessions.map((session) => session.id)).toEqual([
|
||||
'session-other',
|
||||
'session-stream',
|
||||
]);
|
||||
|
||||
useStore.getState().updateAIChatMessage('session-stream', 'assistant-1', {
|
||||
loading: false,
|
||||
phase: 'idle',
|
||||
});
|
||||
|
||||
expect(useStore.getState().aiChatSessions.map((session) => session.id)).toEqual([
|
||||
'session-stream',
|
||||
'session-other',
|
||||
]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps store fallback titles out of production source literals', async () => {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const source = readFileSync(new URL('./store.ts', import.meta.url), 'utf8');
|
||||
|
||||
@@ -1495,6 +1495,23 @@ interface AppState {
|
||||
setAIActiveSessionId: (sessionId: string | null) => void;
|
||||
}
|
||||
|
||||
const AI_STREAMING_MESSAGE_UPDATE_KEYS = new Set<keyof AIChatMessage>([
|
||||
"content",
|
||||
"thinking",
|
||||
"reasoning_content",
|
||||
"phase",
|
||||
]);
|
||||
|
||||
const isAIStreamingOnlyMessageUpdate = (
|
||||
updates: Partial<AIChatMessage>,
|
||||
): boolean => {
|
||||
const updateKeys = Object.keys(updates) as Array<keyof AIChatMessage>;
|
||||
return (
|
||||
updateKeys.length > 0 &&
|
||||
updateKeys.every((key) => AI_STREAMING_MESSAGE_UPDATE_KEYS.has(key))
|
||||
);
|
||||
};
|
||||
|
||||
const sanitizeSqlSnippets = (value: unknown): SqlSnippet[] => {
|
||||
if (!Array.isArray(value)) return DEFAULT_SQL_SNIPPETS;
|
||||
const result: SqlSnippet[] = [];
|
||||
@@ -3455,9 +3472,7 @@ export const useStore = create<AppState>()(
|
||||
const newMessages = [...messages];
|
||||
newMessages[idx] = { ...newMessages[idx], ...updates };
|
||||
const history = { ...state.aiChatHistory, [sessionId]: newMessages };
|
||||
const isContentOnlyUpdate =
|
||||
Object.keys(updates).length === 1 && "content" in updates;
|
||||
if (!isContentOnlyUpdate) {
|
||||
if (!isAIStreamingOnlyMessageUpdate(updates)) {
|
||||
let newSessions = [...state.aiChatSessions];
|
||||
const existingSession = newSessions.find((s) => s.id === sessionId);
|
||||
if (existingSession) {
|
||||
|
||||
Reference in New Issue
Block a user