From c73bbb9eacbd72c2053ccbb4ecebdeff036b028f Mon Sep 17 00:00:00 2001 From: kristinhe Date: Fri, 6 Nov 2026 11:16:46 +0800 Subject: [PATCH] fix(memhub): bound Lite raw conversation map Co-authored-by: Cursor --- memhub/apps/core/src/one_memory/api.py | 256 ++--------- .../apps/core/tests/test_sdk_api_surface.py | 38 +- memhub/apps/web/src/MemoryMap.test.tsx | 24 - memhub/apps/web/src/MemoryMap.tsx | 47 +- memhub/apps/web/src/MemorySpace.r6.test.tsx | 163 ++++--- memhub/apps/web/src/MemorySpace.tsx | 100 +---- .../web/src/RawConversationGraph3D.test.ts | 80 ---- .../apps/web/src/RawConversationGraph3D.tsx | 421 ------------------ memhub/apps/web/src/api.raw.test.ts | 17 +- memhub/apps/web/src/api.ts | 6 +- memhub/apps/web/src/sdk-raw-map.ts | 2 +- memhub/packages/contracts/src/index.ts | 5 +- 12 files changed, 163 insertions(+), 996 deletions(-) delete mode 100644 memhub/apps/web/src/RawConversationGraph3D.test.ts delete mode 100644 memhub/apps/web/src/RawConversationGraph3D.tsx 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. */