Files
JX-BPOS/apps/web/src/features/gov-testbed/internal/activity-run.tsx
T
ChaiHaoandClaude Opus 5 259192f736 人手改的那一手真正落得下去;界面不撒谎那几处补齐
手改是**兜 AI 的错**的那一手 —— 这一轮把它从「摆在那儿但落不了地」做成真的:

件上就地改
· 保存**按地址收**,不看当下那张卡:载荷从前借道工单的 `blocks[i]` 取列名,
  于是熔断卡(没有 blocks)在屏上时,人补的那一格一个字都送不出去 ——
  而他要补的恰恰是把这一单卡住的那个必填格。授权面改由活动清单的台账声明把关。
· **能增删行**:错不止「这一格填错了」,还有「这一行不该在」与「漏了一行」。
  删挂记号不当场抹掉(看得见、可后悔),且**删要说出来**(`drops`)——
  「没回传就是删了」会把一次不完整的回传变成一次删表。
· **必填没填按不下提交**:与放行门 L1 同一个数同一套算法;闸上摆出差在哪一行哪一格。
· 只写真改了的那几行 —— 改一格却留「修订 4 行」的痕,那句话是假的。

判据与门
· 裁量判据带谓词时**谓词说了算**:「选填缺口已补齐」基座数得出来,
  人填好的目标价它照样判「客户未提供」的毛病由此消掉。
· 验收卡判据**读时现算** —— 人在成果件上补一格,判据跟着变。
· 放行门认「**办完了没有**」不认「有人写过」:从前写个 `false` 就把门推开,
  于是一单没打标就进了放行门。三个数全由基座现数。
· 拒回把「活没干完」说成「数或行对不上」的毛病:判据点名的表一行都没写时直说那一步没办。
  **指向两扇打不开的门,比不指还糟。**

界面不撒谎
· 日志只有三色:负责人绿 · 智能体蓝 · 系统灰(归侧五种是度量的分法,不是给人看的)。
· 验收收口答两件事:落了什么(按**交付物**点名,一份成果的几张表并成一行)、
  往哪儿去(转出哪一口、带过去哪些字段);一个口都没发就直说到此为止。
· 签完到收尾之间那一段有自己的话「字已签下,正在落地」—— 中间态本来就该说出来,只是要说对。
· 消息不再被内部事件刷屏:只有从通道进来的件才谈得上「无人认领」。
· 建客户公司与联系人**一次确认落两行**;联系人编码自增不问人;卡面摆表的人读名。

A1 三案例端到端全 released:全 A 类直通 12 拍 / 客户点名跳一条 10 拍 / 新客户缺必填 14 拍。
五道门禁全绿(971 passed)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JsTDjoHovERXyy6Wmw1ALp
2026-08-02 13:41:44 +08:00

654 lines
26 KiB
TypeScript

