Compare commits

...
Author SHA1 Message Date
kristinhe1111andCursor 9737431cf8 chore(release): bump MemHub build number to 45 (#376)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-06 15:26:45 +08:00
kristinhe1111andCursor a8156dd410 fix(memhub): restore compact memory mode settings (#378)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-06 15:26:24 +08:00
kristinhe1111andCursor 30f796ddc4 fix(memhub): bound Lite raw conversation map (#364)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-06 14:57:40 +08:00
kristinhe1111andCursor d2f1acc84a style(memhub): refine onboarding memory mode layout (#374)
* style(memhub): clarify onboarding memory mode layout

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

* style(memhub): simplify mode capability list

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

* style(memhub): integrate ultra schedule detail

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

* style(memhub): align mode dividers and clarify schedule

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

* fix(memhub): harden mode card edge states

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-06 14:43:37 +08:00
kristinhe1111andCursor 2e22399e2e style(memhub): format Core sources with Ruff (#372)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-06 14:04:03 +08:00
kristinhe1111andCursor fdb62be235 fix(memhub): restore cold-start connection step layout (#373)
* fix(memhub): polish cold-start setup layout

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

* fix(memhub): restore v1.0.4 connection step

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-06 14:03:18 +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
87 changed files with 3639 additions and 1164 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 45, 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 45),GitHub 仓库为
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
`memhub-cli`。当前代码架构见
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
+210 -63
View File
@@ -652,6 +652,33 @@ class SdkRawPage(BaseModel):
time_filtered: bool = False
class SdkRawGraphItem(BaseModel):
"""Small, non-authoritative projection used to draw the bounded 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 bounded page from the 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=300)
has_more: bool = False
class SdkLayerCounts(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -1758,6 +1785,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 +2121,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 +3268,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 +3420,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:
@@ -4497,6 +4570,26 @@ def create_app(
] = {}
raw_turn_cache_locks: dict[tuple[object, ...], asyncio.Lock] = {}
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)
@app.get(
"/api/v2/raw",
response_model=SdkRawPage,
@@ -4633,15 +4726,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 +4743,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 +4755,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 +4790,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 +4801,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 +4975,77 @@ 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=300, ge=1, le=300),
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
exclude_sources: list[str] | None = Query(default=None), # noqa: B008
session_id: list[str] | None = Query(default=None), # noqa: B008
statuses: list[str] | None = Query(default=None), # noqa: B008
) -> SdkRawGraphPage:
"""Bounded 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)
if offset:
raise HTTPException(
status_code=422,
detail="raw_graph_is_single_page",
)
query: dict[str, object] = {
"user_id": user_id,
**raw_list_filters(chosen_statuses),
"include_sources": sources or None,
"exclude_sources": excluded or None,
"session_ids": sessions or None,
"order": "desc",
}
try:
client = await _sdk_client_handle(app).client()
value = await asyncio.to_thread(
client.list_memories,
**query,
offset=0,
limit=limit,
)
page = HySdkRawPage.from_memory_list(
value,
limit=limit,
offset=0,
)
except HTTPException:
raise
except Exception as error:
raise _sdk_http_error(
error,
"hy_memory_raw_graph_failed",
) from None
projected = graph_items(page.raw)
selected = projected[:limit]
total = max(page.total, len(projected))
return SdkRawGraphPage(
user_id=user_id,
items=selected,
total=total,
offset=0,
limit=limit,
has_more=page.total > len(page.raw) or len(projected) > limit,
)
@app.post(
"/api/v2/memories/ask",
response_model=SdkMemoryAnswer,
@@ -6624,9 +6772,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,24 @@ def test_raw_lists_active_and_shadow_turns_with_roles_and_hashes(tmp_path, monke
assert cursor_page["has_more"] is True
assert cursor_turns == ["我喜欢绿茶", "我叫费天翔", "你好"]
assert len(sdk.all("list_memories")) == cursor_calls + 3
graph_calls = len(sdk.all("list_memories"))
graph = await client.get("/api/v2/raw/graph", params={"limit": 2})
assert graph.status_code == 200, graph.text
assert len(sdk.all("list_memories")) == graph_calls + 1
graph_body = graph.json()
assert graph_body["total"] == 3
assert graph_body["has_more"] is True
assert [item["label"] for item in graph_body["items"]] == [
"[user]: 我喜欢绿茶 [assistant]: 也记住了。",
"[user]: 我叫费天翔 [assistant]: 好的,记住了。",
]
assert (await client.get("/api/v2/raw/graph", params={"limit": 301})).status_code == 422
assert (
await client.get(
"/api/v2/raw/graph",
params={"limit": 2, "offset": 2},
)
).status_code == 422
inserted = {
"memory_id": "raw-new",
"content": "[user]: 新消息",
@@ -754,6 +867,9 @@ def test_raw_lists_active_and_shadow_turns_with_roles_and_hashes(tmp_path, monke
}
RAW_ROWS["local"].insert(0, inserted)
try:
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>
+121 -105
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,
@@ -263,82 +266,9 @@ export function FirstImportReview({
step={2}
title={text(title)}
footer={
<div className="first-import-r6-footer-actions">
{changing ? (
<R6Button
onClick={() => {
stopBatch.current = true;
}}
>
{text("停止后续操作")}
</R6Button>
) : onChangeJob && (canPause || canResume) ? (
<R6Button
disabled={busy || (stale && !canPause)}
onClick={() => void changeBatch(canPause ? "pause" : "resume")}
>
{canPause ? (
<Pause size={16} aria-hidden="true" />
) : (
<Play size={16} aria-hidden="true" />
)}
{text(canPause ? "暂停导入" : "继续导入")}
</R6Button>
) : null}
{recovery ? (
<>
<R6Button
disabled={busy}
onClick={() => {
stopBatch.current = true;
recovery.onSkip();
}}
>
{text(
recovery.destination === "sources"
? "仍进入应用页"
: recovery.destination === "settings"
? "仍进入设置"
: "仍进入工作台",
)}
</R6Button>
<R6Button
variant="primary"
disabled={busy}
onClick={() => leave(recovery.destination)}
>
{text("重试保存并进入")}
</R6Button>
</>
) : (
<R6Button
variant="primary"
disabled={busy}
onClick={() => leave("settings")}
>
{text("选择记忆模式")}
</R6Button>
)}
</div>
}
>
<div className="first-import-r6-layout">
<section
className="first-import-r6-main"
aria-label={text("导入与原文")}
>
<div className="first-import-r6-intro">
<h2>{text("正在导入你的会话")}</h2>
<p>
{text(
allCompleted
? "本次导入已结束。已保存的对话无需重复导入。"
: "后台将持续为你导入,可以放心进入下一步",
)}
</p>
</div>
{(error || actionMessage) && (
<div className="first-import-r6-feedback">
<>
<div className="first-import-r6-footer-note">
{(error || actionMessage) && (
<p
role={
error?.includes("应用连接") ||
@@ -357,8 +287,87 @@ export function FirstImportReview({
>
{error ? text(error) : actionMessage}
</p>
</div>
)}
)}
</div>
<div className="first-import-r6-footer-actions">
{changing ? (
<R6Button
onClick={() => {
stopBatch.current = true;
}}
>
{text("停止后续操作")}
</R6Button>
) : onChangeJob && (canPause || canResume) ? (
<R6Button
disabled={busy || (stale && !canPause)}
onClick={() => void changeBatch(canPause ? "pause" : "resume")}
>
{canPause ? (
<Pause size={16} aria-hidden="true" />
) : (
<Play size={16} aria-hidden="true" />
)}
{text(canPause ? "暂停导入" : "继续导入")}
</R6Button>
) : null}
{recovery ? (
<>
<R6Button
disabled={busy}
onClick={() => {
stopBatch.current = true;
recovery.onSkip();
}}
>
{text(
recovery.destination === "sources"
? "仍进入应用页"
: recovery.destination === "settings"
? "仍进入设置"
: "仍进入工作台",
)}
</R6Button>
<R6Button
variant="primary"
disabled={busy}
onClick={() => leave(recovery.destination)}
>
{text("重试保存并进入")}
</R6Button>
</>
) : (
<R6Button
variant="primary"
disabled={busy}
onClick={() => leave("settings")}
>
{text("选择记忆模式")}
</R6Button>
)}
</div>
</>
}
>
<div className="first-import-r6-layout">
<section
className="first-import-r6-main"
aria-label={text("导入与原文")}
>
<p className="first-import-r6-intro">
{importSubmissionState === "checking"
? t("onboardingImportChecking")
: text(
allCompleted
? "本次导入已结束。已保存的对话无需重复导入。"
: "对话先保存在本机。你可以同时设置后续连接。",
)}
</p>
<p className="first-import-r6-subintro">
{text(
"导入不会自动开放应用读取权限。配置模型后,才会开始生成记忆。",
)}
</p>
{historyWindowLimited && (
<p className="first-import-r6-history-window" role="status">
{t("onboardingHistoryWindow")}
@@ -370,39 +379,46 @@ export function FirstImportReview({
jobs={batch}
stale={progressStale}
/>
{Object.keys(failedSources).length > 0 && (
<section
className="first-import-r6-failures"
aria-label={text("未开始的导入")}
>
{Object.keys(failedSources).map((id) => (
<p key={id}>
{sources.find((source) => source.source_id === id)
?.display_name ?? id}{" "}
{text("未能开始导入,其他已接受任务不受影响。")}
</p>
))}
<R6Button
variant="quiet"
disabled={busy}
onClick={() => leave("sources")}
<section
className="first-import-r6-feed"
aria-label={text("导入说明")}
>
<h2>{text("正在导入你的会话")}</h2>
<p className="first-import-r6-feed-status">
{text("后台将持续为你导入,可以放心进入下一步")}
</p>
{Object.keys(failedSources).length > 0 && (
<section
className="first-import-r6-failures"
aria-label={text("未开始的导入")}
>
{text("查看未导入的应用")}
</R6Button>
</section>
)}
{Object.keys(failedSources).map((id) => (
<p key={id}>
{sources.find((source) => source.source_id === id)
?.display_name ?? id}{" "}
{text("未能开始导入,其他已接受任务不受影响。")}
</p>
))}
<R6Button
variant="quiet"
disabled={busy}
onClick={() => leave("sources")}
>
{text("查看未导入的应用")}
</R6Button>
</section>
)}
</section>
</section>
<aside
className="first-import-r6-options"
aria-label={text("连接应用")}
>
<div className="first-import-r6-options-head">
<p className="first-import-r6-eyebrow">{text("让记忆继续生长")}</p>
<h2>{text("连接你的应用")}</h2>
<p className="first-import-r6-options-intro">
{text("适用于本次选择且支持的应用。")}
</p>
</div>
<p className="first-import-r6-eyebrow">{text("让记忆继续生长")}</p>
<h2>{text("连接你的应用")}</h2>
<p className="first-import-r6-options-intro">
{text("适用于本次选择且支持的应用。")}
</p>
<SelectionTile
title={text(
hostConnectionAvailable ? "一键接入应用" : "测试实例不接入应用",
+58
View File
@@ -491,6 +491,7 @@ function Host({
onViewHistory,
onConfigureModel = () => {},
libraryView,
raw = false,
}: {
simplified?: boolean;
date?: string | null;
@@ -509,6 +510,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 +520,7 @@ function Host({
return (
<MemoryMap
simplified={simplified}
contentKind={raw ? "raw" : "memory"}
libraryView={libraryView}
hidden={hidden}
overlayBlocked={overlayBlocked}
@@ -587,6 +590,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: [],
+102 -15
View File
@@ -16,6 +16,7 @@ import type {
import type { GuiLocale } from "@one-memory/i18n";
import {
ArrowUp,
ChevronDown,
ChevronLeft,
ChevronRight,
FileText,
@@ -179,6 +180,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 +260,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 +349,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 +391,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 +443,7 @@ export function MemoryMap(props: Props) {
search,
sources,
serverPaged,
raw,
library?.query,
library?.graphLoading,
library?.graphFailed,
@@ -1026,11 +1061,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 +1152,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 +1209,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 +1324,17 @@ 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("可在沉淀记忆后查看视图")}
</p>
)}
{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
@@ -0,0 +1,71 @@
// @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.getByText("· Pro").className).toContain("memory-mode-tier");
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,
);
});
+73 -61
View File
@@ -1,7 +1,7 @@
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 { MODE_COPY, MODE_TIER, modeDisplayName } from "./memory-mode-ui";
import { useOnboardingTranslation } from "./onboarding-language";
import "./memory-mode-cards.css";
@@ -32,8 +32,11 @@ export function MemoryModeCards({
current,
currentBadge,
disabled = false,
layout = "grid",
describedBy,
onChange,
ultraDetail,
ultraDetailBusy = false,
"aria-label": ariaLabel,
"aria-busy": ariaBusy,
}: {
@@ -41,8 +44,11 @@ export function MemoryModeCards({
current?: MemoryPipelineMode;
currentBadge?: ReactNode;
disabled?: boolean | ((mode: MemoryPipelineMode) => boolean);
layout?: "grid" | "rows";
describedBy?: Partial<Record<MemoryPipelineMode, string | undefined>>;
onChange(mode: MemoryPipelineMode, control: HTMLInputElement): void;
ultraDetail?: ReactNode;
ultraDetailBusy?: boolean;
"aria-label": string;
"aria-busy"?: boolean;
}) {
@@ -53,70 +59,76 @@ 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 title = modeDisplayName(text, mode.id);
const tier = MODE_TIER[mode.id];
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={`${title} · ${tier}`}
aria-describedby={describedBy?.[mode.id]}
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">· {tier}</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>
</label>
{mode.id === "ultra" && selected && ultraDetail ? (
<div className="memory-mode-detail">{ultraDetail}</div>
) : null}
</div>
);
})}
</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>
</label>
{mode.id === "ultra" && selected && ultraDetail ? (
<div
className="memory-mode-detail"
aria-busy={ultraDetailBusy || undefined}
>
<Clock3 size={14} aria-hidden="true" />
<div>{ultraDetail}</div>
</div>
) : null}
</div>
);
})}
</div>
</div>
);
}
+137 -39
View File
@@ -22,6 +22,7 @@ import {
setMemoryMode,
} from "./api";
import { MemoryModeSettings } from "./MemoryModeSettings";
import { resetClient } from "./query-client";
vi.mock("./api", () => ({
getMemoryMode: vi.fn(),
@@ -74,29 +75,60 @@ async function chooseMode(
user: ReturnType<typeof userEvent.setup>,
name: RegExp,
) {
const option = await screen.findByRole("radio", { name });
const control = await screen.findByRole("combobox", {
name: "记忆模式",
});
await user.click(control);
const option = await screen.findByRole("option", { name });
await user.click(option);
return option;
return control;
}
afterEach(() => {
cleanup();
resetClient();
vi.clearAllMocks();
vi.mocked(apiErrorCode).mockReturnValue(null);
vi.mocked(memoryModePlanUnsupported).mockReturnValue(false);
});
it("renders all settings modes as one grouped shared choice list", async () => {
it("shows the current mode in a compact dropdown", async () => {
prepare("pro");
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const group = await screen.findByRole("radiogroup", {
name: "当前模式",
const control = await screen.findByRole("combobox", {
name: "记忆模式",
});
expect(within(group).getAllByRole("radio")).toHaveLength(3);
expect(control.textContent).toContain("智能记忆 · Pro");
expect(control.textContent).toContain("沉淀高级记忆");
expect(
within(group).getByText("当前模式").closest("label")?.textContent,
).toContain("智能记忆PRO");
control.querySelector(".om-option-menu-suffix")?.textContent,
).toContain("· Pro");
expect(control.textContent).not.toContain("(");
expect(screen.queryByText("当前模式")).toBeNull();
expect(screen.queryByRole("radiogroup")).toBeNull();
await user.click(control);
expect(screen.getAllByRole("option")).toHaveLength(3);
});
it("shows the effective Ultra mode and disables selection when forced by the environment", async () => {
prepare("ultra");
vi.mocked(getMemoryMode).mockResolvedValue({
...state("ultra"),
configured_mode: "pro",
forced_by_env: true,
});
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const control = await screen.findByRole("combobox", {
name: "记忆模式",
});
expect(control.textContent).toContain("深度记忆 · Ultra");
expect(control).toHaveProperty("disabled", true);
expect(screen.getByRole("switch", { name: "每天自动整合" })).toBeTruthy();
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
});
it("confirms the data effects before switching to lite", async () => {
@@ -109,7 +141,7 @@ it("confirms the data effects before switching to lite", async () => {
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /基础记忆(lite)/);
await chooseMode(user, /基础记忆 · Lite/);
expect(screen.getByText(/已有记忆继续保留/)).toBeTruthy();
expect(screen.getByText("模型调用与费用")).toBeTruthy();
expect(screen.getByText("搜索范围")).toBeTruthy();
@@ -119,18 +151,18 @@ it("confirms the data effects before switching to lite", async () => {
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledWith("lite"));
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole("radio", { name: /基础记忆(lite)/ }),
screen.getByRole("combobox", { name: "记忆模式" }),
),
);
});
it("traps modal focus, closes on Escape and restores the mode-card focus", async () => {
it("traps modal focus, closes on Escape and restores the mode dropdown", async () => {
prepare("pro");
const outside = document.createElement("button");
document.body.append(outside);
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const lite = await chooseMode(user, /基础记忆(lite)/);
const lite = await chooseMode(user, /基础记忆 · Lite/);
const cancel = screen.getByRole("button", { name: "取消" });
await waitFor(() => expect(document.activeElement).toBe(cancel));
@@ -152,7 +184,7 @@ it("keeps focus inside the modal while the mode change is pending", async () =>
vi.mocked(setMemoryMode).mockReturnValue(new Promise(() => {}));
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /基础记忆(lite)/);
await chooseMode(user, /基础记忆 · Lite/);
await user.click(screen.getByRole("button", { name: "确认选择" }));
const modal = screen.getByRole("dialog");
@@ -183,7 +215,7 @@ it("routes to model setup instead of switching pro without a model", async () =>
<MemoryModeSettings hasActiveModel={false} onConfigureModel={configure} />,
);
await chooseMode(user, /智能记忆(pro)/);
await chooseMode(user, /智能记忆 · Pro/);
expect(configure).toHaveBeenCalledExactlyOnceWith(
"使用 pro 或 ultra 前需要先配置并启用生成模型。",
);
@@ -197,11 +229,29 @@ it("uses the isolated legacy plan only for a route-level old-Core 404", async ()
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /基础记忆(lite)/);
await chooseMode(user, /基础记忆 · Lite/);
expect(planMemoryMode).toHaveBeenCalledWith("lite");
expect(await screen.findByText(/已有记忆继续保留/)).toBeTruthy();
});
it("restores focus to the mode dropdown when planning fails", async () => {
prepare("pro");
vi.mocked(planMemoryMode).mockRejectedValue(new Error("offline"));
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /基础记忆 · Lite/);
expect(
await screen.findByText("模式选择影响暂时无法读取,请重试。"),
).toBeTruthy();
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole("combobox", { name: "记忆模式" }),
),
);
});
it("keeps downgrade effects when an old Core falls back from ultra to pro", async () => {
prepare("ultra");
vi.mocked(planMemoryMode).mockRejectedValue(new Error("404"));
@@ -209,7 +259,7 @@ it("keeps downgrade effects when an old Core falls back from ultra to pro", asyn
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /智能记忆(pro)/);
await chooseMode(user, /智能记忆 · Pro/);
const dialog = screen.getByRole("dialog");
expect(within(dialog).getAllByText("已有数据")).toHaveLength(1);
@@ -221,7 +271,7 @@ it("explains model cost, search scope and the next step before switching to ultr
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /深度记忆(ultra)/);
await chooseMode(user, /深度记忆 · Ultra/);
expect(screen.getByText("模型调用与费用")).toBeTruthy();
expect(screen.getByText("搜索范围")).toBeTruthy();
@@ -237,7 +287,7 @@ it("distinguishes regular memories from higher-level Ultra data when switching t
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /智能记忆(pro)/);
await chooseMode(user, /智能记忆 · Pro/);
const dialog = screen.getByRole("dialog");
expect(within(dialog).getAllByText("已有数据")).toHaveLength(1);
@@ -254,7 +304,7 @@ it("does not show automatic consolidation when switching from lite to pro", asyn
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /智能记忆(pro)/);
await chooseMode(user, /智能记忆 · Pro/);
const dialog = screen.getByRole("dialog");
expect(within(dialog).getAllByText("已有数据")).toHaveLength(1);
@@ -271,10 +321,10 @@ it("focuses the Evolve settings after switching to ultra", async () => {
});
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await screen.findByRole("radio", { name: /智能记忆(pro)/ });
await screen.findByRole("combobox", { name: "记忆模式" });
vi.mocked(getMemoryMode).mockResolvedValue(state("ultra"));
await chooseMode(user, /深度记忆(ultra)/);
await chooseMode(user, /深度记忆 · Ultra/);
await user.click(screen.getByRole("button", { name: "确认选择" }));
await waitFor(() =>
@@ -285,6 +335,29 @@ it("focuses the Evolve settings after switching to ultra", async () => {
).toBeTruthy();
});
it("focuses the recoverable Evolve section when Ultra schedule loading fails", async () => {
prepare("pro");
vi.mocked(getEvolveSchedule).mockRejectedValue(new Error("offline"));
vi.mocked(setMemoryMode).mockResolvedValue({
...state("ultra"),
previous_mode: "pro",
effects: ["existing_memories_added_to_graph"],
});
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await screen.findByRole("combobox", { name: "记忆模式" });
vi.mocked(getMemoryMode).mockResolvedValue(state("ultra"));
await chooseMode(user, /深度记忆 · Ultra/);
await user.click(screen.getByRole("button", { name: "确认选择" }));
await waitFor(() =>
expect(document.activeElement?.getAttribute("id")).toContain("-evolve"),
);
expect(screen.getByText("自动整合设置暂时无法读取,请重试。")).toBeTruthy();
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
});
it("shows the default nightly evolve controls in ultra", async () => {
prepare("ultra");
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
@@ -303,6 +376,26 @@ it("shows the default nightly evolve controls in ultra", async () => {
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
});
it("keeps the immediate action visible and recovers when the schedule read fails", async () => {
prepare("ultra");
vi.mocked(getEvolveSchedule).mockRejectedValueOnce(new Error("offline"));
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
expect(
await screen.findByText("自动整合设置暂时无法读取,请重试。"),
).toBeTruthy();
expect(screen.queryByRole("switch", { name: "每天自动整合" })).toBeNull();
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
await user.click(screen.getByRole("button", { name: "重新读取" }));
const automatic = await screen.findByRole("switch", {
name: "每天自动整合",
});
await waitFor(() => expect(document.activeElement).toBe(automatic));
});
it("opens the time picker from the whole control and saves a complete time", async () => {
prepare("ultra");
vi.mocked(setEvolveSchedule).mockResolvedValue({
@@ -441,9 +534,9 @@ it("hides the time field when automatic evolve is off and keeps the saved time v
it("updates mode state automatically without showing a manual refresh control", async () => {
prepare("pro");
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const pro = await screen.findByRole("radio", { name: /智能记忆(pro)/ });
const mode = await screen.findByRole("combobox", { name: "记忆模式" });
expect((pro as HTMLInputElement).checked).toBe(true);
expect(mode.textContent).toContain("智能记忆 · Pro");
expect(screen.queryByRole("button", { name: "刷新记忆模式" })).toBeNull();
expect(getMemoryMode).toHaveBeenCalledTimes(1);
await waitFor(() => {
@@ -457,9 +550,9 @@ it("keeps the mode usable when an evolve status read fails", async () => {
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
expect(
(await screen.findByRole("radio", {
name: /智能记忆(pro)/,
})) as HTMLInputElement,
await screen.findByRole("combobox", {
name: "记忆模式",
}),
).toHaveProperty("disabled", false);
});
@@ -472,15 +565,17 @@ it("offers a text recovery action when the mode read fails", async () => {
expect(
await screen.findByText("记忆模式状态暂时无法读取,请重试。"),
).toBeTruthy();
expect(
screen.getByRole("radio", { name: /基础记忆(lite)/ }),
).toHaveProperty("disabled", true);
expect(screen.getByRole("combobox", { name: "记忆模式" })).toHaveProperty(
"disabled",
true,
);
await user.click(screen.getByRole("button", { name: "重新读取" }));
await waitFor(() =>
expect(
screen.getByRole("radio", { name: /智能记忆(pro)/ }),
).toHaveProperty("disabled", false),
expect(screen.getByRole("combobox", { name: "记忆模式" })).toHaveProperty(
"disabled",
false,
),
);
});
@@ -491,7 +586,7 @@ it("keeps a failed switch explanation inside the open confirmation dialog", asyn
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /深度记忆(ultra)/);
await chooseMode(user, /深度记忆 · Ultra/);
await user.click(screen.getByRole("button", { name: "确认选择" }));
const dialog = screen.getByRole("dialog");
@@ -512,15 +607,17 @@ it("keeps lite available while model status is unknown", async () => {
/>,
);
const user = userEvent.setup();
await user.click(await screen.findByRole("combobox", { name: "记忆模式" }));
expect(
await screen.findByRole("radio", { name: /基础记忆(lite)/ }),
await screen.findByRole("option", { name: /基础记忆 · Lite/ }),
).toHaveProperty("disabled", false);
expect(screen.getByRole("radio", { name: /智能记忆(pro)/ })).toHaveProperty(
expect(screen.getByRole("option", { name: /智能记忆 · Pro/ })).toHaveProperty(
"disabled",
true,
);
expect(
screen.getByRole("radio", { name: /深度记忆(ultra)/ }),
screen.getByRole("option", { name: /深度记忆 · Ultra/ }),
).toHaveProperty("disabled", true);
});
@@ -536,9 +633,10 @@ it("blocks mode and Evolve controls during embedding maintenance", async () => {
"正在检查或切换向量模型,完成前不能选择模式或手动整合。",
),
).toBeTruthy();
expect(
screen.getByRole("radio", { name: /基础记忆(lite)/ }),
).toHaveProperty("disabled", true);
expect(screen.getByRole("combobox", { name: "记忆模式" })).toHaveProperty(
"disabled",
true,
);
expect(screen.getByRole("button", { name: "立即整合" })).toHaveProperty(
"disabled",
true,
+163 -103
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,8 +22,15 @@ import {
commitConfirmedMemoryMode,
issueConfirmedModeTicket,
} from "./confirmed-memory-mode";
import { MemoryModeCards } from "./MemoryModeCards";
import { BLOCKER_COPY, MemoryModeConfirmDialog } from "./memory-mode-ui";
import { commitEvolveRun, useEvolveState } from "./evolve-state";
import {
BLOCKER_COPY,
MemoryModeConfirmDialog,
MODE_COPY,
MODE_TIER,
modeDisplayName,
} from "./memory-mode-ui";
import { OptionMenu } from "./OptionMenu";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Button } from "./R6Controls";
import { R6StateRecovery } from "./R6StateRecovery";
@@ -47,7 +51,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,38 +64,47 @@ 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 [scheduleReadFailed, setScheduleReadFailed] = useState(false);
const refreshVersion = useRef(0);
const dialogOpener = useRef<HTMLElement | null>(null);
const focusEvolveWhenReady = useRef(false);
const focusModeWhenReady = useRef<MemoryPipelineMode | null>(null);
const focusScheduleWhenReady = useRef(false);
useEffect(() => {
if (!focusEvolveWhenReady.current || pendingMode || state?.mode !== "ultra")
return;
focusEvolveWhenReady.current = false;
document.getElementById(`${id}-evolve`)?.focus();
const evolve = document.getElementById(`${id}-evolve`);
if (evolve) {
focusEvolveWhenReady.current = false;
evolve.focus();
}
}, [id, pendingMode, state?.mode]);
useEffect(() => {
const target = focusModeWhenReady.current;
if (!target || busy || pendingMode || state?.mode !== target) return;
focusModeWhenReady.current = null;
document.getElementById(`${id}-select`)?.focus();
}, [busy, id, pendingMode, state?.mode]);
useEffect(() => {
if (!focusScheduleWhenReady.current || scheduleReadFailed || !schedule)
return;
focusScheduleWhenReady.current = false;
document
.querySelector<HTMLInputElement>(
`.r6-memory-mode-settings input[type="radio"][value="${target}"]`,
)
.getElementById(`${id}-evolve`)
?.querySelector<HTMLElement>('[role="switch"]')
?.focus();
}, [busy, pendingMode, state?.mode]);
}, [id, schedule, scheduleReadFailed]);
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;
@@ -101,11 +118,11 @@ export function MemoryModeSettings({
setError("记忆模式状态暂时无法读取,请重试。");
setReadFailed(true);
}
if (scheduleResult.status === "fulfilled")
if (scheduleResult.status === "fulfilled") {
setScheduleState(scheduleResult.value);
if (overviewResult.status === "fulfilled") {
setOverview(overviewResult.value);
setRun(overviewResult.value.running[0] ?? null);
setScheduleReadFailed(false);
} else {
setScheduleReadFailed(true);
}
if (processingResult.status === "fulfilled")
setMaintenance(Boolean(processingResult.value.embedding_switch));
@@ -116,21 +133,18 @@ 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 ||
run?.state === "running" ||
state?.graph_build.state === "running";
async function choose(mode: MemoryPipelineMode, opener: HTMLElement) {
async function choose(mode: MemoryPipelineMode) {
if (!state || mode === state.mode || busy || lifecycleBusy) return;
setError("");
setBusy(true);
@@ -140,6 +154,7 @@ export function MemoryModeSettings({
} catch (reason) {
if (!memoryModePlanUnsupported(reason)) {
setError("模式切换影响暂时无法读取,请重试。");
focusModeWhenReady.current = state.mode;
setBusy(false);
return;
}
@@ -150,7 +165,7 @@ export function MemoryModeSettings({
onConfigureModel(BLOCKER_COPY.active_model_required);
return;
}
dialogOpener.current = opener;
dialogOpener.current = document.getElementById(`${id}-select`);
setPendingPlan(plan);
setPendingMode(mode);
}
@@ -202,13 +217,24 @@ export function MemoryModeSettings({
}
async function evolveNow() {
if (!state) return;
refreshVersion.current++;
setBusy(true);
setError("");
try {
const receipt = await startEvolve();
setRun(receipt);
await refresh();
commitEvolveRun(
receipt,
overview ??
(schedule
? {
mode: state.mode,
schedule,
users: {},
running: [],
}
: undefined),
);
} catch (reason) {
const code = apiErrorCode(reason);
setError(
@@ -233,28 +259,69 @@ export function MemoryModeSettings({
<p>{text("模式决定新对话如何保存,以及记忆页面展示什么。")}</p>
</div>
</div>
<MemoryModeCards
aria-label={text("当前模式")}
aria-busy={state === null}
current={state?.mode}
value={state?.mode ?? null}
currentBadge={text("当前模式")}
disabled={(mode) =>
!state ||
busy ||
lifecycleBusy ||
Boolean(state.forced_by_env && mode !== state.mode) ||
(mode !== "lite" && !modelStateKnown)
}
onChange={(mode, control) => void choose(mode, control)}
ultraDetail={
state?.mode === "ultra" && schedule ? (
<section
id={`${id}-evolve`}
className="r6-evolve-settings r6-evolve-settings--in-card"
aria-labelledby={`${id}-evolve-title`}
tabIndex={-1}
>
<div
className="r6-mode-control"
data-expanded={state?.mode === "ultra" || undefined}
>
<div className="r6-mode-setting-row" aria-busy={state === null}>
<OptionMenu
id={`${id}-select`}
ariaLabel={text("记忆模式")}
value={state?.mode ?? ""}
disabled={
!state || busy || lifecycleBusy || Boolean(state?.forced_by_env)
}
options={(["lite", "pro", "ultra"] as const).map((mode) => ({
value: mode,
label: modeDisplayName(text, mode),
suffix: MODE_TIER[mode],
description: text(MODE_COPY[mode].description),
disabled: mode !== "lite" && !modelStateKnown,
}))}
className="r6-language-option-menu r6-mode-option-menu"
panelClassName="r6-language-option-menu-panel--settings r6-mode-option-menu-panel"
onChange={(value) => {
if (value === "lite" || value === "pro" || value === "ultra")
void choose(value);
}}
/>
</div>
{state?.forced_by_env ? (
<p className="r6-settings-note" role="status">
{text("开发环境当前强制使用 ultra,保存的选择暂不会生效。")}
</p>
) : null}
{maintenance ? (
<p className="r6-settings-note" role="status">
{text("正在检查或切换向量模型,完成前不能切换模式或手动整合。")}
</p>
) : null}
{state?.graph_build.state === "running" ? (
<p className="r6-settings-note" role="status">
{text("正在把已有高级记忆加入知识图谱…")}
</p>
) : null}
{error && !pendingMode ? (
readFailed ? (
<R6StateRecovery
message={text(error)}
onRetry={() => void refresh()}
/>
) : (
<p className="r6-settings-error" role="alert">
{text(error)}
</p>
)
) : null}
{state?.mode === "ultra" ? (
<section
id={`${id}-evolve`}
className="r6-evolve-settings"
aria-labelledby={`${id}-evolve-title`}
tabIndex={-1}
>
{schedule ? (
<UltraEvolveScheduleFields
schedule={schedule}
headingId={`${id}-evolve-title`}
@@ -262,63 +329,56 @@ export function MemoryModeSettings({
onEnabledChange={(enabled) => void changeSchedule({ enabled })}
onTimeChange={(time) => changeSchedule({ time })}
/>
<div className="r6-evolve-setting r6-evolve-setting--manual">
<div className="r6-evolve-setting-copy">
<h3>{text("立即整合一次")}</h3>
) : (
<>
<div className="r6-evolve-heading">
<h3 id={`${id}-evolve-title`}>
{text("记忆整合(Evolve)")}
</h3>
<p>
{text("只运行这一次,不改变每天的计划。")}
{" · "}
{text(
run?.state === "running"
? "正在整合记忆…"
: last?.last_run_at
? text("上次整合:{time}").replace(
"{time}",
new Date(last.last_run_at).toLocaleString(locale),
)
: "尚未整合",
)}
{text("定期重新整理已有记忆,补充更稳定的关联和结论。")}
</p>
</div>
<R6Button
pending={run?.state === "running"}
disabled={busy || lifecycleBusy}
onClick={() => void evolveNow()}
>
{text("立即整合")}
</R6Button>
{scheduleReadFailed ? (
<R6StateRecovery
message={text("自动整合设置暂时无法读取,请重试。")}
onRetry={() => {
focusScheduleWhenReady.current = true;
void refresh();
}}
/>
) : null}
</>
)}
<div className="r6-evolve-setting r6-evolve-setting--manual">
<div className="r6-evolve-setting-copy">
<h3>{text("立即整合一次")}</h3>
<p>
{text("只运行这一次,不改变每天的计划。")}
{" · "}
{text(
run?.state === "running"
? "正在整合记忆…"
: last?.last_run_at
? text("上次整合:{time}").replace(
"{time}",
new Date(last.last_run_at).toLocaleString(locale),
)
: "尚未整合",
)}
</p>
</div>
</section>
) : null
}
/>
{state?.forced_by_env ? (
<p className="r6-settings-note" role="status">
{text("开发环境当前强制使用 ultra,保存的选择暂不会生效。")}
</p>
) : null}
{maintenance ? (
<p className="r6-settings-note" role="status">
{text("正在检查或切换向量模型,完成前不能切换模式或手动整合。")}
</p>
) : null}
{state?.graph_build.state === "running" ? (
<p className="r6-settings-note" role="status">
{text("正在把已有高级记忆加入知识图谱…")}
</p>
) : null}
{error && !pendingMode ? (
readFailed ? (
<R6StateRecovery
message={text(error)}
onRetry={() => void refresh()}
/>
) : (
<p className="r6-settings-error" role="alert">
{text(error)}
</p>
)
) : null}
<R6Button
pending={run?.state === "running"}
disabled={busy || lifecycleBusy}
onClick={() => void evolveNow()}
>
{text("立即整合")}
</R6Button>
</div>
</section>
) : null}
</div>
{pendingMode && pendingPlan ? (
<MemoryModeConfirmDialog
+216 -1
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,16 @@ 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}
/>
),
}));
afterEach(() => {
cleanup();
resetClient();
vi.useRealTimers();
vi.clearAllMocks();
});
@@ -154,7 +165,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 +306,24 @@ 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: 300,
offset: 0,
has_more: false,
});
reads.rawSearch.mockResolvedValue([]);
}
@@ -321,6 +437,94 @@ it("shows raw conversations instead of advanced memory in lite", async () => {
expect(document.querySelector(".memory-mode-notice")).toBeNull();
});
it("loads one bounded raw graph page without claiming an exact turn total", async () => {
setup();
reads.processing.mockResolvedValue(modeProcessing("lite"));
reads.rawGraph.mockResolvedValue({
user_id: "local",
items: Array.from({ length: 300 }, (_, index) => ({
turn_id: `turn-${index}`,
raw_id: `raw-${index}`,
label: `原始会话 ${index}`,
source: "cursor",
session_id: `session-${index}`,
memory_at: null,
gmt_created: 1_761_000_000,
})),
total: 1_600,
limit: 300,
offset: 0,
has_more: true,
});
render(<MemorySpace simplified />);
await waitFor(() => expect(reads.rawGraph).toHaveBeenCalledTimes(1));
expect(reads.rawGraph).toHaveBeenCalledWith({
source: undefined,
offset: 0,
limit: 300,
signal: expect.any(AbortSignal),
});
expect(screen.getByTestId("graph-owner").dataset.graphNodes).toBe("301");
expect(document.querySelector(".memory-loaded-count")?.textContent).not.toBe(
"1,600 条原始对话",
);
expect(screen.queryByText("1,600 / 1,600")).toBeNull();
});
it("keeps the last successful Lite graph when a background refresh fails", async () => {
setup();
reads.processing.mockResolvedValue(modeProcessing("lite"));
const source = {
source_id: "cursor",
display_name: "Cursor",
authorized: true,
imported_messages: 1,
imported_conversations: 1,
} as SourceResponse;
const view = render(<MemorySpace simplified sources={[source]} />);
await waitFor(() =>
expect(screen.getByTestId("graph-owner").dataset.graphNodes).toBe("2"),
);
reads.rawGraph.mockRejectedValueOnce(new Error("refresh failed"));
view.rerender(
<MemorySpace
simplified
sources={[
source,
{
...source,
source_id: "codex",
display_name: "Codex",
},
]}
/>,
);
await waitFor(() => expect(reads.rawGraph).toHaveBeenCalledTimes(2));
expect(screen.getByTestId("graph-owner").dataset.graphNodes).toBe("2");
expect(screen.queryByText("原始对话暂时无法读取。")).toBeNull();
});
it("does not request the raw graph for a keyword search", async () => {
setup();
reads.processing.mockResolvedValue(modeProcessing("lite"));
reads.rawSearch.mockResolvedValue([]);
const user = userEvent.setup();
render(<MemorySpace simplified />);
await waitFor(() => expect(reads.rawGraph).toHaveBeenCalledTimes(1));
await user.type(
screen.getByRole("textbox", { name: "搜索原始对话" }),
"发布计划",
);
await user.click(screen.getByRole("button", { name: "搜索" }));
await waitFor(() => expect(reads.rawSearch).toHaveBeenCalledTimes(1));
expect(reads.rawGraph).toHaveBeenCalledTimes(1);
});
it("pages and filters the raw conversation data plane in lite", async () => {
setup();
reads.processing.mockResolvedValue(modeProcessing("lite"));
@@ -368,9 +572,12 @@ 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,
@@ -620,6 +827,14 @@ it("opens a search hit with the complete role transcript", async () => {
truncated: true,
},
]);
reads.rawGraph.mockResolvedValue({
user_id: "local",
items: [],
total: 1_600,
limit: 300,
offset: 0,
has_more: false,
});
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 () => {
+152 -73
View File
@@ -38,15 +38,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 +63,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 +120,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 +175,8 @@ export function MemorySpace({
source.grant_revision,
]),
);
const sourcesRef = useRef(sources);
sourcesRef.current = sources;
const management = simplified && displayedMode === "library";
useEffect(() => {
viewActive.current = active;
@@ -191,6 +203,12 @@ export function MemorySpace({
// 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 +257,17 @@ 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.value
: null;
sdkGraph?.scope === sdkGraphRequestedScope ? sdkGraph.value : null;
const sdkGraphFailed =
sdkGraph?.scope === sdkRequestedScope && sdkGraph.status === "failed";
sdkGraph?.scope === sdkGraphRequestedScope &&
sdkGraph.status === "failed" &&
sdkGraph.value === null;
// A keyword search pages through one server-side result window. The id is
// kept for the query and source it was searched with; a new query or
// source starts without one, so the server searches afresh.
@@ -295,61 +312,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 +398,6 @@ export function MemorySpace({
settledMode: pipelineMode,
};
});
setSdkGraph({ scope: windowKey, ...graph });
} catch (reason) {
if (cancelled || request.signal.aborted) return;
if (
@@ -443,6 +435,73 @@ 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;
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 });
return;
}
const page = await getSdkRawGraph({
source,
offset: 0,
limit: 300,
signal: request.signal,
});
if (cancelled) return;
rawGraphRecords.current.clear();
for (const item of page.items) {
const record = rawGraphRecord(item);
rawGraphRecords.current.set(rawRecordId(record), record);
}
setSdkGraph({
scope,
status: "ready",
value: rawGraphProjection(page.items, page.total, sourcesRef.current),
});
} catch {
if (cancelled || request.signal.aborted) return;
setSdkGraph((current) => ({
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 = sdkPage;
const sdkFilterLabel = [
sdkLibrary.settledSource
? `${t("memory:来源")} ${
@@ -572,10 +631,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 +712,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 +825,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 +844,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 +854,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 +869,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 +1035,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 +1148,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 +1336,11 @@ 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,
stale: false,
settledSource: sdkLibrary.settledSource,
settledQuery: sdkLibrary.settledQuery,
@@ -1289,7 +1368,7 @@ export function MemorySpace({
displayedMode !== "map" ||
(simplified && pipelineMode === null)
}
memories={sdkPage?.items ?? []}
memories={displayedSdkPage?.items ?? []}
graph={visibleSdkGraph}
sources={sources}
processing={processing}
@@ -156,7 +156,7 @@ it("moves from import to the actual third step without completing onboarding", a
);
await userEvent.click(screen.getByRole("button", { name: "进入第三步" }));
await userEvent.click(screen.getByRole("radio", { name: /pro/i }));
await userEvent.click(screen.getByRole("button", { name: "继续配置模型" }));
await userEvent.click(screen.getByRole("button", { name: "配置模型" }));
expect(screen.getByRole("heading", { name: "第三步模型页面" })).toBeTruthy();
expect(completeOnboarding).not.toHaveBeenCalled();
expect(finish).not.toHaveBeenCalled();
+77 -55
View File
@@ -240,7 +240,7 @@ async function continueImport(user: ReturnType<typeof userEvent.setup>) {
async function openModelStep(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole("button", { name: "选择记忆模式" }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(screen.getByRole("button", { name: "配置模型" }));
}
async function chooseLiteAndContinue(user: ReturnType<typeof userEvent.setup>) {
await selectMode(user, /lite/i);
@@ -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();
});
@@ -687,7 +688,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(screen.getByRole("button", { name: "配置模型" }));
const receipt = screen.getByRole("button", {
name: "模型启用回执已确认",
});
@@ -1030,6 +1031,12 @@ describe("OnboardingFlow S1.3", () => {
await user.click(screen.getByRole("button", { name: SKIP }));
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
expect(
screen.getByRole("heading", {
name: "你希望 MemHub 如何形成记忆?",
}),
).toBeTruthy();
expect(screen.queryByText("Choose how MemHub remembers")).toBeNull();
expect(screen.getByText("之后可在设置中更改。")).toBeTruthy();
expect(screen.getAllByRole("radio")).toHaveLength(3);
expect(screen.getByRole("radio", { name: /ultra/i })).toBeTruthy();
@@ -1042,7 +1049,7 @@ describe("OnboardingFlow S1.3", () => {
await selectMode(user, /lite/i);
expect(screen.getByRole("button", { name: "使用 Lite 进入" })).toBeTruthy();
await selectMode(user, /pro/i);
expect(screen.getByRole("button", { name: "继续配置模型" })).toBeTruthy();
expect(screen.getByRole("button", { name: "配置模型" })).toBeTruthy();
});
it("configures Ultra scheduling before model setup and completes after activation", async () => {
@@ -1082,10 +1089,19 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /ultra/i }));
expect(screen.getByText("每天自动整合")).toBeTruthy();
const ultra = screen.getByRole("radio", { name: /ultra/i });
await user.click(ultra);
const schedule = screen.getByText("每天自动整合");
expect(
schedule.closest(".memory-mode-card")?.getAttribute("data-mode"),
).toBe("ultra");
expect(schedule.closest("label")).toBeNull();
expect(ultra.getAttribute("aria-describedby")).toBe(
"onboarding-ultra-schedule-description",
);
expect(screen.getByText(/每天 03:00 左右自动整合/)).toBeTruthy();
await user.click(screen.getByRole("button", { name: "继续" }));
expect(screen.getByText("后续可在设置页自定义时间或关闭。")).toBeTruthy();
await user.click(screen.getByRole("button", { name: "配置模型" }));
expect(screen.getByRole("region", { name: "模型步骤边界" })).toBeTruthy();
expect(putOnboardingProgress).toHaveBeenCalledWith(
expect.objectContaining({
@@ -1148,14 +1164,15 @@ describe("OnboardingFlow S1.3", () => {
}),
);
await waitFor(() =>
expect(screen.getByRole("button", { name: "继续" })).toHaveProperty(
expect(screen.getByRole("button", { name: "配置模型" })).toHaveProperty(
"disabled",
false,
),
);
expect(screen.getByText(/重新开启后仍按 05:30 运行/)).toBeTruthy();
expect(screen.queryByText("后续可在设置页自定义时间或关闭。")).toBeNull();
await user.click(screen.getByRole("button", { name: "继续" }));
await user.click(screen.getByRole("button", { name: "配置模型" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
@@ -1189,7 +1206,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await selectMode(user, /ultra/i);
await user.click(screen.getByRole("button", { name: "继续" }));
await user.click(screen.getByRole("button", { name: "配置模型" }));
expect((await screen.findByRole("alert")).textContent).toContain(
"自动整合设置未能保存",
);
@@ -1198,7 +1215,7 @@ describe("OnboardingFlow S1.3", () => {
screen.getByRole<HTMLInputElement>("radio", { name: /ultra/i }).checked,
).toBe(true);
await user.click(screen.getByRole("button", { name: "继续" }));
await user.click(screen.getByRole("button", { name: "配置模型" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
@@ -1206,9 +1223,14 @@ describe("OnboardingFlow S1.3", () => {
});
it("retries a failed Ultra schedule read without losing the selection", async () => {
let resolveRetry!: (value: { enabled: boolean; time: string }) => void;
vi.mocked(api.getEvolveSchedule)
.mockRejectedValueOnce(new Error("offline"))
.mockResolvedValueOnce({ enabled: true, time: "04:15" });
.mockReturnValueOnce(
new Promise((resolve) => {
resolveRetry = resolve;
}),
);
const user = userEvent.setup();
render(
<OnboardingFlow
@@ -1224,19 +1246,35 @@ describe("OnboardingFlow S1.3", () => {
expect(screen.getByRole("alert").textContent).toContain(
"记忆模式状态暂时无法读取",
);
expect(screen.getByRole("button", { name: "继续" })).toHaveProperty(
expect(screen.getByRole("button", { name: "配置模型" })).toHaveProperty(
"disabled",
true,
);
await user.click(screen.getByRole("button", { name: "重试" }));
const retry = screen.getByRole("button", { name: "重试" });
expect(retry.closest(".memory-mode-card")?.getAttribute("data-mode")).toBe(
"ultra",
);
expect(retry.closest("label")).toBeNull();
retry.focus();
await user.click(retry);
expect(screen.getByRole("button", { name: "重试" })).toHaveProperty(
"disabled",
true,
);
expect(
retry.closest(".memory-mode-detail")?.getAttribute("aria-busy"),
).toBe("true");
expect(document.activeElement).toBe(retry);
expect(api.getEvolveSchedule).toHaveBeenCalledTimes(2);
await act(async () => resolveRetry({ enabled: true, time: "04:15" }));
await waitFor(() =>
expect(screen.getByText(/每天 04:15 左右自动整合/)).toBeTruthy(),
);
expect(
screen.getByRole<HTMLInputElement>("radio", { name: /ultra/i }).checked,
).toBe(true);
expect(screen.getByRole("button", { name: "继续" })).toHaveProperty(
expect(screen.getByRole("button", { name: "配置模型" })).toHaveProperty(
"disabled",
false,
);
@@ -1429,7 +1467,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(screen.getByRole("button", { name: "配置模型" }));
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
@@ -1483,7 +1521,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(screen.getByRole("button", { name: "配置模型" }));
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
@@ -1491,7 +1529,7 @@ describe("OnboardingFlow S1.3", () => {
await user.click(screen.getByRole("button", { name: "返回导入" }));
modelActive = false;
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(screen.getByRole("button", { name: "配置模型" }));
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
@@ -1537,7 +1575,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(screen.getByRole("button", { name: "配置模型" }));
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
@@ -1579,7 +1617,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(screen.getByRole("button", { name: "配置模型" }));
const revisionBeforePut = confirmedMemoryModeSnapshot().revision;
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
@@ -1628,7 +1666,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(screen.getByRole("button", { name: "配置模型" }));
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
@@ -1668,7 +1706,7 @@ describe("OnboardingFlow S1.3", () => {
await waitFor(() => expect(document.activeElement).toBe(firstTitle));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(screen.getByRole("button", { name: "配置模型" }));
await user.click(screen.getByRole("button", { name: "返回导入" }));
const returnedTitle = screen.getByRole("heading", {
name: "选择记忆模式",
@@ -1698,7 +1736,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(screen.getByRole("button", { name: "配置模型" }));
await waitFor(() => expect(finish).toHaveBeenCalledWith("sources"));
expect(screen.queryByRole("alert")).toBeNull();
expect(setMemoryMode).not.toHaveBeenCalled();
@@ -1717,7 +1755,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(screen.getByRole("button", { name: "配置模型" }));
const lateReceipt = screen.getByRole("button", {
name: "模型启用回执已确认",
});
@@ -1766,14 +1804,14 @@ describe("OnboardingFlow S1.3", () => {
await user.click(screen.getByRole("button", { name: SKIP }));
const pro = screen.getByRole<HTMLInputElement>("radio", { name: /pro/i });
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(screen.getByRole("button", { name: "配置模型" }));
expect((await screen.findByRole("alert")).textContent).toContain(
"模式未能保存",
);
expect(pro.checked).toBe(true);
expect(screen.queryByRole("region", { name: "模型步骤边界" })).toBeNull();
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(screen.getByRole("button", { name: "配置模型" }));
expect(
await screen.findByRole("region", { name: "模型步骤边界" }),
).toBeTruthy();
@@ -2690,7 +2728,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 +2744,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 +2762,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 +2777,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();
});
+70 -23
View File
@@ -41,6 +41,7 @@ import {
defaultHostSourceIds,
FirstImportReview,
type ImportConnectionDraft,
type ImportSubmissionState,
} from "./FirstImportReview";
import {
enableHostConnection,
@@ -111,7 +112,7 @@ export function OnboardingFlow({
imports: ImportSourcesController;
onFinish(destination: OnboardingDestination): void;
}) {
const { text, t, locale } = useOnboardingTranslation();
const { text, t } = useOnboardingTranslation();
const [sources, setSources] = useState(imports.sources);
const sourceGeneration = useRef({ value: imports.sources, epoch: 0 });
if (sourceGeneration.current.value !== imports.sources) {
@@ -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" &&
@@ -162,6 +165,7 @@ export function OnboardingFlow({
});
const [scheduleLoaded, setScheduleLoaded] = useState(false);
const [scheduleReadFailed, setScheduleReadFailed] = useState(false);
const [scheduleLoading, setScheduleLoading] = useState(true);
const scheduleReadOperation = useRef(0);
const [modeFocusToken, setModeFocusToken] = useState(
resumedStep === "mode" ? 1 : 0,
@@ -427,7 +431,7 @@ export function OnboardingFlow({
const refreshUltraSchedule = useCallback(async () => {
const operation = ++scheduleReadOperation.current;
setScheduleLoaded(false);
setScheduleReadFailed(false);
setScheduleLoading(true);
try {
const schedule = await getEvolveSchedule();
if (!alive.current || scheduleReadOperation.current !== operation) return;
@@ -443,6 +447,9 @@ export function OnboardingFlow({
setScheduleLoaded(false);
setScheduleReadFailed(true);
}
} finally {
if (alive.current && scheduleReadOperation.current === operation)
setScheduleLoading(false);
}
}, []);
@@ -1058,6 +1065,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 +1077,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 +1104,7 @@ export function OnboardingFlow({
setItemErrors(result.failed_sources);
if (result.accepted_source_ids.length === 0) {
setStartingImport(null);
setImportSubmissionState("refused");
setError(
"本次没有来源获准导入。请核对下方原因后重试,或到应用页查看状态。",
);
@@ -1101,16 +1113,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 +1207,7 @@ export function OnboardingFlow({
jobs={reviewJobs}
failedSources={accepted?.failed_sources}
sourceSnapshot={imports.sourceSnapshot}
importSubmissionState={importSubmissionState}
busy={completionBusy}
error={error && text(error)}
onContinue={(destination) => {
@@ -1242,11 +1264,11 @@ export function OnboardingFlow({
? "正在保存模式…"
: modePhase === "completing"
? "正在完成首次设置…"
: modeChoice === "lite"
? "使用 Lite 进入"
: modeChoice === "pro"
? "继续配置模型"
: "继续",
: !modeChoice
? "继续"
: modeChoice === "lite"
? "使用 Lite 进入"
: "配置模型",
)}
</R6Button>
}
@@ -1256,17 +1278,21 @@ export function OnboardingFlow({
aria-label={text("记忆模式")}
>
<div className="onboarding-r6-mode-intro">
<h2>
{locale === "zh-Hans"
? "Choose how MemHub remembers"
: text("你希望 MemHub 如何形成记忆?")}
</h2>
<h2>{text("你希望 MemHub 如何形成记忆?")}</h2>
<p>{text("需要明确选择一种方式;之后仍可在设置中更改。")}</p>
</div>
<MemoryModeCards
aria-label={text("记忆模式")}
value={modeChoice}
layout="rows"
describedBy={{
ultra:
modeChoice === "ultra"
? "onboarding-ultra-schedule-description"
: undefined,
}}
disabled={modeSaving || completionBusy}
ultraDetailBusy={scheduleLoading}
onChange={(mode) => {
modeChoiceTouched.current = true;
intentRef.current = mode;
@@ -1275,30 +1301,51 @@ export function OnboardingFlow({
}}
ultraDetail={
scheduleLoaded ? (
<div className="onboarding-r6-ultra-default">
<div className="onboarding-r6-ultra-default" role="status">
<strong>{text("每天自动整合")}</strong>
<small>
<small id="onboarding-ultra-schedule-description">
{text(
ultraSchedule.enabled
? "每天 {time} 左右自动整合,使用这台设备的本地时间;休眠错过后会补跑。"
: "已关闭。重新开启后仍按 {time} 运行;已生成的结果会保留。",
).replace("{time}", ultraSchedule.time)}
{ultraSchedule.enabled ? (
<>
<br />
<span>
{text("后续可在设置页自定义时间或关闭。")}
</span>
</>
) : null}
</small>
</div>
) : scheduleReadFailed ? (
<div className="onboarding-r6-schedule-recovery">
<p role="alert">
<p
id="onboarding-ultra-schedule-description"
role="alert"
>
{text("记忆模式状态暂时无法读取,请重试。")}
</p>
<R6Button
className="onboarding-r6-schedule-retry"
pending={scheduleLoading}
disabled={
scheduleLoading || modeSaving || completionBusy
}
onClick={() => void refreshUltraSchedule()}
>
{text("重试")}
</R6Button>
</div>
) : (
<p>{text("正在读取自动整合设置…")}</p>
<p
id="onboarding-ultra-schedule-description"
className="onboarding-r6-schedule-loading"
role="status"
>
{text("正在读取自动整合设置…")}
</p>
)
}
/>
@@ -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>
+70
View File
@@ -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 { OptionMenu } from "./OptionMenu";
afterEach(cleanup);
it("shows a suffix without requiring description copy", async () => {
const user = userEvent.setup();
render(
<LanguageProvider>
<OptionMenu
ariaLabel="记忆模式"
value="pro"
options={[
{ value: "lite", label: "基础记忆", suffix: "Lite" },
{ value: "pro", label: "智能记忆", suffix: "Pro" },
]}
onChange={vi.fn()}
/>
</LanguageProvider>,
);
const control = screen.getByRole("combobox", { name: "记忆模式" });
expect(control.textContent).toContain("智能记忆 · Pro");
await user.click(control);
expect(screen.getByRole("option", { name: "基础记忆 · Lite" })).toBeTruthy();
});
it("keeps option descriptions available to assistive technology", async () => {
const user = userEvent.setup();
render(
<LanguageProvider>
<OptionMenu
ariaLabel="记忆模式"
value="pro"
options={[
{
value: "lite",
label: "基础记忆",
description: "保存原始会话",
},
{
value: "pro",
label: "智能记忆",
suffix: "Pro",
description: "调用模型整理记忆",
},
]}
onChange={vi.fn()}
/>
</LanguageProvider>,
);
await user.click(screen.getByRole("combobox", { name: "记忆模式" }));
const option = screen.getByRole("option", { name: "智能记忆 · Pro" });
const descriptionId = option.getAttribute("aria-describedby");
expect(descriptionId).toBeTruthy();
expect(document.getElementById(descriptionId ?? "")?.textContent).toBe(
"调用模型整理记忆",
);
expect(
screen
.getByRole("option", { name: /基础记忆.*保存原始会话/ })
.getAttribute("aria-describedby"),
).toBeNull();
});
+27 -5
View File
@@ -13,6 +13,7 @@ import { useGuiLanguage } from "./gui-language";
export interface OptionMenuItem {
value: string;
label: string;
suffix?: string;
description?: string;
disabled?: boolean;
lang?: string;
@@ -22,6 +23,17 @@ const VIEWPORT_MARGIN = 12;
const PANEL_GAP = 4;
const MAX_PANEL_HEIGHT = 280;
function optionLabel(label: string, suffix?: string) {
return suffix ? (
<>
{label}
<span className="om-option-menu-suffix"> · {suffix}</span>
</>
) : (
label
);
}
export function optionMenuPosition(
anchor: Pick<DOMRect, "top" | "right" | "bottom" | "left" | "width">,
panelHeight: number,
@@ -268,11 +280,11 @@ export function OptionMenu(props: {
>
{selected?.description ? (
<span className="om-option-menu-copy">
<span>{visibleLabel}</span>
<span>{optionLabel(visibleLabel, selected.suffix)}</span>
<small>{selected.description}</small>
</span>
) : (
visibleLabel
optionLabel(visibleLabel, selected?.suffix)
)}
</button>
{open &&
@@ -299,6 +311,14 @@ export function OptionMenu(props: {
lang={item.lang}
data-active={activeIndex === index || undefined}
aria-selected={item.value === props.value}
aria-label={
item.suffix ? `${item.label} · ${item.suffix}` : undefined
}
aria-describedby={
item.description && item.suffix
? `${panelId}-option-${index}-description`
: undefined
}
disabled={item.disabled}
tabIndex={-1}
onMouseEnter={() => {
@@ -308,11 +328,13 @@ export function OptionMenu(props: {
>
{item.description ? (
<span className="om-option-menu-copy">
<span>{item.label}</span>
<small>{item.description}</small>
<span>{optionLabel(item.label, item.suffix)}</span>
<small id={`${panelId}-option-${index}-description`}>
{item.description}
</small>
</span>
) : (
item.label
optionLabel(item.label, item.suffix)
)}
</button>
))}
@@ -82,7 +82,7 @@ it("does not reserve an empty eyebrow row", () => {
expect(screen.queryByText("你的记忆,从这里接上")).toBeNull();
});
it("keeps progress cards content-sized and the step-two footer in normal flow", () => {
it("keeps the v1.0.4 step-two split layout and progress geometry", () => {
const card = declaration(
"r6-import-progress.css",
".r6-import-progress-card",
@@ -94,13 +94,12 @@ it("keeps progress cards content-sized and the step-two footer in normal flow",
"first-import-r6.css",
".first-import-r6-main > .r6-import-progress",
),
).toMatch(/flex:\s*0 0 auto/);
expect(declaration("first-import-r6.css", ".first-import-r6-layout")).toMatch(
/height:\s*100%/,
).toMatch(/flex:\s*0 0 218px/);
const layout = declaration("first-import-r6.css", ".first-import-r6-layout");
expect(layout).toMatch(
/grid-template-columns:\s*minmax\(0,\s*738px\)\s+278px/,
);
expect(
declaration("first-import-r6.css", ".first-import-r6-feedback"),
).not.toMatch(/max-height|overflow/);
expect(layout).toMatch(/height:\s*560\.2px/);
expect(declaration("onboarding-r6.css", ".onboarding-r6-body")).toMatch(
/overflow-y:\s*auto/,
);
+13 -2
View File
@@ -19,13 +19,24 @@ 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: "保存修改" });
expect(button.getAttribute("data-pending-capable")).toBe("true");
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("保存修改");
+13 -4
View File
@@ -13,24 +13,33 @@ 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}
data-pending-capable={supportsPending || 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>
+9 -2
View File
@@ -204,7 +204,13 @@ it("uses one sidebar hierarchy and explains when a saved model is inactive in li
await user.click(screen.getByRole("button", { name: "界面语言" }));
expect(screen.getByRole("combobox", { name: "语言" })).toBeTruthy();
await user.click(screen.getByRole("button", { name: "模型配置" }));
expect(await screen.findByText("当前是基础记忆(lite)")).toBeTruthy();
expect(
await screen.findByText(
(_, element) =>
element?.tagName === "STRONG" &&
element.textContent === "基础记忆 · Lite",
),
).toBeTruthy();
expect(
screen.getByText(/是否开始整理取决于.*“用模型沉淀记忆”.*开关/),
).toBeTruthy();
@@ -243,8 +249,9 @@ it("carries a missing-model explanation into model configuration and moves focus
const user = userEvent.setup();
render(<R6Settings onDiagnostics={vi.fn()} />);
await user.click(await screen.findByRole("combobox", { name: "记忆模式" }));
await user.click(
await screen.findByRole("radio", { name: /智能记忆(pro)/ }),
await screen.findByRole("option", { name: /智能记忆 · Pro/ }),
);
expect(
+11 -8
View File
@@ -18,6 +18,7 @@ import {
type ModelSettingsActions,
type ModelSettingsFocusSection,
} from "./ModelSettings";
import { MODE_TIER, modeDisplayName } from "./memory-mode-ui";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Button, R6Switch } from "./R6Controls";
import { R6StateRecovery } from "./R6StateRecovery";
@@ -394,14 +395,16 @@ export function R6Settings({
{currentModel && (
<div className="r6-model-mode-status" role="status">
<strong>
{text(
memoryMode === "lite"
? "当前是基础记忆(lite)"
: memoryMode === "ultra"
? "当前是深度记忆(ultra)"
: memoryMode === "pro"
? "当前是智能记忆(pro)"
: "正在读取记忆模式…",
{memoryMode ? (
<>
{modeDisplayName(text, memoryMode)}
<span className="r6-model-mode-tier">
{" "}
· {MODE_TIER[memoryMode]}
</span>
</>
) : (
text("正在读取记忆模式…")
)}
</strong>
<p>
+12 -7
View File
@@ -8,14 +8,19 @@ function declaration(file: string, selector: string): string {
}
it("fills settings controls without widening shared fallback controls", () => {
for (const selector of [
".r6-memory-mode-settings .memory-mode-cards",
const modeMenu = declaration(
"r6-settings.css",
".r6-settings .r6-mode-option-menu",
);
expect(modeMenu).toMatch(/(?:^|[\s;])width:\s*100%\s*;/);
expect(modeMenu).toMatch(/(?:^|[\s;])max-width:\s*none\s*;/);
const fallback = declaration(
"r6-settings.css",
".r6-settings .connection-fallback .custom-harness-name",
]) {
const rule = declaration("r6-settings.css", selector);
expect(rule, selector).toMatch(/(?:^|[\s;])width:\s*100%\s*;/);
expect(rule, selector).toMatch(/(?:^|[\s;])max-width:\s*none\s*;/);
}
);
expect(fallback).toMatch(/(?:^|[\s;])width:\s*100%\s*;/);
expect(fallback).toMatch(/(?:^|[\s;])max-width:\s*none\s*;/);
expect(declaration("core-visual.css", ".custom-harness-name")).toMatch(
/(?:^|[\s;])max-width:\s*420px\s*;/,
);
+40 -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,41 @@ 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("requests one Pro-sized raw graph page", async () => {
get.mockResolvedValue({
data: {
user_id: "local",
items: [],
total: 1_600,
offset: 0,
limit: 300,
has_more: true,
},
});
const signal = new AbortController().signal;
await getSdkRawGraph({
source: "cursor",
offset: 0,
limit: 300,
signal,
});
expect(get).toHaveBeenCalledWith("/api/v2/raw/graph", {
params: {
source: "cursor",
offset: 0,
limit: 300,
},
signal,
});
});
+21
View File
@@ -45,6 +45,7 @@ import {
type SdkMemoryStatus,
type SdkMemoryVariant,
type SdkRawContextItem,
type SdkRawGraphPage,
type SdkRawPage,
type SourceDirectoryCandidate,
type SourceImportReceipt,
@@ -1015,6 +1016,24 @@ export async function getSdkRaw({
return response.data;
}
export async function getSdkRawGraph({
source,
offset = 0,
limit = 300,
signal,
}: {
source?: string;
offset?: number;
limit?: number;
signal?: AbortSignal;
} = {}): Promise<SdkRawGraphPage> {
const response = await client.get<SdkRawGraphPage>("/api/v2/raw/graph", {
params: { source, offset, limit },
signal,
});
return response.data;
}
export async function searchSdkRaw(
query: string,
options: { source?: string; signal?: AbortSignal } = {},
@@ -1031,6 +1050,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;
}
+49 -161
View File
@@ -1,10 +1,10 @@
/* 143:2419/2502/2594/2685: only step2; shared frame/progress are owned elsewhere. */
.first-import-r6-layout {
display: flex;
flex-direction: column;
gap: 18px;
min-height: 100%;
height: auto;
display: grid;
grid-template-columns: minmax(0, 738px) 278px;
gap: 30px;
height: 560.2px;
min-height: 0;
}
.first-import-r6-main {
min-width: 0;
@@ -13,23 +13,19 @@
flex-direction: column;
}
.first-import-r6-intro {
margin: 0 0 8px;
}
.first-import-r6-intro h2 {
margin: 0 0 3px;
color: #262a2e;
font-size: 16px;
font-weight: 600;
line-height: 26px;
}
.first-import-r6-intro p {
margin: 0;
color: #5a6169;
font-size: 13px;
line-height: 25px;
}
.first-import-r6-subintro {
margin: 1px 0 20px;
color: #63707b;
font-size: 12px;
line-height: 20px;
line-height: 24px;
}
.first-import-r6-history-window {
margin: 0 0 12px;
margin: -10px 0 12px;
padding: 8px 10px;
border: 1px solid #ead7a7;
border-radius: 8px;
@@ -39,107 +35,13 @@
line-height: 20px;
}
.first-import-r6-main > .r6-import-progress {
flex: 0 0 auto;
width: 100%;
margin-inline: auto;
}
.first-import-r6-main > .r6-import-progress[data-count="1"] {
width: min(680px, 100%);
grid-auto-columns: 100%;
}
.first-import-r6-main > .r6-import-progress[data-count="2"] {
width: min(720px, 100%);
grid-auto-columns: calc((100% - 16px) / 2);
}
.first-import-r6-main > .r6-import-progress .r6-import-progress-card {
min-height: 112px;
padding: 12px 18px;
display: grid;
grid-template-columns: 82px minmax(0, 1fr);
grid-template-rows: auto auto auto;
align-content: center;
column-gap: 16px;
text-align: left;
}
.first-import-r6-main > .r6-import-progress .r6-import-progress-orbit {
width: 76px;
height: 76px;
grid-column: 1;
grid-row: 1 / 4;
margin: 0;
}
.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-main
> .r6-import-progress[data-count="many"]:not(:has(> :nth-child(4))) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.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-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-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-main
> .r6-import-progress[data-count="many"]:has(> :nth-child(7)) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.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;
flex: 0 0 218px;
}
.first-import-r6-feed {
flex: 0 0 auto;
margin-top: 20px;
overflow: visible;
min-height: 0;
flex: 1;
margin-top: 26px;
overflow: auto;
scrollbar-width: thin;
overscroll-behavior: contain;
}
@@ -186,14 +88,9 @@
margin: 0;
}
.first-import-r6-options {
flex: 1 1 auto;
min-width: 0;
min-height: 132px;
padding: 22px 24px;
display: grid;
grid-template-columns: minmax(190px, 0.7fr) minmax(0, 1.3fr);
align-items: center;
gap: 0 28px;
min-height: 0;
padding: 24px;
border: 1px solid #ffffffe0;
border-radius: 22px;
background:
@@ -202,13 +99,9 @@
box-shadow:
inset 0 1px #fff,
0 12px 32px rgb(49 75 101 / 4%);
overflow: visible;
overflow: auto;
scrollbar-width: thin;
overscroll-behavior: contain;
align-self: stretch;
}
.first-import-r6-options-head {
min-width: 0;
}
.first-import-r6-eyebrow {
margin: 0 0 14px;
@@ -235,12 +128,12 @@
position: relative;
display: block;
width: 100%;
min-height: 104px;
margin: 0;
padding: 18px 52px 16px 18px;
min-height: 124px;
margin: 0 0 16px;
padding: 18px 16px 12px;
border: 1px solid rgb(179 196 209 / 42%);
border-radius: 12px;
background: rgb(255 255 255 / 34%);
border-radius: 0;
background: rgb(255 255 255 / 10%);
box-shadow: inset 0 1px 1px #ffffffd9;
backdrop-filter: blur(20px);
cursor: pointer;
@@ -269,8 +162,8 @@
.first-import-r6-selection input {
appearance: none;
position: absolute;
top: 20px;
right: 20px;
top: 22px;
right: 18px;
width: 16px;
height: 16px;
min-height: 16px;
@@ -308,11 +201,10 @@
display: block;
font-size: 12px;
line-height: 20px;
margin-top: 7px;
margin-top: 8px;
color: #5e6e7a;
}
.first-import-r6-options-note {
grid-column: 1 / -1;
font-size: 11px;
line-height: 22px;
margin: 0 0 12px;
@@ -332,37 +224,33 @@
.first-import-r6-options details p {
margin: 6px 0;
}
.first-import-r6-feedback {
margin: 0 0 12px;
padding: 10px 12px;
border-radius: 8px;
background: rgb(255 255 255 / 50%);
.first-import-r6-footer-note {
flex: 1;
min-width: 0;
max-height: 64px;
overflow: auto;
}
.first-import-r6-feedback p {
.first-import-r6-footer-note p {
margin: 0;
font-size: 12px;
line-height: 20px;
color: #63707b;
}
.first-import-r6-feedback [role="alert"] {
.first-import-r6-footer-note [role="alert"] {
color: #944242;
}
.first-import-r6-feedback .first-import-r6-partial-success {
.first-import-r6-footer-note .first-import-r6-partial-success {
color: #245f50;
font-weight: 600;
}
.first-import-r6-footer-actions {
display: flex;
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
flex-wrap: wrap;
gap: 12px;
gap: 16px;
align-items: center;
margin-left: auto;
}
.first-import-r6-footer-actions > [data-variant="primary"] {
min-width: min(160px, 100%);
min-width: 210px;
}
.first-import-r6-footer-actions .r6-button-label {
display: inline-flex;
@@ -509,12 +397,13 @@ dialog.first-import-r6-original[open] {
}
@media (max-width: 1150px) {
.first-import-r6-layout {
gap: 18px;
grid-template-columns: minmax(0, 1fr) 278px;
gap: 24px;
}
}
@media (min-width: 761px) and (max-height: 959px) {
.onboarding-r6-body:has(> .first-import-r6-layout) {
overflow-y: auto;
overflow: hidden;
padding-top: 16px;
padding-bottom: 16px;
}
@@ -524,8 +413,8 @@ dialog.first-import-r6-original[open] {
}
@media (min-width: 761px) and (max-height: 859px) {
.first-import-r6-options {
padding: 18px 20px;
overflow: visible;
padding: 14px;
overflow: hidden;
}
.first-import-r6-eyebrow {
margin-bottom: 4px;
@@ -542,16 +431,16 @@ dialog.first-import-r6-original[open] {
}
.first-import-r6-selection,
.first-import-r6-selection + .first-import-r6-selection {
min-height: 96px;
margin-bottom: 0;
padding: 15px 48px 13px 16px;
min-height: 86px;
margin-bottom: 8px;
padding: 9px 12px;
}
.first-import-r6-selection strong {
line-height: 24px;
}
.first-import-r6-selection input {
top: 17px;
right: 16px;
top: 13px;
right: 12px;
}
.first-import-r6-selection span {
margin-top: 4px;
@@ -572,7 +461,6 @@ dialog.first-import-r6-original[open] {
overflow: visible;
}
.first-import-r6-options {
display: block;
overflow: visible;
}
.first-import-r6-footer-note {
+298 -42
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;
@@ -38,15 +50,26 @@
box-shadow: 0 10px 26px rgb(47 107 87 / 8%);
}
.memory-mode-cards--rows
.memory-mode-card[data-mode="ultra"][data-selected="true"] {
min-height: 166px;
}
.memory-mode-card[data-disabled="true"] {
opacity: 0.55;
}
.memory-mode-card-choice {
min-height: 284px;
padding: 20px;
display: flex;
flex-direction: column;
position: relative;
flex: 1 1 auto;
min-height: 0;
height: auto;
padding: 18px 20px;
display: grid;
grid-template-columns: minmax(250px, 0.9fr) minmax(0, 1.6fr);
grid-template-rows: auto auto;
column-gap: 24px;
align-items: center;
cursor: pointer;
}
@@ -68,10 +91,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 {
@@ -96,7 +122,6 @@
font-size: 11px;
font-weight: 650;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.memory-mode-current {
@@ -110,9 +135,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 +160,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,42 +172,152 @@
}
.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 24px;
display: grid;
gap: 8px;
border-top: 1px solid #dbe4ea;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-content: center;
gap: 0;
color: #5e6e7a;
font-size: 12px;
line-height: 19px;
}
.memory-mode-features > span {
position: relative;
display: flex;
align-items: flex-start;
gap: 8px;
align-items: center;
min-width: 0;
min-height: 40px;
padding: 0 16px;
overflow-wrap: break-word;
hyphens: auto;
}
.memory-mode-features > span::before {
content: "";
position: absolute;
top: 50%;
left: 0;
width: 1px;
height: 22px;
background: rgb(199 211 220 / 78%);
transform: translateY(-50%);
}
.memory-mode-features i {
width: 5px;
height: 5px;
margin-top: 7px;
flex: 0 0 5px;
border-radius: 50%;
background: #7f95a4;
display: none;
}
.memory-mode-detail {
margin-top: auto;
padding: 14px 18px;
border-top: 1px solid #d4e0e7;
background: #f1f6f8;
width: auto;
margin: 7px 18px 12px;
padding: 10px 0 0;
display: flex;
align-items: flex-start;
gap: 9px;
border-top: 1px solid rgb(166 190 179 / 48%);
background: transparent;
color: #405867;
font-size: 12px;
line-height: 20px;
line-height: 18px;
animation: memory-mode-detail-in 180ms ease-out both;
}
.memory-mode-detail > svg {
color: #527565;
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;
grid-template-columns: minmax(0, 1fr);
border-top: 1px solid #dbe4ea;
border-left: 0;
}
.memory-mode-cards--grid .memory-mode-features > span {
min-height: 34px;
padding: 6px 0;
}
.memory-mode-cards--grid .memory-mode-features > span + span {
border-top: 1px solid rgb(199 211 220 / 72%);
border-left: 0;
}
.memory-mode-cards--grid .memory-mode-features > span::before {
display: none;
}
.memory-mode-cards--grid .memory-mode-detail {
width: auto;
margin: 8px 18px 16px;
padding: 12px 0 0;
display: block;
background: transparent;
font-size: 12px;
line-height: 20px;
}
.memory-mode-cards--grid .memory-mode-detail > svg {
display: none;
}
@keyframes memory-mode-detail-in {
from {
opacity: 0;
@@ -185,49 +325,165 @@
}
}
@media (min-width: 761px) and (max-height: 959px) {
.memory-mode-cards--rows {
min-height: 284px;
}
.memory-mode-cards--rows .memory-mode-card-list {
gap: 10px;
}
.memory-mode-cards--rows .memory-mode-card {
min-height: 88px;
}
.memory-mode-cards--rows
.memory-mode-card[data-mode="ultra"][data-selected="true"] {
min-height: 116px;
}
.memory-mode-cards--rows .memory-mode-card-choice {
min-height: 0;
padding: 11px 16px;
grid-template-columns: minmax(220px, 0.85fr) minmax(0, 1.65fr);
column-gap: 18px;
}
.memory-mode-cards--rows .memory-mode-radio {
top: 11px;
right: 16px;
}
.memory-mode-cards--rows .memory-mode-title strong {
font-size: 16px;
line-height: 22px;
}
.memory-mode-cards--rows .memory-mode-description {
min-height: 0;
margin-top: 2px;
font-size: 11px;
line-height: 16px;
}
.memory-mode-cards--rows .memory-mode-features {
margin-top: 0;
padding: 0 34px 0 18px;
gap: 0;
font-size: 11px;
line-height: 15px;
}
.memory-mode-cards--rows .memory-mode-features > span {
min-height: 32px;
padding: 0 10px;
}
.memory-mode-cards--rows .memory-mode-detail {
margin: 6px 14px 10px;
padding: 8px 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;
grid-template-columns: minmax(0, 1fr);
border-top: 1px solid #dbe4ea;
border-left: 0;
}
.memory-mode-cards--rows .memory-mode-features > span {
min-height: 34px;
padding: 6px 0;
}
.memory-mode-cards--rows .memory-mode-features > span + span {
border-top: 1px solid rgb(199 211 220 / 72%);
border-left: 0;
}
.memory-mode-cards--rows .memory-mode-features > span::before {
display: none;
}
.memory-mode-cards--rows .memory-mode-detail {
width: auto;
margin: 8px 20px 14px;
padding: 12px 0 0;
background: transparent;
}
}
@media (min-width: 681px) and (max-height: 959px) {
.memory-mode-cards {
.memory-mode-cards--grid {
gap: 14px;
}
.memory-mode-card {
.memory-mode-cards--grid .memory-mode-card {
min-height: 194px;
}
.memory-mode-card-choice {
.memory-mode-cards--grid .memory-mode-card-choice {
min-height: 194px;
padding: 16px;
}
.memory-mode-title strong {
.memory-mode-cards--grid .memory-mode-title strong {
font-size: 17px;
line-height: 25px;
}
.memory-mode-description {
.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 {
padding: 10px 14px;
.memory-mode-cards--grid .memory-mode-detail {
width: auto;
margin: 6px 14px 14px;
padding: 10px 0 0;
font-size: 11px;
line-height: 18px;
}
}
@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;
}
}
+17 -1
View File
@@ -21,6 +21,22 @@ export const MODE_COPY: Record<
},
};
export const MODE_TIER: Record<MemoryPipelineMode, string> = {
lite: "Lite",
pro: "Pro",
ultra: "Ultra",
};
export function modeDisplayName(
text: (value: string) => string,
mode: MemoryPipelineMode,
) {
return text(MODE_COPY[mode].title)
.replace(/\s*[((](?:lite|pro|ultra)[))]\s*/i, " ")
.replace(/\s{2,}/g, " ")
.trim();
}
export const BLOCKER_COPY: Record<MemoryModePlan["blockers"][number], string> =
{
active_model_required: "使用 pro 或 ultra 前需要先配置并启用生成模型。",
@@ -184,7 +200,7 @@ export function MemoryModeConfirmDialog({
<h2 id="memory-mode-confirm-title">
{text("切换到 {mode}?").replace(
"{mode}",
text(MODE_COPY[mode].title),
`${modeDisplayName(text, mode)} · ${MODE_TIER[mode]}`,
)}
</h2>
<dl className="r6-mode-confirm-effects">
+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) {
+47 -6
View File
@@ -588,6 +588,25 @@
font-size: 11px;
line-height: 18px;
}
.onboarding-r6-schedule-loading {
margin: 0;
color: #607482;
font-size: 12px;
line-height: 20px;
}
.memory-mode-detail .onboarding-r6-schedule-recovery {
width: 100%;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
text-align: left;
}
.memory-mode-detail .onboarding-r6-schedule-retry {
flex: 0 0 auto;
min-width: 76px;
}
.onboarding-r6-evolve-settings {
margin-top: 0;
padding: 18px 20px 4px;
@@ -615,15 +634,12 @@
font-weight: 600;
line-height: 22px;
}
.onboarding-r6-schedule-recovery {
display: grid;
justify-items: center;
gap: 10px;
.onboarding-r6-mode .onboarding-r6-schedule-recovery {
color: #944242;
font-size: 13px;
font-size: 12px;
line-height: 20px;
}
.onboarding-r6-schedule-recovery p {
.onboarding-r6-mode .onboarding-r6-schedule-recovery p {
margin: 0;
}
.onboarding-r6-mode-error {
@@ -634,6 +650,24 @@
font-size: 13px;
line-height: 20px;
}
@media (min-width: 761px) {
.onboarding-r6-mode {
min-height: 100%;
}
}
@media (min-width: 761px) and (max-height: 959px) {
.onboarding-r6-mode {
gap: 12px;
}
.onboarding-r6-mode-intro h2 {
font-size: 20px;
line-height: 28px;
}
.onboarding-r6-mode-intro p {
font-size: 12px;
line-height: 20px;
}
}
.onboarding-r6-empty {
margin: 0;
padding: 28px 18px;
@@ -802,6 +836,13 @@
flex-wrap: wrap;
flex-basis: auto;
}
.memory-mode-detail .onboarding-r6-schedule-recovery {
flex-direction: column;
align-items: stretch;
}
.memory-mode-detail .onboarding-r6-schedule-retry {
align-self: flex-start;
}
}
@media (prefers-reduced-transparency: reduce) {
.onboarding-r6-shell {
+20 -2
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%);
@@ -190,6 +190,10 @@ button.r6-button[data-variant="primary"] {
0 3px 8px -2px rgb(64 87 102 / 12%);
}
button.r6-button[data-pending-capable="true"] {
padding-inline: 32px;
}
button.r6-button[data-variant="quiet"] {
height: 36px;
min-height: 36px;
@@ -204,6 +208,10 @@ button.r6-button[data-variant="quiet"] {
color: var(--r6-muted);
}
button.r6-button[data-variant="quiet"][data-pending-capable="true"] {
padding-inline: 24px;
}
button.r6-button:hover:not(:disabled) {
background: rgb(231 239 244 / 60%);
border-color: #aabcc8;
@@ -242,16 +250,26 @@ button.r6-button[data-variant="quiet"]:disabled {
}
.r6-button-label {
min-width: 0;
text-align: center;
}
.r6-button-spinner {
position: absolute;
right: 5px;
top: 50%;
right: 12px;
flex: 0 0 auto;
width: 12px;
height: 12px;
margin-top: -6px;
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;
+154 -14
View File
@@ -227,26 +227,140 @@
font-size: 13px;
line-height: 22px;
}
.r6-memory-mode-settings .memory-mode-cards {
.r6-settings .r6-mode-setting-row {
width: 100%;
margin-top: 4px;
}
.r6-mode-control[data-expanded="true"] {
border: 1px solid #cbd6de;
border-radius: 10px;
background: #fbfcfd;
box-shadow:
inset 0 1px 0 #fff,
0 4px 14px rgb(48 71 87 / 5%);
}
.r6-mode-control[data-expanded="true"] .r6-mode-setting-row {
margin-top: 0;
border-bottom: 1px solid #dbe4ea;
}
.r6-settings
.r6-mode-control[data-expanded="true"]
.r6-mode-option-menu
.om-option-menu-button {
border: 0;
border-radius: 9px 9px 0 0;
box-shadow: inset 0 1px 0 #fff;
}
.r6-settings .r6-mode-option-menu {
width: 100%;
max-width: none;
}
.r6-memory-mode-settings
.memory-mode-card[data-mode="ultra"][data-selected="true"] {
grid-column: 1 / -1;
.r6-settings
.r6-mode-option-menu.r6-language-option-menu
.om-option-menu-button {
height: auto;
min-height: 72px;
padding: 12px 64px 12px 18px;
border-color: #cbd6de;
border-radius: 10px;
background: #f8fafb;
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 90%),
0 1px 2px rgb(48 71 87 / 4%);
white-space: normal;
transition:
border-color 140ms ease-out,
background-color 140ms ease-out,
box-shadow 140ms ease-out;
}
.r6-memory-mode-settings
.memory-mode-card[data-mode="ultra"][data-selected="true"]
.memory-mode-card-choice {
min-height: 180px;
.r6-settings
.r6-mode-option-menu.r6-language-option-menu
.om-option-menu-button::before {
content: "";
position: absolute;
top: 12px;
right: 52px;
bottom: 12px;
width: 1px;
background: #dbe4ea;
}
.r6-evolve-settings--in-card {
margin: 0;
padding: 0;
border-top: 0;
.r6-settings
.r6-mode-option-menu.r6-language-option-menu
.om-option-menu-button::after {
right: 22px;
}
.r6-evolve-settings--in-card .r6-evolve-setting:last-child {
border-bottom: 0;
.r6-mode-option-menu.r6-language-option-menu
.om-option-menu-button:hover:not(:disabled) {
border-color: #a8bac5;
background: #fbfcfd;
box-shadow:
inset 0 1px 0 #fff,
0 4px 12px rgb(48 71 87 / 7%);
}
.r6-settings
.r6-mode-option-menu.r6-language-option-menu
.om-option-menu-button[aria-expanded="true"] {
border-color: #7892a6;
background: #fff;
box-shadow:
inset 0 1px 0 #fff,
0 0 0 3px rgb(85 124 147 / 10%);
}
.r6-mode-option-menu .om-option-menu-copy,
.r6-mode-option-menu-panel .om-option-menu-copy {
display: grid;
gap: 3px;
min-width: 0;
}
.r6-mode-option-menu .om-option-menu-copy > span,
.r6-mode-option-menu-panel .om-option-menu-copy > span {
color: #304757;
font-size: 14px;
font-weight: 600;
line-height: 22px;
letter-spacing: 0.01em;
}
.r6-mode-option-menu .om-option-menu-suffix,
.r6-mode-option-menu-panel .om-option-menu-suffix {
color: var(--ok, #2f6b57);
font-size: 12px;
font-weight: 650;
letter-spacing: 0.03em;
}
.r6-mode-option-menu .om-option-menu-copy > small,
.r6-mode-option-menu-panel .om-option-menu-copy > small {
color: #526674;
font-size: 12px;
font-weight: 400;
line-height: 20px;
letter-spacing: 0.01em;
overflow-wrap: anywhere;
}
.r6-language-option-menu-panel--settings.r6-mode-option-menu-panel {
padding: 8px;
border-color: #cbd6de;
border-radius: 12px;
box-shadow: 0 14px 32px rgb(48 71 87 / 12%);
}
.r6-language-option-menu-panel--settings.r6-mode-option-menu-panel
[role="option"] {
min-height: 64px;
padding: 10px 12px;
border-radius: 8px;
white-space: normal;
}
.r6-language-option-menu-panel--settings.r6-mode-option-menu-panel
[role="option"][aria-selected="true"] {
background: #e8eff3;
}
.r6-language-option-menu-panel--settings.r6-mode-option-menu-panel
[role="option"][data-active="true"] {
background: #f0f4f6;
box-shadow: inset 2px 0 #607b8e;
}
.r6-language-option-menu-panel--settings.r6-mode-option-menu-panel
[role="option"][aria-selected="true"][data-active="true"] {
background: #e8eff3;
}
.r6-settings-note {
margin: 14px 0 0;
@@ -261,11 +375,18 @@
margin-top: 28px;
padding-top: 24px;
border-top: 1px solid #d8e2ea;
animation: r6-evolve-settings-in 180ms ease-out both;
}
.r6-evolve-settings:focus-visible {
outline: 2px solid var(--r6-focus);
outline-offset: 4px;
}
@keyframes r6-evolve-settings-in {
from {
opacity: 0;
transform: translateY(-4px);
}
}
.r6-evolve-heading {
margin-bottom: 8px;
}
@@ -692,6 +813,20 @@
line-height: 22px;
margin: 8px 0 0;
}
.r6-mode-control[data-expanded="true"] > .r6-settings-note,
.r6-mode-control[data-expanded="true"] > .r6-settings-error {
margin-right: 18px;
margin-left: 18px;
}
.r6-mode-control[data-expanded="true"] > .r6-evolve-settings {
margin: 18px 18px 0;
padding-top: 20px;
}
.r6-mode-control[data-expanded="true"]
> .r6-evolve-settings
.r6-evolve-setting--manual {
border-bottom: 0;
}
.r6-settings .r6-settings-read-status {
color: #63707b;
font-size: 13px;
@@ -727,6 +862,11 @@
font-weight: 600;
line-height: 22px;
}
.r6-settings .r6-model-mode-tier {
color: var(--ok, #2f6b57);
font-weight: 650;
letter-spacing: 0.03em;
}
.r6-settings .r6-model-mode-status p {
margin: 2px 0 0;
color: var(--r6-muted);
+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 = 20;
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 45)的发行目标如下:
| 平台 | 发行物 | 构建与验收 |
| --- | --- | --- |
@@ -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": 45,
"macos_min_version": "13.0",
"app_update": "manual",
"runtime_manifest_schema": 1,
+22
View File
@@ -720,6 +720,28 @@ export interface SdkRawPage {
time_filtered?: boolean;
}
/** Lightweight bounded-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[];
/** SDK-reported matching raw scope; `items` remains capped by `limit`. */
total: number;
limit: number;
offset: number;
/** More matching turns exist, but the product map does not auto-page them. */
has_more: boolean;
}
/** `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",
+41 -1
View File
@@ -10,6 +10,7 @@ const modeChineseMessages = {
选择记忆方式: "选择记忆方式",
选择记忆模式: "选择记忆模式",
"之后可在设置中更改。": "之后可在设置中更改。",
"后续可在设置页自定义时间或关闭。": "后续可在设置页自定义时间或关闭。",
"Ultra 可在完成首次设置后到设置页开启。":
"Ultra 可在完成首次设置后到设置页开启。",
"使用 Lite 进入": "使用 Lite 进入",
@@ -23,7 +24,7 @@ const modeChineseMessages = {
"当前模型已经启用,可以继续完成首次设置。":
"当前模型已经启用,可以继续完成首次设置。",
继续使用当前模型: "继续使用当前模型",
"你希望 MemHub 如何形成记忆?": "选择记忆模式",
"你希望 MemHub 如何形成记忆?": "你希望 MemHub 如何形成记忆?",
"需要明确选择一种方式;之后仍可在设置中更改。": "之后可在设置中更改。",
记忆模式: "记忆模式",
记忆模式与模型: "记忆模式与模型",
@@ -61,6 +62,7 @@ const modeChineseMessages = {
"模式切换未能确认,仍显示上次确认的模式。":
"模式选择未能确认,仍显示上次确认的模式。",
"自动整合设置未能保存,请重试。": "自动整合设置未能保存,请重试。",
"自动整合设置暂时无法读取,请重试。": "自动整合设置暂时无法读取,请重试。",
"正在检查或切换向量模型,完成后才能整合记忆。":
"正在检查或切换向量模型,完成后才能整合记忆。",
"未能开始整合记忆,请重试。": "未能开始整合记忆,请重试。",
@@ -200,6 +202,8 @@ export const modeEnglishMessages: ModeMessages = {
选择记忆方式: "Choose how memory works",
选择记忆模式: "Choose memory mode",
"之后可在设置中更改。": "You can change this later in Settings.",
"后续可在设置页自定义时间或关闭。":
"You can later customize the time or turn this off in Settings.",
"Ultra 可在完成首次设置后到设置页开启。":
"Ultra can be enabled in Settings after initial setup.",
"使用 Lite 进入": "Enter with Lite",
@@ -257,6 +261,8 @@ export const modeEnglishMessages: ModeMessages = {
"The mode change could not be confirmed. The last confirmed mode is still shown.",
"自动整合设置未能保存,请重试。":
"Could not save automatic consolidation settings. Please retry.",
"自动整合设置暂时无法读取,请重试。":
"Could not load automatic consolidation settings. Please retry.",
"正在检查或切换向量模型,完成后才能整合记忆。":
"The embedding model is being checked or changed. Wait for it to finish before consolidating memories.",
"未能开始整合记忆,请重试。":
@@ -404,6 +410,8 @@ export const modeSpanishMessages: ModeMessages = {
选择记忆方式: "Elegir cómo funciona la memoria",
选择记忆模式: "Elegir el modo de memoria",
"之后可在设置中更改。": "Puedes cambiarlo más adelante en Configuración.",
"后续可在设置页自定义时间或关闭。":
"Más adelante, puedes personalizar la hora o desactivarlo en Configuración.",
"Ultra 可在完成首次设置后到设置页开启。":
"Puedes activar Ultra en Configuración después de completar la configuración inicial.",
"使用 Lite 进入": "Entrar con Lite",
@@ -461,6 +469,8 @@ export const modeSpanishMessages: ModeMessages = {
"No se pudo confirmar el cambio. Se sigue mostrando el último modo confirmado.",
"自动整合设置未能保存,请重试。":
"No se pudo guardar la consolidación automática. Reinténtalo.",
"自动整合设置暂时无法读取,请重试。":
"No se pudo cargar la configuración de consolidación automática. Reinténtalo.",
"正在检查或切换向量模型,完成后才能整合记忆。":
"Se está comprobando o cambiando el modelo de embeddings. Espera para consolidar memorias.",
"未能开始整合记忆,请重试。":
@@ -607,6 +617,7 @@ export const modeTraditionalChineseMessages: ModeMessages = {
选择记忆方式: "選擇記憶方式",
选择记忆模式: "選擇記憶模式",
"之后可在设置中更改。": "之後可在設定中更改。",
"后续可在设置页自定义时间或关闭。": "之後可在設定頁自訂時間或關閉。",
"Ultra 可在完成首次设置后到设置页开启。":
"完成首次設定後,可到設定頁開啟 Ultra。",
"使用 Lite 进入": "使用 Lite 進入",
@@ -660,6 +671,7 @@ export const modeTraditionalChineseMessages: ModeMessages = {
"模式切换未能确认,仍显示上次确认的模式。":
"無法確認模式切換,仍顯示上次確認的模式。",
"自动整合设置未能保存,请重试。": "自動整合設定無法儲存,請重試。",
"自动整合设置暂时无法读取,请重试。": "暫時無法讀取自動整合設定,請重試。",
"正在检查或切换向量模型,完成后才能整合记忆。":
"正在檢查或切換向量模型,完成後才能整合記憶。",
"未能开始整合记忆,请重试。": "無法開始整合記憶,請重試。",
@@ -795,6 +807,8 @@ export const modeJapaneseMessages: ModeMessages = {
选择记忆方式: "記憶方法を選択",
选择记忆模式: "記憶モードを選択",
"之后可在设置中更改。": "後から設定で変更できます。",
"后续可在设置页自定义时间或关闭。":
"後から設定画面で時刻を変更したり、無効にしたりできます。",
"Ultra 可在完成首次设置后到设置页开启。":
"初期設定の完了後、設定画面で Ultra を有効にできます。",
"使用 Lite 进入": "Lite で開始",
@@ -940,6 +954,8 @@ export const modeJapaneseMessages: ModeMessages = {
"モード変更を確認できませんでした。最後に確認されたモードを表示しています。",
"自动整合设置未能保存,请重试。":
"自動統合の設定を保存できませんでした。もう一度お試しください。",
"自动整合设置暂时无法读取,请重试。":
"自動統合の設定を読み込めませんでした。もう一度お試しください。",
"正在检查或切换向量模型,完成后才能整合记忆。":
"埋め込みモデルを確認または切り替えています。完了後に記憶を統合できます。",
"未能开始整合记忆,请重试。":
@@ -994,6 +1010,8 @@ export const modeGermanMessages: ModeMessages = {
选择记忆方式: "Erinnerungsmodus wählen",
选择记忆模式: "Erinnerungsmodus auswählen",
"之后可在设置中更改。": "Sie können dies später in den Einstellungen ändern.",
"后续可在设置页自定义时间或关闭。":
"Später können Sie die Uhrzeit in den Einstellungen anpassen oder die Funktion deaktivieren.",
"Ultra 可在完成首次设置后到设置页开启。":
"Ultra kann nach der Ersteinrichtung in den Einstellungen aktiviert werden.",
"使用 Lite 进入": "Mit Lite starten",
@@ -1143,6 +1161,8 @@ export const modeGermanMessages: ModeMessages = {
"Der Moduswechsel konnte nicht bestätigt werden. Der zuletzt bestätigte Modus wird weiterhin angezeigt.",
"自动整合设置未能保存,请重试。":
"Die Einstellungen zur automatischen Konsolidierung konnten nicht gespeichert werden. Bitte erneut versuchen.",
"自动整合设置暂时无法读取,请重试。":
"Die Einstellungen zur automatischen Konsolidierung konnten nicht geladen werden. Bitte erneut versuchen.",
"正在检查或切换向量模型,完成后才能整合记忆。":
"Das Embedding-Modell wird geprüft oder gewechselt. Danach können Erinnerungen konsolidiert werden.",
"未能开始整合记忆,请重试。":
@@ -1198,6 +1218,8 @@ export const modePortugueseMessages: ModeMessages = {
选择记忆方式: "Escolher o modo de memória",
选择记忆模式: "Escolher o modo de memória",
"之后可在设置中更改。": "Pode alterar esta opção mais tarde nas Definições.",
"后续可在设置页自定义时间或关闭。":
"Mais tarde, pode personalizar a hora ou desativar esta opção nas Definições.",
"Ultra 可在完成首次设置后到设置页开启。":
"Pode ativar o Ultra nas Definições depois da configuração inicial.",
"使用 Lite 进入": "Entrar com Lite",
@@ -1347,6 +1369,8 @@ export const modePortugueseMessages: ModeMessages = {
"Não foi possível confirmar a mudança. Continua a ser mostrado o último modo confirmado.",
"自动整合设置未能保存,请重试。":
"Não foi possível guardar a consolidação automática. Tente novamente.",
"自动整合设置暂时无法读取,请重试。":
"Não foi possível carregar as configurações de consolidação automática. Tente novamente.",
"正在检查或切换向量模型,完成后才能整合记忆。":
"O modelo de embeddings está a ser verificado ou alterado. Aguarde para consolidar memórias.",
"未能开始整合记忆,请重试。":
@@ -1404,6 +1428,8 @@ export const modeFrenchMessages: ModeMessages = {
选择记忆模式: "Choisir le mode de mémoire",
"之后可在设置中更改。":
"Vous pourrez le modifier plus tard dans les réglages.",
"后续可在设置页自定义时间或关闭。":
"Vous pourrez ensuite personnaliser l’heure ou désactiver cette option dans les réglages.",
"Ultra 可在完成首次设置后到设置页开启。":
"Vous pourrez activer Ultra dans les réglages après la configuration initiale.",
"使用 Lite 进入": "Entrer avec Lite",
@@ -1553,6 +1579,8 @@ export const modeFrenchMessages: ModeMessages = {
"Le changement n’a pas pu être confirmé. Le dernier mode confirmé reste affiché.",
"自动整合设置未能保存,请重试。":
"Impossible d’enregistrer la consolidation automatique. Réessayez.",
"自动整合设置暂时无法读取,请重试。":
"Impossible de charger les paramètres de consolidation automatique. Réessayez.",
"正在检查或切换向量模型,完成后才能整合记忆。":
"Le modèle d’embeddings est en cours de vérification ou de changement. Patientez avant de consolider les mémoires.",
"未能开始整合记忆,请重试。":
@@ -1607,6 +1635,8 @@ export const modeKoreanMessages: ModeMessages = {
选择记忆方式: "메모리 방식 선택",
选择记忆模式: "메모리 모드 선택",
"之后可在设置中更改。": "나중에 설정에서 변경할 수 있습니다.",
"后续可在设置页自定义时间或关闭。":
"나중에 설정에서 시간을 변경하거나 끌 수 있습니다.",
"Ultra 可在完成首次设置后到设置页开启。":
"초기 설정을 마친 후 설정에서 Ultra를 켤 수 있습니다.",
"使用 Lite 进入": "Lite로 시작",
@@ -1752,6 +1782,8 @@ export const modeKoreanMessages: ModeMessages = {
"모드 전환을 확인하지 못했습니다. 마지막으로 확인된 모드가 계속 표시됩니다.",
"自动整合设置未能保存,请重试。":
"자동 통합 설정을 저장하지 못했습니다. 다시 시도하세요.",
"自动整合设置暂时无法读取,请重试。":
"자동 통합 설정을 불러오지 못했습니다. 다시 시도하세요.",
"正在检查或切换向量模型,完成后才能整合记忆。":
"임베딩 모델을 확인하거나 전환 중입니다. 완료된 후 메모리를 통합할 수 있습니다.",
"未能开始整合记忆,请重试。":
@@ -1805,6 +1837,8 @@ export const modeRussianMessages: ModeMessages = {
选择记忆方式: "Выберите режим памяти",
选择记忆模式: "Выберите режим памяти",
"之后可在设置中更改。": "Позже режим можно изменить в настройках.",
"后续可在设置页自定义时间或关闭。":
"Позже в настройках можно изменить время или отключить эту функцию.",
"Ultra 可在完成首次设置后到设置页开启。":
"Ultra можно включить в настройках после завершения первоначальной настройки.",
"使用 Lite 进入": "Войти с Lite",
@@ -1953,6 +1987,8 @@ export const modeRussianMessages: ModeMessages = {
"Не удалось подтвердить смену режима. По-прежнему показан последний подтверждённый режим.",
"自动整合设置未能保存,请重试。":
"Не удалось сохранить настройки автообъединения. Повторите попытку.",
"自动整合设置暂时无法读取,请重试。":
"Не удалось загрузить настройки автообъединения. Повторите попытку.",
"正在检查或切换向量模型,完成后才能整合记忆。":
"Модель эмбеддингов проверяется или меняется. После завершения можно будет объединить воспоминания.",
"未能开始整合记忆,请重试。":
@@ -2009,6 +2045,8 @@ export const modeIndonesianMessages: ModeMessages = {
选择记忆方式: "Pilih cara kerja memori",
选择记忆模式: "Pilih mode memori",
"之后可在设置中更改。": "Anda dapat mengubahnya nanti di Pengaturan.",
"后续可在设置页自定义时间或关闭。":
"Nanti, Anda dapat menyesuaikan waktunya atau menonaktifkannya di Pengaturan.",
"Ultra 可在完成首次设置后到设置页开启。":
"Ultra dapat diaktifkan di Pengaturan setelah penyiapan awal selesai.",
"使用 Lite 进入": "Masuk dengan Lite",
@@ -2156,6 +2194,8 @@ export const modeIndonesianMessages: ModeMessages = {
"Perubahan mode tidak dapat dikonfirmasi. Mode terakhir yang dikonfirmasi tetap ditampilkan.",
"自动整合设置未能保存,请重试。":
"Pengaturan konsolidasi otomatis tidak dapat disimpan. Coba lagi.",
"自动整合设置暂时无法读取,请重试。":
"Pengaturan konsolidasi otomatis tidak dapat dimuat. Coba lagi.",
"正在检查或切换向量模型,完成后才能整合记忆。":
"Model embedding sedang diperiksa atau diganti. Tunggu hingga selesai sebelum mengonsolidasikan memori.",
"未能开始整合记忆,请重试。":
@@ -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",
默认位置: "預設位置",
自定义位置: "自訂位置",