Archived
人手改的那一手真正落得下去;界面不撒谎那几处补齐
手改是**兜 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
This commit is contained in:
@@ -0,0 +1,653 @@
|
||||
/**
|
||||
* 试跑**一个活动** —— 测试台的第二种试法,与试跑流程包并列。
|
||||
*
|
||||
* 为什么要单有一种:开发一个活动时,关心的是「这个活动自己跑不跑得动」——
|
||||
* 案例模型推不推得开、判据取不取得齐、台账写不写得对、验收卡长什么样。
|
||||
* 走流程包试,前面几个节点的噪音得先趟一遍,而且每改一次活动就要重跑整条流。
|
||||
*
|
||||
* 起出来的是**真任务**:照旧过装载两审、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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user