接入模型改成右侧抽屉:先挑供应商、再填连接、当场测连通(且只测不存)

上一版是把字段倒在地上:整页宽的内联卡、输入框拉到 1100px、动作钮掉出屏幕。
照模型接入面的通行做法重排 —— 520px 右侧抽屉,头固定 / 身滚动 / 底动作栏常驻:

  ① **供应商**:一排可点的牌子,挑中即预填它的默认端点(没有默认的标「端点自填」);
  ② **连接**:API 端点 + API Key(密码框带显示切换),下面就是「测试连接」;
  ③ **型号与用途**:模型(厂商侧型号名)+ 用途名(活动里声明的那个)+ 两枚开关。
  遮罩点击与 Esc 都关得掉 —— 抽屉盖住整屏,不给键盘出路是把人关在里面。

**默认端点从服务端取**(新 `GET /api/gov/llm/providers`):前端不自带一份 URL 表 ——
那种表迟早与 provider 档案漂移,而漂移那天没人会发现(端点错了只表现为「连不上」)。

**「测试连接」只测不存**(新 `POST /api/gov/llm/probe` + `llm.probeDraft`):
拿表单上正填的四元真发一次最小调用,一个字节不落库;密钥留空则取该实例已存的密文,
改端点、换型号不必重打钥匙。上一版是「先存再试」—— 人只想知道钥匙对不对,
结果端点被改了、实例被建了,点取消也收不回来。实测:改错端点后测试,
库里 instances 逐字节未变(测过 before/after 全等)。

