界面与交互全面翻修(前两波):运行态有了 HUD 和时间轴,终态会收尾,全站令牌、组件、文案统一

来自 2026-09 的 UI/UX 审查(142 条)。本提交是前两波,第三波(记录页接入时间轴、跨页互链、check-ui 更新、全站扫尾)另行提交。

地基
- 设计令牌:全局规则和 .btn/.field/.chip 放进 CSS 层,Tailwind 工具类重新生效,选中态、开启态不再被吞掉;字号阶梯;
  辅助文字在所有底色上 >= 4.5:1;状态色专用,节点类型色与之拉开色相;「跟随系统」真的跟随,首帧不闪深色;新字标与 favicon
- 基础组件:confirmDialog / promptDialog 取代原生对话框;toast 出错不自动消失、可复制、读屏可播报;离线横幅与断线恢复;
  九种剪影的状态徽标;唯一一份状态文案(lib/status)、格式化(lib/format)、术语(lib/terms)、报错人话(lib/errors)
- 运行态内核:事件折成航迹(run/trace.ts),可投影到任意时刻;取消 / 失败 / 服务重启后统一收尾,不再永远转圈;
  run.status 随事件回写;token 事件不再堆进 events
- 后端:协作成员逐个完成即发事件,调度者决策有事件;时长分执行 / 墙钟 / 等人;API 时间带时区;
  中断的运行也发 stream.end;运行列表可筛选翻页;删除运行 / 工作流有保护;运行默认值接通;可测未保存的配置;
  报错说清发生了什么、为什么、怎么办

功能
- 画布:状态四重编码、固定高度遥测槽、按缩放三档、运行胶囊与 HUD、航迹时间轴与回放、执行轨迹边、
  等待闸门、跟随执行、正式运行只读、一次性时刻(开场点亮、回扫、阻断级联、出具印章)
- 编辑:撤销重做、版本历史、问题面板可定位、三栏可收起、模板与表达式字段区分、变量血缘、快捷键、带搜索的选择器
- 回答流 / 问数据:贴底才跟随滚动、工件下钻、出具横幅列出缺口、答案可复制导出、按会话的忙闲、
  答案不再在 2000 字处截断、重启后这一轮会收尾
- 记录页:待审批 / 运行中 / 失败页签、筛选翻页、实时接流、排错路径指到节点、封存凭证常驻
- 管理页:新的「数据」页、写操作失败都有反馈、弹窗里先测连接、统一持久的连接状态
- 外壳:导航改版、⌘K 命令面板、? 快捷键帮助、404、页面标题与 favicon 状态点、后台提醒

