feat: V1记忆系统支持、管理员记忆编辑及对话记忆注入开关

- 新增 V1 记忆系统(memory.json)支持:提取结果强制中文、历史字段修复为增量累积日志
- 用户设置页面:V1 模式下新增 agent 选择器,可查看各 agent 的私有记忆
- 管理员记忆页面:区分 V1/V2 渲染,V1 支持完整 CRUD(用户画像/历史摘要/事实记录)
- 后端新增 V1 记忆 CRUD API(sections/facts),前端同步添加对应 hooks 和 API 调用
- 新建专家页面:标记 bootstrap 线程类型,跳过记忆保存,侧边栏过滤该类线程
- 输入框工具栏新增「记忆」开关:关闭时 V1/V2 均不注入,开启时按 config.yaml 版本正常注入
- 修复 agent 目录不存在时的 FileNotFoundError,回退至默认配置

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
xuhdandClaude Sonnet 4.6 committed 2026-05-22 12:32:47 +08:00
1 parent d59e0514b3
commit b3a39cbdf7
27 files changed
+2725 -92

No files matched your search

@@ -19,8 +19,12 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
useAdminAddMemoryEntry,
useAdminAddV1Fact,
useAdminDeleteMemoryEntry,
useAdminDeleteV1Fact,
useAdminReplaceMemoryEntry,
useAdminUpdateV1Fact,
useAdminUpdateV1Section,
useUserMemory,
} from "@/core/admin/hooks";
import { useAllUsers } from "@/core/auth/hooks";
@@ -31,6 +35,288 @@ function getEntries(data: Record<string, unknown>): string[] {
return raw.filter((e): e is string => typeof e === "string" && e.trim().length > 0);
}
// ── V1 只读视图 ────────────────────────────────────────────────────────────
const CTX_LABELS: Record<string, string> = {
workContext: "工作背景",
personalContext: "个人信息",
topOfMind: "当前重点",
};
const HISTORY_LABELS: Record<string, string> = {
recentMonths: "近期",
earlierContext: "早期背景",
longTermBackground: "长期背景",
};
const CATEGORY_COLORS: Record<string, string> = {
preference: "bg-blue-100 text-blue-700",
knowledge: "bg-green-100 text-green-700",
context: "bg-yellow-100 text-yellow-700",
behavior: "bg-purple-100 text-purple-700",
goal: "bg-orange-100 text-orange-700",
correction: "bg-red-100 text-red-700",
};
const V1_CATEGORIES = ["preference", "knowledge", "context", "behavior", "goal", "correction"];
function extractSummary(val: unknown): string {
if (typeof val === "string") return val;
if (val && typeof val === "object" && "summary" in val)
return String((val as Record<string, unknown>).summary ?? "");
return "";
}
// 单个摘要字段的行内编辑
function V1SectionField({
label,
value,
onSave,
}: {
label: string;
value: string;
onSave: (v: string) => Promise<void>;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const [saving, setSaving] = useState(false);
async function handleSave() {
if (draft.trim() === value.trim()) { setEditing(false); return; }
setSaving(true);
try { await onSave(draft.trim()); setEditing(false); }
catch (err) { toast.error(err instanceof Error ? err.message : "保存失败"); }
finally { setSaving(false); }
}
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">{label}</span>
{!editing && (
<Button size="icon" variant="ghost" className="size-6" onClick={() => { setDraft(value); setEditing(true); }}>
<Pencil className="size-3" />
</Button>
)}
</div>
{editing ? (
<div className="space-y-1.5">
<Textarea value={draft} onChange={(e) => setDraft(e.target.value)} className="min-h-[72px] text-sm" autoFocus />
<div className="flex gap-1.5">
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}保存
</Button>
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}><X className="size-3" />取消</Button>
</div>
</div>
) : (
<p className="text-sm whitespace-pre-wrap text-foreground/80 min-h-[1.5rem]">{value || <span className="text-muted-foreground italic">(空)</span>}</p>
)}
</div>
);
}
// 单条事实的行内编辑
function V1FactItem({
fact,
agent,
userId,
}: {
fact: Record<string, unknown>;
agent: string | null;
userId: string;
}) {
const [editing, setEditing] = useState(false);
const [draftContent, setDraftContent] = useState(String(fact.content ?? ""));
const [draftCategory, setDraftCategory] = useState(String(fact.category ?? "context"));
const [draftConfidence, setDraftConfidence] = useState(
typeof fact.confidence === "number" ? Math.round(fact.confidence * 100) : 80,
);
const updateFact = useAdminUpdateV1Fact(userId);
const deleteFact = useAdminDeleteV1Fact(userId);
const factId = String(fact.id ?? "");
const confidence = typeof fact.confidence === "number" ? fact.confidence : 0;
const category = String(fact.category ?? "context");
async function handleSave() {
try {
await updateFact.mutateAsync({
factId,
updates: { content: draftContent.trim(), category: draftCategory, confidence: draftConfidence / 100 },
agent,
});
setEditing(false);
} catch (err) { toast.error(err instanceof Error ? err.message : "更新失败"); }
}
async function handleDelete() {
try { await deleteFact.mutateAsync({ factId, agent }); }
catch (err) { toast.error(err instanceof Error ? err.message : "删除失败"); }
}
return (
<div className="rounded border p-2 space-y-1 group">
{editing ? (
<div className="space-y-2">
<Textarea value={draftContent} onChange={(e) => setDraftContent(e.target.value)} className="min-h-[64px] text-sm" autoFocus />
<div className="flex items-center gap-2 flex-wrap">
<Select value={draftCategory} onValueChange={setDraftCategory}>
<SelectTrigger className="h-7 w-36 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>{V1_CATEGORIES.map((c) => <SelectItem key={c} value={c} className="text-xs">{c}</SelectItem>)}</SelectContent>
</Select>
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">置信度</span>
<input
type="number" min={0} max={100} value={draftConfidence}
onChange={(e) => setDraftConfidence(Number(e.target.value))}
className="w-16 h-7 rounded border px-2 text-xs"
/>
<span className="text-xs text-muted-foreground">%</span>
</div>
</div>
<div className="flex gap-1.5">
<Button size="sm" onClick={handleSave} disabled={updateFact.isPending}>
{updateFact.isPending ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}保存
</Button>
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}><X className="size-3" />取消</Button>
</div>
</div>
) : (
<>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${CATEGORY_COLORS[category] ?? "bg-gray-100 text-gray-700"}`}>{category}</span>
<span className="text-xs text-muted-foreground">{Math.round(confidence * 100)}%</span>
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button size="icon" variant="ghost" className="size-6" onClick={() => { setDraftContent(String(fact.content ?? "")); setDraftCategory(category); setDraftConfidence(Math.round(confidence * 100)); setEditing(true); }}>
<Pencil className="size-3" />
</Button>
<Button size="icon" variant="ghost" className="size-6 hover:text-destructive" onClick={handleDelete} disabled={deleteFact.isPending}>
{deleteFact.isPending ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
</Button>
</div>
</div>
<p className="text-sm whitespace-pre-wrap">{String(fact.content ?? "")}</p>
{fact.sourceError ? <p className="text-xs text-red-600 bg-red-50 rounded px-2 py-1">避免:{String(fact.sourceError)}</p> : null}
</>
)}
</div>
);
}
// 新增事实表单
function V1AddFactForm({ agent, userId }: { agent: string | null; userId: string }) {
const [open, setOpen] = useState(false);
const [content, setContent] = useState("");
const [category, setCategory] = useState("context");
const [confidence, setConfidence] = useState(80);
const addFact = useAdminAddV1Fact(userId);
async function handleAdd() {
if (!content.trim()) return;
try {
await addFact.mutateAsync({ content: content.trim(), category, confidence: confidence / 100, agent });
setContent(""); setCategory("context"); setConfidence(80); setOpen(false);
} catch (err) { toast.error(err instanceof Error ? err.message : "新增失败"); }
}
if (!open) {
return (
<Button variant="outline" size="sm" className="mt-1 w-full" onClick={() => setOpen(true)}>
<Plus className="size-3" />新增事实
</Button>
);
}
return (
<div className="rounded-md border p-3 space-y-2">
<Textarea placeholder="事实内容…" value={content} onChange={(e) => setContent(e.target.value)} className="min-h-[64px] text-sm" autoFocus />
<div className="flex items-center gap-2 flex-wrap">
<Select value={category} onValueChange={setCategory}>
<SelectTrigger className="h-7 w-36 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>{V1_CATEGORIES.map((c) => <SelectItem key={c} value={c} className="text-xs">{c}</SelectItem>)}</SelectContent>
</Select>
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">置信度</span>
<input type="number" min={0} max={100} value={confidence} onChange={(e) => setConfidence(Number(e.target.value))} className="w-16 h-7 rounded border px-2 text-xs" />
<span className="text-xs text-muted-foreground">%</span>
</div>
</div>
<div className="flex gap-1.5">
<Button size="sm" onClick={handleAdd} disabled={addFact.isPending || !content.trim()}>
{addFact.isPending ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}确认
</Button>
<Button size="sm" variant="ghost" onClick={() => setOpen(false)}><X className="size-3" />取消</Button>
</div>
</div>
);
}
function V1BucketView({
data,
agent,
userId,
}: {
data: Record<string, unknown>;
agent: string | null;
userId: string;
}) {
const updateSection = useAdminUpdateV1Section(userId);
const ctxRaw = (data.user ?? data.userContext ?? {}) as Record<string, unknown>;
const historyRaw = (data.history ?? {}) as Record<string, unknown>;
const factsRaw = Array.isArray(data.facts) ? (data.facts as Record<string, unknown>[]) : [];
const allCtxFields = ["workContext", "personalContext", "topOfMind"];
const allHistoryFields = ["recentMonths", "earlierContext", "longTermBackground"];
if (!Object.keys(ctxRaw).length && !Object.keys(historyRaw).length && !factsRaw.length) {
return <p className="text-muted-foreground py-6 text-center text-sm">暂无 V1 记忆</p>;
}
return (
<div className="space-y-4">
{/* 用户画像字段 */}
<div className="rounded-md border p-3 space-y-3">
<h4 className="text-xs font-semibold text-muted-foreground">用户画像</h4>
{allCtxFields.map((field) => (
<V1SectionField
key={field}
label={CTX_LABELS[field] ?? field}
value={extractSummary(ctxRaw[field])}
onSave={(summary) =>
updateSection.mutateAsync({ sectionType: "user", field, summary, agent }).then(() => undefined)
}
/>
))}
</div>
{/* 历史摘要字段 */}
<div className="rounded-md border p-3 space-y-3">
<h4 className="text-xs font-semibold text-muted-foreground">历史摘要</h4>
{allHistoryFields.map((field) => (
<V1SectionField
key={field}
label={HISTORY_LABELS[field] ?? field}
value={extractSummary(historyRaw[field])}
onSave={(summary) =>
updateSection.mutateAsync({ sectionType: "history", field, summary, agent }).then(() => undefined)
}
/>
))}
</div>
{/* 事实记录 */}
<div className="rounded-md border p-3 space-y-2">
<h4 className="text-xs font-semibold text-muted-foreground">事实记录({factsRaw.length} 条)</h4>
{factsRaw.map((fact, i) => (
<V1FactItem key={String(fact.id ?? i)} fact={fact} agent={agent} userId={userId} />
))}
<V1AddFactForm agent={agent} userId={userId} />
</div>
</div>
);
}
// ── Memory entry list with inline editing ──────────────────────────────────
function MemoryEntryList({
@@ -217,11 +503,76 @@ function UserMemoryPanel({ userId }: { userId: string }) {
);
}
const isV1 = memory?.version === "v1";
const globalBucket = memory?.buckets.find((b) => b.name === "global");
const agentBuckets = (memory?.buckets ?? []).filter((b) => b.name !== "global");
const userEntries = globalBucket ? getEntries(globalBucket.data) : [];
// ── V1 只读视图 ──────────────────────────────────────────────────────────
if (isV1) {
const currentAgent = selectedAgent ?? agentBuckets[0]?.name ?? null;
const currentBucket = currentAgent
? agentBuckets.find((b) => b.name === currentAgent)
: globalBucket;
return (
<Tabs defaultValue="global" className="flex flex-1 flex-col overflow-hidden">
<div className="border-b px-4 pt-4">
<div className="mb-2 rounded-md bg-amber-50 border border-amber-200 px-3 py-1.5 text-xs text-amber-700">
V1 模式:记忆由系统自动提取,不支持手动管理
</div>
<TabsList variant="line">
<TabsTrigger value="global">用户记忆</TabsTrigger>
{agentBuckets.length > 0 && (
<TabsTrigger value="agent">
Agent 记忆
<Badge variant="secondary" className="ml-1.5 text-xs">
{agentBuckets.length}
</Badge>
</TabsTrigger>
)}
</TabsList>
</div>
<TabsContent value="global" className="flex-1 overflow-hidden p-0">
<ScrollArea className="h-full">
<div className="p-4">
{globalBucket ? <V1BucketView data={globalBucket.data} agent={null} userId={userId} /> : (
<p className="text-muted-foreground py-6 text-center text-sm">暂无数据</p>
)}
</div>
</ScrollArea>
</TabsContent>
{agentBuckets.length > 0 && (
<TabsContent value="agent" className="flex-1 overflow-hidden p-0">
<ScrollArea className="h-full">
<div className="p-4">
<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>
</div>
{currentBucket && <V1BucketView data={currentBucket.data} agent={currentAgent} userId={userId} />}
</div>
</ScrollArea>
</TabsContent>
)}
</Tabs>
);
}
// ── V2 可编辑视图 ────────────────────────────────────────────────────────
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) : [];
@@ -284,16 +635,10 @@ function UserMemoryPanel({ userId }: { userId: string }) {
))}
</SelectContent>
</Select>
<span className="text-muted-foreground text-xs">
— MEMORY.md
</span>
<span className="text-muted-foreground text-xs">— MEMORY.md</span>
</div>
{currentAgent && (
<MemoryEntryList
entries={agentEntries}
userId={userId}
agent={currentAgent}
/>
<MemoryEntryList entries={agentEntries} userId={userId} agent={currentAgent} />
)}
</>
)}
@@ -2,6 +2,7 @@
import type { ChatStatus } from "ai";
import {
BrainCircuitIcon,
CheckIcon,
FileTextIcon,
GraduationCapIcon,
@@ -134,6 +135,7 @@ export function InputBox({
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean;
memory_injection_enabled?: boolean;
};
extraHeader?: React.ReactNode;
isNewThread?: boolean;
@@ -147,6 +149,7 @@ export function InputBox({
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean;
memory_injection_enabled?: boolean;
},
) => void;
onFollowupsVisibilityChange?: (visible: boolean) => void;
@@ -285,6 +288,14 @@ export function InputBox({
});
}, [context, onContextChange]);
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
const handleMemoryInjectionToggle = useCallback(() => {
onContextChange?.({
...context,
memory_injection_enabled: !memoryInjectionEnabled,
});
}, [context, memoryInjectionEnabled, onContextChange]);
const handleSubmit = useCallback(
async (message: PromptInputMessage) => {
if (status === "streaming") {
@@ -590,6 +601,26 @@ export function InputBox({
<span className="text-xs font-normal">写作</span>
</PromptInputButton>
</Tooltip>
<Tooltip
content={
memoryInjectionEnabled
? "记忆注入已开启:对话前会注入历史记忆"
: "记忆注入已关闭:本次对话不注入记忆"
}
>
<PromptInputButton
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
memoryInjectionEnabled
? "border border-blue-300 bg-blue-50 text-blue-700 hover:bg-blue-100 dark:border-blue-700 dark:bg-blue-950/40 dark:text-blue-200"
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
)}
onClick={handleMemoryInjectionToggle}
>
<BrainCircuitIcon className="size-3" />
<span className="text-xs font-normal">记忆</span>
</PromptInputButton>
</Tooltip>
<PromptInputActionMenu>
<ModeHoverGuide
mode={
@@ -150,7 +150,9 @@ export function RecentChatList() {
);
const visibleThreads = threads.filter(
(thread) => thread.metadata?.thread_type !== "scheduler",
(thread) =>
thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "bootstrap",
);
if (visibleThreads.length === 0) {
@@ -36,6 +36,7 @@ import {
useReplaceMemoryEntry,
} from "@/core/memory/hooks";
import type {
MemoryFact,
MemorySearchMode,
MemoryTarget,
MemoryV2Config,
@@ -54,6 +55,10 @@ export function MemorySettingsPage() {
const { t } = useI18n();
const m = t.settings.memory;
// 先读配置拿到 version,再决定渲染哪套 UI
const { configResponse } = useMemoryConfig();
const memoryVersion = configResponse?.version ?? "v2";
// 工作笔记按专家隔离 —— selectedAgent 控制查看/编辑哪个专家的 MEMORY.md。
// 用户画像 (USER.md) 是 per-user 的,不受 selectedAgent 影响。
// 专家列表来自 agents 注册表 (非内建的才是用户的专家);default 是每个
@@ -211,7 +216,33 @@ export function MemorySettingsPage() {
</div>
) : !memory ? (
<div className="text-muted-foreground text-sm">{m.entryEmpty}</div>
) : memoryVersion === "v1" ? (
// V1 模式:只读展示 facts 卡片列表,支持按 agent 切换
<div className="space-y-4">
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-sm">
{m.workNotesAgentLabel}:
</span>
<Select value={selectedAgent} onValueChange={setSelectedAgent}>
<SelectTrigger className="h-8 w-44" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">
{m.workNotesDefaultAgent}
</SelectItem>
{experts.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<MemoryV1View memory={memory} />
</div>
) : (
// V2 模式:USER.md / MEMORY.md 条目编辑
<div className="space-y-4">
{renderEntrySection(
"user",
@@ -251,13 +282,17 @@ export function MemorySettingsPage() {
)}
</SettingsSection>
<div className="mt-8">
<MemoryConfigPanel />
</div>
{memoryVersion !== "v1" && (
<>
<div className="mt-8">
<MemoryConfigPanel />
</div>
<div className="mt-8">
<MemorySearchPanel />
</div>
<div className="mt-8">
<MemorySearchPanel />
</div>
</>
)}
{/* 新增 / 编辑条目对话框 */}
<Dialog
@@ -415,6 +450,133 @@ function MemoryConfigPanel() {
);
}
const CTX_LABELS: Record<string, string> = {
workContext: "工作背景",
personalContext: "个人信息",
topOfMind: "当前重点",
};
const HISTORY_LABELS: Record<string, string> = {
recentMonths: "近期",
earlierContext: "早期背景",
longTermBackground: "长期背景",
};
const CATEGORY_COLORS: Record<string, string> = {
preference: "bg-blue-100 text-blue-700",
knowledge: "bg-green-100 text-green-700",
context: "bg-yellow-100 text-yellow-700",
behavior: "bg-purple-100 text-purple-700",
goal: "bg-orange-100 text-orange-700",
correction: "bg-red-100 text-red-700",
};
/** V1 记忆只读视图:展示 memory.json 的全部字段。 */
function MemoryV1View({
memory,
}: {
memory: {
facts: MemoryFact[];
user_context: Record<string, string>;
history: Record<string, string>;
memory_usage: string;
};
}) {
const ctxEntries = Object.entries(memory.user_context ?? {}).filter(([, v]) => v);
const historyEntries = Object.entries(memory.history ?? {}).filter(([, v]) => v);
return (
<div className="space-y-4">
{/* 说明横幅 */}
<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>
{/* 历史摘要 (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>
{/* 事实记录 (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>
<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>
);
}
/** 一行开关控件。 */
function ToggleRow({
label,
+70
View File
@@ -31,6 +31,7 @@ export interface AdminMemoryBucket {
export interface AdminMemoryResponse {
user_id: string;
version: "v1" | "v2";
buckets: AdminMemoryBucket[];
}
@@ -118,3 +119,72 @@ export async function deleteAdminMemoryEntry(
"Failed to delete memory entry",
);
}
// ── V1 Memory CRUD ──────────────────────────────────────────────────────────
export async function updateAdminV1Section(
userId: string,
sectionType: "user" | "history",
field: string,
summary: string,
agent: string | null,
): Promise<AdminMemoryBucket> {
return parse(
await apiFetch(`${base()}/${encodeURIComponent(userId)}/memory/v1/sections`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ section_type: sectionType, field, summary, agent }),
}),
"Failed to update V1 section",
);
}
export async function addAdminV1Fact(
userId: string,
content: string,
category: string,
confidence: number,
agent: string | null,
): Promise<AdminMemoryBucket> {
return parse(
await apiFetch(`${base()}/${encodeURIComponent(userId)}/memory/v1/facts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content, category, confidence, agent }),
}),
"Failed to add V1 fact",
);
}
export async function updateAdminV1Fact(
userId: string,
factId: string,
updates: { content?: string; category?: string; confidence?: number },
agent: string | null,
): Promise<AdminMemoryBucket> {
return parse(
await apiFetch(`${base()}/${encodeURIComponent(userId)}/memory/v1/facts/${encodeURIComponent(factId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...updates, agent }),
}),
"Failed to update V1 fact",
);
}
export async function deleteAdminV1Fact(
userId: string,
factId: string,
agent: string | null,
): Promise<AdminMemoryBucket> {
const params = new URLSearchParams();
if (agent) params.set("agent", agent);
const qs = params.toString() ? `?${params}` : "";
return parse(
await apiFetch(
`${base()}/${encodeURIComponent(userId)}/memory/v1/facts/${encodeURIComponent(factId)}${qs}`,
{ method: "DELETE" },
),
"Failed to delete V1 fact",
);
}
+67
View File
@@ -2,11 +2,15 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
addAdminMemoryEntry,
addAdminV1Fact,
deleteAdminMemoryEntry,
deleteAdminV1Fact,
getUserMemory,
listUserThreadMessages,
listUserThreads,
replaceAdminMemoryEntry,
updateAdminV1Fact,
updateAdminV1Section,
} from "./api";
/** Threads owned by a given user. Disabled until a user is picked. */
@@ -70,3 +74,66 @@ export function useAdminDeleteMemoryEntry(userId: string) {
onSuccess: invalidate,
});
}
// ── V1 CRUD hooks ──────────────────────────────────────────────────────────
export function useAdminUpdateV1Section(userId: string) {
const invalidate = useMemoryInvalidate(userId);
return useMutation({
mutationFn: ({
sectionType,
field,
summary,
agent,
}: {
sectionType: "user" | "history";
field: string;
summary: string;
agent: string | null;
}) => updateAdminV1Section(userId, sectionType, field, summary, agent),
onSuccess: invalidate,
});
}
export function useAdminAddV1Fact(userId: string) {
const invalidate = useMemoryInvalidate(userId);
return useMutation({
mutationFn: ({
content,
category,
confidence,
agent,
}: {
content: string;
category: string;
confidence: number;
agent: string | null;
}) => addAdminV1Fact(userId, content, category, confidence, agent),
onSuccess: invalidate,
});
}
export function useAdminUpdateV1Fact(userId: string) {
const invalidate = useMemoryInvalidate(userId);
return useMutation({
mutationFn: ({
factId,
updates,
agent,
}: {
factId: string;
updates: { content?: string; category?: string; confidence?: number };
agent: string | null;
}) => updateAdminV1Fact(userId, factId, updates, agent),
onSuccess: invalidate,
});
}
export function useAdminDeleteV1Fact(userId: string) {
const invalidate = useMemoryInvalidate(userId);
return useMutation({
mutationFn: ({ factId, agent }: { factId: string; agent: string | null }) =>
deleteAdminV1Fact(userId, factId, agent),
onSuccess: invalidate,
});
}
+29 -3
View File
@@ -1,7 +1,23 @@
// Memory V2 数据模型。对应后端 /api/memory/v2 系列端点。
// Memory 数据模型。对应后端 /api/memory/v2 系列端点。支持 V1 / V2 两个版本。
/** builtin 本地记忆数据视图 (USER.md / MEMORY.md)。 */
/** V1 记忆中的单条事实。 */
export interface MemoryFact {
id: string;
content: string;
category: string;
confidence: number;
createdAt: string;
/** 纠错类事实:被纠正的原错误描述(category=correction 时可能有值)。 */
sourceError?: string;
}
/** 记忆数据视图 —— V1/V2 共用结构。
*
* - V2 模式: user_entries / memory_entries / user_usage / memory_usage 有值
* - V1 模式: facts / user_context / memory_usage 有值
*/
export interface MemoryV2Data {
// V2 字段
/** USER.md 条目 —— 用户画像,跨 agent 共享。 */
user_entries: string[];
/** MEMORY.md 条目 —— 当前 agent 的工作笔记。 */
@@ -10,17 +26,27 @@ export interface MemoryV2Data {
user_usage: string;
/** MEMORY.md 字符用量。 */
memory_usage: string;
// V1 字段
/** V1 facts 列表,按置信度降序。 */
facts: MemoryFact[];
/** V1 userContext 摘要。 */
user_context: Record<string, string>;
/** V1 history 历史摘要。 */
history: Record<string, string>;
}
/** 记忆条目所属类别。 */
export type MemoryTarget = "user" | "memory";
/** 生效记忆配置 + 用户可改字段白名单。 */
/** 生效记忆配置 + 用户可改字段白名单 + 版本号。 */
export interface MemoryV2ConfigResponse {
/** 按 per-user 覆盖合并后的生效配置 (嵌套 dict)。 */
config: MemoryV2Config;
/** 用户可在运行时覆盖的字段 (点号路径)。 */
writable_fields: string[];
/** 当前记忆版本:v1 或 v2。 */
version: "v1" | "v2";
}
/** 生效记忆配置的关注字段 (后端 dict 的子集)。 */
+1
View File
@@ -506,6 +506,7 @@ export function useThreadStream({
: context.mode === "thinking"
? "low"
: undefined),
memory_injection_enabled: context.memory_injection_enabled ?? true,
thread_id: threadId,
},
},
+6 -5
View File
@@ -14,11 +14,12 @@ export interface AgentThreadContext extends Record<string, unknown> {
model_name: string | undefined;
thinking_enabled: boolean;
is_plan_mode: boolean;
subagent_enabled: boolean;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean;
writing_artifact_path?: string;
agent_id?: string;
subagent_enabled: boolean;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean;
writing_artifact_path?: string;
memory_injection_enabled?: boolean;
agent_id?: string;
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
agent_name?: string;
}
+1 -1
View File
@@ -139,7 +139,7 @@ export default function NewAgentPage() {
if (threadPrepared) {
return;
}
await getAPIClient().threads.create({ threadId });
await getAPIClient().threads.create({ threadId, metadata: { thread_type: "bootstrap" } });
setThreadPrepared(true);
}, [threadId, threadPrepared]);