feat(gateway,admin-web): 模型目录自动化 —— GET /admin/llm/catalog(裁决 03.4.2 次件)
- 新增 GET /api/v1/admin/llm/catalog(owner|admin,?refresh=1 强制刷新):抓 models.dev api.json 并裁剪 (226 供应商 / 8392 模型 → 1.31 MB),写落盘快照 data/llm_catalog.json(无凭据,TTL 24h,gitignore) - 降级诚实:快照优先 → 在线刷新 → stale 快照兜底(标 stale+warning)→ 都不行回 503 catalog_unavailable, 绝不编造;抓取只发生在该端点内,**不进运行时热路径** - 前端:新增 llm/LlmCatalogPanel(在线目录 + 粘贴导入落同一份候选 → 一键带出 baseURL/model id/multimodal/ maxTokens);Llm.tsx 挂载面板、删掉硬编码的「主→备→本地」假状态、表单按 id#seq 重挂 - 诚实性:面板把「加载中 / 端点未实现 / 抓取失败或离线 / 响应结构不认识 / 目录为空」分成五种独立状态, 全程无 mock/sample 回退 - 复核:新增 9 条零外网用例全绿;**变异**(Stale:true→false / TS 只认 camelCase)各自红后已还原; Go 真产出与 TS 解析器双向实测 8392 模型能力位零差异 - 越界说明:routes.go 仅 +3 行(注册该路由);llm/LlmCatalogPanel.tsx 为拆出以守住 Llm.tsx ≤500 行
This commit is contained in:
1 parent
b8fa53e43c
commit
63e5b70e3d
7 files changed
+1320
-37
No files matched your search
@@ -6,6 +6,7 @@ import { useAuditStore } from "../audit";
|
||||
import { Notice, PageHeader } from "../components";
|
||||
import { Button, EmptyState, Surface, Tag } from "../../components/primitives";
|
||||
import { Icon } from "../../components/Icon";
|
||||
import { CatalogPanel } from "./llm/LlmCatalogPanel";
|
||||
import { LlmProviderForm } from "./llm/LlmProviderForm";
|
||||
|
||||
/** 一个供应商分组行:身份 + 当前 token 预算 + 测试/编辑动作。 */
|
||||
@@ -86,7 +87,7 @@ function ProviderList({
|
||||
<EmptyState
|
||||
icon={<Icon name="robot" size={32} />}
|
||||
title="还没有配置 LLM 供应商"
|
||||
hint="用下方表单新增第一个供应商 / 模型分组。"
|
||||
hint="用下方目录候选或表单新增第一个供应商 / 模型分组。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -100,6 +101,69 @@ function ProviderList({
|
||||
);
|
||||
}
|
||||
|
||||
/** 一次真实模型探测(POST /admin/llm/test):结果与错误都写进页面顶部的提示区,
|
||||
* 成功/失败各记一条前端动作日志。它是真实往返,绝不假装成功。 */
|
||||
function runLlmTest(
|
||||
p: AdminLlmProvider,
|
||||
log: (what: string, result: "ok" | "error") => void,
|
||||
setBusy: (v: boolean) => void,
|
||||
setFail: (v: string | null) => void,
|
||||
setResult: (v: string | null) => void
|
||||
): void {
|
||||
setBusy(true);
|
||||
setFail(null);
|
||||
setResult(null);
|
||||
adminTestLlm({ provider: p.provider || "openai", baseUrl: p.baseUrl, model: p.model })
|
||||
.then((r) => {
|
||||
log("llm.test", "ok");
|
||||
setResult(`【${p.name}】模型 ${r.model} 响应:${r.reply}(${r.tokens} tokens)`);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
log("llm.test", "error");
|
||||
setFail(e instanceof Error ? e.message : "模型测试失败");
|
||||
})
|
||||
.finally(() => setBusy(false));
|
||||
}
|
||||
|
||||
/** 页面标题 + 测试结果 / 失败提示。 */
|
||||
function LlmPageStatus({ fail, result }: { fail: string | null; result: string | null }): React.ReactNode {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="LLM 模型"
|
||||
subtitle="供应商 + 模型配置(base/key/多模态检测/token 预算) + 测试(真实调用) + fallback + 按角色路由 + 用量"
|
||||
/>
|
||||
{fail ? <Notice tone="err">{fail}</Notice> : null}
|
||||
{result ? <Notice tone="ok">{result}</Notice> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 生效链:只画接口真的返回过的分组与它们的备选模型,不臆造本地第三级
|
||||
* 或 per-persona 路由的运行态(那两个由后端配置决定,本端点不报告)。 */
|
||||
function FallbackChain({ providers }: { providers: AdminLlmProvider[] }): React.ReactNode {
|
||||
return (
|
||||
<Surface className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">生效链(来自接口返回的真实分组)</h3>
|
||||
{providers.length === 0 ? (
|
||||
<p className="text-xs text-[var(--mu-text-lo)]">暂无分组:上面保存一个之后,这里会显示它的主/备链。</p>
|
||||
) : (
|
||||
providers.map((p) => (
|
||||
<div key={p.id} className="flex flex-wrap items-center gap-3 text-sm">
|
||||
<Tag tone={p.enabled ? "ok" : "neutral"}>{p.enabled ? "生效组" : "备用组"}</Tag>
|
||||
<span className="font-mono text-xs text-[var(--mu-text-lo)]">{p.model}</span>
|
||||
<span className="text-[var(--mu-text-mid)]">→ {p.fallback || "无备选模型"}</span>
|
||||
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">接口报错自动降级</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<Notice>
|
||||
本地模型第三级与 per-persona 路由由后端开关/配置决定,本端点不报告它们的运行态,因此这里不显示,也不臆造。
|
||||
</Notice>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
export function LlmModule(): React.ReactNode {
|
||||
const { data, error, loading, reload } = useAsyncData(adminGetLlm, []);
|
||||
const log = useAuditStore((s) => s.log);
|
||||
@@ -109,36 +173,21 @@ export function LlmModule(): React.ReactNode {
|
||||
const providers = data?.providers ?? [];
|
||||
|
||||
const [editing, setEditing] = useState<AdminLlmProvider | null>(null);
|
||||
// 同一分组 id 连续填入不同候选时也必须重挂表单(表单按 key 取初值),
|
||||
// 否则第二次填入不会生效。
|
||||
const [draftSeq, setDraftSeq] = useState(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [fail, setFail] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
|
||||
const test = (p: AdminLlmProvider) => {
|
||||
setBusy(true);
|
||||
setFail(null);
|
||||
setResult(null);
|
||||
const providerId = p.provider || "openai";
|
||||
adminTestLlm({ provider: providerId, baseUrl: p.baseUrl, model: p.model })
|
||||
.then((r) => {
|
||||
log("llm.test", "ok");
|
||||
setResult(`【${p.name}】模型 ${r.model} 响应:${r.reply}(${r.tokens} tokens)`);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
log("llm.test", "error");
|
||||
setFail(e instanceof Error ? e.message : "模型测试失败");
|
||||
})
|
||||
.finally(() => setBusy(false));
|
||||
const edit = (p: AdminLlmProvider | null) => {
|
||||
setEditing(p);
|
||||
setDraftSeq((n) => n + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title="LLM 模型"
|
||||
subtitle="供应商 + 模型配置(base/key/多模态检测/token 预算) + 测试(真实调用) + fallback + 按角色路由 + 用量"
|
||||
/>
|
||||
|
||||
{fail ? <Notice tone="err">{fail}</Notice> : null}
|
||||
{result ? <Notice tone="ok">{result}</Notice> : null}
|
||||
<LlmPageStatus fail={fail} result={result} />
|
||||
|
||||
<Surface className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">供应商 / 模型</h3>
|
||||
@@ -148,13 +197,20 @@ export function LlmModule(): React.ReactNode {
|
||||
error={error}
|
||||
unimplemented={unimplemented}
|
||||
busy={busy}
|
||||
onTest={test}
|
||||
onEdit={setEditing}
|
||||
onTest={(p) => runLlmTest(p, log, setBusy, setFail, setResult)}
|
||||
onEdit={(p) => edit(p)}
|
||||
/>
|
||||
</Surface>
|
||||
|
||||
<CatalogPanel
|
||||
onPick={(draft) => {
|
||||
log("llm.catalog.fill", "ok");
|
||||
edit(draft);
|
||||
}}
|
||||
/>
|
||||
|
||||
<LlmProviderForm
|
||||
key={editing?.id ?? "new"}
|
||||
key={editing ? `${editing.id}#${draftSeq}` : `new#${draftSeq}`}
|
||||
initial={editing}
|
||||
busy={busy}
|
||||
setBusy={setBusy}
|
||||
@@ -164,18 +220,11 @@ export function LlmModule(): React.ReactNode {
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
|
||||
<Surface className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">按角色路由 + 降级链</h3>
|
||||
{["owner", "admin", "viewer"].map((role) => (
|
||||
<div key={role} className="flex items-center gap-3 text-sm">
|
||||
<Tag tone="neutral">{role}</Tag>
|
||||
<span className="text-[var(--mu-text-mid)]">主 → 备 → 本地</span>
|
||||
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">接口报错自动降级</span>
|
||||
</div>
|
||||
))}
|
||||
</Surface>
|
||||
<FallbackChain providers={providers} />
|
||||
|
||||
<Notice>per-role 路由 + 降级逻辑由后端 loop 实现;面板保存即写入 models.json 并热更新运行中的 LLM provider(无需重启,design/04 §3.1)。.env 为兜底。每组 maxTokens 留空 = 保持原值 / 未设时回落 LLM_FALLBACK_MAX_TOKENS。</Notice>
|
||||
<Notice>
|
||||
per-role 路由 + 降级逻辑由后端 loop 实现;面板保存即写入 models.json 并热更新运行中的 LLM provider(无需重启,design/04 §3.1)。.env 为兜底。每组 maxTokens 留空 = 保持原值 / 未设时回落 LLM_FALLBACK_MAX_TOKENS。目录候选来自 models.dev,由后端缓存成快照(离线仍可用),绝不进模型运行时热路径。
|
||||
</Notice>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
import { useState } from "react";
|
||||
import { useAsyncData } from "../../../lib/useAsyncData";
|
||||
import { isNotImplemented } from "../../../lib/api";
|
||||
// 目录端点是本页新引入的调用;按仓库惯例它本该落在 lib/api/admin.ts 里,
|
||||
// 但本批次只允许改 Llm.tsx(+ 本文件),因此经 lib/api 的传输层直接调用
|
||||
// (信封解包 / 401 处理与其余端点一致)。
|
||||
import { request } from "../../../lib/api/core";
|
||||
import type { AdminLlmProvider } from "../../../lib/types";
|
||||
import { Notice } from "../../components";
|
||||
import { Button, Surface, Tag } from "../../../components/primitives";
|
||||
|
||||
/** 目录候选一次最多渲染多少行(其余用一行文字如实说明还有多少条)。 */
|
||||
const CATALOG_MAX_ROWS = 40;
|
||||
|
||||
/** 目录里的一个候选模型:只保留面板用得上的能力位。 */
|
||||
interface CatalogModel {
|
||||
id: string;
|
||||
name: string;
|
||||
/** 接受图片/文件输入 → 表单的 multimodal。 */
|
||||
attachment: boolean;
|
||||
reasoning: boolean;
|
||||
toolCall: boolean;
|
||||
contextLimit: number;
|
||||
outputLimit: number;
|
||||
}
|
||||
|
||||
/** 目录里的一个供应商:baseURL + 模型清单 + 申请 key 的提示。 */
|
||||
interface CatalogProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
api: string;
|
||||
doc: string;
|
||||
env: string[];
|
||||
npm: string;
|
||||
models: CatalogModel[];
|
||||
}
|
||||
|
||||
/** 后端裁剪形状与 models.dev 原始形状字段名不同(toolCall vs tool_call、
|
||||
* 嵌套 limit 等),但两条来源(在线目录 / 手动粘贴)必须落到同一份候选上,
|
||||
* 因此下面的解析同时接受两种拼写,缺失字段一律取保守默认值。 */
|
||||
function asRecord(v: unknown): Record<string, unknown> | null {
|
||||
return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function str(v: unknown): string {
|
||||
return typeof v === "string" ? v : "";
|
||||
}
|
||||
|
||||
function num(v: unknown): number {
|
||||
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
||||
}
|
||||
|
||||
function strList(v: unknown): string[] {
|
||||
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
|
||||
}
|
||||
|
||||
/** 解析一个模型条目;modelId 来自外层键名,仅在条目自报 id 缺失时兜底。 */
|
||||
function parseCatalogModel(raw: unknown, modelId: string): CatalogModel | null {
|
||||
const obj = asRecord(raw);
|
||||
if (!obj) return null;
|
||||
const id = str(obj.id) || modelId;
|
||||
if (!id) return null;
|
||||
const limit = asRecord(obj.limit);
|
||||
return {
|
||||
id,
|
||||
name: str(obj.name) || id,
|
||||
attachment: obj.attachment === true,
|
||||
reasoning: obj.reasoning === true,
|
||||
toolCall: obj.toolCall === true || obj.tool_call === true,
|
||||
contextLimit: num(obj.contextLimit) || (limit ? num(limit.context) : 0),
|
||||
outputLimit: num(obj.outputLimit) || (limit ? num(limit.output) : 0),
|
||||
};
|
||||
}
|
||||
|
||||
/** 解析模型清单:后端给数组,models.dev 给「id → 模型」映射。 */
|
||||
function parseCatalogModels(raw: unknown): CatalogModel[] {
|
||||
const out: CatalogModel[] = [];
|
||||
const push = (v: unknown, key: string): void => {
|
||||
const parsed = parseCatalogModel(v, key);
|
||||
if (parsed) out.push(parsed);
|
||||
};
|
||||
if (Array.isArray(raw)) {
|
||||
raw.forEach((m) => push(m, ""));
|
||||
} else {
|
||||
const map = asRecord(raw);
|
||||
if (map) Object.keys(map).forEach((key) => push(map[key], key));
|
||||
}
|
||||
return out.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
||||
}
|
||||
|
||||
/** 解析一个供应商条目;providerId 是外层键名兜底。没有模型清单的供应商
|
||||
* 给不出候选,直接丢弃(面板不显示点了没用的行)。 */
|
||||
function parseCatalogProvider(raw: unknown, providerId: string): CatalogProvider | null {
|
||||
const obj = asRecord(raw);
|
||||
if (!obj) return null;
|
||||
const id = str(obj.id) || providerId;
|
||||
const models = parseCatalogModels(obj.models);
|
||||
if (!id || models.length === 0) return null;
|
||||
return {
|
||||
id,
|
||||
name: str(obj.name) || id,
|
||||
api: str(obj.api),
|
||||
doc: str(obj.doc),
|
||||
env: strList(obj.env),
|
||||
npm: str(obj.npm),
|
||||
models,
|
||||
};
|
||||
}
|
||||
|
||||
/** 把任意一层结构解析成供应商候选:接受 `{providers:[…]}`(后端形状)、
|
||||
* `[…]`(数组)与 `{providerId:{…}}`(models.dev 的 api.json 形状)。
|
||||
* 结构完全不认识时抛错,由调用方按「诚实错误」渲染,而不是当空目录。 */
|
||||
function parseCatalogProviders(raw: unknown, source: string): CatalogProvider[] {
|
||||
const out: CatalogProvider[] = [];
|
||||
const push = (v: unknown, key: string): void => {
|
||||
const parsed = parseCatalogProvider(v, key);
|
||||
if (parsed) out.push(parsed);
|
||||
};
|
||||
if (Array.isArray(raw)) {
|
||||
raw.forEach((item) => push(item, ""));
|
||||
return out;
|
||||
}
|
||||
const obj = asRecord(raw);
|
||||
if (!obj) throw new Error(`${source}:顶层既不是对象也不是数组`);
|
||||
if (Array.isArray(obj.providers)) {
|
||||
obj.providers.forEach((item) => push(item, ""));
|
||||
return out;
|
||||
}
|
||||
Object.keys(obj).forEach((key) => push(obj[key], key));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 离线路径:解析粘贴进来的 models.dev api.json(或单个供应商片段)。 */
|
||||
function parsePastedCatalog(text: string): CatalogProvider[] {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) throw new Error("粘贴内容为空");
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(trimmed) as unknown;
|
||||
} catch (e: unknown) {
|
||||
throw new Error(`粘贴内容不是合法 JSON:${e instanceof Error ? e.message : "解析失败"}`);
|
||||
}
|
||||
return parseCatalogProviders(raw, "粘贴内容");
|
||||
}
|
||||
|
||||
/** 合并在线目录与粘贴候选:同一供应商以主人粘贴的那份为准。 */
|
||||
function mergeProviders(fetched: CatalogProvider[], pasted: CatalogProvider[]): CatalogProvider[] {
|
||||
const byId = new Map<string, CatalogProvider>();
|
||||
fetched.forEach((p) => byId.set(p.id, p));
|
||||
pasted.forEach((p) => byId.set(p.id, p));
|
||||
return [...byId.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
||||
}
|
||||
|
||||
/** 从目录的 SDK 包名/供应商 id 推断 wire 协议:只认能明确判定的,
|
||||
* 其余按 OpenAI 兼容(面板仍可手改成 anthropic)。 */
|
||||
function protocolFromCatalog(p: CatalogProvider): string {
|
||||
return /anthropic/i.test(p.npm) || /anthropic/i.test(p.id) ? "anthropic" : "openai";
|
||||
}
|
||||
|
||||
/** 目录候选 → 表单草稿。分组 id 取目录 id,重复填入即幂等更新同名分组;
|
||||
* maxTokens 建议值取模型的输出上限(目录真值,没有任何猜测成分)。 */
|
||||
function draftFromCatalog(p: CatalogProvider, m: CatalogModel): AdminLlmProvider {
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
provider: protocolFromCatalog(p),
|
||||
baseUrl: p.api,
|
||||
model: m.id,
|
||||
multimodal: m.attachment,
|
||||
enabled: false,
|
||||
maxTokens: m.outputLimit || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** 目录里的一行候选:能力位如实来自目录,点「填入表单」变成表单草稿。 */
|
||||
function CatalogModelRow({
|
||||
provider,
|
||||
model,
|
||||
onPick,
|
||||
}: {
|
||||
provider: CatalogProvider;
|
||||
model: CatalogModel;
|
||||
onPick: () => void;
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2">
|
||||
<span className="text-sm font-medium">{model.name}</span>
|
||||
<span className="font-mono text-xs text-[var(--mu-text-lo)]">{model.id}</span>
|
||||
{model.attachment ? <Tag tone="accent">多模态</Tag> : null}
|
||||
{model.reasoning ? <Tag tone="neutral">推理</Tag> : null}
|
||||
{model.toolCall ? <Tag tone="neutral">工具</Tag> : null}
|
||||
{model.outputLimit ? <Tag tone="neutral">{`输出上限 ${model.outputLimit}`}</Tag> : null}
|
||||
{provider.api ? null : <Tag tone="warn">目录未标 baseURL</Tag>}
|
||||
<span className="ml-auto">
|
||||
<Button size="sm" variant="ghost" onClick={onPick}>
|
||||
填入表单
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 候选列表(按供应商 + 关键词过滤)。渲染条数有上限,超出部分用文字说明,
|
||||
* 不做任何截断式伪数据。 */
|
||||
function CatalogList({
|
||||
providers,
|
||||
query,
|
||||
providerId,
|
||||
onPick,
|
||||
}: {
|
||||
providers: CatalogProvider[];
|
||||
query: string;
|
||||
providerId: string;
|
||||
onPick: (draft: AdminLlmProvider) => void;
|
||||
}): React.ReactNode {
|
||||
const q = query.trim().toLowerCase();
|
||||
const rows: { provider: CatalogProvider; model: CatalogModel }[] = [];
|
||||
providers.forEach((provider) => {
|
||||
if (providerId && provider.id !== providerId) return;
|
||||
provider.models.forEach((model) => {
|
||||
if (q && !`${provider.id} ${provider.name} ${model.id} ${model.name}`.toLowerCase().includes(q)) return;
|
||||
rows.push({ provider, model });
|
||||
});
|
||||
});
|
||||
if (rows.length === 0) {
|
||||
return <p className="text-xs text-[var(--mu-text-lo)]">没有匹配的模型:换个关键词,或清空供应商过滤。</p>;
|
||||
}
|
||||
const shown = rows.slice(0, CATALOG_MAX_ROWS);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{shown.map(({ provider, model }) => (
|
||||
<CatalogModelRow
|
||||
key={`${provider.id}/${model.id}`}
|
||||
provider={provider}
|
||||
model={model}
|
||||
onPick={() => onPick(draftFromCatalog(provider, model))}
|
||||
/>
|
||||
))}
|
||||
{rows.length > shown.length ? (
|
||||
<p className="text-xs text-[var(--mu-text-lo)]">
|
||||
匹配 {rows.length} 个模型,已显示前 {shown.length} 个;用关键词缩小范围。
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 离线路径:粘贴导入。后端目录拿不到(断网/未接线)时候选照样能落地,
|
||||
* 解析失败只影响这一栏,绝不碰表单里已填的内容。 */
|
||||
function PasteImport({ onParsed }: { onParsed: (providers: CatalogProvider[]) => void }): React.ReactNode {
|
||||
const [text, setText] = useState("");
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const submit = () => {
|
||||
try {
|
||||
const parsed = parsePastedCatalog(text);
|
||||
if (parsed.length === 0) {
|
||||
setErr("粘贴内容里没有可用供应商(需要 id + 至少一个模型)");
|
||||
return;
|
||||
}
|
||||
setErr(null);
|
||||
onParsed(parsed);
|
||||
} catch (e: unknown) {
|
||||
setErr(e instanceof Error ? e.message : "粘贴内容解析失败");
|
||||
}
|
||||
};
|
||||
return (
|
||||
<details className="rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2">
|
||||
<summary className="cursor-pointer text-xs text-[var(--mu-text-mid)]">
|
||||
离线路径:粘贴 models.dev 的 api.json(或单个供应商片段)
|
||||
</summary>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
rows={4}
|
||||
placeholder='{"deepseek":{"id":"deepseek","api":"https://api.deepseek.com","models":{"deepseek-v4-pro":{"id":"deepseek-v4-pro","attachment":false}}}}'
|
||||
className="mt-2 w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-3)] px-3 py-2 font-mono text-xs outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
|
||||
/>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" variant="ghost" disabled={!text.trim()} onClick={submit}>
|
||||
解析粘贴内容
|
||||
</Button>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">解析失败只影响这一栏,不会动表单里已填的内容。</span>
|
||||
</div>
|
||||
{err ? <Notice tone="err">{err}</Notice> : null}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
/** 目录面板的头部与状态行:加载中 / 端点未实现 / 抓取失败 / 解析失败 / 目录为空
|
||||
* 各自独立且诚实,绝不用一句「暂无可选」把不同原因糊在一起。 */
|
||||
function CatalogStatus({
|
||||
loading,
|
||||
error,
|
||||
hasData,
|
||||
unimplemented,
|
||||
parseErr,
|
||||
empty,
|
||||
force,
|
||||
onForce,
|
||||
}: {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
hasData: boolean;
|
||||
unimplemented: boolean;
|
||||
parseErr: string | null;
|
||||
empty: boolean;
|
||||
force: boolean;
|
||||
onForce: () => void;
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">从模型目录自动化填入</h3>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">
|
||||
目录候选(models.dev)自动带出 baseURL / 模型 id / 多模态 / 输出上限,省去手抄
|
||||
</span>
|
||||
<Button size="sm" variant="ghost" className="ml-auto" disabled={loading} onClick={onForce}>
|
||||
{force ? "再强制在线刷新" : "强制在线刷新"}
|
||||
</Button>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className="text-xs text-[var(--mu-text-lo)]">正在读取目录(首次会去 models.dev 抓一次,可能较慢)…</p>
|
||||
) : null}
|
||||
{hasData && error ? <Notice tone="err">刷新目录失败,下面是上次拿到的候选:{error}</Notice> : null}
|
||||
{!hasData && unimplemented ? (
|
||||
<Notice tone="warn">
|
||||
后端未提供 GET /api/v1/admin/llm/catalog(404/501):拿不到在线候选。仍可用下面的「粘贴导入」离线喂入目录。
|
||||
</Notice>
|
||||
) : null}
|
||||
{!hasData && !unimplemented && error ? (
|
||||
<Notice tone="err">目录不可用(后端不可达、离线且无本地快照,或抓取失败):{error}</Notice>
|
||||
) : null}
|
||||
{parseErr ? <Notice tone="err">{parseErr}</Notice> : null}
|
||||
{empty ? <Notice tone="warn">目录为空:接口返回了空候选列表。用下面的粘贴导入,或直接手填表单。</Notice> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 目录过滤条:关键词 + 供应商。 */
|
||||
function CatalogFilters({
|
||||
providers,
|
||||
query,
|
||||
providerId,
|
||||
onQuery,
|
||||
onProvider,
|
||||
}: {
|
||||
providers: CatalogProvider[];
|
||||
query: string;
|
||||
providerId: string;
|
||||
onQuery: (q: string) => void;
|
||||
onProvider: (id: string) => void;
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => onQuery(e.target.value)}
|
||||
placeholder="搜索供应商 / 模型"
|
||||
className="w-56 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-1.5 text-xs outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
|
||||
/>
|
||||
<select
|
||||
value={providerId}
|
||||
onChange={(e) => onProvider(e.target.value)}
|
||||
className="rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-1.5 text-xs outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
|
||||
>
|
||||
<option value="">全部供应商({providers.length})</option>
|
||||
{providers.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}({p.models.length})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">填入 = 用候选重建下方表单;动表单前请先保存已填内容</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 目录面板:把「在线目录(后端快照 + 离线兜底)」与「粘贴导入」汇成同一份候选。
|
||||
* 任何失败都只影响本栏,绝不编造候选、也绝不改动表单内容。 */
|
||||
export function CatalogPanel({ onPick }: { onPick: (draft: AdminLlmProvider) => void }): React.ReactNode {
|
||||
// 第一次点「强制在线刷新」起,请求带上 ?refresh=1(再次点击即再刷一次)。
|
||||
const [refreshSeq, setRefreshSeq] = useState(0);
|
||||
const force = refreshSeq > 0;
|
||||
const { data, error, loading } = useAsyncData(
|
||||
() => request<unknown>(force ? "/admin/llm/catalog?refresh=1" : "/admin/llm/catalog"),
|
||||
[refreshSeq]
|
||||
);
|
||||
const [pasted, setPasted] = useState<CatalogProvider[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [providerId, setProviderId] = useState("");
|
||||
|
||||
let fetched: CatalogProvider[] = [];
|
||||
let parseErr: string | null = null;
|
||||
if (data !== null) {
|
||||
try {
|
||||
fetched = parseCatalogProviders(data, "目录响应");
|
||||
} catch (e: unknown) {
|
||||
parseErr = e instanceof Error ? e.message : "目录响应无法解析";
|
||||
}
|
||||
}
|
||||
const providers = mergeProviders(fetched, pasted);
|
||||
const selected = providers.find((p) => p.id === providerId);
|
||||
const empty = !loading && !error && !parseErr && providers.length === 0;
|
||||
|
||||
return (
|
||||
<Surface className="space-y-3">
|
||||
<CatalogStatus
|
||||
loading={loading}
|
||||
error={error}
|
||||
hasData={data !== null}
|
||||
unimplemented={isNotImplemented(error)}
|
||||
parseErr={parseErr}
|
||||
empty={empty}
|
||||
force={force}
|
||||
onForce={() => setRefreshSeq((n) => n + 1)}
|
||||
/>
|
||||
<PasteImport onParsed={(list) => setPasted(list)} />
|
||||
{providers.length > 0 ? (
|
||||
<CatalogFilters
|
||||
providers={providers}
|
||||
query={query}
|
||||
providerId={providerId}
|
||||
onQuery={setQuery}
|
||||
onProvider={setProviderId}
|
||||
/>
|
||||
) : null}
|
||||
{selected ? (
|
||||
<p className="text-xs text-[var(--mu-text-lo)]">
|
||||
{selected.name}:{selected.api ? `baseURL ${selected.api}` : "目录未标 baseURL,填入后请手填"}
|
||||
{selected.env.length ? ` · 官方 key 变量 ${selected.env.join("/")}` : ""}
|
||||
{selected.doc ? ` · 文档 ${selected.doc}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
{providers.length > 0 ? (
|
||||
<CatalogList providers={providers} query={query} providerId={providerId} onPick={onPick} />
|
||||
) : null}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user