MVP底座⑥:工具箱四段卡片 —— 功能/LLM/人类/接入

三类工具并列不是编排,是「一件事能交给谁办」的全集:交给系统、交给模型、交给人。
导入表单走两审同门(端点真调);MCP 如实标未连接,不摆点不动的按钮。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-28 02:02:00 +08:00
co-authored by Claude Fable 5
parent 8e7509f65b
commit 06b9cbde04
3 changed files with 392 additions and 50 deletions
+370 -41
View File
@@ -1,65 +1,397 @@
/**
* feature `gov-tool` —— 工具管理:注册、白名单、凭证绑定(只回键,永不回值)
* feature `gov-tool` —— 工具箱:**系统能对世界做什么**,一屏四段。
*
* 功能工具 · 已注册的调用契约(读类自由调,写类登记意向)
* LLM 工具 · 模型实例(活动只见逻辑名,不触厂商与密钥)
* 人类工具 · 工单两模式 + 对话卡三族(调人也是调工具,只是被调的是人)
* 接入 · 导入自定义工具(与第一方同门两审)/ 连接 MCP
*
* 三类工具并列不是编排:它是「一件事能交给谁办」的全集 ——
* 交给系统(功能工具)、交给模型(LLM)、交给人(人类工具)。少列一类,活动就以为自己没得选。
*
* 自 `~/coding/JCAI_PRD/governance/_components/Governance.tsx` 平移:
* DOM 结构、类名(`g-*` + 色调 `t-*`)与文案原样保留,mock 常量换成服务端投影(README 平移规则 1/2/4)。
* 区首页只负责导航,治理动作一律落在下钻详情页的**就地确认带**(影响预览三必答)。
* 类名(`g-*` + 色调 `t-*`)与文案沿用;设计台以**卡片**为基调(运营台才以列表为主)。
*/
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { registerToolRoute } from '@contracts';
import { call } from '@web-shared/typed-fetch.ts';
import { Ico } from '@web-shared/Ico.tsx';
import { Card, Chip, Clear, NList, NRow, Note, PageHead, QueryGate } from '@web-shared/gov/primitives.tsx';
import type { Nav } from '@web-shared/gov/gov-route.ts';
import {
useGovTools,
} from '@web-shared/gov/gov-api.ts';
Acts,
Card,
Chip,
Clear,
Field,
ListEdit,
Note,
PageHead,
Prop,
QueryGate,
Sec,
Seg,
St,
} from '@web-shared/gov/primitives.tsx';
import type { Nav } from '@web-shared/gov/gov-route.ts';
import { useGovLlm, useGovTools } from '@web-shared/gov/gov-api.ts';
export type HomeProps = { tab: string; nav: Nav };
/**
* 人类工具目录 —— 系统内置的五种形态(语义出处 `packages/contracts/src/card.ts`)。
* 它们不在工具目录里注册:调人的能力是基座给的,不是谁装上来的。
*/
const HUMAN_TOOLS = [
{
name: '执行工单',
mode: 'work-order · execution',
ico: 'clipboard-check',
desc: '作业指导书 + 空表单,请人把一步做完;落地失败卡是它的系统预置形态,不算第五种卡',
},
{
name: '验收工单',
mode: 'work-order · acceptance',
ico: 'shield-check',
desc: '请人验收产出:通过 / 驳回重做 / 驳回迭代 —— 终局唯放行',
},
{
name: '决策卡',
mode: 'chat-card · decision',
ico: 'git-branch',
desc: '分支条件选择表,含自定义收尾项 —— 决策是对话区的选择题,不进视图区',
},
{
name: '补全卡',
mode: 'chat-card · elicitation',
ico: 'pen-line',
desc: '就地要几个小信息;「没法填」旁路与提交两条路必走其一,不许静默挂着',
},
{
name: '评价卡',
mode: 'chat-card · rating',
ico: 'star',
desc: '任务评价:评分 1–5 + 改进建议,回流度量与蒸馏',
},
] as const;
export function ToolsHome({ nav }: HomeProps) {
const q = useGovTools();
const tools = q.data?.tools ?? [];
const reads = tools.filter((t) => t.effect === 'read');
const writes = tools.filter((t) => t.effect === 'write');
const tools = useGovTools();
const llm = useGovLlm();
const rows = tools.data?.tools ?? [];
return (
<QueryGate query={q}>
<QueryGate query={tools}>
<>
<Note>
按<b>效应等级</b>分列:读类过程中自由调用;写类只登记<b>动作意向</b>,放行后统一落地。密钥只存治理台,不入包、不入活动。
一件事能交给谁办,全集在这里:交给<b>系统</b>(功能工具)、交给<b>模型</b>(LLM)、交给
<b>人</b>(人类工具)。工具承载权力,约束严于活动 —— 契约对活动、实现对系统、凭证只存治理台。
</Note>
{[
{ label: '读类 · 过程中自由调用', items: reads, tone: 'brand', ico: 'search' },
{ label: '写类 · 登记意向,放行后落地', items: writes, tone: 'danger', ico: 'pen-line' },
].map((group) => (
<div key={group.label} className="g-sec">
<div className="g-band">
<Ico name={group.ico} s={13} />
{group.label}
<span className="x">{group.items.length}</span>
</div>
<NList>
{group.items.map((t) => (
<NRow
<Sec ico="wrench" title="功能工具" extra={`${rows.length} 件 · 读类自由调用,写类登记意向、放行后落地`}>
{rows.length === 0 ? (
<Clear>目录里还没有工具 —— 从下面「接入」导入一件,或由第一方随系统装入。</Clear>
) : (
<div className="g-shelf">
{rows.map((t) => {
const write = t.effect === 'write';
return (
<button
key={t.name}
tone={group.tone}
ico="wrench"
title={t.name}
sub={t.description}
cols={[
{ k: '版本', v: <span className="g-mono">{t.version}</span> },
{ k: '连接配置键', v: <span className="g-mono">{t.connectionKeys.join(', ') || '—'}</span> },
]}
right={t.pure ? <Chip tone="ok">纯工具</Chip> : <Chip tone={group.tone}>{t.effect === 'read' ? '读' : '写'}</Chip>}
className={'g-tile t-' + (write ? 'warn' : 'ok')}
onClick={() => nav.push({ k: 'tool', id: t.name })}
/>
>
<span className="g-tile-h">
<span className="g-tile-i">
<Ico name="wrench" s={16} />
</span>
<span className="g-tile-t">
<b>{t.name}</b>
<em>v{t.version}</em>
</span>
<St tone={write ? 'warn' : 'ok'} ico={write ? 'pen-line' : 'search'}>
{write ? '写' : '读'}
</St>
</span>
<span className="g-tile-m">
<span className="g-tile-d">{t.description}</span>
</span>
<span className="g-tile-f">
{t.pure ? (
<Chip tone="ok" ico="lock">
纯工具 · 活动私有
</Chip>
) : (
<Chip ico="key">{t.connectionKeys.length === 0 ? '无需连接' : `连接 ${t.connectionKeys.join('/')}`}</Chip>
)}
</span>
</button>
);
})}
</div>
)}
</Sec>
<Sec ico="cpu" title="LLM 工具" extra="调模型经模型网关一缝 · 活动只见逻辑名,四元绑定在系统区">
<div className="g-shelf">
{(llm.data?.instances ?? []).map((m) => (
<button
key={m.id}
className={'g-tile t-' + (m.enabled ? 'auto' : 'mute')}
onClick={() => nav.jump('system', 'llm', { k: 'llm', id: m.id })}
>
<span className="g-tile-h">
<span className="g-tile-i">
<Ico name="cpu" s={16} />
</span>
<span className="g-tile-t">
<b>{m.name}</b>
<em>{m.provider}</em>
</span>
<St tone={m.apiKeyBound ? 'ok' : 'danger'} ico={m.apiKeyBound ? 'circle-check' : 'alert-circle'}>
{m.apiKeyBound ? '已绑密钥' : '未绑密钥'}
</St>
</span>
<span className="g-tile-m">
<span className="g-tile-d">型号 {m.model}</span>
</span>
<span className="g-tile-f">
{m.systemUse && <Chip tone="brand">系统用途</Chip>}
<span className="r">{m.enabled ? '启用中' : '已停用'}</span>
</span>
</button>
))}
</NList>
{(llm.data?.instances ?? []).length === 0 && (
<Clear>还没有绑定模型 —— 去系统区加一个实例,活动才有模型可用。</Clear>
)}
</div>
</Sec>
<Sec ico="hand" title="人类工具" extra="调人也是调工具 · 打扰上限在契约授权边界,由 Harness 强制">
<div className="g-shelf">
{HUMAN_TOOLS.map((h) => (
<div key={h.name} className="g-tile t-collab">
<span className="g-tile-h">
<span className="g-tile-i">
<Ico name={h.ico} s={16} />
</span>
<span className="g-tile-t">
<b>{h.name}</b>
<em>{h.mode}</em>
</span>
<St tone="ok" ico="circle-check">
内置
</St>
</span>
<span className="g-tile-m">
<span className="g-tile-d">{h.desc}</span>
</span>
</div>
))}
</div>
</Sec>
<Sec ico="plus" title="接入" extra="自定义工具与第一方同门两审 · MCP 经工具调用一缝接入">
<ImportToolCard />
<McpCard />
</Sec>
</>
</QueryGate>
);
}
/* ---------------- ⑦ 数据管理 ---------------- */
/* ---------------- 接入 · 导入自定义工具 ---------------- */
/**
* 导入 = 交一份**调用契约** + 一个能调通的端点 + 至少一条样例。
* 两审当场跑:静态审读契约,试运行审真调那个端点 —— 调不通就不入目录。
*/
function ImportToolCard() {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [name, setName] = useState('');
const [version, setVersion] = useState('1.0.0');
const [description, setDescription] = useState('');
const [effect, setEffect] = useState<'read' | 'write'>('read');
const [endpoint, setEndpoint] = useState('');
const [inputFields, setInputFields] = useState<string[]>([]);
const [sample, setSample] = useState('{}');
const importTool = useMutation({
mutationFn: () =>
call<{ accepted: boolean; findings: Array<{ message: string }>; trialRun: { failures: string[] } }>(
registerToolRoute,
{
body: {
contract: {
name: name.trim(),
version: version.trim(),
description: description.trim(),
effect,
input: {
type: 'object',
required: inputFields.map((f) => f.trim()).filter((f) => f !== ''),
properties: Object.fromEntries(
inputFields.map((f) => f.trim()).filter((f) => f !== '').map((f) => [f, { type: 'string' }]),
),
},
output: { type: 'object' },
errors: [],
idempotent: true,
timeoutMs: 10_000,
retries: 1,
connectionKeys: ['endpoint'],
},
connectionValues: { endpoint: endpoint.trim() },
samples: [{ input: parseSample(sample) }],
},
},
),
onSuccess: (out) => {
if (out.accepted) {
void queryClient.invalidateQueries({ queryKey: ['gov', 'tools'] });
setOpen(false);
}
},
});
const verdict = importTool.data;
if (!open) {
return (
<Card
ico="plus"
title="导入自定义工具"
extra={
<button className="g-btn primary" onClick={() => setOpen(true)}>
开始导入
</button>
}
>
<Note>
交一份调用契约 + 一个能调通的端点,两审当场跑:静态审读契约,试运行审<b>真调那个端点</b>。
调不通就不入目录 —— 一件调不通的工具进了目录,活动用它时才失败,那才是真的坏。
</Note>
</Card>
);
}
return (
<Card ico="plus" title="导入自定义工具">
<Prop ico="tag" title="身份与说明">
<Field k="工具名" hint="小写点分标识,如 erp.stock-read">
<input className="g-input" value={name} onChange={(e) => setName(e.target.value)} placeholder="erp.stock-read" />
</Field>
<Field k="版本">
<input className="g-input" value={version} onChange={(e) => setVersion(e.target.value)} />
</Field>
<Field k="适用说明" hint="面向 AI 写:何时用、语义边界 —— 这段话决定模型会不会用错它">
<input
className="g-input"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="读取 ERP 现货库存:PN → 库存量与库龄,寻源前先看自有呆滞"
/>
</Field>
</Prop>
<Prop ico="shield-check" title="效应与连接">
<Field k="效应等级" hint="读类过程中自由调用;写类只登记动作意向,放行后才触达世界">
<Seg
value={effect}
options={[
{ k: 'read', label: '读' },
{ k: 'write', label: '写' },
]}
onPick={(k) => setEffect(k === 'write' ? 'write' : 'read')}
/>
</Field>
<Field k="端点" hint="值只写不读:落库即密封,此后治理台也只回「是否已绑定」">
<input
className="g-input"
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
placeholder="https://erp.internal/api/stock"
/>
</Field>
</Prop>
<Prop ico="arrow-left-right" title="入参与样例">
<Field k="必填入参" hint="一行一个字段名;类型按字符串收,细的形状由端点自己校">
<ListEdit items={inputFields} placeholder="pn" onChange={setInputFields} />
</Field>
<Field k="样例入参" hint="JSON —— 试运行拿它真调一次端点,没有样例就没有门">
<input className="g-input" value={sample} onChange={(e) => setSample(e.target.value)} />
</Field>
</Prop>
{verdict !== undefined && !verdict.accepted && (
<Note tone="warn">
两审未过,没有入目录:
{[...verdict.findings.map((f) => f.message), ...verdict.trialRun.failures].join(';') || '未知原因'}
</Note>
)}
<Acts
align="end"
items={[
{ label: '取消', ico: 'x', onClick: () => setOpen(false) },
{
label: importTool.isPending ? '两审中…' : '导入并送审',
ico: 'circle-check',
primary: true,
disabled: importTool.isPending || name.trim() === '' || endpoint.trim() === '',
ask: {
title: `导入工具:${name.trim()}`,
note: <Note>与第一方同一条门:静态审 + 试运行审。过了才入目录;入目录还不等于能用,要再给活动授白名单。</Note>,
impact: {
who: '仅工具目录;没进白名单的活动仍然调不到它',
undo: '可停用;契约演进只增不破坏,破坏性变更要换新工具名',
when: '确认后当场跑两审',
},
okLabel: '确认导入',
onOk: () => importTool.mutate(),
},
},
]}
/>
</Card>
);
}
/** 样例入参解析:写坏了就当空对象送审 —— 让试运行去报错,别在这里拦 */
function parseSample(text: string): Record<string, unknown> {
try {
const parsed: unknown = JSON.parse(text);
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
} catch {
return {};
}
}
/* ---------------- 接入 · MCP ---------------- */
function McpCard() {
return (
<Card
ico="link"
title="连接 MCP 服务器"
extra={
<St tone="mute" ico="unplug">
未连接
</St>
}
>
<Note>
MCP 服务器上的工具,接进来之后与自定义工具同等待遇:经<b>工具调用一缝</b>去调、过两审入目录、
按白名单授给活动。当前系统还没有 MCP 客户端 —— 这里如实标着未连接,不摆一个点不动的按钮。
</Note>
</Card>
);
}
/* ---------------- 工具详情 ---------------- */
export function ToolPage({ id }: { id: string }) {
const q = useGovTools();
@@ -104,6 +436,3 @@ export function ToolPage({ id }: { id: string }) {
</>
);
}
/* ---------------- 数据对象 ---------------- */
+1 -1
View File
@@ -113,7 +113,7 @@ export const ZM: Readonly<Record<GovZone, { title: string; desc: string; accent:
title: '工具箱',
icon: 'wrench',
accent: 'linear-gradient(140deg,#7f3bf5,#5b21cf)',
desc: '能力——系统能对世界做什么。功能工具按效应等级分列:读类过程中自由调用,写类只登记动作意向、放行后落地。密钥只存治理台。',
desc: '能力——系统能对世界做什么。一件事能交给谁办的全集:功能工具(交给系统)、LLM 工具(交给模型)、人类工具(交给人)。读类过程中自由调用,写类只登记动作意向、放行后落地;密钥只存治理台,不入包、不入活动。',
},
data: {
title: '数据域',
+13
View File
@@ -60,3 +60,16 @@
死信目前只以治理者消息要素送出(`deadLetters.report` → `message`),没有一个按 kind 计数的
查询面 —— 要补这一卡,得给 system_message 加一条按 kind 聚合的治理路由。
在那之前,驾驶舱不摆一个查不出数的卡位。
## 工具箱 · MCP 连接(2026-07-28)
工具箱「接入」段有两件:**导入自定义工具已实装**(与第一方同门两审,端点调不通就不入目录);
**MCP 连接仅呈现**。系统还没有 MCP 客户端 —— 接进来之后 MCP 的工具与自定义工具同等待遇:
经工具调用一缝去调、过两审入目录、按白名单授给活动。卡上如实标「未连接」,不摆点不动的按钮。
## 活动槽位未实现(2026-07-28)
六个槽位(rfq-intake / alt-selection / sourcing-inquiry / cost-pricing / quote-negotiation /
intel-archive)只有契约面,入口产物是抛「未实现」的占位。起单后任务会停在 A1,这是**诚实状态**,
不是缺陷 —— 底座备齐后按 A1→A7 顺序逐个开发,届时以新包版本经安装通道换入,槽位契约不动。