"use client"; import * as React from "react"; import { Loader2Icon, PlugZapIcon, PlusIcon, RefreshCwIcon, RotateCcwIcon, SaveIcon, StarIcon, Trash2Icon, ZapIcon, } from "lucide-react"; import { useTranslations } from "next-intl"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { api } from "@/lib/api"; import type { LLMPoolMember, LLMPoolStatus, LLMProfile, LLMRetryOverride } from "@/lib/types"; import { cn } from "@/lib/utils"; import { ProfileRetryFields, RetryPolicyPanel, ZERO_OVERRIDE } from "./_components/retry"; // 思考开关(thinking.type)与思考强度(reasoning_effort)是两个【互相独立】的字段, // 各自单独设置——有些接口没有 thinking 字段、只靠强度参数就能激活思考,故需解耦。 // 存库空字符串 = 该字段【不发送】;Radix Select 不接受空 value,故 UI 用 "none" // 哨兵表示不发送,存取时与 "" 互转(NONE / fromStore / toStore)。 const NONE = "none"; const fromStore = (v?: string) => (v ? v : NONE); const toStore = (v: string) => (v === NONE ? "" : v); function cooldownText(secs: number) { if (secs <= 0) return ""; if (secs < 60) return `${secs}s`; return `${Math.ceil(secs / 60)}min`; } // 一个配置在卡片上显示的「是否正常」。没填 Key 的配置根本发不出请求,比熔断更该先说; // 其余状态来自轮询的熔断记录(轮询关着时不会产生新记录,此时「正常」= 没有已知故障)。 // 文案走 i18n(llmPage.health.*),所以把 t 传进来由调用方注入。 type Health = { label: string; cls: string; hint?: string }; // ───────────────────────────────────────────────────────────────────────────── // 轮询配置抽屉 // ───────────────────────────────────────────────────────────────────────────── function PoolSheet({ open, onOpenChange, pool, onReload, }: { open: boolean; onOpenChange: (o: boolean) => void; pool: LLMPoolStatus | null; onReload: () => Promise; }) { const t = useTranslations("llmPage"); const [busy, setBusy] = React.useState(false); // 冷却倒计时是后端算出的剩余秒数——抽屉开着且有配置不正常时才定时拉,让它走起来。 React.useEffect(() => { if (!open || !pool?.enabled || !pool.chain.some((m) => m.state !== "ok")) return; const t = setInterval(() => void onReload(), 10_000); return () => clearInterval(t); }, [open, pool, onReload]); async function toggle(patch: { llm_pool_enabled?: boolean; llm_pool_bind_fallback?: boolean }) { if (busy) return; setBusy(true); try { await api.setSettings(patch); await onReload(); if (patch.llm_pool_enabled !== undefined) { toast.success(patch.llm_pool_enabled ? t("pool.toast.enabled") : t("pool.toast.disabled")); } else { toast.success(t("pool.toast.fallbackUpdated")); } } catch (e) { toast.error(t("pool.toast.setFailed", { msg: (e as Error).message })); } finally { setBusy(false); } } async function recover(id?: string) { try { await api.resetLLMPool(id); await onReload(); toast.success(id ? t("pool.toast.recoveredOne") : t("pool.toast.recoveredAll")); } catch (e) { toast.error(t("pool.toast.recoverFailed", { msg: (e as Error).message })); } } const enabled = pool?.enabled ?? false; const chain = pool?.chain ?? []; // 参与轮询的成员(排除被标记「不参与轮询」的),顺序即后端实际的尝试顺序。 const inChain = chain.filter((m) => m.active || !m.excluded); const tripped = chain.filter((m) => m.state === "tripped"); return ( {t("pool.title")} {t.rich("pool.desc", { b: (c) => {c} })}

{t("pool.enable.hint")}

