First Commit
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
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
This commit is contained in:
@@ -0,0 +1,966 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user