Skip to content

Commit 16c5b31

Browse files
committed
perf(ai): 对话流帧级批量渲染——高频 chunk 合帧刷新(REQ-275)
1 parent 296e4d1 commit 16c5b31

2 files changed

Lines changed: 172 additions & 6 deletions

File tree

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/**
2+
* useChatStream.test — 帧级批量渲染契约(REQ-275)。
3+
* AAA:高频 chunk 同帧合并为单次 setView;终态/切会话立即 flush 不残留;
4+
* 单会话单流与终态清理回归。
5+
* @vitest-environment jsdom(renderHook 需 document)
6+
*/
7+
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
8+
import { renderHook, act } from "@testing-library/react";
9+
import type { ChatStreamEvent } from "../types";
10+
import useChatStream from "./useChatStream";
11+
12+
/** 受控 rAF 桩(jsdom 无 rAF——手动推进帧) */
13+
let rafQueue: { id: number; cb: FrameRequestCallback }[] = [];
14+
let rafSeq = 1;
15+
function advanceOneFrame() {
16+
const item = rafQueue.shift();
17+
if (item) item.cb(0);
18+
}
19+
function pendingFrames(): number {
20+
return rafQueue.length;
21+
}
22+
23+
/** Channel mock:捕获实例以便手动触发 onmessage */
24+
let lastChannel: { onmessage: ((ev: ChatStreamEvent) => void) | null } | null = null;
25+
const invokeMock = vi.fn(async () => undefined);
26+
vi.mock("@tauri-apps/api/core", () => ({
27+
Channel: class {
28+
onmessage: ((ev: ChatStreamEvent) => void) | null = null;
29+
constructor() {
30+
lastChannel = this;
31+
}
32+
},
33+
invoke: () => invokeMock(),
34+
}));
35+
36+
beforeEach(() => {
37+
rafQueue = [];
38+
rafSeq = 1;
39+
lastChannel = null;
40+
invokeMock.mockClear();
41+
globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => {
42+
rafQueue.push({ id: rafSeq, cb });
43+
return rafSeq++;
44+
};
45+
globalThis.cancelAnimationFrame = (id: number) => {
46+
rafQueue = rafQueue.filter((i) => i.id !== id);
47+
};
48+
});
49+
50+
afterEach(() => {
51+
vi.restoreAllMocks();
52+
});
53+
54+
async function startStream(hook: ReturnType<typeof renderHook<ReturnType<typeof useChatStream>, unknown>>["result"]) {
55+
await act(async () => {
56+
expect(await hook.current.launch(1, "chat_send", {})).toBe(true);
57+
});
58+
expect(lastChannel).not.toBeNull();
59+
}
60+
61+
const chunk = (delta: string): ChatStreamEvent => ({ kind: "chunk", delta });
62+
const doneEv: ChatStreamEvent = { kind: "done", content: "", usageJson: null };
63+
64+
describe("useChatStream 帧级批量渲染(REQ-275)", () => {
65+
it("高频 chunk 同帧合并为一次 setView(先排帧后落视图)", async () => {
66+
const settled = vi.fn();
67+
const { result } = renderHook(() => useChatStream(settled));
68+
await startStream(result);
69+
act(() => { result.current.setActive(1); });
70+
act(() => {
71+
lastChannel!.onmessage!(chunk("你"));
72+
lastChannel!.onmessage!(chunk("好"));
73+
});
74+
// 帧未推进:文本仍为空(chunk 已累积在 accs,等待合帧)
75+
expect(result.current.view?.text).toBeUndefined();
76+
expect(pendingFrames()).toBe(1); // 多 chunk 只挂一帧
77+
act(() => advanceOneFrame());
78+
expect(result.current.view?.text).toBe("你好");
79+
});
80+
81+
it("帧挂起期间的后续 chunk 合并进同一帧", async () => {
82+
const { result } = renderHook(() => useChatStream(vi.fn()));
83+
await startStream(result);
84+
act(() => result.current.setActive(1));
85+
act(() => lastChannel!.onmessage!(chunk("A")));
86+
expect(pendingFrames()).toBe(1);
87+
act(() => lastChannel!.onmessage!(chunk("B")));
88+
expect(pendingFrames()).toBe(1); // 未新增帧
89+
act(() => advanceOneFrame());
90+
expect(result.current.view?.text).toBe("AB");
91+
// 下一帧可再次调度
92+
act(() => lastChannel!.onmessage!(chunk("C")));
93+
expect(pendingFrames()).toBe(1);
94+
act(() => advanceOneFrame());
95+
expect(result.current.view?.text).toBe("ABC");
96+
});
97+
98+
it("终态立即 flush 残留帧并清流(不丢最后一段)", async () => {
99+
const settled = vi.fn();
100+
const { result } = renderHook(() => useChatStream(settled));
101+
await startStream(result);
102+
act(() => result.current.setActive(1));
103+
act(() => lastChannel!.onmessage!(chunk("尾")));
104+
expect(pendingFrames()).toBe(1);
105+
act(() => lastChannel!.onmessage!(doneEv));
106+
expect(pendingFrames()).toBe(0); // 残留帧已取消
107+
expect(result.current.view).toBeNull();
108+
expect(settled).toHaveBeenCalledWith(1, doneEv);
109+
expect(result.current.isStreaming(1)).toBe(false);
110+
});
111+
112+
it("切会话前立即 flush 当前会话残留(视图不串台)", async () => {
113+
const { result } = renderHook(() => useChatStream(vi.fn()));
114+
await startStream(result);
115+
act(() => result.current.setActive(1));
116+
act(() => lastChannel!.onmessage!(chunk("旧会话末字")));
117+
expect(pendingFrames()).toBe(1);
118+
act(() => result.current.setActive(2));
119+
expect(pendingFrames()).toBe(0); // flush 取消残留帧
120+
expect(result.current.view?.text ?? "").not.toContain("旧会话末字");
121+
});
122+
123+
it("invoke 前置失败清理流槽且无残留帧", async () => {
124+
invokeMock.mockRejectedValueOnce(new Error("gate"));
125+
const { result } = renderHook(() => useChatStream(vi.fn()));
126+
let err: unknown = null;
127+
await act(async () => {
128+
try {
129+
await result.current.launch(1, "chat_send", {});
130+
} catch (e) { err = e; }
131+
});
132+
expect(err).not.toBeNull();
133+
expect(result.current.isStreaming(1)).toBe(false);
134+
expect(pendingFrames()).toBe(0);
135+
});
136+
});

