Files
AgentLab/frontend/src/dev/preview.tsx
T
ThearchyHelios cc608f56ec 跑完先过一道复核再交给你:异常说破、数字不许现编、出错自动改一次
「跑完了」和「答得对」之间一直没有任何一层。库里 264 条 succeeded 里,
14 条带着 warn、4 条 agent 步数用满,而交到用户手上的答案对此只字不提。
最糟的一次(run dd9927e6)交出去的干脆是「本轮只执行了第一个工具」——
一句内部管道文案冒充结论。

出具体系(issuance.py)本来就是干这个的,但它挂在输出节点的可选契约上,
还要配口径卡提供受控指标集;问数据页生成的图基本都是 input → agent → output,
走不到那条路。所以补一层轻的。

**干净的运行一次模型都不调。** 先用规则扫一遍事件流(engine/review.py:scan),
没扫出东西就到此为止。实测 15ms 返回。这是整层设计的前提,测试里用
「monkeypatch 一个会抛异常的模型,断言它没被碰过」来守。

**分类挂在 code 上,不是中文消息串。** 12 处 warn 发射点都带上 code。
这样「流式失败,回退非流式」能归成 benign 直接跳过——它对答案内容零影响,
为它叫一次模型等于每次网络抖一下就多花一笔;而「步数用满」归 broken。
原来这两者在事件流里长得一模一样。工具报错之后同一个工具又跑成了,
算 degraded 不算 broken——否则参数自愈每修一次就报一次「答案不可信」。

**复核不许现编数字。** 它手上只有原答案和一份异常清单,没有任何原始数据,
写出来的新数字只能是凭空来的。直接复用 issuance.trace_numbers,把原答案的
数字当受控指标集,对不上就整条退回原答案、只留说明。硬拦截,不是提示。

**规则管重试资格,模型只有否决权。** 一开始写成两边取并,实测 loop_limit
时模型判 retry=true——而重跑一遍循环还是会撞上限,白烧一次建图加一次跑图。
反过来之后,「检索什么都没查到」这类也不会被模型强推重跑。

**重试带着失败的那张图,不是推倒重来。** 原来传 base_graph=null,模型只看到
「原问题 + 一句失败原因」,很可能原样再建出同一张坏图。而且它连自己上次建了
什么都看不到——会话历史按 status != running 过滤,正在重试的这一轮恰好被
排除在外。现在交回去走后端的改图分支,调大步数、改正参数就是改一个字段。
上限一次。

**答案等复核完成再显示。** 端到端抓到的:答案先摆出来,十秒后说明才追上去——
这十秒里用户读的是一个没人核对过的结论,正是要修的那件事本身。改成落库立刻
(防关标签页丢结果)、显示等复核,并给复核加 25s 上限。说明排在成果上方,
broken 用报警色、degraded 用普通边框——混成一个样子用户就学会了一概忽略。

复核结论跟着轮次落库,刷新后还在;也进下一轮的对话历史,否则模型会拿一个
已知不可信的结论当事实继续往下推。

验证:后端 242 passed(新增 test_review.py 25 条);tsc 干净;
check-decode / check-stream / check-ui 全过,check-stream 新增 9 条渲染断言。
两条端到端都在真浏览器 + 真模型上跑通:检索降级那次说明正确排在答案上方;
重试那次建图请求 2 次、第二次 base_graph 带着失败的 3 个节点、第一版没漏出去。

复核结果不是事件流,decode.ts 翻译不到它,所以渲染测试放在 check-stream。
2026-09-20 11:18:53 +08:00

233 lines
9.5 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 { useState } from 'react'
import { createRoot } from 'react-dom/client'
import { Database, MessageSquare } from 'lucide-react'
import { AssistantStream, StreamEmpty, type StreamTurn } from '../run/AssistantStream'
import { Markdown } from '../run/Markdown'
import { decodeRun } from '../run/decode'
import { ToastHost } from '../components/ui'
import fixtures from '../run/__tests__/fixtures.json'
import mdSamples from '../run/__tests__/markdown-samples.json'
import '../index.css'
/**
* AssistantStream 的离线预览。
*
* 只有 dev server 会加载(vite build 的 input 只有 index.html),所以它不进
* 生产包。存在的理由:要改这个组件的排版,不该每次都真跑一次图——真跑一次
* 意味着要连数据库、等模型、还得凑巧撞上人工介入分支才能看到审批卡长什么样。
* 这里用的是真实导出的事件,走的是同一个 decodeRun,只有事件来源是静态的。
*
* 打开 http://localhost:5273/preview.html
*/
type Bucket = keyof typeof fixtures
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,
}
})
}
/**
* 一段恶意输入。这类测例必须是构造的——真实输出不会自带攻击,但模型输出
* 是不可信内容,渲染层要么天生免疫,要么就是个 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 LONG_TURN: StreamTurn = {
id: "long", question: "需要能够显示总结内容", phase: "done", status: "完成",
steps: [], output: { result: longReport() },
}
/** 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-[10px] text-faint">真实输出 #{i + 1}</div>
<Markdown text={t} />
</div>
))}
</div>
)
}
function Preview() {
// ?case=db 只看一个用例,?theme=light 直接出浅色——都是为了截图可复现,
// 不用靠点按钮
const params = new URLSearchParams(location.search)
const only = params.get('case')
const md = params.get('md') === '1'
if (params.get('theme') === 'light') document.documentElement.dataset.theme = 'light'
const [all] = useState(buildTurns)
const turns = only ? all.filter((t) => t.id === only) : all
const [dense, setDense] = useState(false)
const [emptyState, setEmptyState] = useState(false)
const empty = (
<StreamEmpty
icon={<MessageSquare size={22} />}
title="问点什么"
hint="它会自己接数据源、写查询、跑完给结论——你不用碰画布"
/>
)
if (md) return <div className="h-full overflow-y-auto"><MarkdownCases /></div>
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]} />
}
return (
<div className="flex h-full flex-col">
<header className="flex items-center gap-3 border-b px-4 py-2 text-[12px]">
<Database size={14} style={{ color: 'var(--accent)' }} />
<span className="font-semibold">AssistantStream 预览</span>
<span className="text-faint">真实事件 · 离线渲染</span>
<span className="flex-1" />
<label className="flex items-center gap-1.5 text-[11.5px] text-dim">
<input type="checkbox" checked={dense} onChange={(e) => setDense(e.target.checked)} />
dense(360px 窄栏)
</label>
<label className="flex items-center gap-1.5 text-[11.5px] 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' ? '' : 'light'
}}>
切主题
</button>
</header>
<div className="flex min-h-0 flex-1">
{/* 宽屏:问数据页的形态 */}
<div className="min-w-0 flex-1 border-r">
<AssistantStream turns={emptyState ? [] : turns} empty={empty} />
</div>
{/* 窄栏:画布右侧助手栏的形态。固定 360px,和真实右栏一致 */}
<div className="w-[360px] shrink-0 bg-panel">
<AssistantStream turns={emptyState ? [] : turns} dense empty={empty} />
</div>
</div>
</div>
)
}
createRoot(document.getElementById('root')!).render(
<ToastHost>
<Preview />
</ToastHost>,
)