diff --git a/apps/web/src/features/gov-tool/internal/panel.tsx b/apps/web/src/features/gov-tool/internal/panel.tsx index 217bf84..de3724e 100644 --- a/apps/web/src/features/gov-tool/internal/panel.tsx +++ b/apps/web/src/features/gov-tool/internal/panel.tsx @@ -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 ( - - <> - - 按效应等级分列:读类过程中自由调用;写类只登记动作意向,放行后统一落地。密钥只存治理台,不入包、不入活动。 - - {[ - { label: '读类 · 过程中自由调用', items: reads, tone: 'brand', ico: 'search' }, - { label: '写类 · 登记意向,放行后落地', items: writes, tone: 'danger', ico: 'pen-line' }, - ].map((group) => ( - - - - {group.label} - {group.items.length} - - - {group.items.map((t) => ( - {t.version} }, - { k: '连接配置键', v: {t.connectionKeys.join(', ') || '—'} }, - ]} - right={t.pure ? 纯工具 : {t.effect === 'read' ? '读' : '写'}} - onClick={() => nav.push({ k: 'tool', id: t.name })} - /> + + <> + + 一件事能交给谁办,全集在这里:交给系统(功能工具)、交给模型(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(); @@ -104,6 +436,3 @@ export function ToolPage({ id }: { id: string }) { > ); } - -/* ---------------- 数据对象 ---------------- */ - diff --git a/apps/web/src/shared/gov/gov-route.ts b/apps/web/src/shared/gov/gov-route.ts index a9b1488..4eff566 100644 --- a/apps/web/src/shared/gov/gov-route.ts +++ b/apps/web/src/shared/gov/gov-route.ts @@ -113,7 +113,7 @@ export const ZM: Readonly