Files
AgentLab/frontend/src/App.tsx
T

1032 lines
48 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, useRef, useState } from 'react'
import type { ReactNode, RefObject } from 'react'
import { Link, NavLink, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
import { Bell, BellOff, Moon, RotateCw, Sun, UserRound } from 'lucide-react'
import clsx from 'clsx'
import { ApiError, NETWORK_MESSAGE, api } from './api/client'
import { CATALOG_TIMEOUT_MS, catalogListState, useCatalog, useOnReconnect } from './store/catalog'
import type { CatalogCheck } from './store/catalog'
import { useStudio } from './store/studio'
import { useChat } from './store/chat'
import { useConversations } from './store/conversations'
import { ChatPage } from './pages/ChatPage'
import { StudioPage } from './pages/StudioPage'
import { RunsPage } from './pages/RunsPage'
import { ComparePage } from './pages/runs/ComparePage'
import { DataPage } from './pages/DataPage'
import { CatalogPage } from './pages/catalog/CatalogPage'
import { ToolsPage } from './pages/ToolsPage'
import { KnowledgePage } from './pages/KnowledgePage'
import { SettingsPage } from './pages/SettingsPage'
import { SchedulesPage } from './pages/schedules/SchedulesPage'
import { NotFound } from './pages/NotFound'
import { Logo } from './components/Logo'
import {
CommandPalette, PAGES, ShortcutHelp, flushThemePref, openShortcutHelp, toggleCommandPalette, toggleNotify,
toggleTheme, useTheme, waitedFor,
} from './components/CommandPalette'
import { ErrorBoundary, Kbd, OfflineBanner, Spinner, StatusBadge, isComposing, toast, useTicker } from './components/ui'
import { applyTheme, normalizeTheme } from './lib/theme'
import { ariaShortcut, isTypingTarget, matchShortcut } from './lib/keys'
import { formatDateTime, formatDuration, formatRelative, shortId } from './lib/format'
import { humanizeError } from './lib/errors'
import { isActivePhase } from './run/trace'
import {
composeTitle, flagAttention, notifyInBackground, notifySupported, paintFavicon, useSignals,
} from './lib/notify'
import type { Attention, FaviconDot } from './lib/notify'
import { useLeaveBlocker } from './lib/leave'
import { useLocalActor } from './lib/actor'
import { RUN_COMPARE_TEXT, runName } from './lib/terms'
export default function App() {
const loaded = useCatalog((s) => s.loaded)
const backend = useCatalog((s) => s.backend)
const everOk = useCatalog((s) => s.lastOkAt != null)
const allFailed = useCatalog((s) => s.checks.length > 0 && s.checks.every((c) => c.state === 'error'))
const location = useLocation()
const [offlineOk, setOfflineOk] = useState(false)
useEffect(() => { void useCatalog.getState().refresh() }, [])
// 连着时每 4 秒拉一次待审批,顺带当心跳;断开后的退避重试由 catalog 自己排
useEffect(() => useCatalog.getState().startHeartbeat(), [])
// 主题的真源在后端设置里。启动时没连上就等恢复再读一次,不然这次会话一直用缓存。
// 读到过、或者人在这台设备上换过(导航、⌘K 都派发 agentlab:theme),就不再读,
// 路上的那一次读回来也不应用:断线时换的主题不能在重连时被服务端的旧值翻回去
const themeSettled = useRef(false)
const loadTheme = () => {
if (themeSettled.current) return
api.settings.get().then((s) => {
if (themeSettled.current) return
themeSettled.current = true
applyTheme(normalizeTheme(s?.ui?.theme))
}).catch(() => {})
}
useEffect(loadTheme, [])
useEffect(() => {
const onLocal = () => { themeSettled.current = true }
window.addEventListener('agentlab:theme', onLocal)
return () => window.removeEventListener('agentlab:theme', onLocal)
}, [])
// 断线时换的那一下当时没存上:恢复后补存,而不是去读服务端
useOnReconnect(() => { if (!flushThemePref()) loadTheme() })
// 在 App 这一层算,从启动那一刻起计时:从启动页「先进去看看」时已经卡了好几秒,
// 进门那一下就得是琥珀,不能进了门再从头等
const stalled = useStalledChecks()
// 从没连上过就断着:停在启动页说清楚原因,而不是放人进去看一屏「还没有…」
const offlineAtBoot = !everOk && (backend === 'down' || (allFailed && backend === 'checking'))
const booting = !offlineOk && (!loaded || offlineAtBoot)
// 启动页的清单自己会说哪几项还在等、哪几项失败了;进了外壳才需要当面提醒
useCatalogWarning(stalled, !booting)
if (booting) return <BootScreen onEnterOffline={() => setOfflineOk(true)} />
return (
<div className="flex h-full">
<Nav stalled={stalled} />
<main className="flex min-w-0 flex-1 flex-col">
{/* 横幅必须是 main 的第一个孩子:它把自己的高度写给 toast,toast 才会跟着下移 */}
<OfflineBanner />
<div className="relative min-h-0 flex-1">
{/* 「在看哪个」属于 URL,不属于 store。
每页写成两条显式路由(带 id / 不带 id),而不是可选参数 `:id?`:
「没带 id」是真要单独处理的一种情况(落到上次那个、或列表第一个),
藏在一个问号里下次就没人记得它存在了。
导航高亮不用动——NavLink 默认前缀匹配,/chat/abc 照样点亮「问数据」。*/}
<ErrorBoundary resetKey={location.pathname}>
<Routes>
{/* 默认落到对话页:多数人要的是答案,画布留给要自己编排的人 */}
<Route path="/" element={<Navigate to="/chat" replace />} />
<Route path="/chat" element={<ChatPage />} />
<Route path="/chat/:conversationId" element={<ChatPage />} />
<Route path="/studio" element={<StudioPage />} />
<Route path="/studio/:workflowId" element={<StudioPage />} />
<Route path="/runs" element={<RunsPage />} />
{/* 固定路径写在 /runs/:runId 前面:读的人一眼看出 compare 不是运行 id */}
<Route path="/runs/compare" element={<ComparePage />} />
<Route path="/runs/:runId" element={<RunsPage />} />
<Route path="/data" element={<DataPage />} />
<Route path="/data/:tab" element={<DataPage />} />
{/* 数据源的数据目录:两段以上的 /data 地址不会落到上面的标签页 */}
<Route path="/data/catalog/:sourceId" element={<CatalogPage />} />
<Route path="/data/catalog/:sourceId/:table" element={<CatalogPage />} />
<Route path="/tools" element={<ToolsPage />} />
<Route path="/tools/:tab" element={<ToolsPage />} />
<Route path="/tools/:tab/:id" element={<ToolsPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/knowledge/:tab" element={<KnowledgePage />} />
<Route path="/knowledge/:tab/:id" element={<KnowledgePage />} />
<Route path="/schedules" element={<SchedulesPage />} />
<Route path="/schedules/:id" element={<SchedulesPage />} />
{/* 数据源搬到了顶级「数据」页;旧书签和各处「去设置 → 数据源」的链接还在 */}
<Route path="/settings/datasources" element={<Navigate to="/data" replace />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/settings/:tab" element={<SettingsPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
</ErrorBoundary>
</div>
</main>
<CommandPalette />
<ShortcutHelp />
<ShellKeys />
<ShellSignals />
<LeaveBlocker />
</div>
)
}
// -------------------------------------------------------------------------
// 全局快捷键
// -------------------------------------------------------------------------
/**
* 离开前确认的那一个 blocker(lib/leave):导航、链接、⌘K、⌥ 数字、浏览器后退,
* 换页都先经过页面登记的守卫。单独成一个组件,blocker 变了只重绘它
*/
function LeaveBlocker() {
useLeaveBlocker()
return null
}
/** 启动页上不挂:那时按下的 ⌘K 会在加载完之后才突然弹出面板 */
function ShellKeys() {
const navigate = useNavigate()
const navRef = useRef(navigate)
navRef.current = navigate
useEffect(() => {
// 捕获阶段注册:比各页面自己挂在 window 上的监听先到,? 只弹一层
const onKey = (e: KeyboardEvent) => {
if (e.defaultPrevented || isComposing(e)) return
// 别的弹窗开着时键盘归它:在弹窗上再叠一层命令面板,Esc 该关谁就乱了
const modal = document.querySelector('[aria-modal="true"]:not([data-command-palette])')
if (matchShortcut(e, 'Mod+K')) {
if (modal) return
e.preventDefault()
// 按住不放会连发:面板开了又关、关了又开
if (!e.repeat) toggleCommandPalette()
return
}
// 其余的都让给打字:输入框里的 ? 是问号,⌥1 在 Mac 上是「¡」
if (modal || document.querySelector('[data-command-palette]') || isTypingTarget(e.target)) return
if (matchShortcut(e, '?')) {
e.preventDefault()
e.stopPropagation()
openShortcutHelp()
return
}
const page = PAGES.find((p) => matchShortcut(e, p.shortcut))
if (page) {
e.preventDefault()
// 有没保存的改动时由 LeaveBlocker 先问,这里照常跳
navRef.current(page.to)
}
}
window.addEventListener('keydown', onKey, true)
return () => window.removeEventListener('keydown', onKey, true)
}, [])
return null
}
// -------------------------------------------------------------------------
// 导航
// -------------------------------------------------------------------------
/** 悬停提示:挂在导航右侧,读屏不念(导航项自己有文字,提示只是补充) */
function Tip({ children, badge = false }: { children: ReactNode; badge?: boolean }) {
return (
<span
aria-hidden
className={clsx(
'pointer-events-none invisible absolute left-full top-1/2 z-40 ml-2.5 flex -translate-y-1/2 items-center gap-2',
'whitespace-nowrap rounded-md border bg-elev px-2 py-1 text-xs font-normal text-fg opacity-0 shadow-elev-2',
'transition-opacity duration-150',
badge
? 'group-hover/badge:visible group-hover/badge:opacity-100 group-focus-visible/badge:visible group-focus-visible/badge:opacity-100'
: 'group-hover:visible group-hover:opacity-100 group-hover:delay-300 group-focus-visible:visible group-focus-visible:opacity-100',
)}
>
{children}
</span>
)
}
/** 有没有运行在跑:品牌标的起点节点亮起、标题加前缀都看它 */
function useStudioActivity(): string {
return useStudio((s) => {
if (s.runPhase === 'running' || s.runPhase === 'queued') {
const total = s.trace.nodesTotal ?? s.nodes.length
let done = 0
for (const n of Object.values(s.trace.nodes)) if (n.state === 'done' || n.state === 'skipped') done++
return total ? `run:${Math.min(done, total)}/${total}` : 'run'
}
return s.copilot.active ? 'gen' : ''
})
}
function useChatActivity(): '' | 'run' | 'gen' {
return useChat((s) => {
let gen = false
for (const turns of Object.values(s.byConversation)) {
for (const t of turns) {
if (t.phase === 'running') return 'run'
if (t.phase === 'planning' || t.phase === 'building') gen = true
}
}
return gen ? 'gen' : ''
})
}
function Nav({ stalled }: { stalled: CatalogCheck[] }) {
const studio = useStudioActivity()
const chat = useChatActivity()
const live = studio.startsWith('run') || chat === 'run'
// 整条导航约 580px 高(八个导航项),窗口再矮,最底下的遥测点和署名就被切掉、够不着。
// 矮窗口(≤600px)按高度收紧到 455px 左右(导航项 36px),而不是让导航滚动:导航一旦
// overflow,挂在右侧的悬停提示和遥测浮层也会被一起裁掉。加导航项时在 480px 高的窗口里
// 量一遍(check-shell 的「窗口高 480px」)
return (
<nav aria-label="主导航" className="relative z-30 flex w-16 shrink-0 flex-col border-r bg-panel">
<Link
to="/"
aria-label={live ? 'AgentLab 首页(有运行正在进行)' : 'AgentLab 首页'}
className="group relative mx-auto mb-2 mt-3 flex h-10 w-10 shrink-0 items-center justify-center rounded-lg outline-none hover:bg-hover focus-visible:ring-2 focus-visible:ring-[var(--accent)] [@media(max-height:600px)]:mb-1 [@media(max-height:600px)]:mt-2 [@media(max-height:600px)]:h-9"
>
<Logo size={22} live={live} className="text-accent" title="" />
<Tip>
<span className="font-semibold">AgentLab</span>
<span className="text-faint">受限动态编排{live ? ' · 有运行正在进行' : ''}</span>
</Tip>
</Link>
<div aria-hidden className="mx-4 mb-2 h-px shrink-0 bg-hairline [@media(max-height:600px)]:mb-1" />
<ul className="flex flex-col gap-0.5">
{PAGES.map((p) => (
<li key={p.to} className="relative px-1">
<NavLink
to={p.to}
aria-keyshortcuts={ariaShortcut(p.shortcut)}
className={({ isActive }) => clsx(
'group relative flex h-12 w-full flex-col items-center justify-center gap-1 rounded-md text-2xs leading-none outline-none',
'[@media(max-height:600px)]:h-9',
'transition-colors focus-visible:ring-2 focus-visible:ring-[var(--accent)]',
isActive ? 'bg-hover font-medium text-fg' : 'text-dim hover:bg-hover hover:text-fg',
)}
>
{({ isActive }) => (
<>
{/* 选中态三条通道:左缘竖条、图标变强调色、底色——只靠淡灰底根本认不出在哪一页 */}
{isActive && <span aria-hidden className="absolute -left-1 bottom-2 top-2 w-0.5 rounded-r bg-accent" />}
<p.icon size={17} strokeWidth={isActive ? 2 : 1.75} className={isActive ? 'text-accent' : undefined} />
<span>{p.label}</span>
<Tip>
<span className="font-medium">{p.label}</span>
<span className="text-faint">{p.hint}</span>
<Kbd combo={p.shortcut} />
</Tip>
</>
)}
</NavLink>
{p.to === '/runs' && <ApprovalBadge />}
</li>
))}
</ul>
<div className="mt-auto flex flex-col items-center gap-1.5 pb-2 pt-3 [@media(max-height:600px)]:gap-1 [@media(max-height:600px)]:pb-1 [@media(max-height:600px)]:pt-2">
<div className="flex items-center gap-0.5">
<NotifyToggle />
<ThemeToggle />
</div>
<ActorButton />
<div aria-hidden className="h-px w-8 bg-hairline" />
<Telemetry stalled={stalled} />
</div>
</nav>
)
}
/**
* 待审批徽标。
*
* 它是一个单独的链接(直达记录页的待审批页签),不是「记录」的一部分:导航
* 项本身永远进 /runs,否则同一个入口时而进列表、时而进某条详情,人就摸不准
* 点下去会到哪。挂在图标右上角外侧,外圈一道面板底色的描边把它从图标上抠开;
* 浅色下用 warn-solid 配 on-warn,不再是黑字压在深琥珀上糊成一团。
*/
function ApprovalBadge() {
const approvals = useCatalog((s) => s.approvals)
const pending = approvals.filter((a) => a.status === 'pending')
if (!pending.length) return null
const oldest = pending.reduce((a, b) => ((a.created_at ?? '') <= (b.created_at ?? '') ? a : b))
const waited = waitedFor(oldest.created_at)
const text = `${pending.length} 项待审批${waited ? `,最久已等待 ${waited}` : ''}`
return (
<Link
to="/runs?tab=approvals"
aria-label={`${text}。打开待审批`}
data-approval-badge=""
className={clsx(
'group/badge absolute left-[calc(50%+7px)] top-[3px] z-10 flex h-4 min-w-4 items-center justify-center rounded-full px-1',
'[@media(max-height:600px)]:top-0',
'bg-warn-solid text-[10px] font-semibold leading-none text-on-warn tnum ring-2 ring-[var(--bg-panel)]',
'outline-none focus-visible:ring-[var(--accent)]',
)}
>
{pending.length > 99 ? '99+' : pending.length}
<Tip badge>
<span className="font-medium">{text}</span>
<span className="text-faint">点击处理</span>
</Tip>
</Link>
)
}
function NotifyToggle() {
const notify = useSignals((s) => s.notify)
const permission = useSignals((s) => s.permission)
if (!notifySupported()) return null
const Icon = notify ? Bell : BellOff
const tip = notify
? '后台提醒:已开启 · 运行结束、失败或有新的待审批时发送系统通知'
: permission === 'denied'
? '后台提醒:浏览器已拒绝通知权限,请在站点设置中允许'
: '后台提醒:已关闭 · 开启时浏览器将请求通知权限'
return (
<button
type="button"
aria-label="后台提醒"
aria-pressed={notify}
onClick={() => void toggleNotify()}
className={clsx(
'group relative flex h-8 w-7 items-center justify-center rounded-md outline-none hover:bg-hover [@media(max-height:600px)]:h-7',
'focus-visible:ring-2 focus-visible:ring-[var(--accent)]',
notify ? 'text-accent' : 'text-faint hover:text-fg',
)}
>
<Icon size={15} aria-hidden />
<Tip>{tip}</Tip>
</button>
)
}
function ThemeToggle() {
const { pref, resolved } = useTheme()
const now = resolved === 'dark' ? '深色' : '浅色'
const next = resolved === 'dark' ? '浅色' : '深色'
const Icon = resolved === 'dark' ? Moon : Sun
return (
<button
type="button"
aria-label={`切换到${next}主题`}
onClick={toggleTheme}
className="group relative flex h-8 w-7 items-center justify-center rounded-md text-faint outline-none hover:bg-hover hover:text-fg focus-visible:ring-2 focus-visible:ring-[var(--accent)] [@media(max-height:600px)]:h-7"
>
<Icon size={15} aria-hidden />
<Tip>
切换到{next}主题
<span className="text-faint">当前{pref === 'system' ? `跟随系统(${now})` : now}</span>
</Tip>
</button>
)
}
/** 操作者署名的首字:发布、审批、正式运行都记在这个名下,得一直看得见是谁 */
function ActorButton() {
const actor = useLocalActor()
// 兜底:绕开 setLocalActor 直接写 localStorage 的地方不会通知,导航重绘时也重读一次
useLocation()
const initial = actor ? Array.from(actor)[0].toUpperCase() : ''
return (
<Link
to="/settings/prefs"
aria-label={actor ? `署名:${actor}。前往设置修改` : '尚未署名。前往设置填写'}
className={clsx(
'group relative flex h-7 w-7 items-center justify-center rounded-full border text-xs font-semibold outline-none',
'hover:border-[var(--border-strong)] hover:bg-hover focus-visible:ring-2 focus-visible:ring-[var(--accent)]',
actor ? 'bg-elev text-fg' : 'border-dashed text-faint',
)}
>
{initial || <UserRound size={13} aria-hidden />}
<Tip>
{actor ? <span>署名 <span className="font-medium">{actor}</span></span> : <span>未署名</span>}
<span className="text-faint">发布、审批和正式运行以此署名记录 · 点击修改</span>
</Tip>
</Link>
)
}
/** 延迟超过这个数就算慢:本机和内网通常是几十毫秒 */
const SLOW_MS = 1500
interface Tone { color: string; text: string; label: string; word: string }
/**
* 五档:离线(红)、加载中(琥珀:连得上,但请求卡着没回来)、降级(琥珀:连得上,
* 但有几张表报错)、偏慢(琥珀)、在线(绿)。加载中和降级单列出来:那几张表对应
* 的下拉和列表是空的,不说就像"没有"
*/
function backendTone(backend: string, latency: number | null, failing: number, stalled: number): Tone {
if (backend === 'down') return { color: 'var(--st-failed)', text: '离线', label: '服务端未连接', word: '未连接' }
if (backend === 'checking') return { color: 'var(--text-faint)', text: '检测中', label: '正在检测服务端', word: '检测中' }
if (stalled > 0) {
return { color: 'var(--st-waiting)', text: '加载中', label: `服务端在线,但仍有 ${stalled} 项未加载完成`, word: '加载中' }
}
if (failing > 0) {
return { color: 'var(--st-waiting)', text: '降级', label: `服务端在线,但有 ${failing} 项加载失败`, word: '降级' }
}
if (latency != null && latency >= SLOW_MS) {
return { color: 'var(--st-waiting)', text: `${latency}ms`, label: `服务端响应慢,延迟 ${latency} 毫秒`, word: '偏慢' }
}
return {
color: 'var(--st-done)',
text: latency != null ? `${latency}ms` : '在线',
label: latency != null ? `服务端在线,延迟 ${latency} 毫秒` : '服务端在线',
word: '在线',
}
}
const useFailingChecks = () => useCatalog((s) => s.checks.filter((c) => c.state === 'error').length)
const NO_CHECKS: CatalogCheck[] = []
/** 请求发出去这么久还没回来,就不算「还在加载」,算卡住了 */
const STALL_MS = 3000
/**
* 卡着没回来的 catalog 请求(后端没断时)。
*
* refresh 按表各自请求、先回先填,每个请求最多等 CATALOG_TIMEOUT_MS。一张表卡住
* (后端连得上、就是不回)只拖它自己:卡着的这段时间遥测点是「加载中」,到点那一项
* 记成出错,遥测点转成「降级」。正常刷新几十毫秒就回,不到 STALL_MS 不算,免得
* 每次刷新遥测点都闪一下琥珀。
*/
function useStalledChecks(): CatalogCheck[] {
const waiting = useCatalog((s) => s.checks.some((c) => c.state === 'pending'))
const down = useCatalog((s) => s.backend === 'down')
const checks = useCatalog((s) => s.checks)
const [stalled, setStalled] = useState(false)
useEffect(() => {
if (!waiting) return
const t = setTimeout(() => setStalled(true), STALL_MS)
return () => { clearTimeout(t); setStalled(false) }
}, [waiting])
// 断开时归离线横幅说,这里不再叠一层
return stalled && waiting && !down ? checks.filter((c) => c.state === 'pending') : NO_CHECKS
}
/** 失败的那一项怎么说:回了错误码写码;等满 CATALOG_TIMEOUT_MS 才断的是超时;其余是够不着 */
function failWord(c: CatalogCheck): string {
if (c.status) return String(c.status)
return c.ms != null && c.ms >= CATALOG_TIMEOUT_MS ? '超时' : '连接失败'
}
/**
* 眼下是空的、却不能当「没有」的那几张表:从没取回来过(catalogListState 不是 ok),
* 请求又卡着(超过 STALL_MS)或者已经失败了(回了错误码、超时)。人眼前是「还没有
* 工作流」和一个「新建」按钮,得当面说一句,而且一直挂着,直到那几张表取回来:
* 卡着的那一项到点超时,提示跟着改口说「没加载成功」,不能悄悄撤掉。
*
* 取回来过的表刷新卡住、失败了都不算:失败不清空,手上的还是真数据,遥测点说一声
* 就够。后端断开归离线横幅说;启动页上清单自己会说(enabled=false)。
*/
function useCatalogWarning(stalled: CatalogCheck[], enabled: boolean) {
// 选成字符串:只在「是哪几张表」变了的时候才换提示
const waiting = useCatalog((s) => stalled
.filter((c) => catalogListState(s, c.key) === 'loading').map((c) => c.key).join(','))
const failed = useCatalog((s) => s.checks
.filter((c) => catalogListState(s, c.key) === 'error').map((c) => c.key).join(','))
useEffect(() => {
if (!enabled || (!waiting && !failed)) return
const { checks } = useCatalog.getState()
const pick = (keys: string) => keys.split(',').filter(Boolean)
.map((k) => checks.find((c) => c.key === k)).filter((c): c is CatalogCheck => !!c)
const w = pick(waiting)
const f = pick(failed)
const text = [
w.length ? `仍有 ${w.length} 项未加载完成(${w.map((c) => c.label).join('、')})` : '',
f.length ? `${w.length ? '' : '有 '}${f.length} 项加载失败(${f.map((c) => `${c.label} ${failWord(c)}`).join('、')})` : '',
].filter(Boolean).join(',')
const detail = [
w.length ? '服务端可以连接,但这些请求已等待数秒仍未返回。' : '',
...f.map((c) => `${c.label}:${c.error ?? failWord(c)}`),
'加载完成前,相关列表和下拉选项可能为空,不代表数据丢失。可在导航底部的连接指示灯中查看各项状态。',
].filter(Boolean).join('\n')
// 键跟着「是哪几张表」走:同一个键的提示只会累计次数,不会换字
const id = toast.warn(`${text}:列表可能不完整,建议加载完成后再新建`, {
sticky: true,
key: `catalog-unknown:${waiting}|${failed}`,
detail,
action: { label: '重试', onClick: () => void useCatalog.getState().refresh() },
})
return () => toast.dismiss(id)
}, [enabled, waiting, failed])
}
/** 连接遥测点:导航最底下,像仪表盘上的指示灯。点开看地址、延迟、最近一次错误 */
function Telemetry({ stalled }: { stalled: CatalogCheck[] }) {
const backend = useCatalog((s) => s.backend)
const latency = useCatalog((s) => s.latencyMs)
const failing = useFailingChecks()
const [open, setOpen] = useState(false)
const btnRef = useRef<HTMLButtonElement>(null)
const tone = backendTone(backend, latency, failing, stalled.length)
return (
<div className="relative">
<button
ref={btnRef}
type="button"
aria-label={`${tone.label}。查看连接详情`}
aria-expanded={open}
aria-haspopup="dialog"
data-telemetry={backend}
data-tone={tone.word}
onClick={() => setOpen((o) => !o)}
className={clsx(
'group relative flex h-6 w-14 items-center justify-center gap-1.5 rounded-md text-2xs outline-none',
'hover:bg-hover focus-visible:ring-2 focus-visible:ring-[var(--accent)]',
backend === 'down' ? 'text-[var(--st-failed)]' : 'text-faint hover:text-dim',
)}
>
<span aria-hidden className="h-1.5 w-1.5 shrink-0 rounded-full" style={{ background: tone.color }} />
<span className="mono tnum">{tone.text}</span>
{!open && <Tip>{tone.label}<span className="text-faint">点击查看详情</span></Tip>}
</button>
{open && (
<TelemetryPanel
stalled={stalled}
anchor={btnRef}
onClose={(refocus) => { setOpen(false); if (refocus) btnRef.current?.focus() }}
/>
)}
</div>
)
}
function TelemetryPanel({ onClose, anchor, stalled }: {
onClose: (refocus: boolean) => void
anchor: RefObject<HTMLButtonElement | null>
stalled: CatalogCheck[]
}) {
const backend = useCatalog((s) => s.backend)
const latency = useCatalog((s) => s.latencyMs)
const lastOkAt = useCatalog((s) => s.lastOkAt)
const backendError = useCatalog((s) => s.backendError)
const retryAt = useCatalog((s) => s.retryAt)
const checks = useCatalog((s) => s.checks)
const [busy, setBusy] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const down = backend === 'down'
const now = useTicker(1000, down)
const tone = backendTone(backend, latency, useFailingChecks(), stalled.length)
// 卡着的里头有没有从没取回来过的:有的话那张表眼下是空的,才要劝一句别急着新建
const unknown = useCatalog((s) => stalled.some((c) => catalogListState(s, c.key) !== 'ok'))
const onCloseRef = useRef(onClose)
onCloseRef.current = onClose
useEffect(() => { ref.current?.focus() }, [])
useEffect(() => {
const onDown = (e: MouseEvent) => {
const t = e.target as Node
if (!ref.current?.contains(t) && !anchor.current?.contains(t)) onCloseRef.current(false)
}
window.addEventListener('mousedown', onDown)
return () => window.removeEventListener('mousedown', onDown)
}, [anchor])
const secs = down && retryAt ? Math.max(0, Math.ceil((retryAt - now) / 1000)) : null
const retry = async () => {
if (busy) return
setBusy(true)
try {
const ok = await useCatalog.getState().checkBackend()
if (ok && !down) await useCatalog.getState().refresh()
} finally {
setBusy(false)
}
}
return (
<div
ref={ref}
role="dialog"
aria-label="服务端连接"
tabIndex={-1}
onKeyDown={(e) => {
if (e.key === 'Escape' && !isComposing(e)) {
e.stopPropagation()
onClose(true)
}
}}
className="fade-up absolute bottom-0 left-full z-40 ml-2.5 w-80 rounded-lg border bg-panel text-xs shadow-elev-3 outline-none"
>
<div className="flex items-center justify-between border-b px-3 py-2">
<span className="font-semibold text-fg">服务端连接</span>
<span className="flex items-center gap-1.5" style={{ color: tone.color }}>
<span aria-hidden className="h-1.5 w-1.5 rounded-full" style={{ background: tone.color }} />
{tone.word}
</span>
</div>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 px-3 py-2.5">
<dt className="text-faint">地址</dt>
<dd className="mono truncate text-dim" title={`${window.location.origin}/api`}>{window.location.origin}/api</dd>
<dt className="text-faint">延迟</dt>
<dd className="mono tnum text-dim">{latency != null ? `${latency} ms` : '—'}</dd>
<dt className="text-faint">最后连通</dt>
<dd className="text-dim" title={lastOkAt ? formatDateTime(lastOkAt) : undefined}>
{lastOkAt ? formatRelative(lastOkAt) : '—'}
</dd>
{secs != null && (
<>
<dt className="text-faint">自动重试</dt>
<dd className="tnum text-dim">{secs} 秒后</dd>
</>
)}
</dl>
{backendError && (
<div className="mx-3 mb-2.5 rounded-md border px-2 py-1.5 text-2xs leading-relaxed"
style={{ borderColor: 'color-mix(in srgb, var(--err) 35%, var(--border))', background: 'var(--st-failed-soft)' }}>
<div className="text-[var(--st-failed)]">最近一次错误</div>
<div className="mono mt-0.5 break-all text-dim">{backendError}</div>
</div>
)}
{stalled.length > 0 && (
<div className="mx-3 mb-2.5 rounded-md border px-2 py-1.5 text-2xs leading-relaxed"
style={{ borderColor: 'color-mix(in srgb, var(--warn) 35%, var(--border))', background: 'var(--st-waiting-soft)' }}>
<div className="text-[var(--st-waiting)]">{stalled.map((c) => c.label).join('、')}尚未返回</div>
<div className="mt-0.5 text-dim">
{unknown
? '加载完成前各页面的列表可能不完整,不代表数据丢失,建议暂缓新建。'
: '页面显示的是上次加载的列表,加载完成后将自动更新。'}
</div>
</div>
)}
{checks.length > 0 && (
<div className="border-t px-3 py-2">
<div className="mb-1 text-2xs text-faint">最近一次全量加载</div>
<CheckList checks={checks} />
</div>
)}
<div className="flex justify-end border-t px-3 py-2">
{/* 忙着时用 aria-disabled 而不是 disabled:按钮一 disabled 焦点就掉到 body,
Esc 到不了浮层,键盘用户关不掉它 */}
<button type="button" className="btn btn-sm aria-disabled:cursor-not-allowed aria-disabled:opacity-45"
aria-disabled={busy || undefined} onClick={() => void retry()}>
{busy ? <Spinner size={12} /> : <RotateCw size={12} aria-hidden />} {down ? '立即重试' : '重新检测'}
</button>
</div>
</div>
)
}
/** catalog.refresh 的逐项结果:每一行都是一个真实请求,✓ 带耗时,✕ 带状态码 */
function CheckList({ checks, lead }: { checks: CatalogCheck[]; lead?: ReactNode }) {
return (
<ul className="flex flex-col">
{lead}
{checks.map((c) => (
<li key={c.key} className="flex h-6 items-center gap-2 text-xs" data-check={c.key} data-state={c.state}>
<CheckGlyph state={c.state} />
<span className={c.state === 'error' ? 'text-fg' : 'text-dim'}>{c.label}</span>
<span className="mono ml-auto tnum text-2xs" style={{ color: c.state === 'error' ? 'var(--st-failed)' : 'var(--text-faint)' }}
title={c.error}>
{/* 勾叉是装饰图形,读屏靠这一个词分辨成败 */}
<span className="sr-only">{c.state === 'pending' ? '等待中' : c.state === 'ok' ? '已加载 ' : '失败 '}</span>
{c.state === 'pending'
? <span aria-hidden>…</span>
: c.state === 'ok' ? `${c.ms ?? '—'} ms` : failWord(c)}
</span>
</li>
))}
</ul>
)
}
/** 启动慢的时候读屏念什么:还在等哪几项、哪几项失败了 */
function bootProgress(checks: CatalogCheck[]): string | null {
const waiting = checks.filter((c) => c.state === 'pending').map((c) => c.label)
const failed = checks.filter((c) => c.state === 'error').map((c) => c.label)
return [waiting.length && `正在等待${waiting.join('、')}`, failed.length && `${failed.join('、')}加载失败`]
.filter(Boolean).join(';') || null
}
function CheckGlyph({ state }: { state: CatalogCheck['state'] }) {
if (state === 'pending') return <span className="flex w-3.5 justify-center text-faint"><Spinner size={11} /></span>
return <StatusBadge status={state === 'ok' ? 'done' : 'failed'} size={13} decorative animate={false} />
}
// -------------------------------------------------------------------------
// 启动页
// -------------------------------------------------------------------------
/** 这么久还没加载完就不再只是「正在连接」:主动探一次、给出重试 */
const SLOW_BOOT_MS = 5000
/**
* 启动页:品牌标 + 启动清单。
*
* 之前只有一个转圈和「正在连接后端…」,后端没起就永远转下去,看不出卡在哪。
* 清单的每一行都是 catalog.refresh 的一个真实请求——✓ 带耗时,✕ 带状态码;
* 从没连上过就停在这里说清原因和怎么办,给重试,也允许先进去(离线横幅会一直
* 挂着),不放人进一屏「还没有工作流」。
*/
function BootScreen({ onEnterOffline }: { onEnterOffline: () => void }) {
const checks = useCatalog((s) => s.checks)
const backend = useCatalog((s) => s.backend)
const latency = useCatalog((s) => s.latencyMs)
const backendError = useCatalog((s) => s.backendError)
const retryAt = useCatalog((s) => s.retryAt)
const loaded = useCatalog((s) => s.loaded)
const [t0] = useState(() => Date.now())
const now = useTicker(250)
const [busy, setBusy] = useState(false)
const elapsed = now - t0
const down = backend === 'down'
const slow = !down && !loaded && elapsed > SLOW_BOOT_MS
// 一闪而过的启动(本机几十毫秒)不画清单,免得整屏跳一下
const reveal = down || elapsed > 300
// 每张表要等满 CATALOG_TIMEOUT_MS 才记成失败,后端卡死(连得上但不回)时启动页得
// 干等 15 秒:到点先主动探一次 /health,它的超时短得多,卡死和断开都能在这里先判出来
useEffect(() => { if (slow) void useCatalog.getState().checkBackend() }, [slow])
const retry = async () => {
setBusy(true)
try {
const ok = await useCatalog.getState().checkBackend()
// 从断开恢复时 catalog 自己会重拉;本来就连着(只是慢)就再拉一遍
if (ok && !down) await useCatalog.getState().refresh()
} finally {
setBusy(false)
}
}
const done = checks.filter((c) => c.state !== 'pending').length
const secs = down && retryAt ? Math.max(0, Math.ceil((retryAt - now) / 1000)) : null
const h = down ? humanizeError(new ApiError(0, backendError || NETWORK_MESSAGE, { kind: 'network' })) : null
const headline = down ? '无法连接服务' : slow ? '比平时慢' : '正在连接服务'
// 读屏只播这一句:阶段变了(连接中 → 比平时慢 → 连不上)、慢的时候哪几项回来了。
// 计时器和倒计时一秒一跳,放进播报区就是一秒念一遍
const spoken = down ? h?.reason : slow ? bootProgress(checks) : null
return (
<div className="flex h-full items-center justify-center overflow-auto bg-bg p-6" data-boot={down ? 'down' : slow ? 'slow' : 'loading'}>
<div className="w-full max-w-[360px]">
<div className="flex items-center gap-3">
<Logo size={34} className="text-accent" title="AgentLab" />
<div>
<div className="text-lg font-semibold leading-tight text-fg">AgentLab</div>
<div className="text-2xs text-faint">受限动态编排</div>
</div>
</div>
<div className={clsx('mt-6 overflow-hidden rounded-lg border bg-panel shadow-elev-1 transition-opacity duration-200', reveal ? 'opacity-100' : 'opacity-0')}>
{/* 进度条只按「已经回来了几个请求」走,不插值、不做假进度 */}
<div aria-hidden className="h-px bg-hairline">
<div
className="h-px origin-left transition-transform duration-200"
style={{
transform: `scaleX(${checks.length ? done / checks.length : 0})`,
background: down ? 'var(--st-failed)' : 'var(--accent)',
}}
/>
</div>
<div className="flex items-center justify-between px-3 pb-1.5 pt-2.5">
<span role="status" className="flex items-center gap-2 text-sm font-medium" style={{ color: down ? 'var(--st-failed)' : slow ? 'var(--st-waiting)' : undefined }}>
{!down && <Spinner size={13} />}
{headline}
{spoken && <span className="sr-only">:{spoken}</span>}
</span>
{!down && <span aria-hidden className="mono text-2xs text-faint tnum">{formatDuration(elapsed)}</span>}
</div>
<div className="px-3 pb-2.5">
<CheckList
checks={checks}
lead={
<li className="flex h-6 items-center gap-2 text-xs" data-check="backend" data-state={backend}>
{backend === 'checking'
? <span className="flex w-3.5 justify-center text-faint"><Spinner size={11} /></span>
: <StatusBadge status={down ? 'failed' : 'done'} size={13} decorative animate={false} />}
<span className={down ? 'text-fg' : 'text-dim'}>服务端</span>
<span className="mono ml-auto text-2xs tnum" style={{ color: down ? 'var(--st-failed)' : 'var(--text-faint)' }}>
{down ? '连接失败' : backend === 'ok' && latency != null ? `${latency} ms` : '/api'}
</span>
</li>
}
/>
</div>
{(down || slow) && (
<div className="border-t px-3 py-3 text-xs leading-relaxed">
{down ? (
<>
<div className="text-fg">{h?.reason ?? backendError ?? '请求未到达服务端'}</div>
<div className="mt-1 text-dim">
请确认服务端已启动;如问题持续,请联系管理员。
</div>
<div className="mono mt-1 break-all text-2xs text-faint">
{window.location.origin}/api · 开发环境:./scripts/dev.sh
{backendError && backendError !== NETWORK_MESSAGE && <> · {backendError}</>}
</div>
</>
) : (
<>
<div className="text-dim">
请求已发出 {Math.floor(elapsed / 1000)} 秒,仍有部分未返回:服务端可能正在启动,或某个请求处理缓慢。
</div>
{/* 后端连得上时「先进去」也还是一条路:一个请求卡死就把人永远关在门外更糟。
但进去之前先说清楚代价,进去以后遥测点和提示会接着说 */}
<div className="mt-1 text-faint">直接进入时,未返回的数据在页面上将显示为空,不代表没有数据。</div>
</>
)}
<div className="mt-3 flex items-center gap-2">
<button type="button" className="btn btn-sm btn-primary" disabled={busy} onClick={() => void retry()}>
{busy ? <Spinner size={12} /> : <RotateCw size={12} aria-hidden />} 立即重试
</button>
<button type="button" className="btn btn-sm btn-ghost" onClick={onEnterOffline}>直接进入</button>
{secs != null && !busy && <span className="ml-auto text-2xs text-faint tnum">{secs} 秒后自动重试</span>}
</div>
</div>
)}
</div>
</div>
</div>
)
}
// -------------------------------------------------------------------------
// 后台信号:标题、favicon、系统通知
// -------------------------------------------------------------------------
const PAGE_LABEL: Record<string, string> = Object.fromEntries(PAGES.map((p) => [p.to.slice(1), p.label]))
function describePath(
pathname: string, search: string, ctx: { workflowName: string | null; conversationTitle: string | null },
): [string, string | null] {
const [seg, id] = pathname.split('/').filter(Boolean)
if (!seg) return ['问数据', null]
const page = PAGE_LABEL[seg]
if (!page) return ['页面不存在', null]
if (seg === 'studio' && id) return [page, ctx.workflowName]
if (seg === 'chat' && id) return [page, ctx.conversationTitle || null]
if (seg === 'runs') {
if (id === 'compare') return [page, RUN_COMPARE_TEXT.title]
if (id) return [page, `运行 ${shortId(id)}`]
return [page, new URLSearchParams(search).get('tab') === 'approvals' ? '待审批' : null]
}
return [page, null]
}
type Go = (to: string | null) => void
/** 在后台时结束的事:挂到标题和 favicon 上,开了提醒就再发一条系统通知 */
function report(go: Go, n: { attention: Attention; title: string; body?: string; to: string | null; tag: string }) {
if (!document.hidden) return
flagAttention(n.attention)
notifyInBackground({ title: n.title, body: n.body, tag: n.tag, onClick: () => go(n.to) })
}
/** 回放、打开历史运行时事件是一口气灌进来的:活跃不到这么久就结束的,不算"刚跑完" */
const MIN_LIVE_MS = 1000
function watchStudio(go: Go): () => void {
let watch: { runId: string; since: number } | null = null
return useStudio.subscribe((s, p) => {
if (s.runPhase === p.runPhase && s.run?.id === p.run?.id) return
const id = s.run?.id ?? null
if (!id) { watch = null; return }
if (isActivePhase(s.runPhase)) {
if (watch?.runId !== id) watch = { runId: id, since: Date.now() }
return
}
const w = watch
watch = null
if (!w || w.runId !== id || s.trace?.lastReplay || Date.now() - w.since < MIN_LIVE_MS) return
const name = s.workflow?.name || (s.run?.workflow_name && runName(s.run)) || '工作流'
// 人多半还停在这张画布上:点通知回来就行,不跳走
const here = s.workflow && window.location.pathname.startsWith(`/studio/${s.workflow.id}`)
const to = here ? null : `/runs/${id}`
const tag = `run:${id}`
if (s.runPhase === 'succeeded') {
const t = s.trace
const wall = t.timing?.wallMs ?? (t.startedAt && t.endedAt ? t.endedAt - t.startedAt : null)
report(go, {
attention: { tone: 'ok', text: '✓ 运行完成' }, title: `「${name}」运行完成`,
body: wall != null ? `总时长 ${formatDuration(wall)}` : undefined, to, tag,
})
} else if (s.runPhase === 'failed') {
const nodeId = s.trace.failedNodeId
const label = nodeId ? s.nodes.find((n) => n.id === nodeId)?.data?.label || nodeId : null
const error = (nodeId && s.trace.nodes[nodeId]?.error) || s.run?.error || ''
report(go, {
attention: { tone: 'err', text: '✕ 运行失败' }, title: `「${name}」运行失败`,
body: [label && `停在「${label}」`, error.split('\n')[0]].filter(Boolean).join(':') || undefined, to, tag,
})
} else if (s.runPhase === 'suspended') {
report(go, {
attention: { tone: 'warn', text: '‖ 运行中断' }, title: `「${name}」运行已中断`,
body: '服务重启中断了本次运行,可从断点继续运行', to, tag,
})
}
})
}
function watchChat(go: Go): () => void {
const seen = new Map<string, number | null>()
return useChat.subscribe((s, p) => {
if (s.byConversation === p.byConversation) return
for (const [cid, turns] of Object.entries(s.byConversation)) {
for (const t of turns) {
const active = t.phase === 'planning' || t.phase === 'building' || t.phase === 'running'
const since = seen.get(t.id) ?? null
if (active) {
if (since == null) seen.set(t.id, Date.now())
continue
}
if (since == null) continue
seen.set(t.id, null)
if (t.restored || Date.now() - since < MIN_LIVE_MS) continue
const to = window.location.pathname === `/chat/${cid}` ? null : `/chat/${cid}`
const question = t.question.length > 60 ? `${t.question.slice(0, 60)}…` : t.question
if (t.phase === 'done') {
report(go, { attention: { tone: 'ok', text: '✓ 已回答' }, title: '问数据:回答已生成', body: question, to, tag: `turn:${t.id}` })
} else if (t.phase === 'error') {
report(go, { attention: { tone: 'err', text: '✕ 回答失败' }, title: '问数据:本次提问失败', body: question, to, tag: `turn:${t.id}` })
}
}
}
})
}
function watchApprovals(go: Go): () => void {
const pendingIds = (list: { id: string; status: string }[]) => list.filter((a) => a.status === 'pending').map((a) => a.id)
// 已有的那些不算「新来的」:拿到过一次真列表才记底。启动时那一拉若失败了,
// 列表是兜底的空数组,拿它记底,下一拍心跳就会把老的全当成新的
const listed = (s: ReturnType<typeof useCatalog.getState>) =>
s.loaded && s.checks.find((c) => c.key === 'approvals')?.state === 'ok'
let seen: Set<string> | null = listed(useCatalog.getState()) ? new Set(pendingIds(useCatalog.getState().approvals)) : null
return useCatalog.subscribe((s, p) => {
if (s.approvals === p.approvals) return
if (!seen) {
if (listed(s) || (s.loaded && s.backend === 'ok')) seen = new Set(pendingIds(s.approvals))
return
}
const fresh = s.approvals.filter((a) => a.status === 'pending' && !seen!.has(a.id))
fresh.forEach((a) => seen!.add(a.id))
for (const a of fresh) {
notifyInBackground({
title: `新的待审批:${a.title || '有步骤等待处理'}`,
body: [a.workflow_name && runName(a), a.node_label].filter(Boolean).join(' · ') || undefined,
tag: `approval:${a.id}`,
onClick: () => go(`/runs/${a.run_id}`),
})
}
})
}
function ShellSignals() {
const { pathname, search } = useLocation()
const navigate = useNavigate()
const pending = useCatalog((s) => s.approvals.filter((a) => a.status === 'pending').length)
const studio = useStudioActivity()
const chat = useChatActivity()
const attention = useSignals((s) => s.attention)
const pageTitle = useSignals((s) => s.pageTitle)
const workflowName = useStudio((s) => s.workflow?.name ?? null)
const conversationTitle = useConversations((s) => s.list.find((c) => c.id === s.currentId)?.title ?? null)
const [page, object] = describePath(pathname, search, { workflowName, conversationTitle })
const progress = studio.startsWith('run:') ? ` ${studio.slice(4)}` : ''
const activity = studio.startsWith('run') || chat === 'run'
? `● 运行中${progress}`
: studio === 'gen' || chat === 'gen' ? '● 生成中' : null
useEffect(() => {
document.title = composeTitle({
pending, activity, attention, page: pageTitle ?? page, object: pageTitle ? null : object,
})
})
// 点的优先级:失败 > 等人 > 在跑 > 后台时跑完了
const dot: FaviconDot | null = attention?.tone === 'err'
? 'failed'
: pending > 0 || attention?.tone === 'warn'
? 'waiting'
: activity ? 'running' : attention?.tone === 'ok' ? 'done' : null
useEffect(() => { paintFavicon(dot) }, [dot])
const navRef = useRef(navigate)
navRef.current = navigate
useEffect(() => {
const go: Go = (to) => { if (to) navRef.current(to) }
const stops = [watchStudio(go), watchChat(go), watchApprovals(go)]
return () => stops.forEach((stop) => stop())
}, [])
return null
}