Files
artex/web/src/app/(main)/system/llm/page.tsx
T
dela 0335d572de
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s
First Commit
2026-10-09 08:38:16 +08:00

967 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<void>;
}) {
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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="flex flex-col gap-0 p-0 data-[side=right]:sm:max-w-lg">
<SheetHeader className="px-4">
<SheetTitle className="flex items-center gap-2">
<ZapIcon className="size-4" /> {t("pool.title")}
</SheetTitle>
<SheetDescription>{t.rich("pool.desc", { b: (c) => <b>{c}</b> })}</SheetDescription>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 pb-6">
<div className="flex items-center justify-between gap-4 rounded-lg border p-3">
<div className="grid gap-0.5">
<Label className="text-sm">{t("pool.enable.label")}</Label>
<p className="text-muted-foreground text-xs">{t("pool.enable.hint")}</p>
</div>
<Switch
checked={enabled}
disabled={busy}
onCheckedChange={(v) => void toggle({ llm_pool_enabled: v })}
aria-label={t("pool.enable.aria")}
/>
</div>
{enabled && (
<>
<div className="flex items-center justify-between gap-4 rounded-lg border p-3">
<div className="grid gap-0.5">
<Label className="text-sm">{t("pool.bindFallback.label")}</Label>
<p className="text-muted-foreground text-xs">{t("pool.bindFallback.hint")}</p>
</div>
<Switch
checked={pool?.bind_fallback ?? false}
disabled={busy}
onCheckedChange={(v) => void toggle({ llm_pool_bind_fallback: v })}
aria-label={t("pool.bindFallback.aria")}
/>
</div>
<Separator />
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label className="text-sm">{t("pool.order.label")}</Label>
{tripped.length > 0 && (
<Button size="sm" variant="ghost" onClick={() => void recover()}>
<RotateCcwIcon /> {t("pool.recoverAll")}
</Button>
)}
</div>
{inChain.length < 2 && (
<p className="text-muted-foreground text-xs">{t("pool.order.tooFew", { n: inChain.length })}</p>
)}
{chain.map((m) => {
const excluded = m.excluded && !m.active;
const order = excluded ? null : inChain.findIndex((x) => x.profile_id === m.profile_id) + 1;
return (
<div
key={m.profile_id}
className={cn(
"grid gap-1 rounded-lg border p-2.5 text-sm",
excluded && "opacity-55",
m.state === "tripped" && "border-destructive/40",
)}
>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="w-5 shrink-0 text-center font-mono text-muted-foreground text-xs">
{order ?? "—"}
</span>
<span className="font-medium">{m.name}</span>
{m.active && (
<Badge variant="outline" className="border-amber-400/50 text-amber-500">
{t("pool.badge.active")}
</Badge>
)}
{excluded && <Badge variant="outline">{t("pool.badge.excluded")}</Badge>}
<div className="ml-auto flex items-center gap-2">
{m.state === "tripped" && m.cooldown_secs > 0 && (
<span className="text-muted-foreground text-xs">
{t("pool.cooldown", { cooldown: cooldownText(m.cooldown_secs) })}
</span>
)}
{m.state === "degraded" && (
<span className="text-muted-foreground text-xs">
{t("pool.consecutiveFails", { fails: m.fails })}
</span>
)}
{m.state !== "ok" && (
<Button
size="icon"
variant="ghost"
className="size-7"
aria-label={t("pool.recoverNow.aria")}
title={t("pool.recoverNow.title")}
onClick={() => void recover(m.profile_id)}
>
<RotateCcwIcon className="size-3.5" />
</Button>
)}
</div>
</div>
<div className="flex flex-wrap items-center gap-x-3 pl-7 text-muted-foreground text-xs">
<code className="truncate font-mono">{m.model}</code>
{!m.active && <span>{t("pool.priority", { priority: m.priority })}</span>}
</div>
{m.last_error && (
<p className="truncate pl-7 font-mono text-muted-foreground text-xs" title={m.last_error}>
{m.last_error}
</p>
)}
</div>
);
})}
{chain.length === 0 && (
<div className="rounded-lg border border-dashed p-4 text-center text-muted-foreground text-sm">
{t("pool.empty")}
</div>
)}
</div>
<div className="rounded-lg border border-dashed p-3 text-muted-foreground text-xs leading-relaxed">
{t("pool.note")}
</div>
</>
)}
</div>
</SheetContent>
</Sheet>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// 模型配置抽屉(新建 / 编辑共用同一套表单)
// ─────────────────────────────────────────────────────────────────────────────
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<LLMRetryOverride>(ZERO_OVERRIDE); // 本配置的重试覆盖;全 0=跟随全局
const [testing, setTesting] = React.useState(false);
const [saving, setSaving] = React.useState(false);
const [models, setModels] = React.useState<string[]>([]);
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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="flex flex-col gap-0 p-0 data-[side=right]:min-w-[420px] data-[side=right]:sm:max-w-xl"
>
<SheetHeader className="px-4">
<SheetTitle className="flex items-center gap-2">
{isNew ? t("profile.titleNew") : t("profile.titleEdit", { name: profile?.name ?? "" })}
{profile?.is_default && (
<Badge variant="outline" className="border-amber-400/50 text-amber-500">
{t("profile.activeBadge")}
</Badge>
)}
</SheetTitle>
<SheetDescription>{isNew ? t("profile.descNew") : t("profile.descEdit")}</SheetDescription>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 pb-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="p-name">{t("profile.name.label")}</Label>
<Input
id="p-name"
placeholder={t("profile.name.placeholder")}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label>{t("profile.format.label")}</Label>
<Select value={format} onValueChange={(v) => setFormat(v as "anthropic" | "openai" | "openai-responses")}>
<SelectTrigger>
<SelectValue placeholder={t("profile.format.placeholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="anthropic">Anthropic</SelectItem>
<SelectItem value="openai">OpenAI (Chat Completions)</SelectItem>
<SelectItem value="openai-responses">OpenAI (Responses API)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="p-model">{t("profile.model.label")}</Label>
<div className="flex gap-2">
<Input
id="p-model"
className="font-mono"
placeholder="claude-opus-4-8"
value={model}
onChange={(e) => setModel(e.target.value)}
/>
{/* modal: 这个 Popover 的内容被 portal 到 <body>,在 Sheet 的滚动锁之外,
不加 modal 时列表能渲染却滚不动。modal 让它自己持有最上层滚动锁。 */}
<Popover open={modelsOpen} onOpenChange={setModelsOpen} modal>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className="shrink-0"
disabled={loadingModels}
onClick={loadModels}
title={t("profile.model.loadTitle")}
>
{loadingModels ? <Loader2Icon className="animate-spin" /> : <RefreshCwIcon />}
</Button>
</PopoverTrigger>
{models.length > 0 && (
<PopoverContent className="max-h-72 w-72 gap-0 overflow-y-auto overscroll-contain p-1" align="end">
{models.map((m) => (
<button
key={m}
type="button"
className="w-full shrink-0 rounded-md px-2 py-1.5 text-left font-mono text-xs hover:bg-accent hover:text-accent-foreground"
onClick={() => {
setModel(m);
setModelsOpen(false);
}}
>
{m}
</button>
))}
</PopoverContent>
)}
</Popover>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="p-base-url">{t("profile.baseUrl.label")}</Label>
<Input
id="p-base-url"
className="font-mono"
placeholder="https://api.openai.com/v1"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="p-proxy">{t("profile.proxy.label")}</Label>
<Input
id="p-proxy"
className="font-mono"
placeholder="socks5://user:pass@127.0.0.1:1080 · http://127.0.0.1:8080"
value={proxy}
onChange={(e) => setProxy(e.target.value)}
/>
<p className="text-muted-foreground text-xs">{t("profile.proxy.hint")}</p>
</div>
<div className="grid gap-2">
<Label htmlFor="p-session-header">{t("profile.sessionHeader.label")}</Label>
<Input
id="p-session-header"
className="font-mono"
placeholder={t("profile.sessionHeader.placeholder")}
value={sessionHeaderKey}
onChange={(e) => setSessionHeaderKey(e.target.value)}
/>
<p className="text-muted-foreground text-xs">
{t.rich("profile.sessionHeader.hint", { b: (c) => <b>{c}</b> })}
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="p-api-key">API Key</Label>
<Input
id="p-api-key"
type="password"
placeholder={keyHint ? t("profile.apiKey.placeholderSet", { hint: keyHint }) : "sk-…"}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="grid gap-2">
<Label htmlFor="p-rps">{t("profile.rps")}</Label>
<Input id="p-rps" type="number" min={0} value={rps} onChange={(e) => setRps(e.target.value)} />
</div>
<div className="grid gap-2">
<Label htmlFor="p-rpm">{t("profile.rpm")}</Label>
<Input id="p-rpm" type="number" min={0} value={rpm} onChange={(e) => setRpm(e.target.value)} />
</div>
<div className="grid gap-2">
<Label htmlFor="p-cw">{t("profile.cw")}</Label>
<Input
id="p-cw"
type="number"
min={0}
max={1000}
value={cw}
onChange={(e) => setCw(e.target.value)}
placeholder="200"
/>
</div>
</div>
<p className="-mt-2 text-muted-foreground text-xs">{t("profile.rateHint")}</p>
<div className="grid gap-3 rounded-lg border p-3">
<div className="flex items-center justify-between gap-4">
<div className="grid gap-0.5">
<Label htmlFor="p-priority" className="text-sm">
{t("profile.priority.label")}
</Label>
<p className="text-muted-foreground text-xs">{t("profile.priority.hint")}</p>
</div>
<Input
id="p-priority"
type="number"
className="w-24 shrink-0"
value={priority}
onChange={(e) => setPriority(e.target.value)}
/>
</div>
<div className="flex items-center justify-between gap-4 border-t pt-3">
<div className="grid gap-0.5">
<Label className="text-sm">{t("profile.exclude.label")}</Label>
<p className="text-muted-foreground text-xs">{t("profile.exclude.hint")}</p>
</div>
<Switch checked={poolExclude} onCheckedChange={setPoolExclude} aria-label={t("profile.exclude.aria")} />
</div>
<div className="flex items-center justify-between gap-4 border-t pt-3">
<div className="grid gap-0.5">
<Label className="text-sm">{t("profile.streaming.label")}</Label>
<p className="text-muted-foreground text-xs">{t("profile.streaming.hint")}</p>
</div>
<Switch checked={streaming} onCheckedChange={setStreaming} aria-label={t("profile.streaming.aria")} />
</div>
</div>
<div className="grid gap-3 rounded-lg border p-3">
<div className="flex items-center justify-between gap-4">
<div className="grid gap-0.5">
<Label htmlFor="p-max-tokens" className="text-sm">
{t("profile.maxTokens.label")}
</Label>
<p className="text-muted-foreground text-xs">{t("profile.maxTokens.hint")}</p>
</div>
<Input
id="p-max-tokens"
type="number"
min={0}
className="w-28 shrink-0"
value={maxTokens}
onChange={(e) => setMaxTokens(e.target.value)}
placeholder="0"
/>
</div>
<div className="flex items-center justify-between gap-4 border-t pt-3">
<div className="grid gap-0.5">
<Label className="text-sm">{t("profile.maxTokensField.label")}</Label>
<p className="text-muted-foreground text-xs">{t(`profile.maxTokensFieldHint.${format}`)}</p>
</div>
<Select
value={format === "openai" ? maxTokensField : NONE}
onValueChange={setMaxTokensField}
disabled={format !== "openai"}
>
<SelectTrigger className="w-56 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MAX_TOKENS_FIELDS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-3 rounded-lg border p-3">
<div className="flex items-center justify-between gap-4">
<div className="grid gap-0.5">
<Label className="text-sm">{t("profile.thinkingType.label")}</Label>
<p className="text-muted-foreground text-xs">{t("profile.thinkingType.hint")}</p>
</div>
<Select value={thinkingType} onValueChange={setThinkingType}>
<SelectTrigger className="w-32 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{THINKING_TYPES.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between gap-4 border-t pt-3">
<div className="grid gap-0.5">
<Label className="text-sm">{t("profile.effort.label")}</Label>
<p className="text-muted-foreground text-xs">{t("profile.effort.hint")}</p>
</div>
<Select value={effort} onValueChange={setEffort}>
<SelectTrigger className="w-32 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{EFFORT_LEVELS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<ProfileRetryFields value={retry} onChange={setRetry} />
</div>
<div className="flex gap-2 border-t px-4 py-3">
<Button variant="outline" onClick={testConnection} disabled={testing}>
{testing ? <Loader2Icon className="animate-spin" /> : <PlugZapIcon />}
{testing ? t("profile.testing") : t("profile.testConnection")}
</Button>
<Button onClick={save} disabled={saving} className="flex-1">
{saving && <Loader2Icon className="animate-spin" />}
{!saving && (isNew ? <PlusIcon /> : <SaveIcon />)}
{isNew ? t("profile.create") : t("profile.save")}
</Button>
</div>
</SheetContent>
</Sheet>
);
}
// ─────────────────────────────────────────────────────────────────────────────
export default function LLMPage() {
const t = useTranslations("llmPage");
const [profiles, setProfiles] = React.useState<LLMProfile[]>([]);
const [pool, setPool] = React.useState<LLMPoolStatus | null>(null);
const [poolOpen, setPoolOpen] = React.useState(false);
// 抽屉的开关和内容分开存:关闭时 editing 保持不变,否则关闭动画期间标题会从
// 「编辑 X」闪成「新建」。editing = null 表示新建。
const [editOpen, setEditOpen] = React.useState(false);
const [editing, setEditing] = React.useState<LLMProfile | null>(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<string, LLMPoolMember>();
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 (
<div className="flex flex-1 flex-col gap-4 md:gap-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="font-semibold text-xl tracking-tight">LLM</h1>
<p className="text-muted-foreground text-sm">{t("page.subtitle")}</p>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setPoolOpen(true)}>
<ZapIcon /> {t("page.poolConfig")}
{poolOn && (
<Badge variant="outline" className="ml-1 border-emerald-500/50 text-emerald-600 dark:text-emerald-400">
{t("page.enabled")}
</Badge>
)}
</Button>
<Button size="sm" variant="outline" onClick={() => openEditor(null)}>
<PlusIcon /> {t("page.new")}
</Button>
</div>
</div>
<Tabs defaultValue="profiles" className="flex-1">
<TabsList>
<TabsTrigger value="profiles">{t("page.tab.profiles")}</TabsTrigger>
<TabsTrigger value="retry">{t("page.tab.retry")}</TabsTrigger>
</TabsList>
<TabsContent value="profiles" className="mt-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{profiles.map((p) => {
const h = healthOf(p, health.get(p.id));
return (
// biome-ignore lint/a11y/useSemanticElements: 卡片内含自己的操作按钮,用原生 <button> 会造成按钮嵌套(非法 HTML)
<Card
key={p.id}
role="button"
tabIndex={0}
onClick={() => openEditor(p)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
openEditor(p);
}
}}
className={cn(
"cursor-pointer gap-0 py-4 outline-none transition-colors hover:border-foreground/30",
p.is_default && "border-amber-400/50 bg-amber-400/5",
)}
>
<CardContent className="grid gap-2 px-4">
<div className="flex items-start gap-2">
<StarIcon
className={cn(
"mt-0.5 size-4 shrink-0",
p.is_default ? "fill-amber-400 text-amber-400" : "text-muted-foreground",
)}
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate font-medium text-sm">{p.name}</span>
<Badge variant="outline" className="uppercase">
{p.format}
</Badge>
<Badge variant="outline" className={cn("ml-auto", h.cls)} title={h.hint}>
{h.label}
</Badge>
</div>
<code className="mt-1 block truncate font-mono text-muted-foreground text-xs">{p.model}</code>
</div>
</div>
<div className="flex flex-wrap gap-x-3 gap-y-0.5 pl-6 text-muted-foreground text-xs">
{p.api_key_hint && <span>{p.api_key_hint}</span>}
<span>
{p.rate_per_second}/s · {p.rate_per_minute}/min
</span>
{p.proxy && <span className="truncate">{t("card.proxy", { proxy: p.proxy })}</span>}
{p.reasoning_effort && (
<span>
{t("card.thinking", {
effort: p.reasoning_effort === "off" ? t("card.thinkingOff") : p.reasoning_effort,
})}
</span>
)}
{/* 轮询相关的两个字段只在轮询开着时才有意义,关着时不占版面 */}
{poolOn &&
!p.is_default &&
(p.pool_exclude ? (
<span>{t("card.excluded")}</span>
) : (
<span>{t("card.priority", { priority: p.priority ?? 0 })}</span>
))}
</div>
<div className="mt-1 flex gap-2">
<Button
size="sm"
variant="outline"
className="flex-1"
disabled={p.is_default}
onClick={(e) => {
e.stopPropagation();
void activate(p.id, p.name);
}}
>
{p.is_default ? t("card.active") : t("card.activate")}
</Button>
<Button
size="icon"
variant="outline"
aria-label={t("card.deleteAria")}
onClick={(e) => {
e.stopPropagation();
void remove(p);
}}
>
<Trash2Icon className="text-destructive" />
</Button>
</div>
</CardContent>
</Card>
);
})}
{profiles.length === 0 && (
<div className="col-span-full rounded-lg border border-dashed p-10 text-center text-muted-foreground text-sm">
{t("page.empty")}
</div>
)}
</div>
</TabsContent>
<TabsContent value="retry" className="mt-4">
<RetryPolicyPanel />
</TabsContent>
</Tabs>
<ProfileSheet profile={editing} open={editOpen} onOpenChange={setEditOpen} onSaved={() => void load()} />
<PoolSheet open={poolOpen} onOpenChange={setPoolOpen} pool={pool} onReload={loadPool} />
</div>
);
}