This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-MVP/apps/web/src/features/gov-memory/internal/panel.tsx
T
ChaiHaoandClaude Opus 5 72f90f293b 测试台改仪表台:启动输入换成一封信;卡片化触发器与大模型;清掉 ⓘ 说教条
**测试台**
  · 启动输入不再是「调用参数」表 —— 这条流真实的起法就是客户来信,
    界面上就该是一封信(发件人 / 收件人 / 主题 / 正文 / BOM 附件)。
    服务端拿**真通道的抽键正则** + **真触发器的装配映射**推出调用入参:
    测试台自己不解析邮件,否则试通的是测试台而不是这条流。
    抽不出必填就如实报「哪个字段没命中、通道的正则要什么」,不静默起空单。
  · 一屏两大块:左选流,右两节 —— 01 输入配置、02 节点指派。
  · 起单钮只剩一枚(原来页头一枚、RUN 卡里一枚)。
  · 改暗色仪表台:调色板集中在 .g-tb/.g-rig 的一组变量上,
    TRACE 那张树 + 甘特的结构一行没动,只是纸变成了面板。

**触发器 / 大模型** 列表改卡片:一条规则、一个实例都要一眼看全
(何时起 · 起哪条流 · 开没开 / 谁家的 · 什么型号 · 密钥绑没绑)。
触发器卡是 div 不是 button —— 卡里还坐着「改规则」那枚真按钮。

**同类卡等高**:底栏钉在卡底、说明恒占两行、卡头一行截断。
实测同一货架内 13 张工具卡、6 张活动卡、3 张模型卡各自完全等高。

**删掉 19 条 ⓘ 说教横幅**(「只列有生效版的包…」那一类)。
留下的是三种:确认带里的影响说明(点了动作才出现,是决策依据)、
warn/ok 的真反馈、以及「X 不存在」这类空态。

删掉入站邮件演练整屏 —— 它验的那两步已经并进测试台的起单路径了。

新增一条接缝测试:通道抽出来的键叫 keys.customerCode,触发器映射也得照这名字取;
哪头改了名字单还是能起,只是入参悄悄变空、流走到 G1 才莫名其妙地停。

四闸绿;契约快照随本提交入库。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
2026-07-29 10:42:01 +08:00

