From 2c0bb2041bca152b6e61ba05306c6ba6369a7c34 Mon Sep 17 00:00:00 2001 From: xuhd Date: Fri, 22 May 2026 16:04:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=9F=E4=B8=80=20Admin=20=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E4=B8=AD=E5=BF=83=20+=20=E4=BF=AE=E5=A4=8D=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E5=AF=B9=E8=AF=9D=E8=AE=B0=E5=BF=86=E5=AD=98=E5=82=A8?= =?UTF-8?q?=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 前端 - 新增 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 --- .../workspace/admin/admin-memory-page.tsx | 8 +- .../workspace/admin/admin-user-hub.tsx | 419 ++++++++++++++++++ .../settings/memory-settings-page.tsx | 176 ++++---- .../workspace/workspace-nav-menu.tsx | 22 +- frontend-web/src/pages/AdminUserHubPage.tsx | 5 + frontend-web/src/pages/WorkspaceRoutes.tsx | 45 +- .../app/gateway/routers/admin_users.py | 7 +- .../backend/app/gateway/routers/memory.py | 2 +- .../harness/deerflow/agents/memory/manager.py | 2 +- .../agents/middlewares/memory_middleware.py | 9 +- 10 files changed, 558 insertions(+), 137 deletions(-) create mode 100644 frontend-web/src/components/workspace/admin/admin-user-hub.tsx create mode 100644 frontend-web/src/pages/AdminUserHubPage.tsx diff --git a/frontend-web/src/components/workspace/admin/admin-memory-page.tsx b/frontend-web/src/components/workspace/admin/admin-memory-page.tsx index bd48cde9..7469cd81 100644 --- a/frontend-web/src/components/workspace/admin/admin-memory-page.tsx +++ b/frontend-web/src/components/workspace/admin/admin-memory-page.tsx @@ -29,7 +29,7 @@ import { } from "@/core/admin/hooks"; import { useAllUsers } from "@/core/auth/hooks"; -function getEntries(data: Record): string[] { +export function getEntries(data: Record): 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(null); diff --git a/frontend-web/src/components/workspace/admin/admin-user-hub.tsx b/frontend-web/src/components/workspace/admin/admin-user-hub.tsx new file mode 100644 index 00000000..44b851f6 --- /dev/null +++ b/frontend-web/src/components/workspace/admin/admin-user-hub.tsx @@ -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).text; + if (typeof text === "string") return text; + } + return ""; + }) + .join(""); + } + if (typeof content === "object") { + const inner = (content as Record).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 ( +
+
+
邮箱
+
{user.email}
+
角色
+
+ {user.system_role === "admin" ? ( + 管理员 + ) : ( + 普通用户 + )} +
+
Skills
+
{user.skill_count}
+
Agents
+
{user.agent_count}
+
User ID
+
{user.id}
+
+
+ ); +} + +// ── 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 ( +
+ + {role === "user" ? "用户提问" : "AI 回答"} + +
+ {text} +
+
+ ); +} + +// ── UserAuditPanel ────────────────────────────────────────────────────────── + +function UserAuditPanel({ userId }: { userId: string }) { + const [threadId, setThreadId] = useState(null); + + const { data: threads, isLoading: threadsLoading } = useUserThreads(userId); + const { data: messages, isLoading: messagesLoading } = useUserThreadMessages( + userId, + threadId, + ); + + return ( +
+ {/* Thread list */} +
+
+ 会话列表{threads ? `(${threads.length})` : ""} +
+ + {threadsLoading && ( +
+ 加载中 +
+ )} + {!threadsLoading && (threads?.length ?? 0) === 0 && ( +
+ 该用户暂无会话 +
+ )} +
+ {(threads ?? []).map((thread) => ( + + ))} +
+
+
+ + {/* Messages */} + +
+ {!threadId && ( +
+ + 请从左侧选择一个会话 +
+ )} + {threadId && messagesLoading && ( +
+ 加载中 +
+ )} + {threadId && !messagesLoading && ( +
+ {(messages ?? []).map((m, i) => ( + + ))} + {(messages ?? []).every((m) => !roleOf(m)) && ( +
+ 该会话暂无问答记录 +
+ )} +
+ )} +
+
+
+ ); +} + +// ── 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("default"); + + if (isLoading) { + return ( +
+ 加载中… +
+ ); + } + + 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 ( +
+ +
+ {/* 智能体下拉选择器 */} +
+ 智能体: + + {!isV1 && currentBucket && ( + + {getEntries(currentBucket.data).length} 条 + + )} +
+ + {/* 当前智能体记忆内容 */} + {!currentBucket ? ( +
+ 暂无记忆数据 +
+ ) : isV1 ? ( + + ) : ( + + )} +
+
+
+ ); +} + +// ── AdminUserHub (main) ───────────────────────────────────────────────────── + +export function AdminUserHub() { + const { data: users, isLoading: usersLoading } = useAllUsers(true); + const [selectedUserId, setSelectedUserId] = useState(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 ( +
+
+

