Compare commits

...
Author SHA1 Message Date
kristinheandCursor 6c762d75bf chore: make PR validation risk-based
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-06 15:16:50 +08:00
kristinhe f5a385c3ad Merge remote-tracking branch 'origin/main' into chore/clean-main-worktree-rule 2026-11-06 15:15:25 +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
kristinheandCursor 1b907225dd chore: require clean main worktrees
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 23:34:30 +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
kristinhe1111andCursor 45dbf46545 chore(release): bump MemHub build number to 43 (#348)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 18:23:03 +08:00
kristinhe1111andCursor fac7a43ffb style(memhub): refine onboarding setup flow (#350)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 18:19:49 +08:00
kristinhe1111andCursor 6e4b22a363 fix(memhub): align home overview metric rows (#347)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 16:58:48 +08:00
kristinhe1111 c186e5b2fb fix(memhub): recover cold-start import receipts (#349) 2026-11-05 16:58:25 +08:00
feitianxiang ad84e5decd Merge pull request #344 from Tencent-Hunyuan/fix/memhub-hooks-no-evidence
fix(memhub): capture hooks hand turns to Core instead of writing Evidence
2026-11-05 16:03:24 +08:00
kristinhe1111andCursor 0c43973fc2 chore(release): bump MemHub build to 42 (#341)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 14:29:10 +08:00
alvinfeiandClaude Opus 5.5 ebf16b9399 fix(memhub): capture hooks hand turns to Core instead of writing Evidence
`one-memory capture <host>` ran as a separate short-lived process on every
hook and, on every call, constructed `EvidenceStore` (re-creating the retired
evidence/FTS tables on an SDK-authority home), put Evidence rows and assembled
write jobs: three write transactions outside Core's single writer, contending
for SQLite's file lock with Core and with other hosts' hooks.

The hook process now never opens the database. It checks the migration fence,
reads the hook JSON and posts it to the running Core over loopback with the
runtime token (`POST /api/v1/live-capture/{source}`, 1.5 s budget); any
failure drops the turn silently with no local fallback. Core applies the
grant, sync switch, rejection counting, automated-session and Cursor/Claude
rules and stages the lines in-process through the live file-sync path
(`materialize_write_input` -> `stage_sdk_input` -> `finalize_sdk_file`, live
priority). No Evidence table is involved.

The hook and live file sync gave one message different identities (the hook
has no workspace and often no message id) and separate assembly rows, so a
turn both saw was written to the SDK twice. Hook input for a source live sync
reads from its own files is now left to that path; only a source without
file polling is staged from its hook.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-05 14:16:49 +08:00
kristinhe1111andCursor 87d70b0eb9 fix(memhub): clarify Lite raw conversation labels (#337)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 14:04:40 +08:00
kristinhe1111 603e0843c5 style(memhub): remove Pro recommendation badge 2026-11-05 11:08:33 +08:00
kristinhe1111 80f4231e46 style(memhub): simplify responsive onboarding 2026-11-05 10:52:04 +08:00
kristinhe1111 2f9e028c50 fix(memhub): share model probe readiness 2026-11-05 03:12:34 +08:00
kristinhe1111 4cbf4f1501 feat(memhub): offer all memory modes during onboarding 2026-11-05 02:40:34 +08:00
kristinhe1111 e5959578c8 fix(memhub): let Lite onboarding complete immediately 2026-11-05 01:48:15 +08:00
kristinhe1111 df5a65797c fix(memhub): page Lite raw turns without full scans 2026-11-05 00:55:07 +08:00
kristinhe1111 2ea48e68a6 Merge pull request #326 from Tencent-Hunyuan/fix/home-recent-update-bottom-align
fix(memhub): align recent activity to card bottom
2026-11-05 00:31:55 +08:00
kristinhe1111 b8fdde7d0b Merge pull request #327 from Tencent-Hunyuan/fix/model-technical-hover
fix(web): preserve model details hover contrast
2026-11-05 00:31:36 +08:00
kristinhe1111 5134524743 Merge pull request #328 from Tencent-Hunyuan/fix/ultra-evolve-time-picker
fix(memhub): replace native evolve time input
2026-11-05 00:31:20 +08:00
kristinhe1111 e34101cb0b Merge pull request #329 from Sthreal/fix/301-model-probe-pending-state
fix(memhub): fail model probe reservations when execution never dispatches
2026-11-05 00:31:00 +08:00
kristinhe1111 995c7e6c32 Merge pull request #330 from Tencent-Hunyuan/fix/memhub-complete-log-export
fix(memhub): export complete logs from Settings
2026-11-05 00:30:38 +08:00
kristinheandCursor f1afdb03cd fix(memhub): export complete logs from settings
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 23:49:21 +08:00
Sthreal 4df4ce72bd fix(memhub): fail model probe reservations when execution never dispatches 2026-11-04 23:33:06 +08:00
kristinheandCursor a6d9894975 fix(memhub): replace native evolve time input
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 22:31:00 +08:00
kristinheandCursor bce58e638d fix(memhub): align recent activity to card bottom
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 21:47:57 +08:00
135 changed files with 7954 additions and 2607 deletions

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
---
description: Require every coding task and PR to use an isolated worktree based on current main
alwaysApply: true
---
# Clean Main Worktree Workflow
For every task that may edit code:
1. Before editing, run `git fetch origin main`; if it fails, stop rather than use a stale ref.
2. Never develop in a dirty, detached, or stale primary workspace.
3. Record `BASE_SHA=$(git rev-parse origin/main)`, then create the task branch and isolated worktree from that exact SHA.
4. Verify the worktree is clean and its initial `HEAD` equals `BASE_SHA`.
5. Keep one task per branch/worktree and include only files required by that task.
6. Never delete, prune, force-remove, or reuse another task's worktree to resolve a path, branch, or registration collision.
Before pushing or opening/updating a PR:
1. Apply intended formatting/fixes, then commit only the current task.
2. Fetch `origin/main` again; if it fails, stop.
3. If main advanced, integrate it before delivery. Rebase is allowed only for an unpublished branch used by this task alone; never rewrite shared history without approval. Otherwise merge main or ask.
4. Re-review files changed by both sides; a conflict-free integration is not proof of semantic compatibility.
5. Run only checks that are necessary and proportionate to the changed files and risk. Prefer focused tests. Do not run repository-wide tests, type checks, or builds by default.
6. Do not let optional, slow, or stalled local checks block opening or updating a PR. Stop them when appropriate, report exactly what ran, passed, failed, or was not run, and rely on required CI before merge.
7. If any check changes files, inspect and commit them, then repeat the relevant checks.
8. Require a clean worktree after checks so the tested tree equals the pushed `HEAD`.
Before merge:
1. Fetch `origin/main` again; if it fails, stop.
2. Record the fetched base SHA and PR head SHA. Required CI must validate the merge result produced from those exact revisions.
3. Confirm the PR targets `main`, contains only intended files, and has no failing or pending required CI.
4. If either SHA changes, repeat overlap review and validation before merge.
Never discard or clean an existing dirty workspace without explicit user approval.
+22 -1
View File
@@ -4,6 +4,27 @@ Hy Memory and MemHub are versioned and released independently.
## Unreleased
- MemHub host capture hooks no longer write the database. `one-memory
capture <host>` used to open the main SQLite file from its own short-lived
process on every hook, re-create the retired `evidence` / FTS tables on an
SDK-authority home and write the write queue outside Core's single writer,
contending for the file lock with Core and with other hosts' hooks. It now
reads only the runtime receipt and token and posts the hook's JSON to the
running Core (`POST /api/v1/live-capture/{source}`, 1.5 s budget). Core
applies the same gates (grant, sync switch, rejection counting, automated
sessions, the Cursor/Claude Code rule; the migration fence stays on the hook
side) and stages the turn in-process through the live file-sync path. When
Core is not running the turn is dropped silently; nothing falls back to a
local write.
- MemHub no longer sends one turn to the SDK twice when a host's hook and live
file sync both see it. The two lanes gave the same message different
identities (the hook carries no workspace and often no message id) and
separate assembly rows, so both were written as raws. Hook input for a
source that live sync reads from its own files (every hooked host except
Qwen Work, while live sync is on) is now left to the file path; only a
source without file polling is staged from its hook. Tables an older hook
re-created on an SDK home are dropped by the existing startup cleanup.
## Hy Memory 2.0.14 — 2026-11-04
- `search()` / `async_search()` take an optional `statuses` for explicit
@@ -84,7 +105,7 @@ Hy Memory and MemHub are versioned and released independently.
## MemHub 1.0.6 — 2026-11-04
Build 41, on `hy-memory==2.0.14`. Linux x64/arm64 Runtime and macOS arm64
Build 44, on `hy-memory==2.0.14`. Linux x64/arm64 Runtime and macOS arm64
App and Runtime.
- MemHub App now exposes the complete memory-mode lifecycle. New installs
+1 -1
View File
@@ -19,7 +19,7 @@ If you run into a problem, please open an Issue with the version, platform,repro
## 发行与平台合同
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.14`。MemHub 源码版本为 `1.0.6`
(build 41),GitHub 仓库为
(build 44),GitHub 仓库为
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
`memhub-cli`。当前代码架构见
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
+394 -40
View File
@@ -644,11 +644,41 @@ class SdkRawPage(BaseModel):
offset: int = Field(ge=0)
limit: int = Field(ge=1, le=500)
unit: Literal["raw", "turn"] = "raw"
next_cursor: str | None = None
has_more: bool = False
total_exact: bool = True
#: Set when a time filter was applied in Core. For storage `raw`, `total`
#: remains the SDK count; for product `turn`, it is the filtered turn count.
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")
@@ -1755,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 ()
)
@@ -2090,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()
@@ -2203,6 +2239,19 @@ def create_app(
from one_memory.embedding_switch_routes import embedding_switch_router
app.include_router(embedding_switch_router(protected))
from one_memory.live_capture import live_capture_router
def file_polled(request: Request, source_id: str) -> bool:
"""Whether live sync reads this source's own files, so the hook's copy is redundant."""
registration = getattr(request.app.state, "source_catalog", {}).get(source_id)
return (
runtime_settings.live_sync_enabled
and registration is not None
and registration.capabilities.live_sync is CapabilityState.IMPLEMENTED
)
# Host hooks hand their turn over here instead of opening the database.
app.include_router(live_capture_router(protected, file_polled=file_polled))
app.add_middleware(
CORSMiddleware,
@@ -3219,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,
@@ -3308,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:
@@ -4481,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,
@@ -4491,6 +4600,8 @@ def create_app(
offset: int = Query(default=0, ge=0),
limit: int = Query(default=100, ge=1, le=500),
unit: Literal["raw", "turn"] = Query(default="raw"),
paging: Literal["offset", "cursor"] = Query(default="offset"),
cursor: str | None = Query(default=None, max_length=160),
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
@@ -4516,9 +4627,52 @@ def create_app(
_library_values(statuses or [], MEMORY_STATUSES, "memory_status_unknown")
or raw_list_filters()["statuses"]
)
if paging == "cursor" and unit != "turn":
raise HTTPException(status_code=422, detail="raw_cursor_requires_turn_unit")
if cursor is not None and paging != "cursor":
raise HTTPException(status_code=422, detail="raw_cursor_requires_cursor_paging")
if paging == "cursor" and (offset or after is not None or before is not None):
raise HTTPException(status_code=422, detail="raw_cursor_filter_unsupported")
raw_cursor = (0, 0, 0, 0, "", "")
if cursor:
parts = cursor.split(":")
if (
len(parts) != 7
or parts[0] != "turn"
or any(re.fullmatch(r"[0-9]+", part) is None for part in parts[1:5])
or re.fullmatch(r"(?:p?[0-9a-f]{16})?", parts[5]) is None
or re.fullmatch(r"[0-9a-f]{16}", parts[6]) is None
):
raise HTTPException(status_code=422, detail="raw_cursor_invalid")
raw_cursor = (
int(parts[1]),
int(parts[2]),
int(parts[3]),
int(parts[4]),
parts[5],
parts[6],
)
if any(value > 100_000_000 for value in raw_cursor[:4]):
raise HTTPException(status_code=422, detail="raw_cursor_invalid")
sources = split_csv(source)
excluded = split_csv(exclude_sources)
sessions = split_csv(session_id)
cursor_scope = hashlib.sha256(
json.dumps(
{
"user_id": user_id,
"statuses": sorted(chosen_statuses),
"sources": sorted(sources),
"excluded": sorted(excluded),
"sessions": sorted(sessions),
"order": order,
},
sort_keys=True,
separators=(",", ":"),
).encode()
).hexdigest()[:16]
if cursor and raw_cursor[5] != cursor_scope:
raise HTTPException(status_code=422, detail="raw_cursor_scope_mismatch")
try:
client = await _sdk_client_handle(app).client()
query = {
@@ -4527,8 +4681,138 @@ def create_app(
"include_sources": sources or None,
"exclude_sources": excluded or None,
"session_ids": sessions or None,
"order": order if unit == "raw" else "desc",
"order": order if unit == "raw" or paging == "cursor" else "desc",
}
if unit == "turn" and paging == "cursor":
(
raw_offset,
turn_offset,
emitted,
previous_total,
expected_raw,
_scope,
) = raw_cursor
value = await asyncio.to_thread(
client.list_memories,
**query,
offset=raw_offset,
limit=min(limit + 1, 500),
)
raw_page = HySdkRawPage.from_memory_list(
value, limit=min(limit + 1, 500), offset=raw_offset
)
def raw_anchor(record: HySdkRawRecord) -> str:
return hashlib.sha256(
f"{record.raw_id}\0{record.content}".encode()
).hexdigest()[:16]
if cursor:
if raw_page.total < previous_total:
raise HTTPException(status_code=409, detail="raw_cursor_stale")
observed_total = raw_page.total
added = raw_page.total - previous_total
if added and order == "desc":
raw_offset += added
value = await asyncio.to_thread(
client.list_memories,
**query,
offset=raw_offset,
limit=min(limit + 1, 500),
)
raw_page = HySdkRawPage.from_memory_list(
value,
limit=min(limit + 1, 500),
offset=raw_offset,
)
if raw_page.total != observed_total:
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")
previous_value = await asyncio.to_thread(
client.list_memories,
**query,
offset=raw_offset - 1,
limit=1,
)
previous_page = HySdkRawPage.from_memory_list(
previous_value, limit=1, offset=raw_offset - 1
)
valid_anchor = (
previous_page.total == raw_page.total
and bool(previous_page.raw)
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
)
if expected_raw and not valid_anchor:
raise HTTPException(status_code=409, detail="raw_cursor_stale")
selected_turns: list[tuple[HySdkRawRecord, RawDisplayTurn]] = []
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
start = turn_offset if record_index == 0 else 0
for index in range(start, len(ordered_turns)):
selected_turns.append((record, ordered_turns[index]))
if len(selected_turns) < limit:
continue
absolute_raw = raw_offset + record_index
next_raw = absolute_raw
next_turn = index + 1
if next_turn >= len(ordered_turns):
next_raw += 1
next_turn = 0
if next_raw < raw_page.total:
if next_turn:
anchor_record = record
else:
next_index = record_index + 1
anchor_record = (
raw_page.raw[next_index]
if next_index < len(raw_page.raw)
else None
)
anchor = (
raw_anchor(anchor_record)
if anchor_record is not None
else f"p{raw_anchor(record)}"
)
next_cursor = (
f"turn:{next_raw}:{next_turn}:"
f"{emitted + len(selected_turns)}:{raw_page.total}:"
f"{anchor}:{cursor_scope}"
)
break
if len(selected_turns) >= limit:
break
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")
next_raw = raw_offset + len(raw_page.raw)
previous_anchor = raw_anchor(raw_page.raw[-1])
next_cursor = (
f"turn:{next_raw}:0:{emitted + len(selected_turns)}:"
f"{raw_page.total}:p{previous_anchor}:{cursor_scope}"
)
return SdkRawPage(
user_id=user_id,
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.
total=emitted + len(selected_turns) + (1 if next_cursor else 0),
offset=emitted,
limit=limit,
unit="turn",
next_cursor=next_cursor,
has_more=next_cursor is not None,
total_exact=False,
)
if unit == "raw":
value = await asyncio.to_thread(
client.list_memories,
@@ -4691,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,
@@ -6417,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
@@ -0,0 +1,133 @@
"""The hook side of live capture: hand the turn to the running Core, nothing more.
A host runs `one-memory capture <source>` as a short-lived process on every
hook, often several at once. That process used to open the main database
itself and write Evidence and the write queue from outside Core's single
writer. It now never opens the database: it reads the hook's JSON, posts it to
the running Core over loopback with the runtime token, and exits. Core applies
every gate and stages the turn in-process (`live_capture.capture_hook`).
Anything that goes wrong — Core not running, a stale receipt, a slow answer —
drops the turn silently. There is no local fallback write: file-backed sources
are read again by live sync from their persisted cursor once Core is back.
"""
import json
import os
from pathlib import Path
from typing import TextIO
CAPTURE_SOURCES = frozenset(
{
"workbuddy",
"claude-code",
"codex",
"cursor",
"pi",
"qwen-code",
"qwenwork",
"hermes",
"deepseek-harness",
"opencode",
"openclaw",
}
)
SDK_MIGRATION_CAPTURE_FENCE = ".sdk-migration-capture-fence.json"
#: Core's in-process intake for a hook's payload; see `live_capture`.
CAPTURE_ROUTE = "/api/v1/live-capture/{source_id}"
#: Whole request, connect to answer. Pi and OpenCode wait for the hook
#: synchronously (3 s, interpreter start included), so this stays well under.
HANDOFF_TIMEOUT_SECONDS = 1.5
#: A hook carries one turn, or one session's visible text at most.
MAX_PAYLOAD_BYTES = 8 * 1024 * 1024
def data_directory() -> Path:
from one_memory.config import load_settings
return load_settings().data_dir
def capture_stdio(source_id: str, stdin: TextIO, output: TextIO) -> int:
"""Host hooks must stay silent. A bad payload or an absent Core does not fail the turn."""
del output
if source_id not in CAPTURE_SOURCES:
return 2
try:
data_dir = data_directory()
if sdk_migration_capture_fenced(data_dir):
return 0
payload = json.loads(stdin.read() or "")
except Exception:
return 0
if not isinstance(payload, dict):
return 0
try:
hand_to_core(source_id, payload, data_dir)
except Exception:
return 0
return 0
def hand_to_core(
source_id: str,
payload: dict[str, object],
data_dir: Path,
*,
timeout: float = HANDOFF_TIMEOUT_SECONDS,
) -> bool:
"""Post the payload to the Core named by this home's receipt. True when Core took it.
Only the receipt, the runtime token and a loopback socket are touched; the
main database is never opened from here.
"""
from one_memory.runtime_client import core_endpoint
endpoint = core_endpoint(data_dir)
if endpoint is None:
return False
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
if len(body) > MAX_PAYLOAD_BYTES:
return False
from one_memory.http_client import create_client
with create_client(timeout=timeout) as client:
response = client.post(
f"http://{endpoint.host}:{endpoint.port}{CAPTURE_ROUTE.format(source_id=source_id)}",
content=body,
headers={
"Authorization": f"Bearer {endpoint.token}",
"Content-Type": "application/json",
},
)
return response.status_code == 202
def sdk_migration_capture_fenced(data_dir: Path) -> bool:
"""Ignore stale fences; a live migration or a failed one pauses hook writes.
A failed upgrade migration leaves the fence `frozen` (no pid): the home was
put back as it was before the upgrade and takes no writes until the next
migration attempt, which replaces the fence and lifts it on success.
"""
marker = data_dir / SDK_MIGRATION_CAPTURE_FENCE
try:
document = json.loads(marker.read_text(encoding="utf-8"))
if isinstance(document, dict) and document.get("frozen") is True:
return True
pid = int(document["pid"])
if pid < 1:
raise ValueError("invalid migration fence pid")
os.kill(pid, 0)
return True
except FileNotFoundError:
return False
except ProcessLookupError:
marker.unlink(missing_ok=True)
return False
except PermissionError:
return True
except (KeyError, TypeError, ValueError, json.JSONDecodeError, OSError):
marker.unlink(missing_ok=True)
return False
+1 -1
View File
@@ -119,7 +119,7 @@ def run(arguments: Sequence[str], *, output: TextIO | None = None) -> int:
if command == "sdk-migration-status":
return sdk_migration_status(options, stream)
if command == "capture" and len(options) == 1:
from one_memory.live_capture import capture_stdio
from one_memory.capture_handoff import capture_stdio
return capture_stdio(options[0], sys.stdin, stream)
if command not in {"status", "doctor"} or options not in ([], ["--json"]):
@@ -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"]),
@@ -1,6 +1,6 @@
import hashlib
import os
from collections.abc import AsyncIterator, Iterator
from collections.abc import AsyncIterator, Callable, Iterator
from dataclasses import dataclass
from pathlib import Path
@@ -28,6 +28,7 @@ def discovered_off_loop(
suffixes: frozenset[str],
max_targets: int,
excluded_directory_names: frozenset[str] = frozenset(),
directory_predicate: Callable[[Path], bool] | None = None,
) -> AsyncIterator[SourceFileDiscoveryEvent]:
"""The same walk, for callers that have an event loop to keep free.
@@ -43,6 +44,7 @@ def discovered_off_loop(
suffixes=suffixes,
max_targets=max_targets,
excluded_directory_names=excluded_directory_names,
directory_predicate=directory_predicate,
),
scope_key=grant.source_id,
)
@@ -54,6 +56,7 @@ def discover_source_files(
suffixes: frozenset[str],
max_targets: int,
excluded_directory_names: frozenset[str] = frozenset(),
directory_predicate: Callable[[Path], bool] | None = None,
) -> Iterator[SourceFileDiscoveryEvent]:
"""Metadata only: one lstat per candidate, no open and no per-file grant lookup.
@@ -71,10 +74,16 @@ def discover_source_files(
grant.root,
followlinks=False,
):
current = Path(directory)
child_directories[:] = sorted(
child
for child in child_directories
if not (Path(directory) / child).is_symlink() and child not in excluded_directory_names
if not (current / child).is_symlink()
and child not in excluded_directory_names
and (
directory_predicate is None
or directory_predicate((current / child).relative_to(grant.root))
)
)
for filename in sorted(filenames):
if Path(filename).suffix.lower() not in suffixes:
@@ -591,7 +591,11 @@ class PollingLiveSyncSupervisor:
async def _poll_source(self, source_id: str) -> None:
while True:
await asyncio.sleep(self.interval_seconds)
# A watched source is normally triggered by its filesystem event.
# Its timer is only a missed-event backstop; recursively walking
# every multi-gigabyte host root once a minute starved unrelated
# Core reads even when no source had changed.
await asyncio.sleep(self._poll_interval(source_id))
try:
await self.trigger(source_id)
except asyncio.CancelledError:
@@ -608,6 +612,11 @@ class PollingLiveSyncSupervisor:
except Exception as again:
self._note_internal_error(source_id, again)
def _poll_interval(self, source_id: str) -> float:
if source_id in (self._watched_sources or ()):
return max(self.interval_seconds, 15 * 60)
return self.interval_seconds
def _note_worker_still_running(self, source_id: str) -> None:
current = self.health(source_id)
self._set_health(
@@ -226,6 +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
@property
def descriptor(self) -> SourceDescriptor:
@@ -281,6 +284,11 @@ class JsonlSourceAdapter:
predicate=self.file_predicate,
cursors=options.cursors,
)
self._probe_listing_cache = (
dict(options.cursors),
options.max_scan_targets,
listing,
)
return bool(listing.changed or listing.failures)
async def live_baseline(self) -> dict[str, SourceCursor]:
@@ -315,9 +323,11 @@ class JsonlSourceAdapter:
async for event in self._scan_frozen(options):
yield event
return
listing = await asyncio.to_thread(
self._history_listing if options.reuse_listing else self._fresh_listing,
options,
listing = (
await asyncio.to_thread(self._history_listing, options)
if options.reuse_listing
else self._take_probe_listing(options)
or await asyncio.to_thread(self._fresh_listing, options)
)
for failure in listing.failures:
yield ScanFailure(reason_code=discovery_failure_code(failure))
@@ -349,6 +359,16 @@ class JsonlSourceAdapter:
self._history_listing_cache = listing
return listing
def _take_probe_listing(self, options: ScanOptions) -> "SourceListing | None":
cached = self._probe_listing_cache
self._probe_listing_cache = None
if cached is None:
return None
cursors, max_scan_targets, listing = cached
if cursors != options.cursors or max_scan_targets != options.max_scan_targets:
return None
return listing
def _fresh_listing(self, options: ScanOptions) -> "SourceListing":
return list_changed_files(
self.grant,
@@ -9,6 +9,7 @@ from collections.abc import AsyncIterator, Iterator
from dataclasses import dataclass, replace
from datetime import UTC, datetime, timedelta, timezone
from functools import partial
from pathlib import Path
from pydantic import JsonValue
@@ -175,6 +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
@property
def descriptor(self) -> SourceDescriptor:
@@ -207,6 +211,15 @@ class CursorUnifiedSourceAdapter:
state = await self.install_state()
return min(state.session_count, limit) if limit is not None else state.session_count
async def has_changes(self, options: ScanOptions) -> bool:
listing = await asyncio.to_thread(self._fresh_listing, options)
self._probe_listing_cache = (
dict(options.cursors),
options.max_scan_targets,
listing,
)
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."""
listing = await asyncio.to_thread(
@@ -222,9 +235,11 @@ class CursorUnifiedSourceAdapter:
async for event in self._scan_frozen(options):
yield event
return
listing = await asyncio.to_thread(
self._history_listing if options.reuse_listing else self._fresh_listing,
options,
listing = (
await asyncio.to_thread(self._history_listing, options)
if options.reuse_listing
else self._take_probe_listing(options)
or await asyncio.to_thread(self._fresh_listing, options)
)
for failure in listing.failures:
yield ScanFailure(reason_code=discovery_failure_code(failure))
@@ -329,6 +344,16 @@ class CursorUnifiedSourceAdapter:
self._history_listing_cache = listing
return listing
def _take_probe_listing(self, options: ScanOptions) -> "_CursorListing | None":
cached = self._probe_listing_cache
self._probe_listing_cache = None
if cached is None:
return None
cursors, max_scan_targets, listing = cached
if cursors != options.cursors or max_scan_targets != options.max_scan_targets:
return None
return listing
def _fresh_listing(self, options: ScanOptions) -> "_CursorListing":
return _list_cursor_files(
self.grant,
@@ -376,6 +401,7 @@ class CursorUnifiedSourceAdapter:
self.grant,
suffixes=frozenset({".db"}),
max_targets=options.max_scan_targets or 100_000,
directory_predicate=_cursor_data_directory,
):
if isinstance(discovered, SourceFileDiscoveryFailure):
yield ScanFailure(reason_code=discovery_failure_code(discovered))
@@ -477,7 +503,10 @@ def _list_cursor_files(
legacy: list[AuthorizedSourceFile] = []
failures: list[SourceFileDiscoveryFailure] = []
for item in discover_source_files(
grant, suffixes=frozenset({".jsonl", ".db", ".vscdb"}), max_targets=max_targets
grant,
suffixes=frozenset({".jsonl", ".db", ".vscdb"}),
max_targets=max_targets,
directory_predicate=_cursor_data_directory,
):
if isinstance(item, SourceFileDiscoveryFailure):
failures.append(item)
@@ -586,6 +615,25 @@ def _is_cursor_data_file(source_file: AuthorizedSourceFile) -> bool:
)
def _cursor_data_directory(relative_path: Path) -> bool:
"""Enter only Cursor directories that can contain conversation authority.
The granted root also contains extensions, skills, caches, logs and agent
artifacts. Recursively walking all of them every live-sync poll made the
reader monopolize Core even though none can pass the file predicates.
"""
parts = relative_path.parts
if not parts:
return False
if parts[0] in {"chats", "acp-sessions"}:
return True
if parts[0] == "projects":
return len(parts) <= 2 or parts[2] == "agent-transcripts"
if parts[0] == "User":
return len(parts) == 1 or parts[1] in {"globalStorage", "workspaceStorage"}
return False
def _is_transcript_file(source_file: AuthorizedSourceFile) -> bool:
parts = source_file.relative_path.parts
return source_file.relative_path.suffix == ".jsonl" and "agent-transcripts" in parts
@@ -634,6 +682,7 @@ def _select_transcript_files(
grant,
suffixes=frozenset({".jsonl"}),
max_targets=100_000,
directory_predicate=_cursor_data_directory,
):
if not isinstance(item, AuthorizedSourceFile) or not _is_transcript_file(item):
continue
@@ -654,6 +703,7 @@ def _subagent_conversation_ids(grant: AuthorizedSourceRoot) -> set[str]:
grant,
suffixes=frozenset({".jsonl"}),
max_targets=100_000,
directory_predicate=_cursor_data_directory,
)
if isinstance(item, AuthorizedSourceFile)
and _is_transcript_file(item)
@@ -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)
)
+155 -137
View File
@@ -1,15 +1,35 @@
"""Write visible hook text into Evidence. Tool output stays out."""
"""Core's intake for host hooks: visible text only, staged under Core's writer.
The hook process (`capture_handoff`) never opens the database; it posts the
hook's JSON to `POST /api/v1/live-capture/{source}` on the running Core, which
calls `capture_hook` here. Tool output stays out.
A source that live sync already reads from its own files is left to that
path: the hook and the file name the same message differently (the hook has
no workspace and often no message id), so staging both sent one turn to the
SDK twice. Only a source no file poller follows is staged from the hook.
"""
import asyncio
import json
import os
import time
from collections.abc import Callable
from datetime import UTC, datetime
from enum import StrEnum
from pathlib import Path
from typing import TextIO, cast
from typing import cast
from fastapi import APIRouter, HTTPException, Request
from fastapi.params import Depends
from pydantic import JsonValue
from one_memory.capture_handoff import (
CAPTURE_ROUTE,
CAPTURE_SOURCES,
MAX_PAYLOAD_BYTES,
SDK_MIGRATION_CAPTURE_FENCE,
sdk_migration_capture_fenced,
)
from one_memory.database_access import main_database
from one_memory.derived_echo import PREFACE, strip_injected_memory
from one_memory.imports.access import opaque_workspace_id, safe_project_label
@@ -19,6 +39,7 @@ from one_memory.imports.models import (
EvidenceRecord,
OriginMetadata,
WrittenBy,
materialize_write_input,
)
from one_memory.imports.sources.claude_code import parse_claude_code_record
from one_memory.imports.sources.codex import HARNESS_PREFIXES, parse_codex_record
@@ -38,23 +59,21 @@ from one_memory.imports.sources.parsing import (
)
from one_memory.imports.sources.pi import _is_automated_session
from one_memory.imports.sources.workbuddy import _is_scheduled_automation, parse_workbuddy_record
from one_memory.imports.storage import EvidenceStore
from one_memory.memory_server.durable_queue import DurableWriteQueue
from one_memory.sdk_data_plane_migration import MemoryAuthority
__all__ = [
"CAPTURE_SOURCES",
"SDK_MIGRATION_CAPTURE_FENCE",
"CaptureOutcome",
"CaptureRejection",
"capture_hook",
"capture_rejection",
"live_capture_router",
"record_capture_rejection",
"sdk_migration_capture_fenced",
]
CAPTURE_SOURCES = frozenset(
{
"workbuddy",
"claude-code",
"codex",
"cursor",
"pi",
"qwen-code",
"qwenwork",
"hermes",
"deepseek-harness",
"opencode",
"openclaw",
}
)
_UNKNOWN_USER = frozenset({"hermes", "opencode", "openclaw"})
_USER_FIELDS = {
"UserPromptSubmit": "prompt",
@@ -75,112 +94,130 @@ _FILE_FALLBACK = {
}
_FALLBACK_EVENTS = frozenset({"Stop", "stop", "SubagentStop", "StopFailure"})
_TEXT_BLOCKS = frozenset({"text"})
SDK_MIGRATION_CAPTURE_FENCE = ".sdk-migration-capture-fence.json"
def database_path() -> Path:
from one_memory.config import load_settings
class CaptureOutcome(StrEnum):
"""What Core did with one hook payload. Never the payload itself."""
return load_settings().data_dir / "one-memory.sqlite3"
STAGED = "staged"
#: Live sync reads this source's own files; that path owns the turn.
FILE_POLLED = "file_polled"
REJECTED = "rejected"
IGNORED = "ignored"
def capture_stdio(source_id: str, stdin: TextIO, output: TextIO) -> int:
"""Host hooks must stay silent. A bad payload does not fail the turn."""
def capture_hook(
source_id: str,
payload: object,
database: Path,
*,
queue: DurableWriteQueue,
file_polled: Callable[[str], bool] = lambda _source_id: False,
) -> CaptureOutcome:
"""Gate one hook payload and stage its visible lines, inside Core.
del output
if source_id not in CAPTURE_SOURCES:
return 2
database = database_path()
if sdk_migration_capture_fenced(database.parent):
return 0
try:
payload = json.loads(stdin.read() or "")
except json.JSONDecodeError:
return 0
try:
capture_hook(source_id, payload, database)
except Exception:
return 0
return 0
def sdk_migration_capture_fenced(data_dir: Path) -> bool:
"""Ignore stale fences; a live migration or a failed one pauses hook writes.
A failed upgrade migration leaves the fence `frozen` (no pid): the home was
put back as it was before the upgrade and takes no writes until the next
migration attempt, which replaces the fence and lifts it on success.
Runs on SDK authority through Core's single writer: the lines become
write-job input exactly as live file sync stages them
(`materialize_write_input` -> `stage_sdk_input` -> `finalize_sdk_file`,
live priority). No Evidence table is involved.
"""
marker = data_dir / SDK_MIGRATION_CAPTURE_FENCE
try:
document = json.loads(marker.read_text(encoding="utf-8"))
if isinstance(document, dict) and document.get("frozen") is True:
return True
pid = int(document["pid"])
if pid < 1:
raise ValueError("invalid migration fence pid")
os.kill(pid, 0)
return True
except FileNotFoundError:
return False
except ProcessLookupError:
marker.unlink(missing_ok=True)
return False
except PermissionError:
return True
except (KeyError, TypeError, ValueError, json.JSONDecodeError, OSError):
marker.unlink(missing_ok=True)
return False
def capture_hook(source_id: str, payload: object, database: Path) -> bool:
if source_id not in CAPTURE_SOURCES or not isinstance(payload, dict):
return False
return CaptureOutcome.IGNORED
# Cursor also runs ~/.claude/settings.json hooks; its own hook already files the turn.
if source_id != "cursor" and "cursor_version" in payload:
return False
return CaptureOutcome.IGNORED
if not database.is_file():
return False
return CaptureOutcome.IGNORED
rejection = capture_rejection(database, source_id)
if rejection is not None:
# Silent, and the reason is counted rather than printed: a hook that
# spoke would put this into the host's turn as text.
record_capture_rejection(database, source_id, rejection)
return False
return CaptureOutcome.REJECTED
if source_id == "pi" and _is_automated_session(first_string(payload.get("cwd"))):
return False
lines = _messages(source_id, payload)
if lines:
conversation_id = _conversation(payload)
store = EvidenceStore(database)
wrote = False
for role, written_by, verbatim, message_id in lines:
target = conversation_id
if target is None:
continue
result = store.put(
EvidenceInput(
source_id=source_id,
conversation_id=target,
message_id=message_id,
source_ordinal=None if message_id else "hook-visible",
role=role,
written_by=written_by,
created_at=datetime.now(UTC),
verbatim=verbatim,
origin_meta={EvidenceOrigin.HOOK: OriginMetadata()},
)
)
_submit_live_memory(
store, result.record, changed=result.was_created or result.was_revised
)
wrote = True
return wrote
return _capture_transcript(source_id, payload, database)
return CaptureOutcome.IGNORED
if file_polled(source_id):
# The same turn reaches the SDK once, from the file: the two lanes give
# one message different identities, so staging both wrote it twice.
return CaptureOutcome.FILE_POLLED
inputs = _hook_inputs(source_id, payload) or _capture_transcript(source_id, payload, database)
if not inputs:
return CaptureOutcome.IGNORED
observed = datetime.now(UTC)
conversations: dict[str, list[EvidenceRecord]] = {}
for value in inputs:
conversations.setdefault(value.conversation_id, []).append(
materialize_write_input(value, observed_at=observed)
)
for conversation_id, records in conversations.items():
# The turn rule decides what is written (`turn_batches`): a turn is
# complete when the next user message arrives, batches are 5 turns or
# 8000 characters, and Core writes the rest after 10 quiet minutes.
queue.assemble_live_records(tuple(records), file_key=f"hook:{conversation_id}")
return CaptureOutcome.STAGED
def _sync_open(database: Path, source_id: str) -> bool:
return capture_rejection(database, source_id) is None
def _hook_inputs(source_id: str, payload: dict[str, object]) -> list[EvidenceInput]:
conversation_id = _conversation(payload)
if conversation_id is None:
return []
observed = datetime.now(UTC)
return [
EvidenceInput(
source_id=source_id,
conversation_id=conversation_id,
message_id=message_id,
source_ordinal=None if message_id else "hook-visible",
role=role,
written_by=written_by,
created_at=observed,
verbatim=verbatim,
origin_meta={EvidenceOrigin.HOOK: OriginMetadata()},
)
for role, written_by, verbatim, message_id in _messages(source_id, payload)
]
def live_capture_router(
dependencies: list[Depends],
*,
file_polled: Callable[[Request, str], bool],
) -> APIRouter:
"""`POST /api/v1/live-capture/{source_id}`: a hook's payload, handed over by the hook.
202 whatever the gates decide, so a hook has nothing to print. The answer
names the outcome only, never any of the payload.
"""
router = APIRouter(dependencies=dependencies)
@router.post(CAPTURE_ROUTE, status_code=202)
async def live_capture(source_id: str, request: Request) -> dict[str, str]:
if source_id not in CAPTURE_SOURCES:
raise HTTPException(status_code=404, detail="capture_source_not_found")
body = await request.body()
if len(body) > MAX_PAYLOAD_BYTES:
raise HTTPException(status_code=413, detail="capture_payload_too_large")
try:
payload = json.loads(body)
except (json.JSONDecodeError, UnicodeError) as error:
raise HTTPException(status_code=422, detail="capture_payload_invalid") from error
state = request.app.state
queue = getattr(state, "memory_write_queue", None)
if getattr(state, "memory_authority", None) is not MemoryAuthority.SDK or queue is None:
# Only an SDK-authority home is served; nothing here may bring the
# retired Evidence tables back for any other.
return {"outcome": CaptureOutcome.IGNORED.value}
outcome = await asyncio.to_thread(
capture_hook,
source_id,
payload,
queue.database_path,
queue=queue,
file_polled=lambda source: file_polled(request, source),
)
return {"outcome": outcome.value}
return router
class CaptureRejection(StrEnum):
@@ -255,22 +292,6 @@ def record_capture_rejection(
return
def _submit_live_memory(store: EvidenceStore, record: EvidenceRecord, *, changed: bool) -> None:
"""Add the record to its conversation's live assembling row.
The turn rule decides what is written (`turn_batches`): a turn is
complete when the next user message arrives, batches are 5 turns or
8000 characters, and Core writes the rest after 10 quiet minutes.
"""
if not changed or not _sync_open(store.database_path, record.source_id):
return
from one_memory.memory_server.durable_queue import DurableWriteQueue
DurableWriteQueue(store.database_path, evidence=store).assemble_live_records(
(record,), file_key=f"hook:{record.conversation_id}"
)
def _conversation(payload: dict[str, object]) -> str | None:
return first_string(
cast(JsonValue | None, payload.get("session_id")),
@@ -470,34 +491,35 @@ def _opencode_messages(
return lines
def _capture_transcript(source_id: str, payload: dict[str, object], database: Path) -> bool:
def _capture_transcript(
source_id: str, payload: dict[str, object], database: Path
) -> list[EvidenceInput]:
"""Stop without in-memory text reads the granted transcript, then the same parser."""
event = payload.get("hook_event_name") or payload.get("hookEventName")
parser = _FILE_FALLBACK.get(source_id)
transcript = payload.get("transcript_path")
if event not in _FALLBACK_EVENTS or parser is None or not isinstance(transcript, str):
return False
return []
root = _grant_root(database, source_id)
if root is None:
return False
return []
try:
granted = root.resolve()
target = Path(transcript).resolve()
except OSError:
return False
return []
if target != granted and granted not in target.parents:
return False
return []
if not target.is_file():
return False
return []
_wait_stable(target)
try:
raw = target.read_text(encoding="utf-8")
except OSError:
return False
return []
relative = target.relative_to(granted).as_posix()
store = EvidenceStore(database)
wrote = False
inputs: list[EvidenceInput] = []
for index, line in enumerate(raw.splitlines()):
if not line.strip():
continue
@@ -514,7 +536,7 @@ def _capture_transcript(source_id: str, payload: dict[str, object], database: Pa
if not isinstance(parsed, ParsedMessage):
continue
for row_index, row in enumerate(parsed.rows()):
result = store.put(
inputs.append(
EvidenceInput(
source_id=source_id,
workspace=opaque_workspace_id(source_id, row.workspace),
@@ -533,11 +555,7 @@ def _capture_transcript(source_id: str, payload: dict[str, object], database: Pa
origin_meta={EvidenceOrigin.FILE: OriginMetadata()},
)
)
_submit_live_memory(
store, result.record, changed=result.was_created or result.was_revised
)
wrote = True
return wrote
return inputs
def _grant_root(database: Path, source_id: str) -> Path | None:
+16 -3
View File
@@ -26,6 +26,7 @@ if TYPE_CHECKING:
_operation: ContextVar[tuple["ModelProbeOperations", str] | None] = ContextVar(
"model_probe_operation", default=None
)
_dispatch_started: ContextVar[bool] = ContextVar("model_probe_dispatch_started", default=False)
class ProbeCode(StrEnum):
@@ -113,16 +114,26 @@ class ModelProber:
if existing is not None:
return existing
token = _operation.set((operations, request.operation_id))
dispatch_token = _dispatch_started.set(False)
try:
await self._probe(profile_id)
except ProbeOperationConflict:
await asyncio.to_thread(
operations.fail, request.operation_id, "model_probe_profile_changed"
)
except Exception:
# Unknown dispatch/storage outcomes stay reserved, including across restart.
raise ProbeOperationUnavailable("model_probe_operation_unavailable") from None
except Exception as exc:
if _dispatch_started.get():
# Once a request may have reached the provider, preserve the
# reservation so a retry cannot duplicate a possibly billed call.
raise ProbeOperationUnavailable("model_probe_operation_unavailable") from None
logging.getLogger(__name__).warning(
"model probe failed before dispatch: %s", type(exc).__name__
)
await asyncio.to_thread(
operations.fail, request.operation_id, "model_probe_execution_failed"
)
finally:
_dispatch_started.reset(dispatch_token)
_operation.reset(token)
return await asyncio.to_thread(operations.lookup, request.operation_id)
@@ -317,6 +328,8 @@ class ModelProber:
listing_optional: bool = False,
) -> ProbeStage:
await self._check_operation()
if _operation.get() is not None:
_dispatch_started.set(True)
started = monotonic()
try:
response = await client.request(
@@ -118,7 +118,7 @@ class OnboardingState(BaseModel):
selected_source_ids: tuple[str, ...]
started_at: datetime | None
completed_at: datetime | None
mode_intent: Literal["lite", "pro"] | None
mode_intent: Literal["lite", "pro", "ultra"] | None
setup_step: Literal["review", "mode", "model"] | None
model_profile_id: str | None
@@ -128,7 +128,7 @@ class OnboardingProgressUpdate(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
mode_intent: Literal["lite", "pro"] | None = None
mode_intent: Literal["lite", "pro", "ultra"] | None = None
setup_step: Literal["review", "mode", "model"] | None = None
model_profile_id: str | None = Field(default=None, min_length=36, max_length=36)
@@ -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)
@@ -113,6 +113,37 @@ def inspect_runtime(
)
@dataclass(frozen=True)
class CoreEndpoint:
host: Literal["127.0.0.1"]
port: int
token: str
def core_endpoint(
data_dir: Path,
*,
alive: Callable[[int], bool] = process_is_alive,
) -> CoreEndpoint | None:
"""Where this home's Core listens and the token it accepts, from files alone.
The receipt and token checks `inspect_runtime` makes, without its identity
round trip: a caller that only has to hand something over quickly lets the
authenticated request itself be the proof. None when no live Core is named.
"""
try:
raw = (data_dir / "runtime.json").read_text(encoding="utf-8")
receipt = _receipt(json.loads(raw))
except (OSError, UnicodeError, json.JSONDecodeError):
return None
if receipt is None or not alive(receipt.pid):
return None
token = _runtime_token(data_dir)
if token is None:
return None
return CoreEndpoint(host=receipt.host, port=receipt.port, token=token)
def _receipt(value: object) -> RuntimeReceipt | None:
if not isinstance(value, dict):
return None
@@ -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 -1
View File
@@ -138,7 +138,7 @@
| `test_internal_preset.py` | `contract` | The optional model preset a configured build carries. |
| `test_jsonl_snapshot_tail.py` | `contract` | Bounded EOF handling on synthetic files only, never original host history. |
| `test_library_search_window.py` | `unit` | The library search window store and the unified record/keyword parsing. |
| `test_live_capture.py` | `integration` | Visible hook text lands in Evidence before the transcript file is flushed. |
| `test_live_capture.py` | `integration` | Hooks hand visible text to Core, which stages it once, without Evidence. |
| `test_live_sync.py` | `contract` | 覆盖 live sync preempts a reader batch before it emits work、live sync cuts in front of a running history import、live sync does not resume a history import the user paused 等 24 个场景。 |
| `test_live_sync_file_incremental.py` | `contract` | 覆盖 unchanged file metadata avoids a job and content read、changed file content is read once by the import job、workbuddy first live poll baselines history then imports only append 等 6 个场景。 |
| `test_live_sync_job_coalescing.py` | `contract` | A poll that finds nothing must not leave an import job behind. |
+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
@@ -29,7 +29,6 @@ from one_memory.imports.sources.registry import (
build_source_catalog,
)
from one_memory.imports.supervisor import ImportSupervisor
from one_memory.live_capture import capture_hook
FREEZE_THRESHOLD_SECONDS = 1.0
@@ -228,31 +227,17 @@ async def _run(
hook_text = "realtime hook while history import is running"
hook_started = time.monotonic()
result["hook_accepted"] = await asyncio.to_thread(
capture_hook,
"workbuddy",
{
# A hook hands its turn to Core over loopback; it no longer writes the
# database itself. What is measured is that hand-off under import load.
handed = await client.post(
"/api/v1/live-capture/workbuddy",
json={
"hook_event_name": "UserPromptSubmit",
"session_id": "pressure-live-session",
"prompt": hook_text,
},
app.state.evidence_store.database_path,
)
hook_deadline = hook_started + 10
hook_stored = False
while time.monotonic() < hook_deadline:
with sqlite3.connect(app.state.evidence_store.database_path) as connection:
hook_stored = (
connection.execute(
"SELECT 1 FROM evidence WHERE source_id='workbuddy' AND verbatim=?",
(hook_text,),
).fetchone()
is not None
)
if hook_stored:
break
await asyncio.sleep(0.01)
result["hook_stored"] = hook_stored
result["hook_accepted"] = handed.status_code == 202
result["hook_visible_ms"] = round((time.monotonic() - hook_started) * 1000, 1)
during, during_unavailable, sampled_terminal = await _measure_recall_until(
@@ -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 (
@@ -131,3 +131,24 @@ def test_run_as_the_entry_it_ends_itself_while_still_importing(tmp_path) -> None
_reap([process])
assert process.returncode == -signal.SIGALRM
assert time.monotonic() - started < 10
def test_with_no_core_a_real_capture_exits_at_once_and_writes_nothing(tmp_path) -> None:
# The process a host actually starts: no Core is running for this home,
# so the turn is dropped. Nothing may fall back to writing the database.
data_dir = tmp_path / "data"
env = {
key: value for key, value in os.environ.items() if key != "ONE_MEMORY_CAPTURE_DEADLINE_S"
}
env.update(HOME=str(tmp_path), ONE_MEMORY_DATA_DIR=str(data_dir))
payload = b'{"hook_event_name": "UserPromptSubmit", "session_id": "s", "prompt": "hi"}'
result = subprocess.run(
[sys.executable, "-m", "one_memory.cli", "capture", "workbuddy"],
input=payload,
capture_output=True,
env=env,
timeout=60,
check=False,
)
assert (result.returncode, result.stdout, result.stderr) == (0, b"", b"")
assert not (data_dir / "one-memory.sqlite3").exists()
+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"]},
File diff suppressed because it is too large. Load diff
@@ -293,6 +293,9 @@ 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("unknown-source") == live.interval_seconds
await imports.shutdown()
asyncio.run(scenario())
@@ -91,6 +91,51 @@ def test_reserved_before_transport_and_atomic_result_reopens_without_resending(e
asyncio.run(scenario())
def test_pre_dispatch_failure_marks_operation_failed_and_unfences(env, monkeypatch):
store, secrets, profile, ops, calls, respond = env
operation = str(uuid4())
def fail_require(*args, **kwargs):
raise RuntimeError("profile load failed")
prober = ModelProber(store=store, secrets=secrets, transport=httpx.MockTransport(respond))
with monkeypatch.context() as patch:
patch.setattr(store, "require", fail_require)
receipt = asyncio.run(
prober.probe(
profile.profile_id,
operation_id=operation,
expected_profile_revision=profile.revision,
)
)
assert receipt.state == "failed"
assert receipt.code == "model_probe_execution_failed"
assert receipt.result is None
assert calls == []
assert ops.lookup(operation) == receipt
assert (
asyncio.run(
prober.probe(
profile.profile_id,
operation_id=operation,
expected_profile_revision=profile.revision,
)
)
== receipt
)
completed = asyncio.run(
prober.probe(
profile.profile_id,
operation_id=str(uuid4()),
expected_profile_revision=profile.revision,
)
)
assert completed.state == "completed"
assert len(calls) == 3
def test_same_id_and_same_profile_concurrency_refused_before_second_call(env):
store, secrets, profile, ops, calls, respond = env
@@ -149,6 +149,13 @@ def test_progress_patch_and_put_round_trip_only_resumable_choices(tmp_path: Path
assert replaced.json()["model_profile_id"] == profile_id
assert (await client.get("/api/v1/onboarding")).json() == replaced.json()
ultra = await client.put(
"/api/v1/onboarding/progress",
json={"mode_intent": "ultra", "setup_step": "model"},
)
assert ultra.status_code == 200
assert ultra.json()["mode_intent"] == "ultra"
asyncio.run(scenario())
@@ -166,7 +173,7 @@ def test_progress_rejects_unknown_or_invalid_fields(tmp_path: Path) -> None:
)
invalid_mode = await client.patch(
"/api/v1/onboarding/progress",
json={"mode_intent": "ultra"},
json={"mode_intent": "future"},
)
assert secret.status_code == 422
@@ -77,6 +77,36 @@ def test_workbuddy_record_parsing_never_blocks_the_event_loop(tmp_path) -> None:
asyncio.run(scenario())
def test_jsonl_live_probe_listing_is_reused_by_the_import(tmp_path, monkeypatch) -> None:
root = tmp_path / ".workbuddy" / "projects"
write_jsonl(
root / "project" / "session.jsonl",
[{"role": "user", "sessionId": "session", "content": "new conversation"}],
)
adapter = create_workbuddy_adapter(grant("workbuddy", root))
from one_memory.imports.sources import common
calls = 0
original = common.list_changed_files
def counted(*args, **kwargs):
nonlocal calls
calls += 1
return original(*args, **kwargs)
monkeypatch.setattr(common, "list_changed_files", counted)
async def probe_then_scan():
options = ScanOptions()
assert await adapter.has_changes(options)
return [event async for event in adapter.scan(options)]
records = evidence(asyncio.run(probe_then_scan()))
assert [item.evidence.verbatim for item in records] == ["new conversation"]
assert calls == 1
def test_cursor_store_user_info_envelope_is_not_attributed_to_the_person(tmp_path) -> None:
"""A Cursor store blob can be the host's session context, not the user's words.
@@ -1352,6 +1382,68 @@ def test_cursor_transcripts_with_the_same_name_in_two_projects_are_both_kept(
assert len({item.evidence.source_ordinal for item in records}) == 2
def test_cursor_discovery_does_not_enter_non_conversation_trees(tmp_path) -> None:
root = tmp_path / ".cursor"
write_jsonl(
root / "projects" / "project" / "agent-transcripts" / "kept" / "kept.jsonl",
[
{
"role": "user",
"message": {"content": [{"type": "text", "text": "kept conversation"}]},
}
],
)
write_jsonl(
root / "extensions" / "large-cache" / "agent-transcripts" / "ignored" / "ignored.jsonl",
[
{
"role": "user",
"message": {"content": [{"type": "text", "text": "must not be scanned"}]},
}
],
)
records = evidence(asyncio.run(scan(CursorUnifiedSourceAdapter(grant=grant("cursor", root)))))
assert [item.evidence.verbatim for item in records] == ["kept conversation"]
def test_cursor_live_probe_listing_is_reused_by_the_import(tmp_path, monkeypatch) -> None:
root = tmp_path / ".cursor"
write_jsonl(
root / "projects" / "project" / "agent-transcripts" / "new" / "new.jsonl",
[
{
"role": "user",
"message": {"content": [{"type": "text", "text": "new conversation"}]},
}
],
)
adapter = CursorUnifiedSourceAdapter(grant=grant("cursor", root))
from one_memory.imports.adapters import ScanOptions
from one_memory.imports.sources import cursor_unified
calls = 0
original = cursor_unified._list_cursor_files
def counted(*args, **kwargs):
nonlocal calls
calls += 1
return original(*args, **kwargs)
monkeypatch.setattr(cursor_unified, "_list_cursor_files", counted)
async def probe_then_scan():
options = ScanOptions()
assert await adapter.has_changes(options)
return [event async for event in adapter.scan(options)]
records = evidence(asyncio.run(probe_then_scan()))
assert [item.evidence.verbatim for item in records] == ["new conversation"]
assert calls == 1
def test_cursor_bounded_scans_reuse_the_authorized_file_listing(tmp_path, monkeypatch) -> None:
root = tmp_path / ".cursor"
transcript = root / "projects" / "project" / "agent-transcripts" / "session" / "session.jsonl"
@@ -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():
+194 -1
View File
@@ -214,7 +214,15 @@ class RecordingSdk:
)
and (not kwargs.get("session_ids") or row["session_id"] in kwargs["session_ids"])
]
return {"vdb": {"memories": rows, "total": len(rows)}}
total = len(rows)
offset = kwargs.get("offset", 0)
limit = kwargs.get("limit", 100)
return {
"vdb": {
"memories": rows[offset : offset + limit],
"total": total,
}
}
rows = [_chat(user_id, *row[:2], *row[3:]) for row in CHAT_ROWS.get(user_id, [])]
if layers:
rows = [row for row in rows if row["layer"] in layers]
@@ -461,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):
@@ -712,6 +815,96 @@ def test_raw_lists_active_and_shadow_turns_with_roles_and_hashes(tmp_path, monke
["我喜欢绿茶", "也记住了。"],
["我叫费天翔", "好的,记住了。"],
]
cursor = None
first_cursor = None
cursor_turns = []
cursor_calls = len(sdk.all("list_memories"))
while True:
params = {"unit": "turn", "paging": "cursor", "limit": 1}
if cursor is not None:
params["cursor"] = cursor
response = await client.get("/api/v2/raw", params=params)
assert response.status_code == 200, response.text
cursor_page = response.json()
assert cursor_page["total_exact"] is False
assert len(cursor_page["items"]) == 1
cursor_turns.append(cursor_page["items"][0]["messages"][0]["content"])
cursor = cursor_page["next_cursor"]
if first_cursor is None:
first_cursor = cursor
if cursor is None:
assert cursor_page["has_more"] is False
break
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]: 新消息",
"status": "active",
"session_id": "s-new",
"source": "cursor",
"memory_at": 1_790_200_000,
"gmt_created": 1_790_200_100,
}
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={
"unit": "turn",
"paging": "cursor",
"limit": 1,
"cursor": first_cursor,
},
)
assert continued.status_code == 200, continued.text
assert continued.json()["items"][0]["messages"][0]["content"] == "我叫费天翔"
finally:
RAW_ROWS["local"].remove(inserted)
assert (
await client.get(
"/api/v2/raw",
params={
"unit": "turn",
"paging": "cursor",
"cursor": first_cursor,
"source": "hermes",
},
)
).status_code == 422
assert (
await client.get(
"/api/v2/raw",
params={
"unit": "turn",
"paging": "cursor",
"cursor": "turn:²:0:0:2::0000000000000000",
},
)
).status_code == 422
calls_before_refresh = len(sdk.all("list_memories"))
await client.get("/api/v2/raw", params={"unit": "turn"})
assert len(sdk.all("list_memories")) == calls_before_refresh + 1
@@ -293,6 +293,22 @@ def _retry_succeeds(home: Path, monkeypatch) -> None:
assert len(list((home / "backups").glob("*-pre-cutover.sdk-bundle"))) == 1
def test_evidence_an_older_capture_hook_recreated_is_dropped_on_the_next_start(home):
# Builds through 1.0.6 let the hook process open `EvidenceStore`, which
# rebuilt the retired tables on an SDK home. Nothing reads them there, and
# the next startup migration's leftovers cleanup removes them again.
assert _run(home).state == "cutover"
database = home / "one-memory.sqlite3"
EvidenceStore(database)
assert "evidence" in legacy_tables_present(database)
result = _run(home)
assert result.state == "complete"
assert {"evidence", "evidence_search"} <= set(result.dropped_tables)
assert legacy_tables_present(database) == ()
def test_a_failure_after_the_cutover_commit_is_not_restored(home, monkeypatch):
import one_memory.sdk_migration_runner as runner
@@ -23,9 +23,8 @@ def test_workbuddy_recall_remains_available_during_100k_import(tmp_path, monkeyp
assert result["import_status"] == "completed"
assert result["stored"] == 100_000
assert result["evidence_rows"] == 100_001
assert result["evidence_rows"] == 100_000
assert result["hook_accepted"] is True
assert result["hook_stored"] is True
assert result["hook_visible_ms"] < 10_000
assert result["recall_sampling_terminal"] is True
assert result["during_unavailable"] == 0
@@ -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
@@ -366,7 +366,7 @@ it("refuses a ui trace from an untrusted window", async () => {
).rejects.toThrow(/untrusted_window/);
});
it("marks a problem and exports a redacted local bundle", async () => {
it("marks a problem and exports the complete local log bundle", async () => {
const value = await fixture();
const mark = await value.handlers.get(
"one-memory:mark-diagnostic-incident",
@@ -375,6 +375,14 @@ it("marks a problem and exports a redacted local bundle", async () => {
incident_id: expect.any(String),
session_id: expect.any(String),
});
await writeFile(
path.join(value.dataDir, "logs", "core.log"),
"2026-10-14 10:31:00 WARNING database wait\n",
);
await writeFile(
path.join(value.dataDir, "logs", "service-error.log"),
"worker failed\n",
);
const exported = await value.handlers.get("one-memory:export-diagnostics")?.(
{},
);
@@ -398,6 +406,16 @@ it("marks a problem and exports a redacted local bundle", async () => {
),
);
expect(summary).toMatchObject({ build: "3800" });
const entries = execFileSync(
"/usr/bin/unzip",
["-Z1", path.join(value.dataDir, zip)],
{ encoding: "utf8" },
)
.trim()
.split("\n");
expect(entries).toContain("logs/core.log");
expect(entries).toContain("logs/service-error.log");
expect(entries).toContain("logs/diagnostic-desktop.jsonl");
value.dispose();
});
+3 -1
View File
@@ -196,6 +196,7 @@ export function resolveDiagnosticBuildIdentity(options: {
export async function exportDesktopDiagnostics(
options: DiagnosticExportOptions,
exportOptions: { includeRawLogs?: boolean } = {},
) {
const build = options.buildNumber ?? options.productVersion;
const target = path.join(
@@ -209,6 +210,7 @@ export async function exportDesktopDiagnostics(
dataDir: options.dataDir,
target,
productVersion: build,
includeRawLogs: exportOptions.includeRawLogs === true,
...(options.commit === undefined ? {} : { commit: options.commit }),
...(options.coreSha256 === undefined
? {}
@@ -644,7 +646,7 @@ export function installDiagnosticsIpc(
options.ipc.handle(EXPORT, async (event, ...args) => {
if (args.length) throw new Error("diagnostic_arguments_rejected");
options.requireTrustedWindow(event);
return exportDesktopDiagnostics(options);
return exportDesktopDiagnostics(options, { includeRawLogs: true });
});
return () => {
+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>
@@ -371,16 +371,14 @@ it("uses user-facing connection confirmation copy without implementation narrati
/>,
);
expect(screen.getByRole("complementary", { name: "连接应用" })).toBeTruthy();
expect(screen.getByRole("checkbox", { name: "一键接入应用" })).toBeTruthy();
expect(
screen.getByText(
"点击底部按钮后,后台分别接入各应用;某个应用失败不影响其他应用。",
screen.queryByText(
/点击底部按钮后|选择草稿|按各应用回执|本页任务控制尚未接通/,
),
).toBeTruthy();
expect(
screen.queryByText(/选择草稿|按各应用回执|本页任务控制尚未接通/),
).toBeNull();
expect(screen.getByText(/下一步选择记忆模式/)).toBeTruthy();
expect(screen.getByText(/之后仍可在设置中更改/)).toBeTruthy();
expect(screen.queryByText(/下一步选择记忆模式/)).toBeNull();
expect(screen.queryByText(/之后仍可在设置中更改/)).toBeNull();
});
it.each(["paused", "failed", "cancelled", "interrupted"] as const)(
@@ -398,11 +396,7 @@ it.each(["paused", "failed", "cancelled", "interrupted"] as const)(
expect(
screen.getByRole("heading", { name: "对话正在汇入,记忆从这里开始" }),
).toBeTruthy();
expect(
screen.getByText(/已保存的对话还在/, {
selector: ".first-import-r6-footer-note p",
}),
).toBeTruthy();
expect(screen.queryByText(/已保存的对话还在/)).toBeNull();
},
);
@@ -439,9 +433,7 @@ it.each(["stale", "revoked", "missing"])(
onContinue={vi.fn()}
/>,
);
if (kind === "missing")
expect(screen.getByText("导入在后台继续,无需等待。")).toBeTruthy();
else expect(screen.queryByText("导入在后台继续,无需等待。")).toBeNull();
expect(screen.queryByText("导入在后台继续,无需等待。")).toBeNull();
expect(
screen.getByRole("heading", { name: "对话正在汇入,记忆从这里开始" }),
).toBeTruthy();
@@ -199,8 +199,8 @@ it("keeps the saved count when authority or freshness changes, without a convers
expect(view.container.querySelector('[data-running="true"]')).toBeTruthy();
expect(screen.getByText("已保存 7 条消息")).toBeTruthy();
expect(
screen.getAllByText("正在重新核对来源;已保存数量仍按任务回执显示。"),
).toHaveLength(1);
screen.queryByText("正在重新核对来源;已保存数量仍按任务回执显示。"),
).toBeNull();
view.update({
sourceSnapshot: { confirmedAt: "2026-09-14T00:00:02Z", stale: false },
jobs: [{ ...job, stored: 20 }],
+18 -47
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,
@@ -153,26 +156,6 @@ export function FirstImportReview({
ids.every((id) => batch.some((job) => job.source_id === id)) &&
batch.every((job) => job.status === "completed") &&
!Object.keys(failedSources).length;
const allPaused =
batch.length > 0 && batch.every((job) => job.status === "paused");
const importing =
!stale &&
batch.some(
(job) =>
["pending", "running"].includes(job.status) &&
sources.some(
(source) => source.source_id === job.source_id && source.authorized,
),
);
const displayImporting =
importing ||
(!stale &&
!allCompleted &&
!allPaused &&
batch.length === 0 &&
ids.some((id) =>
sources.some((source) => source.source_id === id && source.authorized),
));
const title = "对话正在汇入,记忆从这里开始";
const hostEligible = importHostEligible(sources, ids);
const draftReady = !!connectionDraft && !!onConnectionDraftChange;
@@ -282,23 +265,9 @@ export function FirstImportReview({
<R6ColdStartFrame
step={2}
title={text(title)}
eyebrow={text("导入进行中 · 顺手连接应用")}
footer={
<>
<div className="first-import-r6-footer-note">
<p>
{text(
allCompleted
? "对话已保存,记忆尚待生成。"
: displayImporting
? "导入在后台继续,无需等待。"
: stale
? "正在重新核对来源;已保存数量仍按任务回执显示。"
: allPaused
? "本次历史导入已暂停,已保存的对话还在。"
: "导入等待处理,已保存的对话还在。",
)}
</p>
{(error || actionMessage) && (
<p
role={
@@ -386,14 +355,18 @@ export function FirstImportReview({
aria-label={text("导入与原文")}
>
<p className="first-import-r6-intro">
{text(
allCompleted
? "本次导入已结束。已保存的对话无需重复导入。"
: "对话先保存在本机。你可以同时设置后续连接。",
)}
{importSubmissionState === "checking"
? t("onboardingImportChecking")
: text(
allCompleted
? "本次导入已结束。已保存的对话无需重复导入。"
: "对话先保存在本机。你可以同时设置后续连接。",
)}
</p>
<p className="first-import-r6-subintro">
{text("下一步选择记忆模式;之后仍可在设置中更改。")}
{text(
"导入不会自动开放应用读取权限。配置模型后,才会开始生成记忆。",
)}
</p>
{historyWindowLimited && (
<p className="first-import-r6-history-window" role="status">
@@ -481,13 +454,11 @@ export function FirstImportReview({
{text(connectionNote)}
</p>
)}
<p className="first-import-r6-options-note">
{text(
draftReady
? "点击底部按钮后,后台分别接入各应用;某个应用失败不影响其他应用。"
: "暂时无法连接应用,当前不会安装连接。",
)}
</p>
{!draftReady && (
<p className="first-import-r6-options-note">
{text("暂时无法连接应用,当前不会安装连接。")}
</p>
)}
</aside>
</div>
</R6ColdStartFrame>
+1
View File
@@ -226,6 +226,7 @@ describe("HealthPanel", () => {
});
vi.spyOn(window, "prompt").mockReturnValue("蓝莓没出来");
render(<HealthPanel imports={imports()} />);
expect(screen.getByText(/可能包含会话正文和本机路径/)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "记录问题时间点" }));
expect(await screen.findByText(/问题编号已复制/)).toBeTruthy();
+1
View File
@@ -298,6 +298,7 @@ export function HealthPanel({ imports }: { imports: ImportState }) {
<Download size={16} aria-hidden="true" />
{t("nativeExportLogs")}
</R6Button>
<p>{t("把本机运行日志打包成一个文件,发给开发排查。")}</p>
</div>
<section className="health-block" aria-label={t("healthLocal")}>
+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: [],
+178 -69
View File
@@ -16,6 +16,7 @@ import type {
import type { GuiLocale } from "@one-memory/i18n";
import {
ArrowUp,
ChevronDown,
ChevronLeft,
ChevronRight,
FileText,
@@ -110,12 +111,15 @@ type Props = {
settledSource?: string;
settledQuery?: string;
requestedOffset?: number;
cursorPaging?: boolean;
hasMore?: boolean;
/** The source id the SDK uses for "no source given" (shown as 未识别来源). */
unattributedSource?: string;
onQuery(value: string): void;
onSource(value: string): void;
resetPage(): void;
onPage(offset: number): void;
onMore?(): void;
onRetry(): void;
};
};
@@ -176,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();
@@ -254,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
@@ -339,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.
@@ -354,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(
@@ -406,6 +443,7 @@ export function MemoryMap(props: Props) {
search,
sources,
serverPaged,
raw,
library?.query,
library?.graphLoading,
library?.graphFailed,
@@ -1023,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"
@@ -1083,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">
@@ -1131,62 +1207,93 @@ export function MemoryMap(props: Props) {
aria-busy={pageTransitioning || undefined}
className="map-pagination"
>
<button
type="button"
className="quiet map-tool"
aria-label={text("上一页记忆")}
title={text("上一页记忆")}
disabled={pageTransitioning || library.page.offset === 0}
onClick={() => {
if (axis === "topic") setGroupKey("");
if (library.page) {
const offset = library.page.offset - library.page.limit;
setPageRetrying(false);
pageRetrySawLoading.current = false;
setPendingPageOffset(offset);
library.onPage(offset);
}
}}
>
<ChevronLeft size={16} aria-hidden="true" />
</button>
<span>
{text(
"共 {count} 条,第 {page} / {pages} 页",
{
count: count(library.page.total),
page:
library.page.total === 0
? 0
: Math.floor(library.page.offset / library.page.limit) +
1,
pages: Math.ceil(library.page.total / library.page.limit),
},
library.page.total,
)}
</span>
<button
type="button"
className="quiet map-tool"
aria-label={text("下一页记忆")}
title={text("下一页记忆")}
disabled={
pageTransitioning ||
library.page.offset + library.page.limit >= library.page.total
}
onClick={() => {
if (axis === "topic") setGroupKey("");
if (library.page) {
const offset = library.page.offset + library.page.limit;
setPageRetrying(false);
pageRetrySawLoading.current = false;
setPendingPageOffset(offset);
library.onPage(offset);
}
}}
>
<ChevronRight size={16} aria-hidden="true" />
</button>
{library.cursorPaging ? (
library.hasMore ? (
<button
type="button"
className="map-load-more"
disabled={pageTransitioning}
aria-label={
pageTransitioning ? text("正在读取…") : text("加载更多")
}
title={
pageTransitioning ? text("正在读取…") : text("加载更多")
}
onClick={() => library.onMore?.()}
>
<ChevronDown
size={18}
aria-hidden="true"
className={pageTransitioning ? "loading" : undefined}
/>
</button>
) : (
<span>{text("已加载全部")}</span>
)
) : (
<>
<button
type="button"
className="quiet map-tool"
aria-label={text("上一页记忆")}
title={text("上一页记忆")}
disabled={pageTransitioning || library.page.offset === 0}
onClick={() => {
if (axis === "topic") setGroupKey("");
if (library.page) {
const offset = library.page.offset - library.page.limit;
setPageRetrying(false);
pageRetrySawLoading.current = false;
setPendingPageOffset(offset);
library.onPage(offset);
}
}}
>
<ChevronLeft size={16} aria-hidden="true" />
</button>
<span>
{text(
"共 {count} 条,第 {page} / {pages} 页",
{
count: count(library.page.total),
page:
library.page.total === 0
? 0
: Math.floor(
library.page.offset / library.page.limit,
) + 1,
pages: Math.ceil(
library.page.total / library.page.limit,
),
},
library.page.total,
)}
</span>
<button
type="button"
className="quiet map-tool"
aria-label={text("下一页记忆")}
title={text("下一页记忆")}
disabled={
pageTransitioning ||
library.page.offset + library.page.limit >=
library.page.total
}
onClick={() => {
if (axis === "topic") setGroupKey("");
if (library.page) {
const offset = library.page.offset + library.page.limit;
setPageRetrying(false);
pageRetrySawLoading.current = false;
setPendingPageOffset(offset);
library.onPage(offset);
}
}}
>
<ChevronRight size={16} aria-hidden="true" />
</button>
</>
)}
</nav>
)}
{!library && loaded && !filtered.length && (search || groupKey) && (
@@ -1213,19 +1320,21 @@ export function MemoryMap(props: Props) {
<h2>
{props.simplified
? raw
? t("memory:原始对话")
? text("来源归属")
: text("记忆关联")
: label}
</h2>
{props.simplified && (
<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,70 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { LanguageProvider } from "./gui-language";
import { MemoryModeCards } from "./MemoryModeCards";
afterEach(cleanup);
it("shows every mode feature and reveals Ultra scheduling after selection", async () => {
const onChange = vi.fn();
const user = userEvent.setup();
const view = (value: "lite" | "pro" | "ultra") => (
<LanguageProvider>
<MemoryModeCards
aria-label="记忆模式"
value={value}
layout="rows"
onChange={onChange}
ultraDetail={
<div>
<strong>每天自动整合</strong>
<small>默认 03:00,可在设置中修改。</small>
</div>
}
/>
</LanguageProvider>
);
const { rerender } = render(view("pro"));
expect(screen.getByRole("radiogroup", { name: "记忆模式" })).toBeTruthy();
expect(screen.getAllByRole("radio")).toHaveLength(3);
expect(screen.getByText("不调用生成模型")).toBeTruthy();
expect(screen.getByText("沉淀高级记忆")).toBeTruthy();
expect(screen.getByText("增强关联召回")).toBeTruthy();
expect(screen.queryByText("每天自动整合")).toBeNull();
await user.click(screen.getByRole("radio", { name: /深度记忆/ }));
expect(onChange).toHaveBeenCalledWith("ultra", expect.any(HTMLInputElement));
rerender(view("ultra"));
expect(screen.getByText("每天自动整合")).toBeTruthy();
});
it("preserves per-mode disabled behavior", () => {
render(
<LanguageProvider>
<MemoryModeCards
aria-label="记忆模式"
value="lite"
disabled={(mode) => mode !== "lite"}
onChange={vi.fn()}
/>
</LanguageProvider>,
);
expect(screen.getByRole("radio", { name: /基础记忆/ })).toHaveProperty(
"disabled",
false,
);
expect(screen.getByRole("radio", { name: /智能记忆/ })).toHaveProperty(
"disabled",
true,
);
expect(screen.getByRole("radio", { name: /深度记忆/ })).toHaveProperty(
"disabled",
true,
);
});
+139
View File
@@ -0,0 +1,139 @@
import type { MemoryPipelineMode } from "@one-memory/contracts";
import { Check, Clock3 } from "lucide-react";
import { type ReactNode, useId } from "react";
import { MODE_COPY } from "./memory-mode-ui";
import { useOnboardingTranslation } from "./onboarding-language";
import "./memory-mode-cards.css";
const MODES: Array<{
id: MemoryPipelineMode;
index: string;
description: string;
}> = [
{
id: "lite",
index: "01",
description: "不调用生成模型。保存并检索原始对话,不再生成高级记忆。",
},
{
id: "pro",
index: "02",
description: "使用当前模型从新对话中提取事实、偏好和计划。",
},
{
id: "ultra",
index: "03",
description: "在智能记忆之上使用知识图谱,并默认每天 03:00 自动整合记忆。",
},
];
export function MemoryModeCards({
value,
current,
currentBadge,
disabled = false,
layout = "grid",
describedBy,
onChange,
ultraDetail,
ultraDetailBusy = false,
"aria-label": ariaLabel,
"aria-busy": ariaBusy,
}: {
value: MemoryPipelineMode | null;
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;
}) {
const { text } = useOnboardingTranslation();
const name = useId();
const unavailable = (mode: MemoryPipelineMode) =>
typeof disabled === "function" ? disabled(mode) : disabled;
return (
<div
className={`memory-mode-cards memory-mode-cards--${layout}`}
role="radiogroup"
aria-label={ariaLabel}
aria-busy={ariaBusy || undefined}
>
<div className="memory-mode-card-list">
{MODES.map((mode) => {
const selected = value === mode.id;
const modeDisabled = unavailable(mode.id);
const localizedTitle = text(MODE_COPY[mode.id].title);
const title = localizedTitle.replace(
/\s*[((](?:lite|pro|ultra)[))]\s*$/i,
"",
);
const features = text(MODE_COPY[mode.id].description).split(
/\s*·\s*/,
);
return (
<div
key={mode.id}
className="memory-mode-card"
data-mode={mode.id}
data-selected={selected || undefined}
data-current={current === mode.id || undefined}
data-disabled={modeDisabled || undefined}
>
<label className="memory-mode-card-choice">
<input
type="radio"
name={name}
value={mode.id}
aria-label={localizedTitle}
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">
{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)}
</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>
);
}
+134 -22
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(),
@@ -81,6 +82,7 @@ async function chooseMode(
afterEach(() => {
cleanup();
resetClient();
vi.clearAllMocks();
vi.mocked(apiErrorCode).mockReturnValue(null);
vi.mocked(memoryModePlanUnsupported).mockReturnValue(false);
@@ -96,7 +98,7 @@ it("renders all settings modes as one grouped shared choice list", async () => {
expect(within(group).getAllByRole("radio")).toHaveLength(3);
expect(
within(group).getByText("当前模式").closest("label")?.textContent,
).toContain("智能记忆(pro)");
).toContain("智能记忆PRO");
});
it("confirms the data effects before switching to lite", async () => {
@@ -115,9 +117,7 @@ it("confirms the data effects before switching to lite", async () => {
expect(screen.getByText("搜索范围")).toBeTruthy();
expect(setMemoryMode).not.toHaveBeenCalled();
await user.click(
screen.getByRole("button", { name: "切换到基础记忆(lite)" }),
);
await user.click(screen.getByRole("button", { name: "确认选择" }));
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledWith("lite"));
await waitFor(() =>
expect(document.activeElement).toBe(
@@ -139,7 +139,7 @@ it("traps modal focus, closes on Escape and restores the mode-card focus", async
expect(outside.inert).toBe(true);
await user.keyboard("{Shift>}{Tab}{/Shift}");
expect(document.activeElement).toBe(
screen.getByRole("button", { name: "切换到基础记忆(lite)" }),
screen.getByRole("button", { name: "确认选择" }),
);
await user.keyboard("{Escape}");
@@ -155,9 +155,7 @@ it("keeps focus inside the modal while the mode change is pending", async () =>
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /基础记忆(lite)/);
await user.click(
screen.getByRole("button", { name: "切换到基础记忆(lite)" }),
);
await user.click(screen.getByRole("button", { name: "确认选择" }));
const modal = screen.getByRole("dialog");
await waitFor(() => expect(document.activeElement).toBe(modal));
@@ -229,11 +227,11 @@ it("explains model cost, search scope and the next step before switching to ultr
expect(screen.getByText("模型调用与费用")).toBeTruthy();
expect(screen.getByText("搜索范围")).toBeTruthy();
expect(screen.getByText("切换后")).toBeTruthy();
expect(screen.getByText("选择后")).toBeTruthy();
expect(screen.getByText(/已经处理完的旧对话不会补做/)).toBeTruthy();
expect(screen.getByText("知识图谱")).toBeTruthy();
expect(screen.getAllByText("已有数据")).toHaveLength(1);
expect(screen.getByText(/切换本身不会立即整合/)).toBeTruthy();
expect(screen.getByText(/选择模式本身不会立即整合/)).toBeTruthy();
});
it("distinguishes regular memories from higher-level Ultra data when switching to pro", async () => {
@@ -279,9 +277,7 @@ it("focuses the Evolve settings after switching to ultra", async () => {
vi.mocked(getMemoryMode).mockResolvedValue(state("ultra"));
await chooseMode(user, /深度记忆(ultra)/);
await user.click(
screen.getByRole("button", { name: "切换到深度记忆(ultra)" }),
);
await user.click(screen.getByRole("button", { name: "确认选择" }));
await waitFor(() =>
expect(document.activeElement?.getAttribute("id")).toContain("-evolve"),
@@ -300,13 +296,131 @@ it("shows the default nightly evolve controls in ultra", async () => {
"aria-checked",
),
).toBe("true");
expect((screen.getByLabelText("执行时间") as HTMLInputElement).value).toBe(
"03:00",
);
const timePicker = screen.getByRole("button", {
name: "执行时间,03:00",
});
expect(timePicker.tagName).toBe("BUTTON");
expect(timePicker.textContent).toContain("03:00");
expect(screen.getByText(/只运行这一次,不改变每天的计划/)).toBeTruthy();
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
});
it("opens the time picker from the whole control and saves a complete time", async () => {
prepare("ultra");
vi.mocked(setEvolveSchedule).mockResolvedValue({
enabled: true,
time: "04:30",
});
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const trigger = await screen.findByRole("button", {
name: "执行时间,03:00",
});
await user.click(trigger);
const picker = screen.getByRole("dialog", { name: "执行时间" });
await user.selectOptions(
within(picker).getByRole("listbox", { name: "执行时间 HH" }),
"04",
);
await user.selectOptions(
within(picker).getByRole("listbox", { name: "执行时间 MM" }),
"30",
);
expect(setEvolveSchedule).not.toHaveBeenCalled();
await user.click(within(picker).getByRole("button", { name: "保存修改" }));
await waitFor(() =>
expect(setEvolveSchedule).toHaveBeenCalledExactlyOnceWith({
time: "04:30",
}),
);
await waitFor(() =>
expect(screen.getByRole("button", { name: "执行时间,04:30" })).toBe(
document.activeElement,
),
);
expect(screen.queryByRole("dialog", { name: "执行时间" })).toBeNull();
});
it("keeps Tab inside the time picker and restores focus on Escape", async () => {
prepare("ultra");
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const trigger = await screen.findByRole("button", {
name: "执行时间,03:00",
});
await user.click(trigger);
const picker = screen.getByRole("dialog", { name: "执行时间" });
const hours = within(picker).getByRole("listbox", {
name: "执行时间 HH",
});
const save = within(picker).getByRole("button", { name: "保存修改" });
expect(document.activeElement).toBe(hours);
await user.keyboard("{Shift>}{Tab}{/Shift}");
expect(document.activeElement).toBe(save);
await user.keyboard("{Tab}");
expect(document.activeElement).toBe(hours);
await user.keyboard("{Escape}");
expect(screen.queryByRole("dialog", { name: "执行时间" })).toBeNull();
expect(document.activeElement).toBe(trigger);
});
it("keeps the selected time open for retry when saving fails", async () => {
prepare("ultra");
vi.mocked(setEvolveSchedule).mockRejectedValue(new Error("offline"));
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await user.click(
await screen.findByRole("button", { name: "执行时间,03:00" }),
);
const picker = screen.getByRole("dialog", { name: "执行时间" });
const minutes = within(picker).getByRole("listbox", {
name: "执行时间 MM",
});
await user.selectOptions(minutes, "30");
await user.click(within(picker).getByRole("button", { name: "保存修改" }));
expect(
await screen.findByText("自动整合设置未能保存,请重试。"),
).toBeTruthy();
expect(screen.getByRole("dialog", { name: "执行时间" })).toBeTruthy();
expect((minutes as HTMLSelectElement).value).toBe("30");
await waitFor(() =>
expect(document.activeElement).toBe(
within(picker).getByRole("button", { name: "保存修改" }),
),
);
});
it("keeps the time dialog and focus stable while saving is pending", async () => {
prepare("ultra");
vi.mocked(setEvolveSchedule).mockReturnValue(new Promise(() => {}));
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await user.click(
await screen.findByRole("button", { name: "执行时间,03:00" }),
);
const picker = screen.getByRole("dialog", { name: "执行时间" });
await user.selectOptions(
within(picker).getByRole("listbox", { name: "执行时间 MM" }),
"30",
);
await user.click(within(picker).getByRole("button", { name: "保存修改" }));
await waitFor(() => expect(setEvolveSchedule).toHaveBeenCalledTimes(1));
expect(document.activeElement).toBe(picker);
await user.keyboard("{Escape}");
expect(screen.getByRole("dialog", { name: "执行时间" })).toBe(picker);
expect(document.activeElement).toBe(picker);
});
it("hides the time field when automatic evolve is off and keeps the saved time visible", async () => {
prepare("ultra");
vi.mocked(setEvolveSchedule).mockResolvedValue({
@@ -322,7 +436,7 @@ it("hides the time field when automatic evolve is off and keeps the saved time v
await user.click(automatic);
await screen.findByText(/重新开启后仍按 03:00 运行/);
expect(screen.queryByLabelText("执行时间")).toBeNull();
expect(screen.queryByRole("button", { name: /执行时间/ })).toBeNull();
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
});
@@ -380,14 +494,12 @@ it("keeps a failed switch explanation inside the open confirmation dialog", asyn
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /深度记忆(ultra)/);
await user.click(
screen.getByRole("button", { name: "切换到深度记忆(ultra)" }),
);
await user.click(screen.getByRole("button", { name: "确认选择" }));
const dialog = screen.getByRole("dialog");
expect(
await within(dialog).findByText(
"当前安装缺少知识图谱组件,无法开启 ultra。",
"当前安装缺少知识图谱组件,无法选择 ultra。",
),
).toBeTruthy();
});
@@ -423,7 +535,7 @@ it("blocks mode and Evolve controls during embedding maintenance", async () => {
expect(
await screen.findByText(
"正在检查或切换向量模型,完成前不能切换模式或手动整合。",
"正在检查或切换向量模型,完成前不能选择模式或手动整合。",
),
).toBeTruthy();
expect(
+84 -304
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,96 +22,13 @@ import {
commitConfirmedMemoryMode,
issueConfirmedModeTicket,
} from "./confirmed-memory-mode";
import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
import { commitEvolveRun, useEvolveState } from "./evolve-state";
import { MemoryModeCards } from "./MemoryModeCards";
import { BLOCKER_COPY, MemoryModeConfirmDialog } from "./memory-mode-ui";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Button, R6Switch } from "./R6Controls";
import { R6Button } from "./R6Controls";
import { R6StateRecovery } from "./R6StateRecovery";
const MODE_COPY: Record<
MemoryPipelineMode,
{ title: string; description: string }
> = {
lite: {
title: "基础记忆(lite)",
description: "保存原始对话 · 不调用生成模型 · 搜索原始会话",
},
pro: {
title: "智能记忆(pro)",
description: "沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆",
},
ultra: {
title: "深度记忆(ultra)",
description: "智能记忆 + 知识图谱 · 可自动 Evolve · 增强关联召回",
},
};
function planRows(plan: MemoryModePlan) {
const rows = [
{
label: "新对话",
content:
plan.capabilities.content_plane === "raw"
? "之后只保存原始对话,不再生成新记忆。"
: plan.capabilities.graph_enabled
? "新对话会整理成记忆并写入知识图谱。"
: "新对话会由模型整理成记忆。",
},
{
label: "模型调用与费用",
content: plan.capabilities.generation_required
? plan.capabilities.evolve_available
? "会调用当前生成模型;自动整合可能产生额外费用。切换本身不会立即整合。"
: "会调用当前生成模型,可能产生服务商费用。"
: "不调用生成模型,不再产生这部分费用。",
},
{
label: "搜索范围",
content:
plan.capabilities.content_plane === "raw"
? "切换后搜索原始对话。"
: plan.capabilities.graph_enabled
? "搜索沉淀记忆,并用知识图谱补充关联。"
: "搜索沉淀后的记忆。",
},
];
if (plan.effects.includes("earlier_raw_not_extracted"))
rows.push({
label: "已有数据",
content: "已经处理完的旧对话不会补做;原始对话全部保留。",
});
if (plan.effects.includes("higher_mode_memories_hidden"))
rows.push({
label: "已有数据",
content:
plan.effective_target === "pro"
? "普通沉淀记忆仍可使用;深度记忆新增的更高层记忆和图谱数据继续保留,但在智能记忆下不显示、不参与召回。原始对话不会删除。"
: "已有记忆继续保留,但在基础记忆下不显示、不参与召回;原始对话不会删除。",
});
if (plan.effects.includes("existing_memories_added_to_graph"))
rows.push({
label: "知识图谱",
content: "已有记忆会在后台加入知识图谱,期间仍可使用。",
});
if (plan.effects.includes("evolve_stopped"))
rows.push({
label: "每天自动整合",
content: "每天自动整合会停止,已经生成的数据不会删除。",
});
if (plan.capabilities.evolve_available)
rows.push({
label: "切换后",
content: "可设置每天自动整合时间,也可以随时立即整合一次。",
});
return rows;
}
const BLOCKER_COPY: Record<MemoryModePlan["blockers"][number], string> = {
active_model_required: "使用 pro 或 ultra 前需要先配置并启用生成模型。",
embedding_maintenance_in_progress:
"正在检查或切换向量模型,完成后才能切换记忆模式。",
memory_lifecycle_busy: "记忆维护正在进行,完成后才能切换模式。",
graph_store_unavailable: "当前安装缺少知识图谱组件,无法开启 ultra。",
};
import { UltraEvolveScheduleFields } from "./UltraEvolveScheduleFields";
export function MemoryModeSettings({
hasActiveModel,
@@ -131,7 +45,11 @@ export function MemoryModeSettings({
const id = useId();
const [state, setState] = useState<MemoryModeState | null>(null);
const [schedule, setScheduleState] = useState<EvolveSchedule | null>(null);
const [overview, setOverview] = useState<EvolveOverview | null>(null);
const evolveState = useEvolveState();
const overview = evolveState.data ?? null;
const run =
overview?.running.find((candidate) => candidate.user_id === "local") ??
null;
const [pendingMode, setPendingMode] = useState<MemoryPipelineMode | null>(
null,
);
@@ -140,67 +58,10 @@ export function MemoryModeSettings({
const [maintenance, setMaintenance] = useState(false);
const [error, setError] = useState("");
const [readFailed, setReadFailed] = useState(false);
const [run, setRun] = useState<EvolveRunReceipt | null>(null);
const refreshVersion = useRef(0);
const dialog = useRef<HTMLElement>(null);
const dialogOpener = useRef<HTMLElement | null>(null);
const focusEvolveWhenReady = useRef(false);
const focusModeWhenReady = useRef<MemoryPipelineMode | null>(null);
const busyRef = useRef(false);
busyRef.current = busy;
useEffect(() => {
if (!pendingMode || !dialog.current) return;
const panel = dialog.current;
const inertSiblings = new Map<HTMLElement, boolean>();
let branch: HTMLElement = panel.parentElement ?? panel;
while (branch.parentElement) {
for (const sibling of branch.parentElement.children) {
if (!(sibling instanceof HTMLElement) || sibling === branch) continue;
inertSiblings.set(sibling, sibling.inert);
sibling.inert = true;
}
branch = branch.parentElement;
if (branch === document.body) break;
}
const controls = () =>
Array.from(panel.querySelectorAll<HTMLElement>("button:not(:disabled)"));
controls()[0]?.focus();
const handleKey = (event: KeyboardEvent) => {
if (event.key === "Escape" && !busyRef.current) {
event.preventDefault();
setPendingPlan(null);
setPendingMode(null);
return;
}
if (event.key !== "Tab") return;
const items = controls();
const first = items[0];
const last = items.at(-1);
if (!first || !last) {
event.preventDefault();
panel.focus();
return;
}
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
panel.addEventListener("keydown", handleKey);
return () => {
panel.removeEventListener("keydown", handleKey);
for (const [sibling, wasInert] of inertSiblings) sibling.inert = wasInert;
if (dialogOpener.current?.isConnected) dialogOpener.current.focus();
};
}, [pendingMode]);
useEffect(() => {
if (pendingMode && busy) dialog.current?.focus();
}, [busy, pendingMode]);
useEffect(() => {
if (!focusEvolveWhenReady.current || pendingMode || state?.mode !== "ultra")
@@ -223,11 +84,10 @@ export function MemoryModeSettings({
const refresh = useCallback(async () => {
const version = ++refreshVersion.current;
const modeTicket = issueConfirmedModeTicket("settings");
const [modeResult, scheduleResult, overviewResult, processingResult] =
const [modeResult, scheduleResult, processingResult] =
await Promise.allSettled([
getMemoryMode(),
getEvolveSchedule(),
getEvolveOverview(),
getMemoryProcessing(),
]);
if (version !== refreshVersion.current) return;
@@ -243,10 +103,6 @@ export function MemoryModeSettings({
}
if (scheduleResult.status === "fulfilled")
setScheduleState(scheduleResult.value);
if (overviewResult.status === "fulfilled") {
setOverview(overviewResult.value);
setRun(overviewResult.value.running[0] ?? null);
}
if (processingResult.status === "fulfilled")
setMaintenance(Boolean(processingResult.value.embedding_switch));
}, [onModeChange]);
@@ -256,14 +112,11 @@ export function MemoryModeSettings({
}, [refresh]);
useEffect(() => {
const running =
maintenance ||
state?.graph_build.state === "running" ||
run?.state === "running";
const running = maintenance || state?.graph_build.state === "running";
if (!running) return;
const timer = window.setInterval(() => void refresh(), 1500);
return () => window.clearInterval(timer);
}, [maintenance, refresh, run?.state, state?.graph_build.state]);
}, [maintenance, refresh, state?.graph_build.state]);
const lifecycleBusy =
maintenance ||
@@ -332,21 +185,31 @@ export function MemoryModeSettings({
setError("");
try {
setScheduleState(await setEvolveSchedule(update));
return true;
} catch {
setError("自动整合设置未能保存,请重试。");
return false;
} finally {
setBusy(false);
}
}
async function evolveNow() {
if (!state || !schedule) return;
refreshVersion.current++;
setBusy(true);
setError("");
try {
const receipt = await startEvolve();
setRun(receipt);
await refresh();
commitEvolveRun(
receipt,
overview ?? {
mode: state.mode,
schedule,
users: {},
running: [],
},
);
} catch (reason) {
const code = apiErrorCode(reason);
setError(
@@ -371,16 +234,11 @@ export function MemoryModeSettings({
<p>{text("模式决定新对话如何保存,以及记忆页面展示什么。")}</p>
</div>
</div>
<MemoryModeChoiceList
<MemoryModeCards
aria-label={text("当前模式")}
aria-busy={state === null}
modes={(["lite", "pro", "ultra"] as const).map((mode) => ({
mode,
title: text(MODE_COPY[mode].title),
description: text(MODE_COPY[mode].description),
}))}
current={state?.mode}
selected={state?.mode ?? null}
value={state?.mode ?? null}
currentBadge={text("当前模式")}
disabled={(mode) =>
!state ||
@@ -390,6 +248,50 @@ export function MemoryModeSettings({
(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}
>
<UltraEvolveScheduleFields
schedule={schedule}
headingId={`${id}-evolve-title`}
disabled={busy || maintenance}
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>
<p>
{text("只运行这一次,不改变每天的计划。")}
{" · "}
{text(
run?.state === "running"
? "正在整合记忆…"
: last?.last_run_at
? text("上次整合:{time}").replace(
"{time}",
new Date(last.last_run_at).toLocaleString(locale),
)
: "尚未整合",
)}
</p>
</div>
<R6Button
pending={run?.state === "running"}
disabled={busy || lifecycleBusy}
onClick={() => void evolveNow()}
>
{text("立即整合")}
</R6Button>
</div>
</section>
) : null
}
/>
{state?.forced_by_env ? (
<p className="r6-settings-note" role="status">
@@ -419,141 +321,19 @@ export function MemoryModeSettings({
)
) : null}
{state?.mode === "ultra" && schedule ? (
<section
id={`${id}-evolve`}
className="r6-evolve-settings"
aria-labelledby={`${id}-evolve-title`}
tabIndex={-1}
>
<div className="r6-evolve-heading">
<h3 id={`${id}-evolve-title`}>{text("记忆整合(Evolve)")}</h3>
<p>{text("定期重新整理已有记忆,补充更稳定的关联和结论。")}</p>
</div>
<div className="r6-evolve-setting">
<div className="r6-evolve-setting-copy">
<h3>{text("每天自动整合")}</h3>
<p>
{text(
schedule.enabled
? "每天 {time} 左右自动整合,使用这台设备的本地时间;休眠错过后会补跑。"
: "已关闭。重新开启后仍按 {time} 运行;已生成的结果会保留。",
).replace("{time}", schedule.time)}
</p>
</div>
<R6Switch
label={text("每天自动整合")}
checked={schedule.enabled}
disabled={busy || maintenance}
onCheckedChange={(enabled) => void changeSchedule({ enabled })}
/>
{schedule.enabled ? (
<div className="r6-evolve-time">
<div>
<label htmlFor={`${id}-evolve-time`}>
{text("执行时间")}
</label>
<p>{text("使用这台设备的本地时间。")}</p>
</div>
<input
id={`${id}-evolve-time`}
type="time"
value={schedule.time}
disabled={busy || maintenance}
onChange={(event) =>
void changeSchedule({ time: event.target.value })
}
/>
</div>
) : null}
</div>
<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>
<R6Button
pending={run?.state === "running"}
disabled={busy || lifecycleBusy}
onClick={() => void evolveNow()}
>
{text("立即整合")}
</R6Button>
</div>
</section>
) : null}
{pendingMode ? (
<div className="r6-settings-dialog-layer" role="presentation">
<section
ref={dialog}
className="r6-settings-dialog r6-mode-confirm-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="memory-mode-confirm-title"
tabIndex={-1}
>
<h2 id="memory-mode-confirm-title">
{text("切换到 {mode}?").replace(
"{mode}",
text(MODE_COPY[pendingMode].title),
)}
</h2>
<dl className="r6-mode-confirm-effects">
{(pendingPlan ? planRows(pendingPlan) : []).map((row) => (
<div key={`${row.label}:${row.content}`}>
<dt>{text(row.label)}</dt>
<dd>{text(row.content)}</dd>
</div>
))}
</dl>
{(pendingPlan?.blockers ?? []).map((blocker) => (
<p className="r6-settings-error" role="alert" key={blocker}>
{text(BLOCKER_COPY[blocker])}
</p>
))}
{error ? (
<p className="r6-settings-error" role="alert">
{text(error)}
</p>
) : null}
<footer>
<R6Button
disabled={busy}
onClick={() => {
setPendingPlan(null);
setPendingMode(null);
}}
>
{text("取消")}
</R6Button>
<R6Button
variant="primary"
pending={busy}
disabled={Boolean(pendingPlan?.blockers.length)}
onClick={() => void commitMode()}
>
{text("切换到 {mode}").replace(
"{mode}",
text(MODE_COPY[pendingMode].title),
)}
</R6Button>
</footer>
</section>
</div>
{pendingMode && pendingPlan ? (
<MemoryModeConfirmDialog
mode={pendingMode}
plan={pendingPlan}
busy={busy}
error={error}
opener={dialogOpener.current}
onCancel={() => {
setPendingPlan(null);
setPendingMode(null);
}}
onConfirm={() => void commitMode()}
/>
) : null}
</section>
);
+250 -13
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([]);
}
@@ -289,6 +405,11 @@ it("shows raw conversations instead of advanced memory in lite", async () => {
render(<MemorySpace simplified />);
expect(await screen.findAllByText("原始对话内容")).toHaveLength(2);
expect(screen.getByText("1 条原始对话")).toBeTruthy();
expect(screen.queryByText("找到 1 条")).toBeNull();
expect(
screen.getByRole("heading", { level: 2, name: "来源归属" }),
).toBeTruthy();
expect(
document.querySelector(".core-memory-map")?.hasAttribute("hidden"),
).toBe(false);
@@ -316,13 +437,101 @@ 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"));
reads.raw.mockImplementation(async (options = {}) => {
const offset = "offset" in options ? (options.offset ?? 0) : 0;
const cursor = "cursor" in options ? options.cursor : null;
const source = "source" in options ? options.source : undefined;
const rawId = `${source ?? "all"}-${offset}`;
const rawId = `${source ?? "all"}-${cursor ? "more" : "first"}`;
return {
items: [
{
@@ -333,14 +542,17 @@ it("pages and filters the raw conversation data plane in lite", async () => {
source: source ?? "cursor",
session_id: `session-${rawId}`,
memory_at: null,
gmt_created: 1_761_000_000 - offset,
gmt_created: 1_761_000_000,
custom: {},
messages: [{ role: "user", content: `原始会话 ${rawId}` }],
},
],
total: source ? 1 : 21,
total: cursor ? 2 : 1,
limit: 20,
offset,
offset: cursor ? 1 : 0,
next_cursor: !source && !cursor ? "turn:1:0:1" : null,
has_more: !source && !cursor,
total_exact: false,
};
});
const user = userEvent.setup();
@@ -359,30 +571,46 @@ it("pages and filters the raw conversation data plane in lite", async () => {
/>,
);
expect(await screen.findAllByText("原始会话 all-0")).toHaveLength(2);
await user.click(screen.getByRole("button", { name: "下一页记忆" }));
expect(await screen.findAllByText("原始会话 all-20")).toHaveLength(2);
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, offset: 20, limit: 20 }),
expect.objectContaining({
source: undefined,
cursor: "turn:1:0:1",
paging: "cursor",
limit: 20,
}),
);
await user.click(screen.getByRole("combobox", { name: "来源" }));
await user.click(screen.getByRole("option", { name: "Cursor" }));
expect(await screen.findAllByText("原始会话 cursor-0")).toHaveLength(2);
expect(await screen.findAllByText("原始会话 cursor-first")).toHaveLength(2);
expect(reads.raw).toHaveBeenLastCalledWith(
expect.objectContaining({ source: "cursor", offset: 0, limit: 20 }),
expect.objectContaining({
source: "cursor",
cursor: null,
paging: "cursor",
limit: 20,
}),
);
await user.click(screen.getByRole("combobox", { name: "来源" }));
await user.click(screen.getByRole("option", { name: "手动添加" }));
expect(
await screen.findAllByText("原始会话 one-memory-manual-0"),
await screen.findAllByText("原始会话 one-memory-manual-first"),
).toHaveLength(2);
expect(reads.raw).toHaveBeenLastCalledWith(
expect.objectContaining({
source: "one-memory-manual",
offset: 0,
limit: 20,
paging: "cursor",
cursor: null,
}),
);
});
@@ -599,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 />);
@@ -609,6 +845,7 @@ it("opens a search hit with the complete role transcript", async () => {
await user.click(screen.getByRole("button", { name: "搜索" }));
expect(await screen.findByText("完整问题")).toBeTruthy();
expect(screen.getByText("找到 1 条")).toBeTruthy();
expect(screen.getByText("完整回答")).toBeTruthy();
expect(
document.querySelector(".map-dialogue-reading")?.textContent,
@@ -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 () => {
+241 -88
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;
@@ -123,6 +133,7 @@ export function MemorySpace({
const selectedRawRef = useRef<RawMapRecord | null>(null);
selectedRawRef.current = selectedRaw;
const rawRecords = useRef(new Map<string, RawMapRecord>());
const rawRecordsScope = useRef("");
const [detailLoad, setDetailLoad] = useState<{
memoryId: string;
status: "loading" | "failed";
@@ -164,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;
@@ -190,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;
@@ -209,6 +228,7 @@ export function MemorySpace({
const [sdkLibraryQuery, setSdkLibraryQuery] = useState("");
const [sdkLibrarySource, setSdkLibrarySource] = useState("");
const [sdkLibraryOffset, setSdkLibraryOffset] = useState(0);
const [sdkRawCursor, setSdkRawCursor] = useState<string | null>(null);
const [processing, setProcessing] = useState<MemoryProcessingSummary | null>(
null,
);
@@ -226,6 +246,10 @@ export function MemorySpace({
evolve_available: pipelineMode === "ultra",
}
: null;
const refreshSdkMap = useCallback(() => {
if (lite) setSdkRawCursor(null);
bumpSdkMap();
}, [bumpSdkMap, lite]);
const requestedQuery = sdkLibraryQuery.trim();
const sdkPageSettled =
sdkLibrary.settledMode === pipelineMode &&
@@ -233,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.
@@ -276,7 +299,10 @@ export function MemorySpace({
if (first) setSdkLibrary((current) => ({ ...current, loading: true }));
try {
const rememberRaw = (items: RawMapRecord[]) => {
const next = new Map<string, RawMapRecord>();
const next =
sdkRawCursor && rawRecordsScope.current === windowKey
? new Map(rawRecords.current)
: new Map<string, RawMapRecord>();
if (selectedRawRef.current)
next.set(
rawRecordId(selectedRawRef.current),
@@ -284,60 +310,39 @@ export function MemorySpace({
);
for (const item of items) next.set(rawRecordId(item), item);
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: sdkLibraryOffset,
limit: SDK_MEMORY_PAGE_SIZE,
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 = {
@@ -346,17 +351,63 @@ export function MemorySpace({
};
// The window expired: the server searched again and answered page 1.
if (page.window_reset && sdkLibraryOffset !== 0) setSdkLibraryOffset(0);
setSdkLibrary({
page,
loading: false,
failed: false,
settledSource: sdkLibrarySource,
settledQuery: sdkLibraryQuery,
settledMode: pipelineMode,
setSdkLibrary((current) => {
const currentPage = current.page;
const append =
lite &&
sdkRawCursor !== null &&
currentPage !== null &&
current.settledSource === sdkLibrarySource &&
current.settledQuery === sdkLibraryQuery &&
current.settledMode === pipelineMode;
if (!append)
return {
page,
loading: false,
failed: false,
settledSource: sdkLibrarySource,
settledQuery: sdkLibraryQuery,
settledMode: pipelineMode,
};
const items = [...currentPage.items];
const known = new Set(items.map((item) => item.memory_id));
for (const item of page.items)
if (!known.has(item.memory_id)) {
known.add(item.memory_id);
items.push(item);
}
return {
page: {
...page,
items,
total: items.length + (page.has_more ? 1 : 0),
offset: 0,
source_ids_by_memory: {
...currentPage.source_ids_by_memory,
...page.source_ids_by_memory,
},
source_time_by_memory: {
...currentPage.source_time_by_memory,
...page.source_time_by_memory,
},
},
loading: false,
failed: false,
settledSource: sdkLibrarySource,
settledQuery: sdkLibraryQuery,
settledMode: pipelineMode,
};
});
setSdkGraph({ scope: windowKey, ...graph });
} catch {
} catch (reason) {
if (cancelled || request.signal.aborted) return;
if (
lite &&
sdkRawCursor !== null &&
apiErrorCode(reason) === "raw_cursor_stale"
) {
setSdkRawCursor(null);
return;
}
setSdkLibrary((current) => ({
...current,
loading: false,
@@ -365,10 +416,13 @@ export function MemorySpace({
}
};
void read(true);
const timer = setInterval(() => void read(false), 30_000);
const timer =
lite && sdkRawCursor !== null
? undefined
: setInterval(() => void read(false), 30_000);
return () => {
cancelled = true;
clearInterval(timer);
if (timer !== undefined) clearInterval(timer);
controller?.abort();
};
}, [
@@ -377,9 +431,77 @@ export function MemorySpace({
sdkLibraryOffset,
sdkLibraryQuery,
sdkLibrarySource,
sdkRawCursor,
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:来源")} ${
@@ -509,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);
@@ -525,6 +650,7 @@ export function MemorySpace({
rawRecords.current.clear();
sdkSearchWindow.current = { key: "", id: null };
setSdkLibraryOffset(0);
setSdkRawCursor(null);
setSdkLibraryQuery("");
setSdkGraph(null);
setSdkLibrary((current) => ({
@@ -586,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;
@@ -697,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,
@@ -712,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) {
@@ -720,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,
@@ -734,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));
@@ -804,7 +942,7 @@ export function MemorySpace({
);
trace.done("succeeded");
closeDetail();
bumpSdkMap();
refreshSdkMap();
} catch (reason) {
trace.done(writeTraceStatus(reason), {
reason: "memory_action_failed",
@@ -833,7 +971,7 @@ export function MemorySpace({
trace.done("succeeded");
setDetail(sdkDetail(saved));
setDraft(sdkEditableText(saved));
bumpSdkMap();
refreshSdkMap();
} catch (reason) {
trace.done(writeTraceStatus(reason), {
reason: isDefiniteRefusal(reason)
@@ -897,11 +1035,12 @@ export function MemorySpace({
? pipelineMode === null
? t("memory:记忆暂时无法读取")
: lite
? sdkPage
? `${t("memory:原始对话")} · ${tn(
"sdk.raw.found",
sdkPage.total,
)}`
? displayedSdkPage
? displayedSdkPage.total_exact === false
? t("memory:原始对话")
: sdkLibrary.settledQuery.trim()
? tn("sdk.raw.found", displayedSdkPage.total)
: tn("sdk.raw.total", displayedSdkPage.total)
: sdkLibrary.failed
? null
: t("memory:正在读取…")
@@ -934,7 +1073,7 @@ export function MemorySpace({
onSaved={() => {
// A finished addition is new page content: re-read now rather than
// leave it for the next poll.
bumpSdkMap();
refreshSdkMap();
}}
onOpenMemory={(id) => {
if (!simplified) setMode("library");
@@ -1009,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>
@@ -1192,21 +1336,30 @@ 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,
requestedOffset: sdkLibraryOffset,
cursorPaging: lite && !sdkLibraryQuery.trim(),
hasMore: !!sdkPage?.has_more && !!sdkPage?.next_cursor,
unattributedSource: UNATTRIBUTED_SOURCE_ID,
onQuery: setSdkLibraryQuery,
onSource: setSdkLibrarySource,
resetPage: () => setSdkLibraryOffset(0),
resetPage: () => {
setSdkLibraryOffset(0);
setSdkRawCursor(null);
},
onPage: (offset) => setSdkLibraryOffset(offset),
onRetry: bumpSdkMap,
onMore: () => {
if (sdkPage?.next_cursor)
setSdkRawCursor(sdkPage.next_cursor);
},
onRetry: refreshSdkMap,
}
: undefined
}
@@ -1215,7 +1368,7 @@ export function MemorySpace({
displayedMode !== "map" ||
(simplified && pipelineMode === null)
}
memories={sdkPage?.items ?? []}
memories={displayedSdkPage?.items ?? []}
graph={visibleSdkGraph}
sources={sources}
processing={processing}
+15 -7
View File
@@ -1482,7 +1482,12 @@ describe("ModelSettings", () => {
...profile,
profile_id: "created",
revision: 1,
capabilities,
capabilities: {
...capabilities,
endpoint_reachable: false,
model_available: false,
embedding_available: false,
},
};
profiles = [];
create.mockImplementation(async () => {
@@ -1491,11 +1496,17 @@ describe("ModelSettings", () => {
});
probeProfile.mockResolvedValue({
profile_id: created.profile_id,
profile_revision: created.revision,
input_profile_revision: created.revision,
profile_revision: 2,
stages: [],
capabilities,
});
activate.mockResolvedValue({ ...created, is_active: true });
activate.mockResolvedValue({
...created,
revision: 2,
capabilities,
is_active: true,
});
render(<ModelSettings compactSingle onSetupComplete={onSetupComplete} />);
await user.type(screen.getByLabelText("配置名称"), "Current model");
@@ -1504,10 +1515,7 @@ describe("ModelSettings", () => {
await user.click(screen.getByRole("button", { name: "测试并完成配置" }));
await waitFor(() => expect(onSetupComplete).toHaveBeenCalledOnce());
expect(planActivation).toHaveBeenCalledWith(
created.profile_id,
created.revision,
);
expect(planActivation).toHaveBeenCalledWith(created.profile_id, 2);
expect(activate).toHaveBeenCalledWith(created.profile_id);
});
+15 -50
View File
@@ -27,6 +27,7 @@ import {
presetForGeneration,
validateGenerationModel,
} from "./GenerationModelPrimitives";
import { modelProfileReady, profileFromProbe } from "./model-profile-readiness";
import { OptionMenu } from "./OptionMenu";
import { useOnboardingTranslation } from "./onboarding-language";
import { PROVIDER_PRESETS } from "./provider-presets";
@@ -174,27 +175,6 @@ function validEndpoint(value: string): boolean {
}
}
function ready(
profile: ModelProfile,
probe: ModelProbeResult | null = null,
error: string | null = null,
): boolean {
return (
!error &&
(!probe ||
(probe.profile_id === profile.profile_id &&
probe.profile_revision === profile.revision &&
probe.capabilities.endpoint_reachable &&
probe.capabilities.model_available &&
probe.capabilities.embedding_available)) &&
profile.credential_state !== "unavailable" &&
!(profile.has_configured_api_key && !profile.has_api_key) &&
profile.capabilities.endpoint_reachable &&
profile.capabilities.model_available &&
profile.capabilities.embedding_available
);
}
function endpointLabel(value: string | null): string {
if (!value) return "未提供接口地址";
try {
@@ -467,7 +447,11 @@ export function ModelSettings({
technicalTouched.current.clear();
};
const isReady = (profile: ModelProfile) =>
ready(profile, models.probeFor(profile), models.probeErrorFor(profile));
modelProfileReady(
profile,
models.probeFor(profile),
models.probeErrorFor(profile),
);
function editProfile(profile: ModelProfile) {
if (ownerLocked) return;
@@ -626,7 +610,7 @@ export function ModelSettings({
!active ||
active.profile_id !== profile.profile_id ||
!active.is_active ||
!ready(active, result)
!modelProfileReady(active, result)
) {
setSetupError("配置已保存,尚未确认启用成功。请核对配置后重试。");
return false;
@@ -640,38 +624,15 @@ export function ModelSettings({
setSetupPhase("testing");
const result = await models.probeProfile(profile.profile_id);
if (!alive.current) return false;
if (
result &&
result.profile_id === profile.profile_id &&
result.profile_revision !== profile.revision
) {
// A newer revision used to be accepted here, and then activation turned
// on whatever the configuration had become. The user is told the
// settings they filled in passed, so this hands them a model they never
// tested. `ready` below cannot catch it either: by then the probe and
// the activated profile are both on the new revision and agree.
setSetupError(
result.profile_revision > profile.revision
? "这份配置在测试期间被改过,刚才测的是改动后的版本。配置已保存但未启用,请重新测试并启用。"
: "测试结果来自这份配置更早的版本。配置已保存但未启用,请重新测试并启用。",
);
return false;
}
if (
!result ||
result.profile_id !== profile.profile_id ||
result.profile_revision !== profile.revision ||
!result.capabilities.endpoint_reachable ||
!result.capabilities.model_available ||
!result.capabilities.embedding_available
) {
const testedProfile = profileFromProbe(profile, result);
if (!testedProfile || !modelProfileReady(testedProfile, result ?? null)) {
setSetupError(
"配置已保存,连接测试未通过。请核对配置后重试,历史对话尚未发送。",
);
setFields(probeFieldErrors(result));
return false;
}
return activateWithPlan(profile, result);
return activateWithPlan(testedProfile, result ?? null);
}
async function activateTested(profile: ModelProfile) {
@@ -1940,7 +1901,11 @@ function PresetProfileCard({
</button>
<button
type="button"
disabled={busy || !canEnable || !ready(profile, probe, probeError)}
disabled={
busy ||
!canEnable ||
!modelProfileReady(profile, probe, probeError)
}
onClick={onActivate}
>
{text("设为当前模型")}
@@ -10,13 +10,33 @@ import type { ImportSourcesController } from "./use-import-sources";
vi.mock("./api", () => ({
completeOnboarding: vi.fn(),
getEvolveSchedule: vi.fn().mockResolvedValue({
enabled: true,
time: "03:00",
}),
getModelProfiles: vi.fn().mockResolvedValue([]),
setEvolveSchedule: vi.fn(),
memoryModePlanUnsupported: vi.fn(() => false),
getMemoryMode: vi.fn().mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: false,
}),
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
planMemoryMode: vi.fn().mockResolvedValue({ blockers: [] }),
planMemoryMode: vi.fn().mockImplementation(async (target) => ({
current: "lite",
target,
effective_target: target,
capabilities: {
content_plane: target === "lite" ? "raw" : "memory",
generation_required: target !== "lite",
graph_enabled: target === "ultra",
evolve_available: target === "ultra",
},
effects: [],
warnings: [],
blockers: target === "lite" ? [] : ["active_model_required"],
})),
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
}));
@@ -136,13 +156,12 @@ 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();
await userEvent.click(screen.getByRole("button", { name: "返回第二步" }));
await userEvent.click(screen.getByRole("button", { name: "返回导入" }));
expect(screen.getByText("Import accepted")).toBeTruthy();
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
expect(completeOnboarding).not.toHaveBeenCalled();
});
@@ -161,7 +180,7 @@ it("lets an empty first-time user enter without granting or importing a source",
onFinish={finish}
/>,
);
const skip = await screen.findByRole("button", { name: "稍后添加,先进入" });
const skip = await screen.findByRole("button", { name: "选择记忆模式" });
await waitFor(() => expect((skip as HTMLButtonElement).disabled).toBe(false));
await userEvent.click(skip);
await userEvent.click(screen.getByRole("radio", { name: /lite/i }));
@@ -184,7 +203,7 @@ it("retains explicit retry or enter when saving the skipped onboarding fails", a
onFinish={finish}
/>,
);
const skip = await screen.findByRole("button", { name: "稍后添加,先进入" });
const skip = await screen.findByRole("button", { name: "选择记忆模式" });
await waitFor(() => expect((skip as HTMLButtonElement).disabled).toBe(false));
await userEvent.click(skip);
await userEvent.click(screen.getByRole("radio", { name: /lite/i }));
@@ -273,7 +292,9 @@ it("freezes the explicitly submitted source list when a later source arrives", a
resolveRefresh = resolve;
}),
);
await userEvent.click(screen.getByRole("button", { name: "开始导入" }));
await userEvent.click(
await screen.findByRole("button", { name: "开始导入" }),
);
const lateSources = [source("workbuddy"), source("codex")];
rendered.rerender(view({ ...imports, sources: lateSources }));
expect(checkbox("codex").checked).toBe(false);
@@ -24,6 +24,12 @@ import type { ImportSourcesController } from "./use-import-sources";
vi.mock("./api", () => ({
completeOnboarding: vi.fn(),
getEvolveSchedule: vi.fn().mockResolvedValue({
enabled: true,
time: "03:00",
}),
setEvolveSchedule: vi.fn(),
memoryModePlanUnsupported: vi.fn(() => false),
getModelProfiles: vi.fn(),
getMemoryMode: vi.fn().mockResolvedValue({
mode: "pro",
@@ -31,7 +37,20 @@ vi.mock("./api", () => ({
explicitly_configured: false,
}),
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
planMemoryMode: vi.fn().mockResolvedValue({ blockers: [] }),
planMemoryMode: vi.fn().mockResolvedValue({
current: "pro",
target: "lite",
effective_target: "lite",
capabilities: {
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
},
effects: [],
warnings: [],
blockers: [],
}),
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
}));
@@ -192,10 +211,10 @@ it("translates discovery, progress and privacy without importing or authorizing
}),
).toBeTruthy();
expect(
screen.getByText(
screen.queryByText(
"Antes de confirmar, solo se comprueba la información de las aplicaciones. No se leen conversaciones ni se instalan conexiones.",
),
).toBeTruthy();
).toBeNull();
expect(
screen.getByText("/fixture/cursor · Ubicación predeterminada"),
).toBeTruthy();
@@ -270,10 +289,10 @@ it("translates import review and preserves independent permission disclosure", a
}),
).toBeTruthy();
expect(
screen.getByText(
screen.queryByText(
"A continuación, elige un modo de memoria. Podrás cambiarlo más adelante en Configuración.",
),
).toBeTruthy();
).toBeNull();
expect(
screen.getByRole("region", {
name: "Progreso de importación de las aplicaciones",
@@ -305,16 +324,15 @@ it("uses compact Spanish actions and preserves Chinese copy and permissions", as
}),
).toBeTruthy();
expect(
screen.getByText(/A continuación, elige un modo de memoria/),
).toBeTruthy();
screen.queryByText(/A continuación, elige un modo de memoria/),
).toBeNull();
expect(
screen.getByText(/Solo para las apps seleccionadas y compatibles/),
).toBeTruthy();
expect(screen.getByText(/Si una falla, las demás siguen/)).toBeTruthy();
fireEvent.click(screen.getByText("中文"));
expect(screen.getByRole("button", { name: "选择记忆模式" })).toBeTruthy();
expect(navigation.textContent).toContain("连接应用");
expect(screen.getByText(/下一步选择记忆模式/)).toBeTruthy();
expect(screen.queryByText(/下一步选择记忆模式/)).toBeNull();
expect(next).not.toHaveBeenCalled();
expect(draft).not.toHaveBeenCalled();
});
@@ -406,6 +424,12 @@ it("translates completion recovery live without repeating completion writes", as
new Error("fixture-offline"),
);
const imports = controller();
imports.sources = [];
vi.mocked(imports.refresh).mockResolvedValue({
sources: [],
jobs: [],
health: null as never,
});
const finish = vi.fn();
render(
<LanguageProvider>
@@ -415,11 +439,11 @@ it("translates completion recovery live without repeating completion writes", as
);
await waitFor(() =>
expect(
screen.getByRole("button", { name: "Entrar y añadir más tarde" }),
screen.getByRole("button", { name: "Elegir el modo de memoria" }),
).toHaveProperty("disabled", false),
);
fireEvent.click(
screen.getByRole("button", { name: "Entrar y añadir más tarde" }),
screen.getByRole("button", { name: "Elegir el modo de memoria" }),
);
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
fireEvent.click(screen.getByRole("button", { name: "Entrar con Lite" }));
@@ -26,13 +26,32 @@ vi.mock("./api", () => ({
getImportJobs: vi.fn(),
connectSources: vi.fn(),
completeOnboarding: vi.fn(),
getEvolveSchedule: vi.fn().mockResolvedValue({
enabled: true,
time: "03:00",
}),
setEvolveSchedule: vi.fn(),
memoryModePlanUnsupported: vi.fn(() => false),
getMemoryMode: vi.fn().mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: false,
}),
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
planMemoryMode: vi.fn().mockResolvedValue({ blockers: [] }),
planMemoryMode: vi.fn().mockResolvedValue({
current: "pro",
target: "lite",
effective_target: "lite",
capabilities: {
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
},
effects: [],
warnings: [],
blockers: [],
}),
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
getSourceConnection: vi.fn(),
@@ -159,8 +178,23 @@ beforeEach(() => {
} as never);
vi.mocked(api.setMemoryMode).mockResolvedValue({ mode: "pro" } as never);
vi.mocked(api.planMemoryMode).mockResolvedValue({
current: "pro",
target: "lite",
effective_target: "lite",
capabilities: {
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
},
effects: [],
warnings: [],
blockers: [],
} as never);
vi.mocked(api.getEvolveSchedule).mockResolvedValue({
enabled: true,
time: "03:00",
});
vi.mocked(api.putOnboardingProgress).mockResolvedValue(state);
vi.mocked(api.patchOnboardingProgress).mockResolvedValue(state);
localStorage.clear();
@@ -313,11 +347,11 @@ it("keeps the single-source submit frozen when another source arrives during fin
it("empty discovery shows zero and allows explicit entry without a connection request", async () => {
await ready();
await waitFor(() => expect(count()).toBe("0"));
expect(screen.getByRole("button", { name: "开始导入" })).toHaveProperty(
expect(screen.getByRole("button", { name: "选择记忆模式" })).toHaveProperty(
"disabled",
true,
false,
);
fireEvent.click(screen.getByRole("button", { name: "稍后添加,先进入" }));
fireEvent.click(screen.getByRole("button", { name: "选择记忆模式" }));
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
fireEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
await waitFor(() =>
@@ -363,13 +397,7 @@ it("partial acceptance preserves the successful job and failure through model-st
"accepted-job:completed",
);
fireEvent.click(screen.getByRole("button", { name: "继续配置模型" }));
fireEvent.click(await screen.findByRole("button", { name: "返回导入" }));
expect(screen.getByLabelText("保留任务").textContent).toBe(
"accepted-job:completed",
);
expect(screen.getByLabelText("失败来源").textContent).toBe("cursor");
expect(api.completeOnboarding).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "进入应用" }));
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
fireEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
await waitFor(() =>
+11 -5
View File
@@ -15,6 +15,12 @@ import type { ImportSourcesController } from "./use-import-sources";
vi.mock("./api", () => ({
completeOnboarding: vi.fn(),
getEvolveSchedule: vi.fn().mockResolvedValue({
enabled: true,
time: "03:00",
}),
setEvolveSchedule: vi.fn(),
memoryModePlanUnsupported: vi.fn(() => false),
getMemoryMode: vi.fn().mockResolvedValue({
mode: "pro",
configured_mode: "pro",
@@ -119,7 +125,7 @@ it("renders the final shell, double title, step one, branded rows and exact sour
expect(
screen.getByRole("heading", { name: "把熟悉的背景,带到这里" }),
).toBeTruthy();
expect(screen.getByText("你的记忆,从这里接上")).toBeTruthy();
expect(screen.queryByText("你的记忆,从这里接上")).toBeNull();
expect(
screen
.getByRole("navigation", { name: "首次设置进度" })
@@ -127,8 +133,8 @@ it("renders the final shell, double title, step one, branded rows and exact sour
).toContain("导入历史");
expect(screen.getByRole("heading", { name: "本机 AI 应用" })).toBeTruthy();
expect(
screen.getByText("确认前只检查应用信息,不读取对话,也不安装连接。"),
).toBeTruthy();
screen.queryByText("确认前只检查应用信息,不读取对话,也不安装连接。"),
).toBeNull();
expect(screen.queryByRole("button", { name: "导入历史包" })).toBeNull();
expect(screen.getByText("/fixture/workbuddy · 默认位置")).toBeTruthy();
expect(
@@ -343,7 +349,7 @@ it("custom source cancel does not grant or select and accepted import retains re
expect(
(screen.getByLabelText("导入 codex 历史") as HTMLInputElement).checked,
).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "开始导入" }));
fireEvent.click(await screen.findByRole("button", { name: "开始导入" }));
await screen.findByText("已受理 workbuddy");
expect(vi.mocked(imports.connectForOnboarding).mock.calls[0]?.[0]).toEqual([
"workbuddy",
@@ -367,7 +373,7 @@ it("does not submit authorization when the first-step owner leaves during final
done = resolve;
}),
);
fireEvent.click(screen.getByRole("button", { name: "开始导入" }));
fireEvent.click(await screen.findByRole("button", { name: "开始导入" }));
view.unmount();
await act(async () =>
done({
File diff suppressed because it is too large. Load diff
+417 -185
View File
@@ -1,9 +1,12 @@
import type {
ConnectSourcesResponse,
EvolveSchedule,
ImportJob,
OnboardingModeIntent,
OnboardingState,
SourceResponse,
} from "@one-memory/contracts";
import { legacyMemoryModePlan } from "@one-memory/operations";
import { Check, X } from "lucide-react";
import {
type CSSProperties,
@@ -18,10 +21,14 @@ import {
import {
apiErrorCode,
completeOnboarding,
getEvolveSchedule,
getMemoryMode,
getModelProfiles,
memoryModePlanUnsupported,
patchOnboardingProgress,
planMemoryMode,
putOnboardingProgress,
setEvolveSchedule,
setMemoryMode,
} from "./api";
import { AppMark } from "./app-mark";
@@ -34,24 +41,29 @@ import {
defaultHostSourceIds,
FirstImportReview,
type ImportConnectionDraft,
type ImportSubmissionState,
} from "./FirstImportReview";
import {
enableHostConnection,
hostCapabilities,
hostWriteReason,
} from "./host-state";
import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
import { MemoryModeCards } from "./MemoryModeCards";
import { BLOCKER_COPY } from "./memory-mode-ui";
import { OnboardingModelController } from "./OnboardingModelController";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6ColdStartFrame } from "./R6ColdStartFrame";
import { R6Button } from "./R6Controls";
import { traceUiAction } from "./ui-trace";
import type { ImportSourcesController } from "./use-import-sources";
import {
type ImportSourcesController,
isDefiniteAccessRefusal,
} from "./use-import-sources";
import { isDefiniteRefusal } from "./write-outcome";
import "./cold-start.css";
export type OnboardingDestination = "memory" | "sources" | "settings";
type OnboardingMemoryMode = "lite" | "pro";
type OnboardingMemoryMode = OnboardingModeIntent;
// Cold start neither reads nor writes installation intent.
export const PLUGIN_CONSENT_KEY = "one-memory.consent.plugins";
@@ -112,9 +124,8 @@ export function OnboardingFlow({
const [locationCheck, setLocationCheck] = useState<LocationCheck>("checking");
const [selected, setSelected] = useState<SourceResponse[]>([]);
const [custom, setCustom] = useState<SourceResponse | null>(null);
const [busy, setBusy] = useState<
"checking" | "submitting" | "leaving" | null
>(null);
const [busy, setBusy] = useState<"checking" | "submitting" | null>(null);
const [completionBusy, setCompletionBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [itemErrors, setItemErrors] = useState<Record<string, string>>({});
const [exitDestination, setExitDestination] =
@@ -124,8 +135,12 @@ export function OnboardingFlow({
const [startingImport, setStartingImport] = useState<SourceResponse[] | null>(
null,
);
const [importSubmissionState, setImportSubmissionState] =
useState<ImportSubmissionState>("confirmed");
const resumedStep =
state.mode_intent === "pro" && state.setup_step === "model"
state.mode_intent &&
state.mode_intent !== "lite" &&
state.setup_step === "model"
? "model"
: state.mode_intent
? "mode"
@@ -138,8 +153,20 @@ export function OnboardingFlow({
);
const [modeDestination, setModeDestination] =
useState<OnboardingDestination>("sources");
const [modeSaving, setModeSaving] = useState(false);
const [modePhase, setModePhase] = useState<"idle" | "saving" | "completing">(
"idle",
);
const modeSaving = modePhase !== "idle";
const [modeError, setModeError] = useState<string | null>(null);
const [knownMode, setKnownMode] = useState<OnboardingMemoryMode>("pro");
const [ultraSchedule, setUltraSchedule] = useState<EvolveSchedule>({
enabled: true,
time: "03:00",
});
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,
);
@@ -181,7 +208,10 @@ export function OnboardingFlow({
)
.join("、"),
});
const flight = useRef(false);
const sourceFlight = useRef(false);
const modeOperation = useRef(0);
const proConfirmationOwner = useRef<string | null>(null);
const completionFlight = useRef(false);
const connectionAttempt = useRef<string | null>(null);
const connectionQueue = useRef(Promise.resolve());
const connectionQueueDepth = useRef(0);
@@ -368,20 +398,24 @@ export function OnboardingFlow({
selected.some((chosen) => chosen.source_id === item.source_id),
);
const locked =
busy !== null || exitDestination !== null || state.status === "importing";
busy !== null ||
completionBusy ||
exitDestination !== null ||
state.status === "importing";
useEffect(() => {
alive.current = true;
const modeTicket = issueConfirmedModeTicket("onboarding");
void getMemoryMode().then(
(memoryMode) => {
if (alive.current)
if (alive.current) {
commitConfirmedMemoryMode(memoryMode.mode, modeTicket);
setKnownMode(memoryMode.mode);
}
if (
alive.current &&
!modeChoiceTouched.current &&
memoryMode.explicitly_configured &&
memoryMode.configured_mode !== "ultra"
memoryMode.explicitly_configured
)
setModeChoice(memoryMode.configured_mode);
},
@@ -394,6 +428,35 @@ export function OnboardingFlow({
};
}, []);
const refreshUltraSchedule = useCallback(async () => {
const operation = ++scheduleReadOperation.current;
setScheduleLoaded(false);
setScheduleLoading(true);
try {
const schedule = await getEvolveSchedule();
if (!alive.current || scheduleReadOperation.current !== operation) return;
if (!schedule) return;
setUltraSchedule(schedule);
setScheduleLoaded(true);
setScheduleReadFailed(false);
setModeError((current) =>
current === "记忆模式状态暂时无法读取,请重试。" ? null : current,
);
} catch {
if (alive.current && scheduleReadOperation.current === operation) {
setScheduleLoaded(false);
setScheduleReadFailed(true);
}
} finally {
if (alive.current && scheduleReadOperation.current === operation)
setScheduleLoading(false);
}
}, []);
useEffect(() => {
void refreshUltraSchedule();
}, [refreshUltraSchedule]);
useEffect(() => {
setSources(imports.sources);
}, [imports.sources]);
@@ -456,7 +519,7 @@ export function OnboardingFlow({
locationCheck !== "ready" ||
locked ||
reviewingImport ||
flight.current
sourceFlight.current
)
return;
if (!scopeMatches(selected, sources)) return;
@@ -483,17 +546,24 @@ export function OnboardingFlow({
[onFinish],
);
const enterModelStep = useCallback(() => {
intentRef.current = "pro";
setupStepRef.current = "model";
const token = ++modelGeneration.current;
setModelGenerationToken(token);
setVisitedModel(true);
setSetupStep("model");
}, []);
const enterModelStep = useCallback(
(mode: Exclude<OnboardingMemoryMode, "lite"> = "pro") => {
intentRef.current = mode;
setupStepRef.current = "model";
const token = ++modelGeneration.current;
setModelGenerationToken(token);
setVisitedModel(true);
setSetupStep("model");
},
[],
);
const leaveModelStep = useCallback(
(nextIntent: OnboardingMemoryMode, nextStep: "review" | "mode") => {
(
nextIntent: OnboardingMemoryMode,
nextStep: "review" | "mode",
resetModePhase = true,
) => {
// This fence is synchronous: hidden model work must observe invalidation
// before React renders the replacement step.
modelGeneration.current += 1;
@@ -502,7 +572,10 @@ export function OnboardingFlow({
setModeChoice(nextIntent);
setSetupStep(nextStep);
if (nextStep === "mode") setModeFocusToken((value) => value + 1);
setModeSaving(false);
if (resetModePhase) {
modeOperation.current += 1;
setModePhase("idle");
}
},
[],
);
@@ -611,12 +684,15 @@ export function OnboardingFlow({
const leave = useCallback(
async (destination: OnboardingDestination) => {
if (flight.current || finished.current) return;
flight.current = true;
setBusy("leaving");
await persistFinish(destination);
flight.current = false;
if (alive.current) setBusy(null);
if (completionFlight.current || finished.current) return;
completionFlight.current = true;
setCompletionBusy(true);
try {
await persistFinish(destination);
} finally {
completionFlight.current = false;
if (alive.current) setCompletionBusy(false);
}
},
[persistFinish],
);
@@ -624,7 +700,8 @@ export function OnboardingFlow({
const requestModeChoice = useCallback(
(destination: OnboardingDestination) => {
if (finished.current) return;
if (setupStepRef.current === "model") leaveModelStep("pro", "mode");
if (setupStepRef.current === "model")
leaveModelStep(intentRef.current === "ultra" ? "ultra" : "pro", "mode");
else {
intentRef.current = modeChoice;
setupStepRef.current = "mode";
@@ -639,30 +716,97 @@ export function OnboardingFlow({
[leaveModelStep, modeChoice],
);
const loadModePlan = useCallback(
async (target: OnboardingMemoryMode) => {
try {
return await planMemoryMode(target);
} catch (reason) {
if (!memoryModePlanUnsupported(reason)) throw reason;
const profiles = await getModelProfiles();
return legacyMemoryModePlan(knownMode, target, {
hasActiveModel: profiles.some((profile) => profile.is_active),
});
}
},
[knownMode],
);
const saveModeChoice = useCallback(async () => {
if (
!modeChoice ||
modeSaving ||
finished.current ||
(modeChoice === "lite" && flight.current)
)
return;
setModeSaving(true);
if (!modeChoice || modeSaving || finished.current || completionBusy) return;
const operation = ++modeOperation.current;
const ownsModeOperation = () =>
alive.current && !finished.current && modeOperation.current === operation;
setModePhase("saving");
setModeError(null);
const modeTicket = issueConfirmedModeTicket("onboarding");
try {
if (modeChoice === "pro") {
intentRef.current = "pro";
if (modeChoice !== "lite") {
const target = modeChoice;
const [plan, profiles] = await Promise.all([
loadModePlan(target),
getModelProfiles(),
]);
if (!ownsModeOperation()) return;
const hasActiveModel = profiles.some((profile) => profile.is_active);
if (target === "ultra" && !scheduleLoaded) {
setModeError(
scheduleReadFailed
? "记忆模式状态暂时无法读取,请重试。"
: "正在读取记忆模式…",
);
return;
}
const blocking = plan.blockers.filter(
(blocker) => blocker !== "active_model_required",
);
if (blocking.length > 0) {
setModeError(
blocking.map((blocker) => BLOCKER_COPY[blocker]).join(" "),
);
return;
}
intentRef.current = target;
setupStepRef.current = "mode";
const selectionStillCurrent = () =>
alive.current &&
!finished.current &&
intentRef.current === "pro" &&
ownsModeOperation() &&
intentRef.current === target &&
setupStepRef.current === "mode";
if (
hasActiveModel &&
!plan.blockers.includes("active_model_required")
) {
const changed = await queueModeWrite(target, selectionStillCurrent);
if (!selectionStillCurrent()) return;
commitConfirmedMemoryMode(changed.mode, modeTicket);
setKnownMode(changed.mode);
if (target === "ultra") {
try {
await setEvolveSchedule(ultraSchedule);
} catch {
if (selectionStillCurrent())
setModeError("自动整合设置未能保存,请重试。");
return;
}
if (!selectionStillCurrent()) return;
}
await queueProgressWrite(
() =>
putOnboardingProgress({
mode_intent: target,
setup_step: "mode",
model_profile_id: null,
}),
selectionStillCurrent,
);
if (!selectionStillCurrent()) return;
setModePhase("completing");
await leave(modeDestination);
return;
}
const savedProgress = await queueProgressWrite(
() =>
putOnboardingProgress({
mode_intent: "pro",
mode_intent: target,
setup_step: "model",
model_profile_id: modelProfileId.current,
}),
@@ -670,15 +814,26 @@ export function OnboardingFlow({
);
if (!selectionStillCurrent()) return;
modelProfileId.current = savedProgress.model_profile_id;
enterModelStep();
enterModelStep(target);
return;
}
leaveModelStep("lite", "mode");
leaveModelStep("lite", "mode", false);
const liteStillCurrent = () =>
alive.current &&
!finished.current &&
ownsModeOperation() &&
intentRef.current === "lite" &&
setupStepRef.current === "mode";
const plan = await loadModePlan("lite");
if (!liteStillCurrent()) return;
if (plan.blockers.length > 0) {
setModeError(
plan.blockers.map((blocker) => BLOCKER_COPY[blocker]).join(" "),
);
return;
}
const changed = await queueModeWrite("lite", liteStillCurrent);
if (!liteStillCurrent()) return;
commitConfirmedMemoryMode(changed.mode, modeTicket);
setKnownMode(changed.mode);
await queueProgressWrite(
() =>
putOnboardingProgress({
@@ -688,19 +843,17 @@ export function OnboardingFlow({
}),
liteStillCurrent,
);
if (!alive.current) return;
const changed = await queueModeWrite("lite");
if (!alive.current) return;
commitConfirmedMemoryMode(changed.mode, modeTicket);
if (!liteStillCurrent()) return;
setModePhase("completing");
await leave(modeDestination);
} catch (reason) {
if (isCompletedProgressConflict(reason)) {
finishOnce(modeDestination);
} else if (alive.current) {
} else if (ownsModeOperation() && !isGenerationInvalid(reason)) {
setModeError("模式未能保存。你的选择已保留,请重试。");
}
} finally {
if (alive.current) setModeSaving(false);
if (ownsModeOperation()) setModePhase("idle");
}
}, [
enterModelStep,
@@ -709,9 +862,14 @@ export function OnboardingFlow({
modeChoice,
modeDestination,
modeSaving,
completionBusy,
finishOnce,
loadModePlan,
queueModeWrite,
queueProgressWrite,
scheduleLoaded,
scheduleReadFailed,
ultraSchedule,
]);
const persistModelProfile = useCallback(
@@ -729,58 +887,101 @@ export function OnboardingFlow({
[queueProgressWrite],
);
const confirmProMode = useCallback(
const confirmModelMode = useCallback(
async (token: number, ownerProfileId: string) => {
const target = intentRef.current;
if (target !== "pro" && target !== "ultra") return;
if (target === "ultra" && !scheduleLoaded) {
leaveModelStep("ultra", "mode");
setModeError(
scheduleReadFailed
? "记忆模式状态暂时无法读取,请重试。"
: "正在读取记忆模式…",
);
return;
}
const confirmationOwner = `${token}:${ownerProfileId}:${target}`;
const ownsModelStep = () =>
alive.current &&
!finished.current &&
modelGeneration.current === token &&
intentRef.current === "pro" &&
intentRef.current === target &&
setupStepRef.current === "model" &&
modelProfileId.current === ownerProfileId;
if (!ownsModelStep()) return;
setModeSaving(true);
if (
!ownsModelStep() ||
proConfirmationOwner.current === confirmationOwner ||
completionBusy
)
return;
proConfirmationOwner.current = confirmationOwner;
const operation = ++modeOperation.current;
const ownsConfirmation = () =>
ownsModelStep() && modeOperation.current === operation;
setModePhase("saving");
setModeError(null);
const modeTicket = issueConfirmedModeTicket("onboarding");
try {
const plan = await planMemoryMode("pro");
if (!ownsModelStep()) return;
const plan = await loadModePlan(target);
if (!ownsConfirmation()) return;
if (plan.blockers.length > 0) throw new Error(plan.blockers.join(","));
const changed = await queueModeWrite("pro", ownsModelStep);
if (!ownsModelStep()) return;
const changed = await queueModeWrite(target, ownsConfirmation);
if (!ownsConfirmation()) return;
commitConfirmedMemoryMode(changed.mode, modeTicket);
setKnownMode(changed.mode);
if (target === "ultra") {
try {
await setEvolveSchedule(ultraSchedule);
} catch {
if (ownsConfirmation()) {
leaveModelStep("ultra", "mode");
setModeError("自动整合设置未能保存,请重试。");
}
return;
}
if (!ownsConfirmation()) return;
}
await queueProgressWrite(
() =>
putOnboardingProgress({
mode_intent: "pro",
mode_intent: target,
setup_step: "model",
model_profile_id: ownerProfileId,
}),
ownsModelStep,
ownsConfirmation,
);
if (!ownsModelStep()) return;
await persistFinish(modeDestination);
if (!ownsConfirmation()) return;
setModePhase("completing");
await leave(modeDestination);
} catch (reason) {
if (isCompletedProgressConflict(reason)) {
finishOnce(modeDestination);
} else if (alive.current && !isGenerationInvalid(reason))
setModeError("Pro 模式未能确认保存。模型配置已保留,请重试。");
} else if (ownsConfirmation() && !isGenerationInvalid(reason))
setModeError("模式切换未能确认,仍显示上次确认的模式。");
} finally {
if (alive.current) setModeSaving(false);
if (proConfirmationOwner.current === confirmationOwner)
proConfirmationOwner.current = null;
if (ownsConfirmation()) setModePhase("idle");
}
},
[
completionBusy,
finishOnce,
leave,
leaveModelStep,
loadModePlan,
modeDestination,
persistFinish,
queueModeWrite,
queueProgressWrite,
scheduleLoaded,
scheduleReadFailed,
ultraSchedule,
],
);
async function checkLocations() {
if (flight.current || locked) return;
flight.current = true;
if (sourceFlight.current || locked) return;
sourceFlight.current = true;
scopeVersion.current += 1;
setBusy("checking");
setLocationCheck("checking");
@@ -805,7 +1006,7 @@ export function OnboardingFlow({
setLocationCheck("failed");
setError("位置检测未完成,尚未读取历史。可以重新检测,或先进入工作台。");
} finally {
flight.current = false;
sourceFlight.current = false;
if (alive.current) setBusy(null);
}
}
@@ -813,7 +1014,7 @@ export function OnboardingFlow({
function toggleSource(source: SourceResponse, checked: boolean) {
if (
flight.current ||
sourceFlight.current ||
locked ||
locationCheck !== "ready" ||
!isAppSource(source) ||
@@ -836,7 +1037,7 @@ export function OnboardingFlow({
}
function selectAllSources() {
if (flight.current || locked || locationCheck !== "ready") return;
if (sourceFlight.current || locked || locationCheck !== "ready") return;
scopeVersion.current += 1;
setError(null);
setItemErrors({});
@@ -849,7 +1050,7 @@ export function OnboardingFlow({
async function startImport() {
if (
flight.current ||
sourceFlight.current ||
locked ||
locationCheck !== "ready" ||
!selected.length
@@ -863,7 +1064,8 @@ export function OnboardingFlow({
}
async function submitImport(approved: SourceResponse[]) {
flight.current = true;
sourceFlight.current = true;
setImportSubmissionState("checking");
if (window.oneMemory) setStartingImport(approved);
const version = scopeVersion.current;
const retryFailed = Object.keys(itemErrors).length > 0;
@@ -875,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;
@@ -899,6 +1104,7 @@ export function OnboardingFlow({
setItemErrors(result.failed_sources);
if (result.accepted_source_ids.length === 0) {
setStartingImport(null);
setImportSubmissionState("refused");
setError(
"本次没有来源获准导入。请核对下方原因后重试,或到应用页查看状态。",
);
@@ -907,22 +1113,36 @@ 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 {
} catch (reason) {
if (alive.current) {
setStartingImport(null);
setError(
"开始导入的结果尚未确认,选择已保留。可以重试,或进入应用页核对。",
);
const refused = isDefiniteAccessRefusal(reason);
if (refused) {
setStartingImport(null);
setImportSubmissionState("refused");
setError(
"本次没有来源获准导入。请核对下方原因后重试,或到应用页查看状态。",
);
} else {
setImportSubmissionState("checking");
setError(
window.oneMemory
? null
: "开始导入的结果尚未确认,选择已保留。可以重试,或进入应用页核对。",
);
}
}
} finally {
connectSent.current = false;
flight.current = false;
sourceFlight.current = false;
if (alive.current) setBusy(null);
}
}
const count = selected.length;
const continueWithoutImport =
busy === null && locationCheck !== "checking" && count === 0;
// D51 item 3: only a *confirmed* snapshot may show a number. A failed
// detection must read 未完成 even when sources are listed — otherwise the
// number branch answered first and the summary claimed a count while the
@@ -987,10 +1207,11 @@ export function OnboardingFlow({
jobs={reviewJobs}
failedSources={accepted?.failed_sources}
sourceSnapshot={imports.sourceSnapshot}
busy={busy === "leaving"}
importSubmissionState={importSubmissionState}
busy={completionBusy}
error={error && text(error)}
onContinue={(destination) => {
if (busy === "leaving") return;
if (completionBusy) return;
if (completionFailed) {
void leave(destination);
return;
@@ -1025,96 +1246,109 @@ export function OnboardingFlow({
<R6ColdStartFrame
step={3}
title={text("选择记忆模式")}
eyebrow={text("最后一步 · 决定如何处理对话")}
focusToken={modeFocusToken}
footer={
<>
<R6Button
variant="quiet"
disabled={modeSaving || busy === "leaving"}
onClick={() => {
setModeError(null);
intentRef.current = modeChoice;
setupStepRef.current = "review";
setSetupStep("review");
void queueProgressWrite(() =>
patchOnboardingProgress({
mode_intent: modeChoice,
setup_step: "review",
}),
).catch((reason) => {
if (!isCompletedProgressConflict(reason) && alive.current)
setModeError(
"恢复进度未能保存。你的选择已保留,请重试。",
);
});
}}
>
{text("返回导入")}
</R6Button>
<R6Button
className="onboarding-r6-submit"
variant="primary"
pending={modeSaving}
disabled={
!modeChoice ||
busy === "leaving" ||
(modeChoice === "lite" && flight.current)
}
onClick={() => void saveModeChoice()}
>
{text(
modeSaving
? "正在保存…"
: modeChoice === "lite"
? "使用 Lite 进入"
: modeChoice === "pro"
? "继续配置模型"
: "继续",
)}
</R6Button>
</>
<R6Button
className="onboarding-r6-submit"
variant="primary"
pending={modeSaving}
disabled={
!modeChoice ||
completionBusy ||
(modeChoice === "ultra" && !scheduleLoaded)
}
onClick={() => void saveModeChoice()}
>
{text(
modePhase === "saving"
? "正在保存模式…"
: modePhase === "completing"
? "正在完成首次设置…"
: !modeChoice
? "继续"
: modeChoice === "lite"
? "使用 Lite 进入"
: "配置模型",
)}
</R6Button>
}
>
<section
className="onboarding-r6-mode"
aria-labelledby="onboarding-memory-mode-title"
aria-label={text("记忆模式")}
>
<div className="onboarding-r6-mode-intro">
<p id="onboarding-memory-mode-title">
{text("之后可在设置中更改。")}
</p>
<h2>{text("你希望 MemHub 如何形成记忆?")}</h2>
<p>{text("需要明确选择一种方式;之后仍可在设置中更改。")}</p>
</div>
<MemoryModeChoiceList
<MemoryModeCards
aria-label={text("记忆模式")}
modes={[
{
mode: "lite",
title: "Lite",
description: text(
"无需模型,只保存并检索原始对话,不生成提炼记忆。",
),
},
{
mode: "pro",
title: "Pro",
description: text(
"使用模型理解对话,提取并整理可持续生长的记忆。",
),
},
]}
selected={modeChoice}
disabled={modeSaving || busy === "leaving"}
recommended={{ mode: "pro", badge: text("推荐") }}
value={modeChoice}
layout="rows"
describedBy={{
ultra:
modeChoice === "ultra"
? "onboarding-ultra-schedule-description"
: undefined,
}}
disabled={modeSaving || completionBusy}
ultraDetailBusy={scheduleLoading}
onChange={(mode) => {
modeChoiceTouched.current = true;
setModeChoice(mode as OnboardingMemoryMode);
intentRef.current = mode;
setModeChoice(mode);
setModeError(null);
}}
ultraDetail={
scheduleLoaded ? (
<div className="onboarding-r6-ultra-default" role="status">
<strong>{text("每天自动整合")}</strong>
<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
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
id="onboarding-ultra-schedule-description"
className="onboarding-r6-schedule-loading"
role="status"
>
{text("正在读取自动整合设置…")}
</p>
)
}
/>
<p className="onboarding-r6-mode-ultra-note">
{text("Ultra 可在完成首次设置后到设置页开启。")}
</p>
{(modeError || error) && (
<p className="onboarding-r6-mode-error" role="alert">
{text(modeError ?? error ?? "")}
@@ -1129,7 +1363,8 @@ export function OnboardingFlow({
generationToken={modelGenerationToken}
isGenerationCurrent={(token) =>
modelGeneration.current === token &&
intentRef.current === "pro" &&
intentRef.current !== "lite" &&
intentRef.current !== null &&
setupStepRef.current === "model"
}
initialProfileId={modelProfileId.current}
@@ -1142,7 +1377,7 @@ export function OnboardingFlow({
void persistModelProfile(profileId)
}
onModelActiveConfirmed={(token, ownerProfileId) =>
void confirmProMode(token, ownerProfileId)
void confirmModelMode(token, ownerProfileId)
}
sourceNames={sourceIds.map(
(id) =>
@@ -1157,10 +1392,11 @@ export function OnboardingFlow({
)}
onBack={() => {
setModeError(null);
leaveModelStep("pro", "mode");
const target = intentRef.current === "ultra" ? "ultra" : "pro";
leaveModelStep(target, "mode");
void queueProgressWrite(() =>
putOnboardingProgress({
mode_intent: "pro",
mode_intent: target,
setup_step: "mode",
model_profile_id: modelProfileId.current,
}),
@@ -1188,7 +1424,7 @@ export function OnboardingFlow({
<>
<R6Button
variant="quiet"
disabled={busy !== null}
disabled={busy !== null || completionBusy}
onClick={() => finishOnce(exitDestination)}
>
{text(
@@ -1197,7 +1433,7 @@ export function OnboardingFlow({
</R6Button>
<R6Button
variant="primary"
disabled={busy !== null}
disabled={busy !== null || completionBusy}
onClick={() => void leave(exitDestination)}
>
{text("重试保存并进入")}
@@ -1215,16 +1451,8 @@ export function OnboardingFlow({
<R6ColdStartFrame
step={1}
title={text("把熟悉的背景,带到这里")}
eyebrow={text("你的记忆,从这里接上")}
footer={
<>
<R6Button
variant="quiet"
disabled={locked}
onClick={() => requestModeChoice("sources")}
>
{text("稍后添加,先进入")}
</R6Button>
{error && (
<p className="onboarding-r6-error" role="alert">
{error && text(error)}
@@ -1233,10 +1461,20 @@ export function OnboardingFlow({
<R6Button
className="onboarding-r6-submit"
variant="primary"
disabled={locked || count === 0}
onClick={() => void startImport()}
disabled={locked}
onClick={() =>
continueWithoutImport
? requestModeChoice("sources")
: void startImport()
}
>
{text(busy === "submitting" ? "正在开始导入…" : "开始导入")}
{text(
continueWithoutImport
? "选择记忆模式"
: busy === "submitting"
? "正在开始导入…"
: "开始导入",
)}
</R6Button>
</>
}
@@ -1246,12 +1484,6 @@ export function OnboardingFlow({
className="onboarding-r6-apps"
aria-labelledby="cold-start-phase"
>
<div className="onboarding-r6-intro">
<p>
{text("不用从头介绍自己。从常用的 App 开始,把已有背景带过来。")}
</p>
<p>{text("确认前只检查应用信息,不读取对话,也不安装连接。")}</p>
</div>
<div className="onboarding-r6-list-heading">
<h2 id="cold-start-phase" ref={heading} tabIndex={-1}>
{text("本机 AI 应用")}
@@ -23,7 +23,17 @@ const { embedding, getModelProfiles, models, step } = vi.hoisted(() => ({
max_concurrency: 4,
context_window: null,
has_api_key: true,
credential_state: "available" as
| "available"
| "missing"
| "unavailable",
is_active: true,
capabilities: {
endpoint_reachable: true,
model_available: true,
embedding_available: true,
structured_output: false,
},
},
],
pendingProbe: null as null | {
@@ -57,7 +67,7 @@ const { embedding, getModelProfiles, models, step } = vi.hoisted(() => ({
supported: false as boolean | null,
planActivation: vi.fn(),
startPlannedSwitch: vi.fn(),
shouldUseLegacyActivation: () => true,
shouldUseLegacyActivation: (() => true) as () => boolean,
clearPlan: vi.fn(),
clearError: vi.fn(),
},
@@ -123,6 +133,7 @@ beforeEach(() => {
models.creationMissing = null;
embedding.current = null;
embedding.supported = false;
embedding.shouldUseLegacyActivation = () => true;
step.current = null;
});
@@ -164,6 +175,32 @@ it("resumes and confirms the active profile explicitly owned by onboarding", asy
expect(enabled).toHaveBeenCalledWith(1, "active-profile");
});
it("does not complete with an active profile whose credential is unavailable", async () => {
models.profiles = [
{
...activeProfile,
credential_state: "unavailable",
},
];
const enabled = vi.fn();
render(
<OnboardingModelController
sourceNames={[]}
initialProfileId="active-profile"
onBack={vi.fn()}
onModelActiveConfirmed={enabled}
/>,
);
await waitFor(() => expect(step.current).not.toBeNull());
expect(enabled).not.toHaveBeenCalled();
await userEvent.click(
screen.getByRole("button", { name: "继续使用当前模型" }),
);
expect(enabled).not.toHaveBeenCalled();
expect(screen.getByText(/模型凭据或连接状态已变化/)).toBeTruthy();
});
it("hydrates a persisted inactive profile without creating a duplicate", async () => {
models.profiles = [{ ...activeProfile, is_active: false }];
const active = vi.fn();
@@ -206,7 +243,7 @@ it("recovers a pending creation and persists its profile id", async () => {
});
it("reconciles a pending probe instead of saving or creating again", async () => {
const inactive = { ...activeProfile, is_active: false };
const inactive = { ...activeProfile, revision: 4, is_active: false };
models.profiles = [inactive];
models.pendingProbe = {
operationId: "probe-operation",
@@ -215,7 +252,8 @@ it("reconciles a pending probe instead of saving or creating again", async () =>
};
models.probeProfile.mockResolvedValueOnce({
profile_id: "active-profile",
profile_revision: 3,
input_profile_revision: 3,
profile_revision: 4,
capabilities: {
endpoint_reachable: true,
model_available: true,
@@ -238,10 +276,76 @@ it("reconciles a pending probe instead of saving or creating again", async () =>
await waitFor(() =>
expect(models.probeProfile).toHaveBeenCalledWith("active-profile"),
);
await waitFor(() =>
expect(screen.getByText("模型连接测试通过,可以启用。")).toBeTruthy(),
);
expect(currentStep().canSaveAndEnable).toBe(true);
expect(models.update).not.toHaveBeenCalled();
expect(models.create).not.toHaveBeenCalled();
});
it("passes the first test when Core advances the tested profile revision", async () => {
const inactive = { ...activeProfile, revision: 1, is_active: false };
models.profiles = [inactive];
models.update.mockResolvedValueOnce(inactive);
models.probeProfile.mockResolvedValueOnce({
profile_id: inactive.profile_id,
input_profile_revision: 1,
profile_revision: 2,
capabilities: {
endpoint_reachable: true,
model_available: true,
embedding_available: true,
},
stages: [{ ok: false }],
});
models.activate.mockResolvedValueOnce({
...inactive,
revision: 2,
is_active: true,
capabilities: {
endpoint_reachable: true,
model_available: true,
embedding_available: true,
},
});
embedding.supported = true;
embedding.current = { writes_paused: false };
embedding.shouldUseLegacyActivation = () => false;
embedding.planActivation.mockResolvedValueOnce({ needed: false });
const confirmed = vi.fn();
render(
<OnboardingModelController
sourceNames={[]}
initialProfileId={inactive.profile_id}
onBack={vi.fn()}
onModelActiveConfirmed={confirmed}
/>,
);
await waitFor(() =>
expect(
(currentStep().profile as { profile_id: string } | null)?.profile_id,
).toBe(inactive.profile_id),
);
await act(async () => {
await (currentStep().onTest as () => Promise<void>)();
});
await waitFor(() =>
expect(screen.getByText("模型连接测试通过,可以启用。")).toBeTruthy(),
);
expect(currentStep().canSaveAndEnable).toBe(true);
expect(currentStep().profile).toMatchObject({ revision: 2 });
expect(getModelProfiles).not.toHaveBeenCalled();
await act(async () => {
await (currentStep().onSaveAndEnable as () => Promise<void>)();
});
expect(embedding.planActivation).toHaveBeenCalledWith(inactive.profile_id, 2);
expect(models.activate).toHaveBeenCalledWith(inactive.profile_id);
expect(confirmed).toHaveBeenCalledWith(1, inactive.profile_id);
});
it("keeps an active resumed profile on the model step while writes are paused", async () => {
embedding.supported = true;
embedding.current = { writes_paused: true };
@@ -1,16 +1,15 @@
import type {
ModelProbeResult,
ModelProfile,
ModelProfileInput,
ModelProfilePatch,
} from "@one-memory/contracts";
import { useEffect, useRef, useState } from "react";
import { getModelProfiles } from "./api";
import { useCallback, useEffect, useRef, useState } from "react";
import { EmbeddingSwitchConfirmDialog } from "./EmbeddingSwitchExperience";
import {
presetForGeneration,
validateGenerationModel,
} from "./GenerationModelPrimitives";
import { modelProfileReady, profileFromProbe } from "./model-profile-readiness";
import {
type OnboardingModelDraft,
OnboardingModelStep,
@@ -41,23 +40,6 @@ const initial: OnboardingModelDraft = {
context_window: "",
};
export function probeMatchesProfile(
original: ModelProfile,
current: ModelProfile | undefined,
probe: ModelProbeResult | undefined | null,
): current is ModelProfile {
return Boolean(
current &&
probe &&
probe.profile_id === original.profile_id &&
probe.profile_revision === current.revision &&
probe.capabilities.endpoint_reachable &&
probe.capabilities.model_available &&
probe.capabilities.embedding_available &&
probe.stages.every((stage) => stage.ok),
);
}
export function OnboardingModelController({
sourceNames,
importedConversationCount = 0,
@@ -112,6 +94,17 @@ export function OnboardingModelController({
>(),
);
const autoConfirmed = useRef<string | null>(null);
const confirmReadyProfile = useCallback(
(profile: ModelProfile, owner = generationToken) => {
if (!isGenerationCurrent(owner) || !modelProfileReady(profile)) {
setError("模型凭据或连接状态已变化,请重新测试后再完成首次设置。");
return false;
}
onModelActiveConfirmed?.(owner, profile.profile_id);
return true;
},
[generationToken, isGenerationCurrent, onModelActiveConfirmed],
);
useEffect(() => {
const profile = initialProfileId
@@ -152,21 +145,19 @@ export function OnboardingModelController({
useEffect(() => {
if (
!saved?.is_active ||
!isGenerationCurrent(generationToken) ||
(embeddingSwitch.supported !== false && !embeddingSwitch.current) ||
embeddingSwitch.current?.writes_paused
)
return;
const key = `${generationToken}:${saved.profile_id}`;
if (autoConfirmed.current === key) return;
autoConfirmed.current = key;
onModelActiveConfirmed?.(generationToken, saved.profile_id);
if (confirmReadyProfile(saved, generationToken))
autoConfirmed.current = key;
}, [
embeddingSwitch.current,
embeddingSwitch.supported,
confirmReadyProfile,
generationToken,
isGenerationCurrent,
onModelActiveConfirmed,
saved,
]);
@@ -182,8 +173,7 @@ export function OnboardingModelController({
flight.current = false;
setSwitchPendingJobId(null);
setPhase("idle");
if (saved && isGenerationCurrent(generationToken))
onModelActiveConfirmed?.(generationToken, saved.profile_id);
if (saved) confirmReadyProfile(saved, generationToken);
} else if (finished.state === "rolled_back") {
flight.current = false;
setSwitchPendingJobId(null);
@@ -192,9 +182,8 @@ export function OnboardingModelController({
}
}, [
embeddingSwitch.current?.last_finished,
confirmReadyProfile,
generationToken,
isGenerationCurrent,
onModelActiveConfirmed,
saved,
switchPendingJobId,
]);
@@ -338,11 +327,10 @@ export function OnboardingModelController({
setPhase("testing");
const result = await models.probeProfile(profile.profile_id);
if (!isGenerationCurrent(owner)) return;
const current = (await getModelProfiles()).find(
(item) => item.profile_id === profile.profile_id,
const current = profileFromProbe(profile, result);
const passed = Boolean(
current && modelProfileReady(current, result ?? null),
);
if (!isGenerationCurrent(owner)) return;
const passed = probeMatchesProfile(profile, current, result);
if (current) setSaved(current);
setProbe({
state: passed ? "passed" : "failed",
@@ -378,11 +366,10 @@ export function OnboardingModelController({
try {
const result = await models.probeProfile(saved.profile_id);
if (!isGenerationCurrent(owner)) return;
const current = (await getModelProfiles()).find(
(item) => item.profile_id === saved.profile_id,
const current = profileFromProbe(saved, result);
const passed = Boolean(
current && modelProfileReady(current, result ?? null),
);
if (!isGenerationCurrent(owner)) return;
const passed = probeMatchesProfile(saved, current, result);
if (current) setSaved(current);
setProbe({
state: passed ? "passed" : "failed",
@@ -408,8 +395,7 @@ export function OnboardingModelController({
setError("模型写入暂时关闭。请等待索引维护完成后重试。");
return;
}
if (isGenerationCurrent(generationToken))
onModelActiveConfirmed?.(generationToken, saved.profile_id);
confirmReadyProfile(saved, generationToken);
return;
}
const owner = generationToken;
@@ -433,9 +419,15 @@ export function OnboardingModelController({
}
const active = await models.activate(saved.profile_id);
if (!isGenerationCurrent(owner)) return;
if (!active?.is_active) throw new Error("model_activation_unconfirmed");
if (
!active?.is_active ||
active.profile_id !== saved.profile_id ||
active.revision !== saved.revision ||
!modelProfileReady(active)
)
throw new Error("model_activation_unconfirmed");
trace.done("succeeded");
onModelActiveConfirmed?.(owner, active.profile_id);
confirmReadyProfile(active, owner);
} catch {
trace.done("unknown", { reason: "model_enable_unconfirmed" });
setError("模型未能确认启用,请在设置中核对。");
@@ -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>
+40 -7
View File
@@ -48,7 +48,41 @@ it("sizes the import action to its label and wraps footer content", () => {
);
});
it("keeps progress cards content-sized and the step-two footer in normal flow", () => {
it("uses content-driven shell, discovery and mode geometry", () => {
const stage = declaration("onboarding-r6.css", ".onboarding-r6-stage");
const shell = declaration("onboarding-r6.css", ".onboarding-r6-shell");
const discovery = declaration(
"onboarding-r6.css",
".onboarding-r6-discovery",
);
const heading = declaration(
"onboarding-r6.css",
".onboarding-r6-list-heading",
);
const mode = declaration("onboarding-r6.css", ".onboarding-r6-mode");
expect(stage).toMatch(/min-height:\s*0/);
expect(stage).not.toMatch(/min-height:\s*852px/);
expect(shell).toMatch(/height:\s*100%/);
expect(shell).not.toMatch(/(?:^|;)\s*height:\s*820px/);
expect(discovery).toMatch(/height:\s*100%/);
expect(heading).toMatch(/position:\s*static/);
expect(mode).toMatch(/width:\s*100%/);
});
it("does not reserve an empty eyebrow row", () => {
render(
<LanguageProvider>
<R6ColdStartFrame step={1} title="Fixture" footer={null}>
<p>Fixture body</p>
</R6ColdStartFrame>
</LanguageProvider>,
);
expect(screen.getByRole("heading", { name: "Fixture" })).toBeTruthy();
expect(screen.queryByText("你的记忆,从这里接上")).toBeNull();
});
it("keeps the v1.0.4 step-two split layout and progress geometry", () => {
const card = declaration(
"r6-import-progress.css",
".r6-import-progress-card",
@@ -60,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*auto/,
).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-footer-note"),
).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/,
);
+2 -2
View File
@@ -13,7 +13,7 @@ export function R6ColdStartFrame({
}: {
step: 1 | 2 | 3;
title: string;
eyebrow: string;
eyebrow?: string | null;
children: ReactNode;
footer: ReactNode;
focusToken?: number;
@@ -39,7 +39,7 @@ export function R6ColdStartFrame({
<section className="onboarding-r6-shell" data-step={step}>
<header className="onboarding-r6-head">
<div>
<p>{eyebrow}</p>
{eyebrow ? <p>{eyebrow}</p> : null}
<h1
ref={titleRef}
tabIndex={focusToken === undefined ? undefined : -1}
+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>
+1
View File
@@ -169,6 +169,7 @@ it("shows the help and diagnostics group by default and hides it when opted out"
await user.click(screen.getByRole("button", { name: "帮助与诊断" }));
expect(screen.getByRole("heading", { name: "帮助与诊断" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "导出日志" })).toBeTruthy();
expect(screen.getByText(/可能包含会话正文和本机路径/)).toBeTruthy();
expect(screen.getByRole("heading", { name: "关于" })).toBeTruthy();
expect(screen.getByText("1.0.4")).toBeTruthy();
expect(screen.getByText("8")).toBeTruthy();
+1 -1
View File
@@ -9,7 +9,7 @@ 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-choice-list",
".r6-memory-mode-settings .memory-mode-cards",
".r6-settings .connection-fallback .custom-harness-name",
]) {
const rule = declaration("r6-settings.css", selector);
@@ -29,6 +29,8 @@ it("shows every role and preserves a long original message in the detail pane",
messages: [
{ role: "user", content: long },
{ role: "assistant", content: "已收到" },
{ role: "tool", content: "3 tests passed" },
{ role: "unknown", content: "来源角色缺失" },
],
}}
sources={[]}
@@ -38,6 +40,10 @@ it("shows every role and preserves a long original message in the detail pane",
expect(screen.getByText("你")).toBeTruthy();
expect(screen.getByText("助手")).toBeTruthy();
expect(screen.getByText("工具")).toBeTruthy();
expect(screen.getByText("作者待核对")).toBeTruthy();
expect(screen.getByText(long)).toHaveProperty("textContent", long);
expect(screen.getByText("已收到")).toBeTruthy();
expect(screen.queryByText("原始对话")).toBeNull();
expect(screen.getByRole("article", { name: "原始对话" })).toBeTruthy();
});
+2 -2
View File
@@ -14,6 +14,7 @@ function authorLabel(role: string, t: ReturnType<typeof useGuiLanguage>["t"]) {
if (role === "user") return t("selectedMemory.author.user");
if (role === "assistant" || role === "agent")
return t("selectedMemory.author.agent");
if (role === "tool") return t("selectedMemory.author.tool");
return t("selectedMemory.author.unknown");
}
@@ -46,8 +47,7 @@ export function SelectedRawDetail({
record.source || t("selectedMemory.sourceUnknown"),
);
return (
<article className="map-dialogue-reading">
<p className="selected-memory-status">{t("memory:原始对话")}</p>
<article className="map-dialogue-reading" aria-label={t("memory:原始对话")}>
<ul className="map-transcript">
{messages.map((message) => (
<li key={`${rawRecordId(record)}:${message.role}:${message.content}`}>
+265
View File
@@ -0,0 +1,265 @@
import { Check, ChevronDown, Clock3 } from "lucide-react";
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Button } from "./R6Controls";
const HOURS = Array.from({ length: 24 }, (_, value) =>
value.toString().padStart(2, "0"),
);
const MINUTES = Array.from({ length: 60 }, (_, value) =>
value.toString().padStart(2, "0"),
);
function splitTime(value: string): [string, string] {
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value);
return match ? [match[1], match[2]] : ["00", "00"];
}
export function TimePicker({
id,
label,
value,
disabled = false,
onChange,
}: {
id: string;
label: string;
value: string;
disabled?: boolean;
onChange(value: string): Promise<boolean>;
}) {
const { locale, text } = useOnboardingTranslation();
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [placement, setPlacement] = useState<"above" | "below">("above");
const [hour, setHour] = useState(() => splitTime(value)[0]);
const [minute, setMinute] = useState(() => splitTime(value)[1]);
const root = useRef<HTMLDivElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
const panel = useRef<HTMLElement>(null);
const hourSelect = useRef<HTMLSelectElement>(null);
const minuteSelect = useRef<HTMLSelectElement>(null);
const restoreTriggerFocus = useRef(false);
const restoreSaveFocus = useRef(false);
const savingRef = useRef(false);
savingRef.current = saving;
const panelId = `${id}-picker`;
const close = useCallback((restoreFocus = true) => {
setOpen(false);
restoreTriggerFocus.current = restoreFocus;
}, []);
function show() {
const [nextHour, nextMinute] = splitTime(value);
setHour(nextHour);
setMinute(nextMinute);
setOpen(true);
}
useEffect(() => {
if (!open) return;
const closeOutside = (event: MouseEvent) => {
if (savingRef.current) return;
const target = event.target as Element | null;
const label = target?.closest("label");
if (label instanceof HTMLLabelElement && label.htmlFor === id) return;
if (!root.current?.contains(target)) close(false);
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== "Escape" || savingRef.current) return;
event.preventDefault();
close();
};
document.addEventListener("mousedown", closeOutside);
document.addEventListener("keydown", closeOnEscape);
hourSelect.current?.focus();
hourSelect.current?.selectedOptions[0]?.scrollIntoView?.({
block: "nearest",
});
minuteSelect.current?.selectedOptions[0]?.scrollIntoView?.({
block: "nearest",
});
return () => {
document.removeEventListener("mousedown", closeOutside);
document.removeEventListener("keydown", closeOnEscape);
};
}, [close, id, open]);
useEffect(() => {
if (disabled && open && !saving) close(false);
if (!disabled && !open && restoreTriggerFocus.current) {
restoreTriggerFocus.current = false;
trigger.current?.focus();
}
if (!disabled && open && restoreSaveFocus.current) {
restoreSaveFocus.current = false;
panel.current
?.querySelector<HTMLButtonElement>("[data-time-picker-save]")
?.focus();
}
if (saving && open) panel.current?.focus();
}, [close, disabled, open, saving]);
useLayoutEffect(() => {
if (!open) return;
const place = () => {
const anchor = trigger.current?.getBoundingClientRect();
const picker = panel.current?.getBoundingClientRect();
if (!anchor || !picker) return;
const scroller = trigger.current?.closest(
".app-content, .onboarding-frame",
);
const bounds = scroller?.getBoundingClientRect();
const topEdge = (bounds ? Math.max(0, bounds.top) : 0) + 12;
const bottomEdge =
(bounds
? Math.min(window.innerHeight, bounds.bottom)
: window.innerHeight) - 12;
const above = anchor.top - topEdge;
const below = bottomEdge - anchor.bottom;
setPlacement(
above >= picker.height || above >= below ? "above" : "below",
);
};
place();
window.addEventListener("resize", place);
window.addEventListener("scroll", place, true);
return () => {
window.removeEventListener("resize", place);
window.removeEventListener("scroll", place, true);
};
}, [open]);
async function save() {
const next = `${hour}:${minute}`;
if (next === value) {
close();
return;
}
setSaving(true);
const saved = await onChange(next);
setSaving(false);
if (saved) close();
else restoreSaveFocus.current = true;
}
return (
<div className="r6-time-picker" ref={root}>
<button
ref={trigger}
id={id}
type="button"
className="r6-time-picker-trigger"
aria-label={
locale.startsWith("zh") ? `${label},${value}` : `${label}: ${value}`
}
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={panelId}
disabled={disabled}
onClick={() => (open ? close() : show())}
>
<span>{value}</span>
<span className="r6-time-picker-icons" aria-hidden="true">
<Clock3 size={15} />
<ChevronDown size={14} />
</span>
</button>
{open ? (
<section
ref={panel}
id={panelId}
className="r6-time-picker-panel"
role="dialog"
aria-modal="true"
aria-label={label}
tabIndex={-1}
data-placement={placement}
onKeyDown={(event) => {
if (event.key !== "Tab") return;
const controls = Array.from(
event.currentTarget.querySelectorAll<HTMLElement>(
"select:not(:disabled), button:not(:disabled)",
),
);
const first = controls[0];
const last = controls.at(-1);
if (!first || !last) {
event.preventDefault();
event.currentTarget.focus();
return;
}
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}}
>
<div className="r6-time-picker-columns">
<label className="r6-time-picker-column">
<span aria-hidden="true">HH</span>
<select
ref={hourSelect}
size={6}
aria-label={`${label} HH`}
value={hour}
disabled={saving}
onChange={(event) => setHour(event.target.value)}
>
{HOURS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<span className="r6-time-picker-separator" aria-hidden="true">
:
</span>
<label className="r6-time-picker-column">
<span aria-hidden="true">MM</span>
<select
ref={minuteSelect}
size={6}
aria-label={`${label} MM`}
value={minute}
disabled={saving}
onChange={(event) => setMinute(event.target.value)}
>
{MINUTES.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
</div>
<footer>
<R6Button disabled={saving} onClick={() => close()}>
{text("取消")}
</R6Button>
<R6Button
variant="primary"
pending={saving}
data-time-picker-save
onClick={() => void save()}
>
<Check size={15} aria-hidden="true" />
{text("保存修改")}
</R6Button>
</footer>
</section>
) : null}
</div>
);
}
@@ -0,0 +1,63 @@
import type { EvolveSchedule } from "@one-memory/contracts";
import { useId } from "react";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Switch } from "./R6Controls";
import { TimePicker } from "./TimePicker";
export function UltraEvolveScheduleFields({
schedule,
headingId,
disabled = false,
onEnabledChange,
onTimeChange,
}: {
schedule: EvolveSchedule;
headingId?: string;
disabled?: boolean;
onEnabledChange(enabled: boolean): void;
onTimeChange(time: string): Promise<boolean>;
}) {
const { text } = useOnboardingTranslation();
const id = useId();
return (
<>
<div className="r6-evolve-heading">
<h3 id={headingId}>{text("记忆整合(Evolve)")}</h3>
<p>{text("定期重新整理已有记忆,补充更稳定的关联和结论。")}</p>
</div>
<div className="r6-evolve-setting">
<div className="r6-evolve-setting-copy">
<h3>{text("每天自动整合")}</h3>
<p>
{text(
schedule.enabled
? "每天 {time} 左右自动整合,使用这台设备的本地时间;休眠错过后会补跑。"
: "已关闭。重新开启后仍按 {time} 运行;已生成的结果会保留。",
).replace("{time}", schedule.time)}
</p>
</div>
<R6Switch
label={text("每天自动整合")}
checked={schedule.enabled}
disabled={disabled}
onCheckedChange={onEnabledChange}
/>
{schedule.enabled ? (
<div className="r6-evolve-time">
<div>
<label htmlFor={`${id}-time`}>{text("执行时间")}</label>
<p>{text("使用这台设备的本地时间。")}</p>
</div>
<TimePicker
id={`${id}-time`}
label={text("执行时间")}
value={schedule.time}
disabled={disabled}
onChange={onTimeChange}
/>
</div>
) : null}
</div>
</>
);
}
+42 -2
View File
@@ -14,7 +14,7 @@ vi.mock("axios", () => ({
},
}));
import { getSdkRaw, searchSdkRaw } from "./api";
import { getSdkRaw, getSdkRawGraph, searchSdkRaw } from "./api";
beforeEach(() => vi.clearAllMocks());
@@ -33,13 +33,15 @@ it("lists raw conversations for the selected application", async () => {
offset: 20,
limit: 20,
unit: "turn",
paging: undefined,
cursor: undefined,
},
signal,
});
});
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 });
@@ -57,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,
});
});
+34 -1
View File
@@ -45,6 +45,7 @@ import {
type SdkMemoryStatus,
type SdkMemoryVariant,
type SdkRawContextItem,
type SdkRawGraphPage,
type SdkRawPage,
type SourceDirectoryCandidate,
type SourceImportReceipt,
@@ -987,6 +988,8 @@ export async function getSdkRaw({
offset = 0,
limit = 100,
unit = "turn",
paging,
cursor,
signal,
}: {
source?: string;
@@ -994,10 +997,38 @@ export async function getSdkRaw({
offset?: number;
limit?: number;
unit?: "raw" | "turn";
paging?: "offset" | "cursor";
cursor?: string | null;
signal?: AbortSignal;
} = {}): Promise<SdkRawPage> {
const response = await client.get<SdkRawPage>("/api/v2/raw", {
params: { source, session_id: sessionId, offset, limit, unit },
params: {
source,
session_id: sessionId,
offset,
limit,
unit,
paging,
cursor: cursor || undefined,
},
signal,
});
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;
@@ -1019,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(
+31 -9
View File
@@ -42,6 +42,8 @@
margin-bottom: 24px;
}
.r6-home-overview {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 346px;
padding: 26px;
@@ -67,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;
@@ -74,6 +87,9 @@
min-height: 129px;
}
.r6-home-metrics > div {
display: grid;
grid-template-rows: 20px 72px minmax(20px, auto);
align-content: start;
min-width: 0;
padding: 4px 24px;
border-right: 1px solid #d5dfe8;
@@ -87,12 +103,16 @@
.r6-home-metrics p,
.r6-home-metrics small {
font-size: 12px;
line-height: 20px;
color: #63707b;
margin: 0;
}
.r6-home-metrics small {
display: block;
}
.r6-home-figure {
display: flex;
align-items: flex-end;
align-items: center;
gap: 6px;
margin: 0;
}
@@ -100,12 +120,10 @@
font-style: normal;
font-size: 12px;
line-height: 20px;
padding-bottom: 10px;
color: #405e75;
}
/* Holds the digit slot while the first summary read is in flight, so the 48px
type does not spend that frame rendering a word. Height matches the strong
below (48px x 1.3). */
type does not spend that frame rendering a word. */
.r6-home-figure-pending {
display: block;
width: 92px;
@@ -132,24 +150,25 @@
animation: none;
}
.r6-home-metrics strong {
display: block;
display: flex;
align-items: center;
font-size: 48px;
font-weight: 400;
line-height: 1.3;
line-height: 1;
color: #405e75;
font-variant-numeric: tabular-nums;
}
.r6-home-metrics strong.r6-home-word {
font-size: 32px;
padding: 14px 0;
line-height: 1.25;
overflow-wrap: anywhere;
}
.r6-home-metrics strong.r6-home-time {
font-size: 34px;
line-height: 1.85;
padding: 8px 0;
line-height: 1.25;
}
.r6-home-activity {
margin-top: auto;
border-top: 1px solid #d5dfe8;
padding-top: 12px;
}
@@ -278,7 +297,10 @@
white-space: normal;
}
.r6-home-activity small {
display: block;
margin-top: 4px;
font-size: 12px;
line-height: 18px;
color: #637786;
}
.r6-home-runtime {
+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;
}
+14 -14
View File
@@ -3,8 +3,8 @@
display: grid;
grid-template-columns: minmax(0, 738px) 278px;
gap: 30px;
min-height: 100%;
height: auto;
height: 560.2px;
min-height: 0;
}
.first-import-r6-main {
min-width: 0;
@@ -35,12 +35,13 @@
line-height: 20px;
}
.first-import-r6-main > .r6-import-progress {
flex: 0 0 auto;
flex: 0 0 218px;
}
.first-import-r6-feed {
flex: 0 0 auto;
min-height: 0;
flex: 1;
margin-top: 26px;
overflow: visible;
overflow: auto;
scrollbar-width: thin;
overscroll-behavior: contain;
}
@@ -224,8 +225,10 @@
margin: 6px 0;
}
.first-import-r6-footer-note {
flex: 1 1 240px;
flex: 1;
min-width: 0;
max-height: 64px;
overflow: auto;
}
.first-import-r6-footer-note p {
margin: 0;
@@ -242,15 +245,12 @@
}
.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;
}
.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;
@@ -403,18 +403,18 @@ dialog.first-import-r6-original[open] {
}
@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;
}
.first-import-r6-layout {
height: auto;
height: 100%;
}
}
@media (min-width: 761px) and (max-height: 859px) {
.first-import-r6-options {
padding: 14px;
overflow: visible;
overflow: hidden;
}
.first-import-r6-eyebrow {
margin-bottom: 4px;
+498
View File
@@ -0,0 +1,498 @@
.memory-mode-cards {
position: relative;
flex: 1;
min-width: 0;
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: 128px;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid #c7d3dc;
border-radius: 15px;
background: rgb(255 255 255 / 66%);
color: #304757;
box-shadow: inset 0 1px rgb(255 255 255 / 82%);
transition:
border-color 140ms ease-out,
background-color 140ms ease-out,
box-shadow 140ms ease-out,
transform 140ms ease-out;
}
.memory-mode-card:has(.memory-mode-card-choice:hover):not(
[data-disabled="true"]
) {
border-color: #91a7b6;
background: rgb(255 255 255 / 82%);
transform: translateY(-1px);
}
.memory-mode-card[data-selected="true"] {
border: 2px solid #6f9183;
background: #fbfdfc;
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 {
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;
}
.memory-mode-card[data-disabled="true"] .memory-mode-card-choice {
cursor: default;
}
.memory-mode-card-choice > input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.memory-mode-card:has(input:focus-visible) {
outline: 2px solid var(--r6-focus, #557c93);
outline-offset: 3px;
}
.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 {
flex: 0 0 auto;
color: var(--ok, #2f6b57);
font-size: 11px;
font-variant-numeric: tabular-nums;
letter-spacing: 0.06em;
}
.memory-mode-title strong {
min-width: 0;
color: #263947;
font-size: 19px;
font-weight: 600;
line-height: 28px;
}
.memory-mode-tier {
flex: 0 0 auto;
color: var(--ok, #2f6b57);
font-size: 11px;
font-weight: 650;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.memory-mode-current {
flex: 0 0 auto;
padding: 1px 6px;
border-radius: 999px;
color: #526a60;
background: #edf4f0;
font-size: 10px;
line-height: 17px;
}
.memory-mode-radio {
position: absolute;
top: 18px;
right: 20px;
width: 21px;
height: 21px;
flex: 0 0 21px;
display: grid;
place-items: center;
border: 1px solid #8da0ae;
border-radius: 6px;
color: #fff;
background: rgb(255 255 255 / 82%);
}
.memory-mode-card[data-selected="true"] .memory-mode-radio {
border-color: var(--ok, #2f6b57);
background: var(--ok, #2f6b57);
}
.memory-mode-radio svg {
stroke: #fff;
stroke-width: 2;
}
.memory-mode-description {
grid-column: 1;
grid-row: 2;
align-self: start;
min-height: 0;
margin-top: 6px;
color: #526674;
font-size: 13px;
line-height: 22px;
text-wrap: pretty;
}
.memory-mode-features {
grid-column: 2;
grid-row: 1 / -1;
align-self: stretch;
margin-top: 0;
padding: 0 42px 0 24px;
display: grid;
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: 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 {
display: none;
}
.memory-mode-detail {
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: 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;
transform: translateY(-4px);
}
}
@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--grid {
gap: 14px;
}
.memory-mode-cards--grid .memory-mode-card {
min-height: 194px;
}
.memory-mode-cards--grid .memory-mode-card-choice {
min-height: 194px;
padding: 16px;
}
.memory-mode-cards--grid .memory-mode-title strong {
font-size: 17px;
line-height: 25px;
}
.memory-mode-cards--grid .memory-mode-description {
min-height: 36px;
margin-top: 8px;
font-size: 12px;
line-height: 20px;
}
.memory-mode-cards--grid .memory-mode-features {
padding-top: 10px;
gap: 6px;
font-size: 11px;
line-height: 18px;
}
.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--grid {
grid-template-columns: 1fr;
}
.memory-mode-cards--grid .memory-mode-card,
.memory-mode-cards--grid .memory-mode-card-choice {
min-height: 0;
}
.memory-mode-cards--grid .memory-mode-description {
min-height: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.memory-mode-card,
.memory-mode-detail {
animation: none;
transition: none;
}
}
+232
View File
@@ -0,0 +1,232 @@
import type { MemoryModePlan, MemoryPipelineMode } from "@one-memory/contracts";
import { useEffect, useRef } from "react";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Button } from "./R6Controls";
export const MODE_COPY: Record<
MemoryPipelineMode,
{ title: string; description: string }
> = {
lite: {
title: "基础记忆(lite)",
description: "保存原始对话 · 不调用生成模型 · 搜索原始会话",
},
pro: {
title: "智能记忆(pro)",
description: "沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆",
},
ultra: {
title: "深度记忆(ultra)",
description: "智能记忆 + 知识图谱 · 可自动 Evolve · 增强关联召回",
},
};
export const BLOCKER_COPY: Record<MemoryModePlan["blockers"][number], string> =
{
active_model_required: "使用 pro 或 ultra 前需要先配置并启用生成模型。",
embedding_maintenance_in_progress:
"正在检查或切换向量模型,完成后才能切换记忆模式。",
memory_lifecycle_busy: "记忆维护正在进行,完成后才能切换模式。",
graph_store_unavailable: "当前安装缺少知识图谱组件,无法开启 ultra。",
};
export function memoryModePlanRows(plan: MemoryModePlan) {
const rows = [
{
label: "新对话",
content:
plan.capabilities.content_plane === "raw"
? "之后只保存原始对话,不再生成新记忆。"
: plan.capabilities.graph_enabled
? "新对话会整理成记忆并写入知识图谱。"
: "新对话会由模型整理成记忆。",
},
{
label: "模型调用与费用",
content: plan.capabilities.generation_required
? plan.capabilities.evolve_available
? "会调用当前生成模型;自动整合可能产生额外费用。切换本身不会立即整合。"
: "会调用当前生成模型,可能产生服务商费用。"
: "不调用生成模型,不再产生这部分费用。",
},
{
label: "搜索范围",
content:
plan.capabilities.content_plane === "raw"
? "切换后搜索原始对话。"
: plan.capabilities.graph_enabled
? "搜索沉淀记忆,并用知识图谱补充关联。"
: "搜索沉淀后的记忆。",
},
];
if (plan.effects.includes("earlier_raw_not_extracted"))
rows.push({
label: "已有数据",
content: "已经处理完的旧对话不会补做;原始对话全部保留。",
});
if (plan.effects.includes("higher_mode_memories_hidden"))
rows.push({
label: "已有数据",
content:
plan.effective_target === "pro"
? "普通沉淀记忆仍可使用;深度记忆新增的更高层记忆和图谱数据继续保留,但在智能记忆下不显示、不参与召回。原始对话不会删除。"
: "已有记忆继续保留,但在基础记忆下不显示、不参与召回;原始对话不会删除。",
});
if (plan.effects.includes("existing_memories_added_to_graph"))
rows.push({
label: "知识图谱",
content: "已有记忆会在后台加入知识图谱,期间仍可使用。",
});
if (plan.effects.includes("evolve_stopped"))
rows.push({
label: "每天自动整合",
content: "每天自动整合会停止,已经生成的数据不会删除。",
});
if (plan.capabilities.evolve_available)
rows.push({
label: "切换后",
content: "可设置每天自动整合时间,也可以随时立即整合一次。",
});
return rows;
}
export function MemoryModeConfirmDialog({
mode,
plan,
busy,
error,
opener,
allowedBlockers = [],
onCancel,
onConfirm,
}: {
mode: MemoryPipelineMode;
plan: MemoryModePlan;
busy: boolean;
error?: string;
opener?: HTMLElement | null;
allowedBlockers?: readonly MemoryModePlan["blockers"][number][];
onCancel(): void;
onConfirm(): void;
}) {
const { text } = useOnboardingTranslation();
const panel = useRef<HTMLElement>(null);
const busyRef = useRef(busy);
const onCancelRef = useRef(onCancel);
busyRef.current = busy;
onCancelRef.current = onCancel;
const blocking = plan.blockers.filter(
(blocker) => !allowedBlockers.includes(blocker),
);
useEffect(() => {
const element = panel.current;
if (!element) return;
const inertSiblings = new Map<HTMLElement, boolean>();
let branch: HTMLElement = element.parentElement ?? element;
while (branch.parentElement) {
for (const sibling of branch.parentElement.children) {
if (!(sibling instanceof HTMLElement) || sibling === branch) continue;
inertSiblings.set(sibling, sibling.inert);
sibling.inert = true;
}
branch = branch.parentElement;
if (branch === document.body) break;
}
const controls = () =>
Array.from(
element.querySelectorAll<HTMLElement>("button:not(:disabled)"),
);
controls()[0]?.focus();
const handleKey = (event: KeyboardEvent) => {
if (event.key === "Escape" && !busyRef.current) {
event.preventDefault();
onCancelRef.current();
return;
}
if (event.key !== "Tab") return;
const items = controls();
const first = items[0];
const last = items.at(-1);
if (!first || !last) {
event.preventDefault();
element.focus();
} else if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
element.addEventListener("keydown", handleKey);
return () => {
element.removeEventListener("keydown", handleKey);
for (const [sibling, wasInert] of inertSiblings) sibling.inert = wasInert;
if (opener?.isConnected) opener.focus();
};
}, [opener]);
useEffect(() => {
if (busy) panel.current?.focus();
}, [busy]);
return (
<div className="r6-settings-dialog-layer" role="presentation">
<section
ref={panel}
className="r6-settings-dialog r6-mode-confirm-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="memory-mode-confirm-title"
tabIndex={-1}
>
<h2 id="memory-mode-confirm-title">
{text("切换到 {mode}?").replace(
"{mode}",
text(MODE_COPY[mode].title),
)}
</h2>
<dl className="r6-mode-confirm-effects">
{memoryModePlanRows(plan).map((row) => (
<div key={`${row.label}:${row.content}`}>
<dt>{text(row.label)}</dt>
<dd>{text(row.content)}</dd>
</div>
))}
</dl>
{plan.blockers.map((blocker) => (
<p
className={
blocking.includes(blocker)
? "r6-settings-error"
: "r6-settings-note"
}
role={blocking.includes(blocker) ? "alert" : "status"}
key={blocker}
>
{text(BLOCKER_COPY[blocker])}
</p>
))}
{error ? (
<p className="r6-settings-error" role="alert">
{text(error)}
</p>
) : null}
<footer>
<R6Button disabled={busy} onClick={onCancel}>
{text("取消")}
</R6Button>
<R6Button
variant="primary"
pending={busy}
disabled={blocking.length > 0}
onClick={onConfirm}
>
{text("切换模式")}
</R6Button>
</footer>
</section>
</div>
);
}
@@ -0,0 +1,79 @@
import type { ModelProbeResult, ModelProfile } from "@one-memory/contracts";
import { expect, it } from "vitest";
import { modelProfileReady, profileFromProbe } from "./model-profile-readiness";
const profile: ModelProfile = {
profile_id: "saved-1",
name: "Saved model",
provider_kind: "openai_compatible",
base_url: "https://models.example/v1",
model: "model-1",
embedding_model: null,
embedding_provider_kind: null,
embedding_base_url: null,
embedding_dimensions: null,
context_window: null,
disable_thinking: false,
has_api_key: true,
timeout_seconds: 60,
max_concurrency: 1,
revision: 1,
is_active: false,
created_at: "2026-09-14T00:00:00Z",
updated_at: "2026-09-14T00:00:00Z",
capabilities: {
endpoint_reachable: false,
model_available: false,
embedding_available: false,
structured_output: false,
},
};
function probe(
inputRevision: number | null | undefined = 1,
outputRevision = 2,
): ModelProbeResult {
return {
profile_id: profile.profile_id,
input_profile_revision: inputRevision,
profile_revision: outputRevision,
stages: [],
capabilities: {
endpoint_reachable: true,
model_available: true,
embedding_available: true,
structured_output: false,
},
};
}
it("accepts a measured one-revision bump before or after profile refresh", () => {
const receipt = probe();
const beforeRefresh = profileFromProbe(profile, receipt);
const afterRefresh = profileFromProbe({ ...profile, revision: 2 }, receipt);
expect(beforeRefresh).toMatchObject({ revision: 2 });
expect(afterRefresh).toMatchObject({ revision: 2 });
expect(modelProfileReady(beforeRefresh as ModelProfile, receipt)).toBe(true);
});
it("rejects a bump without input evidence, a skipped revision and another profile", () => {
expect(profileFromProbe(profile, probe(null, 2))).toBeNull();
expect(profileFromProbe(profile, probe(1, 3))).toBeNull();
expect(
profileFromProbe(profile, {
...probe(),
profile_id: "another-profile",
}),
).toBeNull();
});
it("keeps unavailable credentials from becoming ready", () => {
const receipt = probe();
const tested = profileFromProbe(
{ ...profile, credential_state: "unavailable" },
receipt,
);
expect(tested).not.toBeNull();
expect(modelProfileReady(tested as ModelProfile, receipt)).toBe(false);
});
@@ -0,0 +1,48 @@
import type { ModelProbeResult, ModelProfile } from "@one-memory/contracts";
export function profileFromProbe(
profile: ModelProfile,
probe: ModelProbeResult | null | undefined,
): ModelProfile | null {
if (!probe || probe.profile_id !== profile.profile_id) return null;
const inputRevision = probe.input_profile_revision;
if (!Number.isSafeInteger(probe.profile_revision)) return null;
if (inputRevision === undefined || inputRevision === null) {
if (probe.profile_revision !== profile.revision) return null;
} else {
if (
!Number.isSafeInteger(inputRevision) ||
(probe.profile_revision !== inputRevision &&
probe.profile_revision !== inputRevision + 1) ||
(profile.revision !== inputRevision &&
profile.revision !== probe.profile_revision)
)
return null;
}
return {
...profile,
revision: probe.profile_revision,
capabilities: probe.capabilities,
};
}
export function modelProfileReady(
profile: ModelProfile,
probe: ModelProbeResult | null = null,
error: string | null = null,
): boolean {
return (
!error &&
(!probe ||
(probe.profile_id === profile.profile_id &&
probe.profile_revision === profile.revision &&
probe.capabilities.endpoint_reachable &&
probe.capabilities.model_available &&
probe.capabilities.embedding_available)) &&
profile.credential_state !== "unavailable" &&
!(profile.has_configured_api_key && !profile.has_api_key) &&
profile.capabilities.endpoint_reachable &&
profile.capabilities.model_available &&
profile.capabilities.embedding_available
);
}
+110 -87
View File
@@ -588,49 +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(760px, 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;
gap: 20px;
padding: 24px;
border: 1px solid #d8e2e8;
border-radius: 14px;
background: #fff;
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,11 +636,8 @@
.onboarding-model-default-summary {
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;
@@ -655,100 +647,131 @@
font-size: 14px;
font-weight: 600;
}
.onboarding-model-disclosure {
display: grid;
gap: 6px;
}
.generation-technical-disclosure {
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;
}
.generation-model-error {
color: #a24646 !important;
.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;
}
@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 (max-width: 600px) {
.onboarding-model-layout--single-column-600 .onboarding-model-group {
padding: 16px;
border-radius: 10px;
@media (min-width: 761px) and (max-height: 959px) {
.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--single-column-600 .generation-technical-fields,
.onboarding-model-layout--single-column-600 .generation-runtime-fields {
.generation-technical-fields {
margin-top: 16px;
}
}
@media (max-width: 760px) {
.generation-technical-fields,
.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%;
gap: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
+142 -106
View File
@@ -115,14 +115,16 @@
}
.onboarding-r6-stage {
flex: 1;
min-height: 852px;
padding: 32px 24px 2px;
min-height: 0;
display: flex;
padding: 24px;
}
.onboarding-r6-shell {
min-height: 0;
width: 1120px;
max-width: 100%;
height: 820px;
height: 100%;
max-height: 820px;
margin: 0 auto;
display: flex;
flex-direction: column;
@@ -137,7 +139,7 @@
inset 0 -1px 0 #adc0d22e;
}
.onboarding-r6-head {
min-height: 123.7px;
min-height: 0;
flex: 0 0 auto;
display: flex;
flex-wrap: wrap;
@@ -195,6 +197,7 @@
background: #ffffff38;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 16px;
border-radius: 0 0 25px 25px;
}
@@ -222,29 +225,39 @@
overscroll-behavior: contain;
scrollbar-width: thin;
}
.onboarding-r6-body:has(> .onboarding-r6-mode) {
padding-block: 14px;
overflow-y: auto;
}
.onboarding-r6-mode {
width: 100%;
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-discovery {
height: 560.2px;
height: 100%;
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 738px) 278px;
gap: 30px;
}
.onboarding-r6-apps {
position: relative;
min-width: 0;
}
.onboarding-r6-intro {
margin: 0;
font-size: 13px;
line-height: 24.05px;
color: #5a6169;
}
.onboarding-r6-intro p {
margin: 0;
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 12px;
}
.onboarding-r6-list-heading {
position: absolute;
inset: 96px 0 auto;
position: static;
height: 30px;
display: flex;
align-items: center;
@@ -277,8 +290,10 @@
cursor: default;
}
.onboarding-r6-list-area {
position: absolute;
inset: 142px 0 auto;
position: static;
min-height: 0;
display: flex;
flex-direction: column;
}
.onboarding-r6-list-status .onboarding-r6-quiet:disabled {
color: #4e6478;
@@ -288,7 +303,9 @@
padding: 0;
margin: 0;
list-style: none;
height: 330px;
min-height: 0;
height: auto;
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
@@ -451,44 +468,6 @@
content: "↓";
margin-right: 6px;
}
.onboarding-r6-import {
position: absolute;
top: 498px;
left: 0;
right: 0;
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
color: #6a7988;
}
/* 143:2085 puts 导入历史包 first and the "没有本机历史?" sentence after it; the
markup keeps the sentence first so it stays the accessible label order for
the control that follows. Reordering visually only, rather than moving the
DOM, keeps that reading order intact. */
.onboarding-r6-import > p {
order: 2;
}
.onboarding-r6-import > .history-import,
.onboarding-r6-import > .history-import-trigger {
order: 1;
}
.onboarding-r6-import > p {
margin: 0;
}
.onboarding-r6-import .history-import-trigger {
font: inherit;
border: 0;
border-radius: 0;
background: transparent;
color: #4e6478;
padding: 5px 11px;
min-height: 30px;
box-shadow: none;
}
.onboarding-r6-import .history-import-trigger > svg {
display: none;
}
.onboarding-r6-summary {
border-radius: 22px;
border: 1px solid #ffffffe0;
@@ -500,6 +479,8 @@
display: flex;
flex-direction: column;
gap: 11px;
align-self: stretch;
min-height: 0;
}
.onboarding-r6-summary p {
margin: 0;
@@ -572,45 +553,94 @@
border: 0;
}
.onboarding-r6-mode {
width: min(720px, 100%);
margin: 0 auto;
width: 100%;
margin: 0;
display: flex;
flex-direction: column;
gap: 20px;
gap: 16px;
}
.onboarding-r6-mode-intro {
text-align: center;
text-align: left;
}
.onboarding-r6-mode-intro h2 {
margin: 0;
color: #263947;
font-size: 22px;
line-height: 32px;
}
.onboarding-r6-mode-intro p {
margin: 0;
margin: 4px 0 0;
color: #637381;
font-size: 14px;
line-height: 22px;
}
.onboarding-r6-mode .memory-mode-choice-list {
background: rgb(255 255 255 / 72%);
.onboarding-r6-ultra-default {
display: grid;
gap: 2px;
}
.onboarding-r6-mode .memory-mode-choice {
min-height: 82px;
padding: 15px 18px;
.onboarding-r6-ultra-default strong {
color: #304757;
font-size: 12px;
font-weight: 600;
}
.onboarding-r6-mode .memory-mode-choice-copy {
gap: 4px;
.onboarding-r6-ultra-default small {
color: #607482;
font-size: 11px;
line-height: 18px;
}
.onboarding-r6-mode .memory-mode-choice-copy strong {
font-size: 16px;
line-height: 25px;
}
.onboarding-r6-mode .memory-mode-choice-copy small {
font-size: 13px;
line-height: 22px;
}
.onboarding-r6-mode-ultra-note {
margin: -6px 0 0;
color: #637381;
.onboarding-r6-schedule-loading {
margin: 0;
color: #607482;
font-size: 12px;
line-height: 20px;
text-align: center;
}
.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;
border: 1px solid #cfdae2;
border-radius: 12px;
background: rgb(255 255 255 / 58%);
}
.onboarding-r6-evolve-settings .r6-evolve-heading {
margin-bottom: 2px;
}
.onboarding-r6-evolve-settings .r6-evolve-setting {
grid-template-columns: minmax(0, 1fr) auto;
padding-bottom: 12px;
border-bottom: 0;
}
.onboarding-r6-evolve-settings .r6-evolve-setting-copy {
min-width: 0;
}
.onboarding-r6-evolve-settings .r6-evolve-time {
grid-template-columns: minmax(0, 1fr) 180px;
}
.onboarding-r6-evolve-settings .r6-evolve-time label {
color: #304757;
font-size: 13px;
font-weight: 600;
line-height: 22px;
}
.onboarding-r6-mode .onboarding-r6-schedule-recovery {
color: #944242;
font-size: 12px;
line-height: 20px;
}
.onboarding-r6-mode .onboarding-r6-schedule-recovery p {
margin: 0;
}
.onboarding-r6-mode-error {
min-height: 20px;
@@ -620,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;
@@ -763,17 +811,11 @@
flex-direction: column;
}
.onboarding-r6-apps {
min-height: 590px;
min-height: 0;
}
.onboarding-r6-list-heading {
top: 110px;
}
.onboarding-r6-list-area {
top: 154px;
}
.onboarding-r6-import {
top: 552px;
flex-wrap: wrap;
.onboarding-r6-list {
height: min(330px, 45vh);
flex: none;
}
.onboarding-r6-row > label {
padding: 14px 10px;
@@ -794,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 {
@@ -815,20 +864,7 @@
}
.onboarding-r6-discovery > .onboarding-r6-apps {
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto 30px minmax(0, 1fr) auto;
gap: 12px;
}
.onboarding-r6-apps > .onboarding-r6-list-heading,
.onboarding-r6-apps > .onboarding-r6-list-area,
.onboarding-r6-apps > .onboarding-r6-import {
position: static;
min-height: 0;
}
.onboarding-r6-apps > .onboarding-r6-list-area {
display: flex;
flex-direction: column;
grid-template-rows: auto minmax(0, 1fr);
}
.onboarding-r6-list-area > .onboarding-r6-list {
min-height: 0;
+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;
+155 -7
View File
@@ -227,10 +227,27 @@
font-size: 13px;
line-height: 22px;
}
.r6-memory-mode-settings .memory-mode-choice-list {
.r6-memory-mode-settings .memory-mode-cards {
width: 100%;
max-width: none;
}
.r6-memory-mode-settings
.memory-mode-card[data-mode="ultra"][data-selected="true"] {
grid-column: 1 / -1;
}
.r6-memory-mode-settings
.memory-mode-card[data-mode="ultra"][data-selected="true"]
.memory-mode-card-choice {
min-height: 180px;
}
.r6-evolve-settings--in-card {
margin: 0;
padding: 0;
border-top: 0;
}
.r6-evolve-settings--in-card .r6-evolve-setting:last-child {
border-bottom: 0;
}
.r6-settings-note {
margin: 14px 0 0;
padding: 10px 12px;
@@ -295,7 +312,7 @@
padding-top: 16px;
border-top: 1px solid #e1e8ed;
}
.r6-settings .r6-evolve-time label {
.r6-settings .r6-evolve-time > div > label {
color: #304757;
font-size: 13px;
font-weight: 600;
@@ -304,20 +321,146 @@
.r6-evolve-time p {
margin-top: 1px;
}
.r6-evolve-settings input[type="time"] {
.r6-time-picker {
position: relative;
width: 180px;
}
.r6-time-picker-trigger {
box-sizing: border-box;
width: 180px;
min-height: 44px;
padding: 0 10px;
height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 12px 0 14px;
border: 1px solid #c6d2da;
border-radius: 8px;
color: #304757;
background: #fff;
font:
500 14px / 22px "Noto Sans SC",
"PingFang SC",
sans-serif;
font-variant-numeric: tabular-nums;
cursor: pointer;
transition:
border-color 140ms ease-out,
background-color 140ms ease-out,
box-shadow 140ms ease-out;
}
.r6-evolve-settings input[type="time"]:focus-visible {
.r6-time-picker-trigger:hover:not(:disabled) {
border-color: #9eafbb;
background: #f8fafb;
}
.r6-time-picker-trigger[aria-expanded="true"] {
border-color: #7892a6;
box-shadow: 0 0 0 3px rgb(85 124 147 / 12%);
}
.r6-time-picker-trigger:focus-visible {
outline: 2px solid var(--r6-focus);
outline-offset: 2px;
}
.r6-time-picker-trigger:disabled {
color: #71808b;
background: #edf0f3;
cursor: default;
}
.r6-time-picker-trigger:active:not(:disabled) {
transform: none;
}
.r6-time-picker-icons {
display: inline-flex;
align-items: center;
gap: 5px;
color: #607888;
}
.r6-time-picker-trigger[aria-expanded="true"]
.r6-time-picker-icons
svg:last-child {
transform: rotate(180deg);
}
.r6-time-picker-panel {
box-sizing: border-box;
position: absolute;
z-index: 1200;
right: 0;
bottom: calc(100% + 8px);
width: min(280px, calc(100vw - 32px));
padding: 14px;
border: 1px solid #c6d2da;
border-radius: 10px;
background: #fff;
box-shadow: 0 14px 36px rgb(48 71 87 / 16%);
}
.r6-time-picker-panel[data-placement="below"] {
top: calc(100% + 8px);
bottom: auto;
}
.r6-time-picker-columns {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: end;
gap: 8px;
}
.r6-time-picker-column {
display: grid;
gap: 6px;
color: #5e6e7a;
font-size: 11px;
font-weight: 600;
line-height: 18px;
text-align: center;
}
.r6-time-picker-columns select {
box-sizing: border-box;
width: 100%;
height: 174px;
padding: 4px;
border: 1px solid #d5dfe8;
border-radius: 7px;
color: #304757;
background: #f8fafb;
font:
500 14px / 24px "Noto Sans SC",
"PingFang SC",
sans-serif;
font-variant-numeric: tabular-nums;
scrollbar-width: thin;
scrollbar-color: #b6c3cc transparent;
}
.r6-time-picker-columns select:focus-visible {
outline: 2px solid var(--r6-focus);
outline-offset: 1px;
}
.r6-time-picker-columns option {
min-height: 28px;
padding: 5px 8px;
border-radius: 5px;
}
.r6-time-picker-columns option:checked {
color: #304757;
background: #dfe9ef;
}
.r6-time-picker-separator {
align-self: center;
margin-top: 22px;
color: #7892a6;
font-size: 18px;
font-weight: 600;
}
.r6-time-picker-panel footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #e1e8ed;
flex-wrap: wrap;
}
.r6-time-picker-panel footer .r6-button {
min-width: 68px;
}
.r6-state-recovery {
display: flex;
align-items: center;
@@ -1059,9 +1202,14 @@
.r6-evolve-time {
gap: 8px;
}
.r6-evolve-settings input[type="time"] {
.r6-time-picker,
.r6-time-picker-trigger {
width: min(220px, 100%);
}
.r6-time-picker-panel {
right: auto;
left: 0;
}
.r6-state-recovery {
align-items: flex-start;
flex-direction: column;
+87 -1
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;
@@ -72,6 +76,11 @@ function pageFor(
total: number,
offset: number,
limit: number,
cursor?: {
next_cursor?: string | null;
has_more?: boolean;
total_exact?: boolean;
},
): MemoryLibraryPage {
const sourceIds: Record<string, string[]> = {};
const sourceTime: Record<
@@ -97,6 +106,7 @@ function pageFor(
total,
offset,
limit,
...cursor,
source_ids_by_memory: sourceIds,
source_time_by_memory: sourceTime,
search_window: null,
@@ -107,13 +117,89 @@ function pageFor(
}
export function rawLibraryPage(page: SdkRawPage): MemoryLibraryPage {
return pageFor(page.items, page.total, page.offset, page.limit);
return pageFor(page.items, page.total, page.offset, page.limit, {
next_cursor: page.next_cursor,
has_more: page.has_more,
total_exact: page.total_exact,
});
}
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 {
@@ -129,7 +129,7 @@ function ColdIntentHarness() {
}
describe("useImportSources", () => {
it("queries an uncertain cold-start intent before trying another write", async () => {
it("recovers an uncertain cold-start write from its durable receipt immediately", async () => {
connectSources.mockRejectedValueOnce(new Error("response lost"));
getSourceConnection.mockImplementation(async (requestId: string) => ({
request_id: requestId,
@@ -142,11 +142,11 @@ describe("useImportSources", () => {
const button = screen.getByRole("button", { name: "start cold import" });
await waitFor(() => expect(button).toHaveProperty("disabled", false));
await userEvent.click(button);
await userEvent.click(button);
expect(connectSources).toHaveBeenCalledTimes(1);
await waitFor(() => expect(getSourceConnection).toHaveBeenCalledOnce());
const sent = connectSources.mock.calls[0][2];
expect(sent.sourceRoots).toEqual({ cursor: "root:v2:fixture" });
expect(getSourceConnection).toHaveBeenCalledWith(sent.requestId);
expect(connectSources).toHaveBeenCalledTimes(1);
});
it("translates a source error code without exposing a generic HTTP failure", async () => {
const reason = Object.assign(
@@ -434,6 +434,7 @@ describe("useImportSources", () => {
expect(stored).not.toContain("authorized");
expect(stored).not.toContain("~/.fixture");
first.unmount();
getSourceConnection.mockClear();
getHealth.mockResolvedValue(health(instance));
getSourceConnection.mockResolvedValue(receipt(requestId, ["ok"]));
const next = await controller();
@@ -639,7 +640,9 @@ describe("useImportSources", () => {
let currentSource = { ...source("ok"), grant_revision: 1 };
const saved = new Map<string, ConnectSourcesResponse>();
getSources.mockImplementation(async () => [currentSource]);
getSourceConnection.mockImplementation(async (id: string) => saved.get(id));
getSourceConnection
.mockRejectedValueOnce(new Error("receipt temporarily offline"))
.mockImplementation(async (id: string) => saved.get(id));
connectSources.mockImplementation(async (ids: string[], _, options) => {
saved.set(options.requestId, receipt(options.requestId, ids));
currentSource = { ...currentSource, authorized: true, grant_revision: 2 };
@@ -854,6 +857,7 @@ describe("useImportSources", () => {
it("recovers an uncertain failed-only retry after remount without replaying accepted sources", async () => {
const saved = new Map<string, ConnectSourcesResponse>();
let loseRetryReceiptOnce = true;
connectSources.mockImplementation(async (ids: string[], _, options) => {
const response =
ids.length === 2
@@ -863,7 +867,17 @@ describe("useImportSources", () => {
if (ids.length === 1) throw new Error("retry response lost");
return response;
});
getSourceConnection.mockImplementation(async (id: string) => saved.get(id));
getSourceConnection.mockImplementation(async (id: string) => {
const known = saved.get(id);
if (
loseRetryReceiptOnce &&
known?.accepted_source_ids.includes("failed")
) {
loseRetryReceiptOnce = false;
throw new Error("retry receipt temporarily offline");
}
return known;
});
const first = await controller();
const snapshot = [source("ok"), source("failed")];
await act(async () => {
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff. Show more