feat: 添加LLM模型清单、命令执行记录录制和LLM请求记录录制功能

This commit is contained in:
lornlee committed 2026-07-30 15:18:40 +08:00
1 parent 0f94849012
commit 5b58330f01
14 files changed
+1546 -197

No files matched your search

@@ -0,0 +1,307 @@
"use client";
import * as React from "react";
import {
TerminalIcon,
SearchIcon,
ChevronLeftIcon,
ChevronRightIcon,
Loader2Icon,
XIcon,
} from "lucide-react";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { api } from "@/lib/api";
import type { CommandRecord } from "@/lib/types";
function fmtTime(ts: string) {
return new Date(ts).toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
// extractCommand parses the tool_use detail (JSON like {"command":"..."}) and
// returns just the command string. Falls back to raw text if not JSON.
function extractCommand(raw: string): string {
try {
const obj = JSON.parse(raw);
if (obj && typeof obj.command === "string") return obj.command;
} catch { /* not JSON */ }
return raw;
}
// truncate clips a string to maxLen characters.
function truncate(s: string, maxLen: number): string {
const first = s.split("\n")[0]; // single-line preview
if (first.length <= maxLen) return first;
return first.slice(0, maxLen) + "…";
}
const PAGE_SIZES = [25, 50, 100];
const CMD_MAX_LEN = 80;
export default function CommandsPage() {
const [page, setPage] = React.useState(0);
const [size, setSize] = React.useState(50);
const [query, setQuery] = React.useState("");
const [queryQ, setQueryQ] = React.useState("");
const [taskFilter, setTaskFilter] = React.useState("");
const [commands, setCommands] = React.useState<CommandRecord[]>([]);
const [total, setTotal] = React.useState(0);
const [loading, setLoading] = React.useState(false);
// Inline detail panel (like traffic page, not a dialog)
const [selected, setSelected] = React.useState<CommandRecord | null>(null);
// Debounce search input.
React.useEffect(() => {
const t = setTimeout(() => setQueryQ(query.trim()), 300);
return () => clearTimeout(t);
}, [query]);
// Reset page on filter change.
React.useEffect(() => {
setPage(0);
}, [queryQ, taskFilter, size]);
// Load data.
React.useEffect(() => {
let alive = true;
setLoading(true);
api
.commands({ task: taskFilter || undefined, q: queryQ || undefined, page, size })
.then((r) => {
if (!alive) return;
setCommands(r.commands ?? []);
setTotal(r.total ?? 0);
})
.catch(() => {})
.finally(() => alive && setLoading(false));
return () => { alive = false; };
}, [page, size, queryQ, taskFilter]);
const totalPages = Math.max(1, Math.ceil(total / size));
const rangeStart = total === 0 ? 0 : page * size + 1;
const rangeEnd = page * size + commands.length;
return (
<div className="flex min-h-0 flex-1 flex-col gap-4">
{/* Header */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<TerminalIcon className="h-5 w-5 text-muted-foreground" />
<h1 className="text-xl font-semibold tracking-tight">命令记录</h1>
<Badge variant="secondary">{total}</Badge>
</div>
</div>
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-2">
<div className="relative max-w-sm flex-1">
<SearchIcon className="absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="搜索命令..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-8 pl-8"
/>
</div>
<Input
placeholder="任务 ID"
className="h-8 w-28"
value={taskFilter}
onChange={(e) => setTaskFilter(e.target.value.replace(/\D/g, ""))}
/>
<Select value={String(size)} onValueChange={(v) => setSize(Number(v))}>
<SelectTrigger size="sm" className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZES.map((n) => (
<SelectItem key={n} value={String(n)}>
{n} / 页
</SelectItem>
))}
</SelectContent>
</Select>
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
<span className="tabular-nums">
{rangeStart}–{rangeEnd} / {total}
</span>
<Button
variant="outline"
size="icon"
className="size-8"
disabled={page <= 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
<ChevronLeftIcon />
</Button>
<span className="tabular-nums">
{page + 1} / {totalPages}
</span>
<Button
variant="outline"
size="icon"
className="size-8"
disabled={page + 1 >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
>
<ChevronRightIcon />
</Button>
</div>
</div>
{/* History table + inline detail (Burp-style split) */}
<div className="flex h-[calc(100vh-13rem)] min-h-0 flex-col gap-3">
<Card className="flex min-h-0 flex-1 flex-col overflow-hidden py-0">
<div className="min-h-0 flex-1 overflow-auto">
<Table>
<TableHeader className="sticky top-0 z-10 bg-card">
<TableRow>
<TableHead className="w-[130px]">时间</TableHead>
<TableHead className="w-[60px]">任务</TableHead>
<TableHead className="w-[90px]">Worker</TableHead>
<TableHead>命令</TableHead>
<TableHead className="w-[60px]">状态</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && commands.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="py-12 text-center">
<Loader2Icon className="mx-auto h-5 w-5 animate-spin text-muted-foreground" />
</TableCell>
</TableRow>
) : commands.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="py-12 text-center text-sm text-muted-foreground">
暂无命令记录
</TableCell>
</TableRow>
) : (
commands.map((cmd) => (
<TableRow
key={cmd.id}
className={cn(
"cursor-pointer",
selected?.id === cmd.id && "bg-accent hover:bg-accent",
)}
onClick={() => setSelected(cmd)}
>
<TableCell className="text-xs text-muted-foreground tabular-nums">
{fmtTime(cmd.created_at)}
</TableCell>
<TableCell className="text-xs font-mono text-muted-foreground">
#{cmd.exploration_id}
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs font-mono">
{cmd.worker || "-"}
</Badge>
</TableCell>
<TableCell className="max-w-0">
<code className="block truncate font-mono text-xs">
{truncate(extractCommand(cmd.command), CMD_MAX_LEN)}
</code>
</TableCell>
<TableCell>
{cmd.is_error ? (
<Badge variant="destructive" className="text-xs">失败</Badge>
) : (
<Badge variant="secondary" className="text-xs text-emerald-600">成功</Badge>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</Card>
{/* Inline detail panel */}
{selected && (
<Card className="flex h-[42%] min-h-0 flex-col overflow-hidden py-0">
{/* Detail header */}
<div className="flex items-center gap-2 border-b px-3 py-2">
<Badge variant="outline" className="text-xs font-mono">
#{selected.exploration_id}
</Badge>
<Badge variant="outline" className="text-xs font-mono">
{selected.worker || "-"}
</Badge>
<span className="text-xs text-muted-foreground">
{fmtTime(selected.created_at)}
</span>
{selected.is_error ? (
<Badge variant="destructive" className="text-xs">失败</Badge>
) : (
<Badge variant="secondary" className="text-xs text-emerald-600">成功</Badge>
)}
<Button
variant="ghost"
size="icon"
className="ml-auto size-7 shrink-0"
onClick={() => setSelected(null)}
>
<XIcon />
</Button>
</div>
{/* Input / Output split */}
<div className="grid min-h-0 flex-1 grid-cols-2 divide-x">
<div className="flex min-h-0 min-w-0 flex-col">
<div className="border-b px-3 py-1 text-[11px] font-medium text-muted-foreground">
输入 Command
</div>
<div className="min-h-0 flex-1 overflow-auto">
<pre className="p-3 font-mono text-xs break-all whitespace-pre-wrap">
{extractCommand(selected.command)}
</pre>
</div>
</div>
<div className="flex min-h-0 min-w-0 flex-col">
<div className="border-b px-3 py-1 text-[11px] font-medium text-muted-foreground">
输出 Output
</div>
<div className="min-h-0 flex-1 overflow-auto">
<pre className={cn(
"p-3 font-mono text-xs break-all whitespace-pre-wrap",
selected.is_error && "text-red-600 dark:text-red-400",
)}>
{selected.output || "(空)"}
</pre>
</div>
</div>
</div>
</Card>
)}
</div>
</div>
);
}
@@ -0,0 +1,365 @@
"use client";
import * as React from "react";
import {
RadioIcon,
SearchIcon,
ChevronLeftIcon,
ChevronRightIcon,
Loader2Icon,
XIcon,
} from "lucide-react";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { api } from "@/lib/api";
import type { LLMRecordItem, LLMRecordDetail } from "@/lib/types";
function fmtTime(ts: string) {
return new Date(ts).toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function fmtLatency(ms: number) {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function fmtTokens(n: number) {
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
return String(n);
}
function tryFormatJSON(s: string): string {
try {
return JSON.stringify(JSON.parse(s), null, 2);
} catch {
return s;
}
}
const PAGE_SIZES = [25, 50, 100];
export default function LLMRecordsPage() {
const [page, setPage] = React.useState(0);
const [size, setSize] = React.useState(50);
const [session, setSession] = React.useState("");
const [sessionQ, setSessionQ] = React.useState("");
const [model, setModel] = React.useState("");
const [records, setRecords] = React.useState<LLMRecordItem[]>([]);
const [total, setTotal] = React.useState(0);
const [loading, setLoading] = React.useState(false);
// Inline detail panel (Burp-style split, not a dialog)
const [selected, setSelected] = React.useState<LLMRecordItem | null>(null);
const [detail, setDetail] = React.useState<LLMRecordDetail | null>(null);
const [detailLoading, setDetailLoading] = React.useState(false);
// Debounce session filter.
React.useEffect(() => {
const t = setTimeout(() => setSessionQ(session.trim()), 300);
return () => clearTimeout(t);
}, [session]);
// Reset page on filter change.
React.useEffect(() => {
setPage(0);
}, [sessionQ, model, size]);
// Load list.
React.useEffect(() => {
let alive = true;
setLoading(true);
api
.llmRecords({ model: model || undefined, session: sessionQ || undefined, page, size })
.then((r) => {
if (!alive) return;
setRecords(r.records ?? []);
setTotal(r.total ?? 0);
})
.catch(() => {})
.finally(() => alive && setLoading(false));
return () => { alive = false; };
}, [page, size, sessionQ, model]);
// Lazy-load full request/response when a row is selected.
React.useEffect(() => {
if (!selected) {
setDetail(null);
return;
}
let alive = true;
setDetailLoading(true);
setDetail(null);
api
.llmRecordDetail(selected.id)
.then((d) => { if (alive) setDetail(d); })
.catch(() => {})
.finally(() => { if (alive) setDetailLoading(false); });
return () => { alive = false; };
}, [selected]);
const totalPages = Math.max(1, Math.ceil(total / size));
const rangeStart = total === 0 ? 0 : page * size + 1;
const rangeEnd = page * size + records.length;
return (
<div className="flex min-h-0 flex-1 flex-col gap-4">
{/* Header */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<RadioIcon className="h-5 w-5 text-muted-foreground" />
<h1 className="text-xl font-semibold tracking-tight">LLM 录制</h1>
<Badge variant="secondary">{total}</Badge>
</div>
</div>
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-2">
<div className="relative max-w-sm flex-1">
<SearchIcon className="absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="搜索 Session ID..."
value={session}
onChange={(e) => setSession(e.target.value)}
className="h-8 pl-8"
/>
</div>
<Input
placeholder="Model"
className="h-8 w-48"
value={model}
onChange={(e) => setModel(e.target.value)}
/>
<Select value={String(size)} onValueChange={(v) => setSize(Number(v))}>
<SelectTrigger size="sm" className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZES.map((n) => (
<SelectItem key={n} value={String(n)}>
{n} / 页
</SelectItem>
))}
</SelectContent>
</Select>
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
<span className="tabular-nums">
{rangeStart}–{rangeEnd} / {total}
</span>
<Button
variant="outline"
size="icon"
className="size-8"
disabled={page <= 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
<ChevronLeftIcon />
</Button>
<span className="tabular-nums">
{page + 1} / {totalPages}
</span>
<Button
variant="outline"
size="icon"
className="size-8"
disabled={page + 1 >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
>
<ChevronRightIcon />
</Button>
</div>
</div>
{/* History table + inline detail (Burp-style split) */}
<div className="flex h-[calc(100vh-13rem)] min-h-0 flex-col gap-3">
<Card className="flex min-h-0 flex-1 flex-col overflow-hidden py-0">
<div className="min-h-0 flex-1 overflow-auto">
<Table>
<TableHeader className="sticky top-0 z-10 bg-card">
<TableRow>
<TableHead className="w-[130px]">时间</TableHead>
<TableHead className="w-[60px]">任务</TableHead>
<TableHead className="w-[90px]">Worker</TableHead>
<TableHead className="w-[100px]">Profile</TableHead>
<TableHead className="w-[140px]">Model</TableHead>
<TableHead className="w-[70px]">延迟</TableHead>
<TableHead className="w-[90px]">Tokens</TableHead>
<TableHead className="w-[60px]">状态</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && records.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="py-12 text-center">
<Loader2Icon className="mx-auto h-5 w-5 animate-spin text-muted-foreground" />
</TableCell>
</TableRow>
) : records.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="py-12 text-center text-sm text-muted-foreground">
暂无 LLM 调用记录
</TableCell>
</TableRow>
) : (
records.map((rec) => (
<TableRow
key={rec.id}
className={cn(
"cursor-pointer",
selected?.id === rec.id && "bg-accent hover:bg-accent",
)}
onClick={() => setSelected(rec)}
>
<TableCell className="text-xs text-muted-foreground tabular-nums">
{fmtTime(rec.ts)}
</TableCell>
<TableCell className="text-xs font-mono text-muted-foreground">
{rec.task_id ? `#${rec.task_id}` : "-"}
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs font-mono">
{rec.worker || "-"}
</Badge>
</TableCell>
<TableCell>
<span className="text-xs">{rec.profile_name || "-"}</span>
</TableCell>
<TableCell>
<span className="text-xs font-mono">{rec.model || "-"}</span>
</TableCell>
<TableCell>
<span className={cn("text-xs", rec.latency_ms > 30000 && "text-amber-500")}>
{fmtLatency(rec.latency_ms)}
</span>
</TableCell>
<TableCell>
<span className="text-xs">
{fmtTokens(rec.input_tokens)} / {fmtTokens(rec.output_tokens)}
</span>
</TableCell>
<TableCell>
{rec.status === "ok" ? (
<Badge variant="secondary" className="text-xs text-emerald-600">OK</Badge>
) : (
<Badge variant="destructive" className="text-xs">Error</Badge>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</Card>
{/* Inline detail panel */}
{selected && (
<Card className="flex h-[42%] min-h-0 flex-col overflow-hidden py-0">
{/* Detail header */}
<div className="flex items-center gap-2 border-b px-3 py-2">
<Badge variant="outline" className="text-xs font-mono">
#{selected.id}
</Badge>
<Badge variant="outline" className="text-xs font-mono">
{selected.profile_name || "-"}
</Badge>
<Badge variant="outline" className="text-xs font-mono">
{selected.model || "-"}
</Badge>
{selected.task_id && (
<Badge variant="outline" className="text-xs font-mono">
任务 #{selected.task_id}
</Badge>
)}
<span className="text-xs text-muted-foreground">
{fmtTime(selected.ts)}
</span>
<span className={cn("text-xs", selected.latency_ms > 30000 && "text-amber-500")}>
{fmtLatency(selected.latency_ms)}
</span>
{selected.status === "ok" ? (
<Badge variant="secondary" className="text-xs text-emerald-600">OK</Badge>
) : (
<Badge variant="destructive" className="text-xs">Error</Badge>
)}
<Button
variant="ghost"
size="icon"
className="ml-auto size-7 shrink-0"
onClick={() => setSelected(null)}
>
<XIcon />
</Button>
</div>
{/* Request / Response split */}
<div className="grid min-h-0 flex-1 grid-cols-2 divide-x">
<div className="flex min-h-0 min-w-0 flex-col">
<div className="border-b px-3 py-1 text-[11px] font-medium text-muted-foreground">
Request
</div>
<div className="min-h-0 flex-1 overflow-auto">
{detailLoading ? (
<div className="flex items-center gap-2 p-3 text-xs text-muted-foreground">
<Loader2Icon className="size-3.5 animate-spin" />
加载…
</div>
) : (
<pre className="p-3 font-mono text-xs break-all whitespace-pre-wrap">
{detail?.request_body ? tryFormatJSON(detail.request_body) : "(空)"}
</pre>
)}
</div>
</div>
<div className="flex min-h-0 min-w-0 flex-col">
<div className="border-b px-3 py-1 text-[11px] font-medium text-muted-foreground">
Response
</div>
<div className="min-h-0 flex-1 overflow-auto">
{detailLoading ? (
<div className="flex items-center gap-2 p-3 text-xs text-muted-foreground">
<Loader2Icon className="size-3.5 animate-spin" />
加载…
</div>
) : (
<pre className={cn(
"p-3 font-mono text-xs break-all whitespace-pre-wrap",
selected.status !== "ok" && "text-red-600 dark:text-red-400",
)}>
{detail?.response_body ? tryFormatJSON(detail.response_body) : "(空)"}
</pre>
)}
</div>
</div>
</div>
</Card>
)}
</div>
</div>
);
}
+138 -15
View File
@@ -2,7 +2,7 @@
import * as React from "react";
import { Loader2Icon, PlugZapIcon, PlusIcon, SaveIcon, StarIcon, Trash2Icon } from "lucide-react";
import { Loader2Icon, PlugZapIcon, PlusIcon, RefreshCwIcon, SaveIcon, StarIcon, Trash2Icon } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
@@ -20,6 +20,7 @@ import {
} from "@/components/ui/dialog";
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 { api } from "@/lib/api";
@@ -60,6 +61,9 @@ function NewProfileDialog({ onCreated }: { onCreated: (id: string) => void }) {
const [cw, setCw] = React.useState("0");
const [thinkMode, setThinkMode] = React.useState<ThinkMode>("none");
const [effort, setEffort] = React.useState("high");
const [models, setModels] = React.useState<string[]>([]);
const [loadingModels, setLoadingModels] = React.useState(false);
const [modelsOpen, setModelsOpen] = React.useState(false);
function reset() {
setName("");
@@ -73,6 +77,28 @@ function NewProfileDialog({ onCreated }: { onCreated: (id: string) => void }) {
setCw("0");
setThinkMode("none");
setEffort("high");
setModels([]);
setModelsOpen(false);
}
async function loadModels() {
if (loadingModels) return;
setLoadingModels(true);
setModels([]);
try {
const r = await api.fetchLLMModels(format, baseUrl, apiKey, proxy);
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 create() {
@@ -145,13 +171,47 @@ function NewProfileDialog({ onCreated }: { onCreated: (id: string) => void }) {
</div>
<div className="grid gap-2">
<Label htmlFor="np-model">模型</Label>
<Input
id="np-model"
className="font-mono"
placeholder="claude-opus-4-8"
value={model}
onChange={(e) => setModel(e.target.value)}
/>
<div className="flex gap-2">
<Input
id="np-model"
className="font-mono"
placeholder="claude-opus-4-8"
value={model}
onChange={(e) => setModel(e.target.value)}
/>
<Popover open={modelsOpen} onOpenChange={setModelsOpen}>
<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="w-72 max-h-64 overflow-y-auto p-1" align="end">
{models.map((m) => (
<button
key={m}
type="button"
className="w-full 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>
<div className="grid gap-2">
@@ -276,6 +336,9 @@ export default function LLMPage() {
const [thinkMode, setThinkMode] = React.useState<ThinkMode>("none");
const [effort, setEffort] = React.useState("high");
const [testing, setTesting] = React.useState(false);
const [models, setModels] = React.useState<string[]>([]);
const [loadingModels, setLoadingModels] = React.useState(false);
const [modelsOpen, setModelsOpen] = React.useState(false);
const load = React.useCallback(async () => {
try {
@@ -312,6 +375,32 @@ export default function LLMPage() {
setKeyHint(selected.api_key_hint ?? "");
}, [selected]);
async function loadModels() {
if (loadingModels) return;
setLoadingModels(true);
setModels([]);
try {
const r = await api.fetchLLMModels(
format,
baseUrl,
apiKey,
proxy,
selectedId ? Number(selectedId) : undefined,
);
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);
@@ -451,13 +540,47 @@ export default function LLMPage() {
<div className="grid gap-2">
<Label htmlFor="model">模型</Label>
<Input
id="model"
className="font-mono"
placeholder="claude-opus-4-8"
value={model}
onChange={(e) => setModel(e.target.value)}
/>
<div className="flex gap-2">
<Input
id="model"
className="font-mono"
placeholder="claude-opus-4-8"
value={model}
onChange={(e) => setModel(e.target.value)}
/>
<Popover open={modelsOpen} onOpenChange={setModelsOpen}>
<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="w-72 max-h-64 overflow-y-auto p-1" align="end">
{models.map((m) => (
<button
key={m}
type="button"
className="w-full 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">