From 7090b034f8141719efa8134f7d8f0186da60f7c0 Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:25:45 -0400 Subject: [PATCH] =?UTF-8?q?refactor(ui):=20LLM=20=E9=A1=B5=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E9=85=8D=E7=BD=AE=E5=8D=A1=E7=89=87=E5=88=97=E8=A1=A8?= =?UTF-8?q?=20+=20=E6=8A=BD=E5=B1=89=E7=BC=96=E8=BE=91,=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E8=BD=AE=E8=AF=A2=E9=85=8D=E7=BD=AE=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来是「上边轮询卡片 + 下边左列表右详情」的分栏布局,详情区常驻占掉三分之二 版面。改为: - 页面主体 = 模型配置卡片网格,点卡片打开右侧抽屉编辑/测试/保存 - 卡片右上角显示该配置是否正常:未配置 Key / 正常 / 异常·失败 N 次 / 已熔断·冷却 xx,悬停出具体错误 - 工具栏「新建」左边加「轮询配置」入口,抽屉里放开关、兜底开关、实际链路 顺序、熔断状态与恢复操作;开启时按钮上带「已开启」徽章 新建与编辑合用同一个抽屉(profile === null 即新建)。原先新建是 Dialog、编辑 是常驻表单,两份几乎完全相同的表单代码约 150 行,加字段得改两处——现在只有 一份 ProfileSheet。 抽屉的开关状态与内容状态分开存(editOpen / editing):否则关闭动画那 200ms 里标题会从「编辑 XX」闪成「新建模型配置」。 卡片上的优先级/不参与轮询只在轮询开启时才显示——关着时这两个字段没有意义, 不占版面。 注:卡片的「正常」是排除法得出的弱信号(填了 Key 且轮询没记录到它失败过), 不是主动探测——从未被调用过的错误配置也会显示正常。 --- web/src/app/(main)/system/llm/page.tsx | 1295 ++++++++++-------------- 1 file changed, 521 insertions(+), 774 deletions(-) diff --git a/web/src/app/(main)/system/llm/page.tsx b/web/src/app/(main)/system/llm/page.tsx index 6de5007..fb37607 100644 --- a/web/src/app/(main)/system/llm/page.tsx +++ b/web/src/app/(main)/system/llm/page.tsx @@ -17,25 +17,16 @@ import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; +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 { LLMPoolStatus, LLMProfile } from "@/lib/types"; +import type { LLMPoolMember, LLMPoolStatus, LLMProfile } from "@/lib/types"; import { cn } from "@/lib/utils"; // 思考强度档位(仅在开关打开时生效)。 @@ -59,54 +50,74 @@ const toStore = (mode: ThinkMode, effort: string) => (mode === "on" ? effort : m const modeFromStore = (v?: string): ThinkMode => (!v ? "none" : v === "off" ? "off" : "on"); const effortFromStore = (v?: string) => (v && v !== "off" ? v : "high"); -// 熔断状态 → 徽章样式。degraded 是「在失败但还没到熔断阈值」的中间态。 -const STATE_BADGE: Record = { - ok: { label: "正常", cls: "border-emerald-500/50 text-emerald-600 dark:text-emerald-400" }, - degraded: { label: "异常", cls: "border-amber-500/50 text-amber-600 dark:text-amber-400" }, - tripped: { label: "已熔断", cls: "border-destructive/50 text-destructive" }, -}; - function cooldownText(secs: number) { if (secs <= 0) return ""; if (secs < 60) return `${secs}s`; return `${Math.ceil(secs / 60)}min`; } -// PoolCard 是轮询功能的总控:主开关、绑定兜底开关,以及后端算出来的**实际**链路顺序。 -// 顺序预览很关键——「激活 + 优先级 + 不参与轮询」三个因素叠加后,光看单张配置卡片 -// 是猜不出真实顺序的。 -function PoolCard({ refreshKey }: { refreshKey: number }) { - const [pool, setPool] = React.useState(null); +// 一个配置在卡片上显示的「是否正常」。没填 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; +}) { const [busy, setBusy] = React.useState(false); - const load = React.useCallback(async () => { - try { - setPool(await api.llmPool()); - } catch { - /* ignore */ - } - }, []); - // biome-ignore lint/correctness/useExhaustiveDependencies: refreshKey is a prop — bumping it is exactly how the parent asks for a refetch after a profile edit + // 冷却倒计时是后端算出的剩余秒数——抽屉开着且有配置不正常时才定时拉,让它走起来。 React.useEffect(() => { - void load(); - }, [load, refreshKey]); - // 冷却倒计时是后端算的剩余秒数,定时拉一次让它走起来(只在有熔断时才轮询)。 - React.useEffect(() => { - if (!pool?.enabled || !pool.chain.some((m) => m.state !== "ok")) return; - const t = setInterval(() => void load(), 10_000); + if (!open || !pool?.enabled || !pool.chain.some((m) => m.state !== "ok")) return; + const t = setInterval(() => void onReload(), 10_000); return () => clearInterval(t); - }, [pool, load]); + }, [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 load(); + await onReload(); if (patch.llm_pool_enabled !== undefined) { toast.success(patch.llm_pool_enabled ? "已开启 LLM 轮询" : "已关闭 LLM 轮询"); } else { - toast.success("已更新绑定兜底设置"); + toast.success("已更新兜底设置"); } } catch (e) { toast.error(`设置失败:${(e as Error).message}`); @@ -117,7 +128,8 @@ function PoolCard({ refreshKey }: { refreshKey: number }) { async function recover(id?: string) { try { - setPool(await api.resetLLMPool(id)); + await api.resetLLMPool(id); + await onReload(); toast.success(id ? "已恢复该配置" : "已恢复全部配置"); } catch (e) { toast.error(`恢复失败:${(e as Error).message}`); @@ -125,180 +137,214 @@ function PoolCard({ refreshKey }: { refreshKey: number }) { } 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 ( - - - - LLM 轮询 · 故障转移 - - - 开启后,未指定模型的 Agent 在当前配置不可用(余额不足 / Key 失效 / 限流 / - 服务异常)时自动切换到下一个配置。 指定了模型的 Agent 与任务默认不参与轮询。 - - - void toggle({ llm_pool_enabled: v })} - aria-label="LLM 轮询开关" - /> - - - {enabled && ( - + + + + + LLM 轮询 · 故障转移 + + + 开启后,未指定模型的 Agent 在当前配置不可用(余额不足 / Key 失效 / 限流 / + 服务异常)时自动切到下一个配置。 + + + +
- -