void toggle({ llm_pool_enabled: v })} aria-label={t("pool.enable.aria")} />
{enabled && ( <>

{t("pool.bindFallback.hint")}

void toggle({ llm_pool_bind_fallback: v })} aria-label={t("pool.bindFallback.aria")} />
{tripped.length > 0 && ( )}
{inChain.length < 2 && (

{t("pool.order.tooFew", { n: inChain.length })}

)} {chain.map((m) => { const excluded = m.excluded && !m.active; const order = excluded ? null : inChain.findIndex((x) => x.profile_id === m.profile_id) + 1; return (
{order ?? "—"} {m.name} {m.active && ( {t("pool.badge.active")} )} {excluded && {t("pool.badge.excluded")}}
{m.state === "tripped" && m.cooldown_secs > 0 && ( {t("pool.cooldown", { cooldown: cooldownText(m.cooldown_secs) })} )} {m.state === "degraded" && ( {t("pool.consecutiveFails", { fails: m.fails })} )} {m.state !== "ok" && ( )}
{m.model} {!m.active && {t("pool.priority", { priority: m.priority })}}
{m.last_error && (

{m.last_error}

)}
); })} {chain.length === 0 && (
{t("pool.empty")}
)}
{t("pool.note")}
)}
); } // ───────────────────────────────────────────────────────────────────────────── // 模型配置抽屉(新建 / 编辑共用同一套表单) // ───────────────────────────────────────────────────────────────────────────── function ProfileSheet({ profile, open, onOpenChange, onSaved, }: { profile: LLMProfile | null; // null = 新建 open: boolean; onOpenChange: (o: boolean) => void; onSaved: (id: string) => void; }) { const t = useTranslations("llmPage"); const isNew = !profile; const [name, setName] = React.useState(""); const [format, setFormat] = React.useState<"anthropic" | "openai" | "openai-responses">("anthropic"); const [model, setModel] = React.useState(""); const [baseUrl, setBaseUrl] = React.useState(""); const [proxy, setProxy] = React.useState(""); const [apiKey, setApiKey] = React.useState(""); const [keyHint, setKeyHint] = React.useState(""); const [rps, setRps] = React.useState("0"); const [rpm, setRpm] = React.useState("0"); const [cw, setCw] = React.useState("0"); // 上下文窗口(K tokens);0=默认200K const [thinkingType, setThinkingType] = React.useState(NONE); const [effort, setEffort] = React.useState(NONE); const [priority, setPriority] = React.useState("0"); // 轮询顺位;越大越先 const [poolExclude, setPoolExclude] = React.useState(false); const [streaming, setStreaming] = React.useState(true); // true=流式(默认);false=非流式 const [maxTokens, setMaxTokens] = React.useState("0"); // 单次回复输出上限;0=不发送 const [maxTokensField, setMaxTokensField] = React.useState(NONE); // 上限用哪个字段名;NONE=max_tokens const [sessionHeaderKey, setSessionHeaderKey] = React.useState(""); // 自定义会话头名;空=不发送 const [retry, setRetry] = React.useState(ZERO_OVERRIDE); // 本配置的重试覆盖;全 0=跟随全局 const [testing, setTesting] = React.useState(false); const [saving, setSaving] = React.useState(false); const [models, setModels] = React.useState([]); const [loadingModels, setLoadingModels] = React.useState(false); const [modelsOpen, setModelsOpen] = React.useState(false); // 思考开关 / 上限字段名 / 思考强度三组选项的标签走 i18n,故在组件内按当前语言构造。 const THINKING_TYPES: { value: string; label: string }[] = [ { value: NONE, label: t("profile.thinkingType.opt.none") }, { value: "disabled", label: t("profile.thinkingType.opt.disabled") }, { value: "enabled", label: t("profile.thinkingType.opt.enabled") }, ]; const MAX_TOKENS_FIELDS: { value: string; label: string }[] = [ { value: NONE, label: t("profile.maxTokensField.opt.default") }, { value: "max_completion_tokens", label: "max_completion_tokens" }, ]; const EFFORT_LEVELS: { value: string; label: string }[] = [ { value: NONE, label: t("profile.effort.opt.none") }, { value: "low", label: "low" }, { value: "medium", label: "medium" }, { value: "high", label: "high" }, { value: "xhigh", label: "xhigh" }, { value: "max", label: "max" }, ]; // 每次打开时从传入的 profile 灌一遍表单(新建则重置为默认值)。抽屉关掉再打开 // 就是一次干净的开始,不会留下上一个配置的残影。 React.useEffect(() => { if (!open) return; setName(profile?.name ?? ""); setFormat(profile?.format === "openai" || profile?.format === "openai-responses" ? profile.format : "anthropic"); setModel(profile?.model ?? ""); setBaseUrl(profile?.base_url ?? ""); setProxy(profile?.proxy ?? ""); setRps(String(profile?.rate_per_second ?? 0)); setRpm(String(profile?.rate_per_minute ?? 0)); setCw(String(profile?.context_window_k ?? 0)); setThinkingType(fromStore(profile?.thinking_type)); setEffort(fromStore(profile?.reasoning_effort)); setPriority(String(profile?.priority ?? 0)); setPoolExclude(profile?.pool_exclude ?? false); setStreaming(profile?.streaming ?? true); setMaxTokens(String(profile?.max_tokens ?? 0)); setMaxTokensField(fromStore(profile?.max_tokens_field)); setSessionHeaderKey(profile?.session_header_key ?? ""); setRetry(profile?.retry ?? ZERO_OVERRIDE); setApiKey(""); setKeyHint(profile?.api_key_hint ?? ""); setModels([]); setModelsOpen(false); }, [open, profile]); const profileId = profile ? Number(profile.id) : undefined; async function loadModels() { if (loadingModels) return; setLoadingModels(true); setModels([]); try { const r = await api.fetchLLMModels(format, baseUrl, apiKey, proxy, profileId); if (r.ok && r.models && r.models.length > 0) { setModels(r.models); setModelsOpen(true); toast.success(t("profile.toast.modelsLoaded", { n: r.models.length })); } else { toast.error(t("profile.toast.loadModelsFailed", { error: r.error ?? t("profile.toast.noModels") })); } } catch (e) { toast.error(t("profile.toast.loadModelsError", { msg: (e as Error).message })); } finally { setLoadingModels(false); } } async function testConnection() { if (testing) return; setTesting(true); try { // 用配置实际会跑的思考参数来测,这样不支持该字段的模型在这里就失败, // 而不是等到跑任务时才炸。传 profile id:Key 输入框留空时用已存的 Key。 const r = await api.testLLM( format, model, baseUrl, apiKey, proxy, toStore(thinkingType), toStore(effort), profileId, streaming, sessionHeaderKey.trim(), ); // 回复内容一并展示:看得见模型确实说了话,才算和会话里跑通是一回事。 if (r.ok) toast.success(t("profile.toast.testOk", { latency: r.latency_ms ?? "?", model: r.model ?? model }), { description: r.reply ? t("profile.toast.testReply", { reply: r.reply }) : undefined, }); else toast.error(t("profile.toast.testFailed", { error: r.error ?? t("profile.toast.unknown") })); } catch (e) { toast.error(t("profile.toast.testError", { msg: (e as Error).message })); } finally { setTesting(false); } } async function save() { if (!name.trim() || !model.trim()) { toast.error(t("profile.toast.nameModelRequired")); return; } if (saving) return; setSaving(true); try { const { id } = await api.saveLLMProfile({ ...(profile ? { id: Number(profile.id) } : {}), name: name.trim(), format, model: model.trim(), base_url: baseUrl.trim(), proxy: proxy.trim(), api_key: apiKey, rate_per_second: Number(rps) || 0, rate_per_minute: Number(rpm) || 0, context_window_k: Number(cw) || 0, thinking_type: toStore(thinkingType), reasoning_effort: toStore(effort), priority: Number(priority) || 0, pool_exclude: poolExclude, streaming, max_tokens: Math.max(0, Number(maxTokens) || 0), // 字段名开关只对 openai(Chat Completions) 有意义,其它格式一律回落到默认; // 后端也会再做一次同样的归一化,这里只是别让 UI 送出自相矛盾的值。 max_tokens_field: format === "openai" ? toStore(maxTokensField) : "", session_header_key: sessionHeaderKey.trim(), retry, }); if (isNew) toast.success(t("profile.toast.created", { name: name.trim() })); else toast.success(profile?.is_default ? t("profile.toast.savedActive") : t("profile.toast.saved")); onSaved(String(id)); onOpenChange(false); } catch (e) { toast.error(t("profile.toast.saveFailed", { msg: (e as Error).message })); } finally { setSaving(false); } } return ( {isNew ? t("profile.titleNew") : t("profile.titleEdit", { name: profile?.name ?? "" })} {profile?.is_default && ( {t("profile.activeBadge")} )} {isNew ? t("profile.descNew") : t("profile.descEdit")}
setName(e.target.value)} />
setModel(e.target.value)} /> {/* modal: 这个 Popover 的内容被 portal 到 ,在 Sheet 的滚动锁之外, 不加 modal 时列表能渲染却滚不动。modal 让它自己持有最上层滚动锁。 */} {models.length > 0 && ( {models.map((m) => ( ))} )}
setBaseUrl(e.target.value)} />
setProxy(e.target.value)} />

