feat: agent 实时输出流(SSE)

B-② SSE 实时流(⑧ ★ GET /api/tasks/:id/stream):

后端:
- GET /api/tasks/:id/stream:SSE 跟随任务最近一条 run 的 transcript 文件,
  新写入的 agent 消息逐行推送(活跃 run=实时;已结束=回放后 end);
  500ms 轮询 tail(按 offset 增量读),run 转非 started 即结束流,客户端断开即停

前端:
- StreamModal(EventSource 消费 SSE,summarizeMessage 把 SDK 消息→可读行:
  助手文本/⚙工具调用/■终态/·maestro 元事件,自动滚动到底)
- AgentSection agent 行可点击 → 打开实时流模态;adaptActiveAgents 带出 taskId

验证:typecheck 干净;前端 build 通过;dev 渲染 0 console 错误。
(实时效果需有运行中的 agent;端到端随 daemon 重启验证。)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-24 17:01:58 +08:00
parent 9b17d3e24f
commit a688b6977e
4 changed files with 105 additions and 5 deletions
+40 -1
View File
@@ -13,7 +13,8 @@ import { git, removeWorktree } from '../executor/worktree.js';
import { cleanupTaskRunArtifacts } from '../executor/cleanup.js';
import { resolveLogo, LOGO_MIME } from './logo.js';
import { readTranscript, TranscriptError } from '../executor/transcript.js';
import { createReadStream, createWriteStream, mkdirSync } from 'node:fs';
import { createReadStream, createWriteStream, mkdirSync, readFileSync, statSync } from 'node:fs';
import { transcriptDir } from '../executor/cc.js';
import { homedir } from 'node:os';
import { join, basename } from 'node:path';
import { pipeline } from 'node:stream/promises';
@@ -284,6 +285,44 @@ export function buildServer(opts: ApiOptions): FastifyInstance {
return store.listRuns(id);
});
// 实时流(SSE):跟随任务「最近一条 run」的 transcript 文件,把新写入的 agent 消息逐行推送。
// 活跃 runstatus=started= 真实时;已结束 run = 一次性回放后 end。客户端 EventSource 消费。
app.get('/api/tasks/:id/stream', (req, reply) => {
const { id } = req.params as { id: string };
const runs = store.listRuns(id);
const run = runs.find((r) => r.status === 'started') ?? runs[runs.length - 1];
if (!run) return reply.code(404).send({ error: '该任务暂无 run 可流式' });
const file = join(transcriptDir(), `${run.id}.jsonl`);
reply.hijack();
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache',
Connection: 'keep-alive', 'X-Accel-Buffering': 'no',
});
reply.raw.write(`event: open\ndata: {"runId":"${run.id}","kind":"${run.kind}"}\n\n`);
let offset = 0;
let closed = false;
const stop = () => { if (closed) return; closed = true; clearInterval(timer); reply.raw.end(); };
const tick = (): void => {
if (closed) return;
try {
const size = statSync(file).size;
if (size > offset) {
const chunk = readFileSync(file).subarray(offset).toString('utf8');
offset = size;
for (const line of chunk.split('\n')) if (line.trim()) reply.raw.write(`data: ${line}\n\n`);
}
} catch { /* 文件尚未创建:等下一拍 */ }
const cur = store.getRun(run.id);
if (!cur || cur.status !== 'started') {
reply.raw.write('event: end\ndata: {}\n\n');
stop();
}
};
const timer = setInterval(tick, 500);
tick();
req.raw.on('close', stop);
});
// 执行转录回放:读 run.transcriptRef 指向的 jsonl,逐行解析成脱敏后的结构化消息数组。
// 校验:runId 合法 → 关联任务/项目存在(可访问)→ 有转录记录 → 路径在 transcriptDir 下(防穿越)。
app.get('/api/runs/:id/transcript', (req, reply) => {