- 默认关闭:Agent 或任务指定了某个配置就只用它,失败即失败(不会悄悄换成别的模型)。 - 开启后,指定的配置失败时也会回落到下面的轮询链。 -

+ +

默认关闭。关闭时始终只用激活配置,失败即失败。

void toggle({ llm_pool_bind_fallback: v })} - aria-label="绑定配置失败兜底开关" + onCheckedChange={(v) => void toggle({ llm_pool_enabled: v })} + aria-label="LLM 轮询开关" />
-
-
- - {tripped.length > 0 && ( - - )} -
- {inChain.length < 2 && ( -

- 当前只有 {inChain.length} 个可用配置,轮询不会生效——至少需要 2 个已填 API Key 且参与轮询的配置。 -

- )} -
- {chain.map((m) => { - const badge = STATE_BADGE[m.state] ?? STATE_BADGE.ok; - const order = - m.excluded && !m.active ? null : inChain.findIndex((x) => x.profile_id === m.profile_id) + 1; - return ( -
- - {order ?? "—"} - - {m.name} - {m.model} - {m.active && ( - - 激活 - - )} - {m.excluded && !m.active && 不参与轮询} - {!m.active && 优先级 {m.priority}} -
- {m.state === "tripped" && m.cooldown_secs > 0 && ( - 冷却 {cooldownText(m.cooldown_secs)} + {enabled && ( + <> +
+
+ +

+ 默认关闭:Agent 或任务指定了某个配置就只用它,失败即失败(不会悄悄换成别的模型)。 + 开启后,指定的配置失败时也会回落到下面的轮询链。 +

+
+ void toggle({ llm_pool_bind_fallback: v })} + aria-label="绑定配置失败兜底开关" + /> +
+ + + +
+
+ + {tripped.length > 0 && ( + + )} +
+ {inChain.length < 2 && ( +

+ 当前只有 {inChain.length} 个可用配置,轮询不会生效——至少需要 2 个已填 API Key 且参与轮询的配置。 +

+ )} + {chain.map((m) => { + const excluded = m.excluded && !m.active; + const order = excluded ? null : inChain.findIndex((x) => x.profile_id === m.profile_id) + 1; + return ( +
连续失败 {m.fails} 次 - )} - - {badge.label} - - {m.state !== "ok" && ( - + > +
+ + {order ?? "—"} + + {m.name} + {m.active && ( + + 激活 + + )} + {excluded && 不参与轮询} +
+ {m.state === "tripped" && m.cooldown_secs > 0 && ( + 冷却 {cooldownText(m.cooldown_secs)} + )} + {m.state === "degraded" && ( + 连续失败 {m.fails} 次 + )} + {m.state !== "ok" && ( + + )} +
+
+
+ {m.model} + {!m.active && 优先级 {m.priority}} +
+ {m.last_error && ( +

+ {m.last_error} +

)}
- {m.last_error && ( -

- {m.last_error} -

- )} + ); + })} + {chain.length === 0 && ( +
+ 暂无配置
- ); - })} - {chain.length === 0 && ( -
- 暂无配置 -
- )} -
-

- 激活配置恒为第 1 顺位,其余按优先级从高到低。某个配置失败后进入冷却(60s → 5min → 30min), - 冷却期内被跳过;恢复后自动切回。上下文窗口装不下当前请求的配置会被跳过。 -

-
- - )} - + )} +
+ +
+ 激活配置恒为第 1 顺位,其余按优先级从高到低(在各配置里设置)。某个配置失败后进入冷却 (60s → 5min → + 30min),冷却期内被跳过,恢复后自动切回。上下文窗口装不下当前请求的配置会被跳过。 指定了模型的 Agent + 与任务默认不参与轮询。 +
+ + )} +
+ + ); } -function NewProfileDialog({ onCreated }: { onCreated: (id: string) => void }) { - const [open, setOpen] = React.useState(false); +// ───────────────────────────────────────────────────────────────────────────── +// 模型配置抽屉(新建 / 编辑共用同一套表单) +// ───────────────────────────────────────────────────────────────────────────── + +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">("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"); + const [cw, setCw] = React.useState("0"); // 上下文窗口(K tokens);0=默认200K const [thinkMode, setThinkMode] = React.useState("none"); const [effort, setEffort] = React.useState("high"); - const [priority, setPriority] = React.useState("0"); + const [priority, setPriority] = React.useState("0"); // 轮询顺位;越大越先 const [poolExclude, setPoolExclude] = React.useState(false); + const [testing, setTesting] = React.useState(false); + const [saving, setSaving] = React.useState(false); const [models, setModels] = React.useState([]); const [loadingModels, setLoadingModels] = React.useState(false); const [modelsOpen, setModelsOpen] = React.useState(false); - function reset() { - setName(""); - setFormat("anthropic"); - setModel(""); - setBaseUrl(""); - setProxy(""); + // 每次打开时从传入的 profile 灌一遍表单(新建则重置为默认值)。抽屉关掉再打开 + // 就是一次干净的开始,不会留下上一个配置的残影。 + React.useEffect(() => { + if (!open) return; + setName(profile?.name ?? ""); + setFormat(profile?.format === "openai" ? "openai" : "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)); + setThinkMode(modeFromStore(profile?.reasoning_effort)); + setEffort(effortFromStore(profile?.reasoning_effort)); + setPriority(String(profile?.priority ?? 0)); + setPoolExclude(profile?.pool_exclude ?? false); setApiKey(""); - setRps("0"); - setRpm("0"); - setCw("0"); - setThinkMode("none"); - setEffort("high"); - setPriority("0"); - setPoolExclude(false); + 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); + const r = await api.fetchLLMModels(format, baseUrl, apiKey, proxy, profileId); if (r.ok && r.models && r.models.length > 0) { setModels(r.models); setModelsOpen(true); @@ -313,13 +359,32 @@ function NewProfileDialog({ onCreated }: { onCreated: (id: string) => void }) { } } - async function create() { + async function testConnection() { + if (testing) return; + setTesting(true); + try { + // 用配置实际会跑的思考参数来测,这样不支持该字段的模型在这里就失败, + // 而不是等到跑任务时才炸。传 profile id:Key 输入框留空时用已存的 Key。 + const r = await api.testLLM(format, model, baseUrl, apiKey, proxy, toStore(thinkMode, effort), profileId); + if (r.ok) toast.success(`连接成功 · ${r.latency_ms ?? "?"}ms · ${r.model ?? model}`); + 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(), @@ -333,48 +398,52 @@ function NewProfileDialog({ onCreated }: { onCreated: (id: string) => void }) { priority: Number(priority) || 0, pool_exclude: poolExclude, }); - toast.success(`已新建 Profile:${name.trim()}(在列表中「设为激活」以启用)`); - reset(); - setOpen(false); - onCreated(String(id)); + 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}`); + toast.error(`保存失败:${(e as Error).message}`); + } finally { + setSaving(false); } } return ( - { - setOpen(o); - if (!o) reset(); - }} - > - - - - + e.preventDefault()} > - - 新建 Profile - 新建后不会自动激活,请在列表中「设为激活」以启用。 - -
-
- - setName(e.target.value)} - /> -
+ + + {isNew ? "新建模型配置" : `编辑:${profile?.name}`} + {profile?.is_default && ( + + 激活中 + + )} + + + {isNew + ? "新建后不会自动激活,请在卡片上「设为激活」以启用。" + : "修改后点击保存;激活配置保存后对全部 Agent 立即生效。"} + + + +
+
+ + setName(e.target.value)} + /> +
-
- -
- setModel(e.target.value)} - /> - {/* modal: this Popover lives inside a Dialog, and its content is - portaled to — outside the Dialog's react-remove-scroll - lock, which then cancels every wheel event over it (the list - rendered but refused to scroll). modal makes the Popover own - the topmost scroll lock, so its own list scrolls again. */} - - - - - {models.length > 0 && ( - - {models.map((m) => ( - - ))} - - )} - -
+
+ +
+ +
+ setModel(e.target.value)} + /> + {/* modal: 这个 Popover 的内容被 portal 到 ,在 Sheet 的滚动锁之外, + 不加 modal 时列表能渲染却滚不动。modal 让它自己持有最上层滚动锁。 */} + + + + + {models.length > 0 && ( + + {models.map((m) => ( + + ))} + + )} +
+
- + setBaseUrl(e.target.value)} />
+
- + setProxy(e.target.value)} /> +