{t("profile.proxy.hint")}

setSessionHeaderKey(e.target.value)} />

{t.rich("profile.sessionHeader.hint", { b: (c) => {c} })}

setApiKey(e.target.value)} />
setRps(e.target.value)} />
setRpm(e.target.value)} />
setCw(e.target.value)} placeholder="200" />

{t("profile.rateHint")}

{t("profile.priority.hint")}

setPriority(e.target.value)} />

{t("profile.exclude.hint")}

{t("profile.streaming.hint")}

{t("profile.maxTokens.hint")}

setMaxTokens(e.target.value)} placeholder="0" />

{t(`profile.maxTokensFieldHint.${format}`)}

{t("profile.thinkingType.hint")}

{t("profile.effort.hint")}

); } // ───────────────────────────────────────────────────────────────────────────── export default function LLMPage() { const t = useTranslations("llmPage"); const [profiles, setProfiles] = React.useState([]); const [pool, setPool] = React.useState(null); const [poolOpen, setPoolOpen] = React.useState(false); // 抽屉的开关和内容分开存:关闭时 editing 保持不变,否则关闭动画期间标题会从 // 「编辑 X」闪成「新建」。editing = null 表示新建。 const [editOpen, setEditOpen] = React.useState(false); const [editing, setEditing] = React.useState(null); const openEditor = React.useCallback((p: LLMProfile | null) => { setEditing(p); setEditOpen(true); }, []); // 一个配置在卡片上显示的「是否正常」。没填 Key 的配置根本发不出请求,比熔断更该先说。 const healthOf = React.useCallback( (p: LLMProfile, m?: LLMPoolMember): Health => { if (!p.api_key_hint) { return { label: t("health.noKey.label"), cls: "border-muted-foreground/40 text-muted-foreground", hint: t("health.noKey.hint"), }; } if (m?.state === "tripped") { return { label: m.cooldown_secs > 0 ? t("health.trippedCooldown", { cooldown: cooldownText(m.cooldown_secs) }) : t("health.tripped"), cls: "border-destructive/50 text-destructive", hint: m.last_error, }; } if (m?.state === "degraded") { return { label: t("health.degraded", { fails: m.fails }), cls: "border-amber-500/50 text-amber-600 dark:text-amber-400", hint: m.last_error, }; } return { label: t("health.ok"), cls: "border-emerald-500/50 text-emerald-600 dark:text-emerald-400" }; }, [t], ); const loadPool = React.useCallback(async () => { try { setPool(await api.llmPool()); } catch { /* ignore */ } }, []); const load = React.useCallback(async () => { try { setProfiles(await api.llmProfiles()); } catch { /* ignore */ } await loadPool(); }, [loadPool]); React.useEffect(() => { void load(); }, [load]); // 卡片上的健康徽章按 profile id 取轮询状态。 const health = React.useMemo(() => { const m = new Map(); for (const c of pool?.chain ?? []) m.set(c.profile_id, c); return m; }, [pool]); async function activate(id: string, name: string) { try { await api.activateLLMProfile(id); toast.success(t("page.toast.activated", { name })); await load(); } catch (e) { toast.error(t("page.toast.activateFailed", { msg: (e as Error).message })); } } async function remove(p: LLMProfile) { if (p.is_default) { toast.error(t("page.toast.cannotDeleteActive")); return; } try { await api.deleteLLMProfile(p.id); toast.success(t("page.toast.deleted", { name: p.name })); await load(); } catch (e) { toast.error(t("page.toast.deleteFailed", { msg: (e as Error).message })); } } const poolOn = pool?.enabled ?? false; return (

LLM

{t("page.subtitle")}

{t("page.tab.profiles")} {t("page.tab.retry")}
{profiles.map((p) => { const h = healthOf(p, health.get(p.id)); return ( // biome-ignore lint/a11y/useSemanticElements: 卡片内含自己的操作按钮,用原生
); })} {profiles.length === 0 && (
{t("page.empty")}
)}
void load()} /> ); }