Compare commits

...
Author SHA1 Message Date
kristinheandCursor 77c9f300e5 fix(memhub): restore compact memory mode settings
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-06 15:17:25 +08:00
kristinhe1111andCursor 30f796ddc4 fix(memhub): bound Lite raw conversation map (#364)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-06 14:57:40 +08:00
25 changed files with 776 additions and 1176 deletions

No files matched your search

+40 -216
View File
@@ -21,7 +21,7 @@ from datetime import UTC, datetime, timedelta
from functools import partial
from pathlib import Path
from typing import Any, Literal, cast
from uuid import UUID, uuid4
from uuid import UUID
from fastapi import (
Depends,
@@ -653,7 +653,7 @@ class SdkRawPage(BaseModel):
class SdkRawGraphItem(BaseModel):
"""Small, non-authoritative projection used to draw the complete raw map."""
"""Small, non-authoritative projection used to draw the bounded raw map."""
model_config = ConfigDict(frozen=True, extra="forbid")
@@ -667,7 +667,7 @@ class SdkRawGraphItem(BaseModel):
class SdkRawGraphPage(BaseModel):
"""One page from a complete, bounded-memory raw-turn catalog."""
"""One bounded page from the raw-turn catalog."""
model_config = ConfigDict(extra="forbid")
@@ -675,9 +675,8 @@ class SdkRawGraphPage(BaseModel):
items: tuple[SdkRawGraphItem, ...] = ()
total: int = Field(ge=0)
offset: int = Field(ge=0)
limit: int = Field(ge=1, le=500)
limit: int = Field(ge=1, le=300)
has_more: bool = False
snapshot_id: str = Field(min_length=1, max_length=64)
class SdkLayerCounts(BaseModel):
@@ -4570,55 +4569,6 @@ def create_app(
],
] = {}
raw_turn_cache_locks: dict[tuple[object, ...], asyncio.Lock] = {}
raw_graph_cache: dict[
tuple[object, ...],
tuple[
float,
int,
tuple[tuple[str, str | None], ...],
tuple[SdkRawGraphItem, ...],
int,
],
] = {}
raw_graph_cache_locks: dict[tuple[object, ...], asyncio.Lock] = {}
raw_graph_snapshots: dict[
str,
tuple[float, tuple[object, ...], tuple[SdkRawGraphItem, ...], int],
] = {}
raw_graph_cache_ttl = 300.0
raw_graph_snapshot_ttl = 120.0
raw_graph_cache_budget = 8 * 1024 * 1024
def graph_cache_size(items: Iterable[SdkRawGraphItem]) -> int:
return sum(
128
+ len(item.turn_id.encode())
+ len(item.raw_id.encode())
+ len(item.label.encode())
+ len(item.source.encode())
+ len(item.session_id.encode())
for item in items
)
def prune_raw_graph_state() -> None:
now = time.monotonic()
for key, cached in tuple(raw_graph_cache.items()):
if now - cached[0] > raw_graph_cache_ttl:
raw_graph_cache.pop(key, None)
for snapshot_id, snapshot in tuple(raw_graph_snapshots.items()):
if now - snapshot[0] > raw_graph_snapshot_ttl:
raw_graph_snapshots.pop(snapshot_id, None)
while raw_graph_cache and (
len(raw_graph_cache) > 8
or sum(cached[4] for cached in raw_graph_cache.values()) > raw_graph_cache_budget
):
raw_graph_cache.pop(next(iter(raw_graph_cache)))
while raw_graph_snapshots and (
len(raw_graph_snapshots) > 8
or sum(snapshot[3] for snapshot in raw_graph_snapshots.values())
> raw_graph_cache_budget
):
raw_graph_snapshots.pop(next(iter(raw_graph_snapshots)))
def graph_items(
records: Iterable[HySdkRawRecord],
@@ -4640,95 +4590,6 @@ def create_app(
)
return tuple(items)
async def raw_graph_catalog(
client: object,
*,
query: dict[str, object],
cache_key: tuple[object, ...],
) -> tuple[SdkRawGraphItem, ...]:
"""Build a complete raw-turn map without retaining raw bodies.
The SDK remains the sole raw authority. This cache contains only stable
ids and short labels and can be dropped at any time. A verified first
page makes one catalog snapshot atomic from the reader's perspective.
"""
prune_raw_graph_state()
if len(raw_graph_cache_locks) > 16:
for stale_key, stale_lock in tuple(raw_graph_cache_locks.items()):
if stale_key not in raw_graph_cache and not stale_lock.locked():
raw_graph_cache_locks.pop(stale_key, None)
lock = raw_graph_cache_locks.setdefault(cache_key, asyncio.Lock())
async with lock:
for _attempt in range(3):
value = await asyncio.to_thread(
client.list_memories,
**query,
offset=0,
limit=500,
)
first_page = HySdkRawPage.from_memory_list(value, limit=500, offset=0)
signature = tuple((record.raw_id, record.status) for record in first_page.raw)
cached = raw_graph_cache.get(cache_key)
if cached is not None and cached[1] == first_page.total and cached[2] == signature:
return cached[3]
added = (
None
if cached is None
else raw_prefix_added(
cached[2],
signature,
cached[1],
first_page.total,
)
)
if cached is not None and added is not None:
items = graph_items(first_page.raw[:added]) + cached[3]
else:
items = graph_items(first_page.raw)
raw_offset = len(first_page.raw)
snapshot_changed = False
while first_page.raw and raw_offset < first_page.total:
value = await asyncio.to_thread(
client.list_memories,
**query,
offset=raw_offset,
limit=500,
)
page = HySdkRawPage.from_memory_list(value, limit=500, offset=raw_offset)
if (
page.total != first_page.total
or not page.raw
or raw_offset + len(page.raw) > first_page.total
):
snapshot_changed = True
break
items += graph_items(page.raw)
raw_offset += len(page.raw)
if snapshot_changed or raw_offset != first_page.total:
continue
verify_value = await asyncio.to_thread(
client.list_memories,
**query,
offset=0,
limit=500,
)
verify = HySdkRawPage.from_memory_list(verify_value, limit=500, offset=0)
verify_signature = tuple((record.raw_id, record.status) for record in verify.raw)
if verify.total != first_page.total or verify_signature != signature:
continue
raw_graph_cache.pop(cache_key, None)
raw_graph_cache[cache_key] = (
time.monotonic(),
first_page.total,
signature,
items,
graph_cache_size(items),
)
prune_raw_graph_state()
return items
raise RuntimeError("hy_memory_raw_snapshot_busy")
@app.get(
"/api/v2/raw",
response_model=SdkRawPage,
@@ -5122,14 +4983,13 @@ def create_app(
async def sdk_raw_graph(
source: list[str] | None = Query(default=None), # noqa: B008
offset: int = Query(default=0, ge=0),
limit: int = Query(default=500, ge=1, le=500),
snapshot_id: str | None = Query(default=None, max_length=64),
limit: int = Query(default=300, ge=1, le=300),
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
exclude_sources: list[str] | None = Query(default=None), # noqa: B008
session_id: list[str] | None = Query(default=None), # noqa: B008
statuses: list[str] | None = Query(default=None), # noqa: B008
) -> SdkRawGraphPage:
"""Complete lightweight raw graph, independent of list pagination."""
"""Bounded lightweight raw graph, independent of list pagination."""
chosen_statuses = (
_library_values(
statuses or [],
@@ -5141,85 +5001,49 @@ def create_app(
sources = split_csv(source)
excluded = split_csv(exclude_sources)
sessions = split_csv(session_id)
snapshot_scope: tuple[object, ...] = (
user_id,
tuple(sorted(chosen_statuses)),
tuple(sorted(sources)),
tuple(sorted(excluded)),
tuple(sorted(sessions)),
)
if offset and snapshot_id is None:
if offset:
raise HTTPException(
status_code=422,
detail="raw_graph_snapshot_required",
detail="raw_graph_is_single_page",
)
prune_raw_graph_state()
if snapshot_id is not None:
snapshot = raw_graph_snapshots.get(snapshot_id)
if snapshot is None or snapshot[1] != snapshot_scope:
raise HTTPException(
status_code=409,
detail="raw_graph_snapshot_stale",
)
items = snapshot[2]
else:
query: dict[str, object] = {
"user_id": user_id,
**raw_list_filters(chosen_statuses),
"order": "desc",
}
cache_key: tuple[object, ...] = (
user_id,
tuple(sorted(chosen_statuses)),
query: dict[str, object] = {
"user_id": user_id,
**raw_list_filters(chosen_statuses),
"include_sources": sources or None,
"exclude_sources": excluded or None,
"session_ids": sessions or None,
"order": "desc",
}
try:
client = await _sdk_client_handle(app).client()
value = await asyncio.to_thread(
client.list_memories,
**query,
offset=0,
limit=limit,
)
try:
client = await _sdk_client_handle(app).client()
catalog = await raw_graph_catalog(
client,
query=query,
cache_key=cache_key,
)
except HTTPException:
raise
except Exception as error:
raise _sdk_http_error(
error,
"hy_memory_raw_graph_failed",
) from None
source_set = set(sources)
excluded_set = set(excluded)
session_set = set(sessions)
items = tuple(
item
for item in catalog
if (not source_set or item.source in source_set)
and item.source not in excluded_set
and (not session_set or item.session_id in session_set)
page = HySdkRawPage.from_memory_list(
value,
limit=limit,
offset=0,
)
snapshot_id = uuid4().hex
raw_graph_snapshots[snapshot_id] = (
time.monotonic(),
snapshot_scope,
items,
graph_cache_size(items),
)
prune_raw_graph_state()
if snapshot_id not in raw_graph_snapshots:
raise HTTPException(
status_code=413,
detail="raw_graph_snapshot_too_large",
)
if snapshot_id is None:
raise RuntimeError("raw_graph_snapshot_missing")
selected = items[offset : offset + limit]
except HTTPException:
raise
except Exception as error:
raise _sdk_http_error(
error,
"hy_memory_raw_graph_failed",
) from None
projected = graph_items(page.raw)
selected = projected[:limit]
total = max(page.total, len(projected))
return SdkRawGraphPage(
user_id=user_id,
items=selected,
total=len(items),
offset=offset,
total=total,
offset=0,
limit=limit,
has_more=offset + len(selected) < len(items),
snapshot_id=snapshot_id,
has_more=page.total > len(page.raw) or len(projected) > limit,
)
@app.post(
+3 -35
View File
@@ -838,8 +838,10 @@ def test_raw_lists_active_and_shadow_turns_with_roles_and_hashes(tmp_path, monke
assert cursor_page["has_more"] is True
assert cursor_turns == ["我喜欢绿茶", "我叫费天翔", "你好"]
assert len(sdk.all("list_memories")) == cursor_calls + 3
graph_calls = len(sdk.all("list_memories"))
graph = await client.get("/api/v2/raw/graph", params={"limit": 2})
assert graph.status_code == 200, graph.text
assert len(sdk.all("list_memories")) == graph_calls + 1
graph_body = graph.json()
assert graph_body["total"] == 3
assert graph_body["has_more"] is True
@@ -847,35 +849,13 @@ def test_raw_lists_active_and_shadow_turns_with_roles_and_hashes(tmp_path, monke
"[user]: 我喜欢绿茶 [assistant]: 也记住了。",
"[user]: 我叫费天翔 [assistant]: 好的,记住了。",
]
graph_page_2 = (
await client.get(
"/api/v2/raw/graph",
params={
"limit": 2,
"offset": 2,
"snapshot_id": graph_body["snapshot_id"],
},
)
).json()
assert graph_page_2["total"] == 3
assert graph_page_2["has_more"] is False
assert graph_page_2["items"][0]["turn_id"]
assert (await client.get("/api/v2/raw/graph", params={"limit": 301})).status_code == 422
assert (
await client.get(
"/api/v2/raw/graph",
params={"limit": 2, "offset": 2},
)
).status_code == 422
assert (
await client.get(
"/api/v2/raw/graph",
params={
"limit": 2,
"offset": 2,
"snapshot_id": "missing",
},
)
).status_code == 409
inserted = {
"memory_id": "raw-new",
"content": "[user]: 新消息",
@@ -887,18 +867,6 @@ def test_raw_lists_active_and_shadow_turns_with_roles_and_hashes(tmp_path, monke
}
RAW_ROWS["local"].insert(0, inserted)
try:
stable_graph_page = (
await client.get(
"/api/v2/raw/graph",
params={
"limit": 2,
"offset": 2,
"snapshot_id": graph_body["snapshot_id"],
},
)
).json()
assert stable_graph_page["total"] == 3
assert stable_graph_page["items"][0]["label"] == "[user]: 你好"
updated_graph = (await client.get("/api/v2/raw/graph")).json()
assert updated_graph["total"] == 4
assert updated_graph["items"][0]["label"] == "[user]: 新消息"
-24
View File
@@ -62,30 +62,6 @@ vi.mock("./MemoryGraph3D", () => ({
</div>
),
}));
vi.mock("./RawConversationGraph3D", () => ({
RawConversationGraph3D: ({
graph,
selectedNodeId,
onSelectNode,
}: {
graph: MemoryGraphResponse;
selectedNodeId: string | null;
onSelectNode(node: MemoryGraphNode): void;
}) => (
<div data-testid="canvas" data-selected={selectedNodeId}>
{graph.nodes.map((node) => (
<button
key={node.node_id}
type="button"
aria-label={`图中选择 ${node.label}`}
onClick={() => onSelectNode(node)}
>
{node.label}
</button>
))}
</div>
),
}));
afterEach(() => {
cleanup();
vi.resetAllMocks();
+8 -39
View File
@@ -52,7 +52,6 @@ import {
} from "./memory-map-model";
import { OptionMenu } from "./OptionMenu";
import { R6Button } from "./R6Controls";
import { RawConversationGraph3D } from "./RawConversationGraph3D";
import { SelectedMemoryDetail } from "./SelectedMemoryDetail";
import { SelectedRawDetail } from "./SelectedRawDetail";
import { sourceNameOr } from "./source-label";
@@ -108,8 +107,6 @@ type Props = {
/** The graph for the current source/query scope has not arrived yet. */
graphLoading?: boolean;
graphFailed?: boolean;
graphLoaded?: number;
graphTotal?: number;
stale: boolean;
settledSource?: string;
settledQuery?: string;
@@ -1327,23 +1324,6 @@ export function MemoryMap(props: Props) {
: text("记忆关联")
: label}
</h2>
{props.simplified && raw && library?.graphTotal !== undefined ? (
<p className="selected-graph-subtitle" role="status">
{library.graphLoading
? `${text("正在读取记忆地图…")} ${new Intl.NumberFormat(
locale,
).format(
library.graphLoaded ?? 0,
)} / ${new Intl.NumberFormat(locale).format(
library.graphTotal,
)}`
: `${new Intl.NumberFormat(locale).format(
library.graphLoaded ?? 0,
)} / ${new Intl.NumberFormat(locale).format(
library.graphTotal,
)}`}
</p>
) : null}
{props.simplified &&
(!raw || !projection.nodes.length) &&
!(raw && library?.graphLoading) && (
@@ -1374,25 +1354,14 @@ export function MemoryMap(props: Props) {
</header>
<div className="map-canvas" data-tour-id="memory-map-canvas">
{projection.nodes.length && (!props.simplified || !hidden) ? (
raw ? (
<RawConversationGraph3D
graph={projection}
selectedNodeId={
visibleGroupNode?.node_id ??
(activeId ? `memory:${activeId}` : null)
}
onSelectNode={pickNode}
/>
) : (
<MemoryGraph3D
graph={projection}
selectedNodeId={
visibleGroupNode?.node_id ??
(activeId ? `memory:${activeId}` : null)
}
onSelectNode={pickNode}
/>
)
<MemoryGraph3D
graph={projection}
selectedNodeId={
visibleGroupNode?.node_id ??
(activeId ? `memory:${activeId}` : null)
}
onSelectNode={pickNode}
/>
) : (
<div className="map-empty-stage">
<Network size={32} strokeWidth={1} />
@@ -34,6 +34,7 @@ it("shows every mode feature and reveals Ultra scheduling after selection", asyn
expect(screen.getByText("不调用生成模型")).toBeTruthy();
expect(screen.getByText("沉淀高级记忆")).toBeTruthy();
expect(screen.getByText("增强关联召回")).toBeTruthy();
expect(screen.getByText("· Pro").className).toContain("memory-mode-tier");
expect(screen.queryByText("每天自动整合")).toBeNull();
await user.click(screen.getByRole("radio", { name: /深度记忆/ }));
+5 -10
View File
@@ -1,7 +1,7 @@
import type { MemoryPipelineMode } from "@one-memory/contracts";
import { Check, Clock3 } from "lucide-react";
import { type ReactNode, useId } from "react";
import { MODE_COPY } from "./memory-mode-ui";
import { MODE_COPY, MODE_TIER, modeDisplayName } from "./memory-mode-ui";
import { useOnboardingTranslation } from "./onboarding-language";
import "./memory-mode-cards.css";
@@ -68,11 +68,8 @@ export function MemoryModeCards({
{MODES.map((mode) => {
const selected = value === mode.id;
const modeDisabled = unavailable(mode.id);
const localizedTitle = text(MODE_COPY[mode.id].title);
const title = localizedTitle.replace(
/\s*[((](?:lite|pro|ultra)[))]\s*$/i,
"",
);
const title = modeDisplayName(text, mode.id);
const tier = MODE_TIER[mode.id];
const features = text(MODE_COPY[mode.id].description).split(
/\s*·\s*/,
);
@@ -90,7 +87,7 @@ export function MemoryModeCards({
type="radio"
name={name}
value={mode.id}
aria-label={localizedTitle}
aria-label={`${title} · ${tier}`}
aria-describedby={describedBy?.[mode.id]}
checked={selected}
disabled={modeDisabled}
@@ -99,9 +96,7 @@ export function MemoryModeCards({
<span className="memory-mode-title">
<span className="memory-mode-index">{mode.index}</span>
<strong>{title}</strong>
<span className="memory-mode-tier">
{mode.id.toUpperCase()}
</span>
<span className="memory-mode-tier">· {tier}</span>
{current === mode.id && currentBadge ? (
<span className="memory-mode-current">{currentBadge}</span>
) : null}
+135 -39
View File
@@ -75,9 +75,13 @@ async function chooseMode(
user: ReturnType<typeof userEvent.setup>,
name: RegExp,
) {
const option = await screen.findByRole("radio", { name });
const control = await screen.findByRole("combobox", {
name: "记忆模式",
});
await user.click(control);
const option = await screen.findByRole("option", { name });
await user.click(option);
return option;
return control;
}
afterEach(() => {
@@ -88,17 +92,43 @@ afterEach(() => {
vi.mocked(memoryModePlanUnsupported).mockReturnValue(false);
});
it("renders all settings modes as one grouped shared choice list", async () => {
it("shows the current mode in a compact dropdown", async () => {
prepare("pro");
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const group = await screen.findByRole("radiogroup", {
name: "当前模式",
const control = await screen.findByRole("combobox", {
name: "记忆模式",
});
expect(within(group).getAllByRole("radio")).toHaveLength(3);
expect(control.textContent).toContain("智能记忆 · Pro");
expect(control.textContent).toContain("沉淀高级记忆");
expect(
within(group).getByText("当前模式").closest("label")?.textContent,
).toContain("智能记忆PRO");
control.querySelector(".om-option-menu-suffix")?.textContent,
).toContain("· Pro");
expect(control.textContent).not.toContain("(");
expect(screen.queryByText("当前模式")).toBeNull();
expect(screen.queryByRole("radiogroup")).toBeNull();
await user.click(control);
expect(screen.getAllByRole("option")).toHaveLength(3);
});
it("shows the effective Ultra mode and disables selection when forced by the environment", async () => {
prepare("ultra");
vi.mocked(getMemoryMode).mockResolvedValue({
...state("ultra"),
configured_mode: "pro",
forced_by_env: true,
});
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const control = await screen.findByRole("combobox", {
name: "记忆模式",
});
expect(control.textContent).toContain("深度记忆 · Ultra");
expect(control).toHaveProperty("disabled", true);
expect(screen.getByRole("switch", { name: "每天自动整合" })).toBeTruthy();
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
});
it("confirms the data effects before switching to lite", async () => {
@@ -111,7 +141,7 @@ it("confirms the data effects before switching to lite", async () => {
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /基础记忆(lite)/);
await chooseMode(user, /基础记忆 · Lite/);
expect(screen.getByText(/已有记忆继续保留/)).toBeTruthy();
expect(screen.getByText("模型调用与费用")).toBeTruthy();
expect(screen.getByText("搜索范围")).toBeTruthy();
@@ -121,18 +151,18 @@ it("confirms the data effects before switching to lite", async () => {
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledWith("lite"));
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole("radio", { name: /基础记忆(lite)/ }),
screen.getByRole("combobox", { name: "记忆模式" }),
),
);
});
it("traps modal focus, closes on Escape and restores the mode-card focus", async () => {
it("traps modal focus, closes on Escape and restores the mode dropdown", async () => {
prepare("pro");
const outside = document.createElement("button");
document.body.append(outside);
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const lite = await chooseMode(user, /基础记忆(lite)/);
const lite = await chooseMode(user, /基础记忆 · Lite/);
const cancel = screen.getByRole("button", { name: "取消" });
await waitFor(() => expect(document.activeElement).toBe(cancel));
@@ -154,7 +184,7 @@ it("keeps focus inside the modal while the mode change is pending", async () =>
vi.mocked(setMemoryMode).mockReturnValue(new Promise(() => {}));
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /基础记忆(lite)/);
await chooseMode(user, /基础记忆 · Lite/);
await user.click(screen.getByRole("button", { name: "确认选择" }));
const modal = screen.getByRole("dialog");
@@ -185,7 +215,7 @@ it("routes to model setup instead of switching pro without a model", async () =>
<MemoryModeSettings hasActiveModel={false} onConfigureModel={configure} />,
);
await chooseMode(user, /智能记忆(pro)/);
await chooseMode(user, /智能记忆 · Pro/);
expect(configure).toHaveBeenCalledExactlyOnceWith(
"使用 pro 或 ultra 前需要先配置并启用生成模型。",
);
@@ -199,11 +229,29 @@ it("uses the isolated legacy plan only for a route-level old-Core 404", async ()
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /基础记忆(lite)/);
await chooseMode(user, /基础记忆 · Lite/);
expect(planMemoryMode).toHaveBeenCalledWith("lite");
expect(await screen.findByText(/已有记忆继续保留/)).toBeTruthy();
});
it("restores focus to the mode dropdown when planning fails", async () => {
prepare("pro");
vi.mocked(planMemoryMode).mockRejectedValue(new Error("offline"));
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /基础记忆 · Lite/);
expect(
await screen.findByText("模式选择影响暂时无法读取,请重试。"),
).toBeTruthy();
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole("combobox", { name: "记忆模式" }),
),
);
});
it("keeps downgrade effects when an old Core falls back from ultra to pro", async () => {
prepare("ultra");
vi.mocked(planMemoryMode).mockRejectedValue(new Error("404"));
@@ -211,7 +259,7 @@ it("keeps downgrade effects when an old Core falls back from ultra to pro", asyn
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /智能记忆(pro)/);
await chooseMode(user, /智能记忆 · Pro/);
const dialog = screen.getByRole("dialog");
expect(within(dialog).getAllByText("已有数据")).toHaveLength(1);
@@ -223,7 +271,7 @@ it("explains model cost, search scope and the next step before switching to ultr
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /深度记忆(ultra)/);
await chooseMode(user, /深度记忆 · Ultra/);
expect(screen.getByText("模型调用与费用")).toBeTruthy();
expect(screen.getByText("搜索范围")).toBeTruthy();
@@ -239,7 +287,7 @@ it("distinguishes regular memories from higher-level Ultra data when switching t
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /智能记忆(pro)/);
await chooseMode(user, /智能记忆 · Pro/);
const dialog = screen.getByRole("dialog");
expect(within(dialog).getAllByText("已有数据")).toHaveLength(1);
@@ -256,7 +304,7 @@ it("does not show automatic consolidation when switching from lite to pro", asyn
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /智能记忆(pro)/);
await chooseMode(user, /智能记忆 · Pro/);
const dialog = screen.getByRole("dialog");
expect(within(dialog).getAllByText("已有数据")).toHaveLength(1);
@@ -273,10 +321,10 @@ it("focuses the Evolve settings after switching to ultra", async () => {
});
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await screen.findByRole("radio", { name: /智能记忆(pro)/ });
await screen.findByRole("combobox", { name: "记忆模式" });
vi.mocked(getMemoryMode).mockResolvedValue(state("ultra"));
await chooseMode(user, /深度记忆(ultra)/);
await chooseMode(user, /深度记忆 · Ultra/);
await user.click(screen.getByRole("button", { name: "确认选择" }));
await waitFor(() =>
@@ -287,6 +335,29 @@ it("focuses the Evolve settings after switching to ultra", async () => {
).toBeTruthy();
});
it("focuses the recoverable Evolve section when Ultra schedule loading fails", async () => {
prepare("pro");
vi.mocked(getEvolveSchedule).mockRejectedValue(new Error("offline"));
vi.mocked(setMemoryMode).mockResolvedValue({
...state("ultra"),
previous_mode: "pro",
effects: ["existing_memories_added_to_graph"],
});
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await screen.findByRole("combobox", { name: "记忆模式" });
vi.mocked(getMemoryMode).mockResolvedValue(state("ultra"));
await chooseMode(user, /深度记忆 · Ultra/);
await user.click(screen.getByRole("button", { name: "确认选择" }));
await waitFor(() =>
expect(document.activeElement?.getAttribute("id")).toContain("-evolve"),
);
expect(screen.getByText("自动整合设置暂时无法读取,请重试。")).toBeTruthy();
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
});
it("shows the default nightly evolve controls in ultra", async () => {
prepare("ultra");
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
@@ -305,6 +376,26 @@ it("shows the default nightly evolve controls in ultra", async () => {
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
});
it("keeps the immediate action visible and recovers when the schedule read fails", async () => {
prepare("ultra");
vi.mocked(getEvolveSchedule).mockRejectedValueOnce(new Error("offline"));
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
expect(
await screen.findByText("自动整合设置暂时无法读取,请重试。"),
).toBeTruthy();
expect(screen.queryByRole("switch", { name: "每天自动整合" })).toBeNull();
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
await user.click(screen.getByRole("button", { name: "重新读取" }));
const automatic = await screen.findByRole("switch", {
name: "每天自动整合",
});
await waitFor(() => expect(document.activeElement).toBe(automatic));
});
it("opens the time picker from the whole control and saves a complete time", async () => {
prepare("ultra");
vi.mocked(setEvolveSchedule).mockResolvedValue({
@@ -443,9 +534,9 @@ it("hides the time field when automatic evolve is off and keeps the saved time v
it("updates mode state automatically without showing a manual refresh control", async () => {
prepare("pro");
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const pro = await screen.findByRole("radio", { name: /智能记忆(pro)/ });
const mode = await screen.findByRole("combobox", { name: "记忆模式" });
expect((pro as HTMLInputElement).checked).toBe(true);
expect(mode.textContent).toContain("智能记忆 · Pro");
expect(screen.queryByRole("button", { name: "刷新记忆模式" })).toBeNull();
expect(getMemoryMode).toHaveBeenCalledTimes(1);
await waitFor(() => {
@@ -459,9 +550,9 @@ it("keeps the mode usable when an evolve status read fails", async () => {
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
expect(
(await screen.findByRole("radio", {
name: /智能记忆(pro)/,
})) as HTMLInputElement,
await screen.findByRole("combobox", {
name: "记忆模式",
}),
).toHaveProperty("disabled", false);
});
@@ -474,15 +565,17 @@ it("offers a text recovery action when the mode read fails", async () => {
expect(
await screen.findByText("记忆模式状态暂时无法读取,请重试。"),
).toBeTruthy();
expect(
screen.getByRole("radio", { name: /基础记忆(lite)/ }),
).toHaveProperty("disabled", true);
expect(screen.getByRole("combobox", { name: "记忆模式" })).toHaveProperty(
"disabled",
true,
);
await user.click(screen.getByRole("button", { name: "重新读取" }));
await waitFor(() =>
expect(
screen.getByRole("radio", { name: /智能记忆(pro)/ }),
).toHaveProperty("disabled", false),
expect(screen.getByRole("combobox", { name: "记忆模式" })).toHaveProperty(
"disabled",
false,
),
);
});
@@ -493,7 +586,7 @@ it("keeps a failed switch explanation inside the open confirmation dialog", asyn
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /深度记忆(ultra)/);
await chooseMode(user, /深度记忆 · Ultra/);
await user.click(screen.getByRole("button", { name: "确认选择" }));
const dialog = screen.getByRole("dialog");
@@ -514,15 +607,17 @@ it("keeps lite available while model status is unknown", async () => {
/>,
);
const user = userEvent.setup();
await user.click(await screen.findByRole("combobox", { name: "记忆模式" }));
expect(
await screen.findByRole("radio", { name: /基础记忆(lite)/ }),
await screen.findByRole("option", { name: /基础记忆 · Lite/ }),
).toHaveProperty("disabled", false);
expect(screen.getByRole("radio", { name: /智能记忆(pro)/ })).toHaveProperty(
expect(screen.getByRole("option", { name: /智能记忆 · Pro/ })).toHaveProperty(
"disabled",
true,
);
expect(
screen.getByRole("radio", { name: /深度记忆(ultra)/ }),
screen.getByRole("option", { name: /深度记忆 · Ultra/ }),
).toHaveProperty("disabled", true);
});
@@ -538,9 +633,10 @@ it("blocks mode and Evolve controls during embedding maintenance", async () => {
"正在检查或切换向量模型,完成前不能选择模式或手动整合。",
),
).toBeTruthy();
expect(
screen.getByRole("radio", { name: /基础记忆(lite)/ }),
).toHaveProperty("disabled", true);
expect(screen.getByRole("combobox", { name: "记忆模式" })).toHaveProperty(
"disabled",
true,
);
expect(screen.getByRole("button", { name: "立即整合" })).toHaveProperty(
"disabled",
true,
+152 -93
View File
@@ -23,8 +23,14 @@ import {
issueConfirmedModeTicket,
} from "./confirmed-memory-mode";
import { commitEvolveRun, useEvolveState } from "./evolve-state";
import { MemoryModeCards } from "./MemoryModeCards";
import { BLOCKER_COPY, MemoryModeConfirmDialog } from "./memory-mode-ui";
import {
BLOCKER_COPY,
MemoryModeConfirmDialog,
MODE_COPY,
MODE_TIER,
modeDisplayName,
} from "./memory-mode-ui";
import { OptionMenu } from "./OptionMenu";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Button } from "./R6Controls";
import { R6StateRecovery } from "./R6StateRecovery";
@@ -58,28 +64,39 @@ export function MemoryModeSettings({
const [maintenance, setMaintenance] = useState(false);
const [error, setError] = useState("");
const [readFailed, setReadFailed] = useState(false);
const [scheduleReadFailed, setScheduleReadFailed] = useState(false);
const refreshVersion = useRef(0);
const dialogOpener = useRef<HTMLElement | null>(null);
const focusEvolveWhenReady = useRef(false);
const focusModeWhenReady = useRef<MemoryPipelineMode | null>(null);
const focusScheduleWhenReady = useRef(false);
useEffect(() => {
if (!focusEvolveWhenReady.current || pendingMode || state?.mode !== "ultra")
return;
focusEvolveWhenReady.current = false;
document.getElementById(`${id}-evolve`)?.focus();
const evolve = document.getElementById(`${id}-evolve`);
if (evolve) {
focusEvolveWhenReady.current = false;
evolve.focus();
}
}, [id, pendingMode, state?.mode]);
useEffect(() => {
const target = focusModeWhenReady.current;
if (!target || busy || pendingMode || state?.mode !== target) return;
focusModeWhenReady.current = null;
document.getElementById(`${id}-select`)?.focus();
}, [busy, id, pendingMode, state?.mode]);
useEffect(() => {
if (!focusScheduleWhenReady.current || scheduleReadFailed || !schedule)
return;
focusScheduleWhenReady.current = false;
document
.querySelector<HTMLInputElement>(
`.r6-memory-mode-settings input[type="radio"][value="${target}"]`,
)
.getElementById(`${id}-evolve`)
?.querySelector<HTMLElement>('[role="switch"]')
?.focus();
}, [busy, pendingMode, state?.mode]);
}, [id, schedule, scheduleReadFailed]);
const refresh = useCallback(async () => {
const version = ++refreshVersion.current;
@@ -101,8 +118,12 @@ export function MemoryModeSettings({
setError("记忆模式状态暂时无法读取,请重试。");
setReadFailed(true);
}
if (scheduleResult.status === "fulfilled")
if (scheduleResult.status === "fulfilled") {
setScheduleState(scheduleResult.value);
setScheduleReadFailed(false);
} else {
setScheduleReadFailed(true);
}
if (processingResult.status === "fulfilled")
setMaintenance(Boolean(processingResult.value.embedding_switch));
}, [onModeChange]);
@@ -123,7 +144,7 @@ export function MemoryModeSettings({
run?.state === "running" ||
state?.graph_build.state === "running";
async function choose(mode: MemoryPipelineMode, opener: HTMLElement) {
async function choose(mode: MemoryPipelineMode) {
if (!state || mode === state.mode || busy || lifecycleBusy) return;
setError("");
setBusy(true);
@@ -133,6 +154,7 @@ export function MemoryModeSettings({
} catch (reason) {
if (!memoryModePlanUnsupported(reason)) {
setError("模式切换影响暂时无法读取,请重试。");
focusModeWhenReady.current = state.mode;
setBusy(false);
return;
}
@@ -143,7 +165,7 @@ export function MemoryModeSettings({
onConfigureModel(BLOCKER_COPY.active_model_required);
return;
}
dialogOpener.current = opener;
dialogOpener.current = document.getElementById(`${id}-select`);
setPendingPlan(plan);
setPendingMode(mode);
}
@@ -195,7 +217,7 @@ export function MemoryModeSettings({
}
async function evolveNow() {
if (!state || !schedule) return;
if (!state) return;
refreshVersion.current++;
setBusy(true);
setError("");
@@ -203,12 +225,15 @@ export function MemoryModeSettings({
const receipt = await startEvolve();
commitEvolveRun(
receipt,
overview ?? {
mode: state.mode,
schedule,
users: {},
running: [],
},
overview ??
(schedule
? {
mode: state.mode,
schedule,
users: {},
running: [],
}
: undefined),
);
} catch (reason) {
const code = apiErrorCode(reason);
@@ -234,28 +259,69 @@ export function MemoryModeSettings({
<p>{text("模式决定新对话如何保存,以及记忆页面展示什么。")}</p>
</div>
</div>
<MemoryModeCards
aria-label={text("当前模式")}
aria-busy={state === null}
current={state?.mode}
value={state?.mode ?? null}
currentBadge={text("当前模式")}
disabled={(mode) =>
!state ||
busy ||
lifecycleBusy ||
Boolean(state.forced_by_env && mode !== state.mode) ||
(mode !== "lite" && !modelStateKnown)
}
onChange={(mode, control) => void choose(mode, control)}
ultraDetail={
state?.mode === "ultra" && schedule ? (
<section
id={`${id}-evolve`}
className="r6-evolve-settings r6-evolve-settings--in-card"
aria-labelledby={`${id}-evolve-title`}
tabIndex={-1}
>
<div
className="r6-mode-control"
data-expanded={state?.mode === "ultra" || undefined}
>
<div className="r6-mode-setting-row" aria-busy={state === null}>
<OptionMenu
id={`${id}-select`}
ariaLabel={text("记忆模式")}
value={state?.mode ?? ""}
disabled={
!state || busy || lifecycleBusy || Boolean(state?.forced_by_env)
}
options={(["lite", "pro", "ultra"] as const).map((mode) => ({
value: mode,
label: modeDisplayName(text, mode),
suffix: MODE_TIER[mode],
description: text(MODE_COPY[mode].description),
disabled: mode !== "lite" && !modelStateKnown,
}))}
className="r6-language-option-menu r6-mode-option-menu"
panelClassName="r6-language-option-menu-panel--settings r6-mode-option-menu-panel"
onChange={(value) => {
if (value === "lite" || value === "pro" || value === "ultra")
void choose(value);
}}
/>
</div>
{state?.forced_by_env ? (
<p className="r6-settings-note" role="status">
{text("开发环境当前强制使用 ultra,保存的选择暂不会生效。")}
</p>
) : null}
{maintenance ? (
<p className="r6-settings-note" role="status">
{text("正在检查或切换向量模型,完成前不能切换模式或手动整合。")}
</p>
) : null}
{state?.graph_build.state === "running" ? (
<p className="r6-settings-note" role="status">
{text("正在把已有高级记忆加入知识图谱…")}
</p>
) : null}
{error && !pendingMode ? (
readFailed ? (
<R6StateRecovery
message={text(error)}
onRetry={() => void refresh()}
/>
) : (
<p className="r6-settings-error" role="alert">
{text(error)}
</p>
)
) : null}
{state?.mode === "ultra" ? (
<section
id={`${id}-evolve`}
className="r6-evolve-settings"
aria-labelledby={`${id}-evolve-title`}
tabIndex={-1}
>
{schedule ? (
<UltraEvolveScheduleFields
schedule={schedule}
headingId={`${id}-evolve-title`}
@@ -263,63 +329,56 @@ export function MemoryModeSettings({
onEnabledChange={(enabled) => void changeSchedule({ enabled })}
onTimeChange={(time) => changeSchedule({ time })}
/>
<div className="r6-evolve-setting r6-evolve-setting--manual">
<div className="r6-evolve-setting-copy">
<h3>{text("立即整合一次")}</h3>
) : (
<>
<div className="r6-evolve-heading">
<h3 id={`${id}-evolve-title`}>
{text("记忆整合(Evolve)")}
</h3>
<p>
{text("只运行这一次,不改变每天的计划。")}
{" · "}
{text(
run?.state === "running"
? "正在整合记忆…"
: last?.last_run_at
? text("上次整合:{time}").replace(
"{time}",
new Date(last.last_run_at).toLocaleString(locale),
)
: "尚未整合",
)}
{text("定期重新整理已有记忆,补充更稳定的关联和结论。")}
</p>
</div>
<R6Button
pending={run?.state === "running"}
disabled={busy || lifecycleBusy}
onClick={() => void evolveNow()}
>
{text("立即整合")}
</R6Button>
{scheduleReadFailed ? (
<R6StateRecovery
message={text("自动整合设置暂时无法读取,请重试。")}
onRetry={() => {
focusScheduleWhenReady.current = true;
void refresh();
}}
/>
) : null}
</>
)}
<div className="r6-evolve-setting r6-evolve-setting--manual">
<div className="r6-evolve-setting-copy">
<h3>{text("立即整合一次")}</h3>
<p>
{text("只运行这一次,不改变每天的计划。")}
{" · "}
{text(
run?.state === "running"
? "正在整合记忆…"
: last?.last_run_at
? text("上次整合:{time}").replace(
"{time}",
new Date(last.last_run_at).toLocaleString(locale),
)
: "尚未整合",
)}
</p>
</div>
</section>
) : null
}
/>
{state?.forced_by_env ? (
<p className="r6-settings-note" role="status">
{text("开发环境当前强制使用 ultra,保存的选择暂不会生效。")}
</p>
) : null}
{maintenance ? (
<p className="r6-settings-note" role="status">
{text("正在检查或切换向量模型,完成前不能切换模式或手动整合。")}
</p>
) : null}
{state?.graph_build.state === "running" ? (
<p className="r6-settings-note" role="status">
{text("正在把已有高级记忆加入知识图谱…")}
</p>
) : null}
{error && !pendingMode ? (
readFailed ? (
<R6StateRecovery
message={text(error)}
onRetry={() => void refresh()}
/>
) : (
<p className="r6-settings-error" role="alert">
{text(error)}
</p>
)
) : null}
<R6Button
pending={run?.state === "running"}
disabled={busy || lifecycleBusy}
onClick={() => void evolveNow()}
>
{text("立即整合")}
</R6Button>
</div>
</section>
) : null}
</div>
{pendingMode && pendingPlan ? (
<MemoryModeConfirmDialog
+78 -85
View File
@@ -129,9 +129,6 @@ vi.mock("./MemoryGraph3D", () => ({
/>
),
}));
vi.mock("./RawConversationGraph3D", () => ({
RawConversationGraph3D: () => <div data-testid="graph-owner" />,
}));
afterEach(() => {
cleanup();
resetClient();
@@ -323,10 +320,9 @@ function setup() {
},
],
total: 1,
limit: 500,
limit: 300,
offset: 0,
has_more: false,
snapshot_id: "snapshot-1",
});
reads.rawSearch.mockResolvedValue([]);
}
@@ -431,7 +427,7 @@ it("shows raw conversations instead of advanced memory in lite", async () => {
expect.objectContaining({
source: undefined,
offset: 0,
limit: 50,
limit: 20,
signal: expect.any(AbortSignal),
}),
);
@@ -441,94 +437,92 @@ it("shows raw conversations instead of advanced memory in lite", async () => {
expect(document.querySelector(".memory-mode-notice")).toBeNull();
});
it("loads the complete raw graph independently and reports its exact total", async () => {
it("loads one bounded raw graph page without claiming an exact turn total", async () => {
setup();
reads.processing.mockResolvedValue(modeProcessing("lite"));
reads.rawGraph.mockImplementation(async ({ offset = 0 }) => {
const total = 1_600;
const count = Math.min(500, total - offset);
return {
user_id: "local",
items: Array.from({ length: count }, (_, index) => {
const absolute = offset + index;
return {
turn_id: `turn-${absolute}`,
raw_id: `raw-${absolute}`,
label: `原始会话 ${absolute}`,
source: "cursor",
session_id: `session-${absolute}`,
memory_at: null,
gmt_created: 1_761_000_000,
};
}),
total,
limit: 500,
offset,
has_more: offset + count < total,
snapshot_id: "snapshot-1600",
};
reads.rawGraph.mockResolvedValue({
user_id: "local",
items: Array.from({ length: 300 }, (_, index) => ({
turn_id: `turn-${index}`,
raw_id: `raw-${index}`,
label: `原始会话 ${index}`,
source: "cursor",
session_id: `session-${index}`,
memory_at: null,
gmt_created: 1_761_000_000,
})),
total: 1_600,
limit: 300,
offset: 0,
has_more: true,
});
render(<MemorySpace simplified />);
expect(await screen.findByText("1,600 条原始对话")).toBeTruthy();
await waitFor(() => expect(reads.rawGraph).toHaveBeenCalledTimes(4));
expect(reads.rawGraph.mock.calls.map(([request]) => request.offset)).toEqual([
0, 500, 1_000, 1_500,
]);
expect(
reads.rawGraph.mock.calls
.slice(1)
.every(([request]) => request.snapshotId === "snapshot-1600"),
).toBe(true);
expect(screen.getByText("1,600 / 1,600")).toBeTruthy();
await waitFor(() => expect(reads.rawGraph).toHaveBeenCalledTimes(1));
expect(reads.rawGraph).toHaveBeenCalledWith({
source: undefined,
offset: 0,
limit: 300,
signal: expect.any(AbortSignal),
});
expect(screen.getByTestId("graph-owner").dataset.graphNodes).toBe("301");
expect(document.querySelector(".memory-loaded-count")?.textContent).not.toBe(
"1,600 条原始对话",
);
expect(screen.queryByText("1,600 / 1,600")).toBeNull();
});
it("restarts the complete graph when its snapshot expires between pages", async () => {
it("keeps the last successful Lite graph when a background refresh fails", async () => {
setup();
reads.processing.mockResolvedValue(modeProcessing("lite"));
const item = {
turn_id: "turn-1",
raw_id: "raw-1",
label: "原始会话",
source: "cursor",
session_id: "session-1",
memory_at: null,
gmt_created: 1_761_000_000,
};
reads.rawGraph
.mockResolvedValueOnce({
user_id: "local",
items: [item],
total: 2,
limit: 500,
offset: 0,
has_more: true,
snapshot_id: "expired",
})
.mockRejectedValueOnce(
Object.assign(new Error("stale"), {
isAxiosError: true,
response: { data: { detail: "raw_graph_snapshot_stale" } },
}),
)
.mockResolvedValueOnce({
user_id: "local",
items: [item],
total: 1,
limit: 500,
offset: 0,
has_more: false,
snapshot_id: "fresh",
});
const source = {
source_id: "cursor",
display_name: "Cursor",
authorized: true,
imported_messages: 1,
imported_conversations: 1,
} as SourceResponse;
const view = render(<MemorySpace simplified sources={[source]} />);
await waitFor(() =>
expect(screen.getByTestId("graph-owner").dataset.graphNodes).toBe("2"),
);
reads.rawGraph.mockRejectedValueOnce(new Error("refresh failed"));
view.rerender(
<MemorySpace
simplified
sources={[
source,
{
...source,
source_id: "codex",
display_name: "Codex",
},
]}
/>,
);
await waitFor(() => expect(reads.rawGraph).toHaveBeenCalledTimes(2));
expect(screen.getByTestId("graph-owner").dataset.graphNodes).toBe("2");
expect(screen.queryByText("原始对话暂时无法读取。")).toBeNull();
});
it("does not request the raw graph for a keyword search", async () => {
setup();
reads.processing.mockResolvedValue(modeProcessing("lite"));
reads.rawSearch.mockResolvedValue([]);
const user = userEvent.setup();
render(<MemorySpace simplified />);
await waitFor(() => expect(reads.rawGraph).toHaveBeenCalledTimes(3));
expect(reads.rawGraph.mock.calls[2][0]).toEqual(
expect.objectContaining({ offset: 0, snapshotId: undefined }),
await waitFor(() => expect(reads.rawGraph).toHaveBeenCalledTimes(1));
await user.type(
screen.getByRole("textbox", { name: "搜索原始对话" }),
"发布计划",
);
expect(screen.getByText("1 / 1")).toBeTruthy();
await user.click(screen.getByRole("button", { name: "搜索" }));
await waitFor(() => expect(reads.rawSearch).toHaveBeenCalledTimes(1));
expect(reads.rawGraph).toHaveBeenCalledTimes(1);
});
it("pages and filters the raw conversation data plane in lite", async () => {
@@ -589,7 +583,7 @@ it("pages and filters the raw conversation data plane in lite", async () => {
source: undefined,
cursor: "turn:1:0:1",
paging: "cursor",
limit: 50,
limit: 20,
}),
);
@@ -601,7 +595,7 @@ it("pages and filters the raw conversation data plane in lite", async () => {
source: "cursor",
cursor: null,
paging: "cursor",
limit: 50,
limit: 20,
}),
);
@@ -614,7 +608,7 @@ it("pages and filters the raw conversation data plane in lite", async () => {
expect.objectContaining({
source: "one-memory-manual",
offset: 0,
limit: 50,
limit: 20,
paging: "cursor",
cursor: null,
}),
@@ -837,10 +831,9 @@ it("opens a search hit with the complete role transcript", async () => {
user_id: "local",
items: [],
total: 1_600,
limit: 500,
limit: 300,
offset: 0,
has_more: false,
snapshot_id: "search-total",
});
const user = userEvent.setup();
render(<MemorySpace simplified />);
+21 -79
View File
@@ -9,7 +9,6 @@ import type {
MemoryLibraryPage,
MemoryProcessingSummary,
SdkAskMemory,
SdkRawGraphItem,
SourceResponse,
} from "@one-memory/contracts";
import { isMessageKey } from "@one-memory/i18n";
@@ -201,11 +200,6 @@ export function MemorySpace({
status: "ready" | "failed";
value: MemoryGraphResponse | null;
} | null>(null);
const [sdkGraphProgress, setSdkGraphProgress] = useState<{
scope: string;
loaded: number;
total: number | null;
} | null>(null);
// Bumping this re-reads the page and graph after an edit or a delete.
const [sdkMapRevision, setSdkMapRevision] = useState(0);
const bumpSdkMap = useCallback(() => setSdkMapRevision((v) => v + 1), []);
@@ -269,16 +263,11 @@ export function MemorySpace({
]);
const sdkGraphSettled = sdkGraph?.scope === sdkGraphRequestedScope;
const visibleSdkGraph =
sdkGraph?.scope === sdkGraphRequestedScope &&
(sdkGraph.status === "ready" || evolveRunning)
? sdkGraph.value
: null;
sdkGraph?.scope === sdkGraphRequestedScope ? sdkGraph.value : null;
const sdkGraphFailed =
sdkGraph?.scope === sdkGraphRequestedScope && sdkGraph.status === "failed";
const visibleGraphProgress =
sdkGraphProgress?.scope === sdkGraphRequestedScope
? sdkGraphProgress
: null;
sdkGraph?.scope === sdkGraphRequestedScope &&
sdkGraph.status === "failed" &&
sdkGraph.value === null;
// A keyword search pages through one server-side result window. The id is
// kept for the query and source it was searched with; a new query or
// source starts without one, so the server searches afresh.
@@ -456,8 +445,6 @@ export function MemorySpace({
const request = new AbortController();
let cancelled = false;
let reading = false;
rawGraphRecords.current.clear();
setSdkGraphProgress({ scope, loaded: 0, total: null });
const readGraph = async () => {
if (reading) return;
reading = true;
@@ -469,58 +456,25 @@ export function MemorySpace({
);
if (cancelled) return;
setSdkGraph({ scope, status: "ready", value });
setSdkGraphProgress({
scope,
loaded: value.projected_memories,
total: value.total_memories,
});
return;
}
for (let attempt = 0; attempt < 2; attempt += 1) {
const items: SdkRawGraphItem[] = [];
let offset = 0;
let total = 0;
let snapshotId: string | undefined;
rawGraphRecords.current.clear();
try {
while (!cancelled) {
const page = await getSdkRawGraph({
source,
offset,
limit: 500,
snapshotId,
signal: request.signal,
});
if (cancelled) return;
snapshotId = page.snapshot_id;
total = page.total;
items.push(...page.items);
for (const item of page.items) {
const record = rawGraphRecord(item);
rawGraphRecords.current.set(rawRecordId(record), record);
}
setSdkGraphProgress({ scope, loaded: items.length, total });
if (!page.has_more || page.items.length === 0) break;
offset += page.items.length;
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
} catch (reason) {
if (
attempt === 0 &&
apiErrorCode(reason) === "raw_graph_snapshot_stale"
)
continue;
throw reason;
}
if (cancelled) return;
setSdkGraph({
scope,
status: "ready",
value: rawGraphProjection(items, total, sourcesRef.current),
});
break;
const page = await getSdkRawGraph({
source,
offset: 0,
limit: 300,
signal: request.signal,
});
if (cancelled) return;
rawGraphRecords.current.clear();
for (const item of page.items) {
const record = rawGraphRecord(item);
rawGraphRecords.current.set(rawRecordId(record), record);
}
setSdkGraph({
scope,
status: "ready",
value: rawGraphProjection(page.items, page.total, sourcesRef.current),
});
} catch {
if (cancelled || request.signal.aborted) return;
setSdkGraph((current) => ({
@@ -547,17 +501,7 @@ export function MemorySpace({
sdkMapVisible,
sourceKey,
]);
const displayedSdkPage =
lite &&
!sdkLibraryQuery.trim() &&
sdkPage &&
visibleGraphProgress?.total != null
? {
...sdkPage,
total: visibleGraphProgress.total,
total_exact: true,
}
: sdkPage;
const displayedSdkPage = sdkPage;
const sdkFilterLabel = [
sdkLibrary.settledSource
? `${t("memory:来源")} ${
@@ -1397,8 +1341,6 @@ export function MemorySpace({
failed: sdkLibrary.failed && !evolveRunning,
graphLoading: !sdkGraphSettled,
graphFailed: sdkGraphFailed && !evolveRunning,
graphLoaded: visibleGraphProgress?.loaded,
graphTotal: visibleGraphProgress?.total ?? undefined,
stale: false,
settledSource: sdkLibrary.settledSource,
settledQuery: sdkLibrary.settledQuery,
+70
View File
@@ -0,0 +1,70 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { LanguageProvider } from "./gui-language";
import { OptionMenu } from "./OptionMenu";
afterEach(cleanup);
it("shows a suffix without requiring description copy", async () => {
const user = userEvent.setup();
render(
<LanguageProvider>
<OptionMenu
ariaLabel="记忆模式"
value="pro"
options={[
{ value: "lite", label: "基础记忆", suffix: "Lite" },
{ value: "pro", label: "智能记忆", suffix: "Pro" },
]}
onChange={vi.fn()}
/>
</LanguageProvider>,
);
const control = screen.getByRole("combobox", { name: "记忆模式" });
expect(control.textContent).toContain("智能记忆 · Pro");
await user.click(control);
expect(screen.getByRole("option", { name: "基础记忆 · Lite" })).toBeTruthy();
});
it("keeps option descriptions available to assistive technology", async () => {
const user = userEvent.setup();
render(
<LanguageProvider>
<OptionMenu
ariaLabel="记忆模式"
value="pro"
options={[
{
value: "lite",
label: "基础记忆",
description: "保存原始会话",
},
{
value: "pro",
label: "智能记忆",
suffix: "Pro",
description: "调用模型整理记忆",
},
]}
onChange={vi.fn()}
/>
</LanguageProvider>,
);
await user.click(screen.getByRole("combobox", { name: "记忆模式" }));
const option = screen.getByRole("option", { name: "智能记忆 · Pro" });
const descriptionId = option.getAttribute("aria-describedby");
expect(descriptionId).toBeTruthy();
expect(document.getElementById(descriptionId ?? "")?.textContent).toBe(
"调用模型整理记忆",
);
expect(
screen
.getByRole("option", { name: /基础记忆.*保存原始会话/ })
.getAttribute("aria-describedby"),
).toBeNull();
});
+27 -5
View File
@@ -13,6 +13,7 @@ import { useGuiLanguage } from "./gui-language";
export interface OptionMenuItem {
value: string;
label: string;
suffix?: string;
description?: string;
disabled?: boolean;
lang?: string;
@@ -22,6 +23,17 @@ const VIEWPORT_MARGIN = 12;
const PANEL_GAP = 4;
const MAX_PANEL_HEIGHT = 280;
function optionLabel(label: string, suffix?: string) {
return suffix ? (
<>
{label}
<span className="om-option-menu-suffix"> · {suffix}</span>
</>
) : (
label
);
}
export function optionMenuPosition(
anchor: Pick<DOMRect, "top" | "right" | "bottom" | "left" | "width">,
panelHeight: number,
@@ -268,11 +280,11 @@ export function OptionMenu(props: {
>
{selected?.description ? (
<span className="om-option-menu-copy">
<span>{visibleLabel}</span>
<span>{optionLabel(visibleLabel, selected.suffix)}</span>
<small>{selected.description}</small>
</span>
) : (
visibleLabel
optionLabel(visibleLabel, selected?.suffix)
)}
</button>
{open &&
@@ -299,6 +311,14 @@ export function OptionMenu(props: {
lang={item.lang}
data-active={activeIndex === index || undefined}
aria-selected={item.value === props.value}
aria-label={
item.suffix ? `${item.label} · ${item.suffix}` : undefined
}
aria-describedby={
item.description && item.suffix
? `${panelId}-option-${index}-description`
: undefined
}
disabled={item.disabled}
tabIndex={-1}
onMouseEnter={() => {
@@ -308,11 +328,13 @@ export function OptionMenu(props: {
>
{item.description ? (
<span className="om-option-menu-copy">
<span>{item.label}</span>
<small>{item.description}</small>
<span>{optionLabel(item.label, item.suffix)}</span>
<small id={`${panelId}-option-${index}-description`}>
{item.description}
</small>
</span>
) : (
item.label
optionLabel(item.label, item.suffix)
)}
</button>
))}
+9 -2
View File
@@ -204,7 +204,13 @@ it("uses one sidebar hierarchy and explains when a saved model is inactive in li
await user.click(screen.getByRole("button", { name: "界面语言" }));
expect(screen.getByRole("combobox", { name: "语言" })).toBeTruthy();
await user.click(screen.getByRole("button", { name: "模型配置" }));
expect(await screen.findByText("当前是基础记忆(lite)")).toBeTruthy();
expect(
await screen.findByText(
(_, element) =>
element?.tagName === "STRONG" &&
element.textContent === "基础记忆 · Lite",
),
).toBeTruthy();
expect(
screen.getByText(/是否开始整理取决于.*“用模型沉淀记忆”.*开关/),
).toBeTruthy();
@@ -243,8 +249,9 @@ it("carries a missing-model explanation into model configuration and moves focus
const user = userEvent.setup();
render(<R6Settings onDiagnostics={vi.fn()} />);
await user.click(await screen.findByRole("combobox", { name: "记忆模式" }));
await user.click(
await screen.findByRole("radio", { name: /智能记忆(pro)/ }),
await screen.findByRole("option", { name: /智能记忆 · Pro/ }),
);
expect(
+11 -8
View File
@@ -18,6 +18,7 @@ import {
type ModelSettingsActions,
type ModelSettingsFocusSection,
} from "./ModelSettings";
import { MODE_TIER, modeDisplayName } from "./memory-mode-ui";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Button, R6Switch } from "./R6Controls";
import { R6StateRecovery } from "./R6StateRecovery";
@@ -394,14 +395,16 @@ export function R6Settings({
{currentModel && (
<div className="r6-model-mode-status" role="status">
<strong>
{text(
memoryMode === "lite"
? "当前是基础记忆(lite)"
: memoryMode === "ultra"
? "当前是深度记忆(ultra)"
: memoryMode === "pro"
? "当前是智能记忆(pro)"
: "正在读取记忆模式…",
{memoryMode ? (
<>
{modeDisplayName(text, memoryMode)}
<span className="r6-model-mode-tier">
{" "}
· {MODE_TIER[memoryMode]}
</span>
</>
) : (
text("正在读取记忆模式…")
)}
</strong>
<p>
+12 -7
View File
@@ -8,14 +8,19 @@ function declaration(file: string, selector: string): string {
}
it("fills settings controls without widening shared fallback controls", () => {
for (const selector of [
".r6-memory-mode-settings .memory-mode-cards",
const modeMenu = declaration(
"r6-settings.css",
".r6-settings .r6-mode-option-menu",
);
expect(modeMenu).toMatch(/(?:^|[\s;])width:\s*100%\s*;/);
expect(modeMenu).toMatch(/(?:^|[\s;])max-width:\s*none\s*;/);
const fallback = declaration(
"r6-settings.css",
".r6-settings .connection-fallback .custom-harness-name",
]) {
const rule = declaration("r6-settings.css", selector);
expect(rule, selector).toMatch(/(?:^|[\s;])width:\s*100%\s*;/);
expect(rule, selector).toMatch(/(?:^|[\s;])max-width:\s*none\s*;/);
}
);
expect(fallback).toMatch(/(?:^|[\s;])width:\s*100%\s*;/);
expect(fallback).toMatch(/(?:^|[\s;])max-width:\s*none\s*;/);
expect(declaration("core-visual.css", ".custom-harness-name")).toMatch(
/(?:^|[\s;])max-width:\s*420px\s*;/,
);
@@ -1,80 +0,0 @@
import type {
MemoryGraphEdge,
MemoryGraphNode,
MemoryGraphResponse,
} from "@one-memory/contracts";
import { describe, expect, it } from "vitest";
import { createRawClusterLayout } from "./RawConversationGraph3D";
function makeRawGraph(count: number, sourceCount = 4): MemoryGraphResponse {
const sources: MemoryGraphNode[] = Array.from(
{ length: sourceCount },
(_, index) => ({
node_id: `source:s-${index}`,
node_type: "source",
memory_id: null,
source_id: `s-${index}`,
label: `Source ${index}`,
kind: null,
status: null,
confidence: null,
}),
);
const memories: MemoryGraphNode[] = Array.from(
{ length: count },
(_, index) => ({
node_id: `memory:m-${index}`,
node_type: "memory",
memory_id: `m-${index}`,
source_id: null,
label: `Turn ${index}`,
kind: "chat",
status: "confirmed",
confidence: null,
}),
);
const edges: MemoryGraphEdge[] = memories.map((memory, index) => ({
edge_id: `edge-${index}`,
source_node_id: sources[index % sourceCount].node_id,
target_node_id: memory.node_id,
relation: "evidence_source",
origin: "provenance",
weight: null,
}));
return {
total_memories: count,
projected_memories: count,
hy_graph_available: false,
nodes: [...sources, ...memories],
edges,
};
}
describe("createRawClusterLayout", () => {
it.each([1_600, 5_000])(
"positions every node in bounded linear time at %i turns",
(count) => {
const graph = makeRawGraph(count);
const started = performance.now();
const positions = createRawClusterLayout(graph);
const elapsed = performance.now() - started;
expect(positions.size).toBe(graph.nodes.length);
expect(elapsed).toBeLessThan(1_000);
for (const position of positions.values()) {
expect(Number.isFinite(position.x)).toBe(true);
expect(Number.isFinite(position.y)).toBe(true);
expect(Number.isFinite(position.z)).toBe(true);
}
},
);
it("is deterministic", () => {
const graph = makeRawGraph(80);
const first = createRawClusterLayout(graph);
const second = createRawClusterLayout(graph);
expect([...first].map(([id, value]) => [id, value.toArray()])).toEqual(
[...second].map(([id, value]) => [id, value.toArray()]),
);
});
});
@@ -1,421 +0,0 @@
import type {
MemoryGraphNode,
MemoryGraphResponse,
} from "@one-memory/contracts";
import { Focus, Minus, Plus } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { useMemoryLanguage } from "./memory-language";
const GRAPH_CANVAS = 0xedf1f5;
const MEMORY_COLOR = new THREE.Color(0x4e6478);
const SELECTED_COLOR = new THREE.Color(0x294355);
/** O(n) deterministic source clusters for the complete lite raw map. */
export function createRawClusterLayout(
graph: MemoryGraphResponse,
): Map<string, THREE.Vector3> {
const positions = new Map<string, THREE.Vector3>();
const sources = graph.nodes.filter((node) => node.node_type === "source");
const sourceIds = new Set(sources.map((node) => node.node_id));
const sourceByMemory = new Map<string, string>();
for (const edge of graph.edges) {
const sourceId = sourceIds.has(edge.source_node_id)
? edge.source_node_id
: sourceIds.has(edge.target_node_id)
? edge.target_node_id
: null;
if (!sourceId) continue;
const memoryId =
edge.source_node_id === sourceId
? edge.target_node_id
: edge.source_node_id;
sourceByMemory.set(memoryId, sourceId);
}
const fallbackSource = sources[0]?.node_id ?? "source:unknown";
const groups = new Map<string, MemoryGraphNode[]>();
for (const node of graph.nodes) {
if (node.node_type !== "memory") continue;
const sourceId = sourceByMemory.get(node.node_id) ?? fallbackSource;
const group = groups.get(sourceId) ?? [];
group.push(node);
groups.set(sourceId, group);
}
const clusterRing = Math.max(3.5, sources.length * 1.2);
sources.forEach((source, sourceIndex) => {
const angle = (sourceIndex / Math.max(1, sources.length)) * Math.PI * 2;
const center = new THREE.Vector3(
Math.cos(angle) * clusterRing,
((sourceIndex % 3) - 1) * 1.4,
Math.sin(angle) * clusterRing,
);
positions.set(source.node_id, center);
const memories = groups.get(source.node_id) ?? [];
memories.forEach((memory, index) => {
const direction = seededDirection(memory.node_id);
const radius = 0.9 + Math.cbrt(index + 1) * 0.42;
positions.set(
memory.node_id,
center.clone().addScaledVector(direction, radius),
);
});
});
return positions;
}
export function RawConversationGraph3D({
graph,
selectedNodeId,
onSelectNode,
}: {
graph: MemoryGraphResponse;
selectedNodeId: string | null;
onSelectNode(node: MemoryGraphNode): void;
}) {
const { text } = useMemoryLanguage();
const host = useRef<HTMLDivElement>(null);
const selectRef = useRef(onSelectNode);
const selectedRef = useRef(selectedNodeId);
const graphRef = useRef(graph);
const applySelection = useRef<(() => void) | null>(null);
const actions = useRef<{
zoom(scale: number): void;
reset(): void;
} | null>(null);
const [available, setAvailable] = useState(true);
const [hovered, setHovered] = useState<{
label: string;
x: number;
y: number;
} | null>(null);
selectRef.current = onSelectNode;
graphRef.current = graph;
const identity = useMemo(
() =>
`${graph.nodes
.map((node) => `${node.node_id}\0${node.label}`)
.join("\n")}\n${graph.edges
.map(
(edge) =>
`${edge.edge_id}\0${edge.source_node_id}\0${edge.target_node_id}`,
)
.join("\n")}`,
[graph],
);
useEffect(() => {
selectedRef.current = selectedNodeId;
applySelection.current?.();
}, [selectedNodeId]);
useEffect(() => {
void identity;
const graph = graphRef.current;
const element = host.current;
if (!element) return;
let renderer: THREE.WebGLRenderer;
try {
renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
powerPreference: "high-performance",
});
} catch {
setAvailable(false);
return;
}
setAvailable(true);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setClearColor(0x000000, 0);
element.appendChild(renderer.domElement);
renderer.domElement.setAttribute("aria-hidden", "true");
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(GRAPH_CANVAS, 0.025);
scene.add(new THREE.AmbientLight(0xf2f6fa, 1.8));
const directional = new THREE.DirectionalLight(0xe3edf5, 2);
directional.position.set(8, 10, 12);
scene.add(directional);
const camera = new THREE.PerspectiveCamera(48, 1, 0.1, 240);
const defaultDistance = Math.max(13, Math.sqrt(graph.nodes.length) * 0.55);
camera.position.set(0, defaultDistance * 0.25, defaultDistance);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = false;
controls.minDistance = 4;
controls.maxDistance = 140;
const positions = createRawClusterLayout(graph);
const nodeById = new Map(graph.nodes.map((node) => [node.node_id, node]));
const memories = graph.nodes.filter((node) => node.node_type === "memory");
const sources = graph.nodes.filter((node) => node.node_type === "source");
const memoryGeometry = new THREE.SphereGeometry(0.14, 10, 8);
const memoryMaterial = new THREE.MeshStandardMaterial({
color: MEMORY_COLOR,
roughness: 0.78,
metalness: 0.04,
vertexColors: true,
});
const instances = new THREE.InstancedMesh(
memoryGeometry,
memoryMaterial,
Math.max(1, memories.length),
);
instances.count = memories.length;
const matrix = new THREE.Matrix4();
memories.forEach((node, index) => {
matrix.setPosition(positions.get(node.node_id) ?? new THREE.Vector3());
instances.setMatrixAt(index, matrix);
instances.setColorAt(index, MEMORY_COLOR);
});
instances.instanceMatrix.needsUpdate = true;
if (instances.instanceColor) instances.instanceColor.needsUpdate = true;
scene.add(instances);
const sourceGeometry = new THREE.SphereGeometry(0.3, 16, 12);
const sourceMeshes = sources.map((node) => {
const material = new THREE.MeshStandardMaterial({
color: 0x2f6b57,
roughness: 0.65,
});
const mesh = new THREE.Mesh(sourceGeometry, material);
mesh.position.copy(positions.get(node.node_id) ?? new THREE.Vector3());
mesh.userData.nodeId = node.node_id;
scene.add(mesh);
return mesh;
});
const edgePositions = new Float32Array(graph.edges.length * 6);
graph.edges.forEach((edge, index) => {
const start = positions.get(edge.source_node_id);
const end = positions.get(edge.target_node_id);
if (!start || !end) return;
edgePositions.set(
[start.x, start.y, start.z, end.x, end.y, end.z],
index * 6,
);
});
const edgeGeometry = new THREE.BufferGeometry();
edgeGeometry.setAttribute(
"position",
new THREE.BufferAttribute(edgePositions, 3),
);
const edgeMaterial = new THREE.LineBasicMaterial({
color: 0x91a99c,
transparent: true,
opacity: 0.24,
});
scene.add(new THREE.LineSegments(edgeGeometry, edgeMaterial));
const render = () => renderer.render(scene, camera);
const reset = () => {
camera.position.set(0, defaultDistance * 0.25, defaultDistance);
controls.target.set(0, 0, 0);
controls.update();
render();
};
actions.current = {
zoom(scale) {
const offset = camera.position.clone().sub(controls.target);
offset.setLength(
Math.max(
controls.minDistance,
Math.min(controls.maxDistance, offset.length() * scale),
),
);
camera.position.copy(controls.target).add(offset);
controls.update();
render();
},
reset,
};
applySelection.current = () => {
memories.forEach((node, index) => {
instances.setColorAt(
index,
node.node_id === selectedRef.current ? SELECTED_COLOR : MEMORY_COLOR,
);
});
if (instances.instanceColor) instances.instanceColor.needsUpdate = true;
for (const mesh of sourceMeshes) {
const material = mesh.material as THREE.MeshStandardMaterial;
material.color.setHex(
mesh.userData.nodeId === selectedRef.current ? 0x294355 : 0x2f6b57,
);
}
render();
};
applySelection.current();
const resize = () => {
const width = Math.max(1, element.clientWidth);
const height = Math.max(1, element.clientHeight);
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
render();
};
const observer = new ResizeObserver(resize);
observer.observe(element);
controls.addEventListener("change", render);
resize();
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
let lastRaycast = 0;
let pressed: { x: number; y: number; id: number; moved: boolean } | null =
null;
const hit = (event: PointerEvent) => {
const bounds = renderer.domElement.getBoundingClientRect();
pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1;
pointer.y = -((event.clientY - bounds.top) / bounds.height) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
const match = raycaster.intersectObjects(
[instances, ...sourceMeshes],
false,
)[0];
if (!match) return null;
if (match.object === instances && match.instanceId !== undefined)
return memories[match.instanceId] ?? null;
return nodeById.get(String(match.object.userData.nodeId)) ?? null;
};
const move = (event: PointerEvent) => {
if (pressed)
pressed.moved ||=
Math.hypot(event.clientX - pressed.x, event.clientY - pressed.y) > 5;
const now = performance.now();
if (now - lastRaycast < 50 || pressed) return;
lastRaycast = now;
const node = hit(event);
renderer.domElement.style.cursor = node ? "pointer" : "grab";
setHovered(
node
? {
label: node.label,
x: event.clientX - element.getBoundingClientRect().left,
y: event.clientY - element.getBoundingClientRect().top,
}
: null,
);
};
const pointerDown = (event: PointerEvent) => {
pressed =
event.button === 0
? {
x: event.clientX,
y: event.clientY,
id: event.pointerId,
moved: false,
}
: null;
};
const select = (event: PointerEvent) => {
const start = pressed;
pressed = null;
if (!start || start.id !== event.pointerId || start.moved) return;
const node = hit(event);
if (node) selectRef.current(node);
};
const leave = () => {
pressed = null;
setHovered(null);
};
renderer.domElement.addEventListener("pointerdown", pointerDown);
renderer.domElement.addEventListener("pointermove", move);
renderer.domElement.addEventListener("pointerup", select);
renderer.domElement.addEventListener("pointerleave", leave);
return () => {
applySelection.current = null;
actions.current = null;
observer.disconnect();
controls.removeEventListener("change", render);
controls.dispose();
renderer.domElement.removeEventListener("pointerdown", pointerDown);
renderer.domElement.removeEventListener("pointermove", move);
renderer.domElement.removeEventListener("pointerup", select);
renderer.domElement.removeEventListener("pointerleave", leave);
memoryGeometry.dispose();
memoryMaterial.dispose();
sourceGeometry.dispose();
for (const mesh of sourceMeshes)
(mesh.material as THREE.Material).dispose();
edgeGeometry.dispose();
edgeMaterial.dispose();
renderer.dispose();
renderer.domElement.remove();
};
}, [identity]);
return (
<div className="memory-graph-canvas" ref={host}>
{!available ? (
<div className="memory-graph-fallback">
<p>{text("这台设备打不开 3D 视图,已显示关联列表。")}</p>
</div>
) : (
<div
className="memory-graph-controls"
role="toolbar"
aria-label={text("图谱工具")}
>
<button
type="button"
className="secondary"
aria-label={text("放大图谱")}
onClick={() => actions.current?.zoom(0.8)}
>
<Plus size={16} />
</button>
<button
type="button"
className="secondary"
aria-label={text("缩小图谱")}
onClick={() => actions.current?.zoom(1.25)}
>
<Minus size={16} />
</button>
<button
type="button"
className="secondary"
aria-label={text("重置图谱视角")}
onClick={() => actions.current?.reset()}
>
<Focus size={16} />
</button>
</div>
)}
{hovered ? (
<div
className="memory-graph-tooltip"
style={{ left: hovered.x + 12, top: Math.max(0, hovered.y - 40) }}
>
{hovered.label}
</div>
) : null}
</div>
);
}
function seededDirection(value: string): THREE.Vector3 {
const u = seededUnit(value, 0);
const v = seededUnit(value, 1);
const theta = u * Math.PI * 2;
const z = v * 2 - 1;
const radius = Math.sqrt(Math.max(0, 1 - z * z));
return new THREE.Vector3(
Math.cos(theta) * radius,
z,
Math.sin(theta) * radius,
);
}
function seededUnit(value: string, salt: number): number {
let hash = 2166136261 + salt;
for (const character of value) {
hash ^= character.charCodeAt(0);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0) / 4294967295;
}
+7 -10
View File
@@ -68,34 +68,31 @@ it("does not present an unavailable raw search as no matches", async () => {
await expect(searchSdkRaw("3d")).rejects.toThrow("raw_search_unavailable");
});
it("pages the independent lightweight raw graph", async () => {
it("requests one Pro-sized raw graph page", async () => {
get.mockResolvedValue({
data: {
user_id: "local",
items: [],
total: 1_600,
offset: 500,
limit: 500,
offset: 0,
limit: 300,
has_more: true,
snapshot_id: "snapshot-1",
},
});
const signal = new AbortController().signal;
await getSdkRawGraph({
source: "cursor",
offset: 500,
limit: 500,
snapshotId: "snapshot-1",
offset: 0,
limit: 300,
signal,
});
expect(get).toHaveBeenCalledWith("/api/v2/raw/graph", {
params: {
source: "cursor",
offset: 500,
limit: 500,
snapshot_id: "snapshot-1",
offset: 0,
limit: 300,
},
signal,
});
+2 -4
View File
@@ -1019,18 +1019,16 @@ export async function getSdkRaw({
export async function getSdkRawGraph({
source,
offset = 0,
limit = 500,
snapshotId,
limit = 300,
signal,
}: {
source?: string;
offset?: number;
limit?: number;
snapshotId?: string;
signal?: AbortSignal;
} = {}): Promise<SdkRawGraphPage> {
const response = await client.get<SdkRawGraphPage>("/api/v2/raw/graph", {
params: { source, offset, limit, snapshot_id: snapshotId },
params: { source, offset, limit },
signal,
});
return response.data;
@@ -122,7 +122,6 @@
font-size: 11px;
font-weight: 650;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.memory-mode-current {
+17 -1
View File
@@ -21,6 +21,22 @@ export const MODE_COPY: Record<
},
};
export const MODE_TIER: Record<MemoryPipelineMode, string> = {
lite: "Lite",
pro: "Pro",
ultra: "Ultra",
};
export function modeDisplayName(
text: (value: string) => string,
mode: MemoryPipelineMode,
) {
return text(MODE_COPY[mode].title)
.replace(/\s*[((](?:lite|pro|ultra)[))]\s*/i, " ")
.replace(/\s{2,}/g, " ")
.trim();
}
export const BLOCKER_COPY: Record<MemoryModePlan["blockers"][number], string> =
{
active_model_required: "使用 pro 或 ultra 前需要先配置并启用生成模型。",
@@ -184,7 +200,7 @@ export function MemoryModeConfirmDialog({
<h2 id="memory-mode-confirm-title">
{text("切换到 {mode}?").replace(
"{mode}",
text(MODE_COPY[mode].title),
`${modeDisplayName(text, mode)} · ${MODE_TIER[mode]}`,
)}
</h2>
<dl className="r6-mode-confirm-effects">
+154 -14
View File
@@ -227,26 +227,140 @@
font-size: 13px;
line-height: 22px;
}
.r6-memory-mode-settings .memory-mode-cards {
.r6-settings .r6-mode-setting-row {
width: 100%;
margin-top: 4px;
}
.r6-mode-control[data-expanded="true"] {
border: 1px solid #cbd6de;
border-radius: 10px;
background: #fbfcfd;
box-shadow:
inset 0 1px 0 #fff,
0 4px 14px rgb(48 71 87 / 5%);
}
.r6-mode-control[data-expanded="true"] .r6-mode-setting-row {
margin-top: 0;
border-bottom: 1px solid #dbe4ea;
}
.r6-settings
.r6-mode-control[data-expanded="true"]
.r6-mode-option-menu
.om-option-menu-button {
border: 0;
border-radius: 9px 9px 0 0;
box-shadow: inset 0 1px 0 #fff;
}
.r6-settings .r6-mode-option-menu {
width: 100%;
max-width: none;
}
.r6-memory-mode-settings
.memory-mode-card[data-mode="ultra"][data-selected="true"] {
grid-column: 1 / -1;
.r6-settings
.r6-mode-option-menu.r6-language-option-menu
.om-option-menu-button {
height: auto;
min-height: 72px;
padding: 12px 64px 12px 18px;
border-color: #cbd6de;
border-radius: 10px;
background: #f8fafb;
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 90%),
0 1px 2px rgb(48 71 87 / 4%);
white-space: normal;
transition:
border-color 140ms ease-out,
background-color 140ms ease-out,
box-shadow 140ms ease-out;
}
.r6-memory-mode-settings
.memory-mode-card[data-mode="ultra"][data-selected="true"]
.memory-mode-card-choice {
min-height: 180px;
.r6-settings
.r6-mode-option-menu.r6-language-option-menu
.om-option-menu-button::before {
content: "";
position: absolute;
top: 12px;
right: 52px;
bottom: 12px;
width: 1px;
background: #dbe4ea;
}
.r6-evolve-settings--in-card {
margin: 0;
padding: 0;
border-top: 0;
.r6-settings
.r6-mode-option-menu.r6-language-option-menu
.om-option-menu-button::after {
right: 22px;
}
.r6-evolve-settings--in-card .r6-evolve-setting:last-child {
border-bottom: 0;
.r6-mode-option-menu.r6-language-option-menu
.om-option-menu-button:hover:not(:disabled) {
border-color: #a8bac5;
background: #fbfcfd;
box-shadow:
inset 0 1px 0 #fff,
0 4px 12px rgb(48 71 87 / 7%);
}
.r6-settings
.r6-mode-option-menu.r6-language-option-menu
.om-option-menu-button[aria-expanded="true"] {
border-color: #7892a6;
background: #fff;
box-shadow:
inset 0 1px 0 #fff,
0 0 0 3px rgb(85 124 147 / 10%);
}
.r6-mode-option-menu .om-option-menu-copy,
.r6-mode-option-menu-panel .om-option-menu-copy {
display: grid;
gap: 3px;
min-width: 0;
}
.r6-mode-option-menu .om-option-menu-copy > span,
.r6-mode-option-menu-panel .om-option-menu-copy > span {
color: #304757;
font-size: 14px;
font-weight: 600;
line-height: 22px;
letter-spacing: 0.01em;
}
.r6-mode-option-menu .om-option-menu-suffix,
.r6-mode-option-menu-panel .om-option-menu-suffix {
color: var(--ok, #2f6b57);
font-size: 12px;
font-weight: 650;
letter-spacing: 0.03em;
}
.r6-mode-option-menu .om-option-menu-copy > small,
.r6-mode-option-menu-panel .om-option-menu-copy > small {
color: #526674;
font-size: 12px;
font-weight: 400;
line-height: 20px;
letter-spacing: 0.01em;
overflow-wrap: anywhere;
}
.r6-language-option-menu-panel--settings.r6-mode-option-menu-panel {
padding: 8px;
border-color: #cbd6de;
border-radius: 12px;
box-shadow: 0 14px 32px rgb(48 71 87 / 12%);
}
.r6-language-option-menu-panel--settings.r6-mode-option-menu-panel
[role="option"] {
min-height: 64px;
padding: 10px 12px;
border-radius: 8px;
white-space: normal;
}
.r6-language-option-menu-panel--settings.r6-mode-option-menu-panel
[role="option"][aria-selected="true"] {
background: #e8eff3;
}
.r6-language-option-menu-panel--settings.r6-mode-option-menu-panel
[role="option"][data-active="true"] {
background: #f0f4f6;
box-shadow: inset 2px 0 #607b8e;
}
.r6-language-option-menu-panel--settings.r6-mode-option-menu-panel
[role="option"][aria-selected="true"][data-active="true"] {
background: #e8eff3;
}
.r6-settings-note {
margin: 14px 0 0;
@@ -261,11 +375,18 @@
margin-top: 28px;
padding-top: 24px;
border-top: 1px solid #d8e2ea;
animation: r6-evolve-settings-in 180ms ease-out both;
}
.r6-evolve-settings:focus-visible {
outline: 2px solid var(--r6-focus);
outline-offset: 4px;
}
@keyframes r6-evolve-settings-in {
from {
opacity: 0;
transform: translateY(-4px);
}
}
.r6-evolve-heading {
margin-bottom: 8px;
}
@@ -692,6 +813,20 @@
line-height: 22px;
margin: 8px 0 0;
}
.r6-mode-control[data-expanded="true"] > .r6-settings-note,
.r6-mode-control[data-expanded="true"] > .r6-settings-error {
margin-right: 18px;
margin-left: 18px;
}
.r6-mode-control[data-expanded="true"] > .r6-evolve-settings {
margin: 18px 18px 0;
padding-top: 20px;
}
.r6-mode-control[data-expanded="true"]
> .r6-evolve-settings
.r6-evolve-setting--manual {
border-bottom: 0;
}
.r6-settings .r6-settings-read-status {
color: #63707b;
font-size: 13px;
@@ -727,6 +862,11 @@
font-weight: 600;
line-height: 22px;
}
.r6-settings .r6-model-mode-tier {
color: var(--ok, #2f6b57);
font-weight: 650;
letter-spacing: 0.03em;
}
.r6-settings .r6-model-mode-status p {
margin: 2px 0 0;
color: var(--r6-muted);
+1 -1
View File
@@ -11,7 +11,7 @@ import type {
} from "@one-memory/contracts";
export type RawMapRecord = SdkRawRecord | SdkRawContextItem;
export const SDK_RAW_PAGE_SIZE = 50;
export const SDK_RAW_PAGE_SIZE = 20;
export function rawRecordId(record: RawMapRecord): string {
return record.turn_id || record.raw_id;
+3 -2
View File
@@ -720,7 +720,7 @@ export interface SdkRawPage {
time_filtered?: boolean;
}
/** Lightweight complete-map projection. Raw text remains SDK-owned. */
/** Lightweight bounded-map projection. Raw text remains SDK-owned. */
export interface SdkRawGraphItem {
turn_id: string;
raw_id: string;
@@ -734,11 +734,12 @@ export interface SdkRawGraphItem {
export interface SdkRawGraphPage {
user_id: string;
items: SdkRawGraphItem[];
/** SDK-reported matching raw scope; `items` remains capped by `limit`. */
total: number;
limit: number;
offset: number;
/** More matching turns exist, but the product map does not auto-page them. */
has_more: boolean;
snapshot_id: string;
}
/** `GET /api/v2/memory-library/layers`: memories per SDK layer. */
+20
View File
@@ -62,6 +62,7 @@ const modeChineseMessages = {
"模式切换未能确认,仍显示上次确认的模式。":
"模式选择未能确认,仍显示上次确认的模式。",
"自动整合设置未能保存,请重试。": "自动整合设置未能保存,请重试。",
"自动整合设置暂时无法读取,请重试。": "自动整合设置暂时无法读取,请重试。",
"正在检查或切换向量模型,完成后才能整合记忆。":
"正在检查或切换向量模型,完成后才能整合记忆。",
"未能开始整合记忆,请重试。": "未能开始整合记忆,请重试。",
@@ -260,6 +261,8 @@ export const modeEnglishMessages: ModeMessages = {
"The mode change could not be confirmed. The last confirmed mode is still shown.",
"自动整合设置未能保存,请重试。":
"Could not save automatic consolidation settings. Please retry.",
"自动整合设置暂时无法读取,请重试。":
"Could not load automatic consolidation settings. Please retry.",
"正在检查或切换向量模型,完成后才能整合记忆。":
"The embedding model is being checked or changed. Wait for it to finish before consolidating memories.",
"未能开始整合记忆,请重试。":
@@ -466,6 +469,8 @@ export const modeSpanishMessages: ModeMessages = {
"No se pudo confirmar el cambio. Se sigue mostrando el último modo confirmado.",
"自动整合设置未能保存,请重试。":
"No se pudo guardar la consolidación automática. Reinténtalo.",
"自动整合设置暂时无法读取,请重试。":
"No se pudo cargar la configuración de consolidación automática. Reinténtalo.",
"正在检查或切换向量模型,完成后才能整合记忆。":
"Se está comprobando o cambiando el modelo de embeddings. Espera para consolidar memorias.",
"未能开始整合记忆,请重试。":
@@ -666,6 +671,7 @@ export const modeTraditionalChineseMessages: ModeMessages = {
"模式切换未能确认,仍显示上次确认的模式。":
"無法確認模式切換,仍顯示上次確認的模式。",
"自动整合设置未能保存,请重试。": "自動整合設定無法儲存,請重試。",
"自动整合设置暂时无法读取,请重试。": "暫時無法讀取自動整合設定,請重試。",
"正在检查或切换向量模型,完成后才能整合记忆。":
"正在檢查或切換向量模型,完成後才能整合記憶。",
"未能开始整合记忆,请重试。": "無法開始整合記憶,請重試。",
@@ -948,6 +954,8 @@ export const modeJapaneseMessages: ModeMessages = {
"モード変更を確認できませんでした。最後に確認されたモードを表示しています。",
"自动整合设置未能保存,请重试。":
"自動統合の設定を保存できませんでした。もう一度お試しください。",
"自动整合设置暂时无法读取,请重试。":
"自動統合の設定を読み込めませんでした。もう一度お試しください。",
"正在检查或切换向量模型,完成后才能整合记忆。":
"埋め込みモデルを確認または切り替えています。完了後に記憶を統合できます。",
"未能开始整合记忆,请重试。":
@@ -1153,6 +1161,8 @@ export const modeGermanMessages: ModeMessages = {
"Der Moduswechsel konnte nicht bestätigt werden. Der zuletzt bestätigte Modus wird weiterhin angezeigt.",
"自动整合设置未能保存,请重试。":
"Die Einstellungen zur automatischen Konsolidierung konnten nicht gespeichert werden. Bitte erneut versuchen.",
"自动整合设置暂时无法读取,请重试。":
"Die Einstellungen zur automatischen Konsolidierung konnten nicht geladen werden. Bitte erneut versuchen.",
"正在检查或切换向量模型,完成后才能整合记忆。":
"Das Embedding-Modell wird geprüft oder gewechselt. Danach können Erinnerungen konsolidiert werden.",
"未能开始整合记忆,请重试。":
@@ -1359,6 +1369,8 @@ export const modePortugueseMessages: ModeMessages = {
"Não foi possível confirmar a mudança. Continua a ser mostrado o último modo confirmado.",
"自动整合设置未能保存,请重试。":
"Não foi possível guardar a consolidação automática. Tente novamente.",
"自动整合设置暂时无法读取,请重试。":
"Não foi possível carregar as configurações de consolidação automática. Tente novamente.",
"正在检查或切换向量模型,完成后才能整合记忆。":
"O modelo de embeddings está a ser verificado ou alterado. Aguarde para consolidar memórias.",
"未能开始整合记忆,请重试。":
@@ -1567,6 +1579,8 @@ export const modeFrenchMessages: ModeMessages = {
"Le changement n’a pas pu être confirmé. Le dernier mode confirmé reste affiché.",
"自动整合设置未能保存,请重试。":
"Impossible d’enregistrer la consolidation automatique. Réessayez.",
"自动整合设置暂时无法读取,请重试。":
"Impossible de charger les paramètres de consolidation automatique. Réessayez.",
"正在检查或切换向量模型,完成后才能整合记忆。":
"Le modèle d’embeddings est en cours de vérification ou de changement. Patientez avant de consolider les mémoires.",
"未能开始整合记忆,请重试。":
@@ -1768,6 +1782,8 @@ export const modeKoreanMessages: ModeMessages = {
"모드 전환을 확인하지 못했습니다. 마지막으로 확인된 모드가 계속 표시됩니다.",
"自动整合设置未能保存,请重试。":
"자동 통합 설정을 저장하지 못했습니다. 다시 시도하세요.",
"自动整合设置暂时无法读取,请重试。":
"자동 통합 설정을 불러오지 못했습니다. 다시 시도하세요.",
"正在检查或切换向量模型,完成后才能整合记忆。":
"임베딩 모델을 확인하거나 전환 중입니다. 완료된 후 메모리를 통합할 수 있습니다.",
"未能开始整合记忆,请重试。":
@@ -1971,6 +1987,8 @@ export const modeRussianMessages: ModeMessages = {
"Не удалось подтвердить смену режима. По-прежнему показан последний подтверждённый режим.",
"自动整合设置未能保存,请重试。":
"Не удалось сохранить настройки автообъединения. Повторите попытку.",
"自动整合设置暂时无法读取,请重试。":
"Не удалось загрузить настройки автообъединения. Повторите попытку.",
"正在检查或切换向量模型,完成后才能整合记忆。":
"Модель эмбеддингов проверяется или меняется. После завершения можно будет объединить воспоминания.",
"未能开始整合记忆,请重试。":
@@ -2176,6 +2194,8 @@ export const modeIndonesianMessages: ModeMessages = {
"Perubahan mode tidak dapat dikonfirmasi. Mode terakhir yang dikonfirmasi tetap ditampilkan.",
"自动整合设置未能保存,请重试。":
"Pengaturan konsolidasi otomatis tidak dapat disimpan. Coba lagi.",
"自动整合设置暂时无法读取,请重试。":
"Pengaturan konsolidasi otomatis tidak dapat dimuat. Coba lagi.",
"正在检查或切换向量模型,完成后才能整合记忆。":
"Model embedding sedang diperiksa atau diganti. Tunggu hingga selesai sebelum mengonsolidasikan memori.",
"未能开始整合记忆,请重试。":