Compare commits
66
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af35f0484f | ||
|
|
9737431cf8 | ||
|
|
a8156dd410 | ||
|
|
30f796ddc4 | ||
|
|
d2f1acc84a | ||
|
|
2e22399e2e | ||
|
|
fdb62be235 | ||
|
|
4c2844531d | ||
|
|
d23ebf9bd8 | ||
|
|
7a80287f4b | ||
|
|
9004977163 | ||
|
|
7f0c8198d9 | ||
|
|
25349b4cab | ||
|
|
5a42ce5861 | ||
|
|
b7731ac834 | ||
|
|
9560327ee2 | ||
|
|
f445ad69c4 | ||
|
|
45dbf46545 | ||
|
|
fac7a43ffb | ||
|
|
6e4b22a363 | ||
|
|
c186e5b2fb | ||
|
|
ad84e5decd | ||
|
|
0c43973fc2 | ||
|
|
ebf16b9399 | ||
|
|
87d70b0eb9 | ||
|
|
603e0843c5 | ||
|
|
80f4231e46 | ||
|
|
2f9e028c50 | ||
|
|
4cbf4f1501 | ||
|
|
e5959578c8 | ||
|
|
df5a65797c | ||
|
|
2ea48e68a6 | ||
|
|
b8fdde7d0b | ||
|
|
5134524743 | ||
|
|
e34101cb0b | ||
|
|
995c7e6c32 | ||
|
|
f1afdb03cd | ||
|
|
4df4ce72bd | ||
|
|
a6d9894975 | ||
|
|
218bcf779b | ||
|
|
7e4ee0d8ea | ||
|
|
bce58e638d | ||
|
|
b0e429b9ae | ||
|
|
01faf28942 | ||
|
|
fb1b1373ab | ||
|
|
2823bd743d | ||
|
|
837c084be2 | ||
|
|
bf52724461 | ||
|
|
964d8610f3 | ||
|
|
e1933d3307 | ||
|
|
5fdaf6c280 | ||
|
|
58f2fa884a | ||
|
|
c0910621c1 | ||
|
|
dd13c551c4 | ||
|
|
e7d82ee5e8 | ||
|
|
63e009bf6c | ||
|
|
d4cfd1bb74 | ||
|
|
2822cbf368 | ||
|
|
8d23347e1a | ||
|
|
8ae0d1889e | ||
|
|
2479de22eb | ||
|
|
265a22d612 | ||
|
|
38ad2292d1 | ||
|
|
413b9033f9 | ||
|
|
0953c5af35 | ||
|
|
470ea4459a |
No files matched your search
+83
-9
@@ -4,13 +4,35 @@ 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.
|
||||
- MemHub host capture hooks no longer write the database. `one-memory
|
||||
capture <host>` used to open the main SQLite file from its own short-lived
|
||||
process on every hook, re-create the retired `evidence` / FTS tables on an
|
||||
SDK-authority home and write the write queue outside Core's single writer,
|
||||
contending for the file lock with Core and with other hosts' hooks. It now
|
||||
reads only the runtime receipt and token and posts the hook's JSON to the
|
||||
running Core (`POST /api/v1/live-capture/{source}`, 1.5 s budget). Core
|
||||
applies the same gates (grant, sync switch, rejection counting, automated
|
||||
sessions, the Cursor/Claude Code rule; the migration fence stays on the hook
|
||||
side) and stages the turn in-process through the live file-sync path. When
|
||||
Core is not running the turn is dropped silently; nothing falls back to a
|
||||
local write.
|
||||
- MemHub no longer sends one turn to the SDK twice when a host's hook and live
|
||||
file sync both see it. The two lanes gave the same message different
|
||||
identities (the hook carries no workspace and often no message id) and
|
||||
separate assembly rows, so both were written as raws. Hook input for a
|
||||
source that live sync reads from its own files (every hooked host except
|
||||
Qwen Work, while live sync is on) is now left to the file path; only a
|
||||
source without file polling is staged from its hook. Tables an older hook
|
||||
re-created on an SDK home are dropped by the existing startup cleanup.
|
||||
|
||||
## Hy Memory 2.0.14 — 2026-11-04
|
||||
|
||||
- `search()` / `async_search()` take an optional `statuses` for explicit
|
||||
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 +103,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 40, on `hy-memory==2.0.13`. Linux x64/arm64 Runtime and macOS arm64
|
||||
Build 46, 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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 40),GitHub 仓库为
|
||||
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.14`。MemHub 源码版本为 `1.0.6`
|
||||
(build 46),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`。
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""The hook side of live capture: hand the turn to the running Core, nothing more.
|
||||
|
||||
A host runs `one-memory capture <source>` as a short-lived process on every
|
||||
hook, often several at once. That process used to open the main database
|
||||
itself and write Evidence and the write queue from outside Core's single
|
||||
writer. It now never opens the database: it reads the hook's JSON, posts it to
|
||||
the running Core over loopback with the runtime token, and exits. Core applies
|
||||
every gate and stages the turn in-process (`live_capture.capture_hook`).
|
||||
|
||||
Anything that goes wrong — Core not running, a stale receipt, a slow answer —
|
||||
drops the turn silently. There is no local fallback write: file-backed sources
|
||||
are read again by live sync from their persisted cursor once Core is back.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import TextIO
|
||||
|
||||
CAPTURE_SOURCES = frozenset(
|
||||
{
|
||||
"workbuddy",
|
||||
"claude-code",
|
||||
"codex",
|
||||
"cursor",
|
||||
"pi",
|
||||
"qwen-code",
|
||||
"qwenwork",
|
||||
"hermes",
|
||||
"deepseek-harness",
|
||||
"opencode",
|
||||
"openclaw",
|
||||
}
|
||||
)
|
||||
SDK_MIGRATION_CAPTURE_FENCE = ".sdk-migration-capture-fence.json"
|
||||
#: Core's in-process intake for a hook's payload; see `live_capture`.
|
||||
CAPTURE_ROUTE = "/api/v1/live-capture/{source_id}"
|
||||
#: Whole request, connect to answer. Pi and OpenCode wait for the hook
|
||||
#: synchronously (3 s, interpreter start included), so this stays well under.
|
||||
HANDOFF_TIMEOUT_SECONDS = 1.5
|
||||
#: A hook carries one turn, or one session's visible text at most.
|
||||
MAX_PAYLOAD_BYTES = 8 * 1024 * 1024
|
||||
|
||||
|
||||
def data_directory() -> Path:
|
||||
from one_memory.config import load_settings
|
||||
|
||||
return load_settings().data_dir
|
||||
|
||||
|
||||
def capture_stdio(source_id: str, stdin: TextIO, output: TextIO) -> int:
|
||||
"""Host hooks must stay silent. A bad payload or an absent Core does not fail the turn."""
|
||||
|
||||
del output
|
||||
if source_id not in CAPTURE_SOURCES:
|
||||
return 2
|
||||
try:
|
||||
data_dir = data_directory()
|
||||
if sdk_migration_capture_fenced(data_dir):
|
||||
return 0
|
||||
payload = json.loads(stdin.read() or "")
|
||||
except Exception:
|
||||
return 0
|
||||
if not isinstance(payload, dict):
|
||||
return 0
|
||||
try:
|
||||
hand_to_core(source_id, payload, data_dir)
|
||||
except Exception:
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
def hand_to_core(
|
||||
source_id: str,
|
||||
payload: dict[str, object],
|
||||
data_dir: Path,
|
||||
*,
|
||||
timeout: float = HANDOFF_TIMEOUT_SECONDS,
|
||||
) -> bool:
|
||||
"""Post the payload to the Core named by this home's receipt. True when Core took it.
|
||||
|
||||
Only the receipt, the runtime token and a loopback socket are touched; the
|
||||
main database is never opened from here.
|
||||
"""
|
||||
from one_memory.runtime_client import core_endpoint
|
||||
|
||||
endpoint = core_endpoint(data_dir)
|
||||
if endpoint is None:
|
||||
return False
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
if len(body) > MAX_PAYLOAD_BYTES:
|
||||
return False
|
||||
from one_memory.http_client import create_client
|
||||
|
||||
with create_client(timeout=timeout) as client:
|
||||
response = client.post(
|
||||
f"http://{endpoint.host}:{endpoint.port}{CAPTURE_ROUTE.format(source_id=source_id)}",
|
||||
content=body,
|
||||
headers={
|
||||
"Authorization": f"Bearer {endpoint.token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
return response.status_code == 202
|
||||
|
||||
|
||||
def sdk_migration_capture_fenced(data_dir: Path) -> bool:
|
||||
"""Ignore stale fences; a live migration or a failed one pauses hook writes.
|
||||
|
||||
A failed upgrade migration leaves the fence `frozen` (no pid): the home was
|
||||
put back as it was before the upgrade and takes no writes until the next
|
||||
migration attempt, which replaces the fence and lifts it on success.
|
||||
"""
|
||||
marker = data_dir / SDK_MIGRATION_CAPTURE_FENCE
|
||||
try:
|
||||
document = json.loads(marker.read_text(encoding="utf-8"))
|
||||
if isinstance(document, dict) and document.get("frozen") is True:
|
||||
return True
|
||||
pid = int(document["pid"])
|
||||
if pid < 1:
|
||||
raise ValueError("invalid migration fence pid")
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except ProcessLookupError:
|
||||
marker.unlink(missing_ok=True)
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
except (KeyError, TypeError, ValueError, json.JSONDecodeError, OSError):
|
||||
marker.unlink(missing_ok=True)
|
||||
return False
|
||||
@@ -119,7 +119,7 @@ def run(arguments: Sequence[str], *, output: TextIO | None = None) -> int:
|
||||
if command == "sdk-migration-status":
|
||||
return sdk_migration_status(options, stream)
|
||||
if command == "capture" and len(options) == 1:
|
||||
from one_memory.live_capture import capture_stdio
|
||||
from one_memory.capture_handoff import capture_stdio
|
||||
|
||||
return capture_stdio(options[0], sys.stdin, stream)
|
||||
if command not in {"status", "doctor"} or options not in ([], ["--json"]):
|
||||
|
||||
@@ -11,14 +11,14 @@ from one_memory.context_models import ContextSearchRequest
|
||||
from one_memory.external_memory_access import ExternalMemoryAccess
|
||||
from one_memory.memory_server.store_binding import store_for
|
||||
|
||||
#: The revoked-source query, per engine.
|
||||
#: The connected-source query, per engine.
|
||||
#:
|
||||
#: Identical text today because it uses no placeholders and reads by position,
|
||||
#: which both engines support. Kept as a named constant so that adding a
|
||||
#: parameter later forces the `?`/`%s` difference to be considered here rather
|
||||
#: than being introduced silently at the call site.
|
||||
REVOKED_SOURCES_SQL = (
|
||||
"SELECT source_id FROM source_grants WHERE user_id='local' AND revoked_at IS NOT NULL"
|
||||
CONNECTED_SOURCES_SQL = (
|
||||
"SELECT DISTINCT source_id FROM source_grants WHERE user_id='local' AND revoked_at IS NULL"
|
||||
)
|
||||
|
||||
|
||||
@@ -49,16 +49,10 @@ def resolve_context_authority(
|
||||
scope for scope in access.scopes if scope == "all-apps" or scope.startswith("workspace:v1:")
|
||||
)
|
||||
memory_allowed = memory_requested and "om_recall" in access.tools and bool(memory_scopes)
|
||||
declared_sources = frozenset(
|
||||
scope.removeprefix("source:") for scope in access.scopes if scope.startswith("source:")
|
||||
)
|
||||
raw_allowed = (
|
||||
raw_requested
|
||||
and "om_conversations" in access.tools
|
||||
and "all-apps" in access.scopes
|
||||
and bool(declared_sources)
|
||||
raw_requested and "om_conversations" in access.tools and "all-apps" in access.scopes
|
||||
)
|
||||
if explicit is not None and (not raw_allowed or not explicit <= declared_sources):
|
||||
if explicit is not None and not raw_allowed:
|
||||
raise HTTPException(403, "external_context_scope_denied")
|
||||
|
||||
raw_sources: frozenset[str] = frozenset()
|
||||
@@ -70,10 +64,10 @@ def resolve_context_authority(
|
||||
# the same code runs on both engines without a branch per engine.
|
||||
if hasattr(db, "set_progress_handler"):
|
||||
db.set_progress_handler(lambda: int(time.monotonic() >= deadline), 1000)
|
||||
revoked = frozenset(str(row[0]) for row in db.execute(REVOKED_SOURCES_SQL))
|
||||
connected = frozenset(str(row[0]) for row in db.execute(CONNECTED_SOURCES_SQL))
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError
|
||||
raw_sources = declared_sources - revoked
|
||||
raw_sources = connected
|
||||
if explicit is not None:
|
||||
if not explicit <= raw_sources:
|
||||
raise HTTPException(403, "external_context_scope_denied")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Authenticated SDK context search route, without generation or reindex side effects."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
from datetime import UTC, datetime
|
||||
@@ -30,11 +31,14 @@ 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
|
||||
|
||||
CALL_EVENT_RESPONSE_BUDGET_MS = 250
|
||||
SDK_CONTEXT_CONTENT_LIMIT = 20_000
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Pass-through filters the raw lane honours. Scene, layers, reader and the
|
||||
#: schema/working floors describe memory lanes; the raw lane is always the
|
||||
@@ -70,6 +74,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:
|
||||
@@ -77,6 +93,13 @@ def hit_namespace(hit: SemanticMemoryHit) -> dict[str, str]:
|
||||
return {}
|
||||
|
||||
|
||||
def bounded_context_content(content: str) -> tuple[str, bool]:
|
||||
"""Fit SDK text into its wire contract before Pydantic validates it."""
|
||||
if len(content) <= SDK_CONTEXT_CONTENT_LIMIT:
|
||||
return content, False
|
||||
return content[:SDK_CONTEXT_CONTENT_LIMIT], True
|
||||
|
||||
|
||||
def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
"""SDK-native context: no Evidence/Governance ids or fabricated revisions."""
|
||||
router = APIRouter(prefix="/api/v2/context", dependencies=dependencies)
|
||||
@@ -120,6 +143,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 +161,65 @@ 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:
|
||||
content, truncated = bounded_context_content(hit.content)
|
||||
primary.append(
|
||||
SdkRawContextItem(
|
||||
raw_id=hit.raw_id,
|
||||
content=content,
|
||||
source=hit.source,
|
||||
session_id=hit.session_id,
|
||||
memory_at=memory_at,
|
||||
score=hit.score,
|
||||
truncated=truncated,
|
||||
)
|
||||
)
|
||||
continue
|
||||
# The embedding is the batch's: the hit is shown by the turns
|
||||
# that contain the query, or else by its closest turn, each
|
||||
# with the hit's score.
|
||||
shown: list[SdkRawContextItem] = []
|
||||
for turn in turns_for_hit(turns, value.query):
|
||||
content, truncated = bounded_context_content(turn.content)
|
||||
shown.append(
|
||||
SdkRawContextItem(
|
||||
raw_id=hit.raw_id,
|
||||
turn_id=turn.turn_id,
|
||||
turn_index=turn.turn_index,
|
||||
turn_count=turn.turn_count,
|
||||
content=content,
|
||||
source=hit.source,
|
||||
session_id=hit.session_id,
|
||||
memory_at=memory_at,
|
||||
score=hit.score,
|
||||
truncated=truncated,
|
||||
)
|
||||
)
|
||||
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
|
||||
@@ -201,16 +265,15 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
or not owns(memory.user_id, value.user_id)
|
||||
):
|
||||
continue
|
||||
content, truncated = bounded_context_content(
|
||||
hit.formatted_content
|
||||
if hit.sdk_formatted and hit.formatting_complete and hit.formatted_content
|
||||
else memory.content
|
||||
)
|
||||
items.append(
|
||||
SdkMemoryContextItem(
|
||||
memory_id=memory.memory_id,
|
||||
content=(
|
||||
hit.formatted_content
|
||||
if hit.sdk_formatted
|
||||
and hit.formatting_complete
|
||||
and hit.formatted_content
|
||||
else memory.content
|
||||
),
|
||||
content=content,
|
||||
layer=memory.layer,
|
||||
status=memory.status,
|
||||
source=memory.source,
|
||||
@@ -220,6 +283,7 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
else None
|
||||
),
|
||||
score=hit.score,
|
||||
truncated=truncated,
|
||||
)
|
||||
)
|
||||
return tuple(items)
|
||||
@@ -269,10 +333,45 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
if isinstance(lane, EmbeddingUnavailableError):
|
||||
# No lexical fallback: the query could not be embedded.
|
||||
raise HTTPException(503, EMBEDDING_UNAVAILABLE)
|
||||
partial = isinstance(raw_items, Exception) or isinstance(memory_items, Exception)
|
||||
requested_lanes = {
|
||||
"raw": raw_items,
|
||||
"memory": memory_items,
|
||||
}
|
||||
failed_lanes = {
|
||||
name: lane
|
||||
for name, lane in requested_lanes.items()
|
||||
if name in value.kinds and isinstance(lane, Exception)
|
||||
}
|
||||
for name, error in failed_lanes.items():
|
||||
logger.warning(
|
||||
"sdk_context_lane_failed lane=%s error_class=%s",
|
||||
name,
|
||||
type(error).__name__,
|
||||
)
|
||||
all_requested_failed = len(failed_lanes) == len(value.kinds)
|
||||
partial = bool(failed_lanes)
|
||||
raw_candidates = (
|
||||
() if isinstance(raw_items, Exception) else cast(tuple[SdkContextItem, ...], raw_items)
|
||||
)
|
||||
if access is not None and raw_candidates:
|
||||
try:
|
||||
current = await asyncio.to_thread(
|
||||
resolve_context_authority,
|
||||
value,
|
||||
access,
|
||||
database,
|
||||
deadline=started + value.timeout_ms / 1000,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
raise HTTPException(503, "context_authority_unavailable") from None
|
||||
current_sources = current.raw_sources or frozenset()
|
||||
raw_candidates = tuple(
|
||||
item
|
||||
for item in raw_candidates
|
||||
if item.kind == "raw" and item.source in current_sources
|
||||
)
|
||||
memory_candidates = (
|
||||
()
|
||||
if isinstance(memory_items, Exception)
|
||||
@@ -298,11 +397,20 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
if len(content) > remaining:
|
||||
item = item.model_copy(update={"content": content[:remaining], "truncated": True})
|
||||
truncated = True
|
||||
truncated = truncated or item.truncated
|
||||
selected.append(item)
|
||||
used += len(item.content)
|
||||
final = SdkContextSearchResponse(
|
||||
request_id=str(uuid4()),
|
||||
state=("partial" if partial else "grounded" if selected else "empty"),
|
||||
state=(
|
||||
"unavailable"
|
||||
if all_requested_failed
|
||||
else "partial"
|
||||
if partial
|
||||
else "grounded"
|
||||
if selected
|
||||
else "empty"
|
||||
),
|
||||
items=tuple(selected),
|
||||
used_characters=used,
|
||||
truncated=truncated,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -376,8 +376,9 @@ def apply_sdk_language(config: MemoryConfig, language: str | None) -> MemoryConf
|
||||
"""Set the SDK prompt language (`MemoryConfig.language`), when there is one.
|
||||
|
||||
None (no language set) sets nothing: the SDK detects zh/en as it always
|
||||
did. Every display language, zh-Hans included, is set explicitly. Like a feature, the language never touches `vector_store`, so
|
||||
the collection identity never depends on it.
|
||||
did. Every display language, zh-Hans included, is set explicitly. Like a
|
||||
feature, the language never touches `vector_store`, so the collection
|
||||
identity never depends on it.
|
||||
"""
|
||||
if language is not None:
|
||||
config.language = language
|
||||
|
||||
@@ -99,9 +99,7 @@ class HySdkMemoryRecord(BaseModel):
|
||||
created_at = gmt_created
|
||||
updated_at = _epoch(raw.get("updated_at"))
|
||||
modified_at = _epoch(raw.get("gmt_modified"))
|
||||
sort_updated_at = (
|
||||
modified_at if modified_at is not None else updated_at
|
||||
)
|
||||
sort_updated_at = modified_at if modified_at is not None else updated_at
|
||||
return cls(
|
||||
memory_id=str(raw["memory_id"]),
|
||||
content=str(raw["content"]),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import hashlib
|
||||
import os
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from collections.abc import AsyncIterator, Callable, Iterator
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
@@ -28,6 +28,7 @@ def discovered_off_loop(
|
||||
suffixes: frozenset[str],
|
||||
max_targets: int,
|
||||
excluded_directory_names: frozenset[str] = frozenset(),
|
||||
directory_predicate: Callable[[Path], bool] | None = None,
|
||||
) -> AsyncIterator[SourceFileDiscoveryEvent]:
|
||||
"""The same walk, for callers that have an event loop to keep free.
|
||||
|
||||
@@ -43,6 +44,7 @@ def discovered_off_loop(
|
||||
suffixes=suffixes,
|
||||
max_targets=max_targets,
|
||||
excluded_directory_names=excluded_directory_names,
|
||||
directory_predicate=directory_predicate,
|
||||
),
|
||||
scope_key=grant.source_id,
|
||||
)
|
||||
@@ -54,6 +56,7 @@ def discover_source_files(
|
||||
suffixes: frozenset[str],
|
||||
max_targets: int,
|
||||
excluded_directory_names: frozenset[str] = frozenset(),
|
||||
directory_predicate: Callable[[Path], bool] | None = None,
|
||||
) -> Iterator[SourceFileDiscoveryEvent]:
|
||||
"""Metadata only: one lstat per candidate, no open and no per-file grant lookup.
|
||||
|
||||
@@ -71,10 +74,16 @@ def discover_source_files(
|
||||
grant.root,
|
||||
followlinks=False,
|
||||
):
|
||||
current = Path(directory)
|
||||
child_directories[:] = sorted(
|
||||
child
|
||||
for child in child_directories
|
||||
if not (Path(directory) / child).is_symlink() and child not in excluded_directory_names
|
||||
if not (current / child).is_symlink()
|
||||
and child not in excluded_directory_names
|
||||
and (
|
||||
directory_predicate is None
|
||||
or directory_predicate((current / child).relative_to(grant.root))
|
||||
)
|
||||
)
|
||||
for filename in sorted(filenames):
|
||||
if Path(filename).suffix.lower() not in suffixes:
|
||||
|
||||
@@ -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,15 @@ class CursorUnifiedSourceAdapter:
|
||||
state = await self.install_state()
|
||||
return min(state.session_count, limit) if limit is not None else state.session_count
|
||||
|
||||
async def has_changes(self, options: ScanOptions) -> bool:
|
||||
listing = await asyncio.to_thread(self._fresh_listing, options)
|
||||
self._probe_listing_cache = (
|
||||
dict(options.cursors),
|
||||
options.max_scan_targets,
|
||||
listing,
|
||||
)
|
||||
return bool(listing.transcripts or listing.stores or listing.legacy or listing.failures)
|
||||
|
||||
async def live_baseline(self) -> dict[str, SourceCursor]:
|
||||
"""Capture Cursor's current tail so the realtime lane never backfills history."""
|
||||
listing = await asyncio.to_thread(
|
||||
@@ -222,9 +235,11 @@ class CursorUnifiedSourceAdapter:
|
||||
async for event in self._scan_frozen(options):
|
||||
yield event
|
||||
return
|
||||
listing = await asyncio.to_thread(
|
||||
self._history_listing if options.reuse_listing else self._fresh_listing,
|
||||
options,
|
||||
listing = (
|
||||
await asyncio.to_thread(self._history_listing, options)
|
||||
if options.reuse_listing
|
||||
else self._take_probe_listing(options)
|
||||
or await asyncio.to_thread(self._fresh_listing, options)
|
||||
)
|
||||
for failure in listing.failures:
|
||||
yield ScanFailure(reason_code=discovery_failure_code(failure))
|
||||
@@ -329,6 +344,16 @@ class CursorUnifiedSourceAdapter:
|
||||
self._history_listing_cache = listing
|
||||
return listing
|
||||
|
||||
def _take_probe_listing(self, options: ScanOptions) -> "_CursorListing | None":
|
||||
cached = self._probe_listing_cache
|
||||
self._probe_listing_cache = None
|
||||
if cached is None:
|
||||
return None
|
||||
cursors, max_scan_targets, listing = cached
|
||||
if cursors != options.cursors or max_scan_targets != options.max_scan_targets:
|
||||
return None
|
||||
return listing
|
||||
|
||||
def _fresh_listing(self, options: ScanOptions) -> "_CursorListing":
|
||||
return _list_cursor_files(
|
||||
self.grant,
|
||||
@@ -376,6 +401,7 @@ class CursorUnifiedSourceAdapter:
|
||||
self.grant,
|
||||
suffixes=frozenset({".db"}),
|
||||
max_targets=options.max_scan_targets or 100_000,
|
||||
directory_predicate=_cursor_data_directory,
|
||||
):
|
||||
if isinstance(discovered, SourceFileDiscoveryFailure):
|
||||
yield ScanFailure(reason_code=discovery_failure_code(discovered))
|
||||
@@ -477,7 +503,10 @@ def _list_cursor_files(
|
||||
legacy: list[AuthorizedSourceFile] = []
|
||||
failures: list[SourceFileDiscoveryFailure] = []
|
||||
for item in discover_source_files(
|
||||
grant, suffixes=frozenset({".jsonl", ".db", ".vscdb"}), max_targets=max_targets
|
||||
grant,
|
||||
suffixes=frozenset({".jsonl", ".db", ".vscdb"}),
|
||||
max_targets=max_targets,
|
||||
directory_predicate=_cursor_data_directory,
|
||||
):
|
||||
if isinstance(item, SourceFileDiscoveryFailure):
|
||||
failures.append(item)
|
||||
@@ -586,6 +615,25 @@ def _is_cursor_data_file(source_file: AuthorizedSourceFile) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _cursor_data_directory(relative_path: Path) -> bool:
|
||||
"""Enter only Cursor directories that can contain conversation authority.
|
||||
|
||||
The granted root also contains extensions, skills, caches, logs and agent
|
||||
artifacts. Recursively walking all of them every live-sync poll made the
|
||||
reader monopolize Core even though none can pass the file predicates.
|
||||
"""
|
||||
parts = relative_path.parts
|
||||
if not parts:
|
||||
return False
|
||||
if parts[0] in {"chats", "acp-sessions"}:
|
||||
return True
|
||||
if parts[0] == "projects":
|
||||
return len(parts) <= 2 or parts[2] == "agent-transcripts"
|
||||
if parts[0] == "User":
|
||||
return len(parts) == 1 or parts[1] in {"globalStorage", "workspaceStorage"}
|
||||
return False
|
||||
|
||||
|
||||
def _is_transcript_file(source_file: AuthorizedSourceFile) -> bool:
|
||||
parts = source_file.relative_path.parts
|
||||
return source_file.relative_path.suffix == ".jsonl" and "agent-transcripts" in parts
|
||||
@@ -634,6 +682,7 @@ def _select_transcript_files(
|
||||
grant,
|
||||
suffixes=frozenset({".jsonl"}),
|
||||
max_targets=100_000,
|
||||
directory_predicate=_cursor_data_directory,
|
||||
):
|
||||
if not isinstance(item, AuthorizedSourceFile) or not _is_transcript_file(item):
|
||||
continue
|
||||
@@ -654,6 +703,7 @@ def _subagent_conversation_ids(grant: AuthorizedSourceRoot) -> set[str]:
|
||||
grant,
|
||||
suffixes=frozenset({".jsonl"}),
|
||||
max_targets=100_000,
|
||||
directory_predicate=_cursor_data_directory,
|
||||
)
|
||||
if isinstance(item, AuthorizedSourceFile)
|
||||
and _is_transcript_file(item)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -15,9 +15,7 @@ class HyLifecycle:
|
||||
object.__setattr__(self, "hidden", MappingProxyType(dict(self.hidden)))
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
return isinstance(other, HyLifecycle) and dict(self.hidden) == dict(
|
||||
other.hidden
|
||||
)
|
||||
return isinstance(other, HyLifecycle) and dict(self.hidden) == dict(other.hidden)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(tuple(sorted(self.hidden.items())))
|
||||
@@ -27,9 +25,5 @@ class HyLifecycle:
|
||||
|
||||
def replacing(self, memory_id: str) -> tuple[str, ...]:
|
||||
return tuple(
|
||||
sorted(
|
||||
old
|
||||
for old, replacements in self.hidden.items()
|
||||
if memory_id in replacements
|
||||
)
|
||||
sorted(old for old, replacements in self.hidden.items() if memory_id in replacements)
|
||||
)
|
||||
@@ -1,15 +1,35 @@
|
||||
"""Write visible hook text into Evidence. Tool output stays out."""
|
||||
"""Core's intake for host hooks: visible text only, staged under Core's writer.
|
||||
|
||||
The hook process (`capture_handoff`) never opens the database; it posts the
|
||||
hook's JSON to `POST /api/v1/live-capture/{source}` on the running Core, which
|
||||
calls `capture_hook` here. Tool output stays out.
|
||||
|
||||
A source that live sync already reads from its own files is left to that
|
||||
path: the hook and the file name the same message differently (the hook has
|
||||
no workspace and often no message id), so staging both sent one turn to the
|
||||
SDK twice. Only a source no file poller follows is staged from the hook.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
from typing import TextIO, cast
|
||||
from typing import cast
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.params import Depends
|
||||
from pydantic import JsonValue
|
||||
|
||||
from one_memory.capture_handoff import (
|
||||
CAPTURE_ROUTE,
|
||||
CAPTURE_SOURCES,
|
||||
MAX_PAYLOAD_BYTES,
|
||||
SDK_MIGRATION_CAPTURE_FENCE,
|
||||
sdk_migration_capture_fenced,
|
||||
)
|
||||
from one_memory.database_access import main_database
|
||||
from one_memory.derived_echo import PREFACE, strip_injected_memory
|
||||
from one_memory.imports.access import opaque_workspace_id, safe_project_label
|
||||
@@ -19,6 +39,7 @@ from one_memory.imports.models import (
|
||||
EvidenceRecord,
|
||||
OriginMetadata,
|
||||
WrittenBy,
|
||||
materialize_write_input,
|
||||
)
|
||||
from one_memory.imports.sources.claude_code import parse_claude_code_record
|
||||
from one_memory.imports.sources.codex import HARNESS_PREFIXES, parse_codex_record
|
||||
@@ -38,23 +59,21 @@ from one_memory.imports.sources.parsing import (
|
||||
)
|
||||
from one_memory.imports.sources.pi import _is_automated_session
|
||||
from one_memory.imports.sources.workbuddy import _is_scheduled_automation, parse_workbuddy_record
|
||||
from one_memory.imports.storage import EvidenceStore
|
||||
from one_memory.memory_server.durable_queue import DurableWriteQueue
|
||||
from one_memory.sdk_data_plane_migration import MemoryAuthority
|
||||
|
||||
__all__ = [
|
||||
"CAPTURE_SOURCES",
|
||||
"SDK_MIGRATION_CAPTURE_FENCE",
|
||||
"CaptureOutcome",
|
||||
"CaptureRejection",
|
||||
"capture_hook",
|
||||
"capture_rejection",
|
||||
"live_capture_router",
|
||||
"record_capture_rejection",
|
||||
"sdk_migration_capture_fenced",
|
||||
]
|
||||
|
||||
CAPTURE_SOURCES = frozenset(
|
||||
{
|
||||
"workbuddy",
|
||||
"claude-code",
|
||||
"codex",
|
||||
"cursor",
|
||||
"pi",
|
||||
"qwen-code",
|
||||
"qwenwork",
|
||||
"hermes",
|
||||
"deepseek-harness",
|
||||
"opencode",
|
||||
"openclaw",
|
||||
}
|
||||
)
|
||||
_UNKNOWN_USER = frozenset({"hermes", "opencode", "openclaw"})
|
||||
_USER_FIELDS = {
|
||||
"UserPromptSubmit": "prompt",
|
||||
@@ -75,112 +94,130 @@ _FILE_FALLBACK = {
|
||||
}
|
||||
_FALLBACK_EVENTS = frozenset({"Stop", "stop", "SubagentStop", "StopFailure"})
|
||||
_TEXT_BLOCKS = frozenset({"text"})
|
||||
SDK_MIGRATION_CAPTURE_FENCE = ".sdk-migration-capture-fence.json"
|
||||
|
||||
|
||||
def database_path() -> Path:
|
||||
from one_memory.config import load_settings
|
||||
class CaptureOutcome(StrEnum):
|
||||
"""What Core did with one hook payload. Never the payload itself."""
|
||||
|
||||
return load_settings().data_dir / "one-memory.sqlite3"
|
||||
STAGED = "staged"
|
||||
#: Live sync reads this source's own files; that path owns the turn.
|
||||
FILE_POLLED = "file_polled"
|
||||
REJECTED = "rejected"
|
||||
IGNORED = "ignored"
|
||||
|
||||
|
||||
def capture_stdio(source_id: str, stdin: TextIO, output: TextIO) -> int:
|
||||
"""Host hooks must stay silent. A bad payload does not fail the turn."""
|
||||
def capture_hook(
|
||||
source_id: str,
|
||||
payload: object,
|
||||
database: Path,
|
||||
*,
|
||||
queue: DurableWriteQueue,
|
||||
file_polled: Callable[[str], bool] = lambda _source_id: False,
|
||||
) -> CaptureOutcome:
|
||||
"""Gate one hook payload and stage its visible lines, inside Core.
|
||||
|
||||
del output
|
||||
if source_id not in CAPTURE_SOURCES:
|
||||
return 2
|
||||
database = database_path()
|
||||
if sdk_migration_capture_fenced(database.parent):
|
||||
return 0
|
||||
try:
|
||||
payload = json.loads(stdin.read() or "")
|
||||
except json.JSONDecodeError:
|
||||
return 0
|
||||
try:
|
||||
capture_hook(source_id, payload, database)
|
||||
except Exception:
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
def sdk_migration_capture_fenced(data_dir: Path) -> bool:
|
||||
"""Ignore stale fences; a live migration or a failed one pauses hook writes.
|
||||
|
||||
A failed upgrade migration leaves the fence `frozen` (no pid): the home was
|
||||
put back as it was before the upgrade and takes no writes until the next
|
||||
migration attempt, which replaces the fence and lifts it on success.
|
||||
Runs on SDK authority through Core's single writer: the lines become
|
||||
write-job input exactly as live file sync stages them
|
||||
(`materialize_write_input` -> `stage_sdk_input` -> `finalize_sdk_file`,
|
||||
live priority). No Evidence table is involved.
|
||||
"""
|
||||
marker = data_dir / SDK_MIGRATION_CAPTURE_FENCE
|
||||
try:
|
||||
document = json.loads(marker.read_text(encoding="utf-8"))
|
||||
if isinstance(document, dict) and document.get("frozen") is True:
|
||||
return True
|
||||
pid = int(document["pid"])
|
||||
if pid < 1:
|
||||
raise ValueError("invalid migration fence pid")
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except ProcessLookupError:
|
||||
marker.unlink(missing_ok=True)
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
except (KeyError, TypeError, ValueError, json.JSONDecodeError, OSError):
|
||||
marker.unlink(missing_ok=True)
|
||||
return False
|
||||
|
||||
|
||||
def capture_hook(source_id: str, payload: object, database: Path) -> bool:
|
||||
if source_id not in CAPTURE_SOURCES or not isinstance(payload, dict):
|
||||
return False
|
||||
return CaptureOutcome.IGNORED
|
||||
# Cursor also runs ~/.claude/settings.json hooks; its own hook already files the turn.
|
||||
if source_id != "cursor" and "cursor_version" in payload:
|
||||
return False
|
||||
return CaptureOutcome.IGNORED
|
||||
if not database.is_file():
|
||||
return False
|
||||
return CaptureOutcome.IGNORED
|
||||
rejection = capture_rejection(database, source_id)
|
||||
if rejection is not None:
|
||||
# Silent, and the reason is counted rather than printed: a hook that
|
||||
# spoke would put this into the host's turn as text.
|
||||
record_capture_rejection(database, source_id, rejection)
|
||||
return False
|
||||
return CaptureOutcome.REJECTED
|
||||
if source_id == "pi" and _is_automated_session(first_string(payload.get("cwd"))):
|
||||
return False
|
||||
lines = _messages(source_id, payload)
|
||||
if lines:
|
||||
conversation_id = _conversation(payload)
|
||||
store = EvidenceStore(database)
|
||||
wrote = False
|
||||
for role, written_by, verbatim, message_id in lines:
|
||||
target = conversation_id
|
||||
if target is None:
|
||||
continue
|
||||
result = store.put(
|
||||
EvidenceInput(
|
||||
source_id=source_id,
|
||||
conversation_id=target,
|
||||
message_id=message_id,
|
||||
source_ordinal=None if message_id else "hook-visible",
|
||||
role=role,
|
||||
written_by=written_by,
|
||||
created_at=datetime.now(UTC),
|
||||
verbatim=verbatim,
|
||||
origin_meta={EvidenceOrigin.HOOK: OriginMetadata()},
|
||||
)
|
||||
)
|
||||
_submit_live_memory(
|
||||
store, result.record, changed=result.was_created or result.was_revised
|
||||
)
|
||||
wrote = True
|
||||
return wrote
|
||||
return _capture_transcript(source_id, payload, database)
|
||||
return CaptureOutcome.IGNORED
|
||||
if file_polled(source_id):
|
||||
# The same turn reaches the SDK once, from the file: the two lanes give
|
||||
# one message different identities, so staging both wrote it twice.
|
||||
return CaptureOutcome.FILE_POLLED
|
||||
inputs = _hook_inputs(source_id, payload) or _capture_transcript(source_id, payload, database)
|
||||
if not inputs:
|
||||
return CaptureOutcome.IGNORED
|
||||
observed = datetime.now(UTC)
|
||||
conversations: dict[str, list[EvidenceRecord]] = {}
|
||||
for value in inputs:
|
||||
conversations.setdefault(value.conversation_id, []).append(
|
||||
materialize_write_input(value, observed_at=observed)
|
||||
)
|
||||
for conversation_id, records in conversations.items():
|
||||
# The turn rule decides what is written (`turn_batches`): a turn is
|
||||
# complete when the next user message arrives, batches are 5 turns or
|
||||
# 8000 characters, and Core writes the rest after 10 quiet minutes.
|
||||
queue.assemble_live_records(tuple(records), file_key=f"hook:{conversation_id}")
|
||||
return CaptureOutcome.STAGED
|
||||
|
||||
|
||||
def _sync_open(database: Path, source_id: str) -> bool:
|
||||
return capture_rejection(database, source_id) is None
|
||||
def _hook_inputs(source_id: str, payload: dict[str, object]) -> list[EvidenceInput]:
|
||||
conversation_id = _conversation(payload)
|
||||
if conversation_id is None:
|
||||
return []
|
||||
observed = datetime.now(UTC)
|
||||
return [
|
||||
EvidenceInput(
|
||||
source_id=source_id,
|
||||
conversation_id=conversation_id,
|
||||
message_id=message_id,
|
||||
source_ordinal=None if message_id else "hook-visible",
|
||||
role=role,
|
||||
written_by=written_by,
|
||||
created_at=observed,
|
||||
verbatim=verbatim,
|
||||
origin_meta={EvidenceOrigin.HOOK: OriginMetadata()},
|
||||
)
|
||||
for role, written_by, verbatim, message_id in _messages(source_id, payload)
|
||||
]
|
||||
|
||||
|
||||
def live_capture_router(
|
||||
dependencies: list[Depends],
|
||||
*,
|
||||
file_polled: Callable[[Request, str], bool],
|
||||
) -> APIRouter:
|
||||
"""`POST /api/v1/live-capture/{source_id}`: a hook's payload, handed over by the hook.
|
||||
|
||||
202 whatever the gates decide, so a hook has nothing to print. The answer
|
||||
names the outcome only, never any of the payload.
|
||||
"""
|
||||
router = APIRouter(dependencies=dependencies)
|
||||
|
||||
@router.post(CAPTURE_ROUTE, status_code=202)
|
||||
async def live_capture(source_id: str, request: Request) -> dict[str, str]:
|
||||
if source_id not in CAPTURE_SOURCES:
|
||||
raise HTTPException(status_code=404, detail="capture_source_not_found")
|
||||
body = await request.body()
|
||||
if len(body) > MAX_PAYLOAD_BYTES:
|
||||
raise HTTPException(status_code=413, detail="capture_payload_too_large")
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except (json.JSONDecodeError, UnicodeError) as error:
|
||||
raise HTTPException(status_code=422, detail="capture_payload_invalid") from error
|
||||
state = request.app.state
|
||||
queue = getattr(state, "memory_write_queue", None)
|
||||
if getattr(state, "memory_authority", None) is not MemoryAuthority.SDK or queue is None:
|
||||
# Only an SDK-authority home is served; nothing here may bring the
|
||||
# retired Evidence tables back for any other.
|
||||
return {"outcome": CaptureOutcome.IGNORED.value}
|
||||
outcome = await asyncio.to_thread(
|
||||
capture_hook,
|
||||
source_id,
|
||||
payload,
|
||||
queue.database_path,
|
||||
queue=queue,
|
||||
file_polled=lambda source: file_polled(request, source),
|
||||
)
|
||||
return {"outcome": outcome.value}
|
||||
|
||||
return router
|
||||
|
||||
|
||||
class CaptureRejection(StrEnum):
|
||||
@@ -255,22 +292,6 @@ def record_capture_rejection(
|
||||
return
|
||||
|
||||
|
||||
def _submit_live_memory(store: EvidenceStore, record: EvidenceRecord, *, changed: bool) -> None:
|
||||
"""Add the record to its conversation's live assembling row.
|
||||
|
||||
The turn rule decides what is written (`turn_batches`): a turn is
|
||||
complete when the next user message arrives, batches are 5 turns or
|
||||
8000 characters, and Core writes the rest after 10 quiet minutes.
|
||||
"""
|
||||
if not changed or not _sync_open(store.database_path, record.source_id):
|
||||
return
|
||||
from one_memory.memory_server.durable_queue import DurableWriteQueue
|
||||
|
||||
DurableWriteQueue(store.database_path, evidence=store).assemble_live_records(
|
||||
(record,), file_key=f"hook:{record.conversation_id}"
|
||||
)
|
||||
|
||||
|
||||
def _conversation(payload: dict[str, object]) -> str | None:
|
||||
return first_string(
|
||||
cast(JsonValue | None, payload.get("session_id")),
|
||||
@@ -470,34 +491,35 @@ def _opencode_messages(
|
||||
return lines
|
||||
|
||||
|
||||
def _capture_transcript(source_id: str, payload: dict[str, object], database: Path) -> bool:
|
||||
def _capture_transcript(
|
||||
source_id: str, payload: dict[str, object], database: Path
|
||||
) -> list[EvidenceInput]:
|
||||
"""Stop without in-memory text reads the granted transcript, then the same parser."""
|
||||
|
||||
event = payload.get("hook_event_name") or payload.get("hookEventName")
|
||||
parser = _FILE_FALLBACK.get(source_id)
|
||||
transcript = payload.get("transcript_path")
|
||||
if event not in _FALLBACK_EVENTS or parser is None or not isinstance(transcript, str):
|
||||
return False
|
||||
return []
|
||||
root = _grant_root(database, source_id)
|
||||
if root is None:
|
||||
return False
|
||||
return []
|
||||
try:
|
||||
granted = root.resolve()
|
||||
target = Path(transcript).resolve()
|
||||
except OSError:
|
||||
return False
|
||||
return []
|
||||
if target != granted and granted not in target.parents:
|
||||
return False
|
||||
return []
|
||||
if not target.is_file():
|
||||
return False
|
||||
return []
|
||||
_wait_stable(target)
|
||||
try:
|
||||
raw = target.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return False
|
||||
return []
|
||||
relative = target.relative_to(granted).as_posix()
|
||||
store = EvidenceStore(database)
|
||||
wrote = False
|
||||
inputs: list[EvidenceInput] = []
|
||||
for index, line in enumerate(raw.splitlines()):
|
||||
if not line.strip():
|
||||
continue
|
||||
@@ -514,7 +536,7 @@ def _capture_transcript(source_id: str, payload: dict[str, object], database: Pa
|
||||
if not isinstance(parsed, ParsedMessage):
|
||||
continue
|
||||
for row_index, row in enumerate(parsed.rows()):
|
||||
result = store.put(
|
||||
inputs.append(
|
||||
EvidenceInput(
|
||||
source_id=source_id,
|
||||
workspace=opaque_workspace_id(source_id, row.workspace),
|
||||
@@ -533,11 +555,7 @@ def _capture_transcript(source_id: str, payload: dict[str, object], database: Pa
|
||||
origin_meta={EvidenceOrigin.FILE: OriginMetadata()},
|
||||
)
|
||||
)
|
||||
_submit_live_memory(
|
||||
store, result.record, changed=result.was_created or result.was_revised
|
||||
)
|
||||
wrote = True
|
||||
return wrote
|
||||
return inputs
|
||||
|
||||
|
||||
def _grant_root(database: Path, source_id: str) -> Path | None:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3235,6 +3235,65 @@ async def _062_onboarding_progress(cursor: Any) -> None:
|
||||
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.
|
||||
|
||||
@@ -3541,4 +3600,9 @@ MIGRATIONS: tuple[Migration, ...] = (
|
||||
_062_onboarding_progress,
|
||||
("onboarding_state",),
|
||||
),
|
||||
Migration(
|
||||
IMPORT_JOB_LOOKUP_INDEXES_MIGRATION_VERSION,
|
||||
"import_job_lookup_indexes",
|
||||
_063_import_job_lookup_indexes,
|
||||
),
|
||||
)
|
||||
@@ -26,6 +26,7 @@ if TYPE_CHECKING:
|
||||
_operation: ContextVar[tuple["ModelProbeOperations", str] | None] = ContextVar(
|
||||
"model_probe_operation", default=None
|
||||
)
|
||||
_dispatch_started: ContextVar[bool] = ContextVar("model_probe_dispatch_started", default=False)
|
||||
|
||||
|
||||
class ProbeCode(StrEnum):
|
||||
@@ -113,16 +114,26 @@ class ModelProber:
|
||||
if existing is not None:
|
||||
return existing
|
||||
token = _operation.set((operations, request.operation_id))
|
||||
dispatch_token = _dispatch_started.set(False)
|
||||
try:
|
||||
await self._probe(profile_id)
|
||||
except ProbeOperationConflict:
|
||||
await asyncio.to_thread(
|
||||
operations.fail, request.operation_id, "model_probe_profile_changed"
|
||||
)
|
||||
except Exception:
|
||||
# Unknown dispatch/storage outcomes stay reserved, including across restart.
|
||||
raise ProbeOperationUnavailable("model_probe_operation_unavailable") from None
|
||||
except Exception as exc:
|
||||
if _dispatch_started.get():
|
||||
# Once a request may have reached the provider, preserve the
|
||||
# reservation so a retry cannot duplicate a possibly billed call.
|
||||
raise ProbeOperationUnavailable("model_probe_operation_unavailable") from None
|
||||
logging.getLogger(__name__).warning(
|
||||
"model probe failed before dispatch: %s", type(exc).__name__
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
operations.fail, request.operation_id, "model_probe_execution_failed"
|
||||
)
|
||||
finally:
|
||||
_dispatch_started.reset(dispatch_token)
|
||||
_operation.reset(token)
|
||||
return await asyncio.to_thread(operations.lookup, request.operation_id)
|
||||
|
||||
@@ -317,6 +328,8 @@ class ModelProber:
|
||||
listing_optional: bool = False,
|
||||
) -> ProbeStage:
|
||||
await self._check_operation()
|
||||
if _operation.get() is not None:
|
||||
_dispatch_started.set(True)
|
||||
started = monotonic()
|
||||
try:
|
||||
response = await client.request(
|
||||
|
||||
@@ -118,7 +118,7 @@ class OnboardingState(BaseModel):
|
||||
selected_source_ids: tuple[str, ...]
|
||||
started_at: datetime | None
|
||||
completed_at: datetime | None
|
||||
mode_intent: Literal["lite", "pro"] | None
|
||||
mode_intent: Literal["lite", "pro", "ultra"] | None
|
||||
setup_step: Literal["review", "mode", "model"] | None
|
||||
model_profile_id: str | None
|
||||
|
||||
@@ -128,7 +128,7 @@ class OnboardingProgressUpdate(BaseModel):
|
||||
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
mode_intent: Literal["lite", "pro"] | None = None
|
||||
mode_intent: Literal["lite", "pro", "ultra"] | None = None
|
||||
setup_step: Literal["review", "mode", "model"] | None = None
|
||||
model_profile_id: str | None = Field(default=None, min_length=36, max_length=36)
|
||||
|
||||
|
||||
@@ -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),)
|
||||
@@ -11,6 +11,7 @@ instead: plain retrieval over what was imported, the same lane
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import UTC, datetime
|
||||
@@ -46,6 +47,50 @@ def recall_total_timeout_seconds(timeout_ms: int) -> float:
|
||||
return min(timeout_ms / 1_000 + RECALL_TOTAL_GRACE_SECONDS, RECALL_TOTAL_CEILING_SECONDS)
|
||||
|
||||
|
||||
def _bounded_raw_excerpt(content: str, query: str, limit: int) -> str:
|
||||
"""Fit one raw hit without discarding the highest-ranked candidate.
|
||||
|
||||
Prefer the literal query, then its longest matching word. A semantic hit
|
||||
with no lexical anchor keeps its beginning: that is also the part the
|
||||
embedding service preserves when an input exceeds its own token limit.
|
||||
"""
|
||||
if len(content) <= limit:
|
||||
return content
|
||||
if limit <= 2:
|
||||
return content[:limit]
|
||||
|
||||
anchor = 0
|
||||
needle = query.strip()
|
||||
if needle:
|
||||
exact = re.search(re.escape(needle), content, re.IGNORECASE)
|
||||
if exact is not None:
|
||||
anchor = exact.start()
|
||||
else:
|
||||
terms = sorted(re.findall(r"\w+", needle), key=len, reverse=True)
|
||||
match = next(
|
||||
(
|
||||
found
|
||||
for term in terms
|
||||
if (found := re.search(re.escape(term), content, re.IGNORECASE)) is not None
|
||||
),
|
||||
None,
|
||||
)
|
||||
if match is not None:
|
||||
anchor = match.start()
|
||||
|
||||
payload = limit
|
||||
while True:
|
||||
start = max(0, min(anchor - payload // 3, len(content) - payload))
|
||||
end = min(len(content), start + payload)
|
||||
adjusted = limit - int(start > 0) - int(end < len(content))
|
||||
if adjusted == payload:
|
||||
break
|
||||
payload = adjusted
|
||||
prefix = "…" if start > 0 else ""
|
||||
suffix = "…" if end < len(content) else ""
|
||||
return f"{prefix}{content[start:end]}{suffix}"
|
||||
|
||||
|
||||
async def recall_with_fallback(
|
||||
request: RecallRequest,
|
||||
*,
|
||||
@@ -123,6 +168,9 @@ async def _raw_recall(
|
||||
)
|
||||
except TimeoutError:
|
||||
return receipt(truncated_by=TruncationBudget.TIMEOUT)
|
||||
current_sources = await raw_sources()
|
||||
if sources is not None:
|
||||
sources &= current_sources or frozenset()
|
||||
hits: list[RecallHit] = []
|
||||
used = 0
|
||||
truncated_by: TruncationBudget | None = None
|
||||
@@ -137,13 +185,23 @@ async def _raw_recall(
|
||||
truncated_by=truncated_by,
|
||||
unexamined=len(found) - index,
|
||||
)
|
||||
if used + len(hit.content) > request.character_budget:
|
||||
remaining = request.character_budget - used
|
||||
if remaining <= 0:
|
||||
truncated_by = TruncationBudget.CHARACTER
|
||||
return receipt(
|
||||
tuple(hits),
|
||||
used=used,
|
||||
truncated_by=truncated_by,
|
||||
unexamined=len(found) - index,
|
||||
)
|
||||
content = hit.content
|
||||
if len(content) > remaining:
|
||||
content = _bounded_raw_excerpt(content, request.query, remaining)
|
||||
truncated_by = TruncationBudget.CHARACTER
|
||||
continue
|
||||
hits.append(
|
||||
RecallHit(
|
||||
memory_id=hit.raw_id,
|
||||
content=hit.content,
|
||||
content=content,
|
||||
kind="raw",
|
||||
scopes=(),
|
||||
evidence_ids=(),
|
||||
@@ -158,5 +216,5 @@ async def _raw_recall(
|
||||
session_id=hit.session_id,
|
||||
)
|
||||
)
|
||||
used += len(hit.content)
|
||||
used += len(content)
|
||||
return receipt(tuple(hits), used=used, truncated_by=truncated_by)
|
||||
@@ -113,6 +113,37 @@ def inspect_runtime(
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CoreEndpoint:
|
||||
host: Literal["127.0.0.1"]
|
||||
port: int
|
||||
token: str
|
||||
|
||||
|
||||
def core_endpoint(
|
||||
data_dir: Path,
|
||||
*,
|
||||
alive: Callable[[int], bool] = process_is_alive,
|
||||
) -> CoreEndpoint | None:
|
||||
"""Where this home's Core listens and the token it accepts, from files alone.
|
||||
|
||||
The receipt and token checks `inspect_runtime` makes, without its identity
|
||||
round trip: a caller that only has to hand something over quickly lets the
|
||||
authenticated request itself be the proof. None when no live Core is named.
|
||||
"""
|
||||
try:
|
||||
raw = (data_dir / "runtime.json").read_text(encoding="utf-8")
|
||||
receipt = _receipt(json.loads(raw))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
return None
|
||||
if receipt is None or not alive(receipt.pid):
|
||||
return None
|
||||
token = _runtime_token(data_dir)
|
||||
if token is None:
|
||||
return None
|
||||
return CoreEndpoint(host=receipt.host, port=receipt.port, token=token)
|
||||
|
||||
|
||||
def _receipt(value: object) -> RuntimeReceipt | None:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
|
||||
@@ -288,4 +288,3 @@ def verify_readback(
|
||||
productivity_readable=sum(1 for memory_id in legacy.productivity if readable(memory_id)),
|
||||
chat_missing_before_migration=chat_missing_before_migration,
|
||||
)
|
||||
|
||||
@@ -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 个场景。 |
|
||||
@@ -137,7 +138,7 @@
|
||||
| `test_internal_preset.py` | `contract` | The optional model preset a configured build carries. |
|
||||
| `test_jsonl_snapshot_tail.py` | `contract` | Bounded EOF handling on synthetic files only, never original host history. |
|
||||
| `test_library_search_window.py` | `unit` | The library search window store and the unified record/keyword parsing. |
|
||||
| `test_live_capture.py` | `integration` | Visible hook text lands in Evidence before the transcript file is flushed. |
|
||||
| `test_live_capture.py` | `integration` | Hooks hand visible text to Core, which stages it once, without Evidence. |
|
||||
| `test_live_sync.py` | `contract` | 覆盖 live sync preempts a reader batch before it emits work、live sync cuts in front of a running history import、live sync does not resume a history import the user paused 等 24 个场景。 |
|
||||
| `test_live_sync_file_incremental.py` | `contract` | 覆盖 unchanged file metadata avoids a job and content read、changed file content is read once by the import job、workbuddy first live poll baselines history then imports only append 等 6 个场景。 |
|
||||
| `test_live_sync_job_coalescing.py` | `contract` | A poll that finds nothing must not leave an import job behind. |
|
||||
@@ -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",
|
||||
|
||||
@@ -39,8 +39,7 @@ def kuzu_graph_with_embedding_width(db_path: Path, width: int) -> None:
|
||||
database = kuzu.Database(str(db_path))
|
||||
connection = kuzu.Connection(database)
|
||||
connection.execute(
|
||||
f"CREATE NODE TABLE Memory("
|
||||
f"node_id STRING, embedding FLOAT[{width}], PRIMARY KEY(node_id))"
|
||||
f"CREATE NODE TABLE Memory(node_id STRING, embedding FLOAT[{width}], PRIMARY KEY(node_id))"
|
||||
)
|
||||
del connection
|
||||
del database
|
||||
|
||||
@@ -29,7 +29,6 @@ from one_memory.imports.sources.registry import (
|
||||
build_source_catalog,
|
||||
)
|
||||
from one_memory.imports.supervisor import ImportSupervisor
|
||||
from one_memory.live_capture import capture_hook
|
||||
|
||||
FREEZE_THRESHOLD_SECONDS = 1.0
|
||||
|
||||
@@ -228,31 +227,17 @@ async def _run(
|
||||
|
||||
hook_text = "realtime hook while history import is running"
|
||||
hook_started = time.monotonic()
|
||||
result["hook_accepted"] = await asyncio.to_thread(
|
||||
capture_hook,
|
||||
"workbuddy",
|
||||
{
|
||||
# A hook hands its turn to Core over loopback; it no longer writes the
|
||||
# database itself. What is measured is that hand-off under import load.
|
||||
handed = await client.post(
|
||||
"/api/v1/live-capture/workbuddy",
|
||||
json={
|
||||
"hook_event_name": "UserPromptSubmit",
|
||||
"session_id": "pressure-live-session",
|
||||
"prompt": hook_text,
|
||||
},
|
||||
app.state.evidence_store.database_path,
|
||||
)
|
||||
hook_deadline = hook_started + 10
|
||||
hook_stored = False
|
||||
while time.monotonic() < hook_deadline:
|
||||
with sqlite3.connect(app.state.evidence_store.database_path) as connection:
|
||||
hook_stored = (
|
||||
connection.execute(
|
||||
"SELECT 1 FROM evidence WHERE source_id='workbuddy' AND verbatim=?",
|
||||
(hook_text,),
|
||||
).fetchone()
|
||||
is not None
|
||||
)
|
||||
if hook_stored:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
result["hook_stored"] = hook_stored
|
||||
result["hook_accepted"] = handed.status_code == 202
|
||||
result["hook_visible_ms"] = round((time.monotonic() - hook_started) * 1000, 1)
|
||||
|
||||
during, during_unavailable, sampled_terminal = await _measure_recall_until(
|
||||
|
||||
@@ -104,9 +104,7 @@ SDK_PRIVATE_ATTRIBUTES = frozenset(
|
||||
}
|
||||
)
|
||||
SDK_PRIVATE_ACCESS_EXCEPTIONS = {
|
||||
f"{PACKAGE_LABEL}/hy_graph_projection.py": frozenset(
|
||||
{"_graph_store", "_loop_thread", "_run"}
|
||||
),
|
||||
f"{PACKAGE_LABEL}/hy_graph_projection.py": frozenset({"_graph_store", "_loop_thread", "_run"}),
|
||||
# Startup migration runs while Runtime writers are stopped. It is the one
|
||||
# non-runtime path allowed to manipulate SDK storage directly so that
|
||||
# migration-only mechanics never become public HyMemoryClient methods.
|
||||
@@ -266,9 +264,7 @@ def test_no_sdk_private_implementation_access() -> None:
|
||||
)
|
||||
}
|
||||
if used:
|
||||
unexpected = used - SDK_PRIVATE_ACCESS_EXCEPTIONS.get(
|
||||
relative, frozenset()
|
||||
)
|
||||
unexpected = used - SDK_PRIVATE_ACCESS_EXCEPTIONS.get(relative, frozenset())
|
||||
if unexpected:
|
||||
offenders.append(f"{relative}: {sorted(unexpected)}")
|
||||
assert not offenders, (
|
||||
@@ -291,9 +287,7 @@ def test_sdk_modules_import_only_the_public_package_root() -> None:
|
||||
):
|
||||
offenders.append(relative)
|
||||
break
|
||||
if isinstance(node, ast.ImportFrom) and (
|
||||
node.module or ""
|
||||
).startswith("hy_memory."):
|
||||
if isinstance(node, ast.ImportFrom) and (node.module or "").startswith("hy_memory."):
|
||||
offenders.append(relative)
|
||||
break
|
||||
if (
|
||||
|
||||
@@ -131,3 +131,24 @@ def test_run_as_the_entry_it_ends_itself_while_still_importing(tmp_path) -> None
|
||||
_reap([process])
|
||||
assert process.returncode == -signal.SIGALRM
|
||||
assert time.monotonic() - started < 10
|
||||
|
||||
|
||||
def test_with_no_core_a_real_capture_exits_at_once_and_writes_nothing(tmp_path) -> None:
|
||||
# The process a host actually starts: no Core is running for this home,
|
||||
# so the turn is dropped. Nothing may fall back to writing the database.
|
||||
data_dir = tmp_path / "data"
|
||||
env = {
|
||||
key: value for key, value in os.environ.items() if key != "ONE_MEMORY_CAPTURE_DEADLINE_S"
|
||||
}
|
||||
env.update(HOME=str(tmp_path), ONE_MEMORY_DATA_DIR=str(data_dir))
|
||||
payload = b'{"hook_event_name": "UserPromptSubmit", "session_id": "s", "prompt": "hi"}'
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "one_memory.cli", "capture", "workbuddy"],
|
||||
input=payload,
|
||||
capture_output=True,
|
||||
env=env,
|
||||
timeout=60,
|
||||
check=False,
|
||||
)
|
||||
assert (result.returncode, result.stdout, result.stderr) == (0, b"", b"")
|
||||
assert not (data_dir / "one-memory.sqlite3").exists()
|
||||
@@ -16,6 +16,8 @@ def database(tmp_path):
|
||||
path = tmp_path / "context.sqlite3"
|
||||
with closing(sqlite3.connect(path)) as db, db:
|
||||
db.execute("CREATE TABLE source_grants(user_id TEXT, source_id TEXT, revoked_at TEXT)")
|
||||
db.execute("INSERT INTO source_grants VALUES('local','cursor',NULL)")
|
||||
db.execute("INSERT INTO source_grants VALUES('local','codex',NULL)")
|
||||
db.execute("INSERT INTO source_grants VALUES('local','revoked','2026-09-19')")
|
||||
db.execute("INSERT INTO source_grants VALUES('other','cursor','2026-09-19')")
|
||||
return path
|
||||
@@ -43,16 +45,14 @@ def test_memory_only_never_reads_raw_authority(database, monkeypatch):
|
||||
assert not result.raw_allowed and result.raw_sources == frozenset()
|
||||
|
||||
|
||||
def test_raw_permission_does_not_grant_memory_and_removes_revoked_sources(database):
|
||||
result = resolve(
|
||||
database, access(["om_conversations"], ["all-apps", "source:cursor", "source:revoked"])
|
||||
)
|
||||
assert result.raw_allowed and result.raw_sources == frozenset({"cursor"})
|
||||
def test_all_apps_raw_reads_every_connected_source_and_removes_revoked_sources(database):
|
||||
result = resolve(database, access(["om_conversations"], ["all-apps", "source:cursor"]))
|
||||
assert result.raw_allowed and result.raw_sources == frozenset({"cursor", "codex"})
|
||||
assert not result.memory_allowed
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scopes", [["source:cursor"], ["all-apps"], []])
|
||||
def test_raw_requires_all_apps_and_source(database, scopes):
|
||||
@pytest.mark.parametrize("scopes", [["source:cursor"], []])
|
||||
def test_raw_requires_all_apps(database, scopes):
|
||||
with pytest.raises(HTTPException) as error:
|
||||
resolve(database, access(["om_conversations"], scopes))
|
||||
assert error.value.status_code == 403
|
||||
@@ -80,9 +80,7 @@ def test_explicit_raw_filter_must_be_currently_authorized(database, source):
|
||||
with pytest.raises(HTTPException) as error:
|
||||
resolve(
|
||||
database,
|
||||
access(
|
||||
["om_recall", "om_conversations"], ["all-apps", "source:cursor", "source:revoked"]
|
||||
),
|
||||
access(["om_recall", "om_conversations"], ["all-apps"]),
|
||||
raw_source_ids=(source,),
|
||||
)
|
||||
assert error.value.status_code == 403
|
||||
@@ -104,10 +102,19 @@ def test_filters_do_not_change_memory_scope(database):
|
||||
assert result.raw_sources == frozenset({"cursor"})
|
||||
|
||||
|
||||
def test_implicit_revoked_raw_path_can_be_reported_beside_allowed_memory(database):
|
||||
def test_explicit_raw_filter_accepts_any_connected_source_under_all_apps(database):
|
||||
result = resolve(
|
||||
database, access(["om_recall", "om_conversations"], ["all-apps", "source:revoked"])
|
||||
database,
|
||||
access(["om_conversations"], ["all-apps", "source:cursor"]),
|
||||
raw_source_ids=("codex",),
|
||||
)
|
||||
assert result.raw_allowed and result.raw_sources == frozenset({"codex"})
|
||||
|
||||
|
||||
def test_no_connected_raw_source_can_be_reported_beside_allowed_memory(database):
|
||||
with closing(sqlite3.connect(database)) as db, db:
|
||||
db.execute("UPDATE source_grants SET revoked_at='2026-09-20' WHERE user_id='local'")
|
||||
result = resolve(database, access(["om_recall", "om_conversations"], ["all-apps"]))
|
||||
assert result.memory_allowed and not result.raw_allowed
|
||||
|
||||
|
||||
@@ -124,7 +131,7 @@ def test_expired_deadline_does_not_open_database(database, monkeypatch):
|
||||
with pytest.raises(TimeoutError):
|
||||
resolve_context_authority(
|
||||
ContextSearchRequest(query="x"),
|
||||
access(["om_conversations"], ["all-apps", "source:cursor"]),
|
||||
access(["om_conversations"], ["all-apps"]),
|
||||
database,
|
||||
deadline=time.monotonic() - 1,
|
||||
)
|
||||
|
||||
@@ -53,8 +53,8 @@ def test_external_marker_is_not_an_installation_grant(tmp_path):
|
||||
"/api/v2/memories/ask", json={"question": "history"}, headers=headers()
|
||||
)
|
||||
assert denied_tool.status_code == 403
|
||||
# om_conversations reaches Core only as the v2 raw lane, and only
|
||||
# with a source grant: all-apps alone opens no raw source.
|
||||
# om_conversations reaches Core only as the v2 raw lane. all-apps
|
||||
# has nothing to open because this fresh home has no connected source.
|
||||
no_raw = await client.post(
|
||||
"/api/v2/context/search",
|
||||
json={"query": "history", "kinds": ["raw"]},
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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())
|
||||
File diff suppressed because it is too large.
Load diff
@@ -293,6 +293,9 @@ def test_health_states_that_watching_is_layered_on_the_timer(tmp_path: Path) ->
|
||||
|
||||
assert live.health(PI_DESCRIPTOR.source_id).mode == "watch_and_polling"
|
||||
assert live.health("unknown-source").mode == "watch_and_polling"
|
||||
live.set_watched_sources((PI_DESCRIPTOR.source_id,))
|
||||
assert live._poll_interval(PI_DESCRIPTOR.source_id) == max(live.interval_seconds, 15 * 60)
|
||||
assert live._poll_interval("unknown-source") == live.interval_seconds
|
||||
await imports.shutdown()
|
||||
|
||||
asyncio.run(scenario())
|
||||
@@ -641,3 +644,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
|
||||
@@ -91,6 +91,51 @@ def test_reserved_before_transport_and_atomic_result_reopens_without_resending(e
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_pre_dispatch_failure_marks_operation_failed_and_unfences(env, monkeypatch):
|
||||
store, secrets, profile, ops, calls, respond = env
|
||||
operation = str(uuid4())
|
||||
|
||||
def fail_require(*args, **kwargs):
|
||||
raise RuntimeError("profile load failed")
|
||||
|
||||
prober = ModelProber(store=store, secrets=secrets, transport=httpx.MockTransport(respond))
|
||||
with monkeypatch.context() as patch:
|
||||
patch.setattr(store, "require", fail_require)
|
||||
receipt = asyncio.run(
|
||||
prober.probe(
|
||||
profile.profile_id,
|
||||
operation_id=operation,
|
||||
expected_profile_revision=profile.revision,
|
||||
)
|
||||
)
|
||||
|
||||
assert receipt.state == "failed"
|
||||
assert receipt.code == "model_probe_execution_failed"
|
||||
assert receipt.result is None
|
||||
assert calls == []
|
||||
assert ops.lookup(operation) == receipt
|
||||
assert (
|
||||
asyncio.run(
|
||||
prober.probe(
|
||||
profile.profile_id,
|
||||
operation_id=operation,
|
||||
expected_profile_revision=profile.revision,
|
||||
)
|
||||
)
|
||||
== receipt
|
||||
)
|
||||
|
||||
completed = asyncio.run(
|
||||
prober.probe(
|
||||
profile.profile_id,
|
||||
operation_id=str(uuid4()),
|
||||
expected_profile_revision=profile.revision,
|
||||
)
|
||||
)
|
||||
assert completed.state == "completed"
|
||||
assert len(calls) == 3
|
||||
|
||||
|
||||
def test_same_id_and_same_profile_concurrency_refused_before_second_call(env):
|
||||
store, secrets, profile, ops, calls, respond = env
|
||||
|
||||
|
||||
@@ -149,6 +149,13 @@ def test_progress_patch_and_put_round_trip_only_resumable_choices(tmp_path: Path
|
||||
assert replaced.json()["model_profile_id"] == profile_id
|
||||
assert (await client.get("/api/v1/onboarding")).json() == replaced.json()
|
||||
|
||||
ultra = await client.put(
|
||||
"/api/v1/onboarding/progress",
|
||||
json={"mode_intent": "ultra", "setup_step": "model"},
|
||||
)
|
||||
assert ultra.status_code == 200
|
||||
assert ultra.json()["mode_intent"] == "ultra"
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
@@ -166,7 +173,7 @@ def test_progress_rejects_unknown_or_invalid_fields(tmp_path: Path) -> None:
|
||||
)
|
||||
invalid_mode = await client.patch(
|
||||
"/api/v1/onboarding/progress",
|
||||
json={"mode_intent": "ultra"},
|
||||
json={"mode_intent": "future"},
|
||||
)
|
||||
|
||||
assert secret.status_code == 422
|
||||
|
||||
@@ -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,68 @@ def test_cursor_transcripts_with_the_same_name_in_two_projects_are_both_kept(
|
||||
assert len({item.evidence.source_ordinal for item in records}) == 2
|
||||
|
||||
|
||||
def test_cursor_discovery_does_not_enter_non_conversation_trees(tmp_path) -> None:
|
||||
root = tmp_path / ".cursor"
|
||||
write_jsonl(
|
||||
root / "projects" / "project" / "agent-transcripts" / "kept" / "kept.jsonl",
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"message": {"content": [{"type": "text", "text": "kept conversation"}]},
|
||||
}
|
||||
],
|
||||
)
|
||||
write_jsonl(
|
||||
root / "extensions" / "large-cache" / "agent-transcripts" / "ignored" / "ignored.jsonl",
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"message": {"content": [{"type": "text", "text": "must not be scanned"}]},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
records = evidence(asyncio.run(scan(CursorUnifiedSourceAdapter(grant=grant("cursor", root)))))
|
||||
|
||||
assert [item.evidence.verbatim for item in records] == ["kept conversation"]
|
||||
|
||||
|
||||
def test_cursor_live_probe_listing_is_reused_by_the_import(tmp_path, monkeypatch) -> None:
|
||||
root = tmp_path / ".cursor"
|
||||
write_jsonl(
|
||||
root / "projects" / "project" / "agent-transcripts" / "new" / "new.jsonl",
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"message": {"content": [{"type": "text", "text": "new conversation"}]},
|
||||
}
|
||||
],
|
||||
)
|
||||
adapter = CursorUnifiedSourceAdapter(grant=grant("cursor", root))
|
||||
from one_memory.imports.adapters import ScanOptions
|
||||
from one_memory.imports.sources import cursor_unified
|
||||
|
||||
calls = 0
|
||||
original = cursor_unified._list_cursor_files
|
||||
|
||||
def counted(*args, **kwargs):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return original(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(cursor_unified, "_list_cursor_files", counted)
|
||||
|
||||
async def probe_then_scan():
|
||||
options = ScanOptions()
|
||||
assert await adapter.has_changes(options)
|
||||
return [event async for event in adapter.scan(options)]
|
||||
|
||||
records = evidence(asyncio.run(probe_then_scan()))
|
||||
|
||||
assert [item.evidence.verbatim for item in records] == ["new conversation"]
|
||||
assert calls == 1
|
||||
|
||||
|
||||
def test_cursor_bounded_scans_reuse_the_authorized_file_listing(tmp_path, monkeypatch) -> None:
|
||||
root = tmp_path / ".cursor"
|
||||
transcript = root / "projects" / "project" / "agent-transcripts" / "session" / "session.jsonl"
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
Without a usable generation model no memory can be distilled, and with no
|
||||
memory in the library a memory search can only come back empty. In both cases
|
||||
recall searches the SDK raw layer and says so with `mode: "raw"`. Original
|
||||
conversations stay under their own grant: recall's grant does not widen it.
|
||||
recall searches the SDK raw layer and says so with `mode: "raw"`. External
|
||||
callers need the conversations tool and all-apps scope; Core resolves that to
|
||||
every connected, unrevoked source.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -18,6 +19,7 @@ from one_memory import api
|
||||
from one_memory.api import create_app
|
||||
from one_memory.config import Settings
|
||||
from one_memory.hy_sdk_types import HySdkRawHit
|
||||
from one_memory.imports.sources.registry import RootCandidateKind
|
||||
from one_memory.recall import RecallHit, RecallReceipt, RecallRequest, RecallState
|
||||
from one_memory.recall_fallback import recall_with_fallback
|
||||
from one_memory.secrets import InMemorySecretStore
|
||||
@@ -156,6 +158,26 @@ def test_raw_fallback_respects_the_callers_raw_sources():
|
||||
assert [hit.memory_id for hit in receipt.hits] == ["r1"]
|
||||
|
||||
|
||||
def test_raw_fallback_rechecks_sources_after_search_before_releasing_content():
|
||||
_, fakes = _fakes(
|
||||
ready=False,
|
||||
memories=[],
|
||||
has_memories=False,
|
||||
raw=[_raw("r1", "private", source="cursor")],
|
||||
sources=frozenset({"cursor"}),
|
||||
)
|
||||
checks = iter((frozenset({"cursor"}), frozenset()))
|
||||
|
||||
async def sources():
|
||||
return next(checks)
|
||||
|
||||
fakes["raw_sources"] = sources
|
||||
receipt = run(RecallRequest(query="private"), fakes)
|
||||
|
||||
assert receipt.state == RecallState.EMPTY
|
||||
assert receipt.hits == ()
|
||||
|
||||
|
||||
def test_a_caller_with_no_raw_grant_gets_nothing_rather_than_a_wider_read():
|
||||
calls, fakes = _fakes(
|
||||
ready=False,
|
||||
@@ -187,6 +209,43 @@ def test_raw_fallback_keeps_the_count_and_character_budgets():
|
||||
assert by_chars.used_characters == 300
|
||||
|
||||
|
||||
def test_oversized_raw_hit_returns_a_query_anchored_excerpt_instead_of_empty():
|
||||
_, fakes = _fakes(
|
||||
ready=False,
|
||||
memories=[],
|
||||
has_memories=False,
|
||||
raw=[_raw("large", ("x" * 5_000) + "WebGL 黄色节点" + ("y" * 5_000))],
|
||||
)
|
||||
|
||||
receipt = run(
|
||||
RecallRequest(query="WebGL 黄色节点", limit=3, character_budget=300),
|
||||
fakes,
|
||||
)
|
||||
|
||||
assert receipt.state == RecallState.GROUNDED
|
||||
assert len(receipt.hits) == 1
|
||||
assert len(receipt.hits[0].content) == receipt.used_characters == 300
|
||||
assert "WebGL 黄色节点" in receipt.hits[0].content
|
||||
assert receipt.truncated_by.value == "character_budget"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prefix_length", [0, 100, 5_000, 9_980])
|
||||
def test_raw_excerpt_never_exceeds_its_character_budget(prefix_length):
|
||||
needle = "WebGL"
|
||||
content = ("x" * prefix_length) + needle + ("y" * (10_000 - prefix_length))
|
||||
_, fakes = _fakes(
|
||||
ready=False,
|
||||
memories=[],
|
||||
has_memories=False,
|
||||
raw=[_raw("large", content)],
|
||||
)
|
||||
|
||||
receipt = run(RecallRequest(query=needle, character_budget=300), fakes)
|
||||
|
||||
assert len(receipt.hits[0].content) == receipt.used_characters == 300
|
||||
assert needle in receipt.hits[0].content
|
||||
|
||||
|
||||
def test_the_probe_never_falls_back():
|
||||
calls, fakes = _fakes(ready=False, memories=[], has_memories=False, raw=[_raw("r1", "x")])
|
||||
|
||||
@@ -237,6 +296,14 @@ def test_recall_route_falls_back_and_keeps_the_raw_grant(tmp_path, monkeypatch):
|
||||
headers={"Authorization": "Bearer recall-test"},
|
||||
) as client,
|
||||
):
|
||||
cursor_root = tmp_path / "cursor"
|
||||
cursor_root.mkdir()
|
||||
app.state.source_grant_store.grant(
|
||||
user_id="local",
|
||||
source_id="cursor",
|
||||
root=cursor_root,
|
||||
candidate_kind=RootCandidateKind.DEFAULT,
|
||||
)
|
||||
local = await client.post("/api/v1/recall", json={"query": "数据库"})
|
||||
recall_only = await client.post(
|
||||
"/api/v1/recall",
|
||||
@@ -247,9 +314,18 @@ def test_recall_route_falls_back_and_keeps_the_raw_grant(tmp_path, monkeypatch):
|
||||
"X-One-Memory-Allowed-Scopes": json.dumps(["all-apps"]),
|
||||
},
|
||||
)
|
||||
return local.json(), recall_only.json()
|
||||
cross_app = await client.post(
|
||||
"/api/v1/recall",
|
||||
json={"query": "数据库"},
|
||||
headers={
|
||||
**EXTERNAL,
|
||||
"X-One-Memory-Allowed-Tools": json.dumps(["om_recall", "om_conversations"]),
|
||||
"X-One-Memory-Allowed-Scopes": json.dumps(["all-apps", "source:workbuddy"]),
|
||||
},
|
||||
)
|
||||
return local.json(), recall_only.json(), cross_app.json()
|
||||
|
||||
local, recall_only = asyncio.run(scenario())
|
||||
local, recall_only, cross_app = asyncio.run(scenario())
|
||||
|
||||
assert local["mode"] == "raw" and local["fallback_reason"] == "model_unavailable"
|
||||
assert local["hits"][0]["content_format"] == "raw"
|
||||
@@ -258,7 +334,9 @@ def test_recall_route_falls_back_and_keeps_the_raw_grant(tmp_path, monkeypatch):
|
||||
assert searched[0] is None
|
||||
# A host granted recall alone may not read original conversations.
|
||||
assert recall_only["mode"] == "raw" and recall_only["hits"] == []
|
||||
assert len(searched) == 1
|
||||
# all-apps covers every connected source, not only the host's source scope.
|
||||
assert cross_app["hits"][0]["source"] == "cursor"
|
||||
assert searched == [None, frozenset({"cursor"})]
|
||||
|
||||
|
||||
def test_the_total_recall_budget_stays_under_the_brokers_wait():
|
||||
|
||||
@@ -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,170 @@ 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_long_semantic_raw_hit_cannot_erase_an_earlier_match(tmp_path, monkeypatch) -> None:
|
||||
async def scenario(app, sdk, client):
|
||||
app.state.memory_mode = lambda: "lite"
|
||||
|
||||
def search(query, **kwargs):
|
||||
sdk._record("search", {"query": query, **kwargs})
|
||||
memories = [
|
||||
{
|
||||
"memory_id": "raw-target",
|
||||
"content": "[user]: lite 模式的 3d 图需要调整",
|
||||
"layer": "l1_raw",
|
||||
"status": "active",
|
||||
"user_id": "local",
|
||||
"agent_id": "all-apps",
|
||||
"session_id": "target-session",
|
||||
"source": "cursor",
|
||||
"memory_at": 1_790_100_000,
|
||||
"score": 0.97,
|
||||
},
|
||||
{
|
||||
"memory_id": "raw-long",
|
||||
"content": f"[user]: {'x' * 20_001}",
|
||||
"layer": "l1_raw",
|
||||
"status": "active",
|
||||
"user_id": "local",
|
||||
"agent_id": "all-apps",
|
||||
"session_id": "long-session",
|
||||
"source": "cursor",
|
||||
"memory_at": 1_790_000_000,
|
||||
"score": 0.9,
|
||||
},
|
||||
]
|
||||
return {
|
||||
"request_id": "long-raw",
|
||||
"memories": memories[1:] if query == "oversized" else memories,
|
||||
}
|
||||
|
||||
sdk.search = search
|
||||
response = await client.post(
|
||||
"/api/v2/context/search",
|
||||
json={
|
||||
"query": "3d",
|
||||
"kinds": ["raw"],
|
||||
"raw_unit": "turn",
|
||||
"limit": 20,
|
||||
"character_budget": 20_000,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["state"] == "grounded"
|
||||
assert body["items"][0]["raw_id"] == "raw-target"
|
||||
assert "3d" in body["items"][0]["content"]
|
||||
assert body["items"][1]["raw_id"] == "raw-long"
|
||||
assert body["items"][1]["truncated"] is True
|
||||
assert body["truncated"] is True
|
||||
|
||||
single = await client.post(
|
||||
"/api/v2/context/search",
|
||||
json={
|
||||
"query": "oversized",
|
||||
"kinds": ["raw"],
|
||||
"raw_unit": "turn",
|
||||
"character_budget": 20_000,
|
||||
},
|
||||
)
|
||||
assert single.json()["items"][0]["truncated"] is True
|
||||
assert single.json()["truncated"] is True
|
||||
|
||||
run_with_sdk(tmp_path, monkeypatch, scenario)
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_raw_only_lane_failure_is_unavailable_not_an_empty_partial(tmp_path, monkeypatch) -> None:
|
||||
async def scenario(app, sdk, client):
|
||||
app.state.memory_mode = lambda: "lite"
|
||||
|
||||
def search(_query, **_kwargs):
|
||||
raise ValueError("private conversation text must not reach logs")
|
||||
|
||||
sdk.search = search
|
||||
response = await client.post(
|
||||
"/api/v2/context/search",
|
||||
json={"query": "3d", "kinds": ["raw"], "raw_unit": "turn"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["state"] == "unavailable"
|
||||
assert response.json()["items"] == []
|
||||
|
||||
run_with_sdk(tmp_path, monkeypatch, scenario)
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_every_recall_and_ask_option_reaches_the_sdk(tmp_path, monkeypatch) -> None:
|
||||
async def scenario(app, sdk, client):
|
||||
@@ -574,7 +791,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 +806,144 @@ 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
|
||||
graph_calls = len(sdk.all("list_memories"))
|
||||
graph = await client.get("/api/v2/raw/graph", params={"limit": 2})
|
||||
assert graph.status_code == 200, graph.text
|
||||
assert len(sdk.all("list_memories")) == graph_calls + 1
|
||||
graph_body = graph.json()
|
||||
assert graph_body["total"] == 3
|
||||
assert graph_body["has_more"] is True
|
||||
assert [item["label"] for item in graph_body["items"]] == [
|
||||
"[user]: 我喜欢绿茶 [assistant]: 也记住了。",
|
||||
"[user]: 我叫费天翔 [assistant]: 好的,记住了。",
|
||||
]
|
||||
assert (await client.get("/api/v2/raw/graph", params={"limit": 301})).status_code == 422
|
||||
assert (
|
||||
await client.get(
|
||||
"/api/v2/raw/graph",
|
||||
params={"limit": 2, "offset": 2},
|
||||
)
|
||||
).status_code == 422
|
||||
inserted = {
|
||||
"memory_id": "raw-new",
|
||||
"content": "[user]: 新消息",
|
||||
"status": "active",
|
||||
"session_id": "s-new",
|
||||
"source": "cursor",
|
||||
"memory_at": 1_790_200_000,
|
||||
"gmt_created": 1_790_200_100,
|
||||
}
|
||||
RAW_ROWS["local"].insert(0, inserted)
|
||||
try:
|
||||
updated_graph = (await client.get("/api/v2/raw/graph")).json()
|
||||
assert updated_graph["total"] == 4
|
||||
assert updated_graph["items"][0]["label"] == "[user]: 新消息"
|
||||
continued = await client.get(
|
||||
"/api/v2/raw",
|
||||
params={
|
||||
"unit": "turn",
|
||||
"paging": "cursor",
|
||||
"limit": 1,
|
||||
"cursor": first_cursor,
|
||||
},
|
||||
)
|
||||
assert continued.status_code == 200, continued.text
|
||||
assert continued.json()["items"][0]["messages"][0]["content"] == "我叫费天翔"
|
||||
finally:
|
||||
RAW_ROWS["local"].remove(inserted)
|
||||
assert (
|
||||
await client.get(
|
||||
"/api/v2/raw",
|
||||
params={
|
||||
"unit": "turn",
|
||||
"paging": "cursor",
|
||||
"cursor": first_cursor,
|
||||
"source": "hermes",
|
||||
},
|
||||
)
|
||||
).status_code == 422
|
||||
assert (
|
||||
await client.get(
|
||||
"/api/v2/raw",
|
||||
params={
|
||||
"unit": "turn",
|
||||
"paging": "cursor",
|
||||
"cursor": "turn:²:0:0:2::0000000000000000",
|
||||
},
|
||||
)
|
||||
).status_code == 422
|
||||
|
||||
calls_before_refresh = len(sdk.all("list_memories"))
|
||||
await client.get("/api/v2/raw", params={"unit": "turn"})
|
||||
assert len(sdk.all("list_memories")) == calls_before_refresh + 1
|
||||
|
||||
# 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",
|
||||
|
||||
@@ -293,6 +293,22 @@ def _retry_succeeds(home: Path, monkeypatch) -> None:
|
||||
assert len(list((home / "backups").glob("*-pre-cutover.sdk-bundle"))) == 1
|
||||
|
||||
|
||||
def test_evidence_an_older_capture_hook_recreated_is_dropped_on_the_next_start(home):
|
||||
# Builds through 1.0.6 let the hook process open `EvidenceStore`, which
|
||||
# rebuilt the retired tables on an SDK home. Nothing reads them there, and
|
||||
# the next startup migration's leftovers cleanup removes them again.
|
||||
assert _run(home).state == "cutover"
|
||||
database = home / "one-memory.sqlite3"
|
||||
EvidenceStore(database)
|
||||
assert "evidence" in legacy_tables_present(database)
|
||||
|
||||
result = _run(home)
|
||||
|
||||
assert result.state == "complete"
|
||||
assert {"evidence", "evidence_search"} <= set(result.dropped_tables)
|
||||
assert legacy_tables_present(database) == ()
|
||||
|
||||
|
||||
def test_a_failure_after_the_cutover_commit_is_not_restored(home, monkeypatch):
|
||||
import one_memory.sdk_migration_runner as runner
|
||||
|
||||
|
||||
@@ -23,9 +23,8 @@ def test_workbuddy_recall_remains_available_during_100k_import(tmp_path, monkeyp
|
||||
|
||||
assert result["import_status"] == "completed"
|
||||
assert result["stored"] == 100_000
|
||||
assert result["evidence_rows"] == 100_001
|
||||
assert result["evidence_rows"] == 100_000
|
||||
assert result["hook_accepted"] is True
|
||||
assert result["hook_stored"] is True
|
||||
assert result["hook_visible_ms"] < 10_000
|
||||
assert result["recall_sampling_terminal"] is True
|
||||
assert result["during_unavailable"] == 0
|
||||
|
||||
@@ -197,6 +197,82 @@ def test_connection_retry_reuses_completed_job_and_checks_root_snapshot(tmp_path
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_connection_returns_committed_receipt_before_live_activation_finishes(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr("one_memory.model_presets._preset_data", lambda: None)
|
||||
|
||||
async def scenario():
|
||||
home = tmp_path / "home"
|
||||
root = home / ".claude/projects"
|
||||
root.mkdir(parents=True)
|
||||
app = create_app(
|
||||
Settings(
|
||||
data_dir=tmp_path / "data",
|
||||
source_home=home,
|
||||
api_token=SecretStr("test"),
|
||||
automatic_memory_processing=False,
|
||||
live_sync_enabled=False,
|
||||
),
|
||||
source_catalog=build_source_catalog(home=home, environment={}),
|
||||
)
|
||||
async with (
|
||||
app.router.lifespan_context(app),
|
||||
AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
headers={"Authorization": "Bearer test"},
|
||||
) as client,
|
||||
):
|
||||
found = await client.post("/api/v1/sources/claude-code/location")
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
original = app.state.live_sync_supervisor.initialize_source
|
||||
|
||||
async def blocked_initialize(source_id, *, baseline_missing_files):
|
||||
started.set()
|
||||
await release.wait()
|
||||
return await original(
|
||||
source_id,
|
||||
baseline_missing_files=baseline_missing_files,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
app.state.live_sync_supervisor,
|
||||
"initialize_source",
|
||||
blocked_initialize,
|
||||
)
|
||||
try:
|
||||
response = await asyncio.wait_for(
|
||||
client.post(
|
||||
"/api/v1/sources/connect",
|
||||
json={
|
||||
"source_ids": ["claude-code"],
|
||||
"request_id": "fast-receipt",
|
||||
"source_roots": {
|
||||
"claude-code": found.json()["root_id"],
|
||||
},
|
||||
"onboarding": True,
|
||||
"full_rescan": True,
|
||||
"enable_sync": True,
|
||||
},
|
||||
),
|
||||
timeout=1,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["request_id"] == "fast-receipt"
|
||||
assert app.state.config_store.sync_enabled("claude-code")
|
||||
await asyncio.wait_for(started.wait(), timeout=1)
|
||||
assert not release.is_set()
|
||||
finally:
|
||||
release.set()
|
||||
tasks = tuple(app.state.source_activation_tasks.values())
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("turn_count", "age_days", "limited"),
|
||||
[
|
||||
|
||||
@@ -10,6 +10,7 @@ from pydantic import SecretStr
|
||||
from one_memory import api
|
||||
from one_memory.api import create_app
|
||||
from one_memory.config import Settings
|
||||
from one_memory.imports.sources.registry import RootCandidateKind
|
||||
from one_memory.sdk_data_plane_migration import MemoryAuthority
|
||||
from one_memory.sdk_memory_qa import SdkAskMemory, SdkMemoryAnswer
|
||||
from one_memory.secrets import InMemorySecretStore
|
||||
@@ -96,8 +97,11 @@ class Sdk:
|
||||
|
||||
|
||||
class Handle:
|
||||
def __init__(self, sdk=None):
|
||||
self.sdk = sdk or Sdk()
|
||||
|
||||
async def client(self):
|
||||
return Sdk()
|
||||
return self.sdk
|
||||
|
||||
async def close(self):
|
||||
return None
|
||||
@@ -195,3 +199,50 @@ def test_v2_host_calls_are_recorded_and_renderer_calls_are_not(tmp_path, monkeyp
|
||||
assert listed.json()["total"] == 3
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_v2_raw_rechecks_a_source_revoked_during_search(tmp_path):
|
||||
app = application(tmp_path)
|
||||
|
||||
class RevokingRawSdk:
|
||||
def search(self, query, **kwargs):
|
||||
app.state.source_grant_store.revoke(user_id="local", source_id="cursor")
|
||||
return {
|
||||
"memories": {
|
||||
"schema": [],
|
||||
"proactive": [],
|
||||
"normal": [
|
||||
{
|
||||
"memory_id": "raw-1",
|
||||
"content": "must not escape",
|
||||
"user_id": "local",
|
||||
"agent_id": "all-apps",
|
||||
"session_id": "session-1",
|
||||
"source": "cursor",
|
||||
"score": 0.9,
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
async def scenario():
|
||||
async with app.router.lifespan_context(app), client_for(app, EXTERNAL) as host:
|
||||
root = tmp_path / "cursor"
|
||||
root.mkdir()
|
||||
app.state.source_grant_store.grant(
|
||||
user_id="local",
|
||||
source_id="cursor",
|
||||
root=root,
|
||||
candidate_kind=RootCandidateKind.DEFAULT,
|
||||
)
|
||||
app.state.sdk_client_handle = Handle(RevokingRawSdk())
|
||||
return await host.post(
|
||||
"/api/v2/context/search",
|
||||
json={"query": "private", "kinds": ["raw"]},
|
||||
)
|
||||
|
||||
response = asyncio.run(scenario())
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "must not escape" not in response.text
|
||||
Generated
+4
-4
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -72,15 +72,23 @@ it("localizes the whole home live without repeating reads or changing imported t
|
||||
expect(summary).toHaveBeenCalledTimes(calls);
|
||||
});
|
||||
|
||||
const { summary, projection, processing, sedimentation, history, liveSync } =
|
||||
vi.hoisted(() => ({
|
||||
summary: vi.fn(),
|
||||
projection: vi.fn(),
|
||||
processing: vi.fn(),
|
||||
sedimentation: vi.fn(),
|
||||
history: vi.fn(),
|
||||
liveSync: vi.fn(),
|
||||
}));
|
||||
const {
|
||||
summary,
|
||||
projection,
|
||||
processing,
|
||||
sedimentation,
|
||||
history,
|
||||
liveSync,
|
||||
evolve,
|
||||
} = vi.hoisted(() => ({
|
||||
summary: vi.fn(),
|
||||
projection: vi.fn(),
|
||||
processing: vi.fn(),
|
||||
sedimentation: vi.fn(),
|
||||
history: vi.fn(),
|
||||
liveSync: vi.fn(),
|
||||
evolve: vi.fn(),
|
||||
}));
|
||||
vi.mock("./call-history-client", async (original) => ({
|
||||
...(await original<typeof import("./call-history-client")>()),
|
||||
getCallHistory: history,
|
||||
@@ -105,6 +113,7 @@ vi.mock("./api", () => ({
|
||||
getSdkRaw: vi.fn(),
|
||||
getLiveSyncHealth: liveSync,
|
||||
getMemoryProcessing: processing,
|
||||
getEvolveOverview: evolve,
|
||||
getSedimentation: sedimentation,
|
||||
getSources: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
@@ -146,6 +155,12 @@ beforeEach(() => {
|
||||
history
|
||||
.mockReset()
|
||||
.mockResolvedValue({ available: true, recording_error: false, items: [] });
|
||||
evolve.mockReset().mockResolvedValue({
|
||||
mode: "ultra",
|
||||
schedule: { enabled: true, time: "03:00" },
|
||||
users: {},
|
||||
running: [],
|
||||
});
|
||||
imports.refresh = vi.fn();
|
||||
summary.mockReset().mockResolvedValue({
|
||||
memory_count: 601,
|
||||
@@ -940,6 +955,39 @@ it.each([
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the home snapshot visible during Evolve and refreshes when it ends", async () => {
|
||||
evolve.mockResolvedValue({
|
||||
mode: "ultra",
|
||||
schedule: { enabled: true, time: "03:00" },
|
||||
users: {},
|
||||
running: [
|
||||
{
|
||||
run_id: "run-1",
|
||||
user_id: "local",
|
||||
state: "running",
|
||||
},
|
||||
],
|
||||
});
|
||||
render(mountHome());
|
||||
|
||||
expect(await screen.findByText("601")).toBeTruthy();
|
||||
expect(await screen.findByText("正在整合记忆…")).toBeTruthy();
|
||||
const readsBeforeCompletion = projection.mock.calls.length;
|
||||
|
||||
act(() => {
|
||||
queryClient.setQueryData(["evolve", "local"], {
|
||||
mode: "ultra",
|
||||
schedule: { enabled: true, time: "03:00" },
|
||||
users: {},
|
||||
running: [],
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(projection.mock.calls.length).toBeGreaterThan(readsBeforeCompletion),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetClient();
|
||||
@@ -1235,7 +1283,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();
|
||||
});
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
retryAutoReconcile,
|
||||
useAutoConnect,
|
||||
} from "./connection-reconcile";
|
||||
import { localEvolveRunning, useEvolveState } from "./evolve-state";
|
||||
import { useGuiLanguage } from "./gui-language";
|
||||
import { HomeCardBoundary } from "./HomeCardBoundary";
|
||||
import {
|
||||
@@ -55,6 +56,7 @@ import {
|
||||
sourceConnectionIncomplete,
|
||||
useRecallHosts,
|
||||
} from "./host-state";
|
||||
import { useOnboardingTranslation } from "./onboarding-language";
|
||||
import { subscribePoll } from "./poll-scheduler";
|
||||
import { R6Button } from "./R6Controls";
|
||||
import { prefetchDataRuntime } from "./runtime-state";
|
||||
@@ -209,6 +211,10 @@ export function ApplicationsHome({
|
||||
active?: boolean;
|
||||
}) {
|
||||
const { locale, t, tn } = useGuiLanguage();
|
||||
const { text } = useOnboardingTranslation();
|
||||
const evolveState = useEvolveState(active);
|
||||
const evolveRunning = localEvolveRunning(evolveState.data);
|
||||
const evolveUncertain = evolveState.isError && evolveRunning;
|
||||
const hostQuery = useRecallHosts({ poll: active });
|
||||
const hostBridge = hostBridgeAvailable();
|
||||
const hosts = hostQuery.data ?? [];
|
||||
@@ -313,6 +319,12 @@ export function ApplicationsHome({
|
||||
const [expanded, setExpanded] = useState(retained.expanded);
|
||||
const [phase, setPhase] = useState("idle");
|
||||
const [revision, setRevision] = useState(0);
|
||||
const previousEvolveRunning = useRef(evolveRunning);
|
||||
useEffect(() => {
|
||||
const previous = previousEvolveRunning.current;
|
||||
previousEvolveRunning.current = evolveRunning;
|
||||
if (previous && !evolveRunning && active) setRevision((value) => value + 1);
|
||||
}, [active, evolveRunning]);
|
||||
const confirmedModeSeen = useRef(confirmedMode);
|
||||
const expandButton = useRef<HTMLButtonElement>(null);
|
||||
const backButton = useRef<HTMLButtonElement>(null);
|
||||
@@ -953,6 +965,11 @@ export function ApplicationsHome({
|
||||
<header>
|
||||
<h2>{t("apps.overview")}</h2>
|
||||
</header>
|
||||
{evolveRunning ? (
|
||||
<p className="r6-evolve-status" role="status">
|
||||
{text(evolveUncertain ? "正在核对整合状态…" : "正在整合记忆…")}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="r6-home-metrics">
|
||||
<div role="status" aria-live="polite" aria-atomic="true">
|
||||
<p>{t(viewModel.countLabel)}</p>
|
||||
|
||||
@@ -371,16 +371,14 @@ it("uses user-facing connection confirmation copy without implementation narrati
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("complementary", { name: "连接应用" })).toBeTruthy();
|
||||
expect(screen.getByRole("checkbox", { name: "一键接入应用" })).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"点击底部按钮后,后台分别接入各应用;某个应用失败不影响其他应用。",
|
||||
screen.queryByText(
|
||||
/点击底部按钮后|选择草稿|按各应用回执|本页任务控制尚未接通/,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByText(/选择草稿|按各应用回执|本页任务控制尚未接通/),
|
||||
).toBeNull();
|
||||
expect(screen.getByText(/下一步选择记忆模式/)).toBeTruthy();
|
||||
expect(screen.getByText(/之后仍可在设置中更改/)).toBeTruthy();
|
||||
expect(screen.queryByText(/下一步选择记忆模式/)).toBeNull();
|
||||
expect(screen.queryByText(/之后仍可在设置中更改/)).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["paused", "failed", "cancelled", "interrupted"] as const)(
|
||||
@@ -398,11 +396,7 @@ it.each(["paused", "failed", "cancelled", "interrupted"] as const)(
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "对话正在汇入,记忆从这里开始" }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/已保存的对话还在/, {
|
||||
selector: ".first-import-r6-footer-note p",
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText(/已保存的对话还在/)).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -439,9 +433,7 @@ it.each(["stale", "revoked", "missing"])(
|
||||
onContinue={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
if (kind === "missing")
|
||||
expect(screen.getByText("导入在后台继续,无需等待。")).toBeTruthy();
|
||||
else expect(screen.queryByText("导入在后台继续,无需等待。")).toBeNull();
|
||||
expect(screen.queryByText("导入在后台继续,无需等待。")).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "对话正在汇入,记忆从这里开始" }),
|
||||
).toBeTruthy();
|
||||
|
||||
@@ -199,8 +199,8 @@ it("keeps the saved count when authority or freshness changes, without a convers
|
||||
expect(view.container.querySelector('[data-running="true"]')).toBeTruthy();
|
||||
expect(screen.getByText("已保存 7 条消息")).toBeTruthy();
|
||||
expect(
|
||||
screen.getAllByText("正在重新核对来源;已保存数量仍按任务回执显示。"),
|
||||
).toHaveLength(1);
|
||||
screen.queryByText("正在重新核对来源;已保存数量仍按任务回执显示。"),
|
||||
).toBeNull();
|
||||
view.update({
|
||||
sourceSnapshot: { confirmedAt: "2026-09-14T00:00:02Z", stale: false },
|
||||
jobs: [{ ...job, stored: 20 }],
|
||||
|
||||
@@ -46,6 +46,7 @@ export function defaultHostSourceIds(
|
||||
export type ImportConnectionDraft = {
|
||||
hostSourceIds: readonly string[];
|
||||
};
|
||||
export type ImportSubmissionState = "confirmed" | "checking" | "refused";
|
||||
export interface FirstImportReviewProps {
|
||||
sourceIds: readonly string[];
|
||||
sources: SourceResponse[];
|
||||
@@ -54,6 +55,7 @@ export interface FirstImportReviewProps {
|
||||
acceptedJobIds?: readonly string[];
|
||||
failedSources?: Record<string, string>;
|
||||
sourceSnapshot?: { confirmedAt: string | null; stale: boolean };
|
||||
importSubmissionState?: ImportSubmissionState;
|
||||
busy?: boolean;
|
||||
error?: string | null;
|
||||
connectionNote?: string;
|
||||
@@ -78,6 +80,7 @@ export function FirstImportReview({
|
||||
acceptedJobIds,
|
||||
failedSources = {},
|
||||
sourceSnapshot,
|
||||
importSubmissionState = "confirmed",
|
||||
busy = false,
|
||||
error,
|
||||
connectionNote,
|
||||
@@ -153,26 +156,6 @@ export function FirstImportReview({
|
||||
ids.every((id) => batch.some((job) => job.source_id === id)) &&
|
||||
batch.every((job) => job.status === "completed") &&
|
||||
!Object.keys(failedSources).length;
|
||||
const allPaused =
|
||||
batch.length > 0 && batch.every((job) => job.status === "paused");
|
||||
const importing =
|
||||
!stale &&
|
||||
batch.some(
|
||||
(job) =>
|
||||
["pending", "running"].includes(job.status) &&
|
||||
sources.some(
|
||||
(source) => source.source_id === job.source_id && source.authorized,
|
||||
),
|
||||
);
|
||||
const displayImporting =
|
||||
importing ||
|
||||
(!stale &&
|
||||
!allCompleted &&
|
||||
!allPaused &&
|
||||
batch.length === 0 &&
|
||||
ids.some((id) =>
|
||||
sources.some((source) => source.source_id === id && source.authorized),
|
||||
));
|
||||
const title = "对话正在汇入,记忆从这里开始";
|
||||
const hostEligible = importHostEligible(sources, ids);
|
||||
const draftReady = !!connectionDraft && !!onConnectionDraftChange;
|
||||
@@ -282,23 +265,9 @@ export function FirstImportReview({
|
||||
<R6ColdStartFrame
|
||||
step={2}
|
||||
title={text(title)}
|
||||
eyebrow={text("导入进行中 · 顺手连接应用")}
|
||||
footer={
|
||||
<>
|
||||
<div className="first-import-r6-footer-note">
|
||||
<p>
|
||||
{text(
|
||||
allCompleted
|
||||
? "对话已保存,记忆尚待生成。"
|
||||
: displayImporting
|
||||
? "导入在后台继续,无需等待。"
|
||||
: stale
|
||||
? "正在重新核对来源;已保存数量仍按任务回执显示。"
|
||||
: allPaused
|
||||
? "本次历史导入已暂停,已保存的对话还在。"
|
||||
: "导入等待处理,已保存的对话还在。",
|
||||
)}
|
||||
</p>
|
||||
{(error || actionMessage) && (
|
||||
<p
|
||||
role={
|
||||
@@ -386,14 +355,18 @@ export function FirstImportReview({
|
||||
aria-label={text("导入与原文")}
|
||||
>
|
||||
<p className="first-import-r6-intro">
|
||||
{text(
|
||||
allCompleted
|
||||
? "本次导入已结束。已保存的对话无需重复导入。"
|
||||
: "对话先保存在本机。你可以同时设置后续连接。",
|
||||
)}
|
||||
{importSubmissionState === "checking"
|
||||
? t("onboardingImportChecking")
|
||||
: text(
|
||||
allCompleted
|
||||
? "本次导入已结束。已保存的对话无需重复导入。"
|
||||
: "对话先保存在本机。你可以同时设置后续连接。",
|
||||
)}
|
||||
</p>
|
||||
<p className="first-import-r6-subintro">
|
||||
{text("下一步选择记忆模式;之后仍可在设置中更改。")}
|
||||
{text(
|
||||
"导入不会自动开放应用读取权限。配置模型后,才会开始生成记忆。",
|
||||
)}
|
||||
</p>
|
||||
{historyWindowLimited && (
|
||||
<p className="first-import-r6-history-window" role="status">
|
||||
@@ -481,13 +454,11 @@ export function FirstImportReview({
|
||||
{text(connectionNote)}
|
||||
</p>
|
||||
)}
|
||||
<p className="first-import-r6-options-note">
|
||||
{text(
|
||||
draftReady
|
||||
? "点击底部按钮后,后台分别接入各应用;某个应用失败不影响其他应用。"
|
||||
: "暂时无法连接应用,当前不会安装连接。",
|
||||
)}
|
||||
</p>
|
||||
{!draftReady && (
|
||||
<p className="first-import-r6-options-note">
|
||||
{text("暂时无法连接应用,当前不会安装连接。")}
|
||||
</p>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</R6ColdStartFrame>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")}>
|
||||
|
||||
@@ -491,6 +491,7 @@ function Host({
|
||||
onViewHistory,
|
||||
onConfigureModel = () => {},
|
||||
libraryView,
|
||||
raw = false,
|
||||
}: {
|
||||
simplified?: boolean;
|
||||
date?: string | null;
|
||||
@@ -509,6 +510,7 @@ function Host({
|
||||
onViewHistory?(): void;
|
||||
onConfigureModel?(): void;
|
||||
libraryView?: Parameters<typeof MemoryMap>[0]["libraryView"];
|
||||
raw?: boolean;
|
||||
} = {}) {
|
||||
const [detail, setDetail] = useState<MemoryDetail | null>(null);
|
||||
const [detailLoad, setDetailLoad] = useState<{
|
||||
@@ -518,6 +520,7 @@ function Host({
|
||||
return (
|
||||
<MemoryMap
|
||||
simplified={simplified}
|
||||
contentKind={raw ? "raw" : "memory"}
|
||||
libraryView={libraryView}
|
||||
hidden={hidden}
|
||||
overlayBlocked={overlayBlocked}
|
||||
@@ -587,6 +590,61 @@ function Host({
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
it("windows raw rows and automatically continues near the bottom", () => {
|
||||
const items = Array.from({ length: 150 }, (_, index) => ({
|
||||
...memories[0],
|
||||
memory_id: `raw-${index}`,
|
||||
content: `原始会话 ${index}`,
|
||||
}));
|
||||
const onMore = vi.fn();
|
||||
const sourceIds = Object.fromEntries(
|
||||
items.map((item) => [item.memory_id, ["cursor"]]),
|
||||
);
|
||||
render(
|
||||
<Host
|
||||
simplified
|
||||
raw
|
||||
items={items}
|
||||
libraryView={{
|
||||
query: "",
|
||||
source: "",
|
||||
page: {
|
||||
items,
|
||||
total: 1_600,
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
source_ids_by_memory: sourceIds,
|
||||
},
|
||||
loading: false,
|
||||
failed: false,
|
||||
stale: false,
|
||||
cursorPaging: true,
|
||||
hasMore: true,
|
||||
onQuery: vi.fn(),
|
||||
onSource: vi.fn(),
|
||||
resetPage: vi.fn(),
|
||||
onPage: vi.fn(),
|
||||
onMore,
|
||||
onRetry: vi.fn(),
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const list = screen.getByRole("list", { name: "原始对话" });
|
||||
Object.defineProperties(list, {
|
||||
clientHeight: { configurable: true, value: 500 },
|
||||
scrollHeight: { configurable: true, value: 17_400 },
|
||||
scrollTop: { configurable: true, writable: true, value: 16_500 },
|
||||
});
|
||||
fireEvent.scroll(list);
|
||||
|
||||
expect(onMore).toHaveBeenCalledTimes(1);
|
||||
expect(document.querySelectorAll("[data-memory-id]").length).toBeLessThan(30);
|
||||
expect(screen.getByRole("button", { name: "加载更多" }).classList).toContain(
|
||||
"map-load-more",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not present an unverified empty page as zero memories", () => {
|
||||
const page = {
|
||||
items: [],
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
import type { GuiLocale } from "@one-memory/i18n";
|
||||
import {
|
||||
ArrowUp,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
@@ -110,12 +111,15 @@ type Props = {
|
||||
settledSource?: string;
|
||||
settledQuery?: string;
|
||||
requestedOffset?: number;
|
||||
cursorPaging?: boolean;
|
||||
hasMore?: boolean;
|
||||
/** The source id the SDK uses for "no source given" (shown as 未识别来源). */
|
||||
unattributedSource?: string;
|
||||
onQuery(value: string): void;
|
||||
onSource(value: string): void;
|
||||
resetPage(): void;
|
||||
onPage(offset: number): void;
|
||||
onMore?(): void;
|
||||
onRetry(): void;
|
||||
};
|
||||
};
|
||||
@@ -176,6 +180,8 @@ const SCENE_LABELS: Record<SdkMemoryScene, string> = {
|
||||
working: "工作",
|
||||
coding: "编码",
|
||||
};
|
||||
const RAW_ROW_HEIGHT = 116;
|
||||
const RAW_LIST_OVERSCAN = 6;
|
||||
|
||||
export function MemoryMap(props: Props) {
|
||||
const { locale, text, count, plain } = useMemoryLanguage();
|
||||
@@ -254,6 +260,10 @@ export function MemoryMap(props: Props) {
|
||||
const pane = useRef<HTMLElement>(null);
|
||||
const memoryList = useRef<HTMLUListElement>(null);
|
||||
const inspectorBody = useRef<HTMLDivElement>(null);
|
||||
const [rawListWindow, setRawListWindow] = useState({
|
||||
start: 0,
|
||||
end: 20,
|
||||
});
|
||||
const openedDefault = useRef(false);
|
||||
const scrollSelection = useRef(false);
|
||||
const controlledSource = library
|
||||
@@ -339,6 +349,33 @@ export function MemoryMap(props: Props) {
|
||||
),
|
||||
[active, group, search, serverPaged, axis, groupKey],
|
||||
);
|
||||
const rawWindowed = raw && !!library?.cursorPaging && filtered.length > 100;
|
||||
const visibleMemories = rawWindowed
|
||||
? filtered.slice(rawListWindow.start, rawListWindow.end)
|
||||
: filtered;
|
||||
const rawTopSpace = rawWindowed ? rawListWindow.start * RAW_ROW_HEIGHT : 0;
|
||||
const rawBottomSpace = rawWindowed
|
||||
? Math.max(0, filtered.length - rawListWindow.end) * RAW_ROW_HEIGHT
|
||||
: 0;
|
||||
const updateRawListWindow = useCallback(
|
||||
(element: HTMLUListElement) => {
|
||||
if (!rawWindowed) return;
|
||||
const first = Math.max(
|
||||
0,
|
||||
Math.floor(element.scrollTop / RAW_ROW_HEIGHT) - RAW_LIST_OVERSCAN,
|
||||
);
|
||||
const visible = Math.ceil(element.clientHeight / RAW_ROW_HEIGHT);
|
||||
setRawListWindow({
|
||||
start: first,
|
||||
end: Math.min(filtered.length, first + visible + RAW_LIST_OVERSCAN * 2),
|
||||
});
|
||||
},
|
||||
[filtered.length, rawWindowed],
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
if (rawWindowed && memoryList.current)
|
||||
updateRawListWindow(memoryList.current);
|
||||
}, [rawWindowed, updateRawListWindow]);
|
||||
const projection = useMemo(() => {
|
||||
// Until the new scope's graph arrives (or after it failed), drawing the
|
||||
// previous scope's graph would show the wrong source.
|
||||
@@ -354,7 +391,7 @@ export function MemoryMap(props: Props) {
|
||||
// left-hand list window. Projecting the graph back down to the current
|
||||
// 20-row page made "All applications" look like whichever source happened
|
||||
// to dominate that page. Keyword search is intentionally page-scoped.
|
||||
if (serverPaged && !library?.query) {
|
||||
if (serverPaged && (!library?.query || raw)) {
|
||||
return (
|
||||
baseGraph ??
|
||||
projectMap(
|
||||
@@ -406,6 +443,7 @@ export function MemoryMap(props: Props) {
|
||||
search,
|
||||
sources,
|
||||
serverPaged,
|
||||
raw,
|
||||
library?.query,
|
||||
library?.graphLoading,
|
||||
library?.graphFailed,
|
||||
@@ -1023,11 +1061,42 @@ export function MemoryMap(props: Props) {
|
||||
)}
|
||||
<ul
|
||||
ref={memoryList}
|
||||
className="map-memory-list"
|
||||
className={`map-memory-list${raw ? " raw" : ""}`}
|
||||
aria-label={raw ? t("memory:原始对话") : text("地图记忆列表")}
|
||||
id="memory-pane-memories"
|
||||
onScroll={(event) => {
|
||||
const element = event.currentTarget;
|
||||
updateRawListWindow(element);
|
||||
if (
|
||||
library?.cursorPaging &&
|
||||
library.hasMore &&
|
||||
!pageTransitioning &&
|
||||
element.scrollHeight - element.scrollTop - element.clientHeight <
|
||||
element.clientHeight
|
||||
)
|
||||
library.onMore?.();
|
||||
}}
|
||||
onWheel={(event) => {
|
||||
const element = event.currentTarget;
|
||||
if (
|
||||
event.deltaY > 0 &&
|
||||
library?.cursorPaging &&
|
||||
library.hasMore &&
|
||||
!pageTransitioning &&
|
||||
element.scrollHeight - element.scrollTop - element.clientHeight <
|
||||
8
|
||||
)
|
||||
library.onMore?.();
|
||||
}}
|
||||
>
|
||||
{filtered.map((m) => (
|
||||
{rawTopSpace ? (
|
||||
<li
|
||||
className="map-memory-spacer"
|
||||
style={{ height: rawTopSpace }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
{visibleMemories.map((m) => (
|
||||
<li key={m.memory_id} data-memory-id={m.memory_id}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1083,6 +1152,13 @@ export function MemoryMap(props: Props) {
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{rawBottomSpace ? (
|
||||
<li
|
||||
className="map-memory-spacer"
|
||||
style={{ height: rawBottomSpace }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
</ul>
|
||||
{library?.failed && (
|
||||
<div role="alert">
|
||||
@@ -1131,62 +1207,93 @@ export function MemoryMap(props: Props) {
|
||||
aria-busy={pageTransitioning || undefined}
|
||||
className="map-pagination"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="quiet map-tool"
|
||||
aria-label={text("上一页记忆")}
|
||||
title={text("上一页记忆")}
|
||||
disabled={pageTransitioning || library.page.offset === 0}
|
||||
onClick={() => {
|
||||
if (axis === "topic") setGroupKey("");
|
||||
if (library.page) {
|
||||
const offset = library.page.offset - library.page.limit;
|
||||
setPageRetrying(false);
|
||||
pageRetrySawLoading.current = false;
|
||||
setPendingPageOffset(offset);
|
||||
library.onPage(offset);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChevronLeft size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<span>
|
||||
{text(
|
||||
"共 {count} 条,第 {page} / {pages} 页",
|
||||
{
|
||||
count: count(library.page.total),
|
||||
page:
|
||||
library.page.total === 0
|
||||
? 0
|
||||
: Math.floor(library.page.offset / library.page.limit) +
|
||||
1,
|
||||
pages: Math.ceil(library.page.total / library.page.limit),
|
||||
},
|
||||
library.page.total,
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="quiet map-tool"
|
||||
aria-label={text("下一页记忆")}
|
||||
title={text("下一页记忆")}
|
||||
disabled={
|
||||
pageTransitioning ||
|
||||
library.page.offset + library.page.limit >= library.page.total
|
||||
}
|
||||
onClick={() => {
|
||||
if (axis === "topic") setGroupKey("");
|
||||
if (library.page) {
|
||||
const offset = library.page.offset + library.page.limit;
|
||||
setPageRetrying(false);
|
||||
pageRetrySawLoading.current = false;
|
||||
setPendingPageOffset(offset);
|
||||
library.onPage(offset);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChevronRight size={16} aria-hidden="true" />
|
||||
</button>
|
||||
{library.cursorPaging ? (
|
||||
library.hasMore ? (
|
||||
<button
|
||||
type="button"
|
||||
className="map-load-more"
|
||||
disabled={pageTransitioning}
|
||||
aria-label={
|
||||
pageTransitioning ? text("正在读取…") : text("加载更多")
|
||||
}
|
||||
title={
|
||||
pageTransitioning ? text("正在读取…") : text("加载更多")
|
||||
}
|
||||
onClick={() => library.onMore?.()}
|
||||
>
|
||||
<ChevronDown
|
||||
size={18}
|
||||
aria-hidden="true"
|
||||
className={pageTransitioning ? "loading" : undefined}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span>{text("已加载全部")}</span>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="quiet map-tool"
|
||||
aria-label={text("上一页记忆")}
|
||||
title={text("上一页记忆")}
|
||||
disabled={pageTransitioning || library.page.offset === 0}
|
||||
onClick={() => {
|
||||
if (axis === "topic") setGroupKey("");
|
||||
if (library.page) {
|
||||
const offset = library.page.offset - library.page.limit;
|
||||
setPageRetrying(false);
|
||||
pageRetrySawLoading.current = false;
|
||||
setPendingPageOffset(offset);
|
||||
library.onPage(offset);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChevronLeft size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<span>
|
||||
{text(
|
||||
"共 {count} 条,第 {page} / {pages} 页",
|
||||
{
|
||||
count: count(library.page.total),
|
||||
page:
|
||||
library.page.total === 0
|
||||
? 0
|
||||
: Math.floor(
|
||||
library.page.offset / library.page.limit,
|
||||
) + 1,
|
||||
pages: Math.ceil(
|
||||
library.page.total / library.page.limit,
|
||||
),
|
||||
},
|
||||
library.page.total,
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="quiet map-tool"
|
||||
aria-label={text("下一页记忆")}
|
||||
title={text("下一页记忆")}
|
||||
disabled={
|
||||
pageTransitioning ||
|
||||
library.page.offset + library.page.limit >=
|
||||
library.page.total
|
||||
}
|
||||
onClick={() => {
|
||||
if (axis === "topic") setGroupKey("");
|
||||
if (library.page) {
|
||||
const offset = library.page.offset + library.page.limit;
|
||||
setPageRetrying(false);
|
||||
pageRetrySawLoading.current = false;
|
||||
setPendingPageOffset(offset);
|
||||
library.onPage(offset);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChevronRight size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
)}
|
||||
{!library && loaded && !filtered.length && (search || groupKey) && (
|
||||
@@ -1213,19 +1320,21 @@ export function MemoryMap(props: Props) {
|
||||
<h2>
|
||||
{props.simplified
|
||||
? raw
|
||||
? t("memory:原始对话")
|
||||
? text("来源归属")
|
||||
: text("记忆关联")
|
||||
: label}
|
||||
</h2>
|
||||
{props.simplified && (
|
||||
<p className="selected-graph-subtitle">
|
||||
{projection.nodes.length
|
||||
? text("来源归属")
|
||||
: raw
|
||||
? t("sdk.raw.empty")
|
||||
: text("可在沉淀记忆后查看视图")}
|
||||
</p>
|
||||
)}
|
||||
{props.simplified &&
|
||||
(!raw || !projection.nodes.length) &&
|
||||
!(raw && library?.graphLoading) && (
|
||||
<p className="selected-graph-subtitle">
|
||||
{projection.nodes.length
|
||||
? text("来源归属")
|
||||
: raw
|
||||
? t("sdk.raw.empty")
|
||||
: text("可在沉淀记忆后查看视图")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{compact && (activeId || selectedSource) && !inspectorOpen && (
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { LanguageProvider } from "./gui-language";
|
||||
import { MemoryModeCards } from "./MemoryModeCards";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it("shows every mode feature and reveals Ultra scheduling after selection", async () => {
|
||||
const onChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
const view = (value: "lite" | "pro" | "ultra") => (
|
||||
<LanguageProvider>
|
||||
<MemoryModeCards
|
||||
aria-label="记忆模式"
|
||||
value={value}
|
||||
layout="rows"
|
||||
onChange={onChange}
|
||||
ultraDetail={
|
||||
<div>
|
||||
<strong>每天自动整合</strong>
|
||||
<small>默认 03:00,可在设置中修改。</small>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</LanguageProvider>
|
||||
);
|
||||
const { rerender } = render(view("pro"));
|
||||
|
||||
expect(screen.getByRole("radiogroup", { name: "记忆模式" })).toBeTruthy();
|
||||
expect(screen.getAllByRole("radio")).toHaveLength(3);
|
||||
expect(screen.getByText("不调用生成模型")).toBeTruthy();
|
||||
expect(screen.getByText("沉淀高级记忆")).toBeTruthy();
|
||||
expect(screen.getByText("增强关联召回")).toBeTruthy();
|
||||
expect(screen.getByText("· Pro").className).toContain("memory-mode-tier");
|
||||
expect(screen.queryByText("每天自动整合")).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole("radio", { name: /深度记忆/ }));
|
||||
expect(onChange).toHaveBeenCalledWith("ultra", expect.any(HTMLInputElement));
|
||||
|
||||
rerender(view("ultra"));
|
||||
expect(screen.getByText("每天自动整合")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("preserves per-mode disabled behavior", () => {
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<MemoryModeCards
|
||||
aria-label="记忆模式"
|
||||
value="lite"
|
||||
disabled={(mode) => mode !== "lite"}
|
||||
onChange={vi.fn()}
|
||||
/>
|
||||
</LanguageProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("radio", { name: /基础记忆/ })).toHaveProperty(
|
||||
"disabled",
|
||||
false,
|
||||
);
|
||||
expect(screen.getByRole("radio", { name: /智能记忆/ })).toHaveProperty(
|
||||
"disabled",
|
||||
true,
|
||||
);
|
||||
expect(screen.getByRole("radio", { name: /深度记忆/ })).toHaveProperty(
|
||||
"disabled",
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { MemoryPipelineMode } from "@one-memory/contracts";
|
||||
import { Check, Clock3 } from "lucide-react";
|
||||
import { type ReactNode, useId } from "react";
|
||||
import { MODE_COPY, MODE_TIER, modeDisplayName } from "./memory-mode-ui";
|
||||
import { useOnboardingTranslation } from "./onboarding-language";
|
||||
import "./memory-mode-cards.css";
|
||||
|
||||
const MODES: Array<{
|
||||
id: MemoryPipelineMode;
|
||||
index: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
id: "lite",
|
||||
index: "01",
|
||||
description: "不调用生成模型。保存并检索原始对话,不再生成高级记忆。",
|
||||
},
|
||||
{
|
||||
id: "pro",
|
||||
index: "02",
|
||||
description: "使用当前模型从新对话中提取事实、偏好和计划。",
|
||||
},
|
||||
{
|
||||
id: "ultra",
|
||||
index: "03",
|
||||
description: "在智能记忆之上使用知识图谱,并默认每天 03:00 自动整合记忆。",
|
||||
},
|
||||
];
|
||||
|
||||
export function MemoryModeCards({
|
||||
value,
|
||||
current,
|
||||
currentBadge,
|
||||
disabled = false,
|
||||
layout = "grid",
|
||||
describedBy,
|
||||
onChange,
|
||||
ultraDetail,
|
||||
ultraDetailBusy = false,
|
||||
"aria-label": ariaLabel,
|
||||
"aria-busy": ariaBusy,
|
||||
}: {
|
||||
value: MemoryPipelineMode | null;
|
||||
current?: MemoryPipelineMode;
|
||||
currentBadge?: ReactNode;
|
||||
disabled?: boolean | ((mode: MemoryPipelineMode) => boolean);
|
||||
layout?: "grid" | "rows";
|
||||
describedBy?: Partial<Record<MemoryPipelineMode, string | undefined>>;
|
||||
onChange(mode: MemoryPipelineMode, control: HTMLInputElement): void;
|
||||
ultraDetail?: ReactNode;
|
||||
ultraDetailBusy?: boolean;
|
||||
"aria-label": string;
|
||||
"aria-busy"?: boolean;
|
||||
}) {
|
||||
const { text } = useOnboardingTranslation();
|
||||
const name = useId();
|
||||
const unavailable = (mode: MemoryPipelineMode) =>
|
||||
typeof disabled === "function" ? disabled(mode) : disabled;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`memory-mode-cards memory-mode-cards--${layout}`}
|
||||
role="radiogroup"
|
||||
aria-label={ariaLabel}
|
||||
aria-busy={ariaBusy || undefined}
|
||||
>
|
||||
<div className="memory-mode-card-list">
|
||||
{MODES.map((mode) => {
|
||||
const selected = value === mode.id;
|
||||
const modeDisabled = unavailable(mode.id);
|
||||
const title = modeDisplayName(text, mode.id);
|
||||
const tier = MODE_TIER[mode.id];
|
||||
const features = text(MODE_COPY[mode.id].description).split(
|
||||
/\s*·\s*/,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={mode.id}
|
||||
className="memory-mode-card"
|
||||
data-mode={mode.id}
|
||||
data-selected={selected || undefined}
|
||||
data-current={current === mode.id || undefined}
|
||||
data-disabled={modeDisabled || undefined}
|
||||
>
|
||||
<label className="memory-mode-card-choice">
|
||||
<input
|
||||
type="radio"
|
||||
name={name}
|
||||
value={mode.id}
|
||||
aria-label={`${title} · ${tier}`}
|
||||
aria-describedby={describedBy?.[mode.id]}
|
||||
checked={selected}
|
||||
disabled={modeDisabled}
|
||||
onChange={(event) => onChange(mode.id, event.currentTarget)}
|
||||
/>
|
||||
<span className="memory-mode-title">
|
||||
<span className="memory-mode-index">{mode.index}</span>
|
||||
<strong>{title}</strong>
|
||||
<span className="memory-mode-tier">· {tier}</span>
|
||||
{current === mode.id && currentBadge ? (
|
||||
<span className="memory-mode-current">{currentBadge}</span>
|
||||
) : null}
|
||||
<span className="memory-mode-radio" aria-hidden="true">
|
||||
{selected ? <Check size={13} /> : null}
|
||||
</span>
|
||||
</span>
|
||||
<span className="memory-mode-description">
|
||||
{text(mode.description)}
|
||||
</span>
|
||||
<span className="memory-mode-features">
|
||||
{features.map((feature) => (
|
||||
<span key={feature}>
|
||||
<i aria-hidden="true" />
|
||||
{text(feature)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</label>
|
||||
{mode.id === "ultra" && selected && ultraDetail ? (
|
||||
<div
|
||||
className="memory-mode-detail"
|
||||
aria-busy={ultraDetailBusy || undefined}
|
||||
>
|
||||
<Clock3 size={14} aria-hidden="true" />
|
||||
<div>{ultraDetail}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { legacyMemoryModePlan } from "@one-memory/operations";
|
||||
import {
|
||||
cleanup,
|
||||
render,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
setMemoryMode,
|
||||
} from "./api";
|
||||
import { MemoryModeSettings } from "./MemoryModeSettings";
|
||||
import { resetClient } from "./query-client";
|
||||
|
||||
vi.mock("./api", () => ({
|
||||
getMemoryMode: vi.fn(),
|
||||
@@ -64,60 +66,69 @@ 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(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
name: RegExp,
|
||||
) {
|
||||
const option = await screen.findByRole("radio", { name });
|
||||
const control = await screen.findByRole("combobox", {
|
||||
name: "记忆模式",
|
||||
});
|
||||
await user.click(control);
|
||||
const option = await screen.findByRole("option", { name });
|
||||
await user.click(option);
|
||||
return option;
|
||||
return control;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetClient();
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(apiErrorCode).mockReturnValue(null);
|
||||
vi.mocked(memoryModePlanUnsupported).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("renders all settings modes as one grouped shared choice list", async () => {
|
||||
it("shows the current mode in a compact dropdown", async () => {
|
||||
prepare("pro");
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
|
||||
const group = await screen.findByRole("radiogroup", {
|
||||
name: "当前模式",
|
||||
const control = await screen.findByRole("combobox", {
|
||||
name: "记忆模式",
|
||||
});
|
||||
expect(within(group).getAllByRole("radio")).toHaveLength(3);
|
||||
expect(control.textContent).toContain("智能记忆 · Pro");
|
||||
expect(control.textContent).toContain("沉淀高级记忆");
|
||||
expect(
|
||||
within(group).getByText("当前模式").closest("label")?.textContent,
|
||||
).toContain("智能记忆(pro)");
|
||||
control.querySelector(".om-option-menu-suffix")?.textContent,
|
||||
).toContain("· Pro");
|
||||
expect(control.textContent).not.toContain("(");
|
||||
expect(screen.queryByText("当前模式")).toBeNull();
|
||||
expect(screen.queryByRole("radiogroup")).toBeNull();
|
||||
|
||||
await user.click(control);
|
||||
expect(screen.getAllByRole("option")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("shows the effective Ultra mode and disables selection when forced by the environment", async () => {
|
||||
prepare("ultra");
|
||||
vi.mocked(getMemoryMode).mockResolvedValue({
|
||||
...state("ultra"),
|
||||
configured_mode: "pro",
|
||||
forced_by_env: true,
|
||||
});
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
|
||||
const control = await screen.findByRole("combobox", {
|
||||
name: "记忆模式",
|
||||
});
|
||||
expect(control.textContent).toContain("深度记忆 · Ultra");
|
||||
expect(control).toHaveProperty("disabled", true);
|
||||
expect(screen.getByRole("switch", { name: "每天自动整合" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("confirms the data effects before switching to lite", async () => {
|
||||
@@ -130,37 +141,35 @@ it("confirms the data effects before switching to lite", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
|
||||
await chooseMode(user, /基础记忆(lite)/);
|
||||
await chooseMode(user, /基础记忆 · Lite/);
|
||||
expect(screen.getByText(/已有记忆继续保留/)).toBeTruthy();
|
||||
expect(screen.getByText("模型调用与费用")).toBeTruthy();
|
||||
expect(screen.getByText("搜索范围")).toBeTruthy();
|
||||
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(
|
||||
screen.getByRole("radio", { name: /基础记忆(lite)/ }),
|
||||
screen.getByRole("combobox", { name: "记忆模式" }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("traps modal focus, closes on Escape and restores the mode-card focus", async () => {
|
||||
it("traps modal focus, closes on Escape and restores the mode dropdown", async () => {
|
||||
prepare("pro");
|
||||
const outside = document.createElement("button");
|
||||
document.body.append(outside);
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
const lite = await chooseMode(user, /基础记忆(lite)/);
|
||||
const lite = await chooseMode(user, /基础记忆 · Lite/);
|
||||
|
||||
const cancel = screen.getByRole("button", { name: "取消" });
|
||||
await waitFor(() => expect(document.activeElement).toBe(cancel));
|
||||
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}");
|
||||
|
||||
@@ -175,10 +184,8 @@ it("keeps focus inside the modal while the mode change is pending", async () =>
|
||||
vi.mocked(setMemoryMode).mockReturnValue(new Promise(() => {}));
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
await chooseMode(user, /基础记忆(lite)/);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "切换到基础记忆(lite)" }),
|
||||
);
|
||||
await chooseMode(user, /基础记忆 · Lite/);
|
||||
await user.click(screen.getByRole("button", { name: "确认选择" }));
|
||||
|
||||
const modal = screen.getByRole("dialog");
|
||||
await waitFor(() => expect(document.activeElement).toBe(modal));
|
||||
@@ -208,7 +215,7 @@ it("routes to model setup instead of switching pro without a model", async () =>
|
||||
<MemoryModeSettings hasActiveModel={false} onConfigureModel={configure} />,
|
||||
);
|
||||
|
||||
await chooseMode(user, /智能记忆(pro)/);
|
||||
await chooseMode(user, /智能记忆 · Pro/);
|
||||
expect(configure).toHaveBeenCalledExactlyOnceWith(
|
||||
"使用 pro 或 ultra 前需要先配置并启用生成模型。",
|
||||
);
|
||||
@@ -222,23 +229,57 @@ it("uses the isolated legacy plan only for a route-level old-Core 404", async ()
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
|
||||
await chooseMode(user, /基础记忆(lite)/);
|
||||
await chooseMode(user, /基础记忆 · Lite/);
|
||||
expect(planMemoryMode).toHaveBeenCalledWith("lite");
|
||||
expect(await screen.findByText(/已有记忆继续保留/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("restores focus to the mode dropdown when planning fails", async () => {
|
||||
prepare("pro");
|
||||
vi.mocked(planMemoryMode).mockRejectedValue(new Error("offline"));
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
|
||||
await chooseMode(user, /基础记忆 · Lite/);
|
||||
|
||||
expect(
|
||||
await screen.findByText("模式选择影响暂时无法读取,请重试。"),
|
||||
).toBeTruthy();
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement).toBe(
|
||||
screen.getByRole("combobox", { name: "记忆模式" }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps downgrade effects when an old Core falls back from ultra to pro", async () => {
|
||||
prepare("ultra");
|
||||
vi.mocked(planMemoryMode).mockRejectedValue(new Error("404"));
|
||||
vi.mocked(memoryModePlanUnsupported).mockReturnValue(true);
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
|
||||
await chooseMode(user, /智能记忆 · Pro/);
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(within(dialog).getAllByText("已有数据")).toHaveLength(1);
|
||||
expect(within(dialog).getByText("每天自动整合")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("explains model cost, search scope and the next step before switching to ultra", async () => {
|
||||
prepare("lite");
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
|
||||
await chooseMode(user, /深度记忆(ultra)/);
|
||||
await chooseMode(user, /深度记忆 · Ultra/);
|
||||
|
||||
expect(screen.getByText("模型调用与费用")).toBeTruthy();
|
||||
expect(screen.getByText("搜索范围")).toBeTruthy();
|
||||
expect(screen.getByText("切换后")).toBeTruthy();
|
||||
expect(screen.getByText("选择后")).toBeTruthy();
|
||||
expect(screen.getByText(/已经处理完的旧对话不会补做/)).toBeTruthy();
|
||||
expect(screen.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 () => {
|
||||
@@ -246,11 +287,29 @@ it("distinguishes regular memories from higher-level Ultra data when switching t
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
|
||||
await chooseMode(user, /智能记忆(pro)/);
|
||||
await chooseMode(user, /智能记忆 · Pro/);
|
||||
|
||||
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 () => {
|
||||
@@ -262,13 +321,11 @@ it("focuses the Evolve settings after switching to ultra", async () => {
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
await screen.findByRole("radio", { name: /智能记忆(pro)/ });
|
||||
await screen.findByRole("combobox", { name: "记忆模式" });
|
||||
vi.mocked(getMemoryMode).mockResolvedValue(state("ultra"));
|
||||
|
||||
await chooseMode(user, /深度记忆(ultra)/);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "切换到深度记忆(ultra)" }),
|
||||
);
|
||||
await chooseMode(user, /深度记忆 · Ultra/);
|
||||
await user.click(screen.getByRole("button", { name: "确认选择" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement?.getAttribute("id")).toContain("-evolve"),
|
||||
@@ -278,6 +335,29 @@ it("focuses the Evolve settings after switching to ultra", async () => {
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("focuses the recoverable Evolve section when Ultra schedule loading fails", async () => {
|
||||
prepare("pro");
|
||||
vi.mocked(getEvolveSchedule).mockRejectedValue(new Error("offline"));
|
||||
vi.mocked(setMemoryMode).mockResolvedValue({
|
||||
...state("ultra"),
|
||||
previous_mode: "pro",
|
||||
effects: ["existing_memories_added_to_graph"],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
await screen.findByRole("combobox", { name: "记忆模式" });
|
||||
vi.mocked(getMemoryMode).mockResolvedValue(state("ultra"));
|
||||
|
||||
await chooseMode(user, /深度记忆 · Ultra/);
|
||||
await user.click(screen.getByRole("button", { name: "确认选择" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement?.getAttribute("id")).toContain("-evolve"),
|
||||
);
|
||||
expect(screen.getByText("自动整合设置暂时无法读取,请重试。")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the default nightly evolve controls in ultra", async () => {
|
||||
prepare("ultra");
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
@@ -287,13 +367,151 @@ 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("keeps the immediate action visible and recovers when the schedule read fails", async () => {
|
||||
prepare("ultra");
|
||||
vi.mocked(getEvolveSchedule).mockRejectedValueOnce(new Error("offline"));
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
|
||||
expect(
|
||||
await screen.findByText("自动整合设置暂时无法读取,请重试。"),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByRole("switch", { name: "每天自动整合" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "重新读取" }));
|
||||
|
||||
const automatic = await screen.findByRole("switch", {
|
||||
name: "每天自动整合",
|
||||
});
|
||||
await waitFor(() => expect(document.activeElement).toBe(automatic));
|
||||
});
|
||||
|
||||
it("opens the time picker from the whole control and saves a complete time", async () => {
|
||||
prepare("ultra");
|
||||
vi.mocked(setEvolveSchedule).mockResolvedValue({
|
||||
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({
|
||||
@@ -309,16 +527,16 @@ 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();
|
||||
});
|
||||
|
||||
it("updates mode state automatically without showing a manual refresh control", async () => {
|
||||
prepare("pro");
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
const pro = await screen.findByRole("radio", { name: /智能记忆(pro)/ });
|
||||
const mode = await screen.findByRole("combobox", { name: "记忆模式" });
|
||||
|
||||
expect((pro as HTMLInputElement).checked).toBe(true);
|
||||
expect(mode.textContent).toContain("智能记忆 · Pro");
|
||||
expect(screen.queryByRole("button", { name: "刷新记忆模式" })).toBeNull();
|
||||
expect(getMemoryMode).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => {
|
||||
@@ -332,9 +550,9 @@ it("keeps the mode usable when an evolve status read fails", async () => {
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
|
||||
expect(
|
||||
(await screen.findByRole("radio", {
|
||||
name: /智能记忆(pro)/,
|
||||
})) as HTMLInputElement,
|
||||
await screen.findByRole("combobox", {
|
||||
name: "记忆模式",
|
||||
}),
|
||||
).toHaveProperty("disabled", false);
|
||||
});
|
||||
|
||||
@@ -347,15 +565,17 @@ it("offers a text recovery action when the mode read fails", async () => {
|
||||
expect(
|
||||
await screen.findByText("记忆模式状态暂时无法读取,请重试。"),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("radio", { name: /基础记忆(lite)/ }),
|
||||
).toHaveProperty("disabled", true);
|
||||
expect(screen.getByRole("combobox", { name: "记忆模式" })).toHaveProperty(
|
||||
"disabled",
|
||||
true,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "重新读取" }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("radio", { name: /智能记忆(pro)/ }),
|
||||
).toHaveProperty("disabled", false),
|
||||
expect(screen.getByRole("combobox", { name: "记忆模式" })).toHaveProperty(
|
||||
"disabled",
|
||||
false,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -366,15 +586,13 @@ it("keeps a failed switch explanation inside the open confirmation dialog", asyn
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
|
||||
await chooseMode(user, /深度记忆(ultra)/);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "切换到深度记忆(ultra)" }),
|
||||
);
|
||||
await chooseMode(user, /深度记忆 · Ultra/);
|
||||
await user.click(screen.getByRole("button", { name: "确认选择" }));
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(
|
||||
await within(dialog).findByText(
|
||||
"当前安装缺少知识图谱组件,无法开启 ultra。",
|
||||
"当前安装缺少知识图谱组件,无法选择 ultra。",
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
@@ -389,15 +607,17 @@ it("keeps lite available while model status is unknown", async () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(await screen.findByRole("combobox", { name: "记忆模式" }));
|
||||
expect(
|
||||
await screen.findByRole("radio", { name: /基础记忆(lite)/ }),
|
||||
await screen.findByRole("option", { name: /基础记忆 · Lite/ }),
|
||||
).toHaveProperty("disabled", false);
|
||||
expect(screen.getByRole("radio", { name: /智能记忆(pro)/ })).toHaveProperty(
|
||||
expect(screen.getByRole("option", { name: /智能记忆 · Pro/ })).toHaveProperty(
|
||||
"disabled",
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("radio", { name: /深度记忆(ultra)/ }),
|
||||
screen.getByRole("option", { name: /深度记忆 · Ultra/ }),
|
||||
).toHaveProperty("disabled", true);
|
||||
});
|
||||
|
||||
@@ -410,12 +630,13 @@ it("blocks mode and Evolve controls during embedding maintenance", async () => {
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"正在检查或切换向量模型,完成前不能切换模式或手动整合。",
|
||||
"正在检查或切换向量模型,完成前不能选择模式或手动整合。",
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("radio", { name: /基础记忆(lite)/ }),
|
||||
).toHaveProperty("disabled", true);
|
||||
expect(screen.getByRole("combobox", { name: "记忆模式" })).toHaveProperty(
|
||||
"disabled",
|
||||
true,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "立即整合" })).toHaveProperty(
|
||||
"disabled",
|
||||
true,
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import type {
|
||||
EvolveOverview,
|
||||
EvolveRunReceipt,
|
||||
EvolveSchedule,
|
||||
MemoryModeChange,
|
||||
MemoryModePlan,
|
||||
@@ -11,7 +9,6 @@ import { legacyMemoryModePlan } from "@one-memory/operations";
|
||||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
||||
import {
|
||||
apiErrorCode,
|
||||
getEvolveOverview,
|
||||
getEvolveSchedule,
|
||||
getMemoryMode,
|
||||
getMemoryProcessing,
|
||||
@@ -25,94 +22,19 @@ import {
|
||||
commitConfirmedMemoryMode,
|
||||
issueConfirmedModeTicket,
|
||||
} from "./confirmed-memory-mode";
|
||||
import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
|
||||
import { commitEvolveRun, useEvolveState } from "./evolve-state";
|
||||
import {
|
||||
BLOCKER_COPY,
|
||||
MemoryModeConfirmDialog,
|
||||
MODE_COPY,
|
||||
MODE_TIER,
|
||||
modeDisplayName,
|
||||
} from "./memory-mode-ui";
|
||||
import { OptionMenu } from "./OptionMenu";
|
||||
import { useOnboardingTranslation } from "./onboarding-language";
|
||||
import { R6Button, 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,
|
||||
@@ -129,7 +51,11 @@ export function MemoryModeSettings({
|
||||
const id = useId();
|
||||
const [state, setState] = useState<MemoryModeState | null>(null);
|
||||
const [schedule, setScheduleState] = useState<EvolveSchedule | null>(null);
|
||||
const [overview, setOverview] = useState<EvolveOverview | null>(null);
|
||||
const evolveState = useEvolveState();
|
||||
const overview = evolveState.data ?? null;
|
||||
const run =
|
||||
overview?.running.find((candidate) => candidate.user_id === "local") ??
|
||||
null;
|
||||
const [pendingMode, setPendingMode] = useState<MemoryPipelineMode | null>(
|
||||
null,
|
||||
);
|
||||
@@ -138,94 +64,47 @@ export function MemoryModeSettings({
|
||||
const [maintenance, setMaintenance] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [readFailed, setReadFailed] = useState(false);
|
||||
const [run, setRun] = useState<EvolveRunReceipt | null>(null);
|
||||
const [scheduleReadFailed, setScheduleReadFailed] = useState(false);
|
||||
const refreshVersion = useRef(0);
|
||||
const 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]);
|
||||
const focusScheduleWhenReady = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusEvolveWhenReady.current || pendingMode || state?.mode !== "ultra")
|
||||
return;
|
||||
focusEvolveWhenReady.current = false;
|
||||
document.getElementById(`${id}-evolve`)?.focus();
|
||||
const evolve = document.getElementById(`${id}-evolve`);
|
||||
if (evolve) {
|
||||
focusEvolveWhenReady.current = false;
|
||||
evolve.focus();
|
||||
}
|
||||
}, [id, pendingMode, state?.mode]);
|
||||
|
||||
useEffect(() => {
|
||||
const target = focusModeWhenReady.current;
|
||||
if (!target || busy || pendingMode || state?.mode !== target) return;
|
||||
focusModeWhenReady.current = null;
|
||||
document.getElementById(`${id}-select`)?.focus();
|
||||
}, [busy, id, pendingMode, state?.mode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusScheduleWhenReady.current || scheduleReadFailed || !schedule)
|
||||
return;
|
||||
focusScheduleWhenReady.current = false;
|
||||
document
|
||||
.querySelector<HTMLInputElement>(
|
||||
`.r6-memory-mode-settings input[type="radio"][value="${target}"]`,
|
||||
)
|
||||
.getElementById(`${id}-evolve`)
|
||||
?.querySelector<HTMLElement>('[role="switch"]')
|
||||
?.focus();
|
||||
}, [busy, pendingMode, state?.mode]);
|
||||
}, [id, schedule, scheduleReadFailed]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const version = ++refreshVersion.current;
|
||||
const modeTicket = issueConfirmedModeTicket("settings");
|
||||
const [modeResult, scheduleResult, overviewResult, processingResult] =
|
||||
const [modeResult, scheduleResult, processingResult] =
|
||||
await Promise.allSettled([
|
||||
getMemoryMode(),
|
||||
getEvolveSchedule(),
|
||||
getEvolveOverview(),
|
||||
getMemoryProcessing(),
|
||||
]);
|
||||
if (version !== refreshVersion.current) return;
|
||||
@@ -239,11 +118,11 @@ export function MemoryModeSettings({
|
||||
setError("记忆模式状态暂时无法读取,请重试。");
|
||||
setReadFailed(true);
|
||||
}
|
||||
if (scheduleResult.status === "fulfilled")
|
||||
if (scheduleResult.status === "fulfilled") {
|
||||
setScheduleState(scheduleResult.value);
|
||||
if (overviewResult.status === "fulfilled") {
|
||||
setOverview(overviewResult.value);
|
||||
setRun(overviewResult.value.running[0] ?? null);
|
||||
setScheduleReadFailed(false);
|
||||
} else {
|
||||
setScheduleReadFailed(true);
|
||||
}
|
||||
if (processingResult.status === "fulfilled")
|
||||
setMaintenance(Boolean(processingResult.value.embedding_switch));
|
||||
@@ -254,21 +133,18 @@ export function MemoryModeSettings({
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
const running =
|
||||
maintenance ||
|
||||
state?.graph_build.state === "running" ||
|
||||
run?.state === "running";
|
||||
const running = maintenance || state?.graph_build.state === "running";
|
||||
if (!running) return;
|
||||
const timer = window.setInterval(() => void refresh(), 1500);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [maintenance, refresh, run?.state, state?.graph_build.state]);
|
||||
}, [maintenance, refresh, state?.graph_build.state]);
|
||||
|
||||
const lifecycleBusy =
|
||||
maintenance ||
|
||||
run?.state === "running" ||
|
||||
state?.graph_build.state === "running";
|
||||
|
||||
async function choose(mode: MemoryPipelineMode, opener: HTMLElement) {
|
||||
async function choose(mode: MemoryPipelineMode) {
|
||||
if (!state || mode === state.mode || busy || lifecycleBusy) return;
|
||||
setError("");
|
||||
setBusy(true);
|
||||
@@ -278,6 +154,7 @@ export function MemoryModeSettings({
|
||||
} catch (reason) {
|
||||
if (!memoryModePlanUnsupported(reason)) {
|
||||
setError("模式切换影响暂时无法读取,请重试。");
|
||||
focusModeWhenReady.current = state.mode;
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
@@ -288,7 +165,7 @@ export function MemoryModeSettings({
|
||||
onConfigureModel(BLOCKER_COPY.active_model_required);
|
||||
return;
|
||||
}
|
||||
dialogOpener.current = opener;
|
||||
dialogOpener.current = document.getElementById(`${id}-select`);
|
||||
setPendingPlan(plan);
|
||||
setPendingMode(mode);
|
||||
}
|
||||
@@ -330,21 +207,34 @@ export function MemoryModeSettings({
|
||||
setError("");
|
||||
try {
|
||||
setScheduleState(await setEvolveSchedule(update));
|
||||
return true;
|
||||
} catch {
|
||||
setError("自动整合设置未能保存,请重试。");
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function evolveNow() {
|
||||
if (!state) return;
|
||||
refreshVersion.current++;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const receipt = await startEvolve();
|
||||
setRun(receipt);
|
||||
await refresh();
|
||||
commitEvolveRun(
|
||||
receipt,
|
||||
overview ??
|
||||
(schedule
|
||||
? {
|
||||
mode: state.mode,
|
||||
schedule,
|
||||
users: {},
|
||||
running: [],
|
||||
}
|
||||
: undefined),
|
||||
);
|
||||
} catch (reason) {
|
||||
const code = apiErrorCode(reason);
|
||||
setError(
|
||||
@@ -369,189 +259,140 @@ export function MemoryModeSettings({
|
||||
<p>{text("模式决定新对话如何保存,以及记忆页面展示什么。")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<MemoryModeChoiceList
|
||||
aria-label={text("当前模式")}
|
||||
aria-busy={state === null}
|
||||
modes={(["lite", "pro", "ultra"] as const).map((mode) => ({
|
||||
mode,
|
||||
title: text(MODE_COPY[mode].title),
|
||||
description: text(MODE_COPY[mode].description),
|
||||
}))}
|
||||
current={state?.mode}
|
||||
selected={state?.mode ?? null}
|
||||
currentBadge={text("当前模式")}
|
||||
disabled={(mode) =>
|
||||
!state ||
|
||||
busy ||
|
||||
lifecycleBusy ||
|
||||
Boolean(state.forced_by_env && mode !== state.mode) ||
|
||||
(mode !== "lite" && !modelStateKnown)
|
||||
}
|
||||
onChange={(mode, control) => void choose(mode, control)}
|
||||
/>
|
||||
{state?.forced_by_env ? (
|
||||
<p className="r6-settings-note" role="status">
|
||||
{text("开发环境当前强制使用 ultra,保存的选择暂不会生效。")}
|
||||
</p>
|
||||
) : null}
|
||||
{maintenance ? (
|
||||
<p className="r6-settings-note" role="status">
|
||||
{text("正在检查或切换向量模型,完成前不能切换模式或手动整合。")}
|
||||
</p>
|
||||
) : null}
|
||||
{state?.graph_build.state === "running" ? (
|
||||
<p className="r6-settings-note" role="status">
|
||||
{text("正在把已有高级记忆加入知识图谱…")}
|
||||
</p>
|
||||
) : null}
|
||||
{error && !pendingMode ? (
|
||||
readFailed ? (
|
||||
<R6StateRecovery
|
||||
message={text(error)}
|
||||
onRetry={() => void refresh()}
|
||||
<div
|
||||
className="r6-mode-control"
|
||||
data-expanded={state?.mode === "ultra" || undefined}
|
||||
>
|
||||
<div className="r6-mode-setting-row" aria-busy={state === null}>
|
||||
<OptionMenu
|
||||
id={`${id}-select`}
|
||||
ariaLabel={text("记忆模式")}
|
||||
value={state?.mode ?? ""}
|
||||
disabled={
|
||||
!state || busy || lifecycleBusy || Boolean(state?.forced_by_env)
|
||||
}
|
||||
options={(["lite", "pro", "ultra"] as const).map((mode) => ({
|
||||
value: mode,
|
||||
label: modeDisplayName(text, mode),
|
||||
suffix: MODE_TIER[mode],
|
||||
description: text(MODE_COPY[mode].description),
|
||||
disabled: mode !== "lite" && !modelStateKnown,
|
||||
}))}
|
||||
className="r6-language-option-menu r6-mode-option-menu"
|
||||
panelClassName="r6-language-option-menu-panel--settings r6-mode-option-menu-panel"
|
||||
onChange={(value) => {
|
||||
if (value === "lite" || value === "pro" || value === "ultra")
|
||||
void choose(value);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<p className="r6-settings-error" role="alert">
|
||||
{text(error)}
|
||||
</div>
|
||||
{state?.forced_by_env ? (
|
||||
<p className="r6-settings-note" role="status">
|
||||
{text("开发环境当前强制使用 ultra,保存的选择暂不会生效。")}
|
||||
</p>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{state?.mode === "ultra" && schedule ? (
|
||||
<section
|
||||
id={`${id}-evolve`}
|
||||
className="r6-evolve-settings"
|
||||
aria-labelledby={`${id}-evolve-title`}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="r6-evolve-heading">
|
||||
<h3 id={`${id}-evolve-title`}>{text("记忆整合(Evolve)")}</h3>
|
||||
<p>{text("定期重新整理已有记忆,补充更稳定的关联和结论。")}</p>
|
||||
</div>
|
||||
<div className="r6-evolve-setting">
|
||||
<div className="r6-evolve-setting-copy">
|
||||
<h3>{text("每天自动整合")}</h3>
|
||||
<p>
|
||||
{text(
|
||||
schedule.enabled
|
||||
? "每天 {time} 左右自动整合,使用这台设备的本地时间;休眠错过后会补跑。"
|
||||
: "已关闭。重新开启后仍按 {time} 运行;已生成的结果会保留。",
|
||||
).replace("{time}", schedule.time)}
|
||||
</p>
|
||||
</div>
|
||||
<R6Switch
|
||||
label={text("每天自动整合")}
|
||||
checked={schedule.enabled}
|
||||
disabled={busy || maintenance}
|
||||
onCheckedChange={(enabled) => void changeSchedule({ enabled })}
|
||||
) : null}
|
||||
{maintenance ? (
|
||||
<p className="r6-settings-note" role="status">
|
||||
{text("正在检查或切换向量模型,完成前不能切换模式或手动整合。")}
|
||||
</p>
|
||||
) : null}
|
||||
{state?.graph_build.state === "running" ? (
|
||||
<p className="r6-settings-note" role="status">
|
||||
{text("正在把已有高级记忆加入知识图谱…")}
|
||||
</p>
|
||||
) : null}
|
||||
{error && !pendingMode ? (
|
||||
readFailed ? (
|
||||
<R6StateRecovery
|
||||
message={text(error)}
|
||||
onRetry={() => void refresh()}
|
||||
/>
|
||||
{schedule.enabled ? (
|
||||
<div className="r6-evolve-time">
|
||||
<div>
|
||||
<label htmlFor={`${id}-evolve-time`}>
|
||||
{text("执行时间")}
|
||||
</label>
|
||||
<p>{text("使用这台设备的本地时间。")}</p>
|
||||
</div>
|
||||
<input
|
||||
id={`${id}-evolve-time`}
|
||||
type="time"
|
||||
value={schedule.time}
|
||||
disabled={busy || maintenance}
|
||||
onChange={(event) =>
|
||||
void changeSchedule({ time: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="r6-evolve-setting r6-evolve-setting--manual">
|
||||
<div className="r6-evolve-setting-copy">
|
||||
<h3>{text("立即整合一次")}</h3>
|
||||
<p>
|
||||
{text("只运行这一次,不改变每天的计划。")}
|
||||
{" · "}
|
||||
{text(
|
||||
run?.state === "running"
|
||||
? "正在整合记忆…"
|
||||
: last?.last_run_at
|
||||
? text("上次整合:{time}").replace(
|
||||
"{time}",
|
||||
new Date(last.last_run_at).toLocaleString(locale),
|
||||
)
|
||||
: "尚未整合",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<R6Button
|
||||
pending={run?.state === "running"}
|
||||
disabled={busy || lifecycleBusy}
|
||||
onClick={() => void evolveNow()}
|
||||
>
|
||||
{text("立即整合")}
|
||||
</R6Button>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
) : (
|
||||
<p className="r6-settings-error" role="alert">
|
||||
{text(error)}
|
||||
</p>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{pendingMode ? (
|
||||
<div className="r6-settings-dialog-layer" role="presentation">
|
||||
{state?.mode === "ultra" ? (
|
||||
<section
|
||||
ref={dialog}
|
||||
className="r6-settings-dialog r6-mode-confirm-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="memory-mode-confirm-title"
|
||||
id={`${id}-evolve`}
|
||||
className="r6-evolve-settings"
|
||||
aria-labelledby={`${id}-evolve-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>
|
||||
{schedule ? (
|
||||
<UltraEvolveScheduleFields
|
||||
schedule={schedule}
|
||||
headingId={`${id}-evolve-title`}
|
||||
disabled={busy || maintenance}
|
||||
onEnabledChange={(enabled) => void changeSchedule({ enabled })}
|
||||
onTimeChange={(time) => changeSchedule({ time })}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="r6-evolve-heading">
|
||||
<h3 id={`${id}-evolve-title`}>
|
||||
{text("记忆整合(Evolve)")}
|
||||
</h3>
|
||||
<p>
|
||||
{text("定期重新整理已有记忆,补充更稳定的关联和结论。")}
|
||||
</p>
|
||||
</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>
|
||||
{scheduleReadFailed ? (
|
||||
<R6StateRecovery
|
||||
message={text("自动整合设置暂时无法读取,请重试。")}
|
||||
onRetry={() => {
|
||||
focusScheduleWhenReady.current = true;
|
||||
void refresh();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<div className="r6-evolve-setting r6-evolve-setting--manual">
|
||||
<div className="r6-evolve-setting-copy">
|
||||
<h3>{text("立即整合一次")}</h3>
|
||||
<p>
|
||||
{text("只运行这一次,不改变每天的计划。")}
|
||||
{" · "}
|
||||
{text(
|
||||
run?.state === "running"
|
||||
? "正在整合记忆…"
|
||||
: last?.last_run_at
|
||||
? text("上次整合:{time}").replace(
|
||||
"{time}",
|
||||
new Date(last.last_run_at).toLocaleString(locale),
|
||||
)
|
||||
: "尚未整合",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<R6Button
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setPendingPlan(null);
|
||||
setPendingMode(null);
|
||||
}}
|
||||
pending={run?.state === "running"}
|
||||
disabled={busy || lifecycleBusy}
|
||||
onClick={() => void evolveNow()}
|
||||
>
|
||||
{text("取消")}
|
||||
{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>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
|
||||
import { MemorySpace } from "./MemorySpace";
|
||||
import { queryClient, resetClient } from "./query-client";
|
||||
|
||||
it("translates memory filters and detail affordances live without refetching", async () => {
|
||||
setup();
|
||||
@@ -77,7 +78,9 @@ const { reads } = vi.hoisted(() => ({
|
||||
processing: vi.fn(),
|
||||
profiles: vi.fn(),
|
||||
raw: vi.fn(),
|
||||
rawGraph: vi.fn(),
|
||||
rawSearch: vi.fn(),
|
||||
evolve: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("./api", async (importOriginal) => ({
|
||||
@@ -87,7 +90,9 @@ vi.mock("./api", async (importOriginal) => ({
|
||||
getMemoryProcessing: reads.processing,
|
||||
getModelProfiles: reads.profiles,
|
||||
getSdkRaw: reads.raw,
|
||||
getSdkRawGraph: reads.rawGraph,
|
||||
searchSdkRaw: reads.rawSearch,
|
||||
getEvolveOverview: reads.evolve,
|
||||
}));
|
||||
vi.mock("./GraphWidthPrompt", () => ({ GraphWidthPrompt: () => null }));
|
||||
vi.mock("./MemoryAddComposer", () => ({
|
||||
@@ -117,10 +122,16 @@ vi.mock("./MemoryAddComposer", () => ({
|
||||
),
|
||||
}));
|
||||
vi.mock("./MemoryGraph3D", () => ({
|
||||
MemoryGraph3D: () => <div data-testid="graph-owner" />,
|
||||
MemoryGraph3D: ({ graph }: { graph?: { nodes?: unknown[] } | null }) => (
|
||||
<div
|
||||
data-testid="graph-owner"
|
||||
data-graph-nodes={graph?.nodes?.length ?? 0}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetClient();
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -154,7 +165,94 @@ it("translates the model-required prompt without configuring a model", async ()
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("keeps the memory snapshot visible during Evolve and refreshes when it ends", async () => {
|
||||
setup();
|
||||
reads.processing.mockResolvedValue(modeProcessing("ultra"));
|
||||
reads.evolve.mockResolvedValue({
|
||||
mode: "ultra",
|
||||
schedule: { enabled: true, time: "03:00" },
|
||||
users: {},
|
||||
running: [
|
||||
{
|
||||
run_id: "run-1",
|
||||
user_id: "local",
|
||||
state: "running",
|
||||
},
|
||||
],
|
||||
});
|
||||
render(<MemorySpace simplified />);
|
||||
|
||||
expect((await screen.findAllByText("已保存的记忆")).length).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
expect(await screen.findByText("正在整合记忆…")).toBeTruthy();
|
||||
const readsBeforeCompletion = reads.sdkLibrary.mock.calls.length;
|
||||
|
||||
act(() => {
|
||||
queryClient.setQueryData(["evolve", "local"], {
|
||||
mode: "ultra",
|
||||
schedule: { enabled: true, time: "03:00" },
|
||||
users: {},
|
||||
running: [],
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(reads.sdkLibrary.mock.calls.length).toBeGreaterThan(
|
||||
readsBeforeCompletion,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the last successful graph when its Evolve refresh fails", async () => {
|
||||
setup();
|
||||
reads.processing.mockResolvedValue(modeProcessing("ultra"));
|
||||
reads.evolve.mockResolvedValue({
|
||||
mode: "ultra",
|
||||
schedule: { enabled: true, time: "03:00" },
|
||||
users: {},
|
||||
running: [{ run_id: "run-1", user_id: "local", state: "running" }],
|
||||
});
|
||||
const firstSource = {
|
||||
source_id: "cursor",
|
||||
display_name: "Cursor",
|
||||
authorized: true,
|
||||
sync_enabled: true,
|
||||
imported_messages: 1,
|
||||
imported_conversations: 1,
|
||||
} as SourceResponse;
|
||||
const view = render(<MemorySpace simplified sources={[firstSource]} />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("graph-owner").dataset.graphNodes).toBe("1"),
|
||||
);
|
||||
reads.sdkGraph.mockRejectedValueOnce(new Error("evolve write lock"));
|
||||
|
||||
view.rerender(
|
||||
<MemorySpace
|
||||
simplified
|
||||
sources={[
|
||||
firstSource,
|
||||
{
|
||||
...firstSource,
|
||||
source_id: "codex",
|
||||
display_name: "Codex",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(reads.sdkGraph).toHaveBeenCalledTimes(2));
|
||||
expect(screen.getByTestId("graph-owner").dataset.graphNodes).toBe("1");
|
||||
});
|
||||
|
||||
function setup() {
|
||||
reads.evolve.mockResolvedValue({
|
||||
mode: "ultra",
|
||||
schedule: { enabled: true, time: "03:00" },
|
||||
users: {},
|
||||
running: [],
|
||||
});
|
||||
const memories = [
|
||||
{
|
||||
memory_id: "m1",
|
||||
@@ -208,6 +306,24 @@ function setup() {
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
});
|
||||
reads.rawGraph.mockResolvedValue({
|
||||
user_id: "local",
|
||||
items: [
|
||||
{
|
||||
turn_id: "raw-1",
|
||||
raw_id: "raw-1",
|
||||
label: "原始对话内容",
|
||||
source: "cursor",
|
||||
session_id: "session-1",
|
||||
memory_at: null,
|
||||
gmt_created: 1_761_000_000,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
limit: 300,
|
||||
offset: 0,
|
||||
has_more: false,
|
||||
});
|
||||
reads.rawSearch.mockResolvedValue([]);
|
||||
}
|
||||
|
||||
@@ -289,6 +405,11 @@ it("shows raw conversations instead of advanced memory in lite", async () => {
|
||||
render(<MemorySpace simplified />);
|
||||
|
||||
expect(await screen.findAllByText("原始对话内容")).toHaveLength(2);
|
||||
expect(screen.getByText("1 条原始对话")).toBeTruthy();
|
||||
expect(screen.queryByText("找到 1 条")).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 2, name: "来源归属" }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
document.querySelector(".core-memory-map")?.hasAttribute("hidden"),
|
||||
).toBe(false);
|
||||
@@ -316,13 +437,101 @@ it("shows raw conversations instead of advanced memory in lite", async () => {
|
||||
expect(document.querySelector(".memory-mode-notice")).toBeNull();
|
||||
});
|
||||
|
||||
it("loads one bounded raw graph page without claiming an exact turn total", async () => {
|
||||
setup();
|
||||
reads.processing.mockResolvedValue(modeProcessing("lite"));
|
||||
reads.rawGraph.mockResolvedValue({
|
||||
user_id: "local",
|
||||
items: Array.from({ length: 300 }, (_, index) => ({
|
||||
turn_id: `turn-${index}`,
|
||||
raw_id: `raw-${index}`,
|
||||
label: `原始会话 ${index}`,
|
||||
source: "cursor",
|
||||
session_id: `session-${index}`,
|
||||
memory_at: null,
|
||||
gmt_created: 1_761_000_000,
|
||||
})),
|
||||
total: 1_600,
|
||||
limit: 300,
|
||||
offset: 0,
|
||||
has_more: true,
|
||||
});
|
||||
|
||||
render(<MemorySpace simplified />);
|
||||
|
||||
await waitFor(() => expect(reads.rawGraph).toHaveBeenCalledTimes(1));
|
||||
expect(reads.rawGraph).toHaveBeenCalledWith({
|
||||
source: undefined,
|
||||
offset: 0,
|
||||
limit: 300,
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(screen.getByTestId("graph-owner").dataset.graphNodes).toBe("301");
|
||||
expect(document.querySelector(".memory-loaded-count")?.textContent).not.toBe(
|
||||
"1,600 条原始对话",
|
||||
);
|
||||
expect(screen.queryByText("1,600 / 1,600")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the last successful Lite graph when a background refresh fails", async () => {
|
||||
setup();
|
||||
reads.processing.mockResolvedValue(modeProcessing("lite"));
|
||||
const source = {
|
||||
source_id: "cursor",
|
||||
display_name: "Cursor",
|
||||
authorized: true,
|
||||
imported_messages: 1,
|
||||
imported_conversations: 1,
|
||||
} as SourceResponse;
|
||||
const view = render(<MemorySpace simplified sources={[source]} />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("graph-owner").dataset.graphNodes).toBe("2"),
|
||||
);
|
||||
reads.rawGraph.mockRejectedValueOnce(new Error("refresh failed"));
|
||||
view.rerender(
|
||||
<MemorySpace
|
||||
simplified
|
||||
sources={[
|
||||
source,
|
||||
{
|
||||
...source,
|
||||
source_id: "codex",
|
||||
display_name: "Codex",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(reads.rawGraph).toHaveBeenCalledTimes(2));
|
||||
expect(screen.getByTestId("graph-owner").dataset.graphNodes).toBe("2");
|
||||
expect(screen.queryByText("原始对话暂时无法读取。")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not request the raw graph for a keyword search", async () => {
|
||||
setup();
|
||||
reads.processing.mockResolvedValue(modeProcessing("lite"));
|
||||
reads.rawSearch.mockResolvedValue([]);
|
||||
const user = userEvent.setup();
|
||||
render(<MemorySpace simplified />);
|
||||
|
||||
await waitFor(() => expect(reads.rawGraph).toHaveBeenCalledTimes(1));
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: "搜索原始对话" }),
|
||||
"发布计划",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "搜索" }));
|
||||
await waitFor(() => expect(reads.rawSearch).toHaveBeenCalledTimes(1));
|
||||
expect(reads.rawGraph).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("pages and filters the raw conversation data plane in lite", async () => {
|
||||
setup();
|
||||
reads.processing.mockResolvedValue(modeProcessing("lite"));
|
||||
reads.raw.mockImplementation(async (options = {}) => {
|
||||
const offset = "offset" in options ? (options.offset ?? 0) : 0;
|
||||
const cursor = "cursor" in options ? options.cursor : null;
|
||||
const source = "source" in options ? options.source : undefined;
|
||||
const rawId = `${source ?? "all"}-${offset}`;
|
||||
const rawId = `${source ?? "all"}-${cursor ? "more" : "first"}`;
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
@@ -333,14 +542,17 @@ it("pages and filters the raw conversation data plane in lite", async () => {
|
||||
source: source ?? "cursor",
|
||||
session_id: `session-${rawId}`,
|
||||
memory_at: null,
|
||||
gmt_created: 1_761_000_000 - offset,
|
||||
gmt_created: 1_761_000_000,
|
||||
custom: {},
|
||||
messages: [{ role: "user", content: `原始会话 ${rawId}` }],
|
||||
},
|
||||
],
|
||||
total: source ? 1 : 21,
|
||||
total: cursor ? 2 : 1,
|
||||
limit: 20,
|
||||
offset,
|
||||
offset: cursor ? 1 : 0,
|
||||
next_cursor: !source && !cursor ? "turn:1:0:1" : null,
|
||||
has_more: !source && !cursor,
|
||||
total_exact: false,
|
||||
};
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
@@ -359,34 +571,111 @@ it("pages and filters the raw conversation data plane in lite", async () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findAllByText("原始会话 all-0")).toHaveLength(2);
|
||||
await user.click(screen.getByRole("button", { name: "下一页记忆" }));
|
||||
expect(await screen.findAllByText("原始会话 all-20")).toHaveLength(2);
|
||||
expect(await screen.findAllByText("原始会话 all-first")).toHaveLength(2);
|
||||
await waitFor(() => expect(reads.rawGraph).toHaveBeenCalledTimes(1));
|
||||
const graphCalls = reads.rawGraph.mock.calls.length;
|
||||
await user.click(screen.getByRole("button", { name: "加载更多" }));
|
||||
expect(await screen.findAllByText("原始会话 all-more")).toHaveLength(1);
|
||||
expect(screen.getAllByText("原始会话 all-first")).toHaveLength(2);
|
||||
expect(reads.rawGraph).toHaveBeenCalledTimes(graphCalls);
|
||||
expect(reads.raw).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ source: undefined, offset: 20, limit: 20 }),
|
||||
expect.objectContaining({
|
||||
source: undefined,
|
||||
cursor: "turn:1:0:1",
|
||||
paging: "cursor",
|
||||
limit: 20,
|
||||
}),
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("combobox", { name: "来源" }));
|
||||
await user.click(screen.getByRole("option", { name: "Cursor" }));
|
||||
expect(await screen.findAllByText("原始会话 cursor-0")).toHaveLength(2);
|
||||
expect(await screen.findAllByText("原始会话 cursor-first")).toHaveLength(2);
|
||||
expect(reads.raw).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ source: "cursor", offset: 0, limit: 20 }),
|
||||
expect.objectContaining({
|
||||
source: "cursor",
|
||||
cursor: null,
|
||||
paging: "cursor",
|
||||
limit: 20,
|
||||
}),
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("combobox", { name: "来源" }));
|
||||
await user.click(screen.getByRole("option", { name: "手动添加" }));
|
||||
expect(
|
||||
await screen.findAllByText("原始会话 one-memory-manual-0"),
|
||||
await screen.findAllByText("原始会话 one-memory-manual-first"),
|
||||
).toHaveLength(2);
|
||||
expect(reads.raw).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "one-memory-manual",
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
paging: "cursor",
|
||||
cursor: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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 +771,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 +806,7 @@ it("opens a search hit with the complete role transcript", async () => {
|
||||
],
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
total: 2,
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
}
|
||||
@@ -506,6 +816,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",
|
||||
@@ -514,6 +827,14 @@ it("opens a search hit with the complete role transcript", async () => {
|
||||
truncated: true,
|
||||
},
|
||||
]);
|
||||
reads.rawGraph.mockResolvedValue({
|
||||
user_id: "local",
|
||||
items: [],
|
||||
total: 1_600,
|
||||
limit: 300,
|
||||
offset: 0,
|
||||
has_more: false,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<MemorySpace simplified />);
|
||||
|
||||
@@ -524,7 +845,11 @@ it("opens a search hit with the complete role transcript", async () => {
|
||||
await user.click(screen.getByRole("button", { name: "搜索" }));
|
||||
|
||||
expect(await screen.findByText("完整问题")).toBeTruthy();
|
||||
expect(screen.getByText("找到 1 条")).toBeTruthy();
|
||||
expect(screen.getByText("完整回答")).toBeTruthy();
|
||||
expect(
|
||||
document.querySelector(".map-dialogue-reading")?.textContent,
|
||||
).not.toContain("第一轮问题");
|
||||
expect(reads.raw).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "cursor",
|
||||
|
||||
@@ -289,7 +289,7 @@ it("keeps rows, sources, count and graph on the one filtered page", async () =>
|
||||
expect(await screen.findByText(/566/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides the previous source graph until the all-applications graph settles", async () => {
|
||||
it("loads each source graph independently from list pagination and search", async () => {
|
||||
sdkAuthority();
|
||||
render(<MemorySpace simplified active />);
|
||||
await waitFor(() =>
|
||||
@@ -415,17 +415,13 @@ it("hides the previous source graph until the all-applications graph settles", a
|
||||
);
|
||||
const graphCalls = api.sdkGraph.mock.calls.length;
|
||||
fireEvent.click(screen.getByText("search deploy"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("map-graph-loading").textContent).toBe("true"),
|
||||
);
|
||||
expect(screen.getByTestId("map-nodes").textContent).toBe("none");
|
||||
expect(screen.getByTestId("map-graph-loading").textContent).toBe("false");
|
||||
expect(screen.getByTestId("map-nodes").textContent).toBe("4");
|
||||
expect(screen.getByTestId("map-memories").textContent).toBe("");
|
||||
expect(api.sdkGraph).toHaveBeenCalledTimes(graphCalls);
|
||||
|
||||
await act(async () => releaseSearch(searchPage));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("map-graph-loading").textContent).toBe("false"),
|
||||
);
|
||||
expect(screen.getByTestId("map-graph-loading").textContent).toBe("false");
|
||||
});
|
||||
|
||||
it("opens an SDK memory in place instead of switching away from the map", async () => {
|
||||
|
||||
@@ -38,15 +38,18 @@ import {
|
||||
getSdkMemoryGraph,
|
||||
getSdkMemoryLibrary,
|
||||
getSdkRaw,
|
||||
getSdkRawGraph,
|
||||
manualExtract,
|
||||
searchSdkRaw,
|
||||
setExtractionWindow,
|
||||
updateSdkMemory,
|
||||
} from "./api";
|
||||
import { localEvolveRunning, useEvolveState } from "./evolve-state";
|
||||
import { GraphWidthPrompt } from "./GraphWidthPrompt";
|
||||
import { useGuiLanguage } from "./gui-language";
|
||||
import { MemoryDeleteDialog } from "./MemoryDeleteDialog";
|
||||
import { MemoryMap } from "./MemoryMap";
|
||||
import { useOnboardingTranslation } from "./onboarding-language";
|
||||
import { subscribePoll } from "./poll-scheduler";
|
||||
import { SdkMemoryLibrary } from "./SdkMemoryLibrary";
|
||||
import { SdkRawHistory } from "./SdkRawHistory";
|
||||
@@ -60,8 +63,12 @@ import {
|
||||
import {
|
||||
type RawMapRecord,
|
||||
rawDetail,
|
||||
rawGraphProjection,
|
||||
rawGraphRecord,
|
||||
rawLibraryPage,
|
||||
rawRecordId,
|
||||
rawSearchPage,
|
||||
SDK_RAW_PAGE_SIZE,
|
||||
} from "./sdk-raw-map";
|
||||
import { sourceNameOr, UNATTRIBUTED_SOURCE_ID } from "./source-label";
|
||||
import { traceUiAction } from "./ui-trace";
|
||||
@@ -113,6 +120,10 @@ export function MemorySpace({
|
||||
onFocusMemoryConsumed?(): void;
|
||||
}) {
|
||||
const { locale, t, tn } = useGuiLanguage();
|
||||
const { text } = useOnboardingTranslation();
|
||||
const evolveState = useEvolveState(active || preload);
|
||||
const evolveRunning = localEvolveRunning(evolveState.data);
|
||||
const evolveUncertain = evolveState.isError && evolveRunning;
|
||||
const memoryText = (text: string) => {
|
||||
const key = `memory:${text}`;
|
||||
return isMessageKey(key) ? t(key) : text;
|
||||
@@ -122,6 +133,7 @@ export function MemorySpace({
|
||||
const selectedRawRef = useRef<RawMapRecord | null>(null);
|
||||
selectedRawRef.current = selectedRaw;
|
||||
const rawRecords = useRef(new Map<string, RawMapRecord>());
|
||||
const rawRecordsScope = useRef("");
|
||||
const [detailLoad, setDetailLoad] = useState<{
|
||||
memoryId: string;
|
||||
status: "loading" | "failed";
|
||||
@@ -163,6 +175,8 @@ export function MemorySpace({
|
||||
source.grant_revision,
|
||||
]),
|
||||
);
|
||||
const sourcesRef = useRef(sources);
|
||||
sourcesRef.current = sources;
|
||||
const management = simplified && displayedMode === "library";
|
||||
useEffect(() => {
|
||||
viewActive.current = active;
|
||||
@@ -189,6 +203,12 @@ export function MemorySpace({
|
||||
// Bumping this re-reads the page and graph after an edit or a delete.
|
||||
const [sdkMapRevision, setSdkMapRevision] = useState(0);
|
||||
const bumpSdkMap = useCallback(() => setSdkMapRevision((v) => v + 1), []);
|
||||
const previousEvolveRunning = useRef(evolveRunning);
|
||||
useEffect(() => {
|
||||
const previous = previousEvolveRunning.current;
|
||||
previousEvolveRunning.current = evolveRunning;
|
||||
if (previous && !evolveRunning) bumpSdkMap();
|
||||
}, [bumpSdkMap, evolveRunning]);
|
||||
const [sdkLibrary, setSdkLibrary] = useState<{
|
||||
page: MemoryLibraryPage | null;
|
||||
loading: boolean;
|
||||
@@ -208,6 +228,7 @@ export function MemorySpace({
|
||||
const [sdkLibraryQuery, setSdkLibraryQuery] = useState("");
|
||||
const [sdkLibrarySource, setSdkLibrarySource] = useState("");
|
||||
const [sdkLibraryOffset, setSdkLibraryOffset] = useState(0);
|
||||
const [sdkRawCursor, setSdkRawCursor] = useState<string | null>(null);
|
||||
const [processing, setProcessing] = useState<MemoryProcessingSummary | null>(
|
||||
null,
|
||||
);
|
||||
@@ -225,6 +246,10 @@ export function MemorySpace({
|
||||
evolve_available: pipelineMode === "ultra",
|
||||
}
|
||||
: null;
|
||||
const refreshSdkMap = useCallback(() => {
|
||||
if (lite) setSdkRawCursor(null);
|
||||
bumpSdkMap();
|
||||
}, [bumpSdkMap, lite]);
|
||||
const requestedQuery = sdkLibraryQuery.trim();
|
||||
const sdkPageSettled =
|
||||
sdkLibrary.settledMode === pipelineMode &&
|
||||
@@ -232,18 +257,17 @@ export function MemorySpace({
|
||||
sdkLibrary.settledQuery.trim() === requestedQuery &&
|
||||
sdkLibrary.page?.offset === sdkLibraryOffset;
|
||||
const sdkPage = sdkPageSettled ? sdkLibrary.page : null;
|
||||
const sdkRequestedScope = JSON.stringify([
|
||||
const sdkGraphRequestedScope = JSON.stringify([
|
||||
pipelineMode,
|
||||
sdkLibraryQuery.trim(),
|
||||
sdkLibrarySource,
|
||||
]);
|
||||
const sdkGraphSettled = sdkGraph?.scope === sdkRequestedScope;
|
||||
const sdkGraphSettled = sdkGraph?.scope === sdkGraphRequestedScope;
|
||||
const visibleSdkGraph =
|
||||
sdkGraph?.scope === sdkRequestedScope && sdkGraph.status === "ready"
|
||||
? sdkGraph.value
|
||||
: null;
|
||||
sdkGraph?.scope === sdkGraphRequestedScope ? sdkGraph.value : null;
|
||||
const sdkGraphFailed =
|
||||
sdkGraph?.scope === sdkRequestedScope && sdkGraph.status === "failed";
|
||||
sdkGraph?.scope === sdkGraphRequestedScope &&
|
||||
sdkGraph.status === "failed" &&
|
||||
sdkGraph.value === null;
|
||||
// A keyword search pages through one server-side result window. The id is
|
||||
// kept for the query and source it was searched with; a new query or
|
||||
// source starts without one, so the server searches afresh.
|
||||
@@ -275,65 +299,50 @@ 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([
|
||||
query
|
||||
? searchSdkRaw(query, {
|
||||
source,
|
||||
signal: request.signal,
|
||||
}).then((items) => {
|
||||
rememberRaw(items);
|
||||
return rawSearchPage(items);
|
||||
})
|
||||
: getSdkRaw({
|
||||
source,
|
||||
offset: sdkLibraryOffset,
|
||||
limit: SDK_MEMORY_PAGE_SIZE,
|
||||
signal: request.signal,
|
||||
}).then((rawPage) => {
|
||||
rememberRaw(rawPage.items);
|
||||
return rawLibraryPage(rawPage);
|
||||
}),
|
||||
Promise.resolve({
|
||||
status: "ready" as const,
|
||||
value: null,
|
||||
}),
|
||||
])
|
||||
: await Promise.all([
|
||||
getSdkMemoryLibrary(
|
||||
{
|
||||
statuses: ["active"],
|
||||
source,
|
||||
query,
|
||||
offset: sdkLibraryOffset,
|
||||
limit: SDK_MEMORY_PAGE_SIZE,
|
||||
windowId: query ? sdkSearchWindow.current.id : null,
|
||||
},
|
||||
request.signal,
|
||||
).then(sdkLibraryPage),
|
||||
query
|
||||
? Promise.resolve({
|
||||
status: "ready" as const,
|
||||
value: null,
|
||||
})
|
||||
: getSdkMemoryGraph(
|
||||
{
|
||||
source,
|
||||
offset: 0,
|
||||
limit: 300,
|
||||
},
|
||||
request.signal,
|
||||
).then(
|
||||
(value) => ({ status: "ready" as const, value }),
|
||||
() => ({ status: "failed" as const, value: null }),
|
||||
),
|
||||
]);
|
||||
const page = lite
|
||||
? query
|
||||
? await searchSdkRaw(query, {
|
||||
source,
|
||||
signal: request.signal,
|
||||
}).then((items) => {
|
||||
rememberRaw(items);
|
||||
return rawSearchPage(items);
|
||||
})
|
||||
: await getSdkRaw({
|
||||
source,
|
||||
offset: 0,
|
||||
limit: SDK_RAW_PAGE_SIZE,
|
||||
paging: "cursor",
|
||||
cursor: sdkRawCursor,
|
||||
signal: request.signal,
|
||||
}).then((rawPage) => {
|
||||
rememberRaw(rawPage.items);
|
||||
return rawLibraryPage(rawPage);
|
||||
})
|
||||
: await getSdkMemoryLibrary(
|
||||
{
|
||||
statuses: ["active"],
|
||||
source,
|
||||
query,
|
||||
offset: sdkLibraryOffset,
|
||||
limit: SDK_MEMORY_PAGE_SIZE,
|
||||
windowId: query ? sdkSearchWindow.current.id : null,
|
||||
},
|
||||
request.signal,
|
||||
).then(sdkLibraryPage);
|
||||
if (cancelled) return;
|
||||
if (!lite && sdkSearchWindow.current.key === windowKey)
|
||||
sdkSearchWindow.current = {
|
||||
@@ -342,17 +351,63 @@ export function MemorySpace({
|
||||
};
|
||||
// The window expired: the server searched again and answered page 1.
|
||||
if (page.window_reset && sdkLibraryOffset !== 0) setSdkLibraryOffset(0);
|
||||
setSdkLibrary({
|
||||
page,
|
||||
loading: false,
|
||||
failed: false,
|
||||
settledSource: sdkLibrarySource,
|
||||
settledQuery: sdkLibraryQuery,
|
||||
settledMode: pipelineMode,
|
||||
setSdkLibrary((current) => {
|
||||
const currentPage = current.page;
|
||||
const append =
|
||||
lite &&
|
||||
sdkRawCursor !== null &&
|
||||
currentPage !== null &&
|
||||
current.settledSource === sdkLibrarySource &&
|
||||
current.settledQuery === sdkLibraryQuery &&
|
||||
current.settledMode === pipelineMode;
|
||||
if (!append)
|
||||
return {
|
||||
page,
|
||||
loading: false,
|
||||
failed: false,
|
||||
settledSource: sdkLibrarySource,
|
||||
settledQuery: sdkLibraryQuery,
|
||||
settledMode: pipelineMode,
|
||||
};
|
||||
const items = [...currentPage.items];
|
||||
const known = new Set(items.map((item) => item.memory_id));
|
||||
for (const item of page.items)
|
||||
if (!known.has(item.memory_id)) {
|
||||
known.add(item.memory_id);
|
||||
items.push(item);
|
||||
}
|
||||
return {
|
||||
page: {
|
||||
...page,
|
||||
items,
|
||||
total: items.length + (page.has_more ? 1 : 0),
|
||||
offset: 0,
|
||||
source_ids_by_memory: {
|
||||
...currentPage.source_ids_by_memory,
|
||||
...page.source_ids_by_memory,
|
||||
},
|
||||
source_time_by_memory: {
|
||||
...currentPage.source_time_by_memory,
|
||||
...page.source_time_by_memory,
|
||||
},
|
||||
},
|
||||
loading: false,
|
||||
failed: false,
|
||||
settledSource: sdkLibrarySource,
|
||||
settledQuery: sdkLibraryQuery,
|
||||
settledMode: pipelineMode,
|
||||
};
|
||||
});
|
||||
setSdkGraph({ scope: windowKey, ...graph });
|
||||
} catch {
|
||||
} catch (reason) {
|
||||
if (cancelled || request.signal.aborted) return;
|
||||
if (
|
||||
lite &&
|
||||
sdkRawCursor !== null &&
|
||||
apiErrorCode(reason) === "raw_cursor_stale"
|
||||
) {
|
||||
setSdkRawCursor(null);
|
||||
return;
|
||||
}
|
||||
setSdkLibrary((current) => ({
|
||||
...current,
|
||||
loading: false,
|
||||
@@ -361,10 +416,13 @@ export function MemorySpace({
|
||||
}
|
||||
};
|
||||
void read(true);
|
||||
const timer = setInterval(() => void read(false), 30_000);
|
||||
const timer =
|
||||
lite && sdkRawCursor !== null
|
||||
? undefined
|
||||
: setInterval(() => void read(false), 30_000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
if (timer !== undefined) clearInterval(timer);
|
||||
controller?.abort();
|
||||
};
|
||||
}, [
|
||||
@@ -373,9 +431,77 @@ export function MemorySpace({
|
||||
sdkLibraryOffset,
|
||||
sdkLibraryQuery,
|
||||
sdkLibrarySource,
|
||||
sdkRawCursor,
|
||||
lite,
|
||||
pipelineMode,
|
||||
]);
|
||||
const rawGraphRecords = useRef(new Map<string, RawMapRecord>());
|
||||
useEffect(() => {
|
||||
if (!sdkMapVisible) return;
|
||||
void sdkMapRevision;
|
||||
void sourceKey;
|
||||
const scope = sdkGraphRequestedScope;
|
||||
const source = sdkLibrarySource || undefined;
|
||||
const request = new AbortController();
|
||||
let cancelled = false;
|
||||
let reading = false;
|
||||
const readGraph = async () => {
|
||||
if (reading) return;
|
||||
reading = true;
|
||||
try {
|
||||
if (!lite) {
|
||||
const value = await getSdkMemoryGraph(
|
||||
{ source, offset: 0, limit: 300 },
|
||||
request.signal,
|
||||
);
|
||||
if (cancelled) return;
|
||||
setSdkGraph({ scope, status: "ready", value });
|
||||
return;
|
||||
}
|
||||
const page = await getSdkRawGraph({
|
||||
source,
|
||||
offset: 0,
|
||||
limit: 300,
|
||||
signal: request.signal,
|
||||
});
|
||||
if (cancelled) return;
|
||||
rawGraphRecords.current.clear();
|
||||
for (const item of page.items) {
|
||||
const record = rawGraphRecord(item);
|
||||
rawGraphRecords.current.set(rawRecordId(record), record);
|
||||
}
|
||||
setSdkGraph({
|
||||
scope,
|
||||
status: "ready",
|
||||
value: rawGraphProjection(page.items, page.total, sourcesRef.current),
|
||||
});
|
||||
} catch {
|
||||
if (cancelled || request.signal.aborted) return;
|
||||
setSdkGraph((current) => ({
|
||||
scope,
|
||||
status: "failed",
|
||||
value: current?.scope === scope ? current.value : null,
|
||||
}));
|
||||
} finally {
|
||||
reading = false;
|
||||
}
|
||||
};
|
||||
void readGraph();
|
||||
const timer = setInterval(() => void readGraph(), 30_000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
request.abort();
|
||||
};
|
||||
}, [
|
||||
lite,
|
||||
sdkGraphRequestedScope,
|
||||
sdkLibrarySource,
|
||||
sdkMapRevision,
|
||||
sdkMapVisible,
|
||||
sourceKey,
|
||||
]);
|
||||
const displayedSdkPage = sdkPage;
|
||||
const sdkFilterLabel = [
|
||||
sdkLibrary.settledSource
|
||||
? `${t("memory:来源")} ${
|
||||
@@ -505,10 +631,13 @@ export function MemorySpace({
|
||||
}, [active, onArrivalChange]);
|
||||
const requestIds = useRef(new Map<string, string>());
|
||||
const detailEpoch = useRef(0);
|
||||
const rawDetailController = useRef<AbortController | null>(null);
|
||||
const answerEpoch = useRef(0);
|
||||
const answerMemories = useRef<AskMemory[]>([]);
|
||||
const closeDetail = useCallback(() => {
|
||||
detailEpoch.current++;
|
||||
rawDetailController.current?.abort();
|
||||
rawDetailController.current = null;
|
||||
setDetail(null);
|
||||
setSelectedRaw(null);
|
||||
setDetailLoad(null);
|
||||
@@ -521,6 +650,7 @@ export function MemorySpace({
|
||||
rawRecords.current.clear();
|
||||
sdkSearchWindow.current = { key: "", id: null };
|
||||
setSdkLibraryOffset(0);
|
||||
setSdkRawCursor(null);
|
||||
setSdkLibraryQuery("");
|
||||
setSdkGraph(null);
|
||||
setSdkLibrary((current) => ({
|
||||
@@ -582,6 +712,8 @@ export function MemorySpace({
|
||||
cancelled = true;
|
||||
stopProcessing();
|
||||
detailEpoch.current++;
|
||||
rawDetailController.current?.abort();
|
||||
rawDetailController.current = null;
|
||||
answerEpoch.current++;
|
||||
deleteAnswer.current?.(false);
|
||||
deleteAnswer.current = null;
|
||||
@@ -693,11 +825,15 @@ export function MemorySpace({
|
||||
async function open(memoryId: string) {
|
||||
if (simplified && (!viewActive.current || stale.current)) return;
|
||||
const epoch = ++detailEpoch.current;
|
||||
rawDetailController.current?.abort();
|
||||
rawDetailController.current = null;
|
||||
setError(null);
|
||||
setDetail(null);
|
||||
setSelectedRaw(null);
|
||||
if (lite) {
|
||||
let record = rawRecords.current.get(memoryId);
|
||||
let record =
|
||||
rawRecords.current.get(memoryId) ??
|
||||
rawGraphRecords.current.get(memoryId);
|
||||
if (!record) {
|
||||
setDetailLoad({
|
||||
memoryId,
|
||||
@@ -708,6 +844,8 @@ export function MemorySpace({
|
||||
}
|
||||
if ("kind" in record) {
|
||||
setDetailLoad({ memoryId, status: "loading" });
|
||||
const request = new AbortController();
|
||||
rawDetailController.current = request;
|
||||
try {
|
||||
let offset = 0;
|
||||
while (true) {
|
||||
@@ -716,8 +854,11 @@ export function MemorySpace({
|
||||
sessionId: record.session_id || undefined,
|
||||
offset,
|
||||
limit: 500,
|
||||
signal: request.signal,
|
||||
});
|
||||
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);
|
||||
@@ -728,15 +869,18 @@ export function MemorySpace({
|
||||
throw new Error("raw_not_found");
|
||||
}
|
||||
} catch {
|
||||
if (epoch === detailEpoch.current)
|
||||
if (epoch === detailEpoch.current) {
|
||||
rawDetailController.current = null;
|
||||
setDetailLoad({
|
||||
memoryId,
|
||||
status: "failed",
|
||||
error: t("sdk.raw.loadFailed"),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (epoch !== detailEpoch.current) return;
|
||||
rawDetailController.current = null;
|
||||
}
|
||||
setSelectedRaw(record);
|
||||
setDetail(rawDetail(record));
|
||||
@@ -798,7 +942,7 @@ export function MemorySpace({
|
||||
);
|
||||
trace.done("succeeded");
|
||||
closeDetail();
|
||||
bumpSdkMap();
|
||||
refreshSdkMap();
|
||||
} catch (reason) {
|
||||
trace.done(writeTraceStatus(reason), {
|
||||
reason: "memory_action_failed",
|
||||
@@ -827,7 +971,7 @@ export function MemorySpace({
|
||||
trace.done("succeeded");
|
||||
setDetail(sdkDetail(saved));
|
||||
setDraft(sdkEditableText(saved));
|
||||
bumpSdkMap();
|
||||
refreshSdkMap();
|
||||
} catch (reason) {
|
||||
trace.done(writeTraceStatus(reason), {
|
||||
reason: isDefiniteRefusal(reason)
|
||||
@@ -891,11 +1035,12 @@ export function MemorySpace({
|
||||
? pipelineMode === null
|
||||
? t("memory:记忆暂时无法读取")
|
||||
: lite
|
||||
? sdkPage
|
||||
? `${t("memory:原始对话")} · ${tn(
|
||||
"sdk.raw.found",
|
||||
sdkPage.total,
|
||||
)}`
|
||||
? displayedSdkPage
|
||||
? displayedSdkPage.total_exact === false
|
||||
? t("memory:原始对话")
|
||||
: sdkLibrary.settledQuery.trim()
|
||||
? tn("sdk.raw.found", displayedSdkPage.total)
|
||||
: tn("sdk.raw.total", displayedSdkPage.total)
|
||||
: sdkLibrary.failed
|
||||
? null
|
||||
: t("memory:正在读取…")
|
||||
@@ -928,7 +1073,7 @@ export function MemorySpace({
|
||||
onSaved={() => {
|
||||
// A finished addition is new page content: re-read now rather than
|
||||
// leave it for the next poll.
|
||||
bumpSdkMap();
|
||||
refreshSdkMap();
|
||||
}}
|
||||
onOpenMemory={(id) => {
|
||||
if (!simplified) setMode("library");
|
||||
@@ -1003,6 +1148,11 @@ export function MemorySpace({
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
{evolveRunning ? (
|
||||
<p className="memory-evolve-status" role="status">
|
||||
{text(evolveUncertain ? "正在核对整合状态…" : "正在整合记忆…")}
|
||||
</p>
|
||||
) : null}
|
||||
{processing && (!simplified || managementOpen) ? (
|
||||
<details className="memory-processing-details">
|
||||
<summary>
|
||||
@@ -1186,21 +1336,30 @@ export function MemorySpace({
|
||||
? {
|
||||
query: sdkLibraryQuery,
|
||||
source: sdkLibrarySource,
|
||||
page: sdkPage,
|
||||
page: displayedSdkPage,
|
||||
loading: sdkLibrary.loading,
|
||||
failed: sdkLibrary.failed,
|
||||
failed: sdkLibrary.failed && !evolveRunning,
|
||||
graphLoading: !sdkGraphSettled,
|
||||
graphFailed: sdkGraphFailed,
|
||||
graphFailed: sdkGraphFailed && !evolveRunning,
|
||||
stale: false,
|
||||
settledSource: sdkLibrary.settledSource,
|
||||
settledQuery: sdkLibrary.settledQuery,
|
||||
requestedOffset: sdkLibraryOffset,
|
||||
cursorPaging: lite && !sdkLibraryQuery.trim(),
|
||||
hasMore: !!sdkPage?.has_more && !!sdkPage?.next_cursor,
|
||||
unattributedSource: UNATTRIBUTED_SOURCE_ID,
|
||||
onQuery: setSdkLibraryQuery,
|
||||
onSource: setSdkLibrarySource,
|
||||
resetPage: () => setSdkLibraryOffset(0),
|
||||
resetPage: () => {
|
||||
setSdkLibraryOffset(0);
|
||||
setSdkRawCursor(null);
|
||||
},
|
||||
onPage: (offset) => setSdkLibraryOffset(offset),
|
||||
onRetry: bumpSdkMap,
|
||||
onMore: () => {
|
||||
if (sdkPage?.next_cursor)
|
||||
setSdkRawCursor(sdkPage.next_cursor);
|
||||
},
|
||||
onRetry: refreshSdkMap,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
@@ -1209,7 +1368,7 @@ export function MemorySpace({
|
||||
displayedMode !== "map" ||
|
||||
(simplified && pipelineMode === null)
|
||||
}
|
||||
memories={sdkPage?.items ?? []}
|
||||
memories={displayedSdkPage?.items ?? []}
|
||||
graph={visibleSdkGraph}
|
||||
sources={sources}
|
||||
processing={processing}
|
||||
|
||||
@@ -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();
|
||||
@@ -1044,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",
|
||||
@@ -1278,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("把一张预置卡设为当前模型", () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type FormEvent,
|
||||
type Ref,
|
||||
useEffect,
|
||||
useId,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
@@ -26,6 +27,8 @@ import {
|
||||
presetForGeneration,
|
||||
validateGenerationModel,
|
||||
} from "./GenerationModelPrimitives";
|
||||
import { modelProfileReady, profileFromProbe } from "./model-profile-readiness";
|
||||
import { OptionMenu } from "./OptionMenu";
|
||||
import { useOnboardingTranslation } from "./onboarding-language";
|
||||
import { PROVIDER_PRESETS } from "./provider-presets";
|
||||
import { R6StateRecovery } from "./R6StateRecovery";
|
||||
@@ -172,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 {
|
||||
@@ -238,6 +220,7 @@ export function ModelSettings({
|
||||
onSetupComplete?(): void;
|
||||
} = {}) {
|
||||
const { text, t } = useOnboardingTranslation();
|
||||
const fieldId = useId();
|
||||
const models = useModelProfiles();
|
||||
const embeddingSwitch = useEmbeddingSwitch();
|
||||
const [provider, setProvider] = useState<ProviderKind>("openai_compatible");
|
||||
@@ -275,6 +258,9 @@ 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);
|
||||
@@ -308,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;
|
||||
}
|
||||
@@ -323,6 +310,7 @@ export function ModelSettings({
|
||||
);
|
||||
setSwitchTarget(null);
|
||||
setSwitchJobId(null);
|
||||
setSwitchRefreshCompleted(null);
|
||||
}
|
||||
}, [
|
||||
embeddingSwitch.current?.last_finished,
|
||||
@@ -331,6 +319,45 @@ 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]);
|
||||
@@ -381,6 +408,7 @@ export function ModelSettings({
|
||||
saved);
|
||||
const editorLocked =
|
||||
saving ||
|
||||
switchJobId !== null ||
|
||||
models.busy !== null ||
|
||||
!!models.pendingCreation ||
|
||||
!!models.pendingProbe ||
|
||||
@@ -419,7 +447,11 @@ export function ModelSettings({
|
||||
technicalTouched.current.clear();
|
||||
};
|
||||
const isReady = (profile: ModelProfile) =>
|
||||
ready(profile, models.probeFor(profile), models.probeErrorFor(profile));
|
||||
modelProfileReady(
|
||||
profile,
|
||||
models.probeFor(profile),
|
||||
models.probeErrorFor(profile),
|
||||
);
|
||||
|
||||
function editProfile(profile: ModelProfile) {
|
||||
if (ownerLocked) return;
|
||||
@@ -578,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;
|
||||
@@ -592,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) {
|
||||
@@ -864,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.
|
||||
@@ -1339,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,
|
||||
@@ -1360,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" ? (
|
||||
<>
|
||||
@@ -1397,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
|
||||
@@ -1669,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?.();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
@@ -1861,7 +1901,11 @@ function PresetProfileCard({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !canEnable || !ready(profile, probe, probeError)}
|
||||
disabled={
|
||||
busy ||
|
||||
!canEnable ||
|
||||
!modelProfileReady(profile, probe, probeError)
|
||||
}
|
||||
onClick={onActivate}
|
||||
>
|
||||
{text("设为当前模型")}
|
||||
|
||||
@@ -10,13 +10,33 @@ import type { ImportSourcesController } from "./use-import-sources";
|
||||
|
||||
vi.mock("./api", () => ({
|
||||
completeOnboarding: vi.fn(),
|
||||
getEvolveSchedule: vi.fn().mockResolvedValue({
|
||||
enabled: true,
|
||||
time: "03:00",
|
||||
}),
|
||||
getModelProfiles: vi.fn().mockResolvedValue([]),
|
||||
setEvolveSchedule: vi.fn(),
|
||||
memoryModePlanUnsupported: vi.fn(() => false),
|
||||
getMemoryMode: vi.fn().mockResolvedValue({
|
||||
mode: "pro",
|
||||
configured_mode: "pro",
|
||||
explicitly_configured: false,
|
||||
}),
|
||||
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
|
||||
planMemoryMode: vi.fn().mockResolvedValue({ blockers: [] }),
|
||||
planMemoryMode: vi.fn().mockImplementation(async (target) => ({
|
||||
current: "lite",
|
||||
target,
|
||||
effective_target: target,
|
||||
capabilities: {
|
||||
content_plane: target === "lite" ? "raw" : "memory",
|
||||
generation_required: target !== "lite",
|
||||
graph_enabled: target === "ultra",
|
||||
evolve_available: target === "ultra",
|
||||
},
|
||||
effects: [],
|
||||
warnings: [],
|
||||
blockers: target === "lite" ? [] : ["active_model_required"],
|
||||
})),
|
||||
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
|
||||
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
@@ -136,13 +156,12 @@ it("moves from import to the actual third step without completing onboarding", a
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "进入第三步" }));
|
||||
await userEvent.click(screen.getByRole("radio", { name: /pro/i }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "配置模型" }));
|
||||
expect(screen.getByRole("heading", { name: "第三步模型页面" })).toBeTruthy();
|
||||
expect(completeOnboarding).not.toHaveBeenCalled();
|
||||
expect(finish).not.toHaveBeenCalled();
|
||||
await userEvent.click(screen.getByRole("button", { name: "返回第二步" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "返回导入" }));
|
||||
expect(screen.getByText("Import accepted")).toBeTruthy();
|
||||
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
|
||||
expect(completeOnboarding).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -161,7 +180,7 @@ it("lets an empty first-time user enter without granting or importing a source",
|
||||
onFinish={finish}
|
||||
/>,
|
||||
);
|
||||
const skip = await screen.findByRole("button", { name: "稍后添加,先进入" });
|
||||
const skip = await screen.findByRole("button", { name: "选择记忆模式" });
|
||||
await waitFor(() => expect((skip as HTMLButtonElement).disabled).toBe(false));
|
||||
await userEvent.click(skip);
|
||||
await userEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
@@ -184,7 +203,7 @@ it("retains explicit retry or enter when saving the skipped onboarding fails", a
|
||||
onFinish={finish}
|
||||
/>,
|
||||
);
|
||||
const skip = await screen.findByRole("button", { name: "稍后添加,先进入" });
|
||||
const skip = await screen.findByRole("button", { name: "选择记忆模式" });
|
||||
await waitFor(() => expect((skip as HTMLButtonElement).disabled).toBe(false));
|
||||
await userEvent.click(skip);
|
||||
await userEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
@@ -273,7 +292,9 @@ it("freezes the explicitly submitted source list when a later source arrives", a
|
||||
resolveRefresh = resolve;
|
||||
}),
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "开始导入" }));
|
||||
await userEvent.click(
|
||||
await screen.findByRole("button", { name: "开始导入" }),
|
||||
);
|
||||
const lateSources = [source("workbuddy"), source("codex")];
|
||||
rendered.rerender(view({ ...imports, sources: lateSources }));
|
||||
expect(checkbox("codex").checked).toBe(false);
|
||||
|
||||
@@ -24,6 +24,12 @@ import type { ImportSourcesController } from "./use-import-sources";
|
||||
|
||||
vi.mock("./api", () => ({
|
||||
completeOnboarding: vi.fn(),
|
||||
getEvolveSchedule: vi.fn().mockResolvedValue({
|
||||
enabled: true,
|
||||
time: "03:00",
|
||||
}),
|
||||
setEvolveSchedule: vi.fn(),
|
||||
memoryModePlanUnsupported: vi.fn(() => false),
|
||||
getModelProfiles: vi.fn(),
|
||||
getMemoryMode: vi.fn().mockResolvedValue({
|
||||
mode: "pro",
|
||||
@@ -31,7 +37,20 @@ vi.mock("./api", () => ({
|
||||
explicitly_configured: false,
|
||||
}),
|
||||
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
|
||||
planMemoryMode: vi.fn().mockResolvedValue({ blockers: [] }),
|
||||
planMemoryMode: vi.fn().mockResolvedValue({
|
||||
current: "pro",
|
||||
target: "lite",
|
||||
effective_target: "lite",
|
||||
capabilities: {
|
||||
content_plane: "raw",
|
||||
generation_required: false,
|
||||
graph_enabled: false,
|
||||
evolve_available: false,
|
||||
},
|
||||
effects: [],
|
||||
warnings: [],
|
||||
blockers: [],
|
||||
}),
|
||||
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
|
||||
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
@@ -192,10 +211,10 @@ it("translates discovery, progress and privacy without importing or authorizing
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(
|
||||
screen.queryByText(
|
||||
"Antes de confirmar, solo se comprueba la información de las aplicaciones. No se leen conversaciones ni se instalan conexiones.",
|
||||
),
|
||||
).toBeTruthy();
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.getByText("/fixture/cursor · Ubicación predeterminada"),
|
||||
).toBeTruthy();
|
||||
@@ -270,10 +289,10 @@ it("translates import review and preserves independent permission disclosure", a
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(
|
||||
screen.queryByText(
|
||||
"A continuación, elige un modo de memoria. Podrás cambiarlo más adelante en Configuración.",
|
||||
),
|
||||
).toBeTruthy();
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("region", {
|
||||
name: "Progreso de importación de las aplicaciones",
|
||||
@@ -305,16 +324,15 @@ it("uses compact Spanish actions and preserves Chinese copy and permissions", as
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/A continuación, elige un modo de memoria/),
|
||||
).toBeTruthy();
|
||||
screen.queryByText(/A continuación, elige un modo de memoria/),
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.getByText(/Solo para las apps seleccionadas y compatibles/),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText(/Si una falla, las demás siguen/)).toBeTruthy();
|
||||
fireEvent.click(screen.getByText("中文"));
|
||||
expect(screen.getByRole("button", { name: "选择记忆模式" })).toBeTruthy();
|
||||
expect(navigation.textContent).toContain("连接应用");
|
||||
expect(screen.getByText(/下一步选择记忆模式/)).toBeTruthy();
|
||||
expect(screen.queryByText(/下一步选择记忆模式/)).toBeNull();
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(draft).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -406,6 +424,12 @@ it("translates completion recovery live without repeating completion writes", as
|
||||
new Error("fixture-offline"),
|
||||
);
|
||||
const imports = controller();
|
||||
imports.sources = [];
|
||||
vi.mocked(imports.refresh).mockResolvedValue({
|
||||
sources: [],
|
||||
jobs: [],
|
||||
health: null as never,
|
||||
});
|
||||
const finish = vi.fn();
|
||||
render(
|
||||
<LanguageProvider>
|
||||
@@ -415,11 +439,11 @@ it("translates completion recovery live without repeating completion writes", as
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Entrar y añadir más tarde" }),
|
||||
screen.getByRole("button", { name: "Elegir el modo de memoria" }),
|
||||
).toHaveProperty("disabled", false),
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Entrar y añadir más tarde" }),
|
||||
screen.getByRole("button", { name: "Elegir el modo de memoria" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Entrar con Lite" }));
|
||||
|
||||
@@ -26,13 +26,32 @@ vi.mock("./api", () => ({
|
||||
getImportJobs: vi.fn(),
|
||||
connectSources: vi.fn(),
|
||||
completeOnboarding: vi.fn(),
|
||||
getEvolveSchedule: vi.fn().mockResolvedValue({
|
||||
enabled: true,
|
||||
time: "03:00",
|
||||
}),
|
||||
setEvolveSchedule: vi.fn(),
|
||||
memoryModePlanUnsupported: vi.fn(() => false),
|
||||
getMemoryMode: vi.fn().mockResolvedValue({
|
||||
mode: "pro",
|
||||
configured_mode: "pro",
|
||||
explicitly_configured: false,
|
||||
}),
|
||||
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
|
||||
planMemoryMode: vi.fn().mockResolvedValue({ blockers: [] }),
|
||||
planMemoryMode: vi.fn().mockResolvedValue({
|
||||
current: "pro",
|
||||
target: "lite",
|
||||
effective_target: "lite",
|
||||
capabilities: {
|
||||
content_plane: "raw",
|
||||
generation_required: false,
|
||||
graph_enabled: false,
|
||||
evolve_available: false,
|
||||
},
|
||||
effects: [],
|
||||
warnings: [],
|
||||
blockers: [],
|
||||
}),
|
||||
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
|
||||
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
|
||||
getSourceConnection: vi.fn(),
|
||||
@@ -159,8 +178,23 @@ beforeEach(() => {
|
||||
} as never);
|
||||
vi.mocked(api.setMemoryMode).mockResolvedValue({ mode: "pro" } as never);
|
||||
vi.mocked(api.planMemoryMode).mockResolvedValue({
|
||||
current: "pro",
|
||||
target: "lite",
|
||||
effective_target: "lite",
|
||||
capabilities: {
|
||||
content_plane: "raw",
|
||||
generation_required: false,
|
||||
graph_enabled: false,
|
||||
evolve_available: false,
|
||||
},
|
||||
effects: [],
|
||||
warnings: [],
|
||||
blockers: [],
|
||||
} as never);
|
||||
vi.mocked(api.getEvolveSchedule).mockResolvedValue({
|
||||
enabled: true,
|
||||
time: "03:00",
|
||||
});
|
||||
vi.mocked(api.putOnboardingProgress).mockResolvedValue(state);
|
||||
vi.mocked(api.patchOnboardingProgress).mockResolvedValue(state);
|
||||
localStorage.clear();
|
||||
@@ -313,11 +347,11 @@ it("keeps the single-source submit frozen when another source arrives during fin
|
||||
it("empty discovery shows zero and allows explicit entry without a connection request", async () => {
|
||||
await ready();
|
||||
await waitFor(() => expect(count()).toBe("0"));
|
||||
expect(screen.getByRole("button", { name: "开始导入" })).toHaveProperty(
|
||||
expect(screen.getByRole("button", { name: "选择记忆模式" })).toHaveProperty(
|
||||
"disabled",
|
||||
true,
|
||||
false,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "稍后添加,先进入" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "选择记忆模式" }));
|
||||
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
||||
await waitFor(() =>
|
||||
@@ -363,13 +397,7 @@ it("partial acceptance preserves the successful job and failure through model-st
|
||||
"accepted-job:completed",
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "继续配置模型" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "返回导入" }));
|
||||
expect(screen.getByLabelText("保留任务").textContent).toBe(
|
||||
"accepted-job:completed",
|
||||
);
|
||||
expect(screen.getByLabelText("失败来源").textContent).toBe("cursor");
|
||||
expect(api.completeOnboarding).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "进入应用" }));
|
||||
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
|
||||
await waitFor(() =>
|
||||
|
||||
@@ -15,6 +15,12 @@ import type { ImportSourcesController } from "./use-import-sources";
|
||||
|
||||
vi.mock("./api", () => ({
|
||||
completeOnboarding: vi.fn(),
|
||||
getEvolveSchedule: vi.fn().mockResolvedValue({
|
||||
enabled: true,
|
||||
time: "03:00",
|
||||
}),
|
||||
setEvolveSchedule: vi.fn(),
|
||||
memoryModePlanUnsupported: vi.fn(() => false),
|
||||
getMemoryMode: vi.fn().mockResolvedValue({
|
||||
mode: "pro",
|
||||
configured_mode: "pro",
|
||||
@@ -119,7 +125,7 @@ it("renders the final shell, double title, step one, branded rows and exact sour
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "把熟悉的背景,带到这里" }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("你的记忆,从这里接上")).toBeTruthy();
|
||||
expect(screen.queryByText("你的记忆,从这里接上")).toBeNull();
|
||||
expect(
|
||||
screen
|
||||
.getByRole("navigation", { name: "首次设置进度" })
|
||||
@@ -127,8 +133,8 @@ it("renders the final shell, double title, step one, branded rows and exact sour
|
||||
).toContain("导入历史");
|
||||
expect(screen.getByRole("heading", { name: "本机 AI 应用" })).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText("确认前只检查应用信息,不读取对话,也不安装连接。"),
|
||||
).toBeTruthy();
|
||||
screen.queryByText("确认前只检查应用信息,不读取对话,也不安装连接。"),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "导入历史包" })).toBeNull();
|
||||
expect(screen.getByText("/fixture/workbuddy · 默认位置")).toBeTruthy();
|
||||
expect(
|
||||
@@ -343,7 +349,7 @@ it("custom source cancel does not grant or select and accepted import retains re
|
||||
expect(
|
||||
(screen.getByLabelText("导入 codex 历史") as HTMLInputElement).checked,
|
||||
).toBe(false);
|
||||
fireEvent.click(screen.getByRole("button", { name: "开始导入" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "开始导入" }));
|
||||
await screen.findByText("已受理 workbuddy");
|
||||
expect(vi.mocked(imports.connectForOnboarding).mock.calls[0]?.[0]).toEqual([
|
||||
"workbuddy",
|
||||
@@ -367,7 +373,7 @@ it("does not submit authorization when the first-step owner leaves during final
|
||||
done = resolve;
|
||||
}),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "开始导入" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "开始导入" }));
|
||||
view.unmount();
|
||||
await act(async () =>
|
||||
done({
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,9 +1,12 @@
|
||||
import type {
|
||||
ConnectSourcesResponse,
|
||||
EvolveSchedule,
|
||||
ImportJob,
|
||||
OnboardingModeIntent,
|
||||
OnboardingState,
|
||||
SourceResponse,
|
||||
} from "@one-memory/contracts";
|
||||
import { legacyMemoryModePlan } from "@one-memory/operations";
|
||||
import { Check, X } from "lucide-react";
|
||||
import {
|
||||
type CSSProperties,
|
||||
@@ -18,10 +21,14 @@ import {
|
||||
import {
|
||||
apiErrorCode,
|
||||
completeOnboarding,
|
||||
getEvolveSchedule,
|
||||
getMemoryMode,
|
||||
getModelProfiles,
|
||||
memoryModePlanUnsupported,
|
||||
patchOnboardingProgress,
|
||||
planMemoryMode,
|
||||
putOnboardingProgress,
|
||||
setEvolveSchedule,
|
||||
setMemoryMode,
|
||||
} from "./api";
|
||||
import { AppMark } from "./app-mark";
|
||||
@@ -34,24 +41,29 @@ import {
|
||||
defaultHostSourceIds,
|
||||
FirstImportReview,
|
||||
type ImportConnectionDraft,
|
||||
type ImportSubmissionState,
|
||||
} from "./FirstImportReview";
|
||||
import {
|
||||
enableHostConnection,
|
||||
hostCapabilities,
|
||||
hostWriteReason,
|
||||
} from "./host-state";
|
||||
import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
|
||||
import { MemoryModeCards } from "./MemoryModeCards";
|
||||
import { BLOCKER_COPY } from "./memory-mode-ui";
|
||||
import { OnboardingModelController } from "./OnboardingModelController";
|
||||
import { useOnboardingTranslation } from "./onboarding-language";
|
||||
import { R6ColdStartFrame } from "./R6ColdStartFrame";
|
||||
import { R6Button } from "./R6Controls";
|
||||
import { traceUiAction } from "./ui-trace";
|
||||
import type { ImportSourcesController } from "./use-import-sources";
|
||||
import {
|
||||
type ImportSourcesController,
|
||||
isDefiniteAccessRefusal,
|
||||
} from "./use-import-sources";
|
||||
import { isDefiniteRefusal } from "./write-outcome";
|
||||
import "./cold-start.css";
|
||||
|
||||
export type OnboardingDestination = "memory" | "sources" | "settings";
|
||||
type OnboardingMemoryMode = "lite" | "pro";
|
||||
type OnboardingMemoryMode = OnboardingModeIntent;
|
||||
|
||||
// Cold start neither reads nor writes installation intent.
|
||||
export const PLUGIN_CONSENT_KEY = "one-memory.consent.plugins";
|
||||
@@ -112,9 +124,8 @@ export function OnboardingFlow({
|
||||
const [locationCheck, setLocationCheck] = useState<LocationCheck>("checking");
|
||||
const [selected, setSelected] = useState<SourceResponse[]>([]);
|
||||
const [custom, setCustom] = useState<SourceResponse | null>(null);
|
||||
const [busy, setBusy] = useState<
|
||||
"checking" | "submitting" | "leaving" | null
|
||||
>(null);
|
||||
const [busy, setBusy] = useState<"checking" | "submitting" | null>(null);
|
||||
const [completionBusy, setCompletionBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [itemErrors, setItemErrors] = useState<Record<string, string>>({});
|
||||
const [exitDestination, setExitDestination] =
|
||||
@@ -124,8 +135,12 @@ export function OnboardingFlow({
|
||||
const [startingImport, setStartingImport] = useState<SourceResponse[] | null>(
|
||||
null,
|
||||
);
|
||||
const [importSubmissionState, setImportSubmissionState] =
|
||||
useState<ImportSubmissionState>("confirmed");
|
||||
const resumedStep =
|
||||
state.mode_intent === "pro" && state.setup_step === "model"
|
||||
state.mode_intent &&
|
||||
state.mode_intent !== "lite" &&
|
||||
state.setup_step === "model"
|
||||
? "model"
|
||||
: state.mode_intent
|
||||
? "mode"
|
||||
@@ -138,8 +153,20 @@ export function OnboardingFlow({
|
||||
);
|
||||
const [modeDestination, setModeDestination] =
|
||||
useState<OnboardingDestination>("sources");
|
||||
const [modeSaving, setModeSaving] = useState(false);
|
||||
const [modePhase, setModePhase] = useState<"idle" | "saving" | "completing">(
|
||||
"idle",
|
||||
);
|
||||
const modeSaving = modePhase !== "idle";
|
||||
const [modeError, setModeError] = useState<string | null>(null);
|
||||
const [knownMode, setKnownMode] = useState<OnboardingMemoryMode>("pro");
|
||||
const [ultraSchedule, setUltraSchedule] = useState<EvolveSchedule>({
|
||||
enabled: true,
|
||||
time: "03:00",
|
||||
});
|
||||
const [scheduleLoaded, setScheduleLoaded] = useState(false);
|
||||
const [scheduleReadFailed, setScheduleReadFailed] = useState(false);
|
||||
const [scheduleLoading, setScheduleLoading] = useState(true);
|
||||
const scheduleReadOperation = useRef(0);
|
||||
const [modeFocusToken, setModeFocusToken] = useState(
|
||||
resumedStep === "mode" ? 1 : 0,
|
||||
);
|
||||
@@ -181,7 +208,10 @@ export function OnboardingFlow({
|
||||
)
|
||||
.join("、"),
|
||||
});
|
||||
const flight = useRef(false);
|
||||
const sourceFlight = useRef(false);
|
||||
const modeOperation = useRef(0);
|
||||
const proConfirmationOwner = useRef<string | null>(null);
|
||||
const completionFlight = useRef(false);
|
||||
const connectionAttempt = useRef<string | null>(null);
|
||||
const connectionQueue = useRef(Promise.resolve());
|
||||
const connectionQueueDepth = useRef(0);
|
||||
@@ -368,20 +398,24 @@ export function OnboardingFlow({
|
||||
selected.some((chosen) => chosen.source_id === item.source_id),
|
||||
);
|
||||
const locked =
|
||||
busy !== null || exitDestination !== null || state.status === "importing";
|
||||
busy !== null ||
|
||||
completionBusy ||
|
||||
exitDestination !== null ||
|
||||
state.status === "importing";
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
const modeTicket = issueConfirmedModeTicket("onboarding");
|
||||
void getMemoryMode().then(
|
||||
(memoryMode) => {
|
||||
if (alive.current)
|
||||
if (alive.current) {
|
||||
commitConfirmedMemoryMode(memoryMode.mode, modeTicket);
|
||||
setKnownMode(memoryMode.mode);
|
||||
}
|
||||
if (
|
||||
alive.current &&
|
||||
!modeChoiceTouched.current &&
|
||||
memoryMode.explicitly_configured &&
|
||||
memoryMode.configured_mode !== "ultra"
|
||||
memoryMode.explicitly_configured
|
||||
)
|
||||
setModeChoice(memoryMode.configured_mode);
|
||||
},
|
||||
@@ -394,6 +428,35 @@ export function OnboardingFlow({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refreshUltraSchedule = useCallback(async () => {
|
||||
const operation = ++scheduleReadOperation.current;
|
||||
setScheduleLoaded(false);
|
||||
setScheduleLoading(true);
|
||||
try {
|
||||
const schedule = await getEvolveSchedule();
|
||||
if (!alive.current || scheduleReadOperation.current !== operation) return;
|
||||
if (!schedule) return;
|
||||
setUltraSchedule(schedule);
|
||||
setScheduleLoaded(true);
|
||||
setScheduleReadFailed(false);
|
||||
setModeError((current) =>
|
||||
current === "记忆模式状态暂时无法读取,请重试。" ? null : current,
|
||||
);
|
||||
} catch {
|
||||
if (alive.current && scheduleReadOperation.current === operation) {
|
||||
setScheduleLoaded(false);
|
||||
setScheduleReadFailed(true);
|
||||
}
|
||||
} finally {
|
||||
if (alive.current && scheduleReadOperation.current === operation)
|
||||
setScheduleLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshUltraSchedule();
|
||||
}, [refreshUltraSchedule]);
|
||||
|
||||
useEffect(() => {
|
||||
setSources(imports.sources);
|
||||
}, [imports.sources]);
|
||||
@@ -456,7 +519,7 @@ export function OnboardingFlow({
|
||||
locationCheck !== "ready" ||
|
||||
locked ||
|
||||
reviewingImport ||
|
||||
flight.current
|
||||
sourceFlight.current
|
||||
)
|
||||
return;
|
||||
if (!scopeMatches(selected, sources)) return;
|
||||
@@ -483,17 +546,24 @@ export function OnboardingFlow({
|
||||
[onFinish],
|
||||
);
|
||||
|
||||
const enterModelStep = useCallback(() => {
|
||||
intentRef.current = "pro";
|
||||
setupStepRef.current = "model";
|
||||
const token = ++modelGeneration.current;
|
||||
setModelGenerationToken(token);
|
||||
setVisitedModel(true);
|
||||
setSetupStep("model");
|
||||
}, []);
|
||||
const enterModelStep = useCallback(
|
||||
(mode: Exclude<OnboardingMemoryMode, "lite"> = "pro") => {
|
||||
intentRef.current = mode;
|
||||
setupStepRef.current = "model";
|
||||
const token = ++modelGeneration.current;
|
||||
setModelGenerationToken(token);
|
||||
setVisitedModel(true);
|
||||
setSetupStep("model");
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const leaveModelStep = useCallback(
|
||||
(nextIntent: OnboardingMemoryMode, nextStep: "review" | "mode") => {
|
||||
(
|
||||
nextIntent: OnboardingMemoryMode,
|
||||
nextStep: "review" | "mode",
|
||||
resetModePhase = true,
|
||||
) => {
|
||||
// This fence is synchronous: hidden model work must observe invalidation
|
||||
// before React renders the replacement step.
|
||||
modelGeneration.current += 1;
|
||||
@@ -502,7 +572,10 @@ export function OnboardingFlow({
|
||||
setModeChoice(nextIntent);
|
||||
setSetupStep(nextStep);
|
||||
if (nextStep === "mode") setModeFocusToken((value) => value + 1);
|
||||
setModeSaving(false);
|
||||
if (resetModePhase) {
|
||||
modeOperation.current += 1;
|
||||
setModePhase("idle");
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -611,12 +684,15 @@ export function OnboardingFlow({
|
||||
|
||||
const leave = useCallback(
|
||||
async (destination: OnboardingDestination) => {
|
||||
if (flight.current || finished.current) return;
|
||||
flight.current = true;
|
||||
setBusy("leaving");
|
||||
await persistFinish(destination);
|
||||
flight.current = false;
|
||||
if (alive.current) setBusy(null);
|
||||
if (completionFlight.current || finished.current) return;
|
||||
completionFlight.current = true;
|
||||
setCompletionBusy(true);
|
||||
try {
|
||||
await persistFinish(destination);
|
||||
} finally {
|
||||
completionFlight.current = false;
|
||||
if (alive.current) setCompletionBusy(false);
|
||||
}
|
||||
},
|
||||
[persistFinish],
|
||||
);
|
||||
@@ -624,7 +700,8 @@ export function OnboardingFlow({
|
||||
const requestModeChoice = useCallback(
|
||||
(destination: OnboardingDestination) => {
|
||||
if (finished.current) return;
|
||||
if (setupStepRef.current === "model") leaveModelStep("pro", "mode");
|
||||
if (setupStepRef.current === "model")
|
||||
leaveModelStep(intentRef.current === "ultra" ? "ultra" : "pro", "mode");
|
||||
else {
|
||||
intentRef.current = modeChoice;
|
||||
setupStepRef.current = "mode";
|
||||
@@ -639,30 +716,97 @@ export function OnboardingFlow({
|
||||
[leaveModelStep, modeChoice],
|
||||
);
|
||||
|
||||
const loadModePlan = useCallback(
|
||||
async (target: OnboardingMemoryMode) => {
|
||||
try {
|
||||
return await planMemoryMode(target);
|
||||
} catch (reason) {
|
||||
if (!memoryModePlanUnsupported(reason)) throw reason;
|
||||
const profiles = await getModelProfiles();
|
||||
return legacyMemoryModePlan(knownMode, target, {
|
||||
hasActiveModel: profiles.some((profile) => profile.is_active),
|
||||
});
|
||||
}
|
||||
},
|
||||
[knownMode],
|
||||
);
|
||||
|
||||
const saveModeChoice = useCallback(async () => {
|
||||
if (
|
||||
!modeChoice ||
|
||||
modeSaving ||
|
||||
finished.current ||
|
||||
(modeChoice === "lite" && flight.current)
|
||||
)
|
||||
return;
|
||||
setModeSaving(true);
|
||||
if (!modeChoice || modeSaving || finished.current || completionBusy) return;
|
||||
const operation = ++modeOperation.current;
|
||||
const ownsModeOperation = () =>
|
||||
alive.current && !finished.current && modeOperation.current === operation;
|
||||
setModePhase("saving");
|
||||
setModeError(null);
|
||||
const modeTicket = issueConfirmedModeTicket("onboarding");
|
||||
try {
|
||||
if (modeChoice === "pro") {
|
||||
intentRef.current = "pro";
|
||||
if (modeChoice !== "lite") {
|
||||
const target = modeChoice;
|
||||
const [plan, profiles] = await Promise.all([
|
||||
loadModePlan(target),
|
||||
getModelProfiles(),
|
||||
]);
|
||||
if (!ownsModeOperation()) return;
|
||||
const hasActiveModel = profiles.some((profile) => profile.is_active);
|
||||
if (target === "ultra" && !scheduleLoaded) {
|
||||
setModeError(
|
||||
scheduleReadFailed
|
||||
? "记忆模式状态暂时无法读取,请重试。"
|
||||
: "正在读取记忆模式…",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const blocking = plan.blockers.filter(
|
||||
(blocker) => blocker !== "active_model_required",
|
||||
);
|
||||
if (blocking.length > 0) {
|
||||
setModeError(
|
||||
blocking.map((blocker) => BLOCKER_COPY[blocker]).join(" "),
|
||||
);
|
||||
return;
|
||||
}
|
||||
intentRef.current = target;
|
||||
setupStepRef.current = "mode";
|
||||
const selectionStillCurrent = () =>
|
||||
alive.current &&
|
||||
!finished.current &&
|
||||
intentRef.current === "pro" &&
|
||||
ownsModeOperation() &&
|
||||
intentRef.current === target &&
|
||||
setupStepRef.current === "mode";
|
||||
if (
|
||||
hasActiveModel &&
|
||||
!plan.blockers.includes("active_model_required")
|
||||
) {
|
||||
const changed = await queueModeWrite(target, selectionStillCurrent);
|
||||
if (!selectionStillCurrent()) return;
|
||||
commitConfirmedMemoryMode(changed.mode, modeTicket);
|
||||
setKnownMode(changed.mode);
|
||||
if (target === "ultra") {
|
||||
try {
|
||||
await setEvolveSchedule(ultraSchedule);
|
||||
} catch {
|
||||
if (selectionStillCurrent())
|
||||
setModeError("自动整合设置未能保存,请重试。");
|
||||
return;
|
||||
}
|
||||
if (!selectionStillCurrent()) return;
|
||||
}
|
||||
await queueProgressWrite(
|
||||
() =>
|
||||
putOnboardingProgress({
|
||||
mode_intent: target,
|
||||
setup_step: "mode",
|
||||
model_profile_id: null,
|
||||
}),
|
||||
selectionStillCurrent,
|
||||
);
|
||||
if (!selectionStillCurrent()) return;
|
||||
setModePhase("completing");
|
||||
await leave(modeDestination);
|
||||
return;
|
||||
}
|
||||
const savedProgress = await queueProgressWrite(
|
||||
() =>
|
||||
putOnboardingProgress({
|
||||
mode_intent: "pro",
|
||||
mode_intent: target,
|
||||
setup_step: "model",
|
||||
model_profile_id: modelProfileId.current,
|
||||
}),
|
||||
@@ -670,15 +814,26 @@ export function OnboardingFlow({
|
||||
);
|
||||
if (!selectionStillCurrent()) return;
|
||||
modelProfileId.current = savedProgress.model_profile_id;
|
||||
enterModelStep();
|
||||
enterModelStep(target);
|
||||
return;
|
||||
}
|
||||
leaveModelStep("lite", "mode");
|
||||
leaveModelStep("lite", "mode", false);
|
||||
const liteStillCurrent = () =>
|
||||
alive.current &&
|
||||
!finished.current &&
|
||||
ownsModeOperation() &&
|
||||
intentRef.current === "lite" &&
|
||||
setupStepRef.current === "mode";
|
||||
const plan = await loadModePlan("lite");
|
||||
if (!liteStillCurrent()) return;
|
||||
if (plan.blockers.length > 0) {
|
||||
setModeError(
|
||||
plan.blockers.map((blocker) => BLOCKER_COPY[blocker]).join(" "),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const changed = await queueModeWrite("lite", liteStillCurrent);
|
||||
if (!liteStillCurrent()) return;
|
||||
commitConfirmedMemoryMode(changed.mode, modeTicket);
|
||||
setKnownMode(changed.mode);
|
||||
await queueProgressWrite(
|
||||
() =>
|
||||
putOnboardingProgress({
|
||||
@@ -688,19 +843,17 @@ export function OnboardingFlow({
|
||||
}),
|
||||
liteStillCurrent,
|
||||
);
|
||||
if (!alive.current) return;
|
||||
const changed = await queueModeWrite("lite");
|
||||
if (!alive.current) return;
|
||||
commitConfirmedMemoryMode(changed.mode, modeTicket);
|
||||
if (!liteStillCurrent()) return;
|
||||
setModePhase("completing");
|
||||
await leave(modeDestination);
|
||||
} catch (reason) {
|
||||
if (isCompletedProgressConflict(reason)) {
|
||||
finishOnce(modeDestination);
|
||||
} else if (alive.current) {
|
||||
} else if (ownsModeOperation() && !isGenerationInvalid(reason)) {
|
||||
setModeError("模式未能保存。你的选择已保留,请重试。");
|
||||
}
|
||||
} finally {
|
||||
if (alive.current) setModeSaving(false);
|
||||
if (ownsModeOperation()) setModePhase("idle");
|
||||
}
|
||||
}, [
|
||||
enterModelStep,
|
||||
@@ -709,9 +862,14 @@ export function OnboardingFlow({
|
||||
modeChoice,
|
||||
modeDestination,
|
||||
modeSaving,
|
||||
completionBusy,
|
||||
finishOnce,
|
||||
loadModePlan,
|
||||
queueModeWrite,
|
||||
queueProgressWrite,
|
||||
scheduleLoaded,
|
||||
scheduleReadFailed,
|
||||
ultraSchedule,
|
||||
]);
|
||||
|
||||
const persistModelProfile = useCallback(
|
||||
@@ -729,58 +887,101 @@ export function OnboardingFlow({
|
||||
[queueProgressWrite],
|
||||
);
|
||||
|
||||
const confirmProMode = useCallback(
|
||||
const confirmModelMode = useCallback(
|
||||
async (token: number, ownerProfileId: string) => {
|
||||
const target = intentRef.current;
|
||||
if (target !== "pro" && target !== "ultra") return;
|
||||
if (target === "ultra" && !scheduleLoaded) {
|
||||
leaveModelStep("ultra", "mode");
|
||||
setModeError(
|
||||
scheduleReadFailed
|
||||
? "记忆模式状态暂时无法读取,请重试。"
|
||||
: "正在读取记忆模式…",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const confirmationOwner = `${token}:${ownerProfileId}:${target}`;
|
||||
const ownsModelStep = () =>
|
||||
alive.current &&
|
||||
!finished.current &&
|
||||
modelGeneration.current === token &&
|
||||
intentRef.current === "pro" &&
|
||||
intentRef.current === target &&
|
||||
setupStepRef.current === "model" &&
|
||||
modelProfileId.current === ownerProfileId;
|
||||
if (!ownsModelStep()) return;
|
||||
setModeSaving(true);
|
||||
if (
|
||||
!ownsModelStep() ||
|
||||
proConfirmationOwner.current === confirmationOwner ||
|
||||
completionBusy
|
||||
)
|
||||
return;
|
||||
proConfirmationOwner.current = confirmationOwner;
|
||||
const operation = ++modeOperation.current;
|
||||
const ownsConfirmation = () =>
|
||||
ownsModelStep() && modeOperation.current === operation;
|
||||
setModePhase("saving");
|
||||
setModeError(null);
|
||||
const modeTicket = issueConfirmedModeTicket("onboarding");
|
||||
try {
|
||||
const plan = await planMemoryMode("pro");
|
||||
if (!ownsModelStep()) return;
|
||||
const plan = await loadModePlan(target);
|
||||
if (!ownsConfirmation()) return;
|
||||
if (plan.blockers.length > 0) throw new Error(plan.blockers.join(","));
|
||||
const changed = await queueModeWrite("pro", ownsModelStep);
|
||||
if (!ownsModelStep()) return;
|
||||
const changed = await queueModeWrite(target, ownsConfirmation);
|
||||
if (!ownsConfirmation()) return;
|
||||
commitConfirmedMemoryMode(changed.mode, modeTicket);
|
||||
setKnownMode(changed.mode);
|
||||
if (target === "ultra") {
|
||||
try {
|
||||
await setEvolveSchedule(ultraSchedule);
|
||||
} catch {
|
||||
if (ownsConfirmation()) {
|
||||
leaveModelStep("ultra", "mode");
|
||||
setModeError("自动整合设置未能保存,请重试。");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!ownsConfirmation()) return;
|
||||
}
|
||||
await queueProgressWrite(
|
||||
() =>
|
||||
putOnboardingProgress({
|
||||
mode_intent: "pro",
|
||||
mode_intent: target,
|
||||
setup_step: "model",
|
||||
model_profile_id: ownerProfileId,
|
||||
}),
|
||||
ownsModelStep,
|
||||
ownsConfirmation,
|
||||
);
|
||||
if (!ownsModelStep()) return;
|
||||
await persistFinish(modeDestination);
|
||||
if (!ownsConfirmation()) return;
|
||||
setModePhase("completing");
|
||||
await leave(modeDestination);
|
||||
} catch (reason) {
|
||||
if (isCompletedProgressConflict(reason)) {
|
||||
finishOnce(modeDestination);
|
||||
} else if (alive.current && !isGenerationInvalid(reason))
|
||||
setModeError("Pro 模式未能确认保存。模型配置已保留,请重试。");
|
||||
} else if (ownsConfirmation() && !isGenerationInvalid(reason))
|
||||
setModeError("模式切换未能确认,仍显示上次确认的模式。");
|
||||
} finally {
|
||||
if (alive.current) setModeSaving(false);
|
||||
if (proConfirmationOwner.current === confirmationOwner)
|
||||
proConfirmationOwner.current = null;
|
||||
if (ownsConfirmation()) setModePhase("idle");
|
||||
}
|
||||
},
|
||||
[
|
||||
completionBusy,
|
||||
finishOnce,
|
||||
leave,
|
||||
leaveModelStep,
|
||||
loadModePlan,
|
||||
modeDestination,
|
||||
persistFinish,
|
||||
queueModeWrite,
|
||||
queueProgressWrite,
|
||||
scheduleLoaded,
|
||||
scheduleReadFailed,
|
||||
ultraSchedule,
|
||||
],
|
||||
);
|
||||
|
||||
async function checkLocations() {
|
||||
if (flight.current || locked) return;
|
||||
flight.current = true;
|
||||
if (sourceFlight.current || locked) return;
|
||||
sourceFlight.current = true;
|
||||
scopeVersion.current += 1;
|
||||
setBusy("checking");
|
||||
setLocationCheck("checking");
|
||||
@@ -805,7 +1006,7 @@ export function OnboardingFlow({
|
||||
setLocationCheck("failed");
|
||||
setError("位置检测未完成,尚未读取历史。可以重新检测,或先进入工作台。");
|
||||
} finally {
|
||||
flight.current = false;
|
||||
sourceFlight.current = false;
|
||||
if (alive.current) setBusy(null);
|
||||
}
|
||||
}
|
||||
@@ -813,7 +1014,7 @@ export function OnboardingFlow({
|
||||
|
||||
function toggleSource(source: SourceResponse, checked: boolean) {
|
||||
if (
|
||||
flight.current ||
|
||||
sourceFlight.current ||
|
||||
locked ||
|
||||
locationCheck !== "ready" ||
|
||||
!isAppSource(source) ||
|
||||
@@ -836,7 +1037,7 @@ export function OnboardingFlow({
|
||||
}
|
||||
|
||||
function selectAllSources() {
|
||||
if (flight.current || locked || locationCheck !== "ready") return;
|
||||
if (sourceFlight.current || locked || locationCheck !== "ready") return;
|
||||
scopeVersion.current += 1;
|
||||
setError(null);
|
||||
setItemErrors({});
|
||||
@@ -849,7 +1050,7 @@ export function OnboardingFlow({
|
||||
|
||||
async function startImport() {
|
||||
if (
|
||||
flight.current ||
|
||||
sourceFlight.current ||
|
||||
locked ||
|
||||
locationCheck !== "ready" ||
|
||||
!selected.length
|
||||
@@ -863,7 +1064,8 @@ export function OnboardingFlow({
|
||||
}
|
||||
|
||||
async function submitImport(approved: SourceResponse[]) {
|
||||
flight.current = true;
|
||||
sourceFlight.current = true;
|
||||
setImportSubmissionState("checking");
|
||||
if (window.oneMemory) setStartingImport(approved);
|
||||
const version = scopeVersion.current;
|
||||
const retryFailed = Object.keys(itemErrors).length > 0;
|
||||
@@ -875,8 +1077,11 @@ export function OnboardingFlow({
|
||||
try {
|
||||
current = (await imports.refresh()).sources;
|
||||
} catch {
|
||||
if (alive.current)
|
||||
if (alive.current) {
|
||||
setStartingImport(null);
|
||||
setImportSubmissionState("refused");
|
||||
setError("暂时无法核对来源,尚未提交授权。选择已保留,可以重试。");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!alive.current) return;
|
||||
@@ -899,6 +1104,7 @@ export function OnboardingFlow({
|
||||
setItemErrors(result.failed_sources);
|
||||
if (result.accepted_source_ids.length === 0) {
|
||||
setStartingImport(null);
|
||||
setImportSubmissionState("refused");
|
||||
setError(
|
||||
"本次没有来源获准导入。请核对下方原因后重试,或到应用页查看状态。",
|
||||
);
|
||||
@@ -907,22 +1113,36 @@ export function OnboardingFlow({
|
||||
// Keep the accepted intent and job IDs while the user inspects early originals.
|
||||
setAccepted(result);
|
||||
setStartingImport(null);
|
||||
setImportSubmissionState("confirmed");
|
||||
setError(null);
|
||||
} catch {
|
||||
} catch (reason) {
|
||||
if (alive.current) {
|
||||
setStartingImport(null);
|
||||
setError(
|
||||
"开始导入的结果尚未确认,选择已保留。可以重试,或进入应用页核对。",
|
||||
);
|
||||
const refused = isDefiniteAccessRefusal(reason);
|
||||
if (refused) {
|
||||
setStartingImport(null);
|
||||
setImportSubmissionState("refused");
|
||||
setError(
|
||||
"本次没有来源获准导入。请核对下方原因后重试,或到应用页查看状态。",
|
||||
);
|
||||
} else {
|
||||
setImportSubmissionState("checking");
|
||||
setError(
|
||||
window.oneMemory
|
||||
? null
|
||||
: "开始导入的结果尚未确认,选择已保留。可以重试,或进入应用页核对。",
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
connectSent.current = false;
|
||||
flight.current = false;
|
||||
sourceFlight.current = false;
|
||||
if (alive.current) setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
const count = selected.length;
|
||||
const continueWithoutImport =
|
||||
busy === null && locationCheck !== "checking" && count === 0;
|
||||
// D51 item 3: only a *confirmed* snapshot may show a number. A failed
|
||||
// detection must read 未完成 even when sources are listed — otherwise the
|
||||
// number branch answered first and the summary claimed a count while the
|
||||
@@ -987,10 +1207,11 @@ export function OnboardingFlow({
|
||||
jobs={reviewJobs}
|
||||
failedSources={accepted?.failed_sources}
|
||||
sourceSnapshot={imports.sourceSnapshot}
|
||||
busy={busy === "leaving"}
|
||||
importSubmissionState={importSubmissionState}
|
||||
busy={completionBusy}
|
||||
error={error && text(error)}
|
||||
onContinue={(destination) => {
|
||||
if (busy === "leaving") return;
|
||||
if (completionBusy) return;
|
||||
if (completionFailed) {
|
||||
void leave(destination);
|
||||
return;
|
||||
@@ -1025,96 +1246,109 @@ export function OnboardingFlow({
|
||||
<R6ColdStartFrame
|
||||
step={3}
|
||||
title={text("选择记忆模式")}
|
||||
eyebrow={text("最后一步 · 决定如何处理对话")}
|
||||
focusToken={modeFocusToken}
|
||||
footer={
|
||||
<>
|
||||
<R6Button
|
||||
variant="quiet"
|
||||
disabled={modeSaving || busy === "leaving"}
|
||||
onClick={() => {
|
||||
setModeError(null);
|
||||
intentRef.current = modeChoice;
|
||||
setupStepRef.current = "review";
|
||||
setSetupStep("review");
|
||||
void queueProgressWrite(() =>
|
||||
patchOnboardingProgress({
|
||||
mode_intent: modeChoice,
|
||||
setup_step: "review",
|
||||
}),
|
||||
).catch((reason) => {
|
||||
if (!isCompletedProgressConflict(reason) && alive.current)
|
||||
setModeError(
|
||||
"恢复进度未能保存。你的选择已保留,请重试。",
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{text("返回导入")}
|
||||
</R6Button>
|
||||
<R6Button
|
||||
className="onboarding-r6-submit"
|
||||
variant="primary"
|
||||
pending={modeSaving}
|
||||
disabled={
|
||||
!modeChoice ||
|
||||
busy === "leaving" ||
|
||||
(modeChoice === "lite" && flight.current)
|
||||
}
|
||||
onClick={() => void saveModeChoice()}
|
||||
>
|
||||
{text(
|
||||
modeSaving
|
||||
? "正在保存…"
|
||||
: modeChoice === "lite"
|
||||
? "使用 Lite 进入"
|
||||
: modeChoice === "pro"
|
||||
? "继续配置模型"
|
||||
: "继续",
|
||||
)}
|
||||
</R6Button>
|
||||
</>
|
||||
<R6Button
|
||||
className="onboarding-r6-submit"
|
||||
variant="primary"
|
||||
pending={modeSaving}
|
||||
disabled={
|
||||
!modeChoice ||
|
||||
completionBusy ||
|
||||
(modeChoice === "ultra" && !scheduleLoaded)
|
||||
}
|
||||
onClick={() => void saveModeChoice()}
|
||||
>
|
||||
{text(
|
||||
modePhase === "saving"
|
||||
? "正在保存模式…"
|
||||
: modePhase === "completing"
|
||||
? "正在完成首次设置…"
|
||||
: !modeChoice
|
||||
? "继续"
|
||||
: modeChoice === "lite"
|
||||
? "使用 Lite 进入"
|
||||
: "配置模型",
|
||||
)}
|
||||
</R6Button>
|
||||
}
|
||||
>
|
||||
<section
|
||||
className="onboarding-r6-mode"
|
||||
aria-labelledby="onboarding-memory-mode-title"
|
||||
aria-label={text("记忆模式")}
|
||||
>
|
||||
<div className="onboarding-r6-mode-intro">
|
||||
<p id="onboarding-memory-mode-title">
|
||||
{text("之后可在设置中更改。")}
|
||||
</p>
|
||||
<h2>{text("你希望 MemHub 如何形成记忆?")}</h2>
|
||||
<p>{text("需要明确选择一种方式;之后仍可在设置中更改。")}</p>
|
||||
</div>
|
||||
<MemoryModeChoiceList
|
||||
<MemoryModeCards
|
||||
aria-label={text("记忆模式")}
|
||||
modes={[
|
||||
{
|
||||
mode: "lite",
|
||||
title: "Lite",
|
||||
description: text(
|
||||
"无需模型,只保存并检索原始对话,不生成提炼记忆。",
|
||||
),
|
||||
},
|
||||
{
|
||||
mode: "pro",
|
||||
title: "Pro",
|
||||
description: text(
|
||||
"使用模型理解对话,提取并整理可持续生长的记忆。",
|
||||
),
|
||||
},
|
||||
]}
|
||||
selected={modeChoice}
|
||||
disabled={modeSaving || busy === "leaving"}
|
||||
recommended={{ mode: "pro", badge: text("推荐") }}
|
||||
value={modeChoice}
|
||||
layout="rows"
|
||||
describedBy={{
|
||||
ultra:
|
||||
modeChoice === "ultra"
|
||||
? "onboarding-ultra-schedule-description"
|
||||
: undefined,
|
||||
}}
|
||||
disabled={modeSaving || completionBusy}
|
||||
ultraDetailBusy={scheduleLoading}
|
||||
onChange={(mode) => {
|
||||
modeChoiceTouched.current = true;
|
||||
setModeChoice(mode as OnboardingMemoryMode);
|
||||
intentRef.current = mode;
|
||||
setModeChoice(mode);
|
||||
setModeError(null);
|
||||
}}
|
||||
ultraDetail={
|
||||
scheduleLoaded ? (
|
||||
<div className="onboarding-r6-ultra-default" role="status">
|
||||
<strong>{text("每天自动整合")}</strong>
|
||||
<small id="onboarding-ultra-schedule-description">
|
||||
{text(
|
||||
ultraSchedule.enabled
|
||||
? "每天 {time} 左右自动整合,使用这台设备的本地时间;休眠错过后会补跑。"
|
||||
: "已关闭。重新开启后仍按 {time} 运行;已生成的结果会保留。",
|
||||
).replace("{time}", ultraSchedule.time)}
|
||||
{ultraSchedule.enabled ? (
|
||||
<>
|
||||
<br />
|
||||
<span>
|
||||
{text("后续可在设置页自定义时间或关闭。")}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</small>
|
||||
</div>
|
||||
) : scheduleReadFailed ? (
|
||||
<div className="onboarding-r6-schedule-recovery">
|
||||
<p
|
||||
id="onboarding-ultra-schedule-description"
|
||||
role="alert"
|
||||
>
|
||||
{text("记忆模式状态暂时无法读取,请重试。")}
|
||||
</p>
|
||||
<R6Button
|
||||
className="onboarding-r6-schedule-retry"
|
||||
pending={scheduleLoading}
|
||||
disabled={
|
||||
scheduleLoading || modeSaving || completionBusy
|
||||
}
|
||||
onClick={() => void refreshUltraSchedule()}
|
||||
>
|
||||
{text("重试")}
|
||||
</R6Button>
|
||||
</div>
|
||||
) : (
|
||||
<p
|
||||
id="onboarding-ultra-schedule-description"
|
||||
className="onboarding-r6-schedule-loading"
|
||||
role="status"
|
||||
>
|
||||
{text("正在读取自动整合设置…")}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<p className="onboarding-r6-mode-ultra-note">
|
||||
{text("Ultra 可在完成首次设置后到设置页开启。")}
|
||||
</p>
|
||||
{(modeError || error) && (
|
||||
<p className="onboarding-r6-mode-error" role="alert">
|
||||
{text(modeError ?? error ?? "")}
|
||||
@@ -1129,7 +1363,8 @@ export function OnboardingFlow({
|
||||
generationToken={modelGenerationToken}
|
||||
isGenerationCurrent={(token) =>
|
||||
modelGeneration.current === token &&
|
||||
intentRef.current === "pro" &&
|
||||
intentRef.current !== "lite" &&
|
||||
intentRef.current !== null &&
|
||||
setupStepRef.current === "model"
|
||||
}
|
||||
initialProfileId={modelProfileId.current}
|
||||
@@ -1142,7 +1377,7 @@ export function OnboardingFlow({
|
||||
void persistModelProfile(profileId)
|
||||
}
|
||||
onModelActiveConfirmed={(token, ownerProfileId) =>
|
||||
void confirmProMode(token, ownerProfileId)
|
||||
void confirmModelMode(token, ownerProfileId)
|
||||
}
|
||||
sourceNames={sourceIds.map(
|
||||
(id) =>
|
||||
@@ -1157,10 +1392,11 @@ export function OnboardingFlow({
|
||||
)}
|
||||
onBack={() => {
|
||||
setModeError(null);
|
||||
leaveModelStep("pro", "mode");
|
||||
const target = intentRef.current === "ultra" ? "ultra" : "pro";
|
||||
leaveModelStep(target, "mode");
|
||||
void queueProgressWrite(() =>
|
||||
putOnboardingProgress({
|
||||
mode_intent: "pro",
|
||||
mode_intent: target,
|
||||
setup_step: "mode",
|
||||
model_profile_id: modelProfileId.current,
|
||||
}),
|
||||
@@ -1188,7 +1424,7 @@ export function OnboardingFlow({
|
||||
<>
|
||||
<R6Button
|
||||
variant="quiet"
|
||||
disabled={busy !== null}
|
||||
disabled={busy !== null || completionBusy}
|
||||
onClick={() => finishOnce(exitDestination)}
|
||||
>
|
||||
{text(
|
||||
@@ -1197,7 +1433,7 @@ export function OnboardingFlow({
|
||||
</R6Button>
|
||||
<R6Button
|
||||
variant="primary"
|
||||
disabled={busy !== null}
|
||||
disabled={busy !== null || completionBusy}
|
||||
onClick={() => void leave(exitDestination)}
|
||||
>
|
||||
{text("重试保存并进入")}
|
||||
@@ -1215,16 +1451,8 @@ export function OnboardingFlow({
|
||||
<R6ColdStartFrame
|
||||
step={1}
|
||||
title={text("把熟悉的背景,带到这里")}
|
||||
eyebrow={text("你的记忆,从这里接上")}
|
||||
footer={
|
||||
<>
|
||||
<R6Button
|
||||
variant="quiet"
|
||||
disabled={locked}
|
||||
onClick={() => requestModeChoice("sources")}
|
||||
>
|
||||
{text("稍后添加,先进入")}
|
||||
</R6Button>
|
||||
{error && (
|
||||
<p className="onboarding-r6-error" role="alert">
|
||||
{error && text(error)}
|
||||
@@ -1233,10 +1461,20 @@ export function OnboardingFlow({
|
||||
<R6Button
|
||||
className="onboarding-r6-submit"
|
||||
variant="primary"
|
||||
disabled={locked || count === 0}
|
||||
onClick={() => void startImport()}
|
||||
disabled={locked}
|
||||
onClick={() =>
|
||||
continueWithoutImport
|
||||
? requestModeChoice("sources")
|
||||
: void startImport()
|
||||
}
|
||||
>
|
||||
{text(busy === "submitting" ? "正在开始导入…" : "开始导入")}
|
||||
{text(
|
||||
continueWithoutImport
|
||||
? "选择记忆模式"
|
||||
: busy === "submitting"
|
||||
? "正在开始导入…"
|
||||
: "开始导入",
|
||||
)}
|
||||
</R6Button>
|
||||
</>
|
||||
}
|
||||
@@ -1246,12 +1484,6 @@ export function OnboardingFlow({
|
||||
className="onboarding-r6-apps"
|
||||
aria-labelledby="cold-start-phase"
|
||||
>
|
||||
<div className="onboarding-r6-intro">
|
||||
<p>
|
||||
{text("不用从头介绍自己。从常用的 App 开始,把已有背景带过来。")}
|
||||
</p>
|
||||
<p>{text("确认前只检查应用信息,不读取对话,也不安装连接。")}</p>
|
||||
</div>
|
||||
<div className="onboarding-r6-list-heading">
|
||||
<h2 id="cold-start-phase" ref={heading} tabIndex={-1}>
|
||||
{text("本机 AI 应用")}
|
||||
|
||||
@@ -23,7 +23,17 @@ const { embedding, getModelProfiles, models, step } = vi.hoisted(() => ({
|
||||
max_concurrency: 4,
|
||||
context_window: null,
|
||||
has_api_key: true,
|
||||
credential_state: "available" as
|
||||
| "available"
|
||||
| "missing"
|
||||
| "unavailable",
|
||||
is_active: true,
|
||||
capabilities: {
|
||||
endpoint_reachable: true,
|
||||
model_available: true,
|
||||
embedding_available: true,
|
||||
structured_output: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
pendingProbe: null as null | {
|
||||
@@ -57,7 +67,7 @@ const { embedding, getModelProfiles, models, step } = vi.hoisted(() => ({
|
||||
supported: false as boolean | null,
|
||||
planActivation: vi.fn(),
|
||||
startPlannedSwitch: vi.fn(),
|
||||
shouldUseLegacyActivation: () => true,
|
||||
shouldUseLegacyActivation: (() => true) as () => boolean,
|
||||
clearPlan: vi.fn(),
|
||||
clearError: vi.fn(),
|
||||
},
|
||||
@@ -123,6 +133,7 @@ beforeEach(() => {
|
||||
models.creationMissing = null;
|
||||
embedding.current = null;
|
||||
embedding.supported = false;
|
||||
embedding.shouldUseLegacyActivation = () => true;
|
||||
step.current = null;
|
||||
});
|
||||
|
||||
@@ -164,6 +175,32 @@ it("resumes and confirms the active profile explicitly owned by onboarding", asy
|
||||
expect(enabled).toHaveBeenCalledWith(1, "active-profile");
|
||||
});
|
||||
|
||||
it("does not complete with an active profile whose credential is unavailable", async () => {
|
||||
models.profiles = [
|
||||
{
|
||||
...activeProfile,
|
||||
credential_state: "unavailable",
|
||||
},
|
||||
];
|
||||
const enabled = vi.fn();
|
||||
render(
|
||||
<OnboardingModelController
|
||||
sourceNames={[]}
|
||||
initialProfileId="active-profile"
|
||||
onBack={vi.fn()}
|
||||
onModelActiveConfirmed={enabled}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(step.current).not.toBeNull());
|
||||
expect(enabled).not.toHaveBeenCalled();
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "继续使用当前模型" }),
|
||||
);
|
||||
expect(enabled).not.toHaveBeenCalled();
|
||||
expect(screen.getByText(/模型凭据或连接状态已变化/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hydrates a persisted inactive profile without creating a duplicate", async () => {
|
||||
models.profiles = [{ ...activeProfile, is_active: false }];
|
||||
const active = vi.fn();
|
||||
@@ -206,7 +243,7 @@ it("recovers a pending creation and persists its profile id", async () => {
|
||||
});
|
||||
|
||||
it("reconciles a pending probe instead of saving or creating again", async () => {
|
||||
const inactive = { ...activeProfile, is_active: false };
|
||||
const inactive = { ...activeProfile, revision: 4, is_active: false };
|
||||
models.profiles = [inactive];
|
||||
models.pendingProbe = {
|
||||
operationId: "probe-operation",
|
||||
@@ -215,7 +252,8 @@ it("reconciles a pending probe instead of saving or creating again", async () =>
|
||||
};
|
||||
models.probeProfile.mockResolvedValueOnce({
|
||||
profile_id: "active-profile",
|
||||
profile_revision: 3,
|
||||
input_profile_revision: 3,
|
||||
profile_revision: 4,
|
||||
capabilities: {
|
||||
endpoint_reachable: true,
|
||||
model_available: true,
|
||||
@@ -238,10 +276,76 @@ it("reconciles a pending probe instead of saving or creating again", async () =>
|
||||
await waitFor(() =>
|
||||
expect(models.probeProfile).toHaveBeenCalledWith("active-profile"),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("模型连接测试通过,可以启用。")).toBeTruthy(),
|
||||
);
|
||||
expect(currentStep().canSaveAndEnable).toBe(true);
|
||||
expect(models.update).not.toHaveBeenCalled();
|
||||
expect(models.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes the first test when Core advances the tested profile revision", async () => {
|
||||
const inactive = { ...activeProfile, revision: 1, is_active: false };
|
||||
models.profiles = [inactive];
|
||||
models.update.mockResolvedValueOnce(inactive);
|
||||
models.probeProfile.mockResolvedValueOnce({
|
||||
profile_id: inactive.profile_id,
|
||||
input_profile_revision: 1,
|
||||
profile_revision: 2,
|
||||
capabilities: {
|
||||
endpoint_reachable: true,
|
||||
model_available: true,
|
||||
embedding_available: true,
|
||||
},
|
||||
stages: [{ ok: false }],
|
||||
});
|
||||
models.activate.mockResolvedValueOnce({
|
||||
...inactive,
|
||||
revision: 2,
|
||||
is_active: true,
|
||||
capabilities: {
|
||||
endpoint_reachable: true,
|
||||
model_available: true,
|
||||
embedding_available: true,
|
||||
},
|
||||
});
|
||||
embedding.supported = true;
|
||||
embedding.current = { writes_paused: false };
|
||||
embedding.shouldUseLegacyActivation = () => false;
|
||||
embedding.planActivation.mockResolvedValueOnce({ needed: false });
|
||||
const confirmed = vi.fn();
|
||||
render(
|
||||
<OnboardingModelController
|
||||
sourceNames={[]}
|
||||
initialProfileId={inactive.profile_id}
|
||||
onBack={vi.fn()}
|
||||
onModelActiveConfirmed={confirmed}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(currentStep().profile as { profile_id: string } | null)?.profile_id,
|
||||
).toBe(inactive.profile_id),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await (currentStep().onTest as () => Promise<void>)();
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("模型连接测试通过,可以启用。")).toBeTruthy(),
|
||||
);
|
||||
expect(currentStep().canSaveAndEnable).toBe(true);
|
||||
expect(currentStep().profile).toMatchObject({ revision: 2 });
|
||||
expect(getModelProfiles).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
await (currentStep().onSaveAndEnable as () => Promise<void>)();
|
||||
});
|
||||
expect(embedding.planActivation).toHaveBeenCalledWith(inactive.profile_id, 2);
|
||||
expect(models.activate).toHaveBeenCalledWith(inactive.profile_id);
|
||||
expect(confirmed).toHaveBeenCalledWith(1, inactive.profile_id);
|
||||
});
|
||||
|
||||
it("keeps an active resumed profile on the model step while writes are paused", async () => {
|
||||
embedding.supported = true;
|
||||
embedding.current = { writes_paused: true };
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import type {
|
||||
ModelProbeResult,
|
||||
ModelProfile,
|
||||
ModelProfileInput,
|
||||
ModelProfilePatch,
|
||||
} from "@one-memory/contracts";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { getModelProfiles } from "./api";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { EmbeddingSwitchConfirmDialog } from "./EmbeddingSwitchExperience";
|
||||
import {
|
||||
presetForGeneration,
|
||||
validateGenerationModel,
|
||||
} from "./GenerationModelPrimitives";
|
||||
import { modelProfileReady, profileFromProbe } from "./model-profile-readiness";
|
||||
import {
|
||||
type OnboardingModelDraft,
|
||||
OnboardingModelStep,
|
||||
@@ -41,23 +40,6 @@ const initial: OnboardingModelDraft = {
|
||||
context_window: "",
|
||||
};
|
||||
|
||||
export function probeMatchesProfile(
|
||||
original: ModelProfile,
|
||||
current: ModelProfile | undefined,
|
||||
probe: ModelProbeResult | undefined | null,
|
||||
): current is ModelProfile {
|
||||
return Boolean(
|
||||
current &&
|
||||
probe &&
|
||||
probe.profile_id === original.profile_id &&
|
||||
probe.profile_revision === current.revision &&
|
||||
probe.capabilities.endpoint_reachable &&
|
||||
probe.capabilities.model_available &&
|
||||
probe.capabilities.embedding_available &&
|
||||
probe.stages.every((stage) => stage.ok),
|
||||
);
|
||||
}
|
||||
|
||||
export function OnboardingModelController({
|
||||
sourceNames,
|
||||
importedConversationCount = 0,
|
||||
@@ -112,6 +94,17 @@ export function OnboardingModelController({
|
||||
>(),
|
||||
);
|
||||
const autoConfirmed = useRef<string | null>(null);
|
||||
const confirmReadyProfile = useCallback(
|
||||
(profile: ModelProfile, owner = generationToken) => {
|
||||
if (!isGenerationCurrent(owner) || !modelProfileReady(profile)) {
|
||||
setError("模型凭据或连接状态已变化,请重新测试后再完成首次设置。");
|
||||
return false;
|
||||
}
|
||||
onModelActiveConfirmed?.(owner, profile.profile_id);
|
||||
return true;
|
||||
},
|
||||
[generationToken, isGenerationCurrent, onModelActiveConfirmed],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const profile = initialProfileId
|
||||
@@ -152,21 +145,19 @@ export function OnboardingModelController({
|
||||
useEffect(() => {
|
||||
if (
|
||||
!saved?.is_active ||
|
||||
!isGenerationCurrent(generationToken) ||
|
||||
(embeddingSwitch.supported !== false && !embeddingSwitch.current) ||
|
||||
embeddingSwitch.current?.writes_paused
|
||||
)
|
||||
return;
|
||||
const key = `${generationToken}:${saved.profile_id}`;
|
||||
if (autoConfirmed.current === key) return;
|
||||
autoConfirmed.current = key;
|
||||
onModelActiveConfirmed?.(generationToken, saved.profile_id);
|
||||
if (confirmReadyProfile(saved, generationToken))
|
||||
autoConfirmed.current = key;
|
||||
}, [
|
||||
embeddingSwitch.current,
|
||||
embeddingSwitch.supported,
|
||||
confirmReadyProfile,
|
||||
generationToken,
|
||||
isGenerationCurrent,
|
||||
onModelActiveConfirmed,
|
||||
saved,
|
||||
]);
|
||||
|
||||
@@ -182,8 +173,7 @@ export function OnboardingModelController({
|
||||
flight.current = false;
|
||||
setSwitchPendingJobId(null);
|
||||
setPhase("idle");
|
||||
if (saved && isGenerationCurrent(generationToken))
|
||||
onModelActiveConfirmed?.(generationToken, saved.profile_id);
|
||||
if (saved) confirmReadyProfile(saved, generationToken);
|
||||
} else if (finished.state === "rolled_back") {
|
||||
flight.current = false;
|
||||
setSwitchPendingJobId(null);
|
||||
@@ -192,9 +182,8 @@ export function OnboardingModelController({
|
||||
}
|
||||
}, [
|
||||
embeddingSwitch.current?.last_finished,
|
||||
confirmReadyProfile,
|
||||
generationToken,
|
||||
isGenerationCurrent,
|
||||
onModelActiveConfirmed,
|
||||
saved,
|
||||
switchPendingJobId,
|
||||
]);
|
||||
@@ -338,11 +327,10 @@ export function OnboardingModelController({
|
||||
setPhase("testing");
|
||||
const result = await models.probeProfile(profile.profile_id);
|
||||
if (!isGenerationCurrent(owner)) return;
|
||||
const current = (await getModelProfiles()).find(
|
||||
(item) => item.profile_id === profile.profile_id,
|
||||
const current = profileFromProbe(profile, result);
|
||||
const passed = Boolean(
|
||||
current && modelProfileReady(current, result ?? null),
|
||||
);
|
||||
if (!isGenerationCurrent(owner)) return;
|
||||
const passed = probeMatchesProfile(profile, current, result);
|
||||
if (current) setSaved(current);
|
||||
setProbe({
|
||||
state: passed ? "passed" : "failed",
|
||||
@@ -378,11 +366,10 @@ export function OnboardingModelController({
|
||||
try {
|
||||
const result = await models.probeProfile(saved.profile_id);
|
||||
if (!isGenerationCurrent(owner)) return;
|
||||
const current = (await getModelProfiles()).find(
|
||||
(item) => item.profile_id === saved.profile_id,
|
||||
const current = profileFromProbe(saved, result);
|
||||
const passed = Boolean(
|
||||
current && modelProfileReady(current, result ?? null),
|
||||
);
|
||||
if (!isGenerationCurrent(owner)) return;
|
||||
const passed = probeMatchesProfile(saved, current, result);
|
||||
if (current) setSaved(current);
|
||||
setProbe({
|
||||
state: passed ? "passed" : "failed",
|
||||
@@ -408,8 +395,7 @@ export function OnboardingModelController({
|
||||
setError("模型写入暂时关闭。请等待索引维护完成后重试。");
|
||||
return;
|
||||
}
|
||||
if (isGenerationCurrent(generationToken))
|
||||
onModelActiveConfirmed?.(generationToken, saved.profile_id);
|
||||
confirmReadyProfile(saved, generationToken);
|
||||
return;
|
||||
}
|
||||
const owner = generationToken;
|
||||
@@ -433,9 +419,15 @@ export function OnboardingModelController({
|
||||
}
|
||||
const active = await models.activate(saved.profile_id);
|
||||
if (!isGenerationCurrent(owner)) return;
|
||||
if (!active?.is_active) throw new Error("model_activation_unconfirmed");
|
||||
if (
|
||||
!active?.is_active ||
|
||||
active.profile_id !== saved.profile_id ||
|
||||
active.revision !== saved.revision ||
|
||||
!modelProfileReady(active)
|
||||
)
|
||||
throw new Error("model_activation_unconfirmed");
|
||||
trace.done("succeeded");
|
||||
onModelActiveConfirmed?.(owner, active.profile_id);
|
||||
confirmReadyProfile(active, owner);
|
||||
} catch {
|
||||
trace.done("unknown", { reason: "model_enable_unconfirmed" });
|
||||
setError("模型未能确认启用,请在设置中核对。");
|
||||
|
||||
@@ -76,7 +76,9 @@ describe("OnboardingModelStep", () => {
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
|
||||
const title = guiMessages[locale]["配置记忆整理模型"];
|
||||
const back = guiMessages[locale]["返回选择模式"];
|
||||
if (!title || !back) throw new Error(`missing ${locale} model copy`);
|
||||
const summary = guiMessages[locale]["记忆整理"];
|
||||
if (!title || !back || !summary)
|
||||
throw new Error(`missing ${locale} model copy`);
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<OnboardingModelStep {...props()} />
|
||||
@@ -93,10 +95,11 @@ describe("OnboardingModelStep", () => {
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByLabelText(/API Key/)).toBeTruthy();
|
||||
expect(screen.getByRole("complementary", { name: summary })).toBeTruthy();
|
||||
expect(screen.getAllByRole("button").length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it("keeps step 3 on memory mode in a generation-only column", () => {
|
||||
it("keeps current behavior in the restored open-source composition", () => {
|
||||
render(<OnboardingModelStep {...props()} />);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "配置记忆整理模型" }),
|
||||
@@ -110,12 +113,9 @@ describe("OnboardingModelStep", () => {
|
||||
expect(screen.getByText("DeepSeek · deepseek-flash")).toBeTruthy();
|
||||
expect(screen.queryByText("本次沉淀")).toBeNull();
|
||||
expect(screen.queryByLabelText(/向量|Embedding/)).toBeNull();
|
||||
expect(
|
||||
document.querySelector(
|
||||
".onboarding-model-layout--single-column-600" +
|
||||
'[data-responsive="single-column-600"]',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(document.querySelector(".onboarding-model-layout")).toBeTruthy();
|
||||
expect(document.querySelector(".onboarding-model-summary")).toBeTruthy();
|
||||
expect(screen.getByText("让记忆从这里开始")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("focuses the page title when entering the model substep", async () => {
|
||||
@@ -181,6 +181,18 @@ describe("OnboardingModelStep", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns to the visible API key field when it fails in advanced mode", async () => {
|
||||
const p = props();
|
||||
p.advancedOpen = true;
|
||||
p.fieldErrors = { api_key: "请填写 API Key。" };
|
||||
const view = render(<OnboardingModelStep {...p} />);
|
||||
await waitFor(() => expect(p.onAdvancedChange).toHaveBeenCalledWith(false));
|
||||
view.rerender(<OnboardingModelStep {...p} advancedOpen={false} />);
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement).toBe(screen.getByLabelText("API Key")),
|
||||
);
|
||||
});
|
||||
|
||||
it("links field errors and keeps secrets out of callback errors", async () => {
|
||||
const p = props();
|
||||
p.fieldErrors = { api_key: "请填写 API Key。" };
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
import { useOnboardingTranslation } from "./onboarding-language";
|
||||
import { R6ColdStartFrame } from "./R6ColdStartFrame";
|
||||
import { R6Button } from "./R6Controls";
|
||||
import "./onboarding-model-r6.css";
|
||||
|
||||
type Action = "test" | "save-and-enable";
|
||||
|
||||
@@ -60,6 +61,10 @@ export function OnboardingModelStepNext(props: OnboardingModelStepProps) {
|
||||
props.onAdvancedChange(true);
|
||||
return;
|
||||
}
|
||||
if (firstError === "api_key" && props.advancedOpen) {
|
||||
props.onAdvancedChange(false);
|
||||
return;
|
||||
}
|
||||
if (firstError && (!technicalError || props.advancedOpen)) {
|
||||
formRef.current
|
||||
?.querySelector<HTMLElement>(`[name="${firstError}"]`)
|
||||
@@ -173,10 +178,7 @@ export function OnboardingModelStepNext(props: OnboardingModelStepProps) {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="onboarding-model-layout onboarding-model-layout--single-column-600"
|
||||
data-responsive="single-column-600"
|
||||
>
|
||||
<div className="onboarding-model-layout">
|
||||
<section
|
||||
ref={formRef}
|
||||
className="onboarding-model-form"
|
||||
@@ -184,6 +186,9 @@ export function OnboardingModelStepNext(props: OnboardingModelStepProps) {
|
||||
data-advanced={props.advancedOpen}
|
||||
>
|
||||
<div className="onboarding-model-group">
|
||||
<p className="onboarding-model-intro">
|
||||
{text("选择服务商,填入 API Key。核对已有配置后再继续。")}
|
||||
</p>
|
||||
<label
|
||||
className="onboarding-model-field onboarding-model-provider"
|
||||
htmlFor={`${id}-provider`}
|
||||
@@ -263,16 +268,6 @@ export function OnboardingModelStepNext(props: OnboardingModelStepProps) {
|
||||
)}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="onboarding-model-disclosure">
|
||||
<p>
|
||||
{text("相关对话会发送给所选服务商。")}
|
||||
{sourceSummary ? ` ${sourceSummary}` : ""}
|
||||
</p>
|
||||
<p>
|
||||
{text("密钥保存在系统安全存储中,不写入对话或日志。")}{" "}
|
||||
{text("测试只向此接口发送测试内容,不发送历史对话。")}
|
||||
</p>
|
||||
</div>
|
||||
<GenerationTechnicalDisclosure
|
||||
open={props.advancedOpen}
|
||||
disabled={fieldsLocked}
|
||||
@@ -348,6 +343,35 @@ export function OnboardingModelStepNext(props: OnboardingModelStepProps) {
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
<aside
|
||||
className="onboarding-model-summary"
|
||||
aria-label={text("记忆整理")}
|
||||
>
|
||||
<div className="onboarding-model-summary-heading">
|
||||
<p>{text("记忆整理")}</p>
|
||||
<h2>{text("让记忆从这里开始")}</h2>
|
||||
</div>
|
||||
<div className="onboarding-model-connections">
|
||||
<p>{text("对话来源")}</p>
|
||||
<strong>{sourceSummary || text("尚无已选来源")}</strong>
|
||||
<small>
|
||||
{t("onboardingImportedConversations", {
|
||||
count: props.importedConversationCount ?? 0,
|
||||
})}
|
||||
</small>
|
||||
</div>
|
||||
<div className="onboarding-model-summary-status">
|
||||
<small>
|
||||
{text("处理服务:")}
|
||||
{text(provider?.label ?? "待选择")}
|
||||
</small>
|
||||
<small>
|
||||
{text("相关对话会发送给所选服务商。")}
|
||||
<br />
|
||||
{text("测试只向此接口发送测试内容,不发送历史对话。")}
|
||||
</small>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</R6ColdStartFrame>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { LanguageProvider } from "./gui-language";
|
||||
import { OptionMenu } from "./OptionMenu";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it("shows a suffix without requiring description copy", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<OptionMenu
|
||||
ariaLabel="记忆模式"
|
||||
value="pro"
|
||||
options={[
|
||||
{ value: "lite", label: "基础记忆", suffix: "Lite" },
|
||||
{ value: "pro", label: "智能记忆", suffix: "Pro" },
|
||||
]}
|
||||
onChange={vi.fn()}
|
||||
/>
|
||||
</LanguageProvider>,
|
||||
);
|
||||
|
||||
const control = screen.getByRole("combobox", { name: "记忆模式" });
|
||||
expect(control.textContent).toContain("智能记忆 · Pro");
|
||||
await user.click(control);
|
||||
expect(screen.getByRole("option", { name: "基础记忆 · Lite" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps option descriptions available to assistive technology", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<OptionMenu
|
||||
ariaLabel="记忆模式"
|
||||
value="pro"
|
||||
options={[
|
||||
{
|
||||
value: "lite",
|
||||
label: "基础记忆",
|
||||
description: "保存原始会话",
|
||||
},
|
||||
{
|
||||
value: "pro",
|
||||
label: "智能记忆",
|
||||
suffix: "Pro",
|
||||
description: "调用模型整理记忆",
|
||||
},
|
||||
]}
|
||||
onChange={vi.fn()}
|
||||
/>
|
||||
</LanguageProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("combobox", { name: "记忆模式" }));
|
||||
const option = screen.getByRole("option", { name: "智能记忆 · Pro" });
|
||||
const descriptionId = option.getAttribute("aria-describedby");
|
||||
expect(descriptionId).toBeTruthy();
|
||||
expect(document.getElementById(descriptionId ?? "")?.textContent).toBe(
|
||||
"调用模型整理记忆",
|
||||
);
|
||||
expect(
|
||||
screen
|
||||
.getByRole("option", { name: /基础记忆.*保存原始会话/ })
|
||||
.getAttribute("aria-describedby"),
|
||||
).toBeNull();
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import { useGuiLanguage } from "./gui-language";
|
||||
export interface OptionMenuItem {
|
||||
value: string;
|
||||
label: string;
|
||||
suffix?: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
lang?: string;
|
||||
@@ -22,6 +23,17 @@ const VIEWPORT_MARGIN = 12;
|
||||
const PANEL_GAP = 4;
|
||||
const MAX_PANEL_HEIGHT = 280;
|
||||
|
||||
function optionLabel(label: string, suffix?: string) {
|
||||
return suffix ? (
|
||||
<>
|
||||
{label}
|
||||
<span className="om-option-menu-suffix"> · {suffix}</span>
|
||||
</>
|
||||
) : (
|
||||
label
|
||||
);
|
||||
}
|
||||
|
||||
export function optionMenuPosition(
|
||||
anchor: Pick<DOMRect, "top" | "right" | "bottom" | "left" | "width">,
|
||||
panelHeight: number,
|
||||
@@ -111,7 +123,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();
|
||||
}
|
||||
@@ -268,11 +280,11 @@ export function OptionMenu(props: {
|
||||
>
|
||||
{selected?.description ? (
|
||||
<span className="om-option-menu-copy">
|
||||
<span>{visibleLabel}</span>
|
||||
<span>{optionLabel(visibleLabel, selected.suffix)}</span>
|
||||
<small>{selected.description}</small>
|
||||
</span>
|
||||
) : (
|
||||
visibleLabel
|
||||
optionLabel(visibleLabel, selected?.suffix)
|
||||
)}
|
||||
</button>
|
||||
{open &&
|
||||
@@ -299,6 +311,14 @@ export function OptionMenu(props: {
|
||||
lang={item.lang}
|
||||
data-active={activeIndex === index || undefined}
|
||||
aria-selected={item.value === props.value}
|
||||
aria-label={
|
||||
item.suffix ? `${item.label} · ${item.suffix}` : undefined
|
||||
}
|
||||
aria-describedby={
|
||||
item.description && item.suffix
|
||||
? `${panelId}-option-${index}-description`
|
||||
: undefined
|
||||
}
|
||||
disabled={item.disabled}
|
||||
tabIndex={-1}
|
||||
onMouseEnter={() => {
|
||||
@@ -308,11 +328,13 @@ export function OptionMenu(props: {
|
||||
>
|
||||
{item.description ? (
|
||||
<span className="om-option-menu-copy">
|
||||
<span>{item.label}</span>
|
||||
<small>{item.description}</small>
|
||||
<span>{optionLabel(item.label, item.suffix)}</span>
|
||||
<small id={`${panelId}-option-${index}-description`}>
|
||||
{item.description}
|
||||
</small>
|
||||
</span>
|
||||
) : (
|
||||
item.label
|
||||
optionLabel(item.label, item.suffix)
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
Loaded 100 of 184 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user