Archived
模型实例卡重做:名字不截、密钥不撒谎、不可用就别摆统计;只留 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:
@@ -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>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user