顺带补 `eye-off` 图标。五闸绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-30 01:10:49 +08:00
co-authored by Claude Opus 5
parent 21aea48c6c
commit f9318b1ae9
12 changed files with 422 additions and 76 deletions
@@ -5,18 +5,15 @@
* DOM 结构、类名(`g-*` + 色调 `t-*`)与文案原样保留,mock 常量换成服务端投影(README 平移规则 1/2/4)。
* 区首页只负责导航,治理动作一律落在下钻详情页的**就地确认带**(影响预览三必答)。
*/
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { LlmInstanceView, LlmProvider } from '@contracts';
import { LLM_PROVIDERS, probeLlmInstanceRoute, upsertLlmInstanceRoute } from '@contracts';
import { probeLlmDraftRoute, probeLlmInstanceRoute, upsertLlmInstanceRoute } from '@contracts';
import { call } from '@web-shared/typed-fetch.ts';
import { Ico } from '@web-shared/Ico.tsx';
import { Acts, Card, Chip, Field, NList, NRow, Note, PageHead, QueryGate, Rows, St } from '@web-shared/gov/primitives.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,
} from '@web-shared/gov/gov-api.ts';
import { useGovChannels, useGovLlm, useGovLlmProviders } from '@web-shared/gov/gov-api.ts';
export type HomeProps = { tab: string; nav: Nav };
@@ -75,12 +72,15 @@ const EMPTY_DRAFT: LlmDraft = {
};
/**
* 实例配置表单 —— 四元绑定就在这里改(Provider / 端点 / 型号 / 密钥)+ 两枚开关。
* 接入模型 —— **右侧抽屉**,照模型接入面的通行做法排:
* ① 先挑供应商(一排可点的牌子,选中即预填它的默认端点)
* ② 再填连接(端点 + 密钥,密钥旁边就是「测试连接」—— 填完当场知道通不通)
* ③ 最后是型号与用途。底部一条固定动作栏。
*
* **密钥只写不读**(OS 7.3 凭证隔离):服务端只回「绑没绑」,永不回显明文;
* 所以这一格留空的意思是「保持原值」,不是「清空」。
* 为什么是抽屉不是页内卡:配置是**一次性的岔路**,做完就该回到货架。
* 从前那版把表单摊在页面上,输入框拉到 1100px 宽、动作钮掉出屏幕 —— 那不是表单,是把字段倒在地上。
*/
function LlmForm({
function LlmDrawer({
draft,
onCancel,
onSaved,
@@ -90,10 +90,32 @@ function LlmForm({
onSaved: (saved: LlmInstanceView) => void;
}) {
const [d, setD] = useState<LlmDraft>(draft);
const [showKey, setShowKey] = useState(false);
const [err, setErr] = useState<string | undefined>(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 = d.provider === 'openai-compatible' && d.baseUrl.trim() === '';
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<LlmInstanceView>(upsertLlmInstanceRoute, {
@@ -112,71 +134,139 @@ function LlmForm({
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 (
<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 className="g-drawer" role="dialog" aria-modal="true" aria-label={isNew ? '接入模型' : `配置 ${draft.name}`}>
<button className="g-drawer-veil" aria-label="关闭" onClick={onCancel} />
<div className="g-drawer-p">
<header>
<div>
<b>{isNew ? '接入模型' : `配置 ${draft.name}`}</b>
<span>活动只声明用途名 —— 换厂商、换型号、换密钥都不动活动一行代码</span>
</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(),
},
]}
/>
<button className="x" aria-label="关闭" onClick={onCancel}>
<Ico name="x" s={15} />
</button>
</header>
<div className="g-drawer-b">
<section>
<h4>供应商</h4>
{/* 一排牌子:挑一个,端点自动填它的默认值 —— 接入面的第一步本来就是「谁家的」 */}
<div className="g-picktiles">
{(providers.data?.providers ?? []).map((p) => (
<button
key={p.provider}
className={'g-picktile' + (p.provider === d.provider ? ' on' : '')}
onClick={() => pickProvider(p.provider, p.defaultBaseUrl)}
>
<span className="n">{p.label}</span>
<span className="s">{p.defaultBaseUrl === '' ? '端点自填' : p.provider}</span>
{p.provider === d.provider && <Ico name="circle-check" s={13} />}
</button>
))}
</div>
</section>
<section>
<h4>连接</h4>
<label className="g-fld">
<span>API 端点</span>
<input
className="g-input mono"
value={d.baseUrl}
placeholder={urlNeeded ? '必填,如 https://your-gateway/v1' : '留空取默认端点'}
onChange={(e) => setD({ ...d, baseUrl: e.target.value })}
/>
<small>{urlNeeded ? '这家没有默认端点 —— 不填连不上' : '一般不用改;自建网关或代理时填你自己的地址'}</small>
</label>
<label className="g-fld">
<span>API Key</span>
<span className="g-inwrap">
<input
className="g-input mono"
type={showKey ? 'text' : 'password'}
autoComplete="off"
value={d.apiKey}
placeholder={isNew ? 'sk-…' : '已绑定 · 留空保持原值'}
onChange={(e) => setD({ ...d, apiKey: e.target.value })}
/>
<button className="eye" aria-label={showKey ? '隐藏' : '显示'} onClick={() => setShowKey(!showKey)}>
<Ico name={showKey ? 'eye-off' : 'eye'} s={13} />
</button>
</span>
<small>密文存治理台,永不回显、不入包、不入留痕</small>
</label>
<div className="g-testrow">
<button className="g-btn xs" disabled={test.isPending || d.model.trim() === '' || urlNeeded} onClick={() => test.mutate()}>
<Ico name={test.isPending ? 'hourglass' : 'zap'} s={11} />
{test.isPending ? '测试中…' : '测试连接'}
</button>
{probed !== undefined && (
<span className={'r' + (probed.ok ? ' ok' : ' bad')}>
{probed.ok ? `连通 · ${probed.latencyMs ?? '—'} ms` : (probed.error ?? '不通')}
</span>
)}
</div>
</section>
<section>
<h4>型号与用途</h4>
<label className="g-fld">
<span>模型</span>
<input className="g-input mono" value={d.model} placeholder="如:deepseek-chat" onChange={(e) => setD({ ...d, model: e.target.value })} />
<small>厂商侧的型号名,照厂商文档填</small>
</label>
<label className="g-fld">
<span>用途名</span>
<input className="g-input" value={d.name} placeholder="如:quote-extractor" onChange={(e) => setD({ ...d, name: e.target.value })} />
<small>活动里声明的就是这个名 —— 起个说得清用途的名字,不要写型号</small>
</label>
<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>
<small className="g-hint">系统用途:叙述性小结 / 汇报生成 / 蒸馏钩子走它,计量入系统账</small>
</section>
{err !== undefined && <Note tone="warn">{err}</Note>}
</div>
<footer>
<button className="g-btn" onClick={onCancel}>
取消
</button>
<button className="g-btn primary" disabled={save.isPending || invalid} onClick={() => save.mutate()}>
{save.isPending ? '保存中…' : isNew ? '接入' : '保存'}
</button>
</footer>
</div>
</Card>
</div>
);
}
@@ -298,7 +388,7 @@ function LlmShelf({ nav }: { nav: Nav }) {
<QueryGate query={llm}>
<>
{editing !== null && (
<LlmForm
<LlmDrawer
draft={editing}
onCancel={() => setEditing(null)}
onSaved={() => {
@@ -343,7 +433,7 @@ export function LlmPage({ id }: { id: string }) {
desc="名称间接层:活动只声明自定义模型名,四元绑定在系统侧。换厂商、换端点、换密钥都不动活动一行代码。"
/>
{editing ? (
<LlmForm
<LlmDrawer
draft={draftOf(i)}
onCancel={() => setEditing(false)}
onSaved={() => {