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

No files matched your search

@@ -0,0 +1,604 @@
"use client";
import { Download, Loader2 } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
downloadToolMetricsExcel,
TOOL_ERROR_CATEGORIES,
TOOL_ERROR_CATEGORY_LABELS,
type ToolMetric,
type ToolMetricsFilters,
} from "@/core/tool-metrics/api";
import { useToolMetrics, useToolMetricsSummary } from "@/core/tool-metrics/hooks";
import { cn } from "@/lib/utils";
const PAGE_SIZE = 50;
type StatusFilter = "all" | "success" | "error";
type KindFilter = "skill" | "all";
function displayUser(row: { user_name?: string | null; user_id?: string | null }): string {
if (row.user_name && row.user_name.trim()) return row.user_name.trim();
const id = (row.user_id ?? "").trim();
return id || "—";
}
function formatDate(value?: string | null): string {
if (!value) return "—";
try {
return new Date(value).toLocaleString();
} catch {
return value;
}
}
function formatDuration(value?: number | null): string {
if (value === null || value === undefined || Number.isNaN(value)) return "—";
if (value === 0) return "0 ms";
if (value < 1000) return `${value} ms`;
if (value < 60_000) return `${(value / 1000).toFixed(1)} s`;
const minutes = Math.floor(value / 60_000);
const seconds = Math.round((value % 60_000) / 1000);
return `${minutes}m ${seconds}s`;
}
function categoryLabel(category?: string | null): string {
if (!category) return "—";
return TOOL_ERROR_CATEGORY_LABELS[category] ?? category;
}
export function ToolMetricsPanel() {
const [page, setPage] = useState(0);
const [tool, setTool] = useState("");
const [user, setUser] = useState("");
const [skill, setSkill] = useState("");
// Default to skill-only — the table is otherwise flooded with low-level
// bash / file-op calls. "全部" reveals every tool invocation.
const [kind, setKind] = useState<KindFilter>("skill");
const [status, setStatus] = useState<StatusFilter>("all");
const [category, setCategory] = useState<string>("all");
const [since, setSince] = useState("");
const [until, setUntil] = useState("");
const [exporting, setExporting] = useState(false);
const [detailRow, setDetailRow] = useState<ToolMetric | null>(null);
const filters = useMemo<ToolMetricsFilters>(() => {
const out: ToolMetricsFilters = {};
if (tool.trim()) out.tool_name = tool.trim();
if (user.trim()) out.user_name = user.trim();
if (skill.trim()) out.skill_name = skill.trim();
if (kind !== "all") out.kind = kind;
if (status !== "all") out.status = status;
if (category !== "all") out.error_category = category;
if (since) out.since = new Date(since).toISOString();
if (until) out.until = new Date(until).toISOString();
return out;
}, [tool, user, skill, kind, status, category, since, until]);
const query = useMemo(
() => ({ ...filters, limit: PAGE_SIZE, offset: page * PAGE_SIZE }),
[filters, page],
);
const { data, isLoading, error, isFetching } = useToolMetrics(query);
// The summary ignores the status/category filter so the breakdown always
// reflects the full failure picture for the chosen tool/user/time window.
const summaryFilters = useMemo<ToolMetricsFilters>(() => {
const out: ToolMetricsFilters = {};
if (tool.trim()) out.tool_name = tool.trim();
if (user.trim()) out.user_name = user.trim();
if (skill.trim()) out.skill_name = skill.trim();
if (kind !== "all") out.kind = kind;
if (since) out.since = new Date(since).toISOString();
if (until) out.until = new Date(until).toISOString();
return out;
}, [tool, user, skill, kind, since, until]);
const { data: summary } = useToolMetricsSummary(summaryFilters);
const handleExport = async () => {
setExporting(true);
try {
await downloadToolMetricsExcel(filters);
toast.success("已导出为 Excel");
} catch (e) {
toast.error(e instanceof Error ? e.message : "导出失败");
} finally {
setExporting(false);
}
};
const resetFilters = () => {
setTool("");
setUser("");
setSkill("");
setKind("skill");
setStatus("all");
setCategory("all");
setSince("");
setUntil("");
setPage(0);
};
const items = data?.items ?? [];
const total = data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const errorRate =
summary && summary.total > 0
? ((summary.error_total / summary.total) * 100).toFixed(1)
: "0.0";
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h2 className="text-lg font-semibold">工具调用统计</h2>
<p className="text-muted-foreground mt-1 text-sm">
记录每一次工具 / 技能 / MCP 调用,并对失败进行归类:检索为空、IP
被封、限流、超时、鉴权失败等。后台无条件记录。
</p>
</div>
<Button onClick={handleExport} disabled={exporting}>
{exporting ? (
<Loader2 className="mr-1 size-4 animate-spin" />
) : (
<Download className="mr-1 size-4" />
)}
导出 Excel
</Button>
</div>
{/* Summary strip */}
<div className="flex flex-wrap items-center gap-2 rounded-xl border bg-white p-4">
<div className="mr-4 flex flex-col">
<span className="text-muted-foreground text-xs">总调用</span>
<span className="text-lg font-semibold tabular-nums">
{summary?.total ?? "—"}
</span>
</div>
<div className="mr-4 flex flex-col">
<span className="text-muted-foreground text-xs">失败数</span>
<span className="text-destructive text-lg font-semibold tabular-nums">
{summary?.error_total ?? "—"}
</span>
</div>
<div className="mr-4 flex flex-col">
<span className="text-muted-foreground text-xs">失败率</span>
<span className="text-lg font-semibold tabular-nums">{errorRate}%</span>
</div>
<div className="flex flex-1 flex-wrap items-center gap-1.5">
{summary && Object.keys(summary.by_category).length === 0 && (
<span className="text-muted-foreground text-sm">暂无失败记录 🎉</span>
)}
{summary &&
Object.entries(summary.by_category)
.sort((a, b) => b[1] - a[1])
.map(([cat, count]) => (
<button
key={cat}
type="button"
onClick={() => {
setCategory(cat);
setStatus("error");
setPage(0);
}}
className="hover:bg-muted inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs"
>
<span>{categoryLabel(cat)}</span>
<span className="text-destructive font-semibold tabular-nums">
{count}
</span>
</button>
))}
</div>
</div>
{/* Per-skill breakdown — answers "was skill X invoked, and did it succeed". */}
{summary && summary.by_skill.length > 0 && (
<div className="rounded-xl border bg-white p-4">
<h3 className="mb-2 text-sm font-medium">各技能调用情况</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-muted-foreground">
<tr>
<th className="px-2 py-1 text-left text-xs font-medium">技能名称</th>
<th className="px-2 py-1 text-right text-xs font-medium">调用次数</th>
<th className="px-2 py-1 text-right text-xs font-medium">成功</th>
<th className="px-2 py-1 text-right text-xs font-medium">失败</th>
<th className="px-2 py-1 text-right text-xs font-medium">成功率</th>
</tr>
</thead>
<tbody>
{summary.by_skill.map((s) => {
const rate =
s.total > 0 ? ((s.success / s.total) * 100).toFixed(0) : "0";
return (
<tr
key={s.skill_name}
className="hover:bg-muted cursor-pointer border-t"
title="点击只看该技能"
onClick={() => {
setSkill(s.skill_name);
setPage(0);
}}
>
<td className="px-2 py-1.5 font-medium">{s.skill_name}</td>
<td className="px-2 py-1.5 text-right tabular-nums">{s.total}</td>
<td className="px-2 py-1.5 text-right tabular-nums text-green-600">
{s.success}
</td>
<td className="text-destructive px-2 py-1.5 text-right tabular-nums">
{s.error}
</td>
<td className="px-2 py-1.5 text-right tabular-nums">{rate}%</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{summary && kind === "skill" && summary.by_skill.length === 0 && (
<div className="text-muted-foreground rounded-xl border bg-white p-4 text-sm">
当前筛选范围内没有检测到任何技能调用记录。技能只有在被 Agent 通过
<code className="bg-muted mx-1 rounded px-1">skill_view</code>
读取(即真正"调用")时才会被记录;仅在系统提示里被列出、但未被读取的技能不会计入。
</div>
)}
{/* Filters */}
<div className="grid gap-3 rounded-xl border bg-white p-4 md:grid-cols-3 lg:grid-cols-6">
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs">调用类型</label>
<Select
value={kind}
onValueChange={(value) => {
setKind(value as KindFilter);
setPage(0);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="skill">技能(Agent Skill)</SelectItem>
<SelectItem value="all">全部工具调用</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs">技能名称</label>
<Input
value={skill}
placeholder="按技能名筛选"
onChange={(e) => {
setSkill(e.target.value);
setPage(0);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs">工具名</label>
<Input
value={tool}
placeholder="按工具名筛选"
onChange={(e) => {
setTool(e.target.value);
setPage(0);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs">用户名</label>
<Input
value={user}
placeholder="@ 前部分"
onChange={(e) => {
setUser(e.target.value);
setPage(0);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs">状态</label>
<Select
value={status}
onValueChange={(value) => {
setStatus(value as StatusFilter);
setPage(0);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部</SelectItem>
<SelectItem value="success">成功</SelectItem>
<SelectItem value="error">失败</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs">错误类型</label>
<Select
value={category}
onValueChange={(value) => {
setCategory(value);
setPage(0);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部</SelectItem>
{TOOL_ERROR_CATEGORIES.map((cat) => (
<SelectItem key={cat} value={cat}>
{categoryLabel(cat)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs">开始时间</label>
<Input
type="datetime-local"
value={since}
onChange={(e) => {
setSince(e.target.value);
setPage(0);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs">结束时间</label>
<Input
type="datetime-local"
value={until}
onChange={(e) => {
setUntil(e.target.value);
setPage(0);
}}
/>
</div>
<div className="flex justify-end lg:col-span-6">
<Button variant="outline" size="sm" onClick={resetFilters}>
清空筛选
</Button>
</div>
</div>
{/* Table */}
<div className="rounded-xl border bg-white">
<div className="overflow-x-auto">
<table className="w-full min-w-[960px] text-sm">
<thead className="bg-muted/60 text-muted-foreground">
<tr>
<Th>时间</Th>
<Th>用户</Th>
<Th>Agent</Th>
<Th>技能</Th>
<Th>工具</Th>
<Th className="text-right">耗时</Th>
<Th>状态</Th>
<Th>错误类型</Th>
<Th>错误详情</Th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={9} className="py-10 text-center">
<Loader2 className="text-muted-foreground inline size-5 animate-spin" />
</td>
</tr>
)}
{!isLoading && error && (
<tr>
<td colSpan={9} className="text-destructive py-10 text-center text-sm">
加载失败:{error.message}
</td>
</tr>
)}
{!isLoading && !error && items.length === 0 && (
<tr>
<td colSpan={9} className="text-muted-foreground py-10 text-center text-sm">
暂无符合条件的调用记录。
</td>
</tr>
)}
{items.map((row) => (
<tr key={row.id} className="border-t">
<Td>{formatDate(row.created_at)}</Td>
<Td title={row.user_id ?? undefined}>{displayUser(row)}</Td>
<Td>{row.agent_name ?? "—"}</Td>
<Td className="font-medium">
{row.skill_name ? (
<button
type="button"
onClick={() => {
setSkill(row.skill_name as string);
setPage(0);
}}
className="hover:text-foreground underline-offset-2 hover:underline"
title="点击只看该技能"
>
{row.skill_name}
</button>
) : (
<span className="text-muted-foreground">—</span>
)}
</Td>
<Td className="font-mono text-xs">{row.tool_name ?? "—"}</Td>
<Td className="text-right tabular-nums" title={`${row.duration_ms} ms`}>
{formatDuration(row.duration_ms)}
</Td>
<Td>
<Badge variant={row.status === "success" ? "default" : "destructive"}>
{row.status === "success" ? "成功" : "失败"}
</Badge>
</Td>
<Td>
{row.error_category ? (
<Badge variant="outline">{categoryLabel(row.error_category)}</Badge>
) : (
"—"
)}
</Td>
<Td className="max-w-[300px]">
{row.error_message ? (
<button
type="button"
onClick={() => setDetailRow(row)}
className="text-muted-foreground hover:text-foreground block max-w-full truncate text-left underline-offset-2 hover:underline"
title="点击查看完整报错"
>
{row.error_message}
</button>
) : (
<span className="text-muted-foreground">—</span>
)}
</Td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-between border-t px-4 py-2 text-xs">
<span className="text-muted-foreground">
{isFetching && !isLoading ? "更新中..." : `共 ${total} 条`}
</span>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={page <= 0 || isLoading}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
上一页
</Button>
<span className="text-muted-foreground">
第 {page + 1} / {totalPages} 页
</span>
<Button
size="sm"
variant="outline"
disabled={page + 1 >= totalPages || isLoading}
onClick={() => setPage((p) => p + 1)}
>
下一页
</Button>
</div>
</div>
</div>
{/* Full error detail popup */}
<Dialog
open={detailRow !== null}
onOpenChange={(open) => {
if (!open) setDetailRow(null);
}}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>工具调用报错详情</DialogTitle>
</DialogHeader>
{detailRow && (
<div className="flex flex-col gap-3 text-sm">
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
<DetailField label="时间" value={formatDate(detailRow.created_at)} />
<DetailField label="用户" value={displayUser(detailRow)} />
<DetailField label="工具" value={detailRow.tool_name ?? "—"} mono />
<DetailField label="技能" value={detailRow.skill_name ?? "—"} />
<DetailField label="Agent" value={detailRow.agent_name ?? "—"} />
<DetailField
label="状态"
value={detailRow.status === "success" ? "成功" : "失败"}
/>
<DetailField
label="错误类型"
value={categoryLabel(detailRow.error_category)}
/>
<DetailField label="耗时" value={formatDuration(detailRow.duration_ms)} />
<DetailField label="Thread" value={detailRow.thread_id ?? "—"} mono />
</div>
<div>
<span className="text-muted-foreground text-xs">完整报错信息</span>
<pre className="bg-muted mt-1 max-h-72 overflow-auto whitespace-pre-wrap break-all rounded-md p-3 text-xs">
{detailRow.error_message ?? "(无)"}
</pre>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}
function DetailField({
label,
value,
mono,
}: {
label: string;
value: string;
mono?: boolean;
}) {
return (
<div className="flex flex-col">
<span className="text-muted-foreground text-xs">{label}</span>
<span className={cn("break-all", mono && "font-mono text-xs")}>{value}</span>
</div>
);
}
function Th({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<th className={`px-3 py-2 text-left text-xs font-medium ${className ?? ""}`}>
{children}
</th>
);
}
function Td({
children,
className,
title,
}: {
children: React.ReactNode;
className?: string;
title?: string;
}) {
return (
<td className={cn("px-3 py-2 align-top text-sm", className)} title={title}>
{children}
</td>
);
}
@@ -18,9 +18,9 @@ export function AgentGallery() {
const router = useRouter();
const [activeTab, setActiveTab] = useState<"all" | "mine">("mine");
const [search, setSearch] = useState("");
const [tagId, setTagId] = useState<string | null>(null);
const [tagIds, setTagIds] = useState<string[]>([]);
// Name + tag filtering is resolved server-side via the /api/agents query.
const { agents, isLoading } = useAgents({ search, tagId });
const { agents, isLoading } = useAgents({ search, tagIds });
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
@@ -87,7 +87,7 @@ export function AgentGallery() {
onValueChange={(v) => setActiveTab(v as "all" | "mine")}
className="flex flex-1 flex-col overflow-hidden"
>
<div className="mx-6 mt-4 flex items-center justify-between gap-4">
<div className="mx-6 mt-4 flex flex-col gap-3">
<TabsList className="w-auto self-start">
<TabsTrigger value="mine">我的</TabsTrigger>
<TabsTrigger value="all">广场</TabsTrigger>
@@ -95,9 +95,8 @@ export function AgentGallery() {
<TagSearchBar
targetType="agent"
onSearchChange={setSearch}
tagId={tagId}
onTagIdChange={setTagId}
className="w-[420px] max-w-[50%]"
tagIds={tagIds}
onTagIdsChange={setTagIds}
/>
</div>
@@ -2,16 +2,16 @@
import {
ArrowDownIcon,
Code2Icon,
CopyIcon,
DownloadIcon,
EyeIcon,
LoaderIcon,
PackageIcon,
SaveIcon,
SquareArrowOutUpRightIcon,
XIcon,
} from "lucide-react";
Code2Icon,
CopyIcon,
DownloadIcon,
EyeIcon,
LoaderIcon,
PackageIcon,
SaveIcon,
SquareArrowOutUpRightIcon,
XIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Streamdown } from "streamdown";
@@ -34,14 +34,14 @@ import {
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { CodeEditor } from "@/components/workspace/code-editor";
import { apiFetch } from "@/core/api";
import {
downloadArtifactFile,
openArtifactInNewTab,
} from "@/core/artifacts/download";
import {
useArtifactContent,
useUpdateArtifactContent,
} from "@/core/artifacts/hooks";
import {
downloadArtifactFile,
openArtifactInNewTab,
} from "@/core/artifacts/download";
import {
useArtifactContent,
useUpdateArtifactContent,
} from "@/core/artifacts/hooks";
import { urlOfArtifact } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks";
import { installSkill } from "@/core/skills/api";
@@ -89,7 +89,7 @@ export function ArtifactFileDetail({
}) {
const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts();
const { isMock, thread } = useThread();
const { isMock, thread, publicShareCode } = useThread();
const isWriteFile = useMemo(() => {
return filepathFromProps.startsWith("write-file:");
}, [filepathFromProps]);
@@ -117,18 +117,18 @@ export function ArtifactFileDetail({
const isSupportPreview = useMemo(() => {
return language === "html" || language === "markdown";
}, [language]);
const { content, isApplyingEdit } = useArtifactContent({
threadId,
filepath: filepathFromProps,
enabled: isCodeFile && !isWriteFile,
});
const canEditArtifact =
language === "markdown" && !isWriteFile && !isSkillFile && !isMock;
const [draftContent, setDraftContent] = useState("");
const [hasLocalEdits, setHasLocalEdits] = useState(false);
const updateArtifact = useUpdateArtifactContent();
const displayContent = canEditArtifact ? draftContent : (content ?? "");
const { content, isApplyingEdit } = useArtifactContent({
threadId,
filepath: filepathFromProps,
enabled: isCodeFile && !isWriteFile,
});
const canEditArtifact =
language === "markdown" && !isWriteFile && !isSkillFile && !isMock;
const [draftContent, setDraftContent] = useState("");
const [hasLocalEdits, setHasLocalEdits] = useState(false);
const updateArtifact = useUpdateArtifactContent();
const displayContent = canEditArtifact ? draftContent : (content ?? "");
const isHtmlStreaming = language === "html" && thread.isLoading;
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
const fileTypeLabel = useMemo(() => {
@@ -148,20 +148,20 @@ export function ArtifactFileDetail({
const [binaryPreviewUrl, setBinaryPreviewUrl] = useState<string | null>(
null,
);
const artifactContentRef = useRef<HTMLDivElement | null>(null);
const resumeButtonTimeoutRef = useRef<number | null>(null);
useEffect(() => {
setDraftContent(content ?? "");
setHasLocalEdits(false);
}, [filepathFromProps]);
useEffect(() => {
if (!canEditArtifact || hasLocalEdits) {
return;
}
setDraftContent(content ?? "");
}, [canEditArtifact, content, hasLocalEdits]);
const artifactContentRef = useRef<HTMLDivElement | null>(null);
const resumeButtonTimeoutRef = useRef<number | null>(null);
useEffect(() => {
setDraftContent(content ?? "");
setHasLocalEdits(false);
}, [filepathFromProps]);
useEffect(() => {
if (!canEditArtifact || hasLocalEdits) {
return;
}
setDraftContent(content ?? "");
}, [canEditArtifact, content, hasLocalEdits]);
useEffect(() => {
setAllowAutoViewSwitch(true);
@@ -279,7 +279,7 @@ export function ArtifactFileDetail({
(async () => {
try {
const res = await apiFetch(
urlOfArtifact({ filepath, threadId, isMock }),
urlOfArtifact({ filepath, threadId, isMock, publicShareCode }),
);
if (cancelled || !res.ok) return;
const blob = await res.blob();
@@ -304,7 +304,7 @@ export function ArtifactFileDetail({
return null;
});
};
}, [filepath, threadId, isMock, isCodeFile]);
}, [filepath, threadId, isMock, isCodeFile, publicShareCode]);
const handlePauseAutoScroll = useCallback(() => {
if (!thread.isLoading || !autoScrollEnabled) {
@@ -329,7 +329,7 @@ export function ArtifactFileDetail({
}, 1600);
}, [scrollArtifactToBottom]);
const handleInstallSkill = useCallback(async () => {
const handleInstallSkill = useCallback(async () => {
if (isInstalling) return;
setIsInstalling(true);
@@ -349,33 +349,33 @@ export function ArtifactFileDetail({
} finally {
setIsInstalling(false);
}
}, [threadId, filepath, isInstalling]);
const handleSaveArtifact = useCallback(async () => {
if (!canEditArtifact || updateArtifact.isPending) {
return;
}
try {
await updateArtifact.mutateAsync({
filepath,
threadId,
isMock,
content: draftContent,
});
setHasLocalEdits(false);
toast.success("Markdown 已保存");
} catch (error) {
console.error("Failed to save artifact:", error);
toast.error(error instanceof Error ? error.message : "Markdown 保存失败");
}
}, [
canEditArtifact,
draftContent,
filepath,
isMock,
threadId,
updateArtifact,
]);
}, [threadId, filepath, isInstalling]);
const handleSaveArtifact = useCallback(async () => {
if (!canEditArtifact || updateArtifact.isPending) {
return;
}
try {
await updateArtifact.mutateAsync({
filepath,
threadId,
isMock,
content: draftContent,
});
setHasLocalEdits(false);
toast.success("Markdown 已保存");
} catch (error) {
console.error("Failed to save artifact:", error);
toast.error(error instanceof Error ? error.message : "Markdown 保存失败");
}
}, [
canEditArtifact,
draftContent,
filepath,
isMock,
threadId,
updateArtifact,
]);
return (
<Artifact className={cn(className)}>
@@ -452,19 +452,19 @@ export function ArtifactFileDetail({
</div>
)}
</div>
<div className="flex items-center justify-end gap-2">
<ArtifactActions className="bg-muted/45 rounded-full border border-black/6 p-1 dark:border-white/8">
{canEditArtifact && (
<ArtifactAction
icon={updateArtifact.isPending ? LoaderIcon : SaveIcon}
label="保存 Markdown"
tooltip={hasLocalEdits ? "保存 Markdown 修改" : "Markdown 已保存"}
disabled={!hasLocalEdits || updateArtifact.isPending}
onClick={handleSaveArtifact}
/>
)}
{!isWriteFile && filepath.endsWith(".skill") && (
<Tooltip content={t.toolCalls.skillInstallTooltip}>
<div className="flex items-center justify-end gap-2">
<ArtifactActions className="bg-muted/45 rounded-full border border-black/6 p-1 dark:border-white/8">
{canEditArtifact && (
<ArtifactAction
icon={updateArtifact.isPending ? LoaderIcon : SaveIcon}
label="保存 Markdown"
tooltip={hasLocalEdits ? "保存 Markdown 修改" : "Markdown 已保存"}
disabled={!hasLocalEdits || updateArtifact.isPending}
onClick={handleSaveArtifact}
/>
)}
{!isWriteFile && filepath.endsWith(".skill") && (
<Tooltip content={t.toolCalls.skillInstallTooltip}>
<ArtifactAction
icon={isInstalling ? LoaderIcon : PackageIcon}
label={t.common.install}
@@ -488,6 +488,7 @@ export function ArtifactFileDetail({
filepath,
threadId,
isMock,
publicShareCode,
});
} catch (error) {
console.error(error);
@@ -528,6 +529,7 @@ export function ArtifactFileDetail({
filepath,
threadId,
isMock,
publicShareCode,
});
} catch (error) {
console.error(error);
@@ -550,19 +552,19 @@ export function ArtifactFileDetail({
</ArtifactActions>
</div>
</ArtifactHeader>
<ArtifactContent
ref={artifactContentRef}
className="relative p-0"
onWheelCapture={handlePauseAutoScroll}
>
{isApplyingEdit && (
<div className="absolute right-4 bottom-4 z-20 inline-flex items-center gap-2 rounded-full border border-[#d8b878] bg-[#fff3cf]/95 px-3 py-1.5 text-xs text-[#6d5625] shadow-sm">
<span className="h-3.5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
正在改写 Markdown
</div>
)}
{showResumeAutoScroll && (
<button
<ArtifactContent
ref={artifactContentRef}
className="relative p-0"
onWheelCapture={handlePauseAutoScroll}
>
{isApplyingEdit && (
<div className="absolute right-4 bottom-4 z-20 inline-flex items-center gap-2 rounded-full border border-[#d8b878] bg-[#fff3cf]/95 px-3 py-1.5 text-xs text-[#6d5625] shadow-sm">
<span className="h-3.5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
正在改写 Markdown
</div>
)}
{showResumeAutoScroll && (
<button
type="button"
onClick={handleResumeAutoScroll}
className="animate-[sandbox-scroll-button-in_220ms_ease-out] absolute inset-x-0 top-1/2 z-20 mx-auto flex w-fit -translate-y-1/2 items-center gap-2 rounded-full border border-white/35 bg-black/28 px-4 py-2 text-sm font-medium text-white shadow-[0_20px_40px_-24px_rgba(15,23,42,0.7)] backdrop-blur-md transition hover:bg-black/40"
@@ -574,31 +576,31 @@ export function ArtifactFileDetail({
{isSupportPreview &&
viewMode === "preview" &&
(language === "markdown" || language === "html") && (
<ArtifactFilePreview
content={displayContent}
language={language ?? "text"}
animated={showPreviewSwitchPulse}
activeCursor={isApplyingEdit}
/>
)}
<ArtifactFilePreview
content={displayContent}
language={language ?? "text"}
animated={showPreviewSwitchPulse}
activeCursor={isApplyingEdit}
/>
)}
{isCodeFile && viewMode === "code" && (
<div className="size-full">
<CodeEditor
className="size-full resize-none rounded-none border-none bg-transparent"
value={displayContent ?? ""}
readonly={!canEditArtifact}
onChange={
canEditArtifact
? (value) => {
setDraftContent(value);
setHasLocalEdits(true);
}
: undefined
}
streamingReadonlyFallback={!canEditArtifact}
/>
</div>
)}
<CodeEditor
className="size-full resize-none rounded-none border-none bg-transparent"
value={displayContent ?? ""}
readonly={!canEditArtifact}
onChange={
canEditArtifact
? (value) => {
setDraftContent(value);
setHasLocalEdits(true);
}
: undefined
}
streamingReadonlyFallback={!canEditArtifact}
/>
</div>
)}
{!isCodeFile &&
(binaryPreviewUrl ? (
<iframe className="size-full bg-white" src={binaryPreviewUrl} />
@@ -613,17 +615,17 @@ export function ArtifactFileDetail({
);
}
export function ArtifactFilePreview({
content,
language,
animated = false,
activeCursor = false,
}: {
content: string;
language: string;
animated?: boolean;
activeCursor?: boolean;
}) {
export function ArtifactFilePreview({
content,
language,
animated = false,
activeCursor = false,
}: {
content: string;
language: string;
animated?: boolean;
activeCursor?: boolean;
}) {
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
useEffect(() => {
@@ -654,15 +656,15 @@ export function ArtifactFilePreview({
className="size-full"
{...streamdownPlugins}
components={{ a: ArtifactLink }}
>
{content ?? ""}
</Streamdown>
{activeCursor && (
<span className="mt-2 inline-block h-5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
)}
</div>
);
}
>
{content ?? ""}
</Streamdown>
{activeCursor && (
<span className="mt-2 inline-block h-5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
)}
</div>
);
}
if (language === "html") {
return (
@@ -33,7 +33,7 @@ export function ArtifactFileList({
threadId: string;
}) {
const { t } = useI18n();
const { isMock } = useThread();
const { isMock, publicShareCode } = useThread();
const { select: selectArtifact, setOpen } = useArtifacts();
const [installingFile, setInstallingFile] = useState<string | null>(null);
const [downloadingFile, setDownloadingFile] = useState<string | null>(null);
@@ -81,7 +81,7 @@ export function ArtifactFileList({
if (downloadingFile) return;
setDownloadingFile(filepath);
try {
await downloadArtifactFile({ filepath, threadId, isMock });
await downloadArtifactFile({ filepath, threadId, isMock, publicShareCode });
} catch (error) {
console.error(error);
toast.error(
@@ -91,7 +91,7 @@ export function ArtifactFileList({
setDownloadingFile(null);
}
},
[threadId, isMock, downloadingFile],
[threadId, isMock, publicShareCode, downloadingFile],
);
return (
@@ -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")}