feat(commands): 工具执行页新增调用次数统计

「工具执行」页工具栏加「统计」按钮,弹窗按工具展示调用次数、占比与
失败次数,按次数降序。统计沿用列表的 task/q 筛选,算的是整个结果集
而非当前页;弹窗打开时才请求,翻页不会多付一次聚合查询。

- db: 抽出 commandFilter,列表与统计共用同一份 WHERE;新增 ToolStats
- server: GET /api/commands/stats
- web: 统计弹窗 + demo 模式 mock
This commit is contained in:
Autumn-27 committed 2026-08-27 10:56:11 -04:00
1 parent a380eb4195
commit e10f8413dc
7 files changed
+207 -22

No files matched your search

+57 -12
View File
@@ -19,18 +19,10 @@ type CommandRecord struct {
CreatedAt time.Time `json:"created_at"`
}
// ListCommands returns tool executions (tool_use + paired tool_result) across all
// explorations, with optional filtering and pagination. Covers every tool, not
// just Bash; q matches the tool name or its input.
func (d *DB) ListCommands(expID *int64, q string, page, size int) ([]CommandRecord, int, error) {
if size <= 0 {
size = 50
}
if page < 0 {
page = 0
}
offset := page * size
// commandFilter builds the WHERE clause shared by the tool-execution list and
// its per-tool tally, so the summary always describes exactly the rows the table
// pages through. Returns the clause, its args, and the next placeholder index.
func commandFilter(expID *int64, q string) (string, []any, int) {
where := `WHERE u.kind = 'tool_use'`
args := []any{}
argN := 1
@@ -45,6 +37,59 @@ func (d *DB) ListCommands(expID *int64, q string, page, size int) ([]CommandReco
args = append(args, "%"+q+"%")
argN++
}
return where, args, argN
}
// ToolStat is one tool's execution tally for the usage summary.
type ToolStat struct {
Tool string `json:"tool"`
Total int `json:"total"`
Errors int `json:"errors"`
}
// ToolStats counts executions grouped by tool under the same filters
// ListCommands takes. Unpaginated on purpose: the tally describes the whole
// filtered set, not the page currently on screen.
func (d *DB) ToolStats(expID *int64, q string) ([]ToolStat, error) {
where, args, _ := commandFilter(expID, q)
rows, err := d.Query(`
SELECT COALESCE(NULLIF(u.tool,''),'-') AS tool, COUNT(*) AS total,
COUNT(*) FILTER (WHERE COALESCE(r.is_error,false)) AS errors
FROM activity u
LEFT JOIN activity r ON r.tool_use_id = u.tool_use_id AND r.kind = 'tool_result'
`+where+`
GROUP BY 1
ORDER BY total DESC, tool ASC`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []ToolStat{}
for rows.Next() {
var s ToolStat
if err := rows.Scan(&s.Tool, &s.Total, &s.Errors); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// ListCommands returns tool executions (tool_use + paired tool_result) across all
// explorations, with optional filtering and pagination. Covers every tool, not
// just Bash; q matches the tool name or its input.
func (d *DB) ListCommands(expID *int64, q string, page, size int) ([]CommandRecord, int, error) {
if size <= 0 {
size = 50
}
if page < 0 {
page = 0
}
offset := page * size
where, args, argN := commandFilter(expID, q)
// count
var total int
+32 -8
View File
@@ -15,14 +15,7 @@ func (s *Server) pgListCommands(w http.ResponseWriter, r *http.Request) {
size := atoiDefault(q.Get("size"), 50)
keyword := q.Get("q")
var expID *int64
if tv := q.Get("task"); tv != "" {
if n, err := strconv.ParseInt(tv, 10, 64); err == nil {
expID = &n
}
}
records, total, err := s.m.PG().ListCommands(expID, keyword, page, size)
records, total, err := s.m.PG().ListCommands(commandTaskFilter(q.Get("task")), keyword, page, size)
if err != nil {
writeErr(w, 500, err.Error())
return
@@ -30,6 +23,37 @@ func (s *Server) pgListCommands(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"commands": records, "total": total})
}
// pgToolStats returns per-tool call counts for the tool-execution history, under
// the same task/keyword filters the list takes — the summary describes the whole
// filtered set, not the page on screen.
func (s *Server) pgToolStats(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
pg := s.m.PG()
if pg == nil {
writeJSON(w, 200, map[string]any{"stats": []db.ToolStat{}})
return
}
stats, err := pg.ToolStats(commandTaskFilter(q.Get("task")), q.Get("q"))
if err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"stats": stats})
}
// commandTaskFilter parses the ?task= exploration id. Absent or unparsable → nil
// (no filter), matching the list endpoint's long-standing lenient behaviour.
func commandTaskFilter(v string) *int64 {
if v == "" {
return nil
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return nil
}
return &n
}
// pgListLLMRecords returns paginated LLM call records.
func (s *Server) pgListLLMRecords(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
+1
View File
@@ -787,6 +787,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/traffic/exchange", s.getTrafficExchange)
mux.HandleFunc("GET /api/traffic/blob", s.getTrafficBlob)
mux.HandleFunc("GET /api/commands", s.pgListCommands)
mux.HandleFunc("GET /api/commands/stats", s.pgToolStats) // 按工具聚合调用次数
mux.HandleFunc("GET /api/llm/records", s.pgListLLMRecords)
mux.HandleFunc("DELETE /api/llm/records", s.pgDeleteLLMRecords)
mux.HandleFunc("GET /api/llm/records/tasks", s.pgLLMTasks)
+91 -2
View File
@@ -2,17 +2,18 @@
import * as React from "react";
import { ChevronLeftIcon, ChevronRightIcon, Loader2Icon, SearchIcon, TerminalIcon } from "lucide-react";
import { BarChart3Icon, ChevronLeftIcon, ChevronRightIcon, Loader2Icon, SearchIcon, TerminalIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api } from "@/lib/api";
import type { CommandRecord } from "@/lib/types";
import type { CommandRecord, ToolStat } from "@/lib/types";
import { cn } from "@/lib/utils";
function fmtTime(ts: string) {
@@ -59,6 +60,11 @@ export default function CommandsPage() {
const [total, setTotal] = React.useState(0);
const [loading, setLoading] = React.useState(false);
// 各工具调用次数。弹窗打开时才拉取(多一次聚合查询,不必每次翻页都付)。
const [statsOpen, setStatsOpen] = React.useState(false);
const [stats, setStats] = React.useState<ToolStat[]>([]);
const [statsLoading, setStatsLoading] = React.useState(false);
// Selected execution is rendered in a right-side detail sheet.
const [selected, setSelected] = React.useState<CommandRecord | null>(null);
@@ -96,6 +102,24 @@ export default function CommandsPage() {
};
}, [page, size, queryQ, taskFilter]);
// 统计跟随筛选条件走,和表格描述的是同一批记录(但不分页)。
React.useEffect(() => {
if (!statsOpen) return;
let alive = true;
setStatsLoading(true);
api
.commandStats({ task: taskFilter || undefined, q: queryQ || undefined })
.then((r) => alive && setStats(r.stats ?? []))
.catch(() => alive && setStats([]))
.finally(() => alive && setStatsLoading(false));
return () => {
alive = false;
};
}, [statsOpen, queryQ, taskFilter]);
const statsTotal = stats.reduce((n, s) => n + s.total, 0);
const statsErrors = stats.reduce((n, s) => n + s.errors, 0);
const statsMax = stats.reduce((n, s) => Math.max(n, s.total), 0);
const totalPages = Math.max(1, Math.ceil(total / size));
const rangeStart = total === 0 ? 0 : page * size + 1;
const rangeEnd = page * size + commands.length;
@@ -141,6 +165,11 @@ export default function CommandsPage() {
</SelectContent>
</Select>
<Button variant="outline" size="sm" className="h-8" onClick={() => setStatsOpen(true)}>
<BarChart3Icon className="size-4" />
统计
</Button>
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
<span className="tabular-nums">
{rangeStart}–{rangeEnd} / {total}
@@ -243,6 +272,66 @@ export default function CommandsPage() {
</Card>
</div>
{/* 工具调用统计:与表格同一批记录(同筛选、不分页) */}
<Dialog open={statsOpen} onOpenChange={setStatsOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>工具调用统计</DialogTitle>
<DialogDescription>
{taskFilter || queryQ ? "当前筛选条件下的全部记录" : "全部工具执行记录"}
{stats.length > 0 && (
<>
{" · "}
<span className="tabular-nums">{stats.length}</span> 个工具 ·{" "}
<span className="tabular-nums">{statsTotal}</span> 次调用
{statsErrors > 0 && (
<>
{" · 失败 "}
<span className="tabular-nums text-red-600 dark:text-red-400">{statsErrors}</span>
</>
)}
</>
)}
</DialogDescription>
</DialogHeader>
{statsLoading && stats.length === 0 ? (
<div className="py-10 text-center">
<Loader2Icon className="mx-auto h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : stats.length === 0 ? (
<div className="py-10 text-center text-sm text-muted-foreground">暂无统计数据</div>
) : (
<div className="-mr-2 max-h-[55vh] space-y-1 overflow-auto pr-2">
{stats.map((s) => (
<div key={s.tool} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-md p-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="truncate font-mono text-xs font-medium">{s.tool}</span>
{s.errors > 0 && (
<span className="text-[11px] tabular-nums text-red-600 dark:text-red-400">失败 {s.errors}</span>
)}
</div>
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary"
style={{ width: `${statsMax > 0 ? (s.total / statsMax) * 100 : 0}%` }}
/>
</div>
</div>
<div className="text-right">
<div className="text-xs font-semibold tabular-nums">{s.total}</div>
<div className="text-[11px] tabular-nums text-muted-foreground">
{statsTotal > 0 ? ((s.total / statsTotal) * 100).toFixed(1) : "0.0"}%
</div>
</div>
</div>
))}
</div>
)}
</DialogContent>
</Dialog>
<Sheet open={selected !== null} onOpenChange={(open) => !open && setSelected(null)}>
<SheetContent className="w-full! max-w-none! gap-0 p-0 sm:w-[46rem]! sm:max-w-[46rem]!">
{selected && (
+8
View File
@@ -71,6 +71,7 @@ import type {
TokenTotal,
TokenUsage,
Tool,
ToolStat,
TrafficDetail,
TrafficHost,
TrafficResp,
@@ -941,6 +942,13 @@ export const api = {
sp.set("size", String(params?.size ?? 50));
return get<{ commands: CommandRecord[]; total: number }>(`/commands?${sp}`);
},
// 各工具调用次数;沿用列表的 task/q 筛选,统计的是整个结果集而非当前页。
commandStats: (params?: { task?: string; q?: string }) => {
const sp = new URLSearchParams();
if (params?.task) sp.set("task", params.task);
if (params?.q) sp.set("q", params.q);
return get<{ stats: ToolStat[] }>(`/commands/stats?${sp}`);
},
// ---- LLM records ----
llmRecords: (params?: { model?: string; session?: string; task?: string; page?: number; size?: number }) => {
+11
View File
@@ -1128,6 +1128,17 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
// ── 工具执行历史 ──
if (path === "/commands" && m === "GET") return { commands: D.commandRecords, total: D.commandRecords.length };
if (path === "/commands/stats" && m === "GET") {
const tally = new Map<string, { tool: string; total: number; errors: number }>();
for (const c of D.commandRecords) {
const tool = c.tool || "-";
const s = tally.get(tool) ?? { tool, total: 0, errors: 0 };
s.total++;
if (c.is_error) s.errors++;
tally.set(tool, s);
}
return { stats: [...tally.values()].sort((a, b) => b.total - a.total || a.tool.localeCompare(b.tool)) };
}
// ── LLM ──
if (path === "/llm/records" && m === "GET") return { records: mockLLMRecords, total: mockLLMRecords.length };
+7
View File
@@ -1065,6 +1065,13 @@ export interface CommandRecord {
created_at: string;
}
// 单个工具的调用统计(/commands/stats);errors 为其中失败的次数。
export interface ToolStat {
tool: string;
total: number;
errors: number;
}
// ---- LLM recording ----
export interface LLMRecordItem {
id: number;