Admin 用户中心

+

+ 管理用户信息、审计历史问答、编辑用户记忆。 +

+
+ +
+ {/* Left: user list */} +
+
+ + setUserQuery(e.target.value)} + placeholder="搜索用户邮箱" + className="pl-8" + /> +
+ + {usersLoading && ( +
+ 加载中 +
+ )} + {!usersLoading && filteredUsers.length === 0 && ( +
+ 无用户 +
+ )} +
+ {filteredUsers.map((u) => ( + + ))} +
+
+
+ + {/* Right: tabs */} + {!selectedUserId ? ( +
+ 从左侧选择一个用户 +
+ ) : ( + setActiveTab(v as typeof activeTab)} + className="flex min-w-0 flex-1 flex-col overflow-hidden" + > +
+ + 用户信息 + 活动审计 + 记忆管理 + +
+ + + {selectedUser && } + + + + + + + + + +
+ )} +
+
+ ); +} diff --git a/frontend-web/src/components/workspace/settings/memory-settings-page.tsx b/frontend-web/src/components/workspace/settings/memory-settings-page.tsx index 75d732ea..35f2262d 100644 --- a/frontend-web/src/components/workspace/settings/memory-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/memory-settings-page.tsx @@ -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 = { 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 ( -
+
{/* 说明横幅 */}
V1 模式:记忆由系统在对话结束后自动提取,无需手动管理。
- {/* 用户画像 (userContext) */} -
-

用户画像

- {ctxEntries.length === 0 ? ( -

暂无画像信息,对话后系统会自动归纳。

- ) : ( -
- {ctxEntries.map(([key, value]) => ( -
-
- {CTX_LABELS[key] ?? key} -
-
{value}
-
- ))} -
- )} -
+ + + + 事实记录 + {memory.facts.length > 0 && ( + + {memory.facts.length} + + )} + + 用户画像 & 历史 + - {/* 历史摘要 (history) */} -
-

历史摘要

- {historyEntries.length === 0 ? ( -

暂无历史摘要。

- ) : ( -
- {historyEntries.map(([key, value]) => ( -
-
- {HISTORY_LABELS[key] ?? key} -
-
{value}
+ {/* 事实记录 Tab */} + +
+
+

事实记录

+ {memory.memory_usage} +
+ {memory.facts.length === 0 ? ( +
+ 暂无事实记录,对话后系统会自动提取。
- ))} -
- )} -
- - {/* 事实记录 (facts) */} -
-
-

事实记录

- {memory.memory_usage} -
- {memory.facts.length === 0 ? ( -
- 暂无事实记录,对话后系统会自动提取。 -
- ) : ( -
- {memory.facts.map((fact) => ( -
-
- - {fact.category} - -
-
+ ) : ( +
+ {memory.facts.map((fact) => ( +
+
+ + {fact.category} + +
+
+
+ + {Math.round(fact.confidence * 100)}% + +
+

{fact.content}

+ {fact.sourceError && ( +

+ 避免:{fact.sourceError} +

+ )}
- - {Math.round(fact.confidence * 100)}% - -
-

{fact.content}

- {fact.sourceError && ( -

- 避免:{fact.sourceError} -

- )} + ))}
- ))} + )}
- )} -
+ + + {/* 用户画像 & 历史摘要 Tab */} + + {/* 用户画像 (userContext) */} +
+

用户画像

+ {ctxEntries.length === 0 ? ( +

暂无画像信息,对话后系统会自动归纳。

+ ) : ( +
+ {ctxEntries.map(([key, value]) => ( +
+
+ {CTX_LABELS[key] ?? key} +
+
{value}
+
+ ))} +
+ )} +
+ + {/* 历史摘要 (history) */} +
+

历史摘要

+ {historyEntries.length === 0 ? ( +

暂无历史摘要。

+ ) : ( +
+ {historyEntries.map(([key, value]) => ( +
+
+ {HISTORY_LABELS[key] ?? key} +
+
{value}
+
+ ))} +
+ )} +
+
+
); } diff --git a/frontend-web/src/components/workspace/workspace-nav-menu.tsx b/frontend-web/src/components/workspace/workspace-nav-menu.tsx index 93a9e121..009969ac 100644 --- a/frontend-web/src/components/workspace/workspace-nav-menu.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-menu.tsx @@ -155,18 +155,10 @@ export function WorkspaceNavMenu() { )} {isAdmin && ( navigate("/page/workspace/admin/users")} + onClick={() => navigate("/page/workspace/admin")} > - 用户管理 - - )} - {isAdmin && ( - navigate("/page/workspace/admin/memory")} - > - - 记忆管理 + 用户中心 )} {isAdmin && ( @@ -179,16 +171,6 @@ export function WorkspaceNavMenu() { 定时任务管理 )} - {isAdmin && ( - - navigate("/page/workspace/admin/user-activity") - } - > - - 用户活动审计 - - )} {isAdmin && ( navigate("/page/workspace/admin/tags")} diff --git a/frontend-web/src/pages/AdminUserHubPage.tsx b/frontend-web/src/pages/AdminUserHubPage.tsx new file mode 100644 index 00000000..d5d6b901 --- /dev/null +++ b/frontend-web/src/pages/AdminUserHubPage.tsx @@ -0,0 +1,5 @@ +import { AdminUserHub } from "@/components/workspace/admin/admin-user-hub"; + +export default function AdminUserHubPage() { + return ; +} diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx index 9afd198f..784d5ff0 100644 --- a/frontend-web/src/pages/WorkspaceRoutes.tsx +++ b/frontend-web/src/pages/WorkspaceRoutes.tsx @@ -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({ } /> + {/* Unified admin user hub */} - + + ) : ( ) } /> + {/* Legacy redirects */} + } + /> + } + /> + } + /> - - - - ) : ( - - ) - } - /> - - - - ) : ( - - ) - } - /> AdminMemoryResponse # V1 模式:每个桶读 memory.json,内容为 {facts, user, history} buckets: list[AdminMemoryBucket] = [] try: - buckets.append(AdminMemoryBucket(name="global", display_name="用户记忆", data=_read_v1_memory_bucket(user_id, None))) + buckets.append(AdminMemoryBucket(name="default", display_name="默认助手", data=_read_v1_memory_bucket(user_id, None))) except Exception: - buckets.append(AdminMemoryBucket(name="global", display_name="用户记忆", data={})) + logger.exception("Failed to read V1 global memory for user %s", user_id) + buckets.append(AdminMemoryBucket(name="default", display_name="默认助手", data={})) for agent in owned_agents: agent_id: str = agent["id"] @@ -198,7 +199,7 @@ async def get_user_memory(user_id: str, request: Request) -> AdminMemoryResponse # V2 模式:读 USER.md / MEMORY.md,内容为 {entries: [...]} buckets = [ - AdminMemoryBucket(name="global", display_name="用户画像", data=_read_memory_bucket(user_id, None)) + AdminMemoryBucket(name="default", display_name="默认助手", data=_read_memory_bucket(user_id, None)) ] for agent in owned_agents: agent_id = agent["id"] diff --git a/offline-backend-20260512/backend/app/gateway/routers/memory.py b/offline-backend-20260512/backend/app/gateway/routers/memory.py index 54246e75..b23cc985 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/memory.py +++ b/offline-backend-20260512/backend/app/gateway/routers/memory.py @@ -39,7 +39,7 @@ router = APIRouter(prefix="/api", tags=["memory"]) # 表示"全局桶 (无 agent)"的 agent 取值。 -_GLOBAL_AGENT_VALUES: frozenset[str] = frozenset({"", "global", "__global__"}) +_GLOBAL_AGENT_VALUES: frozenset[str] = frozenset({"", "global", "__global__", "default"}) def _resolve_agent_name(agent: str | None) -> str | None: diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/memory/manager.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/memory/manager.py index 6b56a898..08c0e681 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/memory/manager.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/memory/manager.py @@ -288,7 +288,7 @@ def build_memory_manager( manager.initialize_all( user_id=user_id, - agent_id=agent_id or "default", + agent_id=agent_id, thread_id=thread_id, base_dir=base_dir, ) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/memory_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/memory_middleware.py index 20f0bdc1..6a067ef0 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/memory_middleware.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/memory_middleware.py @@ -117,15 +117,18 @@ class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]): except RuntimeError: return None - def _resolve_agent_id(self, runtime: Runtime | None) -> str: - """解析 agent_id;优先 middleware 构造时的 agent_name,再 runtime context。""" + def _resolve_agent_id(self, runtime: Runtime | None) -> str | None: + """解析 agent_id;优先 middleware 构造时的 agent_name,再 runtime context。 + + 无 agent 上下文时返回 None,使 V1 记忆存入全局文件(users/{id}/memory.json)。 + """ if self._agent_name: return self._agent_name if runtime is not None and getattr(runtime, "context", None): agent_id = runtime.context.get("agent_id") if agent_id: return str(agent_id) - return "default" + return None # ---- wrap_model_call:Hindsight 动态召回注入 -------------------------