Files
AgentLab/frontend/src/dev/preview.tsx
T

677 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react'
import type { JSX } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { Database, MessageSquare, RotateCw } from 'lucide-react'
import { AssistantStream, StreamEmpty, type StreamTurn } from '../run/AssistantStream'
import { Markdown } from '../run/Markdown'
import { ApprovalCard } from '../run/RunPanel'
import { decodeCopilot, decodeRun } from '../run/decode'
import { catalogPatchesOf } from '../run/catalogPatch'
import { ToastHost } from '../components/ui'
import type { RunEvent } from '../types'
import fixtures from '../run/__tests__/fixtures.json'
import mdSamples from '../run/__tests__/markdown-samples.json'
import evidenceFixture from '../run/__tests__/evidence-doc.json'
import evidenceJudge from '../run/__tests__/evidence-judge.json'
import {
CALIBER_ONE_OUTPUT, CALIBERS_OUTPUT, SQL_DEGRADED_OUTPUT, COPILOT_CATALOG_PATCH, COPILOT_CONTEXT, COPILOT_CONTEXT_FALLBACK, COPILOT_STUCK, COPILOT_TOOLS_DROPPED, MIXED_OUTPUT, abandonedRun, cancelledRun, exhaustedTeam, longLoop,
markupRun, mergeRun, mixedRun, parallelQueryRun, pipelineRun, repairRun, teamRun, timeoutRun,
} from '../run/__tests__/synthetic'
import '../index.css'
/**
* AssistantStream 的离线预览。
*
* 只有 dev server 会加载(vite build 的 input 只有 index.html),所以它不进
* 生产包。存在的理由:要改这个组件的排版,不该每次都真跑一次图——真跑一次
* 意味着要连数据库、等模型、还得凑巧撞上人工介入分支才能看到审批卡长什么样。
* 这里用的是真实导出的事件(fixtures.json)和照后端字段合成的事件
* (synthetic.ts),走的是同一个 decodeRun,只有事件来源是静态的。
*
* 打开 http://localhost:5273/preview.html;scripts/check-stream.mjs 按下面这些
* 查询参数逐个打开截图、断言。
*/
type Bucket = keyof typeof fixtures
/**
* 各功能自带的预览用例:dev/fixtures/*.tsx 各自导出 cases(用例名 → 组件),?syn=<用例名> 先在这里找,
* 找不到再走下面 synthetic() 里的用例。新功能加预览只加自己的文件,不用改这里——几个工作包同时加用例,
* 不会在这一个文件上互相冲突。写法见 dev/fixtures/_example.tsx
*/
type FixtureCases = Record<string, () => JSX.Element>
const FIXTURE_CASES: FixtureCases = {}
for (const [file, mod] of Object.entries(import.meta.glob<{ cases?: FixtureCases }>('./fixtures/*.tsx', { eager: true }))) {
for (const [name, render] of Object.entries(mod.cases ?? {})) {
if (name in FIXTURE_CASES) console.warn(`预览用例重名:${name}(${file} 盖掉了先加载的那个)`)
FIXTURE_CASES[name] = render
}
}
const CASES: { key: Bucket; question: string; phase: StreamTurn['phase']; status: string }[] = [
{ key: 'db', question: '这个库里有多少数据?挑最大的表说说', phase: 'done', status: '已完成' },
{ key: 'think', question: '分析上季度华东区销量下滑的原因', phase: 'running', status: '运行中' },
{ key: 'human', question: '把这批客户的信用额度调高 10%', phase: 'waiting', status: '等待审批' },
{ key: 'issue', question: '出一份本月经营分析', phase: 'done', status: '已完成' },
{ key: 'failed', question: '查一下不存在的那张表', phase: 'error', status: '' },
{ key: 'loop_approve', question: '写条上线公告,我过一眼再发', phase: 'done', status: '已完成' },
{ key: 'supervisor', question: '冬虫夏草是什么', phase: 'done', status: '已完成' },
]
function buildTurns(): StreamTurn[] {
return CASES.map((c) => {
const events = (fixtures as any)[c.key] as any[]
const steps = decodeRun(events)
const finished = events.find((e) => e.type === 'run.finished')
const failed = events.find((e) => e.type === 'run.failed')
return {
id: c.key,
question: c.question,
phase: c.phase,
status: c.status,
steps,
thinking: c.phase === 'running'
? '先看一下 ANALYTICS 里有哪些和销量相关的视图,再按区域和月份聚合……'
: undefined,
output: finished?.data?.output ?? null,
error: failed ? String(failed.data?.error ?? '运行失败') : undefined,
runId: events[0]?.run_id,
}
})
}
/**
* 合成事件的 ts 固定在过去某一刻,拿来演"进行中"时秒表会显示几天。
* 整体平移到"最后一条是 lagMs 之前",实时计时就是一个合理的数
*/
function rebase(events: RunEvent[], lagMs = 3200): RunEvent[] {
const last = events[events.length - 1]?.ts ?? 0
const shift = (Date.now() - lagMs) / 1000 - last
return events.map((e) => ({ ...e, ts: e.ts + shift }))
}
/**
* 一段恶意输入。这类测例必须是构造的——真实输出不会自带攻击,但模型输出
* 是不可信内容,渲染层要么天生免疫,要么就是个 XSS 口子。
*/
const HOSTILE = [
'正常文本 **加粗**。',
'',
'<script>window.__pwned = 1</script>',
'<img src=x onerror="window.__pwned=1">',
'[点我](javascript:window.__pwned=1)',
'[正常链接](https://example.com)',
'<iframe src="https://evil.example"></iframe>',
'`<script>alert(1)</script>`',
].join('\n')
/**
* 一份会超出折叠上限的报告,表格正好横跨切点。
*
* 之前折叠是按字符硬切的,切点落进表格中间——前面几行渲染成表格、最后半行
* 留成原始的 `| 1 | ThearchyHelios | …`。用户看到的是一份被咬掉一口的报告,
* 而它其实是完整的,只是被折叠了。
*/
function longReport(): string {
const head = [
"## 总结", "",
"- **管理员(平台级/区域级角色)**:共 9 人", "- **商家账户**:共 5 个", "",
"口径说明:".padEnd(400, "口"), "",
"## 一、管理员名单", "",
].join("\n")
const rows = Array.from({ length: 40 }, (_, i) =>
`| ${i + 1} | user_${i} | user${i}@example.com | 昵称${i} | ` +
`85e11976-6a7b-4167-b561-deec4fd${String(i).padStart(4, "0")} | 商家 ${i} | c${i}@example.com |`)
return [
head,
"| userID | 用户名 | 邮箱 | 昵称 | providerID | 商家名称 | 联系人邮箱 |",
"| --- | --- | --- | --- | --- | --- | --- |",
...rows,
"", "> 说明:部分账户身兼管理员与商家双重身份。",
].join("\n")
}
/** 直接回答的那一轮:没查库,界面上必须说破 */
const NO_QUERY_TURN: StreamTurn = {
id: "noquery", question: "刚才那 6 个管理员是怎么算的?", phase: "done", status: "已完成",
steps: [], noQuery: true,
output: { answer: "第 1 轮走的是 `role.level IN (platform, regional)` 这个口径。要换口径我重新查一遍。" },
}
/** 复核判了「不可信」:说明排在成果上方,且用报警色 */
const REVIEW_BROKEN_TURN: StreamTurn = {
id: "reviewbroken", question: "商家账户有多少个?", phase: "done", status: "已完成",
steps: [],
review: {
verdict: "annotated", severity: "broken", retry: true,
note: "这次取数没跑通(表名对不上),下面这个数是模型根据上下文推断的,不能当结论用。",
answer: null,
signals: [
{ kind: "tool_error", severity: "broken",
detail: "工具 db_query__shop 调用失败:no such table: merchant_account" },
{ kind: "step_limit", severity: "broken", detail: "Agent 已用完 8 步上限,仍未给出结论" },
],
},
output: { answer: "大约 5 个左右。" },
}
/** 复核只是补了一句说明,答案照常给;外加改写前的原文可展开 */
const REVIEW_DEGRADED_TURN: StreamTurn = {
id: "reviewdegraded", question: "谁能改别人的权限?", phase: "done", status: "已完成",
steps: [],
review: {
verdict: "rewritten", severity: "degraded", retry: false,
note: "这次知识库检索退回了纯关键词匹配,换了说法的内容可能没被搜到。",
answer: null,
signals: [
{ kind: "retrieve_degraded", severity: "degraded",
detail: "重排失败,已按初筛顺序返回" },
],
},
output: { answer: "只有平台管理员可以修改他人权限,且需要走角色变更流程。" },
rawOutput: { answer: "平台管理员可以修改他人权限。" },
}
/** 协作团队的泳道:前两轮并行、第三轮汇总 */
const TEAM_TURN: StreamTurn = {
id: "team", question: "比较三种向量数据库在中文检索上的取舍", phase: "done", status: "已完成",
steps: [{
id: "n-team", seq: 1, kind: "node", status: "done", title: "调研团队", meta: "24.1 s", ms: 24100,
team: {
members: ["researcher", "analyst", "writer"],
savedMs: 11800,
finished: true,
rounds: [
{
round: 0, parallel: 2, wallMs: 9600, sumMs: 16400,
reason: "两边资料互不相干,可以同时查",
members: [
{ agent: "researcher", instruction: "查 Milvus 与 Qdrant 的中文分词支持", ms: 9600,
status: "done", result: "Milvus 2.4 起内置 jieba…" },
{ agent: "analyst", instruction: "查 pgvector 的中文检索方案", ms: 6800,
status: "done", result: "pgvector 本身不分词,需配 zhparser…" },
],
},
{
round: 1, parallel: 2, wallMs: 7400, sumMs: 12400,
reason: "两组基准测试互不依赖",
members: [
{ agent: "researcher", instruction: "跑召回率基准", ms: 7400, status: "done",
result: "hit@1:Milvus 0.91 / Qdrant 0.89 / pgvector 0.84" },
{ agent: "analyst", instruction: "跑写入吞吐基准", ms: 5000, status: "done",
result: "10 万条:Milvus 42s / Qdrant 51s / pgvector 88s" },
],
},
{
round: 2, parallel: 1, wallMs: 7100, sumMs: 7100,
reason: "两份基准都出来了,可以汇总",
members: [
{ agent: "writer", instruction: "根据前面的结论写选型建议", ms: 7100,
status: "done", result: "## 结论\n中文为主且要开箱即用 → Milvus…" },
],
},
],
},
}],
output: { 建议: "中文为主且要开箱即用选 Milvus;已有 Postgres 且数据量在百万以内,pgvector + zhparser 的运维成本最低。" },
}
const LONG_TURN: StreamTurn = {
id: "long", question: "需要能够显示总结内容", phase: "done", status: "已完成",
steps: [], output: { result: longReport() },
}
/**
* 带逐段证据的一轮:口径卡 → 报告撰写 → 成果(_evidence 标注了「周报」字段)。报告文档和
* 片段接口由 check-stream 用 page.route 伪造(/api/artifacts/<doc>、…/evidence/segments/…)。
* 后面跟一轮同样的答案、但没有 _evidence:旧运行就是这样,渲染必须和普通 Markdown 一模一样
*/
function evidenceTurns(): StreamTurn[] {
const fx = evidenceFixture
const E = (seq: number, type: string, node_id: string | null, data: Record<string, any> = {}): RunEvent =>
({ seq, type, node_id, ts: 1790000000 + seq, data })
const events = [
E(1, 'run.started', null, { total: 3 }),
E(2, 'node.started', 'caliber', { node_type: 'metrics', label: '周报口径' }),
E(3, 'node.finished', 'caliber', { duration_ms: 12, preview: { kind: 'metric_set', caliber: '周报口径' } }),
E(4, 'node.started', 'write', { node_type: 'report', label: '写周报' }),
E(5, 'report.checked', 'write', fx.report_checked),
E(6, 'node.finished', 'write', { duration_ms: 2300 }),
E(7, 'node.started', 'out', { node_type: 'output', label: '成果' }),
E(8, 'node.finished', 'out', { duration_ms: 3 }),
E(9, 'run.finished', null, { output: {} }),
]
const text = fx.output['周报']
return [
{ id: 'evidence', question: '写一份本周周报', phase: 'done', status: '已完成', runClass: 'exploratory',
steps: decodeRun(events, { status: 'succeeded' }), output: fx.output, runId: fx.run_id },
{ id: 'evidence-legacy', question: '写一份本周周报(旧运行)', phase: 'done', status: '已完成',
steps: [], output: { 周报: text }, runId: 'syn-evidence-legacy' },
]
}
/**
* 四期:结论句裁判走问数据页、画布右栏同一条路(Output → EvidenceField → 出具横幅)。
* - judge-plain:探索运行、报告没开裁判(require_citation)。点开结论句请模型判断之后,横幅得多出「结论 N 句」那一段;
* - judge-formal:正式运行、报告 claims=judge,节点里判好了。没挂依据的那句送了裁判(到上限没判),出具照样按没挂依据记缺口。
* 报告工件、片段接口、按需裁判的 POST 由 check-evidence 用 page.route 伪造(夹具 evidence-judge.json)
*/
function evidenceJudgeTurns(): StreamTurn[] {
const { plain, formal } = evidenceJudge
return [
{ id: 'judge-plain', question: '写一份本周周报', phase: 'done', status: '已完成', runClass: 'exploratory',
steps: [], output: plain.output, runId: plain.run_id },
{ id: 'judge-formal', question: '写一份本周周报(受管模板)', phase: 'done', status: '已完成', runClass: 'formal',
steps: [], output: formal.output, runId: formal.run_id },
]
}
/** 合成场景:新后端才有的事件,老库里导不出来 */
function synthetic(name: string): StreamTurn[] {
switch (name) {
case 'evidence':
return evidenceTurns()
case 'evidence-judge':
return evidenceJudgeTurns()
case 'team-live': {
const ev = rebase(teamRun('members'))
return [{ id: 'team-live', question: '比较三家供应商的交期风险', phase: 'running', status: '运行中',
steps: decodeRun(ev), runId: 'syn-team-live' }]
}
case 'team-cancelled': {
// 第 1 轮三人都还在跑时被取消:那一轮不知道本来要多久
const ev = rebase(teamRun('members').filter((e) =>
!(e.data?.agent === '物流员' && (e.type === 'agent.step.end' || e.type === 'llm.end'))))
const last = ev[ev.length - 1]
ev.push({ seq: last.seq + 1, type: 'run.cancelled', node_id: null, ts: last.ts + 1, data: {} })
return [{ id: 'team-cancelled', question: '比较三家供应商的交期风险', phase: 'done', statusCode: 'cancelled',
steps: decodeRun(ev), runId: 'syn-team-cancelled' }]
}
case 'team-routing': {
const ev = rebase(teamRun('routing'))
return [{ id: 'team-routing', question: '比较三家供应商的交期风险', phase: 'running', status: '运行中',
steps: decodeRun(ev), runId: 'syn-team-routing' }]
}
case 'team': {
const ev = teamRun('done')
return [{ id: 'team-done', question: '比较三家供应商的交期风险', phase: 'done', status: '已完成',
steps: decodeRun(ev), output: ev[ev.length - 1].data.output, runId: 'syn-team-done' }]
}
case 'long': {
const ev = longLoop(145)
return [{ id: 'long-loop', question: '逐拍读取传感器,跑完这一批', phase: 'done', status: '已完成',
steps: decodeRun(ev), output: ev[ev.length - 1].data.output, runId: 'syn-long' }]
}
case 'mixed': {
const ev = mixedRun()
const end = ev[ev.length - 1].data
return [{ id: 'mixed', question: '9 月 19 日各班次出勤率', phase: 'error', status: '失败',
steps: decodeRun(ev), output: MIXED_OUTPUT, runId: 'syn-mixed', runClass: 'exploratory',
error: { error: String(end.error), detail: end.detail } }]
}
case 'issued': {
// 同一份出具,跑完的样子:横幅、正文里画出来的数字、复制 / 导出
const ev = mixedRun().filter((e) => e.type !== 'node.failed' && e.type !== 'run.failed')
return [{ id: 'issued', question: '9 月 19 日各班次出勤率', phase: 'done', status: '已完成',
steps: decodeRun(ev, { status: 'succeeded' }), output: MIXED_OUTPUT, runId: 'syn-issued',
runClass: 'exploratory', review: REVIEW_DEGRADED_TURN.review }]
}
case 'issued-sql':
// 数字都有出处,却因为查询没通过 SQL 检查而降档:横幅单列说明
return [{ id: name, question: '上周订单金额', phase: 'done', status: '已完成',
steps: decodeRun(parallelQueryRun(), { status: 'succeeded' }), runId: 'syn-issued-sql', runClass: 'formal',
output: SQL_DEGRADED_OUTPUT }]
case 'calibers':
case 'caliber-one': {
// 按口径卡清点回指的数字:两张卡各数各的;只有一张卡时直说都来自它
return [{ id: name, question: '上周订单情况', phase: 'done', status: '已完成',
steps: decodeRun(pipelineRun(2), { status: 'succeeded' }), runId: `syn-${name}`, runClass: 'formal',
output: name === 'calibers' ? CALIBERS_OUTPUT : CALIBER_ONE_OUTPUT }]
}
case 'live': {
// 取数正在进行:第一条查询还没回来
const all = mixedRun()
const cut = all.findIndex((e) => e.type === 'tool.end' && e.data.call_id === 'q1')
const ev = rebase(all.slice(0, cut), 12400)
return [{ id: 'live', question: '9 月 19 日各班次出勤率', phase: 'running', status: '运行中',
steps: decodeRun(ev), runId: 'syn-live' }]
}
case 'cancelled':
return [{ id: 'cancelled', question: '把大表全量拉一遍', phase: 'done', statusCode: 'cancelled',
steps: decodeRun(cancelledRun()), runId: 'syn-cancel' }]
case 'chat': {
// 问数据的一轮:先建图、再执行。执行开始后「规划」收成一行
const plan = decodeCopilot([
{ op: 'heartbeat', phase: 'planning', elapsed_ms: 6400 },
{ op: 'context', elapsed_ms: 2100, sources: [
{ source: 'warehouse', tables: ['v_device_kpi', 'dim_device'], selected_by: 'model', total: 53 }] },
{ op: 'thinking', delta: '要先查表结构,再按设备汇总 KPI。' },
{ op: 'plan', summary: '取数 → 汇总 → 出结论' },
{ op: 'add_node', node: { id: 'q', type: 'tool', label: '查询设备 KPI' } },
{ op: 'add_node', node: { id: 'o', type: 'output', label: '成果' } },
{ op: 'done', explanation: '两步' },
{ op: 'final', graph: { nodes: [{ id: 'q' }, { id: 'o' }] } },
])
const ev = (fixtures as any).db as RunEvent[]
return [{ id: 'chat', question: '这个库里有多少数据?挑最大的表说说', phase: 'done', status: '已完成',
steps: [...plan, ...decodeRun(ev)], runId: 'syn-chat',
output: ev[ev.length - 1].data.output,
graph: { nodes: [{ id: 'q', type: 'tool', data: { label: '查询设备 KPI' } },
{ id: 'o', type: 'output', data: { label: '成果' } }] } }]
}
case 'copilot-context':
// 大库按需求挑表:过程里一行「参考了 N 张表」,展开按数据源分组列表名
return [{ id: 'copilot-context', question: '各渠道上月的入园人次', phase: 'done', status: '已应用到画布',
steps: decodeCopilot(COPILOT_CONTEXT, { context: 'canvas' }) }]
case 'catalog-patch':
// 用户说了一条数据事实:助手只回答、不改图,附一张「建议更新数据目录」卡片
return [{ id: 'catalog-patch', question: 'status=9 表示作废,统计时要排除', phase: 'done', status: '已回答(未修改画布)',
steps: decodeCopilot(COPILOT_CATALOG_PATCH, { context: 'canvas' }),
output: { 回答: '好的,已整理成数据目录的修改建议,确认后保存即可。' },
catalogPatches: catalogPatchesOf(COPILOT_CATALOG_PATCH) }]
case 'copilot-fallback':
return [{ id: 'copilot-fallback', question: '各渠道上月的入园人次', phase: 'done', status: '已应用到画布',
steps: decodeCopilot(COPILOT_CONTEXT_FALLBACK, { context: 'canvas' }) }]
case 'copilot':
return [{ id: 'copilot', question: '做一个每天的出勤日报', phase: 'done',
status: '已应用到画布,仍有 2 处问题待处理',
steps: decodeCopilot(COPILOT_STUCK, { context: 'canvas' }) }]
case 'codes':
// 编号、分组长得像数:'1063'、'001'。它们不该右对齐,也不该出「按 attribute_group 从高到低排」
return [{ id: 'codes', question: '各产线本周产量', phase: 'done', status: '已完成', steps: [],
output: { 结果: JSON.stringify({
columns: ['factory_code', 'line_name', 'attribute_group', 'output_qty'],
rows: [['1063', '一号线', '001', 1520], ['1064', '二号线', '002', 1310], ['1065', '三号线', '001', 980]],
row_count: 3 }) } }]
case 'parallel':
// 两个并行的查询节点用同一个工具;第一条查询没通过 SQL 检查,口径卡的问题指到它的 SQL
return [{ id: 'parallel', question: '订单金额和退款数', phase: 'done', status: '已完成',
steps: decodeRun(parallelQueryRun(), { status: 'succeeded' }) }]
case 'merge':
// 合并查询:两个库各查一次、在库外按门店合并,带两道警告(键类型不一致、行数放大)
return [{ id: 'merge', question: '按门店合并销售和到店人数', phase: 'done', status: '已完成',
steps: decodeRun(mergeRun(), { status: 'succeeded' }) }]
case 'schema': {
const ev = mixedRun().slice(0, 10)
return [{ id: 'schema', question: '看看有哪些人事表', phase: 'done', status: '已完成',
steps: decodeRun(ev, { status: 'succeeded' }) }]
}
// ---- 平台问题的几种结局(NI-3/4/5):失败的轮次把 run.failed 的原话交给流,和运行页、画布右栏一样
case 'exhausted':
case 'markup':
case 'repair': {
const ev = name === 'exhausted' ? exhaustedTeam('fail') : name === 'markup' ? markupRun() : repairRun()
const end = ev[ev.length - 1].data
return [{ id: name, question: '上月各区域订单额', phase: 'error', steps: decodeRun(ev), runId: `syn-${name}`,
error: { error: String(end.error), ...(end.detail ? { detail: end.detail } : {}) } }]
}
case 'exhausted-degrade': {
const ev = exhaustedTeam('degrade')
return [{ id: 'exhausted-degrade', question: '上月各区域订单额', phase: 'done', steps: decodeRun(ev),
output: { 结论: ev[ev.length - 1].data.output.结论, _issuance: {
tier: 'degraded', calibers: [], metrics_checked: 0, matched_numbers: 0, unmatched_numbers: [],
missing_required: [], missing_expected: [],
gaps: ['协作团队「销售分析团队」用完 2 轮仍未完成,交付的是成员最后的回复'] } },
runId: 'syn-exhausted-degrade', runClass: 'exploratory' }]
}
case 'exhausted-closing': {
const ev = rebase(exhaustedTeam('closing'))
return [{ id: 'exhausted-closing', question: '上月各区域订单额', phase: 'running', steps: decodeRun(ev),
runId: 'syn-exhausted-closing' }]
}
case 'timeout-live': {
// 查询已经走了 34 秒,时限是 30 秒:秒表要说破「已超出上限」
const ev = rebase(timeoutRun('live'), 34000)
return [{ id: 'timeout-live', question: '把订单明细全拉出来', phase: 'running', steps: decodeRun(ev),
runId: 'syn-timeout-live' }]
}
case 'timeout':
return [{ id: 'timeout', question: '把订单明细全拉出来', phase: 'error', steps: decodeRun(timeoutRun('done')),
runId: 'syn-timeout', error: { error: '查询超过 30 秒没有返回,已停止等待(数据库可能仍在执行,连接会在后台回收)。请添加 WHERE 条件或 LIMIT 缩小范围后重试' } }]
case 'abandoned':
return [{ id: 'abandoned', question: '把这份报表发出去', phase: 'done', statusCode: 'cancelled',
steps: decodeRun(abandonedRun()), runId: 'syn-abandoned' }]
case 'structured':
// 页面已经把报错拆好了(问数据页的 Failure):照原样画,不再翻译一遍
return [{ id: 'structured', question: '上周各区域订单数', phase: 'error', steps: [],
error: { title: '操作超时', reason: '查询超时:数据库 90 秒没有返回', hint: '缩小时间范围后点击「重试本轮」',
detail: 'OperationalError: (3024) maximum statement execution time exceeded' } }]
case 'restored':
// 从库里恢复的一轮:没有事件,查询次数由页面从落库的 meta 带过来
return [{ id: 'restored', question: '上周各区域订单数', phase: 'done', steps: [], queries: 3, elapsedMs: 8400,
output: { answer: '华东 1,204 单,华南 986 单,华北 731 单。' }, runId: 'syn-restored' }]
case 'tools-dropped':
return [{ id: 'tools-dropped', question: '只查上月的数据', phase: 'done', status: '已更新画布(修改 1)',
tone: 'warn', steps: decodeCopilot(COPILOT_TOOLS_DROPPED, { context: 'canvas' }) }]
default:
return []
}
}
/** Markdown 渲染用真实输出验,不用编的样本 */
function MarkdownCases() {
return (
<div className="mx-auto max-w-3xl space-y-4 p-4">
{[...(mdSamples as string[]), HOSTILE].map((t, i) => (
<div key={i} className="rounded-lg border bg-panel p-3">
<div className="mb-2 text-2xs text-dim">真实输出 #{i + 1}</div>
<Markdown text={t} />
</div>
))}
</div>
)
}
/**
* 一条在长的运行:window.__grow(n) 再放出 n 条事件。check-stream 用它验证
* 「往上翻着看的时候不被拽回底部、底下浮出跳到最新」
*/
function Growing({ dense }: { dense: boolean }) {
const [all] = useState(() => pipelineRun(60))
const [n, setN] = useState(60)
useEffect(() => {
;(window as any).__grow = (k: number) => setN((x) => Math.min(all.length, x + k))
}, [all.length])
const ev = rebase(all.slice(0, n))
return (
<AssistantStream dense={dense} turns={[{
id: 'grow', question: '把这批报表各查一遍', phase: 'running', status: '运行中',
steps: decodeRun(ev), runId: 'syn-grow',
}]} />
)
}
/**
* 几轮已经完成的对话,执行过程按需取回:window.__expand(i) 给第 i 轮填上步骤,和问数据页点
* 「看执行过程」一样。check-stream 用它验证取回的历史步骤不算「新进展」、也不把人拽走
*/
function Expanding({ dense, n }: { dense: boolean; n: number }) {
const [open, setOpen] = useState<number[]>([])
useEffect(() => {
;(window as any).__expand = (i: number) => setOpen((x) => [...x, i])
}, [])
const turns: StreamTurn[] = Array.from({ length: n }, (_, i) => ({
id: `past-${i}`, question: `第 ${i + 1} 个问题:把这批报表各查一遍`, phase: 'done', status: '已完成',
steps: open.includes(i) ? decodeRun(pipelineRun(12), { status: 'succeeded' }) : [],
output: { result: longReport() }, runId: `syn-past-${i}`,
}))
return <AssistantStream dense={dense} turns={turns} />
}
/** 审批卡的上下文:等了多久、挂在哪个节点、将以谁的名义签批 */
const APPROVAL = {
id: 'ap-1', run_id: 'syn-mixed', node_id: 'review', mode: 'approve' as const,
title: '出勤结论可以发出吗?', status: 'pending', response: {},
payload: { kind: 'human_node', message: '早班出勤率 **94.23%**,晚班 **73.08%**。' },
created_at: new Date(Date.now() - 8 * 86_400_000 - 3_600_000).toISOString().replace('Z', ''),
workflow_name: '出勤日报', node_label: '班长复核', run_class: 'formal' as const,
}
/**
* ?approval=report:探索运行里审一份报告。核对概况取 evidence-doc 夹具(7/12 数字有出处、1 处引用解析不了),
* 报告没开结论句裁判;?approval=report-formal:正式运行,节点里判过,1 句证据相矛盾
*/
const APPROVAL_REPORT = {
id: 'ap-2', run_id: evidenceFixture.run_id, node_id: 'review', mode: 'approve' as const,
title: '本周周报可以发出吗?', status: 'pending', response: {},
payload: {
kind: 'human_node', message: '请审核本周周报。',
reports: [{ node_id: 'write', label: '写周报', stats: evidenceFixture.doc.stats, claims: 'off',
doc_artifact: evidenceFixture.doc_artifact }],
},
created_at: new Date(Date.now() - 120_000).toISOString().replace('Z', ''),
workflow_name: '周报', node_label: '主管审批', run_class: 'exploratory' as const,
}
const APPROVAL_REPORT_FORMAL = {
...APPROVAL_REPORT, id: 'ap-3', run_class: 'formal' as const,
payload: {
...APPROVAL_REPORT.payload,
reports: [{ ...APPROVAL_REPORT.payload.reports[0], claims: 'judge', stats: { ...evidenceFixture.doc.stats, numbers_cited: 12, uncited_numbers: 0, unresolved: 0 },
judge: { mode: 'inline', counts: { supported: 5, contradicted: 1, unjudged: 0 }, complete: true } }],
},
}
function Preview() {
// ?case=db 只看一个用例,?theme=light 直接出浅色——都是为了截图可复现,
// 不用靠点按钮
const params = new URLSearchParams(location.search)
const only = params.get('case')
const syn = params.get('syn')
const md = params.get('md') === '1'
const theme = params.get('theme')
if (theme === 'light' || theme === 'dark') document.documentElement.dataset.theme = theme
if (params.get('actor') != null) {
try { localStorage.setItem('agentlab_actor', params.get('actor') ?? '') } catch { /* 预览里没有也行 */ }
}
const dense = params.get('dense') === '1'
const [all] = useState(buildTurns)
const turns = only ? all.filter((t) => t.id === only) : all
const [denseToggle, setDense] = useState(false)
const [emptyState, setEmptyState] = useState(false)
// ?follow=1:真实用例也出追问标签,看它从真实结果集里派生出什么
const onFollowUp = params.get('follow') === '1'
? (q: string) => { (window as any).__followUp = q } : undefined
const empty = (
<StreamEmpty
icon={<MessageSquare size={22} />}
title="问你的数据"
hint="用一句话描述需求,助手会自动连接数据源、编写查询并给出结论,无需操作画布"
/>
)
if (md) return <div className="h-full overflow-y-auto"><MarkdownCases /></div>
if (params.get('grow') === '1') return <Growing dense={dense} />
if (params.get('expand')) return <Expanding dense={dense} n={Number(params.get('expand')) || 1} />
const approvalCase = params.get('approval')
if (approvalCase === '1' || approvalCase === 'report' || approvalCase === 'report-formal') {
const approval = approvalCase === 'report' ? APPROVAL_REPORT : approvalCase === 'report-formal' ? APPROVAL_REPORT_FORMAL : APPROVAL
return (
<div className={dense ? 'w-[360px] border-r' : 'mx-auto max-w-3xl p-4'}>
<div className="overflow-hidden rounded-lg border" style={{ borderColor: 'var(--warn)' }}>
<ApprovalCard approval={approval} onResolved={() => undefined} />
</div>
</div>
)
}
if (syn) {
// 当组件渲染,不直接调用:用例里可以用 hook
const Fixture = FIXTURE_CASES[syn]
if (Fixture) return <Fixture />
const linked: string[] = ((window as any).__linked ??= [])
const stream = (
<AssistantStream
dense={dense}
turns={synthetic(syn)}
onStepHover={params.get('link') === '1' ? (id) => { (window as any).__hovered = id } : undefined}
onStepFocus={params.get('link') === '1' ? (id) => { linked.push(id) } : undefined}
// 「打开设置」:记下要打开哪个节点、落到哪一栏(指标的问题落到来源查询的 SQL)
onStepOpen={params.get('link') === '1'
? (id, field) => { ((window as any).__opened ??= []).push([id, field ?? null]) } : undefined}
renderTurnActions={(t) => (t.phase === 'error'
? <button type="button" className="btn btn-xs"><RotateCw size={11} aria-hidden /> 重试本轮</button>
: null)}
onFollowUp={(q) => { (window as any).__followUp = q }}
/>
)
// 证据场景另摆一份同样文字的普通 Markdown:没有 _evidence 的那一轮必须和它长得一模一样
const baseline = syn === 'evidence' && (
<div id="md-baseline" hidden><Markdown text={evidenceFixture.output['周报']} dense={dense} /></div>
)
return dense
? <div className="h-full w-[360px] border-r bg-panel">{stream}{baseline}</div>
: <>{stream}{baseline}</>
}
if (params.get('long') === '1') {
return <AssistantStream turns={[LONG_TURN]} />
}
if (params.get('noquery') === '1') {
return <AssistantStream turns={[NO_QUERY_TURN]} />
}
if (params.get('review') === '1') {
return <AssistantStream turns={[REVIEW_BROKEN_TURN, REVIEW_DEGRADED_TURN]} />
}
if (params.get('fanout') === '1') {
return <AssistantStream dense={dense} turns={[{
id: 'fanout', question: '三个库各查一遍,汇总给我', phase: 'done', status: '已完成',
steps: decodeRun((fixtures as any).fanout),
output: { 结果: '三路都回来了' },
}]} />
}
if (params.get('team') === '1') {
return <AssistantStream turns={[TEAM_TURN]} dense={dense} />
}
return (
<div className="flex h-full flex-col">
<header className="flex items-center gap-3 border-b px-4 py-2 text-xs">
<Database size={14} style={{ color: 'var(--accent)' }} />
<span className="font-semibold">AssistantStream 预览</span>
<span className="text-dim">真实事件 · 离线渲染</span>
<span className="flex-1" />
<label className="flex items-center gap-1.5 text-dim">
<input type="checkbox" checked={denseToggle} onChange={(e) => setDense(e.target.checked)} />
dense(360px 窄栏)
</label>
<label className="flex items-center gap-1.5 text-dim">
<input type="checkbox" checked={emptyState}
onChange={(e) => setEmptyState(e.target.checked)} />
空态
</label>
<button className="btn btn-sm btn-ghost"
onClick={() => {
const el = document.documentElement
el.dataset.theme = el.dataset.theme === 'light' ? 'dark' : 'light'
}}>
切主题
</button>
</header>
<div className="flex min-h-0 flex-1">
{/* 宽屏:问数据页的形态 */}
<div className="min-w-0 flex-1 border-r">
<AssistantStream turns={emptyState ? [] : turns} dense={denseToggle} empty={empty}
onFollowUp={onFollowUp} />
</div>
{/* 窄栏:画布右侧助手栏的形态。固定 360px,和真实右栏一致 */}
<div className="w-[360px] shrink-0 bg-panel">
<AssistantStream turns={emptyState ? [] : turns} dense empty={empty} onFollowUp={onFollowUp} />
</div>
</div>
</div>
)
}
// 预览引用了 store(审批卡要用),别人改 store 时 HMR 会把这个入口重新执行一遍,
// 同一个容器上再 createRoot 会报错
const w = window as any
w.__previewRoot ??= createRoot(document.getElementById('root')!)
w.__previewRoot.render(
<BrowserRouter>
<ToastHost>
<Preview />
</ToastHost>
</BrowserRouter>,
)