mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:51:57 +00:00
界面与交互全面翻修(前两波):运行态有了 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:
+210
-35
@@ -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>,
|
||||
)
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
Reference in New Issue
Block a user