From 4940e0d8ed9b83e474f50e123489b9574c2206c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Tue, 19 May 2026 00:59:15 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=E5=B7=A5=E5=85=B7/=E6=8A=80?= =?UTF-8?q?=E8=83=BD=E8=B0=83=E7=94=A8=E7=BB=9F=E8=AE=A1=20+=20=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=B4=BB=E5=8A=A8=E5=AE=A1=E8=AE=A1=20+=20=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E5=A4=9A=E9=80=89=E7=AD=9B=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 任务1: 用户管理/定时任务管理移入左下角菜单, 从侧边栏删除原入口 - 任务2: 管理员查看用户问答(读 LangGraph checkpoint)与记忆, 新增 admin_users 路由与审计页 - 任务3: 标签筛选改为顶部多选标签栏(OR), agents/skills/scheduled_tasks 三端 - 任务4: 新增 tool_call_metrics 表 + ToolMetricsMiddleware + tool-metrics 路由, 工具/技能调用统计并入大模型统计页, 含错误分类 - 技能识别: lead agent 经 read_file 加载 /mnt/skills///SKILL.md 调用技能, 中间件扫描工具参数里的 skills/{public,custom}/{name}/ 路径还原 skill_name - 单元测试 tests/test_tool_metrics_middleware.py (18 项全过) - 附: SQLite->MySQL 切换后清理脚本 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../workspace/admin/tool-metrics-panel.tsx | 604 ++++++++++++++++++ .../workspace/agents/agent-gallery.tsx | 11 +- .../artifacts/artifact-file-detail.tsx | 290 ++++----- .../artifacts/artifact-file-list.tsx | 6 +- .../settings/skill-settings-page.tsx | 9 +- .../workspace/tags/tag-search-bar.tsx | 120 +++- .../workspace/workspace-nav-chat-list.tsx | 33 +- .../workspace/workspace-nav-menu.tsx | 31 + frontend-web/src/core/admin/api.ts | 72 +++ frontend-web/src/core/admin/hooks.ts | 33 + frontend-web/src/core/agents/api.ts | 4 +- frontend-web/src/core/agents/hooks.ts | 8 +- frontend-web/src/core/artifacts/download.ts | 8 +- frontend-web/src/core/artifacts/hooks.ts | 56 +- frontend-web/src/core/artifacts/loader.ts | 4 +- frontend-web/src/core/artifacts/utils.ts | 10 + frontend-web/src/core/scheduled-tasks/api.ts | 4 +- .../src/core/scheduled-tasks/hooks.ts | 8 +- frontend-web/src/core/skills/api.ts | 4 +- frontend-web/src/core/skills/hooks.ts | 8 +- frontend-web/src/core/tool-metrics/api.ts | 151 +++++ frontend-web/src/core/tool-metrics/hooks.ts | 26 + .../src/pages/AdminUserActivityPage.tsx | 343 ++++++++++ frontend-web/src/pages/LlmMetricsPage.tsx | 28 +- frontend-web/src/pages/PublicSharePage.tsx | 59 +- frontend-web/src/pages/ScheduledTasksPage.tsx | 8 +- frontend-web/src/pages/WorkspaceRoutes.tsx | 23 +- offline-backend-20260512/backend/CLAUDE.md | 7 +- .../backend/app/gateway/app.py | 10 +- .../backend/app/gateway/deps.py | 9 + .../app/gateway/routers/admin_users.py | 162 +++++ .../backend/app/gateway/routers/agents.py | 7 +- .../app/gateway/routers/scheduled_tasks.py | 7 +- .../backend/app/gateway/routers/skills.py | 11 +- .../app/gateway/routers/thread_shares.py | 39 +- .../app/gateway/routers/tool_metrics.py | 380 +++++++++++ .../tool_error_handling_middleware.py | 8 + .../middlewares/tool_metrics_middleware.py | 319 +++++++++ .../versions/20260518_07_tool_call_metrics.py | 58 ++ ...0260519_01_tool_call_metrics_skill_name.py | 62 ++ .../deerflow/persistence/models/__init__.py | 2 + .../persistence/tool_metrics/__init__.py | 27 + .../deerflow/persistence/tool_metrics/base.py | 84 +++ .../persistence/tool_metrics/model.py | 59 ++ .../deerflow/persistence/tool_metrics/sql.py | 138 ++++ .../cleanup_sqlite_after_mysql_migration.py | 264 ++++++++ 46 files changed, 3299 insertions(+), 315 deletions(-) create mode 100644 frontend-web/src/components/workspace/admin/tool-metrics-panel.tsx create mode 100644 frontend-web/src/core/admin/api.ts create mode 100644 frontend-web/src/core/admin/hooks.ts create mode 100644 frontend-web/src/core/tool-metrics/api.ts create mode 100644 frontend-web/src/core/tool-metrics/hooks.ts create mode 100644 frontend-web/src/pages/AdminUserActivityPage.tsx create mode 100644 offline-backend-20260512/backend/app/gateway/routers/admin_users.py create mode 100644 offline-backend-20260512/backend/app/gateway/routers/tool_metrics.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/tool_metrics_middleware.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260518_07_tool_call_metrics.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260519_01_tool_call_metrics_skill_name.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/tool_metrics/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/tool_metrics/base.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/tool_metrics/model.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/tool_metrics/sql.py create mode 100644 offline-backend-20260512/backend/scripts/cleanup_sqlite_after_mysql_migration.py diff --git a/frontend-web/src/components/workspace/admin/tool-metrics-panel.tsx b/frontend-web/src/components/workspace/admin/tool-metrics-panel.tsx new file mode 100644 index 00000000..1955da41 --- /dev/null +++ b/frontend-web/src/components/workspace/admin/tool-metrics-panel.tsx @@ -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("skill"); + const [status, setStatus] = useState("all"); + const [category, setCategory] = useState("all"); + const [since, setSince] = useState(""); + const [until, setUntil] = useState(""); + const [exporting, setExporting] = useState(false); + const [detailRow, setDetailRow] = useState(null); + + const filters = useMemo(() => { + 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(() => { + 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 ( +
+
+
+

