feat(memory): P2 读路径打分 + 桌面端记忆面板(看/改/删)

读路径(Generative Agents 公式的 Recency+Importance 两项,Relevance 待 P3):
- memory.Get 改为 rankProfiles:Score=0.4·Recency+0.6·Importance,按分降序、截断 top-30。
  Recency=0.98^天 指数衰减;未评分行用兜底 importance=5(不被不公平遗忘)。纯函数 + 单测。
- 新增 Store.List(结构化、不截断)+ memory_list 工具。

桌面端记忆面板:
- gateway GET /memory(列表) + DELETE /memory?key=(软删),受保护组。
- api listMemory/deleteMemory;MemoryView 右侧从占位 → 真列表:按分排序展示
  key/value/重要度/最近时间,可内联编辑(PUT)与删除(软删);左侧登记后自动刷新。

实测:PUT 两条 → GET 返回带 importance/last_seen 的有序列表(较新者靠前)→ DELETE 一条
(软删)→ 再 GET 已消失。rank/recency/兜底 importance 单测过;前端 tsc+构建通过。

至此记忆:召回(打分) + 历史 + 自动对账(P1) + 用户可管控(面板) 闭环。Relevance(Milvus) 留 P3。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-22 14:48:11 +08:00
parent 1674252d81
commit 0edfc948ba
9 changed files with 316 additions and 14 deletions
+25
View File
@@ -411,3 +411,28 @@ export async function setMemory(
if (!res.ok) throw new Error(data.error ?? `memory failed: ${res.status}`);
return data.message ?? "ok";
}
// 一条长期偏好(含读路径打分维度)。
export interface MemoryItem {
key: string;
value: string;
importance: number;
last_seen: string;
}
// listMemory: GET /api/v1/memory —— 列出当前用户偏好(已按 Score 降序)。
export async function listMemory(id: Identity): Promise<MemoryItem[]> {
const res = guard401(await fetch(`${GATEWAY}/api/v1/memory`, { headers: idHeaders(id) }));
const data = (await res.json()) as { memories?: MemoryItem[]; error?: string };
if (!res.ok) throw new Error(data.error ?? `list memory failed: ${res.status}`);
return data.memories ?? [];
}
// deleteMemory: DELETE /api/v1/memory?key= —— 软删一条偏好。
export async function deleteMemory(id: Identity, key: string): Promise<void> {
const res = guard401(await fetch(`${GATEWAY}/api/v1/memory?key=${encodeURIComponent(key)}`, { method: "DELETE", headers: idHeaders(id) }));
if (!res.ok) {
const d = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(d.error ?? `delete memory failed: ${res.status}`);
}
}
@@ -4,7 +4,7 @@ import { setMemory, type Identity } from "../lib/api";
import { Button, Input, Textarea, Field, useToast } from "../ui";
// 偏好记忆面板 —— 让用户显式登记/纠正模型对自己的记忆(→ PUT /api/v1/memory)。
export function MemoryPanel({ identity }: { identity: Identity }) {
export function MemoryPanel({ identity, onSaved }: { identity: Identity; onSaved?: () => void }) {
const toast = useToast();
const [key, setKey] = useState("回答偏好");
const [value, setValue] = useState("简洁、中文、多给要点");
@@ -16,6 +16,7 @@ export function MemoryPanel({ identity }: { identity: Identity }) {
const m = await setMemory(identity, key.trim(), value.trim());
setSaved((s) => [{ key: key.trim(), value: value.trim() }, ...s.filter((x) => x.key !== key.trim())]);
toast.push("success", m);
onSaved?.();
} catch (e) {
toast.push("error", (e as Error).message);
}
@@ -1,18 +1,123 @@
import { useCallback, useEffect, useState } from "react";
import { Trash2, RefreshCw, Save, X, Pencil, Brain } from "lucide-react";
import { MemoryPanel } from "../panels/MemoryPanel";
import type { Identity } from "../lib/api";
import { listMemory, setMemory, deleteMemory, type Identity, type MemoryItem } from "../lib/api";
import { Textarea, EmptyState, useToast, cn } from "../ui";
// 记忆模块:当前以偏好登记为主;规划做厚为 画像看/改/删 + 会话历史浏览
// 记忆模块:左侧登记偏好;右侧"模型记得我什么"——按 Score 排序列出,可改/删(软删)
export function MemoryView({ identity }: { identity: Identity }) {
const toast = useToast();
const [items, setItems] = useState<MemoryItem[]>([]);
const [loading, setLoading] = useState(false);
const [editKey, setEditKey] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const load = useCallback(async () => {
setLoading(true);
try {
setItems(await listMemory(identity));
} catch {
setItems([]);
} finally {
setLoading(false);
}
}, [identity]);
useEffect(() => {
void load();
}, [load]);
const saveEdit = async (key: string) => {
if (!draft.trim()) return;
try {
await setMemory(identity, key, draft.trim());
setEditKey(null);
toast.push("success", "已更新");
void load();
} catch (e) {
toast.push("error", (e as Error).message);
}
};
const remove = async (key: string) => {
try {
await deleteMemory(identity, key);
toast.push("success", `已删除「${key}`);
void load();
} catch (e) {
toast.push("error", (e as Error).message);
}
};
const fmtDate = (s: string) => {
const d = new Date(s);
return isNaN(d.getTime()) || d.getFullYear() < 2000 ? "—" : d.toLocaleDateString();
};
return (
<div className="flex h-full">
<div className="w-96 border-r border-line bg-ink-900">
<MemoryPanel identity={identity} />
<div className="flex h-full min-h-0">
<div className="w-96 shrink-0 overflow-y-auto border-r border-line bg-ink-900">
<MemoryPanel identity={identity} onSaved={load} />
</div>
<div className="flex-1 p-6 text-xs leading-relaxed text-slate-500">
<div className="mb-1 text-sm font-semibold text-slate-300"> / </div>
/ +
<br />
memory_upsert sundynix_user_profile
<div className="min-h-0 flex-1 overflow-y-auto p-5">
<div className="mb-3 flex items-center gap-2">
<Brain className="h-4 w-4 text-brand-400" />
<h2 className="text-sm font-semibold text-slate-200">{items.length}</h2>
<span className="text-[11px] text-slate-600"> × </span>
<button onClick={load} className="ml-auto text-slate-600 hover:text-slate-300" title="刷新">
<RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />
</button>
</div>
{items.length === 0 ? (
<EmptyState icon={Brain} title="还没有长期记忆" desc="左侧登记偏好,或多聊几轮后系统会自动提炼(每 3 轮对账一次)。" />
) : (
<ul className="space-y-2">
{items.map((m) => (
<li key={m.key} className="rounded-lg border border-line bg-ink-850 p-3">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-200">{m.key}</span>
<span className="rounded bg-brand/15 px-1.5 py-0.5 text-[10px] text-brand-400" title="重要度">
{m.importance || "—"}
</span>
<span className="text-[10px] text-slate-600"> {fmtDate(m.last_seen)}</span>
<div className="ml-auto flex items-center gap-1">
{editKey === m.key ? (
<>
<button onClick={() => saveEdit(m.key)} className="text-success hover:opacity-80" title="保存">
<Save className="h-3.5 w-3.5" />
</button>
<button onClick={() => setEditKey(null)} className="text-slate-500 hover:text-slate-300" title="取消">
<X className="h-3.5 w-3.5" />
</button>
</>
) : (
<>
<button
onClick={() => {
setEditKey(m.key);
setDraft(m.value);
}}
className="text-slate-500 hover:text-brand-400"
title="编辑"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button onClick={() => remove(m.key)} className="text-slate-600 hover:text-danger" title="删除(软删)">
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
</div>
{editKey === m.key ? (
<Textarea className="mt-2 h-16 w-full resize-none text-xs" value={draft} onChange={(e) => setDraft(e.target.value)} autoFocus />
) : (
<div className="mt-1 text-xs text-slate-400">{m.value}</div>
)}
</li>
))}
</ul>
)}
</div>
</div>
);