Compare commits

..
Author SHA1 Message Date
kristinheandCursor f6713a4343 fix(memhub): share model probe readiness
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 03:07:59 +08:00
kristinhe1111 4cbf4f1501 feat(memhub): offer all memory modes during onboarding 2026-11-05 02:40:34 +08:00
kristinhe1111 e5959578c8 fix(memhub): let Lite onboarding complete immediately 2026-11-05 01:48:15 +08:00
kristinhe1111 df5a65797c fix(memhub): page Lite raw turns without full scans 2026-11-05 00:55:07 +08:00
kristinhe1111 2ea48e68a6 Merge pull request #326 from Tencent-Hunyuan/fix/home-recent-update-bottom-align
fix(memhub): align recent activity to card bottom
2026-11-05 00:31:55 +08:00
kristinhe1111 b8fdde7d0b Merge pull request #327 from Tencent-Hunyuan/fix/model-technical-hover
fix(web): preserve model details hover contrast
2026-11-05 00:31:36 +08:00
kristinhe1111 5134524743 Merge pull request #328 from Tencent-Hunyuan/fix/ultra-evolve-time-picker
fix(memhub): replace native evolve time input
2026-11-05 00:31:20 +08:00
kristinhe1111 e34101cb0b Merge pull request #329 from Sthreal/fix/301-model-probe-pending-state
fix(memhub): fail model probe reservations when execution never dispatches
2026-11-05 00:31:00 +08:00
kristinhe1111 995c7e6c32 Merge pull request #330 from Tencent-Hunyuan/fix/memhub-complete-log-export
fix(memhub): export complete logs from Settings
2026-11-05 00:30:38 +08:00
kristinheandCursor f1afdb03cd fix(memhub): export complete logs from settings
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 23:49:21 +08:00
Sthreal 4df4ce72bd fix(memhub): fail model probe reservations when execution never dispatches 2026-11-04 23:33:06 +08:00
kristinheandCursor a6d9894975 fix(memhub): replace native evolve time input
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 22:31:00 +08:00
kristinheandCursor 218bcf779b test: stabilize reembed merge fixture timestamps
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 22:11:46 +08:00
kristinheandCursor 7e4ee0d8ea fix(web): preserve model details hover contrast
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 21:48:08 +08:00
kristinheandCursor bce58e638d fix(memhub): align recent activity to card bottom
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 21:47:57 +08:00
feitianxiang b0e429b9ae Merge pull request #322 from Tencent-Hunyuan/docs/memhub-1.0.6-changelog
docs(changelog): complete MemHub 1.0.6 notes (build 41)
2026-11-04 20:49:02 +08:00
alvinfeiandClaude Opus 5.5 01faf28942 docs(changelog): MemHub 1.0.6 covers everything merged through build 41
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-04 20:47:19 +08:00
kristinhe1111 fb1b1373ab Merge pull request #321 from Tencent-Hunyuan/chore/memhub-build-41
chore(release): bump MemHub build to 41
2026-11-04 20:39:34 +08:00
kristinheandCursor 2823bd743d chore(release): bump MemHub build to 41
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 20:36:00 +08:00
feitianxiang 837c084be2 Merge pull request #320 from Tencent-Hunyuan/fix/memhub-db-contention
fix(memhub): bound import jobs, keep the write queue off the bulk pool, nothing blocking on the event loop
2026-11-04 20:27:32 +08:00
alvinfeiandClaude Opus 5.5 bf52724461 fix(memhub): bound import jobs, keep the write queue off the bulk pool, nothing blocking on the event loop
Under a long live-sync history, writes stalled behind "database is locked"
refusals, durable leases lapsed, and the App stopped answering for tens of
seconds at a time.

- import_jobs is bounded: finished (completed/cancelled) sync jobs are
  pruned to the newest 20 per (user, source), with their issues and skip
  counts, in bounded batches at startup and every 10 minutes. History jobs
  and every unfinished job are kept.
- Job lookups are SQL (find_jobs / latest_job) instead of reading the whole
  table, served by new indexes in schema migration 63. GET
  /api/v1/import-jobs keeps its shape and order.
- The durable write queue runs claim, settle and maintenance in its own pool
  and lease renewal in a dedicated one, so bulk work cannot starve a lease.
  The single writer and its line are unchanged.
- abandon, resume, enqueue, pause, cancel, shutdown and startup resume make
  their database calls off the loop; the file watcher resolves changed
  paths off the loop; revoke reads and writes its grant off the loop.
- The WAL cadence checkpoint is requested from the write path and run by one
  checkpointer thread instead of inline on the writing thread.
- The durable drain is supervised: an unexpected exit is reported with its
  stack and the drain restarts with capped backoff under a new lease owner.
  memory-processing and memhub status show write_drain and
  write_drain_error. Contained per-job failures are reported with stacks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-04 19:57:26 +08:00
feitianxiang 964d8610f3 Merge pull request #319 from Tencent-Hunyuan/fix/memhub-lite-raw-rag
fix(memhub): hy-memory 2.0.14; Lite search is plain RAG over raw
2026-11-04 19:42:51 +08:00
feitianxiang e1933d3307 Merge pull request #318 from Tencent-Hunyuan/release/hy-memory-2.0.14
release: hy-memory 2.0.14
2026-11-04 19:42:39 +08:00
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
alvinfeiandClaude Opus 5.5 c0910621c1 release: hy-memory 2.0.14
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-04 17:52:06 +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
145 changed files with 11412 additions and 2761 deletions

No files matched your search

