import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminGetLlm, adminListPersonas, adminTestLlm, isNotImplemented } from "../../lib/api";
import type { AdminLlmProvider } from "../../lib/types";
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";
import { LlmResetFromEnv } from "./llm/LlmResetFromEnv";
import type { CatalogHint } from "./llm/LlmProviderRouting";
/** 一个供应商分组行:身份 + token 预算 + 上下文窗口 + 推理形状 + 路由 + 测试/编辑动作。 */
function ProviderRow({
p,
busy,
onTest,
onEdit,
}: {
p: AdminLlmProvider;
busy: boolean;
onTest: (p: AdminLlmProvider) => void;
onEdit: (p: AdminLlmProvider) => void;
}): React.ReactNode {
const routes = Object.keys(p.personaModels ?? {}).length;
return (
{p.name}
{p.model}
{p.baseUrl || "—"}
{p.maxTokens ? `maxTokens ${p.maxTokens}` : "maxTokens 默认"}
{p.contextWindow ? `窗口 ${p.contextWindow}` : "窗口未声明"}
{`推理 ${p.reasoning?.kind || "未声明"}`}
{routes > 0 ? {`按角色路由 ${routes}`} : null}
{p.enabled ? "启用" : "停用"}
{p.multimodal ? "主模型可收图" : "主模型不收图"}
);
}
interface ProviderListProps {
providers: AdminLlmProvider[];
loading: boolean;
error: string | null;
/** 后端明确表示未提供该端点(404/501)—— 与「请求失败」「真为空」是三种状态。 */
unimplemented: boolean;
busy: boolean;
onTest: (p: AdminLlmProvider) => void;
onEdit: (p: AdminLlmProvider) => void;
}
/** 供应商列表:把「加载中 / 端点未实现 / 请求失败 / 真为空 / 有数据」分开渲染。
* 每种状态都只反映接口的真实结果,绝不回退到示例供应商。 */
function ProviderList({
providers,
loading,
error,
unimplemented,
busy,
onTest,
onEdit,
}: ProviderListProps): React.ReactNode {
if (loading) {
return 加载中…
;
}
if (providers.length === 0) {
if (unimplemented) {
return (
后端未提供 GET /api/v1/admin/llm(404/501):配置读不到。本页只渲染接口返回的真实数据,不显示任何示例供应商。
);
}
if (error) {
return (
加载 LLM 配置失败(后端不可达或接口报错):{error} —— 这是错误态,不是空配置。
);
}
return (
}
title="还没有配置 LLM 供应商"
hint="用下方目录候选或表单新增第一个供应商 / 模型分组。"
/>
);
}
return (
{error ?
刷新失败,下方仍是上次成功读取的配置:{error} : null}
{providers.map((r) => (
))}
);
}
/** 一次真实模型探测(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 (
<>
{fail ? {fail} : null}
{result ? {result} : null}
>
);
}
/** 一个分组已配置的 per-persona 路由(接口返回的配置真值)。两张表取并集,
* 使「只声明了协议没声明模型」的死配置也照实显示,而不是看不见。 */
function PersonaRouteList({ p }: { p: AdminLlmProvider }): React.ReactNode {
const providers = p.personaProviders ?? {};
const models = p.personaModels ?? {};
const personas = [...new Set([...Object.keys(models), ...Object.keys(providers)])].sort();
if (personas.length === 0) return null;
return (
按角色路由:
{personas
.map((id) => `${id} → ${models[id] || "(未配模型,不生效)"}${providers[id] ? `(协议 ${providers[id]})` : ""}`)
.join(",")}
);
}
/** 生效链:只画接口真的返回过的分组、它们的备选模型与已配置的 per-persona 路由;
* 本地第三级由后端开关决定、本端点不报告其运行态,因此不臆造。 */
function FallbackChain({ providers }: { providers: AdminLlmProvider[] }): React.ReactNode {
return (
生效链与按角色路由(来自接口返回的真实分组)
{providers.length === 0 ? (
暂无分组:上面保存一个之后,这里会显示它的主/备链。
) : (
providers.map((p) => (
{p.enabled ? "生效组" : "备用组"}
{p.model}
→ {p.fallback || "无备选模型"}
接口报错自动降级
))
)}
本地模型第三级由后端开关与本地组配置决定,本端点不报告它的运行态,因此这里不显示,也不臆造。
);
}
export function LlmModule(): React.ReactNode {
const { data, error, loading, reload } = useAsyncData(adminGetLlm, []);
// 角色清单只作候选提示:取不到也不阻塞编辑(persona id 可手填)。
const personas = useAsyncData(adminListPersonas, []);
const log = useAuditStore((s) => s.log);
// 端点未实现 / 请求失败必须分开:前者是 404/501,后者是断连或 5xx。
const unimplemented = isNotImplemented(error);
// 只渲染接口真的返回过的供应商;没有就是空列表,绝不拿示例配置顶替。
const providers = data?.providers ?? [];
const [editing, setEditing] = useState(null);
// 同一分组 id 连续填入不同候选时也必须重挂表单(表单按 key 取初值),
// 否则第二次填入不会生效。
const [draftSeq, setDraftSeq] = useState(0);
const [busy, setBusy] = useState(false);
const [fail, setFail] = useState(null);
const [result, setResult] = useState(null);
// 目录候选带进来的能力建议(可推翻);没有建议时是 null。
const [hint, setHint] = useState(null);
const edit = (p: AdminLlmProvider | null, next: CatalogHint | null) => {
setEditing(p);
setHint(next);
setDraftSeq((n) => n + 1);
};
return (
供应商 / 模型
runLlmTest(p, log, setBusy, setFail, setResult)}
onEdit={(p) => edit(p, null)}
/>
{/* 「重置为 env 默认」(v0.62):面板把键改坏时的一键回基线入口。成功后重新
拉取配置,让上面的列表立刻反映退回后的真值。 */}
{
log("llm.catalog.fill", "ok");
edit(draft, picked);
}}
/>
setEditing(null)}
hint={hint}
reasoningKinds={data?.reasoningKinds ?? []}
personaProviderOptions={data?.personaProviderOptions ?? []}
personaIds={(personas.data ?? []).map((p) => p.id)}
personaListError={personas.error}
/>
每组的 reasoning.kind(推理开关形状)按端点选:形状属于端点而不属于模型,选错会 400,因此面板只给出「未声明 / auto」与后端下发的几种形状。按角色路由(persona → 模型 / 协议)与降级链由后端 loop 真接线;面板保存即写入 models.json 并热更新运行中的 LLM provider(无需重启,design/04 §3.1),.env 为兜底。maxTokens 留空 = 保持原值 / 未设时回落 LLM_FALLBACK_MAX_TOKENS。contextWindow(模型上下文窗口,token)留空 = 未声明:后端按 128 Ki token 保守估算并记一条 WARN,填该模型的真实窗口才能吃到按窗口推导的上下文预算(该推导只在启动时算一次 ⇒ 重启后生效)。multimodal 只表示主模型自己能否收图;图片/OCR 工具用的模型走 .env 的 LLM_VISION_MODEL / LLM_OCR_MODEL。目录候选来自 models.dev,由后端缓存成快照(离线仍可用),绝不进模型运行时热路径。
);
}