Compare commits

...
Author SHA1 Message Date
alvinfeiandClaude Opus 5.5 5fdaf6c280 fix(memhub): Lite search is plain RAG over raw
Lite context search, recall and the Host Bridge return every raw vector
hit above the floor, ranked by score, over active and shadow raws. The
turn view shows the turns containing the query, or else the hit's
closest turn, instead of dropping semantic hits. Pro and ultra raw
search stays active-only.

A new test runs the raw routes in a child interpreter against the
installed hy-memory wheel, and the route-test fake SDK now rejects
search kwargs the pinned release does not accept.

The lite raw-turn page projects only raws newly put on top instead of
re-listing the whole raw layer on every poll.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-04 19:32:12 +08:00
alvinfeiandClaude Opus 5.5 58f2fa884a build(memhub): pin hy-memory 2.0.14
2.0.14 accepts `statuses` on explicit L1_RAW search, which Core has
passed since #316. Covers the same files as the 2.0.13 pin (#285).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-04 19:32:11 +08:00
kristinhe1111 dd13c551c4 Merge pull request #317 from Tencent-Hunyuan/fix/settings-field-width
fix(web): expand settings controls to content width
2026-11-04 17:41:20 +08:00
kristinheandCursor e7d82ee5e8 fix(web): expand settings controls to content width
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 16:54:55 +08:00
kristinhe1111 63e009bf6c Merge pull request #307 from Tencent-Hunyuan/fix/lite-home-saving-copy
fix(memhub): keep home status copy compact
2026-11-04 16:26:27 +08:00
kristinhe1111 d4cfd1bb74 Merge pull request #309 from Tencent-Hunyuan/fix/memhub-durable-drain-liveness
fix(memhub): keep durable write drain alive
2026-11-04 16:26:08 +08:00
kristinhe1111 2822cbf368 Merge pull request #310 from Tencent-Hunyuan/fix/memhub-inline-model-config
fix(memhub): inline model configuration panels
2026-11-04 16:25:47 +08:00
kristinhe1111 8d23347e1a Merge pull request #312 from Tencent-Hunyuan/fix/memory-mode-confirm-copy
fix(memhub): clarify memory mode switch effects
2026-11-04 16:25:22 +08:00
kristinhe1111 8ae0d1889e Merge pull request #315 from Tencent-Hunyuan/fix/ultra-topic-hover-only
fix(memhub): make Ultra topic nodes hover-only
2026-11-04 16:24:58 +08:00
kristinhe1111 2479de22eb Merge pull request #316 from Tencent-Hunyuan/fix/memhub-lite-turn-view
fix(memhub): show Lite raw conversations by turn
2026-11-04 16:24:44 +08:00
kristinheandCursor 265a22d612 fix(memhub): show lite raw conversations by turn
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 16:10:57 +08:00
kristinheandCursor 93d0c64490 style(memhub): format graph topic changes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 16:10:30 +08:00
kristinheandCursor 1589ae31bc fix(memhub): make topic nodes hover-only
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 16:06:56 +08:00
kristinheandCursor 38ad2292d1 fix(memhub): clarify memory mode switch effects
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 15:58:03 +08:00
kristinheandCursor 413b9033f9 fix(memhub): inline model configuration panels
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 15:49:20 +08:00
kristinheandCursor 0953c5af35 fix(memhub): keep durable write drain alive
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 15:22:29 +08:00
kristinheandCursor 470ea4459a fix(memhub): keep home status copy compact
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 14:53:33 +08:00
kristinhe1111 b58e95fdae Merge pull request #305 from Tencent-Hunyuan/chore/memhub-build-40
chore(release): MemHub 1.0.6, build 40
2026-11-04 13:27:01 +08:00
kristinheandCursor 42b577f6f8 chore(release): bump MemHub build to 40
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 13:16:08 +08:00
kristinhe1111 2554fb3b96 Merge pull request #304 from Tencent-Hunyuan/feat/memhub-onboarding-mode-model
feat(memhub): 重构冷启动记忆模式与模型配置
2026-11-04 13:12:38 +08:00
kristinheandCursor e32dc987c2 feat(memhub): redesign onboarding mode setup
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 12:00:46 +08:00
kristinhe1111 598e83525e Merge pull request #298 from Tencent-Hunyuan/feat/memhub-mode-projection
feat(memhub): project home and settings by memory mode
2026-11-04 09:29:48 +08:00
kristinhe 01ace26d08 Merge remote-tracking branch 'origin/main' into feat/memhub-mode-projection
Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	memhub/apps/core/src/one_memory/api.py
#	memhub/apps/core/src/one_memory/sdk_write_processor.py
#	memhub/apps/core/tests/test_memory_mode_and_evolve.py
#	memhub/apps/web/src/ApplicationsHome.tsx
#	memhub/apps/web/src/MemorySpace.tsx
2026-11-04 03:17:38 +08:00
kristinheandCursor ba2f647a30 feat(memhub): project home by memory mode
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 02:58:32 +08:00
kristinhe1111 82d9514d62 Merge pull request #297 from Tencent-Hunyuan/fix/memhub-lite-raw-conversations
fix(memhub): preserve raw conversations in lite mode
2026-11-04 01:22:04 +08:00
kristinhe1111 369351719d Merge pull request #295 from Tencent-Hunyuan/fix/memhub-model-center-main
fix(memhub): land Model Center on main
2026-11-04 00:28:38 +08:00
kristinheandCursor 1429ba2d19 fix(memhub): close model center settings gaps
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 00:22:25 +08:00
kristinheandCursor 4e7d752980 feat(memhub): add lite manual raw input
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 00:05:36 +08:00
kristinheandCursor fb13a6fda2 fix(memhub): keep mode-specific memory views consistent
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 23:20:41 +08:00
kristinheandCursor 6ff8b3a3b4 fix(memhub): simplify topic node details
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 22:49:47 +08:00
kristinheandCursor 17441d368c fix(memhub): harden settings state recovery
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 22:39:47 +08:00
kristinheandCursor 89d29c9c41 feat(memhub): redesign memory mode settings
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 22:27:00 +08:00
kristinheandCursor 5271bddce3 fix(memhub): preserve raw conversations in lite mode
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 22:18:12 +08:00
kristinhe eac795b2d2 Merge remote-tracking branch 'origin/main' into fix/memhub-model-center-main 2026-11-03 20:55:54 +08:00
kristinhe1111 d938f1d5b2 Merge pull request #294 from Tencent-Hunyuan/chore/memhub-build-39
chore(release): MemHub 1.0.6, build 39
2026-11-03 20:05:08 +08:00
kristinheandCursor 8cee64c813 chore(release): bump MemHub build number to 39
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 19:59:27 +08:00
kristinhe1111 1ffcc9eb57 Merge pull request #292 from Tencent-Hunyuan/feat/memhub-mode-ui
feat(memhub): complete memory mode app lifecycle
2026-11-03 19:43:30 +08:00
kristinhe 23b74f91bc Merge remote-tracking branch 'origin/feat/memhub-mode-ui' into feat/memhub-model-center 2026-11-03 19:41:41 +08:00
kristinheandCursor b36452d110 fix(memhub): reset mode state and satisfy lint
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 19:36:42 +08:00
kristinhe a8b92d21e1 Merge remote-tracking branch 'origin/feat/memhub-mode-ui' into feat/memhub-model-center 2026-11-03 19:35:22 +08:00
kristinheandCursor e58a7c0d14 fix(memhub): polish mode selector and raw transitions
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 19:32:56 +08:00
kristinheandCursor 1663c9424e fix(memhub): align model center with R6 visuals
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 19:31:00 +08:00
kristinheandCursor fba9d8cf26 fix(memhub): align lite and mode settings UI
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 19:30:43 +08:00
kristinhe f754f8bc0f Merge remote-tracking branch 'origin/feat/memhub-mode-ui' into feat/memhub-model-center
Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	memhub/apps/web/src/OnboardingModelController.tsx
#	memhub/apps/web/src/R6Settings.tsx
2026-11-03 18:38:24 +08:00
kristinheandCursor 2155c4c773 feat(memhub): add safe model center
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 18:36:08 +08:00
kristinheandCursor 18e2fca34d fix(memhub): harden mode startup recovery and search retry
Close review follow-ups for graph backfill gates, onboarding resume,
settings refresh races, modal focus trapping, and raw search retry.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 18:01:18 +08:00
kristinheandCursor 88feb0adca feat(memhub): add memory mode app lifecycle
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 17:22:09 +08:00
feitianxiang d2e9cdaf06 Merge pull request #286 from Tencent-Hunyuan/chore/memhub-build-38
chore(release): MemHub 1.0.6, build 38
2026-11-02 21:47:36 +08:00
alvinfeiandClaude Opus 5.5 8277aaee51 chore(release): bump MemHub build number to 38
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 21:40:56 +08:00
feitianxiang 65550621e0 Merge pull request #285 from Tencent-Hunyuan/release/memhub-1.0.6-hy-2.0.13
chore(memhub): hy-memory 2.0.13; 1.0.6 changelog
2026-11-02 21:29:36 +08:00
alvinfeiandClaude Opus 5.5 1a67427bf3 Merge github/main (hy-memory 2.0.13 release) into release/memhub-1.0.6-hy-2.0.13
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 21:22:42 +08:00
feitianxiang 58f1477680 Merge pull request #282 from Tencent-Hunyuan/release/hy-memory-2.0.13
release: hy-memory 2.0.13
2026-11-02 21:20:29 +08:00
alvinfeiandClaude Opus 5.5 76c459e39a fix(memhub): a failed SDK write keeps the SDK's cause as its error code
hy-memory 2.0.13 reports a model failure as success: false with
agent_error_code / reconcile_error_code. The job's error_code now carries
that cause (hy_sdk_reconcile_empty_response, hy_sdk_llm_error, ...) instead
of a bare hy_sdk_write_failed; only upper-snake codes are kept, never a
message. Regression tests: a reconcile provider failure (401/503/timeout)
and a coding-path extractor failure wait for the provider and complete after
recovery; an empty or unparseable 200 reply is retried 3 times, then failed
with its cause.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 21:03:22 +08:00
alvinfeiandClaude Opus 5.5 b864e0bda6 chore(memhub): pin hy-memory 2.0.13; MemHub 1.0.6 changelog
Pin the Core dependency, lock, HY_MEMORY_VERSION, SBOM, notices, docs and
version guards to hy-memory 2.0.13. Move the MemHub entries from Unreleased
into the 1.0.6 section (build 37, on hy-memory 2.0.13) and update the v1.0.6
news lines.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 21:03:22 +08:00
184 changed files with 22718 additions and 2599 deletions

No files matched your search

+69 -56
View File
@@ -4,56 +4,24 @@ Hy Memory and MemHub are versioned and released independently.
## Unreleased
- MemHub multi-user hardening (follow-up to #250):
- Read responses name the user they were read for: `user_id` (the one
asked for, `local` by default) is now on `GET /api/v2/memory-library`,
`/memory-library/layers`, `/user-profile`, `/home-summary`,
`/memories/graph`, `/raw`, and `POST /api/v2/context/search`,
`/api/v1/recall`, `/api/v2/memories/ask`. `ask/stream` carries it on its
first (`recall`) event. The Host Bridge context validator accepts the new
field and still accepts a Core without it.
- `DELETE /api/v2/memories/{id}` answers 404 `memory_not_found` for another
user's memory and for an id that does not exist (the same response, so
existence is still not revealed), and also when the SDK finds nothing to
delete; 204 only when the caller's own memory was deleted. The App treats
that 404 as already gone (a double-click delete no longer shows an
error); `memhub memories delete` prints "未找到记忆 <id>(可能已删除)",
exits 0, and its JSON is `state: "committed"` with `already_gone: true`.
- Hermes never reads a `profiles/` directory at any depth (scan, detect and
live sync), so a root granted above `~/.hermes` cannot pick up another
user's profile. Connecting refuses a root that is the `profiles`
container itself (422 `hermes_root_is_profiles_container`) and a root
equal to, containing or inside another user's active Hermes grant (409
`hermes_root_overlaps_other_user`, symlinks resolved); `local` on
`~/.hermes` with another user on `~/.hermes/profiles/<name>` stays
allowed. The CLI explains both codes.
- `memhub sources connect --user-id` with an invalid id now says the user
id need not equal the Hermes profile name (e.g. `--user-id wife-work
--root ~/.hermes/profiles/wife:work`).
- MemHub's DeepSeek Harness app page can grant recall again (#230). The
"一键插入应用" switch used to be greyed out with `unsupported_host`, and a
profile installed from the CLI stayed `install-only` with no way to approve
it. The one switch now covers every detected profile
(`deepseek-harness:<profile>`): on installs each profile's MCP patch and
grants recall, off removes the patch and revokes the grant. The page lists
each profile with its state, and names any profile that failed while
keeping the others. While the switch is on, a profile created later is
installed and granted automatically, without restarting MemHub; the Runtime
also no longer fails every host read when it meets a profile created after
it started. The harness docs no longer describe an unmounted sources page.
- MemHub's `deepseek-harness` source imports session files whose names carry the
format generation: `session.v3.jsonl.zstd` and `session.v4.jsonl.zstd` are now
discovered alongside the original `session.jsonl[.zstd]`. Discovery accepts
any `session[.vN].jsonl[.zstd]` name and the header's `version` decides
whether the session is parsed (v0/v3/v4) or refused as `unsupported_schema`.
A refused file is reported and keeps no cursor, so the release that learns
its format reads it from the top.
v4 tool results, which are written as plain `text` blocks rather than
`tool-result` blocks, are read through the same clipping and credential
scrubbing path. Previously every versioned session was invisible: the import
job completed with `discovered 0` while the files sat on disk.
- MemHub App now exposes the complete memory-mode lifecycle. New installs
explicitly choose lite or pro during onboarding; pro completes only after a
generation model is active, while ultra remains an advanced Settings choice.
Settings shows mode effects, graph backfill, the default 03:00 evolve
schedule and manual evolve. The memory page shows original conversations in
lite and advanced memories in pro/ultra. Existing installs remain pro unless
the user changes them, and embedding maintenance blocks mode/evolve changes.
- MemHub pins `hy-memory==2.0.14`. Lite search is plain RAG over raw: the
App's lite search, CLI context search, `/api/v1/recall` and the Host Bridge
(`MemHub_recall`, context search) return every vector hit above the floor,
ranked by score, over active and shadow raws, so history pro already
processed is still found after switching to lite. The turn view no longer
drops a hit whose turns do not literally contain the query; it shows the
hit's closest turn instead. Pro and ultra raw search stays active-only.
Fixes every raw search failing in packaged builds since #316, which passed
`statuses` to an SDK (2.0.13) that did not accept it. The lite raw-turn
page also stops re-listing the whole raw layer on each poll when a live
session only adds new raws.
## Hy Memory 2.0.13 — 2026-11-02
@@ -126,14 +94,17 @@ Hy Memory and MemHub are versioned and released independently.
## MemHub 1.0.6 — 2026-11-02
Build 37, on `hy-memory==2.0.11`. Linux x64/arm64 Runtime and macOS arm64
Build 40, on `hy-memory==2.0.14`. Linux x64/arm64 Runtime and macOS arm64
App and Runtime.
- Interfaces in 11 languages: English, Simplified and Traditional Chinese,
Japanese, Spanish, German, Portuguese, French, Korean, Russian and
Indonesian. Choosing any language other than Simplified Chinese also sets
the SDK prompt language; Simplified Chinese keeps the automatic zh/en
detection.
Indonesian. Every interface language, Simplified Chinese included, sets the
SDK prompt language (#270). **User-visible:** zh-Hans users now get Chinese
prompts and Chinese memory text for every conversation, English ones
included; the App sends the language it displays, also when it follows the
system. Only an unset language (CLI/API installs, `memhub settings language
system`) keeps the SDK's zh/en detection.
- Memory modes: lite (no LLM, keeps raw conversations), pro (LLM extraction)
and ultra (pro plus the knowledge graph and a nightly evolve run). Set with
`GET`/`PUT /api/v1/settings/memory-mode` or `memhub settings mode`. Raw
@@ -146,10 +117,52 @@ App and Runtime.
embedding service is unavailable, search returns `embedding_unavailable`.
- Multi-user Runtime: memory APIs and the CLI take `user_id` (default
`local`); records are isolated per user; Hermes profiles can be connected as
separate users. The App keeps using `local`.
separate users. The App keeps using `local`. Hardening (#280): read
responses echo `user_id`; deleting another user's memory, or one that does
not exist, is 404 `memory_not_found` (the App and `memhub memories delete`
treat it as already gone); Hermes never reads a `profiles/` directory, and
a root that is the `profiles` container or overlaps another user's grant is
refused (`hermes_root_is_profiles_container`,
`hermes_root_overlaps_other_user`).
- Configurable SDK features through the CLI and API (`memhub settings
features`): graph rerank, episode recall, session episodes, episode
summaries, intention and few-shot. Defaults are unchanged.
summaries, intention and few-shot, and the intention dedup switch with its
threshold (`intention_dedup`, `intention_dedup_threshold`, #277). Defaults
are unchanged.
- Memory writes retry themselves (#278), each time under a new SDK request
id after checking whether the previous one landed; interrupted writes
re-run automatically. A model provider failure (401/403, 404, 408, 429,
5xx, timeout, unreachable) keeps the job pending in place without spending
a retry, and a per-profile circuit breaker paces the queue (5 s doubling to
180 s, one probe job at a time). Any other failure is retried after 30, 60
and 120 s, then `failed`. With `hy-memory==2.0.13` a model failure is no
longer a silent success, and a failed job's `error_code` names the SDK's
cause (`hy_sdk_reconcile_empty_response`, `hy_sdk_llm_error`, ...) rather
than `hy_sdk_write_failed`. **User-visible:** the manual 重新整理 button,
the "整理结果不确定" display and
`POST /api/v1/memory-processing/retry-unknown` are gone; a banner
("模型配置有问题……记忆暂停处理,已有 N 条在排队") with a button to the model
settings shows while a model problem pauses processing, and clears on
recovery.
- Queue, WAL and recall (#278): the WAL stays bounded (64 MiB journal limit,
idle `TRUNCATE` checkpoints); a disabled or revoked source's jobs are
`suspended` and resume when it is turned back on; SDK writes and recall
reads use separate thread pools, and recall has a total budget (504
`recall_timeout`, `broker_timeout` in clients) instead of starving behind
stuck writes.
- One Runtime per data dir (#273). App quit asks the service manager at quit
time: an enabled background service is never stopped, a disabled one has
every Runtime of the data dir stopped (only after an authenticated identity
check), and an unknown state stops nothing. At start the App adopts a
Runtime of the same build, replaces an older one, and silently adopts a
newer one. The tray quit label follows the current state.
- DeepSeek Harness: the app page's one switch installs and grants recall for
every detected profile, and keeps new profiles granted without a restart
(#230); versioned session files (`session.v3.jsonl.zstd`,
`session.v4.jsonl.zstd`) are imported, and v4 tool output goes through the
same clipping and scrubbing (#175).
- The packaged Runtime accepts only a full 40-character commit, lowercased; a
malformed `ONE_MEMORY_COMMIT` fails the build (#235, #236).
- Search and recall pass through the SDK's search parameters with server-side
score floors; the memory library filters by layer; `/api/v2/raw` returns
data again; v2 source and import routes; profile version history.
+1 -1
View File
@@ -33,7 +33,7 @@ English | [中文](README.zh.md)
## Latest News
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Open-sourced** quantized **FP8** (**[Hy-Memory-30B-A3B-FP8](https://huggingface.co/tencent/Hy-Memory-30B-A3B-FP8)**) and **GGUF** (**[Hy-Memory-30B-A3B-GGUF](https://huggingface.co/tencent/Hy-Memory-30B-A3B-GGUF)**) versions of **Hy-Memory-30B-A3B** on Hugging Face!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-11]**: 📦 **Released [MemHub v1.0.6](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.6)** with interfaces in 11 languages, memory modes (lite / pro / ultra with nightly evolve), safe embedding-model switching, multi-user support for the CLI and API, and more reliable upgrades, on **`hy-memory==2.0.11`**. See the release notes for details.
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-11]**: 📦 **Released [MemHub v1.0.6](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.6)** with interfaces in 11 languages, memory modes (lite / pro / ultra with nightly evolve), safe embedding-model switching, multi-user support for the CLI and API, memory writes that retry themselves and pause while the model provider is down, and more reliable upgrades, on **`hy-memory==2.0.14`**. See the release notes for details.
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **Released [MemHub v1.0.5](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.5)** with Chinese, English and Spanish interfaces, one-time pairing codes for more custom Harnesses, and smoother browsing, search, startup and recovery, on **`hy-memory==2.0.8`**. See the release notes for details.
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **Released [MemHub v1.0.4](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.4)** and **[memhub-cli 1.0.5](https://www.npmjs.com/package/memhub-cli/v/1.0.5)**, with a signed macOS desktop app and verified macOS/Linux Runtimes!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Released** **Hy Memory 2.0 (`hy-memory==2.0.4`)** on [PyPI](https://pypi.org/project/hy-memory/2.0.4/) and open-sourced the **Hy-Memory** monorepo together with **[MemHub](memhub/README.md)**!
+1 -1
View File
@@ -33,7 +33,7 @@
## 最新动态
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **模型开源** 在 Hugging Face 发布 **Hy-Memory-30B-A3B** 的 **FP8**(**[Hy-Memory-30B-A3B-FP8](https://huggingface.co/tencent/Hy-Memory-30B-A3B-FP8)**)与 **GGUF**(**[Hy-Memory-30B-A3B-GGUF](https://huggingface.co/tencent/Hy-Memory-30B-A3B-GGUF)**)量化版本模型!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-11]**: 📦 **正式发布 [MemHub v1.0.6](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.6)**:界面支持 11 种语言,新增记忆模式(lite / pro / ultra,ultra 每晚自动整理),可安全切换嵌入模型,CLI 和 API 支持多用户,升级更可靠;底层使用 **`hy-memory==2.0.11`**。详见发布说明。
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-11]**: 📦 **正式发布 [MemHub v1.0.6](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.6)**:界面支持 11 种语言,新增记忆模式(lite / pro / ultra,ultra 每晚自动整理),可安全切换嵌入模型,CLI 和 API 支持多用户,记忆写入自动重试、模型服务异常时暂停排队,升级更可靠;底层使用 **`hy-memory==2.0.14`**。详见发布说明。
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **正式发布 [MemHub v1.0.5](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.5)**:支持中文、英文和西班牙语界面,可通过一次性配对码连接更多自定义 Harness,并优化了记忆浏览、搜索、启动和异常恢复;底层使用 **`hy-memory==2.0.8`**。详见发布说明。
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **正式发布 [MemHub v1.0.4](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.4)** 与 **[memhub-cli 1.0.5](https://www.npmjs.com/package/memhub-cli/v/1.0.5)**,提供已签名的 macOS 桌面应用及经过验证的 macOS/Linux Runtime!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **正式发布** **Hy Memory 2.0 (`hy-memory==2.0.4`)**(已上线 [PyPI](https://pypi.org/project/hy-memory/2.0.4/)),并开源 **Hy-Memory** monorepo 与 **[MemHub](memhub/README.md)**!
+24 -5
View File
@@ -1864,6 +1864,7 @@ class HyMemoryClient:
branch: Optional[str] = None,
request_id: Optional[str] = None,
layers: Optional[List[Any]] = None,
statuses: Optional[List[Any]] = None,
embed_override: Optional[Dict[str, Any]] = None,
query_embedding: Optional[List[float]] = None,
rerank: bool = True,
@@ -1956,6 +1957,8 @@ class HyMemoryClient:
layers: 可选。pro/ultra 下只召回指定层级(字符串或
MemoryLayer),在召回阶段生效。lite 下忽略该参数并
强制只召回 L1_RAW。
statuses: 仅用于 L1_RAW 定向搜索,可显式包含 active / shadow。
非 raw 搜索或 scene="all" 传入时拒绝,避免静默忽略。
rerank: 默认 True(原行为)。False 时本次调用跳过全部重排阶段:
chat 路的本地 cross-encoder 重排(recall.local_rerank_enabled)
与记忆关系图重排(graph_rerank / episode 扩展),working/
@@ -1999,7 +2002,7 @@ class HyMemoryClient:
intention_limit=intention_limit, created_after=created_after,
reader=reader,
workspace_id=workspace_id, branch=branch,
request_id=request_id, layers=layers,
request_id=request_id, layers=layers, statuses=statuses,
embed_override=embed_override,
query_embedding=query_embedding,
rerank=rerank,
@@ -3387,12 +3390,14 @@ class HyMemoryClient:
request_id: str,
t0: float,
query_embedding: Optional[List[float]],
statuses: Optional[List[Any]],
) -> Dict[str, Any]:
"""Implement `search(..., layers=['l1_raw'])` without a second API."""
from .models.memory import MemoryLayer, MemoryStatus
embedding = query_embedding or await self._embed_service.embed(query)
fetch = min(max(limit * (10 if session_ids else 2), limit), 10_000)
status_filter = self._normalize_statuses(statuses) or [MemoryStatus.ACTIVE]
hits = await self._vector_store.search(
query_embedding=embedding,
user_ids=user_ids,
@@ -3402,7 +3407,7 @@ class HyMemoryClient:
layers=[MemoryLayer.L1_RAW],
limit=fetch,
score_threshold=min_score,
status_filter=[MemoryStatus.ACTIVE],
status_filter=status_filter,
only_latest=False,
created_after=created_after,
)
@@ -3420,7 +3425,7 @@ class HyMemoryClient:
"memory_id": row.pop("raw_id"),
**row,
"layer": MemoryLayer.L1_RAW.value,
"status": MemoryStatus.ACTIVE.value,
"status": node.status.value,
"score": round(float(hit.get("score", 0.0)), 4),
}
)
@@ -3864,6 +3869,7 @@ class HyMemoryClient:
branch: Optional[str] = None,
request_id: Optional[str] = None,
layers: Optional[List[Any]] = None,
statuses: Optional[List[Any]] = None,
embed_override: Optional[Dict[str, Any]] = None,
query_embedding: Optional[List[float]] = None,
rerank: bool = True,
@@ -3893,7 +3899,7 @@ class HyMemoryClient:
schema_min_score=schema_min_score, schema_limit=schema_limit,
intention_limit=intention_limit, created_after=created_after,
reader=reader, workspace_id=workspace_id, branch=branch,
request_id=request_id, layers=layers,
request_id=request_id, layers=layers, statuses=statuses,
query_embedding=query_embedding,
rerank=rerank,
)
@@ -3908,7 +3914,7 @@ class HyMemoryClient:
schema_min_score=schema_min_score, schema_limit=schema_limit,
intention_limit=intention_limit, created_after=created_after,
reader=reader, workspace_id=workspace_id, branch=branch,
request_id=request_id, layers=layers,
request_id=request_id, layers=layers, statuses=statuses,
query_embedding=query_embedding,
rerank=rerank,
)
@@ -3938,6 +3944,7 @@ class HyMemoryClient:
branch: Optional[str] = None,
request_id: Optional[str] = None,
layers: Optional[List[Any]] = None,
statuses: Optional[List[Any]] = None,
query_embedding: Optional[List[float]] = None,
rerank: bool = True,
) -> Dict[str, Any]:
@@ -4023,6 +4030,17 @@ class HyMemoryClient:
"Use single agent_id with multiple session_ids, or multiple agent_ids without session_ids."
)
if statuses is not None:
from .models.memory import MemoryLayer
requested_layers = self._normalize_layers(layers)
if scene_all or (
not lite_mode and requested_layers != [MemoryLayer.L1_RAW]
):
raise ValueError(
"statuses is supported only for explicit L1_RAW search"
)
if scene_all:
return await self._search_all_scenes(
query, queries=queries, search_mode=search_mode,
@@ -4358,6 +4376,7 @@ class HyMemoryClient:
request_id=request_id,
t0=t0,
query_embedding=query_embedding,
statuses=statuses,
)
read_req = ReadRequest(
+3 -3
View File
@@ -18,8 +18,8 @@ If you run into a problem, please open an Issue with the version, platform,repro
## 发行与平台合同
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.12`。MemHub 源码版本为 `1.0.6`
(build 37),GitHub 仓库为
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.14`。MemHub 源码版本为 `1.0.6`
(build 40),GitHub 仓库为
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
`memhub-cli`。当前代码架构见
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
@@ -118,7 +118,7 @@ MemHub 的 SQLite 数据默认位于 `~/.one-memory`,不在 npm 包或 Runtime
## Hy Memory
SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.12`。SDK 配置、权限边界及 search
SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.14`。SDK 配置、权限边界及 search
返回格式见 [`MEMORY.md`](MEMORY.md)。HY search 返回的 `content` 已包含 SDK 统一生成的
逻辑时间和演化上下文;调用方不应再次转换时间戳或重复拼接 `When`。
+1 -1
View File
@@ -2352,7 +2352,7 @@
{
"SPDXID": "SPDXRef-Package-hy-memory",
"name": "hy-memory",
"versionInfo": "2.0.12",
"versionInfo": "2.0.14",
"downloadLocation": "NOASSERTION",
"filesAnalyzed": false,
"licenseConcluded": "Apache-2.0",
+1 -1
View File
@@ -9,7 +9,7 @@ Per-package license texts are not reproduced here yet; the license
column is filled in as part of release review, and the SBOM records
`NOASSERTION` until then rather than guessing.
## hy-memory 2.0.12
## hy-memory 2.0.14
License: Apache-2.0
+3
View File
@@ -17,11 +17,14 @@ const READ_ONLY_PATHS = [
"/api/v1/model-profiles",
"/api/v1/source-grants",
"/api/v1/sources",
"/api/v2/home-projection",
"/api/v2/home-summary",
"/api/v2/raw",
"/api/v2/memory-library",
"/api/v2/user-profile",
"/api/v2/user-profile/versions",
"/api/v1/settings/memory-mode",
"/api/v1/settings/memory-mode/plan",
"/api/v1/settings/memory-features",
"/api/v1/settings/ui-language",
"/api/v2/evolve",
+175
View File
@@ -1,4 +1,5 @@
import type {
HomeProjectionV1,
ImportIssue,
ImportJob,
LiveSyncHealth,
@@ -26,6 +27,9 @@ export const MEMORY_STATUSES = [
const ID_MAX = 128;
const LABEL_MAX = 256;
const LIST_MAX = 500;
const USER_ID = /^[A-Za-z0-9._-]{1,64}$/;
const ISO_TIME =
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
const LIVE_SYNC_STATES = [
"idle",
"running",
@@ -406,6 +410,153 @@ export function validateHomeSummary(value: unknown): SdkHomeSummary | null {
return summary as unknown as SdkHomeSummary;
}
/** `GET /api/v2/home-projection`: the mode-aware home content plane. */
export function validateHomeProjection(
value: unknown,
): HomeProjectionV1 | null {
const projection = exactRecord(value, [
"projection_version",
"user_id",
"effective_mode",
"capabilities",
"visible_count",
"display_state",
"latest",
"observed_at",
]);
if (!projection) return null;
const mode = projection.effective_mode;
const capabilities = exactRecord(projection.capabilities, [
"content_plane",
"generation_required",
"graph_enabled",
"evolve_available",
]);
if (
projection.projection_version !== 1 ||
typeof projection.user_id !== "string" ||
!USER_ID.test(projection.user_id) ||
!oneOf(mode, ["lite", "pro", "ultra"] as const) ||
!capabilities ||
capabilities.content_plane !== (mode === "lite" ? "raw" : "memory") ||
capabilities.generation_required !== (mode !== "lite") ||
capabilities.graph_enabled !== (mode === "ultra") ||
capabilities.evolve_available !== (mode === "ultra") ||
count(projection.visible_count) === null ||
!oneOf(projection.display_state, [
"empty",
"excerpt",
"no_user_excerpt",
"no_displayable_memory",
] as const) ||
!isoTime(projection.observed_at) ||
!validProjectionLatest(projection.latest, mode)
) {
return null;
}
const visible = projection.visible_count as number;
const latest = projection.latest as Record<string, unknown> | null;
if (
(visible === 0 &&
(latest !== null || projection.display_state !== "empty")) ||
(visible > 0 && projection.display_state === "empty") ||
(latest === null &&
["excerpt", "no_user_excerpt"].includes(
projection.display_state as string,
)) ||
(latest?.type === "raw" &&
projection.display_state !== latest.display_state) ||
(latest?.type === "memory" && projection.display_state !== "excerpt")
) {
return null;
}
return projection as unknown as HomeProjectionV1;
}
function validProjectionLatest(
value: unknown,
mode: "lite" | "pro" | "ultra",
): boolean {
if (value === null) return true;
const base = [
"type",
"time",
"time_basis",
"excerpt",
"truncated",
"source_id",
];
const candidate = record(value);
if (!candidate) return false;
const keys =
candidate.type === "raw"
? [...base, "raw_id", "session_id", "display_state"]
: [...base, "memory_id", "scene", "kind", "variant"];
const latest = exactRecord(value, keys);
if (
!latest ||
!(latest.time === null || isoTime(latest.time)) ||
![null, "conversation", "import", "write", "update"].includes(
latest.time_basis as null | string,
) ||
text(latest.excerpt, 200) === null ||
typeof latest.truncated !== "boolean" ||
text(latest.source_id, ID_MAX) === null
) {
return false;
}
if (latest.type === "raw") {
return (
mode === "lite" &&
nonemptyText(latest.raw_id, ID_MAX) &&
nonemptyText(latest.session_id, LABEL_MAX) &&
oneOf(latest.display_state, ["excerpt", "no_user_excerpt"] as const) &&
(latest.display_state !== "no_user_excerpt" ||
(latest.excerpt === "" && latest.truncated === false))
);
}
return (
latest.type === "memory" &&
mode !== "lite" &&
nonemptyText(latest.memory_id, ID_MAX) &&
oneOf(latest.scene, ["chat", "working", "coding"] as const) &&
oneOf(latest.kind, ["chat", "experience"] as const) &&
[null, "working", "coding"].includes(latest.variant as null | string) &&
((latest.kind === "chat" &&
latest.scene === "chat" &&
latest.variant === null) ||
(latest.kind === "experience" &&
latest.scene === latest.variant &&
oneOf(latest.scene, ["working", "coding"] as const)))
);
}
/** The bounded `/api/v2/raw?limit=1` envelope used only for its real total. */
export function validateRawTotal(value: unknown): number | null {
const page = exactRecord(value, [
"user_id",
"items",
"total",
"offset",
"limit",
"time_filtered",
]);
if (
!page ||
typeof page.user_id !== "string" ||
!USER_ID.test(page.user_id) ||
!Array.isArray(page.items) ||
page.items.length > 1 ||
count(page.total) === null ||
page.offset !== 0 ||
page.limit !== 1 ||
page.time_filtered !== false
) {
return null;
}
return page.total as number;
}
function identifiers(value: unknown): string[] | null {
return list(value ?? [], (item) => text(item, ID_MAX));
}
@@ -430,6 +581,30 @@ function record(value: unknown): Record<string, unknown> | null {
: null;
}
function exactRecord(
value: unknown,
keys: readonly string[],
): Record<string, unknown> | null {
const item = record(value);
return item &&
Object.keys(item).length === keys.length &&
keys.every((key) => Object.hasOwn(item, key))
? item
: null;
}
function isoTime(value: unknown): value is string {
return (
typeof value === "string" &&
ISO_TIME.test(value) &&
Number.isFinite(Date.parse(value))
);
}
function nonemptyText(value: unknown, max: number): value is string {
return typeof value === "string" && value.length > 0 && value.length <= max;
}
function text(value: unknown, max: number): string | null {
return typeof value === "string" && value.length <= max ? value : null;
}
@@ -250,6 +250,20 @@ describe("models activate with an embedding change", () => {
code: "embedding_switch_required",
});
});
it("does not treat a domain 404 as an old-Core route miss", async () => {
transport.post.mockResolvedValue({
status: 404,
data: { detail: "embedding_switch_not_found" },
});
const result = await capturedRun(["models", "activate", PROFILE, "--json"]);
expect(result.code).toBe(1);
expect(
transport.post.mock.calls.some(([url]) =>
String(url).endsWith("/activate"),
),
).toBe(false);
});
});
describe("models switch and spaces", () => {
+6 -1
View File
@@ -9,6 +9,7 @@
* makes it live. The CLI shows the plan, asks, starts the job and follows it.
*/
import { isBareNotFound } from "@one-memory/operations";
import type { CoreReader } from "./core-client.js";
import type {
CoreDeleteOperations,
@@ -387,7 +388,11 @@ async function requestPlan(
}
if (outcome.state === "rejected") {
// A Core before the registry has no plan route: activation as before.
if (outcome.http_status === 404 && outcome.code === "http_404") {
if (
outcome.http_status === 404 &&
outcome.code === "http_404" &&
isBareNotFound(outcome.body)
) {
return { state: "unsupported" };
}
const plan = parsePlan(record(outcome.body)?.plan);
+11 -1
View File
@@ -161,6 +161,7 @@ const CORE = new Map<string, { status: number; data: unknown }>([
blocked_stages: [
{ kind: "extract", reason: "model_missing", count: 2 },
],
pipeline: { mode: "pro", configured_mode: "pro" },
},
},
],
@@ -202,7 +203,9 @@ const READ_ONLY_PATHS = [
"/api/v1/live-sync",
"/api/v1/memory-processing",
"/api/v1/model-profiles",
"/api/v2/home-projection",
"/api/v2/home-summary",
"/api/v2/raw",
"/api/v2/memory-library",
"/api/v2/memories/",
"/api/v2/user-profile",
@@ -257,6 +260,9 @@ describe("CLI-20 status summary", () => {
expect(report.sections.model).toMatchObject({ state: "ok", profiles: 1 });
expect(report.sections.memories).toMatchObject({
state: "ok",
content_plane: "memory",
visible_count: 42,
effective_mode: "pro",
memory_count: 42,
});
expect(report.summary.state).toBe("ok");
@@ -903,7 +909,10 @@ describe("CLI-21 memory reads", () => {
expect(isReadOnlyPath("/api/v2/memories/ask")).toBe(false);
expect(isReadOnlyPath("/api/v2/memories/m1")).toBe(true);
expect(isReadOnlyPath("/api/v2/memory-library")).toBe(true);
expect(isReadOnlyPath("/api/v2/home-projection")).toBe(true);
expect(isReadOnlyPath("/api/v2/home-summary")).toBe(true);
expect(isReadOnlyPath("/api/v2/raw")).toBe(true);
expect(isReadOnlyPath("/api/v1/settings/memory-mode/plan")).toBe(true);
// The retired v1 data plane is no longer a CLI read.
expect(isReadOnlyPath("/api/v1/memories/m1")).toBe(false);
expect(isReadOnlyPath("/api/v1/home-summary")).toBe(false);
@@ -1085,7 +1094,8 @@ describe("malformed success responses", () => {
it("refuses a model profile whose string fields are not strings", async () => {
transport.get.mockImplementation(async (url: string) => {
if (url !== "/api/v1/model-profiles") return CORE.get(url);
if (url !== "/api/v1/model-profiles")
return CORE.get(url) ?? { status: 404, data: { detail: "not_found" } };
return {
status: 200,
data: [
+137
View File
@@ -147,3 +147,140 @@ describe("status and doctor on an unfinished upgrade migration", () => {
expect(report.runtime.log_file).toBeUndefined();
});
});
describe("mode-aware status content projection", () => {
const runtime = {
state: "running" as const,
code: "runtime_verified",
host: "127.0.0.1",
port: 30001,
};
const processing = (mode: "lite" | "pro" | "ultra") => ({
pending: 0,
fresh_pending: 0,
backlog_pending: 0,
outside_window_pending: 0,
running: 0,
waiting_for_model: 0,
completed: 0,
failed: 0,
pipeline: { mode, configured_mode: mode },
});
const projection = {
projection_version: 1,
user_id: "local",
effective_mode: "lite",
capabilities: {
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
},
visible_count: 7,
display_state: "excerpt",
latest: {
type: "raw",
raw_id: "raw-1",
session_id: "session-1",
display_state: "excerpt",
time: null,
time_basis: null,
excerpt: "hello",
truncated: false,
source_id: "cursor",
},
observed_at: "2026-11-04T00:00:00Z",
};
it("uses the projection without reading either legacy count endpoint", async () => {
const calls: string[] = [];
const reader: CoreReader = async <T>(path: string) => {
calls.push(path);
const data =
path === "/api/v2/home-projection"
? projection
: path === "/api/v1/memory-processing"
? processing("lite")
: null;
return { state: "ok", data } as ReadOutcome<T>;
};
const report = await composeStatusReport({
dataDir: "/tmp/isolated",
runtime,
background: null,
reader,
});
expect(report.sections.memories).toEqual({
state: "ok",
content_plane: "raw",
visible_count: 7,
effective_mode: "lite",
raw_count: 7,
});
expect(calls).not.toContain("/api/v2/raw");
expect(calls).not.toContain("/api/v2/home-summary");
expect(renderStatusText(report, "MemHub")).toContain(
"原始对话:原始对话 7",
);
});
it("falls back to the real raw total only for an old Lite Core", async () => {
const reader: CoreReader = async <T>(path: string) => {
if (path === "/api/v2/home-projection")
return { state: "not_found", code: "http_404" } as ReadOutcome<T>;
if (path === "/api/v1/memory-processing")
return {
state: "ok",
data: processing("lite"),
} as ReadOutcome<T>;
if (path === "/api/v2/raw")
return {
state: "ok",
data: {
user_id: "local",
items: [],
total: 9,
offset: 0,
limit: 1,
time_filtered: false,
},
} as ReadOutcome<T>;
return { state: "unavailable", code: "x" } as ReadOutcome<T>;
};
const report = await composeStatusReport({
dataDir: "/tmp/isolated",
runtime,
background: null,
reader,
});
expect(report.sections.memories).toMatchObject({
state: "ok",
content_plane: "raw",
visible_count: 9,
raw_count: 9,
});
expect(report.sections.memories).not.toHaveProperty("memory_count");
});
it("rejects extra projection fields instead of falling back or leaking them", async () => {
const reader: CoreReader = async <T>(path: string) =>
({
state: "ok",
data:
path === "/api/v2/home-projection"
? { ...projection, secret: "must-not-leak" }
: processing("lite"),
}) as ReadOutcome<T>;
const report = await composeStatusReport({
dataDir: "/tmp/isolated",
runtime,
background: null,
reader,
});
expect(report.sections.memories).toEqual({
state: "invalid",
code: "response_invalid",
});
expect(JSON.stringify(report)).not.toContain("must-not-leak");
});
});
+86 -8
View File
@@ -1,4 +1,5 @@
import type {
HomeProjectionV1,
LiveSyncHealth,
MemoryProcessingSummary,
ModelProfile,
@@ -12,10 +13,12 @@ import { sdkMigrationLogPath } from "@one-memory/runtime-control";
import type { CoreReader, ReadOutcome } from "./core-client.js";
import {
type SdkHomeSummary,
validateHomeProjection,
validateHomeSummary,
validateLiveSync,
validateModelProfiles,
validateProcessing,
validateRawTotal,
validateSources,
} from "./core-responses.js";
import { describeEmbeddingSwitchSummary } from "./embedding-switch-flow.js";
@@ -211,12 +214,12 @@ export async function composeStatusReport(options: {
for (const name of SECTION_NAMES) sections[name] = { state, code };
} else {
const reader = options.reader;
const [sources, sync, processing, model, memories] = await Promise.all([
const [sources, sync, processing, model, projection] = await Promise.all([
reader<unknown>("/api/v1/sources"),
reader<unknown>("/api/v1/live-sync"),
reader<unknown>("/api/v1/memory-processing"),
reader<unknown>("/api/v1/model-profiles"),
reader<unknown>("/api/v2/home-summary"),
reader<unknown>("/api/v2/home-projection"),
]);
sections.sources = section(sources, validateSources, describeSources);
sections.sync = section(sync, validateLiveSync, describeSync);
@@ -228,11 +231,7 @@ export async function composeStatusReport(options: {
pipeline =
processing.state === "ok" ? describePipeline(processing.data) : null;
sections.model = section(model, validateModelProfiles, describeModel);
sections.memories = section(
memories,
validateHomeSummary,
describeMemories,
);
sections.memories = await memorySection(reader, projection, pipeline);
}
// A bridge we could not inspect is unknown, not broken and not fine.
const bridge: StatusDocument["bridge"] =
@@ -312,7 +311,9 @@ export function renderStatusText(
];
for (const name of SECTION_NAMES) {
lines.push(
`${SECTION_LABELS[name]}:${sectionLine(report.sections[name])}`,
`${sectionLabel(name, report.sections[name])}:${sectionLine(
report.sections[name],
)}`,
);
}
if (report.pipeline) lines.push(`记忆模式:${pipelineLine(report.pipeline)}`);
@@ -409,8 +410,17 @@ const SECTION_LINE_OMITTED = new Set([
"embedding_switch",
"llm_backoff_until",
"llm_backoff_error",
"content_plane",
"visible_count",
"effective_mode",
]);
function sectionLabel(name: string, section: Section | undefined): string {
return name === "memories" && section?.content_plane === "raw"
? "原始对话"
: SECTION_LABELS[name];
}
function sectionLine(section: Section | undefined): string {
if (!section) return "未检查";
if (section.state === "not_checked") return "未检查";
@@ -462,6 +472,10 @@ function fieldLabel(key: string): string {
profiles: "配置",
active: "当前",
memory_count: "记忆",
raw_count: "原始对话",
visible_count: "可见",
content_plane: "内容层",
effective_mode: "模式",
};
return labels[key] ?? key;
}
@@ -595,6 +609,70 @@ function describeMemories(summary: SdkHomeSummary): Record<string, unknown> {
};
}
async function memorySection(
reader: CoreReader,
projection: ReadOutcome<unknown>,
pipeline: PipelineSummary | null,
): Promise<Section> {
if (projection.state === "ok") {
const parsed = validateHomeProjection(projection.data);
return parsed === null
? { state: "invalid", code: "response_invalid" }
: { state: "ok", ...describeProjection(parsed) };
}
if (projection.state !== "not_found") {
return {
state: projection.state === "denied" ? "denied" : "unavailable",
code: projection.code,
};
}
if (pipeline === null) {
return { state: "unavailable", code: "effective_mode_unavailable" };
}
if (pipeline.mode === "lite") {
const raw = await reader<unknown>("/api/v2/raw", {
limit: 1,
offset: 0,
});
if (raw.state !== "ok") {
return {
state: raw.state === "denied" ? "denied" : "unavailable",
code: raw.code,
};
}
const total = validateRawTotal(raw.data);
return total === null
? { state: "invalid", code: "response_invalid" }
: {
state: "ok",
content_plane: "raw",
visible_count: total,
effective_mode: "lite",
raw_count: total,
};
}
const legacy = await reader<unknown>("/api/v2/home-summary");
return section(legacy, validateHomeSummary, (summary) => ({
content_plane: "memory",
visible_count: summary.memory_count,
effective_mode: pipeline.mode,
...describeMemories(summary),
}));
}
function describeProjection(
projection: HomeProjectionV1,
): Record<string, unknown> {
return {
content_plane: projection.capabilities.content_plane,
visible_count: projection.visible_count,
effective_mode: projection.effective_mode,
...(projection.capabilities.content_plane === "raw"
? { raw_count: projection.visible_count }
: { memory_count: projection.visible_count }),
};
}
function sum(
sources: SourceResponse[],
key: "imported_conversations" | "imported_messages",
+1 -1
View File
@@ -12,7 +12,7 @@ dependencies = [
"fastapi>=0.141.1",
"filelock>=3.32.4",
# Public SDK version validated against MemHub's permission and recall contracts.
"hy-memory==2.0.12",
"hy-memory==2.0.14",
# chromadb arrives through the HY SDK; locked here so an unrelated upgrade
# cannot move the SDK's store. Built-in embedding consumes numpy directly.
"chromadb==1.5.9",
+431 -74
View File
@@ -264,7 +264,13 @@ from one_memory.model_request_audit import (
ModelRequestAuditStore,
)
from one_memory.model_request_budget import BudgetedModelTransport, ModelRequestBudget
from one_memory.onboarding import OnboardingState, OnboardingStore
from one_memory.onboarding import (
OnboardingCompletedError,
OnboardingProgressUpdate,
OnboardingState,
OnboardingStore,
)
from one_memory.raw_turns import RawDisplayTurn, raw_display_turns, raw_prefix_added
from one_memory.recall import (
GovernedRecallTarget,
RecallReceipt,
@@ -323,6 +329,13 @@ from one_memory.ui_language import (
UiLanguage,
sdk_language,
)
from one_memory.ui_projection import (
HomeProjectionCapabilities,
HomeProjectionV1,
home_capabilities,
raw_list_filters,
read_home_projection,
)
from one_memory.user_scope import (
DEFAULT_USER_ID,
MULTI_USER_SOURCES,
@@ -592,24 +605,30 @@ class SdkRawItem(HySdkRawRecord):
role: str | None = None
content_hash: str
created: datetime | None = None
turn_id: str | None = Field(default=None, min_length=1, max_length=96)
turn_index: int | None = Field(default=None, ge=0)
turn_count: int | None = Field(default=None, ge=1)
@classmethod
def of(cls, record: HySdkRawRecord) -> "SdkRawItem":
messages = tuple(
SdkRawMessage(role=role, content=text)
for role, text in raw_turn_messages(record.content)
)
def of(cls, record: HySdkRawRecord, turn: RawDisplayTurn | None = None) -> "SdkRawItem":
content = turn.content if turn is not None else record.content
raw_messages = turn.messages if turn is not None else raw_turn_messages(record.content)
messages = tuple(SdkRawMessage(role=role, content=text) for role, text in raw_messages)
roles = {message.role for message in messages}
return cls(
**record.model_dump(),
**record.model_dump(exclude={"content"}),
content=content,
messages=messages,
role=next(iter(roles)) if len(roles) == 1 and "" not in roles else None,
content_hash="sha256:" + hashlib.sha256(record.content.encode("utf-8")).hexdigest(),
content_hash="sha256:" + hashlib.sha256(content.encode("utf-8")).hexdigest(),
created=(
datetime.fromtimestamp(record.gmt_created, tz=UTC)
if record.gmt_created is not None
else None
),
turn_id=turn.turn_id if turn is not None else None,
turn_index=turn.turn_index if turn is not None else None,
turn_count=turn.turn_count if turn is not None else None,
)
@@ -622,9 +641,9 @@ class SdkRawPage(BaseModel):
total: int = Field(ge=0)
offset: int = Field(ge=0)
limit: int = Field(ge=1, le=500)
#: Set when a time filter was applied to this page in Core (the SDK list
#: has none): `items` is the page's rows inside the range, `total` stays
#: the SDK's count before that filter.
unit: Literal["raw", "turn"] = "raw"
#: 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
@@ -640,11 +659,6 @@ class SdkLayerCounts(BaseModel):
statuses: tuple[str, ...] | None = None
#: Raw lifecycle states that are still a kept conversation. Pro marks a raw
#: SHADOW once extraction succeeds; lite leaves it ACTIVE.
RAW_STATUSES = ("active", "shadow")
class MemoryProcessingBlock(BaseModel):
kind: str
reason: str
@@ -740,6 +754,8 @@ class MemoryModeState(BaseModel):
mode: MemoryMode
#: The saved setting (default pro).
configured_mode: MemoryMode
#: True once a person chose a mode, including explicitly choosing pro.
explicitly_configured: bool
#: `ONE_MEMORY_GRAPH_STORE` (dev) forces ultra over the setting.
forced_by_env: bool
modes: tuple[MemoryMode, ...] = MEMORY_MODES
@@ -769,6 +785,29 @@ class MemoryModeChange(MemoryModeState):
effects: tuple[MemoryModeEffect, ...] = ()
MemoryModeBlocker = Literal[
"active_model_required",
"embedding_maintenance_in_progress",
"memory_lifecycle_busy",
"graph_store_unavailable",
]
MemoryModeWarning = Literal["forced_by_env", "effective_no_change"]
class MemoryModePlan(BaseModel):
"""Shared validation result used by plan and PUT."""
model_config = ConfigDict(extra="forbid")
current: MemoryMode
target: MemoryMode
effective_target: MemoryMode
capabilities: HomeProjectionCapabilities
effects: tuple[MemoryModeEffect, ...] = ()
warnings: tuple[MemoryModeWarning, ...] = ()
blockers: tuple[MemoryModeBlocker, ...] = ()
class EvolveScheduleUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -1440,10 +1479,15 @@ def create_app(
service=app.state.evolve_service,
schedule=lambda: _config_store(app).evolve_schedule(),
users_with_memories=lambda: _users_with_memories(app),
lifecycle_lock=app.state.memory_mode_lock,
lifecycle_open=lambda: (
_embedding_writes_open(app) and not app.state.memory_graph_backfill.running
),
)
app.state.memory_graph_backfill = MemoryGraphBackfill(
coordinator=app.state.sdk_write_coordinator,
state=app.state.evolve_state_store,
lifecycle_open=lambda: _embedding_writes_open(app),
)
# C2 owns the single production budget (handle 5s + reserve 250ms,
# total 5.25s, lease 10s, renew 1s, claim 250ms). The composition
@@ -1465,22 +1509,15 @@ def create_app(
async def admit_hy_invocation(
source_id: str,
requires_generation: bool,
mark_invoked: Callable[[], Awaitable[bool]],
) -> bool:
"""Serialize pause with the last boundary before an HY call."""
source_lock = app.state.source_permission_locks.setdefault(
source_id, asyncio.Lock()
return await _admit_pipeline_invocation(
app,
source_id,
requires_generation,
mark_invoked,
)
async with app.state.sedimentation_lock, source_lock:
if not _config_store(app).sedimentation_enabled():
raise RuntimeError("model_sedimentation_disabled")
if not _embedding_writes_open(app):
# Re-checked at the last boundary: a switch that began
# after the claim keeps this job queued, unsent.
raise RuntimeError("embedding_switch_in_progress")
if not _source_writes_on(app, source_id):
raise RuntimeError("model_content_source_disabled")
return await mark_invoked()
memory_write_drain = DurableWriteDrain(
memory_write_queue,
@@ -1529,7 +1566,7 @@ def create_app(
# therefore skipped on every drain tick — the note stayed
# "saved, waiting" with no task behind it, forever.
source_claim_enabled=lambda source_id: _sedimentation_claim_enabled(app, source_id),
model_claim_allowance=app.state.model_backoff.allowance,
model_claim_allowance=lambda: _model_claim_allowance(app),
)
app.state.memory_write_drain = memory_write_drain
@@ -1842,6 +1879,16 @@ def create_app(
name="one-memory-durable-write-drain",
)
async def resume_graph_backfill() -> None:
saved = app.state.evolve_state_store.graph_build()
if saved.state != "running":
return
if _memory_mode(app) != "ultra":
app.state.evolve_state_store.set_graph_build(GraphBuildState())
return
users = await asyncio.to_thread(_known_user_ids, app)
app.state.memory_graph_backfill.recover(users)
async def start_evolve_schedule() -> None:
# Idle outside ultra: each tick asks the mode first.
app.state.evolve_scheduler.start()
@@ -1930,7 +1977,14 @@ def create_app(
# served from the live space throughout.
if sdk:
await step("embedding_spaces", resume_embedding_spaces)
await step("graph_backfill", resume_graph_backfill)
await step("write_drain", start_write_drain)
if sdk and _memory_mode(app) == "lite":
# A previous pro run may have parked jobs while model
# sedimentation was off. Lite needs no generation model:
# readmit them in the background so startup can finish
# while raw conversations begin appearing.
_resume_sedimentation(app)
if sdk:
await step("evolve_schedule", start_evolve_schedule)
else:
@@ -2484,6 +2538,21 @@ def create_app(
def get_onboarding() -> OnboardingState:
return _onboarding_store(app).get()
@app.api_route(
"/api/v1/onboarding/progress",
methods=["PATCH", "PUT"],
response_model=OnboardingState,
dependencies=protected,
)
async def update_onboarding_progress(
update: OnboardingProgressUpdate,
) -> OnboardingState:
"""Persist only resumable onboarding choices; secrets are never accepted."""
try:
return await asyncio.to_thread(_onboarding_store(app).update_progress, update)
except OnboardingCompletedError as error:
raise HTTPException(status_code=409, detail=str(error)) from error
@app.post(
"/api/v1/onboarding/complete",
response_model=OnboardingState,
@@ -3829,6 +3898,30 @@ def create_app(
app.state.memory_task_store.pause_profile_for_model(profile_id)
return Response(status_code=204)
@app.get(
"/api/v2/home-projection",
response_model=HomeProjectionV1,
dependencies=strict,
)
async def home_projection(
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
) -> HomeProjectionV1:
"""The mode-neutral homepage read model owned by Core."""
try:
mode = _memory_mode(app)
handle = _sdk_client_handle(app)
if isinstance(handle, ActiveProfileReadHandle):
client = await handle.client_for_mode(mode)
else:
client = await handle.client()
return await read_home_projection(
client,
user_id=user_id,
mode=mode,
)
except Exception as error:
raise _sdk_http_error(error, "home_projection_read_failed") from None
@app.get(
"/api/v2/home-summary",
response_model=SdkHomeSummary,
@@ -3995,7 +4088,9 @@ def create_app(
client = await _sdk_client_handle(app).client()
def count(layer: str) -> int:
layer_statuses = chosen or (list(RAW_STATUSES) if layer == "l1_raw" else ["active"])
layer_statuses = chosen or (
raw_list_filters()["statuses"] if layer == "l1_raw" else ["active"]
)
raw = client.list_memories(
user_id=user_id,
layers=[layer],
@@ -4353,6 +4448,16 @@ def create_app(
raise _sdk_http_error(error, "hy_memory_delete_failed") from None
return Response(status_code=204)
raw_turn_cache: dict[
tuple[object, ...],
tuple[
int,
tuple[tuple[str, str | None], ...],
tuple[tuple[HySdkRawRecord, RawDisplayTurn], ...],
],
] = {}
raw_turn_cache_locks: dict[tuple[object, ...], asyncio.Lock] = {}
@app.get(
"/api/v2/raw",
response_model=SdkRawPage,
@@ -4362,6 +4467,7 @@ def create_app(
source: list[str] | None = Query(default=None), # noqa: B008
offset: int = Query(default=0, ge=0),
limit: int = Query(default=100, ge=1, le=500),
unit: Literal["raw", "turn"] = Query(default="raw"),
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
@@ -4375,38 +4481,175 @@ def create_app(
The SDK lists raw only when asked for `layers=["l1_raw"]`, and only
active rows unless told otherwise. Pro shadows a raw once extraction
succeeds, so a raw page reads active and shadow (`statuses` narrows
it). `source` and `session_id` (a conversation) may repeat or be comma
it). `unit=turn` projects storage batches into stable product turns;
the first read builds an append-only in-memory index and later polls
only probe the latest SDK page unless it changed. `source` and
`session_id` (a conversation) may repeat or be comma
separated. `after`/`before` (unix seconds, against the conversation
time, else the stored time) have no SDK list filter: they cut this
page in Core and mark it `time_filtered`.
"""
chosen_statuses = _library_values(
statuses or [], MEMORY_STATUSES, "memory_status_unknown"
) or list(RAW_STATUSES)
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)
try:
client = await _sdk_client_handle(app).client()
value = await asyncio.to_thread(
client.list_memories,
user_id=user_id,
layers=["l1_raw"],
statuses=chosen_statuses,
include_sources=sources or None,
exclude_sources=excluded or None,
session_ids=sessions or None,
offset=offset,
limit=limit,
order=order,
)
page = HySdkRawPage.from_memory_list(value, limit=limit, offset=offset)
query = {
"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": order if unit == "raw" else "desc",
}
if unit == "raw":
value = await asyncio.to_thread(
client.list_memories,
**query,
offset=offset,
limit=limit,
)
page = HySdkRawPage.from_memory_list(value, limit=limit, offset=offset)
records = page.raw
else:
cache_key = (
user_id,
tuple(sorted(chosen_statuses)),
tuple(sorted(sources)),
tuple(sorted(excluded)),
tuple(sorted(sessions)),
)
if len(raw_turn_cache_locks) > 64:
for stale_key, stale_lock in tuple(raw_turn_cache_locks.items()):
if stale_key not in raw_turn_cache and not stale_lock.locked():
raw_turn_cache_locks.pop(stale_key, None)
lock = raw_turn_cache_locks.setdefault(cache_key, asyncio.Lock())
async with lock:
for _attempt in range(3):
value = await asyncio.to_thread(
client.list_memories,
**query,
offset=0,
limit=500,
)
first_page = HySdkRawPage.from_memory_list(value, limit=500, offset=0)
signature = tuple(
(record.raw_id, record.status) for record in first_page.raw
)
cached = raw_turn_cache.get(cache_key)
if (
cached is not None
and cached[0] == first_page.total
and cached[1] == signature
):
projected_turns = cached[2]
break
added = (
None
if cached is None
else raw_prefix_added(cached[1], signature, cached[0], first_page.total)
)
if cached is not None and added is not None:
# A live session only puts new raws on top: project
# those and keep the rest, instead of re-listing the
# whole raw layer on every poll.
projected_turns = (
tuple(
(record, turn)
for record in first_page.raw[:added]
for turn in reversed(raw_display_turns(record))
)
+ cached[2]
)
raw_turn_cache[cache_key] = (
first_page.total,
signature,
projected_turns,
)
break
records_list = list(first_page.raw)
raw_offset = len(first_page.raw)
raw_total = first_page.total
while first_page.raw and raw_offset < raw_total:
value = await asyncio.to_thread(
client.list_memories,
**query,
offset=raw_offset,
limit=500,
)
raw_page = HySdkRawPage.from_memory_list(
value, limit=500, offset=raw_offset
)
records_list.extend(raw_page.raw)
raw_offset += len(raw_page.raw)
if not raw_page.raw:
break
verify_value = await asyncio.to_thread(
client.list_memories,
**query,
offset=0,
limit=500,
)
verify = HySdkRawPage.from_memory_list(verify_value, limit=500, offset=0)
verify_signature = tuple(
(record.raw_id, record.status) for record in verify.raw
)
if verify.total != first_page.total or verify_signature != signature:
continue
projected_turns = tuple(
(record, turn)
for record in records_list
for turn in reversed(raw_display_turns(record))
)
raw_turn_cache[cache_key] = (
first_page.total,
signature,
projected_turns,
)
while len(raw_turn_cache) > 32:
oldest = next(iter(raw_turn_cache))
raw_turn_cache.pop(oldest)
old_lock = raw_turn_cache_locks.get(oldest)
if old_lock is not None and not old_lock.locked():
raw_turn_cache_locks.pop(oldest, None)
break
else:
raise RuntimeError("hy_memory_raw_snapshot_busy")
if cache_key not in raw_turn_cache and not lock.locked():
raw_turn_cache_locks.pop(cache_key, None)
except HTTPException:
raise
except Exception as error:
raise _sdk_http_error(error, "hy_memory_raw_list_failed") from None
records = page.raw
time_filtered = after is not None or before is not None
if unit == "turn":
turns = projected_turns
if time_filtered:
turns = tuple(
(record, turn)
for record, turn in turns
if (at := record.memory_at or record.gmt_created) is not None
and (after is None or at >= after)
and (before is None or at < before)
)
if order == "asc":
turns = tuple(reversed(turns))
selected = turns[offset : offset + limit]
return SdkRawPage(
user_id=user_id,
items=tuple(SdkRawItem.of(record, turn) for record, turn in selected),
total=len(turns),
offset=offset,
limit=limit,
unit="turn",
time_filtered=time_filtered,
)
if time_filtered:
records = tuple(
record
@@ -4421,6 +4664,7 @@ def create_app(
total=page.total,
offset=offset,
limit=limit,
unit="raw",
time_filtered=time_filtered,
)
@@ -4675,6 +4919,15 @@ def create_app(
"""The SDK mode (lite / pro / ultra): configured, effective, and why."""
return _memory_mode_state(app)
@app.post(
"/api/v1/settings/memory-mode/plan",
response_model=MemoryModePlan,
dependencies=strict,
)
def plan_memory_mode(request: MemoryModeUpdate) -> MemoryModePlan:
"""Validate and explain a mode change without mutating anything."""
return _memory_mode_plan(app, request.mode)
@app.put(
"/api/v1/settings/memory-mode",
response_model=MemoryModeChange,
@@ -4693,13 +4946,14 @@ def create_app(
added to the graph once, in the background (`graph_build`). No raw is
ever deleted, in any mode. See docs/contracts/memory-mode-evolve-v1.md.
"""
if request.mode == "ultra" and importlib.util.find_spec("kuzu") is None:
raise HTTPException(status_code=409, detail="graph_store_unavailable")
store = _config_store(app)
async with app.state.memory_mode_lock:
previous_effective = _memory_mode(app)
plan = _memory_mode_plan(app, request.mode)
if plan.blockers:
raise HTTPException(status_code=409, detail=plan.blockers[0])
previous_effective = plan.current
previous = store.memory_mode()
if previous != request.mode or "memory_mode" not in store.document().model_fields_set:
if previous != request.mode or not store.memory_mode_explicitly_configured():
await asyncio.to_thread(store.set_memory_mode, request.mode)
effective = _memory_mode(app)
backfill: bool | None = None
@@ -4718,6 +4972,8 @@ def create_app(
outcome="succeeded",
details={"mode": effective, "previous_mode": previous_effective},
)
if effective == "lite" and effective != previous_effective:
_resume_sedimentation(app)
return MemoryModeChange(
**_memory_mode_state(app).model_dump(),
previous_mode=previous_effective,
@@ -4862,14 +5118,18 @@ def create_app(
in flight and holds their next writes until it ends, and repeats
while the SDK says there is more to do (up to 20 rounds).
"""
try:
return _evolve_service(app).start(
request.user_id,
agent_id=request.agent_id,
max_batches=request.max_batches,
)
except EvolveModeError:
raise HTTPException(status_code=409, detail="memory_mode_not_ultra") from None
async with app.state.memory_mode_lock:
_refuse_pipeline_change_during_embedding_maintenance(app)
if app.state.memory_graph_backfill.running:
raise HTTPException(status_code=409, detail="memory_lifecycle_busy")
try:
return _evolve_service(app).start(
request.user_id,
agent_id=request.agent_id,
max_batches=request.max_batches,
)
except EvolveModeError:
raise HTTPException(status_code=409, detail="memory_mode_not_ultra") from None
@app.get(
"/api/v2/evolve",
@@ -4987,16 +5247,16 @@ def create_app(
"""Start or stop model-driven sedimentation, keeping the configuration.
Stopping is the user's cost control: nothing further goes to the model
and already queued work waits rather than failing, so starting again
resumes it. Importing conversations is not affected either way.
and already queued work remains pending rather than failing. Starting
again lets the normal claim tick continue it; this route never broadly
re-admits permission/model parked jobs. Importing conversations is not
affected either way.
"""
store = _config_store(app)
changed = False
async with app.state.sedimentation_lock:
previous = store.sedimentation_enabled()
if previous is not request.enabled:
await asyncio.to_thread(store.set_sedimentation_enabled, request.enabled)
changed = True
diagnostic_event(
level="info",
component="memory.sedimentation",
@@ -5004,11 +5264,6 @@ def create_app(
outcome="succeeded",
details={"enabled": request.enabled},
)
if changed and request.enabled:
# Work parked by the stop is picked up again rather than waiting
# for the next natural dispatch, but not on this request's time:
# see _resume_sedimentation.
_resume_sedimentation(app)
return SedimentationState(enabled=store.sedimentation_enabled())
@app.post(
@@ -5808,10 +6063,19 @@ def _resume_sedimentation(app: FastAPI) -> None:
"""
running = cast("asyncio.Task[None] | None", getattr(app.state, "sedimentation_resume", None))
if running is not None and not running.done():
app.state.sedimentation_resume_again = True
return
async def resume_until_stable() -> None:
while True:
app.state.sedimentation_resume_again = False
await _sdk_memory_runtime(app).resume_allowed()
if not app.state.sedimentation_resume_again:
return
app.state.sedimentation_resume = watched_task(
asyncio.get_running_loop(),
lambda: _sdk_memory_runtime(app).resume_allowed(),
resume_until_stable,
component="memory.sedimentation",
name="one-memory-sedimentation-resume",
)
@@ -6114,7 +6378,7 @@ async def _search_sdk_raw(
`filters` are a caller's pass-through options: its floor and row filters
apply, and its `include_sources` only narrow `sources`, never widen them.
"""
from one_memory.context_routes import raw_lane_overrides
from one_memory.context_routes import raw_lane_overrides, raw_lane_statuses
include = sorted(sources) if sources else None
if filters is not None and filters.include_sources is not None:
@@ -6141,6 +6405,8 @@ async def _search_sdk_raw(
"scene": "chat",
"reader": HY_MEMORY_READER,
"layers": ["l1_raw"],
# Lite recall is plain RAG over every raw, shadow included.
**raw_lane_statuses(lite=app.state.memory_mode() == "lite"),
**(raw_lane_overrides(filters) if filters is not None else {}),
},
)
@@ -6178,14 +6444,65 @@ def _memory_mode(app: FastAPI) -> MemoryMode:
def _memory_mode_state(app: FastAPI) -> MemoryModeState:
mode = _memory_mode(app)
store = _config_store(app)
return MemoryModeState(
mode=mode,
configured_mode=_config_store(app).memory_mode(),
configured_mode=store.memory_mode(),
explicitly_configured=store.memory_mode_explicitly_configured(),
forced_by_env=graph_store_enabled(),
graph_build=app.state.evolve_state_store.graph_build(),
)
def _memory_mode_plan(app: FastAPI, target: MemoryMode) -> MemoryModePlan:
"""One validator for previews and mutations, in stable blocker order."""
current = _memory_mode(app)
effective_target = cast(MemoryMode, effective_memory_mode(target))
blockers: list[MemoryModeBlocker] = []
if not _embedding_writes_open(app):
blockers.append("embedding_maintenance_in_progress")
if (
effective_target != current
and effective_target in {"pro", "ultra"}
and _model_profile_store(app).active_identity() is None
):
blockers.append("active_model_required")
if effective_target != current and (
_evolve_service(app).running() or app.state.memory_graph_backfill.running
):
blockers.append("memory_lifecycle_busy")
if (
effective_target != current
and effective_target == "ultra"
and importlib.util.find_spec("kuzu") is None
):
blockers.append("graph_store_unavailable")
warnings: list[MemoryModeWarning] = []
if graph_store_enabled():
warnings.append("forced_by_env")
if effective_target == current:
warnings.append("effective_no_change")
graph_backfill: bool | None = None
if effective_target == "ultra" and current != "ultra":
graph_backfill = not app.state.memory_graph_backfill.running
effects = memory_mode_effects(
current,
effective_target,
graph_backfill=graph_backfill,
)
return MemoryModePlan(
current=current,
target=target,
effective_target=effective_target,
capabilities=home_capabilities(effective_target),
effects=() if blockers else effects,
warnings=tuple(warnings),
blockers=tuple(blockers),
)
def _evolve_service(app: FastAPI) -> EvolveService:
return cast(EvolveService, app.state.evolve_service)
@@ -6559,7 +6876,7 @@ def _sedimentation_claim_enabled(app: FastAPI, source_id: str) -> bool:
"""
return (
_config_store(app).sedimentation_enabled()
_pipeline_writes_enabled(app)
and _embedding_writes_open(app)
and (_memory_mode(app) == "lite" or _model_profile_store(app).active_identity() is not None)
and _history_import_gate_released(app)
@@ -6568,6 +6885,40 @@ def _sedimentation_claim_enabled(app: FastAPI, source_id: str) -> bool:
)
async def _admit_pipeline_invocation(
app: FastAPI,
source_id: str,
requires_generation: bool,
mark_invoked: Callable[[], Awaitable[bool]],
) -> bool:
"""Serialize the final source and pipeline checks with the durable mark."""
source_lock = app.state.source_permission_locks.setdefault(source_id, asyncio.Lock())
async with app.state.sedimentation_lock, source_lock:
if requires_generation and not _config_store(app).sedimentation_enabled():
raise RuntimeError("model_sedimentation_disabled")
if not _embedding_writes_open(app):
raise RuntimeError("embedding_switch_in_progress")
if not _source_writes_on(app, source_id):
raise RuntimeError("model_content_source_disabled")
return await mark_invoked()
def _pipeline_writes_enabled(app: FastAPI) -> bool:
"""Raw-only lite writes run even when model sedimentation is paused."""
return _memory_mode(app) == "lite" or _config_store(app).sedimentation_enabled()
def _model_claim_allowance(app: FastAPI) -> int | None:
"""A failed generation model never pauses raw-only lite ingestion."""
backoff = cast(ModelBackoff, app.state.model_backoff)
if _memory_mode(app) == "lite":
return backoff.allowance(kind="embedding")
return backoff.allowance()
def _user_evolving(app: FastAPI, user_id: str) -> bool:
"""Whether evolve holds this user's writes right now."""
coordinator = getattr(app.state, "sdk_write_coordinator", None)
@@ -6612,6 +6963,12 @@ def _refuse_write_during_embedding_switch(app: FastAPI) -> None:
raise HTTPException(status_code=409, detail="embedding_switch_in_progress")
def _refuse_pipeline_change_during_embedding_maintenance(app: FastAPI) -> None:
"""Keep mode/evolve lifecycle changes out of embedding maintenance."""
if not _embedding_writes_open(app):
raise HTTPException(status_code=409, detail="embedding_maintenance_in_progress")
_EMBEDDING_PATCH_FIELDS = frozenset(
{"embedding_model", "embedding_provider_kind", "embedding_base_url", "embedding_dimensions"}
)
@@ -66,6 +66,8 @@ class SdkContextSearchRequest(ContextSearchRequest, SdkSearchOptions):
limit: int = Field(default=10, ge=1, le=20, strict=True)
schema_limit: int = Field(default=3, ge=0, le=20, strict=True)
intention_limit: int = Field(default=1, ge=0, le=20, strict=True)
#: Product callers can request one item per turn instead of SDK raw batches.
raw_unit: Literal["raw", "turn"] = "raw"
#: Whose memories to search. The App never sends it and reads `local`.
user_id: UserId = DEFAULT_USER_ID
@@ -123,6 +125,9 @@ class SdkRawContextItem(BaseModel):
memory_at: datetime | None = None
score: float = Field(ge=0, le=1)
truncated: bool = False
turn_id: str | None = Field(default=None, min_length=1, max_length=96)
turn_index: int | None = Field(default=None, ge=0)
turn_count: int | None = Field(default=None, ge=1)
class SdkMemoryContextItem(BaseModel):
@@ -30,6 +30,7 @@ from one_memory.hy_sdk_types import (
parse_sdk_search,
)
from one_memory.imports.storage import EvidenceStore
from one_memory.raw_turns import raw_display_turns, turns_for_hit
from one_memory.sdk_search_options import SdkSearchFilters, SearchLane
from one_memory.semantic_deadline import SemanticMemoryHit
from one_memory.user_scope import owns
@@ -70,6 +71,18 @@ def raw_lane_overrides(value: SdkSearchFilters) -> dict[str, object]:
return found
def raw_lane_statuses(*, lite: bool) -> dict[str, list[str]]:
"""Which raw statuses the raw lane searches.
Lite extracts nothing, so its search is plain RAG over every raw,
including the shadow raws pro already processed before the switch to
lite. Pro and ultra keep the SDK default (active only): a shadow raw is
already represented by the memories extracted from it, and returning it
again would repeat that content in the caller's context budget.
"""
return {"statuses": ["active", "shadow"]} if lite else {}
def hit_namespace(hit: SemanticMemoryHit) -> dict[str, str]:
"""The `get` namespace of a hit: nothing for chat, kind and variant otherwise."""
if hit.kind in {"experience", "fact"} and hit.variant is not None:
@@ -120,6 +133,9 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
sources = raw_lane_sources(authority.raw_sources, value.include_sources)
if sources is None:
return ()
# Plain RAG: every vector hit over the floor is an answer, ranked
# by its score. Each hit yields at least one item, so the SDK
# needs to return no more hits than the caller's limit.
raw = await asyncio.to_thread(
client.search,
value.query,
@@ -135,27 +151,60 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
"scene": "chat",
"reader": HY_MEMORY_READER,
"layers": ["l1_raw"],
**raw_lane_statuses(lite=lite),
**raw_lane_overrides(value),
},
)
hits = tuple(
hit for hit in parse_sdk_raw_search(raw) if value.passes_floor("raw", hit.score)
)
return tuple(
SdkRawContextItem(
raw_id=hit.raw_id,
content=hit.content,
source=hit.source,
session_id=hit.session_id,
memory_at=(
datetime.fromtimestamp(hit.memory_at, tz=UTC)
if hit.memory_at is not None
else None
),
score=hit.score,
# One item per hit first, so a hit is never crowded out by another
# hit's sibling turns; the siblings fill whatever room is left.
primary: list[SdkRawContextItem] = []
siblings: list[SdkRawContextItem] = []
for hit in hits:
memory_at = (
datetime.fromtimestamp(hit.memory_at, tz=UTC)
if hit.memory_at is not None
else None
)
for hit in hits
)
turns = raw_display_turns(hit) if value.raw_unit == "turn" else ()
if not turns:
primary.append(
SdkRawContextItem(
raw_id=hit.raw_id,
content=hit.content,
source=hit.source,
session_id=hit.session_id,
memory_at=memory_at,
score=hit.score,
)
)
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,
)
for turn in turns_for_hit(turns, value.query)
]
primary.append(shown[0])
siblings.extend(shown[1:])
chosen = primary[: value.limit]
chosen.extend(siblings[: value.limit - len(chosen)])
# Stable: a hit's turns stay together, in hit-score order.
chosen.sort(key=lambda item: item.score, reverse=True)
return tuple(chosen)
async def memory_lane() -> tuple[SdkMemoryContextItem, ...]:
# lite extracts nothing: its search answers raw turns, which the
@@ -126,15 +126,23 @@ def embedding_switch_router(dependencies: Sequence[Depends]) -> APIRouter:
@router.post("/api/v1/embedding-switches", status_code=202, response_model=None)
async def start_embedding_switch(value: EmbeddingSwitchStartRequest, request: Request) -> Any:
try:
job = await _service(request).start_switch(
value.profile_id,
expected_revision=value.expected_revision,
plan_token=value.plan_token,
requested_by=value.requested_by,
)
except EmbeddingSwitchError as error:
return refusal(error.code, plan=error.plan)
# One lifecycle lock orders mode/evolve/backfill and embedding
# maintenance. Without it each endpoint could pass its own preflight
# and then begin concurrently.
async with request.app.state.memory_mode_lock:
evolve = request.app.state.evolve_service
backfill = request.app.state.memory_graph_backfill
if evolve.running() or backfill.running:
return refusal("memory_lifecycle_busy")
try:
job = await _service(request).start_switch(
value.profile_id,
expected_revision=value.expected_revision,
plan_token=value.plan_token,
requested_by=value.requested_by,
)
except EmbeddingSwitchError as error:
return refusal(error.code, plan=error.plan)
return job.model_dump(mode="json")
@router.get("/api/v1/embedding-switches/current")
@@ -14,7 +14,7 @@ from pathlib import Path
from typing import Literal, cast
HY_MEMORY_DISTRIBUTION = "hy-memory"
HY_MEMORY_VERSION = "2.0.12"
HY_MEMORY_VERSION = "2.0.14"
HY_MEMORY_NAMESPACE = "governed-v2"
DiagnosticLogLevel = Literal["error", "warning", "info", "debug"]
@@ -503,7 +503,7 @@ def _ensure_coding_schema(path: Path) -> None:
CodingMemoryStore,
)
# Relies on the pinned SDK's internals (hy-memory==2.0.12 is pinned exactly).
# Relies on the pinned SDK's internals (hy-memory==2.0.14 is pinned exactly).
store = CodingMemoryStore.__new__(CodingMemoryStore)
store._db_path = str(path)
store._conn = None
@@ -75,6 +75,9 @@ class HySdkMemoryRecord(BaseModel):
# Epoch seconds. `updated_at` falls back to `created_at`.
created_at: int | None = None
updated_at: int | None = None
# Actual scene=all ordering timestamp before the public fallback above.
# Internal-only: callers keep the existing SdkMemoryRecord wire shape.
sort_updated_at: int | None = Field(default=None, exclude=True, repr=False)
@classmethod
def from_sdk(cls, raw: object) -> HySdkMemoryRecord:
@@ -95,6 +98,10 @@ class HySdkMemoryRecord(BaseModel):
if created_at is None:
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
)
return cls(
memory_id=str(raw["memory_id"]),
content=str(raw["content"]),
@@ -116,6 +123,7 @@ class HySdkMemoryRecord(BaseModel):
gmt_created=gmt_created,
created_at=created_at,
updated_at=updated_at if updated_at is not None else created_at,
sort_updated_at=sort_updated_at,
)
except (KeyError, TypeError, ValueError) as error:
raise HySdkReadError("hy_memory_record_shape_unknown") from error
@@ -180,6 +188,7 @@ class HySdkMemoryRecord(BaseModel):
solution=solution,
created_at=created_at,
updated_at=updated_at if updated_at is not None else created_at,
sort_updated_at=updated_at,
)
except (KeyError, TypeError, ValueError) as error:
raise HySdkReadError("hy_memory_record_shape_unknown") from error
@@ -10,7 +10,10 @@ from pydantic import JsonValue
from one_memory.input_sanitization import sanitize_model_input
_REMINDER_TAGS = r"additional_data|system[-_]reminder|working_memory_reminder"
_CONTEXT_TAGS = r"user_info|rules|agent_transcripts|system[-_]notification|pipeline_log"
_CONTEXT_TAGS = (
r"user_info|rules|agent_transcripts|attached_files|image_local_path|"
r"system[-_]notification|pipeline_log"
)
_TAG_ATTRIBUTES = r"""(?:\s+[\w:-]+\s*=\s*(?:"[^"<>]*"|'[^'<>]*'))*\s*"""
_HOST_PREFIX = re.compile(rf"<(?P<tag>{_REMINDER_TAGS}|{_CONTEXT_TAGS}){_TAG_ATTRIBUTES}>")
_REMINDER_START = re.compile(rf"<(?P<tag>{_REMINDER_TAGS}){_TAG_ATTRIBUTES}>")
@@ -465,12 +465,16 @@ class EvolveScheduler:
service: EvolveService,
schedule: Callable[[], EvolveSchedule],
users_with_memories: Callable[[], Awaitable[Iterable[str]]],
lifecycle_lock: asyncio.Lock | None = None,
lifecycle_open: Callable[[], bool] = lambda: True,
now: Callable[[], datetime] = lambda: datetime.now().astimezone(),
check_seconds: float = SCHEDULE_CHECK_SECONDS,
) -> None:
self.service = service
self.schedule = schedule
self.users_with_memories = users_with_memories
self.lifecycle_lock = lifecycle_lock
self.lifecycle_open = lifecycle_open
self._now = now
self.check_seconds = check_seconds
self._task: asyncio.Task[None] | None = None
@@ -492,7 +496,15 @@ class EvolveScheduler:
if not evolve_due(now, schedule, last, anchor):
continue
try:
receipt = self.service.start(user_id, trigger="scheduled")
if self.lifecycle_lock is None:
if not self.lifecycle_open():
break
receipt = self.service.start(user_id, trigger="scheduled")
else:
async with self.lifecycle_lock:
if not self.lifecycle_open():
break
receipt = self.service.start(user_id, trigger="scheduled")
except EvolveModeError:
break
await self.service.wait(receipt.run_id)
@@ -533,10 +545,12 @@ class MemoryGraphBackfill:
*,
coordinator: EvolveCoordinator,
state: EvolveStateStore,
lifecycle_open: Callable[[], bool] = lambda: True,
now: Callable[[], datetime] = lambda: datetime.now(UTC),
) -> None:
self.coordinator = coordinator
self.state = state
self.lifecycle_open = lifecycle_open
self._now = now
self._task: asyncio.Task[None] | None = None
@@ -557,6 +571,35 @@ class MemoryGraphBackfill:
self._task = asyncio.create_task(self._run(users), name="one-memory-graph-backfill")
return state
def recover(self, user_ids: Iterable[str]) -> GraphBuildState:
"""Resume an interrupted backfill without repeating completed users."""
if self.running:
return self.state.graph_build()
saved = self.state.graph_build()
if saved.state != "running":
return saved
users = tuple(dict.fromkeys((*saved.users.keys(), *user_ids)))
pending = tuple(
user_id
for user_id in users
if saved.users.get(user_id, GraphBuildUser()).state != "succeeded"
)
resumed = saved.model_copy(deep=True)
for user_id in pending:
resumed.users[user_id] = GraphBuildUser()
if not pending:
resumed.state = "succeeded"
resumed.finished_at = self._now()
self.state.set_graph_build(resumed)
return resumed
resumed.state = "running"
resumed.finished_at = None
self.state.set_graph_build(resumed)
self._task = asyncio.create_task(
self._run(pending), name="one-memory-graph-backfill-recovery"
)
return resumed
async def wait(self) -> None:
if self._task is not None:
await asyncio.shield(self._task)
@@ -564,6 +607,8 @@ class MemoryGraphBackfill:
async def _run(self, users: tuple[str, ...]) -> None:
state = self.state.graph_build()
for user_id in users:
while not self.lifecycle_open():
await asyncio.sleep(0.2)
state.users[user_id] = GraphBuildUser(state="running")
await asyncio.to_thread(self.state.set_graph_build, state.model_copy(deep=True))
try:
@@ -153,6 +153,11 @@ class ConfigStore:
with self._lock:
return self._document.memory_mode
def memory_mode_explicitly_configured(self) -> bool:
"""Whether a person chose the mode, including choosing the default."""
with self._lock:
return "memory_mode" in self._document.model_fields_set
def set_memory_mode(self, mode: MemoryMode) -> ConfigDocument:
if mode not in MEMORY_MODES:
raise ValueError("invalid_memory_mode")
@@ -2540,7 +2540,7 @@ async def execute_job(
evidence: EvidenceStore,
owner: str | None = None,
source_writes_permitted: Callable[[str], bool] | None = None,
admit_hy_invocation: Callable[[str, Callable[[], Awaitable[bool]]], Awaitable[bool]]
admit_hy_invocation: Callable[[str, bool, Callable[[], Awaitable[bool]]], Awaitable[bool]]
| None = None,
model_failures: Callable[[], AbstractContextManager[ModelAttempt | None]] | None = None,
) -> None:
@@ -2593,7 +2593,7 @@ async def execute_job(
hy_invoked = False
async def mark_hy_invocation() -> None:
async def mark_hy_invocation(requires_generation: bool) -> None:
"""Persist the durable boundary immediately before the real HY call."""
nonlocal hy_invoked
if hy_invoked:
@@ -2621,7 +2621,7 @@ async def execute_job(
return await in_the_background(queue.mark_invoked, job_id, owner=owner)
if admit_hy_invocation is not None:
admitted = await admit_hy_invocation(source_id, mark)
admitted = await admit_hy_invocation(source_id, requires_generation, mark)
else:
if source_writes_permitted is not None and not source_writes_permitted(source_id):
raise RuntimeError("model_content_source_disabled")
@@ -3261,11 +3261,9 @@ class DurableWriteDrain:
"durable_write_drain_job_ceiling_exceeded job_id=%s",
job_id,
)
await in_the_background(
self.queue.settle_worker_error,
await self._settle_worker_error(
job_id,
error_code="durable_work_ceiling_exceeded",
owner=self.owner,
)
except Exception as error:
logger.error(
@@ -3274,11 +3272,9 @@ class DurableWriteDrain:
type(error).__name__,
)
self._record_database_error(error)
await in_the_background(
self.queue.settle_worker_error,
await self._settle_worker_error(
job_id,
error_code="durable_executor_error",
owner=self.owner,
)
finally:
execution.cancel()
@@ -3287,6 +3283,36 @@ class DurableWriteDrain:
with contextlib.suppress(asyncio.CancelledError, Exception):
await task
async def _settle_worker_error(self, job_id: str, *, error_code: str) -> None:
"""Best-effort settlement must not kill the long-lived drain.
The row still has a durable lease when this fallback runs. If the
single SQLite writer is busy, waiting and retrying here only extends
the same contention that made the executor fail. Leave the row where
it is instead: lease recovery later moves a never-invoked job back to
pending and a possibly-invoked job to unknown, preserving the
no-blind-retry boundary.
Only transient lock/busy refusals are deferred. A malformed query or
other programming error still escapes to the outer per-job fence.
"""
try:
await in_the_background(
self.queue.settle_worker_error,
job_id,
error_code=error_code,
owner=self.owner,
)
except sqlite3.OperationalError as error:
if "locked" not in str(error).lower() and "busy" not in str(error).lower():
raise
self._sample_database_failure("settle_worker_error", error)
logger.warning(
"durable_write_worker_settlement_deferred job_id=%s error_type=%s",
job_id,
type(error).__name__,
)
async def _execute_with_ceiling(self, job_id: str) -> None:
ceiling = (
await in_the_background(self.work_ceiling)
@@ -3324,7 +3350,26 @@ class DurableWriteDrain:
finished = {task for task in active if task.done()}
active.difference_update(finished)
for task in finished:
await task
try:
await task
except asyncio.CancelledError:
raise
except _LeaseRenewalFatalError:
# Renewal is the ownership fence. If it is broken,
# continuing could let another worker reclaim a job
# while this process is still executing it.
raise
except Exception as error:
# A job task is a fault-containment boundary. Its row
# retains the durable lease and is recovered according
# to hy_invoked; killing this only consumer would strand
# every unrelated write behind it.
self._record_database_error(error)
logger.error(
"durable_write_drain_job_task_failed task_name=%s error_type=%s",
task.get_name(),
type(error).__name__,
)
if self.budget is not None and work_deadline is not None:
now = _time.monotonic()
# Always admit the first unit of a cycle even when the
@@ -3349,7 +3394,12 @@ class DurableWriteDrain:
else ()
)
for job_id in ids:
active.add(asyncio.create_task(self._run_claimed_job(job_id)))
active.add(
asyncio.create_task(
self._run_claimed_job(job_id),
name=f"durable-write:{job_id}",
)
)
executed = len(ids)
self._database_failure_streaks.pop("claim", None)
except asyncio.CancelledError:
@@ -3190,6 +3190,51 @@ async def _061_write_job_retries(cursor: Any) -> None:
await cursor.execute(statement)
ONBOARDING_PROGRESS_MIGRATION_VERSION = 62
def onboarding_progress_ddl(dialect: str = "mysql") -> str:
"""Append resumable onboarding choices without rewriting migration 12."""
if dialect == "sqlite":
return """
ALTER TABLE onboarding_state
ADD COLUMN mode_intent TEXT NULL;
ALTER TABLE onboarding_state
ADD COLUMN setup_step TEXT NULL;
ALTER TABLE onboarding_state
ADD COLUMN model_profile_id TEXT NULL;
"""
return """
ALTER TABLE onboarding_state
ADD COLUMN mode_intent VARCHAR(16) NULL AFTER updated_at;
ALTER TABLE onboarding_state
ADD COLUMN setup_step VARCHAR(16) NULL AFTER mode_intent;
ALTER TABLE onboarding_state
ADD COLUMN model_profile_id VARCHAR(36) NULL AFTER setup_step;
"""
async def _062_onboarding_progress(cursor: Any) -> None:
if _dialect(cursor) == "sqlite":
existing = await cursor.columns("onboarding_state")
statements = _split_statements(onboarding_progress_ddl("sqlite"))
for column, statement in zip(
("mode_intent", "setup_step", "model_profile_id"),
statements,
strict=True,
):
if column not in existing:
await cursor.execute(statement)
return
for statement in _split_statements(onboarding_progress_ddl("mysql")):
try:
await cursor.execute(statement)
except Exception as error: # noqa: BLE001 - MySQL driver error type varies
if _is_duplicate_column(error):
continue
raise
def _split_statements(sql: str) -> list[str]:
"""Split a multi-statement script, dropping blanks, PRAGMA and comments.
@@ -3490,4 +3535,10 @@ MIGRATIONS: tuple[Migration, ...] = (
("memory_note_requests",),
),
Migration(61, "write_job_retries", _061_write_job_retries),
Migration(
ONBOARDING_PROGRESS_MIGRATION_VERSION,
"onboarding_progress",
_062_onboarding_progress,
("onboarding_state",),
),
)
@@ -247,15 +247,17 @@ class ModelBackoff:
and self._clock() < self._until
)
def allowance(self) -> int | None:
"""How many model jobs may be claimed now: None for no limit.
def allowance(self, *, kind: str | None = None) -> int | None:
"""How many matching provider jobs may be claimed now.
0 while paused; once the pause is over, one probe at a time until it
reports or `PROBE_HOLD_SECONDS` pass.
reports or `PROBE_HOLD_SECONDS` pass. ``kind`` keeps a failed chat
model from pausing raw-only work that needs embeddings, while an
embedding outage still paces that work.
"""
now = self._clock()
with self._lock:
if self._failures == 0:
if self._failures == 0 or (kind is not None and kind not in self._failing):
return None
if now < self._until or now < self._probe_until:
return 0
+171 -32
View File
@@ -4,9 +4,10 @@ from collections.abc import Iterator
from contextlib import contextmanager
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from typing import Any, Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field, field_validator
from one_memory.database import (
take_the_write_lock_first,
@@ -14,11 +15,25 @@ from one_memory.database import (
from one_memory.database_access import main_database
#: Schema owned by `memory_server.migrations` (migration 12). No local copy.
from one_memory.memory_server.migrations import onboarding_state_ddl
from one_memory.memory_server.sql_compat import execute, is_mysql
from one_memory.memory_server.migrations import (
onboarding_progress_ddl,
onboarding_state_ddl,
)
from one_memory.memory_server.sql_compat import execute, is_mysql, row_to_dict
from one_memory.memory_server.store_binding import store_for
SCHEMA = onboarding_state_ddl("sqlite")
ONBOARDING_COLUMNS = (
"singleton",
"status",
"selected_source_ids_json",
"started_at",
"completed_at",
"updated_at",
"mode_intent",
"setup_step",
"model_profile_id",
)
#: The start/complete upserts, per engine.
#:
@@ -51,11 +66,13 @@ START_MYSQL = """
updated_at = VALUES(updated_at)
"""
COMPLETE_UPDATE_SQLITE = (
"UPDATE onboarding_state SET status = 'completed', completed_at = ?, updated_at = ? "
"UPDATE onboarding_state SET status = 'completed', completed_at = ?, updated_at = ?, "
"mode_intent = NULL, setup_step = NULL, model_profile_id = NULL "
"WHERE singleton = 1"
)
COMPLETE_UPDATE_MYSQL = (
"UPDATE onboarding_state SET status = 'completed', completed_at = %s, updated_at = %s "
"UPDATE onboarding_state SET status = 'completed', completed_at = %s, updated_at = %s, "
"mode_intent = NULL, setup_step = NULL, model_profile_id = NULL "
"WHERE singleton = 1"
)
COMPLETE_INSERT_SQLITE = (
@@ -71,13 +88,27 @@ COMPLETE_INSERT_MYSQL = (
RESET_UPDATE_SQLITE = (
"UPDATE onboarding_state SET status = 'not_started', "
"selected_source_ids_json = '[]', started_at = NULL, completed_at = NULL, "
"mode_intent = NULL, setup_step = NULL, model_profile_id = NULL, "
"updated_at = ? WHERE singleton = 1"
)
RESET_UPDATE_MYSQL = (
"UPDATE onboarding_state SET status = 'not_started', "
"selected_source_ids_json = '[]', started_at = NULL, completed_at = NULL, "
"mode_intent = NULL, setup_step = NULL, model_profile_id = NULL, "
"updated_at = %s WHERE singleton = 1"
)
ENSURE_PROGRESS_SQLITE = (
"INSERT INTO onboarding_state ("
"singleton,status,selected_source_ids_json,started_at,completed_at,updated_at"
") VALUES (1,'not_started','[]',NULL,NULL,?) "
"ON CONFLICT(singleton) DO NOTHING"
)
ENSURE_PROGRESS_MYSQL = (
"INSERT INTO onboarding_state ("
"singleton,status,selected_source_ids_json,started_at,completed_at,updated_at"
") VALUES (1,'not_started','[]',NULL,NULL,%s) "
"ON DUPLICATE KEY UPDATE singleton=VALUES(singleton)"
)
class OnboardingState(BaseModel):
@@ -87,6 +118,35 @@ class OnboardingState(BaseModel):
selected_source_ids: tuple[str, ...]
started_at: datetime | None
completed_at: datetime | None
mode_intent: Literal["lite", "pro"] | None
setup_step: Literal["review", "mode", "model"] | None
model_profile_id: str | None
class OnboardingProgressUpdate(BaseModel):
"""Only non-sensitive, resumable choices accepted from the onboarding UI."""
model_config = ConfigDict(frozen=True, extra="forbid")
mode_intent: Literal["lite", "pro"] | None = None
setup_step: Literal["review", "mode", "model"] | None = None
model_profile_id: str | None = Field(default=None, min_length=36, max_length=36)
@field_validator("model_profile_id")
@classmethod
def validate_model_profile_id(cls, value: str | None) -> str | None:
if value is None:
return None
try:
if str(UUID(value)) != value:
raise ValueError
except (ValueError, TypeError, AttributeError):
raise ValueError("invalid_model_profile_id") from None
return value
class OnboardingCompletedError(RuntimeError):
pass
class OnboardingStore:
@@ -103,12 +163,31 @@ class OnboardingStore:
"""
with self._connect(initialize=True) as connection:
execute(connection, is_mysql(self._store), SCHEMA)
if not is_mysql(self._store):
existing = {
str(row[1]) for row in connection.execute("PRAGMA table_info(onboarding_state)")
}
statements = tuple(
statement.strip()
for statement in onboarding_progress_ddl("sqlite").split(";")
if statement.strip()
)
for column, statement in zip(
("mode_intent", "setup_step", "model_profile_id"),
statements,
strict=True,
):
if column not in existing:
connection.execute(statement)
def get(self) -> OnboardingState:
mysql = is_mysql(self._store)
with self._connect() as connection:
row = execute(
connection, mysql, "SELECT * FROM onboarding_state WHERE singleton = 1"
connection,
mysql,
"SELECT " + ", ".join(ONBOARDING_COLUMNS) + " "
"FROM onboarding_state WHERE singleton = 1",
).fetchone()
if row is None:
return OnboardingState(
@@ -116,8 +195,11 @@ class OnboardingStore:
selected_source_ids=(),
started_at=None,
completed_at=None,
mode_intent=None,
setup_step=None,
model_profile_id=None,
)
return self._to_state(row)
return self._to_state(row, mysql=mysql)
def start(self, source_ids: tuple[str, ...]) -> OnboardingState:
now = datetime.now(UTC).isoformat()
@@ -150,6 +232,43 @@ class OnboardingStore:
)
return self.get()
def update_progress(self, update: OnboardingProgressUpdate) -> OnboardingState:
fields = tuple(
field
for field in ("mode_intent", "setup_step", "model_profile_id")
if field in update.model_fields_set
)
if not fields:
state = self.get()
if state.status == "completed":
raise OnboardingCompletedError("onboarding_completed")
return state
mysql = is_mysql(self._store)
placeholder = "%s" if mysql else "?"
assignments = [f"{field} = {placeholder}" for field in fields]
assignments.append(f"updated_at = {placeholder}")
values = [getattr(update, field) for field in fields]
now = datetime.now(UTC).isoformat()
values.append(now)
with self._connect(writing=True) as connection:
execute(
connection,
mysql,
ENSURE_PROGRESS_MYSQL if mysql else ENSURE_PROGRESS_SQLITE,
(now,),
)
updated = execute(
connection,
mysql,
"UPDATE onboarding_state SET "
+ ", ".join(assignments)
+ " WHERE singleton = 1 AND status != 'completed'",
tuple(values),
)
if updated.rowcount != 1:
raise OnboardingCompletedError("onboarding_completed")
return self.get()
def reset(self) -> OnboardingState:
"""Return onboarding to its first-run state, touching nothing else.
@@ -207,33 +326,53 @@ class OnboardingStore:
yield connection
@staticmethod
def _to_state(row: Any) -> OnboardingState:
"""Read the row by position, so it works for `sqlite3.Row` and tuples.
`SELECT *` makes the column order part of the contract here, which is
why the query is `SELECT *` and the indices are named: relying on
`row["..."]` would only work on SQLite, and relying on position alone
without saying which column is which would be unreadable.
Timestamps are a further engine difference: SQLite hands back the stored
string, MySQL hands back a `datetime`. Accepting both is not padding —
parsing a `datetime` raises, and treating a string as one would be
silently wrong.
"""
# singleton, status, selected_source_ids_json, started_at, completed_at, updated_at
raw_ids = json.loads(row[2])
def _to_state(row: Any, *, mysql: bool) -> OnboardingState:
"""Map SQLite rows and MySQL tuples through the shared database adapter."""
if mysql:
if not isinstance(row, tuple):
raise ValueError("onboarding_row_shape_invalid")
elif not isinstance(row, sqlite3.Row):
raise ValueError("onboarding_row_shape_invalid")
if len(row) != len(ONBOARDING_COLUMNS):
raise ValueError("onboarding_row_shape_invalid")
if mysql:
values = dict(zip(ONBOARDING_COLUMNS, tuple(row), strict=True))
else:
values = row_to_dict(row, ONBOARDING_COLUMNS, mysql=False)
if set(values) != set(ONBOARDING_COLUMNS):
raise ValueError("onboarding_row_shape_invalid")
_as_datetime(values["updated_at"], mysql=mysql, nullable=False)
raw_ids = json.loads(values["selected_source_ids_json"])
return OnboardingState(
status=row[1],
status=values["status"],
selected_source_ids=tuple(value for value in raw_ids if isinstance(value, str)),
started_at=_as_datetime(row[3]),
completed_at=_as_datetime(row[4]),
started_at=_as_datetime(values["started_at"], mysql=mysql),
completed_at=_as_datetime(values["completed_at"], mysql=mysql),
mode_intent=values["mode_intent"],
setup_step=values["setup_step"],
model_profile_id=values["model_profile_id"],
)
def _as_datetime(value: object) -> datetime | None:
"""SQLite stores these as text; MySQL returns `datetime` objects."""
if value is None or value == "":
def _as_datetime(
value: object,
*,
mysql: bool,
nullable: bool = True,
) -> datetime | None:
"""Validate the engine's timestamp shape and normalize it to UTC."""
if value is None:
if not nullable:
raise ValueError("onboarding_datetime_invalid")
return None
if isinstance(value, datetime):
return value
return datetime.fromisoformat(str(value))
if mysql:
if not isinstance(value, datetime):
raise ValueError("onboarding_datetime_invalid")
parsed = value
else:
if not isinstance(value, str) or value == "":
raise ValueError("onboarding_datetime_invalid")
parsed = datetime.fromisoformat(value)
if parsed.tzinfo is None:
return parsed.replace(tzinfo=UTC)
return parsed.astimezone(UTC)
@@ -0,0 +1,123 @@
"""Turn-level read projection for SDK raw batches.
HY keeps several complete conversation turns in one L1 raw node so model
writes stay batched. Product readers must not expose that storage batch as
one conversation item: this module gives every contained turn a stable
identity while retaining the parent raw id for traceability.
"""
from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass
from one_memory.hy_sdk_types import HySdkRawRecord, raw_turn_messages
@dataclass(frozen=True)
class RawDisplayTurn:
turn_id: str
turn_index: int
turn_count: int
content: str
messages: tuple[tuple[str, str], ...]
def raw_display_turns(record: HySdkRawRecord) -> tuple[RawDisplayTurn, ...]:
"""Split one stored raw batch into user-opened turns.
A user message opens a new turn; assistant/tool/system messages stay with
that user message until the next user message. Legacy content with no user
marker remains one turn so it never disappears from the product.
"""
messages = raw_turn_messages(record.content)
grouped: list[list[tuple[str, str]]] = []
for message in messages:
role, _ = message
has_user = bool(grouped) and any(item[0] == "user" for item in grouped[-1])
if not grouped or (role == "user" and has_user):
grouped.append([message])
else:
grouped[-1].append(message)
if not grouped:
grouped = [[("", record.content)]]
digest = hashlib.sha256(record.raw_id.encode("utf-8")).hexdigest()
count = len(grouped)
return tuple(
RawDisplayTurn(
turn_id=f"raw-turn:{digest}:{index}",
turn_index=index,
turn_count=count,
content=_turn_content(turn),
messages=tuple(turn),
)
for index, turn in enumerate(grouped)
)
def _turn_content(messages: list[tuple[str, str]]) -> str:
return "\n".join(f"[{role}]: {content}" if role else content for role, content in messages)
def raw_prefix_added(
cached: tuple[tuple[str, str | None], ...],
current: tuple[tuple[str, str | None], ...],
cached_total: int,
current_total: int,
) -> int | None:
"""How many raws were only put on top of a cached newest-first listing.
`cached` and `current` are the (raw id, status) of the first page of each
listing. The answer is a count when the current page is the cached one
with new raws before it and nothing else changed; None when anything else
happened (a delete, a status change, a raw dated into the middle), which
needs a full re-listing.
"""
added = current_total - cached_total
if added <= 0 or added > len(current):
return None
kept = current[added:]
if not kept and cached_total:
return None
if kept != cached[: len(kept)]:
return None
return added
_WORD = re.compile(r"\w+")
def _query_terms(query: str) -> frozenset[str]:
"""Words of the query, plus character bigrams of unspaced (CJK) words."""
terms: set[str] = set()
for word in _WORD.findall(query.casefold()):
terms.add(word)
if not word.isascii() and len(word) > 1:
terms.update(word[index : index + 2] for index in range(len(word) - 1))
return frozenset(terms)
def turns_for_hit(turns: tuple[RawDisplayTurn, ...], query: str) -> tuple[RawDisplayTurn, ...]:
"""The turns one semantic raw hit shows, never none while it has turns.
The SDK embeds a whole raw batch, so a vector hit cannot say which of its
turns matched. Every turn containing the query text is shown; when none
does, the turn sharing the most query terms (the earliest on a tie) stands
for the hit. A semantic hit is never dropped for lacking the literal query.
"""
if not turns:
return ()
needle = query.casefold().strip()
if needle:
lexical = tuple(turn for turn in turns if needle in turn.content.casefold())
if lexical:
return lexical
terms = _query_terms(query)
def overlap(turn: RawDisplayTurn) -> tuple[int, int]:
content = turn.content.casefold()
return (sum(1 for term in terms if term in content), -turn.turn_index)
return (max(turns, key=overlap),)
@@ -25,7 +25,7 @@ from one_memory.hy_runtime_config import HY_MEMORY_READER
from one_memory.hy_sdk_runtime import SdkClientHandle, sdk_relevance_floor
from one_memory.hy_sdk_types import HySdkError, parse_sdk_search
from one_memory.memory_queue import ExtractionWindowPreset, QueueDepths
from one_memory.memory_server.durable_queue import DurableWriteQueue
from one_memory.memory_server.durable_queue import WAITING_FOR_MODEL, DurableWriteQueue
from one_memory.sdk_write_processor import SdkWriteCoordinator
from one_memory.semantic_deadline import SemanticMemoryHit
@@ -309,7 +309,10 @@ class SdkMemoryRuntime:
return []
async def resume_allowed(self) -> None:
await asyncio.to_thread(self.write_queue.readmit_parked)
await asyncio.to_thread(
self.write_queue.readmit_parked,
states=(WAITING_FOR_MODEL,),
)
def accepted_backfill_status(self) -> None:
return None
@@ -94,6 +94,11 @@ class ActiveProfileReadHandle(SdkClientHandle):
handle = await self.current_handle()
return self._guarded(handle, await handle.client())
async def client_for_mode(self, mode: str) -> Any:
"""Build/read with the caller's one mode snapshot, without rereading it."""
handle = await self.current_handle(mode=mode)
return self._guarded(handle, await handle.client())
def _guarded(self, handle: SdkClientHandle, client: Any) -> Any:
"""A remote embedding's searches report `embedding_unavailable` (no fallback)."""
profile = getattr(handle, "profile", None)
@@ -111,15 +116,15 @@ class ActiveProfileReadHandle(SdkClientHandle):
),
)
async def current_handle(self) -> SdkClientHandle:
async def current_handle(self, *, mode: str | None = None) -> SdkClientHandle:
"""The handle of the live space, held for the asking task."""
space, identity = await in_sdk_read(self._resolve)
mode, features = self.memory_mode(), self.current_features()
selected_mode, features = mode or self.memory_mode(), self.current_features()
language = self.current_language()
key = (
space.space_id if space is not None else "",
*identity,
mode,
selected_mode,
features,
language,
)
@@ -128,7 +133,9 @@ class ActiveProfileReadHandle(SdkClientHandle):
if handle is None:
# Only on a change of space or profile: resolving a key can
# reach the system keychain, so it is not done on every read.
handle = await in_sdk_read(self._build, space, identity, mode, features, language)
handle = await in_sdk_read(
self._build, space, identity, selected_mode, features, language
)
self._handles[key] = handle
self._retired.update(value for name, value in self._handles.items() if name != key)
self._handles = {key: handle}
@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
import re
import threading
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
from contextlib import asynccontextmanager
@@ -22,11 +23,12 @@ from one_memory.hy_sdk_runtime import (
SdkClientHandle,
builtin_retrieval_snapshot,
language_kwargs,
retrieval_view,
)
from one_memory.imports.models import EvidenceContentKind, EvidenceOrigin, EvidenceRecord
from one_memory.input_sanitization import sanitize_model_input
from one_memory.memory_features import FeatureOverrides
from one_memory.model_profiles import ModelProfileSnapshot, ModelProfileStore
from one_memory.model_profiles import ModelProfileSnapshot, ModelProfileStore, ProviderKind
from one_memory.secrets import SecretStore, SecretStoreUnavailableError
PRODUCT_AGENT_ID = "all-apps"
@@ -111,7 +113,7 @@ class SdkWriteProcessor:
records: tuple[EvidenceRecord, ...],
*,
request_id: str,
before_hy_invocation: Callable[[], Awaitable[None]],
before_hy_invocation: Callable[[bool], Awaitable[None]],
) -> SdkWriteReceipt:
if not records:
raise RuntimeError("hy_memory_task_without_inputs")
@@ -138,7 +140,7 @@ class SdkWriteProcessor:
if extracted and not self._lite:
contents = [sanitize_model_input(record.verbatim) for record in records]
await before_hy_invocation()
await before_hy_invocation(True)
result = await _write(
request_id,
client.add_extracted,
@@ -166,7 +168,7 @@ class SdkWriteProcessor:
]
if not messages:
raise RuntimeError("hy_memory_task_without_inputs")
await before_hy_invocation()
await before_hy_invocation(not self._lite)
result = await _write(
request_id,
client.add,
@@ -282,10 +284,8 @@ class SdkWriteCoordinator:
records: tuple[EvidenceRecord, ...],
*,
request_id: str,
before_hy_invocation: Callable[[], Awaitable[None]],
before_hy_invocation: Callable[[bool], Awaitable[None]],
) -> SdkWriteReceipt:
if not self.sedimentation_enabled():
raise RuntimeError("model_sedimentation_disabled")
if not self.writes_open():
# An embedding switch paused writes: the drain puts the job back.
raise RuntimeError("embedding_switch_in_progress")
@@ -296,9 +296,8 @@ class SdkWriteCoordinator:
self._writing[user_id] = self._writing.get(user_id, 0) + 1
try:
async with self._active_handle() as handle:
return await SdkWriteProcessor(
handle, lite=getattr(handle, "mode", None) == "lite"
).process_sdk_job(
lite = getattr(handle, "mode", None) == "lite"
return await SdkWriteProcessor(handle, lite=lite).process_sdk_job(
records,
request_id=request_id,
before_hy_invocation=before_hy_invocation,
@@ -465,24 +464,27 @@ class SdkWriteCoordinator:
return (builtin.profile_id, builtin.revision), builtin, None, space
identity = (space.profile_id, space.profile_revision)
profile = self.profiles.snapshot(*identity)
try:
secret = self.profiles.resolve_snapshot_secret(*identity, self.secrets)
except SecretStoreUnavailableError:
raise RuntimeError("secure_storage_unavailable") from None
if profile.has_api_key and secret is None:
raise RuntimeError("model_secret_unavailable")
return identity, profile, secret, space
view = retrieval_view(profile)
secret: str | None = None
if view.embedding_provider_kind is ProviderKind.OPENAI_COMPATIBLE:
try:
secret = self.profiles.resolve_snapshot_secret(*identity, self.secrets)
except SecretStoreUnavailableError:
raise RuntimeError("secure_storage_unavailable") from None
if secret is None:
raise RuntimeError("model_secret_unavailable")
return identity, view, secret, space
def _read_active_credentials(
self,
mode: str = "pro",
) -> tuple[tuple[str, int], ModelProfileSnapshot, str | None, EmbeddingSpace | None]:
if mode == "lite":
# A lite write is an embedding-only view of the live space. Never
# resolve or inject the generation model endpoint or its secret.
return self._lite_credentials()
identity = self.profiles.active_identity()
if identity is None:
if mode == "lite":
# lite stores raw turns with one embedding and calls no model:
# the live space's own, so they land where reads look.
return self._lite_credentials()
raise RuntimeError("model_profile_not_active")
profile = self.profiles.snapshot(*identity)
space = self._live_space() if self.spaces is not None else None
@@ -568,11 +570,36 @@ def _is_extracted_content(record: EvidenceRecord) -> bool:
)
#: The SDK's stable failure codes are upper snake case (`RECONCILE_FAILED`).
_SDK_FAILURE_CODE = re.compile(r"[A-Z][A-Z0-9_]{0,47}")
def sdk_write_failure_code(value: Mapping[object, object]) -> str:
"""The job's `error_code` for a failed SDK write: the SDK's own cause if it gave one.
hy-memory 2.0.13 names why a write failed: `agent_error_code`
(`LLM_ERROR`, `EMPTY_RESPONSE`, `JSON_PARSE_FAILED`, `EMBEDDING_FAILED`,
`RECONCILE_FAILED`) and, for a failed reconcile, `reconcile_error_code`
(`LLM_UNAVAILABLE`, `EMPTY_RESPONSE`, `JSON_PARSE_FAILED`,
`RECONCILE_EXCEPTION`). They become `hy_sdk_reconcile_empty_response`,
`hy_sdk_llm_error`, ... Without a recognisable code it stays
`hy_sdk_write_failed`. Only the code is kept, never the SDK's message.
"""
reconcile = value.get("reconcile_error_code")
if isinstance(reconcile, str) and _SDK_FAILURE_CODE.fullmatch(reconcile):
cause = reconcile.removeprefix("RECONCILE_") or "FAILED"
return f"hy_sdk_reconcile_{cause.lower()}"
agent = value.get("agent_error_code")
if isinstance(agent, str) and _SDK_FAILURE_CODE.fullmatch(agent):
return f"hy_sdk_{agent.lower()}"
return "hy_sdk_write_failed"
def _receipt_from_result(value: object) -> SdkWriteReceipt:
if not isinstance(value, Mapping):
raise RuntimeError("hy_sdk_write_receipt_shape_invalid")
if value.get("success") is not True:
raise RuntimeError("hy_sdk_write_failed")
raise RuntimeError(sdk_write_failure_code(value))
# Read only documented public receipt fields. ``variant`` identifies the
# productivity namespace; IDs from both variants are canonical memory IDs.
@@ -0,0 +1,283 @@
"""Stable UI projections over the HY SDK's mode-specific storage shapes."""
from __future__ import annotations
import asyncio
import re
from datetime import UTC, datetime
from typing import Any, Literal, cast
from pydantic import BaseModel, ConfigDict, Field
from one_memory.hy_sdk_types import (
HySdkMemoryPage,
HySdkMemoryRecord,
HySdkRawPage,
HySdkRawRecord,
raw_turn_messages,
)
from one_memory.imports.sources.parsing import unwrap_host_envelope_with_positions
MemoryMode = Literal["lite", "pro", "ultra"]
TimeBasis = Literal["conversation", "import", "write", "update"]
ProjectionDisplayState = Literal["empty", "excerpt", "no_user_excerpt", "no_displayable_memory"]
RAW_STATUSES = ("active", "shadow")
HOME_PROJECTION_CANDIDATES = 10
HOME_EXCERPT_CODEPOINTS = 200
_SPACE = re.compile(r"\s+")
_CONTROL_ONLY = re.compile(
r"^(?:"
r"<<?\s*(?:image|attachment|system[_-]?reminder|no[_ -]?content)\s*>>?"
r"|\[\s*(?:image|attachment|system[_ -]?reminder|no[_ -]?content)\s*\]"
r")$",
re.IGNORECASE,
)
class HomeProjectionCapabilities(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
content_plane: Literal["raw", "memory"]
generation_required: bool
graph_enabled: bool
evolve_available: bool
class _HomeLatest(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
time: datetime | None
time_basis: TimeBasis | None
excerpt: str
truncated: bool
source_id: str
class HomeRawLatest(_HomeLatest):
type: Literal["raw"] = "raw"
raw_id: str
session_id: str
display_state: Literal["excerpt", "no_user_excerpt"]
class HomeMemoryLatest(_HomeLatest):
type: Literal["memory"] = "memory"
memory_id: str
scene: Literal["chat", "working", "coding"]
# scene=all lists chat rows and productivity experiences; facts are not
# part of that page or its total.
kind: Literal["chat", "experience"]
variant: Literal["working", "coding"] | None
class HomeProjectionV1(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
projection_version: Literal[1] = 1
user_id: str
effective_mode: MemoryMode
capabilities: HomeProjectionCapabilities
visible_count: int = Field(ge=0)
display_state: ProjectionDisplayState
latest: HomeRawLatest | HomeMemoryLatest | None
observed_at: datetime
def raw_list_filters(statuses: list[str] | tuple[str, ...] | None = None) -> dict[str, list[str]]:
"""The one kept-raw definition shared by `/raw` and the home projection."""
return {
"layers": ["l1_raw"],
"statuses": list(statuses or RAW_STATUSES),
}
def home_capabilities(mode: MemoryMode) -> HomeProjectionCapabilities:
return HomeProjectionCapabilities(
content_plane="raw" if mode == "lite" else "memory",
generation_required=mode != "lite",
graph_enabled=mode == "ultra",
evolve_available=mode == "ultra",
)
def raw_user_excerpt(content: str, *, limit: int = HOME_EXCERPT_CODEPOINTS) -> tuple[str, bool]:
"""Return only displayable user text from one SDK raw turn.
Role selection happens before wrapper removal, so assistant/system content
can never become a homepage excerpt after sanitization.
"""
candidates: list[str] = []
for role, text in raw_turn_messages(content):
if role != "user":
continue
envelope = unwrap_host_envelope_with_positions(text)
if envelope.status == "ambiguous":
# An uncertain host envelope may contain system context. Showing
# nothing is safer than guessing which bytes the person authored.
continue
cleaned = envelope.text or ""
cleaned = _SPACE.sub(" ", cleaned).strip()
if not cleaned or _CONTROL_ONLY.fullmatch(cleaned):
continue
candidates.append(cleaned)
joined = " ".join(candidates)
return joined[:limit], len(joined) > limit
def _epoch(value: int | None) -> datetime | None:
return datetime.fromtimestamp(value, tz=UTC) if value is not None else None
def _raw_time(record: HySdkRawRecord) -> tuple[datetime | None, TimeBasis | None]:
if record.memory_at is not None:
return _epoch(record.memory_at), "conversation"
if record.gmt_created is not None:
return _epoch(record.gmt_created), "import"
return None, None
def _memory_time(record: HySdkMemoryRecord) -> tuple[datetime | None, TimeBasis | None]:
# Exactly the scene=all merge key. `memory_at` is event time and does not
# decide which row list_memories returns first.
if record.sort_updated_at is not None:
return _epoch(record.sort_updated_at), "update"
written = record.gmt_created if record.scene == "chat" else record.created_at
if written is not None:
return _epoch(written), "write"
return None, None
def _bounded(text: str) -> tuple[str, bool]:
flattened = _SPACE.sub(" ", text).strip()
return flattened[:HOME_EXCERPT_CODEPOINTS], len(flattened) > HOME_EXCERPT_CODEPOINTS
def project_raw_latest(records: tuple[HySdkRawRecord, ...]) -> HomeRawLatest | None:
if not records:
return None
for record in records:
excerpt, truncated = raw_user_excerpt(record.content)
if excerpt:
at, basis = _raw_time(record)
return HomeRawLatest(
raw_id=record.raw_id,
session_id=record.session_id,
display_state="excerpt",
time=at,
time_basis=basis,
excerpt=excerpt,
truncated=truncated,
source_id=record.source,
)
newest = records[0]
at, basis = _raw_time(newest)
return HomeRawLatest(
raw_id=newest.raw_id,
session_id=newest.session_id,
display_state="no_user_excerpt",
time=at,
time_basis=basis,
excerpt="",
truncated=False,
source_id=newest.source,
)
def project_memory_latest(
records: tuple[HySdkMemoryRecord, ...],
) -> HomeMemoryLatest | None:
record = next(
(
item
for item in records
if item.kind in {"chat", "experience"}
and (
(item.kind == "chat" and item.scene == "chat" and item.variant is None)
or (
item.kind == "experience"
and item.scene in {"working", "coding"}
and item.variant == item.scene
)
)
),
None,
)
if record is None:
return None
excerpt, truncated = _bounded(record.content)
at, basis = _memory_time(record)
return HomeMemoryLatest(
memory_id=record.memory_id,
scene=record.scene,
kind=cast(Literal["chat", "experience"], record.kind),
variant=record.variant,
time=at,
time_basis=basis,
excerpt=excerpt,
truncated=truncated,
source_id=record.source or "",
)
async def read_home_projection(
client: Any,
*,
user_id: str,
mode: MemoryMode,
) -> HomeProjectionV1:
"""Read one authoritative projection; any SDK/shape failure escapes."""
client_mode = getattr(client, "mode", mode)
if client_mode != mode:
raise RuntimeError("home_projection_mode_mismatch")
if mode == "lite":
raw = await asyncio.to_thread(
client.list_memories,
user_id=user_id,
**raw_list_filters(),
limit=HOME_PROJECTION_CANDIDATES,
offset=0,
order="desc",
)
raw_page = HySdkRawPage.from_memory_list(raw, limit=HOME_PROJECTION_CANDIDATES, offset=0)
visible_count = raw_page.total
raw_latest = project_raw_latest(raw_page.raw)
latest: HomeRawLatest | HomeMemoryLatest | None = raw_latest
display_state: ProjectionDisplayState = (
"empty"
if visible_count == 0
else raw_latest.display_state
if raw_latest is not None
else "no_user_excerpt"
)
else:
raw = await asyncio.to_thread(
client.list_memories,
user_id=user_id,
scene="all",
statuses=["active"],
limit=HOME_PROJECTION_CANDIDATES,
offset=0,
order="desc",
)
memory_page, _skipped = HySdkMemoryPage.from_sdk_skipping_invalid(
raw, limit=HOME_PROJECTION_CANDIDATES, offset=0
)
visible_count = memory_page.total
latest = project_memory_latest(memory_page.memories)
display_state = (
"empty"
if visible_count == 0
else "excerpt"
if latest is not None
else "no_displayable_memory"
)
return HomeProjectionV1(
user_id=user_id,
effective_mode=mode,
capabilities=home_capabilities(mode),
visible_count=visible_count,
display_state=display_state,
latest=latest,
observed_at=datetime.now(UTC),
)
+3 -1
View File
@@ -111,6 +111,7 @@
| `test_health.py` | `contract` | 覆盖 a backfill that fails says so rather than disappearing、a failed catch up leaves a traceback behind、configured model without content consent does not start backfill 等 15 个场景。 |
| `test_hermes_gateway_injection.py` | `contract` | Hermes gateway text in `user` rows: notices are skipped, relayed messages unwrapped. |
| `test_history_listing_rotation.py` | `contract` | 覆盖 history listing alternates started and untouched files、history listing drains whichever cohort is longer 场景。 |
| `test_home_projection.py` | `contract` | 覆盖 memory projection covers pro and ultra、memory projection preserves productivity namespace、project raw latest uses import time without conversation time 等 7 个场景。 |
| `test_host_capabilities.py` | `unit` | A plugin does not become realtime on every host or every unknown version. |
| `test_hourly_c1_peak_replay_contract.py` | `contract` | Guard the package peak-replay probe's error accounting. |
| `test_hourly_c1_recall_stage_probe_contract.py` | `contract` | Guard the packaged recall stage probe's command and error semantics. |
@@ -212,6 +213,7 @@
| `test_onboarding_profile.py` | `integration` | R-09:首次安装引导把画像录下来,不问用户一句话。 |
| `test_oversized_file_guard.py` | `contract` | One pass over a source file is bounded by a byte budget, not skipped. |
| `test_parent_watchdog.py` | `integration` | A Core that outlives its launcher makes the next launch impossible. |
| `test_pinned_sdk_raw_search.py` | `integration` | MemHub's raw search against the hy-memory wheel it pins, not the repo source. |
| `test_policy_api_wiring.py` | `integration` | 覆盖 global model permission off does not backfill、backfill puts history on write jobs without scheduling memory tasks、source pause skips history until the source is reenabled 场景。 |
| `test_policy_production_entry.py` | `contract` | 覆盖 production serve passes only supported app options、maintenance serve does not own the runtime 场景。 |
| `test_precipitation_concurrency_setting.py` | `contract` | Rule 2/9: precipitation concurrency is configurable, not hardcoded to 1. |
@@ -320,4 +322,4 @@
| `test_workspace_recall.py` | `contract` | Workspace memories were unreachable from every host; these pin the way in. |
| `test_write_queue_lease_renewal.py` | `contract` | Rule 8: a lease must outlive the work it covers, and be renewed while it runs. |
共 315 个测试文件。
共 317 个测试文件。
@@ -14,8 +14,8 @@ from pathlib import Path
import pytest
PRIMARY_MARKERS = ("unit", "contract", "integration", "performance", "live")
EXPECTED_TEST_FILE_COUNT = 315
EXPECTED_TEST_FILES_SHA256 = "681258b2a13ca852e273707071b286afe6b0b2fe537f90a8a2c64d83ec662772"
EXPECTED_TEST_FILE_COUNT = 317
EXPECTED_TEST_FILES_SHA256 = "b1370c1ea537ecb3f9837d0a6354e27c8ebe7c588a5b2927044e6ac27532fd26"
# Pure transformations, validation models, parity checks and state machines.
# This list is intentionally small: uncertain files default to contract.
@@ -93,6 +93,7 @@ INTEGRATION_FILES = frozenset(
"test_model_profile_context_reset.py",
"test_onboarding_profile.py",
"test_parent_watchdog.py",
"test_pinned_sdk_raw_search.py",
"test_policy_api_wiring.py",
"test_recall_scope_control.py",
"test_retrieval_lifespan.py",
@@ -263,6 +263,48 @@ def test_a_switch_over_the_api_pauses_writes_and_keeps_intake(tmp_path, backend)
asyncio.run(scenario())
def test_graph_backfill_blocks_starting_an_embedding_switch(tmp_path, backend):
backend.store.put(
builtin_base(),
512,
{"m1": ("memory", "local")},
model="bge-small-zh-v1.5",
)
async def scenario():
app = application(tmp_path)
async with app.router.lifespan_context(app), client_for(app) as client:
await recovered(client)
profile = remote_profile(app)
plan = (
await client.post(
"/api/v1/embedding-switches/plan",
json={"profile_id": profile.profile_id},
)
).json()
hold = asyncio.Event()
task = asyncio.create_task(hold.wait())
app.state.memory_graph_backfill._task = task
try:
response = await client.post(
"/api/v1/embedding-switches",
json={
"profile_id": profile.profile_id,
"expected_revision": profile.revision,
"plan_token": plan["plan_token"],
},
)
assert response.status_code == 409
assert response.json()["detail"] == "memory_lifecycle_busy"
assert app.state.embedding_switch.writes_open() is True
finally:
task.cancel()
await asyncio.gather(task, return_exceptions=True)
app.state.memory_graph_backfill._task = None
asyncio.run(scenario())
def test_cancel_over_the_api(tmp_path, backend):
backend.store.put(builtin_base(), 512, {f"m{i}": (f"m {i}", "local") for i in range(4)})
@@ -0,0 +1,397 @@
from __future__ import annotations
import asyncio
import pytest
from fastapi.testclient import TestClient
from pydantic import SecretStr
from one_memory.api import create_app
from one_memory.config import Settings
from one_memory.hy_sdk_types import HySdkReadError
from one_memory.ui_projection import (
HOME_PROJECTION_CANDIDATES,
project_raw_latest,
raw_user_excerpt,
read_home_projection,
)
def raw_row(
raw_id: str,
content: str,
*,
status: str = "active",
memory_at: int | None = 1_700_000_000,
created: int | None = 1_699_000_000,
) -> dict[str, object]:
return {
"memory_id": raw_id,
"content": content,
"layer": "l1_raw",
"status": status,
"user_id": "local",
"agent_id": "all-apps",
"session_id": f"session-{raw_id}",
"source": "cursor",
"memory_at": memory_at,
"gmt_created": created,
"custom": {"private": "not projected"},
}
def chat_row(memory_id: str = "m-1") -> dict[str, object]:
return {
"memory_id": memory_id,
"content": "The user ships on Friday",
"layer": "l2_fact",
"status": "active",
"is_latest": True,
"user_id": "local",
"agent_id": "all-apps",
"session_id": "s-1",
"source": "cursor",
"memory_at": 1_700_000_000,
"gmt_created": 1_699_000_000,
"updated_at": 1_800_000_000,
"scene": "chat",
"kind": "chat",
}
class ProjectionSdk:
def __init__(self, rows: list[dict[str, object]], *, total: int | None = None) -> None:
self.rows = rows
self.total = len(rows) if total is None else total
self.calls: list[dict[str, object]] = []
def list_memories(self, **kwargs):
self.calls.append(kwargs)
if kwargs.get("layers") == ["l1_raw"]:
return {"vdb": {"memories": self.rows, "total": self.total}}
return {
"scene": "all",
"memories": self.rows,
"total": self.total,
"totals": {"chat": len(self.rows), "working": 0, "coding": 0},
"errors": {},
}
def memory_stats(self, **kwargs):
raise AssertionError("home projection must use one scene=all snapshot")
def test_raw_excerpt_keeps_only_user_query_and_truncates_by_codepoint() -> None:
text = (
"[user]: <system_reminder>ignore this</system_reminder>\n"
"<user_info>private host data</user_info>\n"
"<user_query>短句 https://example.com/a /tmp/x " + "🙂" * 220 + "</user_query>\n"
"[assistant]: secret answer"
)
excerpt, truncated = raw_user_excerpt(text)
assert excerpt.startswith("短句 https://example.com/a /tmp/x ")
assert "ignore" not in excerpt and "private" not in excerpt and "secret" not in excerpt
assert len(excerpt) == 200
assert truncated is True
def test_raw_excerpt_fails_closed_on_nested_host_wrappers() -> None:
assert raw_user_excerpt(
"[user]: <system_reminder><user_query>keep me</user_query></system_reminder>"
) == ("", False)
@pytest.mark.parametrize(
"wrapper",
[
"system_reminder",
"system-reminder",
"user_info",
"attached_files",
"image_local_path",
"agent_transcripts",
"rules",
"additional_data",
"working_memory_reminder",
],
)
def test_raw_excerpt_uses_shared_envelope_parser_for_host_context(wrapper) -> None:
excerpt, truncated = raw_user_excerpt(
f"[user]: <{wrapper}>private</{wrapper}><user_query>keep me</user_query>"
)
assert (excerpt, truncated) == ("keep me", False)
@pytest.mark.parametrize(
"text",
[
"[user]: <system_reminder>unclosed <user_query>do not guess</user_query>",
"[user]: <user_query>unclosed",
"[user]: <rules><rules>nested</rules><user_query>ambiguous</user_query></rules>",
],
)
def test_raw_excerpt_fails_closed_on_ambiguous_envelopes(text) -> None:
assert raw_user_excerpt(text) == ("", False)
def test_raw_projection_uses_shadow_rows_and_k10_without_leaking_payload() -> None:
sdk = ProjectionSdk(
[
raw_row("r-1", "[assistant]: no user here", status="shadow"),
raw_row("r-2", "[user]: deploy Friday", status="active"),
],
total=42,
)
result = asyncio.run(read_home_projection(sdk, user_id="local", mode="lite"))
assert result.visible_count == 42
assert result.latest is not None and result.latest.type == "raw"
assert result.latest.raw_id == "r-2"
assert result.latest.excerpt == "deploy Friday"
assert result.latest.time_basis == "conversation"
assert result.display_state == "excerpt"
assert sdk.calls == [
{
"user_id": "local",
"layers": ["l1_raw"],
"statuses": ["active", "shadow"],
"limit": HOME_PROJECTION_CANDIDATES,
"offset": 0,
"order": "desc",
}
]
assert set(result.latest.model_dump()) == {
"type",
"raw_id",
"session_id",
"display_state",
"time",
"time_basis",
"excerpt",
"truncated",
"source_id",
}
serialized = result.model_dump(mode="json")
assert serialized["projection_version"] == 1
assert serialized["latest"]["time"].endswith("Z")
assert "content" not in serialized["latest"]
def test_raw_projection_reports_no_user_excerpt_when_k_has_none() -> None:
sdk = ProjectionSdk([raw_row("r-1", "[assistant]: answer")])
result = asyncio.run(read_home_projection(sdk, user_id="local", mode="lite"))
assert result.latest is not None and result.latest.type == "raw"
assert result.latest.raw_id == "r-1"
assert result.latest.display_state == "no_user_excerpt"
assert result.latest.excerpt == ""
@pytest.mark.parametrize(
("mode", "graph", "evolve"),
[("pro", False, False), ("ultra", True, True)],
)
def test_memory_projection_covers_pro_and_ultra(mode, graph, evolve) -> None:
sdk = ProjectionSdk([chat_row()], total=9)
result = asyncio.run(read_home_projection(sdk, user_id="local", mode=mode))
assert result.visible_count == 9
assert result.display_state == "excerpt"
assert result.capabilities.content_plane == "memory"
assert result.capabilities.graph_enabled is graph
assert result.capabilities.evolve_available is evolve
assert result.latest is not None and result.latest.type == "memory"
assert result.latest.memory_id == "m-1"
assert result.latest.time_basis == "update"
assert result.latest.time.isoformat() == "2027-01-15T08:00:00+00:00"
def test_chat_projection_prefers_gmt_modified_when_the_sdk_supplies_it() -> None:
row = {**chat_row(), "gmt_modified": 1_810_000_000}
result = asyncio.run(read_home_projection(ProjectionSdk([row]), user_id="local", mode="pro"))
assert result.latest is not None
assert result.latest.time_basis == "update"
assert result.latest.time.timestamp() == 1_810_000_000
def test_memory_projection_uses_productivity_updated_at() -> None:
row = {
"kind": "experience",
"experience_id": "e-1",
"task": "Release",
"solution": "Run tests",
"scene": "working",
"status": "active",
"user_id": "local",
"agent_id": "all-apps",
"source": "manual",
"memory_at": 1_600_000_000,
"created_at": 1_700_000_000,
"updated_at": 1_710_000_000,
}
result = asyncio.run(read_home_projection(ProjectionSdk([row]), user_id="local", mode="pro"))
assert result.latest is not None and result.latest.type == "memory"
assert (result.latest.scene, result.latest.kind, result.latest.variant) == (
"working",
"experience",
"working",
)
assert result.latest.time_basis == "update"
assert result.latest.time.isoformat() == "2024-03-09T16:00:00+00:00"
@pytest.mark.parametrize(
"row",
[
{**chat_row(), "updated_at": None},
{
"kind": "experience",
"experience_id": "e-1",
"task": "Release",
"solution": "Run tests",
"scene": "coding",
"status": "active",
"user_id": "local",
"agent_id": "all-apps",
"source": "manual",
"created_at": 1_700_000_000,
"updated_at": None,
},
],
)
def test_memory_projection_sort_time_falls_back_to_scene_creation(row) -> None:
result = asyncio.run(read_home_projection(ProjectionSdk([row]), user_id="local", mode="pro"))
assert result.latest is not None
assert result.latest.time_basis == "write"
expected = row.get("gmt_created") or row.get("created_at")
assert result.latest.time.timestamp() == expected
def test_fact_is_not_a_home_scene_all_item() -> None:
row = {
"kind": "fact",
"fact_id": "f-1",
"statement": "Uses pytest",
"scene": "coding",
"status": "active",
"user_id": "local",
"agent_id": "all-apps",
"source": "manual",
"created_at": 1_700_000_000,
}
result = asyncio.run(read_home_projection(ProjectionSdk([row]), user_id="local", mode="pro"))
assert result.visible_count == 1
assert result.latest is None
assert result.display_state == "no_displayable_memory"
def test_invalid_latest_uses_next_parseable_candidate_from_same_snapshot() -> None:
sdk = ProjectionSdk(
[
{"memory_id": "broken", "content": "", "scene": "chat", "kind": "chat"},
chat_row("m-2"),
],
total=2,
)
result = asyncio.run(read_home_projection(sdk, user_id="local", mode="pro"))
assert result.visible_count == 2
assert result.latest is not None and result.latest.memory_id == "m-2"
assert len(sdk.calls) == 1
def test_scene_all_rejects_boolean_total() -> None:
sdk = ProjectionSdk([chat_row()])
sdk.total = True # type: ignore[assignment]
with pytest.raises(HySdkReadError):
asyncio.run(read_home_projection(sdk, user_id="local", mode="pro"))
def test_projection_read_failure_is_not_converted_to_an_empty_projection() -> None:
class Broken:
def list_memories(self, **kwargs):
raise OSError("down")
with pytest.raises(OSError, match="down"):
asyncio.run(read_home_projection(Broken(), user_id="local", mode="lite"))
def test_projection_refuses_a_client_built_for_another_mode() -> None:
sdk = ProjectionSdk([chat_row()])
sdk.mode = "lite"
with pytest.raises(RuntimeError, match="home_projection_mode_mismatch"):
asyncio.run(read_home_projection(sdk, user_id="local", mode="pro"))
def test_home_projection_route_maps_whole_read_failure_to_503(tmp_path, monkeypatch) -> None:
class Broken:
def list_memories(self, **kwargs):
raise OSError("down")
class Handle:
async def client(self):
return Broken()
app = create_app(
Settings(
_env_file=None,
data_dir=tmp_path / "data",
source_home=tmp_path,
api_token=SecretStr("test"),
imports_enabled=False,
automatic_memory_processing=False,
live_sync_enabled=False,
),
source_catalog={},
)
monkeypatch.setattr("one_memory.api._sdk_client_handle", lambda _app: Handle())
with TestClient(app, headers={"Authorization": "Bearer test"}) as client:
response = client.get("/api/v2/home-projection")
assert response.status_code == 503
assert response.json() == {"detail": "home_projection_read_failed"}
def test_home_projection_reads_mode_once_and_matches_the_client(tmp_path, monkeypatch) -> None:
sdk = ProjectionSdk([chat_row()])
sdk.mode = "pro"
class Handle:
async def client(self):
return sdk
app = create_app(
Settings(
_env_file=None,
data_dir=tmp_path / "data",
source_home=tmp_path,
api_token=SecretStr("test"),
imports_enabled=False,
automatic_memory_processing=False,
live_sync_enabled=False,
),
source_catalog={},
)
reads = 0
def mode(_app):
nonlocal reads
reads += 1
return "pro"
with TestClient(app, headers={"Authorization": "Bearer test"}) as client:
monkeypatch.setattr("one_memory.api._memory_mode", mode)
monkeypatch.setattr("one_memory.api._sdk_client_handle", lambda _app: Handle())
response = client.get("/api/v2/home-projection")
assert response.status_code == 200, response.text
assert response.json()["effective_mode"] == "pro"
assert reads == 1
def test_project_raw_latest_uses_import_time_without_conversation_time() -> None:
from one_memory.hy_sdk_types import HySdkRawRecord
record = HySdkRawRecord.from_sdk(
{
**raw_row("r-1", "[user]: hello", memory_at=None),
"raw_id": "r-1",
}
)
latest = project_raw_latest((record,))
assert latest is not None
assert latest.time_basis == "import"
+1 -1
View File
@@ -298,7 +298,7 @@ def test_hook_job_completes_after_file_identity_adoption(tmp_path) -> None:
assert current is not None
assert ingest_journal.matches_current(connection, current)
connection.rollback()
await before_hy_invocation()
await before_hy_invocation(True)
return SdkWriteReceipt()
drain = DurableWriteDrain(queue, execute=lambda _job: None)
@@ -524,6 +524,11 @@ def test_features_api_get_put_reset_and_status(tmp_path, monkeypatch) -> None:
"episode_recall": True,
}
monkeypatch.setattr(
app.state.model_profile_store,
"active_identity",
lambda: ("active-profile", 1),
)
await client.put("/api/v1/settings/memory-mode", json={"mode": "ultra"})
await app.state.memory_graph_backfill.wait()
ultra = _by_name((await client.get("/api/v1/settings/memory-features")).json())
@@ -41,6 +41,8 @@ from one_memory.memory_evolution import (
EvolveScheduler,
EvolveService,
EvolveStateStore,
GraphBuildState,
GraphBuildUser,
MemoryGraphBackfill,
evolve_due,
)
@@ -58,8 +60,10 @@ def test_mode_defaults_to_pro_and_the_env_still_forces_ultra(tmp_path, monkeypat
monkeypatch.delenv("ONE_MEMORY_GRAPH_STORE", raising=False)
store = ConfigStore(tmp_path)
assert store.memory_mode() == "pro"
assert store.memory_mode_explicitly_configured() is False
assert effective_memory_mode(store.memory_mode()) == "pro"
store.set_memory_mode("lite")
assert store.memory_mode_explicitly_configured() is True
assert ConfigStore(tmp_path).memory_mode() == "lite"
assert json.loads((tmp_path / "config.json").read_text())["memory_mode"] == "lite"
monkeypatch.setenv("ONE_MEMORY_GRAPH_STORE", "kuzu")
@@ -70,6 +74,21 @@ def test_mode_defaults_to_pro_and_the_env_still_forces_ultra(tmp_path, monkeypat
store.set_memory_mode("max") # type: ignore[arg-type]
def test_old_config_defaults_to_implicit_pro_until_pro_is_explicitly_set(tmp_path) -> None:
(tmp_path / "config.json").write_text(
json.dumps({"schema_version": 1, "sources": {}}), encoding="utf-8"
)
store = ConfigStore(tmp_path)
assert store.memory_mode() == "pro"
assert store.memory_mode_explicitly_configured() is False
store.set_memory_mode("pro")
assert store.memory_mode_explicitly_configured() is True
assert json.loads((tmp_path / "config.json").read_text())["memory_mode"] == "pro"
assert ConfigStore(tmp_path).memory_mode_explicitly_configured() is True
def test_an_untouched_install_writes_no_new_settings(tmp_path) -> None:
store = ConfigStore(tmp_path)
store.set_sync("cursor", True)
@@ -158,6 +177,37 @@ def test_the_read_handle_rebuilds_on_a_mode_or_profile_change(monkeypatch, tmp_p
asyncio.run(scenario())
def test_read_handle_can_bind_one_caller_mode_snapshot(monkeypatch, tmp_path) -> None:
from one_memory import sdk_read_handle
callback_reads = 0
def current_mode() -> str:
nonlocal callback_reads
callback_reads += 1
return "pro"
monkeypatch.setattr(
sdk_read_handle,
"SdkClientHandle",
lambda profile, **kwargs: _FakeHandle(kwargs.get("mode") or "pro", profile.profile_id),
)
reads = sdk_read_handle.ActiveProfileReadHandle(
profiles=_Profiles(identity=None), # type: ignore[arg-type]
secrets=None, # type: ignore[arg-type]
data_dir=tmp_path,
memory_mode=current_mode,
)
async def scenario():
client = await reads.client_for_mode("lite")
assert client.mode == "lite"
await reads.close()
asyncio.run(scenario())
assert callback_reads == 0
class _FakeHandle:
def __init__(self, mode: str, profile_id: str = "p") -> None:
self.mode = mode
@@ -288,7 +338,7 @@ class _SdkHandle:
return None
async def _noop() -> None:
async def _noop(_requires_generation: bool) -> None:
return None
@@ -311,14 +361,14 @@ def test_a_note_in_lite_is_stored_as_raw_and_never_calls_add_extracted() -> None
]
def test_a_lite_write_runs_through_the_coordinator_without_a_model(monkeypatch) -> None:
def test_a_lite_write_ignores_the_model_sedimentation_pause(monkeypatch) -> None:
sdk = _WritingSdk()
coordinator = sdk_write_processor.SdkWriteCoordinator(
profiles=_Profiles(identity=None), # type: ignore[arg-type]
secrets=None, # type: ignore[arg-type]
data_dir=None,
transport_factory=lambda _profile: None,
sedimentation_enabled=lambda: True,
sedimentation_enabled=lambda: False,
memory_mode=lambda: "lite",
)
monkeypatch.setattr(
@@ -335,6 +385,37 @@ def test_a_lite_write_runs_through_the_coordinator_without_a_model(monkeypatch)
assert [name for name, _ in sdk.calls] == ["add"]
def test_a_lite_write_ignores_the_model_sedimentation_switch(monkeypatch) -> None:
sdk = _WritingSdk()
coordinator = sdk_write_processor.SdkWriteCoordinator(
profiles=_Profiles(identity=None), # type: ignore[arg-type]
secrets=None, # type: ignore[arg-type]
data_dir=None,
transport_factory=lambda _profile: None,
sedimentation_enabled=lambda: False,
memory_mode=lambda: "lite",
)
monkeypatch.setattr(
sdk_write_processor, "SdkClientHandle", lambda profile, **kwargs: _SdkHandle(sdk, "lite")
)
admitted = 0
async def admit(_requires_generation: bool) -> None:
nonlocal admitted
admitted += 1
receipt = asyncio.run(
coordinator.process_sdk_job(
(_record("still stored as raw", extracted=False),),
request_id="r-off-lite",
before_hy_invocation=admit,
)
)
assert receipt.raw_ids == ("raw-1",)
assert admitted == 1
assert [name for name, _ in sdk.calls] == ["add"]
def test_no_raw_is_ever_deleted_in_any_mode() -> None:
"""Owner decision: MemHub keeps every raw; there is no retention window."""
from one_memory import sdk_memory_runtime
@@ -624,6 +705,38 @@ def test_the_scheduler_runs_each_due_user_once_in_ultra_only(tmp_path, monkeypat
assert state.user("wife").last_result["trigger"] == "scheduled"
def test_scheduled_evolve_obeys_the_shared_lifecycle_gate(tmp_path, monkeypatch) -> None:
sdk = _EvolveSdk(continuing=0)
state = EvolveStateStore(tmp_path)
state.anchor(_at(1, 12))
open_gate = [False]
service = EvolveService(
coordinator=_Coordinator(sdk, monkeypatch),
state=state,
memory_mode=lambda: "ultra",
now=lambda: _at(2, 3, 2),
)
async def users():
return ("local",)
async def scenario():
scheduler = EvolveScheduler(
service=service,
schedule=EvolveSchedule,
users_with_memories=users,
lifecycle_lock=asyncio.Lock(),
lifecycle_open=lambda: open_gate[0],
now=lambda: _at(2, 3, 2),
)
assert await scheduler.tick() == ()
assert sdk.calls == []
open_gate[0] = True
assert await scheduler.tick() == ("local",)
asyncio.run(scenario())
def test_the_graph_backfill_runs_once_per_user(tmp_path, monkeypatch) -> None:
sdk = _EvolveSdk()
state = EvolveStateStore(tmp_path)
@@ -643,6 +756,63 @@ def test_the_graph_backfill_runs_once_per_user(tmp_path, monkeypatch) -> None:
}
def test_an_interrupted_graph_backfill_resumes_only_unfinished_users(tmp_path, monkeypatch) -> None:
sdk = _EvolveSdk()
state = EvolveStateStore(tmp_path)
state.set_graph_build(
GraphBuildState(
state="running",
started_at=_at(1, 3),
users={
"local": GraphBuildUser(state="succeeded", memories_scanned=4, nodes=4),
"wife": GraphBuildUser(state="running"),
},
)
)
backfill = MemoryGraphBackfill(coordinator=_Coordinator(sdk, monkeypatch), state=state)
async def scenario():
resumed = backfill.recover(["local", "wife"])
assert resumed.state == "running"
assert resumed.users["local"].state == "succeeded"
assert resumed.users["wife"].state == "pending"
await backfill.wait()
asyncio.run(scenario())
assert sdk.graph_builds == ["wife"]
assert state.graph_build().state == "succeeded"
def test_graph_backfill_waits_for_embedding_maintenance_to_finish(tmp_path, monkeypatch) -> None:
sdk = _EvolveSdk()
state = EvolveStateStore(tmp_path)
gate = [False]
checked = asyncio.Event()
def lifecycle_open() -> bool:
checked.set()
return gate[0]
backfill = MemoryGraphBackfill(
coordinator=_Coordinator(sdk, monkeypatch),
state=state,
lifecycle_open=lifecycle_open,
)
async def scenario():
backfill.start(["local"])
await checked.wait()
assert sdk.graph_builds == []
assert state.graph_build().state == "running"
assert state.graph_build().users["local"].state == "pending"
gate[0] = True
await backfill.wait()
asyncio.run(scenario())
assert sdk.graph_builds == ["local"]
assert state.graph_build().state == "succeeded"
# --- the HTTP surface -----------------------------------------------------------
@@ -701,23 +871,38 @@ def test_mode_api_get_put_and_its_effects(tmp_path, monkeypatch) -> None:
async def scenario(app, sdk, client):
current = (await client.get("/api/v1/settings/memory-mode")).json()
assert current["mode"] == current["configured_mode"] == "pro"
assert current["explicitly_configured"] is False
assert current["forced_by_env"] is False
assert app.state.sdk_write_coordinator.memory_mode() == "pro"
assert (
await client.put("/api/v1/sedimentation", json={"enabled": False})
).status_code == 200
from one_memory.api import _sedimentation_claim_enabled
app.state.history_import_gate_released = True
assert not _sedimentation_claim_enabled(app, "one-memory-manual")
app.state.config_store.set_sedimentation_enabled(False)
lite = await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"})
assert lite.status_code == 200, lite.text
body = lite.json()
assert body["mode"] == "lite" and body["previous_mode"] == "pro"
assert body["explicitly_configured"] is True
assert body["effects"] == ["higher_mode_memories_hidden"]
resume = getattr(app.state, "sedimentation_resume", None)
assert resume is not None
await asyncio.wait_for(asyncio.shield(resume), 5)
assert app.state.sdk_write_coordinator.memory_mode() == "lite"
assert _sdk_client_handle(app).memory_mode() == "lite"
# lite claims queued work without a generation model.
from one_memory.api import _sedimentation_claim_enabled
assert app.state.model_profile_store.active_identity() is None
app.state.history_import_gate_released = True
assert _sedimentation_claim_enabled(app, "one-memory-manual")
monkeypatch.setattr(
app.state.model_profile_store,
"active_identity",
lambda: ("active-profile", 1),
)
ultra = await client.put("/api/v1/settings/memory-mode", json={"mode": "ultra"})
assert ultra.status_code == 200, ultra.text
assert "existing_memories_added_to_graph" in ultra.json()["effects"]
@@ -741,6 +926,173 @@ def test_mode_api_get_put_and_its_effects(tmp_path, monkeypatch) -> None:
run_app(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_mode_plan_and_put_share_validation_and_effects(tmp_path, monkeypatch) -> None:
async def scenario(app, _sdk, client):
plan = await client.post(
"/api/v1/settings/memory-mode/plan",
json={"mode": "lite"},
)
assert plan.status_code == 200
body = plan.json()
assert body == {
"current": "pro",
"target": "lite",
"effective_target": "lite",
"capabilities": {
"content_plane": "raw",
"generation_required": False,
"graph_enabled": False,
"evolve_available": False,
},
"effects": ["higher_mode_memories_hidden"],
"warnings": [],
"blockers": [],
}
changed = await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"})
assert changed.status_code == 200
assert changed.json()["effects"] == body["effects"]
missing_model = (
await client.post(
"/api/v1/settings/memory-mode/plan",
json={"mode": "pro"},
)
).json()
assert missing_model["blockers"] == ["active_model_required"]
assert missing_model["effects"] == []
refused = await client.put("/api/v1/settings/memory-mode", json={"mode": "pro"})
assert refused.status_code == 409
assert refused.json()["detail"] == missing_model["blockers"][0]
monkeypatch.setenv("ONE_MEMORY_GRAPH_STORE", "kuzu")
forced = (
await client.post(
"/api/v1/settings/memory-mode/plan",
json={"mode": "lite"},
)
).json()
assert forced["effective_target"] == "ultra"
assert forced["warnings"] == ["forced_by_env", "effective_no_change"]
run_app(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_mode_plan_reports_all_blockers_without_planned_effects(tmp_path, monkeypatch) -> None:
async def scenario(app, _sdk, client):
changed = await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"})
assert changed.status_code == 200
switch = app.state.embedding_switch
switch._scan_hold = True
hold = asyncio.Event()
task = asyncio.create_task(hold.wait())
app.state.memory_graph_backfill._task = task
monkeypatch.setattr("one_memory.api.importlib.util.find_spec", lambda _name: None)
try:
plan = (
await client.post(
"/api/v1/settings/memory-mode/plan",
json={"mode": "ultra"},
)
).json()
assert plan["blockers"] == [
"embedding_maintenance_in_progress",
"active_model_required",
"memory_lifecycle_busy",
"graph_store_unavailable",
]
assert plan["effects"] == []
finally:
switch._scan_hold = False
task.cancel()
await asyncio.gather(task, return_exceptions=True)
app.state.memory_graph_backfill._task = None
run_app(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_lite_startup_readmits_jobs_while_model_sedimentation_is_off(tmp_path, monkeypatch) -> None:
store = ConfigStore(tmp_path / "data")
store.set_memory_mode("lite")
store.set_sedimentation_enabled(False)
resumed = asyncio.Event()
original = SdkMemoryRuntime.resume_allowed
async def resume_allowed(runtime):
await original(runtime)
resumed.set()
monkeypatch.setattr(SdkMemoryRuntime, "resume_allowed", resume_allowed)
async def scenario(app, _sdk, _client):
await asyncio.wait_for(resumed.wait(), 5)
assert app.state.config_store.sedimentation_enabled() is False
assert app.state.config_store.memory_mode() == "lite"
run_app(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_embedding_maintenance_blocks_mode_put_and_evolve_but_not_mode_get(
tmp_path, monkeypatch
) -> None:
async def scenario(app, _sdk, client):
switch = app.state.embedding_switch
switch._scan_hold = True
current = await client.get("/api/v1/settings/memory-mode")
assert current.status_code == 200
assert current.json()["explicitly_configured"] is False
for response in (
await client.put("/api/v1/settings/memory-mode", json={"mode": "pro"}),
await client.post("/api/v2/evolve", json={}),
):
assert response.status_code == 409
assert response.json()["detail"] == "embedding_maintenance_in_progress"
assert app.state.config_store.memory_mode_explicitly_configured() is False
switch._scan_hold = False
switch._open_cache = True
assert (await client.get("/api/v1/settings/memory-mode")).status_code == 200
for response in (
await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"}),
await client.post("/api/v2/evolve", json={}),
):
assert response.status_code == 409
assert response.json()["detail"] == "embedding_maintenance_in_progress"
switch._open_cache = False
explicitly_pro = await client.put("/api/v1/settings/memory-mode", json={"mode": "pro"})
assert explicitly_pro.status_code == 200
assert explicitly_pro.json()["explicitly_configured"] is True
assert explicitly_pro.json()["effects"] == []
run_app(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_graph_backfill_blocks_mode_change_and_manual_evolve(tmp_path, monkeypatch) -> None:
async def scenario(app, _sdk, client):
hold = asyncio.Event()
task = asyncio.create_task(hold.wait())
app.state.memory_graph_backfill._task = task
try:
for response in (
await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"}),
await client.post("/api/v2/evolve", json={}),
):
assert response.status_code == 409
assert response.json()["detail"] == "memory_lifecycle_busy"
finally:
task.cancel()
await asyncio.gather(task, return_exceptions=True)
app.state.memory_graph_backfill._task = None
run_app(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_evolve_api_is_ultra_only_async_and_scoped_per_user(tmp_path, monkeypatch) -> None:
async def scenario(app, sdk, client):
@@ -748,7 +1100,13 @@ def test_evolve_api_is_ultra_only_async_and_scoped_per_user(tmp_path, monkeypatc
assert refused.status_code == 409
assert refused.json()["detail"] == "memory_mode_not_ultra"
await client.put("/api/v1/settings/memory-mode", json={"mode": "ultra"})
monkeypatch.setattr(
app.state.model_profile_store,
"active_identity",
lambda: ("active-profile", 1),
)
changed = await client.put("/api/v1/settings/memory-mode", json={"mode": "ultra"})
assert changed.status_code == 200, changed.text
await app.state.memory_graph_backfill.wait()
started = await client.post(
"/api/v2/evolve", json={"user_id": "wife", "agent_id": "all-apps", "max_batches": 2}
@@ -865,3 +1223,87 @@ def test_a_job_written_in_lite_stays_done_and_a_pending_job_takes_the_new_mode(
await coordinator.close()
run_app(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_paused_model_still_writes_a_lite_job_through_the_durable_boundary(
tmp_path, monkeypatch
) -> None:
from one_memory.api import _admit_pipeline_invocation
from one_memory.memory_server.durable_queue import DurableWriteDrain, execute_job
seen: list[str] = []
class Sdk:
mode = "lite"
def add(self, messages, **kwargs):
seen.append("add")
return {"success": True, "raw_ids": ["raw-lite"]}
def add_extracted(self, contents, **kwargs):
seen.append("add_extracted")
raise AssertionError("lite must never call add_extracted")
monkeypatch.setattr(
sdk_write_processor,
"SdkClientHandle",
lambda profile, **kwargs: _SdkHandle(Sdk(), "lite"),
)
coordinator = sdk_write_processor.SdkWriteCoordinator(
profiles=_Profiles(identity=None), # type: ignore[arg-type]
secrets=None, # type: ignore[arg-type]
data_dir=None,
transport_factory=lambda _profile: None,
sedimentation_enabled=lambda: False,
memory_mode=lambda: "lite",
)
async def never(_job_id):
return None
async def scenario(app, _sdk, client):
app.state.memory_write_drain.stop()
if app.state.memory_write_drain_task is not None:
await app.state.memory_write_drain_task
app.state.memory_write_drain_task = None
await client.put("/api/v1/sedimentation", json={"enabled": False})
await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"})
app.state.history_import_gate_released = True
written = await client.post(
"/api/v2/memory-writes",
json={
"request_id": "paused-lite-turn",
"session_id": "paused-lite-session",
"source_id": "one-memory-manual",
"messages": [
{"role": "user", "content": "保留原始会话"},
{"role": "assistant", "content": "已保留"},
],
},
)
assert written.status_code == 202
queue = app.state.memory_write_queue
drain = DurableWriteDrain(queue, execute=never, lease=timedelta(seconds=30))
[job_id] = drain.claim(limit=1)
await execute_job(
job_id,
queue=queue,
supervisor=coordinator,
evidence=None, # type: ignore[arg-type]
admit_hy_invocation=lambda source_id, requires_generation, mark: (
_admit_pipeline_invocation(
app,
source_id,
requires_generation,
mark,
)
),
)
current = queue.get("paused-lite-turn")
assert current is not None and current.state == "completed"
assert current.sdk_raw_ids == ("raw-lite",)
assert seen == ["add"]
await coordinator.close()
run_app(tmp_path, monkeypatch, scenario)
@@ -832,7 +832,7 @@ class _Recorder:
async def process_sdk_job(self, records, **_kwargs):
self.seen.append(tuple(record.evidence_id for record in records))
if self.invoke_hy:
await _kwargs["before_hy_invocation"]()
await _kwargs["before_hy_invocation"](True)
if self.error is not None:
raise self.error
return type("Receipt", (), {"raw_ids": (), "memory_ids": ()})()
@@ -844,10 +844,10 @@ class _TwoInvocationRecorder:
self.invocations = 0
async def process_sdk_job(self, _records, **kwargs):
await kwargs["before_hy_invocation"]()
await kwargs["before_hy_invocation"](True)
self.invocations += 1
self.pause_after_first()
await kwargs["before_hy_invocation"]()
await kwargs["before_hy_invocation"](True)
self.invocations += 1
return type("Receipt", (), {"raw_ids": (), "memory_ids": ()})()
@@ -916,7 +916,7 @@ def test_execute_job_persists_sdk_ids_returned_after_the_single_add(tmp_path) ->
@classmethod
async def process_sdk_job(cls, _records, **kwargs):
cls.calls += 1
await kwargs["before_hy_invocation"]()
await kwargs["before_hy_invocation"](True)
return type(
"Result",
(),
@@ -1045,7 +1045,7 @@ def test_sdk_write_processor_calls_add_once_with_public_scope_and_receipt() -> N
)
async def run() -> SdkWriteReceipt:
async def before() -> None:
async def before(_requires_generation: bool) -> None:
client.events.append("before")
return await processor.process_sdk_job(
@@ -1105,7 +1105,7 @@ def test_sdk_write_processor_uses_add_extracted_for_memory_content_and_additions
processor = SdkWriteProcessor(_SdkPublicHandle(client)) # type: ignore[arg-type]
async def run() -> SdkWriteReceipt:
async def before() -> None:
async def before(_requires_generation: bool) -> None:
client.events.append("before")
return await processor.process_sdk_job(
@@ -1260,7 +1260,7 @@ def test_a_profile_change_does_not_close_the_client_of_a_write_in_flight(monkeyp
async def process(self, records, *, request_id, before_hy_invocation):
client = await self._client_handle.client()
await before_hy_invocation()
await before_hy_invocation(getattr(client, "mode", None) != "lite")
seen.append((client.profile_id, client.closed))
if client.profile_id == "profile-a":
await writing.wait()
@@ -1270,7 +1270,7 @@ def test_a_profile_change_does_not_close_the_client_of_a_write_in_flight(monkeyp
monkeypatch.setattr(sdk_write_processor.SdkWriteProcessor, "process_sdk_job", process)
async def noop():
async def noop(_requires_generation: bool):
return None
async def scenario():
@@ -1728,7 +1728,7 @@ def test_a_failed_job_logs_where_it_failed_but_never_the_message(tmp_path, caplo
class _Failing:
async def process_sdk_job(self, _records, **kwargs):
await kwargs["before_hy_invocation"]()
await kwargs["before_hy_invocation"](True)
try:
closed_store(type("Client", (), {"private_store_name": None})())
except AttributeError as error:
@@ -4138,6 +4138,71 @@ def test_a_failing_renewal_does_not_kill_the_drain(tmp_path) -> None:
assert queue.get("idem-1").state == COMPLETED
def test_a_locked_fallback_settlement_does_not_kill_the_drain(tmp_path, monkeypatch) -> None:
"""The incident shape: executor and its fallback both lose the writer.
The first row keeps its lease for normal reclaim; an unrelated row must
still run instead of being stranded behind a dead drain task.
"""
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
first = queue.submit(_request(key="first", source="cursor"))
second = queue.submit(_request(key="second", source="codex"))
executed: list[str] = []
async def execute(job_id: str) -> None:
executed.append(job_id)
if job_id == first.job_id:
raise RuntimeError("executor failed")
queue.settle(job_id, state=COMPLETED)
drain.stop()
drain = DurableWriteDrain(queue, execute=execute, max_concurrent_sources=1)
def locked_settlement(*_args, **_kwargs):
raise sqlite3.OperationalError("database is locked")
monkeypatch.setattr(queue, "settle_worker_error", locked_settlement)
asyncio.run(asyncio.wait_for(drain.run_forever(idle_pause=0.005), 1))
assert executed == [first.job_id, second.job_id]
assert queue.get("first").state == RUNNING
assert queue.get("second").state == COMPLETED
def test_an_unexpected_job_task_failure_is_contained_by_the_drain(
tmp_path, monkeypatch, caplog
) -> None:
"""A programming error stays visible without taking down the consumer."""
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
first = queue.submit(_request(key="first", source="cursor"))
second = queue.submit(_request(key="second", source="codex"))
executed: list[str] = []
async def run_job(job_id: str) -> None:
executed.append(job_id)
if job_id == first.job_id:
raise RuntimeError("unexpected job task failure")
drain.stop()
drain = DurableWriteDrain(
queue,
execute=_never_execute,
max_concurrent_sources=1,
)
monkeypatch.setattr(drain, "_run_claimed_job", run_job)
with caplog.at_level(logging.ERROR, logger="one_memory.memory_server.durable_queue"):
asyncio.run(asyncio.wait_for(drain.run_forever(idle_pause=0.005), 1))
assert executed == [first.job_id, second.job_id]
assert any(
"durable_write_drain_job_task_failed" in message.getMessage()
and "error_type=RuntimeError" in message.getMessage()
for message in caplog.records
)
@pytest.mark.parametrize("terminal", [COMPLETED, FAILED, DEAD])
def test_a_terminal_row_is_sticky_against_a_late_settle(tmp_path, terminal: str) -> None:
"""A late settle must not overwrite an outcome that already happened.
@@ -4758,7 +4823,7 @@ def test_sedimentation_pause_releases_claim_without_spending_an_attempt(tmp_path
drain = DurableWriteDrain(queue, execute=_never_execute, owner="pause-owner")
assert drain.claim(limit=1) == (record.job_id,)
async def refuse_before_hy(_source_id, _mark_invoked):
async def refuse_before_hy(_source_id, _requires_generation, _mark_invoked):
raise RuntimeError("model_sedimentation_disabled")
asyncio.run(
@@ -4980,6 +5045,27 @@ def test_permanent_renewal_error_stops_the_worker(tmp_path, monkeypatch) -> None
asyncio.run(drain.run_one())
def test_permanent_renewal_error_stops_the_long_lived_drain(tmp_path, monkeypatch) -> None:
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
queue.submit(_request())
drain = DurableWriteDrain(
queue,
execute=lambda _job_id: asyncio.sleep(0.03),
lease=timedelta(seconds=1),
renew_every=timedelta(milliseconds=10),
)
monkeypatch.setattr(
drain,
"renew",
lambda *_args, **_kwargs: (_ for _ in ()).throw(
sqlite3.OperationalError("database schema is corrupt")
),
)
with pytest.raises(RuntimeError):
asyncio.run(asyncio.wait_for(drain.run_forever(idle_pause=0.005), 1))
def test_lost_renewal_waits_for_execution_before_releasing_source(tmp_path, monkeypatch) -> None:
path = tmp_path / "one-memory.sqlite3"
queue = DurableWriteQueue(path)
@@ -17,6 +17,7 @@ from one_memory.memory_server.migration_runner import (
InMemoryMigrationStore,
MigrationResult,
MigrationSchemaMismatchError,
SqliteMigrationStore,
ledger_sql,
migrate,
)
@@ -138,6 +139,76 @@ def test_sdk_write_receipts_are_owned_by_migration_56() -> None:
assert "CREATE TABLE IF NOT EXISTS memory_task_sdk_receipts" in ddl
def test_onboarding_progress_migrates_old_sqlite_columns_in_append_order(tmp_path) -> None:
path = tmp_path / "old-onboarding.sqlite3"
with sqlite3.connect(path) as database:
database.execute(
"""
CREATE TABLE onboarding_state (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
status TEXT NOT NULL,
selected_source_ids_json TEXT NOT NULL,
started_at TEXT,
completed_at TEXT,
updated_at TEXT NOT NULL
)
"""
)
database.execute(
"INSERT INTO onboarding_state VALUES "
"(1,'importing','[\"cursor\"]',NULL,NULL,'2026-11-04T00:00:00+00:00')"
)
migration = next(item for item in MIGRATIONS if item.version == 62)
result = asyncio.run(migrate(SqliteMigrationStore(path), (migration,)))
assert result.applied == (62,)
with sqlite3.connect(path) as database:
columns = [str(row[1]) for row in database.execute("PRAGMA table_info(onboarding_state)")]
row = database.execute(
"SELECT mode_intent, setup_step, model_profile_id FROM onboarding_state"
).fetchone()
assert columns == [
"singleton",
"status",
"selected_source_ids_json",
"started_at",
"completed_at",
"updated_at",
"mode_intent",
"setup_step",
"model_profile_id",
]
assert row == (None, None, None)
def test_onboarding_progress_mysql_retries_each_duplicate_column() -> None:
class DuplicateColumnError(Exception):
pass
class Cursor:
dialect = "mysql"
def __init__(self) -> None:
self.attempted: list[str] = []
self.applied: list[str] = []
async def execute(self, statement: str) -> None:
self.attempted.append(statement)
if "ADD COLUMN mode_intent" in statement or "ADD COLUMN setup_step" in statement:
raise DuplicateColumnError(1060, "duplicate column")
self.applied.append(statement)
cursor = Cursor()
migration = next(item for item in MIGRATIONS if item.version == 62)
asyncio.run(migration.apply(cursor))
assert len(cursor.attempted) == 3
assert len(cursor.applied) == 1
assert "model_profile_id" in cursor.applied[0]
def test_sdk_matter_references_are_owned_by_migration_57() -> None:
from one_memory.memory_server.migrations import MIGRATIONS, sdk_matter_references_ddl
@@ -259,7 +259,7 @@ def test_old_branch_job_executes_its_frozen_revision_after_harness_rewind(tmp_pa
):
assert request_id == old_job.request_id
captured.extend(records)
await before_hy_invocation()
await before_hy_invocation(True)
return SimpleNamespace(raw_ids=(), memory_ids=())
asyncio.run(
+269 -1
View File
@@ -14,6 +14,13 @@ from pydantic import SecretStr
from one_memory.api import create_app
from one_memory.config import Settings
from one_memory.imports.models import (
EvidenceInput,
EvidenceOrigin,
OriginMetadata,
WrittenBy,
materialize_write_input,
)
from one_memory.memory_server.contracts import BatchIdentity, WriteRequest
from one_memory.memory_server.durable_queue import (
FAILED,
@@ -29,6 +36,7 @@ from one_memory.model_backoff import (
ModelBackoffRefusal,
backoff_seconds,
)
from one_memory.sdk_write_processor import SdkWriteProcessor, sdk_write_failure_code
from one_memory.secrets import InMemorySecretStore
CHAT = "https://models.example.com/v1/chat/completions"
@@ -176,6 +184,17 @@ def test_a_working_embedding_endpoint_does_not_clear_a_broken_chat_model() -> No
assert backoff.paused()
def test_claim_allowance_can_pace_embedding_without_pausing_lite_for_chat() -> None:
backoff = ModelBackoff(clock=_Clock())
backoff.record_failure("model_not_found", "p:1", "chat")
assert backoff.allowance(kind="embedding") is None
assert backoff.allowance(kind="chat") == 0
backoff.reset()
backoff.record_failure("embedding_service_failed", "p:1", "embedding")
assert backoff.allowance(kind="embedding") == 0
def test_an_unreachable_provider_is_paused_like_a_refusing_one() -> None:
clock = _Clock()
backoff = ModelBackoff(clock=clock)
@@ -302,7 +321,7 @@ class _FakeSdk:
self.fail_with: str | None = None
async def process_sdk_job(self, records, *, request_id, before_hy_invocation):
await before_hy_invocation()
await before_hy_invocation(True)
self.request_ids.append(request_id)
if request_id in self.outcomes:
if self.outcomes[request_id] == "failed":
@@ -605,3 +624,252 @@ def test_the_manual_retry_endpoint_is_gone(tmp_path) -> None:
return response.status_code
assert asyncio.run(scenario()) in {404, 405}
# --- hy-memory 2.0.13: a model failure is `success: false`, never a silent success ---
class _Sdk2013Client:
"""`HyMemoryClient.add` as hy-memory 2.0.13 answers it.
Each write makes one model call through MemHub's transport, so the
breaker sees what the provider answered. The SDK turns a failed or
unusable model reply into `success: false` with its stable codes; the
shape of those results is the SDK's own (`_mark_reconcile_failure`,
`CodingWriter`).
"""
def __init__(self, transport: BackedOffModelTransport) -> None:
self.transport = transport
self.reply = "[]"
self.messages: list[list[dict[str, str]]] = []
def add(self, messages, **_kwargs):
self.messages.append(messages)
coding = any(message["role"] == "tool" for message in messages)
try:
response = asyncio.run(self.transport.handle_async_request(httpx.Request("POST", CHAT)))
except httpx.TransportError:
return self._failed(coding, "LLM_UNAVAILABLE", "LLM_ERROR")
if response.status_code >= 400:
return self._failed(coding, "LLM_UNAVAILABLE", "LLM_ERROR")
if not self.reply:
return self._failed(coding, "EMPTY_RESPONSE", "EMPTY_RESPONSE")
if not self.reply.startswith("["):
return self._failed(coding, "JSON_PARSE_FAILED", "JSON_PARSE_FAILED")
return {"success": True, "memory_ids": ["m-1"]}
@staticmethod
def _failed(coding: bool, reconcile_cause: str, coding_code: str) -> dict[str, object]:
if coding:
return {"success": False, "agent_error_code": coding_code}
return {
"success": False,
"error_code": 502,
"agent_error_code": "RECONCILE_FAILED",
"reconcile_error_code": reconcile_cause,
}
class _Sdk2013Handle:
mode = "pro"
def __init__(self, client: _Sdk2013Client) -> None:
self.sdk = client
async def client(self) -> _Sdk2013Client:
return self.sdk
class _Sdk2013Supervisor:
def __init__(self, client: _Sdk2013Client) -> None:
self.processor = SdkWriteProcessor(_Sdk2013Handle(client)) # type: ignore[arg-type]
async def process_sdk_job(self, records, *, request_id, before_hy_invocation):
return await self.processor.process_sdk_job(
records, request_id=request_id, before_hy_invocation=before_hy_invocation
)
async def inquire_write_outcome(self, _request_id):
return None
class _Turns:
"""Evidence for one conversation batch: a chat turn, or a coding turn with tools."""
def __init__(self, roles: tuple[str, ...]) -> None:
self.records = {
f"e-{index}": materialize_write_input(
EvidenceInput(
user_id="local",
source_id="cursor",
conversation_id="sess-1",
message_id=f"msg-{index}",
role=role,
written_by=WrittenBy.PERSON,
created_at=datetime(2026, 10, 1, tzinfo=UTC),
verbatim=f"{role} says something worth keeping ({index})",
origin_meta={EvidenceOrigin.FILE: OriginMetadata()},
)
)
for index, role in enumerate(roles)
}
def get(self, evidence_id: str):
return self.records[evidence_id]
def request(self) -> WriteRequest:
return WriteRequest(
internal_session_id="sess-1",
batch_identity=BatchIdentity(internal_session_id="sess-1", first_round=1, last_round=1),
evidence_ids=list(self.records),
idempotency_key="idem-000",
source_id="cursor",
)
def _outage_then_recovery(tmp_path, roles: tuple[str, ...], failure) -> tuple[list, list]:
clock = _Clock()
backoff = ModelBackoff(clock=clock)
calls: list[float] = []
def provider(request: httpx.Request) -> httpx.Response:
calls.append(clock.now)
if clock.now < 60.0:
return failure(request)
return httpx.Response(200)
client = _Sdk2013Client(
BackedOffModelTransport(backoff, profile_id="p:1", inner=httpx.MockTransport(provider))
)
turns = _Turns(roles)
path = tmp_path / "one-memory.sqlite3"
queue = DurableWriteQueue(path)
queue.submit(turns.request())
drain = DurableWriteDrain(
queue, execute=_never_execute, owner="outage", model_claim_allowance=backoff.allowance
)
seen: list[tuple[str, int, int, str | None]] = []
for second in range(120):
clock.now = float(second)
for job_id in drain.claim(limit=1):
asyncio.run(
execute_job(
job_id,
queue=queue,
supervisor=_Sdk2013Supervisor(client),
evidence=turns,
owner="outage",
model_failures=backoff.attempt,
)
)
seen.append(_jobs(path)[0][1:])
assert all(len(sent) == len(roles) for sent in client.messages)
return calls, seen
def _timeout(request: httpx.Request) -> httpx.Response:
raise httpx.ReadTimeout("provider timed out", request=request)
@pytest.mark.parametrize(
("failure", "code"),
[
(lambda _request: httpx.Response(401), "model_auth_failed"),
(lambda _request: httpx.Response(503), "model_server_error"),
(_timeout, "model_timeout"),
],
ids=["401", "503", "timeout"],
)
def test_a_reconcile_model_failure_waits_for_the_provider_and_completes_after_it(
tmp_path, failure, code
) -> None:
"""hy-memory 2.0.13 fails the write when reconcile cannot reach its model.
The breaker saw the provider fail, so the job waits in place without
spending a retry, is tried once per pause, and lands after recovery.
"""
calls, seen = _outage_then_recovery(tmp_path, ("user", "assistant"), failure)
assert calls == [0.0, 5.0, 15.0, 35.0, 75.0]
assert seen[:-1] == [("pending", attempt, 0, code) for attempt in range(1, 5)]
assert seen[-1] == ("completed", 4, 0, None)
def test_a_coding_batch_whose_extractor_fails_at_the_provider_waits_like_chat(tmp_path) -> None:
"""Tool turns go down the SDK's coding path; its `LLM_ERROR` waits the same way."""
calls, seen = _outage_then_recovery(
tmp_path, ("user", "assistant", "tool", "assistant"), lambda _request: httpx.Response(502)
)
assert calls == [0.0, 5.0, 15.0, 35.0, 75.0]
assert seen[:-1] == [("pending", attempt, 0, "model_server_error") for attempt in range(1, 5)]
assert seen[-1] == ("completed", 4, 0, None)
@pytest.mark.parametrize(
("reply", "code"),
[
("", "hy_sdk_reconcile_empty_response"),
("I am not sure what you mean.", "hy_sdk_reconcile_json_parse_failed"),
],
ids=["empty", "unparseable"],
)
def test_a_reconcile_reply_the_sdk_cannot_use_is_retried_then_failed_with_its_cause(
tmp_path, reply, code
) -> None:
"""HTTP 200 with nothing usable is not a provider failure: 3 retries, then `failed`.
The job's `error_code` names the SDK's cause instead of a bare
`hy_sdk_write_failed`, and the provider is never paused.
"""
backoff = ModelBackoff(clock=_Clock())
client = _Sdk2013Client(
BackedOffModelTransport(
backoff, profile_id="p:1", inner=httpx.MockTransport(lambda _r: httpx.Response(200))
)
)
client.reply = reply
turns = _Turns(("user", "assistant"))
path = tmp_path / "one-memory.sqlite3"
queue = DurableWriteQueue(path)
queue.submit(turns.request())
drain = DurableWriteDrain(queue, execute=_never_execute, owner="bad-reply")
now = datetime.now(UTC)
for offset in (0, 40, 150, 400):
for job_id in drain.claim(limit=1, now=now + timedelta(seconds=offset)):
asyncio.run(
execute_job(
job_id,
queue=queue,
supervisor=_Sdk2013Supervisor(client),
evidence=turns,
owner="bad-reply",
model_failures=backoff.attempt,
)
)
assert _jobs(path)[0][1:] == ("failed", 3, 3, code)
assert queue.get("idem-000").error_code == code
assert len(client.messages) == 4
assert backoff.allowance() is None
@pytest.mark.parametrize(
("result", "code"),
[
(
{"agent_error_code": "RECONCILE_FAILED", "reconcile_error_code": "LLM_UNAVAILABLE"},
"hy_sdk_reconcile_llm_unavailable",
),
(
{"agent_error_code": "RECONCILE_FAILED", "reconcile_error_code": "RECONCILE_EXCEPTION"},
"hy_sdk_reconcile_exception",
),
({"agent_error_code": "RECONCILE_FAILED"}, "hy_sdk_reconcile_failed"),
({"agent_error_code": "EMBEDDING_FAILED"}, "hy_sdk_embedding_failed"),
({"agent_error_code": "JSON_PARSE_FAILED"}, "hy_sdk_json_parse_failed"),
({"agent_error_code": "provider said: bad key sk-123"}, "hy_sdk_write_failed"),
({"agent_error_code": 502}, "hy_sdk_write_failed"),
({}, "hy_sdk_write_failed"),
],
)
def test_a_failed_sdk_write_keeps_only_the_sdks_stable_code(result, code) -> None:
assert sdk_write_failure_code({"success": False, **result}) == code
@@ -35,6 +35,12 @@ def test_profile_uses_one_model_and_keeps_key_out_of_database(tmp_path) -> None:
)
assert profile.has_api_key is True
assert profile.embedding_provider_kind is ProviderKind.LOCAL_ONNX
assert profile.embedding_model == "bge-small-zh-v1.5"
assert profile.embedding_dimensions == 512
assert profile.timeout_seconds == 240
assert profile.max_concurrency == 4
assert profile.context_window is None
assert "api_key" not in profile.model_dump()
assert API_KEY.encode() not in database.read_bytes()
assert secrets.resolve(store.secret_ref(profile.profile_id) or "") == API_KEY
@@ -1,13 +1,17 @@
"""Onboarding endpoints manage UI state without touching SDK memory."""
import asyncio
import sqlite3
from datetime import UTC, datetime
from pathlib import Path
import pytest
from httpx import ASGITransport, AsyncClient
from pydantic import SecretStr
from one_memory.api import create_app
from one_memory.config import Settings
from one_memory.onboarding import OnboardingStore
def _app(tmp_path: Path):
@@ -63,13 +67,29 @@ def test_reset_returns_to_first_run_without_touching_sdk_data(tmp_path: Path) ->
sdk_marker = tmp_path / "data" / "hy-memory" / "governed-v2" / "memory.marker"
sdk_marker.parent.mkdir(parents=True, exist_ok=True)
sdk_marker.write_text("keep-sdk-memory", encoding="utf-8")
progress = await client.patch(
"/api/v1/onboarding/progress",
json={
"mode_intent": "pro",
"setup_step": "model",
"model_profile_id": "9be6d465-4cf2-4c72-93f7-15f3c012bc80",
},
)
assert progress.status_code == 200
assert (await client.post("/api/v1/onboarding/complete")).status_code == 200
completed = await client.post("/api/v1/onboarding/complete")
assert completed.status_code == 200
assert completed.json()["mode_intent"] is None
assert completed.json()["setup_step"] is None
assert completed.json()["model_profile_id"] is None
reset = await client.post("/api/v1/onboarding/reset")
assert reset.status_code == 200
assert reset.json()["status"] == "not_started"
assert reset.json()["completed_at"] is None
assert reset.json()["mode_intent"] is None
assert reset.json()["setup_step"] is None
assert reset.json()["model_profile_id"] is None
assert (await client.get("/api/v1/onboarding")).json()["status"] == "not_started"
assert sdk_marker.read_text(encoding="utf-8") == "keep-sdk-memory"
@@ -92,6 +112,7 @@ def test_sdk_unavailability_cannot_block_onboarding_completion(tmp_path: Path) -
async def scenario() -> None:
app = _app(tmp_path)
async with app.router.lifespan_context(app), _client(app) as client:
async def unavailable() -> dict[str, object]:
raise RuntimeError("hy_sdk_unavailable")
@@ -102,3 +123,197 @@ def test_sdk_unavailability_cannot_block_onboarding_completion(tmp_path: Path) -
assert finished.json()["status"] == "completed"
asyncio.run(scenario())
def test_progress_patch_and_put_round_trip_only_resumable_choices(tmp_path: Path) -> None:
async def scenario() -> None:
app = _app(tmp_path)
async with app.router.lifespan_context(app), _client(app) as client:
patched = await client.patch(
"/api/v1/onboarding/progress",
json={"mode_intent": "pro", "setup_step": "mode"},
)
assert patched.status_code == 200
assert patched.json()["mode_intent"] == "pro"
assert patched.json()["setup_step"] == "mode"
assert patched.json()["model_profile_id"] is None
profile_id = "9be6d465-4cf2-4c72-93f7-15f3c012bc80"
replaced = await client.put(
"/api/v1/onboarding/progress",
json={"setup_step": "model", "model_profile_id": profile_id},
)
assert replaced.status_code == 200
assert replaced.json()["mode_intent"] == "pro"
assert replaced.json()["setup_step"] == "model"
assert replaced.json()["model_profile_id"] == profile_id
assert (await client.get("/api/v1/onboarding")).json() == replaced.json()
asyncio.run(scenario())
def test_progress_rejects_unknown_or_invalid_fields(tmp_path: Path) -> None:
async def scenario() -> None:
app = _app(tmp_path)
async with app.router.lifespan_context(app), _client(app) as client:
secret = await client.patch(
"/api/v1/onboarding/progress",
json={"mode_intent": "lite", "api_key": "must-not-be-stored"},
)
invalid_id = await client.put(
"/api/v1/onboarding/progress",
json={"model_profile_id": "not-a-profile-id"},
)
invalid_mode = await client.patch(
"/api/v1/onboarding/progress",
json={"mode_intent": "ultra"},
)
assert secret.status_code == 422
assert "must-not-be-stored" not in secret.text
assert invalid_id.status_code == 422
assert invalid_mode.status_code == 422
state = (await client.get("/api/v1/onboarding")).json()
assert state["mode_intent"] is None
assert state["model_profile_id"] is None
asyncio.run(scenario())
def test_progress_row_adapter_accepts_mysql_tuple_shape() -> None:
started_at = datetime(2026, 11, 4, 1, 0, tzinfo=UTC)
updated_at = datetime(2026, 11, 4, 1, 1)
state = OnboardingStore._to_state(
(
1,
"importing",
'["cursor"]',
started_at,
None,
updated_at,
"lite",
"review",
None,
),
mysql=True,
)
assert state.selected_source_ids == ("cursor",)
assert state.started_at == started_at
assert state.mode_intent == "lite"
assert state.setup_step == "review"
def test_progress_row_adapter_accepts_real_sqlite_row_and_text_datetime() -> None:
with sqlite3.connect(":memory:") as connection:
connection.row_factory = sqlite3.Row
row = connection.execute(
"SELECT 1 AS singleton, 'importing' AS status, '[\"cursor\"]' "
"AS selected_source_ids_json, '2026-11-04T01:00:00+00:00' AS started_at, "
"NULL AS completed_at, '2026-11-04T01:01:00+00:00' AS updated_at, "
"'pro' AS mode_intent, 'model' AS setup_step, "
"'9be6d465-4cf2-4c72-93f7-15f3c012bc80' AS model_profile_id"
).fetchone()
assert row is not None
state = OnboardingStore._to_state(row, mysql=False)
assert state.started_at == datetime(2026, 11, 4, 1, 0, tzinfo=UTC)
assert state.mode_intent == "pro"
assert state.setup_step == "model"
def test_progress_row_adapter_rejects_wrong_length() -> None:
with pytest.raises(ValueError, match="onboarding_row_shape_invalid"):
OnboardingStore._to_state((1, "importing"), mysql=True)
def test_progress_row_adapter_rejects_wrong_row_types() -> None:
values = (
1,
"importing",
"[]",
None,
None,
datetime(2026, 11, 4, 1, 1),
None,
None,
None,
)
with pytest.raises(ValueError, match="onboarding_row_shape_invalid"):
OnboardingStore._to_state(list(values), mysql=True)
with pytest.raises(ValueError, match="onboarding_row_shape_invalid"):
OnboardingStore._to_state(values, mysql=False)
def test_progress_row_adapter_rejects_wrong_engine_datetime_shape() -> None:
with pytest.raises(ValueError, match="onboarding_datetime_invalid"):
OnboardingStore._to_state(
(
1,
"importing",
"[]",
"2026-11-04T01:00:00+00:00",
None,
datetime(2026, 11, 4, 1, 1),
None,
None,
None,
),
mysql=True,
)
def test_runtime_sqlite_upgrade_does_not_record_progress_migration(tmp_path: Path) -> None:
database = tmp_path / "old.sqlite3"
with sqlite3.connect(database) as connection:
connection.executescript(
"""
CREATE TABLE schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL
);
INSERT INTO schema_migrations VALUES (61, 'write_job_retries', '2026-11-03');
CREATE TABLE onboarding_state (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
status TEXT NOT NULL,
selected_source_ids_json TEXT NOT NULL,
started_at TEXT,
completed_at TEXT,
updated_at TEXT NOT NULL
);
"""
)
OnboardingStore(database)
with sqlite3.connect(database) as connection:
recorded = connection.execute(
"SELECT name FROM schema_migrations WHERE version=62"
).fetchone()
columns = {str(row[1]) for row in connection.execute("PRAGMA table_info(onboarding_state)")}
assert recorded is None
assert {"mode_intent", "setup_step", "model_profile_id"} <= columns
def test_progress_rejects_updates_after_completion(tmp_path: Path) -> None:
async def scenario() -> None:
app = _app(tmp_path)
async with app.router.lifespan_context(app), _client(app) as client:
assert (await client.post("/api/v1/onboarding/complete")).status_code == 200
populated = await client.patch(
"/api/v1/onboarding/progress",
json={"mode_intent": "lite"},
)
empty = await client.put("/api/v1/onboarding/progress", json={})
assert populated.status_code == 409
assert populated.json()["detail"] == "onboarding_completed"
assert empty.status_code == 409
state = (await client.get("/api/v1/onboarding")).json()
assert state["status"] == "completed"
assert state["mode_intent"] is None
asyncio.run(scenario())
@@ -0,0 +1,237 @@
"""MemHub's raw search against the hy-memory wheel it pins, not the repo source.
The suite's pythonpath puts the repository root first, so `import hy_memory`
in a test reads the repo's `hy_memory/`, which can be ahead of the pinned
release. Search kwargs the release does not accept then pass every test and
fail every raw search in a packaged build (a `statuses` argument did exactly
that against 2.0.13). These tests run in a child interpreter started with
`-I`, so `hy_memory` resolves to the wheel installed in the Core venv, and
drive the real routes over a real lite SDK client with a deterministic local
embedder (no model, no network, no ONNX assets).
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import textwrap
import pytest
from tests.test_sdk_api_surface import SDK_SEARCH_PARAMETERS
pytestmark = pytest.mark.integration
_CHILD = textwrap.dedent(
r'''
import asyncio
import hashlib
import inspect
import json
import math
import re
import sys
from importlib.metadata import version
from pathlib import Path
import hy_memory
from hy_memory import HyMemoryClient
from one_memory.hy_runtime_config import HY_MEMORY_VERSION
home = Path(sys.argv[1])
wheel = Path(hy_memory.__file__).resolve()
assert "site-packages" in wheel.parts, f"not the installed wheel: {wheel}"
assert version("hy-memory") == HY_MEMORY_VERSION, version("hy-memory")
parameters = sorted(
name
for name, parameter in inspect.signature(HyMemoryClient.search).parameters.items()
if name not in {"self", "query"} and parameter.kind is not inspect.Parameter.VAR_KEYWORD
)
var_keyword = any(
parameter.kind is inspect.Parameter.VAR_KEYWORD
for parameter in inspect.signature(HyMemoryClient.search).parameters.values()
)
from httpx import ASGITransport, AsyncClient
from pydantic import SecretStr
import one_memory.api as api
from one_memory.api import create_app
from one_memory.config import Settings
from one_memory.embedding_assets import BUILTIN_EMBEDDING_DIMENSIONS
from one_memory.hy_sdk_runtime import (
LocalOnnxEmbeddingService,
SdkClientHandle,
builtin_retrieval_snapshot,
)
from one_memory.secrets import InMemorySecretStore
WORD = re.compile(r"\w+")
class HashedEmbedder:
"""Bag of words and CJK bigrams, hashed: texts sharing words are near."""
dimensions = BUILTIN_EMBEDDING_DIMENSIONS
def encode(self, texts):
vectors = []
for text in texts:
vector = [0.0] * self.dimensions
for word in WORD.findall(text.casefold()):
grams = [word]
if not word.isascii():
grams += [word[i : i + 2] for i in range(len(word) - 1)]
for gram in grams:
digest = hashlib.sha256(gram.encode()).digest()
vector[int.from_bytes(digest[:4], "big") % self.dimensions] += 1.0
norm = math.sqrt(sum(value * value for value in vector)) or 1.0
vectors.append([value / norm for value in vector])
return vectors
LocalOnnxEmbeddingService._embedder = HashedEmbedder()
async def lite_has_no_model(_app):
return False
api._recall_model_ready = lite_has_no_model
async def main():
settings = Settings(
data_dir=home / "data",
source_home=home,
api_token=SecretStr("pinned-sdk"),
imports_enabled=False,
automatic_memory_processing=False,
live_sync_enabled=False,
)
app = create_app(settings, secret_store=InMemorySecretStore())
app.state.memory_mode = lambda: "lite"
handle = SdkClientHandle(
builtin_retrieval_snapshot(),
api_key=None,
data_dir=home / "data",
mode="lite",
)
result = {"parameters": parameters, "var_keyword": var_keyword}
async with app.router.lifespan_context(app):
app.state.sdk_client_handle = handle
client = await handle.client()
for session, messages in (
(
"s-hike",
[
("user", "我叫小林"),
("assistant", "你好小林"),
("user", "I love hiking mountain trails at dawn"),
("assistant", "Noted, early hikes."),
],
),
(
"s-tea",
[
("user", "my favourite drink is green tea"),
("assistant", "Green tea, noted."),
],
),
):
written = client.add(
[{"role": role, "content": content} for role, content in messages],
user_id="local",
source="hermes",
session_id=session,
)
assert written.get("success", True), written
listed = client.list_memories(user_id="local", layers=["l1_raw"], limit=10)
rows = listed.get("vdb", listed)["memories"]
tea = next(row for row in rows if "green tea" in row["content"])
# What pro leaves behind: the raw it already processed is shadow.
client._loop_thread.run(
client._vector_store.update_payload(tea["memory_id"], {"status": "shadow"})
)
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
headers={"Authorization": "Bearer pinned-sdk"},
) as http:
for name, body in (
("hike_turn", {"query": "hiking trails mountain", "raw_unit": "turn"}),
("tea_turn", {"query": "favourite green tea drink", "raw_unit": "turn"}),
("tea_raw", {"query": "favourite green tea drink"}),
):
response = await http.post(
"/api/v2/context/search", json={"kinds": ["raw"], "limit": 5, **body}
)
result[name] = {"status": response.status_code, "body": response.json()}
response = await http.post(
"/api/v1/recall", json={"query": "favourite green tea drink"}
)
result["recall"] = {"status": response.status_code, "body": response.json()}
await handle.close()
print("RESULT " + json.dumps(result, ensure_ascii=False))
asyncio.run(main())
'''
)
@pytest.fixture(scope="module")
def pinned(tmp_path_factory) -> dict:
root = tmp_path_factory.mktemp("pinned-sdk")
script = root / "scripts" / "child.py"
script.parent.mkdir()
script.write_text(_CHILD, encoding="utf-8")
home = root / "home"
home.mkdir()
environment = {
**os.environ,
"ANONYMIZED_TELEMETRY": "False",
"ONE_MEMORY_DATA_DIR": str(home / "data"),
}
completed = subprocess.run(
[sys.executable, "-I", str(script), str(home)],
cwd=home,
env=environment,
capture_output=True,
text=True,
timeout=240,
check=False,
)
assert completed.returncode == 0, completed.stderr[-4000:]
[line] = [line for line in completed.stdout.splitlines() if line.startswith("RESULT ")]
return json.loads(line.removeprefix("RESULT "))
def test_the_fake_sdk_accepts_exactly_the_pinned_search_parameters(pinned) -> None:
# The route tests' stand-in rejects what the pinned SDK rejects.
assert pinned["var_keyword"] is False
assert sorted(SDK_SEARCH_PARAMETERS) == pinned["parameters"]
def test_lite_turn_search_returns_semantic_hits_without_the_literal_query(pinned) -> None:
hike = pinned["hike_turn"]
assert hike["status"] == 200, hike
items = hike["body"]["items"]
assert items, hike["body"]
top = items[0]
# No turn contains "hiking trails mountain"; the hit still comes back,
# shown by its closest turn and carrying the hit's score.
assert "hiking mountain trails" in top["content"]
assert "小林" not in top["content"]
assert top["turn_index"] == 1 and top["turn_count"] == 2
assert [item["score"] for item in items] == sorted(
(item["score"] for item in items), reverse=True
)
def test_lite_search_and_recall_include_shadow_raws(pinned) -> None:
for name in ("tea_turn", "tea_raw"):
result = pinned[name]
assert result["status"] == 200, result
assert any("green tea" in item["content"] for item in result["body"]["items"]), result
recall = pinned["recall"]
assert recall["status"] == 200, recall
assert recall["body"]["mode"] == "raw"
assert any("green tea" in hit["content"] for hit in recall["body"]["hits"]), recall
+169 -2
View File
@@ -42,7 +42,10 @@ RAW_ROWS = {
"local": [
{
"memory_id": "raw-1",
"content": "[user]: 我叫费天翔\n[assistant]: 好的,记住了。",
"content": (
"[user]: 我叫费天翔\n[assistant]: 好的,记住了。"
"\n[user]: 我喜欢绿茶\n[assistant]: 也记住了。"
),
"status": "shadow",
"session_id": "s-1",
"source": "hermes",
@@ -88,6 +91,40 @@ def _chat(user_id: str, memory_id: str, content: str, layer: str, source: str) -
}
#: The keyword parameters of the pinned `HyMemoryClient.search`, which takes
#: no **kwargs. `test_pinned_sdk_raw_search` checks this set against the
#: installed wheel, so the stand-in below rejects what the release rejects.
SDK_SEARCH_PARAMETERS = frozenset(
{
"queries",
"scene",
"search_mode",
"user_ids",
"agent_ids",
"session_ids",
"include_sources",
"exclude_sources",
"limit",
"min_score",
"working_fact_min_score",
"working_experience_min_score",
"schema_min_score",
"schema_limit",
"intention_limit",
"created_after",
"reader",
"workspace_id",
"branch",
"request_id",
"layers",
"statuses",
"embed_override",
"query_embedding",
"rerank",
}
)
class RecordingSdk:
"""The public client surface, recording every call's keyword arguments."""
@@ -106,6 +143,10 @@ class RecordingSdk:
# The SDK answers rows below a caller's floor in some lanes (a schema
# floor, a fused rank score), so Core must filter what it returns.
def search(self, query, **kwargs):
unknown = set(kwargs) - SDK_SEARCH_PARAMETERS
if unknown:
# The pinned `HyMemoryClient.search` takes no **kwargs.
raise TypeError(f"search() got unexpected keyword arguments {sorted(unknown)}")
self._record("search", {"query": query, **kwargs})
[user_id] = kwargs["user_ids"]
if kwargs.get("layers") == ["l1_raw"]:
@@ -271,6 +312,10 @@ async def _model_ready(_app) -> bool:
return True
async def _model_not_ready(_app) -> bool:
return False
def run_with_sdk(tmp_path, monkeypatch, scenario) -> None:
async def main() -> None:
app = application(tmp_path)
@@ -347,6 +392,75 @@ def test_every_context_search_option_reaches_the_sdk_and_absent_ones_keep_the_ol
run_with_sdk(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_raw_context_search_can_return_turns_instead_of_storage_batches(
tmp_path, monkeypatch
) -> None:
async def scenario(app, sdk, client):
response = await client.post(
"/api/v2/context/search",
json={
"query": "绿茶",
"kinds": ["raw"],
"raw_unit": "turn",
"limit": 20,
},
)
assert response.status_code == 200, response.text
raw_one = [item for item in response.json()["items"] if item["raw_id"] == "raw-1"]
assert len(raw_one) == 1
assert raw_one[0]["turn_index"] == 1
assert "我喜欢绿茶" in raw_one[0]["content"]
assert "我叫费天翔" not in raw_one[0]["content"]
# pro/ultra: a shadow raw is already in its extracted memories.
assert "statuses" not in sdk.last("search")
run_with_sdk(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_lite_raw_search_is_plain_rag_over_active_and_shadow_raws(tmp_path, monkeypatch) -> None:
async def scenario(app, sdk, client):
app.state.memory_mode = lambda: "lite"
response = await client.post(
"/api/v2/context/search",
json={"query": "喜欢喝绿茶的人", "kinds": ["raw"], "raw_unit": "turn", "limit": 5},
)
assert response.status_code == 200, response.text
sent = sdk.last("search")
assert sent["statuses"] == ["active", "shadow"] and sent["limit"] == 5
items = response.json()["items"]
# No turn contains the query, yet every vector hit is returned, in
# score order: raw-1 by its closest turn, raw-2 by its only one.
assert [(item["raw_id"], item["turn_index"]) for item in items] == [
("raw-1", 1),
("raw-2", 0),
]
assert [item["score"] for item in items] == [0.97, 0.3]
assert "我喜欢绿茶" in items[0]["content"]
floored = await client.post(
"/api/v2/context/search",
json={
"query": "喜欢喝绿茶的人",
"kinds": ["raw"],
"raw_unit": "turn",
"min_score": 0.5,
},
)
assert [item["raw_id"] for item in floored.json()["items"]] == ["raw-1"]
# Lite has no model, so recall answers from the raw layer.
monkeypatch.setattr("one_memory.api._recall_model_ready", _model_not_ready)
recall = await client.post("/api/v1/recall", json={"query": "喜欢喝绿茶的人"})
assert recall.status_code == 200, recall.text
assert recall.json()["mode"] == "raw"
assert sdk.last("search")["statuses"] == ["active", "shadow"]
assert [hit["memory_id"] for hit in recall.json()["hits"]][:1] == ["raw-1"]
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):
@@ -574,7 +688,12 @@ def test_raw_lists_active_and_shadow_turns_with_roles_and_hashes(tmp_path, monke
assert sent["layers"] == ["l1_raw"] and sent["statuses"] == ["active", "shadow"]
first = next(item for item in body["items"] if item["raw_id"] == "raw-1")
assert first["status"] == "shadow"
assert [message["role"] for message in first["messages"]] == ["user", "assistant"]
assert [message["role"] for message in first["messages"]] == [
"user",
"assistant",
"user",
"assistant",
]
assert first["messages"][0]["content"] == "我叫费天翔"
assert first["role"] is None
expected = hashlib.sha256(first["content"].encode()).hexdigest()
@@ -584,6 +703,54 @@ def test_raw_lists_active_and_shadow_turns_with_roles_and_hashes(tmp_path, monke
second = next(item for item in body["items"] if item["raw_id"] == "raw-2")
assert second["role"] == "user"
turns = (await client.get("/api/v2/raw", params={"unit": "turn"})).json()
assert turns["unit"] == "turn" and turns["total"] == 3
first_turns = [item for item in turns["items"] if item["raw_id"] == "raw-1"]
assert [item["turn_index"] for item in first_turns] == [1, 0]
assert len({item["turn_id"] for item in first_turns}) == 2
assert [[message["content"] for message in item["messages"]] for item in first_turns] == [
["我喜欢绿茶", "也记住了。"],
["我叫费天翔", "好的,记住了。"],
]
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
# A live session puts a new raw on top: one listing call, not a rescan.
newest = {
**RAW_ROWS["local"][1],
"memory_id": "raw-new",
"content": "[user]: 刚刚说的",
"status": "active",
}
original = RAW_ROWS["local"]
monkeypatch.setitem(RAW_ROWS, "local", [newest, *original])
calls_before_append = len(sdk.all("list_memories"))
appended = (await client.get("/api/v2/raw", params={"unit": "turn"})).json()
assert len(sdk.all("list_memories")) == calls_before_append + 1
assert appended["total"] == 4
assert [item["raw_id"] for item in appended["items"]][:1] == ["raw-new"]
assert [item["turn_id"] for item in appended["items"][1:]] == [
item["turn_id"] for item in turns["items"]
]
# Anything else (here a raw going away) lists the raw layer again.
monkeypatch.setitem(RAW_ROWS, "local", [newest, original[1]])
calls_before_delete = len(sdk.all("list_memories"))
shrunk = (await client.get("/api/v2/raw", params={"unit": "turn"})).json()
assert len(sdk.all("list_memories")) > calls_before_delete + 1
assert shrunk["total"] == 2
monkeypatch.setitem(RAW_ROWS, "local", original)
calls_before_restore = len(sdk.all("list_memories"))
restored = (await client.get("/api/v2/raw", params={"unit": "turn"})).json()
assert len(sdk.all("list_memories")) > calls_before_restore + 1
assert restored["total"] == 3
ascending = (
await client.get("/api/v2/raw", params={"unit": "turn", "order": "asc"})
).json()
ascending_first = [item for item in ascending["items"] if item["raw_id"] == "raw-1"]
assert [item["turn_index"] for item in ascending_first] == [0, 1]
await client.get(
"/api/v2/raw",
params=[("source", "hermes,codex"), ("session_id", "s-1"), ("order", "asc")],
@@ -1613,7 +1613,7 @@ def test_a_completed_sdk_write_does_not_touch_retired_evidence(store) -> None:
class Supervisor:
async def process_sdk_job(self, records, *, request_id, before_hy_invocation):
del records, request_id
await before_hy_invocation()
await before_hy_invocation(True)
return SimpleNamespace(raw_ids=("raw-1",), memory_ids=("m-1",))
asyncio.run(
@@ -91,7 +91,7 @@ def test_bundle_contains_management_sdk_config_and_verified_manifest(tmp_path):
assert manifest["bundle_id"] == "migration-1"
assert manifest["sdk_namespace"] == "governed-v2"
assert manifest["runtime_build"] == "build-13"
assert manifest["snapshot_sdk_version"] == "2.0.12"
assert manifest["snapshot_sdk_version"] == "2.0.14"
paths = {item["path"]: item for item in manifest["files"]}
assert {
"management/one-memory.sqlite3",
@@ -1,13 +1,12 @@
"""The sedimentation API persists decisions and resumes the SDK queue."""
import asyncio
import threading
"""The sedimentation API persists decisions without rewriting parked work."""
from fastapi.testclient import TestClient
from pydantic import SecretStr
from one_memory.api import create_app
from one_memory.config import Settings
from one_memory.memory_server.contracts import BatchIdentity, WriteRequest
from one_memory.memory_server.durable_queue import WAITING_FOR_PERMISSION
def _app(tmp_path):
@@ -25,51 +24,62 @@ def _app(tmp_path):
)
def test_sedimentation_route_persists_and_resumes_without_restart(tmp_path) -> None:
def test_sedimentation_route_persists_without_restart(tmp_path) -> None:
with TestClient(_app(tmp_path), headers={"Authorization": "Bearer test"}) as client:
assert client.get("/api/v1/sedimentation").json()["enabled"] is True
assert client.put(
"/api/v1/sedimentation", json={"enabled": False}
).json()["enabled"] is False
assert (
client.put("/api/v1/sedimentation", json={"enabled": False}).json()["enabled"] is False
)
assert client.get("/api/v1/sedimentation").json()["enabled"] is False
assert client.put(
"/api/v1/sedimentation", json={"enabled": True}
).json()["enabled"] is True
assert client.put("/api/v1/sedimentation", json={"enabled": True}).json()["enabled"] is True
def test_switch_answers_before_sdk_queue_resume_finishes(tmp_path, monkeypatch) -> None:
def test_switch_does_not_run_the_broad_parked_queue_resume(tmp_path, monkeypatch) -> None:
app = _app(tmp_path)
released = threading.Event()
finished = threading.Event()
called = False
with TestClient(app, headers={"Authorization": "Bearer test"}) as client:
async def slow_resume() -> None:
await asyncio.get_running_loop().run_in_executor(None, released.wait, 10.0)
finished.set()
monkeypatch.setattr(app.state.sdk_memory_runtime, "resume_allowed", slow_resume)
assert client.put(
"/api/v1/sedimentation", json={"enabled": False}
).status_code == 200
async def forbidden_resume() -> None:
nonlocal called
called = True
monkeypatch.setattr(app.state.sdk_memory_runtime, "resume_allowed", forbidden_resume)
assert client.put("/api/v1/sedimentation", json={"enabled": False}).status_code == 200
resumed = client.put("/api/v1/sedimentation", json={"enabled": True})
assert resumed.status_code == 200
assert resumed.json()["enabled"] is True
assert finished.is_set() is False
released.set()
assert called is False
def test_a_failing_sdk_queue_resume_does_not_fail_the_switch(tmp_path, monkeypatch) -> None:
def test_switch_does_not_rewrite_permission_parked_jobs(tmp_path) -> None:
app = _app(tmp_path)
with TestClient(app, headers={"Authorization": "Bearer test"}) as client:
async def broken_resume() -> None:
raise RuntimeError("sdk_queue_unavailable")
queue = app.state.memory_write_queue
queue.evidence = None
request = WriteRequest(
internal_session_id="session-1",
source_id="cursor",
batch_identity=BatchIdentity(
internal_session_id="session-1",
first_round=1,
last_round=1,
),
evidence_ids=["e-1"],
idempotency_key="permission-parked",
)
record = queue.submit(request)
queue.settle(
record.job_id,
state=WAITING_FOR_PERMISSION,
error_code="model_content_permission_required",
hy_invoked=False,
)
monkeypatch.setattr(app.state.sdk_memory_runtime, "resume_allowed", broken_resume)
assert client.put(
"/api/v1/sedimentation", json={"enabled": False}
).status_code == 200
resumed = client.put("/api/v1/sedimentation", json={"enabled": True})
assert client.put("/api/v1/sedimentation", json={"enabled": False}).status_code == 200
assert client.put("/api/v1/sedimentation", json={"enabled": True}).status_code == 200
assert resumed.status_code == 200
assert resumed.json()["enabled"] is True
parked = queue.get("permission-parked")
assert parked.state == WAITING_FOR_PERMISSION
assert parked.detail == "model_content_permission_required"
@@ -16,7 +16,7 @@ from one_memory.imports.connections import SourceConnectionStore
from one_memory.imports.grants import SourceGrantStore
from one_memory.imports.jobs import ImportJobStore
from one_memory.imports.sources.registry import build_source_catalog
from one_memory.onboarding import OnboardingStore
from one_memory.onboarding import OnboardingProgressUpdate, OnboardingStore
@pytest.mark.parametrize("entry", ["connect", "authorize"])
@@ -371,7 +371,14 @@ def test_onboarding_persists_one_history_window_on_every_accepted_source(tmp_pat
database = tmp_path / "data.sqlite"
grants = SourceGrantStore(database, home=home)
jobs = ImportJobStore(database)
OnboardingStore(database)
onboarding = OnboardingStore(database)
onboarding.update_progress(
OnboardingProgressUpdate(
mode_intent="pro",
setup_step="model",
model_profile_id="9be6d465-4cf2-4c72-93f7-15f3c012bc80",
)
)
store = SourceConnectionStore(grants, jobs, home=home)
catalog = build_source_catalog(home=home, environment={})
cutoff = datetime(2026, 10, 13, 5, 0, tzinfo=UTC)
@@ -391,3 +398,7 @@ def test_onboarding_persists_one_history_window_on_every_accepted_source(tmp_pat
assert receipt.accepted_source_ids == ("claude-code", "codex")
assert {job.history_after for job in receipt.jobs} == {cutoff}
assert store.get("bounded-first-import") == receipt
state = onboarding.get()
assert state.mode_intent == "pro"
assert state.setup_step == "model"
assert state.model_profile_id == "9be6d465-4cf2-4c72-93f7-15f3c012bc80"
+4 -4
View File
@@ -991,7 +991,7 @@ wheels = [
[[package]]
name = "hy-memory"
version = "2.0.12"
version = "2.0.14"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "chromadb" },
@@ -1008,9 +1008,9 @@ dependencies = [
{ name = "scikit-learn" },
{ name = "tiktoken" },
]
sdist = { url = "https://files.pythonhosted.org/packages/44/19/bcd0de44ae2011ee1ad70579996aad72e18f0ca292df22d1545db05db850/hy_memory-2.0.12.tar.gz", hash = "sha256:c1231596b4cf12595775541f81680d4819461cb69e4aa4dc449f876d4c3b6742", size = 1246969, upload-time = "2026-11-02T06:30:37.448Z" }
sdist = { url = "https://files.pythonhosted.org/packages/4a/68/932ac0f684a971741643d8dab0a209c6a09d5a132ca607bbb9355e0b8cf2/hy_memory-2.0.14.tar.gz", hash = "sha256:94cf354f51ab8b506c0ac4909a95a6b13fd542f81c894322210beb20996f16fd", size = 1253180, upload-time = "2026-11-04T09:52:17.127Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/75/9e/b64c7643c1bad1ddca4623a39bb62423bed0c22e7fd0d86be1ff73b2a17a/hy_memory-2.0.12-py3-none-any.whl", hash = "sha256:7a2332288197fafb89d5037eac7fef97df4c80794ead18159d21da84cde431df", size = 1316922, upload-time = "2026-11-02T06:30:35.105Z" },
{ url = "https://files.pythonhosted.org/packages/76/7f/9ecbaa2589dae9bd83657adc6388b0c7b0be415c5584fef045db309911eb/hy_memory-2.0.14-py3-none-any.whl", hash = "sha256:6df76d8dc84aaa352149e85055331d22ef904e4acd4540a2328bf058a3f0f2a6", size = 1322364, upload-time = "2026-11-04T09:52:14.096Z" },
]
[[package]]
@@ -1837,7 +1837,7 @@ requires-dist = [
{ name = "fastapi", specifier = ">=0.141.1" },
{ name = "filelock", specifier = ">=3.32.4" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "hy-memory", specifier = "==2.0.12" },
{ name = "hy-memory", specifier = "==2.0.14" },
{ name = "keyring", specifier = ">=25.7.0" },
{ name = "numpy", specifier = "==2.5.2" },
{ name = "onnxruntime", specifier = "==1.23.2" },
+34 -1
View File
@@ -9,6 +9,11 @@ import {
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { App } from "./App";
import {
commitConfirmedMemoryMode,
issueConfirmedModeTicket,
resetConfirmedMemoryModeForTests,
} from "./confirmed-memory-mode";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
it("localizes shared navigation and footer in Spanish", async () => {
@@ -36,6 +41,7 @@ it("localizes shared navigation and footer in Spanish", async () => {
});
const memoryProps = vi.hoisted(() => vi.fn());
const progressProps = vi.hoisted(() => vi.fn());
vi.mock("./api", () => ({
getOnboarding: vi.fn().mockResolvedValue({ status: "completed" }),
@@ -62,11 +68,21 @@ vi.mock("./MemorySpace", () => ({
vi.mock("./ModelSettings", () => ({
ModelSettings: () => <p>模型设置内容</p>,
}));
vi.mock("./ImportProgressStrip", () => ({ ImportProgressStrip: () => null }));
vi.mock("./R6Settings", () => ({
R6Settings: () => <p>设置视图内容</p>,
}));
vi.mock("./ImportProgressStrip", () => ({
ImportProgressStrip: (props: unknown) => {
progressProps(props);
return null;
},
}));
vi.mock("./HealthPanel", () => ({ HealthPanel: () => null }));
vi.mock("./OnboardingFlow", () => ({ OnboardingFlow: () => null }));
beforeEach(() => {
resetConfirmedMemoryModeForTests();
progressProps.mockClear();
window.history.replaceState({}, "", "/");
localStorage.clear();
});
@@ -105,6 +121,23 @@ it("uses explicit memory deep links and restores the applications default URL",
).toBe("page");
});
it("passes confirmed mode capabilities to the production progress strip", async () => {
commitConfirmedMemoryMode("lite", issueConfirmedModeTicket("settings"));
render(<App />);
const nav = await screen.findByRole("navigation", { name: "主要导航" });
await userEvent.click(within(nav).getByRole("button", { name: "设置" }));
await waitFor(() =>
expect(progressProps).toHaveBeenCalledWith(
expect.objectContaining({
modeCapabilities: expect.objectContaining({
content_plane: "raw",
generation_required: false,
}),
}),
),
);
});
it("keeps an independent scroll position for each primary page", async () => {
render(<App />);
const nav = await screen.findByRole("navigation", { name: "主要导航" });
+39 -2
View File
@@ -37,7 +37,41 @@ const { getOnboarding, sources, modelProps, mutations } = vi.hoisted(() => ({
vi.mock("./api", () => ({
getOnboarding,
getModelProfiles: vi.fn().mockResolvedValue([]),
getMemoryMode: vi.fn().mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: false,
forced_by_env: false,
modes: ["lite", "pro", "ultra"],
graph_build: {
state: "idle",
started_at: null,
finished_at: null,
users: {},
},
}),
getEvolveSchedule: vi.fn().mockResolvedValue({
enabled: true,
time: "03:00",
}),
getEvolveOverview: vi.fn().mockResolvedValue({
mode: "pro",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [],
}),
getMemoryProcessing: vi.fn().mockResolvedValue({
embedding_switch: null,
}),
setMemoryMode: vi.fn(),
setEvolveSchedule: vi.fn(),
startEvolve: vi.fn(),
apiErrorCode: vi.fn().mockReturnValue(null),
getSources: vi.fn().mockResolvedValue(sources),
getCurrentEmbeddingSwitch: vi
.fn()
.mockRejectedValue(new Error("unsupported")),
embeddingSwitchUnsupported: vi.fn().mockReturnValue(true),
...mutations,
}));
vi.mock("./use-import-sources", () => ({
@@ -119,7 +153,9 @@ it("enters D47 settings without a tour or mandatory second scope-confirmation fl
).toBeNull();
noModelWrites();
expect(screen.queryByRole("button", { name: "管理已有授权" })).toBeNull();
expect(screen.getByRole("button", { name: "模型" })).toBeTruthy();
expect(screen.getByRole("button", { name: "界面语言" })).toBeTruthy();
expect(screen.getByRole("button", { name: "记忆模式" })).toBeTruthy();
expect(screen.getByRole("button", { name: "模型配置" })).toBeTruthy();
expect(screen.getByRole("button", { name: "连接其他应用" })).toBeTruthy();
expect(screen.getByRole("button", { name: "帮助与诊断" })).toBeTruthy();
noModelWrites();
@@ -182,13 +218,14 @@ it("keeps an open model draft while switching settings groups", async () => {
window.history.replaceState({}, "", "/?view=settings");
render(<App />);
await waitFor(() => expect(modelProps).toHaveBeenCalled());
await userEvent.click(screen.getByRole("button", { name: "模型配置" }));
await userEvent.click(screen.getByRole("button", { name: "配置模型" }));
await userEvent.type(
screen.getByLabelText("保留的模型草稿"),
"unsaved draft",
);
await userEvent.click(screen.getByRole("button", { name: "连接其他应用" }));
await userEvent.click(screen.getByRole("button", { name: "模型" }));
await userEvent.click(screen.getByRole("button", { name: "模型配置" }));
expect(window.location.search).toBe("?view=settings");
expect(screen.getByLabelText("保留的模型草稿")).toHaveProperty(
"value",
+17 -4
View File
@@ -23,8 +23,10 @@ import {
import { getOnboarding } from "./api";
import { useApplicationsLanguage } from "./applications-language";
import { CallHistory } from "./CallHistory";
import { useConfirmedMemoryMode } from "./confirmed-memory-mode";
import { DebugLogPanel } from "./DebugLogPanel";
import { DesktopFrame } from "./DesktopFrame";
import { EmbeddingSwitchGlobalStatus } from "./EmbeddingSwitchExperience";
import { useGuiLanguage } from "./gui-language";
import { HealthPanel } from "./HealthPanel";
import { ImportProgressStrip } from "./ImportProgressStrip";
@@ -34,6 +36,7 @@ import { OnboardingFlow } from "./OnboardingFlow";
import { R6Button, R6LocalStorage } from "./R6Controls";
import { R6Settings } from "./R6Settings";
import { prefetchDataRuntime } from "./runtime-state";
import { EmbeddingSwitchProvider } from "./use-embedding-switch";
import "./styles.css";
import "./core-visual.css";
import "./r6-shell.css";
@@ -47,16 +50,19 @@ const rendererStartedAt = Date.now();
export function App() {
return (
<DesktopFrame>
<AppContent />
<DebugLogPanel />
</DesktopFrame>
<EmbeddingSwitchProvider>
<DesktopFrame>
<AppContent />
<DebugLogPanel />
</DesktopFrame>
</EmbeddingSwitchProvider>
);
}
function AppContent() {
const { t } = useGuiLanguage();
const { a } = useApplicationsLanguage();
const confirmedMode = useConfirmedMemoryMode();
const [onboarding, setOnboarding] = useState<OnboardingState | null>(null);
const [startupRead, setStartupRead] = useState<"loading" | "ready" | "error">(
"loading",
@@ -309,6 +315,10 @@ function AppContent() {
ref={content}
data-view={displayedView}
>
<EmbeddingSwitchGlobalStatus
onConfigureSearch={() => navigate("settings")}
onDiagnostics={() => navigate("health")}
/>
{/* First run hands the user in here while the import is still going.
* Without this the work would be invisible from every screen except
* the one they were no longer required to sit on. */}
@@ -322,6 +332,9 @@ function AppContent() {
sourceSnapshot={imports.sourceSnapshot}
onRefresh={imports.refresh}
arrival={memoryArrival}
modeCapabilities={
confirmedMode.capabilities ?? memoryArrival?.capabilities
}
onConfigureModel={() => navigate("settings")}
onJobAction={imports.changeJob}
showSourceLink={displayedView !== "settings"}
+316 -18
View File
@@ -15,6 +15,11 @@ import {
CALL_HISTORY_ENTRY_VISIBLE,
} from "./ApplicationsHome";
import * as api from "./api";
import {
commitConfirmedMemoryMode,
issueConfirmedModeTicket,
resetConfirmedMemoryModeForTests,
} from "./confirmed-memory-mode";
import { saveConnectionPreferences } from "./connection-preferences";
import { resetConnectionReconcile } from "./connection-reconcile";
import { LanguageProvider } from "./gui-language";
@@ -67,21 +72,37 @@ it("localizes the whole home live without repeating reads or changing imported t
expect(summary).toHaveBeenCalledTimes(calls);
});
const { summary, processing, sedimentation, history, liveSync } = vi.hoisted(
() => ({
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(),
}),
);
}));
vi.mock("./call-history-client", async (original) => ({
...(await original<typeof import("./call-history-client")>()),
getCallHistory: history,
}));
vi.mock("./api", () => ({
getHomeProjection: projection,
getHomeSummary: summary,
homeProjectionUnsupported: vi.fn().mockReturnValue(true),
getMemoryMode: vi.fn().mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: true,
forced_by_env: false,
modes: ["lite", "pro", "ultra"],
graph_build: {
state: "idle",
started_at: null,
finished_at: null,
users: {},
},
}),
getSdkRaw: vi.fn(),
getLiveSyncHealth: liveSync,
getMemoryProcessing: processing,
getSedimentation: sedimentation,
@@ -106,6 +127,22 @@ const imports = {
sourceSnapshot: { stale: false },
} as unknown as Parameters<typeof ApplicationsHome>[0]["imports"];
beforeEach(() => {
resetConfirmedMemoryModeForTests();
vi.mocked(api.getMemoryMode).mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: true,
forced_by_env: false,
modes: ["lite", "pro", "ultra"],
graph_build: {
state: "idle",
started_at: null,
finished_at: null,
users: {},
},
});
vi.mocked(api.getSdkRaw).mockReset();
vi.mocked(api.homeProjectionUnsupported).mockReturnValue(true);
history
.mockReset()
.mockResolvedValue({ available: true, recording_error: false, items: [] });
@@ -122,6 +159,7 @@ beforeEach(() => {
},
observed_at: "2026-09-19T00:00:00Z",
});
projection.mockReset().mockRejectedValue(new Error("legacy core"));
processing.mockReset().mockResolvedValue({ running: 0, pending: 0 });
sedimentation.mockReset().mockResolvedValue({
enabled: true,
@@ -149,6 +187,218 @@ beforeEach(() => {
]),
} as unknown as NonNullable<typeof window.oneMemory>;
});
it("hides an old projection immediately when confirmed mode changes", async () => {
vi.mocked(api.homeProjectionUnsupported).mockReturnValue(false);
commitConfirmedMemoryMode("pro", issueConfirmedModeTicket("settings"));
projection
.mockResolvedValueOnce({
projection_version: 1,
user_id: "local",
effective_mode: "pro",
capabilities: {
content_plane: "memory",
generation_required: true,
graph_enabled: false,
evolve_available: false,
},
visible_count: 1,
display_state: "excerpt",
latest: {
type: "memory",
memory_id: "old",
scene: "chat",
kind: "chat",
variant: null,
source_id: "app-0",
excerpt: "旧模式投影",
truncated: false,
time: null,
time_basis: "update",
},
observed_at: "2026-11-04T00:00:00Z",
})
.mockImplementationOnce(() => new Promise(() => {}));
render(
<ApplicationsHome
imports={imports}
onManage={() => {}}
onMemories={() => {}}
/>,
);
expect(await screen.findByText("旧模式投影")).toBeTruthy();
act(() =>
commitConfirmedMemoryMode("lite", issueConfirmedModeTicket("settings")),
);
expect(screen.queryByText("旧模式投影")).toBeNull();
expect(screen.getAllByText("正在核对本机原始对话…")).toHaveLength(2);
});
it("renders the lite projection as raw data with mode metadata and no navigation tail", async () => {
vi.mocked(api.homeProjectionUnsupported).mockReturnValue(false);
projection.mockResolvedValue({
projection_version: 1,
user_id: "local",
effective_mode: "lite",
capabilities: {
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
},
visible_count: 7,
display_state: "excerpt",
latest: {
type: "raw",
raw_id: "raw-1",
session_id: "session-1",
source_id: "app-0",
excerpt: "用户原始对话摘要",
truncated: false,
display_state: "excerpt",
time: "2026-09-19T00:00:00Z",
time_basis: "conversation",
},
observed_at: "2026-09-19T00:00:01Z",
});
render(
<ApplicationsHome
imports={imports}
onManage={() => {}}
onMemories={vi.fn()}
onCalls={() => {}}
/>,
);
expect(await screen.findByText("原始对话")).toBeTruthy();
expect(screen.getByText("7")).toBeTruthy();
expect(screen.getByText("用户原始对话摘要")).toBeTruthy();
expect(screen.getByText(/原始对话 · App 0 · 对话时间/)).toBeTruthy();
expect(screen.getByText("lite")).toBeTruthy();
expect(screen.queryByRole("button", { name: "查看记忆" })).toBeNull();
expect(screen.queryByRole("button", { name: "查看记录" })).toBeNull();
});
it.each([
["conversation", "对话时间"],
["import", "导入时间"],
["write", "写入时间"],
["update", "更新时间"],
[null, null],
] as const)(
"renders projection time basis %s truthfully",
async (basis, label) => {
vi.mocked(api.homeProjectionUnsupported).mockReturnValue(false);
projection.mockResolvedValue({
projection_version: 1,
user_id: "local",
effective_mode: "lite",
capabilities: {
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
},
visible_count: 1,
display_state: "excerpt",
latest: {
type: "raw",
raw_id: "raw-basis",
session_id: "session-basis",
source_id: "app-0",
excerpt: "basis excerpt",
truncated: false,
display_state: "excerpt",
time: "2026-09-19T00:00:00Z",
time_basis: basis,
},
observed_at: "2026-09-19T00:00:01Z",
});
render(mountHome());
const activity = (await screen.findByText("basis excerpt")).closest(
"section",
);
const meta = activity?.querySelector("small")?.textContent ?? "";
if (label) expect(meta).toContain(label);
else expect(meta).not.toMatch(/对话时间|导入时间|写入时间|更新时间/);
},
);
it("does not publish a stopped legacy raw fallback", async () => {
vi.mocked(api.getMemoryMode).mockResolvedValue({
mode: "lite",
configured_mode: "lite",
explicitly_configured: true,
forced_by_env: false,
modes: ["lite", "pro", "ultra"],
graph_build: {
state: "idle",
started_at: null,
finished_at: null,
users: {},
},
});
let resolveRaw!: (value: {
user_id: string;
items: [];
total: number;
limit: number;
offset: number;
}) => void;
vi.mocked(api.getSdkRaw).mockReturnValue(
new Promise((resolve) => {
resolveRaw = resolve;
}),
);
const view = render(mountHome(true));
await waitFor(() => expect(api.getSdkRaw).toHaveBeenCalledWith({ limit: 1 }));
view.rerender(mountHome(false));
await act(async () =>
resolveRaw({
user_id: "local",
items: [],
total: 73,
limit: 1,
offset: 0,
}),
);
expect(screen.queryByText("73")).toBeNull();
});
it("uses only total from the legacy raw fallback", async () => {
vi.mocked(api.getMemoryMode).mockResolvedValue({
mode: "lite",
configured_mode: "lite",
explicitly_configured: true,
forced_by_env: false,
modes: ["lite", "pro", "ultra"],
graph_build: {
state: "idle",
started_at: null,
finished_at: null,
users: {},
},
});
vi.mocked(api.getSdkRaw).mockResolvedValue({
user_id: "local",
items: [
{
raw_id: "raw-private",
content: "正文不应进入首页",
user_id: "local",
agent_id: "agent",
session_id: "session",
source: "app-0",
memory_at: null,
gmt_created: null,
custom: {},
},
],
total: 9,
limit: 1,
offset: 0,
});
render(mountHome());
expect(await screen.findByText("9")).toBeTruthy();
expect(screen.queryByText("正文不应进入首页")).toBeNull();
});
it.each([
[true, "Organizando", "Organizando nuevas conversaciones"],
[false, "En pausa", "Se dejó de generar memorias nuevas"],
@@ -197,7 +447,7 @@ it("localizes startup recovery and empty states without leaking errors", async (
"El servicio se está iniciando; se actualizará automáticamente",
),
).toBeTruthy();
expect(screen.getByText("0")).toBeTruthy();
expect(screen.getByText("—")).toBeTruthy();
expect(screen.getByText("Aún no hay aplicaciones para mostrar")).toBeTruthy();
expect(
screen.queryByRole("button", { name: "Reintentar lectura" }),
@@ -235,7 +485,7 @@ it("shows latest recorded call separately without exposing recent-update detail
);
await screen.findByText("实际治理记忆");
fireEvent.click(screen.getByRole("tab", { name: "最近调用" }));
await screen.findByText("Codex 查到 3 条相关内容");
await screen.findByText("Codex 找到 2 条记忆 · 1 条原始会话");
expect(document.querySelector(".r6-home-call-direction")).toBeTruthy();
expect(screen.queryByText("实际治理记忆")).toBeNull();
expect(screen.getByText(/Codex · 今天 .+ · 查询成功/)).toBeTruthy();
@@ -292,7 +542,7 @@ it("skips direct diagnostics and shows the latest attributed application call",
);
fireEvent.click(await screen.findByRole("tab", { name: "最近调用" }));
expect(await screen.findByText("WorkBuddy 查到 5 条相关内容")).toBeTruthy();
expect(await screen.findByText("WorkBuddy 找到 5 条记忆")).toBeTruthy();
expect(screen.queryByText(/直接调用/)).toBeNull();
expect(screen.getByText(/WorkBuddy · 今天 .+ · 查询成功/)).toBeTruthy();
const direction = document.querySelector(".r6-home-call-direction");
@@ -330,7 +580,7 @@ it("shows only the host for memory hits whose references have no source", async
/>,
);
fireEvent.click(await screen.findByRole("tab", { name: "最近调用" }));
await screen.findByText("WorkBuddy 查到 2 条相关内容");
await screen.findByText("WorkBuddy 找到 2 条记忆");
const direction = document.querySelector(".r6-home-call-direction");
expect(direction?.querySelectorAll("img")).toHaveLength(1);
expect(direction?.querySelector("img")?.getAttribute("src")).toContain(
@@ -368,7 +618,7 @@ it("keeps source to host direction for raw hits with a source", async () => {
/>,
);
fireEvent.click(await screen.findByRole("tab", { name: "最近调用" }));
await screen.findByText("WorkBuddy 查到 1 条相关内容");
await screen.findByText("WorkBuddy 找到 1 条原始会话");
const direction = document.querySelector(".r6-home-call-direction");
expect(direction?.querySelectorAll("img")).toHaveLength(1);
expect(direction?.querySelector("svg")).toBeTruthy();
@@ -416,7 +666,9 @@ it("shows unlinked apps and the latest returned call on matching rows", async ()
onCalls={() => {}}
/>,
);
expect(await screen.findByText(/Cursor 查到 3 条相关内容 ·/)).toBeTruthy();
expect(
await screen.findByText(/Cursor 找到 2 条记忆 · 1 条原始会话 ·/),
).toBeTruthy();
expect(screen.getByText("尚未关联")).toBeTruthy();
});
it("keeps the latest call visible when recording is incomplete", async () => {
@@ -444,7 +696,7 @@ it("keeps the latest call visible when recording is incomplete", async () => {
/>,
);
fireEvent.click(await screen.findByRole("tab", { name: "最近调用" }));
await screen.findByText("Cursor 查到 1 条相关内容");
await screen.findByText("Cursor 找到 1 条记忆");
expect(screen.getByText(/有几次调用没被记下来/)).toBeTruthy();
expect(screen.queryByText("暂时无法读取调用记录")).toBeNull();
expect(screen.queryByText("暂无已记录调用")).toBeNull();
@@ -496,7 +748,7 @@ it("marks a retained application activity as last-read when call history cannot
/>,
);
await screen.findByText(/App 0 查到 3 条相关内容 ·/);
await screen.findByText(/App 0 找到 2 条记忆 · 1 条原始会话 ·/);
view.rerender(
<ApplicationsHome
active={false}
@@ -520,8 +772,10 @@ it("marks a retained application activity as last-read when call history cannot
await waitFor(() => expect(history).toHaveBeenCalledTimes(2));
expect(await screen.findByText("实际治理记忆")).toBeTruthy();
expect(screen.getByText(/上次读取:App 0 查到 3 条相关内容/)).toBeTruthy();
expect(screen.queryByText(/^App 0 查到/)).toBeNull();
expect(
screen.getByText(/上次读取:App 0 找到 2 条记忆 · 1 条原始会话/),
).toBeTruthy();
expect(screen.queryByText(/^App 0 找到/)).toBeNull();
});
it("does not say an unavailable call returned memories", async () => {
history.mockResolvedValue({
@@ -770,7 +1024,7 @@ it("keeps authoritative home and processing data when only the source snapshot b
expect(screen.getAllByText("整理中")).toHaveLength(2);
expect(screen.queryByText("状态待核对")).toBeNull();
});
it("keeps a neutral zero while the first home summary read recovers", async () => {
it("keeps an unknown dash while the first home read recovers", async () => {
summary.mockRejectedValue(new Error("private offline detail"));
render(
<ApplicationsHome
@@ -783,7 +1037,7 @@ it("keeps a neutral zero while the first home summary read recovers", async () =
await waitFor(() =>
expect(screen.getByText("后台正在启动,稍后自动更新")).toBeTruthy(),
);
expect(screen.getByText("0")).toBeTruthy();
expect(screen.getByText("—")).toBeTruthy();
expect(screen.getByText("正在准备记忆概览")).toBeTruthy();
expect(screen.queryByText(/暂时?无法读取|读取失败/)).toBeNull();
expect(document.body.textContent).not.toContain("private offline detail");
@@ -944,6 +1198,50 @@ it("shows a paused switch immediately even when task rows still say running", as
expect(screen.queryByText("新的对话正在整理")).toBeNull();
});
it("shows lite raw ingestion as running even when model sedimentation is paused", async () => {
vi.mocked(api.homeProjectionUnsupported).mockReturnValue(false);
projection.mockResolvedValue({
projection_version: 1,
user_id: "local",
effective_mode: "lite",
capabilities: {
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
},
visible_count: 0,
display_state: "empty",
latest: null,
observed_at: "2026-11-04T00:00:00Z",
});
processing.mockResolvedValue({
running: 0,
pending: 10,
pipeline: { mode: "lite" },
});
sedimentation.mockResolvedValue({
enabled: false,
pending_retained: true,
imports_unaffected: true,
});
render(
<ApplicationsHome
imports={imports}
onManage={() => {}}
onMemories={() => {}}
onSettings={() => {}}
/>,
);
expect(await screen.findAllByText("正在保存")).toHaveLength(2);
expect(screen.getByText("暂无更新")).toBeTruthy();
expect(screen.getByText("已核对,暂无可显示的原始对话更新")).toBeTruthy();
expect(screen.getByText("原始对话持续保存,不需要生成模型")).toBeTruthy();
expect(screen.queryByText("已暂停")).toBeNull();
});
it("keeps the import-in-progress empty state stable without a progress link", async () => {
summary.mockResolvedValue({
memory_count: 0,
@@ -1312,7 +1610,7 @@ it("keeps the conversation-sync checking copy before the first home read", () =>
expect(screen.queryByText(/个应用已开启/)).toBeNull();
});
it("shows a provisional zero while the first summary read is pending", async () => {
it("shows an unknown dash while the first projection read is pending", async () => {
summary.mockImplementation(() => new Promise(() => {}));
history.mockResolvedValue({
available: true,
@@ -1329,7 +1627,7 @@ it("shows a provisional zero while the first summary read is pending", async ()
/>,
);
await screen.findByText("正在准备记忆概览");
expect(screen.getByText("0")).toBeTruthy();
expect(screen.getByText("—")).toBeTruthy();
expect(screen.getByText("正在核对本机记忆…")).toBeTruthy();
expect(screen.queryByText("还没有已生成的记忆")).toBeNull();
});
+249 -114
View File
@@ -1,4 +1,5 @@
import type {
HomeProjectionV1,
HostHomeSummary,
LiveSyncHealth,
MemoryProcessingSummary,
@@ -8,7 +9,15 @@ import { type GuiLocale, isMessageKey } from "@one-memory/i18n";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { getHomeSummary, getLiveSyncHealth, getMemoryProcessing } from "./api";
import {
getHomeProjection,
getHomeSummary,
getLiveSyncHealth,
getMemoryMode,
getMemoryProcessing,
getSdkRaw,
homeProjectionUnsupported,
} from "./api";
import { AppMark } from "./app-mark";
import {
type CallEvent,
@@ -17,6 +26,13 @@ import {
callRowDetail,
getCallHistory,
} from "./call-history-client";
import {
commitConfirmedMemoryMode,
confirmedMemoryModeSnapshot,
isConfirmedModeTicketCurrent,
issueConfirmedModeTicket,
useConfirmedMemoryMode,
} from "./confirmed-memory-mode";
import { hostMatchesSource, sameConnectionHost } from "./connection-hosts";
import { readConnectionPreferences } from "./connection-preferences";
import {
@@ -27,6 +43,10 @@ import {
} from "./connection-reconcile";
import { useGuiLanguage } from "./gui-language";
import { HomeCardBoundary } from "./HomeCardBoundary";
import {
buildHomeViewModel,
type HomeProjectionReadState,
} from "./home-view-model";
import {
hostBridgeAvailable,
hostConnectionApproved,
@@ -85,6 +105,17 @@ function stamped(value: string, locale: GuiLocale, t: Translate, suffix = "") {
return `${parts.today ? t("apps.today") : ""}${parts.clock}${suffix}`;
}
function projectionTimeBasisLabel(
basis: string | null,
t: Translate,
): string | null {
if (basis === "conversation") return t("apps.conversationTime");
if (basis === "import") return t("apps.importTime");
if (basis === "write") return t("apps.writeTime");
if (basis === "update") return t("apps.updateTime");
return null;
}
type ActivityChoice = "call" | "update";
function sourceMatchesHost(sourceId: string, hostId: string | null) {
@@ -167,7 +198,6 @@ function SourceMark({ source }: { source: SourceResponse }) {
export function ApplicationsHome({
imports,
onManage,
onMemories,
onCalls,
active = true,
}: {
@@ -192,6 +222,11 @@ export function ApplicationsHome({
const hostStale = hostQuery.isError && hostQuery.data !== undefined;
const auto = useAutoConnect();
const sedimentation = useSedimentation();
const confirmedMode = useConfirmedMemoryMode();
const [projection, setProjection] = useState<HomeProjectionV1 | null>(null);
const [projectionState, setProjectionState] =
useState<HomeProjectionReadState>("loading");
const [legacyCore, setLegacyCore] = useState(false);
const [summary, setSummary] = useState<HostHomeSummary | null>(null);
const [processing, setProcessing] = useState<MemoryProcessingSummary | null>(
null,
@@ -278,6 +313,7 @@ export function ApplicationsHome({
const [expanded, setExpanded] = useState(retained.expanded);
const [phase, setPhase] = useState("idle");
const [revision, setRevision] = useState(0);
const confirmedModeSeen = useRef(confirmedMode);
const expandButton = useRef<HTMLButtonElement>(null);
const backButton = useRef<HTMLButtonElement>(null);
const root = useRef<HTMLElement>(null);
@@ -538,6 +574,18 @@ export function ApplicationsHome({
.map((s) => `${s.source_id}:${s.authorized}:${s.grant_revision}`)
.join("|");
const requestKey = `${sourceVersion}:${revision}`;
useEffect(() => {
const previous = confirmedModeSeen.current;
if (previous.revision === confirmedMode.revision) return;
confirmedModeSeen.current = confirmedMode;
// The first confirmed mode accompanies the first self-consistent
// projection. There is no old-mode projection to hide yet.
if (previous.mode === null) return;
setProjection(null);
setSummary(null);
setProjectionState("loading");
if (active) setRevision((value) => value + 1);
}, [active, confirmedMode]);
// requestKey retriggers this read when the source snapshot or a manual retry changes.
// biome-ignore lint/correctness/useExhaustiveDependencies: requestKey is the refresh token
useEffect(() => {
@@ -547,8 +595,9 @@ export function ApplicationsHome({
async function refresh() {
if (busy || document.hidden) return;
busy = true;
const homeTicket = issueConfirmedModeTicket("home");
const [home, state, sync] = await Promise.allSettled([
getHomeSummary(),
getHomeProjection(),
getMemoryProcessing(),
getLiveSyncHealth(),
refreshSedimentation(),
@@ -556,17 +605,99 @@ export function ApplicationsHome({
busy = false;
if (stopped) return;
if (home.status === "fulfilled") {
// stale=true 表示这轮读取超时,数字不代表现状:保留上一次的概览,
// 等 10 秒后的下一轮轮询补上。重复 stale 仍要进入可恢复状态,
// 不能永远停在“正在核对”。
if (!("stale" in home.value) || !home.value.stale) {
setSummaryFailures(0);
setSummary(home.value);
} else {
setSummaryFailures((count) => count + 1);
}
if (
!commitConfirmedMemoryMode(
home.value.effective_mode,
homeTicket,
home.value.capabilities,
)
)
return;
setLegacyCore(false);
setSummaryFailures(0);
setProjection(home.value);
setProjectionState("ready");
} else {
setSummaryFailures((count) => count + 1);
if (homeProjectionUnsupported(home.reason)) {
setLegacyCore(true);
try {
const modeTicket = issueConfirmedModeTicket("home");
const modeResult = await getMemoryMode();
if (stopped) return;
const modeAccepted = commitConfirmedMemoryMode(
modeResult.mode,
modeTicket,
);
const effectiveMode = modeAccepted
? modeResult.mode
: confirmedMemoryModeSnapshot().mode;
if (effectiveMode === "lite") {
const raw = await getSdkRaw({ limit: 1 });
if (stopped || !isConfirmedModeTicketCurrent(modeTicket)) return;
setProjection({
projection_version: 1,
user_id: raw.user_id ?? "local",
effective_mode: "lite",
capabilities: {
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
},
visible_count: raw.total,
display_state: raw.total > 0 ? "no_user_excerpt" : "empty",
latest: null,
observed_at: new Date().toISOString(),
});
setSummaryFailures(0);
setProjectionState("ready");
return;
}
const legacy = await getHomeSummary();
if (stopped || !isConfirmedModeTicketCurrent(modeTicket)) return;
if (!("stale" in legacy) || !legacy.stale) {
setSummary(legacy);
setSummaryFailures(0);
setProjectionState("ready");
} else {
setSummaryFailures((count) => {
const next = count + 1;
setProjectionState((current) =>
next < 2
? current
: current === "ready" || current === "stale"
? "stale"
: "unavailable",
);
return next;
});
}
} catch {
setSummaryFailures((count) => {
const next = count + 1;
setProjectionState((current) =>
next < 2
? current
: current === "ready" || current === "stale"
? "stale"
: "unavailable",
);
return next;
});
}
} else {
setSummaryFailures((count) => {
const next = count + 1;
setProjectionState((current) =>
next < 2
? current
: current === "ready" || current === "stale"
? "stale"
: "unavailable",
);
return next;
});
}
}
if (state.status === "fulfilled") setProcessing(state.value);
if (sync.status === "fulfilled") setSyncHealth(sync.value);
@@ -582,10 +713,31 @@ export function ApplicationsHome({
stop();
};
}, [active, requestKey]);
const visible = summary;
const latest = visible?.latest;
const summaryUnavailable = visible === null && summaryFailures >= 2;
const summaryStale = visible !== null && summaryFailures >= 2;
const modeChangedPending =
confirmedModeSeen.current.mode !== null &&
confirmedModeSeen.current.revision !== confirmedMode.revision;
const effectiveProjection = modeChangedPending ? null : projection;
const visible = modeChangedPending ? null : summary;
const historyImporting = imports.jobs.some(
(job) => job.status === "running" || job.status === "pending",
);
const viewModel = buildHomeViewModel({
projection: effectiveProjection,
projectionState: modeChangedPending ? "loading" : projectionState,
confirmedMode: confirmedMode.mode,
legacySummary: legacyCore ? visible : null,
processing,
sedimentation: sedimentation ?? null,
recentCall,
importsRunning: historyImporting,
hostState,
syncEnabledCount: imports.sources.filter(
(source) => source.authorized && source.sync_enabled,
).length,
});
const latest = viewModel.recent;
const summaryUnavailable = viewModel.state === "unavailable";
const summaryStale = viewModel.state === "stale";
const hasCall =
recentCall !== null &&
(callState === "ready" || callState === "incomplete");
@@ -599,9 +751,6 @@ export function ApplicationsHome({
activityView !== null && availableActivities.includes(activityView)
? activityView
: null;
const historyImporting = imports.jobs.some(
(job) => job.status === "running" || job.status === "pending",
);
const recentCallSourceId = (recentCall?.references ?? []).find(
(reference) => reference.source_id,
)?.source_id;
@@ -616,72 +765,46 @@ export function ApplicationsHome({
: null;
const attention = callAttention(callEvents);
const attentionHost = attention ? callHostName(attention.host, locale) : null;
const baseActivity =
viewModel.activity.kind === "update"
? ("memory" as const)
: viewModel.activity.kind;
const activity = summaryUnavailable
? "unavailable"
: visible === null
? "loading"
: pinned === "call"
? "call"
: pinned === "update"
: pinned === "call"
? "call"
: pinned === "update"
? updateKind
: updateKind
? updateKind
: updateKind
? updateKind
: hasCall
? "call"
: callState === "unavailable"
? "unavailable"
: historyImporting
? "importing"
: callState === "loading"
? recentCall !== null
? "call"
: "loading"
: processing?.running
? "first-running"
: processing?.pending || processing?.waiting_for_model
? "first-queued"
: "empty";
: hasCall
? "call"
: callState === "unavailable"
? "unavailable"
: callState === "loading" && recentCall !== null
? "call"
: baseActivity;
const apps = imports.sources.filter(
(s) => s.authorized || s.detected === true || s.location_state === "found",
);
const stateLabel =
sedimentation?.enabled === true
? t("apps.running")
: sedimentation?.enabled === false
? t("apps.paused")
: t("apps.checkingStatus");
const stateDetail =
sedimentation?.enabled === false
? t("apps.stoppedNew")
: sedimentation?.enabled === true
? t("apps.organizingNew")
: t("apps.checkingProcessing");
const latestUpdatedAt =
latest === null || latest === undefined
? null
: "revision" in latest
? latest.updated_at
: latest.gmt_created === null
? null
: new Date(latest.gmt_created * 1000).toISOString();
const stateLabel = t(viewModel.processingLabel as never);
const stateDetail = t(viewModel.processingDetail as never);
const latestUpdatedAt = latest?.time ?? null;
const latestClock = latestUpdatedAt
? clockParts(latestUpdatedAt, locale)
: null;
const latestSourceIds =
latest === null || latest === undefined
? []
: "revision" in latest
? latest.source_ids
: latest.source
? [latest.source]
: [];
const latestSourceIds = latest?.sourceId ? [latest.sourceId] : [];
const latestSourceNames = latestSourceIds.map(
(id) =>
imports.sources.find((source) => source.source_id === id)?.display_name ??
id,
);
const time = latestClock?.clock ?? (latest ? t("apps.unknownTime") : null);
const checked = visible ? clockParts(visible.observed_at, locale) : null;
const checked = effectiveProjection
? clockParts(effectiveProjection.observed_at, locale)
: visible
? clockParts(visible.observed_at, locale)
: null;
// A failed read leaves the last health in place on purpose: clearing it would
// drop the instance it names, and that instance is what keeps memory writes
// and backfill receipts attached to the run that produced them. So the card
@@ -784,7 +907,7 @@ export function ApplicationsHome({
? tn("apps.enabledCount", authorizedSyncSources.length)
: syncApplyFailed
? t("apps.enableFailed")
: wantsSync || !visible
: wantsSync || viewModel.state === "loading"
? t("apps.checkingState")
: t("apps.notEnabled");
function toggleAll(next: boolean) {
@@ -832,29 +955,31 @@ export function ApplicationsHome({
</header>
<div className="r6-home-metrics">
<div role="status" aria-live="polite" aria-atomic="true">
<p>{t("apps.memories")}</p>
<p>{t(viewModel.countLabel)}</p>
<p className="r6-home-figure">
<strong>
{new Intl.NumberFormat(locale).format(
visible?.memory_count ?? 0,
)}
{viewModel.count === null
? "—"
: new Intl.NumberFormat(locale).format(viewModel.count)}
</strong>
{t("apps.unit") ? <i>{t("apps.unit")}</i> : null}
{viewModel.count !== null && t("apps.unit") ? (
<i>{t("apps.unit")}</i>
) : null}
</p>
<small>
{visible
{viewModel.state !== "loading"
? summaryStale
? t("apps.showingPrevious")
: t("apps.scope")
: t(viewModel.countDetail)
: summaryUnavailable
? t("apps.countWillUpdate")
: summaryFailures
? t("apps.startingRetry")
: t("apps.readingCount")}
: t(viewModel.countDetail)}
</small>
</div>
<div>
<p>{t("apps.processing")}</p>
<p>{t(viewModel.processingTitle)}</p>
<strong className="r6-home-word">{stateLabel}</strong>
<small>{stateDetail}</small>
</div>
@@ -862,16 +987,14 @@ export function ApplicationsHome({
<p>{t("apps.recentUpdate")}</p>
<strong className={time ? "r6-home-time" : "r6-home-word"}>
{time ??
(visible
? t("apps.notGenerated")
: t("apps.checkingRecent"))}
(viewModel.recentMetric.valueKey
? t(viewModel.recentMetric.valueKey)
: "")}
</strong>
<small>
{time
? `${latestClock?.today ? `${t("apps.today")}· ` : ""}${t("apps.saved")}`
: visible
? t("apps.separate")
: t("apps.recentWillUpdate")}
? `${latestClock?.today ? `${t("apps.today")}· ` : ""}${t(viewModel.recentMetric.detailKey)}`
: t(viewModel.recentMetric.detailKey)}
</small>
</div>
</div>
@@ -905,7 +1028,7 @@ export function ApplicationsHome({
? t("apps.recentCall")
: activity === "memory"
? t("apps.recentUpdate")
: t("apps.firstBatch")}
: t(viewModel.activity.headingKey)}
</p>
)}
<div>
@@ -935,7 +1058,13 @@ export function ApplicationsHome({
})()
) : null}
<section>
<h3>
<h3
className={
activity === "memory" && latest?.kind === "raw"
? "r6-home-raw-excerpt"
: undefined
}
>
{activity === "unavailable"
? callState === "unavailable"
? t("apps.callsUnavailable")
@@ -947,16 +1076,12 @@ export function ApplicationsHome({
locale,
)
: activity === "memory" && latest
? `${latest.content}${"truncated" in latest && latest.truncated ? "…" : ""}`
: activity === "importing"
? t("apps.importing")
: activity === "first-running"
? t("apps.firstRunning")
: activity === "first-queued"
? t("apps.firstQueued")
: activity === "loading"
? t("apps.readingActivity")
: t("apps.emptyMemories")}
? latest.emptyExcerpt
? t("apps.rawNoExcerpt")
: latest.excerpt
: viewModel.activity.titleKey
? t(viewModel.activity.titleKey)
: ""}
</h3>
<small>
{activity === "unavailable"
@@ -966,14 +1091,24 @@ export function ApplicationsHome({
: activity === "call" && recentCall
? `${recentCallHostName} · ${stamped(recentCall.occurred_at, locale, t)} · ${callRowDetail(recentCall, locale)}${callState === "incomplete" ? t("apps.incompleteCalls") : ""}`
: activity === "memory" && latest
? [time, latestSourceNames.join(", ")]
? (latest.kind === "raw"
? [
t("apps.rawConversation"),
latestSourceNames.join(", "),
projectionTimeBasisLabel(latest.timeBasis, t),
time,
]
: [
time,
latestSourceNames.join(", "),
projectionTimeBasisLabel(latest.timeBasis, t),
]
)
.filter(Boolean)
.join(" · ")
: activity === "importing"
? t("apps.savingConversations")
: activity === "loading"
? t("apps.recentWillUpdate")
: t("apps.waitingMemories")}
: viewModel.activity.detailKey
? t(viewModel.activity.detailKey)
: ""}
</small>
</section>
{(activity === "unavailable" ||
@@ -987,16 +1122,12 @@ export function ApplicationsHome({
setRevision((n) => n + 1);
setCallRevision((n) => n + 1);
}
: activity === "call"
? onCalls
: () => onMemories(latest?.memory_id)
: onCalls
}
>
{activity === "unavailable"
? t("apps.reread")
: activity === "call"
? t("apps.viewCalls")
: t("apps.viewMemory")}
: t("apps.viewCalls")}
{activity === "unavailable" ? null : (
<ArrowRight size={14} aria-hidden="true" />
)}
@@ -1049,8 +1180,12 @@ export function ApplicationsHome({
<dd>{syncState}</dd>
</div>
<div>
<dt>{t("apps.organizing")}</dt>
<dd>{stateLabel}</dd>
<dt>{t(viewModel.organizingTitle)}</dt>
<dd>{t(viewModel.organizingLabel as never)}</dd>
</div>
<div>
<dt>{t("apps.currentMode")}</dt>
<dd>{viewModel.currentModeLabel}</dd>
</div>
<div>
<dt>{t("apps.access")}</dt>
+10 -10
View File
@@ -159,7 +159,9 @@ it("traduce el historial en vivo sin cambiar nombres ni contenido importado", as
expect(
screen.getByRole("heading", { name: "Historial de uso" }),
).toBeTruthy();
expect(first.textContent).toBe("Cursor encontró 3 contenidos relacionados");
expect(first.textContent).toBe(
"Cursor encontró 1 memoria · 2 conversaciones originales",
);
fireEvent.click(first);
expect(
screen.getByRole("heading", { name: "Esta consulta de memorias" }),
@@ -364,7 +366,7 @@ it("shows exact identity, timestamp, result counts and metadata-only references"
).toBeTruthy();
expect(
within(detail).getByText((value) =>
value.includes("Cursor 查到 3 条相关内容"),
value.includes("Cursor 找到 1 条记忆 · 2 条原始会话"),
),
).toBeTruthy();
expect(within(detail).getAllByText("source-1").length).toBeGreaterThan(0);
@@ -465,13 +467,9 @@ it("keeps detail bound to event ID across refresh reordering, then removes missi
});
it.each([
["grounded", "查询成功", "直接调用(诊断) 查到 0 条相关内容"],
["grounded", "查询成功", "直接调用(诊断) 调用已完成"],
["empty", "查询成功,没有找到相关记忆", "直接调用(诊断) 没有查到相关记忆"],
[
"partial",
"查询成功,部分内容还在整理中",
"直接调用(诊断) 查到 0 条相关内容",
],
["partial", "查询成功,部分内容还在整理中", "直接调用(诊断) 调用已完成"],
[
"timed_out",
"查询超时,没有提供记忆",
@@ -546,11 +544,13 @@ it.each(["om_recall", "om_conversations", "om_ask", "om_matters"] as const)(
mount();
expect(
await screen.findByRole("button", {
name: "Cursor 查到 3 条相关内容",
name: "Cursor 找到 1 条记忆 · 2 条原始会话",
}),
).toBeTruthy();
fireEvent.click(
screen.getByRole("button", { name: "Cursor 查到 3 条相关内容" }),
screen.getByRole("button", {
name: "Cursor 找到 1 条记忆 · 2 条原始会话",
}),
);
expect(
within(screen.getByRole("article")).getByText("查记忆"),
@@ -0,0 +1,289 @@
// @vitest-environment jsdom
import type {
EmbeddingSwitchCurrent,
EmbeddingSwitchJob,
EmbeddingSwitchPlan,
} from "@one-memory/contracts";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
const cancelCurrentSwitch = vi.fn();
let current: EmbeddingSwitchCurrent | null = null;
vi.mock("./use-embedding-switch", () => ({
useEmbeddingSwitch: () => ({
current,
busy: null,
cancelCurrentSwitch,
}),
}));
vi.mock("./api", () => ({
deleteRetiredEmbeddingSpace: vi.fn(),
}));
import {
EmbeddingSwitchConfirmDialog,
EmbeddingSwitchGlobalStatus,
} from "./EmbeddingSwitchExperience";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
const NOW = "2026-11-03T09:00:00Z";
const space = (id: string | null, model: string) => ({
space_id: id,
model,
dims: model === "old-search" ? 512 : 1024,
collection_base: id ? "memory" : "",
vectors: id ? 8421 : null,
});
const plan: EmbeddingSwitchPlan = {
needed: true,
reason: "embedding_identity_changed",
plan_token: "token",
source: space("space-old", "old-search"),
target: space(null, "new-search"),
target_profile_id: "profile-1",
target_revision: 2,
target_provider_kind: "openai_compatible",
target_local: false,
sources: [
{
space_id: "space-old",
collection_base: "memory",
dims: 512,
vectors: 8421,
unmerged: null,
},
],
total_vectors: 8421,
users: 1,
graph_vectors: null,
estimate_s: 120,
requests: 43,
disk_bytes_needed: 72 * 1024 * 1024,
disk_bytes_free: 1024 * 1024 * 1024,
retention_days: 14,
blockers: [],
};
const job: EmbeddingSwitchJob = {
job_id: "job-1",
kind: "switch",
state: "reembedding",
source: space("space-old", "old-search"),
target: space("space-new", "new-search"),
stray_space_ids: [],
target_profile_id: "profile-1",
target_revision: 2,
requested_by: "app",
progress: {
phase: "vectors",
done: 4884,
total: 8421,
percent: 58,
embedded: 4884,
reused: 0,
kept: 0,
role: "memory",
source_index: 0,
sources: 1,
rate_per_s: 90,
eta_s: 52,
updated_at: NOW,
},
cancel_requested: false,
error_code: null,
attempt: 1,
created_at: NOW,
updated_at: NOW,
finished_at: null,
};
afterEach(() => {
cleanup();
current = null;
localStorage.removeItem(LANGUAGE_STORAGE_KEY);
vi.clearAllMocks();
});
it("switches Chinese, Spanish, and English without leaking Chinese", async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-Hans");
const view = render(
<LanguageProvider>
<EmbeddingSwitchConfirmDialog
plan={{ ...plan, users: 2 }}
busy={false}
errorCode={null}
onCancel={vi.fn()}
onStart={vi.fn()}
/>
</LanguageProvider>,
);
await waitFor(() =>
expect(
screen.getByRole("heading", { name: "更新记忆搜索索引?" }),
).toBeTruthy(),
);
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
window.dispatchEvent(
new StorageEvent("storage", { key: LANGUAGE_STORAGE_KEY }),
);
await waitFor(() =>
expect(
screen.getByRole("heading", {
name: "¿Actualizar el índice de búsqueda de memorias?",
}),
).toBeTruthy(),
);
expect(view.container.textContent).not.toMatch(/[一-鿿]/);
expect(view.container.textContent).toContain("8421");
localStorage.setItem(LANGUAGE_STORAGE_KEY, "en");
window.dispatchEvent(
new StorageEvent("storage", { key: LANGUAGE_STORAGE_KEY }),
);
await waitFor(() =>
expect(
screen.getByRole("heading", {
name: "Update the memory search index?",
}),
).toBeTruthy(),
);
expect(view.container.textContent).not.toMatch(/[一-鿿]/);
expect(view.container.textContent).toContain("8,421");
});
it("explains the index update in user terms before exposing technical details", async () => {
const onStart = vi.fn();
render(
<EmbeddingSwitchConfirmDialog
plan={plan}
busy={false}
errorCode={null}
onCancel={vi.fn()}
onStart={onStart}
/>,
);
expect(
screen.getByRole("heading", { name: "更新记忆搜索索引?" }),
).toBeTruthy();
expect(screen.getByText("8,421 条")).toBeTruthy();
expect(screen.getByText("约 2 分钟")).toBeTruthy();
expect(screen.getByText(/失败或取消时仍使用原来的模型/)).toBeTruthy();
await userEvent.click(screen.getByText("查看技术详情"));
expect(screen.getByText("512 → 1024")).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: "开始更新索引" }));
expect(onStart).toHaveBeenCalledTimes(1);
});
it("blocks start when Core reports a prerequisite", () => {
render(
<EmbeddingSwitchConfirmDialog
plan={{ ...plan, blockers: ["insufficient_disk_space"] }}
busy={false}
errorCode={null}
onCancel={vi.fn()}
onStart={vi.fn()}
/>,
);
expect(screen.getByText(/剩余空间不足/)).toBeTruthy();
expect(screen.getByRole("button", { name: "开始更新索引" })).toHaveProperty(
"disabled",
true,
);
});
it("keeps a running switch visible and cancellable outside settings", async () => {
current = {
schema_version: 1,
phase: "running",
writes_paused: true,
scan_state: "done",
scan_error: null,
job,
last_finished: null,
};
render(
<EmbeddingSwitchGlobalStatus
onConfigureSearch={vi.fn()}
onDiagnostics={vi.fn()}
/>,
);
expect(screen.getByText("正在更新记忆索引")).toBeTruthy();
expect(screen.getByRole("progressbar").getAttribute("aria-valuenow")).toBe(
"58",
);
await userEvent.click(screen.getByRole("button", { name: "取消更新" }));
expect(cancelCurrentSwitch).toHaveBeenCalledTimes(1);
});
it("shows startup index inspection without blocking the rest of the app", () => {
current = {
schema_version: 1,
phase: "scanning",
writes_paused: true,
scan_state: "scanning",
scan_error: null,
job: null,
last_finished: null,
};
render(
<EmbeddingSwitchGlobalStatus
onConfigureSearch={vi.fn()}
onDiagnostics={vi.fn()}
/>,
);
expect(screen.getByText("正在检查本机的记忆搜索索引")).toBeTruthy();
expect(screen.getByText(/搜索仍可使用/)).toBeTruthy();
});
it("explains rollback safety and gives recovery actions", async () => {
current = {
schema_version: 1,
phase: "running",
writes_paused: true,
scan_state: "done",
scan_error: null,
job,
last_finished: null,
};
const onConfigureSearch = vi.fn();
const onDiagnostics = vi.fn();
const view = render(
<EmbeddingSwitchGlobalStatus
onConfigureSearch={onConfigureSearch}
onDiagnostics={onDiagnostics}
/>,
);
current = {
schema_version: 1,
phase: "idle",
writes_paused: false,
scan_state: "done",
scan_error: null,
job: null,
last_finished: {
...job,
state: "rolled_back",
error_code: "embedding_service_failed",
progress: { ...job.progress, phase: "done" },
finished_at: NOW,
},
};
view.rerender(
<EmbeddingSwitchGlobalStatus
onConfigureSearch={onConfigureSearch}
onDiagnostics={onDiagnostics}
/>,
);
await waitFor(() => expect(screen.getByText(/记忆没有丢失/)).toBeTruthy());
await userEvent.click(screen.getByRole("button", { name: "检查连接" }));
expect(onConfigureSearch).toHaveBeenCalledTimes(1);
});
@@ -0,0 +1,480 @@
import type {
EmbeddingSwitchCurrent,
EmbeddingSwitchJob,
EmbeddingSwitchPlan,
} from "@one-memory/contracts";
import type { GuiLocale, MessageKey } from "@one-memory/i18n";
import { ChevronDown, CircleAlert, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { deleteRetiredEmbeddingSpace } from "./api";
import { useGuiLanguage } from "./gui-language";
import { R6Button } from "./R6Controls";
import { useEmbeddingSwitch } from "./use-embedding-switch";
import "./embedding-switch.css";
type Translator = (
key: MessageKey,
params?: Record<string, string | number>,
) => string;
function count(locale: GuiLocale, value: number | null | undefined) {
return new Intl.NumberFormat(locale).format(value ?? 0);
}
function duration(locale: GuiLocale, t: Translator, seconds: number | null) {
if (seconds === null) return t("embeddingSwitch.duration.unknown");
const value = Math.max(1, Math.round(seconds < 60 ? seconds : seconds / 60));
return t(
seconds < 60
? "embeddingSwitch.duration.seconds"
: "embeddingSwitch.duration.minutes",
{ count: count(locale, value) },
);
}
function size(locale: GuiLocale, bytes: number) {
const megabytes = bytes >= 1024 * 1024;
const value = Math.ceil(bytes / 1024 / (megabytes ? 1024 : 1));
return `${count(locale, value)} ${megabytes ? "MB" : "KB"}`;
}
const blockerKeys: Record<string, MessageKey> = {
embedding_probe_required: "embeddingSwitch.blocker.embedding_probe_required",
embedding_dimensions_unknown:
"embeddingSwitch.blocker.embedding_dimensions_unknown",
embedding_switch_requires_migration:
"embeddingSwitch.blocker.embedding_switch_requires_migration",
sdk_reembed_unavailable: "embeddingSwitch.blocker.sdk_reembed_unavailable",
insufficient_disk_space: "embeddingSwitch.blocker.insufficient_disk_space",
embedding_switch_running: "embeddingSwitch.blocker.embedding_switch_running",
memory_lifecycle_busy: "embeddingSwitch.blocker.memory_lifecycle_busy",
};
const failureKeys: Record<string, MessageKey> = {
writes_did_not_drain: "embeddingSwitch.failure.writes_did_not_drain",
embedding_service_failed: "embeddingSwitch.failure.embedding_service_failed",
embedding_dimensions_mismatch:
"embeddingSwitch.failure.embedding_dimensions_mismatch",
embedding_verification_failed:
"embeddingSwitch.failure.embedding_verification_failed",
target_profile_changed: "embeddingSwitch.failure.target_profile_changed",
embedding_switch_cancelled:
"embeddingSwitch.failure.embedding_switch_cancelled",
model_secret_unavailable: "embeddingSwitch.failure.model_secret_unavailable",
secure_storage_unavailable:
"embeddingSwitch.failure.secure_storage_unavailable",
embedding_switch_clients_busy:
"embeddingSwitch.failure.embedding_switch_clients_busy",
};
export function EmbeddingSwitchConfirmDialog({
plan,
busy,
errorCode,
onCancel,
onStart,
}: {
plan: EmbeddingSwitchPlan;
busy: boolean;
errorCode: string | null;
onCancel(): void;
onStart(): void;
}) {
const { locale, t } = useGuiLanguage();
const panel = useRef<HTMLElement>(null);
const blockers = plan.blockers.map((code) =>
t(blockerKeys[code] ?? "embeddingSwitch.blocker.default"),
);
useEffect(() => {
const opener =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
panel.current
?.querySelector<HTMLButtonElement>("button:not(:disabled)")
?.focus();
return () => {
if (opener?.isConnected) opener.focus();
};
}, []);
return (
<div
className="r6-settings-dialog-layer embedding-switch-dialog-layer"
role="presentation"
>
<section
ref={panel}
className="r6-settings-dialog embedding-switch-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="embedding-switch-title"
onKeyDown={(event) => {
if (event.key === "Escape" && !busy) onCancel();
if (event.key !== "Tab" || !panel.current) return;
const controls = [
...panel.current.querySelectorAll<HTMLElement>(
"button:not(:disabled), summary, input:not(:disabled), select:not(:disabled)",
),
];
const first = controls[0];
const last = controls.at(-1);
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}}
>
<header>
<div>
<h2 id="embedding-switch-title">
{t("embeddingSwitch.confirm.title")}
</h2>
<p>{t("embeddingSwitch.confirm.description")}</p>
</div>
</header>
<dl className="embedding-switch-impact">
<div>
<dt>{t("embeddingSwitch.impact.memories")}</dt>
<dd>
{t("embeddingSwitch.impact.memoryCount", {
count: count(locale, plan.total_vectors),
})}
{plan.users && plan.users > 1
? t("embeddingSwitch.impact.userCount", {
count: count(locale, plan.users),
})
: ""}
</dd>
</div>
<div>
<dt>{t("embeddingSwitch.impact.duration")}</dt>
<dd>{duration(locale, t, plan.estimate_s)}</dd>
</div>
<div>
<dt>{t("embeddingSwitch.impact.requests")}</dt>
<dd>
{plan.target_local
? t("embeddingSwitch.requests.none")
: plan.requests === null
? t("embeddingSwitch.requests.billed")
: t("embeddingSwitch.requests.approx", {
count: count(locale, plan.requests),
})}
</dd>
</div>
</dl>
<div className="embedding-switch-safety">
<strong>{t("embeddingSwitch.safety.searchTitle")}</strong>
<p>{t("embeddingSwitch.safety.searchBody")}</p>
<strong>{t("embeddingSwitch.safety.dataTitle")}</strong>
<p>{t("embeddingSwitch.safety.dataBody")}</p>
</div>
<details className="embedding-switch-details">
<summary>
<ChevronDown size={14} aria-hidden="true" />
{t("embeddingSwitch.details.title")}
</summary>
<dl>
<div>
<dt>{t("embeddingSwitch.details.currentModel")}</dt>
<dd>
{plan.source.model ?? t("embeddingSwitch.details.legacyIndex")}
</dd>
</div>
<div>
<dt>{t("embeddingSwitch.details.newModel")}</dt>
<dd>
{plan.target.model ?? t("embeddingSwitch.details.pending")}
</dd>
</div>
<div>
<dt>{t("embeddingSwitch.details.dimensions")}</dt>
<dd>
{plan.source.dims ?? t("embeddingSwitch.details.unknown")} →{" "}
{plan.target.dims ?? t("embeddingSwitch.details.unknown")}
</dd>
</div>
<div>
<dt>{t("embeddingSwitch.details.disk")}</dt>
<dd>{size(locale, plan.disk_bytes_needed)}</dd>
</div>
<div>
<dt>{t("embeddingSwitch.details.retention")}</dt>
<dd>
{t("embeddingSwitch.details.days", {
count: count(locale, plan.retention_days),
})}
</dd>
</div>
</dl>
</details>
{blockers.length > 0 ? (
<div className="embedding-switch-alert" role="alert">
<CircleAlert size={16} aria-hidden="true" />
<span>{blockers.join(" ")}</span>
</div>
) : null}
{errorCode ? (
<div className="embedding-switch-alert" role="alert">
<CircleAlert size={16} aria-hidden="true" />
<span>
{errorCode === "embedding_switch_plan_stale"
? t("embeddingSwitch.error.planStale")
: t("embeddingSwitch.error.startFailed")}
</span>
</div>
) : null}
<footer>
<R6Button disabled={busy} onClick={onCancel}>
{t("embeddingSwitch.action.notNow")}
</R6Button>
<R6Button
variant="primary"
pending={busy}
disabled={blockers.length > 0}
onClick={onStart}
>
{t("embeddingSwitch.action.start")}
</R6Button>
</footer>
</section>
</div>
);
}
function jobTitle(t: Translator, job: EmbeddingSwitchJob) {
if (job.kind === "merge") return t("embeddingSwitch.job.merge");
if (job.state === "preparing") return t("embeddingSwitch.job.preparing");
if (job.state === "reembedding") return t("embeddingSwitch.job.reembedding");
if (job.state === "verifying") return t("embeddingSwitch.job.verifying");
if (job.state === "committing") return t("embeddingSwitch.job.committing");
if (job.state === "failed") return t("embeddingSwitch.job.failed");
return t("embeddingSwitch.job.default");
}
function canCancel(job: EmbeddingSwitchJob) {
return ["preparing", "reembedding", "verifying"].includes(job.state);
}
function ActiveSwitchBanner({
current,
onCancel,
cancelling,
}: {
current: EmbeddingSwitchCurrent;
onCancel(): void;
cancelling: boolean;
}) {
const { locale, t } = useGuiLanguage();
const job = current.job;
if (!job && current.phase !== "scanning") return null;
const percent = job?.progress.percent ?? 0;
return (
<aside className="embedding-switch-strip" aria-live="polite">
<div className="embedding-switch-strip-copy">
<strong>
{job ? jobTitle(t, job) : t("embeddingSwitch.job.scanning")}
</strong>
<span>
{job?.state === "failed"
? t("embeddingSwitch.job.failedSafety")
: job
? `${new Intl.NumberFormat(locale, {
style: "percent",
maximumFractionDigits: 0,
}).format(percent / 100)}${
job.progress.eta_s === null
? ""
: ` · ${duration(locale, t, job.progress.eta_s)}`
}`
: t("embeddingSwitch.job.scanPaused")}
</span>
</div>
{job ? (
<div
className="embedding-switch-progress"
role="progressbar"
aria-label={t("embeddingSwitch.progress.aria")}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(percent)}
>
<span style={{ width: `${Math.max(2, percent)}%` }} />
</div>
) : null}
{job && canCancel(job) ? (
<R6Button variant="quiet" pending={cancelling} onClick={onCancel}>
{t("embeddingSwitch.action.cancel")}
</R6Button>
) : null}
</aside>
);
}
export function EmbeddingSwitchGlobalStatus({
onConfigureSearch,
onDiagnostics,
}: {
onConfigureSearch(): void;
onDiagnostics(): void;
}) {
const { t } = useGuiLanguage();
const controller = useEmbeddingSwitch();
const [recent, setRecent] = useState<EmbeddingSwitchJob | null>(null);
const [cleaning, setCleaning] = useState(false);
const [cleanupError, setCleanupError] = useState(false);
const previousJob = useRef<string | null>(null);
const alive = useRef(true);
useEffect(
() => () => {
alive.current = false;
},
[],
);
useEffect(() => {
const open = controller.current?.job?.job_id ?? null;
if (
previousJob.current &&
!open &&
controller.current?.last_finished?.job_id === previousJob.current
) {
setRecent(controller.current.last_finished);
}
previousJob.current = open;
}, [controller.current]);
return (
<>
{controller.current ? (
<ActiveSwitchBanner
current={controller.current}
cancelling={controller.busy === "cancel"}
onCancel={() => void controller.cancelCurrentSwitch()}
/>
) : null}
{controller.refreshFailed && controller.supported === true ? (
<aside className="embedding-switch-outcome is-error" role="status">
<div>
<strong>{t("embeddingSwitch.refresh.title")}</strong>
<span>{t("embeddingSwitch.refresh.body")}</span>
</div>
<R6Button variant="quiet" onClick={() => void controller.refresh()}>
{t("embeddingSwitch.action.refresh")}
</R6Button>
</aside>
) : null}
{controller.current &&
["failed", "unavailable"].includes(controller.current.scan_state) ? (
<aside className="embedding-switch-outcome is-error" role="alert">
<div>
<strong>{t("embeddingSwitch.scan.title")}</strong>
<span>
{controller.current.writes_paused
? t("embeddingSwitch.scan.paused")
: t("embeddingSwitch.scan.recovered")}
</span>
</div>
<R6Button variant="quiet" onClick={onDiagnostics}>
{t("embeddingSwitch.action.diagnostics")}
</R6Button>
</aside>
) : null}
{recent?.state === "done" ? (
<aside
className="embedding-switch-outcome"
role="status"
aria-live="polite"
>
<div>
<strong>{t("embeddingSwitch.success.title")}</strong>
<span>{t("embeddingSwitch.success.body")}</span>
</div>
<div className="embedding-switch-outcome-actions">
{recent.source.space_id ? (
<R6Button
variant="quiet"
pending={cleaning}
onClick={() => {
if (!recent.source.space_id) return;
setCleaning(true);
setCleanupError(false);
void deleteRetiredEmbeddingSpace(recent.source.space_id)
.then(() => {
if (alive.current) setRecent(null);
})
.catch(() => {
if (alive.current) setCleanupError(true);
})
.finally(() => {
if (alive.current) setCleaning(false);
});
}}
>
{t("embeddingSwitch.action.cleanup")}
</R6Button>
) : null}
<R6Button
variant="quiet"
aria-label={t("embeddingSwitch.action.closeSuccess")}
onClick={() => setRecent(null)}
>
<X size={16} aria-hidden="true" />
</R6Button>
</div>
</aside>
) : null}
{cleanupError ? (
<p className="embedding-switch-cleanup-error" role="alert">
{t("embeddingSwitch.cleanup.failed")}
</p>
) : null}
{recent?.state === "rolled_back" ? (
<aside
className="embedding-switch-outcome is-error"
role="alert"
aria-live="assertive"
>
<div>
<strong>{t("embeddingSwitch.rollback.title")}</strong>
<span>
{failureKeys[recent.error_code ?? ""]
? t("embeddingSwitch.rollback.reason", {
reason: t(failureKeys[recent.error_code ?? ""]),
safety: t("embeddingSwitch.rollback.safety"),
})
: t("embeddingSwitch.rollback.safety")}
</span>
</div>
<div className="embedding-switch-outcome-actions">
<R6Button variant="quiet" onClick={onDiagnostics}>
{t("embeddingSwitch.action.diagnostics")}
</R6Button>
<R6Button variant="quiet" onClick={onConfigureSearch}>
{t("embeddingSwitch.action.checkConnection")}
</R6Button>
<R6Button
variant="quiet"
aria-label={t("embeddingSwitch.action.closeFailure")}
onClick={() => setRecent(null)}
>
<X size={16} aria-hidden="true" />
</R6Button>
</div>
</aside>
) : null}
</>
);
}
@@ -239,7 +239,7 @@ it("emits controlled connection drafts only and passes continuation without comp
screen.getByRole<HTMLInputElement>("checkbox", { name: "一键接入应用" })
.checked,
).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "配置模型,沉淀记忆" }));
fireEvent.click(screen.getByRole("button", { name: "选择记忆模式" }));
expect(next).toHaveBeenCalledWith("settings");
});
@@ -379,8 +379,8 @@ it("uses user-facing connection confirmation copy without implementation narrati
expect(
screen.queryByText(/选择草稿|按各应用回执|本页任务控制尚未接通/),
).toBeNull();
expect(screen.getByText(/不会自动开放应用读取权限/)).toBeTruthy();
expect(screen.getByText(/配置模型后,才会开始生成记忆/)).toBeTruthy();
expect(screen.getByText(/下一步选择记忆模式/)).toBeTruthy();
expect(screen.getByText(/之后仍可在设置中更改/)).toBeTruthy();
});
it.each(["paused", "failed", "cancelled", "interrupted"] as const)(
@@ -147,7 +147,7 @@ it("explains that one large harness limits every first-import source to three da
it("continues to step3 while sample GET is pending without calling completion APIs", () => {
api.receipt.mockReturnValue(new Promise(() => {}));
const { props } = setup();
fireEvent.click(screen.getByRole("button", { name: "配置模型,沉淀记忆" }));
fireEvent.click(screen.getByRole("button", { name: "选择记忆模式" }));
expect(props.onContinue).toHaveBeenCalledWith("settings");
expect(screen.queryByText(/^已生成.*记忆/)).toBeNull();
});
+2 -4
View File
@@ -373,7 +373,7 @@ export function FirstImportReview({
disabled={busy}
onClick={() => leave("settings")}
>
{text("配置模型,沉淀记忆")}
{text("选择记忆模式")}
</R6Button>
)}
</div>
@@ -393,9 +393,7 @@ export function FirstImportReview({
)}
</p>
<p className="first-import-r6-subintro">
{text(
"导入不会自动开放应用读取权限。配置模型后,才会开始生成记忆。",
)}
{text("下一步选择记忆模式;之后仍可在设置中更改。")}
</p>
{historyWindowLimited && (
<p className="first-import-r6-history-window" role="status">
@@ -0,0 +1,345 @@
import type { ProviderKind } from "@one-memory/contracts";
import { ChevronDown, ChevronUp } from "lucide-react";
import type { ReactNode, Ref } from "react";
import { OptionMenu } from "./OptionMenu";
import { PROVIDER_PRESETS } from "./provider-presets";
export interface GenerationModelValues {
provider_kind: ProviderKind;
model: string;
base_url: string;
timeout_seconds: string;
max_concurrency: string;
context_window: string;
disable_thinking: boolean;
}
export type GenerationModelField = keyof GenerationModelValues | "api_key";
export type GenerationModelErrors = Partial<
Record<GenerationModelField, string>
>;
export const GENERATION_PROTOCOLS: readonly {
value: ProviderKind;
label: string;
supported: boolean;
}[] = [
{
value: "openai_compatible",
label: "OpenAI-compatible",
supported: true,
},
{ value: "ollama", label: "Ollama(本机)", supported: true },
{ value: "anthropic", label: "Anthropic(暂不支持)", supported: false },
{ value: "hy_default", label: "HY 托管(当前不可用)", supported: false },
];
export function presetForGeneration(profile: {
provider_kind: string;
base_url: string | null;
}): string {
return (
PROVIDER_PRESETS.find(
(preset) =>
preset.provider === profile.provider_kind &&
endpointKey(preset.baseUrl) === endpointKey(profile.base_url ?? ""),
)?.id ?? "custom"
);
}
export function generationProviderLabel(provider: ProviderKind): string {
return (
GENERATION_PROTOCOLS.find((item) => item.value === provider)?.label ??
provider
);
}
export function generationSummary(
presetId: string,
provider: ProviderKind,
model: string,
text: (value: string) => string,
): string {
const preset = PROVIDER_PRESETS.find((item) => item.id === presetId);
return `${preset ? text(preset.label) : text(generationProviderLabel(provider))} · ${
model.trim() || text("模型待填写")
}`;
}
export function validGenerationEndpoint(value: string): boolean {
try {
const url = new URL(value);
return (
["http:", "https:"].includes(url.protocol) &&
!!url.hostname &&
!url.username &&
!url.password
);
} catch {
return false;
}
}
export function validateGenerationModel(
values: GenerationModelValues,
options: {
apiKey: string;
keyRequired: boolean;
requireRuntimeValues?: boolean;
},
): GenerationModelErrors {
const errors: GenerationModelErrors = {};
if (!values.model.trim()) errors.model = "请填写实际可用的模型 ID。";
if (!validGenerationEndpoint(values.base_url))
errors.base_url = "请填写有效的模型接口地址。";
if (
options.apiKey.trim() &&
/^(?:[a-z][a-z\d+.-]*:\/\/|www\.|\/\/)/i.test(options.apiKey.trim())
)
errors.api_key = "这里需要 API Key,不是网址。请填写服务方提供的密钥。";
else if (options.keyRequired && !options.apiKey.trim())
errors.api_key = "请填写 API Key。";
const integer = (
key: "timeout_seconds" | "max_concurrency" | "context_window",
min: number,
max: number,
) => {
const raw = values[key].trim();
if (!raw && !options.requireRuntimeValues) return;
const value = Number(raw);
if (!raw || !Number.isInteger(value) || value < min || value > max)
errors[key] = "onboardingIntegerRange";
};
integer("timeout_seconds", 1, 600);
integer("max_concurrency", 1, 32);
if (values.context_window.trim()) integer("context_window", 1024, 10_000_000);
return errors;
}
export function ProviderPresetField({
id,
labelledBy,
ariaLabel,
describedBy,
value,
disabled,
text,
includePlaceholder = false,
onChange,
}: {
id?: string;
labelledBy?: string;
ariaLabel?: string;
describedBy?: string;
value: string;
disabled?: boolean;
text: (value: string) => string;
includePlaceholder?: boolean;
onChange(value: string): void;
}) {
return (
<OptionMenu
id={id}
labelledBy={labelledBy}
ariaLabel={ariaLabel}
describedBy={describedBy}
value={value}
disabled={disabled}
options={[
...(includePlaceholder && !value
? [{ value: "", label: text("选择服务商"), disabled: true }]
: []),
...PROVIDER_PRESETS.map((preset) => ({
value: preset.id,
label: text(preset.label),
})),
{ value: "custom", label: text("自定义兼容服务") },
]}
onChange={onChange}
/>
);
}
export function GenerationTechnicalDisclosure({
open,
disabled,
controls,
text,
onChange,
children,
}: {
open: boolean;
disabled?: boolean;
controls: string;
text: (value: string) => string;
onChange(open: boolean): void;
children: ReactNode;
}) {
return (
<section className="generation-technical-disclosure">
<button
type="button"
className="generation-technical-toggle"
aria-expanded={open}
aria-controls={controls}
disabled={disabled}
onClick={() => onChange(!open)}
>
{text("配置详情与高级设置")}
{open ? (
<ChevronUp size={16} aria-hidden="true" />
) : (
<ChevronDown size={16} aria-hidden="true" />
)}
</button>
<div id={controls} className="generation-technical-fields" hidden={!open}>
{children}
</div>
</section>
);
}
export function GenerationTechnicalFields({
idPrefix,
values,
errors = {},
disabled,
text,
modelRef,
onChange,
}: {
idPrefix: string;
values: GenerationModelValues;
errors?: GenerationModelErrors;
disabled?: boolean;
text: (value: string) => string;
modelRef?: Ref<HTMLInputElement>;
onChange(patch: Partial<GenerationModelValues>): void;
}) {
const field = (
key:
| "model"
| "base_url"
| "timeout_seconds"
| "max_concurrency"
| "context_window",
label: string,
options: {
type?: string;
min?: number;
max?: number;
placeholder?: string;
ref?: Ref<HTMLInputElement>;
} = {},
) => {
const error = errors[key];
const inputId = `${idPrefix}-${key}`;
return (
<label className="generation-model-field" htmlFor={inputId}>
<span>{text(label)}</span>
<input
ref={options.ref}
id={inputId}
name={key}
aria-label={text(label)}
type={options.type ?? "text"}
inputMode={options.type === "url" ? "url" : undefined}
autoComplete="off"
spellCheck={false}
value={values[key]}
disabled={disabled}
min={options.min}
max={options.max}
maxLength={key === "model" ? 256 : undefined}
placeholder={
options.placeholder ? text(options.placeholder) : undefined
}
aria-invalid={!!error}
aria-describedby={error ? `${inputId}-error` : undefined}
onChange={(event) => onChange({ [key]: event.target.value })}
/>
{error ? (
<small id={`${inputId}-error`} className="generation-model-error">
{text(error)}
</small>
) : null}
</label>
);
};
return (
<>
<label
className="generation-model-field"
htmlFor={`${idPrefix}-protocol`}
>
<span>{text("协议")}</span>
<select
id={`${idPrefix}-protocol`}
name="provider"
aria-label={text("协议")}
value={values.provider_kind}
disabled={disabled}
onChange={(event) =>
onChange({ provider_kind: event.target.value as ProviderKind })
}
>
{GENERATION_PROTOCOLS.map((protocol) => (
<option
key={protocol.value}
value={protocol.value}
disabled={!protocol.supported}
>
{text(protocol.label)}
</option>
))}
</select>
</label>
{field("model", "生成模型名", {
placeholder: "例如:gpt-4o-mini…",
ref: modelRef,
})}
{field("base_url", "Base URL", { type: "url" })}
<div className="generation-runtime-fields">
{field("timeout_seconds", "超时(秒)", {
type: "number",
min: 1,
max: 600,
placeholder: "自动 / 可选",
})}
{field("max_concurrency", "最大并发", {
type: "number",
min: 1,
max: 32,
placeholder: "自动 / 可选",
})}
{field("context_window", "上下文窗口", {
type: "number",
min: 1024,
max: 10_000_000,
placeholder: "自动 / 可选",
})}
</div>
<small className="generation-runtime-note">
{text("留空使用 Core 默认值。")}
</small>
<label className="generation-thinking">
<input
name="disable-thinking"
type="checkbox"
checked={values.disable_thinking}
disabled={disabled}
onChange={(event) =>
onChange({ disable_thinking: event.target.checked })
}
/>
{text("关闭思考模式")}
</label>
</>
);
}
function endpointKey(value: string): string {
return value.trim().replace(/\/+$/, "").toLowerCase();
}
@@ -153,6 +153,58 @@ describe("ImportProgressStrip", () => {
expect(container.textContent).toBe("");
});
it("uses raw-saving copy and never offers model setup in lite mode", () => {
render(
<ImportProgressStrip
jobs={[job()]}
sources={sources}
backfilling={false}
onOpenSources={vi.fn()}
onConfigureModel={vi.fn()}
modeCapabilities={{
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
}}
arrival={{
importing: true,
importedMessages: 3,
importedConversations: 1,
phase: "model-required",
}}
/>,
);
expect(screen.getByRole("status").textContent).toContain(
"正在保存原始对话",
);
expect(screen.queryByRole("button", { name: "设置模型" })).toBeNull();
});
it("does not keep a lite progress strip mounted without active work", () => {
const { container } = render(
<ImportProgressStrip
jobs={[]}
sources={sources}
backfilling={false}
onOpenSources={vi.fn()}
modeCapabilities={{
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
}}
arrival={{
importing: false,
importedMessages: 3,
importedConversations: 1,
phase: "stopped",
}}
/>,
);
expect(container.textContent).toBe("");
});
it("shows one plain sentence for first history import without details", () => {
render(
<ImportProgressStrip
+46 -29
View File
@@ -1,4 +1,5 @@
import type {
HomeProjectionCapabilities,
ImportIssue,
ImportJob,
SourceResponse,
@@ -68,6 +69,7 @@ export interface ImportProgressStripProps {
showSourceLink?: boolean;
showProgressDetails?: boolean;
userFacing?: boolean;
modeCapabilities?: HomeProjectionCapabilities | null;
}
/** Named sources and confirmed counts, without claiming cached jobs are live. */
@@ -85,6 +87,7 @@ export function ImportProgressStrip({
showSourceLink = true,
showProgressDetails = true,
userFacing = false,
modeCapabilities,
}: ImportProgressStripProps) {
const { t, tn } = useGuiLanguage();
const [refreshing, setRefreshing] = useState(false);
@@ -212,7 +215,13 @@ export function ImportProgressStrip({
),
);
const currentArrival = arrival
? resolveMemoryArrival({ arrival, processing: null, sources, loaded: true })
? resolveMemoryArrival({
arrival,
processing: null,
sources,
loaded: true,
capabilities: modeCapabilities,
})
: null;
const needsAction =
currentArrival &&
@@ -253,11 +262,14 @@ export function ImportProgressStrip({
(sum, source) => sum + source.imported_conversations,
0,
);
const message = firstImport
? tn("importUserProgress", imported)
: currentArrival?.phase === "model-required"
? t("importUserModel")
: t("importUserIncomplete");
const message =
modeCapabilities?.generation_required === false
? t("apps.rawSaving")
: firstImport
? tn("importUserProgress", imported)
: currentArrival?.phase === "model-required"
? t("importUserModel")
: t("importUserIncomplete");
return (
<div
className="import-strip"
@@ -272,16 +284,18 @@ export function ImportProgressStrip({
)}
<strong>{message}</strong>
</div>
{!firstImport && onConfigureModel && (
<button
type="button"
className="secondary"
onClick={onConfigureModel}
>
<Settings2 size={15} aria-hidden="true" />
{t("importUserSettings")}
</button>
)}
{!firstImport &&
modeCapabilities?.generation_required !== false &&
onConfigureModel && (
<button
type="button"
className="secondary"
onClick={onConfigureModel}
>
<Settings2 size={15} aria-hidden="true" />
{t("importUserSettings")}
</button>
)}
</div>
</div>
);
@@ -322,19 +336,21 @@ export function ImportProgressStrip({
? updating
? "已保存内容保留,正在核对进度"
: "进度待更新"
: currentArrival && needsAction
? currentArrival.phase === "model-required"
? "待继续"
: memoryArrivalTitle(currentArrival)
: generating
? "正在沉淀记忆"
: importing || currentArrival?.phase === "importing"
? running.length > 0 && running.every(isSyncJob)
? "正在检查新增对话"
: "正在导入对话"
: backfilling
? "正在准备整理"
: "待继续";
: modeCapabilities?.generation_required === false
? t("apps.rawSaving")
: currentArrival && needsAction
? currentArrival.phase === "model-required"
? "待继续"
: memoryArrivalTitle(currentArrival)
: generating
? "正在沉淀记忆"
: importing || currentArrival?.phase === "importing"
? running.length > 0 && running.every(isSyncJob)
? "正在检查新增对话"
: "正在导入对话"
: backfilling
? "正在准备整理"
: "待继续";
return (
<div
@@ -645,6 +661,7 @@ export function ImportProgressStrip({
</button>
)}
{!stale &&
modeCapabilities?.generation_required !== false &&
currentArrival?.phase === "model-required" &&
onConfigureModel && (
<button type="button" className="quiet" onClick={onConfigureModel}>
+111 -1
View File
@@ -46,6 +46,7 @@ const api = vi.hoisted(() => ({
vi.mock("./api", () => api);
const REQUEST_ID = "11111111-2222-4333-8444-555555555555";
const OLD_REQUEST_ID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee";
const addition: MemoryAddReceipt = {
evidence_id: "evidence-1",
text: "项目周会不排在周五。",
@@ -122,13 +123,14 @@ it("explains source verification failure without retrying or claiming success",
expect(screen.queryByRole("button", { name: "重新整理" })).toBeNull();
});
function page() {
function page(rawOnly = false) {
const onSaved = vi.fn(),
onOpenMemory = vi.fn(),
onConfigureModel = vi.fn(),
onPendingChange = vi.fn();
const view = render(
<MemoryAddComposer
rawOnly={rawOnly}
{...{ onSaved, onOpenMemory, onConfigureModel, onPendingChange }}
/>,
);
@@ -198,6 +200,114 @@ it("saves from an empty library with the linked-app scope by default", async ()
expect(screen.queryByRole("button", { name: "查看记忆" })).toBeNull();
});
it("saves a lite manual addition as original content without model UI", async () => {
api.addMemory.mockResolvedValue({
...addition,
state: "no_memories",
});
const callbacks = page(true);
await userEvent.click(screen.getByRole("button", { name: "手动添加" }));
expect(screen.getByRole("dialog", { name: "手动添加" })).toBeTruthy();
expect(screen.queryByText(/整理模型:/)).toBeNull();
expect(api.getModelProfiles).not.toHaveBeenCalled();
await userEvent.type(
screen.getByRole("textbox", { name: "记下这件事" }),
addition.text,
);
await userEvent.click(screen.getByRole("button", { name: "保存" }));
expect(await screen.findByText("已保存原文")).toBeTruthy();
expect(callbacks.onSaved).toHaveBeenCalledOnce();
expect(
callbacks.onPendingChange.mock.calls.some(([value]) => value === null),
).toBe(true);
expect(screen.queryByRole("dialog", { name: "手动添加" })).toBeNull();
});
it("refreshes the lite raw page when an asynchronous manual save completes", async () => {
api.addMemory.mockResolvedValue({
...addition,
state: "running",
});
api.getMemoryAddStatus.mockResolvedValue({
...addition,
state: "no_memories",
});
const callbacks = page(true);
await userEvent.click(screen.getByRole("button", { name: "手动添加" }));
await userEvent.type(screen.getByRole("textbox"), addition.text);
await userEvent.click(screen.getByRole("button", { name: "保存" }));
await waitFor(() => expect(callbacks.onSaved).toHaveBeenCalledTimes(2), {
timeout: 2_500,
});
expect(screen.getByText("已保存原文")).toBeTruthy();
expect(callbacks.onPendingChange).toHaveBeenLastCalledWith(null);
});
it("does not treat an older completed addition as a newly saved raw turn", async () => {
localStorage.setItem(
RECENT_ADDITIONS_KEY,
JSON.stringify([REQUEST_ID, OLD_REQUEST_ID]),
);
let currentState: MemoryAddReceipt["state"] = "running";
const calls = new Map<string, number>();
api.getMemoryAddStatus.mockImplementation(async (requestId: string) => {
calls.set(requestId, (calls.get(requestId) ?? 0) + 1);
return requestId === OLD_REQUEST_ID
? {
...addition,
evidence_id: "old-evidence",
state: "no_memories",
}
: { ...addition, state: currentState };
});
const callbacks = page(true);
await waitFor(
() => expect(calls.get(REQUEST_ID) ?? 0).toBeGreaterThanOrEqual(2),
{ timeout: 2_500 },
);
expect(callbacks.onSaved).not.toHaveBeenCalled();
currentState = "no_memories";
await waitFor(() => expect(callbacks.onSaved).toHaveBeenCalledOnce(), {
timeout: 2_500,
});
});
it("keeps a failed lite raw write visible after the composer closes", async () => {
api.addMemory.mockResolvedValue({
...addition,
state: "running",
});
api.getMemoryAddStatus.mockResolvedValue({
...addition,
state: "failed",
});
const callbacks = page(true);
await userEvent.click(screen.getByRole("button", { name: "手动添加" }));
await userEvent.type(screen.getByRole("textbox"), addition.text);
await userEvent.click(screen.getByRole("button", { name: "保存" }));
expect(screen.queryByText("已保存原文")).toBeNull();
await waitFor(
() =>
expect(callbacks.onPendingChange).toHaveBeenLastCalledWith({
evidenceId: addition.evidence_id,
text: addition.text,
state: "failed",
}),
{ timeout: 2_500 },
);
await waitFor(() =>
expect(screen.getByText("原话已保存,整理未完成")).toBeTruthy(),
);
});
it("preserves draft and request identity after an uncertain save", async () => {
api.addMemory
.mockRejectedValueOnce(new Error("timeout"))
+118 -30
View File
@@ -103,12 +103,14 @@ export function MemoryAddComposer({
onConfigureModel,
onPendingChange,
suspended = false,
rawOnly = false,
}: {
onSaved(): void;
onOpenMemory(id: string): void;
onConfigureModel(): void;
onPendingChange?(addition: PendingMemoryAdd | null): void;
suspended?: boolean;
rawOnly?: boolean;
}) {
const { text: copy } = useMemoryLanguage();
const [open, setOpen] = useState(false);
@@ -128,6 +130,7 @@ export function MemoryAddComposer({
const additionsRequest = useRef(0);
const intent = useRef<{ text: string; requestId: string } | null>(null);
const completed = useRef(new Set<string>());
const watched = useRef(new Set<string>());
const lastSaved = useRef<string | null>(null);
const onSavedRef = useRef(onSaved);
onSavedRef.current = onSaved;
@@ -156,34 +159,72 @@ export function MemoryAddComposer({
}, []);
const refreshAdditions = useCallback(
async (failure: string, notifyResults = false) => {
async (
failure: string,
notifyResults = false,
allowWhileClosed = false,
) => {
const request = ++additionsRequest.current;
try {
const value = await loadRecentAdditions();
if (!isOpen.current || request !== additionsRequest.current) return;
if (
(!allowWhileClosed && !isOpen.current) ||
request !== additionsRequest.current
)
return;
setAdditions(value);
reportPending(value);
if (!notifyResults) return;
if (!notifyResults) {
for (const { receipt } of value) {
if (
receipt.state === "ready" ||
(rawOnly && receipt.state === "no_memories")
)
completed.current.add(receipt.evidence_id);
else if (receipt.state === "pending" || receipt.state === "running")
watched.current.add(receipt.evidence_id);
}
return;
}
const latest = value.find(
(item) => item.receipt.evidence_id === lastSaved.current,
);
if (latest) setNotice(states[latest.receipt.state]);
if (latest) {
setNotice(
rawOnly && latest.receipt.state === "no_memories"
? "已保存原文"
: states[latest.receipt.state],
);
if (rawOnly && latest.receipt.state === "failed")
setSavedToast(states.failed);
}
const newResults = value.filter(
({ receipt }) =>
receipt.state === "ready" &&
(receipt.state === "ready" ||
(rawOnly && receipt.state === "no_memories")) &&
watched.current.has(receipt.evidence_id) &&
!completed.current.has(receipt.evidence_id),
);
if (newResults.length) {
for (const { receipt } of newResults)
for (const { receipt } of newResults) {
completed.current.add(receipt.evidence_id);
watched.current.delete(receipt.evidence_id);
}
if (rawOnly) setSavedToast("已保存原文");
onSavedRef.current();
}
for (const { receipt } of value)
if (receipt.state === "failed")
watched.current.delete(receipt.evidence_id);
} catch {
if (isOpen.current && request === additionsRequest.current)
if (
(isOpen.current || allowWhileClosed) &&
request === additionsRequest.current
)
setError(failure);
}
},
[reportPending],
[rawOnly, reportPending],
);
useEffect(() => {
@@ -193,6 +234,15 @@ export function MemoryAddComposer({
const before = document.activeElement;
panel.current?.querySelector("textarea")?.focus();
void refreshAdditions("最近添加的内容读取失败。");
if (rawOnly) {
setModel(null);
setModelRead(true);
return () => {
isOpen.current = false;
additionsRequest.current += 1;
if (before instanceof HTMLElement && before.isConnected) before.focus();
};
}
setModelRead(false);
getModelProfiles()
.then((value) => {
@@ -211,32 +261,38 @@ export function MemoryAddComposer({
additionsRequest.current += 1;
if (before instanceof HTMLElement && before.isConnected) before.focus();
};
}, [open, refreshAdditions, suspended]);
}, [open, rawOnly, refreshAdditions, suspended]);
useEffect(() => {
if (open || suspended || !onPendingChange) return;
void loadRecentAdditions()
.then((value) => reportPending(value))
.catch(() => {
// The page already keeps the last known state when a background read
// fails; do not turn an unknown pending count into zero here.
});
}, [open, onPendingChange, reportPending, suspended]);
void refreshAdditions(
"最近添加的内容读取失败。",
lastSaved.current !== null,
true,
);
}, [open, onPendingChange, refreshAdditions, suspended]);
const working = additions.some(
({ receipt }) => receipt.state === "pending" || receipt.state === "running",
);
useEffect(() => {
if (!open || suspended || !working) return;
if (suspended || !working) return;
const stop = subscribePoll({
// A key of its own: sharing one with another surface would make one
// tick read the wrong endpoint for the other caller.
key: "memory-add-processing",
intervalMs: 1_500,
run: () => refreshAdditions("整理状态暂时不可用,原话已保存。", true),
run: () =>
refreshAdditions(
rawOnly
? "保存状态暂时不可用,原文已保存。"
: "整理状态暂时不可用,原话已保存。",
true,
!open,
),
});
return stop;
}, [open, working, refreshAdditions, suspended]);
}, [open, rawOnly, working, refreshAdditions, suspended]);
useEffect(() => {
if (!savedToast) return;
@@ -273,12 +329,27 @@ export function MemoryAddComposer({
setNotice("");
const trace = traceUiAction("memory", "submit");
const completeSave = (receipt: MemoryAddReceipt) => {
if (
receipt.state === "ready" ||
(rawOnly && receipt.state === "no_memories")
)
completed.current.add(receipt.evidence_id);
else if (receipt.state === "pending" || receipt.state === "running")
watched.current.add(receipt.evidence_id);
remember({ requestId, receipt });
lastSaved.current = receipt.evidence_id;
setText("");
intent.current = null;
setNotice("");
setSavedToast("已保存,正在后台沉淀记忆");
setSavedToast(
rawOnly
? receipt.state === "ready" || receipt.state === "no_memories"
? "已保存原文"
: receipt.state === "failed"
? states.failed
: "正在保存…"
: "已保存,正在后台沉淀记忆",
);
isOpen.current = false;
additionsRequest.current += 1;
setOpen(false);
@@ -321,7 +392,8 @@ export function MemoryAddComposer({
data-tour-id="memory-add-trigger"
onClick={() => setOpen(true)}
>
<FilePlus2 size={16} aria-hidden="true" /> {copy("添加记忆")}
<FilePlus2 size={16} aria-hidden="true" />{" "}
{copy(rawOnly ? "手动添加" : "添加记忆")}
</button>
{savedToast && (
<p className="memory-add-toast" role="status">
@@ -360,11 +432,13 @@ export function MemoryAddComposer({
}}
>
<header className="memory-add-heading">
<h2 id="memory-add-title">{copy("添加记忆")}</h2>
<h2 id="memory-add-title">
{copy(rawOnly ? "手动添加" : "添加记忆")}
</h2>
<button
className="quiet"
type="button"
aria-label={copy("关闭新增记忆")}
aria-label={copy(rawOnly ? "关闭" : "关闭新增记忆")}
title={copy("关闭")}
disabled={busy}
onClick={close}
@@ -390,11 +464,13 @@ export function MemoryAddComposer({
placeholder={copy("例如:项目周会不排在周五。")}
onChange={(event) => setText(event.target.value)}
/>
<p className="memory-add-model">
{copy("整理模型:")}
{model?.name ??
copy(modelRead ? "尚未配置,原话仍可保存" : "正在读取")}
</p>
{!rawOnly && (
<p className="memory-add-model">
{copy("整理模型:")}
{model?.name ??
copy(modelRead ? "尚未配置,原话仍可保存" : "正在读取")}
</p>
)}
{error && (
<p className="error-banner" role="alert">
{copy(error)}
@@ -402,7 +478,7 @@ export function MemoryAddComposer({
)}
{notice && <p role="status">{copy(notice)}</p>}
<div className="consent-actions">
{modelRead && !model && (
{!rawOnly && modelRead && !model && (
<button
type="button"
className="secondary"
@@ -430,7 +506,19 @@ export function MemoryAddComposer({
{additions.map(({ requestId, receipt }) => (
<li key={requestId}>
<p>{receipt.text}</p>
<small>{copy(states[receipt.state])}</small>
<small>
{copy(
rawOnly &&
(receipt.state === "pending" ||
receipt.state === "running")
? "正在保存…"
: rawOnly &&
(receipt.state === "ready" ||
receipt.state === "no_memories")
? "已保存原文"
: states[receipt.state],
)}
</small>
{memoryWriteErrorText(receipt.error) && (
<p className="error-banner">
{copy(memoryWriteErrorText(receipt.error) ?? "")}
+17 -1
View File
@@ -6,7 +6,11 @@ import type {
} from "@one-memory/contracts";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { MemoryGraph3D } from "./MemoryGraph3D";
import {
graphNodeTooltip,
isGraphNodeSelectable,
MemoryGraph3D,
} from "./MemoryGraph3D";
afterEach(cleanup);
@@ -48,6 +52,18 @@ function makeGraph(nodeCount: number): MemoryGraphResponse {
}
describe("MemoryGraph3D", () => {
it("keeps topic annotations hover-only", () => {
const topic = {
...makeGraph(1).nodes[0],
node_type: "topic" as const,
label: "CI",
};
expect(isGraphNodeSelectable(topic)).toBe(false);
expect(graphNodeTooltip(topic, "主题:")).toBe("主题:CI");
expect(graphNodeTooltip(topic, "Topic: ")).toBe("Topic: CI");
expect(isGraphNodeSelectable({ ...topic, node_type: "memory" })).toBe(true);
});
it("没有 WebGL 时给出可访问降级提示", () => {
render(
<MemoryGraph3D
+58 -24
View File
@@ -27,6 +27,20 @@ const SELECT_DURATION_MS = 180;
const SELECTED_COLOR = new THREE.Color(0x294355);
const SELECTED_EMISSIVE = new THREE.Color(0x607e97);
/** Topic nodes explain the graph; unlike memories and sources, they do not own a detail view. */
export function isGraphNodeSelectable(node: MemoryGraphNode): boolean {
return node.node_type !== "topic";
}
export function graphNodeTooltip(
node: MemoryGraphNode,
topicPrefix: string,
): string {
return node.node_type === "topic"
? `${topicPrefix}${node.label}`
: node.label;
}
export function MemoryGraph3D({
graph,
selectedNodeId,
@@ -36,10 +50,20 @@ export function MemoryGraph3D({
selectedNodeId: string | null;
onSelectNode(node: MemoryGraphNode): void;
}) {
const { text } = useMemoryLanguage();
const { locale, text } = useMemoryLanguage();
const host = useRef<HTMLDivElement>(null);
const labelHost = useRef<HTMLDivElement>(null);
const selectRef = useRef(onSelectNode);
const nodesByIdRef = useRef(
new Map(graph.nodes.map((node) => [node.node_id, node])),
);
nodesByIdRef.current = new Map(
graph.nodes.map((node) => [node.node_id, node]),
);
const topicPrefixRef = useRef("");
topicPrefixRef.current = `${text("主题")}${
locale.startsWith("zh") || locale.startsWith("ja") ? ":" : ": "
}`;
// 选中态通过 ref 传入渲染循环,而不是进 useEffect 依赖。
// 依赖里带 selectedNodeId 会导致每次点击重建整个场景、重跑布局、重建全部 mesh。
const selectedRef = useRef(selectedNodeId);
@@ -157,9 +181,7 @@ export function MemoryGraph3D({
}
}
const labels = graph.nodes
.filter(
(node) => node.node_type === "source" || node.node_type === "topic",
)
.filter((node) => node.node_type === "source")
.slice(0, 4)
.map((node) => {
const element = document.createElement("span");
@@ -194,7 +216,7 @@ export function MemoryGraph3D({
}
};
const selectable: THREE.Mesh[] = [];
const hittable: THREE.Mesh[] = [];
const materials = new Set<THREE.Material>();
const geometryByType = {
source: new THREE.SphereGeometry(0.24, 18, 12),
@@ -237,7 +259,7 @@ export function MemoryGraph3D({
mesh.scale.setScalar(1);
mesh.visible = returning || extending;
scene.add(mesh);
selectable.push(mesh);
hittable.push(mesh);
visuals.push({
mesh,
material,
@@ -298,7 +320,6 @@ export function MemoryGraph3D({
});
}
const nodesById = new Map(graph.nodes.map((node) => [node.node_id, node]));
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
const hitAt = (event: PointerEvent) => {
@@ -307,7 +328,7 @@ export function MemoryGraph3D({
pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1;
pointer.y = -((event.clientY - bounds.top) / bounds.height) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
return raycaster.intersectObjects(selectable, false)[0];
return raycaster.intersectObjects(hittable, false)[0];
};
let pressed: { x: number; y: number; id: number; moved: boolean } | null =
null;
@@ -333,8 +354,9 @@ export function MemoryGraph3D({
)
return;
const nodeId = hitAt(event)?.object.userData.nodeId;
const node = typeof nodeId === "string" ? nodesById.get(nodeId) : null;
if (node) selectRef.current(node);
const node =
typeof nodeId === "string" ? nodesByIdRef.current.get(nodeId) : null;
if (node && isGraphNodeSelectable(node)) selectRef.current(node);
};
const hoverNode = (event: PointerEvent) => {
if (pressed) {
@@ -344,12 +366,18 @@ export function MemoryGraph3D({
return;
}
const nodeId = hitAt(event)?.object.userData.nodeId;
const node = typeof nodeId === "string" ? nodesById.get(nodeId) : null;
renderer.domElement.style.cursor = node ? "pointer" : "grab";
const node =
typeof nodeId === "string" ? nodesByIdRef.current.get(nodeId) : null;
renderer.domElement.style.cursor =
node && isGraphNodeSelectable(node)
? "pointer"
: node
? "default"
: "grab";
setHovered(
node
? {
label: node.label,
label: graphNodeTooltip(node, topicPrefixRef.current),
x: event.clientX - element.getBoundingClientRect().left,
y: event.clientY - element.getBoundingClientRect().top,
}
@@ -629,17 +657,23 @@ export function MemoryGraph3D({
{!available ? (
<div className="memory-graph-fallback">
<p>{text("这台设备打不开 3D 视图,已显示关联列表。")}</p>
{graph.nodes.map((node) => (
<button
type="button"
className="secondary"
key={node.node_id}
aria-pressed={selectedNodeId === node.node_id}
onClick={() => onSelectNode(node)}
>
{node.label}
</button>
))}
{graph.nodes.map((node) =>
isGraphNodeSelectable(node) ? (
<button
type="button"
className="secondary"
key={node.node_id}
aria-pressed={selectedNodeId === node.node_id}
onClick={() => onSelectNode(node)}
>
{node.label}
</button>
) : (
<span className="memory-graph-fallback-topic" key={node.node_id}>
{graphNodeTooltip(node, topicPrefixRef.current)}
</span>
),
)}
</div>
) : null}
{available && (
+99
View File
@@ -149,6 +149,102 @@ it("OBS-22: the graph inspector has no editing entry and never says changes cann
expect(screen.queryByText(/暂不能保存更改/)).toBeNull();
});
it("keeps topic annotations out of the inspector and opens detail-bearing graph nodes", async () => {
const onOpen = vi.fn();
const topicGraph = {
total_memories: 2,
projected_memories: 2,
hy_graph_available: true,
nodes: [
{
node_id: "memory:m0",
node_type: "memory",
memory_id: "m0",
label: "第一条",
},
{
node_id: "memory:outside",
node_type: "memory",
memory_id: "outside",
label: "图中但不在当前页的记忆",
},
{
node_id: "topic:git",
node_type: "topic",
memory_id: null,
label: "git",
},
{
node_id: "schema:workflow",
node_type: "schema",
memory_id: null,
label: "先验证,再推送",
},
],
edges: [
{
edge_id: "topic-m0",
source_node_id: "topic:git",
target_node_id: "memory:m0",
relation: "tagged_with",
origin: "shared_tag",
},
{
edge_id: "topic-outside",
source_node_id: "topic:git",
target_node_id: "memory:outside",
relation: "tagged_with",
origin: "shared_tag",
},
{
edge_id: "schema-m0",
source_node_id: "schema:workflow",
target_node_id: "memory:m0",
relation: "derived_from",
origin: "hy_relation",
},
],
} as MemoryGraphResponse;
render(
<Host
simplified
items={[memories[0]]}
graphValue={topicGraph}
onOpenSpy={onOpen}
libraryView={pagedView({
items: [memories[0]],
total: 2,
offset: 0,
limit: 1,
source_ids_by_memory: { m0: ["codex"] },
})}
/>,
);
await act(async () => {});
onOpen.mockClear();
await userEvent.click(screen.getByRole("button", { name: "图中选择 git" }));
expect(onOpen).not.toHaveBeenCalled();
expect(screen.queryByRole("heading", { name: "git" })).toBeNull();
expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy();
expect(screen.getByTestId("canvas").dataset.selected).toBe("memory:m0");
await userEvent.click(
screen.getByRole("button", { name: "图中选择 先验证,再推送" }),
);
expect(screen.getByRole("heading", { name: "先验证,再推送" })).toBeTruthy();
expect(screen.queryByText("关系归纳")).toBeNull();
expect(screen.queryByText("归纳自")).toBeNull();
await userEvent.click(
screen.getByRole("button", {
name: "图中选择 图中但不在当前页的记忆",
}),
);
expect(onOpen).toHaveBeenLastCalledWith("outside");
expect(screen.getByTestId("canvas").dataset.selected).toBe("memory:outside");
expect(screen.queryByText("所选记忆不在当前筛选内,已取消选择。")).toBeNull();
});
it("OBS-19: Enter in the memory filter submits it; submitting it again re-reads", async () => {
const onRetry = vi.fn();
const onQuery = vi.fn();
@@ -391,6 +487,7 @@ function Host({
deferMemoryId,
failMemoryId,
graphValue = graph,
onOpenSpy,
onViewHistory,
onConfigureModel = () => {},
libraryView,
@@ -408,6 +505,7 @@ function Host({
deferMemoryId?: string;
failMemoryId?: string;
graphValue?: MemoryGraphResponse;
onOpenSpy?(memoryId: string): void;
onViewHistory?(): void;
onConfigureModel?(): void;
libraryView?: Parameters<typeof MemoryMap>[0]["libraryView"];
@@ -445,6 +543,7 @@ function Host({
streaming={null}
requestIds={{ current: new Map() }}
onOpenMemory={(id) => {
onOpenSpy?.(id);
if (failMemoryId && id === failMemoryId) {
setDetail(null);
setDetailLoad({ memoryId: id, status: "failed" });
+142 -51
View File
@@ -1,5 +1,6 @@
import type {
GovernedMemory,
HomeProjectionCapabilities,
HostAskMemoryResponse,
MemoryDetail,
MemoryGraphNode,
@@ -8,6 +9,8 @@ import type {
MemoryProcessingSummary,
SdkAskMemory,
SdkMemoryScene,
SdkRawContextItem,
SdkRawRecord,
SourceResponse,
} from "@one-memory/contracts";
import type { GuiLocale } from "@one-memory/i18n";
@@ -30,6 +33,7 @@ import {
useRef,
useState,
} from "react";
import { useGuiLanguage } from "./gui-language";
import { HandoffBar } from "./HandoffBar";
import { MemoryEvidencePanel } from "./MemoryEvidencePanel";
import { MemoryGraph3D } from "./MemoryGraph3D";
@@ -48,6 +52,7 @@ import {
import { OptionMenu } from "./OptionMenu";
import { R6Button } from "./R6Controls";
import { SelectedMemoryDetail } from "./SelectedMemoryDetail";
import { SelectedRawDetail } from "./SelectedRawDetail";
import { sourceNameOr } from "./source-label";
import { useMediaPreference } from "./use-media-preference";
import { Answer, NodeDetail, type SavedAnswer } from "./Workbench";
@@ -88,7 +93,10 @@ type Props = {
hidden?: boolean;
id?: string;
arrival?: MemoryArrival;
modeCapabilities?: HomeProjectionCapabilities | null;
onViewHistory?(): void;
contentKind?: "memory" | "raw";
rawRecord?: SdkRawRecord | SdkRawContextItem | null;
libraryView?: {
query: string;
source: string;
@@ -171,6 +179,8 @@ const SCENE_LABELS: Record<SdkMemoryScene, string> = {
export function MemoryMap(props: Props) {
const { locale, text, count, plain } = useMemoryLanguage();
const { t } = useGuiLanguage();
const raw = props.contentKind === "raw";
const {
memories,
graph: baseGraph,
@@ -260,6 +270,7 @@ export function MemoryMap(props: Props) {
processing: props.processing,
sources,
loaded,
capabilities: props.modeCapabilities,
});
useEffect(() => {
if (!loaded || library) return;
@@ -411,6 +422,15 @@ export function MemoryMap(props: Props) {
)),
) ?? null)
: null;
const projectedMemoryIds = useMemo(
() =>
new Set(
projection.nodes.flatMap((node) =>
node.memory_id ? [node.memory_id] : [],
),
),
[projection.nodes],
);
const selectedSource =
visibleGroupNode?.node_type === "source"
? sources.find(
@@ -428,9 +448,12 @@ export function MemoryMap(props: Props) {
}
}, [groupNode, visibleGroupNode]);
const requestedId = selectedId ?? detail?.memory.memory_id ?? null;
const activeId = active.some((memory) => memory.memory_id === requestedId)
? requestedId
: null;
const activeId =
requestedId &&
(active.some((memory) => memory.memory_id === requestedId) ||
projectedMemoryIds.has(requestedId))
? requestedId
: null;
const inspectorIdentity = activeId
? `memory:${activeId}`
: selectedSource
@@ -440,7 +463,8 @@ export function MemoryMap(props: Props) {
const visibleDetail =
detail &&
(!activeId || activeId === detail.memory.memory_id) &&
filtered.some((m) => m.memory_id === detail.memory.memory_id)
(filtered.some((m) => m.memory_id === detail.memory.memory_id) ||
projectedMemoryIds.has(detail.memory.memory_id))
? detail
: null;
const lastDetail = useRef(visibleDetail);
@@ -458,7 +482,8 @@ export function MemoryMap(props: Props) {
props.detailLoad.status === "failed";
const pick = useCallback(
(memoryId: string, scroll = true) => {
if (!active.some((memory) => memory.memory_id === memoryId)) return;
const listed = active.some((memory) => memory.memory_id === memoryId);
if (!listed && !projectedMemoryIds.has(memoryId)) return;
if (pendingPageOffset !== null && library?.failed && library.page)
library.onPage(library.page.offset);
setPendingSourceSelection(null);
@@ -467,7 +492,7 @@ export function MemoryMap(props: Props) {
pageRetrySawLoading.current = false;
setDetailResetRevision((current) => current + 1);
scrollSelection.current = scroll;
if (!filtered.some((memory) => memory.memory_id === memoryId)) {
if (listed && !filtered.some((memory) => memory.memory_id === memoryId)) {
setGroupKey("");
setSearch("");
setFilterNotice("已清除筛选,定位到关联记忆。");
@@ -477,7 +502,14 @@ export function MemoryMap(props: Props) {
setInspectorOpen(true);
onOpenMemory(memoryId);
},
[active, filtered, library, onOpenMemory, pendingPageOffset],
[
active,
filtered,
library,
onOpenMemory,
pendingPageOffset,
projectedMemoryIds,
],
);
useEffect(() => {
if (
@@ -597,12 +629,18 @@ export function MemoryMap(props: Props) {
return;
if (
requestedId &&
!active.some((memory) => memory.memory_id === requestedId)
!active.some((memory) => memory.memory_id === requestedId) &&
!projectedMemoryIds.has(requestedId)
) {
clear();
return;
}
if (!activeId || filtered.some((memory) => memory.memory_id === activeId))
const listed = active.some((memory) => memory.memory_id === activeId);
if (
!activeId ||
filtered.some((memory) => memory.memory_id === activeId) ||
(!listed && projectedMemoryIds.has(activeId))
)
return;
clear();
setFilterNotice("所选记忆不在当前筛选内,已取消选择。");
@@ -615,6 +653,7 @@ export function MemoryMap(props: Props) {
overlaysSuspended,
library?.loading,
pendingPageOffset,
projectedMemoryIds,
]);
function closeInspector() {
if (!compact) {
@@ -645,6 +684,9 @@ export function MemoryMap(props: Props) {
setGroupKey(key);
}
function pickNode(node: MemoryGraphNode) {
// Topics are graph annotations, not detail-bearing records. They remain
// hoverable in the canvas but must not replace the memory inspector.
if (node.node_type === "topic") return;
if (node.memory_id) {
pick(node.memory_id);
return;
@@ -669,11 +711,6 @@ export function MemoryMap(props: Props) {
if (first) pick(first, false);
}
}
if (node.node_type === "topic") {
if (props.simplified) return;
setAxis("topic");
setGroupKey(`topic:${node.label}`);
}
}
const label =
group?.label ??
@@ -699,6 +736,7 @@ export function MemoryMap(props: Props) {
: "";
if (
props.simplified &&
!raw &&
(!library ||
(!library.loading &&
!library.failed &&
@@ -779,6 +817,17 @@ export function MemoryMap(props: Props) {
value={controlledSource}
options={[
{ value: "", label: text("全部应用") },
...(raw &&
!sources.some(
(source) => source.source_id === "one-memory-manual",
)
? [
{
value: "source:one-memory-manual",
label: text("手动添加"),
},
]
: []),
...(library?.source &&
library.source !== library.unattributedSource &&
!sources.some(
@@ -860,11 +909,15 @@ export function MemoryMap(props: Props) {
>
<Search size={16} aria-hidden="true" />
<input
aria-label={text("筛选地图记忆")}
aria-label={raw ? t("sdk.raw.searchLabel") : text("筛选地图记忆")}
name="map-memory-search"
autoComplete="off"
placeholder={
props.simplified ? text("搜索记忆") : text("筛选记忆…")
raw
? t("sdk.raw.searchLabel")
: props.simplified
? text("搜索记忆")
: text("筛选记忆…")
}
value={library ? searchDraft : search}
maxLength={library ? 512 : undefined}
@@ -971,7 +1024,7 @@ export function MemoryMap(props: Props) {
<ul
ref={memoryList}
className="map-memory-list"
aria-label={text("地图记忆列表")}
aria-label={raw ? t("memory:原始对话") : text("地图记忆列表")}
id="memory-pane-memories"
>
{filtered.map((m) => (
@@ -1014,12 +1067,12 @@ export function MemoryMap(props: Props) {
m.updated_at,
locale,
)}`}
{/* D99: the conversation date alone would hide that this
one was made today, so the generated day stays too. */}
<span className="map-memory-made">
{text("沉淀于 ")}
{dayStamp(m.updated_at, locale)}
</span>
{!raw ? (
<span className="map-memory-made">
{text("沉淀于 ")}
{dayStamp(m.updated_at, locale)}
</span>
) : null}
</>
) : (
text("已可用 · {count} 段原文", {
@@ -1033,9 +1086,11 @@ export function MemoryMap(props: Props) {
</ul>
{library?.failed && (
<div role="alert">
<p>{text("记忆读取失败,请重试。")}</p>
<p>
{raw ? t("sdk.raw.loadFailed") : text("记忆读取失败,请重试。")}
</p>
<R6Button onClick={retryLibrary}>
{text("重新读取地图记忆")}
{raw ? t("connectionFallback.retry") : text("重新读取地图记忆")}
</R6Button>
</div>
)}
@@ -1045,13 +1100,17 @@ export function MemoryMap(props: Props) {
!library.stale &&
!filtered.length && (
<p className="map-empty">
{library.query
? text("没有匹配的记忆")
: library.source
? text("这个来源下还没有记忆")
: groupKey
? text("本页没有匹配的主题记忆")
: text("还没有已生成的记忆")}
{raw
? library.query
? t("sdk.raw.found", { count: 0 })
: t("sdk.raw.empty")
: library.query
? text("没有匹配的记忆")
: library.source
? text("这个来源下还没有记忆")
: groupKey
? text("本页没有匹配的主题记忆")
: text("还没有已生成的记忆")}
</p>
)}
{library?.page?.search_window &&
@@ -1151,12 +1210,20 @@ export function MemoryMap(props: Props) {
<span className="map-kicker" hidden={props.simplified}>
{text(axis === "topic" ? "主题视角(非事项)" : "来源视角")}
</span>
<h2>{props.simplified ? text("记忆关联") : label}</h2>
<h2>
{props.simplified
? raw
? t("memory:原始对话")
: text("记忆关联")
: label}
</h2>
{props.simplified && (
<p className="selected-graph-subtitle">
{projection.nodes.length
? text("来源归属")
: text("可在沉淀记忆后查看视图")}
: raw
? t("sdk.raw.empty")
: text("可在沉淀记忆后查看视图")}
</p>
)}
</div>
@@ -1195,21 +1262,33 @@ export function MemoryMap(props: Props) {
: {})}
>
{library?.stale
? text("可在沉淀记忆后查看视图")
? raw
? t("sdk.raw.loadFailed")
: text("可在沉淀记忆后查看视图")
: library?.graphFailed || library?.failed
? text("记忆暂时无法读取")
? raw
? t("sdk.raw.loadFailed")
: text("记忆暂时无法读取")
: library?.graphLoading
? text("正在读取记忆地图…")
: library?.page &&
(library.query || library.source || groupKey)
? text("当前筛选没有可显示的记忆")
? raw
? t("sdk.raw.found", { count: 0 })
: text("当前筛选没有可显示的记忆")
: !loaded
? text("正在读取记忆…")
? raw
? t("sdk.raw.recent")
: text("正在读取记忆…")
: active.length
? text("当前筛选没有可显示的记忆")
: arrival.importedMessages > 0
? text("已保存的对话还未生成记忆")
: text("还没有可显示的记忆")}
? raw
? t("sdk.raw.found", { count: 0 })
: text("当前筛选没有可显示的记忆")
: raw
? t("sdk.raw.empty")
: arrival.importedMessages > 0
? text("已保存的对话还未生成记忆")
: text("还没有可显示的记忆")}
</p>
{library?.graphFailed ? (
<R6Button onClick={library.onRetry}>
@@ -1313,13 +1392,18 @@ export function MemoryMap(props: Props) {
>
<header className="map-inspector-heading">
<h2>
{inspectorDetail
? text("记忆详情")
: selectedSource
{raw
? t("memory:原始对话")
: inspectorDetail
? text("记忆详情")
: visibleGroupNode
? text("关联节点")
: text("记忆详情")}
: selectedSource
? text("记忆详情")
: visibleGroupNode
? visibleGroupNode.node_type === "topic" ||
visibleGroupNode.node_type === "schema"
? visibleGroupNode.label
: text("关联节点")
: text("记忆详情")}
</h2>
{(activeId || visibleGroupNode) && (
<button
@@ -1339,7 +1423,9 @@ export function MemoryMap(props: Props) {
{library?.failed &&
library.source === selectedSource.source_id ? (
<>
<p role="alert">{text("记忆暂时无法读取")}</p>
<p role="alert">
{raw ? t("sdk.raw.loadFailed") : text("记忆暂时无法读取")}
</p>
<button
type="button"
className="secondary"
@@ -1356,10 +1442,12 @@ export function MemoryMap(props: Props) {
<i />
<i />
</div>
<p role="status">{text("正在读取记忆详情…")}</p>
<p role="status">
{raw ? t("sdk.raw.recent") : text("正在读取记忆详情…")}
</p>
</>
) : (
<p>{text("还没有已生成的记忆")}</p>
<p>{raw ? t("sdk.raw.empty") : text("还没有已生成的记忆")}</p>
)}
</div>
) : props.simplified && inspectorDetail ? (
@@ -1423,6 +1511,8 @@ export function MemoryMap(props: Props) {
</div>
<p role="status">{text("正在读取这条记忆…")}</p>
</div>
) : raw && props.rawRecord ? (
<SelectedRawDetail record={props.rawRecord} sources={sources} />
) : (
<SelectedMemoryDetail
detail={inspectorDetail}
@@ -1452,6 +1542,7 @@ export function MemoryMap(props: Props) {
onDraft={props.onDraft}
onSave={props.onSave}
onDelete={props.onDelete}
onOpenMemory={pick}
requestIds={props.requestIds}
onClose={closeInspector}
/>
@@ -0,0 +1,64 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { afterEach, expect, it, vi } from "vitest";
import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
afterEach(cleanup);
const modes = [
{ mode: "lite" as const, title: "Lite", description: "Raw conversations" },
{ mode: "pro" as const, title: "Pro", description: "Extracted memories" },
];
it("supports native radio keyboard selection and reports the chosen control", async () => {
const changed = vi.fn();
function Harness() {
const [selected, setSelected] = useState<"lite" | "pro">("lite");
return (
<MemoryModeChoiceList
aria-label="Memory mode"
modes={modes}
selected={selected}
onChange={(mode, control) => {
changed(mode, control);
setSelected(mode as "lite" | "pro");
}}
/>
);
}
const user = userEvent.setup();
render(<Harness />);
const lite = screen.getByRole("radio", { name: /Lite/ });
lite.focus();
await user.keyboard("{ArrowDown}");
const pro = screen.getByRole<HTMLInputElement>("radio", { name: /Pro/ });
expect(pro.checked).toBe(true);
expect(changed).toHaveBeenCalledWith("pro", pro);
});
it("exposes current and recommended badges while respecting per-mode disabled state", () => {
render(
<MemoryModeChoiceList
aria-label="Memory mode"
modes={modes}
current="lite"
selected="lite"
currentBadge="Current"
recommended={{ mode: "pro", badge: "Recommended" }}
disabled={(mode) => mode === "pro"}
onChange={vi.fn()}
/>,
);
expect(screen.getByText("Current")).toBeTruthy();
expect(screen.getByText("Recommended")).toBeTruthy();
expect(screen.getByRole("radio", { name: /Pro/ })).toHaveProperty(
"disabled",
true,
);
});
@@ -0,0 +1,84 @@
import type { MemoryPipelineMode } from "@one-memory/contracts";
import { type ReactNode, useId } from "react";
import "./r6-controls.css";
export type MemoryModeChoice = {
mode: MemoryPipelineMode;
title: ReactNode;
description: ReactNode;
};
export function MemoryModeChoiceList({
modes,
current,
selected,
disabled = false,
onChange,
currentBadge,
recommended,
"aria-label": ariaLabel,
"aria-busy": ariaBusy,
}: {
modes: readonly MemoryModeChoice[];
current?: MemoryPipelineMode;
selected: MemoryPipelineMode | null;
disabled?: boolean | ((mode: MemoryPipelineMode) => boolean);
onChange(mode: MemoryPipelineMode, control: HTMLInputElement): void;
currentBadge?: ReactNode;
recommended?: {
mode: MemoryPipelineMode;
badge: ReactNode;
};
"aria-label": string;
"aria-busy"?: boolean;
}) {
const name = useId();
const isDisabled = (mode: MemoryPipelineMode) =>
typeof disabled === "function" ? disabled(mode) : disabled;
return (
<div
className="memory-mode-choice-list"
role="radiogroup"
aria-label={ariaLabel}
aria-busy={ariaBusy || undefined}
>
{modes.map((choice) => {
const checked = selected === choice.mode;
const unavailable = isDisabled(choice.mode);
return (
<label
key={choice.mode}
className="memory-mode-choice"
data-selected={checked || undefined}
data-current={current === choice.mode || undefined}
data-disabled={unavailable || undefined}
>
<input
type="radio"
name={name}
value={choice.mode}
checked={checked}
disabled={unavailable}
onChange={(event) => onChange(choice.mode, event.currentTarget)}
/>
<span className="memory-mode-choice-copy">
<span className="memory-mode-choice-heading">
<strong>{choice.title}</strong>
{recommended?.mode === choice.mode ? (
<span className="memory-mode-choice-badge">
{recommended.badge}
</span>
) : null}
</span>
<small>{choice.description}</small>
</span>
{current === choice.mode && currentBadge ? (
<span className="memory-mode-choice-current">{currentBadge}</span>
) : null}
</label>
);
})}
</div>
);
}
@@ -0,0 +1,458 @@
// @vitest-environment jsdom
import { legacyMemoryModePlan } from "@one-memory/operations";
import {
cleanup,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import {
apiErrorCode,
getEvolveOverview,
getEvolveSchedule,
getMemoryMode,
getMemoryProcessing,
memoryModePlanUnsupported,
planMemoryMode,
setEvolveSchedule,
setMemoryMode,
} from "./api";
import { MemoryModeSettings } from "./MemoryModeSettings";
vi.mock("./api", () => ({
getMemoryMode: vi.fn(),
setMemoryMode: vi.fn(),
getEvolveSchedule: vi.fn(),
setEvolveSchedule: vi.fn(),
getEvolveOverview: vi.fn(),
startEvolve: vi.fn(),
getMemoryProcessing: vi.fn(),
planMemoryMode: vi.fn(),
memoryModePlanUnsupported: vi.fn().mockReturnValue(false),
apiErrorCode: vi.fn().mockReturnValue(null),
}));
function state(mode: "lite" | "pro" | "ultra" = "pro") {
return {
mode,
configured_mode: mode,
explicitly_configured: true,
forced_by_env: false,
modes: ["lite", "pro", "ultra"] as Array<"lite" | "pro" | "ultra">,
graph_build: {
state: "idle" as const,
started_at: null,
finished_at: null,
users: {},
},
};
}
function prepare(mode: "lite" | "pro" | "ultra" = "pro") {
vi.mocked(getMemoryMode).mockResolvedValue(state(mode));
vi.mocked(getEvolveSchedule).mockResolvedValue({
enabled: true,
time: "03:00",
});
vi.mocked(getEvolveOverview).mockResolvedValue({
mode,
schedule: { enabled: true, time: "03:00" },
users: {},
running: [],
});
vi.mocked(getMemoryProcessing).mockResolvedValue({} as never);
vi.mocked(planMemoryMode).mockImplementation(async (target) =>
legacyMemoryModePlan(mode, target, { hasActiveModel: true }),
);
}
async function chooseMode(
user: ReturnType<typeof userEvent.setup>,
name: RegExp,
) {
const option = await screen.findByRole("radio", { name });
await user.click(option);
return option;
}
afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.mocked(apiErrorCode).mockReturnValue(null);
vi.mocked(memoryModePlanUnsupported).mockReturnValue(false);
});
it("renders all settings modes as one grouped shared choice list", async () => {
prepare("pro");
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const group = await screen.findByRole("radiogroup", {
name: "当前模式",
});
expect(within(group).getAllByRole("radio")).toHaveLength(3);
expect(
within(group).getByText("当前模式").closest("label")?.textContent,
).toContain("智能记忆(pro)");
});
it("confirms the data effects before switching to lite", async () => {
prepare("pro");
vi.mocked(setMemoryMode).mockResolvedValue({
...state("lite"),
previous_mode: "pro",
effects: ["higher_mode_memories_hidden"],
});
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /基础记忆(lite)/);
expect(screen.getByText(/已有记忆继续保留/)).toBeTruthy();
expect(screen.getByText("模型调用与费用")).toBeTruthy();
expect(screen.getByText("搜索范围")).toBeTruthy();
expect(setMemoryMode).not.toHaveBeenCalled();
await user.click(
screen.getByRole("button", { name: "切换到基础记忆(lite)" }),
);
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledWith("lite"));
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole("radio", { name: /基础记忆(lite)/ }),
),
);
});
it("traps modal focus, closes on Escape and restores the mode-card focus", async () => {
prepare("pro");
const outside = document.createElement("button");
document.body.append(outside);
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const lite = await chooseMode(user, /基础记忆(lite)/);
const cancel = screen.getByRole("button", { name: "取消" });
await waitFor(() => expect(document.activeElement).toBe(cancel));
expect(outside.inert).toBe(true);
await user.keyboard("{Shift>}{Tab}{/Shift}");
expect(document.activeElement).toBe(
screen.getByRole("button", { name: "切换到基础记忆(lite)" }),
);
await user.keyboard("{Escape}");
expect(screen.queryByRole("dialog")).toBeNull();
expect(outside.inert).toBeFalsy();
expect(document.activeElement).toBe(lite);
outside.remove();
});
it("keeps focus inside the modal while the mode change is pending", async () => {
prepare("pro");
vi.mocked(setMemoryMode).mockReturnValue(new Promise(() => {}));
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /基础记忆(lite)/);
await user.click(
screen.getByRole("button", { name: "切换到基础记忆(lite)" }),
);
const modal = screen.getByRole("dialog");
await waitFor(() => expect(document.activeElement).toBe(modal));
await user.keyboard("{Tab}");
expect(document.activeElement).toBe(modal);
});
it("routes to model setup instead of switching pro without a model", async () => {
prepare("lite");
vi.mocked(planMemoryMode).mockResolvedValue({
current: "lite",
target: "pro",
effective_target: "pro",
capabilities: {
content_plane: "memory",
generation_required: true,
graph_enabled: false,
evolve_available: false,
},
effects: ["earlier_raw_not_extracted", "lite_search_stops"],
warnings: [],
blockers: ["active_model_required"],
});
const configure = vi.fn();
const user = userEvent.setup();
render(
<MemoryModeSettings hasActiveModel={false} onConfigureModel={configure} />,
);
await chooseMode(user, /智能记忆(pro)/);
expect(configure).toHaveBeenCalledExactlyOnceWith(
"使用 pro 或 ultra 前需要先配置并启用生成模型。",
);
expect(setMemoryMode).not.toHaveBeenCalled();
});
it("uses the isolated legacy plan only for a route-level old-Core 404", async () => {
prepare("pro");
vi.mocked(planMemoryMode).mockRejectedValue(new Error("404"));
vi.mocked(memoryModePlanUnsupported).mockReturnValue(true);
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /基础记忆(lite)/);
expect(planMemoryMode).toHaveBeenCalledWith("lite");
expect(await screen.findByText(/已有记忆继续保留/)).toBeTruthy();
});
it("keeps downgrade effects when an old Core falls back from ultra to pro", async () => {
prepare("ultra");
vi.mocked(planMemoryMode).mockRejectedValue(new Error("404"));
vi.mocked(memoryModePlanUnsupported).mockReturnValue(true);
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /智能记忆(pro)/);
const dialog = screen.getByRole("dialog");
expect(within(dialog).getAllByText("已有数据")).toHaveLength(1);
expect(within(dialog).getByText("每天自动整合")).toBeTruthy();
});
it("explains model cost, search scope and the next step before switching to ultra", async () => {
prepare("lite");
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /深度记忆(ultra)/);
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();
});
it("distinguishes regular memories from higher-level Ultra data when switching to pro", async () => {
prepare("ultra");
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /智能记忆(pro)/);
const dialog = screen.getByRole("dialog");
expect(within(dialog).getAllByText("已有数据")).toHaveLength(1);
expect(within(dialog).getByText(/普通沉淀记忆仍可使用/)).toBeTruthy();
expect(within(dialog).getByText(/更高层记忆和图谱数据继续保留/)).toBeTruthy();
expect(within(dialog).getByText("每天自动整合")).toBeTruthy();
expect(
within(dialog).getByText("每天自动整合会停止;已生成的整合结果继续保留。"),
).toBeTruthy();
});
it("does not show automatic consolidation when switching from lite to pro", async () => {
prepare("lite");
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /智能记忆(pro)/);
const dialog = screen.getByRole("dialog");
expect(within(dialog).getAllByText("已有数据")).toHaveLength(1);
expect(within(dialog).getByText(/已经处理完的旧对话不会补做/)).toBeTruthy();
expect(within(dialog).queryByText("每天自动整合")).toBeNull();
});
it("focuses the Evolve settings after switching to ultra", async () => {
prepare("pro");
vi.mocked(setMemoryMode).mockResolvedValue({
...state("ultra"),
previous_mode: "pro",
effects: ["existing_memories_added_to_graph"],
});
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await screen.findByRole("radio", { name: /智能记忆(pro)/ });
vi.mocked(getMemoryMode).mockResolvedValue(state("ultra"));
await chooseMode(user, /深度记忆(ultra)/);
await user.click(
screen.getByRole("button", { name: "切换到深度记忆(ultra)" }),
);
await waitFor(() =>
expect(document.activeElement?.getAttribute("id")).toContain("-evolve"),
);
expect(
screen.getByRole("heading", { name: "记忆整合(Evolve)" }),
).toBeTruthy();
});
it("shows the default nightly evolve controls in ultra", async () => {
prepare("ultra");
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
expect(
(await screen.findByRole("switch", { name: "每天自动整合" })).getAttribute(
"aria-checked",
),
).toBe("true");
expect((screen.getByLabelText("执行时间") as HTMLInputElement).value).toBe(
"03:00",
);
expect(screen.getByText(/只运行这一次,不改变每天的计划/)).toBeTruthy();
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
});
it("hides the time field when automatic evolve is off and keeps the saved time visible", async () => {
prepare("ultra");
vi.mocked(setEvolveSchedule).mockResolvedValue({
enabled: false,
time: "03:00",
});
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const automatic = await screen.findByRole("switch", {
name: "每天自动整合",
});
await user.click(automatic);
await screen.findByText(/重新开启后仍按 03:00 运行/);
expect(screen.queryByLabelText("执行时间")).toBeNull();
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
});
it("updates mode state automatically without showing a manual refresh control", async () => {
prepare("pro");
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const pro = await screen.findByRole("radio", { name: /智能记忆(pro)/ });
expect((pro as HTMLInputElement).checked).toBe(true);
expect(screen.queryByRole("button", { name: "刷新记忆模式" })).toBeNull();
expect(getMemoryMode).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(getEvolveSchedule).toHaveBeenCalledTimes(1);
});
});
it("keeps the mode usable when an evolve status read fails", async () => {
prepare("pro");
vi.mocked(getEvolveSchedule).mockRejectedValueOnce(new Error("offline"));
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
expect(
(await screen.findByRole("radio", {
name: /智能记忆(pro)/,
})) as HTMLInputElement,
).toHaveProperty("disabled", false);
});
it("offers a text recovery action when the mode read fails", async () => {
prepare("pro");
vi.mocked(getMemoryMode).mockRejectedValueOnce(new Error("offline"));
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
expect(
await screen.findByText("记忆模式状态暂时无法读取,请重试。"),
).toBeTruthy();
expect(
screen.getByRole("radio", { name: /基础记忆(lite)/ }),
).toHaveProperty("disabled", true);
await user.click(screen.getByRole("button", { name: "重新读取" }));
await waitFor(() =>
expect(
screen.getByRole("radio", { name: /智能记忆(pro)/ }),
).toHaveProperty("disabled", false),
);
});
it("keeps a failed switch explanation inside the open confirmation dialog", async () => {
prepare("pro");
vi.mocked(apiErrorCode).mockReturnValue("graph_store_unavailable");
vi.mocked(setMemoryMode).mockRejectedValue(new Error("unavailable"));
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /深度记忆(ultra)/);
await user.click(
screen.getByRole("button", { name: "切换到深度记忆(ultra)" }),
);
const dialog = screen.getByRole("dialog");
expect(
await within(dialog).findByText(
"当前安装缺少知识图谱组件,无法开启 ultra。",
),
).toBeTruthy();
});
it("keeps lite available while model status is unknown", async () => {
prepare("pro");
render(
<MemoryModeSettings
hasActiveModel={false}
modelStateKnown={false}
onConfigureModel={vi.fn()}
/>,
);
expect(
await screen.findByRole("radio", { name: /基础记忆(lite)/ }),
).toHaveProperty("disabled", false);
expect(screen.getByRole("radio", { name: /智能记忆(pro)/ })).toHaveProperty(
"disabled",
true,
);
expect(
screen.getByRole("radio", { name: /深度记忆(ultra)/ }),
).toHaveProperty("disabled", true);
});
it("blocks mode and Evolve controls during embedding maintenance", async () => {
prepare("ultra");
vi.mocked(getMemoryProcessing).mockResolvedValue({
embedding_switch: { phase: "running" },
} as never);
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
expect(
await screen.findByText(
"正在检查或切换向量模型,完成前不能切换模式或手动整合。",
),
).toBeTruthy();
expect(
screen.getByRole("radio", { name: /基础记忆(lite)/ }),
).toHaveProperty("disabled", true);
expect(screen.getByRole("button", { name: "立即整合" })).toHaveProperty(
"disabled",
true,
);
});
it("blocks lifecycle controls while the knowledge graph is being built", async () => {
prepare("ultra");
vi.mocked(getMemoryMode).mockResolvedValue({
...state("ultra"),
graph_build: {
state: "running",
started_at: "2026-11-03T09:00:00Z",
finished_at: null,
users: {},
},
});
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
expect(
await screen.findByText("正在把已有高级记忆加入知识图谱…"),
).toBeTruthy();
expect(screen.getByRole("button", { name: "立即整合" })).toHaveProperty(
"disabled",
true,
);
});
+560
View File
@@ -0,0 +1,560 @@
import type {
EvolveOverview,
EvolveRunReceipt,
EvolveSchedule,
MemoryModeChange,
MemoryModePlan,
MemoryModeState,
MemoryPipelineMode,
} from "@one-memory/contracts";
import { legacyMemoryModePlan } from "@one-memory/operations";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import {
apiErrorCode,
getEvolveOverview,
getEvolveSchedule,
getMemoryMode,
getMemoryProcessing,
memoryModePlanUnsupported,
planMemoryMode,
setEvolveSchedule,
setMemoryMode,
startEvolve,
} from "./api";
import {
commitConfirmedMemoryMode,
issueConfirmedModeTicket,
} from "./confirmed-memory-mode";
import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Button, R6Switch } 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。",
};
export function MemoryModeSettings({
hasActiveModel,
modelStateKnown = true,
onConfigureModel,
onModeChange,
}: {
hasActiveModel: boolean;
modelStateKnown?: boolean;
onConfigureModel(message?: string): void;
onModeChange?(mode: MemoryPipelineMode): void;
}) {
const { text, locale } = useOnboardingTranslation();
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 [pendingMode, setPendingMode] = useState<MemoryPipelineMode | null>(
null,
);
const [pendingPlan, setPendingPlan] = useState<MemoryModePlan | null>(null);
const [busy, setBusy] = useState(false);
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")
return;
focusEvolveWhenReady.current = false;
document.getElementById(`${id}-evolve`)?.focus();
}, [id, pendingMode, state?.mode]);
useEffect(() => {
const target = focusModeWhenReady.current;
if (!target || busy || pendingMode || state?.mode !== target) return;
focusModeWhenReady.current = null;
document
.querySelector<HTMLInputElement>(
`.r6-memory-mode-settings input[type="radio"][value="${target}"]`,
)
?.focus();
}, [busy, pendingMode, state?.mode]);
const refresh = useCallback(async () => {
const version = ++refreshVersion.current;
const modeTicket = issueConfirmedModeTicket("settings");
const [modeResult, scheduleResult, overviewResult, processingResult] =
await Promise.allSettled([
getMemoryMode(),
getEvolveSchedule(),
getEvolveOverview(),
getMemoryProcessing(),
]);
if (version !== refreshVersion.current) return;
if (modeResult.status === "fulfilled") {
commitConfirmedMemoryMode(modeResult.value.mode, modeTicket);
setState(modeResult.value);
onModeChange?.(modeResult.value.mode);
setError("");
setReadFailed(false);
} else {
setError("记忆模式状态暂时无法读取,请重试。");
setReadFailed(true);
}
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]);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
const running =
maintenance ||
state?.graph_build.state === "running" ||
run?.state === "running";
if (!running) return;
const timer = window.setInterval(() => void refresh(), 1500);
return () => window.clearInterval(timer);
}, [maintenance, refresh, run?.state, state?.graph_build.state]);
const lifecycleBusy =
maintenance ||
run?.state === "running" ||
state?.graph_build.state === "running";
async function choose(mode: MemoryPipelineMode, opener: HTMLElement) {
if (!state || mode === state.mode || busy || lifecycleBusy) return;
setError("");
setBusy(true);
let plan: MemoryModePlan;
try {
plan = await planMemoryMode(mode);
} catch (reason) {
if (!memoryModePlanUnsupported(reason)) {
setError("模式切换影响暂时无法读取,请重试。");
setBusy(false);
return;
}
plan = legacyMemoryModePlan(state.mode, mode, { hasActiveModel });
}
setBusy(false);
if (plan.blockers.includes("active_model_required")) {
onConfigureModel(BLOCKER_COPY.active_model_required);
return;
}
dialogOpener.current = opener;
setPendingPlan(plan);
setPendingMode(mode);
}
async function commitMode() {
if (!pendingMode) return;
const targetMode = pendingMode;
refreshVersion.current++;
const modeTicket = issueConfirmedModeTicket("settings");
setBusy(true);
setError("");
try {
const changed: MemoryModeChange = await setMemoryMode(pendingMode);
commitConfirmedMemoryMode(changed.mode, modeTicket);
focusEvolveWhenReady.current = targetMode === "ultra";
focusModeWhenReady.current = targetMode === "ultra" ? null : targetMode;
setState(changed);
onModeChange?.(changed.mode);
setPendingPlan(null);
setPendingMode(null);
await refresh();
} catch (reason) {
const code = apiErrorCode(reason);
setError(
code === "embedding_maintenance_in_progress"
? "正在检查或切换向量模型,完成后才能切换记忆模式。"
: code === "graph_store_unavailable"
? "当前安装缺少知识图谱组件,无法开启 ultra。"
: "模式切换未能确认,仍显示上次确认的模式。",
);
} finally {
setBusy(false);
}
}
async function changeSchedule(update: Partial<EvolveSchedule>) {
refreshVersion.current++;
setBusy(true);
setError("");
try {
setScheduleState(await setEvolveSchedule(update));
} catch {
setError("自动整合设置未能保存,请重试。");
} finally {
setBusy(false);
}
}
async function evolveNow() {
refreshVersion.current++;
setBusy(true);
setError("");
try {
const receipt = await startEvolve();
setRun(receipt);
await refresh();
} catch (reason) {
const code = apiErrorCode(reason);
setError(
code === "embedding_maintenance_in_progress"
? "正在检查或切换向量模型,完成后才能整合记忆。"
: "未能开始整合记忆,请重试。",
);
} finally {
setBusy(false);
}
}
const last = overview?.users.local;
return (
<section
className="r6-memory-mode-settings"
aria-labelledby="memory-mode-title"
>
<div className="r6-mode-heading">
<div>
<h2 id="memory-mode-title">{text("记忆模式")}</h2>
<p>{text("模式决定新对话如何保存,以及记忆页面展示什么。")}</p>
</div>
</div>
<MemoryModeChoiceList
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}
currentBadge={text("当前模式")}
disabled={(mode) =>
!state ||
busy ||
lifecycleBusy ||
Boolean(state.forced_by_env && mode !== state.mode) ||
(mode !== "lite" && !modelStateKnown)
}
onChange={(mode, control) => void choose(mode, control)}
/>
{state?.forced_by_env ? (
<p className="r6-settings-note" role="status">
{text("开发环境当前强制使用 ultra,保存的选择暂不会生效。")}
</p>
) : null}
{maintenance ? (
<p className="r6-settings-note" role="status">
{text("正在检查或切换向量模型,完成前不能切换模式或手动整合。")}
</p>
) : null}
{state?.graph_build.state === "running" ? (
<p className="r6-settings-note" role="status">
{text("正在把已有高级记忆加入知识图谱…")}
</p>
) : null}
{error && !pendingMode ? (
readFailed ? (
<R6StateRecovery
message={text(error)}
onRetry={() => void refresh()}
/>
) : (
<p className="r6-settings-error" role="alert">
{text(error)}
</p>
)
) : null}
{state?.mode === "ultra" && 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>
) : null}
</section>
);
}
+477 -1
View File
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
import type { SourceResponse } from "@one-memory/contracts";
import {
act,
cleanup,
@@ -7,6 +8,7 @@ import {
screen,
waitFor,
} from "@testing-library/react";
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";
@@ -74,6 +76,8 @@ const { reads } = vi.hoisted(() => ({
sdkGraph: vi.fn(),
processing: vi.fn(),
profiles: vi.fn(),
raw: vi.fn(),
rawSearch: vi.fn(),
},
}));
vi.mock("./api", async (importOriginal) => ({
@@ -82,17 +86,21 @@ vi.mock("./api", async (importOriginal) => ({
getSdkMemoryGraph: reads.sdkGraph,
getMemoryProcessing: reads.processing,
getModelProfiles: reads.profiles,
getSdkRaw: reads.raw,
searchSdkRaw: reads.rawSearch,
}));
vi.mock("./GraphWidthPrompt", () => ({ GraphWidthPrompt: () => null }));
vi.mock("./MemoryAddComposer", () => ({
MemoryAddComposer: ({
onPendingChange,
rawOnly,
}: {
onPendingChange?(addition: {
evidenceId: string;
text: string;
state: string;
}): void;
rawOnly?: boolean;
}) => (
<button
type="button"
@@ -104,7 +112,7 @@ vi.mock("./MemoryAddComposer", () => ({
})
}
>
补充记忆
{rawOnly ? "手动添加" : "补充记忆"}
</button>
),
}));
@@ -194,8 +202,476 @@ function setup() {
completed: 1,
});
reads.profiles.mockResolvedValue([]);
reads.raw.mockResolvedValue({
items: [],
total: 0,
limit: 100,
offset: 0,
});
reads.rawSearch.mockResolvedValue([]);
}
function modeProcessing(mode: "lite" | "pro" | "ultra") {
return {
running: 0,
pending: 0,
blocked_stages: [],
completed: 1,
pipeline: {
mode,
configured_mode: mode,
forced_by_env: false,
evolve: {
mode,
schedule: { enabled: true, time: "03:00" },
users: {},
running: [],
},
last_evolve_at: null,
graph_build: {
state: "idle",
started_at: null,
finished_at: null,
users: {},
},
features: {},
},
};
}
it("shows raw conversations instead of advanced memory in lite", async () => {
setup();
reads.processing.mockResolvedValue({
running: 0,
pending: 0,
blocked_stages: [],
completed: 1,
pipeline: {
mode: "lite",
configured_mode: "lite",
forced_by_env: false,
evolve: {
mode: "lite",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [],
},
last_evolve_at: null,
graph_build: {
state: "idle",
started_at: null,
finished_at: null,
users: {},
},
features: {},
},
});
reads.raw.mockResolvedValue({
items: [
{
raw_id: "raw-1",
content: "原始对话内容",
user_id: "local",
agent_id: "all-apps",
source: "cursor",
session_id: "session-1",
memory_at: null,
gmt_created: 1_761_000_000,
custom: {},
messages: [{ role: "user", content: "原始对话内容" }],
},
],
total: 1,
limit: 20,
offset: 0,
});
render(<MemorySpace simplified />);
expect(await screen.findAllByText("原始对话内容")).toHaveLength(2);
expect(
document.querySelector(".core-memory-map")?.hasAttribute("hidden"),
).toBe(false);
expect(screen.getByTestId("graph-owner")).toBeTruthy();
expect(screen.getByRole("textbox", { name: "搜索原始对话" })).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: "手动添加" }));
expect(
screen.getByText(/正在保存… 项目周会统一安排在周三上午。/),
).toBeTruthy();
await userEvent.click(screen.getByRole("combobox", { name: "来源" }));
expect(screen.getByRole("option", { name: "手动添加" })).toBeTruthy();
expect(reads.sdkLibrary).not.toHaveBeenCalled();
expect(reads.sdkGraph).not.toHaveBeenCalled();
expect(reads.raw).toHaveBeenCalledWith(
expect.objectContaining({
source: undefined,
offset: 0,
limit: 20,
signal: expect.any(AbortSignal),
}),
);
expect(document.querySelector(".memory-mode-label")?.textContent).toBe(
"lite",
);
expect(document.querySelector(".memory-mode-notice")).toBeNull();
});
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 source = "source" in options ? options.source : undefined;
const rawId = `${source ?? "all"}-${offset}`;
return {
items: [
{
raw_id: rawId,
content: `原始会话 ${rawId}`,
user_id: "local",
agent_id: "all-apps",
source: source ?? "cursor",
session_id: `session-${rawId}`,
memory_at: null,
gmt_created: 1_761_000_000 - offset,
custom: {},
messages: [{ role: "user", content: `原始会话 ${rawId}` }],
},
],
total: source ? 1 : 21,
limit: 20,
offset,
};
});
const user = userEvent.setup();
render(
<MemorySpace
simplified
sources={
[
{
source_id: "cursor",
display_name: "Cursor",
authorized: true,
},
] as SourceResponse[]
}
/>,
);
expect(await screen.findAllByText("原始会话 all-0")).toHaveLength(2);
await user.click(screen.getByRole("button", { name: "下一页记忆" }));
expect(await screen.findAllByText("原始会话 all-20")).toHaveLength(2);
expect(reads.raw).toHaveBeenLastCalledWith(
expect.objectContaining({ source: undefined, offset: 20, 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(reads.raw).toHaveBeenLastCalledWith(
expect.objectContaining({ source: "cursor", offset: 0, limit: 20 }),
);
await user.click(screen.getByRole("combobox", { name: "来源" }));
await user.click(screen.getByRole("option", { name: "手动添加" }));
expect(
await screen.findAllByText("原始会话 one-memory-manual-0"),
).toHaveLength(2);
expect(reads.raw).toHaveBeenLastCalledWith(
expect.objectContaining({
source: "one-memory-manual",
offset: 0,
limit: 20,
}),
);
});
it("selects and renders one turn when two turns share a raw storage batch", async () => {
setup();
reads.processing.mockResolvedValue(modeProcessing("lite"));
reads.raw.mockResolvedValue({
items: [
{
raw_id: "raw-batch",
turn_id: "raw-turn:batch:0",
turn_index: 0,
turn_count: 2,
content: "[user]: 第一问\n[assistant]: 第一答",
user_id: "local",
agent_id: "all-apps",
source: "cursor",
session_id: "session-batch",
memory_at: null,
gmt_created: 1_761_000_000,
custom: {},
messages: [
{ role: "user", content: "第一问" },
{ role: "assistant", content: "第一答" },
],
},
{
raw_id: "raw-batch",
turn_id: "raw-turn:batch:1",
turn_index: 1,
turn_count: 2,
content: "[user]: 第二问\n[assistant]: 第二答",
user_id: "local",
agent_id: "all-apps",
source: "cursor",
session_id: "session-batch",
memory_at: null,
gmt_created: 1_761_000_000,
custom: {},
messages: [
{ role: "user", content: "第二问" },
{ role: "assistant", content: "第二答" },
],
},
],
total: 2,
limit: 20,
offset: 0,
unit: "turn",
});
const user = userEvent.setup();
render(<MemorySpace simplified />);
await user.click(await screen.findByRole("button", { name: /第二问/ }));
await waitFor(() => {
const detail = document.querySelector(".map-dialogue-reading");
expect(detail?.textContent).toContain("第二问");
expect(detail?.textContent).toContain("第二答");
expect(detail?.textContent).not.toContain("第一问");
expect(detail?.textContent).not.toContain("第一答");
});
});
it("switches data planes without keeping rows or details from the prior mode", async () => {
setup();
vi.useFakeTimers();
reads.processing.mockResolvedValue(modeProcessing("pro"));
reads.raw.mockResolvedValue({
items: [
{
raw_id: "raw-after-switch",
content: "切换后的原始会话",
user_id: "local",
agent_id: "all-apps",
source: "cursor",
session_id: "session-after-switch",
memory_at: null,
gmt_created: 1_761_000_000,
custom: {},
messages: [{ role: "user", content: "切换后的原始会话" }],
},
],
total: 1,
limit: 20,
offset: 0,
});
render(<MemorySpace simplified />);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(reads.sdkLibrary).toHaveBeenCalled();
expect(screen.getAllByText("已保存的记忆").length).toBeGreaterThan(0);
reads.processing.mockResolvedValue(modeProcessing("lite"));
await act(async () => {
await vi.advanceTimersByTimeAsync(3_000);
});
await vi.waitFor(() => expect(reads.raw).toHaveBeenCalled(), {
timeout: 1_000,
});
expect(screen.getAllByText("切换后的原始会话")).toHaveLength(2);
expect(screen.queryByText("已保存的记忆")).toBeNull();
expect(document.querySelector(".memory-mode-label")?.textContent).toBe(
"lite",
);
const libraryCalls = reads.sdkLibrary.mock.calls.length;
reads.processing.mockResolvedValue(modeProcessing("ultra"));
await act(async () => {
await vi.advanceTimersByTimeAsync(3_000);
});
await vi.waitFor(
() =>
expect(reads.sdkLibrary.mock.calls.length).toBeGreaterThan(libraryCalls),
{ timeout: 1_000 },
);
expect(screen.getAllByText("已保存的记忆").length).toBeGreaterThan(0);
expect(screen.queryByText("切换后的原始会话")).toBeNull();
expect(document.querySelector(".memory-mode-label")?.textContent).toBe(
"ultra",
);
});
it("opens a search hit with the complete role transcript", async () => {
setup();
reads.processing.mockResolvedValue({
running: 0,
pending: 0,
blocked_stages: [],
completed: 1,
pipeline: {
mode: "lite",
configured_mode: "lite",
forced_by_env: false,
evolve: {
mode: "lite",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [],
},
last_evolve_at: null,
graph_build: {
state: "idle",
started_at: null,
finished_at: null,
users: {},
},
features: {},
},
});
reads.raw.mockImplementation(async (options = {}) =>
"sessionId" in options && options.sessionId
? {
items: [
{
raw_id: "raw-search",
turn_id: "raw-turn:search:0",
turn_index: 0,
turn_count: 2,
content: "[user]: 第一轮问题\n[assistant]: 第一轮回答",
user_id: "local",
agent_id: "all-apps",
source: "cursor",
session_id: "session-search",
memory_at: null,
gmt_created: 1_761_000_000,
custom: {},
messages: [
{ role: "user", content: "第一轮问题" },
{ role: "assistant", content: "第一轮回答" },
],
},
{
raw_id: "raw-search",
turn_id: "raw-turn:search:1",
turn_index: 1,
turn_count: 2,
content: "[user]: 完整问题\n[assistant]: 完整回答",
user_id: "local",
agent_id: "all-apps",
source: "cursor",
session_id: "session-search",
memory_at: null,
gmt_created: 1_761_000_000,
custom: {},
messages: [
{ role: "user", content: "完整问题" },
{ role: "assistant", content: "完整回答" },
],
},
],
total: 2,
limit: 500,
offset: 0,
}
: { items: [], total: 0, limit: 20, offset: 0 },
);
reads.rawSearch.mockResolvedValue([
{
kind: "raw",
raw_id: "raw-search",
turn_id: "raw-turn:search:1",
turn_index: 1,
turn_count: 2,
content: "被截断的搜索摘要",
source: "cursor",
session_id: "session-search",
memory_at: null,
score: 0.9,
truncated: true,
},
]);
const user = userEvent.setup();
render(<MemorySpace simplified />);
const search = await screen.findByRole("textbox", {
name: "搜索原始对话",
});
await user.type(search, "完整");
await user.click(screen.getByRole("button", { name: "搜索" }));
expect(await screen.findByText("完整问题")).toBeTruthy();
expect(screen.getByText("完整回答")).toBeTruthy();
expect(
document.querySelector(".map-dialogue-reading")?.textContent,
).not.toContain("第一轮问题");
expect(reads.raw).toHaveBeenCalledWith(
expect.objectContaining({
source: "cursor",
sessionId: "session-search",
limit: 500,
}),
);
});
it("does not request advanced memories before the pipeline mode is known", async () => {
setup();
let resolveProcessing!: (value: unknown) => void;
reads.processing.mockReturnValueOnce(
new Promise((resolve) => {
resolveProcessing = resolve;
}),
);
render(<MemorySpace simplified />);
await act(async () => Promise.resolve());
expect(reads.sdkLibrary).not.toHaveBeenCalled();
expect(reads.sdkGraph).not.toHaveBeenCalled();
await act(async () => {
resolveProcessing({
running: 0,
pending: 0,
blocked_stages: [],
completed: 0,
pipeline: {
mode: "lite",
configured_mode: "lite",
forced_by_env: false,
evolve: {
mode: "lite",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [],
},
last_evolve_at: null,
graph_build: {
state: "idle",
started_at: null,
finished_at: null,
users: {},
},
features: {},
},
});
});
await waitFor(() =>
expect(document.querySelector(".memory-mode-label")?.textContent).toBe(
"lite",
),
);
expect(reads.sdkLibrary).not.toHaveBeenCalled();
expect(reads.sdkGraph).not.toHaveBeenCalled();
});
it("keeps an added memory visible while it waits for model processing", async () => {
setup();
render(<MemorySpace simplified onConfigureModel={vi.fn()} />);
@@ -389,7 +389,7 @@ it("hides the previous source graph until the all-applications graph settles", a
expect(screen.getByTestId("map-graph-loading").textContent).toBe("true"),
);
expect(screen.getByTestId("map-nodes").textContent).toBe("none");
// Waiting for the graph is not page staleness: the list stays usable.
expect(screen.getByTestId("map-memories").textContent).toBe("");
expect(screen.getByTestId("map-stale").textContent).toBe("false");
await act(async () => {
@@ -419,6 +419,7 @@ it("hides the previous source graph until the all-applications graph settles", a
expect(screen.getByTestId("map-graph-loading").textContent).toBe("true"),
);
expect(screen.getByTestId("map-nodes").textContent).toBe("none");
expect(screen.getByTestId("map-memories").textContent).toBe("");
expect(api.sdkGraph).toHaveBeenCalledTimes(graphCalls);
await act(async () => releaseSearch(searchPage));
+286 -84
View File
@@ -1,6 +1,7 @@
import type {
ExtractionWindowPreset,
GovernedMemory,
HomeProjectionCapabilities,
HostAskMemoryResponse,
ImportJob,
MemoryDetail,
@@ -36,7 +37,9 @@ import {
getSdkMemory,
getSdkMemoryGraph,
getSdkMemoryLibrary,
getSdkRaw,
manualExtract,
searchSdkRaw,
setExtractionWindow,
updateSdkMemory,
} from "./api";
@@ -54,6 +57,13 @@ import {
sdkLibraryPage,
sdkNamespace,
} from "./sdk-memory-map";
import {
type RawMapRecord,
rawDetail,
rawLibraryPage,
rawRecordId,
rawSearchPage,
} from "./sdk-raw-map";
import { sourceNameOr, UNATTRIBUTED_SOURCE_ID } from "./source-label";
import { traceUiAction } from "./ui-trace";
import { isDefiniteRefusal, writeTraceStatus } from "./write-outcome";
@@ -109,6 +119,10 @@ export function MemorySpace({
return isMessageKey(key) ? t(key) : text;
};
const [detail, setDetail] = useState<MemoryDetail | null>(null);
const [selectedRaw, setSelectedRaw] = useState<RawMapRecord | null>(null);
const selectedRawRef = useRef<RawMapRecord | null>(null);
selectedRawRef.current = selectedRaw;
const rawRecords = useRef(new Map<string, RawMapRecord>());
const [detailLoad, setDetailLoad] = useState<{
memoryId: string;
status: "loading" | "failed";
@@ -183,17 +197,44 @@ export function MemorySpace({
/** The filter the shown page was read with, for the count above it. */
settledSource: string;
settledQuery: string;
settledMode: "lite" | "pro" | "ultra" | null;
}>({
page: null,
loading: false,
failed: false,
settledSource: "",
settledQuery: "",
settledMode: null,
});
const [sdkLibraryQuery, setSdkLibraryQuery] = useState("");
const [sdkLibrarySource, setSdkLibrarySource] = useState("");
const [sdkLibraryOffset, setSdkLibraryOffset] = useState(0);
const [processing, setProcessing] = useState<MemoryProcessingSummary | null>(
null,
);
const [processingFailed, setProcessingFailed] = useState(false);
const [processingResolved, setProcessingResolved] = useState(false);
const pipelineMode =
processing?.pipeline?.mode ??
(processingResolved && !processingFailed ? "pro" : null);
const lite = pipelineMode === "lite";
const modeCapabilities: HomeProjectionCapabilities | null = pipelineMode
? {
content_plane: lite ? "raw" : "memory",
generation_required: !lite,
graph_enabled: pipelineMode === "ultra",
evolve_available: pipelineMode === "ultra",
}
: null;
const requestedQuery = sdkLibraryQuery.trim();
const sdkPageSettled =
sdkLibrary.settledMode === pipelineMode &&
sdkLibrary.settledSource === sdkLibrarySource &&
sdkLibrary.settledQuery.trim() === requestedQuery &&
sdkLibrary.page?.offset === sdkLibraryOffset;
const sdkPage = sdkPageSettled ? sdkLibrary.page : null;
const sdkRequestedScope = JSON.stringify([
pipelineMode,
sdkLibraryQuery.trim(),
sdkLibrarySource,
]);
@@ -213,8 +254,9 @@ export function MemorySpace({
});
// The page the list shows, for the namespace of a memory being opened.
const sdkPageRef = useRef<MemoryLibraryPage | null>(null);
sdkPageRef.current = sdkLibrary.page;
const sdkMapVisible = simplified && active && displayedMode === "map";
sdkPageRef.current = sdkPage;
const sdkMapVisible =
simplified && active && displayedMode === "map" && pipelineMode !== null;
useEffect(() => {
if (!sdkMapVisible) return;
// A write bumps the revision; reading it here re-runs the read at once
@@ -224,7 +266,7 @@ export function MemorySpace({
let controller: AbortController | null = null;
const source = sdkLibrarySource || undefined;
const query = sdkLibraryQuery.trim() || undefined;
const windowKey = JSON.stringify([query ?? "", source ?? ""]);
const windowKey = JSON.stringify([pipelineMode, query ?? "", source ?? ""]);
if (sdkSearchWindow.current.key !== windowKey)
sdkSearchWindow.current = { key: windowKey, id: null };
const read = async (first: boolean) => {
@@ -233,42 +275,71 @@ export function MemorySpace({
controller = request;
if (first) setSdkLibrary((current) => ({ ...current, loading: true }));
try {
const [page, graph] = await Promise.all([
getSdkMemoryLibrary(
{
statuses: ["active"],
source,
query,
offset: sdkLibraryOffset,
limit: SDK_MEMORY_PAGE_SIZE,
windowId: query ? sdkSearchWindow.current.id : null,
},
request.signal,
),
// The overlay is decoration: the list stands without it. It reads
// the full active/source-filtered scene up to the API's visual cap.
// Reading only the first list-page prefix made "All applications"
// look identical to Cursor whenever newer Cursor memories occupied
// that prefix.
query
? Promise.resolve({
const rememberRaw = (items: RawMapRecord[]) => {
const next = new Map<string, RawMapRecord>();
if (selectedRawRef.current)
next.set(
rawRecordId(selectedRawRef.current),
selectedRawRef.current,
);
for (const item of items) next.set(rawRecordId(item), item);
rawRecords.current = next;
};
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,
})
: getSdkMemoryGraph(
}),
])
: await Promise.all([
getSdkMemoryLibrary(
{
statuses: ["active"],
source,
offset: 0,
limit: 300,
query,
offset: sdkLibraryOffset,
limit: SDK_MEMORY_PAGE_SIZE,
windowId: query ? sdkSearchWindow.current.id : null,
},
request.signal,
).then(
(value) => ({ status: "ready" as const, value }),
() => ({ status: "failed" as const, value: null }),
),
]);
).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 }),
),
]);
if (cancelled) return;
if (sdkSearchWindow.current.key === windowKey)
if (!lite && sdkSearchWindow.current.key === windowKey)
sdkSearchWindow.current = {
key: windowKey,
id: page.window_id ?? null,
@@ -276,11 +347,12 @@ export function MemorySpace({
// The window expired: the server searched again and answered page 1.
if (page.window_reset && sdkLibraryOffset !== 0) setSdkLibraryOffset(0);
setSdkLibrary({
page: sdkLibraryPage(page),
page,
loading: false,
failed: false,
settledSource: sdkLibrarySource,
settledQuery: sdkLibraryQuery,
settledMode: pipelineMode,
});
setSdkGraph({ scope: windowKey, ...graph });
} catch {
@@ -305,6 +377,8 @@ export function MemorySpace({
sdkLibraryOffset,
sdkLibraryQuery,
sdkLibrarySource,
lite,
pipelineMode,
]);
const sdkFilterLabel = [
sdkLibrary.settledSource
@@ -337,9 +411,6 @@ export function MemorySpace({
}
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [processing, setProcessing] = useState<MemoryProcessingSummary | null>(
null,
);
const [pendingAdd, setPendingAdd] = useState<PendingMemoryAdd | null>(null);
const [checkingSetup, setCheckingSetup] = useState(false);
// biome-ignore lint/correctness/useExhaustiveDependencies: Source authority changes invalidate an in-flight setup snapshot.
@@ -394,6 +465,7 @@ export function MemorySpace({
),
refreshing: checkingSetup,
memoryCount: undefined,
capabilities: modeCapabilities,
blockedStages: processing?.blocked_stages,
backlogPending: processing?.backlog_pending,
phase: sourceSnapshotStale ? "unavailable" : "checking",
@@ -408,7 +480,11 @@ export function MemorySpace({
const update = async () => {
try {
const next = await getMemoryProcessing();
if (!stopped) setProcessing(next);
if (!stopped) {
setProcessing(next);
setProcessingFailed(false);
setProcessingResolved(true);
}
} catch {
// Background read failures do not invalidate the last verified state.
}
@@ -438,8 +514,29 @@ export function MemorySpace({
const closeDetail = useCallback(() => {
detailEpoch.current++;
setDetail(null);
setSelectedRaw(null);
setDetailLoad(null);
}, []);
const previousPipelineMode = useRef(pipelineMode);
useEffect(() => {
if (pipelineMode === null || previousPipelineMode.current === pipelineMode)
return;
previousPipelineMode.current = pipelineMode;
rawRecords.current.clear();
sdkSearchWindow.current = { key: "", id: null };
setSdkLibraryOffset(0);
setSdkLibraryQuery("");
setSdkGraph(null);
setSdkLibrary((current) => ({
...current,
page: null,
loading: true,
failed: false,
settledQuery: "",
settledMode: null,
}));
closeDetail();
}, [pipelineMode, closeDetail]);
const previousSourceKey = useRef(sourceKey);
useEffect(() => {
@@ -460,14 +557,23 @@ export function MemorySpace({
* this subscriber and the background arrival poll read the same endpoint,
* so one `fetch` per tick is the whole point of sharing the key. */
const consumeProcessing = (value: unknown) => {
if (!cancelled) setProcessing(value as MemoryProcessingSummary);
if (!cancelled) {
setProcessing(value as MemoryProcessingSummary);
setProcessingFailed(false);
setProcessingResolved(true);
}
};
// `subscribePoll` only schedules ticks; the first read still has to happen
// on mount. Do it once through the shared fetch so mount does not double-read
// with the background arrival subscriber.
void getMemoryProcessing()
.then(consumeProcessing)
.catch(() => {});
.catch(() => {
if (!cancelled) {
setProcessingFailed(true);
setProcessingResolved(true);
}
});
// The processing poll shares one read with the background arrival poll on
// the same key (3s wins over 12s).
const stopProcessing = subscribePoll({
@@ -593,6 +699,56 @@ export function MemorySpace({
const epoch = ++detailEpoch.current;
setError(null);
setDetail(null);
setSelectedRaw(null);
if (lite) {
let record = rawRecords.current.get(memoryId);
if (!record) {
setDetailLoad({
memoryId,
status: "failed",
error: t("sdk.raw.loadFailed"),
});
return;
}
if ("kind" in record) {
setDetailLoad({ memoryId, status: "loading" });
try {
let offset = 0;
while (true) {
const page = await getSdkRaw({
source: record.source || undefined,
sessionId: record.session_id || undefined,
offset,
limit: 500,
});
const full = page.items.find(
(item) => rawRecordId(item) === memoryId,
);
if (full) {
record = full;
rawRecords.current.set(memoryId, full);
break;
}
offset += page.items.length;
if (!page.items.length || offset >= page.total)
throw new Error("raw_not_found");
}
} catch {
if (epoch === detailEpoch.current)
setDetailLoad({
memoryId,
status: "failed",
error: t("sdk.raw.loadFailed"),
});
return;
}
if (epoch !== detailEpoch.current) return;
}
setSelectedRaw(record);
setDetail(rawDetail(record));
setDetailLoad(null);
return;
}
setDetailLoad({ memoryId, status: "loading" });
try {
// The inspector reads the memory through v2 and renders it in place.
@@ -728,37 +884,56 @@ export function MemorySpace({
</div>
<header className="memory-instrument-header">
<div>
<h1 id="memory-title">
{simplified ? t("memory:记忆") : t("memory:记忆空间")}
</h1>
<div className="memory-title-line">
<h1 id="memory-title">
{simplified ? t("memory:记忆") : t("memory:记忆空间")}
</h1>
{simplified && pipelineMode ? (
<span className="memory-mode-label">{pipelineMode}</span>
) : null}
</div>
<span className="memory-loaded-count">
{simplified && !management
? sdkLibrary.page
? tn(
"memory:已沉淀 {count} 条{filter} · 当前展示 {shown} 条",
sdkLibrary.page.total,
{
filter: sdkFilterLabel
? t("memory:({filter})", { filter: sdkFilterLabel })
: "",
shown: new Intl.NumberFormat(locale).format(
sdkLibrary.page.items.length,
),
},
)
: // The list below says a failed read once, with its retry.
sdkLibrary.failed
? null
: t("memory:正在读取…")
? pipelineMode === null
? t("memory:记忆暂时无法读取")
: lite
? sdkPage
? `${t("memory:原始对话")} · ${tn(
"sdk.raw.found",
sdkPage.total,
)}`
: sdkLibrary.failed
? null
: t("memory:正在读取…")
: sdkPage
? tn(
"memory:已沉淀 {count} 条{filter} · 当前展示 {shown} 条",
sdkPage.total,
{
filter: sdkFilterLabel
? t("memory:({filter})", {
filter: sdkFilterLabel,
})
: "",
shown: new Intl.NumberFormat(locale).format(
sdkPage.items.length,
),
},
)
: // The list below says a failed read once, with its retry.
sdkLibrary.failed
? null
: t("memory:正在读取…")
: null}
</span>
</div>
<MemoryAddComposer
suspended={!active}
rawOnly={lite}
onPendingChange={setPendingAdd}
onSaved={() => {
// A finished addition is a new memory: re-read the page now rather
// than leave it for the next poll.
// A finished addition is new page content: re-read now rather than
// leave it for the next poll.
bumpSdkMap();
}}
onOpenMemory={(id) => {
@@ -920,22 +1095,33 @@ export function MemorySpace({
</details>
) : null}
<ModelBackoffBanner
processing={processing}
onConfigureModel={onConfigureModel}
/>
<ModelPrompt
processing={processing}
hasMemories={(sdkLibrary.page?.total ?? 0) > 0}
onConfigureModel={onConfigureModel}
/>
{!lite ? (
<>
<ModelBackoffBanner
processing={processing}
onConfigureModel={onConfigureModel}
/>
<ModelPrompt
processing={processing}
hasMemories={(sdkPage?.total ?? 0) > 0}
onConfigureModel={onConfigureModel}
/>
</>
) : null}
{simplified && pendingAdd ? (
<p className="memory-pending-add" role="status">
<span>
{t("memory:正在等待整理:")}
{lite
? t(
pendingAdd.state === "failed"
? "memory:原话已保存,整理未完成"
: "memory:正在保存…",
)
: t("memory:正在等待整理:")}
{lite ? " " : null}
{pendingAdd.text}
</span>
{pendingAdd.state === "waiting_for_model" ? (
{!lite && pendingAdd.state === "waiting_for_model" ? (
<button type="button" onClick={onConfigureModel}>
{t("memory:配置整理模型")}
</button>
@@ -954,7 +1140,7 @@ export function MemorySpace({
id="memory-mode-panel-history"
hidden={displayedMode !== "history"}
>
<SdkRawHistory />
<SdkRawHistory active={active && displayedMode === "history"} />
</div>
)}
@@ -995,14 +1181,18 @@ export function MemorySpace({
{/* Native hiding retains the WebGL owner; its visibility observer pauses drawing. */}
<MemoryMap
key={simplified ? (pipelineMode ?? "unknown") : "full"}
id="memory-mode-panel-map"
simplified={simplified}
contentKind={lite ? "raw" : "memory"}
modeCapabilities={modeCapabilities}
rawRecord={selectedRaw}
libraryView={
simplified
? {
query: sdkLibraryQuery,
source: sdkLibrarySource,
page: sdkLibrary.page,
page: sdkPage,
loading: sdkLibrary.loading,
failed: sdkLibrary.failed,
graphLoading: !sdkGraphSettled,
@@ -1020,14 +1210,18 @@ export function MemorySpace({
}
: undefined
}
hidden={!active || displayedMode !== "map"}
memories={sdkLibrary.page?.items ?? []}
hidden={
!active ||
displayedMode !== "map" ||
(simplified && pipelineMode === null)
}
memories={sdkPage?.items ?? []}
graph={visibleSdkGraph}
sources={sources}
processing={processing}
arrival={memoryArrival}
onViewHistory={simplified ? onConnect : () => setMode("history")}
loaded={sdkLibrary.page !== null}
loaded={sdkPage !== null}
detail={detail}
detailLoad={detailLoad}
overlayBlocked={
@@ -1035,7 +1229,7 @@ export function MemorySpace({
deleteCount > 0 ||
// Only the paged map waits for its page; the three-column surface
// never reads one.
(simplified && sdkLibrary.page === null)
(simplified && sdkPage === null)
}
draft={draft}
busy={busy}
@@ -1128,10 +1322,12 @@ export function MemorySpace({
className="memory-library-layout"
hidden={displayedMode !== "library"}
>
<SdkMemoryLibrary
active={active}
focusedMemoryId={sdkFocusedMemoryId}
/>
{!simplified || (pipelineMode !== null && !lite) ? (
<SdkMemoryLibrary
active={active}
focusedMemoryId={sdkFocusedMemoryId}
/>
) : null}
</div>
</section>
);
@@ -1350,7 +1546,7 @@ function message(reason: unknown, t: Translate): string {
reason.message === "memory_answer_context_changed"
? reason.message
: apiErrorCode(reason);
const labels: Record<string, Parameters<Translate>[0]> = {
const labels: Record<string, string> = {
memory_answer_context_changed:
"memory:记忆内容或使用权限已变化,回答已停止。请重新提问。",
memory_source_revoked: "memory:这条记忆的来源授权已撤回,暂时不能读取。",
@@ -1363,8 +1559,14 @@ function message(reason: unknown, t: Translate): string {
memory_request_conflict: "memory:这次操作与已完成的请求不一致。",
request_validation_failed: "memory:输入内容不符合要求。",
local_store_unavailable: "memory:本地记忆库正忙,请稍后重试。",
embedding_unavailable:
"memory:搜索服务暂时不可用,无法检索记忆。请到设置中检查记忆搜索配置。",
embedding_switch_in_progress:
"memory:正在更新记忆索引,完成后才能修改或删除记忆。搜索仍可使用。",
embedding_space_profile_missing:
"memory:当前搜索模型配置已不存在,请到设置中重新配置记忆搜索。",
};
if (code && labels[code]) return t(labels[code]);
if (code && labels[code]) return t(labels[code] as Parameters<Translate>[0]);
// No code at all means the request never reached the core — usually because
// it is still starting. Saying only "failed" left the one case that resolves
// itself looking like the ones that do not.
+523 -17
View File
@@ -1,6 +1,12 @@
// @vitest-environment jsdom
import type { ModelProbeResult, ModelProfile } from "@one-memory/contracts";
import type {
EmbeddingSwitchCurrent,
EmbeddingSwitchJob,
EmbeddingSwitchPlan,
ModelProbeResult,
ModelProfile,
} from "@one-memory/contracts";
import {
act,
cleanup,
@@ -66,6 +72,11 @@ const probeProfile = vi.fn();
const activate = vi.fn();
const remove = vi.fn();
const update = vi.fn();
const refreshProfiles = vi.fn();
const planActivation = vi.fn();
const startPlannedSwitch = vi.fn();
let switchPlan: EmbeddingSwitchPlan | null = null;
let switchCurrent: EmbeddingSwitchCurrent | null = null;
it("explains an insufficient model balance with the required recovery", () => {
expect(probeLabel("insufficient_balance")).toBe(
@@ -114,6 +125,71 @@ const preset: ModelProfile = {
has_api_key: false,
disable_thinking: true,
};
const migrationPlan: EmbeddingSwitchPlan = {
needed: true,
reason: "embedding_identity_changed",
plan_token: "plan-token",
source: {
space_id: "old-space",
model: "old-search",
dims: 512,
collection_base: "memory",
vectors: 12,
},
target: {
space_id: null,
model: "new-search",
dims: 1024,
collection_base: "",
vectors: null,
},
target_profile_id: profile.profile_id,
target_revision: profile.revision,
target_provider_kind: "openai_compatible",
target_local: false,
sources: [],
total_vectors: 12,
users: 1,
graph_vectors: null,
estimate_s: 10,
requests: 1,
disk_bytes_needed: 1024,
disk_bytes_free: 4096,
retention_days: 14,
blockers: [],
};
const oldFinishedJob: EmbeddingSwitchJob = {
job_id: "old-job",
kind: "switch",
state: "done",
source: migrationPlan.source,
target: { ...migrationPlan.target, space_id: "new-space", vectors: 12 },
stray_space_ids: [],
target_profile_id: profile.profile_id,
target_revision: profile.revision,
requested_by: "app",
progress: {
phase: "done",
done: 12,
total: 12,
percent: 100,
embedded: 12,
reused: 0,
kept: 0,
role: null,
source_index: 0,
sources: 1,
rate_per_s: null,
eta_s: 0,
updated_at: "2026-11-03T09:00:00Z",
},
cancel_requested: false,
error_code: null,
attempt: 1,
created_at: "2026-11-03T09:00:00Z",
updated_at: "2026-11-03T09:00:01Z",
finished_at: "2026-11-03T09:00:01Z",
};
/** Re-renders on demand so a cleared journal is picked up, like a real refresh. */
function ProbeRecoveryHarness({
@@ -149,6 +225,20 @@ vi.mock("./use-model-profiles", () => ({
activate,
remove,
update,
refreshProfiles,
}),
}));
vi.mock("./use-embedding-switch", () => ({
useEmbeddingSwitch: () => ({
current: switchCurrent,
plan: switchPlan,
busy: null,
errorCode: null,
planActivation,
startPlannedSwitch,
shouldUseLegacyActivation: () => false,
clearPlan: vi.fn(),
clearError: vi.fn(),
}),
}));
@@ -187,8 +277,15 @@ describe("ModelSettings", () => {
probes = {};
pendingProbe = null;
profiles = [profile];
switchPlan = null;
switchCurrent = null;
create.mockResolvedValue(profile);
update.mockResolvedValue({ ...preset, has_api_key: true, revision: 2 });
refreshProfiles.mockResolvedValue(undefined);
planActivation.mockResolvedValue({
needed: false,
reason: "same_embedding",
});
});
it("submits a generation-only profile and leaves embedding to the builtin", async () => {
@@ -399,6 +496,62 @@ describe("ModelSettings", () => {
});
});
it("focuses generation without exposing or patching the search lane", async () => {
update.mockResolvedValue(profile);
render(<ModelSettings focusSection="generation" />);
await userEvent.click(screen.getByRole("button", { name: "编辑配置" }));
expect(screen.getByRole("heading", { name: "记忆整理" })).toBeTruthy();
expect(screen.queryByLabelText("搜索模型")).toBeNull();
expect(screen.getByLabelText("生成模型名")).toHaveProperty(
"value",
profile.model,
);
await userEvent.click(screen.getByRole("button", { name: "保存修改" }));
expect(update).toHaveBeenCalledExactlyOnceWith(profile.profile_id, {
expected_revision: profile.revision,
});
});
it("does not repair a hidden legacy search endpoint while editing generation", async () => {
const legacy = {
...profile,
embedding_provider_kind: "openai_compatible" as const,
embedding_model: "remote-search",
embedding_base_url: null,
};
profiles = [legacy];
update.mockResolvedValue(legacy);
render(<ModelSettings focusSection="generation" />);
await userEvent.click(screen.getByRole("button", { name: "编辑配置" }));
await userEvent.type(screen.getByLabelText("生成模型名"), "-new");
await userEvent.click(screen.getByRole("button", { name: "保存修改" }));
expect(update).toHaveBeenCalledExactlyOnceWith(profile.profile_id, {
expected_revision: profile.revision,
model: "memory-model-new",
});
});
it("focuses search on the recommended builtin without exposing or patching generation", async () => {
update.mockResolvedValue(profile);
render(<ModelSettings focusSection="embedding" />);
await userEvent.click(screen.getByRole("button", { name: "编辑配置" }));
expect(screen.getByRole("heading", { name: "记忆搜索" })).toBeTruthy();
expect(screen.queryByLabelText("生成模型名")).toBeNull();
expect(screen.getByLabelText("搜索模型")).toHaveProperty("value", "local");
expect(screen.getByText(/无需密钥/)).toBeTruthy();
expect(screen.getByText("技术类型:Embedding")).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: "保存修改" }));
expect(update).toHaveBeenCalledExactlyOnceWith(profile.profile_id, {
expected_revision: profile.revision,
});
});
it("does not replace an edit draft with a newer external profile revision", async () => {
const view = render(<ModelSettings />);
await userEvent.click(screen.getByRole("button", { name: "编辑配置" }));
@@ -426,7 +579,8 @@ describe("ModelSettings", () => {
name: "高级:自行配置向量模型",
});
await user.click(disclosure);
await user.selectOptions(screen.getByLabelText("向量配置模式"), "custom");
await user.click(screen.getByLabelText("向量配置模式"));
await user.click(screen.getByRole("option", { name: "自定义搜索模型" }));
await user.type(screen.getByLabelText("向量模型名"), "custom-vector");
await user.type(
screen.getByLabelText("向量模型 Base URL"),
@@ -516,9 +670,9 @@ describe("ModelSettings", () => {
];
render(<ModelSettings />);
await userEvent.click(screen.getByRole("button", { name: "编辑配置" }));
await userEvent.selectOptions(
screen.getByLabelText("向量配置模式"),
"local",
await userEvent.click(screen.getByLabelText("向量配置模式"));
await userEvent.click(
screen.getByRole("option", { name: "本地内置(推荐)" }),
);
await userEvent.click(screen.getByRole("button", { name: "保存修改" }));
expect(update).toHaveBeenCalledWith(profile.profile_id, {
@@ -537,9 +691,9 @@ describe("ModelSettings", () => {
name: "高级:自行配置向量模型",
});
await userEvent.click(disclosure);
await userEvent.selectOptions(
screen.getByLabelText("向量配置模式"),
"custom",
await userEvent.click(screen.getByLabelText("向量配置模式"));
await userEvent.click(
screen.getByRole("option", { name: "自定义搜索模型" }),
);
await userEvent.click(disclosure);
await userEvent.click(screen.getByRole("button", { name: "保存配置" }));
@@ -661,9 +815,229 @@ describe("ModelSettings", () => {
expect(container.textContent).not.toContain("secret-value");
await user.click(screen.getByRole("button", { name: "设为当前模型" }));
expect(planActivation).toHaveBeenCalledWith("profile-1", 1);
expect(activate).toHaveBeenCalledWith("profile-1");
});
it("asks before activation when the search index must be rebuilt", async () => {
profiles = [
{
...profile,
capabilities: {
...profile.capabilities,
endpoint_reachable: true,
model_available: true,
embedding_available: true,
},
},
];
planActivation.mockImplementationOnce(async () => {
switchPlan = migrationPlan;
return migrationPlan;
});
switchCurrent = {
schema_version: 1,
phase: "idle",
writes_paused: false,
scan_state: "done",
scan_error: null,
job: null,
last_finished: oldFinishedJob,
};
render(<ModelSettings />);
await userEvent.click(screen.getByRole("button", { name: "设为当前模型" }));
expect(
await screen.findByRole("heading", { name: "更新记忆搜索索引?" }),
).toBeTruthy();
expect(activate).not.toHaveBeenCalled();
});
it("completes an embedding change only after the switch commit activates the target", async () => {
const onSetupComplete = vi.fn();
profiles = [
{
...profile,
capabilities: {
...profile.capabilities,
endpoint_reachable: true,
model_available: true,
embedding_available: true,
},
},
];
planActivation.mockImplementationOnce(async () => {
switchPlan = migrationPlan;
return migrationPlan;
});
const runningJob: EmbeddingSwitchJob = {
...oldFinishedJob,
job_id: "switch-job",
state: "reembedding",
progress: { ...oldFinishedJob.progress, phase: "vectors", done: 0 },
};
startPlannedSwitch.mockResolvedValue(runningJob);
switchCurrent = {
schema_version: 1,
phase: "idle",
writes_paused: false,
scan_state: "done",
scan_error: null,
job: null,
last_finished: oldFinishedJob,
};
const view = render(<ModelSettings onSetupComplete={onSetupComplete} />);
await userEvent.click(screen.getByRole("button", { name: "设为当前模型" }));
await userEvent.click(
await screen.findByRole("button", { name: "开始更新索引" }),
);
await waitFor(() =>
expect(startPlannedSwitch).toHaveBeenCalledExactlyOnceWith(),
);
expect(onSetupComplete).not.toHaveBeenCalled();
refreshProfiles.mockImplementationOnce(async () => {
profiles = [{ ...profiles[0], is_active: true }];
});
switchCurrent = {
...switchCurrent,
phase: "idle",
job: null,
last_finished: { ...runningJob, state: "done" },
};
view.rerender(<ModelSettings onSetupComplete={onSetupComplete} />);
await waitFor(() => expect(onSetupComplete).toHaveBeenCalledOnce());
});
it("does not complete a committed switch until the target revision is active", async () => {
const onSetupComplete = vi.fn();
profiles = [
{
...profile,
capabilities: {
...profile.capabilities,
endpoint_reachable: true,
model_available: true,
embedding_available: true,
},
},
];
planActivation.mockImplementationOnce(async () => {
switchPlan = migrationPlan;
return migrationPlan;
});
const runningJob: EmbeddingSwitchJob = {
...oldFinishedJob,
job_id: "switch-job",
state: "reembedding",
progress: { ...oldFinishedJob.progress, phase: "vectors", done: 0 },
};
startPlannedSwitch.mockResolvedValue(runningJob);
switchCurrent = {
schema_version: 1,
phase: "idle",
writes_paused: false,
scan_state: "done",
scan_error: null,
job: null,
last_finished: oldFinishedJob,
};
const view = render(<ModelSettings onSetupComplete={onSetupComplete} />);
await userEvent.click(screen.getByRole("button", { name: "设为当前模型" }));
await userEvent.click(
await screen.findByRole("button", { name: "开始更新索引" }),
);
await waitFor(() => expect(startPlannedSwitch).toHaveBeenCalledOnce());
switchCurrent = {
...switchCurrent,
phase: "idle",
job: null,
last_finished: { ...runningJob, state: "done" },
};
view.rerender(<ModelSettings onSetupComplete={onSetupComplete} />);
expect(
await screen.findByText(
"配置已保存,尚未确认启用成功。请核对配置后重试。",
),
).toBeTruthy();
expect(onSetupComplete).not.toHaveBeenCalled();
expect(
(
screen.getByRole("button", {
name: "保存并测试启用",
}) as HTMLButtonElement
).disabled,
).toBe(false);
});
it("keeps embedding configuration open when the switch rolls back", async () => {
const onSetupComplete = vi.fn();
profiles = [
{
...profile,
capabilities: {
...profile.capabilities,
endpoint_reachable: true,
model_available: true,
embedding_available: true,
},
},
];
planActivation.mockImplementationOnce(async () => {
switchPlan = migrationPlan;
return migrationPlan;
});
const runningJob: EmbeddingSwitchJob = {
...oldFinishedJob,
job_id: "switch-job",
state: "reembedding",
progress: { ...oldFinishedJob.progress, phase: "vectors", done: 0 },
};
startPlannedSwitch.mockResolvedValue(runningJob);
switchCurrent = {
schema_version: 1,
phase: "idle",
writes_paused: false,
scan_state: "done",
scan_error: null,
job: null,
last_finished: oldFinishedJob,
};
const view = render(<ModelSettings onSetupComplete={onSetupComplete} />);
await userEvent.click(screen.getByRole("button", { name: "设为当前模型" }));
await userEvent.click(
await screen.findByRole("button", { name: "开始更新索引" }),
);
await waitFor(() => expect(startPlannedSwitch).toHaveBeenCalledOnce());
switchCurrent = {
...switchCurrent,
phase: "idle",
job: null,
last_finished: {
...runningJob,
state: "rolled_back",
error_code: "embedding_switch_failed",
},
};
view.rerender(<ModelSettings onSetupComplete={onSetupComplete} />);
expect(
await screen.findByText(
"索引更新没有完成,仍在使用原来的搜索模型,记忆没有丢失。",
),
).toBeTruthy();
expect(onSetupComplete).not.toHaveBeenCalled();
});
it("hands a successful save to its card, clears only the key, and never tests or activates", async () => {
profiles = [];
const user = userEvent.setup();
@@ -714,6 +1088,51 @@ describe("ModelSettings", () => {
);
});
it("keeps required generation fields mounted when technical details collapse", async () => {
render(<ModelSettings />);
await userEvent.click(
screen.getByRole("button", { name: "配置详情与高级设置" }),
);
const modelField =
document.querySelector<HTMLInputElement>('[name="model"]');
const baseUrlField =
document.querySelector<HTMLInputElement>('[name="base_url"]');
expect(modelField).toBeTruthy();
expect(baseUrlField).toBeTruthy();
expect(modelField?.closest("[hidden]")).toBeTruthy();
expect(baseUrlField?.closest("[hidden]")).toBeTruthy();
});
it("reopens collapsed generation details and focuses the first invalid field", async () => {
render(<ModelSettings />);
await userEvent.click(
screen.getByRole("button", { name: "配置详情与高级设置" }),
);
await userEvent.click(screen.getByRole("button", { name: "保存配置" }));
const modelField = await screen.findByLabelText("生成模型名");
expect(
screen
.getByRole("button", { name: "配置详情与高级设置" })
.getAttribute("aria-expanded"),
).toBe("true");
await waitFor(() => expect(document.activeElement).toBe(modelField));
expect(create).not.toHaveBeenCalled();
});
it("clears inherited runtime values when an edit changes protocol and creates with Core defaults", async () => {
render(<ModelSettings />);
await userEvent.click(screen.getByRole("button", { name: "编辑配置" }));
expect(screen.getByLabelText("超时(秒)")).toHaveProperty("value", "60");
await userEvent.selectOptions(screen.getByLabelText("协议"), "ollama");
expect(screen.getByLabelText("超时(秒)")).toHaveProperty("value", "");
await userEvent.click(screen.getByRole("button", { name: "保存修改" }));
expect(create).toHaveBeenCalledOnce();
expect(create.mock.calls[0][0]).not.toHaveProperty("timeout_seconds");
expect(create.mock.calls[0][0]).not.toHaveProperty("max_concurrency");
expect(create.mock.calls[0][0]).not.toHaveProperty("context_window");
expect(update).not.toHaveBeenCalled();
});
it("shows field-local Chinese required errors for mouse and keyboard submission", async () => {
const user = userEvent.setup();
render(<ModelSettings />);
@@ -813,14 +1232,16 @@ describe("ModelSettings", () => {
await user.click(
screen.getByRole("button", { name: "高级:自行配置向量模型" }),
);
await user.selectOptions(screen.getByLabelText("向量配置模式"), "custom");
await user.click(screen.getByLabelText("向量配置模式"));
await user.click(screen.getByRole("option", { name: "自定义搜索模型" }));
await user.type(
screen.getByLabelText("配置名称"),
"Remote with local embedding",
);
await user.type(screen.getByLabelText("生成模型名"), "remote-model");
await user.type(screen.getByLabelText("向量模型名"), "nomic-embed-text");
await user.selectOptions(screen.getByLabelText("向量模型来源"), "ollama");
await user.click(screen.getByLabelText("向量模型来源"));
await user.click(screen.getByRole("option", { name: "本机 Ollama" }));
await user.type(
screen.getByLabelText("向量模型 Base URL"),
"http://127.0.0.1:11434",
@@ -836,7 +1257,7 @@ describe("ModelSettings", () => {
);
});
it("后端激活不了的协议根本不出现在下拉里", () => {
it("后端激活不了的协议显示但不可选择", () => {
render(<ModelSettings />);
const providerSelect = screen.getByLabelText("协议") as HTMLSelectElement;
@@ -849,12 +1270,16 @@ describe("ModelSettings", () => {
);
expect(labels.some((text) => text.startsWith("Ollama(本机)"))).toBe(true);
// ModelProfileStore.activate() 对这两个 provider 抛
// model_provider_not_supported。之前它们以 disabled 的形态留在下拉里,
// 等于把两条死路摆在明面上让人去读懂括号里的「暂不支持」。
expect(labels.some((text) => text.includes("Anthropic"))).toBe(false);
expect(labels.some((text) => text.includes("HY Memory"))).toBe(false);
expect(providerSelect.options.length).toBe(2);
expect(
Array.from(providerSelect.options).find((option) =>
option.textContent?.includes("Anthropic"),
)?.disabled,
).toBe(true);
expect(
Array.from(providerSelect.options).find((option) =>
option.textContent?.includes("HY 托管"),
)?.disabled,
).toBe(true);
});
it("已保存的配置不印内置向量模型", () => {
@@ -1043,6 +1468,87 @@ describe("ModelSettings", () => {
expect(nameField().disabled).toBe(false);
});
});
it("only completes compact setup after the backend confirms the profile is active", async () => {
const user = userEvent.setup();
const onSetupComplete = vi.fn();
const capabilities = {
endpoint_reachable: true,
model_available: true,
structured_output: false,
embedding_available: true,
};
const created = {
...profile,
profile_id: "created",
revision: 1,
capabilities,
};
profiles = [];
create.mockImplementation(async () => {
profiles = [created];
return created;
});
probeProfile.mockResolvedValue({
profile_id: created.profile_id,
profile_revision: created.revision,
stages: [],
capabilities,
});
activate.mockResolvedValue({ ...created, is_active: true });
render(<ModelSettings compactSingle onSetupComplete={onSetupComplete} />);
await user.type(screen.getByLabelText("配置名称"), "Current model");
await user.type(screen.getByLabelText("生成模型名"), "model-v1");
await user.type(screen.getByLabelText(/^API Key/), "secret-value");
await user.click(screen.getByRole("button", { name: "测试并完成配置" }));
await waitFor(() => expect(onSetupComplete).toHaveBeenCalledOnce());
expect(planActivation).toHaveBeenCalledWith(
created.profile_id,
created.revision,
);
expect(activate).toHaveBeenCalledWith(created.profile_id);
});
it("keeps compact setup open when activation is not confirmed", async () => {
const user = userEvent.setup();
const onSetupComplete = vi.fn();
const capabilities = {
endpoint_reachable: true,
model_available: true,
structured_output: false,
embedding_available: true,
};
const created = {
...profile,
profile_id: "created",
revision: 1,
capabilities,
};
profiles = [];
create.mockImplementation(async () => {
profiles = [created];
return created;
});
probeProfile.mockResolvedValue({
profile_id: created.profile_id,
profile_revision: created.revision,
stages: [],
capabilities,
});
activate.mockResolvedValue({ ...created, is_active: false });
render(<ModelSettings compactSingle onSetupComplete={onSetupComplete} />);
await user.type(screen.getByLabelText("配置名称"), "Inactive model");
await user.type(screen.getByLabelText("生成模型名"), "model-v1");
await user.type(screen.getByLabelText(/^API Key/), "secret-value");
await user.click(screen.getByRole("button", { name: "测试并完成配置" }));
await screen.findByText("配置已保存,尚未确认启用成功。请核对配置后重试。");
expect(onSetupComplete).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "测试并完成配置" })).toBeTruthy();
});
});
describe("把一张预置卡设为当前模型", () => {
File diff suppressed because it is too large. Load diff
@@ -8,7 +8,18 @@ import { completeOnboarding } from "./api";
import { OnboardingFlow } from "./OnboardingFlow";
import type { ImportSourcesController } from "./use-import-sources";
vi.mock("./api", () => ({ completeOnboarding: vi.fn() }));
vi.mock("./api", () => ({
completeOnboarding: vi.fn(),
getMemoryMode: vi.fn().mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: false,
}),
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
planMemoryMode: vi.fn().mockResolvedValue({ blockers: [] }),
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
}));
vi.mock("./FirstImportReview", () => ({
defaultHostSourceIds: () => [],
FirstImportReview: ({
@@ -41,6 +52,9 @@ const state = {
selected_source_ids: [],
started_at: null,
completed_at: null,
mode_intent: null,
setup_step: null,
model_profile_id: null,
} as const;
function source(
id: string,
@@ -121,10 +135,13 @@ 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: "继续配置模型" }));
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(completeOnboarding).not.toHaveBeenCalled();
});
@@ -147,6 +164,8 @@ it("lets an empty first-time user enter without granting or importing a source",
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 }));
await userEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
@@ -168,6 +187,8 @@ it("retains explicit retry or enter when saving the skipped onboarding fails", a
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 }));
await userEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
await screen.findByRole("button", { name: "重试保存并进入" });
expect(finish).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: "仍进入应用页" }));
@@ -25,6 +25,15 @@ import type { ImportSourcesController } from "./use-import-sources";
vi.mock("./api", () => ({
completeOnboarding: vi.fn(),
getModelProfiles: vi.fn(),
getMemoryMode: vi.fn().mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: false,
}),
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
planMemoryMode: vi.fn().mockResolvedValue({ blockers: [] }),
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
}));
const models = {
pendingProbe: false,
@@ -38,6 +47,22 @@ const models = {
activate: vi.fn(),
};
vi.mock("./use-model-profiles", () => ({ useModelProfiles: () => models }));
vi.mock("./use-embedding-switch", () => ({
useEmbeddingSwitch: () => ({
current: null,
plan: null,
busy: null,
errorCode: null,
refreshFailed: false,
supported: false,
planActivation: vi.fn(),
startPlannedSwitch: vi.fn(),
cancelCurrentSwitch: vi.fn(),
shouldUseLegacyActivation: () => true,
clearPlan: vi.fn(),
clearError: vi.fn(),
}),
}));
const source: SourceResponse = {
source_id: "cursor",
display_name: "Cursor",
@@ -87,6 +112,9 @@ const state = {
selected_source_ids: [],
started_at: null,
completed_at: null,
mode_intent: null,
setup_step: null,
model_profile_id: null,
};
beforeEach(() => {
delete window.oneMemory;
@@ -243,7 +271,7 @@ it("translates import review and preserves independent permission disclosure", a
).toBeTruthy();
expect(
screen.getByText(
"Importar no concede automáticamente acceso de lectura a las aplicaciones. Las memorias comenzarán a generarse cuando configures un modelo.",
"A continuación, elige un modo de memoria. Podrás cambiarlo más adelante en Configuración.",
),
).toBeTruthy();
expect(
@@ -273,20 +301,20 @@ it("uses compact Spanish actions and preserves Chinese copy and permissions", as
expect(navigation.textContent).toContain("Conectar apps");
expect(
screen.getByRole("button", {
name: "Configurar un modelo para crear memorias",
name: "Elegir el modo de memoria",
}),
).toBeTruthy();
expect(screen.getByText(/Importar no concede automáticamente/)).toBeTruthy();
expect(
screen.getByText(/A continuación, elige un modo de memoria/),
).toBeTruthy();
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(screen.getByRole("button", { name: "选择记忆模式" })).toBeTruthy();
expect(navigation.textContent).toContain("连接应用");
expect(screen.getByText(/不会自动开放应用读取权限/)).toBeTruthy();
expect(screen.getByText(/下一步选择记忆模式/)).toBeTruthy();
expect(next).not.toHaveBeenCalled();
expect(draft).not.toHaveBeenCalled();
});
@@ -393,6 +421,8 @@ it("translates completion recovery live without repeating completion writes", as
fireEvent.click(
screen.getByRole("button", { name: "Entrar y añadir más tarde" }),
);
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
fireEvent.click(screen.getByRole("button", { name: "Entrar con Lite" }));
expect(
await screen.findByRole("button", { name: "Reintentar guardar y entrar" }),
).toBeTruthy();
@@ -415,9 +445,14 @@ it("translates model configuration, validation and advanced fields without activ
);
expect(
await screen.findByRole("heading", {
name: "Elige un modelo para crear memorias",
name: "Configurar el modelo de organización de recuerdos",
}),
).toBeTruthy();
fireEvent.click(
screen.getByRole("button", {
name: "Detalles y configuración avanzada",
}),
);
fireEvent.click(screen.getByRole("button", { name: "Probar conexión" }));
expect(await screen.findByText("Introduce una API Key.")).toBeTruthy();
expect(screen.getByLabelText("Tiempo de espera (segundos)")).toBeTruthy();
@@ -428,6 +463,42 @@ it("translates model configuration, validation and advanced fields without activ
expect(screen.getByLabelText("超时(秒)")).toBeTruthy();
});
it.each([
[
"索引更新没有完成,仍在使用原来的搜索模型。请检查连接后重试。",
"No se completó la actualización del índice. Se sigue usando el modelo de búsqueda anterior. Comprueba la conexión y vuelve a intentarlo.",
],
[
"上次模型测试结果仍待核对,请稍后重试。",
"El resultado de la última prueba del modelo aún debe comprobarse. Vuelve a intentarlo más tarde.",
],
[
"模型写入暂时关闭。请等待索引维护完成后重试。",
"La escritura del modelo está temporalmente pausada. Espera a que termine el mantenimiento del índice y vuelve a intentarlo.",
],
[
"模型未能确认启用,请在设置中核对。",
"No se pudo confirmar que el modelo esté habilitado. Compruébalo en Configuración.",
],
[
"已保存的模型配置暂时无法读取,不会重复创建。请重试刷新。",
"La configuración guardada del modelo no se puede leer temporalmente. No se creará un duplicado. Vuelve a actualizar.",
],
])("recognizes controller message key %s", async (key, translated) => {
render(
<LanguageProvider>
<OnboardingModelController
sourceNames={[]}
flowError={key}
onBack={vi.fn()}
/>
</LanguageProvider>,
);
expect(await screen.findByText(translated)).toBeTruthy();
expect(screen.queryByText(key)).toBeNull();
});
it.each([
[
"completed",
@@ -26,6 +26,15 @@ vi.mock("./api", () => ({
getImportJobs: vi.fn(),
connectSources: vi.fn(),
completeOnboarding: vi.fn(),
getMemoryMode: vi.fn().mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: false,
}),
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
planMemoryMode: vi.fn().mockResolvedValue({ blockers: [] }),
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
getSourceConnection: vi.fn(),
createImportJob: vi.fn(),
detectSource: vi.fn(),
@@ -68,6 +77,9 @@ const state: OnboardingState = {
selected_source_ids: [],
started_at: null,
completed_at: null,
mode_intent: null,
setup_step: null,
model_profile_id: null,
};
let sources: SourceResponse[];
let jobs: ImportJob[];
@@ -140,6 +152,17 @@ function noUnrequestedWrites() {
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(api.getMemoryMode).mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: false,
} as never);
vi.mocked(api.setMemoryMode).mockResolvedValue({ mode: "pro" } as never);
vi.mocked(api.planMemoryMode).mockResolvedValue({
blockers: [],
} as never);
vi.mocked(api.putOnboardingProgress).mockResolvedValue(state);
vi.mocked(api.patchOnboardingProgress).mockResolvedValue(state);
localStorage.clear();
vi.stubGlobal("crypto", webcrypto);
delete window.oneMemory;
@@ -161,6 +184,34 @@ beforeEach(() => {
...state,
status: "completed",
});
vi.mocked(api.getMemoryMode).mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: false,
forced_by_env: false,
modes: ["lite", "pro", "ultra"],
graph_build: {
state: "idle",
started_at: null,
finished_at: null,
users: {},
},
});
vi.mocked(api.setMemoryMode).mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: true,
forced_by_env: false,
modes: ["lite", "pro", "ultra"],
graph_build: {
state: "idle",
started_at: null,
finished_at: null,
users: {},
},
previous_mode: "pro",
effects: [],
});
vi.mocked(api.connectSources).mockImplementation(
async (ids, _confirmed, options) => ({
request_id: options?.requestId ?? null,
@@ -267,6 +318,8 @@ it("empty discovery shows zero and allows explicit entry without a connection re
true,
);
fireEvent.click(screen.getByRole("button", { name: "稍后添加,先进入" }));
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
fireEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
@@ -317,6 +370,8 @@ it("partial acceptance preserves the successful job and failure through model-st
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(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
+15 -1
View File
@@ -13,7 +13,18 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { OnboardingFlow } from "./OnboardingFlow";
import type { ImportSourcesController } from "./use-import-sources";
vi.mock("./api", () => ({ completeOnboarding: vi.fn() }));
vi.mock("./api", () => ({
completeOnboarding: vi.fn(),
getMemoryMode: vi.fn().mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: false,
}),
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
planMemoryMode: vi.fn().mockResolvedValue({ blockers: [] }),
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
}));
vi.mock("./FirstImportReview", () => ({
FirstImportReview: ({ sourceIds }: { sourceIds: string[] }) => (
<p>已受理 {sourceIds.join(",")}</p>
@@ -33,6 +44,9 @@ const state = {
selected_source_ids: [],
started_at: null,
completed_at: null,
mode_intent: null,
setup_step: null,
model_profile_id: null,
} as const;
function source(
id: string,
+471 -31
View File
@@ -9,6 +9,7 @@ import type {
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
@@ -17,6 +18,10 @@ import {
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "./api";
import {
confirmedMemoryModeSnapshot,
resetConfirmedMemoryModeForTests,
} from "./confirmed-memory-mode";
import { readConnectionPreferences } from "./connection-preferences";
import { OnboardingFlow, PLUGIN_CONSENT_KEY } from "./OnboardingFlow";
import {
@@ -25,6 +30,11 @@ import {
} from "./use-import-sources";
const completeOnboarding = vi.fn();
const setMemoryMode = vi.fn();
const getMemoryMode = vi.fn();
const planMemoryMode = vi.fn();
const putOnboardingProgress = vi.fn();
const patchOnboardingProgress = vi.fn();
const getModelProfiles = vi.fn();
const getMemoryProcessing = vi.fn();
const previewSourceDirectory = vi.fn();
@@ -44,6 +54,12 @@ vi.mock("./api", () => ({
getImportJob: vi.fn(),
apiErrorCode: vi.fn(() => null),
completeOnboarding: (...args: unknown[]) => completeOnboarding(...args),
setMemoryMode: (...args: unknown[]) => setMemoryMode(...args),
getMemoryMode: (...args: unknown[]) => getMemoryMode(...args),
planMemoryMode: (...args: unknown[]) => planMemoryMode(...args),
putOnboardingProgress: (...args: unknown[]) => putOnboardingProgress(...args),
patchOnboardingProgress: (...args: unknown[]) =>
patchOnboardingProgress(...args),
getModelProfiles: (...args: unknown[]) => getModelProfiles(...args),
getMemoryProcessing: (...args: unknown[]) => getMemoryProcessing(...args),
previewSourceDirectory: (...args: unknown[]) =>
@@ -58,20 +74,33 @@ vi.mock("./OnboardingModelController", () => ({
OnboardingModelController: ({
onBack,
onSkip,
onEnabled,
onProfilePersisted,
onModelActiveConfirmed,
generationToken,
}: {
onBack(): void;
onSkip(): void;
onEnabled(): void;
onSkip?(): void;
onProfilePersisted(profileId: string): void;
onModelActiveConfirmed(token: number, profileId: string): void;
generationToken: number;
}) => (
<section aria-label="模型步骤边界">
<button type="button" onClick={onBack}>
返回导入
</button>
<button type="button" onClick={onEnabled}>
<button
type="button"
onClick={() => {
onProfilePersisted("9be6d465-4cf2-4c72-93f7-15f3c012bc80");
onModelActiveConfirmed(
generationToken,
"9be6d465-4cf2-4c72-93f7-15f3c012bc80",
);
}}
>
模型启用回执已确认
</button>
<button type="button" onClick={onSkip}>
<button type="button" onClick={onSkip ?? onBack}>
跳过模型进入应用
</button>
</section>
@@ -83,6 +112,9 @@ const onboarding: OnboardingState = {
selected_source_ids: [],
started_at: null,
completed_at: null,
mode_intent: null,
setup_step: null,
model_profile_id: null,
};
const health = {
status: "ok" as const,
@@ -176,9 +208,18 @@ async function skipWelcome(user?: ReturnType<typeof userEvent.setup>) {
await openApps(user);
}
async function continueImport(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole("button", { name: "配置模型,沉淀记忆" }));
await openModelStep(user);
await user.click(screen.getByRole("button", { name: "模型启用回执已确认" }));
}
async function openModelStep(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole("button", { name: "选择记忆模式" }));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
}
async function chooseLiteAndContinue(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole("radio", { name: /lite/i }));
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
}
function choice(name = "Claude Code") {
return screen.getByRole<HTMLInputElement>("checkbox", {
name: `导入 ${name} 历史`,
@@ -212,12 +253,40 @@ async function approve(user: ReturnType<typeof userEvent.setup>) {
describe("OnboardingFlow S1.3", () => {
beforeEach(() => {
vi.resetAllMocks();
resetConfirmedMemoryModeForTests();
window.localStorage.clear();
window.oneMemory = undefined;
completeOnboarding.mockResolvedValue({
...onboarding,
status: "completed",
});
setMemoryMode.mockResolvedValue({ mode: "pro" });
planMemoryMode.mockResolvedValue({
current: "lite",
target: "pro",
effective_target: "pro",
capabilities: {},
effects: [],
warnings: [],
blockers: [],
});
putOnboardingProgress.mockImplementation(
async (update: Partial<OnboardingState>) => ({
...onboarding,
...update,
}),
);
patchOnboardingProgress.mockImplementation(
async (update: Partial<OnboardingState>) => ({
...onboarding,
...update,
}),
);
getMemoryMode.mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: false,
});
});
afterEach(() => {
cleanup();
@@ -352,10 +421,10 @@ describe("OnboardingFlow S1.3", () => {
await user.click(screen.getByRole("button", { name: START }));
const continueButton = await screen.findByRole("button", {
name: "配置模型,沉淀记忆",
name: "选择记忆模式",
});
expect(continueButton).toHaveProperty("disabled", false);
await user.click(continueButton);
await openModelStep(user);
const modelBoundary = screen.getByRole("region", {
name: "模型步骤边界",
});
@@ -365,6 +434,7 @@ describe("OnboardingFlow S1.3", () => {
modelBoundary,
);
await user.click(screen.getByRole("button", { name: "返回导入" }));
await user.click(screen.getByRole("button", { name: "返回导入" }));
await waitFor(() =>
expect(screen.getByRole("alert").textContent).toContain(
"开始导入的结果尚未确认",
@@ -388,9 +458,7 @@ describe("OnboardingFlow S1.3", () => {
onFinish={finish}
/>,
);
await userEvent.click(
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
);
await openModelStep(userEvent.setup());
expect(screen.getByRole("region", { name: "模型步骤边界" })).toBeTruthy();
expect(completeOnboarding).not.toHaveBeenCalled();
expect(finish).not.toHaveBeenCalled();
@@ -649,9 +717,7 @@ describe("OnboardingFlow S1.3", () => {
await approve(user);
await user.click(screen.getByRole("button", { name: START }));
expect(onFinish).not.toHaveBeenCalled();
await user.click(
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
);
await openModelStep(user);
expect(onFinish).not.toHaveBeenCalled();
expect(completeOnboarding).not.toHaveBeenCalled();
await user.click(
@@ -678,13 +744,387 @@ describe("OnboardingFlow S1.3", () => {
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /lite/i }));
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
expect(onFinish).toHaveBeenCalledWith("sources");
expect(completeOnboarding).toHaveBeenCalledOnce();
expect(controller.refresh).toHaveBeenCalledOnce();
expect(controller.connectForOnboarding).not.toHaveBeenCalled();
});
it("allows entering the app from step three without creating a model", async () => {
it("shows the compact Lite and Pro mode step with contextual actions", async () => {
const user = userEvent.setup();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={vi.fn()}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
expect(screen.getByText("之后可在设置中更改。")).toBeTruthy();
expect(screen.getAllByRole("radio")).toHaveLength(2);
expect(screen.queryByRole("radio", { name: /ultra/i })).toBeNull();
expect(screen.getByText("推荐").closest("label")?.textContent).toContain(
"Pro",
);
expect(
screen.getByText("Ultra 可在完成首次设置后到设置页开启。"),
).toBeTruthy();
const modeStep = screen
.getByRole("navigation", { name: "首次设置进度" })
.querySelector('[aria-current="step"]');
expect(modeStep?.getAttribute("aria-current")).toBe("step");
await user.click(screen.getByRole("radio", { name: /lite/i }));
expect(screen.getByRole("button", { name: "使用 Lite 进入" })).toBeTruthy();
await user.click(screen.getByRole("radio", { name: /pro/i }));
expect(screen.getByRole("button", { name: "继续配置模型" })).toBeTruthy();
});
it("does not let a late saved-mode read overwrite the user's new choice", async () => {
let resolveMode!: (value: {
mode: "pro";
configured_mode: "pro";
explicitly_configured: true;
}) => void;
getMemoryMode.mockReturnValueOnce(
new Promise((resolve) => {
resolveMode = resolve;
}),
);
const user = userEvent.setup();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={vi.fn()}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
const lite = screen.getByRole<HTMLInputElement>("radio", { name: /lite/i });
await user.click(lite);
await act(async () => {
resolveMode({
mode: "pro",
configured_mode: "pro",
explicitly_configured: true,
});
});
expect(lite.checked).toBe(true);
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
expect(setMemoryMode).toHaveBeenLastCalledWith("lite");
});
it("retains the explicit lite choice when saving the mode fails and retries", async () => {
const user = userEvent.setup();
const controller = imports({ sources: [] });
const onFinish = vi.fn();
setMemoryMode.mockRejectedValueOnce(new Error("offline"));
render(
<OnboardingFlow
state={onboarding}
imports={controller}
onFinish={onFinish}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
const lite = screen.getByRole<HTMLInputElement>("radio", { name: /lite/i });
expect(screen.getByRole("button", { name: "继续" })).toHaveProperty(
"disabled",
true,
);
await user.click(lite);
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
expect((await screen.findByRole("alert")).textContent).toContain(
"模式未能保存。你的选择已保留,请重试。",
);
expect(lite.checked).toBe(true);
expect(completeOnboarding).not.toHaveBeenCalled();
expect(onFinish).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledTimes(2));
expect(setMemoryMode).toHaveBeenLastCalledWith("lite");
expect(completeOnboarding).toHaveBeenCalledOnce();
expect(onFinish).toHaveBeenCalledWith("sources");
});
it("serializes an in-flight Pro PUT before a later Lite choice so Lite wins", async () => {
let resolvePro!: (value: { mode: "pro" }) => void;
setMemoryMode
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolvePro = resolve;
}),
)
.mockResolvedValueOnce({ mode: "lite" });
const user = userEvent.setup();
const finish = vi.fn();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={finish}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledWith("pro"));
await user.click(screen.getByRole("button", { name: "返回导入" }));
await user.click(screen.getByRole("radio", { name: /lite/i }));
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
expect(setMemoryMode).toHaveBeenCalledTimes(1);
await act(async () => resolvePro({ mode: "pro" }));
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledTimes(2));
expect(setMemoryMode.mock.calls.map(([mode]) => mode)).toEqual([
"pro",
"lite",
]);
await waitFor(() => expect(finish).toHaveBeenCalledWith("sources"));
expect(completeOnboarding).toHaveBeenCalledOnce();
});
it("drops an in-flight Pro response after returning to mode without confirming or completing", async () => {
let resolvePro!: (value: { mode: "pro" }) => void;
setMemoryMode.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolvePro = resolve;
}),
);
const user = userEvent.setup();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={vi.fn()}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
const revisionBeforePut = confirmedMemoryModeSnapshot().revision;
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledWith("pro"));
await user.click(screen.getByRole("button", { name: "返回导入" }));
await act(async () => resolvePro({ mode: "pro" }));
await waitFor(() =>
expect(
screen.getByRole("heading", { name: "选择记忆模式" }),
).toBeTruthy(),
);
expect(confirmedMemoryModeSnapshot().revision).toBe(revisionBeforePut);
expect(completeOnboarding).not.toHaveBeenCalled();
});
it("serializes model and back progress writes so setup mode wins", async () => {
let resolveModelProgress!: (value: OnboardingState) => void;
putOnboardingProgress
.mockResolvedValueOnce({
...onboarding,
mode_intent: "pro",
setup_step: "model",
})
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveModelProgress = resolve;
}),
)
.mockResolvedValueOnce({
...onboarding,
mode_intent: "pro",
setup_step: "mode",
});
const user = userEvent.setup();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={vi.fn()}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
await waitFor(() => expect(putOnboardingProgress).toHaveBeenCalledTimes(2));
await user.click(screen.getByRole("button", { name: "返回导入" }));
expect(putOnboardingProgress).toHaveBeenCalledTimes(2);
await act(async () =>
resolveModelProgress({
...onboarding,
mode_intent: "pro",
setup_step: "model",
}),
);
await waitFor(() => expect(putOnboardingProgress).toHaveBeenCalledTimes(3));
expect(putOnboardingProgress).toHaveBeenLastCalledWith({
mode_intent: "pro",
setup_step: "mode",
model_profile_id: "9be6d465-4cf2-4c72-93f7-15f3c012bc80",
});
expect(completeOnboarding).not.toHaveBeenCalled();
});
it("moves focus to the mode title on entry and after returning from model", async () => {
const user = userEvent.setup();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={vi.fn()}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
const firstTitle = screen.getByRole("heading", { name: "选择记忆模式" });
await waitFor(() => expect(document.activeElement).toBe(firstTitle));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(screen.getByRole("button", { name: "返回导入" }));
const returnedTitle = screen.getByRole("heading", {
name: "选择记忆模式",
});
await waitFor(() => expect(document.activeElement).toBe(returnedTitle));
});
it("treats completed progress conflicts as terminal instead of retryable failures", async () => {
putOnboardingProgress.mockRejectedValueOnce(
Object.assign(new Error("completed"), {
response: {
status: 409,
data: { detail: "onboarding_completed" },
},
}),
);
const user = userEvent.setup();
const finish = vi.fn();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={finish}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await waitFor(() => expect(finish).toHaveBeenCalledWith("sources"));
expect(screen.queryByRole("alert")).toBeNull();
expect(setMemoryMode).not.toHaveBeenCalled();
});
it("persists a late profile receipt after leaving model but fences late activation", async () => {
const user = userEvent.setup();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={vi.fn()}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
const lateReceipt = screen.getByRole("button", {
name: "模型启用回执已确认",
});
await user.click(screen.getByRole("button", { name: "返回导入" }));
fireEvent.click(lateReceipt);
await waitFor(() =>
expect(patchOnboardingProgress).toHaveBeenCalledWith({
model_profile_id: "9be6d465-4cf2-4c72-93f7-15f3c012bc80",
}),
);
expect(setMemoryMode).not.toHaveBeenCalled();
expect(completeOnboarding).not.toHaveBeenCalled();
});
it("restores a persisted Pro model step directly after restart", async () => {
render(
<OnboardingFlow
state={{
...onboarding,
mode_intent: "pro",
setup_step: "model",
model_profile_id: "9be6d465-4cf2-4c72-93f7-15f3c012bc80",
}}
imports={imports({ sources: [] })}
onFinish={vi.fn()}
/>,
);
expect(screen.getByRole("region", { name: "模型步骤边界" })).toBeTruthy();
expect(screen.queryByRole("heading", { name: "选择记忆模式" })).toBeNull();
});
it("keeps the Pro choice retryable when progress persistence fails", async () => {
putOnboardingProgress.mockRejectedValueOnce(new Error("offline"));
const user = userEvent.setup();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={vi.fn()}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
const pro = screen.getByRole<HTMLInputElement>("radio", { name: /pro/i });
await user.click(pro);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
expect((await screen.findByRole("alert")).textContent).toContain(
"模式未能保存",
);
expect(pro.checked).toBe(true);
expect(screen.queryByRole("region", { name: "模型步骤边界" })).toBeNull();
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
expect(
await screen.findByRole("region", { name: "模型步骤边界" }),
).toBeTruthy();
});
it("returns from skipped pro setup to an explicit mode choice", async () => {
const user = userEvent.setup();
const controller = imports();
const onFinish = vi.fn();
@@ -698,10 +1138,13 @@ describe("OnboardingFlow S1.3", () => {
await choose(user);
await approve(user);
await user.click(screen.getByRole("button", { name: START }));
await user.click(
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
);
await openModelStep(user);
await user.click(screen.getByRole("button", { name: "跳过模型进入应用" }));
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
expect(completeOnboarding).not.toHaveBeenCalled();
expect(onFinish).not.toHaveBeenCalled();
await user.click(screen.getByRole("radio", { name: /lite/i }));
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
expect(completeOnboarding).toHaveBeenCalledOnce();
expect(onFinish).toHaveBeenCalledWith("sources");
expect(getModelProfiles).not.toHaveBeenCalled();
@@ -721,6 +1164,7 @@ describe("OnboardingFlow S1.3", () => {
);
await choose(user);
await user.click(screen.getByRole("button", { name: SKIP }));
await chooseLiteAndContinue(user);
expect(onFinish).toHaveBeenCalledWith("sources");
for (const fn of [
controller.connectForOnboarding,
@@ -775,6 +1219,7 @@ describe("OnboardingFlow S1.3", () => {
expect(choice()).toHaveProperty("disabled", false);
await waitFor(() => expect(choice()).toHaveProperty("checked", true));
await user.click(screen.getByRole("button", { name: SKIP }));
await chooseLiteAndContinue(user);
expect(onFinish).toHaveBeenCalledWith("sources");
expect(controller.connectForOnboarding).not.toHaveBeenCalled();
});
@@ -798,6 +1243,7 @@ describe("OnboardingFlow S1.3", () => {
expect(controller.connectForOnboarding).not.toHaveBeenCalled();
expect(onFinish).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: SKIP }));
await chooseLiteAndContinue(user);
expect(onFinish).toHaveBeenCalledWith("sources");
});
@@ -1651,7 +2097,7 @@ describe("OnboardingFlow S1.3", () => {
});
await openApps();
await user.click(screen.getByRole("button", { name: START }));
await screen.findByRole("button", { name: "配置模型,沉淀记忆" });
await screen.findByRole("button", { name: "选择记忆模式" });
await continueImport(user);
expect(onFinish).toHaveBeenCalledWith("sources");
expect(api.connectSources).toHaveBeenCalledOnce();
@@ -1868,6 +2314,7 @@ describe("OnboardingFlow S1.3", () => {
await user.click(screen.getByRole("button", { name: START }));
expect(onFinish).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "查看未导入的应用" }));
await chooseLiteAndContinue(user);
expect(onFinish).toHaveBeenCalledWith("sources");
});
@@ -2042,6 +2489,7 @@ describe("OnboardingFlow S1.3", () => {
/>,
);
await user.click(screen.getByRole("button", { name: SKIP }));
await chooseLiteAndContinue(user);
await user.click(screen.getByRole("button", { name: "仍进入应用页" }));
expect(onFinish).toHaveBeenCalledWith("sources");
expect(controller.connectForOnboarding).not.toHaveBeenCalled();
@@ -2143,9 +2591,7 @@ describe("OnboardingFlow S1.3", () => {
expect(readConnectionPreferences().hostSourceIds).toContain("claude");
expect(controller.setSync).not.toHaveBeenCalled();
expect(previewRecallInstall).not.toHaveBeenCalled();
await user.click(
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
);
await openModelStep(user);
expect(screen.getByRole("region", { name: "模型步骤边界" })).toBeTruthy();
expect(controller.setSync).not.toHaveBeenCalled();
expect(previewRecallInstall).toHaveBeenCalledWith("claude-code");
@@ -2192,9 +2638,7 @@ describe("OnboardingFlow S1.3", () => {
await user.click(insertion);
expect(insertion.checked).toBe(false);
expect(readConnectionPreferences().hostSourceIds).toEqual([]);
await user.click(
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
);
await openModelStep(user);
expect(controller.setSync).not.toHaveBeenCalled();
expect(previewRecallInstall).not.toHaveBeenCalled();
});
@@ -2240,9 +2684,7 @@ describe("OnboardingFlow S1.3", () => {
});
expect(host.disabled).toBe(true);
expect(host.checked).toBe(false);
await user.click(
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
);
await user.click(screen.getByRole("button", { name: "选择记忆模式" }));
expect(controller.setSync).not.toHaveBeenCalled();
expect(previewRecallInstall).not.toHaveBeenCalled();
});
@@ -2355,9 +2797,7 @@ describe("OnboardingFlow S1.3", () => {
await waitFor(() => expect(insertion.disabled).toBe(false));
expect(previewRecallInstall).not.toHaveBeenCalled();
expect(insertion.checked).toBe(true);
await user.click(
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
);
await openModelStep(user);
expect(screen.getByRole("region", { name: "模型步骤边界" })).toBeTruthy();
expect(previewRecallInstall).toHaveBeenCalledWith("claude-code");
expect(window.oneMemory?.installRecall).toHaveBeenCalledTimes(1);
+472 -14
View File
@@ -15,8 +15,20 @@ import {
useState,
} from "react";
import { completeOnboarding } from "./api";
import {
apiErrorCode,
completeOnboarding,
getMemoryMode,
patchOnboardingProgress,
planMemoryMode,
putOnboardingProgress,
setMemoryMode,
} from "./api";
import { AppMark } from "./app-mark";
import {
commitConfirmedMemoryMode,
issueConfirmedModeTicket,
} from "./confirmed-memory-mode";
import { saveConnectionPreferences } from "./connection-preferences";
import {
defaultHostSourceIds,
@@ -28,6 +40,7 @@ import {
hostCapabilities,
hostWriteReason,
} from "./host-state";
import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
import { OnboardingModelController } from "./OnboardingModelController";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6ColdStartFrame } from "./R6ColdStartFrame";
@@ -38,6 +51,7 @@ import { isDefiniteRefusal } from "./write-outcome";
import "./cold-start.css";
export type OnboardingDestination = "memory" | "sources" | "settings";
type OnboardingMemoryMode = "lite" | "pro";
// Cold start neither reads nor writes installation intent.
export const PLUGIN_CONSENT_KEY = "one-memory.consent.plugins";
@@ -46,6 +60,28 @@ const SCOPE_CHANGED =
"来源位置、身份或状态已变化,请重新选择并核对。尚未提交本次授权。";
type LocationCheck = "idle" | "checking" | "ready" | "failed";
function isCompletedProgressConflict(reason: unknown): boolean {
if (apiErrorCode(reason) === "onboarding_completed") return true;
if (!reason || typeof reason !== "object" || !("response" in reason))
return false;
const response = (
reason as {
response?: { status?: unknown; data?: { detail?: unknown } };
}
).response;
return (
response?.status === 409 && response.data?.detail === "onboarding_completed"
);
}
function isGenerationInvalid(reason: unknown): boolean {
return (
reason instanceof Error &&
(reason.message === "onboarding_generation_invalid" ||
reason.message === "onboarding_progress_generation_invalid")
);
}
function defaultConnectionDraft(
sources: SourceResponse[],
sourceIds: readonly string[],
@@ -88,8 +124,37 @@ export function OnboardingFlow({
const [startingImport, setStartingImport] = useState<SourceResponse[] | null>(
null,
);
const [modelStep, setModelStep] = useState(false);
const [visitedModel, setVisitedModel] = useState(false);
const resumedStep =
state.mode_intent === "pro" && state.setup_step === "model"
? "model"
: state.mode_intent
? "mode"
: "review";
const [setupStep, setSetupStep] = useState<"review" | "mode" | "model">(
resumedStep,
);
const [modeChoice, setModeChoice] = useState<OnboardingMemoryMode | null>(
state.mode_intent,
);
const [modeDestination, setModeDestination] =
useState<OnboardingDestination>("sources");
const [modeSaving, setModeSaving] = useState(false);
const [modeError, setModeError] = useState<string | null>(null);
const [modeFocusToken, setModeFocusToken] = useState(
resumedStep === "mode" ? 1 : 0,
);
const modeChoiceTouched = useRef(state.mode_intent !== null);
const [visitedSetup, setVisitedSetup] = useState(resumedStep !== "review");
const [visitedModel, setVisitedModel] = useState(resumedStep === "model");
const [modelGenerationToken, setModelGenerationToken] = useState(
resumedStep === "model" ? 1 : 0,
);
const modelGeneration = useRef(resumedStep === "model" ? 1 : 0);
const intentRef = useRef<OnboardingMemoryMode | null>(state.mode_intent);
const setupStepRef = useRef<"review" | "mode" | "model">(resumedStep);
const modelProfileId = useRef<string | null>(state.model_profile_id);
const modeWriteQueue = useRef<Promise<void>>(Promise.resolve());
const progressWriteQueue = useRef<Promise<void>>(Promise.resolve());
const [modelSourceIds, setModelSourceIds] = useState<string[]>([]);
const [connectionDraft, setConnectionDraft] = useState<ImportConnectionDraft>(
{ hostSourceIds: [] },
@@ -135,7 +200,7 @@ export function OnboardingFlow({
const consideredSources = useRef(new Set<string>());
const heading = useRef<HTMLHeadingElement>(null);
const reviewingImport =
visitedModel ||
(visitedSetup && !completionFailed) ||
accepted !== null ||
startingImport !== null ||
state.status === "importing";
@@ -307,6 +372,23 @@ export function OnboardingFlow({
useEffect(() => {
alive.current = true;
const modeTicket = issueConfirmedModeTicket("onboarding");
void getMemoryMode().then(
(memoryMode) => {
if (alive.current)
commitConfirmedMemoryMode(memoryMode.mode, modeTicket);
if (
alive.current &&
!modeChoiceTouched.current &&
memoryMode.explicitly_configured &&
memoryMode.configured_mode !== "ultra"
)
setModeChoice(memoryMode.configured_mode);
},
() => {
// The choice screen remains usable; saving rechecks Core.
},
);
return () => {
alive.current = false;
};
@@ -401,6 +483,93 @@ export function OnboardingFlow({
[onFinish],
);
const enterModelStep = useCallback(() => {
intentRef.current = "pro";
setupStepRef.current = "model";
const token = ++modelGeneration.current;
setModelGenerationToken(token);
setVisitedModel(true);
setSetupStep("model");
}, []);
const leaveModelStep = useCallback(
(nextIntent: OnboardingMemoryMode, nextStep: "review" | "mode") => {
// This fence is synchronous: hidden model work must observe invalidation
// before React renders the replacement step.
modelGeneration.current += 1;
intentRef.current = nextIntent;
setupStepRef.current = nextStep;
setModeChoice(nextIntent);
setSetupStep(nextStep);
if (nextStep === "mode") setModeFocusToken((value) => value + 1);
setModeSaving(false);
},
[],
);
const queueModeWrite = useCallback(
(mode: OnboardingMemoryMode, allowed: () => boolean = () => true) => {
let resolveResult!: (
value: Awaited<ReturnType<typeof setMemoryMode>>,
) => void;
let rejectResult!: (reason?: unknown) => void;
const result = new Promise<Awaited<ReturnType<typeof setMemoryMode>>>(
(resolve, reject) => {
resolveResult = resolve;
rejectResult = reject;
},
);
modeWriteQueue.current = modeWriteQueue.current
.catch(() => undefined)
.then(async () => {
if (!allowed()) {
rejectResult(new Error("onboarding_generation_invalid"));
return;
}
try {
resolveResult(await setMemoryMode(mode));
} catch (reason) {
rejectResult(reason);
}
});
return result;
},
[],
);
const queueProgressWrite = useCallback(
(
operation: () => ReturnType<typeof putOnboardingProgress>,
allowed: () => boolean = () => true,
) => {
let resolveResult!: (
value: Awaited<ReturnType<typeof putOnboardingProgress>>,
) => void;
let rejectResult!: (reason?: unknown) => void;
const result = new Promise<
Awaited<ReturnType<typeof putOnboardingProgress>>
>((resolve, reject) => {
resolveResult = resolve;
rejectResult = reject;
});
progressWriteQueue.current = progressWriteQueue.current
.catch(() => undefined)
.then(async () => {
if (!allowed()) {
rejectResult(new Error("onboarding_progress_generation_invalid"));
return;
}
try {
resolveResult(await operation());
} catch (reason) {
rejectResult(reason);
}
});
return result;
},
[],
);
const persistFinish = useCallback(
async (destination: OnboardingDestination) => {
setExitDestination(destination);
@@ -420,8 +589,10 @@ export function OnboardingFlow({
: "onboarding_complete_result_unknown",
});
if (alive.current) {
modelGeneration.current += 1;
setupStepRef.current = "review";
setCompletionFailed(true);
setModelStep(false);
setSetupStep("review");
// A definite refusal means it was not saved; anything else (timeout,
// 5xx, no response) leaves the outcome unknown, and the copy must not
// assert a failure the UI cannot prove.
@@ -450,6 +621,163 @@ export function OnboardingFlow({
[persistFinish],
);
const requestModeChoice = useCallback(
(destination: OnboardingDestination) => {
if (finished.current) return;
if (setupStepRef.current === "model") leaveModelStep("pro", "mode");
else {
intentRef.current = modeChoice;
setupStepRef.current = "mode";
setSetupStep("mode");
setModeFocusToken((value) => value + 1);
}
setModeDestination(destination === "settings" ? "sources" : destination);
setModeError(null);
setError(null);
setVisitedSetup(true);
},
[leaveModelStep, modeChoice],
);
const saveModeChoice = useCallback(async () => {
if (
!modeChoice ||
modeSaving ||
finished.current ||
(modeChoice === "lite" && flight.current)
)
return;
setModeSaving(true);
setModeError(null);
const modeTicket = issueConfirmedModeTicket("onboarding");
try {
if (modeChoice === "pro") {
intentRef.current = "pro";
setupStepRef.current = "mode";
const selectionStillCurrent = () =>
alive.current &&
!finished.current &&
intentRef.current === "pro" &&
setupStepRef.current === "mode";
const savedProgress = await queueProgressWrite(
() =>
putOnboardingProgress({
mode_intent: "pro",
setup_step: "model",
model_profile_id: modelProfileId.current,
}),
selectionStillCurrent,
);
if (!selectionStillCurrent()) return;
modelProfileId.current = savedProgress.model_profile_id;
enterModelStep();
return;
}
leaveModelStep("lite", "mode");
const liteStillCurrent = () =>
alive.current &&
!finished.current &&
intentRef.current === "lite" &&
setupStepRef.current === "mode";
await queueProgressWrite(
() =>
putOnboardingProgress({
mode_intent: "lite",
setup_step: "mode",
model_profile_id: null,
}),
liteStillCurrent,
);
if (!alive.current) return;
const changed = await queueModeWrite("lite");
if (!alive.current) return;
commitConfirmedMemoryMode(changed.mode, modeTicket);
await leave(modeDestination);
} catch (reason) {
if (isCompletedProgressConflict(reason)) {
finishOnce(modeDestination);
} else if (alive.current) {
setModeError("模式未能保存。你的选择已保留,请重试。");
}
} finally {
if (alive.current) setModeSaving(false);
}
}, [
enterModelStep,
leave,
leaveModelStep,
modeChoice,
modeDestination,
modeSaving,
finishOnce,
queueModeWrite,
queueProgressWrite,
]);
const persistModelProfile = useCallback(
async (profileId: string) => {
modelProfileId.current = profileId;
try {
await queueProgressWrite(() =>
patchOnboardingProgress({ model_profile_id: profileId }),
);
} catch (reason) {
if (!isCompletedProgressConflict(reason) && alive.current)
setModeError("模型配置已保存,但恢复进度未能保存。请重试当前步骤。");
}
},
[queueProgressWrite],
);
const confirmProMode = useCallback(
async (token: number, ownerProfileId: string) => {
const ownsModelStep = () =>
alive.current &&
!finished.current &&
modelGeneration.current === token &&
intentRef.current === "pro" &&
setupStepRef.current === "model" &&
modelProfileId.current === ownerProfileId;
if (!ownsModelStep()) return;
setModeSaving(true);
setModeError(null);
const modeTicket = issueConfirmedModeTicket("onboarding");
try {
const plan = await planMemoryMode("pro");
if (!ownsModelStep()) return;
if (plan.blockers.length > 0) throw new Error(plan.blockers.join(","));
const changed = await queueModeWrite("pro", ownsModelStep);
if (!ownsModelStep()) return;
commitConfirmedMemoryMode(changed.mode, modeTicket);
await queueProgressWrite(
() =>
putOnboardingProgress({
mode_intent: "pro",
setup_step: "model",
model_profile_id: ownerProfileId,
}),
ownsModelStep,
);
if (!ownsModelStep()) return;
await persistFinish(modeDestination);
} catch (reason) {
if (isCompletedProgressConflict(reason)) {
finishOnce(modeDestination);
} else if (alive.current && !isGenerationInvalid(reason))
setModeError("Pro 模式未能确认保存。模型配置已保留,请重试。");
} finally {
if (alive.current) setModeSaving(false);
}
},
[
finishOnce,
modeDestination,
persistFinish,
queueModeWrite,
queueProgressWrite,
],
);
async function checkLocations() {
if (flight.current || locked) return;
flight.current = true;
@@ -636,7 +964,7 @@ export function OnboardingFlow({
return (
<>
<div
hidden={modelStep}
hidden={setupStep !== "review"}
data-import-submission={startingImport ? "pending" : "settled"}
>
<FirstImportReview
@@ -663,6 +991,10 @@ export function OnboardingFlow({
error={error && text(error)}
onContinue={(destination) => {
if (busy === "leaving") return;
if (completionFailed) {
void leave(destination);
return;
}
const selection = {
hostSourceIds: hostConnectionAvailable
? [...activeDraft.hostSourceIds]
@@ -676,9 +1008,8 @@ export function OnboardingFlow({
setApplyTick((value) => value + 1);
if (destination === "settings") {
setModelSourceIds([...sourceIds]);
setVisitedModel(true);
setModelStep(true);
} else void leave(destination);
}
requestModeChoice(destination);
}}
recovery={
completionFailed && exitDestination
@@ -690,14 +1021,129 @@ export function OnboardingFlow({
}
/>
</div>
{setupStep === "mode" && (
<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>
</>
}
>
<section
className="onboarding-r6-mode"
aria-labelledby="onboarding-memory-mode-title"
>
<div className="onboarding-r6-mode-intro">
<p id="onboarding-memory-mode-title">
{text("之后可在设置中更改。")}
</p>
</div>
<MemoryModeChoiceList
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("推荐") }}
onChange={(mode) => {
modeChoiceTouched.current = true;
setModeChoice(mode as OnboardingMemoryMode);
setModeError(null);
}}
/>
<p className="onboarding-r6-mode-ultra-note">
{text("Ultra 可在完成首次设置后到设置页开启。")}
</p>
{(modeError || error) && (
<p className="onboarding-r6-mode-error" role="alert">
{text(modeError ?? error ?? "")}
</p>
)}
</section>
</R6ColdStartFrame>
)}
{visitedModel && (
<div hidden={!modelStep}>
<div hidden={setupStep !== "model"}>
<OnboardingModelController
generationToken={modelGenerationToken}
isGenerationCurrent={(token) =>
modelGeneration.current === token &&
intentRef.current === "pro" &&
setupStepRef.current === "model"
}
initialProfileId={modelProfileId.current}
flowError={modeError}
connectionAlreadyApplied={
connectionApplication === "applied" ||
connectionApplication === "partial"
}
onEnabled={() => void leave("sources")}
onProfilePersisted={(profileId) =>
void persistModelProfile(profileId)
}
onModelActiveConfirmed={(token, ownerProfileId) =>
void confirmProMode(token, ownerProfileId)
}
sourceNames={sourceIds.map(
(id) =>
imports.sources.find((source) => source.source_id === id)
@@ -709,8 +1155,20 @@ export function OnboardingFlow({
(total, source) => total + source.imported_conversations,
0,
)}
onBack={() => setModelStep(false)}
onSkip={() => void leave("sources")}
onBack={() => {
setModeError(null);
leaveModelStep("pro", "mode");
void queueProgressWrite(() =>
putOnboardingProgress({
mode_intent: "pro",
setup_step: "mode",
model_profile_id: modelProfileId.current,
}),
).catch((reason) => {
if (!isCompletedProgressConflict(reason) && alive.current)
setModeError("恢复进度未能保存。你的选择已保留,请重试。");
});
}}
/>
</div>
)}
@@ -763,7 +1221,7 @@ export function OnboardingFlow({
<R6Button
variant="quiet"
disabled={locked}
onClick={() => void leave("sources")}
onClick={() => requestModeChoice("sources")}
>
{text("稍后添加,先进入")}
</R6Button>
@@ -0,0 +1,417 @@
// @vitest-environment jsdom
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { OnboardingModelController } from "./OnboardingModelController";
const { embedding, getModelProfiles, models, step } = vi.hoisted(() => ({
models: {
profiles: [
{
profile_id: "active-profile",
revision: 3,
name: "Current model",
provider_kind: "openai_compatible",
model: "model-1",
base_url: "https://models.example/v1",
disable_thinking: true,
embedding_provider_kind: "local_onnx",
embedding_model: "bge-small-zh-v1.5",
embedding_base_url: null,
timeout_seconds: 90,
max_concurrency: 4,
context_window: null,
has_api_key: true,
is_active: true,
},
],
pendingProbe: null as null | {
operationId: string;
profileId: string;
inputRevision: number;
},
probeStorageFailed: false,
pendingCreation: false,
creationMissing: null,
creationStorageFailed: false,
refreshing: false,
error: null,
create: vi.fn(),
update: vi.fn(),
probeProfile: vi.fn(),
activate: vi.fn(),
reconcileCreation: vi.fn(),
discardMissingCreation: vi.fn(),
},
getModelProfiles: vi.fn(),
embedding: {
current: null as null | {
writes_paused: boolean;
last_finished?: { job_id: string; state: string };
},
plan: null,
busy: null,
errorCode: null,
refreshFailed: false,
supported: false as boolean | null,
planActivation: vi.fn(),
startPlannedSwitch: vi.fn(),
shouldUseLegacyActivation: () => true,
clearPlan: vi.fn(),
clearError: vi.fn(),
},
step: { current: null as Record<string, unknown> | null },
}));
vi.mock("./api", () => ({
getModelProfiles: (...args: unknown[]) => getModelProfiles(...args),
}));
vi.mock("./use-model-profiles", () => ({
useModelProfiles: () => models,
}));
vi.mock("./use-embedding-switch", () => ({
useEmbeddingSwitch: () => embedding,
}));
vi.mock("./OnboardingModelStep", () => ({
OnboardingModelStep: (props: Record<string, unknown>) => {
step.current = props;
const probe = props.probe as { message: string } | null | undefined;
return (
<section>
<p>{probe?.message}</p>
<p>{String(props.error ?? "")}</p>
<button
type="button"
onClick={() => void (props.onSaveAndEnable as () => Promise<void>)()}
>
{String(props.enableActionLabel ?? "")}
</button>
<button
type="button"
disabled={!props.canTest}
onClick={() => void (props.onTest as () => Promise<void>)()}
>
{String(props.testActionLabel ?? "测试模型连通性")}
</button>
{props.onRecoverCreation ? (
<button
type="button"
onClick={() =>
void (props.onRecoverCreation as () => Promise<void>)()
}
>
核对创建结果
</button>
) : null}
</section>
);
},
}));
function currentStep(): Record<string, unknown> {
if (!step.current) throw new Error("onboarding_model_step_not_rendered");
return step.current;
}
const activeProfile = { ...models.profiles[0] };
beforeEach(() => {
models.profiles = [{ ...activeProfile }];
models.pendingCreation = false;
models.pendingProbe = null;
models.creationMissing = null;
embedding.current = null;
embedding.supported = false;
step.current = null;
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("does not claim or auto-complete an arbitrary active profile without an onboarding id", async () => {
const enabled = vi.fn();
render(
<OnboardingModelController
sourceNames={[]}
onBack={vi.fn()}
onModelActiveConfirmed={enabled}
/>,
);
await waitFor(() => expect(step.current).not.toBeNull());
expect(currentStep().profile).toBeNull();
expect(enabled).not.toHaveBeenCalled();
expect(models.activate).not.toHaveBeenCalled();
});
it("resumes and confirms the active profile explicitly owned by onboarding", async () => {
const enabled = vi.fn();
render(
<OnboardingModelController
sourceNames={[]}
initialProfileId="active-profile"
onBack={vi.fn()}
onModelActiveConfirmed={enabled}
/>,
);
expect(
await screen.findByText("当前模型已经启用,可以继续完成首次设置。"),
).toBeTruthy();
await waitFor(() => expect(enabled).toHaveBeenCalledOnce());
expect(enabled).toHaveBeenCalledWith(1, "active-profile");
});
it("hydrates a persisted inactive profile without creating a duplicate", async () => {
models.profiles = [{ ...activeProfile, is_active: false }];
const active = vi.fn();
render(
<OnboardingModelController
sourceNames={[]}
initialProfileId="active-profile"
onBack={vi.fn()}
onModelActiveConfirmed={active}
/>,
);
await waitFor(() =>
expect(
(currentStep().profile as { profile_id: string } | null)?.profile_id,
).toBe("active-profile"),
);
expect(models.create).not.toHaveBeenCalled();
expect(active).not.toHaveBeenCalled();
});
it("recovers a pending creation and persists its profile id", async () => {
models.profiles = [];
models.pendingCreation = true;
models.reconcileCreation.mockResolvedValueOnce(activeProfile);
const persisted = vi.fn();
render(
<OnboardingModelController
sourceNames={[]}
onBack={vi.fn()}
onProfilePersisted={persisted}
/>,
);
await userEvent.click(
await screen.findByRole("button", { name: "核对创建结果" }),
);
await waitFor(() => expect(persisted).toHaveBeenCalledWith("active-profile"));
expect(models.create).not.toHaveBeenCalled();
});
it("reconciles a pending probe instead of saving or creating again", async () => {
const inactive = { ...activeProfile, is_active: false };
models.profiles = [inactive];
models.pendingProbe = {
operationId: "probe-operation",
profileId: "active-profile",
inputRevision: 3,
};
models.probeProfile.mockResolvedValueOnce({
profile_id: "active-profile",
profile_revision: 3,
capabilities: {
endpoint_reachable: true,
model_available: true,
embedding_available: true,
},
stages: [{ ok: true }],
});
getModelProfiles.mockResolvedValueOnce([inactive]);
render(
<OnboardingModelController
sourceNames={[]}
initialProfileId="active-profile"
onBack={vi.fn()}
/>,
);
await userEvent.click(
await screen.findByRole("button", { name: "核对测试结果" }),
);
await waitFor(() =>
expect(models.probeProfile).toHaveBeenCalledWith("active-profile"),
);
expect(models.update).not.toHaveBeenCalled();
expect(models.create).not.toHaveBeenCalled();
});
it("keeps an active resumed profile on the model step while writes are paused", async () => {
embedding.supported = true;
embedding.current = { writes_paused: true };
const active = vi.fn();
render(
<OnboardingModelController
sourceNames={[]}
initialProfileId="active-profile"
onBack={vi.fn()}
onModelActiveConfirmed={active}
/>,
);
expect(
await screen.findByText("模型写入暂时关闭。请等待索引维护完成后重试。"),
).toBeTruthy();
expect(active).not.toHaveBeenCalled();
});
it("does not confirm a late embedding gate opening after generation invalidation", async () => {
embedding.supported = true;
embedding.current = { writes_paused: true };
const active = vi.fn();
let current = true;
const view = render(
<OnboardingModelController
sourceNames={[]}
initialProfileId="active-profile"
generationToken={7}
isGenerationCurrent={() => current}
onBack={vi.fn()}
onModelActiveConfirmed={active}
/>,
);
await screen.findByText("当前模型已经启用,可以继续完成首次设置。");
current = false;
embedding.current = {
writes_paused: false,
last_finished: { job_id: "late-job", state: "done" },
};
view.rerender(
<OnboardingModelController
sourceNames={[]}
initialProfileId="active-profile"
generationToken={7}
isGenerationCurrent={() => current}
onBack={vi.fn()}
onModelActiveConfirmed={active}
/>,
);
await waitFor(() => expect(active).not.toHaveBeenCalled());
});
it("creates a cold-start profile without embedding or Core-owned runtime defaults", async () => {
models.profiles = [];
const created = {
...activeProfile,
profile_id: "created-profile",
is_active: false,
timeout_seconds: 240,
};
models.create.mockResolvedValueOnce(created);
render(<OnboardingModelController sourceNames={[]} onBack={vi.fn()} />);
await act(async () => {
(currentStep().onDraftChange as (patch: Record<string, unknown>) => void)({
api_key: "test-key",
});
});
await act(async () => {
await (currentStep().onSaveDraft as () => Promise<void>)();
});
expect(models.create).toHaveBeenCalledOnce();
const input = models.create.mock.calls[0][0];
expect(input).toMatchObject({
provider_kind: "openai_compatible",
model: "deepseek-flash",
base_url: "https://api.deepseek.com",
api_key: "test-key",
});
for (const key of [
"embedding_provider_kind",
"embedding_model",
"embedding_base_url",
"embedding_dimensions",
"timeout_seconds",
"max_concurrency",
"context_window",
])
expect(input).not.toHaveProperty(key);
});
it("clears inherited runtime display on provider switch and creates with Core defaults", async () => {
models.profiles = [];
models.create.mockResolvedValueOnce({
...activeProfile,
profile_id: "ollama-profile",
provider_kind: "ollama",
base_url: "http://127.0.0.1:11434",
model: "qwen2.5",
timeout_seconds: 240,
is_active: false,
});
render(<OnboardingModelController sourceNames={[]} onBack={vi.fn()} />);
await act(async () => {
(currentStep().onDraftChange as (patch: Record<string, unknown>) => void)({
timeout_seconds: "90",
});
});
await act(async () => {
(currentStep().onDraftChange as (patch: Record<string, unknown>) => void)({
provider_id: "ollama",
});
});
expect(
(currentStep().draft as { timeout_seconds: string }).timeout_seconds,
).toBe("");
await act(async () => {
(currentStep().onDraftChange as (patch: Record<string, unknown>) => void)({
model: "qwen2.5",
});
});
await act(async () => {
await (currentStep().onSaveDraft as () => Promise<void>)();
});
expect(models.create.mock.calls[0][0]).toMatchObject({
provider_kind: "ollama",
base_url: "http://127.0.0.1:11434",
model: "qwen2.5",
});
expect(models.create.mock.calls[0][0]).not.toHaveProperty("timeout_seconds");
});
it("updates generation without overwriting an existing custom embedding", async () => {
const custom = {
...activeProfile,
is_active: false,
embedding_provider_kind: "ollama" as const,
embedding_model: "nomic-embed-text",
embedding_base_url: null,
embedding_dimensions: 768,
};
models.profiles = [custom];
models.update.mockResolvedValueOnce({ ...custom, model: "model-2" });
render(
<OnboardingModelController
sourceNames={[]}
initialProfileId={custom.profile_id}
onBack={vi.fn()}
/>,
);
await waitFor(() =>
expect(
(currentStep().profile as { profile_id: string } | null)?.profile_id,
).toBe(custom.profile_id),
);
await act(async () => {
(currentStep().onDraftChange as (patch: Record<string, unknown>) => void)({
model: "model-2",
});
});
await act(async () => {
await (currentStep().onSaveDraft as () => Promise<void>)();
});
expect(models.update).toHaveBeenCalledWith(custom.profile_id, {
expected_revision: custom.revision,
model: "model-2",
});
});
+426 -152
View File
@@ -2,9 +2,15 @@ import type {
ModelProbeResult,
ModelProfile,
ModelProfileInput,
ModelProfilePatch,
} from "@one-memory/contracts";
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { getModelProfiles } from "./api";
import { EmbeddingSwitchConfirmDialog } from "./EmbeddingSwitchExperience";
import {
presetForGeneration,
validateGenerationModel,
} from "./GenerationModelPrimitives";
import {
type OnboardingModelDraft,
OnboardingModelStep,
@@ -12,6 +18,7 @@ import {
import { useOnboardingTranslation } from "./onboarding-language";
import { PROVIDER_PRESETS } from "./provider-presets";
import { traceUiAction } from "./ui-trace";
import { useEmbeddingSwitch } from "./use-embedding-switch";
import { useModelProfiles } from "./use-model-profiles";
const defaultPreset = PROVIDER_PRESETS.find(
@@ -29,8 +36,8 @@ const initial: OnboardingModelDraft = {
embedding_provider_kind: "openai_compatible",
embedding_model: "",
embedding_base_url: "",
timeout_seconds: "90",
max_concurrency: "4",
timeout_seconds: "",
max_concurrency: "",
context_window: "",
};
@@ -55,19 +62,28 @@ export function OnboardingModelController({
sourceNames,
importedConversationCount = 0,
onBack,
onSkip,
onEnabled,
onProfilePersisted,
onModelActiveConfirmed,
generationToken = 1,
isGenerationCurrent = () => true,
initialProfileId = null,
flowError = null,
connectionAlreadyApplied = false,
}: {
sourceNames: readonly string[];
importedConversationCount?: number;
onBack(): void;
onSkip?(): void;
onEnabled?(): void;
onProfilePersisted?(profileId: string): void;
onModelActiveConfirmed?(token: number, ownerProfileId: string): void;
generationToken?: number;
isGenerationCurrent?(token: number): boolean;
initialProfileId?: string | null;
flowError?: string | null;
connectionAlreadyApplied?: boolean;
}) {
const { text, t } = useOnboardingTranslation();
const models = useModelProfiles();
const embeddingSwitch = useEmbeddingSwitch();
const [draft, setDraft] = useState(initial);
const [advanced, setAdvanced] = useState(false);
const [saved, setSaved] = useState<ModelProfile | null>(null);
@@ -82,10 +98,118 @@ export function OnboardingModelController({
state: "passed" | "failed" | "stale";
message: string;
} | null>(null);
const [switchPendingJobId, setSwitchPendingJobId] = useState<string | null>(
null,
);
const flight = useRef(false);
const draftTouched = useRef(false);
const technicalTouched = useRef(
new Set<
| "timeout_seconds"
| "max_concurrency"
| "context_window"
| "disable_thinking"
>(),
);
const autoConfirmed = useRef<string | null>(null);
useEffect(() => {
const profile = initialProfileId
? (models.profiles ?? []).find(
(candidate) => candidate.profile_id === initialProfileId,
)
: undefined;
if (!profile || saved || draftTouched.current) return;
setSaved(profile);
setDraft((current) => ({
...current,
provider_id: presetForGeneration(profile),
name: profile.name,
provider_kind: profile.provider_kind,
model: profile.model,
base_url: profile.base_url ?? "",
disable_thinking: profile.disable_thinking,
embedding_mode:
profile.embedding_provider_kind === "local_onnx" ? "local" : "custom",
embedding_provider_kind:
profile.embedding_provider_kind === "ollama"
? "ollama"
: "openai_compatible",
embedding_model: profile.embedding_model ?? "",
embedding_base_url: profile.embedding_base_url ?? "",
timeout_seconds: String(profile.timeout_seconds),
max_concurrency: String(profile.max_concurrency),
context_window:
profile.context_window === null ? "" : String(profile.context_window),
}));
if (profile.is_active)
setProbe({
state: "passed",
message: "当前模型已经启用,可以继续完成首次设置。",
});
}, [initialProfileId, models.profiles, saved]);
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);
}, [
embeddingSwitch.current,
embeddingSwitch.supported,
generationToken,
isGenerationCurrent,
onModelActiveConfirmed,
saved,
]);
useEffect(() => {
const finished = embeddingSwitch.current?.last_finished;
if (
!switchPendingJobId ||
!finished ||
finished.job_id !== switchPendingJobId
)
return;
if (finished.state === "done") {
flight.current = false;
setSwitchPendingJobId(null);
setPhase("idle");
if (saved && isGenerationCurrent(generationToken))
onModelActiveConfirmed?.(generationToken, saved.profile_id);
} else if (finished.state === "rolled_back") {
flight.current = false;
setSwitchPendingJobId(null);
setPhase("idle");
setError("索引更新没有完成,仍在使用原来的搜索模型。请检查连接后重试。");
}
}, [
embeddingSwitch.current?.last_finished,
generationToken,
isGenerationCurrent,
onModelActiveConfirmed,
saved,
switchPendingJobId,
]);
function edit(patch: Partial<OnboardingModelDraft>) {
if (flight.current) return;
draftTouched.current = true;
for (const key of [
"timeout_seconds",
"max_concurrency",
"context_window",
"disable_thinking",
] as const) {
if (key in patch) technicalTouched.current.add(key);
}
if (patch.provider_id && patch.provider_id !== draft.provider_id) {
const preset = PROVIDER_PRESETS.find(
(item) => item.id === patch.provider_id,
@@ -95,9 +219,14 @@ export function OnboardingModelController({
provider_kind: preset?.provider ?? "openai_compatible",
base_url: preset?.baseUrl ?? "",
model: preset?.model ?? "",
name: preset?.label ?? "",
name: preset?.label ?? "自定义兼容服务",
api_key: "",
timeout_seconds: "",
max_concurrency: "",
context_window: "",
disable_thinking: true,
};
technicalTouched.current.clear();
setSaved(null);
setAdvanced(!preset?.model);
}
@@ -108,82 +237,42 @@ export function OnboardingModelController({
}
function input(): ModelProfileInput | null {
const next: Partial<Record<keyof OnboardingModelDraft, string>> = {};
const integer = (
key: "timeout_seconds" | "max_concurrency" | "context_window",
min: number,
max: number,
) => {
const value = Number(draft[key]);
if (
!draft[key].trim() ||
!Number.isInteger(value) ||
value < min ||
value > max
)
next[key] = "onboardingIntegerRange";
return value;
};
const timeout = integer("timeout_seconds", 1, 600);
const concurrency = integer("max_concurrency", 1, 32);
const context = draft.context_window.trim()
? integer("context_window", 1024, 10_000_000)
: null;
if (!draft.name.trim()) next.name = "请填写配置名称。";
if (!draft.model.trim()) next.model = "请填写实际可用的模型 ID。";
try {
const url = new URL(draft.base_url);
if (
!["http:", "https:"].includes(url.protocol) ||
url.username ||
url.password
)
throw new Error();
} catch {
next.base_url = "请填写有效的模型接口地址。";
}
if (
draft.provider_kind !== "ollama" &&
!draft.api_key.trim() &&
!saved?.has_api_key
)
next.api_key = "请填写 API Key。";
if (draft.embedding_mode === "custom" && !draft.embedding_model.trim())
next.embedding_model = "请填写向量模型 ID。";
const next: Partial<Record<keyof OnboardingModelDraft, string>> =
validateGenerationModel(draft, {
apiKey: draft.api_key,
keyRequired:
draft.provider_kind !== "ollama" &&
!(saved?.has_configured_api_key ?? saved?.has_api_key),
});
if (Object.keys(next).length) {
setFields(next);
setAdvanced(true);
if (Object.keys(next).some((key) => key !== "api_key" && key !== "name"))
setAdvanced(true);
return null;
}
return {
const value: ModelProfileInput = {
name: draft.name.trim(),
model: draft.model.trim(),
provider_kind: draft.provider_kind,
base_url: draft.base_url.trim(),
api_key: draft.api_key.trim() || undefined,
timeout_seconds: timeout,
max_concurrency: concurrency,
context_window: context,
disable_thinking: draft.disable_thinking,
embedding_provider_kind:
draft.embedding_mode === "local"
? "local_onnx"
: draft.embedding_provider_kind,
embedding_model:
draft.embedding_mode === "local"
? "bge-small-zh-v1.5"
: draft.embedding_model.trim(),
// Core no longer lets the embedding endpoint follow the generation
// one, so an empty field is written as the endpoint it means.
embedding_base_url:
draft.embedding_mode === "local"
? null
: draft.embedding_base_url.trim() ||
(draft.embedding_provider_kind === "ollama"
? "http://127.0.0.1:11434"
: draft.base_url.trim()),
embedding_dimensions: draft.embedding_mode === "local" ? 512 : null,
};
if (
technicalTouched.current.has("timeout_seconds") &&
draft.timeout_seconds.trim()
)
value.timeout_seconds = Number(draft.timeout_seconds);
if (
technicalTouched.current.has("max_concurrency") &&
draft.max_concurrency.trim()
)
value.max_concurrency = Number(draft.max_concurrency);
if (technicalTouched.current.has("context_window"))
value.context_window = draft.context_window.trim()
? Number(draft.context_window)
: null;
return value;
}
async function save(test: boolean) {
@@ -193,27 +282,66 @@ export function OnboardingModelController({
flight.current = true;
setError(null);
setPhase("saving");
const owner = generationToken;
try {
const { provider_kind: providerKind, ...patch } = value;
const { provider_kind: providerKind } = value;
const patch: ModelProfilePatch = {
expected_revision: saved?.revision ?? 0,
};
if (saved) {
if (value.name !== saved.name) patch.name = value.name;
if (value.model !== saved.model) patch.model = value.model;
if (value.base_url !== saved.base_url) patch.base_url = value.base_url;
if (draft.api_key.trim()) patch.api_key = draft.api_key.trim();
if (
technicalTouched.current.has("disable_thinking") &&
value.disable_thinking !== saved.disable_thinking
)
patch.disable_thinking = value.disable_thinking;
if (
technicalTouched.current.has("timeout_seconds") &&
value.timeout_seconds !== saved.timeout_seconds
)
patch.timeout_seconds = value.timeout_seconds;
if (
technicalTouched.current.has("max_concurrency") &&
value.max_concurrency !== saved.max_concurrency
)
patch.max_concurrency = value.max_concurrency;
if (
technicalTouched.current.has("context_window") &&
value.context_window !== saved.context_window
)
patch.context_window = value.context_window;
}
const profile =
saved && saved.provider_kind === providerKind
? await models.update(saved.profile_id, {
...patch,
expected_revision: saved.revision,
})
? await models.update(saved.profile_id, patch)
: await models.create(value);
if (!profile) {
setError("配置结果尚未确认,请在模型设置中核对后重试。");
return;
}
onProfilePersisted?.(profile.profile_id);
setSaved(profile);
setDraft((current) => ({ ...current, api_key: "" }));
if (!test) return;
technicalTouched.current.clear();
setDraft((current) => ({
...current,
api_key: "",
timeout_seconds: String(profile.timeout_seconds),
max_concurrency: String(profile.max_concurrency),
context_window:
profile.context_window === null ? "" : String(profile.context_window),
disable_thinking: profile.disable_thinking,
}));
if (!test || !isGenerationCurrent(owner)) return;
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,
);
if (!isGenerationCurrent(owner)) return;
const passed = probeMatchesProfile(profile, current, result);
if (current) setSaved(current);
setProbe({
@@ -230,17 +358,84 @@ export function OnboardingModelController({
}
}
async function testModel() {
const pending = models.pendingProbe;
if (!pending) {
await save(true);
return;
}
if (
flight.current ||
!saved ||
pending.profileId !== saved.profile_id ||
!isGenerationCurrent(generationToken)
)
return;
const owner = generationToken;
flight.current = true;
setError(null);
setPhase("testing");
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,
);
if (!isGenerationCurrent(owner)) return;
const passed = probeMatchesProfile(saved, current, result);
if (current) setSaved(current);
setProbe({
state: passed ? "passed" : "failed",
message: passed
? "模型连接测试通过,可以启用。"
: "连接未通过,请核对模型、密钥和接收方。",
});
} catch {
setError("上次模型测试结果仍待核对,请稍后重试。");
} finally {
flight.current = false;
setPhase("idle");
}
}
async function enable() {
if (flight.current || probe?.state !== "passed" || !saved) return;
if (saved.is_active) {
if (
embeddingSwitch.current?.writes_paused ||
(embeddingSwitch.supported !== false && !embeddingSwitch.current)
) {
setError("模型写入暂时关闭。请等待索引维护完成后重试。");
return;
}
if (isGenerationCurrent(generationToken))
onModelActiveConfirmed?.(generationToken, saved.profile_id);
return;
}
const owner = generationToken;
flight.current = true;
setPhase("enabling");
setError(null);
const trace = traceUiAction("memory", "submit");
try {
const plan = embeddingSwitch.shouldUseLegacyActivation()
? null
: await embeddingSwitch.planActivation(
saved.profile_id,
saved.revision,
);
if (!isGenerationCurrent(owner)) return;
if (!plan && !embeddingSwitch.shouldUseLegacyActivation())
throw new Error("embedding_switch_plan_unconfirmed");
if (plan?.needed) {
trace.done("succeeded", { reason: "embedding_switch_confirmation" });
return;
}
const active = await models.activate(saved.profile_id);
if (!isGenerationCurrent(owner)) return;
if (!active?.is_active) throw new Error("model_activation_unconfirmed");
trace.done("succeeded");
onEnabled?.();
onModelActiveConfirmed?.(owner, active.profile_id);
} catch {
trace.done("unknown", { reason: "model_enable_unconfirmed" });
setError("模型未能确认启用,请在设置中核对。");
@@ -251,76 +446,155 @@ export function OnboardingModelController({
}
return (
<OnboardingModelStep
draft={draft}
providers={[
...PROVIDER_PRESETS.map((preset) => ({
id: preset.id,
label: preset.label,
keyRequired: preset.provider !== "ollama",
supported: true,
})),
{
id: "custom",
label: "自定义兼容服务",
keyRequired: true,
supported: true,
},
]}
advancedOpen={advanced}
onAdvancedChange={setAdvanced}
onDraftChange={edit}
phase={phase}
capability={{ saveAndEnable: true }}
canSaveAndEnable={probe?.state === "passed" && !!saved}
canTest={!models.pendingProbe && !models.probeStorageFailed}
canSaveDraft={!models.pendingCreation && !models.creationStorageFailed}
inputsLocked={flight.current}
fieldErrors={Object.fromEntries(
Object.entries(fields).map(([key, value]) => [
key,
value === "onboardingIntegerRange"
? t(
"onboardingIntegerRange",
key === "timeout_seconds"
? { min: 1, max: 600 }
: key === "max_concurrency"
? { min: 1, max: 32 }
: { min: 1024, max: 10_000_000 },
)
: text(value),
]),
)}
error={error ?? (models.error ? text(models.error) : null)}
probe={probe}
profile={
saved
? {
profile_id: saved.profile_id,
revision: saved.revision,
has_configured_api_key:
saved.has_configured_api_key ?? saved.has_api_key,
credential_state: saved.credential_state,
}
: null
}
sourceNames={sourceNames}
importedConversationCount={importedConversationCount}
connectionSummary={
connectionAlreadyApplied ? "所选连接已启用" : "本次未选择应用连接"
}
generationRecipient={draft.base_url || "尚未填写接收方"}
embeddingRecipient={
draft.embedding_mode === "local"
? "本机内置向量模型"
: draft.embedding_base_url || draft.base_url || "尚未填写接收方"
}
onSaveDraft={() => save(false)}
onTest={() => save(true)}
onSaveAndEnable={enable}
onBack={onBack}
onSkip={onSkip}
onActionError={() => setError("操作未确认,草稿已保留。")}
/>
<>
<OnboardingModelStep
draft={draft}
providers={[
...PROVIDER_PRESETS.map((preset) => ({
id: preset.id,
label: preset.label,
keyRequired: preset.provider !== "ollama",
supported: true,
})),
{
id: "custom",
label: "自定义兼容服务",
keyRequired: true,
supported: true,
},
]}
advancedOpen={advanced}
onAdvancedChange={setAdvanced}
onDraftChange={edit}
phase={phase}
capability={{ saveAndEnable: true }}
canSaveAndEnable={probe?.state === "passed" && !!saved}
enableActionLabel={saved?.is_active ? "继续使用当前模型" : undefined}
canTest={
!models.probeStorageFailed &&
(!models.pendingProbe ||
models.pendingProbe.profileId === saved?.profile_id)
}
testActionLabel={models.pendingProbe ? "核对测试结果" : undefined}
canSaveDraft={
!models.pendingCreation &&
!models.creationStorageFailed &&
(!initialProfileId || !!saved)
}
inputsLocked={flight.current}
fieldErrors={Object.fromEntries(
Object.entries(fields).map(([key, value]) => [
key,
value === "onboardingIntegerRange"
? t(
"onboardingIntegerRange",
key === "timeout_seconds"
? { min: 1, max: 600 }
: key === "max_concurrency"
? { min: 1, max: 32 }
: { min: 1024, max: 10_000_000 },
)
: text(value),
]),
)}
error={
(error ? text(error) : null) ??
(flowError ? text(flowError) : null) ??
(embeddingSwitch.current?.writes_paused
? text("模型写入暂时关闭。请等待索引维护完成后重试。")
: null) ??
(initialProfileId && !saved && !models.refreshing
? text("已保存的模型配置暂时无法读取,不会重复创建。请重试刷新。")
: null) ??
(models.error ? text(models.error) : null)
}
probe={probe}
profile={
saved
? {
profile_id: saved.profile_id,
revision: saved.revision,
has_configured_api_key:
saved.has_configured_api_key ?? saved.has_api_key,
credential_state: saved.credential_state,
}
: null
}
sourceNames={sourceNames}
importedConversationCount={importedConversationCount}
connectionSummary={
connectionAlreadyApplied ? "所选连接已启用" : "本次未选择应用连接"
}
generationRecipient={draft.base_url || "尚未填写接收方"}
embeddingRecipient={
draft.embedding_mode === "local"
? "本机内置向量模型"
: draft.embedding_base_url || draft.base_url || "尚未填写接收方"
}
onSaveDraft={() => save(false)}
onTest={testModel}
onSaveAndEnable={enable}
onRecoverCreation={
models.pendingCreation
? async () => {
const profile = await models.reconcileCreation();
if (!profile) return;
onProfilePersisted?.(profile.profile_id);
technicalTouched.current.clear();
setSaved(profile);
setDraft((current) => ({
...current,
provider_id: presetForGeneration(profile),
name: profile.name,
provider_kind: profile.provider_kind,
model: profile.model,
base_url: profile.base_url ?? "",
api_key: "",
disable_thinking: profile.disable_thinking,
timeout_seconds: String(profile.timeout_seconds),
max_concurrency: String(profile.max_concurrency),
context_window:
profile.context_window === null
? ""
: String(profile.context_window),
}));
}
: undefined
}
onDiscardMissingCreation={
models.creationMissing
? async () => {
await models.discardMissingCreation();
}
: undefined
}
onBack={onBack}
focusToken={generationToken}
onActionError={() => setError("操作未确认,草稿已保留。")}
/>
{embeddingSwitch.plan?.needed && saved ? (
<EmbeddingSwitchConfirmDialog
plan={embeddingSwitch.plan}
busy={embeddingSwitch.busy === "start"}
errorCode={embeddingSwitch.errorCode}
onCancel={() => {
embeddingSwitch.clearPlan();
embeddingSwitch.clearError();
}}
onStart={() => {
flight.current = true;
setPhase("enabling");
void embeddingSwitch.startPlannedSwitch().then((job) => {
if (job) {
setSwitchPendingJobId(job.job_id);
return;
}
flight.current = false;
setPhase("idle");
});
}}
/>
) : null}
</>
);
}
+163 -381
View File
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
import { guiMessages, SUPPORTED_LOCALES } from "@one-memory/i18n";
import {
act,
cleanup,
@@ -6,9 +7,8 @@ import {
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import {
OnboardingModelStep,
@@ -20,417 +20,199 @@ afterEach(() => {
localStorage.clear();
});
it.each(["es", "zh-Hans"] as const)(
"keeps %s footer actions, pending locks and permission boundaries",
async (locale) => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
const p = props();
p.onSkip = vi.fn();
let finish!: () => void;
p.onTest = vi.fn(
() =>
new Promise<void>((resolve) => {
finish = resolve;
}),
);
const labels =
locale === "es"
? [
"Omitir por ahora",
"Probar conexión",
"Confirmar y habilitar",
"Probando el modelo…",
]
: ["跳过,先进入应用", "测试模型连通性", "确认启用", "正在测试模型…"];
const draw = (probe?: OnboardingModelStepProps["probe"]) => (
<LanguageProvider>
<OnboardingModelStep
{...p}
capability={{ saveAndEnable: true }}
probe={probe}
advancedOpen
/>
</LanguageProvider>
);
const view = render(draw());
const skip = screen.getByRole("button", { name: labels[0] });
if (locale === "es") {
expect(
document.getElementById(skip.getAttribute("aria-describedby") ?? "")
?.textContent,
).toContain("Aplicaciones");
}
fireEvent.click(skip);
expect(p.onSkip).toHaveBeenCalledOnce();
expect(p.onBack).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: labels[2] })).toHaveProperty(
"disabled",
true,
);
fireEvent.click(screen.getByRole("button", { name: labels[1] }));
expect(screen.getByRole("button", { name: labels[3] })).toHaveProperty(
"disabled",
true,
);
expect(skip).toHaveProperty("disabled", true);
expect(p.onSaveAndEnable).not.toHaveBeenCalled();
await act(async () => finish());
view.rerender(draw({ state: "failed", message: "synthetic failure" }));
expect(screen.getByRole("button", { name: labels[2] })).toHaveProperty(
"disabled",
true,
);
expect(screen.getByLabelText("API Key")).toHaveProperty("value", "secret");
expect(p.onDraftChange).not.toHaveBeenCalled();
view.rerender(draw({ state: "passed", message: "synthetic success" }));
fireEvent.click(screen.getByRole("button", { name: labels[2] }));
await act(async () => {});
expect(p.onSaveAndEnable).toHaveBeenCalledOnce();
expect(p.onSaveDraft).not.toHaveBeenCalled();
expect(
screen.getByText(
locale === "es"
? /La prueba de conexión solo envía/
: /测试连接只发送测试内容/,
),
).toBeTruthy();
},
);
function props(): OnboardingModelStepProps {
return {
draft: {
provider_id: "fixture",
name: "My profile",
provider_id: "deepseek",
name: "DeepSeek",
provider_kind: "openai_compatible",
model: "user-model",
base_url: "https://example.invalid/v1",
api_key: "secret",
model: "deepseek-flash",
base_url: "https://api.deepseek.com",
api_key: "",
disable_thinking: true,
embedding_mode: "local",
embedding_provider_kind: "openai_compatible",
embedding_model: "",
embedding_base_url: "",
timeout_seconds: "240",
max_concurrency: "4",
timeout_seconds: "",
max_concurrency: "",
context_window: "",
},
providers: [
{ id: "fixture", label: "用户服务", keyRequired: true, supported: true },
{
id: "deepseek",
label: "DeepSeek",
keyRequired: true,
supported: true,
},
{
id: "ollama",
label: "Ollama(本机)",
keyRequired: false,
supported: true,
},
],
advancedOpen: false,
onAdvancedChange: vi.fn(),
onDraftChange: vi.fn(),
phase: "idle",
capability: { saveAndEnable: false },
capability: { saveAndEnable: true },
canTest: true,
canSaveDraft: true,
canSaveAndEnable: true,
sourceNames: ["Source A"],
importedConversationCount: 37,
generationRecipient: "用户服务 / 生成",
embeddingRecipient: "本机 / 向量",
canSaveAndEnable: false,
sourceNames: ["Cursor", "Codex"],
importedConversationCount: 12,
generationRecipient: "https://api.deepseek.com",
embeddingRecipient: "本机",
onTest: vi.fn().mockResolvedValue(undefined),
onSaveAndEnable: vi.fn().mockResolvedValue(undefined),
onSaveDraft: vi.fn().mockResolvedValue(undefined),
onBack: vi.fn(),
onActionError: vi.fn(),
};
}
it("renders step3 without engineering-only recipients, with unavailable enable failing closed", () => {
const p = props();
render(<OnboardingModelStep {...p} />);
expect(
screen.getByRole("heading", { name: "选好模型,让记忆开始沉淀" }),
).toBeTruthy();
expect(
screen
.getByRole("navigation", { name: "首次设置进度" })
.querySelector('[aria-current="step"]')?.textContent,
).toContain("沉淀记忆");
fireEvent.click(screen.getByLabelText("模型服务商"));
expect(screen.getByRole("option", { name: "用户服务" })).toBeTruthy();
expect(screen.queryByRole("option", { name: "DeepSeek" })).toBeNull();
expect(screen.queryByText("用户服务 / 生成")).toBeNull();
expect(screen.queryByText("本机 / 向量")).toBeNull();
expect(screen.getByText("Source A")).toBeTruthy();
expect(screen.getAllByText(/当前不能在此启用模型/).length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole("button", { name: "确认启用" }));
expect(p.onSaveAndEnable).not.toHaveBeenCalled();
expect(screen.queryByRole("checkbox", { name: /同意|允许发送/ })).toBeNull();
});
it("keeps a real multi-source summary compact", () => {
const p = props();
render(
<OnboardingModelStep
{...p}
sourceNames={["WorkBuddy", "Cursor", "Codex", "Claude Code", "Pi"]}
/>,
);
expect(
screen.getByText("WorkBuddy · Cursor · Codex 等 5 个应用"),
).toBeTruthy();
expect(screen.queryByText("Pi")).toBeNull();
});
it("guides first-run keychain authorization when a saved key is unreadable", () => {
const p = props();
render(
<OnboardingModelStep
{...p}
profile={{
profile_id: "saved-profile",
revision: 1,
has_configured_api_key: true,
credential_state: "unavailable",
}}
/>,
);
expect(screen.getByText(/需要允许 MemHub 访问钥匙串/)).toBeTruthy();
expect(screen.getByText(/始终允许/)).toBeTruthy();
});
it("draft edits and advanced mode are controlled; return never clears or saves values", () => {
const p = props();
const view = render(<OnboardingModelStep {...p} />);
fireEvent.change(screen.getByLabelText("API Key"), {
target: { value: "new-secret" },
});
expect(p.onDraftChange).toHaveBeenCalledWith({ api_key: "new-secret" });
expect((screen.getByLabelText("API Key") as HTMLInputElement).value).toBe(
"secret",
);
fireEvent.click(screen.getByRole("button", { name: "配置详情与高级设置" }));
expect(p.onAdvancedChange).toHaveBeenCalledWith(true);
view.rerender(<OnboardingModelStep {...p} advancedOpen />);
expect(screen.getByLabelText("API Key")).toHaveProperty("value", "secret");
fireEvent.click(screen.getByRole("button", { name: "返回服务商选择" }));
expect(p.onAdvancedChange).toHaveBeenLastCalledWith(false);
expect(
screen.getByRole("heading", { name: "配置详情,按需要调整" }),
).toBeTruthy();
expect((screen.getByLabelText("超时(秒)") as HTMLInputElement).max).toBe(
"600",
);
expect((screen.getByLabelText("最大并发") as HTMLInputElement).max).toBe(
"32",
);
expect((screen.getByLabelText("上下文窗口") as HTMLInputElement).min).toBe(
"1024",
);
fireEvent.click(screen.getByRole("button", { name: "跳过,先进入应用" }));
expect(p.onBack).toHaveBeenCalledTimes(1);
expect(p.onSaveDraft).not.toHaveBeenCalled();
expect(p.draft.api_key).toBe("secret");
});
it("keeps skip available while an uncertain write is reconciled", () => {
const p = props();
render(
<OnboardingModelStep
{...p}
advancedOpen
inputsLocked
capability={{ saveAndEnable: true }}
enableActionLabel="核对提交状态"
/>,
);
expect((screen.getByLabelText("配置名称") as HTMLInputElement).disabled).toBe(
true,
);
expect(
(
screen.getByRole("button", {
name: "跳过,先进入应用",
}) as HTMLButtonElement
).disabled,
).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "跳过,先进入应用" }));
expect(p.onBack).toHaveBeenCalledOnce();
expect(
(screen.getByRole("button", { name: "核对提交状态" }) as HTMLButtonElement)
.disabled,
).toBe(false);
});
it.each(["testing", "saving", "enabling"] as const)(
"%s locks controlled actions and fields",
(phase) => {
const p = props();
render(<OnboardingModelStep {...p} phase={phase} />);
describe("OnboardingModelStep", () => {
it.each(SUPPORTED_LOCALES)("renders named controls in %s", (locale) => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
const title = guiMessages[locale]["配置记忆整理模型"];
const back = guiMessages[locale]["返回选择模式"];
if (!title || !back) throw new Error(`missing ${locale} model copy`);
render(
<LanguageProvider>
<OnboardingModelStep {...props()} />
</LanguageProvider>,
);
expect(
(screen.getByLabelText("API Key") as HTMLInputElement).disabled,
).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "跳过,先进入应用" }));
expect(p.onBack).not.toHaveBeenCalled();
if (phase === "saving")
expect(screen.queryByRole("status", { name: "模型提交状态" })).toBeNull();
else
expect(screen.getByRole("status", { name: "模型提交状态" })).toBeTruthy();
},
);
it("one in-flight callback across double click, errors stay owner-controlled without exposing thrown secrets", async () => {
const p = props();
let reject!: (e: Error) => void;
p.onSaveAndEnable = vi.fn(
() =>
new Promise<void>((_, r) => {
reject = r;
screen.getByRole("heading", {
name: title,
}),
);
render(
<OnboardingModelStep
{...p}
capability={{ saveAndEnable: true }}
probe={{ state: "passed", message: "测试通过" }}
/>,
);
const button = screen.getByRole("button", { name: "确认启用" });
fireEvent.click(button);
fireEvent.click(button);
expect(p.onSaveAndEnable).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("button", { name: "测试连接" })).toBeNull();
expect(screen.queryByRole("button", { name: "保存草稿" })).toBeNull();
await act(async () => reject(new Error("SECRET_TOKEN")));
expect(p.onActionError).toHaveBeenCalledWith("save-and-enable");
expect(screen.queryByText(/SECRET_TOKEN/)).toBeNull();
await waitFor(() =>
expect((button as HTMLButtonElement).disabled).toBe(false),
);
});
it("renders field errors with links and preserves custom embedding in advanced mode", () => {
const p = props();
const view = render(
<OnboardingModelStep
{...p}
error="配置已变化,请核对。"
fieldErrors={{ api_key: "密钥未通过验证" }}
/>,
);
const key = screen.getByLabelText("API Key");
expect(key.getAttribute("aria-invalid")).toBe("true");
expect(
document.getElementById(key.getAttribute("aria-describedby") ?? "")
?.textContent,
).toContain("密钥未通过验证");
view.rerender(
<OnboardingModelStep
{...p}
advancedOpen
draft={{
...p.draft,
embedding_mode: "custom",
embedding_model: "saved-vector",
embedding_base_url: "http://127.0.0.1:11434",
}}
/>,
);
expect(
(screen.getByLabelText("向量模型名称") as HTMLInputElement).value,
).toBe("saved-vector");
expect(screen.queryByRole("button", { name: "收起高级配置" })).toBeNull();
expect(p.onDraftChange).not.toHaveBeenCalled();
});
).toBeTruthy();
expect(
screen.getByRole("button", {
name: back,
}),
).toBeTruthy();
expect(screen.getByLabelText(/API Key/)).toBeTruthy();
expect(screen.getAllByRole("button").length).toBeGreaterThanOrEqual(4);
});
it("discloses that testing does not send historical conversations", () => {
render(<OnboardingModelStep {...props()} />);
it("keeps step 3 on memory mode in a generation-only column", () => {
render(<OnboardingModelStep {...props()} />);
expect(
screen.getByRole("heading", { name: "配置记忆整理模型" }),
).toBeTruthy();
expect(screen.getByText("智能记忆 Pro · 配置记忆整理")).toBeTruthy();
expect(
screen
.getByRole("navigation", { name: "首次设置进度" })
.querySelector('[aria-current="step"]')?.textContent,
).toContain("记忆模式");
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(screen.getByText(/测试连接只发送测试内容/)).toBeTruthy();
expect(screen.getByText(/已导入 37 条对话/)).toBeTruthy();
expect(screen.getByText(/启用后会使用此模型整理/)).toBeTruthy();
});
it("focuses the page title when entering the model substep", async () => {
render(<OnboardingModelStep {...props()} focusToken={7} />);
const title = screen.getByRole("heading", {
name: "配置记忆整理模型",
});
await waitFor(() => expect(document.activeElement).toBe(title));
});
it("does not expose an extra backlog selector in the first-run model page", () => {
const p = props();
render(<OnboardingModelStep {...p} capability={{ saveAndEnable: true }} />);
fireEvent.click(screen.getByRole("button", { name: "确认启用" }));
expect(p.onSaveAndEnable).not.toHaveBeenCalled();
expect(screen.getByText(/先测试模型连通性/)).toBeTruthy();
expect(screen.queryByLabelText("启用前积压")).toBeNull();
});
it("separates connectivity testing from final enable", async () => {
const p = props();
render(<OnboardingModelStep {...p} capability={{ saveAndEnable: true }} />);
expect(screen.getByRole("button", { name: "测试模型连通性" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "保存草稿" })).toBeNull();
expect(screen.getByRole("button", { name: "确认启用" })).toHaveProperty(
"disabled",
true,
);
fireEvent.click(screen.getByRole("button", { name: "测试模型连通性" }));
await act(async () => {});
expect(p.onTest).toHaveBeenCalledTimes(1);
expect(p.onSaveDraft).not.toHaveBeenCalled();
expect(p.onSaveAndEnable).not.toHaveBeenCalled();
});
it("uses back, test, and enable actions without a skip path", async () => {
const p = props();
render(<OnboardingModelStep {...p} />);
fireEvent.click(screen.getByRole("button", { name: "返回选择模式" }));
fireEvent.click(screen.getByRole("button", { name: "测试连接" }));
await act(async () => {});
expect(p.onBack).toHaveBeenCalledOnce();
expect(p.onTest).toHaveBeenCalledOnce();
expect(screen.queryByRole("button", { name: /跳过/ })).toBeNull();
expect(screen.getByRole("button", { name: "启用并进入" })).toHaveProperty(
"disabled",
true,
);
});
it("requires owner validation as well as policy capability and truthful recipients", () => {
const p = props();
const view = render(
<OnboardingModelStep
{...p}
capability={{ saveAndEnable: true }}
canSaveAndEnable={false}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "确认启用" }));
view.rerender(
<OnboardingModelStep
{...p}
capability={{ saveAndEnable: true }}
generationRecipient=""
/>,
);
fireEvent.click(screen.getByRole("button", { name: "确认启用" }));
expect(p.onSaveAndEnable).not.toHaveBeenCalled();
});
it("renders shared technical generation fields without embedding", () => {
const p = props();
const view = render(<OnboardingModelStep {...p} />);
const mountedModel =
document.querySelector<HTMLInputElement>('[name="model"]');
expect(mountedModel).toBeTruthy();
expect(mountedModel?.closest("[hidden]")).toBeTruthy();
const disclosure = screen.getByRole("button", {
name: "配置详情与高级设置",
});
expect(disclosure.getAttribute("aria-expanded")).toBe("false");
fireEvent.click(disclosure);
expect(p.onAdvancedChange).toHaveBeenCalledWith(true);
view.rerender(<OnboardingModelStep {...p} advancedOpen />);
expect(screen.getByLabelText("协议")).toBeTruthy();
expect(screen.getByLabelText("生成模型名")).toHaveProperty(
"value",
"deepseek-flash",
);
expect(screen.getByLabelText("Base URL")).toBeTruthy();
expect(screen.getByLabelText("超时(秒)")).toHaveProperty("value", "");
expect(screen.getByLabelText("最大并发")).toBeTruthy();
expect(screen.getByLabelText("上下文窗口")).toBeTruthy();
expect(screen.getByLabelText("关闭思考模式")).toBeTruthy();
expect(screen.getByText("留空使用 Core 默认值。")).toBeTruthy();
expect(screen.queryByText(/向量模型/)).toBeNull();
});
it("opens the advanced design state with a route back to provider selection", () => {
const p = props();
const view = render(<OnboardingModelStep {...p} />);
const toggle = screen.getByRole("button", { name: "配置详情与高级设置" });
expect(toggle.getAttribute("aria-expanded")).toBe("false");
expect(
document.getElementById(toggle.getAttribute("aria-controls") ?? ""),
).toBeTruthy();
toggle.focus();
fireEvent.click(toggle);
view.rerender(<OnboardingModelStep {...p} advancedOpen />);
expect(screen.getByRole("button", { name: "返回服务商选择" })).toBeTruthy();
expect(screen.getByLabelText("API Key")).toBeTruthy();
expect(
screen.getByText("预设已填好,只有自定义服务或特殊需求才需要修改。"),
).toBeTruthy();
expect(p.onDraftChange).not.toHaveBeenCalled();
});
it("expands technical details and focuses the first invalid required field", async () => {
const p = props();
p.draft = { ...p.draft, model: "" };
p.fieldErrors = { model: "请填写实际可用的模型 ID。" };
const view = render(<OnboardingModelStep {...p} />);
await waitFor(() => expect(p.onAdvancedChange).toHaveBeenCalledWith(true));
view.rerender(<OnboardingModelStep {...p} advancedOpen />);
await waitFor(() =>
expect(document.activeElement).toBe(screen.getByLabelText("生成模型名")),
);
});
it("uses the failure-state title and omits consent and scope-management UI", () => {
const p = props();
render(
<OnboardingModelStep
{...p}
capability={{ saveAndEnable: true }}
probe={{ state: "failed", message: "synthetic failure" }}
/>,
);
expect(
screen.getByRole("heading", { name: "连接未通过,配置已保留" }),
).toBeTruthy();
expect(screen.getByText(/连接未通过,请检查 API Key/)).toBeTruthy();
expect(screen.getByText("synthetic failure")).toBeTruthy();
expect(screen.queryByText("调整处理范围")).toBeNull();
expect(screen.queryByText(/允许发送少量测试内容/)).toBeNull();
});
it("links field errors and keeps secrets out of callback errors", async () => {
const p = props();
p.fieldErrors = { api_key: "请填写 API Key。" };
p.canSaveAndEnable = true;
p.probe = { state: "passed", message: "模型连接测试通过,可以启用。" };
p.onSaveAndEnable = vi.fn().mockRejectedValue(new Error("SECRET_TOKEN"));
render(<OnboardingModelStep {...p} />);
const key = screen.getByLabelText("API Key");
expect(key.getAttribute("aria-invalid")).toBe("true");
expect(
document.getElementById(key.getAttribute("aria-describedby") ?? "")
?.textContent,
).toContain("请填写 API Key");
const enable = screen.getByRole("button", { name: "启用并进入" });
fireEvent.click(enable);
fireEvent.click(enable);
await act(async () => {});
expect(p.onSaveAndEnable).toHaveBeenCalledOnce();
expect(screen.queryByText(/SECRET_TOKEN/)).toBeNull();
});
it("keeps the first-run model form free of a second consent strip", () => {
const p = props();
render(
<OnboardingModelStep
{...p}
advancedOpen
draft={{ ...p.draft, embedding_mode: "custom" }}
/>,
);
const fields = screen.getByRole("region", { name: "模型参数" });
expect(within(fields).getByLabelText("向量 Base URL")).toBeTruthy();
expect(screen.queryByText(/允许发送少量测试内容/)).toBeNull();
expect(screen.queryByText(/通过后自动启用/)).toBeNull();
expect(screen.queryByText(p.generationRecipient)).toBeNull();
expect(screen.queryByText(p.embeddingRecipient)).toBeNull();
expect(screen.queryByLabelText("启用前积压")).toBeNull();
it("keeps recovery controls while embedding UI remains hidden", async () => {
const p = props();
p.onRecoverCreation = vi.fn().mockResolvedValue(undefined);
p.onDiscardMissingCreation = vi.fn().mockResolvedValue(undefined);
render(<OnboardingModelStep {...p} />);
fireEvent.click(screen.getByRole("button", { name: "核对创建结果" }));
fireEvent.click(
screen.getByRole("button", { name: "放弃旧记录,重新配置" }),
);
await act(async () => {});
expect(p.onRecoverCreation).toHaveBeenCalledOnce();
expect(p.onDiscardMissingCreation).toHaveBeenCalledOnce();
});
});
+6 -615
View File
@@ -1,11 +1,4 @@
import type { ProviderKind } from "@one-memory/contracts";
import { ChevronDown, ChevronUp } from "lucide-react";
import { useEffect, useId, useRef, useState } from "react";
import { OptionMenu } from "./OptionMenu";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6ColdStartFrame } from "./R6ColdStartFrame";
import { R6Button } from "./R6Controls";
import "./onboarding-model-r6.css";
/** Strings preserve partial numeric edits. The owner validates and maps to API payloads. */
export interface OnboardingModelDraft {
@@ -16,6 +9,7 @@ export interface OnboardingModelDraft {
base_url: string;
api_key: string;
disable_thinking: boolean;
/** Retained in controller state only for persisted-profile recovery; cold start never renders or writes it. */
embedding_mode: "local" | "custom";
embedding_provider_kind: "openai_compatible" | "ollama";
embedding_model: string;
@@ -24,13 +18,16 @@ export interface OnboardingModelDraft {
max_concurrency: string;
context_window: string;
}
export interface OnboardingModelProvider {
id: string;
label: string;
keyRequired: boolean;
supported: boolean;
}
type Action = "test" | "save-draft" | "save-and-enable";
export interface OnboardingModelStepProps {
draft: OnboardingModelDraft;
providers: readonly OnboardingModelProvider[];
@@ -41,7 +38,6 @@ export interface OnboardingModelStepProps {
capability: { saveAndEnable: boolean; reason?: string };
canTest: boolean;
canSaveDraft: boolean;
/** Owner validation/current revision gate; absence fails closed. */
canSaveAndEnable?: boolean;
enableActionLabel?: string;
testActionLabel?: string;
@@ -66,613 +62,8 @@ export interface OnboardingModelStepProps {
onRecoverCreation?(): Promise<void>;
onDiscardMissingCreation?(): Promise<void>;
onBack(): void;
onSkip?(): void;
/** Owner must preserve receipts/unknown results and supply a safe display error. */
focusToken?: number;
onActionError?(action: Action): void;
}
export function OnboardingModelStep(props: OnboardingModelStepProps) {
const { text, t, locale } = useOnboardingTranslation();
const { draft, advancedOpen, onDraftChange, fieldErrors = {}, phase } = props;
const id = useId();
// The Chinese label already names the destination; shorter translated labels
// need it as a description for assistive technology.
const skipDestination =
locale === "zh-Hans" ? "" : t("onboardingModelSkipDestination");
const fields = useRef<HTMLElement>(null);
const advancedToggle = useRef<HTMLButtonElement>(null);
const restoreToggleFocus = useRef(false);
const flight = useRef(false);
const alive = useRef(true);
const [pending, setPending] = useState<Action | null>(null);
const [callbackFailed, setCallbackFailed] = useState(false);
useEffect(() => {
alive.current = true;
return () => {
alive.current = false;
};
}, []);
const locked = phase !== "idle" || pending !== null;
const fieldsLocked = locked || props.inputsLocked === true;
// biome-ignore lint/correctness/useExhaustiveDependencies: Restore only after the controlled mode change has rendered its replacement toggle.
useEffect(() => {
if (!restoreToggleFocus.current) return;
restoreToggleFocus.current = false;
if (fields.current) fields.current.scrollTop = 0;
advancedToggle.current?.focus({ preventScroll: true });
}, [advancedOpen]);
function changeAdvanced(open: boolean) {
restoreToggleFocus.current = true;
props.onAdvancedChange(open);
}
const provider = props.providers.find(
(item) => item.id === draft.provider_id,
);
const canEnable =
(props.probe?.state === "passed" ||
props.enableActionLabel === "核对提交状态") &&
props.capability.saveAndEnable &&
props.canSaveAndEnable === true &&
!!props.generationRecipient.trim() &&
!!props.embeddingRecipient.trim() &&
provider?.supported;
const sourceSummary =
props.sourceNames.length <= 3
? props.sourceNames.join(" · ")
: t("onboardingSourcesSummary", {
names: props.sourceNames.slice(0, 3).join(" · "),
count: props.sourceNames.length,
});
async function run(action: Action) {
if (flight.current || locked) return;
if (action === "save-and-enable" && !canEnable) return;
if (action === "test" && (!props.canTest || !provider?.supported)) return;
if (action === "save-draft" && !props.canSaveDraft) return;
flight.current = true;
setPending(action);
setCallbackFailed(false);
try {
await (action === "test"
? props.onTest()
: action === "save-draft"
? props.onSaveDraft()
: props.onSaveAndEnable());
} catch {
if (alive.current) {
setCallbackFailed(true);
props.onActionError?.(action);
}
} finally {
flight.current = false;
if (alive.current) setPending(null);
}
}
function field(
key: keyof OnboardingModelDraft,
label: string,
options: {
type?: string;
min?: number;
max?: number;
maxLength?: number;
required?: boolean;
placeholder?: string;
} = {},
) {
const error = fieldErrors[key];
return (
<label className="onboarding-model-field" htmlFor={`${id}-${key}`}>
<span>{text(label)}</span>
<input
id={`${id}-${key}`}
name={key}
aria-label={text(label)}
type={options.type ?? "text"}
autoComplete="off"
spellCheck={false}
value={String(draft[key])}
disabled={fieldsLocked}
aria-invalid={!!error}
aria-describedby={error ? `${id}-${key}-error` : undefined}
min={options.min}
max={options.max}
maxLength={options.maxLength}
required={options.required}
placeholder={
options.placeholder ? text(options.placeholder) : undefined
}
onChange={(event) => {
if (!flight.current && !fieldsLocked)
onDraftChange({ [key]: event.target.value });
}}
/>
{error && (
<small id={`${id}-${key}-error`} className="onboarding-model-error">
{text(error)}
</small>
)}
</label>
);
}
const displayedPhase =
phase !== "idle"
? phase
: pending === "test"
? "testing"
: pending === "save-draft"
? "saving"
: pending
? "enabling"
: "idle";
const connectionFailed = props.probe?.state === "failed";
const pageTitle = advancedOpen
? "配置详情,按需要调整"
: connectionFailed
? "连接未通过,配置已保留"
: "选好模型,让记忆开始沉淀";
const connectionReceipt = props.connectionSummary?.includes("已启用")
? "连接设置已保存\n成功项保留,不重复执行。"
: props.connectionSummary === "持续同步 · 一键接入"
? "已选择:持续同步 · 一键接入"
: props.connectionSummary;
return (
<div
className="onboarding-model-r6"
data-integration={
props.capability.saveAndEnable
? "capability-provided"
: "not-integrated"
}
>
<R6ColdStartFrame
step={3}
eyebrow={text("沉淀记忆 · 使用你选择的模型")}
title={text(pageTitle)}
footer={
<>
<p className="onboarding-model-footer-note">
{text(
!props.capability.saveAndEnable
? "当前无法启用模型,已填写内容保留。"
: props.probe?.state === "passed"
? "模型连接测试通过,可以启用并开始沉淀记忆。"
: "先测试模型连通性;测试通过后再启用。",
)}
</p>
<div className="onboarding-model-footer-actions">
{skipDestination && (
<span id={`${id}-skip-destination`} hidden>
{skipDestination}
</span>
)}
<R6Button
aria-describedby={
skipDestination ? `${id}-skip-destination` : undefined
}
disabled={locked}
onClick={() => {
if (!flight.current && !locked)
(props.onSkip ?? props.onBack)();
}}
>
{text("跳过,先进入应用")}
</R6Button>
<R6Button
disabled={locked || !props.canTest}
onClick={() => void run("test")}
>
{text(
displayedPhase === "testing"
? "正在测试模型…"
: (props.testActionLabel ?? "测试模型连通性"),
)}
</R6Button>
<R6Button
variant="primary"
disabled={locked || !canEnable}
onClick={() => void run("save-and-enable")}
>
{text(
displayedPhase === "enabling"
? "正在启用…"
: (props.enableActionLabel ?? "确认启用"),
)}
</R6Button>
</div>
</>
}
>
<div className="onboarding-model-layout">
<section
className="onboarding-model-form"
aria-label={text("模型配置")}
data-advanced={advancedOpen}
>
<section
ref={fields}
id={`${id}-fields`}
className="onboarding-model-fields"
aria-label={text("模型参数")}
// biome-ignore lint/a11y/noNoninteractiveTabindex: The parameter pane needs keyboard scrolling.
tabIndex={0}
>
{!advancedOpen ? (
<>
<p className="onboarding-model-intro">
{text("选择服务商,填入 API Key。核对已有配置后再继续。")}
</p>
<label
className="onboarding-model-field onboarding-model-provider"
htmlFor={`${id}-provider`}
>
<span id={`${id}-provider-label`}>
{text("模型服务商")}
</span>
<OptionMenu
id={`${id}-provider`}
labelledBy={`${id}-provider-label`}
value={draft.provider_id}
disabled={fieldsLocked}
options={[
...(!provider
? [{ value: "", label: text("选择服务商") }]
: []),
...props.providers.map((item) => ({
value: item.id,
label: text(item.label),
disabled: !item.supported,
})),
]}
onChange={(value) => {
if (!flight.current)
onDraftChange({ provider_id: value });
}}
/>
</label>
<div className="onboarding-model-key">
{field("api_key", "API Key", {
type: "password",
required:
provider?.keyRequired &&
!props.profile?.has_configured_api_key,
placeholder: props.profile?.has_configured_api_key
? "已安全保存,留空保留"
: "输入服务商提供的 API Key",
})}
</div>
<p
className="onboarding-model-key-note"
data-state={connectionFailed ? "failed" : "idle"}
>
{text(
connectionFailed
? "连接未通过,请检查 API Key、模型与接口地址后重试。配置已保留。"
: props.profile?.credential_state === "unavailable"
? "需要允许 MemHub 访问钥匙串。系统询问时请选择“始终允许”。"
: props.profile?.has_configured_api_key
? "留空保留已保存密钥;填写新密钥后需重新测试。"
: "密钥保存在系统安全存储中,不写入对话或日志。",
)}
</p>
<button
ref={advancedToggle}
type="button"
className="onboarding-model-toggle"
aria-expanded={false}
aria-controls={`${id}-fields`}
disabled={fieldsLocked}
onClick={() => changeAdvanced(true)}
>
{text("配置详情与高级设置")}{" "}
<ChevronDown size={16} aria-hidden="true" />
</button>
<p className="onboarding-model-profile">
{provider ? text(provider.label) : text("服务商待选择")} ·{" "}
{draft.model || text("模型待填写")}
<br />
{text(safeEndpoint(draft.base_url))} ·{" "}
{draft.embedding_mode === "local"
? text("本地向量模型")
: draft.embedding_model || text("向量模型待填写")}{" "}
·{" "}
{text(draft.disable_thinking ? "思考已关闭" : "思考未关闭")}
</p>
</>
) : (
<>
<div className="onboarding-model-advanced-head">
<p>
{text("预设已填好,只有自定义服务或特殊需求才需要修改。")}
</p>
<button
ref={advancedToggle}
type="button"
className="onboarding-model-toggle"
aria-expanded={true}
aria-controls={`${id}-fields`}
disabled={fieldsLocked}
onClick={() => changeAdvanced(false)}
>
{text("返回服务商选择")}{" "}
<ChevronUp size={16} aria-hidden="true" />
</button>
</div>
<div className="onboarding-model-advanced-grid">
{field("name", "配置名称", { maxLength: 128 })}
{field("model", "模型名称", {
required: true,
maxLength: 256,
})}
<div className="onboarding-model-wide">
{field("base_url", "Base URL", {
type: "url",
required: true,
})}
</div>
<div className="onboarding-model-wide onboarding-model-key">
{field("api_key", "API Key", {
type: "password",
required:
provider?.keyRequired &&
!props.profile?.has_configured_api_key,
placeholder: props.profile?.has_configured_api_key
? "已安全保存,留空保留"
: "输入服务商提供的 API Key",
})}
<p
className="onboarding-model-key-note"
data-state={connectionFailed ? "failed" : "idle"}
>
{text(
props.profile?.has_configured_api_key
? "留空保留已保存密钥;填写新密钥后需重新测试。"
: "密钥保存在系统安全存储中,不写入对话或日志。",
)}
</p>
</div>
<label className="onboarding-model-field">
<span>{text("协议")}</span>
<select
aria-label={text("协议")}
value={draft.provider_kind}
disabled={fieldsLocked}
onChange={(e) => {
if (!flight.current)
onDraftChange({
provider_kind: e.target.value as ProviderKind,
});
}}
>
<option value="openai_compatible">
OpenAI-compatible
</option>
<option value="ollama">Ollama</option>
<option value="anthropic" disabled>
{text("Anthropic(暂不支持)")}
</option>
<option value="hy_default" disabled>
{text("HY 托管(当前不可用)")}
</option>
</select>
</label>
<label className="onboarding-model-field">
<span>{text("向量模型")}</span>
<select
aria-label={text("向量模型")}
value={draft.embedding_mode}
disabled={fieldsLocked}
onChange={(e) => {
if (!flight.current)
onDraftChange({
embedding_mode: e.target.value as
| "local"
| "custom",
});
}}
>
<option value="local">
{text("本地 ONNX · 内置")}
</option>
<option value="custom">{text("自定义向量")}</option>
</select>
</label>
<div className="onboarding-model-numbers">
{field("timeout_seconds", "超时(秒)", {
type: "number",
min: 1,
max: 600,
})}
{field("max_concurrency", "最大并发", {
type: "number",
min: 1,
max: 32,
})}
{field("context_window", "上下文窗口", {
type: "number",
min: 1024,
max: 10000000,
placeholder: "自动 / 可选",
})}
</div>
<label className="onboarding-model-thinking">
<input
type="checkbox"
checked={draft.disable_thinking}
disabled={fieldsLocked}
onChange={(e) => {
if (!flight.current)
onDraftChange({
disable_thinking: e.target.checked,
});
}}
/>
{text("关闭思考模式")}
</label>
<p className="onboarding-model-vector-note">
{text(
"自定义向量配置可展开;远程向量服务须遵守凭据边界。",
)}
<br />
{text("切换服务商或模型后,需要重新测试并启用。")}
</p>
{draft.embedding_mode === "custom" && (
<>
{field("embedding_model", "向量模型名称", {
required: true,
maxLength: 256,
})}
<label className="onboarding-model-field">
<span>{text("向量协议")}</span>
<select
aria-label={text("向量协议")}
value={draft.embedding_provider_kind}
disabled={fieldsLocked}
onChange={(e) => {
if (!flight.current)
onDraftChange({
embedding_provider_kind: e.target.value as
| "ollama"
| "openai_compatible",
});
}}
>
<option value="openai_compatible">
{text("同源 OpenAI-compatible(共用 Key)")}
</option>
<option value="ollama">
{text("本机 Ollama")}
</option>
</select>
</label>
<div className="onboarding-model-wide">
{field("embedding_base_url", "向量 Base URL", {
type: "url",
required: true,
})}
<small>
{text(
"远程向量地址须与生成服务同源;本机地址仍需校验。",
)}
</small>
</div>
</>
)}
</div>
</>
)}
</section>
<div className="onboarding-model-recovery-actions">
{props.onRecoverCreation && (
<R6Button
disabled={locked}
onClick={() => void props.onRecoverCreation?.()}
>
{text("核对创建结果")}
</R6Button>
)}
{props.onDiscardMissingCreation && (
<R6Button
variant="secondary"
disabled={locked}
onClick={() => void props.onDiscardMissingCreation?.()}
>
{text("放弃旧记录,重新配置")}
</R6Button>
)}
</div>
<div className="onboarding-model-feedback" tabIndex={-1}>
{(displayedPhase === "testing" ||
displayedPhase === "enabling") && (
<p role="status" aria-label={text("模型提交状态")}>
{text(
displayedPhase === "testing"
? "正在测试连接…"
: "正在提交保存与启用,请等待回执…",
)}
</p>
)}
{props.probe && (
<p
className="onboarding-model-probe"
data-state={props.probe.state}
role="status"
>
{text(props.probe.message)}
</p>
)}
{(props.error || callbackFailed) && (
<p className="onboarding-model-error" role="alert">
{text(
props.error ||
"操作结果未确认,已填写内容保留,请核对后重试。",
)}
</p>
)}
</div>
</section>
<aside
className="onboarding-model-summary"
aria-label={text("本次沉淀")}
data-full-height={advancedOpen || connectionFailed}
// biome-ignore lint/a11y/noNoninteractiveTabindex: Keyboard users must be able to scroll the full permission disclosure.
tabIndex={0}
>
<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">
{connectionReceipt && (
<small className="onboarding-model-connection-receipt">
{text(connectionReceipt)}
</small>
)}
<small>
{text("处理服务:")}
{text(provider?.label ?? "待选择")}
<br />
{text("相关对话会发送给所选服务商。")}
</small>
<small>
{text(
"测试连接只发送测试内容。启用后会使用此模型整理已导入和之后同步的会话。",
)}
</small>
</div>
{!props.capability.saveAndEnable && (
<p role="status">
{text(props.capability.reason || "当前不能在此启用模型。")}
</p>
)}
</aside>
</div>
</R6ColdStartFrame>
</div>
);
}
function safeEndpoint(value: string): string {
try {
const url = new URL(value);
if (
!["http:", "https:"].includes(url.protocol) ||
url.username ||
url.password ||
url.search ||
url.hash
)
return "接口地址待核对";
return `${url.origin}${url.pathname.replace(/\/$/, "")}`;
} catch {
return "接口地址待核对";
}
}
export { OnboardingModelStepNext as OnboardingModelStep } from "./OnboardingModelStepNext";
@@ -0,0 +1,355 @@
import { useEffect, useId, useRef, useState } from "react";
import {
GenerationTechnicalDisclosure,
GenerationTechnicalFields,
generationSummary,
ProviderPresetField,
} from "./GenerationModelPrimitives";
import type {
OnboardingModelDraft,
OnboardingModelStepProps,
} from "./OnboardingModelStep";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6ColdStartFrame } from "./R6ColdStartFrame";
import { R6Button } from "./R6Controls";
type Action = "test" | "save-and-enable";
export function OnboardingModelStepNext(props: OnboardingModelStepProps) {
const { text, t } = useOnboardingTranslation();
const id = useId();
const modelRef = useRef<HTMLInputElement>(null);
const formRef = useRef<HTMLElement>(null);
const flight = useRef(false);
const alive = useRef(true);
const [pending, setPending] = useState<Action | null>(null);
const [callbackFailed, setCallbackFailed] = useState(false);
const provider = props.providers.find(
(candidate) => candidate.id === props.draft.provider_id,
);
const locked = props.phase !== "idle" || pending !== null;
const fieldsLocked = locked || props.inputsLocked === true;
const canEnable =
(props.probe?.state === "passed" ||
props.enableActionLabel === "核对提交状态") &&
props.capability.saveAndEnable &&
props.canSaveAndEnable === true &&
!!props.generationRecipient.trim() &&
provider?.supported;
useEffect(() => {
alive.current = true;
return () => {
alive.current = false;
};
}, []);
useEffect(() => {
const firstError = (
[
"api_key",
"model",
"base_url",
"timeout_seconds",
"max_concurrency",
"context_window",
] as const
).find((key) => props.fieldErrors?.[key]);
const technicalError = firstError && firstError !== "api_key";
if (technicalError && !props.advancedOpen) {
props.onAdvancedChange(true);
return;
}
if (firstError && (!technicalError || props.advancedOpen)) {
formRef.current
?.querySelector<HTMLElement>(`[name="${firstError}"]`)
?.focus({ preventScroll: true });
return;
}
if (props.advancedOpen && !props.draft.model.trim())
modelRef.current?.focus({ preventScroll: true });
}, [
props.advancedOpen,
props.draft.model,
props.fieldErrors,
props.onAdvancedChange,
]);
async function run(action: Action) {
if (flight.current || locked) return;
if (action === "test" && (!props.canTest || !provider?.supported)) return;
if (action === "save-and-enable" && !canEnable) return;
flight.current = true;
setPending(action);
setCallbackFailed(false);
try {
await (action === "test" ? props.onTest() : props.onSaveAndEnable());
} catch {
if (alive.current) {
setCallbackFailed(true);
props.onActionError?.(action);
}
} finally {
flight.current = false;
if (alive.current) setPending(null);
}
}
const displayedPhase =
props.phase !== "idle"
? props.phase
: pending === "test"
? "testing"
: pending
? "enabling"
: "idle";
const sourceSummary =
props.sourceNames.length <= 3
? props.sourceNames.join(" · ")
: t("onboardingSourcesSummary", {
names: props.sourceNames.slice(0, 3).join(" · "),
count: props.sourceNames.length,
});
const runtimeErrors = Object.fromEntries(
Object.entries(props.fieldErrors ?? {}).filter(([key]) =>
[
"provider_kind",
"model",
"base_url",
"timeout_seconds",
"max_concurrency",
"context_window",
].includes(key),
),
) as Partial<Record<keyof OnboardingModelDraft, string>>;
return (
<div className="onboarding-model-r6">
<R6ColdStartFrame
step={3}
eyebrow={text("智能记忆 Pro · 配置记忆整理")}
title={text("配置记忆整理模型")}
focusToken={props.focusToken}
footer={
<>
<p className="onboarding-model-footer-note">
{text(
props.probe?.state === "passed"
? "模型连接测试通过,可以启用并开始沉淀记忆。"
: "先测试模型连通性;测试通过后再启用。",
)}
</p>
<div className="onboarding-model-footer-actions">
<R6Button
disabled={locked}
onClick={() => {
if (!flight.current && !locked) props.onBack();
}}
>
{text("返回选择模式")}
</R6Button>
<R6Button
disabled={locked || !props.canTest || !provider?.supported}
onClick={() => void run("test")}
>
{text(
displayedPhase === "testing"
? "正在测试模型…"
: (props.testActionLabel ?? "测试连接"),
)}
</R6Button>
<R6Button
variant="primary"
disabled={locked || !canEnable}
onClick={() => void run("save-and-enable")}
>
{text(
displayedPhase === "enabling"
? "正在启用…"
: (props.enableActionLabel ?? "启用并进入"),
)}
</R6Button>
</div>
</>
}
>
<div
className="onboarding-model-layout onboarding-model-layout--single-column-600"
data-responsive="single-column-600"
>
<section
ref={formRef}
className="onboarding-model-form"
aria-label={text("模型配置")}
data-advanced={props.advancedOpen}
>
<div className="onboarding-model-group">
<label
className="onboarding-model-field onboarding-model-provider"
htmlFor={`${id}-provider`}
>
<span id={`${id}-provider-label`}>{text("模型服务商")}</span>
<ProviderPresetField
id={`${id}-provider`}
labelledBy={`${id}-provider-label`}
value={props.draft.provider_id}
disabled={fieldsLocked}
text={text}
onChange={(provider_id) =>
props.onDraftChange({ provider_id })
}
/>
</label>
<label
className="onboarding-model-field onboarding-model-key"
htmlFor={`${id}-api-key`}
>
<span>
API Key{provider?.keyRequired ? "" : text("(可选)")}
</span>
<input
id={`${id}-api-key`}
name="api_key"
aria-label={`API Key${provider?.keyRequired ? "" : text("(可选)")}`}
type="password"
autoComplete="off"
value={props.draft.api_key}
disabled={fieldsLocked}
required={
provider?.keyRequired &&
!props.profile?.has_configured_api_key
}
aria-invalid={!!props.fieldErrors?.api_key}
aria-describedby={
props.fieldErrors?.api_key
? `${id}-api-key-error`
: `${id}-api-key-help`
}
placeholder={text(
props.profile?.has_configured_api_key
? "已安全保存,留空保留"
: "输入服务商提供的 API Key",
)}
onChange={(event) =>
props.onDraftChange({ api_key: event.target.value })
}
/>
<small id={`${id}-api-key-help`}>
{text(
props.profile?.credential_state === "unavailable"
? "需要允许 MemHub 访问钥匙串。系统询问时请选择“始终允许”。"
: props.profile?.has_configured_api_key
? "留空保留已保存密钥;填写新密钥后需重新测试。"
: "密钥保存在系统安全存储中,不写入对话或日志。",
)}
</small>
{props.fieldErrors?.api_key ? (
<small
id={`${id}-api-key-error`}
className="generation-model-error"
>
{text(props.fieldErrors.api_key)}
</small>
) : null}
</label>
<div className="onboarding-model-default-summary">
<span>{text("默认模型")}</span>
<strong>
{generationSummary(
props.draft.provider_id,
props.draft.provider_kind,
props.draft.model,
text,
)}
</strong>
</div>
<div className="onboarding-model-disclosure">
<p>
{text("相关对话会发送给所选服务商。")}
{sourceSummary ? ` ${sourceSummary}` : ""}
</p>
<p>
{text("密钥保存在系统安全存储中,不写入对话或日志。")}{" "}
{text("测试只向此接口发送测试内容,不发送历史对话。")}
</p>
</div>
<GenerationTechnicalDisclosure
open={props.advancedOpen}
disabled={fieldsLocked}
controls={`${id}-technical`}
text={text}
onChange={props.onAdvancedChange}
>
<GenerationTechnicalFields
idPrefix={`${id}-generation`}
values={props.draft}
errors={runtimeErrors}
disabled={fieldsLocked}
text={text}
modelRef={modelRef}
onChange={(patch) =>
props.onDraftChange(patch as Partial<OnboardingModelDraft>)
}
/>
</GenerationTechnicalDisclosure>
</div>
<div className="onboarding-model-recovery-actions">
{props.onRecoverCreation ? (
<R6Button
disabled={locked}
onClick={() => void props.onRecoverCreation?.()}
>
{text("核对创建结果")}
</R6Button>
) : null}
{props.onDiscardMissingCreation ? (
<R6Button
variant="secondary"
disabled={locked}
onClick={() => void props.onDiscardMissingCreation?.()}
>
{text("放弃旧记录,重新配置")}
</R6Button>
) : null}
</div>
<div className="onboarding-model-feedback" tabIndex={-1}>
{(displayedPhase === "testing" ||
displayedPhase === "enabling") && (
<p role="status" aria-label={text("模型提交状态")}>
{text(
displayedPhase === "testing"
? "正在测试连接…"
: "正在提交保存与启用,请等待回执…",
)}
</p>
)}
{props.probe ? (
<p
className="onboarding-model-probe"
data-state={props.probe.state}
role="status"
>
{text(props.probe.message)}
</p>
) : null}
{props.error || callbackFailed ? (
<p className="onboarding-model-error" role="alert">
{text(
props.error ||
"操作结果未确认,已填写内容保留,请核对后重试。",
)}
</p>
) : null}
{!props.capability.saveAndEnable ? (
<p role="status">
{text(props.capability.reason || "当前不能在此启用模型。")}
</p>
) : null}
</div>
</section>
</div>
</R6ColdStartFrame>
</div>
);
}
+18 -3
View File
@@ -13,6 +13,7 @@ import { useGuiLanguage } from "./gui-language";
export interface OptionMenuItem {
value: string;
label: string;
description?: string;
disabled?: boolean;
lang?: string;
}
@@ -110,7 +111,7 @@ export function OptionMenu(props: {
function choose(index: number) {
const item = props.options[index];
if (!item || item.disabled) return;
props.onChange(item.value);
if (item.value !== props.value) props.onChange(item.value);
setOpen(false);
button.current?.focus();
}
@@ -265,7 +266,14 @@ export function OptionMenu(props: {
}
}}
>
{visibleLabel}
{selected?.description ? (
<span className="om-option-menu-copy">
<span>{visibleLabel}</span>
<small>{selected.description}</small>
</span>
) : (
visibleLabel
)}
</button>
{open &&
createPortal(
@@ -298,7 +306,14 @@ export function OptionMenu(props: {
}}
onClick={() => choose(index)}
>
{item.label}
{item.description ? (
<span className="om-option-menu-copy">
<span>{item.label}</span>
<small>{item.description}</small>
</span>
) : (
item.label
)}
</button>
))}
</div>,
+14 -2
View File
@@ -1,4 +1,4 @@
import type { ReactNode } from "react";
import { type ReactNode, useEffect, useRef } from "react";
import { LanguageSelector } from "./LanguageSelector";
import { R6LocalStorage, R6Stepper } from "./R6Controls";
import "./onboarding-r6.css";
@@ -9,13 +9,20 @@ export function R6ColdStartFrame({
eyebrow,
children,
footer,
focusToken,
}: {
step: 1 | 2 | 3;
title: string;
eyebrow: string;
children: ReactNode;
footer: ReactNode;
focusToken?: number;
}) {
const titleRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
if (focusToken === undefined) return;
titleRef.current?.focus({ preventScroll: true });
}, [focusToken]);
return (
<main className="onboarding-r6" aria-label={title}>
{/* The source (143:3007 / 143:3008) puts a mark + "MemHub" on the
@@ -33,7 +40,12 @@ export function R6ColdStartFrame({
<header className="onboarding-r6-head">
<div>
<p>{eyebrow}</p>
<h1>{title}</h1>
<h1
ref={titleRef}
tabIndex={focusToken === undefined ? undefined : -1}
>
{title}
</h1>
</div>
<R6Stepper active={step} />
</header>
+1 -1
View File
@@ -40,7 +40,7 @@ export function R6Stepper({ active }: { active: 1 | 2 | 3 }) {
return (
<nav className="r6-stepper" aria-label={text("首次设置进度")}>
<ol>
{["导入历史", "连接应用", "沉淀记忆"].map((label, index) => (
{["导入历史", "连接应用", "记忆模式"].map((label, index) => (
<li
key={label}
aria-current={active === index + 1 ? "step" : undefined}
+255 -15
View File
@@ -1,14 +1,29 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import {
cleanup,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { Ref } from "react";
import { afterEach, expect, it, vi } from "vitest";
import { getModelProfiles, getSedimentation, setSedimentation } from "./api";
import {
getMemoryMode,
getModelProfiles,
getSedimentation,
planMemoryMode,
setSedimentation,
} from "./api";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import { queryClient, resetClient } from "./query-client";
import { R6Settings } from "./R6Settings";
const editProfileAction = vi.fn(() => true);
const addProfileAction = vi.fn(() => true);
it("changes settings copy live without rereading profiles or changing permissions", async () => {
const user = userEvent.setup();
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
@@ -19,7 +34,7 @@ it("changes settings copy live without rereading profiles or changing permission
</LanguageProvider>,
);
expect(
await screen.findByRole("heading", { name: "Modelo de memorias" }),
await screen.findByRole("heading", { name: "Modo de memoria" }),
).toBeTruthy();
expect(
screen.queryByRole("switch", {
@@ -27,9 +42,13 @@ it("changes settings copy live without rereading profiles or changing permission
}),
).toBeNull();
const reads = vi.mocked(getModelProfiles).mock.calls.length;
await user.click(screen.getByRole("combobox"));
await user.click(
screen.getByRole("button", { name: "Idioma de la interfaz" }),
);
await user.click(screen.getByRole("combobox", { name: "Idioma" }));
await user.click(screen.getByRole("option", { name: "简体中文" }));
expect(await screen.findByRole("heading", { name: "记忆模型" })).toBeTruthy();
await user.click(screen.getByRole("button", { name: "模型配置" }));
expect(await screen.findByRole("heading", { name: "模型配置" })).toBeTruthy();
expect(vi.mocked(getModelProfiles).mock.calls).toHaveLength(reads);
});
vi.mock("./api", () => ({
@@ -48,6 +67,38 @@ vi.mock("./api", () => ({
imports_unaffected: true,
}),
setSedimentation: vi.fn(),
getMemoryMode: vi.fn().mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: true,
forced_by_env: false,
modes: ["lite", "pro", "ultra"],
graph_build: {
state: "idle",
started_at: null,
finished_at: null,
users: {},
},
}),
setMemoryMode: vi.fn(),
getEvolveSchedule: vi.fn().mockResolvedValue({
enabled: true,
time: "03:00",
}),
setEvolveSchedule: vi.fn(),
getEvolveOverview: vi.fn().mockResolvedValue({
mode: "pro",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [],
}),
startEvolve: vi.fn(),
getMemoryProcessing: vi.fn().mockResolvedValue({
embedding_switch: null,
}),
planMemoryMode: vi.fn(),
memoryModePlanUnsupported: vi.fn().mockReturnValue(false),
apiErrorCode: vi.fn().mockReturnValue(null),
}));
vi.mock("./ModelSettings", async () => {
const { createElement, useImperativeHandle } = await import("react");
@@ -55,14 +106,25 @@ vi.mock("./ModelSettings", async () => {
// Stands in for the real component at its worst: every mutating action is
// fenced, and the only way out is the recovery entry that lives inside the
// same area the fence used to keep shut.
ModelSettings: ({ controlsRef }: { controlsRef?: Ref<unknown> }) => {
ModelSettings: ({
controlsRef,
focusSection,
}: {
controlsRef?: Ref<unknown>;
focusSection?: "generation" | "embedding";
}) => {
useImperativeHandle(controlsRef, () => ({
edit: () => false,
editProfile: () => false,
add: () => false,
editProfile: editProfileAction,
add: addProfileAction,
test: () => Promise.resolve(undefined),
}));
return createElement("button", { type: "button" }, "核对测试结果");
return createElement(
"div",
null,
createElement("span", null, focusSection),
createElement("button", { type: "button" }, "核对测试结果"),
);
},
};
});
@@ -97,8 +159,9 @@ it("shows the help and diagnostics group by default and hides it when opted out"
} as NonNullable<typeof window.oneMemory>;
const user = userEvent.setup();
const view = render(<R6Settings onDiagnostics={vi.fn()} />);
expect(screen.getByRole("combobox")).toBeTruthy();
await user.click(screen.getByRole("combobox"));
await user.click(screen.getByRole("button", { name: "界面语言" }));
expect(screen.getByRole("combobox", { name: "语言" })).toBeTruthy();
await user.click(screen.getByRole("combobox", { name: "语言" }));
expect(screen.getAllByRole("option")).toHaveLength(12);
await user.keyboard("{Escape}");
await user.click(screen.getByRole("button", { name: "连接其他应用" }));
@@ -115,6 +178,93 @@ it("shows the help and diagnostics group by default and hides it when opted out"
expect(screen.queryByRole("button", { name: "帮助与诊断" })).toBeNull();
});
it("uses one sidebar hierarchy and explains when a saved model is inactive in lite", async () => {
vi.mocked(getMemoryMode).mockResolvedValueOnce({
mode: "lite",
configured_mode: "lite",
explicitly_configured: true,
forced_by_env: false,
modes: ["lite", "pro", "ultra"],
graph_build: {
state: "idle",
started_at: null,
finished_at: null,
users: {},
},
});
const user = userEvent.setup();
render(<R6Settings onDiagnostics={vi.fn()} />);
expect(screen.getByRole("button", { name: "界面语言" })).toBeTruthy();
expect(screen.getByRole("button", { name: "记忆模式" })).toBeTruthy();
expect(screen.getByRole("button", { name: "模型配置" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "刷新记忆模式" })).toBeNull();
await user.click(screen.getByRole("button", { name: "界面语言" }));
expect(screen.getByRole("combobox", { name: "语言" })).toBeTruthy();
await user.click(screen.getByRole("button", { name: "模型配置" }));
expect(await screen.findByText("当前是基础记忆(lite)")).toBeTruthy();
expect(
screen.getByText(/是否开始整理取决于.*“用模型沉淀记忆”.*开关/),
).toBeTruthy();
expect(screen.queryByRole("button", { name: "刷新模型状态" })).toBeNull();
});
it("carries a missing-model explanation into model configuration and moves focus", async () => {
vi.mocked(getModelProfiles).mockResolvedValueOnce([]);
vi.mocked(getMemoryMode).mockResolvedValueOnce({
mode: "lite",
configured_mode: "lite",
explicitly_configured: true,
forced_by_env: false,
modes: ["lite", "pro", "ultra"],
graph_build: {
state: "idle",
started_at: null,
finished_at: null,
users: {},
},
});
vi.mocked(planMemoryMode).mockResolvedValueOnce({
current: "lite",
target: "pro",
effective_target: "pro",
capabilities: {
content_plane: "memory",
generation_required: true,
graph_enabled: false,
evolve_available: false,
},
effects: ["earlier_raw_not_extracted", "lite_search_stops"],
warnings: [],
blockers: ["active_model_required"],
});
const user = userEvent.setup();
render(<R6Settings onDiagnostics={vi.fn()} />);
await user.click(
await screen.findByRole("radio", { name: /智能记忆(pro)/ }),
);
expect(
await screen.findByText("使用 pro 或 ultra 前需要先配置并启用生成模型。"),
).toBeTruthy();
const heading = screen.getByRole("heading", { name: "模型配置" });
await waitFor(() => expect(document.activeElement).toBe(heading));
});
it("offers a text recovery action when the model overview cannot be read", async () => {
vi.mocked(getModelProfiles).mockRejectedValueOnce(new Error("offline"));
const user = userEvent.setup();
render(<R6Settings onDiagnostics={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "模型配置" }));
expect(await screen.findByText("模型状态未能读取,请重试。")).toBeTruthy();
await user.click(screen.getByRole("button", { name: "重新读取" }));
expect(await screen.findByText("当前模型")).toBeTruthy();
});
it("stops model sedimentation on one switch flip without touching the model config", async () => {
const user = userEvent.setup();
vi.mocked(setSedimentation).mockResolvedValue({
@@ -123,6 +273,7 @@ it("stops model sedimentation on one switch flip without touching the model conf
imports_unaffected: true,
});
render(<R6Settings onDiagnostics={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "模型配置" }));
const toggle = await screen.findByRole("switch", { name: "用模型沉淀记忆" });
expect(toggle.getAttribute("aria-checked")).toBe("true");
@@ -141,6 +292,7 @@ it("reports a stop that did not confirm instead of showing it stopped", async ()
const user = userEvent.setup();
vi.mocked(setSedimentation).mockRejectedValue(new Error("offline"));
render(<R6Settings onDiagnostics={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "模型配置" }));
const toggle = await screen.findByRole("switch", { name: "用模型沉淀记忆" });
await user.click(toggle);
@@ -151,36 +303,124 @@ it("reports a stop that did not confirm instead of showing it stopped", async ()
);
// The model configuration is a different store: a failed switch must not
// leave the page claiming the model was removed.
expect(screen.getByRole("heading", { name: "当前模型" })).toBeTruthy();
expect(screen.getByText("当前模型")).toBeTruthy();
});
it("opens the model area even when a pending probe fences the editor, so recovery stays reachable", async () => {
editProfileAction.mockReturnValueOnce(false);
const user = userEvent.setup();
render(<R6Settings onDiagnostics={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "模型配置" }));
await user.click(await screen.findByRole("button", { name: "修改模型" }));
const generationLane = (
await screen.findByRole("heading", { name: "记忆整理" })
).closest(".r6-model-lane") as HTMLElement | null;
if (!generationLane) throw new Error("Missing generation lane");
await user.click(
within(generationLane).getByRole("button", { name: "配置" }),
);
// The fence is still reported: the editor really did refuse.
const alert = await screen.findByRole("alert");
expect(alert.textContent).toContain("待核对");
// What changed is that the recovery entry no longer hides with the form it
// is supposed to unlock.
expect(screen.getByRole("button", { name: "核对测试结果" })).toBeTruthy();
expect(
within(generationLane).getByRole("button", { name: "核对测试结果" }),
).toBeTruthy();
});
it("reaches the same recovery entry when no model has been configured yet", async () => {
addProfileAction.mockReturnValueOnce(false);
vi.mocked(getModelProfiles).mockResolvedValueOnce([]);
const user = userEvent.setup();
render(<R6Settings onDiagnostics={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "模型配置" }));
await user.click(await screen.findByRole("button", { name: "配置模型" }));
const generationLane = (
await screen.findByRole("heading", { name: "记忆整理" })
).closest(".r6-model-lane") as HTMLElement | null;
if (!generationLane) throw new Error("Missing generation lane");
await user.click(
await within(generationLane).findByRole("button", { name: "配置模型" }),
);
expect(screen.getByRole("button", { name: "核对测试结果" })).toBeTruthy();
const searchLane = screen
.getByRole("heading", { name: "记忆搜索" })
.closest(".r6-model-lane") as HTMLElement | null;
if (!searchLane) throw new Error("Missing search lane");
expect(
within(searchLane).getByRole("button", { name: "配置" }),
).toHaveProperty("disabled", true);
expect(searchLane.textContent).toContain("配置记忆整理后可自定义");
});
it("shows separate compact lanes and opens the requested presentation", async () => {
const user = userEvent.setup();
render(<R6Settings onDiagnostics={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "模型配置" }));
const generationLane = (
await screen.findByRole("heading", { name: "记忆整理" })
).closest(".r6-model-lane") as HTMLElement | null;
const embeddingLane = screen
.getByRole("heading", { name: "记忆搜索" })
.closest(".r6-model-lane") as HTMLElement | null;
if (!generationLane || !embeddingLane) throw new Error("Missing model lane");
expect(generationLane.textContent).toContain("当前模型");
expect(generationLane.textContent).toContain("LLM");
expect(embeddingLane.textContent).toContain("本地内置(推荐)");
expect(embeddingLane.textContent).toContain("Embedding");
await user.click(within(embeddingLane).getByRole("button", { name: "配置" }));
expect(await within(embeddingLane).findByText("embedding")).toBeTruthy();
expect(editProfileAction).toHaveBeenCalledTimes(1);
expect(editProfileAction).toHaveBeenLastCalledWith(
expect.objectContaining({ profile_id: "profile-1" }),
);
expect(embeddingLane.dataset.expanded).toBe("true");
expect(
within(embeddingLane)
.getByRole("button", { name: "收起" })
.getAttribute("aria-expanded"),
).toBe("true");
await user.click(
within(generationLane).getByRole("button", { name: "配置" }),
);
expect(await within(generationLane).findByText("generation")).toBeTruthy();
expect(editProfileAction).toHaveBeenCalledTimes(2);
expect(generationLane.dataset.expanded).toBe("true");
expect(within(embeddingLane).queryByText("embedding")).toBeNull();
await user.click(
within(generationLane).getByRole("button", { name: "收起" }),
);
expect(
within(generationLane)
.getByText("generation")
.closest(".r6-model-owner")
?.hasAttribute("hidden"),
).toBe(true);
await user.click(
within(generationLane).getByRole("button", { name: "配置" }),
);
expect(editProfileAction).toHaveBeenCalledTimes(2);
expect(
within(generationLane)
.getByText("generation")
.closest(".r6-model-owner")
?.hasAttribute("hidden"),
).toBe(false);
expect(screen.queryByRole("alert")).toBeNull();
});
it("leaves the switch unusable when the state cannot be read", async () => {
vi.mocked(getSedimentation).mockRejectedValueOnce(new Error("offline"));
render(<R6Settings onDiagnostics={vi.fn()} />);
await userEvent.click(screen.getByRole("button", { name: "模型配置" }));
const toggle = (await screen.findByRole("switch", {
name: "用模型沉淀记忆",
+348 -124
View File
@@ -1,5 +1,5 @@
import type { ModelProfile } from "@one-memory/contracts";
import { Download, RefreshCw } from "lucide-react";
import type { MemoryPipelineMode, ModelProfile } from "@one-memory/contracts";
import { Download } from "lucide-react";
import {
useCallback,
useEffect,
@@ -12,9 +12,15 @@ import { getModelProfiles, setSedimentation } from "./api";
import { ConnectionFallback } from "./ConnectionFallback";
import { exportDiagnosticsFile } from "./diagnostics-export";
import { LanguageSelector } from "./LanguageSelector";
import { ModelSettings, type ModelSettingsActions } from "./ModelSettings";
import { MemoryModeSettings } from "./MemoryModeSettings";
import {
ModelSettings,
type ModelSettingsActions,
type ModelSettingsFocusSection,
} from "./ModelSettings";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Button, R6Switch } from "./R6Controls";
import { R6StateRecovery } from "./R6StateRecovery";
import {
refreshLoginPreference,
setLoginPreference,
@@ -27,14 +33,9 @@ import {
} from "./sedimentation-state";
import "./r6-settings.css";
type Group = "model" | "connections" | "help";
type Group = "language" | "mode" | "models" | "connections" | "help";
const labels: Record<Group, string> = {
model: "模型",
connections: "连接其他应用",
help: "帮助与诊断",
};
const primaryGroups: Group[] = ["model", "connections"];
const primaryGroups: Group[] = ["language", "mode", "models", "connections"];
function safeEndpoint(value: string | null) {
if (!value) return "本机 / 受管端点";
@@ -61,12 +62,26 @@ export function R6Settings({
const groups = showDiagnostics
? [...primaryGroups, "help" as const]
: primaryGroups;
const [group, setGroup] = useState<Group>("model");
const groupLabel = (key: Group) => {
if (key === "language") return t("languageTitle");
if (key === "mode") return text("记忆模式");
if (key === "models") return text("模型配置");
if (key === "connections") return text("连接其他应用");
return text("帮助与诊断");
};
const [group, setGroup] = useState<Group>("mode");
const [profiles, setProfiles] = useState<ModelProfile[] | null>(null);
const [modelError, setModelError] = useState(false);
const [configurationRefusal, setConfigurationRefusal] = useState(false);
const [modeHandoffMessage, setModeHandoffMessage] = useState("");
const [modelVersion, setModelVersion] = useState(0);
const [configurationOpen, setConfigurationOpen] = useState(false);
const [configurationFocus, setConfigurationFocus] =
useState<ModelSettingsFocusSection>("generation");
const [configurationRequest, setConfigurationRequest] = useState(0);
const [memoryMode, setMemoryModeState] = useState<MemoryPipelineMode | null>(
null,
);
const [quitOpen, setQuitOpen] = useState(false);
const [quitting, setQuitting] = useState(false);
const [quitError, setQuitError] = useState("");
@@ -87,6 +102,10 @@ export function R6Settings({
const [sedimentationUnavailable, setSedimentationUnavailable] =
useState(false);
const modelActions = useRef<ModelSettingsActions>(null);
const configurationStarted = useRef(false);
const configurationStartedFocus = useRef<ModelSettingsFocusSection | null>(
null,
);
const quitPanel = useRef<HTMLElement>(null);
const quitOpener = useRef<HTMLElement | null>(null);
const currentModel = profiles?.find((profile) => profile.is_active) ?? null;
@@ -122,9 +141,8 @@ export function R6Settings({
// biome-ignore lint/correctness/useExhaustiveDependencies: modelVersion is the explicit refresh trigger.
useEffect(() => {
if (!active) return;
if (group !== "model") return;
void readModels();
}, [active, group, modelVersion, readModels]);
}, [active, modelVersion, readModels]);
const readSedimentation = useCallback(async () => {
try {
@@ -139,7 +157,7 @@ export function R6Settings({
useEffect(() => {
if (!active) return;
if (group !== "model") return;
if (group !== "models") return;
void readSedimentation();
}, [active, group, readSedimentation]);
@@ -208,18 +226,52 @@ export function R6Settings({
}
}
function openConfiguration() {
function openConfiguration(
focusSection: ModelSettingsFocusSection = "generation",
) {
if (
configurationStarted.current &&
configurationStartedFocus.current === focusSection
) {
setConfigurationFocus(focusSection);
setConfigurationOpen(true);
return;
}
setConfigurationRefusal(false);
setConfigurationFocus(focusSection);
configurationStarted.current = true;
configurationStartedFocus.current = focusSection;
setConfigurationOpen(true);
setConfigurationRequest((value) => value + 1);
}
// The focused editor can move between the two lanes. Fill it only after
// React has mounted it in the requested lane, otherwise an edit sent to the
// old lane would be lost during the move.
// biome-ignore lint/correctness/useExhaustiveDependencies: configurationRequest is the explicit intent trigger.
useEffect(() => {
if (!configurationOpen || configurationRequest === 0) return;
const opened = currentModel
? modelActions.current?.editProfile(currentModel)
: modelActions.current?.add();
// The area opens even when the editor refuses: the only recovery entry
// lives inside it, and hiding both is what locked people out of their own
// settings. They needed the form open to reach “核对测试结果”, and that
// button to clear the pending record that kept the form shut. The fence
// itself is untouched (see ModelSettings): refusing still returns false.
setConfigurationOpen(true);
// The area stays open even when the editor refuses: its recovery entry is
// the only way to reconcile the pending operation.
if (!opened) setConfigurationRefusal(true);
}, [configurationRequest]);
function toggleConfiguration(focusSection: ModelSettingsFocusSection) {
if (configurationOpen && configurationFocus === focusSection) {
setConfigurationOpen(false);
return;
}
openConfiguration(focusSection);
}
function completeConfiguration() {
configurationStarted.current = false;
configurationStartedFocus.current = null;
setConfigurationRefusal(false);
setConfigurationOpen(false);
}
async function quitApplication() {
@@ -268,7 +320,6 @@ export function R6Settings({
return (
<section className="r6-settings" aria-label={t("settings")}>
<h1>{t("settings")}</h1>
<LanguageSelector />
<div className="r6-settings-layout">
<nav aria-label={text("设置分组")}>
{groups.map((key) => (
@@ -279,120 +330,293 @@ export function R6Settings({
aria-controls={`${id}-panel-${key}`}
aria-current={group === key ? "page" : undefined}
onKeyDown={moveGroupFocus}
onClick={() => setGroup(key)}
onClick={() => {
setModeHandoffMessage("");
setGroup(key);
}}
>
{text(labels[key])}
{groupLabel(key)}
</button>
))}
</nav>
<div className="r6-settings-content">
<section
id={`${id}-panel-model`}
aria-labelledby={`${id}-nav-model`}
hidden={group !== "model"}
id={`${id}-panel-language`}
aria-labelledby={`${id}-nav-language`}
hidden={group !== "language"}
className="r6-settings-language"
>
<LanguageSelector />
</section>
<section
id={`${id}-panel-mode`}
aria-labelledby={`${id}-nav-mode`}
hidden={group !== "mode"}
className="r6-settings-mode"
>
<MemoryModeSettings
hasActiveModel={currentModel !== null}
modelStateKnown={profiles !== null && !modelError}
onConfigureModel={(message) => {
setModeHandoffMessage(message ?? "");
setGroup("models");
openConfiguration("generation");
window.setTimeout(() => {
document.getElementById(`${id}-model-title`)?.focus();
}, 0);
}}
onModeChange={setMemoryModeState}
/>
</section>
<section
id={`${id}-panel-models`}
aria-labelledby={`${id}-nav-models`}
hidden={group !== "models"}
className="r6-settings-model"
>
<h2>{text("记忆模型")}</h2>
<div className="r6-model-overview">
<p className="r6-model-caption">{text("当前模型")}</p>
<h3 className="r6-current-model">
{currentModel?.name ??
text(
profiles === null
? "正在读取模型…"
: modelError
? "模型状态暂不可读"
: "尚未配置模型",
)}
</h3>
{currentModel && (
<p className="r6-model-state">
{currentModel.model} ·{" "}
{text(safeEndpoint(currentModel.base_url))}
</p>
)}
<div className="r6-model-actions">
<R6Button
variant="primary"
disabled={profiles === null}
onClick={openConfiguration}
<div className="r6-model-heading">
<div>
<h2
id={`${id}-model-title`}
className="r6-model-title"
tabIndex={-1}
>
{text(currentModel ? "修改模型" : "配置模型")}
</R6Button>
<R6Button
variant="quiet"
aria-label={text("刷新模型状态")}
title={text("刷新模型状态")}
onClick={() => setModelVersion((value) => value + 1)}
>
<RefreshCw size={16} aria-hidden="true" />
</R6Button>
{text("模型配置")}
</h2>
<p>{text("分别管理记忆整理与搜索所使用的模型。")}</p>
</div>
{modelError && (
<p role="alert" className="r6-settings-error">
{text(
profiles === null
? "模型状态未能读取,请重试。"
: "模型状态刷新未成功,仍显示上次确认的模型状态;请重试。",
)}
</p>
)}
{configurationRefusal && (
<p role="alert" className="r6-settings-error">
{text(
"上次模型测试或创建还待核对,配置暂时不能改动。请先在下方模型配置区核对结果; 现有配置和原文不会被改动。",
)}
</p>
)}
{currentModel && (
<div className="r6-setting-row">
<div>
<h3>{text("用模型沉淀记忆")}</h3>
<p>
{text(
"开启后,导入和同步到本机的会话会交给当前模型整理成记忆,会产生模型调用。 停止后不再调用模型、不再整理记忆,但会话仍会照常导入和保存;已排队的内容会留着,重新开启后继续。 模型配置始终保留,重新开启不需要重新填写。",
)}
</p>
</div>
<R6Switch
label={text("用模型沉淀记忆")}
checked={sedimentation === true}
disabled={
sedimentation === null ||
sedimentationPending ||
sedimentationUnavailable
}
onCheckedChange={(enabled) =>
void changeSedimentation(enabled)
}
/>
</div>
)}
{sedimentationError && (
<p role="alert" className="r6-settings-error">
{text(sedimentationError)}
</p>
)}
{sedimentationUnavailable && (
<p role="alert" className="r6-settings-error">
{text(
"当前安装还不能读取这个开关,请确认后台服务已启动后重试。",
)}
</p>
)}
</div>
<section
className="r6-model-owner"
hidden={!configurationOpen}
aria-label={text("模型配置表单")}
>
<ModelSettings
compactSingle
controlsRef={modelActions}
onProfilesChanged={() => setModelVersion((value) => value + 1)}
onSetupComplete={() => setConfigurationOpen(false)}
{modeHandoffMessage ? (
<p className="r6-settings-note" role="status">
{text(modeHandoffMessage)}
</p>
) : null}
{currentModel && (
<div className="r6-model-mode-status" role="status">
<strong>
{text(
memoryMode === "lite"
? "当前是基础记忆(lite)"
: memoryMode === "ultra"
? "当前是深度记忆(ultra)"
: memoryMode === "pro"
? "当前是智能记忆(pro)"
: "正在读取记忆模式…",
)}
</strong>
<p>
{text(
memoryMode === "lite"
? "模型配置已保留,但当前不会用于沉淀记忆。开启 pro 或 ultra 后,模型将生效并开始整理新对话。"
: memoryMode === "pro" || memoryMode === "ultra"
? sedimentation === null
? "正在确认模型沉淀状态,模型配置和已有记忆不会受影响。"
: sedimentation === false
? "当前模式支持模型沉淀,但模型整理已暂停;重新开启后会继续处理保留的任务。"
: "模型已在当前模式下生效,会用于整理新对话并沉淀记忆。"
: "模型配置已保留,确认记忆模式后会显示是否生效。",
)}
</p>
</div>
)}
<div className="r6-model-overview">
<section
className="r6-model-lane"
data-expanded={
configurationOpen && configurationFocus === "generation"
? "true"
: undefined
}
>
<div className="r6-model-lane-header">
<div className="r6-model-lane-copy">
<h3>{text("记忆整理")}</h3>
<p>{text("把会话理解并整理成可长期使用的记忆。")}</p>
<strong>
{currentModel?.name ??
text(
profiles === null
? "正在读取模型…"
: modelError
? "模型状态暂不可读"
: "尚未配置模型",
)}
</strong>
{currentModel && (
<small>
LLM · {currentModel.model} ·{" "}
{text(safeEndpoint(currentModel.base_url))}
</small>
)}
</div>
<R6Button
variant="quiet"
disabled={profiles === null}
aria-expanded={
configurationOpen && configurationFocus === "generation"
}
aria-controls={
configurationFocus === "generation"
? `${id}-generation-configuration`
: undefined
}
onClick={() => toggleConfiguration("generation")}
>
{configurationOpen && configurationFocus === "generation"
? t("wb.collapse")
: text(currentModel ? "配置" : "配置模型")}
</R6Button>
</div>
{configurationFocus === "generation" && (
<section
id={`${id}-generation-configuration`}
className="r6-model-owner"
hidden={!configurationOpen}
aria-label={text("模型配置表单")}
>
{configurationRefusal && (
<p role="alert" className="r6-settings-error">
{text(
"上次模型测试或创建还待核对,配置暂时不能改动。请先在下方模型配置区核对结果; 现有配置和原文不会被改动。",
)}
</p>
)}
<ModelSettings
compactSingle
focusSection={configurationFocus}
controlsRef={modelActions}
onProfilesChanged={() =>
setModelVersion((value) => value + 1)
}
onSetupComplete={completeConfiguration}
/>
</section>
)}
</section>
<section
className="r6-model-lane"
data-expanded={
configurationOpen && configurationFocus === "embedding"
? "true"
: undefined
}
>
<div className="r6-model-lane-header">
<div className="r6-model-lane-copy">
<h3>{text("记忆搜索")}</h3>
<p>{text("在需要时找到与你的问题最相关的记忆。")}</p>
<strong>
{text(
currentModel?.embedding_provider_kind &&
currentModel.embedding_provider_kind !==
"local_onnx" &&
currentModel.embedding_model
? currentModel.embedding_model
: "本地内置(推荐)",
)}
</strong>
<small>
{text(
currentModel?.embedding_provider_kind &&
currentModel.embedding_provider_kind !== "local_onnx"
? `Embedding · ${currentModel.embedding_provider_kind}`
: profiles !== null && !currentModel
? "Embedding · 配置记忆整理后可自定义"
: "Embedding · 本机处理,无需额外配置",
)}
</small>
</div>
<R6Button
variant="quiet"
disabled={profiles === null || !currentModel}
aria-expanded={
configurationOpen && configurationFocus === "embedding"
}
aria-controls={
configurationFocus === "embedding"
? `${id}-embedding-configuration`
: undefined
}
onClick={() => toggleConfiguration("embedding")}
>
{configurationOpen && configurationFocus === "embedding"
? t("wb.collapse")
: text("配置")}
</R6Button>
</div>
{configurationFocus === "embedding" && (
<section
id={`${id}-embedding-configuration`}
className="r6-model-owner"
hidden={!configurationOpen}
aria-label={text("模型配置表单")}
>
{configurationRefusal && (
<p role="alert" className="r6-settings-error">
{text(
"上次模型测试或创建还待核对,配置暂时不能改动。请先在下方模型配置区核对结果; 现有配置和原文不会被改动。",
)}
</p>
)}
<ModelSettings
compactSingle
focusSection={configurationFocus}
controlsRef={modelActions}
onProfilesChanged={() =>
setModelVersion((value) => value + 1)
}
onSetupComplete={completeConfiguration}
/>
</section>
)}
</section>
</div>
{modelError && (
<R6StateRecovery
message={text(
profiles === null
? "模型状态未能读取,请重试。"
: "模型状态刷新未成功,仍显示上次确认的模型状态;请重试。",
)}
onRetry={() => setModelVersion((value) => value + 1)}
/>
</section>
)}
{currentModel && memoryMode !== "lite" && (
<div className="r6-setting-row">
<div>
<h3>{text("用模型沉淀记忆")}</h3>
<p>
{text(
"开启后,导入和同步到本机的会话会交给当前模型整理成记忆,会产生模型调用。 停止后不再调用模型、不再整理记忆,但会话仍会照常导入和保存;已排队的内容会留着,重新开启后继续。 模型配置始终保留,重新开启不需要重新填写。",
)}
</p>
</div>
<R6Switch
label={text("用模型沉淀记忆")}
checked={sedimentation === true}
disabled={
sedimentation === null ||
sedimentationPending ||
sedimentationUnavailable
}
onCheckedChange={(enabled) =>
void changeSedimentation(enabled)
}
/>
</div>
)}
{sedimentationError && (
<p role="alert" className="r6-settings-error">
{text(sedimentationError)}
</p>
)}
{sedimentationUnavailable && (
<p role="alert" className="r6-settings-error">
{text(
"当前安装还不能读取这个开关,请确认后台服务已启动后重试。",
)}
</p>
)}
</section>
<section
id={`${id}-panel-connections`}
@@ -0,0 +1,22 @@
import { readFileSync } from "node:fs";
import { expect, it } from "vitest";
function declaration(file: string, selector: string): string {
const css = readFileSync(new URL(`./${file}`, import.meta.url), "utf8");
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return css.match(new RegExp(`${escaped}\\s*\\{([^}]*)\\}`))?.[1] ?? "";
}
it("fills settings controls without widening shared fallback controls", () => {
for (const selector of [
".r6-memory-mode-settings .memory-mode-choice-list",
".r6-settings .connection-fallback .custom-harness-name",
]) {
const rule = declaration("r6-settings.css", selector);
expect(rule, selector).toMatch(/(?:^|[\s;])width:\s*100%\s*;/);
expect(rule, selector).toMatch(/(?:^|[\s;])max-width:\s*none\s*;/);
}
expect(declaration("core-visual.css", ".custom-harness-name")).toMatch(
/(?:^|[\s;])max-width:\s*420px\s*;/,
);
});
Loaded 100 of 184 files, more files were not shown because too many files have changed in this diff. Show more