This commit is contained in:
xuhd committed 2026-05-19 16:05:11 +08:00
commit 64492bec43
46 files changed
+3131 -146

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>
@@ -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]);
@@ -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) {
@@ -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);
@@ -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 (
@@ -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")}
+72
View File
@@ -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",
);
}
+33
View File
@@ -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,
});
}
+2 -2
View File
@@ -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}` : ""}`,
+4 -4
View File
@@ -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 };
+6 -2
View File
@@ -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));
+3 -3
View File
@@ -73,7 +73,7 @@ export function useArtifactContent({
const isWriteFile = useMemo(() => {
return filepath.startsWith("write-file:");
}, [filepath]);
const { thread, isMock } = useThread();
const { thread, isMock, publicShareCode } = useThread();
const content = useMemo(() => {
if (isWriteFile) {
return loadArtifactContentFromToolCall({ url: filepath, thread });
@@ -82,9 +82,9 @@ export function useArtifactContent({
}, [filepath, isWriteFile, thread]);
const { data, isLoading, error } = useQuery({
queryKey: ["artifact", filepath, threadId, isMock],
queryKey: ["artifact", filepath, threadId, isMock, publicShareCode],
queryFn: () => {
return loadArtifactContent({ filepath, threadId, isMock });
return loadArtifactContent({ filepath, threadId, isMock, publicShareCode });
},
enabled,
refetchInterval: thread.isLoading ? 1000 : false,
+3 -1
View File
@@ -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 };
+10
View File
@@ -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" : ""}`;
}
+2 -2
View File
@@ -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,
});
+2 -2
View File
@@ -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}` : ""}`,
+4 -4
View File
@@ -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 };
+151
View File
@@ -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,343 @@
"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 bucket: context summaries + facts. */
function MemoryBucketView({ bucket }: { bucket: AdminMemoryBucket }) {
const data = bucket.data as {
user?: Record<string, { summary?: string }>;
history?: Record<string, { summary?: string }>;
facts?: Array<{ content?: string; category?: string; confidence?: number }>;
};
const sections: Array<[string, string]> = [];
const labels: Record<string, string> = {
workContext: "工作背景",
personalContext: "个人背景",
topOfMind: "近期关注",
recentMonths: "近几个月",
earlierContext: "更早记录",
longTermBackground: "长期背景",
};
for (const group of [data.user, data.history]) {
for (const [key, value] of Object.entries(group ?? {})) {
const summary = value?.summary?.trim();
if (summary) sections.push([labels[key] ?? key, summary]);
}
}
const facts = (data.facts ?? []).filter((f) => f.content?.trim());
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>
</div>
{sections.length === 0 && facts.length === 0 && (
<p className="text-muted-foreground text-sm">该记忆库暂无内容。</p>
)}
{sections.length > 0 && (
<div className="flex flex-col gap-2">
{sections.map(([label, summary]) => (
<div key={label}>
<span className="text-muted-foreground text-xs">{label}</span>
<p className="text-sm">{summary}</p>
</div>
))}
</div>
)}
{facts.length > 0 && (
<div className="mt-3">
<span className="text-muted-foreground text-xs">事实记录({facts.length})</span>
<ul className="mt-1 flex flex-col gap-1">
{facts.map((fact, i) => (
<li key={i} className="flex items-start gap-2 text-sm">
<span className="text-muted-foreground">·</span>
<span className="flex-1">{fact.content}</span>
{fact.category && (
<Badge variant="outline" className="shrink-0 text-xs">
{fact.category}
</Badge>
)}
</li>
))}
</ul>
</div>
)}
</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>
);
}
+25 -3
View File
@@ -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>
);
}
+54 -5
View File
@@ -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>
+20 -3
View File
@@ -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={
+4 -2
View File
@@ -173,6 +173,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. Surfaced by the admin `GET /api/admin/tool-metrics` router (which has a `kind=skill` filter and a per-skill `/summary` breakdown).
9. **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
10. **SummarizationMiddleware** - Context reduction when approaching token limits (optional, if enabled)
11. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
@@ -230,8 +231,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 |
@@ -241,7 +243,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,
@@ -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,162 @@
"""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.updater import get_memory_data
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)
@router.get("/{user_id}/memory", response_model=AdminMemoryResponse)
async def get_user_memory(user_id: str, request: Request) -> AdminMemoryResponse:
"""Return every memory bucket (global + per-agent) for one user."""
await _require_admin(request)
buckets: list[AdminMemoryBucket] = [
AdminMemoryBucket(name="global", data=get_memory_data(None, user_id=user_id))
]
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.json").exists():
continue
if not AGENT_NAME_PATTERN.match(entry.name):
continue
try:
data = get_memory_data(entry.name, user_id=user_id)
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,
)
@@ -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
@@ -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
@@ -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")
@@ -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")
@@ -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",
]
@@ -0,0 +1,27 @@
"""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.
"""
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)
@@ -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
@@ -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,
}
@@ -0,0 +1,138 @@
"""SQLAlchemy-backed tool metrics repository."""
from __future__ import annotations
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__)
def _normalize_created_at(value: datetime) -> datetime:
return value if value.tzinfo else value.replace(tzinfo=UTC)
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:
row = 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,
)
try:
async with self._sf() as session:
session.add(row)
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)
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())