+ 仅 LLM 出站请求走此代理,支持 http/https/socks5;留空则用环境变量(HTTP_PROXY/HTTPS_PROXY)。 +

+
- + setApiKey(e.target.value)} />
+
- - setRps(e.target.value)} /> + + setRps(e.target.value)} />
- - setRpm(e.target.value)} /> + + setRpm(e.target.value)} />
- + void }) { />
+

+ 限速 0 = 不限,全 Agent 共享。上下文窗口单位 K(千 token),0 = 默认 200K,上限 1000(即 + 1M);设太高会导致压缩不触发。 +

+
-
void }) {

- 开启后不会被当作故障转移目标(仍可被 Agent / 任务显式指定使用)。 + 开启后不会被当作故障转移目标(仍可被 Agent / 任务显式指定使用)。 适合「只给某个 Agent + 专用、不希望别人失败时烧掉」的昂贵配置。

+
@@ -541,7 +624,9 @@ function NewProfileDialog({ onCreated }: { onCreated: (id: string) => void }) {
-

推理投入档位,越高越深入。

+

+ 推理投入档位,越高越深入(OpenAI reasoning_effort / Anthropic output_config.effort)。 +

setName(e.target.value)} - /> -
-
- - -
-
- -
- -
- setModel(e.target.value)} - /> - - - - - {models.length > 0 && ( - - {models.map((m) => ( - - ))} - - )} - -
-
- -
- - setBaseUrl(e.target.value)} - /> -
- -
- - setProxy(e.target.value)} - /> -

