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;
|
if (projectId && a.projectId !== projectId) continue;
|
||||||
for (const r of a.active || []) {
|
for (const r of a.active || []) {
|
||||||
out.push({
|
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 || '(运行中)',
|
title: r.title || r.taskTitle || r.taskId || '(运行中)',
|
||||||
kind: (r.kind || 'run').toUpperCase(),
|
kind: (r.kind || 'run').toUpperCase(),
|
||||||
time: TIME(r.startedAt || r.at), meta: `${r.worktree || ''} · ${r.model || ''}`,
|
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() {
|
export function App() {
|
||||||
const I18N = window.MAESTRO_I18N;
|
const I18N = window.MAESTRO_I18N;
|
||||||
const [lang, setLang] = React.useState(() => {
|
const [lang, setLang] = React.useState(() => {
|
||||||
@@ -69,6 +124,7 @@ export function App() {
|
|||||||
// ── UI state(沿用原型)─────────────────────────────
|
// ── UI state(沿用原型)─────────────────────────────
|
||||||
const [showConfig, setShowConfig] = React.useState(false);
|
const [showConfig, setShowConfig] = React.useState(false);
|
||||||
const [archiveItem, setArchiveItem] = React.useState(null);
|
const [archiveItem, setArchiveItem] = React.useState(null);
|
||||||
|
const [streamAgent, setStreamAgent] = React.useState(null);
|
||||||
const [toasts, setToasts] = React.useState([]);
|
const [toasts, setToasts] = React.useState([]);
|
||||||
const [theme, setTheme] = React.useState(() => localStorage.getItem('maestro-kit-theme') || 'dark');
|
const [theme, setTheme] = React.useState(() => localStorage.getItem('maestro-kit-theme') || 'dark');
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -253,7 +309,7 @@ export function App() {
|
|||||||
onSave={saveConfig}
|
onSave={saveConfig}
|
||||||
onSync={onSync}
|
onSync={onSync}
|
||||||
onClose={() => setShowConfig(false)} /> : null}
|
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} />
|
<window.MaestroKitGateSection approvals={gates} onDecide={decide} t={t} />
|
||||||
{tasks.length ? (
|
{tasks.length ? (
|
||||||
<window.MaestroKitTaskSection tasks={tasks} onToast={toast} onCreate={createTask} t={t} />
|
<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} />
|
<window.MaestroKitEventPanel events={events} collapsed={evCollapsed} onToggleCollapse={toggleEvents} t={t} />
|
||||||
{archiveItem ? <window.MaestroKitArchiveModal item={archiveItem} t={t} onClose={() => setArchiveItem(null)} /> : null}
|
{archiveItem ? <window.MaestroKitArchiveModal item={archiveItem} t={t} onClose={() => setArchiveItem(null)} /> : null}
|
||||||
{showNewProject ? <NewProjectModal t={t} lang={lang} onCreate={createProject} onClose={() => setShowNewProject(false)} /> : 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' }}>
|
<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>)}
|
{toasts.map((x) => <Toast key={x.id} kind={x.kind}>{x.text}</Toast>)}
|
||||||
</div>
|
</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;
|
const { SectionHead, QuotaMeter } = window.MaestroDesignSystem_a6a290;
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
@@ -176,7 +176,11 @@ function AgentSection({ agents, quota, t }) {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, display: 'grid', gap: 10, minWidth: 0, alignContent: 'center' }}>
|
<div style={{ flex: 1, display: 'grid', gap: 10, minWidth: 0, alignContent: 'center' }}>
|
||||||
{agents.map((a) => (
|
{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={{ 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={{ 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>
|
<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 { cleanupTaskRunArtifacts } from '../executor/cleanup.js';
|
||||||
import { resolveLogo, LOGO_MIME } from './logo.js';
|
import { resolveLogo, LOGO_MIME } from './logo.js';
|
||||||
import { readTranscript, TranscriptError } from '../executor/transcript.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 { homedir } from 'node:os';
|
||||||
import { join, basename } from 'node:path';
|
import { join, basename } from 'node:path';
|
||||||
import { pipeline } from 'node:stream/promises';
|
import { pipeline } from 'node:stream/promises';
|
||||||
@@ -284,6 +285,44 @@ export function buildServer(opts: ApiOptions): FastifyInstance {
|
|||||||
return store.listRuns(id);
|
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,逐行解析成脱敏后的结构化消息数组。
|
// 执行转录回放:读 run.transcriptRef 指向的 jsonl,逐行解析成脱敏后的结构化消息数组。
|
||||||
// 校验:runId 合法 → 关联任务/项目存在(可访问)→ 有转录记录 → 路径在 transcriptDir 下(防穿越)。
|
// 校验:runId 合法 → 关联任务/项目存在(可访问)→ 有转录记录 → 路径在 transcriptDir 下(防穿越)。
|
||||||
app.get('/api/runs/:id/transcript', (req, reply) => {
|
app.get('/api/runs/:id/transcript', (req, reply) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user