Compare commits

..
Author SHA1 Message Date
kristinheandCursor 38ad2292d1 fix(memhub): clarify memory mode switch effects
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 15:58:03 +08:00
11 changed files with 133 additions and 139 deletions

No files matched your search

+1 -17
View File
@@ -6,11 +6,7 @@ import type {
} from "@one-memory/contracts";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import {
graphNodeTooltip,
isGraphNodeSelectable,
MemoryGraph3D,
} from "./MemoryGraph3D";
import { MemoryGraph3D } from "./MemoryGraph3D";
afterEach(cleanup);
@@ -52,18 +48,6 @@ function makeGraph(nodeCount: number): MemoryGraphResponse {
}
describe("MemoryGraph3D", () => {
it("keeps topic annotations hover-only", () => {
const topic = {
...makeGraph(1).nodes[0],
node_type: "topic" as const,
label: "CI",
};
expect(isGraphNodeSelectable(topic)).toBe(false);
expect(graphNodeTooltip(topic, "主题:")).toBe("主题:CI");
expect(graphNodeTooltip(topic, "Topic: ")).toBe("Topic: CI");
expect(isGraphNodeSelectable({ ...topic, node_type: "memory" })).toBe(true);
});
it("没有 WebGL 时给出可访问降级提示", () => {
render(
<MemoryGraph3D
+24 -58
View File
@@ -27,20 +27,6 @@ const SELECT_DURATION_MS = 180;
const SELECTED_COLOR = new THREE.Color(0x294355);
const SELECTED_EMISSIVE = new THREE.Color(0x607e97);
/** Topic nodes explain the graph; unlike memories and sources, they do not own a detail view. */
export function isGraphNodeSelectable(node: MemoryGraphNode): boolean {
return node.node_type !== "topic";
}
export function graphNodeTooltip(
node: MemoryGraphNode,
topicPrefix: string,
): string {
return node.node_type === "topic"
? `${topicPrefix}${node.label}`
: node.label;
}
export function MemoryGraph3D({
graph,
selectedNodeId,
@@ -50,20 +36,10 @@ export function MemoryGraph3D({
selectedNodeId: string | null;
onSelectNode(node: MemoryGraphNode): void;
}) {
const { locale, text } = useMemoryLanguage();
const { text } = useMemoryLanguage();
const host = useRef<HTMLDivElement>(null);
const labelHost = useRef<HTMLDivElement>(null);
const selectRef = useRef(onSelectNode);
const nodesByIdRef = useRef(
new Map(graph.nodes.map((node) => [node.node_id, node])),
);
nodesByIdRef.current = new Map(
graph.nodes.map((node) => [node.node_id, node]),
);
const topicPrefixRef = useRef("");
topicPrefixRef.current = `${text("主题")}${
locale.startsWith("zh") || locale.startsWith("ja") ? ":" : ": "
}`;
// 选中态通过 ref 传入渲染循环,而不是进 useEffect 依赖。
// 依赖里带 selectedNodeId 会导致每次点击重建整个场景、重跑布局、重建全部 mesh。
const selectedRef = useRef(selectedNodeId);
@@ -181,7 +157,9 @@ export function MemoryGraph3D({
}
}
const labels = graph.nodes
.filter((node) => node.node_type === "source")
.filter(
(node) => node.node_type === "source" || node.node_type === "topic",
)
.slice(0, 4)
.map((node) => {
const element = document.createElement("span");
@@ -216,7 +194,7 @@ export function MemoryGraph3D({
}
};
const hittable: THREE.Mesh[] = [];
const selectable: THREE.Mesh[] = [];
const materials = new Set<THREE.Material>();
const geometryByType = {
source: new THREE.SphereGeometry(0.24, 18, 12),
@@ -259,7 +237,7 @@ export function MemoryGraph3D({
mesh.scale.setScalar(1);
mesh.visible = returning || extending;
scene.add(mesh);
hittable.push(mesh);
selectable.push(mesh);
visuals.push({
mesh,
material,
@@ -320,6 +298,7 @@ export function MemoryGraph3D({
});
}
const nodesById = new Map(graph.nodes.map((node) => [node.node_id, node]));
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
const hitAt = (event: PointerEvent) => {
@@ -328,7 +307,7 @@ export function MemoryGraph3D({
pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1;
pointer.y = -((event.clientY - bounds.top) / bounds.height) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
return raycaster.intersectObjects(hittable, false)[0];
return raycaster.intersectObjects(selectable, false)[0];
};
let pressed: { x: number; y: number; id: number; moved: boolean } | null =
null;
@@ -354,9 +333,8 @@ export function MemoryGraph3D({
)
return;
const nodeId = hitAt(event)?.object.userData.nodeId;
const node =
typeof nodeId === "string" ? nodesByIdRef.current.get(nodeId) : null;
if (node && isGraphNodeSelectable(node)) selectRef.current(node);
const node = typeof nodeId === "string" ? nodesById.get(nodeId) : null;
if (node) selectRef.current(node);
};
const hoverNode = (event: PointerEvent) => {
if (pressed) {
@@ -366,18 +344,12 @@ export function MemoryGraph3D({
return;
}
const nodeId = hitAt(event)?.object.userData.nodeId;
const node =
typeof nodeId === "string" ? nodesByIdRef.current.get(nodeId) : null;
renderer.domElement.style.cursor =
node && isGraphNodeSelectable(node)
? "pointer"
: node
? "default"
: "grab";
const node = typeof nodeId === "string" ? nodesById.get(nodeId) : null;
renderer.domElement.style.cursor = node ? "pointer" : "grab";
setHovered(
node
? {
label: graphNodeTooltip(node, topicPrefixRef.current),
label: node.label,
x: event.clientX - element.getBoundingClientRect().left,
y: event.clientY - element.getBoundingClientRect().top,
}
@@ -657,23 +629,17 @@ export function MemoryGraph3D({
{!available ? (
<div className="memory-graph-fallback">
<p>{text("这台设备打不开 3D 视图,已显示关联列表。")}</p>
{graph.nodes.map((node) =>
isGraphNodeSelectable(node) ? (
<button
type="button"
className="secondary"
key={node.node_id}
aria-pressed={selectedNodeId === node.node_id}
onClick={() => onSelectNode(node)}
>
{node.label}
</button>
) : (
<span className="memory-graph-fallback-topic" key={node.node_id}>
{graphNodeTooltip(node, topicPrefixRef.current)}
</span>
),
)}
{graph.nodes.map((node) => (
<button
type="button"
className="secondary"
key={node.node_id}
aria-pressed={selectedNodeId === node.node_id}
onClick={() => onSelectNode(node)}
>
{node.label}
</button>
))}
</div>
) : null}
{available && (
+7 -7
View File
@@ -149,7 +149,7 @@ it("OBS-22: the graph inspector has no editing entry and never says changes cann
expect(screen.queryByText(/暂不能保存更改/)).toBeNull();
});
it("keeps topic annotations out of the inspector and opens detail-bearing graph nodes", async () => {
it("replaces a topic detail with an off-page graph memory detail", async () => {
const onOpen = vi.fn();
const topicGraph = {
total_memories: 2,
@@ -221,13 +221,13 @@ it("keeps topic annotations out of the inspector and opens detail-bearing graph
/>,
);
await act(async () => {});
onOpen.mockClear();
await userEvent.click(screen.getByRole("button", { name: "图中选择 git" }));
expect(onOpen).not.toHaveBeenCalled();
expect(screen.queryByRole("heading", { name: "git" })).toBeNull();
expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy();
expect(screen.getByTestId("canvas").dataset.selected).toBe("memory:m0");
expect(screen.getByRole("heading", { name: "git" })).toBeTruthy();
expect(screen.getAllByText("第一条").length).toBeGreaterThan(0);
expect(screen.queryByText("对应记忆")).toBeNull();
expect(screen.queryByText("属于主题")).toBeNull();
expect(screen.queryByText("主题")).toBeNull();
expect(screen.queryByText("两跳")).toBeNull();
await userEvent.click(
screen.getByRole("button", { name: "图中选择 先验证,再推送" }),
);
+5 -3
View File
@@ -684,9 +684,6 @@ export function MemoryMap(props: Props) {
setGroupKey(key);
}
function pickNode(node: MemoryGraphNode) {
// Topics are graph annotations, not detail-bearing records. They remain
// hoverable in the canvas but must not replace the memory inspector.
if (node.node_type === "topic") return;
if (node.memory_id) {
pick(node.memory_id);
return;
@@ -711,6 +708,11 @@ export function MemoryMap(props: Props) {
if (first) pick(first, false);
}
}
if (node.node_type === "topic") {
if (props.simplified) return;
setAxis("topic");
setGroupKey(`topic:${node.label}`);
}
}
const label =
group?.label ??
+41 -28
View File
@@ -1,5 +1,6 @@
// @vitest-environment jsdom
import { legacyMemoryModePlan } from "@one-memory/operations";
import {
cleanup,
render,
@@ -64,31 +65,9 @@ function prepare(mode: "lite" | "pro" | "ultra" = "pro") {
running: [],
});
vi.mocked(getMemoryProcessing).mockResolvedValue({} as never);
vi.mocked(planMemoryMode).mockImplementation(async (target) => ({
current: mode,
target,
effective_target: target,
capabilities: {
content_plane: target === "lite" ? "raw" : "memory",
generation_required: target !== "lite",
graph_enabled: target === "ultra",
evolve_available: target === "ultra",
},
effects: [
...(target === "lite" ? (["higher_mode_memories_hidden"] as const) : []),
...(mode === "lite" && target !== "lite"
? (["earlier_raw_not_extracted", "lite_search_stops"] as const)
: []),
...(target === "ultra" && mode !== "ultra"
? (["existing_memories_added_to_graph"] as const)
: []),
...(mode === "ultra" && target !== "ultra"
? (["evolve_stopped"] as const)
: []),
],
warnings: [],
blockers: [],
}));
vi.mocked(planMemoryMode).mockImplementation(async (target) =>
legacyMemoryModePlan(mode, target, { hasActiveModel: true }),
);
}
async function chooseMode(
@@ -227,6 +206,20 @@ it("uses the isolated legacy plan only for a route-level old-Core 404", async ()
expect(await screen.findByText(/已有记忆继续保留/)).toBeTruthy();
});
it("keeps downgrade effects when an old Core falls back from ultra to pro", async () => {
prepare("ultra");
vi.mocked(planMemoryMode).mockRejectedValue(new Error("404"));
vi.mocked(memoryModePlanUnsupported).mockReturnValue(true);
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /智能记忆(pro)/);
const dialog = screen.getByRole("dialog");
expect(within(dialog).getAllByText("已有数据")).toHaveLength(1);
expect(within(dialog).getByText("每天自动整合")).toBeTruthy();
});
it("explains model cost, search scope and the next step before switching to ultra", async () => {
prepare("lite");
const user = userEvent.setup();
@@ -238,6 +231,8 @@ it("explains model cost, search scope and the next step before switching to ultr
expect(screen.getByText("搜索范围")).toBeTruthy();
expect(screen.getByText("切换后")).toBeTruthy();
expect(screen.getByText(/已经处理完的旧对话不会补做/)).toBeTruthy();
expect(screen.getByText("知识图谱")).toBeTruthy();
expect(screen.getAllByText("已有数据")).toHaveLength(1);
expect(screen.getByText(/切换本身不会立即整合/)).toBeTruthy();
});
@@ -248,9 +243,27 @@ it("distinguishes regular memories from higher-level Ultra data when switching t
await chooseMode(user, /智能记忆(pro)/);
expect(screen.getByText(/普通沉淀记忆仍可使用/)).toBeTruthy();
expect(screen.getByText(/更高层记忆和图谱数据继续保留/)).toBeTruthy();
expect(screen.getByText(/每天自动整合会停止/)).toBeTruthy();
const dialog = screen.getByRole("dialog");
expect(within(dialog).getAllByText("已有数据")).toHaveLength(1);
expect(within(dialog).getByText(/普通沉淀记忆仍可使用/)).toBeTruthy();
expect(within(dialog).getByText(/更高层记忆和图谱数据继续保留/)).toBeTruthy();
expect(within(dialog).getByText("每天自动整合")).toBeTruthy();
expect(
within(dialog).getByText("每天自动整合会停止;已生成的整合结果继续保留。"),
).toBeTruthy();
});
it("does not show automatic consolidation when switching from lite to pro", async () => {
prepare("lite");
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /智能记忆(pro)/);
const dialog = screen.getByRole("dialog");
expect(within(dialog).getAllByText("已有数据")).toHaveLength(1);
expect(within(dialog).getByText(/已经处理完的旧对话不会补做/)).toBeTruthy();
expect(within(dialog).queryByText("每天自动整合")).toBeNull();
});
it("focuses the Evolve settings after switching to ultra", async () => {
+7 -5
View File
@@ -85,18 +85,20 @@ function planRows(plan: MemoryModePlan) {
if (plan.effects.includes("higher_mode_memories_hidden"))
rows.push({
label: "已有数据",
content: "已有记忆继续保留,但高级记忆在当前模式下不显示、不参与召回。",
content:
plan.effective_target === "pro"
? "普通沉淀记忆仍可使用;深度记忆新增的更高层记忆和图谱数据继续保留,但在智能记忆下不显示、不参与召回。原始对话不会删除。"
: "已有记忆继续保留,但在基础记忆下不显示、不参与召回;原始对话不会删除。",
});
if (plan.effects.includes("existing_memories_added_to_graph"))
rows.push({
label: "已有数据",
label: "知识图谱",
content: "已有记忆会在后台加入知识图谱,期间仍可使用。",
});
if (plan.effects.includes("evolve_stopped"))
rows.push({
label: "已有数据",
content:
"普通沉淀记忆仍可使用;更高层记忆和图谱数据继续保留,但不参与当前召回;每天自动整合会停止。",
label: "每天自动整合",
content: "每天自动整合会停止,已经生成的数据不会删除。",
});
if (plan.capabilities.evolve_available)
rows.push({
-8
View File
@@ -981,14 +981,6 @@ button.map-tool {
background: #fff;
box-shadow: var(--shadow-pop);
}
.memory-graph-fallback-topic {
display: block;
width: 100%;
margin-block: 6px;
padding: 8px 14px;
color: var(--tx2);
overflow-wrap: anywhere;
}
.graph-labels {
position: absolute;
inset: 0;
@@ -275,9 +275,10 @@ describe("Mode catalog", () => {
"搜索沉淀记忆,并用知识图谱补充关联。",
"搜索沉淀后的记忆。",
"已经处理完的旧对话不会补做;原始对话全部保留。",
"已有记忆继续保留,但高级记忆在当前模式下不显示、不参与召回。",
"知识图谱",
"普通沉淀记忆仍可使用;深度记忆新增的更高层记忆和图谱数据继续保留,但在智能记忆下不显示、不参与召回。原始对话不会删除。",
"已有记忆会在后台加入知识图谱,期间仍可使用。",
"普通沉淀记忆仍可使用;更高层记忆和图谱数据继续保留,但不参与当前召回;每天自动整合会停止。",
"每天自动整合会停止,已经生成的数据不会删除。",
"记忆维护正在进行,完成后才能切换模式。",
"模式切换影响暂时无法读取,请重试。",
] as const;
+12 -1
View File
@@ -47,7 +47,7 @@ const modeChineseMessages = {
"以前生成的高级记忆会继续保留,但在 lite 下不会显示或参与记忆召回。":
"高级记忆会保留,但在 lite 下不显示、不参与召回。",
"每天自动整合会停止,已经生成的数据不会删除。":
"自动整合会停止,已有数据保留。",
"每天自动整合会停止;已生成的整合结果继续保留。",
"已有高级记忆会在后台加入知识图谱,期间可以继续使用 MemHub。":
"已有高级记忆将在后台加入知识图谱。",
"知识图谱仍在补建,不会重复启动。": "知识图谱仍在补建,不会重复启动。",
@@ -97,6 +97,7 @@ const modeChineseMessages = {
模型调用与费用: "模型调用与费用",
搜索范围: "搜索范围",
已有数据: "已有数据",
知识图谱: "知识图谱",
切换后: "切换后",
"之后只保存原始对话,不再生成新记忆。":
"之后只保存原始对话,不再生成新记忆。",
@@ -295,6 +296,7 @@ export const modeEnglishMessages: ModeMessages = {
模型调用与费用: "Model calls and cost",
搜索范围: "Search scope",
已有数据: "Existing data",
知识图谱: "Knowledge graph",
切换后: "After switching",
"之后只保存原始对话,不再生成新记忆。":
"New conversations are saved as original text only; no new memories are created.",
@@ -498,6 +500,7 @@ export const modeSpanishMessages: ModeMessages = {
模型调用与费用: "Llamadas al modelo y coste",
搜索范围: "Ámbito de búsqueda",
已有数据: "Datos existentes",
知识图谱: "Grafo de conocimiento",
切换后: "Después del cambio",
"之后只保存原始对话,不再生成新记忆。":
"Las conversaciones nuevas se guardan solo como texto original; no se crean memorias nuevas.",
@@ -693,6 +696,7 @@ export const modeTraditionalChineseMessages: ModeMessages = {
模型调用与费用: "模型呼叫與費用",
搜索范围: "搜尋範圍",
已有数据: "現有資料",
知识图谱: "知識圖譜",
切换后: "切換後",
"之后只保存原始对话,不再生成新记忆。":
"之後只儲存原始對話,不再產生新記憶。",
@@ -843,6 +847,7 @@ export const modeJapaneseMessages: ModeMessages = {
模型调用与费用: "モデル呼び出しと料金",
搜索范围: "検索範囲",
已有数据: "既存データ",
知识图谱: "ナレッジグラフ",
切换后: "切り替え後",
"之后只保存原始对话,不再生成新记忆。":
"以降は元の会話のみを保存し、新しい記憶は生成しません。",
@@ -1043,6 +1048,7 @@ export const modeGermanMessages: ModeMessages = {
模型调用与费用: "Modellaufrufe und Kosten",
搜索范围: "Suchbereich",
已有数据: "Vorhandene Daten",
知识图谱: "Wissensgraph",
切换后: "Nach dem Wechsel",
"之后只保存原始对话,不再生成新记忆。":
"Neue Unterhaltungen werden nur noch im Original gespeichert; es werden keine neuen Erinnerungen erstellt.",
@@ -1246,6 +1252,7 @@ export const modePortugueseMessages: ModeMessages = {
模型调用与费用: "Chamadas ao modelo e custos",
搜索范围: "Âmbito da pesquisa",
已有数据: "Dados existentes",
知识图谱: "Grafo de conhecimento",
切换后: "Após a mudança",
"之后只保存原始对话,不再生成新记忆。":
"As novas conversas passam a ser guardadas apenas como texto original; não são criadas novas memórias.",
@@ -1451,6 +1458,7 @@ export const modeFrenchMessages: ModeMessages = {
模型调用与费用: "Appels au modèle et coût",
搜索范围: "Périmètre de recherche",
已有数据: "Données existantes",
知识图谱: "Graphe de connaissances",
切换后: "Après le changement",
"之后只保存原始对话,不再生成新记忆。":
"Les nouvelles conversations sont uniquement enregistrées dans leur forme originale ; aucune nouvelle mémoire n’est créée.",
@@ -1651,6 +1659,7 @@ export const modeKoreanMessages: ModeMessages = {
模型调用与费用: "모델 호출 및 비용",
搜索范围: "검색 범위",
已有数据: "기존 데이터",
知识图谱: "지식 그래프",
切换后: "전환 후",
"之后只保存原始对话,不再生成新记忆。":
"이후 새 대화는 원본만 저장되며 새 메모리는 생성되지 않습니다.",
@@ -1850,6 +1859,7 @@ export const modeRussianMessages: ModeMessages = {
模型调用与费用: "Вызовы модели и расходы",
搜索范围: "Область поиска",
已有数据: "Существующие данные",
知识图谱: "Граф знаний",
切换后: "После переключения",
"之后只保存原始对话,不再生成新记忆。":
"Новые диалоги будут сохраняться только в исходном виде; новые воспоминания создаваться не будут.",
@@ -2052,6 +2062,7 @@ export const modeIndonesianMessages: ModeMessages = {
模型调用与费用: "Panggilan model dan biaya",
搜索范围: "Cakupan pencarian",
已有数据: "Data yang ada",
知识图谱: "Graf pengetahuan",
切换后: "Setelah beralih",
"之后只保存原始对话,不再生成新记忆。":
"Percakapan baru hanya disimpan sebagai teks asli; tidak ada memori baru yang dibuat.",
@@ -32,7 +32,31 @@ describe("memory mode plan", () => {
expect(parseMemoryModePlan({ ...valid, blockers: ["unknown"] })).toBeNull();
});
it("isolates the old-Core matrix in one helper", () => {
it.each([
["lite", "pro", ["earlier_raw_not_extracted", "lite_search_stops"]],
[
"lite",
"ultra",
[
"earlier_raw_not_extracted",
"lite_search_stops",
"existing_memories_added_to_graph",
],
],
["pro", "lite", ["higher_mode_memories_hidden"]],
["pro", "ultra", ["existing_memories_added_to_graph"]],
["ultra", "lite", ["higher_mode_memories_hidden", "evolve_stopped"]],
["ultra", "pro", ["higher_mode_memories_hidden", "evolve_stopped"]],
] as const)(
"keeps the old-Core %s to %s effects aligned with the plan route",
(current, target, effects) => {
expect(
legacyMemoryModePlan(current, target, { hasActiveModel: true }).effects,
).toEqual(effects);
},
);
it("keeps the old-Core model blocker and target capabilities", () => {
expect(
legacyMemoryModePlan("lite", "ultra", { hasActiveModel: false }),
).toMatchObject({
@@ -42,10 +66,6 @@ describe("memory mode plan", () => {
graph_enabled: true,
},
blockers: ["active_model_required"],
effects: expect.arrayContaining([
"earlier_raw_not_extracted",
"existing_memories_added_to_graph",
]),
});
});
});
@@ -106,12 +106,15 @@ export function legacyMemoryModePlan(
options: { hasActiveModel: boolean },
): MemoryModePlan {
const effects: MemoryModeChange["effects"] = [];
if (target === "lite") {
effects.push("higher_mode_memories_hidden");
if (current === "ultra") effects.push("evolve_stopped");
} else if (current === "lite") {
const rank: Record<MemoryPipelineMode, number> = {
lite: 0,
pro: 1,
ultra: 2,
};
if (current !== target && current === "lite")
effects.push("earlier_raw_not_extracted", "lite_search_stops");
}
if (rank[target] < rank[current]) effects.push("higher_mode_memories_hidden");
if (current !== target && current === "ultra") effects.push("evolve_stopped");
if (target === "ultra" && current !== "ultra")
effects.push("existing_memories_added_to_graph");
return {