feat: 添加LLM模型清单、命令执行记录录制和LLM请求记录录制功能
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user