‎app/src/hooks/useChatStream.ts‎

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* useChatStream — AI 对话流式状态 hook(v0.16.0 审查重构)。
2+
* useChatStream — AI 对话流式状态 hook(v0.16.0 审查重构;REQ-275 帧级节流)。
33
*
44
* @ai-context: 审查发现(2026-08-30):原实现全局单流 + 全局 streaming 态——
55
* ① 跨会话发送被静默拒绝(无提示);② 切换会话后旧流 chunk 仍
@@ -8,6 +8,10 @@
88
* 过滤:事件只在"当前展示会话"可见,旧流后台完成并落库。
99
* @ai-context: 单会话单流:同会话再次发送 → return false(调用方提示);
1010
* 流终态(done/aborted/failed)→ onEvent 回调(调用方刷新消息)。
11+
* @ai-context: REQ-275(v0.19.4)——逐 delta IPC 高频到达时每 chunk 一次
12+
* setState 会拖垮渲染线程(用户感知"打字慢/卡顿");改为按帧
13+
* 批量 flush(requestAnimationFrame 合并同帧全部累积 delta 为
14+
* 单次 setView)。终态/切会话立即 flush 不残留。
1115
*/
1216
import { useCallback, useRef, useState } from "react";
1317
import { Channel, invoke } from "@tauri-apps/api/core";
@@ -29,6 +33,8 @@ export default function useChatStream(onSettled: StreamSettled) {
2933
const accs = useRef(new Map<number, string>());
3034
// v0.19.1(REQ-260):每会话命中片段(kb_hits 非终态事件——与文本累积平行)
3135
const hitsRef = useRef(new Map<number, KbHit[]>());
36+
// REQ-275:每会话挂起的帧刷新(raf 句柄)——同帧多 chunk 合并为一次 setView
37+
const rafRef = useRef(new Map<number, number>());
3238
const [view, setView] = useState<ChatStreamView | null>(null);
3339
const viewRef = useRef<number | null>(null);
3440
const onSettledRef = useRef(onSettled);
@@ -41,15 +47,36 @@ export default function useChatStream(onSettled: StreamSettled) {
4147
setView(hits.length > 0 || acc !== "" ? { sessionId, text: acc, hits } : null);
4248
}, []);
4349