+62 -9
View File
@@ -4,13 +4,14 @@ Hy Memory and MemHub are versioned and released independently.
## Unreleased
- 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.
## Hy Memory 2.0.14 — 2026-11-04
- `search()` / `async_search()` take an optional `statuses` for explicit
L1_RAW search (`layers=["l1_raw"]`, or any search in lite mode), e.g.
`statuses=["active", "shadow"]`, so raw turns a pro extraction already
shadowed can still be searched. Default stays active-only. Raw hits now
report their real `status`. Passing `statuses` to a non-raw search or to
`scene="all"` raises `ValueError` instead of being ignored.
## Hy Memory 2.0.13 — 2026-11-02
@@ -81,11 +82,63 @@ Hy Memory and MemHub are versioned and released independently.
like the other feature switches. The API answer gains `parameters`, and
`pipeline.features` can now carry the threshold as a number. Refs #241.
## MemHub 1.0.6 — 2026-11-02
## MemHub 1.0.6 — 2026-11-04
Build 39, on `hy-memory==2.0.13`. Linux x64/arm64 Runtime and macOS arm64
Build 41, on `hy-memory==2.0.14`. Linux x64/arm64 Runtime and macOS arm64
App and Runtime.
- MemHub App now exposes the complete memory-mode lifecycle. New installs
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.
- MemHub Core no longer stalls writes or freezes the App for tens of seconds
on a machine with a long live-sync history. Finished live-sync import jobs
are pruned to the newest 20 per source (at startup, in bounded batches, and
every 10 minutes); history imports and every unfinished job are kept. Job
lookups are filtered in SQL with new indexes (schema migration 63) instead
of reading the whole table. The durable write queue runs its claims,
settles and lease renewals on its own threads, so import work can no longer
starve a lease. Import abandon, resume and enqueue, and the file watcher's
path attribution, no longer touch the database or the disk on the event
loop, and the WAL cadence checkpoint runs on its own thread instead of on
the writer's. The write drain now restarts itself after an unexpected exit,
reports the failure with its stack, and shows `write_drain` /
`write_drain_error` in memory-processing and `memhub status`.
- Model Center (#293, #295): Settings splits into 界面语言 / 记忆模式 /
模型配置 pages, with separate 记忆整理 (generation) and 记忆搜索
(embedding) lanes. Changing one lane sends only that lane's patch. An
embedding change shows the switch plan, confirmation, progress, cancel and
rollback, and the search model only changes after the rebuilt index is
verified. Model panels open inside the lane that launched them and close
only after Core confirms the profile is active (#310).
- Lite keeps raw conversations (#297): lite raw writes are no longer stopped
by the "pause model sedimentation" switch, the memory page lists every
imported raw conversation by role (by turn since #316), and lite adds a
manual raw input (source `one-memory-manual`) that never calls a
generation model.
- The home page follows the memory mode (#298): lite counts raw
conversations and previews the latest one; pro and ultra read memories.
Pro and ultra now require an active generation model, in both the mode
preview and the switch.
- Onboarding (#304) asks for lite or pro and resumes after a restart (schema
migration 62); pro finishes only once a generation model is active.
- Smaller App fixes: compact home status copy (#307), clearer mode-switch
effects (#312), Ultra topic nodes are hover-only (#315), and settings
controls use the content width (#317).
- Interfaces in 11 languages: English, Simplified and Traditional Chinese,
Japanese, Spanish, German, Portuguese, French, Korean, Russian and
Indonesian. Every interface language, Simplified Chinese included, sets the
+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, memory writes that retry themselves and pause while the model provider is down, and more reliable upgrades, on **`hy-memory==2.0.13`**. 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.13`**。详见发布说明。
* *<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.13`。MemHub 源码版本为 `1.0.6`
(build 39),GitHub 仓库为
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.14`。MemHub 源码版本为 `1.0.6`
(build 41),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.13`。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.13",
"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.13
## hy-memory 2.0.14
License: Apache-2.0
+33
View File
@@ -109,6 +109,39 @@ describe("status on a paused model", () => {
});
expect(renderStatusText(report, "MemHub")).not.toContain("模型暂停");
});
it("reports a restarting write drain and its exit code", async () => {
const report = await compose({
...processing({ llm_backoff_until: null, llm_backoff_error: null }),
write_drain: "restarting",
write_drain_error: "operational_error",
});
expect(report.sections.processing).toMatchObject({
write_drain: "restarting",
write_drain_error: "operational_error",
});
const text = renderStatusText(report, "MemHub");
expect(text).toContain(
"写入队列:异常退出(operational_error),正在自动重启",
);
expect(text).not.toContain("write_drain");
});
it("says nothing about a running drain and nulls what an older Core omits", async () => {
const running = await compose({
...processing({ llm_backoff_until: null, llm_backoff_error: null }),
write_drain: "running",
write_drain_error: null,
});
expect(renderStatusText(running, "MemHub")).not.toContain("写入队列");
const older = await compose(
processing({ llm_backoff_until: null, llm_backoff_error: null }),
);
expect(older.sections.processing).toMatchObject({
write_drain: null,
write_drain_error: null,
});
});
});
describe("status and doctor on an unfinished upgrade migration", () => {
+37
View File
@@ -323,6 +323,8 @@ export function renderStatusText(
if (embeddingSwitch !== null) lines.push(`向量模型:${embeddingSwitch}`);
const backoff = modelBackoffLine(report.sections.processing);
if (backoff !== null) lines.push(backoff);
const drain = writeDrainLine(report.sections.processing);
if (drain !== null) lines.push(drain);
if (report.summary.state === "partial") {
const notes = [
report.summary.degraded.length > 0
@@ -353,6 +355,20 @@ function modelBackoffLine(section: Section | undefined): string | null {
}`;
}
/** The durable write drain, when it is not simply running: it exited on an
* error and is coming back (`restarting`), or it is not running at all. */
function writeDrainLine(section: Section | undefined): string | null {
if (section?.state !== "ok") return null;
const state = section.write_drain;
if (state !== "restarting" && state !== "stopped") return null;
const error = section.write_drain_error;
const reason =
typeof error === "string" && error.length > 0 ? `(${error})` : "";
return state === "restarting"
? `写入队列:异常退出${reason},正在自动重启`
: `写入队列:已停止${reason}`;
}
export function pipelineLine(pipeline: PipelineSummary): string {
const parts: string[] = [pipeline.mode];
if (pipeline.forced_by_env)
@@ -410,6 +426,8 @@ const SECTION_LINE_OMITTED = new Set([
"embedding_switch",
"llm_backoff_until",
"llm_backoff_error",
"write_drain",
"write_drain_error",
"content_plane",
"visible_count",
"effective_mode",
@@ -549,6 +567,7 @@ function describeProcessing(
})),
suspended: typeof summary.suspended === "number" ? summary.suspended : 0,
...llmBackoffFields(summary),
...writeDrainFields(summary),
...embeddingSwitchField(summary),
};
}
@@ -569,6 +588,24 @@ function llmBackoffFields(summary: MemoryProcessingSummary): {
};
}
/** Durable write drain liveness. Null when Core does not report it (older
* Core, or recovery has not started the drain yet). */
function writeDrainFields(summary: MemoryProcessingSummary): {
write_drain: "running" | "restarting" | "stopped" | null;
write_drain_error: string | null;
} {
const state = summary.write_drain;
const error = summary.write_drain_error;
return {
write_drain:
state === "running" || state === "restarting" || state === "stopped"
? state
: null,
write_drain_error:
typeof error === "string" && /^[a-z_]{1,64}$/.test(error) ? error : null,
};
}
/** The running switch or merge, projected; absent when none runs. */
function embeddingSwitchField(
summary: MemoryProcessingSummary,
+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.13",
"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",
+553 -97
View File
@@ -49,6 +49,7 @@ from one_memory.auth import local_api_guard, renderer_origins
from one_memory.background_work import (
in_sdk_read,
in_the_background,
in_the_write_queue,
shutdown_bulk_pool,
warm_bulk_pool,
)
@@ -164,6 +165,7 @@ from one_memory.imports.grants import (
)
from one_memory.imports.host_packages import CapabilityState, HostPackageCapabilities
from one_memory.imports.jobs import (
PRUNE_BATCH_ROWS,
ImportIssueRecord,
ImportJob,
ImportJobStore,
@@ -264,7 +266,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,
@@ -599,24 +607,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,
)
@@ -629,9 +643,12 @@ 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"
next_cursor: str | None = None
has_more: bool = False
total_exact: bool = True
#: Set when a time filter was applied in Core. For storage `raw`, `total`
#: remains the SDK count; for product `turn`, it is the filtered turn count.
time_filtered: bool = False
@@ -731,6 +748,12 @@ class MemoryProcessingSummary(BaseModel):
#: The last provider failure behind the pause, as a stable code
#: (`model_auth_failed`, `model_not_found`, `model_unreachable`, ...).
llm_backoff_error: str | None = None
#: The durable write drain, the queue's only consumer: `running`,
#: `restarting` (it exited on an error and comes back after a pause) or
#: `stopped`; None before startup recovery has started it.
write_drain: Literal["running", "restarting", "stopped"] | None = None
#: Why the drain last exited unexpectedly, as a stable code; None if never.
write_drain_error: str | None = None
class MemoryModeState(BaseModel):
@@ -1490,7 +1513,7 @@ def create_app(
# under a new SDK request id; the re-run first asks the SDK
# whether the old one landed (`execute_job`). Nothing stays
# `unknown` waiting for a person.
await in_the_background(
await in_the_write_queue(
memory_write_queue.recover_unknown,
in_flight=sdk_write_in_flight,
)
@@ -1619,6 +1642,7 @@ def create_app(
# Started by `recover_after_ready`: a running drain claims jobs and
# holds the single writer, which the rest of startup still needs.
app.state.memory_write_drain_task = None
app.state.import_job_pruning = None
# D52: every entry that already holds committed Evidence goes
# through the one durable queue. The intake turns one record into
# one complete-turn request; the drain stays the only consumer, so
@@ -1861,12 +1885,21 @@ def create_app(
app.state.embedding_switch.start_retention()
async def start_write_drain() -> None:
# Supervised: an unexpected exit is reported and the drain
# restarts, instead of stranding every queued write.
app.state.memory_write_drain_task = watched_task(
asyncio.get_running_loop(),
lambda: memory_write_drain.run_forever(),
lambda: memory_write_drain.run_supervised(),
name="one-memory-durable-write-drain",
)
async def start_import_job_pruning() -> None:
app.state.import_job_pruning = watched_task(
asyncio.get_running_loop(),
lambda: _prune_finished_import_jobs(app.state.import_job_store),
name="one-memory-import-job-pruning",
)
async def resume_graph_backfill() -> None:
saved = app.state.evolve_state_store.graph_build()
if saved.state != "running":
@@ -1937,7 +1970,7 @@ def create_app(
)
async def resume_imports() -> None:
app.state.import_supervisor.resume_interrupted()
await app.state.import_supervisor.resume_interrupted_off_loop()
async def start_live_sync() -> None:
if runtime_settings.live_sync_enabled:
@@ -1977,6 +2010,7 @@ def create_app(
await step("evolve_schedule", start_evolve_schedule)
else:
await step("pending_dispatch_replay", replay_pending_dispatch)
await step("import_job_pruning", start_import_job_pruning)
await step("import_resume", resume_imports)
await step("live_sync", start_live_sync)
await step("source_reconcile", _reconcile_source_sync_from_config)
@@ -2066,6 +2100,10 @@ def create_app(
# Recovery never reached the metadata repair step.
release_stores(runtime_settings.data_dir)
app.state.sdk_stores_held = False
if app.state.import_job_pruning is not None:
app.state.import_job_pruning.cancel()
with suppress(asyncio.CancelledError):
await app.state.import_job_pruning
await app.state.import_supervisor.shutdown()
app.state.memory_write_drain.stop()
if app.state.memory_write_drain_task is not None:
@@ -2526,6 +2564,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,
@@ -2789,17 +2842,16 @@ def create_app(
await _refuse_overlapping_root(source_id, registration.candidate.path, DEFAULT_USER_ID)
root_changed = prior is not None and prior.root_id != request.expected_root_id
if root_changed:
source_jobs = [job for job in _job_store(app).list_jobs() if job.source_id == source_id]
source_jobs = await asyncio.to_thread(
partial(
_job_store(app).find_jobs,
source_id=source_id,
statuses=_UNFINISHED_IMPORT_STATUSES,
)
)
if any(job.status is ImportStatus.RUNNING for job in source_jobs):
raise HTTPException(status_code=409, detail="source_import_busy")
for job in source_jobs:
if job.status not in {
ImportStatus.PENDING,
ImportStatus.PAUSED,
ImportStatus.INTERRUPTED,
ImportStatus.FAILED,
}:
continue
await _supervisor(app).cancel(job.job_id)
try:
receipt = await asyncio.to_thread(
@@ -2978,21 +3030,18 @@ def create_app(
user's own cursors and history job.
"""
await _live_sync(app).initialize_source(source_id, baseline_missing_files=True)
active = {
ImportStatus.PENDING,
ImportStatus.PAUSED,
ImportStatus.INTERRUPTED,
ImportStatus.RUNNING,
ImportStatus.FAILED,
}
if any(
lane_key(job.user_id, job.source_id) == source_id
and job.operation == "history"
and job.status in active
for job in _job_store(app).list_jobs()
):
return
user_id, bare_source_id = split_lane(source_id)
active_history = await asyncio.to_thread(
partial(
_job_store(app).latest_job,
user_id=user_id,
source_id=bare_source_id,
operation="history",
statuses=_UNFINISHED_IMPORT_STATUSES,
)
)
if active_history is not None:
return
await asyncio.to_thread(
_job_store(app).create,
user_id=user_id,
@@ -3002,18 +3051,25 @@ def create_app(
async def _activate_source_resume(source_id: str) -> None:
"""Run work prepared for a source after config.json has committed."""
user_id, bare_source_id = split_lane(source_id)
try:
for job in _job_store(app).list_jobs():
if lane_key(job.user_id, job.source_id) != source_id:
continue
for job in await asyncio.to_thread(
partial(
_job_store(app).find_jobs,
user_id=user_id,
source_id=bare_source_id,
statuses=(
ImportStatus.PENDING,
ImportStatus.PAUSED,
ImportStatus.INTERRUPTED,
ImportStatus.FAILED,
),
)
):
if job.status is ImportStatus.PENDING:
_supervisor(app).enqueue(job.job_id)
elif job.status in {
ImportStatus.PAUSED,
ImportStatus.INTERRUPTED,
ImportStatus.FAILED,
}:
_supervisor(app).resume(job.job_id)
await _supervisor(app).enqueue_off_loop(job.job_id, job=job)
else:
await _supervisor(app).resume_off_loop(job.job_id)
except SourceAccessError as error:
raise HTTPException(status_code=409, detail=error.code) from None
if app.state.settings.automatic_memory_processing:
@@ -3040,22 +3096,19 @@ def create_app(
_require_registration(app, source_id)
if user_id != DEFAULT_USER_ID:
return await _revoke_other_user_grant(source_id, user_id)
receipt = _grant_store(app).receipt(
user_id="local",
source_id=source_id,
receipt = await asyncio.to_thread(
partial(_grant_store(app).receipt, user_id="local", source_id=source_id)
)
if receipt is None:
raise HTTPException(status_code=404, detail="grant_not_found")
active_statuses = {
ImportStatus.PENDING,
ImportStatus.RUNNING,
ImportStatus.PAUSED,
ImportStatus.INTERRUPTED,
ImportStatus.FAILED,
}
for job in _job_store(app).list_jobs():
if job.source_id == source_id and job.status in active_statuses:
await _supervisor(app).cancel(job.job_id)
for job in await asyncio.to_thread(
partial(
_job_store(app).find_jobs,
source_id=source_id,
statuses=_UNFINISHED_IMPORT_STATUSES,
)
):
await _supervisor(app).cancel(job.job_id)
# Revocation is the user's node for "stop using this source" (D73):
# park unfinished extraction instead of leaving it to fail later, so
# re-authorizing resumes the same work.
@@ -3067,9 +3120,8 @@ def create_app(
await _live_sync(app).stop_source(source_id)
_supervisor(app).unregister_adapter(source_id)
_detection(app).pop(source_id, None)
receipt = _grant_store(app).revoke(
user_id="local",
source_id=source_id,
receipt = await asyncio.to_thread(
partial(_grant_store(app).revoke, user_id="local", source_id=source_id)
)
await asyncio.to_thread(_config_store(app).set_sync, source_id, False)
await _refresh_live_watch(app)
@@ -3085,16 +3137,15 @@ def create_app(
)
if receipt is None:
raise HTTPException(status_code=404, detail="grant_not_found")
active_statuses = {
ImportStatus.PENDING,
ImportStatus.RUNNING,
ImportStatus.PAUSED,
ImportStatus.INTERRUPTED,
ImportStatus.FAILED,
}
for job in _job_store(app).list_jobs():
if lane_key(job.user_id, job.source_id) == lane and job.status in active_statuses:
await _supervisor(app).cancel(job.job_id)
for job in await asyncio.to_thread(
partial(
_job_store(app).find_jobs,
user_id=user_id,
source_id=source_id,
statuses=_UNFINISHED_IMPORT_STATUSES,
)
):
await _supervisor(app).cancel(job.job_id)
await _live_sync(app).stop_source(lane)
_supervisor(app).unregister_adapter(lane)
receipt = await asyncio.to_thread(
@@ -3159,7 +3210,7 @@ def create_app(
)
except SourceAccessError as error:
raise HTTPException(status_code=409, detail=error.code) from None
_supervisor(app).enqueue(job.job_id)
await _supervisor(app).enqueue_off_loop(job.job_id, job=job)
return job
@app.post(
@@ -3454,8 +3505,10 @@ def create_app(
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
) -> list[ImportJob]:
# One user's jobs: the App reads `local`'s and must not show another
# user's Hermes import as its own.
return [job for job in _job_store(app).list_jobs() if job.user_id == user_id]
# user's Hermes import as its own. Every one of them, oldest first, as
# before; finished live syncs past the newest twenty per source are
# pruned (`prune_finished_sync_jobs`), so this list stays short.
return _job_store(app).find_jobs(user_id=user_id)
@app.get(
"/api/v1/live-sync",
@@ -3552,7 +3605,7 @@ def create_app(
dependencies=protected,
)
async def pause_import_job(job_id: str) -> ImportJob:
_require_job(app, job_id)
await asyncio.to_thread(_require_job, app, job_id)
return await _translate_transition(_supervisor(app).pause(job_id))
@app.post(
@@ -3561,11 +3614,11 @@ def create_app(
dependencies=protected,
)
async def resume_import_job(job_id: str) -> ImportJob:
job = _require_job(app, job_id)
job = await asyncio.to_thread(_require_job, app, job_id)
if job.status in {ImportStatus.COMPLETED, ImportStatus.CANCELLED}:
raise HTTPException(status_code=409, detail="job_not_resumable")
try:
return _supervisor(app).resume(job_id)
return await _supervisor(app).resume_off_loop(job_id)
except InvalidJobTransitionError:
raise HTTPException(409, "invalid_job_transition") from None
@@ -3575,7 +3628,7 @@ def create_app(
dependencies=protected,
)
async def cancel_import_job(job_id: str) -> ImportJob:
_require_job(app, job_id)
await asyncio.to_thread(_require_job, app, job_id)
return await _translate_transition(_supervisor(app).cancel(job_id))
@app.get(
@@ -4421,6 +4474,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,
@@ -4430,6 +4493,9 @@ 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"),
paging: Literal["offset", "cursor"] = Query(default="offset"),
cursor: str | None = Query(default=None, max_length=160),
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
exclude_sources: list[str] | None = Query(default=None), # noqa: B008
session_id: list[str] | None = Query(default=None), # noqa: B008
@@ -4443,7 +4509,10 @@ 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`.
@@ -4452,29 +4521,352 @@ def create_app(
_library_values(statuses or [], MEMORY_STATUSES, "memory_status_unknown")
or raw_list_filters()["statuses"]
)
if paging == "cursor" and unit != "turn":
raise HTTPException(status_code=422, detail="raw_cursor_requires_turn_unit")
if cursor is not None and paging != "cursor":
raise HTTPException(status_code=422, detail="raw_cursor_requires_cursor_paging")
if paging == "cursor" and (offset or after is not None or before is not None):
raise HTTPException(status_code=422, detail="raw_cursor_filter_unsupported")
raw_cursor = (0, 0, 0, 0, "", "")
if cursor:
parts = cursor.split(":")
if (
len(parts) != 7
or parts[0] != "turn"
or any(re.fullmatch(r"[0-9]+", part) is None for part in parts[1:5])
or re.fullmatch(r"(?:p?[0-9a-f]{16})?", parts[5]) is None
or re.fullmatch(r"[0-9a-f]{16}", parts[6]) is None
):
raise HTTPException(status_code=422, detail="raw_cursor_invalid")
raw_cursor = (
int(parts[1]),
int(parts[2]),
int(parts[3]),
int(parts[4]),
parts[5],
parts[6],
)
if any(value > 100_000_000 for value in raw_cursor[:4]):
raise HTTPException(status_code=422, detail="raw_cursor_invalid")
sources = split_csv(source)
excluded = split_csv(exclude_sources)
sessions = split_csv(session_id)
cursor_scope = hashlib.sha256(
json.dumps(
{
"user_id": user_id,
"statuses": sorted(chosen_statuses),
"sources": sorted(sources),
"excluded": sorted(excluded),
"sessions": sorted(sessions),
"order": order,
},
sort_keys=True,
separators=(",", ":"),
).encode()
).hexdigest()[:16]
if cursor and raw_cursor[5] != cursor_scope:
raise HTTPException(status_code=422, detail="raw_cursor_scope_mismatch")
try:
client = await _sdk_client_handle(app).client()
value = await asyncio.to_thread(
client.list_memories,
user_id=user_id,
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,
offset=offset,
limit=limit,
order=order,
)
page = HySdkRawPage.from_memory_list(value, limit=limit, offset=offset)
"include_sources": sources or None,
"exclude_sources": excluded or None,
"session_ids": sessions or None,
"order": order if unit == "raw" or paging == "cursor" else "desc",
}
if unit == "turn" and paging == "cursor":
(
raw_offset,
turn_offset,
emitted,
previous_total,
expected_raw,
_scope,
) = raw_cursor
value = await asyncio.to_thread(
client.list_memories,
**query,
offset=raw_offset,
limit=min(limit + 1, 500),
)
raw_page = HySdkRawPage.from_memory_list(
value, limit=min(limit + 1, 500), offset=raw_offset
)
def raw_anchor(record: HySdkRawRecord) -> str:
return hashlib.sha256(
f"{record.raw_id}\0{record.content}".encode()
).hexdigest()[:16]
if cursor:
if raw_page.total < previous_total:
raise HTTPException(status_code=409, detail="raw_cursor_stale")
observed_total = raw_page.total
added = raw_page.total - previous_total
if added and order == "desc":
raw_offset += added
value = await asyncio.to_thread(
client.list_memories,
**query,
offset=raw_offset,
limit=min(limit + 1, 500),
)
raw_page = HySdkRawPage.from_memory_list(
value,
limit=min(limit + 1, 500),
offset=raw_offset,
)
if raw_page.total != observed_total:
raise HTTPException(
status_code=409, detail="raw_cursor_stale"
)
if expected_raw.startswith("p"):
if raw_offset == 0:
raise HTTPException(
status_code=409, detail="raw_cursor_stale"
)
previous_value = await asyncio.to_thread(
client.list_memories,
**query,
offset=raw_offset - 1,
limit=1,
)
previous_page = HySdkRawPage.from_memory_list(
previous_value, limit=1, offset=raw_offset - 1
)
valid_anchor = (
previous_page.total == raw_page.total
and bool(previous_page.raw)
and raw_anchor(previous_page.raw[0])
== expected_raw[1:]
)
else:
valid_anchor = (
bool(raw_page.raw)
and raw_anchor(raw_page.raw[0]) == expected_raw
)
if expected_raw and not valid_anchor:
raise HTTPException(status_code=409, detail="raw_cursor_stale")
selected_turns: list[tuple[HySdkRawRecord, RawDisplayTurn]] = []
next_cursor: str | None = None
for record_index, record in enumerate(raw_page.raw):
turns = raw_display_turns(record)
ordered_turns = (
tuple(reversed(turns)) if order == "desc" else turns
)
start = turn_offset if record_index == 0 else 0
for index in range(start, len(ordered_turns)):
selected_turns.append((record, ordered_turns[index]))
if len(selected_turns) < limit:
continue
absolute_raw = raw_offset + record_index
next_raw = absolute_raw
next_turn = index + 1
if next_turn >= len(ordered_turns):
next_raw += 1
next_turn = 0
if next_raw < raw_page.total:
if next_turn:
anchor_record = record
else:
next_index = record_index + 1
anchor_record = (
raw_page.raw[next_index]
if next_index < len(raw_page.raw)
else None
)
anchor = (
raw_anchor(anchor_record)
if anchor_record is not None
else f"p{raw_anchor(record)}"
)
next_cursor = (
f"turn:{next_raw}:{next_turn}:"
f"{emitted + len(selected_turns)}:{raw_page.total}:"
f"{anchor}:{cursor_scope}"
)
break
if len(selected_turns) >= limit:
break
if (
next_cursor is None
and raw_offset + len(raw_page.raw) < raw_page.total
):
if not raw_page.raw:
raise HTTPException(
status_code=409, detail="raw_cursor_stale"
)
next_raw = raw_offset + len(raw_page.raw)
previous_anchor = raw_anchor(raw_page.raw[-1])
next_cursor = (
f"turn:{next_raw}:0:{emitted + len(selected_turns)}:"
f"{raw_page.total}:p{previous_anchor}:{cursor_scope}"
)
return SdkRawPage(
user_id=user_id,
items=tuple(
SdkRawItem.of(record, turn)
for record, turn in selected_turns
),
# Cursor consumers do not use a precise total. This lower
# bound preserves the legacy numeric field without forcing
# a full scan merely to count variable-size turn batches.
total=emitted + len(selected_turns) + (1 if next_cursor else 0),
offset=emitted,
limit=limit,
unit="turn",
next_cursor=next_cursor,
has_more=next_cursor is not None,
total_exact=False,
)
if unit == "raw":
value = await asyncio.to_thread(
client.list_memories,
**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
@@ -4489,6 +4881,7 @@ def create_app(
total=page.total,
offset=offset,
limit=limit,
unit="raw",
time_filtered=time_filtered,
)
@@ -4685,6 +5078,7 @@ def create_app(
running_rows = counts.get(MemoryTaskStatus.RUNNING, 0)
backoff = cast(ModelBackoff | None, getattr(app.state, "model_backoff", None))
backoff_state = backoff.state() if backoff is not None else None
drain = getattr(app.state, "memory_write_drain", None)
return MemoryProcessingSummary(
pending=(
sdk_counts.get("pending", 0)
@@ -4732,6 +5126,8 @@ def create_app(
if backoff_state is not None and backoff_state.until is not None
else None
),
write_drain=getattr(drain, "liveness", None),
write_drain_error=getattr(drain, "last_error_code", None),
)
@app.get(
@@ -5529,6 +5925,16 @@ def _diagnostic_request_details(request: Request, event: str | None) -> dict[str
return details
#: Every state a job can still leave: the ones a revoke or re-root cancels.
_UNFINISHED_IMPORT_STATUSES = (
ImportStatus.PENDING,
ImportStatus.RUNNING,
ImportStatus.PAUSED,
ImportStatus.INTERRUPTED,
ImportStatus.FAILED,
)
def _require_job(app: FastAPI, job_id: str) -> ImportJob:
try:
return _job_store(app).require(job_id)
@@ -5803,6 +6209,54 @@ def _backfill_state(
return "running"
#: How often finished live syncs are pruned while the Core runs, and how many
#: bounded batches one such pass may take (`prune_finished_sync_jobs`).
IMPORT_JOB_PRUNE_SECONDS = 600.0
IMPORT_JOB_PRUNE_PASS_BATCHES = 10
async def _prune_finished_import_jobs(
store: ImportJobStore,
*,
interval_seconds: float = IMPORT_JOB_PRUNE_SECONDS,
pass_batches: int = IMPORT_JOB_PRUNE_PASS_BATCHES,
) -> None:
"""Keep `import_jobs` bounded: at startup down to the keep count, then on a timer.
Every batch is its own short write transaction, made off the loop, with a
pause between batches so the writer's line keeps moving. The first pass
runs until nothing is left, which on a home that grew for weeks is the
one long pass; after it each pass is at most `pass_batches` batches.
A busy database skips the rest of a pass rather than failing it.
"""
first = True
while True:
removed_total = 0
batches = 0
while first or batches < pass_batches:
try:
removed = await in_the_background(store.prune_finished_sync_jobs)
except sqlite3.OperationalError as error:
if "locked" not in str(error).lower() and "busy" not in str(error).lower():
raise
logger.info("import_jobs_prune_deferred error_type=%s", type(error).__name__)
break
batches += 1
removed_total += removed
if removed < PRUNE_BATCH_ROWS:
break
await asyncio.sleep(0.05)
if removed_total:
logger.info(
"import_jobs_pruned removed=%d batches=%d startup=%s",
removed_total,
batches,
first,
)
first = False
await asyncio.sleep(interval_seconds)
async def _keep_loop_heartbeat(watchdog: LoopWatchdog) -> None:
"""The loop saying it is still able to run something."""
# The watchdog already logs each stall with a stack, but a stack is prose:
@@ -6202,7 +6656,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:
@@ -6229,6 +6683,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 {}),
},
)
@@ -6843,12 +7299,12 @@ def _history_import_gate_released(app: FastAPI) -> bool:
app.state.history_import_gate_released = True
return True
selected = set(state.selected_source_ids)
# Asked inside the drain's claim, per candidate: history jobs of `local`
# only, in SQL, never the whole table.
matching = [
job
for job in jobs.list_jobs()
if job.operation == "history"
and job.user_id == DEFAULT_USER_ID
and job.source_id in selected
for job in jobs.find_jobs(user_id=DEFAULT_USER_ID, operation="history")
if job.source_id in selected
and (state.started_at is None or job.created_at >= state.started_at)
]
if {job.source_id for job in matching} != selected:
@@ -20,6 +20,15 @@ SOURCE_READER_WORKERS = 4
SDK_WRITE_WORKERS = 12
# Recall's SDK reads: one search and the `get`s behind it (`_SDK_GET_CONCURRENCY`).
SDK_READ_WORKERS = 8
# The durable write queue's own bookkeeping: claim, settle, the per-job reads
# around an SDK write, and the drain's maintenance passes. Each is one short
# statement on the single writer or a reader; the drain runs at most
# `max_concurrent_sources` jobs, and they wait for the writer, not for CPU.
DURABLE_QUEUE_WORKERS = 4
# Lease renewal alone. Renewal is the drain's ownership fence: a lease that
# lapses while its SDK write is still running hands the job to recovery, so it
# must never wait for a thread behind any other work, its own queue's included.
LEASE_RENEWAL_WORKERS = 2
_pool: ThreadPoolExecutor | None = None
_snapshot_pool: ThreadPoolExecutor | None = None
@@ -28,6 +37,8 @@ _raw_read_pool: ThreadPoolExecutor | None = None
_memory_read_pool: ThreadPoolExecutor | None = None
_source_reader_pool: ThreadPoolExecutor | None = None
_sdk_write_pool: ThreadPoolExecutor | None = None
_durable_queue_pool: ThreadPoolExecutor | None = None
_lease_renewal_pool: ThreadPoolExecutor | None = None
_warmed_readers: weakref.WeakSet[ThreadPoolExecutor] = weakref.WeakSet()
_reader_warm_lock = threading.Lock()
_interactive_reads = 0
@@ -109,6 +120,35 @@ def sdk_write_pool() -> ThreadPoolExecutor:
return _sdk_write_pool
def durable_queue_pool() -> ThreadPoolExecutor:
"""The durable write queue's database work, apart from bulk work.
The queue used to hand its claims, settles and maintenance to the bulk
pool, whose three threads also carry import heartbeats and job listings.
Under an import peak all three sat in those for tens of seconds, and the
queue's next statement waited for a thread before it could even join the
writer's line, and leases lapsed while their SDK writes were still running.
"""
global _durable_queue_pool
if _durable_queue_pool is None:
_durable_queue_pool = ThreadPoolExecutor(
max_workers=DURABLE_QUEUE_WORKERS,
thread_name_prefix="one-memory-write-queue",
)
return _durable_queue_pool
def lease_renewal_pool() -> ThreadPoolExecutor:
"""Threads that only renew durable write leases (`DurableWriteDrain.renew`)."""
global _lease_renewal_pool
if _lease_renewal_pool is None:
_lease_renewal_pool = ThreadPoolExecutor(
max_workers=LEASE_RENEWAL_WORKERS,
thread_name_prefix="one-memory-lease-renewal",
)
return _lease_renewal_pool
def source_reader_pool() -> ThreadPoolExecutor:
"""Bounded process-wide pool for source iteration and parsing."""
global _source_reader_pool
@@ -128,9 +168,21 @@ def warm_bulk_pool() -> None:
Starting a thread is nothing on an idle machine and 2.5 seconds on a busy
one, which is precisely when bulk work is being handed over. So it is
done here instead, at startup, before there is anything to be late for.
The durable write queue's pools are warmed here too, for the same reason:
their first renewal must not pay for starting a thread.
"""
pool = bulk_pool()
started = tuple(threading.Event() for _ in range(BULK_WORKERS))
_start_every_worker(bulk_pool(), BULK_WORKERS, "bulk_pool_warmup_timeout")
_start_every_worker(
durable_queue_pool(), DURABLE_QUEUE_WORKERS, "durable_queue_pool_warmup_timeout"
)
_start_every_worker(
lease_renewal_pool(), LEASE_RENEWAL_WORKERS, "lease_renewal_pool_warmup_timeout"
)
def _start_every_worker(pool: ThreadPoolExecutor, workers: int, timeout_code: str) -> None:
started = tuple(threading.Event() for _ in range(workers))
release = threading.Event()
def occupy(marker: threading.Event) -> None:
@@ -146,7 +198,7 @@ def warm_bulk_pool() -> None:
try:
for marker in started:
if not marker.wait(10):
raise RuntimeError("bulk_pool_warmup_timeout")
raise RuntimeError(timeout_code)
finally:
release.set()
for future in futures:
@@ -228,6 +280,38 @@ async def in_sdk_write[T](
)
async def in_the_write_queue[T](
work: Callable[..., T],
/,
*args: object,
**kwargs: object,
) -> T:
"""One durable write queue statement, in the queue's pool (`durable_queue_pool`).
It still goes through the one writer and its line, like every write; what
it no longer does is wait for a bulk thread first.
"""
return await asyncio.get_running_loop().run_in_executor(
durable_queue_pool(),
copy_context().run,
partial(work, *args, **kwargs),
)
async def in_lease_renewal[T](
work: Callable[..., T],
/,
*args: object,
**kwargs: object,
) -> T:
"""One lease renewal, in the threads kept for nothing else (`lease_renewal_pool`)."""
return await asyncio.get_running_loop().run_in_executor(
lease_renewal_pool(),
copy_context().run,
partial(work, *args, **kwargs),
)
def _retrieve[T](future: "asyncio.Future[T]") -> None:
"""Mark a shielded read's outcome as seen.
@@ -314,7 +398,13 @@ def _warm_reader_pool(reader: ThreadPoolExecutor) -> None:
def shutdown_bulk_pool() -> None:
global _pool, _snapshot_pool, _interactive_pool, _raw_read_pool, _memory_read_pool
global _source_reader_pool, _sdk_write_pool
global _source_reader_pool, _sdk_write_pool, _durable_queue_pool, _lease_renewal_pool
if _durable_queue_pool is not None:
_durable_queue_pool.shutdown(wait=False, cancel_futures=True)
_durable_queue_pool = None
if _lease_renewal_pool is not None:
_lease_renewal_pool.shutdown(wait=False, cancel_futures=True)
_lease_renewal_pool = None
if _sdk_write_pool is not None:
_sdk_write_pool.shutdown(wait=False, cancel_futures=True)
_sdk_write_pool = None
@@ -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
@@ -242,6 +242,75 @@ class _InTurn:
self._line.remove(place)
class _Checkpointer:
"""The one thread that runs cadence checkpoints, for every main database.
A database asks for at most one checkpoint at a time: a request made
while one is already waiting is the same request. The checkpoint goes
through the database's own writer, in its line, like any other write.
A waiting request holds the database weakly: it must not keep alive a
database nobody else holds any more.
"""
def __init__(self) -> None:
self._state = threading.Condition()
self._due: OrderedDict[int, weakref.ref[MainDatabase]] = OrderedDict()
self._running = 0
self._thread: threading.Thread | None = None
def request(self, database: "MainDatabase") -> None:
with self._state:
self._due.setdefault(id(database), weakref.ref(database))
if self._thread is None or not self._thread.is_alive():
self._thread = threading.Thread(
target=self._run,
name="one-memory-checkpoint",
daemon=True,
)
self._thread.start()
self._state.notify_all()
def forget(self, database: "MainDatabase") -> None:
with self._state:
self._due.pop(id(database), None)
self._state.notify_all()
def wait_idle(self, timeout: float) -> bool:
"""Whether every requested checkpoint has run within `timeout` seconds."""
with self._state:
return self._state.wait_for(lambda: not self._due and not self._running, timeout)
def _run(self) -> None:
while True:
with self._state:
self._state.wait_for(lambda: bool(self._due))
_key, reference = self._due.popitem(last=False)
database = reference()
if database is None:
self._state.notify_all()
continue
self._running += 1
try:
database._cadence_checkpoint()
except Exception: # noqa: BLE001 - maintenance must not end the thread
logger.warning(
"main_database_checkpoint_failed file=%s", database.path.name, exc_info=True
)
finally:
del database
with self._state:
self._running -= 1
self._state.notify_all()
_checkpointer = _Checkpointer()
def wait_for_cadence_checkpoints(timeout: float = 5.0) -> bool:
"""Wait for the cadence checkpoints already requested; for tests and shutdown."""
return _checkpointer.wait_idle(timeout)
def _locked() -> sqlite3.OperationalError:
# Callers tell a busy database from a broken one by these, as they would
# for SQLite's own refusal.
@@ -614,13 +683,23 @@ class MainDatabase:
return None
def _maybe_checkpoint_after_write(self) -> None:
"""Checkpoint on a write cadence without opening another connection.
"""Count a write, and hand a due cadence checkpoint to the checkpointer.
SQLite's default autocheckpoint only runs when the writer itself
crosses the page threshold. Production hit a state where the WAL grew
to 13GB, so the main database needs its own bounded, observable
maintenance path. This runs after the caller's checkout has been reset
and only re-enters the same serialized writer queue.
maintenance path: every `checkpoint_write_interval()` writes or
`MAIN_DATABASE_CHECKPOINT_SECONDS`, whichever comes first.
The checkpoint itself used to run right here, on the thread that had
just written, as one more turn in the writer's line. That thread was
an import heartbeat or a durable lease renewal as often as not, and
it then waited behind the whole line a second time before it could
return. The idle TRUNCATE (#278) cannot replace the cadence: under a
sustained import the database is never idle, and that is exactly when
the WAL grows. So the cadence stays, and one checkpointer thread runs
it (`_Checkpointer`); the writer stays the only connection that
writes, and no caller waits for maintenance it did not ask for.
"""
now = time.monotonic()
with self._state:
@@ -632,8 +711,12 @@ class MainDatabase:
if due:
self._writes_since_checkpoint = 0
self._last_checkpoint_at = now
if not due:
return
if due:
_checkpointer.request(self)
def _cadence_checkpoint(self) -> None:
"""One PASSIVE checkpoint through the writer, and the WAL alert after it."""
now = time.monotonic()
try:
busy, log_frames, checkpointed = self._checkpoint_from_maintenance("PASSIVE")
except sqlite3.Error:
@@ -707,6 +790,8 @@ class MainDatabase:
def close(self) -> None:
"""Close every idle connection now; ones in use close when handed back."""
# A checkpoint still waiting would open the writer again after this.
_checkpointer.forget(self)
with self._state:
self._generation += 1
self._identity = None
@@ -14,7 +14,7 @@ from pathlib import Path
from typing import Literal, cast
HY_MEMORY_DISTRIBUTION = "hy-memory"
HY_MEMORY_VERSION = "2.0.13"
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.13 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
@@ -1,6 +1,6 @@
import hashlib
import os
from collections.abc import AsyncIterator, Iterator
from collections.abc import AsyncIterator, Callable, Iterator
from dataclasses import dataclass
from pathlib import Path
@@ -28,6 +28,7 @@ def discovered_off_loop(
suffixes: frozenset[str],
max_targets: int,
excluded_directory_names: frozenset[str] = frozenset(),
directory_predicate: Callable[[Path], bool] | None = None,
) -> AsyncIterator[SourceFileDiscoveryEvent]:
"""The same walk, for callers that have an event loop to keep free.
@@ -43,6 +44,7 @@ def discovered_off_loop(
suffixes=suffixes,
max_targets=max_targets,
excluded_directory_names=excluded_directory_names,
directory_predicate=directory_predicate,
),
scope_key=grant.source_id,
)
@@ -54,6 +56,7 @@ def discover_source_files(
suffixes: frozenset[str],
max_targets: int,
excluded_directory_names: frozenset[str] = frozenset(),
directory_predicate: Callable[[Path], bool] | None = None,
) -> Iterator[SourceFileDiscoveryEvent]:
"""Metadata only: one lstat per candidate, no open and no per-file grant lookup.
@@ -71,10 +74,16 @@ def discover_source_files(
grant.root,
followlinks=False,
):
current = Path(directory)
child_directories[:] = sorted(
child
for child in child_directories
if not (Path(directory) / child).is_symlink() and child not in excluded_directory_names
if not (current / child).is_symlink()
and child not in excluded_directory_names
and (
directory_predicate is None
or directory_predicate((current / child).relative_to(grant.root))
)
)
for filename in sorted(filenames):
if Path(filename).suffix.lower() not in suffixes:
+188 -8
View File
@@ -1,6 +1,7 @@
import logging
import re
import sqlite3
from collections.abc import Iterable
from contextlib import AbstractContextManager, nullcontext
from datetime import UTC, datetime
from enum import StrEnum
@@ -18,13 +19,27 @@ from one_memory.imports.adapters import ScanPhase, SourceCursor
#: the drift would only surface on a fresh database.
#:
#: This runs on SQLite today, so the SQLite rendering is used.
from one_memory.memory_server.migrations import import_jobs_ddl
from one_memory.memory_server.migrations import (
import_job_lookup_indexes_ddl,
import_jobs_ddl,
)
logger = logging.getLogger("one_memory.imports.jobs")
JOB_SCHEMA = import_jobs_ddl("sqlite")
#: Migration 63, applied by the store as well so a running home gets it.
JOB_LOOKUP_INDEXES = import_job_lookup_indexes_ddl("sqlite")
MAX_LIVE_BACKLOG_BYTES = 256 * 1024
#: Finished live syncs kept per (user, source). Live sync opens a job for every
#: change it imports, and nothing removed them: hundreds a day on an active
#: machine. The App shows the latest job per source and the active ones; twenty is
#: that plus enough recent history to read a failure back.
SYNC_JOBS_KEPT_PER_SOURCE = 20
#: Rows one pruning transaction may delete, so it holds the single writer for
#: a few milliseconds at most however large the table grew.
PRUNE_BATCH_ROWS = 200
class ImportStatus(StrEnum):
PENDING = "pending"
@@ -36,6 +51,12 @@ class ImportStatus(StrEnum):
CANCELLED = "cancelled"
#: States no job ever leaves (`ImportJobStore._TRANSITIONS`). Failed and
#: interrupted are not among them: both can be resumed, and both still hold the
#: source's one active slot.
FINISHED_STATUSES = (ImportStatus.COMPLETED, ImportStatus.CANCELLED)
class ImportJob(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
@@ -272,6 +293,9 @@ class ImportJobStore:
WHERE status IN ('pending', 'running', 'paused', 'interrupted', 'failed')
"""
)
for statement in JOB_LOOKUP_INDEXES.split(";"):
if statement.strip():
connection.execute(statement)
def create(
self,
@@ -334,8 +358,8 @@ class ImportJobStore:
read again.
"""
opened: list[ImportJob] = []
for job in self.list_jobs(status=ImportStatus.FAILED):
if job.operation != "history" or job.stored != 0:
for job in self.find_jobs(operation="history", statuses=(ImportStatus.FAILED,)):
if job.stored != 0:
continue
reasons = {issue.reason for issue in self.list_issues(job.job_id)}
if not reasons or not reasons <= self._UNPUBLISHED_SNAPSHOT_FAILURES:
@@ -799,16 +823,172 @@ class ImportJobStore:
return self.require(job_id)
def list_jobs(self, *, status: ImportStatus | None = None) -> list[ImportJob]:
"""Every job, oldest first. Hot paths ask `find_jobs` a narrower question."""
return self.find_jobs(statuses=(status,) if status is not None else None)
def find_jobs(
self,
*,
user_id: str | None = None,
source_id: str | None = None,
operation: Literal["history", "sync"] | None = None,
statuses: Iterable[ImportStatus] | None = None,
newest_first: bool = False,
limit: int | None = None,
) -> list[ImportJob]:
"""The jobs matching every given filter, oldest first unless asked otherwise.
The filters are SQL, served by `import_jobs_lane` / `import_jobs_status`
(migration 63). Reading the whole table and filtering here was what
three bulk threads and three request threads were all inside, for tens
of seconds, while the loop waited on them.
"""
clauses: list[str] = []
parameters: list[str | int] = []
if user_id is not None:
clauses.append("user_id = ?")
parameters.append(user_id)
if source_id is not None:
clauses.append("source_id = ?")
parameters.append(source_id)
if operation is not None:
clauses.append("operation = ?")
parameters.append(operation)
if statuses is not None:
wanted = tuple(dict.fromkeys(ImportStatus(status) for status in statuses))
if not wanted:
return []
clauses.append(f"status IN ({', '.join('?' for _ in wanted)})")
parameters.extend(wanted)
query = "SELECT * FROM import_jobs"
parameters: tuple[str, ...] = ()
if status is not None:
query += " WHERE status = ?"
parameters = (status,)
query += " ORDER BY created_at, job_id"
if clauses:
query += " WHERE " + " AND ".join(clauses)
query += (
" ORDER BY created_at DESC, job_id DESC"
if newest_first
else " ORDER BY created_at, job_id"
)
if limit is not None:
if limit < 1:
raise ValueError("limit must be positive")
query += " LIMIT ?"
parameters.append(limit)
with self._connect() as connection:
rows = connection.execute(query, parameters).fetchall()
return [self._to_job(row) for row in rows]
def latest_job(
self,
*,
user_id: str,
source_id: str,
operation: Literal["history", "sync"] | None = None,
statuses: Iterable[ImportStatus] | None = None,
) -> ImportJob | None:
jobs = self.find_jobs(
user_id=user_id,
source_id=source_id,
operation=operation,
statuses=statuses,
newest_first=True,
limit=1,
)
return jobs[0] if jobs else None
def prune_finished_sync_jobs(
self,
*,
keep: int = SYNC_JOBS_KEPT_PER_SOURCE,
max_rows: int = PRUNE_BATCH_ROWS,
) -> int:
"""Delete finished live-sync jobs beyond the newest `keep` of each source.
One bounded transaction: at most `max_rows` jobs, with their issues and
skip counts. Returns how many jobs went; fewer than `max_rows` means
nothing is left to prune, so calling again until then finishes the job.
Only `sync` jobs in a state nothing leaves (completed, cancelled) are
ever deleted. Failed and interrupted jobs hold the source's active slot
and can be resumed; history jobs are named by connection receipts, by
the onboarding gate and by the first-import review, and there are a
handful per source, so they are all kept. Cursors live in their own
tables keyed by file, not by job, so a pruned job takes no position
with it. The newest job of every source is always among those kept,
which is the one the App and live sync health name.
"""
if keep < 1:
raise ValueError("keep must be positive")
if max_rows < 1:
raise ValueError("max_rows must be positive")
finished = tuple(FINISHED_STATUSES)
placeholders = ", ".join("?" for _ in finished)
deleted = 0
with self._connect(writing=True) as connection:
lanes = connection.execute(
f"""
SELECT user_id, source_id, COUNT(*) AS finished
FROM import_jobs
WHERE operation = 'sync' AND status IN ({placeholders})
GROUP BY user_id, source_id
HAVING COUNT(*) > ?
""",
(*finished, keep),
).fetchall()
for lane in lanes:
if deleted >= max_rows:
break
# The oldest job still kept: everything strictly older goes.
boundary = connection.execute(
f"""
SELECT created_at, job_id FROM import_jobs
WHERE user_id = ? AND source_id = ? AND operation = 'sync'
AND status IN ({placeholders})
ORDER BY created_at DESC, job_id DESC
LIMIT 1 OFFSET ?
""",
(lane["user_id"], lane["source_id"], *finished, keep - 1),
).fetchone()
if boundary is None:
continue
victims = [
str(row["job_id"])
for row in connection.execute(
f"""
SELECT job_id FROM import_jobs
WHERE user_id = ? AND source_id = ? AND operation = 'sync'
AND status IN ({placeholders})
AND (created_at < ? OR (created_at = ? AND job_id < ?))
ORDER BY created_at, job_id
LIMIT ?
""",
(
lane["user_id"],
lane["source_id"],
*finished,
boundary["created_at"],
boundary["created_at"],
boundary["job_id"],
max_rows - deleted,
),
)
]
if not victims:
continue
marks = ", ".join("?" for _ in victims)
# Foreign keys cascade these too; deleting them by name keeps
# the outcome the same on a connection that has them off.
connection.execute(f"DELETE FROM import_issues WHERE job_id IN ({marks})", victims)
connection.execute(
f"DELETE FROM import_skip_reason_counts WHERE job_id IN ({marks})", victims
)
removed = connection.execute(
f"DELETE FROM import_jobs WHERE job_id IN ({marks}) "
f"AND operation = 'sync' AND status IN ({placeholders})",
(*victims, *finished),
)
deleted += removed.rowcount
return deleted
def get_source_cursors(
self,
*,
@@ -28,7 +28,7 @@ from one_memory.imports.adapters import (
)
from one_memory.imports.jobs import ImportJob, ImportJobStore, ImportStatus
from one_memory.imports.supervisor import ImportSupervisor
from one_memory.user_scope import DEFAULT_USER_ID, lane_key, split_lane
from one_memory.user_scope import DEFAULT_USER_ID, split_lane
#: How long an automatic retry will wait for a wedged worker to notice cancel.
ABANDON_TIMEOUT_SECONDS = 5.0
@@ -249,13 +249,15 @@ class PollingLiveSyncSupervisor:
),
)
return None
source_jobs = await in_the_background(self.job_store.list_jobs)
# This lane's unfinished jobs only: the newest unfinished sync, and
# whatever history shares its cursor. Asked in SQL; listing every
# job ever run and filtering here was the poll's biggest read.
source_jobs = await in_the_background(self._lane_jobs, source_id)
active_sync = next(
(
job
for job in reversed(source_jobs)
if lane_key(job.user_id, job.source_id) == source_id
and job.operation == "sync"
if job.operation == "sync"
and job.status
in {
ImportStatus.PENDING,
@@ -275,7 +277,9 @@ class PollingLiveSyncSupervisor:
active_sync.job_id
):
attempted_at = datetime.now(UTC)
self.import_supervisor.enqueue(active_sync.job_id)
await self.import_supervisor.enqueue_off_loop(
active_sync.job_id, job=active_sync
)
if not wait_for_completion:
self._observe_sync(source_id, attempted_at, active_sync.job_id)
return active_sync
@@ -295,22 +299,20 @@ class PollingLiveSyncSupervisor:
# by a process restart between yield and claim. Enqueue is
# the operation that actually creates the task.
attempted_at = datetime.now(UTC)
self.import_supervisor.resume(active_sync.job_id)
self.import_supervisor.enqueue(active_sync.job_id)
resumed = await self.import_supervisor.resume_off_loop(active_sync.job_id)
await self.import_supervisor.enqueue_off_loop(active_sync.job_id, job=resumed)
completed = await self.import_supervisor.wait(active_sync.job_id)
self._record_outcome(source_id, attempted_at, completed)
return completed
competing = [
job
for job in source_jobs
if lane_key(job.user_id, job.source_id) == source_id
and job.operation != "sync"
if job.operation != "sync"
and job.status
in {ImportStatus.PENDING, ImportStatus.RUNNING, ImportStatus.INTERRUPTED}
]
bounded_history_incomplete = any(
lane_key(job.user_id, job.source_id) == source_id
and job.operation == "history"
job.operation == "history"
and job.history_after is not None
and job.status is not ImportStatus.COMPLETED
for job in source_jobs
@@ -354,6 +356,38 @@ class PollingLiveSyncSupervisor:
# automatically after the realtime slice completes.
return await self._run_sync(source_id, wait_for_completion=wait_for_completion)
def _lane_jobs(self, source_id: str) -> list[ImportJob]:
"""The lane's jobs `_sync` decides on, oldest first.
Everything not completed, of either operation: an unfinished sync to
pick up, a history that competes for the cursor, and a bounded history
that has not completed yet (which includes a cancelled one). Finished
syncs, the rows that grow without end, are never read.
"""
user_id, bare_source_id = split_lane(source_id)
unfinished = tuple(
status for status in ImportStatus if status is not ImportStatus.COMPLETED
)
return [
*self.job_store.find_jobs(
user_id=user_id,
source_id=bare_source_id,
operation="history",
statuses=unfinished,
),
*self.job_store.find_jobs(
user_id=user_id,
source_id=bare_source_id,
operation="sync",
statuses=(
ImportStatus.PENDING,
ImportStatus.RUNNING,
ImportStatus.PAUSED,
ImportStatus.INTERRUPTED,
),
),
]
async def _has_work(self, source_id: str) -> bool:
"""Ask the source whether a scan would write anything, before taking a job out.
@@ -452,7 +486,7 @@ class PollingLiveSyncSupervisor:
),
)
self._active_jobs[source_id] = job.job_id
self.import_supervisor.enqueue(job.job_id)
await self.import_supervisor.enqueue_off_loop(job.job_id, job=job)
if not wait_for_completion:
self._observe_sync(source_id, attempted_at, job.job_id)
return job
@@ -557,7 +591,11 @@ class PollingLiveSyncSupervisor:
async def _poll_source(self, source_id: str) -> None:
while True:
await asyncio.sleep(self.interval_seconds)
# A watched source is normally triggered by its filesystem event.
# Its timer is only a missed-event backstop; recursively walking
# every multi-gigabyte host root once a minute starved unrelated
# Core reads even when no source had changed.
await asyncio.sleep(self._poll_interval(source_id))
try:
await self.trigger(source_id)
except asyncio.CancelledError:
@@ -574,6 +612,11 @@ class PollingLiveSyncSupervisor:
except Exception as again:
self._note_internal_error(source_id, again)
def _poll_interval(self, source_id: str) -> float:
if source_id in (self._watched_sources or ()):
return max(self.interval_seconds, 15 * 60)
return self.interval_seconds
def _note_worker_still_running(self, source_id: str) -> None:
current = self.health(source_id)
self._set_health(
@@ -23,6 +23,7 @@ from pathlib import Path
from watchfiles import awatch
from one_memory.background_work import in_the_background
from one_memory.diagnostic_log import diagnostic_event
from one_memory.imports.jobs import ImportStatus
from one_memory.imports.live_sync import PollingLiveSyncSupervisor
@@ -90,7 +91,8 @@ class FileWatchLiveSync:
# turn that caused setup. The watcher cannot observe a change
# from before it existed, so immediately run the same bounded
# trigger once instead of waiting for the 60-second backstop.
self._queue_changes(set(self.roots.values()))
# The roots are this watcher's own sources: nothing to resolve.
self._queue_sources(tuple(self.roots), count=len(self.roots))
async def shutdown(self) -> None:
task = self._task
@@ -139,28 +141,54 @@ class FileWatchLiveSync:
observed_at: datetime | None = None,
) -> tuple[str, ...]:
"""Queue affected sources and wait for their coalesced work to settle."""
workers = self._queue_changes(changed, observed_at=observed_at)
workers = await self._queue_changes(changed, observed_at=observed_at)
await asyncio.gather(*(asyncio.shield(worker) for worker in workers.values()))
return tuple(workers)
def _queue_changes(
def sources_for(self, changed: set[Path]) -> tuple[str, ...]:
"""The sources owning `changed`, in path order. Resolves every path."""
return tuple(
dict.fromkeys(
source
for source in (self.source_for(path) for path in sorted(changed))
if source is not None
)
)
async def _queue_changes(
self, changed: set[Path], *, observed_at: datetime | None = None
) -> dict[str, asyncio.Task[None]]:
"""Attribute a batch of changed paths to sources and queue them.
Attributing resolves each path, a `stat` per component, and a busy
host reports hundreds of paths at once. On the loop that was the main
thread inside `source_for` while nothing else was answered, so the
resolving happens off it and only the queueing here.
"""
if self._stop.is_set():
return {}
observed = observed_at or datetime.now(UTC)
sources = await in_the_background(self.sources_for, changed)
return self._queue_sources(sources, count=len(changed), observed_at=observed)
def _queue_sources(
self,
sources: tuple[str, ...],
*,
count: int,
observed_at: datetime | None = None,
) -> dict[str, asyncio.Task[None]]:
if self._stop.is_set():
return {}
observed = observed_at or datetime.now(UTC)
workers: dict[str, asyncio.Task[None]] = {}
for source_id in dict.fromkeys(
source
for source in (self.source_for(path) for path in sorted(changed))
if source is not None
):
for source_id in sources:
diagnostic_event(
level="debug",
component="import.live_watch",
event="live_sync.hook_received",
outcome="queued",
details={"source_id": source_id, "count": len(changed)},
details={"source_id": source_id, "count": count},
)
self._pending[source_id] = max(self._pending.get(source_id, observed), observed)
worker = self._workers.get(source_id)
@@ -239,7 +267,7 @@ class FileWatchLiveSync:
stop_event=self._stop,
ignore_permission_denied=True,
):
self._queue_changes({Path(path) for _change, path in changes})
await self._queue_changes({Path(path) for _change, path in changes})
except asyncio.CancelledError:
raise
except Exception:
@@ -226,6 +226,9 @@ class JsonlSourceAdapter:
self.header_gate = header_gate
self._header_cache: dict[str, tuple[str, dict[str, JsonValue] | None]] = {}
self._history_listing_cache: SourceListing | None = None
self._probe_listing_cache: tuple[
dict[str, SourceCursor], int | None, SourceListing
] | None = None
@property
def descriptor(self) -> SourceDescriptor:
@@ -281,6 +284,11 @@ class JsonlSourceAdapter:
predicate=self.file_predicate,
cursors=options.cursors,
)
self._probe_listing_cache = (
dict(options.cursors),
options.max_scan_targets,
listing,
)
return bool(listing.changed or listing.failures)
async def live_baseline(self) -> dict[str, SourceCursor]:
@@ -315,9 +323,11 @@ class JsonlSourceAdapter:
async for event in self._scan_frozen(options):
yield event
return
listing = await asyncio.to_thread(
self._history_listing if options.reuse_listing else self._fresh_listing,
options,
listing = (
await asyncio.to_thread(self._history_listing, options)
if options.reuse_listing
else self._take_probe_listing(options)
or await asyncio.to_thread(self._fresh_listing, options)
)
for failure in listing.failures:
yield ScanFailure(reason_code=discovery_failure_code(failure))
@@ -349,6 +359,16 @@ class JsonlSourceAdapter:
self._history_listing_cache = listing
return listing
def _take_probe_listing(self, options: ScanOptions) -> "SourceListing | None":
cached = self._probe_listing_cache
self._probe_listing_cache = None
if cached is None:
return None
cursors, max_scan_targets, listing = cached
if cursors != options.cursors or max_scan_targets != options.max_scan_targets:
return None
return listing
def _fresh_listing(self, options: ScanOptions) -> "SourceListing":
return list_changed_files(
self.grant,
@@ -9,6 +9,7 @@ from collections.abc import AsyncIterator, Iterator
from dataclasses import dataclass, replace
from datetime import UTC, datetime, timedelta, timezone
from functools import partial
from pathlib import Path
from pydantic import JsonValue
@@ -175,6 +176,9 @@ class CursorUnifiedSourceAdapter:
self.grant = grant
self.source_version = source_version
self._history_listing_cache: _CursorListing | None = None
self._probe_listing_cache: tuple[
dict[str, SourceCursor], int | None, _CursorListing
] | None = None
@property
def descriptor(self) -> SourceDescriptor:
@@ -207,6 +211,20 @@ class CursorUnifiedSourceAdapter:
state = await self.install_state()
return min(state.session_count, limit) if limit is not None else state.session_count
async def has_changes(self, options: ScanOptions) -> bool:
listing = await asyncio.to_thread(self._fresh_listing, options)
self._probe_listing_cache = (
dict(options.cursors),
options.max_scan_targets,
listing,
)
return bool(
listing.transcripts
or listing.stores
or listing.legacy
or listing.failures
)
async def live_baseline(self) -> dict[str, SourceCursor]:
"""Capture Cursor's current tail so the realtime lane never backfills history."""
listing = await asyncio.to_thread(
@@ -222,9 +240,11 @@ class CursorUnifiedSourceAdapter:
async for event in self._scan_frozen(options):
yield event
return
listing = await asyncio.to_thread(
self._history_listing if options.reuse_listing else self._fresh_listing,
options,
listing = (
await asyncio.to_thread(self._history_listing, options)
if options.reuse_listing
else self._take_probe_listing(options)
or await asyncio.to_thread(self._fresh_listing, options)
)
for failure in listing.failures:
yield ScanFailure(reason_code=discovery_failure_code(failure))
@@ -329,6 +349,16 @@ class CursorUnifiedSourceAdapter:
self._history_listing_cache = listing
return listing
def _take_probe_listing(self, options: ScanOptions) -> "_CursorListing | None":
cached = self._probe_listing_cache
self._probe_listing_cache = None
if cached is None:
return None
cursors, max_scan_targets, listing = cached
if cursors != options.cursors or max_scan_targets != options.max_scan_targets:
return None
return listing
def _fresh_listing(self, options: ScanOptions) -> "_CursorListing":
return _list_cursor_files(
self.grant,
@@ -376,6 +406,7 @@ class CursorUnifiedSourceAdapter:
self.grant,
suffixes=frozenset({".db"}),
max_targets=options.max_scan_targets or 100_000,
directory_predicate=_cursor_data_directory,
):
if isinstance(discovered, SourceFileDiscoveryFailure):
yield ScanFailure(reason_code=discovery_failure_code(discovered))
@@ -477,7 +508,10 @@ def _list_cursor_files(
legacy: list[AuthorizedSourceFile] = []
failures: list[SourceFileDiscoveryFailure] = []
for item in discover_source_files(
grant, suffixes=frozenset({".jsonl", ".db", ".vscdb"}), max_targets=max_targets
grant,
suffixes=frozenset({".jsonl", ".db", ".vscdb"}),
max_targets=max_targets,
directory_predicate=_cursor_data_directory,
):
if isinstance(item, SourceFileDiscoveryFailure):
failures.append(item)
@@ -586,6 +620,25 @@ def _is_cursor_data_file(source_file: AuthorizedSourceFile) -> bool:
)
def _cursor_data_directory(relative_path: Path) -> bool:
"""Enter only Cursor directories that can contain conversation authority.
The granted root also contains extensions, skills, caches, logs and agent
artifacts. Recursively walking all of them every live-sync poll made the
reader monopolize Core even though none can pass the file predicates.
"""
parts = relative_path.parts
if not parts:
return False
if parts[0] in {"chats", "acp-sessions"}:
return True
if parts[0] == "projects":
return len(parts) <= 2 or parts[2] == "agent-transcripts"
if parts[0] == "User":
return len(parts) == 1 or parts[1] in {"globalStorage", "workspaceStorage"}
return False
def _is_transcript_file(source_file: AuthorizedSourceFile) -> bool:
parts = source_file.relative_path.parts
return source_file.relative_path.suffix == ".jsonl" and "agent-transcripts" in parts
@@ -634,6 +687,7 @@ def _select_transcript_files(
grant,
suffixes=frozenset({".jsonl"}),
max_targets=100_000,
directory_predicate=_cursor_data_directory,
):
if not isinstance(item, AuthorizedSourceFile) or not _is_transcript_file(item):
continue
@@ -654,6 +708,7 @@ def _subagent_conversation_ids(grant: AuthorizedSourceRoot) -> set[str]:
grant,
suffixes=frozenset({".jsonl"}),
max_targets=100_000,
directory_predicate=_cursor_data_directory,
)
if isinstance(item, AuthorizedSourceFile)
and _is_transcript_file(item)
@@ -1,6 +1,7 @@
import asyncio
from collections.abc import Awaitable, Callable, Mapping
from contextlib import suppress
from functools import partial
from time import monotonic
from typing import Literal
@@ -82,6 +83,10 @@ class ImportSupervisor:
)
self._tasks: dict[str, asyncio.Task[None]] = {}
self._drains: dict[str, asyncio.Task[None]] = {}
#: Pause and cancel settle a stopped job with a read and a transition.
#: Made on the loop, two of them could not interleave; made off it,
#: they are serialized here so they still land in the order they woke.
self._settling = asyncio.Lock()
def register_adapter(self, adapter: SourceAdapter, *, user_id: str = "local") -> None:
"""Adapters are keyed by lane: the source id for `local`, `source@user`
@@ -116,11 +121,44 @@ class ImportSupervisor:
raise UnknownSourceError(lane)
if self.source_enabled is not None and not self.source_enabled(lane):
return job
existing = self._tasks.get(job_id)
self._start(job)
return job
async def enqueue_off_loop(self, job_id: str, *, job: ImportJob | None = None) -> ImportJob:
"""`enqueue`, with its job read and its grant check made off the loop.
Both are database reads, and the grant check resolves the source root
on disk. Live sync enqueues from its poll, so on the loop they were
the main thread waiting for a reader behind an import peak. `job` is
the row the caller has just read, when it has one.
"""
if job_id in self._drains:
raise InvalidJobTransitionError("import_cleanup_in_progress")
def admit() -> tuple[ImportJob, bool]:
current = job if job is not None and job.job_id == job_id else None
if current is None:
current = self.job_store.require(job_id)
lane = self.lane_of(current)
return current, self.source_enabled is None or self.source_enabled(lane)
current, enabled = await in_the_background(admit)
lane = self.lane_of(current)
if lane not in self.adapters:
raise UnknownSourceError(lane)
if job_id in self._drains:
raise InvalidJobTransitionError("import_cleanup_in_progress")
if enabled:
self._start(current)
return current
def _start(self, job: ImportJob) -> None:
lane = self.lane_of(job)
existing = self._tasks.get(job.job_id)
if existing is None or existing.done():
self._tasks[job_id] = asyncio.create_task(
self._tasks[job.job_id] = asyncio.create_task(
self._execute(
job_id,
job.job_id,
source_id=lane,
operation=job.operation,
)
@@ -130,10 +168,24 @@ class ImportSupervisor:
component="import.supervisor",
event="import.queued",
outcome="queued",
job_id=job_id,
job_id=job.job_id,
details={"source_id": job.source_id, "mode": job.operation},
)
return job
async def resume_interrupted_off_loop(self) -> None:
"""`resume_interrupted` for the running Core: every read and write off the loop."""
for status in (ImportStatus.INTERRUPTED, ImportStatus.PENDING):
jobs = await in_the_background(partial(self.job_store.find_jobs, statuses=(status,)))
for job in jobs:
if self.lane_of(job) not in self.adapters:
continue
if job.status is not ImportStatus.PENDING:
if self.source_enabled is not None and not await in_the_background(
self.source_enabled, self.lane_of(job)
):
continue
job = await in_the_background(self.job_store.queue_for_resume, job.job_id)
await self.enqueue_off_loop(job.job_id, job=job)
def resume_interrupted(self) -> None:
# A process restart must not place recoverable work behind every newly
@@ -166,40 +218,64 @@ class ImportSupervisor:
self.enqueue(job_id)
return queued
async def resume_off_loop(self, job_id: str) -> ImportJob:
"""`resume`, with its reads and its write made off the event loop."""
if job_id in self._drains:
raise InvalidJobTransitionError("import_cleanup_in_progress")
queued = await in_the_background(self._queue_resumable, job_id)
if queued.status is not ImportStatus.PENDING:
return queued
return await self.enqueue_off_loop(job_id, job=queued)
def _queue_resumable(self, job_id: str) -> ImportJob:
job = self.job_store.require(job_id)
if job.status not in {
ImportStatus.PENDING,
ImportStatus.PAUSED,
ImportStatus.INTERRUPTED,
ImportStatus.FAILED,
}:
return job
return self.job_store.queue_for_resume(job_id)
async def wait(self, job_id: str) -> ImportJob:
task = self._tasks.get(job_id)
if task is not None:
await asyncio.shield(task)
return self.job_store.require(job_id)
return await in_the_background(self.job_store.require, job_id)
async def pause(self, job_id: str) -> ImportJob:
job = self.job_store.require(job_id)
job = await in_the_background(self.job_store.require, job_id)
if job.status in {ImportStatus.PAUSED, ImportStatus.INTERRUPTED}:
return job
await self._cancel_task(job_id)
current = self.job_store.require(job_id)
if current.status is ImportStatus.COMPLETED:
return current
return self.job_store.transition(job_id, ImportStatus.PAUSED)
async with self._settling:
return await in_the_background(self._settle_stopped, job_id, ImportStatus.PAUSED)
async def cancel(self, job_id: str) -> ImportJob:
job = self.job_store.require(job_id)
job = await in_the_background(self.job_store.require, job_id)
if job.status is ImportStatus.CANCELLED:
return job
await self._cancel_task(job_id)
async with self._settling:
return await in_the_background(self._settle_stopped, job_id, ImportStatus.CANCELLED)
def _settle_stopped(self, job_id: str, target: ImportStatus) -> ImportJob:
"""A stopped runner's job moves to `target`, unless it completed first."""
current = self.job_store.require(job_id)
if current.status is ImportStatus.COMPLETED:
return current
return self.job_store.transition(job_id, ImportStatus.CANCELLED)
return self.job_store.transition(job_id, target)
async def shutdown(self) -> None:
running = [
job
for job in self.job_store.list_jobs(status=ImportStatus.RUNNING)
if job.job_id in self._tasks
]
for job in running:
self.job_store.interrupt_running(job.job_id)
mine = frozenset(self._tasks)
def interrupt_mine() -> None:
for job in self.job_store.find_jobs(statuses=(ImportStatus.RUNNING,)):
if job.job_id in mine:
self.job_store.interrupt_running(job.job_id)
await in_the_background(interrupt_mine)
await asyncio.gather(
*(self._cancel_task(job_id) for job_id in tuple(self._tasks)),
)
@@ -355,9 +431,7 @@ class ImportSupervisor:
try:
await asyncio.wait_for(asyncio.shield(task), timeout)
except TimeoutError:
current = self.job_store.require(job_id)
if current.status is ImportStatus.RUNNING:
self.job_store.interrupt_running(job_id)
await in_the_background(self._interrupt_if_running, job_id)
return False
except asyncio.CancelledError:
# The worker noticed cancel and exited. A cancel of this
@@ -365,10 +439,16 @@ class ImportSupervisor:
caller = asyncio.current_task()
if caller is not None and caller.cancelling():
raise
# A read and a write on the single writer: off the loop. Live sync
# calls this from its poll, and on the loop it was the main thread
# waiting its turn for the writer behind an import peak.
await in_the_background(self._interrupt_if_running, job_id)
return True
def _interrupt_if_running(self, job_id: str) -> None:
current = self.job_store.require(job_id)
if current.status is ImportStatus.RUNNING:
self.job_store.interrupt_running(job_id)
return True
async def _cancel_task(self, job_id: str) -> None:
drain = self._drains.get(job_id)
@@ -28,10 +28,11 @@ from pathlib import Path
from typing import Any, Literal
from uuid import uuid4
from one_memory.background_work import in_the_background
from one_memory.background_work import in_lease_renewal, in_the_write_queue
from one_memory.conversation_processing import TURN_IDLE_FLUSH_SECONDS, turn_batches
from one_memory.database_access import main_database
from one_memory.diagnostic_log import diagnostic_event
from one_memory.exception_reporting import report_exception
from one_memory.imports.models import EvidenceOrigin, EvidenceRecord
from one_memory.imports.storage import EvidenceStore
from one_memory.memory_server.contracts import (
@@ -451,6 +452,14 @@ class _LeaseRenewalFatalError(RuntimeError):
"""A non-transient renewal failure that must stop the worker."""
def _drain_exit_code(error: BaseException) -> str:
"""A stable, lowercase code for why the drain exited; never message text."""
if isinstance(error, _LeaseRenewalFatalError):
return "lease_renewal_failed"
name = re.sub(r"(?<!^)(?=[A-Z])", "_", type(error).__name__).lower()
return name if re.fullmatch(r"[a-z_]{1,64}", name) else "drain_error"
@dataclass(frozen=True)
class UnknownWriteJob:
"""An `unknown` row that still needs an external inquiry.
@@ -2562,14 +2571,14 @@ async def execute_job(
# Every store call here is synchronous SQLite work on the single writer or
# a reader; made from the loop it would block health checks and the API for
# as long as the write lock is held.
request = await in_the_background(queue.payload_for, job_id)
request = await in_the_write_queue(queue.payload_for, job_id)
if request is None:
# A claimed row whose payload is missing or unreadable (reaped while
# still claimable, corrupted, or a foreign row) must not be left
# `running`: nothing was sent, so it is a terminal failure rather than
# an unknown, and the lease must be released so it is not held until it
# lapses.
await in_the_background(
await in_the_write_queue(
queue.settle,
job_id,
state=FAILED,
@@ -2579,9 +2588,9 @@ async def execute_job(
)
return
try:
records = await in_the_background(evidence_for_request, evidence, request)
records = await in_the_write_queue(evidence_for_request, evidence, request)
except LookupError as error:
await in_the_background(
await in_the_write_queue(
queue.settle,
job_id,
state=FAILED,
@@ -2618,7 +2627,7 @@ async def execute_job(
source_id = lane_key(next(iter(users), DEFAULT_USER_ID), source_id)
async def mark() -> bool:
return await in_the_background(queue.mark_invoked, job_id, owner=owner)
return await in_the_write_queue(queue.mark_invoked, job_id, owner=owner)
if admit_hy_invocation is not None:
admitted = await admit_hy_invocation(source_id, requires_generation, mark)
@@ -2633,7 +2642,7 @@ async def execute_job(
raise RuntimeError("durable_write_lease_lost_before_invocation")
hy_invoked = True
attempt = await in_the_background(queue.sdk_attempt, job_id)
attempt = await in_the_write_queue(queue.sdk_attempt, job_id)
window = model_failures() if model_failures is not None else nullcontext(None)
provider: ModelAttempt | None = None
try:
@@ -2675,7 +2684,7 @@ async def execute_job(
}
and not hy_invoked
):
await in_the_background(queue.release_unstarted, job_id, owner=owner)
await in_the_write_queue(queue.release_unstarted, job_id, owner=owner)
return
provider_failure = provider.failure if provider is not None else None
if hy_invoked and provider_failure is not None:
@@ -2683,7 +2692,7 @@ async def execute_job(
# gone, a provider that is down): the job is fine and waits for
# the provider, in its place, without spending a retry. The
# breaker paces when it is tried again.
await in_the_background(
await in_the_write_queue(
queue.requeue_for_model, job_id, error_code=provider_failure, owner=owner
)
logger.info(
@@ -2712,7 +2721,7 @@ async def execute_job(
elif not hy_invoked and code in MODEL_NOT_READY_CODES:
state = WAITING_FOR_MODEL
if state is not None:
await in_the_background(
await in_the_write_queue(
queue.settle,
job_id,
state=state,
@@ -2724,7 +2733,7 @@ async def execute_job(
# Anything else is this job's own trouble: content the model or
# the SDK refused, a reply that did not parse, a check that failed
# after the write. Retried a few times, then `failed`.
state = await in_the_background(
state = await in_the_write_queue(
queue.retry_or_fail, job_id, error_code=code, owner=owner
)
# Never `logger.exception` here: it prints the exception's message,
@@ -2751,7 +2760,7 @@ async def execute_job(
error_chain_text(error),
)
return
await in_the_background(
await in_the_write_queue(
queue.settle,
job_id,
state=COMPLETED,
@@ -2760,13 +2769,13 @@ async def execute_job(
sdk_memory_ids=memory_ids,
)
release = getattr(evidence, "release_sdk_staging", None)
durable_receipt = await in_the_background(queue.sdk_receipt, job_id)
durable_receipt = await in_the_write_queue(queue.sdk_receipt, job_id)
if (
callable(release)
and durable_receipt is not None
and (durable_receipt.raw_ids or durable_receipt.memory_ids)
):
await in_the_background(
await in_the_write_queue(
release,
tuple(record.evidence_id for record in records),
)
@@ -2891,6 +2900,16 @@ class DurableWriteDrain:
#: a minute against a provider that refused every one (#245).
self.model_claim_allowance = model_claim_allowance
self._last_unknown_reconcile: datetime | None = None
#: What `run_supervised` says about the drain: None before it was
#: first started, then `running`, `restarting` (it exited on an error
#: and waits out its backoff) or `stopped`. Shown by memory-processing.
self.liveness: Literal["running", "restarting", "stopped"] | None = None
#: The stable code of the last unexpected exit, kept after a restart.
self.last_error_code: str | None = None
self.restarts = 0
self._stop_requested = False
#: Wakes a restart backoff, so `stop()` does not wait it out.
self._stopping = asyncio.Event()
def claim(
self,
@@ -3218,7 +3237,7 @@ class DurableWriteDrain:
# Claim is a blocking SQLite write on the single writer connection.
# Run it off the loop, like every other DB call in this chain; a
# busy writer would otherwise stall the whole event loop.
ids = await in_the_background(
ids = await in_the_write_queue(
self.claim,
limit=limit,
budget_ms=self.claim_budget_ms,
@@ -3231,10 +3250,10 @@ class DurableWriteDrain:
await asyncio.gather(*(self._run_claimed_job(job_id) for job_id in ids))
async def _run_claimed_job(self, job_id: str) -> None:
if not await in_the_background(self.still_owns, job_id):
if not await in_the_write_queue(self.still_owns, job_id):
logger.warning("durable_write_drain_lease_lost_before_execution job_id=%s", job_id)
return
deadline = await in_the_background(self.lease_deadline, job_id)
deadline = await in_the_write_queue(self.lease_deadline, job_id)
if deadline is None:
return
renewal = asyncio.create_task(self._renew_loop(job_id, deadline))
@@ -3261,11 +3280,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(
@@ -3273,12 +3290,15 @@ class DurableWriteDrain:
job_id,
type(error).__name__,
)
report_exception(
"memory.write_queue",
"memory.write_queue.executor_failed",
error,
)
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,9 +3307,39 @@ 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_write_queue(
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)
await in_the_write_queue(self.work_ceiling)
if callable(self.work_ceiling)
else self.work_ceiling
)
@@ -3324,7 +3374,33 @@ 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__,
)
# With its stack: a type name alone cannot tell one
# stray failure from a bug that fails every job.
report_exception(
"memory.write_queue",
"memory.write_queue.job_task_failed",
error,
)
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
@@ -3340,7 +3416,7 @@ class DurableWriteDrain:
try:
available = self.max_concurrent_sources - len(active)
ids = (
await in_the_background(
await in_the_write_queue(
self.claim,
limit=available,
budget_ms=self.claim_budget_ms,
@@ -3349,7 +3425,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:
@@ -3447,7 +3528,79 @@ class DurableWriteDrain:
await asyncio.gather(*active, return_exceptions=True)
def stop(self) -> None:
self._stop_requested = True
self.running = False
self._stopping.set()
async def run_supervised(
self,
*,
idle_pause: float = 0.05,
restart_backoff: float = 1.0,
max_restart_backoff: float = 60.0,
) -> None:
"""`run_forever`, started again whenever it exits on an error.
The drain is the queue's only consumer, so a drain that died stranded
every write behind it until the next start, and nothing said so: an
unexpected SQLite error on the claim path (disk I/O, a vanished file)
or an exception from a claim gate ended it for good. Each exit is now
reported with its stack, and the drain comes back after a pause that
doubles up to `max_restart_backoff`, back to `restart_backoff` once it
has run for that long. `stop()` and cancellation end it as before.
Ownership across a restart: `run_forever` only returns once every job
task it started has finished (its `finally`), and a task whose lease
renewal broke (`_LeaseRenewalFatalError`) waits for its execution to
return first, so nothing of the old drain is still running a job when
the new one claims. The new one also takes a new lease owner, so a
statement of the old one still in a pool thread can neither renew nor
settle a row under the new drain's name, and the old drain's leases
are only ever recovered by expiry (`reclaim_expired`), which decides
by `hy_invoked` exactly as for a drain that died with its process.
"""
backoff = restart_backoff
while not self._stop_requested:
started = _time.monotonic()
self.liveness = "running"
try:
await self.run_forever(idle_pause=idle_pause)
except asyncio.CancelledError:
self.liveness = "stopped"
raise
except Exception as error:
self.last_error_code = _drain_exit_code(error)
self.restarts += 1
report_exception(
"memory.write_queue",
"memory.write_queue.drain_exited",
error,
context={"restarts": self.restarts},
)
logger.error(
"durable_write_drain_exited error_code=%s restarts=%d chain=%s",
self.last_error_code,
self.restarts,
error_chain_text(error),
)
else:
if self._stop_requested:
break
if self._stop_requested:
break
self.owner = f"durable-drain:{uuid4()}"
if _time.monotonic() - started >= max_restart_backoff:
backoff = restart_backoff
self.liveness = "restarting"
try:
await asyncio.wait_for(self._stopping.wait(), timeout=backoff)
except TimeoutError:
pass
except asyncio.CancelledError:
self.liveness = "stopped"
raise
backoff = min(backoff * 2, max_restart_backoff)
self.liveness = "stopped"
async def _run_idle_maintenance(
self,
@@ -3465,7 +3618,7 @@ class DurableWriteDrain:
persisting provider or user content.
"""
try:
await in_the_background(operation)
await in_the_write_queue(operation)
self._database_failure_streaks.pop(f"maintenance:{operation_name}", None)
except asyncio.CancelledError:
raise
@@ -3493,7 +3646,7 @@ class DurableWriteDrain:
renewed_at = datetime.now(UTC)
remaining = (deadline - renewed_at).total_seconds()
if remaining <= 0 or not await asyncio.wait_for(
in_the_background(self.renew, job_id), timeout=remaining
in_lease_renewal(self.renew, job_id), timeout=remaining
):
return False
deadline = renewed_at + self.lease
@@ -3190,6 +3190,110 @@ 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
IMPORT_JOB_LOOKUP_INDEXES_MIGRATION_VERSION = 63
def import_job_lookup_indexes_ddl(dialect: str = "mysql") -> str:
"""Index the per-source and per-status import job lookups.
Migration 7 gave SQLite `import_jobs` no index beyond its primary key (the
one-active-job index is partial), so every "this source's jobs" or "the
jobs in this state" question read and sorted the whole table. Live sync
adds a row per change it imports and nothing removed them, so on a machine
a few weeks old that table was thousands of rows read several times a poll.
`import_jobs_lane` serves the lane lookups (one user, one source, one
operation, a status, newest first) and the pruning of finished syncs;
`import_jobs_status` serves restart recovery and the status filters.
MySQL's migration 7 already has a status index and no `operation` column,
so it only gains the ordered source index.
"""
if dialect == "sqlite":
return """
CREATE INDEX IF NOT EXISTS import_jobs_lane
ON import_jobs(user_id, source_id, operation, status, created_at);
CREATE INDEX IF NOT EXISTS import_jobs_status
ON import_jobs(status, created_at);
"""
return """
CREATE INDEX idx_import_jobs_source_created
ON import_jobs(user_id, source_id, created_at);
"""
def _is_duplicate_index(error: Exception) -> bool:
"""MySQL's "index name already there" (1061): the index is in place."""
args: tuple[object, ...] = getattr(error, "args", ())
return bool(args) and args[0] == 1061
async def _063_import_job_lookup_indexes(cursor: Any) -> None:
dialect = _dialect(cursor)
if dialect == "sqlite":
# `operation` was added by the job store, not by migration 7, so a
# database built from the catalog alone may not have it yet. Same
# declaration as the store's own addition.
if "operation" not in await cursor.columns("import_jobs"):
await cursor.execute(
"ALTER TABLE import_jobs ADD COLUMN operation TEXT NOT NULL DEFAULT 'history'"
)
for statement in _split_statements(import_job_lookup_indexes_ddl(dialect)):
await cursor.execute(statement)
return
for statement in _split_statements(import_job_lookup_indexes_ddl(dialect)):
try:
await cursor.execute(statement)
except Exception as error: # noqa: BLE001 - MySQL driver error type varies
if _is_duplicate_index(error):
continue
raise
def _split_statements(sql: str) -> list[str]:
"""Split a multi-statement script, dropping blanks, PRAGMA and comments.
@@ -3490,4 +3594,15 @@ 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",),
),
Migration(
IMPORT_JOB_LOOKUP_INDEXES_MIGRATION_VERSION,
"import_job_lookup_indexes",
_063_import_job_lookup_indexes,
),
)
+16 -3
View File
@@ -26,6 +26,7 @@ if TYPE_CHECKING:
_operation: ContextVar[tuple["ModelProbeOperations", str] | None] = ContextVar(
"model_probe_operation", default=None
)
_dispatch_started: ContextVar[bool] = ContextVar("model_probe_dispatch_started", default=False)
class ProbeCode(StrEnum):
@@ -113,16 +114,26 @@ class ModelProber:
if existing is not None:
return existing
token = _operation.set((operations, request.operation_id))
dispatch_token = _dispatch_started.set(False)
try:
await self._probe(profile_id)
except ProbeOperationConflict:
await asyncio.to_thread(
operations.fail, request.operation_id, "model_probe_profile_changed"
)
except Exception:
# Unknown dispatch/storage outcomes stay reserved, including across restart.
raise ProbeOperationUnavailable("model_probe_operation_unavailable") from None
except Exception as exc:
if _dispatch_started.get():
# Once a request may have reached the provider, preserve the
# reservation so a retry cannot duplicate a possibly billed call.
raise ProbeOperationUnavailable("model_probe_operation_unavailable") from None
logging.getLogger(__name__).warning(
"model probe failed before dispatch: %s", type(exc).__name__
)
await asyncio.to_thread(
operations.fail, request.operation_id, "model_probe_execution_failed"
)
finally:
_dispatch_started.reset(dispatch_token)
_operation.reset(token)
return await asyncio.to_thread(operations.lookup, request.operation_id)
@@ -317,6 +328,8 @@ class ModelProber:
listing_optional: bool = False,
) -> ProbeStage:
await self._check_operation()
if _operation.get() is not None:
_dispatch_started.set(True)
started = monotonic()
try:
response = await client.request(
+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", "ultra"] | 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", "ultra"] | None = None
setup_step: Literal["review", "mode", "model"] | None = None
model_profile_id: str | None = Field(default=None, min_length=36, max_length=36)
@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),)
+3 -1
View File
@@ -123,6 +123,7 @@
| `test_import_api.py` | `integration` | 覆盖 resume during cleanup returns conflict without internal error、authorize detect import and revoke api flow、protected api rejects missing token and foreign origin 等 5 个场景。 |
| `test_import_control_races_review.py` | `contract` | Read-only review repro: real runner/store, synthetic delayed adapter cleanup. |
| `test_import_evidence_atomicity.py` | `contract` | 覆盖 counter failure rolls back evidence versions and cursor、same identity cursor rewind is blocked and logged、replaced identity may reset checkpoint 等 20 个场景。 |
| `test_import_job_retention.py` | `integration` | `import_jobs` stays bounded, and the questions asked of it are SQL, not scans. |
| `test_import_oversized_verbatim.py` | `contract` | 覆盖 normal verbatim is unchanged、oversized verbatim is truncated instead of rejected 场景。 |
| `test_import_runner.py` | `contract` | 覆盖 job transitions are explicit、runner keeps per record errors and continues、sync slices reuse the same source listing 等 22 个场景。 |
| `test_import_skip_batching.py` | `contract` | 覆盖 batch limit and tail use fewer commits、batched skips keep their order against the next write、flushes before every nonmatching event 等 14 个场景。 |
@@ -213,6 +214,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. |
@@ -321,4 +323,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. |
共 316 个测试文件。
共 318 个测试文件。
@@ -14,8 +14,8 @@ from pathlib import Path
import pytest
PRIMARY_MARKERS = ("unit", "contract", "integration", "performance", "live")
EXPECTED_TEST_FILE_COUNT = 316
EXPECTED_TEST_FILES_SHA256 = "011482974a26e9d43ba03bb88cbd168e645175beacd96c02ff642bc17dac49da"
EXPECTED_TEST_FILE_COUNT = 318
EXPECTED_TEST_FILES_SHA256 = "df8752566a0bf9e540f68b4948f3f5dbc409bd6836707b1330206e2349d70940"
# Pure transformations, validation models, parity checks and state machines.
# This list is intentionally small: uncertain files default to contract.
@@ -70,6 +70,7 @@ INTEGRATION_FILES = frozenset(
"test_file_import_operations.py",
"test_graph_width_endpoint.py",
"test_import_api.py",
"test_import_job_retention.py",
"test_live_capture.py",
"test_live_watch_grants.py",
"test_log_privacy.py",
@@ -93,6 +94,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",
+12 -1
View File
@@ -150,7 +150,18 @@ def test_lifespan_content_backfill_failure_is_visible(tmp_path, monkeypatch) ->
async def scenario() -> None:
async with app.router.lifespan_context(app):
health = await _get_json(app, "/api/v1/health")
# The rebuild is released when post-ready recovery finishes, and
# recovery now resumes imports off the loop, so it can still be
# running at the first request.
for _ in range(500):
if app.state.recovery in {"done", "failed"}:
break
await asyncio.sleep(0.01)
for _ in range(500):
health = await _get_json(app, "/api/v1/health")
if health["content_candidate_backfill"] != "running":
break
await asyncio.sleep(0.01)
assert health["content_candidate_backfill"] == "failed"
assert health["content_candidate_backfill_failure"] == "database_locked"
+2 -2
View File
@@ -34,10 +34,10 @@ def test_resume_during_cleanup_returns_conflict_without_internal_error(tmp_path,
job = supervisor.job_store.create(user_id="local", source_id="fixture")
supervisor.job_store.transition(job.job_id, ImportStatus.PAUSED)
def draining(_job_id):
async def draining(_job_id):
raise InvalidJobTransitionError("import_cleanup_in_progress")
monkeypatch.setattr(supervisor, "resume", draining)
monkeypatch.setattr(supervisor, "resume_off_loop", draining)
response = await client.post(f"/api/v1/import-jobs/{job.job_id}/resume")
assert response.status_code == 409
assert response.json() == {"detail": "invalid_job_transition"}
@@ -0,0 +1,383 @@
"""`import_jobs` stays bounded, and the questions asked of it are SQL, not scans.
Live sync opened a job per change it imported and nothing ever removed one, so
the table only grew; every caller then read all of it and filtered in Python.
These tests hold the three halves of the fix: what pruning keeps, that the
filtered queries answer exactly what the old filters did, and that the indexes
behind them arrive by migration.
"""
from __future__ import annotations
import asyncio
import random
import sqlite3
from datetime import UTC, datetime, timedelta
import pytest
from httpx import ASGITransport, AsyncClient
from pydantic import SecretStr
from one_memory.api import _prune_finished_import_jobs, create_app
from one_memory.config import Settings
from one_memory.imports.jobs import (
FINISHED_STATUSES,
SYNC_JOBS_KEPT_PER_SOURCE,
ImportJob,
ImportJobStore,
ImportStatus,
)
from one_memory.memory_server.migration_runner import SqliteMigrationStore, migrate
from one_memory.memory_server.migrations import (
IMPORT_JOB_LOOKUP_INDEXES_MIGRATION_VERSION,
MIGRATIONS,
)
_BASE = datetime(2026, 1, 1, tzinfo=UTC)
def _store(tmp_path) -> ImportJobStore:
return ImportJobStore(tmp_path / "one-memory.sqlite3")
def _job(
store: ImportJobStore,
*,
user_id: str = "local",
source_id: str = "fixture",
operation: str = "sync",
status: ImportStatus = ImportStatus.COMPLETED,
minute: int,
issue: bool = False,
) -> str:
"""Insert one row as its terminal shape, at a chosen creation time."""
created = (_BASE + timedelta(minutes=minute)).isoformat()
job_id = f"{source_id}-{user_id}-{operation}-{minute:05d}"
with store._connect(writing=True) as connection:
connection.execute(
"INSERT INTO import_jobs (job_id,user_id,source_id,status,created_at,updated_at,"
"operation) VALUES (?,?,?,?,?,?,?)",
(job_id, user_id, source_id, status, created, created, operation),
)
if issue:
connection.execute(
"INSERT INTO import_issues (job_id,kind,conversation_id,reason,created_at) "
"VALUES (?,'error',NULL,'synthetic_reason',?)",
(job_id, created),
)
connection.execute(
"INSERT INTO import_skip_reason_counts (job_id,reason,count) VALUES (?,'r',1)",
(job_id,),
)
return job_id
def _ids(store: ImportJobStore, **filters) -> list[str]:
return [job.job_id for job in store.find_jobs(**filters)]
def _count(store: ImportJobStore, table: str, job_id: str) -> int:
with store._connect() as connection:
return int(
connection.execute(
f"SELECT COUNT(*) FROM {table} WHERE job_id=?", (job_id,)
).fetchone()[0]
)
def test_pruning_keeps_the_newest_finished_syncs_of_every_source(tmp_path) -> None:
store = _store(tmp_path)
keep = 5
for source in ("alpha", "beta"):
for minute in range(12):
status = ImportStatus.CANCELLED if minute % 4 == 0 else ImportStatus.COMPLETED
_job(store, source_id=source, minute=minute, status=status, issue=True)
# A source with fewer than `keep` keeps all of them.
for minute in range(3):
_job(store, source_id="gamma", minute=minute)
# Never pruned: the active sync, history of any state, another user's lane.
running = _job(store, source_id="alpha", minute=1000, status=ImportStatus.RUNNING)
failed_history = _job(
store, source_id="alpha", operation="history", minute=2, status=ImportStatus.FAILED
)
histories = [
_job(store, source_id="alpha", operation="history", minute=100 + minute)
for minute in range(10)
]
other_user = [_job(store, user_id="other", source_id="alpha", minute=m) for m in range(8)]
removed = store.prune_finished_sync_jobs(keep=keep)
assert removed == (12 - keep) * 2 + (8 - keep)
for source in ("alpha", "beta"):
finished = _ids(
store, user_id="local", source_id=source, operation="sync", statuses=FINISHED_STATUSES
)
assert finished == [f"{source}-local-sync-{m:05d}" for m in range(12 - keep, 12)]
# Their issues and skip counts went with them; the kept ones still have theirs.
assert _count(store, "import_issues", f"{source}-local-sync-00000") == 0
assert _count(store, "import_skip_reason_counts", f"{source}-local-sync-00000") == 0
assert _count(store, "import_issues", f"{source}-local-sync-00011") == 1
assert len(_ids(store, source_id="gamma")) == 3
assert store.require(running).status is ImportStatus.RUNNING
assert store.require(failed_history).status is ImportStatus.FAILED
assert all(store.get(job_id) is not None for job_id in histories)
assert _ids(store, user_id="other") == other_user[-keep:]
# The newest job of every lane, which the App and live sync health show,
# is always kept.
assert store.latest_job(user_id="local", source_id="beta").job_id == "beta-local-sync-00011"
before = _ids(store)
assert store.prune_finished_sync_jobs(keep=keep) == 0
assert _ids(store) == before
def test_pruning_is_bounded_per_call_and_finishes_when_repeated(tmp_path) -> None:
store = _store(tmp_path)
for minute in range(60):
_job(store, minute=minute)
passes = []
while True:
removed = store.prune_finished_sync_jobs(keep=10, max_rows=15)
passes.append(removed)
if removed < 15:
break
assert passes == [15, 15, 15, 5]
assert len(_ids(store)) == 10
def test_the_default_keep_count_is_twenty(tmp_path) -> None:
store = _store(tmp_path)
for minute in range(SYNC_JOBS_KEPT_PER_SOURCE + 7):
_job(store, minute=minute)
assert store.prune_finished_sync_jobs() == 7
assert len(_ids(store)) == SYNC_JOBS_KEPT_PER_SOURCE == 20
def test_startup_pruning_runs_to_completion_off_the_loop(tmp_path) -> None:
store = _store(tmp_path)
for minute in range(500):
_job(store, minute=minute)
async def scenario() -> None:
task = asyncio.create_task(_prune_finished_import_jobs(store, interval_seconds=3600))
for _ in range(500):
await asyncio.sleep(0.01)
if len(await asyncio.to_thread(_ids, store)) == SYNC_JOBS_KEPT_PER_SOURCE:
break
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
asyncio.run(scenario())
assert len(_ids(store)) == SYNC_JOBS_KEPT_PER_SOURCE
def test_filtered_queries_answer_what_the_python_filters_did(tmp_path) -> None:
store = _store(tmp_path)
rng = random.Random(7)
lanes = [(user, source) for user in ("local", "other") for source in ("a", "b", "c")]
for minute in range(240):
user, source = rng.choice(lanes)
_job(
store,
user_id=user,
source_id=source,
operation=rng.choice(("sync", "history")),
# Only terminal shapes: the active-slot index allows one active job per lane.
status=rng.choice(FINISHED_STATUSES),
minute=minute,
)
everything: list[ImportJob] = store.list_jobs()
assert len(everything) == 240
status_sets = [
None,
(ImportStatus.COMPLETED,),
(ImportStatus.CANCELLED, ImportStatus.COMPLETED),
(ImportStatus.RUNNING,),
]
for user_id in (None, "local", "other"):
for source_id in (None, "a", "c"):
for operation in (None, "sync", "history"):
for statuses in status_sets:
expected = [
job
for job in everything
if (user_id is None or job.user_id == user_id)
and (source_id is None or job.source_id == source_id)
and (operation is None or job.operation == operation)
and (statuses is None or job.status in statuses)
]
found = store.find_jobs(
user_id=user_id,
source_id=source_id,
operation=operation,
statuses=statuses,
)
assert found == expected
newest = store.find_jobs(
user_id=user_id,
source_id=source_id,
operation=operation,
statuses=statuses,
newest_first=True,
limit=1,
)
assert newest == (
[max(expected, key=lambda job: (job.created_at, job.job_id))]
if expected
else []
)
for status in ImportStatus:
assert store.list_jobs(status=status) == [job for job in everything if job.status is status]
assert store.find_jobs(statuses=()) == []
def test_the_lookup_indexes_arrive_by_migration_and_serve_the_lane_query(tmp_path) -> None:
path = tmp_path / "one-memory.sqlite3"
older = tuple(
item for item in MIGRATIONS if item.version < IMPORT_JOB_LOOKUP_INDEXES_MIGRATION_VERSION
)
asyncio.run(migrate(SqliteMigrationStore(path), older))
with sqlite3.connect(path) as database:
before = {
row[0] for row in database.execute("SELECT name FROM sqlite_master WHERE type='index'")
}
assert "import_jobs_lane" not in before
result = asyncio.run(migrate(SqliteMigrationStore(path), MIGRATIONS))
assert result.applied == (IMPORT_JOB_LOOKUP_INDEXES_MIGRATION_VERSION,)
with sqlite3.connect(path) as database:
indexes = {
row[0] for row in database.execute("SELECT name FROM sqlite_master WHERE type='index'")
}
plan = " ".join(
str(row[-1])
for row in database.execute(
"EXPLAIN QUERY PLAN SELECT * FROM import_jobs "
"WHERE user_id=? AND source_id=? AND operation=? AND status IN (?,?) "
"ORDER BY created_at DESC LIMIT 1",
("local", "fixture", "sync", "pending", "running"),
)
)
assert {"import_jobs_lane", "import_jobs_status"} <= indexes
assert "import_jobs_lane" in plan
# Re-running it is a no-op, and a store opened on the database agrees.
asyncio.run(migrate(SqliteMigrationStore(path), MIGRATIONS))
ImportJobStore(path)
def test_a_running_home_gets_the_indexes_from_the_store(tmp_path) -> None:
path = tmp_path / "one-memory.sqlite3"
ImportJobStore(path)
with sqlite3.connect(path) as database:
database.execute("DROP INDEX import_jobs_lane")
database.execute("DROP INDEX import_jobs_status")
ImportJobStore(path)
with sqlite3.connect(path) as database:
indexes = {
row[0] for row in database.execute("SELECT name FROM sqlite_master WHERE type='index'")
}
assert {"import_jobs_lane", "import_jobs_status"} <= indexes
def test_the_mysql_rendering_tolerates_an_index_already_there() -> None:
class Cursor:
dialect = "mysql"
def __init__(self) -> None:
self.attempted: list[str] = []
async def execute(self, statement: str) -> None:
self.attempted.append(statement)
raise RuntimeError(1061, "duplicate key name")
cursor = Cursor()
migration = next(
item for item in MIGRATIONS if item.version == IMPORT_JOB_LOOKUP_INDEXES_MIGRATION_VERSION
)
asyncio.run(migration.apply(cursor))
assert cursor.attempted and "import_jobs" in cursor.attempted[0]
def test_the_import_jobs_route_keeps_its_shape_and_order(tmp_path) -> None:
async def scenario() -> None:
app = create_app(
Settings(
data_dir=tmp_path / "data",
source_home=tmp_path / "home",
api_token=SecretStr("synthetic"),
automatic_memory_processing=False,
live_sync_enabled=False,
)
)
async with (
app.router.lifespan_context(app),
AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
headers={"Authorization": "Bearer synthetic"},
) as client,
):
store: ImportJobStore = app.state.import_job_store
for minute in (3, 1, 2):
_job(store, source_id="fixture", minute=minute)
_job(store, source_id="fixture", operation="history", minute=0)
_job(store, user_id="other", source_id="hermes", minute=4)
response = await client.get("/api/v1/import-jobs")
other = await client.get("/api/v1/import-jobs", params={"user_id": "other"})
assert response.status_code == 200
body = response.json()
expected = [job for job in store.list_jobs() if job.user_id == "local"]
assert [item["job_id"] for item in body] == [job.job_id for job in expected]
assert [item["created_at"] for item in body] == sorted(
item["created_at"] for item in body
)
assert set(body[0]) == set(ImportJob.model_fields)
assert [item["user_id"] for item in other.json()] == ["other"]
asyncio.run(scenario())
def test_memory_processing_reports_the_write_drain(tmp_path) -> None:
async def scenario() -> None:
app = create_app(
Settings(
data_dir=tmp_path / "data",
source_home=tmp_path / "home",
api_token=SecretStr("synthetic"),
automatic_memory_processing=False,
live_sync_enabled=False,
)
)
async with (
app.router.lifespan_context(app),
AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
headers={"Authorization": "Bearer synthetic"},
) as client,
):
for _ in range(500):
if app.state.recovery in {"done", "failed"}:
break
await asyncio.sleep(0.01)
body = (await client.get("/api/v1/memory-processing")).json()
assert body["write_drain"] == "running"
assert body["write_drain_error"] is None
drain = app.state.memory_write_drain
drain.liveness = "restarting"
drain.last_error_code = "operational_error"
body = (await client.get("/api/v1/memory-processing")).json()
assert body["write_drain"] == "restarting"
assert body["write_drain_error"] == "operational_error"
drain.liveness = "running"
asyncio.run(scenario())
@@ -415,15 +415,15 @@ def test_shutdown_preserves_a_job_that_finished_after_the_running_snapshot(
task = asyncio.create_task(waiting())
supervisor._tasks[job.job_id] = task
await asyncio.sleep(0)
original = jobs.list_jobs
original = jobs.find_jobs
def finish_after_snapshot(*args, **kwargs):
found = original(*args, **kwargs)
if kwargs.get("status") is ImportStatus.RUNNING:
if tuple(kwargs.get("statuses") or ()) == (ImportStatus.RUNNING,):
jobs.finish_owned(job.job_id, runner_token=token, target=terminal)
return found
monkeypatch.setattr(jobs, "list_jobs", finish_after_snapshot)
monkeypatch.setattr(jobs, "find_jobs", finish_after_snapshot)
try:
await supervisor.shutdown()
assert jobs.require(job.job_id).status is terminal
@@ -433,3 +433,45 @@ def test_shutdown_preserves_a_job_that_finished_after_the_running_snapshot(
await asyncio.gather(task, return_exceptions=True)
asyncio.run(scenario())
def test_abandon_makes_its_database_calls_off_the_event_loop(tmp_path, monkeypatch) -> None:
"""Live sync abandons from its poll; a writer wait there froze the whole Core."""
import threading
async def scenario() -> None:
evidence, jobs = stores(tmp_path)
supervisor = ImportSupervisor(adapters={}, evidence_store=evidence, job_store=jobs)
job = jobs.create(user_id="local", source_id="fixture", operation="sync")
job, _token = jobs.claim(job.job_id)
loop_thread = threading.current_thread()
calls: list[tuple[str, threading.Thread]] = []
for name in ("require", "interrupt_running"):
original = getattr(jobs, name)
def recorded(*args, _name=name, _original=original, **kwargs):
calls.append((_name, threading.current_thread()))
return _original(*args, **kwargs)
monkeypatch.setattr(jobs, name, recorded)
async def wedged() -> None:
# Ignores the first cancel, like a runner stuck in a thread.
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
await asyncio.sleep(0.2)
task = asyncio.create_task(wedged())
supervisor._tasks[job.job_id] = task
await asyncio.sleep(0)
try:
assert await supervisor.abandon(job.job_id, timeout=0.01) is False
finally:
await asyncio.gather(task, return_exceptions=True)
assert {name for name, _thread in calls} == {"require", "interrupt_running"}
assert all(thread is not loop_thread for _name, thread in calls)
assert jobs.require(job.job_id).status is ImportStatus.INTERRUPTED
asyncio.run(scenario())
+34
View File
@@ -293,6 +293,11 @@ def test_health_states_that_watching_is_layered_on_the_timer(tmp_path: Path) ->
assert live.health(PI_DESCRIPTOR.source_id).mode == "watch_and_polling"
assert live.health("unknown-source").mode == "watch_and_polling"
live.set_watched_sources((PI_DESCRIPTOR.source_id,))
assert live._poll_interval(PI_DESCRIPTOR.source_id) == max(
live.interval_seconds, 15 * 60
)
assert live._poll_interval("unknown-source") == live.interval_seconds
await imports.shutdown()
asyncio.run(scenario())
@@ -641,3 +646,32 @@ def _job(*, created_at: datetime) -> ImportJob:
created_at=created_at,
updated_at=datetime.now(UTC),
)
def test_attributing_changed_paths_happens_off_the_event_loop(tmp_path: Path, monkeypatch) -> None:
"""Resolving a host's batch of changed paths blocked the loop for seconds."""
import threading
async def scenario() -> None:
root, (_evidence, live, imports) = _source(tmp_path)
watch = FileWatchLiveSync(
roots={PI_DESCRIPTOR.source_id: root},
supervisor=live,
debounce_seconds=0.1,
min_interval_seconds=0.1,
)
loop_thread = threading.current_thread()
resolved_on: list[threading.Thread] = []
original = watch.source_for
def recorded(path: Path) -> str | None:
resolved_on.append(threading.current_thread())
return original(path)
monkeypatch.setattr(watch, "source_for", recorded)
assert await watch.handle_changes({tmp_path / "elsewhere/session.jsonl"}) == ()
assert resolved_on and loop_thread not in resolved_on
await imports.shutdown()
asyncio.run(scenario())
@@ -318,9 +318,31 @@ def test_main_database_checkpoints_on_a_write_cadence(tmp_path, monkeypatch):
with database.write() as db, db:
db.execute("UPDATE counter SET value = 2 WHERE id = 1")
assert _access.wait_for_cadence_checkpoints()
assert checkpoints and checkpoints[0][0] == "PASSIVE"
def test_the_cadence_checkpoint_does_not_run_on_the_writing_thread(tmp_path, monkeypatch):
"""The thread that wrote returns at once; the checkpointer takes the next turn."""
path = _database(tmp_path)
database = main_database(path)
ran_on = []
original = database._checkpoint_from_maintenance
def observed(mode):
ran_on.append(threading.current_thread().name)
return original(mode)
monkeypatch.setattr(database, "_checkpoint_from_maintenance", observed)
monkeypatch.setattr(_access, "MAIN_DATABASE_CHECKPOINT_WRITE_INTERVAL", 1)
with database.write() as db, db:
db.execute("UPDATE counter SET value = 1 WHERE id = 1")
assert _access.wait_for_cadence_checkpoints()
assert ran_on == ["one-memory-checkpoint"]
def test_main_database_warns_when_wal_stays_above_the_high_water_mark(
tmp_path, monkeypatch, caplog
):
@@ -332,6 +354,7 @@ def test_main_database_warns_when_wal_stays_above_the_high_water_mark(
with database.write() as db, db:
db.execute("UPDATE counter SET value = 1 WHERE id = 1")
assert _access.wait_for_cadence_checkpoints()
assert any("main_database_wal_high" in record.message for record in caplog.records)
@@ -349,6 +372,7 @@ def test_a_wal_whose_frames_are_all_checkpointed_is_not_reported_high(
with database.write() as db, db:
db.execute("UPDATE counter SET value = 1 WHERE id = 1")
assert _access.wait_for_cadence_checkpoints()
assert database._wal_bytes() > 0
assert not any("main_database_wal_high" in record.message for record in caplog.records)
@@ -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.
@@ -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)
@@ -5442,3 +5528,190 @@ def test_a_disabled_source_suspends_its_jobs_and_enabling_it_resumes_them(tmp_pa
assert runtime.status_counts() == {PENDING: 3, COMPLETED: 1}
assert runtime.queue_depths().fresh == 3
assert len(drain.claim(limit=10)) == 1
def test_a_claim_path_error_restarts_the_drain_and_later_jobs_still_run(
tmp_path, monkeypatch
) -> None:
"""An unexpected SQLite error on the claim path used to end the drain for good."""
from one_memory.memory_server import durable_queue as _queue_module
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
queue.submit(_request(key="later", source="cursor"))
reported: list[tuple[str, BaseException]] = []
monkeypatch.setattr(
_queue_module,
"report_exception",
lambda component, event, error, **_kwargs: reported.append((event, error)),
)
async def execute(job_id: str) -> None:
queue.settle(job_id, state=COMPLETED)
drain.stop()
drain = DurableWriteDrain(queue, execute=execute, max_concurrent_sources=1)
real_claim = drain.claim
calls = {"claim": 0}
def failing_once(**kwargs):
calls["claim"] += 1
if calls["claim"] == 1:
raise sqlite3.OperationalError("disk I/O error")
return real_claim(**kwargs)
monkeypatch.setattr(drain, "claim", failing_once)
first_owner = drain.owner
asyncio.run(
asyncio.wait_for(
drain.run_supervised(idle_pause=0.005, restart_backoff=0.01, max_restart_backoff=0.05),
2,
)
)
assert queue.get("later").state == COMPLETED
assert drain.restarts == 1
assert drain.last_error_code == "operational_error"
assert drain.liveness == "stopped"
# A restarted drain leases under a new name: nothing the old one left in a
# pool thread can renew or settle as it.
assert drain.owner != first_owner
assert [event for event, _error in reported] == ["memory.write_queue.drain_exited"]
assert reported[0][1].__traceback__ is not None
def test_a_claim_gate_exception_restarts_the_drain(tmp_path, monkeypatch) -> None:
from one_memory.memory_server import durable_queue as _queue_module
monkeypatch.setattr(_queue_module, "report_exception", lambda *_args, **_kwargs: None)
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
queue.submit(_request(key="gated", source="cursor"))
asked = {"count": 0}
def gate(_source_key: str) -> bool:
asked["count"] += 1
if asked["count"] == 1:
raise KeyError("gate bug")
return True
async def execute(job_id: str) -> None:
queue.settle(job_id, state=COMPLETED)
drain.stop()
drain = DurableWriteDrain(queue, execute=execute, source_claim_enabled=gate)
asyncio.run(asyncio.wait_for(drain.run_supervised(idle_pause=0.005, restart_backoff=0.01), 2))
assert queue.get("gated").state == COMPLETED
assert drain.last_error_code == "key_error"
def test_stop_ends_a_restart_backoff_at_once(tmp_path, monkeypatch) -> None:
from one_memory.memory_server import durable_queue as _queue_module
monkeypatch.setattr(_queue_module, "report_exception", lambda *_args, **_kwargs: None)
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
drain = DurableWriteDrain(queue, execute=_never_execute)
def broken(**_kwargs):
raise sqlite3.DatabaseError("database disk image is malformed")
monkeypatch.setattr(drain, "claim", broken)
async def scenario() -> None:
task = asyncio.create_task(drain.run_supervised(restart_backoff=30))
for _ in range(200):
if drain.liveness == "restarting":
break
await asyncio.sleep(0.005)
assert drain.liveness == "restarting"
drain.stop()
await asyncio.wait_for(task, 1)
asyncio.run(scenario())
assert drain.liveness == "stopped"
assert drain.last_error_code == "database_error"
def test_a_cancelled_supervised_drain_stays_cancelled(tmp_path) -> None:
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
drain = DurableWriteDrain(queue, execute=_never_execute)
async def scenario() -> None:
task = asyncio.create_task(drain.run_supervised(idle_pause=0.005))
await asyncio.sleep(0.05)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
asyncio.run(scenario())
assert drain.liveness == "stopped"
assert drain.restarts == 0
def test_a_contained_job_task_failure_is_reported_with_its_stack(tmp_path, monkeypatch) -> None:
from one_memory.memory_server import durable_queue as _queue_module
reported: list[tuple[str, BaseException]] = []
monkeypatch.setattr(
_queue_module,
"report_exception",
lambda component, event, error, **_kwargs: reported.append((event, error)),
)
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
first = queue.submit(_request(key="first", source="cursor"))
queue.submit(_request(key="second", source="codex"))
async def run_job(job_id: str) -> None:
if job_id == first.job_id:
raise RuntimeError("systematic job task bug")
drain.stop()
drain = DurableWriteDrain(queue, execute=_never_execute, max_concurrent_sources=1)
monkeypatch.setattr(drain, "_run_claimed_job", run_job)
asyncio.run(asyncio.wait_for(drain.run_forever(idle_pause=0.005), 1))
assert [event for event, _error in reported] == ["memory.write_queue.job_task_failed"]
assert reported[0][1].__traceback__ is not None
def test_a_lease_is_renewed_while_every_bulk_thread_is_blocked(tmp_path) -> None:
"""The incident: bulk threads stuck in job listings starved lease renewal."""
from one_memory.background_work import BULK_WORKERS, bulk_pool, shutdown_bulk_pool
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
queue.submit(_request(key="slow", source="cursor"))
release = threading.Event()
blocked = [bulk_pool().submit(release.wait, 10) for _ in range(BULK_WORKERS * 2)]
renewed: list[bool] = []
async def execute(job_id: str) -> None:
# Longer than the lease: only renewals keep it.
await asyncio.sleep(0.8)
queue.settle(job_id, state=COMPLETED, owner=drain.owner)
drain = DurableWriteDrain(
queue,
execute=execute,
lease=timedelta(milliseconds=400),
renew_every=timedelta(milliseconds=50),
)
real_renew = drain.renew
def counted(job_id: str, **kwargs) -> bool:
result = real_renew(job_id, **kwargs)
renewed.append(result)
return result
drain.renew = counted # type: ignore[method-assign]
try:
assert asyncio.run(asyncio.wait_for(drain.run_one(), 3)) == 1
finally:
release.set()
for future in blocked:
future.result(timeout=5)
shutdown_bulk_pool()
assert renewed and all(renewed)
assert queue.get("slow").state == COMPLETED
@@ -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
@@ -91,6 +91,51 @@ def test_reserved_before_transport_and_atomic_result_reopens_without_resending(e
asyncio.run(scenario())
def test_pre_dispatch_failure_marks_operation_failed_and_unfences(env, monkeypatch):
store, secrets, profile, ops, calls, respond = env
operation = str(uuid4())
def fail_require(*args, **kwargs):
raise RuntimeError("profile load failed")
prober = ModelProber(store=store, secrets=secrets, transport=httpx.MockTransport(respond))
with monkeypatch.context() as patch:
patch.setattr(store, "require", fail_require)
receipt = asyncio.run(
prober.probe(
profile.profile_id,
operation_id=operation,
expected_profile_revision=profile.revision,
)
)
assert receipt.state == "failed"
assert receipt.code == "model_probe_execution_failed"
assert receipt.result is None
assert calls == []
assert ops.lookup(operation) == receipt
assert (
asyncio.run(
prober.probe(
profile.profile_id,
operation_id=operation,
expected_profile_revision=profile.revision,
)
)
== receipt
)
completed = asyncio.run(
prober.probe(
profile.profile_id,
operation_id=str(uuid4()),
expected_profile_revision=profile.revision,
)
)
assert completed.state == "completed"
assert len(calls) == 3
def test_same_id_and_same_profile_concurrency_refused_before_second_call(env):
store, secrets, profile, ops, calls, respond = env
@@ -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,204 @@ 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()
ultra = await client.put(
"/api/v1/onboarding/progress",
json={"mode_intent": "ultra", "setup_step": "model"},
)
assert ultra.status_code == 200
assert ultra.json()["mode_intent"] == "ultra"
asyncio.run(scenario())
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": "future"},
)
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
@@ -189,7 +189,11 @@ def test_first_import_gate_waits_for_every_selected_history_job() -> None:
app = SimpleNamespace(
state=SimpleNamespace(
onboarding_store=SimpleNamespace(get=lambda: state),
import_job_store=SimpleNamespace(list_jobs=lambda: jobs),
import_job_store=SimpleNamespace(
find_jobs=lambda *, user_id, operation: [
job for job in jobs if job.user_id == user_id and job.operation == operation
]
),
)
)
@@ -77,6 +77,36 @@ def test_workbuddy_record_parsing_never_blocks_the_event_loop(tmp_path) -> None:
asyncio.run(scenario())
def test_jsonl_live_probe_listing_is_reused_by_the_import(tmp_path, monkeypatch) -> None:
root = tmp_path / ".workbuddy" / "projects"
write_jsonl(
root / "project" / "session.jsonl",
[{"role": "user", "sessionId": "session", "content": "new conversation"}],
)
adapter = create_workbuddy_adapter(grant("workbuddy", root))
from one_memory.imports.sources import common
calls = 0
original = common.list_changed_files
def counted(*args, **kwargs):
nonlocal calls
calls += 1
return original(*args, **kwargs)
monkeypatch.setattr(common, "list_changed_files", counted)
async def probe_then_scan():
options = ScanOptions()
assert await adapter.has_changes(options)
return [event async for event in adapter.scan(options)]
records = evidence(asyncio.run(probe_then_scan()))
assert [item.evidence.verbatim for item in records] == ["new conversation"]
assert calls == 1
def test_cursor_store_user_info_envelope_is_not_attributed_to_the_person(tmp_path) -> None:
"""A Cursor store blob can be the host's session context, not the user's words.
@@ -1352,6 +1382,73 @@ def test_cursor_transcripts_with_the_same_name_in_two_projects_are_both_kept(
assert len({item.evidence.source_ordinal for item in records}) == 2
def test_cursor_discovery_does_not_enter_non_conversation_trees(tmp_path) -> None:
root = tmp_path / ".cursor"
write_jsonl(
root / "projects" / "project" / "agent-transcripts" / "kept" / "kept.jsonl",
[
{
"role": "user",
"message": {"content": [{"type": "text", "text": "kept conversation"}]},
}
],
)
write_jsonl(
root
/ "extensions"
/ "large-cache"
/ "agent-transcripts"
/ "ignored"
/ "ignored.jsonl",
[
{
"role": "user",
"message": {"content": [{"type": "text", "text": "must not be scanned"}]},
}
],
)
records = evidence(asyncio.run(scan(CursorUnifiedSourceAdapter(grant=grant("cursor", root)))))
assert [item.evidence.verbatim for item in records] == ["kept conversation"]
def test_cursor_live_probe_listing_is_reused_by_the_import(tmp_path, monkeypatch) -> None:
root = tmp_path / ".cursor"
write_jsonl(
root / "projects" / "project" / "agent-transcripts" / "new" / "new.jsonl",
[
{
"role": "user",
"message": {"content": [{"type": "text", "text": "new conversation"}]},
}
],
)
adapter = CursorUnifiedSourceAdapter(grant=grant("cursor", root))
from one_memory.imports.adapters import ScanOptions
from one_memory.imports.sources import cursor_unified
calls = 0
original = cursor_unified._list_cursor_files
def counted(*args, **kwargs):
nonlocal calls
calls += 1
return original(*args, **kwargs)
monkeypatch.setattr(cursor_unified, "_list_cursor_files", counted)
async def probe_then_scan():
options = ScanOptions()
assert await adapter.has_changes(options)
return [event async for event in adapter.scan(options)]
records = evidence(asyncio.run(probe_then_scan()))
assert [item.evidence.verbatim for item in records] == ["new conversation"]
assert calls == 1
def test_cursor_bounded_scans_reuse_the_authorized_file_listing(tmp_path, monkeypatch) -> None:
root = tmp_path / ".cursor"
transcript = root / "projects" / "project" / "agent-transcripts" / "session" / "session.jsonl"
+247 -3
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"]:
@@ -173,7 +214,15 @@ class RecordingSdk:
)
and (not kwargs.get("session_ids") or row["session_id"] in kwargs["session_ids"])
]
return {"vdb": {"memories": rows, "total": len(rows)}}
total = len(rows)
offset = kwargs.get("offset", 0)
limit = kwargs.get("limit", 100)
return {
"vdb": {
"memories": rows[offset : offset + limit],
"total": total,
}
}
rows = [_chat(user_id, *row[:2], *row[3:]) for row in CHAT_ROWS.get(user_id, [])]
if layers:
rows = [row for row in rows if row["layer"] in layers]
@@ -271,6 +320,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 +400,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 +696,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 +711,123 @@ 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] == [
["我喜欢绿茶", "也记住了。"],
["我叫费天翔", "好的,记住了。"],
]
cursor = None
first_cursor = None
cursor_turns = []
cursor_calls = len(sdk.all("list_memories"))
while True:
params = {"unit": "turn", "paging": "cursor", "limit": 1}
if cursor is not None:
params["cursor"] = cursor
response = await client.get("/api/v2/raw", params=params)
assert response.status_code == 200, response.text
cursor_page = response.json()
assert cursor_page["total_exact"] is False
assert len(cursor_page["items"]) == 1
cursor_turns.append(cursor_page["items"][0]["messages"][0]["content"])
cursor = cursor_page["next_cursor"]
if first_cursor is None:
first_cursor = cursor
if cursor is None:
assert cursor_page["has_more"] is False
break
assert cursor_page["has_more"] is True
assert cursor_turns == ["我喜欢绿茶", "我叫费天翔", "你好"]
assert len(sdk.all("list_memories")) == cursor_calls + 3
inserted = {
"memory_id": "raw-new",
"content": "[user]: 新消息",
"status": "active",
"session_id": "s-new",
"source": "cursor",
"memory_at": 1_790_200_000,
"gmt_created": 1_790_200_100,
}
RAW_ROWS["local"].insert(0, inserted)
try:
continued = await client.get(
"/api/v2/raw",
params={
"unit": "turn",
"paging": "cursor",
"limit": 1,
"cursor": first_cursor,
},
)
assert continued.status_code == 200, continued.text
assert continued.json()["items"][0]["messages"][0]["content"] == "我叫费天翔"
finally:
RAW_ROWS["local"].remove(inserted)
assert (
await client.get(
"/api/v2/raw",
params={
"unit": "turn",
"paging": "cursor",
"cursor": first_cursor,
"source": "hermes",
},
)
).status_code == 422
assert (
await client.get(
"/api/v2/raw",
params={
"unit": "turn",
"paging": "cursor",
"cursor": "turn:²:0:0:2::0000000000000000",
},
)
).status_code == 422
calls_before_refresh = len(sdk.all("list_memories"))
await client.get("/api/v2/raw", params={"unit": "turn"})
assert len(sdk.all("list_memories")) == calls_before_refresh + 1
# 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")],
@@ -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.13"
assert manifest["snapshot_sdk_version"] == "2.0.14"
paths = {item["path"]: item for item in manifest["files"]}
assert {
"management/one-memory.sqlite3",
@@ -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.13"
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/88/46/7d5a2d76be135664302d91e15f0d5018cf8e228956e4c80b86583b131cb3/hy_memory-2.0.13.tar.gz", hash = "sha256:f95850d492de74e91891502716da96e8b5297c3fc045dc70ec3474ed240da958", size = 1252802, upload-time = "2026-11-02T09:30:40.331Z" }
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/99/63/2b4d21d555a5dd50ab838fad944c7fdd6cf90d2dc814e2a8e79eca3d4e03/hy_memory-2.0.13-py3-none-any.whl", hash = "sha256:128cf4729e6ea10b9da3c7fdf21005191294621a65378cd9583827904eea064a", size = 1322154, upload-time = "2026-11-02T09:30:37.746Z" },
{ 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.13" },
{ 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" },
@@ -366,7 +366,7 @@ it("refuses a ui trace from an untrusted window", async () => {
).rejects.toThrow(/untrusted_window/);
});
it("marks a problem and exports a redacted local bundle", async () => {
it("marks a problem and exports the complete local log bundle", async () => {
const value = await fixture();
const mark = await value.handlers.get(
"one-memory:mark-diagnostic-incident",
@@ -375,6 +375,14 @@ it("marks a problem and exports a redacted local bundle", async () => {
incident_id: expect.any(String),
session_id: expect.any(String),
});
await writeFile(
path.join(value.dataDir, "logs", "core.log"),
"2026-10-14 10:31:00 WARNING database wait\n",
);
await writeFile(
path.join(value.dataDir, "logs", "service-error.log"),
"worker failed\n",
);
const exported = await value.handlers.get("one-memory:export-diagnostics")?.(
{},
);
@@ -398,6 +406,16 @@ it("marks a problem and exports a redacted local bundle", async () => {
),
);
expect(summary).toMatchObject({ build: "3800" });
const entries = execFileSync(
"/usr/bin/unzip",
["-Z1", path.join(value.dataDir, zip)],
{ encoding: "utf8" },
)
.trim()
.split("\n");
expect(entries).toContain("logs/core.log");
expect(entries).toContain("logs/service-error.log");
expect(entries).toContain("logs/diagnostic-desktop.jsonl");
value.dispose();
});
+3 -1
View File
@@ -196,6 +196,7 @@ export function resolveDiagnosticBuildIdentity(options: {
export async function exportDesktopDiagnostics(
options: DiagnosticExportOptions,
exportOptions: { includeRawLogs?: boolean } = {},
) {
const build = options.buildNumber ?? options.productVersion;
const target = path.join(
@@ -209,6 +210,7 @@ export async function exportDesktopDiagnostics(
dataDir: options.dataDir,
target,
productVersion: build,
includeRawLogs: exportOptions.includeRawLogs === true,
...(options.commit === undefined ? {} : { commit: options.commit }),
...(options.coreSha256 === undefined
? {}
@@ -644,7 +646,7 @@ export function installDiagnosticsIpc(
options.ipc.handle(EXPORT, async (event, ...args) => {
if (args.length) throw new Error("diagnostic_arguments_rejected");
options.requireTrustedWindow(event);
return exportDesktopDiagnostics(options);
return exportDesktopDiagnostics(options, { includeRawLogs: true });
});
return () => {
@@ -1235,7 +1235,9 @@ it("shows lite raw ingestion as running even when model sedimentation is paused"
/>,
);
expect(await screen.findAllByText("正在保存原始对话")).toHaveLength(2);
expect(await screen.findAllByText("正在保存")).toHaveLength(2);
expect(screen.getByText("暂无更新")).toBeTruthy();
expect(screen.getByText("已核对,暂无可显示的原始对话更新")).toBeTruthy();
expect(screen.getByText("原始对话持续保存,不需要生成模型")).toBeTruthy();
expect(screen.queryByText("已暂停")).toBeNull();
});
@@ -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();
}
+1
View File
@@ -226,6 +226,7 @@ describe("HealthPanel", () => {
});
vi.spyOn(window, "prompt").mockReturnValue("蓝莓没出来");
render(<HealthPanel imports={imports()} />);
expect(screen.getByText(/可能包含会话正文和本机路径/)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "记录问题时间点" }));
expect(await screen.findByText(/问题编号已复制/)).toBeTruthy();
+1
View File
@@ -298,6 +298,7 @@ export function HealthPanel({ imports }: { imports: ImportState }) {
<Download size={16} aria-hidden="true" />
{t("nativeExportLogs")}
</R6Button>
<p>{t("把本机运行日志打包成一个文件,发给开发排查。")}</p>
</div>
<section className="health-block" aria-label={t("healthLocal")}>
+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 && (
+7 -7
View File
@@ -149,7 +149,7 @@ it("OBS-22: the graph inspector has no editing entry and never says changes cann
expect(screen.queryByText(/暂不能保存更改/)).toBeNull();
});
it("replaces a topic detail with an off-page graph memory detail", async () => {
it("keeps topic annotations out of the inspector and opens detail-bearing graph nodes", async () => {
const onOpen = vi.fn();
const topicGraph = {
total_memories: 2,
@@ -221,13 +221,13 @@ it("replaces a topic detail with an off-page graph memory detail", async () => {
/>,
);
await act(async () => {});
onOpen.mockClear();
await userEvent.click(screen.getByRole("button", { name: "图中选择 git" }));
expect(screen.getByRole("heading", { name: "git" })).toBeTruthy();
expect(screen.getAllByText("第一条").length).toBeGreaterThan(0);
expect(screen.queryByText("对应记忆")).toBeNull();
expect(screen.queryByText("属于主题")).toBeNull();
expect(screen.queryByText("主题")).toBeNull();
expect(screen.queryByText("两跳")).toBeNull();
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: "图中选择 先验证,再推送" }),
);
+81 -61
View File
@@ -110,12 +110,15 @@ type Props = {
settledSource?: string;
settledQuery?: string;
requestedOffset?: number;
cursorPaging?: boolean;
hasMore?: boolean;
/** The source id the SDK uses for "no source given" (shown as 未识别来源). */
unattributedSource?: string;
onQuery(value: string): void;
onSource(value: string): void;
resetPage(): void;
onPage(offset: number): void;
onMore?(): void;
onRetry(): void;
};
};
@@ -684,6 +687,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;
@@ -708,11 +714,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 ??
@@ -1133,62 +1134,81 @@ export function MemoryMap(props: Props) {
aria-busy={pageTransitioning || undefined}
className="map-pagination"
>
<button
type="button"
className="quiet map-tool"
aria-label={text("上一页记忆")}
title={text("上一页记忆")}
disabled={pageTransitioning || library.page.offset === 0}
onClick={() => {
if (axis === "topic") setGroupKey("");
if (library.page) {
const offset = library.page.offset - library.page.limit;
setPageRetrying(false);
pageRetrySawLoading.current = false;
setPendingPageOffset(offset);
library.onPage(offset);
}
}}
>
<ChevronLeft size={16} aria-hidden="true" />
</button>
<span>
{text(
"共 {count} 条,第 {page} / {pages} 页",
{
count: count(library.page.total),
page:
library.page.total === 0
? 0
: Math.floor(library.page.offset / library.page.limit) +
1,
pages: Math.ceil(library.page.total / library.page.limit),
},
library.page.total,
)}
</span>
<button
type="button"
className="quiet map-tool"
aria-label={text("下一页记忆")}
title={text("下一页记忆")}
disabled={
pageTransitioning ||
library.page.offset + library.page.limit >= library.page.total
}
onClick={() => {
if (axis === "topic") setGroupKey("");
if (library.page) {
const offset = library.page.offset + library.page.limit;
setPageRetrying(false);
pageRetrySawLoading.current = false;
setPendingPageOffset(offset);
library.onPage(offset);
}
}}
>
<ChevronRight size={16} aria-hidden="true" />
</button>
{library.cursorPaging ? (
library.hasMore ? (
<R6Button
disabled={pageTransitioning}
onClick={() => library.onMore?.()}
>
{pageTransitioning ? text("正在读取…") : text("加载更多")}
</R6Button>
) : (
<span>{text("已加载全部")}</span>
)
) : (
<>
<button
type="button"
className="quiet map-tool"
aria-label={text("上一页记忆")}
title={text("上一页记忆")}
disabled={pageTransitioning || library.page.offset === 0}
onClick={() => {
if (axis === "topic") setGroupKey("");
if (library.page) {
const offset = library.page.offset - library.page.limit;
setPageRetrying(false);
pageRetrySawLoading.current = false;
setPendingPageOffset(offset);
library.onPage(offset);
}
}}
>
<ChevronLeft size={16} aria-hidden="true" />
</button>
<span>
{text(
"共 {count} 条,第 {page} / {pages} 页",
{
count: count(library.page.total),
page:
library.page.total === 0
? 0
: Math.floor(
library.page.offset / library.page.limit,
) + 1,
pages: Math.ceil(
library.page.total / library.page.limit,
),
},
library.page.total,
)}
</span>
<button
type="button"
className="quiet map-tool"
aria-label={text("下一页记忆")}
title={text("下一页记忆")}
disabled={
pageTransitioning ||
library.page.offset + library.page.limit >=
library.page.total
}
onClick={() => {
if (axis === "topic") setGroupKey("");
if (library.page) {
const offset = library.page.offset + library.page.limit;
setPageRetrying(false);
pageRetrySawLoading.current = false;
setPendingPageOffset(offset);
library.onPage(offset);
}
}}
>
<ChevronRight size={16} aria-hidden="true" />
</button>
</>
)}
</nav>
)}
{!library && loaded && !filtered.length && (search || groupKey) && (
@@ -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>
);
}
+185 -49
View File
@@ -1,5 +1,6 @@
// @vitest-environment jsdom
import { legacyMemoryModePlan } from "@one-memory/operations";
import {
cleanup,
render,
@@ -64,31 +65,9 @@ function prepare(mode: "lite" | "pro" | "ultra" = "pro") {
running: [],
});
vi.mocked(getMemoryProcessing).mockResolvedValue({} as never);
vi.mocked(planMemoryMode).mockImplementation(async (target) => ({
current: mode,
target,
effective_target: target,
capabilities: {
content_plane: target === "lite" ? "raw" : "memory",
generation_required: target !== "lite",
graph_enabled: target === "ultra",
evolve_available: target === "ultra",
},
effects: [
...(target === "lite" ? (["higher_mode_memories_hidden"] as const) : []),
...(mode === "lite" && target !== "lite"
? (["earlier_raw_not_extracted", "lite_search_stops"] as const)
: []),
...(target === "ultra" && mode !== "ultra"
? (["existing_memories_added_to_graph"] as const)
: []),
...(mode === "ultra" && target !== "ultra"
? (["evolve_stopped"] as const)
: []),
],
warnings: [],
blockers: [],
}));
vi.mocked(planMemoryMode).mockImplementation(async (target) =>
legacyMemoryModePlan(mode, target, { hasActiveModel: true }),
);
}
async function chooseMode(
@@ -107,6 +86,19 @@ afterEach(() => {
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({
@@ -123,9 +115,7 @@ it("confirms the data effects before switching to lite", async () => {
expect(screen.getByText("搜索范围")).toBeTruthy();
expect(setMemoryMode).not.toHaveBeenCalled();
await user.click(
screen.getByRole("button", { name: "切换到基础记忆(lite)" }),
);
await user.click(screen.getByRole("button", { name: "确认选择" }));
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledWith("lite"));
await waitFor(() =>
expect(document.activeElement).toBe(
@@ -147,7 +137,7 @@ it("traps modal focus, closes on Escape and restores the mode-card focus", async
expect(outside.inert).toBe(true);
await user.keyboard("{Shift>}{Tab}{/Shift}");
expect(document.activeElement).toBe(
screen.getByRole("button", { name: "切换到基础记忆(lite)" }),
screen.getByRole("button", { name: "确认选择" }),
);
await user.keyboard("{Escape}");
@@ -163,9 +153,7 @@ it("keeps focus inside the modal while the mode change is pending", async () =>
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /基础记忆(lite)/);
await user.click(
screen.getByRole("button", { name: "切换到基础记忆(lite)" }),
);
await user.click(screen.getByRole("button", { name: "确认选择" }));
const modal = screen.getByRole("dialog");
await waitFor(() => expect(document.activeElement).toBe(modal));
@@ -214,6 +202,20 @@ it("uses the isolated legacy plan only for a route-level old-Core 404", async ()
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();
@@ -223,9 +225,11 @@ it("explains model cost, search scope and the next step before switching to ultr
expect(screen.getByText("模型调用与费用")).toBeTruthy();
expect(screen.getByText("搜索范围")).toBeTruthy();
expect(screen.getByText("切换后")).toBeTruthy();
expect(screen.getByText("选择后")).toBeTruthy();
expect(screen.getByText(/已经处理完的旧对话不会补做/)).toBeTruthy();
expect(screen.getByText(/切换本身不会立即整合/)).toBeTruthy();
expect(screen.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 () => {
@@ -235,9 +239,27 @@ it("distinguishes regular memories from higher-level Ultra data when switching t
await chooseMode(user, /智能记忆(pro)/);
expect(screen.getByText(/普通沉淀记忆仍可使用/)).toBeTruthy();
expect(screen.getByText(/更高层记忆和图谱数据继续保留/)).toBeTruthy();
expect(screen.getByText(/每天自动整合会停止/)).toBeTruthy();
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 () => {
@@ -253,9 +275,7 @@ it("focuses the Evolve settings after switching to ultra", async () => {
vi.mocked(getMemoryMode).mockResolvedValue(state("ultra"));
await chooseMode(user, /深度记忆(ultra)/);
await user.click(
screen.getByRole("button", { name: "切换到深度记忆(ultra)" }),
);
await user.click(screen.getByRole("button", { name: "确认选择" }));
await waitFor(() =>
expect(document.activeElement?.getAttribute("id")).toContain("-evolve"),
@@ -274,13 +294,131 @@ it("shows the default nightly evolve controls in ultra", async () => {
"aria-checked",
),
).toBe("true");
expect((screen.getByLabelText("执行时间") as HTMLInputElement).value).toBe(
"03:00",
);
const timePicker = screen.getByRole("button", {
name: "执行时间,03:00",
});
expect(timePicker.tagName).toBe("BUTTON");
expect(timePicker.textContent).toContain("03:00");
expect(screen.getByText(/只运行这一次,不改变每天的计划/)).toBeTruthy();
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
});
it("opens the time picker from the whole control and saves a complete time", async () => {
prepare("ultra");
vi.mocked(setEvolveSchedule).mockResolvedValue({
enabled: true,
time: "04:30",
});
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const trigger = await screen.findByRole("button", {
name: "执行时间,03:00",
});
await user.click(trigger);
const picker = screen.getByRole("dialog", { name: "执行时间" });
await user.selectOptions(
within(picker).getByRole("listbox", { name: "执行时间 HH" }),
"04",
);
await user.selectOptions(
within(picker).getByRole("listbox", { name: "执行时间 MM" }),
"30",
);
expect(setEvolveSchedule).not.toHaveBeenCalled();
await user.click(within(picker).getByRole("button", { name: "保存修改" }));
await waitFor(() =>
expect(setEvolveSchedule).toHaveBeenCalledExactlyOnceWith({
time: "04:30",
}),
);
await waitFor(() =>
expect(screen.getByRole("button", { name: "执行时间,04:30" })).toBe(
document.activeElement,
),
);
expect(screen.queryByRole("dialog", { name: "执行时间" })).toBeNull();
});
it("keeps Tab inside the time picker and restores focus on Escape", async () => {
prepare("ultra");
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const trigger = await screen.findByRole("button", {
name: "执行时间,03:00",
});
await user.click(trigger);
const picker = screen.getByRole("dialog", { name: "执行时间" });
const hours = within(picker).getByRole("listbox", {
name: "执行时间 HH",
});
const save = within(picker).getByRole("button", { name: "保存修改" });
expect(document.activeElement).toBe(hours);
await user.keyboard("{Shift>}{Tab}{/Shift}");
expect(document.activeElement).toBe(save);
await user.keyboard("{Tab}");
expect(document.activeElement).toBe(hours);
await user.keyboard("{Escape}");
expect(screen.queryByRole("dialog", { name: "执行时间" })).toBeNull();
expect(document.activeElement).toBe(trigger);
});
it("keeps the selected time open for retry when saving fails", async () => {
prepare("ultra");
vi.mocked(setEvolveSchedule).mockRejectedValue(new Error("offline"));
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await user.click(
await screen.findByRole("button", { name: "执行时间,03:00" }),
);
const picker = screen.getByRole("dialog", { name: "执行时间" });
const minutes = within(picker).getByRole("listbox", {
name: "执行时间 MM",
});
await user.selectOptions(minutes, "30");
await user.click(within(picker).getByRole("button", { name: "保存修改" }));
expect(
await screen.findByText("自动整合设置未能保存,请重试。"),
).toBeTruthy();
expect(screen.getByRole("dialog", { name: "执行时间" })).toBeTruthy();
expect((minutes as HTMLSelectElement).value).toBe("30");
await waitFor(() =>
expect(document.activeElement).toBe(
within(picker).getByRole("button", { name: "保存修改" }),
),
);
});
it("keeps the time dialog and focus stable while saving is pending", async () => {
prepare("ultra");
vi.mocked(setEvolveSchedule).mockReturnValue(new Promise(() => {}));
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await user.click(
await screen.findByRole("button", { name: "执行时间,03:00" }),
);
const picker = screen.getByRole("dialog", { name: "执行时间" });
await user.selectOptions(
within(picker).getByRole("listbox", { name: "执行时间 MM" }),
"30",
);
await user.click(within(picker).getByRole("button", { name: "保存修改" }));
await waitFor(() => expect(setEvolveSchedule).toHaveBeenCalledTimes(1));
expect(document.activeElement).toBe(picker);
await user.keyboard("{Escape}");
expect(screen.getByRole("dialog", { name: "执行时间" })).toBe(picker);
expect(document.activeElement).toBe(picker);
});
it("hides the time field when automatic evolve is off and keeps the saved time visible", async () => {
prepare("ultra");
vi.mocked(setEvolveSchedule).mockResolvedValue({
@@ -296,7 +434,7 @@ it("hides the time field when automatic evolve is off and keeps the saved time v
await user.click(automatic);
await screen.findByText(/重新开启后仍按 03:00 运行/);
expect(screen.queryByLabelText("执行时间")).toBeNull();
expect(screen.queryByRole("button", { name: /执行时间/ })).toBeNull();
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
});
@@ -354,14 +492,12 @@ it("keeps a failed switch explanation inside the open confirmation dialog", asyn
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /深度记忆(ultra)/);
await user.click(
screen.getByRole("button", { name: "切换到深度记忆(ultra)" }),
);
await user.click(screen.getByRole("button", { name: "确认选择" }));
const dialog = screen.getByRole("dialog");
expect(
await within(dialog).findByText(
"当前安装缺少知识图谱组件,无法开启 ultra。",
"当前安装缺少知识图谱组件,无法选择 ultra。",
),
).toBeTruthy();
});
@@ -397,7 +533,7 @@ it("blocks mode and Evolve controls during embedding maintenance", async () => {
expect(
await screen.findByText(
"正在检查或切换向量模型,完成前不能切换模式或手动整合。",
"正在检查或切换向量模型,完成前不能选择模式或手动整合。",
),
).toBeTruthy();
expect(
+50 -287
View File
@@ -25,93 +25,16 @@ import {
commitConfirmedMemoryMode,
issueConfirmedModeTicket,
} from "./confirmed-memory-mode";
import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
import {
BLOCKER_COPY,
MemoryModeConfirmDialog,
MODE_COPY,
} from "./memory-mode-ui";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Button, R6Switch } from "./R6Controls";
import { R6Button } from "./R6Controls";
import { R6StateRecovery } from "./R6StateRecovery";
const MODE_COPY: Record<
MemoryPipelineMode,
{ title: string; description: string }
> = {
lite: {
title: "基础记忆(lite)",
description: "保存原始对话 · 不调用生成模型 · 搜索原始会话",
},
pro: {
title: "智能记忆(pro)",
description: "沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆",
},
ultra: {
title: "深度记忆(ultra)",
description: "智能记忆 + 知识图谱 · 可自动 Evolve · 增强关联召回",
},
};
function planRows(plan: MemoryModePlan) {
const rows = [
{
label: "新对话",
content:
plan.capabilities.content_plane === "raw"
? "之后只保存原始对话,不再生成新记忆。"
: plan.capabilities.graph_enabled
? "新对话会整理成记忆并写入知识图谱。"
: "新对话会由模型整理成记忆。",
},
{
label: "模型调用与费用",
content: plan.capabilities.generation_required
? plan.capabilities.evolve_available
? "会调用当前生成模型;自动整合可能产生额外费用。切换本身不会立即整合。"
: "会调用当前生成模型,可能产生服务商费用。"
: "不调用生成模型,不再产生这部分费用。",
},
{
label: "搜索范围",
content:
plan.capabilities.content_plane === "raw"
? "切换后搜索原始对话。"
: plan.capabilities.graph_enabled
? "搜索沉淀记忆,并用知识图谱补充关联。"
: "搜索沉淀后的记忆。",
},
];
if (plan.effects.includes("earlier_raw_not_extracted"))
rows.push({
label: "已有数据",
content: "已经处理完的旧对话不会补做;原始对话全部保留。",
});
if (plan.effects.includes("higher_mode_memories_hidden"))
rows.push({
label: "已有数据",
content: "已有记忆继续保留,但高级记忆在当前模式下不显示、不参与召回。",
});
if (plan.effects.includes("existing_memories_added_to_graph"))
rows.push({
label: "已有数据",
content: "已有记忆会在后台加入知识图谱,期间仍可使用。",
});
if (plan.effects.includes("evolve_stopped"))
rows.push({
label: "已有数据",
content:
"普通沉淀记忆仍可使用;更高层记忆和图谱数据继续保留,但不参与当前召回;每天自动整合会停止。",
});
if (plan.capabilities.evolve_available)
rows.push({
label: "切换后",
content: "可设置每天自动整合时间,也可以随时立即整合一次。",
});
return rows;
}
const BLOCKER_COPY: Record<MemoryModePlan["blockers"][number], string> = {
active_model_required: "使用 pro 或 ultra 前需要先配置并启用生成模型。",
embedding_maintenance_in_progress:
"正在检查或切换向量模型,完成后才能切换记忆模式。",
memory_lifecycle_busy: "记忆维护正在进行,完成后才能切换模式。",
graph_store_unavailable: "当前安装缺少知识图谱组件,无法开启 ultra。",
};
import { UltraEvolveScheduleFields } from "./UltraEvolveScheduleFields";
export function MemoryModeSettings({
hasActiveModel,
@@ -139,65 +62,9 @@ export function MemoryModeSettings({
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")
@@ -212,10 +79,10 @@ export function MemoryModeSettings({
focusModeWhenReady.current = null;
document
.querySelector<HTMLInputElement>(
`input[name="${id}-mode"][value="${target}"]`,
`.r6-memory-mode-settings input[type="radio"][value="${target}"]`,
)
?.focus();
}, [busy, id, pendingMode, state?.mode]);
}, [busy, pendingMode, state?.mode]);
const refresh = useCallback(async () => {
const version = ++refreshVersion.current;
@@ -329,8 +196,10 @@ export function MemoryModeSettings({
setError("");
try {
setScheduleState(await setEvolveSchedule(update));
return true;
} catch {
setError("自动整合设置未能保存,请重试。");
return false;
} finally {
setBusy(false);
}
@@ -368,53 +237,26 @@ export function MemoryModeSettings({
<p>{text("模式决定新对话如何保存,以及记忆页面展示什么。")}</p>
</div>
</div>
<div
className="r6-mode-choice-list"
role="radiogroup"
<MemoryModeChoiceList
aria-label={text("当前模式")}
aria-busy={state === null}
>
{(["lite", "pro", "ultra"] as const).map((mode) => {
const checked = state?.mode === mode;
return (
<label
key={mode}
className="r6-mode-choice"
data-current={checked || undefined}
data-disabled={
!state ||
busy ||
lifecycleBusy ||
(state?.forced_by_env && mode !== state.mode) ||
(mode !== "lite" && !modelStateKnown) ||
undefined
}
>
<input
type="radio"
name={`${id}-mode`}
value={mode}
checked={checked}
disabled={
!state ||
busy ||
lifecycleBusy ||
(state?.forced_by_env && mode !== state.mode) ||
(mode !== "lite" && !modelStateKnown)
}
onChange={(event) => void choose(mode, event.currentTarget)}
/>
<span className="r6-mode-choice-copy">
<strong>{text(MODE_COPY[mode].title)}</strong>
<small>{text(MODE_COPY[mode].description)}</small>
</span>
{checked ? (
<span className="r6-mode-current">{text("当前模式")}</span>
) : null}
</label>
);
})}
</div>
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,保存的选择暂不会生效。")}
@@ -450,47 +292,13 @@ export function MemoryModeSettings({
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>
<UltraEvolveScheduleFields
schedule={schedule}
headingId={`${id}-evolve-title`}
disabled={busy || maintenance}
onEnabledChange={(enabled) => void changeSchedule({ enabled })}
onTimeChange={(time) => changeSchedule({ time })}
/>
<div className="r6-evolve-setting r6-evolve-setting--manual">
<div className="r6-evolve-setting-copy">
<h3>{text("立即整合一次")}</h3>
@@ -520,64 +328,19 @@ export function MemoryModeSettings({
</section>
) : null}
{pendingMode ? (
<div className="r6-settings-dialog-layer" role="presentation">
<section
ref={dialog}
className="r6-settings-dialog r6-mode-confirm-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="memory-mode-confirm-title"
tabIndex={-1}
>
<h2 id="memory-mode-confirm-title">
{text("切换到 {mode}?").replace(
"{mode}",
text(MODE_COPY[pendingMode].title),
)}
</h2>
<dl className="r6-mode-confirm-effects">
{(pendingPlan ? planRows(pendingPlan) : []).map((row) => (
<div key={`${row.label}:${row.content}`}>
<dt>{text(row.label)}</dt>
<dd>{text(row.content)}</dd>
</div>
))}
</dl>
{(pendingPlan?.blockers ?? []).map((blocker) => (
<p className="r6-settings-error" role="alert" key={blocker}>
{text(BLOCKER_COPY[blocker])}
</p>
))}
{error ? (
<p className="r6-settings-error" role="alert">
{text(error)}
</p>
) : null}
<footer>
<R6Button
disabled={busy}
onClick={() => {
setPendingPlan(null);
setPendingMode(null);
}}
>
{text("取消")}
</R6Button>
<R6Button
variant="primary"
pending={busy}
disabled={Boolean(pendingPlan?.blockers.length)}
onClick={() => void commitMode()}
>
{text("切换到 {mode}").replace(
"{mode}",
text(MODE_COPY[pendingMode].title),
)}
</R6Button>
</footer>
</section>
</div>
{pendingMode && pendingPlan ? (
<MemoryModeConfirmDialog
mode={pendingMode}
plan={pendingPlan}
busy={busy}
error={error}
opener={dialogOpener.current}
onCancel={() => {
setPendingPlan(null);
setPendingMode(null);
}}
onConfirm={() => void commitMode()}
/>
) : null}
</section>
);
+117 -13
View File
@@ -320,9 +320,9 @@ it("pages and filters the raw conversation data plane in lite", async () => {
setup();
reads.processing.mockResolvedValue(modeProcessing("lite"));
reads.raw.mockImplementation(async (options = {}) => {
const offset = "offset" in options ? (options.offset ?? 0) : 0;
const cursor = "cursor" in options ? options.cursor : null;
const source = "source" in options ? options.source : undefined;
const rawId = `${source ?? "all"}-${offset}`;
const rawId = `${source ?? "all"}-${cursor ? "more" : "first"}`;
return {
items: [
{
@@ -333,14 +333,17 @@ it("pages and filters the raw conversation data plane in lite", async () => {
source: source ?? "cursor",
session_id: `session-${rawId}`,
memory_at: null,
gmt_created: 1_761_000_000 - offset,
gmt_created: 1_761_000_000,
custom: {},
messages: [{ role: "user", content: `原始会话 ${rawId}` }],
},
],
total: source ? 1 : 21,
total: cursor ? 2 : 1,
limit: 20,
offset,
offset: cursor ? 1 : 0,
next_cursor: !source && !cursor ? "turn:1:0:1" : null,
has_more: !source && !cursor,
total_exact: false,
};
});
const user = userEvent.setup();
@@ -359,34 +362,108 @@ it("pages and filters the raw conversation data plane in lite", async () => {
/>,
);
expect(await screen.findAllByText("原始会话 all-0")).toHaveLength(2);
await user.click(screen.getByRole("button", { name: "下一页记忆" }));
expect(await screen.findAllByText("原始会话 all-20")).toHaveLength(2);
expect(await screen.findAllByText("原始会话 all-first")).toHaveLength(2);
await user.click(screen.getByRole("button", { name: "加载更多" }));
expect(await screen.findAllByText("原始会话 all-more")).toHaveLength(1);
expect(screen.getAllByText("原始会话 all-first")).toHaveLength(2);
expect(reads.raw).toHaveBeenLastCalledWith(
expect.objectContaining({ source: undefined, offset: 20, limit: 20 }),
expect.objectContaining({
source: undefined,
cursor: "turn:1:0:1",
paging: "cursor",
limit: 20,
}),
);
await user.click(screen.getByRole("combobox", { name: "来源" }));
await user.click(screen.getByRole("option", { name: "Cursor" }));
expect(await screen.findAllByText("原始会话 cursor-0")).toHaveLength(2);
expect(await screen.findAllByText("原始会话 cursor-first")).toHaveLength(2);
expect(reads.raw).toHaveBeenLastCalledWith(
expect.objectContaining({ source: "cursor", offset: 0, limit: 20 }),
expect.objectContaining({
source: "cursor",
cursor: null,
paging: "cursor",
limit: 20,
}),
);
await user.click(screen.getByRole("combobox", { name: "来源" }));
await user.click(screen.getByRole("option", { name: "手动添加" }));
expect(
await screen.findAllByText("原始会话 one-memory-manual-0"),
await screen.findAllByText("原始会话 one-memory-manual-first"),
).toHaveLength(2);
expect(reads.raw).toHaveBeenLastCalledWith(
expect.objectContaining({
source: "one-memory-manual",
offset: 0,
limit: 20,
paging: "cursor",
cursor: null,
}),
);
});
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();
@@ -482,6 +559,27 @@ it("opens a search hit with the complete role transcript", async () => {
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",
@@ -496,7 +594,7 @@ it("opens a search hit with the complete role transcript", async () => {
],
},
],
total: 1,
total: 2,
limit: 500,
offset: 0,
}
@@ -506,6 +604,9 @@ it("opens a search hit with the complete role transcript", async () => {
{
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",
@@ -525,6 +626,9 @@ it("opens a search hit with the complete role transcript", async () => {
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",
+105 -24
View File
@@ -61,6 +61,7 @@ import {
type RawMapRecord,
rawDetail,
rawLibraryPage,
rawRecordId,
rawSearchPage,
} from "./sdk-raw-map";
import { sourceNameOr, UNATTRIBUTED_SOURCE_ID } from "./source-label";
@@ -122,6 +123,7 @@ export function MemorySpace({
const selectedRawRef = useRef<RawMapRecord | null>(null);
selectedRawRef.current = selectedRaw;
const rawRecords = useRef(new Map<string, RawMapRecord>());
const rawRecordsScope = useRef("");
const [detailLoad, setDetailLoad] = useState<{
memoryId: string;
status: "loading" | "failed";
@@ -208,6 +210,7 @@ export function MemorySpace({
const [sdkLibraryQuery, setSdkLibraryQuery] = useState("");
const [sdkLibrarySource, setSdkLibrarySource] = useState("");
const [sdkLibraryOffset, setSdkLibraryOffset] = useState(0);
const [sdkRawCursor, setSdkRawCursor] = useState<string | null>(null);
const [processing, setProcessing] = useState<MemoryProcessingSummary | null>(
null,
);
@@ -225,6 +228,10 @@ export function MemorySpace({
evolve_available: pipelineMode === "ultra",
}
: null;
const refreshSdkMap = useCallback(() => {
if (lite) setSdkRawCursor(null);
bumpSdkMap();
}, [bumpSdkMap, lite]);
const requestedQuery = sdkLibraryQuery.trim();
const sdkPageSettled =
sdkLibrary.settledMode === pipelineMode &&
@@ -275,11 +282,18 @@ export function MemorySpace({
if (first) setSdkLibrary((current) => ({ ...current, loading: true }));
try {
const rememberRaw = (items: RawMapRecord[]) => {
const next = new Map<string, RawMapRecord>();
const next =
sdkRawCursor && rawRecordsScope.current === windowKey
? new Map(rawRecords.current)
: new Map<string, RawMapRecord>();
if (selectedRawRef.current)
next.set(selectedRawRef.current.raw_id, selectedRawRef.current);
for (const item of items) next.set(item.raw_id, item);
next.set(
rawRecordId(selectedRawRef.current),
selectedRawRef.current,
);
for (const item of items) next.set(rawRecordId(item), item);
rawRecords.current = next;
rawRecordsScope.current = windowKey;
};
const [page, graph] = lite
? await Promise.all([
@@ -293,8 +307,10 @@ export function MemorySpace({
})
: getSdkRaw({
source,
offset: sdkLibraryOffset,
offset: 0,
limit: SDK_MEMORY_PAGE_SIZE,
paging: "cursor",
cursor: sdkRawCursor,
signal: request.signal,
}).then((rawPage) => {
rememberRaw(rawPage.items);
@@ -342,17 +358,64 @@ export function MemorySpace({
};
// The window expired: the server searched again and answered page 1.
if (page.window_reset && sdkLibraryOffset !== 0) setSdkLibraryOffset(0);
setSdkLibrary({
page,
loading: false,
failed: false,
settledSource: sdkLibrarySource,
settledQuery: sdkLibraryQuery,
settledMode: pipelineMode,
setSdkLibrary((current) => {
const currentPage = current.page;
const append =
lite &&
sdkRawCursor !== null &&
currentPage !== null &&
current.settledSource === sdkLibrarySource &&
current.settledQuery === sdkLibraryQuery &&
current.settledMode === pipelineMode;
if (!append)
return {
page,
loading: false,
failed: false,
settledSource: sdkLibrarySource,
settledQuery: sdkLibraryQuery,
settledMode: pipelineMode,
};
const items = [...currentPage.items];
const known = new Set(items.map((item) => item.memory_id));
for (const item of page.items)
if (!known.has(item.memory_id)) {
known.add(item.memory_id);
items.push(item);
}
return {
page: {
...page,
items,
total: items.length + (page.has_more ? 1 : 0),
offset: 0,
source_ids_by_memory: {
...currentPage.source_ids_by_memory,
...page.source_ids_by_memory,
},
source_time_by_memory: {
...currentPage.source_time_by_memory,
...page.source_time_by_memory,
},
},
loading: false,
failed: false,
settledSource: sdkLibrarySource,
settledQuery: sdkLibraryQuery,
settledMode: pipelineMode,
};
});
setSdkGraph({ scope: windowKey, ...graph });
} catch {
} catch (reason) {
if (cancelled || request.signal.aborted) return;
if (
lite &&
sdkRawCursor !== null &&
apiErrorCode(reason) === "raw_cursor_stale"
) {
setSdkRawCursor(null);
return;
}
setSdkLibrary((current) => ({
...current,
loading: false,
@@ -361,10 +424,13 @@ export function MemorySpace({
}
};
void read(true);
const timer = setInterval(() => void read(false), 30_000);
const timer =
lite && sdkRawCursor !== null
? undefined
: setInterval(() => void read(false), 30_000);
return () => {
cancelled = true;
clearInterval(timer);
if (timer !== undefined) clearInterval(timer);
controller?.abort();
};
}, [
@@ -373,6 +439,7 @@ export function MemorySpace({
sdkLibraryOffset,
sdkLibraryQuery,
sdkLibrarySource,
sdkRawCursor,
lite,
pipelineMode,
]);
@@ -521,6 +588,7 @@ export function MemorySpace({
rawRecords.current.clear();
sdkSearchWindow.current = { key: "", id: null };
setSdkLibraryOffset(0);
setSdkRawCursor(null);
setSdkLibraryQuery("");
setSdkGraph(null);
setSdkLibrary((current) => ({
@@ -717,7 +785,9 @@ export function MemorySpace({
offset,
limit: 500,
});
const full = page.items.find((item) => item.raw_id === memoryId);
const full = page.items.find(
(item) => rawRecordId(item) === memoryId,
);
if (full) {
record = full;
rawRecords.current.set(memoryId, full);
@@ -798,7 +868,7 @@ export function MemorySpace({
);
trace.done("succeeded");
closeDetail();
bumpSdkMap();
refreshSdkMap();
} catch (reason) {
trace.done(writeTraceStatus(reason), {
reason: "memory_action_failed",
@@ -827,7 +897,7 @@ export function MemorySpace({
trace.done("succeeded");
setDetail(sdkDetail(saved));
setDraft(sdkEditableText(saved));
bumpSdkMap();
refreshSdkMap();
} catch (reason) {
trace.done(writeTraceStatus(reason), {
reason: isDefiniteRefusal(reason)
@@ -892,10 +962,12 @@ export function MemorySpace({
? t("memory:记忆暂时无法读取")
: lite
? sdkPage
? `${t("memory:原始对话")} · ${tn(
"sdk.raw.found",
sdkPage.total,
)}`
? sdkPage.total_exact === false
? t("memory:原始对话")
: `${t("memory:原始对话")} · ${tn(
"sdk.raw.found",
sdkPage.total,
)}`
: sdkLibrary.failed
? null
: t("memory:正在读取…")
@@ -928,7 +1000,7 @@ export function MemorySpace({
onSaved={() => {
// A finished addition is new page content: re-read now rather than
// leave it for the next poll.
bumpSdkMap();
refreshSdkMap();
}}
onOpenMemory={(id) => {
if (!simplified) setMode("library");
@@ -1195,12 +1267,21 @@ export function MemorySpace({
settledSource: sdkLibrary.settledSource,
settledQuery: sdkLibrary.settledQuery,
requestedOffset: sdkLibraryOffset,
cursorPaging: lite && !sdkLibraryQuery.trim(),
hasMore: !!sdkPage?.has_more && !!sdkPage?.next_cursor,
unattributedSource: UNATTRIBUTED_SOURCE_ID,
onQuery: setSdkLibraryQuery,
onSource: setSdkLibrarySource,
resetPage: () => setSdkLibraryOffset(0),
resetPage: () => {
setSdkLibraryOffset(0);
setSdkRawCursor(null);
},
onPage: (offset) => setSdkLibraryOffset(offset),
onRetry: bumpSdkMap,
onMore: () => {
if (sdkPage?.next_cursor)
setSdkRawCursor(sdkPage.next_cursor);
},
onRetry: refreshSdkMap,
}
: undefined
}
+344 -16
View File
@@ -72,6 +72,7 @@ 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;
@@ -224,6 +225,7 @@ vi.mock("./use-model-profiles", () => ({
activate,
remove,
update,
refreshProfiles,
}),
}));
vi.mock("./use-embedding-switch", () => ({
@@ -279,6 +281,7 @@ describe("ModelSettings", () => {
switchCurrent = null;
create.mockResolvedValue(profile);
update.mockResolvedValue({ ...preset, has_api_key: true, revision: 2 });
refreshProfiles.mockResolvedValue(undefined);
planActivation.mockResolvedValue({
needed: false,
reason: "same_embedding",
@@ -576,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"),
@@ -666,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, {
@@ -687,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: "保存配置" }));
@@ -850,6 +854,190 @@ describe("ModelSettings", () => {
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();
@@ -900,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 />);
@@ -999,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",
@@ -1022,7 +1257,7 @@ describe("ModelSettings", () => {
);
});
it("后端激活不了的协议根本不出现在下拉里", () => {
it("后端激活不了的协议显示但不可选择", () => {
render(<ModelSettings />);
const providerSelect = screen.getByLabelText("协议") as HTMLSelectElement;
@@ -1035,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("已保存的配置不印内置向量模型", () => {
@@ -1229,6 +1468,95 @@ 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: {
...capabilities,
endpoint_reachable: false,
model_available: false,
embedding_available: false,
},
};
profiles = [];
create.mockImplementation(async () => {
profiles = [created];
return created;
});
probeProfile.mockResolvedValue({
profile_id: created.profile_id,
input_profile_revision: created.revision,
profile_revision: 2,
stages: [],
capabilities,
});
activate.mockResolvedValue({
...created,
revision: 2,
capabilities,
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, 2);
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("把一张预置卡设为当前模型", () => {
+371 -219
View File
@@ -11,12 +11,23 @@ import {
type FormEvent,
type Ref,
useEffect,
useId,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
} from "react";
import { EmbeddingSwitchConfirmDialog } from "./EmbeddingSwitchExperience";
import {
type GenerationModelValues,
GenerationTechnicalDisclosure,
GenerationTechnicalFields,
generationSummary,
ProviderPresetField,
presetForGeneration,
validateGenerationModel,
} from "./GenerationModelPrimitives";
import { modelProfileReady, profileFromProbe } from "./model-profile-readiness";
import { OptionMenu } from "./OptionMenu";
import { useOnboardingTranslation } from "./onboarding-language";
import { PROVIDER_PRESETS } from "./provider-presets";
@@ -95,8 +106,11 @@ const PROVIDERS: Array<{
type FieldName =
| "model"
| "base-url"
| "api-key"
| "base_url"
| "api_key"
| "timeout_seconds"
| "max_concurrency"
| "context_window"
| "embedding-model"
| "embedding-base-url";
@@ -127,11 +141,11 @@ function probeFieldErrors(
: undefined;
};
if (!result.capabilities.endpoint_reachable) {
errors["base-url"] = text("endpoint") ?? FIELD_PROBE_TEXT.unreachable;
errors.base_url = text("endpoint") ?? FIELD_PROBE_TEXT.unreachable;
}
if (!result.capabilities.model_available) {
if (stages.get("model")?.code === "unauthorized") {
errors["api-key"] = FIELD_PROBE_TEXT.unauthorized;
errors.api_key = FIELD_PROBE_TEXT.unauthorized;
} else {
errors.model = text("model") ?? FIELD_PROBE_TEXT.model_not_found;
}
@@ -147,19 +161,6 @@ function endpointKey(value: string): string {
return value.trim().replace(/\/+$/, "").toLowerCase();
}
function presetForProfile(profile: {
provider_kind: string;
base_url: string | null;
}): string {
return (
PROVIDER_PRESETS.find(
(item) =>
item.provider === profile.provider_kind &&
endpointKey(item.baseUrl) === endpointKey(profile.base_url ?? ""),
)?.id ?? "custom"
);
}
function validEndpoint(value: string): boolean {
try {
const url = new URL(value);
@@ -174,27 +175,6 @@ function validEndpoint(value: string): boolean {
}
}
function ready(
profile: ModelProfile,
probe: ModelProbeResult | null = null,
error: string | null = null,
): boolean {
return (
!error &&
(!probe ||
(probe.profile_id === profile.profile_id &&
probe.profile_revision === profile.revision &&
probe.capabilities.endpoint_reachable &&
probe.capabilities.model_available &&
probe.capabilities.embedding_available)) &&
profile.credential_state !== "unavailable" &&
!(profile.has_configured_api_key && !profile.has_api_key) &&
profile.capabilities.endpoint_reachable &&
profile.capabilities.model_available &&
profile.capabilities.embedding_available
);
}
function endpointLabel(value: string | null): string {
if (!value) return "未提供接口地址";
try {
@@ -239,7 +219,8 @@ export function ModelSettings({
focusSection?: ModelSettingsFocusSection;
onSetupComplete?(): void;
} = {}) {
const { text } = useOnboardingTranslation();
const { text, t } = useOnboardingTranslation();
const fieldId = useId();
const models = useModelProfiles();
const embeddingSwitch = useEmbeddingSwitch();
const [provider, setProvider] = useState<ProviderKind>("openai_compatible");
@@ -260,6 +241,10 @@ export function ModelSettings({
const [embeddingModel, setEmbeddingModel] = useState("");
const [apiKey, setApiKey] = useState("");
const [disableThinking, setDisableThinking] = useState(true);
const [technicalOpen, setTechnicalOpen] = useState(true);
const [timeoutSeconds, setTimeoutSeconds] = useState("");
const [maxConcurrency, setMaxConcurrency] = useState("");
const [contextWindow, setContextWindow] = useState("");
const [customOpen, setCustomOpen] = useState(false);
const [saved, setSaved] = useState<ModelProfile | null>(null);
const [editing, setEditing] = useState<ModelProfile | null>(null);
@@ -273,12 +258,24 @@ export function ModelSettings({
const [setupError, setSetupError] = useState<string | null>(null);
const [switchTarget, setSwitchTarget] = useState<ModelProfile | null>(null);
const [switchJobId, setSwitchJobId] = useState<string | null>(null);
const [switchRefreshCompleted, setSwitchRefreshCompleted] = useState<
string | null
>(null);
const settledSwitchJob = useRef<string | null>(null);
const flight = useRef(false);
const alive = useRef(true);
const queuedOpen = useRef<ModelOpenIntent | null>(null);
const savedCard = useRef<HTMLElement>(null);
const formEl = useRef<HTMLFormElement>(null);
const modelInput = useRef<HTMLInputElement>(null);
const technicalTouched = useRef(
new Set<
| "timeout_seconds"
| "max_concurrency"
| "context_window"
| "disable_thinking"
>(),
);
useEffect(() => {
alive.current = true;
return () => {
@@ -297,10 +294,11 @@ export function ModelSettings({
return;
settledSwitchJob.current = finished.job_id;
if (finished.state === "done") {
void models.refreshProfiles();
setSetupPhase("activating");
setSetupError(null);
setSwitchTarget(null);
setSwitchJobId(null);
void models.refreshProfiles().then(() => {
if (alive.current) setSwitchRefreshCompleted(finished.job_id);
});
onProfilesChanged?.();
return;
}
@@ -312,6 +310,7 @@ export function ModelSettings({
);
setSwitchTarget(null);
setSwitchJobId(null);
setSwitchRefreshCompleted(null);
}
}, [
embeddingSwitch.current?.last_finished,
@@ -320,17 +319,78 @@ export function ModelSettings({
switchJobId,
switchTarget,
]);
useEffect(() => {
const finished = embeddingSwitch.current?.last_finished;
if (
!switchTarget ||
!switchJobId ||
finished?.job_id !== switchJobId ||
finished.state !== "done" ||
switchRefreshCompleted !== switchJobId
)
return;
const active = models.profiles.find(
(profile) =>
profile.profile_id === switchTarget.profile_id &&
profile.revision >= switchTarget.revision &&
profile.is_active,
);
if (!active) {
setSetupPhase(null);
setSetupError("配置已保存,尚未确认启用成功。请核对配置后重试。");
setSwitchTarget(null);
setSwitchJobId(null);
setSwitchRefreshCompleted(null);
return;
}
if (active.name !== PRESET_PROFILE_NAME) setSaved(active);
setSetupPhase(null);
setSetupError(null);
setSwitchTarget(null);
setSwitchJobId(null);
setSwitchRefreshCompleted(null);
onSetupComplete?.();
}, [
embeddingSwitch.current?.last_finished,
models.profiles,
onSetupComplete,
switchJobId,
switchRefreshCompleted,
switchTarget,
]);
useLayoutEffect(() => {
if (saved) savedCard.current?.focus({ preventScroll: true });
}, [saved]);
useLayoutEffect(() => {
const first = Object.keys(fields).find((key) => fields[key as FieldName]);
if (advancedEmbedding && first?.startsWith("embedding-")) {
if (!first) return;
if (
[
"model",
"base_url",
"api_key",
"timeout_seconds",
"max_concurrency",
"context_window",
].includes(first)
) {
if (!technicalOpen && first !== "api_key") {
setTechnicalOpen(true);
return;
}
formEl.current
?.querySelector<HTMLInputElement>(`[name="${first}"]`)
?.focus();
?.focus({ preventScroll: true });
} else if (advancedEmbedding && first.startsWith("embedding-")) {
formEl.current
?.querySelector<HTMLInputElement>(`[name="${first}"]`)
?.focus({ preventScroll: true });
}
}, [advancedEmbedding, fields]);
}, [advancedEmbedding, fields, technicalOpen]);
useEffect(() => {
if (technicalOpen && !model.trim())
modelInput.current?.focus({ preventScroll: true });
}, [technicalOpen, model]);
const selected =
PROVIDERS.find((item) => item.kind === provider) ?? PROVIDERS[0];
const presetProfiles = models.profiles.filter(
@@ -348,6 +408,7 @@ export function ModelSettings({
saved);
const editorLocked =
saving ||
switchJobId !== null ||
models.busy !== null ||
!!models.pendingCreation ||
!!models.pendingProbe ||
@@ -377,8 +438,20 @@ export function ModelSettings({
nextModel.trim()
? `${providerLabel(nextProvider)} · ${nextModel.trim()}`.slice(0, 128)
: "";
const clearInheritedRuntimeForNewProfile = (nextProvider: ProviderKind) => {
if (!editing || nextProvider === editing.provider_kind) return;
setTimeoutSeconds("");
setMaxConcurrency("");
setContextWindow("");
setDisableThinking(true);
technicalTouched.current.clear();
};
const isReady = (profile: ModelProfile) =>
ready(profile, models.probeFor(profile), models.probeErrorFor(profile));
modelProfileReady(
profile,
models.probeFor(profile),
models.probeErrorFor(profile),
);
function editProfile(profile: ModelProfile) {
if (ownerLocked) return;
@@ -389,10 +462,17 @@ export function ModelSettings({
setName(profile.name);
setNameEdited(true);
setProvider(profile.provider_kind);
setProviderPreset(presetForProfile(profile));
setProviderPreset(presetForGeneration(profile));
setBaseUrl(profile.base_url ?? "");
setModel(profile.model);
setDisableThinking(profile.disable_thinking);
setTimeoutSeconds(String(profile.timeout_seconds));
setMaxConcurrency(String(profile.max_concurrency));
setContextWindow(
profile.context_window === null ? "" : String(profile.context_window),
);
setTechnicalOpen(true);
technicalTouched.current.clear();
setApiKey("");
setAdvancedEmbedding(
profile.embedding_provider_kind !== BUILTIN_EMBEDDING_KIND &&
@@ -430,6 +510,11 @@ export function ModelSettings({
setModel("");
setApiKey("");
setDisableThinking(true);
setTimeoutSeconds("");
setMaxConcurrency("");
setContextWindow("");
setTechnicalOpen(true);
technicalTouched.current.clear();
setAdvancedEmbedding(false);
setEmbeddingMode("local");
setEmbeddingModel("");
@@ -525,7 +610,7 @@ export function ModelSettings({
!active ||
active.profile_id !== profile.profile_id ||
!active.is_active ||
!ready(active, result)
!modelProfileReady(active, result)
) {
setSetupError("配置已保存,尚未确认启用成功。请核对配置后重试。");
return false;
@@ -539,38 +624,15 @@ export function ModelSettings({
setSetupPhase("testing");
const result = await models.probeProfile(profile.profile_id);
if (!alive.current) return false;
if (
result &&
result.profile_id === profile.profile_id &&
result.profile_revision !== profile.revision
) {
// A newer revision used to be accepted here, and then activation turned
// on whatever the configuration had become. The user is told the
// settings they filled in passed, so this hands them a model they never
// tested. `ready` below cannot catch it either: by then the probe and
// the activated profile are both on the new revision and agree.
setSetupError(
result.profile_revision > profile.revision
? "这份配置在测试期间被改过,刚才测的是改动后的版本。配置已保存但未启用,请重新测试并启用。"
: "测试结果来自这份配置更早的版本。配置已保存但未启用,请重新测试并启用。",
);
return false;
}
if (
!result ||
result.profile_id !== profile.profile_id ||
result.profile_revision !== profile.revision ||
!result.capabilities.endpoint_reachable ||
!result.capabilities.model_available ||
!result.capabilities.embedding_available
) {
const testedProfile = profileFromProbe(profile, result);
if (!testedProfile || !modelProfileReady(testedProfile, result ?? null)) {
setSetupError(
"配置已保存,连接测试未通过。请核对配置后重试,历史对话尚未发送。",
);
setFields(probeFieldErrors(result));
return false;
}
return activateWithPlan(profile, result);
return activateWithPlan(testedProfile, result ?? null);
}
async function activateTested(profile: ModelProfile) {
@@ -659,11 +721,37 @@ export function ModelSettings({
return;
const errors: Partial<Record<FieldName, string>> = {};
if (focusSection !== "embedding") {
if (!model.trim()) errors.model = "请填写生成模型名。";
if (!validEndpoint(baseUrl))
errors["base-url"] = "请填写完整的 http:// 或 https:// 接口地址。";
const credentialError = keyError(apiKey, keyRequired);
if (credentialError) errors["api-key"] = credentialError;
const generationErrors = validateGenerationModel(
{
provider_kind: provider,
model,
base_url: baseUrl,
timeout_seconds: timeoutSeconds,
max_concurrency: maxConcurrency,
context_window: contextWindow,
disable_thinking: disableThinking,
},
{ apiKey, keyRequired },
);
if (generationErrors.model) errors.model = "请填写生成模型名。";
if (generationErrors.base_url)
errors.base_url = "请填写完整的 http:// 或 https:// 接口地址。";
if (generationErrors.api_key) errors.api_key = generationErrors.api_key;
if (generationErrors.timeout_seconds)
errors.timeout_seconds = t("onboardingIntegerRange", {
min: 1,
max: 600,
});
if (generationErrors.max_concurrency)
errors.max_concurrency = t("onboardingIntegerRange", {
min: 1,
max: 32,
});
if (generationErrors.context_window)
errors.context_window = t("onboardingIntegerRange", {
min: 1024,
max: 10_000_000,
});
}
if (focusSection !== "generation" && embeddingMode === "custom") {
if (!embeddingModel.trim())
@@ -676,6 +764,19 @@ export function ModelSettings({
setSetupError(null);
const first = Object.keys(errors)[0];
if (first) {
if (
[
"model",
"base_url",
"timeout_seconds",
"max_concurrency",
"context_window",
].includes(first) &&
!technicalOpen
) {
setTechnicalOpen(true);
return;
}
if (first.startsWith("embedding-") && !advancedEmbedding) {
setAdvancedEmbedding(true);
return;
@@ -702,6 +803,20 @@ export function ModelSettings({
...(provider === "hy_default" ? {} : { base_url: baseUrl }),
...(apiKey ? { api_key: apiKey } : {}),
};
if (
technicalTouched.current.has("timeout_seconds") &&
timeoutSeconds.trim()
)
input.timeout_seconds = Number(timeoutSeconds);
if (
technicalTouched.current.has("max_concurrency") &&
maxConcurrency.trim()
)
input.max_concurrency = Number(maxConcurrency);
if (technicalTouched.current.has("context_window"))
input.context_window = contextWindow.trim()
? Number(contextWindow)
: null;
try {
const patch: ModelProfilePatch = editing
? { expected_revision: editing.revision }
@@ -715,6 +830,21 @@ export function ModelSettings({
if (input.disable_thinking !== editing.disable_thinking)
patch.disable_thinking = input.disable_thinking;
if (apiKey) patch.api_key = apiKey;
if (
technicalTouched.current.has("timeout_seconds") &&
input.timeout_seconds !== editing.timeout_seconds
)
patch.timeout_seconds = input.timeout_seconds;
if (
technicalTouched.current.has("max_concurrency") &&
input.max_concurrency !== editing.max_concurrency
)
patch.max_concurrency = input.max_concurrency;
if (
technicalTouched.current.has("context_window") &&
input.context_window !== editing.context_window
)
patch.context_window = input.context_window;
}
if (focusSection !== "generation" && embeddingMode === "custom") {
if (embeddingModel !== editing.embedding_model)
@@ -743,6 +873,9 @@ export function ModelSettings({
if (!alive.current) return;
if (created) {
if (thenSetup) {
// Retrying after probe, activation, or switch confirmation failed
// must update this saved revision instead of creating a duplicate.
setEditing(created);
// The probe decides whether this form closes. A rejected key or a
// failed endpoint leaves the filled-in form on screen with the
// reason; only a passing test returns to the saved card.
@@ -971,8 +1104,14 @@ export function ModelSettings({
</div>
<h3>{savedProfile.name}</h3>
<p>
{savedProfile.model} · {text(endpointLabel(savedProfile.base_url))}{" "}
· {text("版本")} {savedProfile.revision}
{generationSummary(
presetForGeneration(savedProfile),
savedProfile.provider_kind,
savedProfile.model,
text,
)}{" "}
· {text(endpointLabel(savedProfile.base_url))} · {text("版本")}{" "}
{savedProfile.revision}
</p>
{savedProfile.credential_state === "unavailable" && (
<p role="status">
@@ -1057,29 +1196,34 @@ export function ModelSettings({
<>
<div className="model-form-field">
{text("服务商预设")}
<OptionMenu
<ProviderPresetField
ariaLabel={text("服务商预设")}
describedBy="model-preset-help"
value={providerPreset}
disabled={formLocked}
options={[
...PROVIDER_PRESETS.map((item) => ({
value: item.id,
label: item.label,
})),
{ value: "custom", label: text("自定义兼容服务") },
]}
text={text}
onChange={(value) => {
const next = PROVIDER_PRESETS.find(
(item) => item.id === value,
);
setProviderPreset(value);
if (!next) return;
if (!next) {
clearInheritedRuntimeForNewProfile("openai_compatible");
setProvider("openai_compatible");
setBaseUrl("");
setModel("");
setApiKey("");
setTechnicalOpen(true);
if (!nameEdited) setName("");
return;
}
clearInheritedRuntimeForNewProfile(next.provider);
setProvider(next.provider);
setBaseUrl(next.baseUrl);
setModel(next.model);
setApiKey("");
setDisableThinking(true);
setTechnicalOpen(true);
if (editing) {
setName(next.label);
setNameEdited(true);
@@ -1096,34 +1240,6 @@ export function ModelSettings({
)}
</small>
</div>
<label>
{text("协议")}
<select
name="provider"
aria-label={text("协议")}
aria-describedby="model-provider-help"
value={provider}
disabled={formLocked}
onChange={(event) => {
const next = event.target.value as ProviderKind;
setProvider(next);
setProviderPreset("custom");
if (!nameEdited) setName(suggestedName(model, next));
}}
>
{/* Only what the backend will actually activate. These used to be
* listed and disabled, which put two dead ends on screen and made
* the reader parse a parenthesis to find out they were dead. */}
{PROVIDERS.filter((item) => item.activatable).map((item) => (
<option key={item.kind} value={item.kind}>
{text(item.label)}
</option>
))}
</select>
<small id="model-provider-help">
{text("与模型服务提供的接口协议保持一致。")}
</small>
</label>
<label>
{text("配置名称")}
<input
@@ -1143,34 +1259,70 @@ export function ModelSettings({
{text("用于区分配置,不影响模型请求。")}
</small>
</label>
<label>
{text("生成模型名")}
<input
name="model"
aria-label={text("生成模型名")}
aria-describedby={`model-identifier-help${fields.model ? " model-field-error" : ""}`}
aria-invalid={!!fields.model}
autoComplete="off"
required
maxLength={256}
value={model}
<GenerationTechnicalDisclosure
open={technicalOpen}
disabled={formLocked}
controls="model-generation-technical"
text={text}
onChange={setTechnicalOpen}
>
<GenerationTechnicalFields
idPrefix="model-generation"
values={{
provider_kind: provider,
model,
base_url: baseUrl,
timeout_seconds: timeoutSeconds,
max_concurrency: maxConcurrency,
context_window: contextWindow,
disable_thinking: disableThinking,
}}
errors={{
model: fields.model,
base_url: fields.base_url,
timeout_seconds: fields.timeout_seconds,
max_concurrency: fields.max_concurrency,
context_window: fields.context_window,
}}
disabled={formLocked}
onChange={(event) => {
setModel(event.target.value);
setProviderPreset("custom");
if (!nameEdited) setName(suggestedName(event.target.value));
text={text}
modelRef={modelInput}
onChange={(patch: Partial<GenerationModelValues>) => {
if (patch.provider_kind !== undefined) {
clearInheritedRuntimeForNewProfile(patch.provider_kind);
setProvider(patch.provider_kind);
setProviderPreset("custom");
if (!nameEdited)
setName(suggestedName(model, patch.provider_kind));
}
if (patch.model !== undefined) {
setModel(patch.model);
setProviderPreset("custom");
if (!nameEdited) setName(suggestedName(patch.model));
}
if (patch.base_url !== undefined) {
setBaseUrl(patch.base_url);
setProviderPreset("custom");
}
if (patch.timeout_seconds !== undefined) {
setTimeoutSeconds(patch.timeout_seconds);
technicalTouched.current.add("timeout_seconds");
}
if (patch.max_concurrency !== undefined) {
setMaxConcurrency(patch.max_concurrency);
technicalTouched.current.add("max_concurrency");
}
if (patch.context_window !== undefined) {
setContextWindow(patch.context_window);
technicalTouched.current.add("context_window");
}
if (patch.disable_thinking !== undefined) {
setDisableThinking(patch.disable_thinking);
technicalTouched.current.add("disable_thinking");
}
}}
placeholder={text("例如:gpt-4o-mini…")}
/>
<small id="model-identifier-help">
{text("填写服务方提供的完整模型标识。")}
</small>
{fields.model && (
<small id="model-field-error" className="model-field-error">
{text(fields.model)}
</small>
)}
</label>
</GenerationTechnicalDisclosure>
</>
)}
{/* Disclosure only changes visibility; mode determines what is saved. */}
@@ -1199,20 +1351,37 @@ export function ModelSettings({
)}
{focusSection === "embedding" || advancedEmbedding ? (
<>
<label>
{text(
focusSection === "embedding"
? "搜索模型"
: "向量配置模式",
)}
<select
name="embedding-mode"
<div className="model-form-field">
<label
className="model-option-label"
id={`${fieldId}-embedding-mode-label`}
htmlFor={`${fieldId}-embedding-mode`}
>
{text(
focusSection === "embedding"
? "搜索模型"
: "向量配置模式",
)}
</label>
<OptionMenu
id={`${fieldId}-embedding-mode`}
labelledBy={`${fieldId}-embedding-mode-label`}
value={embeddingMode}
disabled={formLocked}
onChange={(event) => {
setEmbeddingMode(
event.target.value as "local" | "custom",
);
className="model-option-menu"
panelClassName="model-option-menu-panel"
options={[
{
value: "local",
label: text("本地内置(推荐)"),
},
{
value: "custom",
label: text("自定义搜索模型"),
},
]}
onChange={(value) => {
setEmbeddingMode(value as "local" | "custom");
setEmbeddingModeChanged(true);
setFields((previous) => ({
...previous,
@@ -1220,11 +1389,8 @@ export function ModelSettings({
"embedding-base-url": undefined,
}));
}}
>
<option value="local">{text("本地内置(推荐)")}</option>
<option value="custom">{text("自定义搜索模型")}</option>
</select>
</label>
/>
</div>
<small>{text("技术类型:Embedding")}</small>
{embeddingMode === "custom" ? (
<>
@@ -1257,25 +1423,38 @@ export function ModelSettings({
</small>
)}
</label>
<label>
{text("向量模型来源")}
<select
name="embedding-provider"
<div className="model-form-field">
<label
className="model-option-label"
id={`${fieldId}-embedding-provider-label`}
htmlFor={`${fieldId}-embedding-provider`}
>
{text("向量模型来源")}
</label>
<OptionMenu
id={`${fieldId}-embedding-provider`}
labelledBy={`${fieldId}-embedding-provider-label`}
value={embeddingProvider}
disabled={formLocked}
onChange={(event) => {
const next = event.target.value as
className="model-option-menu"
panelClassName="model-option-menu-panel"
options={[
{ value: "ollama", label: text("本机 Ollama") },
{
value: "openai_compatible",
label: text(
"同源 OpenAI-compatible(共用 API Key)",
),
},
]}
onChange={(value) => {
const next = value as
| "openai_compatible"
| "ollama";
setEmbeddingProvider(next);
}}
>
<option value="ollama">{text("本机 Ollama")}</option>
<option value="openai_compatible">
{text("同源 OpenAI-compatible(共用 API Key)")}
</option>
</select>
</label>
/>
</div>
<label>
{text("向量模型 Base URL")}
<input
@@ -1337,40 +1516,13 @@ export function ModelSettings({
)}
{focusSection !== "embedding" && (
<>
<label>
Base URL
<input
name="base-url"
aria-label="Base URL"
aria-describedby={`model-url-help${fields["base-url"] ? " model-url-error" : ""}`}
aria-invalid={!!fields["base-url"]}
type="url"
inputMode="url"
autoComplete="off"
required={provider !== "hy_default"}
disabled={formLocked || provider === "hy_default"}
value={baseUrl}
onChange={(event) => {
setBaseUrl(event.target.value);
setProviderPreset("custom");
}}
/>
<small id="model-url-help">
{text("填写服务接口地址,不是聊天网页地址。")}
</small>
{fields["base-url"] && (
<small id="model-url-error" className="model-field-error">
{text(fields["base-url"])}
</small>
)}
</label>
<label>
API Key{keyRequired ? "" : text("(可选)")}
<input
name="api-key"
name="api_key"
aria-label={`API Key${keyRequired ? "" : text("(可选)")}`}
aria-describedby={`model-key-help${fields["api-key"] ? " model-key-error" : ""}`}
aria-invalid={!!fields["api-key"]}
aria-describedby={`model-key-help${fields.api_key ? " model-key-error" : ""}`}
aria-invalid={!!fields.api_key}
type="password"
autoComplete="off"
required={keyRequired}
@@ -1388,22 +1540,12 @@ export function ModelSettings({
: "保存在系统安全存储中,不随安装包分发。",
)}
</small>
{fields["api-key"] && (
{fields.api_key && (
<small id="model-key-error" className="model-field-error">
{text(fields["api-key"])}
{text(fields.api_key)}
</small>
)}
</label>
<label>
<input
name="disable-thinking"
type="checkbox"
checked={disableThinking}
disabled={formLocked}
onChange={(event) => setDisableThinking(event.target.checked)}
/>
{text("关闭思考链(向支持的厂商下发 thinking-off 参数)")}
</label>
</>
)}
<div className="form-submit">
@@ -1475,8 +1617,13 @@ export function ModelSettings({
<div>
<strong>{profile.name}</strong>
<p>
{text(providerLabel(profile.provider_kind))} ·{" "}
{profile.model} ·
{generationSummary(
presetForGeneration(profile),
profile.provider_kind,
profile.model,
text,
)}{" "}
·
{/* Core fills in the built-in vector model on every profile,
* so printing it unconditionally put a line about
* bge-small-zh-v1.5 under a profile whose owner never chose
@@ -1561,13 +1708,14 @@ export function ModelSettings({
embeddingSwitch.clearError();
setSwitchTarget(null);
setSwitchJobId(null);
setSwitchRefreshCompleted(null);
}}
onStart={() => {
void embeddingSwitch.startPlannedSwitch().then((job) => {
if (!job || !alive.current) return;
setSwitchJobId(job.job_id);
setSwitchRefreshCompleted(null);
setSetupError(null);
onSetupComplete?.();
});
}}
/>
@@ -1753,7 +1901,11 @@ function PresetProfileCard({
</button>
<button
type="button"
disabled={busy || !canEnable || !ready(profile, probe, probeError)}
disabled={
busy ||
!canEnable ||
!modelProfileReady(profile, probe, probeError)
}
onClick={onActivate}
>
{text("设为当前模型")}
@@ -10,12 +10,35 @@ import type { ImportSourcesController } from "./use-import-sources";
vi.mock("./api", () => ({
completeOnboarding: vi.fn(),
getEvolveSchedule: vi.fn().mockResolvedValue({
enabled: true,
time: "03:00",
}),
getModelProfiles: vi.fn().mockResolvedValue([]),
setEvolveSchedule: vi.fn(),
memoryModePlanUnsupported: vi.fn(() => false),
getMemoryMode: vi.fn().mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: false,
}),
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
planMemoryMode: vi.fn().mockResolvedValue({
current: "lite",
target: "pro",
effective_target: "pro",
capabilities: {
content_plane: "memory",
generation_required: true,
graph_enabled: false,
evolve_available: false,
},
effects: [],
warnings: [],
blockers: ["active_model_required"],
}),
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
}));
vi.mock("./FirstImportReview", () => ({
defaultHostSourceIds: () => [],
@@ -49,6 +72,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,
@@ -130,7 +156,10 @@ it("moves from import to the actual third step without completing onboarding", a
);
await userEvent.click(screen.getByRole("button", { name: "进入第三步" }));
await userEvent.click(screen.getByRole("radio", { name: /pro/i }));
await userEvent.click(screen.getByRole("button", { name: "继续" }));
await userEvent.click(
await screen.findByRole("button", { name: "确认选择" }),
);
await userEvent.click(screen.getByRole("button", { name: "继续配置模型" }));
expect(screen.getByRole("heading", { name: "第三步模型页面" })).toBeTruthy();
expect(completeOnboarding).not.toHaveBeenCalled();
expect(finish).not.toHaveBeenCalled();
@@ -159,7 +188,10 @@ it("lets an empty first-time user enter without granting or importing a source",
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: "继续" }));
await userEvent.click(
await screen.findByRole("button", { name: "确认选择" }),
);
await userEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
@@ -182,7 +214,10 @@ it("retains explicit retry or enter when saving the skipped onboarding fails", a
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: "继续" }));
await userEvent.click(
await screen.findByRole("button", { name: "确认选择" }),
);
await userEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
await screen.findByRole("button", { name: "重试保存并进入" });
expect(finish).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: "仍进入应用页" }));
@@ -24,6 +24,12 @@ import type { ImportSourcesController } from "./use-import-sources";
vi.mock("./api", () => ({
completeOnboarding: vi.fn(),
getEvolveSchedule: vi.fn().mockResolvedValue({
enabled: true,
time: "03:00",
}),
setEvolveSchedule: vi.fn(),
memoryModePlanUnsupported: vi.fn(() => false),
getModelProfiles: vi.fn(),
getMemoryMode: vi.fn().mockResolvedValue({
mode: "pro",
@@ -31,6 +37,22 @@ vi.mock("./api", () => ({
explicitly_configured: false,
}),
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
planMemoryMode: vi.fn().mockResolvedValue({
current: "pro",
target: "lite",
effective_target: "lite",
capabilities: {
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
},
effects: [],
warnings: [],
blockers: [],
}),
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
}));
const models = {
pendingProbe: false,
@@ -109,6 +131,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;
@@ -265,7 +290,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(
@@ -295,20 +320,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();
});
@@ -416,7 +441,10 @@ it("translates completion recovery live without repeating completion writes", as
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: "Continuar" }));
fireEvent.click(
await screen.findByRole("button", { name: "Confirmar selección" }),
);
fireEvent.click(screen.getByRole("button", { name: "Entrar con Lite" }));
expect(
await screen.findByRole("button", { name: "Reintentar guardar y entrar" }),
).toBeTruthy();
@@ -439,9 +467,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();
@@ -452,6 +485,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,12 +26,34 @@ vi.mock("./api", () => ({
getImportJobs: vi.fn(),
connectSources: vi.fn(),
completeOnboarding: vi.fn(),
getEvolveSchedule: vi.fn().mockResolvedValue({
enabled: true,
time: "03:00",
}),
setEvolveSchedule: vi.fn(),
memoryModePlanUnsupported: vi.fn(() => false),
getMemoryMode: vi.fn().mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: false,
}),
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
planMemoryMode: vi.fn().mockResolvedValue({
current: "pro",
target: "lite",
effective_target: "lite",
capabilities: {
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
},
effects: [],
warnings: [],
blockers: [],
}),
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
getSourceConnection: vi.fn(),
createImportJob: vi.fn(),
detectSource: vi.fn(),
@@ -74,6 +96,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[];
@@ -146,6 +171,32 @@ 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({
current: "pro",
target: "lite",
effective_target: "lite",
capabilities: {
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
},
effects: [],
warnings: [],
blockers: [],
} as never);
vi.mocked(api.getEvolveSchedule).mockResolvedValue({
enabled: true,
time: "03:00",
});
vi.mocked(api.putOnboardingProgress).mockResolvedValue(state);
vi.mocked(api.patchOnboardingProgress).mockResolvedValue(state);
localStorage.clear();
vi.stubGlobal("crypto", webcrypto);
delete window.oneMemory;
@@ -302,7 +353,8 @@ it("empty discovery shows zero and allows explicit entry without a connection re
);
fireEvent.click(screen.getByRole("button", { name: "稍后添加,先进入" }));
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
fireEvent.click(screen.getByRole("button", { name: "继续" }));
fireEvent.click(await screen.findByRole("button", { name: "确认选择" }));
fireEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
@@ -354,7 +406,8 @@ it("partial acceptance preserves the successful job and failure through model-st
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: "继续" }));
fireEvent.click(await screen.findByRole("button", { name: "确认选择" }));
fireEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
@@ -15,12 +15,21 @@ import type { ImportSourcesController } from "./use-import-sources";
vi.mock("./api", () => ({
completeOnboarding: vi.fn(),
getEvolveSchedule: vi.fn().mockResolvedValue({
enabled: true,
time: "03:00",
}),
setEvolveSchedule: vi.fn(),
memoryModePlanUnsupported: vi.fn(() => false),
getMemoryMode: vi.fn().mockResolvedValue({
mode: "pro",
configured_mode: "pro",
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[] }) => (
@@ -41,6 +50,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,
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,11 +1,11 @@
// @vitest-environment jsdom
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { OnboardingModelController } from "./OnboardingModelController";
const { models } = vi.hoisted(() => ({
const { embedding, getModelProfiles, models, step } = vi.hoisted(() => ({
models: {
profiles: [
{
@@ -23,76 +23,499 @@ const { models } = vi.hoisted(() => ({
max_concurrency: 4,
context_window: null,
has_api_key: true,
credential_state: "available" as
| "available"
| "missing"
| "unavailable",
is_active: true,
capabilities: {
endpoint_reachable: true,
model_available: true,
embedding_available: true,
structured_output: false,
},
},
],
pendingProbe: false,
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(),
},
}));
vi.mock("./use-model-profiles", () => ({
useModelProfiles: () => models,
}));
vi.mock("./use-embedding-switch", () => ({
useEmbeddingSwitch: () => ({
current: null,
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,
supported: false as boolean | null,
planActivation: vi.fn(),
startPlannedSwitch: vi.fn(),
shouldUseLegacyActivation: () => true,
shouldUseLegacyActivation: (() => true) as () => boolean,
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: {
probe?: { message: string } | null;
enableActionLabel?: string;
onSaveAndEnable(): Promise<void>;
}) => (
<section>
<p>{props.probe?.message}</p>
<button type="button" onClick={() => void props.onSaveAndEnable()}>
{props.enableActionLabel}
</button>
</section>
),
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;
embedding.shouldUseLegacyActivation = () => true;
step.current = null;
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("resumes onboarding with an already active model after a restart", async () => {
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()}
onEnabled={enabled}
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("does not complete with an active profile whose credential is unavailable", async () => {
models.profiles = [
{
...activeProfile,
credential_state: "unavailable",
},
];
const enabled = vi.fn();
render(
<OnboardingModelController
sourceNames={[]}
initialProfileId="active-profile"
onBack={vi.fn()}
onModelActiveConfirmed={enabled}
/>,
);
await waitFor(() => expect(step.current).not.toBeNull());
expect(enabled).not.toHaveBeenCalled();
await userEvent.click(
screen.getByRole("button", { name: "继续使用当前模型" }),
);
expect(enabled).not.toHaveBeenCalled();
expect(screen.getByText(/模型凭据或连接状态已变化/)).toBeTruthy();
});
it("hydrates a persisted inactive profile without creating a duplicate", async () => {
models.profiles = [{ ...activeProfile, is_active: false }];
const active = vi.fn();
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, revision: 4, is_active: false };
models.profiles = [inactive];
models.pendingProbe = {
operationId: "probe-operation",
profileId: "active-profile",
inputRevision: 3,
};
models.probeProfile.mockResolvedValueOnce({
profile_id: "active-profile",
input_profile_revision: 3,
profile_revision: 4,
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"),
);
await waitFor(() =>
expect(screen.getByText("模型连接测试通过,可以启用。")).toBeTruthy(),
);
expect(currentStep().canSaveAndEnable).toBe(true);
expect(models.update).not.toHaveBeenCalled();
expect(models.create).not.toHaveBeenCalled();
});
it("passes the first test when Core advances the tested profile revision", async () => {
const inactive = { ...activeProfile, revision: 1, is_active: false };
models.profiles = [inactive];
models.update.mockResolvedValueOnce(inactive);
models.probeProfile.mockResolvedValueOnce({
profile_id: inactive.profile_id,
input_profile_revision: 1,
profile_revision: 2,
capabilities: {
endpoint_reachable: true,
model_available: true,
embedding_available: true,
},
stages: [{ ok: false }],
});
models.activate.mockResolvedValueOnce({
...inactive,
revision: 2,
is_active: true,
capabilities: {
endpoint_reachable: true,
model_available: true,
embedding_available: true,
},
});
embedding.supported = true;
embedding.current = { writes_paused: false };
embedding.shouldUseLegacyActivation = () => false;
embedding.planActivation.mockResolvedValueOnce({ needed: false });
const confirmed = vi.fn();
render(
<OnboardingModelController
sourceNames={[]}
initialProfileId={inactive.profile_id}
onBack={vi.fn()}
onModelActiveConfirmed={confirmed}
/>,
);
await waitFor(() =>
expect(
(currentStep().profile as { profile_id: string } | null)?.profile_id,
).toBe(inactive.profile_id),
);
await act(async () => {
await (currentStep().onTest as () => Promise<void>)();
});
await waitFor(() =>
expect(screen.getByText("模型连接测试通过,可以启用。")).toBeTruthy(),
);
expect(currentStep().canSaveAndEnable).toBe(true);
expect(currentStep().profile).toMatchObject({ revision: 2 });
expect(getModelProfiles).not.toHaveBeenCalled();
await act(async () => {
await (currentStep().onSaveAndEnable as () => Promise<void>)();
});
expect(embedding.planActivation).toHaveBeenCalledWith(inactive.profile_id, 2);
expect(models.activate).toHaveBeenCalledWith(inactive.profile_id);
expect(confirmed).toHaveBeenCalledWith(1, inactive.profile_id);
});
it("keeps an active resumed profile on the model step while writes are paused", async () => {
embedding.supported = true;
embedding.current = { writes_paused: true };
const active = vi.fn();
render(
<OnboardingModelController
sourceNames={[]}
initialProfileId="active-profile"
onBack={vi.fn()}
onModelActiveConfirmed={active}
/>,
);
expect(
await screen.findByText("当前模型已经启用,可以继续完成首次设置。"),
await screen.findByText("模型写入暂时关闭。请等待索引维护完成后重试。"),
).toBeTruthy();
await userEvent.click(
screen.getByRole("button", { name: "继续使用当前模型" }),
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 waitFor(() => expect(enabled).toHaveBeenCalledOnce());
expect(models.activate).not.toHaveBeenCalled();
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",
});
});
+297 -132
View File
@@ -1,11 +1,15 @@
import type {
ModelProbeResult,
ModelProfile,
ModelProfileInput,
ModelProfilePatch,
} from "@one-memory/contracts";
import { useEffect, useRef, useState } from "react";
import { getModelProfiles } from "./api";
import { useCallback, useEffect, useRef, useState } from "react";
import { EmbeddingSwitchConfirmDialog } from "./EmbeddingSwitchExperience";
import {
presetForGeneration,
validateGenerationModel,
} from "./GenerationModelPrimitives";
import { modelProfileReady, profileFromProbe } from "./model-profile-readiness";
import {
type OnboardingModelDraft,
OnboardingModelStep,
@@ -31,41 +35,32 @@ 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: "",
};
export function probeMatchesProfile(
original: ModelProfile,
current: ModelProfile | undefined,
probe: ModelProbeResult | undefined | null,
): current is ModelProfile {
return Boolean(
current &&
probe &&
probe.profile_id === original.profile_id &&
probe.profile_revision === current.revision &&
probe.capabilities.endpoint_reachable &&
probe.capabilities.model_available &&
probe.capabilities.embedding_available &&
probe.stages.every((stage) => stage.ok),
);
}
export function OnboardingModelController({
sourceNames,
importedConversationCount = 0,
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();
@@ -90,36 +85,81 @@ export function OnboardingModelController({
);
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);
const confirmReadyProfile = useCallback(
(profile: ModelProfile, owner = generationToken) => {
if (!isGenerationCurrent(owner) || !modelProfileReady(profile)) {
setError("模型凭据或连接状态已变化,请重新测试后再完成首次设置。");
return false;
}
onModelActiveConfirmed?.(owner, profile.profile_id);
return true;
},
[generationToken, isGenerationCurrent, onModelActiveConfirmed],
);
useEffect(() => {
const active = (models.profiles ?? []).find((profile) => profile.is_active);
if (!active || saved || draftTouched.current) return;
setSaved(active);
const profile = initialProfileId
? (models.profiles ?? []).find(
(candidate) => candidate.profile_id === initialProfileId,
)
: undefined;
if (!profile || saved || draftTouched.current) return;
setSaved(profile);
setDraft((current) => ({
...current,
name: active.name,
provider_kind: active.provider_kind,
model: active.model,
base_url: active.base_url ?? "",
disable_thinking: active.disable_thinking,
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:
active.embedding_provider_kind === "local_onnx" ? "local" : "custom",
profile.embedding_provider_kind === "local_onnx" ? "local" : "custom",
embedding_provider_kind:
active.embedding_provider_kind === "ollama"
profile.embedding_provider_kind === "ollama"
? "ollama"
: "openai_compatible",
embedding_model: active.embedding_model ?? "",
embedding_base_url: active.embedding_base_url ?? "",
timeout_seconds: String(active.timeout_seconds),
max_concurrency: String(active.max_concurrency),
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:
active.context_window === null ? "" : String(active.context_window),
profile.context_window === null ? "" : String(profile.context_window),
}));
setProbe({
state: "passed",
message: "当前模型已经启用,可以继续完成首次设置。",
});
}, [models.profiles, saved]);
if (profile.is_active)
setProbe({
state: "passed",
message: "当前模型已经启用,可以继续完成首次设置。",
});
}, [initialProfileId, models.profiles, saved]);
useEffect(() => {
if (
!saved?.is_active ||
(embeddingSwitch.supported !== false && !embeddingSwitch.current) ||
embeddingSwitch.current?.writes_paused
)
return;
const key = `${generationToken}:${saved.profile_id}`;
if (autoConfirmed.current === key) return;
if (confirmReadyProfile(saved, generationToken))
autoConfirmed.current = key;
}, [
embeddingSwitch.current,
embeddingSwitch.supported,
confirmReadyProfile,
generationToken,
saved,
]);
useEffect(() => {
const finished = embeddingSwitch.current?.last_finished;
@@ -133,18 +173,32 @@ export function OnboardingModelController({
flight.current = false;
setSwitchPendingJobId(null);
setPhase("idle");
onEnabled?.();
if (saved) confirmReadyProfile(saved, generationToken);
} else if (finished.state === "rolled_back") {
flight.current = false;
setSwitchPendingJobId(null);
setPhase("idle");
setError("索引更新没有完成,仍在使用原来的搜索模型。请检查连接后重试。");
}
}, [embeddingSwitch.current?.last_finished, onEnabled, switchPendingJobId]);
}, [
embeddingSwitch.current?.last_finished,
confirmReadyProfile,
generationToken,
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,
@@ -154,9 +208,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);
}
@@ -167,82 +226,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) {
@@ -252,28 +271,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);
const current = (await getModelProfiles()).find(
(item) => item.profile_id === profile.profile_id,
if (!isGenerationCurrent(owner)) return;
const current = profileFromProbe(profile, result);
const passed = Boolean(
current && modelProfileReady(current, result ?? null),
);
const passed = probeMatchesProfile(profile, current, result);
if (current) setSaved(current);
setProbe({
state: passed ? "passed" : "failed",
@@ -289,12 +346,59 @@ 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 = profileFromProbe(saved, result);
const passed = Boolean(
current && modelProfileReady(current, result ?? null),
);
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) {
onEnabled?.();
if (
embeddingSwitch.current?.writes_paused ||
(embeddingSwitch.supported !== false && !embeddingSwitch.current)
) {
setError("模型写入暂时关闭。请等待索引维护完成后重试。");
return;
}
confirmReadyProfile(saved, generationToken);
return;
}
const owner = generationToken;
flight.current = true;
setPhase("enabling");
setError(null);
@@ -306,6 +410,7 @@ export function OnboardingModelController({
saved.profile_id,
saved.revision,
);
if (!isGenerationCurrent(owner)) return;
if (!plan && !embeddingSwitch.shouldUseLegacyActivation())
throw new Error("embedding_switch_plan_unconfirmed");
if (plan?.needed) {
@@ -313,9 +418,16 @@ export function OnboardingModelController({
return;
}
const active = await models.activate(saved.profile_id);
if (!active?.is_active) throw new Error("model_activation_unconfirmed");
if (!isGenerationCurrent(owner)) return;
if (
!active?.is_active ||
active.profile_id !== saved.profile_id ||
active.revision !== saved.revision ||
!modelProfileReady(active)
)
throw new Error("model_activation_unconfirmed");
trace.done("succeeded");
onEnabled?.();
confirmReadyProfile(active, owner);
} catch {
trace.done("unknown", { reason: "model_enable_unconfirmed" });
setError("模型未能确认启用,请在设置中核对。");
@@ -350,8 +462,17 @@ export function OnboardingModelController({
capability={{ saveAndEnable: true }}
canSaveAndEnable={probe?.state === "passed" && !!saved}
enableActionLabel={saved?.is_active ? "继续使用当前模型" : undefined}
canTest={!models.pendingProbe && !models.probeStorageFailed}
canSaveDraft={!models.pendingCreation && !models.creationStorageFailed}
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]) => [
@@ -368,7 +489,17 @@ export function OnboardingModelController({
: text(value),
]),
)}
error={error ?? (models.error ? text(models.error) : null)}
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
@@ -393,10 +524,44 @@ export function OnboardingModelController({
: draft.embedding_base_url || draft.base_url || "尚未填写接收方"
}
onSaveDraft={() => save(false)}
onTest={() => save(true)}
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}
onSkip={onSkip}
focusToken={generationToken}
onActionError={() => setError("操作未确认,草稿已保留。")}
/>
{embeddingSwitch.plan?.needed && saved ? (
+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>
);
}
+1 -1
View File
@@ -111,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();
}
+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}
+48 -5
View File
@@ -21,6 +21,9 @@ 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");
@@ -112,8 +115,8 @@ vi.mock("./ModelSettings", async () => {
}) => {
useImperativeHandle(controlsRef, () => ({
edit: () => false,
editProfile: () => false,
add: () => false,
editProfile: editProfileAction,
add: addProfileAction,
test: () => Promise.resolve(undefined),
}));
return createElement(
@@ -166,6 +169,7 @@ it("shows the help and diagnostics group by default and hides it when opted out"
await user.click(screen.getByRole("button", { name: "帮助与诊断" }));
expect(screen.getByRole("heading", { name: "帮助与诊断" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "导出日志" })).toBeTruthy();
expect(screen.getByText(/可能包含会话正文和本机路径/)).toBeTruthy();
expect(screen.getByRole("heading", { name: "关于" })).toBeTruthy();
expect(screen.getByText("1.0.4")).toBeTruthy();
expect(screen.getByText("8")).toBeTruthy();
@@ -304,6 +308,7 @@ it("reports a stop that did not confirm instead of showing it stopped", async ()
});
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: "模型配置" }));
@@ -321,10 +326,13 @@ it("opens the model area even when a pending probe fences the editor, so recover
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()} />);
@@ -368,11 +376,46 @@ it("shows separate compact lanes and opens the requested presentation", async ()
expect(embeddingLane.textContent).toContain("Embedding");
await user.click(within(embeddingLane).getByRole("button", { name: "配置" }));
expect(screen.getByText("embedding")).toBeTruthy();
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(screen.getByText("generation")).toBeTruthy();
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 () => {
+187 -82
View File
@@ -78,6 +78,7 @@ export function R6Settings({
const [configurationOpen, setConfigurationOpen] = useState(false);
const [configurationFocus, setConfigurationFocus] =
useState<ModelSettingsFocusSection>("generation");
const [configurationRequest, setConfigurationRequest] = useState(0);
const [memoryMode, setMemoryModeState] = useState<MemoryPipelineMode | null>(
null,
);
@@ -101,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;
@@ -224,18 +229,49 @@ export function R6Settings({
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() {
@@ -384,67 +420,156 @@ export function R6Settings({
</div>
)}
<div className="r6-model-overview">
<div className="r6-model-lane">
<div className="r6-model-lane-copy">
<h3>{text("记忆整理")}</h3>
<p>{text("把会话理解并整理成可长期使用的记忆。")}</p>
<strong>
{currentModel?.name ??
text(
profiles === null
? "正在读取模型…"
: modelError
? "模型状态暂不可读"
: "尚未配置模型",
<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>
{currentModel && (
</strong>
<small>
LLM · {currentModel.model} ·{" "}
{text(safeEndpoint(currentModel.base_url))}
{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>
<R6Button
variant="quiet"
disabled={profiles === null}
onClick={() => openConfiguration("generation")}
>
{text(currentModel ? "配置" : "配置模型")}
</R6Button>
</div>
<div className="r6-model-lane">
<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
: "本地内置(推荐)",
{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>
)}
</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}
onClick={() => openConfiguration("embedding")}
>
{text("配置")}
</R6Button>
</div>
<ModelSettings
compactSingle
focusSection={configurationFocus}
controlsRef={modelActions}
onProfilesChanged={() =>
setModelVersion((value) => value + 1)
}
onSetupComplete={completeConfiguration}
/>
</section>
)}
</section>
</div>
{modelError && (
<R6StateRecovery
@@ -456,13 +581,6 @@ export function R6Settings({
onRetry={() => setModelVersion((value) => value + 1)}
/>
)}
{configurationRefusal && (
<p role="alert" className="r6-settings-error">
{text(
"上次模型测试或创建还待核对,配置暂时不能改动。请先在下方模型配置区核对结果; 现有配置和原文不会被改动。",
)}
</p>
)}
{currentModel && memoryMode !== "lite" && (
<div className="r6-setting-row">
<div>
@@ -499,19 +617,6 @@ export function R6Settings({
)}
</p>
)}
<section
className="r6-model-owner"
hidden={!configurationOpen}
aria-label={text("模型配置表单")}
>
<ModelSettings
compactSingle
focusSection={configurationFocus}
controlsRef={modelActions}
onProfilesChanged={() => setModelVersion((value) => value + 1)}
onSetupComplete={() => setConfigurationOpen(false)}
/>
</section>
</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*;/,
);
});
+5 -1
View File
@@ -3,6 +3,7 @@ import { type FormEvent, useEffect, useRef, useState } from "react";
import { getSdkRaw, searchSdkRaw } from "./api";
import { useGuiLanguage } from "./gui-language";
import { subscribePoll } from "./poll-scheduler";
import { rawRecordId } from "./sdk-raw-map";
export function SdkRawHistory({ active = true }: { active?: boolean }) {
const { locale, t, tn } = useGuiLanguage();
@@ -121,7 +122,7 @@ export function SdkRawHistory({ active = true }: { active?: boolean }) {
) : null}
<ol>
{items.map((item) => (
<li key={item.raw_id}>
<li key={rawRecordId(item)}>
<p>{item.content}</p>
<small>
{item.source} · {item.session_id}
@@ -140,6 +141,9 @@ function asContextItem(item: SdkRawRecord): SdkRawContextItem {
return {
kind: "raw",
raw_id: item.raw_id,
turn_id: item.turn_id,
turn_index: item.turn_index,
turn_count: item.turn_count,
content: item.content,
source: item.source,
session_id: item.session_id,
+2 -2
View File
@@ -5,7 +5,7 @@ import type {
} from "@one-memory/contracts";
import { clockStamp } from "./clock-stamp";
import { useGuiLanguage } from "./gui-language";
import { rawRecordTime } from "./sdk-raw-map";
import { rawRecordId, rawRecordTime } from "./sdk-raw-map";
import { sourceNameOr, UNATTRIBUTED_SOURCE_ID } from "./source-label";
type RawRecord = SdkRawRecord | SdkRawContextItem;
@@ -50,7 +50,7 @@ export function SelectedRawDetail({
<p className="selected-memory-status">{t("memory:原始对话")}</p>
<ul className="map-transcript">
{messages.map((message) => (
<li key={`${record.raw_id}:${message.role}:${message.content}`}>
<li key={`${rawRecordId(record)}:${message.role}:${message.content}`}>
<small>{authorLabel(message.role, t)}</small>
<p>{message.content}</p>
</li>
+265
View File
@@ -0,0 +1,265 @@
import { Check, ChevronDown, Clock3 } from "lucide-react";
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Button } from "./R6Controls";
const HOURS = Array.from({ length: 24 }, (_, value) =>
value.toString().padStart(2, "0"),
);
const MINUTES = Array.from({ length: 60 }, (_, value) =>
value.toString().padStart(2, "0"),
);
function splitTime(value: string): [string, string] {
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value);
return match ? [match[1], match[2]] : ["00", "00"];
}
export function TimePicker({
id,
label,
value,
disabled = false,
onChange,
}: {
id: string;
label: string;
value: string;
disabled?: boolean;
onChange(value: string): Promise<boolean>;
}) {
const { locale, text } = useOnboardingTranslation();
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [placement, setPlacement] = useState<"above" | "below">("above");
const [hour, setHour] = useState(() => splitTime(value)[0]);
const [minute, setMinute] = useState(() => splitTime(value)[1]);
const root = useRef<HTMLDivElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
const panel = useRef<HTMLElement>(null);
const hourSelect = useRef<HTMLSelectElement>(null);
const minuteSelect = useRef<HTMLSelectElement>(null);
const restoreTriggerFocus = useRef(false);
const restoreSaveFocus = useRef(false);
const savingRef = useRef(false);
savingRef.current = saving;
const panelId = `${id}-picker`;
const close = useCallback((restoreFocus = true) => {
setOpen(false);
restoreTriggerFocus.current = restoreFocus;
}, []);
function show() {
const [nextHour, nextMinute] = splitTime(value);
setHour(nextHour);
setMinute(nextMinute);
setOpen(true);
}
useEffect(() => {
if (!open) return;
const closeOutside = (event: MouseEvent) => {
if (savingRef.current) return;
const target = event.target as Element | null;
const label = target?.closest("label");
if (label instanceof HTMLLabelElement && label.htmlFor === id) return;
if (!root.current?.contains(target)) close(false);
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== "Escape" || savingRef.current) return;
event.preventDefault();
close();
};
document.addEventListener("mousedown", closeOutside);
document.addEventListener("keydown", closeOnEscape);
hourSelect.current?.focus();
hourSelect.current?.selectedOptions[0]?.scrollIntoView?.({
block: "nearest",
});
minuteSelect.current?.selectedOptions[0]?.scrollIntoView?.({
block: "nearest",
});
return () => {
document.removeEventListener("mousedown", closeOutside);
document.removeEventListener("keydown", closeOnEscape);
};
}, [close, id, open]);
useEffect(() => {
if (disabled && open && !saving) close(false);
if (!disabled && !open && restoreTriggerFocus.current) {
restoreTriggerFocus.current = false;
trigger.current?.focus();
}
if (!disabled && open && restoreSaveFocus.current) {
restoreSaveFocus.current = false;
panel.current
?.querySelector<HTMLButtonElement>("[data-time-picker-save]")
?.focus();
}
if (saving && open) panel.current?.focus();
}, [close, disabled, open, saving]);
useLayoutEffect(() => {
if (!open) return;
const place = () => {
const anchor = trigger.current?.getBoundingClientRect();
const picker = panel.current?.getBoundingClientRect();
if (!anchor || !picker) return;
const scroller = trigger.current?.closest(
".app-content, .onboarding-frame",
);
const bounds = scroller?.getBoundingClientRect();
const topEdge = (bounds ? Math.max(0, bounds.top) : 0) + 12;
const bottomEdge =
(bounds
? Math.min(window.innerHeight, bounds.bottom)
: window.innerHeight) - 12;
const above = anchor.top - topEdge;
const below = bottomEdge - anchor.bottom;
setPlacement(
above >= picker.height || above >= below ? "above" : "below",
);
};
place();
window.addEventListener("resize", place);
window.addEventListener("scroll", place, true);
return () => {
window.removeEventListener("resize", place);
window.removeEventListener("scroll", place, true);
};
}, [open]);
async function save() {
const next = `${hour}:${minute}`;
if (next === value) {
close();
return;
}
setSaving(true);
const saved = await onChange(next);
setSaving(false);
if (saved) close();
else restoreSaveFocus.current = true;
}
return (
<div className="r6-time-picker" ref={root}>
<button
ref={trigger}
id={id}
type="button"
className="r6-time-picker-trigger"
aria-label={
locale.startsWith("zh") ? `${label},${value}` : `${label}: ${value}`
}
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={panelId}
disabled={disabled}
onClick={() => (open ? close() : show())}
>
<span>{value}</span>
<span className="r6-time-picker-icons" aria-hidden="true">
<Clock3 size={15} />
<ChevronDown size={14} />
</span>
</button>
{open ? (
<section
ref={panel}
id={panelId}
className="r6-time-picker-panel"
role="dialog"
aria-modal="true"
aria-label={label}
tabIndex={-1}
data-placement={placement}
onKeyDown={(event) => {
if (event.key !== "Tab") return;
const controls = Array.from(
event.currentTarget.querySelectorAll<HTMLElement>(
"select:not(:disabled), button:not(:disabled)",
),
);
const first = controls[0];
const last = controls.at(-1);
if (!first || !last) {
event.preventDefault();
event.currentTarget.focus();
return;
}
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}}
>
<div className="r6-time-picker-columns">
<label className="r6-time-picker-column">
<span aria-hidden="true">HH</span>
<select
ref={hourSelect}
size={6}
aria-label={`${label} HH`}
value={hour}
disabled={saving}
onChange={(event) => setHour(event.target.value)}
>
{HOURS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<span className="r6-time-picker-separator" aria-hidden="true">
:
</span>
<label className="r6-time-picker-column">
<span aria-hidden="true">MM</span>
<select
ref={minuteSelect}
size={6}
aria-label={`${label} MM`}
value={minute}
disabled={saving}
onChange={(event) => setMinute(event.target.value)}
>
{MINUTES.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
</div>
<footer>
<R6Button disabled={saving} onClick={() => close()}>
{text("取消")}
</R6Button>
<R6Button
variant="primary"
pending={saving}
data-time-picker-save
onClick={() => void save()}
>
<Check size={15} aria-hidden="true" />
{text("保存修改")}
</R6Button>
</footer>
</section>
) : null}
</div>
);
}
@@ -0,0 +1,63 @@
import type { EvolveSchedule } from "@one-memory/contracts";
import { useId } from "react";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Switch } from "./R6Controls";
import { TimePicker } from "./TimePicker";
export function UltraEvolveScheduleFields({
schedule,
headingId,
disabled = false,
onEnabledChange,
onTimeChange,
}: {
schedule: EvolveSchedule;
headingId?: string;
disabled?: boolean;
onEnabledChange(enabled: boolean): void;
onTimeChange(time: string): Promise<boolean>;
}) {
const { text } = useOnboardingTranslation();
const id = useId();
return (
<>
<div className="r6-evolve-heading">
<h3 id={headingId}>{text("记忆整合(Evolve)")}</h3>
<p>{text("定期重新整理已有记忆,补充更稳定的关联和结论。")}</p>
</div>
<div className="r6-evolve-setting">
<div className="r6-evolve-setting-copy">
<h3>{text("每天自动整合")}</h3>
<p>
{text(
schedule.enabled
? "每天 {time} 左右自动整合,使用这台设备的本地时间;休眠错过后会补跑。"
: "已关闭。重新开启后仍按 {time} 运行;已生成的结果会保留。",
).replace("{time}", schedule.time)}
</p>
</div>
<R6Switch
label={text("每天自动整合")}
checked={schedule.enabled}
disabled={disabled}
onCheckedChange={onEnabledChange}
/>
{schedule.enabled ? (
<div className="r6-evolve-time">
<div>
<label htmlFor={`${id}-time`}>{text("执行时间")}</label>
<p>{text("使用这台设备的本地时间。")}</p>
</div>
<TimePicker
id={`${id}-time`}
label={text("执行时间")}
value={schedule.time}
disabled={disabled}
onChange={onTimeChange}
/>
</div>
) : null}
</div>
</>
);
}
+4
View File
@@ -32,6 +32,9 @@ it("lists raw conversations for the selected application", async () => {
session_id: undefined,
offset: 20,
limit: 20,
unit: "turn",
paging: undefined,
cursor: undefined,
},
signal,
});
@@ -49,6 +52,7 @@ it("keeps the application filter when searching raw conversations", async () =>
query: "release plan",
kinds: ["raw"],
include_sources: ["claude-code"],
raw_unit: "turn",
limit: 20,
character_budget: 12_000,
},
+37 -1
View File
@@ -37,6 +37,7 @@ import {
type ModelProfile,
type ModelProfileInput,
type ModelProfilePatch,
type OnboardingProgressUpdate,
type OnboardingState,
type SdkMemoryKind,
type SdkMemoryPage,
@@ -478,6 +479,26 @@ export async function getOnboarding(): Promise<OnboardingState> {
return response.data;
}
export async function patchOnboardingProgress(
update: OnboardingProgressUpdate,
): Promise<OnboardingState> {
const response = await client.patch<OnboardingState>(
"/api/v1/onboarding/progress",
update,
);
return response.data;
}
export async function putOnboardingProgress(
update: OnboardingProgressUpdate,
): Promise<OnboardingState> {
const response = await client.put<OnboardingState>(
"/api/v1/onboarding/progress",
update,
);
return response.data;
}
export async function completeOnboarding(
traceId?: string,
): Promise<OnboardingState> {
@@ -965,16 +986,30 @@ export async function getSdkRaw({
sessionId,
offset = 0,
limit = 100,
unit = "turn",
paging,
cursor,
signal,
}: {
source?: string;
sessionId?: string;
offset?: number;
limit?: number;
unit?: "raw" | "turn";
paging?: "offset" | "cursor";
cursor?: string | null;
signal?: AbortSignal;
} = {}): Promise<SdkRawPage> {
const response = await client.get<SdkRawPage>("/api/v2/raw", {
params: { source, session_id: sessionId, offset, limit },
params: {
source,
session_id: sessionId,
offset,
limit,
unit,
paging,
cursor: cursor || undefined,
},
signal,
});
return response.data;
@@ -990,6 +1025,7 @@ export async function searchSdkRaw(
query,
kinds: ["raw"],
...(options.source ? { include_sources: [options.source] } : {}),
raw_unit: "turn",
limit: 20,
character_budget: 12_000,
},
@@ -42,6 +42,8 @@
margin-bottom: 24px;
}
.r6-home-overview {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 346px;
padding: 26px;
@@ -150,6 +152,7 @@
padding: 8px 0;
}
.r6-home-activity {
margin-top: auto;
border-top: 1px solid #d5dfe8;
padding-top: 12px;
}
+15 -2
View File
@@ -132,12 +132,17 @@ button svg {
content: "";
position: absolute;
right: 18px;
top: 22px;
top: 50%;
width: 8px;
height: 8px;
border-right: 1.5px solid #4e6478;
border-bottom: 1.5px solid #4e6478;
transform: rotate(45deg);
transform: translateY(-65%) rotate(45deg);
transform-origin: 50% 50%;
transition: transform 150ms ease-out;
}
.om-option-menu-button[aria-expanded="true"]::after {
transform: translateY(-35%) rotate(225deg);
}
.om-option-menu-button:disabled {
color: #66727b;
@@ -981,6 +986,14 @@ button.map-tool {
background: #fff;
box-shadow: var(--shadow-pop);
}
.memory-graph-fallback-topic {
display: block;
width: 100%;
margin-block: 6px;
padding: 8px 14px;
color: var(--tx2);
overflow-wrap: anywhere;
}
.graph-labels {
position: absolute;
inset: 0;
+4 -3
View File
@@ -38,7 +38,7 @@ const base = {
describe("home view model", () => {
it.each([
["lite", "raw", "apps.rawConversations", "apps.rawSaving"],
["lite", "raw", "apps.rawConversations", "apps.rawSavingCompact"],
["pro", "memory", "apps.memories", "apps.running"],
["ultra", "memory", "apps.memories", "apps.running"],
] as const)(
@@ -109,7 +109,7 @@ describe("home view model", () => {
} as never,
});
expect(result.recentMetric).toEqual({
valueKey: "apps.rawCheckedEmpty",
valueKey: "apps.recentEmptyCompact",
detailKey: "apps.checkedRecent",
});
expect(result.activity).toMatchObject({
@@ -158,9 +158,10 @@ describe("home view model", () => {
projectionState: "ready",
});
expect(result.recentMetric).toEqual({
valueKey: "apps.memoryCheckedEmpty",
valueKey: "apps.recentEmptyCompact",
detailKey: "apps.checkedRecent",
});
expect(result.activity.titleKey).toBe("apps.memoryCheckedEmpty");
expect(result.activity.kind).toBe("empty");
});
+3 -10
View File
@@ -40,11 +40,7 @@ export interface HomeViewModel {
emptyExcerpt: boolean;
} | null;
recentMetric: {
valueKey:
| "apps.checkingRecent"
| "apps.rawCheckedEmpty"
| "apps.memoryCheckedEmpty"
| null;
valueKey: "apps.checkingRecent" | "apps.recentEmptyCompact" | null;
detailKey:
| "apps.rawSaved"
| "apps.saved"
@@ -120,7 +116,7 @@ export function buildHomeViewModel(input: {
const sedimenting = input.sedimentation?.enabled;
const processingLabel =
!generationRequired || mode === "lite"
? "apps.rawSaving"
? "apps.rawSavingCompact"
: sedimenting === true
? "apps.running"
: sedimenting === false
@@ -179,10 +175,7 @@ export function buildHomeViewModel(input: {
}
: state === "ready" || state === "stale"
? {
valueKey:
plane === "raw"
? ("apps.rawCheckedEmpty" as const)
: ("apps.memoryCheckedEmpty" as const),
valueKey: "apps.recentEmptyCompact" as const,
detailKey: "apps.checkedRecent" as const,
}
: {
+232
View File
@@ -0,0 +1,232 @@
import type { MemoryModePlan, MemoryPipelineMode } from "@one-memory/contracts";
import { useEffect, useRef } from "react";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Button } from "./R6Controls";
export const MODE_COPY: Record<
MemoryPipelineMode,
{ title: string; description: string }
> = {
lite: {
title: "基础记忆(lite)",
description: "保存原始对话 · 不调用生成模型 · 搜索原始会话",
},
pro: {
title: "智能记忆(pro)",
description: "沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆",
},
ultra: {
title: "深度记忆(ultra)",
description: "智能记忆 + 知识图谱 · 可自动 Evolve · 增强关联召回",
},
};
export const BLOCKER_COPY: Record<MemoryModePlan["blockers"][number], string> =
{
active_model_required: "使用 pro 或 ultra 前需要先配置并启用生成模型。",
embedding_maintenance_in_progress:
"正在检查或切换向量模型,完成后才能切换记忆模式。",
memory_lifecycle_busy: "记忆维护正在进行,完成后才能切换模式。",
graph_store_unavailable: "当前安装缺少知识图谱组件,无法开启 ultra。",
};
export function memoryModePlanRows(plan: MemoryModePlan) {
const rows = [
{
label: "新对话",
content:
plan.capabilities.content_plane === "raw"
? "之后只保存原始对话,不再生成新记忆。"
: plan.capabilities.graph_enabled
? "新对话会整理成记忆并写入知识图谱。"
: "新对话会由模型整理成记忆。",
},
{
label: "模型调用与费用",
content: plan.capabilities.generation_required
? plan.capabilities.evolve_available
? "会调用当前生成模型;自动整合可能产生额外费用。切换本身不会立即整合。"
: "会调用当前生成模型,可能产生服务商费用。"
: "不调用生成模型,不再产生这部分费用。",
},
{
label: "搜索范围",
content:
plan.capabilities.content_plane === "raw"
? "切换后搜索原始对话。"
: plan.capabilities.graph_enabled
? "搜索沉淀记忆,并用知识图谱补充关联。"
: "搜索沉淀后的记忆。",
},
];
if (plan.effects.includes("earlier_raw_not_extracted"))
rows.push({
label: "已有数据",
content: "已经处理完的旧对话不会补做;原始对话全部保留。",
});
if (plan.effects.includes("higher_mode_memories_hidden"))
rows.push({
label: "已有数据",
content:
plan.effective_target === "pro"
? "普通沉淀记忆仍可使用;深度记忆新增的更高层记忆和图谱数据继续保留,但在智能记忆下不显示、不参与召回。原始对话不会删除。"
: "已有记忆继续保留,但在基础记忆下不显示、不参与召回;原始对话不会删除。",
});
if (plan.effects.includes("existing_memories_added_to_graph"))
rows.push({
label: "知识图谱",
content: "已有记忆会在后台加入知识图谱,期间仍可使用。",
});
if (plan.effects.includes("evolve_stopped"))
rows.push({
label: "每天自动整合",
content: "每天自动整合会停止,已经生成的数据不会删除。",
});
if (plan.capabilities.evolve_available)
rows.push({
label: "切换后",
content: "可设置每天自动整合时间,也可以随时立即整合一次。",
});
return rows;
}
export function MemoryModeConfirmDialog({
mode,
plan,
busy,
error,
opener,
allowedBlockers = [],
onCancel,
onConfirm,
}: {
mode: MemoryPipelineMode;
plan: MemoryModePlan;
busy: boolean;
error?: string;
opener?: HTMLElement | null;
allowedBlockers?: readonly MemoryModePlan["blockers"][number][];
onCancel(): void;
onConfirm(): void;
}) {
const { text } = useOnboardingTranslation();
const panel = useRef<HTMLElement>(null);
const busyRef = useRef(busy);
const onCancelRef = useRef(onCancel);
busyRef.current = busy;
onCancelRef.current = onCancel;
const blocking = plan.blockers.filter(
(blocker) => !allowedBlockers.includes(blocker),
);
useEffect(() => {
const element = panel.current;
if (!element) return;
const inertSiblings = new Map<HTMLElement, boolean>();
let branch: HTMLElement = element.parentElement ?? element;
while (branch.parentElement) {
for (const sibling of branch.parentElement.children) {
if (!(sibling instanceof HTMLElement) || sibling === branch) continue;
inertSiblings.set(sibling, sibling.inert);
sibling.inert = true;
}
branch = branch.parentElement;
if (branch === document.body) break;
}
const controls = () =>
Array.from(
element.querySelectorAll<HTMLElement>("button:not(:disabled)"),
);
controls()[0]?.focus();
const handleKey = (event: KeyboardEvent) => {
if (event.key === "Escape" && !busyRef.current) {
event.preventDefault();
onCancelRef.current();
return;
}
if (event.key !== "Tab") return;
const items = controls();
const first = items[0];
const last = items.at(-1);
if (!first || !last) {
event.preventDefault();
element.focus();
} else if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
element.addEventListener("keydown", handleKey);
return () => {
element.removeEventListener("keydown", handleKey);
for (const [sibling, wasInert] of inertSiblings) sibling.inert = wasInert;
if (opener?.isConnected) opener.focus();
};
}, [opener]);
useEffect(() => {
if (busy) panel.current?.focus();
}, [busy]);
return (
<div className="r6-settings-dialog-layer" role="presentation">
<section
ref={panel}
className="r6-settings-dialog r6-mode-confirm-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="memory-mode-confirm-title"
tabIndex={-1}
>
<h2 id="memory-mode-confirm-title">
{text("切换到 {mode}?").replace(
"{mode}",
text(MODE_COPY[mode].title),
)}
</h2>
<dl className="r6-mode-confirm-effects">
{memoryModePlanRows(plan).map((row) => (
<div key={`${row.label}:${row.content}`}>
<dt>{text(row.label)}</dt>
<dd>{text(row.content)}</dd>
</div>
))}
</dl>
{plan.blockers.map((blocker) => (
<p
className={
blocking.includes(blocker)
? "r6-settings-error"
: "r6-settings-note"
}
role={blocking.includes(blocker) ? "alert" : "status"}
key={blocker}
>
{text(BLOCKER_COPY[blocker])}
</p>
))}
{error ? (
<p className="r6-settings-error" role="alert">
{text(error)}
</p>
) : null}
<footer>
<R6Button disabled={busy} onClick={onCancel}>
{text("取消")}
</R6Button>
<R6Button
variant="primary"
pending={busy}
disabled={blocking.length > 0}
onClick={onConfirm}
>
{text("切换模式")}
</R6Button>
</footer>
</section>
</div>
);
}
@@ -0,0 +1,79 @@
import type { ModelProbeResult, ModelProfile } from "@one-memory/contracts";
import { expect, it } from "vitest";
import { modelProfileReady, profileFromProbe } from "./model-profile-readiness";
const profile: ModelProfile = {
profile_id: "saved-1",
name: "Saved model",
provider_kind: "openai_compatible",
base_url: "https://models.example/v1",
model: "model-1",
embedding_model: null,
embedding_provider_kind: null,
embedding_base_url: null,
embedding_dimensions: null,
context_window: null,
disable_thinking: false,
has_api_key: true,
timeout_seconds: 60,
max_concurrency: 1,
revision: 1,
is_active: false,
created_at: "2026-09-14T00:00:00Z",
updated_at: "2026-09-14T00:00:00Z",
capabilities: {
endpoint_reachable: false,
model_available: false,
embedding_available: false,
structured_output: false,
},
};
function probe(
inputRevision: number | null | undefined = 1,
outputRevision = 2,
): ModelProbeResult {
return {
profile_id: profile.profile_id,
input_profile_revision: inputRevision,
profile_revision: outputRevision,
stages: [],
capabilities: {
endpoint_reachable: true,
model_available: true,
embedding_available: true,
structured_output: false,
},
};
}
it("accepts a measured one-revision bump before or after profile refresh", () => {
const receipt = probe();
const beforeRefresh = profileFromProbe(profile, receipt);
const afterRefresh = profileFromProbe({ ...profile, revision: 2 }, receipt);
expect(beforeRefresh).toMatchObject({ revision: 2 });
expect(afterRefresh).toMatchObject({ revision: 2 });
expect(modelProfileReady(beforeRefresh as ModelProfile, receipt)).toBe(true);
});
it("rejects a bump without input evidence, a skipped revision and another profile", () => {
expect(profileFromProbe(profile, probe(null, 2))).toBeNull();
expect(profileFromProbe(profile, probe(1, 3))).toBeNull();
expect(
profileFromProbe(profile, {
...probe(),
profile_id: "another-profile",
}),
).toBeNull();
});
it("keeps unavailable credentials from becoming ready", () => {
const receipt = probe();
const tested = profileFromProbe(
{ ...profile, credential_state: "unavailable" },
receipt,
);
expect(tested).not.toBeNull();
expect(modelProfileReady(tested as ModelProfile, receipt)).toBe(false);
});
@@ -0,0 +1,48 @@
import type { ModelProbeResult, ModelProfile } from "@one-memory/contracts";
export function profileFromProbe(
profile: ModelProfile,
probe: ModelProbeResult | null | undefined,
): ModelProfile | null {
if (!probe || probe.profile_id !== profile.profile_id) return null;
const inputRevision = probe.input_profile_revision;
if (!Number.isSafeInteger(probe.profile_revision)) return null;
if (inputRevision === undefined || inputRevision === null) {
if (probe.profile_revision !== profile.revision) return null;
} else {
if (
!Number.isSafeInteger(inputRevision) ||
(probe.profile_revision !== inputRevision &&
probe.profile_revision !== inputRevision + 1) ||
(profile.revision !== inputRevision &&
profile.revision !== probe.profile_revision)
)
return null;
}
return {
...profile,
revision: probe.profile_revision,
capabilities: probe.capabilities,
};
}
export function modelProfileReady(
profile: ModelProfile,
probe: ModelProbeResult | null = null,
error: string | null = null,
): boolean {
return (
!error &&
(!probe ||
(probe.profile_id === profile.profile_id &&
probe.profile_revision === profile.revision &&
probe.capabilities.endpoint_reachable &&
probe.capabilities.model_available &&
probe.capabilities.embedding_available)) &&
profile.credential_state !== "unavailable" &&
!(profile.has_configured_api_key && !profile.has_api_key) &&
profile.capabilities.endpoint_reachable &&
profile.capabilities.model_available &&
profile.capabilities.embedding_available
);
}
Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff. Show more