/** * feature `gov-system` —— 系统管理:LLM 实例四元绑定、通道实例 * * 自 `~/coding/JCAI_PRD/governance/_components/Governance.tsx` 平移: * DOM 结构、类名(`g-*` + 色调 `t-*`)与文案原样保留,mock 常量换成服务端投影(README 平移规则 1/2/4)。 * 区首页只负责导航,治理动作一律落在下钻详情页的**就地确认带**(影响预览三必答)。 */ import { useEffect, useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import type { LlmInstanceView, LlmProvider } from '@contracts'; import { probeLlmDraftRoute, probeLlmInstanceRoute, upsertLlmInstanceRoute } from '@contracts'; import { call } from '@web-shared/typed-fetch.ts'; import { Ico } from '@web-shared/Ico.tsx'; import { Card, Chip, NList, NRow, Note, PageHead, QueryGate, Rows, St } from '@web-shared/gov/primitives.tsx'; import type { Nav } from '@web-shared/gov/gov-route.ts'; import { useGovChannels, useGovLlm, useGovLlmProviders } from '@web-shared/gov/gov-api.ts'; export type HomeProps = { tab: string; nav: Nav }; /** * Provider 的人读名 —— **只是标签**:默认端点、结构化出参口径、推理回包这些 * 由服务端的 provider-profile 说了算(那份是唯一真相,前端不复制一份 URL 表来漂移)。 */ const PROVIDER_LABEL: Readonly> = { 'openai-compatible': 'OpenAI 兼容端点', openai: 'OpenAI', deepseek: 'DeepSeek', anthropic: 'Anthropic', qwen: '通义千问', }; /** 端点只显示主机名 —— 卡上要的是「连的是哪家」,不是整条 URL */ const hostOf = (url: string): string => { try { return new URL(url).host; } catch { return url === '' ? '—' : url; } }; /** 表单草稿:apiKey 是**只写字段**,留空 = 保持原值(服务端永不回显密钥) */ type LlmDraft = { id?: string; name: string; provider: LlmProvider; baseUrl: string; model: string; apiKey: string; systemUse: boolean; enabled: boolean; }; const draftOf = (i: LlmInstanceView): LlmDraft => ({ id: i.id, name: i.name, provider: i.provider, baseUrl: i.baseUrl, model: i.model, apiKey: '', systemUse: i.systemUse, enabled: i.enabled, }); const EMPTY_DRAFT: LlmDraft = { name: '', provider: 'deepseek', baseUrl: '', model: '', apiKey: '', systemUse: false, enabled: true, }; /** * 接入模型 —— **右侧抽屉**,照模型接入面的通行做法排: * ① 先挑供应商(一排可点的牌子,选中即预填它的默认端点) * ② 再填连接(端点 + 密钥,密钥旁边就是「测试连接」—— 填完当场知道通不通) * ③ 最后是型号与用途。底部一条固定动作栏。 * * 为什么是抽屉不是页内卡:配置是**一次性的岔路**,做完就该回到货架。 * 从前那版把表单摊在页面上,输入框拉到 1100px 宽、动作钮掉出屏幕 —— 那不是表单,是把字段倒在地上。 */ function LlmDrawer({ draft, onCancel, onSaved, }: { draft: LlmDraft; onCancel: () => void; onSaved: (saved: LlmInstanceView) => void; }) { const [d, setD] = useState(draft); const [showKey, setShowKey] = useState(false); const [err, setErr] = useState(undefined); const [probed, setProbed] = useState<{ ok: boolean; latencyMs?: number; error?: string } | undefined>(undefined); const providers = useGovLlmProviders(); const isNew = draft.id === undefined; // Esc 关抽屉 —— 抽屉盖住整屏,不给一条键盘出路是把人关在里面 useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onCancel(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onCancel]); const profile = (providers.data?.providers ?? []).find((p) => p.provider === d.provider); // openai-compatible 没有默认端点:不自填就连不上,这条闸前端先拦一道(服务端也拦) const urlNeeded = (profile?.defaultBaseUrl ?? '') === '' && d.baseUrl.trim() === ''; const invalid = d.name.trim() === '' || d.model.trim() === '' || urlNeeded; /** 换供应商时把端点跟着换成它的默认值 —— 除非用户已经手填过别的 */ const pickProvider = (next: string, nextDefault: string) => { const prevDefault = profile?.defaultBaseUrl ?? ''; const untouched = d.baseUrl.trim() === '' || d.baseUrl.trim() === prevDefault; setD({ ...d, provider: next as LlmProvider, ...(untouched ? { baseUrl: nextDefault } : {}) }); setProbed(undefined); }; const save = useMutation({ mutationFn: () => call(upsertLlmInstanceRoute, { body: { ...(d.id === undefined ? {} : { id: d.id }), name: d.name.trim(), provider: d.provider, baseUrl: d.baseUrl.trim(), model: d.model.trim(), ...(d.apiKey === '' ? {} : { apiKey: d.apiKey }), systemUse: d.systemUse, enabled: d.enabled, }, }), onSuccess: onSaved, onError: (e: unknown) => setErr((e as { message?: string }).message ?? '存不下'), }); /** * 测试连接 —— **只测不存**:拿表单上正填的四元去试,一个字节不落库。 * 密钥留空就用这条实例已存的那把(改端点、换型号不必重打钥匙)。 */ const test = useMutation({ mutationFn: () => call<{ ok: boolean; latencyMs?: number; error?: string }>(probeLlmDraftRoute, { body: { ...(d.id === undefined ? {} : { id: d.id }), name: d.name.trim(), provider: d.provider, baseUrl: d.baseUrl.trim(), model: d.model.trim(), ...(d.apiKey === '' ? {} : { apiKey: d.apiKey }), }, }), onSuccess: setProbed, onError: (e: unknown) => setProbed({ ok: false, error: (e as { message?: string }).message ?? '测试失败' }), }); return (
{/* * 排布按「决定性」从大到小,且**一屏装得下** —— 上一版把 118px 的供应商牌子摆在最前, * 结果「模型」「用途名」这两个定义身份的字段掉到折线以下:点开看不见输入框。 * 现在:供应商收成一行小签 → 身份两栏并排 → 连接 → 开关。 */}

供应商

{(providers.data?.providers ?? []).map((p) => ( ))}

身份

连接

{probed !== undefined && ( {probed.ok ? `连通 · ${probed.latencyMs ?? '—'} ms` : (probed.error ?? '不通')} )}
系统用途:叙述性小结 / 汇报生成 / 蒸馏钩子走它
{err !== undefined && {err}}
); } /** * 一张实例卡 —— 自检状态归它自己:通了报延迟,不通**把厂商原话摆在卡里**。 * 原话不改写:「密钥无效」和「端点不通」是两件完全不同的事,改写成一句「连不上」 * 就等于把要人去修的那条线索抹了(实测:种子里的密钥是占位串,自检当场就说清了)。 */ function LlmCard({ i, nav, onEdit }: { i: LlmInstanceView; nav: Nav; onEdit: () => void }) { const [out, setOut] = useState<{ ok: boolean; latencyMs?: number; error?: string } | undefined>(undefined); const probe = useMutation({ mutationFn: () => call<{ ok: boolean; latencyMs?: number; error?: string }>(probeLlmInstanceRoute, { params: { id: i.id } }), onSuccess: setOut, onError: (e: unknown) => setOut({ ok: false, error: (e as { message?: string }).message ?? '自检失败' }), }); return (
{i.apiKeyBound ? '已绑' : '未绑'} 密钥 {i.systemUse ? '是' : '否'} 系统用途 {hostOf(i.baseUrl)} 端点
{out !== undefined && (
{out.ok ? `连通 · ${out.latencyMs ?? '—'} ms` : (out.error ?? '不通')}
)} ); } export function ModelsHome({ nav }: HomeProps) { return ; } export function SystemHome({ tab, nav }: HomeProps) { const channels = useGovChannels(); if (tab === 'chan') { return ( <> {(channels.data?.channels ?? []).map((ch) => ( {Math.round(ch.addressWindowMs / 3600_000)}h }, { k: '默认认领岗位', v: ch.fallbackPosition ?? '—' }, ]} right={ {ch.enabled ? '已启用' : '已停用'} } onClick={() => nav.push({ k: 'chan', id: ch.id })} /> ))} ); } return ; } /** * 模型实例货架 —— **能配**:卡上直接改四元绑定、开关与密钥,还能当场自检连通。 * 从前这一屏只读:进来只看得见「密钥已绑定」,却没有一处能绑 —— 那不叫配置面。 */ function LlmShelf({ nav }: { nav: Nav }) { const llm = useGovLlm(); const queryClient = useQueryClient(); const [editing, setEditing] = useState(null); const instances = llm.data?.instances ?? []; return ( <> {editing !== null && ( setEditing(null)} onSaved={() => { void queryClient.invalidateQueries({ queryKey: ['gov', 'llm'] }); setEditing(null); }} /> )}
{instances.map((i) => ( setEditing(draftOf(i))} /> ))}
); } export function LlmPage({ id }: { id: string }) { const q = useGovLlm(); const queryClient = useQueryClient(); const [editing, setEditing] = useState(false); const i = (q.data?.instances ?? []).find((x) => x.id === id); if (i === undefined) return 实例不存在。; return ( <> {i.model} {i.apiKeyBound ? '密钥已绑定' : '密钥未绑定'} {i.systemUse && 系统用途} } desc="名称间接层:活动只声明自定义模型名,四元绑定在系统侧。换厂商、换端点、换密钥都不动活动一行代码。" /> {editing ? ( setEditing(false)} onSaved={() => { void queryClient.invalidateQueries({ queryKey: ['gov', 'llm'] }); setEditing(false); }} /> ) : ( setEditing(true)}> 配置 } > )} ); } export function ChanPage({ id }: { id: string }) { const q = useGovChannels(); const ch = (q.data?.channels ?? []).find((x) => x.id === id); if (ch === undefined) return 通道不存在。; return ( <> {ch.address} {ch.enabled ? '已启用' : '已停用'} } desc="归属解析三级逐级短路:会话线程 → 监听键 → 地址窗口;歧义或失败一律判无主——宁交人,不错投。" /> ); }