Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into liuq
This commit is contained in:
commit
b90c71d0ce
50 files changed
+3514
-321
No files matched your search
@@ -0,0 +1,604 @@
|
||||
"use client";
|
||||
|
||||
import { 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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
downloadToolMetricsExcel,
|
||||
TOOL_ERROR_CATEGORIES,
|
||||
TOOL_ERROR_CATEGORY_LABELS,
|
||||
type ToolMetric,
|
||||
type ToolMetricsFilters,
|
||||
} from "@/core/tool-metrics/api";
|
||||
import { useToolMetrics, useToolMetricsSummary } from "@/core/tool-metrics/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
type StatusFilter = "all" | "success" | "error";
|
||||
type KindFilter = "skill" | "all";
|
||||
|
||||
function displayUser(row: { user_name?: string | null; user_id?: string | null }): string {
|
||||
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 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 categoryLabel(category?: string | null): string {
|
||||
if (!category) return "—";
|
||||
return TOOL_ERROR_CATEGORY_LABELS[category] ?? category;
|
||||
}
|
||||
|
||||
export function ToolMetricsPanel() {
|
||||
const [page, setPage] = useState(0);
|
||||
const [tool, setTool] = useState("");
|
||||
const [user, setUser] = useState("");
|
||||
const [skill, setSkill] = useState("");
|
||||
// Default to skill-only — the table is otherwise flooded with low-level
|
||||
// bash / file-op calls. "全部" reveals every tool invocation.
|
||||
const [kind, setKind] = useState<KindFilter>("skill");
|
||||
const [status, setStatus] = useState<StatusFilter>("all");
|
||||
const [category, setCategory] = useState<string>("all");
|
||||
const [since, setSince] = useState("");
|
||||
const [until, setUntil] = useState("");
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [detailRow, setDetailRow] = useState<ToolMetric | null>(null);
|
||||
|
||||
const filters = useMemo<ToolMetricsFilters>(() => {
|
||||
const out: ToolMetricsFilters = {};
|
||||
if (tool.trim()) out.tool_name = tool.trim();
|
||||
if (user.trim()) out.user_name = user.trim();
|
||||
if (skill.trim()) out.skill_name = skill.trim();
|
||||
if (kind !== "all") out.kind = kind;
|
||||
if (status !== "all") out.status = status;
|
||||
if (category !== "all") out.error_category = category;
|
||||
if (since) out.since = new Date(since).toISOString();
|
||||
if (until) out.until = new Date(until).toISOString();
|
||||
return out;
|
||||
}, [tool, user, skill, kind, status, category, since, until]);
|
||||
|
||||
const query = useMemo(
|
||||
() => ({ ...filters, limit: PAGE_SIZE, offset: page * PAGE_SIZE }),
|
||||
[filters, page],
|
||||
);
|
||||
|
||||
const { data, isLoading, error, isFetching } = useToolMetrics(query);
|
||||
// The summary ignores the status/category filter so the breakdown always
|
||||
// reflects the full failure picture for the chosen tool/user/time window.
|
||||
const summaryFilters = useMemo<ToolMetricsFilters>(() => {
|
||||
const out: ToolMetricsFilters = {};
|
||||
if (tool.trim()) out.tool_name = tool.trim();
|
||||
if (user.trim()) out.user_name = user.trim();
|
||||
if (skill.trim()) out.skill_name = skill.trim();
|
||||
if (kind !== "all") out.kind = kind;
|
||||
if (since) out.since = new Date(since).toISOString();
|
||||
if (until) out.until = new Date(until).toISOString();
|
||||
return out;
|
||||
}, [tool, user, skill, kind, since, until]);
|
||||
const { data: summary } = useToolMetricsSummary(summaryFilters);
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
await downloadToolMetricsExcel(filters);
|
||||
toast.success("已导出为 Excel");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "导出失败");
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setTool("");
|
||||
setUser("");
|
||||
setSkill("");
|
||||
setKind("skill");
|
||||
setStatus("all");
|
||||
setCategory("all");
|
||||
setSince("");
|
||||
setUntil("");
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const items = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
const errorRate =
|
||||
summary && summary.total > 0
|
||||
? ((summary.error_total / summary.total) * 100).toFixed(1)
|
||||
: "0.0";
|
||||
|
||||
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">
|
||||
记录每一次工具 / 技能 / MCP 调用,并对失败进行归类:检索为空、IP
|
||||
被封、限流、超时、鉴权失败等。后台无条件记录。
|
||||
</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>
|
||||
|
||||
{/* Summary strip */}
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-xl border bg-white p-4">
|
||||
<div className="mr-4 flex flex-col">
|
||||
<span className="text-muted-foreground text-xs">总调用</span>
|
||||
<span className="text-lg font-semibold tabular-nums">
|
||||
{summary?.total ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mr-4 flex flex-col">
|
||||
<span className="text-muted-foreground text-xs">失败数</span>
|
||||
<span className="text-destructive text-lg font-semibold tabular-nums">
|
||||
{summary?.error_total ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mr-4 flex flex-col">
|
||||
<span className="text-muted-foreground text-xs">失败率</span>
|
||||
<span className="text-lg font-semibold tabular-nums">{errorRate}%</span>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-wrap items-center gap-1.5">
|
||||
{summary && Object.keys(summary.by_category).length === 0 && (
|
||||
<span className="text-muted-foreground text-sm">暂无失败记录 🎉</span>
|
||||
)}
|
||||
{summary &&
|
||||
Object.entries(summary.by_category)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([cat, count]) => (
|
||||
<button
|
||||
key={cat}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCategory(cat);
|
||||
setStatus("error");
|
||||
setPage(0);
|
||||
}}
|
||||
className="hover:bg-muted inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs"
|
||||
>
|
||||
<span>{categoryLabel(cat)}</span>
|
||||
<span className="text-destructive font-semibold tabular-nums">
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Per-skill breakdown — answers "was skill X invoked, and did it succeed". */}
|
||||
{summary && summary.by_skill.length > 0 && (
|
||||
<div className="rounded-xl border bg-white p-4">
|
||||
<h3 className="mb-2 text-sm font-medium">各技能调用情况</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-2 py-1 text-left text-xs font-medium">技能名称</th>
|
||||
<th className="px-2 py-1 text-right text-xs font-medium">调用次数</th>
|
||||
<th className="px-2 py-1 text-right text-xs font-medium">成功</th>
|
||||
<th className="px-2 py-1 text-right text-xs font-medium">失败</th>
|
||||
<th className="px-2 py-1 text-right text-xs font-medium">成功率</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{summary.by_skill.map((s) => {
|
||||
const rate =
|
||||
s.total > 0 ? ((s.success / s.total) * 100).toFixed(0) : "0";
|
||||
return (
|
||||
<tr
|
||||
key={s.skill_name}
|
||||
className="hover:bg-muted cursor-pointer border-t"
|
||||
title="点击只看该技能"
|
||||
onClick={() => {
|
||||
setSkill(s.skill_name);
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
<td className="px-2 py-1.5 font-medium">{s.skill_name}</td>
|
||||
<td className="px-2 py-1.5 text-right tabular-nums">{s.total}</td>
|
||||
<td className="px-2 py-1.5 text-right tabular-nums text-green-600">
|
||||
{s.success}
|
||||
</td>
|
||||
<td className="text-destructive px-2 py-1.5 text-right tabular-nums">
|
||||
{s.error}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right tabular-nums">{rate}%</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{summary && kind === "skill" && summary.by_skill.length === 0 && (
|
||||
<div className="text-muted-foreground rounded-xl border bg-white p-4 text-sm">
|
||||
当前筛选范围内没有检测到任何技能调用记录。技能只有在被 Agent 通过
|
||||
<code className="bg-muted mx-1 rounded px-1">skill_view</code>
|
||||
读取(即真正"调用")时才会被记录;仅在系统提示里被列出、但未被读取的技能不会计入。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="grid gap-3 rounded-xl border bg-white p-4 md:grid-cols-3 lg:grid-cols-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-muted-foreground text-xs">调用类型</label>
|
||||
<Select
|
||||
value={kind}
|
||||
onValueChange={(value) => {
|
||||
setKind(value as KindFilter);
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="skill">技能(Agent Skill)</SelectItem>
|
||||
<SelectItem value="all">全部工具调用</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-muted-foreground text-xs">技能名称</label>
|
||||
<Input
|
||||
value={skill}
|
||||
placeholder="按技能名筛选"
|
||||
onChange={(e) => {
|
||||
setSkill(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-muted-foreground text-xs">工具名</label>
|
||||
<Input
|
||||
value={tool}
|
||||
placeholder="按工具名筛选"
|
||||
onChange={(e) => {
|
||||
setTool(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-muted-foreground text-xs">用户名</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">状态</label>
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={(value) => {
|
||||
setStatus(value as StatusFilter);
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<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">错误类型</label>
|
||||
<Select
|
||||
value={category}
|
||||
onValueChange={(value) => {
|
||||
setCategory(value);
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部</SelectItem>
|
||||
{TOOL_ERROR_CATEGORIES.map((cat) => (
|
||||
<SelectItem key={cat} value={cat}>
|
||||
{categoryLabel(cat)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-muted-foreground text-xs">开始时间</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">结束时间</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={until}
|
||||
onChange={(e) => {
|
||||
setUntil(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end lg:col-span-6">
|
||||
<Button variant="outline" size="sm" onClick={resetFilters}>
|
||||
清空筛选
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-xl border bg-white">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[960px] text-sm">
|
||||
<thead className="bg-muted/60 text-muted-foreground">
|
||||
<tr>
|
||||
<Th>时间</Th>
|
||||
<Th>用户</Th>
|
||||
<Th>Agent</Th>
|
||||
<Th>技能</Th>
|
||||
<Th>工具</Th>
|
||||
<Th className="text-right">耗时</Th>
|
||||
<Th>状态</Th>
|
||||
<Th>错误类型</Th>
|
||||
<Th>错误详情</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td colSpan={9} className="py-10 text-center">
|
||||
<Loader2 className="text-muted-foreground inline size-5 animate-spin" />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!isLoading && error && (
|
||||
<tr>
|
||||
<td colSpan={9} className="text-destructive py-10 text-center text-sm">
|
||||
加载失败:{error.message}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!isLoading && !error && items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={9} className="text-muted-foreground py-10 text-center text-sm">
|
||||
暂无符合条件的调用记录。
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{items.map((row) => (
|
||||
<tr key={row.id} className="border-t">
|
||||
<Td>{formatDate(row.created_at)}</Td>
|
||||
<Td title={row.user_id ?? undefined}>{displayUser(row)}</Td>
|
||||
<Td>{row.agent_name ?? "—"}</Td>
|
||||
<Td className="font-medium">
|
||||
{row.skill_name ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSkill(row.skill_name as string);
|
||||
setPage(0);
|
||||
}}
|
||||
className="hover:text-foreground underline-offset-2 hover:underline"
|
||||
title="点击只看该技能"
|
||||
>
|
||||
{row.skill_name}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</Td>
|
||||
<Td className="font-mono text-xs">{row.tool_name ?? "—"}</Td>
|
||||
<Td className="text-right tabular-nums" title={`${row.duration_ms} ms`}>
|
||||
{formatDuration(row.duration_ms)}
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge variant={row.status === "success" ? "default" : "destructive"}>
|
||||
{row.status === "success" ? "成功" : "失败"}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
{row.error_category ? (
|
||||
<Badge variant="outline">{categoryLabel(row.error_category)}</Badge>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</Td>
|
||||
<Td className="max-w-[300px]">
|
||||
{row.error_message ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailRow(row)}
|
||||
className="text-muted-foreground hover:text-foreground block max-w-full truncate text-left underline-offset-2 hover:underline"
|
||||
title="点击查看完整报错"
|
||||
>
|
||||
{row.error_message}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* Full error detail popup */}
|
||||
<Dialog
|
||||
open={detailRow !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDetailRow(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>工具调用报错详情</DialogTitle>
|
||||
</DialogHeader>
|
||||
{detailRow && (
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||
<DetailField label="时间" value={formatDate(detailRow.created_at)} />
|
||||
<DetailField label="用户" value={displayUser(detailRow)} />
|
||||
<DetailField label="工具" value={detailRow.tool_name ?? "—"} mono />
|
||||
<DetailField label="技能" value={detailRow.skill_name ?? "—"} />
|
||||
<DetailField label="Agent" value={detailRow.agent_name ?? "—"} />
|
||||
<DetailField
|
||||
label="状态"
|
||||
value={detailRow.status === "success" ? "成功" : "失败"}
|
||||
/>
|
||||
<DetailField
|
||||
label="错误类型"
|
||||
value={categoryLabel(detailRow.error_category)}
|
||||
/>
|
||||
<DetailField label="耗时" value={formatDuration(detailRow.duration_ms)} />
|
||||
<DetailField label="Thread" value={detailRow.thread_id ?? "—"} mono />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs">完整报错信息</span>
|
||||
<pre className="bg-muted mt-1 max-h-72 overflow-auto whitespace-pre-wrap break-all rounded-md p-3 text-xs">
|
||||
{detailRow.error_message ?? "(无)"}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailField({
|
||||
label,
|
||||
value,
|
||||
mono,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-muted-foreground text-xs">{label}</span>
|
||||
<span className={cn("break-all", mono && "font-mono text-xs")}>{value}</span>
|
||||
</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={cn("px-3 py-2 align-top text-sm", className)} title={title}>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
@@ -18,9 +18,9 @@ export function AgentGallery() {
|
||||
const router = useRouter();
|
||||
const [activeTab, setActiveTab] = useState<"all" | "mine">("mine");
|
||||
const [search, setSearch] = useState("");
|
||||
const [tagId, setTagId] = useState<string | null>(null);
|
||||
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||
// Name + tag filtering is resolved server-side via the /api/agents query.
|
||||
const { agents, isLoading } = useAgents({ search, tagId });
|
||||
const { agents, isLoading } = useAgents({ search, tagIds });
|
||||
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const currentUserId = storedAuth?.user_id ?? null;
|
||||
@@ -87,7 +87,7 @@ export function AgentGallery() {
|
||||
onValueChange={(v) => setActiveTab(v as "all" | "mine")}
|
||||
className="flex flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
<div className="mx-6 mt-4 flex items-center justify-between gap-4">
|
||||
<div className="mx-6 mt-4 flex flex-col gap-3">
|
||||
<TabsList className="w-auto self-start">
|
||||
<TabsTrigger value="mine">我的</TabsTrigger>
|
||||
<TabsTrigger value="all">广场</TabsTrigger>
|
||||
@@ -95,9 +95,8 @@ export function AgentGallery() {
|
||||
<TagSearchBar
|
||||
targetType="agent"
|
||||
onSearchChange={setSearch}
|
||||
tagId={tagId}
|
||||
onTagIdChange={setTagId}
|
||||
className="w-[420px] max-w-[50%]"
|
||||
tagIds={tagIds}
|
||||
onTagIdsChange={setTagIds}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
Code2Icon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
EyeIcon,
|
||||
LoaderIcon,
|
||||
PackageIcon,
|
||||
SaveIcon,
|
||||
SquareArrowOutUpRightIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
Code2Icon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
EyeIcon,
|
||||
LoaderIcon,
|
||||
PackageIcon,
|
||||
SaveIcon,
|
||||
SquareArrowOutUpRightIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Streamdown } from "streamdown";
|
||||
@@ -34,14 +34,14 @@ import {
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { CodeEditor } from "@/components/workspace/code-editor";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import {
|
||||
downloadArtifactFile,
|
||||
openArtifactInNewTab,
|
||||
} from "@/core/artifacts/download";
|
||||
import {
|
||||
useArtifactContent,
|
||||
useUpdateArtifactContent,
|
||||
} from "@/core/artifacts/hooks";
|
||||
import {
|
||||
downloadArtifactFile,
|
||||
openArtifactInNewTab,
|
||||
} from "@/core/artifacts/download";
|
||||
import {
|
||||
useArtifactContent,
|
||||
useUpdateArtifactContent,
|
||||
} from "@/core/artifacts/hooks";
|
||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { installSkill } from "@/core/skills/api";
|
||||
@@ -89,7 +89,7 @@ export function ArtifactFileDetail({
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { artifacts, setOpen, select } = useArtifacts();
|
||||
const { isMock, thread } = useThread();
|
||||
const { isMock, thread, publicShareCode } = useThread();
|
||||
const isWriteFile = useMemo(() => {
|
||||
return filepathFromProps.startsWith("write-file:");
|
||||
}, [filepathFromProps]);
|
||||
@@ -117,18 +117,18 @@ export function ArtifactFileDetail({
|
||||
const isSupportPreview = useMemo(() => {
|
||||
return language === "html" || language === "markdown";
|
||||
}, [language]);
|
||||
const { content, isApplyingEdit } = useArtifactContent({
|
||||
threadId,
|
||||
filepath: filepathFromProps,
|
||||
enabled: isCodeFile && !isWriteFile,
|
||||
});
|
||||
|
||||
const canEditArtifact =
|
||||
language === "markdown" && !isWriteFile && !isSkillFile && !isMock;
|
||||
const [draftContent, setDraftContent] = useState("");
|
||||
const [hasLocalEdits, setHasLocalEdits] = useState(false);
|
||||
const updateArtifact = useUpdateArtifactContent();
|
||||
const displayContent = canEditArtifact ? draftContent : (content ?? "");
|
||||
const { content, isApplyingEdit } = useArtifactContent({
|
||||
threadId,
|
||||
filepath: filepathFromProps,
|
||||
enabled: isCodeFile && !isWriteFile,
|
||||
});
|
||||
|
||||
const canEditArtifact =
|
||||
language === "markdown" && !isWriteFile && !isSkillFile && !isMock;
|
||||
const [draftContent, setDraftContent] = useState("");
|
||||
const [hasLocalEdits, setHasLocalEdits] = useState(false);
|
||||
const updateArtifact = useUpdateArtifactContent();
|
||||
const displayContent = canEditArtifact ? draftContent : (content ?? "");
|
||||
const isHtmlStreaming = language === "html" && thread.isLoading;
|
||||
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
|
||||
const fileTypeLabel = useMemo(() => {
|
||||
@@ -148,20 +148,20 @@ export function ArtifactFileDetail({
|
||||
const [binaryPreviewUrl, setBinaryPreviewUrl] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const artifactContentRef = useRef<HTMLDivElement | null>(null);
|
||||
const resumeButtonTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setDraftContent(content ?? "");
|
||||
setHasLocalEdits(false);
|
||||
}, [filepathFromProps]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canEditArtifact || hasLocalEdits) {
|
||||
return;
|
||||
}
|
||||
setDraftContent(content ?? "");
|
||||
}, [canEditArtifact, content, hasLocalEdits]);
|
||||
const artifactContentRef = useRef<HTMLDivElement | null>(null);
|
||||
const resumeButtonTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setDraftContent(content ?? "");
|
||||
setHasLocalEdits(false);
|
||||
}, [filepathFromProps]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canEditArtifact || hasLocalEdits) {
|
||||
return;
|
||||
}
|
||||
setDraftContent(content ?? "");
|
||||
}, [canEditArtifact, content, hasLocalEdits]);
|
||||
|
||||
useEffect(() => {
|
||||
setAllowAutoViewSwitch(true);
|
||||
@@ -279,7 +279,7 @@ export function ArtifactFileDetail({
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiFetch(
|
||||
urlOfArtifact({ filepath, threadId, isMock }),
|
||||
urlOfArtifact({ filepath, threadId, isMock, publicShareCode }),
|
||||
);
|
||||
if (cancelled || !res.ok) return;
|
||||
const blob = await res.blob();
|
||||
@@ -304,7 +304,7 @@ export function ArtifactFileDetail({
|
||||
return null;
|
||||
});
|
||||
};
|
||||
}, [filepath, threadId, isMock, isCodeFile]);
|
||||
}, [filepath, threadId, isMock, isCodeFile, publicShareCode]);
|
||||
|
||||
const handlePauseAutoScroll = useCallback(() => {
|
||||
if (!thread.isLoading || !autoScrollEnabled) {
|
||||
@@ -329,7 +329,7 @@ export function ArtifactFileDetail({
|
||||
}, 1600);
|
||||
}, [scrollArtifactToBottom]);
|
||||
|
||||
const handleInstallSkill = useCallback(async () => {
|
||||
const handleInstallSkill = useCallback(async () => {
|
||||
if (isInstalling) return;
|
||||
|
||||
setIsInstalling(true);
|
||||
@@ -349,33 +349,33 @@ export function ArtifactFileDetail({
|
||||
} finally {
|
||||
setIsInstalling(false);
|
||||
}
|
||||
}, [threadId, filepath, isInstalling]);
|
||||
|
||||
const handleSaveArtifact = useCallback(async () => {
|
||||
if (!canEditArtifact || updateArtifact.isPending) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateArtifact.mutateAsync({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
content: draftContent,
|
||||
});
|
||||
setHasLocalEdits(false);
|
||||
toast.success("Markdown 已保存");
|
||||
} catch (error) {
|
||||
console.error("Failed to save artifact:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Markdown 保存失败");
|
||||
}
|
||||
}, [
|
||||
canEditArtifact,
|
||||
draftContent,
|
||||
filepath,
|
||||
isMock,
|
||||
threadId,
|
||||
updateArtifact,
|
||||
]);
|
||||
}, [threadId, filepath, isInstalling]);
|
||||
|
||||
const handleSaveArtifact = useCallback(async () => {
|
||||
if (!canEditArtifact || updateArtifact.isPending) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateArtifact.mutateAsync({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
content: draftContent,
|
||||
});
|
||||
setHasLocalEdits(false);
|
||||
toast.success("Markdown 已保存");
|
||||
} catch (error) {
|
||||
console.error("Failed to save artifact:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Markdown 保存失败");
|
||||
}
|
||||
}, [
|
||||
canEditArtifact,
|
||||
draftContent,
|
||||
filepath,
|
||||
isMock,
|
||||
threadId,
|
||||
updateArtifact,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Artifact className={cn(className)}>
|
||||
@@ -452,19 +452,19 @@ export function ArtifactFileDetail({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<ArtifactActions className="bg-muted/45 rounded-full border border-black/6 p-1 dark:border-white/8">
|
||||
{canEditArtifact && (
|
||||
<ArtifactAction
|
||||
icon={updateArtifact.isPending ? LoaderIcon : SaveIcon}
|
||||
label="保存 Markdown"
|
||||
tooltip={hasLocalEdits ? "保存 Markdown 修改" : "Markdown 已保存"}
|
||||
disabled={!hasLocalEdits || updateArtifact.isPending}
|
||||
onClick={handleSaveArtifact}
|
||||
/>
|
||||
)}
|
||||
{!isWriteFile && filepath.endsWith(".skill") && (
|
||||
<Tooltip content={t.toolCalls.skillInstallTooltip}>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<ArtifactActions className="bg-muted/45 rounded-full border border-black/6 p-1 dark:border-white/8">
|
||||
{canEditArtifact && (
|
||||
<ArtifactAction
|
||||
icon={updateArtifact.isPending ? LoaderIcon : SaveIcon}
|
||||
label="保存 Markdown"
|
||||
tooltip={hasLocalEdits ? "保存 Markdown 修改" : "Markdown 已保存"}
|
||||
disabled={!hasLocalEdits || updateArtifact.isPending}
|
||||
onClick={handleSaveArtifact}
|
||||
/>
|
||||
)}
|
||||
{!isWriteFile && filepath.endsWith(".skill") && (
|
||||
<Tooltip content={t.toolCalls.skillInstallTooltip}>
|
||||
<ArtifactAction
|
||||
icon={isInstalling ? LoaderIcon : PackageIcon}
|
||||
label={t.common.install}
|
||||
@@ -488,6 +488,7 @@ export function ArtifactFileDetail({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
publicShareCode,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -528,6 +529,7 @@ export function ArtifactFileDetail({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
publicShareCode,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -550,19 +552,19 @@ export function ArtifactFileDetail({
|
||||
</ArtifactActions>
|
||||
</div>
|
||||
</ArtifactHeader>
|
||||
<ArtifactContent
|
||||
ref={artifactContentRef}
|
||||
className="relative p-0"
|
||||
onWheelCapture={handlePauseAutoScroll}
|
||||
>
|
||||
{isApplyingEdit && (
|
||||
<div className="absolute right-4 bottom-4 z-20 inline-flex items-center gap-2 rounded-full border border-[#d8b878] bg-[#fff3cf]/95 px-3 py-1.5 text-xs text-[#6d5625] shadow-sm">
|
||||
<span className="h-3.5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
|
||||
正在改写 Markdown
|
||||
</div>
|
||||
)}
|
||||
{showResumeAutoScroll && (
|
||||
<button
|
||||
<ArtifactContent
|
||||
ref={artifactContentRef}
|
||||
className="relative p-0"
|
||||
onWheelCapture={handlePauseAutoScroll}
|
||||
>
|
||||
{isApplyingEdit && (
|
||||
<div className="absolute right-4 bottom-4 z-20 inline-flex items-center gap-2 rounded-full border border-[#d8b878] bg-[#fff3cf]/95 px-3 py-1.5 text-xs text-[#6d5625] shadow-sm">
|
||||
<span className="h-3.5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
|
||||
正在改写 Markdown
|
||||
</div>
|
||||
)}
|
||||
{showResumeAutoScroll && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResumeAutoScroll}
|
||||
className="animate-[sandbox-scroll-button-in_220ms_ease-out] absolute inset-x-0 top-1/2 z-20 mx-auto flex w-fit -translate-y-1/2 items-center gap-2 rounded-full border border-white/35 bg-black/28 px-4 py-2 text-sm font-medium text-white shadow-[0_20px_40px_-24px_rgba(15,23,42,0.7)] backdrop-blur-md transition hover:bg-black/40"
|
||||
@@ -574,31 +576,31 @@ export function ArtifactFileDetail({
|
||||
{isSupportPreview &&
|
||||
viewMode === "preview" &&
|
||||
(language === "markdown" || language === "html") && (
|
||||
<ArtifactFilePreview
|
||||
content={displayContent}
|
||||
language={language ?? "text"}
|
||||
animated={showPreviewSwitchPulse}
|
||||
activeCursor={isApplyingEdit}
|
||||
/>
|
||||
)}
|
||||
<ArtifactFilePreview
|
||||
content={displayContent}
|
||||
language={language ?? "text"}
|
||||
animated={showPreviewSwitchPulse}
|
||||
activeCursor={isApplyingEdit}
|
||||
/>
|
||||
)}
|
||||
{isCodeFile && viewMode === "code" && (
|
||||
<div className="size-full">
|
||||
<CodeEditor
|
||||
className="size-full resize-none rounded-none border-none bg-transparent"
|
||||
value={displayContent ?? ""}
|
||||
readonly={!canEditArtifact}
|
||||
onChange={
|
||||
canEditArtifact
|
||||
? (value) => {
|
||||
setDraftContent(value);
|
||||
setHasLocalEdits(true);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
streamingReadonlyFallback={!canEditArtifact}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<CodeEditor
|
||||
className="size-full resize-none rounded-none border-none bg-transparent"
|
||||
value={displayContent ?? ""}
|
||||
readonly={!canEditArtifact}
|
||||
onChange={
|
||||
canEditArtifact
|
||||
? (value) => {
|
||||
setDraftContent(value);
|
||||
setHasLocalEdits(true);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
streamingReadonlyFallback={!canEditArtifact}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isCodeFile &&
|
||||
(binaryPreviewUrl ? (
|
||||
<iframe className="size-full bg-white" src={binaryPreviewUrl} />
|
||||
@@ -613,17 +615,17 @@ export function ArtifactFileDetail({
|
||||
);
|
||||
}
|
||||
|
||||
export function ArtifactFilePreview({
|
||||
content,
|
||||
language,
|
||||
animated = false,
|
||||
activeCursor = false,
|
||||
}: {
|
||||
content: string;
|
||||
language: string;
|
||||
animated?: boolean;
|
||||
activeCursor?: boolean;
|
||||
}) {
|
||||
export function ArtifactFilePreview({
|
||||
content,
|
||||
language,
|
||||
animated = false,
|
||||
activeCursor = false,
|
||||
}: {
|
||||
content: string;
|
||||
language: string;
|
||||
animated?: boolean;
|
||||
activeCursor?: boolean;
|
||||
}) {
|
||||
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -654,15 +656,15 @@ export function ArtifactFilePreview({
|
||||
className="size-full"
|
||||
{...streamdownPlugins}
|
||||
components={{ a: ArtifactLink }}
|
||||
>
|
||||
{content ?? ""}
|
||||
</Streamdown>
|
||||
{activeCursor && (
|
||||
<span className="mt-2 inline-block h-5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
>
|
||||
{content ?? ""}
|
||||
</Streamdown>
|
||||
{activeCursor && (
|
||||
<span className="mt-2 inline-block h-5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (language === "html") {
|
||||
return (
|
||||
|
||||
@@ -33,7 +33,7 @@ export function ArtifactFileList({
|
||||
threadId: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { isMock } = useThread();
|
||||
const { isMock, publicShareCode } = useThread();
|
||||
const { select: selectArtifact, setOpen } = useArtifacts();
|
||||
const [installingFile, setInstallingFile] = useState<string | null>(null);
|
||||
const [downloadingFile, setDownloadingFile] = useState<string | null>(null);
|
||||
@@ -81,7 +81,7 @@ export function ArtifactFileList({
|
||||
if (downloadingFile) return;
|
||||
setDownloadingFile(filepath);
|
||||
try {
|
||||
await downloadArtifactFile({ filepath, threadId, isMock });
|
||||
await downloadArtifactFile({ filepath, threadId, isMock, publicShareCode });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
@@ -91,7 +91,7 @@ export function ArtifactFileList({
|
||||
setDownloadingFile(null);
|
||||
}
|
||||
},
|
||||
[threadId, isMock, downloadingFile],
|
||||
[threadId, isMock, publicShareCode, downloadingFile],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -88,7 +88,13 @@ export function MessageTokenUsage({
|
||||
// otherwise wait until usage arrives so the bar doesn't render on its own.
|
||||
const usage = getUsageMetadata(message);
|
||||
const showUsage = enabled && !isLoading && Boolean(usage);
|
||||
const showSpeed = speedEnabled && resolvedMetric !== undefined && resolvedMetric.durationMs > 0;
|
||||
// The speed pill is shown once per turn — only on the turn-final AI message
|
||||
// — so a turn with several LLM calls no longer renders a pill per call.
|
||||
const showSpeed =
|
||||
speedEnabled &&
|
||||
resolvedMetric !== undefined &&
|
||||
resolvedMetric.durationMs > 0 &&
|
||||
resolvedMetric.isTurnFinal;
|
||||
|
||||
if (!showUsage && !showSpeed) {
|
||||
return null;
|
||||
|
||||
@@ -36,6 +36,13 @@ export interface MessageMetric {
|
||||
* only to add a "~" prefix when the value is approximate.
|
||||
*/
|
||||
fromUsage: boolean;
|
||||
/**
|
||||
* True iff this is the turn-final AI message — the last AI message before
|
||||
* the next human message. An agentic turn makes several LLM calls, each its
|
||||
* own AI message; the speed pill is shown only on the turn-final one so a
|
||||
* turn renders a single pill instead of one per call.
|
||||
*/
|
||||
isTurnFinal: boolean;
|
||||
}
|
||||
|
||||
type Sample = { at: number; chars: number };
|
||||
@@ -161,6 +168,25 @@ export function useMessageMetrics(
|
||||
return () => window.clearInterval(interval);
|
||||
}, [isLoading]);
|
||||
|
||||
// Identify the turn-final AI message of each turn — the last AI message
|
||||
// (with a recorded metric) before the next human message. Only these show
|
||||
// the speed pill, collapsing a multi-LLM-call turn to a single pill.
|
||||
const turnFinalIds = new Set<string>();
|
||||
let pendingTurnFinal: string | null = null;
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i];
|
||||
if (!message) continue;
|
||||
if (message.type === "human") {
|
||||
if (pendingTurnFinal) turnFinalIds.add(pendingTurnFinal);
|
||||
pendingTurnFinal = null;
|
||||
continue;
|
||||
}
|
||||
if (isAiMessage(message) && timingRef.current.has(getMessageId(message, i))) {
|
||||
pendingTurnFinal = getMessageId(message, i);
|
||||
}
|
||||
}
|
||||
if (pendingTurnFinal) turnFinalIds.add(pendingTurnFinal);
|
||||
|
||||
// Build the snapshot map. We compute on every render so consumers see the
|
||||
// freshest duration; the underlying timing storage is the source of truth.
|
||||
const out = new Map<string, MessageMetric>();
|
||||
@@ -212,6 +238,7 @@ export function useMessageMetrics(
|
||||
durationMs,
|
||||
isActive,
|
||||
fromUsage,
|
||||
isTurnFinal: turnFinalIds.has(id),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -89,9 +89,9 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
const [staleSheetOpen, setStaleSheetOpen] = useState(false);
|
||||
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [tagId, setTagId] = useState<string | null>(null);
|
||||
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||
// Name + tag filtering is resolved server-side via the /api/skills query.
|
||||
const { skills, isLoading, error } = useSkills({ search, tagId });
|
||||
const { skills, isLoading, error } = useSkills({ search, tagIds });
|
||||
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const currentUserId = storedAuth?.user_id ?? null;
|
||||
@@ -208,9 +208,8 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
<TagSearchBar
|
||||
targetType="skill"
|
||||
onSearchChange={setSearch}
|
||||
tagId={tagId}
|
||||
onTagIdChange={setTagId}
|
||||
className="max-w-xl"
|
||||
tagIds={tagIds}
|
||||
onTagIdsChange={setTagIds}
|
||||
/>
|
||||
{isLoading && (
|
||||
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
|
||||
|
||||
@@ -1,46 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { SearchIcon } from "lucide-react";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, SearchIcon } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useTags, type TagType } from "@/core/tags";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ALL = "__all__";
|
||||
import { resolveTagColor } from "./tag-colors";
|
||||
|
||||
// How many tag chips to show before the list is collapsed behind a toggle.
|
||||
const COLLAPSE_THRESHOLD = 16;
|
||||
|
||||
/**
|
||||
* Reusable name-search + tag-filter toolbar for resource list pages.
|
||||
*
|
||||
* The name input is debounced (300ms) before ``onSearchChange`` fires, so the
|
||||
* caller can feed the value straight into a server-side query without a
|
||||
* request per keystroke. Filtering is open to all users.
|
||||
* request per keystroke.
|
||||
*
|
||||
* Tags are rendered inline as a multi-select chip row (instead of a dropdown):
|
||||
* "全部" is selected when no tag is picked, and any number of tags can be
|
||||
* toggled. Filtering is OR — a resource matches if it carries any picked tag.
|
||||
* When there are many tags the row collapses behind a 展开/收起 toggle.
|
||||
*/
|
||||
export function TagSearchBar({
|
||||
targetType,
|
||||
onSearchChange,
|
||||
tagId,
|
||||
onTagIdChange,
|
||||
tagIds,
|
||||
onTagIdsChange,
|
||||
className,
|
||||
}: {
|
||||
targetType: TagType;
|
||||
onSearchChange: (value: string) => void;
|
||||
tagId: string | null;
|
||||
onTagIdChange: (value: string | null) => void;
|
||||
tagIds: string[];
|
||||
onTagIdsChange: (value: string[]) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { data: tags } = useTags(targetType);
|
||||
|
||||
const [input, setInput] = useState("");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const cbRef = useRef(onSearchChange);
|
||||
cbRef.current = onSearchChange;
|
||||
useEffect(() => {
|
||||
@@ -48,9 +50,24 @@ export function TagSearchBar({
|
||||
return () => clearTimeout(id);
|
||||
}, [input]);
|
||||
|
||||
const allTags = tags ?? [];
|
||||
const canCollapse = allTags.length > COLLAPSE_THRESHOLD;
|
||||
// When collapsed, always keep the selected tags visible so the active
|
||||
// filter never hides itself behind the toggle.
|
||||
const visibleTags =
|
||||
canCollapse && !expanded
|
||||
? allTags.filter((tag, i) => i < COLLAPSE_THRESHOLD || tagIds.includes(tag.id))
|
||||
: allTags;
|
||||
|
||||
const toggleTag = (id: string) => {
|
||||
onTagIdsChange(
|
||||
tagIds.includes(id) ? tagIds.filter((x) => x !== id) : [...tagIds, id],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<div className="relative flex-1">
|
||||
<div className={cn("flex flex-col gap-2.5", className)}>
|
||||
<div className="relative">
|
||||
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={input}
|
||||
@@ -59,22 +76,61 @@ export function TagSearchBar({
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={tagId ?? ALL}
|
||||
onValueChange={(v) => onTagIdChange(v === ALL ? null : v)}
|
||||
>
|
||||
<SelectTrigger className="w-44">
|
||||
<SelectValue placeholder={t.adminTags.filterByTag} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>{t.adminTags.allTags}</SelectItem>
|
||||
{(tags ?? []).map((tag) => (
|
||||
<SelectItem key={tag.id} value={tag.id}>
|
||||
{tag.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{allTags.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTagIdsChange([])}
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-all",
|
||||
tagIds.length === 0
|
||||
? "bg-foreground text-background border-foreground"
|
||||
: "bg-muted text-muted-foreground border-transparent hover:opacity-80",
|
||||
)}
|
||||
>
|
||||
{t.adminTags.allTags}
|
||||
</button>
|
||||
{visibleTags.map((tag) => {
|
||||
const checked = tagIds.includes(tag.id);
|
||||
const color = resolveTagColor(tag.color);
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => toggleTag(tag.id)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-medium transition-all",
|
||||
color.chip,
|
||||
checked
|
||||
? "ring-2 ring-offset-1 ring-foreground/40"
|
||||
: "opacity-45 hover:opacity-90",
|
||||
)}
|
||||
>
|
||||
{checked && <CheckIcon className="size-3" />}
|
||||
{tag.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{canCollapse && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-0.5 px-1.5 py-1 text-xs"
|
||||
>
|
||||
{expanded ? (
|
||||
<>
|
||||
收起 <ChevronUpIcon className="size-3.5" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
展开全部 <ChevronDownIcon className="size-3.5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
|
||||
import { BotIcon, BrainIcon, CalendarClock, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
|
||||
@@ -19,7 +18,6 @@ import { BotIcon, BrainIcon, CalendarClock, ListChecks, MessageSquarePlus, Messa
|
||||
export function WorkspaceNavChatList() {
|
||||
const { t } = useI18n();
|
||||
const pathname = usePathname();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
return (
|
||||
<SidebarGroup className="px-2 pt-2">
|
||||
<SidebarMenu>
|
||||
@@ -86,35 +84,6 @@ export function WorkspaceNavChatList() {
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
{isAdmin && (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/admin/users"}
|
||||
asChild
|
||||
>
|
||||
<Link className="text-sidebar-foreground" href="/page/workspace/admin/users">
|
||||
<UsersIcon />
|
||||
<span>{t.sidebar.users}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/admin/scheduled-tasks"}
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
className="text-sidebar-foreground"
|
||||
href="/page/workspace/admin/scheduled-tasks"
|
||||
>
|
||||
<CalendarClock />
|
||||
<span>定时任务管理</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
);
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
|
||||
import {
|
||||
BellIcon,
|
||||
CalendarClockIcon,
|
||||
ChevronsUpDown,
|
||||
GaugeIcon,
|
||||
HistoryIcon,
|
||||
MessageSquareCodeIcon,
|
||||
MessageSquarePlusIcon,
|
||||
Settings2Icon,
|
||||
SettingsIcon,
|
||||
TagsIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
@@ -149,6 +152,34 @@ export function WorkspaceNavMenu() {
|
||||
大模型调用统计
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/users")}
|
||||
>
|
||||
<UsersIcon />
|
||||
用户管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/workspace/admin/scheduled-tasks")
|
||||
}
|
||||
>
|
||||
<CalendarClockIcon />
|
||||
定时任务管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/workspace/admin/user-activity")
|
||||
}
|
||||
>
|
||||
<HistoryIcon />
|
||||
用户活动审计
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/tags")}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
/** A conversation thread owned by some user (admin view). */
|
||||
export interface AdminThread {
|
||||
thread_id: string;
|
||||
display_name: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One conversation message, as serialized from the LangGraph checkpoint —
|
||||
* a LangChain message dump: ``type`` is "human" | "ai" | "tool" | "system",
|
||||
* ``content`` is a string or a content-block list.
|
||||
*/
|
||||
export interface AdminMessage {
|
||||
type?: string;
|
||||
content?: unknown;
|
||||
id?: string | null;
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
/** One memory bucket: the global file or a per-agent file. */
|
||||
export interface AdminMemoryBucket {
|
||||
name: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AdminMemoryResponse {
|
||||
user_id: string;
|
||||
buckets: AdminMemoryBucket[];
|
||||
}
|
||||
|
||||
const base = () => `${getBackendBaseURL()}/api/admin/users`;
|
||||
|
||||
async function parse<T>(res: Response, what: string): Promise<T> {
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `${what} (HTTP ${res.status})`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function listUserThreads(userId: string): Promise<AdminThread[]> {
|
||||
return parse(
|
||||
await apiFetch(`${base()}/${encodeURIComponent(userId)}/threads?limit=200`),
|
||||
"Failed to load threads",
|
||||
);
|
||||
}
|
||||
|
||||
export async function listUserThreadMessages(
|
||||
userId: string,
|
||||
threadId: string,
|
||||
): Promise<AdminMessage[]> {
|
||||
return parse(
|
||||
await apiFetch(
|
||||
`${base()}/${encodeURIComponent(userId)}/threads/${encodeURIComponent(threadId)}/messages`,
|
||||
),
|
||||
"Failed to load messages",
|
||||
);
|
||||
}
|
||||
|
||||
export async function getUserMemory(
|
||||
userId: string,
|
||||
): Promise<AdminMemoryResponse> {
|
||||
return parse(
|
||||
await apiFetch(`${base()}/${encodeURIComponent(userId)}/memory`),
|
||||
"Failed to load memory",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { getUserMemory, listUserThreadMessages, listUserThreads } from "./api";
|
||||
|
||||
/** Threads owned by a given user. Disabled until a user is picked. */
|
||||
export function useUserThreads(userId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["admin-activity", "threads", userId],
|
||||
queryFn: () => listUserThreads(userId!),
|
||||
enabled: !!userId,
|
||||
});
|
||||
}
|
||||
|
||||
/** Messages of a given thread. Disabled until a thread is picked. */
|
||||
export function useUserThreadMessages(
|
||||
userId: string | null,
|
||||
threadId: string | null,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["admin-activity", "messages", userId, threadId],
|
||||
queryFn: () => listUserThreadMessages(userId!, threadId!),
|
||||
enabled: !!userId && !!threadId,
|
||||
});
|
||||
}
|
||||
|
||||
/** All memory buckets of a given user. Disabled until a user is picked. */
|
||||
export function useUserMemory(userId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["admin-activity", "memory", userId],
|
||||
queryFn: () => getUserMemory(userId!),
|
||||
enabled: !!userId,
|
||||
});
|
||||
}
|
||||
@@ -17,13 +17,13 @@ export class AgentNameCheckError extends Error {
|
||||
|
||||
export interface ListAgentsParams {
|
||||
search?: string;
|
||||
tagId?: string | null;
|
||||
tagIds?: string[];
|
||||
}
|
||||
|
||||
export async function listAgents(params: ListAgentsParams = {}): Promise<Agent[]> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.search) qs.set("search", params.search);
|
||||
if (params.tagId) qs.set("tag_id", params.tagId);
|
||||
for (const id of params.tagIds ?? []) qs.append("tag_ids", id);
|
||||
const query = qs.toString();
|
||||
const res = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/agents${query ? `?${query}` : ""}`,
|
||||
|
||||
@@ -14,12 +14,12 @@ import {
|
||||
} from "./api";
|
||||
import type { CreateAgentRequest, UpdateAgentRequest } from "./types";
|
||||
|
||||
export function useAgents(params: { search?: string; tagId?: string | null } = {}) {
|
||||
export function useAgents(params: { search?: string; tagIds?: string[] } = {}) {
|
||||
const search = params.search ?? "";
|
||||
const tagId = params.tagId ?? null;
|
||||
const tagIds = params.tagIds ?? [];
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["agents", search, tagId],
|
||||
queryFn: () => listAgents({ search, tagId }),
|
||||
queryKey: ["agents", search, [...tagIds].sort()],
|
||||
queryFn: () => listAgents({ search, tagIds }),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
return { agents: data ?? [], isLoading, error };
|
||||
|
||||
@@ -43,12 +43,14 @@ export async function downloadArtifactFile({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
publicShareCode,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
isMock?: boolean;
|
||||
publicShareCode?: string;
|
||||
}): Promise<void> {
|
||||
const url = urlOfArtifact({ filepath, threadId, isMock, download: true });
|
||||
const url = urlOfArtifact({ filepath, threadId, isMock, publicShareCode, download: true });
|
||||
const res = await apiFetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(await errorMessageFromResponse(res));
|
||||
@@ -73,12 +75,14 @@ export async function openArtifactInNewTab({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
publicShareCode,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
isMock?: boolean;
|
||||
publicShareCode?: string;
|
||||
}): Promise<void> {
|
||||
const url = urlOfArtifact({ filepath, threadId, isMock, download: false });
|
||||
const url = urlOfArtifact({ filepath, threadId, isMock, publicShareCode, download: false });
|
||||
const res = await apiFetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(await errorMessageFromResponse(res));
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useThread } from "@/components/workspace/messages/context";
|
||||
|
||||
|
||||
import { useThread } from "@/components/workspace/messages/context";
|
||||
|
||||
import { loadArtifactContent, loadArtifactContentFromToolCall } from "./loader";
|
||||
import { updateArtifactContent } from "./api";
|
||||
|
||||
@@ -60,32 +60,32 @@ function applyPendingTextEdit({
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useArtifactContent({
|
||||
filepath,
|
||||
threadId,
|
||||
enabled,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const isWriteFile = useMemo(() => {
|
||||
return filepath.startsWith("write-file:");
|
||||
}, [filepath]);
|
||||
const { thread, isMock } = useThread();
|
||||
const content = useMemo(() => {
|
||||
if (isWriteFile) {
|
||||
return loadArtifactContentFromToolCall({ url: filepath, thread });
|
||||
}
|
||||
return null;
|
||||
}, [filepath, isWriteFile, thread]);
|
||||
|
||||
|
||||
export function useArtifactContent({
|
||||
filepath,
|
||||
threadId,
|
||||
enabled,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const isWriteFile = useMemo(() => {
|
||||
return filepath.startsWith("write-file:");
|
||||
}, [filepath]);
|
||||
const { thread, isMock, publicShareCode } = useThread();
|
||||
const content = useMemo(() => {
|
||||
if (isWriteFile) {
|
||||
return loadArtifactContentFromToolCall({ url: filepath, thread });
|
||||
}
|
||||
return null;
|
||||
}, [filepath, isWriteFile, thread]);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["artifact", filepath, threadId, isMock],
|
||||
queryFn: () => {
|
||||
return loadArtifactContent({ filepath, threadId, isMock });
|
||||
},
|
||||
queryKey: ["artifact", filepath, threadId, isMock, publicShareCode],
|
||||
queryFn: () => {
|
||||
return loadArtifactContent({ filepath, threadId, isMock, publicShareCode });
|
||||
},
|
||||
enabled,
|
||||
refetchInterval: thread.isLoading ? 1000 : false,
|
||||
// Cache artifact content for 5 minutes to avoid repeated fetches (especially for .skill ZIP extraction)
|
||||
|
||||
@@ -9,16 +9,18 @@ export async function loadArtifactContent({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
publicShareCode,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
isMock?: boolean;
|
||||
publicShareCode?: string;
|
||||
}) {
|
||||
let enhancedFilepath = filepath;
|
||||
if (filepath.endsWith(".skill")) {
|
||||
enhancedFilepath = filepath + "/SKILL.md";
|
||||
}
|
||||
const url = urlOfArtifact({ filepath: enhancedFilepath, threadId, isMock });
|
||||
const url = urlOfArtifact({ filepath: enhancedFilepath, threadId, isMock, publicShareCode });
|
||||
const response = await apiFetch(url);
|
||||
const text = await response.text();
|
||||
return { content: text, url };
|
||||
|
||||
@@ -6,12 +6,22 @@ export function urlOfArtifact({
|
||||
threadId,
|
||||
download = false,
|
||||
isMock = false,
|
||||
publicShareCode,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
download?: boolean;
|
||||
isMock?: boolean;
|
||||
publicShareCode?: string;
|
||||
}) {
|
||||
// A public read-only share has no auth-gated thread route — its referenced
|
||||
// files are served by the unauthenticated public-share endpoint instead.
|
||||
if (publicShareCode) {
|
||||
const normalized = filepath.startsWith("/") ? filepath : `/${filepath}`;
|
||||
return `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(
|
||||
publicShareCode,
|
||||
)}/files${normalized}`;
|
||||
}
|
||||
if (isMock) {
|
||||
return `${getBackendBaseURL()}/mock/api/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`;
|
||||
}
|
||||
|
||||
@@ -31,13 +31,13 @@ export async function getSchedulerThread() {
|
||||
|
||||
export interface ListScheduledTasksParams {
|
||||
search?: string;
|
||||
tagId?: string | null;
|
||||
tagIds?: string[];
|
||||
}
|
||||
|
||||
export async function listScheduledTasks(params: ListScheduledTasksParams = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.search) qs.set("search", params.search);
|
||||
if (params.tagId) qs.set("tag_id", params.tagId);
|
||||
for (const id of params.tagIds ?? []) qs.append("tag_ids", id);
|
||||
const query = qs.toString();
|
||||
return parseResponse<ScheduledTask[]>(
|
||||
await apiFetch(`${baseUrl()}${query ? `?${query}` : ""}`),
|
||||
|
||||
@@ -42,13 +42,13 @@ export function useSchedulerThread() {
|
||||
}
|
||||
|
||||
export function useScheduledTasks(
|
||||
params: { search?: string; tagId?: string | null } = {},
|
||||
params: { search?: string; tagIds?: string[] } = {},
|
||||
) {
|
||||
const search = params.search ?? "";
|
||||
const tagId = params.tagId ?? null;
|
||||
const tagIds = params.tagIds ?? [];
|
||||
return useQuery({
|
||||
queryKey: ["scheduled-tasks", "list", search, tagId],
|
||||
queryFn: () => listScheduledTasks({ search, tagId }),
|
||||
queryKey: ["scheduled-tasks", "list", search, [...tagIds].sort()],
|
||||
queryFn: () => listScheduledTasks({ search, tagIds }),
|
||||
refetchOnWindowFocus: false,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
@@ -5,13 +5,13 @@ import type { Skill, SkillState } from "./type";
|
||||
|
||||
export interface ListSkillsParams {
|
||||
search?: string;
|
||||
tagId?: string | null;
|
||||
tagIds?: string[];
|
||||
}
|
||||
|
||||
export async function loadSkills(params: ListSkillsParams = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.search) qs.set("search", params.search);
|
||||
if (params.tagId) qs.set("tag_id", params.tagId);
|
||||
for (const id of params.tagIds ?? []) qs.append("tag_ids", id);
|
||||
const query = qs.toString();
|
||||
const skills = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills${query ? `?${query}` : ""}`,
|
||||
|
||||
@@ -21,12 +21,12 @@ import type { SkillState } from "./type";
|
||||
|
||||
import { loadSkills } from ".";
|
||||
|
||||
export function useSkills(params: { search?: string; tagId?: string | null } = {}) {
|
||||
export function useSkills(params: { search?: string; tagIds?: string[] } = {}) {
|
||||
const search = params.search ?? "";
|
||||
const tagId = params.tagId ?? null;
|
||||
const tagIds = params.tagIds ?? [];
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["skills", search, tagId],
|
||||
queryFn: () => loadSkills({ search, tagId }),
|
||||
queryKey: ["skills", search, [...tagIds].sort()],
|
||||
queryFn: () => loadSkills({ search, tagIds }),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
return { skills: data ?? [], isLoading, error };
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
/** Coarse failure buckets, mirrored from the backend classifier. */
|
||||
export const TOOL_ERROR_CATEGORIES = [
|
||||
"rate_limited",
|
||||
"ip_blocked",
|
||||
"auth_error",
|
||||
"timeout",
|
||||
"network_error",
|
||||
"not_found",
|
||||
"empty_result",
|
||||
"tool_error",
|
||||
] as const;
|
||||
|
||||
export type ToolErrorCategory = (typeof TOOL_ERROR_CATEGORIES)[number];
|
||||
|
||||
/** Human-readable Chinese labels for each failure bucket. */
|
||||
export const TOOL_ERROR_CATEGORY_LABELS: Record<string, string> = {
|
||||
rate_limited: "限流 / 频率超限",
|
||||
ip_blocked: "IP 被封 / 拒绝访问",
|
||||
auth_error: "鉴权失败",
|
||||
timeout: "超时",
|
||||
network_error: "网络错误",
|
||||
not_found: "资源不存在",
|
||||
empty_result: "检索为空",
|
||||
tool_error: "工具内部错误",
|
||||
};
|
||||
|
||||
export interface ToolMetric {
|
||||
id: string;
|
||||
created_at?: string | null;
|
||||
user_id?: string | null;
|
||||
user_name?: string | null;
|
||||
thread_id?: string | null;
|
||||
run_id?: string | null;
|
||||
agent_name?: string | null;
|
||||
tool_name?: string | null;
|
||||
/** The Agent Skill this call targets (skill tool calls only). */
|
||||
skill_name?: string | null;
|
||||
duration_ms: number;
|
||||
status: "success" | "error" | string;
|
||||
error_category?: string | null;
|
||||
error_message?: string | null;
|
||||
}
|
||||
|
||||
export interface ToolMetricsListResponse {
|
||||
items: ToolMetric[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
/** Per-skill aggregate — was the skill invoked, and how often did it succeed. */
|
||||
export interface SkillStat {
|
||||
skill_name: string;
|
||||
total: number;
|
||||
success: number;
|
||||
error: number;
|
||||
}
|
||||
|
||||
export interface ToolMetricsSummary {
|
||||
total: number;
|
||||
error_total: number;
|
||||
by_category: Record<string, number>;
|
||||
by_skill: SkillStat[];
|
||||
}
|
||||
|
||||
export interface ToolMetricsFilters {
|
||||
user_id?: string;
|
||||
user_name?: string;
|
||||
tool_name?: string;
|
||||
/** Coarse call kind: "skill" keeps only Agent-Skill invocations; omit for all. */
|
||||
kind?: "skill" | "all";
|
||||
/** Filter to one specific Agent Skill by name. */
|
||||
skill_name?: string;
|
||||
status?: "success" | "error";
|
||||
error_category?: string;
|
||||
since?: string;
|
||||
until?: string;
|
||||
}
|
||||
|
||||
export interface ToolMetricsListQuery extends ToolMetricsFilters {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
function buildQuery(filters: ToolMetricsListQuery): string {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
params.set(key, String(value));
|
||||
}
|
||||
const qs = params.toString();
|
||||
return qs ? `?${qs}` : "";
|
||||
}
|
||||
|
||||
export async function loadToolMetrics(
|
||||
query: ToolMetricsListQuery,
|
||||
): Promise<ToolMetricsListResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/admin/tool-metrics${buildQuery(query)}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
throw new Error(detail.detail ?? "Failed to load tool metrics");
|
||||
}
|
||||
return (await response.json()) as ToolMetricsListResponse;
|
||||
}
|
||||
|
||||
export async function loadToolMetricsSummary(
|
||||
filters: ToolMetricsFilters,
|
||||
): Promise<ToolMetricsSummary> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/admin/tool-metrics/summary${buildQuery(filters)}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
throw new Error(detail.detail ?? "Failed to load tool metrics summary");
|
||||
}
|
||||
return (await response.json()) as ToolMetricsSummary;
|
||||
}
|
||||
|
||||
export async function downloadToolMetricsExcel(
|
||||
filters: ToolMetricsFilters,
|
||||
): Promise<void> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/admin/tool-metrics/export${buildQuery(filters)}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
throw new Error(detail.detail ?? "Failed to export tool metrics");
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const filename = (() => {
|
||||
const header = response.headers.get("content-disposition") ?? "";
|
||||
const utf8 = /filename\*=UTF-8''([^;]+)/i.exec(header);
|
||||
if (utf8?.[1]) return decodeURIComponent(utf8[1]);
|
||||
const plain = /filename="?([^";]+)"?/i.exec(header);
|
||||
return plain?.[1] ?? `tool_call_metrics.xlsx`;
|
||||
})();
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.rel = "noopener";
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
loadToolMetrics,
|
||||
loadToolMetricsSummary,
|
||||
type ToolMetricsFilters,
|
||||
type ToolMetricsListQuery,
|
||||
} from "./api";
|
||||
|
||||
export function useToolMetrics(query: ToolMetricsListQuery) {
|
||||
return useQuery({
|
||||
queryKey: ["toolMetrics", query],
|
||||
queryFn: () => loadToolMetrics(query),
|
||||
refetchOnWindowFocus: false,
|
||||
placeholderData: (previous) => previous,
|
||||
});
|
||||
}
|
||||
|
||||
export function useToolMetricsSummary(filters: ToolMetricsFilters) {
|
||||
return useQuery({
|
||||
queryKey: ["toolMetricsSummary", filters],
|
||||
queryFn: () => loadToolMetricsSummary(filters),
|
||||
refetchOnWindowFocus: false,
|
||||
placeholderData: (previous) => previous,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, MessageSquareIcon, SearchIcon, UserIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useUserMemory, useUserThreadMessages, useUserThreads } from "@/core/admin/hooks";
|
||||
import type { AdminMemoryBucket, AdminMessage } from "@/core/admin/api";
|
||||
import { useAllUsers } from "@/core/auth/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Flatten a langchain message dump (or raw string / block list) into text. */
|
||||
function extractText(content: unknown): string {
|
||||
if (content == null) return "";
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((block) => {
|
||||
if (typeof block === "string") return block;
|
||||
if (block && typeof block === "object") {
|
||||
const text = (block as Record<string, unknown>).text;
|
||||
if (typeof text === "string") return text;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
if (typeof content === "object") {
|
||||
const inner = (content as Record<string, unknown>).content;
|
||||
if (inner !== undefined) return extractText(inner);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function roleOf(message: AdminMessage): "user" | "assistant" | null {
|
||||
const type = (message.type ?? "").toLowerCase();
|
||||
if (type === "human") return "user";
|
||||
if (type === "ai" || type === "aimessagechunk") return "assistant";
|
||||
return null; // tool results and system messages are hidden from the Q&A view
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
|
||||
}
|
||||
|
||||
/** A single Q&A bubble. */
|
||||
function MessageRow({ message }: { message: AdminMessage }) {
|
||||
const role = roleOf(message);
|
||||
if (!role) return null;
|
||||
const text = extractText(message.content).trim();
|
||||
if (!text) return null; // skip empty AI turns (e.g. tool-call-only responses)
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1", role === "user" ? "items-start" : "items-end")}>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{role === "user" ? "用户提问" : "AI 回答"}
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[85%] whitespace-pre-wrap rounded-lg border px-3 py-2 text-sm",
|
||||
role === "user" ? "bg-muted" : "bg-primary/5 border-primary/20",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Render one Memory V2 bucket: a flat list of USER.md / MEMORY.md entries. */
|
||||
function MemoryBucketView({ bucket }: { bucket: AdminMemoryBucket }) {
|
||||
const raw = (bucket.data as { entries?: unknown }).entries;
|
||||
const entries = Array.isArray(raw)
|
||||
? raw.filter(
|
||||
(e): e is string => typeof e === "string" && e.trim().length > 0,
|
||||
)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="rounded-md border p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Badge variant={bucket.name === "global" ? "default" : "secondary"}>
|
||||
{bucket.name === "global" ? "用户画像" : `Agent: ${bucket.name}`}
|
||||
</Badge>
|
||||
{entries.length > 0 && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{entries.length} 条
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{entries.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">该记忆库暂无内容。</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{entries.map((entry, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm">
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span className="flex-1 whitespace-pre-wrap">{entry}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminUserActivityPage() {
|
||||
const { data: users, isLoading: usersLoading } = useAllUsers(true);
|
||||
const [userId, setUserId] = useState<string | null>(null);
|
||||
const [threadId, setThreadId] = useState<string | null>(null);
|
||||
const [userQuery, setUserQuery] = useState("");
|
||||
const [tab, setTab] = useState<"qa" | "memory">("qa");
|
||||
|
||||
const { data: threads, isLoading: threadsLoading } = useUserThreads(userId);
|
||||
const { data: messages, isLoading: messagesLoading } = useUserThreadMessages(
|
||||
userId,
|
||||
threadId,
|
||||
);
|
||||
const { data: memory, isLoading: memoryLoading } = useUserMemory(userId);
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
const needle = userQuery.trim().toLowerCase();
|
||||
const list = users ?? [];
|
||||
return needle ? list.filter((u) => u.email.toLowerCase().includes(needle)) : list;
|
||||
}, [users, userQuery]);
|
||||
|
||||
const selectedUser = users?.find((u) => u.id === userId) ?? null;
|
||||
|
||||
const selectUser = (id: string) => {
|
||||
setUserId(id);
|
||||
setThreadId(null);
|
||||
setTab("qa");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col">
|
||||
<div className="border-b px-6 py-4">
|
||||
<h1 className="text-xl font-semibold">用户活动审计</h1>
|
||||
<p className="text-muted-foreground mt-0.5 text-sm">
|
||||
查看任意用户的历史问答与记忆(只读)。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* Pane 1: users */}
|
||||
<div className="flex w-64 shrink-0 flex-col border-r">
|
||||
<div className="relative p-2">
|
||||
<SearchIcon className="text-muted-foreground absolute left-4 top-1/2 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={userQuery}
|
||||
onChange={(e) => setUserQuery(e.target.value)}
|
||||
placeholder="搜索用户邮箱"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
{usersLoading && (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" /> 加载中
|
||||
</div>
|
||||
)}
|
||||
{!usersLoading && filteredUsers.length === 0 && (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">无用户</div>
|
||||
)}
|
||||
<div className="flex flex-col p-1">
|
||||
{filteredUsers.map((u) => (
|
||||
<button
|
||||
key={u.id}
|
||||
type="button"
|
||||
onClick={() => selectUser(u.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-2 py-2 text-left text-sm",
|
||||
u.id === userId ? "bg-accent" : "hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<UserIcon className="text-muted-foreground size-4 shrink-0" />
|
||||
<span className="flex-1 truncate">{u.email}</span>
|
||||
{u.system_role === "admin" && (
|
||||
<Badge variant="outline" className="shrink-0 text-xs">
|
||||
管理员
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Pane 2: threads */}
|
||||
<div className="flex w-72 shrink-0 flex-col border-r">
|
||||
<div className="text-muted-foreground border-b px-3 py-2 text-xs font-medium">
|
||||
会话列表{threads ? `(${threads.length})` : ""}
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
{!userId && (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||
请先选择用户
|
||||
</div>
|
||||
)}
|
||||
{userId && threadsLoading && (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" /> 加载中
|
||||
</div>
|
||||
)}
|
||||
{userId && !threadsLoading && (threads?.length ?? 0) === 0 && (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||
该用户暂无会话
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col p-1">
|
||||
{(threads ?? []).map((thread) => (
|
||||
<button
|
||||
key={thread.thread_id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setThreadId(thread.thread_id);
|
||||
setTab("qa");
|
||||
}}
|
||||
className={cn(
|
||||
"flex flex-col gap-0.5 rounded-md px-2 py-2 text-left",
|
||||
thread.thread_id === threadId ? "bg-accent" : "hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-sm">
|
||||
{thread.display_name || "未命名会话"}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{fmtTime(thread.updated_at)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Pane 3: detail */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{!userId ? (
|
||||
<div className="text-muted-foreground flex flex-1 items-center justify-center text-sm">
|
||||
选择一个用户开始审计
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3 border-b px-4 py-2">
|
||||
<span className="text-muted-foreground truncate text-sm">
|
||||
{selectedUser?.email}
|
||||
</span>
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as "qa" | "memory")}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="qa">问答记录</TabsTrigger>
|
||||
<TabsTrigger value="memory">记忆</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
{tab === "qa" && (
|
||||
<div className="p-4">
|
||||
{!threadId && (
|
||||
<div className="text-muted-foreground flex flex-col items-center gap-2 py-16 text-sm">
|
||||
<MessageSquareIcon className="size-6" />
|
||||
请从中间列选择一个会话
|
||||
</div>
|
||||
)}
|
||||
{threadId && messagesLoading && (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-16 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" /> 加载中
|
||||
</div>
|
||||
)}
|
||||
{threadId && !messagesLoading && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{(messages ?? []).map((m, i) => (
|
||||
<MessageRow key={m.id ?? i} message={m} />
|
||||
))}
|
||||
{(messages ?? []).every((m) => !roleOf(m)) && (
|
||||
<div className="text-muted-foreground py-12 text-center text-sm">
|
||||
该会话暂无问答记录
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "memory" && (
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
{memoryLoading && (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-16 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" /> 加载中
|
||||
</div>
|
||||
)}
|
||||
{!memoryLoading &&
|
||||
(memory?.buckets ?? []).map((bucket) => (
|
||||
<MemoryBucketView key={bucket.name} bucket={bucket} />
|
||||
))}
|
||||
{!memoryLoading && (memory?.buckets?.length ?? 0) === 0 && (
|
||||
<div className="text-muted-foreground py-12 text-center text-sm">
|
||||
该用户暂无记忆数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ToolMetricsPanel } from "@/components/workspace/admin/tool-metrics-panel";
|
||||
import {
|
||||
downloadLlmMetricsExcel,
|
||||
type LlmMetricsFilters,
|
||||
@@ -70,7 +72,8 @@ function speedColor(tps?: number | null): string {
|
||||
return "#ef4444";
|
||||
}
|
||||
|
||||
export default function LlmMetricsPage() {
|
||||
/** The original LLM-call metrics view, now one tab of the metrics page. */
|
||||
function LlmMetricsPanel() {
|
||||
const [page, setPage] = useState(0);
|
||||
const [model, setModel] = useState("");
|
||||
const [user, setUser] = useState("");
|
||||
@@ -245,10 +248,10 @@ export default function LlmMetricsPage() {
|
||||
const colSpan = visibleColumns.length || 1;
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-4 px-6 py-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">大模型调用统计</h1>
|
||||
<h2 className="text-lg font-semibold">大模型调用统计</h2>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
记录每一次大模型的实际调用,包括 token 数、耗时、估算速度和失败原因。
|
||||
后台无条件记录,与前端速度指示器开关无关。
|
||||
@@ -456,3 +459,22 @@ function Td({
|
||||
</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="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>
|
||||
);
|
||||
}
|
||||
@@ -3,16 +3,65 @@
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||
import {
|
||||
ArtifactFileDetail,
|
||||
ArtifactsProvider,
|
||||
useArtifacts,
|
||||
} from "@/components/workspace/artifacts";
|
||||
import { MessageList } from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { SubtasksProvider } from "@/core/tasks/context";
|
||||
import { getPublicShareSnapshot } from "@/core/thread-shares/api";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
|
||||
/**
|
||||
* Conversation + artifact split shown inside the share's providers.
|
||||
*
|
||||
* The public page has no workspace layout, so it renders its own artifact
|
||||
* panel: clicking a file in the conversation selects it via `ArtifactsProvider`
|
||||
* and the detail view appears on the right.
|
||||
*/
|
||||
function PublicShareBody({
|
||||
thread,
|
||||
shareCode,
|
||||
artifacts,
|
||||
}: {
|
||||
thread: BaseStream<AgentThreadState>;
|
||||
shareCode?: string;
|
||||
artifacts: string[];
|
||||
}) {
|
||||
const { selectedArtifact, open, setArtifacts } = useArtifacts();
|
||||
const threadId = shareCode ?? "public-share";
|
||||
|
||||
// Feed the snapshot's artifact list to the provider so the detail view's
|
||||
// file switcher works.
|
||||
useEffect(() => {
|
||||
setArtifacts(artifacts);
|
||||
}, [artifacts, setArtifacts]);
|
||||
|
||||
const showArtifact = open && !!selectedArtifact;
|
||||
|
||||
return (
|
||||
<div className="flex size-full min-h-0">
|
||||
<div className="min-h-0 min-w-0 flex-1">
|
||||
<MessageList threadId={threadId} thread={thread} paddingBottom={48} />
|
||||
</div>
|
||||
{showArtifact && (
|
||||
<div className="flex min-h-0 w-1/2 min-w-0 shrink-0 flex-col border-l bg-[#fffdfa] p-4">
|
||||
<ArtifactFileDetail
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={threadId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public, read-only conversation viewer.
|
||||
*
|
||||
@@ -91,10 +140,10 @@ export default function PublicSharePage() {
|
||||
<ThreadContext.Provider
|
||||
value={{ thread, isMock: true, publicShareCode: shareCode }}
|
||||
>
|
||||
<MessageList
|
||||
threadId={shareCode ?? "public-share"}
|
||||
<PublicShareBody
|
||||
thread={thread}
|
||||
paddingBottom={48}
|
||||
shareCode={shareCode}
|
||||
artifacts={data.artifacts ?? []}
|
||||
/>
|
||||
</ThreadContext.Provider>
|
||||
</ArtifactsProvider>
|
||||
|
||||
@@ -90,9 +90,9 @@ export default function ScheduledTasksPage() {
|
||||
// so the list re-selects (and scrolls to) the task the user was viewing.
|
||||
const initialTaskId = searchParams.get("task");
|
||||
const [search, setSearch] = useState("");
|
||||
const [tagId, setTagId] = useState<string | null>(null);
|
||||
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||
// Name + tag filtering is resolved server-side via the /api/scheduled-tasks query.
|
||||
const { data: tasks = [], isLoading } = useScheduledTasks({ search, tagId });
|
||||
const { data: tasks = [], isLoading } = useScheduledTasks({ search, tagIds });
|
||||
const { data: deliveryProfile } = useScheduledTaskDeliveryProfile();
|
||||
const { agents } = useAgents();
|
||||
const actions = useScheduledTaskActions();
|
||||
@@ -283,8 +283,8 @@ export default function ScheduledTasksPage() {
|
||||
<TagSearchBar
|
||||
targetType="scheduled_task"
|
||||
onSearchChange={setSearch}
|
||||
tagId={tagId}
|
||||
onTagIdChange={setTagId}
|
||||
tagIds={tagIds}
|
||||
onTagIdsChange={setTagIds}
|
||||
/>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
@@ -27,6 +27,7 @@ import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
|
||||
import SkillEvolutionPage from "./SkillEvolutionPage";
|
||||
import SkillsPage from "./SkillsPage";
|
||||
import UsersPage from "./UsersPage";
|
||||
import AdminUserActivityPage from "./AdminUserActivityPage";
|
||||
import TagsPage from "./TagsPage";
|
||||
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
||||
|
||||
@@ -189,9 +190,13 @@ export default function WorkspaceRoutes({
|
||||
<Route
|
||||
path="admin/users"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<UsersPage />
|
||||
</WorkspaceLayout>
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<UsersPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
@@ -206,6 +211,18 @@ export default function WorkspaceRoutes({
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/user-activity"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AdminUserActivityPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/tags"
|
||||
element={
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
const rootDir = fileURLToPath(new URL(".", import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
@@ -32,6 +32,7 @@ export default defineConfig({
|
||||
},
|
||||
],
|
||||
},
|
||||
"base":"./",
|
||||
server: {
|
||||
port: 5173,
|
||||
},
|
||||
|
||||
@@ -187,6 +187,7 @@ Lead-agent middlewares are assembled in strict append order across `packages/har
|
||||
16. **SubagentLimitMiddleware** - Truncates excess `task` tool calls from model response to enforce `MAX_CONCURRENT_SUBAGENTS` limit (optional, if `subagent_enabled`)
|
||||
17. **LoopDetectionMiddleware** - Detects repeated tool-call loops; hard-stop responses clear both structured `tool_calls` and raw provider tool-call metadata before forcing a final text answer
|
||||
18. **ClarificationMiddleware** - Intercepts `ask_clarification` tool calls, interrupts via `Command(goto=END)` (must be last)
|
||||
8a. **ToolMetricsMiddleware** - Appended immediately after `ToolErrorHandlingMiddleware` in `_build_runtime_middlewares`, so it sits _deepest_ in the `wrap_tool_call` chain. Records one `tool_call_metrics` row per tool/skill/MCP invocation — timing duration and bucketing failures into a coarse `error_category` (rate_limited / ip_blocked / auth_error / timeout / network_error / not_found / empty_result / tool_error). It catches both raised exceptions (before #8 converts them) and _self-handled_ errors returned as error `ToolMessage`s (search tools that swallow HTTP 429/403). It also captures the targeted Agent Skill into `tool_call_metrics.skill_name`: the lead agent invokes a skill by `read_file`-ing its `/mnt/skills/<category>/<name>/SKILL.md` path (per the `<skill_system>` prompt block — there is no dedicated "use skill" tool), so `skill_name` is recovered by scanning every tool call's string arguments for the `skills/{public,custom}/{name}/` path shape (`_SKILL_PATH_RE`), plus the explicit `name` argument of `skill_view`/`skill_manage`/`skill_archive`. `skill_view`-specific success detection handles its swallowed failures (`Skill 'x' not found.` returned as a plain string rather than raised). Regression coverage: `tests/test_tool_metrics_middleware.py`. Writes are best-effort fire-and-forget. **Per-run skill merging**: `SqlToolMetricsStore.record` upserts skill rows by `(run_id, skill_name)` instead of inserting one row per call — a skill touched by several tool calls / retries inside one run collapses to a single row. Any success in the run wins (a skill that failed then finally succeeded is recorded as one `success`); a skill that only ever failed yields one `error` row whose `error_message` accumulates every attempt's message; durations are summed. Non-skill tool calls, and skill calls with no `run_id`, keep one row each. The read→merge→write is guarded by a per-event-loop `asyncio.Lock`. Surfaced by the admin `GET /api/admin/tool-metrics` router (which has a `kind=skill` filter and a per-skill `/summary` breakdown).
|
||||
19. **PromptPrefixMiddleware** - Injects the admin-configured prompt prefix into the model request only. The prefix is tagged onto the first human message's `additional_kwargs['prompt_prefix']` by the Gateway (`app.gateway.services`) and prepended to content here at call time, so the persisted/displayed message stays prefix-free
|
||||
20. **SummarizationMiddleware** - Context reduction when approaching token limits (optional, if enabled)
|
||||
21. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
|
||||
@@ -246,8 +247,9 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
|
||||
| **Skills** (`/api/skills`) | `GET /` - list skills (optional `search` fuzzy-name + `tag_id` filters; each item carries `tags`); `GET /{name}` - details; `PUT /{name}` - update enabled; `POST /install` - install from .skill archive (accepts standard optional frontmatter like `version`, `author`, `compatibility`) |
|
||||
| **Skills** (`/api/skills`) | `GET /` - list skills (optional `search` fuzzy-name + `tag_id` filters; each item carries `tags` + owner-authored `detail`); `GET /{name}` - details; `PUT /{name}` - update enabled; `PUT /{name}/detail` - set the long-form `detail` text (custom skill: owner or admin; built-in/public: admin only — materializes a `skills` row on demand); `POST /install` - install from .skill archive (accepts standard optional frontmatter like `version`, `author`, `compatibility`) |
|
||||
| **Skills** (`/api/skills`) | `GET /` - list skills (optional `search` fuzzy-name + `tag_id` filters; each item carries `tags` + owner-authored `detail`); `GET /{name}` - details; `PUT /{name}` - update enabled; `PUT /{name}/detail` - set the long-form `detail` text (custom skill: owner or admin; built-in/public: admin only — materializes a `skills` row on demand); `GET /{name}/content` - raw SKILL.md source, restricted to an admin or the publisher (custom-skill owner); other users get 403 and see only description + detail; `POST /install` - install from .skill archive (accepts standard optional frontmatter like `version`, `author`, `compatibility`) |
|
||||
| **Skills** (`/api/skills`) | `GET /` - list skills (optional `search` fuzzy-name + repeatable `tag_ids` multi-select filter, OR-matched; each item carries `tags` + owner-authored `detail`); `GET /{name}` - details; `PUT /{name}` - update enabled; `PUT /{name}/detail` - set the long-form `detail` text (custom skill: owner or admin; built-in/public: admin only — materializes a `skills` row on demand); `GET /{name}/content` - raw SKILL.md source, restricted to an admin or the publisher (custom-skill owner); other users get 403 and see only description + detail; `POST /install` - install from .skill archive (accepts standard optional frontmatter like `version`, `author`, `compatibility`) |
|
||||
| **Memory** (`/api/memory`) | `GET /` - memory data; `POST /reload` - force reload; `GET /config` - config; `GET /status` - config + data |
|
||||
| **Agents** (`/api/agents`) | `GET /` - list built-in, owned, and published agents (optional `search` + `tag_id` filters; each item carries `tags`); `POST /` - create user-owned agent; `GET/PUT/DELETE /{id}` - read/update/delete by stable agent id; `GET /check` - validate non-empty display name (duplicates allowed) |
|
||||
| **Agents** (`/api/agents`) | `GET /` - list built-in, owned, and published agents (optional `search` + repeatable `tag_ids` multi-select filter, OR-matched; each item carries `tags`); `POST /` - create user-owned agent; `GET/PUT/DELETE /{id}` - read/update/delete by stable agent id; `GET /check` - validate non-empty display name (duplicates allowed) |
|
||||
| **Tags** (`/api/tags`) | `GET /` - list tags (optional `tag_type` filter; open to all); `POST /` - create tag (admin); `PUT /{id}` / `DELETE /{id}` - update/delete tag (admin); `GET /assignments/{target_type}/{target_id}` - tags on a resource; `PUT /assignments/{target_type}/{target_id}` - replace a resource's tags (admin). Tags are typed `agent`/`skill`/`scheduled_task` and may only tag resources of their own type. |
|
||||
| **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload files (auto-converts PDF/PPT/Excel/Word); `GET /list` - list; `DELETE /{filename}` - delete |
|
||||
| **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail |
|
||||
@@ -257,7 +259,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
|
||||
| **Feedback** (`/api/threads/{id}/runs/{rid}/feedback`) | `PUT /` - upsert feedback; `DELETE /` - delete user feedback; `POST /` - create feedback; `GET /` - list feedback; `GET /stats` - aggregate stats; `DELETE /{fid}` - delete specific |
|
||||
| **Runs** (`/api/runs`) | `POST /stream` - stateless run + SSE; `POST /wait` - stateless run + block; `GET /{rid}/messages` - paginated messages by run_id `{data, has_more}` (cursor: `after_seq`/`before_seq`); `GET /{rid}/feedback` - list feedback by run_id |
|
||||
| **Thread Shares** (`/api/thread-shares`) | Per-conversation sharing with two modes (`thread_shares.mode`). **`import`**: `POST /` `{thread_id, mode}` - create (or reuse) a revocable share code; `GET /{code}/preview` + `POST /{code}/import` - copy that conversation into the caller's account (fresh `thread_id` via `app/gateway/thread_copy.py` + new `threads_meta` row + `user-data` copy; dedup on `metadata.shared_origin`). **`view`**: a public, read-only static page — see Public Shares below. Both modes: `GET /` - list my codes; `DELETE /{code}` - revoke. |
|
||||
| **Public Shares** (`/api/public/shares`) | **Unauthenticated** (the `/api/public/` prefix is whitelisted in `auth_middleware._PUBLIC_PATH_PREFIXES`). `GET /{code}` - read-only conversation snapshot (title + serialized messages + artifacts) for a `view`-mode share; messages are read straight from the latest checkpoint. `GET /{code}/files/{path}` - serves an **image** referenced by that conversation (images only — non-image paths 404; SVG forced to download). Missing/revoked/non-`view` codes all return 404. |
|
||||
| **Public Shares** (`/api/public/shares`) | **Unauthenticated** (the `/api/public/` prefix is whitelisted in `auth_middleware._PUBLIC_PATH_PREFIXES`). `GET /{code}` - read-only conversation snapshot (title + serialized messages + artifacts) for a `view`-mode share; messages are read straight from the latest checkpoint. `GET /{code}/files/{path}` - serves a file referenced by that conversation (images, Markdown, text, PDF, …) so the shared page's links are openable; path resolution is confined to the owner's thread directory. Active content (`text/html`, `application/xhtml+xml`, `image/svg+xml`) is forced to download (`_public_share_disposition`); everything else is served inline. Missing/revoked/non-`view` codes all return 404. |
|
||||
| **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. |
|
||||
|
||||
Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` → Gateway.
|
||||
|
||||
@@ -23,6 +23,7 @@ from app.gateway.csrf_middleware import CSRFMiddleware
|
||||
from app.gateway.deps import langgraph_runtime
|
||||
from app.gateway.routers import (
|
||||
ai_writing,
|
||||
admin_users,
|
||||
agents,
|
||||
artifacts,
|
||||
assistants_compat,
|
||||
@@ -42,6 +43,7 @@ from app.gateway.routers import (
|
||||
system_settings,
|
||||
tags,
|
||||
thread_runs,
|
||||
tool_metrics,
|
||||
thread_shares,
|
||||
threads,
|
||||
uploads,
|
||||
@@ -256,7 +258,7 @@ async def _run_db_migrations() -> None:
|
||||
logger.info("Persistence backend=memory — skipping Alembic migrations")
|
||||
return
|
||||
|
||||
db_url = engine.url.render_as_string(hide_password=False).replace("%", "%%")
|
||||
db_url = engine.url.render_as_string(hide_password=False).replace("%", "%%")
|
||||
migrations_dir = str(Path(_persistence_pkg.__file__).parent / "migrations")
|
||||
|
||||
def _run_sync() -> None:
|
||||
@@ -689,6 +691,12 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
# Admin LLM metrics audit + export
|
||||
app.include_router(llm_metrics.router)
|
||||
|
||||
# Admin window into other users' threads / messages / memory
|
||||
app.include_router(admin_users.router)
|
||||
|
||||
# Admin tool / skill call metrics audit + export
|
||||
app.include_router(tool_metrics.router)
|
||||
|
||||
# Channels API is mounted at /api/channels
|
||||
app.include_router(channels.router)
|
||||
|
||||
|
||||
@@ -97,6 +97,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
app.state.llm_metrics_store = make_llm_metrics_store(sf)
|
||||
|
||||
from deerflow.persistence.tool_metrics import make_tool_metrics_store
|
||||
|
||||
app.state.tool_metrics_store = make_tool_metrics_store(sf)
|
||||
|
||||
from deerflow.persistence.agents import make_agent_store
|
||||
|
||||
app.state.agent_store = make_agent_store(sf)
|
||||
@@ -202,6 +206,11 @@ def get_llm_metrics_store(request: Request):
|
||||
return getattr(request.app.state, "llm_metrics_store", None)
|
||||
|
||||
|
||||
def get_tool_metrics_store(request: Request):
|
||||
"""Return the tool-call metrics store, or None when not configured."""
|
||||
return getattr(request.app.state, "tool_metrics_store", None)
|
||||
|
||||
|
||||
def get_agent_store(request: Request) -> AgentStore:
|
||||
"""Return the custom-agent store."""
|
||||
val = getattr(request.app.state, "agent_store", None)
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Admin-only window into another user's activity.
|
||||
|
||||
Lets an administrator inspect any user's conversation history (threads + the
|
||||
Q&A messages inside them) and their accumulated memory (global + per-agent
|
||||
buckets). All endpoints require ``system_role == "admin"``; they are strictly
|
||||
read-only.
|
||||
|
||||
The underlying stores normally auto-scope every query to the *calling* user
|
||||
via a contextvar. These endpoints pass an explicit ``user_id`` so the admin
|
||||
reads the *target* user's data instead — which is exactly what those store
|
||||
methods' ``user_id`` parameter is designed for.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.deps import (
|
||||
get_checkpointer,
|
||||
get_current_user_from_request,
|
||||
get_thread_store,
|
||||
)
|
||||
from deerflow.agents.memory import BuiltinFileProvider
|
||||
from deerflow.config.agents_config import AGENT_NAME_PATTERN
|
||||
from deerflow.config.paths import get_paths
|
||||
from deerflow.runtime import serialize_channel_values
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/admin/users", tags=["admin-users"])
|
||||
|
||||
|
||||
async def _require_admin(request: Request) -> None:
|
||||
"""Reject non-admin callers. In no-auth mode there is no user → allow."""
|
||||
try:
|
||||
user = await get_current_user_from_request(request)
|
||||
except HTTPException:
|
||||
raise
|
||||
if user is None:
|
||||
return
|
||||
if getattr(user, "system_role", None) != "admin":
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
|
||||
|
||||
# ── Threads ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AdminThreadItem(BaseModel):
|
||||
thread_id: str
|
||||
display_name: str = ""
|
||||
status: str = "idle"
|
||||
created_at: str = ""
|
||||
updated_at: str = ""
|
||||
|
||||
|
||||
@router.get("/{user_id}/threads", response_model=list[AdminThreadItem])
|
||||
async def list_user_threads(
|
||||
user_id: str,
|
||||
request: Request,
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> list[AdminThreadItem]:
|
||||
"""List one user's conversation threads, newest first."""
|
||||
await _require_admin(request)
|
||||
repo = get_thread_store(request)
|
||||
rows = await repo.search(limit=limit, offset=offset, user_id=user_id)
|
||||
return [
|
||||
AdminThreadItem(
|
||||
thread_id=r["thread_id"],
|
||||
display_name=r.get("display_name") or "",
|
||||
status=r.get("status", "idle"),
|
||||
created_at=str(r.get("created_at") or ""),
|
||||
updated_at=str(r.get("updated_at") or ""),
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{user_id}/threads/{thread_id}/messages", response_model=list[dict])
|
||||
async def list_user_thread_messages(
|
||||
user_id: str,
|
||||
thread_id: str,
|
||||
request: Request,
|
||||
limit: int = Query(default=400, ge=1, le=1000),
|
||||
) -> list[dict]:
|
||||
"""Return the conversation messages of one of the user's threads.
|
||||
|
||||
Read straight from the LangGraph checkpoint — the same source the chat UI
|
||||
renders — rather than the optional ``run_events`` audit log, which may be
|
||||
unconfigured (``run_events.backend=memory``) or empty for older threads.
|
||||
"""
|
||||
await _require_admin(request)
|
||||
|
||||
repo = get_thread_store(request)
|
||||
# ``user_id=None`` bypasses the owner filter so the admin can fetch any row;
|
||||
# we then assert the thread really belongs to the target user.
|
||||
thread = await repo.get(thread_id, user_id=None)
|
||||
if thread is None:
|
||||
raise HTTPException(status_code=404, detail="Thread not found")
|
||||
owner = thread.get("user_id")
|
||||
if owner is not None and owner != user_id:
|
||||
raise HTTPException(status_code=404, detail="Thread does not belong to this user")
|
||||
|
||||
checkpointer = get_checkpointer(request)
|
||||
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
||||
try:
|
||||
checkpoint_tuple = await checkpointer.aget_tuple(config)
|
||||
except Exception as exc: # noqa: BLE001 - surface a clean error
|
||||
logger.error("Failed to read checkpoint for thread %s: %s", thread_id, exc, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Failed to read thread state") from exc
|
||||
if checkpoint_tuple is None:
|
||||
return []
|
||||
|
||||
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
|
||||
channel_values = checkpoint.get("channel_values", {}) if isinstance(checkpoint, dict) else {}
|
||||
values = serialize_channel_values(channel_values)
|
||||
messages = values.get("messages", [])
|
||||
if not isinstance(messages, list):
|
||||
return []
|
||||
# Keep the most recent ``limit`` messages — long threads stay bounded.
|
||||
return messages[-limit:]
|
||||
|
||||
|
||||
# ── Memory ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AdminMemoryBucket(BaseModel):
|
||||
name: str = Field(..., description="Bucket name: 'global' or a custom-agent name")
|
||||
data: dict = Field(default_factory=dict, description="Raw memory data for the bucket")
|
||||
|
||||
|
||||
class AdminMemoryResponse(BaseModel):
|
||||
user_id: str
|
||||
buckets: list[AdminMemoryBucket] = Field(default_factory=list)
|
||||
|
||||
|
||||
def _read_memory_bucket(user_id: str, agent_id: str | None) -> dict:
|
||||
"""Read one Memory V2 file into a JSON-able ``{"entries": [...]}`` dict.
|
||||
|
||||
``agent_id is None`` -> the user's ``USER.md`` (profile, cross-agent);
|
||||
otherwise that agent's ``MEMORY.md`` (per-agent working notes).
|
||||
"""
|
||||
provider = BuiltinFileProvider()
|
||||
provider.initialize(user_id=user_id, agent_id=agent_id)
|
||||
target = "user" if agent_id is None else "memory"
|
||||
return {"entries": provider.read(target)}
|
||||
|
||||
|
||||
@router.get("/{user_id}/memory", response_model=AdminMemoryResponse)
|
||||
async def get_user_memory(user_id: str, request: Request) -> AdminMemoryResponse:
|
||||
"""Return every memory bucket for one user.
|
||||
|
||||
Memory V2 layout: the ``global`` bucket is ``USER.md`` (user profile);
|
||||
each per-agent bucket is that agent's ``MEMORY.md`` working notes.
|
||||
"""
|
||||
await _require_admin(request)
|
||||
|
||||
buckets: list[AdminMemoryBucket] = [
|
||||
AdminMemoryBucket(name="global", data=_read_memory_bucket(user_id, None))
|
||||
]
|
||||
|
||||
agents_root = get_paths().user_dir(user_id) / "agents"
|
||||
if agents_root.is_dir():
|
||||
for entry in sorted(agents_root.iterdir(), key=lambda p: p.name.lower()):
|
||||
if not entry.is_dir() or not (entry / "MEMORY.md").exists():
|
||||
continue
|
||||
if not AGENT_NAME_PATTERN.match(entry.name):
|
||||
continue
|
||||
try:
|
||||
data = _read_memory_bucket(user_id, entry.name)
|
||||
except Exception:
|
||||
logger.warning("Failed to read memory bucket %s for user", entry.name)
|
||||
continue
|
||||
buckets.append(AdminMemoryBucket(name=entry.name, data=data))
|
||||
|
||||
return AdminMemoryResponse(user_id=user_id, buckets=buckets)
|
||||
@@ -244,7 +244,7 @@ async def _get_editable_or_raise(
|
||||
async def list_agents(
|
||||
request: Request,
|
||||
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on agent name"),
|
||||
tag_id: str | None = Query(default=None, description="Keep only agents carrying this tag"),
|
||||
tag_ids: list[str] | None = Query(default=None, description="Keep agents carrying any of these tags (OR)"),
|
||||
) -> AgentsListResponse:
|
||||
_require_agents_api_enabled()
|
||||
|
||||
@@ -262,8 +262,9 @@ async def list_agents(
|
||||
if search:
|
||||
needle = search.strip().lower()
|
||||
responses = [r for r in responses if needle in r.name.lower()]
|
||||
if tag_id:
|
||||
responses = [r for r in responses if any(t.get("id") == tag_id for t in r.tags)]
|
||||
if tag_ids:
|
||||
wanted = set(tag_ids)
|
||||
responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)]
|
||||
return AgentsListResponse(agents=responses)
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
@@ -197,7 +197,7 @@ async def get_scheduler_thread(request: Request) -> dict:
|
||||
async def list_scheduled_tasks(
|
||||
request: Request,
|
||||
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on task name"),
|
||||
tag_id: str | None = Query(default=None, description="Keep only tasks carrying this tag"),
|
||||
tag_ids: list[str] | None = Query(default=None, description="Keep tasks carrying any of these tags (OR)"),
|
||||
) -> list[dict]:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
@@ -211,8 +211,9 @@ async def list_scheduled_tasks(
|
||||
if search:
|
||||
needle = search.strip().lower()
|
||||
tasks = [t for t in tasks if needle in str(t.get("name", "")).lower()]
|
||||
if tag_id:
|
||||
tasks = [t for t in tasks if any(tg.get("id") == tag_id for tg in t.get("tags", []))]
|
||||
if tag_ids:
|
||||
wanted = set(tag_ids)
|
||||
tasks = [t for t in tasks if any(tg.get("id") in wanted for tg in t.get("tags", []))]
|
||||
return tasks
|
||||
|
||||
|
||||
|
||||
@@ -236,7 +236,7 @@ async def _attach_and_filter_tags(
|
||||
tag_store: TagStore,
|
||||
responses: list[SkillResponse],
|
||||
search: str | None,
|
||||
tag_id: str | None,
|
||||
tag_ids: list[str] | None,
|
||||
) -> list[SkillResponse]:
|
||||
"""Bulk-attach tags to skill responses, then apply name/tag filters."""
|
||||
assignments = await tag_store.list_assignments("skill", [r.name for r in responses])
|
||||
@@ -245,8 +245,9 @@ async def _attach_and_filter_tags(
|
||||
if search:
|
||||
needle = search.strip().lower()
|
||||
responses = [r for r in responses if needle in r.name.lower()]
|
||||
if tag_id:
|
||||
responses = [r for r in responses if any(t.get("id") == tag_id for t in r.tags)]
|
||||
if tag_ids:
|
||||
wanted = set(tag_ids)
|
||||
responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)]
|
||||
return responses
|
||||
|
||||
|
||||
@@ -262,7 +263,7 @@ async def list_skills(
|
||||
skill_store: SkillStore = Depends(get_skill_store),
|
||||
tag_store: TagStore = Depends(get_tag_store),
|
||||
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on skill name"),
|
||||
tag_id: str | None = Query(default=None, description="Keep only skills carrying this tag"),
|
||||
tag_ids: list[str] | None = Query(default=None, description="Keep skills carrying any of these tags (OR)"),
|
||||
) -> SkillsListResponse:
|
||||
try:
|
||||
user_id = _current_user_id(request)
|
||||
@@ -281,7 +282,7 @@ async def list_skills(
|
||||
if record is None:
|
||||
continue
|
||||
responses.append(_skill_to_response(skill, record))
|
||||
responses = await _attach_and_filter_tags(tag_store, responses, search, tag_id)
|
||||
responses = await _attach_and_filter_tags(tag_store, responses, search, tag_ids)
|
||||
return SkillsListResponse(skills=responses)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load skills: {e}", exc_info=True)
|
||||
|
||||
@@ -294,20 +294,38 @@ async def get_public_share_snapshot(
|
||||
)
|
||||
|
||||
|
||||
# Content types that can execute scripts in the app origin — always served as
|
||||
# a download attachment so a shared file can never run as a top-level document.
|
||||
_PUBLIC_SHARE_ACTIVE_TYPES = {"text/html", "application/xhtml+xml", "image/svg+xml"}
|
||||
|
||||
|
||||
def _public_share_disposition(mime_type: str) -> str:
|
||||
"""Pick a Content-Disposition for a public-share file.
|
||||
|
||||
Active content (HTML/XHTML/SVG) is forced to ``attachment`` so it cannot
|
||||
execute as a top-level document; everything else (images, Markdown, text,
|
||||
PDF, …) is served ``inline`` so viewers can open it directly.
|
||||
"""
|
||||
return "attachment" if mime_type in _PUBLIC_SHARE_ACTIVE_TYPES else "inline"
|
||||
|
||||
|
||||
@public_router.get(
|
||||
"/{share_code}/files/{path:path}",
|
||||
summary="Public image attachment from a view-shared conversation",
|
||||
summary="Public file attachment from a view-shared conversation",
|
||||
)
|
||||
async def get_public_share_file(
|
||||
share_code: str,
|
||||
path: str,
|
||||
share_store: ThreadShareStore = Depends(get_thread_share_store),
|
||||
) -> FileResponse:
|
||||
"""Serve an image referenced by a view-shared conversation.
|
||||
"""Serve a file referenced by a view-shared conversation.
|
||||
|
||||
Restricted to images only — the public share page renders inline images,
|
||||
but the endpoint must not become a way to read arbitrary thread files.
|
||||
Non-image paths return 404 just like a missing file.
|
||||
A ``view`` share is a public read-only page, so the files its conversation
|
||||
references (images, Markdown, text, PDF, …) must be openable. Path
|
||||
resolution is confined to the owner's thread directory, so this cannot read
|
||||
arbitrary files. Active content (HTML/XHTML/SVG) is forced to download so it
|
||||
can never execute as a top-level document in the app origin; everything
|
||||
else is served inline.
|
||||
"""
|
||||
share = await share_store.get(share_code)
|
||||
if share is None or share.get("revoked") or share.get("mode") != "view":
|
||||
@@ -324,17 +342,10 @@ async def get_public_share_file(
|
||||
if not actual_path.exists() or not actual_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
mime_type, _ = mimetypes.guess_type(actual_path.name)
|
||||
if not mime_type or not mime_type.startswith("image/"):
|
||||
# Only images are public; everything else is treated as not found.
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
# SVG can carry scripts — serve it as a download so it can never execute
|
||||
# as a top-level document in the app origin. Raster images render inline.
|
||||
is_svg = mime_type == "image/svg+xml"
|
||||
mime_type = mimetypes.guess_type(actual_path.name)[0] or "application/octet-stream"
|
||||
return FileResponse(
|
||||
actual_path,
|
||||
media_type=mime_type,
|
||||
content_disposition_type="attachment" if is_svg else "inline",
|
||||
content_disposition_type=_public_share_disposition(mime_type),
|
||||
headers={"Cache-Control": "public, max-age=300"},
|
||||
)
|
||||
@@ -0,0 +1,380 @@
|
||||
"""Admin-only tool / skill call metrics: list + Excel export.
|
||||
|
||||
Companion to ``llm_metrics`` — where that audits every LLM provider call, this
|
||||
audits every *tool* call (built-in tools, skills, MCP tools, subagent calls),
|
||||
including the failures: retrieval errors, blocked/rate-limited IPs, timeouts,
|
||||
auth problems. Failures are bucketed into a coarse ``error_category`` so an
|
||||
admin can scan for systemic issues.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.deps import get_local_provider, get_optional_user_from_request, get_tool_metrics_store
|
||||
from deerflow.persistence.tool_metrics.base import ToolMetricsQuery, ToolMetricsStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/admin/tool-metrics", tags=["tool-metrics"])
|
||||
|
||||
_VALID_CATEGORIES = (
|
||||
"rate_limited",
|
||||
"ip_blocked",
|
||||
"auth_error",
|
||||
"timeout",
|
||||
"network_error",
|
||||
"not_found",
|
||||
"empty_result",
|
||||
"tool_error",
|
||||
)
|
||||
|
||||
def _kind_is_skill(kind: str | None) -> bool:
|
||||
"""``kind=skill`` keeps only rows that target a specific Agent Skill."""
|
||||
return kind == "skill"
|
||||
|
||||
|
||||
class ToolMetricResponse(BaseModel):
|
||||
id: str
|
||||
created_at: str | None = None
|
||||
user_id: str | None = None
|
||||
user_name: str | None = Field(
|
||||
default=None,
|
||||
description="Local part (before @) of the user's email, resolved from user_id",
|
||||
)
|
||||
thread_id: str | None = None
|
||||
run_id: str | None = None
|
||||
agent_name: str | None = None
|
||||
tool_name: str | None = None
|
||||
skill_name: str | None = Field(
|
||||
default=None,
|
||||
description="The Agent Skill this call targets (skill tools only)",
|
||||
)
|
||||
duration_ms: int = 0
|
||||
status: str = "success"
|
||||
error_category: str | None = None
|
||||
error_message: str | None = None
|
||||
|
||||
|
||||
class ToolMetricsListResponse(BaseModel):
|
||||
items: list[ToolMetricResponse]
|
||||
total: int = Field(..., description="Total rows matching the filter, ignoring pagination")
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
async def _require_admin(request: Request) -> None:
|
||||
user = await get_optional_user_from_request(request)
|
||||
if user is None:
|
||||
return
|
||||
if getattr(user, "system_role", None) != "admin":
|
||||
raise HTTPException(status_code=403, detail="Tool metrics admin view is admin-only")
|
||||
|
||||
|
||||
def _require_store(request: Request) -> ToolMetricsStore:
|
||||
store = get_tool_metrics_store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="Tool metrics store is not configured")
|
||||
return store
|
||||
|
||||
|
||||
def _parse_dt(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=f"Invalid datetime: {value!r} ({exc})") from exc
|
||||
|
||||
|
||||
def _email_local_part(email: str | None) -> str | None:
|
||||
if not email:
|
||||
return None
|
||||
at = email.find("@")
|
||||
return email[:at] if at > 0 else email
|
||||
|
||||
|
||||
async def _resolve_user_name_to_id(name: str | None) -> str | None:
|
||||
if not name:
|
||||
return None
|
||||
try:
|
||||
provider = get_local_provider()
|
||||
user = await provider.find_user_by_email_local_part(name)
|
||||
except Exception:
|
||||
return None
|
||||
return str(user.id) if user is not None else None
|
||||
|
||||
|
||||
async def _attach_usernames(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Resolve each row's ``user_id`` UUID to a friendly ``user_name``."""
|
||||
if not rows:
|
||||
return rows
|
||||
user_ids = {row["user_id"] for row in rows if isinstance(row, dict) and row.get("user_id")}
|
||||
if not user_ids:
|
||||
return rows
|
||||
try:
|
||||
provider = get_local_provider()
|
||||
except Exception:
|
||||
provider = None
|
||||
email_by_id: dict[str, str] = {}
|
||||
if provider is not None:
|
||||
for uid in user_ids:
|
||||
try:
|
||||
user = await provider.get_user(uid)
|
||||
except Exception:
|
||||
user = None
|
||||
if user is not None and user.email:
|
||||
email_by_id[uid] = user.email
|
||||
for row in rows:
|
||||
if isinstance(row, dict) and row.get("user_id"):
|
||||
email = email_by_id.get(row["user_id"])
|
||||
row["user_name"] = _email_local_part(email) or row["user_id"]
|
||||
return rows
|
||||
|
||||
|
||||
def _build_query(
|
||||
*,
|
||||
user_id: str | None,
|
||||
tool_name: str | None,
|
||||
skill_only: bool = False,
|
||||
skill_name: str | None = None,
|
||||
status: str | None,
|
||||
error_category: str | None,
|
||||
since: str | None,
|
||||
until: str | None,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> ToolMetricsQuery:
|
||||
return ToolMetricsQuery(
|
||||
user_id=user_id or None,
|
||||
tool_name=tool_name or None,
|
||||
skill_only=skill_only,
|
||||
skill_name=skill_name or None,
|
||||
status=status or None,
|
||||
error_category=error_category or None,
|
||||
since=_parse_dt(since),
|
||||
until=_parse_dt(until),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=ToolMetricsListResponse)
|
||||
async def list_tool_metrics(
|
||||
request: Request,
|
||||
user_id: str | None = Query(default=None),
|
||||
user_name: str | None = Query(default=None, description="Username (email local part) to filter by"),
|
||||
tool_name: str | None = Query(default=None),
|
||||
kind: str | None = Query(default=None, description="Coarse call kind: 'skill' keeps only Agent-Skill invocations"),
|
||||
skill_name: str | None = Query(default=None, description="Filter to one specific Agent Skill by name"),
|
||||
status: str | None = Query(default=None, pattern="^(success|error)$"),
|
||||
error_category: str | None = Query(default=None, description="Coarse failure bucket"),
|
||||
since: str | None = Query(default=None, description="ISO 8601 datetime, inclusive lower bound"),
|
||||
until: str | None = Query(default=None, description="ISO 8601 datetime, inclusive upper bound"),
|
||||
limit: int = Query(default=50, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> ToolMetricsListResponse:
|
||||
await _require_admin(request)
|
||||
store = _require_store(request)
|
||||
if error_category and error_category not in _VALID_CATEGORIES:
|
||||
raise HTTPException(status_code=422, detail=f"Unknown error_category: {error_category!r}")
|
||||
if not user_id and user_name:
|
||||
resolved = await _resolve_user_name_to_id(user_name)
|
||||
if resolved is None:
|
||||
return ToolMetricsListResponse(items=[], total=0, limit=limit, offset=offset)
|
||||
user_id = resolved
|
||||
query = _build_query(
|
||||
user_id=user_id,
|
||||
tool_name=tool_name,
|
||||
skill_only=_kind_is_skill(kind),
|
||||
skill_name=skill_name,
|
||||
status=status,
|
||||
error_category=error_category,
|
||||
since=since,
|
||||
until=until,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
rows = await store.list(query)
|
||||
total = await store.count(query)
|
||||
await _attach_usernames(rows)
|
||||
return ToolMetricsListResponse(
|
||||
items=[ToolMetricResponse(**row) for row in rows],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
class SkillStat(BaseModel):
|
||||
skill_name: str
|
||||
total: int
|
||||
success: int
|
||||
error: int
|
||||
|
||||
|
||||
class ToolMetricsSummaryResponse(BaseModel):
|
||||
total: int
|
||||
error_total: int
|
||||
by_category: dict[str, int] = Field(default_factory=dict)
|
||||
by_skill: list[SkillStat] = Field(
|
||||
default_factory=list,
|
||||
description="Per-skill invocation counts — answers 'was skill X called, and did it succeed'",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/summary", response_model=ToolMetricsSummaryResponse)
|
||||
async def tool_metrics_summary(
|
||||
request: Request,
|
||||
user_id: str | None = Query(default=None),
|
||||
user_name: str | None = Query(default=None),
|
||||
tool_name: str | None = Query(default=None),
|
||||
kind: str | None = Query(default=None),
|
||||
skill_name: str | None = Query(default=None),
|
||||
since: str | None = Query(default=None),
|
||||
until: str | None = Query(default=None),
|
||||
) -> ToolMetricsSummaryResponse:
|
||||
"""Aggregate counts: total, errors, per-category breakdown, and per-skill stats."""
|
||||
await _require_admin(request)
|
||||
store = _require_store(request)
|
||||
if not user_id and user_name:
|
||||
resolved = await _resolve_user_name_to_id(user_name)
|
||||
if resolved is None:
|
||||
return ToolMetricsSummaryResponse(total=0, error_total=0, by_category={}, by_skill=[])
|
||||
user_id = resolved
|
||||
skill_only = _kind_is_skill(kind)
|
||||
|
||||
def _q(*, status: str | None = None, error_category: str | None = None) -> ToolMetricsQuery:
|
||||
return _build_query(
|
||||
user_id=user_id,
|
||||
tool_name=tool_name,
|
||||
skill_only=skill_only,
|
||||
skill_name=skill_name,
|
||||
status=status,
|
||||
error_category=error_category,
|
||||
since=since,
|
||||
until=until,
|
||||
limit=1,
|
||||
offset=0,
|
||||
)
|
||||
|
||||
total = await store.count(_q())
|
||||
error_total = await store.count(_q(status="error"))
|
||||
by_category: dict[str, int] = {}
|
||||
for category in _VALID_CATEGORIES:
|
||||
count = await store.count(_q(status="error", error_category=category))
|
||||
if count:
|
||||
by_category[category] = count
|
||||
by_skill = [SkillStat(**row) for row in await store.skill_breakdown(_q())]
|
||||
return ToolMetricsSummaryResponse(
|
||||
total=total, error_total=error_total, by_category=by_category, by_skill=by_skill
|
||||
)
|
||||
|
||||
|
||||
def _format_cell(value: Any) -> Any:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, datetime):
|
||||
return value.replace(tzinfo=None) if value.tzinfo else value
|
||||
return value
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
async def export_tool_metrics(
|
||||
request: Request,
|
||||
user_id: str | None = Query(default=None),
|
||||
user_name: str | None = Query(default=None),
|
||||
tool_name: str | None = Query(default=None),
|
||||
kind: str | None = Query(default=None),
|
||||
skill_name: str | None = Query(default=None),
|
||||
status: str | None = Query(default=None, pattern="^(success|error)$"),
|
||||
error_category: str | None = Query(default=None),
|
||||
since: str | None = Query(default=None),
|
||||
until: str | None = Query(default=None),
|
||||
):
|
||||
"""Stream the full filtered metric set as an ``.xlsx`` file."""
|
||||
await _require_admin(request)
|
||||
store = _require_store(request)
|
||||
if not user_id and user_name:
|
||||
resolved = await _resolve_user_name_to_id(user_name)
|
||||
user_id = resolved if resolved is not None else "__no_such_user__"
|
||||
|
||||
try:
|
||||
from openpyxl import Workbook
|
||||
except ImportError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"openpyxl is not installed: {exc}") from exc
|
||||
|
||||
query = _build_query(
|
||||
user_id=user_id,
|
||||
tool_name=tool_name,
|
||||
skill_only=_kind_is_skill(kind),
|
||||
skill_name=skill_name,
|
||||
status=status,
|
||||
error_category=error_category,
|
||||
since=since,
|
||||
until=until,
|
||||
limit=1,
|
||||
offset=0,
|
||||
)
|
||||
|
||||
workbook = Workbook(write_only=True)
|
||||
sheet = workbook.create_sheet("tool_call_metrics")
|
||||
columns = [
|
||||
"id",
|
||||
"created_at",
|
||||
"status",
|
||||
"error_category",
|
||||
"user_name",
|
||||
"user_id",
|
||||
"thread_id",
|
||||
"run_id",
|
||||
"agent_name",
|
||||
"tool_name",
|
||||
"skill_name",
|
||||
"duration_ms",
|
||||
"error_message",
|
||||
]
|
||||
sheet.append(columns)
|
||||
|
||||
buffered: list[dict[str, Any]] = []
|
||||
FLUSH_AT = 200
|
||||
|
||||
async def flush() -> int:
|
||||
if not buffered:
|
||||
return 0
|
||||
await _attach_usernames(buffered)
|
||||
for row in buffered:
|
||||
sheet.append([_format_cell(row.get(col)) for col in columns])
|
||||
n = len(buffered)
|
||||
buffered.clear()
|
||||
return n
|
||||
|
||||
count = 0
|
||||
async for row in store.iter_all(query):
|
||||
buffered.append(row)
|
||||
if len(buffered) >= FLUSH_AT:
|
||||
count += await flush()
|
||||
count += await flush()
|
||||
|
||||
buffer = BytesIO()
|
||||
workbook.save(buffer)
|
||||
buffer.seek(0)
|
||||
logger.info("Exported %d tool_call_metrics rows", count)
|
||||
|
||||
timestamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
|
||||
filename = f"tool_call_metrics-{timestamp}.xlsx"
|
||||
headers = {
|
||||
"Content-Disposition": f"attachment; filename=\"{filename}\"; filename*=UTF-8''{quote(filename)}",
|
||||
}
|
||||
return StreamingResponse(
|
||||
buffer,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers=headers,
|
||||
)
|
||||
+8
@@ -131,6 +131,14 @@ def _build_runtime_middlewares(
|
||||
|
||||
middlewares.append(SandboxAuditMiddleware())
|
||||
middlewares.append(ToolErrorHandlingMiddleware())
|
||||
|
||||
# Tool / skill call metrics — appended last so it sits *deepest* in the
|
||||
# wrap_tool_call chain: its handler is the raw tool, so it observes the
|
||||
# unconverted exception (before ToolErrorHandlingMiddleware turns it into
|
||||
# an error ToolMessage) and the raw return value.
|
||||
from deerflow.agents.middlewares.tool_metrics_middleware import ToolMetricsMiddleware
|
||||
|
||||
middlewares.append(ToolMetricsMiddleware())
|
||||
return middlewares
|
||||
|
||||
|
||||
|
||||
+319
@@ -0,0 +1,319 @@
|
||||
"""Middleware that records per-tool-call metrics for the admin audit page.
|
||||
|
||||
Every tool / skill invocation ends with one row in ``tool_call_metrics``:
|
||||
- Successes record the wall-clock duration.
|
||||
- Failures record a coarse ``error_category`` plus a one-line message.
|
||||
|
||||
Two failure shapes are captured:
|
||||
|
||||
1. **Raised exceptions** — the tool blew up. We record, then re-raise so the
|
||||
outer :class:`ToolErrorHandlingMiddleware` still converts it into an error
|
||||
``ToolMessage`` for the model.
|
||||
2. **Self-handled errors** — search/retrieval tools (Serper, Tavily, Jina, …)
|
||||
catch their own HTTP 429/403/timeout and *return* an error payload instead
|
||||
of raising. Those never reach an ``except`` block, so we also inspect the
|
||||
returned ``ToolMessage``: an ``status == "error"`` message, or content that
|
||||
looks like an error string, is bucketed too.
|
||||
|
||||
This middleware is appended **after** ``ToolErrorHandlingMiddleware`` so it
|
||||
sits deepest in the ``wrap_tool_call`` chain — its handler is the raw tool, so
|
||||
it observes the unconverted exception and the raw return value.
|
||||
|
||||
Writes go through a best-effort store: a DB outage logs a warning and the
|
||||
agent runtime keeps running.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, override
|
||||
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain_core.messages import ToolMessage
|
||||
from langgraph.errors import GraphBubbleUp
|
||||
from langgraph.prebuilt.tool_node import ToolCallRequest
|
||||
from langgraph.types import Command
|
||||
|
||||
from deerflow.persistence.engine import get_session_factory
|
||||
from deerflow.persistence.tool_metrics import ToolMetricRecord, make_tool_metrics_store
|
||||
from deerflow.persistence.tool_metrics.base import ToolMetricsStore
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def _trim(text: str, limit: int = 500) -> str:
|
||||
text = (text or "").strip()
|
||||
return text if len(text) <= limit else text[: limit - 3] + "..."
|
||||
|
||||
|
||||
def _classify_error(text: str) -> str:
|
||||
"""Bucket an error string into a coarse, admin-friendly category."""
|
||||
t = (text or "").lower()
|
||||
if any(k in t for k in ("429", "rate limit", "ratelimit", "too many request", "quota")):
|
||||
return "rate_limited"
|
||||
if any(k in t for k in ("403", "forbidden", "ip ban", "ip block", "blocked", "access denied", "banned")):
|
||||
return "ip_blocked"
|
||||
if any(k in t for k in ("401", "unauthorized", "api key", "apikey", "invalid key", "authentication", "credential")):
|
||||
return "auth_error"
|
||||
if any(k in t for k in ("timeout", "timed out", "deadline")):
|
||||
return "timeout"
|
||||
if any(k in t for k in ("connecterror", "connection error", "connection refused", "network", "dns", "unreachable", "ssl", "proxy")):
|
||||
return "network_error"
|
||||
if any(k in t for k in ("404", "not found")):
|
||||
return "not_found"
|
||||
if any(k in t for k in ("no results", "no result", "0 results", "empty result")):
|
||||
return "empty_result"
|
||||
return "tool_error"
|
||||
|
||||
|
||||
def _content_text(content: Any) -> str:
|
||||
"""Flatten a tool result's content (string or block list) into text."""
|
||||
if content is None:
|
||||
return ""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts: list[str] = []
|
||||
for block in content:
|
||||
if isinstance(block, str):
|
||||
parts.append(block)
|
||||
elif isinstance(block, dict):
|
||||
text = block.get("text")
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
return " ".join(parts)
|
||||
return str(content)
|
||||
|
||||
|
||||
def _looks_like_error(text: str) -> bool:
|
||||
"""Heuristically detect a self-handled error payload returned as content.
|
||||
|
||||
Deliberately conservative — only an explicit ``Error:`` prefix or a JSON
|
||||
object whose first key is ``error`` counts, so a normal result that merely
|
||||
mentions the word "error" is not misclassified.
|
||||
"""
|
||||
head = (text or "").lstrip()[:64].lower()
|
||||
return head.startswith("error:") or head.startswith('{"error"') or head.startswith("{'error'")
|
||||
|
||||
|
||||
# Skill tools whose ``name`` argument names one specific Agent Skill directly.
|
||||
_SKILL_NAME_ARG_TOOLS = {"skill_view", "skill_manage", "skill_archive"}
|
||||
|
||||
# Matches a skill directory embedded in any tool-argument path. Skills live
|
||||
# under ``skills/{public,custom}/{name}/`` — both inside the sandbox
|
||||
# (``/mnt/skills/...``) and on the host. The lead agent's system prompt tells
|
||||
# it to load a skill by calling ``read_file`` on that SKILL.md path (it does
|
||||
# NOT call a dedicated "use skill" tool), so the skill name has to be recovered
|
||||
# from the path argument of whatever tool touched it (read_file / bash / ls /
|
||||
# str_replace / write_file ...).
|
||||
_SKILL_PATH_RE = re.compile(r"[\\/]skills[\\/](?:public|custom)[\\/]([^\\/]+)(?:[\\/]|$)")
|
||||
|
||||
|
||||
def _skill_name_from_call(tool_name: str | None, tool_call: Any) -> str | None:
|
||||
"""Recover the Agent Skill a tool call targets, or ``None``.
|
||||
|
||||
Two detection paths:
|
||||
|
||||
1. ``skill_view`` / ``skill_manage`` / ``skill_archive`` take an explicit
|
||||
``name`` argument.
|
||||
2. Every other tool references a skill by *path*. The canonical skill
|
||||
invocation is ``read_file('/mnt/skills/<cat>/<name>/SKILL.md')``; helper
|
||||
files and ``bash`` script runs hit the same directory. So we scan the
|
||||
string arguments for the ``skills/{public,custom}/{name}/`` shape.
|
||||
"""
|
||||
if not isinstance(tool_call, dict):
|
||||
return None
|
||||
args = tool_call.get("args")
|
||||
if not isinstance(args, dict):
|
||||
return None
|
||||
|
||||
# Case 1 — explicit ``name`` argument on the skill tools.
|
||||
if tool_name in _SKILL_NAME_ARG_TOOLS:
|
||||
raw = args.get("name")
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
return raw.strip()
|
||||
|
||||
# Case 2 — a skill path inside any string argument.
|
||||
for value in args.values():
|
||||
if isinstance(value, str):
|
||||
match = _SKILL_PATH_RE.search(value)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def _evaluate_skill_view(text: str) -> tuple[str, str | None, str | None]:
|
||||
"""Classify a ``skill_view`` return.
|
||||
|
||||
``skill_view`` never raises — on failure it returns a plain string
|
||||
(``Skill 'x' not found.`` / ``Error reading skill 'x': ...``); on success
|
||||
it returns the SKILL.md content. So success/failure must be read off the
|
||||
returned text, not an exception or ``status`` flag.
|
||||
"""
|
||||
head = (text or "").lstrip()
|
||||
low = head[:200].lower()
|
||||
if head.startswith("Skill '") and "not found" in low:
|
||||
return "error", "not_found", _trim(text)
|
||||
if low.startswith("error reading skill"):
|
||||
return "error", _classify_error(text), _trim(text)
|
||||
return "success", None, None
|
||||
|
||||
|
||||
def _evaluate_result(result: Any, tool_name: str | None) -> tuple[str, str | None, str | None]:
|
||||
"""Return ``(status, error_category, error_message)`` for a tool return."""
|
||||
if isinstance(result, ToolMessage):
|
||||
text = _content_text(result.content)
|
||||
if getattr(result, "status", None) == "error":
|
||||
return "error", _classify_error(text), _trim(text)
|
||||
# skill_view swallows its own failures into a plain string return.
|
||||
if tool_name == "skill_view":
|
||||
return _evaluate_skill_view(text)
|
||||
if _looks_like_error(text):
|
||||
return "error", _classify_error(text), _trim(text)
|
||||
return "success", None, None
|
||||
# Command (control flow) or any other shape — treat as a successful call.
|
||||
return "success", None, None
|
||||
|
||||
|
||||
class ToolMetricsMiddleware(AgentMiddleware[AgentState]):
|
||||
"""Wrap each tool call to time it and persist a metric row."""
|
||||
|
||||
def __init__(self, *, store: ToolMetricsStore | None = None, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._store_override = store
|
||||
|
||||
# -- store / context ----------------------------------------------------
|
||||
|
||||
def _store(self) -> ToolMetricsStore | None:
|
||||
if self._store_override is not None:
|
||||
return self._store_override
|
||||
try:
|
||||
sf = get_session_factory()
|
||||
except Exception:
|
||||
return None
|
||||
return make_tool_metrics_store(sf)
|
||||
|
||||
@staticmethod
|
||||
def _runtime_lookup(request: ToolCallRequest, keys: tuple[str, ...]) -> str | None:
|
||||
runtime = getattr(request, "runtime", None)
|
||||
if runtime is None:
|
||||
return None
|
||||
context = getattr(runtime, "context", None)
|
||||
if isinstance(context, dict):
|
||||
for key in keys:
|
||||
value = context.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
config = getattr(runtime, "config", None)
|
||||
if isinstance(config, dict):
|
||||
configurable = config.get("configurable") if isinstance(config.get("configurable"), dict) else {}
|
||||
metadata = config.get("metadata") if isinstance(config.get("metadata"), dict) else {}
|
||||
for bucket in (configurable, metadata):
|
||||
for key in keys:
|
||||
value = bucket.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return None
|
||||
|
||||
def _build_base_record(self, request: ToolCallRequest) -> ToolMetricRecord:
|
||||
tool_call = getattr(request, "tool_call", None) or {}
|
||||
tool_name = tool_call.get("name") if isinstance(tool_call, dict) else None
|
||||
tool_name = str(tool_name) if tool_name else None
|
||||
return ToolMetricRecord(
|
||||
id=uuid.uuid4().hex,
|
||||
created_at=_now(),
|
||||
user_id=get_effective_user_id(),
|
||||
thread_id=self._runtime_lookup(request, ("thread_id",)),
|
||||
run_id=self._runtime_lookup(request, ("run_id", "langgraph_run_id")),
|
||||
agent_name=self._runtime_lookup(request, ("agent_name", "agent_id")),
|
||||
skill_name=_skill_name_from_call(tool_name, tool_call),
|
||||
tool_name=tool_name,
|
||||
)
|
||||
|
||||
# -- finalize -----------------------------------------------------------
|
||||
|
||||
def _finalize_result(self, record: ToolMetricRecord, result: Any, started: float) -> None:
|
||||
record.duration_ms = int(max(0.0, time.monotonic() - started) * 1000)
|
||||
record.status, record.error_category, record.error_message = _evaluate_result(
|
||||
result, record.tool_name
|
||||
)
|
||||
|
||||
def _finalize_error(self, record: ToolMetricRecord, exc: BaseException, started: float) -> None:
|
||||
record.duration_ms = int(max(0.0, time.monotonic() - started) * 1000)
|
||||
record.status = "error"
|
||||
name = exc.__class__.__name__
|
||||
combined = f"{name}: {(str(exc) or name).strip()}"
|
||||
record.error_category = _classify_error(combined)
|
||||
record.error_message = _trim(combined)
|
||||
|
||||
def _flush(self, record: ToolMetricRecord) -> None:
|
||||
store = self._store()
|
||||
if store is None:
|
||||
return
|
||||
try:
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
if loop is not None and loop.is_running():
|
||||
# Fire-and-forget — never let metric IO block the tool path.
|
||||
loop.create_task(store.record(record))
|
||||
else:
|
||||
asyncio.run(store.record(record))
|
||||
except Exception:
|
||||
logger.warning("ToolMetricsMiddleware: failed to flush metric %s", record.id, exc_info=True)
|
||||
|
||||
# -- wrap ---------------------------------------------------------------
|
||||
|
||||
@override
|
||||
def wrap_tool_call(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
handler: Callable[[ToolCallRequest], ToolMessage | Command],
|
||||
) -> ToolMessage | Command:
|
||||
record = self._build_base_record(request)
|
||||
started = time.monotonic()
|
||||
try:
|
||||
result = handler(request)
|
||||
except GraphBubbleUp:
|
||||
# LangGraph control flow — not a tool failure.
|
||||
raise
|
||||
except Exception as exc:
|
||||
self._finalize_error(record, exc, started)
|
||||
self._flush(record)
|
||||
raise
|
||||
self._finalize_result(record, result, started)
|
||||
self._flush(record)
|
||||
return result
|
||||
|
||||
@override
|
||||
async def awrap_tool_call(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]],
|
||||
) -> ToolMessage | Command:
|
||||
record = self._build_base_record(request)
|
||||
started = time.monotonic()
|
||||
try:
|
||||
result = await handler(request)
|
||||
except GraphBubbleUp:
|
||||
raise
|
||||
except Exception as exc:
|
||||
self._finalize_error(record, exc, started)
|
||||
self._flush(record)
|
||||
raise
|
||||
self._finalize_result(record, result, started)
|
||||
self._flush(record)
|
||||
return result
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
"""Create tool_call_metrics table.
|
||||
|
||||
Revision ID: 20260518_07
|
||||
Revises: 20260518_06
|
||||
Create Date: 2026-05-18
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "20260518_07"
|
||||
down_revision = "20260518_06"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_table(table_name: str) -> bool:
|
||||
inspector = inspect(op.get_bind())
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if _has_table("tool_call_metrics"):
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
"tool_call_metrics",
|
||||
sa.Column("id", sa.String(64), primary_key=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("user_id", sa.String(64), nullable=True),
|
||||
sa.Column("thread_id", sa.String(64), nullable=True),
|
||||
sa.Column("run_id", sa.String(64), nullable=True),
|
||||
sa.Column("agent_name", sa.String(128), nullable=True),
|
||||
sa.Column("tool_name", sa.String(128), nullable=True),
|
||||
sa.Column("skill_name", sa.String(128), nullable=True),
|
||||
sa.Column("duration_ms", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("status", sa.String(16), nullable=False, server_default="success"),
|
||||
sa.Column("error_category", sa.String(32), nullable=True),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_tool_call_metrics_created_at", "tool_call_metrics", ["created_at"])
|
||||
op.create_index("ix_tool_call_metrics_user_id", "tool_call_metrics", ["user_id"])
|
||||
op.create_index("ix_tool_call_metrics_thread_id", "tool_call_metrics", ["thread_id"])
|
||||
op.create_index("ix_tool_call_metrics_run_id", "tool_call_metrics", ["run_id"])
|
||||
op.create_index("ix_tool_call_metrics_agent_name", "tool_call_metrics", ["agent_name"])
|
||||
op.create_index("ix_tool_call_metrics_tool_name", "tool_call_metrics", ["tool_name"])
|
||||
op.create_index("ix_tool_call_metrics_skill_name", "tool_call_metrics", ["skill_name"])
|
||||
op.create_index("ix_tool_call_metrics_status", "tool_call_metrics", ["status"])
|
||||
op.create_index("ix_tool_call_metrics_error_category", "tool_call_metrics", ["error_category"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _has_table("tool_call_metrics"):
|
||||
op.drop_table("tool_call_metrics")
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
"""Add skill_name column to tool_call_metrics.
|
||||
|
||||
``tool_call_metrics`` is created by ``create_all`` at startup. On deployments
|
||||
where the table was materialised *before* the ``skill_name`` column existed,
|
||||
neither ``create_all`` (it never ALTERs) nor revision ``20260518_07`` (it skips
|
||||
when the table already exists) adds the column — leaving MySQL/Postgres with a
|
||||
schema that the ORM model expects a column it does not have. This revision
|
||||
patches the existing table.
|
||||
|
||||
Revision ID: 20260519_01
|
||||
Revises: 20260518_07
|
||||
Create Date: 2026-05-19
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "20260519_01"
|
||||
down_revision = "20260518_07"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_table(table_name: str) -> bool:
|
||||
inspector = inspect(op.get_bind())
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _has_column(table_name: str, column_name: str) -> bool:
|
||||
if not _has_table(table_name):
|
||||
return False
|
||||
inspector = inspect(op.get_bind())
|
||||
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def _has_index(table_name: str, index_name: str) -> bool:
|
||||
if not _has_table(table_name):
|
||||
return False
|
||||
inspector = inspect(op.get_bind())
|
||||
return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Brand-new DBs: the table is created with the column already present
|
||||
# (by create_all / revision 20260518_07) — nothing to patch.
|
||||
if not _has_table("tool_call_metrics"):
|
||||
return
|
||||
if not _has_column("tool_call_metrics", "skill_name"):
|
||||
op.add_column("tool_call_metrics", sa.Column("skill_name", sa.String(128), nullable=True))
|
||||
if not _has_index("tool_call_metrics", "ix_tool_call_metrics_skill_name"):
|
||||
op.create_index("ix_tool_call_metrics_skill_name", "tool_call_metrics", ["skill_name"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _has_index("tool_call_metrics", "ix_tool_call_metrics_skill_name"):
|
||||
op.drop_index("ix_tool_call_metrics_skill_name", table_name="tool_call_metrics")
|
||||
if _has_column("tool_call_metrics", "skill_name"):
|
||||
op.drop_column("tool_call_metrics", "skill_name")
|
||||
+2
@@ -33,6 +33,7 @@ from deerflow.persistence.skills.model import SkillRow
|
||||
from deerflow.persistence.tags.model import TagAssignmentRow, TagRow
|
||||
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
||||
from deerflow.persistence.thread_shares.model import ThreadShareRow
|
||||
from deerflow.persistence.tool_metrics.model import ToolCallMetricRow
|
||||
from deerflow.persistence.user.model import UserRow
|
||||
|
||||
__all__ = [
|
||||
@@ -53,5 +54,6 @@ __all__ = [
|
||||
"TagRow",
|
||||
"ThreadMetaRow",
|
||||
"ThreadShareRow",
|
||||
"ToolCallMetricRow",
|
||||
"UserRow",
|
||||
]
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
"""Persistence layer for per-tool-call (skill-call) metrics.
|
||||
|
||||
Every tool invocation — built-in tools, skills, MCP tools, subagent calls —
|
||||
writes one row recording duration and outcome. Failures are bucketed into a
|
||||
coarse ``error_category`` (rate_limited / ip_blocked / auth_error / timeout /
|
||||
network_error / not_found / empty_result / tool_error) so administrators can
|
||||
spot retrieval outages, blocked IPs, and quota problems at a glance.
|
||||
|
||||
**Skill-call merging**: a skill is often touched by several tool calls inside
|
||||
one run (read ``SKILL.md``, run a script, retry after a failure). Rather than
|
||||
one row per call, ``SqlToolMetricsStore.record`` upserts a single row per
|
||||
``(run_id, skill_name)``: any success in the run wins (a skill that failed then
|
||||
finally succeeded is recorded as one success), and a skill that only ever
|
||||
failed yields one ``error`` row whose ``error_message`` accumulates every
|
||||
attempt's message. Non-skill tool calls keep one row each.
|
||||
"""
|
||||
|
||||
from deerflow.persistence.tool_metrics.base import ToolMetricRecord, ToolMetricsStore
|
||||
from deerflow.persistence.tool_metrics.model import ToolCallMetricRow
|
||||
from deerflow.persistence.tool_metrics.sql import SqlToolMetricsStore
|
||||
|
||||
__all__ = [
|
||||
"ToolCallMetricRow",
|
||||
"ToolMetricRecord",
|
||||
"ToolMetricsStore",
|
||||
"SqlToolMetricsStore",
|
||||
"make_tool_metrics_store",
|
||||
]
|
||||
|
||||
|
||||
def make_tool_metrics_store(session_factory) -> ToolMetricsStore | None:
|
||||
"""Return a SQL-backed metrics store, or ``None`` when no DB is configured."""
|
||||
if session_factory is None:
|
||||
return None
|
||||
return SqlToolMetricsStore(session_factory)
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
"""Abstract repository contract for tool / skill call metrics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolMetricRecord:
|
||||
"""In-memory shape of a tool-call metric row before persistence."""
|
||||
|
||||
id: str
|
||||
created_at: datetime
|
||||
user_id: str | None = None
|
||||
thread_id: str | None = None
|
||||
run_id: str | None = None
|
||||
agent_name: str | None = None
|
||||
tool_name: str | None = None
|
||||
# The specific Agent Skill this call targets, when the tool is a skill tool
|
||||
# (``skill_view`` / ``skill_manage`` / ``skill_archive``) — taken from the
|
||||
# call's ``name`` argument. Null for every non-skill tool call. This is
|
||||
# what lets the admin see "skill X was invoked, and succeeded/failed".
|
||||
skill_name: str | None = None
|
||||
duration_ms: int = 0
|
||||
status: str = "success"
|
||||
# Coarse failure bucket: rate_limited / ip_blocked / auth_error / timeout /
|
||||
# network_error / not_found / empty_result / tool_error. Null on success.
|
||||
error_category: str | None = None
|
||||
error_message: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolMetricsQuery:
|
||||
"""Filter set for the admin list / export endpoints."""
|
||||
|
||||
user_id: str | None = None
|
||||
tool_name: str | None = None
|
||||
# When True, keep only rows that target a specific Agent Skill
|
||||
# (``skill_name IS NOT NULL``) — the ``kind=skill`` filter.
|
||||
skill_only: bool = False
|
||||
# Filter to one specific skill by name.
|
||||
skill_name: str | None = None
|
||||
status: str | None = None
|
||||
error_category: str | None = None
|
||||
since: datetime | None = None
|
||||
until: datetime | None = None
|
||||
limit: int = 100
|
||||
offset: int = 0
|
||||
|
||||
|
||||
class ToolMetricsStore(ABC):
|
||||
"""Persistence contract for the tool-metrics middleware + admin API."""
|
||||
|
||||
@abstractmethod
|
||||
async def record(self, metric: ToolMetricRecord) -> None:
|
||||
"""Persist one metric row. Must never raise on best-effort writes."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def list(self, query: ToolMetricsQuery) -> list[dict[str, Any]]:
|
||||
"""Return the matching rows (newest first), paginated by ``query``."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def count(self, query: ToolMetricsQuery) -> int:
|
||||
"""Return the unpaginated row count for ``query``."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def iter_all(self, query: ToolMetricsQuery):
|
||||
"""Yield rows matching ``query`` without pagination — used by export."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def skill_breakdown(self, query: ToolMetricsQuery) -> list[dict[str, Any]]:
|
||||
"""Per-skill aggregate: ``[{skill_name, total, success, error}, ...]``.
|
||||
|
||||
Only rows with a non-null ``skill_name`` are counted, so this answers
|
||||
"which skills were invoked, and how often did each succeed/fail".
|
||||
"""
|
||||
raise NotImplementedError
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
"""ORM model for per-tool-call (skill-call) metrics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.types import BeijingDateTime
|
||||
|
||||
|
||||
class ToolCallMetricRow(Base):
|
||||
"""One row per completed (or failed) tool / skill invocation.
|
||||
|
||||
Unlike :class:`LlmCallMetricRow` this also captures *self-handled*
|
||||
failures — search tools that swallow an HTTP 429/403 and return an error
|
||||
payload instead of raising — so an admin can see "retrieval failed" /
|
||||
"IP blocked" events that never surfaced as exceptions.
|
||||
"""
|
||||
|
||||
__tablename__ = "tool_call_metrics"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC), index=True)
|
||||
|
||||
user_id: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
thread_id: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
run_id: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
agent_name: Mapped[str | None] = mapped_column(String(128), index=True)
|
||||
tool_name: Mapped[str | None] = mapped_column(String(128), index=True)
|
||||
# The specific Agent Skill targeted by a skill tool call (from the call's
|
||||
# ``name`` argument); null for non-skill tool calls.
|
||||
skill_name: Mapped[str | None] = mapped_column(String(128), index=True)
|
||||
|
||||
duration_ms: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
# "success" or "error".
|
||||
status: Mapped[str] = mapped_column(String(16), default="success", index=True)
|
||||
# Coarse failure bucket; null on success.
|
||||
error_category: Mapped[str | None] = mapped_column(String(32), index=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"created_at": self.created_at.isoformat() if isinstance(self.created_at, datetime) else self.created_at,
|
||||
"user_id": self.user_id,
|
||||
"thread_id": self.thread_id,
|
||||
"run_id": self.run_id,
|
||||
"agent_name": self.agent_name,
|
||||
"tool_name": self.tool_name,
|
||||
"skill_name": self.skill_name,
|
||||
"duration_ms": self.duration_ms,
|
||||
"status": self.status,
|
||||
"error_category": self.error_category,
|
||||
"error_message": self.error_message,
|
||||
}
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
"""SQLAlchemy-backed tool metrics repository."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.tool_metrics.base import ToolMetricRecord, ToolMetricsQuery, ToolMetricsStore
|
||||
from deerflow.persistence.tool_metrics.model import ToolCallMetricRow
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Separator joining every failed attempt's message inside one merged skill row.
|
||||
_ERROR_SEP = "\n--- \n"
|
||||
# Hard cap on the accumulated ``error_message`` of a merged row.
|
||||
_MERGED_ERROR_LIMIT = 2000
|
||||
|
||||
# A single skill invocation often spans several tool calls (read SKILL.md, run
|
||||
# a script, ...), and the model may retry a skill within the same run. Those
|
||||
# fire-and-forget writes can interleave on the event loop, so the read → merge →
|
||||
# write of a skill row is guarded by a lock. ``asyncio.Lock`` binds to the loop
|
||||
# it is used on; the lock is therefore (re)created whenever the running loop
|
||||
# changes (the Gateway keeps one persistent loop, while the ``asyncio.run``
|
||||
# fallback path spins up a throwaway loop per call — already serialized).
|
||||
_merge_lock: asyncio.Lock | None = None
|
||||
_merge_lock_loop: object = None
|
||||
|
||||
|
||||
def _skill_merge_lock() -> asyncio.Lock:
|
||||
global _merge_lock, _merge_lock_loop
|
||||
loop = asyncio.get_running_loop()
|
||||
if _merge_lock is None or _merge_lock_loop is not loop:
|
||||
_merge_lock = asyncio.Lock()
|
||||
_merge_lock_loop = loop
|
||||
return _merge_lock
|
||||
|
||||
|
||||
def _normalize_created_at(value: datetime) -> datetime:
|
||||
return value if value.tzinfo else value.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
def _merge_error_messages(existing: str | None, new: str | None) -> str | None:
|
||||
"""Append a failed attempt's message to the accumulated error text.
|
||||
|
||||
All failures are kept ("都统计下来"), separated and capped at
|
||||
:data:`_MERGED_ERROR_LIMIT` characters.
|
||||
"""
|
||||
new = (new or "").strip()
|
||||
if not new:
|
||||
return existing
|
||||
existing = (existing or "").strip()
|
||||
if not existing:
|
||||
return new
|
||||
if new in existing:
|
||||
# Identical retry failure — no point storing the same line twice.
|
||||
return existing
|
||||
combined = existing + _ERROR_SEP + new
|
||||
if len(combined) > _MERGED_ERROR_LIMIT:
|
||||
combined = combined[: _MERGED_ERROR_LIMIT - 3] + "..."
|
||||
return combined
|
||||
|
||||
|
||||
def _merge_skill_row(existing: ToolCallMetricRow, metric: ToolMetricRecord) -> None:
|
||||
"""Fold another same-run, same-skill call into an existing metric row.
|
||||
|
||||
Outcome rules:
|
||||
- any success in the run -> the row becomes a single ``success`` and earlier
|
||||
failures are dropped ("先报错、最后成功 -> 记一次成功");
|
||||
- all calls failed -> a single ``error`` row whose ``error_message``
|
||||
accumulates every attempt's message ("一直报错 -> 记一次,报错信息都统计下来").
|
||||
"""
|
||||
existing.duration_ms = (existing.duration_ms or 0) + (metric.duration_ms or 0)
|
||||
if metric.status == "success":
|
||||
if existing.status != "success":
|
||||
existing.status = "success"
|
||||
existing.error_category = None
|
||||
existing.error_message = None
|
||||
return
|
||||
# metric.status == "error"
|
||||
if existing.status == "success":
|
||||
# The skill already succeeded once this run — keep that outcome.
|
||||
return
|
||||
existing.status = "error"
|
||||
if metric.error_category:
|
||||
existing.error_category = metric.error_category
|
||||
existing.error_message = _merge_error_messages(existing.error_message, metric.error_message)
|
||||
|
||||
|
||||
def _row_from_metric(metric: ToolMetricRecord) -> ToolCallMetricRow:
|
||||
return ToolCallMetricRow(
|
||||
id=metric.id,
|
||||
created_at=_normalize_created_at(metric.created_at),
|
||||
user_id=metric.user_id,
|
||||
thread_id=metric.thread_id,
|
||||
run_id=metric.run_id,
|
||||
agent_name=metric.agent_name,
|
||||
tool_name=metric.tool_name,
|
||||
skill_name=metric.skill_name,
|
||||
duration_ms=metric.duration_ms,
|
||||
status=metric.status,
|
||||
error_category=metric.error_category,
|
||||
error_message=metric.error_message,
|
||||
)
|
||||
|
||||
|
||||
class SqlToolMetricsStore(ToolMetricsStore):
|
||||
"""SQLite/Postgres/MySQL-backed implementation; writes are fire-and-forget."""
|
||||
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
async def record(self, metric: ToolMetricRecord) -> None:
|
||||
# Skill calls are merged per run: the model may touch one skill across
|
||||
# several tool calls / retries, and the admin wants a single row per
|
||||
# (run, skill) instead of one per call. Non-skill tool calls, and skill
|
||||
# calls with no run_id, fall back to a plain insert.
|
||||
if metric.skill_name and metric.run_id:
|
||||
await self._record_skill_merged(metric)
|
||||
return
|
||||
try:
|
||||
async with self._sf() as session:
|
||||
session.add(_row_from_metric(metric))
|
||||
await session.commit()
|
||||
except Exception:
|
||||
# Metrics are best-effort; never surface a write failure to the runtime.
|
||||
logger.warning("Failed to persist tool_call_metrics row %s", metric.id, exc_info=True)
|
||||
|
||||
async def _record_skill_merged(self, metric: ToolMetricRecord) -> None:
|
||||
"""Upsert a per-(run, skill) row, folding repeated calls into one."""
|
||||
try:
|
||||
async with _skill_merge_lock():
|
||||
async with self._sf() as session:
|
||||
existing = await session.scalar(
|
||||
select(ToolCallMetricRow)
|
||||
.where(
|
||||
ToolCallMetricRow.run_id == metric.run_id,
|
||||
ToolCallMetricRow.skill_name == metric.skill_name,
|
||||
)
|
||||
.order_by(ToolCallMetricRow.created_at)
|
||||
.limit(1)
|
||||
)
|
||||
if existing is None:
|
||||
session.add(_row_from_metric(metric))
|
||||
else:
|
||||
_merge_skill_row(existing, metric)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to merge tool_call_metrics for skill %s (run %s)",
|
||||
metric.skill_name,
|
||||
metric.run_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def _apply_filters(self, stmt, query: ToolMetricsQuery):
|
||||
if query.user_id:
|
||||
stmt = stmt.where(ToolCallMetricRow.user_id == query.user_id)
|
||||
if query.tool_name:
|
||||
stmt = stmt.where(ToolCallMetricRow.tool_name == query.tool_name)
|
||||
if query.skill_only:
|
||||
stmt = stmt.where(ToolCallMetricRow.skill_name.is_not(None))
|
||||
if query.skill_name:
|
||||
stmt = stmt.where(ToolCallMetricRow.skill_name == query.skill_name)
|
||||
if query.status:
|
||||
stmt = stmt.where(ToolCallMetricRow.status == query.status)
|
||||
if query.error_category:
|
||||
stmt = stmt.where(ToolCallMetricRow.error_category == query.error_category)
|
||||
if query.since is not None:
|
||||
stmt = stmt.where(ToolCallMetricRow.created_at >= _normalize_created_at(query.since))
|
||||
if query.until is not None:
|
||||
stmt = stmt.where(ToolCallMetricRow.created_at <= _normalize_created_at(query.until))
|
||||
return stmt
|
||||
|
||||
async def list(self, query: ToolMetricsQuery) -> list[dict[str, Any]]:
|
||||
stmt = select(ToolCallMetricRow)
|
||||
stmt = self._apply_filters(stmt, query)
|
||||
stmt = stmt.order_by(desc(ToolCallMetricRow.created_at)).limit(max(1, min(query.limit, 1000))).offset(max(0, query.offset))
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [row.to_dict() for row in result.scalars()]
|
||||
|
||||
async def count(self, query: ToolMetricsQuery) -> int:
|
||||
stmt = select(func.count()).select_from(ToolCallMetricRow)
|
||||
stmt = self._apply_filters(stmt, query)
|
||||
async with self._sf() as session:
|
||||
return (await session.scalar(stmt)) or 0
|
||||
|
||||
async def iter_all(self, query: ToolMetricsQuery) -> AsyncIterator[dict[str, Any]]:
|
||||
page_size = 500
|
||||
offset = 0
|
||||
while True:
|
||||
page = await self.list(
|
||||
ToolMetricsQuery(
|
||||
user_id=query.user_id,
|
||||
tool_name=query.tool_name,
|
||||
skill_only=query.skill_only,
|
||||
skill_name=query.skill_name,
|
||||
status=query.status,
|
||||
error_category=query.error_category,
|
||||
since=query.since,
|
||||
until=query.until,
|
||||
limit=page_size,
|
||||
offset=offset,
|
||||
)
|
||||
)
|
||||
if not page:
|
||||
return
|
||||
for item in page:
|
||||
yield item
|
||||
if len(page) < page_size:
|
||||
return
|
||||
offset += page_size
|
||||
|
||||
async def skill_breakdown(self, query: ToolMetricsQuery) -> list[dict[str, Any]]:
|
||||
# Count success/error per skill. Force the skill-only filter so rows
|
||||
# without a skill_name (plain tool calls) never leak into the result.
|
||||
scoped = ToolMetricsQuery(
|
||||
user_id=query.user_id,
|
||||
skill_only=True,
|
||||
skill_name=query.skill_name,
|
||||
since=query.since,
|
||||
until=query.until,
|
||||
)
|
||||
stmt = select(
|
||||
ToolCallMetricRow.skill_name,
|
||||
ToolCallMetricRow.status,
|
||||
func.count().label("n"),
|
||||
)
|
||||
stmt = self._apply_filters(stmt, scoped).group_by(
|
||||
ToolCallMetricRow.skill_name, ToolCallMetricRow.status
|
||||
)
|
||||
agg: dict[str, dict[str, int]] = {}
|
||||
async with self._sf() as session:
|
||||
for skill_name, status, n in (await session.execute(stmt)).all():
|
||||
bucket = agg.setdefault(skill_name, {"total": 0, "success": 0, "error": 0})
|
||||
bucket["total"] += n
|
||||
bucket["success" if status == "success" else "error"] += n
|
||||
return [
|
||||
{"skill_name": name, **counts}
|
||||
for name, counts in sorted(agg.items(), key=lambda kv: kv[1]["total"], reverse=True)
|
||||
]
|
||||
@@ -0,0 +1,264 @@
|
||||
"""Drop the stale business tables from the SQLite file after the MySQL switch.
|
||||
|
||||
Context
|
||||
-------
|
||||
In SQLite mode the LangGraph checkpointer **and** the application share one
|
||||
file: ``.deer-flow/data/deerflow.db``. After ``database.backend`` is switched
|
||||
to ``mysql`` (and ``migrate_business_sqlite_to_mysql.py`` has copied the data
|
||||
over), the business tables in ``deerflow.db`` are dead weight — the app now
|
||||
reads them from MySQL — while the LangGraph checkpoint tables in the *same*
|
||||
file are still actively used (``checkpointer.type`` stays ``sqlite``).
|
||||
|
||||
This script drops only the stale business tables and ``VACUUM``s to reclaim
|
||||
the freed pages.
|
||||
|
||||
Safety design
|
||||
-------------
|
||||
* **Explicit DROP allowlist** — only the known business tables are dropped.
|
||||
LangGraph tables (``checkpoints`` / ``writes`` / ``store`` /
|
||||
``store_migrations``) and any unrecognised table are left untouched.
|
||||
* **MySQL pre-check** — before dropping, it connects to MySQL and verifies
|
||||
every business table exists there. A missing table aborts the run (the
|
||||
migration looks incomplete). Skippable only via an explicit flag.
|
||||
* **Dry-run by default** — nothing is changed unless ``--apply`` is passed.
|
||||
* **Automatic backup** — ``--apply`` first copies ``deerflow.db`` to a
|
||||
timestamped ``.bak`` file (disable with ``--no-backup``).
|
||||
|
||||
IMPORTANT: stop the backend first. ``DROP TABLE`` / ``VACUUM`` need an
|
||||
exclusive lock on the SQLite file; with the server running they will fail
|
||||
with "database is locked" (a safe failure — nothing is changed).
|
||||
|
||||
Usage
|
||||
-----
|
||||
# from backend/ , with the backend's interpreter (has the MySQL driver)
|
||||
PYTHONPATH=. uv run python scripts/cleanup_sqlite_after_mysql_migration.py # dry-run
|
||||
PYTHONPATH=. uv run python scripts/cleanup_sqlite_after_mysql_migration.py --apply # do it
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sqlite3
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# Business tables migrated to MySQL — identical to BUSINESS_TABLES in
|
||||
# migrate_business_sqlite_to_mysql.py. These are the DROP allowlist.
|
||||
BUSINESS_TABLES: list[str] = [
|
||||
"users",
|
||||
"threads_meta",
|
||||
"runs",
|
||||
"run_events",
|
||||
"feedback",
|
||||
"agents",
|
||||
"skills",
|
||||
"scheduled_tasks",
|
||||
"scheduled_task_subscriptions",
|
||||
"scheduled_task_runs",
|
||||
"scheduled_task_delivery_profiles",
|
||||
"scheduler_threads",
|
||||
"llm_call_metrics",
|
||||
"notifications",
|
||||
"recommended_questions",
|
||||
"tags",
|
||||
"tag_assignments",
|
||||
"thread_shares",
|
||||
]
|
||||
|
||||
# Newer business tables that may exist depending on deployment age. Dropped if
|
||||
# present; absence is fine. They are NOT checked against MySQL (a fresh MySQL
|
||||
# install creates them via create_all / Alembic, but an older SQLite file may
|
||||
# never have had them).
|
||||
OPTIONAL_BUSINESS_TABLES: list[str] = [
|
||||
"ai_writing_sessions",
|
||||
"article_types",
|
||||
"tool_call_metrics",
|
||||
]
|
||||
|
||||
# SQLite-side Alembic version tracker. After the MySQL switch the SQLite file
|
||||
# only serves the checkpointer, which does not use Alembic — so this row is
|
||||
# stale. Dropped, but never MySQL-checked.
|
||||
ALEMBIC_TABLE = "alembic_version"
|
||||
|
||||
# LangGraph infrastructure tables — these stay on SQLite and must NEVER be
|
||||
# dropped. The checkpointer/store keep using them.
|
||||
KEEP_TABLES: set[str] = {"checkpoints", "writes", "store", "store_migrations"}
|
||||
|
||||
|
||||
def _repo_root_from_script() -> Path:
|
||||
# scripts/ -> backend/ -> offline-backend-20260512/
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _load_mysql_url(explicit: str | None) -> str | None:
|
||||
if explicit:
|
||||
return explicit
|
||||
env = os.environ.get("MYSQL_DATABASE_URL")
|
||||
if env:
|
||||
return env
|
||||
# Fall back to the .env next to the backend directory.
|
||||
for candidate in (
|
||||
_repo_root_from_script() / ".env",
|
||||
Path(__file__).resolve().parents[1] / ".env",
|
||||
):
|
||||
if candidate.is_file():
|
||||
for line in candidate.read_text(encoding="utf-8").splitlines():
|
||||
if line.startswith("MYSQL_DATABASE_URL="):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_async_url(url: str) -> str:
|
||||
"""Force the URL onto the asyncmy async driver SQLAlchemy understands."""
|
||||
if url.startswith(("mysql+asyncmy://", "mysql+aiomysql://")):
|
||||
return url
|
||||
return re.sub(r"^mysql(\+\w+)?://", "mysql+asyncmy://", url)
|
||||
|
||||
|
||||
def _default_sqlite_path() -> Path:
|
||||
return _repo_root_from_script() / "backend" / ".deer-flow" / "data" / "deerflow.db"
|
||||
|
||||
|
||||
async def _mysql_existing_tables(url: str) -> set[str]:
|
||||
"""Return the set of table names present in the MySQL business database."""
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.engine import make_url
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
async_url = _normalize_async_url(url)
|
||||
db_name = make_url(async_url).database
|
||||
engine = create_async_engine(async_url)
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
rows = await conn.execute(
|
||||
text("SELECT table_name FROM information_schema.tables WHERE table_schema = :s"),
|
||||
{"s": db_name},
|
||||
)
|
||||
return {r[0] for r in rows}
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def _sqlite_tables(con: sqlite3.Connection) -> list[str]:
|
||||
return [r[0] for r in con.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")]
|
||||
|
||||
|
||||
def _row_count(con: sqlite3.Connection, table: str) -> int:
|
||||
return con.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--sqlite", default=str(_default_sqlite_path()), help="Path to deerflow.db")
|
||||
parser.add_argument("--mysql-url", default=None, help="MySQL URL (default: $MYSQL_DATABASE_URL or .env)")
|
||||
parser.add_argument("--apply", action="store_true", help="Actually drop tables + VACUUM (default: dry-run)")
|
||||
parser.add_argument("--no-backup", action="store_true", help="Skip the pre-apply backup copy")
|
||||
parser.add_argument("--skip-mysql-check", action="store_true", help="Do NOT verify tables exist in MySQL (unsafe)")
|
||||
parser.add_argument("--drop-alembic", action="store_true", help="Also drop the stale SQLite alembic_version table")
|
||||
args = parser.parse_args()
|
||||
|
||||
sqlite_path = Path(args.sqlite).resolve()
|
||||
if not sqlite_path.is_file():
|
||||
print(f"ERROR: SQLite file not found: {sqlite_path}")
|
||||
return 1
|
||||
|
||||
size_before = sqlite_path.stat().st_size
|
||||
print(f"SQLite file : {sqlite_path}")
|
||||
print(f"Size : {size_before / 1024 / 1024:.1f} MB")
|
||||
print(f"Mode : {'APPLY' if args.apply else 'DRY-RUN'}\n")
|
||||
|
||||
con = sqlite3.connect(sqlite_path)
|
||||
try:
|
||||
present = set(_sqlite_tables(con))
|
||||
|
||||
drop_business = [t for t in BUSINESS_TABLES if t in present]
|
||||
drop_optional = [t for t in OPTIONAL_BUSINESS_TABLES if t in present]
|
||||
to_drop = drop_business + drop_optional
|
||||
if args.drop_alembic and ALEMBIC_TABLE in present:
|
||||
to_drop.append(ALEMBIC_TABLE)
|
||||
|
||||
keep = sorted(present & KEEP_TABLES)
|
||||
known = set(BUSINESS_TABLES) | set(OPTIONAL_BUSINESS_TABLES) | KEEP_TABLES | {ALEMBIC_TABLE}
|
||||
unknown = sorted(present - known)
|
||||
|
||||
missing_business = [t for t in BUSINESS_TABLES if t not in present]
|
||||
if missing_business:
|
||||
print(f"NOTE: business tables already absent from SQLite: {', '.join(missing_business)}\n")
|
||||
|
||||
print("KEEP (LangGraph checkpointer / store - never dropped):")
|
||||
for t in keep:
|
||||
print(f" + {t:<34}{_row_count(con, t):>10} rows")
|
||||
if unknown:
|
||||
print("\nUNKNOWN (not recognised - left untouched, review manually):")
|
||||
for t in unknown:
|
||||
print(f" ? {t:<34}{_row_count(con, t):>10} rows")
|
||||
print("\nDROP (stale business tables):")
|
||||
for t in to_drop:
|
||||
print(f" - {t:<34}{_row_count(con, t):>10} rows")
|
||||
if not to_drop:
|
||||
print(" (nothing to drop - already clean)")
|
||||
return 0
|
||||
|
||||
# --- MySQL safety pre-check -------------------------------------
|
||||
if args.skip_mysql_check:
|
||||
print("\nWARNING: --skip-mysql-check set - NOT verifying the MySQL side.")
|
||||
else:
|
||||
mysql_url = _load_mysql_url(args.mysql_url)
|
||||
if not mysql_url:
|
||||
print("\nERROR: no MySQL URL ($MYSQL_DATABASE_URL / .env / --mysql-url).")
|
||||
print(" Pass --skip-mysql-check to bypass (only if you are certain).")
|
||||
return 1
|
||||
print("\nVerifying business tables exist in MySQL ...")
|
||||
try:
|
||||
mysql_tables = asyncio.run(_mysql_existing_tables(mysql_url))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"ERROR: could not query MySQL: {exc}")
|
||||
print(" Run this on the backend host (MySQL access is host-restricted),")
|
||||
print(" or pass --skip-mysql-check to bypass (only if you are certain).")
|
||||
return 1
|
||||
missing_in_mysql = [t for t in drop_business if t not in mysql_tables]
|
||||
if missing_in_mysql:
|
||||
print(f"ABORT: these business tables are MISSING in MySQL: {', '.join(missing_in_mysql)}")
|
||||
print(" The migration looks incomplete - not dropping anything.")
|
||||
return 1
|
||||
print(f"OK - all {len(drop_business)} business tables present in MySQL.")
|
||||
|
||||
if not args.apply:
|
||||
print("\nDry-run only. Re-run with --apply to drop the tables and VACUUM.")
|
||||
return 0
|
||||
|
||||
# --- apply ------------------------------------------------------
|
||||
if not args.no_backup:
|
||||
backup = sqlite_path.with_name(
|
||||
f"{sqlite_path.name}.bak-{datetime.now():%Y%m%d-%H%M%S}"
|
||||
)
|
||||
print(f"\nBacking up -> {backup}")
|
||||
shutil.copy2(sqlite_path, backup)
|
||||
|
||||
print("Dropping tables ...")
|
||||
for t in to_drop:
|
||||
con.execute(f'DROP TABLE IF EXISTS "{t}"')
|
||||
con.commit()
|
||||
|
||||
print("VACUUM (reclaiming space) ...")
|
||||
con.isolation_level = None # VACUUM cannot run inside a transaction
|
||||
con.execute("VACUUM")
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
size_after = sqlite_path.stat().st_size
|
||||
print(
|
||||
f"\nDone. Size {size_before / 1024 / 1024:.1f} MB "
|
||||
f"-> {size_after / 1024 / 1024:.1f} MB "
|
||||
f"(reclaimed {(size_before - size_after) / 1024 / 1024:.1f} MB)"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -73,6 +73,20 @@ tools:
|
||||
sandbox:
|
||||
use: deerflow.sandbox.local:LocalSandboxProvider
|
||||
allow_host_bash: true
|
||||
title:
|
||||
enabled: true
|
||||
max_words: 6
|
||||
max_chars: 60
|
||||
prompt_template: |
|
||||
请为下面这段对话生成一个简洁的标题,概括对话的核心主题。
|
||||
|
||||
要求:
|
||||
- 标题必须使用简体中文
|
||||
- 尽量精炼,不超过 {max_words} 个词
|
||||
- 只返回标题本身,不要加引号、不要解释、结尾不要标点
|
||||
|
||||
User: {user_msg}
|
||||
Assistant: {assistant_msg}
|
||||
skills:
|
||||
path: /app/skills
|
||||
agents_api:
|
||||
@@ -85,3 +99,59 @@ database:
|
||||
checkpointer:
|
||||
type: sqlite
|
||||
connection_string: D:\cursor-work\offline-backend-20260512\backend\.deer-flow\data\deerflow.db
|
||||
# 记忆子系统:跨会话持久化用户画像与 agent 私有记忆。
|
||||
# 三层覆盖优先级(高->低):
|
||||
# users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处
|
||||
memory:
|
||||
enabled: true
|
||||
provider: hindsight # 'builtin' | 'hindsight'(外部 provider 名)
|
||||
# builtin 始终启用,这里只决定 external 选哪个
|
||||
builtin:
|
||||
enabled: true
|
||||
memory_char_limit: 2200
|
||||
user_char_limit: 1375
|
||||
deduplicate_on_load: true
|
||||
|
||||
hindsight: # provider=hindsight 时生效,需另行部署 Hindsight 服务
|
||||
mode: local_external # 'cloud' | 'local_embedded' | 'local_external'
|
||||
api_url: http://localhost:8765
|
||||
api_key: "" # 部署 Hindsight 后填,或改成 $HINDSIGHT_API_KEY 并设同名环境变量
|
||||
# 注意:写成 $VAR 但环境变量未设置会导致配置加载失败
|
||||
bank_id_template: "deerflow-user-{user_id}"
|
||||
work_bank_id_template: "deerflow-work-{user_id}-{agent_id}"
|
||||
memory_mode: context # 'context' | 'tools' | 'hybrid'
|
||||
bank_mission: ""
|
||||
bank_retain_mission: ""
|
||||
recall_budget: mid # 'low' | 'mid' | 'high'
|
||||
recall_max_tokens: 4096
|
||||
recall_max_input_chars: 800
|
||||
auto_recall: true
|
||||
auto_retain: true
|
||||
retain_async: true
|
||||
retain_every_n_turns: 1
|
||||
retain_user_prefix: "User"
|
||||
retain_assistant_prefix: "Assistant"
|
||||
|
||||
# background_extraction:预留功能,代码尚未实现 —— 仅设计文档存在。
|
||||
# 待对应版本支持后,取消下面整段注释即可启用(漏记兜底)。
|
||||
# background_extraction:
|
||||
# enabled: false # 默认关闭,作为漏记兜底
|
||||
# debounce_seconds: 30
|
||||
# model_name: ~ # null = 用默认模型
|
||||
# fact_confidence_threshold: 0.7
|
||||
# max_facts_per_extraction: 10
|
||||
# correction_detection: true
|
||||
# reinforcement_detection: true
|
||||
|
||||
injection:
|
||||
enabled: true
|
||||
max_tokens: 2000
|
||||
include_builtin: true
|
||||
include_hindsight: true
|
||||
context_tag: "memory-context" # 包裹 Hindsight recall 的标签名
|
||||
|
||||
security:
|
||||
scan_content: true # 注入/外渗模式扫描
|
||||
block_invisible_unicode: true
|
||||
# streaming_scrubber:预留字段,代码尚未实现 —— 待版本支持后取消注释。
|
||||
# streaming_scrubber: true # SSE 流前清洗 <memory-context>
|
||||
Reference in new issue
Block a user