模型实例卡重做:名字不截、密钥不撒谎、不可用就别摆统计;只留 flash 与 pro 两条

三条毛病一次改净:

**① 名字被截断**。用途名是活动里声明的那个名,看不全等于没显示 —— 卡面改成
   40px 图标 + **名字独占一行、折行也不截**(overflow-wrap: anywhere),
   厂商与型号退到第二行小字。

**② 「已绑定」是谎**。种子从前塞一个 `sk-placeholder` 冒充密钥,卡上写着「已绑定」,
   一自检就是 `api key ... is invalid`;抽屉里那格也照「是不是新建」写占位,
   于是没钥匙的实例也显示「已绑定 · 留空保持原值」。现在:
     · 种子**读不到环境密钥就不绑**,如实显示未绑定;
     · 抽屉的占位与判词改看 `keyBound` —— 没绑就写「还没有密钥 —— 不填这条实例调不动」。
   **界面撒谎比缺功能更坏**:缺功能人会去补,撒谎的人会照着假状态做决定。

**③ 不可用还摆一排统计**。没密钥连调都调不起来,「系统用途/端点」那些格没有意义 ——
   未绑定时卡上只剩一句判词 +「去配置」;绑好了才谈端点与「测试」。
   系统用途从卡面撤了(用户:不要管是不是系统用途,两条都能用)。

**只留两条实例**:`deepseek-v4-flash` 与 `deepseek-v4-pro`,都不预设系统用途。
配套两件:
  · `pickSystemUse` 加**回落** —— 没人勾就取第一条能用的(启用且已绑密钥);
    从前非勾不可,没勾那天系统叙述整条哑掉,那是配置疏忽不该变成功能缺失(补了三条测试);
  · 六个活动包里声明的用途名 `quote-extractor` 改指 `deepseek-v4-flash`,各升一个补丁版。

