Files
2026-09-07 18:24:55 +08:00

513 lines
16 KiB
TypeScript

"use client";
import { AlertCircle,CheckCircle2, Download, Loader2 } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ToolMetricsPanel } from "@/components/workspace/admin/tool-metrics-panel";
import {
downloadLlmMetricsExcel,
type LlmMetricsFilters,
} from "@/core/llm-metrics/api";
import { useLlmMetrics } from "@/core/llm-metrics/hooks";
import { cn } from "@/lib/utils";
import { useTheme } from "@/contexts/ThemeContext";
const PAGE_SIZE = 50;
type StatusFilter = "all" | "success" | "error";
function displayUser(row: { user_name?: string | null; user_id?: string | null }): string {
// Prefer the resolved email's local part (sent by the backend). Fall back to
// the raw UUID only when no account record exists for the recorded user_id —
// showing a UUID is uglier but preserves the link to who triggered the call.
if (row.user_name && row.user_name.trim()) return row.user_name.trim();
const id = (row.user_id ?? "").trim();
return id || "—";
}
function formatDate(value?: string | null): string {
if (!value) return "—";
try {
return new Date(value).toLocaleString();
} catch {
return value;
}
}
function formatNumber(value?: number | null, fractionDigits = 0): string {
if (value === null || value === undefined || Number.isNaN(value)) return "—";
return value.toLocaleString(undefined, {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
});
}
function formatDuration(value?: number | null): string {
if (value === null || value === undefined || Number.isNaN(value)) return "—";
if (value === 0) return "0 ms";
if (value < 1000) return `${value} ms`;
if (value < 60_000) return `${(value / 1000).toFixed(1)} s`;
const minutes = Math.floor(value / 60_000);
const seconds = Math.round((value % 60_000) / 1000);
return `${minutes}m ${seconds}s`;
}
function speedColor(tps?: number | null): string {
if (tps == null) return "#9ca3af";
if (tps >= 50) return "#16a34a";
if (tps >= 30) return "#65a30d";
if (tps >= 15) return "#eab308";
if (tps >= 5) return "#f97316";
return "#ef4444";
}
/** The original LLM-call metrics view, now one tab of the metrics page. */
function LlmMetricsPanel() {
const { theme } = useTheme();
const [page, setPage] = useState(0);
const [model, setModel] = useState("");
const [user, setUser] = useState("");
const [status, setStatus] = useState<StatusFilter>("all");
const [since, setSince] = useState("");
const [until, setUntil] = useState("");
const [exporting, setExporting] = useState(false);
/** 成功 toast:仅暗色模式,浅色走 Sonner 默认 */
const successToastDarkOptions = {
classNames: {
toast:
"!rounded-[2px] !border !border-[#106455] !bg-[#052934] !text-[#FFFFFF] shadow-md",
title: "!text-[#FFFFFF]",
description: "!text-[#FFFFFF]",
},
icon: <CheckCircle2 className="size-4 shrink-0 text-[#2FD69A]" />,
} as const;
/** 失败 toast:仅暗色模式,浅色走 Sonner 默认 */
const errorToastDarkOptions = {
classNames: {
toast:
"!rounded-[2px] !border !border-[#7F1A2A] !bg-[#311629] !text-[#FFFFFF] shadow-md",
title: "!text-[#FFFFFF]",
description: "!text-[#FFFFFF]",
},
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
} as const;
const darkToastOpts =
theme === "dark"
? { success: successToastDarkOptions, error: errorToastDarkOptions }
: { success: undefined, error: undefined };
const filters = useMemo<LlmMetricsFilters>(() => {
const out: LlmMetricsFilters = {};
if (model.trim()) out.model_name = model.trim();
if (user.trim()) out.user_name = user.trim();
if (status !== "all") out.status = status;
if (since) out.since = new Date(since).toISOString();
if (until) out.until = new Date(until).toISOString();
return out;
}, [model, user, status, since, until]);
const query = useMemo(
() => ({ ...filters, limit: PAGE_SIZE, offset: page * PAGE_SIZE }),
[filters, page],
);
const { data, isLoading, error, isFetching } = useLlmMetrics(query);
const handleExport = async () => {
setExporting(true);
try {
await downloadLlmMetricsExcel(filters);
toast.success("已导出为 Excel",darkToastOpts.success);
} catch (e) {
toast.error(e instanceof Error ? e.message : "导出失败",darkToastOpts.error);
} finally {
setExporting(false);
}
};
const resetFilters = () => {
setModel("");
setUser("");
setStatus("all");
setSince("");
setUntil("");
setPage(0);
};
const items = data?.items ?? [];
const total = data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
// Hide columns that are empty for every visible row. Keeps the table dense
// when older records lack a tokens breakdown — better to drop the column
// than fill the page with placeholder dashes.
const visibleColumns = useMemo(() => {
type Col = {
id: string;
header: string;
align?: "left" | "right";
alwaysShow?: boolean;
hasValue: (row: typeof items[number]) => boolean;
render: (row: typeof items[number]) => React.ReactNode;
title?: (row: typeof items[number]) => string | undefined;
className?: string;
};
const columns: Col[] = [
{
id: "time",
header: "时间",
alwaysShow: true,
hasValue: () => true,
render: (row) => formatDate(row.created_at),
},
{
id: "user",
header: "用户",
alwaysShow: true,
hasValue: () => true,
render: (row) => displayUser(row),
title: (row) => row.user_id ?? undefined,
},
{
id: "model",
header: "模型",
alwaysShow: true,
hasValue: () => true,
render: (row) => row.model_name ?? "—",
},
{
id: "agent",
header: "Agent",
hasValue: (row) => Boolean(row.agent_name),
render: (row) => row.agent_name ?? "—",
},
{
id: "input",
header: "输入",
align: "right",
hasValue: (row) => row.input_tokens != null && row.input_tokens > 0,
render: (row) => formatNumber(row.input_tokens),
className: "tabular-nums",
},
{
id: "output",
header: "输出",
align: "right",
hasValue: (row) => row.output_tokens != null && row.output_tokens > 0,
render: (row) => formatNumber(row.output_tokens),
className: "tabular-nums",
},
{
id: "total",
header: "总 Token",
align: "right",
hasValue: (row) => row.total_tokens != null && row.total_tokens > 0,
render: (row) => formatNumber(row.total_tokens),
className: "tabular-nums",
},
{
id: "duration",
header: "耗时",
align: "right",
alwaysShow: true,
hasValue: (row) => row.duration_ms != null && row.duration_ms > 0,
render: (row) => formatDuration(row.duration_ms),
title: (row) => `${row.duration_ms} ms`,
className: "tabular-nums",
},
{
id: "tokens_per_sec",
header: "tok/s",
align: "right",
hasValue: (row) => row.tokens_per_sec != null && row.tokens_per_sec > 0,
render: (row) =>
row.tokens_per_sec != null ? (
<span
className="font-mono tabular-nums"
style={{ color: speedColor(row.tokens_per_sec) }}
>
{row.tokens_per_sec.toFixed(1)}
</span>
) : (
"—"
),
},
{
id: "status",
header: "状态",
alwaysShow: true,
hasValue: () => true,
render: (row) => (
<Badge
variant={row.status === "success" ? "default" : "destructive"}
>
{row.status === "success" ? "成功" : "失败"}
</Badge>
),
},
{
id: "error",
header: "错误",
hasValue: (row) => Boolean(row.error_type || row.error_message),
render: (row) => row.error_type ?? row.error_message ?? "—",
title: (row) => row.error_message ?? undefined,
className: "text-muted-foreground max-w-[260px] truncate",
},
];
return columns.filter(
(col) => col.alwaysShow || items.some((row) => col.hasValue(row)),
);
}, [items]);
const colSpan = visibleColumns.length || 1;
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h2 className="text-lg font-semibold">大模型调用统计</h2>
<p className="text-muted-foreground mt-1 text-sm">
记录每一次大模型的实际调用,包括 token 数、耗时、估算速度和失败原因。
后台无条件记录,与前端速度指示器开关无关。
</p>
</div>
<Button onClick={handleExport} disabled={exporting}>
{exporting ? (
<Loader2 className="mr-1 size-4 animate-spin" />
) : (
<Download className="mr-1 size-4" />
)}
导出 Excel
</Button>
</div>
<div className="grid gap-3 rounded-xl border bg-white p-4 md:grid-cols-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
<div className="flex flex-col gap-1 ">
<label className="text-muted-foreground text-xs dark:text-white">模型</label>
<Input
value={model}
placeholder="按模型名筛选"
onChange={(e) => {
setModel(e.target.value);
setPage(0);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs dark:text-white">用户名</label>
<Input
value={user}
placeholder="按用户名模糊搜索(@ 前部分)"
onChange={(e) => {
setUser(e.target.value);
setPage(0);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs dark:text-white">状态</label>
<Select
value={status}
onValueChange={(value) => {
setStatus(value as StatusFilter);
setPage(0);
}}
>
<SelectTrigger className="w-full dark:bg-[#0D2D54] dark:hover:bg-[#0D2D54]">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="all">全部</SelectItem>
<SelectItem value="success">成功</SelectItem>
<SelectItem value="error">失败</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs dark:text-white">开始时间</label>
<Input
type="datetime-local"
value={since}
onChange={(e) => {
setSince(e.target.value);
setPage(0);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs dark:text-white">结束时间</label>
<Input
type="datetime-local"
value={until}
onChange={(e) => {
setUntil(e.target.value);
setPage(0);
}}
/>
</div>
<div className="md:col-span-5 flex justify-end">
<Button variant="outline" size="sm" onClick={resetFilters}>
清空筛选
</Button>
</div>
</div>
<div className="rounded-xl border bg-white dark:border-[#213D5E] dark:bg-[#02244C] ">
<div className="overflow-x-auto">
<table className="w-full min-w-[1080px] text-sm">
<thead className="bg-muted/60 text-muted-foreground dark:text-white">
<tr>
{visibleColumns.map((col) => (
<Th
key={col.id}
className={col.align === "right" ? "text-right" : undefined}
>
{col.header}
</Th>
))}
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={colSpan} className="py-10 text-center">
<Loader2 className="text-muted-foreground inline size-5 animate-spin" />
</td>
</tr>
)}
{!isLoading && error && (
<tr>
<td colSpan={colSpan} className="text-destructive py-10 text-center text-sm">
加载失败:{error.message}
</td>
</tr>
)}
{!isLoading && !error && items.length === 0 && (
<tr>
<td colSpan={colSpan} className="text-muted-foreground py-10 text-center text-sm">
暂无符合条件的调用记录。
</td>
</tr>
)}
{items.map((row) => (
<tr key={row.id} className="border-t">
{visibleColumns.map((col) => (
<Td
key={col.id}
className={cn(
col.align === "right" && "text-right",
col.className,
)}
title={col.title?.(row)}
>
{col.render(row)}
</Td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-between border-t px-4 py-2 text-xs">
<span className="text-muted-foreground">
{isFetching && !isLoading ? "更新中..." : `共 ${total} 条`}
</span>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={page <= 0 || isLoading}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
上一页
</Button>
<span className="text-muted-foreground">
第 {page + 1} / {totalPages} 页
</span>
<Button
size="sm"
variant="outline"
disabled={page + 1 >= totalPages || isLoading}
onClick={() => setPage((p) => p + 1)}
>
下一页
</Button>
</div>
</div>
</div>
</div>
);
}
function Th({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<th
className={`px-3 py-2 text-left text-xs font-medium ${className ?? ""}`}
>
{children}
</th>
);
}
function Td({
children,
className,
title,
}: {
children: React.ReactNode;
className?: string;
title?: string;
}) {
return (
<td
className={`px-3 py-2 align-top text-sm ${className ?? ""}`}
title={title}
>
{children}
</td>
);
}
/**
* Admin metrics page. Two tabs: LLM provider calls and tool / skill calls —
* the latter surfaces retrieval/IP-block/rate-limit failures.
*/
export default function LlmMetricsPage() {
const [tab, setTab] = useState<"llm" | "tool">("llm");
return (
<div className="flex size-full min-h-0 w-full">
<ScrollArea className="min-h-0 flex-1">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-4 px-6 py-8">
<Tabs value={tab} onValueChange={(v) => setTab(v as "llm" | "tool")}>
<TabsList>
<TabsTrigger value="llm">大模型调用</TabsTrigger>
<TabsTrigger value="tool">工具调用</TabsTrigger>
</TabsList>
</Tabs>
{tab === "llm" ? <LlmMetricsPanel /> : <ToolMetricsPanel />}
</div>
</ScrollArea>
</div>
);
}