mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-06 14:33:46 +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);
|
||||
};
|
||||
}, [
|
||||
|
||||
Reference in New Issue
Block a user