feat(commands): 工具执行页新增调用次数统计
「工具执行」页工具栏加「统计」按钮,弹窗按工具展示调用次数、占比与 失败次数,按次数降序。统计沿用列表的 task/q 筛选,算的是整个结果集 而非当前页;弹窗打开时才请求,翻页不会多付一次聚合查询。 - db: 抽出 commandFilter,列表与统计共用同一份 WHERE;新增 ToolStats - server: GET /api/commands/stats - web: 统计弹窗 + demo 模式 mock
This commit is contained in:
1 parent
a380eb4195
commit
e10f8413dc
7 files changed
+207
-22
No files matched your search
+57
-12
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user