241 lines
9.4 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.
/**
* feature `gov-memory` —— 运营中心的两处:**经营驾驶舱**(度量八指标)与**知识库**(活动记忆四层)。
*
* 自 `~/coding/JCAI_PRD/governance/_components/Governance.tsx` 平移:
* DOM 结构、类名(`g-*` + 色调 `t-*`)与文案原样保留,mock 常量换成服务端投影(README 平移规则 1/2/4)。
*
* 原本这两件同挂在「记忆管理」一区,是两种心智硬挤在一起:
* 看指标问的是「今天跑得怎样」,管沉淀问的是「学到了什么、放不放行」。三面拆分后各自成页。
*/
import { useState } from 'react';
import { TERMINAL_STATE_LABEL } from '@contracts';
import type { TerminalState } from '@contracts';
import { Ico } from '@web-shared/Ico.tsx';
import { Chip, Clear, Kpi, NList, NRow, Note, PageHead, QueryGate, Todo } from '@web-shared/gov/primitives.tsx';
import type { Nav } from '@web-shared/gov/gov-route.ts';
import {
useGovActivities,
useGovMemory,
useGovMetrics,
useGovTasks,
} from '@web-shared/gov/gov-api.ts';
export type HomeProps = { tab: string; nav: Nav };
const pct = (v: number | undefined) => Math.round((v ?? 0) * 100);
/** 终态五名的呈现色 —— 放行绿、驳回琥珀、停滞灰、耗尽红、终止墨,与状态章同族 */
const TERMINAL_TONE: Readonly<Record<string, string>> = {
released: 'var(--ok-accent)',
blocked: 'var(--warn-accent)',
stalled: '#8f959e',
exhausted: 'var(--danger-accent)',
terminated: '#41464e',
};
/* ---------------- 经营驾驶舱(运营首页) ---------------- */
/**
* 看企业今天跑得怎样 —— 先看**要不要管**(异常三卡),再看**跑得好不好**(度量八指标)。
* 异常在上:开屏第一眼该回答的是「有没有事要我处置」,不是「自主率几个点」。
*/
export function CockpitHome({ nav }: HomeProps) {
const metrics = useGovMetrics();
const tasks = useGovTasks();
const m = metrics.data;
const workflows = tasks.data?.workflows ?? [];
const taskRows = tasks.data?.tasks ?? [];
const abnormal = workflows.filter((w) => w.state === 'abnormal').length;
const running = taskRows.filter((t) => t.terminal === undefined).length;
const mix = m?.terminalMix ?? {};
const mixTop = Object.entries(mix)
.filter(([, v]) => v > 0)
.sort((a, b) => b[1] - a[1]);
return (
<QueryGate query={metrics}>
<>
<div className="g-kpis">
<Kpi
ico="alert-triangle"
tone="danger"
label="异常工作流"
value={abnormal}
sub="失败走向未消化,待人工处置"
/>
{/*
* 「未闭合草稿」这张卡撤了 —— 未闭合是**定义面**的常态(画图不检查,激活才查),
* 归流程包详情的异常看板管,不是运营指标。
* 天然的第三张是「死路」(条件出边一条都判不中而卡住的流,已有 route-dead-end 事件),
* 但那需要一条按事件类型聚合的查询路由 —— 没有之前不摆(同死信卡的规矩)。
*/}
<Kpi ico="activity" tone="brand" label="在途任务" value={running} sub="尚未落终态的任务" />
</div>
{abnormal > 0 ? (
<div className="g-todo">
<Todo
tone="danger"
ico="alert-triangle"
title={`${abnormal} 条工作流异常`}
desc="失败走向未消化 —— 去需处置逐条看它卡在哪个节点、要不要改派或叫停。"
acts={[{ label: '去处置', ico: 'flag', onClick: () => nav.jump('tasks', 'todo') }]}
/>
</div>
) : (
<Clear>没有异常工作流 —— 在途的都还在正常走。</Clear>
)}
<div className="g-kpis">
<Kpi
ico="bot"
tone="auto"
label="自主率"
value={pct(m?.autonomy)}
unit="%"
dial={pct(m?.autonomy)}
sub="机器产出 ÷(机器 + 人)"
/>
<Kpi
ico="rotate-ccw"
tone="warn"
label="纠正率"
value={pct(m?.correction)}
unit="%"
dial={pct(m?.correction)}
sub="被纠正批次 ÷ 提交批次"
/>
<Kpi
ico="gauge"
tone="brand"
label="有效自主率"
value={pct(m?.effectiveAutonomy)}
unit="%"
dial={pct(m?.effectiveAutonomy)}
sub="自主率 ×(1 − 纠正率)"
/>
<Kpi ico="hand" label="打扰率" value={(m?.interruption ?? 0).toFixed(1)} sub="每单任务调用人类工具次数" />
</div>
<div className="g-kpis">
<Kpi ico="unplug" tone="danger" label="断点率" value={pct(m?.breakpoint)} unit="%" sub="当下可为 ∩ 目标所需 = 空" />
<Kpi ico="git-commit" tone="ok" label="DMN 覆盖率" value={pct(m?.dmnCoverage)} unit="%" sub="确定性求值占判断次数" />
<Kpi
ico="coins"
label="单位成本"
value={Math.round(m?.unitCost.tokens ?? 0)}
unit="tok"
sub={`每放行一单:${(m?.unitCost.minutes ?? 0).toFixed(1)} 分钟 · 调人 ${(m?.unitCost.humanCalls ?? 0).toFixed(1)} 次`}
/>
{/* 终态分布:比例即信息 —— 五终态五色一条,不把五个百分比塞进一行小字 */}
<div className="g-kpi">
<span className="g-kpi-l">
<Ico name="flag" s={12} />
终态分布
</span>
{mixTop.length === 0 ? (
<>
<span className="g-kpi-v">—</span>
<span className="g-kpi-s">还没有任务落终态</span>
</>
) : (
<>
<span className="g-mixbar">
{mixTop.map(([k, v]) => (
<i key={k} style={{ width: `${pct(v)}%`, background: TERMINAL_TONE[k] ?? '#c9d2dd' }} />
))}
</span>
<span className="g-mixbar-legend">
{mixTop.map(([k, v]) => (
<span key={k}>
<i style={{ background: TERMINAL_TONE[k] ?? '#c9d2dd' }} />
{TERMINAL_STATE_LABEL[k as TerminalState] ?? k} <b>{pct(v)}%</b>
</span>
))}
</span>
</>
)}
</div>
</div>
<Note>
自主率与纠正率<b>成对读</b>,复合为有效自主率——单看自主率会奖励「猛冲后返工」。度量只是仪表,不载权力。
八指标与工作台的汇报<b>同源同口径</b>,统计窗口:近一{m?.period === 'day' ? '日' : m?.period === 'week' ? '周' : m?.period === 'year' ? '年' : '月'}。
</Note>
</>
</QueryGate>
);
}
/* ---------------- 知识库 ---------------- */
/** 活动记忆四层 + 候选增量的放行口 —— 记忆的终点是成为模型,那道门在这里 */
export function KnowledgeHome({ nav }: HomeProps) {
const activities = useGovActivities();
const [picked, setPicked] = useState<string | undefined>(undefined);
const memory = useGovMemory(picked);
return (
<>
<div className="g-picker">
{(activities.data?.activities ?? []).map((a) => (
<button
key={a.activityId}
className={'g-pick' + (picked === a.activityId ? ' on' : '')}
onClick={() => setPicked(a.activityId)}
>
{a.name}
</button>
))}
</div>
<NList>
{(memory.data?.entries ?? []).map((e) => (
<NRow
key={e.id}
tone={e.incrementState === undefined ? 'brand' : 'warn'}
ico="layers"
title={e.statement}
sub={`${e.layer} · ${e.createdAt.slice(0, 16).replace('T', ' ')}`}
cols={[{ k: '证据', v: e.evidenceCount }]}
right={
e.incrementState !== undefined ? (
<Chip tone="warn">候选 · {e.incrementState}</Chip>
) : (
<Chip tone="ok">已成条目</Chip>
)
}
onClick={() => nav.push({ k: 'mem', id: e.id })}
/>
))}
{picked === undefined && <Clear>选一个活动,看它这些年学到了什么。</Clear>}
{picked !== undefined && (memory.data?.entries ?? []).length === 0 && (
<Clear>这个活动还没有记忆条目 —— 任务落终态封卷、蒸馏钩子跑过之后,沉淀会出现在这里。</Clear>
)}
</NList>
</>
);
}
/* ---------------- 记忆条目详情 ---------------- */
export function MemPage({ id }: { id: string }) {
const activities = useGovActivities();
const first = (activities.data?.activities ?? [])[0]?.activityId;
const memory = useGovMemory(first);
const e = (memory.data?.entries ?? []).find((x) => x.id === id);
if (e === undefined) return <Note>记忆条目需从对应活动进入。</Note>;
return (
<PageHead
ico="layers"
kicker={`活动记忆 · ${e.layer}`}
title={e.statement}
chips={
<>
<Chip ico="link">{e.evidenceCount} 条证据</Chip>
{e.incrementState !== undefined && <Chip tone="warn">候选增量 · {e.incrementState}</Chip>}
</>
}
desc="写入唯蒸馏,条目必挂证据链;记忆不载权力——强制力只经候选增量 + 回放 + 人放行进入业务模型。"
/>
);
}