first commit
This commit is contained in:
12
async-agent-lab/web/index.html
Normal file
12
async-agent-lab/web/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Agent 为什么能边问边做</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/stage/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
290
async-agent-lab/web/src/App.tsx
Normal file
290
async-agent-lab/web/src/App.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, App as AntApp, Badge, Button, Card, Input, Segmented, Select, Slider, Space, Statistic, Tabs, Tag, Tooltip, Typography } from 'antd';
|
||||
import { CaretRightOutlined, DownloadOutlined, PauseOutlined, ReloadOutlined, SaveOutlined, StepBackwardOutlined, StepForwardOutlined, StopOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
||||
import type { TraceEvent } from '../../shared/types.ts';
|
||||
import { derive, fmtSec, openQuestions, pendingInbox, runningJobs } from './lib/derive.ts';
|
||||
import { Api, embedded, useEvents, useNow, type RecordingMeta, type ServerConfig, type Source } from './lib/source.ts';
|
||||
import { Chat } from './components/Chat.tsx';
|
||||
import { Timeline, type Selection } from './components/Timeline.tsx';
|
||||
import { Detail } from './components/Detail.tsx';
|
||||
import { ComparePanel, DiffPanel, InboxPanel, RequestsPanel, SourcePanel } from './components/Panels.tsx';
|
||||
|
||||
// 逐步查看时跳过的高频事件
|
||||
const NOISY = new Set<TraceEvent['type']>(['model.delta', 'tool.output', 'job.output', 'history.append', 'workspace.diff']);
|
||||
|
||||
export default function App() {
|
||||
const { message } = AntApp.useApp();
|
||||
const [config, setConfig] = useState<ServerConfig | null>(null);
|
||||
const [recordings, setRecordings] = useState<RecordingMeta[]>([]);
|
||||
const [level, setLevel] = useState(1);
|
||||
const [tasks, setTasks] = useState<Record<number, string>>({});
|
||||
const [source, setSource] = useState<Source>(() => (embedded ? { kind: 'replay', name: embedded.runId, recording: embedded } : { kind: 'none' }));
|
||||
const [selected, setSelected] = useState<Selection | null>(null);
|
||||
const [tab, setTab] = useState('detail');
|
||||
const [playT, setPlayT] = useState<number | null>(null); // null = 跟随最新
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [speed, setSpeed] = useState(2);
|
||||
const [starting, setStarting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (embedded) {
|
||||
setLevel(embedded.level);
|
||||
return;
|
||||
}
|
||||
Api.config().then(setConfig).catch((e) => message.error(`连不上本地服务:${e.message}`));
|
||||
Api.recordings().then(setRecordings).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const levelInfo = config?.levels.find((l) => l.id === level);
|
||||
const task = tasks[level] ?? levelInfo?.defaultTask ?? '';
|
||||
const { events } = useEvents(source);
|
||||
const live = source.kind === 'live';
|
||||
|
||||
const lastT = events.at(-1)?.t ?? 0;
|
||||
const count = playT === null ? events.length : upperBound(events, playT);
|
||||
const state = useMemo(() => derive(events, count), [events, count]);
|
||||
const startedAt = (events[0] as any)?.startedAt as number | undefined;
|
||||
const busy = state.phase !== 'idle' || runningJobs(state).length > 0 || openQuestions(state).length > 0 || pendingInbox(state).length > 0;
|
||||
const running = live && !state.stopped && busy;
|
||||
const liveNow = useNow(startedAt, running);
|
||||
const now = playT ?? (running ? Math.max(liveNow, lastT) : lastT);
|
||||
|
||||
// 回放:按真实时间推进
|
||||
useEffect(() => {
|
||||
if (!playing) return;
|
||||
const timer = setInterval(() => {
|
||||
setPlayT((t) => {
|
||||
const next = (t ?? 0) + 100 * speed;
|
||||
if (next >= lastT) {
|
||||
setPlaying(false);
|
||||
return null;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, 100);
|
||||
return () => clearInterval(timer);
|
||||
}, [playing, speed, lastT]);
|
||||
|
||||
const keyTimes = useMemo(() => [...new Set(events.filter((e) => !NOISY.has(e.type)).map((e) => e.t))], [events]);
|
||||
const stepTo = (dir: 1 | -1) => {
|
||||
setPlaying(false);
|
||||
const cur = playT ?? lastT;
|
||||
const next = dir > 0 ? keyTimes.find((t) => t > cur) : [...keyTimes].reverse().find((t) => t < cur);
|
||||
setPlayT(next === undefined ? (dir > 0 ? null : 0) : next >= lastT ? null : next);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.target as HTMLElement)?.tagName === 'INPUT' || (e.target as HTMLElement)?.tagName === 'TEXTAREA') return;
|
||||
if (e.key === 'ArrowRight') stepTo(1);
|
||||
if (e.key === 'ArrowLeft') stepTo(-1);
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
});
|
||||
|
||||
const start = async () => {
|
||||
setStarting(true);
|
||||
try {
|
||||
if (source.kind === 'live' && !state.stopped) await Api.stop(source.runId).catch(() => {});
|
||||
const { runId } = await Api.start(level, task);
|
||||
setSelected(null);
|
||||
setPlayT(null);
|
||||
setSource({ kind: 'live', runId });
|
||||
} catch (e: any) {
|
||||
message.error(e.message);
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openRecording = async (name: string) => {
|
||||
const recording = await Api.recording(name);
|
||||
if (source.kind === 'live' && !state.stopped) await Api.stop(source.runId).catch(() => {});
|
||||
setLevel(recording.level);
|
||||
setSelected(null);
|
||||
setPlaying(false);
|
||||
setPlayT(null);
|
||||
setSource({ kind: 'replay', name, recording });
|
||||
};
|
||||
|
||||
const select = (sel: Selection) => {
|
||||
setSelected(sel);
|
||||
setTab('detail');
|
||||
};
|
||||
|
||||
const statusTag =
|
||||
state.stopped ? <Tag>已停止</Tag>
|
||||
: state.phase === 'model' ? <Tag color="blue">请求模型 #{state.requests.at(-1)?.n}</Tag>
|
||||
: state.phase === 'tool' ? <Tag color="orange">等工具返回</Tag>
|
||||
: busy ? <Tag color="default">空闲,等收件箱</Tag>
|
||||
: state.requests.length ? <Tag color="green">本轮已结束</Tag> : <Tag>未开始</Tag>;
|
||||
|
||||
const replayInfo = source.kind === 'replay';
|
||||
const shownLevel = replayInfo ? source.recording.level : level;
|
||||
const shownInfo = config?.levels.find((l) => l.id === shownLevel) ?? (embedded ? undefined : levelInfo);
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#f5f5f5' }}>
|
||||
<div style={{ background: '#fff', borderBottom: '1px solid #f0f0f0', padding: '8px 16px', display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>Agent 为什么能边问边做</Typography.Title>
|
||||
{!embedded ? (
|
||||
<Segmented
|
||||
value={level}
|
||||
onChange={(v) => setLevel(Number(v))}
|
||||
options={(config?.levels ?? []).map((l) => ({ value: l.id, label: `${l.id}. ${l.name}` }))}
|
||||
/>
|
||||
) : (
|
||||
<Tag color="purple">单文件回放 · 第 {shownLevel} 级</Tag>
|
||||
)}
|
||||
<div style={{ flex: 1 }} />
|
||||
{config ? (
|
||||
<Tooltip title={config.provider.baseURL}>
|
||||
<Tag icon={<ThunderboltOutlined />} color="geekblue">{config.provider.displayName} · {config.provider.model}</Tag>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{!embedded ? (
|
||||
<>
|
||||
<Button type="primary" icon={<CaretRightOutlined />} loading={starting} onClick={start} disabled={!config}>
|
||||
{live ? '重新开始' : '开始'}
|
||||
</Button>
|
||||
<Button icon={<StopOutlined />} disabled={!live || !!state.stopped} onClick={() => source.kind === 'live' && Api.stop(source.runId)}>
|
||||
停止
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => { if (source.kind === 'live' && !state.stopped) Api.stop(source.runId); setSource({ kind: 'none' }); setSelected(null); setPlayT(null); }}>
|
||||
重置
|
||||
</Button>
|
||||
<Select
|
||||
placeholder="打开录制"
|
||||
style={{ width: 220 }}
|
||||
value={source.kind === 'replay' ? source.name : undefined}
|
||||
onChange={openRecording}
|
||||
onDropdownVisibleChange={(open) => open && Api.recordings().then(setRecordings)}
|
||||
options={recordings.map((r) => ({ value: r.name, label: `L${r.level} · ${r.name}(${fmtSec(r.durationMs)})` }))}
|
||||
/>
|
||||
<Button
|
||||
icon={<SaveOutlined />}
|
||||
disabled={source.kind !== 'live'}
|
||||
onClick={async () => {
|
||||
if (source.kind !== 'live') return;
|
||||
const { name } = await Api.save(source.runId, source.runId);
|
||||
setRecordings(await Api.recordings());
|
||||
message.success(`已保存录制 ${name},可在「打开录制」和「对比」里选`);
|
||||
}}
|
||||
>
|
||||
保存录制
|
||||
</Button>
|
||||
<Button
|
||||
icon={<DownloadOutlined />}
|
||||
disabled={source.kind === 'none'}
|
||||
href={source.kind === 'live' ? Api.exportUrl(source.runId) : source.kind === 'replay' ? Api.exportRecordingUrl(source.name) : undefined}
|
||||
>
|
||||
导出回放
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: 12 }}>
|
||||
{shownInfo ? (
|
||||
<Card size="small" style={{ marginBottom: 10 }}>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Space wrap>
|
||||
<Typography.Text strong style={{ fontSize: 15 }}>第 {shownInfo.id} 级:{shownInfo.title}</Typography.Text>
|
||||
{shownInfo.tools.map((t) => <Tag key={t}>{t}</Tag>)}
|
||||
<Typography.Link onClick={() => setTab('source')}>看这一级的源码</Typography.Link>
|
||||
</Space>
|
||||
<Typography.Text><b>新增:</b>{shownInfo.adds}</Typography.Text>
|
||||
<Typography.Text type="secondary"><b>看点:</b>{shownInfo.watch}</Typography.Text>
|
||||
{!replayInfo && !embedded ? (
|
||||
<Input.TextArea
|
||||
value={task}
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
onChange={(e) => setTasks({ ...tasks, [level]: e.target.value })}
|
||||
style={{ fontSize: 13 }}
|
||||
placeholder="任务"
|
||||
/>
|
||||
) : (
|
||||
<Typography.Text><b>任务:</b>{source.kind === 'replay' ? source.recording.task : ''}</Typography.Text>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
) : embedded ? (
|
||||
<Card size="small" style={{ marginBottom: 10 }}>
|
||||
<Typography.Text><b>任务:</b>{embedded.task}</Typography.Text>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{state.error ? <Alert type="error" showIcon message={state.error} style={{ marginBottom: 10 }} /> : null}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(360px, 34%) 1fr', gap: 12 }}>
|
||||
<Card size="small" title={<Space>对话 {statusTag}</Space>} styles={{ body: { padding: 0, height: 'calc(100vh - 250px)', minHeight: 520 } }}>
|
||||
<Chat
|
||||
state={state}
|
||||
now={now}
|
||||
live={live && !state.stopped && playT === null}
|
||||
onSend={(text) => source.kind === 'live' && Api.message(source.runId, text).catch((e) => message.error(e.message))}
|
||||
onAnswer={(id, answer) => source.kind === 'live' && Api.answer(source.runId, id, answer).catch((e) => message.error(e.message))}
|
||||
onSelect={select}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Card size="small" style={{ marginBottom: 10 }}>
|
||||
<Space size={28} wrap style={{ marginBottom: 6 }}>
|
||||
<Statistic title="模型请求" value={state.requests.length ? `#${state.requests.at(-1)!.n}` : '-'} />
|
||||
<Statistic title="Loop 状态" valueRender={() => statusTag} />
|
||||
<Statistic title="后台任务运行中" value={runningJobs(state).length} />
|
||||
<Statistic title="待回答的问题" value={openQuestions(state).length} />
|
||||
<Statistic title="收件箱排队" value={pendingInbox(state).length} />
|
||||
<Statistic title="已运行" value={fmtSec(now)} />
|
||||
</Space>
|
||||
<Timeline state={state} now={now} selected={selected} onSelect={select} />
|
||||
{source.kind !== 'none' ? (
|
||||
<Space style={{ marginTop: 8, width: '100%' }}>
|
||||
<Tooltip title="上一个事件(←)"><Button size="small" icon={<StepBackwardOutlined />} onClick={() => stepTo(-1)} /></Tooltip>
|
||||
<Button size="small" icon={playing ? <PauseOutlined /> : <CaretRightOutlined />} onClick={() => { if (playT === null) setPlayT(0); setPlaying(!playing); }}>
|
||||
{playing ? '暂停' : '播放'}
|
||||
</Button>
|
||||
<Tooltip title="下一个事件(→)"><Button size="small" icon={<StepForwardOutlined />} onClick={() => stepTo(1)} /></Tooltip>
|
||||
<Segmented size="small" value={speed} onChange={(v) => setSpeed(Number(v))} options={[1, 2, 4, 8].map((s) => ({ value: s, label: `${s}×` }))} />
|
||||
<Slider style={{ flex: 1, minWidth: 200 }} min={0} max={Math.max(lastT, 1)} step={100} value={playT ?? lastT} tooltip={{ formatter: (v) => fmtSec(v ?? 0) }} onChange={(v) => { setPlaying(false); setPlayT(v >= lastT ? null : v); }} />
|
||||
{playT !== null ? <Button size="small" onClick={() => { setPlaying(false); setPlayT(null); }}>{live ? '回到实时' : '跳到结尾'}</Button> : <Badge status={running ? 'processing' : 'default'} text={live ? (running ? '实时' : '最新') : '结尾'} />}
|
||||
</Space>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<Card size="small" styles={{ body: { paddingTop: 0 } }}>
|
||||
<Tabs
|
||||
activeKey={tab}
|
||||
onChange={setTab}
|
||||
items={[
|
||||
{ key: 'detail', label: '详情', children: <Detail state={state} sel={selected} /> },
|
||||
{ key: 'requests', label: `请求记录(${state.requests.length})`, children: <RequestsPanel state={state} /> },
|
||||
{ key: 'inbox', label: `收件箱(${state.inbox.size})`, children: <InboxPanel state={state} onSelect={select} /> },
|
||||
{ key: 'diff', label: '代码改动', children: <DiffPanel diff={state.diff} /> },
|
||||
...(!embedded ? [
|
||||
{ key: 'source', label: '源码', children: <SourcePanel focus={shownInfo?.source ?? []} /> },
|
||||
{ key: 'compare', label: '对比', children: <ComparePanel recordings={recordings} load={Api.recording} current={source.kind === 'live' ? { label: `当前 ${source.runId}`, state } : undefined} /> },
|
||||
{ key: 'prompt', label: '系统提示词', children: <pre style={{ whiteSpace: 'pre-wrap', fontSize: 12 }}>{shownInfo && 'systemPrompt' in shownInfo ? (shownInfo as any).systemPrompt : ''}</pre> },
|
||||
] : []),
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function upperBound(events: TraceEvent[], t: number) {
|
||||
let lo = 0;
|
||||
let hi = events.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (events[mid].t <= t) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
287
async-agent-lab/web/src/components/Chat.tsx
Normal file
287
async-agent-lab/web/src/components/Chat.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
// 左栏:对话、工具卡片、问题卡、输入框。
|
||||
import { Alert, Button, Collapse, Input, Space, Spin, Tag, Typography } from 'antd';
|
||||
import { CheckCircleFilled, ClockCircleOutlined, CloudServerOutlined, InboxOutlined, QuestionCircleFilled, RobotOutlined, ToolOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { fmtSec, openQuestions, type ChatItem, type InboxEntry, type RunState } from '../lib/derive.ts';
|
||||
import { COLORS, type Selection } from './Timeline.tsx';
|
||||
|
||||
export function Chat(props: {
|
||||
state: RunState;
|
||||
now: number;
|
||||
live: boolean;
|
||||
onSend: (text: string) => void;
|
||||
onAnswer: (questionId: string, answer: string) => void;
|
||||
onSelect: (sel: Selection) => void;
|
||||
}) {
|
||||
const { state, now, live, onSend, onAnswer, onSelect } = props;
|
||||
const scroller = useRef<HTMLDivElement>(null);
|
||||
const stick = useRef(true);
|
||||
const [draft, setDraft] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const el = scroller.current;
|
||||
if (el && stick.current) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
|
||||
const questions = openQuestions(state);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div
|
||||
ref={scroller}
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget;
|
||||
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 60;
|
||||
}}
|
||||
style={{ flex: 1, overflowY: 'auto', padding: '8px 12px' }}
|
||||
>
|
||||
{state.chat.length === 0 ? (
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: 40, textAlign: 'center' }}>
|
||||
选一个级别,点「开始」。Agent 会对一份真实的订单服务代码干活。
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
{state.chat.map((item) => (
|
||||
<ChatRow key={item.key} item={item} state={state} now={now} onSelect={onSelect} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{questions.map((q) => (
|
||||
<QuestionCard key={q.questionId} q={q} now={now} disabled={!live} onAnswer={onAnswer} state={state} />
|
||||
))}
|
||||
|
||||
<div style={{ padding: 10, borderTop: '1px solid #f0f0f0', background: '#fafafa' }}>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
value={draft}
|
||||
disabled={!live}
|
||||
placeholder={live ? '随时插话:消息先进收件箱,Loop 在步与步之间取走' : '回放模式'}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onPressEnter={() => {
|
||||
if (!draft.trim()) return;
|
||||
onSend(draft.trim());
|
||||
setDraft('');
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!live || !draft.trim()}
|
||||
onClick={() => {
|
||||
onSend(draft.trim());
|
||||
setDraft('');
|
||||
}}
|
||||
>
|
||||
发送
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InboxStatus({ entry }: { entry: InboxEntry }) {
|
||||
if (entry.deliveredAt === undefined) {
|
||||
return (
|
||||
<Tag icon={<InboxOutlined />} color="purple">
|
||||
在收件箱排队,Loop 还没取走
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
if (entry.seenByModel === undefined) return <Tag color="purple">已加进历史,等下一次请求</Tag>;
|
||||
return (
|
||||
<Tag color="blue">
|
||||
排队 {fmtSec(entry.deliveredAt - entry.pushedAt)} · 模型在 #{entry.seenByModel} 看到
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatRow({ item, state, now, onSelect }: { item: ChatItem; state: RunState; now: number; onSelect: (sel: Selection) => void }) {
|
||||
const time = <Typography.Text type="secondary" style={{ fontSize: 11 }}>{fmtSec(item.t)}</Typography.Text>;
|
||||
|
||||
if (item.kind === 'user') {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', margin: '10px 0' }}>
|
||||
<div style={{ maxWidth: '88%' }}>
|
||||
<div style={{ textAlign: 'right', marginBottom: 2 }}>
|
||||
{time} <UserOutlined /> {item.isTask ? '任务' : '插话'}
|
||||
</div>
|
||||
<div style={{ background: '#e6f4ff', borderRadius: 10, padding: '8px 12px', whiteSpace: 'pre-wrap' }}>{item.text}</div>
|
||||
{!item.isTask ? (
|
||||
<div style={{ textAlign: 'right', marginTop: 4, cursor: 'pointer' }} onClick={() => onSelect({ kind: 'inbox', id: item.inbox.id })}>
|
||||
<InboxStatus entry={item.inbox} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.kind === 'assistant') {
|
||||
const r = item.req;
|
||||
const streaming = r.end === undefined;
|
||||
return (
|
||||
<div style={{ margin: '10px 0' }}>
|
||||
<div style={{ marginBottom: 2 }}>
|
||||
<RobotOutlined style={{ color: COLORS.model }} />{' '}
|
||||
<Tag color="blue" style={{ cursor: 'pointer' }} onClick={() => onSelect({ kind: 'request', id: r.n })}>
|
||||
模型请求 #{r.n}
|
||||
</Tag>
|
||||
{time}
|
||||
{streaming ? <Spin size="small" style={{ marginLeft: 8 }} /> : null}
|
||||
{r.end !== undefined ? (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11, marginLeft: 6 }}>
|
||||
{fmtSec(r.end - r.start)}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
{r.reasoning ? (
|
||||
<Collapse
|
||||
size="small"
|
||||
ghost
|
||||
items={[{ key: 'r', label: <Typography.Text type="secondary" style={{ fontSize: 12 }}>思考过程({r.reasoning.length} 字)</Typography.Text>, children: <div style={{ fontSize: 12, color: '#888', whiteSpace: 'pre-wrap' }}>{r.reasoning}</div> }]}
|
||||
/>
|
||||
) : null}
|
||||
{r.content ? (
|
||||
<div className="md" style={{ background: '#fff', border: '1px solid #f0f0f0', borderRadius: 10, padding: '4px 12px' }}>
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{r.content}</Markdown>
|
||||
</div>
|
||||
) : null}
|
||||
{r.error ? <Alert type="error" showIcon message={r.error} /> : null}
|
||||
{state.forged.some((f) => f.n === r.n) ? (
|
||||
<Alert type="warning" showIcon style={{ marginTop: 4 }} message="这条回复里模型自己写出了系统通知标签,内容是编的。运行时已提醒它作废。" />
|
||||
) : forgedTag(r.content) && r.end ? (
|
||||
<Alert type="warning" showIcon style={{ marginTop: 4 }} message={`这条回复里模型自己写出了 <${forgedTag(r.content)}>:内容是编的,真正的通知还没到。`} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.kind === 'tool') {
|
||||
const t = item.tool;
|
||||
const running = t.end === undefined;
|
||||
const job = t.jobId ? state.jobs.get(t.jobId) : undefined;
|
||||
const q = t.questionId ? state.questions.get(t.questionId) : undefined;
|
||||
const title =
|
||||
t.name === 'bash' ? String(t.args?.command ?? '') : t.name === 'edit_file' ? `edit_file ${t.args?.path ?? ''}` : t.name === 'question' ? `question:${t.args?.question ?? ''}` : t.name;
|
||||
const sel: Selection = q ? { kind: 'question', id: q.questionId } : job ? { kind: 'job', id: job.jobId } : { kind: 'tool', id: t.callId };
|
||||
const output = t.output || t.result || '';
|
||||
return (
|
||||
<div style={{ margin: '4px 0 4px 18px' }}>
|
||||
<div
|
||||
onClick={() => onSelect(sel)}
|
||||
style={{ cursor: 'pointer', border: `1px solid ${running ? COLORS.tool : '#f0f0f0'}`, borderRadius: 8, padding: '4px 8px', background: running ? '#fff7e6' : '#fcfcfc', fontSize: 12 }}
|
||||
>
|
||||
<Space size={6} wrap>
|
||||
<ToolOutlined style={{ color: t.name === 'question' ? COLORS.question : COLORS.tool }} />
|
||||
<code style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>{title.length > 160 ? `${title.slice(0, 160)}…` : title}</code>
|
||||
{running ? (
|
||||
<Tag color="orange" icon={<ClockCircleOutlined />}>
|
||||
工具未返回 {fmtSec(now - t.start)}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag icon={<CheckCircleFilled />} color="default">
|
||||
返回 {fmtSec(t.end! - t.start)}
|
||||
</Tag>
|
||||
)}
|
||||
{job ? (
|
||||
<Tag color="cyan" icon={<CloudServerOutlined />}>
|
||||
{job.jobId} {job.end === undefined ? `后台运行中 ${fmtSec(now - job.start)}` : `已退出 exit ${job.exitCode}`}
|
||||
</Tag>
|
||||
) : null}
|
||||
{q ? <Tag color="magenta">{q.mode === 'blocking' ? '阻塞提问' : '异步提问'} {q.questionId}</Tag> : null}
|
||||
</Space>
|
||||
{t.name === 'bash' && !job && output ? (
|
||||
<pre style={{ margin: '4px 0 0', maxHeight: running ? 160 : 90, overflow: 'auto', fontSize: 11, background: '#1f1f1f', color: '#d9d9d9', padding: 6, borderRadius: 6 }}>
|
||||
{tailLines(output, running ? 12 : 6)}
|
||||
</pre>
|
||||
) : null}
|
||||
{job && job.end === undefined && job.output ? (
|
||||
<pre style={{ margin: '4px 0 0', maxHeight: 120, overflow: 'auto', fontSize: 11, background: '#002329', color: '#b5f5ec', padding: 6, borderRadius: 6 }}>
|
||||
{tailLines(job.output, 8)}
|
||||
</pre>
|
||||
) : null}
|
||||
{(job || q) && t.result ? (
|
||||
<div style={{ marginTop: 3, color: '#888' }}>
|
||||
工具立刻返回:<code>{t.result.length > 90 ? `${t.result.slice(0, 90)}…` : t.result}</code>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.kind === 'job_done') {
|
||||
const job = state.jobs.get(item.inbox.ref ?? '');
|
||||
return (
|
||||
<div style={{ margin: '8px 0', padding: '6px 10px', borderLeft: `3px solid ${COLORS.job}`, background: '#e6fffb', borderRadius: 6, fontSize: 12, cursor: 'pointer' }} onClick={() => onSelect({ kind: 'job', id: item.inbox.ref ?? '' })}>
|
||||
<Space wrap size={6}>
|
||||
<CloudServerOutlined style={{ color: COLORS.job }} />
|
||||
<b>{item.inbox.ref} 退出</b>
|
||||
<span>exit {job?.exitCode ?? '?'} · 跑了 {job?.end ? fmtSec(job.end - job.start) : '?'}</span>
|
||||
{time}
|
||||
<span>→ 完成通知进收件箱</span>
|
||||
<InboxStatus entry={item.inbox} />
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// answer
|
||||
const q = item.question;
|
||||
return (
|
||||
<div style={{ margin: '8px 0', padding: '6px 10px', borderLeft: `3px solid ${COLORS.question}`, background: '#fff0f6', borderRadius: 6, fontSize: 12, cursor: 'pointer' }} onClick={() => onSelect({ kind: 'question', id: q.questionId })}>
|
||||
<Space wrap size={6}>
|
||||
<QuestionCircleFilled style={{ color: COLORS.question }} />
|
||||
<b>你回答了 {q.questionId}:{q.answer}</b>
|
||||
{time}
|
||||
{q.mode === 'blocking' ? <span>→ 作为 question 工具的返回值,工具这才结束</span> : <span>→ 作为带 question_id 的用户消息进收件箱</span>}
|
||||
{item.inbox ? <InboxStatus entry={item.inbox} /> : q.seenByModel ? <Tag color="blue">模型在 #{q.seenByModel} 看到</Tag> : null}
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionCard({ q, now, disabled, onAnswer, state }: { q: ReturnType<typeof openQuestions>[number]; now: number; disabled: boolean; onAnswer: (id: string, a: string) => void; state: RunState }) {
|
||||
const [custom, setCustom] = useState('');
|
||||
const blocking = q.mode === 'blocking';
|
||||
const working = state.requests.filter((r) => r.start >= q.askedAt).length;
|
||||
return (
|
||||
<div style={{ margin: '0 10px 8px', border: `2px solid ${COLORS.question}`, borderRadius: 10, padding: 10, background: '#fff' }}>
|
||||
<Space style={{ marginBottom: 6 }} wrap>
|
||||
<QuestionCircleFilled style={{ color: COLORS.question }} />
|
||||
<Tag color="magenta">{q.questionId}</Tag>
|
||||
{blocking ? (
|
||||
<Tag color="red">阻塞:question 工具还没返回,Agent 停在这里</Tag>
|
||||
) : (
|
||||
<Tag color="green">异步:工具已返回 accepted,提问后模型又请求了 {working} 次</Tag>
|
||||
)}
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
已挂 {fmtSec(now - q.askedAt)}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
<div style={{ fontWeight: 500, marginBottom: 8 }}>{q.question}</div>
|
||||
<Space wrap>
|
||||
{q.options.map((opt) => (
|
||||
<Button key={opt} disabled={disabled} onClick={() => onAnswer(q.questionId, opt)}>
|
||||
{opt}
|
||||
</Button>
|
||||
))}
|
||||
<Space.Compact>
|
||||
<Input size="middle" placeholder="其他回答" value={custom} disabled={disabled} onChange={(e) => setCustom(e.target.value)} style={{ width: 140 }} />
|
||||
<Button disabled={disabled || !custom.trim()} onClick={() => onAnswer(q.questionId, custom.trim())}>
|
||||
回答
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function forgedTag(text: string) {
|
||||
return text.match(/<(background_job_done|question_reply)\b/)?.[1];
|
||||
}
|
||||
|
||||
function tailLines(text: string, n: number) {
|
||||
return text.trimEnd().split('\n').slice(-n).join('\n');
|
||||
}
|
||||
230
async-agent-lab/web/src/components/Detail.tsx
Normal file
230
async-agent-lab/web/src/components/Detail.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
// 详情:模型请求(发了什么、回了什么)、工具、后台任务、问题、收件箱条目。
|
||||
import { Descriptions, Empty, Segmented, Space, Steps, Tag, Typography } from 'antd';
|
||||
import JsonView from '@uiw/react-json-view';
|
||||
import { useState } from 'react';
|
||||
import type { Message } from '../../../shared/types.ts';
|
||||
import { fmtSec, type ModelRequest, type RunState } from '../lib/derive.ts';
|
||||
import { COLORS, inboxLabel, keyMoments, type Selection } from './Timeline.tsx';
|
||||
|
||||
const ROLE_COLOR: Record<string, string> = { system: 'default', user: 'blue', assistant: 'green', tool: 'orange' };
|
||||
|
||||
export function MessageView({ m, index, fresh }: { m: Message; index: number; fresh: boolean }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const content = m.content ?? '';
|
||||
const long = content.length > 400;
|
||||
const label =
|
||||
m.role === 'user' && content.startsWith('<background_job_done')
|
||||
? 'user · 后台完成通知'
|
||||
: m.role === 'user' && content.startsWith('<question_reply')
|
||||
? 'user · 问题答复'
|
||||
: m.role === 'tool'
|
||||
? `tool · ${m.tool_call_id.slice(-8)}`
|
||||
: m.role;
|
||||
return (
|
||||
<div style={{ borderLeft: `3px solid ${fresh ? '#52c41a' : '#f0f0f0'}`, background: fresh ? '#f6ffed' : undefined, padding: '4px 8px', marginBottom: 6, borderRadius: 4 }}>
|
||||
<Space size={4}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>[{index}]</Typography.Text>
|
||||
<Tag color={ROLE_COLOR[m.role]}>{label}</Tag>
|
||||
{fresh ? <Tag color="green">本次新增</Tag> : null}
|
||||
</Space>
|
||||
{content ? (
|
||||
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all', fontSize: 12, margin: '4px 0 0', maxHeight: open ? undefined : 160, overflow: 'hidden' }}>
|
||||
{open || !long ? content : `${content.slice(0, 400)}…`}
|
||||
</pre>
|
||||
) : null}
|
||||
{long ? <Typography.Link style={{ fontSize: 12 }} onClick={() => setOpen(!open)}>{open ? '收起' : `展开全部 ${content.length} 字`}</Typography.Link> : null}
|
||||
{m.role === 'assistant' && m.tool_calls?.length ? (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
{m.tool_calls.map((c) => (
|
||||
<div key={c.id} style={{ fontSize: 12 }}>
|
||||
<Tag color="orange">tool_call · {c.id.slice(-8)}</Tag>
|
||||
<code style={{ wordBreak: 'break-all' }}>{c.function.name}({c.function.arguments.slice(0, 300)})</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RequestDetail({ req, state }: { req: ModelRequest; state: RunState }) {
|
||||
const [view, setView] = useState<'new' | 'all' | 'raw'>('new');
|
||||
const body = { model: state.model, stream: true, temperature: 0, messages: req.messages, tools: state.toolSpecs };
|
||||
return (
|
||||
<div>
|
||||
<Descriptions size="small" column={2} bordered style={{ marginBottom: 12 }}>
|
||||
<Descriptions.Item label="请求">#{req.n}</Descriptions.Item>
|
||||
<Descriptions.Item label="发出">{fmtSec(req.start)}</Descriptions.Item>
|
||||
<Descriptions.Item label="耗时">{req.end ? fmtSec(req.end - req.start) : '进行中'}</Descriptions.Item>
|
||||
<Descriptions.Item label="首包">{req.ttftMs ? `${req.ttftMs}ms` : '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="消息">共 {req.messages.length} 条,新增 {req.messages.length - req.newFrom} 条</Descriptions.Item>
|
||||
<Descriptions.Item label="tokens">
|
||||
{req.usage ? `入 ${req.usage.prompt_tokens}(缓存 ${req.usage.cached_tokens ?? 0})/ 出 ${req.usage.completion_tokens}` : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="接口" span={2}>
|
||||
<code>POST {state.baseURL}/chat/completions</code>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Segmented
|
||||
value={view}
|
||||
onChange={(v) => setView(v as typeof view)}
|
||||
options={[
|
||||
{ label: '本次新增的消息', value: 'new' },
|
||||
{ label: '完整历史', value: 'all' },
|
||||
{ label: '原始请求体', value: 'raw' },
|
||||
]}
|
||||
style={{ marginBottom: 10 }}
|
||||
/>
|
||||
{view === 'raw' ? (
|
||||
<JsonView value={body} collapsed={3} displayDataTypes={false} shortenTextAfterLength={160} style={{ fontSize: 12 }} />
|
||||
) : (
|
||||
(view === 'new' ? req.messages.slice(req.newFrom) : req.messages).map((m, i) => {
|
||||
const index = view === 'new' ? req.newFrom + i : i;
|
||||
return <MessageView key={index} m={m} index={index} fresh={index >= req.newFrom} />;
|
||||
})
|
||||
)}
|
||||
<Typography.Title level={5} style={{ marginTop: 16 }}>模型返回</Typography.Title>
|
||||
{req.reasoning ? (
|
||||
<details style={{ marginBottom: 6 }}>
|
||||
<summary style={{ color: '#888', fontSize: 12 }}>reasoning_content({req.reasoning.length} 字)</summary>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', fontSize: 12, color: '#888' }}>{req.reasoning}</pre>
|
||||
</details>
|
||||
) : null}
|
||||
<pre style={{ whiteSpace: 'pre-wrap', fontSize: 12, background: '#fafafa', padding: 8, borderRadius: 6 }}>{req.content || '(没有文字)'}</pre>
|
||||
{req.toolCalls.map((c) => (
|
||||
<div key={c.id} style={{ fontSize: 12, marginBottom: 4 }}>
|
||||
<Tag color="orange">{c.name}</Tag>
|
||||
<code style={{ wordBreak: 'break-all' }}>{c.args}</code>
|
||||
</div>
|
||||
))}
|
||||
{req.end !== undefined && !req.toolCalls.length ? <Tag>没有工具调用 → 这一轮结束,Loop 回到空闲</Tag> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Moments({ state, sel }: { state: RunState; sel: Selection }) {
|
||||
const moments = keyMoments(state, sel);
|
||||
if (!moments.length) return null;
|
||||
return (
|
||||
<Steps
|
||||
size="small"
|
||||
direction="vertical"
|
||||
style={{ margin: '12px 0' }}
|
||||
items={moments.map((m) => ({ title: `${m.label}`, description: fmtSec(m.t), status: 'finish' as const }))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Detail({ state, sel }: { state: RunState; sel: Selection | null }) {
|
||||
if (!sel) return <Empty description="在时间线或对话里点一项" />;
|
||||
|
||||
if (sel.kind === 'request') {
|
||||
const req = state.requests.find((r) => r.n === sel.id);
|
||||
return req ? <RequestDetail req={req} state={state} /> : <Empty />;
|
||||
}
|
||||
|
||||
if (sel.kind === 'tool') {
|
||||
const t = state.tools.get(sel.id);
|
||||
if (!t) return <Empty />;
|
||||
return (
|
||||
<div>
|
||||
<Descriptions size="small" column={1} bordered>
|
||||
<Descriptions.Item label="工具">{t.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="由谁发起">模型请求 #{t.n}</Descriptions.Item>
|
||||
<Descriptions.Item label="开始 → 返回">
|
||||
{fmtSec(t.start)} → {t.end !== undefined ? `${fmtSec(t.end)}(${fmtSec(t.end - t.start)})` : '还没返回'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Typography.Title level={5}>参数</Typography.Title>
|
||||
<JsonView value={typeof t.args === 'object' && t.args ? t.args : { raw: t.args }} displayDataTypes={false} style={{ fontSize: 12 }} />
|
||||
<Typography.Title level={5}>返回给模型的内容</Typography.Title>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', fontSize: 12, background: '#1f1f1f', color: '#d9d9d9', padding: 8, borderRadius: 6, maxHeight: 420, overflow: 'auto' }}>
|
||||
{t.result ?? t.output ?? ''}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (sel.kind === 'job') {
|
||||
const job = state.jobs.get(sel.id);
|
||||
if (!job) return <Empty />;
|
||||
const tool = state.tools.get(job.callId);
|
||||
const notice = [...state.inbox.values()].find((i) => i.kind === 'job_done' && i.ref === job.jobId);
|
||||
return (
|
||||
<div>
|
||||
<Descriptions size="small" column={1} bordered>
|
||||
<Descriptions.Item label="后台任务">{job.jobId}</Descriptions.Item>
|
||||
<Descriptions.Item label="命令"><code>{job.command}</code></Descriptions.Item>
|
||||
<Descriptions.Item label="由谁发起">模型请求 #{tool?.n}</Descriptions.Item>
|
||||
<Descriptions.Item label="运行">
|
||||
{fmtSec(job.start)} → {job.end !== undefined ? `${fmtSec(job.end)}(${fmtSec(job.end - job.start)}),exit ${job.exitCode}` : '运行中'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="工具当场返回"><code>{tool?.result}</code></Descriptions.Item>
|
||||
<Descriptions.Item label="运行期间">模型又请求了 {state.requests.filter((r) => r.start >= job.start && r.start < (job.end ?? Infinity)).length} 次</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Moments state={state} sel={sel} />
|
||||
{notice ? (
|
||||
<>
|
||||
<Typography.Title level={5}>完成通知(作为 user 消息进历史)</Typography.Title>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', fontSize: 12, background: '#e6fffb', padding: 8, borderRadius: 6 }}>{notice.content}</pre>
|
||||
</>
|
||||
) : null}
|
||||
<Typography.Title level={5}>完整输出</Typography.Title>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', fontSize: 12, background: '#002329', color: '#b5f5ec', padding: 8, borderRadius: 6, maxHeight: 360, overflow: 'auto' }}>{job.output}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (sel.kind === 'question') {
|
||||
const q = state.questions.get(sel.id);
|
||||
if (!q) return <Empty />;
|
||||
const tool = state.tools.get(q.callId);
|
||||
const reply = [...state.inbox.values()].find((i) => i.kind === 'question_reply' && i.ref === q.questionId);
|
||||
const during = state.requests.filter((r) => r.start >= q.askedAt && r.start < (q.answeredAt ?? Infinity));
|
||||
return (
|
||||
<div>
|
||||
<Descriptions size="small" column={1} bordered>
|
||||
<Descriptions.Item label="问题">{q.question}</Descriptions.Item>
|
||||
<Descriptions.Item label="方式">
|
||||
{q.mode === 'blocking' ? <Tag color="red">阻塞:工具等到回答才返回</Tag> : <Tag color="green">异步:工具先回执,答案稍后进收件箱</Tag>}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="工具返回">
|
||||
{tool?.end !== undefined ? `${fmtSec(tool.end)}:` : '还没返回'}
|
||||
<code>{tool?.result}</code>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="回答">{q.answer ? `${fmtSec(q.answeredAt!)}:${q.answer}` : '还没回答'}</Descriptions.Item>
|
||||
<Descriptions.Item label="等待期间">
|
||||
模型请求了 {during.length} 次{during.length ? `(#${during.map((r) => r.n).join('、#')})` : ''}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Moments state={state} sel={sel} />
|
||||
{reply ? (
|
||||
<>
|
||||
<Typography.Title level={5}>答案进入历史的样子</Typography.Title>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', fontSize: 12, background: '#fff0f6', padding: 8, borderRadius: 6 }}>{`{ "role": "user", "content": ${JSON.stringify(reply.content)} }`}</pre>
|
||||
</>
|
||||
) : q.mode === 'blocking' && tool?.result ? (
|
||||
<>
|
||||
<Typography.Title level={5}>答案进入历史的样子</Typography.Title>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', fontSize: 12, background: '#fff0f6', padding: 8, borderRadius: 6 }}>{`{ "role": "tool", "tool_call_id": "${q.callId}", "content": ${JSON.stringify(tool.result)} }`}</pre>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const entry = state.inbox.get(sel.id);
|
||||
if (!entry) return <Empty />;
|
||||
return (
|
||||
<div>
|
||||
<Descriptions size="small" column={1} bordered>
|
||||
<Descriptions.Item label="类型">{inboxLabel(entry.kind, entry.ref)}</Descriptions.Item>
|
||||
<Descriptions.Item label="进收件箱">{fmtSec(entry.pushedAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="加进历史">{entry.deliveredAt !== undefined ? `${fmtSec(entry.deliveredAt)}(排队 ${fmtSec(entry.deliveredAt - entry.pushedAt)})` : '还在排队'}</Descriptions.Item>
|
||||
<Descriptions.Item label="模型看到">{entry.seenByModel ? `请求 #${entry.seenByModel}(${fmtSec(entry.seenAt!)})` : '-'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Moments state={state} sel={sel} />
|
||||
<pre style={{ whiteSpace: 'pre-wrap', fontSize: 12, background: '#f9f0ff', padding: 8, borderRadius: 6, borderLeft: `3px solid ${COLORS.inbox}` }}>{entry.content}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
200
async-agent-lab/web/src/components/Panels.tsx
Normal file
200
async-agent-lab/web/src/components/Panels.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
// 右栏下方的几个标签页:请求记录、收件箱、代码改动、源码、对比。
|
||||
import { Badge, Col, Empty, List, Row, Select, Space, Table, Tag, Typography } from 'antd';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { RunRecording } from '../../../shared/types.ts';
|
||||
import { derive, fmtSec, metrics, type RunState } from '../lib/derive.ts';
|
||||
import { RequestDetail } from './Detail.tsx';
|
||||
import { inboxLabel, Timeline, type Selection } from './Timeline.tsx';
|
||||
import agentSrc from '../../../agent/agent.ts?raw';
|
||||
import toolsSrc from '../../../agent/tools.ts?raw';
|
||||
import inboxSrc from '../../../agent/inbox.ts?raw';
|
||||
import llmSrc from '../../../agent/llm.ts?raw';
|
||||
import levelsSrc from '../../../agent/levels.ts?raw';
|
||||
|
||||
export function RequestsPanel({ state }: { state: RunState }) {
|
||||
const [n, setN] = useState<number | null>(null);
|
||||
const current = state.requests.find((r) => r.n === n) ?? state.requests.at(-1);
|
||||
if (!current) return <Empty description="还没有模型请求" />;
|
||||
return (
|
||||
<Row gutter={12}>
|
||||
<Col span={7}>
|
||||
<List
|
||||
size="small"
|
||||
bordered
|
||||
style={{ maxHeight: 560, overflowY: 'auto' }}
|
||||
dataSource={state.requests}
|
||||
renderItem={(r) => {
|
||||
const fresh = r.messages.slice(r.newFrom);
|
||||
return (
|
||||
<List.Item onClick={() => setN(r.n)} style={{ cursor: 'pointer', background: r.n === current.n ? '#e6f4ff' : undefined, display: 'block' }}>
|
||||
<Space size={4}>
|
||||
<b>#{r.n}</b>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{fmtSec(r.start)}</Typography.Text>
|
||||
{r.toolCalls.length ? <Tag color="orange">{r.toolCalls.map((c) => c.name).join('+')}</Tag> : r.end ? <Tag>结束本轮</Tag> : <Tag color="blue">进行中</Tag>}
|
||||
</Space>
|
||||
<div style={{ fontSize: 11, color: '#888' }}>
|
||||
新增:{fresh.map((m) => (m.role === 'user' ? (m.content.startsWith('<background') ? '完成通知' : m.content.startsWith('<question_reply') ? '问题答复' : '用户') : m.role)).join('、') || '-'}
|
||||
</div>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={17}>
|
||||
<RequestDetail req={current} state={state} />
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
export function InboxPanel({ state, onSelect }: { state: RunState; onSelect: (s: Selection) => void }) {
|
||||
const rows = [...state.inbox.values()];
|
||||
return (
|
||||
<div>
|
||||
<Typography.Paragraph type="secondary">
|
||||
会话外面发生的事都先进收件箱:用户插话、后台完成通知、异步提问的答复。Loop 只在一轮开始、以及每一步的工具都返回之后取走它们。
|
||||
</Typography.Paragraph>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
dataSource={rows}
|
||||
onRow={(r) => ({ onClick: () => onSelect({ kind: 'inbox', id: r.id }), style: { cursor: 'pointer' } })}
|
||||
columns={[
|
||||
{ title: 'ID', dataIndex: 'id', width: 70 },
|
||||
{ title: '类型', render: (_, r) => <Tag color={r.kind === 'user' ? 'default' : r.kind === 'job_done' ? 'cyan' : 'magenta'}>{inboxLabel(r.kind, r.ref)}</Tag> },
|
||||
{ title: '到达', render: (_, r) => fmtSec(r.pushedAt) },
|
||||
{ title: '加进历史', render: (_, r) => (r.deliveredAt !== undefined ? fmtSec(r.deliveredAt) : <Badge status="processing" text="排队中" />) },
|
||||
{ title: '排队时长', render: (_, r) => (r.deliveredAt !== undefined ? <b>{fmtSec(r.deliveredAt - r.pushedAt)}</b> : '-') },
|
||||
{ title: '模型看到', render: (_, r) => (r.seenByModel ? `#${r.seenByModel} @ ${fmtSec(r.seenAt!)}` : '-') },
|
||||
{ title: '内容', render: (_, r) => <Typography.Text ellipsis style={{ maxWidth: 260 }}>{r.content}</Typography.Text> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DiffPanel({ diff }: { diff: string }) {
|
||||
if (!diff.trim()) return <Empty description="还没有改动" />;
|
||||
return (
|
||||
<pre style={{ fontSize: 12, background: '#fafafa', padding: 10, borderRadius: 8, maxHeight: 600, overflow: 'auto' }}>
|
||||
{diff.split('\n').map((line, i) => {
|
||||
const color = line.startsWith('+++') || line.startsWith('---') ? '#555' : line.startsWith('+') ? '#237804' : line.startsWith('-') ? '#a8071a' : line.startsWith('@@') ? '#1677ff' : line.startsWith('diff ') ? '#000' : '#666';
|
||||
const bg = line.startsWith('+') && !line.startsWith('+++') ? '#f6ffed' : line.startsWith('-') && !line.startsWith('---') ? '#fff1f0' : undefined;
|
||||
return (
|
||||
<div key={i} style={{ color, background: bg, fontWeight: line.startsWith('diff ') ? 600 : undefined }}>
|
||||
{line || ' '}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
const SOURCES: Record<string, string> = {
|
||||
'agent/agent.ts': agentSrc,
|
||||
'agent/tools.ts': toolsSrc,
|
||||
'agent/inbox.ts': inboxSrc,
|
||||
'agent/llm.ts': llmSrc,
|
||||
'agent/levels.ts': levelsSrc,
|
||||
};
|
||||
|
||||
export function SourcePanel({ focus }: { focus: { file: string; anchor: string }[] }) {
|
||||
const [file, setFile] = useState(focus[0]?.file ?? 'agent/agent.ts');
|
||||
const anchor = focus.find((f) => f.file === file)?.anchor;
|
||||
const lines = SOURCES[file].split('\n');
|
||||
const range = useMemo(() => findBlock(lines, anchor), [file, anchor]);
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const el = box.current?.querySelector('[data-hl="1"]');
|
||||
el?.scrollIntoView({ block: 'center' });
|
||||
}, [file, anchor]);
|
||||
useEffect(() => setFile(focus[0]?.file ?? 'agent/agent.ts'), [focus]);
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 8 }} wrap>
|
||||
<Select value={file} onChange={setFile} style={{ width: 200 }} options={Object.keys(SOURCES).map((f) => ({ value: f, label: f }))} />
|
||||
{anchor ? <Typography.Text type="secondary">高亮:本级新增或关键的 <code>{anchor}</code></Typography.Text> : null}
|
||||
</Space>
|
||||
<div ref={box} style={{ maxHeight: 560, overflow: 'auto', background: '#1f1f1f', borderRadius: 8, padding: '8px 0', fontSize: 12, fontFamily: 'ui-monospace, Menlo, monospace' }}>
|
||||
{lines.map((line, i) => {
|
||||
const hl = range && i >= range[0] && i <= range[1];
|
||||
return (
|
||||
<div key={i} data-hl={hl && i === range![0] ? '1' : undefined} style={{ display: 'flex', background: hl ? '#3a3a1f' : undefined, whiteSpace: 'pre', color: line.trim().startsWith('//') ? '#7cb305' : '#d9d9d9' }}>
|
||||
<span style={{ width: 40, textAlign: 'right', paddingRight: 10, color: '#666', flex: 'none', userSelect: 'none' }}>{i + 1}</span>
|
||||
<span>{line}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function findBlock(lines: string[], anchor?: string): [number, number] | null {
|
||||
if (!anchor) return null;
|
||||
let start = lines.findIndex((l) => new RegExp(`\\b${anchor}\\b\\s*[(<]`).test(l) && !l.includes(`this.${anchor}`));
|
||||
if (start < 0) return null;
|
||||
while (start > 0 && lines[start - 1].trim().startsWith('//')) start -= 1;
|
||||
let depth = 0;
|
||||
let seen = false;
|
||||
for (let i = start; i < lines.length; i += 1) {
|
||||
for (const ch of lines[i]) {
|
||||
if (ch === '{') {
|
||||
depth += 1;
|
||||
seen = true;
|
||||
} else if (ch === '}') depth -= 1;
|
||||
}
|
||||
if (seen && depth <= 0) return [start, i];
|
||||
}
|
||||
return [start, start];
|
||||
}
|
||||
|
||||
export function ComparePanel(props: { recordings: { name: string; level: number }[]; load: (name: string) => Promise<RunRecording>; current?: { label: string; state: RunState } }) {
|
||||
const { recordings, load, current } = props;
|
||||
const [names, setNames] = useState<string[]>([]);
|
||||
const [loaded, setLoaded] = useState<Record<string, RunRecording>>({});
|
||||
useEffect(() => {
|
||||
for (const name of names) if (!loaded[name]) load(name).then((rec) => setLoaded((all) => ({ ...all, [name]: rec })));
|
||||
}, [names]);
|
||||
useEffect(() => {
|
||||
if (!names.length && recordings.length) setNames(recordings.filter((r) => r.name.startsWith('level1') || r.name.startsWith('level2')).map((r) => r.name).slice(0, 2));
|
||||
}, [recordings]);
|
||||
|
||||
const runs = [
|
||||
...(current ? [{ label: current.label, state: current.state }] : []),
|
||||
...names.filter((n) => loaded[n]).map((n) => ({ label: n, state: derive(loaded[n].events) })),
|
||||
];
|
||||
const span = Math.max(...runs.map((r) => r.state.now), 20000);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 10 }} wrap>
|
||||
<Typography.Text>挑几份录制放在同一时间刻度上比:</Typography.Text>
|
||||
<Select mode="multiple" style={{ minWidth: 420 }} value={names} onChange={setNames} options={recordings.map((r) => ({ value: r.name, label: `L${r.level} · ${r.name}` }))} />
|
||||
</Space>
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="label"
|
||||
dataSource={runs.map((r) => ({ label: r.label, ...metrics(r.state) }))}
|
||||
columns={[
|
||||
{ title: '运行', dataIndex: 'label' },
|
||||
{ title: '总时长', render: (_, r) => fmtSec(r.total) },
|
||||
{ title: '模型请求', dataIndex: 'requests' },
|
||||
{ title: 'Loop 等工具返回', render: (_, r) => fmtSec(r.toolWait) },
|
||||
{ title: '后台任务运行期间的请求', dataIndex: 'jobOverlap' },
|
||||
{ title: '问题挂着期间的请求', dataIndex: 'workWhileWaiting' },
|
||||
{ title: '第一次改文件', render: (_, r) => (r.firstEdit !== undefined ? fmtSec(r.firstEdit) : '-') },
|
||||
{ title: 'tokens', dataIndex: 'tokens' },
|
||||
]}
|
||||
/>
|
||||
{runs.map((r) => (
|
||||
<div key={r.label} style={{ marginTop: 14 }}>
|
||||
<Typography.Text strong>{r.label}</Typography.Text>
|
||||
<Timeline state={r.state} now={r.state.now} selected={null} onSelect={() => {}} spanMs={span} compact />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
337
async-agent-lab/web/src/components/Timeline.tsx
Normal file
337
async-agent-lab/web/src/components/Timeline.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
// 泳道时间线:同一条时间轴上看 Loop 在干什么、模型请求、工具、后台任务、人、收件箱。
|
||||
import { Tooltip, Slider, Space, Typography } from 'antd';
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { fmtSec, type RunState } from '../lib/derive.ts';
|
||||
|
||||
export type Selection =
|
||||
| { kind: 'request'; id: number }
|
||||
| { kind: 'tool'; id: string }
|
||||
| { kind: 'job'; id: string }
|
||||
| { kind: 'question'; id: string }
|
||||
| { kind: 'inbox'; id: string };
|
||||
|
||||
export const COLORS = {
|
||||
model: '#1677ff',
|
||||
tool: '#fa8c16',
|
||||
job: '#13a8a8',
|
||||
question: '#eb2f96',
|
||||
inbox: '#722ed1',
|
||||
user: '#595959',
|
||||
idle: '#d9d9d9',
|
||||
};
|
||||
|
||||
const LABEL_W = 92;
|
||||
const LANE_H = 34;
|
||||
|
||||
type Bar = {
|
||||
key: string;
|
||||
start: number;
|
||||
end?: number;
|
||||
color: string;
|
||||
label?: string;
|
||||
tip: ReactNode;
|
||||
sel?: Selection;
|
||||
hollow?: boolean;
|
||||
thin?: boolean;
|
||||
};
|
||||
|
||||
type Marker = { key: string; t: number; color: string; tip: ReactNode; sel?: Selection; shape: 'dot' | 'diamond' };
|
||||
|
||||
type Lane = { title: string; hint: string; bars: Bar[]; markers?: Marker[] };
|
||||
|
||||
export function keyMoments(state: RunState, sel: Selection | null) {
|
||||
if (!sel) return [];
|
||||
const out: { t: number; label: string; color: string }[] = [];
|
||||
if (sel.kind === 'job') {
|
||||
const job = state.jobs.get(sel.id);
|
||||
const tool = job && state.tools.get(job.callId);
|
||||
const inbox = [...state.inbox.values()].find((i) => i.kind === 'job_done' && i.ref === sel.id);
|
||||
if (tool?.end !== undefined) out.push({ t: tool.end, label: '① 工具返回', color: COLORS.tool });
|
||||
if (job?.end !== undefined) out.push({ t: job.end, label: '② 结果到达', color: COLORS.job });
|
||||
if (inbox?.seenAt !== undefined) out.push({ t: inbox.seenAt, label: `③ 模型看到(#${inbox.seenByModel})`, color: COLORS.model });
|
||||
}
|
||||
if (sel.kind === 'question') {
|
||||
const q = state.questions.get(sel.id);
|
||||
const tool = q && state.tools.get(q.callId);
|
||||
if (tool?.end !== undefined) out.push({ t: tool.end, label: '① 工具返回', color: COLORS.tool });
|
||||
if (q?.answeredAt !== undefined) out.push({ t: q.answeredAt, label: '② 答案到达', color: COLORS.question });
|
||||
const req = q?.seenByModel ? state.requests.find((r) => r.n === q.seenByModel) : undefined;
|
||||
if (req) out.push({ t: req.start, label: `③ 模型看到(#${req.n})`, color: COLORS.model });
|
||||
}
|
||||
if (sel.kind === 'inbox') {
|
||||
const entry = state.inbox.get(sel.id);
|
||||
if (entry) {
|
||||
out.push({ t: entry.pushedAt, label: '② 进入收件箱', color: COLORS.inbox });
|
||||
if (entry.deliveredAt !== undefined) out.push({ t: entry.deliveredAt, label: '加进历史', color: COLORS.inbox });
|
||||
if (entry.seenAt !== undefined) out.push({ t: entry.seenAt, label: `③ 模型看到(#${entry.seenByModel})`, color: COLORS.model });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function Timeline(props: {
|
||||
state: RunState;
|
||||
now: number;
|
||||
selected: Selection | null;
|
||||
onSelect: (sel: Selection) => void;
|
||||
spanMs?: number; // 对比时几条时间线共用一个刻度
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { state, now, selected, onSelect, spanMs, compact } = props;
|
||||
const wrap = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(800);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!wrap.current) return;
|
||||
const ro = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width));
|
||||
ro.observe(wrap.current);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const span = (spanMs ?? Math.max(now, 20_000)) * 1.04;
|
||||
const pxPerMs = ((width - LABEL_W - 16) / span) * zoom;
|
||||
const x = (t: number) => LABEL_W + t * pxPerMs;
|
||||
const innerW = Math.max(width, LABEL_W + span * pxPerMs + 16);
|
||||
|
||||
const lanes: Lane[] = [
|
||||
{
|
||||
title: 'Agent Loop',
|
||||
hint: '蓝:在请求模型;橙:在等工具返回;灰:空闲,等收件箱',
|
||||
bars: state.loop.map((seg, i) => ({
|
||||
key: `loop${i}`,
|
||||
start: seg.start,
|
||||
end: seg.end ?? now,
|
||||
color: seg.phase === 'model' ? COLORS.model : seg.phase === 'tool' ? COLORS.tool : COLORS.idle,
|
||||
label: seg.phase === 'model' ? seg.label : seg.phase === 'tool' ? `等 ${seg.label}` : '空闲',
|
||||
tip: `${seg.phase === 'model' ? `请求模型 ${seg.label}` : seg.phase === 'tool' ? `等工具 ${seg.label} 返回` : '空闲:收件箱有东西才继续'} ${fmtSec(seg.start)} → ${seg.end ? fmtSec(seg.end) : '进行中'}`,
|
||||
})),
|
||||
},
|
||||
{
|
||||
title: '模型请求',
|
||||
hint: '每一次 chat/completions 请求',
|
||||
bars: state.requests.map((r) => ({
|
||||
key: `r${r.n}`,
|
||||
start: r.start,
|
||||
end: r.end ?? now,
|
||||
color: r.error ? '#ff4d4f' : COLORS.model,
|
||||
label: `#${r.n}`,
|
||||
sel: { kind: 'request', id: r.n },
|
||||
tip: (
|
||||
<>
|
||||
模型请求 #{r.n}:{fmtSec(r.start)} → {r.end ? fmtSec(r.end) : '进行中'}
|
||||
<br />
|
||||
发出 {r.messages.length} 条消息,其中新增 {r.messages.length - r.newFrom} 条
|
||||
{r.toolCalls.length ? <><br />要调用:{r.toolCalls.map((c) => c.name).join('、')}</> : null}
|
||||
</>
|
||||
),
|
||||
})),
|
||||
},
|
||||
{
|
||||
title: '工具调用',
|
||||
hint: '从工具开始到工具返回结果',
|
||||
bars: [...state.tools.values()].map((t) => ({
|
||||
key: t.callId,
|
||||
start: t.start,
|
||||
end: t.end ?? now,
|
||||
color: t.name === 'question' ? COLORS.question : COLORS.tool,
|
||||
label: t.name === 'bash' ? shortCmd(t.args?.command) : t.name,
|
||||
sel: t.questionId ? { kind: 'question', id: t.questionId } : t.jobId ? { kind: 'job', id: t.jobId } : { kind: 'tool', id: t.callId },
|
||||
tip: (
|
||||
<>
|
||||
{t.name} {t.jobId ? `(后台 ${t.jobId})` : ''}
|
||||
{t.name === 'bash' ? <><br /><code>{String(t.args?.command ?? '').slice(0, 120)}</code></> : null}
|
||||
<br />
|
||||
{fmtSec(t.start)} → {t.end !== undefined ? `${fmtSec(t.end)},耗时 ${fmtSec(t.end - t.start)}` : '还没返回'}
|
||||
</>
|
||||
),
|
||||
})),
|
||||
},
|
||||
{
|
||||
title: '后台任务',
|
||||
hint: '后台进程从启动到退出',
|
||||
bars: [...state.jobs.values()].map((job) => ({
|
||||
key: job.jobId,
|
||||
start: job.start,
|
||||
end: job.end ?? now,
|
||||
color: job.exitCode && job.exitCode !== 0 ? '#ff4d4f' : COLORS.job,
|
||||
label: `${job.jobId} ${shortCmd(job.command)}`,
|
||||
sel: { kind: 'job', id: job.jobId },
|
||||
tip: `${job.jobId}:${job.command}|${fmtSec(job.start)} → ${job.end ? `${fmtSec(job.end)},exit ${job.exitCode}` : '运行中'}`,
|
||||
})),
|
||||
},
|
||||
{
|
||||
title: '人',
|
||||
hint: '问题挂着的时间;圆点是用户插话',
|
||||
bars: [...state.questions.values()].map((q) => ({
|
||||
key: q.questionId,
|
||||
start: q.askedAt,
|
||||
end: q.answeredAt ?? now,
|
||||
color: COLORS.question,
|
||||
hollow: true,
|
||||
label: q.answer ? `${q.questionId} → ${q.answer}` : `${q.questionId} 等回答`,
|
||||
sel: { kind: 'question', id: q.questionId },
|
||||
tip: `${q.mode === 'blocking' ? '阻塞' : '异步'}提问:${q.question}`,
|
||||
})),
|
||||
markers: [...state.inbox.values()]
|
||||
.filter((i) => i.kind === 'user')
|
||||
.map((i) => ({
|
||||
key: `u${i.id}`,
|
||||
t: i.pushedAt,
|
||||
color: COLORS.user,
|
||||
shape: 'dot' as const,
|
||||
sel: { kind: 'inbox', id: i.id } as Selection,
|
||||
tip: `用户消息:${i.content.slice(0, 60)}`,
|
||||
})),
|
||||
},
|
||||
{
|
||||
title: '收件箱',
|
||||
hint: '从进收件箱到被加进历史;菱形是模型看到它的那次请求',
|
||||
bars: [...state.inbox.values()].map((i) => ({
|
||||
key: `ib${i.id}`,
|
||||
start: i.pushedAt,
|
||||
end: i.deliveredAt ?? now,
|
||||
color: COLORS.inbox,
|
||||
thin: true,
|
||||
label: inboxLabel(i.kind, i.ref),
|
||||
sel: { kind: 'inbox', id: i.id },
|
||||
tip: (
|
||||
<>
|
||||
{inboxLabel(i.kind, i.ref)}:{fmtSec(i.pushedAt)} 进收件箱
|
||||
<br />
|
||||
{i.deliveredAt !== undefined ? `${fmtSec(i.deliveredAt)} 加进历史,排队 ${fmtSec(i.deliveredAt - i.pushedAt)}` : '还在排队'}
|
||||
{i.seenByModel ? <><br />模型在请求 #{i.seenByModel} 看到</> : null}
|
||||
</>
|
||||
),
|
||||
})),
|
||||
markers: [...state.inbox.values()]
|
||||
.filter((i) => i.seenAt !== undefined)
|
||||
.map((i) => ({
|
||||
key: `seen${i.id}`,
|
||||
t: i.seenAt!,
|
||||
color: COLORS.model,
|
||||
shape: 'diamond' as const,
|
||||
sel: { kind: 'inbox', id: i.id } as Selection,
|
||||
tip: `${inboxLabel(i.kind, i.ref)} 在请求 #${i.seenByModel} 被模型看到`,
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
const moments = keyMoments(state, selected);
|
||||
const step = niceStep(span / zoom);
|
||||
const ticks: number[] = [];
|
||||
for (let t = 0; t <= span; t += step) ticks.push(t);
|
||||
|
||||
const isSel = (s?: Selection) => !!s && !!selected && s.kind === selected.kind && s.id === selected.id;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{compact ? null : <Space style={{ marginBottom: 8 }} wrap>
|
||||
<Typography.Text type="secondary">缩放</Typography.Text>
|
||||
<Slider style={{ width: 140 }} min={1} max={6} step={0.5} value={zoom} onChange={setZoom} />
|
||||
<Typography.Text type="secondary">点任意一条看详情。选中后台任务或问题,会标出三个时间点。</Typography.Text>
|
||||
</Space>}
|
||||
<div ref={wrap} style={{ overflowX: 'auto', border: '1px solid #f0f0f0', borderRadius: 8, background: '#fff' }}>
|
||||
<div style={{ position: 'relative', width: innerW, height: lanes.length * LANE_H + 30 }}>
|
||||
{ticks.map((t) => (
|
||||
<div key={t} style={{ position: 'absolute', left: x(t), top: 0, bottom: 0, borderLeft: '1px dashed #f0f0f0' }}>
|
||||
<span style={{ position: 'absolute', bottom: 4, left: 3, fontSize: 11, color: '#999' }}>{t / 1000}s</span>
|
||||
</div>
|
||||
))}
|
||||
{lanes.map((lane, li) => (
|
||||
<div key={lane.title} style={{ position: 'absolute', top: li * LANE_H, left: 0, right: 0, height: LANE_H, borderBottom: '1px solid #fafafa' }}>
|
||||
<Tooltip title={lane.hint} placement="right">
|
||||
<div style={{ position: 'sticky', left: 0, width: LABEL_W - 6, height: LANE_H, lineHeight: `${LANE_H}px`, paddingLeft: 8, fontSize: 12, fontWeight: 500, color: '#555', background: '#fff', zIndex: 2 }}>
|
||||
{lane.title}
|
||||
</div>
|
||||
</Tooltip>
|
||||
{lane.bars.map((bar) => {
|
||||
const left = x(bar.start);
|
||||
const w = Math.max(3, ((bar.end ?? now) - bar.start) * pxPerMs);
|
||||
const h = bar.thin ? 10 : 22;
|
||||
const selectedBar = isSel(bar.sel);
|
||||
return (
|
||||
<Tooltip key={bar.key} title={bar.tip} mouseEnterDelay={0.15}>
|
||||
<div
|
||||
onClick={() => bar.sel && onSelect(bar.sel)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left,
|
||||
top: (LANE_H - h) / 2,
|
||||
width: w,
|
||||
height: h,
|
||||
borderRadius: 4,
|
||||
background: bar.hollow ? `${bar.color}22` : bar.color,
|
||||
border: bar.hollow ? `1.5px dashed ${bar.color}` : 'none',
|
||||
outline: selectedBar ? '2px solid #000' : 'none',
|
||||
cursor: bar.sel ? 'pointer' : 'default',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: 11,
|
||||
lineHeight: `${h}px`,
|
||||
color: bar.hollow ? bar.color : '#fff',
|
||||
paddingLeft: 4,
|
||||
boxSizing: 'border-box',
|
||||
opacity: bar.color === COLORS.idle ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{!bar.thin && w > 28 ? <span style={{ color: bar.color === COLORS.idle ? '#666' : undefined }}>{bar.label}</span> : null}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
{lane.markers?.map((m) => (
|
||||
<Tooltip key={m.key} title={m.tip}>
|
||||
<div
|
||||
onClick={() => m.sel && onSelect(m.sel)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: x(m.t) - 6,
|
||||
top: LANE_H / 2 - 6,
|
||||
width: 12,
|
||||
height: 12,
|
||||
background: m.color,
|
||||
borderRadius: m.shape === 'dot' ? 6 : 2,
|
||||
transform: m.shape === 'diamond' ? 'rotate(45deg)' : undefined,
|
||||
border: '2px solid #fff',
|
||||
boxShadow: '0 0 0 1px ' + m.color,
|
||||
cursor: 'pointer',
|
||||
zIndex: 3,
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{moments.map((m, i) => (
|
||||
<div key={i} style={{ position: 'absolute', left: x(m.t), top: 0, height: lanes.length * LANE_H, borderLeft: `2px solid ${m.color}`, zIndex: 4, pointerEvents: 'none' }}>
|
||||
<span style={{ position: 'absolute', top: 2 + i * 15, left: 4, fontSize: 11, fontWeight: 600, color: m.color, background: '#ffffffd9', whiteSpace: 'nowrap', padding: '0 3px' }}>
|
||||
{m.label} {fmtSec(m.t)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ position: 'absolute', left: x(now), top: 0, height: lanes.length * LANE_H, borderLeft: '2px solid #ff4d4f', zIndex: 5, pointerEvents: 'none' }}>
|
||||
<span style={{ position: 'absolute', top: lanes.length * LANE_H + 2, left: -14, fontSize: 11, color: '#ff4d4f' }}>{fmtSec(now)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function inboxLabel(kind: string, ref?: string) {
|
||||
if (kind === 'job_done') return `完成通知 ${ref ?? ''}`;
|
||||
if (kind === 'question_reply') return `问题答复 ${ref ?? ''}`;
|
||||
return '用户消息';
|
||||
}
|
||||
|
||||
function shortCmd(cmd: unknown) {
|
||||
const s = String(cmd ?? '');
|
||||
return s.length > 28 ? `${s.slice(0, 26)}…` : s;
|
||||
}
|
||||
|
||||
function niceStep(spanMs: number) {
|
||||
const target = spanMs / 10;
|
||||
for (const s of [1000, 2000, 5000, 10000, 20000, 30000, 60000]) if (s >= target) return s;
|
||||
return 120000;
|
||||
}
|
||||
345
async-agent-lab/web/src/lib/derive.ts
Normal file
345
async-agent-lab/web/src/lib/derive.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
// 把事件流还原成页面要的状态。实时视图、逐步查看、导出回放都调用它,输入只是「前 N 个事件」。
|
||||
import type { InboxItem, Message, ToolSpec, TraceEvent, Usage, QuestionMode } from '../../../shared/types.ts';
|
||||
|
||||
export type ModelRequest = {
|
||||
n: number;
|
||||
start: number;
|
||||
end?: number;
|
||||
ttftMs?: number | null;
|
||||
content: string;
|
||||
reasoning: string;
|
||||
toolCalls: { id: string; name: string; args: string }[];
|
||||
usage?: Usage | null;
|
||||
error?: string;
|
||||
messages: Message[]; // 这次请求发给模型的完整历史
|
||||
newFrom: number; // messages 里从这个下标开始是上一次请求之后新加的
|
||||
};
|
||||
|
||||
export type ToolRun = {
|
||||
callId: string;
|
||||
name: string;
|
||||
args: any;
|
||||
n: number;
|
||||
start: number;
|
||||
end?: number;
|
||||
output: string;
|
||||
result?: string;
|
||||
jobId?: string;
|
||||
questionId?: string;
|
||||
};
|
||||
|
||||
export type Job = {
|
||||
jobId: string;
|
||||
callId: string;
|
||||
command: string;
|
||||
start: number;
|
||||
end?: number;
|
||||
exitCode?: number | null;
|
||||
output: string;
|
||||
};
|
||||
|
||||
export type Question = {
|
||||
questionId: string;
|
||||
callId: string;
|
||||
mode: QuestionMode;
|
||||
question: string;
|
||||
options: string[];
|
||||
askedAt: number;
|
||||
answer?: string;
|
||||
answeredAt?: number;
|
||||
seenByModel?: number; // 答案第一次出现在哪一次模型请求里
|
||||
};
|
||||
|
||||
export type InboxEntry = InboxItem & {
|
||||
pushedAt: number;
|
||||
deliveredAt?: number;
|
||||
seenByModel?: number;
|
||||
seenAt?: number;
|
||||
};
|
||||
|
||||
export type LoopPhase = 'model' | 'tool' | 'idle';
|
||||
export type LoopSegment = { phase: LoopPhase; start: number; end?: number; label?: string };
|
||||
|
||||
export type ChatItem =
|
||||
| { kind: 'user'; key: string; t: number; text: string; inbox: InboxEntry; isTask: boolean }
|
||||
| { kind: 'assistant'; key: string; t: number; req: ModelRequest }
|
||||
| { kind: 'tool'; key: string; t: number; tool: ToolRun }
|
||||
| { kind: 'job_done'; key: string; t: number; inbox: InboxEntry; job?: Job }
|
||||
| { kind: 'answer'; key: string; t: number; question: Question; inbox?: InboxEntry };
|
||||
|
||||
export type RunState = {
|
||||
runId: string;
|
||||
level: number;
|
||||
model: string;
|
||||
baseURL: string;
|
||||
toolSpecs: ToolSpec[];
|
||||
now: number;
|
||||
history: Message[];
|
||||
requests: ModelRequest[];
|
||||
tools: Map<string, ToolRun>;
|
||||
jobs: Map<string, Job>;
|
||||
questions: Map<string, Question>;
|
||||
inbox: Map<string, InboxEntry>;
|
||||
chat: ChatItem[];
|
||||
loop: LoopSegment[];
|
||||
phase: LoopPhase;
|
||||
diff: string;
|
||||
forged: { n: number; tag: string; t: number }[];
|
||||
stopped?: { reason: string; error?: string };
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export function derive(events: TraceEvent[], count = events.length): RunState {
|
||||
const state: RunState = {
|
||||
runId: '',
|
||||
level: 0,
|
||||
model: '',
|
||||
baseURL: '',
|
||||
toolSpecs: [],
|
||||
now: 0,
|
||||
history: [],
|
||||
requests: [],
|
||||
tools: new Map(),
|
||||
jobs: new Map(),
|
||||
questions: new Map(),
|
||||
inbox: new Map(),
|
||||
chat: [],
|
||||
loop: [],
|
||||
phase: 'idle',
|
||||
diff: '',
|
||||
forged: [],
|
||||
};
|
||||
let lastRequestLen = 0;
|
||||
const historyOrigin = new Map<number, string>(); // history 下标 → inbox id
|
||||
|
||||
const setPhase = (phase: LoopPhase, t: number, label?: string) => {
|
||||
const last = state.loop.at(-1);
|
||||
if (last && last.phase === phase && last.label === label) return;
|
||||
if (last) last.end = t;
|
||||
state.loop.push({ phase, start: t, label });
|
||||
state.phase = phase;
|
||||
};
|
||||
|
||||
for (let i = 0; i < count && i < events.length; i += 1) {
|
||||
const e = events[i];
|
||||
state.now = e.t;
|
||||
switch (e.type) {
|
||||
case 'run.start':
|
||||
state.runId = e.runId;
|
||||
state.level = e.level;
|
||||
state.model = e.model;
|
||||
state.baseURL = e.baseURL;
|
||||
state.toolSpecs = e.tools;
|
||||
setPhase('idle', e.t);
|
||||
break;
|
||||
case 'history.append':
|
||||
state.history[e.index] = e.message;
|
||||
if (e.origin) historyOrigin.set(e.index, e.origin.inboxId);
|
||||
break;
|
||||
case 'inbox.push': {
|
||||
const entry: InboxEntry = { ...e.item, pushedAt: e.t };
|
||||
state.inbox.set(entry.id, entry);
|
||||
if (entry.kind === 'user') {
|
||||
state.chat.push({
|
||||
kind: 'user',
|
||||
key: entry.id,
|
||||
t: e.t,
|
||||
text: entry.content,
|
||||
inbox: entry,
|
||||
isTask: entry.id === 'in_1',
|
||||
});
|
||||
} else if (entry.kind === 'job_done') {
|
||||
state.chat.push({ kind: 'job_done', key: entry.id, t: e.t, inbox: entry, job: state.jobs.get(entry.ref ?? '') });
|
||||
} else if (entry.kind === 'question_reply') {
|
||||
const q = state.questions.get(entry.ref ?? '');
|
||||
const chatItem = state.chat.find((c) => c.kind === 'answer' && c.question.questionId === entry.ref);
|
||||
if (chatItem && chatItem.kind === 'answer') chatItem.inbox = entry;
|
||||
else if (q) state.chat.push({ kind: 'answer', key: entry.id, t: e.t, question: q, inbox: entry });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'inbox.deliver':
|
||||
for (const id of e.ids) {
|
||||
const entry = state.inbox.get(id);
|
||||
if (entry) entry.deliveredAt = e.t;
|
||||
}
|
||||
break;
|
||||
case 'model.start': {
|
||||
const messages = state.history.slice(0, e.messageCount);
|
||||
state.requests.push({
|
||||
n: e.n,
|
||||
start: e.t,
|
||||
content: '',
|
||||
reasoning: '',
|
||||
toolCalls: [],
|
||||
messages,
|
||||
newFrom: lastRequestLen,
|
||||
});
|
||||
for (let idx = lastRequestLen; idx < messages.length; idx += 1) {
|
||||
const inboxId = historyOrigin.get(idx);
|
||||
const entry = inboxId ? state.inbox.get(inboxId) : undefined;
|
||||
if (entry && entry.seenByModel === undefined) {
|
||||
entry.seenByModel = e.n;
|
||||
entry.seenAt = e.t;
|
||||
if (entry.kind === 'question_reply') {
|
||||
const q = state.questions.get(entry.ref ?? '');
|
||||
if (q) q.seenByModel = e.n;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 阻塞提问的答案是工具结果,不走收件箱;它被模型看到的时刻是答案之后的第一次请求。
|
||||
for (const q of state.questions.values()) {
|
||||
if (q.mode === 'blocking' && q.answeredAt !== undefined && q.seenByModel === undefined) q.seenByModel = e.n;
|
||||
}
|
||||
lastRequestLen = messages.length;
|
||||
state.chat.push({ kind: 'assistant', key: `req${e.n}`, t: e.t, req: state.requests.at(-1)! });
|
||||
setPhase('model', e.t, `#${e.n}`);
|
||||
break;
|
||||
}
|
||||
case 'model.delta': {
|
||||
const req = state.requests.at(-1);
|
||||
if (req) {
|
||||
if (e.content) req.content += e.content;
|
||||
if (e.reasoning) req.reasoning += e.reasoning;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'model.end': {
|
||||
const req = state.requests.find((r) => r.n === e.n);
|
||||
if (req) {
|
||||
req.end = e.t;
|
||||
req.ttftMs = e.ttftMs;
|
||||
req.content = e.message.content;
|
||||
req.reasoning = e.reasoning;
|
||||
req.usage = e.usage;
|
||||
req.toolCalls = (e.message.tool_calls ?? []).map((c) => ({ id: c.id, name: c.function.name, args: c.function.arguments }));
|
||||
}
|
||||
if (!e.message.tool_calls?.length) setPhase('idle', e.t);
|
||||
break;
|
||||
}
|
||||
case 'model.error': {
|
||||
const req = state.requests.find((r) => r.n === e.n);
|
||||
if (req) {
|
||||
req.end = e.t;
|
||||
req.error = e.error;
|
||||
}
|
||||
state.error = e.error;
|
||||
setPhase('idle', e.t);
|
||||
break;
|
||||
}
|
||||
case 'tool.start': {
|
||||
const tool: ToolRun = { callId: e.callId, name: e.name, args: e.args, n: e.n, start: e.t, output: '' };
|
||||
state.tools.set(e.callId, tool);
|
||||
state.chat.push({ kind: 'tool', key: e.callId, t: e.t, tool });
|
||||
setPhase('tool', e.t, e.name);
|
||||
break;
|
||||
}
|
||||
case 'tool.output': {
|
||||
const tool = state.tools.get(e.callId);
|
||||
if (tool) tool.output += e.chunk;
|
||||
break;
|
||||
}
|
||||
case 'tool.end': {
|
||||
const tool = state.tools.get(e.callId);
|
||||
if (tool) {
|
||||
tool.end = e.t;
|
||||
tool.result = e.result;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'job.start': {
|
||||
state.jobs.set(e.jobId, { jobId: e.jobId, callId: e.callId, command: e.command, start: e.t, output: '' });
|
||||
const tool = state.tools.get(e.callId);
|
||||
if (tool) tool.jobId = e.jobId;
|
||||
break;
|
||||
}
|
||||
case 'job.output': {
|
||||
const job = state.jobs.get(e.jobId);
|
||||
if (job) job.output += e.chunk;
|
||||
break;
|
||||
}
|
||||
case 'job.end': {
|
||||
const job = state.jobs.get(e.jobId);
|
||||
if (job) {
|
||||
job.end = e.t;
|
||||
job.exitCode = e.exitCode;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'question.ask': {
|
||||
state.questions.set(e.questionId, {
|
||||
questionId: e.questionId,
|
||||
callId: e.callId,
|
||||
mode: e.mode,
|
||||
question: e.question,
|
||||
options: e.options,
|
||||
askedAt: e.t,
|
||||
});
|
||||
const tool = state.tools.get(e.callId);
|
||||
if (tool) tool.questionId = e.questionId;
|
||||
break;
|
||||
}
|
||||
case 'question.answer': {
|
||||
const q = state.questions.get(e.questionId);
|
||||
if (q) {
|
||||
q.answer = e.answer;
|
||||
q.answeredAt = e.t;
|
||||
if (q.mode === 'blocking') state.chat.push({ kind: 'answer', key: `ans-${q.questionId}`, t: e.t, question: q });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'guard.forged':
|
||||
state.forged.push({ n: e.n, tag: e.tag, t: e.t });
|
||||
break;
|
||||
case 'workspace.diff':
|
||||
state.diff = e.diff;
|
||||
break;
|
||||
case 'run.stop':
|
||||
state.stopped = { reason: e.reason, error: e.error };
|
||||
if (e.error) state.error = e.error;
|
||||
setPhase('idle', e.t);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 工具都返回之后、下一次请求之前,Loop 在投递收件箱,这段时间极短,归为 tool 段的尾巴即可。
|
||||
return state;
|
||||
}
|
||||
|
||||
export const fmtSec = (ms: number) => `${(ms / 1000).toFixed(1)}s`;
|
||||
|
||||
export function pendingInbox(state: RunState): InboxEntry[] {
|
||||
return [...state.inbox.values()].filter((entry) => entry.deliveredAt === undefined);
|
||||
}
|
||||
|
||||
export function runningJobs(state: RunState): Job[] {
|
||||
return [...state.jobs.values()].filter((job) => job.end === undefined);
|
||||
}
|
||||
|
||||
export function openQuestions(state: RunState): Question[] {
|
||||
return [...state.questions.values()].filter((q) => q.answer === undefined);
|
||||
}
|
||||
|
||||
// 两次运行对比用的指标
|
||||
export function metrics(state: RunState) {
|
||||
const toolWait = [...state.tools.values()]
|
||||
.filter((t) => t.end !== undefined)
|
||||
.reduce((sum, t) => sum + (t.end! - t.start), 0);
|
||||
const firstEdit = [...state.tools.values()].find((t) => t.name === 'edit_file');
|
||||
const firstQuestion = [...state.questions.values()][0];
|
||||
const workWhileWaiting = firstQuestion
|
||||
? state.requests.filter((r) => r.start >= firstQuestion.askedAt && r.start < (firstQuestion.answeredAt ?? Infinity)).length
|
||||
: 0;
|
||||
const jobOverlap = [...state.jobs.values()].reduce(
|
||||
(sum, job) => sum + state.requests.filter((r) => r.start >= job.start && r.start < (job.end ?? Infinity)).length,
|
||||
0,
|
||||
);
|
||||
return {
|
||||
total: state.now,
|
||||
requests: state.requests.length,
|
||||
toolWait,
|
||||
firstEdit: firstEdit?.start,
|
||||
workWhileWaiting,
|
||||
jobOverlap,
|
||||
tokens: state.requests.reduce((sum, r) => sum + (r.usage?.total_tokens ?? 0), 0),
|
||||
};
|
||||
}
|
||||
102
async-agent-lab/web/src/lib/source.ts
Normal file
102
async-agent-lab/web/src/lib/source.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
// 数据来源:实时运行(本地服务 + SSE)或者录制回放(接口读取,或导出文件里内嵌的数据)。
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { LevelInfo, RunRecording, TraceEvent } from '../../../shared/types.ts';
|
||||
|
||||
export type ServerConfig = {
|
||||
provider: { displayName: string; baseURL: string; model: string };
|
||||
levels: (LevelInfo & { systemPrompt: string })[];
|
||||
};
|
||||
|
||||
export type RecordingMeta = { name: string; level: number; durationMs: number; startedAt: number };
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__RECORDING__?: RunRecording;
|
||||
}
|
||||
}
|
||||
|
||||
export const embedded = typeof window !== 'undefined' ? window.__RECORDING__ : undefined;
|
||||
|
||||
async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`/api${path}`, {
|
||||
...init,
|
||||
headers: { 'Content-Type': 'application/json', ...init?.headers },
|
||||
});
|
||||
const body = await res.json();
|
||||
if (!res.ok) throw new Error(body.error ?? `请求失败 ${res.status}`);
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export const Api = {
|
||||
config: () => api<ServerConfig>('/config'),
|
||||
recordings: () => api<RecordingMeta[]>('/recordings'),
|
||||
recording: (name: string) => api<RunRecording>(`/recordings/${name}`),
|
||||
start: (level: number, task: string) => api<{ runId: string }>('/runs', { method: 'POST', body: JSON.stringify({ level, task }) }),
|
||||
message: (runId: string, text: string) => api(`/runs/${runId}/message`, { method: 'POST', body: JSON.stringify({ text }) }),
|
||||
answer: (runId: string, questionId: string, answer: string) =>
|
||||
api(`/runs/${runId}/answer`, { method: 'POST', body: JSON.stringify({ questionId, answer }) }),
|
||||
stop: (runId: string) => api(`/runs/${runId}/stop`, { method: 'POST' }),
|
||||
save: (runId: string, name: string) => api<{ name: string }>(`/runs/${runId}/save`, { method: 'POST', body: JSON.stringify({ name }) }),
|
||||
exportUrl: (runId: string) => `/api/runs/${runId}/export`,
|
||||
exportRecordingUrl: (name: string) => `/api/recordings/${name}/export`,
|
||||
};
|
||||
|
||||
export type Source =
|
||||
| { kind: 'none' }
|
||||
| { kind: 'live'; runId: string }
|
||||
| { kind: 'replay'; name: string; recording: RunRecording };
|
||||
|
||||
// 实时运行时持续追加事件;回放时一次给全量事件。
|
||||
export function useEvents(source: Source) {
|
||||
const [events, setEvents] = useState<TraceEvent[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const buffer = useRef<TraceEvent[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
buffer.current = [];
|
||||
setEvents([]);
|
||||
if (source.kind === 'replay') {
|
||||
setEvents(source.recording.events);
|
||||
return;
|
||||
}
|
||||
if (source.kind !== 'live') return;
|
||||
const es = new EventSource(`/api/runs/${source.runId}/events`);
|
||||
let frame = 0;
|
||||
es.onopen = () => setConnected(true);
|
||||
es.onerror = () => setConnected(false);
|
||||
es.onmessage = (msg) => {
|
||||
buffer.current.push(JSON.parse(msg.data));
|
||||
// 流式分片很密,合并到一帧里更新
|
||||
if (!frame) {
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = 0;
|
||||
setEvents([...buffer.current]);
|
||||
});
|
||||
}
|
||||
};
|
||||
return () => {
|
||||
es.close();
|
||||
cancelAnimationFrame(frame);
|
||||
setConnected(false);
|
||||
};
|
||||
}, [source]);
|
||||
|
||||
return { events, connected };
|
||||
}
|
||||
|
||||
// 实时模式下,「现在」随墙钟走,时间线上的条会一直变长。
|
||||
export function useNow(startedAt: number | undefined, running: boolean) {
|
||||
const [now, setNow] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!startedAt || !running) return;
|
||||
const timer = setInterval(() => setNow(Date.now() - startedAt), 100);
|
||||
return () => clearInterval(timer);
|
||||
}, [startedAt, running]);
|
||||
return now;
|
||||
}
|
||||
|
||||
export function useStableCallback<T extends (...args: any[]) => any>(fn: T): T {
|
||||
const ref = useRef(fn);
|
||||
ref.current = fn;
|
||||
return useCallback(((...args: any[]) => ref.current(...args)) as T, []);
|
||||
}
|
||||
13
async-agent-lab/web/src/main.tsx
Normal file
13
async-agent-lab/web/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App as AntApp, ConfigProvider } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import App from './App.tsx';
|
||||
import './styles.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<ConfigProvider locale={zhCN} theme={{ token: { borderRadius: 6, fontSize: 13 } }}>
|
||||
<AntApp>
|
||||
<App />
|
||||
</AntApp>
|
||||
</ConfigProvider>,
|
||||
);
|
||||
21
async-agent-lab/web/src/stage/Code.tsx
Normal file
21
async-agent-lab/web/src/stage/Code.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Source } from './content.ts';
|
||||
|
||||
const tokens = /(\/\/.*$|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\b(?:async|await|return|throw|new|for|of|if|const|let|true|false|yield|function|export|break|type)\b)/g;
|
||||
|
||||
export function Code({ source, compact = false }: { source: Source; compact?: boolean }) {
|
||||
return <div className={`code-card ${compact ? 'compact-code' : ''}`}>
|
||||
<header>
|
||||
{source.link
|
||||
? <a href={source.link} target="_blank" rel="noreferrer">{source.file} <span aria-hidden="true">↗</span></a>
|
||||
: <span>{source.file}</span>}
|
||||
</header>
|
||||
<div className="code-scroll" tabIndex={0} aria-label="源码">
|
||||
<pre><code>{source.code.split('\n').map((line, i) => <span className={`code-line ${source.highlight && line.includes(source.highlight) ? 'focus-line' : ''}`} key={i}>
|
||||
{source.line && <span className="line-no" aria-hidden="true">{source.line + i}</span>}
|
||||
<span className="line-text">{line.split(tokens).map((part, index) => index % 2
|
||||
? <span key={index} className={part.startsWith('//') ? 'token-comment' : /^["']/.test(part) ? 'token-string' : 'token-keyword'}>{part}</span>
|
||||
: part)}{'\n'}</span>
|
||||
</span>)}</code></pre>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
79
async-agent-lab/web/src/stage/Conversation.tsx
Normal file
79
async-agent-lab/web/src/stage/Conversation.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { LiveEvent } from '../../../live/types.ts';
|
||||
import { evidenceOutput } from './trace.ts';
|
||||
|
||||
function ToolOutput({ value }: { value: string }) {
|
||||
const evidence = evidenceOutput(value);
|
||||
if (evidence) return <div className="tool-evidence">
|
||||
<p>核查了 {evidence.repositories.length} 个仓库。</p>
|
||||
<ul>{evidence.repositories.map(repo => <li key={repo.path}><strong>{repo.path.split('/').at(-1)}</strong>
|
||||
<span>{repo.commits.length} 条提交 · {repo.artifacts.length} 份产物线索</span></li>)}</ul>
|
||||
</div>;
|
||||
let text = value;
|
||||
try {
|
||||
const result = JSON.parse(value);
|
||||
if (result.accepted) text = `已受理,关联 ${result.question_id || result.job_id}。后续结果将送回会话。`;
|
||||
else if (typeof result.stdout === 'string') {
|
||||
try {
|
||||
const output = JSON.parse(result.stdout);
|
||||
text = Array.isArray(output.commits)
|
||||
? output.commits.map((item: { subject: string }) => '• ' + item.subject).join('\n')
|
||||
: result.stdout;
|
||||
} catch { text = result.stdout || result.stderr || `退出码 ${result.exit_code}`; }
|
||||
} else if (result.text) text = result.text;
|
||||
else if (result.error) text = result.error;
|
||||
} catch { /* Text tool results are already readable. */ }
|
||||
return <pre className="conversation-output">{text}</pre>;
|
||||
}
|
||||
|
||||
export function Conversation({ events, task, children }: { events: LiveEvent[]; task: string; children: ReactNode }) {
|
||||
const state = events.findLast(e => e.type === 'run.state');
|
||||
const ended = state?.type === 'run.state' && ['done', 'stopped', 'error'].includes(state.state);
|
||||
const completed = new Set(events.filter(e => e.type === 'model.result').map(e => e.n));
|
||||
const streaming = new Map<number, string>();
|
||||
for (const e of events) {
|
||||
if (e.type === 'model.text' && !completed.has(e.n)) streaming.set(e.n, (streaming.get(e.n) || '') + e.text);
|
||||
}
|
||||
return <section className="conversation-panel" aria-label="现场对话">
|
||||
<header className="panel-heading"><h2>现场对话</h2><span>任务、工具与答复</span></header>
|
||||
{children}
|
||||
<div className="conversation-body">
|
||||
<article className="chat-message user"><span className="role-tag user">用户</span><p>{task}</p></article>
|
||||
{!events.length && <p className="conversation-empty">开始演示后,这里会逐步显示模型的回复、工具执行结果,以及你的回答。</p>}
|
||||
{events.map(e => {
|
||||
if (e.type === 'model.result' && e.response.message.content.trim()) return <article className="chat-message" key={e.seq}>
|
||||
<span className="role-tag model">模型</span><div className="chat-prose"><ReactMarkdown>{e.response.message.content}</ReactMarkdown></div>
|
||||
</article>;
|
||||
if (e.type === 'tool.call') {
|
||||
const result = events.find(item => item.type === 'tool.result' && item.callId === e.callId);
|
||||
const args = e.args as { command?: string; path?: string; pattern?: string } | null;
|
||||
return <article className="chat-tool" key={e.seq}>
|
||||
<header><span className="role-tag tool">工具</span><strong>{e.name}</strong><span>{result ? '已返回' : ended ? '已结束' : '执行中'}</span></header>
|
||||
{(args?.command || args?.path || args?.pattern) && <code className="tool-command">{args.command || args.path || args.pattern}</code>}
|
||||
{result?.type === 'tool.result' && <ToolOutput value={result.result} />}
|
||||
</article>;
|
||||
}
|
||||
if (e.type === 'question.open') return <article className="chat-message question" key={e.seq}>
|
||||
<span className="role-tag model">模型提问</span><p>{e.question.text}</p>
|
||||
</article>;
|
||||
if (e.type === 'question.answer') {
|
||||
const opened = events.find(item => item.type === 'question.open' && item.question.id === e.questionId);
|
||||
const answer = e.answer;
|
||||
const text = 'text' in answer ? answer.text : answer.selected.map(i =>
|
||||
opened?.type === 'question.open' ? opened.question.options[i] : String(i + 1)).join('\n');
|
||||
return <article className="chat-message user" key={e.seq}><span className="role-tag user">用户答复</span>
|
||||
<p className="answer-text">{text || '本周工作项全部取消。'}</p></article>;
|
||||
}
|
||||
if (e.type === 'job.exit') return <article className="chat-tool" key={e.seq}>
|
||||
<header><span className="role-tag runtime">后台进程</span><strong>{e.jobId}</strong><span>退出码 {e.code ?? '—'}</span></header>
|
||||
<ToolOutput value={e.output} />
|
||||
</article>;
|
||||
return null;
|
||||
})}
|
||||
{[...streaming].map(([n, text]) => <article className="chat-message" key={`stream-${n}`}>
|
||||
<span className="role-tag model">{ended ? '模型' : '模型正在回复'}</span><div className="chat-prose"><ReactMarkdown>{text}</ReactMarkdown></div>
|
||||
</article>)}
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
233
async-agent-lab/web/src/stage/Demo.tsx
Normal file
233
async-agent-lab/web/src/stage/Demo.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import type { Answer, LiveEvent, Question, ScenarioId, ScenarioInfo } from '../../../live/types.ts';
|
||||
import { Conversation } from './Conversation.tsx';
|
||||
import { Lesson } from './Lesson.tsx';
|
||||
import { Code } from './Code.tsx';
|
||||
import { Dialog, EventDetail, PromptDetail, Report } from './Detail.tsx';
|
||||
import { demos } from './content.ts';
|
||||
import { answer, start, stop, useLive } from './live.ts';
|
||||
import { questions, requestView, rows, time } from './trace.ts';
|
||||
import type { Row } from './trace.ts';
|
||||
|
||||
function senderClass(route: string) {
|
||||
const sender = route.split(' → ')[0];
|
||||
return ({ '模型': 'model', '网关': 'model', '工具': 'tool', 'Bash': 'tool', 'write': 'tool', '网页': 'user' } as Record<string, string>)[sender] || 'runtime';
|
||||
}
|
||||
|
||||
type Detail = 'prompt' | 'report' | 'summary' | Row;
|
||||
|
||||
function QuestionForm({ question, submit }: { question: Question; submit: (answer: Answer) => Promise<unknown> }) {
|
||||
const [text, setText] = useState('');
|
||||
const [selected, setSelected] = useState(() => question.options.map((_, index) => index));
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
return <form className="question-form" onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setSending(true);
|
||||
setError('');
|
||||
void submit(question.kind === 'priority' ? { text } : { selected })
|
||||
.catch((error: Error) => setError(error.message)).finally(() => setSending(false));
|
||||
}}>
|
||||
<label htmlFor={`${question.mode}-${question.id}`} className="question-title">
|
||||
<code>{question.id}</code><span>{question.text}</span>
|
||||
</label>
|
||||
{question.kind === 'priority'
|
||||
? <div className="answer-line">
|
||||
<input id={`${question.mode}-${question.id}`} placeholder="输入下周优先事项" required maxLength={160}
|
||||
value={text} onChange={(event) => setText(event.target.value)} />
|
||||
<button className="button primary small" disabled={!text.trim() || sending}>提交答复</button>
|
||||
</div>
|
||||
: <>
|
||||
<div className="choices" id={`${question.mode}-${question.id}`} role="group" aria-label="本周候选工作项">
|
||||
{question.options.map((option, index) => <label key={option}>
|
||||
<input type="checkbox" checked={selected.includes(index)} onChange={(event) => setSelected(
|
||||
event.target.checked ? [...selected, index] : selected.filter((value) => value !== index),
|
||||
)} />
|
||||
<span>{option}</span>
|
||||
</label>)}
|
||||
</div>
|
||||
<div className="selection-footer"><span>已选 {selected.length} / {question.options.length}</span>
|
||||
<button className="button primary small" disabled={sending}>确认保留项</button></div>
|
||||
</>}
|
||||
{error && <div className="error" role="alert">{error}</div>}
|
||||
</form>;
|
||||
}
|
||||
|
||||
type Signal = { label: string; event?: LiveEvent };
|
||||
|
||||
function signals(events: LiveEvent[], scenario: ScenarioId): Signal[] {
|
||||
const carrying = (callId: string | undefined) => callId
|
||||
? events.find((event) => event.type === 'http.request' && requestView(event).messages.some((message) =>
|
||||
message.role === 'tool' && message.tool_call_id === callId))
|
||||
: undefined;
|
||||
const bash = events.find((event) => event.type === 'tool.result' && event.name === 'bash' && !event.result.startsWith('{"error":'));
|
||||
const bashId = bash?.type === 'tool.result' ? bash.callId : undefined;
|
||||
const asked = events.find((event) => event.type === 'question.open');
|
||||
const replied = events.find((event) => event.type === 'question.answer');
|
||||
const job = events.find((event) => event.type === 'job.exit');
|
||||
const firstCall = asked?.type === 'question.open' ? asked.question.callId : undefined;
|
||||
switch (scenario) {
|
||||
case 'sync':
|
||||
return [
|
||||
{ label: '模型调用 Bash', event: events.find((event) => event.type === 'model.result' && event.response.message.tool_calls?.some((call) => call.id === bashId)) },
|
||||
{ label: 'Bash 返回', event: bash },
|
||||
{ label: '结果进入请求', event: carrying(bashId) },
|
||||
];
|
||||
case 'background':
|
||||
return [
|
||||
{ label: '启动回执', event: bash },
|
||||
{ label: '进程退出', event: job },
|
||||
{ label: '完成通知进入请求', event: events.find((event) => event.type === 'http.request' && event.deliveries?.some((item) => item.kind === 'job_done')) },
|
||||
];
|
||||
case 'blocking':
|
||||
return [
|
||||
{ label: '问题待答', event: asked },
|
||||
{ label: '网页提交', event: replied },
|
||||
{ label: '携带答案的请求', event: carrying(firstCall) },
|
||||
];
|
||||
case 'async':
|
||||
return [
|
||||
{ label: '提问回执', event: events.find((event) => event.type === 'tool.result' && event.name === 'question' && event.ref === 'q_1') },
|
||||
{ label: '继续取证', event: carrying(firstCall) },
|
||||
{ label: '答复进入请求', event: events.find((event) => event.type === 'http.request' && event.deliveries?.some((item) => item.ref === 'q_1')) },
|
||||
{ label: '写入周报', event: events.find((event) => event.type === 'report.written') },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
function lastOf<T extends LiveEvent['type']>(events: LiveEvent[], type: T) {
|
||||
return events.findLast((event): event is Extract<LiveEvent, { type: T }> => event.type === type);
|
||||
}
|
||||
|
||||
export function Demo({ scenario, config, available, active }: { scenario: ScenarioId; config?: ScenarioInfo; available: boolean; active: boolean }) {
|
||||
const demo = demos[scenario];
|
||||
const [run, setRun] = useState<string>();
|
||||
const [sourceIndex, setSourceIndex] = useState(0);
|
||||
const [activeQuestion, setActiveQuestion] = useState<string>();
|
||||
const [detail, setDetail] = useState<Detail>();
|
||||
const [all, setAll] = useState(false);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [mode, setMode] = useState<'explain' | 'run'>('explain');
|
||||
const { events, connected } = useLive(run);
|
||||
const track = useMemo(() => rows(events, scenario), [events, scenario]);
|
||||
const signalsNow = useMemo(() => signals(events, scenario), [events, scenario]);
|
||||
const visible = all ? track : track.filter((row) => row.key);
|
||||
const current = lastOf(events, 'run.state');
|
||||
const request = lastOf(events, 'http.request');
|
||||
const report = lastOf(events, 'report.written');
|
||||
const pending = questions(events);
|
||||
const terminal = current && ['done', 'error', 'stopped'].includes(current.state);
|
||||
const running = starting || Boolean(run && !terminal);
|
||||
const status = starting ? '正在启动' : !run ? '准备就绪' : terminal
|
||||
? (current.state === 'done' ? '运行完成' : current.state === 'stopped' ? '已停止' : '运行出错')
|
||||
: !connected ? '连接中' : scenario === 'blocking' && pending.length ? '等待网页答复'
|
||||
: current?.state === 'waiting' ? '等待后续消息' : pending.length ? '问题待答 · Loop 仍在运行' : 'Loop 运行中';
|
||||
async function launch() {
|
||||
setMode('run');
|
||||
setStarting(true);
|
||||
setError('');
|
||||
setDetail(undefined);
|
||||
setActiveQuestion(undefined);
|
||||
try {
|
||||
if (run && !terminal) await stop(run);
|
||||
setRun((await start(scenario)).id);
|
||||
} catch (error) {
|
||||
setError((error as Error).message);
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const open = (event: LiveEvent | undefined) => {
|
||||
const row = track.find((item) => item.event.seq === event?.seq);
|
||||
if (row) setDetail(row);
|
||||
};
|
||||
const currentQuestion = pending.find((question) => question.id === activeQuestion) ?? pending[0];
|
||||
|
||||
return <section className="slide demo" aria-labelledby={`title-${scenario}`}>
|
||||
<div className="demo-heading">
|
||||
<div>
|
||||
<div className="demo-label">{demo.name}</div>
|
||||
<h1 id={`title-${scenario}`}>{demo.title}</h1>
|
||||
<p>{demo.prompt}</p>
|
||||
</div>
|
||||
<div className="demo-actions">
|
||||
<button className="button" onClick={() => setDetail('prompt')}>任务与工具</button>
|
||||
{running && run && <button className="button" onClick={() => void stop(run).catch((error: Error) => setError(error.message))}>停止</button>}
|
||||
<button className="button primary" disabled={starting || !available} onClick={() => void launch()}>
|
||||
<span aria-hidden="true">{run ? '↻' : '▶'}</span> {run ? '重新运行' : '开始演示'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mode-tabs" role="group" aria-label="案例展示模式">
|
||||
<button aria-pressed={mode === 'explain'} onClick={() => setMode('explain')}>源码与说明</button>
|
||||
<button aria-pressed={mode === 'run'} onClick={() => setMode('run')}>现场运行</button>
|
||||
<span>{mode === 'explain' ? '先理解执行逻辑,再启动真实演示。' : '左侧阅读对话,右侧核对事件时序。'}</span>
|
||||
</div>
|
||||
<div className="lesson-columns" hidden={mode !== 'explain'}>
|
||||
<Lesson scenario={scenario} />
|
||||
<div className="demo-source">
|
||||
{demo.sources.length > 1 && <div className="tabs source-tabs" aria-label="核心源码">
|
||||
{demo.sources.map((source, index) => <button className="tab" key={source.file} aria-pressed={index === sourceIndex} onClick={() => setSourceIndex(index)}>
|
||||
{index === 0 ? '主流程' : source.file.split(' · ').at(-1)}
|
||||
</button>)}
|
||||
</div>}
|
||||
<Code source={demo.sources[sourceIndex]} compact />
|
||||
<p className="cue">{demo.cue}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="demo-columns" hidden={mode !== 'run'}>
|
||||
<Conversation events={events} task={config?.task || demo.prompt}>
|
||||
{run && !terminal && pending.length > 0 && <div className="question-zone">
|
||||
{pending.length > 1 && <div className="tabs question-tabs" aria-label="待答问题">
|
||||
{pending.map((question) => <button className="tab" key={question.id} aria-pressed={question.id === currentQuestion?.id}
|
||||
onClick={() => setActiveQuestion(question.id)}>{question.kind === 'priority' ? '下周计划' : '本周保留项'}</button>)}
|
||||
</div>}
|
||||
{currentQuestion && <div className="question-forms">
|
||||
{pending.map((question) => <div key={question.id} hidden={question.id !== currentQuestion.id}>
|
||||
<QuestionForm question={question} submit={(value) => answer(run, question.id, value)} />
|
||||
</div>)}
|
||||
</div>}
|
||||
</div>}
|
||||
|
||||
</Conversation>
|
||||
<div className="trace-panel">
|
||||
<header className="trace-head">
|
||||
<h2>运行轨迹</h2>
|
||||
<div className="status" role="status"><span className={`signal-dot ${running ? 'live' : terminal && current.state === 'done' ? 'success' : ''}`} />{status}</div>
|
||||
<span className="request-count">请求 <b>{request?.n ?? 0}</b></span>
|
||||
</header>
|
||||
<div className="signals" aria-label="关键事件">
|
||||
{signalsNow.map(({ label, event }) => <button key={label} className={`signal ${event ? 'seen' : ''}`}
|
||||
disabled={!event} onClick={() => open(event)}><span>{label}</span><time>{event ? time(event.t) : '—'}</time></button>)}
|
||||
</div>
|
||||
{error || current?.state === 'error' ? <div className="trace-error" role="alert">{error || current?.note}</div> : null}
|
||||
{(report || current?.summary) && <div className="trace-output">
|
||||
{current?.summary && <button className="button small" onClick={() => setDetail('summary')}>查看模型总结</button>}
|
||||
{report && <button className="button primary small" onClick={() => setDetail('report')}>打开 weekly-report.md <span aria-hidden="true">↗</span></button>}
|
||||
</div>}
|
||||
<div className="trace-list-head">
|
||||
<span>{all ? '全部事件' : '关键事件'}</span>
|
||||
{track.length > 0 && <button className="text-button" onClick={() => setAll(!all)}>{all ? '仅看关键' : '查看全部'}</button>}
|
||||
</div>
|
||||
<div className="trace-list" aria-label="按发生顺序排列的事件">
|
||||
{visible.length === 0 && <p className="empty-trace">启动后,模型请求、工具结果和网页答复会出现在这里。</p>}
|
||||
{visible.map((row) => <button className={`trace-row ${row.kind}`} key={row.event.seq} onClick={() => setDetail(row)}>
|
||||
<span className={`role-tag ${senderClass(row.route)}`}>{row.route.split(' → ')[0]}</span>
|
||||
<span className="row-event"><strong>{row.label}</strong><span>→ {row.route.split(' → ')[1]} · {time(row.event.t)}</span></span>
|
||||
<code title={row.meta}>{row.meta}</code><span className="row-arrow" aria-hidden="true">›</span>
|
||||
</button>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{active && detail && <Dialog title={detail === 'prompt' ? '任务与工具' : detail === 'report' ? 'weekly-report.md' : detail === 'summary' ? '模型总结' : detail.label}
|
||||
close={() => setDetail(undefined)}>
|
||||
{detail === 'prompt' ? <PromptDetail config={config} />
|
||||
: detail === 'report' && report ? <Report path={report.path} markdown={report.markdown} />
|
||||
: detail === 'summary' ? <div className="report"><ReactMarkdown>{current?.summary}</ReactMarkdown></div>
|
||||
: typeof detail === 'object' ? <EventDetail row={detail} /> : null}
|
||||
</Dialog>}
|
||||
</section>;
|
||||
}
|
||||
98
async-agent-lab/web/src/stage/Detail.tsx
Normal file
98
async-agent-lab/web/src/stage/Detail.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import type { LiveEvent, ScenarioInfo } from '../../../live/types.ts';
|
||||
import { evidenceOutput, requestView } from './trace.ts';
|
||||
import type { Row } from './trace.ts';
|
||||
|
||||
export function Dialog({ title, close, children }: { title: string; close: () => void; children: ReactNode }) {
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
useEffect(() => { ref.current?.showModal(); }, []);
|
||||
return <dialog ref={ref} className="detail-dialog" onCancel={(event) => { event.preventDefault(); close(); }}
|
||||
onClick={(event) => { if (event.target === event.currentTarget) close(); }}>
|
||||
<div className="detail-inner">
|
||||
<header><h2>{title}</h2><button className="button small" onClick={close} autoFocus>关闭 <kbd>Esc</kbd></button></header>
|
||||
<div className="detail-scroll">{children}</div>
|
||||
</div>
|
||||
</dialog>;
|
||||
}
|
||||
|
||||
function json(value: unknown) {
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function pretty(content: string) {
|
||||
try {
|
||||
const parsed = JSON.parse(content) as unknown;
|
||||
if (parsed && typeof parsed === 'object' && 'stdout' in parsed && typeof parsed.stdout === 'string') {
|
||||
try { return json({ ...parsed, stdout: JSON.parse(parsed.stdout) }); } catch { return json(parsed); }
|
||||
}
|
||||
return json(parsed);
|
||||
} catch { return content; }
|
||||
}
|
||||
|
||||
function Output({ output }: { output: string }) {
|
||||
const evidence = evidenceOutput(output);
|
||||
if (!evidence) return <pre className="payload">{pretty(output)}</pre>;
|
||||
return <>
|
||||
<h3>取证结果</h3>
|
||||
<ul className="evidence-sources">{evidence.sources.map((source) => <li key={source.source}>
|
||||
{source.source} <span>{source.status === '已查询' ? `${source.sessions} 个本周会话` : source.status}</span>
|
||||
</li>)}</ul>
|
||||
<ul className="evidence-repos">{evidence.repositories.map((repo) => <li key={repo.path} title={repo.path}>
|
||||
<strong>{repo.path.split('/').at(-1)}</strong><span>{repo.commits.length} 条提交 · {repo.artifacts.length} 份待核查产物</span>
|
||||
</li>)}</ul>
|
||||
<details className="response-raw"><summary>展开完整输出</summary><pre className="payload">{pretty(output)}</pre></details>
|
||||
</>;
|
||||
}
|
||||
|
||||
function Request({ event }: { event: Extract<LiveEvent, { type: 'http.request' }> }) {
|
||||
const [tab, setTab] = useState<'messages' | 'raw'>('messages');
|
||||
const body = requestView(event);
|
||||
const messages = body.messages.map((message, index) => ({ ...message, index })).reverse();
|
||||
return <>
|
||||
<p className="detail-meta"><code>POST /chat/completions</code> · {body.model} · 尝试 {event.attempt}</p>
|
||||
<div className="tabs detail-tabs" aria-label="请求详情">
|
||||
<button className="tab" aria-pressed={tab === 'messages'} onClick={() => setTab('messages')}>请求消息 · {messages.length}</button>
|
||||
<button className="tab" aria-pressed={tab === 'raw'} onClick={() => setTab('raw')}>实际序列化请求体</button>
|
||||
</div>
|
||||
{tab === 'raw'
|
||||
? <pre className="payload">{event.body}</pre>
|
||||
: <div className="message-list">{messages.map((message) => {
|
||||
const delivery = event.deliveries?.find((item) => item.messageIndex === message.index);
|
||||
const tag = delivery?.ref || message.tool_call_id || message.tool_calls?.map((call) => call.id).join(' · ');
|
||||
return <details key={message.index} open={Boolean(delivery) || message.index === messages.length - 1}>
|
||||
<summary><code>{message.role}</code><span>消息 {message.index + 1}</span>{tag && <code className="detail-ref">{tag}</code>}</summary>
|
||||
<pre className="payload">{message.tool_calls?.length ? json(message.tool_calls) : pretty(message.content)}</pre>
|
||||
</details>;
|
||||
})}</div>}
|
||||
</>;
|
||||
}
|
||||
|
||||
export function EventDetail({ row }: { row: Row }) {
|
||||
const e = row.event;
|
||||
if (e.type === 'http.request') return <Request event={e} />;
|
||||
if (e.type === 'model.result' && !e.response.message.tool_calls?.length) return <>
|
||||
<div className="report"><ReactMarkdown>{e.response.message.content}</ReactMarkdown></div>
|
||||
<details className="response-raw"><summary>展开响应详情</summary><pre className="payload">{json(e.response)}</pre></details>
|
||||
</>;
|
||||
if (e.type === 'tool.result') return <><p className="detail-meta">调用 <code>{e.callId}</code>{e.ref && <> · 关联 <code>{e.ref}</code></>}</p><Output output={e.result} /></>;
|
||||
if (e.type === 'job.exit') return <><p className="detail-meta">{e.jobId} · 退出状态 {e.code ?? '—'}</p><Output output={e.output} /></>;
|
||||
if (e.type === 'report.written') return <Report path={e.path} markdown={e.markdown} />;
|
||||
return <pre className="payload">{json(e)}</pre>;
|
||||
}
|
||||
|
||||
export function PromptDetail({ config }: { config?: ScenarioInfo }) {
|
||||
return config ? <>
|
||||
<h3>任务提示词</h3><p>{config.task}</p>
|
||||
<h3>系统提示词</h3><pre className="payload">{config.system}</pre>
|
||||
<h3>工具定义</h3><div className="message-list">{config.tools.map((tool) => <details key={tool.function.name}>
|
||||
<summary><code>{tool.function.name}</code><span>{tool.function.description}</span></summary>
|
||||
<pre className="payload">{json(tool)}</pre>
|
||||
</details>)}</div>
|
||||
</> : <p>正在连接本地服务。</p>;
|
||||
}
|
||||
|
||||
export function Report({ path, markdown }: { path: string; markdown: string }) {
|
||||
return <div className="report"><p className="detail-meta" title={path}><code>{path.slice(path.lastIndexOf('/.runs/') + 1)}</code></p><ReactMarkdown>{markdown}</ReactMarkdown></div>;
|
||||
}
|
||||
75
async-agent-lab/web/src/stage/Lesson.tsx
Normal file
75
async-agent-lab/web/src/stage/Lesson.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { ScenarioId } from '../../../live/types.ts';
|
||||
|
||||
const lessons: Record<ScenarioId, { intro: string; steps: [string, string][]; observation: string }> = {
|
||||
sync: {
|
||||
intro: '先用一份消息历史保存任务。每次请求模型,它可能给出文本,也可能要求执行工具。运行时把工具结果追加到历史,再请求一次模型,这个反复执行的过程就是最小 Agent Loop。',
|
||||
steps: [
|
||||
['把上下文交给模型', 'callModel(history) 发送当前历史和工具定义。模型返回的 tool_calls 描述工具名称、参数和调用 ID。'],
|
||||
['执行工具并等待结果', '运行时按工具名找到 Bash 执行函数。await runTool 在命令退出前保持等待,这时 Loop 还没有发起下一次模型请求。'],
|
||||
['追加结果,继续一轮', '用 role: tool 和原 tool_call_id 把结果接回历史。模型在下一次请求中读到 Git 提交,再形成总结。本例在成功取证后要求模型给出文本答复。'],
|
||||
],
|
||||
observation: '运行时留意请求 #1 与请求 #2 之间的 Bash 返回。工具调用和返回通过同一个 call_id 对应。',
|
||||
},
|
||||
background: {
|
||||
intro: '跨仓库取证由一个本地子进程执行。Bash 工具启动它后先返回 job_id,Loop 便可以请求模型整理周报结构。进程在自己的生命周期里继续工作。',
|
||||
steps: [
|
||||
['回执结束当前工具调用', 'startBackground 返回 accepted 和 job_id。此时模型知道工作已经启动,具体的提交证据仍要等进程完成。'],
|
||||
['完成结果进入收件箱', '进程退出时,运行时记录退出码和输出,并投递带 job_id 的通知。这个结果可以在 Loop 忙碌时先排队。'],
|
||||
['下一次请求接收结果', 'callModel 发请求前把收件箱加入历史。如果 Loop 已在等待,收件箱中的新消息会唤醒它。'],
|
||||
],
|
||||
observation: '按 job_1 依次观察启动回执、进程退出、模型请求携带完成通知。取证很快时,三个事件仍有明确的先后顺序。',
|
||||
},
|
||||
blocking: {
|
||||
intro: '这个场景先询问下周计划。question 工具打开网页问题卡后,等待浏览器提交答案。等待发生在工具调用内部,因此本次调用还没有结果可以交回模型。',
|
||||
steps: [
|
||||
['创建等待中的问题', 'openQuestion 返回问题信息和一个 Promise。运行时用问题 ID 保存等待关系,让浏览器答复能找到它。'],
|
||||
['等待用户回答', 'await answer 暂停当前工具的执行。网页仍能操作,但这条 Loop 要等工具返回才能继续请求模型。'],
|
||||
['答案作为工具结果返回', '浏览器提交后解除 Promise。原 question 调用得到用户答案,再用同一个 tool_call_id 写入历史,随后开始周报取证。'],
|
||||
],
|
||||
observation: '留出几秒答题时间。问题卡待答期间,请求数应停在 1;提交后再观察请求 #2 中的 tool 消息。',
|
||||
},
|
||||
async: {
|
||||
intro: '异步 question 打开问题卡后立即返回 accepted 和 question_id。Loop 得到回执,便继续收集本周证据。用户的答案稍后作为一条新的消息进入收件箱。',
|
||||
steps: [
|
||||
['先问,再继续独立工作', 'ask 返回问题 ID,collect 随即定位活跃仓库并核查 Git。下周优先事项尚未确定,也可以收集本周已经发生的工作。'],
|
||||
['把后来的答复接回历史', '浏览器提交的答案带有 question_id。运行时将它加入下一次模型请求,模型可据此识别它回答的是哪一道题。'],
|
||||
['在依赖答案的步骤汇合', '候选工作项归纳完成后再请讲者选择。waitForAnswer 等齐两项决策,write 才将选中的本周工作和下周计划写入周报。'],
|
||||
],
|
||||
observation: '先让问题卡保持待答,观察取证对话继续出现。随后答复计划、确认保留项,并核对写出的 weekly-report.md。',
|
||||
},
|
||||
};
|
||||
|
||||
export function Lesson({ scenario }: { scenario: ScenarioId }) {
|
||||
const lesson = lessons[scenario];
|
||||
return <article className="lesson">
|
||||
<h2>这段实现如何工作</h2><p>{lesson.intro}</p>
|
||||
<ol>{lesson.steps.map(([title, body]) => <li key={title}><h3>{title}</h3><p>{body}</p></li>)}</ol>
|
||||
<aside><h3>运行时观察什么</h3><p>{lesson.observation}</p></aside>
|
||||
</article>;
|
||||
}
|
||||
|
||||
export function LoopLesson() {
|
||||
return <section className="slide explain loop-lesson">
|
||||
<div className="slide-heading"><h1>最基础的 Agent Loop 是什么?</h1>
|
||||
<p>一个围绕消息历史重复执行的程序,让模型根据工具的真实结果决定下一步。</p></div>
|
||||
<div className="reading-grid">
|
||||
<article className="lesson">
|
||||
<h2>模型负责提出下一步,运行时负责执行</h2>
|
||||
<p>用户提出任务后,运行时把任务、已有对话和工具定义发给模型。模型可能直接回答,也可能返回一个工具调用,例如让 Bash 查询 Git 提交。</p>
|
||||
<p>收到工具调用后,程序找到对应的执行函数,把参数交给它。执行结果连同调用 ID 一起追加到消息历史,模型才能在下一次请求中使用这些新信息。</p>
|
||||
<p>只要模型继续请求工具,这个过程就继续重复。模型给出最终文本时,这一轮可以结束。如果之后有后台结果或用户答复到达,运行时可以再次启动一轮。</p>
|
||||
<h2>“等待”发生在哪里?</h2>
|
||||
<p>同步工具会在执行完成后返回,因此 Loop 的下一步要等待它。异步工具先返回启动回执,运行时再通过后续消息交付结果。接下来的四个案例逐步展示这两种时序。</p>
|
||||
</article>
|
||||
<div className="loop-steps">
|
||||
{[
|
||||
['用户任务与消息历史', '历史保存模型已经知道的任务、对话和工具结果。'],
|
||||
['请求模型', '把消息历史和可调用的工具定义发送给模型。'],
|
||||
['执行模型请求的工具', '按工具名分发,将参数传给真实的执行函数。'],
|
||||
['结果追加回历史', '保留调用 ID,再请求模型,直到这一轮结束。'],
|
||||
].map(([title, body], i) => <div key={title}><span className="loop-step-number">{i + 1}</span><section><h3>{title}</h3><p>{body}</p></section></div>)}
|
||||
<p className="loop-return">工具结果回到第 2 步,形成 Loop。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
105
async-agent-lab/web/src/stage/Slides.tsx
Normal file
105
async-agent-lab/web/src/stage/Slides.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useState } from 'react';
|
||||
import { Code } from './Code.tsx';
|
||||
import { codexAsync, opencodeQuestion, opencodeShell, readExecution, toolContract } from './content.ts';
|
||||
import type { Source } from './content.ts';
|
||||
|
||||
export function Intro({ next }: { next: () => void }) {
|
||||
return <section className="intro slide" aria-labelledby="intro-title">
|
||||
<div className="intro-copy">
|
||||
<h1 id="intro-title">Agent 为什么能<span>边问边做</span></h1>
|
||||
<p>问题卡还在等答复,Agent 已经继续查询代码。这次分享从最基础的 Agent Loop 讲起,逐步观察工具何时返回,以及稍后到达的结果怎样被模型接收。</p>
|
||||
<button className="button primary intro-next" onClick={next}>开始分享 <span aria-hidden="true">→</span></button>
|
||||
</div>
|
||||
<div className="intro-figure" aria-label="问题仍待回答,Agent 同时在取证">
|
||||
<div className="intro-ask"><span className="signal-dot" /> 问题待答 <code>q_1</code>
|
||||
<strong>下周优先推进什么?</strong>
|
||||
</div>
|
||||
<div className="intro-flow" aria-hidden="true"><span /><i /><span /></div>
|
||||
<div className="intro-work"><span className="signal-dot" /> Agent 正在取证 <code>请求 #2</code>
|
||||
<strong>从本地会话定位活跃仓库</strong>
|
||||
</div>
|
||||
<div className="intro-note">AiWork 的阻塞提问 · OpenCode 的后台 Shell · Codex 的异步提问</div>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
export function ToolsSlide() {
|
||||
const [execution, setExecution] = useState(false);
|
||||
return <section className="slide explain" aria-labelledby="tools-title">
|
||||
<div className="slide-heading"><h1 id="tools-title">先给 Agent 四个基础工具</h1><p>模型说要调用什么,运行时执行,再用调用 ID 交回结果。</p></div>
|
||||
<p className="section-prose">工具定义告诉模型它能做什么,以及需要提供哪些参数。执行函数负责实际读取文件或启动命令。模型返回一次 read 调用后,运行时会把文件内容作为工具结果送回;调用 ID 让模型知道这份结果属于之前的哪一次请求。</p>
|
||||
<div className="tools-grid">
|
||||
<div className="tool-example">
|
||||
<div className="tabs" aria-label="read 的工具契约">
|
||||
<button className="tab" aria-pressed={!execution} onClick={() => setExecution(false)}>工具定义</button>
|
||||
<button className="tab" aria-pressed={execution} onClick={() => setExecution(true)}>执行函数</button>
|
||||
</div>
|
||||
<Code source={execution ? readExecution : toolContract} />
|
||||
<div className="contract" aria-label="read 调用示例">
|
||||
<div><span>模型 → 工具</span><code>read({`{ "path": "docs/plan.md" }`})</code></div>
|
||||
<div><span>工具 → 模型</span><code>{`call_1 → "1 # 周报计划"`}</code></div>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="tool-list">
|
||||
<div><dt>grep</dt><dd>用搜索词找路径和匹配行。</dd></div>
|
||||
<div><dt>read</dt><dd>读源码、文档或指定行范围。</dd></div>
|
||||
<div><dt>write</dt><dd>把确认过的内容写进周报。</dd></div>
|
||||
<div><dt>bash</dt><dd>执行 Git 或本地取证命令。</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
export function ProductSlide({ kind }: { kind: 'opencode-shell' | 'opencode-question' | 'codex' }) {
|
||||
const content: Record<typeof kind, { title: string; subtitle: string; sources: Source[]; note: React.ReactNode }> = {
|
||||
'opencode-shell': {
|
||||
title: 'OpenCode 把退出结果写回会话',
|
||||
subtitle: '后台分支先回执,退出后用 sessions.synthetic 交付。',
|
||||
sources: opencodeShell,
|
||||
note: <>开启实验选项后,超过前台等待预算的同一进程可以转到后台,复用完成通知。 <a href="https://github.com/anomalyco/opencode/pull/63024" target="_blank" rel="noreferrer">OpenCode #63024 ↗</a></>,
|
||||
},
|
||||
'opencode-question': {
|
||||
title: 'AiWork 的问题卡在哪里等',
|
||||
subtitle: 'OpenCode 1.18.18 发布问题事件,再等同一个 Deferred 被解除。',
|
||||
sources: opencodeQuestion,
|
||||
note: <>网页答复通过 <code>requestID</code> 找回等待中的调用。</>,
|
||||
},
|
||||
codex: {
|
||||
title: 'Codex 把答复作为用户消息送回',
|
||||
subtitle: '提问先结算,后续请求用 questionItemId 关联用户答复。',
|
||||
sources: codexAsync,
|
||||
note: <>右侧是上游测试记录的模型请求。<code>accepted</code> 回执和用户答复分别出现。</>,
|
||||
},
|
||||
};
|
||||
const { title, subtitle, sources, note } = content[kind];
|
||||
const explanation = {
|
||||
'opencode-shell': '左侧后台分支先注册完成通知,然后返回后台回执。右侧通知逻辑会等待任务结束,拿到退出状态和输出,再把它们写入发起任务的会话。这与刚才演示中的 job_id、进程退出事件和收件箱投递一一对应。',
|
||||
'opencode-question': 'ask 发布问题后,通过 Deferred.await 等待答案。reply 根据 requestID 找到这个等待对象,再用 Deferred.succeed 提交答案。因此问题卡的交互虽然发生在网页上,模型的工具调用仍要等这一步解除后才能完成。',
|
||||
'codex': '工具源码先向界面发出带问题的消息,再立即返回 accepted。右侧测试快照显示模型已经继续写了一段草稿,用户的回答随后作为 role 为 user 的新消息出现。questionItemId 将这份回答关联回原来的问题。',
|
||||
}[kind];
|
||||
return <section className="slide explain" aria-labelledby="product-title">
|
||||
<div className="slide-heading"><h1 id="product-title">{title}</h1><p>{subtitle}</p></div>
|
||||
<p className="section-prose">{explanation}</p>
|
||||
<div className="compare-grid">{sources.map((source) => <Code key={source.file} source={source} />)}</div>
|
||||
<p className="speaker-note">{note}</p>
|
||||
</section>;
|
||||
}
|
||||
|
||||
export function ReturnSlide() {
|
||||
return <section className="slide explain return-slide" aria-labelledby="return-title">
|
||||
<div className="slide-heading"><h1 id="return-title">后台工作如何回到 Loop</h1><p>运行时拿到结果,按 ID 归属,在下一次可用的模型请求里交付。</p></div>
|
||||
<p className="section-prose">后台工作可以发生在本地子进程、另一个 Agent,或远端服务中。运行时负责保存任务与会话的对应关系,收到完成结果后将它排入收件箱。Loop 在可以接收新消息的边界取走这些结果,追加到历史后再请求模型。后台任务的生命周期和模型的每次请求因此可以交错推进。</p>
|
||||
<div className="return-diagram" aria-label="本地进程、子 Agent、远端任务将结果交给运行时,随后回到模型">
|
||||
<div className="return-sources">
|
||||
<div><strong>本地进程</strong><span>退出码与输出</span></div>
|
||||
<div><strong>子 Agent</strong><span>独立 Loop 结束后报告</span></div>
|
||||
<div><strong>远端任务</strong><span>轮询结果或收到回调</span></div>
|
||||
</div>
|
||||
<div className="diagram-arrow" aria-hidden="true">→</div>
|
||||
<div className="return-node"><strong>运行时收件箱</strong><span>用任务 ID 找回发起会话</span></div>
|
||||
<div className="diagram-arrow" aria-hidden="true">→</div>
|
||||
<div className="return-node last"><strong>模型下一步</strong><span>活跃时接入,空闲时唤醒</span></div>
|
||||
</div>
|
||||
<p className="speaker-note">AiWork 的「立即插入」也要等可接收新消息的边界。若 Loop 还在等阻塞式 question,先由答复解除等待。</p>
|
||||
</section>;
|
||||
}
|
||||
169
async-agent-lab/web/src/stage/content.ts
Normal file
169
async-agent-lab/web/src/stage/content.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import type { ScenarioId } from '../../../live/types.ts';
|
||||
import syncSource from '../../../live/scenarios/sync.ts?raw';
|
||||
import backgroundSource from '../../../live/scenarios/background.ts?raw';
|
||||
import blockingSource from '../../../live/scenarios/blocking.ts?raw';
|
||||
import asyncSource from '../../../live/scenarios/async.ts?raw';
|
||||
import runtimeSource from '../../../live/runtime.ts?raw';
|
||||
import toolsSource from '../../../live/tools.ts?raw';
|
||||
|
||||
function excerpt(source: string, start: string, end: string) {
|
||||
const lines = source.split('\n');
|
||||
const first = lines.findIndex((line) => line.trim() === start || line.trim().startsWith(start));
|
||||
const last = lines.findIndex((line, i) => i > first && line.trim().startsWith(end));
|
||||
if (first < 0 || last < 0) throw new Error(`找不到源码片段 ${start}`);
|
||||
return { code: lines.slice(first, last).join('\n').trimEnd(), line: first + 1 };
|
||||
}
|
||||
|
||||
export type Source = { file: string; code: string; line?: number; highlight?: string; link?: string };
|
||||
|
||||
function core(name: string, source: string, highlight: string): Source {
|
||||
return { file: `live/scenarios/${name}.ts`, highlight, ...excerpt(source, 'export async function run', '// stage:end') };
|
||||
}
|
||||
|
||||
export const toolContract: Source = {
|
||||
file: 'live/tools.ts', ...excerpt(toolsSource, 'export const readSpec', 'export function baseTools'),
|
||||
highlight: 'path: string',
|
||||
};
|
||||
|
||||
export const readExecution: Source = {
|
||||
file: 'live/tools.ts', ...excerpt(toolsSource, 'async read(', 'async grep('),
|
||||
highlight: 'await readFile',
|
||||
};
|
||||
|
||||
export const demos: Record<ScenarioId, { title: string; name: string; prompt: string; cue: string; sources: Source[] }> = {
|
||||
sync: {
|
||||
title: 'Bash 退出,Loop 才继续', name: '同步 Bash',
|
||||
prompt: '查 AiWork 本周的 Git 提交,归纳几项研发进展。',
|
||||
cue: '沿同一个 tool_call_id,从 Bash 返回追到下一次请求。',
|
||||
sources: [core('sync', syncSource, 'await loop.runTool')],
|
||||
},
|
||||
background: {
|
||||
title: 'Bash 可以先还回执', name: '后台 Bash',
|
||||
prompt: '后台收集跨仓库证据。等待期间,先列周报结构。',
|
||||
cue: '沿 job_1 看启动回执、实际退出、完成通知进入请求。',
|
||||
sources: [
|
||||
core('background', backgroundSource, 'await loop.waitForJob'),
|
||||
{ file: 'live/runtime.ts · 后台回执', ...excerpt(runtimeSource, 'startBackground(', 'openQuestion('), highlight: 'this.inbox.push' },
|
||||
],
|
||||
},
|
||||
blocking: {
|
||||
title: '问题挂着,Loop 等答案', name: '阻塞提问',
|
||||
prompt: '先问下周优先事项,收到答复后再查本周工作。',
|
||||
cue: '先留几秒。提交前,请求数保持在提问的那一次。',
|
||||
sources: [core('blocking', blockingSource, 'await answer')],
|
||||
},
|
||||
async: {
|
||||
title: '问题挂着,Loop 继续取证', name: '异步提问',
|
||||
prompt: '先问下周计划,同时查证据。确认本周保留项后写周报。',
|
||||
cue: '先别答。看到取证请求后再提交计划,然后勾选工作项。',
|
||||
sources: [
|
||||
core('async', asyncSource, 'await collect'),
|
||||
{ file: 'live/scenarios/async.ts · 提问回执', ...excerpt(asyncSource, 'async function ask(', '// stage:start'), highlight: 'accepted: true' },
|
||||
{ file: 'live/runtime.ts · 答复入箱', ...excerpt(runtimeSource, "if (question.mode === 'async')", 'async waitForAnswer'), highlight: 'this.inbox.push' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const opencodeShell: Source[] = [
|
||||
{
|
||||
file: 'shell.ts · v2.2.7 · 后台分支', line: 234,
|
||||
code: `if (input.background === true) {
|
||||
yield* jobs.background(job.id)
|
||||
yield* notifyWhenDone(context.sessionID, job.id, info.id, info.command, settled)
|
||||
return backgroundResult(info.id, info.file)
|
||||
}`,
|
||||
highlight: 'return backgroundResult',
|
||||
link: 'https://github.com/anomalyco/opencode/blob/v2.2.7/packages/core/src/tool/plugin/shell.ts#L234-L238',
|
||||
},
|
||||
{
|
||||
file: 'shell.ts · v2.2.7 · 结果回传', line: 160,
|
||||
code: `yield* sessions.synthetic({
|
||||
...(info.notificationID ? { id: info.notificationID } : {}),
|
||||
sessionID,
|
||||
description: command,
|
||||
...ShellResult.notification({
|
||||
jobID: id,
|
||||
shellID,
|
||||
command,
|
||||
state: info.status,
|
||||
text,
|
||||
output,
|
||||
}),
|
||||
})`,
|
||||
highlight: 'sessions.synthetic',
|
||||
link: 'https://github.com/anomalyco/opencode/blob/v2.2.7/packages/core/src/tool/plugin/shell.ts#L150-L173',
|
||||
},
|
||||
];
|
||||
|
||||
export const opencodeQuestion: Source[] = [
|
||||
{
|
||||
file: 'question/index.ts · v1.18.18 · ask', line: 103,
|
||||
code: `pending.set(id, { info, deferred })
|
||||
yield* events.publish(Event.Asked, info)
|
||||
|
||||
return yield* Effect.ensuring(
|
||||
Deferred.await(deferred),
|
||||
Effect.sync(() => {
|
||||
pending.delete(id)
|
||||
}),
|
||||
)`,
|
||||
highlight: 'Deferred.await',
|
||||
link: 'https://github.com/anomalyco/opencode/blob/v1.18.18/packages/opencode/src/question/index.ts#L103-L111',
|
||||
},
|
||||
{
|
||||
file: 'question/index.ts · v1.18.18 · reply', line: 124,
|
||||
code: `pending.delete(input.requestID)
|
||||
yield* Effect.logInfo("replied", { requestID: input.requestID, answers: input.answers })
|
||||
yield* events.publish(Event.Replied, {
|
||||
sessionID: existing.info.sessionID,
|
||||
requestID: existing.info.id,
|
||||
answers: input.answers.map((a) => [...a]),
|
||||
})
|
||||
yield* Deferred.succeed(existing.deferred, input.answers)`,
|
||||
highlight: 'Deferred.succeed',
|
||||
link: 'https://github.com/anomalyco/opencode/blob/v1.18.18/packages/opencode/src/question/index.ts#L114-L131',
|
||||
},
|
||||
];
|
||||
|
||||
export const codexAsync: Source[] = [
|
||||
{
|
||||
file: 'request_user_input_async.rs · 0.167.1', line: 141,
|
||||
code: `session.emit_turn_item_started(turn.as_ref(), &item).await;
|
||||
session.emit_turn_item_completed(turn.as_ref(), item).await;
|
||||
|
||||
Ok(boxed_tool_output(FunctionToolOutput::from_text(
|
||||
r#"{"accepted":true}"#.to_string(),
|
||||
/*success*/ Some(true),
|
||||
)))`,
|
||||
highlight: 'accepted',
|
||||
link: 'https://github.com/openai/codex/blob/rust-v0.167.1/codex-rs/core/src/tools/handlers/request_user_input_async.rs#L131-L147',
|
||||
},
|
||||
{
|
||||
file: 'astra_async_question_and_answer.snap · 0.167.1',
|
||||
code: `09:function_call_output:{"accepted":true}
|
||||
|
||||
-- request 3 (request) --
|
||||
10:message/assistant:
|
||||
I drafted a short launch update.
|
||||
11:message/user:
|
||||
<send_user_message_question_reply>
|
||||
[{"answer":"Customers","question":"Who should receive the launch update?","questionItemId":"[\\"request_user_input_async\\",\\"audience-question\\",0]"}]
|
||||
</send_user_message_question_reply>`,
|
||||
highlight: 'questionItemId',
|
||||
link: 'https://github.com/openai/codex/blob/rust-v0.167.1/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__astra_async_question_and_answer.snap#L38-L50',
|
||||
},
|
||||
];
|
||||
|
||||
export const pages = [
|
||||
{ slug: 'start', title: '为什么能边问边做' },
|
||||
{ slug: 'loop', title: 'Agent Loop 的基本逻辑' },
|
||||
{ slug: 'tools', title: '四个基础工具' },
|
||||
{ slug: 'sync', title: '同步 Bash' },
|
||||
{ slug: 'background', title: '后台 Bash' },
|
||||
{ slug: 'opencode-shell', title: 'OpenCode 后台 Shell' },
|
||||
{ slug: 'blocking', title: '阻塞提问' },
|
||||
{ slug: 'opencode-question', title: 'AiWork 的问答实现' },
|
||||
{ slug: 'async', title: '异步提问' },
|
||||
{ slug: 'codex', title: 'Codex 的问答实现' },
|
||||
{ slug: 'return', title: '后台结果如何回到 Loop' },
|
||||
] as const;
|
||||
50
async-agent-lab/web/src/stage/live.ts
Normal file
50
async-agent-lab/web/src/stage/live.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { Answer, LiveEvent, ScenarioId, ScenarioInfo } from '../../../live/types.ts';
|
||||
|
||||
async function api<T>(path: string, data?: unknown): Promise<T> {
|
||||
const response = await fetch(`/api${path}`, data === undefined ? undefined : {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? '本地服务请求失败。');
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export const getConfig = () => api<{ scenarios: ScenarioInfo[] }>('/config');
|
||||
export const start = (scenario: ScenarioId) => api<{ id: string }>('/runs', { scenario });
|
||||
export const stop = (id: string) => api(`/runs/${id}/stop`, {});
|
||||
export const answer = (id: string, questionId: string, value: Answer) => api(`/runs/${id}/answer`, { questionId, answer: value });
|
||||
|
||||
export function useLive(id: string | undefined) {
|
||||
const [snapshot, setSnapshot] = useState<{ id?: string; events: LiveEvent[]; connected: boolean }>({ events: [], connected: false });
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
const source = new EventSource(`/api/runs/${id}/events`);
|
||||
let timer: number | undefined;
|
||||
let connected = false;
|
||||
const events: LiveEvent[] = [];
|
||||
function refresh() {
|
||||
if (timer !== undefined) return;
|
||||
timer = window.setTimeout(() => {
|
||||
timer = undefined;
|
||||
setSnapshot({ id, events: [...events], connected });
|
||||
}, 25);
|
||||
}
|
||||
source.onopen = () => { connected = true; refresh(); };
|
||||
source.onerror = () => { connected = false; refresh(); };
|
||||
source.onmessage = (message) => {
|
||||
const event = JSON.parse(message.data) as LiveEvent;
|
||||
if (event.seq <= (events.at(-1)?.seq ?? 0)) return;
|
||||
events.push(event);
|
||||
refresh();
|
||||
if (event.type === 'run.state' && ['done', 'stopped', 'error'].includes(event.state)) source.close();
|
||||
};
|
||||
return () => {
|
||||
source.close();
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [id]);
|
||||
return snapshot.id === id ? snapshot : { events: [], connected: false };
|
||||
}
|
||||
107
async-agent-lab/web/src/stage/main.tsx
Normal file
107
async-agent-lab/web/src/stage/main.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useLayoutEffect, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import type { ScenarioId, ScenarioInfo } from '../../../live/types.ts';
|
||||
import { LoopLesson } from './Lesson.tsx';
|
||||
import { Demo } from './Demo.tsx';
|
||||
import { Dialog } from './Detail.tsx';
|
||||
import { Intro, ProductSlide, ReturnSlide, ToolsSlide } from './Slides.tsx';
|
||||
import { pages } from './content.ts';
|
||||
import { getConfig } from './live.ts';
|
||||
import './stage.css';
|
||||
|
||||
const scenarioIds: ScenarioId[] = ['sync', 'background', 'blocking', 'async'];
|
||||
|
||||
function readPage() {
|
||||
return Math.max(0, pages.findIndex((page) => `#${page.slug}` === location.hash));
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [page, setPage] = useState(readPage);
|
||||
const [contents, setContents] = useState(false);
|
||||
const [config, setConfig] = useState<ScenarioInfo[]>();
|
||||
const [error, setError] = useState('');
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const slug = pages[page].slug;
|
||||
|
||||
function go(index: number) {
|
||||
if (index < 0 || index >= pages.length) return;
|
||||
setContents(false);
|
||||
if (pages[index].slug !== slug) {
|
||||
location.hash = pages[index].slug;
|
||||
setPage(index);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getConfig().then((data) => setConfig(data.scenarios)).catch(() => setError('本地演示服务未连接。'));
|
||||
const previousScrollRestoration = history.scrollRestoration;
|
||||
history.scrollRestoration = 'manual';
|
||||
const changed = () => setPage(readPage());
|
||||
const resized = () => setFullscreen(Boolean(document.fullscreenElement));
|
||||
addEventListener('hashchange', changed);
|
||||
document.addEventListener('fullscreenchange', resized);
|
||||
return () => {
|
||||
removeEventListener('hashchange', changed);
|
||||
document.removeEventListener('fullscreenchange', resized);
|
||||
history.scrollRestoration = previousScrollRestoration;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
}, [slug]);
|
||||
|
||||
useEffect(() => {
|
||||
const keys = (event: KeyboardEvent) => {
|
||||
if (event.altKey || event.metaKey || event.ctrlKey || event.shiftKey ||
|
||||
(event.target as HTMLElement)?.closest('input, textarea, select, [contenteditable], dialog, .code-scroll, .trace-list')) return;
|
||||
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
go(page + (event.key === 'ArrowLeft' ? -1 : 1));
|
||||
}
|
||||
};
|
||||
addEventListener('keydown', keys);
|
||||
return () => removeEventListener('keydown', keys);
|
||||
}, [page, slug]);
|
||||
|
||||
return <div className="deck">
|
||||
<header className="deck-head">
|
||||
<a href="#start" className="deck-title"><span aria-hidden="true" />Agent 为什么能边问边做</a>
|
||||
<div className="deck-actions">
|
||||
{error && <span className="connection-error" role="alert">{error}</span>}
|
||||
<button className="button small" onClick={() => setContents(true)}>目录</button>
|
||||
<button className="button small" onClick={() => {
|
||||
if (document.fullscreenElement) void document.exitFullscreen();
|
||||
else void document.documentElement.requestFullscreen();
|
||||
}}>{fullscreen ? '退出全屏' : '全屏'}</button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="deck-main" aria-label={pages[page].title}>
|
||||
{slug === 'start' && <Intro next={() => go(page + 1)} />}
|
||||
{slug === 'loop' && <LoopLesson />}
|
||||
{slug === 'tools' && <ToolsSlide />}
|
||||
{(slug === 'opencode-shell' || slug === 'opencode-question' || slug === 'codex') && <ProductSlide kind={slug} />}
|
||||
{slug === 'return' && <ReturnSlide />}
|
||||
{scenarioIds.map((scenario) => <div className="demo-page" key={scenario} hidden={slug !== scenario}>
|
||||
<Demo scenario={scenario} config={config?.find((info) => info.id === scenario)} available={Boolean(config)} active={slug === scenario} />
|
||||
</div>)}
|
||||
</main>
|
||||
<footer className="deck-foot">
|
||||
<div className="page-index"><b>{String(page + 1).padStart(2, '0')}</b><span> / {pages.length}</span></div>
|
||||
<nav className="page-track" aria-label="分享进度">
|
||||
{pages.map((item, index) => <button key={item.slug} aria-label={`第 ${index + 1} 页 · ${item.title}`}
|
||||
title={item.title} aria-current={index === page ? 'page' : undefined} onClick={() => go(index)} />)}
|
||||
</nav>
|
||||
<nav className="page-buttons" aria-label="翻页">
|
||||
<button className="button small" disabled={page === 0} onClick={() => go(page - 1)}><span aria-hidden="true">←</span> 上一页</button>
|
||||
<button className="button small" disabled={page === pages.length - 1} onClick={() => go(page + 1)}>下一页 <span aria-hidden="true">→</span></button>
|
||||
</nav>
|
||||
</footer>
|
||||
{contents && <Dialog title="分享目录" close={() => setContents(false)}><ol className="contents">
|
||||
{pages.map((item, index) => <li key={item.slug}><button className="button" aria-current={page === index ? 'page' : undefined}
|
||||
onClick={() => go(index)}><code>{String(index + 1).padStart(2, '0')}</code>{item.title}</button></li>)}
|
||||
</ol></Dialog>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<App />);
|
||||
438
async-agent-lab/web/src/stage/stage.css
Normal file
438
async-agent-lab/web/src/stage/stage.css
Normal file
@@ -0,0 +1,438 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--paper: #f8f7f4;
|
||||
--panel: #ffffff;
|
||||
--ink: #292b2e;
|
||||
--muted: #64676c;
|
||||
--line: #e1e1dd;
|
||||
--terracotta: #99543d;
|
||||
--tint: #f4eae4;
|
||||
--green: #3b614b;
|
||||
--mono: "IBM Plex Mono", Menlo, Consolas, "Liberation Mono", "PingFang SC", monospace;
|
||||
--serif: "Inter", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
font-family: "PingFang SC", "Noto Sans CJK SC", "Microsoft YaHei", "Inter", system-ui, sans-serif;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-synthesis: none;
|
||||
line-height: 1.5;
|
||||
scroll-padding-block: 65px;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; }
|
||||
button, input { font: inherit; }
|
||||
a { color: inherit; }
|
||||
button { color: inherit; cursor: pointer; }
|
||||
button:disabled { cursor: default; }
|
||||
button:focus-visible, a:focus-visible, [tabindex="0"]:focus-visible, input:focus-visible {
|
||||
outline: 3px solid #ae573e;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
[hidden] { display: none !important; }
|
||||
h1, h2, h3, p, dl, dd, pre { margin: 0; }
|
||||
code, pre, kbd, time { font-family: var(--mono); }
|
||||
a:focus-visible, button:focus-visible { position: relative; z-index: 1; }
|
||||
|
||||
.deck { min-height: 100dvh; display: flex; flex-direction: column; }
|
||||
.deck-head, .deck-foot {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 9px clamp(22px, 3.15vw, 60px);
|
||||
position: sticky; z-index: 2; background: var(--paper);
|
||||
}
|
||||
.deck-head { top: 0; border-bottom: 1px solid var(--line); }
|
||||
.deck-foot { bottom: 0; border-top: 1px solid var(--line); gap: 24px; }
|
||||
.deck-title { display: flex; align-items: center; gap: 11px; font-size: 15px; font-weight: 700; text-decoration: none; }
|
||||
.deck-title > span { width: 10px; height: 10px; border: 2px solid var(--terracotta); transform: rotate(45deg); }
|
||||
.deck-actions, .page-buttons, .demo-actions, .trace-output, .tabs { display: flex; gap: 8px; align-items: center; }
|
||||
.page-index { flex-shrink: 0; font: 15px var(--mono); color: var(--muted); }
|
||||
.page-index b { font-size: 20px; color: var(--ink); }
|
||||
.page-track { display: flex; align-items: center; gap: 5px; flex: 1; max-width: 375px; }
|
||||
.page-track button { border: 0; background: transparent; flex: 1; min-width: 9px; padding: 9px 0; }
|
||||
.page-track button::before { content: ""; display: block; border-radius: 3px; height: 4px; background: #cfc4b3; }
|
||||
.page-track button:hover::before, .page-track [aria-current]::before { background: var(--terracotta); }
|
||||
.button {
|
||||
border: 1px solid #c9bdac; border-radius: 7px; color: var(--ink);
|
||||
background: var(--panel); padding: 8px 13px; font-weight: 500;
|
||||
font-size: 15px; white-space: nowrap; line-height: 1.35;
|
||||
}
|
||||
.button:not(:disabled):hover { border-color: var(--terracotta); background: #f5ece0; }
|
||||
.button:disabled { opacity: .49; }
|
||||
.button.primary { color: #fffaf4; background: var(--terracotta); border-color: var(--terracotta); }
|
||||
.button.primary:not(:disabled):hover { color: white; background: #862f1f; }
|
||||
.button.small { padding: 6px 11px; font-size: 14px; }
|
||||
.button span[aria-hidden] { margin-inline: 2px; }
|
||||
.deck-main { min-width: 0; flex: 1; }
|
||||
.slide { width: 100%; max-width: 1480px; margin: 0 auto; padding: 32px 40px 48px; }
|
||||
.slide h1 { font-family: var(--serif); font-weight: 600; line-height: 1.35; letter-spacing: -.015em; }
|
||||
.explain { display: flex; flex-direction: column; gap: clamp(16px, 3vh, 36px); }
|
||||
.slide-heading h1 { font-size: 16px; }
|
||||
.slide-heading p { font-size: 16px; color: var(--muted); margin-top: 12px; }
|
||||
|
||||
.intro { display: grid; min-height: calc(100dvh - 112px); grid-template-columns: minmax(0, 1.24fr) minmax(0, .76fr); gap: clamp(30px, 6vw, 110px); align-content: center; align-items: center; padding-block: 34px 56px; }
|
||||
.intro h1 { font-size: 16px; }
|
||||
.intro h1 span { display: block; color: var(--terracotta); margin-top: 9px; }
|
||||
.intro-copy p { margin-top: 25px; font-size: 16px; max-width: 24em; color: var(--muted); }
|
||||
.intro-next { margin-top: 28px; padding: 11px 18px; font-size: 17px; }
|
||||
.intro-figure { align-self: center; }
|
||||
.intro-ask, .intro-work {
|
||||
position: relative; display: flex; flex-wrap: wrap; align-items: center; gap: 9px;
|
||||
padding: clamp(16px, 2vw, 28px); border: 1px solid var(--line); border-radius: 11px;
|
||||
background: var(--panel); color: var(--muted); font-size: 16px;
|
||||
}
|
||||
.intro-ask { background: var(--tint); border-color: #dcb8a4; }
|
||||
.intro-work .signal-dot { background: var(--green); }
|
||||
.intro-figure strong { width: 100%; font-family: var(--serif); color: var(--ink); font-size: 16px; font-weight: 600; line-height: 1.32; }
|
||||
.intro-figure code { margin-left: auto; font-size: .82em; color: var(--terracotta); }
|
||||
.intro-flow { margin-left: 25px; width: 12px; height: 38px; display: grid; justify-items: center; }
|
||||
.intro-flow span { border-left: 1px solid #bb8d76; }
|
||||
.intro-flow i { background: #bb8d76; width: 5px; height: 5px; border-radius: 50%; align-self: center; }
|
||||
.intro-note { margin: 17px 0 0; color: var(--muted); font-size: 16px; }
|
||||
|
||||
.code-card {
|
||||
border-radius: 10px; border: 1px solid var(--line); background: #fafafa; color: #33363b;
|
||||
display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden;
|
||||
}
|
||||
.code-card header {
|
||||
min-width: 0; padding: 10px 15px; border-bottom: 1px solid var(--line); color: #64676c;
|
||||
background: #f2f2ef; font: 650 clamp(12px, 1.1vw, 17px)/1.35 var(--mono); overflow-wrap: anywhere;
|
||||
}
|
||||
.code-card a { text-decoration: none; }
|
||||
.code-card a:hover { text-decoration: underline; }
|
||||
.code-card a span { color: #f1a38b; }
|
||||
.code-scroll { min-height: 0; padding-block: 12px; }
|
||||
.code-card pre { min-width: 0; tab-size: 2; font-size: 16px; line-height: 1.55; }
|
||||
.compact-code pre { font-size: 16px; line-height: 1.48; }
|
||||
.code-line { display: flex; min-width: 0; padding-right: 12px; border-left: 3px solid transparent; }
|
||||
.line-no { flex: 0 0 40px; color: #73777c; text-align: right; padding-right: 10px; user-select: none; }
|
||||
.line-text { flex: 1; min-width: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.code-line:has(.line-text:first-child) { padding-left: 12px; }
|
||||
.focus-line { background: #f4eae4; border-left-color: var(--terracotta); }
|
||||
.token-keyword { color: #884a69; }
|
||||
.token-string { color: #416447; }
|
||||
.token-comment { color: #6b7279; }
|
||||
.tools-grid, .compare-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: clamp(14px, 2.2vw, 34px); min-height: 0; }
|
||||
.tool-example { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
|
||||
.contract { border: 1px solid var(--line); border-radius: 9px; background: var(--panel); padding: 2px 14px; }
|
||||
.contract div { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 15px; padding: 9px 0; }
|
||||
.contract div + div { border-top: 1px solid var(--line); }
|
||||
.contract span { font-weight: 600; }
|
||||
.contract code { font-size: 16px; color: var(--terracotta); overflow-wrap: anywhere; }
|
||||
.tool-list { border-top: 1px solid var(--line); }
|
||||
.tool-list > div { display: grid; align-items: baseline; grid-template-columns: 95px 1fr; gap: 15px; padding: clamp(11px, 2.1vh, 21px) 0; border-bottom: 1px solid var(--line); }
|
||||
.tool-list dt { font: 700 clamp(19px, 2vw, 32px) var(--mono); color: var(--terracotta); }
|
||||
.tool-list dd { font-size: 16px; }
|
||||
.speaker-note { border-left: 3px solid var(--terracotta); padding-left: 15px; color: var(--muted); font-size: 16px; }
|
||||
.speaker-note a { color: var(--terracotta); text-underline-offset: 3px; }
|
||||
.return-diagram { display: grid; grid-template-columns: 1fr auto 1fr auto 1fr; gap: clamp(10px, 2.3vw, 38px); align-items: center; padding-block: 12px; }
|
||||
.return-sources { display: grid; gap: 9px; }
|
||||
.return-sources > div, .return-node { display: grid; gap: 4px; border: 1px solid var(--line); border-radius: 10px; padding: clamp(12px, 1.7vw, 25px); background: var(--panel); }
|
||||
.return-node { min-height: 144px; align-content: center; }
|
||||
.return-node.last { background: var(--tint); border-color: #d5a996; }
|
||||
.return-diagram strong { font-family: var(--serif); font-size: 16px; }
|
||||
.return-diagram span { color: var(--muted); font-size: 16px; }
|
||||
.diagram-arrow { color: var(--terracotta); font-size: 16px; }
|
||||
|
||||
.demo { display: flex; flex-direction: column; gap: clamp(14px, 2.4vh, 28px); }
|
||||
.demo-heading { display: flex; justify-content: space-between; align-items: end; gap: 15px; }
|
||||
.demo-heading h1 { font-size: 16px; }
|
||||
.demo-heading p { font-size: 16px; color: var(--muted); margin-top: 7px; }
|
||||
.demo-label { color: var(--terracotta); font-size: 15px; font-weight: 600; margin-bottom: 4px; }
|
||||
.demo-actions { flex-shrink: 0; }
|
||||
.demo-columns { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: start; gap: clamp(15px, 2.1vw, 40px); }
|
||||
.demo-source { display: flex; flex-direction: column; align-items: stretch; gap: 9px; min-height: 0; min-width: 0; }
|
||||
.demo-source .code-card { flex: 1; }
|
||||
.cue { padding-left: 11px; border-left: 2px solid var(--terracotta); color: var(--muted); font-size: 16px; }
|
||||
.tab {
|
||||
padding: 5px 10px; border: 1px solid transparent; border-radius: 6px;
|
||||
background: transparent; color: var(--muted); font-size: 14px; font-weight: 500; white-space: nowrap;
|
||||
}
|
||||
.tab:hover { color: var(--terracotta); }
|
||||
.tab[aria-pressed="true"] { background: var(--tint); color: var(--terracotta); border-color: #ddbdad; }
|
||||
|
||||
.trace-panel { display: flex; flex-direction: column; min-width: 0; border: 1px solid var(--line); background: var(--panel); border-radius: 10px; }
|
||||
.trace-head {
|
||||
display: flex; align-items: center; flex-wrap: wrap; gap: 6px 12px;
|
||||
padding: 9px 13px; border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.trace-head h2 { font-family: var(--serif); font-weight: 600; font-size: 16px; }
|
||||
.status { margin-left: auto; display: flex; align-items: center; gap: 6px; font-size: 16px; font-weight: 500; }
|
||||
.signal-dot { flex: 0 0 auto; width: 8px; height: 8px; display: inline-block; border-radius: 50%; background: #a19380; }
|
||||
.intro-ask .signal-dot, .signal-dot.live { background: var(--terracotta); }
|
||||
.signal-dot.live { box-shadow: 0 0 0 3px #efd2c1; }
|
||||
.signal-dot.success { background: var(--green); }
|
||||
.request-count { font: 13px var(--mono); color: var(--muted); border: 1px solid var(--line); border-radius: 6px; padding: 3px 6px; }
|
||||
.request-count b { color: var(--ink); font-size: 16px; }
|
||||
.signals { display: flex; padding: 9px; gap: 5px; border-bottom: 1px solid var(--line); }
|
||||
.signal {
|
||||
display: grid; flex: 1 1 0; align-content: space-between; gap: 3px; text-align: left; min-width: 0;
|
||||
border: 1px solid #e3d9ca; border-radius: 6px; background: #f7f2e8; padding: 6px 7px;
|
||||
font-size: 16px; font-weight: 500; color: var(--muted); line-height: 1.3;
|
||||
}
|
||||
.signal time { font-size: .9em; }
|
||||
.signal.seen { background: var(--tint); color: #773a29; border-color: #dec0af; }
|
||||
.signal.seen:hover { border-color: var(--terracotta); }
|
||||
.trace-list-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--muted); padding: 6px 12px 3px; font-size: 13px; }
|
||||
.text-button { border: 0; background: none; padding: 2px 0; color: var(--terracotta); text-decoration: underline; text-underline-offset: 3px; font-size: 13px; }
|
||||
.trace-list { padding: 0 10px 5px; }
|
||||
.trace-row {
|
||||
display: grid; grid-template-columns: 55px minmax(0, 1fr) minmax(0, 125px) 11px; align-items: center;
|
||||
text-align: left; gap: 8px; width: 100%; padding: 7px 4px; min-width: 0;
|
||||
background: transparent; border: 0; border-bottom: 1px solid #e9e0d4;
|
||||
}
|
||||
.trace-row:last-child { border-bottom: 0; }
|
||||
.trace-row:hover { background: #f7ecdf; }
|
||||
.trace-row time, .trace-row code { font-size: 16px; color: var(--muted); }
|
||||
.trace-row code { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; text-align: right; }
|
||||
.row-event { display: flex; flex-wrap: wrap; align-items: baseline; gap: 1px 9px; overflow-wrap: anywhere; }
|
||||
.row-event strong { font-size: 16px; line-height: 1.3; }
|
||||
.row-event > span { font-size: 16px; color: var(--muted); }
|
||||
.trace-row.job strong, .trace-row.question strong { color: #813c2a; }
|
||||
.trace-row.report strong { color: var(--green); }
|
||||
.row-arrow { color: var(--terracotta); font-size: 20px; line-height: 1; }
|
||||
.empty-trace { padding: 13px 3px; color: var(--muted); font-size: 15px; }
|
||||
.trace-output { justify-content: flex-end; flex-wrap: wrap; padding: 8px 10px; border-bottom: 1px solid var(--line); }
|
||||
|
||||
.question-zone { border-bottom: 1px solid #dbc0af; background: #f8ede3; }
|
||||
.question-tabs { padding: 5px 10px 0; }
|
||||
.question-form { display: grid; gap: 8px; padding: 10px 11px; }
|
||||
.question-title { display: flex; align-items: baseline; flex-shrink: 0; gap: 8px; font-weight: 600; font-size: 16px; }
|
||||
.question-title code { color: var(--terracotta); font-size: 13px; }
|
||||
.answer-line { display: flex; align-items: stretch; gap: 6px; }
|
||||
.answer-line input { min-width: 0; flex: 1; color: var(--ink); background: var(--panel); border: 1px solid #c5ab97; border-radius: 6px; padding: 6px 9px; }
|
||||
.answer-line input::placeholder { color: #73685a; }
|
||||
.choices { display: grid; gap: 4px; }
|
||||
.choices label { display: flex; align-items: start; gap: 8px; padding: 4px 6px; background: var(--panel); border-radius: 5px; font-size: 16px; cursor: pointer; }
|
||||
.choices input { width: 16px; height: 16px; margin-top: 3px; flex: 0 0 auto; accent-color: var(--terracotta); }
|
||||
.selection-footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-shrink: 0; }
|
||||
.selection-footer span { font-size: 13px; color: var(--muted); }
|
||||
.error, .connection-error, .trace-error { color: #9b3324; }
|
||||
.trace-error { padding: 6px 12px; font-size: 14px; border-bottom: 1px solid #e7c5b7; background: var(--tint); }
|
||||
|
||||
.detail-dialog { width: min(940px, 93vw); max-height: 88dvh; overflow: hidden; padding: 0; border: 1px solid #c7ad99; border-radius: 11px; background: var(--panel); color: var(--ink); box-shadow: 0 17px 60px #20160f40; }
|
||||
.detail-dialog::backdrop { background: #221b16a6; }
|
||||
.detail-inner { display: flex; flex-direction: column; max-height: 87dvh; }
|
||||
.detail-inner > header { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 20px; border-bottom: 1px solid var(--line); }
|
||||
.detail-inner h2 { font-family: var(--serif); font-size: 25px; overflow-wrap: anywhere; }
|
||||
.detail-inner kbd { font-size: 12px; color: var(--muted); margin-left: 3px; }
|
||||
.detail-scroll { min-height: 0; overflow: auto; padding: 16px 20px 22px; font-size: 17px; }
|
||||
.detail-scroll h3 { margin-top: 13px; }
|
||||
.detail-meta { margin-bottom: 9px; color: var(--muted); overflow-wrap: anywhere; }
|
||||
.detail-tabs { margin: 0 0 11px; }
|
||||
.payload { background: #f3ecdf; color: #372a20; padding: 11px 12px; font-size: 15px; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; border-radius: 6px; }
|
||||
.message-list { display: grid; gap: 6px; }
|
||||
.message-list details { border: 1px solid var(--line); border-radius: 6px; padding: 6px 9px; }
|
||||
.message-list summary { cursor: pointer; overflow-wrap: anywhere; }
|
||||
.message-list summary span, .detail-ref { margin-left: 8px; }
|
||||
.detail-ref { color: var(--terracotta); font-size: 14px; }
|
||||
.message-list .payload { margin-top: 5px; }
|
||||
.response-raw { margin-top: 14px; }
|
||||
.response-raw summary { cursor: pointer; color: var(--terracotta); }
|
||||
.evidence-sources, .evidence-repos { margin: 8px 0; padding: 0; list-style: none; }
|
||||
.evidence-sources { display: flex; flex-wrap: wrap; gap: 6px 18px; padding-bottom: 8px; }
|
||||
.evidence-sources span, .evidence-repos span { color: var(--muted); }
|
||||
.evidence-repos li { display: flex; flex-wrap: wrap; gap: 2px 12px; padding: 8px 0; border-top: 1px solid var(--line); overflow-wrap: anywhere; }
|
||||
.report h1 { font-size: 32px; }
|
||||
.report h2 { margin: 16px 0 6px; }
|
||||
.report li { margin-block: 5px; }
|
||||
.report ul, .report ol { padding-left: 28px; }
|
||||
.contents { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
|
||||
.contents button { width: 100%; text-align: left; padding: 10px; }
|
||||
.contents button[aria-current] { background: var(--tint); border-color: #d6a88e; }
|
||||
.contents code { color: var(--terracotta); margin-right: 10px; }
|
||||
|
||||
@media (min-width: 1600px) {
|
||||
.button { font-size: 17px; }
|
||||
.trace-row { grid-template-columns: 72px minmax(0, 1fr) minmax(0, 175px) 15px; padding-block: 9px; }
|
||||
.trace-head, .signals { padding: 13px 15px; }
|
||||
}
|
||||
@media (max-width: 1050px) {
|
||||
.intro { gap: 20px; }
|
||||
.intro h1 { font-size: 16px; }
|
||||
.demo-heading { flex-wrap: wrap; }
|
||||
.demo-actions { flex-wrap: wrap; flex-shrink: 1; }
|
||||
.demo-columns { grid-template-columns: 1fr; }
|
||||
.trace-panel { order: -1; }
|
||||
.compare-grid { grid-template-columns: 1fr; }
|
||||
.trace-row { grid-template-columns: 55px minmax(0, 1fr) minmax(0, 115px) 11px; }
|
||||
.page-track { max-width: 240px; }
|
||||
}
|
||||
@media (max-width: 610px) {
|
||||
.deck-head, .deck-foot { padding-inline: 12px; gap: 8px; }
|
||||
.deck-title { font-size: 13px; gap: 5px; }
|
||||
.deck-title > span { width: 7px; height: 7px; }
|
||||
.deck-head .button { padding-inline: 7px; }
|
||||
.page-track { display: none; }
|
||||
.connection-error { font-size: 12px; }
|
||||
.slide { padding: 17px 14px; }
|
||||
.intro { grid-template-columns: 1fr; min-height: auto; align-content: start; }
|
||||
.intro-copy p, .intro-next { margin-top: 14px; }
|
||||
.intro-figure { margin-top: 5px; }
|
||||
.tools-grid, .compare-grid { grid-template-columns: 1fr; min-height: auto; }
|
||||
.return-diagram { grid-template-columns: 1fr; gap: 8px; }
|
||||
.return-node { min-height: 0; }
|
||||
.diagram-arrow { line-height: 1; justify-self: center; rotate: 90deg; }
|
||||
.trace-row { grid-template-columns: 44px minmax(0, 1fr) minmax(0, 75px) 9px; gap: 5px; }
|
||||
.signal { overflow-wrap: anywhere; }
|
||||
.detail-scroll, .detail-inner > header { padding-inline: 11px; }
|
||||
.contents { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* Shared reading scale and surfaces for the lesson and live workspace. */
|
||||
:root {
|
||||
--text-small: 13px;
|
||||
--text-body: 16px;
|
||||
--text-title: 28px;
|
||||
--radius: 8px;
|
||||
--transition: 160ms ease;
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
body { line-height: 1.7; }
|
||||
.deck-head, .deck-foot { padding: 8px 32px; }
|
||||
.deck-title { font-size: 14px; font-weight: 600; }
|
||||
.button, .tab, .mode-tabs button, .trace-row { transition: background-color var(--transition), border-color var(--transition), color var(--transition); }
|
||||
.button { font-size: 14px; min-height: 36px; padding: 7px 12px; font-weight: 500; }
|
||||
.button.small { font-size: 13px; min-height: 32px; }
|
||||
.slide-heading h1, .demo-heading h1 { font-size: var(--text-title); font-weight: 600; }
|
||||
.slide-heading p, .demo-heading p { font-size: 16px; line-height: 1.7; margin-top: 8px; max-width: 68ch; }
|
||||
.slide-heading { max-width: 1000px; }
|
||||
.demo-label { font-size: 12px; font-weight: 600; margin-bottom: 8px; letter-spacing: .04em; }
|
||||
.intro { min-height: auto; padding-block: 64px; grid-template-columns: 1.1fr 1fr; gap: 64px; }
|
||||
.intro h1 { font-size: 40px; line-height: 1.35; font-weight: 600; }
|
||||
.intro-copy p { font-size: 17px; line-height: 1.85; max-width: 32em; margin-top: 20px; }
|
||||
.intro h1 span { margin-top: 0; }
|
||||
.intro-figure strong { font-size: 19px; font-weight: 500; }
|
||||
.intro-ask, .intro-work { padding: 20px 24px; font-size: 14px; }
|
||||
.intro-next { margin-top: 24px; font-size: 14px; padding: 9px 16px; }
|
||||
.intro-note { font-size: 13px; line-height: 1.8; }
|
||||
.explain { gap: 28px; }
|
||||
.demo-heading { align-items: center; }
|
||||
.demo { gap: 24px; }
|
||||
.demo-actions { flex-wrap: wrap; justify-content: flex-end; }
|
||||
.demo-columns { grid-template-columns: minmax(0, 1.15fr) minmax(0, .85fr); gap: 24px; }
|
||||
.lesson-columns, .reading-grid { display: grid; grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); gap: 40px; align-items: start; }
|
||||
.lesson { font-size: 16px; color: var(--ink); max-width: 72ch; line-height: 1.85; }
|
||||
.lesson h2 { font-size: 19px; font-weight: 600; margin: 0 0 12px; }
|
||||
.lesson p { margin: 0 0 20px; }
|
||||
.lesson h2:not(:first-child) { margin-top: 28px; }
|
||||
.lesson h3 { font-size: 16px; font-weight: 600; margin-bottom: 6px; }
|
||||
.lesson ol { padding-left: 22px; margin: 24px 0; }
|
||||
.lesson li { padding-left: 6px; margin-bottom: 20px; }
|
||||
.lesson li::marker { color: var(--terracotta); font-size: 14px; }
|
||||
.lesson li p { color: var(--muted); margin-bottom: 0; }
|
||||
.lesson aside { padding: 16px 20px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); }
|
||||
.lesson aside p { margin: 0; font-size: 15px; }
|
||||
.mode-tabs { display: flex; align-items: center; gap: 4px; border-bottom: 1px solid var(--line); padding-bottom: 12px; }
|
||||
.mode-tabs button { background: transparent; border: 1px solid transparent; padding: 8px 16px; border-radius: 6px; font-size: 14px; min-height: 40px; }
|
||||
.mode-tabs button[aria-pressed="true"] { background: var(--panel); border-color: #d4d5d1; color: var(--ink); box-shadow: 0 1px 2px #24242408; font-weight: 600; }
|
||||
.mode-tabs button:hover { background: var(--tint); }
|
||||
.mode-tabs > span { font-size: 13px; color: var(--muted); margin-left: auto; padding-left: 16px; }
|
||||
.code-card { border-radius: var(--radius); }
|
||||
.code-card header { font-size: 12px; font-weight: 500; padding: 10px 14px; }
|
||||
.code-card pre, .compact-code pre { font-size: 13px; line-height: 1.75; }
|
||||
.line-no { font-size: 11px; flex-basis: 36px; }
|
||||
.code-scroll { padding-block: 14px; }
|
||||
.cue { font-size: 14px; line-height: 1.7; }
|
||||
.source-tabs { gap: 4px; flex-wrap: wrap; }
|
||||
.tab { font-size: 13px; font-weight: 500; }
|
||||
.trace-panel, .conversation-panel { border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); min-width: 0; overflow: hidden; }
|
||||
.trace-head, .panel-heading { padding: 14px 16px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px; border-bottom: 1px solid var(--line); }
|
||||
.trace-head h2, .panel-heading h2 { font-size: 16px; font-weight: 600; }
|
||||
.panel-heading > span { font-size: 12px; color: var(--muted); margin-left: auto; }
|
||||
.status { font-size: 12px; font-weight: 400; }
|
||||
.request-count { font-size: 11px; }
|
||||
.request-count b { font-size: 12px; font-weight: 500; }
|
||||
.signals { padding: 12px; gap: 6px; }
|
||||
.signal { font-size: 12px; font-weight: 500; background: #f7f7f4; border-color: var(--line); padding: 8px; }
|
||||
.signal.seen { background: #f6eee9; border-color: #e2cfc3; color: #81462f; }
|
||||
.trace-list { padding: 0 12px 8px; }
|
||||
.trace-row { grid-template-columns: 56px minmax(0, 1fr) 80px 8px; gap: 8px; padding: 11px 0; }
|
||||
.trace-row time, .trace-row code { font-size: 11px; }
|
||||
.row-event { display: block; }
|
||||
.row-event strong { display: block; font-size: 13px; font-weight: 500; }
|
||||
.row-event > span { font-size: 11px; color: var(--muted); display: block; margin-top: 3px; }
|
||||
.trace-row.job strong, .trace-row.question strong { color: var(--ink); }
|
||||
.role-tag { display: inline-flex; align-items: center; justify-content: center; border: 1px solid #dfe3e8; border-radius: 4px; padding: 2px 6px; font-size: 11px; font-weight: 500; line-height: 1.5; color: #4b5563; background: #f2f4f6; white-space: nowrap; width: fit-content; }
|
||||
.role-tag.model { background: #edf2fa; border-color: #d6e0f0; color: #405a82; }
|
||||
.role-tag.tool { background: #edf4ef; border-color: #d4e4d8; color: #3b6249; }
|
||||
.role-tag.user { background: #f8eee8; border-color: #ead7c9; color: #88523b; }
|
||||
.role-tag.runtime { background: #f2eef8; border-color: #e2d9ed; color: #66517e; }
|
||||
.conversation-body { padding: 20px; }
|
||||
.chat-message { margin-bottom: 24px; }
|
||||
.chat-message > .role-tag { margin-bottom: 8px; }
|
||||
.chat-message p { font-size: 15px; line-height: 1.8; }
|
||||
.chat-message.user { background: #f6f5f2; border: 1px solid #e9e7e1; border-radius: 8px; padding: 14px 16px; }
|
||||
.chat-prose { font-size: 15px; line-height: 1.8; overflow-wrap: anywhere; }
|
||||
.chat-prose p, .chat-prose ul, .chat-prose ol { margin: 8px 0; }
|
||||
.chat-prose h1, .chat-prose h2, .chat-prose h3 { font-size: 16px; line-height: 1.6; font-weight: 600; margin: 16px 0 8px; }
|
||||
.chat-prose pre { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 13px; }
|
||||
.chat-prose ul, .chat-prose ol { padding-left: 22px; }
|
||||
.conversation-empty { font-size: 14px; color: var(--muted); line-height: 1.8; padding: 8px 0; }
|
||||
.chat-tool { margin: 16px 0 24px; padding: 12px 14px; border: 1px solid var(--line); border-radius: 7px; font-size: 13px; }
|
||||
.chat-tool header { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
|
||||
.chat-tool header strong { font-size: 13px; font-weight: 500; }
|
||||
.chat-tool header > span:last-child { color: var(--muted); font-size: 11px; margin-left: auto; }
|
||||
.tool-command { display: block; font-size: 12px; margin: 8px 0; overflow-wrap: anywhere; color: var(--muted); }
|
||||
.conversation-output { font-size: 12px; line-height: 1.7; white-space: pre-wrap; overflow-wrap: anywhere; max-height: 220px; overflow: auto; background: #f8f8f6; padding: 10px; border-radius: 4px; }
|
||||
.tool-evidence ul { list-style: none; padding: 0; margin: 8px 0; }
|
||||
.tool-evidence li { display: flex; flex-wrap: wrap; gap: 4px 12px; padding: 5px 0; }
|
||||
.tool-evidence li strong { font-weight: 500; }
|
||||
.tool-evidence li span { color: var(--muted); }
|
||||
.answer-text { white-space: pre-wrap; }
|
||||
.question-zone { background: #fcf8f4; border-top: 1px solid #e5d8cc; border-bottom: 0; }
|
||||
.question-form { padding: 16px 20px; gap: 12px; }
|
||||
.question-title, .choices label { font-size: 14px; font-weight: 400; }
|
||||
.choices label { padding: 9px; border: 1px solid var(--line); }
|
||||
.answer-line input { font-size: 16px; }
|
||||
.speaker-note { font-size: 15px; line-height: 1.85; }
|
||||
.tool-list dt { font-size: 17px; font-weight: 500; }
|
||||
.tool-list dd { font-size: 15px; }
|
||||
.tool-list > div { padding-block: 16px; }
|
||||
.return-diagram strong { font-size: 18px; font-weight: 500; }
|
||||
.return-diagram span { font-size: 14px; }
|
||||
.diagram-arrow { font-size: 24px; }
|
||||
.return-node { min-height: 120px; }
|
||||
.loop-steps { border: 1px solid var(--line); background: var(--panel); border-radius: 8px; padding: 24px; }
|
||||
.loop-steps > div { display: flex; gap: 16px; padding: 16px 0; }
|
||||
.loop-steps > div + div { border-top: 1px solid var(--line); }
|
||||
.loop-step-number { display: grid; place-items: center; width: 28px; height: 28px; flex: 0 0 auto; border-radius: 50%; background: var(--tint); color: var(--terracotta); font-size: 13px; }
|
||||
.loop-steps h3 { font-size: 16px; font-weight: 500; margin-bottom: 4px; }
|
||||
.loop-steps p { font-size: 14px; color: var(--muted); }
|
||||
.loop-return { padding-top: 16px; border-top: 1px solid var(--line); }
|
||||
.detail-inner h2 { font-size: 20px; font-weight: 600; }
|
||||
.detail-scroll { font-size: 15px; line-height: 1.8; }
|
||||
.report h1 { font-size: 24px; }
|
||||
.report h2 { font-size: 19px; }
|
||||
.payload { font-size: 13px; background: #f5f5f2; }
|
||||
.page-index b { font-size: 15px; }
|
||||
.page-index { font-size: 12px; }
|
||||
.page-track { max-width: 280px; }
|
||||
.page-track button::before { height: 3px; }
|
||||
@media (max-width: 1100px) {
|
||||
.lesson-columns, .reading-grid { grid-template-columns: 1fr; gap: 24px; }
|
||||
.demo-columns { grid-template-columns: 1fr; }
|
||||
.trace-panel { order: 0; }
|
||||
.lesson { max-width: none; }
|
||||
.demo-heading { align-items: start; flex-wrap: wrap; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.slide { padding: 24px 20px 40px; }
|
||||
.deck-head, .deck-foot { padding: 8px 16px; }
|
||||
.intro { grid-template-columns: 1fr; gap: 32px; padding-block: 32px; }
|
||||
.intro h1 { font-size: 32px; }
|
||||
.slide-heading h1, .demo-heading h1 { font-size: 25px; }
|
||||
.mode-tabs { flex-wrap: wrap; }
|
||||
.mode-tabs > span { width: 100%; margin: 4px 0 0; padding: 0; }
|
||||
.button, .button.small, .tab, .mode-tabs button { min-height: 44px; }
|
||||
.trace-row { grid-template-columns: 52px minmax(0, 1fr) 64px 8px; }
|
||||
.conversation-body { padding: 16px; }
|
||||
.deck-title { font-size: 12px; }
|
||||
.question-form { padding: 14px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { transition: none !important; animation: none !important; scroll-behavior: auto !important; }
|
||||
}
|
||||
|
||||
.section-prose { max-width: 86ch; font-size: 16px; line-height: 1.85; color: var(--muted); }
|
||||
|
||||
.deck-foot { flex-wrap: wrap; }
|
||||
.page-track { min-width: 140px; }
|
||||
.conversation-panel > .question-zone { border-top: 0; border-bottom: 1px solid #e5d8cc; }
|
||||
111
async-agent-lab/web/src/stage/trace.ts
Normal file
111
async-agent-lab/web/src/stage/trace.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { LiveEvent, Question, ScenarioId } from '../../../live/types.ts';
|
||||
import type { Evidence } from '../../../live/tools.ts';
|
||||
|
||||
export type Row = { event: LiveEvent; route: string; label: string; meta: string; kind: 'model' | 'tool' | 'job' | 'question' | 'report'; key: boolean };
|
||||
|
||||
export function evidenceOutput(output: string): Evidence | undefined {
|
||||
try {
|
||||
const result = JSON.parse(output);
|
||||
if (result.exit_code !== 0 || typeof result.stdout !== 'string') return;
|
||||
const evidence = JSON.parse(result.stdout);
|
||||
if (Array.isArray(evidence.repositories) && Array.isArray(evidence.sources)) return evidence;
|
||||
} catch {
|
||||
// Other tool results can contain plain text.
|
||||
}
|
||||
}
|
||||
|
||||
export function rows(events: LiveEvent[], scenario: ScenarioId): Row[] {
|
||||
const result: Row[] = [];
|
||||
const refs = new Set<string>();
|
||||
for (const event of events) {
|
||||
function add(route: string, label: string, meta: string, kind: Row['kind'], key = true) {
|
||||
result.push({ event, route, label, meta, kind, key });
|
||||
}
|
||||
switch (event.type) {
|
||||
case 'http.request': {
|
||||
const deliveries = event.deliveries?.filter((item) => !refs.has(item.ref)) ?? [];
|
||||
for (const item of deliveries) refs.add(item.ref);
|
||||
const ref = deliveries.map((item) => item.ref).join(' · ');
|
||||
add('Loop → 模型', ref ? `请求 #${event.n} 携带 ${ref}` : `发出模型请求 #${event.n}`,
|
||||
event.attempt > 1 ? `重试 ${event.attempt}` : ref || 'Chat Completions', 'model');
|
||||
break;
|
||||
}
|
||||
case 'http.response':
|
||||
add('网关 → Loop', `HTTP ${event.status}`, `#${event.n} · 尝试 ${event.attempt}`, 'model', event.status !== 200);
|
||||
break;
|
||||
case 'http.error':
|
||||
add('网关 → Loop', '请求失败', `#${event.n} · 尝试 ${event.attempt}`, 'model');
|
||||
break;
|
||||
case 'model.result': {
|
||||
const names = [...new Set(event.response.message.tool_calls?.map((call) => call.function.name) ?? [])].join(' · ');
|
||||
add('模型 → Loop', names ? `模型返回 ${names} 调用` : scenario === 'background' && event.n === 2 ? '模型返回周报结构' : '模型返回文本',
|
||||
`请求 #${event.n}`, 'model', Boolean(names && scenario === 'sync') || (scenario === 'background' && event.n === 2));
|
||||
break;
|
||||
}
|
||||
case 'tool.call': {
|
||||
const command = (event.args as { command?: string } | null)?.command;
|
||||
add('Loop → 工具', command || event.name, event.callId, 'tool',
|
||||
scenario === 'async' && event.name !== 'question');
|
||||
break;
|
||||
}
|
||||
case 'tool.result': {
|
||||
const { name, ref, result: output } = event;
|
||||
const failed = output.startsWith('{"error":');
|
||||
const evidence = name === 'bash' && !ref ? evidenceOutput(output) : undefined;
|
||||
const label = failed ? `${name} 返回错误` :
|
||||
name === 'bash' && ref ? 'Bash 返回启动回执' :
|
||||
evidence ? `Bash 核查了 ${evidence.repositories.length} 个仓库` :
|
||||
name === 'bash' ? 'Bash 返回取证结果' :
|
||||
name === 'question' && scenario === 'blocking' ? 'question 返回用户答案' :
|
||||
name === 'question' ? 'question 返回 accepted' : `${name} 返回`;
|
||||
add('工具 → Loop', label, ref ?? event.callId, name === 'question' ? 'question' : 'tool',
|
||||
failed || name === 'bash' || name === 'question');
|
||||
break;
|
||||
}
|
||||
case 'job.start':
|
||||
add('Bash → 进程', '启动取证进程', event.jobId, 'job', false);
|
||||
break;
|
||||
case 'job.exit':
|
||||
add('进程 → 运行时', `进程退出 · exit ${event.code ?? '—'}`, event.jobId, 'job');
|
||||
break;
|
||||
case 'inbox.push':
|
||||
add('运行时 → 收件箱', event.item.kind === 'job_done' ? '完成通知入箱' : '网页答复入箱',
|
||||
event.item.ref || event.item.id, event.item.kind === 'job_done' ? 'job' : 'question');
|
||||
break;
|
||||
case 'inbox.deliver':
|
||||
add('收件箱 → 历史', '通知加入模型历史', event.items.map((item) => item.ref).join(' · '), 'model', false);
|
||||
break;
|
||||
case 'question.open':
|
||||
add('工具 → 网页', event.question.kind === 'priority' ? '询问下周优先事项' : '请讲者确认本周工作项',
|
||||
event.question.id, 'question');
|
||||
break;
|
||||
case 'question.answer':
|
||||
add('网页 → 运行时', '讲者提交答复', event.questionId, 'question');
|
||||
break;
|
||||
case 'report.written':
|
||||
add('write → 工作目录', '写入 weekly-report.md', '周报', 'report');
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function questions(events: LiveEvent[]) {
|
||||
const pending = new Map<string, Question>();
|
||||
for (const event of events) {
|
||||
if (event.type === 'question.open') pending.set(event.question.id, event.question);
|
||||
if (event.type === 'question.answer') pending.delete(event.questionId);
|
||||
}
|
||||
return [...pending.values()];
|
||||
}
|
||||
|
||||
export const time = (ms: number) => `${(ms / 1000).toFixed(2)}s`;
|
||||
|
||||
export function requestView(event: Extract<LiveEvent, { type: 'http.request' }>) {
|
||||
return JSON.parse(event.body) as {
|
||||
model: string;
|
||||
messages: { role: string; content: string; tool_call_id?: string; tool_calls?: { id: string }[] }[];
|
||||
tools: unknown[];
|
||||
stream: boolean;
|
||||
};
|
||||
}
|
||||
10
async-agent-lab/web/src/styles.css
Normal file
10
async-agent-lab/web/src/styles.css
Normal file
@@ -0,0 +1,10 @@
|
||||
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Segoe UI', sans-serif; }
|
||||
.md { font-size: 13px; line-height: 1.6; }
|
||||
.md p { margin: 6px 0; }
|
||||
.md pre { background: #f6f8fa; padding: 8px; border-radius: 6px; overflow: auto; font-size: 12px; }
|
||||
.md code { background: #f6f8fa; padding: 0 3px; border-radius: 3px; font-size: 12px; }
|
||||
.md pre code { padding: 0; }
|
||||
.md table { border-collapse: collapse; font-size: 12px; margin: 6px 0; }
|
||||
.md th, .md td { border: 1px solid #eee; padding: 3px 8px; }
|
||||
.md ul, .md ol { padding-left: 20px; margin: 4px 0; }
|
||||
.md h1, .md h2, .md h3 { font-size: 14px; margin: 10px 0 4px; }
|
||||
11
async-agent-lab/web/vite.config.ts
Normal file
11
async-agent-lab/web/vite.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { viteSingleFile } from 'vite-plugin-singlefile';
|
||||
|
||||
// 构建成单个 index.html:本地服务直接托管现场主讲稿。
|
||||
export default defineConfig({
|
||||
root: new URL('.', import.meta.url).pathname,
|
||||
plugins: [react(), viteSingleFile()],
|
||||
server: { port: 5178, proxy: { '/api': 'http://127.0.0.1:8791' } },
|
||||
build: { outDir: 'dist', emptyOutDir: true, chunkSizeWarningLimit: 4000 },
|
||||
});
|
||||
Reference in New Issue
Block a user