forked from ChaiHao/BPOS-MVP
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:
@@ -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}>
|
||||
<>
|
||||
<Note>
|
||||
按<b>效应等级</b>分列:读类过程中自由调用;写类只登记<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
|
||||
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>}
|
||||
onClick={() => nav.push({ k: 'tool', id: t.name })}
|
||||
/>
|
||||
<QueryGate query={tools}>
|
||||
<>
|
||||
<Note>
|
||||
一件事能交给谁办,全集在这里:交给<b>系统</b>(功能工具)、交给<b>模型</b>(LLM)、交给
|
||||
<b>人</b>(人类工具)。工具承载权力,约束严于活动 —— 契约对活动、实现对系统、凭证只存治理台。
|
||||
</Note>
|
||||
|
||||
<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}
|
||||
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>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
{(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 }) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- 数据对象 ---------------- */
|
||||
|
||||
|
||||
Reference in New Issue
Block a user