大模型区能配了:四元绑定就地改 + 连通自检,不再是只读橱窗
后端的 upsert 与 probe 一直在(`POST /api/gov/llm` · `/api/gov/llm/:id/probe`),
只是**界面从来没用过**:进去只看得见「密钥已绑定」,却没有一处能绑 —— 那不叫配置面。
现在:
· **实例卡**能配:名 / Provider / Base URL / Model / API-Key / 启用 / 系统用途,
列表卡上一枚笔就地开表单,详情页也有同一张表(同一个组件,不写两份);
· **API-Key 只写不读**(OS 7.3 凭证隔离):服务端永不回显,所以留空 = 保持原值,
不是清空 —— 实测改型号后 `apiKeyBound` 仍为 true,密钥没被空表单洗掉;
· **Base URL 留空取该 Provider 的默认端点**;`openai-compatible` 没有默认,
前端先拦一道(服务端也拦)—— 不填就是连不上,别等自检才知道;
· **连通自检**:真发一次最小调用,通了报延迟,不通把**厂商原话**摆在卡里,
不改写成一句「连不上」—— 「密钥无效」和「端点不通」是两件要分头修的事。
实测种子里的 key 是占位串,自检当场说清:`Your api key: ****lder is invalid`。
· 幽灵卡新建实例(LLM 没有删除路由,所以本轮只验证了改,没造多余实例)。
卡片沿用刚定稿的管理系统风(着色图标 + 状态胶囊 + 计量块);端点那格占两格宽,
主机名不截断。五闸绿。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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<Record<LlmProvider, string>> = {
|
||||
'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<LlmDraft>(draft);
|
||||
const [err, setErr] = useState<string | undefined>(undefined);
|
||||
const isNew = draft.id === undefined;
|
||||
// openai-compatible 没有默认端点:不自填就连不上,这条闸前端先拦一道(服务端也拦)
|
||||
const urlNeeded = d.provider === 'openai-compatible' && d.baseUrl.trim() === '';
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
call<LlmInstanceView>(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 (
|
||||
<Card ico="cpu" title={isNew ? '新建模型实例' : `配置 ${draft.name}`}>
|
||||
<div className="g-newflow">
|
||||
<Field k="自定义模型名" hint="活动只声明这个名 —— 换厂商换型号都不动活动一行代码">
|
||||
<input className="g-input" value={d.name} placeholder="如:quote-extractor" onChange={(e) => setD({ ...d, name: e.target.value })} />
|
||||
</Field>
|
||||
<Field k="Provider" hint="决定端点默认值与结构化出参的口径">
|
||||
<select className="g-tbsel" value={d.provider} onChange={(e) => setD({ ...d, provider: e.target.value as LlmProvider })}>
|
||||
{LLM_PROVIDERS.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{PROVIDER_LABEL[p]} · {p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field k="Base URL" hint="留空 = 取该 Provider 的默认端点;OpenAI 兼容端点没有默认,必须自填">
|
||||
<input
|
||||
className="g-input mono"
|
||||
value={d.baseUrl}
|
||||
placeholder={d.provider === 'openai-compatible' ? '必填,如 https://your-gateway/v1' : '留空取默认端点'}
|
||||
onChange={(e) => setD({ ...d, baseUrl: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field k="Model" hint="厂商侧的型号名,如 deepseek-chat">
|
||||
<input className="g-input mono" value={d.model} placeholder="如:deepseek-chat" onChange={(e) => setD({ ...d, model: e.target.value })} />
|
||||
</Field>
|
||||
<Field k="API-Key" hint="只写不读:密文存治理台,永不回显;留空 = 保持原值">
|
||||
<input
|
||||
className="g-input mono"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={d.apiKey}
|
||||
placeholder={isNew ? '粘贴密钥' : '留空保持原值'}
|
||||
onChange={(e) => setD({ ...d, apiKey: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field k="开关" hint="系统用途:叙述性小结 / 汇报生成 / 蒸馏钩子走它,计量入系统账">
|
||||
<div className="g-swrow">
|
||||
<label>
|
||||
<input type="checkbox" checked={d.enabled} onChange={(e) => setD({ ...d, enabled: e.target.checked })} />
|
||||
启用
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" checked={d.systemUse} onChange={(e) => setD({ ...d, systemUse: e.target.checked })} />
|
||||
系统用途
|
||||
</label>
|
||||
</div>
|
||||
</Field>
|
||||
{urlNeeded && <Note tone="warn">OpenAI 兼容端点没有默认地址 —— 不填 Base URL,这个实例连不上。</Note>}
|
||||
{err !== undefined && <Note tone="warn">{err}</Note>}
|
||||
<Acts
|
||||
align="end"
|
||||
items={[
|
||||
{ label: '取消', ico: 'x', onClick: onCancel },
|
||||
{
|
||||
label: save.isPending ? '保存中…' : '保存',
|
||||
ico: 'send',
|
||||
primary: true,
|
||||
disabled: save.isPending || d.name.trim() === '' || d.model.trim() === '' || urlNeeded,
|
||||
onClick: () => save.mutate(),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一张实例卡 —— 自检状态归它自己:通了报延迟,不通**把厂商原话摆在卡里**。
|
||||
* 原话不改写:「密钥无效」和「端点不通」是两件完全不同的事,改写成一句「连不上」
|
||||
* 就等于把要人去修的那条线索抹了(实测:种子里的密钥是占位串,自检当场就说清了)。
|
||||
*/
|
||||
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 (
|
||||
<div className={'g-fcard' + (i.enabled ? '' : ' off')}>
|
||||
<button className="hit" aria-label={`打开 ${i.name}`} onClick={() => nav.push({ k: 'llm', id: i.id })} />
|
||||
<div className="hd">
|
||||
<span className="ico">
|
||||
<Ico name="cpu" s={17} />
|
||||
</span>
|
||||
<div className="ttl">
|
||||
<b>{i.name}</b>
|
||||
{/* 副标说清「连的是哪家的什么型号」—— 端点主机名进计量块 */}
|
||||
<em>
|
||||
{PROVIDER_LABEL[i.provider]} · {i.model}
|
||||
</em>
|
||||
</div>
|
||||
<span className={'pill' + (i.enabled ? ' on' : '')}>{i.enabled ? '启用' : '停用'}</span>
|
||||
<button
|
||||
className="mbtn"
|
||||
aria-label="连通自检"
|
||||
title="连通自检:真发一次最小调用"
|
||||
disabled={probe.isPending}
|
||||
onClick={() => probe.mutate()}
|
||||
>
|
||||
<Ico name={probe.isPending ? 'hourglass' : 'zap'} s={13} />
|
||||
</button>
|
||||
<button className="mbtn" aria-label="配置" title="配置" onClick={onEdit}>
|
||||
<Ico name="pen-line" s={13} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="plate">
|
||||
<span className={i.apiKeyBound ? '' : 'bad'}>
|
||||
<b>{i.apiKeyBound ? '已绑' : '未绑'}</b>
|
||||
<i>密钥</i>
|
||||
</span>
|
||||
<span>
|
||||
<b>{i.systemUse ? '是' : '否'}</b>
|
||||
<i>系统用途</i>
|
||||
</span>
|
||||
<span className="wide">
|
||||
<b>{hostOf(i.baseUrl)}</b>
|
||||
<i>端点</i>
|
||||
</span>
|
||||
</div>
|
||||
{out !== undefined && (
|
||||
<div className={'probe' + (out.ok ? ' ok' : ' bad')}>
|
||||
<Ico name={out.ok ? 'circle-check' : 'alert-circle'} s={12} />
|
||||
{out.ok ? `连通 · ${out.latencyMs ?? '—'} ms` : (out.error ?? '不通')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelsHome({ nav }: HomeProps) {
|
||||
return <SystemHome tab="llm" nav={nav} />;
|
||||
}
|
||||
|
||||
export function SystemHome({ tab, nav }: HomeProps) {
|
||||
const llm = useGovLlm();
|
||||
const channels = useGovChannels();
|
||||
|
||||
if (tab === 'chan') {
|
||||
@@ -58,39 +281,40 @@ export function SystemHome({ tab, nav }: HomeProps) {
|
||||
);
|
||||
}
|
||||
|
||||
return <LlmShelf nav={nav} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模型实例货架 —— **能配**:卡上直接改四元绑定、开关与密钥,还能当场自检连通。
|
||||
* 从前这一屏只读:进来只看得见「密钥已绑定」,却没有一处能绑 —— 那不叫配置面。
|
||||
*/
|
||||
function LlmShelf({ nav }: { nav: Nav }) {
|
||||
const llm = useGovLlm();
|
||||
const queryClient = useQueryClient();
|
||||
const [editing, setEditing] = useState<LlmDraft | null>(null);
|
||||
const instances = llm.data?.instances ?? [];
|
||||
|
||||
return (
|
||||
<QueryGate query={llm}>
|
||||
<>
|
||||
{/* 卡而不是行:一个实例要一眼看全「谁家的 · 什么型号 · 密钥绑没绑」 */}
|
||||
{editing !== null && (
|
||||
<LlmForm
|
||||
draft={editing}
|
||||
onCancel={() => setEditing(null)}
|
||||
onSaved={() => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['gov', 'llm'] });
|
||||
setEditing(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="g-shelf">
|
||||
{(llm.data?.instances ?? []).map((i) => (
|
||||
<button
|
||||
key={i.id}
|
||||
className={'g-tile t-' + (i.enabled ? 'brand' : 'mute')}
|
||||
onClick={() => nav.push({ k: 'llm', id: i.id })}
|
||||
>
|
||||
<span className="g-tile-h">
|
||||
<span className="g-tile-i">
|
||||
<Ico name="cpu" s={16} />
|
||||
</span>
|
||||
<span className="g-tile-t">
|
||||
<b>{i.name}</b>
|
||||
<em>{i.model}</em>
|
||||
</span>
|
||||
{i.systemUse && <Chip tone="brand">系统用途</Chip>}
|
||||
</span>
|
||||
<span className="g-tile-d">{i.baseUrl}</span>
|
||||
<span className="g-tile-m">
|
||||
<Chip ico="server">{i.provider}</Chip>
|
||||
</span>
|
||||
<span className="g-tile-f">
|
||||
<St tone={i.apiKeyBound ? 'ok' : 'warn'} ico={i.apiKeyBound ? 'shield-check' : 'alert-triangle'}>
|
||||
{i.apiKeyBound ? '密钥已绑定' : '密钥未绑定'}
|
||||
</St>
|
||||
<span className="r">{i.enabled ? '启用' : '停用'}</span>
|
||||
</span>
|
||||
</button>
|
||||
{instances.map((i) => (
|
||||
<LlmCard key={i.id} i={i} nav={nav} onEdit={() => setEditing(draftOf(i))} />
|
||||
))}
|
||||
<button className="g-fcard ghost" onClick={() => setEditing({ ...EMPTY_DRAFT })}>
|
||||
<Ico name="plus" s={18} />
|
||||
新建模型实例
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
</QueryGate>
|
||||
@@ -99,6 +323,8 @@ export function SystemHome({ tab, nav }: HomeProps) {
|
||||
|
||||
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 <Note>实例不存在。</Note>;
|
||||
return (
|
||||
@@ -116,16 +342,37 @@ export function LlmPage({ id }: { id: string }) {
|
||||
}
|
||||
desc="名称间接层:活动只声明自定义模型名,四元绑定在系统侧。换厂商、换端点、换密钥都不动活动一行代码。"
|
||||
/>
|
||||
<Card ico="settings" title="四元绑定">
|
||||
{editing ? (
|
||||
<LlmForm
|
||||
draft={draftOf(i)}
|
||||
onCancel={() => setEditing(false)}
|
||||
onSaved={() => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['gov', 'llm'] });
|
||||
setEditing(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Card
|
||||
ico="settings"
|
||||
title="四元绑定"
|
||||
extra={
|
||||
<button className="g-btn xs" onClick={() => setEditing(true)}>
|
||||
<Ico name="pen-line" s={11} /> 配置
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<Rows
|
||||
items={[
|
||||
{ k: 'Provider', v: i.provider },
|
||||
{ k: 'Provider', v: `${PROVIDER_LABEL[i.provider]} · ${i.provider}` },
|
||||
{ k: 'Base URL', v: i.baseUrl, mono: true },
|
||||
{ k: 'Model', v: i.model, mono: true },
|
||||
{ k: 'API-Key', v: i.apiKeyBound ? '已绑定(密文存储,永不回显)' : '未绑定' },
|
||||
{ k: '启用', v: i.enabled ? '启用中' : '已停用' },
|
||||
{ k: '系统用途', v: i.systemUse ? '是 —— 叙述性小结 / 汇报生成 / 蒸馏钩子走它' : '否' },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ export const ZM: Readonly<Record<GovZone, { title: string; desc: string; accent:
|
||||
title: '大模型',
|
||||
icon: 'cpu',
|
||||
accent: 'linear-gradient(140deg,#4b4a8c,#2f2e63)',
|
||||
desc: '模型实例的四元绑定(Provider / URL / Key / Model)。活动只声明用途名,换厂商换型号改这里,活动一行不动;密钥只存这儿,不入包、不入留痕。',
|
||||
desc: 'Provider / URL / Key / Model 四元绑定,就在这里配:建实例、改端点换型号、换绑密钥、开停用与系统用途,配完当场连通自检(真发一次最小调用,不通就把厂商原话摆出来)。活动只声明用途名——换厂商换型号不动活动一行代码;密钥只写不读,密文存这儿,不入包、不入留痕。',
|
||||
},
|
||||
system: {
|
||||
title: '系统管理',
|
||||
|
||||
Reference in New Issue
Block a user