50+
/** 帧级批量 flush(REQ-275):把 syncView 挂到下一帧——高频 chunk 合帧渲染 */
51+
const scheduleView = useCallback((sessionId: number) => {
52+
if (rafRef.current.has(sessionId)) return; // 已有挂起帧——合并
53+
const rafId = requestAnimationFrame(() => {
54+
rafRef.current.delete(sessionId);
55+
syncView(sessionId);
56+
});
57+
rafRef.current.set(sessionId, rafId);
58+
}, [syncView]);
59+
60+
/** 立即 flush(终态/切会话/取消——不留残留帧) */
61+
const flushView = useCallback((sessionId: number) => {
62+
const rafId = rafRef.current.get(sessionId);
63+
if (rafId != null) {
64+
cancelAnimationFrame(rafId);
65+
rafRef.current.delete(sessionId);
66+
}
67+
syncView(sessionId);
68+
}, [syncView]);
69+
4470
/** 切换展示会话(null=任务视图/无会话)——流式可见性随之切换 */
4571
const setActive = useCallback((sessionId: number | null) => {
72+
if (viewRef.current !== null) flushView(viewRef.current);
4673
viewRef.current = sessionId;
4774
if (sessionId === null) {
4875
setView(null);
4976
return;
5077
}
5178
syncView(sessionId);
52-
}, [syncView]);
79+
}, [flushView, syncView]);
5380

5481
/** 该会话是否有进行中的流 */
5582
const isStreaming = useCallback((sessionId: number) => streams.current.has(sessionId), []);
@@ -73,13 +100,14 @@ export default function useChatStream(onSettled: StreamSettled) {
73100
if (ev.kind === "chunk") {
74101
const acc = (accs.current.get(sessionId) ?? "") + ev.delta;
75102
accs.current.set(sessionId, acc);
76-
syncView(sessionId);
103+
// REQ-275:帧级合并——同一帧内的全部 chunk 只触发一次渲染
104+
scheduleView(sessionId);
77105
return;
78106
}
79107
if (ev.kind === "kb_hits") {
80108
// v0.19.1:命中列表(本地)——非终态,文本流照常累积
81109
hitsRef.current.set(sessionId, ev.hits);
82-
syncView(sessionId);
110+
scheduleView(sessionId);
83111
return;
84112
}
85113
// 终态显式枚举(审查加固):未来若新增非终态事件而此处未登记——
@@ -90,7 +118,8 @@ export default function useChatStream(onSettled: StreamSettled) {
90118
console.warn("[useChatStream] 未登记的事件 kind 被忽略(非终态):", unknownKind);
91119
return;
92120
}
93-
// done / aborted / failed:流终态
121+
// done / aborted / failed:流终态——先立即 flush 残留帧再清流
122+
flushView(sessionId);
94123
accs.current.delete(sessionId);
95124
hitsRef.current.delete(sessionId);
96125
streams.current.delete(sessionId);
@@ -101,14 +130,15 @@ export default function useChatStream(onSettled: StreamSettled) {
101130
await invoke(cmd, { ...args, sessionId, channel });
102131
} catch (e) {
103132
// 命令前置校验失败(gate/单流/参数)——清理半启动的流槽
133+
flushView(sessionId);
104134
accs.current.delete(sessionId);
105135
hitsRef.current.delete(sessionId);
106136
streams.current.delete(sessionId);
107137
if (viewRef.current === sessionId) setView(null);
108138
throw e;
109139
}
110140
return true;
111-
}, [syncView]);
141+
}, [flushView, scheduleView, syncView]);
112142

113143
/** 停止该会话流(后端置取消标志——无进行中流为 no-op) */
114144
const stop = useCallback((sessionId: number) => {

0 commit comments

Comments
 (0)