"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("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: , } 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: , } as const; const darkToastOpts = theme === "dark" ? { success: successToastDarkOptions, error: errorToastDarkOptions } : { success: undefined, error: undefined }; const filters = useMemo(() => { 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 ? ( {row.tokens_per_sec.toFixed(1)} ) : ( "—" ), }, { id: "status", header: "状态", alwaysShow: true, hasValue: () => true, render: (row) => ( {row.status === "success" ? "成功" : "失败"} ), }, { 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 (

大模型调用统计

记录每一次大模型的实际调用,包括 token 数、耗时、估算速度和失败原因。 后台无条件记录,与前端速度指示器开关无关。

{ setModel(e.target.value); setPage(0); }} />
{ setUser(e.target.value); setPage(0); }} />
{ setSince(e.target.value); setPage(0); }} />
{ setUntil(e.target.value); setPage(0); }} />
{visibleColumns.map((col) => ( ))} {isLoading && ( )} {!isLoading && error && ( )} {!isLoading && !error && items.length === 0 && ( )} {items.map((row) => ( {visibleColumns.map((col) => ( ))} ))}
{col.header}
加载失败:{error.message}
暂无符合条件的调用记录。
{col.render(row)}
{isFetching && !isLoading ? "更新中..." : `共 ${total} 条`}
第 {page + 1} / {totalPages} 页
); } function Th({ children, className, }: { children: React.ReactNode; className?: string; }) { return ( {children} ); } function Td({ children, className, title, }: { children: React.ReactNode; className?: string; title?: string; }) { return ( {children} ); } /** * 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 (
setTab(v as "llm" | "tool")}> 大模型调用 工具调用 {tab === "llm" ? : }
); }