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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user