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:
+1
-1
@@ -160,7 +160,7 @@ export function adaptActiveAgents(agentsResp, projectId) {
|
||||
if (projectId && a.projectId !== projectId) continue;
|
||||
for (const r of a.active || []) {
|
||||
out.push({
|
||||
id: r.runId || r.id, project: a.projectName,
|
||||
id: r.runId || r.id, taskId: r.taskId, project: a.projectName,
|
||||
title: r.title || r.taskTitle || r.taskId || '(运行中)',
|
||||
kind: (r.kind || 'run').toUpperCase(),
|
||||
time: TIME(r.startedAt || r.at), meta: `${r.worktree || ''} · ${r.model || ''}`,
|
||||
|
||||
+58
-1
@@ -48,6 +48,61 @@ function NewProjectModal({ t, lang, onCreate, onClose }) {
|
||||
);
|
||||
}
|
||||
|
||||
// SDK 流式消息 → 一行可读文本(助手文本 / 工具调用 / 终态 / maestro 元事件)
|
||||
function summarizeMessage(m) {
|
||||
if (!m || !m.type) return '';
|
||||
const content = m.message && m.message.content;
|
||||
if (m.type === 'assistant' && Array.isArray(content)) {
|
||||
return content.map((c) => (c.type === 'text' ? c.text : c.type === 'tool_use' ? `⚙ ${c.name}` : '')).filter(Boolean).join(' ');
|
||||
}
|
||||
if (m.type === 'user' && Array.isArray(content)) {
|
||||
return content.some((c) => c.type === 'tool_result') ? '↩ tool result' : '';
|
||||
}
|
||||
if (m.type === 'result') return `■ ${m.subtype || 'done'}`;
|
||||
if (typeof m.type === 'string' && m.type.startsWith('maestro.')) return `· ${m.type}`;
|
||||
return '';
|
||||
}
|
||||
|
||||
// 实时流模态:EventSource 跟随某 run 的 transcript,逐条渲染 agent 输出
|
||||
function StreamModal({ agent, onClose }) {
|
||||
const [lines, setLines] = React.useState([]);
|
||||
const [status, setStatus] = React.useState('connecting');
|
||||
const boxRef = React.useRef(null);
|
||||
React.useEffect(() => {
|
||||
const es = new EventSource(`/api/tasks/${agent.taskId}/stream`);
|
||||
es.addEventListener('open', () => setStatus('streaming'));
|
||||
es.onmessage = (e) => {
|
||||
let text = '';
|
||||
try { text = summarizeMessage(JSON.parse(e.data)); } catch { text = ''; }
|
||||
if (text) setLines((ls) => [...ls.slice(-400), text]);
|
||||
};
|
||||
es.addEventListener('end', () => { setStatus('done'); es.close(); });
|
||||
es.onerror = () => { setStatus('disconnected'); es.close(); };
|
||||
return () => es.close();
|
||||
}, [agent.taskId]);
|
||||
React.useEffect(() => { if (boxRef.current) boxRef.current.scrollTop = boxRef.current.scrollHeight; }, [lines]);
|
||||
const dot = { connecting: 'var(--amber)', streaming: 'var(--cyan)', done: 'var(--green)', disconnected: 'var(--faint)' }[status];
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 1100, display: 'grid', placeItems: 'center' }}>
|
||||
<div onClick={onClose} style={{ position: 'absolute', inset: 0, background: 'rgba(4,6,5,.86)', backdropFilter: 'blur(3px)' }}></div>
|
||||
<div style={{ position: 'relative', display: 'flex', flexDirection: 'column', width: 'min(960px,94vw)', height: '82vh', background: 'var(--panel)', border: '1px solid var(--cyan-dim)', borderRadius: 'var(--radius-lg,10px)', overflow: 'hidden', boxShadow: '0 0 0 1px var(--line-soft), 0 24px 64px rgba(0,0,0,.6)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 16px', borderBottom: '1px solid var(--line)', background: 'var(--panel-2)' }}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: '50%', background: dot, boxShadow: `0 0 8px ${dot}` }}></span>
|
||||
<b style={{ fontSize: 13 }}>{agent.title}</b>
|
||||
<span style={{ fontSize: 10, letterSpacing: '.1em', color: 'var(--cyan)', border: '1px solid var(--cyan-dim)', padding: '0 6px', borderRadius: 3 }}>{agent.kind}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--faint)' }}>实时输出 · {status}</span>
|
||||
<span style={{ flex: 1 }}></span>
|
||||
<button onClick={onClose} style={{ fontFamily: 'var(--mono)', fontSize: 13, background: 'transparent', color: 'var(--muted)', border: 'none', cursor: 'pointer' }}>✕</button>
|
||||
</div>
|
||||
<div ref={boxRef} style={{ flex: 1, overflowY: 'auto', padding: '12px 16px', fontFamily: 'var(--mono)', fontSize: 12.5, lineHeight: 1.6, whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: 'var(--ink)' }}>
|
||||
{lines.length ? lines.map((l, i) => <div key={i} style={{ paddingBottom: 2 }}>{l}</div>)
|
||||
: <div style={{ color: 'var(--faint)' }}>等待输出…</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const I18N = window.MAESTRO_I18N;
|
||||
const [lang, setLang] = React.useState(() => {
|
||||
@@ -69,6 +124,7 @@ export function App() {
|
||||
// ── UI state(沿用原型)─────────────────────────────
|
||||
const [showConfig, setShowConfig] = React.useState(false);
|
||||
const [archiveItem, setArchiveItem] = React.useState(null);
|
||||
const [streamAgent, setStreamAgent] = React.useState(null);
|
||||
const [toasts, setToasts] = React.useState([]);
|
||||
const [theme, setTheme] = React.useState(() => localStorage.getItem('maestro-kit-theme') || 'dark');
|
||||
React.useEffect(() => {
|
||||
@@ -253,7 +309,7 @@ export function App() {
|
||||
onSave={saveConfig}
|
||||
onSync={onSync}
|
||||
onClose={() => setShowConfig(false)} /> : null}
|
||||
<window.MaestroKitAgentSection agents={agents} quota={quota} t={t} />
|
||||
<window.MaestroKitAgentSection agents={agents} quota={quota} t={t} onOpenStream={setStreamAgent} />
|
||||
<window.MaestroKitGateSection approvals={gates} onDecide={decide} t={t} />
|
||||
{tasks.length ? (
|
||||
<window.MaestroKitTaskSection tasks={tasks} onToast={toast} onCreate={createTask} t={t} />
|
||||
@@ -268,6 +324,7 @@ export function App() {
|
||||
<window.MaestroKitEventPanel events={events} collapsed={evCollapsed} onToggleCollapse={toggleEvents} t={t} />
|
||||
{archiveItem ? <window.MaestroKitArchiveModal item={archiveItem} t={t} onClose={() => setArchiveItem(null)} /> : null}
|
||||
{showNewProject ? <NewProjectModal t={t} lang={lang} onCreate={createProject} onClose={() => setShowNewProject(false)} /> : null}
|
||||
{streamAgent ? <StreamModal agent={streamAgent} onClose={() => setStreamAgent(null)} /> : null}
|
||||
<div style={{ position: 'fixed', bottom: 18, left: '50%', transform: 'translateX(-50%)', zIndex: 1200, display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center' }}>
|
||||
{toasts.map((x) => <Toast key={x.id} kind={x.kind}>{x.text}</Toast>)}
|
||||
</div>
|
||||
|
||||
@@ -154,7 +154,7 @@ function ConfigPanel({ project, onSave, onClose, onSync, t, lang }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AgentSection({ agents, quota, t }) {
|
||||
function AgentSection({ agents, quota, t, onOpenStream }) {
|
||||
const { SectionHead, QuotaMeter } = window.MaestroDesignSystem_a6a290;
|
||||
return (
|
||||
<section>
|
||||
@@ -176,7 +176,11 @@ function AgentSection({ agents, quota, t }) {
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'grid', gap: 10, minWidth: 0, alignContent: 'center' }}>
|
||||
{agents.map((a) => (
|
||||
<div key={a.id} style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: 12, padding: '3px 0' }}>
|
||||
<div key={a.id} onClick={() => onOpenStream && a.taskId && onOpenStream(a)}
|
||||
title={onOpenStream ? '点击查看实时输出' : undefined}
|
||||
style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: 12, padding: '3px 4px', borderRadius: 4, cursor: onOpenStream && a.taskId ? 'pointer' : 'default' }}
|
||||
onMouseEnter={(e) => { if (onOpenStream && a.taskId) e.currentTarget.style.background = 'var(--panel-2)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}>
|
||||
<span style={{ flex: 'none', width: 6, height: 6, borderRadius: '50%', background: 'var(--cyan)', boxShadow: '0 0 8px var(--cyan)', animation: 'maestro-pulse .9s infinite' }}></span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{a.title}</span>
|
||||
<span style={{ flex: 'none', fontSize: 10, letterSpacing: '.1em', color: 'var(--cyan)', border: '1px solid var(--cyan-dim)', padding: '0 6px', borderRadius: 3 }}>{a.kind}</span>
|
||||
|
||||
+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