界面与交互全面翻修(前两波):运行态有了 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
+276 -48
View File
@@ -1,50 +1,218 @@
import type {
Approval, Conversation, ConversationDetail, ConversationTurn, GraphSpec,
KbDocument, MemoryItem, Provider, ReviewResult, Run, RunEvent, Skill, ToolInfo,
ValidationIssue, VarIssue, Variable, Workflow,
KbDocument, MemoryItem, Provider, ReviewResult, Run, RunEvent, RunStatus, Skill, ToolInfo,
ValidationIssue, VarIssue, Variable, Workflow, WorkflowVersion,
} from '../types'
const BASE = '/api'
/**
* network:请求根本没到后端(没起、在重启、代理断了、超时没回)。
* http:后端回了错误状态码,message 是后端的 detail。
*/
export type ApiErrorKind = 'network' | 'http'
export const NETWORK_MESSAGE = '连不上后端服务(可能没启动或正在重启),稍后重试'
export class ApiError extends Error {
constructor(public status: number, message: string) {
kind: ApiErrorKind
/** 后端 detail 的原样(字符串、校验错误数组都可能) */
detail?: unknown
/** 原始报错文本,给「技术细节」和复制用 */
raw?: string
/** 超时断开时等了多久 */
timeoutMs?: number
constructor(
public status: number,
message: string,
opts?: { kind?: ApiErrorKind; detail?: unknown; raw?: string; timeoutMs?: number },
) {
super(message)
this.name = 'ApiError'
this.kind = opts?.kind ?? (status === 0 ? 'network' : 'http')
this.detail = opts?.detail
this.raw = opts?.raw
this.timeoutMs = opts?.timeoutMs
}
}
/**
* 连接状态的旁听者:任何一次请求够着了后端(不管状态码)就报 true,网络层失败
* 报 false。catalog 靠它维护全站的「后端连没连上」,不用另起一路轮询去猜。
*/
type ConnectivityListener = (reachable: boolean, error?: ApiError) => void
const connectivity = new Set<ConnectivityListener>()
export function onConnectivity(listener: ConnectivityListener): () => void {
connectivity.add(listener)
return () => { connectivity.delete(listener) }
}
function report(reachable: boolean, error?: ApiError) {
for (const l of connectivity) {
try { l(reachable, error) } catch { /* 旁听者出错不能拖垮请求本身 */ }
}
}
function actorHeader(): Record<string, string> {
try {
const actor = localStorage.getItem('agentlab_actor')
return actor ? { 'X-Actor': actor } : {}
// 请求头只能是 Latin-1:中文署名原样放进去,fetch 直接抛错、整个请求发不出去。
// 后端 runs.actor_of 解码,纯 ASCII 的老署名编码前后一样
return actor ? { 'X-Actor': encodeURIComponent(actor) } : {}
} catch {
return {}
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(BASE + path, {
...init,
headers: {
...(init?.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
...actorHeader(),
...init?.headers,
},
})
if (!res.ok) {
let detail = `${res.status} ${res.statusText}`
try {
const body = await res.json()
// FastAPI 的校验错误是数组,直接 String() 会变成 [object Object]
detail = typeof body.detail === 'string' ? body.detail : JSON.stringify(body.detail ?? body)
} catch {
/* 响应体不是 JSON,保留状态行 */
}
throw new ApiError(res.status, detail)
/** FastAPI 的 422 是 [{loc, msg, type}] 数组,直接 String() 会变成 [object Object] */
function describeDetail(detail: unknown): string {
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) {
const parts = detail.map((d: any) => {
const loc = Array.isArray(d?.loc) ? d.loc.filter((x: unknown) => x !== 'body' && x !== 'query').join('.') : ''
return loc ? `${loc}:${d?.msg ?? ''}` : String(d?.msg ?? JSON.stringify(d))
})
return `提交的内容不符合要求:${parts.join(';')}`
}
try { return JSON.stringify(detail) } catch { return String(detail) }
}
/**
* 网关类失败(代理够不着后端)也算网络失败:vite 代理连不上后端时回 500 且响应体
* 为空,nginx 回 502/503/504 的 HTML。后端自己抛的 HTTPException 一定带 JSON detail。
*/
function isGatewayFailure(status: number, bodyText: string, isJson: boolean): boolean {
if (isJson) return false
if (status === 502 || status === 503 || status === 504) return true
return status === 500 && !bodyText.trim()
}
async function request<T>(path: string, init?: RequestInit & { timeoutMs?: number }): Promise<T> {
const { timeoutMs, ...rest } = init ?? {}
// 自己的超时和调用方的取消要分开:调用方取消照旧抛 AbortError(没人想看到
// 「连不上后端」),只有我们自己掐断的才算网络失败
let timer: ReturnType<typeof setTimeout> | undefined
let timedOut = false
let signal = rest.signal
if (timeoutMs) {
const ctrl = new AbortController()
timer = setTimeout(() => { timedOut = true; ctrl.abort() }, timeoutMs)
rest.signal?.addEventListener('abort', () => ctrl.abort())
signal = ctrl.signal
}
let res: Response
try {
res = await fetch(BASE + path, {
...rest,
signal,
headers: {
...(rest.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
...actorHeader(),
...rest.headers,
},
})
} catch (e: any) {
if (timedOut) {
const err = new ApiError(0, `后端 ${Math.round(timeoutMs! / 1000)} 秒没有响应,稍后重试`,
{ kind: 'network', raw: `timeout after ${timeoutMs}ms: ${path}`, timeoutMs })
report(false, err)
throw err
}
if (e?.name === 'AbortError') throw e
// fetch 的网络失败是 TypeError(Chrome「Failed to fetch」、Safari「Load failed」),
// 原文对用户毫无意义,收进 raw
const err = new ApiError(0, NETWORK_MESSAGE, { kind: 'network', raw: `${e?.name ?? 'Error'}: ${e?.message ?? e}` })
report(false, err)
throw err
} finally {
if (timer) clearTimeout(timer)
}
if (!res.ok) {
const text = await res.text().catch(() => '')
let body: any
let isJson = false
try { body = JSON.parse(text); isJson = true } catch { /* 响应体不是 JSON */ }
if (isGatewayFailure(res.status, text, isJson)) {
const err = new ApiError(res.status, NETWORK_MESSAGE, {
kind: 'network', raw: `${res.status} ${res.statusText}${text ? `\n${text.slice(0, 500)}` : ''}`,
})
report(false, err)
throw err
}
report(true)
if (isJson) {
const detail = body?.detail ?? body
throw new ApiError(res.status, describeDetail(detail), {
detail, raw: typeof body?.raw === 'string' ? body.raw : `${res.status} ${text.slice(0, 2000)}`,
})
}
const message = res.status >= 500
? `后端出错了(${res.status}),详情看服务日志`
: `请求没有成功(${res.status} ${res.statusText})`
throw new ApiError(res.status, message, { raw: `${res.status} ${res.statusText}\n${text.slice(0, 2000)}` })
}
report(true)
if (res.status === 204) return undefined as T
return res.json()
}
/**
* 数据源测连接的结果。ok=false 时 error 是一句人话、hint 是怎么办、detail 是
* 驱动的原始报错(放进「技术细节」)。
*/
export interface ConnectionTest {
ok: boolean
elapsed_ms?: number
url?: string
error?: string
hint?: string
detail?: string
[key: string]: any
}
/** 模型接入测试的结果,失败时的字段同 ConnectionTest */
export interface ProviderTest {
ok: boolean
latency_ms?: number
model?: string | null
reply?: string
usage?: Record<string, any>
error?: string
hint?: string
detail?: string
[key: string]: any
}
/** 还没保存的模型接入配置。带 id 表示在编辑已有的那个,api_key 留空沿用已存的 */
export interface ProviderDraft {
id?: string
name?: string
kind: string
base_url?: string | null
api_key?: string | null
models?: { id: string; label?: string; context?: number; pricing?: any }[]
default_model?: string | null
extra?: Record<string, any>
}
/** 拼查询串:null / undefined / 空串跳过,数组用逗号连起来 */
function qs(params?: Record<string, unknown>): string {
if (!params) return ''
const q = new URLSearchParams()
for (const [k, v] of Object.entries(params)) {
if (v == null || v === '') continue
if (Array.isArray(v)) {
if (v.length) q.set(k, v.join(','))
} else {
q.set(k, String(v))
}
}
const s = q.toString()
return s ? `?${s}` : ''
}
const get = <T>(p: string) => request<T>(p)
const post = <T>(p: string, body?: unknown) =>
request<T>(p, { method: 'POST', body: JSON.stringify(body ?? {}) })
@@ -55,7 +223,8 @@ const put = <T>(p: string, body: unknown) =>
const del = (p: string) => request<void>(p, { method: 'DELETE' })
export const api = {
health: () => get<{ status: string }>('/health'),
/** 心跳。带超时:后端卡死(连得上但不回)时也要能判成断开,而不是永远等下去 */
health: (timeoutMs = 5000) => request<{ status: string; service?: string }>('/health', { timeoutMs }),
system: () => get<any>('/system'),
// ---- 工作流 ----
@@ -68,7 +237,9 @@ export const api = {
patch<Workflow>(`/workflows/${id}`, body),
remove: (id: string) => del(`/workflows/${id}`),
duplicate: (id: string) => post<Workflow>(`/workflows/${id}/duplicate`),
versions: (id: string) => get<any[]>(`/workflows/${id}/versions`),
versions: (id: string) => get<WorkflowVersion[]>(`/workflows/${id}/versions`),
/** 某个版本的完整快照(含 graph)。正式运行的输入字段要从已发布版本取,不能取画布 */
version: (id: string, v: number) => get<WorkflowVersion>(`/workflows/${id}/versions/${v}`),
restore: (id: string, v: number) => post<Workflow>(`/workflows/${id}/versions/${v}/restore`),
publish: (id: string, level: 'published' | 'governed', version?: number) =>
post<{ ok: boolean; level?: string; version?: number; issues: any[] }>(
@@ -87,7 +258,9 @@ export const api = {
create: (body: any) => post<any>('/datasources', body),
update: (id: string, body: any) => patch<any>(`/datasources/${id}`, body),
remove: (id: string) => del(`/datasources/${id}`),
test: (id: string) => post<any>(`/datasources/${id}/test`, {}),
test: (id: string) => post<ConnectionTest>(`/datasources/${id}/test`, {}),
/** 测一份还没保存的配置,只测连接不落库。编辑时带上 id,后端可以沿用已存的密码 */
testConfig: (body: any) => post<ConnectionTest>('/datasources/test', body),
/** 上传 Excel / CSV,变成一个可以用 SQL 查的数据源。同名就地替换 */
uploadTable: (file: File, body: { name: string; description?: string; header_row?: number }) => {
const form = new FormData()
@@ -108,13 +281,18 @@ export const api = {
},
runs: {
list: (params?: { workflow_id?: string; status?: string; limit?: number }) => {
const q = new URLSearchParams(
Object.entries(params ?? {}).filter(([, v]) => v != null) as [string, string][],
)
return get<Run[]>(`/runs${q.toString() ? `?${q}` : ''}`)
},
/**
* status 可以给多个(逗号连接);q 按工作流名模糊匹配;before 是翻页游标
* (上一页最后一条的 created_at);limit 上限 200。老后端不认的参数会被忽略。
*/
list: (params?: {
workflow_id?: string; status?: RunStatus | RunStatus[] | string; run_class?: 'formal' | 'exploratory'
q?: string; limit?: number; before?: string
}) => get<Run[]>(`/runs${qs(params)}`),
get: (id: string) => get<Run>(`/runs/${id}`),
/** 这次运行实际跑的那张图(运行时的快照),不是工作流现在的样子 */
graph: (id: string) => get<{ graph: GraphSpec; workflow_id: string | null; version: number | null }>(
`/runs/${id}/graph`),
start: (body: {
workflow_id?: string; graph?: GraphSpec; input?: Record<string, any>
memory_scope?: string; collection?: string
@@ -132,11 +310,15 @@ export const api = {
/** 核对事件流和封存时的清单哈希:事后被改过、删过、插过都会对不上 */
verify: (id: string) => get<{ sealed: boolean; ok: boolean | null; message: string }>(
`/runs/${id}/verify`),
remove: (id: string) => del(`/runs/${id}`),
/** 运行中的删不了(409);封存过的正式运行要 force,否则也是 409,detail 写明后果 */
remove: (id: string, opts?: { force?: boolean }) =>
del(`/runs/${id}${opts?.force ? '?force=true' : ''}`),
},
approvals: {
list: (status = 'pending') => get<Approval[]>(`/approvals?status=${status}`),
/** 老写法 list('pending') 照旧可用。status 可逗号分隔,'all' 取全部 */
list: (params: string | { status?: string; run_id?: string; limit?: number } = 'pending') =>
get<Approval[]>(`/approvals${qs(typeof params === 'string' ? { status: params } : { status: 'pending', ...params })}`),
decide: (id: string, body: { approved: boolean; note?: string; value?: any; args?: any }) =>
post<Run>(`/approvals/${id}/decide`, body),
},
@@ -149,7 +331,14 @@ export const api = {
update: (id: string, body: any) => patch<Provider>(`/providers/${id}`, body),
remove: (id: string) => del(`/providers/${id}`),
test: (id: string, body: { model?: string; prompt?: string }) =>
post<any>(`/providers/${id}/test`, body),
post<ProviderTest>(`/providers/${id}/test`, body),
/** 测一份还没保存的接入配置,不落库。model 不填用 default_model */
testConfig: (body: ProviderDraft & { model?: string | null; prompt?: string }) =>
post<ProviderTest>('/providers/test', body),
/** 问这个接入点有哪些模型(带鉴权调 /v1/models),省得手敲模型 id */
models: (body: ProviderDraft) =>
post<{ ok: boolean; models: string[]; url?: string; error?: string; hint?: string; detail?: string }>(
'/providers/models', body),
},
settings: {
get: () => get<Record<string, any>>('/settings'),
@@ -159,8 +348,9 @@ export const api = {
// ---- 工具 ----
tools: {
list: () => get<ToolInfo[]>('/tools'),
run: (name: string, args: Record<string, any>) =>
post<any>(`/tools/${name}/run`, { args }),
/** 危险工具要 confirm,否则后端回 409,detail 写明它会做什么 */
run: (name: string, args: Record<string, any>, opts?: { confirm?: boolean }) =>
post<any>(`/tools/${name}/run`, opts?.confirm ? { args, confirm: true } : { args }),
},
customTools: {
list: () => get<any[]>('/custom-tools'),
@@ -191,8 +381,19 @@ export const api = {
list: (scope?: string) => get<MemoryItem[]>(`/memory${scope ? `?scope=${scope}` : ''}`),
add: (body: { content: string; scope?: string; kind?: string; importance?: number }) =>
post<MemoryItem>('/memory', body),
search: (q: string, scope = 'default') =>
get<any>(`/memory/search?q=${encodeURIComponent(q)}&scope=${scope}`),
search: (q: string, scope = 'default', opts?: { peek?: boolean; limit?: number }) =>
get<any>(`/memory/search${qs({ q, scope, peek: opts?.peek ? 'true' : undefined, limit: opts?.limit })}`),
/**
* 回忆调试:默认 peek,只看不计数。真实召回会给命中项 use_count += 1 并影响
* 下次排序,调试台点几下就会把「被召回 N 次」抬高
*/
recall: (q: string, opts?: { scope?: string; peek?: boolean; limit?: number }) =>
get<any>(`/memory/search${qs({
q, scope: opts?.scope ?? 'default', peek: (opts?.peek ?? true) ? 'true' : undefined, limit: opts?.limit,
})}`),
/** 原地修改,保留来源和召回记录 */
update: (id: string, body: { content?: string; importance?: number; kind?: string }) =>
patch<MemoryItem>(`/memory/${id}`, body),
remove: (id: string) => del(`/memory/${id}`),
clearScope: (scope: string) => del(`/memory/scope/${scope}`),
},
@@ -208,7 +409,11 @@ export const api = {
return request<KbDocument>(`/kb/upload?collection=${collection}`, { method: 'POST', body: form })
},
search: (q: string, collection?: string, alpha = 0.5) =>
get<any>(`/kb/search?q=${encodeURIComponent(q)}&alpha=${alpha}${collection ? `&collection=${collection}` : ''}`),
get<any & { alpha?: number }>(`/kb/search?q=${encodeURIComponent(q)}&alpha=${alpha}${collection ? `&collection=${collection}` : ''}`),
/** 上传框能收哪些文件。以后端解析器为准,免得界面放行了后端必拒的格式 */
formats: () => get<{
extensions: string[]; text: string[]; tabular: string[]; legacy: string[]; accept: string
}>('/kb/formats'),
/** 一份文档被切成了什么样。检索不准时第一个该看的就是它 */
document: (id: string) => get<{
document: KbDocument
@@ -224,6 +429,8 @@ export const api = {
stale_chunks: number; stale_memories: number; unindexed_chunks: number
// has_semantics 看的是实际在用的那个;fallback = 配了语义模型却退回了本地哈希
has_semantics: boolean; fallback: boolean; fallback_reason: string
/** 运行时 kb_search 实际用的混合权重。调试台的滑块初值要跟它一致 */
default_alpha?: number
}>(`/kb/embedding${collection ? `?collection=${collection}` : ''}`),
setEmbedding: (body: { kind: string; model?: string; base_url?: string }) =>
put<{
@@ -321,11 +528,22 @@ export function streamCopilot(
signal: controller.signal,
})
if (!res.ok || !res.body) {
let detail = `${res.status} ${res.statusText}`
try { detail = (await res.json()).detail ?? detail } catch { /* keep */ }
onEnd(String(detail))
const text = await res.text().catch(() => '')
let body: any
let isJson = false
try { body = JSON.parse(text); isJson = true } catch { /* 响应体不是 JSON */ }
if (isGatewayFailure(res.status, text, isJson)) {
report(false, new ApiError(res.status, NETWORK_MESSAGE, { kind: 'network' }))
onEnd(NETWORK_MESSAGE)
return
}
report(true)
onEnd(isJson ? describeDetail(body?.detail ?? body)
: res.status >= 500 ? `后端出错了(${res.status}),详情看服务日志`
: `请求没有成功(${res.status} ${res.statusText})`)
return
}
report(true)
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
@@ -345,8 +563,14 @@ export function streamCopilot(
}
onEnd()
} catch (e: any) {
if (e?.name !== 'AbortError') onEnd(e?.message ?? '连接中断')
else onEnd()
if (e?.name === 'AbortError') { onEnd(); return }
// 流到一半断了和一开始就连不上,对用户是一回事:后端够不着了
if (e instanceof TypeError) {
report(false, new ApiError(0, NETWORK_MESSAGE, { kind: 'network', raw: String(e) }))
onEnd(NETWORK_MESSAGE)
return
}
onEnd(e?.message ?? '连接中断')
}
})()
return () => controller.abort()
@@ -365,7 +589,9 @@ export function streamCopilot(
export function streamRun(
runId: string,
onEvent: (event: RunEvent) => void,
onClose?: () => void,
// stream.end 带着运行的最终状态:连到一条已经不在进行中的运行(含 interrupted)
// 时,客户端靠它知道不会再有事件,不能一直当它还在跑
onClose?: (end?: { status?: RunStatus | string }) => void,
after = 0,
): () => void {
let socket: WebSocket | null = null
@@ -379,11 +605,12 @@ export function streamRun(
socket = new WebSocket(`${proto}://${location.host}/api/runs/${runId}/stream?after=${lastSeq}`)
socket.onmessage = (ev) => {
const event = JSON.parse(ev.data) as RunEvent & { type: string }
const event = JSON.parse(ev.data) as RunEvent & { type: string; status?: string }
if (event.type === 'stream.end') {
closed = true
socket?.close()
onClose?.()
// 新后端放在 data.status,老后端放在顶层 status
onClose?.({ status: event.data?.status ?? event.status })
return
}
if (event.seq) lastSeq = Math.max(lastSeq, event.seq)
@@ -397,6 +624,7 @@ export function streamRun(
}
socket.onopen = () => {
retry = 0
report(true)
}
}
connect()