diff --git a/memhub/apps/core/src/one_memory/api.py b/memhub/apps/core/src/one_memory/api.py
index 10fc977..412d8df 100644
--- a/memhub/apps/core/src/one_memory/api.py
+++ b/memhub/apps/core/src/one_memory/api.py
@@ -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(
diff --git a/memhub/apps/core/tests/test_sdk_api_surface.py b/memhub/apps/core/tests/test_sdk_api_surface.py
index 2377284..44e50cb 100644
--- a/memhub/apps/core/tests/test_sdk_api_surface.py
+++ b/memhub/apps/core/tests/test_sdk_api_surface.py
@@ -743,8 +743,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
@@ -752,35 +754,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]: 新消息",
@@ -792,18 +772,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]: 新消息"
diff --git a/memhub/apps/web/src/MemoryMap.test.tsx b/memhub/apps/web/src/MemoryMap.test.tsx
index 06964c9..b894421 100644
--- a/memhub/apps/web/src/MemoryMap.test.tsx
+++ b/memhub/apps/web/src/MemoryMap.test.tsx
@@ -62,30 +62,6 @@ vi.mock("./MemoryGraph3D", () => ({
),
}));
-vi.mock("./RawConversationGraph3D", () => ({
- RawConversationGraph3D: ({
- graph,
- selectedNodeId,
- onSelectNode,
- }: {
- graph: MemoryGraphResponse;
- selectedNodeId: string | null;
- onSelectNode(node: MemoryGraphNode): void;
- }) => (
-
- {graph.nodes.map((node) => (
-
- ))}
-
- ),
-}));
afterEach(() => {
cleanup();
vi.resetAllMocks();
diff --git a/memhub/apps/web/src/MemoryMap.tsx b/memhub/apps/web/src/MemoryMap.tsx
index 24be00d..f570e6c 100644
--- a/memhub/apps/web/src/MemoryMap.tsx
+++ b/memhub/apps/web/src/MemoryMap.tsx
@@ -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}
- {props.simplified && raw && library?.graphTotal !== undefined ? (
-
- {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,
- )}`}
-
- ) : null}
{props.simplified &&
(!raw || !projection.nodes.length) &&
!(raw && library?.graphLoading) && (
@@ -1374,25 +1354,14 @@ export function MemoryMap(props: Props) {
{projection.nodes.length && (!props.simplified || !hidden) ? (
- raw ? (
-
- ) : (
-
- )
+
) : (
diff --git a/memhub/apps/web/src/MemorySpace.r6.test.tsx b/memhub/apps/web/src/MemorySpace.r6.test.tsx
index a27affa..42c1675 100644
--- a/memhub/apps/web/src/MemorySpace.r6.test.tsx
+++ b/memhub/apps/web/src/MemorySpace.r6.test.tsx
@@ -129,9 +129,6 @@ vi.mock("./MemoryGraph3D", () => ({
/>
),
}));
-vi.mock("./RawConversationGraph3D", () => ({
- RawConversationGraph3D: () =>
,
-}));
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(
);
- 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(
);
+ await waitFor(() =>
+ expect(screen.getByTestId("graph-owner").dataset.graphNodes).toBe("2"),
+ );
+ reads.rawGraph.mockRejectedValueOnce(new Error("refresh failed"));
+ view.rerender(
+
,
+ );
+
+ 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(
);
- 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(
);
diff --git a/memhub/apps/web/src/MemorySpace.tsx b/memhub/apps/web/src/MemorySpace.tsx
index 799cf23..a0ea198 100644
--- a/memhub/apps/web/src/MemorySpace.tsx
+++ b/memhub/apps/web/src/MemorySpace.tsx
@@ -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
((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,
diff --git a/memhub/apps/web/src/RawConversationGraph3D.test.ts b/memhub/apps/web/src/RawConversationGraph3D.test.ts
deleted file mode 100644
index 9d399fc..0000000
--- a/memhub/apps/web/src/RawConversationGraph3D.test.ts
+++ /dev/null
@@ -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()]),
- );
- });
-});
diff --git a/memhub/apps/web/src/RawConversationGraph3D.tsx b/memhub/apps/web/src/RawConversationGraph3D.tsx
deleted file mode 100644
index 1359f4b..0000000
--- a/memhub/apps/web/src/RawConversationGraph3D.tsx
+++ /dev/null
@@ -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 {
- const positions = new Map();
- const sources = graph.nodes.filter((node) => node.node_type === "source");
- const sourceIds = new Set(sources.map((node) => node.node_id));
- const sourceByMemory = new Map();
- 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();
- 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(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 (
-
- {!available ? (
-
-
{text("这台设备打不开 3D 视图,已显示关联列表。")}
-
- ) : (
-
-
-
-
-
- )}
- {hovered ? (
-
- {hovered.label}
-
- ) : null}
-
- );
-}
-
-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;
-}
diff --git a/memhub/apps/web/src/api.raw.test.ts b/memhub/apps/web/src/api.raw.test.ts
index 1e54da2..de6c44d 100644
--- a/memhub/apps/web/src/api.raw.test.ts
+++ b/memhub/apps/web/src/api.raw.test.ts
@@ -60,34 +60,31 @@ it("keeps the application filter when searching raw conversations", async () =>
);
});
-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,
});
diff --git a/memhub/apps/web/src/api.ts b/memhub/apps/web/src/api.ts
index da2682e..e5b1470 100644
--- a/memhub/apps/web/src/api.ts
+++ b/memhub/apps/web/src/api.ts
@@ -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 {
const response = await client.get("/api/v2/raw/graph", {
- params: { source, offset, limit, snapshot_id: snapshotId },
+ params: { source, offset, limit },
signal,
});
return response.data;
diff --git a/memhub/apps/web/src/sdk-raw-map.ts b/memhub/apps/web/src/sdk-raw-map.ts
index 0b73494..c2aef25 100644
--- a/memhub/apps/web/src/sdk-raw-map.ts
+++ b/memhub/apps/web/src/sdk-raw-map.ts
@@ -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;
diff --git a/memhub/packages/contracts/src/index.ts b/memhub/packages/contracts/src/index.ts
index 2fd3da3..c336bac 100644
--- a/memhub/packages/contracts/src/index.ts
+++ b/memhub/packages/contracts/src/index.ts
@@ -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. */