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,由后端缓存成快照(离线仍可用),绝不进模型运行时热路径。
); }