Archived
三类工具并列不是编排,是「一件事能交给谁办」的全集:交给系统、交给模型、交给人。 导入表单走两审同门(端点真调);MCP 如实标未连接,不摆点不动的按钮。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
439 lines
16 KiB
TypeScript
439 lines
16 KiB
TypeScript
/**
|
|
* feature `gov-tool` —— 工具箱:**系统能对世界做什么**,一屏四段。
|
|
*
|
|
* 功能工具 · 已注册的调用契约(读类自由调,写类登记意向)
|
|
* LLM 工具 · 模型实例(活动只见逻辑名,不触厂商与密钥)
|
|
* 人类工具 · 工单两模式 + 对话卡三族(调人也是调工具,只是被调的是人)
|
|
* 接入 · 导入自定义工具(与第一方同门两审)/ 连接 MCP
|
|
*
|
|
* 三类工具并列不是编排:它是「一件事能交给谁办」的全集 ——
|
|
* 交给系统(功能工具)、交给模型(LLM)、交给人(人类工具)。少列一类,活动就以为自己没得选。
|
|
*
|
|
* 自 `~/coding/JCAI_PRD/governance/_components/Governance.tsx` 平移:
|
|
* 类名(`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 {
|
|
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 tools = useGovTools();
|
|
const llm = useGovLlm();
|
|
const rows = tools.data?.tools ?? [];
|
|
|
|
return (
|
|
<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>
|
|
))}
|
|
{(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();
|
|
const t = (q.data?.tools ?? []).find((x) => x.name === id);
|
|
if (t === undefined) return <Note>工具不存在。</Note>;
|
|
const write = t.effect === 'write';
|
|
return (
|
|
<>
|
|
<PageHead
|
|
ico="wrench"
|
|
tone={write ? 'danger' : 'brand'}
|
|
kicker={`工具 · ${t.name}`}
|
|
title={t.name}
|
|
chips={
|
|
<>
|
|
<Chip tone={write ? 'danger' : 'brand'}>{write ? '写类' : '读类'}</Chip>
|
|
{t.pure && <Chip tone="ok">纯工具</Chip>}
|
|
<Chip ico="hash">v{t.version}</Chip>
|
|
</>
|
|
}
|
|
desc={
|
|
write
|
|
? '写类工具在过程中只登记动作意向,不改世界;放行通过后由落地收敛统一执行。'
|
|
: '读类工具过程中自由调用 —— 读不改世界,故不需放行。'
|
|
}
|
|
/>
|
|
<Card ico="info" title="说明">
|
|
<p className="g-desc">{t.description}</p>
|
|
</Card>
|
|
<Card ico="shield-check" title="连接配置">
|
|
<Note>凭证隔离:治理台只回<b>配置键</b>,永不回值;密钥不入包、不入活动、不入留痕明文。</Note>
|
|
<div className="g-rows">
|
|
{t.connectionKeys.map((k) => (
|
|
<div key={k}>
|
|
<em>{k}</em>
|
|
<b className="mono">已在治理台绑定</b>
|
|
</div>
|
|
))}
|
|
{t.connectionKeys.length === 0 && <Clear>此工具无需外部连接。</Clear>}
|
|
</div>
|
|
</Card>
|
|
</>
|
|
);
|
|
}
|