- 仅 LLM 出站请求走此代理,支持 http/https/socks5;留空则用环境变量(HTTP_PROXY/HTTPS_PROXY)。 -

-
- -
- - setApiKey(e.target.value)} - /> -
- -
-
- - setRps(e.target.value)} /> -
-
- - setRpm(e.target.value)} /> -

0 = 不限

-
-
- -
- - setCw(e.target.value)} - placeholder="200" - /> -

- 模型上下文窗口,用于压缩阈值。单位 K(千 token);0/留空 = 默认 200K;上限 1000(即 - 1M)。设太高会导致压缩不触发。 -

-
- -
-
-
- -

- 数字越大越先被选中;激活配置恒为第 1 - 顺位,与本值无关。相同优先级的配置会轮流打头,天然分摊额度。 -

-
- setPriority(e.target.value)} - /> -
-
-
- -

- 开启后不会被当作故障转移目标(仍可被 Agent / 任务显式指定使用)。 适合「只给某个 Agent - 专用、不希望别人失败时烧掉」的昂贵配置。 -

-
- -
-
- -
-
-
- -

- 不发送=不带思考字段(兼容 MiniMax 等不支持该字段的模型);显式关闭=发 - disabled(默认开思考的模型才需要);开启=先推理再作答。 -

-
- -
- {thinkMode === "on" && ( -
-
- -

- 推理投入档位,越高越深入(OpenAI reasoning_effort / Anthropic output_config.effort)。 -

-
- -
- )} -
- - -
- - -
- - ) : ( - -
- 还没有 Profile,点击左侧「新建」创建第一个。 -
-
+
+
+

LLM

+

+ 全 Agent 共享的格式 / 模型 / 限速配置。点击卡片编辑,星标为当前激活配置。 +

+
+
+ - -
-
- ))} - {profiles.length === 0 && ( -
- 暂无 Profile -
- )} - - + +
+ +
+ {profiles.map((p) => { + const h = healthOf(p, health.get(p.id)); + return ( + // biome-ignore lint/a11y/useSemanticElements: 卡片内含自己的操作按钮,用原生 + +
+ + + ); + })} + {profiles.length === 0 && ( +
+ 还没有模型配置,点击右上角「新建」创建第一个。 +
+ )} +
+ + void load()} /> +
); }