fix(memhub): load complete raw conversation map safely (#353)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
kristinhe1111andCursor authored and GitHub committed 2026-11-05 22:45:50 +08:00
1 parent f445ad69c4
commit 9560327ee2
14 files changed
+1597 -129

No files matched your search

+303 -25
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
from uuid import UUID, uuid4
from fastapi import (
Depends,
@@ -652,6 +652,34 @@ class SdkRawPage(BaseModel):
time_filtered: bool = False
class SdkRawGraphItem(BaseModel):
"""Small, non-authoritative projection used to draw the complete raw map."""
model_config = ConfigDict(frozen=True, extra="forbid")
turn_id: str = Field(min_length=1, max_length=96)
raw_id: str = Field(min_length=1)
label: str = Field(max_length=240)
source: str
session_id: str
memory_at: float | None = None
gmt_created: float | None = None
class SdkRawGraphPage(BaseModel):
"""One page from a complete, bounded-memory raw-turn catalog."""
model_config = ConfigDict(extra="forbid")
user_id: str
items: tuple[SdkRawGraphItem, ...] = ()
total: int = Field(ge=0)
offset: int = Field(ge=0)
limit: int = Field(ge=1, le=500)
has_more: bool = False
snapshot_id: str = Field(min_length=1, max_length=64)
class SdkLayerCounts(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -4496,6 +4524,164 @@ 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],
) -> tuple[SdkRawGraphItem, ...]:
items: list[SdkRawGraphItem] = []
for record in records:
for turn in reversed(raw_display_turns(record)):
label = re.sub(r"\s+", " ", turn.content).strip()[:240]
items.append(
SdkRawGraphItem(
turn_id=turn.turn_id,
raw_id=record.raw_id,
label=label or record.raw_id,
source=record.source,
session_id=record.session_id,
memory_at=record.memory_at,
gmt_created=record.gmt_created,
)
)
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",
@@ -4633,15 +4819,11 @@ def create_app(
offset=raw_offset,
)
if raw_page.total != observed_total:
raise HTTPException(
status_code=409, detail="raw_cursor_stale"
)
raise HTTPException(status_code=409, detail="raw_cursor_stale")
if expected_raw.startswith("p"):
if raw_offset == 0:
raise HTTPException(
status_code=409, detail="raw_cursor_stale"
)
raise HTTPException(status_code=409, detail="raw_cursor_stale")
previous_value = await asyncio.to_thread(
client.list_memories,
**query,
@@ -4654,13 +4836,11 @@ def create_app(
valid_anchor = (
previous_page.total == raw_page.total
and bool(previous_page.raw)
and raw_anchor(previous_page.raw[0])
== expected_raw[1:]
and raw_anchor(previous_page.raw[0]) == expected_raw[1:]
)
else:
valid_anchor = (
bool(raw_page.raw)
and raw_anchor(raw_page.raw[0]) == expected_raw
bool(raw_page.raw) and raw_anchor(raw_page.raw[0]) == expected_raw
)
if expected_raw and not valid_anchor:
raise HTTPException(status_code=409, detail="raw_cursor_stale")
@@ -4668,9 +4848,7 @@ def create_app(
next_cursor: str | None = None
for record_index, record in enumerate(raw_page.raw):
turns = raw_display_turns(record)
ordered_turns = (
tuple(reversed(turns)) if order == "desc" else turns
)
ordered_turns = tuple(reversed(turns)) if order == "desc" else turns
start = turn_offset if record_index == 0 else 0
for index in range(start, len(ordered_turns)):
selected_turns.append((record, ordered_turns[index]))
@@ -4705,14 +4883,9 @@ def create_app(
break
if len(selected_turns) >= limit:
break
if (
next_cursor is None
and raw_offset + len(raw_page.raw) < raw_page.total
):
if next_cursor is None and raw_offset + len(raw_page.raw) < raw_page.total:
if not raw_page.raw:
raise HTTPException(
status_code=409, detail="raw_cursor_stale"
)
raise HTTPException(status_code=409, detail="raw_cursor_stale")
next_raw = raw_offset + len(raw_page.raw)
previous_anchor = raw_anchor(raw_page.raw[-1])
next_cursor = (
@@ -4721,10 +4894,7 @@ def create_app(
)
return SdkRawPage(
user_id=user_id,
items=tuple(
SdkRawItem.of(record, turn)
for record, turn in selected_turns
),
items=tuple(SdkRawItem.of(record, turn) for record, turn in selected_turns),
# Cursor consumers do not use a precise total. This lower
# bound preserves the legacy numeric field without forcing
# a full scan merely to count variable-size turn batches.
@@ -4898,6 +5068,114 @@ def create_app(
time_filtered=time_filtered,
)
@app.get(
"/api/v2/raw/graph",
response_model=SdkRawGraphPage,
dependencies=strict,
)
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),
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."""
chosen_statuses = (
_library_values(
statuses or [],
MEMORY_STATUSES,
"memory_status_unknown",
)
or raw_list_filters()["statuses"]
)
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:
raise HTTPException(
status_code=422,
detail="raw_graph_snapshot_required",
)
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)),
)
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)
)
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]
return SdkRawGraphPage(
user_id=user_id,
items=selected,
total=len(items),
offset=offset,
limit=limit,
has_more=offset + len(selected) < len(items),
snapshot_id=snapshot_id,
)
@app.post(
"/api/v2/memories/ask",
response_model=SdkMemoryAnswer,
@@ -743,6 +743,44 @@ 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 = await client.get("/api/v2/raw/graph", params={"limit": 2})
assert graph.status_code == 200, graph.text
graph_body = graph.json()
assert graph_body["total"] == 3
assert graph_body["has_more"] is True
assert [item["label"] for item in graph_body["items"]] == [
"[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": 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]: 新消息",
@@ -754,6 +792,21 @@ 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]: 新消息"
continued = await client.get(
"/api/v2/raw",
params={
+82
View File
@@ -62,6 +62,30 @@ 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();
@@ -491,6 +515,7 @@ function Host({
onViewHistory,
onConfigureModel = () => {},
libraryView,
raw = false,
}: {
simplified?: boolean;
date?: string | null;
@@ -509,6 +534,7 @@ function Host({
onViewHistory?(): void;
onConfigureModel?(): void;
libraryView?: Parameters<typeof MemoryMap>[0]["libraryView"];
raw?: boolean;
} = {}) {
const [detail, setDetail] = useState<MemoryDetail | null>(null);
const [detailLoad, setDetailLoad] = useState<{
@@ -518,6 +544,7 @@ function Host({
return (
<MemoryMap
simplified={simplified}
contentKind={raw ? "raw" : "memory"}
libraryView={libraryView}
hidden={hidden}
overlayBlocked={overlayBlocked}
@@ -587,6 +614,61 @@ function Host({
/>
);
}
it("windows raw rows and automatically continues near the bottom", () => {
const items = Array.from({ length: 150 }, (_, index) => ({
...memories[0],
memory_id: `raw-${index}`,
content: `原始会话 ${index}`,
}));
const onMore = vi.fn();
const sourceIds = Object.fromEntries(
items.map((item) => [item.memory_id, ["cursor"]]),
);
render(
<Host
simplified
raw
items={items}
libraryView={{
query: "",
source: "",
page: {
items,
total: 1_600,
offset: 0,
limit: 50,
source_ids_by_memory: sourceIds,
},
loading: false,
failed: false,
stale: false,
cursorPaging: true,
hasMore: true,
onQuery: vi.fn(),
onSource: vi.fn(),
resetPage: vi.fn(),
onPage: vi.fn(),
onMore,
onRetry: vi.fn(),
}}
/>,
);
const list = screen.getByRole("list", { name: "原始对话" });
Object.defineProperties(list, {
clientHeight: { configurable: true, value: 500 },
scrollHeight: { configurable: true, value: 17_400 },
scrollTop: { configurable: true, writable: true, value: 16_500 },
});
fireEvent.scroll(list);
expect(onMore).toHaveBeenCalledTimes(1);
expect(document.querySelectorAll("[data-memory-id]").length).toBeLessThan(30);
expect(screen.getByRole("button", { name: "加载更多" }).classList).toContain(
"map-load-more",
);
});
it("does not present an unverified empty page as zero memories", () => {
const page = {
items: [],
+140 -22
View File
@@ -16,6 +16,7 @@ import type {
import type { GuiLocale } from "@one-memory/i18n";
import {
ArrowUp,
ChevronDown,
ChevronLeft,
ChevronRight,
FileText,
@@ -51,6 +52,7 @@ 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";
@@ -106,6 +108,8 @@ 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;
@@ -179,6 +183,8 @@ const SCENE_LABELS: Record<SdkMemoryScene, string> = {
working: "工作",
coding: "编码",
};
const RAW_ROW_HEIGHT = 116;
const RAW_LIST_OVERSCAN = 6;
export function MemoryMap(props: Props) {
const { locale, text, count, plain } = useMemoryLanguage();
@@ -257,6 +263,10 @@ export function MemoryMap(props: Props) {
const pane = useRef<HTMLElement>(null);
const memoryList = useRef<HTMLUListElement>(null);
const inspectorBody = useRef<HTMLDivElement>(null);
const [rawListWindow, setRawListWindow] = useState({
start: 0,
end: 20,
});
const openedDefault = useRef(false);
const scrollSelection = useRef(false);
const controlledSource = library
@@ -342,6 +352,33 @@ export function MemoryMap(props: Props) {
),
[active, group, search, serverPaged, axis, groupKey],
);
const rawWindowed = raw && !!library?.cursorPaging && filtered.length > 100;
const visibleMemories = rawWindowed
? filtered.slice(rawListWindow.start, rawListWindow.end)
: filtered;
const rawTopSpace = rawWindowed ? rawListWindow.start * RAW_ROW_HEIGHT : 0;
const rawBottomSpace = rawWindowed
? Math.max(0, filtered.length - rawListWindow.end) * RAW_ROW_HEIGHT
: 0;
const updateRawListWindow = useCallback(
(element: HTMLUListElement) => {
if (!rawWindowed) return;
const first = Math.max(
0,
Math.floor(element.scrollTop / RAW_ROW_HEIGHT) - RAW_LIST_OVERSCAN,
);
const visible = Math.ceil(element.clientHeight / RAW_ROW_HEIGHT);
setRawListWindow({
start: first,
end: Math.min(filtered.length, first + visible + RAW_LIST_OVERSCAN * 2),
});
},
[filtered.length, rawWindowed],
);
useLayoutEffect(() => {
if (rawWindowed && memoryList.current)
updateRawListWindow(memoryList.current);
}, [rawWindowed, updateRawListWindow]);
const projection = useMemo(() => {
// Until the new scope's graph arrives (or after it failed), drawing the
// previous scope's graph would show the wrong source.
@@ -357,7 +394,7 @@ export function MemoryMap(props: Props) {
// left-hand list window. Projecting the graph back down to the current
// 20-row page made "All applications" look like whichever source happened
// to dominate that page. Keyword search is intentionally page-scoped.
if (serverPaged && !library?.query) {
if (serverPaged && (!library?.query || raw)) {
return (
baseGraph ??
projectMap(
@@ -409,6 +446,7 @@ export function MemoryMap(props: Props) {
search,
sources,
serverPaged,
raw,
library?.query,
library?.graphLoading,
library?.graphFailed,
@@ -1026,11 +1064,42 @@ export function MemoryMap(props: Props) {
)}
<ul
ref={memoryList}
className="map-memory-list"
className={`map-memory-list${raw ? " raw" : ""}`}
aria-label={raw ? t("memory:原始对话") : text("地图记忆列表")}
id="memory-pane-memories"
onScroll={(event) => {
const element = event.currentTarget;
updateRawListWindow(element);
if (
library?.cursorPaging &&
library.hasMore &&
!pageTransitioning &&
element.scrollHeight - element.scrollTop - element.clientHeight <
element.clientHeight
)
library.onMore?.();
}}
onWheel={(event) => {
const element = event.currentTarget;
if (
event.deltaY > 0 &&
library?.cursorPaging &&
library.hasMore &&
!pageTransitioning &&
element.scrollHeight - element.scrollTop - element.clientHeight <
8
)
library.onMore?.();
}}
>
{filtered.map((m) => (
{rawTopSpace ? (
<li
className="map-memory-spacer"
style={{ height: rawTopSpace }}
aria-hidden="true"
/>
) : null}
{visibleMemories.map((m) => (
<li key={m.memory_id} data-memory-id={m.memory_id}>
<button
type="button"
@@ -1086,6 +1155,13 @@ export function MemoryMap(props: Props) {
</button>
</li>
))}
{rawBottomSpace ? (
<li
className="map-memory-spacer"
style={{ height: rawBottomSpace }}
aria-hidden="true"
/>
) : null}
</ul>
{library?.failed && (
<div role="alert">
@@ -1136,12 +1212,24 @@ export function MemoryMap(props: Props) {
>
{library.cursorPaging ? (
library.hasMore ? (
<R6Button
<button
type="button"
className="map-load-more"
disabled={pageTransitioning}
aria-label={
pageTransitioning ? text("正在读取…") : text("加载更多")
}
title={
pageTransitioning ? text("正在读取…") : text("加载更多")
}
onClick={() => library.onMore?.()}
>
{pageTransitioning ? text("正在读取…") : text("加载更多")}
</R6Button>
<ChevronDown
size={18}
aria-hidden="true"
className={pageTransitioning ? "loading" : undefined}
/>
</button>
) : (
<span>{text("已加载全部")}</span>
)
@@ -1239,15 +1327,34 @@ export function MemoryMap(props: Props) {
: text("记忆关联")
: label}
</h2>
{props.simplified && (!raw || !projection.nodes.length) && (
<p className="selected-graph-subtitle">
{projection.nodes.length
? text("来源归属")
: raw
? t("sdk.raw.empty")
: text("可在沉淀记忆后查看视图")}
{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) && (
<p className="selected-graph-subtitle">
{projection.nodes.length
? text("来源归属")
: raw
? t("sdk.raw.empty")
: text("可在沉淀记忆后查看视图")}
</p>
)}
</div>
{compact && (activeId || selectedSource) && !inspectorOpen && (
<button
@@ -1267,14 +1374,25 @@ export function MemoryMap(props: Props) {
</header>
<div className="map-canvas" data-tour-id="memory-map-canvas">
{projection.nodes.length && (!props.simplified || !hidden) ? (
<MemoryGraph3D
graph={projection}
selectedNodeId={
visibleGroupNode?.node_id ??
(activeId ? `memory:${activeId}` : null)
}
onSelectNode={pickNode}
/>
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}
/>
)
) : (
<div className="map-empty-stage">
<Network size={32} strokeWidth={1} />
+130 -4
View File
@@ -77,6 +77,7 @@ const { reads } = vi.hoisted(() => ({
processing: vi.fn(),
profiles: vi.fn(),
raw: vi.fn(),
rawGraph: vi.fn(),
rawSearch: vi.fn(),
},
}));
@@ -87,6 +88,7 @@ vi.mock("./api", async (importOriginal) => ({
getMemoryProcessing: reads.processing,
getModelProfiles: reads.profiles,
getSdkRaw: reads.raw,
getSdkRawGraph: reads.rawGraph,
searchSdkRaw: reads.rawSearch,
}));
vi.mock("./GraphWidthPrompt", () => ({ GraphWidthPrompt: () => null }));
@@ -119,6 +121,9 @@ vi.mock("./MemoryAddComposer", () => ({
vi.mock("./MemoryGraph3D", () => ({
MemoryGraph3D: () => <div data-testid="graph-owner" />,
}));
vi.mock("./RawConversationGraph3D", () => ({
RawConversationGraph3D: () => <div data-testid="graph-owner" />,
}));
afterEach(() => {
cleanup();
vi.useRealTimers();
@@ -208,6 +213,25 @@ function setup() {
limit: 100,
offset: 0,
});
reads.rawGraph.mockResolvedValue({
user_id: "local",
items: [
{
turn_id: "raw-1",
raw_id: "raw-1",
label: "原始对话内容",
source: "cursor",
session_id: "session-1",
memory_at: null,
gmt_created: 1_761_000_000,
},
],
total: 1,
limit: 500,
offset: 0,
has_more: false,
snapshot_id: "snapshot-1",
});
reads.rawSearch.mockResolvedValue([]);
}
@@ -311,7 +335,7 @@ it("shows raw conversations instead of advanced memory in lite", async () => {
expect.objectContaining({
source: undefined,
offset: 0,
limit: 20,
limit: 50,
signal: expect.any(AbortSignal),
}),
);
@@ -321,6 +345,96 @@ 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 () => {
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",
};
});
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();
});
it("restarts the complete graph when its snapshot expires between pages", 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",
});
render(<MemorySpace simplified />);
await waitFor(() => expect(reads.rawGraph).toHaveBeenCalledTimes(3));
expect(reads.rawGraph.mock.calls[2][0]).toEqual(
expect.objectContaining({ offset: 0, snapshotId: undefined }),
);
expect(screen.getByText("1 / 1")).toBeTruthy();
});
it("pages and filters the raw conversation data plane in lite", async () => {
setup();
reads.processing.mockResolvedValue(modeProcessing("lite"));
@@ -368,15 +482,18 @@ it("pages and filters the raw conversation data plane in lite", async () => {
);
expect(await screen.findAllByText("原始会话 all-first")).toHaveLength(2);
await waitFor(() => expect(reads.rawGraph).toHaveBeenCalledTimes(1));
const graphCalls = reads.rawGraph.mock.calls.length;
await user.click(screen.getByRole("button", { name: "加载更多" }));
expect(await screen.findAllByText("原始会话 all-more")).toHaveLength(1);
expect(screen.getAllByText("原始会话 all-first")).toHaveLength(2);
expect(reads.rawGraph).toHaveBeenCalledTimes(graphCalls);
expect(reads.raw).toHaveBeenLastCalledWith(
expect.objectContaining({
source: undefined,
cursor: "turn:1:0:1",
paging: "cursor",
limit: 20,
limit: 50,
}),
);
@@ -388,7 +505,7 @@ it("pages and filters the raw conversation data plane in lite", async () => {
source: "cursor",
cursor: null,
paging: "cursor",
limit: 20,
limit: 50,
}),
);
@@ -401,7 +518,7 @@ it("pages and filters the raw conversation data plane in lite", async () => {
expect.objectContaining({
source: "one-memory-manual",
offset: 0,
limit: 20,
limit: 50,
paging: "cursor",
cursor: null,
}),
@@ -620,6 +737,15 @@ it("opens a search hit with the complete role transcript", async () => {
truncated: true,
},
]);
reads.rawGraph.mockResolvedValue({
user_id: "local",
items: [],
total: 1_600,
limit: 500,
offset: 0,
has_more: false,
snapshot_id: "search-total",
});
const user = userEvent.setup();
render(<MemorySpace simplified />);
@@ -289,7 +289,7 @@ it("keeps rows, sources, count and graph on the one filtered page", async () =>
expect(await screen.findByText(/566/)).toBeTruthy();
});
it("hides the previous source graph until the all-applications graph settles", async () => {
it("loads each source graph independently from list pagination and search", async () => {
sdkAuthority();
render(<MemorySpace simplified active />);
await waitFor(() =>
@@ -415,17 +415,13 @@ it("hides the previous source graph until the all-applications graph settles", a
);
const graphCalls = api.sdkGraph.mock.calls.length;
fireEvent.click(screen.getByText("search deploy"));
await waitFor(() =>
expect(screen.getByTestId("map-graph-loading").textContent).toBe("true"),
);
expect(screen.getByTestId("map-nodes").textContent).toBe("none");
expect(screen.getByTestId("map-graph-loading").textContent).toBe("false");
expect(screen.getByTestId("map-nodes").textContent).toBe("4");
expect(screen.getByTestId("map-memories").textContent).toBe("");
expect(api.sdkGraph).toHaveBeenCalledTimes(graphCalls);
await act(async () => releaseSearch(searchPage));
await waitFor(() =>
expect(screen.getByTestId("map-graph-loading").textContent).toBe("false"),
);
expect(screen.getByTestId("map-graph-loading").textContent).toBe("false");
});
it("opens an SDK memory in place instead of switching away from the map", async () => {
+184 -69
View File
@@ -9,6 +9,7 @@ import type {
MemoryLibraryPage,
MemoryProcessingSummary,
SdkAskMemory,
SdkRawGraphItem,
SourceResponse,
} from "@one-memory/contracts";
import { isMessageKey } from "@one-memory/i18n";
@@ -38,6 +39,7 @@ import {
getSdkMemoryGraph,
getSdkMemoryLibrary,
getSdkRaw,
getSdkRawGraph,
manualExtract,
searchSdkRaw,
setExtractionWindow,
@@ -60,9 +62,12 @@ import {
import {
type RawMapRecord,
rawDetail,
rawGraphProjection,
rawGraphRecord,
rawLibraryPage,
rawRecordId,
rawSearchPage,
SDK_RAW_PAGE_SIZE,
} from "./sdk-raw-map";
import { sourceNameOr, UNATTRIBUTED_SOURCE_ID } from "./source-label";
import { traceUiAction } from "./ui-trace";
@@ -165,6 +170,8 @@ export function MemorySpace({
source.grant_revision,
]),
);
const sourcesRef = useRef(sources);
sourcesRef.current = sources;
const management = simplified && displayedMode === "library";
useEffect(() => {
viewActive.current = active;
@@ -188,6 +195,11 @@ 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), []);
@@ -239,18 +251,21 @@ export function MemorySpace({
sdkLibrary.settledQuery.trim() === requestedQuery &&
sdkLibrary.page?.offset === sdkLibraryOffset;
const sdkPage = sdkPageSettled ? sdkLibrary.page : null;
const sdkRequestedScope = JSON.stringify([
const sdkGraphRequestedScope = JSON.stringify([
pipelineMode,
sdkLibraryQuery.trim(),
sdkLibrarySource,
]);
const sdkGraphSettled = sdkGraph?.scope === sdkRequestedScope;
const sdkGraphSettled = sdkGraph?.scope === sdkGraphRequestedScope;
const visibleSdkGraph =
sdkGraph?.scope === sdkRequestedScope && sdkGraph.status === "ready"
sdkGraph?.scope === sdkGraphRequestedScope && sdkGraph.status === "ready"
? sdkGraph.value
: null;
const sdkGraphFailed =
sdkGraph?.scope === sdkRequestedScope && sdkGraph.status === "failed";
sdkGraph?.scope === sdkGraphRequestedScope && sdkGraph.status === "failed";
const visibleGraphProgress =
sdkGraphProgress?.scope === sdkGraphRequestedScope
? sdkGraphProgress
: 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.
@@ -295,61 +310,37 @@ export function MemorySpace({
rawRecords.current = next;
rawRecordsScope.current = windowKey;
};
const [page, graph] = lite
? await Promise.all([
query
? searchSdkRaw(query, {
source,
signal: request.signal,
}).then((items) => {
rememberRaw(items);
return rawSearchPage(items);
})
: getSdkRaw({
source,
offset: 0,
limit: SDK_MEMORY_PAGE_SIZE,
paging: "cursor",
cursor: sdkRawCursor,
signal: request.signal,
}).then((rawPage) => {
rememberRaw(rawPage.items);
return rawLibraryPage(rawPage);
}),
Promise.resolve({
status: "ready" as const,
value: null,
}),
])
: await Promise.all([
getSdkMemoryLibrary(
{
statuses: ["active"],
source,
query,
offset: sdkLibraryOffset,
limit: SDK_MEMORY_PAGE_SIZE,
windowId: query ? sdkSearchWindow.current.id : null,
},
request.signal,
).then(sdkLibraryPage),
query
? Promise.resolve({
status: "ready" as const,
value: null,
})
: getSdkMemoryGraph(
{
source,
offset: 0,
limit: 300,
},
request.signal,
).then(
(value) => ({ status: "ready" as const, value }),
() => ({ status: "failed" as const, value: null }),
),
]);
const page = lite
? query
? await searchSdkRaw(query, {
source,
signal: request.signal,
}).then((items) => {
rememberRaw(items);
return rawSearchPage(items);
})
: await getSdkRaw({
source,
offset: 0,
limit: SDK_RAW_PAGE_SIZE,
paging: "cursor",
cursor: sdkRawCursor,
signal: request.signal,
}).then((rawPage) => {
rememberRaw(rawPage.items);
return rawLibraryPage(rawPage);
})
: await getSdkMemoryLibrary(
{
statuses: ["active"],
source,
query,
offset: sdkLibraryOffset,
limit: SDK_MEMORY_PAGE_SIZE,
windowId: query ? sdkSearchWindow.current.id : null,
},
request.signal,
).then(sdkLibraryPage);
if (cancelled) return;
if (!lite && sdkSearchWindow.current.key === windowKey)
sdkSearchWindow.current = {
@@ -405,7 +396,6 @@ export function MemorySpace({
settledMode: pipelineMode,
};
});
setSdkGraph({ scope: windowKey, ...graph });
} catch (reason) {
if (cancelled || request.signal.aborted) return;
if (
@@ -443,6 +433,114 @@ export function MemorySpace({
lite,
pipelineMode,
]);
const rawGraphRecords = useRef(new Map<string, RawMapRecord>());
useEffect(() => {
if (!sdkMapVisible) return;
void sdkMapRevision;
void sourceKey;
const scope = sdkGraphRequestedScope;
const source = sdkLibrarySource || undefined;
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;
try {
if (!lite) {
const value = await getSdkMemoryGraph(
{ source, offset: 0, limit: 300 },
request.signal,
);
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;
}
} catch {
if (cancelled || request.signal.aborted) return;
setSdkGraph({ scope, status: "failed", value: null });
} finally {
reading = false;
}
};
void readGraph();
const timer = setInterval(() => void readGraph(), 30_000);
return () => {
cancelled = true;
clearInterval(timer);
request.abort();
};
}, [
lite,
sdkGraphRequestedScope,
sdkLibrarySource,
sdkMapRevision,
sdkMapVisible,
sourceKey,
]);
const displayedSdkPage =
lite &&
!sdkLibraryQuery.trim() &&
sdkPage &&
visibleGraphProgress?.total != null
? {
...sdkPage,
total: visibleGraphProgress.total,
total_exact: true,
}
: sdkPage;
const sdkFilterLabel = [
sdkLibrary.settledSource
? `${t("memory:来源")} ${
@@ -572,10 +670,13 @@ export function MemorySpace({
}, [active, onArrivalChange]);
const requestIds = useRef(new Map<string, string>());
const detailEpoch = useRef(0);
const rawDetailController = useRef<AbortController | null>(null);
const answerEpoch = useRef(0);
const answerMemories = useRef<AskMemory[]>([]);
const closeDetail = useCallback(() => {
detailEpoch.current++;
rawDetailController.current?.abort();
rawDetailController.current = null;
setDetail(null);
setSelectedRaw(null);
setDetailLoad(null);
@@ -650,6 +751,8 @@ export function MemorySpace({
cancelled = true;
stopProcessing();
detailEpoch.current++;
rawDetailController.current?.abort();
rawDetailController.current = null;
answerEpoch.current++;
deleteAnswer.current?.(false);
deleteAnswer.current = null;
@@ -761,11 +864,15 @@ export function MemorySpace({
async function open(memoryId: string) {
if (simplified && (!viewActive.current || stale.current)) return;
const epoch = ++detailEpoch.current;
rawDetailController.current?.abort();
rawDetailController.current = null;
setError(null);
setDetail(null);
setSelectedRaw(null);
if (lite) {
let record = rawRecords.current.get(memoryId);
let record =
rawRecords.current.get(memoryId) ??
rawGraphRecords.current.get(memoryId);
if (!record) {
setDetailLoad({
memoryId,
@@ -776,6 +883,8 @@ export function MemorySpace({
}
if ("kind" in record) {
setDetailLoad({ memoryId, status: "loading" });
const request = new AbortController();
rawDetailController.current = request;
try {
let offset = 0;
while (true) {
@@ -784,6 +893,7 @@ export function MemorySpace({
sessionId: record.session_id || undefined,
offset,
limit: 500,
signal: request.signal,
});
const full = page.items.find(
(item) => rawRecordId(item) === memoryId,
@@ -798,15 +908,18 @@ export function MemorySpace({
throw new Error("raw_not_found");
}
} catch {
if (epoch === detailEpoch.current)
if (epoch === detailEpoch.current) {
rawDetailController.current = null;
setDetailLoad({
memoryId,
status: "failed",
error: t("sdk.raw.loadFailed"),
});
}
return;
}
if (epoch !== detailEpoch.current) return;
rawDetailController.current = null;
}
setSelectedRaw(record);
setDetail(rawDetail(record));
@@ -961,12 +1074,12 @@ export function MemorySpace({
? pipelineMode === null
? t("memory:记忆暂时无法读取")
: lite
? sdkPage
? sdkPage.total_exact === false
? displayedSdkPage
? displayedSdkPage.total_exact === false
? t("memory:原始对话")
: sdkLibrary.settledQuery.trim()
? tn("sdk.raw.found", sdkPage.total)
: tn("sdk.raw.total", sdkPage.total)
? tn("sdk.raw.found", displayedSdkPage.total)
: tn("sdk.raw.total", displayedSdkPage.total)
: sdkLibrary.failed
? null
: t("memory:正在读取…")
@@ -1257,11 +1370,13 @@ export function MemorySpace({
? {
query: sdkLibraryQuery,
source: sdkLibrarySource,
page: sdkPage,
page: displayedSdkPage,
loading: sdkLibrary.loading,
failed: sdkLibrary.failed,
graphLoading: !sdkGraphSettled,
graphFailed: sdkGraphFailed,
graphLoaded: visibleGraphProgress?.loaded,
graphTotal: visibleGraphProgress?.total ?? undefined,
stale: false,
settledSource: sdkLibrary.settledSource,
settledQuery: sdkLibrary.settledQuery,
@@ -1289,7 +1404,7 @@ export function MemorySpace({
displayedMode !== "map" ||
(simplified && pipelineMode === null)
}
memories={sdkPage?.items ?? []}
memories={displayedSdkPage?.items ?? []}
graph={visibleSdkGraph}
sources={sources}
processing={processing}
@@ -0,0 +1,80 @@
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()]),
);
});
});
@@ -0,0 +1,421 @@
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;
}
+34 -1
View File
@@ -14,7 +14,7 @@ vi.mock("axios", () => ({
},
}));
import { getSdkRaw, searchSdkRaw } from "./api";
import { getSdkRaw, getSdkRawGraph, searchSdkRaw } from "./api";
beforeEach(() => vi.clearAllMocks());
@@ -59,3 +59,36 @@ it("keeps the application filter when searching raw conversations", async () =>
{ signal, timeout: 10_000 },
);
});
it("pages the independent lightweight raw graph", async () => {
get.mockResolvedValue({
data: {
user_id: "local",
items: [],
total: 1_600,
offset: 500,
limit: 500,
has_more: true,
snapshot_id: "snapshot-1",
},
});
const signal = new AbortController().signal;
await getSdkRawGraph({
source: "cursor",
offset: 500,
limit: 500,
snapshotId: "snapshot-1",
signal,
});
expect(get).toHaveBeenCalledWith("/api/v2/raw/graph", {
params: {
source: "cursor",
offset: 500,
limit: 500,
snapshot_id: "snapshot-1",
},
signal,
});
});
+21
View File
@@ -45,6 +45,7 @@ import {
type SdkMemoryStatus,
type SdkMemoryVariant,
type SdkRawContextItem,
type SdkRawGraphPage,
type SdkRawPage,
type SourceDirectoryCandidate,
type SourceImportReceipt,
@@ -1015,6 +1016,26 @@ export async function getSdkRaw({
return response.data;
}
export async function getSdkRawGraph({
source,
offset = 0,
limit = 500,
snapshotId,
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 },
signal,
});
return response.data;
}
export async function searchSdkRaw(
query: string,
options: { source?: string; signal?: AbortSignal } = {},
+48
View File
@@ -791,6 +791,44 @@ button.rail-button.active::before {
text-overflow: ellipsis;
white-space: nowrap;
}
.map-pagination:has(.map-load-more) {
justify-content: center;
padding-top: 8px;
border-top: 0;
}
.map-load-more {
display: grid;
width: 32px;
height: 32px;
padding: 0;
place-items: center;
border: 1px solid var(--bd);
border-radius: 50%;
color: var(--tx3);
background: var(--surface);
transition:
color var(--motion-fast) var(--motion-enter),
background-color var(--motion-fast) var(--motion-enter),
transform var(--motion-fast) var(--motion-enter);
}
.map-load-more:hover:not(:disabled) {
color: var(--ac-strong);
background: var(--ac-bg);
transform: translateY(1px);
}
.map-load-more:focus-visible {
outline: 2px solid var(--ac);
outline-offset: 2px;
}
.map-load-more svg.loading {
animation: map-load-more-pulse 800ms ease-in-out infinite alternate;
}
@keyframes map-load-more-pulse {
to {
transform: translateY(4px);
opacity: 0.45;
}
}
.map-list-status {
flex: none;
min-height: 28px;
@@ -829,6 +867,16 @@ button.rail-button.active::before {
.map-memory-list > li {
margin-bottom: 4px;
}
.map-memory-list.raw > li:not(.map-memory-spacer) {
height: 112px;
}
.map-memory-list.raw .map-memory-item {
height: 112px;
}
.map-memory-list > .map-memory-spacer {
margin: 0;
pointer-events: none;
}
.map-memory-item {
display: grid;
grid-template-columns: 5px minmax(0, 1fr);
+76
View File
@@ -1,13 +1,17 @@
import type {
GovernedMemory,
MemoryDetail,
MemoryGraphResponse,
MemoryLibraryPage,
SdkRawContextItem,
SdkRawGraphItem,
SdkRawPage,
SdkRawRecord,
SourceResponse,
} from "@one-memory/contracts";
export type RawMapRecord = SdkRawRecord | SdkRawContextItem;
export const SDK_RAW_PAGE_SIZE = 50;
export function rawRecordId(record: RawMapRecord): string {
return record.turn_id || record.raw_id;
@@ -124,6 +128,78 @@ export function rawSearchPage(items: SdkRawContextItem[]): MemoryLibraryPage {
return pageFor(items, items.length, 0, Math.max(items.length, 1));
}
export function rawGraphRecord(item: SdkRawGraphItem): SdkRawContextItem {
return {
kind: "raw",
raw_id: item.raw_id,
turn_id: item.turn_id,
content: item.label,
source: item.source,
session_id: item.session_id,
memory_at:
typeof item.memory_at === "number"
? new Date(item.memory_at * 1_000).toISOString()
: null,
score: 0,
truncated: true,
};
}
export function rawGraphProjection(
items: SdkRawGraphItem[],
total: number,
sources: SourceResponse[],
): MemoryGraphResponse {
const labels = new Map(
sources.map((source) => [source.source_id, source.display_name]),
);
const nodes = [];
const edges = [];
const sourceIds = new Set<string>();
for (const item of items) {
const sourceId = item.source || "default";
if (!sourceIds.has(sourceId)) {
sourceIds.add(sourceId);
nodes.push({
node_id: `source:${sourceId}`,
node_type: "source" as const,
memory_id: null,
source_id: sourceId,
label: labels.get(sourceId) ?? sourceId,
kind: null,
status: null,
confidence: null,
});
}
const nodeId = `memory:${item.turn_id}`;
nodes.push({
node_id: nodeId,
node_type: "memory" as const,
memory_id: item.turn_id,
source_id: null,
label: item.label,
kind: "chat",
status: "confirmed",
confidence: null,
});
edges.push({
edge_id: `source:${sourceId}->${nodeId}`,
source_node_id: `source:${sourceId}`,
target_node_id: nodeId,
relation: "evidence_source",
origin: "provenance" as const,
weight: null,
});
}
return {
total_memories: total,
projected_memories: items.length,
hy_graph_available: false,
nodes,
edges,
};
}
export function rawDetail(record: RawMapRecord): MemoryDetail {
const memory = rawMapMemory(record);
return {
+21
View File
@@ -720,6 +720,27 @@ export interface SdkRawPage {
time_filtered?: boolean;
}
/** Lightweight complete-map projection. Raw text remains SDK-owned. */
export interface SdkRawGraphItem {
turn_id: string;
raw_id: string;
label: string;
source: string;
session_id: string;
memory_at: number | null;
gmt_created: number | null;
}
export interface SdkRawGraphPage {
user_id: string;
items: SdkRawGraphItem[];
total: number;
limit: number;
offset: number;
has_more: boolean;
snapshot_id: string;
}
/** `GET /api/v2/memory-library/layers`: memories per SDK layer. */
export interface SdkLayerCounts {
/** The user whose memories were counted. */