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">
+30
View File
@@ -12,6 +12,7 @@ import type {
Tool, Conversation, AgentTrigger,
InterceptRule, InterceptPending, InterceptApprovalRow,
TaskAssetView, SSProject, SSTask, ConvTokenSummary,
CommandRecord, LLMRecordItem, LLMRecordDetail,
} from "@/lib/types";
function getToken(): string | null {
@@ -248,6 +249,14 @@ export const api = {
}) => post<{ id: number }>("/llm/profiles", p),
deleteLLMProfile: (id: string) => del<{ deleted: number }>(`/llm/profiles/${id}`),
activateLLMProfile: (id: string) => post<{ ok: boolean }>("/llm/profiles/active", { id: Number(id) }),
fetchLLMModels: (provider: string, base_url: string, api_key: string, proxy = "", profile_id?: number) =>
post<{ ok: boolean; error?: string; models?: string[] }>("/llm/models", {
provider,
base_url,
api_key,
proxy,
profile_id,
}),
// ---- agents ----
agents: () => get<{ agents: Agent[] }>("/agents").then((r) => arr(r.agents)),
@@ -440,4 +449,25 @@ export const api = {
});
if (!r.ok) throw new Error(await r.text());
},
// ---- commands (Bash execution history) ----
commands: (params?: { task?: string; q?: string; page?: number; size?: number }) => {
const sp = new URLSearchParams();
if (params?.task) sp.set("task", params.task);
if (params?.q) sp.set("q", params.q);
sp.set("page", String(params?.page ?? 0));
sp.set("size", String(params?.size ?? 50));
return get<{ commands: CommandRecord[]; total: number }>(`/commands?${sp}`);
},
// ---- LLM records ----
llmRecords: (params?: { model?: string; session?: string; page?: number; size?: number }) => {
const sp = new URLSearchParams();
if (params?.model) sp.set("model", params.model);
if (params?.session) sp.set("session", params.session);
sp.set("page", String(params?.page ?? 0));
sp.set("size", String(params?.size ?? 50));
return get<{ records: LLMRecordItem[]; total: number }>(`/llm/records?${sp}`);
},
llmRecordDetail: (id: number) => get<LLMRecordDetail>(`/llm/records/${id}`),
};
+39 -179
View File
@@ -1,185 +1,45 @@
import {
DM_Sans,
Figtree,
Geist,
Geist_Mono,
Inter,
JetBrains_Mono,
Lora,
Merriweather,
Noto_Sans,
Noto_Serif,
Nunito_Sans,
Outfit,
Playfair_Display,
Public_Sans,
Raleway,
Roboto,
Roboto_Slab,
} from "next/font/google";
// TEMPORARY: offline build fallback — uses local Geist fonts instead of Google Fonts.
// Restore registry.ts.bak after build.
import localFont from "next/font/local";
import { GeistPixelSquare } from "geist/font/pixel";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
});
const notoSans = Noto_Sans({
subsets: ["latin"],
variable: "--font-noto-sans",
});
const roboto = Roboto({
subsets: ["latin"],
weight: ["400", "500", "700"],
variable: "--font-roboto",
});
const geist = Geist({
subsets: ["latin"],
variable: "--font-geist",
});
const outfit = Outfit({
subsets: ["latin"],
variable: "--font-outfit",
});
const geistMono = Geist_Mono({
subsets: ["latin"],
variable: "--font-geist-mono",
});
const dmSans = DM_Sans({
subsets: ["latin"],
variable: "--font-dm-sans",
});
const nunitoSans = Nunito_Sans({
subsets: ["latin"],
variable: "--font-nunito-sans",
});
const figtree = Figtree({
subsets: ["latin"],
variable: "--font-figtree",
});
const raleway = Raleway({
subsets: ["latin"],
variable: "--font-raleway",
});
const publicSans = Public_Sans({
subsets: ["latin"],
variable: "--font-public-sans",
});
const jetBrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-jetbrains-mono",
});
const notoSerif = Noto_Serif({
subsets: ["latin"],
variable: "--font-noto-serif",
});
const robotoSlab = Roboto_Slab({
subsets: ["latin"],
variable: "--font-roboto-slab",
});
const merriweather = Merriweather({
subsets: ["latin"],
weight: ["400", "700"],
variable: "--font-merriweather",
});
const lora = Lora({
subsets: ["latin"],
variable: "--font-lora",
});
const playfairDisplay = Playfair_Display({
subsets: ["latin"],
variable: "--font-playfair-display",
});
const geist = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2", variable: "--font-geist" });
const inter = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2", variable: "--font-inter" });
const notoSans = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2", variable: "--font-noto-sans" });
const roboto = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2", variable: "--font-roboto" });
const outfit = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2", variable: "--font-outfit" });
const dmSans = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2", variable: "--font-dm-sans" });
const nunitoSans = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2", variable: "--font-nunito-sans" });
const figtree = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2", variable: "--font-figtree" });
const raleway = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2", variable: "--font-raleway" });
const publicSans = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2", variable: "--font-public-sans" });
const notoSerif = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2", variable: "--font-noto-serif" });
const robotoSlab = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2", variable: "--font-roboto-slab" });
const merriweather = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2", variable: "--font-merriweather" });
const lora = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2", variable: "--font-lora" });
const playfairDisplay = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2", variable: "--font-playfair-display" });
const geistMono = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-mono/GeistMono-Variable.woff2", variable: "--font-geist-mono" });
const jetBrainsMono = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-mono/GeistMono-Variable.woff2", variable: "--font-jetbrains-mono" });
export const fontRegistry = {
geist: {
label: "Geist",
font: geist,
},
inter: {
label: "Inter",
font: inter,
},
notoSans: {
label: "Noto Sans",
font: notoSans,
},
nunitoSans: {
label: "Nunito Sans",
font: nunitoSans,
},
figtree: {
label: "Figtree",
font: figtree,
},
roboto: {
label: "Roboto",
font: roboto,
},
raleway: {
label: "Raleway",
font: raleway,
},
dmSans: {
label: "DM Sans",
font: dmSans,
},
publicSans: {
label: "Public Sans",
font: publicSans,
},
outfit: {
label: "Outfit",
font: outfit,
},
geistMono: {
label: "Geist Mono",
font: geistMono,
},
geistPixelSquare: {
label: "Geist Pixel Square",
font: GeistPixelSquare,
},
jetBrainsMono: {
label: "JetBrains Mono",
font: jetBrainsMono,
},
notoSerif: {
label: "Noto Serif",
font: notoSerif,
},
robotoSlab: {
label: "Roboto Slab",
font: robotoSlab,
},
merriweather: {
label: "Merriweather",
font: merriweather,
},
lora: {
label: "Lora",
font: lora,
},
playfairDisplay: {
label: "Playfair Display",
font: playfairDisplay,
},
geist: { label: "Geist", font: geist },
inter: { label: "Inter", font: inter },
notoSans: { label: "Noto Sans", font: notoSans },
nunitoSans: { label: "Nunito Sans", font: nunitoSans },
figtree: { label: "Figtree", font: figtree },
roboto: { label: "Roboto", font: roboto },
raleway: { label: "Raleway", font: raleway },
dmSans: { label: "DM Sans", font: dmSans },
publicSans: { label: "Public Sans", font: publicSans },
outfit: { label: "Outfit", font: outfit },
geistMono: { label: "Geist Mono", font: geistMono },
geistPixelSquare: { label: "Geist Pixel Square", font: GeistPixelSquare },
jetBrainsMono: { label: "JetBrains Mono", font: jetBrainsMono },
notoSerif: { label: "Noto Serif", font: notoSerif },
robotoSlab: { label: "Roboto Slab", font: robotoSlab },
merriweather: { label: "Merriweather", font: merriweather },
lora: { label: "Lora", font: lora },
playfairDisplay: { label: "Playfair Display", font: playfairDisplay },
} as const;
export type FontKey = keyof typeof fontRegistry;
@@ -188,7 +48,7 @@ export const fontVars = (Object.values(fontRegistry) as Array<(typeof fontRegist
.map((f) => f.font.variable)
.join(" ");
export const fontOptions = (Object.entries(fontRegistry) as Array<[FontKey, (typeof fontRegistry)[FontKey]]>).map(
export const fontOptions = (Object.entries(fontRegistry) as [FontKey, { label: string; font: { variable: string } }][]).map(
([key, f]) => ({
key,
label: f.label,
+34
View File
@@ -549,3 +549,37 @@ export interface ConvTokenSummary {
cache_read_tokens: number;
cache_write_tokens: number;
}
// ---- Command recording (Bash execution history) ----
export interface CommandRecord {
id: number;
exploration_id: number;
worker: string;
command: string;
output: string;
is_error: boolean;
created_at: string;
}
// ---- LLM recording ----
export interface LLMRecordItem {
id: number;
ts: string;
model: string;
profile_name: string;
session_id: string;
task_id: string;
worker: string;
latency_ms: number;
input_tokens: number;
output_tokens: number;
cache_read: number;
cache_write: number;
status: string;
error?: string;
}
export interface LLMRecordDetail extends LLMRecordItem {
request_body: string;
response_body: string;
}
@@ -10,11 +10,13 @@ import {
MessageSquare,
Network,
Plug,
Radio,
ScrollText,
Settings2,
ShieldAlert,
Sparkles,
Target,
Terminal,
Wrench,
} from "lucide-react";
@@ -66,6 +68,8 @@ export const sidebarItems: NavGroup[] = [
{ id: "tasks", title: "任务", url: "/function/tasks", icon: Target },
{ id: "findings", title: "发现", url: "/function/findings", icon: Bug },
{ id: "traffic", title: "流量", url: "/function/traffic", icon: Activity },
{ id: "commands", title: "命令", url: "/function/commands", icon: Terminal },
{ id: "llm-records", title: "LLM 录制", url: "/function/llm-records", icon: Radio },
{ id: "assets", title: "资产", url: "/function/assets", icon: Network },
{ id: "sync", title: "资产同步", url: "/function/sync", icon: FolderSync },
],