diff --git a/apps/web/src/app/styles/console.css b/apps/web/src/app/styles/console.css index 7878b31..608ef87 100644 --- a/apps/web/src/app/styles/console.css +++ b/apps/web/src/app/styles/console.css @@ -297,6 +297,9 @@ .g-fcard .plate b { font: 700 14px/1 var(--mono); letter-spacing: -.03em; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .g-fcard .plate i { font-style: normal; font-size: 10px; color: var(--ink-4); white-space: nowrap; } .g-fcard .plate > span.bad { background: #fdf3f3; } +/* 宽格:装得下主机名这类长值 —— 占两格宽、字降一档,不省略号 */ +.g-fcard .plate > span.wide { grid-column: span 2; } +.g-fcard .plate > span.wide b { font-size: 11.5px; letter-spacing: 0; } .g-fcard .plate > span.bad b { color: var(--danger); } /* ⋯ 菜单:从头行那枚钮下弹出 */ @@ -307,6 +310,15 @@ .g-fcard .menu button.bad:hover { background: #fdf2f2; } .g-fcard .menu .q { padding: 3px 8px 5px; font-size: 11px; line-height: 1.5; color: var(--ink-3); } +/* 自检读数:卡底一条 —— 通了报延迟,不通把**厂商原话**摆出来(两行内,长了截断) */ +.g-fcard .probe { position: relative; z-index: 1; display: flex; align-items: flex-start; gap: 6px; padding: 8px 10px; border-radius: 9px; background: #f2f7f4; font: 500 11px/1.5 var(--sans); color: #0c8154; } +.g-fcard .probe.bad { background: #fdf3f3; color: #a83a3a; } +.g-fcard .probe svg { flex: none; margin-top: 2px; } +/* 开关行:两枚复选框并排 —— 表单里唯一的横排 */ +.g-swrow { display: flex; align-items: center; gap: 18px; } +.g-swrow label { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--ink-2, var(--ink)); cursor: pointer; } +.g-swrow input { width: 14px; height: 14px; accent-color: var(--brand); cursor: pointer; } + /* 审批依据:一行两格 + 删 */ .g-basis { display: flex; flex-direction: column; gap: 6px; } .g-basis-r { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; gap: 6px; } diff --git a/apps/web/src/features/gov-system/internal/panel.tsx b/apps/web/src/features/gov-system/internal/panel.tsx index 0795114..7c34a64 100644 --- a/apps/web/src/features/gov-system/internal/panel.tsx +++ b/apps/web/src/features/gov-system/internal/panel.tsx @@ -5,8 +5,13 @@ * DOM 结构、类名(`g-*` + 色调 `t-*`)与文案原样保留,mock 常量换成服务端投影(README 平移规则 1/2/4)。 * 区首页只负责导航,治理动作一律落在下钻详情页的**就地确认带**(影响预览三必答)。 */ +import { useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import type { LlmInstanceView, LlmProvider } from '@contracts'; +import { LLM_PROVIDERS, 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 { Acts, Card, Chip, Field, 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, @@ -16,16 +21,234 @@ import { 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, +}; + +/** + * 实例配置表单 —— 四元绑定就在这里改(Provider / 端点 / 型号 / 密钥)+ 两枚开关。 + * + * **密钥只写不读**(OS 7.3 凭证隔离):服务端只回「绑没绑」,永不回显明文; + * 所以这一格留空的意思是「保持原值」,不是「清空」。 + */ +function LlmForm({ + draft, + onCancel, + onSaved, +}: { + draft: LlmDraft; + onCancel: () => void; + onSaved: (saved: LlmInstanceView) => void; +}) { + const [d, setD] = useState(draft); + const [err, setErr] = useState(undefined); + const isNew = draft.id === undefined; + // openai-compatible 没有默认端点:不自填就连不上,这条闸前端先拦一道(服务端也拦) + const urlNeeded = d.provider === 'openai-compatible' && d.baseUrl.trim() === ''; + 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 ?? '存不下'), + }); + + return ( + +
+ + setD({ ...d, name: e.target.value })} /> + + + + + + setD({ ...d, baseUrl: e.target.value })} + /> + + + setD({ ...d, model: e.target.value })} /> + + + setD({ ...d, apiKey: e.target.value })} + /> + + +
+ + +
+
+ {urlNeeded && OpenAI 兼容端点没有默认地址 —— 不填 Base URL,这个实例连不上。} + {err !== undefined && {err}} + save.mutate(), + }, + ]} + /> +
+
+ ); +} + +/** + * 一张实例卡 —— 自检状态归它自己:通了报延迟,不通**把厂商原话摆在卡里**。 + * 原话不改写:「密钥无效」和「端点不通」是两件完全不同的事,改写成一句「连不上」 + * 就等于把要人去修的那条线索抹了(实测:种子里的密钥是占位串,自检当场就说清了)。 + */ +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 llm = useGovLlm(); const channels = useGovChannels(); if (tab === 'chan') { @@ -58,47 +281,50 @@ export function SystemHome({ tab, nav }: HomeProps) { ); } + return ; +} + +/** + * 模型实例货架 —— **能配**:卡上直接改四元绑定、开关与密钥,还能当场自检连通。 + * 从前这一屏只读:进来只看得见「密钥已绑定」,却没有一处能绑 —— 那不叫配置面。 + */ +function LlmShelf({ nav }: { nav: Nav }) { + const llm = useGovLlm(); + const queryClient = useQueryClient(); + const [editing, setEditing] = useState(null); + const instances = llm.data?.instances ?? []; + return ( - <> - {/* 卡而不是行:一个实例要一眼看全「谁家的 · 什么型号 · 密钥绑没绑」 */} -
- {(llm.data?.instances ?? []).map((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 ( @@ -116,16 +342,37 @@ export function LlmPage({ id }: { id: string }) { } desc="名称间接层:活动只声明自定义模型名,四元绑定在系统侧。换厂商、换端点、换密钥都不动活动一行代码。" /> - - setEditing(false)} + onSaved={() => { + void queryClient.invalidateQueries({ queryKey: ['gov', 'llm'] }); + setEditing(false); + }} /> - + ) : ( + setEditing(true)}> + 配置 + + } + > + + + )} ); } diff --git a/apps/web/src/shared/gov/gov-route.ts b/apps/web/src/shared/gov/gov-route.ts index 60d0814..a84653c 100644 --- a/apps/web/src/shared/gov/gov-route.ts +++ b/apps/web/src/shared/gov/gov-route.ts @@ -155,7 +155,7 @@ export const ZM: Readonly