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:
+40
-1
@@ -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 消息逐行推送。
|
||||
// 活跃 run(status=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) => {
|
||||
|
||||
Reference in New Issue
Block a user