/** * 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 ( <> 一件事能交给谁办,全集在这里:交给系统(功能工具)、交给模型(LLM)、交给 人(人类工具)。工具承载权力,约束严于活动 —— 契约对活动、实现对系统、凭证只存治理台。 {rows.length === 0 ? ( 目录里还没有工具 —— 从下面「接入」导入一件,或由第一方随系统装入。 ) : ( {rows.map((t) => { const write = t.effect === 'write'; return ( nav.push({ k: 'tool', id: t.name })} > {t.name} v{t.version} {write ? '写' : '读'} {t.description} {t.pure ? ( 纯工具 · 活动私有 ) : ( {t.connectionKeys.length === 0 ? '无需连接' : `连接 ${t.connectionKeys.join('/')}`} )} ); })} )} {(llm.data?.instances ?? []).map((m) => ( nav.jump('system', 'llm', { k: 'llm', id: m.id })} > {m.name} {m.provider} {m.apiKeyBound ? '已绑密钥' : '未绑密钥'} 型号 {m.model} {m.systemUse && 系统用途} {m.enabled ? '启用中' : '已停用'} ))} {(llm.data?.instances ?? []).length === 0 && ( 还没有绑定模型 —— 去系统区加一个实例,活动才有模型可用。 )} {HUMAN_TOOLS.map((h) => ( {h.name} {h.mode} 内置 {h.desc} ))} > ); } /* ---------------- 接入 · 导入自定义工具 ---------------- */ /** * 导入 = 交一份**调用契约** + 一个能调通的端点 + 至少一条样例。 * 两审当场跑:静态审读契约,试运行审真调那个端点 —— 调不通就不入目录。 */ 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([]); 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 ( setOpen(true)}> 开始导入 } > 交一份调用契约 + 一个能调通的端点,两审当场跑:静态审读契约,试运行审真调那个端点。 调不通就不入目录 —— 一件调不通的工具进了目录,活动用它时才失败,那才是真的坏。 ); } return ( setName(e.target.value)} placeholder="erp.stock-read" /> setVersion(e.target.value)} /> setDescription(e.target.value)} placeholder="读取 ERP 现货库存:PN → 库存量与库龄,寻源前先看自有呆滞" /> setEffect(k === 'write' ? 'write' : 'read')} /> setEndpoint(e.target.value)} placeholder="https://erp.internal/api/stock" /> setSample(e.target.value)} /> {verdict !== undefined && !verdict.accepted && ( 两审未过,没有入目录: {[...verdict.findings.map((f) => f.message), ...verdict.trialRun.failures].join(';') || '未知原因'} )} setOpen(false) }, { label: importTool.isPending ? '两审中…' : '导入并送审', ico: 'circle-check', primary: true, disabled: importTool.isPending || name.trim() === '' || endpoint.trim() === '', ask: { title: `导入工具:${name.trim()}`, note: 与第一方同一条门:静态审 + 试运行审。过了才入目录;入目录还不等于能用,要再给活动授白名单。, impact: { who: '仅工具目录;没进白名单的活动仍然调不到它', undo: '可停用;契约演进只增不破坏,破坏性变更要换新工具名', when: '确认后当场跑两审', }, okLabel: '确认导入', onOk: () => importTool.mutate(), }, }, ]} /> ); } /** 样例入参解析:写坏了就当空对象送审 —— 让试运行去报错,别在这里拦 */ function parseSample(text: string): Record { try { const parsed: unknown = JSON.parse(text); return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? (parsed as Record) : {}; } catch { return {}; } } /* ---------------- 接入 · MCP ---------------- */ function McpCard() { return ( 未连接 } > MCP 服务器上的工具,接进来之后与自定义工具同等待遇:经工具调用一缝去调、过两审入目录、 按白名单授给活动。当前系统还没有 MCP 客户端 —— 这里如实标着未连接,不摆一个点不动的按钮。 ); } /* ---------------- 工具详情 ---------------- */ export function ToolPage({ id }: { id: string }) { const q = useGovTools(); const t = (q.data?.tools ?? []).find((x) => x.name === id); if (t === undefined) return 工具不存在。; const write = t.effect === 'write'; return ( <> {write ? '写类' : '读类'} {t.pure && 纯工具} v{t.version} > } desc={ write ? '写类工具在过程中只登记动作意向,不改世界;放行通过后由落地收敛统一执行。' : '读类工具过程中自由调用 —— 读不改世界,故不需放行。' } /> {t.description} 凭证隔离:治理台只回配置键,永不回值;密钥不入包、不入活动、不入留痕明文。 {t.connectionKeys.map((k) => ( {k} 已在治理台绑定 ))} {t.connectionKeys.length === 0 && 此工具无需外部连接。} > ); }
{t.description}