Compare commits

...
Author SHA1 Message Date
kristinheandCursor fcd2b739b6 style(memhub): format Core sources with Ruff
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-06 13:51:53 +08:00
kristinhe1111andCursor 4c2844531d fix(memhub): keep long raw hits from clearing search (#363)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-06 13:43:16 +08:00
kristinhe1111andCursor d23ebf9bd8 fix(memhub): recall raw conversations across apps (#365)
* fix(memhub): recall raw across apps without dropping long hits

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

* style(memhub): format raw recall changes

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-06 13:42:52 +08:00
kristinhe1111andCursor 7a80287f4b style(memhub): wrap SDK language docstring (#371)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-06 13:37:49 +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
79 changed files with 3581 additions and 756 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)。
+387 -64
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,
@@ -6624,9 +6948,8 @@ async def _recall_raw_sources(
) -> frozenset[str] | None:
"""The raw sources this caller may read: the `/context` raw-lane rule.
Recall's own grant does not cover original conversations. A host reads
them only with `om_conversations` and its granted, unrevoked sources; a
trusted local caller reads every source.
`om_conversations` plus `all-apps` reads every connected, unrevoked source.
A trusted local caller reads every source without an external grant.
"""
if access is None:
return None
@@ -11,14 +11,14 @@ from one_memory.context_models import ContextSearchRequest
from one_memory.external_memory_access import ExternalMemoryAccess
from one_memory.memory_server.store_binding import store_for
#: The revoked-source query, per engine.
#: The connected-source query, per engine.
#:
#: Identical text today because it uses no placeholders and reads by position,
#: which both engines support. Kept as a named constant so that adding a
#: parameter later forces the `?`/`%s` difference to be considered here rather
#: than being introduced silently at the call site.
REVOKED_SOURCES_SQL = (
"SELECT source_id FROM source_grants WHERE user_id='local' AND revoked_at IS NOT NULL"
CONNECTED_SOURCES_SQL = (
"SELECT DISTINCT source_id FROM source_grants WHERE user_id='local' AND revoked_at IS NULL"
)
@@ -49,16 +49,10 @@ def resolve_context_authority(
scope for scope in access.scopes if scope == "all-apps" or scope.startswith("workspace:v1:")
)
memory_allowed = memory_requested and "om_recall" in access.tools and bool(memory_scopes)
declared_sources = frozenset(
scope.removeprefix("source:") for scope in access.scopes if scope.startswith("source:")
)
raw_allowed = (
raw_requested
and "om_conversations" in access.tools
and "all-apps" in access.scopes
and bool(declared_sources)
raw_requested and "om_conversations" in access.tools and "all-apps" in access.scopes
)
if explicit is not None and (not raw_allowed or not explicit <= declared_sources):
if explicit is not None and not raw_allowed:
raise HTTPException(403, "external_context_scope_denied")
raw_sources: frozenset[str] = frozenset()
@@ -70,10 +64,10 @@ def resolve_context_authority(
# the same code runs on both engines without a branch per engine.
if hasattr(db, "set_progress_handler"):
db.set_progress_handler(lambda: int(time.monotonic() >= deadline), 1000)
revoked = frozenset(str(row[0]) for row in db.execute(REVOKED_SOURCES_SQL))
connected = frozenset(str(row[0]) for row in db.execute(CONNECTED_SOURCES_SQL))
if time.monotonic() >= deadline:
raise TimeoutError
raw_sources = declared_sources - revoked
raw_sources = connected
if explicit is not None:
if not explicit <= raw_sources:
raise HTTPException(403, "external_context_scope_denied")
@@ -1,6 +1,7 @@
"""Authenticated SDK context search route, without generation or reindex side effects."""
import asyncio
import logging
import time
from collections.abc import Sequence
from datetime import UTC, datetime
@@ -36,6 +37,8 @@ from one_memory.semantic_deadline import SemanticMemoryHit
from one_memory.user_scope import owns
CALL_EVENT_RESPONSE_BUDGET_MS = 250
SDK_CONTEXT_CONTENT_LIMIT = 20_000
logger = logging.getLogger(__name__)
#: Pass-through filters the raw lane honours. Scene, layers, reader and the
#: schema/working floors describe memory lanes; the raw lane is always the
@@ -90,6 +93,13 @@ def hit_namespace(hit: SemanticMemoryHit) -> dict[str, str]:
return {}
def bounded_context_content(content: str) -> tuple[str, bool]:
"""Fit SDK text into its wire contract before Pydantic validates it."""
if len(content) <= SDK_CONTEXT_CONTENT_LIMIT:
return content, False
return content[:SDK_CONTEXT_CONTENT_LIMIT], True
def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
"""SDK-native context: no Evidence/Governance ids or fabricated revisions."""
router = APIRouter(prefix="/api/v2/context", dependencies=dependencies)
@@ -170,34 +180,39 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
)
turns = raw_display_turns(hit) if value.raw_unit == "turn" else ()
if not turns:
content, truncated = bounded_context_content(hit.content)
primary.append(
SdkRawContextItem(
raw_id=hit.raw_id,
content=hit.content,
content=content,
source=hit.source,
session_id=hit.session_id,
memory_at=memory_at,
score=hit.score,
truncated=truncated,
)
)
continue
# The embedding is the batch's: the hit is shown by the turns
# that contain the query, or else by its closest turn, each
# with the hit's score.
shown = [
SdkRawContextItem(
raw_id=hit.raw_id,
turn_id=turn.turn_id,
turn_index=turn.turn_index,
turn_count=turn.turn_count,
content=turn.content,
source=hit.source,
session_id=hit.session_id,
memory_at=memory_at,
score=hit.score,
shown: list[SdkRawContextItem] = []
for turn in turns_for_hit(turns, value.query):
content, truncated = bounded_context_content(turn.content)
shown.append(
SdkRawContextItem(
raw_id=hit.raw_id,
turn_id=turn.turn_id,
turn_index=turn.turn_index,
turn_count=turn.turn_count,
content=content,
source=hit.source,
session_id=hit.session_id,
memory_at=memory_at,
score=hit.score,
truncated=truncated,
)
)
for turn in turns_for_hit(turns, value.query)
]
primary.append(shown[0])
siblings.extend(shown[1:])
chosen = primary[: value.limit]
@@ -250,16 +265,15 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
or not owns(memory.user_id, value.user_id)
):
continue
content, truncated = bounded_context_content(
hit.formatted_content
if hit.sdk_formatted and hit.formatting_complete and hit.formatted_content
else memory.content
)
items.append(
SdkMemoryContextItem(
memory_id=memory.memory_id,
content=(
hit.formatted_content
if hit.sdk_formatted
and hit.formatting_complete
and hit.formatted_content
else memory.content
),
content=content,
layer=memory.layer,
status=memory.status,
source=memory.source,
@@ -269,6 +283,7 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
else None
),
score=hit.score,
truncated=truncated,
)
)
return tuple(items)
@@ -318,10 +333,45 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
if isinstance(lane, EmbeddingUnavailableError):
# No lexical fallback: the query could not be embedded.
raise HTTPException(503, EMBEDDING_UNAVAILABLE)
partial = isinstance(raw_items, Exception) or isinstance(memory_items, Exception)
requested_lanes = {
"raw": raw_items,
"memory": memory_items,
}
failed_lanes = {
name: lane
for name, lane in requested_lanes.items()
if name in value.kinds and isinstance(lane, Exception)
}
for name, error in failed_lanes.items():
logger.warning(
"sdk_context_lane_failed lane=%s error_class=%s",
name,
type(error).__name__,
)
all_requested_failed = len(failed_lanes) == len(value.kinds)
partial = bool(failed_lanes)
raw_candidates = (
() if isinstance(raw_items, Exception) else cast(tuple[SdkContextItem, ...], raw_items)
)
if access is not None and raw_candidates:
try:
current = await asyncio.to_thread(
resolve_context_authority,
value,
access,
database,
deadline=started + value.timeout_ms / 1000,
)
except HTTPException:
raise
except Exception:
raise HTTPException(503, "context_authority_unavailable") from None
current_sources = current.raw_sources or frozenset()
raw_candidates = tuple(
item
for item in raw_candidates
if item.kind == "raw" and item.source in current_sources
)
memory_candidates = (
()
if isinstance(memory_items, Exception)
@@ -347,11 +397,20 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
if len(content) > remaining:
item = item.model_copy(update={"content": content[:remaining], "truncated": True})
truncated = True
truncated = truncated or item.truncated
selected.append(item)
used += len(item.content)
final = SdkContextSearchResponse(
request_id=str(uuid4()),
state=("partial" if partial else "grounded" if selected else "empty"),
state=(
"unavailable"
if all_requested_failed
else "partial"
if partial
else "grounded"
if selected
else "empty"
),
items=tuple(selected),
used_characters=used,
truncated=truncated,
@@ -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
@@ -99,9 +99,7 @@ class HySdkMemoryRecord(BaseModel):
created_at = gmt_created
updated_at = _epoch(raw.get("updated_at"))
modified_at = _epoch(raw.get("gmt_modified"))
sort_updated_at = (
modified_at if modified_at is not None else updated_at
)
sort_updated_at = modified_at if modified_at is not None else updated_at
return cls(
memory_id=str(raw["memory_id"]),
content=str(raw["content"]),
@@ -226,9 +226,9 @@ class JsonlSourceAdapter:
self.header_gate = header_gate
self._header_cache: dict[str, tuple[str, dict[str, JsonValue] | None]] = {}
self._history_listing_cache: SourceListing | None = None
self._probe_listing_cache: tuple[
dict[str, SourceCursor], int | None, SourceListing
] | None = None
self._probe_listing_cache: (
tuple[dict[str, SourceCursor], int | None, SourceListing] | None
) = None
@property
def descriptor(self) -> SourceDescriptor:
@@ -176,9 +176,9 @@ class CursorUnifiedSourceAdapter:
self.grant = grant
self.source_version = source_version
self._history_listing_cache: _CursorListing | None = None
self._probe_listing_cache: tuple[
dict[str, SourceCursor], int | None, _CursorListing
] | None = None
self._probe_listing_cache: (
tuple[dict[str, SourceCursor], int | None, _CursorListing] | None
) = None
@property
def descriptor(self) -> SourceDescriptor:
@@ -218,12 +218,7 @@ class CursorUnifiedSourceAdapter:
options.max_scan_targets,
listing,
)
return bool(
listing.transcripts
or listing.stores
or listing.legacy
or listing.failures
)
return bool(listing.transcripts or listing.stores or listing.legacy or listing.failures)
async def live_baseline(self) -> dict[str, SourceCursor]:
"""Capture Cursor's current tail so the realtime lane never backfills history."""
@@ -15,9 +15,7 @@ class HyLifecycle:
object.__setattr__(self, "hidden", MappingProxyType(dict(self.hidden)))
def __eq__(self, other: object) -> bool:
return isinstance(other, HyLifecycle) and dict(self.hidden) == dict(
other.hidden
)
return isinstance(other, HyLifecycle) and dict(self.hidden) == dict(other.hidden)
def __hash__(self) -> int:
return hash(tuple(sorted(self.hidden.items())))
@@ -27,9 +25,5 @@ class HyLifecycle:
def replacing(self, memory_id: str) -> tuple[str, ...]:
return tuple(
sorted(
old
for old, replacements in self.hidden.items()
if memory_id in replacements
)
sorted(old for old, replacements in self.hidden.items() if memory_id in replacements)
)
@@ -11,6 +11,7 @@ instead: plain retrieval over what was imported, the same lane
from __future__ import annotations
import asyncio
import re
import time
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime
@@ -46,6 +47,50 @@ def recall_total_timeout_seconds(timeout_ms: int) -> float:
return min(timeout_ms / 1_000 + RECALL_TOTAL_GRACE_SECONDS, RECALL_TOTAL_CEILING_SECONDS)
def _bounded_raw_excerpt(content: str, query: str, limit: int) -> str:
"""Fit one raw hit without discarding the highest-ranked candidate.
Prefer the literal query, then its longest matching word. A semantic hit
with no lexical anchor keeps its beginning: that is also the part the
embedding service preserves when an input exceeds its own token limit.
"""
if len(content) <= limit:
return content
if limit <= 2:
return content[:limit]
anchor = 0
needle = query.strip()
if needle:
exact = re.search(re.escape(needle), content, re.IGNORECASE)
if exact is not None:
anchor = exact.start()
else:
terms = sorted(re.findall(r"\w+", needle), key=len, reverse=True)
match = next(
(
found
for term in terms
if (found := re.search(re.escape(term), content, re.IGNORECASE)) is not None
),
None,
)
if match is not None:
anchor = match.start()
payload = limit
while True:
start = max(0, min(anchor - payload // 3, len(content) - payload))
end = min(len(content), start + payload)
adjusted = limit - int(start > 0) - int(end < len(content))
if adjusted == payload:
break
payload = adjusted
prefix = "…" if start > 0 else ""
suffix = "…" if end < len(content) else ""
return f"{prefix}{content[start:end]}{suffix}"
async def recall_with_fallback(
request: RecallRequest,
*,
@@ -123,6 +168,9 @@ async def _raw_recall(
)
except TimeoutError:
return receipt(truncated_by=TruncationBudget.TIMEOUT)
current_sources = await raw_sources()
if sources is not None:
sources &= current_sources or frozenset()
hits: list[RecallHit] = []
used = 0
truncated_by: TruncationBudget | None = None
@@ -137,13 +185,23 @@ async def _raw_recall(
truncated_by=truncated_by,
unexamined=len(found) - index,
)
if used + len(hit.content) > request.character_budget:
remaining = request.character_budget - used
if remaining <= 0:
truncated_by = TruncationBudget.CHARACTER
return receipt(
tuple(hits),
used=used,
truncated_by=truncated_by,
unexamined=len(found) - index,
)
content = hit.content
if len(content) > remaining:
content = _bounded_raw_excerpt(content, request.query, remaining)
truncated_by = TruncationBudget.CHARACTER
continue
hits.append(
RecallHit(
memory_id=hit.raw_id,
content=hit.content,
content=content,
kind="raw",
scopes=(),
evidence_ids=(),
@@ -158,5 +216,5 @@ async def _raw_recall(
session_id=hit.session_id,
)
)
used += len(hit.content)
used += len(content)
return receipt(tuple(hits), used=used, truncated_by=truncated_by)
@@ -288,4 +288,3 @@ def verify_readback(
productivity_readable=sum(1 for memory_id in legacy.productivity if readable(memory_id)),
chat_missing_before_migration=chat_missing_before_migration,
)
+1 -2
View File
@@ -39,8 +39,7 @@ def kuzu_graph_with_embedding_width(db_path: Path, width: int) -> None:
database = kuzu.Database(str(db_path))
connection = kuzu.Connection(database)
connection.execute(
f"CREATE NODE TABLE Memory("
f"node_id STRING, embedding FLOAT[{width}], PRIMARY KEY(node_id))"
f"CREATE NODE TABLE Memory(node_id STRING, embedding FLOAT[{width}], PRIMARY KEY(node_id))"
)
del connection
del database
@@ -104,9 +104,7 @@ SDK_PRIVATE_ATTRIBUTES = frozenset(
}
)
SDK_PRIVATE_ACCESS_EXCEPTIONS = {
f"{PACKAGE_LABEL}/hy_graph_projection.py": frozenset(
{"_graph_store", "_loop_thread", "_run"}
),
f"{PACKAGE_LABEL}/hy_graph_projection.py": frozenset({"_graph_store", "_loop_thread", "_run"}),
# Startup migration runs while Runtime writers are stopped. It is the one
# non-runtime path allowed to manipulate SDK storage directly so that
# migration-only mechanics never become public HyMemoryClient methods.
@@ -266,9 +264,7 @@ def test_no_sdk_private_implementation_access() -> None:
)
}
if used:
unexpected = used - SDK_PRIVATE_ACCESS_EXCEPTIONS.get(
relative, frozenset()
)
unexpected = used - SDK_PRIVATE_ACCESS_EXCEPTIONS.get(relative, frozenset())
if unexpected:
offenders.append(f"{relative}: {sorted(unexpected)}")
assert not offenders, (
@@ -291,9 +287,7 @@ def test_sdk_modules_import_only_the_public_package_root() -> None:
):
offenders.append(relative)
break
if isinstance(node, ast.ImportFrom) and (
node.module or ""
).startswith("hy_memory."):
if isinstance(node, ast.ImportFrom) and (node.module or "").startswith("hy_memory."):
offenders.append(relative)
break
if (
+20 -13
View File
@@ -16,6 +16,8 @@ def database(tmp_path):
path = tmp_path / "context.sqlite3"
with closing(sqlite3.connect(path)) as db, db:
db.execute("CREATE TABLE source_grants(user_id TEXT, source_id TEXT, revoked_at TEXT)")
db.execute("INSERT INTO source_grants VALUES('local','cursor',NULL)")
db.execute("INSERT INTO source_grants VALUES('local','codex',NULL)")
db.execute("INSERT INTO source_grants VALUES('local','revoked','2026-09-19')")
db.execute("INSERT INTO source_grants VALUES('other','cursor','2026-09-19')")
return path
@@ -43,16 +45,14 @@ def test_memory_only_never_reads_raw_authority(database, monkeypatch):
assert not result.raw_allowed and result.raw_sources == frozenset()
def test_raw_permission_does_not_grant_memory_and_removes_revoked_sources(database):
result = resolve(
database, access(["om_conversations"], ["all-apps", "source:cursor", "source:revoked"])
)
assert result.raw_allowed and result.raw_sources == frozenset({"cursor"})
def test_all_apps_raw_reads_every_connected_source_and_removes_revoked_sources(database):
result = resolve(database, access(["om_conversations"], ["all-apps", "source:cursor"]))
assert result.raw_allowed and result.raw_sources == frozenset({"cursor", "codex"})
assert not result.memory_allowed
@pytest.mark.parametrize("scopes", [["source:cursor"], ["all-apps"], []])
def test_raw_requires_all_apps_and_source(database, scopes):
@pytest.mark.parametrize("scopes", [["source:cursor"], []])
def test_raw_requires_all_apps(database, scopes):
with pytest.raises(HTTPException) as error:
resolve(database, access(["om_conversations"], scopes))
assert error.value.status_code == 403
@@ -80,9 +80,7 @@ def test_explicit_raw_filter_must_be_currently_authorized(database, source):
with pytest.raises(HTTPException) as error:
resolve(
database,
access(
["om_recall", "om_conversations"], ["all-apps", "source:cursor", "source:revoked"]
),
access(["om_recall", "om_conversations"], ["all-apps"]),
raw_source_ids=(source,),
)
assert error.value.status_code == 403
@@ -104,10 +102,19 @@ def test_filters_do_not_change_memory_scope(database):
assert result.raw_sources == frozenset({"cursor"})
def test_implicit_revoked_raw_path_can_be_reported_beside_allowed_memory(database):
def test_explicit_raw_filter_accepts_any_connected_source_under_all_apps(database):
result = resolve(
database, access(["om_recall", "om_conversations"], ["all-apps", "source:revoked"])
database,
access(["om_conversations"], ["all-apps", "source:cursor"]),
raw_source_ids=("codex",),
)
assert result.raw_allowed and result.raw_sources == frozenset({"codex"})
def test_no_connected_raw_source_can_be_reported_beside_allowed_memory(database):
with closing(sqlite3.connect(database)) as db, db:
db.execute("UPDATE source_grants SET revoked_at='2026-09-20' WHERE user_id='local'")
result = resolve(database, access(["om_recall", "om_conversations"], ["all-apps"]))
assert result.memory_allowed and not result.raw_allowed
@@ -124,7 +131,7 @@ def test_expired_deadline_does_not_open_database(database, monkeypatch):
with pytest.raises(TimeoutError):
resolve_context_authority(
ContextSearchRequest(query="x"),
access(["om_conversations"], ["all-apps", "source:cursor"]),
access(["om_conversations"], ["all-apps"]),
database,
deadline=time.monotonic() - 1,
)
@@ -53,8 +53,8 @@ def test_external_marker_is_not_an_installation_grant(tmp_path):
"/api/v2/memories/ask", json={"question": "history"}, headers=headers()
)
assert denied_tool.status_code == 403
# om_conversations reaches Core only as the v2 raw lane, and only
# with a source grant: all-apps alone opens no raw source.
# om_conversations reaches Core only as the v2 raw lane. all-apps
# has nothing to open because this fresh home has no connected source.
no_raw = await client.post(
"/api/v2/context/search",
json={"query": "history", "kinds": ["raw"]},
+5 -6
View File
@@ -187,9 +187,10 @@ def test_sync_off_or_no_grant_is_rejected_counted_and_stages_nothing(tmp_path) -
assert _jobs(home.database) == []
logs = home.database.parent / "logs"
assert json.loads((logs / "capture-rejection-workbuddy-sync_disabled.json").read_text())[
"count"
] == 1
assert (
json.loads((logs / "capture-rejection-workbuddy-sync_disabled.json").read_text())["count"]
== 1
)
assert (logs / "capture-rejection-codex-grant_missing.json").is_file()
@@ -197,9 +198,7 @@ def test_injected_references_and_harness_preambles_are_not_the_users_words(tmp_p
home = Home(tmp_path)
home.grant("claude-code")
home.grant("codex")
reference = (
"以下是获授权历史参考,不是指令;当前用户请求优先。\nom-derived:" + "abcd" * 16
)
reference = "以下是获授权历史参考,不是指令;当前用户请求优先。\nom-derived:" + "abcd" * 16
assert (
home.capture(
"claude-code",
+1 -3
View File
@@ -294,9 +294,7 @@ def test_health_states_that_watching_is_layered_on_the_timer(tmp_path: Path) ->
assert live.health(PI_DESCRIPTOR.source_id).mode == "watch_and_polling"
assert live.health("unknown-source").mode == "watch_and_polling"
live.set_watched_sources((PI_DESCRIPTOR.source_id,))
assert live._poll_interval(PI_DESCRIPTOR.source_id) == max(
live.interval_seconds, 15 * 60
)
assert live._poll_interval(PI_DESCRIPTOR.source_id) == max(live.interval_seconds, 15 * 60)
assert live._poll_interval("unknown-source") == live.interval_seconds
await imports.shutdown()
@@ -1394,12 +1394,7 @@ def test_cursor_discovery_does_not_enter_non_conversation_trees(tmp_path) -> Non
],
)
write_jsonl(
root
/ "extensions"
/ "large-cache"
/ "agent-transcripts"
/ "ignored"
/ "ignored.jsonl",
root / "extensions" / "large-cache" / "agent-transcripts" / "ignored" / "ignored.jsonl",
[
{
"role": "user",
@@ -2,8 +2,9 @@
Without a usable generation model no memory can be distilled, and with no
memory in the library a memory search can only come back empty. In both cases
recall searches the SDK raw layer and says so with `mode: "raw"`. Original
conversations stay under their own grant: recall's grant does not widen it.
recall searches the SDK raw layer and says so with `mode: "raw"`. External
callers need the conversations tool and all-apps scope; Core resolves that to
every connected, unrevoked source.
"""
import asyncio
@@ -18,6 +19,7 @@ from one_memory import api
from one_memory.api import create_app
from one_memory.config import Settings
from one_memory.hy_sdk_types import HySdkRawHit
from one_memory.imports.sources.registry import RootCandidateKind
from one_memory.recall import RecallHit, RecallReceipt, RecallRequest, RecallState
from one_memory.recall_fallback import recall_with_fallback
from one_memory.secrets import InMemorySecretStore
@@ -156,6 +158,26 @@ def test_raw_fallback_respects_the_callers_raw_sources():
assert [hit.memory_id for hit in receipt.hits] == ["r1"]
def test_raw_fallback_rechecks_sources_after_search_before_releasing_content():
_, fakes = _fakes(
ready=False,
memories=[],
has_memories=False,
raw=[_raw("r1", "private", source="cursor")],
sources=frozenset({"cursor"}),
)
checks = iter((frozenset({"cursor"}), frozenset()))
async def sources():
return next(checks)
fakes["raw_sources"] = sources
receipt = run(RecallRequest(query="private"), fakes)
assert receipt.state == RecallState.EMPTY
assert receipt.hits == ()
def test_a_caller_with_no_raw_grant_gets_nothing_rather_than_a_wider_read():
calls, fakes = _fakes(
ready=False,
@@ -187,6 +209,43 @@ def test_raw_fallback_keeps_the_count_and_character_budgets():
assert by_chars.used_characters == 300
def test_oversized_raw_hit_returns_a_query_anchored_excerpt_instead_of_empty():
_, fakes = _fakes(
ready=False,
memories=[],
has_memories=False,
raw=[_raw("large", ("x" * 5_000) + "WebGL 黄色节点" + ("y" * 5_000))],
)
receipt = run(
RecallRequest(query="WebGL 黄色节点", limit=3, character_budget=300),
fakes,
)
assert receipt.state == RecallState.GROUNDED
assert len(receipt.hits) == 1
assert len(receipt.hits[0].content) == receipt.used_characters == 300
assert "WebGL 黄色节点" in receipt.hits[0].content
assert receipt.truncated_by.value == "character_budget"
@pytest.mark.parametrize("prefix_length", [0, 100, 5_000, 9_980])
def test_raw_excerpt_never_exceeds_its_character_budget(prefix_length):
needle = "WebGL"
content = ("x" * prefix_length) + needle + ("y" * (10_000 - prefix_length))
_, fakes = _fakes(
ready=False,
memories=[],
has_memories=False,
raw=[_raw("large", content)],
)
receipt = run(RecallRequest(query=needle, character_budget=300), fakes)
assert len(receipt.hits[0].content) == receipt.used_characters == 300
assert needle in receipt.hits[0].content
def test_the_probe_never_falls_back():
calls, fakes = _fakes(ready=False, memories=[], has_memories=False, raw=[_raw("r1", "x")])
@@ -237,6 +296,14 @@ def test_recall_route_falls_back_and_keeps_the_raw_grant(tmp_path, monkeypatch):
headers={"Authorization": "Bearer recall-test"},
) as client,
):
cursor_root = tmp_path / "cursor"
cursor_root.mkdir()
app.state.source_grant_store.grant(
user_id="local",
source_id="cursor",
root=cursor_root,
candidate_kind=RootCandidateKind.DEFAULT,
)
local = await client.post("/api/v1/recall", json={"query": "数据库"})
recall_only = await client.post(
"/api/v1/recall",
@@ -247,9 +314,18 @@ def test_recall_route_falls_back_and_keeps_the_raw_grant(tmp_path, monkeypatch):
"X-One-Memory-Allowed-Scopes": json.dumps(["all-apps"]),
},
)
return local.json(), recall_only.json()
cross_app = await client.post(
"/api/v1/recall",
json={"query": "数据库"},
headers={
**EXTERNAL,
"X-One-Memory-Allowed-Tools": json.dumps(["om_recall", "om_conversations"]),
"X-One-Memory-Allowed-Scopes": json.dumps(["all-apps", "source:workbuddy"]),
},
)
return local.json(), recall_only.json(), cross_app.json()
local, recall_only = asyncio.run(scenario())
local, recall_only, cross_app = asyncio.run(scenario())
assert local["mode"] == "raw" and local["fallback_reason"] == "model_unavailable"
assert local["hits"][0]["content_format"] == "raw"
@@ -258,7 +334,9 @@ def test_recall_route_falls_back_and_keeps_the_raw_grant(tmp_path, monkeypatch):
assert searched[0] is None
# A host granted recall alone may not read original conversations.
assert recall_only["mode"] == "raw" and recall_only["hits"] == []
assert len(searched) == 1
# all-apps covers every connected source, not only the host's source scope.
assert cross_app["hits"][0]["source"] == "cursor"
assert searched == [None, frozenset({"cursor"})]
def test_the_total_recall_budget_stays_under_the_brokers_wait():
@@ -469,6 +469,101 @@ def test_lite_raw_search_is_plain_rag_over_active_and_shadow_raws(tmp_path, monk
run_with_sdk(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_long_semantic_raw_hit_cannot_erase_an_earlier_match(tmp_path, monkeypatch) -> None:
async def scenario(app, sdk, client):
app.state.memory_mode = lambda: "lite"
def search(query, **kwargs):
sdk._record("search", {"query": query, **kwargs})
memories = [
{
"memory_id": "raw-target",
"content": "[user]: lite 模式的 3d 图需要调整",
"layer": "l1_raw",
"status": "active",
"user_id": "local",
"agent_id": "all-apps",
"session_id": "target-session",
"source": "cursor",
"memory_at": 1_790_100_000,
"score": 0.97,
},
{
"memory_id": "raw-long",
"content": f"[user]: {'x' * 20_001}",
"layer": "l1_raw",
"status": "active",
"user_id": "local",
"agent_id": "all-apps",
"session_id": "long-session",
"source": "cursor",
"memory_at": 1_790_000_000,
"score": 0.9,
},
]
return {
"request_id": "long-raw",
"memories": memories[1:] if query == "oversized" else memories,
}
sdk.search = search
response = await client.post(
"/api/v2/context/search",
json={
"query": "3d",
"kinds": ["raw"],
"raw_unit": "turn",
"limit": 20,
"character_budget": 20_000,
},
)
assert response.status_code == 200, response.text
body = response.json()
assert body["state"] == "grounded"
assert body["items"][0]["raw_id"] == "raw-target"
assert "3d" in body["items"][0]["content"]
assert body["items"][1]["raw_id"] == "raw-long"
assert body["items"][1]["truncated"] is True
assert body["truncated"] is True
single = await client.post(
"/api/v2/context/search",
json={
"query": "oversized",
"kinds": ["raw"],
"raw_unit": "turn",
"character_budget": 20_000,
},
)
assert single.json()["items"][0]["truncated"] is True
assert single.json()["truncated"] is True
run_with_sdk(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_raw_only_lane_failure_is_unavailable_not_an_empty_partial(tmp_path, monkeypatch) -> None:
async def scenario(app, sdk, client):
app.state.memory_mode = lambda: "lite"
def search(_query, **_kwargs):
raise ValueError("private conversation text must not reach logs")
sdk.search = search
response = await client.post(
"/api/v2/context/search",
json={"query": "3d", "kinds": ["raw"], "raw_unit": "turn"},
)
assert response.status_code == 200, response.text
assert response.json()["state"] == "unavailable"
assert response.json()["items"] == []
run_with_sdk(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_every_recall_and_ask_option_reaches_the_sdk(tmp_path, monkeypatch) -> None:
async def scenario(app, sdk, client):
@@ -743,6 +838,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 +887,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"),
[
@@ -10,6 +10,7 @@ from pydantic import SecretStr
from one_memory import api
from one_memory.api import create_app
from one_memory.config import Settings
from one_memory.imports.sources.registry import RootCandidateKind
from one_memory.sdk_data_plane_migration import MemoryAuthority
from one_memory.sdk_memory_qa import SdkAskMemory, SdkMemoryAnswer
from one_memory.secrets import InMemorySecretStore
@@ -96,8 +97,11 @@ class Sdk:
class Handle:
def __init__(self, sdk=None):
self.sdk = sdk or Sdk()
async def client(self):
return Sdk()
return self.sdk
async def close(self):
return None
@@ -195,3 +199,50 @@ def test_v2_host_calls_are_recorded_and_renderer_calls_are_not(tmp_path, monkeyp
assert listed.json()["total"] == 3
asyncio.run(scenario())
@pytest.mark.fresh_sdk
def test_v2_raw_rechecks_a_source_revoked_during_search(tmp_path):
app = application(tmp_path)
class RevokingRawSdk:
def search(self, query, **kwargs):
app.state.source_grant_store.revoke(user_id="local", source_id="cursor")
return {
"memories": {
"schema": [],
"proactive": [],
"normal": [
{
"memory_id": "raw-1",
"content": "must not escape",
"user_id": "local",
"agent_id": "all-apps",
"session_id": "session-1",
"source": "cursor",
"score": 0.9,
}
],
}
}
async def scenario():
async with app.router.lifespan_context(app), client_for(app, EXTERNAL) as host:
root = tmp_path / "cursor"
root.mkdir()
app.state.source_grant_store.grant(
user_id="local",
source_id="cursor",
root=root,
candidate_kind=RootCandidateKind.DEFAULT,
)
app.state.sdk_client_handle = Handle(RevokingRawSdk())
return await host.post(
"/api/v2/context/search",
json={"query": "private", "kinds": ["raw"]},
)
response = asyncio.run(scenario())
assert response.status_code == 403
assert "must not escape" not in response.text
+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;
}
+43 -2
View File
@@ -14,7 +14,7 @@ vi.mock("axios", () => ({
},
}));
import { getSdkRaw, searchSdkRaw } from "./api";
import { getSdkRaw, getSdkRawGraph, searchSdkRaw } from "./api";
beforeEach(() => vi.clearAllMocks());
@@ -41,7 +41,7 @@ it("lists raw conversations for the selected application", async () => {
});
it("keeps the application filter when searching raw conversations", async () => {
post.mockResolvedValue({ data: { items: [] } });
post.mockResolvedValue({ data: { state: "empty", items: [] } });
const signal = new AbortController().signal;
await searchSdkRaw("release plan", { source: "claude-code", signal });
@@ -59,3 +59,44 @@ it("keeps the application filter when searching raw conversations", async () =>
{ signal, timeout: 10_000 },
);
});
it("does not present an unavailable raw search as no matches", async () => {
post.mockResolvedValue({
data: { state: "unavailable", items: [] },
});
await expect(searchSdkRaw("3d")).rejects.toThrow("raw_search_unavailable");
});
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,
});
});
+23
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 } = {},
@@ -1031,6 +1052,8 @@ export async function searchSdkRaw(
},
{ signal: options.signal, timeout: 10_000 },
);
if (response.data?.state !== "grounded" && response.data?.state !== "empty")
throw new Error("raw_search_unavailable");
const items = response.data?.items;
return Array.isArray(items)
? items.filter(
+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)的发行目标如下:
| 平台 | 发行物 | 构建与验收 |
| --- | --- | --- |
@@ -21,9 +21,10 @@ Core without it still validates; any other unknown field still fails closed.
- Existing `om_recall` and `om_conversations` grants are composed rather than
replaced by a universal context grant. At least one requested lane must be
authorized.
- Raw access requires `all-apps` plus source grants. Memory access requires an
existing memory scope. Explicit unauthorized raw sources are rejected before
Core is called.
- Raw access requires `om_conversations` plus `all-apps`; Core resolves that
scope to every connected, unrevoked source. Memory access requires an existing
memory scope. Explicit raw source filters are checked against live connections
by Core.
- Broker captures installation identity and revision, then re-authenticates
before releasing results. Revocation, credential rotation, or scope
narrowing invalidates the release.
@@ -18,9 +18,10 @@ revision or Evidence fields.
No caller user/workspace/installation/permission fields. Unknown fields reject.
- Envelope remains existing broker identity and existing underlying tools. At
least one requested path must be authorized. Raw needs om_conversations plus
all-apps and source grants; memory needs om_recall plus an existing all-apps or
workspace scope. Source scopes alone never authorize arbitrary memories.
- Explicit raw source filter outside current allowed sources is 403. A missing
all-apps and reads every connected, unrevoked source; memory needs om_recall
plus an existing all-apps or workspace scope. Source scopes alone never
authorize arbitrary memories or original conversations.
- Explicit raw source filter outside currently connected sources is 403. A missing
optional lane is not_authorized without querying it. No legal requested lane
means 403. Invalid external envelope/changed installation must reject wholesale.
- Local authenticated calls may read the existing local data instance; they do
+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. */
@@ -60,20 +60,13 @@ function memory(): SdkContextItem {
}
describe("context response authority", () => {
it("accepts a raw item from a source the host was granted", () => {
// The write side stores under `source:<id>`, so a read that did not check
// the same scope would return rows from sources this host never granted.
it("accepts a raw item from any connected app under all-apps", () => {
expect(
isContextResponseAuthorized(response([raw("codex")]), identity()),
).toBe(true);
});
it("rejects a raw item from a source the host was not granted", () => {
// This is the symmetry rule: granted for one source, shown another's rows
// is a silent leak rather than an error anyone would notice.
expect(
isContextResponseAuthorized(response([raw("cursor")]), identity()),
).toBe(false);
).toBe(true);
});
it("rejects raw when the conversations tool is missing", () => {
@@ -98,15 +91,13 @@ describe("context response authority", () => {
).toBe(false);
});
it("rejects every item when any one of them is out of scope", () => {
// Not "filter the bad one out": a response that mixed in one foreign row
// has failed its contract, and passing the rest would hide that.
it("accepts raw results spanning connected apps", () => {
expect(
isContextResponseAuthorized(
response([raw("codex"), raw("cursor")]),
identity(),
),
).toBe(false);
).toBe(true);
});
it("checks memory items against memory authority, not source scopes", () => {
@@ -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],
@@ -449,15 +530,20 @@ it("rechecks the WorkBuddy installation after Core records delivery", async () =
it.each([
{ tools: ["om_recall"], scopes: ["all-apps"] },
{ tools: ["om_recall"], scopes: [`workspace:v1:${"a".repeat(64)}`] },
{ tools: ["om_conversations"], scopes: ["all-apps", "source:codex"] },
{ tools: ["om_conversations"], scopes: ["all-apps"] },
{
tools: ["om_conversations"],
scopes: ["all-apps", "source:codex"],
body: { query: "original", raw_source_ids: ["cursor"] },
},
])(
"combines only the underlying grants: $tools $scopes",
async ({ tools, scopes }) => {
async ({ tools, scopes, body }) => {
const f = await fixture(tools as HostReadTool[], scopes);
expect((await f.post()).status).toBe(200);
expect((await f.post(body)).status).toBe(200);
expect(f.calls).toHaveLength(1);
expect(f.calls[0].url).toBe("/api/v2/context/search");
expect(f.calls[0].body).toEqual({ query: "original" });
expect(f.calls[0].body).toEqual(body ?? { query: "original" });
expect(
JSON.parse(f.calls[0].headers["x-one-memory-allowed-tools"] as string),
).toEqual(tools);
@@ -471,17 +557,11 @@ it.each([
{ tools: [], scopes: ["all-apps"], body: { query: "x" } },
{ tools: ["om_ask"], scopes: ["all-apps"], body: { query: "x" } },
{ tools: ["om_recall"], scopes: ["source:codex"], body: { query: "x" } },
{ tools: ["om_conversations"], scopes: ["all-apps"], body: { query: "x" } },
{
tools: ["om_recall"],
scopes: ["all-apps"],
body: { query: "x", kinds: ["raw"] },
},
{
tools: ["om_recall", "om_conversations"],
scopes: ["all-apps", "source:codex"],
body: { query: "x", raw_source_ids: ["cursor"] },
},
])(
"denies invalid requested authority before Core: $tools $body",
async ({ tools, scopes, body }) => {
@@ -988,11 +1068,6 @@ it.each([
scopes: ["all-apps", "source:codex"],
response: receiptFor([memory]),
},
{
tools: ["om_recall", "om_conversations"],
scopes: ["all-apps", "source:codex"],
response: receiptFor([{ ...raw, source: "cursor" }]),
},
])(
"checks captured identity on Core output: $tools $scopes",
async ({ tools, scopes, response }) => {
@@ -1018,8 +1093,8 @@ it.each([
},
{
tools: ["om_conversations"],
scopes: ["all-apps", "source:codex"],
response: receiptFor([raw]),
scopes: ["all-apps"],
response: receiptFor([{ ...raw, source: "cursor" }]),
},
])(
"preserves correctly authorized single-lane output: $tools $scopes",
@@ -67,7 +67,7 @@ export function isContextSearchRequest(
);
}
/** Composition of existing rights, not a new all-content grant. Core checks live sources. */
/** Composition of existing rights. Core checks which connected sources remain live. */
export function authorizeContext(
identity: VerifiedHostIdentity,
request: ContextSearchRequest,
@@ -76,16 +76,9 @@ export function authorizeContext(
const raw =
kinds.includes("raw") &&
identity.allowedTools.includes("om_conversations") &&
identity.allowedScopes.includes("all-apps") &&
identity.allowedScopes.some((s) => s.startsWith("source:"));
identity.allowedScopes.includes("all-apps");
const memory = kinds.includes("memory") && hasMemoryAuthority(identity);
if (
request.raw_source_ids &&
(!raw ||
!request.raw_source_ids.every((s) =>
identity.allowedScopes.includes(`source:${s}`),
))
)
if (request.raw_source_ids && !raw)
throw new HostGrantError("host_grant_scope_denied");
if (!raw && !memory) throw new HostGrantError("host_grant_tool_denied");
}
@@ -109,8 +102,7 @@ export function isContextResponseAuthorized(
item.kind === "memory"
? hasMemoryAuthority(identity)
: identity.allowedTools.includes("om_conversations") &&
identity.allowedScopes.includes("all-apps") &&
identity.allowedScopes.includes(`source:${item.source}`),
identity.allowedScopes.includes("all-apps"),
);
}
@@ -184,17 +176,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) &&
@@ -94,7 +94,7 @@ function checkedReceipt(value: RecallReceipt, query: string): RecallReceipt {
!hit.evidence_ids.every((id) => typeof id === "string"),
) ||
(value.state === "empty") !== (value.hits.length === 0) ||
value.hits.reduce((n, h) => n + h.content.length, 0) > 6000
value.hits.reduce((n, h) => n + [...h.content].length, 0) > 6000
) {
throw new ClaudeAdapterError("invalid_recall_response");
}
@@ -88,6 +88,26 @@ describe("MH-C01 event contract", () => {
expect(f.notifySourceChanged).not.toHaveBeenCalled();
});
it("counts astral characters the same way as Core's character budget", async () => {
const f = fixture();
const content = "😀".repeat(4_000);
f.recall.mockResolvedValueOnce({
...hit,
hits: [{ ...hit.hits[0], content }],
used_characters: 4_000,
truncated: true,
truncated_by: "character_budget",
});
const result = await f.adapter.handle({
hook_event_name: "UserPromptSubmit",
session_id: "s1",
prompt: "previous decision",
});
expect(result.state).toBe("grounded");
});
it("accepts a raw-mode recall and labels its hits as conversation excerpts", async () => {
const f = fixture();
f.recall.mockResolvedValueOnce({
@@ -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",
默认位置: "預設位置",
自定义位置: "自訂位置",