Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into xuhd
This commit is contained in:
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")}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
/** A conversation thread owned by some user (admin view). */
|
||||
export interface AdminThread {
|
||||
thread_id: string;
|
||||
display_name: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One conversation message, as serialized from the LangGraph checkpoint —
|
||||
* a LangChain message dump: ``type`` is "human" | "ai" | "tool" | "system",
|
||||
* ``content`` is a string or a content-block list.
|
||||
*/
|
||||
export interface AdminMessage {
|
||||
type?: string;
|
||||
content?: unknown;
|
||||
id?: string | null;
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
/** One memory bucket: the global file or a per-agent file. */
|
||||
export interface AdminMemoryBucket {
|
||||
name: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AdminMemoryResponse {
|
||||
user_id: string;
|
||||
buckets: AdminMemoryBucket[];
|
||||
}
|
||||
|
||||
const base = () => `${getBackendBaseURL()}/api/admin/users`;
|
||||
|
||||
async function parse<T>(res: Response, what: string): Promise<T> {
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `${what} (HTTP ${res.status})`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function listUserThreads(userId: string): Promise<AdminThread[]> {
|
||||
return parse(
|
||||
await apiFetch(`${base()}/${encodeURIComponent(userId)}/threads?limit=200`),
|
||||
"Failed to load threads",
|
||||
);
|
||||
}
|
||||
|
||||
export async function listUserThreadMessages(
|
||||
userId: string,
|
||||
threadId: string,
|
||||
): Promise<AdminMessage[]> {
|
||||
return parse(
|
||||
await apiFetch(
|
||||
`${base()}/${encodeURIComponent(userId)}/threads/${encodeURIComponent(threadId)}/messages`,
|
||||
),
|
||||
"Failed to load messages",
|
||||
);
|
||||
}
|
||||
|
||||
export async function getUserMemory(
|
||||
userId: string,
|
||||
): Promise<AdminMemoryResponse> {
|
||||
return parse(
|
||||
await apiFetch(`${base()}/${encodeURIComponent(userId)}/memory`),
|
||||
"Failed to load memory",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { getUserMemory, listUserThreadMessages, listUserThreads } from "./api";
|
||||
|
||||
/** Threads owned by a given user. Disabled until a user is picked. */
|
||||
export function useUserThreads(userId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["admin-activity", "threads", userId],
|
||||
queryFn: () => listUserThreads(userId!),
|
||||
enabled: !!userId,
|
||||
});
|
||||
}
|
||||
|
||||
/** Messages of a given thread. Disabled until a thread is picked. */
|
||||
export function useUserThreadMessages(
|
||||
userId: string | null,
|
||||
threadId: string | null,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["admin-activity", "messages", userId, threadId],
|
||||
queryFn: () => listUserThreadMessages(userId!, threadId!),
|
||||
enabled: !!userId && !!threadId,
|
||||
});
|
||||
}
|
||||
|
||||
/** All memory buckets of a given user. Disabled until a user is picked. */
|
||||
export function useUserMemory(userId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["admin-activity", "memory", userId],
|
||||
queryFn: () => getUserMemory(userId!),
|
||||
enabled: !!userId,
|
||||
});
|
||||
}
|
||||
@@ -17,13 +17,13 @@ export class AgentNameCheckError extends Error {
|
||||
|
||||
export interface ListAgentsParams {
|
||||
search?: string;
|
||||
tagId?: string | null;
|
||||
tagIds?: string[];
|
||||
}
|
||||
|
||||
export async function listAgents(params: ListAgentsParams = {}): Promise<Agent[]> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.search) qs.set("search", params.search);
|
||||
if (params.tagId) qs.set("tag_id", params.tagId);
|
||||
for (const id of params.tagIds ?? []) qs.append("tag_ids", id);
|
||||
const query = qs.toString();
|
||||
const res = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/agents${query ? `?${query}` : ""}`,
|
||||
|
||||
@@ -14,12 +14,12 @@ import {
|
||||
} from "./api";
|
||||
import type { CreateAgentRequest, UpdateAgentRequest } from "./types";
|
||||
|
||||
export function useAgents(params: { search?: string; tagId?: string | null } = {}) {
|
||||
export function useAgents(params: { search?: string; tagIds?: string[] } = {}) {
|
||||
const search = params.search ?? "";
|
||||
const tagId = params.tagId ?? null;
|
||||
const tagIds = params.tagIds ?? [];
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["agents", search, tagId],
|
||||
queryFn: () => listAgents({ search, tagId }),
|
||||
queryKey: ["agents", search, [...tagIds].sort()],
|
||||
queryFn: () => listAgents({ search, tagIds }),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
return { agents: data ?? [], isLoading, error };
|
||||
|
||||
@@ -43,12 +43,14 @@ export async function downloadArtifactFile({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
publicShareCode,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
isMock?: boolean;
|
||||
publicShareCode?: string;
|
||||
}): Promise<void> {
|
||||
const url = urlOfArtifact({ filepath, threadId, isMock, download: true });
|
||||
const url = urlOfArtifact({ filepath, threadId, isMock, publicShareCode, download: true });
|
||||
const res = await apiFetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(await errorMessageFromResponse(res));
|
||||
@@ -73,12 +75,14 @@ export async function openArtifactInNewTab({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
publicShareCode,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
isMock?: boolean;
|
||||
publicShareCode?: string;
|
||||
}): Promise<void> {
|
||||
const url = urlOfArtifact({ filepath, threadId, isMock, download: false });
|
||||
const url = urlOfArtifact({ filepath, threadId, isMock, publicShareCode, download: false });
|
||||
const res = await apiFetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(await errorMessageFromResponse(res));
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useThread } from "@/components/workspace/messages/context";
|
||||
|
||||
|
||||
import { useThread } from "@/components/workspace/messages/context";
|
||||
|
||||
import { loadArtifactContent, loadArtifactContentFromToolCall } from "./loader";
|
||||
import { updateArtifactContent } from "./api";
|
||||
|
||||
@@ -60,32 +60,32 @@ function applyPendingTextEdit({
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useArtifactContent({
|
||||
filepath,
|
||||
threadId,
|
||||
enabled,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const isWriteFile = useMemo(() => {
|
||||
return filepath.startsWith("write-file:");
|
||||
}, [filepath]);
|
||||
const { thread, isMock } = useThread();
|
||||
const content = useMemo(() => {
|
||||
if (isWriteFile) {
|
||||
return loadArtifactContentFromToolCall({ url: filepath, thread });
|
||||
}
|
||||
return null;
|
||||
}, [filepath, isWriteFile, thread]);
|
||||
|
||||
|
||||
export function useArtifactContent({
|
||||
filepath,
|
||||
threadId,
|
||||
enabled,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const isWriteFile = useMemo(() => {
|
||||
return filepath.startsWith("write-file:");
|
||||
}, [filepath]);
|
||||
const { thread, isMock, publicShareCode } = useThread();
|
||||
const content = useMemo(() => {
|
||||
if (isWriteFile) {
|
||||
return loadArtifactContentFromToolCall({ url: filepath, thread });
|
||||
}
|
||||
return null;
|
||||
}, [filepath, isWriteFile, thread]);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["artifact", filepath, threadId, isMock],
|
||||
queryFn: () => {
|
||||
return loadArtifactContent({ filepath, threadId, isMock });
|
||||
},
|
||||
queryKey: ["artifact", filepath, threadId, isMock, publicShareCode],
|
||||
queryFn: () => {
|
||||
return loadArtifactContent({ filepath, threadId, isMock, publicShareCode });
|
||||
},
|
||||
enabled,
|
||||
refetchInterval: thread.isLoading ? 1000 : false,
|
||||
// Cache artifact content for 5 minutes to avoid repeated fetches (especially for .skill ZIP extraction)
|
||||
|
||||
@@ -9,16 +9,18 @@ export async function loadArtifactContent({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
publicShareCode,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
isMock?: boolean;
|
||||
publicShareCode?: string;
|
||||
}) {
|
||||
let enhancedFilepath = filepath;
|
||||
if (filepath.endsWith(".skill")) {
|
||||
enhancedFilepath = filepath + "/SKILL.md";
|
||||
}
|
||||
const url = urlOfArtifact({ filepath: enhancedFilepath, threadId, isMock });
|
||||
const url = urlOfArtifact({ filepath: enhancedFilepath, threadId, isMock, publicShareCode });
|
||||
const response = await apiFetch(url);
|
||||
const text = await response.text();
|
||||
return { content: text, url };
|
||||
|
||||
@@ -6,12 +6,22 @@ export function urlOfArtifact({
|
||||
threadId,
|
||||
download = false,
|
||||
isMock = false,
|
||||
publicShareCode,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
download?: boolean;
|
||||
isMock?: boolean;
|
||||
publicShareCode?: string;
|
||||
}) {
|
||||
// A public read-only share has no auth-gated thread route — its referenced
|
||||
// files are served by the unauthenticated public-share endpoint instead.
|
||||
if (publicShareCode) {
|
||||
const normalized = filepath.startsWith("/") ? filepath : `/${filepath}`;
|
||||
return `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(
|
||||
publicShareCode,
|
||||
)}/files${normalized}`;
|
||||
}
|
||||
if (isMock) {
|
||||
return `${getBackendBaseURL()}/mock/api/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`;
|
||||
}
|
||||
|
||||
@@ -31,13 +31,13 @@ export async function getSchedulerThread() {
|
||||
|
||||
export interface ListScheduledTasksParams {
|
||||
search?: string;
|
||||
tagId?: string | null;
|
||||
tagIds?: string[];
|
||||
}
|
||||
|
||||
export async function listScheduledTasks(params: ListScheduledTasksParams = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.search) qs.set("search", params.search);
|
||||
if (params.tagId) qs.set("tag_id", params.tagId);
|
||||
for (const id of params.tagIds ?? []) qs.append("tag_ids", id);
|
||||
const query = qs.toString();
|
||||
return parseResponse<ScheduledTask[]>(
|
||||
await apiFetch(`${baseUrl()}${query ? `?${query}` : ""}`),
|
||||
|
||||
@@ -42,13 +42,13 @@ export function useSchedulerThread() {
|
||||
}
|
||||
|
||||
export function useScheduledTasks(
|
||||
params: { search?: string; tagId?: string | null } = {},
|
||||
params: { search?: string; tagIds?: string[] } = {},
|
||||
) {
|
||||
const search = params.search ?? "";
|
||||
const tagId = params.tagId ?? null;
|
||||
const tagIds = params.tagIds ?? [];
|
||||
return useQuery({
|
||||
queryKey: ["scheduled-tasks", "list", search, tagId],
|
||||
queryFn: () => listScheduledTasks({ search, tagId }),
|
||||
queryKey: ["scheduled-tasks", "list", search, [...tagIds].sort()],
|
||||
queryFn: () => listScheduledTasks({ search, tagIds }),
|
||||
refetchOnWindowFocus: false,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
@@ -5,13 +5,13 @@ import type { Skill, SkillState } from "./type";
|
||||
|
||||
export interface ListSkillsParams {
|
||||
search?: string;
|
||||
tagId?: string | null;
|
||||
tagIds?: string[];
|
||||
}
|
||||
|
||||
export async function loadSkills(params: ListSkillsParams = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.search) qs.set("search", params.search);
|
||||
if (params.tagId) qs.set("tag_id", params.tagId);
|
||||
for (const id of params.tagIds ?? []) qs.append("tag_ids", id);
|
||||
const query = qs.toString();
|
||||
const skills = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills${query ? `?${query}` : ""}`,
|
||||
|
||||
@@ -21,12 +21,12 @@ import type { SkillState } from "./type";
|
||||
|
||||
import { loadSkills } from ".";
|
||||
|
||||
export function useSkills(params: { search?: string; tagId?: string | null } = {}) {
|
||||
export function useSkills(params: { search?: string; tagIds?: string[] } = {}) {
|
||||
const search = params.search ?? "";
|
||||
const tagId = params.tagId ?? null;
|
||||
const tagIds = params.tagIds ?? [];
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["skills", search, tagId],
|
||||
queryFn: () => loadSkills({ search, tagId }),
|
||||
queryKey: ["skills", search, [...tagIds].sort()],
|
||||
queryFn: () => loadSkills({ search, tagIds }),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
return { skills: data ?? [], isLoading, error };
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
/** Coarse failure buckets, mirrored from the backend classifier. */
|
||||
export const TOOL_ERROR_CATEGORIES = [
|
||||
"rate_limited",
|
||||
"ip_blocked",
|
||||
"auth_error",
|
||||
"timeout",
|
||||
"network_error",
|
||||
"not_found",
|
||||
"empty_result",
|
||||
"tool_error",
|
||||
] as const;
|
||||
|
||||
export type ToolErrorCategory = (typeof TOOL_ERROR_CATEGORIES)[number];
|
||||
|
||||
/** Human-readable Chinese labels for each failure bucket. */
|
||||
export const TOOL_ERROR_CATEGORY_LABELS: Record<string, string> = {
|
||||
rate_limited: "限流 / 频率超限",
|
||||
ip_blocked: "IP 被封 / 拒绝访问",
|
||||
auth_error: "鉴权失败",
|
||||
timeout: "超时",
|
||||
network_error: "网络错误",
|
||||
not_found: "资源不存在",
|
||||
empty_result: "检索为空",
|
||||
tool_error: "工具内部错误",
|
||||
};
|
||||
|
||||
export interface ToolMetric {
|
||||
id: string;
|
||||
created_at?: string | null;
|
||||
user_id?: string | null;
|
||||
user_name?: string | null;
|
||||
thread_id?: string | null;
|
||||
run_id?: string | null;
|
||||
agent_name?: string | null;
|
||||
tool_name?: string | null;
|
||||
/** The Agent Skill this call targets (skill tool calls only). */
|
||||
skill_name?: string | null;
|
||||
duration_ms: number;
|
||||
status: "success" | "error" | string;
|
||||
error_category?: string | null;
|
||||
error_message?: string | null;
|
||||
}
|
||||
|
||||
export interface ToolMetricsListResponse {
|
||||
items: ToolMetric[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
/** Per-skill aggregate — was the skill invoked, and how often did it succeed. */
|
||||
export interface SkillStat {
|
||||
skill_name: string;
|
||||
total: number;
|
||||
success: number;
|
||||
error: number;
|
||||
}
|
||||
|
||||
export interface ToolMetricsSummary {
|
||||
total: number;
|
||||
error_total: number;
|
||||
by_category: Record<string, number>;
|
||||
by_skill: SkillStat[];
|
||||
}
|
||||
|
||||
export interface ToolMetricsFilters {
|
||||
user_id?: string;
|
||||
user_name?: string;
|
||||
tool_name?: string;
|
||||
/** Coarse call kind: "skill" keeps only Agent-Skill invocations; omit for all. */
|
||||
kind?: "skill" | "all";
|
||||
/** Filter to one specific Agent Skill by name. */
|
||||
skill_name?: string;
|
||||
status?: "success" | "error";
|
||||
error_category?: string;
|
||||
since?: string;
|
||||
until?: string;
|
||||
}
|
||||
|
||||
export interface ToolMetricsListQuery extends ToolMetricsFilters {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
function buildQuery(filters: ToolMetricsListQuery): string {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
params.set(key, String(value));
|
||||
}
|
||||
const qs = params.toString();
|
||||
return qs ? `?${qs}` : "";
|
||||
}
|
||||
|
||||
export async function loadToolMetrics(
|
||||
query: ToolMetricsListQuery,
|
||||
): Promise<ToolMetricsListResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/admin/tool-metrics${buildQuery(query)}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
throw new Error(detail.detail ?? "Failed to load tool metrics");
|
||||
}
|
||||
return (await response.json()) as ToolMetricsListResponse;
|
||||
}
|
||||
|
||||
export async function loadToolMetricsSummary(
|
||||
filters: ToolMetricsFilters,
|
||||
): Promise<ToolMetricsSummary> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/admin/tool-metrics/summary${buildQuery(filters)}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
throw new Error(detail.detail ?? "Failed to load tool metrics summary");
|
||||
}
|
||||
return (await response.json()) as ToolMetricsSummary;
|
||||
}
|
||||
|
||||
export async function downloadToolMetricsExcel(
|
||||
filters: ToolMetricsFilters,
|
||||
): Promise<void> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/admin/tool-metrics/export${buildQuery(filters)}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
throw new Error(detail.detail ?? "Failed to export tool metrics");
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const filename = (() => {
|
||||
const header = response.headers.get("content-disposition") ?? "";
|
||||
const utf8 = /filename\*=UTF-8''([^;]+)/i.exec(header);
|
||||
if (utf8?.[1]) return decodeURIComponent(utf8[1]);
|
||||
const plain = /filename="?([^";]+)"?/i.exec(header);
|
||||
return plain?.[1] ?? `tool_call_metrics.xlsx`;
|
||||
})();
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.rel = "noopener";
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
loadToolMetrics,
|
||||
loadToolMetricsSummary,
|
||||
type ToolMetricsFilters,
|
||||
type ToolMetricsListQuery,
|
||||
} from "./api";
|
||||
|
||||
export function useToolMetrics(query: ToolMetricsListQuery) {
|
||||
return useQuery({
|
||||
queryKey: ["toolMetrics", query],
|
||||
queryFn: () => loadToolMetrics(query),
|
||||
refetchOnWindowFocus: false,
|
||||
placeholderData: (previous) => previous,
|
||||
});
|
||||
}
|
||||
|
||||
export function useToolMetricsSummary(filters: ToolMetricsFilters) {
|
||||
return useQuery({
|
||||
queryKey: ["toolMetricsSummary", filters],
|
||||
queryFn: () => loadToolMetricsSummary(filters),
|
||||
refetchOnWindowFocus: false,
|
||||
placeholderData: (previous) => previous,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ToolMetricsPanel } from "@/components/workspace/admin/tool-metrics-panel";
|
||||
import {
|
||||
downloadLlmMetricsExcel,
|
||||
type LlmMetricsFilters,
|
||||
@@ -70,7 +72,8 @@ function speedColor(tps?: number | null): string {
|
||||
return "#ef4444";
|
||||
}
|
||||
|
||||
export default function LlmMetricsPage() {
|
||||
/** The original LLM-call metrics view, now one tab of the metrics page. */
|
||||
function LlmMetricsPanel() {
|
||||
const [page, setPage] = useState(0);
|
||||
const [model, setModel] = useState("");
|
||||
const [user, setUser] = useState("");
|
||||
@@ -245,10 +248,10 @@ export default function LlmMetricsPage() {
|
||||
const colSpan = visibleColumns.length || 1;
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-4 px-6 py-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">大模型调用统计</h1>
|
||||
<h2 className="text-lg font-semibold">大模型调用统计</h2>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
记录每一次大模型的实际调用,包括 token 数、耗时、估算速度和失败原因。
|
||||
后台无条件记录,与前端速度指示器开关无关。
|
||||
@@ -456,3 +459,22 @@ function Td({
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin metrics page. Two tabs: LLM provider calls and tool / skill calls —
|
||||
* the latter surfaces retrieval/IP-block/rate-limit failures.
|
||||
*/
|
||||
export default function LlmMetricsPage() {
|
||||
const [tab, setTab] = useState<"llm" | "tool">("llm");
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-4 px-6 py-8">
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as "llm" | "tool")}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="llm">大模型调用</TabsTrigger>
|
||||
<TabsTrigger value="tool">工具调用</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
{tab === "llm" ? <LlmMetricsPanel /> : <ToolMetricsPanel />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,16 +3,65 @@
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||
import {
|
||||
ArtifactFileDetail,
|
||||
ArtifactsProvider,
|
||||
useArtifacts,
|
||||
} from "@/components/workspace/artifacts";
|
||||
import { MessageList } from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { SubtasksProvider } from "@/core/tasks/context";
|
||||
import { getPublicShareSnapshot } from "@/core/thread-shares/api";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
|
||||
/**
|
||||
* Conversation + artifact split shown inside the share's providers.
|
||||
*
|
||||
* The public page has no workspace layout, so it renders its own artifact
|
||||
* panel: clicking a file in the conversation selects it via `ArtifactsProvider`
|
||||
* and the detail view appears on the right.
|
||||
*/
|
||||
function PublicShareBody({
|
||||
thread,
|
||||
shareCode,
|
||||
artifacts,
|
||||
}: {
|
||||
thread: BaseStream<AgentThreadState>;
|
||||
shareCode?: string;
|
||||
artifacts: string[];
|
||||
}) {
|
||||
const { selectedArtifact, open, setArtifacts } = useArtifacts();
|
||||
const threadId = shareCode ?? "public-share";
|
||||
|
||||
// Feed the snapshot's artifact list to the provider so the detail view's
|
||||
// file switcher works.
|
||||
useEffect(() => {
|
||||
setArtifacts(artifacts);
|
||||
}, [artifacts, setArtifacts]);
|
||||
|
||||
const showArtifact = open && !!selectedArtifact;
|
||||
|
||||
return (
|
||||
<div className="flex size-full min-h-0">
|
||||
<div className="min-h-0 min-w-0 flex-1">
|
||||
<MessageList threadId={threadId} thread={thread} paddingBottom={48} />
|
||||
</div>
|
||||
{showArtifact && (
|
||||
<div className="flex min-h-0 w-1/2 min-w-0 shrink-0 flex-col border-l bg-[#fffdfa] p-4">
|
||||
<ArtifactFileDetail
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={threadId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public, read-only conversation viewer.
|
||||
*
|
||||
@@ -91,10 +140,10 @@ export default function PublicSharePage() {
|
||||
<ThreadContext.Provider
|
||||
value={{ thread, isMock: true, publicShareCode: shareCode }}
|
||||
>
|
||||
<MessageList
|
||||
threadId={shareCode ?? "public-share"}
|
||||
<PublicShareBody
|
||||
thread={thread}
|
||||
paddingBottom={48}
|
||||
shareCode={shareCode}
|
||||
artifacts={data.artifacts ?? []}
|
||||
/>
|
||||
</ThreadContext.Provider>
|
||||
</ArtifactsProvider>
|
||||
|
||||
@@ -90,9 +90,9 @@ export default function ScheduledTasksPage() {
|
||||
// so the list re-selects (and scrolls to) the task the user was viewing.
|
||||
const initialTaskId = searchParams.get("task");
|
||||
const [search, setSearch] = useState("");
|
||||
const [tagId, setTagId] = useState<string | null>(null);
|
||||
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||
// Name + tag filtering is resolved server-side via the /api/scheduled-tasks query.
|
||||
const { data: tasks = [], isLoading } = useScheduledTasks({ search, tagId });
|
||||
const { data: tasks = [], isLoading } = useScheduledTasks({ search, tagIds });
|
||||
const { data: deliveryProfile } = useScheduledTaskDeliveryProfile();
|
||||
const { agents } = useAgents();
|
||||
const actions = useScheduledTaskActions();
|
||||
@@ -283,8 +283,8 @@ export default function ScheduledTasksPage() {
|
||||
<TagSearchBar
|
||||
targetType="scheduled_task"
|
||||
onSearchChange={setSearch}
|
||||
tagId={tagId}
|
||||
onTagIdChange={setTagId}
|
||||
tagIds={tagIds}
|
||||
onTagIdsChange={setTagIds}
|
||||
/>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
@@ -27,6 +27,7 @@ import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
|
||||
import SkillEvolutionPage from "./SkillEvolutionPage";
|
||||
import SkillsPage from "./SkillsPage";
|
||||
import UsersPage from "./UsersPage";
|
||||
import AdminUserActivityPage from "./AdminUserActivityPage";
|
||||
import TagsPage from "./TagsPage";
|
||||
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
||||
|
||||
@@ -189,9 +190,13 @@ export default function WorkspaceRoutes({
|
||||
<Route
|
||||
path="admin/users"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<UsersPage />
|
||||
</WorkspaceLayout>
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<UsersPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
@@ -206,6 +211,18 @@ export default function WorkspaceRoutes({
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/user-activity"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AdminUserActivityPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/tags"
|
||||
element={
|
||||
|
||||
Reference in new issue
Block a user