feat: 统一 Admin 用户中心 + 修复默认对话记忆存储路径
## 前端
- 新增 AdminUserHub 页面,合并用户管理/审计/记忆三个页面为统一中心(左侧用户列表 + 右侧 Tab)
- 旧三条 admin 路由重定向到新 /admin 路由
- 侧边栏导航菜单合并为单一入口"用户中心"
- memory-settings-page: 默认助手下拉选项传 agent=default(后端识别为全局哨兵)
## 后端
- memory.py: 将 "default" 加入全局哨兵 _GLOBAL_AGENT_VALUES,?agent=default 读全局记忆文件
- memory_middleware.py: _resolve_agent_id() 无 agent 上下文时返回 None,默认对话写入 users/{id}/memory.json
- manager.py: 移除 agent_id or "default" 回退,让 None 透传给 V1 provider
- admin_users.py: Admin 记忆 API 将"默认助手"桶改为读全局文件(agent_id=None)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
ad8f74c964
commit
2c0bb2041b
10 files changed
+558
-137
No files matched your search
@@ -29,7 +29,7 @@ import {
|
||||
} from "@/core/admin/hooks";
|
||||
import { useAllUsers } from "@/core/auth/hooks";
|
||||
|
||||
function getEntries(data: Record<string, unknown>): string[] {
|
||||
export function getEntries(data: Record<string, unknown>): string[] {
|
||||
const raw = (data as { entries?: unknown }).entries;
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.filter((e): e is string => typeof e === "string" && e.trim().length > 0);
|
||||
@@ -251,7 +251,7 @@ function V1AddFactForm({ agent, userId }: { agent: string | null; userId: string
|
||||
);
|
||||
}
|
||||
|
||||
function V1BucketView({
|
||||
export function V1BucketView({
|
||||
data,
|
||||
agent,
|
||||
userId,
|
||||
@@ -319,7 +319,7 @@ function V1BucketView({
|
||||
|
||||
// ── Memory entry list with inline editing ──────────────────────────────────
|
||||
|
||||
function MemoryEntryList({
|
||||
export function MemoryEntryList({
|
||||
entries,
|
||||
userId,
|
||||
agent,
|
||||
@@ -490,7 +490,7 @@ function MemoryEntryList({
|
||||
|
||||
// ── Right panel: memory tabs for a selected user ───────────────────────────
|
||||
|
||||
function UserMemoryPanel({ userId }: { userId: string }) {
|
||||
export function UserMemoryPanel({ userId }: { userId: string }) {
|
||||
const { data: memory, isLoading } = useUserMemory(userId);
|
||||
const [selectedAgent, setSelectedAgent] = useState<string | null>(null);
|
||||
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
"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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import type { AdminMessage } from "@/core/admin/api";
|
||||
import {
|
||||
useUserMemory,
|
||||
useUserThreadMessages,
|
||||
useUserThreads,
|
||||
} from "@/core/admin/hooks";
|
||||
import type { UserListItem } from "@/core/auth/api";
|
||||
import { useAllUsers } from "@/core/auth/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
getEntries,
|
||||
MemoryEntryList,
|
||||
V1BucketView,
|
||||
} from "./admin-memory-page";
|
||||
|
||||
// ── Helpers (shared with AdminUserActivityPage) ─────────────────────────────
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
|
||||
}
|
||||
|
||||
// ── UserInfoPanel ───────────────────────────────────────────────────────────
|
||||
|
||||
function UserInfoPanel({ user }: { user: UserListItem }) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<dl className="grid grid-cols-[max-content_1fr] gap-x-8 gap-y-3 text-sm">
|
||||
<dt className="text-muted-foreground font-medium">邮箱</dt>
|
||||
<dd className="break-all">{user.email}</dd>
|
||||
<dt className="text-muted-foreground font-medium">角色</dt>
|
||||
<dd>
|
||||
{user.system_role === "admin" ? (
|
||||
<Badge variant="default">管理员</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">普通用户</Badge>
|
||||
)}
|
||||
</dd>
|
||||
<dt className="text-muted-foreground font-medium">Skills</dt>
|
||||
<dd className="tabular-nums">{user.skill_count}</dd>
|
||||
<dt className="text-muted-foreground font-medium">Agents</dt>
|
||||
<dd className="tabular-nums">{user.agent_count}</dd>
|
||||
<dt className="text-muted-foreground font-medium">User ID</dt>
|
||||
<dd className="font-mono text-xs text-muted-foreground break-all">{user.id}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── MessageRow / MemoryBucketView (audit sub-components) ────────────────────
|
||||
|
||||
function MessageRow({ message }: { message: AdminMessage }) {
|
||||
const role = roleOf(message);
|
||||
if (!role) return null;
|
||||
const text = extractText(message.content).trim();
|
||||
if (!text) return null;
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// ── UserAuditPanel ──────────────────────────────────────────────────────────
|
||||
|
||||
function UserAuditPanel({ userId }: { userId: string }) {
|
||||
const [threadId, setThreadId] = useState<string | null>(null);
|
||||
|
||||
const { data: threads, isLoading: threadsLoading } = useUserThreads(userId);
|
||||
const { data: messages, isLoading: messagesLoading } = useUserThreadMessages(
|
||||
userId,
|
||||
threadId,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* Thread list */}
|
||||
<div className="flex w-60 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">
|
||||
{threadsLoading && (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" /> 加载中
|
||||
</div>
|
||||
)}
|
||||
{!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)}
|
||||
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>
|
||||
|
||||
{/* Messages */}
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<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>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── AgentMemoryPanel ─────────────────────────────────────────────────────────
|
||||
|
||||
function bucketLabel(name: string, displayName?: string): string {
|
||||
if (name === "default") return "默认智能体";
|
||||
return displayName || name;
|
||||
}
|
||||
|
||||
function AgentMemoryPanel({ userId }: { userId: string }) {
|
||||
const { data: memory, isLoading } = useUserMemory(userId);
|
||||
// 默认选中"默认助手"("default" 对应全局记忆文件)
|
||||
const [selectedAgent, setSelectedAgent] = useState<string>("default");
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex flex-1 items-center justify-center gap-2 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" /> 加载中…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isV1 = memory?.version === "v1";
|
||||
|
||||
// 非 global、非 default 的其他智能体 bucket
|
||||
const otherBuckets = (memory?.buckets ?? []).filter(
|
||||
(b) => b.name !== "global" && b.name !== "default",
|
||||
);
|
||||
|
||||
// 当前选中的 bucket
|
||||
const currentBucket = (memory?.buckets ?? []).find(
|
||||
(b) => b.name === selectedAgent,
|
||||
);
|
||||
|
||||
// "default" 对应 agent=null(全局记忆文件),与 _resolve_agent_name 的哨兵逻辑一致
|
||||
const bucketAgentParam = selectedAgent === "default" ? null : selectedAgent;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="space-y-4 p-4">
|
||||
{/* 智能体下拉选择器 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-sm shrink-0">智能体:</span>
|
||||
<Select value={selectedAgent} onValueChange={setSelectedAgent}>
|
||||
<SelectTrigger className="h-8 w-48" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* 默认助手始终显示,对应全局记忆文件 */}
|
||||
<SelectItem value="default">默认助手</SelectItem>
|
||||
{otherBuckets.map((b) => (
|
||||
<SelectItem key={b.name} value={b.name}>
|
||||
{bucketLabel(b.name, b.display_name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!isV1 && currentBucket && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{getEntries(currentBucket.data).length} 条
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 当前智能体记忆内容 */}
|
||||
{!currentBucket ? (
|
||||
<div className="text-muted-foreground rounded-md border border-dashed p-4 text-center text-sm">
|
||||
暂无记忆数据
|
||||
</div>
|
||||
) : isV1 ? (
|
||||
<V1BucketView
|
||||
data={currentBucket.data}
|
||||
agent={bucketAgentParam}
|
||||
userId={userId}
|
||||
/>
|
||||
) : (
|
||||
<MemoryEntryList
|
||||
entries={getEntries(currentBucket.data)}
|
||||
userId={userId}
|
||||
agent={bucketAgentParam}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── AdminUserHub (main) ─────────────────────────────────────────────────────
|
||||
|
||||
export function AdminUserHub() {
|
||||
const { data: users, isLoading: usersLoading } = useAllUsers(true);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||
const [userQuery, setUserQuery] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<"info" | "audit" | "memory">("info");
|
||||
|
||||
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 === selectedUserId) ?? null;
|
||||
|
||||
function selectUser(id: string) {
|
||||
setSelectedUserId(id);
|
||||
setActiveTab("info");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col">
|
||||
<div className="border-b px-6 py-4">
|
||||
<h1 className="text-xl font-semibold">Admin 用户中心</h1>
|
||||
<p className="text-muted-foreground mt-0.5 text-sm">
|
||||
管理用户信息、审计历史问答、编辑用户记忆。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* Left: user list */}
|
||||
<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 === selectedUserId
|
||||
? "bg-accent font-medium"
|
||||
: "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>
|
||||
|
||||
{/* Right: tabs */}
|
||||
{!selectedUserId ? (
|
||||
<div className="text-muted-foreground flex flex-1 items-center justify-center text-sm">
|
||||
从左侧选择一个用户
|
||||
</div>
|
||||
) : (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as typeof activeTab)}
|
||||
className="flex min-w-0 flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
<div className="border-b px-4 pt-3 shrink-0">
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="info">用户信息</TabsTrigger>
|
||||
<TabsTrigger value="audit">活动审计</TabsTrigger>
|
||||
<TabsTrigger value="memory">记忆管理</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="info" className="flex-1 overflow-auto p-0">
|
||||
{selectedUser && <UserInfoPanel user={selectedUser} />}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="audit"
|
||||
className="flex min-h-0 flex-1 flex-col p-0"
|
||||
>
|
||||
<UserAuditPanel key={selectedUserId} userId={selectedUserId} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="memory"
|
||||
className="flex min-h-0 flex-1 flex-col p-0"
|
||||
>
|
||||
<AgentMemoryPanel key={selectedUserId} userId={selectedUserId} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
@@ -471,7 +472,7 @@ const CATEGORY_COLORS: Record<string, string> = {
|
||||
correction: "bg-red-100 text-red-700",
|
||||
};
|
||||
|
||||
/** V1 记忆只读视图:展示 memory.json 的全部字段。 */
|
||||
/** V1 记忆只读视图:展示 memory.json 的全部字段,Tab 布局。 */
|
||||
function MemoryV1View({
|
||||
memory,
|
||||
}: {
|
||||
@@ -486,93 +487,112 @@ function MemoryV1View({
|
||||
const historyEntries = Object.entries(memory.history ?? {}).filter(([, v]) => v);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{/* 说明横幅 */}
|
||||
<div className="rounded-md border border-dashed px-4 py-3 text-sm text-muted-foreground">
|
||||
V1 模式:记忆由系统在对话结束后自动提取,无需手动管理。
|
||||
</div>
|
||||
|
||||
{/* 用户画像 (userContext) */}
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<h3 className="text-base font-medium">用户画像</h3>
|
||||
{ctxEntries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">暂无画像信息,对话后系统会自动归纳。</p>
|
||||
) : (
|
||||
<dl className="space-y-2">
|
||||
{ctxEntries.map(([key, value]) => (
|
||||
<div key={key} className="text-sm">
|
||||
<dt className="font-medium text-muted-foreground mb-0.5">
|
||||
{CTX_LABELS[key] ?? key}
|
||||
</dt>
|
||||
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
<Tabs defaultValue="facts">
|
||||
<TabsList>
|
||||
<TabsTrigger value="facts">
|
||||
事实记录
|
||||
{memory.facts.length > 0 && (
|
||||
<span className="ml-1.5 rounded-full bg-primary/10 px-1.5 py-0.5 text-xs text-primary">
|
||||
{memory.facts.length}
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="profile">用户画像 & 历史</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 历史摘要 (history) */}
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<h3 className="text-base font-medium">历史摘要</h3>
|
||||
{historyEntries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">暂无历史摘要。</p>
|
||||
) : (
|
||||
<dl className="space-y-2">
|
||||
{historyEntries.map(([key, value]) => (
|
||||
<div key={key} className="text-sm">
|
||||
<dt className="font-medium text-muted-foreground mb-0.5">
|
||||
{HISTORY_LABELS[key] ?? key}
|
||||
</dt>
|
||||
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{value}</dd>
|
||||
{/* 事实记录 Tab */}
|
||||
<TabsContent value="facts" className="mt-3">
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-medium">事实记录</h3>
|
||||
<span className="text-xs text-muted-foreground">{memory.memory_usage}</span>
|
||||
</div>
|
||||
{memory.facts.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-3 text-sm text-muted-foreground">
|
||||
暂无事实记录,对话后系统会自动提取。
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 事实记录 (facts) */}
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-medium">事实记录</h3>
|
||||
<span className="text-xs text-muted-foreground">{memory.memory_usage}</span>
|
||||
</div>
|
||||
{memory.facts.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-3 text-sm text-muted-foreground">
|
||||
暂无事实记录,对话后系统会自动提取。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{memory.facts.map((fact) => (
|
||||
<div key={fact.id} className="rounded-md border p-3 space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-xs font-medium ${
|
||||
CATEGORY_COLORS[fact.category] ?? "bg-gray-100 text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{fact.category}
|
||||
</span>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary"
|
||||
style={{ width: `${Math.round(fact.confidence * 100)}%` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{memory.facts.map((fact) => (
|
||||
<div key={fact.id} className="rounded-md border p-3 space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-xs font-medium ${
|
||||
CATEGORY_COLORS[fact.category] ?? "bg-gray-100 text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{fact.category}
|
||||
</span>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary"
|
||||
style={{ width: `${Math.round(fact.confidence * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{Math.round(fact.confidence * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap [overflow-wrap:anywhere]">{fact.content}</p>
|
||||
{fact.sourceError && (
|
||||
<p className="text-xs text-red-600 bg-red-50 rounded px-2 py-1">
|
||||
避免:{fact.sourceError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{Math.round(fact.confidence * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap [overflow-wrap:anywhere]">{fact.content}</p>
|
||||
{fact.sourceError && (
|
||||
<p className="text-xs text-red-600 bg-red-50 rounded px-2 py-1">
|
||||
避免:{fact.sourceError}
|
||||
</p>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 用户画像 & 历史摘要 Tab */}
|
||||
<TabsContent value="profile" className="mt-3 space-y-4">
|
||||
{/* 用户画像 (userContext) */}
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<h3 className="text-base font-medium">用户画像</h3>
|
||||
{ctxEntries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">暂无画像信息,对话后系统会自动归纳。</p>
|
||||
) : (
|
||||
<dl className="space-y-2">
|
||||
{ctxEntries.map(([key, value]) => (
|
||||
<div key={key} className="text-sm">
|
||||
<dt className="font-medium text-muted-foreground mb-0.5">
|
||||
{CTX_LABELS[key] ?? key}
|
||||
</dt>
|
||||
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 历史摘要 (history) */}
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<h3 className="text-base font-medium">历史摘要</h3>
|
||||
{historyEntries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">暂无历史摘要。</p>
|
||||
) : (
|
||||
<dl className="space-y-2">
|
||||
{historyEntries.map(([key, value]) => (
|
||||
<div key={key} className="text-sm">
|
||||
<dt className="font-medium text-muted-foreground mb-0.5">
|
||||
{HISTORY_LABELS[key] ?? key}
|
||||
</dt>
|
||||
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -155,18 +155,10 @@ export function WorkspaceNavMenu() {
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/users")}
|
||||
onClick={() => navigate("/page/workspace/admin")}
|
||||
>
|
||||
<UsersIcon />
|
||||
用户管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/memory")}
|
||||
>
|
||||
<BrainIcon />
|
||||
记忆管理
|
||||
用户中心
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
@@ -179,16 +171,6 @@ export function WorkspaceNavMenu() {
|
||||
定时任务管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/workspace/admin/user-activity")
|
||||
}
|
||||
>
|
||||
<HistoryIcon />
|
||||
用户活动审计
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/tags")}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AdminUserHub } from "@/components/workspace/admin/admin-user-hub";
|
||||
|
||||
export default function AdminUserHubPage() {
|
||||
return <AdminUserHub />;
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import SkillsPage from "./SkillsPage";
|
||||
import UsersPage from "./UsersPage";
|
||||
import AdminUserActivityPage from "./AdminUserActivityPage";
|
||||
import AdminMemoryPage from "./AdminMemoryPage";
|
||||
import AdminUserHubPage from "./AdminUserHubPage";
|
||||
import TagsPage from "./TagsPage";
|
||||
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
||||
import AIWritingPage from "@/open-canvas/pages/AIWritingPage";
|
||||
@@ -206,18 +207,32 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
{/* Unified admin user hub */}
|
||||
<Route
|
||||
path="admin/users"
|
||||
path="admin"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<UsersPage />
|
||||
<WorkspaceLayout {...layoutProps} pageSidebarHidden>
|
||||
<AdminUserHubPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
{/* Legacy redirects */}
|
||||
<Route
|
||||
path="admin/users"
|
||||
element={<Navigate to="/page/workspace/admin" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="admin/user-activity"
|
||||
element={<Navigate to="/page/workspace/admin" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="admin/memory"
|
||||
element={<Navigate to="/page/workspace/admin" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="admin/scheduled-tasks"
|
||||
element={
|
||||
@@ -230,30 +245,6 @@ 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/memory"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AdminMemoryPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/tags"
|
||||
element={
|
||||
|
||||
Reference in new issue
Block a user