884 lines
37 KiB
TypeScript
884 lines
37 KiB
TypeScript
"use client";
|
||
|
||
import * as React from "react";
|
||
|
||
import {
|
||
Loader2Icon,
|
||
PlugZapIcon,
|
||
PlusIcon,
|
||
RefreshCwIcon,
|
||
RotateCcwIcon,
|
||
SaveIcon,
|
||
StarIcon,
|
||
Trash2Icon,
|
||
ZapIcon,
|
||
} from "lucide-react";
|
||
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 { api } from "@/lib/api";
|
||
import type { LLMPoolMember, LLMPoolStatus, LLMProfile } from "@/lib/types";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
// 思考开关(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);
|
||
const THINKING_TYPES: { value: string; label: string }[] = [
|
||
{ value: NONE, label: "不发送(默认)" },
|
||
{ value: "disabled", label: "关闭" },
|
||
{ value: "enabled", label: "开启" },
|
||
];
|
||
const EFFORT_LEVELS: { value: string; label: string }[] = [
|
||
{ value: NONE, label: "不发送(默认)" },
|
||
{ value: "low", label: "low" },
|
||
{ value: "medium", label: "medium" },
|
||
{ value: "high", label: "high" },
|
||
{ value: "xhigh", label: "xhigh" },
|
||
{ value: "max", label: "max" },
|
||
];
|
||
|
||
function cooldownText(secs: number) {
|
||
if (secs <= 0) return "";
|
||
if (secs < 60) return `${secs}s`;
|
||
return `${Math.ceil(secs / 60)}min`;
|
||
}
|
||
|
||
// 一个配置在卡片上显示的「是否正常」。没填 Key 的配置根本发不出请求,比熔断更该先说;
|
||
// 其余状态来自轮询的熔断记录(轮询关着时不会产生新记录,此时「正常」= 没有已知故障)。
|
||
type Health = { label: string; cls: string; hint?: string };
|
||
function healthOf(p: LLMProfile, m?: LLMPoolMember): Health {
|
||
if (!p.api_key_hint) {
|
||
return {
|
||
label: "未配置 Key",
|
||
cls: "border-muted-foreground/40 text-muted-foreground",
|
||
hint: "未填 API Key,无法调用",
|
||
};
|
||
}
|
||
if (m?.state === "tripped") {
|
||
return {
|
||
label: m.cooldown_secs > 0 ? `已熔断 · ${cooldownText(m.cooldown_secs)}` : "已熔断",
|
||
cls: "border-destructive/50 text-destructive",
|
||
hint: m.last_error,
|
||
};
|
||
}
|
||
if (m?.state === "degraded") {
|
||
return {
|
||
label: `异常 · 失败 ${m.fails} 次`,
|
||
cls: "border-amber-500/50 text-amber-600 dark:text-amber-400",
|
||
hint: m.last_error,
|
||
};
|
||
}
|
||
return { label: "正常", cls: "border-emerald-500/50 text-emerald-600 dark:text-emerald-400" };
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 轮询配置抽屉
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
function PoolSheet({
|
||
open,
|
||
onOpenChange,
|
||
pool,
|
||
onReload,
|
||
}: {
|
||
open: boolean;
|
||
onOpenChange: (o: boolean) => void;
|
||
pool: LLMPoolStatus | null;
|
||
onReload: () => Promise<void>;
|
||
}) {
|
||
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 ? "已开启 LLM 轮询" : "已关闭 LLM 轮询");
|
||
} else {
|
||
toast.success("已更新兜底设置");
|
||
}
|
||
} catch (e) {
|
||
toast.error(`设置失败:${(e as Error).message}`);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function recover(id?: string) {
|
||
try {
|
||
await api.resetLLMPool(id);
|
||
await onReload();
|
||
toast.success(id ? "已恢复该配置" : "已恢复全部配置");
|
||
} catch (e) {
|
||
toast.error(`恢复失败:${(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" /> LLM 轮询 · 故障转移
|
||
</SheetTitle>
|
||
<SheetDescription>
|
||
开启后,<b>未指定模型</b>的 Agent 在当前配置不可用(余额不足 / Key 失效 / 限流 /
|
||
服务异常)时自动切到下一个配置。
|
||
</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">启用轮询</Label>
|
||
<p className="text-muted-foreground text-xs">默认关闭。关闭时始终只用激活配置,失败即失败。</p>
|
||
</div>
|
||
<Switch
|
||
checked={enabled}
|
||
disabled={busy}
|
||
onCheckedChange={(v) => void toggle({ llm_pool_enabled: v })}
|
||
aria-label="LLM 轮询开关"
|
||
/>
|
||
</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">指定模型失败时也兜底</Label>
|
||
<p className="text-muted-foreground text-xs">
|
||
默认关闭:Agent 或任务指定了某个配置就只用它,失败即失败(不会悄悄换成别的模型)。
|
||
开启后,指定的配置失败时也会回落到下面的轮询链。
|
||
</p>
|
||
</div>
|
||
<Switch
|
||
checked={pool?.bind_fallback ?? false}
|
||
disabled={busy}
|
||
onCheckedChange={(v) => void toggle({ llm_pool_bind_fallback: v })}
|
||
aria-label="绑定配置失败兜底开关"
|
||
/>
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
<div className="grid gap-2">
|
||
<div className="flex items-center justify-between">
|
||
<Label className="text-sm">轮询顺序</Label>
|
||
{tripped.length > 0 && (
|
||
<Button size="sm" variant="ghost" onClick={() => void recover()}>
|
||
<RotateCcwIcon /> 全部恢复
|
||
</Button>
|
||
)}
|
||
</div>
|
||
{inChain.length < 2 && (
|
||
<p className="text-muted-foreground text-xs">
|
||
当前只有 {inChain.length} 个可用配置,轮询不会生效——至少需要 2 个已填 API Key 且参与轮询的配置。
|
||
</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">
|
||
激活
|
||
</Badge>
|
||
)}
|
||
{excluded && <Badge variant="outline">不参与轮询</Badge>}
|
||
<div className="ml-auto flex items-center gap-2">
|
||
{m.state === "tripped" && m.cooldown_secs > 0 && (
|
||
<span className="text-muted-foreground text-xs">冷却 {cooldownText(m.cooldown_secs)}</span>
|
||
)}
|
||
{m.state === "degraded" && (
|
||
<span className="text-muted-foreground text-xs">连续失败 {m.fails} 次</span>
|
||
)}
|
||
{m.state !== "ok" && (
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className="size-7"
|
||
aria-label="立即恢复"
|
||
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>优先级 {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">
|
||
暂无配置
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="rounded-lg border border-dashed p-3 text-muted-foreground text-xs leading-relaxed">
|
||
激活配置恒为第 1 顺位,其余按优先级从高到低(在各配置里设置)。某个配置失败后进入冷却 (60s → 5min →
|
||
30min),冷却期内被跳过,恢复后自动切回。上下文窗口装不下当前请求的配置会被跳过。 指定了模型的 Agent
|
||
与任务默认不参与轮询。
|
||
</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 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 [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);
|
||
|
||
// 每次打开时从传入的 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);
|
||
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(`已加载 ${r.models.length} 个模型`);
|
||
} else {
|
||
toast.error(`加载模型失败:${r.error ?? "未获取到模型"}`);
|
||
}
|
||
} catch (e) {
|
||
toast.error(`加载模型出错:${(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,
|
||
);
|
||
// 回复内容一并展示:看得见模型确实说了话,才算和会话里跑通是一回事。
|
||
if (r.ok)
|
||
toast.success(`连接成功 · ${r.latency_ms ?? "?"}ms · ${r.model ?? model}`, {
|
||
description: r.reply ? `回复:${r.reply}` : undefined,
|
||
});
|
||
else toast.error(`连接失败:${r.error ?? "未知"}`);
|
||
} catch (e) {
|
||
toast.error(`测试出错:${(e as Error).message}`);
|
||
} finally {
|
||
setTesting(false);
|
||
}
|
||
}
|
||
|
||
async function save() {
|
||
if (!name.trim() || !model.trim()) {
|
||
toast.error("请填写名称与模型");
|
||
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,
|
||
});
|
||
if (isNew) toast.success(`已新建:${name.trim()}(在卡片上「设为激活」以启用)`);
|
||
else toast.success(profile?.is_default ? "已保存,激活配置即时生效,无需重启" : "已保存");
|
||
onSaved(String(id));
|
||
onOpenChange(false);
|
||
} catch (e) {
|
||
toast.error(`保存失败:${(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 ? "新建模型配置" : `编辑:${profile?.name}`}
|
||
{profile?.is_default && (
|
||
<Badge variant="outline" className="border-amber-400/50 text-amber-500">
|
||
激活中
|
||
</Badge>
|
||
)}
|
||
</SheetTitle>
|
||
<SheetDescription>
|
||
{isNew
|
||
? "新建后不会自动激活,请在卡片上「设为激活」以启用。"
|
||
: "修改后点击保存;激活配置保存后对全部 Agent 立即生效。"}
|
||
</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">名称</Label>
|
||
<Input
|
||
id="p-name"
|
||
placeholder="例如:OpenAI 生产"
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="grid gap-2">
|
||
<Label>格式</Label>
|
||
<Select value={format} onValueChange={(v) => setFormat(v as "anthropic" | "openai" | "openai-responses")}>
|
||
<SelectTrigger>
|
||
<SelectValue 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">模型</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="从 API 加载可用模型"
|
||
>
|
||
{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">Base URL(可选)</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">代理(可选)</Label>
|
||
<Input
|
||
id="p-proxy"
|
||
className="font-mono"
|
||
placeholder="socks5://user:[email protected]:1080 · http://127.0.0.1:8080"
|
||
value={proxy}
|
||
onChange={(e) => setProxy(e.target.value)}
|
||
/>
|
||
<p className="text-muted-foreground text-xs">
|
||
仅 LLM 出站请求走此代理,支持 http/https/socks5,可带账号密码(如
|
||
socks5://user:pass@host:port,密码含特殊字符需 URL 编码);留空表示不使用代理(直连)。
|
||
</p>
|
||
</div>
|
||
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="p-api-key">API Key</Label>
|
||
<Input
|
||
id="p-api-key"
|
||
type="password"
|
||
placeholder={keyHint ? `已设置(${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">每秒限速</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">每分钟限速</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">上下文窗口(K)</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">
|
||
限速 0 = 不限,全 Agent 共享。上下文窗口单位 K(千 token),0 = 默认 200K,上限 1000(即
|
||
1M);设太高会导致压缩不触发。
|
||
</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">
|
||
轮询优先级
|
||
</Label>
|
||
<p className="text-muted-foreground text-xs">
|
||
数字越大越先被选中;激活配置恒为第 1 顺位,与本值无关。相同优先级的配置会轮流打头,天然分摊额度。
|
||
</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">不参与轮询</Label>
|
||
<p className="text-muted-foreground text-xs">
|
||
开启后不会被当作故障转移目标(仍可被 Agent / 任务显式指定使用)。 适合「只给某个 Agent
|
||
专用、不希望别人失败时烧掉」的昂贵配置。
|
||
</p>
|
||
</div>
|
||
<Switch checked={poolExclude} onCheckedChange={setPoolExclude} aria-label="不参与轮询" />
|
||
</div>
|
||
<div className="flex items-center justify-between gap-4 border-t pt-3">
|
||
<div className="grid gap-0.5">
|
||
<Label className="text-sm">流式输出 · streaming</Label>
|
||
<p className="text-muted-foreground text-xs">
|
||
开启(默认)走流式 SSE,有运行中实时进度与实时 token 计数。 关闭则走真·非流式(stream:false,
|
||
一次性返回完整响应)——可绕开部分网关糟糕的 SSE 实现(空帧 / 思考字段丢帧),
|
||
代价是失去运行中的实时进度。
|
||
</p>
|
||
</div>
|
||
<Switch checked={streaming} onCheckedChange={setStreaming} aria-label="流式输出" />
|
||
</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">思考开关 · thinking.type</Label>
|
||
<p className="text-muted-foreground text-xs">
|
||
控制是否发送 thinking 字段。不发送=不带该字段(兼容 MiniMax 等不支持 的模型);关闭=发
|
||
disabled;开启=发 enabled。与下面的强度互相独立。
|
||
</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">思考强度 · reasoning_effort</Label>
|
||
<p className="text-muted-foreground text-xs">
|
||
独立的强度档位(OpenAI reasoning_effort / Anthropic output_config.effort)。 有些接口没有 thinking
|
||
字段、只靠强度即可激活思考,故可单独设置、不发送思考开关。
|
||
</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>
|
||
</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 ? "测试中…" : "测试连接"}
|
||
</Button>
|
||
<Button onClick={save} disabled={saving} className="flex-1">
|
||
{saving && <Loader2Icon className="animate-spin" />}
|
||
{!saving && (isNew ? <PlusIcon /> : <SaveIcon />)}
|
||
{isNew ? "新建" : "保存"}
|
||
</Button>
|
||
</div>
|
||
</SheetContent>
|
||
</Sheet>
|
||
);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
export default function 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);
|
||
}, []);
|
||
|
||
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(`已激活:${name}`);
|
||
await load();
|
||
} catch (e) {
|
||
toast.error(`激活失败:${(e as Error).message}`);
|
||
}
|
||
}
|
||
|
||
async function remove(p: LLMProfile) {
|
||
if (p.is_default) {
|
||
toast.error("无法删除当前激活的配置");
|
||
return;
|
||
}
|
||
try {
|
||
await api.deleteLLMProfile(p.id);
|
||
toast.success(`已删除:${p.name}`);
|
||
await load();
|
||
} catch (e) {
|
||
toast.error(`删除失败:${(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">
|
||
全 Agent 共享的格式 / 模型 / 限速配置。点击卡片编辑,星标为当前激活配置。
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button size="sm" variant="outline" onClick={() => setPoolOpen(true)}>
|
||
<ZapIcon /> 轮询配置
|
||
{poolOn && (
|
||
<Badge variant="outline" className="ml-1 border-emerald-500/50 text-emerald-600 dark:text-emerald-400">
|
||
已开启
|
||
</Badge>
|
||
)}
|
||
</Button>
|
||
<Button size="sm" variant="outline" onClick={() => openEditor(null)}>
|
||
<PlusIcon /> 新建
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<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">代理 {p.proxy}</span>}
|
||
{p.reasoning_effort && <span>思考 {p.reasoning_effort === "off" ? "关" : p.reasoning_effort}</span>}
|
||
{/* 轮询相关的两个字段只在轮询开着时才有意义,关着时不占版面 */}
|
||
{poolOn &&
|
||
!p.is_default &&
|
||
(p.pool_exclude ? <span>不参与轮询</span> : <span>优先级 {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 ? "已激活" : "设为激活"}
|
||
</Button>
|
||
<Button
|
||
size="icon"
|
||
variant="outline"
|
||
aria-label="删除配置"
|
||
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">
|
||
还没有模型配置,点击右上角「新建」创建第一个。
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<ProfileSheet profile={editing} open={editOpen} onOpenChange={setEditOpen} onSaved={() => void load()} />
|
||
<PoolSheet open={poolOpen} onOpenChange={setPoolOpen} pool={pool} onReload={loadPool} />
|
||
</div>
|
||
);
|
||
}
|