Merge remote-tracking branch 'origin/master' into ji
This commit is contained in:
commit
ef410e298d
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={
|
||||
|
||||
@@ -181,9 +181,10 @@ async def get_user_memory(user_id: str, request: Request) -> 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"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
+6
-3
@@ -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 动态召回注入 -------------------------
|
||||
|
||||
|
||||
Reference in new issue
Block a user