feat: 智能体创建流程优化与管理员记忆页面
- 创建智能体页面:保存后自动跳转列表页、技能列表增加搜索过滤框 - Bootstrap 模式:开放全部技能可见性,注入专属提示词(skill_list 匹配意图、禁止 skill_manage) - 管理员记忆页面:新增 AdminMemoryPage 及路由,支持查看/编辑用户记忆条目 - 内存配置与管理员用户接口同步更新 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
18c2a4122f
commit
3748513996
12 files changed
+692
-143
No files matched your search
@@ -0,0 +1,371 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Check, Loader2, Pencil, Plus, Trash2, X } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
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 { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
useAdminAddMemoryEntry,
|
||||||
|
useAdminDeleteMemoryEntry,
|
||||||
|
useAdminReplaceMemoryEntry,
|
||||||
|
useUserMemory,
|
||||||
|
} from "@/core/admin/hooks";
|
||||||
|
import { useAllUsers } from "@/core/auth/hooks";
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Memory entry list with inline editing ──────────────────────────────────
|
||||||
|
|
||||||
|
function MemoryEntryList({
|
||||||
|
entries,
|
||||||
|
userId,
|
||||||
|
agent,
|
||||||
|
}: {
|
||||||
|
entries: string[];
|
||||||
|
userId: string;
|
||||||
|
agent: string | null;
|
||||||
|
}) {
|
||||||
|
const [editIndex, setEditIndex] = useState<number | null>(null);
|
||||||
|
const [editDraft, setEditDraft] = useState("");
|
||||||
|
const [addDraft, setAddDraft] = useState("");
|
||||||
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
|
||||||
|
const addMutation = useAdminAddMemoryEntry(userId);
|
||||||
|
const replaceMutation = useAdminReplaceMemoryEntry(userId);
|
||||||
|
const deleteMutation = useAdminDeleteMemoryEntry(userId);
|
||||||
|
|
||||||
|
const isPending =
|
||||||
|
addMutation.isPending || replaceMutation.isPending || deleteMutation.isPending;
|
||||||
|
|
||||||
|
function startEdit(index: number) {
|
||||||
|
setEditIndex(index);
|
||||||
|
setEditDraft(entries[index] ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
setEditIndex(null);
|
||||||
|
setEditDraft("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmEdit(index: number) {
|
||||||
|
const oldText = entries[index];
|
||||||
|
if (!oldText || editDraft.trim() === oldText.trim()) {
|
||||||
|
cancelEdit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await replaceMutation.mutateAsync({ oldText, content: editDraft.trim(), agent });
|
||||||
|
cancelEdit();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "修改失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(entry: string) {
|
||||||
|
try {
|
||||||
|
await deleteMutation.mutateAsync({ oldText: entry, agent });
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "删除失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAdd() {
|
||||||
|
const content = addDraft.trim();
|
||||||
|
if (!content) return;
|
||||||
|
try {
|
||||||
|
await addMutation.mutateAsync({ content, agent });
|
||||||
|
setAddDraft("");
|
||||||
|
setIsAdding(false);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "新增失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{entries.length === 0 && !isAdding && (
|
||||||
|
<p className="text-muted-foreground py-6 text-center text-sm">暂无记忆条目</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entries.map((entry, i) => (
|
||||||
|
<div key={i} className="group rounded-md border p-3">
|
||||||
|
{editIndex === i ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Textarea
|
||||||
|
value={editDraft}
|
||||||
|
onChange={(e) => setEditDraft(e.target.value)}
|
||||||
|
className="min-h-[80px] text-sm"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => confirmEdit(i)}
|
||||||
|
disabled={replaceMutation.isPending}
|
||||||
|
>
|
||||||
|
{replaceMutation.isPending ? (
|
||||||
|
<Loader2 className="size-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check className="size-3" />
|
||||||
|
)}
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={cancelEdit}>
|
||||||
|
<X className="size-3" />
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="text-muted-foreground mt-0.5 shrink-0 text-sm">·</span>
|
||||||
|
<span className="flex-1 whitespace-pre-wrap text-sm">{entry}</span>
|
||||||
|
<div className="flex shrink-0 gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="size-7"
|
||||||
|
onClick={() => startEdit(i)}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<Pencil className="size-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="size-7 hover:text-destructive"
|
||||||
|
onClick={() => handleDelete(entry)}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? (
|
||||||
|
<Loader2 className="size-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="size-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{isAdding ? (
|
||||||
|
<div className="flex flex-col gap-2 rounded-md border p-3">
|
||||||
|
<Textarea
|
||||||
|
placeholder="输入新记忆条目内容…"
|
||||||
|
value={addDraft}
|
||||||
|
onChange={(e) => setAddDraft(e.target.value)}
|
||||||
|
className="min-h-[80px] text-sm"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" onClick={handleAdd} disabled={addMutation.isPending || !addDraft.trim()}>
|
||||||
|
{addMutation.isPending ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}
|
||||||
|
确认
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => { setIsAdding(false); setAddDraft(""); }}>
|
||||||
|
<X className="size-3" />
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-1 w-full"
|
||||||
|
onClick={() => setIsAdding(true)}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<Plus className="size-3" />
|
||||||
|
新增条目
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Right panel: memory tabs for a selected user ───────────────────────────
|
||||||
|
|
||||||
|
function UserMemoryPanel({ userId }: { userId: string }) {
|
||||||
|
const { data: memory, isLoading } = useUserMemory(userId);
|
||||||
|
const [selectedAgent, setSelectedAgent] = useState<string | null>(null);
|
||||||
|
|
||||||
|
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 globalBucket = memory?.buckets.find((b) => b.name === "global");
|
||||||
|
const agentBuckets = (memory?.buckets ?? []).filter((b) => b.name !== "global");
|
||||||
|
|
||||||
|
const userEntries = globalBucket ? getEntries(globalBucket.data) : [];
|
||||||
|
|
||||||
|
const currentAgent = selectedAgent ?? agentBuckets[0]?.name ?? null;
|
||||||
|
const agentBucket = agentBuckets.find((b) => b.name === currentAgent);
|
||||||
|
const agentEntries = agentBucket ? getEntries(agentBucket.data) : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs defaultValue="user" className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<div className="border-b px-4 pt-4">
|
||||||
|
<TabsList variant="line">
|
||||||
|
<TabsTrigger value="user">
|
||||||
|
用户画像
|
||||||
|
{userEntries.length > 0 && (
|
||||||
|
<Badge variant="secondary" className="ml-1.5 text-xs">
|
||||||
|
{userEntries.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="agent">
|
||||||
|
Agent 记忆
|
||||||
|
{agentBuckets.length > 0 && (
|
||||||
|
<Badge variant="secondary" className="ml-1.5 text-xs">
|
||||||
|
{agentBuckets.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TabsContent value="user" className="flex-1 overflow-hidden p-0">
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<div className="p-4">
|
||||||
|
<p className="text-muted-foreground mb-3 text-xs">
|
||||||
|
USER.md — 跨 Agent 共享的用户画像
|
||||||
|
</p>
|
||||||
|
<MemoryEntryList entries={userEntries} userId={userId} agent={null} />
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="agent" className="flex-1 overflow-hidden p-0">
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<div className="p-4">
|
||||||
|
{agentBuckets.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground py-6 text-center text-sm">该用户暂无 Agent 记忆</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground text-xs">选择 Agent:</span>
|
||||||
|
<Select
|
||||||
|
value={currentAgent ?? ""}
|
||||||
|
onValueChange={(v) => setSelectedAgent(v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 w-48 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{agentBuckets.map((b) => (
|
||||||
|
<SelectItem key={b.name} value={b.name} className="text-xs">
|
||||||
|
{b.display_name || b.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
— MEMORY.md
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{currentAgent && (
|
||||||
|
<MemoryEntryList
|
||||||
|
entries={agentEntries}
|
||||||
|
userId={userId}
|
||||||
|
agent={currentAgent}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main page ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function AdminMemoryPage() {
|
||||||
|
const { data: users, isLoading: usersLoading } = useAllUsers(true);
|
||||||
|
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||||
|
const [userQuery, setUserQuery] = useState("");
|
||||||
|
|
||||||
|
const filteredUsers = (users ?? []).filter((u) =>
|
||||||
|
u.email.toLowerCase().includes(userQuery.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full">
|
||||||
|
{/* Left: user list */}
|
||||||
|
<div className="flex w-64 shrink-0 flex-col border-r">
|
||||||
|
<div className="border-b p-3">
|
||||||
|
<Input
|
||||||
|
placeholder="搜索用户…"
|
||||||
|
value={userQuery}
|
||||||
|
onChange={(e) => setUserQuery(e.target.value)}
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
{usersLoading && (
|
||||||
|
<div className="text-muted-foreground flex items-center justify-center gap-2 py-10 text-sm">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
加载中…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!usersLoading && filteredUsers.length === 0 && (
|
||||||
|
<p className="text-muted-foreground py-8 text-center text-sm">暂无用户</p>
|
||||||
|
)}
|
||||||
|
{filteredUsers.map((user) => (
|
||||||
|
<button
|
||||||
|
key={user.id}
|
||||||
|
className={`flex w-full flex-col items-start gap-0.5 px-3 py-2.5 text-left text-sm transition-colors hover:bg-accent ${
|
||||||
|
selectedUserId === user.id ? "bg-accent font-medium" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => setSelectedUserId(user.id)}
|
||||||
|
>
|
||||||
|
<span className="max-w-full truncate">{user.email}</span>
|
||||||
|
{user.system_role === "admin" && (
|
||||||
|
<Badge variant="default" className="h-4 px-1 text-[10px]">
|
||||||
|
Admin
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: memory tabs */}
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
{selectedUserId ? (
|
||||||
|
<UserMemoryPanel key={selectedUserId} userId={selectedUserId} />
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground flex flex-1 items-center justify-center text-sm">
|
||||||
|
请在左侧选择用户
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { PenLineIcon, PlusIcon, SearchIcon, Trash2Icon } from "lucide-react";
|
import { PenLineIcon, PlusIcon, SearchIcon, Trash2Icon } from "lucide-react";
|
||||||
import { useId, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -345,16 +345,13 @@ export function MemorySettingsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 记忆设置面板:用户可改的白名单字段。 */
|
/** 记忆设置面板:仅暴露 Hindsight 这一个可选开关,其余默认开启不展示。 */
|
||||||
function MemoryConfigPanel() {
|
function MemoryConfigPanel() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const m = t.settings.memory;
|
const m = t.settings.memory;
|
||||||
const { configResponse, isLoading } = useMemoryConfig();
|
const { configResponse, isLoading } = useMemoryConfig();
|
||||||
const patchConfig = usePatchMemoryConfig();
|
const patchConfig = usePatchMemoryConfig();
|
||||||
|
|
||||||
const maxTokensId = useId();
|
|
||||||
const bankMissionId = useId();
|
|
||||||
|
|
||||||
async function patch(overrides: Record<string, unknown>) {
|
async function patch(overrides: Record<string, unknown>) {
|
||||||
try {
|
try {
|
||||||
await patchConfig.mutateAsync(overrides);
|
await patchConfig.mutateAsync(overrides);
|
||||||
@@ -367,9 +364,7 @@ function MemoryConfigPanel() {
|
|||||||
const config: MemoryV2Config | null = configResponse?.config ?? null;
|
const config: MemoryV2Config | null = configResponse?.config ?? null;
|
||||||
const writable = new Set(configResponse?.writable_fields ?? []);
|
const writable = new Set(configResponse?.writable_fields ?? []);
|
||||||
|
|
||||||
function isWritable(field: string) {
|
const hindsightOn = config?.injection.include_hindsight ?? false;
|
||||||
return writable.has(field);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection title={m.settingsTitle} description={m.settingsDesc}>
|
<SettingsSection title={m.settingsTitle} description={m.settingsDesc}>
|
||||||
@@ -377,123 +372,43 @@ function MemoryConfigPanel() {
|
|||||||
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
|
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4 rounded-lg border p-4">
|
<div className="space-y-4 rounded-lg border p-4">
|
||||||
<ToggleRow
|
{/* 只暴露 Hindsight 开关,记忆总开关和本地记忆默认开启不展示 */}
|
||||||
label={m.injectionEnabled}
|
|
||||||
description={m.injectionEnabledDesc}
|
|
||||||
checked={config.injection.enabled}
|
|
||||||
disabled={!isWritable("injection.enabled") || patchConfig.isPending}
|
|
||||||
onChange={(v) => void patch({ injection: { enabled: v } })}
|
|
||||||
/>
|
|
||||||
<ToggleRow
|
|
||||||
label={m.includeBuiltin}
|
|
||||||
description={m.includeBuiltinDesc}
|
|
||||||
checked={config.injection.include_builtin}
|
|
||||||
disabled={
|
|
||||||
!isWritable("injection.include_builtin") || patchConfig.isPending
|
|
||||||
}
|
|
||||||
onChange={(v) => void patch({ injection: { include_builtin: v } })}
|
|
||||||
/>
|
|
||||||
<ToggleRow
|
<ToggleRow
|
||||||
label={m.includeHindsight}
|
label={m.includeHindsight}
|
||||||
description={m.includeHindsightDesc}
|
description={m.includeHindsightDesc}
|
||||||
checked={config.injection.include_hindsight}
|
checked={hindsightOn}
|
||||||
disabled={
|
disabled={
|
||||||
!isWritable("injection.include_hindsight") || patchConfig.isPending
|
!writable.has("injection.include_hindsight") || patchConfig.isPending
|
||||||
}
|
}
|
||||||
onChange={(v) => void patch({ injection: { include_hindsight: v } })}
|
onChange={(v) => void patch({ injection: { include_hindsight: v } })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-4">
|
{/* Hindsight 细节设置:仅在 Hindsight 开启时展示 */}
|
||||||
<div className="min-w-0">
|
{hindsightOn && (
|
||||||
<label htmlFor={maxTokensId} className="text-sm font-medium">
|
<>
|
||||||
{m.maxTokens}
|
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-4">
|
||||||
</label>
|
<div className="min-w-0">
|
||||||
<p className="text-muted-foreground text-sm">{m.maxTokensDesc}</p>
|
<label className="text-sm font-medium">{m.recallBudget}</label>
|
||||||
</div>
|
<p className="text-muted-foreground text-sm">{m.recallBudgetDesc}</p>
|
||||||
<Input
|
</div>
|
||||||
id={maxTokensId}
|
<Select
|
||||||
type="number"
|
value={config.hindsight.recall_budget}
|
||||||
min="100"
|
disabled={!writable.has("hindsight.recall_budget") || patchConfig.isPending}
|
||||||
max="8000"
|
onValueChange={(v) => void patch({ hindsight: { recall_budget: v } })}
|
||||||
step="100"
|
>
|
||||||
className="w-32"
|
<SelectTrigger className="w-44">
|
||||||
defaultValue={config.injection.max_tokens}
|
<SelectValue />
|
||||||
disabled={!isWritable("injection.max_tokens") || patchConfig.isPending}
|
</SelectTrigger>
|
||||||
onBlur={(event) => {
|
<SelectContent>
|
||||||
const value = Number(event.target.value);
|
<SelectItem value="low">{m.recallBudgetLow}</SelectItem>
|
||||||
if (
|
<SelectItem value="mid">{m.recallBudgetMid}</SelectItem>
|
||||||
Number.isFinite(value) &&
|
<SelectItem value="high">{m.recallBudgetHigh}</SelectItem>
|
||||||
value !== config.injection.max_tokens
|
</SelectContent>
|
||||||
) {
|
</Select>
|
||||||
void patch({ injection: { max_tokens: value } });
|
</div>
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-4">
|
</>
|
||||||
<div className="min-w-0">
|
)}
|
||||||
<label className="text-sm font-medium">{m.memoryMode}</label>
|
|
||||||
<p className="text-muted-foreground text-sm">{m.memoryModeDesc}</p>
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
value={config.hindsight.memory_mode}
|
|
||||||
disabled={!isWritable("hindsight.memory_mode") || patchConfig.isPending}
|
|
||||||
onValueChange={(v) => void patch({ hindsight: { memory_mode: v } })}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-44">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="context">{m.memoryModeContext}</SelectItem>
|
|
||||||
<SelectItem value="tools">{m.memoryModeTools}</SelectItem>
|
|
||||||
<SelectItem value="hybrid">{m.memoryModeHybrid}</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-4">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<label className="text-sm font-medium">{m.recallBudget}</label>
|
|
||||||
<p className="text-muted-foreground text-sm">{m.recallBudgetDesc}</p>
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
value={config.hindsight.recall_budget}
|
|
||||||
disabled={
|
|
||||||
!isWritable("hindsight.recall_budget") || patchConfig.isPending
|
|
||||||
}
|
|
||||||
onValueChange={(v) => void patch({ hindsight: { recall_budget: v } })}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-44">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="low">{m.recallBudgetLow}</SelectItem>
|
|
||||||
<SelectItem value="mid">{m.recallBudgetMid}</SelectItem>
|
|
||||||
<SelectItem value="high">{m.recallBudgetHigh}</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 border-t pt-4">
|
|
||||||
<label htmlFor={bankMissionId} className="text-sm font-medium">
|
|
||||||
{m.bankMission}
|
|
||||||
</label>
|
|
||||||
<p className="text-muted-foreground text-sm">{m.bankMissionDesc}</p>
|
|
||||||
<Textarea
|
|
||||||
id={bankMissionId}
|
|
||||||
rows={2}
|
|
||||||
defaultValue={config.hindsight.bank_mission}
|
|
||||||
placeholder={m.bankMissionPlaceholder}
|
|
||||||
disabled={!isWritable("hindsight.bank_mission") || patchConfig.isPending}
|
|
||||||
onBlur={(event) => {
|
|
||||||
const value = event.target.value;
|
|
||||||
if (value !== config.hindsight.bank_mission) {
|
|
||||||
void patch({ hindsight: { bank_mission: value } });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
BellIcon,
|
BellIcon,
|
||||||
|
BrainIcon,
|
||||||
CalendarClockIcon,
|
CalendarClockIcon,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
GaugeIcon,
|
GaugeIcon,
|
||||||
@@ -160,6 +161,14 @@ export function WorkspaceNavMenu() {
|
|||||||
用户管理
|
用户管理
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => navigate("/page/workspace/admin/memory")}
|
||||||
|
>
|
||||||
|
<BrainIcon />
|
||||||
|
记忆管理
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface AdminMessage {
|
|||||||
/** One memory bucket: the global file or a per-agent file. */
|
/** One memory bucket: the global file or a per-agent file. */
|
||||||
export interface AdminMemoryBucket {
|
export interface AdminMemoryBucket {
|
||||||
name: string;
|
name: string;
|
||||||
|
display_name?: string;
|
||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,3 +71,50 @@ export async function getUserMemory(
|
|||||||
"Failed to load memory",
|
"Failed to load memory",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function addAdminMemoryEntry(
|
||||||
|
userId: string,
|
||||||
|
content: string,
|
||||||
|
agent: string | null,
|
||||||
|
): Promise<AdminMemoryBucket> {
|
||||||
|
return parse(
|
||||||
|
await apiFetch(`${base()}/${encodeURIComponent(userId)}/memory/entries`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ content, agent }),
|
||||||
|
}),
|
||||||
|
"Failed to add memory entry",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function replaceAdminMemoryEntry(
|
||||||
|
userId: string,
|
||||||
|
oldText: string,
|
||||||
|
content: string,
|
||||||
|
agent: string | null,
|
||||||
|
): Promise<AdminMemoryBucket> {
|
||||||
|
return parse(
|
||||||
|
await apiFetch(`${base()}/${encodeURIComponent(userId)}/memory/entries`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ old_text: oldText, content, agent }),
|
||||||
|
}),
|
||||||
|
"Failed to replace memory entry",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAdminMemoryEntry(
|
||||||
|
userId: string,
|
||||||
|
oldText: string,
|
||||||
|
agent: string | null,
|
||||||
|
): Promise<AdminMemoryBucket> {
|
||||||
|
const params = new URLSearchParams({ old_text: oldText });
|
||||||
|
if (agent) params.set("agent", agent);
|
||||||
|
return parse(
|
||||||
|
await apiFetch(
|
||||||
|
`${base()}/${encodeURIComponent(userId)}/memory/entries?${params}`,
|
||||||
|
{ method: "DELETE" },
|
||||||
|
),
|
||||||
|
"Failed to delete memory entry",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { getUserMemory, listUserThreadMessages, listUserThreads } from "./api";
|
import {
|
||||||
|
addAdminMemoryEntry,
|
||||||
|
deleteAdminMemoryEntry,
|
||||||
|
getUserMemory,
|
||||||
|
listUserThreadMessages,
|
||||||
|
listUserThreads,
|
||||||
|
replaceAdminMemoryEntry,
|
||||||
|
} from "./api";
|
||||||
|
|
||||||
/** Threads owned by a given user. Disabled until a user is picked. */
|
/** Threads owned by a given user. Disabled until a user is picked. */
|
||||||
export function useUserThreads(userId: string | null) {
|
export function useUserThreads(userId: string | null) {
|
||||||
@@ -31,3 +38,35 @@ export function useUserMemory(userId: string | null) {
|
|||||||
enabled: !!userId,
|
enabled: !!userId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useMemoryInvalidate(userId: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return () => qc.invalidateQueries({ queryKey: ["admin-activity", "memory", userId] });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAdminAddMemoryEntry(userId: string) {
|
||||||
|
const invalidate = useMemoryInvalidate(userId);
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ content, agent }: { content: string; agent: string | null }) =>
|
||||||
|
addAdminMemoryEntry(userId, content, agent),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAdminReplaceMemoryEntry(userId: string) {
|
||||||
|
const invalidate = useMemoryInvalidate(userId);
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ oldText, content, agent }: { oldText: string; content: string; agent: string | null }) =>
|
||||||
|
replaceAdminMemoryEntry(userId, oldText, content, agent),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAdminDeleteMemoryEntry(userId: string) {
|
||||||
|
const invalidate = useMemoryInvalidate(userId);
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ oldText, agent }: { oldText: string; agent: string | null }) =>
|
||||||
|
deleteAdminMemoryEntry(userId, oldText, agent),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import AdminMemoryComponent from "@/components/workspace/admin/admin-memory-page";
|
||||||
|
|
||||||
|
export default function AdminMemoryPage() {
|
||||||
|
return <AdminMemoryComponent />;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
InfoIcon,
|
InfoIcon,
|
||||||
SaveIcon,
|
SaveIcon,
|
||||||
|
SearchIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
@@ -76,6 +77,7 @@ export default function NewAgentPage() {
|
|||||||
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
|
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
|
||||||
const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false);
|
const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false);
|
||||||
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
|
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
|
||||||
|
const [skillSearch, setSkillSearch] = useState("");
|
||||||
const [threadPrepared, setThreadPrepared] = useState(false);
|
const [threadPrepared, setThreadPrepared] = useState(false);
|
||||||
const { skills, isLoading: isSkillsLoading } = useSkills();
|
const { skills, isLoading: isSkillsLoading } = useSkills();
|
||||||
const { models, isLoading: isModelsLoading } = useModels();
|
const { models, isLoading: isModelsLoading } = useModels();
|
||||||
@@ -85,6 +87,16 @@ export default function NewAgentPage() {
|
|||||||
[skills],
|
[skills],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const filteredSkills = useMemo(() => {
|
||||||
|
const q = skillSearch.trim().toLowerCase();
|
||||||
|
if (!q) return enabledSkills;
|
||||||
|
return enabledSkills.filter(
|
||||||
|
(skill) =>
|
||||||
|
skill.name.toLowerCase().includes(q) ||
|
||||||
|
(skill.description ?? "").toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}, [enabledSkills, skillSearch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSkillSelectionTouched) {
|
if (isSkillSelectionTouched) {
|
||||||
return;
|
return;
|
||||||
@@ -230,7 +242,8 @@ export default function NewAgentPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.success(`智能体「${saved.name}」已保存`);
|
toast.success(`智能体「${saved.name}」已保存`);
|
||||||
}, [ensureAgent]);
|
router.push("/page/workspace/agents");
|
||||||
|
}, [ensureAgent, router]);
|
||||||
|
|
||||||
const handleChatSubmit = useCallback(
|
const handleChatSubmit = useCallback(
|
||||||
async (text: string) => {
|
async (text: string) => {
|
||||||
@@ -439,6 +452,17 @@ export default function NewAgentPage() {
|
|||||||
已选 {selectedSkills.length} 个
|
已选 {selectedSkills.length} 个
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{enabledSkills.length > 0 && (
|
||||||
|
<div className="relative">
|
||||||
|
<SearchIcon className="text-muted-foreground absolute left-2 top-1/2 size-3.5 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
placeholder="搜索技能名称或描述…"
|
||||||
|
value={skillSearch}
|
||||||
|
onChange={(e) => setSkillSearch(e.currentTarget.value)}
|
||||||
|
className="h-8 pl-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="max-h-[240px] overflow-y-auto rounded-md border p-2">
|
<div className="max-h-[240px] overflow-y-auto rounded-md border p-2">
|
||||||
{isSkillsLoading ? (
|
{isSkillsLoading ? (
|
||||||
<p className="text-muted-foreground text-sm">技能加载中...</p>
|
<p className="text-muted-foreground text-sm">技能加载中...</p>
|
||||||
@@ -446,9 +470,13 @@ export default function NewAgentPage() {
|
|||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
当前没有可用技能。
|
当前没有可用技能。
|
||||||
</p>
|
</p>
|
||||||
|
) : filteredSkills.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
没有匹配的技能。
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{enabledSkills.map((skill) => {
|
{filteredSkills.map((skill) => {
|
||||||
const checked = selectedSkills.includes(skill.name);
|
const checked = selectedSkills.includes(skill.name);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import SkillEvolutionPage from "./SkillEvolutionPage";
|
|||||||
import SkillsPage from "./SkillsPage";
|
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 TagsPage from "./TagsPage";
|
import TagsPage from "./TagsPage";
|
||||||
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
||||||
|
|
||||||
@@ -223,6 +224,18 @@ export default function WorkspaceRoutes({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<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={
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ from fastapi import APIRouter, HTTPException, Query, Request
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.gateway.deps import (
|
from app.gateway.deps import (
|
||||||
|
get_agent_store,
|
||||||
get_checkpointer,
|
get_checkpointer,
|
||||||
get_current_user_from_request,
|
get_current_user_from_request,
|
||||||
get_thread_store,
|
get_thread_store,
|
||||||
)
|
)
|
||||||
from deerflow.agents.memory import BuiltinFileProvider
|
from deerflow.agents.memory import BuiltinFileProvider
|
||||||
from deerflow.config.agents_config import AGENT_NAME_PATTERN
|
|
||||||
from deerflow.config.paths import get_paths
|
from deerflow.config.paths import get_paths
|
||||||
from deerflow.runtime import serialize_channel_values
|
from deerflow.runtime import serialize_channel_values
|
||||||
|
|
||||||
@@ -127,10 +127,22 @@ async def list_user_thread_messages(
|
|||||||
|
|
||||||
|
|
||||||
class AdminMemoryBucket(BaseModel):
|
class AdminMemoryBucket(BaseModel):
|
||||||
name: str = Field(..., description="Bucket name: 'global' or a custom-agent name")
|
name: str = Field(..., description="Bucket name: 'global' or the agent id (used for CRUD)")
|
||||||
|
display_name: str = Field("", description="Human-readable name shown in the UI")
|
||||||
data: dict = Field(default_factory=dict, description="Raw memory data for the bucket")
|
data: dict = Field(default_factory=dict, description="Raw memory data for the bucket")
|
||||||
|
|
||||||
|
|
||||||
|
class AdminMemoryEntryAddRequest(BaseModel):
|
||||||
|
content: str = Field(..., min_length=1, description="Entry content to add")
|
||||||
|
agent: str | None = Field(None, description="Agent name; None = global USER.md bucket")
|
||||||
|
|
||||||
|
|
||||||
|
class AdminMemoryEntryReplaceRequest(BaseModel):
|
||||||
|
old_text: str = Field(..., min_length=1, description="Short unique substring to locate the entry")
|
||||||
|
content: str = Field(..., min_length=1, description="New content to replace with")
|
||||||
|
agent: str | None = Field(None, description="Agent name; None = global USER.md bucket")
|
||||||
|
|
||||||
|
|
||||||
class AdminMemoryResponse(BaseModel):
|
class AdminMemoryResponse(BaseModel):
|
||||||
user_id: str
|
user_id: str
|
||||||
buckets: list[AdminMemoryBucket] = Field(default_factory=list)
|
buckets: list[AdminMemoryBucket] = Field(default_factory=list)
|
||||||
@@ -154,25 +166,103 @@ async def get_user_memory(user_id: str, request: Request) -> AdminMemoryResponse
|
|||||||
|
|
||||||
Memory V2 layout: the ``global`` bucket is ``USER.md`` (user profile);
|
Memory V2 layout: the ``global`` bucket is ``USER.md`` (user profile);
|
||||||
each per-agent bucket is that agent's ``MEMORY.md`` working notes.
|
each per-agent bucket is that agent's ``MEMORY.md`` working notes.
|
||||||
|
Agent list is sourced from the database (all agents owned by this user)
|
||||||
|
so agents without any existing MEMORY.md still appear.
|
||||||
"""
|
"""
|
||||||
await _require_admin(request)
|
await _require_admin(request)
|
||||||
|
|
||||||
buckets: list[AdminMemoryBucket] = [
|
buckets: list[AdminMemoryBucket] = [
|
||||||
AdminMemoryBucket(name="global", data=_read_memory_bucket(user_id, None))
|
AdminMemoryBucket(name="global", display_name="用户画像", data=_read_memory_bucket(user_id, None))
|
||||||
]
|
]
|
||||||
|
|
||||||
agents_root = get_paths().user_dir(user_id) / "agents"
|
# Source agents from the database: only agents owned by this specific user.
|
||||||
if agents_root.is_dir():
|
agent_store = get_agent_store(request)
|
||||||
for entry in sorted(agents_root.iterdir(), key=lambda p: p.name.lower()):
|
all_agents = await agent_store.list_visible(user_id)
|
||||||
if not entry.is_dir() or not (entry / "MEMORY.md").exists():
|
owned_agents = sorted(
|
||||||
continue
|
[a for a in all_agents if a.get("user_id") == user_id],
|
||||||
if not AGENT_NAME_PATTERN.match(entry.name):
|
key=lambda a: (a.get("name") or a["id"]).lower(),
|
||||||
continue
|
)
|
||||||
try:
|
|
||||||
data = _read_memory_bucket(user_id, entry.name)
|
for agent in owned_agents:
|
||||||
except Exception:
|
agent_id: str = agent["id"]
|
||||||
logger.warning("Failed to read memory bucket %s for user", entry.name)
|
display_name: str = agent.get("name") or agent_id
|
||||||
continue
|
try:
|
||||||
buckets.append(AdminMemoryBucket(name=entry.name, data=data))
|
data = _read_memory_bucket(user_id, agent_id)
|
||||||
|
except Exception:
|
||||||
|
data = {"entries": []}
|
||||||
|
buckets.append(AdminMemoryBucket(name=agent_id, display_name=display_name, data=data))
|
||||||
|
|
||||||
return AdminMemoryResponse(user_id=user_id, buckets=buckets)
|
return AdminMemoryResponse(user_id=user_id, buckets=buckets)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{user_id}/memory/entries", response_model=AdminMemoryBucket)
|
||||||
|
async def admin_add_memory_entry(
|
||||||
|
user_id: str,
|
||||||
|
body: AdminMemoryEntryAddRequest,
|
||||||
|
request: Request,
|
||||||
|
) -> AdminMemoryBucket:
|
||||||
|
"""Add a memory entry for a user (admin only)."""
|
||||||
|
await _require_admin(request)
|
||||||
|
target = "user" if body.agent is None else "memory"
|
||||||
|
provider = BuiltinFileProvider()
|
||||||
|
provider.initialize(user_id=user_id, agent_id=body.agent)
|
||||||
|
try:
|
||||||
|
result = provider.add(target, body.content)
|
||||||
|
if result is False:
|
||||||
|
raise HTTPException(status_code=400, detail="Failed to add entry (quota exceeded or write error)")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("admin_add_memory_entry failed for user %s: %s", user_id, exc, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to write memory entry") from exc
|
||||||
|
bucket_name = "global" if body.agent is None else body.agent
|
||||||
|
return AdminMemoryBucket(name=bucket_name, data=_read_memory_bucket(user_id, body.agent))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{user_id}/memory/entries", response_model=AdminMemoryBucket)
|
||||||
|
async def admin_replace_memory_entry(
|
||||||
|
user_id: str,
|
||||||
|
body: AdminMemoryEntryReplaceRequest,
|
||||||
|
request: Request,
|
||||||
|
) -> AdminMemoryBucket:
|
||||||
|
"""Replace a memory entry for a user (admin only)."""
|
||||||
|
await _require_admin(request)
|
||||||
|
target = "user" if body.agent is None else "memory"
|
||||||
|
provider = BuiltinFileProvider()
|
||||||
|
provider.initialize(user_id=user_id, agent_id=body.agent)
|
||||||
|
try:
|
||||||
|
result = provider.replace(target, body.old_text, body.content)
|
||||||
|
if result is False:
|
||||||
|
raise HTTPException(status_code=404, detail="Entry not found or write failed")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("admin_replace_memory_entry failed for user %s: %s", user_id, exc, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to replace memory entry") from exc
|
||||||
|
bucket_name = "global" if body.agent is None else body.agent
|
||||||
|
return AdminMemoryBucket(name=bucket_name, data=_read_memory_bucket(user_id, body.agent))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{user_id}/memory/entries", response_model=AdminMemoryBucket)
|
||||||
|
async def admin_delete_memory_entry(
|
||||||
|
user_id: str,
|
||||||
|
request: Request,
|
||||||
|
old_text: str = Query(..., min_length=1, description="Short unique substring to locate the entry"),
|
||||||
|
agent: str | None = Query(None, description="Agent name; None = global USER.md bucket"),
|
||||||
|
) -> AdminMemoryBucket:
|
||||||
|
"""Delete a memory entry for a user (admin only)."""
|
||||||
|
await _require_admin(request)
|
||||||
|
target = "user" if agent is None else "memory"
|
||||||
|
provider = BuiltinFileProvider()
|
||||||
|
provider.initialize(user_id=user_id, agent_id=agent)
|
||||||
|
try:
|
||||||
|
result = provider.remove(target, old_text)
|
||||||
|
if result is False:
|
||||||
|
raise HTTPException(status_code=404, detail="Entry not found")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("admin_delete_memory_entry failed for user %s: %s", user_id, exc, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to delete memory entry") from exc
|
||||||
|
bucket_name = "global" if agent is None else agent
|
||||||
|
return AdminMemoryBucket(name=bucket_name, data=_read_memory_bucket(user_id, agent))
|
||||||
+3
-2
@@ -375,7 +375,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
|||||||
config["metadata"] = {}
|
config["metadata"] = {}
|
||||||
|
|
||||||
if is_bootstrap:
|
if is_bootstrap:
|
||||||
runtime_available_skills: list[str] | None = ["bootstrap"]
|
runtime_available_skills: list[str] | None = None # all enabled skills visible in bootstrap
|
||||||
elif agent_config:
|
elif agent_config:
|
||||||
# Custom agents must use an explicit allowlist. If omitted, default to none.
|
# Custom agents must use an explicit allowlist. If omitted, default to none.
|
||||||
runtime_available_skills = list(agent_config.skills or [])
|
runtime_available_skills = list(agent_config.skills or [])
|
||||||
@@ -416,7 +416,8 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
|||||||
max_concurrent_subagents=max_concurrent_subagents,
|
max_concurrent_subagents=max_concurrent_subagents,
|
||||||
writing_mode=writing_mode,
|
writing_mode=writing_mode,
|
||||||
writing_artifact_path=writing_artifact_path,
|
writing_artifact_path=writing_artifact_path,
|
||||||
available_skills=set(["bootstrap"]),
|
available_skills=None,
|
||||||
|
is_bootstrap=True,
|
||||||
app_config=resolved_app_config,
|
app_config=resolved_app_config,
|
||||||
),
|
),
|
||||||
state_schema=ThreadState,
|
state_schema=ThreadState,
|
||||||
|
|||||||
+28
@@ -496,6 +496,8 @@ You: "Deploying to staging..." [proceed]
|
|||||||
|
|
||||||
{skills_section}
|
{skills_section}
|
||||||
|
|
||||||
|
{bootstrap_section}
|
||||||
|
|
||||||
{deferred_tools_section}
|
{deferred_tools_section}
|
||||||
|
|
||||||
{writing_mode_section}
|
{writing_mode_section}
|
||||||
@@ -819,6 +821,27 @@ def _build_custom_mounts_section(*, app_config: AppConfig | None = None) -> str:
|
|||||||
return f"\n**Custom Mounted Directories:**\n{mounts_list}\n- If the user needs files outside `/mnt/user-data`, use these absolute container paths directly when they match the requested directory"
|
return f"\n**Custom Mounted Directories:**\n{mounts_list}\n- If the user needs files outside `/mnt/user-data`, use these absolute container paths directly when they match the requested directory"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_bootstrap_section() -> str:
|
||||||
|
return """<bootstrap_agent_creation>
|
||||||
|
你正在帮助用户创建一个自定义智能体。
|
||||||
|
|
||||||
|
**技能匹配规则(必须遵守):**
|
||||||
|
1. 先调用 `skill_list` 查看用户当前所有可用技能及其描述。
|
||||||
|
2. 根据用户对新智能体的描述/意图,从已有技能中选出最相关的 1-5 个技能名称(skill name,使用 hyphen-case)。
|
||||||
|
3. 将选出的技能名称填入 `setup_agent` 的 `skills` 参数。
|
||||||
|
4. **严禁在本次创建流程中调用 `skill_manage`(创建、编辑或删除技能)**。如用户提出需要新技能,请告知他们创建完智能体后再单独创建。
|
||||||
|
5. 如果没有任何技能与用户意图相关,可将 `skills` 设为空列表,并向用户说明原因。
|
||||||
|
|
||||||
|
**工作流程:**
|
||||||
|
1. 与用户充分沟通,明确新智能体的用途、专长和边界。
|
||||||
|
2. 调用 `skill_list` 列出现有技能。
|
||||||
|
3. 在思考中匹配技能,选出最合适的。
|
||||||
|
4. 草拟 SOUL.md(定义智能体的人设、工作方式、输出要求)。
|
||||||
|
5. 向用户确认配置后,调用 `setup_agent` 提交。
|
||||||
|
</bootstrap_agent_creation>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def apply_prompt_template(
|
def apply_prompt_template(
|
||||||
subagent_enabled: bool = False,
|
subagent_enabled: bool = False,
|
||||||
max_concurrent_subagents: int = 3,
|
max_concurrent_subagents: int = 3,
|
||||||
@@ -828,6 +851,7 @@ def apply_prompt_template(
|
|||||||
writing_mode: bool = False,
|
writing_mode: bool = False,
|
||||||
writing_artifact_path: str | None = None,
|
writing_artifact_path: str | None = None,
|
||||||
available_skills: set[str] | None = None,
|
available_skills: set[str] | None = None,
|
||||||
|
is_bootstrap: bool = False,
|
||||||
app_config: AppConfig | None = None,
|
app_config: AppConfig | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
storage_agent_id = agent_id if agent_id is not None else agent_name
|
storage_agent_id = agent_id if agent_id is not None else agent_name
|
||||||
@@ -857,6 +881,9 @@ def apply_prompt_template(
|
|||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Bootstrap section: skill-matching guidance (only in agent creation flow)
|
||||||
|
bootstrap_section = _build_bootstrap_section() if is_bootstrap else ""
|
||||||
|
|
||||||
# Get skills section
|
# Get skills section
|
||||||
skills_section = get_skills_prompt_section(available_skills, app_config=app_config)
|
skills_section = get_skills_prompt_section(available_skills, app_config=app_config)
|
||||||
|
|
||||||
@@ -902,6 +929,7 @@ Writing mode is enabled for this run.
|
|||||||
agent_name=agent_name or storage_agent_id or "CM 助手",
|
agent_name=agent_name or storage_agent_id or "CM 助手",
|
||||||
soul=get_agent_soul(storage_agent_id),
|
soul=get_agent_soul(storage_agent_id),
|
||||||
skills_section=skills_section,
|
skills_section=skills_section,
|
||||||
|
bootstrap_section=bootstrap_section,
|
||||||
deferred_tools_section=deferred_tools_section,
|
deferred_tools_section=deferred_tools_section,
|
||||||
writing_mode_section=writing_mode_section,
|
writing_mode_section=writing_mode_section,
|
||||||
memory_context=memory_context,
|
memory_context=memory_context,
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ class HindsightMemoryConfig(BaseModel):
|
|||||||
description="agent 私有工作 bank 命名模板",
|
description="agent 私有工作 bank 命名模板",
|
||||||
)
|
)
|
||||||
memory_mode: str = Field(
|
memory_mode: str = Field(
|
||||||
default="context",
|
default="hybrid",
|
||||||
description="工具暴露策略:context(仅自动) / tools(仅工具) / hybrid(两者)",
|
description="工具暴露策略:context(仅自动) / tools(仅工具) / hybrid(两者)",
|
||||||
)
|
)
|
||||||
bank_mission: str = Field(default="", description="reflect 推理的身份/框架描述")
|
bank_mission: str = Field(default="", description="reflect 推理的身份/框架描述")
|
||||||
@@ -136,13 +136,15 @@ class MemoryConfig(BaseModel):
|
|||||||
# 不在此表中的字段必须改 config.yaml 并重启 (例如 provider / api_key / 字符配额)。
|
# 不在此表中的字段必须改 config.yaml 并重启 (例如 provider / api_key / 字符配额)。
|
||||||
USER_WRITABLE_FIELDS: frozenset[str] = frozenset(
|
USER_WRITABLE_FIELDS: frozenset[str] = frozenset(
|
||||||
{
|
{
|
||||||
"injection.enabled",
|
# injection.enabled and injection.include_builtin are always ON;
|
||||||
|
# removing them from the whitelist makes _filter_to_whitelist drop
|
||||||
|
# any per-user override for these fields, so stored "false" values
|
||||||
|
# in existing memory_config.json files are silently ignored.
|
||||||
"injection.max_tokens",
|
"injection.max_tokens",
|
||||||
"injection.include_builtin",
|
|
||||||
"injection.include_hindsight",
|
"injection.include_hindsight",
|
||||||
"hindsight.memory_mode",
|
# hindsight.memory_mode is fixed to "hybrid"; not user-writable.
|
||||||
|
# hindsight.bank_mission is fixed; not user-writable.
|
||||||
"hindsight.recall_budget",
|
"hindsight.recall_budget",
|
||||||
"hindsight.bank_mission",
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user