工具调用统计

+

+ 记录每一次工具 / 技能 / MCP 调用,并对失败进行归类:检索为空、IP + 被封、限流、超时、鉴权失败等。后台无条件记录。 +

+
+ +
+ + {/* Summary strip */} +
+
+ 总调用 + + {summary?.total ?? "—"} + +
+
+ 失败数 + + {summary?.error_total ?? "—"} + +
+
+ 失败率 + {errorRate}% +
+
+ {summary && Object.keys(summary.by_category).length === 0 && ( + 暂无失败记录 🎉 + )} + {summary && + Object.entries(summary.by_category) + .sort((a, b) => b[1] - a[1]) + .map(([cat, count]) => ( + + ))} +
+
+ + {/* Per-skill breakdown — answers "was skill X invoked, and did it succeed". */} + {summary && summary.by_skill.length > 0 && ( +
+

各技能调用情况

+
+ + + + + + + + + + + + {summary.by_skill.map((s) => { + const rate = + s.total > 0 ? ((s.success / s.total) * 100).toFixed(0) : "0"; + return ( + { + setSkill(s.skill_name); + setPage(0); + }} + > + + + + + + + ); + })} + +
技能名称调用次数成功失败成功率
{s.skill_name}{s.total} + {s.success} + + {s.error} + {rate}%
+
+
+ )} + {summary && kind === "skill" && summary.by_skill.length === 0 && ( +
+ 当前筛选范围内没有检测到任何技能调用记录。技能只有在被 Agent 通过 + skill_view + 读取(即真正"调用")时才会被记录;仅在系统提示里被列出、但未被读取的技能不会计入。 +
+ )} + + {/* Filters */} +
+
+ + +
+
+ + { + setSkill(e.target.value); + setPage(0); + }} + /> +
+
+ + { + setTool(e.target.value); + setPage(0); + }} + /> +
+
+ + { + setUser(e.target.value); + setPage(0); + }} + /> +
+
+ + +
+
+ + +
+
+ + { + setSince(e.target.value); + setPage(0); + }} + /> +
+
+ + { + setUntil(e.target.value); + setPage(0); + }} + /> +
+
+ +
+
+ + {/* Table */} +
+
+ + + + + + + + + + + + + + + + {isLoading && ( + + + + )} + {!isLoading && error && ( + + + + )} + {!isLoading && !error && items.length === 0 && ( + + + + )} + {items.map((row) => ( + + + + + + + + + + + + ))} + +
时间用户Agent技能工具耗时状态错误类型错误详情
+ +
+ 加载失败:{error.message} +
+ 暂无符合条件的调用记录。 +
{formatDate(row.created_at)}{displayUser(row)}{row.agent_name ?? "—"} + {row.skill_name ? ( + + ) : ( + — + )} + {row.tool_name ?? "—"} + {formatDuration(row.duration_ms)} + + + {row.status === "success" ? "成功" : "失败"} + + + {row.error_category ? ( + {categoryLabel(row.error_category)} + ) : ( + "—" + )} + + {row.error_message ? ( + + ) : ( + — + )} +
+
+
+ + {isFetching && !isLoading ? "更新中..." : `共 ${total} 条`} + +
+ + + 第 {page + 1} / {totalPages} 页 + + +
+
+
+ + {/* Full error detail popup */} + { + if (!open) setDetailRow(null); + }} + > + + + 工具调用报错详情 + + {detailRow && ( +
+
+ + + + + + + + + +
+
+ 完整报错信息 +
+                  {detailRow.error_message ?? "(无)"}
+                
+
+
+ )} +
+
+
+ ); +} + +function DetailField({ + label, + value, + mono, +}: { + label: string; + value: string; + mono?: boolean; +}) { + return ( +
+ {label} + {value} +
+ ); +} + +function Th({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( + + {children} + + ); +} + +function Td({ + children, + className, + title, +}: { + children: React.ReactNode; + className?: string; + title?: string; +}) { + return ( + + {children} + + ); +} diff --git a/frontend-web/src/components/workspace/agents/agent-gallery.tsx b/frontend-web/src/components/workspace/agents/agent-gallery.tsx index 146e5557..cdce912f 100644 --- a/frontend-web/src/components/workspace/agents/agent-gallery.tsx +++ b/frontend-web/src/components/workspace/agents/agent-gallery.tsx @@ -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(null); + const [tagIds, setTagIds] = useState([]); // 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" > -
+
我的 广场 @@ -95,9 +95,8 @@ export function AgentGallery() {
diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx index c45a68a6..0e96bd10 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -2,16 +2,16 @@ import { ArrowDownIcon, - Code2Icon, - CopyIcon, - DownloadIcon, - EyeIcon, - LoaderIcon, - PackageIcon, - SaveIcon, - SquareArrowOutUpRightIcon, - XIcon, -} from "lucide-react"; + Code2Icon, + CopyIcon, + DownloadIcon, + EyeIcon, + LoaderIcon, + PackageIcon, + SaveIcon, + SquareArrowOutUpRightIcon, + XIcon, +} from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { Streamdown } from "streamdown"; @@ -34,14 +34,14 @@ import { import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { CodeEditor } from "@/components/workspace/code-editor"; import { apiFetch } from "@/core/api"; -import { - downloadArtifactFile, - openArtifactInNewTab, -} from "@/core/artifacts/download"; -import { - useArtifactContent, - useUpdateArtifactContent, -} from "@/core/artifacts/hooks"; +import { + downloadArtifactFile, + openArtifactInNewTab, +} from "@/core/artifacts/download"; +import { + useArtifactContent, + useUpdateArtifactContent, +} from "@/core/artifacts/hooks"; import { urlOfArtifact } from "@/core/artifacts/utils"; import { useI18n } from "@/core/i18n/hooks"; import { installSkill } from "@/core/skills/api"; @@ -89,7 +89,7 @@ export function ArtifactFileDetail({ }) { const { t } = useI18n(); const { artifacts, setOpen, select } = useArtifacts(); - const { isMock, thread } = useThread(); + const { isMock, thread, publicShareCode } = useThread(); const isWriteFile = useMemo(() => { return filepathFromProps.startsWith("write-file:"); }, [filepathFromProps]); @@ -117,18 +117,18 @@ export function ArtifactFileDetail({ const isSupportPreview = useMemo(() => { return language === "html" || language === "markdown"; }, [language]); - const { content, isApplyingEdit } = useArtifactContent({ - threadId, - filepath: filepathFromProps, - enabled: isCodeFile && !isWriteFile, - }); - - const canEditArtifact = - language === "markdown" && !isWriteFile && !isSkillFile && !isMock; - const [draftContent, setDraftContent] = useState(""); - const [hasLocalEdits, setHasLocalEdits] = useState(false); - const updateArtifact = useUpdateArtifactContent(); - const displayContent = canEditArtifact ? draftContent : (content ?? ""); + const { content, isApplyingEdit } = useArtifactContent({ + threadId, + filepath: filepathFromProps, + enabled: isCodeFile && !isWriteFile, + }); + + const canEditArtifact = + language === "markdown" && !isWriteFile && !isSkillFile && !isMock; + const [draftContent, setDraftContent] = useState(""); + const [hasLocalEdits, setHasLocalEdits] = useState(false); + const updateArtifact = useUpdateArtifactContent(); + const displayContent = canEditArtifact ? draftContent : (content ?? ""); const isHtmlStreaming = language === "html" && thread.isLoading; const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1; const fileTypeLabel = useMemo(() => { @@ -148,20 +148,20 @@ export function ArtifactFileDetail({ const [binaryPreviewUrl, setBinaryPreviewUrl] = useState( null, ); - const artifactContentRef = useRef(null); - const resumeButtonTimeoutRef = useRef(null); - - useEffect(() => { - setDraftContent(content ?? ""); - setHasLocalEdits(false); - }, [filepathFromProps]); - - useEffect(() => { - if (!canEditArtifact || hasLocalEdits) { - return; - } - setDraftContent(content ?? ""); - }, [canEditArtifact, content, hasLocalEdits]); + const artifactContentRef = useRef(null); + const resumeButtonTimeoutRef = useRef(null); + + useEffect(() => { + setDraftContent(content ?? ""); + setHasLocalEdits(false); + }, [filepathFromProps]); + + useEffect(() => { + if (!canEditArtifact || hasLocalEdits) { + return; + } + setDraftContent(content ?? ""); + }, [canEditArtifact, content, hasLocalEdits]); useEffect(() => { setAllowAutoViewSwitch(true); @@ -279,7 +279,7 @@ export function ArtifactFileDetail({ (async () => { try { const res = await apiFetch( - urlOfArtifact({ filepath, threadId, isMock }), + urlOfArtifact({ filepath, threadId, isMock, publicShareCode }), ); if (cancelled || !res.ok) return; const blob = await res.blob(); @@ -304,7 +304,7 @@ export function ArtifactFileDetail({ return null; }); }; - }, [filepath, threadId, isMock, isCodeFile]); + }, [filepath, threadId, isMock, isCodeFile, publicShareCode]); const handlePauseAutoScroll = useCallback(() => { if (!thread.isLoading || !autoScrollEnabled) { @@ -329,7 +329,7 @@ export function ArtifactFileDetail({ }, 1600); }, [scrollArtifactToBottom]); - const handleInstallSkill = useCallback(async () => { + const handleInstallSkill = useCallback(async () => { if (isInstalling) return; setIsInstalling(true); @@ -349,33 +349,33 @@ export function ArtifactFileDetail({ } finally { setIsInstalling(false); } - }, [threadId, filepath, isInstalling]); - - const handleSaveArtifact = useCallback(async () => { - if (!canEditArtifact || updateArtifact.isPending) { - return; - } - try { - await updateArtifact.mutateAsync({ - filepath, - threadId, - isMock, - content: draftContent, - }); - setHasLocalEdits(false); - toast.success("Markdown 已保存"); - } catch (error) { - console.error("Failed to save artifact:", error); - toast.error(error instanceof Error ? error.message : "Markdown 保存失败"); - } - }, [ - canEditArtifact, - draftContent, - filepath, - isMock, - threadId, - updateArtifact, - ]); + }, [threadId, filepath, isInstalling]); + + const handleSaveArtifact = useCallback(async () => { + if (!canEditArtifact || updateArtifact.isPending) { + return; + } + try { + await updateArtifact.mutateAsync({ + filepath, + threadId, + isMock, + content: draftContent, + }); + setHasLocalEdits(false); + toast.success("Markdown 已保存"); + } catch (error) { + console.error("Failed to save artifact:", error); + toast.error(error instanceof Error ? error.message : "Markdown 保存失败"); + } + }, [ + canEditArtifact, + draftContent, + filepath, + isMock, + threadId, + updateArtifact, + ]); return ( @@ -452,19 +452,19 @@ export function ArtifactFileDetail({
)} -
- - {canEditArtifact && ( - - )} - {!isWriteFile && filepath.endsWith(".skill") && ( - +
+ + {canEditArtifact && ( + + )} + {!isWriteFile && filepath.endsWith(".skill") && ( +
- - {isApplyingEdit && ( -
- - 正在改写 Markdown -
- )} - {showResumeAutoScroll && ( -
+ )} {!isCodeFile && (binaryPreviewUrl ? (