Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac958571be | ||
|
|
c73bbb9eac |
No files matched your search
@@ -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(
|
||||
|
||||
@@ -838,8 +838,10 @@ def test_raw_lists_active_and_shadow_turns_with_roles_and_hashes(tmp_path, monke
|
||||
assert cursor_page["has_more"] is True
|
||||
assert cursor_turns == ["我喜欢绿茶", "我叫费天翔", "你好"]
|
||||
assert len(sdk.all("list_memories")) == cursor_calls + 3
|
||||
graph_calls = len(sdk.all("list_memories"))
|
||||
graph = await client.get("/api/v2/raw/graph", params={"limit": 2})
|
||||
assert graph.status_code == 200, graph.text
|
||||
assert len(sdk.all("list_memories")) == graph_calls + 1
|
||||
graph_body = graph.json()
|
||||
assert graph_body["total"] == 3
|
||||
assert graph_body["has_more"] is True
|
||||
@@ -847,35 +849,13 @@ def test_raw_lists_active_and_shadow_turns_with_roles_and_hashes(tmp_path, monke
|
||||
"[user]: 我喜欢绿茶 [assistant]: 也记住了。",
|
||||
"[user]: 我叫费天翔 [assistant]: 好的,记住了。",
|
||||
]
|
||||
graph_page_2 = (
|
||||
await client.get(
|
||||
"/api/v2/raw/graph",
|
||||
params={
|
||||
"limit": 2,
|
||||
"offset": 2,
|
||||
"snapshot_id": graph_body["snapshot_id"],
|
||||
},
|
||||
)
|
||||
).json()
|
||||
assert graph_page_2["total"] == 3
|
||||
assert graph_page_2["has_more"] is False
|
||||
assert graph_page_2["items"][0]["turn_id"]
|
||||
assert (await client.get("/api/v2/raw/graph", params={"limit": 301})).status_code == 422
|
||||
assert (
|
||||
await client.get(
|
||||
"/api/v2/raw/graph",
|
||||
params={"limit": 2, "offset": 2},
|
||||
)
|
||||
).status_code == 422
|
||||
assert (
|
||||
await client.get(
|
||||
"/api/v2/raw/graph",
|
||||
params={
|
||||
"limit": 2,
|
||||
"offset": 2,
|
||||
"snapshot_id": "missing",
|
||||
},
|
||||
)
|
||||
).status_code == 409
|
||||
inserted = {
|
||||
"memory_id": "raw-new",
|
||||
"content": "[user]: 新消息",
|
||||
@@ -887,18 +867,6 @@ def test_raw_lists_active_and_shadow_turns_with_roles_and_hashes(tmp_path, monke
|
||||
}
|
||||
RAW_ROWS["local"].insert(0, inserted)
|
||||
try:
|
||||
stable_graph_page = (
|
||||
await client.get(
|
||||
"/api/v2/raw/graph",
|
||||
params={
|
||||
"limit": 2,
|
||||
"offset": 2,
|
||||
"snapshot_id": graph_body["snapshot_id"],
|
||||
},
|
||||
)
|
||||
).json()
|
||||
assert stable_graph_page["total"] == 3
|
||||
assert stable_graph_page["items"][0]["label"] == "[user]: 你好"
|
||||
updated_graph = (await client.get("/api/v2/raw/graph")).json()
|
||||
assert updated_graph["total"] == 4
|
||||
assert updated_graph["items"][0]["label"] == "[user]: 新消息"
|
||||
|
||||
@@ -62,30 +62,6 @@ vi.mock("./MemoryGraph3D", () => ({
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("./RawConversationGraph3D", () => ({
|
||||
RawConversationGraph3D: ({
|
||||
graph,
|
||||
selectedNodeId,
|
||||
onSelectNode,
|
||||
}: {
|
||||
graph: MemoryGraphResponse;
|
||||
selectedNodeId: string | null;
|
||||
onSelectNode(node: MemoryGraphNode): void;
|
||||
}) => (
|
||||
<div data-testid="canvas" data-selected={selectedNodeId}>
|
||||
{graph.nodes.map((node) => (
|
||||
<button
|
||||
key={node.node_id}
|
||||
type="button"
|
||||
aria-label={`图中选择 ${node.label}`}
|
||||
onClick={() => onSelectNode(node)}
|
||||
>
|
||||
{node.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.resetAllMocks();
|
||||
|
||||
@@ -52,7 +52,6 @@ import {
|
||||
} from "./memory-map-model";
|
||||
import { OptionMenu } from "./OptionMenu";
|
||||
import { R6Button } from "./R6Controls";
|
||||
import { RawConversationGraph3D } from "./RawConversationGraph3D";
|
||||
import { SelectedMemoryDetail } from "./SelectedMemoryDetail";
|
||||
import { SelectedRawDetail } from "./SelectedRawDetail";
|
||||
import { sourceNameOr } from "./source-label";
|
||||
@@ -108,8 +107,6 @@ type Props = {
|
||||
/** The graph for the current source/query scope has not arrived yet. */
|
||||
graphLoading?: boolean;
|
||||
graphFailed?: boolean;
|
||||
graphLoaded?: number;
|
||||
graphTotal?: number;
|
||||
stale: boolean;
|
||||
settledSource?: string;
|
||||
settledQuery?: string;
|
||||
@@ -1327,23 +1324,6 @@ export function MemoryMap(props: Props) {
|
||||
: text("记忆关联")
|
||||
: label}
|
||||
</h2>
|
||||
{props.simplified && raw && library?.graphTotal !== undefined ? (
|
||||
<p className="selected-graph-subtitle" role="status">
|
||||
{library.graphLoading
|
||||
? `${text("正在读取记忆地图…")} ${new Intl.NumberFormat(
|
||||
locale,
|
||||
).format(
|
||||
library.graphLoaded ?? 0,
|
||||
)} / ${new Intl.NumberFormat(locale).format(
|
||||
library.graphTotal,
|
||||
)}`
|
||||
: `${new Intl.NumberFormat(locale).format(
|
||||
library.graphLoaded ?? 0,
|
||||
)} / ${new Intl.NumberFormat(locale).format(
|
||||
library.graphTotal,
|
||||
)}`}
|
||||
</p>
|
||||
) : null}
|
||||
{props.simplified &&
|
||||
(!raw || !projection.nodes.length) &&
|
||||
!(raw && library?.graphLoading) && (
|
||||
@@ -1374,25 +1354,14 @@ export function MemoryMap(props: Props) {
|
||||
</header>
|
||||
<div className="map-canvas" data-tour-id="memory-map-canvas">
|
||||
{projection.nodes.length && (!props.simplified || !hidden) ? (
|
||||
raw ? (
|
||||
<RawConversationGraph3D
|
||||
graph={projection}
|
||||
selectedNodeId={
|
||||
visibleGroupNode?.node_id ??
|
||||
(activeId ? `memory:${activeId}` : null)
|
||||
}
|
||||
onSelectNode={pickNode}
|
||||
/>
|
||||
) : (
|
||||
<MemoryGraph3D
|
||||
graph={projection}
|
||||
selectedNodeId={
|
||||
visibleGroupNode?.node_id ??
|
||||
(activeId ? `memory:${activeId}` : null)
|
||||
}
|
||||
onSelectNode={pickNode}
|
||||
/>
|
||||
)
|
||||
<MemoryGraph3D
|
||||
graph={projection}
|
||||
selectedNodeId={
|
||||
visibleGroupNode?.node_id ??
|
||||
(activeId ? `memory:${activeId}` : null)
|
||||
}
|
||||
onSelectNode={pickNode}
|
||||
/>
|
||||
) : (
|
||||
<div className="map-empty-stage">
|
||||
<Network size={32} strokeWidth={1} />
|
||||
|
||||
@@ -129,9 +129,6 @@ vi.mock("./MemoryGraph3D", () => ({
|
||||
/>
|
||||
),
|
||||
}));
|
||||
vi.mock("./RawConversationGraph3D", () => ({
|
||||
RawConversationGraph3D: () => <div data-testid="graph-owner" />,
|
||||
}));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetClient();
|
||||
@@ -323,10 +320,9 @@ function setup() {
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
limit: 500,
|
||||
limit: 300,
|
||||
offset: 0,
|
||||
has_more: false,
|
||||
snapshot_id: "snapshot-1",
|
||||
});
|
||||
reads.rawSearch.mockResolvedValue([]);
|
||||
}
|
||||
@@ -431,7 +427,7 @@ it("shows raw conversations instead of advanced memory in lite", async () => {
|
||||
expect.objectContaining({
|
||||
source: undefined,
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
limit: 20,
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
@@ -441,94 +437,92 @@ it("shows raw conversations instead of advanced memory in lite", async () => {
|
||||
expect(document.querySelector(".memory-mode-notice")).toBeNull();
|
||||
});
|
||||
|
||||
it("loads the complete raw graph independently and reports its exact total", async () => {
|
||||
it("loads one bounded raw graph page without claiming an exact turn total", async () => {
|
||||
setup();
|
||||
reads.processing.mockResolvedValue(modeProcessing("lite"));
|
||||
reads.rawGraph.mockImplementation(async ({ offset = 0 }) => {
|
||||
const total = 1_600;
|
||||
const count = Math.min(500, total - offset);
|
||||
return {
|
||||
user_id: "local",
|
||||
items: Array.from({ length: count }, (_, index) => {
|
||||
const absolute = offset + index;
|
||||
return {
|
||||
turn_id: `turn-${absolute}`,
|
||||
raw_id: `raw-${absolute}`,
|
||||
label: `原始会话 ${absolute}`,
|
||||
source: "cursor",
|
||||
session_id: `session-${absolute}`,
|
||||
memory_at: null,
|
||||
gmt_created: 1_761_000_000,
|
||||
};
|
||||
}),
|
||||
total,
|
||||
limit: 500,
|
||||
offset,
|
||||
has_more: offset + count < total,
|
||||
snapshot_id: "snapshot-1600",
|
||||
};
|
||||
reads.rawGraph.mockResolvedValue({
|
||||
user_id: "local",
|
||||
items: Array.from({ length: 300 }, (_, index) => ({
|
||||
turn_id: `turn-${index}`,
|
||||
raw_id: `raw-${index}`,
|
||||
label: `原始会话 ${index}`,
|
||||
source: "cursor",
|
||||
session_id: `session-${index}`,
|
||||
memory_at: null,
|
||||
gmt_created: 1_761_000_000,
|
||||
})),
|
||||
total: 1_600,
|
||||
limit: 300,
|
||||
offset: 0,
|
||||
has_more: true,
|
||||
});
|
||||
|
||||
render(<MemorySpace simplified />);
|
||||
|
||||
expect(await screen.findByText("1,600 条原始对话")).toBeTruthy();
|
||||
await waitFor(() => expect(reads.rawGraph).toHaveBeenCalledTimes(4));
|
||||
expect(reads.rawGraph.mock.calls.map(([request]) => request.offset)).toEqual([
|
||||
0, 500, 1_000, 1_500,
|
||||
]);
|
||||
expect(
|
||||
reads.rawGraph.mock.calls
|
||||
.slice(1)
|
||||
.every(([request]) => request.snapshotId === "snapshot-1600"),
|
||||
).toBe(true);
|
||||
expect(screen.getByText("1,600 / 1,600")).toBeTruthy();
|
||||
await waitFor(() => expect(reads.rawGraph).toHaveBeenCalledTimes(1));
|
||||
expect(reads.rawGraph).toHaveBeenCalledWith({
|
||||
source: undefined,
|
||||
offset: 0,
|
||||
limit: 300,
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(screen.getByTestId("graph-owner").dataset.graphNodes).toBe("301");
|
||||
expect(document.querySelector(".memory-loaded-count")?.textContent).not.toBe(
|
||||
"1,600 条原始对话",
|
||||
);
|
||||
expect(screen.queryByText("1,600 / 1,600")).toBeNull();
|
||||
});
|
||||
|
||||
it("restarts the complete graph when its snapshot expires between pages", async () => {
|
||||
it("keeps the last successful Lite graph when a background refresh fails", async () => {
|
||||
setup();
|
||||
reads.processing.mockResolvedValue(modeProcessing("lite"));
|
||||
const item = {
|
||||
turn_id: "turn-1",
|
||||
raw_id: "raw-1",
|
||||
label: "原始会话",
|
||||
source: "cursor",
|
||||
session_id: "session-1",
|
||||
memory_at: null,
|
||||
gmt_created: 1_761_000_000,
|
||||
};
|
||||
reads.rawGraph
|
||||
.mockResolvedValueOnce({
|
||||
user_id: "local",
|
||||
items: [item],
|
||||
total: 2,
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
has_more: true,
|
||||
snapshot_id: "expired",
|
||||
})
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error("stale"), {
|
||||
isAxiosError: true,
|
||||
response: { data: { detail: "raw_graph_snapshot_stale" } },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
user_id: "local",
|
||||
items: [item],
|
||||
total: 1,
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
has_more: false,
|
||||
snapshot_id: "fresh",
|
||||
});
|
||||
const source = {
|
||||
source_id: "cursor",
|
||||
display_name: "Cursor",
|
||||
authorized: true,
|
||||
imported_messages: 1,
|
||||
imported_conversations: 1,
|
||||
} as SourceResponse;
|
||||
const view = render(<MemorySpace simplified sources={[source]} />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("graph-owner").dataset.graphNodes).toBe("2"),
|
||||
);
|
||||
reads.rawGraph.mockRejectedValueOnce(new Error("refresh failed"));
|
||||
view.rerender(
|
||||
<MemorySpace
|
||||
simplified
|
||||
sources={[
|
||||
source,
|
||||
{
|
||||
...source,
|
||||
source_id: "codex",
|
||||
display_name: "Codex",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(reads.rawGraph).toHaveBeenCalledTimes(2));
|
||||
expect(screen.getByTestId("graph-owner").dataset.graphNodes).toBe("2");
|
||||
expect(screen.queryByText("原始对话暂时无法读取。")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not request the raw graph for a keyword search", async () => {
|
||||
setup();
|
||||
reads.processing.mockResolvedValue(modeProcessing("lite"));
|
||||
reads.rawSearch.mockResolvedValue([]);
|
||||
const user = userEvent.setup();
|
||||
render(<MemorySpace simplified />);
|
||||
|
||||
await waitFor(() => expect(reads.rawGraph).toHaveBeenCalledTimes(3));
|
||||
expect(reads.rawGraph.mock.calls[2][0]).toEqual(
|
||||
expect.objectContaining({ offset: 0, snapshotId: undefined }),
|
||||
await waitFor(() => expect(reads.rawGraph).toHaveBeenCalledTimes(1));
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: "搜索原始对话" }),
|
||||
"发布计划",
|
||||
);
|
||||
expect(screen.getByText("1 / 1")).toBeTruthy();
|
||||
await user.click(screen.getByRole("button", { name: "搜索" }));
|
||||
await waitFor(() => expect(reads.rawSearch).toHaveBeenCalledTimes(1));
|
||||
expect(reads.rawGraph).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("pages and filters the raw conversation data plane in lite", async () => {
|
||||
@@ -589,7 +583,7 @@ it("pages and filters the raw conversation data plane in lite", async () => {
|
||||
source: undefined,
|
||||
cursor: "turn:1:0:1",
|
||||
paging: "cursor",
|
||||
limit: 50,
|
||||
limit: 20,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -601,7 +595,7 @@ it("pages and filters the raw conversation data plane in lite", async () => {
|
||||
source: "cursor",
|
||||
cursor: null,
|
||||
paging: "cursor",
|
||||
limit: 50,
|
||||
limit: 20,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -614,7 +608,7 @@ it("pages and filters the raw conversation data plane in lite", async () => {
|
||||
expect.objectContaining({
|
||||
source: "one-memory-manual",
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
limit: 20,
|
||||
paging: "cursor",
|
||||
cursor: null,
|
||||
}),
|
||||
@@ -837,10 +831,9 @@ it("opens a search hit with the complete role transcript", async () => {
|
||||
user_id: "local",
|
||||
items: [],
|
||||
total: 1_600,
|
||||
limit: 500,
|
||||
limit: 300,
|
||||
offset: 0,
|
||||
has_more: false,
|
||||
snapshot_id: "search-total",
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<MemorySpace simplified />);
|
||||
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
MemoryLibraryPage,
|
||||
MemoryProcessingSummary,
|
||||
SdkAskMemory,
|
||||
SdkRawGraphItem,
|
||||
SourceResponse,
|
||||
} from "@one-memory/contracts";
|
||||
import { isMessageKey } from "@one-memory/i18n";
|
||||
@@ -201,11 +200,6 @@ export function MemorySpace({
|
||||
status: "ready" | "failed";
|
||||
value: MemoryGraphResponse | null;
|
||||
} | null>(null);
|
||||
const [sdkGraphProgress, setSdkGraphProgress] = useState<{
|
||||
scope: string;
|
||||
loaded: number;
|
||||
total: number | null;
|
||||
} | null>(null);
|
||||
// Bumping this re-reads the page and graph after an edit or a delete.
|
||||
const [sdkMapRevision, setSdkMapRevision] = useState(0);
|
||||
const bumpSdkMap = useCallback(() => setSdkMapRevision((v) => v + 1), []);
|
||||
@@ -269,16 +263,11 @@ export function MemorySpace({
|
||||
]);
|
||||
const sdkGraphSettled = sdkGraph?.scope === sdkGraphRequestedScope;
|
||||
const visibleSdkGraph =
|
||||
sdkGraph?.scope === sdkGraphRequestedScope &&
|
||||
(sdkGraph.status === "ready" || evolveRunning)
|
||||
? sdkGraph.value
|
||||
: null;
|
||||
sdkGraph?.scope === sdkGraphRequestedScope ? sdkGraph.value : null;
|
||||
const sdkGraphFailed =
|
||||
sdkGraph?.scope === sdkGraphRequestedScope && sdkGraph.status === "failed";
|
||||
const visibleGraphProgress =
|
||||
sdkGraphProgress?.scope === sdkGraphRequestedScope
|
||||
? sdkGraphProgress
|
||||
: null;
|
||||
sdkGraph?.scope === sdkGraphRequestedScope &&
|
||||
sdkGraph.status === "failed" &&
|
||||
sdkGraph.value === null;
|
||||
// A keyword search pages through one server-side result window. The id is
|
||||
// kept for the query and source it was searched with; a new query or
|
||||
// source starts without one, so the server searches afresh.
|
||||
@@ -456,8 +445,6 @@ export function MemorySpace({
|
||||
const request = new AbortController();
|
||||
let cancelled = false;
|
||||
let reading = false;
|
||||
rawGraphRecords.current.clear();
|
||||
setSdkGraphProgress({ scope, loaded: 0, total: null });
|
||||
const readGraph = async () => {
|
||||
if (reading) return;
|
||||
reading = true;
|
||||
@@ -469,58 +456,25 @@ export function MemorySpace({
|
||||
);
|
||||
if (cancelled) return;
|
||||
setSdkGraph({ scope, status: "ready", value });
|
||||
setSdkGraphProgress({
|
||||
scope,
|
||||
loaded: value.projected_memories,
|
||||
total: value.total_memories,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const items: SdkRawGraphItem[] = [];
|
||||
let offset = 0;
|
||||
let total = 0;
|
||||
let snapshotId: string | undefined;
|
||||
rawGraphRecords.current.clear();
|
||||
try {
|
||||
while (!cancelled) {
|
||||
const page = await getSdkRawGraph({
|
||||
source,
|
||||
offset,
|
||||
limit: 500,
|
||||
snapshotId,
|
||||
signal: request.signal,
|
||||
});
|
||||
if (cancelled) return;
|
||||
snapshotId = page.snapshot_id;
|
||||
total = page.total;
|
||||
items.push(...page.items);
|
||||
for (const item of page.items) {
|
||||
const record = rawGraphRecord(item);
|
||||
rawGraphRecords.current.set(rawRecordId(record), record);
|
||||
}
|
||||
setSdkGraphProgress({ scope, loaded: items.length, total });
|
||||
if (!page.has_more || page.items.length === 0) break;
|
||||
offset += page.items.length;
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
} catch (reason) {
|
||||
if (
|
||||
attempt === 0 &&
|
||||
apiErrorCode(reason) === "raw_graph_snapshot_stale"
|
||||
)
|
||||
continue;
|
||||
throw reason;
|
||||
}
|
||||
if (cancelled) return;
|
||||
setSdkGraph({
|
||||
scope,
|
||||
status: "ready",
|
||||
value: rawGraphProjection(items, total, sourcesRef.current),
|
||||
});
|
||||
break;
|
||||
const page = await getSdkRawGraph({
|
||||
source,
|
||||
offset: 0,
|
||||
limit: 300,
|
||||
signal: request.signal,
|
||||
});
|
||||
if (cancelled) return;
|
||||
rawGraphRecords.current.clear();
|
||||
for (const item of page.items) {
|
||||
const record = rawGraphRecord(item);
|
||||
rawGraphRecords.current.set(rawRecordId(record), record);
|
||||
}
|
||||
setSdkGraph({
|
||||
scope,
|
||||
status: "ready",
|
||||
value: rawGraphProjection(page.items, page.total, sourcesRef.current),
|
||||
});
|
||||
} catch {
|
||||
if (cancelled || request.signal.aborted) return;
|
||||
setSdkGraph((current) => ({
|
||||
@@ -547,17 +501,7 @@ export function MemorySpace({
|
||||
sdkMapVisible,
|
||||
sourceKey,
|
||||
]);
|
||||
const displayedSdkPage =
|
||||
lite &&
|
||||
!sdkLibraryQuery.trim() &&
|
||||
sdkPage &&
|
||||
visibleGraphProgress?.total != null
|
||||
? {
|
||||
...sdkPage,
|
||||
total: visibleGraphProgress.total,
|
||||
total_exact: true,
|
||||
}
|
||||
: sdkPage;
|
||||
const displayedSdkPage = sdkPage;
|
||||
const sdkFilterLabel = [
|
||||
sdkLibrary.settledSource
|
||||
? `${t("memory:来源")} ${
|
||||
@@ -1397,8 +1341,6 @@ export function MemorySpace({
|
||||
failed: sdkLibrary.failed && !evolveRunning,
|
||||
graphLoading: !sdkGraphSettled,
|
||||
graphFailed: sdkGraphFailed && !evolveRunning,
|
||||
graphLoaded: visibleGraphProgress?.loaded,
|
||||
graphTotal: visibleGraphProgress?.total ?? undefined,
|
||||
stale: false,
|
||||
settledSource: sdkLibrary.settledSource,
|
||||
settledQuery: sdkLibrary.settledQuery,
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import type {
|
||||
MemoryGraphEdge,
|
||||
MemoryGraphNode,
|
||||
MemoryGraphResponse,
|
||||
} from "@one-memory/contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createRawClusterLayout } from "./RawConversationGraph3D";
|
||||
|
||||
function makeRawGraph(count: number, sourceCount = 4): MemoryGraphResponse {
|
||||
const sources: MemoryGraphNode[] = Array.from(
|
||||
{ length: sourceCount },
|
||||
(_, index) => ({
|
||||
node_id: `source:s-${index}`,
|
||||
node_type: "source",
|
||||
memory_id: null,
|
||||
source_id: `s-${index}`,
|
||||
label: `Source ${index}`,
|
||||
kind: null,
|
||||
status: null,
|
||||
confidence: null,
|
||||
}),
|
||||
);
|
||||
const memories: MemoryGraphNode[] = Array.from(
|
||||
{ length: count },
|
||||
(_, index) => ({
|
||||
node_id: `memory:m-${index}`,
|
||||
node_type: "memory",
|
||||
memory_id: `m-${index}`,
|
||||
source_id: null,
|
||||
label: `Turn ${index}`,
|
||||
kind: "chat",
|
||||
status: "confirmed",
|
||||
confidence: null,
|
||||
}),
|
||||
);
|
||||
const edges: MemoryGraphEdge[] = memories.map((memory, index) => ({
|
||||
edge_id: `edge-${index}`,
|
||||
source_node_id: sources[index % sourceCount].node_id,
|
||||
target_node_id: memory.node_id,
|
||||
relation: "evidence_source",
|
||||
origin: "provenance",
|
||||
weight: null,
|
||||
}));
|
||||
return {
|
||||
total_memories: count,
|
||||
projected_memories: count,
|
||||
hy_graph_available: false,
|
||||
nodes: [...sources, ...memories],
|
||||
edges,
|
||||
};
|
||||
}
|
||||
|
||||
describe("createRawClusterLayout", () => {
|
||||
it.each([1_600, 5_000])(
|
||||
"positions every node in bounded linear time at %i turns",
|
||||
(count) => {
|
||||
const graph = makeRawGraph(count);
|
||||
const started = performance.now();
|
||||
const positions = createRawClusterLayout(graph);
|
||||
const elapsed = performance.now() - started;
|
||||
|
||||
expect(positions.size).toBe(graph.nodes.length);
|
||||
expect(elapsed).toBeLessThan(1_000);
|
||||
for (const position of positions.values()) {
|
||||
expect(Number.isFinite(position.x)).toBe(true);
|
||||
expect(Number.isFinite(position.y)).toBe(true);
|
||||
expect(Number.isFinite(position.z)).toBe(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("is deterministic", () => {
|
||||
const graph = makeRawGraph(80);
|
||||
const first = createRawClusterLayout(graph);
|
||||
const second = createRawClusterLayout(graph);
|
||||
expect([...first].map(([id, value]) => [id, value.toArray()])).toEqual(
|
||||
[...second].map(([id, value]) => [id, value.toArray()]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,421 +0,0 @@
|
||||
import type {
|
||||
MemoryGraphNode,
|
||||
MemoryGraphResponse,
|
||||
} from "@one-memory/contracts";
|
||||
import { Focus, Minus, Plus } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { useMemoryLanguage } from "./memory-language";
|
||||
|
||||
const GRAPH_CANVAS = 0xedf1f5;
|
||||
const MEMORY_COLOR = new THREE.Color(0x4e6478);
|
||||
const SELECTED_COLOR = new THREE.Color(0x294355);
|
||||
|
||||
/** O(n) deterministic source clusters for the complete lite raw map. */
|
||||
export function createRawClusterLayout(
|
||||
graph: MemoryGraphResponse,
|
||||
): Map<string, THREE.Vector3> {
|
||||
const positions = new Map<string, THREE.Vector3>();
|
||||
const sources = graph.nodes.filter((node) => node.node_type === "source");
|
||||
const sourceIds = new Set(sources.map((node) => node.node_id));
|
||||
const sourceByMemory = new Map<string, string>();
|
||||
for (const edge of graph.edges) {
|
||||
const sourceId = sourceIds.has(edge.source_node_id)
|
||||
? edge.source_node_id
|
||||
: sourceIds.has(edge.target_node_id)
|
||||
? edge.target_node_id
|
||||
: null;
|
||||
if (!sourceId) continue;
|
||||
const memoryId =
|
||||
edge.source_node_id === sourceId
|
||||
? edge.target_node_id
|
||||
: edge.source_node_id;
|
||||
sourceByMemory.set(memoryId, sourceId);
|
||||
}
|
||||
const fallbackSource = sources[0]?.node_id ?? "source:unknown";
|
||||
const groups = new Map<string, MemoryGraphNode[]>();
|
||||
for (const node of graph.nodes) {
|
||||
if (node.node_type !== "memory") continue;
|
||||
const sourceId = sourceByMemory.get(node.node_id) ?? fallbackSource;
|
||||
const group = groups.get(sourceId) ?? [];
|
||||
group.push(node);
|
||||
groups.set(sourceId, group);
|
||||
}
|
||||
const clusterRing = Math.max(3.5, sources.length * 1.2);
|
||||
sources.forEach((source, sourceIndex) => {
|
||||
const angle = (sourceIndex / Math.max(1, sources.length)) * Math.PI * 2;
|
||||
const center = new THREE.Vector3(
|
||||
Math.cos(angle) * clusterRing,
|
||||
((sourceIndex % 3) - 1) * 1.4,
|
||||
Math.sin(angle) * clusterRing,
|
||||
);
|
||||
positions.set(source.node_id, center);
|
||||
const memories = groups.get(source.node_id) ?? [];
|
||||
memories.forEach((memory, index) => {
|
||||
const direction = seededDirection(memory.node_id);
|
||||
const radius = 0.9 + Math.cbrt(index + 1) * 0.42;
|
||||
positions.set(
|
||||
memory.node_id,
|
||||
center.clone().addScaledVector(direction, radius),
|
||||
);
|
||||
});
|
||||
});
|
||||
return positions;
|
||||
}
|
||||
|
||||
export function RawConversationGraph3D({
|
||||
graph,
|
||||
selectedNodeId,
|
||||
onSelectNode,
|
||||
}: {
|
||||
graph: MemoryGraphResponse;
|
||||
selectedNodeId: string | null;
|
||||
onSelectNode(node: MemoryGraphNode): void;
|
||||
}) {
|
||||
const { text } = useMemoryLanguage();
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const selectRef = useRef(onSelectNode);
|
||||
const selectedRef = useRef(selectedNodeId);
|
||||
const graphRef = useRef(graph);
|
||||
const applySelection = useRef<(() => void) | null>(null);
|
||||
const actions = useRef<{
|
||||
zoom(scale: number): void;
|
||||
reset(): void;
|
||||
} | null>(null);
|
||||
const [available, setAvailable] = useState(true);
|
||||
const [hovered, setHovered] = useState<{
|
||||
label: string;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
selectRef.current = onSelectNode;
|
||||
graphRef.current = graph;
|
||||
const identity = useMemo(
|
||||
() =>
|
||||
`${graph.nodes
|
||||
.map((node) => `${node.node_id}\0${node.label}`)
|
||||
.join("\n")}\n${graph.edges
|
||||
.map(
|
||||
(edge) =>
|
||||
`${edge.edge_id}\0${edge.source_node_id}\0${edge.target_node_id}`,
|
||||
)
|
||||
.join("\n")}`,
|
||||
[graph],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
selectedRef.current = selectedNodeId;
|
||||
applySelection.current?.();
|
||||
}, [selectedNodeId]);
|
||||
|
||||
useEffect(() => {
|
||||
void identity;
|
||||
const graph = graphRef.current;
|
||||
const element = host.current;
|
||||
if (!element) return;
|
||||
let renderer: THREE.WebGLRenderer;
|
||||
try {
|
||||
renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
powerPreference: "high-performance",
|
||||
});
|
||||
} catch {
|
||||
setAvailable(false);
|
||||
return;
|
||||
}
|
||||
setAvailable(true);
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
element.appendChild(renderer.domElement);
|
||||
renderer.domElement.setAttribute("aria-hidden", "true");
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.fog = new THREE.FogExp2(GRAPH_CANVAS, 0.025);
|
||||
scene.add(new THREE.AmbientLight(0xf2f6fa, 1.8));
|
||||
const directional = new THREE.DirectionalLight(0xe3edf5, 2);
|
||||
directional.position.set(8, 10, 12);
|
||||
scene.add(directional);
|
||||
const camera = new THREE.PerspectiveCamera(48, 1, 0.1, 240);
|
||||
const defaultDistance = Math.max(13, Math.sqrt(graph.nodes.length) * 0.55);
|
||||
camera.position.set(0, defaultDistance * 0.25, defaultDistance);
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = false;
|
||||
controls.minDistance = 4;
|
||||
controls.maxDistance = 140;
|
||||
|
||||
const positions = createRawClusterLayout(graph);
|
||||
const nodeById = new Map(graph.nodes.map((node) => [node.node_id, node]));
|
||||
const memories = graph.nodes.filter((node) => node.node_type === "memory");
|
||||
const sources = graph.nodes.filter((node) => node.node_type === "source");
|
||||
const memoryGeometry = new THREE.SphereGeometry(0.14, 10, 8);
|
||||
const memoryMaterial = new THREE.MeshStandardMaterial({
|
||||
color: MEMORY_COLOR,
|
||||
roughness: 0.78,
|
||||
metalness: 0.04,
|
||||
vertexColors: true,
|
||||
});
|
||||
const instances = new THREE.InstancedMesh(
|
||||
memoryGeometry,
|
||||
memoryMaterial,
|
||||
Math.max(1, memories.length),
|
||||
);
|
||||
instances.count = memories.length;
|
||||
const matrix = new THREE.Matrix4();
|
||||
memories.forEach((node, index) => {
|
||||
matrix.setPosition(positions.get(node.node_id) ?? new THREE.Vector3());
|
||||
instances.setMatrixAt(index, matrix);
|
||||
instances.setColorAt(index, MEMORY_COLOR);
|
||||
});
|
||||
instances.instanceMatrix.needsUpdate = true;
|
||||
if (instances.instanceColor) instances.instanceColor.needsUpdate = true;
|
||||
scene.add(instances);
|
||||
|
||||
const sourceGeometry = new THREE.SphereGeometry(0.3, 16, 12);
|
||||
const sourceMeshes = sources.map((node) => {
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
color: 0x2f6b57,
|
||||
roughness: 0.65,
|
||||
});
|
||||
const mesh = new THREE.Mesh(sourceGeometry, material);
|
||||
mesh.position.copy(positions.get(node.node_id) ?? new THREE.Vector3());
|
||||
mesh.userData.nodeId = node.node_id;
|
||||
scene.add(mesh);
|
||||
return mesh;
|
||||
});
|
||||
|
||||
const edgePositions = new Float32Array(graph.edges.length * 6);
|
||||
graph.edges.forEach((edge, index) => {
|
||||
const start = positions.get(edge.source_node_id);
|
||||
const end = positions.get(edge.target_node_id);
|
||||
if (!start || !end) return;
|
||||
edgePositions.set(
|
||||
[start.x, start.y, start.z, end.x, end.y, end.z],
|
||||
index * 6,
|
||||
);
|
||||
});
|
||||
const edgeGeometry = new THREE.BufferGeometry();
|
||||
edgeGeometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(edgePositions, 3),
|
||||
);
|
||||
const edgeMaterial = new THREE.LineBasicMaterial({
|
||||
color: 0x91a99c,
|
||||
transparent: true,
|
||||
opacity: 0.24,
|
||||
});
|
||||
scene.add(new THREE.LineSegments(edgeGeometry, edgeMaterial));
|
||||
|
||||
const render = () => renderer.render(scene, camera);
|
||||
const reset = () => {
|
||||
camera.position.set(0, defaultDistance * 0.25, defaultDistance);
|
||||
controls.target.set(0, 0, 0);
|
||||
controls.update();
|
||||
render();
|
||||
};
|
||||
actions.current = {
|
||||
zoom(scale) {
|
||||
const offset = camera.position.clone().sub(controls.target);
|
||||
offset.setLength(
|
||||
Math.max(
|
||||
controls.minDistance,
|
||||
Math.min(controls.maxDistance, offset.length() * scale),
|
||||
),
|
||||
);
|
||||
camera.position.copy(controls.target).add(offset);
|
||||
controls.update();
|
||||
render();
|
||||
},
|
||||
reset,
|
||||
};
|
||||
applySelection.current = () => {
|
||||
memories.forEach((node, index) => {
|
||||
instances.setColorAt(
|
||||
index,
|
||||
node.node_id === selectedRef.current ? SELECTED_COLOR : MEMORY_COLOR,
|
||||
);
|
||||
});
|
||||
if (instances.instanceColor) instances.instanceColor.needsUpdate = true;
|
||||
for (const mesh of sourceMeshes) {
|
||||
const material = mesh.material as THREE.MeshStandardMaterial;
|
||||
material.color.setHex(
|
||||
mesh.userData.nodeId === selectedRef.current ? 0x294355 : 0x2f6b57,
|
||||
);
|
||||
}
|
||||
render();
|
||||
};
|
||||
applySelection.current();
|
||||
|
||||
const resize = () => {
|
||||
const width = Math.max(1, element.clientWidth);
|
||||
const height = Math.max(1, element.clientHeight);
|
||||
renderer.setSize(width, height, false);
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
render();
|
||||
};
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(element);
|
||||
controls.addEventListener("change", render);
|
||||
resize();
|
||||
|
||||
const raycaster = new THREE.Raycaster();
|
||||
const pointer = new THREE.Vector2();
|
||||
let lastRaycast = 0;
|
||||
let pressed: { x: number; y: number; id: number; moved: boolean } | null =
|
||||
null;
|
||||
const hit = (event: PointerEvent) => {
|
||||
const bounds = renderer.domElement.getBoundingClientRect();
|
||||
pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1;
|
||||
pointer.y = -((event.clientY - bounds.top) / bounds.height) * 2 + 1;
|
||||
raycaster.setFromCamera(pointer, camera);
|
||||
const match = raycaster.intersectObjects(
|
||||
[instances, ...sourceMeshes],
|
||||
false,
|
||||
)[0];
|
||||
if (!match) return null;
|
||||
if (match.object === instances && match.instanceId !== undefined)
|
||||
return memories[match.instanceId] ?? null;
|
||||
return nodeById.get(String(match.object.userData.nodeId)) ?? null;
|
||||
};
|
||||
const move = (event: PointerEvent) => {
|
||||
if (pressed)
|
||||
pressed.moved ||=
|
||||
Math.hypot(event.clientX - pressed.x, event.clientY - pressed.y) > 5;
|
||||
const now = performance.now();
|
||||
if (now - lastRaycast < 50 || pressed) return;
|
||||
lastRaycast = now;
|
||||
const node = hit(event);
|
||||
renderer.domElement.style.cursor = node ? "pointer" : "grab";
|
||||
setHovered(
|
||||
node
|
||||
? {
|
||||
label: node.label,
|
||||
x: event.clientX - element.getBoundingClientRect().left,
|
||||
y: event.clientY - element.getBoundingClientRect().top,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
};
|
||||
const pointerDown = (event: PointerEvent) => {
|
||||
pressed =
|
||||
event.button === 0
|
||||
? {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
id: event.pointerId,
|
||||
moved: false,
|
||||
}
|
||||
: null;
|
||||
};
|
||||
const select = (event: PointerEvent) => {
|
||||
const start = pressed;
|
||||
pressed = null;
|
||||
if (!start || start.id !== event.pointerId || start.moved) return;
|
||||
const node = hit(event);
|
||||
if (node) selectRef.current(node);
|
||||
};
|
||||
const leave = () => {
|
||||
pressed = null;
|
||||
setHovered(null);
|
||||
};
|
||||
renderer.domElement.addEventListener("pointerdown", pointerDown);
|
||||
renderer.domElement.addEventListener("pointermove", move);
|
||||
renderer.domElement.addEventListener("pointerup", select);
|
||||
renderer.domElement.addEventListener("pointerleave", leave);
|
||||
|
||||
return () => {
|
||||
applySelection.current = null;
|
||||
actions.current = null;
|
||||
observer.disconnect();
|
||||
controls.removeEventListener("change", render);
|
||||
controls.dispose();
|
||||
renderer.domElement.removeEventListener("pointerdown", pointerDown);
|
||||
renderer.domElement.removeEventListener("pointermove", move);
|
||||
renderer.domElement.removeEventListener("pointerup", select);
|
||||
renderer.domElement.removeEventListener("pointerleave", leave);
|
||||
memoryGeometry.dispose();
|
||||
memoryMaterial.dispose();
|
||||
sourceGeometry.dispose();
|
||||
for (const mesh of sourceMeshes)
|
||||
(mesh.material as THREE.Material).dispose();
|
||||
edgeGeometry.dispose();
|
||||
edgeMaterial.dispose();
|
||||
renderer.dispose();
|
||||
renderer.domElement.remove();
|
||||
};
|
||||
}, [identity]);
|
||||
|
||||
return (
|
||||
<div className="memory-graph-canvas" ref={host}>
|
||||
{!available ? (
|
||||
<div className="memory-graph-fallback">
|
||||
<p>{text("这台设备打不开 3D 视图,已显示关联列表。")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="memory-graph-controls"
|
||||
role="toolbar"
|
||||
aria-label={text("图谱工具")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
aria-label={text("放大图谱")}
|
||||
onClick={() => actions.current?.zoom(0.8)}
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
aria-label={text("缩小图谱")}
|
||||
onClick={() => actions.current?.zoom(1.25)}
|
||||
>
|
||||
<Minus size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
aria-label={text("重置图谱视角")}
|
||||
onClick={() => actions.current?.reset()}
|
||||
>
|
||||
<Focus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{hovered ? (
|
||||
<div
|
||||
className="memory-graph-tooltip"
|
||||
style={{ left: hovered.x + 12, top: Math.max(0, hovered.y - 40) }}
|
||||
>
|
||||
{hovered.label}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function seededDirection(value: string): THREE.Vector3 {
|
||||
const u = seededUnit(value, 0);
|
||||
const v = seededUnit(value, 1);
|
||||
const theta = u * Math.PI * 2;
|
||||
const z = v * 2 - 1;
|
||||
const radius = Math.sqrt(Math.max(0, 1 - z * z));
|
||||
return new THREE.Vector3(
|
||||
Math.cos(theta) * radius,
|
||||
z,
|
||||
Math.sin(theta) * radius,
|
||||
);
|
||||
}
|
||||
|
||||
function seededUnit(value: string, salt: number): number {
|
||||
let hash = 2166136261 + salt;
|
||||
for (const character of value) {
|
||||
hash ^= character.charCodeAt(0);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return (hash >>> 0) / 4294967295;
|
||||
}
|
||||
@@ -68,34 +68,31 @@ it("does not present an unavailable raw search as no matches", async () => {
|
||||
await expect(searchSdkRaw("3d")).rejects.toThrow("raw_search_unavailable");
|
||||
});
|
||||
|
||||
it("pages the independent lightweight raw graph", async () => {
|
||||
it("requests one Pro-sized raw graph page", async () => {
|
||||
get.mockResolvedValue({
|
||||
data: {
|
||||
user_id: "local",
|
||||
items: [],
|
||||
total: 1_600,
|
||||
offset: 500,
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
limit: 300,
|
||||
has_more: true,
|
||||
snapshot_id: "snapshot-1",
|
||||
},
|
||||
});
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
await getSdkRawGraph({
|
||||
source: "cursor",
|
||||
offset: 500,
|
||||
limit: 500,
|
||||
snapshotId: "snapshot-1",
|
||||
offset: 0,
|
||||
limit: 300,
|
||||
signal,
|
||||
});
|
||||
|
||||
expect(get).toHaveBeenCalledWith("/api/v2/raw/graph", {
|
||||
params: {
|
||||
source: "cursor",
|
||||
offset: 500,
|
||||
limit: 500,
|
||||
snapshot_id: "snapshot-1",
|
||||
offset: 0,
|
||||
limit: 300,
|
||||
},
|
||||
signal,
|
||||
});
|
||||
|
||||
@@ -1019,18 +1019,16 @@ export async function getSdkRaw({
|
||||
export async function getSdkRawGraph({
|
||||
source,
|
||||
offset = 0,
|
||||
limit = 500,
|
||||
snapshotId,
|
||||
limit = 300,
|
||||
signal,
|
||||
}: {
|
||||
source?: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
snapshotId?: string;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<SdkRawGraphPage> {
|
||||
const response = await client.get<SdkRawGraphPage>("/api/v2/raw/graph", {
|
||||
params: { source, offset, limit, snapshot_id: snapshotId },
|
||||
params: { source, offset, limit },
|
||||
signal,
|
||||
});
|
||||
return response.data;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user