顺带补 `DELETE /api/gov/llm/:id`(卡上 ⋯ 菜单):还有活动声明着这个用途名就拒(409)——
删了那些活动一调模型就是「用途名未绑定」,而人当时以为只是清一条没用的记录。
实测:用它把库里遗留的 quote-extractor 删掉,现在库里就是那两条,卡上如实写「未绑定密钥 · 不可用」。
五闸绿。

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:53:42 +08:00
co-authored by Claude Opus 5
parent fffb46eae7
commit da8ac8f9bc
15 changed files with 276 additions and 80 deletions
@@ -9,7 +9,7 @@ import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { LlmInstanceView, LlmProvider } from '@contracts';
import { probeLlmDraftRoute, probeLlmInstanceRoute, upsertLlmInstanceRoute } from '@contracts';
import { deleteLlmInstanceRoute, 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';
@@ -42,6 +42,8 @@ const hostOf = (url: string): string => {
/** 表单草稿:apiKey 是**只写字段**,留空 = 保持原值(服务端永不回显密钥) */
type LlmDraft = {
id?: string;
/** 这条实例**当前**有没有密钥 —— 占位文案与判词照它写,不照「是不是新建」写 */
keyBound: boolean;
name: string;
provider: LlmProvider;
baseUrl: string;
@@ -53,6 +55,7 @@ type LlmDraft = {
const draftOf = (i: LlmInstanceView): LlmDraft => ({
id: i.id,
keyBound: i.apiKeyBound,
name: i.name,
provider: i.provider,
baseUrl: i.baseUrl,
@@ -63,6 +66,7 @@ const draftOf = (i: LlmInstanceView): LlmDraft => ({
});
const EMPTY_DRAFT: LlmDraft = {
keyBound: false,
name: '',
provider: 'deepseek',
baseUrl: '',
@@ -208,7 +212,7 @@ function LlmDrawer({
<div className="g-fld2">
<label className="g-fld">
<span>用途名</span>
<input className="g-input" value={d.name} placeholder="如:quote-extractor" onChange={(e) => setD({ ...d, name: e.target.value })} />
<input className="g-input" value={d.name} placeholder="如:deepseek-v4-flash" onChange={(e) => setD({ ...d, name: e.target.value })} />
<small>活动里声明的就是这个名</small>
</label>
<label className="g-fld">
@@ -239,14 +243,17 @@ function LlmDrawer({
type={showKey ? 'text' : 'password'}
autoComplete="off"
value={d.apiKey}
placeholder={isNew ? 'sk-…' : '已绑定 · 留空保持原值'}
placeholder={d.keyBound ? '已绑定 · 留空保持原值' : '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>
{/* 没绑就直说没绑:没有钥匙这条实例一次也调不动,不该让人以为只是「可留空」 */}
<small className={d.keyBound ? '' : 'warn'}>
{d.keyBound ? '密文存治理台,永不回显' : '还没有密钥 —— 不填这条实例调不动'}
</small>
</label>
<div className="g-testrow">
<button className="g-btn xs" disabled={test.isPending || d.model.trim() === '' || urlNeeded} onClick={() => test.mutate()}>
@@ -293,65 +300,112 @@ function LlmDrawer({
}
/**
* 一张实例卡 —— 自检状态归它自己:通了报延迟,不通**把厂商原话摆在卡里**。
* 原话不改写:「密钥无效」和「端点不通」是两件完全不同的事,改写成一句「连不上」
* 就等于把要人去修的那条线索抹了(实测:种子里的密钥是占位串,自检当场就说清了)。
* 一张实例卡(2026-07-30 重做)—— 卡面只回答一句话:**这条模型现在能不能用**。
*
* 三处旧毛病一并改掉:
* · 名字被一行截断 —— 名字是这条实例的身份,宁可折行也不许截;
* · 「已绑定」是谎:种子塞过一个占位密钥,卡上写着已绑定,一自检就是 key invalid。
* 现在没钥匙就**不绑**,卡上如实写「未绑定密钥 · 不可用」;
* · 没钥匙的实例还摆一排统计格(系统用途、端点…)—— 连调都调不起来,那些数没意义。
* 未绑定时卡上只留一句判词与一枚「去配置」,绑好了才谈端点与自检。
*/
function LlmCard({ i, nav, onEdit }: { i: LlmInstanceView; nav: Nav; onEdit: () => void }) {
function LlmCard({ i, nav, onEdit, onDropped }: { i: LlmInstanceView; nav: Nav; onEdit: () => void; onDropped: () => void }) {
const [out, setOut] = useState<{ ok: boolean; latencyMs?: number; error?: string } | undefined>(undefined);
const [menu, setMenu] = useState<'none' | 'open' | 'drop'>('none');
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 ?? '自检失败' }),
});
const drop = useMutation({
mutationFn: () => call<{ ok: boolean }>(deleteLlmInstanceRoute, { params: { id: i.id } }),
onSuccess: () => {
setMenu('none');
onDropped();
},
onError: (e: unknown) => {
setOut({ ok: false, error: (e as { message?: string }).message ?? '删不掉' });
setMenu('none');
},
});
const usable = i.apiKeyBound && i.enabled;
return (
<div className={'g-fcard' + (i.enabled ? '' : ' off')}>
<div className={'g-mcard' + (usable ? '' : ' idle')}>
<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} />
<Ico name="cpu" s={18} />
</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>
{/* 名字折行也不截 —— 它是活动里声明的那个名,看不全等于没显示 */}
<b className="nm">{i.name}</b>
<button
className="mbtn"
aria-label="连通自检"
title="连通自检:真发一次最小调用"
disabled={probe.isPending}
onClick={() => probe.mutate()}
className={'mbtn' + (menu !== 'none' ? ' on' : '')}
aria-label="更多"
onClick={() => setMenu(menu === 'none' ? 'open' : 'none')}
>
<Ico name={probe.isPending ? 'hourglass' : 'zap'} s={13} />
</button>
<button className="mbtn" aria-label="配置" title="配置" onClick={onEdit}>
<Ico name="pen-line" s={13} />
<Ico name="more-horizontal" s={14} />
</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 className="meta">
<span className="prov">{PROVIDER_LABEL[i.provider]}</span>
<span className="mdl">{i.model}</span>
</div>
{usable ? (
<div className="bar">
<span className="host" title={i.baseUrl}>
{hostOf(i.baseUrl)}
</span>
<button className="tbtn" disabled={probe.isPending} onClick={() => probe.mutate()}>
<Ico name={probe.isPending ? 'hourglass' : 'zap'} s={11} />
{probe.isPending ? '测试中' : '测试'}
</button>
</div>
) : (
/* 不可用就别摆统计:一句判词 + 一条去路,比四个假数字有用 */
<div className="bar todo">
<span>{i.apiKeyBound ? '已停用' : '未绑定密钥 · 不可用'}</span>
<button className="tbtn go" onClick={onEdit}>
去配置
</button>
</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>
)}
{menu !== 'none' && (
<div className="menu">
{menu === 'drop' ? (
<>
<span className="q">删掉这条实例?活动里声明了它的名就删不掉</span>
<button onClick={() => setMenu('open')}>算了</button>
<button className="bad" disabled={drop.isPending} onClick={() => drop.mutate()}>
删除
</button>
</>
) : (
<>
<button
onClick={() => {
setMenu('none');
onEdit();
}}
>
<Ico name="pen-line" s={12} /> 配置
</button>
<button className="bad" onClick={() => setMenu('drop')}>
<Ico name="trash-2" s={12} /> 删除
</button>
</>
)}
</div>
)}
</div>
);
}
@@ -421,11 +475,17 @@ function LlmShelf({ nav }: { nav: Nav }) {
)}
<div className="g-shelf">
{instances.map((i) => (
<LlmCard key={i.id} i={i} nav={nav} onEdit={() => setEditing(draftOf(i))} />
<LlmCard
key={i.id}
i={i}
nav={nav}
onEdit={() => setEditing(draftOf(i))}
onDropped={() => void queryClient.invalidateQueries({ queryKey: ['gov', 'llm'] })}
/>
))}
<button className="g-fcard ghost" onClick={() => setEditing({ ...EMPTY_DRAFT })}>
<button className="g-mcard ghost" onClick={() => setEditing({ ...EMPTY_DRAFT })}>
<Ico name="plus" s={18} />
新建模型实例
接入模型
</button>
</div>
</>