验证(沙箱):tsc 干净,pytest 579 passed,14 个检查脚本中 13 个全绿;check-ui 剩 1 项旧断言(按旧日期格式找运行行),第三波更新。
This commit is contained in:
ThearchyHelios
2026-09-26 23:11:31 +08:00
parent 95ff6470d1
commit e10c4f23fc
132 changed files with 43558 additions and 5399 deletions
+210 -35
View File
@@ -1,12 +1,18 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { Database, MessageSquare } from 'lucide-react'
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 { decodeRun } from '../run/decode'
import { ApprovalCard } from '../run/RunPanel'
import { decodeCopilot, decodeRun } from '../run/decode'
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 {
COPILOT_STUCK, MIXED_OUTPUT, cancelledRun, longLoop, mixedRun, pipelineRun, teamRun,
} from '../run/__tests__/synthetic'
import '../index.css'
/**
@@ -15,21 +21,23 @@ import '../index.css'
* 只有 dev server 会加载(vite build 的 input 只有 index.html),所以它不进
* 生产包。存在的理由:要改这个组件的排版,不该每次都真跑一次图——真跑一次
* 意味着要连数据库、等模型、还得凑巧撞上人工介入分支才能看到审批卡长什么样。
* 这里用的是真实导出的事件,走的是同一个 decodeRun,只有事件来源是静态的。
* 这里用的是真实导出的事件(fixtures.json)和照后端字段合成的事件
* (synthetic.ts),走的是同一个 decodeRun,只有事件来源是静态的。
*
* 打开 http://localhost:5273/preview.html
* 打开 http://localhost:5273/preview.html;scripts/check-stream.mjs 按下面这些
* 查询参数逐个打开截图、断言。
*/
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: '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: '完成' },
{ key: 'loop_approve', question: '写条上线公告,我过一眼再发', phase: 'done', status: '已完成' },
{ key: 'supervisor', question: '冬虫夏草是什么', phase: 'done', status: '已完成' },
]
function buildTurns(): StreamTurn[] {
@@ -54,6 +62,16 @@ function buildTurns(): StreamTurn[] {
})
}
/**
* 合成事件的 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 口子。
@@ -97,14 +115,14 @@ function longReport(): string {
/** 直接回答的那一轮:没查库,界面上必须说破 */
const NO_QUERY_TURN: StreamTurn = {
id: "noquery", question: "刚才那 6 个管理员是怎么算的?", phase: "done", status: "完成",
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: "完成",
id: "reviewbroken", question: "商家账户有多少个?", phase: "done", status: "已完成",
steps: [],
review: {
verdict: "annotated", severity: "broken", retry: true,
@@ -121,7 +139,7 @@ const REVIEW_BROKEN_TURN: StreamTurn = {
/** 复核只是补了一句说明,答案照常给;外加改写前的原文可展开 */
const REVIEW_DEGRADED_TURN: StreamTurn = {
id: "reviewdegraded", question: "谁能改别人的权限?", phase: "done", status: "完成",
id: "reviewdegraded", question: "谁能改别人的权限?", phase: "done", status: "已完成",
steps: [],
review: {
verdict: "rewritten", severity: "degraded", retry: false,
@@ -138,9 +156,9 @@ const REVIEW_DEGRADED_TURN: StreamTurn = {
/** 协作团队的泳道:前两轮并行、第三轮汇总 */
const TEAM_TURN: StreamTurn = {
id: "team", question: "比较三种向量数据库在中文检索上的取舍", phase: "done", status: "完成",
id: "team", question: "比较三种向量数据库在中文检索上的取舍", phase: "done", status: "已完成",
steps: [{
id: "n-team", seq: 1, kind: "node", status: "done", title: "调研团队", meta: "24.1s",
id: "n-team", seq: 1, kind: "node", status: "done", title: "调研团队", meta: "24.1 s", ms: 24100,
team: {
members: ["researcher", "analyst", "writer"],
savedMs: 11800,
@@ -181,17 +199,104 @@ const TEAM_TURN: StreamTurn = {
}
const LONG_TURN: StreamTurn = {
id: "long", question: "需要能够显示总结内容", phase: "done", status: "完成",
id: "long", question: "需要能够显示总结内容", phase: "done", status: "已完成",
steps: [], output: { result: longReport() },
}
/** 合成场景:新后端才有的事件,老库里导不出来 */
function synthetic(name: string): StreamTurn[] {
switch (name) {
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-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 '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: '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':
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 'schema': {
const ev = mixedRun().slice(0, 10)
return [{ id: 'schema', question: '看看有哪些人事表', phase: 'done', status: '已完成',
steps: decodeRun(ev, { status: 'succeeded' }) }]
}
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-[10px] text-faint">真实输出 #{i + 1}</div>
<div className="mb-2 text-2xs text-dim">真实输出 #{i + 1}</div>
<Markdown text={t} />
</div>
))}
@@ -199,17 +304,54 @@ function MarkdownCases() {
)
}
/**
* 一条在长的运行: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',
}]} />
)
}
/** 审批卡的上下文:等了多久、挂在哪个节点、将以谁的名义签批 */
const APPROVAL = {
id: 'ap-1', run_id: 'syn-mixed', node_id: 'review', mode: 'approve' as const,
title: '出勤结论可以发出吗?', status: 'pending', response: {},
payload: { 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,
}
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'
if (params.get('theme') === 'light') document.documentElement.dataset.theme = 'light'
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 [dense, setDense] = useState(false)
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
@@ -220,6 +362,32 @@ function Preview() {
)
if (md) return <div className="h-full overflow-y-auto"><MarkdownCases /></div>
if (params.get('grow') === '1') return <Growing dense={dense} />
if (params.get('approval') === '1') {
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) {
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}
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 }}
/>
)
return dense ? <div className="h-full w-[360px] border-r bg-panel">{stream}</div> : stream
}
if (params.get('long') === '1') {
return <AssistantStream turns={[LONG_TURN]} />
}
@@ -230,28 +398,28 @@ function Preview() {
return <AssistantStream turns={[REVIEW_BROKEN_TURN, REVIEW_DEGRADED_TURN]} />
}
if (params.get('fanout') === '1') {
return <AssistantStream dense={params.get('dense') === '1'} turns={[{
id: 'fanout', question: '三个库各查一遍,汇总给我', phase: 'done', status: '完成',
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={params.get('dense') === '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-[12px]">
<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-faint">真实事件 · 离线渲染</span>
<span className="text-dim">真实事件 · 离线渲染</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)} />
<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-[11.5px] text-dim">
<label className="flex items-center gap-1.5 text-dim">
<input type="checkbox" checked={emptyState}
onChange={(e) => setEmptyState(e.target.checked)} />
空态
@@ -259,7 +427,7 @@ function Preview() {
<button className="btn btn-sm btn-ghost"
onClick={() => {
const el = document.documentElement
el.dataset.theme = el.dataset.theme === 'light' ? '' : 'light'
el.dataset.theme = el.dataset.theme === 'light' ? 'dark' : 'light'
}}>
切主题
</button>
@@ -268,19 +436,26 @@ function Preview() {
<div className="flex min-h-0 flex-1">
{/* 宽屏:问数据页的形态 */}
<div className="min-w-0 flex-1 border-r">
<AssistantStream turns={emptyState ? [] : turns} empty={empty} />
<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} />
<AssistantStream turns={emptyState ? [] : turns} dense empty={empty} onFollowUp={onFollowUp} />
</div>
</div>
</div>
)
}
createRoot(document.getElementById('root')!).render(
<ToastHost>
<Preview />
</ToastHost>,
// 预览引用了 store(审批卡要用),别人改 store 时 HMR 会把这个入口重新执行一遍,
// 同一个容器上再 createRoot 会报错
const w = window as any
w.__previewRoot ??= createRoot(document.getElementById('root')!)
w.__previewRoot.render(
<BrowserRouter>
<ToastHost>
<Preview />
</ToastHost>
</BrowserRouter>,
)
+351
View File
@@ -0,0 +1,351 @@
import { useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { Inbox, Plus, Trash2, RefreshCw } from 'lucide-react'
import {
EmptyState, ErrorState, Field, IconButton, Kbd, Modal, OfflineBanner, Skeleton, StatusBadge,
StatusPill, Tabs, TabPanel, ToastHost, confirmDialog, promptDialog, toast,
} from '../components/ui'
import { ApiError, api } from '../api/client'
import { useCatalog, useOnReconnect } from '../store/catalog'
import { STATUS } from '../lib/status'
import { formatClock, formatCost, formatDateTime, formatDuration, formatTime, formatTokens } from '../lib/format'
import * as format from '../lib/format'
import * as status from '../lib/status'
import * as keys from '../lib/keys'
import * as terms from '../lib/terms'
import * as errors from '../lib/errors'
import '../index.css'
/**
* 基础组件的离线预览(ui.tsx)。
*
* 只有 dev server 会加载(vite build 的 input 只有 index.html),不进生产包。
* 存在的理由:弹窗的焦点管理、Esc 的输入法判断、toast 的寿命这些东西,在真实
* 页面里要凑齐场景才看得到;这里每样都摆出来,亮暗两套主题一键切换。
*
* 打开 http://localhost:5273/ui-harness.html
*
* window.__ui 和各个按钮的 id 是检查脚本在用的,改名要一起改。
*/
const ALL_STATUS = Object.keys(STATUS)
function Block({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="rounded-xl border bg-panel p-4" data-block={title}>
<h3 className="mb-3 text-[11px] font-semibold uppercase tracking-wide text-faint">{title}</h3>
{children}
</section>
)
}
function DirtyModalDemo() {
const [open, setOpen] = useState(false)
const [text, setText] = useState('')
return (
<>
<button className="btn btn-sm" id="open-dirty" onClick={() => setOpen(true)}>打开编辑弹窗</button>
<Modal
open={open}
onClose={() => { setOpen(false); setText('') }}
title="编辑 Skill"
dirty={text.trim() !== ''}
footer={
<>
<button className="btn" onClick={() => { setOpen(false); setText('') }}>取消</button>
<button className="btn btn-primary" onClick={() => { setOpen(false); setText(''); toast.ok('已保存') }}>保存</button>
</>
}
>
<div className="flex flex-col gap-3">
<Field label="名称" hint="小写英文、数字和下划线" required>
{(p) => <input className="field" {...p} placeholder="sales_report" />}
</Field>
<Field label="指令" hint="填了内容再按 Esc 或点遮罩,会先问一句">
{(p) => (
<textarea className="field" rows={4} {...p} value={text} onChange={(e) => setText(e.target.value)} />
)}
</Field>
</div>
</Modal>
</>
)
}
/**
* 页面自己拉的列表:断开期间拿到 [],恢复后靠 useOnReconnect 重拉。
* 运行记录、数据源、会话这些页面都是这个形状。
*/
function LocalListDemo() {
const [runs, setRuns] = useState<{ id: string }[] | null>(null)
const [loads, setLoads] = useState(0)
const load = () => {
setLoads((n) => n + 1)
void api.runs.list({ limit: 3 }).catch(() => []).then(setRuns)
}
useEffect(load, [])
useOnReconnect(load)
return (
<div className="text-xs text-dim">
<div className="flex items-center gap-2">
<span>
页面自己的列表:拉取 <span id="local-list-loads" className="mono">{loads}</span> 次 · 当前{' '}
<span id="local-list-count" className="mono">{runs?.length ?? '—'}</span> 条
</span>
<button className="btn btn-sm ml-auto" id="local-list-reload" onClick={load}>重新拉取</button>
</div>
{runs && !runs.length && <EmptyState title="还没有运行记录" className="py-4" />}
</div>
)
}
function Harness() {
const [theme, setTheme] = useState<'dark' | 'light'>(() => {
// 没有显式主题时会跟随系统;预览要确定是哪一套,开场就钉住
const t = (document.documentElement.getAttribute('data-theme') as 'dark' | 'light') || 'dark'
document.documentElement.setAttribute('data-theme', t)
return t
})
const [tab, setTab] = useState('a')
const [last, setLast] = useState('')
const backend = useCatalog((s) => s.backend)
const flip = (t: 'dark' | 'light') => {
setTheme(t)
document.documentElement.setAttribute('data-theme', t)
}
return (
<div className="flex h-full flex-col">
<OfflineBanner />
<div className="flex-1 overflow-y-auto">
<div className="mx-auto flex max-w-5xl flex-col gap-4 p-6">
<div className="flex items-center gap-2">
<h1 className="text-base font-semibold">基础组件</h1>
<span className="text-xs text-faint">ui.tsx · lib/*</span>
<div className="ml-auto flex gap-1">
<button className="btn btn-sm" id="theme-dark" onClick={() => flip('dark')} aria-pressed={theme === 'dark'}>暗</button>
<button className="btn btn-sm" id="theme-light" onClick={() => flip('light')} aria-pressed={theme === 'light'}>亮</button>
<button
className="btn btn-sm"
id="toggle-offline"
onClick={() => useCatalog.setState(backend === 'down'
? { backend: 'ok', backendError: null, retryAt: null }
: { backend: 'down', backendError: '连不上后端服务(可能没启动或正在重启),稍后重试', retryAt: Date.now() + 8000, lastOkAt: Date.now() - 42_000 })}
>
{backend === 'down' ? '恢复在线' : '模拟离线'}
</button>
</div>
</div>
<Block title="状态徽标 · 彩色">
<div className="flex flex-wrap gap-x-5 gap-y-3" id="badges-color">
{ALL_STATUS.map((s) => (
<div key={s} className="flex w-24 flex-col items-center gap-1.5">
<StatusBadge status={s} size={18} />
<span className="text-[11px] text-dim">{STATUS[s as keyof typeof STATUS].label}</span>
<span className="mono text-[10px] text-faint">{s}</span>
</div>
))}
</div>
</Block>
<Block title="状态徽标 · 灰度(去掉颜色也要认得出)">
<div className="flex flex-wrap gap-x-5 gap-y-3" id="badges-gray" style={{ filter: 'grayscale(1)' }}>
{ALL_STATUS.map((s) => (
<div key={s} className="flex w-24 flex-col items-center gap-1.5">
<StatusBadge status={s} size={18} animate={false} />
<span className="text-[11px] text-dim">{STATUS[s as keyof typeof STATUS].short}</span>
</div>
))}
</div>
</Block>
<Block title="状态标签 StatusPill">
<div className="flex flex-wrap gap-2">
{ALL_STATUS.map((s) => <StatusPill key={s} status={s} />)}
</div>
<div className="mt-3 flex flex-wrap items-center gap-3 text-xs text-dim">
<span>后端 interrupted:</span>
<StatusPill status="interrupted" pendingApproval />
<StatusPill status="interrupted" pendingApproval={false} />
<span className="ml-4">plain:</span>
<StatusPill status="succeeded" plain />
<StatusPill status="failed" plain />
</div>
</Block>
<div className="grid grid-cols-2 gap-4">
<Block title="弹窗 Modal · confirm · prompt">
<div className="flex flex-wrap gap-2">
<DirtyModalDemo />
<button
className="btn btn-sm"
id="open-confirm"
onClick={async () => setLast(String(await confirmDialog({
title: '发布这个版本?', body: '发布后可以发起正式运行。', confirmLabel: '发布 v4',
})))}
>确认框</button>
<button
className="btn btn-sm btn-danger"
id="open-danger"
onClick={async () => setLast(String(await confirmDialog({
title: '删除工作流「⑥ 多 Agent 协作」?',
consequences: ['连同 4 条运行记录一起删除', '不可恢复'],
confirmLabel: '删除工作流和 4 条记录',
danger: true,
})))}
>危险确认</button>
<button
className="btn btn-sm btn-danger"
id="open-require"
onClick={async () => setLast(String(await confirmDialog({
title: '删除封存过的正式运行?',
body: '这条运行的清单封存过,是审计凭证。',
consequences: ['事件流、产出物、封存哈希一起删除', '之后无法再核对'],
confirmLabel: '删除凭证',
danger: true,
requireText: 'a3f9c2',
})))}
>照抄确认</button>
<button
className="btn btn-sm"
id="open-prompt"
onClick={async () => setLast(JSON.stringify(await promptDialog({
title: '新建工作流', label: '名称', initial: '未命名工作流 09-26 10:05', confirmLabel: '新建',
validate: (v) => (v === '新工作流' ? '已经有一个同名的了' : null),
})))}
>输入框</button>
</div>
<div className="mt-2 text-[11px] text-faint">结果:<span id="dialog-result" className="mono text-dim">{last || '—'}</span></div>
</Block>
<Block title="提示 toast">
<div className="flex flex-wrap gap-2">
<button className="btn btn-sm" onClick={() => toast('已复制到剪贴板')}>普通</button>
<button className="btn btn-sm" onClick={() => toast.ok('已保存')}>成功</button>
<button className="btn btn-sm" onClick={() => toast.warn('这张图有 2 条警告')}>警告</button>
<button className="btn btn-sm" id="toast-error" onClick={() => toast.error('保存失败:工作流名称不能为空')}>出错</button>
<button
className="btn btn-sm"
id="toast-network"
onClick={() => toast.error(new ApiError(0, '连不上后端服务(可能没启动或正在重启),稍后重试',
{ kind: 'network', raw: 'TypeError: Failed to fetch' }))}
>网络断</button>
<button
className="btn btn-sm"
onClick={() => toast.ok('已提取为草稿「⑥ 多 Agent 协作 · 提取模板」', {
action: { label: '去画布打开', onClick: () => toast('(这里会跳到画布)') },
})}
>带动作</button>
<button
className="btn btn-sm"
onClick={() => toast('已删除会话「本周销量」', 'info', {
action: { label: '撤销', onClick: () => toast.ok('已恢复') }, duration: 5000,
})}
>撤销</button>
<button className="btn btn-sm" onClick={() => { for (let i = 0; i < 6; i++) toast(`第 ${i + 1} 条`) }}>叠 6 条</button>
<button className="btn btn-sm" onClick={() => toast.dismiss()}>全部关掉</button>
</div>
</Block>
</div>
<div className="grid grid-cols-2 gap-4">
<Block title="空态 EmptyState(模拟离线时会变)">
<EmptyState
icon={<Inbox size={22} />}
title="还没有工作流"
body="新建一张空白工作流,或者让助手按一句话画出来。"
action={<button className="btn btn-sm btn-primary"><Plus size={12} /> 新建工作流</button>}
/>
<EmptyState title="没有匹配的工具" offline={false} className="py-4" />
</Block>
<Block title="出错 ErrorState">
<ErrorState
error={new ApiError(0, '连不上后端服务(可能没启动或正在重启),稍后重试', { kind: 'network', raw: 'TypeError: Failed to fetch' })}
onRetry={() => toast('重试')}
className="py-4"
/>
<ErrorState
compact
error={'2 validation errors for GraphSpec\nnodes.1.type\n Input should be \'input\', \'output\' … [type=literal_error]'}
/>
<ErrorState
compact
className="mt-2"
error={new ApiError(409, '这条运行还在跑,先停止再删除。', { raw: '409 {"detail":"这条运行还在跑,先停止再删除。"}' })}
/>
</Block>
</div>
<div className="grid grid-cols-2 gap-4">
<Block title="加载 Skeleton">
<Skeleton rows={4} />
<Skeleton rows={2} cols={3} height={44} className="mt-4" />
<div className="mt-4 border-t pt-3"><LocalListDemo /></div>
</Block>
<Block title="图标按钮 · 键帽 · 格式">
<div className="flex items-center gap-2">
<IconButton label="删除这条记录" icon={<Trash2 size={13} />} variant="danger" />
<IconButton label="刷新列表" shortcut="Mod+R" icon={<RefreshCw size={13} />} />
<Kbd combo="Mod+K" />
<Kbd combo="Mod+Enter" />
<Kbd combo="Alt+V" />
<Kbd combo="Shift+?" />
<Kbd combo="Esc" />
</div>
<dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs tabular-nums">
<dt className="text-faint">耗时</dt>
<dd>{[820, 7600, 59_960, 74_000, 119_600, 3_720_000].map(formatDuration).join(' · ')}</dd>
<dt className="text-faint">计时</dt>
<dd className="mono">{[7_630, 74_310, 3_723_400].map(formatClock).join(' · ')}</dd>
<dt className="text-faint">tokens</dt>
<dd>{formatTokens(56034)} · {formatTokens(56034, { compact: true })} · {formatTokens(null)}</dd>
<dt className="text-faint">成本</dt>
<dd>{[0.0312, 0.0004, 0, 1.2345].map(formatCost).join(' · ')}</dd>
<dt className="text-faint">时间</dt>
<dd title={formatDateTime('2026-09-26T01:11:19.891698')}>
{formatTime('2026-09-26T01:11:19.891698')} · {formatTime('2026-09-25T04:52:00Z')} · {formatDateTime('2026-09-26T01:11:19.891698')}
</dd>
</dl>
</Block>
</div>
<Block title="标签页 Tabs · 表单字段 Field">
<Tabs
label="示例标签"
idPrefix="demo"
tabs={[{ key: 'a', label: '模型接入' }, { key: 'b', label: '数据源', badge: 2 }, { key: 'c', label: '偏好' }]}
active={tab}
onChange={setTab}
/>
<TabPanel idPrefix="demo" tabKey={tab} className="grid grid-cols-2 gap-3 pt-3">
<Field label="Base URL" htmlFor="demo-url" hint="OpenAI 兼容端点,以 /v1 结尾" required>
<input id="demo-url" className="field" aria-describedby="demo-url-hint" placeholder="https://…/v1" />
</Field>
<Field label="标识" error="只能用小写英文、数字和下划线,并以字母开头">
{(p) => <input className="field" defaultValue="Sales-DB" {...p} style={{ borderColor: 'var(--err)' }} />}
</Field>
</TabPanel>
</Block>
</div>
</div>
</div>
)
}
;(window as any).__ui = {
toast, confirmDialog, promptDialog, useCatalog, ApiError, api,
// 检查脚本直接拿应用同一份模块实例测 lib:instanceof ApiError 才靠得住
lib: { format, status, keys, terms, errors },
}
createRoot(document.getElementById('root')!).render(
<BrowserRouter>
<ToastHost>
<Harness />
</ToastHost>
</BrowserRouter>,
)