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";
|
} from "@/core/admin/hooks";
|
||||||
import { useAllUsers } from "@/core/auth/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;
|
const raw = (data as { entries?: unknown }).entries;
|
||||||
if (!Array.isArray(raw)) return [];
|
if (!Array.isArray(raw)) return [];
|
||||||
return raw.filter((e): e is string => typeof e === "string" && e.trim().length > 0);
|
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,
|
data,
|
||||||
agent,
|
agent,
|
||||||
userId,
|
userId,
|
||||||
@@ -319,7 +319,7 @@ function V1BucketView({
|
|||||||
|
|
||||||
// ── Memory entry list with inline editing ──────────────────────────────────
|
// ── Memory entry list with inline editing ──────────────────────────────────
|
||||||
|
|
||||||
function MemoryEntryList({
|
export function MemoryEntryList({
|
||||||
entries,
|
entries,
|
||||||
userId,
|
userId,
|
||||||
agent,
|
agent,
|
||||||
@@ -490,7 +490,7 @@ function MemoryEntryList({
|
|||||||
|
|
||||||
// ── Right panel: memory tabs for a selected user ───────────────────────────
|
// ── 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 { data: memory, isLoading } = useUserMemory(userId);
|
||||||
const [selectedAgent, setSelectedAgent] = useState<string | null>(null);
|
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,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
@@ -471,7 +472,7 @@ const CATEGORY_COLORS: Record<string, string> = {
|
|||||||
correction: "bg-red-100 text-red-700",
|
correction: "bg-red-100 text-red-700",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** V1 记忆只读视图:展示 memory.json 的全部字段。 */
|
/** V1 记忆只读视图:展示 memory.json 的全部字段,Tab 布局。 */
|
||||||
function MemoryV1View({
|
function MemoryV1View({
|
||||||
memory,
|
memory,
|
||||||
}: {
|
}: {
|
||||||
@@ -486,93 +487,112 @@ function MemoryV1View({
|
|||||||
const historyEntries = Object.entries(memory.history ?? {}).filter(([, v]) => v);
|
const historyEntries = Object.entries(memory.history ?? {}).filter(([, v]) => v);
|
||||||
|
|
||||||
return (
|
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">
|
<div className="rounded-md border border-dashed px-4 py-3 text-sm text-muted-foreground">
|
||||||
V1 模式:记忆由系统在对话结束后自动提取,无需手动管理。
|
V1 模式:记忆由系统在对话结束后自动提取,无需手动管理。
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 用户画像 (userContext) */}
|
<Tabs defaultValue="facts">
|
||||||
<div className="rounded-lg border p-4 space-y-2">
|
<TabsList>
|
||||||
<h3 className="text-base font-medium">用户画像</h3>
|
<TabsTrigger value="facts">
|
||||||
{ctxEntries.length === 0 ? (
|
事实记录
|
||||||
<p className="text-sm text-muted-foreground">暂无画像信息,对话后系统会自动归纳。</p>
|
{memory.facts.length > 0 && (
|
||||||
) : (
|
<span className="ml-1.5 rounded-full bg-primary/10 px-1.5 py-0.5 text-xs text-primary">
|
||||||
<dl className="space-y-2">
|
{memory.facts.length}
|
||||||
{ctxEntries.map(([key, value]) => (
|
</span>
|
||||||
<div key={key} className="text-sm">
|
)}
|
||||||
<dt className="font-medium text-muted-foreground mb-0.5">
|
</TabsTrigger>
|
||||||
{CTX_LABELS[key] ?? key}
|
<TabsTrigger value="profile">用户画像 & 历史</TabsTrigger>
|
||||||
</dt>
|
</TabsList>
|
||||||
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{value}</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 历史摘要 (history) */}
|
{/* 事实记录 Tab */}
|
||||||
<div className="rounded-lg border p-4 space-y-2">
|
<TabsContent value="facts" className="mt-3">
|
||||||
<h3 className="text-base font-medium">历史摘要</h3>
|
<div className="rounded-lg border p-4 space-y-3">
|
||||||
{historyEntries.length === 0 ? (
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-sm text-muted-foreground">暂无历史摘要。</p>
|
<h3 className="text-base font-medium">事实记录</h3>
|
||||||
) : (
|
<span className="text-xs text-muted-foreground">{memory.memory_usage}</span>
|
||||||
<dl className="space-y-2">
|
</div>
|
||||||
{historyEntries.map(([key, value]) => (
|
{memory.facts.length === 0 ? (
|
||||||
<div key={key} className="text-sm">
|
<div className="rounded-md border border-dashed p-3 text-sm text-muted-foreground">
|
||||||
<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>
|
</div>
|
||||||
))}
|
) : (
|
||||||
</dl>
|
<div className="space-y-2">
|
||||||
)}
|
{memory.facts.map((fact) => (
|
||||||
</div>
|
<div key={fact.id} className="rounded-md border p-3 space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
{/* 事实记录 (facts) */}
|
<span
|
||||||
<div className="rounded-lg border p-4 space-y-3">
|
className={`rounded px-1.5 py-0.5 text-xs font-medium ${
|
||||||
<div className="flex items-center justify-between">
|
CATEGORY_COLORS[fact.category] ?? "bg-gray-100 text-gray-700"
|
||||||
<h3 className="text-base font-medium">事实记录</h3>
|
}`}
|
||||||
<span className="text-xs text-muted-foreground">{memory.memory_usage}</span>
|
>
|
||||||
</div>
|
{fact.category}
|
||||||
{memory.facts.length === 0 ? (
|
</span>
|
||||||
<div className="rounded-md border border-dashed p-3 text-sm text-muted-foreground">
|
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||||
暂无事实记录,对话后系统会自动提取。
|
<div
|
||||||
</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>
|
||||||
<div key={fact.id} className="rounded-md border p-3 space-y-1.5">
|
<span className="text-xs text-muted-foreground shrink-0">
|
||||||
<div className="flex items-center gap-2">
|
{Math.round(fact.confidence * 100)}%
|
||||||
<span
|
</span>
|
||||||
className={`rounded px-1.5 py-0.5 text-xs font-medium ${
|
</div>
|
||||||
CATEGORY_COLORS[fact.category] ?? "bg-gray-100 text-gray-700"
|
<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.category}
|
避免:{fact.sourceError}
|
||||||
</span>
|
</p>
|
||||||
<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>
|
</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>
|
</div>
|
||||||
)}
|
</TabsContent>
|
||||||
</div>
|
|
||||||
|
{/* 用户画像 & 历史摘要 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,18 +155,10 @@ export function WorkspaceNavMenu() {
|
|||||||
)}
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => navigate("/page/workspace/admin/users")}
|
onClick={() => navigate("/page/workspace/admin")}
|
||||||
>
|
>
|
||||||
<UsersIcon />
|
<UsersIcon />
|
||||||
用户管理
|
用户中心
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{isAdmin && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => navigate("/page/workspace/admin/memory")}
|
|
||||||
>
|
|
||||||
<BrainIcon />
|
|
||||||
记忆管理
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
@@ -179,16 +171,6 @@ export function WorkspaceNavMenu() {
|
|||||||
定时任务管理
|
定时任务管理
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{isAdmin && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() =>
|
|
||||||
navigate("/page/workspace/admin/user-activity")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<HistoryIcon />
|
|
||||||
用户活动审计
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => navigate("/page/workspace/admin/tags")}
|
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 UsersPage from "./UsersPage";
|
||||||
import AdminUserActivityPage from "./AdminUserActivityPage";
|
import AdminUserActivityPage from "./AdminUserActivityPage";
|
||||||
import AdminMemoryPage from "./AdminMemoryPage";
|
import AdminMemoryPage from "./AdminMemoryPage";
|
||||||
|
import AdminUserHubPage from "./AdminUserHubPage";
|
||||||
import TagsPage from "./TagsPage";
|
import TagsPage from "./TagsPage";
|
||||||
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
||||||
import AIWritingPage from "@/open-canvas/pages/AIWritingPage";
|
import AIWritingPage from "@/open-canvas/pages/AIWritingPage";
|
||||||
@@ -206,18 +207,32 @@ export default function WorkspaceRoutes({
|
|||||||
</WorkspaceLayout>
|
</WorkspaceLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* Unified admin user hub */}
|
||||||
<Route
|
<Route
|
||||||
path="admin/users"
|
path="admin"
|
||||||
element={
|
element={
|
||||||
getStoredAuth()?.system_role === "admin" ? (
|
getStoredAuth()?.system_role === "admin" ? (
|
||||||
<WorkspaceLayout {...layoutProps}>
|
<WorkspaceLayout {...layoutProps} pageSidebarHidden>
|
||||||
<UsersPage />
|
<AdminUserHubPage />
|
||||||
</WorkspaceLayout>
|
</WorkspaceLayout>
|
||||||
) : (
|
) : (
|
||||||
<Navigate to="/page/workspace/chats/new" replace />
|
<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
|
<Route
|
||||||
path="admin/scheduled-tasks"
|
path="admin/scheduled-tasks"
|
||||||
element={
|
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
|
<Route
|
||||||
path="admin/tags"
|
path="admin/tags"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -181,9 +181,10 @@ async def get_user_memory(user_id: str, request: Request) -> AdminMemoryResponse
|
|||||||
# V1 模式:每个桶读 memory.json,内容为 {facts, user, history}
|
# V1 模式:每个桶读 memory.json,内容为 {facts, user, history}
|
||||||
buckets: list[AdminMemoryBucket] = []
|
buckets: list[AdminMemoryBucket] = []
|
||||||
try:
|
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:
|
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:
|
for agent in owned_agents:
|
||||||
agent_id: str = agent["id"]
|
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: [...]}
|
# V2 模式:读 USER.md / MEMORY.md,内容为 {entries: [...]}
|
||||||
buckets = [
|
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:
|
for agent in owned_agents:
|
||||||
agent_id = agent["id"]
|
agent_id = agent["id"]
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ router = APIRouter(prefix="/api", tags=["memory"])
|
|||||||
|
|
||||||
|
|
||||||
# 表示"全局桶 (无 agent)"的 agent 取值。
|
# 表示"全局桶 (无 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:
|
def _resolve_agent_name(agent: str | None) -> str | None:
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ def build_memory_manager(
|
|||||||
|
|
||||||
manager.initialize_all(
|
manager.initialize_all(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
agent_id=agent_id or "default",
|
agent_id=agent_id,
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
base_dir=base_dir,
|
base_dir=base_dir,
|
||||||
)
|
)
|
||||||
|
|||||||
+6
-3
@@ -117,15 +117,18 @@ class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]):
|
|||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _resolve_agent_id(self, runtime: Runtime | None) -> str:
|
def _resolve_agent_id(self, runtime: Runtime | None) -> str | None:
|
||||||
"""解析 agent_id;优先 middleware 构造时的 agent_name,再 runtime context。"""
|
"""解析 agent_id;优先 middleware 构造时的 agent_name,再 runtime context。
|
||||||
|
|
||||||
|
无 agent 上下文时返回 None,使 V1 记忆存入全局文件(users/{id}/memory.json)。
|
||||||
|
"""
|
||||||
if self._agent_name:
|
if self._agent_name:
|
||||||
return self._agent_name
|
return self._agent_name
|
||||||
if runtime is not None and getattr(runtime, "context", None):
|
if runtime is not None and getattr(runtime, "context", None):
|
||||||
agent_id = runtime.context.get("agent_id")
|
agent_id = runtime.context.get("agent_id")
|
||||||
if agent_id:
|
if agent_id:
|
||||||
return str(agent_id)
|
return str(agent_id)
|
||||||
return "default"
|
return None
|
||||||
|
|
||||||
# ---- wrap_model_call:Hindsight 动态召回注入 -------------------------
|
# ---- wrap_model_call:Hindsight 动态召回注入 -------------------------
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user