Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cee64c813 | ||
|
|
1ffcc9eb57 | ||
|
|
b36452d110 | ||
|
|
e58a7c0d14 | ||
|
|
fba9d8cf26 | ||
|
|
18e2fca34d | ||
|
|
88feb0adca | ||
|
|
d2e9cdaf06 | ||
|
|
8277aaee51 | ||
|
|
65550621e0 | ||
|
|
1a67427bf3 | ||
|
|
58f1477680 | ||
|
|
76c459e39a | ||
|
|
b864e0bda6 |
No files matched your search
+58
-56
@@ -4,56 +4,13 @@ Hy Memory and MemHub are versioned and released independently.
|
||||
|
||||
## Unreleased
|
||||
|
||||
- MemHub multi-user hardening (follow-up to #250):
|
||||
- Read responses name the user they were read for: `user_id` (the one
|
||||
asked for, `local` by default) is now on `GET /api/v2/memory-library`,
|
||||
`/memory-library/layers`, `/user-profile`, `/home-summary`,
|
||||
`/memories/graph`, `/raw`, and `POST /api/v2/context/search`,
|
||||
`/api/v1/recall`, `/api/v2/memories/ask`. `ask/stream` carries it on its
|
||||
first (`recall`) event. The Host Bridge context validator accepts the new
|
||||
field and still accepts a Core without it.
|
||||
- `DELETE /api/v2/memories/{id}` answers 404 `memory_not_found` for another
|
||||
user's memory and for an id that does not exist (the same response, so
|
||||
existence is still not revealed), and also when the SDK finds nothing to
|
||||
delete; 204 only when the caller's own memory was deleted. The App treats
|
||||
that 404 as already gone (a double-click delete no longer shows an
|
||||
error); `memhub memories delete` prints "未找到记忆 <id>(可能已删除)",
|
||||
exits 0, and its JSON is `state: "committed"` with `already_gone: true`.
|
||||
- Hermes never reads a `profiles/` directory at any depth (scan, detect and
|
||||
live sync), so a root granted above `~/.hermes` cannot pick up another
|
||||
user's profile. Connecting refuses a root that is the `profiles`
|
||||
container itself (422 `hermes_root_is_profiles_container`) and a root
|
||||
equal to, containing or inside another user's active Hermes grant (409
|
||||
`hermes_root_overlaps_other_user`, symlinks resolved); `local` on
|
||||
`~/.hermes` with another user on `~/.hermes/profiles/<name>` stays
|
||||
allowed. The CLI explains both codes.
|
||||
- `memhub sources connect --user-id` with an invalid id now says the user
|
||||
id need not equal the Hermes profile name (e.g. `--user-id wife-work
|
||||
--root ~/.hermes/profiles/wife:work`).
|
||||
|
||||
- MemHub's DeepSeek Harness app page can grant recall again (#230). The
|
||||
"一键插入应用" switch used to be greyed out with `unsupported_host`, and a
|
||||
profile installed from the CLI stayed `install-only` with no way to approve
|
||||
it. The one switch now covers every detected profile
|
||||
(`deepseek-harness:<profile>`): on installs each profile's MCP patch and
|
||||
grants recall, off removes the patch and revokes the grant. The page lists
|
||||
each profile with its state, and names any profile that failed while
|
||||
keeping the others. While the switch is on, a profile created later is
|
||||
installed and granted automatically, without restarting MemHub; the Runtime
|
||||
also no longer fails every host read when it meets a profile created after
|
||||
it started. The harness docs no longer describe an unmounted sources page.
|
||||
|
||||
- MemHub's `deepseek-harness` source imports session files whose names carry the
|
||||
format generation: `session.v3.jsonl.zstd` and `session.v4.jsonl.zstd` are now
|
||||
discovered alongside the original `session.jsonl[.zstd]`. Discovery accepts
|
||||
any `session[.vN].jsonl[.zstd]` name and the header's `version` decides
|
||||
whether the session is parsed (v0/v3/v4) or refused as `unsupported_schema`.
|
||||
A refused file is reported and keeps no cursor, so the release that learns
|
||||
its format reads it from the top.
|
||||
v4 tool results, which are written as plain `text` blocks rather than
|
||||
`tool-result` blocks, are read through the same clipping and credential
|
||||
scrubbing path. Previously every versioned session was invisible: the import
|
||||
job completed with `discovered 0` while the files sat on disk.
|
||||
- MemHub App now exposes the complete memory-mode lifecycle. New installs
|
||||
explicitly choose lite or pro during onboarding; pro completes only after a
|
||||
generation model is active, while ultra remains an advanced Settings choice.
|
||||
Settings shows mode effects, graph backfill, the default 03:00 evolve
|
||||
schedule and manual evolve. The memory page shows original conversations in
|
||||
lite and advanced memories in pro/ultra. Existing installs remain pro unless
|
||||
the user changes them, and embedding maintenance blocks mode/evolve changes.
|
||||
|
||||
## Hy Memory 2.0.13 — 2026-11-02
|
||||
|
||||
@@ -126,14 +83,17 @@ Hy Memory and MemHub are versioned and released independently.
|
||||
|
||||
## MemHub 1.0.6 — 2026-11-02
|
||||
|
||||
Build 37, on `hy-memory==2.0.11`. Linux x64/arm64 Runtime and macOS arm64
|
||||
Build 39, on `hy-memory==2.0.13`. Linux x64/arm64 Runtime and macOS arm64
|
||||
App and Runtime.
|
||||
|
||||
- Interfaces in 11 languages: English, Simplified and Traditional Chinese,
|
||||
Japanese, Spanish, German, Portuguese, French, Korean, Russian and
|
||||
Indonesian. Choosing any language other than Simplified Chinese also sets
|
||||
the SDK prompt language; Simplified Chinese keeps the automatic zh/en
|
||||
detection.
|
||||
Indonesian. Every interface language, Simplified Chinese included, sets the
|
||||
SDK prompt language (#270). **User-visible:** zh-Hans users now get Chinese
|
||||
prompts and Chinese memory text for every conversation, English ones
|
||||
included; the App sends the language it displays, also when it follows the
|
||||
system. Only an unset language (CLI/API installs, `memhub settings language
|
||||
system`) keeps the SDK's zh/en detection.
|
||||
- Memory modes: lite (no LLM, keeps raw conversations), pro (LLM extraction)
|
||||
and ultra (pro plus the knowledge graph and a nightly evolve run). Set with
|
||||
`GET`/`PUT /api/v1/settings/memory-mode` or `memhub settings mode`. Raw
|
||||
@@ -146,10 +106,52 @@ App and Runtime.
|
||||
embedding service is unavailable, search returns `embedding_unavailable`.
|
||||
- Multi-user Runtime: memory APIs and the CLI take `user_id` (default
|
||||
`local`); records are isolated per user; Hermes profiles can be connected as
|
||||
separate users. The App keeps using `local`.
|
||||
separate users. The App keeps using `local`. Hardening (#280): read
|
||||
responses echo `user_id`; deleting another user's memory, or one that does
|
||||
not exist, is 404 `memory_not_found` (the App and `memhub memories delete`
|
||||
treat it as already gone); Hermes never reads a `profiles/` directory, and
|
||||
a root that is the `profiles` container or overlaps another user's grant is
|
||||
refused (`hermes_root_is_profiles_container`,
|
||||
`hermes_root_overlaps_other_user`).
|
||||
- Configurable SDK features through the CLI and API (`memhub settings
|
||||
features`): graph rerank, episode recall, session episodes, episode
|
||||
summaries, intention and few-shot. Defaults are unchanged.
|
||||
summaries, intention and few-shot, and the intention dedup switch with its
|
||||
threshold (`intention_dedup`, `intention_dedup_threshold`, #277). Defaults
|
||||
are unchanged.
|
||||
- Memory writes retry themselves (#278), each time under a new SDK request
|
||||
id after checking whether the previous one landed; interrupted writes
|
||||
re-run automatically. A model provider failure (401/403, 404, 408, 429,
|
||||
5xx, timeout, unreachable) keeps the job pending in place without spending
|
||||
a retry, and a per-profile circuit breaker paces the queue (5 s doubling to
|
||||
180 s, one probe job at a time). Any other failure is retried after 30, 60
|
||||
and 120 s, then `failed`. With `hy-memory==2.0.13` a model failure is no
|
||||
longer a silent success, and a failed job's `error_code` names the SDK's
|
||||
cause (`hy_sdk_reconcile_empty_response`, `hy_sdk_llm_error`, ...) rather
|
||||
than `hy_sdk_write_failed`. **User-visible:** the manual 重新整理 button,
|
||||
the "整理结果不确定" display and
|
||||
`POST /api/v1/memory-processing/retry-unknown` are gone; a banner
|
||||
("模型配置有问题……记忆暂停处理,已有 N 条在排队") with a button to the model
|
||||
settings shows while a model problem pauses processing, and clears on
|
||||
recovery.
|
||||
- Queue, WAL and recall (#278): the WAL stays bounded (64 MiB journal limit,
|
||||
idle `TRUNCATE` checkpoints); a disabled or revoked source's jobs are
|
||||
`suspended` and resume when it is turned back on; SDK writes and recall
|
||||
reads use separate thread pools, and recall has a total budget (504
|
||||
`recall_timeout`, `broker_timeout` in clients) instead of starving behind
|
||||
stuck writes.
|
||||
- One Runtime per data dir (#273). App quit asks the service manager at quit
|
||||
time: an enabled background service is never stopped, a disabled one has
|
||||
every Runtime of the data dir stopped (only after an authenticated identity
|
||||
check), and an unknown state stops nothing. At start the App adopts a
|
||||
Runtime of the same build, replaces an older one, and silently adopts a
|
||||
newer one. The tray quit label follows the current state.
|
||||
- DeepSeek Harness: the app page's one switch installs and grants recall for
|
||||
every detected profile, and keeps new profiles granted without a restart
|
||||
(#230); versioned session files (`session.v3.jsonl.zstd`,
|
||||
`session.v4.jsonl.zstd`) are imported, and v4 tool output goes through the
|
||||
same clipping and scrubbing (#175).
|
||||
- The packaged Runtime accepts only a full 40-character commit, lowercased; a
|
||||
malformed `ONE_MEMORY_COMMIT` fails the build (#235, #236).
|
||||
- Search and recall pass through the SDK's search parameters with server-side
|
||||
score floors; the memory library filters by layer; `/api/v2/raw` returns
|
||||
data again; v2 source and import routes; profile version history.
|
||||
|
||||
@@ -33,7 +33,7 @@ English | [中文](README.zh.md)
|
||||
## Latest News
|
||||
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Open-sourced** quantized **FP8** (**[Hy-Memory-30B-A3B-FP8](https://huggingface.co/tencent/Hy-Memory-30B-A3B-FP8)**) and **GGUF** (**[Hy-Memory-30B-A3B-GGUF](https://huggingface.co/tencent/Hy-Memory-30B-A3B-GGUF)**) versions of **Hy-Memory-30B-A3B** on Hugging Face!
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-11]**: 📦 **Released [MemHub v1.0.6](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.6)** with interfaces in 11 languages, memory modes (lite / pro / ultra with nightly evolve), safe embedding-model switching, multi-user support for the CLI and API, and more reliable upgrades, on **`hy-memory==2.0.11`**. See the release notes for details.
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-11]**: 📦 **Released [MemHub v1.0.6](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.6)** with interfaces in 11 languages, memory modes (lite / pro / ultra with nightly evolve), safe embedding-model switching, multi-user support for the CLI and API, memory writes that retry themselves and pause while the model provider is down, and more reliable upgrades, on **`hy-memory==2.0.13`**. 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.11`**。详见发布说明。
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-11]**: 📦 **正式发布 [MemHub v1.0.6](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.6)**:界面支持 11 种语言,新增记忆模式(lite / pro / ultra,ultra 每晚自动整理),可安全切换嵌入模型,CLI 和 API 支持多用户,记忆写入自动重试、模型服务异常时暂停排队,升级更可靠;底层使用 **`hy-memory==2.0.13`**。详见发布说明。
|
||||
* *<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)**!
|
||||
|
||||
+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.12`。MemHub 源码版本为 `1.0.6`
|
||||
(build 37),GitHub 仓库为
|
||||
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.13`。MemHub 源码版本为 `1.0.6`
|
||||
(build 39),GitHub 仓库为
|
||||
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
|
||||
`memhub-cli`。当前代码架构见
|
||||
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
|
||||
@@ -118,7 +118,7 @@ MemHub 的 SQLite 数据默认位于 `~/.one-memory`,不在 npm 包或 Runtime
|
||||
|
||||
## Hy Memory
|
||||
|
||||
SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.12`。SDK 配置、权限边界及 search
|
||||
SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.13`。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.12",
|
||||
"versionInfo": "2.0.13",
|
||||
"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.12
|
||||
## hy-memory 2.0.13
|
||||
|
||||
License: Apache-2.0
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"fastapi>=0.141.1",
|
||||
"filelock>=3.32.4",
|
||||
# Public SDK version validated against MemHub's permission and recall contracts.
|
||||
"hy-memory==2.0.12",
|
||||
"hy-memory==2.0.13",
|
||||
# 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",
|
||||
|
||||
@@ -740,6 +740,8 @@ class MemoryModeState(BaseModel):
|
||||
mode: MemoryMode
|
||||
#: The saved setting (default pro).
|
||||
configured_mode: MemoryMode
|
||||
#: True once a person chose a mode, including explicitly choosing pro.
|
||||
explicitly_configured: bool
|
||||
#: `ONE_MEMORY_GRAPH_STORE` (dev) forces ultra over the setting.
|
||||
forced_by_env: bool
|
||||
modes: tuple[MemoryMode, ...] = MEMORY_MODES
|
||||
@@ -1440,10 +1442,15 @@ def create_app(
|
||||
service=app.state.evolve_service,
|
||||
schedule=lambda: _config_store(app).evolve_schedule(),
|
||||
users_with_memories=lambda: _users_with_memories(app),
|
||||
lifecycle_lock=app.state.memory_mode_lock,
|
||||
lifecycle_open=lambda: (
|
||||
_embedding_writes_open(app) and not app.state.memory_graph_backfill.running
|
||||
),
|
||||
)
|
||||
app.state.memory_graph_backfill = MemoryGraphBackfill(
|
||||
coordinator=app.state.sdk_write_coordinator,
|
||||
state=app.state.evolve_state_store,
|
||||
lifecycle_open=lambda: _embedding_writes_open(app),
|
||||
)
|
||||
# C2 owns the single production budget (handle 5s + reserve 250ms,
|
||||
# total 5.25s, lease 10s, renew 1s, claim 250ms). The composition
|
||||
@@ -1842,6 +1849,16 @@ def create_app(
|
||||
name="one-memory-durable-write-drain",
|
||||
)
|
||||
|
||||
async def resume_graph_backfill() -> None:
|
||||
saved = app.state.evolve_state_store.graph_build()
|
||||
if saved.state != "running":
|
||||
return
|
||||
if _memory_mode(app) != "ultra":
|
||||
app.state.evolve_state_store.set_graph_build(GraphBuildState())
|
||||
return
|
||||
users = await asyncio.to_thread(_known_user_ids, app)
|
||||
app.state.memory_graph_backfill.recover(users)
|
||||
|
||||
async def start_evolve_schedule() -> None:
|
||||
# Idle outside ultra: each tick asks the mode first.
|
||||
app.state.evolve_scheduler.start()
|
||||
@@ -1930,6 +1947,7 @@ def create_app(
|
||||
# served from the live space throughout.
|
||||
if sdk:
|
||||
await step("embedding_spaces", resume_embedding_spaces)
|
||||
await step("graph_backfill", resume_graph_backfill)
|
||||
await step("write_drain", start_write_drain)
|
||||
if sdk:
|
||||
await step("evolve_schedule", start_evolve_schedule)
|
||||
@@ -4693,13 +4711,19 @@ def create_app(
|
||||
added to the graph once, in the background (`graph_build`). No raw is
|
||||
ever deleted, in any mode. See docs/contracts/memory-mode-evolve-v1.md.
|
||||
"""
|
||||
_refuse_pipeline_change_during_embedding_maintenance(app)
|
||||
if request.mode == "ultra" and importlib.util.find_spec("kuzu") is None:
|
||||
raise HTTPException(status_code=409, detail="graph_store_unavailable")
|
||||
store = _config_store(app)
|
||||
async with app.state.memory_mode_lock:
|
||||
_refuse_pipeline_change_during_embedding_maintenance(app)
|
||||
previous_effective = _memory_mode(app)
|
||||
if request.mode != previous_effective and (
|
||||
_evolve_service(app).running() or app.state.memory_graph_backfill.running
|
||||
):
|
||||
raise HTTPException(status_code=409, detail="memory_lifecycle_busy")
|
||||
previous = store.memory_mode()
|
||||
if previous != request.mode or "memory_mode" not in store.document().model_fields_set:
|
||||
if previous != request.mode or not store.memory_mode_explicitly_configured():
|
||||
await asyncio.to_thread(store.set_memory_mode, request.mode)
|
||||
effective = _memory_mode(app)
|
||||
backfill: bool | None = None
|
||||
@@ -4862,14 +4886,18 @@ def create_app(
|
||||
in flight and holds their next writes until it ends, and repeats
|
||||
while the SDK says there is more to do (up to 20 rounds).
|
||||
"""
|
||||
try:
|
||||
return _evolve_service(app).start(
|
||||
request.user_id,
|
||||
agent_id=request.agent_id,
|
||||
max_batches=request.max_batches,
|
||||
)
|
||||
except EvolveModeError:
|
||||
raise HTTPException(status_code=409, detail="memory_mode_not_ultra") from None
|
||||
async with app.state.memory_mode_lock:
|
||||
_refuse_pipeline_change_during_embedding_maintenance(app)
|
||||
if app.state.memory_graph_backfill.running:
|
||||
raise HTTPException(status_code=409, detail="memory_lifecycle_busy")
|
||||
try:
|
||||
return _evolve_service(app).start(
|
||||
request.user_id,
|
||||
agent_id=request.agent_id,
|
||||
max_batches=request.max_batches,
|
||||
)
|
||||
except EvolveModeError:
|
||||
raise HTTPException(status_code=409, detail="memory_mode_not_ultra") from None
|
||||
|
||||
@app.get(
|
||||
"/api/v2/evolve",
|
||||
@@ -6178,9 +6206,11 @@ def _memory_mode(app: FastAPI) -> MemoryMode:
|
||||
|
||||
def _memory_mode_state(app: FastAPI) -> MemoryModeState:
|
||||
mode = _memory_mode(app)
|
||||
store = _config_store(app)
|
||||
return MemoryModeState(
|
||||
mode=mode,
|
||||
configured_mode=_config_store(app).memory_mode(),
|
||||
configured_mode=store.memory_mode(),
|
||||
explicitly_configured=store.memory_mode_explicitly_configured(),
|
||||
forced_by_env=graph_store_enabled(),
|
||||
graph_build=app.state.evolve_state_store.graph_build(),
|
||||
)
|
||||
@@ -6612,6 +6642,12 @@ def _refuse_write_during_embedding_switch(app: FastAPI) -> None:
|
||||
raise HTTPException(status_code=409, detail="embedding_switch_in_progress")
|
||||
|
||||
|
||||
def _refuse_pipeline_change_during_embedding_maintenance(app: FastAPI) -> None:
|
||||
"""Keep mode/evolve lifecycle changes out of embedding maintenance."""
|
||||
if not _embedding_writes_open(app):
|
||||
raise HTTPException(status_code=409, detail="embedding_maintenance_in_progress")
|
||||
|
||||
|
||||
_EMBEDDING_PATCH_FIELDS = frozenset(
|
||||
{"embedding_model", "embedding_provider_kind", "embedding_base_url", "embedding_dimensions"}
|
||||
)
|
||||
|
||||
@@ -126,15 +126,23 @@ def embedding_switch_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
|
||||
@router.post("/api/v1/embedding-switches", status_code=202, response_model=None)
|
||||
async def start_embedding_switch(value: EmbeddingSwitchStartRequest, request: Request) -> Any:
|
||||
try:
|
||||
job = await _service(request).start_switch(
|
||||
value.profile_id,
|
||||
expected_revision=value.expected_revision,
|
||||
plan_token=value.plan_token,
|
||||
requested_by=value.requested_by,
|
||||
)
|
||||
except EmbeddingSwitchError as error:
|
||||
return refusal(error.code, plan=error.plan)
|
||||
# One lifecycle lock orders mode/evolve/backfill and embedding
|
||||
# maintenance. Without it each endpoint could pass its own preflight
|
||||
# and then begin concurrently.
|
||||
async with request.app.state.memory_mode_lock:
|
||||
evolve = request.app.state.evolve_service
|
||||
backfill = request.app.state.memory_graph_backfill
|
||||
if evolve.running() or backfill.running:
|
||||
return refusal("memory_lifecycle_busy")
|
||||
try:
|
||||
job = await _service(request).start_switch(
|
||||
value.profile_id,
|
||||
expected_revision=value.expected_revision,
|
||||
plan_token=value.plan_token,
|
||||
requested_by=value.requested_by,
|
||||
)
|
||||
except EmbeddingSwitchError as error:
|
||||
return refusal(error.code, plan=error.plan)
|
||||
return job.model_dump(mode="json")
|
||||
|
||||
@router.get("/api/v1/embedding-switches/current")
|
||||
|
||||
@@ -14,7 +14,7 @@ from pathlib import Path
|
||||
from typing import Literal, cast
|
||||
|
||||
HY_MEMORY_DISTRIBUTION = "hy-memory"
|
||||
HY_MEMORY_VERSION = "2.0.12"
|
||||
HY_MEMORY_VERSION = "2.0.13"
|
||||
HY_MEMORY_NAMESPACE = "governed-v2"
|
||||
DiagnosticLogLevel = Literal["error", "warning", "info", "debug"]
|
||||
|
||||
|
||||
@@ -503,7 +503,7 @@ def _ensure_coding_schema(path: Path) -> None:
|
||||
CodingMemoryStore,
|
||||
)
|
||||
|
||||
# Relies on the pinned SDK's internals (hy-memory==2.0.12 is pinned exactly).
|
||||
# Relies on the pinned SDK's internals (hy-memory==2.0.13 is pinned exactly).
|
||||
store = CodingMemoryStore.__new__(CodingMemoryStore)
|
||||
store._db_path = str(path)
|
||||
store._conn = None
|
||||
|
||||
@@ -465,12 +465,16 @@ class EvolveScheduler:
|
||||
service: EvolveService,
|
||||
schedule: Callable[[], EvolveSchedule],
|
||||
users_with_memories: Callable[[], Awaitable[Iterable[str]]],
|
||||
lifecycle_lock: asyncio.Lock | None = None,
|
||||
lifecycle_open: Callable[[], bool] = lambda: True,
|
||||
now: Callable[[], datetime] = lambda: datetime.now().astimezone(),
|
||||
check_seconds: float = SCHEDULE_CHECK_SECONDS,
|
||||
) -> None:
|
||||
self.service = service
|
||||
self.schedule = schedule
|
||||
self.users_with_memories = users_with_memories
|
||||
self.lifecycle_lock = lifecycle_lock
|
||||
self.lifecycle_open = lifecycle_open
|
||||
self._now = now
|
||||
self.check_seconds = check_seconds
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
@@ -492,7 +496,15 @@ class EvolveScheduler:
|
||||
if not evolve_due(now, schedule, last, anchor):
|
||||
continue
|
||||
try:
|
||||
receipt = self.service.start(user_id, trigger="scheduled")
|
||||
if self.lifecycle_lock is None:
|
||||
if not self.lifecycle_open():
|
||||
break
|
||||
receipt = self.service.start(user_id, trigger="scheduled")
|
||||
else:
|
||||
async with self.lifecycle_lock:
|
||||
if not self.lifecycle_open():
|
||||
break
|
||||
receipt = self.service.start(user_id, trigger="scheduled")
|
||||
except EvolveModeError:
|
||||
break
|
||||
await self.service.wait(receipt.run_id)
|
||||
@@ -533,10 +545,12 @@ class MemoryGraphBackfill:
|
||||
*,
|
||||
coordinator: EvolveCoordinator,
|
||||
state: EvolveStateStore,
|
||||
lifecycle_open: Callable[[], bool] = lambda: True,
|
||||
now: Callable[[], datetime] = lambda: datetime.now(UTC),
|
||||
) -> None:
|
||||
self.coordinator = coordinator
|
||||
self.state = state
|
||||
self.lifecycle_open = lifecycle_open
|
||||
self._now = now
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
|
||||
@@ -557,6 +571,35 @@ class MemoryGraphBackfill:
|
||||
self._task = asyncio.create_task(self._run(users), name="one-memory-graph-backfill")
|
||||
return state
|
||||
|
||||
def recover(self, user_ids: Iterable[str]) -> GraphBuildState:
|
||||
"""Resume an interrupted backfill without repeating completed users."""
|
||||
if self.running:
|
||||
return self.state.graph_build()
|
||||
saved = self.state.graph_build()
|
||||
if saved.state != "running":
|
||||
return saved
|
||||
users = tuple(dict.fromkeys((*saved.users.keys(), *user_ids)))
|
||||
pending = tuple(
|
||||
user_id
|
||||
for user_id in users
|
||||
if saved.users.get(user_id, GraphBuildUser()).state != "succeeded"
|
||||
)
|
||||
resumed = saved.model_copy(deep=True)
|
||||
for user_id in pending:
|
||||
resumed.users[user_id] = GraphBuildUser()
|
||||
if not pending:
|
||||
resumed.state = "succeeded"
|
||||
resumed.finished_at = self._now()
|
||||
self.state.set_graph_build(resumed)
|
||||
return resumed
|
||||
resumed.state = "running"
|
||||
resumed.finished_at = None
|
||||
self.state.set_graph_build(resumed)
|
||||
self._task = asyncio.create_task(
|
||||
self._run(pending), name="one-memory-graph-backfill-recovery"
|
||||
)
|
||||
return resumed
|
||||
|
||||
async def wait(self) -> None:
|
||||
if self._task is not None:
|
||||
await asyncio.shield(self._task)
|
||||
@@ -564,6 +607,8 @@ class MemoryGraphBackfill:
|
||||
async def _run(self, users: tuple[str, ...]) -> None:
|
||||
state = self.state.graph_build()
|
||||
for user_id in users:
|
||||
while not self.lifecycle_open():
|
||||
await asyncio.sleep(0.2)
|
||||
state.users[user_id] = GraphBuildUser(state="running")
|
||||
await asyncio.to_thread(self.state.set_graph_build, state.model_copy(deep=True))
|
||||
try:
|
||||
|
||||
@@ -153,6 +153,11 @@ class ConfigStore:
|
||||
with self._lock:
|
||||
return self._document.memory_mode
|
||||
|
||||
def memory_mode_explicitly_configured(self) -> bool:
|
||||
"""Whether a person chose the mode, including choosing the default."""
|
||||
with self._lock:
|
||||
return "memory_mode" in self._document.model_fields_set
|
||||
|
||||
def set_memory_mode(self, mode: MemoryMode) -> ConfigDocument:
|
||||
if mode not in MEMORY_MODES:
|
||||
raise ValueError("invalid_memory_mode")
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import threading
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
|
||||
from contextlib import asynccontextmanager
|
||||
@@ -568,11 +569,36 @@ def _is_extracted_content(record: EvidenceRecord) -> bool:
|
||||
)
|
||||
|
||||
|
||||
#: The SDK's stable failure codes are upper snake case (`RECONCILE_FAILED`).
|
||||
_SDK_FAILURE_CODE = re.compile(r"[A-Z][A-Z0-9_]{0,47}")
|
||||
|
||||
|
||||
def sdk_write_failure_code(value: Mapping[object, object]) -> str:
|
||||
"""The job's `error_code` for a failed SDK write: the SDK's own cause if it gave one.
|
||||
|
||||
hy-memory 2.0.13 names why a write failed: `agent_error_code`
|
||||
(`LLM_ERROR`, `EMPTY_RESPONSE`, `JSON_PARSE_FAILED`, `EMBEDDING_FAILED`,
|
||||
`RECONCILE_FAILED`) and, for a failed reconcile, `reconcile_error_code`
|
||||
(`LLM_UNAVAILABLE`, `EMPTY_RESPONSE`, `JSON_PARSE_FAILED`,
|
||||
`RECONCILE_EXCEPTION`). They become `hy_sdk_reconcile_empty_response`,
|
||||
`hy_sdk_llm_error`, ... Without a recognisable code it stays
|
||||
`hy_sdk_write_failed`. Only the code is kept, never the SDK's message.
|
||||
"""
|
||||
reconcile = value.get("reconcile_error_code")
|
||||
if isinstance(reconcile, str) and _SDK_FAILURE_CODE.fullmatch(reconcile):
|
||||
cause = reconcile.removeprefix("RECONCILE_") or "FAILED"
|
||||
return f"hy_sdk_reconcile_{cause.lower()}"
|
||||
agent = value.get("agent_error_code")
|
||||
if isinstance(agent, str) and _SDK_FAILURE_CODE.fullmatch(agent):
|
||||
return f"hy_sdk_{agent.lower()}"
|
||||
return "hy_sdk_write_failed"
|
||||
|
||||
|
||||
def _receipt_from_result(value: object) -> SdkWriteReceipt:
|
||||
if not isinstance(value, Mapping):
|
||||
raise RuntimeError("hy_sdk_write_receipt_shape_invalid")
|
||||
if value.get("success") is not True:
|
||||
raise RuntimeError("hy_sdk_write_failed")
|
||||
raise RuntimeError(sdk_write_failure_code(value))
|
||||
|
||||
# Read only documented public receipt fields. ``variant`` identifies the
|
||||
# productivity namespace; IDs from both variants are canonical memory IDs.
|
||||
|
||||
@@ -263,6 +263,48 @@ def test_a_switch_over_the_api_pauses_writes_and_keeps_intake(tmp_path, backend)
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_graph_backfill_blocks_starting_an_embedding_switch(tmp_path, backend):
|
||||
backend.store.put(
|
||||
builtin_base(),
|
||||
512,
|
||||
{"m1": ("memory", "local")},
|
||||
model="bge-small-zh-v1.5",
|
||||
)
|
||||
|
||||
async def scenario():
|
||||
app = application(tmp_path)
|
||||
async with app.router.lifespan_context(app), client_for(app) as client:
|
||||
await recovered(client)
|
||||
profile = remote_profile(app)
|
||||
plan = (
|
||||
await client.post(
|
||||
"/api/v1/embedding-switches/plan",
|
||||
json={"profile_id": profile.profile_id},
|
||||
)
|
||||
).json()
|
||||
hold = asyncio.Event()
|
||||
task = asyncio.create_task(hold.wait())
|
||||
app.state.memory_graph_backfill._task = task
|
||||
try:
|
||||
response = await client.post(
|
||||
"/api/v1/embedding-switches",
|
||||
json={
|
||||
"profile_id": profile.profile_id,
|
||||
"expected_revision": profile.revision,
|
||||
"plan_token": plan["plan_token"],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
assert response.json()["detail"] == "memory_lifecycle_busy"
|
||||
assert app.state.embedding_switch.writes_open() is True
|
||||
finally:
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
app.state.memory_graph_backfill._task = None
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_cancel_over_the_api(tmp_path, backend):
|
||||
backend.store.put(builtin_base(), 512, {f"m{i}": (f"m {i}", "local") for i in range(4)})
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ from one_memory.memory_evolution import (
|
||||
EvolveScheduler,
|
||||
EvolveService,
|
||||
EvolveStateStore,
|
||||
GraphBuildState,
|
||||
GraphBuildUser,
|
||||
MemoryGraphBackfill,
|
||||
evolve_due,
|
||||
)
|
||||
@@ -58,8 +60,10 @@ def test_mode_defaults_to_pro_and_the_env_still_forces_ultra(tmp_path, monkeypat
|
||||
monkeypatch.delenv("ONE_MEMORY_GRAPH_STORE", raising=False)
|
||||
store = ConfigStore(tmp_path)
|
||||
assert store.memory_mode() == "pro"
|
||||
assert store.memory_mode_explicitly_configured() is False
|
||||
assert effective_memory_mode(store.memory_mode()) == "pro"
|
||||
store.set_memory_mode("lite")
|
||||
assert store.memory_mode_explicitly_configured() is True
|
||||
assert ConfigStore(tmp_path).memory_mode() == "lite"
|
||||
assert json.loads((tmp_path / "config.json").read_text())["memory_mode"] == "lite"
|
||||
monkeypatch.setenv("ONE_MEMORY_GRAPH_STORE", "kuzu")
|
||||
@@ -70,6 +74,21 @@ def test_mode_defaults_to_pro_and_the_env_still_forces_ultra(tmp_path, monkeypat
|
||||
store.set_memory_mode("max") # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_old_config_defaults_to_implicit_pro_until_pro_is_explicitly_set(tmp_path) -> None:
|
||||
(tmp_path / "config.json").write_text(
|
||||
json.dumps({"schema_version": 1, "sources": {}}), encoding="utf-8"
|
||||
)
|
||||
store = ConfigStore(tmp_path)
|
||||
assert store.memory_mode() == "pro"
|
||||
assert store.memory_mode_explicitly_configured() is False
|
||||
|
||||
store.set_memory_mode("pro")
|
||||
|
||||
assert store.memory_mode_explicitly_configured() is True
|
||||
assert json.loads((tmp_path / "config.json").read_text())["memory_mode"] == "pro"
|
||||
assert ConfigStore(tmp_path).memory_mode_explicitly_configured() is True
|
||||
|
||||
|
||||
def test_an_untouched_install_writes_no_new_settings(tmp_path) -> None:
|
||||
store = ConfigStore(tmp_path)
|
||||
store.set_sync("cursor", True)
|
||||
@@ -624,6 +643,38 @@ def test_the_scheduler_runs_each_due_user_once_in_ultra_only(tmp_path, monkeypat
|
||||
assert state.user("wife").last_result["trigger"] == "scheduled"
|
||||
|
||||
|
||||
def test_scheduled_evolve_obeys_the_shared_lifecycle_gate(tmp_path, monkeypatch) -> None:
|
||||
sdk = _EvolveSdk(continuing=0)
|
||||
state = EvolveStateStore(tmp_path)
|
||||
state.anchor(_at(1, 12))
|
||||
open_gate = [False]
|
||||
service = EvolveService(
|
||||
coordinator=_Coordinator(sdk, monkeypatch),
|
||||
state=state,
|
||||
memory_mode=lambda: "ultra",
|
||||
now=lambda: _at(2, 3, 2),
|
||||
)
|
||||
|
||||
async def users():
|
||||
return ("local",)
|
||||
|
||||
async def scenario():
|
||||
scheduler = EvolveScheduler(
|
||||
service=service,
|
||||
schedule=EvolveSchedule,
|
||||
users_with_memories=users,
|
||||
lifecycle_lock=asyncio.Lock(),
|
||||
lifecycle_open=lambda: open_gate[0],
|
||||
now=lambda: _at(2, 3, 2),
|
||||
)
|
||||
assert await scheduler.tick() == ()
|
||||
assert sdk.calls == []
|
||||
open_gate[0] = True
|
||||
assert await scheduler.tick() == ("local",)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_the_graph_backfill_runs_once_per_user(tmp_path, monkeypatch) -> None:
|
||||
sdk = _EvolveSdk()
|
||||
state = EvolveStateStore(tmp_path)
|
||||
@@ -643,6 +694,63 @@ def test_the_graph_backfill_runs_once_per_user(tmp_path, monkeypatch) -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_an_interrupted_graph_backfill_resumes_only_unfinished_users(tmp_path, monkeypatch) -> None:
|
||||
sdk = _EvolveSdk()
|
||||
state = EvolveStateStore(tmp_path)
|
||||
state.set_graph_build(
|
||||
GraphBuildState(
|
||||
state="running",
|
||||
started_at=_at(1, 3),
|
||||
users={
|
||||
"local": GraphBuildUser(state="succeeded", memories_scanned=4, nodes=4),
|
||||
"wife": GraphBuildUser(state="running"),
|
||||
},
|
||||
)
|
||||
)
|
||||
backfill = MemoryGraphBackfill(coordinator=_Coordinator(sdk, monkeypatch), state=state)
|
||||
|
||||
async def scenario():
|
||||
resumed = backfill.recover(["local", "wife"])
|
||||
assert resumed.state == "running"
|
||||
assert resumed.users["local"].state == "succeeded"
|
||||
assert resumed.users["wife"].state == "pending"
|
||||
await backfill.wait()
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert sdk.graph_builds == ["wife"]
|
||||
assert state.graph_build().state == "succeeded"
|
||||
|
||||
|
||||
def test_graph_backfill_waits_for_embedding_maintenance_to_finish(tmp_path, monkeypatch) -> None:
|
||||
sdk = _EvolveSdk()
|
||||
state = EvolveStateStore(tmp_path)
|
||||
gate = [False]
|
||||
checked = asyncio.Event()
|
||||
|
||||
def lifecycle_open() -> bool:
|
||||
checked.set()
|
||||
return gate[0]
|
||||
|
||||
backfill = MemoryGraphBackfill(
|
||||
coordinator=_Coordinator(sdk, monkeypatch),
|
||||
state=state,
|
||||
lifecycle_open=lifecycle_open,
|
||||
)
|
||||
|
||||
async def scenario():
|
||||
backfill.start(["local"])
|
||||
await checked.wait()
|
||||
assert sdk.graph_builds == []
|
||||
assert state.graph_build().state == "running"
|
||||
assert state.graph_build().users["local"].state == "pending"
|
||||
gate[0] = True
|
||||
await backfill.wait()
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert sdk.graph_builds == ["local"]
|
||||
assert state.graph_build().state == "succeeded"
|
||||
|
||||
|
||||
# --- the HTTP surface -----------------------------------------------------------
|
||||
|
||||
|
||||
@@ -701,6 +809,7 @@ def test_mode_api_get_put_and_its_effects(tmp_path, monkeypatch) -> None:
|
||||
async def scenario(app, sdk, client):
|
||||
current = (await client.get("/api/v1/settings/memory-mode")).json()
|
||||
assert current["mode"] == current["configured_mode"] == "pro"
|
||||
assert current["explicitly_configured"] is False
|
||||
assert current["forced_by_env"] is False
|
||||
assert app.state.sdk_write_coordinator.memory_mode() == "pro"
|
||||
|
||||
@@ -708,6 +817,7 @@ def test_mode_api_get_put_and_its_effects(tmp_path, monkeypatch) -> None:
|
||||
assert lite.status_code == 200, lite.text
|
||||
body = lite.json()
|
||||
assert body["mode"] == "lite" and body["previous_mode"] == "pro"
|
||||
assert body["explicitly_configured"] is True
|
||||
assert body["effects"] == ["higher_mode_memories_hidden"]
|
||||
assert app.state.sdk_write_coordinator.memory_mode() == "lite"
|
||||
assert _sdk_client_handle(app).memory_mode() == "lite"
|
||||
@@ -741,6 +851,65 @@ def test_mode_api_get_put_and_its_effects(tmp_path, monkeypatch) -> None:
|
||||
run_app(tmp_path, monkeypatch, scenario)
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_embedding_maintenance_blocks_mode_put_and_evolve_but_not_mode_get(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
async def scenario(app, _sdk, client):
|
||||
switch = app.state.embedding_switch
|
||||
|
||||
switch._scan_hold = True
|
||||
current = await client.get("/api/v1/settings/memory-mode")
|
||||
assert current.status_code == 200
|
||||
assert current.json()["explicitly_configured"] is False
|
||||
for response in (
|
||||
await client.put("/api/v1/settings/memory-mode", json={"mode": "pro"}),
|
||||
await client.post("/api/v2/evolve", json={}),
|
||||
):
|
||||
assert response.status_code == 409
|
||||
assert response.json()["detail"] == "embedding_maintenance_in_progress"
|
||||
assert app.state.config_store.memory_mode_explicitly_configured() is False
|
||||
|
||||
switch._scan_hold = False
|
||||
switch._open_cache = True
|
||||
assert (await client.get("/api/v1/settings/memory-mode")).status_code == 200
|
||||
for response in (
|
||||
await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"}),
|
||||
await client.post("/api/v2/evolve", json={}),
|
||||
):
|
||||
assert response.status_code == 409
|
||||
assert response.json()["detail"] == "embedding_maintenance_in_progress"
|
||||
|
||||
switch._open_cache = False
|
||||
explicitly_pro = await client.put("/api/v1/settings/memory-mode", json={"mode": "pro"})
|
||||
assert explicitly_pro.status_code == 200
|
||||
assert explicitly_pro.json()["explicitly_configured"] is True
|
||||
assert explicitly_pro.json()["effects"] == []
|
||||
|
||||
run_app(tmp_path, monkeypatch, scenario)
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_graph_backfill_blocks_mode_change_and_manual_evolve(tmp_path, monkeypatch) -> None:
|
||||
async def scenario(app, _sdk, client):
|
||||
hold = asyncio.Event()
|
||||
task = asyncio.create_task(hold.wait())
|
||||
app.state.memory_graph_backfill._task = task
|
||||
try:
|
||||
for response in (
|
||||
await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"}),
|
||||
await client.post("/api/v2/evolve", json={}),
|
||||
):
|
||||
assert response.status_code == 409
|
||||
assert response.json()["detail"] == "memory_lifecycle_busy"
|
||||
finally:
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
app.state.memory_graph_backfill._task = None
|
||||
|
||||
run_app(tmp_path, monkeypatch, scenario)
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_evolve_api_is_ultra_only_async_and_scoped_per_user(tmp_path, monkeypatch) -> None:
|
||||
async def scenario(app, sdk, client):
|
||||
|
||||
@@ -14,6 +14,13 @@ from pydantic import SecretStr
|
||||
|
||||
from one_memory.api import create_app
|
||||
from one_memory.config import Settings
|
||||
from one_memory.imports.models import (
|
||||
EvidenceInput,
|
||||
EvidenceOrigin,
|
||||
OriginMetadata,
|
||||
WrittenBy,
|
||||
materialize_write_input,
|
||||
)
|
||||
from one_memory.memory_server.contracts import BatchIdentity, WriteRequest
|
||||
from one_memory.memory_server.durable_queue import (
|
||||
FAILED,
|
||||
@@ -29,6 +36,7 @@ from one_memory.model_backoff import (
|
||||
ModelBackoffRefusal,
|
||||
backoff_seconds,
|
||||
)
|
||||
from one_memory.sdk_write_processor import SdkWriteProcessor, sdk_write_failure_code
|
||||
from one_memory.secrets import InMemorySecretStore
|
||||
|
||||
CHAT = "https://models.example.com/v1/chat/completions"
|
||||
@@ -605,3 +613,252 @@ def test_the_manual_retry_endpoint_is_gone(tmp_path) -> None:
|
||||
return response.status_code
|
||||
|
||||
assert asyncio.run(scenario()) in {404, 405}
|
||||
|
||||
|
||||
# --- hy-memory 2.0.13: a model failure is `success: false`, never a silent success ---
|
||||
|
||||
|
||||
class _Sdk2013Client:
|
||||
"""`HyMemoryClient.add` as hy-memory 2.0.13 answers it.
|
||||
|
||||
Each write makes one model call through MemHub's transport, so the
|
||||
breaker sees what the provider answered. The SDK turns a failed or
|
||||
unusable model reply into `success: false` with its stable codes; the
|
||||
shape of those results is the SDK's own (`_mark_reconcile_failure`,
|
||||
`CodingWriter`).
|
||||
"""
|
||||
|
||||
def __init__(self, transport: BackedOffModelTransport) -> None:
|
||||
self.transport = transport
|
||||
self.reply = "[]"
|
||||
self.messages: list[list[dict[str, str]]] = []
|
||||
|
||||
def add(self, messages, **_kwargs):
|
||||
self.messages.append(messages)
|
||||
coding = any(message["role"] == "tool" for message in messages)
|
||||
try:
|
||||
response = asyncio.run(self.transport.handle_async_request(httpx.Request("POST", CHAT)))
|
||||
except httpx.TransportError:
|
||||
return self._failed(coding, "LLM_UNAVAILABLE", "LLM_ERROR")
|
||||
if response.status_code >= 400:
|
||||
return self._failed(coding, "LLM_UNAVAILABLE", "LLM_ERROR")
|
||||
if not self.reply:
|
||||
return self._failed(coding, "EMPTY_RESPONSE", "EMPTY_RESPONSE")
|
||||
if not self.reply.startswith("["):
|
||||
return self._failed(coding, "JSON_PARSE_FAILED", "JSON_PARSE_FAILED")
|
||||
return {"success": True, "memory_ids": ["m-1"]}
|
||||
|
||||
@staticmethod
|
||||
def _failed(coding: bool, reconcile_cause: str, coding_code: str) -> dict[str, object]:
|
||||
if coding:
|
||||
return {"success": False, "agent_error_code": coding_code}
|
||||
return {
|
||||
"success": False,
|
||||
"error_code": 502,
|
||||
"agent_error_code": "RECONCILE_FAILED",
|
||||
"reconcile_error_code": reconcile_cause,
|
||||
}
|
||||
|
||||
|
||||
class _Sdk2013Handle:
|
||||
mode = "pro"
|
||||
|
||||
def __init__(self, client: _Sdk2013Client) -> None:
|
||||
self.sdk = client
|
||||
|
||||
async def client(self) -> _Sdk2013Client:
|
||||
return self.sdk
|
||||
|
||||
|
||||
class _Sdk2013Supervisor:
|
||||
def __init__(self, client: _Sdk2013Client) -> None:
|
||||
self.processor = SdkWriteProcessor(_Sdk2013Handle(client)) # type: ignore[arg-type]
|
||||
|
||||
async def process_sdk_job(self, records, *, request_id, before_hy_invocation):
|
||||
return await self.processor.process_sdk_job(
|
||||
records, request_id=request_id, before_hy_invocation=before_hy_invocation
|
||||
)
|
||||
|
||||
async def inquire_write_outcome(self, _request_id):
|
||||
return None
|
||||
|
||||
|
||||
class _Turns:
|
||||
"""Evidence for one conversation batch: a chat turn, or a coding turn with tools."""
|
||||
|
||||
def __init__(self, roles: tuple[str, ...]) -> None:
|
||||
self.records = {
|
||||
f"e-{index}": materialize_write_input(
|
||||
EvidenceInput(
|
||||
user_id="local",
|
||||
source_id="cursor",
|
||||
conversation_id="sess-1",
|
||||
message_id=f"msg-{index}",
|
||||
role=role,
|
||||
written_by=WrittenBy.PERSON,
|
||||
created_at=datetime(2026, 10, 1, tzinfo=UTC),
|
||||
verbatim=f"{role} says something worth keeping ({index})",
|
||||
origin_meta={EvidenceOrigin.FILE: OriginMetadata()},
|
||||
)
|
||||
)
|
||||
for index, role in enumerate(roles)
|
||||
}
|
||||
|
||||
def get(self, evidence_id: str):
|
||||
return self.records[evidence_id]
|
||||
|
||||
def request(self) -> WriteRequest:
|
||||
return WriteRequest(
|
||||
internal_session_id="sess-1",
|
||||
batch_identity=BatchIdentity(internal_session_id="sess-1", first_round=1, last_round=1),
|
||||
evidence_ids=list(self.records),
|
||||
idempotency_key="idem-000",
|
||||
source_id="cursor",
|
||||
)
|
||||
|
||||
|
||||
def _outage_then_recovery(tmp_path, roles: tuple[str, ...], failure) -> tuple[list, list]:
|
||||
clock = _Clock()
|
||||
backoff = ModelBackoff(clock=clock)
|
||||
calls: list[float] = []
|
||||
|
||||
def provider(request: httpx.Request) -> httpx.Response:
|
||||
calls.append(clock.now)
|
||||
if clock.now < 60.0:
|
||||
return failure(request)
|
||||
return httpx.Response(200)
|
||||
|
||||
client = _Sdk2013Client(
|
||||
BackedOffModelTransport(backoff, profile_id="p:1", inner=httpx.MockTransport(provider))
|
||||
)
|
||||
turns = _Turns(roles)
|
||||
path = tmp_path / "one-memory.sqlite3"
|
||||
queue = DurableWriteQueue(path)
|
||||
queue.submit(turns.request())
|
||||
drain = DurableWriteDrain(
|
||||
queue, execute=_never_execute, owner="outage", model_claim_allowance=backoff.allowance
|
||||
)
|
||||
seen: list[tuple[str, int, int, str | None]] = []
|
||||
for second in range(120):
|
||||
clock.now = float(second)
|
||||
for job_id in drain.claim(limit=1):
|
||||
asyncio.run(
|
||||
execute_job(
|
||||
job_id,
|
||||
queue=queue,
|
||||
supervisor=_Sdk2013Supervisor(client),
|
||||
evidence=turns,
|
||||
owner="outage",
|
||||
model_failures=backoff.attempt,
|
||||
)
|
||||
)
|
||||
seen.append(_jobs(path)[0][1:])
|
||||
assert all(len(sent) == len(roles) for sent in client.messages)
|
||||
return calls, seen
|
||||
|
||||
|
||||
def _timeout(request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ReadTimeout("provider timed out", request=request)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("failure", "code"),
|
||||
[
|
||||
(lambda _request: httpx.Response(401), "model_auth_failed"),
|
||||
(lambda _request: httpx.Response(503), "model_server_error"),
|
||||
(_timeout, "model_timeout"),
|
||||
],
|
||||
ids=["401", "503", "timeout"],
|
||||
)
|
||||
def test_a_reconcile_model_failure_waits_for_the_provider_and_completes_after_it(
|
||||
tmp_path, failure, code
|
||||
) -> None:
|
||||
"""hy-memory 2.0.13 fails the write when reconcile cannot reach its model.
|
||||
|
||||
The breaker saw the provider fail, so the job waits in place without
|
||||
spending a retry, is tried once per pause, and lands after recovery.
|
||||
"""
|
||||
calls, seen = _outage_then_recovery(tmp_path, ("user", "assistant"), failure)
|
||||
assert calls == [0.0, 5.0, 15.0, 35.0, 75.0]
|
||||
assert seen[:-1] == [("pending", attempt, 0, code) for attempt in range(1, 5)]
|
||||
assert seen[-1] == ("completed", 4, 0, None)
|
||||
|
||||
|
||||
def test_a_coding_batch_whose_extractor_fails_at_the_provider_waits_like_chat(tmp_path) -> None:
|
||||
"""Tool turns go down the SDK's coding path; its `LLM_ERROR` waits the same way."""
|
||||
calls, seen = _outage_then_recovery(
|
||||
tmp_path, ("user", "assistant", "tool", "assistant"), lambda _request: httpx.Response(502)
|
||||
)
|
||||
assert calls == [0.0, 5.0, 15.0, 35.0, 75.0]
|
||||
assert seen[:-1] == [("pending", attempt, 0, "model_server_error") for attempt in range(1, 5)]
|
||||
assert seen[-1] == ("completed", 4, 0, None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("reply", "code"),
|
||||
[
|
||||
("", "hy_sdk_reconcile_empty_response"),
|
||||
("I am not sure what you mean.", "hy_sdk_reconcile_json_parse_failed"),
|
||||
],
|
||||
ids=["empty", "unparseable"],
|
||||
)
|
||||
def test_a_reconcile_reply_the_sdk_cannot_use_is_retried_then_failed_with_its_cause(
|
||||
tmp_path, reply, code
|
||||
) -> None:
|
||||
"""HTTP 200 with nothing usable is not a provider failure: 3 retries, then `failed`.
|
||||
|
||||
The job's `error_code` names the SDK's cause instead of a bare
|
||||
`hy_sdk_write_failed`, and the provider is never paused.
|
||||
"""
|
||||
backoff = ModelBackoff(clock=_Clock())
|
||||
client = _Sdk2013Client(
|
||||
BackedOffModelTransport(
|
||||
backoff, profile_id="p:1", inner=httpx.MockTransport(lambda _r: httpx.Response(200))
|
||||
)
|
||||
)
|
||||
client.reply = reply
|
||||
turns = _Turns(("user", "assistant"))
|
||||
path = tmp_path / "one-memory.sqlite3"
|
||||
queue = DurableWriteQueue(path)
|
||||
queue.submit(turns.request())
|
||||
drain = DurableWriteDrain(queue, execute=_never_execute, owner="bad-reply")
|
||||
now = datetime.now(UTC)
|
||||
for offset in (0, 40, 150, 400):
|
||||
for job_id in drain.claim(limit=1, now=now + timedelta(seconds=offset)):
|
||||
asyncio.run(
|
||||
execute_job(
|
||||
job_id,
|
||||
queue=queue,
|
||||
supervisor=_Sdk2013Supervisor(client),
|
||||
evidence=turns,
|
||||
owner="bad-reply",
|
||||
model_failures=backoff.attempt,
|
||||
)
|
||||
)
|
||||
assert _jobs(path)[0][1:] == ("failed", 3, 3, code)
|
||||
assert queue.get("idem-000").error_code == code
|
||||
assert len(client.messages) == 4
|
||||
assert backoff.allowance() is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("result", "code"),
|
||||
[
|
||||
(
|
||||
{"agent_error_code": "RECONCILE_FAILED", "reconcile_error_code": "LLM_UNAVAILABLE"},
|
||||
"hy_sdk_reconcile_llm_unavailable",
|
||||
),
|
||||
(
|
||||
{"agent_error_code": "RECONCILE_FAILED", "reconcile_error_code": "RECONCILE_EXCEPTION"},
|
||||
"hy_sdk_reconcile_exception",
|
||||
),
|
||||
({"agent_error_code": "RECONCILE_FAILED"}, "hy_sdk_reconcile_failed"),
|
||||
({"agent_error_code": "EMBEDDING_FAILED"}, "hy_sdk_embedding_failed"),
|
||||
({"agent_error_code": "JSON_PARSE_FAILED"}, "hy_sdk_json_parse_failed"),
|
||||
({"agent_error_code": "provider said: bad key sk-123"}, "hy_sdk_write_failed"),
|
||||
({"agent_error_code": 502}, "hy_sdk_write_failed"),
|
||||
({}, "hy_sdk_write_failed"),
|
||||
],
|
||||
)
|
||||
def test_a_failed_sdk_write_keeps_only_the_sdks_stable_code(result, code) -> None:
|
||||
assert sdk_write_failure_code({"success": False, **result}) == code
|
||||
@@ -91,7 +91,7 @@ def test_bundle_contains_management_sdk_config_and_verified_manifest(tmp_path):
|
||||
assert manifest["bundle_id"] == "migration-1"
|
||||
assert manifest["sdk_namespace"] == "governed-v2"
|
||||
assert manifest["runtime_build"] == "build-13"
|
||||
assert manifest["snapshot_sdk_version"] == "2.0.12"
|
||||
assert manifest["snapshot_sdk_version"] == "2.0.13"
|
||||
paths = {item["path"]: item for item in manifest["files"]}
|
||||
assert {
|
||||
"management/one-memory.sqlite3",
|
||||
|
||||
Generated
+4
-4
@@ -991,7 +991,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "hy-memory"
|
||||
version = "2.0.12"
|
||||
version = "2.0.13"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "chromadb" },
|
||||
@@ -1008,9 +1008,9 @@ dependencies = [
|
||||
{ name = "scikit-learn" },
|
||||
{ name = "tiktoken" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/44/19/bcd0de44ae2011ee1ad70579996aad72e18f0ca292df22d1545db05db850/hy_memory-2.0.12.tar.gz", hash = "sha256:c1231596b4cf12595775541f81680d4819461cb69e4aa4dc449f876d4c3b6742", size = 1246969, upload-time = "2026-11-02T06:30:37.448Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/88/46/7d5a2d76be135664302d91e15f0d5018cf8e228956e4c80b86583b131cb3/hy_memory-2.0.13.tar.gz", hash = "sha256:f95850d492de74e91891502716da96e8b5297c3fc045dc70ec3474ed240da958", size = 1252802, upload-time = "2026-11-02T09:30:40.331Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/75/9e/b64c7643c1bad1ddca4623a39bb62423bed0c22e7fd0d86be1ff73b2a17a/hy_memory-2.0.12-py3-none-any.whl", hash = "sha256:7a2332288197fafb89d5037eac7fef97df4c80794ead18159d21da84cde431df", size = 1316922, upload-time = "2026-11-02T06:30:35.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1837,7 +1837,7 @@ requires-dist = [
|
||||
{ name = "fastapi", specifier = ">=0.141.1" },
|
||||
{ name = "filelock", specifier = ">=3.32.4" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "hy-memory", specifier = "==2.0.12" },
|
||||
{ name = "hy-memory", specifier = "==2.0.13" },
|
||||
{ name = "keyring", specifier = ">=25.7.0" },
|
||||
{ name = "numpy", specifier = "==2.5.2" },
|
||||
{ name = "onnxruntime", specifier = "==1.23.2" },
|
||||
|
||||
@@ -37,6 +37,36 @@ const { getOnboarding, sources, modelProps, mutations } = vi.hoisted(() => ({
|
||||
vi.mock("./api", () => ({
|
||||
getOnboarding,
|
||||
getModelProfiles: vi.fn().mockResolvedValue([]),
|
||||
getMemoryMode: vi.fn().mockResolvedValue({
|
||||
mode: "pro",
|
||||
configured_mode: "pro",
|
||||
explicitly_configured: false,
|
||||
forced_by_env: false,
|
||||
modes: ["lite", "pro", "ultra"],
|
||||
graph_build: {
|
||||
state: "idle",
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
users: {},
|
||||
},
|
||||
}),
|
||||
getEvolveSchedule: vi.fn().mockResolvedValue({
|
||||
enabled: true,
|
||||
time: "03:00",
|
||||
}),
|
||||
getEvolveOverview: vi.fn().mockResolvedValue({
|
||||
mode: "pro",
|
||||
schedule: { enabled: true, time: "03:00" },
|
||||
users: {},
|
||||
running: [],
|
||||
}),
|
||||
getMemoryProcessing: vi.fn().mockResolvedValue({
|
||||
embedding_switch: null,
|
||||
}),
|
||||
setMemoryMode: vi.fn(),
|
||||
setEvolveSchedule: vi.fn(),
|
||||
startEvolve: vi.fn(),
|
||||
apiErrorCode: vi.fn().mockReturnValue(null),
|
||||
getSources: vi.fn().mockResolvedValue(sources),
|
||||
...mutations,
|
||||
}));
|
||||
@@ -119,7 +149,7 @@ it("enters D47 settings without a tour or mandatory second scope-confirmation fl
|
||||
).toBeNull();
|
||||
noModelWrites();
|
||||
expect(screen.queryByRole("button", { name: "管理已有授权" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "模型" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "记忆模式与模型" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "连接其他应用" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "帮助与诊断" })).toBeTruthy();
|
||||
noModelWrites();
|
||||
@@ -188,7 +218,7 @@ it("keeps an open model draft while switching settings groups", async () => {
|
||||
"unsaved draft",
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "连接其他应用" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "模型" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "记忆模式与模型" }));
|
||||
expect(window.location.search).toBe("?view=settings");
|
||||
expect(screen.getByLabelText("保留的模型草稿")).toHaveProperty(
|
||||
"value",
|
||||
|
||||
@@ -8,6 +8,8 @@ import type {
|
||||
MemoryProcessingSummary,
|
||||
SdkAskMemory,
|
||||
SdkMemoryScene,
|
||||
SdkRawContextItem,
|
||||
SdkRawRecord,
|
||||
SourceResponse,
|
||||
} from "@one-memory/contracts";
|
||||
import type { GuiLocale } from "@one-memory/i18n";
|
||||
@@ -30,6 +32,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useGuiLanguage } from "./gui-language";
|
||||
import { HandoffBar } from "./HandoffBar";
|
||||
import { MemoryEvidencePanel } from "./MemoryEvidencePanel";
|
||||
import { MemoryGraph3D } from "./MemoryGraph3D";
|
||||
@@ -48,6 +51,7 @@ import {
|
||||
import { OptionMenu } from "./OptionMenu";
|
||||
import { R6Button } from "./R6Controls";
|
||||
import { SelectedMemoryDetail } from "./SelectedMemoryDetail";
|
||||
import { SelectedRawDetail } from "./SelectedRawDetail";
|
||||
import { sourceNameOr } from "./source-label";
|
||||
import { useMediaPreference } from "./use-media-preference";
|
||||
import { Answer, NodeDetail, type SavedAnswer } from "./Workbench";
|
||||
@@ -89,6 +93,8 @@ type Props = {
|
||||
id?: string;
|
||||
arrival?: MemoryArrival;
|
||||
onViewHistory?(): void;
|
||||
contentKind?: "memory" | "raw";
|
||||
rawRecord?: SdkRawRecord | SdkRawContextItem | null;
|
||||
libraryView?: {
|
||||
query: string;
|
||||
source: string;
|
||||
@@ -171,6 +177,8 @@ const SCENE_LABELS: Record<SdkMemoryScene, string> = {
|
||||
|
||||
export function MemoryMap(props: Props) {
|
||||
const { locale, text, count, plain } = useMemoryLanguage();
|
||||
const { t } = useGuiLanguage();
|
||||
const raw = props.contentKind === "raw";
|
||||
const {
|
||||
memories,
|
||||
graph: baseGraph,
|
||||
@@ -699,6 +707,7 @@ export function MemoryMap(props: Props) {
|
||||
: "";
|
||||
if (
|
||||
props.simplified &&
|
||||
!raw &&
|
||||
(!library ||
|
||||
(!library.loading &&
|
||||
!library.failed &&
|
||||
@@ -860,11 +869,15 @@ export function MemoryMap(props: Props) {
|
||||
>
|
||||
<Search size={16} aria-hidden="true" />
|
||||
<input
|
||||
aria-label={text("筛选地图记忆")}
|
||||
aria-label={raw ? t("sdk.raw.searchLabel") : text("筛选地图记忆")}
|
||||
name="map-memory-search"
|
||||
autoComplete="off"
|
||||
placeholder={
|
||||
props.simplified ? text("搜索记忆") : text("筛选记忆…")
|
||||
raw
|
||||
? t("sdk.raw.searchLabel")
|
||||
: props.simplified
|
||||
? text("搜索记忆")
|
||||
: text("筛选记忆…")
|
||||
}
|
||||
value={library ? searchDraft : search}
|
||||
maxLength={library ? 512 : undefined}
|
||||
@@ -971,7 +984,7 @@ export function MemoryMap(props: Props) {
|
||||
<ul
|
||||
ref={memoryList}
|
||||
className="map-memory-list"
|
||||
aria-label={text("地图记忆列表")}
|
||||
aria-label={raw ? t("memory:原始对话") : text("地图记忆列表")}
|
||||
id="memory-pane-memories"
|
||||
>
|
||||
{filtered.map((m) => (
|
||||
@@ -1014,12 +1027,12 @@ export function MemoryMap(props: Props) {
|
||||
m.updated_at,
|
||||
locale,
|
||||
)}`}
|
||||
{/* D99: the conversation date alone would hide that this
|
||||
one was made today, so the generated day stays too. */}
|
||||
<span className="map-memory-made">
|
||||
{text("沉淀于 ")}
|
||||
{dayStamp(m.updated_at, locale)}
|
||||
</span>
|
||||
{!raw ? (
|
||||
<span className="map-memory-made">
|
||||
{text("沉淀于 ")}
|
||||
{dayStamp(m.updated_at, locale)}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
text("已可用 · {count} 段原文", {
|
||||
@@ -1033,9 +1046,11 @@ export function MemoryMap(props: Props) {
|
||||
</ul>
|
||||
{library?.failed && (
|
||||
<div role="alert">
|
||||
<p>{text("记忆读取失败,请重试。")}</p>
|
||||
<p>
|
||||
{raw ? t("sdk.raw.loadFailed") : text("记忆读取失败,请重试。")}
|
||||
</p>
|
||||
<R6Button onClick={retryLibrary}>
|
||||
{text("重新读取地图记忆")}
|
||||
{raw ? t("connectionFallback.retry") : text("重新读取地图记忆")}
|
||||
</R6Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1045,13 +1060,17 @@ export function MemoryMap(props: Props) {
|
||||
!library.stale &&
|
||||
!filtered.length && (
|
||||
<p className="map-empty">
|
||||
{library.query
|
||||
? text("没有匹配的记忆")
|
||||
: library.source
|
||||
? text("这个来源下还没有记忆")
|
||||
: groupKey
|
||||
? text("本页没有匹配的主题记忆")
|
||||
: text("还没有已生成的记忆")}
|
||||
{raw
|
||||
? library.query
|
||||
? t("sdk.raw.found", { count: 0 })
|
||||
: t("sdk.raw.empty")
|
||||
: library.query
|
||||
? text("没有匹配的记忆")
|
||||
: library.source
|
||||
? text("这个来源下还没有记忆")
|
||||
: groupKey
|
||||
? text("本页没有匹配的主题记忆")
|
||||
: text("还没有已生成的记忆")}
|
||||
</p>
|
||||
)}
|
||||
{library?.page?.search_window &&
|
||||
@@ -1151,12 +1170,20 @@ export function MemoryMap(props: Props) {
|
||||
<span className="map-kicker" hidden={props.simplified}>
|
||||
{text(axis === "topic" ? "主题视角(非事项)" : "来源视角")}
|
||||
</span>
|
||||
<h2>{props.simplified ? text("记忆关联") : label}</h2>
|
||||
<h2>
|
||||
{props.simplified
|
||||
? raw
|
||||
? t("memory:原始对话")
|
||||
: text("记忆关联")
|
||||
: label}
|
||||
</h2>
|
||||
{props.simplified && (
|
||||
<p className="selected-graph-subtitle">
|
||||
{projection.nodes.length
|
||||
? text("来源归属")
|
||||
: text("可在沉淀记忆后查看视图")}
|
||||
: raw
|
||||
? t("sdk.raw.empty")
|
||||
: text("可在沉淀记忆后查看视图")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -1195,21 +1222,33 @@ export function MemoryMap(props: Props) {
|
||||
: {})}
|
||||
>
|
||||
{library?.stale
|
||||
? text("可在沉淀记忆后查看视图")
|
||||
? raw
|
||||
? t("sdk.raw.loadFailed")
|
||||
: text("可在沉淀记忆后查看视图")
|
||||
: library?.graphFailed || library?.failed
|
||||
? text("记忆暂时无法读取")
|
||||
? raw
|
||||
? t("sdk.raw.loadFailed")
|
||||
: text("记忆暂时无法读取")
|
||||
: library?.graphLoading
|
||||
? text("正在读取记忆地图…")
|
||||
: library?.page &&
|
||||
(library.query || library.source || groupKey)
|
||||
? text("当前筛选没有可显示的记忆")
|
||||
? raw
|
||||
? t("sdk.raw.found", { count: 0 })
|
||||
: text("当前筛选没有可显示的记忆")
|
||||
: !loaded
|
||||
? text("正在读取记忆…")
|
||||
? raw
|
||||
? t("sdk.raw.recent")
|
||||
: text("正在读取记忆…")
|
||||
: active.length
|
||||
? text("当前筛选没有可显示的记忆")
|
||||
: arrival.importedMessages > 0
|
||||
? text("已保存的对话还未生成记忆")
|
||||
: text("还没有可显示的记忆")}
|
||||
? raw
|
||||
? t("sdk.raw.found", { count: 0 })
|
||||
: text("当前筛选没有可显示的记忆")
|
||||
: raw
|
||||
? t("sdk.raw.empty")
|
||||
: arrival.importedMessages > 0
|
||||
? text("已保存的对话还未生成记忆")
|
||||
: text("还没有可显示的记忆")}
|
||||
</p>
|
||||
{library?.graphFailed ? (
|
||||
<R6Button onClick={library.onRetry}>
|
||||
@@ -1313,13 +1352,15 @@ export function MemoryMap(props: Props) {
|
||||
>
|
||||
<header className="map-inspector-heading">
|
||||
<h2>
|
||||
{inspectorDetail
|
||||
? text("记忆详情")
|
||||
: selectedSource
|
||||
{raw
|
||||
? t("memory:原始对话")
|
||||
: inspectorDetail
|
||||
? text("记忆详情")
|
||||
: visibleGroupNode
|
||||
? text("关联节点")
|
||||
: text("记忆详情")}
|
||||
: selectedSource
|
||||
? text("记忆详情")
|
||||
: visibleGroupNode
|
||||
? text("关联节点")
|
||||
: text("记忆详情")}
|
||||
</h2>
|
||||
{(activeId || visibleGroupNode) && (
|
||||
<button
|
||||
@@ -1339,7 +1380,9 @@ export function MemoryMap(props: Props) {
|
||||
{library?.failed &&
|
||||
library.source === selectedSource.source_id ? (
|
||||
<>
|
||||
<p role="alert">{text("记忆暂时无法读取")}</p>
|
||||
<p role="alert">
|
||||
{raw ? t("sdk.raw.loadFailed") : text("记忆暂时无法读取")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
@@ -1356,10 +1399,12 @@ export function MemoryMap(props: Props) {
|
||||
<i />
|
||||
<i />
|
||||
</div>
|
||||
<p role="status">{text("正在读取记忆详情…")}</p>
|
||||
<p role="status">
|
||||
{raw ? t("sdk.raw.recent") : text("正在读取记忆详情…")}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p>{text("还没有已生成的记忆")}</p>
|
||||
<p>{raw ? t("sdk.raw.empty") : text("还没有已生成的记忆")}</p>
|
||||
)}
|
||||
</div>
|
||||
) : props.simplified && inspectorDetail ? (
|
||||
@@ -1423,6 +1468,8 @@ export function MemoryMap(props: Props) {
|
||||
</div>
|
||||
<p role="status">{text("正在读取这条记忆…")}</p>
|
||||
</div>
|
||||
) : raw && props.rawRecord ? (
|
||||
<SelectedRawDetail record={props.rawRecord} sources={sources} />
|
||||
) : (
|
||||
<SelectedMemoryDetail
|
||||
detail={inspectorDetail}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getEvolveOverview,
|
||||
getEvolveSchedule,
|
||||
getMemoryMode,
|
||||
getMemoryProcessing,
|
||||
setEvolveSchedule,
|
||||
setMemoryMode,
|
||||
} from "./api";
|
||||
import { MemoryModeSettings } from "./MemoryModeSettings";
|
||||
|
||||
vi.mock("./api", () => ({
|
||||
getMemoryMode: vi.fn(),
|
||||
setMemoryMode: vi.fn(),
|
||||
getEvolveSchedule: vi.fn(),
|
||||
setEvolveSchedule: vi.fn(),
|
||||
getEvolveOverview: vi.fn(),
|
||||
startEvolve: vi.fn(),
|
||||
getMemoryProcessing: vi.fn(),
|
||||
apiErrorCode: vi.fn().mockReturnValue(null),
|
||||
}));
|
||||
|
||||
function state(mode: "lite" | "pro" | "ultra" = "pro") {
|
||||
return {
|
||||
mode,
|
||||
configured_mode: mode,
|
||||
explicitly_configured: true,
|
||||
forced_by_env: false,
|
||||
modes: ["lite", "pro", "ultra"] as Array<"lite" | "pro" | "ultra">,
|
||||
graph_build: {
|
||||
state: "idle" as const,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
users: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function prepare(mode: "lite" | "pro" | "ultra" = "pro") {
|
||||
vi.mocked(getMemoryMode).mockResolvedValue(state(mode));
|
||||
vi.mocked(getEvolveSchedule).mockResolvedValue({
|
||||
enabled: true,
|
||||
time: "03:00",
|
||||
});
|
||||
vi.mocked(getEvolveOverview).mockResolvedValue({
|
||||
mode,
|
||||
schedule: { enabled: true, time: "03:00" },
|
||||
users: {},
|
||||
running: [],
|
||||
});
|
||||
vi.mocked(getMemoryProcessing).mockResolvedValue({} as never);
|
||||
}
|
||||
|
||||
async function chooseMode(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
name: RegExp,
|
||||
) {
|
||||
const picker = await screen.findByRole("combobox", { name: "当前模式" });
|
||||
await user.click(picker);
|
||||
await user.click(screen.getByRole("option", { name }));
|
||||
return picker;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("confirms the data effects before switching to lite", async () => {
|
||||
prepare("pro");
|
||||
vi.mocked(setMemoryMode).mockResolvedValue({
|
||||
...state("lite"),
|
||||
previous_mode: "pro",
|
||||
effects: ["higher_mode_memories_hidden"],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
|
||||
await chooseMode(user, /基础记忆(lite)/);
|
||||
expect(screen.getByText(/高级记忆会保留/)).toBeTruthy();
|
||||
expect(setMemoryMode).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "切换模式" }));
|
||||
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledWith("lite"));
|
||||
});
|
||||
|
||||
it("traps modal focus, closes on Escape and restores the mode-card focus", async () => {
|
||||
prepare("pro");
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
const lite = await chooseMode(user, /基础记忆(lite)/);
|
||||
|
||||
const cancel = screen.getByRole("button", { name: "取消" });
|
||||
await waitFor(() => expect(document.activeElement).toBe(cancel));
|
||||
await user.keyboard("{Shift>}{Tab}{/Shift}");
|
||||
expect(document.activeElement).toBe(
|
||||
screen.getByRole("button", { name: "切换模式" }),
|
||||
);
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(document.activeElement).toBe(lite);
|
||||
});
|
||||
|
||||
it("keeps focus inside the modal while the mode change is pending", async () => {
|
||||
prepare("pro");
|
||||
vi.mocked(setMemoryMode).mockReturnValue(new Promise(() => {}));
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
await chooseMode(user, /基础记忆(lite)/);
|
||||
await user.click(screen.getByRole("button", { name: "切换模式" }));
|
||||
|
||||
const modal = screen.getByRole("dialog");
|
||||
await waitFor(() => expect(document.activeElement).toBe(modal));
|
||||
await user.keyboard("{Tab}");
|
||||
expect(document.activeElement).toBe(modal);
|
||||
});
|
||||
|
||||
it("routes to model setup instead of switching pro without a model", async () => {
|
||||
prepare("lite");
|
||||
const configure = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MemoryModeSettings hasActiveModel={false} onConfigureModel={configure} />,
|
||||
);
|
||||
|
||||
await chooseMode(user, /智能记忆(pro)/);
|
||||
expect(configure).toHaveBeenCalledTimes(1);
|
||||
expect(setMemoryMode).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByText("使用 pro 或 ultra 前需要先配置并启用生成模型。"),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the default nightly evolve controls in ultra", async () => {
|
||||
prepare("ultra");
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
|
||||
expect(
|
||||
(await screen.findByRole("switch", { name: "每天自动整合" })).getAttribute(
|
||||
"aria-checked",
|
||||
),
|
||||
).toBe("true");
|
||||
expect(
|
||||
(screen.getByLabelText("自动整合时间") as HTMLInputElement).value,
|
||||
).toBe("03:00");
|
||||
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("ignores an older refresh that finishes after a newer mode read", async () => {
|
||||
prepare("pro");
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
const picker = await screen.findByRole("combobox", { name: "当前模式" });
|
||||
|
||||
let resolveOld!: (value: ReturnType<typeof state>) => void;
|
||||
vi.mocked(getMemoryMode)
|
||||
.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveOld = resolve;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(state("lite"));
|
||||
|
||||
const refresh = screen.getByRole("button", { name: "刷新记忆模式" });
|
||||
await user.click(refresh);
|
||||
await user.click(refresh);
|
||||
await waitFor(() => expect((picker as HTMLButtonElement).value).toBe("lite"));
|
||||
resolveOld(state("pro"));
|
||||
|
||||
await waitFor(() => expect((picker as HTMLButtonElement).value).toBe("lite"));
|
||||
});
|
||||
|
||||
it("does not let an older refresh undo a saved schedule change", async () => {
|
||||
prepare("ultra");
|
||||
vi.mocked(setEvolveSchedule).mockResolvedValue({
|
||||
enabled: false,
|
||||
time: "03:00",
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
const toggle = await screen.findByRole("switch", {
|
||||
name: "每天自动整合",
|
||||
});
|
||||
|
||||
let resolveOld!: (value: { enabled: true; time: string }) => void;
|
||||
vi.mocked(getEvolveSchedule).mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveOld = resolve;
|
||||
}),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "刷新记忆模式" }));
|
||||
await user.click(toggle);
|
||||
await waitFor(() =>
|
||||
expect(setEvolveSchedule).toHaveBeenCalledWith({ enabled: false }),
|
||||
);
|
||||
resolveOld({ enabled: true, time: "03:00" });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toggle.getAttribute("aria-checked")).toBe("false"),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the mode usable when an evolve status read fails", async () => {
|
||||
prepare("pro");
|
||||
vi.mocked(getEvolveSchedule).mockRejectedValueOnce(new Error("offline"));
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
|
||||
expect(
|
||||
(await screen.findByRole("combobox", {
|
||||
name: "当前模式",
|
||||
})) as HTMLButtonElement,
|
||||
).toHaveProperty("disabled", false);
|
||||
});
|
||||
|
||||
it("keeps lite available while model status is unknown", async () => {
|
||||
prepare("pro");
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MemoryModeSettings
|
||||
hasActiveModel={false}
|
||||
modelStateKnown={false}
|
||||
onConfigureModel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole("combobox", { name: "当前模式" }));
|
||||
expect(
|
||||
screen.getByRole("option", { name: /基础记忆(lite)/ }),
|
||||
).toHaveProperty("disabled", false);
|
||||
expect(
|
||||
screen.getByRole("option", { name: /智能记忆(pro)/ }),
|
||||
).toHaveProperty("disabled", true);
|
||||
expect(
|
||||
screen.getByRole("option", { name: /深度记忆(ultra)/ }),
|
||||
).toHaveProperty("disabled", true);
|
||||
});
|
||||
@@ -0,0 +1,420 @@
|
||||
import type {
|
||||
EvolveOverview,
|
||||
EvolveRunReceipt,
|
||||
EvolveSchedule,
|
||||
MemoryModeChange,
|
||||
MemoryModeState,
|
||||
MemoryPipelineMode,
|
||||
} from "@one-memory/contracts";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
||||
import {
|
||||
apiErrorCode,
|
||||
getEvolveOverview,
|
||||
getEvolveSchedule,
|
||||
getMemoryMode,
|
||||
getMemoryProcessing,
|
||||
setEvolveSchedule,
|
||||
setMemoryMode,
|
||||
startEvolve,
|
||||
} from "./api";
|
||||
import { OptionMenu } from "./OptionMenu";
|
||||
import { useOnboardingTranslation } from "./onboarding-language";
|
||||
import { R6Button, R6Switch } from "./R6Controls";
|
||||
|
||||
const MODE_COPY: Record<
|
||||
MemoryPipelineMode,
|
||||
{ title: string; description: string }
|
||||
> = {
|
||||
lite: {
|
||||
title: "基础记忆(lite)",
|
||||
description: "不调用生成模型。保存并检索原始对话,不再生成高级记忆。",
|
||||
},
|
||||
pro: {
|
||||
title: "智能记忆(pro)",
|
||||
description: "使用当前模型从新对话中提取事实、偏好和计划。",
|
||||
},
|
||||
ultra: {
|
||||
title: "深度记忆(ultra)",
|
||||
description: "在智能记忆之上使用知识图谱,并默认每天 03:00 自动整合记忆。",
|
||||
},
|
||||
};
|
||||
|
||||
const EFFECT_COPY: Record<string, string> = {
|
||||
earlier_raw_not_extracted:
|
||||
"lite 下已经处理完成的原始对话不会补提取;排队中和之后的新对话会生成高级记忆。",
|
||||
lite_search_stops: "原始对话检索会停止,改为使用高级记忆。",
|
||||
higher_mode_memories_hidden:
|
||||
"以前生成的高级记忆会继续保留,但在 lite 下不会显示或参与记忆召回。",
|
||||
evolve_stopped: "每天自动整合会停止,已经生成的数据不会删除。",
|
||||
existing_memories_added_to_graph:
|
||||
"已有高级记忆会在后台加入知识图谱,期间可以继续使用 MemHub。",
|
||||
graph_backfill_already_running: "知识图谱仍在补建,不会重复启动。",
|
||||
};
|
||||
|
||||
function plannedEffects(
|
||||
current: MemoryPipelineMode,
|
||||
target: MemoryPipelineMode,
|
||||
): string[] {
|
||||
if (current === target) return [];
|
||||
const effects: string[] = [];
|
||||
if (current === "lite" && target !== "lite")
|
||||
effects.push("earlier_raw_not_extracted", "lite_search_stops");
|
||||
if (target === "lite") effects.push("higher_mode_memories_hidden");
|
||||
if (current === "ultra" && target !== "ultra") effects.push("evolve_stopped");
|
||||
if (target === "ultra") effects.push("existing_memories_added_to_graph");
|
||||
return effects;
|
||||
}
|
||||
|
||||
export function MemoryModeSettings({
|
||||
hasActiveModel,
|
||||
modelStateKnown = true,
|
||||
onConfigureModel,
|
||||
onModeChange,
|
||||
}: {
|
||||
hasActiveModel: boolean;
|
||||
modelStateKnown?: boolean;
|
||||
onConfigureModel(): void;
|
||||
onModeChange?(mode: MemoryPipelineMode): void;
|
||||
}) {
|
||||
const { text } = useOnboardingTranslation();
|
||||
const id = useId();
|
||||
const [state, setState] = useState<MemoryModeState | null>(null);
|
||||
const [schedule, setScheduleState] = useState<EvolveSchedule | null>(null);
|
||||
const [overview, setOverview] = useState<EvolveOverview | null>(null);
|
||||
const [pendingMode, setPendingMode] = useState<MemoryPipelineMode | null>(
|
||||
null,
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [maintenance, setMaintenance] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [run, setRun] = useState<EvolveRunReceipt | null>(null);
|
||||
const refreshVersion = useRef(0);
|
||||
const dialog = useRef<HTMLElement>(null);
|
||||
const dialogOpener = useRef<HTMLElement | null>(null);
|
||||
const busyRef = useRef(false);
|
||||
busyRef.current = busy;
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingMode || !dialog.current) return;
|
||||
const panel = dialog.current;
|
||||
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();
|
||||
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);
|
||||
if (dialogOpener.current?.isConnected) dialogOpener.current.focus();
|
||||
};
|
||||
}, [pendingMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingMode && busy) dialog.current?.focus();
|
||||
}, [busy, pendingMode]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const version = ++refreshVersion.current;
|
||||
const [modeResult, scheduleResult, overviewResult, processingResult] =
|
||||
await Promise.allSettled([
|
||||
getMemoryMode(),
|
||||
getEvolveSchedule(),
|
||||
getEvolveOverview(),
|
||||
getMemoryProcessing(),
|
||||
]);
|
||||
if (version !== refreshVersion.current) return;
|
||||
if (modeResult.status === "fulfilled") {
|
||||
setState(modeResult.value);
|
||||
onModeChange?.(modeResult.value.mode);
|
||||
setError("");
|
||||
} else {
|
||||
setError("记忆模式状态暂时无法读取,请重试。");
|
||||
}
|
||||
if (scheduleResult.status === "fulfilled")
|
||||
setScheduleState(scheduleResult.value);
|
||||
if (overviewResult.status === "fulfilled") {
|
||||
setOverview(overviewResult.value);
|
||||
setRun(overviewResult.value.running[0] ?? null);
|
||||
}
|
||||
if (processingResult.status === "fulfilled")
|
||||
setMaintenance(Boolean(processingResult.value.embedding_switch));
|
||||
}, [onModeChange]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
const running =
|
||||
maintenance ||
|
||||
state?.graph_build.state === "running" ||
|
||||
run?.state === "running";
|
||||
if (!running) return;
|
||||
const timer = window.setInterval(() => void refresh(), 1500);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [maintenance, refresh, run?.state, state?.graph_build.state]);
|
||||
|
||||
const lifecycleBusy =
|
||||
maintenance ||
|
||||
run?.state === "running" ||
|
||||
state?.graph_build.state === "running";
|
||||
|
||||
function choose(mode: MemoryPipelineMode) {
|
||||
if (!state || mode === state.mode || busy || lifecycleBusy) return;
|
||||
if (mode !== "lite" && !hasActiveModel) {
|
||||
setError("使用 pro 或 ultra 前需要先配置并启用生成模型。");
|
||||
onConfigureModel();
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
dialogOpener.current = document.getElementById(`${id}-select`);
|
||||
setPendingMode(mode);
|
||||
}
|
||||
|
||||
async function commitMode() {
|
||||
if (!pendingMode) return;
|
||||
refreshVersion.current++;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const changed: MemoryModeChange = await setMemoryMode(pendingMode);
|
||||
setState(changed);
|
||||
setPendingMode(null);
|
||||
await refresh();
|
||||
} catch (reason) {
|
||||
const code = apiErrorCode(reason);
|
||||
setError(
|
||||
code === "embedding_maintenance_in_progress"
|
||||
? "正在检查或切换向量模型,完成后才能切换记忆模式。"
|
||||
: code === "graph_store_unavailable"
|
||||
? "当前安装缺少知识图谱组件,无法开启 ultra。"
|
||||
: "模式切换未能确认,仍显示上次确认的模式。",
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function changeSchedule(update: Partial<EvolveSchedule>) {
|
||||
refreshVersion.current++;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setScheduleState(await setEvolveSchedule(update));
|
||||
} catch {
|
||||
setError("自动整合设置未能保存,请重试。");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function evolveNow() {
|
||||
refreshVersion.current++;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const receipt = await startEvolve();
|
||||
setRun(receipt);
|
||||
await refresh();
|
||||
} catch (reason) {
|
||||
const code = apiErrorCode(reason);
|
||||
setError(
|
||||
code === "embedding_maintenance_in_progress"
|
||||
? "正在检查或切换向量模型,完成后才能整合记忆。"
|
||||
: "未能开始整合记忆,请重试。",
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const last = overview?.users.local;
|
||||
return (
|
||||
<section
|
||||
className="r6-memory-mode-settings"
|
||||
aria-labelledby="memory-mode-title"
|
||||
>
|
||||
<div className="r6-mode-heading">
|
||||
<div>
|
||||
<h2 id="memory-mode-title">{text("记忆模式")}</h2>
|
||||
<p>{text("模式决定新对话如何保存,以及记忆页面展示什么。")}</p>
|
||||
</div>
|
||||
<R6Button
|
||||
variant="quiet"
|
||||
aria-label={text("刷新记忆模式")}
|
||||
title={text("刷新记忆模式")}
|
||||
disabled={busy}
|
||||
onClick={() => void refresh()}
|
||||
>
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
</R6Button>
|
||||
</div>
|
||||
<div
|
||||
className="r6-language-setting-row r6-mode-setting-row"
|
||||
aria-busy={state === null}
|
||||
>
|
||||
<label id={`${id}-label`} htmlFor={`${id}-select`}>
|
||||
{text("当前模式")}
|
||||
</label>
|
||||
<OptionMenu
|
||||
id={`${id}-select`}
|
||||
labelledBy={`${id}-label`}
|
||||
value={state?.mode ?? ""}
|
||||
disabled={!state || busy || lifecycleBusy}
|
||||
options={(["lite", "pro", "ultra"] as const).map((mode) => ({
|
||||
value: mode,
|
||||
label: text(MODE_COPY[mode].title),
|
||||
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")
|
||||
choose(value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{state?.forced_by_env ? (
|
||||
<p className="r6-settings-note" role="status">
|
||||
{text("开发环境当前强制使用 ultra,保存的选择暂不会生效。")}
|
||||
</p>
|
||||
) : null}
|
||||
{maintenance ? (
|
||||
<p className="r6-settings-note" role="status">
|
||||
{text("正在检查或切换向量模型,完成前不能切换模式或手动整合。")}
|
||||
</p>
|
||||
) : null}
|
||||
{state?.graph_build.state === "running" ? (
|
||||
<p className="r6-settings-note" role="status">
|
||||
{text("正在把已有高级记忆加入知识图谱…")}
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p className="r6-settings-error" role="alert">
|
||||
{text(error)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{state?.mode === "ultra" && schedule ? (
|
||||
<div className="r6-evolve-settings">
|
||||
<div className="r6-setting-row">
|
||||
<div>
|
||||
<h3>{text("每天自动整合")}</h3>
|
||||
<p>
|
||||
{text(
|
||||
"每天 {time} 自动运行 evolve,设备休眠错过后会补跑。",
|
||||
).replace("{time}", schedule.time)}
|
||||
</p>
|
||||
</div>
|
||||
<R6Switch
|
||||
label={text("每天自动整合")}
|
||||
checked={schedule.enabled}
|
||||
disabled={busy || maintenance}
|
||||
onCheckedChange={(enabled) => void changeSchedule({ enabled })}
|
||||
/>
|
||||
</div>
|
||||
<div className="r6-setting-row">
|
||||
<div>
|
||||
<h3>{text("自动整合时间")}</h3>
|
||||
<p>{text("使用这台设备的本地时间。")}</p>
|
||||
</div>
|
||||
<input
|
||||
type="time"
|
||||
value={schedule.time}
|
||||
disabled={busy || maintenance}
|
||||
aria-label={text("自动整合时间")}
|
||||
onChange={(event) =>
|
||||
void changeSchedule({ time: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="r6-setting-row">
|
||||
<div>
|
||||
<h3>{text("立即整合记忆")}</h3>
|
||||
<p>
|
||||
{text(
|
||||
run?.state === "running"
|
||||
? "正在整合记忆…"
|
||||
: last?.last_run_at
|
||||
? text("上次整合:{time}").replace(
|
||||
"{time}",
|
||||
new Date(last.last_run_at).toLocaleString(),
|
||||
)
|
||||
: "尚未整合",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<R6Button
|
||||
variant="primary"
|
||||
pending={run?.state === "running"}
|
||||
disabled={busy || lifecycleBusy}
|
||||
onClick={() => void evolveNow()}
|
||||
>
|
||||
{text("立即整合")}
|
||||
</R6Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{pendingMode ? (
|
||||
<div className="r6-settings-dialog-layer" role="presentation">
|
||||
<section
|
||||
ref={dialog}
|
||||
className="r6-settings-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="memory-mode-confirm-title"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<h2 id="memory-mode-confirm-title">
|
||||
{text("切换到 {mode}?").replace("{mode}", pendingMode)}
|
||||
</h2>
|
||||
<ul>
|
||||
{plannedEffects(state?.mode ?? "pro", pendingMode).map(
|
||||
(effect) => (
|
||||
<li key={effect}>{text(EFFECT_COPY[effect])}</li>
|
||||
),
|
||||
)}
|
||||
<li>{text("原始对话在所有模式下都会保留。")}</li>
|
||||
</ul>
|
||||
<footer>
|
||||
<R6Button disabled={busy} onClick={() => setPendingMode(null)}>
|
||||
{text("取消")}
|
||||
</R6Button>
|
||||
<R6Button
|
||||
variant="primary"
|
||||
pending={busy}
|
||||
onClick={() => void commitMode()}
|
||||
>
|
||||
{text("切换模式")}
|
||||
</R6Button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -74,6 +74,8 @@ const { reads } = vi.hoisted(() => ({
|
||||
sdkGraph: vi.fn(),
|
||||
processing: vi.fn(),
|
||||
profiles: vi.fn(),
|
||||
raw: vi.fn(),
|
||||
rawSearch: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("./api", async (importOriginal) => ({
|
||||
@@ -82,6 +84,8 @@ vi.mock("./api", async (importOriginal) => ({
|
||||
getSdkMemoryGraph: reads.sdkGraph,
|
||||
getMemoryProcessing: reads.processing,
|
||||
getModelProfiles: reads.profiles,
|
||||
getSdkRaw: reads.raw,
|
||||
searchSdkRaw: reads.rawSearch,
|
||||
}));
|
||||
vi.mock("./GraphWidthPrompt", () => ({ GraphWidthPrompt: () => null }));
|
||||
vi.mock("./MemoryAddComposer", () => ({
|
||||
@@ -194,8 +198,130 @@ function setup() {
|
||||
completed: 1,
|
||||
});
|
||||
reads.profiles.mockResolvedValue([]);
|
||||
reads.raw.mockResolvedValue({
|
||||
items: [],
|
||||
total: 0,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
});
|
||||
reads.rawSearch.mockResolvedValue([]);
|
||||
}
|
||||
|
||||
it("shows raw conversations instead of advanced memory in lite", async () => {
|
||||
setup();
|
||||
reads.processing.mockResolvedValue({
|
||||
running: 0,
|
||||
pending: 0,
|
||||
blocked_stages: [],
|
||||
completed: 1,
|
||||
pipeline: {
|
||||
mode: "lite",
|
||||
configured_mode: "lite",
|
||||
forced_by_env: false,
|
||||
evolve: {
|
||||
mode: "lite",
|
||||
schedule: { enabled: true, time: "03:00" },
|
||||
users: {},
|
||||
running: [],
|
||||
},
|
||||
last_evolve_at: null,
|
||||
graph_build: {
|
||||
state: "idle",
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
users: {},
|
||||
},
|
||||
features: {},
|
||||
},
|
||||
});
|
||||
reads.raw.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
raw_id: "raw-1",
|
||||
content: "原始对话内容",
|
||||
user_id: "local",
|
||||
agent_id: "all-apps",
|
||||
source: "cursor",
|
||||
session_id: "session-1",
|
||||
memory_at: null,
|
||||
gmt_created: 1_761_000_000,
|
||||
custom: {},
|
||||
messages: [{ role: "user", content: "原始对话内容" }],
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
render(<MemorySpace simplified />);
|
||||
|
||||
expect(await screen.findAllByText("原始对话内容")).toHaveLength(2);
|
||||
expect(
|
||||
document.querySelector(".core-memory-map")?.hasAttribute("hidden"),
|
||||
).toBe(false);
|
||||
expect(screen.getByTestId("graph-owner")).toBeTruthy();
|
||||
expect(screen.getByRole("textbox", { name: "搜索原始对话" })).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "补充记忆" })).toBeNull();
|
||||
expect(reads.sdkLibrary).not.toHaveBeenCalled();
|
||||
expect(reads.sdkGraph).not.toHaveBeenCalled();
|
||||
expect(reads.raw).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: undefined,
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText(/当前为 lite/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not request advanced memories before the pipeline mode is known", async () => {
|
||||
setup();
|
||||
let resolveProcessing!: (value: unknown) => void;
|
||||
reads.processing.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveProcessing = resolve;
|
||||
}),
|
||||
);
|
||||
render(<MemorySpace simplified />);
|
||||
|
||||
await act(async () => Promise.resolve());
|
||||
expect(reads.sdkLibrary).not.toHaveBeenCalled();
|
||||
expect(reads.sdkGraph).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
resolveProcessing({
|
||||
running: 0,
|
||||
pending: 0,
|
||||
blocked_stages: [],
|
||||
completed: 0,
|
||||
pipeline: {
|
||||
mode: "lite",
|
||||
configured_mode: "lite",
|
||||
forced_by_env: false,
|
||||
evolve: {
|
||||
mode: "lite",
|
||||
schedule: { enabled: true, time: "03:00" },
|
||||
users: {},
|
||||
running: [],
|
||||
},
|
||||
last_evolve_at: null,
|
||||
graph_build: {
|
||||
state: "idle",
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
users: {},
|
||||
},
|
||||
features: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
await screen.findByText(/当前为 lite/);
|
||||
expect(reads.sdkLibrary).not.toHaveBeenCalled();
|
||||
expect(reads.sdkGraph).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps an added memory visible while it waits for model processing", async () => {
|
||||
setup();
|
||||
render(<MemorySpace simplified onConfigureModel={vi.fn()} />);
|
||||
|
||||
@@ -36,7 +36,9 @@ import {
|
||||
getSdkMemory,
|
||||
getSdkMemoryGraph,
|
||||
getSdkMemoryLibrary,
|
||||
getSdkRaw,
|
||||
manualExtract,
|
||||
searchSdkRaw,
|
||||
setExtractionWindow,
|
||||
updateSdkMemory,
|
||||
} from "./api";
|
||||
@@ -54,6 +56,12 @@ import {
|
||||
sdkLibraryPage,
|
||||
sdkNamespace,
|
||||
} from "./sdk-memory-map";
|
||||
import {
|
||||
type RawMapRecord,
|
||||
rawDetail,
|
||||
rawLibraryPage,
|
||||
rawSearchPage,
|
||||
} from "./sdk-raw-map";
|
||||
import { sourceNameOr, UNATTRIBUTED_SOURCE_ID } from "./source-label";
|
||||
import { traceUiAction } from "./ui-trace";
|
||||
import { isDefiniteRefusal, writeTraceStatus } from "./write-outcome";
|
||||
@@ -109,6 +117,10 @@ export function MemorySpace({
|
||||
return isMessageKey(key) ? t(key) : text;
|
||||
};
|
||||
const [detail, setDetail] = useState<MemoryDetail | null>(null);
|
||||
const [selectedRaw, setSelectedRaw] = useState<RawMapRecord | null>(null);
|
||||
const selectedRawRef = useRef<RawMapRecord | null>(null);
|
||||
selectedRawRef.current = selectedRaw;
|
||||
const rawRecords = useRef(new Map<string, RawMapRecord>());
|
||||
const [detailLoad, setDetailLoad] = useState<{
|
||||
memoryId: string;
|
||||
status: "loading" | "failed";
|
||||
@@ -183,17 +195,31 @@ export function MemorySpace({
|
||||
/** The filter the shown page was read with, for the count above it. */
|
||||
settledSource: string;
|
||||
settledQuery: string;
|
||||
settledMode: "lite" | "pro" | "ultra" | null;
|
||||
}>({
|
||||
page: null,
|
||||
loading: false,
|
||||
failed: false,
|
||||
settledSource: "",
|
||||
settledQuery: "",
|
||||
settledMode: null,
|
||||
});
|
||||
const [sdkLibraryQuery, setSdkLibraryQuery] = useState("");
|
||||
const [sdkLibrarySource, setSdkLibrarySource] = useState("");
|
||||
const [sdkLibraryOffset, setSdkLibraryOffset] = useState(0);
|
||||
const [processing, setProcessing] = useState<MemoryProcessingSummary | null>(
|
||||
null,
|
||||
);
|
||||
const [processingFailed, setProcessingFailed] = useState(false);
|
||||
const [processingResolved, setProcessingResolved] = useState(false);
|
||||
const pipelineMode =
|
||||
processing?.pipeline?.mode ??
|
||||
(processingResolved && !processingFailed ? "pro" : null);
|
||||
const lite = pipelineMode === "lite";
|
||||
const sdkPage =
|
||||
sdkLibrary.settledMode === pipelineMode ? sdkLibrary.page : null;
|
||||
const sdkRequestedScope = JSON.stringify([
|
||||
pipelineMode,
|
||||
sdkLibraryQuery.trim(),
|
||||
sdkLibrarySource,
|
||||
]);
|
||||
@@ -213,8 +239,9 @@ export function MemorySpace({
|
||||
});
|
||||
// The page the list shows, for the namespace of a memory being opened.
|
||||
const sdkPageRef = useRef<MemoryLibraryPage | null>(null);
|
||||
sdkPageRef.current = sdkLibrary.page;
|
||||
const sdkMapVisible = simplified && active && displayedMode === "map";
|
||||
sdkPageRef.current = sdkPage;
|
||||
const sdkMapVisible =
|
||||
simplified && active && displayedMode === "map" && pipelineMode !== null;
|
||||
useEffect(() => {
|
||||
if (!sdkMapVisible) return;
|
||||
// A write bumps the revision; reading it here re-runs the read at once
|
||||
@@ -224,7 +251,7 @@ export function MemorySpace({
|
||||
let controller: AbortController | null = null;
|
||||
const source = sdkLibrarySource || undefined;
|
||||
const query = sdkLibraryQuery.trim() || undefined;
|
||||
const windowKey = JSON.stringify([query ?? "", source ?? ""]);
|
||||
const windowKey = JSON.stringify([pipelineMode, query ?? "", source ?? ""]);
|
||||
if (sdkSearchWindow.current.key !== windowKey)
|
||||
sdkSearchWindow.current = { key: windowKey, id: null };
|
||||
const read = async (first: boolean) => {
|
||||
@@ -233,42 +260,68 @@ export function MemorySpace({
|
||||
controller = request;
|
||||
if (first) setSdkLibrary((current) => ({ ...current, loading: true }));
|
||||
try {
|
||||
const [page, graph] = await Promise.all([
|
||||
getSdkMemoryLibrary(
|
||||
{
|
||||
statuses: ["active"],
|
||||
source,
|
||||
query,
|
||||
offset: sdkLibraryOffset,
|
||||
limit: SDK_MEMORY_PAGE_SIZE,
|
||||
windowId: query ? sdkSearchWindow.current.id : null,
|
||||
},
|
||||
request.signal,
|
||||
),
|
||||
// The overlay is decoration: the list stands without it. It reads
|
||||
// the full active/source-filtered scene up to the API's visual cap.
|
||||
// Reading only the first list-page prefix made "All applications"
|
||||
// look identical to Cursor whenever newer Cursor memories occupied
|
||||
// that prefix.
|
||||
query
|
||||
? Promise.resolve({
|
||||
const rememberRaw = (items: RawMapRecord[]) => {
|
||||
const next = new Map<string, RawMapRecord>();
|
||||
if (selectedRawRef.current)
|
||||
next.set(selectedRawRef.current.raw_id, selectedRawRef.current);
|
||||
for (const item of items) next.set(item.raw_id, item);
|
||||
rawRecords.current = next;
|
||||
};
|
||||
const [page, graph] = lite
|
||||
? await Promise.all([
|
||||
query
|
||||
? searchSdkRaw(query, {
|
||||
source,
|
||||
signal: request.signal,
|
||||
}).then((items) => {
|
||||
rememberRaw(items);
|
||||
return rawSearchPage(items);
|
||||
})
|
||||
: getSdkRaw({
|
||||
source,
|
||||
offset: sdkLibraryOffset,
|
||||
limit: SDK_MEMORY_PAGE_SIZE,
|
||||
signal: request.signal,
|
||||
}).then((rawPage) => {
|
||||
rememberRaw(rawPage.items);
|
||||
return rawLibraryPage(rawPage);
|
||||
}),
|
||||
Promise.resolve({
|
||||
status: "ready" as const,
|
||||
value: null,
|
||||
})
|
||||
: getSdkMemoryGraph(
|
||||
}),
|
||||
])
|
||||
: await Promise.all([
|
||||
getSdkMemoryLibrary(
|
||||
{
|
||||
statuses: ["active"],
|
||||
source,
|
||||
offset: 0,
|
||||
limit: 300,
|
||||
query,
|
||||
offset: sdkLibraryOffset,
|
||||
limit: SDK_MEMORY_PAGE_SIZE,
|
||||
windowId: query ? sdkSearchWindow.current.id : null,
|
||||
},
|
||||
request.signal,
|
||||
).then(
|
||||
(value) => ({ status: "ready" as const, value }),
|
||||
() => ({ status: "failed" as const, value: null }),
|
||||
),
|
||||
]);
|
||||
).then(sdkLibraryPage),
|
||||
query
|
||||
? Promise.resolve({
|
||||
status: "ready" as const,
|
||||
value: null,
|
||||
})
|
||||
: getSdkMemoryGraph(
|
||||
{
|
||||
source,
|
||||
offset: 0,
|
||||
limit: 300,
|
||||
},
|
||||
request.signal,
|
||||
).then(
|
||||
(value) => ({ status: "ready" as const, value }),
|
||||
() => ({ status: "failed" as const, value: null }),
|
||||
),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
if (sdkSearchWindow.current.key === windowKey)
|
||||
if (!lite && sdkSearchWindow.current.key === windowKey)
|
||||
sdkSearchWindow.current = {
|
||||
key: windowKey,
|
||||
id: page.window_id ?? null,
|
||||
@@ -276,11 +329,12 @@ export function MemorySpace({
|
||||
// The window expired: the server searched again and answered page 1.
|
||||
if (page.window_reset && sdkLibraryOffset !== 0) setSdkLibraryOffset(0);
|
||||
setSdkLibrary({
|
||||
page: sdkLibraryPage(page),
|
||||
page,
|
||||
loading: false,
|
||||
failed: false,
|
||||
settledSource: sdkLibrarySource,
|
||||
settledQuery: sdkLibraryQuery,
|
||||
settledMode: pipelineMode,
|
||||
});
|
||||
setSdkGraph({ scope: windowKey, ...graph });
|
||||
} catch {
|
||||
@@ -305,6 +359,8 @@ export function MemorySpace({
|
||||
sdkLibraryOffset,
|
||||
sdkLibraryQuery,
|
||||
sdkLibrarySource,
|
||||
lite,
|
||||
pipelineMode,
|
||||
]);
|
||||
const sdkFilterLabel = [
|
||||
sdkLibrary.settledSource
|
||||
@@ -337,9 +393,6 @@ export function MemorySpace({
|
||||
}
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [processing, setProcessing] = useState<MemoryProcessingSummary | null>(
|
||||
null,
|
||||
);
|
||||
const [pendingAdd, setPendingAdd] = useState<PendingMemoryAdd | null>(null);
|
||||
const [checkingSetup, setCheckingSetup] = useState(false);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Source authority changes invalidate an in-flight setup snapshot.
|
||||
@@ -408,7 +461,11 @@ export function MemorySpace({
|
||||
const update = async () => {
|
||||
try {
|
||||
const next = await getMemoryProcessing();
|
||||
if (!stopped) setProcessing(next);
|
||||
if (!stopped) {
|
||||
setProcessing(next);
|
||||
setProcessingFailed(false);
|
||||
setProcessingResolved(true);
|
||||
}
|
||||
} catch {
|
||||
// Background read failures do not invalidate the last verified state.
|
||||
}
|
||||
@@ -438,8 +495,29 @@ export function MemorySpace({
|
||||
const closeDetail = useCallback(() => {
|
||||
detailEpoch.current++;
|
||||
setDetail(null);
|
||||
setSelectedRaw(null);
|
||||
setDetailLoad(null);
|
||||
}, []);
|
||||
const previousPipelineMode = useRef(pipelineMode);
|
||||
useEffect(() => {
|
||||
if (pipelineMode === null || previousPipelineMode.current === pipelineMode)
|
||||
return;
|
||||
previousPipelineMode.current = pipelineMode;
|
||||
rawRecords.current.clear();
|
||||
sdkSearchWindow.current = { key: "", id: null };
|
||||
setSdkLibraryOffset(0);
|
||||
setSdkLibraryQuery("");
|
||||
setSdkGraph(null);
|
||||
setSdkLibrary((current) => ({
|
||||
...current,
|
||||
page: null,
|
||||
loading: true,
|
||||
failed: false,
|
||||
settledQuery: "",
|
||||
settledMode: null,
|
||||
}));
|
||||
closeDetail();
|
||||
}, [pipelineMode, closeDetail]);
|
||||
|
||||
const previousSourceKey = useRef(sourceKey);
|
||||
useEffect(() => {
|
||||
@@ -460,14 +538,23 @@ export function MemorySpace({
|
||||
* this subscriber and the background arrival poll read the same endpoint,
|
||||
* so one `fetch` per tick is the whole point of sharing the key. */
|
||||
const consumeProcessing = (value: unknown) => {
|
||||
if (!cancelled) setProcessing(value as MemoryProcessingSummary);
|
||||
if (!cancelled) {
|
||||
setProcessing(value as MemoryProcessingSummary);
|
||||
setProcessingFailed(false);
|
||||
setProcessingResolved(true);
|
||||
}
|
||||
};
|
||||
// `subscribePoll` only schedules ticks; the first read still has to happen
|
||||
// on mount. Do it once through the shared fetch so mount does not double-read
|
||||
// with the background arrival subscriber.
|
||||
void getMemoryProcessing()
|
||||
.then(consumeProcessing)
|
||||
.catch(() => {});
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setProcessingFailed(true);
|
||||
setProcessingResolved(true);
|
||||
}
|
||||
});
|
||||
// The processing poll shares one read with the background arrival poll on
|
||||
// the same key (3s wins over 12s).
|
||||
const stopProcessing = subscribePoll({
|
||||
@@ -593,6 +680,22 @@ export function MemorySpace({
|
||||
const epoch = ++detailEpoch.current;
|
||||
setError(null);
|
||||
setDetail(null);
|
||||
setSelectedRaw(null);
|
||||
if (lite) {
|
||||
const record = rawRecords.current.get(memoryId);
|
||||
if (!record) {
|
||||
setDetailLoad({
|
||||
memoryId,
|
||||
status: "failed",
|
||||
error: t("sdk.raw.loadFailed"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSelectedRaw(record);
|
||||
setDetail(rawDetail(record));
|
||||
setDetailLoad(null);
|
||||
return;
|
||||
}
|
||||
setDetailLoad({ memoryId, status: "loading" });
|
||||
try {
|
||||
// The inspector reads the memory through v2 and renders it in place.
|
||||
@@ -733,40 +836,55 @@ export function MemorySpace({
|
||||
</h1>
|
||||
<span className="memory-loaded-count">
|
||||
{simplified && !management
|
||||
? sdkLibrary.page
|
||||
? tn(
|
||||
"memory:已沉淀 {count} 条{filter} · 当前展示 {shown} 条",
|
||||
sdkLibrary.page.total,
|
||||
{
|
||||
filter: sdkFilterLabel
|
||||
? t("memory:({filter})", { filter: sdkFilterLabel })
|
||||
: "",
|
||||
shown: new Intl.NumberFormat(locale).format(
|
||||
sdkLibrary.page.items.length,
|
||||
),
|
||||
},
|
||||
)
|
||||
: // The list below says a failed read once, with its retry.
|
||||
sdkLibrary.failed
|
||||
? null
|
||||
: t("memory:正在读取…")
|
||||
? pipelineMode === null
|
||||
? t("memory:记忆暂时无法读取")
|
||||
: lite
|
||||
? sdkPage
|
||||
? `${t("memory:原始对话")} · ${tn(
|
||||
"sdk.raw.found",
|
||||
sdkPage.total,
|
||||
)}`
|
||||
: sdkLibrary.failed
|
||||
? null
|
||||
: t("memory:正在读取…")
|
||||
: sdkPage
|
||||
? tn(
|
||||
"memory:已沉淀 {count} 条{filter} · 当前展示 {shown} 条",
|
||||
sdkPage.total,
|
||||
{
|
||||
filter: sdkFilterLabel
|
||||
? t("memory:({filter})", {
|
||||
filter: sdkFilterLabel,
|
||||
})
|
||||
: "",
|
||||
shown: new Intl.NumberFormat(locale).format(
|
||||
sdkPage.items.length,
|
||||
),
|
||||
},
|
||||
)
|
||||
: // The list below says a failed read once, with its retry.
|
||||
sdkLibrary.failed
|
||||
? null
|
||||
: t("memory:正在读取…")
|
||||
: null}
|
||||
</span>
|
||||
</div>
|
||||
<MemoryAddComposer
|
||||
suspended={!active}
|
||||
onPendingChange={setPendingAdd}
|
||||
onSaved={() => {
|
||||
// A finished addition is a new memory: re-read the page now rather
|
||||
// than leave it for the next poll.
|
||||
bumpSdkMap();
|
||||
}}
|
||||
onOpenMemory={(id) => {
|
||||
if (!simplified) setMode("library");
|
||||
void open(id);
|
||||
}}
|
||||
onConfigureModel={onConfigureModel}
|
||||
/>
|
||||
{!lite ? (
|
||||
<MemoryAddComposer
|
||||
suspended={!active}
|
||||
onPendingChange={setPendingAdd}
|
||||
onSaved={() => {
|
||||
// A finished addition is a new memory: re-read the page now rather
|
||||
// than leave it for the next poll.
|
||||
bumpSdkMap();
|
||||
}}
|
||||
onOpenMemory={(id) => {
|
||||
if (!simplified) setMode("library");
|
||||
void open(id);
|
||||
}}
|
||||
onConfigureModel={onConfigureModel}
|
||||
/>
|
||||
) : null}
|
||||
{!simplified && (
|
||||
<div
|
||||
className="memory-mode-tabs"
|
||||
@@ -834,6 +952,19 @@ export function MemorySpace({
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
{simplified && pipelineMode ? (
|
||||
<p className="memory-mode-notice" role="status">
|
||||
{lite
|
||||
? t(
|
||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。",
|
||||
)
|
||||
: pipelineMode === "ultra"
|
||||
? t(
|
||||
"memory:当前使用深度记忆。新对话会生成高级记忆,并由知识图谱和 evolve 继续整合。",
|
||||
)
|
||||
: t("memory:当前使用智能记忆。新对话会由模型整理为高级记忆。")}
|
||||
</p>
|
||||
) : null}
|
||||
{processing && (!simplified || managementOpen) ? (
|
||||
<details className="memory-processing-details">
|
||||
<summary>
|
||||
@@ -920,16 +1051,20 @@ export function MemorySpace({
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
<ModelBackoffBanner
|
||||
processing={processing}
|
||||
onConfigureModel={onConfigureModel}
|
||||
/>
|
||||
<ModelPrompt
|
||||
processing={processing}
|
||||
hasMemories={(sdkLibrary.page?.total ?? 0) > 0}
|
||||
onConfigureModel={onConfigureModel}
|
||||
/>
|
||||
{simplified && pendingAdd ? (
|
||||
{!lite ? (
|
||||
<>
|
||||
<ModelBackoffBanner
|
||||
processing={processing}
|
||||
onConfigureModel={onConfigureModel}
|
||||
/>
|
||||
<ModelPrompt
|
||||
processing={processing}
|
||||
hasMemories={(sdkPage?.total ?? 0) > 0}
|
||||
onConfigureModel={onConfigureModel}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{simplified && !lite && pendingAdd ? (
|
||||
<p className="memory-pending-add" role="status">
|
||||
<span>
|
||||
{t("memory:正在等待整理:")}
|
||||
@@ -954,7 +1089,7 @@ export function MemorySpace({
|
||||
id="memory-mode-panel-history"
|
||||
hidden={displayedMode !== "history"}
|
||||
>
|
||||
<SdkRawHistory />
|
||||
<SdkRawHistory active={active && displayedMode === "history"} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -995,14 +1130,17 @@ export function MemorySpace({
|
||||
|
||||
{/* Native hiding retains the WebGL owner; its visibility observer pauses drawing. */}
|
||||
<MemoryMap
|
||||
key={simplified ? (pipelineMode ?? "unknown") : "full"}
|
||||
id="memory-mode-panel-map"
|
||||
simplified={simplified}
|
||||
contentKind={lite ? "raw" : "memory"}
|
||||
rawRecord={selectedRaw}
|
||||
libraryView={
|
||||
simplified
|
||||
? {
|
||||
query: sdkLibraryQuery,
|
||||
source: sdkLibrarySource,
|
||||
page: sdkLibrary.page,
|
||||
page: sdkPage,
|
||||
loading: sdkLibrary.loading,
|
||||
failed: sdkLibrary.failed,
|
||||
graphLoading: !sdkGraphSettled,
|
||||
@@ -1020,14 +1158,18 @@ export function MemorySpace({
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
hidden={!active || displayedMode !== "map"}
|
||||
memories={sdkLibrary.page?.items ?? []}
|
||||
hidden={
|
||||
!active ||
|
||||
displayedMode !== "map" ||
|
||||
(simplified && pipelineMode === null)
|
||||
}
|
||||
memories={sdkPage?.items ?? []}
|
||||
graph={visibleSdkGraph}
|
||||
sources={sources}
|
||||
processing={processing}
|
||||
arrival={memoryArrival}
|
||||
onViewHistory={simplified ? onConnect : () => setMode("history")}
|
||||
loaded={sdkLibrary.page !== null}
|
||||
loaded={sdkPage !== null}
|
||||
detail={detail}
|
||||
detailLoad={detailLoad}
|
||||
overlayBlocked={
|
||||
@@ -1035,7 +1177,7 @@ export function MemorySpace({
|
||||
deleteCount > 0 ||
|
||||
// Only the paged map waits for its page; the three-column surface
|
||||
// never reads one.
|
||||
(simplified && sdkLibrary.page === null)
|
||||
(simplified && sdkPage === null)
|
||||
}
|
||||
draft={draft}
|
||||
busy={busy}
|
||||
@@ -1128,10 +1270,12 @@ export function MemorySpace({
|
||||
className="memory-library-layout"
|
||||
hidden={displayedMode !== "library"}
|
||||
>
|
||||
<SdkMemoryLibrary
|
||||
active={active}
|
||||
focusedMemoryId={sdkFocusedMemoryId}
|
||||
/>
|
||||
{!simplified || (pipelineMode !== null && !lite) ? (
|
||||
<SdkMemoryLibrary
|
||||
active={active}
|
||||
focusedMemoryId={sdkFocusedMemoryId}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,15 @@ import { completeOnboarding } from "./api";
|
||||
import { OnboardingFlow } from "./OnboardingFlow";
|
||||
import type { ImportSourcesController } from "./use-import-sources";
|
||||
|
||||
vi.mock("./api", () => ({ completeOnboarding: vi.fn() }));
|
||||
vi.mock("./api", () => ({
|
||||
completeOnboarding: vi.fn(),
|
||||
getMemoryMode: vi.fn().mockResolvedValue({
|
||||
mode: "pro",
|
||||
configured_mode: "pro",
|
||||
explicitly_configured: false,
|
||||
}),
|
||||
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
|
||||
}));
|
||||
vi.mock("./FirstImportReview", () => ({
|
||||
defaultHostSourceIds: () => [],
|
||||
FirstImportReview: ({
|
||||
@@ -121,10 +129,13 @@ it("moves from import to the actual third step without completing onboarding", a
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "进入第三步" }));
|
||||
await userEvent.click(screen.getByRole("radio", { name: /pro/i }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "继续" }));
|
||||
expect(screen.getByRole("heading", { name: "第三步模型页面" })).toBeTruthy();
|
||||
expect(completeOnboarding).not.toHaveBeenCalled();
|
||||
expect(finish).not.toHaveBeenCalled();
|
||||
await userEvent.click(screen.getByRole("button", { name: "返回第二步" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "返回导入" }));
|
||||
expect(screen.getByText("Import accepted")).toBeTruthy();
|
||||
expect(completeOnboarding).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -147,6 +158,8 @@ it("lets an empty first-time user enter without granting or importing a source",
|
||||
const skip = await screen.findByRole("button", { name: "稍后添加,先进入" });
|
||||
await waitFor(() => expect((skip as HTMLButtonElement).disabled).toBe(false));
|
||||
await userEvent.click(skip);
|
||||
await userEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "继续" }));
|
||||
await waitFor(() =>
|
||||
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
|
||||
);
|
||||
@@ -168,6 +181,8 @@ it("retains explicit retry or enter when saving the skipped onboarding fails", a
|
||||
const skip = await screen.findByRole("button", { name: "稍后添加,先进入" });
|
||||
await waitFor(() => expect((skip as HTMLButtonElement).disabled).toBe(false));
|
||||
await userEvent.click(skip);
|
||||
await userEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "继续" }));
|
||||
await screen.findByRole("button", { name: "重试保存并进入" });
|
||||
expect(finish).not.toHaveBeenCalled();
|
||||
await userEvent.click(screen.getByRole("button", { name: "仍进入应用页" }));
|
||||
|
||||
@@ -25,6 +25,12 @@ import type { ImportSourcesController } from "./use-import-sources";
|
||||
vi.mock("./api", () => ({
|
||||
completeOnboarding: vi.fn(),
|
||||
getModelProfiles: vi.fn(),
|
||||
getMemoryMode: vi.fn().mockResolvedValue({
|
||||
mode: "pro",
|
||||
configured_mode: "pro",
|
||||
explicitly_configured: false,
|
||||
}),
|
||||
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
|
||||
}));
|
||||
const models = {
|
||||
pendingProbe: false,
|
||||
@@ -393,6 +399,8 @@ it("translates completion recovery live without repeating completion writes", as
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Entrar y añadir más tarde" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Continuar" }));
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "Reintentar guardar y entrar" }),
|
||||
).toBeTruthy();
|
||||
|
||||
@@ -26,6 +26,12 @@ vi.mock("./api", () => ({
|
||||
getImportJobs: vi.fn(),
|
||||
connectSources: vi.fn(),
|
||||
completeOnboarding: vi.fn(),
|
||||
getMemoryMode: vi.fn().mockResolvedValue({
|
||||
mode: "pro",
|
||||
configured_mode: "pro",
|
||||
explicitly_configured: false,
|
||||
}),
|
||||
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
|
||||
getSourceConnection: vi.fn(),
|
||||
createImportJob: vi.fn(),
|
||||
detectSource: vi.fn(),
|
||||
@@ -161,6 +167,34 @@ beforeEach(() => {
|
||||
...state,
|
||||
status: "completed",
|
||||
});
|
||||
vi.mocked(api.getMemoryMode).mockResolvedValue({
|
||||
mode: "pro",
|
||||
configured_mode: "pro",
|
||||
explicitly_configured: false,
|
||||
forced_by_env: false,
|
||||
modes: ["lite", "pro", "ultra"],
|
||||
graph_build: {
|
||||
state: "idle",
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
users: {},
|
||||
},
|
||||
});
|
||||
vi.mocked(api.setMemoryMode).mockResolvedValue({
|
||||
mode: "pro",
|
||||
configured_mode: "pro",
|
||||
explicitly_configured: true,
|
||||
forced_by_env: false,
|
||||
modes: ["lite", "pro", "ultra"],
|
||||
graph_build: {
|
||||
state: "idle",
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
users: {},
|
||||
},
|
||||
previous_mode: "pro",
|
||||
effects: [],
|
||||
});
|
||||
vi.mocked(api.connectSources).mockImplementation(
|
||||
async (ids, _confirmed, options) => ({
|
||||
request_id: options?.requestId ?? null,
|
||||
@@ -267,6 +301,8 @@ it("empty discovery shows zero and allows explicit entry without a connection re
|
||||
true,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "稍后添加,先进入" }));
|
||||
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "继续" }));
|
||||
await waitFor(() =>
|
||||
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
|
||||
);
|
||||
@@ -317,6 +353,8 @@ it("partial acceptance preserves the successful job and failure through model-st
|
||||
expect(screen.getByLabelText("失败来源").textContent).toBe("cursor");
|
||||
expect(api.completeOnboarding).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "进入应用" }));
|
||||
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "继续" }));
|
||||
await waitFor(() =>
|
||||
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
|
||||
);
|
||||
|
||||
@@ -13,7 +13,15 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { OnboardingFlow } from "./OnboardingFlow";
|
||||
import type { ImportSourcesController } from "./use-import-sources";
|
||||
|
||||
vi.mock("./api", () => ({ completeOnboarding: vi.fn() }));
|
||||
vi.mock("./api", () => ({
|
||||
completeOnboarding: vi.fn(),
|
||||
getMemoryMode: vi.fn().mockResolvedValue({
|
||||
mode: "pro",
|
||||
configured_mode: "pro",
|
||||
explicitly_configured: false,
|
||||
}),
|
||||
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
|
||||
}));
|
||||
vi.mock("./FirstImportReview", () => ({
|
||||
FirstImportReview: ({ sourceIds }: { sourceIds: string[] }) => (
|
||||
<p>已受理 {sourceIds.join(",")}</p>
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
} from "./use-import-sources";
|
||||
|
||||
const completeOnboarding = vi.fn();
|
||||
const setMemoryMode = vi.fn();
|
||||
const getMemoryMode = vi.fn();
|
||||
const getModelProfiles = vi.fn();
|
||||
const getMemoryProcessing = vi.fn();
|
||||
const previewSourceDirectory = vi.fn();
|
||||
@@ -44,6 +46,8 @@ vi.mock("./api", () => ({
|
||||
getImportJob: vi.fn(),
|
||||
apiErrorCode: vi.fn(() => null),
|
||||
completeOnboarding: (...args: unknown[]) => completeOnboarding(...args),
|
||||
setMemoryMode: (...args: unknown[]) => setMemoryMode(...args),
|
||||
getMemoryMode: (...args: unknown[]) => getMemoryMode(...args),
|
||||
getModelProfiles: (...args: unknown[]) => getModelProfiles(...args),
|
||||
getMemoryProcessing: (...args: unknown[]) => getMemoryProcessing(...args),
|
||||
previewSourceDirectory: (...args: unknown[]) =>
|
||||
@@ -176,9 +180,18 @@ async function skipWelcome(user?: ReturnType<typeof userEvent.setup>) {
|
||||
await openApps(user);
|
||||
}
|
||||
async function continueImport(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByRole("button", { name: "配置模型,沉淀记忆" }));
|
||||
await openModelStep(user);
|
||||
await user.click(screen.getByRole("button", { name: "模型启用回执已确认" }));
|
||||
}
|
||||
async function openModelStep(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByRole("button", { name: "配置模型,沉淀记忆" }));
|
||||
await user.click(screen.getByRole("radio", { name: /pro/i }));
|
||||
await user.click(screen.getByRole("button", { name: "继续" }));
|
||||
}
|
||||
async function chooseLiteAndContinue(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
await user.click(screen.getByRole("button", { name: "继续" }));
|
||||
}
|
||||
function choice(name = "Claude Code") {
|
||||
return screen.getByRole<HTMLInputElement>("checkbox", {
|
||||
name: `导入 ${name} 历史`,
|
||||
@@ -218,6 +231,12 @@ describe("OnboardingFlow S1.3", () => {
|
||||
...onboarding,
|
||||
status: "completed",
|
||||
});
|
||||
setMemoryMode.mockResolvedValue({ mode: "pro" });
|
||||
getMemoryMode.mockResolvedValue({
|
||||
mode: "pro",
|
||||
configured_mode: "pro",
|
||||
explicitly_configured: false,
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -355,7 +374,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
name: "配置模型,沉淀记忆",
|
||||
});
|
||||
expect(continueButton).toHaveProperty("disabled", false);
|
||||
await user.click(continueButton);
|
||||
await openModelStep(user);
|
||||
const modelBoundary = screen.getByRole("region", {
|
||||
name: "模型步骤边界",
|
||||
});
|
||||
@@ -365,6 +384,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
modelBoundary,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "返回导入" }));
|
||||
await user.click(screen.getByRole("button", { name: "返回导入" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("alert").textContent).toContain(
|
||||
"开始导入的结果尚未确认",
|
||||
@@ -388,9 +408,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
onFinish={finish}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
|
||||
);
|
||||
await openModelStep(userEvent.setup());
|
||||
expect(screen.getByRole("region", { name: "模型步骤边界" })).toBeTruthy();
|
||||
expect(completeOnboarding).not.toHaveBeenCalled();
|
||||
expect(finish).not.toHaveBeenCalled();
|
||||
@@ -649,9 +667,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
await approve(user);
|
||||
await user.click(screen.getByRole("button", { name: START }));
|
||||
expect(onFinish).not.toHaveBeenCalled();
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
|
||||
);
|
||||
await openModelStep(user);
|
||||
expect(onFinish).not.toHaveBeenCalled();
|
||||
expect(completeOnboarding).not.toHaveBeenCalled();
|
||||
await user.click(
|
||||
@@ -678,13 +694,89 @@ describe("OnboardingFlow S1.3", () => {
|
||||
);
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await user.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
await user.click(screen.getByRole("button", { name: "继续" }));
|
||||
expect(onFinish).toHaveBeenCalledWith("sources");
|
||||
expect(completeOnboarding).toHaveBeenCalledOnce();
|
||||
expect(controller.refresh).toHaveBeenCalledOnce();
|
||||
expect(controller.connectForOnboarding).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows entering the app from step three without creating a model", async () => {
|
||||
it("does not let a late saved-mode read overwrite the user's new choice", async () => {
|
||||
let resolveMode!: (value: {
|
||||
mode: "pro";
|
||||
configured_mode: "pro";
|
||||
explicitly_configured: true;
|
||||
}) => void;
|
||||
getMemoryMode.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveMode = resolve;
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<OnboardingFlow
|
||||
state={onboarding}
|
||||
imports={imports({ sources: [] })}
|
||||
onFinish={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
const lite = screen.getByRole<HTMLInputElement>("radio", { name: /lite/i });
|
||||
await user.click(lite);
|
||||
await act(async () => {
|
||||
resolveMode({
|
||||
mode: "pro",
|
||||
configured_mode: "pro",
|
||||
explicitly_configured: true,
|
||||
});
|
||||
});
|
||||
|
||||
expect(lite.checked).toBe(true);
|
||||
await user.click(screen.getByRole("button", { name: "继续" }));
|
||||
expect(setMemoryMode).toHaveBeenLastCalledWith("lite");
|
||||
});
|
||||
|
||||
it("retains the explicit lite choice when saving the mode fails and retries", async () => {
|
||||
const user = userEvent.setup();
|
||||
const controller = imports({ sources: [] });
|
||||
const onFinish = vi.fn();
|
||||
setMemoryMode.mockRejectedValueOnce(new Error("offline"));
|
||||
render(
|
||||
<OnboardingFlow
|
||||
state={onboarding}
|
||||
imports={controller}
|
||||
onFinish={onFinish}
|
||||
/>,
|
||||
);
|
||||
|
||||
await openApps();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
const lite = screen.getByRole<HTMLInputElement>("radio", { name: /lite/i });
|
||||
expect(screen.getByRole("button", { name: "继续" })).toHaveProperty(
|
||||
"disabled",
|
||||
true,
|
||||
);
|
||||
await user.click(lite);
|
||||
await user.click(screen.getByRole("button", { name: "继续" }));
|
||||
|
||||
expect((await screen.findByRole("alert")).textContent).toContain(
|
||||
"模式未能保存。你的选择已保留,请重试。",
|
||||
);
|
||||
expect(lite.checked).toBe(true);
|
||||
expect(completeOnboarding).not.toHaveBeenCalled();
|
||||
expect(onFinish).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "继续" }));
|
||||
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledTimes(2));
|
||||
expect(setMemoryMode).toHaveBeenLastCalledWith("lite");
|
||||
expect(completeOnboarding).toHaveBeenCalledOnce();
|
||||
expect(onFinish).toHaveBeenCalledWith("sources");
|
||||
});
|
||||
|
||||
it("returns from skipped pro setup to an explicit mode choice", async () => {
|
||||
const user = userEvent.setup();
|
||||
const controller = imports();
|
||||
const onFinish = vi.fn();
|
||||
@@ -698,10 +790,13 @@ describe("OnboardingFlow S1.3", () => {
|
||||
await choose(user);
|
||||
await approve(user);
|
||||
await user.click(screen.getByRole("button", { name: START }));
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
|
||||
);
|
||||
await openModelStep(user);
|
||||
await user.click(screen.getByRole("button", { name: "跳过模型进入应用" }));
|
||||
expect(screen.getByRole("heading", { name: "选择记忆方式" })).toBeTruthy();
|
||||
expect(completeOnboarding).not.toHaveBeenCalled();
|
||||
expect(onFinish).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("radio", { name: /lite/i }));
|
||||
await user.click(screen.getByRole("button", { name: "继续" }));
|
||||
expect(completeOnboarding).toHaveBeenCalledOnce();
|
||||
expect(onFinish).toHaveBeenCalledWith("sources");
|
||||
expect(getModelProfiles).not.toHaveBeenCalled();
|
||||
@@ -721,6 +816,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
);
|
||||
await choose(user);
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await chooseLiteAndContinue(user);
|
||||
expect(onFinish).toHaveBeenCalledWith("sources");
|
||||
for (const fn of [
|
||||
controller.connectForOnboarding,
|
||||
@@ -775,6 +871,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
expect(choice()).toHaveProperty("disabled", false);
|
||||
await waitFor(() => expect(choice()).toHaveProperty("checked", true));
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await chooseLiteAndContinue(user);
|
||||
expect(onFinish).toHaveBeenCalledWith("sources");
|
||||
expect(controller.connectForOnboarding).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -798,6 +895,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
expect(controller.connectForOnboarding).not.toHaveBeenCalled();
|
||||
expect(onFinish).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await chooseLiteAndContinue(user);
|
||||
expect(onFinish).toHaveBeenCalledWith("sources");
|
||||
});
|
||||
|
||||
@@ -1868,6 +1966,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
await user.click(screen.getByRole("button", { name: START }));
|
||||
expect(onFinish).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: "查看未导入的应用" }));
|
||||
await chooseLiteAndContinue(user);
|
||||
expect(onFinish).toHaveBeenCalledWith("sources");
|
||||
});
|
||||
|
||||
@@ -2042,6 +2141,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: SKIP }));
|
||||
await chooseLiteAndContinue(user);
|
||||
await user.click(screen.getByRole("button", { name: "仍进入应用页" }));
|
||||
expect(onFinish).toHaveBeenCalledWith("sources");
|
||||
expect(controller.connectForOnboarding).not.toHaveBeenCalled();
|
||||
@@ -2143,9 +2243,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
expect(readConnectionPreferences().hostSourceIds).toContain("claude");
|
||||
expect(controller.setSync).not.toHaveBeenCalled();
|
||||
expect(previewRecallInstall).not.toHaveBeenCalled();
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
|
||||
);
|
||||
await openModelStep(user);
|
||||
expect(screen.getByRole("region", { name: "模型步骤边界" })).toBeTruthy();
|
||||
expect(controller.setSync).not.toHaveBeenCalled();
|
||||
expect(previewRecallInstall).toHaveBeenCalledWith("claude-code");
|
||||
@@ -2192,9 +2290,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
await user.click(insertion);
|
||||
expect(insertion.checked).toBe(false);
|
||||
expect(readConnectionPreferences().hostSourceIds).toEqual([]);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
|
||||
);
|
||||
await openModelStep(user);
|
||||
expect(controller.setSync).not.toHaveBeenCalled();
|
||||
expect(previewRecallInstall).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -2355,9 +2451,7 @@ describe("OnboardingFlow S1.3", () => {
|
||||
await waitFor(() => expect(insertion.disabled).toBe(false));
|
||||
expect(previewRecallInstall).not.toHaveBeenCalled();
|
||||
expect(insertion.checked).toBe(true);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
|
||||
);
|
||||
await openModelStep(user);
|
||||
expect(screen.getByRole("region", { name: "模型步骤边界" })).toBeTruthy();
|
||||
expect(previewRecallInstall).toHaveBeenCalledWith("claude-code");
|
||||
expect(window.oneMemory?.installRecall).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { completeOnboarding } from "./api";
|
||||
import { completeOnboarding, getMemoryMode, setMemoryMode } from "./api";
|
||||
import { AppMark } from "./app-mark";
|
||||
import { saveConnectionPreferences } from "./connection-preferences";
|
||||
import {
|
||||
@@ -38,6 +38,7 @@ import { isDefiniteRefusal } from "./write-outcome";
|
||||
import "./cold-start.css";
|
||||
|
||||
export type OnboardingDestination = "memory" | "sources" | "settings";
|
||||
type OnboardingMemoryMode = "lite" | "pro";
|
||||
|
||||
// Cold start neither reads nor writes installation intent.
|
||||
export const PLUGIN_CONSENT_KEY = "one-memory.consent.plugins";
|
||||
@@ -88,7 +89,18 @@ export function OnboardingFlow({
|
||||
const [startingImport, setStartingImport] = useState<SourceResponse[] | null>(
|
||||
null,
|
||||
);
|
||||
const [modelStep, setModelStep] = useState(false);
|
||||
const [setupStep, setSetupStep] = useState<"review" | "mode" | "model">(
|
||||
"review",
|
||||
);
|
||||
const [modeChoice, setModeChoice] = useState<OnboardingMemoryMode | null>(
|
||||
null,
|
||||
);
|
||||
const [modeDestination, setModeDestination] =
|
||||
useState<OnboardingDestination>("sources");
|
||||
const [modeSaving, setModeSaving] = useState(false);
|
||||
const [modeError, setModeError] = useState<string | null>(null);
|
||||
const modeChoiceTouched = useRef(false);
|
||||
const [visitedSetup, setVisitedSetup] = useState(false);
|
||||
const [visitedModel, setVisitedModel] = useState(false);
|
||||
const [modelSourceIds, setModelSourceIds] = useState<string[]>([]);
|
||||
const [connectionDraft, setConnectionDraft] = useState<ImportConnectionDraft>(
|
||||
@@ -135,7 +147,7 @@ export function OnboardingFlow({
|
||||
const consideredSources = useRef(new Set<string>());
|
||||
const heading = useRef<HTMLHeadingElement>(null);
|
||||
const reviewingImport =
|
||||
visitedModel ||
|
||||
(visitedSetup && !completionFailed) ||
|
||||
accepted !== null ||
|
||||
startingImport !== null ||
|
||||
state.status === "importing";
|
||||
@@ -307,6 +319,20 @@ export function OnboardingFlow({
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
void getMemoryMode().then(
|
||||
(memoryMode) => {
|
||||
if (
|
||||
alive.current &&
|
||||
!modeChoiceTouched.current &&
|
||||
memoryMode.explicitly_configured &&
|
||||
memoryMode.configured_mode !== "ultra"
|
||||
)
|
||||
setModeChoice(memoryMode.configured_mode);
|
||||
},
|
||||
() => {
|
||||
// The choice screen remains usable; saving rechecks Core.
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
alive.current = false;
|
||||
};
|
||||
@@ -421,7 +447,7 @@ export function OnboardingFlow({
|
||||
});
|
||||
if (alive.current) {
|
||||
setCompletionFailed(true);
|
||||
setModelStep(false);
|
||||
setSetupStep("review");
|
||||
// A definite refusal means it was not saved; anything else (timeout,
|
||||
// 5xx, no response) leaves the outcome unknown, and the copy must not
|
||||
// assert a failure the UI cannot prove.
|
||||
@@ -450,6 +476,44 @@ export function OnboardingFlow({
|
||||
[persistFinish],
|
||||
);
|
||||
|
||||
const requestModeChoice = useCallback(
|
||||
(destination: OnboardingDestination) => {
|
||||
if (finished.current) return;
|
||||
setModeDestination(destination === "settings" ? "sources" : destination);
|
||||
setModeError(null);
|
||||
setError(null);
|
||||
setVisitedSetup(true);
|
||||
setSetupStep("mode");
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const saveModeChoice = useCallback(async () => {
|
||||
if (
|
||||
!modeChoice ||
|
||||
modeSaving ||
|
||||
finished.current ||
|
||||
(modeChoice === "lite" && flight.current)
|
||||
)
|
||||
return;
|
||||
setModeSaving(true);
|
||||
setModeError(null);
|
||||
try {
|
||||
await setMemoryMode(modeChoice);
|
||||
if (!alive.current) return;
|
||||
if (modeChoice === "lite") {
|
||||
await leave(modeDestination);
|
||||
return;
|
||||
}
|
||||
setVisitedModel(true);
|
||||
setSetupStep("model");
|
||||
} catch {
|
||||
if (alive.current) setModeError("模式未能保存。你的选择已保留,请重试。");
|
||||
} finally {
|
||||
if (alive.current) setModeSaving(false);
|
||||
}
|
||||
}, [leave, modeChoice, modeDestination, modeSaving]);
|
||||
|
||||
async function checkLocations() {
|
||||
if (flight.current || locked) return;
|
||||
flight.current = true;
|
||||
@@ -636,7 +700,7 @@ export function OnboardingFlow({
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
hidden={modelStep}
|
||||
hidden={setupStep !== "review"}
|
||||
data-import-submission={startingImport ? "pending" : "settled"}
|
||||
>
|
||||
<FirstImportReview
|
||||
@@ -663,6 +727,10 @@ export function OnboardingFlow({
|
||||
error={error && text(error)}
|
||||
onContinue={(destination) => {
|
||||
if (busy === "leaving") return;
|
||||
if (completionFailed) {
|
||||
void leave(destination);
|
||||
return;
|
||||
}
|
||||
const selection = {
|
||||
hostSourceIds: hostConnectionAvailable
|
||||
? [...activeDraft.hostSourceIds]
|
||||
@@ -676,9 +744,8 @@ export function OnboardingFlow({
|
||||
setApplyTick((value) => value + 1);
|
||||
if (destination === "settings") {
|
||||
setModelSourceIds([...sourceIds]);
|
||||
setVisitedModel(true);
|
||||
setModelStep(true);
|
||||
} else void leave(destination);
|
||||
}
|
||||
requestModeChoice(destination);
|
||||
}}
|
||||
recovery={
|
||||
completionFailed && exitDestination
|
||||
@@ -690,8 +757,116 @@ export function OnboardingFlow({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{setupStep === "mode" && (
|
||||
<R6ColdStartFrame
|
||||
step={3}
|
||||
title={text("选择记忆方式")}
|
||||
eyebrow={text("最后一步 · 决定如何处理对话")}
|
||||
footer={
|
||||
<>
|
||||
<R6Button
|
||||
variant="quiet"
|
||||
disabled={modeSaving || busy === "leaving"}
|
||||
onClick={() => {
|
||||
setModeError(null);
|
||||
setSetupStep("review");
|
||||
}}
|
||||
>
|
||||
{text("返回导入")}
|
||||
</R6Button>
|
||||
<R6Button
|
||||
className="onboarding-r6-submit"
|
||||
variant="primary"
|
||||
pending={modeSaving}
|
||||
disabled={
|
||||
!modeChoice ||
|
||||
busy === "leaving" ||
|
||||
(modeChoice === "lite" && flight.current)
|
||||
}
|
||||
onClick={() => void saveModeChoice()}
|
||||
>
|
||||
{text(modeSaving ? "正在保存…" : "继续")}
|
||||
</R6Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<section
|
||||
className="onboarding-r6-mode"
|
||||
aria-labelledby="onboarding-memory-mode-title"
|
||||
>
|
||||
<div className="onboarding-r6-mode-intro">
|
||||
<h2 id="onboarding-memory-mode-title">
|
||||
{text("你希望 MemHub 如何形成记忆?")}
|
||||
</h2>
|
||||
<p>{text("需要明确选择一种方式;之后仍可在设置中更改。")}</p>
|
||||
</div>
|
||||
<fieldset
|
||||
className="onboarding-r6-mode-options"
|
||||
disabled={modeSaving || busy === "leaving"}
|
||||
>
|
||||
<legend className="onboarding-r6-sr-only">
|
||||
{text("记忆模式")}
|
||||
</legend>
|
||||
<label
|
||||
className="onboarding-r6-mode-card"
|
||||
data-selected={modeChoice === "pro"}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="onboarding-memory-mode"
|
||||
value="pro"
|
||||
checked={modeChoice === "pro"}
|
||||
onChange={() => {
|
||||
modeChoiceTouched.current = true;
|
||||
setModeChoice("pro");
|
||||
setModeError(null);
|
||||
}}
|
||||
/>
|
||||
<span className="onboarding-r6-mode-copy">
|
||||
<span className="onboarding-r6-mode-heading">
|
||||
<strong>pro</strong>
|
||||
<span>{text("推荐")}</span>
|
||||
</span>
|
||||
<span>
|
||||
{text("使用模型理解对话,提取并整理可持续生长的记忆。")}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
className="onboarding-r6-mode-card"
|
||||
data-selected={modeChoice === "lite"}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="onboarding-memory-mode"
|
||||
value="lite"
|
||||
checked={modeChoice === "lite"}
|
||||
onChange={() => {
|
||||
modeChoiceTouched.current = true;
|
||||
setModeChoice("lite");
|
||||
setModeError(null);
|
||||
}}
|
||||
/>
|
||||
<span className="onboarding-r6-mode-copy">
|
||||
<span className="onboarding-r6-mode-heading">
|
||||
<strong>lite</strong>
|
||||
</span>
|
||||
<span>
|
||||
{text("无需模型,只保存并检索原始对话,不生成提炼记忆。")}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
{(modeError || error) && (
|
||||
<p className="onboarding-r6-mode-error" role="alert">
|
||||
{text(modeError ?? error ?? "")}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</R6ColdStartFrame>
|
||||
)}
|
||||
{visitedModel && (
|
||||
<div hidden={!modelStep}>
|
||||
<div hidden={setupStep !== "model"}>
|
||||
<OnboardingModelController
|
||||
connectionAlreadyApplied={
|
||||
connectionApplication === "applied" ||
|
||||
@@ -709,8 +884,8 @@ export function OnboardingFlow({
|
||||
(total, source) => total + source.imported_conversations,
|
||||
0,
|
||||
)}
|
||||
onBack={() => setModelStep(false)}
|
||||
onSkip={() => void leave("sources")}
|
||||
onBack={() => setSetupStep("mode")}
|
||||
onSkip={() => setSetupStep("mode")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -763,7 +938,7 @@ export function OnboardingFlow({
|
||||
<R6Button
|
||||
variant="quiet"
|
||||
disabled={locked}
|
||||
onClick={() => void leave("sources")}
|
||||
onClick={() => requestModeChoice("sources")}
|
||||
>
|
||||
{text("稍后添加,先进入")}
|
||||
</R6Button>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { OnboardingModelController } from "./OnboardingModelController";
|
||||
|
||||
const { models } = vi.hoisted(() => ({
|
||||
models: {
|
||||
profiles: [
|
||||
{
|
||||
profile_id: "active-profile",
|
||||
revision: 3,
|
||||
name: "Current model",
|
||||
provider_kind: "openai_compatible",
|
||||
model: "model-1",
|
||||
base_url: "https://models.example/v1",
|
||||
disable_thinking: true,
|
||||
embedding_provider_kind: "local_onnx",
|
||||
embedding_model: "bge-small-zh-v1.5",
|
||||
embedding_base_url: null,
|
||||
timeout_seconds: 90,
|
||||
max_concurrency: 4,
|
||||
context_window: null,
|
||||
has_api_key: true,
|
||||
is_active: true,
|
||||
},
|
||||
],
|
||||
pendingProbe: false,
|
||||
probeStorageFailed: false,
|
||||
pendingCreation: false,
|
||||
creationStorageFailed: false,
|
||||
error: null,
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
probeProfile: vi.fn(),
|
||||
activate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./use-model-profiles", () => ({
|
||||
useModelProfiles: () => models,
|
||||
}));
|
||||
vi.mock("./OnboardingModelStep", () => ({
|
||||
OnboardingModelStep: (props: {
|
||||
probe?: { message: string } | null;
|
||||
enableActionLabel?: string;
|
||||
onSaveAndEnable(): Promise<void>;
|
||||
}) => (
|
||||
<section>
|
||||
<p>{props.probe?.message}</p>
|
||||
<button type="button" onClick={() => void props.onSaveAndEnable()}>
|
||||
{props.enableActionLabel}
|
||||
</button>
|
||||
</section>
|
||||
),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("resumes onboarding with an already active model after a restart", async () => {
|
||||
const enabled = vi.fn();
|
||||
render(
|
||||
<OnboardingModelController
|
||||
sourceNames={[]}
|
||||
onBack={vi.fn()}
|
||||
onEnabled={enabled}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText("当前模型已经启用,可以继续完成首次设置。"),
|
||||
).toBeTruthy();
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "继续使用当前模型" }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(enabled).toHaveBeenCalledOnce());
|
||||
expect(models.activate).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
ModelProfile,
|
||||
ModelProfileInput,
|
||||
} from "@one-memory/contracts";
|
||||
import { useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { getModelProfiles } from "./api";
|
||||
import {
|
||||
type OnboardingModelDraft,
|
||||
@@ -83,9 +83,41 @@ export function OnboardingModelController({
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const flight = useRef(false);
|
||||
const draftTouched = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const active = (models.profiles ?? []).find((profile) => profile.is_active);
|
||||
if (!active || saved || draftTouched.current) return;
|
||||
setSaved(active);
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
name: active.name,
|
||||
provider_kind: active.provider_kind,
|
||||
model: active.model,
|
||||
base_url: active.base_url ?? "",
|
||||
disable_thinking: active.disable_thinking,
|
||||
embedding_mode:
|
||||
active.embedding_provider_kind === "local_onnx" ? "local" : "custom",
|
||||
embedding_provider_kind:
|
||||
active.embedding_provider_kind === "ollama"
|
||||
? "ollama"
|
||||
: "openai_compatible",
|
||||
embedding_model: active.embedding_model ?? "",
|
||||
embedding_base_url: active.embedding_base_url ?? "",
|
||||
timeout_seconds: String(active.timeout_seconds),
|
||||
max_concurrency: String(active.max_concurrency),
|
||||
context_window:
|
||||
active.context_window === null ? "" : String(active.context_window),
|
||||
}));
|
||||
setProbe({
|
||||
state: "passed",
|
||||
message: "当前模型已经启用,可以继续完成首次设置。",
|
||||
});
|
||||
}, [models.profiles, saved]);
|
||||
|
||||
function edit(patch: Partial<OnboardingModelDraft>) {
|
||||
if (flight.current) return;
|
||||
draftTouched.current = true;
|
||||
if (patch.provider_id && patch.provider_id !== draft.provider_id) {
|
||||
const preset = PROVIDER_PRESETS.find(
|
||||
(item) => item.id === patch.provider_id,
|
||||
@@ -232,6 +264,10 @@ export function OnboardingModelController({
|
||||
|
||||
async function enable() {
|
||||
if (flight.current || probe?.state !== "passed" || !saved) return;
|
||||
if (saved.is_active) {
|
||||
onEnabled?.();
|
||||
return;
|
||||
}
|
||||
flight.current = true;
|
||||
setPhase("enabling");
|
||||
setError(null);
|
||||
@@ -273,6 +309,7 @@ export function OnboardingModelController({
|
||||
phase={phase}
|
||||
capability={{ saveAndEnable: true }}
|
||||
canSaveAndEnable={probe?.state === "passed" && !!saved}
|
||||
enableActionLabel={saved?.is_active ? "继续使用当前模型" : undefined}
|
||||
canTest={!models.pendingProbe && !models.probeStorageFailed}
|
||||
canSaveDraft={!models.pendingCreation && !models.creationStorageFailed}
|
||||
inputsLocked={flight.current}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useGuiLanguage } from "./gui-language";
|
||||
export interface OptionMenuItem {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
lang?: string;
|
||||
}
|
||||
@@ -265,7 +266,14 @@ export function OptionMenu(props: {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{visibleLabel}
|
||||
{selected?.description ? (
|
||||
<span className="om-option-menu-copy">
|
||||
<span>{visibleLabel}</span>
|
||||
<small>{selected.description}</small>
|
||||
</span>
|
||||
) : (
|
||||
visibleLabel
|
||||
)}
|
||||
</button>
|
||||
{open &&
|
||||
createPortal(
|
||||
@@ -298,7 +306,14 @@ export function OptionMenu(props: {
|
||||
}}
|
||||
onClick={() => choose(index)}
|
||||
>
|
||||
{item.label}
|
||||
{item.description ? (
|
||||
<span className="om-option-menu-copy">
|
||||
<span>{item.label}</span>
|
||||
<small>{item.description}</small>
|
||||
</span>
|
||||
) : (
|
||||
item.label
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
|
||||
@@ -27,7 +27,7 @@ it("changes settings copy live without rereading profiles or changing permission
|
||||
}),
|
||||
).toBeNull();
|
||||
const reads = vi.mocked(getModelProfiles).mock.calls.length;
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
await user.click(screen.getByRole("combobox", { name: "Idioma" }));
|
||||
await user.click(screen.getByRole("option", { name: "简体中文" }));
|
||||
expect(await screen.findByRole("heading", { name: "记忆模型" })).toBeTruthy();
|
||||
expect(vi.mocked(getModelProfiles).mock.calls).toHaveLength(reads);
|
||||
@@ -48,6 +48,36 @@ vi.mock("./api", () => ({
|
||||
imports_unaffected: true,
|
||||
}),
|
||||
setSedimentation: vi.fn(),
|
||||
getMemoryMode: vi.fn().mockResolvedValue({
|
||||
mode: "pro",
|
||||
configured_mode: "pro",
|
||||
explicitly_configured: true,
|
||||
forced_by_env: false,
|
||||
modes: ["lite", "pro", "ultra"],
|
||||
graph_build: {
|
||||
state: "idle",
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
users: {},
|
||||
},
|
||||
}),
|
||||
setMemoryMode: vi.fn(),
|
||||
getEvolveSchedule: vi.fn().mockResolvedValue({
|
||||
enabled: true,
|
||||
time: "03:00",
|
||||
}),
|
||||
setEvolveSchedule: vi.fn(),
|
||||
getEvolveOverview: vi.fn().mockResolvedValue({
|
||||
mode: "pro",
|
||||
schedule: { enabled: true, time: "03:00" },
|
||||
users: {},
|
||||
running: [],
|
||||
}),
|
||||
startEvolve: vi.fn(),
|
||||
getMemoryProcessing: vi.fn().mockResolvedValue({
|
||||
embedding_switch: null,
|
||||
}),
|
||||
apiErrorCode: vi.fn().mockReturnValue(null),
|
||||
}));
|
||||
vi.mock("./ModelSettings", async () => {
|
||||
const { createElement, useImperativeHandle } = await import("react");
|
||||
@@ -97,8 +127,8 @@ it("shows the help and diagnostics group by default and hides it when opted out"
|
||||
} as NonNullable<typeof window.oneMemory>;
|
||||
const user = userEvent.setup();
|
||||
const view = render(<R6Settings onDiagnostics={vi.fn()} />);
|
||||
expect(screen.getByRole("combobox")).toBeTruthy();
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
expect(screen.getByRole("combobox", { name: "语言" })).toBeTruthy();
|
||||
await user.click(screen.getByRole("combobox", { name: "语言" }));
|
||||
expect(screen.getAllByRole("option")).toHaveLength(12);
|
||||
await user.keyboard("{Escape}");
|
||||
await user.click(screen.getByRole("button", { name: "连接其他应用" }));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ModelProfile } from "@one-memory/contracts";
|
||||
import type { MemoryPipelineMode, ModelProfile } from "@one-memory/contracts";
|
||||
import { Download, RefreshCw } from "lucide-react";
|
||||
import {
|
||||
useCallback,
|
||||
@@ -12,6 +12,7 @@ import { getModelProfiles, setSedimentation } from "./api";
|
||||
import { ConnectionFallback } from "./ConnectionFallback";
|
||||
import { exportDiagnosticsFile } from "./diagnostics-export";
|
||||
import { LanguageSelector } from "./LanguageSelector";
|
||||
import { MemoryModeSettings } from "./MemoryModeSettings";
|
||||
import { ModelSettings, type ModelSettingsActions } from "./ModelSettings";
|
||||
import { useOnboardingTranslation } from "./onboarding-language";
|
||||
import { R6Button, R6Switch } from "./R6Controls";
|
||||
@@ -30,7 +31,7 @@ import "./r6-settings.css";
|
||||
type Group = "model" | "connections" | "help";
|
||||
|
||||
const labels: Record<Group, string> = {
|
||||
model: "模型",
|
||||
model: "记忆模式与模型",
|
||||
connections: "连接其他应用",
|
||||
help: "帮助与诊断",
|
||||
};
|
||||
@@ -67,6 +68,9 @@ export function R6Settings({
|
||||
const [configurationRefusal, setConfigurationRefusal] = useState(false);
|
||||
const [modelVersion, setModelVersion] = useState(0);
|
||||
const [configurationOpen, setConfigurationOpen] = useState(false);
|
||||
const [memoryMode, setMemoryModeState] = useState<MemoryPipelineMode | null>(
|
||||
null,
|
||||
);
|
||||
const [quitOpen, setQuitOpen] = useState(false);
|
||||
const [quitting, setQuitting] = useState(false);
|
||||
const [quitError, setQuitError] = useState("");
|
||||
@@ -292,6 +296,12 @@ export function R6Settings({
|
||||
hidden={group !== "model"}
|
||||
className="r6-settings-model"
|
||||
>
|
||||
<MemoryModeSettings
|
||||
hasActiveModel={currentModel !== null}
|
||||
modelStateKnown={profiles !== null && !modelError}
|
||||
onConfigureModel={openConfiguration}
|
||||
onModeChange={setMemoryModeState}
|
||||
/>
|
||||
<h2>{text("记忆模型")}</h2>
|
||||
<div className="r6-model-overview">
|
||||
<p className="r6-model-caption">{text("当前模型")}</p>
|
||||
@@ -344,7 +354,7 @@ export function R6Settings({
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{currentModel && (
|
||||
{currentModel && memoryMode !== "lite" && (
|
||||
<div className="r6-setting-row">
|
||||
<div>
|
||||
<h3>{text("用模型沉淀记忆")}</h3>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
} from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -15,6 +21,8 @@ import { SdkRawHistory } from "./SdkRawHistory";
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("localizes controls without translating original conversation fields", async () => {
|
||||
@@ -87,3 +95,150 @@ it("lists and searches SDK raw without legacy conversation APIs", async () => {
|
||||
expect(await screen.findByText("搜索结果")).toBeTruthy();
|
||||
expect(searchSdkRaw).toHaveBeenCalledWith("查询");
|
||||
});
|
||||
|
||||
it("does not call a zero-result search an empty conversation history", async () => {
|
||||
getSdkRaw.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
raw_id: "raw-existing",
|
||||
content: "已有原始对话",
|
||||
session_id: "session-existing",
|
||||
source: "cursor",
|
||||
memory_at: null,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
});
|
||||
searchSdkRaw.mockResolvedValue([]);
|
||||
render(<SdkRawHistory />);
|
||||
|
||||
await userEvent.type(screen.getByLabelText("搜索原始对话"), "没有命中");
|
||||
await userEvent.click(screen.getByRole("button", { name: "搜索" }));
|
||||
|
||||
expect(await screen.findByText("找到 0 条")).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByText("还没有原始对话。连接来源后,对话会显示在这里。"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("retries the lite history read in place after a load failure", async () => {
|
||||
getSdkRaw.mockRejectedValueOnce(new Error("offline")).mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
raw_id: "raw-recovered",
|
||||
content: "恢复后的原始对话",
|
||||
session_id: "session-recovered",
|
||||
source: "cursor",
|
||||
memory_at: null,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
});
|
||||
render(<SdkRawHistory />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "重试" }));
|
||||
|
||||
expect(await screen.findByText("恢复后的原始对话")).toBeTruthy();
|
||||
expect(getSdkRaw).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("retries the failed raw search instead of reloading history", async () => {
|
||||
getSdkRaw.mockResolvedValue({
|
||||
items: [],
|
||||
total: 0,
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
});
|
||||
searchSdkRaw
|
||||
.mockRejectedValueOnce(new Error("offline"))
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
kind: "raw",
|
||||
raw_id: "raw-search-recovered",
|
||||
content: "重试后的搜索结果",
|
||||
source: "cursor",
|
||||
session_id: "session-search",
|
||||
memory_at: null,
|
||||
score: 0.8,
|
||||
truncated: false,
|
||||
},
|
||||
]);
|
||||
render(<SdkRawHistory />);
|
||||
|
||||
await userEvent.type(screen.getByLabelText("搜索原始对话"), "恢复搜索");
|
||||
await userEvent.click(screen.getByRole("button", { name: "搜索" }));
|
||||
await userEvent.click(await screen.findByRole("button", { name: "重试" }));
|
||||
|
||||
expect(await screen.findByText("重试后的搜索结果")).toBeTruthy();
|
||||
expect(searchSdkRaw).toHaveBeenCalledTimes(2);
|
||||
expect(getSdkRaw).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not let a history poll clear an independent search failure", async () => {
|
||||
vi.useFakeTimers();
|
||||
getSdkRaw.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
raw_id: "raw-old",
|
||||
content: "已有历史",
|
||||
session_id: "session-old",
|
||||
source: "cursor",
|
||||
memory_at: null,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
});
|
||||
searchSdkRaw.mockRejectedValue(new Error("search offline"));
|
||||
render(<SdkRawHistory />);
|
||||
await act(async () => Promise.resolve());
|
||||
|
||||
fireEvent.change(screen.getByLabelText("搜索原始对话"), {
|
||||
target: { value: "失败查询" },
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "搜索" }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(screen.getByRole("alert").textContent).toContain("原始对话搜索失败");
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(12_000);
|
||||
});
|
||||
expect(screen.getByRole("alert").textContent).toContain("原始对话搜索失败");
|
||||
});
|
||||
|
||||
it("ignores a stale initial-history failure after a newer search succeeds", async () => {
|
||||
let rejectHistory!: (reason: Error) => void;
|
||||
getSdkRaw.mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectHistory = reject;
|
||||
}),
|
||||
);
|
||||
searchSdkRaw.mockResolvedValue([
|
||||
{
|
||||
kind: "raw",
|
||||
raw_id: "raw-new-search",
|
||||
content: "较新的搜索结果",
|
||||
source: "cursor",
|
||||
session_id: "session-new",
|
||||
memory_at: null,
|
||||
score: 0.9,
|
||||
truncated: false,
|
||||
},
|
||||
]);
|
||||
render(<SdkRawHistory />);
|
||||
|
||||
await userEvent.type(screen.getByLabelText("搜索原始对话"), "新查询");
|
||||
await userEvent.click(screen.getByRole("button", { name: "搜索" }));
|
||||
expect(await screen.findByText("较新的搜索结果")).toBeTruthy();
|
||||
await act(async () => rejectHistory(new Error("late history failure")));
|
||||
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
expect(screen.getByText("较新的搜索结果")).toBeTruthy();
|
||||
});
|
||||
@@ -2,8 +2,9 @@ import type { SdkRawContextItem, SdkRawRecord } from "@one-memory/contracts";
|
||||
import { type FormEvent, useEffect, useRef, useState } from "react";
|
||||
import { getSdkRaw, searchSdkRaw } from "./api";
|
||||
import { useGuiLanguage } from "./gui-language";
|
||||
import { subscribePoll } from "./poll-scheduler";
|
||||
|
||||
export function SdkRawHistory() {
|
||||
export function SdkRawHistory({ active = true }: { active?: boolean }) {
|
||||
const { locale, t, tn } = useGuiLanguage();
|
||||
const translation = useRef(t);
|
||||
translation.current = t;
|
||||
@@ -11,35 +12,67 @@ export function SdkRawHistory() {
|
||||
const [results, setResults] = useState<SdkRawContextItem[] | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [errorKind, setErrorKind] = useState<"load" | "search" | null>(null);
|
||||
const errorKindRef = useRef<"load" | "search" | null>(null);
|
||||
const searchEpoch = useRef(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [revision, setRevision] = useState(0);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: revision is the explicit retry trigger.
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
let current = true;
|
||||
void getSdkRaw({ limit: 100 }).then(
|
||||
(page) => {
|
||||
if (current) setRecent(page.items);
|
||||
},
|
||||
() => {
|
||||
if (current) setError(translation.current("sdk.raw.loadFailed"));
|
||||
},
|
||||
);
|
||||
const startedBeforeSearch = searchEpoch.current;
|
||||
const consume = (value: unknown) => {
|
||||
if (!current) return;
|
||||
setRecent((value as Awaited<ReturnType<typeof getSdkRaw>>).items);
|
||||
if (errorKindRef.current !== "search") {
|
||||
errorKindRef.current = null;
|
||||
setError("");
|
||||
setErrorKind(null);
|
||||
}
|
||||
};
|
||||
void getSdkRaw({ limit: 100 }).then(consume, () => {
|
||||
if (current && startedBeforeSearch === searchEpoch.current) {
|
||||
setError(translation.current("sdk.raw.loadFailed"));
|
||||
errorKindRef.current = "load";
|
||||
setErrorKind("load");
|
||||
}
|
||||
});
|
||||
const stop = subscribePoll({
|
||||
key: "sdk-raw-history",
|
||||
intervalMs: 12_000,
|
||||
fetch: () => getSdkRaw({ limit: 100 }),
|
||||
onValue: consume,
|
||||
});
|
||||
return () => {
|
||||
current = false;
|
||||
stop();
|
||||
};
|
||||
}, []);
|
||||
}, [active, revision]);
|
||||
|
||||
async function runSearch(value: string) {
|
||||
searchEpoch.current += 1;
|
||||
setBusy(true);
|
||||
setResults(null);
|
||||
setError("");
|
||||
errorKindRef.current = null;
|
||||
setErrorKind(null);
|
||||
try {
|
||||
setResults(await searchSdkRaw(value));
|
||||
} catch {
|
||||
setError(t("sdk.raw.searchFailed"));
|
||||
errorKindRef.current = "search";
|
||||
setErrorKind("search");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!query.trim()) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setResults(await searchSdkRaw(query.trim()));
|
||||
} catch {
|
||||
setError(t("sdk.raw.searchFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
await runSearch(query.trim());
|
||||
}
|
||||
|
||||
const items = results ?? recent.map(asContextItem);
|
||||
@@ -57,12 +90,35 @@ export function SdkRawHistory() {
|
||||
{busy ? t("sdk.raw.searching") : t("sdk.raw.search")}
|
||||
</button>
|
||||
</form>
|
||||
{error ? <p role="alert">{error}</p> : null}
|
||||
{error ? (
|
||||
<p role="alert">
|
||||
{error}{" "}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
if (errorKind === "search" && query.trim()) {
|
||||
void runSearch(query.trim());
|
||||
} else {
|
||||
setError("");
|
||||
errorKindRef.current = null;
|
||||
setErrorKind(null);
|
||||
setRevision((value) => value + 1);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("connectionFallback.retry")}
|
||||
</button>
|
||||
</p>
|
||||
) : null}
|
||||
<p aria-live="polite">
|
||||
{results === null
|
||||
? t("sdk.raw.recent")
|
||||
: tn("sdk.raw.found", items.length)}
|
||||
</p>
|
||||
{!error && results === null && items.length === 0 ? (
|
||||
<p>{t("sdk.raw.empty")}</p>
|
||||
) : null}
|
||||
<ol>
|
||||
{items.map((item) => (
|
||||
<li key={item.raw_id}>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type {
|
||||
SdkRawContextItem,
|
||||
SdkRawRecord,
|
||||
SourceResponse,
|
||||
} from "@one-memory/contracts";
|
||||
import { clockStamp } from "./clock-stamp";
|
||||
import { useGuiLanguage } from "./gui-language";
|
||||
import { rawRecordTime } from "./sdk-raw-map";
|
||||
import { sourceNameOr, UNATTRIBUTED_SOURCE_ID } from "./source-label";
|
||||
|
||||
type RawRecord = SdkRawRecord | SdkRawContextItem;
|
||||
|
||||
function authorLabel(role: string, t: ReturnType<typeof useGuiLanguage>["t"]) {
|
||||
if (role === "user") return t("selectedMemory.author.user");
|
||||
if (role === "assistant" || role === "agent")
|
||||
return t("selectedMemory.author.agent");
|
||||
return t("selectedMemory.author.unknown");
|
||||
}
|
||||
|
||||
export function SelectedRawDetail({
|
||||
record,
|
||||
sources,
|
||||
}: {
|
||||
record: RawRecord;
|
||||
sources: SourceResponse[];
|
||||
}) {
|
||||
const { locale, t } = useGuiLanguage();
|
||||
const messages =
|
||||
"messages" in record && record.messages?.length
|
||||
? record.messages
|
||||
: [
|
||||
{
|
||||
role:
|
||||
"role" in record && typeof record.role === "string"
|
||||
? record.role
|
||||
: "unknown",
|
||||
content: record.content,
|
||||
},
|
||||
];
|
||||
const source =
|
||||
record.source === UNATTRIBUTED_SOURCE_ID
|
||||
? t("selectedMemory.sourceUnattributed")
|
||||
: sourceNameOr(
|
||||
sources,
|
||||
record.source,
|
||||
record.source || t("selectedMemory.sourceUnknown"),
|
||||
);
|
||||
return (
|
||||
<article className="map-dialogue-reading">
|
||||
<p className="selected-memory-status">{t("memory:原始对话")}</p>
|
||||
<ul className="map-transcript">
|
||||
{messages.map((message) => (
|
||||
<li key={`${record.raw_id}:${message.role}:${message.content}`}>
|
||||
<small>{authorLabel(message.role, t)}</small>
|
||||
<p>{message.content}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="selected-memory-source">
|
||||
{t("selectedMemory.sourceLine", {
|
||||
source,
|
||||
time: clockStamp(rawRecordTime(record), locale),
|
||||
})}
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,9 @@ import {
|
||||
CORE_SERVICE,
|
||||
type ConnectSourcesResponse,
|
||||
type DetectionResponse,
|
||||
type EvolveOverview,
|
||||
type EvolveRunReceipt,
|
||||
type EvolveSchedule,
|
||||
type ExtractionSettings,
|
||||
type ExtractionWindowPreset,
|
||||
type GraphWidthDecision,
|
||||
@@ -17,6 +20,9 @@ import {
|
||||
type MemoryAddInput,
|
||||
type MemoryAddReceipt,
|
||||
type MemoryGraphResponse,
|
||||
type MemoryModeChange,
|
||||
type MemoryModeState,
|
||||
type MemoryPipelineMode,
|
||||
type MemoryProcessingSummary,
|
||||
type ModelCreationOperation,
|
||||
type ModelProbeOperationReceipt,
|
||||
@@ -789,30 +795,34 @@ export async function getSdkRaw({
|
||||
source,
|
||||
offset = 0,
|
||||
limit = 100,
|
||||
signal,
|
||||
}: {
|
||||
source?: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<SdkRawPage> {
|
||||
const response = await client.get<SdkRawPage>("/api/v2/raw", {
|
||||
params: { source, offset, limit },
|
||||
signal,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function searchSdkRaw(
|
||||
query: string,
|
||||
signal?: AbortSignal,
|
||||
options: { source?: string; signal?: AbortSignal } = {},
|
||||
): Promise<SdkRawContextItem[]> {
|
||||
const response = await client.post(
|
||||
"/api/v2/context/search",
|
||||
{
|
||||
query,
|
||||
kinds: ["raw"],
|
||||
...(options.source ? { include_sources: [options.source] } : {}),
|
||||
limit: 20,
|
||||
character_budget: 12_000,
|
||||
},
|
||||
{ signal, timeout: 10_000 },
|
||||
{ signal: options.signal, timeout: 10_000 },
|
||||
);
|
||||
const items = response.data?.items;
|
||||
return Array.isArray(items)
|
||||
@@ -907,6 +917,56 @@ export async function getMemoryProcessing(): Promise<MemoryProcessingSummary> {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getMemoryMode(): Promise<MemoryModeState> {
|
||||
const response = await client.get<MemoryModeState>(
|
||||
"/api/v1/settings/memory-mode",
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function setMemoryMode(
|
||||
mode: MemoryPipelineMode,
|
||||
): Promise<MemoryModeChange> {
|
||||
const response = await client.put<MemoryModeChange>(
|
||||
"/api/v1/settings/memory-mode",
|
||||
{ mode },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getEvolveSchedule(): Promise<EvolveSchedule> {
|
||||
const response = await client.get<EvolveSchedule>(
|
||||
"/api/v1/settings/evolve-schedule",
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function setEvolveSchedule(
|
||||
update: Partial<EvolveSchedule>,
|
||||
): Promise<EvolveSchedule> {
|
||||
const response = await client.put<EvolveSchedule>(
|
||||
"/api/v1/settings/evolve-schedule",
|
||||
update,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getEvolveOverview(
|
||||
userId = "local",
|
||||
): Promise<EvolveOverview> {
|
||||
const response = await client.get<EvolveOverview>("/api/v2/evolve", {
|
||||
params: { user_id: userId },
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function startEvolve(userId = "local"): Promise<EvolveRunReceipt> {
|
||||
const response = await client.post<EvolveRunReceipt>("/api/v2/evolve", {
|
||||
user_id: userId,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getDetailedLogging(): Promise<boolean> {
|
||||
const response = await client.get("/api/v1/diagnostics/settings");
|
||||
const value = response.data as Record<string, unknown>;
|
||||
|
||||
@@ -561,6 +561,119 @@
|
||||
line-height: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
.onboarding-r6-sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
.onboarding-r6-mode {
|
||||
width: min(720px, 100%);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.onboarding-r6-mode-intro {
|
||||
text-align: center;
|
||||
}
|
||||
.onboarding-r6-mode-intro h2 {
|
||||
margin: 0;
|
||||
color: #263947;
|
||||
font-size: 20px;
|
||||
line-height: 30px;
|
||||
}
|
||||
.onboarding-r6-mode-intro p {
|
||||
margin: 8px 0 0;
|
||||
color: #637381;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
}
|
||||
.onboarding-r6-mode-options {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.onboarding-r6-mode-card {
|
||||
min-height: 136px;
|
||||
padding: 20px;
|
||||
border: 1px solid #c9d5de;
|
||||
border-radius: 10px;
|
||||
background: rgb(255 255 255 / 72%);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
transition:
|
||||
border-color var(--r6-fast, 140ms),
|
||||
background var(--r6-fast, 140ms),
|
||||
box-shadow var(--r6-fast, 140ms);
|
||||
}
|
||||
.onboarding-r6-mode-card:hover {
|
||||
border-color: #8299aa;
|
||||
background: rgb(255 255 255 / 76%);
|
||||
}
|
||||
.onboarding-r6-mode-card[data-selected="true"] {
|
||||
border-color: #557c93;
|
||||
background: #edf3f6;
|
||||
box-shadow: 0 0 0 2px rgb(85 124 147 / 10%);
|
||||
}
|
||||
.onboarding-r6-mode-card:has(input:focus-visible) {
|
||||
outline: 2px solid #557c93;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.onboarding-r6-mode-card input {
|
||||
flex: 0 0 auto;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: 4px 0 0;
|
||||
accent-color: #557c93;
|
||||
}
|
||||
.onboarding-r6-mode-copy {
|
||||
min-width: 0;
|
||||
color: #5f6e79;
|
||||
font-size: 14px;
|
||||
line-height: 23px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.onboarding-r6-mode-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
.onboarding-r6-mode-heading strong {
|
||||
color: #263947;
|
||||
font-size: 17px;
|
||||
line-height: 26px;
|
||||
}
|
||||
.onboarding-r6-mode-heading > span {
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
color: #41677e;
|
||||
background: #dceaf2;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.onboarding-r6-mode-error {
|
||||
min-height: 20px;
|
||||
margin: -6px 0 0;
|
||||
color: #944242;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.onboarding-r6-empty {
|
||||
margin: 0;
|
||||
padding: 28px 18px;
|
||||
@@ -698,6 +811,12 @@
|
||||
.onboarding-r6-body {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
.onboarding-r6-mode-options {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.onboarding-r6-mode-card {
|
||||
min-height: 0;
|
||||
}
|
||||
.onboarding-r6-discovery {
|
||||
height: auto;
|
||||
display: flex;
|
||||
|
||||
@@ -29,6 +29,7 @@ export type PollKey =
|
||||
| "calls"
|
||||
| "model-profiles"
|
||||
| "memory-add-processing"
|
||||
| "sdk-raw-history"
|
||||
| "health";
|
||||
|
||||
export interface PollTask {
|
||||
|
||||
@@ -22,6 +22,16 @@
|
||||
font-size: 13px;
|
||||
color: #637786;
|
||||
}
|
||||
.r6-memory .memory-mode-notice {
|
||||
margin: -8px 0 18px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #d5dfe8;
|
||||
border-radius: 8px;
|
||||
background: #f1f5f8;
|
||||
color: #405665;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
}
|
||||
.r6-memory .memory-pending-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -194,6 +194,105 @@
|
||||
margin: 0;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.r6-memory-mode-settings {
|
||||
margin: 0 0 32px;
|
||||
padding: 0 0 32px;
|
||||
border-bottom: 1px solid #d8e2ea;
|
||||
}
|
||||
.r6-mode-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.r6-settings .r6-memory-mode-settings .r6-mode-heading h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
line-height: 32px;
|
||||
}
|
||||
.r6-mode-heading p {
|
||||
margin: 4px 0 0;
|
||||
color: #5e6e7a;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
}
|
||||
.r6-settings .r6-mode-setting-row {
|
||||
max-width: none;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.r6-settings .r6-mode-option-menu {
|
||||
width: 100%;
|
||||
max-width: 760px;
|
||||
}
|
||||
.r6-settings
|
||||
.r6-mode-option-menu.r6-language-option-menu
|
||||
.om-option-menu-button {
|
||||
height: auto;
|
||||
min-height: 68px;
|
||||
padding: 10px 44px 10px 14px;
|
||||
white-space: normal;
|
||||
}
|
||||
.r6-settings
|
||||
.r6-mode-option-menu.r6-language-option-menu
|
||||
.om-option-menu-button::after {
|
||||
top: 27px;
|
||||
}
|
||||
.r6-mode-option-menu .om-option-menu-copy,
|
||||
.r6-mode-option-menu-panel .om-option-menu-copy {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.r6-mode-option-menu .om-option-menu-copy > span,
|
||||
.r6-mode-option-menu-panel .om-option-menu-copy > span {
|
||||
color: #304757;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 22px;
|
||||
}
|
||||
.r6-mode-option-menu .om-option-menu-copy > small,
|
||||
.r6-mode-option-menu-panel .om-option-menu-copy > small {
|
||||
color: #5e6e7a;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 19px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.r6-language-option-menu-panel--settings.r6-mode-option-menu-panel
|
||||
[role="option"] {
|
||||
min-height: 62px;
|
||||
padding: 9px 10px;
|
||||
white-space: normal;
|
||||
}
|
||||
.r6-settings-note {
|
||||
margin: 14px 0 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: #f1f5f8;
|
||||
color: #405665;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
}
|
||||
.r6-evolve-settings {
|
||||
margin-top: 20px;
|
||||
border-top: 1px solid #d8e2ea;
|
||||
}
|
||||
.r6-evolve-settings input[type="time"] {
|
||||
min-height: 40px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #c6d2da;
|
||||
border-radius: 8px;
|
||||
color: #304757;
|
||||
background: #fff;
|
||||
}
|
||||
.r6-settings-dialog ul {
|
||||
margin: 14px 0 20px;
|
||||
padding-left: 20px;
|
||||
color: #4f6373;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
}
|
||||
.r6-settings .r6-setting-row {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { rawDetail, rawLibraryPage, rawSearchPage } from "./sdk-raw-map";
|
||||
|
||||
const record = {
|
||||
raw_id: "raw-1",
|
||||
content: "[user]: Keep the original words.",
|
||||
user_id: "local",
|
||||
agent_id: "all-apps",
|
||||
session_id: "session-1",
|
||||
source: "cursor",
|
||||
memory_at: 1_761_000_000,
|
||||
gmt_created: 1_761_000_100,
|
||||
custom: {},
|
||||
messages: [{ role: "user", content: "Keep the original words." }],
|
||||
};
|
||||
|
||||
describe("raw map projection", () => {
|
||||
it("projects a raw page into the shared three-column library shape", () => {
|
||||
const page = rawLibraryPage({
|
||||
items: [record],
|
||||
total: 3,
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
expect(page.items[0]).toMatchObject({
|
||||
memory_id: "raw-1",
|
||||
content: record.content,
|
||||
status: "confirmed",
|
||||
});
|
||||
expect(page.source_ids_by_memory).toEqual({ "raw-1": ["cursor"] });
|
||||
expect(page.source_time_by_memory?.["raw-1"]?.basis).toBe("conversation");
|
||||
expect(page.total).toBe(3);
|
||||
});
|
||||
|
||||
it("keeps search results read-only and page-local", () => {
|
||||
const page = rawSearchPage([
|
||||
{
|
||||
kind: "raw",
|
||||
raw_id: "raw-search",
|
||||
content: "Matched original",
|
||||
source: "claude",
|
||||
session_id: "session-2",
|
||||
memory_at: "2026-11-03T09:00:00Z",
|
||||
score: 0.8,
|
||||
truncated: false,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(page).toMatchObject({ total: 1, offset: 0, limit: 1 });
|
||||
expect(page.items[0].memory_id).toBe("raw-search");
|
||||
});
|
||||
|
||||
it("falls back to import time when conversation time is unavailable", () => {
|
||||
const page = rawLibraryPage({
|
||||
items: [{ ...record, memory_at: null }],
|
||||
total: 1,
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
expect(page.source_time_by_memory?.["raw-1"]).toEqual({
|
||||
at: new Date(record.gmt_created * 1_000).toISOString(),
|
||||
basis: "import",
|
||||
});
|
||||
expect(rawDetail({ ...record, memory_at: null }).sdk_memory_at).toBe(
|
||||
new Date(record.gmt_created * 1_000).toISOString(),
|
||||
);
|
||||
});
|
||||
|
||||
it("adapts raw only for the shared selection identity", () => {
|
||||
expect(rawDetail(record)).toMatchObject({
|
||||
memory: { memory_id: "raw-1", content: record.content },
|
||||
source_id: "cursor",
|
||||
evidence: [],
|
||||
history: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import type {
|
||||
GovernedMemory,
|
||||
MemoryDetail,
|
||||
MemoryLibraryPage,
|
||||
SdkRawContextItem,
|
||||
SdkRawPage,
|
||||
SdkRawRecord,
|
||||
} from "@one-memory/contracts";
|
||||
|
||||
export type RawMapRecord = SdkRawRecord | SdkRawContextItem;
|
||||
|
||||
const EARLIEST_PLAUSIBLE = Date.UTC(2000, 0, 1);
|
||||
const LATEST_PLAUSIBLE = () => Date.now() + 86_400_000;
|
||||
|
||||
function isoTime(value: number | string | null | undefined): string {
|
||||
const milliseconds =
|
||||
typeof value === "number"
|
||||
? value * 1_000
|
||||
: typeof value === "string"
|
||||
? Date.parse(value)
|
||||
: Number.NaN;
|
||||
if (
|
||||
Number.isFinite(milliseconds) &&
|
||||
milliseconds >= EARLIEST_PLAUSIBLE &&
|
||||
milliseconds <= LATEST_PLAUSIBLE()
|
||||
)
|
||||
return new Date(milliseconds).toISOString();
|
||||
return "";
|
||||
}
|
||||
|
||||
export function rawRecordTime(record: RawMapRecord): string | null {
|
||||
return (
|
||||
isoTime(record.memory_at) ||
|
||||
("gmt_created" in record ? isoTime(record.gmt_created) : "") ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function sourceOf(record: RawMapRecord): string {
|
||||
return record.source || "default";
|
||||
}
|
||||
|
||||
export function rawMapMemory(record: RawMapRecord): GovernedMemory {
|
||||
const created = rawRecordTime(record) ?? "";
|
||||
return {
|
||||
memory_id: record.raw_id,
|
||||
user_id: "user_id" in record ? record.user_id : "local",
|
||||
kind: "chat",
|
||||
status: "confirmed",
|
||||
content: record.content,
|
||||
scopes: [],
|
||||
evidence_ids: [],
|
||||
// GovernedMemory is only the map's shared projection. Raw remains raw;
|
||||
// this inert value is never shown or written back.
|
||||
origin: "extracted",
|
||||
tags: [],
|
||||
supersedes: [],
|
||||
superseded_by: [],
|
||||
revision: 0,
|
||||
created_at: created,
|
||||
updated_at: created,
|
||||
deleted_at: null,
|
||||
};
|
||||
}
|
||||
|
||||
function pageFor(
|
||||
records: RawMapRecord[],
|
||||
total: number,
|
||||
offset: number,
|
||||
limit: number,
|
||||
): MemoryLibraryPage {
|
||||
const sourceIds: Record<string, string[]> = {};
|
||||
const sourceTime: Record<
|
||||
string,
|
||||
{ at: string; basis: "conversation" | "import" }
|
||||
> = {};
|
||||
for (const record of records) {
|
||||
sourceIds[record.raw_id] = [sourceOf(record)];
|
||||
const conversationAt = isoTime(record.memory_at);
|
||||
const importedAt =
|
||||
"gmt_created" in record ? isoTime(record.gmt_created) : "";
|
||||
if (conversationAt)
|
||||
sourceTime[record.raw_id] = {
|
||||
at: conversationAt,
|
||||
basis: "conversation",
|
||||
};
|
||||
else if (importedAt)
|
||||
sourceTime[record.raw_id] = { at: importedAt, basis: "import" };
|
||||
}
|
||||
return {
|
||||
items: records.map(rawMapMemory),
|
||||
total,
|
||||
offset,
|
||||
limit,
|
||||
source_ids_by_memory: sourceIds,
|
||||
source_time_by_memory: sourceTime,
|
||||
search_window: null,
|
||||
window_id: null,
|
||||
window_reset: false,
|
||||
sdk_memory_refs: {},
|
||||
};
|
||||
}
|
||||
|
||||
export function rawLibraryPage(page: SdkRawPage): MemoryLibraryPage {
|
||||
return pageFor(page.items, page.total, page.offset, page.limit);
|
||||
}
|
||||
|
||||
export function rawSearchPage(items: SdkRawContextItem[]): MemoryLibraryPage {
|
||||
return pageFor(items, items.length, 0, Math.max(items.length, 1));
|
||||
}
|
||||
|
||||
export function rawDetail(record: RawMapRecord): MemoryDetail {
|
||||
const memory = rawMapMemory(record);
|
||||
return {
|
||||
memory,
|
||||
evidence: [],
|
||||
history: [],
|
||||
index_sync: null,
|
||||
source_id: sourceOf(record),
|
||||
sdk_memory_at: rawRecordTime(record),
|
||||
sdk_created_at:
|
||||
"gmt_created" in record ? isoTime(record.gmt_created) || null : null,
|
||||
};
|
||||
}
|
||||
@@ -282,7 +282,7 @@ Support is declared per immutable GitHub Release manifest:
|
||||
- Linux x64/arm64 Runtime on native GHA runners.
|
||||
- macOS arm64/x64 App/Runtime: Developer ID, notarization, native acceptance.
|
||||
- Windows is not supported.
|
||||
- `hy-memory==2.0.12` is the current stable SDK dependency; the release
|
||||
- `hy-memory==2.0.13` is the current stable SDK dependency; the release
|
||||
lock and [`../MEMORY.md`](../MEMORY.md) are authoritative when the pin changes.
|
||||
|
||||
See [`INSTALLATION.md`](INSTALLATION.md).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MemHub 安装与发行
|
||||
|
||||
MemHub `v1.0.6`(build 37)的发行目标如下:
|
||||
MemHub `v1.0.6`(build 39)的发行目标如下:
|
||||
|
||||
| 平台 | 发行物 | 构建与验收 |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -215,6 +215,7 @@ the startup scan, or null.
|
||||
|---|---|---|
|
||||
| `embedding_switch_required` | 409 (+ `plan`) | activate with another embedding on a non-empty home |
|
||||
| `embedding_switch_running` | 409 | activate / start / embedding edit of the active or target profile while a job is open |
|
||||
| `memory_lifecycle_busy` | 409 | start while evolve or graph backfill is running |
|
||||
| `embedding_switch_plan_stale` | 409 (+ `plan`) | `plan_token` no longer matches (data or live space changed) |
|
||||
| `embedding_switch_not_needed` | 409 (+ `plan`) | start with the same identity, or on an empty home (activate instead) |
|
||||
| `embedding_switch_in_progress` | 409 | memory edit/delete, `POST /api/v1/graph-width` while writes are paused |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Memory mode, evolve and the nightly schedule v1
|
||||
|
||||
Status: Core + CLI implemented (PR B of the SDK-surface batch). The App
|
||||
controls are built separately against this contract.
|
||||
Status: Core + CLI + App implemented. The App uses this contract for first-run
|
||||
mode choice, Settings, the mode-specific memory page and evolve status.
|
||||
|
||||
All routes need the Runtime bearer token. Unknown query parameters and body
|
||||
fields answer `422 {"detail": "request_validation_failed"}`, like every
|
||||
@@ -72,6 +72,8 @@ and they are the owner's:
|
||||
(`build_memory_graph(user_id, limit=10000, clear_first=False)`);
|
||||
- this runs in the background, one user at a time, never beside that
|
||||
user's write;
|
||||
- after a crash or App update, startup resumes users that did not finish
|
||||
and does not repeat users already recorded as succeeded;
|
||||
- progress is reported in `graph_build`.
|
||||
- Vector collection names never depend on the mode. The mode is a separate
|
||||
client parameter, not part of the embedding-space identity.
|
||||
@@ -84,6 +86,7 @@ No parameters. `200` response:
|
||||
{
|
||||
"mode": "pro",
|
||||
"configured_mode": "pro",
|
||||
"explicitly_configured": false,
|
||||
"forced_by_env": false,
|
||||
"modes": ["lite", "pro", "ultra"],
|
||||
"graph_build": {
|
||||
@@ -97,6 +100,9 @@ No parameters. `200` response:
|
||||
|
||||
- `mode` is what SDK clients are built with now; `configured_mode` is the
|
||||
saved setting.
|
||||
- `explicitly_configured` is `true` once the user has chosen a mode, including
|
||||
explicitly choosing the default `pro`. An older or untouched installation
|
||||
with no `memory_mode` key defaults to `pro` and reports `false`.
|
||||
- `graph_build.state` is `idle | running | succeeded | failed`. Each entry in
|
||||
`graph_build.users[<user_id>]` is
|
||||
`{state: pending|running|succeeded|failed, memories_scanned, nodes, error_code}`.
|
||||
@@ -129,6 +135,11 @@ this order:
|
||||
|
||||
Errors:
|
||||
|
||||
- `409 {"detail": "embedding_maintenance_in_progress"}`: an embedding switch,
|
||||
startup scan or startup merge has closed the model-write gate. Nothing
|
||||
changed; retry after embedding maintenance finishes. GET remains available.
|
||||
- `409 {"detail": "memory_lifecycle_busy"}`: evolve or graph backfill is
|
||||
running. A real mode change waits until that lifecycle task finishes.
|
||||
- `409 {"detail": "graph_store_unavailable"}`: `ultra` was asked for, but the
|
||||
graph store package is not installed. Nothing changed.
|
||||
- `422 request_validation_failed`: an unknown mode or an extra field.
|
||||
@@ -164,6 +175,12 @@ on in the background.
|
||||
|
||||
Errors:
|
||||
|
||||
- `409 {"detail": "embedding_maintenance_in_progress"}`: an embedding switch,
|
||||
startup scan or startup merge has closed the model-write gate. No evolve run
|
||||
was started; retry after embedding maintenance finishes. Evolve GET routes
|
||||
remain available.
|
||||
- `409 {"detail": "memory_lifecycle_busy"}`: the graph backfill is still
|
||||
running. Start manual evolve after it finishes.
|
||||
- `409 {"detail": "memory_mode_not_ultra"}`: the effective mode is not ultra.
|
||||
- `422`: a malformed `user_id`, `max_batches` outside its range, or an extra
|
||||
field.
|
||||
@@ -334,6 +351,21 @@ mode. A change rebuilds the SDK handles the same way. Contract:
|
||||
|
||||
## 9. Copy the UI can reuse
|
||||
|
||||
The App's product rules are:
|
||||
|
||||
- A new installation explicitly chooses lite or pro. Choosing lite persists
|
||||
the mode and completes onboarding without a generation model. Choosing pro
|
||||
opens model setup and completes onboarding only after activation succeeds.
|
||||
- Existing installations keep the default pro behaviour; an absent
|
||||
`memory_mode` is never inferred from model availability.
|
||||
- Ultra is enabled only from Settings. Its nightly schedule is on by default
|
||||
at 03:00 local time, and Settings also exposes a manual evolve action.
|
||||
- The memory page shows raw conversations in lite and advanced memories in
|
||||
pro/ultra. Higher-mode memories remain stored while lite is active but are
|
||||
neither shown nor served by lite search.
|
||||
- Mode changes and manual evolve are disabled while embedding maintenance has
|
||||
closed the write gate.
|
||||
|
||||
| Key | zh-CN | en | es |
|
||||
|---|---|---|---|
|
||||
| title | 记忆模式 | Memory mode | Modo de memoria |
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "one-memory",
|
||||
"version": "1.0.6",
|
||||
"release": {
|
||||
"build_number": 37,
|
||||
"build_number": 39,
|
||||
"macos_min_version": "13.0",
|
||||
"app_update": "manual",
|
||||
"runtime_manifest_schema": 1,
|
||||
|
||||
@@ -902,6 +902,98 @@ export interface MemoryProcessingBlock {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export type MemoryPipelineMode = "lite" | "pro" | "ultra";
|
||||
|
||||
export interface GraphBuildUser {
|
||||
state: "pending" | "running" | "succeeded" | "failed";
|
||||
memories_scanned: number | null;
|
||||
nodes: number | null;
|
||||
error_code: string | null;
|
||||
}
|
||||
|
||||
export interface GraphBuildState {
|
||||
state: "idle" | "running" | "succeeded" | "failed";
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
users: Record<string, GraphBuildUser>;
|
||||
}
|
||||
|
||||
export interface EvolveSchedule {
|
||||
enabled: boolean;
|
||||
time: string;
|
||||
}
|
||||
|
||||
export interface EvolveRunReceipt {
|
||||
run_id: string;
|
||||
user_id: string;
|
||||
agent_id: string;
|
||||
max_batches: number | null;
|
||||
trigger: "manual" | "scheduled";
|
||||
state: "running" | "succeeded" | "failed";
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
rounds: number;
|
||||
tasks_processed: number;
|
||||
batch_failures: number;
|
||||
cycle_status: string | null;
|
||||
should_continue: boolean | null;
|
||||
continuation_reason: string | null;
|
||||
remaining_clusters: number | null;
|
||||
remaining_actionable_clusters: number | null;
|
||||
remaining_fresh_facts: number | null;
|
||||
capped: boolean;
|
||||
elapsed_ms: number;
|
||||
error_code: string | null;
|
||||
failure_kind: string | null;
|
||||
request_id: string | null;
|
||||
}
|
||||
|
||||
export interface UserEvolveState {
|
||||
last_run_at: string | null;
|
||||
last_result: EvolveRunReceipt | null;
|
||||
last_error: string | null;
|
||||
}
|
||||
|
||||
export interface EvolveOverview {
|
||||
mode: MemoryPipelineMode;
|
||||
schedule: EvolveSchedule;
|
||||
users: Record<string, UserEvolveState>;
|
||||
running: EvolveRunReceipt[];
|
||||
}
|
||||
|
||||
export interface MemoryModeState {
|
||||
mode: MemoryPipelineMode;
|
||||
configured_mode: MemoryPipelineMode;
|
||||
explicitly_configured: boolean;
|
||||
forced_by_env: boolean;
|
||||
modes: MemoryPipelineMode[];
|
||||
graph_build: GraphBuildState;
|
||||
}
|
||||
|
||||
export interface MemoryModeChange extends MemoryModeState {
|
||||
previous_mode: MemoryPipelineMode;
|
||||
effects: Array<
|
||||
| "earlier_raw_not_extracted"
|
||||
| "higher_mode_memories_hidden"
|
||||
| "existing_memories_added_to_graph"
|
||||
| "evolve_stopped"
|
||||
| "lite_search_stops"
|
||||
| "graph_backfill_already_running"
|
||||
>;
|
||||
}
|
||||
|
||||
export interface MemoryPipelineStatus {
|
||||
mode: MemoryPipelineMode;
|
||||
configured_mode: MemoryPipelineMode;
|
||||
forced_by_env: boolean;
|
||||
evolve: EvolveOverview;
|
||||
last_evolve_at: string | null;
|
||||
graph_build: GraphBuildState;
|
||||
features: Record<string, boolean | number>;
|
||||
ui_language?: string | null;
|
||||
sdk_language?: string;
|
||||
}
|
||||
|
||||
export interface MemoryProcessingSummary {
|
||||
pending: number;
|
||||
/** `pending` split by how recent the evidence is: what arrived today, and
|
||||
@@ -963,6 +1055,16 @@ export interface MemoryProcessingSummary {
|
||||
llm_backoff_until?: string | null;
|
||||
/** The provider failure behind the pause, as a stable code. */
|
||||
llm_backoff_error?: string | null;
|
||||
/** The active memory pipeline and its graph/evolve status. */
|
||||
pipeline?: MemoryPipelineStatus | null;
|
||||
/** A running embedding switch or startup merge pauses mode mutations. */
|
||||
embedding_switch?: {
|
||||
job_id?: string;
|
||||
kind: "switch" | "merge";
|
||||
state: string;
|
||||
percent?: number | null;
|
||||
eta_s?: number | null;
|
||||
} | null;
|
||||
metrics?: {
|
||||
raw_evidence: number;
|
||||
complete_turns: number;
|
||||
|
||||
@@ -29,6 +29,18 @@ import {
|
||||
mergeCatalogs,
|
||||
spanishMessages,
|
||||
} from "./gui-i18n-messages.js";
|
||||
import {
|
||||
modeEnglishMessages,
|
||||
modeFrenchMessages,
|
||||
modeGermanMessages,
|
||||
modeIndonesianMessages,
|
||||
modeJapaneseMessages,
|
||||
modeKoreanMessages,
|
||||
modePortugueseMessages,
|
||||
modeRussianMessages,
|
||||
modeSpanishMessages,
|
||||
modeTraditionalChineseMessages,
|
||||
} from "./gui-i18n-mode.js";
|
||||
|
||||
const placeholders = (text: string) =>
|
||||
[...text.matchAll(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g)].map((m) => m[1]).sort();
|
||||
@@ -231,6 +243,27 @@ describe("English catalog", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mode catalog", () => {
|
||||
it("does not leave non-English locales on English fallback copy", () => {
|
||||
const locales = [
|
||||
modeSpanishMessages,
|
||||
modeTraditionalChineseMessages,
|
||||
modeJapaneseMessages,
|
||||
modeGermanMessages,
|
||||
modePortugueseMessages,
|
||||
modeFrenchMessages,
|
||||
modeKoreanMessages,
|
||||
modeRussianMessages,
|
||||
modeIndonesianMessages,
|
||||
];
|
||||
for (const messages of locales)
|
||||
for (const key of Object.keys(modeEnglishMessages) as Array<
|
||||
keyof typeof modeEnglishMessages
|
||||
>)
|
||||
expect(messages[key], key).not.toBe(modeEnglishMessages[key]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Spanish catalog", () => {
|
||||
it("covers every shipped key with matching placeholders", () => {
|
||||
expect(Object.keys(spanishMessages).sort()).toEqual(
|
||||
|
||||
@@ -72,6 +72,7 @@ export const currentChineseMessages = {
|
||||
"sdk.raw.loadFailed": "原始对话暂时无法读取。",
|
||||
"sdk.raw.searchFailed": "原始对话搜索失败,请重试。",
|
||||
"sdk.raw.recent": "最近原始对话",
|
||||
"sdk.raw.empty": "还没有原始对话。连接来源后,对话会显示在这里。",
|
||||
"sdk.raw.found": "找到 {count} 条",
|
||||
"sdk.raw.found.one": "找到 {count} 条",
|
||||
|
||||
@@ -219,6 +220,8 @@ export const currentEnglishMessages: Record<
|
||||
"sdk.raw.searchFailed":
|
||||
"Could not search original conversations. Please retry.",
|
||||
"sdk.raw.recent": "Recent original conversations",
|
||||
"sdk.raw.empty":
|
||||
"No original conversations yet. Connect a source to see conversations here.",
|
||||
"sdk.raw.found": "{count} results",
|
||||
"sdk.raw.found.one": "{count} result",
|
||||
|
||||
@@ -379,6 +382,8 @@ export const currentSpanishMessages: Record<
|
||||
"sdk.raw.searchFailed":
|
||||
"No se pudieron buscar las conversaciones originales. Reinténtalo.",
|
||||
"sdk.raw.recent": "Conversaciones originales recientes",
|
||||
"sdk.raw.empty":
|
||||
"Todavía no hay conversaciones originales. Conecta una fuente para verlas aquí.",
|
||||
"sdk.raw.found": "{count} resultados",
|
||||
"sdk.raw.found.one": "{count} resultado",
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { currentEnglishMessages } from "./gui-i18n-current.js";
|
||||
import { modeEnglishMessages } from "./gui-i18n-mode.js";
|
||||
|
||||
// English translations integrated from PR #114.
|
||||
const baseEnglishMessages = {
|
||||
@@ -1606,6 +1607,12 @@ const baseEnglishMessages = {
|
||||
"memory:来源与主题": "Sources and topics",
|
||||
"memory:记忆或原始对话": "Memories or original conversations",
|
||||
"memory:原始对话": "Original conversations",
|
||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||
"Basic memory is active. New conversations are kept as original records and do not create advanced memories. Existing advanced memories stay saved, but are not shown or used for recall.",
|
||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||
"Smart memory is active. The model turns new conversations into advanced memories.",
|
||||
"memory:当前使用深度记忆。新对话会生成高级记忆,并由知识图谱和 evolve 继续整合。":
|
||||
"Deep memory is active. New conversations create advanced memories that the knowledge graph and evolve continue to consolidate.",
|
||||
"memory:地图组织方式": "Map grouping",
|
||||
"memory:筛选地图记忆": "Filter map memories",
|
||||
"memory:搜索原始对话": "Search original conversations",
|
||||
@@ -2000,4 +2007,5 @@ const baseEnglishMessages = {
|
||||
export const englishMessages = {
|
||||
...baseEnglishMessages,
|
||||
...currentEnglishMessages,
|
||||
...modeEnglishMessages,
|
||||
} as const;
|
||||
@@ -159,6 +159,12 @@ export const memoryChineseMessages = {
|
||||
"memory:来源与主题": "来源与主题",
|
||||
"memory:记忆或原始对话": "记忆或原始对话",
|
||||
"memory:原始对话": "原始对话",
|
||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||
"当前为 lite:只保存和搜索原始对话,不生成高级记忆。已有高级记忆仍会保留,但不显示、不参与召回。",
|
||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||
"当前为 pro:新对话会由模型整理为高级记忆。",
|
||||
"memory:当前使用深度记忆。新对话会生成高级记忆,并由知识图谱和 evolve 继续整合。":
|
||||
"当前为 ultra:新对话会生成高级记忆,并由知识图谱继续整合。",
|
||||
"memory:地图组织方式": "地图组织方式",
|
||||
"memory:筛选地图记忆": "筛选地图记忆",
|
||||
"memory:搜索原始对话": "搜索原始对话",
|
||||
@@ -567,6 +573,12 @@ export const memorySpanishMessages: Record<
|
||||
"memory:来源与主题": "Orígenes y temas",
|
||||
"memory:记忆或原始对话": "Memorias o conversaciones originales",
|
||||
"memory:原始对话": "Conversaciones originales",
|
||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||
"Estás usando la memoria básica. Las conversaciones nuevas se guardan como registros originales y no generan memorias avanzadas. Las memorias avanzadas anteriores se conservan, pero no se muestran ni se usan para recuperar recuerdos.",
|
||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||
"Estás usando la memoria inteligente. El modelo convierte las conversaciones nuevas en memorias avanzadas.",
|
||||
"memory:当前使用深度记忆。新对话会生成高级记忆,并由知识图谱和 evolve 继续整合。":
|
||||
"Estás usando la memoria profunda. Las conversaciones nuevas generan memorias avanzadas que el grafo de conocimiento y evolve siguen consolidando.",
|
||||
"memory:地图组织方式": "Organización del mapa",
|
||||
"memory:筛选地图记忆": "Filtrar memorias del mapa",
|
||||
"memory:搜索原始对话": "Buscar conversaciones originales",
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
migrationChineseMessages,
|
||||
migrationSpanishMessages,
|
||||
} from "./gui-i18n-migration.js";
|
||||
import { modeChineseMessages, modeSpanishMessages } from "./gui-i18n-mode.js";
|
||||
import {
|
||||
onboardingChineseMessages,
|
||||
onboardingSpanishMessages,
|
||||
@@ -124,6 +125,11 @@ export const guiCatalogs: readonly Catalog[] = [
|
||||
chinese: onboardingChineseMessages,
|
||||
spanish: onboardingSpanishMessages,
|
||||
},
|
||||
{
|
||||
name: "mode",
|
||||
chinese: modeChineseMessages,
|
||||
spanish: modeSpanishMessages,
|
||||
},
|
||||
{
|
||||
name: "applications",
|
||||
chinese: applicationsChineseMessages,
|
||||
@@ -185,6 +191,7 @@ export const chineseMessages = merged.chinese as typeof guideChineseMessages &
|
||||
typeof recoveryChineseMessages &
|
||||
typeof settingsChineseMessages &
|
||||
typeof onboardingChineseMessages &
|
||||
typeof modeChineseMessages &
|
||||
typeof applicationsChineseMessages &
|
||||
typeof memoryChineseMessages &
|
||||
typeof callsChineseMessages &
|
||||
|
||||
@@ -0,0 +1,925 @@
|
||||
const modeChineseMessages = {
|
||||
"模式未能保存。你的选择已保留,请重试。":
|
||||
"模式未能保存。你的选择已保留,请重试。",
|
||||
选择记忆方式: "选择记忆方式",
|
||||
"最后一步 · 决定如何处理对话": "最后一步 · 选择记忆模式",
|
||||
返回导入: "返回导入",
|
||||
继续: "继续",
|
||||
"正在保存…": "正在保存…",
|
||||
"当前模型已经启用,可以继续完成首次设置。":
|
||||
"当前模型已经启用,可以继续完成首次设置。",
|
||||
继续使用当前模型: "继续使用当前模型",
|
||||
"你希望 MemHub 如何形成记忆?": "选择记忆模式",
|
||||
"需要明确选择一种方式;之后仍可在设置中更改。": "之后可在设置中更改。",
|
||||
记忆模式: "记忆模式",
|
||||
记忆模式与模型: "记忆模式与模型",
|
||||
推荐: "推荐",
|
||||
"使用模型理解对话,提取并整理可持续生长的记忆。":
|
||||
"使用模型提取事实、偏好和计划。",
|
||||
"无需模型,只保存并检索原始对话,不生成提炼记忆。":
|
||||
"无需模型。保存并搜索原始对话。",
|
||||
"基础记忆(lite)": "基础记忆(lite)",
|
||||
"不调用生成模型。保存并检索原始对话,不再生成高级记忆。":
|
||||
"保存并搜索原始对话,不生成高级记忆。",
|
||||
"智能记忆(pro)": "智能记忆(pro)",
|
||||
"使用当前模型从新对话中提取事实、偏好和计划。":
|
||||
"使用当前模型从新对话中提取事实、偏好和计划。",
|
||||
"深度记忆(ultra)": "深度记忆(ultra)",
|
||||
"在智能记忆之上使用知识图谱,并默认每天 03:00 自动整合记忆。":
|
||||
"使用知识图谱,每天自动整合记忆。",
|
||||
"lite 下已经处理完成的原始对话不会补提取;排队中和之后的新对话会生成高级记忆。":
|
||||
"lite 期间已完成的对话不会补提取;待处理和新对话会生成高级记忆。",
|
||||
"原始对话检索会停止,改为使用高级记忆。": "搜索将改为使用高级记忆。",
|
||||
"以前生成的高级记忆会继续保留,但在 lite 下不会显示或参与记忆召回。":
|
||||
"高级记忆会保留,但在 lite 下不显示、不参与召回。",
|
||||
"每天自动整合会停止,已经生成的数据不会删除。":
|
||||
"自动整合会停止,已有数据保留。",
|
||||
"已有高级记忆会在后台加入知识图谱,期间可以继续使用 MemHub。":
|
||||
"已有高级记忆将在后台加入知识图谱。",
|
||||
"知识图谱仍在补建,不会重复启动。": "知识图谱仍在补建,不会重复启动。",
|
||||
"记忆模式状态暂时无法读取,请重试。": "记忆模式状态暂时无法读取,请重试。",
|
||||
"使用 pro 或 ultra 前需要先配置并启用生成模型。":
|
||||
"使用 pro 或 ultra 前需要先配置并启用生成模型。",
|
||||
"正在检查或切换向量模型,完成后才能切换记忆模式。":
|
||||
"正在检查或切换向量模型,完成后才能切换记忆模式。",
|
||||
"当前安装缺少知识图谱组件,无法开启 ultra。":
|
||||
"当前安装缺少知识图谱组件,无法开启 ultra。",
|
||||
"模式切换未能确认,仍显示上次确认的模式。":
|
||||
"模式切换未能确认,仍显示上次确认的模式。",
|
||||
"自动整合设置未能保存,请重试。": "自动整合设置未能保存,请重试。",
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。":
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。",
|
||||
"未能开始整合记忆,请重试。": "未能开始整合记忆,请重试。",
|
||||
"模式决定新对话如何保存,以及记忆页面展示什么。":
|
||||
"决定新对话的处理方式和记忆页内容。",
|
||||
刷新记忆模式: "刷新记忆模式",
|
||||
当前模式: "当前模式",
|
||||
"开发环境当前强制使用 ultra,保存的选择暂不会生效。":
|
||||
"开发环境当前强制使用 ultra,保存的选择暂不会生效。",
|
||||
"正在检查或切换向量模型,完成前不能切换模式或手动整合。":
|
||||
"正在检查或切换向量模型,完成前不能切换模式或手动整合。",
|
||||
"正在把已有高级记忆加入知识图谱…": "正在把已有高级记忆加入知识图谱…",
|
||||
每天自动整合: "每天自动整合",
|
||||
"每天 {time} 自动运行 evolve,设备休眠错过后会补跑。":
|
||||
"每天 {time} 自动运行 evolve,设备休眠错过后会补跑。",
|
||||
自动整合时间: "自动整合时间",
|
||||
"使用这台设备的本地时间。": "使用这台设备的本地时间。",
|
||||
立即整合记忆: "立即整合记忆",
|
||||
"正在整合记忆…": "正在整合记忆…",
|
||||
"上次整合:{time}": "上次整合:{time}",
|
||||
尚未整合: "尚未整合",
|
||||
立即整合: "立即整合",
|
||||
"切换到 {mode}?": "切换到 {mode}?",
|
||||
"原始对话在所有模式下都会保留。": "原始对话在所有模式下都会保留。",
|
||||
切换模式: "切换模式",
|
||||
} as const;
|
||||
|
||||
export { modeChineseMessages };
|
||||
export type ModeMessageKey = keyof typeof modeChineseMessages;
|
||||
type ModeMessages = Record<ModeMessageKey, string>;
|
||||
|
||||
export const modeEnglishMessages: ModeMessages = {
|
||||
"模式未能保存。你的选择已保留,请重试。":
|
||||
"Could not save the mode. Your selection is retained; please retry.",
|
||||
选择记忆方式: "Choose how memory works",
|
||||
"最后一步 · 决定如何处理对话":
|
||||
"Final step · Choose how conversations are handled",
|
||||
返回导入: "Back to import",
|
||||
继续: "Continue",
|
||||
"正在保存…": "Saving…",
|
||||
"当前模型已经启用,可以继续完成首次设置。":
|
||||
"The current model is already enabled. Continue to finish setup.",
|
||||
继续使用当前模型: "Continue with current model",
|
||||
"你希望 MemHub 如何形成记忆?": "How should MemHub create memories?",
|
||||
"需要明确选择一种方式;之后仍可在设置中更改。":
|
||||
"Choose one option. You can change it later in Settings.",
|
||||
记忆模式: "Memory mode",
|
||||
记忆模式与模型: "Memory mode and model",
|
||||
推荐: "Recommended",
|
||||
"使用模型理解对话,提取并整理可持续生长的记忆。":
|
||||
"Use a model to understand conversations and build memories that grow over time.",
|
||||
"无需模型,只保存并检索原始对话,不生成提炼记忆。":
|
||||
"Save and search original conversations without a model or generated memories.",
|
||||
"基础记忆(lite)": "Basic memory (lite)",
|
||||
"不调用生成模型。保存并检索原始对话,不再生成高级记忆。":
|
||||
"Save and search original conversations without using a generation model or creating advanced memories.",
|
||||
"智能记忆(pro)": "Smart memory (pro)",
|
||||
"使用当前模型从新对话中提取事实、偏好和计划。":
|
||||
"Use the current model to extract facts, preferences and plans from new conversations.",
|
||||
"深度记忆(ultra)": "Deep memory (ultra)",
|
||||
"在智能记忆之上使用知识图谱,并默认每天 03:00 自动整合记忆。":
|
||||
"Add a knowledge graph to smart memory and consolidate memories daily at 03:00 by default.",
|
||||
"lite 下已经处理完成的原始对话不会补提取;排队中和之后的新对话会生成高级记忆。":
|
||||
"Original conversations already processed in lite will not be extracted retroactively. Queued and new conversations will create advanced memories.",
|
||||
"原始对话检索会停止,改为使用高级记忆。":
|
||||
"Original-conversation search will stop and advanced memories will be used instead.",
|
||||
"以前生成的高级记忆会继续保留,但在 lite 下不会显示或参与记忆召回。":
|
||||
"Existing advanced memories will be kept, but lite will not display or recall them.",
|
||||
"每天自动整合会停止,已经生成的数据不会删除。":
|
||||
"Daily consolidation will stop. Existing data will not be deleted.",
|
||||
"已有高级记忆会在后台加入知识图谱,期间可以继续使用 MemHub。":
|
||||
"Existing advanced memories will be added to the knowledge graph in the background. You can keep using MemHub.",
|
||||
"知识图谱仍在补建,不会重复启动。":
|
||||
"The knowledge graph is still being built; it will not be started again.",
|
||||
"记忆模式状态暂时无法读取,请重试。":
|
||||
"Memory mode is temporarily unavailable. Please retry.",
|
||||
"使用 pro 或 ultra 前需要先配置并启用生成模型。":
|
||||
"Configure and enable a generation model before using pro or ultra.",
|
||||
"正在检查或切换向量模型,完成后才能切换记忆模式。":
|
||||
"The embedding model is being checked or changed. Wait for it to finish before switching memory modes.",
|
||||
"当前安装缺少知识图谱组件,无法开启 ultra。":
|
||||
"This installation lacks the knowledge graph component required for ultra.",
|
||||
"模式切换未能确认,仍显示上次确认的模式。":
|
||||
"The mode change could not be confirmed. The last confirmed mode is still shown.",
|
||||
"自动整合设置未能保存,请重试。":
|
||||
"Could not save automatic consolidation settings. Please retry.",
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。":
|
||||
"The embedding model is being checked or changed. Wait for it to finish before consolidating memories.",
|
||||
"未能开始整合记忆,请重试。":
|
||||
"Could not start memory consolidation. Please retry.",
|
||||
"模式决定新对话如何保存,以及记忆页面展示什么。":
|
||||
"The mode controls how new conversations are saved and what appears on the Memory page.",
|
||||
刷新记忆模式: "Refresh memory mode",
|
||||
当前模式: "Current mode",
|
||||
"开发环境当前强制使用 ultra,保存的选择暂不会生效。":
|
||||
"This development environment forces ultra. Saved selections will not take effect yet.",
|
||||
"正在检查或切换向量模型,完成前不能切换模式或手动整合。":
|
||||
"The embedding model is being checked or changed. Modes and manual consolidation are unavailable until it finishes.",
|
||||
"正在把已有高级记忆加入知识图谱…":
|
||||
"Adding existing advanced memories to the knowledge graph…",
|
||||
每天自动整合: "Daily automatic consolidation",
|
||||
"每天 {time} 自动运行 evolve,设备休眠错过后会补跑。":
|
||||
"Run evolve daily at {time}. Missed runs start after the device wakes.",
|
||||
自动整合时间: "Automatic consolidation time",
|
||||
"使用这台设备的本地时间。": "Uses this device's local time.",
|
||||
立即整合记忆: "Consolidate memories now",
|
||||
"正在整合记忆…": "Consolidating memories…",
|
||||
"上次整合:{time}": "Last consolidated: {time}",
|
||||
尚未整合: "Not yet consolidated",
|
||||
立即整合: "Consolidate now",
|
||||
"切换到 {mode}?": "Switch to {mode}?",
|
||||
"原始对话在所有模式下都会保留。":
|
||||
"Original conversations are kept in every mode.",
|
||||
切换模式: "Switch mode",
|
||||
};
|
||||
|
||||
export const modeSpanishMessages: ModeMessages = {
|
||||
"模式未能保存。你的选择已保留,请重试。":
|
||||
"No se pudo guardar el modo. Tu selección se conserva; reinténtalo.",
|
||||
选择记忆方式: "Elegir cómo funciona la memoria",
|
||||
"最后一步 · 决定如何处理对话":
|
||||
"Último paso · Decide cómo tratar las conversaciones",
|
||||
返回导入: "Volver a la importación",
|
||||
继续: "Continuar",
|
||||
"正在保存…": "Guardando…",
|
||||
"当前模型已经启用,可以继续完成首次设置。":
|
||||
"El modelo actual ya está activo. Continúa para terminar la configuración.",
|
||||
继续使用当前模型: "Continuar con el modelo actual",
|
||||
"你希望 MemHub 如何形成记忆?": "¿Cómo quieres que MemHub cree memorias?",
|
||||
"需要明确选择一种方式;之后仍可在设置中更改。":
|
||||
"Elige una opción. Podrás cambiarla después en Configuración.",
|
||||
记忆模式: "Modo de memoria",
|
||||
记忆模式与模型: "Modo de memoria y modelo",
|
||||
推荐: "Recomendado",
|
||||
"使用模型理解对话,提取并整理可持续生长的记忆。":
|
||||
"Usa un modelo para comprender las conversaciones y crear memorias que evolucionan con el tiempo.",
|
||||
"无需模型,只保存并检索原始对话,不生成提炼记忆。":
|
||||
"Guarda y busca las conversaciones originales sin modelo ni memorias generadas.",
|
||||
"基础记忆(lite)": "Memoria básica (lite)",
|
||||
"不调用生成模型。保存并检索原始对话,不再生成高级记忆。":
|
||||
"Guarda y busca las conversaciones originales sin usar un modelo generativo ni crear memorias avanzadas.",
|
||||
"智能记忆(pro)": "Memoria inteligente (pro)",
|
||||
"使用当前模型从新对话中提取事实、偏好和计划。":
|
||||
"Usa el modelo actual para extraer hechos, preferencias y planes de las conversaciones nuevas.",
|
||||
"深度记忆(ultra)": "Memoria profunda (ultra)",
|
||||
"在智能记忆之上使用知识图谱,并默认每天 03:00 自动整合记忆。":
|
||||
"Añade un grafo de conocimiento a la memoria inteligente y consolida las memorias cada día a las 03:00.",
|
||||
"lite 下已经处理完成的原始对话不会补提取;排队中和之后的新对话会生成高级记忆。":
|
||||
"Las conversaciones ya procesadas en lite no se extraerán de forma retroactiva. Las que estén en cola y las nuevas crearán memorias avanzadas.",
|
||||
"原始对话检索会停止,改为使用高级记忆。":
|
||||
"Se dejarán de buscar conversaciones originales y se usarán memorias avanzadas.",
|
||||
"以前生成的高级记忆会继续保留,但在 lite 下不会显示或参与记忆召回。":
|
||||
"Las memorias avanzadas existentes se conservarán, pero lite no las mostrará ni las usará en consultas.",
|
||||
"每天自动整合会停止,已经生成的数据不会删除。":
|
||||
"La consolidación diaria se detendrá. Los datos existentes no se eliminarán.",
|
||||
"已有高级记忆会在后台加入知识图谱,期间可以继续使用 MemHub。":
|
||||
"Las memorias avanzadas se añadirán al grafo en segundo plano. Puedes seguir usando MemHub.",
|
||||
"知识图谱仍在补建,不会重复启动。":
|
||||
"El grafo de conocimiento aún se está creando y no se iniciará de nuevo.",
|
||||
"记忆模式状态暂时无法读取,请重试。":
|
||||
"El modo de memoria no está disponible por ahora. Reinténtalo.",
|
||||
"使用 pro 或 ultra 前需要先配置并启用生成模型。":
|
||||
"Configura y activa un modelo generativo antes de usar pro o ultra.",
|
||||
"正在检查或切换向量模型,完成后才能切换记忆模式。":
|
||||
"Se está comprobando o cambiando el modelo de embeddings. Espera para cambiar el modo de memoria.",
|
||||
"当前安装缺少知识图谱组件,无法开启 ultra。":
|
||||
"Esta instalación no incluye el componente de grafo necesario para ultra.",
|
||||
"模式切换未能确认,仍显示上次确认的模式。":
|
||||
"No se pudo confirmar el cambio. Se sigue mostrando el último modo confirmado.",
|
||||
"自动整合设置未能保存,请重试。":
|
||||
"No se pudo guardar la consolidación automática. Reinténtalo.",
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。":
|
||||
"Se está comprobando o cambiando el modelo de embeddings. Espera para consolidar memorias.",
|
||||
"未能开始整合记忆,请重试。":
|
||||
"No se pudo iniciar la consolidación. Reinténtalo.",
|
||||
"模式决定新对话如何保存,以及记忆页面展示什么。":
|
||||
"El modo determina cómo se guardan las conversaciones nuevas y qué aparece en Memorias.",
|
||||
刷新记忆模式: "Actualizar modo de memoria",
|
||||
当前模式: "Modo actual",
|
||||
"开发环境当前强制使用 ultra,保存的选择暂不会生效。":
|
||||
"Este entorno de desarrollo fuerza ultra. La opción guardada aún no tendrá efecto.",
|
||||
"正在检查或切换向量模型,完成前不能切换模式或手动整合。":
|
||||
"Se está comprobando o cambiando el modelo de embeddings. No puedes cambiar de modo ni consolidar manualmente hasta que termine.",
|
||||
"正在把已有高级记忆加入知识图谱…":
|
||||
"Añadiendo memorias avanzadas al grafo de conocimiento…",
|
||||
每天自动整合: "Consolidación automática diaria",
|
||||
"每天 {time} 自动运行 evolve,设备休眠错过后会补跑。":
|
||||
"Ejecuta evolve cada día a las {time}. Si el dispositivo está suspendido, se ejecutará al reactivarse.",
|
||||
自动整合时间: "Hora de consolidación automática",
|
||||
"使用这台设备的本地时间。": "Usa la hora local de este dispositivo.",
|
||||
立即整合记忆: "Consolidar memorias ahora",
|
||||
"正在整合记忆…": "Consolidando memorias…",
|
||||
"上次整合:{time}": "Última consolidación: {time}",
|
||||
尚未整合: "Aún sin consolidar",
|
||||
立即整合: "Consolidar ahora",
|
||||
"切换到 {mode}?": "¿Cambiar a {mode}?",
|
||||
"原始对话在所有模式下都会保留。":
|
||||
"Las conversaciones originales se conservan en todos los modos.",
|
||||
切换模式: "Cambiar modo",
|
||||
};
|
||||
|
||||
export const modeTraditionalChineseMessages: ModeMessages = {
|
||||
...modeChineseMessages,
|
||||
"模式未能保存。你的选择已保留,请重试。":
|
||||
"模式無法儲存。你的選擇已保留,請重試。",
|
||||
选择记忆方式: "選擇記憶方式",
|
||||
"最后一步 · 决定如何处理对话": "最後一步 · 決定如何處理對話",
|
||||
返回导入: "返回匯入",
|
||||
继续: "繼續",
|
||||
"正在保存…": "儲存中…",
|
||||
"当前模型已经启用,可以继续完成首次设置。":
|
||||
"目前模型已啟用,可以繼續完成首次設定。",
|
||||
继续使用当前模型: "繼續使用目前模型",
|
||||
"你希望 MemHub 如何形成记忆?": "你希望 MemHub 如何形成記憶?",
|
||||
"需要明确选择一种方式;之后仍可在设置中更改。":
|
||||
"請選擇一種方式;之後仍可在設定中更改。",
|
||||
记忆模式: "記憶模式",
|
||||
记忆模式与模型: "記憶模式與模型",
|
||||
推荐: "推薦",
|
||||
"使用模型理解对话,提取并整理可持续生长的记忆。":
|
||||
"使用模型理解對話,擷取並整理可持續成長的記憶。",
|
||||
"无需模型,只保存并检索原始对话,不生成提炼记忆。":
|
||||
"不需模型,只儲存及搜尋原始對話,不產生提煉記憶。",
|
||||
"基础记忆(lite)": "基礎記憶(lite)",
|
||||
"不调用生成模型。保存并检索原始对话,不再生成高级记忆。":
|
||||
"不呼叫生成模型。儲存並搜尋原始對話,不再產生進階記憶。",
|
||||
"智能记忆(pro)": "智慧記憶(pro)",
|
||||
"使用当前模型从新对话中提取事实、偏好和计划。":
|
||||
"使用目前模型從新對話中擷取事實、偏好和計畫。",
|
||||
"深度记忆(ultra)": "深度記憶(ultra)",
|
||||
"在智能记忆之上使用知识图谱,并默认每天 03:00 自动整合记忆。":
|
||||
"在智慧記憶之上使用知識圖譜,並預設每天 03:00 自動整合記憶。",
|
||||
"lite 下已经处理完成的原始对话不会补提取;排队中和之后的新对话会生成高级记忆。":
|
||||
"lite 下已處理完成的原始對話不會補擷取;佇列中及之後的新對話會產生進階記憶。",
|
||||
"原始对话检索会停止,改为使用高级记忆。":
|
||||
"原始對話搜尋會停止,改為使用進階記憶。",
|
||||
"以前生成的高级记忆会继续保留,但在 lite 下不会显示或参与记忆召回。":
|
||||
"過去產生的進階記憶會保留,但在 lite 下不會顯示或參與記憶召回。",
|
||||
"每天自动整合会停止,已经生成的数据不会删除。":
|
||||
"每日自動整合會停止,已產生的資料不會刪除。",
|
||||
"已有高级记忆会在后台加入知识图谱,期间可以继续使用 MemHub。":
|
||||
"現有進階記憶會在背景加入知識圖譜,期間仍可繼續使用 MemHub。",
|
||||
"知识图谱仍在补建,不会重复启动。": "知識圖譜仍在補建,不會重複啟動。",
|
||||
"记忆模式状态暂时无法读取,请重试。": "暫時無法讀取記憶模式狀態,請重試。",
|
||||
"使用 pro 或 ultra 前需要先配置并启用生成模型。":
|
||||
"使用 pro 或 ultra 前,請先設定並啟用生成模型。",
|
||||
"正在检查或切换向量模型,完成后才能切换记忆模式。":
|
||||
"正在檢查或切換向量模型,完成後才能切換記憶模式。",
|
||||
"当前安装缺少知识图谱组件,无法开启 ultra。":
|
||||
"目前安裝缺少知識圖譜元件,無法啟用 ultra。",
|
||||
"模式切换未能确认,仍显示上次确认的模式。":
|
||||
"無法確認模式切換,仍顯示上次確認的模式。",
|
||||
"自动整合设置未能保存,请重试。": "自動整合設定無法儲存,請重試。",
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。":
|
||||
"正在檢查或切換向量模型,完成後才能整合記憶。",
|
||||
"未能开始整合记忆,请重试。": "無法開始整合記憶,請重試。",
|
||||
"模式决定新对话如何保存,以及记忆页面展示什么。":
|
||||
"模式決定新對話如何儲存,以及記憶頁面顯示的內容。",
|
||||
刷新记忆模式: "重新整理記憶模式",
|
||||
当前模式: "目前模式",
|
||||
"开发环境当前强制使用 ultra,保存的选择暂不会生效。":
|
||||
"開發環境目前強制使用 ultra,儲存的選擇暫時不會生效。",
|
||||
"正在检查或切换向量模型,完成前不能切换模式或手动整合。":
|
||||
"正在檢查或切換向量模型,完成前無法切換模式或手動整合。",
|
||||
"正在把已有高级记忆加入知识图谱…": "正在將現有進階記憶加入知識圖譜…",
|
||||
每天自动整合: "每日自動整合",
|
||||
"每天 {time} 自动运行 evolve,设备休眠错过后会补跑。":
|
||||
"每天 {time} 自動執行 evolve;裝置休眠錯過後會補跑。",
|
||||
自动整合时间: "自動整合時間",
|
||||
"使用这台设备的本地时间。": "使用這台裝置的本地時間。",
|
||||
立即整合记忆: "立即整合記憶",
|
||||
"正在整合记忆…": "正在整合記憶…",
|
||||
"上次整合:{time}": "上次整合:{time}",
|
||||
尚未整合: "尚未整合",
|
||||
立即整合: "立即整合",
|
||||
"切换到 {mode}?": "切換到 {mode}?",
|
||||
"原始对话在所有模式下都会保留。": "原始對話會在所有模式下保留。",
|
||||
切换模式: "切換模式",
|
||||
};
|
||||
|
||||
export const modeJapaneseMessages: ModeMessages = {
|
||||
...modeEnglishMessages,
|
||||
"模式未能保存。你的选择已保留,请重试。":
|
||||
"モードを保存できませんでした。選択内容は保持されています。もう一度お試しください。",
|
||||
选择记忆方式: "記憶方法を選択",
|
||||
"最后一步 · 决定如何处理对话": "最後のステップ · 会話の処理方法を選択",
|
||||
返回导入: "インポートに戻る",
|
||||
继续: "続ける",
|
||||
"正在保存…": "保存中…",
|
||||
"当前模型已经启用,可以继续完成首次设置。":
|
||||
"現在のモデルはすでに有効です。このまま初期設定を完了できます。",
|
||||
继续使用当前模型: "現在のモデルで続ける",
|
||||
"你希望 MemHub 如何形成记忆?": "MemHub でどのように記憶を作成しますか?",
|
||||
"需要明确选择一种方式;之后仍可在设置中更改。":
|
||||
"いずれかを選択してください。後から設定で変更できます。",
|
||||
记忆模式: "記憶モード",
|
||||
记忆模式与模型: "記憶モードとモデル",
|
||||
推荐: "おすすめ",
|
||||
"使用模型理解对话,提取并整理可持续生长的记忆。":
|
||||
"モデルで会話を理解し、継続的に成長する記憶として整理します。",
|
||||
"无需模型,只保存并检索原始对话,不生成提炼记忆。":
|
||||
"モデルを使わず、元の会話の保存と検索のみを行います。",
|
||||
"基础记忆(lite)": "基本記憶(lite)",
|
||||
"智能记忆(pro)": "スマート記憶(pro)",
|
||||
"深度记忆(ultra)": "高度な記憶(ultra)",
|
||||
"模式决定新对话如何保存,以及记忆页面展示什么。":
|
||||
"モードによって、新しい会話の保存方法と記憶ページの表示内容が決まります。",
|
||||
刷新记忆模式: "記憶モードを更新",
|
||||
当前模式: "現在のモード",
|
||||
每天自动整合: "毎日自動で統合",
|
||||
自动整合时间: "自動統合の時刻",
|
||||
"使用这台设备的本地时间。": "このデバイスの現地時刻を使用します。",
|
||||
立即整合记忆: "今すぐ記憶を統合",
|
||||
"正在整合记忆…": "記憶を統合中…",
|
||||
"上次整合:{time}": "前回の統合:{time}",
|
||||
尚未整合: "まだ統合されていません",
|
||||
立即整合: "今すぐ統合",
|
||||
"切换到 {mode}?": "{mode} に切り替えますか?",
|
||||
"原始对话在所有模式下都会保留。": "元の会話はどのモードでも保持されます。",
|
||||
切换模式: "モードを切り替える",
|
||||
"不调用生成模型。保存并检索原始对话,不再生成高级记忆。":
|
||||
"生成モデルを使わず、元の会話を保存・検索します。高度な記憶は生成しません。",
|
||||
"使用当前模型从新对话中提取事实、偏好和计划。":
|
||||
"現在のモデルで新しい会話から事実、好み、予定を抽出します。",
|
||||
"在智能记忆之上使用知识图谱,并默认每天 03:00 自动整合记忆。":
|
||||
"スマート記憶に知識グラフを追加し、既定では毎日 03:00 に記憶を自動統合します。",
|
||||
"lite 下已经处理完成的原始对话不会补提取;排队中和之后的新对话会生成高级记忆。":
|
||||
"lite で処理済みの会話はさかのぼって抽出されません。待機中および今後の会話からは高度な記憶が生成されます。",
|
||||
"原始对话检索会停止,改为使用高级记忆。":
|
||||
"元の会話の検索を停止し、代わりに高度な記憶を使用します。",
|
||||
"以前生成的高级记忆会继续保留,但在 lite 下不会显示或参与记忆召回。":
|
||||
"既存の高度な記憶は保持されますが、lite では表示も参照もされません。",
|
||||
"每天自动整合会停止,已经生成的数据不会删除。":
|
||||
"毎日の自動統合は停止します。既存のデータは削除されません。",
|
||||
"已有高级记忆会在后台加入知识图谱,期间可以继续使用 MemHub。":
|
||||
"既存の高度な記憶はバックグラウンドで知識グラフに追加されます。その間も MemHub を使用できます。",
|
||||
"知识图谱仍在补建,不会重复启动。":
|
||||
"知識グラフは引き続き構築中です。重複して開始されません。",
|
||||
"记忆模式状态暂时无法读取,请重试。":
|
||||
"記憶モードの状態を読み込めません。もう一度お試しください。",
|
||||
"使用 pro 或 ultra 前需要先配置并启用生成模型。":
|
||||
"pro または ultra を使う前に、生成モデルを設定して有効にしてください。",
|
||||
"正在检查或切换向量模型,完成后才能切换记忆模式。":
|
||||
"埋め込みモデルを確認または切り替えています。完了後に記憶モードを変更できます。",
|
||||
"当前安装缺少知识图谱组件,无法开启 ultra。":
|
||||
"この環境には知識グラフのコンポーネントがないため、ultra を有効にできません。",
|
||||
"模式切换未能确认,仍显示上次确认的模式。":
|
||||
"モード変更を確認できませんでした。最後に確認されたモードを表示しています。",
|
||||
"自动整合设置未能保存,请重试。":
|
||||
"自動統合の設定を保存できませんでした。もう一度お試しください。",
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。":
|
||||
"埋め込みモデルを確認または切り替えています。完了後に記憶を統合できます。",
|
||||
"未能开始整合记忆,请重试。":
|
||||
"記憶の統合を開始できませんでした。もう一度お試しください。",
|
||||
"开发环境当前强制使用 ultra,保存的选择暂不会生效。":
|
||||
"この開発環境では ultra が強制されています。保存した選択はまだ反映されません。",
|
||||
"正在检查或切换向量模型,完成前不能切换模式或手动整合。":
|
||||
"埋め込みモデルを確認または切り替えています。完了するまでモード変更と手動統合はできません。",
|
||||
"正在把已有高级记忆加入知识图谱…":
|
||||
"既存の高度な記憶を知識グラフに追加しています…",
|
||||
"每天 {time} 自动运行 evolve,设备休眠错过后会补跑。":
|
||||
"毎日 {time} に evolve を実行します。スリープ中に実行できなかった場合は、復帰後に実行します。",
|
||||
};
|
||||
|
||||
export const modeGermanMessages: ModeMessages = {
|
||||
...modeEnglishMessages,
|
||||
"模式未能保存。你的选择已保留,请重试。":
|
||||
"Der Modus konnte nicht gespeichert werden. Ihre Auswahl bleibt erhalten. Bitte erneut versuchen.",
|
||||
选择记忆方式: "Erinnerungsmodus wählen",
|
||||
"最后一步 · 决定如何处理对话":
|
||||
"Letzter Schritt · Verarbeitung der Unterhaltungen wählen",
|
||||
返回导入: "Zurück zum Import",
|
||||
继续: "Weiter",
|
||||
"正在保存…": "Wird gespeichert…",
|
||||
"当前模型已经启用,可以继续完成首次设置。":
|
||||
"Das aktuelle Modell ist bereits aktiviert. Fahre fort, um die Einrichtung abzuschließen.",
|
||||
继续使用当前模型: "Mit aktuellem Modell fortfahren",
|
||||
"你希望 MemHub 如何形成记忆?": "Wie soll MemHub Erinnerungen erstellen?",
|
||||
"需要明确选择一种方式;之后仍可在设置中更改。":
|
||||
"Wählen Sie eine Option. Sie können sie später in den Einstellungen ändern.",
|
||||
记忆模式: "Erinnerungsmodus",
|
||||
记忆模式与模型: "Erinnerungsmodus und Modell",
|
||||
推荐: "Empfohlen",
|
||||
"使用模型理解对话,提取并整理可持续生长的记忆。":
|
||||
"Ein Modell versteht Unterhaltungen und erstellt daraus Erinnerungen, die sich weiterentwickeln.",
|
||||
"无需模型,只保存并检索原始对话,不生成提炼记忆。":
|
||||
"Originalunterhaltungen ohne Modell speichern und durchsuchen; keine abgeleiteten Erinnerungen.",
|
||||
"基础记忆(lite)": "Basiserinnerung (lite)",
|
||||
"智能记忆(pro)": "Intelligente Erinnerung (pro)",
|
||||
"深度记忆(ultra)": "Tiefe Erinnerung (ultra)",
|
||||
"模式决定新对话如何保存,以及记忆页面展示什么。":
|
||||
"Der Modus legt fest, wie neue Unterhaltungen gespeichert werden und was auf der Erinnerungsseite erscheint.",
|
||||
刷新记忆模式: "Erinnerungsmodus aktualisieren",
|
||||
当前模式: "Aktueller Modus",
|
||||
每天自动整合: "Täglich automatisch konsolidieren",
|
||||
自动整合时间: "Zeit der automatischen Konsolidierung",
|
||||
"使用这台设备的本地时间。": "Verwendet die lokale Zeit dieses Geräts.",
|
||||
立即整合记忆: "Erinnerungen jetzt konsolidieren",
|
||||
"正在整合记忆…": "Erinnerungen werden konsolidiert…",
|
||||
"上次整合:{time}": "Zuletzt konsolidiert: {time}",
|
||||
尚未整合: "Noch nicht konsolidiert",
|
||||
立即整合: "Jetzt konsolidieren",
|
||||
"切换到 {mode}?": "Zu {mode} wechseln?",
|
||||
"原始对话在所有模式下都会保留。":
|
||||
"Originalunterhaltungen bleiben in jedem Modus erhalten.",
|
||||
切换模式: "Modus wechseln",
|
||||
"不调用生成模型。保存并检索原始对话,不再生成高级记忆。":
|
||||
"Originalunterhaltungen ohne generatives Modell speichern und durchsuchen. Es werden keine erweiterten Erinnerungen erstellt.",
|
||||
"使用当前模型从新对话中提取事实、偏好和计划。":
|
||||
"Mit dem aktuellen Modell Fakten, Vorlieben und Pläne aus neuen Unterhaltungen extrahieren.",
|
||||
"在智能记忆之上使用知识图谱,并默认每天 03:00 自动整合记忆。":
|
||||
"Ergänzt die intelligente Erinnerung um einen Wissensgraphen und konsolidiert Erinnerungen standardmäßig täglich um 03:00 Uhr.",
|
||||
"lite 下已经处理完成的原始对话不会补提取;排队中和之后的新对话会生成高级记忆。":
|
||||
"Bereits in lite verarbeitete Unterhaltungen werden nicht nachträglich extrahiert. Wartende und neue Unterhaltungen erzeugen erweiterte Erinnerungen.",
|
||||
"原始对话检索会停止,改为使用高级记忆。":
|
||||
"Die Suche in Originalunterhaltungen wird beendet; stattdessen werden erweiterte Erinnerungen verwendet.",
|
||||
"以前生成的高级记忆会继续保留,但在 lite 下不会显示或参与记忆召回。":
|
||||
"Vorhandene erweiterte Erinnerungen bleiben erhalten, werden in lite aber weder angezeigt noch abgerufen.",
|
||||
"每天自动整合会停止,已经生成的数据不会删除。":
|
||||
"Die tägliche Konsolidierung wird beendet. Vorhandene Daten werden nicht gelöscht.",
|
||||
"已有高级记忆会在后台加入知识图谱,期间可以继续使用 MemHub。":
|
||||
"Vorhandene erweiterte Erinnerungen werden im Hintergrund dem Wissensgraphen hinzugefügt. MemHub bleibt nutzbar.",
|
||||
"知识图谱仍在补建,不会重复启动。":
|
||||
"Der Wissensgraph wird noch aufgebaut und nicht erneut gestartet.",
|
||||
"记忆模式状态暂时无法读取,请重试。":
|
||||
"Der Erinnerungsmodus ist vorübergehend nicht verfügbar. Bitte erneut versuchen.",
|
||||
"使用 pro 或 ultra 前需要先配置并启用生成模型。":
|
||||
"Konfigurieren und aktivieren Sie ein generatives Modell, bevor Sie pro oder ultra verwenden.",
|
||||
"正在检查或切换向量模型,完成后才能切换记忆模式。":
|
||||
"Das Embedding-Modell wird geprüft oder gewechselt. Danach kann der Erinnerungsmodus geändert werden.",
|
||||
"当前安装缺少知识图谱组件,无法开启 ultra。":
|
||||
"Dieser Installation fehlt die für ultra benötigte Wissensgraph-Komponente.",
|
||||
"模式切换未能确认,仍显示上次确认的模式。":
|
||||
"Der Moduswechsel konnte nicht bestätigt werden. Der zuletzt bestätigte Modus wird weiterhin angezeigt.",
|
||||
"自动整合设置未能保存,请重试。":
|
||||
"Die Einstellungen zur automatischen Konsolidierung konnten nicht gespeichert werden. Bitte erneut versuchen.",
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。":
|
||||
"Das Embedding-Modell wird geprüft oder gewechselt. Danach können Erinnerungen konsolidiert werden.",
|
||||
"未能开始整合记忆,请重试。":
|
||||
"Die Konsolidierung konnte nicht gestartet werden. Bitte erneut versuchen.",
|
||||
"开发环境当前强制使用 ultra,保存的选择暂不会生效。":
|
||||
"Diese Entwicklungsumgebung erzwingt ultra. Die gespeicherte Auswahl wird noch nicht angewendet.",
|
||||
"正在检查或切换向量模型,完成前不能切换模式或手动整合。":
|
||||
"Das Embedding-Modell wird geprüft oder gewechselt. Bis zum Abschluss sind Moduswechsel und manuelle Konsolidierung nicht verfügbar.",
|
||||
"正在把已有高级记忆加入知识图谱…":
|
||||
"Vorhandene erweiterte Erinnerungen werden dem Wissensgraphen hinzugefügt…",
|
||||
"每天 {time} 自动运行 evolve,设备休眠错过后会补跑。":
|
||||
"evolve wird täglich um {time} ausgeführt. Verpasste Läufe starten nach dem Aufwachen des Geräts.",
|
||||
};
|
||||
|
||||
export const modePortugueseMessages: ModeMessages = {
|
||||
...modeEnglishMessages,
|
||||
"模式未能保存。你的选择已保留,请重试。":
|
||||
"Não foi possível guardar o modo. A sua escolha foi mantida; tente novamente.",
|
||||
选择记忆方式: "Escolher o modo de memória",
|
||||
"最后一步 · 决定如何处理对话":
|
||||
"Último passo · Decida como tratar as conversas",
|
||||
返回导入: "Voltar à importação",
|
||||
继续: "Continuar",
|
||||
"正在保存…": "A guardar…",
|
||||
"当前模型已经启用,可以继续完成首次设置。":
|
||||
"O modelo atual já está ativo. Continua para concluir a configuração.",
|
||||
继续使用当前模型: "Continuar com o modelo atual",
|
||||
"你希望 MemHub 如何形成记忆?": "Como deve o MemHub criar memórias?",
|
||||
"需要明确选择一种方式;之后仍可在设置中更改。":
|
||||
"Escolha uma opção. Pode alterá-la mais tarde nas Definições.",
|
||||
记忆模式: "Modo de memória",
|
||||
记忆模式与模型: "Modo de memória e modelo",
|
||||
推荐: "Recomendado",
|
||||
"使用模型理解对话,提取并整理可持续生长的记忆。":
|
||||
"Use um modelo para compreender conversas e criar memórias que evoluem com o tempo.",
|
||||
"无需模型,只保存并检索原始对话,不生成提炼记忆。":
|
||||
"Guarde e pesquise conversas originais sem modelo nem memórias geradas.",
|
||||
"基础记忆(lite)": "Memória básica (lite)",
|
||||
"智能记忆(pro)": "Memória inteligente (pro)",
|
||||
"深度记忆(ultra)": "Memória profunda (ultra)",
|
||||
"模式决定新对话如何保存,以及记忆页面展示什么。":
|
||||
"O modo determina como as novas conversas são guardadas e o que aparece na página Memórias.",
|
||||
刷新记忆模式: "Atualizar modo de memória",
|
||||
当前模式: "Modo atual",
|
||||
每天自动整合: "Consolidação automática diária",
|
||||
自动整合时间: "Hora da consolidação automática",
|
||||
"使用这台设备的本地时间。": "Usa a hora local deste dispositivo.",
|
||||
立即整合记忆: "Consolidar memórias agora",
|
||||
"正在整合记忆…": "A consolidar memórias…",
|
||||
"上次整合:{time}": "Última consolidação: {time}",
|
||||
尚未整合: "Ainda não consolidado",
|
||||
立即整合: "Consolidar agora",
|
||||
"切换到 {mode}?": "Mudar para {mode}?",
|
||||
"原始对话在所有模式下都会保留。":
|
||||
"As conversas originais são mantidas em todos os modos.",
|
||||
切换模式: "Mudar de modo",
|
||||
"不调用生成模型。保存并检索原始对话,不再生成高级记忆。":
|
||||
"Guarda e pesquisa conversas originais sem usar um modelo generativo nem criar memórias avançadas.",
|
||||
"使用当前模型从新对话中提取事实、偏好和计划。":
|
||||
"Usa o modelo atual para extrair factos, preferências e planos de novas conversas.",
|
||||
"在智能记忆之上使用知识图谱,并默认每天 03:00 自动整合记忆。":
|
||||
"Acrescenta um grafo de conhecimento à memória inteligente e consolida memórias diariamente às 03:00 por predefinição.",
|
||||
"lite 下已经处理完成的原始对话不会补提取;排队中和之后的新对话会生成高级记忆。":
|
||||
"As conversas já processadas em lite não serão extraídas retroativamente. As que estão em fila e as novas criarão memórias avançadas.",
|
||||
"原始对话检索会停止,改为使用高级记忆。":
|
||||
"A pesquisa em conversas originais será interrompida e passará a usar memórias avançadas.",
|
||||
"以前生成的高级记忆会继续保留,但在 lite 下不会显示或参与记忆召回。":
|
||||
"As memórias avançadas existentes serão mantidas, mas o lite não as mostrará nem usará na recuperação.",
|
||||
"每天自动整合会停止,已经生成的数据不会删除。":
|
||||
"A consolidação diária será interrompida. Os dados existentes não serão eliminados.",
|
||||
"已有高级记忆会在后台加入知识图谱,期间可以继续使用 MemHub。":
|
||||
"As memórias avançadas existentes serão adicionadas ao grafo em segundo plano. Pode continuar a usar o MemHub.",
|
||||
"知识图谱仍在补建,不会重复启动。":
|
||||
"O grafo de conhecimento ainda está a ser criado e não será iniciado novamente.",
|
||||
"记忆模式状态暂时无法读取,请重试。":
|
||||
"O modo de memória está temporariamente indisponível. Tente novamente.",
|
||||
"使用 pro 或 ultra 前需要先配置并启用生成模型。":
|
||||
"Configure e ative um modelo generativo antes de usar pro ou ultra.",
|
||||
"正在检查或切换向量模型,完成后才能切换记忆模式。":
|
||||
"O modelo de embeddings está a ser verificado ou alterado. Aguarde para mudar o modo de memória.",
|
||||
"当前安装缺少知识图谱组件,无法开启 ultra。":
|
||||
"Esta instalação não inclui o componente de grafo necessário para ultra.",
|
||||
"模式切换未能确认,仍显示上次确认的模式。":
|
||||
"Não foi possível confirmar a mudança. Continua a ser mostrado o último modo confirmado.",
|
||||
"自动整合设置未能保存,请重试。":
|
||||
"Não foi possível guardar a consolidação automática. Tente novamente.",
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。":
|
||||
"O modelo de embeddings está a ser verificado ou alterado. Aguarde para consolidar memórias.",
|
||||
"未能开始整合记忆,请重试。":
|
||||
"Não foi possível iniciar a consolidação. Tente novamente.",
|
||||
"开发环境当前强制使用 ultra,保存的选择暂不会生效。":
|
||||
"Este ambiente de desenvolvimento força ultra. A opção guardada ainda não terá efeito.",
|
||||
"正在检查或切换向量模型,完成前不能切换模式或手动整合。":
|
||||
"O modelo de embeddings está a ser verificado ou alterado. Não pode mudar de modo nem consolidar manualmente até terminar.",
|
||||
"正在把已有高级记忆加入知识图谱…":
|
||||
"A adicionar memórias avançadas ao grafo de conhecimento…",
|
||||
"每天 {time} 自动运行 evolve,设备休眠错过后会补跑。":
|
||||
"Executa evolve diariamente às {time}. Se o dispositivo estiver suspenso, será executado ao reativar.",
|
||||
};
|
||||
|
||||
export const modeFrenchMessages: ModeMessages = {
|
||||
...modeEnglishMessages,
|
||||
"模式未能保存。你的选择已保留,请重试。":
|
||||
"Impossible d’enregistrer le mode. Votre choix est conservé. Réessayez.",
|
||||
选择记忆方式: "Choisir le mode de mémoire",
|
||||
"最后一步 · 决定如何处理对话":
|
||||
"Dernière étape · Choisissez le traitement des conversations",
|
||||
返回导入: "Retour à l’importation",
|
||||
继续: "Continuer",
|
||||
"正在保存…": "Enregistrement…",
|
||||
"当前模型已经启用,可以继续完成首次设置。":
|
||||
"Le modèle actuel est déjà activé. Continuez pour terminer la configuration.",
|
||||
继续使用当前模型: "Continuer avec le modèle actuel",
|
||||
"你希望 MemHub 如何形成记忆?": "Comment MemHub doit-il créer des mémoires ?",
|
||||
"需要明确选择一种方式;之后仍可在设置中更改。":
|
||||
"Choisissez une option. Vous pourrez la modifier plus tard dans les réglages.",
|
||||
记忆模式: "Mode de mémoire",
|
||||
记忆模式与模型: "Mode de mémoire et modèle",
|
||||
推荐: "Recommandé",
|
||||
"使用模型理解对话,提取并整理可持续生长的记忆。":
|
||||
"Utilisez un modèle pour comprendre les conversations et créer des mémoires qui évoluent.",
|
||||
"无需模型,只保存并检索原始对话,不生成提炼记忆。":
|
||||
"Enregistrez et recherchez les conversations originales sans modèle ni mémoire générée.",
|
||||
"基础记忆(lite)": "Mémoire de base (lite)",
|
||||
"智能记忆(pro)": "Mémoire intelligente (pro)",
|
||||
"深度记忆(ultra)": "Mémoire approfondie (ultra)",
|
||||
"模式决定新对话如何保存,以及记忆页面展示什么。":
|
||||
"Le mode détermine comment les nouvelles conversations sont enregistrées et ce qui apparaît dans Mémoires.",
|
||||
刷新记忆模式: "Actualiser le mode de mémoire",
|
||||
当前模式: "Mode actuel",
|
||||
每天自动整合: "Consolidation automatique quotidienne",
|
||||
自动整合时间: "Heure de consolidation automatique",
|
||||
"使用这台设备的本地时间。": "Utilise l’heure locale de cet appareil.",
|
||||
立即整合记忆: "Consolider les mémoires maintenant",
|
||||
"正在整合记忆…": "Consolidation des mémoires…",
|
||||
"上次整合:{time}": "Dernière consolidation : {time}",
|
||||
尚未整合: "Pas encore consolidé",
|
||||
立即整合: "Consolider maintenant",
|
||||
"切换到 {mode}?": "Passer à {mode} ?",
|
||||
"原始对话在所有模式下都会保留。":
|
||||
"Les conversations originales sont conservées dans tous les modes.",
|
||||
切换模式: "Changer de mode",
|
||||
"不调用生成模型。保存并检索原始对话,不再生成高级记忆。":
|
||||
"Enregistre et recherche les conversations originales sans modèle génératif ni création de mémoires avancées.",
|
||||
"使用当前模型从新对话中提取事实、偏好和计划。":
|
||||
"Utilise le modèle actuel pour extraire les faits, préférences et projets des nouvelles conversations.",
|
||||
"在智能记忆之上使用知识图谱,并默认每天 03:00 自动整合记忆。":
|
||||
"Ajoute un graphe de connaissances à la mémoire intelligente et consolide les mémoires chaque jour à 03:00 par défaut.",
|
||||
"lite 下已经处理完成的原始对话不会补提取;排队中和之后的新对话会生成高级记忆。":
|
||||
"Les conversations déjà traitées en lite ne seront pas extraites rétroactivement. Celles en attente et les nouvelles créeront des mémoires avancées.",
|
||||
"原始对话检索会停止,改为使用高级记忆。":
|
||||
"La recherche dans les conversations originales s’arrêtera au profit des mémoires avancées.",
|
||||
"以前生成的高级记忆会继续保留,但在 lite 下不会显示或参与记忆召回。":
|
||||
"Les mémoires avancées existantes seront conservées, mais lite ne les affichera ni ne les utilisera pour les recherches.",
|
||||
"每天自动整合会停止,已经生成的数据不会删除。":
|
||||
"La consolidation quotidienne s’arrêtera. Les données existantes ne seront pas supprimées.",
|
||||
"已有高级记忆会在后台加入知识图谱,期间可以继续使用 MemHub。":
|
||||
"Les mémoires avancées existantes seront ajoutées au graphe en arrière-plan. Vous pouvez continuer à utiliser MemHub.",
|
||||
"知识图谱仍在补建,不会重复启动。":
|
||||
"Le graphe de connaissances est toujours en cours de création et ne sera pas relancé.",
|
||||
"记忆模式状态暂时无法读取,请重试。":
|
||||
"Le mode de mémoire est temporairement indisponible. Réessayez.",
|
||||
"使用 pro 或 ultra 前需要先配置并启用生成模型。":
|
||||
"Configurez et activez un modèle génératif avant d’utiliser pro ou ultra.",
|
||||
"正在检查或切换向量模型,完成后才能切换记忆模式。":
|
||||
"Le modèle d’embeddings est en cours de vérification ou de changement. Patientez avant de changer de mode.",
|
||||
"当前安装缺少知识图谱组件,无法开启 ultra。":
|
||||
"Cette installation ne contient pas le composant de graphe requis pour ultra.",
|
||||
"模式切换未能确认,仍显示上次确认的模式。":
|
||||
"Le changement n’a pas pu être confirmé. Le dernier mode confirmé reste affiché.",
|
||||
"自动整合设置未能保存,请重试。":
|
||||
"Impossible d’enregistrer la consolidation automatique. Réessayez.",
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。":
|
||||
"Le modèle d’embeddings est en cours de vérification ou de changement. Patientez avant de consolider les mémoires.",
|
||||
"未能开始整合记忆,请重试。":
|
||||
"Impossible de lancer la consolidation. Réessayez.",
|
||||
"开发环境当前强制使用 ultra,保存的选择暂不会生效。":
|
||||
"Cet environnement de développement impose ultra. Le choix enregistré ne s’applique pas encore.",
|
||||
"正在检查或切换向量模型,完成前不能切换模式或手动整合。":
|
||||
"Le modèle d’embeddings est en cours de vérification ou de changement. Le changement de mode et la consolidation manuelle sont indisponibles jusqu’à la fin.",
|
||||
"正在把已有高级记忆加入知识图谱…":
|
||||
"Ajout des mémoires avancées au graphe de connaissances…",
|
||||
"每天 {time} 自动运行 evolve,设备休眠错过后会补跑。":
|
||||
"Exécute evolve chaque jour à {time}. Les exécutions manquées démarrent au réveil de l’appareil.",
|
||||
};
|
||||
|
||||
export const modeKoreanMessages: ModeMessages = {
|
||||
...modeEnglishMessages,
|
||||
"模式未能保存。你的选择已保留,请重试。":
|
||||
"모드를 저장하지 못했습니다. 선택은 유지됩니다. 다시 시도하세요.",
|
||||
选择记忆方式: "메모리 방식 선택",
|
||||
"最后一步 · 决定如何处理对话": "마지막 단계 · 대화 처리 방식 선택",
|
||||
返回导入: "가져오기로 돌아가기",
|
||||
继续: "계속",
|
||||
"正在保存…": "저장 중…",
|
||||
"当前模型已经启用,可以继续完成首次设置。":
|
||||
"현재 모델이 이미 활성화되어 있습니다. 계속해서 초기 설정을 완료하세요.",
|
||||
继续使用当前模型: "현재 모델로 계속",
|
||||
"你希望 MemHub 如何形成记忆?": "MemHub가 메모리를 어떻게 만들까요?",
|
||||
"需要明确选择一种方式;之后仍可在设置中更改。":
|
||||
"한 가지 방식을 선택하세요. 나중에 설정에서 변경할 수 있습니다.",
|
||||
记忆模式: "메모리 모드",
|
||||
记忆模式与模型: "메모리 모드 및 모델",
|
||||
推荐: "추천",
|
||||
"使用模型理解对话,提取并整理可持续生长的记忆。":
|
||||
"모델이 대화를 이해하고 시간이 지날수록 발전하는 메모리로 정리합니다.",
|
||||
"无需模型,只保存并检索原始对话,不生成提炼记忆。":
|
||||
"모델 없이 원본 대화만 저장하고 검색하며, 정리된 메모리는 만들지 않습니다.",
|
||||
"基础记忆(lite)": "기본 메모리(lite)",
|
||||
"智能记忆(pro)": "스마트 메모리(pro)",
|
||||
"深度记忆(ultra)": "심층 메모리(ultra)",
|
||||
"模式决定新对话如何保存,以及记忆页面展示什么。":
|
||||
"모드에 따라 새 대화의 저장 방식과 메모리 페이지의 표시 내용이 결정됩니다.",
|
||||
刷新记忆模式: "메모리 모드 새로고침",
|
||||
当前模式: "현재 모드",
|
||||
每天自动整合: "매일 자동 통합",
|
||||
自动整合时间: "자동 통합 시간",
|
||||
"使用这台设备的本地时间。": "이 기기의 현지 시간을 사용합니다.",
|
||||
立即整合记忆: "지금 메모리 통합",
|
||||
"正在整合记忆…": "메모리 통합 중…",
|
||||
"上次整合:{time}": "마지막 통합: {time}",
|
||||
尚未整合: "아직 통합되지 않음",
|
||||
立即整合: "지금 통합",
|
||||
"切换到 {mode}?": "{mode}(으)로 전환할까요?",
|
||||
"原始对话在所有模式下都会保留。": "원본 대화는 모든 모드에서 유지됩니다.",
|
||||
切换模式: "모드 전환",
|
||||
"不调用生成模型。保存并检索原始对话,不再生成高级记忆。":
|
||||
"생성 모델을 사용하지 않고 원본 대화를 저장하고 검색합니다. 고급 메모리는 만들지 않습니다.",
|
||||
"使用当前模型从新对话中提取事实、偏好和计划。":
|
||||
"현재 모델로 새 대화에서 사실, 선호도, 계획을 추출합니다.",
|
||||
"在智能记忆之上使用知识图谱,并默认每天 03:00 自动整合记忆。":
|
||||
"스마트 메모리에 지식 그래프를 추가하고 기본적으로 매일 03:00에 메모리를 자동 통합합니다.",
|
||||
"lite 下已经处理完成的原始对话不会补提取;排队中和之后的新对话会生成高级记忆。":
|
||||
"lite에서 이미 처리된 대화는 소급 추출되지 않습니다. 대기 중인 대화와 새 대화에서는 고급 메모리가 생성됩니다.",
|
||||
"原始对话检索会停止,改为使用高级记忆。":
|
||||
"원본 대화 검색을 중지하고 고급 메모리를 사용합니다.",
|
||||
"以前生成的高级记忆会继续保留,但在 lite 下不会显示或参与记忆召回。":
|
||||
"기존 고급 메모리는 유지되지만 lite에서는 표시되거나 검색에 사용되지 않습니다.",
|
||||
"每天自动整合会停止,已经生成的数据不会删除。":
|
||||
"매일 자동 통합이 중지됩니다. 기존 데이터는 삭제되지 않습니다.",
|
||||
"已有高级记忆会在后台加入知识图谱,期间可以继续使用 MemHub。":
|
||||
"기존 고급 메모리가 백그라운드에서 지식 그래프에 추가됩니다. 그동안 MemHub를 계속 사용할 수 있습니다.",
|
||||
"知识图谱仍在补建,不会重复启动。":
|
||||
"지식 그래프가 아직 구축 중이며 다시 시작되지 않습니다.",
|
||||
"记忆模式状态暂时无法读取,请重试。":
|
||||
"메모리 모드를 일시적으로 읽을 수 없습니다. 다시 시도하세요.",
|
||||
"使用 pro 或 ultra 前需要先配置并启用生成模型。":
|
||||
"pro 또는 ultra를 사용하기 전에 생성 모델을 구성하고 활성화하세요.",
|
||||
"正在检查或切换向量模型,完成后才能切换记忆模式。":
|
||||
"임베딩 모델을 확인하거나 전환 중입니다. 완료된 후 메모리 모드를 변경할 수 있습니다.",
|
||||
"当前安装缺少知识图谱组件,无法开启 ultra。":
|
||||
"현재 설치에 ultra에 필요한 지식 그래프 구성 요소가 없습니다.",
|
||||
"模式切换未能确认,仍显示上次确认的模式。":
|
||||
"모드 전환을 확인하지 못했습니다. 마지막으로 확인된 모드가 계속 표시됩니다.",
|
||||
"自动整合设置未能保存,请重试。":
|
||||
"자동 통합 설정을 저장하지 못했습니다. 다시 시도하세요.",
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。":
|
||||
"임베딩 모델을 확인하거나 전환 중입니다. 완료된 후 메모리를 통합할 수 있습니다.",
|
||||
"未能开始整合记忆,请重试。":
|
||||
"메모리 통합을 시작하지 못했습니다. 다시 시도하세요.",
|
||||
"开发环境当前强制使用 ultra,保存的选择暂不会生效。":
|
||||
"현재 개발 환경에서는 ultra가 강제됩니다. 저장한 선택은 아직 적용되지 않습니다.",
|
||||
"正在检查或切换向量模型,完成前不能切换模式或手动整合。":
|
||||
"임베딩 모델을 확인하거나 전환 중입니다. 완료될 때까지 모드 전환과 수동 통합을 사용할 수 없습니다.",
|
||||
"正在把已有高级记忆加入知识图谱…":
|
||||
"기존 고급 메모리를 지식 그래프에 추가하는 중…",
|
||||
"每天 {time} 自动运行 evolve,设备休眠错过后会补跑。":
|
||||
"매일 {time}에 evolve를 실행합니다. 절전 중 놓친 실행은 기기가 깨어난 후 시작됩니다.",
|
||||
};
|
||||
|
||||
export const modeRussianMessages: ModeMessages = {
|
||||
...modeEnglishMessages,
|
||||
"模式未能保存。你的选择已保留,请重试。":
|
||||
"Не удалось сохранить режим. Ваш выбор сохранён. Повторите попытку.",
|
||||
选择记忆方式: "Выберите режим памяти",
|
||||
"最后一步 · 决定如何处理对话":
|
||||
"Последний шаг · Выберите способ обработки диалогов",
|
||||
返回导入: "Назад к импорту",
|
||||
继续: "Продолжить",
|
||||
"正在保存…": "Сохранение…",
|
||||
"当前模型已经启用,可以继续完成首次设置。":
|
||||
"Текущая модель уже включена. Продолжите, чтобы завершить настройку.",
|
||||
继续使用当前模型: "Продолжить с текущей моделью",
|
||||
"你希望 MemHub 如何形成记忆?": "Как MemHub должен создавать воспоминания?",
|
||||
"需要明确选择一种方式;之后仍可在设置中更改。":
|
||||
"Выберите один вариант. Его можно изменить позже в настройках.",
|
||||
记忆模式: "Режим памяти",
|
||||
记忆模式与模型: "Режим памяти и модель",
|
||||
推荐: "Рекомендуется",
|
||||
"使用模型理解对话,提取并整理可持续生长的记忆。":
|
||||
"Модель анализирует диалоги и создаёт воспоминания, которые развиваются со временем.",
|
||||
"无需模型,只保存并检索原始对话,不生成提炼记忆。":
|
||||
"Сохраняйте и ищите исходные диалоги без модели и создания воспоминаний.",
|
||||
"基础记忆(lite)": "Базовая память (lite)",
|
||||
"智能记忆(pro)": "Интеллектуальная память (pro)",
|
||||
"深度记忆(ultra)": "Глубокая память (ultra)",
|
||||
"模式决定新对话如何保存,以及记忆页面展示什么。":
|
||||
"Режим определяет, как сохраняются новые диалоги и что отображается на странице памяти.",
|
||||
刷新记忆模式: "Обновить режим памяти",
|
||||
当前模式: "Текущий режим",
|
||||
每天自动整合: "Ежедневное автообъединение",
|
||||
自动整合时间: "Время автообъединения",
|
||||
"使用这台设备的本地时间。": "Используется местное время этого устройства.",
|
||||
立即整合记忆: "Объединить воспоминания сейчас",
|
||||
"正在整合记忆…": "Объединение воспоминаний…",
|
||||
"上次整合:{time}": "Последнее объединение: {time}",
|
||||
尚未整合: "Ещё не объединялись",
|
||||
立即整合: "Объединить сейчас",
|
||||
"切换到 {mode}?": "Переключиться на {mode}?",
|
||||
"原始对话在所有模式下都会保留。":
|
||||
"Исходные диалоги сохраняются во всех режимах.",
|
||||
切换模式: "Переключить режим",
|
||||
"不调用生成模型。保存并检索原始对话,不再生成高级记忆。":
|
||||
"Сохраняет и ищет исходные диалоги без генеративной модели. Расширенные воспоминания не создаются.",
|
||||
"使用当前模型从新对话中提取事实、偏好和计划。":
|
||||
"Использует текущую модель для извлечения фактов, предпочтений и планов из новых диалогов.",
|
||||
"在智能记忆之上使用知识图谱,并默认每天 03:00 自动整合记忆。":
|
||||
"Добавляет граф знаний к интеллектуальной памяти и по умолчанию объединяет воспоминания ежедневно в 03:00.",
|
||||
"lite 下已经处理完成的原始对话不会补提取;排队中和之后的新对话会生成高级记忆。":
|
||||
"Уже обработанные в lite диалоги не будут извлечены задним числом. Диалоги в очереди и новые диалоги создадут расширенные воспоминания.",
|
||||
"原始对话检索会停止,改为使用高级记忆。":
|
||||
"Поиск в исходных диалогах прекратится; вместо него будут использоваться расширенные воспоминания.",
|
||||
"以前生成的高级记忆会继续保留,但在 lite 下不会显示或参与记忆召回。":
|
||||
"Существующие расширенные воспоминания сохранятся, но lite не будет показывать или использовать их при поиске.",
|
||||
"每天自动整合会停止,已经生成的数据不会删除。":
|
||||
"Ежедневное объединение прекратится. Существующие данные не будут удалены.",
|
||||
"已有高级记忆会在后台加入知识图谱,期间可以继续使用 MemHub。":
|
||||
"Существующие расширенные воспоминания будут добавлены в граф знаний в фоновом режиме. MemHub останется доступен.",
|
||||
"知识图谱仍在补建,不会重复启动。":
|
||||
"Граф знаний ещё строится и не будет запущен повторно.",
|
||||
"记忆模式状态暂时无法读取,请重试。":
|
||||
"Режим памяти временно недоступен. Повторите попытку.",
|
||||
"使用 pro 或 ultra 前需要先配置并启用生成模型。":
|
||||
"Настройте и включите генеративную модель перед использованием pro или ultra.",
|
||||
"正在检查或切换向量模型,完成后才能切换记忆模式。":
|
||||
"Модель эмбеддингов проверяется или меняется. После завершения можно будет сменить режим памяти.",
|
||||
"当前安装缺少知识图谱组件,无法开启 ultra。":
|
||||
"В этой установке нет компонента графа знаний, необходимого для ultra.",
|
||||
"模式切换未能确认,仍显示上次确认的模式。":
|
||||
"Не удалось подтвердить смену режима. По-прежнему показан последний подтверждённый режим.",
|
||||
"自动整合设置未能保存,请重试。":
|
||||
"Не удалось сохранить настройки автообъединения. Повторите попытку.",
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。":
|
||||
"Модель эмбеддингов проверяется или меняется. После завершения можно будет объединить воспоминания.",
|
||||
"未能开始整合记忆,请重试。":
|
||||
"Не удалось начать объединение воспоминаний. Повторите попытку.",
|
||||
"开发环境当前强制使用 ultra,保存的选择暂不会生效。":
|
||||
"В этой среде разработки принудительно используется ultra. Сохранённый выбор пока не применяется.",
|
||||
"正在检查或切换向量模型,完成前不能切换模式或手动整合。":
|
||||
"Модель эмбеддингов проверяется или меняется. До завершения смена режима и ручное объединение недоступны.",
|
||||
"正在把已有高级记忆加入知识图谱…":
|
||||
"Расширенные воспоминания добавляются в граф знаний…",
|
||||
"每天 {time} 自动运行 evolve,设备休眠错过后会补跑。":
|
||||
"evolve запускается ежедневно в {time}. Пропущенный запуск начнётся после выхода устройства из сна.",
|
||||
};
|
||||
|
||||
export const modeIndonesianMessages: ModeMessages = {
|
||||
...modeEnglishMessages,
|
||||
"模式未能保存。你的选择已保留,请重试。":
|
||||
"Mode tidak dapat disimpan. Pilihan Anda tetap tersimpan; coba lagi.",
|
||||
选择记忆方式: "Pilih cara kerja memori",
|
||||
"最后一步 · 决定如何处理对话":
|
||||
"Langkah terakhir · Tentukan cara memproses percakapan",
|
||||
返回导入: "Kembali ke impor",
|
||||
继续: "Lanjutkan",
|
||||
"正在保存…": "Menyimpan…",
|
||||
"当前模型已经启用,可以继续完成首次设置。":
|
||||
"Model saat ini sudah aktif. Lanjutkan untuk menyelesaikan penyiapan.",
|
||||
继续使用当前模型: "Lanjutkan dengan model saat ini",
|
||||
"你希望 MemHub 如何形成记忆?": "Bagaimana MemHub membuat memori?",
|
||||
"需要明确选择一种方式;之后仍可在设置中更改。":
|
||||
"Pilih satu opsi. Anda dapat mengubahnya nanti di Pengaturan.",
|
||||
记忆模式: "Mode memori",
|
||||
记忆模式与模型: "Mode memori dan model",
|
||||
推荐: "Disarankan",
|
||||
"使用模型理解对话,提取并整理可持续生长的记忆。":
|
||||
"Gunakan model untuk memahami percakapan dan membuat memori yang terus berkembang.",
|
||||
"无需模型,只保存并检索原始对话,不生成提炼记忆。":
|
||||
"Simpan dan cari percakapan asli tanpa model atau memori hasil olahan.",
|
||||
"基础记忆(lite)": "Memori dasar (lite)",
|
||||
"智能记忆(pro)": "Memori cerdas (pro)",
|
||||
"深度记忆(ultra)": "Memori mendalam (ultra)",
|
||||
"模式决定新对话如何保存,以及记忆页面展示什么。":
|
||||
"Mode menentukan cara percakapan baru disimpan dan apa yang tampil di halaman Memori.",
|
||||
刷新记忆模式: "Muat ulang mode memori",
|
||||
当前模式: "Mode saat ini",
|
||||
每天自动整合: "Konsolidasi otomatis harian",
|
||||
自动整合时间: "Waktu konsolidasi otomatis",
|
||||
"使用这台设备的本地时间。": "Menggunakan waktu lokal perangkat ini.",
|
||||
立即整合记忆: "Konsolidasikan memori sekarang",
|
||||
"正在整合记忆…": "Mengonsolidasikan memori…",
|
||||
"上次整合:{time}": "Terakhir dikonsolidasikan: {time}",
|
||||
尚未整合: "Belum dikonsolidasikan",
|
||||
立即整合: "Konsolidasikan sekarang",
|
||||
"切换到 {mode}?": "Beralih ke {mode}?",
|
||||
"原始对话在所有模式下都会保留。": "Percakapan asli disimpan di semua mode.",
|
||||
切换模式: "Beralih mode",
|
||||
"不调用生成模型。保存并检索原始对话,不再生成高级记忆。":
|
||||
"Simpan dan cari percakapan asli tanpa model generatif. Memori lanjutan tidak dibuat.",
|
||||
"使用当前模型从新对话中提取事实、偏好和计划。":
|
||||
"Gunakan model saat ini untuk mengekstrak fakta, preferensi, dan rencana dari percakapan baru.",
|
||||
"在智能记忆之上使用知识图谱,并默认每天 03:00 自动整合记忆。":
|
||||
"Tambahkan knowledge graph ke memori cerdas dan konsolidasikan memori setiap hari pukul 03.00 secara default.",
|
||||
"lite 下已经处理完成的原始对话不会补提取;排队中和之后的新对话会生成高级记忆。":
|
||||
"Percakapan yang sudah diproses dalam lite tidak akan diekstrak ulang. Percakapan dalam antrean dan percakapan baru akan membuat memori lanjutan.",
|
||||
"原始对话检索会停止,改为使用高级记忆。":
|
||||
"Pencarian percakapan asli akan dihentikan dan diganti dengan memori lanjutan.",
|
||||
"以前生成的高级记忆会继续保留,但在 lite 下不会显示或参与记忆召回。":
|
||||
"Memori lanjutan yang ada tetap disimpan, tetapi lite tidak akan menampilkan atau menggunakannya dalam pencarian.",
|
||||
"每天自动整合会停止,已经生成的数据不会删除。":
|
||||
"Konsolidasi harian akan dihentikan. Data yang ada tidak akan dihapus.",
|
||||
"已有高级记忆会在后台加入知识图谱,期间可以继续使用 MemHub。":
|
||||
"Memori lanjutan yang ada akan ditambahkan ke knowledge graph di latar belakang. MemHub tetap dapat digunakan.",
|
||||
"知识图谱仍在补建,不会重复启动。":
|
||||
"Knowledge graph masih dibuat dan tidak akan dimulai ulang.",
|
||||
"记忆模式状态暂时无法读取,请重试。":
|
||||
"Mode memori untuk sementara tidak tersedia. Coba lagi.",
|
||||
"使用 pro 或 ultra 前需要先配置并启用生成模型。":
|
||||
"Konfigurasikan dan aktifkan model generatif sebelum menggunakan pro atau ultra.",
|
||||
"正在检查或切换向量模型,完成后才能切换记忆模式。":
|
||||
"Model embedding sedang diperiksa atau diganti. Tunggu hingga selesai sebelum mengganti mode memori.",
|
||||
"当前安装缺少知识图谱组件,无法开启 ultra。":
|
||||
"Instalasi ini tidak memiliki komponen knowledge graph yang diperlukan untuk ultra.",
|
||||
"模式切换未能确认,仍显示上次确认的模式。":
|
||||
"Perubahan mode tidak dapat dikonfirmasi. Mode terakhir yang dikonfirmasi tetap ditampilkan.",
|
||||
"自动整合设置未能保存,请重试。":
|
||||
"Pengaturan konsolidasi otomatis tidak dapat disimpan. Coba lagi.",
|
||||
"正在检查或切换向量模型,完成后才能整合记忆。":
|
||||
"Model embedding sedang diperiksa atau diganti. Tunggu hingga selesai sebelum mengonsolidasikan memori.",
|
||||
"未能开始整合记忆,请重试。":
|
||||
"Konsolidasi memori tidak dapat dimulai. Coba lagi.",
|
||||
"开发环境当前强制使用 ultra,保存的选择暂不会生效。":
|
||||
"Lingkungan pengembangan ini memaksa penggunaan ultra. Pilihan yang disimpan belum berlaku.",
|
||||
"正在检查或切换向量模型,完成前不能切换模式或手动整合。":
|
||||
"Model embedding sedang diperiksa atau diganti. Mode dan konsolidasi manual tidak tersedia hingga selesai.",
|
||||
"正在把已有高级记忆加入知识图谱…":
|
||||
"Menambahkan memori lanjutan ke knowledge graph…",
|
||||
"每天 {time} 自动运行 evolve,设备休眠错过后会补跑。":
|
||||
"Jalankan evolve setiap hari pukul {time}. Proses yang terlewat akan dimulai setelah perangkat aktif kembali.",
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type GuiLocale,
|
||||
isLanguagePreference,
|
||||
isLanguageSnapshot,
|
||||
isMessageKey,
|
||||
type LanguagePreference,
|
||||
type LanguageSnapshot,
|
||||
LOCALE_NAMES,
|
||||
@@ -227,4 +228,22 @@ describe("GUI messages", () => {
|
||||
}),
|
||||
).toBe("Provided to <img src=x onerror=alert(1)>$&{count}");
|
||||
});
|
||||
|
||||
it("translates mode copy interpolated before lookup", () => {
|
||||
const cases = [
|
||||
[
|
||||
"每天 03:00 自动运行 evolve,设备休眠错过后会补跑。",
|
||||
"Run evolve daily at 03:00.",
|
||||
],
|
||||
[
|
||||
"上次整合:11/3/2026, 4:00:00 PM",
|
||||
"Last consolidated: 11/3/2026, 4:00:00 PM",
|
||||
],
|
||||
["切换到 ultra?", "Switch to ultra?"],
|
||||
] as const;
|
||||
for (const [source, expected] of cases) {
|
||||
expect(isMessageKey(source)).toBe(true);
|
||||
expect(translate("en", source as MessageKey)).toContain(expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -163,7 +163,28 @@ export function resolveGuiLocale(
|
||||
}
|
||||
|
||||
export function isMessageKey(value: string): value is MessageKey {
|
||||
return Object.hasOwn(chineseMessages, value);
|
||||
return (
|
||||
Object.hasOwn(chineseMessages, value) || dynamicMessage(value) !== null
|
||||
);
|
||||
}
|
||||
|
||||
function dynamicMessage(
|
||||
value: string,
|
||||
): { key: MessageKey; params: Record<string, string> } | null {
|
||||
const patterns: readonly [RegExp, MessageKey, string][] = [
|
||||
[
|
||||
/^每天 (.+) 自动运行 evolve,设备休眠错过后会补跑。$/,
|
||||
"每天 {time} 自动运行 evolve,设备休眠错过后会补跑。",
|
||||
"time",
|
||||
],
|
||||
[/^上次整合:(.+)$/, "上次整合:{time}", "time"],
|
||||
[/^切换到 (lite|pro|ultra)?$/, "切换到 {mode}?", "mode"],
|
||||
];
|
||||
for (const [pattern, key, param] of patterns) {
|
||||
const match = pattern.exec(value);
|
||||
if (match?.[1]) return { key, params: { [param]: match[1] } };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function translate(
|
||||
@@ -171,9 +192,12 @@ export function translate(
|
||||
key: MessageKey,
|
||||
params?: Record<string, string | number>,
|
||||
): string {
|
||||
return translator.getFixedT(locale)(key, {
|
||||
const dynamic = dynamicMessage(key);
|
||||
const selected = dynamic?.key ?? key;
|
||||
return translator.getFixedT(locale)(selected, {
|
||||
...dynamic?.params,
|
||||
...params,
|
||||
defaultValue: chineseMessages[key],
|
||||
defaultValue: chineseMessages[selected],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { MessageKey } from "../gui-i18n.js";
|
||||
import { modeGermanMessages } from "../gui-i18n-mode.js";
|
||||
|
||||
/** German (de, formal Sie) GUI copy. Machine-assisted; native-speaker review recommended. */
|
||||
export const germanMessages: Record<MessageKey, string> = {
|
||||
...modeGermanMessages,
|
||||
"connectionFallback.title": "Andere Tools verbinden",
|
||||
"connectionFallback.desktopOnly":
|
||||
"Lokale Kopplung ist nur in der Desktop-App verfügbar.",
|
||||
@@ -80,6 +82,14 @@ export const germanMessages: Record<MessageKey, string> = {
|
||||
"sdk.raw.searchFailed":
|
||||
"Originalunterhaltungen konnten nicht durchsucht werden. Bitte wiederholen Sie den Vorgang.",
|
||||
"sdk.raw.recent": "Letzte Originalunterhaltungen",
|
||||
"sdk.raw.empty":
|
||||
"Noch keine Originalunterhaltungen. Verbinde eine Quelle, damit sie hier erscheinen.",
|
||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||
"Der Basisspeicher ist aktiv. Neue Unterhaltungen werden als Originale gespeichert und erzeugen keine erweiterten Erinnerungen. Vorhandene erweiterte Erinnerungen bleiben gespeichert, werden aber nicht angezeigt oder abgerufen.",
|
||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||
"Der intelligente Speicher ist aktiv. Das Modell verarbeitet neue Unterhaltungen zu erweiterten Erinnerungen.",
|
||||
"memory:当前使用深度记忆。新对话会生成高级记忆,并由知识图谱和 evolve 继续整合。":
|
||||
"Der tiefe Speicher ist aktiv. Neue Unterhaltungen erzeugen erweiterte Erinnerungen, die Wissensgraph und evolve weiter zusammenführen.",
|
||||
"sdk.raw.found": "{count} Ergebnisse",
|
||||
"sdk.raw.found.one": "{count} Ergebnis",
|
||||
"memoryInspector.emptyHint":
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { MessageKey } from "../gui-i18n.js";
|
||||
import { modeFrenchMessages } from "../gui-i18n-mode.js";
|
||||
|
||||
/** French (fr, vous) GUI copy. Machine-assisted; native-speaker review recommended. */
|
||||
export const frenchMessages: Record<MessageKey, string> = {
|
||||
...modeFrenchMessages,
|
||||
"connectionFallback.title": "Connecter d’autres outils",
|
||||
"connectionFallback.desktopOnly":
|
||||
"L’association locale n’est disponible que dans l’application de bureau.",
|
||||
@@ -81,6 +83,14 @@ export const frenchMessages: Record<MessageKey, string> = {
|
||||
"sdk.raw.searchFailed":
|
||||
"Impossible de rechercher dans les conversations originales. Veuillez réessayer.",
|
||||
"sdk.raw.recent": "Conversations originales récentes",
|
||||
"sdk.raw.empty":
|
||||
"Aucune conversation originale pour le moment. Connectez une source pour les afficher ici.",
|
||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||
"La mémoire de base est active. Les nouvelles conversations sont conservées telles quelles et ne créent pas de mémoires avancées. Les mémoires avancées existantes restent enregistrées, mais ne sont ni affichées ni rappelées.",
|
||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||
"La mémoire intelligente est active. Le modèle transforme les nouvelles conversations en mémoires avancées.",
|
||||
"memory:当前使用深度记忆。新对话会生成高级记忆,并由知识图谱和 evolve 继续整合。":
|
||||
"La mémoire approfondie est active. Les nouvelles conversations créent des mémoires avancées que le graphe de connaissances et evolve continuent de consolider.",
|
||||
"sdk.raw.found": "{count} résultats",
|
||||
"sdk.raw.found.one": "{count} résultat",
|
||||
"memoryInspector.emptyHint":
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { MessageKey } from "../gui-i18n.js";
|
||||
import { modeIndonesianMessages } from "../gui-i18n-mode.js";
|
||||
|
||||
/** Indonesian (id, Anda) GUI copy. Machine-assisted; native-speaker review recommended. */
|
||||
export const indonesianMessages: Record<MessageKey, string> = {
|
||||
...modeIndonesianMessages,
|
||||
"connectionFallback.title": "Hubungkan alat lain",
|
||||
"connectionFallback.desktopOnly":
|
||||
"Pemasangan lokal hanya tersedia di aplikasi desktop.",
|
||||
@@ -75,6 +77,14 @@ export const indonesianMessages: Record<MessageKey, string> = {
|
||||
"sdk.raw.searchFailed":
|
||||
"Tidak dapat mencari percakapan asli. Silakan coba lagi.",
|
||||
"sdk.raw.recent": "Percakapan asli terbaru",
|
||||
"sdk.raw.empty":
|
||||
"Belum ada percakapan asli. Hubungkan sumber agar percakapan tampil di sini.",
|
||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||
"Memori dasar aktif. Percakapan baru disimpan sebagai catatan asli dan tidak membuat memori lanjutan. Memori lanjutan yang ada tetap tersimpan, tetapi tidak ditampilkan atau digunakan untuk recall.",
|
||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||
"Memori cerdas aktif. Model mengolah percakapan baru menjadi memori lanjutan.",
|
||||
"memory:当前使用深度记忆。新对话会生成高级记忆,并由知识图谱和 evolve 继续整合。":
|
||||
"Memori mendalam aktif. Percakapan baru membuat memori lanjutan yang terus dikonsolidasikan oleh knowledge graph dan evolve.",
|
||||
"sdk.raw.found": "{count} hasil",
|
||||
"sdk.raw.found.one": "{count} hasil",
|
||||
"memoryInspector.emptyHint": "Lihat sumber, isi, dan riwayat perubahannya.",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { MessageKey } from "../gui-i18n.js";
|
||||
import { modeJapaneseMessages } from "../gui-i18n-mode.js";
|
||||
|
||||
/** Japanese (ja) GUI copy. Machine-assisted; native-speaker review recommended. */
|
||||
export const japaneseMessages: Record<MessageKey, string> = {
|
||||
...modeJapaneseMessages,
|
||||
"connectionFallback.title": "ほかのツールを接続",
|
||||
"connectionFallback.desktopOnly":
|
||||
"ローカルのペアリングはデスクトップアプリでのみ利用できます。",
|
||||
@@ -77,6 +79,14 @@ export const japaneseMessages: Record<MessageKey, string> = {
|
||||
"sdk.raw.searchFailed":
|
||||
"元の会話を検索できませんでした。もう一度お試しください。",
|
||||
"sdk.raw.recent": "最近の元の会話",
|
||||
"sdk.raw.empty":
|
||||
"元の会話はまだありません。ソースを接続すると、ここに表示されます。",
|
||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||
"基本メモリを使用中です。新しい会話は原文として保存され、高度なメモリは生成されません。既存の高度なメモリは保持されますが、現在は表示も呼び出しもされません。",
|
||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||
"スマートメモリを使用中です。モデルが新しい会話を高度なメモリに整理します。",
|
||||
"memory:当前使用深度记忆。新对话会生成高级记忆,并由知识图谱和 evolve 继续整合。":
|
||||
"ディープメモリを使用中です。新しい会話から高度なメモリを生成し、知識グラフと evolve が統合を続けます。",
|
||||
"sdk.raw.found": "{count} 件見つかりました",
|
||||
"sdk.raw.found.one": "{count} 件見つかりました",
|
||||
"memoryInspector.emptyHint": "ソース、内容、変更履歴を確認できます。",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { MessageKey } from "../gui-i18n.js";
|
||||
import { modeKoreanMessages } from "../gui-i18n-mode.js";
|
||||
|
||||
/** Korean (ko) GUI copy. Machine-assisted; native-speaker review recommended. */
|
||||
export const koreanMessages: Record<MessageKey, string> = {
|
||||
...modeKoreanMessages,
|
||||
"connectionFallback.title": "다른 도구 연결",
|
||||
"connectionFallback.desktopOnly":
|
||||
"로컬 페어링은 데스크톱 앱에서만 사용할 수 있습니다.",
|
||||
@@ -74,6 +76,14 @@ export const koreanMessages: Record<MessageKey, string> = {
|
||||
"sdk.raw.loadFailed": "원본 대화를 일시적으로 불러올 수 없습니다.",
|
||||
"sdk.raw.searchFailed": "원본 대화를 검색하지 못했습니다. 다시 시도하세요.",
|
||||
"sdk.raw.recent": "최근 원본 대화",
|
||||
"sdk.raw.empty":
|
||||
"아직 원본 대화가 없습니다. 소스를 연결하면 여기에 표시됩니다.",
|
||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||
"기본 메모리를 사용 중입니다. 새 대화는 원본 기록으로 저장되며 고급 메모리를 만들지 않습니다. 기존 고급 메모리는 보존되지만 현재 표시되거나 회상에 사용되지 않습니다.",
|
||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||
"스마트 메모리를 사용 중입니다. 모델이 새 대화를 고급 메모리로 정리합니다.",
|
||||
"memory:当前使用深度记忆。新对话会生成高级记忆,并由知识图谱和 evolve 继续整合。":
|
||||
"심층 메모리를 사용 중입니다. 새 대화에서 고급 메모리를 만들고 지식 그래프와 evolve가 계속 통합합니다.",
|
||||
"sdk.raw.found": "결과 {count}개",
|
||||
"sdk.raw.found.one": "결과 {count}개",
|
||||
"memoryInspector.emptyHint": "출처, 내용, 변경 기록을 확인하세요.",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { MessageKey } from "../gui-i18n.js";
|
||||
import { modePortugueseMessages } from "../gui-i18n-mode.js";
|
||||
|
||||
/** Portuguese (pt, Brazilian usage) GUI copy. Machine-assisted; native-speaker review recommended. */
|
||||
export const portugueseMessages: Record<MessageKey, string> = {
|
||||
...modePortugueseMessages,
|
||||
"connectionFallback.title": "Conectar outras ferramentas",
|
||||
"connectionFallback.desktopOnly":
|
||||
"O pareamento local só está disponível no app para desktop.",
|
||||
@@ -80,6 +82,14 @@ export const portugueseMessages: Record<MessageKey, string> = {
|
||||
"sdk.raw.searchFailed":
|
||||
"Não foi possível pesquisar as conversas originais. Tente novamente.",
|
||||
"sdk.raw.recent": "Conversas originais recentes",
|
||||
"sdk.raw.empty":
|
||||
"Ainda não há conversas originais. Ligue uma origem para as ver aqui.",
|
||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||
"A memória básica está ativa. As novas conversas são guardadas como registos originais e não criam memórias avançadas. As memórias avançadas existentes continuam guardadas, mas não são mostradas nem usadas na recuperação.",
|
||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||
"A memória inteligente está ativa. O modelo transforma novas conversas em memórias avançadas.",
|
||||
"memory:当前使用深度记忆。新对话会生成高级记忆,并由知识图谱和 evolve 继续整合。":
|
||||
"A memória profunda está ativa. As novas conversas criam memórias avançadas que o grafo de conhecimento e o evolve continuam a consolidar.",
|
||||
"sdk.raw.found": "Resultados: {count}",
|
||||
"sdk.raw.found.one": "{count} resultado",
|
||||
"memoryInspector.emptyHint":
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { MessageKey } from "../gui-i18n.js";
|
||||
import { modeRussianMessages } from "../gui-i18n-mode.js";
|
||||
|
||||
/** Russian (ru) GUI copy. Machine-assisted; native-speaker review recommended. */
|
||||
export const russianMessages: Record<MessageKey, string> = {
|
||||
...modeRussianMessages,
|
||||
"connectionFallback.title": "Подключить другие инструменты",
|
||||
"connectionFallback.desktopOnly":
|
||||
"Локальное сопряжение доступно только в настольном приложении.",
|
||||
@@ -77,6 +79,14 @@ export const russianMessages: Record<MessageKey, string> = {
|
||||
"sdk.raw.searchFailed":
|
||||
"Не удалось выполнить поиск по исходным диалогам. Повторите попытку.",
|
||||
"sdk.raw.recent": "Недавние исходные диалоги",
|
||||
"sdk.raw.empty":
|
||||
"Исходных диалогов пока нет. Подключите источник, и они появятся здесь.",
|
||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||
"Включена базовая память. Новые диалоги сохраняются в исходном виде и не создают расширенные воспоминания. Существующие расширенные воспоминания сохраняются, но не показываются и не используются при поиске.",
|
||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||
"Включена интеллектуальная память. Модель преобразует новые диалоги в расширенные воспоминания.",
|
||||
"memory:当前使用深度记忆。新对话会生成高级记忆,并由知识图谱和 evolve 继续整合。":
|
||||
"Включена глубокая память. Новые диалоги создают расширенные воспоминания, которые продолжают объединяться графом знаний и evolve.",
|
||||
"sdk.raw.found": "Результатов: {count}",
|
||||
"sdk.raw.found.one": "{count} результат",
|
||||
"memoryInspector.emptyHint":
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { MessageKey } from "../gui-i18n.js";
|
||||
import { modeTraditionalChineseMessages } from "../gui-i18n-mode.js";
|
||||
|
||||
/** Traditional Chinese (zh-Hant, Taiwan usage) GUI copy. Machine-assisted; native-speaker review recommended. */
|
||||
export const traditionalChineseMessages: Record<MessageKey, string> = {
|
||||
...modeTraditionalChineseMessages,
|
||||
"connectionFallback.title": "連線其他工具",
|
||||
"connectionFallback.desktopOnly": "本機配對僅在桌面 App 中可用。",
|
||||
"connectionFallback.description":
|
||||
@@ -69,6 +71,13 @@ export const traditionalChineseMessages: Record<MessageKey, string> = {
|
||||
"sdk.raw.loadFailed": "原始對話暫時無法讀取。",
|
||||
"sdk.raw.searchFailed": "原始對話搜尋失敗,請重試。",
|
||||
"sdk.raw.recent": "最近原始對話",
|
||||
"sdk.raw.empty": "還沒有原始對話。連接來源後,對話會顯示在這裡。",
|
||||
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。":
|
||||
"目前使用基礎記憶。新對話會保留為原始記錄,不會產生進階記憶;過去的進階記憶仍會保留,但目前不會顯示或參與記憶召回。",
|
||||
"memory:当前使用智能记忆。新对话会由模型整理为高级记忆。":
|
||||
"目前使用智慧記憶。模型會把新對話整理為進階記憶。",
|
||||
"memory:当前使用深度记忆。新对话会生成高级记忆,并由知识图谱和 evolve 继续整合。":
|
||||
"目前使用深度記憶。新對話會產生進階記憶,並由知識圖譜與 evolve 持續整合。",
|
||||
"sdk.raw.found": "找到 {count} 則",
|
||||
"sdk.raw.found.one": "找到 {count} 則",
|
||||
"memoryInspector.emptyHint": "檢視它的來源、內容和修改歷史。",
|
||||
|
||||
@@ -36,7 +36,7 @@ MEMMY_LICENSE = (
|
||||
MEMMY_PROVENANCE = MEMMY_LICENSE.with_name("PROVENANCE.md")
|
||||
MEMMY_PIN = "25acd5f29fc7df73b943713abe61f1b2f9839887"
|
||||
HY_MEMORY_NAME = "hy-memory"
|
||||
HY_MEMORY_VERSION = "2.0.12"
|
||||
HY_MEMORY_VERSION = "2.0.13"
|
||||
|
||||
|
||||
class SbomError(RuntimeError):
|
||||
|
||||
@@ -12,7 +12,7 @@ test("installation docs disclose publication and lifecycle boundaries", async ()
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(readme, /`hy-memory==2\.0\.12`/);
|
||||
assert.match(readme, /`hy-memory==2\.0\.13`/);
|
||||
assert.match(readme, /`v1\.0\.6` 的发行目标是 Linux x64\/arm64 Runtime/);
|
||||
assert.match(readme, /Linux 制品由 GitHub Actions/);
|
||||
assert.match(readme, /macOS.*Developer ID 签名、公证/s);
|
||||
|
||||
@@ -13,7 +13,7 @@ spec.loader.exec_module(module)
|
||||
|
||||
|
||||
def test_hy_memory_notice_comes_from_the_installed_dist_info(tmp_path: Path) -> None:
|
||||
dist_info = tmp_path / "hy_memory-2.0.12.dist-info"
|
||||
dist_info = tmp_path / "hy_memory-2.0.13.dist-info"
|
||||
licenses = dist_info / "licenses"
|
||||
licenses.mkdir(parents=True)
|
||||
(dist_info / "METADATA").write_text(
|
||||
@@ -21,7 +21,7 @@ def test_hy_memory_notice_comes_from_the_installed_dist_info(tmp_path: Path) ->
|
||||
(
|
||||
"Metadata-Version: 2.4",
|
||||
"Name: hy-memory",
|
||||
"Version: 2.0.12",
|
||||
"Version: 2.0.13",
|
||||
"License-Expression: Apache-2.0",
|
||||
"",
|
||||
)
|
||||
@@ -38,7 +38,7 @@ def test_hy_memory_notice_comes_from_the_installed_dist_info(tmp_path: Path) ->
|
||||
notice = module.hy_memory_notice(dist_info)
|
||||
|
||||
assert notice["name"] == "hy-memory"
|
||||
assert notice["version"] == "2.0.12"
|
||||
assert notice["version"] == "2.0.13"
|
||||
assert notice["license"] == "Apache-2.0"
|
||||
assert notice["copyright"] == "Copyright (C) 2026 Tencent. All rights reserved."
|
||||
assert "Licensed under the Apache License" in notice["text"]
|
||||
|
||||
Reference in new issue
Block a user