实时状态流 深度解析
这条流不是 token 专线,而是项目一致性通道:消息、项目元数据、资产与 Harness 共享一个 JSON 快照。
导入链与契约
服务端用 400ms 轮询换取无 Broker 的简单一致性,并通过 15 秒保活和 5 分钟上限控制连接生命周期;只要消息或验证仍在运行,连接就继续 对应源码落点是 src/app/api/projects/[id]/chat/stream/route.ts:8、src/app/api/projects/[id]/chat/stream/route.ts:9、src/app/api/projects/[id]/chat/stream/route.ts:10、src/app/api/projects/[id]/chat/stream/route.ts:15、src/app/api/projects/[id]/chat/stream/route.ts:18、src/app/api/projects/[id]/chat/stream/route.ts:19、src/app/api/projects/[id]/chat/stream/route.ts:21。把这些相邻行放在同一条执行链里看,可以确认它不是文档里的愿景描述,而是已经落到请求、状态和界面反馈中的实现;同时也能辨认出失败时由哪一层收敛、下一次刷新从何处恢复,以及扩容后最先需要替换的边界。
30 const stream = new ReadableStream({
31 async start(controller) {
32 let closed = false;
33 const startedAt = Date.now();
34 let lastSnapshot = '';
35 let lastKeepalive = Date.now();
36
37 const send = (data: string) => {
38 controller.enqueue(encoder.encode(`data: ${data}\n\n`));
39 };
40
41 const tick = async (): Promise<boolean> => {
42 // Also tail the Project (title/desc/logo) + its assets so product edits
43 // made by the worker's qb-designer tools surface live — the worker bumps
44 // Project.updatedAt / inserts Asset rows, and this snapshot picks it up.
45 // Also tail the harness tables + harnessRunning flag so pass-3 verdicts and
46 // the running indicator stream over this same connection (browsers cap ~6
47 // per origin, so the harness UI shares the chat stream instead of opening
48 // its own). The worker runs the harness right after the turn settles, so
49 // the in-flight condition below also keys off harnessRunning to keep this
50 // connection open across that hand-off.
核心实现,逐段拆
▸快照去重与运行态判定
每次 tick 并行查四组数据,随后拼成一个字符串快照;只有字符串变化才发 data 帧。运行态同时检查 PENDING、PROCESSING 与 harnessRunning,覆盖 Agent 到验收的交接空隙 对应源码落点是 src/app/api/projects/[id]/chat/stream/route.ts:51、src/app/api/projects/[id]/chat/stream/route.ts:56、src/app/api/projects/[id]/chat/stream/route.ts:67、src/app/api/projects/[id]/chat/stream/route.ts:71、src/app/api/projects/[id]/chat/stream/route.ts:77、src/app/api/projects/[id]/chat/stream/route.ts:85、src/app/api/projects/[id]/chat/stream/route.ts:86。把这些相邻行放在同一条执行链里看,可以确认它不是文档里的愿景描述,而是已经落到请求、状态和界面反馈中的实现;同时也能辨认出失败时由哪一层收敛、下一次刷新从何处恢复,以及扩容后最先需要替换的边界。
51 const [messages, project, assets, requirements] = await Promise.all([
52 prisma.chatMessage.findMany({
53 where: { projectId: id },
54 orderBy: { createdAt: 'asc' },
55 }),
56 prisma.project.findUnique({
57 where: { id },
58 select: {
59 title: true,
60 desc: true,
61 logo: true,
62 status: true,
63 updatedAt: true,
64 harnessRunning: true,
65 },
66 }),
67 prisma.asset.findMany({
68 where: { projectId: id },
69 orderBy: { createdAt: 'asc' },
70 }),
71 prisma.harnessRequirement.findMany({
72 where: { projectId: id },
73 include: { result: true },
74 orderBy: { createdAt: 'asc' },
75 }),
76 ]);
77 const harness = {
78 requirements: requirements.map(serializeRequirement),
79 results: requirements
80 .map((r) => r.result)
81 .filter((r) => r !== null)
82 .map(serializeResult),
83 is_running: project?.harnessRunning ?? false,
84 };
85 const payload = { messages, project, assets, harness };
86 const snapshot = JSON.stringify(payload);
87 if (snapshot !== lastSnapshot) {
88 lastSnapshot = snapshot;
89 send(snapshot);
90 lastKeepalive = Date.now();
91 } else if (Date.now() - lastKeepalive > KEEPALIVE_MS) {
92 controller.enqueue(encoder.encode(': keepalive\n\n'));
93 lastKeepalive = Date.now();
94 }
95
96 const inFlight =
97 messages.some((m) => m.status === 'PENDING' || m.status === 'PROCESSING') ||
98 (project?.harnessRunning ?? false);
99 return inFlight;
100 };
▸客户端自己掌控读流
前端使用 fetch reader 而不是 EventSource,以便在空闲时不自动重连;它自己拆分双换行事件、解析 data 行,并在流关闭后重新拉取最终快照 对应源码落点是 src/components/blueprint/use-project-stream.ts:83、src/components/blueprint/use-project-stream.ts:88、src/components/blueprint/use-project-stream.ts:89、src/components/blueprint/use-project-stream.ts:93、src/components/blueprint/use-project-stream.ts:98、src/components/blueprint/use-project-stream.ts:101、src/components/blueprint/use-project-stream.ts:105。把这些相邻行放在同一条执行链里看,可以确认它不是文档里的愿景描述,而是已经落到请求、状态和界面反馈中的实现;同时也能辨认出失败时由哪一层收敛、下一次刷新从何处恢复,以及扩容后最先需要替换的边界。
83 /**
84 * Tail the SSE stream. Uses fetch + a reader (not EventSource) so we control the
85 * lifecycle — the server self-closes once neither a turn nor the harness is in
86 * flight, and we don't auto-reconnect while idle.
87 */
88 const startStream = useCallback(async () => {
89 if (abortRef.current) return; // already tailing
90 const ac = new AbortController();
91 abortRef.current = ac;
92 try {
93 const res = await fetch(`/api/projects/${projectId}/chat/stream`, {
94 signal: ac.signal,
95 headers: { Accept: 'text/event-stream' },
96 });
97 if (!res.ok || !res.body) return;
98 const reader = res.body.getReader();
99 const decoder = new TextDecoder();
100 let buffer = '';
101 for (;;) {
102 const { value, done } = await reader.read();
103 if (done) break;
104 buffer += decoder.decode(value, { stream: true });
105 const events = buffer.split('\n\n');
106 buffer = events.pop() ?? '';
107 for (const evt of events) {
108 const line = evt.split('\n').find((l) => l.startsWith('data:'));
109 if (!line) continue;
110 try {
111 const payload = JSON.parse(line.slice(5).trim());
112 if (payload.messages) setRows(payload.messages);
113 if (payload.project) setProject(payload.project);
114 if (payload.harness) setHarness(payload.harness);
115 } catch {
116 /* ignore partial/non-json */
117 }
118 }
119 }
120 } catch {
121 /* aborted or network — ignore */
122 } finally {
123 if (abortRef.current === ac) abortRef.current = null;
124 // Settle after the stream closes; if the harness kicked off (or is still
125 // running) the effect below reopens the stream.
126 refetch();
127 }
128 }, [projectId, refetch]);
关键流程
snapshot changed → update slices → idle close → refetch
动作完成后 poke 会主动开流,两个 effect 分别负责首次快照和运行态重启,卸载时 abort。最终 hook 把 rows、映射后的 messages、project、harness 与恢复函数一次返回 对应源码落点是 src/components/blueprint/use-project-stream.ts:130、src/components/blueprint/use-project-stream.ts:132、src/components/blueprint/use-project-stream.ts:136、src/components/blueprint/use-project-stream.ts:140、src/components/blueprint/use-project-stream.ts:143、src/components/blueprint/use-project-stream.ts:144、src/components/blueprint/use-project-stream.ts:147。把这些相邻行放在同一条执行链里看,可以确认它不是文档里的愿景描述,而是已经落到请求、状态和界面反馈中的实现;同时也能辨认出失败时由哪一层收敛、下一次刷新从何处恢复,以及扩容后最先需要替换的边界。
边界 / 失败模式
- 解析失败被忽略,下一帧完整快照会自然修复。
- 连接超时后依靠 refetch 和运行态 effect 重新进入。
- 400ms 多表查询在高并发下可能成为数据库压力源。