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,343 @@
"use client";
import { Loader2, MessageSquareIcon, SearchIcon, UserIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useUserMemory, useUserThreadMessages, useUserThreads } from "@/core/admin/hooks";
import type { AdminMemoryBucket, AdminMessage } from "@/core/admin/api";
import { useAllUsers } from "@/core/auth/hooks";
import { cn } from "@/lib/utils";
/** Flatten a langchain message dump (or raw string / block list) into text. */
function extractText(content: unknown): string {
if (content == null) return "";
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map((block) => {
if (typeof block === "string") return block;
if (block && typeof block === "object") {
const text = (block as Record<string, unknown>).text;
if (typeof text === "string") return text;
}
return "";
})
.join("");
}
if (typeof content === "object") {
const inner = (content as Record<string, unknown>).content;
if (inner !== undefined) return extractText(inner);
}
return "";
}
function roleOf(message: AdminMessage): "user" | "assistant" | null {
const type = (message.type ?? "").toLowerCase();
if (type === "human") return "user";
if (type === "ai" || type === "aimessagechunk") return "assistant";
return null; // tool results and system messages are hidden from the Q&A view
}
function fmtTime(iso: string): string {
if (!iso) return "";
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
}
/** A single Q&A bubble. */
function MessageRow({ message }: { message: AdminMessage }) {
const role = roleOf(message);
if (!role) return null;
const text = extractText(message.content).trim();
if (!text) return null; // skip empty AI turns (e.g. tool-call-only responses)
return (
<div className={cn("flex flex-col gap-1", role === "user" ? "items-start" : "items-end")}>
<span className="text-muted-foreground text-xs">
{role === "user" ? "用户提问" : "AI 回答"}
</span>
<div
className={cn(
"max-w-[85%] whitespace-pre-wrap rounded-lg border px-3 py-2 text-sm",
role === "user" ? "bg-muted" : "bg-primary/5 border-primary/20",
)}
>
{text}
</div>
</div>
);
}
/** Render one memory bucket: context summaries + facts. */
function MemoryBucketView({ bucket }: { bucket: AdminMemoryBucket }) {
const data = bucket.data as {
user?: Record<string, { summary?: string }>;
history?: Record<string, { summary?: string }>;
facts?: Array<{ content?: string; category?: string; confidence?: number }>;
};
const sections: Array<[string, string]> = [];
const labels: Record<string, string> = {
workContext: "工作背景",
personalContext: "个人背景",
topOfMind: "近期关注",
recentMonths: "近几个月",
earlierContext: "更早记录",
longTermBackground: "长期背景",
};
for (const group of [data.user, data.history]) {
for (const [key, value] of Object.entries(group ?? {})) {
const summary = value?.summary?.trim();
if (summary) sections.push([labels[key] ?? key, summary]);
}
}
const facts = (data.facts ?? []).filter((f) => f.content?.trim());
return (
<div className="rounded-md border p-4">
<div className="mb-3 flex items-center gap-2">
<Badge variant={bucket.name === "global" ? "default" : "secondary"}>
{bucket.name === "global" ? "全局记忆" : `Agent: ${bucket.name}`}
</Badge>
</div>
{sections.length === 0 && facts.length === 0 && (
<p className="text-muted-foreground text-sm">该记忆库暂无内容。</p>
)}
{sections.length > 0 && (
<div className="flex flex-col gap-2">
{sections.map(([label, summary]) => (
<div key={label}>
<span className="text-muted-foreground text-xs">{label}</span>
<p className="text-sm">{summary}</p>
</div>
))}
</div>
)}
{facts.length > 0 && (
<div className="mt-3">
<span className="text-muted-foreground text-xs">事实记录({facts.length})</span>
<ul className="mt-1 flex flex-col gap-1">
{facts.map((fact, i) => (
<li key={i} className="flex items-start gap-2 text-sm">
<span className="text-muted-foreground">·</span>
<span className="flex-1">{fact.content}</span>
{fact.category && (
<Badge variant="outline" className="shrink-0 text-xs">
{fact.category}
</Badge>
)}
</li>
))}
</ul>
</div>
)}
</div>
);
}
export default function AdminUserActivityPage() {
const { data: users, isLoading: usersLoading } = useAllUsers(true);
const [userId, setUserId] = useState<string | null>(null);
const [threadId, setThreadId] = useState<string | null>(null);
const [userQuery, setUserQuery] = useState("");
const [tab, setTab] = useState<"qa" | "memory">("qa");
const { data: threads, isLoading: threadsLoading } = useUserThreads(userId);
const { data: messages, isLoading: messagesLoading } = useUserThreadMessages(
userId,
threadId,
);
const { data: memory, isLoading: memoryLoading } = useUserMemory(userId);
const filteredUsers = useMemo(() => {
const needle = userQuery.trim().toLowerCase();
const list = users ?? [];
return needle ? list.filter((u) => u.email.toLowerCase().includes(needle)) : list;
}, [users, userQuery]);
const selectedUser = users?.find((u) => u.id === userId) ?? null;
const selectUser = (id: string) => {
setUserId(id);
setThreadId(null);
setTab("qa");
};
return (
<div className="flex size-full flex-col">
<div className="border-b px-6 py-4">
<h1 className="text-xl font-semibold">用户活动审计</h1>
<p className="text-muted-foreground mt-0.5 text-sm">
查看任意用户的历史问答与记忆(只读)。
</p>
</div>
<div className="flex min-h-0 flex-1">
{/* Pane 1: users */}
<div className="flex w-64 shrink-0 flex-col border-r">
<div className="relative p-2">
<SearchIcon className="text-muted-foreground absolute left-4 top-1/2 size-4 -translate-y-1/2" />
<Input
value={userQuery}
onChange={(e) => setUserQuery(e.target.value)}
placeholder="搜索用户邮箱"
className="pl-8"
/>
</div>
<ScrollArea className="min-h-0 flex-1">
{usersLoading && (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8 text-sm">
<Loader2 className="size-4 animate-spin" /> 加载中
</div>
)}
{!usersLoading && filteredUsers.length === 0 && (
<div className="text-muted-foreground py-8 text-center text-sm">无用户</div>
)}
<div className="flex flex-col p-1">
{filteredUsers.map((u) => (
<button
key={u.id}
type="button"
onClick={() => selectUser(u.id)}
className={cn(
"flex items-center gap-2 rounded-md px-2 py-2 text-left text-sm",
u.id === userId ? "bg-accent" : "hover:bg-muted",
)}
>
<UserIcon className="text-muted-foreground size-4 shrink-0" />
<span className="flex-1 truncate">{u.email}</span>
{u.system_role === "admin" && (
<Badge variant="outline" className="shrink-0 text-xs">
管理员
</Badge>
)}
</button>
))}
</div>
</ScrollArea>
</div>
{/* Pane 2: threads */}
<div className="flex w-72 shrink-0 flex-col border-r">
<div className="text-muted-foreground border-b px-3 py-2 text-xs font-medium">
会话列表{threads ? `(${threads.length})` : ""}
</div>
<ScrollArea className="min-h-0 flex-1">
{!userId && (
<div className="text-muted-foreground py-8 text-center text-sm">
请先选择用户
</div>
)}
{userId && threadsLoading && (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8 text-sm">
<Loader2 className="size-4 animate-spin" /> 加载中
</div>
)}
{userId && !threadsLoading && (threads?.length ?? 0) === 0 && (
<div className="text-muted-foreground py-8 text-center text-sm">
该用户暂无会话
</div>
)}
<div className="flex flex-col p-1">
{(threads ?? []).map((thread) => (
<button
key={thread.thread_id}
type="button"
onClick={() => {
setThreadId(thread.thread_id);
setTab("qa");
}}
className={cn(
"flex flex-col gap-0.5 rounded-md px-2 py-2 text-left",
thread.thread_id === threadId ? "bg-accent" : "hover:bg-muted",
)}
>
<span className="truncate text-sm">
{thread.display_name || "未命名会话"}
</span>
<span className="text-muted-foreground text-xs">
{fmtTime(thread.updated_at)}
</span>
</button>
))}
</div>
</ScrollArea>
</div>
{/* Pane 3: detail */}
<div className="flex min-w-0 flex-1 flex-col">
{!userId ? (
<div className="text-muted-foreground flex flex-1 items-center justify-center text-sm">
选择一个用户开始审计
</div>
) : (
<>
<div className="flex items-center justify-between gap-3 border-b px-4 py-2">
<span className="text-muted-foreground truncate text-sm">
{selectedUser?.email}
</span>
<Tabs value={tab} onValueChange={(v) => setTab(v as "qa" | "memory")}>
<TabsList>
<TabsTrigger value="qa">问答记录</TabsTrigger>
<TabsTrigger value="memory">记忆</TabsTrigger>
</TabsList>
</Tabs>
</div>
<ScrollArea className="min-h-0 flex-1">
{tab === "qa" && (
<div className="p-4">
{!threadId && (
<div className="text-muted-foreground flex flex-col items-center gap-2 py-16 text-sm">
<MessageSquareIcon className="size-6" />
请从中间列选择一个会话
</div>
)}
{threadId && messagesLoading && (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-16 text-sm">
<Loader2 className="size-4 animate-spin" /> 加载中
</div>
)}
{threadId && !messagesLoading && (
<div className="flex flex-col gap-3">
{(messages ?? []).map((m, i) => (
<MessageRow key={m.id ?? i} message={m} />
))}
{(messages ?? []).every((m) => !roleOf(m)) && (
<div className="text-muted-foreground py-12 text-center text-sm">
该会话暂无问答记录
</div>
)}
</div>
)}
</div>
)}
{tab === "memory" && (
<div className="flex flex-col gap-3 p-4">
{memoryLoading && (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-16 text-sm">
<Loader2 className="size-4 animate-spin" /> 加载中
</div>
)}
{!memoryLoading &&
(memory?.buckets ?? []).map((bucket) => (
<MemoryBucketView key={bucket.name} bucket={bucket} />
))}
{!memoryLoading && (memory?.buckets?.length ?? 0) === 0 && (
<div className="text-muted-foreground py-12 text-center text-sm">
该用户暂无记忆数据
</div>
)}
</div>
)}
</ScrollArea>
</>
)}
</div>
</div>
</div>
);
}
+25 -3
View File
@@ -14,6 +14,8 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ToolMetricsPanel } from "@/components/workspace/admin/tool-metrics-panel";
import {
downloadLlmMetricsExcel,
type LlmMetricsFilters,
@@ -70,7 +72,8 @@ function speedColor(tps?: number | null): string {
return "#ef4444";
}
export default function LlmMetricsPage() {
/** The original LLM-call metrics view, now one tab of the metrics page. */
function LlmMetricsPanel() {
const [page, setPage] = useState(0);
const [model, setModel] = useState("");
const [user, setUser] = useState("");
@@ -245,10 +248,10 @@ export default function LlmMetricsPage() {
const colSpan = visibleColumns.length || 1;
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-4 px-6 py-8">
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-xl font-semibold">大模型调用统计</h1>
<h2 className="text-lg font-semibold">大模型调用统计</h2>
<p className="text-muted-foreground mt-1 text-sm">
记录每一次大模型的实际调用,包括 token 数、耗时、估算速度和失败原因。
后台无条件记录,与前端速度指示器开关无关。
@@ -456,3 +459,22 @@ function Td({
</td>
);
}
/**
* Admin metrics page. Two tabs: LLM provider calls and tool / skill calls —
* the latter surfaces retrieval/IP-block/rate-limit failures.
*/
export default function LlmMetricsPage() {
const [tab, setTab] = useState<"llm" | "tool">("llm");
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-4 px-6 py-8">
<Tabs value={tab} onValueChange={(v) => setTab(v as "llm" | "tool")}>
<TabsList>
<TabsTrigger value="llm">大模型调用</TabsTrigger>
<TabsTrigger value="tool">工具调用</TabsTrigger>
</TabsList>
</Tabs>
{tab === "llm" ? <LlmMetricsPanel /> : <ToolMetricsPanel />}
</div>
);
}
+54 -5
View File
@@ -3,16 +3,65 @@
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { useQuery } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { useMemo } from "react";
import { useEffect, useMemo } from "react";
import { useParams } from "react-router-dom";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import {
ArtifactFileDetail,
ArtifactsProvider,
useArtifacts,
} from "@/components/workspace/artifacts";
import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { SubtasksProvider } from "@/core/tasks/context";
import { getPublicShareSnapshot } from "@/core/thread-shares/api";
import type { AgentThreadState } from "@/core/threads";
/**
* Conversation + artifact split shown inside the share's providers.
*
* The public page has no workspace layout, so it renders its own artifact
* panel: clicking a file in the conversation selects it via `ArtifactsProvider`
* and the detail view appears on the right.
*/
function PublicShareBody({
thread,
shareCode,
artifacts,
}: {
thread: BaseStream<AgentThreadState>;
shareCode?: string;
artifacts: string[];
}) {
const { selectedArtifact, open, setArtifacts } = useArtifacts();
const threadId = shareCode ?? "public-share";
// Feed the snapshot's artifact list to the provider so the detail view's
// file switcher works.
useEffect(() => {
setArtifacts(artifacts);
}, [artifacts, setArtifacts]);
const showArtifact = open && !!selectedArtifact;
return (
<div className="flex size-full min-h-0">
<div className="min-h-0 min-w-0 flex-1">
<MessageList threadId={threadId} thread={thread} paddingBottom={48} />
</div>
{showArtifact && (
<div className="flex min-h-0 w-1/2 min-w-0 shrink-0 flex-col border-l bg-[#fffdfa] p-4">
<ArtifactFileDetail
className="size-full"
filepath={selectedArtifact}
threadId={threadId}
/>
</div>
)}
</div>
);
}
/**
* Public, read-only conversation viewer.
*
@@ -91,10 +140,10 @@ export default function PublicSharePage() {
<ThreadContext.Provider
value={{ thread, isMock: true, publicShareCode: shareCode }}
>
<MessageList
threadId={shareCode ?? "public-share"}
<PublicShareBody
thread={thread}
paddingBottom={48}
shareCode={shareCode}
artifacts={data.artifacts ?? []}
/>
</ThreadContext.Provider>
</ArtifactsProvider>
@@ -90,9 +90,9 @@ export default function ScheduledTasksPage() {
// so the list re-selects (and scrolls to) the task the user was viewing.
const initialTaskId = searchParams.get("task");
const [search, setSearch] = useState("");
const [tagId, setTagId] = useState<string | null>(null);
const [tagIds, setTagIds] = useState<string[]>([]);
// Name + tag filtering is resolved server-side via the /api/scheduled-tasks query.
const { data: tasks = [], isLoading } = useScheduledTasks({ search, tagId });
const { data: tasks = [], isLoading } = useScheduledTasks({ search, tagIds });
const { data: deliveryProfile } = useScheduledTaskDeliveryProfile();
const { agents } = useAgents();
const actions = useScheduledTaskActions();
@@ -283,8 +283,8 @@ export default function ScheduledTasksPage() {
<TagSearchBar
targetType="scheduled_task"
onSearchChange={setSearch}
tagId={tagId}
onTagIdChange={setTagId}
tagIds={tagIds}
onTagIdsChange={setTagIds}
/>
)}
</CardHeader>
+20 -3
View File
@@ -27,6 +27,7 @@ import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
import SkillEvolutionPage from "./SkillEvolutionPage";
import SkillsPage from "./SkillsPage";
import UsersPage from "./UsersPage";
import AdminUserActivityPage from "./AdminUserActivityPage";
import TagsPage from "./TagsPage";
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
@@ -189,9 +190,13 @@ export default function WorkspaceRoutes({
<Route
path="admin/users"
element={
<WorkspaceLayout {...layoutProps}>
<UsersPage />
</WorkspaceLayout>
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<UsersPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
@@ -206,6 +211,18 @@ export default function WorkspaceRoutes({
)
}
/>
<Route
path="admin/user-activity"
element={
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<AdminUserActivityPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/tags"
element={