/**
* 试跑**一个活动** —— 测试台的第二种试法,与试跑流程包并列。
*
* 为什么要单有一种:开发一个活动时,关心的是「这个活动自己跑不跑得动」——
* 案例模型推不推得开、判据取不取得齐、台账写不写得对、验收卡长什么样。
* 走流程包试,前面几个节点的噪音得先趟一遍,而且每改一次活动就要重跑整条流。
*
* 起出来的是**真任务**:照旧过装载两审、Harness 预算、案例模型推进、放行门 ——
* 试跑不等于假跑,假跑试不出任何东西。区别只有一个 `origin='testbed'`,好随时连根拔掉。
*
* 视觉沿用同一台仪器的语言:**机器说 mono**(读数、灯、编号),
* **外界伸进来的东西说无衬线**(起单入参就是外界给的那份料,所以它也是一张纸)。
*/
import { useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { TERMINAL_STATE_LABEL, testActivityStateRoute, testRunActivityRoute, testStopRoute, uploadFileRoute } from '@contracts';
import type { UploadedFile } from '@contracts';
import { Ico } from '@web-shared/Ico.tsx';
import { call } from '@web-shared/typed-fetch.ts';
import { useGovActivities, useGovStaff } from '@web-shared/gov/gov-api.ts';
import { QueryGate } from '@web-shared/gov/primitives.tsx';
import type { Nav } from '@web-shared/gov/gov-route.ts';
import { DraftGroups } from './drafts.tsx';
type Parsed<S> = S extends { parse: (data: unknown) => infer O } ? O : never;
export type ActivityState = Parsed<typeof testActivityStateRoute.output>;
type ActivityCard = NonNullable<ReturnType<typeof useGovActivities>['data']>['activities'][number];
/* ==================== 装配态 ==================== */
export function ActivityRun({
taskId,
onStarted,
onStopped,
strip,
nav,
}: {
/** 已在跑的那一单(刷新页面也认得回来) */
taskId?: string;
onStarted: (id: string) => void;
onStopped: () => void;
strip: (input: { lamp: 'idle' | 'live' | 'wait' | 'done' | 'bad'; breathe: boolean; word: string; detail?: ReactNode; right?: ReactNode }) => ReactNode;
nav: Nav;
}): ReactNode {
const actQ = useGovActivities();
const staffQ = useGovStaff();
const staff = useMemo(() => (staffQ.data?.staff ?? []).filter((s) => s.state === 'active'), [staffQ.data]);
const activities = useMemo(() => (actQ.data?.activities ?? []).filter((a) => a.enabled), [actQ.data]);
const [pickedId, setPicked] = useState('');
const picked = activities.find((a) => a.activityId === pickedId) ?? activities[0];
const [data, setData] = useState<Readonly<Record<string, string>>>({});
const [ownerId, setOwner] = useState('');
const [error, setError] = useState<string | undefined>(undefined);
const queryClient = useQueryClient();
/*
* 换活动就换一套入参 —— 上一个活动填的值留在表单里毫无意义(字段名都不一样),
* 而且极易让人以为「我填过了」。有样例就照样例预填:包自带的样例本来就是
* 「这个活动一单长什么样」的权威说法,让人对着空表单编,编出来的多半跑不通。
*/
useEffect(() => {
if (picked === undefined) return;
setData(sampleValues(picked, 0));
}, [picked?.activityId]);
const start = useMutation({
mutationFn: (input: { activityId: string; data: Record<string, unknown>; ownerId: string }) =>
call<{ taskId: string; title: string }>(testRunActivityRoute, { body: input }),
onSuccess: (r) => {
setError(undefined);
onStarted(r.taskId);
void queryClient.invalidateQueries({ queryKey: ['gov', 'testbed'] });
},
// 后端的判词照抄:「活动未装」「入参不合契约」「模型未绑」都是要人改配置的话,不该被改写
onError: (e: unknown) => setError((e as { message?: string }).message ?? '起单失败'),
});
const stop = useMutation({
mutationFn: (id: string) => call<{ destroyed: boolean; tasks: number }>(testStopRoute, { body: { taskId: id } }),
onSuccess: () => {
onStopped();
start.reset();
void queryClient.invalidateQueries({ queryKey: ['gov', 'testbed'] });
void queryClient.invalidateQueries({ queryKey: ['desk'] });
},
});
if (taskId !== undefined) {
return <ActivityObserve taskId={taskId} strip={strip} stopping={stop.isPending} onStop={() => stop.mutate(taskId)} />;
}
const fields = orderFields(picked?.contract.inputFields ?? [], picked?.contract.requiredInputs ?? []);
// 必填缺一即不可起 —— 门口这道校验与后端同一口径(契约的 required)
const missing = (picked?.contract.inputFields ?? [])
.filter((f) => f.required && (data[f.name] ?? '').trim() === '')
.map((f) => f.label ?? f.name);
const fireable = picked !== undefined && missing.length === 0;
const ai = staff.find((s) => s.name === 'AI');
/*
* 起单前**按契约声明归型**:表单格子里全是文本,而契约说得清哪个字段是数组、哪个是对象
* (`attachments: string[]`)。不归型就把 `["BOM.xlsx"]` 这串字面量当字符串送进去,
* 活动拿到的类型与自己声明的对不上 —— 这类错一路要到 Agent 读入参时才看得出来。
* 归不了型当场说,并且**照厂商原话说**:说「不是合法 JSON」,不含糊成「起单失败」。
*/
const fire = (card: ActivityCard): void => {
const typeOf = new Map(card.contract.inputFields.map((f) => [f.name, f.type]));
const shaped: Record<string, unknown> = {};
for (const [key, raw] of Object.entries(data)) {
if (raw.trim() === '') continue;
const type = typeOf.get(key) ?? 'string';
if (!type.endsWith('[]') && type !== 'object') {
shaped[key] = raw;
continue;
}
try {
shaped[key] = JSON.parse(raw);
} catch {
setError(`${typeLabel(card, key)}要填 JSON(${type}),这串解析不了:${raw.slice(0, 40)}`);
return;
}
}
setError(undefined);
start.mutate({ activityId: card.activityId, data: shaped, ownerId });
};
return (
<>
{strip({
lamp: 'idle',
breathe: false,
word: '装配中',
detail: picked === undefined ? '活动库里还没有可试的活动' : `${picked.name} · 单活动仿真`,
right: picked === undefined ? undefined : `${picked.activityId}`,
})}
<QueryGate query={actQ}>
{activities.length === 0 ? (
<div className="tb-slot">
<Ico name="scan" s={26} />
<b>活动库里还没有可试的活动</b>
<p>装一个活动包回来,它就出现在这里 —— 试跑的是真任务,得先有个真活动。</p>
<button className="g-btn" onClick={() => nav.jump('library', 'shelf')}>
<Ico name="package" s={12} />
去活动库
</button>
</div>
) : (
<>
{activities.length > 1 && (
<div className="tb-pkrow">
{activities.map((a) => (
<button
key={a.activityId}
className={'g-pk' + (a.activityId === picked?.activityId ? ' on' : '')}
onClick={() => setPicked(a.activityId)}
>
<b>{a.name}</b>
<span className="id">{a.activityId}</span>
</button>
))}
</div>
)}
<div className="g-rig">
{/* ── 左:起单入参 —— 外界给的那份料,所以它也是一张纸 ── */}
<section className="g-rig-l">
<div className="g-rig-h">
<span className="n">01</span>
输入 · 起单入参
<span className="x">
{(picked?.contract.samples ?? []).map((s, i) => (
<button key={s.name} className="tb-ghost" onClick={() => picked && setData(sampleValues(picked, i))}>
<Ico name="rotate-ccw" s={10} />
{s.name.length > 14 ? `样例 ${i + 1}` : s.name}
</button>
))}
</span>
</div>
<div className="tb-paper">
<div className="tb-pp-h">
{fields.map((f) => (
<label key={f.name} className="tb-pp-r">
<em>
{f.label ?? f.name}
{/* 必填只打一枚星号 —— 「必填」二字在窄标签栏里把字段名挤到折行 */}
{f.required && (
<i className="req" aria-label="必填">
*
</i>
)}
</em>
{f.format === 'file' ? (
<FilePicker
label={f.label ?? f.name}
value={data[f.name] ?? ''}
onChange={(next) => setData((prev) => ({ ...prev, [f.name]: next }))}
onError={setError}
/>
) : isLong(f) ? (
<textarea
className="tb-pp-i"
rows={5}
value={data[f.name] ?? ''}
placeholder={f.type}
onChange={(e) => setData((prev) => ({ ...prev, [f.name]: e.target.value }))}
/>
) : (
<input
className="tb-pp-i"
value={data[f.name] ?? ''}
placeholder={f.type}
onChange={(e) => setData((prev) => ({ ...prev, [f.name]: e.target.value }))}
/>
)}
</label>
))}
</div>
</div>
</section>
{/* ── 右:派给谁 + 契约摘要 ── */}
<section className="g-rig-r">
<div className="g-rig-h">
<span className="n">02</span>
派给谁
<span className="x">
{ai !== undefined && (
<button className="tb-ghost" onClick={() => setOwner(ai.id)}>
<Ico name="bot" s={10} />
派给 AI
</button>
)}
</span>
</div>
<div className="tb-asn">
{/* solo:这一台只有一格插座,没有「活动/审批」那一列 —— 少一格,少一列 */}
<div className={'tb-asn-r solo' + (ownerId !== '' ? ' pin' : '')}>
<span className="rail">
<i />
</span>
<span className="nid">OWNER</span>
<span className="lb">任务负责人</span>
<select
className="tb-socket"
aria-label="这一单派给谁"
value={ownerId}
onChange={(e) => setOwner(e.target.value)}
>
<option value="">我自己</option>
{staff.map((s) => (
<option key={s.id} value={s.id}>
{s.name} · {s.position.code}
</option>
))}
</select>
</div>
</div>
<div className="g-rig-h">
<span className="n">03</span>
这个活动手上有什么
</div>
<ContractFace card={picked} />
</section>
<div className="tb-fire">
<span className="say">
{error !== undefined ? (
<b className="bad">{error}</b>
) : missing.length > 0 ? (
`还缺:${missing.join('、')}`
) : (
'起单会先销毁上一单;这是**真任务** —— 照旧过预算、案例模型与放行门,只是可以随时拔掉'
)}
</span>
<button
className={'tb-go' + (fireable ? ' armed' : '')}
disabled={!fireable || start.isPending}
onClick={() => picked && fire(picked)}
>
<Ico name="play" s={13} />
{start.isPending ? '起单中…' : '起单试跑'}
</button>
</div>
</div>
</>
)}
</QueryGate>
</>
);
}
type InputField = {
name: string;
type: string;
required: boolean;
label?: string | undefined;
/** `file` = 这一格收真文件(契约里的 `format`)—— 界面给文件选择器 */
format?: string | undefined;
};
/** 长文本:正文、对象、数组 —— 一行输入框装不下的东西 */
/* ==================== 真文件那一格 ==================== */
/**
* 附件格 —— **选的是真文件**,不是一串文件名。
*
* 选完当场传上去,基座在入口就把它读成文本(xlsx 读成行列),回传的描述件里带着
* `text` 与一句 `note`。这一格摆的就是那句 note:**读出来什么就说什么** ——
* 读不出的类型如实说读不出,不摆一个「已上传 ✓」让人以为活动看得见里面。
*
* 值仍以 JSON 串存在表单状态里(与其它格同一形状),起单时由 `fire` 统一归型。
*/
function FilePicker({
label,
value,
onChange,
onError,
}: {
label: string;
value: string;
onChange: (next: string) => void;
onError: (msg: string | undefined) => void;
}): ReactNode {
const [busy, setBusy] = useState(false);
const files: UploadedFile[] = (() => {
if (value.trim() === '') return [];
try {
const parsed: unknown = JSON.parse(value);
return Array.isArray(parsed) ? (parsed as UploadedFile[]) : [];
} catch {
return [];
}
})();
const take = async (picked: FileList | null): Promise<void> => {
if (picked === null || picked.length === 0) return;
setBusy(true);
onError(undefined);
try {
const added: UploadedFile[] = [];
for (const file of Array.from(picked)) {
const buf = new Uint8Array(await file.arrayBuffer());
let bin = '';
for (const b of buf) bin += String.fromCharCode(b);
added.push(
await call<UploadedFile>(uploadFileRoute, {
body: { name: file.name, contentType: file.type, dataBase64: btoa(bin) },
}),
);
}
onChange(JSON.stringify([...files, ...added]));
} catch (e: unknown) {
// 后端的判词照抄(「超过 8MB」「解出来是 0 字节」都是要人自己处置的话)
onError((e as { message?: string }).message ?? '上传失败');
} finally {
setBusy(false);
}
};
return (
<span className="tb-files">
{files.map((f, i) => (
<span key={f.ref} className={'tb-file' + (f.text === undefined ? ' blind' : '')}>
<Ico name={f.text === undefined ? 'alert-triangle' : 'file-text'} s={11} />
<b>{f.name}</b>
<i>{(f.size / 1024).toFixed(0)} KB · {f.note}</i>
<button
aria-label={`移除 ${f.name}`}
onClick={() => onChange(JSON.stringify(files.filter((_, j) => j !== i)))}
>
<Ico name="x" s={10} />
</button>
</span>
))}
<label className="tb-file-add">
<Ico name="paperclip" s={11} />
{busy ? '读取中…' : `选文件${files.length === 0 ? '' : '(再加)'}`}
<input
type="file"
multiple
aria-label={`选择${label}`}
disabled={busy}
onChange={(e) => {
void take(e.target.files);
e.target.value = '';
}}
/>
</label>
</span>
);
}
/** 报错要说人话:契约里写了中文名就用中文名 */
function typeLabel(card: ActivityCard, name: string): string {
const f = card.contract.inputFields.find((x) => x.name === name);
return f?.label ?? name;
}
/**
* 哪些格子要摊成大方框:正文、对象、对象数组。
* **一串字符串不算**(`string[]`,比如一行附件名)—— 给它五行高的框,
* 标签会孤零零吊在一片空白左边,而里面只有一行 `["BOM.xlsx"]`。
*/
function isLong(f: InputField): boolean {
return f.name === 'body' || f.type === 'object' || f.type === 'object[]';
}
/**
* 表单字段的顺序**不靠契约里的键序** —— 那个顺序到不了这里。
*
* 清单存进库是 jsonb,PG 读回时会把对象键重排(先短后长、再字典序):
* 一份写作「客户邮箱 / 主题 / 邮件内容 / 附件」的契约,读回来变成
* 「主题 / 客户邮箱 / 附件 / 邮件内容」—— 键序在这一层已经不可信。
*
* 唯一活着穿过 jsonb 的顺序是 **`required` 那个数组**(数组有序,对象无序)。
* 所以:必填照它排 —— 契约作者写下的次序就是他要人填的次序;选填跟在后面,长正文垫底。
*/
function orderFields(fields: readonly InputField[], requiredOrder: readonly string[]): InputField[] {
const rank = (f: InputField): number => {
const i = requiredOrder.indexOf(f.name);
return i === -1 ? requiredOrder.length : i;
};
return [...fields].sort((a, b) => {
if (a.required !== b.required) return a.required ? -1 : 1;
if (a.required) return rank(a) - rank(b);
if (isLong(a) !== isLong(b)) return isLong(a) ? 1 : -1;
return 0;
});
}
/** 样例 → 表单值:一律转成字符串(表单是文本,归型交给活动契约那一侧) */
function sampleValues(card: ActivityCard, index: number): Record<string, string> {
const sample = card.contract.samples[index];
if (sample === undefined) return {};
return Object.fromEntries(
Object.entries(sample.input).map(([k, v]) => [k, typeof v === 'object' && v !== null ? JSON.stringify(v) : String(v ?? '')]),
);
}
/**
* 契约面读数 —— **起单前就该看见**这个活动手上有什么。
* 跑不动的原因八成在这几行里:工具没装、模型没绑、台账对象没物化、预算给太紧。
*/
function ContractFace({ card }: { card: ActivityCard | undefined }): ReactNode {
if (card === undefined) return null;
const c = card.contract;
return (
<div className="tb-face">
<Row label="工具" values={c.tools} empty="不调工具" />
<Row label="模型" values={c.models} empty="不问模型" />
<Row label="台账" values={c.objects} empty="不写台账" />
<Row label="出口" values={c.outputPorts.map((p) => p.name)} empty="无出口" />
<div className="tb-face-r">
<em>预算</em>
<span className="v mono">调人 ≤ {c.humanCallBudget}</span>
</div>
</div>
);
}
function Row({ label, values, empty }: { label: string; values: readonly string[]; empty: string }): ReactNode {
return (
<div className="tb-face-r">
<em>{label}</em>
{values.length === 0 ? <span className="v none">{empty}</span> : (
<span className="v">
{values.map((v) => (
<i key={v} className="chip">
{v}
</i>
))}
</span>
)}
</div>
);
}
/* ==================== 观测态 ==================== */
const RUNTIME_LAMP: Readonly<Record<string, 'live' | 'wait' | 'done' | 'bad'>> = {
running: 'live',
'awaiting-human': 'wait',
'awaiting-event': 'wait',
'landing-blocked': 'bad',
terminal: 'done',
};
/**
* 单活动试跑的观测面 —— 一屏看穿这一单。
*
* 流程包那侧看 trace 树(节点 → 步骤);活动这侧要看的是**活动内部**:
* 每一拍干了什么、拿回了什么、被拒的理由、成果落到台账哪一行、卡问了谁什么。
* 拿 trace 树看不出「第三拍为什么被拒」—— 那句话在任务轨迹里。
*/
function ActivityObserve({
taskId,
strip,
stopping,
onStop,
}: {
taskId: string;
strip: (input: { lamp: 'idle' | 'live' | 'wait' | 'done' | 'bad'; breathe: boolean; word: string; detail?: ReactNode; right?: ReactNode }) => ReactNode;
stopping: boolean;
onStop: () => void;
}): ReactNode {
const q = useQuery({
queryKey: ['gov', 'testbed', 'activity', taskId],
queryFn: () => call<ActivityState>(testActivityStateRoute, { params: { taskId } }),
/*
* 轮询是**兜底**,不是主路:每一拍推进后端都会广播,订阅层按 `gov.testbed` 前缀
* 失效这把查询(见 `app/subscribe.ts`)—— 画面跟着推送走,比 2 秒快得多。
* 轮询留着是为了推送漏掉的那种情形(断线、别的进程推进的单)。
* 到终态就停:已经不动的东西没必要一直问。
*/
refetchInterval: (query) => (query.state.data?.terminal === undefined ? 2000 : false),
});
const s = q.data;
const live = s !== undefined && s.terminal === undefined;
return (
<>
{strip({
lamp: s === undefined ? 'live' : (RUNTIME_LAMP[s.runtime] ?? 'live'),
breathe: live,
word: s?.terminal === undefined ? (s?.runtime ?? '取数中') : (TERMINAL_STATE_LABEL[s.terminal] ?? s.terminal),
detail: (
<>
{s?.title ?? taskId.slice(0, 8)}
{live && <i className="auto"> · 自动刷新 2s</i>}
</>
),
right: (
/*
* 一个钮两种说法 —— **在跑时是「停」,跑完了是「清场」**:
* 跑完之后已经没有什么可停的,还写「停止仿真」会让人以为不点它这一单就还占着;
* 而它真正的作用是把这一单连根拔掉、腾出仪器再来一单。
*/
<button className="tb-stop" disabled={stopping} onClick={onStop}>
<Ico name={live ? 'square' : 'rotate-ccw'} s={11} />
{stopping ? '销毁中…' : live ? '停止仿真' : '清场 · 再来一单'}
</button>
),
})}
<QueryGate query={q}>
{s !== undefined && (
<>
{/* 读数屏:预算三格 —— Harness 在环外掐的就是这三个数 */}
<div className="tb-gauges">
<Gauge label="步数" used={s.used.steps} cap={s.caps.steps} />
<Gauge label="token" used={s.used.tokens} cap={s.caps.tokens} />
<Gauge label="调人" used={s.used.humanCalls} cap={s.caps.humanCalls} />
</div>
{s.reason !== undefined && <p className="tb-reason">{s.reason}</p>}
<div className="g-rig">
<section className="g-rig-l">
<div className="g-rig-h">
<Ico name="list" s={12} />
逐拍轨迹
<span className="x">{s.trail.length} 条</span>
</div>
<ol className="tb-trail">
{s.trail.map((t, i) => (
<li key={`${t.at}-${i}`} className={'k-' + t.kind}>
<span className="t">{new Date(t.at).toLocaleTimeString('zh-CN', { hour12: false })}</span>
<span className="b">
{t.title}
{detailLine(t.detail) !== '' && <i className="d">{detailLine(t.detail)}</i>}
</span>
</li>
))}
</ol>
</section>
<section className="g-rig-r">
<div className="g-rig-h">
<Ico name="database" s={12} />
台账草稿 · 成果就是这些行
<span className="x">{s.drafts.length} 行</span>
</div>
{s.drafts.length === 0 ? (
<p className="tb-none">还没有写下任何一行 —— 活动的成果就是台账里的行,一行没有就是还没交东西。</p>
) : (
<div className="tb-drafts">
<DraftGroups drafts={s.drafts} />
</div>
)}
<div className="g-rig-h">
<Ico name="inbox" s={12} />
卡 · 问了谁什么
<span className="x">{s.cards.length} 张</span>
</div>
{s.cards.length === 0 ? (
<p className="tb-none">还没有发过卡。</p>
) : (
<div className="tb-cards">
{s.cards.map((card) => (
<div key={card.id} className={'row' + (card.done ? ' done' : '')}>
<span className="m">{card.mode === 'acceptance' ? '验收' : '执行'}</span>
<span className="t">{card.title}</span>
<span className="s">{card.done ? (card.result ?? '已答') : '待答'}</span>
</div>
))}
</div>
)}
</section>
</div>
</>
)}
</QueryGate>
</>
);
}
function Gauge({ label, used, cap }: { label: string; used: number; cap: number }): ReactNode {
const pct = cap === 0 ? 0 : Math.min(100, Math.round((used / cap) * 100));
return (
<div className={'tb-g' + (pct >= 90 ? ' hot' : pct >= 70 ? ' warm' : '')}>
<em>{label}</em>
<b>
{used.toLocaleString('zh-CN')}
<i> / {cap.toLocaleString('zh-CN')}</i>
</b>
<span className="bar">
<i style={{ width: `${pct}%` }} />
</span>
</div>
);
}
/** 轨迹行的附注:回填值 / 工具出参 / 错误 —— 与喂给 Agent 的那一份同源 */
function detailLine(detail: Record<string, unknown> | undefined): string {
if (detail === undefined) return '';
const parts: string[] = [];
const values = detail['values'];
if (typeof values === 'object' && values !== null) parts.push(`回填 ${JSON.stringify(values)}`);
const output = detail['output'];
if (output !== undefined && output !== null) parts.push(`出参 ${JSON.stringify(output)}`);
const error = detail['error'];
if (typeof error === 'string' && error !== '') parts.push(error);
const text = parts.join(' · ');
return text.length > 180 ? `${text.slice(0, 180)}…` : text;
}