Compare commits

...
Author SHA1 Message Date
kristinheandCursor d2f441eaaa style(memhub): wrap SDK language docstring
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-06 13:27:50 +08:00
kristinhe1111andCursor 9004977163 chore(release): bump MemHub build number to 44 (#360)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-06 00:14:04 +08:00
kristinhe1111andCursor 7f0c8198d9 fix(memhub): preserve views during Ultra evolve (#359)
* fix(memhub): preserve views during Ultra evolve

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(memhub): reserve pending spinner space

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-06 00:05:23 +08:00
kristinhe1111andCursor 25349b4cab style(memhub): restore open-source model setup layout (#357)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-06 00:00:58 +08:00
kristinhe1111andCursor 5a42ce5861 fix(memhub): stabilize onboarding import review (#356)
* fix(memhub): stabilize onboarding import review

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(memhub): satisfy onboarding lint

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 23:59:31 +08:00
kristinhe1111andCursor b7731ac834 style(memhub): stack onboarding memory mode cards (#355)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 22:46:05 +08:00
kristinhe1111andCursor 9560327ee2 fix(memhub): load complete raw conversation map safely (#353)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 22:45:50 +08:00
kristinhe1111andCursor f445ad69c4 fix(memhub): accept raw turn metadata in context broker (#351)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 22:45:33 +08:00
57 changed files with 3096 additions and 592 deletions

No files matched your search

+1 -1
View File
@@ -105,7 +105,7 @@ Hy Memory and MemHub are versioned and released independently.
## MemHub 1.0.6 — 2026-11-04
Build 43, on `hy-memory==2.0.14`. Linux x64/arm64 Runtime and macOS arm64
Build 44, on `hy-memory==2.0.14`. Linux x64/arm64 Runtime and macOS arm64
App and Runtime.
- MemHub App now exposes the complete memory-mode lifecycle. New installs
+1 -1
View File
@@ -19,7 +19,7 @@ If you run into a problem, please open an Issue with the version, platform,repro
## 发行与平台合同
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.14`。MemHub 源码版本为 `1.0.6`
(build 43),GitHub 仓库为
(build 44),GitHub 仓库为
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
`memhub-cli`。当前代码架构见
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
+385 -61
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")
@@ -1758,6 +1786,7 @@ def create_app(
else None
)
app.state.live_sync_watch_lock = asyncio.Lock()
app.state.source_activation_tasks = {}
app.state.live_sync_supervisor.set_watched_sources(
watch_roots if watching and runtime_settings.live_sync_enabled else ()
)
@@ -2093,6 +2122,11 @@ def create_app(
app.state.memory_governance_store.wait_content_candidate_backfill,
5.0,
)
source_activation_tasks = tuple(app.state.source_activation_tasks.values())
for task in source_activation_tasks:
task.cancel()
if source_activation_tasks:
await asyncio.gather(*source_activation_tasks, return_exceptions=True)
if app.state.live_sync_watch is not None:
await app.state.live_sync_watch.shutdown()
await app.state.live_sync_supervisor.shutdown()
@@ -3235,6 +3269,69 @@ def create_app(
"""`POST /api/v1/import-jobs` on one user's lane (Hermes only for another user)."""
return await create_job_on(request.source_id, request.operation, request.user_id)
async def _activate_connected_sources(
request_id: str,
source_ids: tuple[str, ...],
*,
enable_sync: bool,
) -> None:
"""Apply one committed connection intent without holding its HTTP response."""
try:
for source_id in source_ids:
if enable_sync:
await _live_sync(app).initialize_source(
source_id,
baseline_missing_files=True,
)
else:
await _live_sync(app).stop_source(source_id)
if enable_sync:
for source_id in source_ids:
await _activate_source_resume(source_id)
await _refresh_live_watch(app)
except asyncio.CancelledError:
raise
except Exception as error:
diagnostic_event(
level="error",
component="source.connection",
event="source.connection.activation_failed",
outcome="failed",
**diagnostic_exception(error),
details={"request_id": request_id, "source_ids": list(source_ids)},
)
raise
def _schedule_source_activation(
request_id: str,
source_ids: tuple[str, ...],
*,
enable_sync: bool,
) -> None:
tasks = app.state.source_activation_tasks
existing = tasks.get(request_id)
if existing is not None and not existing.done():
return
task = watched_task(
asyncio.get_running_loop(),
lambda: _activate_connected_sources(
request_id,
source_ids,
enable_sync=enable_sync,
),
component="source.connection",
name=f"source-connection-{request_id}",
)
if task is None:
return
tasks[request_id] = task
def forget(completed: asyncio.Future[None]) -> None:
if tasks.get(request_id) is completed:
tasks.pop(request_id, None)
task.add_done_callback(forget)
@app.post(
"/api/v1/sources/connect",
response_model=ConnectSourcesResponse,
@@ -3324,44 +3421,21 @@ def create_app(
if grant is not None:
_supervisor(app).register_adapter(registration.create_adapter(grant))
_detection(app).pop(job.source_id, None)
if request.enable_sync:
await _prepare_source_resume(job.source_id)
else:
await _live_sync(app).stop_source(job.source_id)
configured.append(job.source_id)
if configured:
previous = {
source_id: _config_store(app).sync_enabled(source_id) for source_id in configured
}
try:
await asyncio.to_thread(
_config_store(app).set_sync_many,
{source_id: request.enable_sync for source_id in configured},
)
except BaseException:
for source_id in configured:
if request.enable_sync:
await _live_sync(app).stop_source(source_id)
elif previous[source_id]:
await _prepare_source_resume(source_id)
await _activate_source_resume(source_id)
await _refresh_live_watch(app)
raise
try:
if request.enable_sync:
for source_id in configured:
await _activate_source_resume(source_id)
await _refresh_live_watch(app)
except BaseException:
await asyncio.to_thread(_config_store(app).set_sync_many, previous)
for source_id in configured:
if request.enable_sync:
await _live_sync(app).stop_source(source_id)
elif previous[source_id]:
await _prepare_source_resume(source_id)
await _activate_source_resume(source_id)
await _refresh_live_watch(app)
raise
await asyncio.to_thread(
_config_store(app).set_sync_many,
{source_id: request.enable_sync for source_id in configured},
)
if request.enable_sync:
for job in result.jobs:
if job.source_id in configured and job.status is ImportStatus.PENDING:
await _supervisor(app).enqueue_off_loop(job.job_id, job=job)
_schedule_source_activation(
request.request_id,
tuple(configured),
enable_sync=request.enable_sync,
)
return ConnectSourcesResponse(**result.model_dump())
async def _refuse_overlapping_root(source_id: str, root: Path, user_id: str) -> None:
@@ -4496,6 +4570,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 +4865,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 +4882,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 +4894,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 +4929,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 +4940,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 +5114,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,
@@ -376,8 +376,9 @@ def apply_sdk_language(config: MemoryConfig, language: str | None) -> MemoryConf
"""Set the SDK prompt language (`MemoryConfig.language`), when there is one.
None (no language set) sets nothing: the SDK detects zh/en as it always
did. Every display language, zh-Hans included, is set explicitly. Like a feature, the language never touches `vector_store`, so
the collection identity never depends on it.
did. Every display language, zh-Hans included, is set explicitly. Like a
feature, the language never touches `vector_store`, so the collection
identity never depends on it.
"""
if language is not None:
config.language = language
@@ -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={
@@ -197,6 +197,82 @@ def test_connection_retry_reuses_completed_job_and_checks_root_snapshot(tmp_path
asyncio.run(scenario())
def test_connection_returns_committed_receipt_before_live_activation_finishes(
tmp_path, monkeypatch
):
monkeypatch.setattr("one_memory.model_presets._preset_data", lambda: None)
async def scenario():
home = tmp_path / "home"
root = home / ".claude/projects"
root.mkdir(parents=True)
app = create_app(
Settings(
data_dir=tmp_path / "data",
source_home=home,
api_token=SecretStr("test"),
automatic_memory_processing=False,
live_sync_enabled=False,
),
source_catalog=build_source_catalog(home=home, environment={}),
)
async with (
app.router.lifespan_context(app),
AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
headers={"Authorization": "Bearer test"},
) as client,
):
found = await client.post("/api/v1/sources/claude-code/location")
started = asyncio.Event()
release = asyncio.Event()
original = app.state.live_sync_supervisor.initialize_source
async def blocked_initialize(source_id, *, baseline_missing_files):
started.set()
await release.wait()
return await original(
source_id,
baseline_missing_files=baseline_missing_files,
)
monkeypatch.setattr(
app.state.live_sync_supervisor,
"initialize_source",
blocked_initialize,
)
try:
response = await asyncio.wait_for(
client.post(
"/api/v1/sources/connect",
json={
"source_ids": ["claude-code"],
"request_id": "fast-receipt",
"source_roots": {
"claude-code": found.json()["root_id"],
},
"onboarding": True,
"full_rescan": True,
"enable_sync": True,
},
),
timeout=1,
)
assert response.status_code == 200
assert response.json()["request_id"] == "fast-receipt"
assert app.state.config_store.sync_enabled("claude-code")
await asyncio.wait_for(started.wait(), timeout=1)
assert not release.is_set()
finally:
release.set()
tasks = tuple(app.state.source_activation_tasks.values())
if tasks:
await asyncio.gather(*tasks)
asyncio.run(scenario())
@pytest.mark.parametrize(
("turn_count", "age_days", "limited"),
[
+57 -9
View File
@@ -72,15 +72,23 @@ it("localizes the whole home live without repeating reads or changing imported t
expect(summary).toHaveBeenCalledTimes(calls);
});
const { summary, projection, processing, sedimentation, history, liveSync } =
vi.hoisted(() => ({
summary: vi.fn(),
projection: vi.fn(),
processing: vi.fn(),
sedimentation: vi.fn(),
history: vi.fn(),
liveSync: vi.fn(),
}));
const {
summary,
projection,
processing,
sedimentation,
history,
liveSync,
evolve,
} = vi.hoisted(() => ({
summary: vi.fn(),
projection: vi.fn(),
processing: vi.fn(),
sedimentation: vi.fn(),
history: vi.fn(),
liveSync: vi.fn(),
evolve: vi.fn(),
}));
vi.mock("./call-history-client", async (original) => ({
...(await original<typeof import("./call-history-client")>()),
getCallHistory: history,
@@ -105,6 +113,7 @@ vi.mock("./api", () => ({
getSdkRaw: vi.fn(),
getLiveSyncHealth: liveSync,
getMemoryProcessing: processing,
getEvolveOverview: evolve,
getSedimentation: sedimentation,
getSources: vi.fn().mockResolvedValue([]),
}));
@@ -146,6 +155,12 @@ beforeEach(() => {
history
.mockReset()
.mockResolvedValue({ available: true, recording_error: false, items: [] });
evolve.mockReset().mockResolvedValue({
mode: "ultra",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [],
});
imports.refresh = vi.fn();
summary.mockReset().mockResolvedValue({
memory_count: 601,
@@ -940,6 +955,39 @@ it.each([
},
);
it("keeps the home snapshot visible during Evolve and refreshes when it ends", async () => {
evolve.mockResolvedValue({
mode: "ultra",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [
{
run_id: "run-1",
user_id: "local",
state: "running",
},
],
});
render(mountHome());
expect(await screen.findByText("601")).toBeTruthy();
expect(await screen.findByText("正在整合记忆…")).toBeTruthy();
const readsBeforeCompletion = projection.mock.calls.length;
act(() => {
queryClient.setQueryData(["evolve", "local"], {
mode: "ultra",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [],
});
});
await waitFor(() =>
expect(projection.mock.calls.length).toBeGreaterThan(readsBeforeCompletion),
);
});
afterEach(() => {
cleanup();
resetClient();
+17
View File
@@ -41,6 +41,7 @@ import {
retryAutoReconcile,
useAutoConnect,
} from "./connection-reconcile";
import { localEvolveRunning, useEvolveState } from "./evolve-state";
import { useGuiLanguage } from "./gui-language";
import { HomeCardBoundary } from "./HomeCardBoundary";
import {
@@ -55,6 +56,7 @@ import {
sourceConnectionIncomplete,
useRecallHosts,
} from "./host-state";
import { useOnboardingTranslation } from "./onboarding-language";
import { subscribePoll } from "./poll-scheduler";
import { R6Button } from "./R6Controls";
import { prefetchDataRuntime } from "./runtime-state";
@@ -209,6 +211,10 @@ export function ApplicationsHome({
active?: boolean;
}) {
const { locale, t, tn } = useGuiLanguage();
const { text } = useOnboardingTranslation();
const evolveState = useEvolveState(active);
const evolveRunning = localEvolveRunning(evolveState.data);
const evolveUncertain = evolveState.isError && evolveRunning;
const hostQuery = useRecallHosts({ poll: active });
const hostBridge = hostBridgeAvailable();
const hosts = hostQuery.data ?? [];
@@ -313,6 +319,12 @@ export function ApplicationsHome({
const [expanded, setExpanded] = useState(retained.expanded);
const [phase, setPhase] = useState("idle");
const [revision, setRevision] = useState(0);
const previousEvolveRunning = useRef(evolveRunning);
useEffect(() => {
const previous = previousEvolveRunning.current;
previousEvolveRunning.current = evolveRunning;
if (previous && !evolveRunning && active) setRevision((value) => value + 1);
}, [active, evolveRunning]);
const confirmedModeSeen = useRef(confirmedMode);
const expandButton = useRef<HTMLButtonElement>(null);
const backButton = useRef<HTMLButtonElement>(null);
@@ -953,6 +965,11 @@ export function ApplicationsHome({
<header>
<h2>{t("apps.overview")}</h2>
</header>
{evolveRunning ? (
<p className="r6-evolve-status" role="status">
{text(evolveUncertain ? "正在核对整合状态…" : "正在整合记忆…")}
</p>
) : null}
<div className="r6-home-metrics">
<div role="status" aria-live="polite" aria-atomic="true">
<p>{t(viewModel.countLabel)}</p>
+21 -7
View File
@@ -46,6 +46,7 @@ export function defaultHostSourceIds(
export type ImportConnectionDraft = {
hostSourceIds: readonly string[];
};
export type ImportSubmissionState = "confirmed" | "checking" | "refused";
export interface FirstImportReviewProps {
sourceIds: readonly string[];
sources: SourceResponse[];
@@ -54,6 +55,7 @@ export interface FirstImportReviewProps {
acceptedJobIds?: readonly string[];
failedSources?: Record<string, string>;
sourceSnapshot?: { confirmedAt: string | null; stale: boolean };
importSubmissionState?: ImportSubmissionState;
busy?: boolean;
error?: string | null;
connectionNote?: string;
@@ -78,6 +80,7 @@ export function FirstImportReview({
acceptedJobIds,
failedSources = {},
sourceSnapshot,
importSubmissionState = "confirmed",
busy = false,
error,
connectionNote,
@@ -329,13 +332,24 @@ export function FirstImportReview({
>
<div className="first-import-r6-intro">
<h2>{text("正在导入你的会话")}</h2>
<p>
{text(
allCompleted
? "本次导入已结束。已保存的对话无需重复导入。"
: "后台将持续为你导入,可以放心进入下一步",
)}
</p>
{importSubmissionState === "checking" ? (
<p className="first-import-r6-submission-status" role="status">
{t("onboardingImportChecking")}
</p>
) : (
<p>
{text(
allCompleted
? "本次导入已结束。已保存的对话无需重复导入。"
: "后台将持续为你导入,可以放心进入下一步",
)}
</p>
)}
{ids.length > 3 && (
<p className="first-import-r6-scroll-hint">
{t("onboardingMoreImportApps", { count: ids.length - 3 })}
</p>
)}
</div>
{(error || actionMessage) && (
<div className="first-import-r6-feedback">
+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} />
@@ -0,0 +1,70 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { LanguageProvider } from "./gui-language";
import { MemoryModeCards } from "./MemoryModeCards";
afterEach(cleanup);
it("shows every mode feature and reveals Ultra scheduling after selection", async () => {
const onChange = vi.fn();
const user = userEvent.setup();
const view = (value: "lite" | "pro" | "ultra") => (
<LanguageProvider>
<MemoryModeCards
aria-label="记忆模式"
value={value}
layout="rows"
onChange={onChange}
ultraDetail={
<div>
<strong>每天自动整合</strong>
<small>默认 03:00,可在设置中修改。</small>
</div>
}
/>
</LanguageProvider>
);
const { rerender } = render(view("pro"));
expect(screen.getByRole("radiogroup", { name: "记忆模式" })).toBeTruthy();
expect(screen.getAllByRole("radio")).toHaveLength(3);
expect(screen.getByText("不调用生成模型")).toBeTruthy();
expect(screen.getByText("沉淀高级记忆")).toBeTruthy();
expect(screen.getByText("增强关联召回")).toBeTruthy();
expect(screen.queryByText("每天自动整合")).toBeNull();
await user.click(screen.getByRole("radio", { name: /深度记忆/ }));
expect(onChange).toHaveBeenCalledWith("ultra", expect.any(HTMLInputElement));
rerender(view("ultra"));
expect(screen.getByText("每天自动整合")).toBeTruthy();
});
it("preserves per-mode disabled behavior", () => {
render(
<LanguageProvider>
<MemoryModeCards
aria-label="记忆模式"
value="lite"
disabled={(mode) => mode !== "lite"}
onChange={vi.fn()}
/>
</LanguageProvider>,
);
expect(screen.getByRole("radio", { name: /基础记忆/ })).toHaveProperty(
"disabled",
false,
);
expect(screen.getByRole("radio", { name: /智能记忆/ })).toHaveProperty(
"disabled",
true,
);
expect(screen.getByRole("radio", { name: /深度记忆/ })).toHaveProperty(
"disabled",
true,
);
});
+69 -60
View File
@@ -1,5 +1,5 @@
import type { MemoryPipelineMode } from "@one-memory/contracts";
import { Check } from "lucide-react";
import { Check, Clock3 } from "lucide-react";
import { type ReactNode, useId } from "react";
import { MODE_COPY } from "./memory-mode-ui";
import { useOnboardingTranslation } from "./onboarding-language";
@@ -32,6 +32,7 @@ export function MemoryModeCards({
current,
currentBadge,
disabled = false,
layout = "grid",
onChange,
ultraDetail,
"aria-label": ariaLabel,
@@ -41,6 +42,7 @@ export function MemoryModeCards({
current?: MemoryPipelineMode;
currentBadge?: ReactNode;
disabled?: boolean | ((mode: MemoryPipelineMode) => boolean);
layout?: "grid" | "rows";
onChange(mode: MemoryPipelineMode, control: HTMLInputElement): void;
ultraDetail?: ReactNode;
"aria-label": string;
@@ -53,70 +55,77 @@ export function MemoryModeCards({
return (
<div
className="memory-mode-cards"
className={`memory-mode-cards memory-mode-cards--${layout}`}
role="radiogroup"
aria-label={ariaLabel}
aria-busy={ariaBusy || undefined}
>
{MODES.map((mode) => {
const selected = value === mode.id;
const modeDisabled = unavailable(mode.id);
const localizedTitle = text(MODE_COPY[mode.id].title);
const title = localizedTitle.replace(
/\s*[((](?:lite|pro|ultra)[))]\s*$/i,
"",
);
const features = text(MODE_COPY[mode.id].description).split(/\s*·\s*/);
return (
<div
key={mode.id}
className="memory-mode-card"
data-mode={mode.id}
data-selected={selected || undefined}
data-current={current === mode.id || undefined}
data-disabled={modeDisabled || undefined}
>
<label className="memory-mode-card-choice">
<input
type="radio"
name={name}
value={mode.id}
aria-label={localizedTitle}
checked={selected}
disabled={modeDisabled}
onChange={(event) => onChange(mode.id, event.currentTarget)}
/>
<span className="memory-mode-title">
<span className="memory-mode-index">{mode.index}</span>
<strong>{title}</strong>
<span className="memory-mode-tier">
{mode.id.toUpperCase()}
</span>
{current === mode.id && currentBadge ? (
<span className="memory-mode-current">{currentBadge}</span>
) : null}
<span className="memory-mode-radio" aria-hidden="true">
{selected ? <Check size={13} /> : null}
</span>
</span>
<span className="memory-mode-description">
{text(mode.description)}
</span>
<span className="memory-mode-features">
{features.map((feature) => (
<span key={feature}>
<i aria-hidden="true" />
{text(feature)}
<div className="memory-mode-card-list">
{MODES.map((mode) => {
const selected = value === mode.id;
const modeDisabled = unavailable(mode.id);
const localizedTitle = text(MODE_COPY[mode.id].title);
const title = localizedTitle.replace(
/\s*[((](?:lite|pro|ultra)[))]\s*$/i,
"",
);
const features = text(MODE_COPY[mode.id].description).split(
/\s*·\s*/,
);
return (
<div
key={mode.id}
className="memory-mode-card"
data-mode={mode.id}
data-selected={selected || undefined}
data-current={current === mode.id || undefined}
data-disabled={modeDisabled || undefined}
>
<label className="memory-mode-card-choice">
<input
type="radio"
name={name}
value={mode.id}
aria-label={localizedTitle}
checked={selected}
disabled={modeDisabled}
onChange={(event) => onChange(mode.id, event.currentTarget)}
/>
<span className="memory-mode-title">
<span className="memory-mode-index">{mode.index}</span>
<strong>{title}</strong>
<span className="memory-mode-tier">
{mode.id.toUpperCase()}
</span>
))}
</span>
</label>
{mode.id === "ultra" && selected && ultraDetail ? (
<div className="memory-mode-detail">{ultraDetail}</div>
) : null}
</div>
);
})}
{current === mode.id && currentBadge ? (
<span className="memory-mode-current">{currentBadge}</span>
) : null}
<span className="memory-mode-radio" aria-hidden="true">
{selected ? <Check size={13} /> : null}
</span>
</span>
<span className="memory-mode-description">
{text(mode.description)}
</span>
<span className="memory-mode-features">
{features.map((feature) => (
<span key={feature}>
<i aria-hidden="true" />
{text(feature)}
</span>
))}
</span>
{mode.id === "ultra" && selected && ultraDetail ? (
<div className="memory-mode-detail">
<Clock3 size={14} aria-hidden="true" />
<div>{ultraDetail}</div>
</div>
) : null}
</label>
</div>
);
})}
</div>
</div>
);
}
@@ -22,6 +22,7 @@ import {
setMemoryMode,
} from "./api";
import { MemoryModeSettings } from "./MemoryModeSettings";
import { resetClient } from "./query-client";
vi.mock("./api", () => ({
getMemoryMode: vi.fn(),
@@ -81,6 +82,7 @@ async function chooseMode(
afterEach(() => {
cleanup();
resetClient();
vi.clearAllMocks();
vi.mocked(apiErrorCode).mockReturnValue(null);
vi.mocked(memoryModePlanUnsupported).mockReturnValue(false);
+19 -18
View File
@@ -1,6 +1,4 @@
import type {
EvolveOverview,
EvolveRunReceipt,
EvolveSchedule,
MemoryModeChange,
MemoryModePlan,
@@ -11,7 +9,6 @@ import { legacyMemoryModePlan } from "@one-memory/operations";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import {
apiErrorCode,
getEvolveOverview,
getEvolveSchedule,
getMemoryMode,
getMemoryProcessing,
@@ -25,6 +22,7 @@ import {
commitConfirmedMemoryMode,
issueConfirmedModeTicket,
} from "./confirmed-memory-mode";
import { commitEvolveRun, useEvolveState } from "./evolve-state";
import { MemoryModeCards } from "./MemoryModeCards";
import { BLOCKER_COPY, MemoryModeConfirmDialog } from "./memory-mode-ui";
import { useOnboardingTranslation } from "./onboarding-language";
@@ -47,7 +45,11 @@ export function MemoryModeSettings({
const id = useId();
const [state, setState] = useState<MemoryModeState | null>(null);
const [schedule, setScheduleState] = useState<EvolveSchedule | null>(null);
const [overview, setOverview] = useState<EvolveOverview | null>(null);
const evolveState = useEvolveState();
const overview = evolveState.data ?? null;
const run =
overview?.running.find((candidate) => candidate.user_id === "local") ??
null;
const [pendingMode, setPendingMode] = useState<MemoryPipelineMode | null>(
null,
);
@@ -56,7 +58,6 @@ export function MemoryModeSettings({
const [maintenance, setMaintenance] = useState(false);
const [error, setError] = useState("");
const [readFailed, setReadFailed] = useState(false);
const [run, setRun] = useState<EvolveRunReceipt | null>(null);
const refreshVersion = useRef(0);
const dialogOpener = useRef<HTMLElement | null>(null);
const focusEvolveWhenReady = useRef(false);
@@ -83,11 +84,10 @@ export function MemoryModeSettings({
const refresh = useCallback(async () => {
const version = ++refreshVersion.current;
const modeTicket = issueConfirmedModeTicket("settings");
const [modeResult, scheduleResult, overviewResult, processingResult] =
const [modeResult, scheduleResult, processingResult] =
await Promise.allSettled([
getMemoryMode(),
getEvolveSchedule(),
getEvolveOverview(),
getMemoryProcessing(),
]);
if (version !== refreshVersion.current) return;
@@ -103,10 +103,6 @@ export function MemoryModeSettings({
}
if (scheduleResult.status === "fulfilled")
setScheduleState(scheduleResult.value);
if (overviewResult.status === "fulfilled") {
setOverview(overviewResult.value);
setRun(overviewResult.value.running[0] ?? null);
}
if (processingResult.status === "fulfilled")
setMaintenance(Boolean(processingResult.value.embedding_switch));
}, [onModeChange]);
@@ -116,14 +112,11 @@ export function MemoryModeSettings({
}, [refresh]);
useEffect(() => {
const running =
maintenance ||
state?.graph_build.state === "running" ||
run?.state === "running";
const running = maintenance || state?.graph_build.state === "running";
if (!running) return;
const timer = window.setInterval(() => void refresh(), 1500);
return () => window.clearInterval(timer);
}, [maintenance, refresh, run?.state, state?.graph_build.state]);
}, [maintenance, refresh, state?.graph_build.state]);
const lifecycleBusy =
maintenance ||
@@ -202,13 +195,21 @@ export function MemoryModeSettings({
}
async function evolveNow() {
if (!state || !schedule) return;
refreshVersion.current++;
setBusy(true);
setError("");
try {
const receipt = await startEvolve();
setRun(receipt);
await refresh();
commitEvolveRun(
receipt,
overview ?? {
mode: state.mode,
schedule,
users: {},
running: [],
},
);
} catch (reason) {
const code = apiErrorCode(reason);
setError(
+227 -5
View File
@@ -12,6 +12,7 @@ import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import { MemorySpace } from "./MemorySpace";
import { queryClient, resetClient } from "./query-client";
it("translates memory filters and detail affordances live without refetching", async () => {
setup();
@@ -77,7 +78,9 @@ const { reads } = vi.hoisted(() => ({
processing: vi.fn(),
profiles: vi.fn(),
raw: vi.fn(),
rawGraph: vi.fn(),
rawSearch: vi.fn(),
evolve: vi.fn(),
},
}));
vi.mock("./api", async (importOriginal) => ({
@@ -87,7 +90,9 @@ vi.mock("./api", async (importOriginal) => ({
getMemoryProcessing: reads.processing,
getModelProfiles: reads.profiles,
getSdkRaw: reads.raw,
getSdkRawGraph: reads.rawGraph,
searchSdkRaw: reads.rawSearch,
getEvolveOverview: reads.evolve,
}));
vi.mock("./GraphWidthPrompt", () => ({ GraphWidthPrompt: () => null }));
vi.mock("./MemoryAddComposer", () => ({
@@ -117,10 +122,19 @@ vi.mock("./MemoryAddComposer", () => ({
),
}));
vi.mock("./MemoryGraph3D", () => ({
MemoryGraph3D: () => <div data-testid="graph-owner" />,
MemoryGraph3D: ({ graph }: { graph?: { nodes?: unknown[] } | null }) => (
<div
data-testid="graph-owner"
data-graph-nodes={graph?.nodes?.length ?? 0}
/>
),
}));
vi.mock("./RawConversationGraph3D", () => ({
RawConversationGraph3D: () => <div data-testid="graph-owner" />,
}));
afterEach(() => {
cleanup();
resetClient();
vi.useRealTimers();
vi.clearAllMocks();
});
@@ -154,7 +168,94 @@ it("translates the model-required prompt without configuring a model", async ()
localStorage.clear();
});
it("keeps the memory snapshot visible during Evolve and refreshes when it ends", async () => {
setup();
reads.processing.mockResolvedValue(modeProcessing("ultra"));
reads.evolve.mockResolvedValue({
mode: "ultra",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [
{
run_id: "run-1",
user_id: "local",
state: "running",
},
],
});
render(<MemorySpace simplified />);
expect((await screen.findAllByText("已保存的记忆")).length).toBeGreaterThan(
0,
);
expect(await screen.findByText("正在整合记忆…")).toBeTruthy();
const readsBeforeCompletion = reads.sdkLibrary.mock.calls.length;
act(() => {
queryClient.setQueryData(["evolve", "local"], {
mode: "ultra",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [],
});
});
await waitFor(() =>
expect(reads.sdkLibrary.mock.calls.length).toBeGreaterThan(
readsBeforeCompletion,
),
);
});
it("keeps the last successful graph when its Evolve refresh fails", async () => {
setup();
reads.processing.mockResolvedValue(modeProcessing("ultra"));
reads.evolve.mockResolvedValue({
mode: "ultra",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [{ run_id: "run-1", user_id: "local", state: "running" }],
});
const firstSource = {
source_id: "cursor",
display_name: "Cursor",
authorized: true,
sync_enabled: true,
imported_messages: 1,
imported_conversations: 1,
} as SourceResponse;
const view = render(<MemorySpace simplified sources={[firstSource]} />);
await waitFor(() =>
expect(screen.getByTestId("graph-owner").dataset.graphNodes).toBe("1"),
);
reads.sdkGraph.mockRejectedValueOnce(new Error("evolve write lock"));
view.rerender(
<MemorySpace
simplified
sources={[
firstSource,
{
...firstSource,
source_id: "codex",
display_name: "Codex",
},
]}
/>,
);
await waitFor(() => expect(reads.sdkGraph).toHaveBeenCalledTimes(2));
expect(screen.getByTestId("graph-owner").dataset.graphNodes).toBe("1");
});
function setup() {
reads.evolve.mockResolvedValue({
mode: "ultra",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [],
});
const memories = [
{
memory_id: "m1",
@@ -208,6 +309,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 +431,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 +441,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 +578,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 +601,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 +614,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 +833,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 () => {
+208 -71
View File
@@ -9,6 +9,7 @@ import type {
MemoryLibraryPage,
MemoryProcessingSummary,
SdkAskMemory,
SdkRawGraphItem,
SourceResponse,
} from "@one-memory/contracts";
import { isMessageKey } from "@one-memory/i18n";
@@ -38,15 +39,18 @@ import {
getSdkMemoryGraph,
getSdkMemoryLibrary,
getSdkRaw,
getSdkRawGraph,
manualExtract,
searchSdkRaw,
setExtractionWindow,
updateSdkMemory,
} from "./api";
import { localEvolveRunning, useEvolveState } from "./evolve-state";
import { GraphWidthPrompt } from "./GraphWidthPrompt";
import { useGuiLanguage } from "./gui-language";
import { MemoryDeleteDialog } from "./MemoryDeleteDialog";
import { MemoryMap } from "./MemoryMap";
import { useOnboardingTranslation } from "./onboarding-language";
import { subscribePoll } from "./poll-scheduler";
import { SdkMemoryLibrary } from "./SdkMemoryLibrary";
import { SdkRawHistory } from "./SdkRawHistory";
@@ -60,9 +64,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";
@@ -114,6 +121,10 @@ export function MemorySpace({
onFocusMemoryConsumed?(): void;
}) {
const { locale, t, tn } = useGuiLanguage();
const { text } = useOnboardingTranslation();
const evolveState = useEvolveState(active || preload);
const evolveRunning = localEvolveRunning(evolveState.data);
const evolveUncertain = evolveState.isError && evolveRunning;
const memoryText = (text: string) => {
const key = `memory:${text}`;
return isMessageKey(key) ? t(key) : text;
@@ -165,6 +176,8 @@ export function MemorySpace({
source.grant_revision,
]),
);
const sourcesRef = useRef(sources);
sourcesRef.current = sources;
const management = simplified && displayedMode === "library";
useEffect(() => {
viewActive.current = active;
@@ -188,9 +201,20 @@ 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), []);
const previousEvolveRunning = useRef(evolveRunning);
useEffect(() => {
const previous = previousEvolveRunning.current;
previousEvolveRunning.current = evolveRunning;
if (previous && !evolveRunning) bumpSdkMap();
}, [bumpSdkMap, evolveRunning]);
const [sdkLibrary, setSdkLibrary] = useState<{
page: MemoryLibraryPage | null;
loading: boolean;
@@ -239,18 +263,22 @@ 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" || evolveRunning)
? 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 +323,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 +409,6 @@ export function MemorySpace({
settledMode: pipelineMode,
};
});
setSdkGraph({ scope: windowKey, ...graph });
} catch (reason) {
if (cancelled || request.signal.aborted) return;
if (
@@ -443,6 +446,118 @@ 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((current) => ({
scope,
status: "failed",
value: current?.scope === scope ? current.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 +687,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 +768,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 +881,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 +900,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 +910,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 +925,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 +1091,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:正在读取…")
@@ -1074,6 +1204,11 @@ export function MemorySpace({
</div>
)}
</header>
{evolveRunning ? (
<p className="memory-evolve-status" role="status">
{text(evolveUncertain ? "正在核对整合状态…" : "正在整合记忆…")}
</p>
) : null}
{processing && (!simplified || managementOpen) ? (
<details className="memory-processing-details">
<summary>
@@ -1257,11 +1392,13 @@ export function MemorySpace({
? {
query: sdkLibraryQuery,
source: sdkLibrarySource,
page: sdkPage,
page: displayedSdkPage,
loading: sdkLibrary.loading,
failed: sdkLibrary.failed,
failed: sdkLibrary.failed && !evolveRunning,
graphLoading: !sdkGraphSettled,
graphFailed: sdkGraphFailed,
graphFailed: sdkGraphFailed && !evolveRunning,
graphLoaded: visibleGraphProgress?.loaded,
graphTotal: visibleGraphProgress?.total ?? undefined,
stale: false,
settledSource: sdkLibrary.settledSource,
settledQuery: sdkLibrary.settledQuery,
@@ -1289,7 +1426,7 @@ export function MemorySpace({
displayedMode !== "map" ||
(simplified && pipelineMode === null)
}
memories={sdkPage?.items ?? []}
memories={displayedSdkPage?.items ?? []}
graph={visibleSdkGraph}
sources={sources}
processing={processing}
+15 -30
View File
@@ -467,9 +467,10 @@ describe("OnboardingFlow S1.3", () => {
name: "对话正在汇入,记忆从这里开始",
}),
).toBeTruthy();
expect(screen.getByRole("alert").textContent).toContain(
"开始导入的结果尚未确认",
);
expect(screen.queryByRole("alert")).toBeNull();
expect(
screen.getByText("正在核对导入结果,已保存的选择不会丢失"),
).toBeTruthy();
expect(
screen.queryByRole("heading", { name: "把熟悉的背景,带到这里" }),
).toBeNull();
@@ -552,11 +553,11 @@ describe("OnboardingFlow S1.3", () => {
modelBoundary,
);
await user.click(screen.getByRole("button", { name: "返回导入" }));
await waitFor(() =>
expect(screen.getByRole("alert").textContent).toContain(
"开始导入的结果尚未确认",
),
);
await waitFor(() => {
expect(
screen.getByText("正在核对导入结果,已保存的选择不会丢失"),
).toBeTruthy();
});
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
});
@@ -2690,7 +2691,7 @@ describe("OnboardingFlow S1.3", () => {
expect(controller.connectForOnboarding).toHaveBeenCalledOnce();
});
it("keeps an unknown connection visible and reconciles its receipt without a duplicate POST", async () => {
it("keeps an unknown connection visible without reporting an error or duplicating the POST", async () => {
const user = userEvent.setup();
const onFinish = vi.fn();
vi.stubGlobal("crypto", webcrypto);
@@ -2706,6 +2707,7 @@ describe("OnboardingFlow S1.3", () => {
vi.mocked(api.getSourceConnection).mockRejectedValueOnce(
new Error("receipt offline"),
);
window.oneMemory = {} as NonNullable<typeof window.oneMemory>;
function Harness() {
const controller = useImportSources({ automaticSync: false });
return controller.loading ? (
@@ -2723,9 +2725,9 @@ describe("OnboardingFlow S1.3", () => {
await choose(user);
await approve(user);
await user.click(screen.getByRole("button", { name: START }));
await screen.findByRole("alert");
await screen.findByText("正在核对导入结果,已保存的选择不会丢失");
expect(onFinish).not.toHaveBeenCalled();
expect(screen.getByRole("alert").textContent).toContain("尚未确认");
expect(screen.queryByRole("alert")).toBeNull();
expect(completeOnboarding).not.toHaveBeenCalled();
expect(api.connectSources).toHaveBeenCalledOnce();
const requestId = vi.mocked(api.connectSources).mock.calls[0][2]?.requestId;
@@ -2738,26 +2740,9 @@ describe("OnboardingFlow S1.3", () => {
requestId,
}),
);
await user.click(screen.getByRole("button", { name: START }));
await waitFor(() =>
expect(api.getSourceConnection).toHaveBeenCalledTimes(2),
);
expect(api.getSourceConnection).toHaveBeenLastCalledWith(requestId);
expect(api.connectSources).toHaveBeenCalledOnce();
expect(screen.getByRole("alert").textContent).toContain("尚未确认");
vi.mocked(api.getSourceConnection).mockResolvedValueOnce({
request_id: requestId,
accepted_source_ids: ["claude"],
failed_sources: {},
jobs: [],
});
await openApps();
await user.click(screen.getByRole("button", { name: START }));
await screen.findByRole("button", { name: "选择记忆模式" });
await continueImport(user);
expect(onFinish).toHaveBeenCalledWith("sources");
expect(api.connectSources).toHaveBeenCalledOnce();
expect(api.getSourceConnection).toHaveBeenCalledTimes(3);
expect(api.getSourceConnection).toHaveBeenCalledOnce();
expect(api.getSourceConnection).toHaveBeenCalledWith(requestId);
expect(api.createImportJob).not.toHaveBeenCalled();
});
+26 -7
View File
@@ -41,6 +41,7 @@ import {
defaultHostSourceIds,
FirstImportReview,
type ImportConnectionDraft,
type ImportSubmissionState,
} from "./FirstImportReview";
import {
enableHostConnection,
@@ -134,6 +135,8 @@ export function OnboardingFlow({
const [startingImport, setStartingImport] = useState<SourceResponse[] | null>(
null,
);
const [importSubmissionState, setImportSubmissionState] =
useState<ImportSubmissionState>("confirmed");
const resumedStep =
state.mode_intent &&
state.mode_intent !== "lite" &&
@@ -1058,6 +1061,7 @@ export function OnboardingFlow({
async function submitImport(approved: SourceResponse[]) {
sourceFlight.current = true;
setImportSubmissionState("checking");
if (window.oneMemory) setStartingImport(approved);
const version = scopeVersion.current;
const retryFailed = Object.keys(itemErrors).length > 0;
@@ -1069,8 +1073,11 @@ export function OnboardingFlow({
try {
current = (await imports.refresh()).sources;
} catch {
if (alive.current)
if (alive.current) {
setStartingImport(null);
setImportSubmissionState("refused");
setError("暂时无法核对来源,尚未提交授权。选择已保留,可以重试。");
}
return;
}
if (!alive.current) return;
@@ -1093,6 +1100,7 @@ export function OnboardingFlow({
setItemErrors(result.failed_sources);
if (result.accepted_source_ids.length === 0) {
setStartingImport(null);
setImportSubmissionState("refused");
setError(
"本次没有来源获准导入。请核对下方原因后重试,或到应用页查看状态。",
);
@@ -1101,16 +1109,25 @@ export function OnboardingFlow({
// Keep the accepted intent and job IDs while the user inspects early originals.
setAccepted(result);
setStartingImport(null);
setImportSubmissionState("confirmed");
setError(null);
} catch (reason) {
if (alive.current) {
const refused = isDefiniteAccessRefusal(reason);
if (refused) setStartingImport(null);
setError(
refused
? "本次没有来源获准导入。请核对下方原因后重试,或到应用页查看状态。"
: "开始导入的结果尚未确认,选择已保留。可以重试,或进入应用页核对。",
);
if (refused) {
setStartingImport(null);
setImportSubmissionState("refused");
setError(
"本次没有来源获准导入。请核对下方原因后重试,或到应用页查看状态。",
);
} else {
setImportSubmissionState("checking");
setError(
window.oneMemory
? null
: "开始导入的结果尚未确认,选择已保留。可以重试,或进入应用页核对。",
);
}
}
} finally {
connectSent.current = false;
@@ -1186,6 +1203,7 @@ export function OnboardingFlow({
jobs={reviewJobs}
failedSources={accepted?.failed_sources}
sourceSnapshot={imports.sourceSnapshot}
importSubmissionState={importSubmissionState}
busy={completionBusy}
error={error && text(error)}
onContinue={(destination) => {
@@ -1266,6 +1284,7 @@ export function OnboardingFlow({
<MemoryModeCards
aria-label={text("记忆模式")}
value={modeChoice}
layout="rows"
disabled={modeSaving || completionBusy}
onChange={(mode) => {
modeChoiceTouched.current = true;
@@ -76,7 +76,9 @@ describe("OnboardingModelStep", () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
const title = guiMessages[locale]["配置记忆整理模型"];
const back = guiMessages[locale]["返回选择模式"];
if (!title || !back) throw new Error(`missing ${locale} model copy`);
const summary = guiMessages[locale]["记忆整理"];
if (!title || !back || !summary)
throw new Error(`missing ${locale} model copy`);
render(
<LanguageProvider>
<OnboardingModelStep {...props()} />
@@ -93,10 +95,11 @@ describe("OnboardingModelStep", () => {
}),
).toBeTruthy();
expect(screen.getByLabelText(/API Key/)).toBeTruthy();
expect(screen.getByRole("complementary", { name: summary })).toBeTruthy();
expect(screen.getAllByRole("button").length).toBeGreaterThanOrEqual(4);
});
it("keeps step 3 on memory mode in a generation-only column", () => {
it("keeps current behavior in the restored open-source composition", () => {
render(<OnboardingModelStep {...props()} />);
expect(
screen.getByRole("heading", { name: "配置记忆整理模型" }),
@@ -110,12 +113,9 @@ describe("OnboardingModelStep", () => {
expect(screen.getByText("DeepSeek · deepseek-flash")).toBeTruthy();
expect(screen.queryByText("本次沉淀")).toBeNull();
expect(screen.queryByLabelText(/向量|Embedding/)).toBeNull();
expect(
document.querySelector(
".onboarding-model-layout--single-column-600" +
'[data-responsive="single-column-600"]',
),
).toBeTruthy();
expect(document.querySelector(".onboarding-model-layout")).toBeTruthy();
expect(document.querySelector(".onboarding-model-summary")).toBeTruthy();
expect(screen.getByText("让记忆从这里开始")).toBeTruthy();
});
it("focuses the page title when entering the model substep", async () => {
@@ -181,6 +181,18 @@ describe("OnboardingModelStep", () => {
);
});
it("returns to the visible API key field when it fails in advanced mode", async () => {
const p = props();
p.advancedOpen = true;
p.fieldErrors = { api_key: "请填写 API Key。" };
const view = render(<OnboardingModelStep {...p} />);
await waitFor(() => expect(p.onAdvancedChange).toHaveBeenCalledWith(false));
view.rerender(<OnboardingModelStep {...p} advancedOpen={false} />);
await waitFor(() =>
expect(document.activeElement).toBe(screen.getByLabelText("API Key")),
);
});
it("links field errors and keeps secrets out of callback errors", async () => {
const p = props();
p.fieldErrors = { api_key: "请填写 API Key。" };
+38 -14
View File
@@ -12,6 +12,7 @@ import type {
import { useOnboardingTranslation } from "./onboarding-language";
import { R6ColdStartFrame } from "./R6ColdStartFrame";
import { R6Button } from "./R6Controls";
import "./onboarding-model-r6.css";
type Action = "test" | "save-and-enable";
@@ -60,6 +61,10 @@ export function OnboardingModelStepNext(props: OnboardingModelStepProps) {
props.onAdvancedChange(true);
return;
}
if (firstError === "api_key" && props.advancedOpen) {
props.onAdvancedChange(false);
return;
}
if (firstError && (!technicalError || props.advancedOpen)) {
formRef.current
?.querySelector<HTMLElement>(`[name="${firstError}"]`)
@@ -173,10 +178,7 @@ export function OnboardingModelStepNext(props: OnboardingModelStepProps) {
</>
}
>
<div
className="onboarding-model-layout onboarding-model-layout--single-column-600"
data-responsive="single-column-600"
>
<div className="onboarding-model-layout">
<section
ref={formRef}
className="onboarding-model-form"
@@ -184,6 +186,9 @@ export function OnboardingModelStepNext(props: OnboardingModelStepProps) {
data-advanced={props.advancedOpen}
>
<div className="onboarding-model-group">
<p className="onboarding-model-intro">
{text("选择服务商,填入 API Key。核对已有配置后再继续。")}
</p>
<label
className="onboarding-model-field onboarding-model-provider"
htmlFor={`${id}-provider`}
@@ -263,16 +268,6 @@ export function OnboardingModelStepNext(props: OnboardingModelStepProps) {
)}
</strong>
</div>
<div className="onboarding-model-disclosure">
<p>
{text("相关对话会发送给所选服务商。")}
{sourceSummary ? ` ${sourceSummary}` : ""}
</p>
<p>
{text("密钥保存在系统安全存储中,不写入对话或日志。")}{" "}
{text("测试只向此接口发送测试内容,不发送历史对话。")}
</p>
</div>
<GenerationTechnicalDisclosure
open={props.advancedOpen}
disabled={fieldsLocked}
@@ -348,6 +343,35 @@ export function OnboardingModelStepNext(props: OnboardingModelStepProps) {
) : null}
</div>
</section>
<aside
className="onboarding-model-summary"
aria-label={text("记忆整理")}
>
<div className="onboarding-model-summary-heading">
<p>{text("记忆整理")}</p>
<h2>{text("让记忆从这里开始")}</h2>
</div>
<div className="onboarding-model-connections">
<p>{text("对话来源")}</p>
<strong>{sourceSummary || text("尚无已选来源")}</strong>
<small>
{t("onboardingImportedConversations", {
count: props.importedConversationCount ?? 0,
})}
</small>
</div>
<div className="onboarding-model-summary-status">
<small>
{text("处理服务:")}
{text(provider?.label ?? "待选择")}
</small>
<small>
{text("相关对话会发送给所选服务商。")}
<br />
{text("测试只向此接口发送测试内容,不发送历史对话。")}
</small>
</div>
</aside>
</div>
</R6ColdStartFrame>
</div>
+12 -2
View File
@@ -19,13 +19,23 @@ describe("R6 shared controls", () => {
it("keeps its label and prevents duplicate activation while pending", () => {
const onClick = vi.fn();
render(
<R6Button pending onClick={onClick}>
const view = render(
<R6Button pending={false} onClick={onClick}>
保存修改
</R6Button>,
);
const button = screen.getByRole("button", { name: "保存修改" });
const reservedSpinner = button.querySelector(".r6-button-spinner");
expect(reservedSpinner).toBeTruthy();
expect(reservedSpinner?.getAttribute("data-visible")).toBeNull();
view.rerender(
<R6Button pending onClick={onClick}>
保存修改
</R6Button>,
);
expect(button.getAttribute("aria-busy")).toBe("true");
expect(button.querySelector(".r6-button-spinner")).toBe(reservedSpinner);
expect(reservedSpinner?.getAttribute("data-visible")).toBe("true");
fireEvent.click(button);
expect(onClick).not.toHaveBeenCalled();
expect(button.textContent).toContain("保存修改");
+12 -4
View File
@@ -13,24 +13,32 @@ type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
export function R6Button({
variant = "secondary",
pending = false,
pending,
disabled,
children,
className = "",
type = "button",
...props
}: ButtonProps) {
const supportsPending = pending !== undefined;
const busy = pending === true;
return (
<button
{...props}
type={type}
className={`r6-button ${className}`}
data-variant={variant}
disabled={disabled || pending}
aria-busy={pending || undefined}
disabled={disabled || busy}
aria-busy={busy || undefined}
>
<span className="r6-button-label">{children}</span>
{pending && <span className="r6-button-spinner" aria-hidden="true" />}
{supportsPending ? (
<span
className="r6-button-spinner"
data-visible={busy || undefined}
aria-hidden="true"
/>
) : null}
</button>
);
}
+34 -2
View File
@@ -160,7 +160,7 @@ it.each(["missing", "pending"] as const)(
},
);
it("centers one or two apps and keeps three or more in a scrolling row", () => {
it("centers one to three apps and keeps four or more in a scrolling row", () => {
function shown(ids: string[]) {
const view = render(
<R6ImportProgress
@@ -181,7 +181,7 @@ it("centers one or two apps and keeps three or more in a scrolling row", () => {
}
expect(shown(["cursor"])).toBe("1");
expect(shown(["cursor", "codex"])).toBe("2");
expect(shown(["cursor", "codex", "claude-code"])).toBe("many");
expect(shown(["cursor", "codex", "claude-code"])).toBe("3");
expect(shown(["cursor", "codex", "claude-code", "workbuddy"])).toBe("many");
const four = render(
<R6ImportProgress
@@ -200,6 +200,38 @@ it("centers one or two apps and keeps three or more in a scrolling row", () => {
.querySelector(".r6-import-progress")
?.getAttribute("data-count"),
).toBe("many");
expect(
four.container
.querySelector(".r6-import-progress")
?.getAttribute("tabindex"),
).toBe("0");
});
it("scrolls one card at a time from the keyboard", () => {
const ids = ["cursor", "codex", "claude-code", "workbuddy"];
render(
<R6ImportProgress
sourceIds={ids}
sources={ids.map((id) => ({
...source,
source_id: id,
display_name: id,
}))}
jobs={[]}
/>,
);
const progress = screen.getByRole("region", {
name: "本次应用导入进度",
});
Object.defineProperty(progress.firstElementChild, "offsetWidth", {
configurable: true,
value: 280,
});
progress.scrollBy = ((options: ScrollToOptions) => {
progress.scrollLeft += Number(options.left ?? 0);
}) as typeof progress.scrollBy;
fireEvent.keyDown(progress, { key: "ArrowRight" });
expect(progress.scrollLeft).toBe(296);
});
it("keeps the first app anchored across receipts unless the user scrolls", () => {
+25 -4
View File
@@ -70,18 +70,39 @@ export function R6ImportProgress({
});
const ids = [...new Set(sourceIds)];
const scrollable = ids.length > 3;
return (
<section
className="r6-import-progress"
aria-label={text("本次应用导入进度")}
data-count={ids.length <= 2 ? String(ids.length) : "many"}
aria-keyshortcuts={
scrollable ? "ArrowLeft ArrowRight Home End" : undefined
}
data-count={ids.length <= 3 ? String(ids.length) : "many"}
ref={scroller}
tabIndex={scrollable ? 0 : -1}
onPointerDown={markUserScrollIntent}
onTouchStart={markUserScrollIntent}
onWheel={markUserScrollIntent}
onKeyDown={(event) => {
if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key))
markUserScrollIntent();
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key))
return;
markUserScrollIntent();
const element = event.currentTarget;
const card = element.querySelector<HTMLElement>(
".r6-import-progress-card",
);
const step = (card?.offsetWidth ?? 0) + 16;
event.preventDefault();
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
element.scrollBy({
left: event.key === "ArrowRight" ? step : -step,
});
} else {
element.scrollTo({
left: event.key === "End" ? element.scrollWidth : 0,
});
}
}}
onScroll={(event) => {
if (userScrollIntent.current)
@@ -133,7 +154,7 @@ export function R6ImportProgress({
<div className="r6-import-progress-orbit" aria-hidden="true">
<span />
<div className="r6-import-progress-icon">
<AppMark id={id} label={source?.display_name ?? id} size={30} />
<AppMark id={id} label={source?.display_name ?? id} size={40} />
</div>
</div>
<h2>{source?.display_name ?? id}</h2>
@@ -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 } = {},
+11
View File
@@ -69,6 +69,17 @@
font-size: 18px;
font-weight: 500;
}
.r6-evolve-status {
width: fit-content;
margin: 12px 0 -4px;
padding: 5px 9px;
border: 1px solid #cbd8e0;
border-radius: 999px;
background: #edf3f6;
color: #4f6574;
font-size: 12px;
line-height: 20px;
}
.r6-home-metrics {
display: grid;
grid-template-columns: 1.1fr 1fr 1fr;
+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);
+81
View File
@@ -0,0 +1,81 @@
import type { EvolveOverview, EvolveRunReceipt } from "@one-memory/contracts";
import { afterEach, expect, it, vi } from "vitest";
import { getEvolveOverview } from "./api";
import {
commitEvolveRun,
localEvolveRunning,
refreshEvolveOverview,
} from "./evolve-state";
import { queryClient, resetClient } from "./query-client";
vi.mock("./api", () => ({ getEvolveOverview: vi.fn() }));
const receipt: EvolveRunReceipt = {
run_id: "run-1",
user_id: "local",
agent_id: "all-apps",
max_batches: null,
trigger: "manual",
state: "running",
started_at: "2026-11-05T14:00:00Z",
finished_at: null,
rounds: 0,
tasks_processed: 0,
batch_failures: 0,
cycle_status: null,
should_continue: null,
continuation_reason: null,
remaining_clusters: null,
remaining_actionable_clusters: null,
remaining_fresh_facts: null,
capped: false,
elapsed_ms: 0,
error_code: null,
failure_kind: null,
request_id: "request-1",
};
const overview: EvolveOverview = {
mode: "ultra",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [],
};
afterEach(() => {
resetClient();
vi.clearAllMocks();
});
it("shares the latest server overview through the query cache", async () => {
vi.mocked(getEvolveOverview).mockResolvedValue(overview);
await expect(refreshEvolveOverview()).resolves.toEqual(overview);
expect(queryClient.getQueryData(["evolve", "local"])).toEqual(overview);
});
it("publishes an accepted local run immediately from a cold cache", () => {
commitEvolveRun(receipt, overview);
const current = queryClient.getQueryData<EvolveOverview>(["evolve", "local"]);
expect(current?.running).toEqual([receipt]);
expect(localEvolveRunning(current)).toBe(true);
});
it("does not let a pre-mutation overview overwrite the accepted run", async () => {
let resolveRead: ((value: EvolveOverview) => void) | undefined;
vi.mocked(getEvolveOverview).mockReturnValue(
new Promise((resolve) => {
resolveRead = resolve;
}),
);
const staleRead = refreshEvolveOverview();
commitEvolveRun(receipt, overview);
resolveRead?.(overview);
await expect(staleRead).resolves.toMatchObject({ running: [receipt] });
expect(
queryClient.getQueryData<EvolveOverview>(["evolve", "local"])?.running,
).toEqual([receipt]);
});
+76
View File
@@ -0,0 +1,76 @@
import type { EvolveOverview, EvolveRunReceipt } from "@one-memory/contracts";
import { useQuery } from "@tanstack/react-query";
import * as api from "./api";
import { onClientReset, queryClient } from "./query-client";
const EVOLVE_KEY = ["evolve", "local"] as const;
queryClient.setQueryDefaults(EVOLVE_KEY, {
gcTime: Number.POSITIVE_INFINITY,
staleTime: 1_000,
});
let mutationGeneration = 0;
let readsStarted = 0;
let readsLanded = 0;
onClientReset(() => {
mutationGeneration = 0;
readsStarted = 0;
readsLanded = 0;
});
export async function refreshEvolveOverview() {
const generation = mutationGeneration;
const order = ++readsStarted;
const next = await api.getEvolveOverview();
const current = queryClient.getQueryData<EvolveOverview>(EVOLVE_KEY);
if (generation !== mutationGeneration || order < readsLanded)
return current ?? next;
readsLanded = order;
queryClient.setQueryData(EVOLVE_KEY, next);
return next;
}
/** Publish an accepted run before polling confirms it so every page agrees. */
export function commitEvolveRun(
receipt: EvolveRunReceipt,
fallback?: EvolveOverview,
) {
mutationGeneration++;
queryClient.setQueryData<EvolveOverview>(EVOLVE_KEY, (current) => {
const base = current ?? fallback;
return base
? {
...base,
running: [
...base.running.filter(
(running) => running.user_id !== receipt.user_id,
),
receipt,
],
}
: current;
});
void queryClient.invalidateQueries({ queryKey: EVOLVE_KEY });
}
export function useEvolveState(active = true) {
return useQuery(
{
queryKey: EVOLVE_KEY,
queryFn: refreshEvolveOverview,
enabled: active,
refetchInterval: (query) =>
query.state.data?.running.length ? 1_500 : 10_000,
},
queryClient,
);
}
export function useEvolveOverview(active = true) {
return useEvolveState(active).data;
}
export function localEvolveRunning(overview?: EvolveOverview | null) {
return overview?.running.some((run) => run.user_id === "local") ?? false;
}
+93 -65
View File
@@ -13,6 +13,7 @@
flex-direction: column;
}
.first-import-r6-intro {
position: relative;
margin: 0 0 8px;
}
.first-import-r6-intro h2 {
@@ -44,97 +45,104 @@
margin-inline: auto;
}
.first-import-r6-main > .r6-import-progress[data-count="1"] {
width: min(680px, 100%);
grid-auto-columns: 100%;
grid-auto-columns: min(320px, 100%);
justify-content: center;
}
.first-import-r6-main > .r6-import-progress[data-count="2"] {
width: min(720px, 100%);
grid-auto-columns: calc((100% - 16px) / 2);
grid-auto-columns: min(320px, calc((100% - 16px) / 2));
justify-content: center;
}
.first-import-r6-main > .r6-import-progress[data-count="3"] {
grid-auto-columns: calc((100% - 32px) / 3);
justify-content: center;
overflow-x: hidden;
}
.first-import-r6-main > .r6-import-progress[data-count="many"] {
grid-auto-flow: column;
grid-auto-columns: calc((100% - 32px) / 3);
justify-content: start;
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
scrollbar-color: rgb(102 128 145 / 50%) transparent;
}
.first-import-r6-main > .r6-import-progress:focus-visible {
outline: 2px solid #557c93;
outline-offset: 3px;
}
.first-import-r6-main > .r6-import-progress .r6-import-progress-card {
min-height: 112px;
padding: 12px 18px;
min-height: 128px;
padding: 16px 20px;
display: grid;
grid-template-columns: 82px minmax(0, 1fr);
grid-template-columns: 92px minmax(0, 1fr);
grid-template-rows: auto auto auto;
align-content: center;
column-gap: 16px;
column-gap: 18px;
text-align: left;
scroll-snap-align: start;
}
.first-import-r6-main > .r6-import-progress .r6-import-progress-orbit {
width: 76px;
height: 76px;
width: 86px;
height: 86px;
grid-column: 1;
grid-row: 1 / 4;
margin: 0;
}
.first-import-r6-main > .r6-import-progress .r6-import-progress-icon {
width: 54px;
height: 54px;
border-radius: 14px;
}
.first-import-r6-main > .r6-import-progress :is(h2, p, small) {
grid-column: 2;
}
.first-import-r6-main > .r6-import-progress .r6-import-progress-card h2 {
align-self: end;
}
.first-import-r6-main > .r6-import-progress[data-count="many"] {
grid-auto-flow: row;
grid-auto-columns: unset;
overflow-x: hidden;
.first-import-r6-intro .first-import-r6-submission-status {
min-height: 26px;
display: flex;
align-items: center;
gap: 7px;
color: #41697d;
font-weight: 500;
white-space: nowrap;
}
.first-import-r6-main
> .r6-import-progress[data-count="many"]:not(:has(> :nth-child(4))) {
grid-template-columns: repeat(3, minmax(0, 1fr));
.first-import-r6-submission-status::before {
content: "";
flex: 0 0 auto;
width: 12px;
height: 12px;
border: 1.5px solid rgb(88 124 144 / 28%);
border-top-color: #587c90;
border-radius: 50%;
animation: first-import-r6-checking 1.2s linear infinite;
}
.first-import-r6-main
> .r6-import-progress[data-count="many"]:has(> :nth-child(4)):not(
:has(> :nth-child(5))
) {
grid-template-columns: repeat(4, minmax(0, 1fr));
.first-import-r6-intro:has(.first-import-r6-submission-status) {
display: flex;
align-items: center;
gap: 12px;
}
.first-import-r6-main
> .r6-import-progress[data-count="many"]:has(> :nth-child(5)):not(
:has(> :nth-child(6))
) {
grid-template-columns: repeat(5, minmax(0, 1fr));
.first-import-r6-intro:has(.first-import-r6-submission-status) h2 {
flex: 0 0 auto;
margin: 0;
}
.first-import-r6-main
> .r6-import-progress[data-count="many"]:has(> :nth-child(6)):not(
:has(> :nth-child(7))
) {
grid-template-columns: repeat(6, minmax(0, 1fr));
.first-import-r6-intro .first-import-r6-scroll-hint {
position: absolute;
right: 0;
bottom: 0;
color: #6b7e8b;
font-size: 11px;
line-height: 20px;
white-space: nowrap;
}
.first-import-r6-main
> .r6-import-progress[data-count="many"]:has(> :nth-child(7)) {
grid-template-columns: repeat(4, minmax(0, 1fr));
.first-import-r6-intro:has(.first-import-r6-scroll-hint) {
padding-right: 190px;
}
.first-import-r6-main
> .r6-import-progress[data-count="many"]:has(> :nth-child(4))
.r6-import-progress-card {
min-height: 112px;
grid-template-columns: minmax(0, 1fr);
justify-items: center;
gap: 1px;
padding: 8px;
text-align: center;
}
.first-import-r6-main
> .r6-import-progress[data-count="many"]:has(> :nth-child(4))
.r6-import-progress-orbit {
width: 54px;
height: 54px;
grid-column: 1;
grid-row: auto;
}
.first-import-r6-main
> .r6-import-progress[data-count="many"]:has(> :nth-child(4))
:is(h2, p, small) {
grid-column: 1;
align-self: auto;
margin-top: 1px;
}
.first-import-r6-main
> .r6-import-progress[data-count="many"]:has(> :nth-child(4))
.r6-import-progress-icon {
width: 36px;
height: 36px;
@keyframes first-import-r6-checking {
to {
transform: rotate(360deg);
}
}
.first-import-r6-feed {
flex: 0 0 auto;
@@ -512,6 +520,22 @@ dialog.first-import-r6-original[open] {
gap: 18px;
}
}
@media (min-width: 761px) and (max-height: 859px) {
.onboarding-r6:has(.first-import-r6-layout)
> :is(.onboarding-r6-titlebar, .onboarding-r6-local-footer) {
display: none;
}
.onboarding-r6:has(.first-import-r6-layout) > .onboarding-r6-stage {
min-height: 0;
height: 100dvh;
padding: 10px 24px 2px;
}
.onboarding-r6:has(.first-import-r6-layout)
> .onboarding-r6-stage
> .onboarding-r6-shell {
height: 100%;
}
}
@media (min-width: 761px) and (max-height: 959px) {
.onboarding-r6-body:has(> .first-import-r6-layout) {
overflow-y: auto;
@@ -630,6 +654,10 @@ dialog.first-import-r6-original[open] {
outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
.first-import-r6-submission-status::before {
animation: none;
border-color: #587c90;
}
.first-import-r6-selection,
.first-import-r6-selection input::after {
transition: none;
+245 -35
View File
@@ -1,14 +1,26 @@
.memory-mode-cards {
position: relative;
flex: 1;
min-width: 0;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
align-items: start;
min-height: 420px;
display: flex;
}
.memory-mode-card-list {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
gap: 14px;
}
.memory-mode-card {
position: relative;
flex: 1 1 0;
min-width: 0;
min-height: 286px;
min-height: 128px;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
@@ -43,10 +55,15 @@
}
.memory-mode-card-choice {
min-height: 284px;
padding: 20px;
display: flex;
flex-direction: column;
position: relative;
min-height: 0;
height: 100%;
padding: 18px 20px;
display: grid;
grid-template-columns: minmax(230px, 0.85fr) minmax(0, 1.45fr);
grid-template-rows: auto auto minmax(0, 1fr);
column-gap: 28px;
align-items: center;
cursor: pointer;
}
@@ -68,10 +85,13 @@
}
.memory-mode-title {
grid-column: 1;
grid-row: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
padding-right: 34px;
}
.memory-mode-index {
@@ -110,9 +130,11 @@
}
.memory-mode-radio {
position: absolute;
top: 18px;
right: 20px;
width: 21px;
height: 21px;
margin-left: auto;
flex: 0 0 21px;
display: grid;
place-items: center;
@@ -133,8 +155,11 @@
}
.memory-mode-description {
min-height: 50px;
margin-top: 10px;
grid-column: 1;
grid-row: 2;
align-self: start;
min-height: 0;
margin-top: 6px;
color: #526674;
font-size: 13px;
line-height: 22px;
@@ -142,11 +167,16 @@
}
.memory-mode-features {
margin-top: auto;
padding: 16px 0 2px;
grid-column: 2;
grid-row: 1 / -1;
align-self: stretch;
margin-top: 0;
padding: 0 42px 0 28px;
display: grid;
grid-template-columns: minmax(0, 1fr);
align-content: center;
gap: 8px;
border-top: 1px solid #dbe4ea;
border-left: 1px solid #dbe4ea;
color: #5e6e7a;
font-size: 12px;
line-height: 19px;
@@ -168,14 +198,97 @@
}
.memory-mode-detail {
margin-top: auto;
padding: 14px 18px;
grid-column: 1;
grid-row: 3;
align-self: start;
margin-top: 6px;
padding: 7px 0 0;
display: flex;
align-items: flex-start;
gap: 7px;
border-top: 1px solid #d4e0e7;
background: #f1f6f8;
background: transparent;
color: #405867;
font-size: 11px;
line-height: 17px;
animation: memory-mode-detail-in 180ms ease-out both;
}
.memory-mode-detail > svg {
margin-top: 1px;
flex: 0 0 auto;
}
.memory-mode-detail > div {
min-width: 0;
flex: 1;
}
.memory-mode-cards--grid {
flex: none;
min-height: 0;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
align-items: start;
}
.memory-mode-cards--grid .memory-mode-card-list {
display: contents;
}
.memory-mode-cards--grid .memory-mode-card {
position: static;
flex: none;
min-height: 286px;
height: auto;
}
.memory-mode-cards--grid .memory-mode-card-choice {
position: static;
min-height: 284px;
height: auto;
padding: 20px;
display: flex;
flex-direction: column;
}
.memory-mode-cards--grid .memory-mode-title {
width: 100%;
padding-right: 0;
}
.memory-mode-cards--grid .memory-mode-radio {
position: static;
margin-left: auto;
}
.memory-mode-cards--grid .memory-mode-description {
min-height: 50px;
margin-top: 10px;
}
.memory-mode-cards--grid .memory-mode-features {
width: 100%;
margin-top: auto;
padding: 16px 0 2px;
border-top: 1px solid #dbe4ea;
border-left: 0;
}
.memory-mode-cards--grid .memory-mode-detail {
width: calc(100% + 40px);
margin: auto -20px -20px;
padding: 14px 18px;
display: block;
border-radius: 0 0 15px 15px;
background: #f1f6f8;
font-size: 12px;
line-height: 20px;
animation: memory-mode-detail-in 180ms ease-out both;
}
.memory-mode-cards--grid .memory-mode-detail > svg {
display: none;
}
@keyframes memory-mode-detail-in {
@@ -185,34 +298,131 @@
}
}
@media (min-width: 681px) and (max-height: 959px) {
.memory-mode-cards {
gap: 14px;
@media (min-width: 761px) and (max-height: 959px) {
.memory-mode-cards--rows {
min-height: 360px;
}
.memory-mode-card {
min-height: 194px;
.memory-mode-cards--rows .memory-mode-card-list {
gap: 12px;
}
.memory-mode-card-choice {
min-height: 194px;
padding: 16px;
.memory-mode-cards--rows .memory-mode-card {
min-height: 112px;
}
.memory-mode-title strong {
.memory-mode-cards--rows .memory-mode-card-choice {
min-height: 0;
padding: 13px 16px;
grid-template-columns: minmax(205px, 0.82fr) minmax(0, 1.5fr);
column-gap: 22px;
}
.memory-mode-cards--rows .memory-mode-radio {
top: 13px;
right: 16px;
}
.memory-mode-cards--rows .memory-mode-title strong {
font-size: 17px;
line-height: 25px;
}
.memory-mode-description {
.memory-mode-cards--rows .memory-mode-description {
min-height: 0;
margin-top: 4px;
font-size: 12px;
line-height: 18px;
}
.memory-mode-cards--rows .memory-mode-features {
margin-top: 0;
padding: 0 36px 0 22px;
gap: 6px;
font-size: 11px;
line-height: 17px;
}
.memory-mode-cards--rows .memory-mode-detail {
margin-top: 5px;
padding: 5px 0 0;
gap: 6px;
font-size: 11px;
line-height: 16px;
}
}
@media (max-width: 760px) {
.memory-mode-cards--rows {
flex: none;
min-height: 0;
}
.memory-mode-cards--rows .memory-mode-card-list {
display: grid;
grid-template-columns: 1fr;
}
.memory-mode-cards--rows .memory-mode-card {
flex: none;
min-height: 0;
height: auto;
}
.memory-mode-cards--rows .memory-mode-card-choice {
height: auto;
min-height: 0;
padding: 20px;
display: flex;
flex-direction: column;
}
.memory-mode-cards--rows .memory-mode-radio {
position: static;
margin-left: auto;
}
.memory-mode-cards--rows .memory-mode-title {
width: 100%;
padding-right: 0;
}
.memory-mode-cards--rows .memory-mode-description {
min-height: 0;
margin-top: 8px;
}
.memory-mode-cards--rows .memory-mode-features {
width: 100%;
margin-top: 16px;
padding: 16px 0 0;
border-top: 1px solid #dbe4ea;
border-left: 0;
}
.memory-mode-cards--rows .memory-mode-detail {
width: calc(100% + 40px);
margin: 16px -20px -20px;
padding: 14px 20px 16px;
border-radius: 0 0 15px 15px;
background: #f1f6f8;
}
}
@media (min-width: 681px) and (max-height: 959px) {
.memory-mode-cards--grid {
gap: 14px;
}
.memory-mode-cards--grid .memory-mode-card {
min-height: 194px;
}
.memory-mode-cards--grid .memory-mode-card-choice {
min-height: 194px;
padding: 16px;
}
.memory-mode-cards--grid .memory-mode-title strong {
font-size: 17px;
line-height: 25px;
}
.memory-mode-cards--grid .memory-mode-description {
min-height: 36px;
margin-top: 8px;
font-size: 12px;
line-height: 20px;
}
.memory-mode-features {
.memory-mode-cards--grid .memory-mode-features {
padding-top: 10px;
gap: 6px;
font-size: 11px;
line-height: 18px;
}
.memory-mode-detail {
.memory-mode-cards--grid .memory-mode-detail {
width: calc(100% + 32px);
margin: auto -16px -16px;
padding: 10px 14px;
font-size: 11px;
line-height: 18px;
@@ -220,14 +430,14 @@
}
@media (max-width: 680px) {
.memory-mode-cards {
.memory-mode-cards--grid {
grid-template-columns: 1fr;
}
.memory-mode-card,
.memory-mode-card-choice {
.memory-mode-cards--grid .memory-mode-card,
.memory-mode-cards--grid .memory-mode-card-choice {
min-height: 0;
}
.memory-mode-description {
.memory-mode-cards--grid .memory-mode-description {
min-height: 0;
}
}
+105 -139
View File
@@ -588,51 +588,44 @@
transition: none;
}
/* Generation-only cold start. Embedding remains a separate settings lane. */
.onboarding-model-r6 .onboarding-r6-head > div {
width: min(760px, 100%);
}
.onboarding-model-layout {
display: block;
width: min(880px, 100%);
height: auto;
margin-inline: auto;
}
.onboarding-model-form {
overflow: visible;
}
/* The current controller keeps generation-only behavior. The presentation
intentionally returns to the original open-source setup composition:
an open form column, a separate context column, and a distinct advanced state. */
.onboarding-model-group {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px 20px;
padding: 24px 26px;
border: 1px solid #d8e2e8;
border-radius: 16px;
background: rgb(255 255 255 / 72%);
box-shadow: inset 0 1px rgb(255 255 255 / 88%);
display: block;
}
.onboarding-model-provider,
.onboarding-model-key {
margin-top: 0;
.onboarding-model-group > .onboarding-model-intro {
margin-bottom: 0;
}
.onboarding-model-field {
gap: 7px;
}
.onboarding-model-field :is(input, select),
.onboarding-model-field .om-option-menu-button,
.generation-model-field :is(input, select) {
width: 100%;
min-width: 0;
height: 44px;
padding: 9px 12px;
border: 1px solid #c8d3db;
height: 54px;
padding: 12px 16px;
border: 1px solid #c0ccd5;
border-radius: 8px;
background: #fff;
background: #f0f5f6;
color: #262a2e;
font:
400 14px / 22px "Noto Sans SC",
400 14px / 23px "Noto Sans SC",
"PingFang SC",
sans-serif;
transition:
border-color var(--r6-fast, 140ms),
background-color var(--r6-fast, 140ms);
}
.onboarding-model-field .om-option-menu-button:hover:not(:disabled),
.generation-model-field :is(input, select):hover:not(:disabled) {
border-color: #9fb6ad;
background: #eaf1f2;
}
.generation-model-field select:not(:disabled),
.generation-technical-toggle:not(:disabled) {
cursor: pointer;
}
:is(.onboarding-model-field, .generation-model-field) [aria-invalid="true"] {
border-color: #a24646;
}
.onboarding-model-field small,
.generation-model-field small {
@@ -641,14 +634,10 @@
line-height: 19px;
}
.onboarding-model-default-summary {
grid-column: 1 / -1;
display: grid;
gap: 6px;
padding: 14px 0;
border-block: 1px solid #e4eaee;
}
.onboarding-model-default-summary span,
.onboarding-model-disclosure {
min-height: 54px;
margin-top: 12px;
color: #63707b;
font-size: 12px;
line-height: 20px;
@@ -658,154 +647,131 @@
font-size: 14px;
font-weight: 600;
}
.onboarding-model-disclosure {
grid-column: 1 / -1;
display: grid;
gap: 6px;
}
.generation-technical-disclosure {
grid-column: 1 / -1;
border-top: 1px solid #e4eaee;
padding-top: 4px;
margin-top: 23px;
}
.generation-technical-toggle {
display: flex;
width: 100%;
min-height: 44px;
min-height: 28px;
align-items: center;
justify-content: space-between;
padding: 8px 0;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: #4e6478;
font:
500 13px / 21px "Noto Sans SC",
"PingFang SC",
sans-serif;
transition:
background-color var(--r6-fast, 140ms),
color var(--r6-fast, 140ms);
}
.generation-technical-toggle:hover:not(:disabled) {
background: #ffffff66;
color: #33424d;
}
.generation-technical-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
padding-top: 12px;
}
.generation-model-field {
display: grid;
min-width: 0;
gap: 6px;
color: #63707b;
font-size: 12px;
line-height: 20px;
}
.generation-model-field:has([name="base-url"]),
.generation-runtime-fields,
.generation-thinking {
grid-column: 1 / -1;
}
.generation-runtime-fields {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
gap: 19px 30px;
margin-top: 34px;
}
.generation-technical-fields[hidden] {
display: none;
}
.generation-runtime-note {
.generation-model-field {
display: grid;
min-width: 0;
gap: 5px;
color: #63707b;
font-size: 12px;
line-height: 20px;
}
.generation-model-field:has([name="base_url"]),
.generation-runtime-fields,
.generation-runtime-note,
.generation-thinking {
grid-column: 1 / -1;
}
.generation-model-field :is(input, select) {
height: 42px;
padding: 9px 12px;
font-size: 13px;
line-height: 21px;
}
.generation-runtime-fields {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 24px;
}
.generation-runtime-note {
color: #63707b;
font-size: 12px;
line-height: 19px;
}
.generation-thinking {
display: flex;
min-height: 32px;
min-height: 28px;
align-items: center;
gap: 9px;
gap: 10px;
color: #416e5c;
font-size: 13px;
font-size: 14px;
line-height: 23px;
}
.onboarding-model-r6 .generation-model-error {
color: #a24646;
}
.onboarding-model-recovery-actions {
margin-top: 12px;
.onboarding-model-form[data-advanced="true"]
:is(
.onboarding-model-intro,
.onboarding-model-provider,
.onboarding-model-key,
.onboarding-model-default-summary
) {
display: none;
}
.onboarding-model-form[data-advanced="true"] .generation-technical-disclosure {
margin-top: 0;
}
.onboarding-model-form[data-advanced="true"] .generation-technical-toggle {
min-height: 28px;
}
.onboarding-model-recovery-actions,
.onboarding-model-feedback {
margin-top: 8px;
}
.onboarding-model-r6 .onboarding-r6-foot {
display: grid;
grid-template-columns: minmax(220px, 1fr) auto;
align-items: center;
gap: 20px 32px;
}
.onboarding-model-r6 .onboarding-model-footer-actions {
flex-wrap: nowrap;
margin-left: 0;
}
.onboarding-model-r6 .onboarding-model-footer-actions .r6-button {
min-width: 132px;
}
@media (min-width: 761px) {
.onboarding-model-r6 .onboarding-r6-body {
.onboarding-model-group {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.onboarding-model-form {
display: block;
overflow: visible;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-gutter: stable;
scroll-padding: 12px;
padding-inline: 4px 16px;
}
}
@media (min-width: 761px) and (max-height: 959px) {
.onboarding-model-r6 .onboarding-r6-body {
overflow-y: hidden;
.onboarding-model-form[data-advanced="false"]
.onboarding-model-field
:is(input, select, .om-option-menu-button) {
height: 44px;
padding-top: 8px;
padding-bottom: 8px;
}
.onboarding-model-layout,
.onboarding-model-form {
height: 100%;
min-height: 0;
}
.onboarding-model-form {
overflow-y: auto;
scrollbar-width: thin;
}
.onboarding-model-group {
gap: 14px 18px;
padding: 18px 22px;
.generation-technical-fields {
margin-top: 16px;
}
}
@media (max-width: 600px) {
.onboarding-model-layout--single-column-600 .onboarding-model-group {
@media (max-width: 760px) {
.generation-technical-fields,
.generation-runtime-fields {
grid-template-columns: minmax(0, 1fr);
padding: 16px;
border-radius: 10px;
}
.onboarding-model-layout--single-column-600 .generation-technical-fields,
.onboarding-model-layout--single-column-600 .generation-runtime-fields {
grid-template-columns: minmax(0, 1fr);
}
.onboarding-model-footer-actions .r6-button,
.onboarding-model-footer-actions .r6-button[data-variant="primary"] {
flex-basis: 100%;
}
}
@media (max-width: 900px) {
.onboarding-model-r6 .onboarding-r6-foot {
grid-template-columns: minmax(0, 1fr);
}
.onboarding-model-r6 .onboarding-model-footer-actions {
width: 100%;
flex-wrap: wrap;
}
.onboarding-model-r6 .onboarding-model-footer-actions .r6-button {
flex: 1 1 160px;
}
}
@media (max-width: 600px) {
.onboarding-model-r6 .onboarding-model-footer-actions .r6-button,
.onboarding-model-r6
.onboarding-model-footer-actions
.r6-button[data-variant="primary"] {
flex-basis: 100%;
gap: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
+6
View File
@@ -634,6 +634,12 @@
font-size: 13px;
line-height: 20px;
}
@media (min-width: 761px) {
.onboarding-r6-mode {
min-height: 100%;
gap: 24px;
}
}
.onboarding-r6-empty {
margin: 0;
padding: 28px 18px;
+9 -3
View File
@@ -159,7 +159,7 @@ button.r6-button {
min-height: 44px;
height: 44px;
padding: 0 20px;
gap: 0;
gap: 8px;
border: 1px solid var(--r6-control-edge);
border-radius: 8px;
background: rgb(255 255 255 / 20%);
@@ -244,14 +244,20 @@ button.r6-button[data-variant="quiet"]:disabled {
min-width: 0;
}
.r6-button-spinner {
position: absolute;
right: 5px;
position: static;
flex: 0 0 auto;
width: 12px;
height: 12px;
border: 1.5px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
visibility: hidden;
animation: r6-spin 1.8s linear infinite;
animation-play-state: paused;
}
.r6-button-spinner[data-visible="true"] {
visibility: visible;
animation-play-state: running;
}
.r6-stepper {
+2 -1
View File
@@ -15,7 +15,8 @@
safe center falls back to the start if the pair would overflow a narrow column.
Three or more keep the existing row and scroll horizontally. */
.r6-import-progress[data-count="1"],
.r6-import-progress[data-count="2"] {
.r6-import-progress[data-count="2"],
.r6-import-progress[data-count="3"] {
justify-content: safe center;
overflow-x: hidden;
}
+11
View File
@@ -33,6 +33,17 @@
font-size: 13px;
color: #637786;
}
.r6-memory .memory-evolve-status {
width: fit-content;
margin: -12px 0 20px;
padding: 5px 9px;
border: 1px solid #cbd8e0;
border-radius: 999px;
background: #edf3f6;
color: #4f6574;
font-size: 12px;
line-height: 20px;
}
.r6-memory .memory-pending-add {
display: flex;
align-items: center;
+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 {
+1 -1
View File
@@ -1,6 +1,6 @@
# MemHub 安装与发行
MemHub `v1.0.6`(build 43)的发行目标如下:
MemHub `v1.0.6`(build 44)的发行目标如下:
| 平台 | 发行物 | 构建与验收 |
| --- | --- | --- |
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "one-memory",
"version": "1.0.6",
"release": {
"build_number": 43,
"build_number": 44,
"macos_min_version": "13.0",
"app_update": "manual",
"runtime_manifest_schema": 1,
+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. */
@@ -271,6 +271,87 @@ it("reads Core's SDK context directly and passes native fields unchanged", async
);
});
it.each([
["omitted", raw],
["null", { ...raw, turn_id: null, turn_index: null, turn_count: null }],
[
"minimum populated",
{
...raw,
turn_id: "t",
turn_index: 0,
turn_count: 1,
},
],
[
"maximum populated",
{
...raw,
turn_id: "t".repeat(96),
turn_index: Number.MAX_SAFE_INTEGER,
turn_count: Number.MAX_SAFE_INTEGER,
},
],
])("passes through %s optional raw turn metadata", async (_name, item) => {
const f = await fixture();
const response = receiptFor([item]);
f.respond(response);
const result = await f.post();
expect(result.status).toBe(200);
expect(result.data).toEqual(response);
expect(f.diagnostics).not.toContainEqual(
expect.objectContaining({ event: "context.response_invalid" }),
);
});
it.each([
["empty turn_id", { turn_id: "" }],
["overlong turn_id", { turn_id: "t".repeat(97) }],
["non-string turn_id", { turn_id: 1 }],
["negative turn_index", { turn_index: -1 }],
["fractional turn_index", { turn_index: 0.5 }],
["non-number turn_index", { turn_index: "0" }],
["unsafe turn_index", { turn_index: 2 ** 53 }],
["zero turn_count", { turn_count: 0 }],
["negative turn_count", { turn_count: -1 }],
["fractional turn_count", { turn_count: 1.5 }],
["non-number turn_count", { turn_count: "1" }],
["unsafe turn_count", { turn_count: 2 ** 53 }],
["unknown field", { revision: 1 }],
])("rejects raw context with %s", async (_name, fields) => {
const f = await fixture();
f.respond(receiptFor([{ ...raw, content: "private turn", ...fields }]));
const result = await f.post();
expect(result.status).toBe(502);
expect(result.data).toEqual({ error: "context_unavailable" });
expect(f.diagnostics).toContainEqual(
expect.objectContaining({
event: "context.response_invalid",
error_code: "invalid_context_response",
details: { reason: "response" },
}),
);
expect(JSON.stringify(f.diagnostics)).not.toContain("private turn");
});
it("rejects raw turn metadata on a memory context item", async () => {
const f = await fixture();
f.respond({
...mixed,
items: [{ ...memory, turn_id: "t" }],
used_characters: [...memory.content].length,
});
const result = await f.post();
expect(result.status).toBe(502);
expect(result.data).toEqual({ error: "context_unavailable" });
});
it.each([
[10, 200],
[11, 502],
@@ -184,17 +184,30 @@ function validSdkItem(value: unknown): boolean {
if (!record(value) || typeof value.truncated !== "boolean") return false;
if (value.kind === "raw")
return (
exact(value, [
"kind",
"raw_id",
"content",
"source",
"session_id",
"memory_at",
"score",
"truncated",
]) &&
exact(
value,
[
"kind",
"raw_id",
"content",
"source",
"session_id",
"memory_at",
"score",
"truncated",
],
["turn_id", "turn_index", "turn_count"],
) &&
text(value.raw_id, 1, 512) &&
(value.turn_id === undefined ||
value.turn_id === null ||
text(value.turn_id, 1, 96)) &&
(value.turn_index === undefined ||
value.turn_index === null ||
integer(value.turn_index, 0, Number.MAX_SAFE_INTEGER)) &&
(value.turn_count === undefined ||
value.turn_count === null ||
integer(value.turn_count, 1, Number.MAX_SAFE_INTEGER)) &&
text(value.content, 0, 20000) &&
text(value.source, 1, 128) &&
text(value.session_id, 1, 512) &&
@@ -1063,6 +1063,9 @@ const baseEnglishMessages = {
"少一些重复交代,多一些理解。": "Less repetition. More understanding.",
onboardingMoreApps: "Scroll for more · {count} apps remaining",
"onboardingMoreApps.one": "Scroll for more · {count} app remaining",
onboardingMoreImportApps: "Scroll horizontally · {count} apps remaining",
onboardingImportChecking:
"Checking the import result; your selection is kept",
onboardingAllApps: "All {count} apps shown",
默认位置: "Default location",
自定义位置: "Custom location",
@@ -83,6 +83,10 @@ export const onboardingSpanishMessages = {
onboardingMoreApps: "Desplázate para ver más · Quedan {count} aplicaciones",
"onboardingMoreApps.one":
"Desplázate para ver más · Queda {count} aplicación",
onboardingMoreImportApps:
"Desplázate horizontalmente · Quedan {count} aplicaciones",
onboardingImportChecking:
"Comprobando el resultado de la importación; tu selección se conserva",
onboardingAllApps: "Se muestran las {count} aplicaciones",
默认位置: "Ubicación predeterminada",
自定义位置: "Ubicación personalizada",
@@ -393,6 +397,8 @@ export const onboardingChineseMessages = {
onboardingImportHistory: "导入 {name} 历史",
onboardingMoreApps: "向下查看更多 · 还有 {count} 个应用",
"onboardingMoreApps.one": "向下查看更多 · 还有 {count} 个应用",
onboardingMoreImportApps: "横向滑动查看更多 · 还有 {count} 个应用",
onboardingImportChecking: "正在核对导入结果,已保存的选择不会丢失",
onboardingAllApps: "已显示全部 {count} 个应用",
onboardingImportRefused: "尚未获准导入({reason})。请核对来源后重试。",
onboardingConfirmHistory: "确认这是 {name} 的历史位置",
@@ -1255,6 +1255,9 @@ export const germanMessages: Record<MessageKey, string> = {
"少一些重复交代,多一些理解。": "Weniger Wiederholung. Mehr Verständnis.",
onboardingMoreApps: "Für mehr scrollen · {count} Apps verbleibend",
"onboardingMoreApps.one": "Für mehr scrollen · {count} App verbleibend",
onboardingMoreImportApps: "Horizontal scrollen · Noch {count} Apps",
onboardingImportChecking:
"Importergebnis wird geprüft; Ihre Auswahl bleibt erhalten",
onboardingAllApps: "Alle {count} Apps angezeigt",
默认位置: "Standardort",
自定义位置: "Eigener Ort",
@@ -1271,6 +1271,10 @@ export const frenchMessages: Record<MessageKey, string> = {
"Faites défiler pour voir plus · {count} applications restantes",
"onboardingMoreApps.one":
"Faites défiler pour voir plus · {count} application restante",
onboardingMoreImportApps:
"Faites défiler horizontalement · encore {count} applications",
onboardingImportChecking:
"Vérification du résultat de l’importation ; votre sélection est conservée",
onboardingAllApps: "Les {count} applications sont affichées",
默认位置: "Emplacement par défaut",
自定义位置: "Emplacement personnalisé",
@@ -1239,6 +1239,9 @@ export const indonesianMessages: Record<MessageKey, string> = {
onboardingMoreApps: "Gulir untuk melihat lainnya · {count} aplikasi lagi",
"onboardingMoreApps.one":
"Gulir untuk melihat lainnya · {count} aplikasi lagi",
onboardingMoreImportApps: "Gulir horizontal · {count} aplikasi lagi",
onboardingImportChecking:
"Memeriksa hasil impor; pilihan Anda tetap tersimpan",
onboardingAllApps: "Semua {count} aplikasi ditampilkan",
默认位置: "Lokasi default",
自定义位置: "Lokasi kustom",
@@ -1234,6 +1234,9 @@ export const japaneseMessages: Record<MessageKey, string> = {
onboardingMoreApps: "スクロールしてさらに表示 · 残り {count} 個のアプリ",
"onboardingMoreApps.one":
"スクロールしてさらに表示 · 残り {count} 個のアプリ",
onboardingMoreImportApps: "横にスクロール · 残り {count} 個のアプリ",
onboardingImportChecking:
"インポート結果を確認しています。選択内容は保持されています",
onboardingAllApps: "全 {count} 個のアプリを表示中",
默认位置: "既定の場所",
自定义位置: "カスタムの場所",
@@ -1210,6 +1210,9 @@ export const koreanMessages: Record<MessageKey, string> = {
"少一些重复交代,多一些理解。": "반복은 줄이고, 이해는 깊게.",
onboardingMoreApps: "스크롤하여 더 보기 · 앱 {count}개 남음",
"onboardingMoreApps.one": "스크롤하여 더 보기 · 앱 {count}개 남음",
onboardingMoreImportApps: "가로로 스크롤 · 앱 {count}개 남음",
onboardingImportChecking:
"가져오기 결과를 확인하는 중이며 선택 항목은 유지됩니다",
onboardingAllApps: "앱 {count}개 모두 표시됨",
默认位置: "기본 위치",
自定义位置: "사용자 지정 위치",
@@ -1258,6 +1258,9 @@ export const portugueseMessages: Record<MessageKey, string> = {
"少一些重复交代,多一些理解。": "Menos repetição. Mais compreensão.",
onboardingMoreApps: "Role para ver mais · Apps restantes: {count}",
"onboardingMoreApps.one": "Role para ver mais · {count} app restante",
onboardingMoreImportApps: "Role horizontalmente · {count} apps restantes",
onboardingImportChecking:
"Verificando o resultado da importação; sua seleção foi mantida",
onboardingAllApps: "Todos os apps exibidos ({count})",
默认位置: "Local padrão",
自定义位置: "Local personalizado",
@@ -1244,6 +1244,9 @@ export const russianMessages: Record<MessageKey, string> = {
"Прокрутите, чтобы увидеть больше · осталось приложений: {count}",
"onboardingMoreApps.one":
"Прокрутите, чтобы увидеть больше · осталось приложений: {count}",
onboardingMoreImportApps:
"Прокрутите по горизонтали · осталось приложений: {count}",
onboardingImportChecking: "Проверяем результат импорта; ваш выбор сохранён",
onboardingAllApps: "Показаны все приложения: {count}",
默认位置: "Расположение по умолчанию",
自定义位置: "Своё расположение",
@@ -1133,6 +1133,8 @@ export const traditionalChineseMessages: Record<MessageKey, string> = {
"少一些重复交代,多一些理解。": "少一些重複交代,多一些理解。",
onboardingMoreApps: "向下捲動檢視更多 · 還有 {count} 個 App",
"onboardingMoreApps.one": "向下捲動檢視更多 · 還有 {count} 個 App",
onboardingMoreImportApps: "橫向捲動查看更多 · 還有 {count} 個 App",
onboardingImportChecking: "正在確認匯入結果,已保留你的選擇",
onboardingAllApps: "已顯示全部 {count} 個 App",
默认位置: "預設位置",
自定义位置: "自訂位置",