Compare commits

..
Author SHA1 Message Date
alvinfeiandCursor e2907ed4ea docs(memhub): add app migration acceptance guide
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 15:15:57 +08:00
alvinfeiandCursor 48b2540eb5 fix(memhub): hash workspace identities without path expansion
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 15:01:59 +08:00
alvinfeiandCursor 0625b8f414 fix(memhub): close CodeQL exposure paths
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 14:53:26 +08:00
alvinfeiandCursor d6a95760e2 refactor(memhub): finish SDK authority cutover
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 14:31:32 +08:00
alvinfeiandCursor 65171074b2 docs(memhub): separate fresh schema from legacy cleanup
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 11:03:34 +08:00
alvinfeiandCursor 35620812df test(memhub): repair acceptance gate fixtures
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 10:56:33 +08:00
alvinfeiandCursor b562a75dd0 docs(memhub): publish sdk authority architecture
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 10:49:29 +08:00
alvinfeiandCursor 74608fa9d6 test(runtime): allow receipt write under parallel load
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 10:44:46 +08:00
alvinfeiandCursor 5e05af59e0 docs(memhub): document sdk matter and raw cutover
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 10:41:23 +08:00
alvinfeiandCursor 23572c31f9 refactor(memhub): retire remaining legacy read surfaces
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 10:36:17 +08:00
alvinfeiandCursor fefd28de5a refactor(memhub): stop legacy maintenance after cutover
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 10:30:51 +08:00
alvinfeiandCursor 24f6fb6e28 refactor(memhub): complete sdk raw and matter cutover
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 10:28:52 +08:00
alvinfeiandCursor 872e3710c5 refactor(memhub): retire legacy digest and boundary after cutover
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 10:20:13 +08:00
alvinfeiandCursor db94e24b3f refactor(memhub): discover matters from sdk authority
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 10:17:57 +08:00
alvinfeiandCursor 04f13cfeed refactor(memhub): queue sdk matter notes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 09:28:20 +08:00
alvinfeiandCursor e974e656ef refactor(memhub): switch matter reads to sdk references
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 09:22:26 +08:00
alvinfeiandCursor 415db57eb1 refactor(memhub): hydrate matters from sdk references
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 09:15:59 +08:00
alvinfeiandCursor 8c60e5c90a refactor(memhub): migrate matters to sdk references
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 09:13:05 +08:00
alvinfeiandCursor cbe4397d14 refactor(memhub): add sdk-native ask v2
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 09:02:58 +08:00
alvinfeiandCursor 04321a2425 refactor(memhub): resume sdk staging cleanup on startup
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 07:29:59 +08:00
alvinfeiandCursor 2a2a8047ef refactor(memhub): block cutover until product paths switch
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 07:27:05 +08:00
alvinfeiandCursor c7fc92c9c7 refactor(memhub): run sdk migration in app loading
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 07:26:00 +08:00
alvinfeiandCursor eb98f3c992 refactor(memhub): gate startup authority cutover
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 07:14:42 +08:00
alvinfeiandCursor 146413992d refactor(memhub): release transient evidence after sdk success
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 07:12:20 +08:00
alvinfeiandCursor 56f98b1889 refactor(memhub): fence startup migration and resume effects
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 07:09:07 +08:00
alvinfeiandCursor 6f81f3f22f refactor(memhub): stop governance mirroring after cutover
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 07:04:02 +08:00
alvinfeiandCursor 3a0752b5b3 refactor(memhub): persist sdk ids after queued add
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 07:00:16 +08:00
alvinfei a218d3c52c Revert "refactor(memhub): add sdk raw scheduling receipts"
This reverts commit d07ae91c93.
2026-10-21 21:47:22 +08:00
alvinfeiandCursor d07ae91c93 refactor(memhub): add sdk raw scheduling receipts
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 21:44:53 +08:00
alvinfeiandCursor 1e2c8baa78 docs(memhub): run migration during app startup
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 21:26:00 +08:00
alvinfeiandCursor 2163a013a4 refactor(memhub): cut host context over to sdk v2
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 21:17:18 +08:00
alvinfeiandCursor 00d7b41eae refactor(memhub): switch library to sdk authority
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 21:00:28 +08:00
alvinfeiandCursor 28d373d798 feat(memhub): add honest SDK-native Context v2
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 20:53:50 +08:00
alvinfeiandCursor 92ab29f95d feat(memhub): search SDK raw through existing API
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 20:51:45 +08:00
alvinfeiandCursor 2369bc30dc chore(memhub): pin raw-search SDK dev preview
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 20:50:09 +08:00
alvinfeiandCursor 430ddc95e8 refactor(web): remove dead recall scope component
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 17:57:31 +08:00
alvinfeiandCursor a720ecdfa8 refactor(memhub): remove per-memory scope mutation API
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 17:45:42 +08:00
alvinfeiandCursor 587147e75d docs(memhub): use SDK source filters for future harness policy
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 17:37:50 +08:00
alvinfeiandCursor 1630c672c6 refactor(web): remove legacy per-memory recall controls
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 17:36:35 +08:00
alvinfeiandCursor dbfd6163d4 refactor(memhub): remove fabricated per-memory harness ACL
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 17:34:01 +08:00
alvinfeiandCursor 81a8224147 feat(memhub): migrate recall scopes by memory id
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 17:28:26 +08:00
alvinfeiandCursor f61f3dabb1 feat(web): add SDK-native memory client
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 17:25:43 +08:00
alvinfeiandCursor 8fa7263316 feat(memhub): expose SDK-native memory APIs
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 17:24:14 +08:00
alvinfeiandCursor 60902fbfa9 feat(memhub): expose offline SDK backfill command
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 17:21:46 +08:00
alvinfeiandCursor 220725b254 feat(memhub): run resumable offline SDK backfill
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 17:20:26 +08:00
alvinfeiandCursor bc07b43f8d feat(memhub): migrate Evidence into SDK raw
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 17:18:35 +08:00
alvinfeiandCursor 01a0369055 refactor(memhub): use one schema-driven legacy migration
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 17:16:27 +08:00
alvinfeiandCursor 9507dad424 fix(memhub): preserve source SDK version in migration bundle
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 17:14:08 +08:00
alvinfeiandCursor 9e4e38cb09 chore(memhub): pin exact migration SDK dev preview
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 17:12:16 +08:00
alvinfeiandCursor 1ea277b0d0 feat(memhub): execute classified memory migrations
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 17:08:52 +08:00
alvinfeiandCursor 38aaa5997f refactor(memhub): trust SDK physical delete receipts
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 17:04:39 +08:00
alvinfeiandCursor abfd97fdec feat(memhub): classify legacy and SDK memory inventories
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 17:00:47 +08:00
alvinfeiandCursor 4a6699eda8 chore(memhub): pin SDK authority dev preview
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 16:59:14 +08:00
alvinfeiandCursor 1048959cba refactor(memhub): recover writes from SDK outcomes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 16:59:14 +08:00
alvinfeiandCursor db11adff84 feat(memhub): add SDK-native memory projection
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 16:59:14 +08:00
alvinfeiandCursor 9b668e29ac feat(memhub): persist SDK cutover state
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 16:59:14 +08:00
alvinfeiandCursor 7e240e769c refactor(memhub): route SDK snapshots through bridge
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 16:59:14 +08:00
alvinfeiandCursor df2aefb922 feat(memhub): restore complete SDK migration bundles
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 16:59:14 +08:00
alvinfeiandCursor 504dded830 refactor(memhub): delegate SDK backup to public API
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 16:59:14 +08:00
alvinfeiandCursor 2289d07715 docs(memhub): keep durable write scheduling in Core
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 16:59:14 +08:00
alvinfeiandCursor bbbcbae692 feat(memhub): expose offline migration bundle command
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 16:59:13 +08:00
alvinfeiandCursor 76e8419e1c feat(memhub): create verified SDK migration bundles
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 16:59:13 +08:00
alvinfeiandCursor fbdea89396 feat(memhub): add SDK cutover migration ledger
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 16:59:13 +08:00
alvinfeiandCursor 2e1237f29f docs(memhub): define SDK data-plane migration contract
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 16:59:13 +08:00
alvinfeiandCursor d96dd1c79e test(memhub): freeze legacy memory authority debt
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 16:59:13 +08:00
alvinfeiandCursor 71f74e4534 docs(memhub): define SDK-owned memory architecture
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 16:59:13 +08:00
200 changed files with 3047 additions and 17842 deletions

No files matched your search

-49
View File
@@ -1,49 +0,0 @@
name: Hermes provider
on:
pull_request:
paths:
- "plugins/hermes/**"
- ".github/workflows/hermes-provider.yml"
push:
branches: [main]
paths:
- "plugins/hermes/**"
- ".github/workflows/hermes-provider.yml"
permissions:
contents: read
jobs:
provider:
runs-on: ubuntu-24.04
timeout-minutes: 3
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
persist-credentials: false
- uses: astral-sh/setup-uv@d0d8abe699bfb85fec6de9f7adb5ae17292296ff
with:
python-version: "3.12"
enable-cache: false
- name: Download and verify native Hermes contract
run: |
uv run --no-project --python 3.12 python - <<'PY'
import hashlib
import os
from pathlib import Path
import urllib.request
url = "https://raw.githubusercontent.com/NousResearch/hermes-agent/818c13be1dc4fd28987e1e881a9408224afd4535/agent/memory_provider.py"
with urllib.request.urlopen(url, timeout=30) as response:
data = response.read()
expected = "a4e44a293013fff831ac136ab77c556541cd18817f53bd662e97090cd80bd5a6"
if hashlib.sha256(data).hexdigest() != expected:
raise SystemExit("Hermes contract SHA-256 mismatch")
snapshot = Path(os.environ["RUNNER_TEMP"]) / "hermes-memory-provider.py"
snapshot.write_bytes(data)
with open(os.environ["GITHUB_ENV"], "a") as env:
env.write(f"HERMES_MEMORY_PROVIDER_SNAPSHOT={snapshot}\n")
PY
- name: Test provider with native contract
run: uv run --no-project --python 3.12 --with pytest python -m pytest plugins/hermes/tests -o addopts= -o log_cli=false -q
-3
View File
@@ -26,9 +26,6 @@ package-lock.json
ui_walkthrough_*/
# Internal release/open-source coordination must never enter the public tree.
HANDSOFF.md
新鲜度_todo.md
memhub/docs/LOCAL_PRODUCT_APP_MIGRATION_TEST.md
OPEN_SOURCE_TODO.md
release_todo.md
-39
View File
@@ -2,32 +2,6 @@
Hy Memory and MemHub are versioned and released independently.
## Hy Memory 2.0.5.dev3 — 2026-10-21
- Development preview; not the final `2.0.5` release.
- Published as
[`hy-memory==2.0.5.dev3`](https://pypi.org/project/hy-memory/2.0.5.dev3/).
- Lets the existing `search` API explicitly query SDK raw with
`layers=["l1_raw"]`, including existing source filters.
## Hy Memory 2.0.5.dev2 — 2026-10-21
- Development preview; not the final `2.0.5` release.
- Published as
[`hy-memory==2.0.5.dev2`](https://pypi.org/project/hy-memory/2.0.5.dev2/).
- Adds exact, idempotent semantic-memory import for legacy MemHub cutover
without extraction, reconciliation or identity changes.
## Hy Memory 2.0.5.dev1 — 2026-10-21
- Development preview for the MemHub SDK-authority migration; this is not the
final `2.0.5` release.
- Published as
[`hy-memory==2.0.5.dev1`](https://pypi.org/project/hy-memory/2.0.5.dev1/).
- Adds lifecycle status filtering, durable idempotent write outcomes, exact raw
APIs, offline local data-plane snapshot/restore, and fail-closed physical
delete receipts.
## Hy Memory 2.0.3 — 2026-10-17
- Added an optional runtime HTTP transport factory so embedded products can
@@ -40,19 +14,6 @@ Hy Memory and MemHub are versioned and released independently.
## Unreleased
- Added a MemHub-managed native Hermes MemoryProvider with automatic prefetch,
persistent per-session memory-ID deduplication, authorized Broker reads, and
recall-context stripping to prevent memory recursion.
- Added an extensible MemHub interface-localization layer with Simplified
Chinese, English, and Spanish catalogs shared by Web and Electron-native
surfaces. Interface language remains independent from memory content and
model processing language.
- `search(..., layers=["l1_raw"])` now explicitly searches SDK raw records in
pro/ultra mode and keeps existing user/agent/session/source filters; default
search continues to hide raw.
- Added `import_memory_exact` for idempotent migration of an existing semantic
memory with its canonical ID, lifecycle and supersession links, without
extraction or reconciliation.
- Added SDK-authoritative lifecycle listing with explicit status filters,
idempotent durable write outcomes, exact raw import/get/list APIs, and
quiesced local data-plane snapshot/restore contracts for the MemHub
+87
View File
@@ -0,0 +1,87 @@
# HANDSOFF — MemHub / Hy Memory 架构改造
更新:2026-10-21
权威细则:[`memhub/docs/ARCHITECTURE.md`](memhub/docs/ARCHITECTURE.md)(~~旧删线~~ + 新并列)
配套:[`memhub/MEMORY.md`](memhub/MEMORY.md);其余 docs 已打「架构变更」条,冲突以 ARCHITECTURE Top Rules 为准。
旧用户迁移:[`memhub/docs/contracts/sdk-data-plane-migration-v1.md`](memhub/docs/contracts/sdk-data-plane-migration-v1.md)。
> 根目录原先无有效 `HANDSOFF` 文件;本文件为清空后重写,只保留本次架构改造交接。
---
## IMPORTANT:测试执行规则
1. 先完整运行目标测试套件一次,收集**全部失败 case**。
2. 按根因分组并批量修复,不得用 `-x` 反复重跑已经通过的前置测试。
3. 修复后只重跑上次失败的 case。
4. 失败 case 全部通过后,才再完整运行一次目标套件作为最终 gate。
## Top Rules(现行根本)
1. **所有对 memory 的读写(含存储 backend)都交给 SDK**,经 SDK config / env 配置。
2. **MemHub 内对 memory 的原子操作也一律经 SDK**;缺接口 → **先补 SDK client**,禁止 Core 直改 VDB/Graph/SDK 私库或再维护正文权威。
3. **MemHub 不存 memory 实体 / 引擎侧关系**(正文、supersede 权威、向量图副本等)。主责是管理面:harness、LLM model、异步写入队列、config、Core/Runtime 状态。
- **可以且应当存 `memory_id`**(SDK 给外层的唯一身份)及组织元数据。
- Matter 占位功能未发布,删除其表与入口;未来若重做,必须另立合同。
**推论:**
| 旧 | 新 |
|---|---|
| MemHub `evidence*` 原文权威 | 原文 → **SDK raw**;Evidence 权威拆除 |
| `governed_*` 列表/版本/supersede 权威 | 拆除方向;UI/纠正/删除 → **授权 + SDK API**;组织层只挂 `memory_id` |
| SDK success 后再镜像进 Governance 才算产品写入 | SDK 合同为准;MemHub 至多记 `memory_id` / 任务回执 |
| 拦 CRUD / parse ops 双写 supersede | 禁止作为目标架构 |
---
## 已改文档(合同先于代码)
- 重写:`memhub/docs/ARCHITECTURE.md`、`memhub/MEMORY.md`
- 条幅 + 关键段新旧对照:`contracts/*`(含 runtime-unification / unified-context-read / source-adapter 等)、`INSTALLATION.md`、`DEVELOPMENT_LOOP.md`、`MEMORY_EVENT_TIME_SCHEDULING_PLAN.md`、`PRODUCT_LOG_TROUBLESHOOTING.md`、`harness-support/README.md`
- 各 harness 专页未逐段改写 → 冲突时以 ARCHITECTURE 为准
**代码尚未按此拆除。** 文档是目标架构,不是已完成迁移。
---
## 建议实现顺序
1. 冻结新功能写入 `governed_*` / MemHub `evidence*` 权威路径。
2. 记忆库 UI / recall:授权 + SDK list/get/search/update/delete;组织层只挂 `memory_id`。
3. 导入/同步:写入 SDK(含 raw);退役 Evidence 权威。
4. 队列降级为「调度 SDK 调用」,完成态以 SDK 可验证结果为准。
5. 清空/删除镜像台账;缺的能力开 **SDK PR**,不在 Core 补影子表。
旧用户的数据迁移不是独立的手工升级步骤:由 App 在可见的启动 loading
阶段、Core 开始提供产品读写之前,完成 bundle、可续跑 backfill、reconciliation
与最终 cutover。失败或缺少前置配置时 marker 保持未提交,不允许静默进入双写。
Fresh install 直接创建最新管理面 schema,禁止创建 `evidence*`、`governed_*`
及旧 retrieval/Matter/session-shadow 表。Legacy upgrade 在 bundle 与 SDK
迁移校验完成后物理 DROP 这些旧表;备份 bundle 才是审计/回滚载体,不在 live DB
保留一套“兼容权威”。
---
## 管理面仍属 MemHub(勿误拆)
- harness:`source_grants`、sync desired-state(`config.json`)、适配器扫描(产出进 SDK,不是 Evidence 权威库)
- model profiles / 密钥边界 / 模型内容许可
- `memory_write_jobs` 单层异步队列、FIFO / event-time 调度与租约(始终属于 MemHub;调度,不是 memory 实体)。旧 `memory_tasks` 二级队列与 task receipt 删除。
- pending job 尚未调用 SDK,因此没有 raw/memory ID;worker 对 SDK 只调用一次
`add()`,SDK 内部完成 raw + extraction/reconciliation,成功后 MemHub 仅保存
返回的 raw ID / memory IDs 与 outcome。禁止“先 import_raw 再排 extraction”。
- Core / Runtime 收据与健康
- Matter 尚未形成已发布产品合同;删除全部 `matter*` 占位表与运行时入口。未来若重新开发,
以新合同重新设计,不为当前占位 schema 保留兼容层。
- call_events 类审计元数据(不存记忆正文)
- ~~session inbox / change_outbox 围绕 Evidence 外发~~ → 需按新规则重定义或标旧
---
## 接手时注意
- Pin 的公开 `hy-memory` 与「先补 SDK 接口」可能不同步:合源码 ≠ 用户包已带能力;用 `Refs` / pin PR 闭环。
- Top Rule 与旧 `ARCHITECTURE`「SQLite 含 Governance 为业务权威」冲突处,**以本 HANDSOFF + 新 ARCHITECTURE 文首为准**。
- 本地 `pr_principle.md` / `pr_review.md` 是 PR 流程台账,不替代本架构 HANDSOFF。
-1
View File
@@ -253,7 +253,6 @@ That version is usually not in the working tree or at HEAD either, so it cannot
- [Graph memory design](docs/pro_graph_design.md) — graph projection, rerank, relation paths, and deployment limits
- [Environment template](.env.example) — a copy-ready configuration template
- [MemHub README](memhub/README.md) — local-first desktop & CLI product overview, runtime commands, and privacy boundaries
- [Hermes native memory provider](plugins/hermes/README.md) — explicit owner/agent scope; separate HTTP service; automatic capture and recall opt-in.
- [OpenClaw plugin](plugins/openclaw/README.md) — agent integrations live under `plugins/` (overview: [plugins/README.md](plugins/README.md))
### License
-1
View File
@@ -253,7 +253,6 @@ Coding 与 Working **复用同一套双层 pipeline 和数据 schema**,区别
- [Graph 记忆设计](docs/pro_graph_design.md) — 图投影、重排、关系路径与部署限制
- [环境变量模板](.env.example) — 可直接复制的配置模板
- [MemHub 说明文档](memhub/README.md) — 本地优先桌面端与 CLI 产品总览、Runtime 命令与隐私边界
- [Hermes 原生记忆 Provider](plugins/hermes/README.md) — 显式用户与 Agent 隔离,独立 HTTP 服务,自动记录与召回默认关闭。
- [OpenClaw 插件](plugins/openclaw/README.md) — Agent 集成见 `plugins/`(总览:[plugins/README.md](plugins/README.md))
### License
+2 -272
View File
@@ -2936,49 +2936,6 @@ class HyMemoryClient:
include_runtime_diag=True,
)
def import_memory_exact(
self,
content: str,
*,
memory_id: str,
user_id: str,
agent_id: str = "default_agent",
session_id: str = "default_session",
source: str = "default",
layer: Any = "l2_fact",
status: Any = "active",
owner: Optional[str] = None,
tags: Optional[List[str]] = None,
supersedes: Optional[List[str]] = None,
superseded_by: Optional[List[str]] = None,
is_latest: bool = True,
memory_at: Optional[datetime] = None,
metadata: Optional[Dict[str, Any]] = None,
request_id: Optional[str] = None,
) -> Dict[str, Any]:
"""Import one existing semantic memory without extraction/reconcile."""
return self._loop_thread.run(
self.async_import_memory_exact(
content,
memory_id=memory_id,
user_id=user_id,
agent_id=agent_id,
session_id=session_id,
source=source,
layer=layer,
status=status,
owner=owner,
tags=tags,
supersedes=supersedes,
superseded_by=superseded_by,
is_latest=is_latest,
memory_at=memory_at,
metadata=metadata,
request_id=request_id,
),
include_runtime_diag=True,
)
def get_raw(self, raw_id: str) -> Optional[Dict[str, Any]]:
"""Read one SDK raw record through the explicit raw API."""
return self._loop_thread.run(self.async_get_raw(raw_id))
@@ -3266,157 +3223,6 @@ class HyMemoryClient:
raise RuntimeError("raw import changed the requested identity")
return {"success": True, "raw_id": node_id, "request_id": request_id}
@_ensure_internal_loop
async def async_import_memory_exact(
self,
content: str,
*,
memory_id: str,
user_id: str,
agent_id: str = "default_agent",
session_id: str = "default_session",
source: str = "default",
layer: Any = "l2_fact",
status: Any = "active",
owner: Optional[str] = None,
tags: Optional[List[str]] = None,
supersedes: Optional[List[str]] = None,
superseded_by: Optional[List[str]] = None,
is_latest: bool = True,
memory_at: Optional[datetime] = None,
metadata: Optional[Dict[str, Any]] = None,
request_id: Optional[str] = None,
) -> Dict[str, Any]:
"""Import a canonical memory node exactly, with no model invocation."""
from .models.memory import MemoryLayer, MemoryStatus
from .utils.source_filter import normalize_source
if not content or not memory_id or not user_id:
raise ValueError("content, memory_id and user_id are required")
normalized_layer = (
layer if isinstance(layer, MemoryLayer) else MemoryLayer.from_string(str(layer))
)
if normalized_layer == MemoryLayer.L1_RAW:
raise ValueError("use import_raw for L1_RAW")
normalized_status = (
status if isinstance(status, MemoryStatus) else MemoryStatus(str(status).lower())
)
request_id = request_id or str(uuid.uuid4())
replay = await self._claim_write_outcome(
request_id=request_id,
user_id=user_id,
agent_id=agent_id,
session_id=session_id,
operation="import_memory_exact",
)
if replay is not None:
return replay
try:
result = await self._async_import_memory_exact_impl(
content,
memory_id=memory_id,
user_id=user_id,
agent_id=agent_id,
session_id=session_id,
source=normalize_source(source),
layer=normalized_layer,
status=normalized_status,
owner=owner,
tags=tags or [],
supersedes=supersedes or [],
superseded_by=superseded_by or [],
is_latest=is_latest,
memory_at=memory_at,
metadata=metadata,
request_id=request_id,
)
except BaseException:
await self._mark_write_outcome_unknown(request_id)
raise
await self._finish_write_outcome(request_id, "succeeded", result)
return result
async def _async_import_memory_exact_impl(
self,
content: str,
*,
memory_id: str,
user_id: str,
agent_id: str,
session_id: str,
source: str,
layer,
status,
owner: Optional[str],
tags: List[str],
supersedes: List[str],
superseded_by: List[str],
is_latest: bool,
memory_at: Optional[datetime],
metadata: Optional[Dict[str, Any]],
request_id: str,
) -> Dict[str, Any]:
from .models.memory import MemoryNode, SourceType
existing = await self._vector_store.get_by_id(memory_id)
if existing is not None:
identity = (
existing.content == content,
existing.user_id == user_id,
existing.agent_id == agent_id,
existing.session_id == session_id,
existing.source == source,
existing.layer == layer,
existing.status == status,
existing.owner == owner,
list(existing.tags or []) == tags,
list(existing.supersedes or []) == supersedes,
list(existing.superseded_by or []) == superseded_by,
existing.is_latest == is_latest,
)
if not all(identity):
raise ValueError("memory_id is already bound to different memory content")
return {
"success": True,
"memory_id": memory_id,
"memory_ids": [memory_id],
"request_id": request_id,
"replayed": True,
}
custom = dict(metadata or {})
custom["request_id"] = request_id
custom["input_kind"] = "memory_exact_import"
embedding = await self._embed_service.embed(content)
node = MemoryNode(
node_id=memory_id,
user_id=user_id,
agent_id=agent_id,
session_id=session_id,
source=source,
layer=layer,
content=content,
source_type=SourceType.EXPLICIT,
status=status,
owner=owner,
tags=list(tags),
supersedes=list(supersedes),
superseded_by=list(superseded_by),
is_latest=is_latest,
embedding=embedding,
memory_at=memory_at,
custom=custom,
)
stored_id = await self._vector_store.upsert(node)
if stored_id != memory_id:
raise RuntimeError("exact memory import changed the requested identity")
return {
"success": True,
"memory_id": memory_id,
"memory_ids": [memory_id],
"request_id": request_id,
}
@_ensure_internal_loop
async def async_get_raw(self, raw_id: str) -> Optional[Dict[str, Any]]:
from .models.memory import MemoryLayer
@@ -3469,66 +3275,6 @@ class HyMemoryClient:
"offset": offset,
}
async def _search_explicit_raw_layer(
self,
query: str,
*,
user_ids: List[str],
agent_ids: List[str],
session_ids: List[str],
include_sources: List[str],
exclude_sources: List[str],
limit: int,
min_score: float,
created_after: Optional[float],
request_id: str,
t0: float,
query_embedding: Optional[List[float]],
) -> Dict[str, Any]:
"""Implement `search(..., layers=['l1_raw'])` without a second API."""
from .models.memory import MemoryLayer, MemoryStatus
embedding = query_embedding or await self._embed_service.embed(query)
fetch = min(max(limit * (10 if session_ids else 2), limit), 10_000)
hits = await self._vector_store.search(
query_embedding=embedding,
user_ids=user_ids,
agent_ids=agent_ids,
include_sources=include_sources,
exclude_sources=exclude_sources,
layers=[MemoryLayer.L1_RAW],
limit=fetch,
score_threshold=min_score,
status_filter=[MemoryStatus.ACTIVE],
only_latest=False,
created_after=created_after,
)
sessions = set(session_ids)
rows: List[Dict[str, Any]] = []
for hit in hits:
node = hit.get("node")
if node is None or node.layer != MemoryLayer.L1_RAW:
continue
if sessions and node.session_id not in sessions:
continue
row = self._raw_node_to_public(node)
rows.append(
{
"memory_id": row.pop("raw_id"),
**row,
"layer": MemoryLayer.L1_RAW.value,
"status": MemoryStatus.ACTIVE.value,
"score": round(float(hit.get("score", 0.0)), 4),
}
)
if len(rows) >= limit:
break
return {
"request_id": request_id,
"memories": {"schema": [], "proactive": [], "normal": rows},
"elapsed_ms": round((time.perf_counter() - t0) * 1000, 2),
}
@staticmethod
def _public_search_views(
result_memories: List[Dict[str, Any]],
@@ -4189,22 +3935,6 @@ class HyMemoryClient:
effective_search_layers = [MemoryLayer.L1_RAW] if lite_mode else layers
effective_schema_limit = 0 if lite_mode else schema_limit
effective_intention_limit = 0 if lite_mode else intention_limit
normalized_search_layers = self._normalize_layers(effective_search_layers)
if normalized_search_layers == [MemoryLayer.L1_RAW]:
return await self._search_explicit_raw_layer(
effective_target_query,
user_ids=effective_user_ids,
agent_ids=effective_agent_ids,
session_ids=effective_session_ids,
include_sources=effective_include_sources,
exclude_sources=effective_exclude_sources,
limit=limit,
min_score=min_score,
created_after=effective_created_after,
request_id=request_id,
t0=t0,
query_embedding=query_embedding,
)
read_req = ReadRequest(
query=effective_target_query,
@@ -4225,7 +3955,7 @@ class HyMemoryClient:
layers=(
[
layer.value
for layer in normalized_search_layers
for layer in self._normalize_layers(effective_search_layers)
]
if effective_search_layers else None
),
@@ -4250,7 +3980,7 @@ class HyMemoryClient:
_wanted_layers = (
{
layer.value
for layer in normalized_search_layers
for layer in self._normalize_layers(effective_search_layers)
}
if effective_search_layers else None
)
+1 -8
View File
@@ -1,6 +1,6 @@
# Contributing
感谢你参与 MemHub。
感谢你参与 One Memory。
## 开发环境
@@ -27,10 +27,3 @@ pnpm check
涉及外部源码时,必须记录项目、固定版本或 commit、许可证、采用文件和修改说明。不得提交
密钥、内部地址、真实对话、个人路径、模型权重或签名证书。
## 界面翻译
新增或修改界面语言时遵循
[`docs/LOCALIZATION.md`](docs/LOCALIZATION.md):使用稳定语义 key,保持所有
locale 的 key 与占位符一致,不翻译用户内容、路径、标识符或外部错误正文。语言
catalog 与无关产品行为应拆成不同 PR。
+49 -17
View File
@@ -2,20 +2,28 @@
更新:2026-10-21
架构边界以 [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) 为准。
**文档约定:** 以 [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) **Top Rules** 为现行权威。
~~删除线~~ = 历史产品合同(曾要求走 Evidence / Governance 台账),**不再作为目标**;SDK 用法示例仍可用于开发调试。
---
## Architecture Principles
## Top Rules(与架构一致 · 新)
1. Memory 读写与存储 backend **只经 SDK**(config / env)。
2. MemHub 对 memory 的原子操作 **只经 SDK**;缺接口先补 SDK。
3. MemHub **不存 memory 实体**;**可以存 `memory_id`** 做组织/回执。原文进入 **SDK raw**。
3. MemHub **不存 memory 实体**;**可以存 `memory_id`** 做组织/回执。原文进 **SDK raw**,不进 MemHub Evidence 权威库。
生产入口仍建议经 `HyMemoryBridge`(共享 Runtime、模型配置、密钥、harness/model 门禁),但 bridge **不得**再把 SDK 结果镜像成第二套 memory 正文权威。
来源授权、模型授权和宿主召回授权仍是 MemHub 管理面门禁;memory
数据平面的权威在 SDK。
---
## ~~旧开篇(已废弃)~~
~~本文说明 MemHub 当前使用的 Hy Memory SDK… 仓库内仍以 One Memory 命名的 Evidence、权限和 Governance 层属于 MemHub 的历史兼容模块;产品代码不应绕过这些层直接调用 SDK。~~
~~MemHub 在本地 Runtime 内嵌入 SDK,但不绕过自身的来源授权、模型授权、Evidence、Governance 和撤权边界。~~
**新:** 来源授权、模型授权、宿主召回授权仍是 MemHub 管理面门禁。~~Evidence / Governance 记忆台账~~ 按 Top Rules 拆除;memory 真相在 SDK。
## 当前版本状态
@@ -30,12 +38,19 @@ reader: hybrid
## MemHub 运行时边界
### 新
MemHub 对 SDK 使用公开、可复现的本地策略(经 bridge / `hy_runtime_config` 注入):
- vector / graph / cache / history / coding 等 **一律 SDK config/env**;
- 模型与 embedding 来自版本化 Model Profile;密钥进安全存储;
- MemHub 管理库可存 harness/model/队列状态与 **`memory_id` 引用**。
### ~~旧~~
~~- MemHub 不绕过 Evidence、Governance…~~
~~- 生产路径不能用直接 `client.add()` 代替 Governance 检查。~~
发布包默认 SDK 行为环境变量仍集中在
`apps/core/src/one_memory/hy_runtime_config.py`,并由下表锁定:
@@ -77,8 +92,8 @@ MemHub 对 SDK 使用公开、可复现的本地策略(经 bridge / `hy_runtim
下面是独立调试示例。
产品代码经 `HyMemoryBridge` 获取已配置的 client(管理面门禁),memory I/O
由 SDK 完成。
**新:** 产品代码经 `HyMemoryBridge` 获取已配置的 client(管理面门禁),memory I/O 仍是 SDK。
~~旧:必须额外提供 Evidence 绑定、写后核验进 Governance。~~
```python
from hy_memory import HyMemoryClient, MemoryConfig
@@ -111,7 +126,7 @@ client = HyMemoryClient(config=config, mode="ultra")
## Write:写入对话
推荐把一段连续对话作为 OpenAI messages 写入;含原文时由 SDK 接管 raw:
推荐把一段连续对话作为 OpenAI messages 写入(**含原文时由 SDK 接管 raw**,不再写入 MemHub Evidence 权威表):
```python
from datetime import UTC, datetime
@@ -130,6 +145,7 @@ result = client.add(
request_id=str(uuid4()),
metadata={
"source_id": "cursor",
# ~~evidence_ids 指向 MemHub Evidence 表~~ → 新:按 SDK raw / 元数据合同填写;勿依赖 MemHub evidence 权威
"workspace_ids": ["workspace-abc"],
"input_kind": "raw_conversation",
},
@@ -170,11 +186,11 @@ if result.get("success") is not True:
memory_ids = list(result.get("memory_ids") or [])
if result.get("memory_id"):
memory_ids.append(result["memory_id"])
# 需要组织或回执时,MemHub 只持久化这些 memory_id。
# 新:需要组织/回执时,MemHub 只持久化这些 memory_id
```
以 SDK 合同为准做成功判定;需要写后核验时使用 SDK `get` 回读。缺少所需
核验能力时扩展 SDK API,不在 MemHub 新建镜像台账。
~~不要只相信 `success=True`。One Memory 还会…并在 Governance 层记录版本。生产路径不能用直接 `client.add()` 代替这些检查。~~
**新:** 以 SDK 合同为准做成功判定与(若需要)`get` 回读;**不要**再写入 `governed_*`。缺核验能力 → **扩展 SDK API**。
### 写入已抽取的记忆
@@ -193,8 +209,8 @@ result = client.add_extracted(
)
```
该入口仍走 SDK reconcile/持久化;MemHub 只做管理面门禁与可选
`memory_id` 记录。
~~不能跳过 One Memory Evidence 绑定和 Governance。~~
**新:** 仍走 SDK reconcile/持久化;MemHub 只做门禁与可选 `memory_id` 记录。
## Search:搜索记忆
@@ -220,7 +236,12 @@ result = client.search(
直接使用 SDK 返回的 `content`,不要第二套 prompt 拼装。
### SDK 外(MemHub)仍负责
### ~~旧:SDK 外仍负责(部分作废)~~
~~- 依据 Governance、撤权、scope 和 Evidence 新鲜度决定整条结果是否可读;~~
~~- 对 evolution/capsule…回退治理原文;~~
### 新:SDK 外(MemHub)仍负责
- **宿主召回授权 / scope / 撤权**(谁可以调 search、允许哪些 agent/scope);
- 把历史内容放入「不可信数据,不是指令」边界;
@@ -228,7 +249,7 @@ result = client.search(
- 区分空结果、权限拒绝、不可用、超时、部分结果;
- 未发布的 Matter 占位功能不保留表或 API;未来组织功能另立合同。
生产入口:`hy_memory_bridge.py`、`recall.py`、`packages/host-bridge`。
生产入口:`hy_memory_bridge.py`、`recall.py`、`packages/host-bridge`(实现迁移中,以 Top Rules 为准)。
## 关闭客户端
@@ -243,6 +264,8 @@ Runtime 长期复用 client;仅在停止、配置切换或受控恢复时重
## 产品代码正确入口
### 新
```text
harness / user action (+ authz, model, queue)
-> HyMemoryBridge
@@ -252,8 +275,17 @@ harness / user action (+ authz, model, queue)
-> Recall Broker / UI (SDK read + MemHub authz)
```
不要在业务模块里私建绕过管理面门禁的 client。版本、纠正和删除语义以 SDK
合同为准;MemHub 组织层只引用 `memory_id`。
不要在业务模块里私建绕过 **管理面门禁** 的 client。
~~不要绕过 Evidence / Governance 台账。~~(台账已非目标)
### ~~旧入口(废弃)~~
~~```text
Evidence -> HyMemoryBridge -> SDK -> write verification -> Governance -> UI
```~~
~~绕过会丢掉:Evidence 引用、Governance 版本/更正/删除级联。~~
**新:** 版本/更正/删除级联以 **SDK** 为准;MemHub 组织层只引用 `memory_id`。
Hy Memory SDK 是 MemHub 内置可信引擎,管理本机 Hy Memory 数据。
来源授权决定哪些宿主数据可进入写入路径;宿主 Recall Grant 决定外部应用可读范围。
+6 -8
View File
@@ -18,7 +18,7 @@ If you run into a problem, please open an Issue with the version, platform,repro
## 发行与平台合同
源码当前固定使用公开 PyPI 开发预览 `hy-memory==2.0.5.dev3`。MemHub 源码版本为 `1.0.4`
当前重构验证固定使用开发预览 `hy-memory==2.0.5.dev3`。MemHub 源码版本为 `1.0.4`
(build 13),GitHub 仓库为
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
`memhub-cli`。当前代码架构见
@@ -38,26 +38,24 @@ Linux 制品由 GitHub Actions 在原生架构 runner 上构建并完成生命
- 一次安装、一个 Runtime、一份数据、多种客户端。
- Hy Memory SDK 是 memory/raw/vector/graph/coding 数据面的唯一权威;Python Core
管理授权、模型、异步队列和 Runtime 状态。
管理授权、模型、异步队列、Runtime 状态与 Matters 引用。
- 来源读取、模型处理和宿主读取是相互独立的授权边界。
- 正式写链路为 `memory_write_jobs -> SDK add() -> raw/memory ID receipt`。
- 正式写链路为 `memory_write_jobs -> SDK add() -> raw/memory ID receipt`;
Matter/Wiki 只保存并解析 SDK ID 引用。
- 产品不包含屏幕、Accessibility / AXTree、OCR、键鼠、剪贴板或后台录音采集。
## 仓库结构
- `apps/core`:Python / FastAPI Kernel,负责授权、模型、队列、SDK bridge 与 API。
- `apps/core`:Python / FastAPI Kernel,负责授权、模型、队列、SDK bridge、Matters 与 API。
- `apps/cli`:安装、控制和诊断共享 Runtime 的 `one-memory` CLI。
- `apps/web`:React / TypeScript 管理界面。
- `apps/desktop`:Electron 桌面壳与图形安装入口。
- `packages/contracts`:公共 API 类型。
- `packages/i18n`:Web 与 Electron 共用的界面语言合同和 catalog。
- `packages/host-bridge`:AI App 的 Skill、Hook、插件和 MCP 接入。
- `packages/runtime-control`:Core、Host Gateway 和 Runtime 生命周期控制。
架构详情见 [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) 和
[`docs/contracts/runtime-unification.md`](docs/contracts/runtime-unification.md)。
界面语言架构与社区翻译规则见
[`docs/LOCALIZATION.md`](docs/LOCALIZATION.md)。
产品、测试和一线支持排查导入与记忆沉淀时,使用
[`docs/PRODUCT_LOG_TROUBLESHOOTING.md`](docs/PRODUCT_LOG_TROUBLESHOOTING.md)。
@@ -117,7 +115,7 @@ MemHub 的 SQLite 数据默认位于 `~/.one-memory`,不在 npm 包或 Runtime
## Hy Memory
SDK 依赖固定为公开 PyPI 开发预览 `hy-memory==2.0.5.dev3`。SDK 配置、权限边界及 search
当前开发依赖固定为公开 PyPI 开发预览 `hy-memory==2.0.5.dev3`。SDK 配置、权限边界及 search
返回格式见 [`MEMORY.md`](MEMORY.md)。HY search 返回的 `content` 已包含 SDK 统一生成的
逻辑时间和演化上下文;调用方不应再次转换时间戳或重复拼接 `When`。
+2 -24
View File
@@ -3,9 +3,9 @@
"dataLicense": "CC0-1.0",
"SPDXID": "SPDXRef-DOCUMENT",
"name": "one-memory",
"documentNamespace": "https://one-memory.local/spdx/2026-10-22T08:09:31Z",
"documentNamespace": "https://one-memory.local/spdx/2026-10-17T13:52:48Z",
"creationInfo": {
"created": "2026-10-22T08:09:31Z",
"created": "2026-10-17T13:52:48Z",
"creators": [
"Tool: one-memory-generate-sbom"
]
@@ -88,23 +88,6 @@
}
]
},
{
"SPDXID": "SPDXRef-Package-npm-i18next",
"name": "i18next",
"versionInfo": "25.10.10",
"downloadLocation": "NOASSERTION",
"filesAnalyzed": false,
"licenseConcluded": "NOASSERTION",
"licenseDeclared": "NOASSERTION",
"copyrightText": "NOASSERTION",
"externalRefs": [
{
"referenceCategory": "PACKAGE-MANAGER",
"referenceType": "purl",
"referenceLocator": "pkg:npm/i18next@25.10.10"
}
]
},
{
"SPDXID": "SPDXRef-Package-npm-lucide-react",
"name": "lucide-react",
@@ -2397,11 +2380,6 @@
"relationshipType": "DEPENDS_ON",
"relatedSpdxElement": "SPDXRef-Package-npm-https-proxy-agent"
},
{
"spdxElementId": "SPDXRef-Package-one-memory",
"relationshipType": "DEPENDS_ON",
"relatedSpdxElement": "SPDXRef-Package-npm-i18next"
},
{
"spdxElementId": "SPDXRef-Package-one-memory",
"relationshipType": "DEPENDS_ON",
+2 -3
View File
@@ -2,7 +2,7 @@
MemHub itself is licensed under Apache-2.0 (see `LICENSE`).
It bundles the runtime dependencies listed below.
Generated from the installed dependency tree on 2026-10-22T08:09:31Z
Generated from the installed dependency tree on 2026-10-17T13:52:48Z
by `python3 scripts/generate_sbom.py`. Do not edit by hand.
Per-package license texts are not reproduced here yet; the license
@@ -112,13 +112,12 @@ Adaptation boundary:
No Memmy brand, icon, font or mascot assets are distributed.
## JavaScript (npm) — 11 packages
## JavaScript (npm) — 10 packages
- @tanstack/react-query 5.103.2
- axios 1.20.0
- fflate 0.8.3
- https-proxy-agent 7.0.0
- i18next 25.10.10
- lucide-react 1.43.0
- proxy-from-env 2.1.0
- react 19.2.8
@@ -6,7 +6,6 @@ from pathlib import PurePosixPath
from pydantic import JsonValue
from one_memory.derived_echo import strip_injected_memory
from one_memory.imports.access import (
AuthorizedSourceFile,
AuthorizedSourceRoot,
@@ -214,7 +213,6 @@ def parse_hermes_rollout_record(
message.get("content"),
allowed_block_types=frozenset({"text", "input_text", "output_text"}),
)
text = strip_injected_memory(text)
if not text:
return ParsedSkip(
reason_code=ScanIssueCode.NO_TEXT,
@@ -388,8 +386,7 @@ def _read_state_db(
# The assistant's call sits on its own row as `tool_calls`; the
# `tool` rows after it hold only what came back.
calls = _tool_calls_text(row["tool_calls"]) if role == "assistant" else None
content = strip_injected_memory(str(row["content"] or ""))
has_text = bool(content.strip())
has_text = bool(str(row["content"] or "").strip())
if role not in {"user", "assistant"} or (not has_text and calls is None):
yield ScanSkip(
cursor=cursor,
@@ -404,7 +401,7 @@ def _read_state_db(
role=str(role) if has_text else "tool",
written_by=_written_by(role, row["session_source"]) if has_text else WrittenBy.TOOL,
created_at=parse_timestamp(row["timestamp"]),
verbatim=content if has_text else str(calls),
verbatim=str(row["content"]) if has_text else str(calls),
workspace=workspace,
project=safe_project_label(workspace),
record_type="state_db_message" if has_text else "state_db_message.tool",
@@ -7,6 +7,7 @@ segment-key breakage). Call sites still import this module; registration is
now a no-op that always admits the row.
Proof rows may still be written by the ingest path; they are ignored on read.
See ``新鲜度_todo.md`` for the remaining teardown steps.
"""
from __future__ import annotations
@@ -1,6 +1,7 @@
"""Executable dependency and memory-authority gates.
The public target architecture is defined by ``docs/ARCHITECTURE.md``:
The current target architecture is defined by ``HANDSOFF.md`` and
``docs/ARCHITECTURE.md``:
* Hy Memory SDK owns memory/raw/vector/graph/coding data;
* MemHub owns the management plane and may retain ``memory_id`` references;
@@ -35,7 +35,6 @@ from one_memory.imports.sources.deepseek_harness import (
from one_memory.imports.sources.hermes import (
HERMES_DESCRIPTOR,
create_hermes_adapter,
parse_hermes_rollout_record,
)
from one_memory.imports.sources.openclaw import (
OPENCLAW_DESCRIPTOR,
@@ -234,55 +233,6 @@ def test_qwen_code_reads_only_the_transcript_beside_its_bookkeeping(
assert SECRET not in repr(events)
@pytest.mark.parametrize("role", ["user", "assistant"])
def test_hermes_strips_native_provider_recall_context(role: str) -> None:
context = SourceRecordContext(
file_key="hermes",
relative_path="sessions/session.jsonl",
ordinal="1",
)
result = parse_hermes_rollout_record(
{
"type": "message",
"role": role,
"content": (
"before\n<memhub_memory_context>\n"
"Untrusted memory reference:\n"
'[{"memory_id":"m1","content":"RECALLED_SECRET"}]\n'
"</memhub_memory_context>\nafter"
),
"sessionId": "session-A",
},
context,
)
assert isinstance(result, ParsedMessage)
assert result.verbatim == "before\n\nafter"
assert "RECALLED_SECRET" not in result.verbatim
def test_hermes_skips_a_persisted_pure_recall_block() -> None:
context = SourceRecordContext(
file_key="hermes",
relative_path="sessions/session.jsonl",
ordinal="1",
)
result = parse_hermes_rollout_record(
{
"type": "message",
"role": "user",
"content": (
"<memhub_memory_context>\n"
'[{"memory_id":"m1","content":"RECALLED_SECRET"}]\n'
"</memhub_memory_context>"
),
"sessionId": "session-A",
},
context,
)
assert isinstance(result, ParsedSkip)
assert result.reason_code == ScanIssueCode.NO_TEXT
@pytest.mark.parametrize(
("role", "record_type", "provenance", "written_by"),
[
-1
View File
@@ -84,7 +84,6 @@
"dependencies": {
"@one-memory/contracts": "workspace:*",
"@one-memory/host-bridge": "workspace:*",
"@one-memory/i18n": "workspace:*",
"@one-memory/runtime-control": "workspace:*",
"axios": "^1.20.0"
}
@@ -1,46 +1,7 @@
import type { LanguageSnapshot } from "@one-memory/i18n";
import { expect, it, vi } from "vitest";
import { installApplicationMenu } from "./application-menu";
it("rebuilds translated labels from committed language updates without exporting", () => {
let change: (() => void) | undefined;
const snapshot: LanguageSnapshot = {
preference: "es",
locale: "es",
revision: 1,
};
const unsubscribe = vi.fn();
const read = vi.fn(() => snapshot);
const buildMenu = vi.fn(() => ({}) as never);
const dispose = installApplicationMenu({
productName: "MemHub",
buildMenu,
setMenu: vi.fn(),
exportLogs: async () => {},
reportError: vi.fn(),
language: {
read,
subscribe: (listener) => {
change = () => listener(snapshot);
return unsubscribe;
},
},
});
expect(buildMenu).toHaveBeenLastCalledWith(
expect.arrayContaining([expect.objectContaining({ label: "Ayuda" })]),
);
snapshot.locale = "zh-CN";
change?.();
expect(read).toHaveBeenCalledTimes(2);
expect(buildMenu).toHaveBeenCalledTimes(2);
expect(buildMenu).toHaveBeenLastCalledWith(
expect.arrayContaining([expect.objectContaining({ label: "帮助" })]),
);
dispose();
expect(unsubscribe).toHaveBeenCalledTimes(1);
});
it("exports logs from Help without requiring a renderer window", async () => {
const exportLogs = vi.fn(async () => undefined);
let template: Array<Record<string, unknown>> = [];
+21 -34
View File
@@ -1,49 +1,36 @@
import { translate } from "@one-memory/i18n";
import type { Menu, MenuItemConstructorOptions } from "electron";
import type { NativeLanguageSource } from "./native-language";
export function installApplicationMenu(options: {
productName: string;
language?: NativeLanguageSource;
buildMenu(template: MenuItemConstructorOptions[]): Menu;
setMenu(menu: Menu): void;
exportLogs(): Promise<void>;
reportError(message: string): void;
}): () => void {
}): void {
const runExport = async () => {
try {
await options.exportLogs();
} catch {
options.reportError(
translate(
options.language?.read().locale ?? "zh-CN",
"nativeExportError",
),
);
options.reportError("日志未能导出,请稍后重试。");
}
};
const refresh = () => {
const locale = options.language?.read().locale ?? "zh-CN";
options.setMenu(
options.buildMenu([
{
label: options.productName,
submenu: [{ role: "about" }, { type: "separator" }, { role: "quit" }],
},
{ role: "editMenu" },
{ role: "windowMenu" },
{
label: translate(locale, "nativeHelp"),
submenu: [
{
label: translate(locale, "nativeExportLogs"),
click: runExport,
},
],
},
]),
);
};
refresh();
return options.language?.subscribe(refresh) ?? (() => {});
options.setMenu(
options.buildMenu([
{
label: options.productName,
submenu: [{ role: "about" }, { type: "separator" }, { role: "quit" }],
},
{ role: "editMenu" },
{ role: "windowMenu" },
{
label: "帮助",
submenu: [
{
label: "导出日志(发给开发排查)",
click: runExport,
},
],
},
]),
);
}
@@ -1,5 +1,4 @@
import { EventEmitter } from "node:events";
import type { LanguageSnapshot } from "@one-memory/i18n";
import type {
App,
Menu,
@@ -26,12 +25,6 @@ function fixture(
enabled: false,
status: "disabled",
};
let changed: (() => void) | undefined;
const languageSnapshot: LanguageSnapshot = {
preference: "zh-CN",
locale: "zh-CN",
revision: 1,
};
const app = { isPackaged: packaged, quit: vi.fn() };
const tray = Object.assign(new EventEmitter(), {
setToolTip: vi.fn(),
@@ -56,15 +49,6 @@ function fixture(
installBackgroundTray({
app: app as unknown as App,
platform,
language: {
read: () => languageSnapshot,
subscribe: (listener) => {
changed = () => listener(languageSnapshot);
return () => {
changed = undefined;
};
},
},
resourcesPath: "/bundle/Contents/Resources",
appPath: "/repo/apps/desktop",
createImage: vi.fn(() => image as unknown as NativeImage),
@@ -96,10 +80,6 @@ function fixture(
install,
menu,
click,
changeLanguage(locale: LanguageSnapshot["locale"]) {
languageSnapshot.locale = locale;
changed?.();
},
setState(value: LoginPreference) {
state = value;
},
@@ -107,26 +87,6 @@ function fixture(
}
describe("native macOS background tray", () => {
it("rebuilds Spanish and Chinese labels without changing login or stopping services", async () => {
const f = fixture();
f.install();
await flush();
f.changeLanguage("es");
await flush();
expect(f.menu()[0]?.label).toBe("Abrir MemHub");
expect(f.menu()[1]?.label).toBe("Iniciar MemHub al iniciar sesión");
expect(f.menu()[3]?.label).toBe(
"Salir de MemHub (el servicio sigue en segundo plano)",
);
expect(f.setLoginPreference).not.toHaveBeenCalled();
f.click(0);
expect(f.open).toHaveBeenCalledOnce();
f.changeLanguage("zh-CN");
await flush();
expect(f.menu()[0]?.label).toBe("打开 MemHub");
f.click(3);
expect(f.app.quit).toHaveBeenCalledOnce();
});
it("reads and changes the unified Runtime login preference", async () => {
const f = fixture();
const dispose = f.install();
+8 -21
View File
@@ -1,5 +1,4 @@
import path from "node:path";
import { type MessageKey, translate } from "@one-memory/i18n";
import type {
App,
Menu,
@@ -8,7 +7,6 @@ import type {
Tray,
} from "electron";
import type { LoginPreference } from "./login-preference";
import type { NativeLanguageSource } from "./native-language";
export const LOGIN_SERVICE_NAME = "com.tencent.onememory.runtime.plist";
@@ -26,7 +24,6 @@ interface TrayOptions {
setLoginPreference(enabled: boolean): Promise<LoginPreference>;
allowLoginStartup?: boolean;
productName?: string;
language?: NativeLanguageSource;
}
export function installBackgroundTray(options: TrayOptions): () => void {
@@ -44,32 +41,29 @@ export function installBackgroundTray(options: TrayOptions): () => void {
let generation = 0;
const refresh = async () => {
if (disposed) return;
const locale = options.language?.read().locale ?? "zh-CN";
const t = (key: MessageKey) =>
translate(locale, key, { product: productName });
const current = ++generation;
let checked = false;
let enabled = app.isPackaged && options.allowLoginStartup !== false;
let label = t("nativeLogin");
let label = `登录时启动 ${productName}`;
if (options.allowLoginStartup === false) {
label += t("nativeTrialUnavailable");
label += "(阶段包不可用)";
} else if (app.isPackaged) {
try {
const state = await options.readLoginPreference();
if (disposed || current !== generation) return;
checked = state.enabled;
enabled = state.supported;
if (state.status === "managed-by-cli") label += t("nativeCliManaged");
if (state.status === "managed-by-cli") label += "(由 CLI 管理)";
} catch {
label += t("nativeStatusUnavailable");
label += "(状态不可用)";
enabled = false;
}
} else {
label += t("nativeDevelopmentUnavailable");
label += "(开发模式不可用)";
}
tray.setContextMenu(
options.buildMenu([
{ label: t("nativeOpen"), click: options.open },
{ label: `打开 ${productName}`, click: options.open },
{
label,
type: "checkbox",
@@ -80,19 +74,14 @@ export function installBackgroundTray(options: TrayOptions): () => void {
void options
.setLoginPreference(item.checked)
.catch(() =>
options.reportError(
translate(
options.language?.read().locale ?? "zh-CN",
"nativeLoginError",
),
),
options.reportError("无法更改登录启动设置,请重试。"),
)
.finally(() => void refresh());
},
},
{ type: "separator" },
{
label: t("nativeQuit"),
label: `退出 ${productName}(后台服务继续运行)`,
click: () => {
if (!disposed) app.quit();
},
@@ -100,7 +89,6 @@ export function installBackgroundTray(options: TrayOptions): () => void {
]),
);
};
const unsubscribeLanguage = options.language?.subscribe(() => void refresh());
try {
void refresh();
// Refresh before the native menu opens, including changes made in Settings.
@@ -113,7 +101,6 @@ export function installBackgroundTray(options: TrayOptions): () => void {
return () => {
if (disposed) return;
disposed = true;
unsubscribeLanguage?.();
tray.removeAllListeners("mouse-down");
tray.removeAllListeners("right-click");
tray.destroy();
@@ -50,7 +50,6 @@ interface Overrides {
whenReady?: Promise<void>;
windowGate?: Promise<void>;
windowError?: Error;
beforeNativeUi?: () => Promise<void>;
/** Give the lifecycle a startup placeholder, as the packaged App does. */
startupWindow?: boolean;
startupWindowError?: Error;
@@ -95,7 +94,6 @@ function fixture(background = false, lock = true, overrides: Overrides = {}) {
platform: overrides.platform ?? "darwin",
background,
prepare,
beforeNativeUi: overrides.beforeNativeUi,
initialize,
stop,
dispose,
@@ -150,35 +148,6 @@ function fixture(background = false, lock = true, overrides: Overrides = {}) {
}
describe("desktop background lifecycle with injected Electron", () => {
it("awaits the post-readiness language hook before menus, tray and runtime", async () => {
const gate = deferred();
const beforeNativeUi = vi.fn(() => gate.promise);
const f = fixture(false, true, { beforeNativeUi });
expect(beforeNativeUi).not.toHaveBeenCalled();
f.ready.resolve();
await f.flush();
expect(beforeNativeUi).toHaveBeenCalledTimes(1);
expect(f.installTray).not.toHaveBeenCalled();
expect(f.initialize).not.toHaveBeenCalled();
gate.resolve();
f.starting.resolve();
await f.flush();
expect(f.installTray).toHaveBeenCalledTimes(1);
expect(f.windows).toHaveLength(1);
});
it("does not install native UI after quitting during the language hook", async () => {
const gate = deferred();
const f = fixture(false, true, { beforeNativeUi: () => gate.promise });
f.ready.resolve();
await f.flush();
f.quit();
gate.resolve();
await f.flush();
expect(f.installTray).not.toHaveBeenCalled();
expect(f.initialize).not.toHaveBeenCalled();
f.stopping.resolve();
await f.flush();
});
it("starts services and tray without a window in background mode", async () => {
const f = fixture(true);
await f.boot();
@@ -18,7 +18,6 @@ interface LifecycleOptions {
platform: NodeJS.Platform;
background: boolean;
prepare(): void;
beforeNativeUi?(): Promise<void>;
initialize(isQuitting: () => boolean): Promise<void>;
stop(): Promise<void>;
dispose(): void;
@@ -153,8 +152,6 @@ export function startDesktopLifecycle(options: LifecycleOptions): void {
.then(async () => {
if (quitting) return;
startup = Promise.resolve().then(async () => {
if (quitting) return;
await options.beforeNativeUi?.();
if (quitting) return;
options.installTray(open);
// A window first, services second. Startup can take tens of seconds;
@@ -1,8 +1,4 @@
import { EventEmitter } from "node:events";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { translate } from "@one-memory/i18n";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
interface FakeFrame {
@@ -15,17 +11,13 @@ interface FakeFrame {
const fake = vi.hoisted(() => ({
ready: undefined as (() => Promise<void>) | undefined,
handlers: new Map<string, (event: unknown, ...args: unknown[]) => unknown>(),
handlers: new Map<string, (event: unknown) => unknown>(),
windows: [] as FakeWindow[],
app: undefined as unknown as EventEmitter,
theme: undefined as unknown as EventEmitter,
renderer: undefined as unknown as EventEmitter,
unload: undefined as unknown as EventTarget,
packaged: true,
userData: "",
preferred: vi.fn(() => ["es-MX", "zh-CN"]),
locale: vi.fn(() => "zh-CN"),
menu: vi.fn(),
loadFailure: false,
reduceMotion: false as unknown,
reduceTransparency: false as unknown,
@@ -101,10 +93,7 @@ vi.mock("electron", () => ({
}),
isPackaged: fake.packaged,
getName: () => "One Memory Preview",
getPath: (name: string) =>
name === "userData" ? fake.userData : "/unused/display-test",
getPreferredSystemLanguages: fake.preferred,
getLocale: fake.locale,
getPath: () => "/unused/display-test",
getAppPath: () => "/unused/display-test",
quit: vi.fn(),
exit: vi.fn(),
@@ -130,10 +119,8 @@ vi.mock("electron", () => ({
},
BrowserWindow: FakeWindow,
ipcMain: {
handle: (
channel: string,
handler: (event: unknown, ...args: unknown[]) => unknown,
) => fake.handlers.set(channel, handler),
handle: (channel: string, handler: (event: unknown) => unknown) =>
fake.handlers.set(channel, handler),
removeHandler: (channel: string) => {
fake.removeHandler(channel);
fake.handlers.delete(channel);
@@ -151,7 +138,7 @@ vi.mock("electron", () => ({
session: { defaultSession: {} },
clipboard: { writeText: vi.fn() },
dialog: { showErrorBox: fake.error },
Menu: { buildFromTemplate: vi.fn(() => ({})), setApplicationMenu: fake.menu },
Menu: { buildFromTemplate: vi.fn(() => ({})), setApplicationMenu: vi.fn() },
shell: { openExternal: vi.fn() },
}));
vi.mock("@one-memory/host-bridge", () => ({
@@ -203,8 +190,7 @@ vi.mock("./core-supervisor", () => ({
findAvailablePort: async () => 9999,
}));
beforeEach(async () => {
fake.userData = await mkdtemp(path.join(os.tmpdir(), "memhub-language-ipc-"));
beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();
fake.app = new EventEmitter();
@@ -226,9 +212,8 @@ beforeEach(async () => {
vi.stubEnv("ONE_MEMORY_WEB_URL", "http://127.0.0.1:5173");
vi.stubEnv("ONE_MEMORY_SMOKE_QUIT_MS", "0");
});
afterEach(async () => {
afterEach(() => {
fake.app.emit("will-quit");
await rm(fake.userData, { recursive: true, force: true });
fake.unload.dispatchEvent(new Event("unload"));
vi.unstubAllGlobals();
vi.unstubAllEnvs();
@@ -257,91 +242,6 @@ const eventFor = (window: FakeWindow) => ({
senderFrame: window.frame,
});
describe("GUI language main IPC", () => {
it("loads the active profile after ready and before native menus", async () => {
await writeFile(
path.join(fake.userData, "gui-language.json"),
JSON.stringify({ preference: "es" }),
);
await import("./main.js");
expect(fake.preferred).not.toHaveBeenCalled();
expect(fake.menu).not.toHaveBeenCalled();
await fake.ready?.();
expect(fake.preferred).toHaveBeenCalledTimes(1);
expect(fake.preferred.mock.invocationCallOrder[0]).toBeLessThan(
fake.menu.mock.invocationCallOrder[0],
);
const read = fake.handlers.get("one-memory:get-language-preference");
if (!read) throw Error("Missing language read handler");
expect(await read(eventFor(fake.windows[0]))).toEqual({
preference: "es",
locale: "es",
revision: 0,
});
});
it("guards both operations and broadcasts only to live owned main frames", async () => {
const window = await startup();
const read = fake.handlers.get("one-memory:get-language-preference");
if (!read) throw Error("Missing language read handler");
const write = fake.handlers.get("one-memory:set-language-preference");
if (!write) throw Error("Missing language write handler");
const foreign = new FakeWindow();
for (const event of [
eventFor(foreign),
{
sender: window.webContents,
senderFrame: { ...window.frame, parent: window.frame },
},
{ sender: window.webContents, senderFrame: null },
]) {
await expect(read(event)).rejects.toThrow();
await expect(write(event, "es")).rejects.toThrow();
}
window.frame.url = "https://example.invalid";
await expect(read(eventFor(window))).rejects.toThrow();
await expect(write(eventFor(window), "es")).rejects.toThrow();
window.frame.url = "one-memory://app/index.html";
await write(eventFor(window), "zh-CN");
expect(window.frame.send).toHaveBeenCalledWith(
"one-memory:language-preference-changed",
{ preference: "zh-CN", locale: "zh-CN", revision: 1 },
);
expect(foreign.frame.send).not.toHaveBeenCalled();
window.frame.send.mockImplementation(() => {
throw Error("destroyed during send");
});
await expect(write(eventFor(window), "es")).resolves.toMatchObject({
revision: 2,
});
fake.app.emit("will-quit");
expect(fake.handlers.has("one-memory:get-language-preference")).toBe(false);
expect(fake.handlers.has("one-memory:set-language-preference")).toBe(false);
});
it("does not deliver an admitted write to a document that navigated during persistence", async () => {
const window = await startup();
const write = fake.handlers.get("one-memory:set-language-preference");
const read = fake.handlers.get("one-memory:get-language-preference");
if (!write || !read) throw Error("Missing language handlers");
const result = write(eventFor(window), "es");
window.frame.url = "https://example.invalid";
await expect(result).rejects.toThrow();
expect(window.frame.send).not.toHaveBeenCalled();
window.frame.url = "one-memory://app/index.html";
expect(await read(eventFor(window))).toMatchObject({
preference: "es",
revision: 1,
});
});
it("uses the shared locale for the native error title without translating error codes", async () => {
fake.loadFailure = true;
await startup();
expect(fake.error).toHaveBeenCalledWith(
translate("es", "nativeErrorTitle"),
"page_load_failed",
);
});
});
describe("display preference main IPC", () => {
it("registers background inspection against the fixed data directory and guards navigation", async () => {
const window = await startup();
@@ -512,9 +412,6 @@ describe("display preference main IPC", () => {
interface PreferenceAPI {
getBackgroundHealth(): Promise<unknown>;
restartBackground(): Promise<{ state: "restarted" }>;
getLanguagePreference(): Promise<unknown>;
setLanguagePreference(preference: unknown): Promise<unknown>;
onLanguagePreferenceChanged(listener: (value: unknown) => void): () => void;
getDisplayPreferences(): Promise<{
reduceMotion: boolean;
reduceTransparency: boolean;
@@ -527,51 +424,6 @@ async function preload() {
return fake.expose.mock.calls[0][1] as PreferenceAPI;
}
describe("language preference preload", () => {
it("validates writes and snapshots, strips event metadata and cleans subscriptions", async () => {
const api = await preload();
const snapshot = { preference: "es", locale: "es", revision: 2 };
fake.invoke.mockResolvedValue({ ...snapshot, secret: "not exposed" });
expect(await api.getLanguagePreference()).toEqual(snapshot);
expect(await api.setLanguagePreference("es")).toEqual(snapshot);
expect(fake.invoke).toHaveBeenLastCalledWith(
"one-memory:set-language-preference",
"es",
);
fake.invoke.mockClear();
await expect(api.setLanguagePreference("fr")).rejects.toThrow();
expect(fake.invoke).not.toHaveBeenCalled();
const listener = vi.fn();
const unsubscribe = api.onLanguagePreferenceChanged(listener);
for (const invalid of [
null,
{},
{ ...snapshot, revision: -1 },
{ ...snapshot, preference: "en" },
{ ...snapshot, locale: "en" },
]) {
fake.invoke.mockResolvedValue(invalid);
await expect(api.getLanguagePreference()).rejects.toThrow();
await expect(api.setLanguagePreference("es")).rejects.toThrow();
fake.renderer.emit("one-memory:language-preference-changed", {}, invalid);
}
expect(listener).not.toHaveBeenCalled();
fake.renderer.emit(
"one-memory:language-preference-changed",
{ secret: true },
{ ...snapshot, secret: true },
);
expect(listener).toHaveBeenCalledExactlyOnceWith(snapshot);
expect(listener.mock.calls[0][0]).not.toBe(snapshot);
fake.unload.dispatchEvent(new Event("unload"));
unsubscribe();
unsubscribe();
expect(
fake.renderer.listenerCount("one-memory:language-preference-changed"),
).toBe(0);
});
});
describe("display preference preload", () => {
it("preload exposes a no-argument background health read", async () => {
const api = await preload();
@@ -2,10 +2,8 @@ import { EventEmitter } from "node:events";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { translate } from "@one-memory/i18n";
import { afterEach, describe, expect, it, vi } from "vitest";
import { installEarlyStartupErrorHandler } from "./early-startup-errors";
import { stableRuntimeUserMessage } from "./stable-runtime";
const roots: string[] = [];
@@ -16,40 +14,6 @@ afterEach(async () => {
});
describe("early desktop startup errors", () => {
it.each(["es", "throws"] as const)(
"uses best-effort language (%s) without changing safe error codes",
async (language) => {
const dataDir = await mkdtemp(
path.join(os.tmpdir(), "desktop-early-language-"),
);
roots.push(dataDir);
const processEvents = new EventEmitter();
const showError = vi.fn();
const exit = vi.fn();
const dispose = installEarlyStartupErrorHandler({
processEvents,
dataDir,
stderr: { write: vi.fn(() => true) },
showError,
exit,
readLocale: () => {
if (language === "throws") throw new Error("unavailable");
return "es";
},
});
processEvents.emit(
"uncaughtException",
new Error("synthetic_safe_failure"),
);
expect(showError).toHaveBeenCalledExactlyOnceWith(
language === "es" ? "MemHub no pudo iniciarse" : "MemHub 无法启动",
stableRuntimeUserMessage(new Error("synthetic_safe_failure")),
);
expect(exit).toHaveBeenCalledExactlyOnceWith(1);
dispose();
},
);
it("replaces an earlier handler on the same process emitter", () => {
const processEvents = new EventEmitter();
const options = {
@@ -68,37 +32,6 @@ describe("early desktop startup errors", () => {
dispose();
});
it.each(["en", "es", undefined] as const)(
"uses a safe title for initialized locale %s and retains the technical code",
async (locale) => {
const dataDir = await mkdtemp(
path.join(os.tmpdir(), "desktop-early-locale-"),
);
roots.push(dataDir);
const processEvents = new EventEmitter();
const showError = vi.fn();
const exit = vi.fn();
const dispose = installEarlyStartupErrorHandler({
processEvents,
dataDir,
stderr: { write: vi.fn(() => true) },
showError,
exit,
readLocale: () => locale,
});
processEvents.emit(
"uncaughtException",
new Error("desktop_startup_failed"),
);
expect(showError).toHaveBeenCalledExactlyOnceWith(
translate(locale ?? "zh-CN", "nativeErrorTitle"),
stableRuntimeUserMessage(new Error("desktop_startup_failed")),
);
expect(exit).toHaveBeenCalledExactlyOnceWith(1);
dispose();
},
);
it("writes stderr and diagnostics before showing the fatal dialog", async () => {
const dataDir = await mkdtemp(
path.join(os.tmpdir(), "desktop-early-error-"),
@@ -1,5 +1,4 @@
import type { EventEmitter } from "node:events";
import { type GuiLocale, translate } from "@one-memory/i18n";
import {
diagnosticError,
writeDiagnosticEventSync,
@@ -13,8 +12,6 @@ interface EarlyStartupErrorHandlerOptions {
showError(title: string, message: string): void;
exit(code: number): void;
afterDiagnosticWrite?: () => void;
/** Only already initialized preferences; never read configuration during a fatal error. */
readLocale?: () => GuiLocale | undefined;
}
const SAFE_ERROR_CODE = /^[a-z][a-z0-9_]{0,127}$/;
@@ -59,16 +56,7 @@ export function installEarlyStartupErrorHandler(
// The dialog is the only part of this a person ever reads, so it gets the
// explanation, not the code. The code stays in the diagnostic event,
// where support can still match it against a known failure.
let locale: GuiLocale = "zh-CN";
try {
locale = options.readLocale?.() ?? "zh-CN";
} catch {
// Preferences may not exist yet; do not read files or guess the locale.
}
options.showError(
translate(locale, "nativeErrorTitle"),
stableRuntimeUserMessage(error),
);
options.showError("MemHub 无法启动", stableRuntimeUserMessage(error));
options.exit(1);
}
};
@@ -1,55 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { LanguagePreferences } from "./language-preferences";
import { installLanguagePreferencesIpc } from "./language-preferences-ipc";
describe("language preference IPC", () => {
it("guards reads/writes, validates payloads, broadcasts commits and disposes", async () => {
const handlers = new Map<
string,
(event: string, ...args: unknown[]) => Promise<unknown>
>();
const save = vi.fn(async () => {});
const preferences = await LanguagePreferences.open(
"/synthetic/profile",
"es",
{ read: async () => "{}", save },
);
const broadcast = vi.fn();
const dispose = installLanguagePreferencesIpc({
ipc: {
handle: (name, handler) => handlers.set(name, handler),
removeHandler: (name) => {
handlers.delete(name);
},
},
preferences,
requireTrustedWindow: (event: string) => {
if (event !== "trusted") throw Error("untrusted");
},
broadcast,
});
const read = handlers.get("one-memory:get-language-preference");
const write = handlers.get("one-memory:set-language-preference");
if (!read || !write) throw Error("Missing language handlers");
await expect(read("foreign")).rejects.toThrow("untrusted");
await expect(write("iframe", "es")).rejects.toThrow("untrusted");
await expect(write("trusted", "fr")).rejects.toThrow();
await expect(write("trusted", "es", "extra")).rejects.toThrow();
expect(save).not.toHaveBeenCalled();
expect(await read("trusted")).toEqual({
preference: "system",
locale: "es",
revision: 0,
});
await write("trusted", "en");
expect(broadcast).toHaveBeenCalledExactlyOnceWith({
preference: "en",
locale: "en",
revision: 1,
});
dispose();
dispose();
expect(handlers.size).toBe(0);
await expect(write("trusted", "es")).rejects.toThrow();
});
});
@@ -1,47 +0,0 @@
import { isLanguagePreference, type LanguageSnapshot } from "@one-memory/i18n";
import type { LanguagePreferences } from "./language-preferences";
const GET = "one-memory:get-language-preference";
const SET = "one-memory:set-language-preference";
export function installLanguagePreferencesIpc<Event>(options: {
ipc: {
handle(
name: string,
handler: (event: Event, ...args: unknown[]) => Promise<unknown>,
): unknown;
removeHandler(name: string): void;
};
preferences: LanguagePreferences;
requireTrustedWindow(event: Event): unknown;
broadcast(snapshot: LanguageSnapshot): void;
}): () => void {
let closed = false;
const requireTrusted = (event: Event) => {
if (closed) throw Error("language_preferences_closed");
options.requireTrustedWindow(event);
};
options.ipc.handle(GET, async (event, ...args) => {
requireTrusted(event);
if (args.length) throw Error("invalid_language_preference_arguments");
return options.preferences.read();
});
options.ipc.handle(SET, async (event, ...args) => {
requireTrusted(event);
if (args.length !== 1 || !isLanguagePreference(args[0]))
throw Error("invalid_language_preference");
const snapshot = await options.preferences.set(args[0]);
requireTrusted(event);
return snapshot;
});
const unsubscribe = options.preferences.subscribe((snapshot) => {
if (!closed) options.broadcast(snapshot);
});
return () => {
if (closed) return;
closed = true;
unsubscribe();
options.ipc.removeHandler(GET);
options.ipc.removeHandler(SET);
};
}
@@ -1,141 +0,0 @@
import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
LanguagePreferences,
preferredSystemLanguage,
} from "./language-preferences";
const roots: string[] = [];
async function profile() {
const root = await mkdtemp(path.join(os.tmpdir(), "memhub-language-"));
roots.push(root);
return root;
}
afterEach(async () => {
await Promise.all(
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
);
});
describe("GUI language preferences", () => {
it("defaults to system, persists only the preference in the active profile and reloads", async () => {
const root = await profile();
const store = await LanguagePreferences.open(root, "es-MX");
expect(store.read()).toEqual({
preference: "system",
locale: "es",
revision: 0,
});
expect(await readdir(root)).toEqual([]);
expect(await store.set("zh-CN")).toEqual({
preference: "zh-CN",
locale: "zh-CN",
revision: 1,
});
expect(
JSON.parse(await readFile(path.join(root, "gui-language.json"), "utf8")),
).toEqual({ preference: "zh-CN" });
expect((await LanguagePreferences.open(root, "es")).read().preference).toBe(
"zh-CN",
);
expect(
(await LanguagePreferences.open(await profile(), "es")).read().preference,
).toBe("system");
expect(await readdir(root)).toEqual(["gui-language.json"]);
});
it.each(["{broken", '{"preference":"fr"}', "null", "[]"])(
"does not overwrite invalid input %s while loading",
async (content) => {
const root = await profile();
const file = path.join(root, "gui-language.json");
await writeFile(file, content);
const store = await LanguagePreferences.open(root, "es");
expect(store.read().preference).toBe("system");
expect(await readFile(file, "utf8")).toBe(content);
},
);
it("survives failed reads and preserves the snapshot on failed writes, then recovers", async () => {
const save = vi
.fn()
.mockRejectedValueOnce(new Error("disk full"))
.mockResolvedValue(undefined);
const store = await LanguagePreferences.open("/synthetic/profile", "es", {
read: async () => {
throw new Error("unreadable");
},
save,
});
const listener = vi.fn();
store.subscribe(listener);
await expect(store.set("zh-CN")).rejects.toThrow(
"language_preference_save_failed",
);
expect(store.read()).toEqual({
preference: "system",
locale: "es",
revision: 0,
});
expect(listener).not.toHaveBeenCalled();
await store.set("zh-CN");
expect(store.read().revision).toBe(1);
expect(listener).toHaveBeenCalledTimes(1);
});
it("serializes racing writes and emits only committed snapshots", async () => {
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const save = vi
.fn()
.mockImplementationOnce(() => gate)
.mockResolvedValue(undefined);
const store = await LanguagePreferences.open("/synthetic/profile", "es", {
read: async () => "{}",
save,
});
const listener = vi.fn();
const unsubscribe = store.subscribe(listener);
const first = store.set("zh-CN");
const second = store.set("es");
await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(1));
expect(store.read().revision).toBe(0);
release();
expect((await first).revision).toBe(1);
expect(await second).toEqual({
preference: "es",
locale: "es",
revision: 2,
});
expect(listener.mock.calls.map(([value]) => value.revision)).toEqual([
1, 2,
]);
unsubscribe();
await store.set("system");
expect(listener).toHaveBeenCalledTimes(2);
await expect(store.set("invalid" as "es")).rejects.toThrow();
});
it("uses only the primary preferred language, falling back to locale if unavailable", () => {
expect(
preferredSystemLanguage({
getPreferredSystemLanguages: () => ["en", "es"],
getLocale: () => "es",
}),
).toBe("en");
expect(
preferredSystemLanguage({
getPreferredSystemLanguages: () => [],
getLocale: () => "es",
}),
).toBe("es");
expect(
preferredSystemLanguage({
getPreferredSystemLanguages: () => {
throw Error();
},
getLocale: () => "es",
}),
).toBe("es");
});
});
@@ -1,135 +0,0 @@
import { randomUUID } from "node:crypto";
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import {
isLanguagePreference,
type LanguagePreference,
type LanguageSnapshot,
resolveGuiLocale,
} from "@one-memory/i18n";
interface Storage {
read(file: string): Promise<string>;
save(file: string, content: string): Promise<void>;
}
const storage: Storage = {
read: (file) => readFile(file, "utf8"),
async save(file, content) {
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
const temporary = `${file}.${randomUUID()}.tmp`;
try {
await writeFile(temporary, content, {
encoding: "utf8",
mode: 0o600,
flag: "wx",
});
await rename(temporary, file);
} finally {
await rm(temporary, { force: true }).catch(() => {});
}
},
};
/** GUI-only preferences, scoped to the active Electron userData profile. */
export class LanguagePreferences {
private queue: Promise<unknown> = Promise.resolve();
private readonly listeners = new Set<(snapshot: LanguageSnapshot) => void>();
private snapshot: LanguageSnapshot;
private constructor(
private readonly file: string,
private readonly systemLanguage: string | undefined,
private readonly io: Storage,
preference: LanguagePreference,
) {
this.snapshot = {
preference,
locale: resolveGuiLocale(preference, systemLanguage),
revision: 0,
};
}
static async open(
userData: string,
systemLanguage?: string,
io: Storage = storage,
): Promise<LanguagePreferences> {
const file = path.join(userData, "gui-language.json");
let preference: LanguagePreference = "system";
try {
const data: unknown = JSON.parse(await io.read(file));
if (
data &&
typeof data === "object" &&
"preference" in data &&
isLanguagePreference(data.preference)
) {
preference = data.preference;
}
} catch {
// Missing, unreadable or corrupt preferences never block startup or get rewritten.
}
return new LanguagePreferences(file, systemLanguage, io, preference);
}
read(): LanguageSnapshot {
return { ...this.snapshot };
}
set(preference: LanguagePreference): Promise<LanguageSnapshot> {
if (!isLanguagePreference(preference))
return Promise.reject(Error("invalid_language_preference"));
const operation = this.queue.then(async () => {
if (preference === this.snapshot.preference) return this.read();
try {
await this.io.save(this.file, `${JSON.stringify({ preference })}\n`);
} catch {
throw Error("language_preference_save_failed");
}
this.snapshot = {
preference,
locale: resolveGuiLocale(preference, this.systemLanguage),
revision: this.snapshot.revision + 1,
};
for (const listener of this.listeners) {
try {
listener(this.read());
} catch {
/* A closed consumer cannot undo a committed save. */
}
}
return this.read();
});
this.queue = operation.catch(() => {});
return operation;
}
subscribe(listener: (snapshot: LanguageSnapshot) => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
async drain(): Promise<void> {
await this.queue;
}
}
/** Call only after Electron readiness: later preferred languages are not fallbacks. */
export function preferredSystemLanguage(app: {
getPreferredSystemLanguages(): string[];
getLocale(): string;
}): string | undefined {
try {
const primary = app.getPreferredSystemLanguages()[0];
if (primary) return primary;
} catch {
/* Older/unavailable platform API: use Electron's locale. */
}
try {
return app.getLocale() || undefined;
} catch {
return undefined;
}
}
+7 -79
View File
@@ -9,7 +9,6 @@ import {
renderCustomHarnessSkill,
verifyRecallInstall,
} from "@one-memory/host-bridge/installer";
import { type GuiLocale, translate } from "@one-memory/i18n";
import {
claimRuntimeServiceForApp,
claimRuntimeServiceForCli,
@@ -92,11 +91,6 @@ import {
resolvePackagedBuildNumber,
resolvePackagedCommit,
} from "./isolated-runtime";
import {
LanguagePreferences,
preferredSystemLanguage,
} from "./language-preferences";
import { installLanguagePreferencesIpc } from "./language-preferences-ipc";
import {
disableLegacyLoginItem,
wasOpenedByLegacyLoginItem,
@@ -131,9 +125,6 @@ let runtimeSupervisor: RuntimeSupervisor | null = null;
let runtimeDataDir: string | null = null;
let preserveManagedRuntimeOnQuit = false;
let disposeTray: (() => void) | null = null;
let disposeApplicationMenu: (() => void) | null = null;
let languagePreferences: LanguagePreferences | null = null;
let disposeLanguagePreferences: (() => void) | null = null;
const displayWindows = new Set<BrowserWindow>();
let disposeDisplayPreferences: (() => void) | null = null;
let disposeSourceDirectoryPicker: (() => void) | null = null;
@@ -155,7 +146,6 @@ installEarlyStartupErrorHandler({
process.env.ONE_MEMORY_DATA_DIR?.trim() ||
path.join(app.getPath("home"), ".one-memory"),
stderr: process.stderr,
readLocale: () => languagePreferences?.read().locale ?? "zh-CN",
showError: (title, message) => dialog.showErrorBox(title, message),
exit: (code) => app.exit(code),
});
@@ -284,16 +274,7 @@ let appProtocolInstalled = false;
function installPackagedWebProtocol(): void {
if (!app.isPackaged || appProtocolInstalled) return;
installAppProtocol(path.join(process.resourcesPath, "web"), {
// Preferences are open by now: beforeNativeUi runs before this window.
startupScreen: () => {
let locale: GuiLocale = "zh-CN";
try {
locale = languagePreferences?.read().locale ?? "zh-CN";
} catch {
// An unreadable preference keeps the Chinese placeholder.
}
return startupScreenHtml(locale);
},
startupScreen: startupScreenHtml,
});
appProtocolInstalled = true;
}
@@ -545,11 +526,6 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
"apps/core/.venv/bin",
process.platform === "win32" ? "one-memory.exe" : "one-memory",
),
hermesProviderSource: path.join(
path.dirname(stable?.mcpEntryPath ?? path.join(hostBridgeDir, "mcp.cjs")),
"hermes-provider",
"hy_memory",
),
dataDir,
};
const standaloneRuntime =
@@ -1100,26 +1076,6 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
};
try {
if (languagePreferences) {
disposeLanguagePreferences = installLanguagePreferencesIpc({
ipc: ipcMain,
preferences: languagePreferences,
requireTrustedWindow,
broadcast: (snapshot) => {
for (const window of displayWindows) {
try {
assertTrustedWindow(window);
window.webContents.mainFrame.send(
"one-memory:language-preference-changed",
snapshot,
);
} catch {
// Navigation/destruction races must never leak to another document.
}
}
},
});
}
disposeDisplayPreferences = installDisplayPreferences();
disposeSourceDirectoryPicker = installSourceDirectoryPicker();
if (appManagedRuntime) {
@@ -1336,20 +1292,10 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
});
void dialog.showMessageBox({
type: "warning",
message: translate(
languagePreferences?.read().locale ?? "zh-CN",
"nativeMoveTitle",
),
detail: translate(
languagePreferences?.read().locale ?? "zh-CN",
"nativeMoveDetail",
),
buttons: [
translate(
languagePreferences?.read().locale ?? "zh-CN",
"nativeAcknowledge",
),
],
message: "请先将 MemHub 拖到“应用程序”文件夹",
detail:
"从“应用程序”重新打开后,MemHub 才会接入其他应用并安装命令行工具。",
buttons: ["知道了"],
});
} else if (
trialProfile === null &&
@@ -1516,21 +1462,14 @@ if (desktopVersion !== null) {
platform: process.platform,
background: desktopBackgroundLaunch,
prepare: prepareProtocol,
beforeNativeUi: async () => {
languagePreferences = await LanguagePreferences.open(
app.getPath("userData"),
preferredSystemLanguage(app),
);
},
initialize,
createWindow,
showStartupWindow: createStartupWindow,
revealStartupWindow,
getWindows: () => [...displayWindows],
installTray: (open) => {
disposeApplicationMenu = installApplicationMenu({
installApplicationMenu({
productName: app.getName(),
language: languagePreferences ?? undefined,
buildMenu: (template) => Menu.buildFromTemplate(template),
setMenu: (menu) => Menu.setApplicationMenu(menu),
exportLogs: async () => {
@@ -1542,7 +1481,6 @@ if (desktopVersion !== null) {
});
disposeTray = installBackgroundTray({
app,
language: languagePreferences ?? undefined,
platform: process.platform,
resourcesPath: process.resourcesPath,
appPath: app.getAppPath(),
@@ -1587,9 +1525,6 @@ if (desktopVersion !== null) {
...(coreSupervisor ? { instance_id: coreSupervisor.instanceId } : {}),
});
// An admitted native effect must settle and journal before Runtime detach.
disposeLanguagePreferences?.();
disposeLanguagePreferences = null;
await languagePreferences?.drain();
await connectionSetup?.drain();
// BUG-12: stopping the supervisor only releases this process's handles. In
// a packaged build the Runtime is its own process and was left running
@@ -1638,10 +1573,6 @@ if (desktopVersion !== null) {
});
},
dispose: () => {
disposeLanguagePreferences?.();
disposeLanguagePreferences = null;
disposeApplicationMenu?.();
disposeApplicationMenu = null;
connectionSetup?.dispose();
disposeDiagnostics?.();
disposeDiagnostics = null;
@@ -1663,10 +1594,7 @@ if (desktopVersion !== null) {
error instanceof Error ? error.name : "UnknownError",
);
dialog.showErrorBox(
translate(
languagePreferences?.read().locale ?? "zh-CN",
"nativeErrorTitle",
),
"MemHub 无法启动",
error instanceof Error ? error.message : "desktop_unavailable",
);
},
@@ -1,7 +0,0 @@
import type { LanguageSnapshot } from "@one-memory/i18n";
/** Shared committed GUI language for native menus, tray, and dialogs. */
export interface NativeLanguageSource {
read(): LanguageSnapshot;
subscribe(listener: (snapshot: LanguageSnapshot) => void): () => void;
}
@@ -59,9 +59,6 @@ it("loads the production preload in a sandbox with Electron as its only module",
const bridge = exposed as unknown as Record<string, unknown>;
expect(bridge.getApiBaseUrl).toBeTypeOf("function");
expect(bridge.getApiToken).toBeTypeOf("function");
expect(bridge.getLanguagePreference).toBeTypeOf("function");
expect(bridge.setLanguagePreference).toBeTypeOf("function");
expect(bridge.onLanguagePreferenceChanged).toBeTypeOf("function");
} finally {
rmSync(output, { recursive: true, force: true });
}
-54
View File
@@ -10,12 +10,6 @@ import type {
RecallInstallPreview,
RecallUninstallPreview,
} from "@one-memory/host-bridge/installer";
import {
isLanguagePreference,
isLanguageSnapshot,
type LanguagePreference,
type LanguageSnapshot,
} from "@one-memory/i18n";
import type { BackgroundHealth } from "@one-memory/runtime-control";
import { contextBridge, ipcRenderer } from "electron";
import type { DebugLogSnapshot } from "./diagnostics-ipc";
@@ -40,60 +34,12 @@ declare const __ONE_MEMORY_VERSION__: string;
declare const __ONE_MEMORY_BUILD_NUMBER__: string;
declare const __ONE_MEMORY_COMMIT__: string;
function languageSnapshot(payload: unknown): LanguageSnapshot {
if (!isLanguageSnapshot(payload))
throw Error("language_preferences_unavailable");
return {
preference: payload.preference,
locale: payload.locale,
revision: payload.revision,
};
}
contextBridge.exposeInMainWorld("oneMemory", {
getBuildInfo: () => ({
version: __ONE_MEMORY_VERSION__,
buildNumber: __ONE_MEMORY_BUILD_NUMBER__,
commit: __ONE_MEMORY_COMMIT__,
}),
getLanguagePreference: async (): Promise<LanguageSnapshot> =>
languageSnapshot(
await ipcRenderer.invoke("one-memory:get-language-preference"),
),
setLanguagePreference: async (
preference: LanguagePreference,
): Promise<LanguageSnapshot> => {
if (!isLanguagePreference(preference))
throw Error("invalid_language_preference");
return languageSnapshot(
await ipcRenderer.invoke(
"one-memory:set-language-preference",
preference,
),
);
},
onLanguagePreferenceChanged: (
listener: (value: LanguageSnapshot) => void,
): (() => void) => {
if (typeof listener !== "function")
throw Error("invalid_language_preference_listener");
const handler = (_event: unknown, payload: unknown) => {
if (isLanguageSnapshot(payload)) listener(languageSnapshot(payload));
};
let subscribed = true;
const unsubscribe = () => {
if (!subscribed) return;
subscribed = false;
ipcRenderer.removeListener(
"one-memory:language-preference-changed",
handler,
);
window.removeEventListener("unload", unsubscribe);
};
ipcRenderer.on("one-memory:language-preference-changed", handler);
window.addEventListener("unload", unsubscribe, { once: true });
return unsubscribe;
},
getDebugLogs: (): Promise<DebugLogSnapshot> =>
ipcRenderer.invoke("one-memory:get-debug-logs"),
logUiTrace: (input: {
@@ -11,16 +11,6 @@ describe("startup screen", () => {
expect(html).not.toContain("oneMemory");
expect(html).not.toContain("ipcRenderer");
});
it("speaks the interface language and keeps Chinese by default", () => {
expect(startupScreenHtml()).toContain('<html lang="zh-CN">');
expect(startupScreenHtml()).toContain("正在启动,正在准备记忆服务…");
const spanish = startupScreenHtml("es");
expect(spanish).toContain('<html lang="es">');
expect(spanish).toContain(
"Iniciando y preparando el servicio de memorias…",
);
expect(spanish).not.toMatch(/[一-鿿]/);
});
it("is served from the app origin, not from the web bundle", () => {
expect(STARTUP_SCREEN_URL).toBe("one-memory://app/__startup");
});
+3 -4
View File
@@ -1,4 +1,3 @@
import { type GuiLocale, translate } from "@one-memory/i18n";
import { APP_ORIGIN, STARTUP_SCREEN_PATH } from "./app-protocol";
export const STARTUP_SCREEN_URL = `${APP_ORIGIN}${STARTUP_SCREEN_PATH}`;
@@ -11,9 +10,9 @@ export const STARTUP_SCREEN_URL = `${APP_ORIGIN}${STARTUP_SCREEN_PATH}`;
* the gap. It deliberately makes no IPC calls: it renders before `initialize`
* has registered any handler, and anything it asked for would fail.
*/
export function startupScreenHtml(locale: GuiLocale = "zh-CN"): string {
export function startupScreenHtml(): string {
return `<!doctype html>
<html lang="${locale}">
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'" />
@@ -103,7 +102,7 @@ export function startupScreenHtml(locale: GuiLocale = "zh-CN"): string {
<body>
<main>
<h1>MemHub</h1>
<p>${translate(locale, "startupPreparing")}</p>
<p>正在启动,正在准备记忆服务…</p>
<div class="bar"><span></span></div>
</main>
</body>
-2
View File
@@ -34,10 +34,8 @@ export default defineConfig({
noExternal: [
"@one-memory/host-bridge",
"@one-memory/contracts",
"@one-memory/i18n",
"@one-memory/runtime-control",
"axios",
"i18next",
"https-proxy-agent",
"proxy-from-env",
],
-1
View File
@@ -11,7 +11,6 @@
},
"dependencies": {
"@one-memory/contracts": "workspace:*",
"@one-memory/i18n": "workspace:*",
"@one-memory/operations": "workspace:*",
"@tanstack/react-query": "^5.103.2",
"axios": "^1.20.0",
-25
View File
@@ -9,31 +9,6 @@ import {
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { App } from "./App";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
it("localizes shared navigation and footer in Spanish", async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
render(
<LanguageProvider>
<App />
</LanguageProvider>,
);
const nav = await screen.findByRole("navigation", {
name: "Navegación principal",
});
expect(
within(nav)
.getAllByRole("button")
.map((button) => button.textContent),
).toEqual(["Aplicaciones", "Memorias", "Configuración"]);
expect(within(nav).getByRole("button", { name: "Configuración" }).title).toBe(
"Configuración",
);
expect(screen.getAllByText("Almacenamiento local").length).toBeGreaterThan(0);
expect(
screen.getByRole("link", { name: "Saltar al contenido principal" }),
).toBeTruthy();
});
const memoryProps = vi.hoisted(() => vi.fn());
+13 -15
View File
@@ -22,12 +22,10 @@ import {
CALL_HISTORY_ENTRY_VISIBLE,
} from "./ApplicationsHome";
import { getOnboarding } from "./api";
import { useApplicationsLanguage } from "./applications-language";
import { CallHistory } from "./CallHistory";
import { DebugLogPanel } from "./DebugLogPanel";
import { DesktopFrame } from "./DesktopFrame";
import { FirstUseGuide } from "./FirstUseGuide";
import { useGuiLanguage } from "./gui-language";
import { HealthPanel } from "./HealthPanel";
import { ImportProgressStrip } from "./ImportProgressStrip";
import { MatterWorkspace } from "./MatterWorkspace";
@@ -66,8 +64,6 @@ export function App() {
}
function AppContent() {
const { t } = useGuiLanguage();
const { a } = useApplicationsLanguage();
const [onboarding, setOnboarding] = useState<OnboardingState | null>(null);
const [startupRead, setStartupRead] = useState<"loading" | "ready" | "error">(
"loading",
@@ -220,19 +216,19 @@ function AppContent() {
{startupRead === "error" ? (
<div className="boot-status">
<p className="error-banner" role="alert">
{t("startupReadError")}
暂时无法读取启动状态,请重试。
</p>
<R6Button
onClick={() => {
if (!startupFlight.current) readStartup();
}}
>
{t("startupRetry")}
重新读取
</R6Button>
</div>
) : (
<p className="boot-status" aria-live="polite">
{t("startupOpening")}
正在打开 MemHub…
</p>
)}
</main>
@@ -268,9 +264,9 @@ function AppContent() {
the text it used to contain. */}
<header className="r6-titlebar" />
<a className="skip-link" href="#main-content">
{a("skip")}
跳到主要内容
</a>
<nav className="app-rail" aria-label={a("navigation")}>
<nav className="app-rail" aria-label="主要导航">
<div className="brand-mark" title="MemHub">
<Layers3 size={21} strokeWidth={1.6} aria-hidden="true" />
<span>MemHub</span>
@@ -289,7 +285,7 @@ function AppContent() {
key={destination}
active={displayedView === destination}
tourId={destination === "sources" ? "nav-sources" : undefined}
label={a(destination === "memory" ? "navMemories" : "apps")}
label={destination === "memory" ? "记忆" : "应用"}
icon={
destination === "memory" ? (
<Network size={19} />
@@ -312,13 +308,16 @@ function AppContent() {
<RailButton
active={displayedView === "settings" || displayedView === "health"}
tourId="nav-settings"
label={t("settings")}
label="设置"
icon={<Settings size={18} />}
onClick={() => navigate("settings")}
/>
<p className="rail-core" title={a("localHint")}>
<p
className="rail-core"
title="记忆保存在本机;使用云模型整理或交给已授权的助手时,相关内容会发送给对应服务。"
>
<span aria-hidden="true" />
{a("local")}
本地存储
</p>
</div>
</nav>
@@ -440,7 +439,7 @@ function AppContent() {
onClick={() => navigate("settings")}
>
<ArrowLeft size={16} />
{t("settings")}
设置
</button>
<HealthPanel imports={imports} />
</>
@@ -512,7 +511,6 @@ function RailButton({
className={`rail-button${active ? " active" : ""}`}
aria-current={active ? "page" : undefined}
data-tour-id={tourId}
title={label}
onClick={onClick}
>
<span className="rail-icon" aria-hidden="true">
@@ -12,32 +12,6 @@ import {
import { useState } from "react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { ApplicationDetail } from "./ApplicationDetail";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
it("translates application detail live without triggering extra reads or writes", async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
const refresh = vi.fn().mockResolvedValue({ sources: [source] });
render(
<LanguageProvider>
<ApplicationDetail {...props} refresh={refresh} />
</LanguageProvider>,
);
expect(
await screen.findByRole("heading", { name: "Detalles de la aplicación" }),
).toBeTruthy();
expect(screen.getByRole("switch", { name: "Sincronización" })).toBeTruthy();
await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1));
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
await act(async () =>
window.dispatchEvent(
new StorageEvent("storage", { key: LANGUAGE_STORAGE_KEY }),
),
);
expect(await screen.findByRole("heading", { name: "应用详情" })).toBeTruthy();
expect(refresh).toHaveBeenCalledTimes(1);
expect(props.setSync).not.toHaveBeenCalled();
});
import { saveConnectionPreferences } from "./connection-preferences";
import { resetConnectionReconcile } from "./connection-reconcile";
import { commitSources } from "./source-state";
@@ -602,109 +576,6 @@ it("shows insertion still on when neither removal nor revocation succeeded", asy
);
});
let languageListener: ((snapshot: unknown) => void) | undefined;
function switchLanguage(locale: "es" | "zh-CN", revision: number) {
act(() => languageListener?.({ preference: locale, locale, revision }));
}
async function failDisconnectInSpanish() {
const oneMemory = window.oneMemory as unknown as Record<string, unknown>;
oneMemory.getLanguagePreference = vi.fn(async () => ({
preference: "es",
locale: "es",
revision: 1,
}));
oneMemory.onLanguagePreferenceChanged = vi.fn(
(listener: (snapshot: unknown) => void) => {
languageListener = listener;
return () => {};
},
);
render(
<LanguageProvider>
<ApplicationDetail
{...props}
source={opencode}
sources={[opencode]}
refresh={vi.fn().mockResolvedValue({ sources: [opencode] })}
/>
</LanguageProvider>,
);
const insertion = await screen.findByRole("switch", {
name: "Conectar con un clic",
});
await waitFor(() =>
expect(insertion.getAttribute("aria-checked")).toBe("true"),
);
fireEvent.click(insertion);
return insertion;
}
it("words a failed turn-off with its reason in Spanish, without Chinese punctuation", async () => {
editedHostBridge({ revokeFails: true });
await failDisconnectInSpanish();
const alert = await screen.findByText(/No se pudo eliminar la conexión/);
expect(alert.textContent).toBe(
"No se pudo eliminar la conexión ni revocar el permiso. OpenCode aún puede usar memorias. Reintenta. Motivo: El canal de control de la aplicación no está disponible.",
);
expect(alert.textContent).not.toMatch(/[。:;,]/);
});
it("re-words a visible failure when the language changes, without new writes", async () => {
const bridge = editedHostBridge({ revokeFails: true });
await failDisconnectInSpanish();
await screen.findByText(/Motivo: El canal de control/);
const reads = (
window.oneMemory as unknown as {
getRecallHosts: ReturnType<typeof vi.fn>;
}
).getRecallHosts;
const readCount = reads.mock.calls.length;
const revokes = bridge.revokeRecallPermission.mock.calls.length;
switchLanguage("zh-CN", 2);
expect(
await screen.findByText(
"连接未能移除,记忆权限也未能撤销,OpenCode 仍可使用记忆,请重试。原因:应用控制通道暂不可用。",
),
).toBeTruthy();
switchLanguage("es", 3);
expect(await screen.findByText(/Motivo: El canal de control/)).toBeTruthy();
expect(bridge.revokeRecallPermission.mock.calls.length).toBe(revokes);
expect(bridge.uninstallRecall).not.toHaveBeenCalled();
expect(reads.mock.calls.length).toBe(readCount);
});
it("omits the reason in Spanish when the failure has no known cause", async () => {
editedHostBridge({ revokeFails: true });
const oneMemory = window.oneMemory as unknown as Record<string, unknown>;
oneMemory.previewRecallUninstall = vi.fn(async () => {
throw Error("something_unknown");
});
oneMemory.revokeRecallPermission = vi.fn(async () => {
throw Error("something_unknown");
});
await failDisconnectInSpanish();
const alert = await screen.findByText(/No se pudo eliminar la conexión/);
expect(alert.textContent).toBe(
"No se pudo eliminar la conexión ni revocar el permiso. OpenCode aún puede usar memorias. Reintenta.",
);
});
it("adds the failed re-read note in Spanish after the reason", async () => {
editedHostBridge({ revokeFails: true });
const oneMemory = window.oneMemory as unknown as Record<string, unknown>;
const getRecallHosts = oneMemory.getRecallHosts as ReturnType<typeof vi.fn>;
const revoke = oneMemory.revokeRecallPermission as ReturnType<typeof vi.fn>;
revoke.mockImplementation(async () => {
getRecallHosts.mockRejectedValue(Error("offline"));
throw Error("host_control_unavailable");
});
await failDisconnectInSpanish();
const alert = await screen.findByText(/No se pudo eliminar la conexión/);
expect(alert.textContent).toBe(
"No se pudo eliminar la conexión ni revocar el permiso. OpenCode aún puede usar memorias. Reintenta. Motivo: El canal de control de la aplicación no está disponible. Tampoco se pudo volver a leer la conexión; el estado mostrado podría no estar actualizado.",
);
});
it("explains why an app without a revocable connection cannot be inserted", async () => {
const pi = {
...source,
+63 -110
View File
@@ -1,5 +1,4 @@
import type { SourceResponse } from "@one-memory/contracts";
import type { MessageKey } from "@one-memory/i18n";
import { useCallback, useEffect, useRef, useState } from "react";
import { AppMark } from "./app-mark";
import { connectionHostId, preferenceHasSource } from "./connection-hosts";
@@ -15,6 +14,7 @@ import {
hostCapabilities,
hostConnectionApproved,
hostConnectionUnsupported,
hostFailureNote,
hostRereadFailed,
hostWriteReason,
matchHost,
@@ -24,7 +24,6 @@ import {
UNSUPPORTED_NOTE,
useRecallHosts,
} from "./host-state";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Button, R6Switch } from "./R6Controls";
import {
latestSource,
@@ -33,20 +32,16 @@ import {
} from "./source-state";
import "./application-detail.css";
/** A failed host write as data: worded only when rendered. */
type HostFailure = { reason: string | null; rereadFailed: boolean };
type InstallError = {
key: MessageKey;
params: Record<string, string>;
failure?: HostFailure;
};
const SYNC_UNCONFIRMED = "此应用的处理开关结果未能确认,其他应用不受影响。";
// D97: the single sync switch now governs both taking in new data and
// generating memories, so the separate read-permission switch is hidden, not
// removed — flip this back to restore it without digging through history.
const MEMORY_PERMISSION_SECTION_VISIBLE = false;
function connectFailure(reason: string | null, rereadFailed = false) {
return `此应用的一键接入未完成,其他应用不受影响。${hostFailureNote(reason, rereadFailed)}`;
}
function connectionLabel(input: {
approved: boolean;
insertionOn: boolean;
@@ -90,13 +85,6 @@ export function ApplicationDetail({
refresh(): Promise<{ sources: SourceResponse[] }>;
onBack(): void;
}) {
const { text, t, locale } = useOnboardingTranslation();
/** Word a host failure at render time so a language switch re-words it. */
function failureNote({ reason, rereadFailed }: HostFailure) {
return `${reason ? t("appFailureReason", { reason: text(reason) }) : ""}${
rereadFailed ? t("appFailureReread") : ""
}`;
}
const source = useSource(given);
const hosts = useRecallHosts().data;
const [read, setRead] = useState<{
@@ -110,7 +98,7 @@ export function ApplicationDetail({
const [refreshing, setRefreshing] = useState(false);
const [refreshError, setRefreshError] = useState(false);
const [installing, setInstalling] = useState(false);
const [installError, setInstallError] = useState<string | InstallError>("");
const [installError, setInstallError] = useState("");
const [syncing, setSyncing] = useState(false);
const [syncError, setSyncError] = useState("");
const [memoryPending, setMemoryPending] = useState(false);
@@ -317,15 +305,7 @@ export function ApplicationDetail({
});
const hostId =
host?.hostId ?? connectionHostId(source.source_id) ?? source.source_id;
const hostError =
typeof installError === "string"
? text(installError)
: t(installError.key, {
...installError.params,
...(installError.failure
? { reason: failureNote(installError.failure) }
: {}),
});
const hostError = installError;
async function applyInsertion(enabled: boolean) {
const bridge = window.oneMemory;
if (!bridge) return;
@@ -338,30 +318,27 @@ export function ApplicationDetail({
const { outcome } = await disconnectHost(hostId);
if (outcome.state === "failed")
throw Object.assign(Error("disconnect_failed"), {
failure: {
reason: outcome.reason,
rereadFailed: outcome.rereadFailed,
} satisfies HostFailure,
note: hostFailureNote(outcome.reason, outcome.rereadFailed),
});
setLink(null);
if (outcome.state === "review")
setInstallError({
key:
setInstallError(
`${
outcome.permission?.receipt?.status === "revoked"
? "appRevokedReview"
: "appRemovalReview",
params: {
name: source.display_name,
paths: outcome.findings
.map((finding) => `${finding.path}(${finding.where})`)
.join(";"),
},
});
? `已撤销记忆权限,${source.display_name} 已查不到记忆。但`
: ""
}无法确认连接已全部移除,需要你手动核对:${outcome.findings
.map((finding) => `${finding.path}(${finding.where})`)
.join(";")}。`,
);
if (outcome.state === "revoked")
setInstallError({
key: outcome.configEdited ? "appRevokedModified" : "appRevokedRetained",
params: { name: source.display_name },
});
setInstallError(
`已撤销记忆权限,${source.display_name} 已查不到记忆。${
outcome.configEdited
? "配置文件被改过,MemHub 没有自动删除;请手动移除其中的 MemHub 连接。"
: "连接文件没有移除,请重新核对。"
}`,
);
}
async function changeInsertion(enabled: boolean) {
writeConnectionPreference("host", source.source_id, enabled);
@@ -387,22 +364,10 @@ export function ApplicationDetail({
// shows the grant that is really still there after the re-read.
setInstallError(
wanted
? {
key: "appConnectFailed",
params: {},
failure: {
reason: hostWriteReason(error),
rereadFailed: hostRereadFailed(error),
},
}
: {
key: "appDisconnectFailed",
params: { name: source.display_name },
failure: (error as { failure?: HostFailure }).failure ?? {
reason: null,
rereadFailed: false,
},
},
? connectFailure(hostWriteReason(error), hostRereadFailed(error))
: `连接未能移除,记忆权限也未能撤销,${source.display_name} 仍可使用记忆,请重试。${
(error as { note?: string }).note ?? ""
}`,
);
}
}
@@ -488,14 +453,12 @@ export function ApplicationDetail({
);
} else if (!wanted) setHostRestartNotice("");
} catch (error) {
setInstallError({
key: "appPermissionFailed",
params: {},
failure: {
reason: hostWriteReason(error),
rereadFailed: hostRereadFailed(error),
},
});
setInstallError(
`此应用的记忆使用权限未能确认,请重试。${hostFailureNote(
hostWriteReason(error),
hostRereadFailed(error),
)}`,
);
}
}
} finally {
@@ -509,11 +472,11 @@ export function ApplicationDetail({
return (
<section
className="r6-app-detail"
aria-label={t("appSettingsTitle", { name: source.display_name })}
aria-label={`${source.display_name} 应用设置`}
>
<h1 className="r6-app-detail-title">{text("应用详情")}</h1>
<h1 className="r6-app-detail-title">应用详情</h1>
<R6Button variant="quiet" onClick={onBack}>
{text("返回应用")}
返回应用
</R6Button>
<header>
<span className="r6-app-detail-icon">
@@ -521,33 +484,31 @@ export function ApplicationDetail({
</span>
<div>
<h2>{source.display_name}</h2>
<p>{text(syncStatus)}</p>
<p>{syncStatus}</p>
</div>
</header>
<div className="r6-app-settings">
<section className="r6-app-setting">
<div>
<h2>{text("同步开关")}</h2>
<p>{text("开启后处理历史待办,并持续接收此应用的新会话。")}</p>
<h2>同步开关</h2>
<p>开启后处理历史待办,并持续接收此应用的新会话。</p>
{syncing && syncChecked && (
<p role="status">
{text(
"正在读取已有对话,首次开启可能需要一两分钟,请不要重复点击。",
)}
正在读取已有对话,首次开启可能需要一两分钟,请不要重复点击。
</p>
)}
{backfillNotice && (
<p role="status">
{text("将补充冻结期间的新对话;原有 pending Job 继续处理。")}
将补充冻结期间的新对话;原有 pending Job 继续处理。
</p>
)}
{!syncing && authorizationNote && (
<p role="status">{text(authorizationNote)}</p>
<p role="status">{authorizationNote}</p>
)}
{syncError && <p role="alert">{text(syncError)}</p>}
{syncError && <p role="alert">{syncError}</p>}
</div>
<R6Switch
label={text("同步开关")}
label="同步开关"
checked={syncChecked}
pending={syncing}
disabled={
@@ -559,19 +520,19 @@ export function ApplicationDetail({
</section>
<section className="r6-app-setting">
<div>
<h2>{text("一键插入应用")}</h2>
<p>{text("安装记忆连接,并默认允许此应用使用已授权记忆。")}</p>
<h2>一键插入应用</h2>
<p>安装记忆连接,并默认允许此应用使用已授权记忆。</p>
{unsupported && (
<p>{`${source.display_name} ${text(UNSUPPORTED_NOTE)}`}</p>
<p>{`${source.display_name} ${UNSUPPORTED_NOTE}`}</p>
)}
{hostError && <p role="alert">{hostError}</p>}
{appLocationNote && <p role="status">{text(appLocationNote)}</p>}
{appLocationNote && <p role="status">{appLocationNote}</p>}
{hostRestartNotice ? (
<p role="status">{text(hostRestartNotice)}</p>
<p role="status">{hostRestartNotice}</p>
) : null}
</div>
<R6Switch
label={text("一键插入应用")}
label="一键插入应用"
checked={insertionChecked}
pending={installing}
disabled={
@@ -588,11 +549,11 @@ export function ApplicationDetail({
{MEMORY_PERMISSION_SECTION_VISIBLE && (
<section className="r6-app-setting">
<div>
<h2>{text("允许使用记忆")}</h2>
<p>{t("appMemoryScope", { name: source.display_name })}</p>
<h2>允许使用记忆</h2>
<p>让 {source.display_name} 查询已授权范围内的记忆。</p>
</div>
<R6Switch
label={text("允许使用记忆")}
label="允许使用记忆"
checked={memoryChecked}
pending={memoryPending}
disabled={
@@ -606,38 +567,30 @@ export function ApplicationDetail({
)}
<section className="r6-app-setting">
<div>
<h2>{text("连接状态")}</h2>
<h2>连接状态</h2>
{refreshError && (
<p role="alert">
{text("暂时无法刷新来源状态,请重试;未更改同步或读取权限。")}
暂时无法刷新来源状态,请重试;未更改同步或读取权限。
</p>
)}
{readFailed && (
<p role="alert">
{text("暂时无法读取连接状态,当前状态待核对;请重试。")}
</p>
<p role="alert">暂时无法读取连接状态,当前状态待核对;请重试。</p>
)}
{probeState && probeState !== "wired" && (
<p>{t("appRestart", { name: source.display_name })}</p>
<p>
{`记忆工具没有进到 ${source.display_name} 当前对话。完全退出 ${source.display_name} 后重新打开,新对话里才会出现。`}
</p>
)}
{probeState === "wired" && (
<p>
{text(
"已接通。已经打开的对话不会中途出现这个工具,请新开一轮。",
)}
</p>
<p>已接通。已经打开的对话不会中途出现这个工具,请新开一轮。</p>
)}
{linkFailed && (
<p role="alert">
{text(
"本次连接探测失败,当前状态待核对;未把上次读取当作本次成功。",
)}
本次连接探测失败,当前状态待核对;未把上次读取当作本次成功。
</p>
)}
{checkedAt && !stale && current?.state === "ready" && (
<small>
{text("上次读取")}: {new Date(checkedAt).toLocaleString(locale)}
</small>
<small>上次读取:{new Date(checkedAt).toLocaleString()}</small>
)}
</div>
<div className="r6-app-detail-actions">
@@ -647,7 +600,7 @@ export function ApplicationDetail({
aria-busy={probing || undefined}
onClick={() => void reread()}
>
<span>{text(stateText)}</span>
<span>{stateText}</span>
{probing ? (
<span className="r6-button-spinner" aria-hidden="true" />
) : null}
@@ -17,54 +17,8 @@ import {
import * as api from "./api";
import { saveConnectionPreferences } from "./connection-preferences";
import { resetConnectionReconcile } from "./connection-reconcile";
import { LanguageProvider } from "./gui-language";
import { queryClient, resetClient } from "./query-client";
it("localizes the whole home live without repeating reads or changing imported text", async () => {
let change:
| ((snapshot: {
preference: "es" | "zh-CN";
locale: "es" | "zh-CN";
revision: number;
}) => void)
| undefined;
Object.assign(window.oneMemory ?? {}, {
getLanguagePreference: vi
.fn()
.mockResolvedValue({ preference: "es", locale: "es", revision: 1 }),
onLanguagePreferenceChanged: vi.fn((listener) => {
change = listener;
return () => {};
}),
});
render(<LanguageProvider>{mountHome()}</LanguageProvider>);
await screen.findByRole("heading", { name: "Aplicaciones", level: 1 });
await screen.findByText("实际治理记忆");
expect(
screen.getByRole("region", { name: "Resumen de memorias" }),
).toBeTruthy();
expect(screen.getByText("Servicio activo")).toBeTruthy();
expect(screen.getByText("2 aplicaciones conectadas")).toBeTruthy();
expect(screen.getAllByRole("button", { name: "Ver detalles" })).toHaveLength(
3,
);
const calls = summary.mock.calls.length;
fireEvent.click(
screen.getByRole("button", { name: "Ver las 5 aplicaciones" }),
);
act(() => change?.({ preference: "zh-CN", locale: "zh-CN", revision: 2 }));
expect(
screen.getByRole("heading", { name: "全部应用", level: 1 }),
).toBeTruthy();
expect(screen.getByText("App 4")).toBeTruthy();
expect(summary).toHaveBeenCalledTimes(calls);
act(() => change?.({ preference: "es", locale: "es", revision: 3 }));
expect(
screen.getByRole("heading", { name: "Todas las aplicaciones", level: 1 }),
).toBeTruthy();
expect(summary).toHaveBeenCalledTimes(calls);
});
const {
summary,
processing,
@@ -160,58 +114,6 @@ beforeEach(() => {
]),
} as unknown as NonNullable<typeof window.oneMemory>;
});
it.each([
[true, "Organizando", "Organizando nuevas conversaciones"],
[false, "En pausa", "Se dejó de generar memorias nuevas"],
])(
"renders the Spanish sedimentation state without extra requests: %s",
async (enabled, label, detail) => {
// Task rows still running must not override the sedimentation switch.
processing.mockResolvedValue({ running: 3, pending: 10 });
sedimentation.mockResolvedValue({
enabled,
pending_retained: true,
imports_unaffected: true,
});
Object.assign(window.oneMemory ?? {}, {
getLanguagePreference: vi
.fn()
.mockResolvedValue({ preference: "es", locale: "es", revision: 1 }),
onLanguagePreferenceChanged: vi.fn(() => () => {}),
});
render(<LanguageProvider>{mountHome()}</LanguageProvider>);
expect(await screen.findAllByText(label)).toHaveLength(2);
expect(screen.getByText(detail)).toBeTruthy();
expect(sedimentation).toHaveBeenCalledOnce();
},
);
it("localizes failure and empty states without leaking errors", async () => {
summary.mockRejectedValue(new Error("synthetic_private_error"));
Object.assign(window.oneMemory ?? {}, {
getLanguagePreference: vi
.fn()
.mockResolvedValue({ preference: "es", locale: "es", revision: 1 }),
onLanguagePreferenceChanged: vi.fn(() => () => {}),
});
render(
<LanguageProvider>
<ApplicationsHome
imports={{ ...imports, sources: [] }}
onManage={() => {}}
onMemories={() => {}}
onSettings={() => {}}
/>
</LanguageProvider>,
);
expect(
await screen.findByText("No se puede leer el estado de las memorias"),
).toBeTruthy();
expect(screen.getByText("Aún no hay aplicaciones para mostrar")).toBeTruthy();
expect(
screen.getByRole("button", { name: "Reintentar lectura" }),
).toBeTruthy();
expect(document.body.textContent).not.toContain("synthetic_private_error");
});
it("hides a debug-frame dialogue excerpt from 最近更新 and falls back to memory", async () => {
recentConversations.mockResolvedValue({
items: [
@@ -596,41 +498,6 @@ it("warns only after three consecutive timeouts and keeps them out of app rows",
expect(screen.queryByRole("button", { name: "查看记录" })).toBeNull();
}
});
it("shows the consecutive-timeout warning in Spanish with the host name", async () => {
history.mockResolvedValue({
available: true,
recording_error: false,
items: Array.from({ length: 3 }, (_, index) => ({
event_id: `timeout-es-${index}`,
host_id: "workbuddy",
tool: "om_recall",
state: "timed_out",
occurred_at: new Date(Date.now() - index * 1000).toISOString(),
memory_count: 0,
raw_count: 0,
references: [],
})),
});
Object.assign(window.oneMemory ?? {}, {
getLanguagePreference: vi
.fn()
.mockResolvedValue({ preference: "es", locale: "es", revision: 1 }),
onLanguagePreferenceChanged: vi.fn(() => () => {}),
});
render(
<LanguageProvider>
<ApplicationsHome
imports={imports}
onManage={() => {}}
onMemories={() => {}}
onSettings={() => {}}
onCalls={() => {}}
/>
</LanguageProvider>,
);
const warning = await screen.findByText(/Las últimas consultas de WorkBuddy/);
expect(warning.textContent).not.toMatch(/[\u4e00-\u9fff]/);
});
it("uses live-sync success time for an application row", async () => {
liveSync.mockResolvedValue([
{
@@ -1427,36 +1294,3 @@ describe("运行状态卡:一次失败的刷新不能让卡片继续说在运
expect(screen.getByText("601")).toBeTruthy();
});
});
it("words an automatic sync failure and its reason in Spanish", async () => {
localStorage.setItem(
"one-memory.connection-preferences.v1",
JSON.stringify({ version: 1, syncSourceIds: ["app-0"], hostSourceIds: [] }),
);
queryClient.setQueryData(["connection-auto"], {
running: [],
attempted: ["app-0"],
failures: [
{
kind: "sync",
sourceId: "app-0",
reason: "此应用的读取授权已撤销,请重新授权。",
},
],
});
Object.assign(window.oneMemory ?? {}, {
getLanguagePreference: vi
.fn()
.mockResolvedValue({ preference: "es", locale: "es", revision: 1 }),
onLanguagePreferenceChanged: vi.fn(() => () => {}),
});
render(<LanguageProvider>{mountHome()}</LanguageProvider>);
const notice = await screen.findByText(
/No se pudo activar la sincronización continua de App 0\./,
);
expect(notice.textContent).toContain(
"Se revocó el permiso de lectura de esta aplicación. Vuelve a autorizarla.",
);
expect(notice.textContent).not.toMatch(/[一-鿿]/);
localStorage.removeItem("one-memory.connection-preferences.v1");
});
+111 -163
View File
@@ -5,7 +5,6 @@ import type {
RecentConversationItem,
SourceResponse,
} from "@one-memory/contracts";
import { type GuiLocale, isMessageKey } from "@one-memory/i18n";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { flushSync } from "react-dom";
@@ -31,7 +30,6 @@ import {
retryAutoReconcile,
useAutoConnect,
} from "./connection-reconcile";
import { useGuiLanguage } from "./gui-language";
import { HomeCardBoundary } from "./HomeCardBoundary";
import {
hostBridgeAvailable,
@@ -72,15 +70,13 @@ function presentableDialogue(text: string): boolean {
const retainedViews = new WeakMap<object, HomeViewState>();
type Translate = ReturnType<typeof useGuiLanguage>["t"];
function clockParts(value: string, locale: GuiLocale) {
function clockParts(value: string) {
const ms = Date.parse(value);
if (!Number.isFinite(ms)) return null;
const date = new Date(ms);
const now = new Date();
return {
clock: new Intl.DateTimeFormat(locale, {
clock: new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit",
}).format(date),
@@ -91,17 +87,16 @@ function clockParts(value: string, locale: GuiLocale) {
};
}
function stamped(value: string, locale: GuiLocale, t: Translate, suffix = "") {
const parts = clockParts(value, locale);
if (!parts) return `${t("apps.unknownTime")}${suffix}`;
return `${parts.today ? t("apps.today") : ""}${parts.clock}${suffix}`;
function stamped(value: string, suffix = "") {
const parts = clockParts(value);
if (!parts) return `时间未知${suffix}`;
return `${parts.today ? "今天 " : ""}${parts.clock}${suffix}`;
}
function memoryAction(action: string | undefined, t: Translate) {
if (action === "create") return t("apps.newMemory");
if (action === "correct" || action === "restore")
return t("apps.updatedMemory");
return t("apps.saved");
function memoryAction(action: string | undefined) {
if (action === "create") return "新增记忆";
if (action === "correct" || action === "restore") return "更新记忆";
return "记忆已保存";
}
type ActivityChoice = "call" | "update";
@@ -140,32 +135,23 @@ function callAttention(events: CallEvent[]) {
function sourceActivity(
source: SourceResponse,
events: CallEvent[],
locale: GuiLocale,
t: Translate,
callHistoryUnavailable = false,
): string {
if (!source.authorized) return t("apps.unlinked");
if (!source.authorized) return "尚未关联";
const latest = events.find(
(item) =>
successfulCall(item) && sourceMatchesHost(source.source_id, item.host_id),
);
if (!latest) return "";
const time = stamped(latest.occurred_at, locale, t);
const prefix = callHistoryUnavailable ? t("apps.lastRead") : "";
return `${prefix}${callDetailHeadline(latest, source.display_name, locale)} · ${time}`;
const time = stamped(latest.occurred_at);
const prefix = callHistoryUnavailable ? "上次读取:" : "";
return `${prefix}${callDetailHeadline(latest, source.display_name)} · ${time}`;
}
function sourceSyncActivity(
sourceId: string,
sync: LiveSyncHealth[],
locale: GuiLocale,
t: Translate,
): string {
function sourceSyncActivity(sourceId: string, sync: LiveSyncHealth[]): string {
const current = sync.find((item) => item.source_id === sourceId);
return current?.last_success_at
? t("apps.recentSync", {
time: stamped(current.last_success_at, locale, t),
})
? `最近同步 ${stamped(current.last_success_at)}`
: "";
}
@@ -195,7 +181,6 @@ export function ApplicationsHome({
onCalls?(): void;
active?: boolean;
}) {
const { locale, t, tn } = useGuiLanguage();
const hostQuery = useRecallHosts({ poll: active });
const hostBridge = hostBridgeAvailable();
const hosts = hostQuery.data ?? [];
@@ -641,7 +626,7 @@ export function ApplicationsHome({
sourceMatchesHost(source.source_id, recentCall?.host_id ?? null),
);
const attention = callAttention(callEvents);
const attentionHost = attention ? callHostName(attention.host, locale) : null;
const attentionHost = attention ? callHostName(attention.host) : null;
const activity = summaryUnavailable
? "unavailable"
: visible === null
@@ -672,16 +657,16 @@ export function ApplicationsHome({
);
const stateLabel =
sedimentation?.enabled === true
? t("apps.running")
? "整理中"
: sedimentation?.enabled === false
? t("apps.paused")
: t("apps.unchecked");
? "已暂停"
: "待核对";
const stateDetail =
sedimentation?.enabled === false
? t("apps.stoppedNew")
? "已停止生成新记忆"
: sedimentation?.enabled === true
? t("apps.organizingNew")
: t("apps.checkingProcessing");
? "新的对话正在整理"
: "正在核对整理状态";
const latestUpdatedAt =
latest === null || latest === undefined
? null
@@ -690,9 +675,7 @@ export function ApplicationsHome({
: latest.gmt_created === null
? null
: new Date(latest.gmt_created * 1000).toISOString();
const latestClock = latestUpdatedAt
? clockParts(latestUpdatedAt, locale)
: null;
const latestClock = latestUpdatedAt ? clockParts(latestUpdatedAt) : null;
const latestSourceIds =
latest === null || latest === undefined
? []
@@ -701,8 +684,8 @@ export function ApplicationsHome({
: latest.source
? [latest.source]
: [];
const time = latestClock?.clock ?? (latest ? t("apps.unknownTime") : null);
const checked = visible ? clockParts(visible.observed_at, locale) : null;
const time = latestClock?.clock ?? (latest ? "时间未知" : null);
const checked = visible ? clockParts(visible.observed_at) : null;
// A failed read leaves the last health in place on purpose: clearing it would
// drop the instance it names, and that instance is what keeps memory writes
// and backfill receipts attached to the run that produced them. So the card
@@ -718,24 +701,20 @@ export function ApplicationsHome({
(!hostBridge && hostState !== "ready") ||
(admitted === 0 && hostApplyPending(auto, preferences.hostSourceIds));
const accessText = accessPending
? t("apps.checkingNow")
? "正在核对中"
: hostReadFailed
? t("apps.unchecked")
? "待核对"
: !unchecked.length
? tn("apps.connectedCount", admitted)
? `${admitted} 个应用已接入`
: admitted
? tn("apps.mixedCount", admitted, {
pending: new Intl.NumberFormat(locale).format(unchecked.length),
})
: tn("apps.pendingCount", unchecked.length);
? `${admitted} 个应用已接入,${unchecked.length} 个待核对`
: `${unchecked.length} 个应用待核对`;
const accessUnknownText = accessPending
? null
: hostReadFailed
? t("apps.unknownAccess")
? "应用接入状态暂时没有读到,无法确认有几个应用已接入。"
: unchecked.length
? t("apps.unknownPermissions", {
names: unchecked.map((host) => host.label).join(", "),
})
? `${unchecked.map((host) => host.label).join("、")} 的记忆权限暂时没有读到,无法确认是否已接入。`
: null;
async function restartBackground() {
if (restartingBackground) return;
@@ -772,23 +751,11 @@ export function ApplicationsHome({
source.source_id === failure.sourceId && source.sync_enabled,
),
);
const localizeFailureReason = (reason: string | null | undefined) =>
reason && isMessageKey(reason) ? t(reason) : (reason ?? "");
const autoFailureText = autoFailures
.map((failure) =>
failure.kind === "host"
? t("apps.autoHostFailed", {
name: nameOf(failure.sourceId),
reason: failure.reason
? t("apps.failureReason", {
reason: localizeFailureReason(failure.reason),
})
: "",
})
: t("apps.autoSyncFailed", {
name: nameOf(failure.sourceId),
reason: localizeFailureReason(failure.reason),
}),
? `${nameOf(failure.sourceId)} 未能自动接入${failure.reason ? `:${failure.reason}` : ""}。`
: `${nameOf(failure.sourceId)} 的持续同步未能自动开启。${failure.reason ?? ""}`,
)
.join("");
const authorizedSyncSources = imports.sources.filter(
@@ -799,12 +766,12 @@ export function ApplicationsHome({
(failure) => failure.kind === "sync",
);
const syncState = authorizedSyncSources.length
? tn("apps.enabledCount", authorizedSyncSources.length)
? `${authorizedSyncSources.length} 个应用已开启`
: syncApplyFailed
? t("apps.enableFailed")
? "未能开启"
: wantsSync || !visible
? t("apps.checkingState")
: t("apps.notEnabled");
? "正在检查状态"
: "尚未开启";
function toggleAll(next: boolean) {
transition.current(next);
}
@@ -812,7 +779,7 @@ export function ApplicationsHome({
<section
className="r6-home"
ref={root}
aria-label={t("apps.home")}
aria-label="应用首页"
data-expanded={expanded}
data-phase={phase}
onFocusCapture={(event) => {
@@ -821,7 +788,7 @@ export function ApplicationsHome({
}}
>
<header className="r6-home-heading">
<h1>{expanded ? t("apps.allApps") : t("apps.apps")}</h1>
<h1>{expanded ? "全部应用" : "应用"}</h1>
</header>
<button
type="button"
@@ -834,7 +801,7 @@ export function ApplicationsHome({
>
<span className="r6-button-label">
<ArrowLeft size={16} aria-hidden="true" />
{t("apps.back")}
返回首页
</span>
</button>
<div
@@ -843,14 +810,14 @@ export function ApplicationsHome({
hidden={expanded}
inert={expanded || phase !== "idle"}
>
<HomeCardBoundary label={t("apps.overview")}>
<section className="r6-home-overview" aria-label={t("apps.overview")}>
<HomeCardBoundary label="记忆概览">
<section className="r6-home-overview" aria-label="记忆概览">
<header>
<h2>{t("apps.overview")}</h2>
<h2>记忆概览</h2>
</header>
<div className="r6-home-metrics">
<div>
<p>{t("apps.memories")}</p>
<p>已沉淀记忆</p>
{/* The figure slot used to hold the word 读取中 while the first
read was in flight, which is how "大数字不可见" (D51) shows up
on a slow link: the biggest type on the page spends the first
@@ -866,41 +833,38 @@ export function ApplicationsHome({
aria-hidden="true"
/>
</p>
<small role="status">{t("apps.readingCount")}</small>
<small role="status">正在读取已沉淀记忆数量…</small>
</>
) : (
<>
<p className="r6-home-figure">
<strong className={visible ? "" : "r6-home-word"}>
{visible
? new Intl.NumberFormat(locale).format(
? new Intl.NumberFormat("zh-CN").format(
visible.memory_count,
)
: t("apps.unreadable")}
: "暂无法读取"}
</strong>
{visible && t("apps.unit") ? (
<i>{t("apps.unit")}</i>
) : null}
{visible ? <i>条</i> : null}
</p>
<small>{t("apps.scope")}</small>
<small>按应用授权范围使用</small>
</>
)}
</div>
<div>
<p>{t("apps.processing")}</p>
<p>沉淀状态</p>
<strong className="r6-home-word">{stateLabel}</strong>
<small>{stateDetail}</small>
</div>
<div>
<p>{t("apps.recentUpdate")}</p>
<p>最近更新</p>
<strong className={time ? "r6-home-time" : "r6-home-word"}>
{time ??
(visible ? t("apps.notGenerated") : t("apps.unchecked"))}
{time ?? (visible ? "尚未生成" : "待核对")}
</strong>
<small>
{time
? `${latestClock?.today ? `${t("apps.today")}· ` : ""}${t("apps.saved")}`
: t("apps.separate")}
? `${latestClock?.today ? "今天 · " : ""}记忆已保存`
: "历史与记忆分别处理"}
</small>
</div>
</div>
@@ -922,21 +886,19 @@ export function ApplicationsHome({
}
onClick={() => setActivityView(item)}
>
{item === "call"
? t("apps.recentCall")
: t("apps.recentUpdate")}
{item === "call" ? "最近调用" : "最近更新"}
</button>
))}
</div>
) : (
<p>
{activity === "unavailable"
? t("apps.activityUnavailable")
? "动态暂不可用"
: activity === "call"
? t("apps.recentCall")
? "最近调用"
: activity === "dialogue" || activity === "memory"
? t("apps.recentUpdate")
: t("apps.firstBatch")}
? "最近更新"
: "首批沉淀"}
</p>
)}
<div>
@@ -976,45 +938,44 @@ export function ApplicationsHome({
<h3>
{activity === "unavailable"
? callState === "unavailable"
? t("apps.callsUnavailable")
: t("apps.memoryUnavailable")
? "暂时无法读取调用记录"
: "暂时无法读取记忆状态"
: activity === "call" && recentCall
? callDetailHeadline(
recentCall,
recentCallHost?.display_name ??
callHostName(recentCall.host_id, locale),
locale,
callHostName(recentCall.host_id),
)
: activity === "memory" && latest
? `${latest.content}${"truncated" in latest && latest.truncated ? "…" : ""}`
: activity === "dialogue" && latestDialogue
? `${latestDialogue.excerpt}${latestDialogue.truncated ? "…" : ""}`
: activity === "importing"
? t("apps.importing")
? "对话持续导入中,即将沉淀你的记忆"
: activity === "first-running"
? t("apps.firstRunning")
? "正在沉淀第一批记忆"
: activity === "first-queued"
? t("apps.firstQueued")
? "准备沉淀第一批记忆"
: activity === "loading"
? t("apps.readingActivity")
: t("apps.emptyMemories")}
? "正在读取动态"
: "还没有已生成的记忆"}
</h3>
<small>
{activity === "unavailable"
? callState === "unavailable"
? t("apps.callReadFailure")
: t("apps.memoryReadFailure")
? "已有记忆仍可查看;重试不会发起新的调用。"
: "读取失败不会当作没有记忆。"
: activity === "call" && recentCall
? `${stamped(recentCall.occurred_at, locale, t)} · ${callRowDetail(recentCall, locale)}${callState === "incomplete" ? t("apps.incompleteCalls") : ""}`
? `${stamped(recentCall.occurred_at)} · ${callRowDetail(recentCall)}${callState === "incomplete" ? " · 有几次调用没被记下来,列表可能不全" : ""}`
: activity === "memory" && latest
? "revision" in latest
? `${time} · ${memoryAction(latest.action, t)} · ${latestSourceIds.map((id) => imports.sources.find((s) => s.source_id === id)?.display_name ?? id).join(", ")} · ${t("apps.revision", { count: new Intl.NumberFormat(locale).format(latest.revision) })}`
? `${time} · ${memoryAction(latest.action)} · ${latestSourceIds.map((id) => imports.sources.find((s) => s.source_id === id)?.display_name ?? id).join("、")} · 版本 ${latest.revision}`
: `${time} · SDK ${latest.layer} · ${latestSourceIds.map((id) => imports.sources.find((s) => s.source_id === id)?.display_name ?? id).join("、")}`
: activity === "dialogue" && latestDialogue
? `${clockParts(latestDialogue.created_at ?? latestDialogue.imported_at, locale)?.clock ?? t("apps.unknownTime")} ${latestDialogue.time_basis === "conversation" ? t("apps.conversationTime") : t("apps.importTime")} · ${imports.sources.find((source) => source.source_id === latestDialogue.source_id)?.display_name ?? latestDialogue.source_id}`
? `${clockParts(latestDialogue.created_at ?? latestDialogue.imported_at)?.clock ?? "时间未知"} ${latestDialogue.time_basis === "conversation" ? "对话时间" : "导入时间"} · ${imports.sources.find((source) => source.source_id === latestDialogue.source_id)?.display_name ?? latestDialogue.source_id}`
: activity === "importing"
? t("apps.savingConversations")
: t("apps.waitingMemories")}
? "对话正在后台持续保存。"
: "历史已保存,记忆生成后会显示在这里。"}
</small>
</section>
{(activity === "unavailable" ||
@@ -1035,10 +996,10 @@ export function ApplicationsHome({
}
>
{activity === "unavailable"
? t("apps.reread")
? "重新读取"
: activity === "call"
? t("apps.viewCalls")
: t("apps.viewMemory")}
? "查看记录"
: "查看记忆"}
{activity === "unavailable" ? null : (
<ArrowRight size={14} aria-hidden="true" />
)}
@@ -1052,8 +1013,8 @@ export function ApplicationsHome({
<section className="r6-home-auto-failure" role="alert">
<p>
{attention.kind === "unavailable"
? t("apps.backgroundDown")
: t("apps.timeoutWarning", { host: attentionHost ?? "" })}
? "后台服务没有运行,应用暂时查不到记忆"
: `${attentionHost} 最近几次没查到记忆,后台可能正忙,稍后会自动恢复`}
</p>
{attention.kind === "unavailable" || CALL_HISTORY_ENTRY_VISIBLE ? (
<R6Button
@@ -1067,42 +1028,40 @@ export function ApplicationsHome({
: onCalls
}
>
{attention.kind === "unavailable"
? t("apps.restart")
: t("apps.viewCalls")}
{attention.kind === "unavailable" ? "重新启动" : "查看记录"}
</R6Button>
) : null}
</section>
) : null}
<HomeCardBoundary label={t("apps.runtime")}>
<HomeCardBoundary label="运行状态">
<aside className="r6-home-runtime">
<h2>{t("apps.runtime")}</h2>
<h2>运行状态</h2>
<h3>
{connected
? t("apps.backgroundRunning")
? "后台运行中"
: imports.healthStale
? t("apps.connectionUnchecked")
: t("apps.disconnected")}
? "连接状态待核对"
: "服务未连接"}
</h3>
<p>{t("apps.authorizedOnly")}</p>
<p>仅处理已授权范围</p>
<dl>
<div>
<dt>{t("apps.sync")}</dt>
<dt>对话同步</dt>
<dd>{syncState}</dd>
</div>
<div>
<dt>{t("apps.organizing")}</dt>
<dt>记忆整理</dt>
<dd>{stateLabel}</dd>
</div>
<div>
<dt>{t("apps.access")}</dt>
<dt>应用访问</dt>
<dd>{accessText}</dd>
</div>
</dl>
{accessUnknownText ? (
<div
role="status"
aria-label={t("apps.checkingAccess")}
aria-label="应用接入核对"
className="r6-home-auto-failure"
>
<p>{accessUnknownText}</p>
@@ -1112,24 +1071,22 @@ export function ApplicationsHome({
disabled={hostQuery.isFetching}
onClick={() => void hostQuery.refetch()}
>
{hostQuery.isFetching
? t("apps.checking")
: t("apps.recheck")}
{hostQuery.isFetching ? "核对中…" : "重新核对"}
</button>
</div>
) : null}
{hostStale ? (
<p
role="status"
aria-label={t("apps.accessState")}
aria-label="应用接入状态"
className="r6-home-auto-failure"
>
{t("apps.staleAccess")}
应用接入状态暂时读取失败,当前为上次状态。
</p>
) : null}
{autoFailureText ? (
<div role="alert" className="r6-home-auto-failure">
<p>{`${autoFailureText}${t("apps.noAutoRetry")}`}</p>
<p>{`${autoFailureText}不会自动重试。`}</p>
<button
type="button"
className="r6-home-activity-link"
@@ -1142,16 +1099,14 @@ export function ApplicationsHome({
})
}
>
{t("apps.reconnect")}
重新接入
</button>
</div>
) : null}
<p className="r6-home-checked">
{!checked
? t("apps.checkedAt", { time: t("apps.unchecked") })
: t("apps.checkedAt", {
time: `${checked.today ? t("apps.today") : ""}${checked.clock}`,
})}
? "上次核对:待核对"
: `上次核对:${checked.today ? "今天 " : ""}${checked.clock}`}
</p>
</aside>
</HomeCardBoundary>
@@ -1159,7 +1114,7 @@ export function ApplicationsHome({
<section
className="r6-home-apps"
ref={panel}
aria-label={t("apps.myApps")}
aria-label="我的应用"
onWheel={(event) => {
if (!expanded && apps.length > 3 && event.deltaY > 10)
toggleAll(true);
@@ -1190,9 +1145,7 @@ export function ApplicationsHome({
}}
>
<header>
<h2>
{expanded ? tn("apps.appCount", apps.length) : t("apps.myApps")}
</h2>
<h2>{expanded ? `${apps.length} 个应用` : "我的应用"}</h2>
{apps.length > 3 ? (
<button
type="button"
@@ -1204,7 +1157,7 @@ export function ApplicationsHome({
className="r6-home-expand"
onClick={() => toggleAll(true)}
>
{tn("apps.viewAll", apps.length)}
查看全部 {apps.length} 个应用
</button>
) : null}
</header>
@@ -1212,7 +1165,7 @@ export function ApplicationsHome({
className="r6-home-list"
ref={list}
id={listId}
aria-label={t("apps.list")}
aria-label="应用列表"
tabIndex={expanded ? 0 : -1}
onScroll={(event) => {
if (expanded)
@@ -1221,13 +1174,8 @@ export function ApplicationsHome({
>
{(expanded ? apps : apps.slice(0, 3)).map((source) => {
const activity =
sourceActivity(
source,
callEvents,
locale,
t,
callState === "unavailable",
) || sourceSyncActivity(source.source_id, syncHealth, locale, t);
sourceActivity(source, callEvents, callState === "unavailable") ||
sourceSyncActivity(source.source_id, syncHealth);
const host = hosts.find((item) =>
sourceMatchesHost(source.source_id, item.hostId),
);
@@ -1239,13 +1187,13 @@ export function ApplicationsHome({
<h3>{source.display_name}</h3>
<p>
{source.sync_enabled
? t("apps.syncEnabled")
? "持续同步已开启"
: host?.installed &&
host.permission?.state === "approved"
? t("apps.recallAllowed")
? "允许请求已授权记忆"
: source.authorized
? t("apps.historyAllowed")
: t("apps.connectionPending")}
? "历史读取已授权"
: "连接待完成"}
</p>
</section>
{activity ? (
@@ -1256,7 +1204,7 @@ export function ApplicationsHome({
data-home-focus={`source:${source.source_id}`}
onClick={() => onManage(source.source_id)}
>
{t("apps.details")}
查看详情
</R6Button>
</div>
</div>
@@ -1264,7 +1212,7 @@ export function ApplicationsHome({
})}
{!apps.length ? (
<p className="r6-home-empty">
{stale ? t("apps.listUnavailable") : t("apps.emptyApps")}
{stale ? "应用列表暂时不可读取" : "还没有可显示的应用"}
</p>
) : null}
</section>
+3 -75
View File
@@ -1,6 +1,5 @@
// @vitest-environment jsdom
import { readFileSync } from "node:fs";
import {
act,
cleanup,
@@ -11,20 +10,12 @@ import {
within,
} from "@testing-library/react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
it("permite crecer al botón de retorno y ajustar la barra sin cortar el español", () => {
const css = readFileSync(`${process.cwd()}/src/call-history.css`, "utf8");
expect(css).toMatch(/\.call-history-back\.r6-button\s*\{[^}]*width:\s*auto/s);
expect(css).toMatch(/\.call-history-actions\s*\{[^}]*flex-wrap:\s*wrap/s);
});
import { CallHistory } from "./CallHistory";
import type {
CallEvent,
CallHistoryPage,
ResolvedCallReference,
} from "./call-history-client";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
const { getHistory, clearEvents, clearAllEvents, getReferences } = vi.hoisted(
() => ({
@@ -147,7 +138,6 @@ beforeEach(() => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
window.localStorage.removeItem(LANGUAGE_STORAGE_KEY);
if (originalShowModal)
Object.defineProperty(
HTMLDialogElement.prototype,
@@ -157,68 +147,6 @@ afterEach(() => {
else Reflect.deleteProperty(HTMLDialogElement.prototype, "showModal");
});
it("traduce el historial en vivo sin cambiar nombres ni contenido importado", async () => {
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
render(
<LanguageProvider>
<CallHistory onBack={vi.fn()} />
</LanguageProvider>,
);
const first = await loaded();
expect(
screen.getByRole("heading", { name: "Historial de uso" }),
).toBeTruthy();
expect(first.textContent).toBe("Cursor encontró 3 contenidos relacionados");
fireEvent.click(first);
expect(
screen.getByRole("heading", { name: "Esta consulta de memorias" }),
).toBeTruthy();
expect(screen.getAllByText("Consulta completada")).toHaveLength(2);
expect(screen.getByText("Consulta de memorias")).toBeTruthy();
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
fireEvent(window, new StorageEvent("storage", { key: LANGUAGE_STORAGE_KEY }));
expect(screen.getByRole("heading", { name: "这次查记忆" })).toBeTruthy();
window.localStorage.removeItem(LANGUAGE_STORAGE_KEY);
});
it.each([0, 1, 3])(
"muestra %i registros con confirmación española y conserva títulos opacos",
async (count) => {
getHistory.mockResolvedValue(
page(
Array.from({ length: count }, (_, index) =>
event(`event-${index}`, {
host_id: "Aplicación 中文",
references: [],
}),
),
),
);
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
render(
<LanguageProvider>
<CallHistory onBack={vi.fn()} />
</LanguageProvider>,
);
if (!count) {
expect(
await screen.findByText("Todavía no hay registros de uso"),
).toBeTruthy();
return;
}
await loaded("event-0");
expect(screen.getAllByText(/Aplicación 中文 encontró/)).toHaveLength(count);
fireEvent.click(
screen.getByRole("button", { name: "Borrar registros de esta página" }),
);
expect(screen.getByRole("dialog").textContent).toContain(
count === 1
? "¿Borrar el 1 registro de esta página?"
: "¿Borrar los 3 registros de esta página?",
);
},
);
it("loads a bounded first page and returns to applications", async () => {
const pending = deferred<CallHistoryPage>();
getHistory.mockReturnValueOnce(pending.promise);
@@ -426,7 +354,7 @@ it("does not claim delivery for a call Core never completed", async () => {
const detail = screen.getByRole("article", { name: "调用详情" });
expect(within(detail).queryByText(/已返回给/)).toBeNull();
expect(
within(detail).getByText("MemHub 暂时不可用,这次回答没有带上记忆。"),
within(detail).getByText("One Memory 暂时不可用,这次回答没有带上记忆。"),
).toBeTruthy();
});
it("does not restate an undelivered result as returned", async () => {
@@ -486,7 +414,7 @@ it.each([
"查询超时,没有提供记忆",
"直接调用(诊断) 这次没来得及查到记忆",
],
["unavailable", "MemHub 暂时不可用", "直接调用(诊断) 暂时无法查记忆"],
["unavailable", "One Memory 暂时不可用", "直接调用(诊断) 暂时无法查记忆"],
["denied", "当前未授权查询", "直接调用(诊断) 未获准查记忆"],
] as const)(
"renders the %s state without claiming external adoption",
@@ -543,7 +471,7 @@ it("does not call an unavailable call returned", async () => {
const detail = screen.getByRole("article", { name: "调用详情" });
expect(within(detail).queryByText(/· 已返回/)).toBeNull();
expect(
within(detail).getAllByText(/MemHub 暂时不可用/).length,
within(detail).getAllByText(/One Memory 暂时不可用/).length,
).toBeGreaterThan(0);
expect(within(detail).queryByText(/采用未确认|Core/)).toBeNull();
});
+100 -127
View File
@@ -1,4 +1,3 @@
import type { GuiLocale, MessageKey } from "@one-memory/i18n";
import {
ArrowLeft,
ChevronLeft,
@@ -10,6 +9,7 @@ import { useEffect, useId, useRef, useState } from "react";
import { getConversation } from "./api";
import {
CALL_HOSTS,
CALL_TOOLS,
type CallEvent,
type CallHistoryPage,
callDeliveryLabel,
@@ -24,24 +24,27 @@ import {
type ResolvedCallReference,
} from "./call-history-client";
import { clockStamp } from "./clock-stamp";
import { useGuiLanguage } from "./gui-language";
import { R6Button } from "./R6Controls";
import { traceUiAction } from "./ui-trace";
import { isDefiniteRefusal } from "./write-outcome";
import "./call-history.css";
const PAGE_SIZE = 20;
const stateLabels = {
grounded: "calls.state.grounded",
empty: "calls.state.empty",
partial: "calls.state.partial",
timed_out: "calls.state.timed_out",
unavailable: "calls.state.unavailable",
denied: "calls.state.denied",
} as const satisfies Record<CallEvent["state"], MessageKey>;
const stateLabels: Record<CallEvent["state"], string> = {
grounded: "查询成功",
empty: "查询成功,没有找到相关记忆",
partial: "查询成功,部分内容还在整理中",
timed_out: "查询超时,没有提供记忆",
unavailable: "One Memory 暂时不可用",
denied: "当前未授权查询",
};
function sourceLabel(id: string | null, notRecorded: string) {
return id === null ? notRecorded : (CALL_HOSTS[id] ?? id);
function toolName(tool: CallEvent["tool"]) {
return CALL_TOOLS[tool];
}
function sourceLabel(id: string | null) {
return id === null ? "未记录" : (CALL_HOSTS[id] ?? id);
}
function CallReferences({
@@ -53,7 +56,6 @@ function CallReferences({
onOpenMemory?(id: string): void;
onOpenEvidence?(id: string): void;
}) {
const { t } = useGuiLanguage();
const [result, setResult] = useState<{
event: CallEvent;
items: ResolvedCallReference[];
@@ -99,31 +101,29 @@ function CallReferences({
return (
<section
className="call-history-references"
aria-label={t("calls.references")}
aria-label="相关内容"
aria-busy={loading}
>
<h3>{t("calls.references")}</h3>
{loading && <p role="status">{t("calls.referencesLoading")}</p>}
{failed && <p role="alert">{t("calls.referencesFailed")}</p>}
<h3>相关内容</h3>
{loading && <p role="status">正在读取相关内容</p>}
{failed && <p role="alert">相关内容暂时无法读取,请刷新重试。</p>}
{event.references.length ? (
<ul>
{Array.from(references, ([key, { reference, current }]) => (
<li key={key}>
<div>
<h4>
{t(reference.kind === "raw" ? "calls.raw" : "calls.memory")} ·{" "}
{reference.kind === "raw" ? "原文" : "记忆"} ·{" "}
{current?.available && current.reason === null
? current.title
: current?.reason === "changed"
? t("calls.changed")
? "版本已变更"
: loading
? t("calls.pending")
: t("calls.notAvailable")}
? "待核对"
: "不可用"}
</h4>
{reference.source_id && (
<p>
{sourceLabel(reference.source_id, t("calls.notRecorded"))}
</p>
<p>{sourceLabel(reference.source_id)}</p>
)}
</div>
{current?.available && current.reason === null && (
@@ -160,29 +160,23 @@ function CallReferences({
});
}}
>
{t(
reference.kind === "raw"
? "calls.openRaw"
: "calls.openMemory",
)}
{reference.kind === "raw" ? "查看原文" : "查看记忆"}
</R6Button>
)}
</li>
))}
</ul>
) : (
<p>{t("calls.noReferences")}</p>
<p>没有找到相关内容</p>
)}
{transcript && (
<aside
aria-label={t("calls.rawBody")}
aria-label="原文正文"
aria-busy={transcript.status === "loading"}
>
{transcript.status === "loading" && (
<p role="status">{t("calls.rawLoading")}</p>
)}
{transcript.status === "loading" && <p role="status">正在读取原文</p>}
{transcript.status === "failed" && (
<p role="alert">{t("calls.rawFailed")}</p>
<p role="alert">原文已不可用或版本已变更。</p>
)}
{transcript.status === "ready" &&
transcript.messages.map((message) => (
@@ -194,8 +188,8 @@ function CallReferences({
);
}
function shortTime(value: string, locale: GuiLocale) {
return clockStamp(value, locale);
function shortTime(value: string) {
return clockStamp(value);
}
function ClearConfirmation({
@@ -213,7 +207,6 @@ function ClearConfirmation({
onCancel(): void;
onConfirm(): void;
}) {
const { t } = useGuiLanguage();
const dialog = useRef<HTMLDialogElement>(null);
const titleId = useId();
const descriptionId = useId();
@@ -240,7 +233,7 @@ function ClearConfirmation({
<p id={descriptionId}>{description}</p>
<div className="call-history-actions">
<R6Button disabled={pending} onClick={onCancel}>
{t("calls.cancel")}
取消
</R6Button>
<R6Button variant="primary" pending={pending} onClick={onConfirm}>
{confirmLabel}
@@ -259,7 +252,6 @@ export function CallHistory({
onOpenMemory?(id: string): void;
onOpenEvidence?(id: string): void;
}) {
const { t, tnPlain, locale } = useGuiLanguage();
const [request, setRequest] = useState({
hostId: "",
offset: 0,
@@ -275,7 +267,7 @@ export function CallHistory({
{ kind: "page"; ids: string[] } | { kind: "all" } | null
>(null);
const [clearing, setClearing] = useState(false);
const [clearNotice, setClearNotice] = useState<MessageKey | null>(null);
const [clearNotice, setClearNotice] = useState("");
const mounted = useRef(false);
const clearFlight = useRef(false);
const heading = useRef<HTMLHeadingElement>(null);
@@ -342,14 +334,14 @@ export function CallHistory({
const clearingAll = confirmation.kind === "all";
clearFlight.current = true;
setClearing(true);
setClearNotice(null);
setClearNotice("");
const trace = traceUiAction("calls", "confirm");
try {
if (clearingAll) await clearAllCallEvents(trace.traceId);
else await clearCallEvents([...confirmation.ids], trace.traceId);
trace.done("succeeded");
if (!mounted.current) return;
setClearNotice(clearingAll ? "calls.clearedAll" : "calls.clearedPage");
setClearNotice(clearingAll ? "已清空全部记录。" : "已清除所选记录。");
} catch (reason) {
// A 4xx (e.g. `409 call_history_disabled`) is Core answering and
// refusing: nothing was deleted, so it is a definite failure, not an
@@ -369,9 +361,9 @@ export function CallHistory({
setClearNotice(
refused
? clearingAll
? "calls.clearAllFailed"
: "calls.clearPageFailed"
: "calls.clearUnknown",
? "未能清空使用记录,当前记录保持不变。请核对后重试。"
: "未能清除所选记录,当前记录保持不变。请核对后重试。"
: "清除结果未确认,请核对重新读取的记录。未自动重试清除。",
);
} finally {
clearFlight.current = false;
@@ -392,7 +384,7 @@ export function CallHistory({
<section className="call-history" aria-labelledby={titleId}>
<header className="call-history-heading">
<h1 id={titleId} ref={heading} tabIndex={-1}>
{t(selectedId === null ? "calls.title" : "calls.query")}
{selectedId === null ? "使用记录" : "这次查记忆"}
</h1>
<R6Button
className="call-history-back"
@@ -400,7 +392,7 @@ export function CallHistory({
onClick={selectedId === null ? onBack : () => setSelectedId(null)}
>
<ArrowLeft size={16} aria-hidden="true" />
{t(selectedId === null ? "calls.backApps" : "calls.backHistory")}
{selectedId === null ? "返回应用" : "返回记录"}
</R6Button>
</header>
<div
@@ -409,7 +401,7 @@ export function CallHistory({
{selectedId === null ? (
<div className="call-history-filter">
<label className="call-history-sr-only" htmlFor={filterId}>
{t("calls.filter")}
按应用筛选
</label>
<select
id={filterId}
@@ -424,7 +416,7 @@ export function CallHistory({
}));
}}
>
<option value="">{t("calls.allApps")}</option>
<option value="">全部应用</option>
{Object.entries(CALL_HOSTS).map(([host, label]) => (
<option key={host} value={host}>
{label}
@@ -436,12 +428,12 @@ export function CallHistory({
<span />
)}
<div className="call-history-actions">
{selectedId === null && <span>{t("calls.recent")}</span>}
{selectedId === null && <span>最近记录</span>}
<R6Button
className="call-history-icon"
variant="quiet"
title={t("calls.refresh")}
aria-label={t("calls.refresh")}
title="刷新记录"
aria-label="刷新记录"
disabled={clearing}
onClick={refresh}
>
@@ -452,8 +444,8 @@ export function CallHistory({
<R6Button
className="call-history-icon"
variant="quiet"
title={t("calls.clearPage")}
aria-label={t("calls.clearPage")}
title="清除本页记录"
aria-label="清除本页记录"
disabled={!events.length || clearing}
onClick={() =>
setConfirmation({
@@ -466,12 +458,12 @@ export function CallHistory({
</R6Button>
<R6Button
variant="quiet"
title={t("calls.clearAll")}
aria-label={t("calls.clearAll")}
title="清空全部记录"
aria-label="清空全部记录"
disabled={!page?.total || clearing}
onClick={() => setConfirmation({ kind: "all" })}
>
{t("calls.clearAllShort")}
清空全部
</R6Button>
</>
)}
@@ -479,67 +471,55 @@ export function CallHistory({
</div>
{clearNotice && (
<p className="call-history-notice" role="status">
{t(clearNotice)}
{clearNotice}
</p>
)}
<div aria-busy={loading || clearing}>
{loading && (
<p className="call-history-state" role="status">
{t("calls.loading")}
正在读取使用记录
</p>
)}
{clearing && (
<p className="call-history-state" role="status">
{t("calls.clearing")}
正在清除所选记录
</p>
)}
{failed && (
<p className="call-history-state" role="alert">
{t("calls.readFailed")}
记录读取失败,请刷新重试。
</p>
)}
{page && !page.available && (
<p className="call-history-state" role="alert">
{t("calls.unavailable")}
使用记录暂不可用,请稍后刷新。
</p>
)}
{page?.recording_error && (
<p className="call-history-notice" role="alert">
{t("calls.incomplete")}
有几次调用没被记下来,列表可能不全。
</p>
)}
{page?.available &&
(selectedId !== null ? (
selected ? (
<article
className="call-history-detail"
aria-label={t("calls.callDetail")}
>
<h2>{`${sourceLabel(selected.host_id, t("calls.notRecorded"))} · ${shortTime(selected.occurred_at, locale)}`}</h2>
<article className="call-history-detail" aria-label="调用详情">
<h2>{`${sourceLabel(selected.host_id)} · ${shortTime(selected.occurred_at)}`}</h2>
<p className="call-history-summary">
{callDetailHeadline(selected, undefined, locale)}
{returnedFrom
? t("calls.source", {
source: sourceLabel(
returnedFrom,
t("calls.notRecorded"),
),
})
: ""}
{callDetailHeadline(selected)}
{returnedFrom ? ` · 来源 ${sourceLabel(returnedFrom)}` : ""}
</p>
<p className="call-history-stage">
{t(stateLabels[selected.state])}
{stateLabels[selected.state]}
</p>
<p className="call-history-stage">
{t("calls.stats", {
raw: selected.raw_count,
memories: selected.memory_count,
seconds: (selected.elapsed_ms / 1000).toFixed(1),
})}
找到原文 {selected.raw_count} 段 · 记忆{" "}
{selected.memory_count} 条 · 用时{" "}
{(selected.elapsed_ms / 1000).toFixed(1)} 秒
</p>
{callDeliveryLabel(selected, locale) && (
{callDeliveryLabel(selected) && (
<p className="call-history-stage">
{callDeliveryLabel(selected, locale)}
{callDeliveryLabel(selected)}
</p>
)}
<CallReferences
@@ -547,20 +527,22 @@ export function CallHistory({
onOpenMemory={onOpenMemory}
onOpenEvidence={onOpenEvidence}
/>
{callReturnDeliveryLabel(selected, locale) && (
{callReturnDeliveryLabel(selected) && (
<p className="call-history-adoption">
{callReturnDeliveryLabel(selected, locale)}
{callReturnDeliveryLabel(selected)}
</p>
)}
<section
className="call-history-metadata"
aria-label={t("calls.metadata")}
aria-label="记录详情"
>
<h3>{t("calls.metadata")}</h3>
<p>{t("calls.privacy")}</p>
<h3>记录详情</h3>
<p>
为保护隐私,这里不保存你问的原话和回答,只保存时间、来源和结果。
</p>
<dl>
<div>
<dt>{t("calls.exactTime")}</dt>
<dt>准确时间</dt>
<dd>
<time dateTime={selected.occurred_at}>
{selected.occurred_at}
@@ -568,29 +550,27 @@ export function CallHistory({
</dd>
</div>
<div>
<dt>{t("calls.app")}</dt>
<dd>
{sourceLabel(selected.host_id, t("calls.notRecorded"))}
</dd>
<dt>应用</dt>
<dd>{sourceLabel(selected.host_id)}</dd>
</div>
<div>
<dt>{t("calls.type")}</dt>
<dd>{t("calls.tool")}</dd>
<dt>类型</dt>
<dd>{toolName(selected.tool)}</dd>
</div>
<div>
<dt>{t("calls.result")}</dt>
<dt>结果状态</dt>
{/* The tool above is already humanized in this same
panel ("查记忆", never `om_recall`); printing the raw
`grounded`/`partial` enum here leaks the internal state
vocabulary the rest of the page translates. */}
<dd>{t(stateLabels[selected.state])}</dd>
<dd>{stateLabels[selected.state]}</dd>
</div>
</dl>
</section>
</article>
) : (
<p className="call-history-state" role="status">
{t("calls.missingRow")}
这条记录已不在当前页,请返回记录查看。
</p>
)
) : (
@@ -598,12 +578,12 @@ export function CallHistory({
<ul
ref={list}
className="call-history-list"
aria-label={t("calls.list")}
aria-label="调用记录"
>
{events.map((item) => (
<li key={item.event_id}>
<time dateTime={item.occurred_at} title={item.occurred_at}>
{shortTime(item.occurred_at, locale)}
{shortTime(item.occurred_at)}
</time>
<div className="call-history-row-content">
<h2>
@@ -612,26 +592,23 @@ export function CallHistory({
data-event-id={item.event_id}
onClick={() => setSelectedId(item.event_id)}
>
{callRowHeadline(item, locale)}
{callRowHeadline(item)}
</button>
</h2>
<p>{callRowDetail(item, locale)}</p>
<p>{callRowDetail(item)}</p>
</div>
</li>
))}
</ul>
{!events.length && (
<p className="call-history-state" role="status">
{t(request.offset ? "calls.emptyPage" : "calls.empty")}
{request.offset ? "本页暂无记录" : "暂无使用记录"}
</p>
)}
<nav
className="call-history-pagination"
aria-label={t("calls.pagination")}
>
<nav className="call-history-pagination" aria-label="记录分页">
<R6Button
aria-label={t("calls.previous")}
title={t("calls.previous")}
aria-label="上一页"
title="上一页"
disabled={request.offset === 0}
onClick={() =>
setRequest((current) => ({
@@ -649,8 +626,8 @@ export function CallHistory({
/ {page.total}
</span>
<R6Button
aria-label={t("calls.next")}
title={t("calls.next")}
aria-label="下一页"
title="下一页"
disabled={
page.offset + PAGE_SIZE >= page.total || !events.length
}
@@ -671,29 +648,25 @@ export function CallHistory({
page.retention_days !== null &&
page.max_events !== null && (
<p className="call-history-policy">
{t("calls.policy", {
days: page.retention_days,
count: new Intl.NumberFormat(locale).format(page.max_events),
})}
保留 {page.retention_days} 天,最多{" "}
{new Intl.NumberFormat("zh-CN").format(page.max_events)} 条记录
</p>
)}
{confirmation && (
<ClearConfirmation
title={
confirmation.kind === "all"
? t("calls.confirmAllTitle")
: tnPlain("calls.confirmPageTitle", confirmation.ids.length)
? "清空全部使用记录?"
: `清除本页 ${confirmation.ids.length} 条记录?`
}
description={
confirmation.kind === "all"
? t("calls.confirmAllDescription")
: t("calls.confirmPageDescription")
? "将删除当前保留的全部调用记录,不限于本页或当前筛选。不删除原文或记忆,不更改授权与留存规则。此操作无法撤销。"
: "仅清除这批调用记录,不删除原文或记忆,不更改授权与留存规则。此操作无法撤销。"
}
confirmLabel={
confirmation.kind === "all" ? "确认清空全部" : "确认清除"
}
confirmLabel={t(
confirmation.kind === "all"
? "calls.confirmAll"
: "calls.confirmPage",
)}
pending={clearing}
onCancel={() => setConfirmation(null)}
onConfirm={() => void confirmClear()}
@@ -3,50 +3,12 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ConnectionFallback } from "./ConnectionFallback";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
afterEach(() => {
cleanup();
delete window.oneMemory;
localStorage.clear();
});
describe("ConnectionFallback", () => {
it("localizes pairing controls while preserving generated Skill bytes", async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
const skill = "pair --token '{no-traducir}'";
window.oneMemory = {
createCustomHarnessSkill: vi.fn().mockResolvedValue({
displayName: "Harness 原名",
expiresAt: "2026-10-19T12:10:00.000Z",
skill,
}),
copyText: vi.fn().mockResolvedValue(undefined),
getLanguagePreference: vi
.fn()
.mockResolvedValue({ preference: "es", locale: "es", revision: 1 }),
onLanguagePreferenceChanged: vi.fn().mockReturnValue(() => {}),
} as unknown as Window["oneMemory"];
render(
<LanguageProvider>
<ConnectionFallback />
</LanguageProvider>,
);
const user = userEvent.setup();
await user.click(await screen.findByText("Conectar otras herramientas"));
await user.type(
screen.getByLabelText("Nombre del Harness"),
"Harness 原名",
);
await user.click(
screen.getByRole("button", {
name: "Generar Skill de emparejamiento",
}),
);
expect(
await screen.findByLabelText("Skill de emparejamiento"),
).toHaveProperty("value", skill);
});
it("names an unknown Harness before generating and copying its pairing Skill", async () => {
const user = userEvent.setup();
const createCustomHarnessSkill = vi.fn().mockResolvedValue({
+19 -181
View File
@@ -1,29 +1,16 @@
import { Copy, RefreshCw } from "lucide-react";
import { useEffect, useState } from "react";
import { useState } from "react";
import { copyText } from "./copy-text";
import { useGuiLanguage } from "./gui-language";
type Material = { displayName: string; expiresAt: string; skill: string };
type ConnectionMaterial = {
mcp: string;
shell: string | null;
prompt: string;
};
type MaterialBridge = {
getConnectionMaterial(): Promise<ConnectionMaterial>;
};
export function ConnectionFallback() {
const { locale, t } = useGuiLanguage();
const [displayName, setDisplayName] = useState("");
const [material, setMaterial] = useState<Material | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [notice, setNotice] = useState("");
const available = !!window.oneMemory?.createCustomHarnessSkill;
const materialBridge = window.oneMemory as
| (Window["oneMemory"] & Partial<MaterialBridge>)
| undefined;
async function generate() {
const bridge = window.oneMemory;
@@ -43,24 +30,21 @@ export function ConnectionFallback() {
return (
<details className="connection-fallback">
<summary>{t("connectionFallback.title")}</summary>
{materialBridge?.getConnectionMaterial ? (
<ConnectionMaterialFallback
bridge={materialBridge as Window["oneMemory"] & MaterialBridge}
/>
) : !available ? (
<p>{t("connectionFallback.desktopOnly")}</p>
<summary>连接其他工具</summary>
{!available ? (
<p>本机配对仅在桌面 App 中可用。</p>
) : (
<>
<p className="connection-boundary">
{t("connectionFallback.description")}
为未列出的本机 Harness 生成一次性配对
Skill。纯网页聊天、远端容器或另一台机器不能直接连接本机。
</p>
<label className="custom-harness-name">
{t("connectionFallback.name")}
Harness 名称
<input
value={displayName}
maxLength={80}
placeholder={t("connectionFallback.namePlaceholder")}
placeholder="例如 Kimi Code"
onChange={(event) => {
setDisplayName(event.target.value);
setMaterial(null);
@@ -74,37 +58,29 @@ export function ConnectionFallback() {
disabled={loading || !displayName.trim()}
onClick={() => void generate()}
>
{loading
? t("connectionFallback.generating")
: t("connectionFallback.generate")}
{loading ? "正在生成…" : "生成配对 Skill"}
</button>
{error && (
<p role="alert">
{t("connectionFallback.generateFailed")}
未能生成配对 Skill。
<button
type="button"
className="quiet"
onClick={() => void generate()}
>
<RefreshCw size={15} aria-hidden="true" />
{t("connectionFallback.retry")}
重试
</button>
</p>
)}
{material && (
<>
<p>
{t("connectionFallback.generated", {
name: material.displayName,
time: new Intl.DateTimeFormat(locale, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).format(new Date(material.expiresAt)),
})}
已为 {material.displayName} 生成一次性配对 Skill,有效期至{" "}
{new Date(material.expiresAt).toLocaleTimeString()}。
</p>
<textarea
aria-label={t("connectionFallback.skillLabel")}
aria-label="配对 Skill"
readOnly
value={material.skill}
spellCheck={false}
@@ -115,14 +91,16 @@ export function ConnectionFallback() {
onClick={async () => {
try {
await copyText(material.skill);
setNotice(t("connectionFallback.copied"));
setNotice(
"已复制配对 Skill。请粘贴到对应 Harness 中执行。",
);
} catch {
setNotice(t("connectionFallback.copyFailed"));
setNotice("复制失败,可在上方选中文本复制。");
}
}}
>
<Copy size={15} aria-hidden="true" />
{t("connectionFallback.copy")}
复制 Skill
</button>
</>
)}
@@ -132,143 +110,3 @@ export function ConnectionFallback() {
</details>
);
}
function ConnectionMaterialFallback({
bridge,
}: {
bridge: Window["oneMemory"] & MaterialBridge;
}) {
const { locale, t } = useGuiLanguage();
const [material, setMaterial] = useState<ConnectionMaterial | null>(null);
const [tab, setTab] = useState<"prompt" | "mcp" | "shell">("prompt");
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [notice, setNotice] = useState("");
async function load() {
setLoading(true);
setError(false);
try {
setMaterial(await bridge.getConnectionMaterial());
} catch {
setError(true);
} finally {
setLoading(false);
}
}
useEffect(() => {
let current = true;
void bridge.getConnectionMaterial().then(
(value) => {
if (!current) return;
setMaterial(value);
setLoading(false);
},
() => {
if (!current) return;
setError(true);
setLoading(false);
},
);
// The material is a snapshot. Language changes re-render its presentation
// without reading paths or snippets again.
return () => {
current = false;
};
}, [bridge]);
if (loading && !material)
return <p role="status">{t("connectionFallback.loading")}</p>;
if (error && !material)
return (
<p role="alert">
{t("connectionFallback.loadFailed")}
<button type="button" className="quiet" onClick={() => void load()}>
<RefreshCw size={15} aria-hidden="true" />
{t("connectionFallback.retry")}
</button>
</p>
);
if (!material) return null;
const shellBlock = material.shell
? t("connectionFallback.promptShell", { shell: material.shell })
: "";
const prompt =
locale === "zh-CN"
? material.prompt
: t("connectionFallback.promptTemplate", {
mcp: material.mcp,
shellBlock,
});
const value =
tab === "prompt"
? prompt
: tab === "mcp"
? material.mcp
: (material.shell ?? "");
const copyLabel =
tab === "prompt"
? t("connectionFallback.copyPrompt")
: tab === "mcp"
? t("connectionFallback.copyConfig")
: t("connectionFallback.copyCommand");
return (
<>
<p className="connection-boundary">
{t("connectionFallback.localDescription")}
</p>
<fieldset>
<legend>{t("connectionFallback.format")}</legend>
<div role="tablist">
{(
[
["prompt", t("connectionFallback.promptTab")],
["mcp", t("connectionFallback.mcpTab")],
["shell", t("connectionFallback.commandTab")],
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
role="tab"
aria-selected={tab === value}
disabled={value === "shell" && !material.shell}
onClick={() => {
setTab(value);
setNotice("");
}}
>
{label}
</button>
))}
</div>
</fieldset>
<textarea
aria-label={t("connectionFallback.materialLabel")}
readOnly
value={value}
spellCheck={false}
/>
<p>{t("connectionFallback.materialBoundary")}</p>
<button
type="button"
className="secondary"
onClick={async () => {
try {
await copyText(value);
setNotice(t("connectionFallback.materialCopied"));
} catch {
setNotice(t("connectionFallback.copyFailed"));
}
}}
>
<Copy size={15} aria-hidden="true" />
{copyLabel}
</button>
{notice && <p role="status">{notice}</p>}
</>
);
}
-103
View File
@@ -11,11 +11,6 @@ import {
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { DebugLogPanel } from "./DebugLogPanel";
import {
LANGUAGE_STORAGE_KEY,
LanguageProvider,
useGuiLanguage,
} from "./gui-language";
import {
recordRendererDebugLog,
resetRendererDebugLogsForTest,
@@ -92,7 +87,6 @@ const snapshot = {
};
beforeEach(() => {
window.localStorage.clear();
resetRendererDebugLogsForTest();
diagnosticLevel.get.mockClear();
diagnosticLevel.set.mockClear();
@@ -108,55 +102,6 @@ afterEach(() => {
vi.useRealTimers();
});
function SwitchLanguage() {
const { setPreference } = useGuiLanguage();
return (
<button type="button" onClick={() => void setPreference("zh-CN")}>
Cambiar idioma
</button>
);
}
it("translates controls live but preserves events and does not change capture", async () => {
// A language bridge is not required by this synthetic browser fixture.
const read = vi.fn().mockResolvedValue(snapshot);
window.oneMemory = undefined;
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
const view = render(
<LanguageProvider>
<SwitchLanguage />
<DebugLogPanel />
</LanguageProvider>,
);
window.oneMemory = { getDebugLogs: read } as unknown as NonNullable<
typeof window.oneMemory
>;
view.rerender(
<LanguageProvider>
<SwitchLanguage />
<DebugLogPanel />
</LanguageProvider>,
);
await userEvent.click(
await screen.findByRole("button", {
name: "Abrir registros de depuración",
}),
);
await screen.findByText(/source\.scanned/);
expect(
screen.getByRole("searchbox", { name: "Buscar registros" }),
).toBeTruthy();
await userEvent.click(
screen.getByRole("button", { name: "Pausar actualización en vivo" }),
);
const count = read.mock.calls.length;
await userEvent.click(screen.getByRole("button", { name: "Cambiar idioma" }));
await screen.findByRole("searchbox", { name: "搜索日志" });
expect(screen.getByText(/source\.scanned/)).toBeTruthy();
expect(read).toHaveBeenCalledTimes(count);
expect(diagnosticLevel.set).not.toHaveBeenCalled();
});
it("opens from the floating button and filters by text, level and source", async () => {
render(<DebugLogPanel />);
await userEvent.click(
@@ -310,51 +255,3 @@ it("shows per-minute model usage in a separate tab", async () => {
}),
).toBeTruthy();
});
it("shows the diagnostics tabs and model usage in Spanish without Chinese copy", async () => {
// Browser-mode language, then the desktop log bridge, as in the test above.
const bridge = window.oneMemory;
window.oneMemory = undefined;
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
const view = render(
<LanguageProvider>
<DebugLogPanel />
</LanguageProvider>,
);
window.oneMemory = bridge;
view.rerender(
<LanguageProvider>
<DebugLogPanel />
</LanguageProvider>,
);
await userEvent.click(
await screen.findByRole("button", {
name: "Abrir registros de depuración",
}),
);
const panel = await screen.findByRole("complementary", {
name: "Registros de depuración",
});
expect(within(panel).getByText("Diagnóstico")).toBeTruthy();
expect(
within(panel).getByRole("button", {
name: "Mostrar los registros en pantalla completa",
}),
).toBeTruthy();
await userEvent.click(
within(panel).getByRole("tab", { name: "Uso del modelo" }),
);
await waitFor(() => expect(diagnosticLevel.usage).toHaveBeenCalled());
expect(within(panel).getByText("Tokens totales")).toBeTruthy();
expect(
within(panel).getByText("Uso del modelo que genera memorias"),
).toBeTruthy();
expect(
within(panel).getByRole("img", {
name: "Tokens consumidos por minuto de cada prompt",
}),
).toBeTruthy();
expect(panel.textContent).not.toMatch(/[一-鿿]/);
});
+34 -46
View File
@@ -28,7 +28,6 @@ import type {
DebugLogLevel,
DebugLogSnapshot,
} from "./debug-log-types";
import { useGuiLanguage } from "./gui-language";
import { ModelUsageView } from "./ModelUsageView";
import {
rendererDebugLogs,
@@ -54,7 +53,6 @@ const SERVICE_LABELS: Record<DebugLogEntry["service"], string> = {
};
export function DebugLogPanel() {
const { t, tnPlain, locale } = useGuiLanguage();
const supported = typeof window.oneMemory?.getDebugLogs === "function";
const [open, setOpen] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
@@ -173,23 +171,23 @@ export function DebugLogPanel() {
{open ? (
<aside
className={`debug-log-panel${fullscreen ? " is-fullscreen" : ""}`}
aria-label={t("debugTitle")}
aria-label="Debug 日志"
>
<header className="debug-log-header">
<div>
<strong>{t("debugDiagnostics")}</strong>
<strong>诊断</strong>
<span>
{tab === "logs"
? tnPlain("debugCount", entries.length)
: t("debugUsageSubtitle")}
? `${entries.length} 条日志,本次启动`
: "记忆沉淀模型用量"}
</span>
</div>
<div className="debug-log-header-actions">
<button
type="button"
className="debug-icon-button"
title={live ? t("debugPause") : t("debugResume")}
aria-label={live ? t("debugPause") : t("debugResume")}
title={live ? "暂停实时刷新" : "继续实时刷新"}
aria-label={live ? "暂停实时刷新" : "继续实时刷新"}
onClick={() => setLive((value) => !value)}
>
{live ? <CirclePause size={17} /> : <CirclePlay size={17} />}
@@ -197,8 +195,8 @@ export function DebugLogPanel() {
<button
type="button"
className="debug-icon-button"
title={t("debugRefresh")}
aria-label={t("debugRefresh")}
title="立即刷新"
aria-label="立即刷新"
onClick={() => {
if (tab === "logs") void refresh();
else setUsageRefreshToken((value) => value + 1);
@@ -212,14 +210,8 @@ export function DebugLogPanel() {
<button
type="button"
className="debug-icon-button"
title={t(
fullscreen ? "debugExitFullscreen" : "debugFullscreen",
)}
aria-label={t(
fullscreen
? "debugExitFullscreenLabel"
: "debugFullscreenLabel",
)}
title={fullscreen ? "退出全屏" : "全屏显示"}
aria-label={fullscreen ? "退出全屏显示日志" : "全屏显示日志"}
onClick={() => setFullscreen((value) => !value)}
>
{fullscreen ? <Minimize2 size={17} /> : <Maximize2 size={17} />}
@@ -227,8 +219,8 @@ export function DebugLogPanel() {
<button
type="button"
className="debug-icon-button"
title={t("debugClose")}
aria-label={t("debugClose")}
title="关闭 Debug 日志"
aria-label="关闭 Debug 日志"
onClick={() => {
setFullscreen(false);
setOpen(false);
@@ -241,7 +233,7 @@ export function DebugLogPanel() {
<div
className="debug-panel-tabs"
role="tablist"
aria-label={t("debugTabs")}
aria-label="诊断页面"
>
<button
type="button"
@@ -249,7 +241,7 @@ export function DebugLogPanel() {
aria-selected={tab === "logs"}
onClick={() => setTab("logs")}
>
{t("debugLogsTab")}
日志
</button>
<button
type="button"
@@ -257,7 +249,7 @@ export function DebugLogPanel() {
aria-selected={tab === "usage"}
onClick={() => setTab("usage")}
>
{t("debugUsageTab")}
模型用量
</button>
</div>
{tab === "logs" ? (
@@ -269,18 +261,18 @@ export function DebugLogPanel() {
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("debugSearchPlaceholder")}
aria-label={t("debugSearch")}
placeholder="搜索事件、组件、错误码或内容"
aria-label="搜索日志"
/>
</label>
<select
aria-label={t("debugSource")}
aria-label="日志来源"
value={service}
onChange={(event) =>
setService(event.target.value as typeof service)
}
>
<option value="all">{t("debugAllSources")}</option>
<option value="all">全部来源</option>
{Object.entries(SERVICE_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
@@ -288,9 +280,9 @@ export function DebugLogPanel() {
))}
</select>
<label className="debug-capture-level">
<span>{t("debugCapture")}</span>
<span>采集级别</span>
<select
aria-label={t("debugCapture")}
aria-label="采集级别"
value={captureLevel}
disabled={capturePending}
onChange={(event) => {
@@ -311,7 +303,7 @@ export function DebugLogPanel() {
</label>
</div>
<fieldset className="debug-levels">
<legend>{t("debugDisplay")}</legend>
<legend>显示级别</legend>
{LEVELS.map((option) => (
<button
type="button"
@@ -319,20 +311,16 @@ export function DebugLogPanel() {
aria-pressed={level === option.value}
onClick={() => setLevel(option.value)}
>
{option.value === "all" ? t("debugAll") : option.label}
{option.label}
</button>
))}
</fieldset>
<div className="debug-log-status" aria-live="polite">
<span>{tnPlain("debugMatches", filtered.length)}</span>
<span>{live ? t("debugLive") : t("debugPaused")}</span>
{snapshot?.truncated ? (
<span>{t("debugTruncated")}</span>
) : null}
<span>{filtered.length} 条匹配</span>
<span>{live ? "实时刷新" : "已暂停"}</span>
{snapshot?.truncated ? <span>日志过多,已截断</span> : null}
{readState === "error" ? (
<span className="debug-read-error">
{t("debugReadError")}
</span>
<span className="debug-read-error">读取失败</span>
) : null}
</div>
<div
@@ -359,7 +347,7 @@ export function DebugLogPanel() {
) : (
<ChevronRight size={14} />
)}
<time>{logTime(entry.timestamp, locale)}</time>
<time>{logTime(entry.timestamp)}</time>
<span className="debug-log-level">{entry.level}</span>
<span className="debug-log-service">
{SERVICE_LABELS[entry.service]}
@@ -381,8 +369,8 @@ export function DebugLogPanel() {
) : (
<p className="debug-log-empty">
{readState === "loading"
? t("debugLoading")
: t("debugEmpty")}
? "正在读取日志…"
: "没有匹配的日志"}
</p>
)}
</div>
@@ -395,8 +383,8 @@ export function DebugLogPanel() {
<button
type="button"
className="debug-fab"
aria-label={open ? t("debugOpened") : t("debugOpen")}
title={t("debugOpen")}
aria-label={open ? "Debug 日志已打开" : "打开 Debug 日志"}
title="打开 Debug 日志"
onClick={() => setOpen(true)}
>
<Bug size={20} aria-hidden="true" />
@@ -412,9 +400,9 @@ function searchableEntry(entry: DebugLogEntry): string {
return JSON.stringify(entry).toLocaleLowerCase();
}
function logTime(timestamp: string, locale: string): string {
function logTime(timestamp: string): string {
const date = new Date(timestamp);
return Number.isNaN(date.getTime())
? timestamp
: date.toLocaleTimeString(locale, { hour12: false });
: date.toLocaleTimeString("zh-CN", { hour12: false });
}
+5 -6
View File
@@ -47,18 +47,17 @@ describe("desktop window frame", () => {
container.querySelectorAll(".onboarding-r6-titlebar"),
).toHaveLength(1);
expect(container.querySelector(".onboarding-r6-traffic")).toBeNull();
// Branding stays removed; only the explicit GUI language selector
// occupies the native titlebar, outside the draggable area.
// The user removed the mark, the "One Memory" wordmark and the
// "第一次见面" caption on 2026-09-20. The bar stays empty; MemHub is not
// put back into it.
expect(screen.queryByText("MemHub")).toBeNull();
expect(screen.queryByText("One Memory")).toBeNull();
expect(screen.queryByText("第一次见面")).toBeNull();
expect(
container.querySelector(".onboarding-r6-titlebar")?.children.length,
).toBe(1);
expect(screen.getByRole("combobox", { name: "界面语言" })).toBeTruthy();
).toBe(0);
const titlebar = declaration(".onboarding-r6-titlebar");
expect(titlebar).toMatch(/flex:\s*0 0 auto/);
expect(titlebar).toMatch(/min-height:\s*58px/);
expect(titlebar).toMatch(/flex:\s*0 0 58px/);
expect(titlebar).toMatch(/-webkit-app-region:\s*drag/);
},
);
+53 -80
View File
@@ -2,7 +2,6 @@ import type { ImportJob, SourceResponse } from "@one-memory/contracts";
import { Pause, Play } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { connectionHostId } from "./connection-hosts";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6ColdStartFrame } from "./R6ColdStartFrame";
import { R6Button } from "./R6Controls";
import { R6ImportProgress } from "./R6ImportProgress";
@@ -88,7 +87,6 @@ export function FirstImportReview({
onConnectionDraftChange,
onContinue,
}: FirstImportReviewProps) {
const { text, t } = useOnboardingTranslation();
const stale = sourceSnapshot?.stale === true;
const ids = [...new Set(sourceIds)];
// Without explicit IDs, capture the initial batch once; later sync/history jobs
@@ -122,14 +120,7 @@ export function FirstImportReview({
);
const progressStale = stale && batch.length === 0;
const [changing, setChanging] = useState(false);
const [actionResult, setActionResult] = useState<{
stopped: boolean;
confirmed: number;
failed: number;
} | null>(null);
const actionMessage = actionResult
? `${actionResult.stopped ? t("onboardingBatchStopped") : ""}${t("onboardingBatchResult", { confirmed: actionResult.confirmed, failed: actionResult.failed })}${actionResult.failed ? text("已确认项保留,请核对未确认任务后重试。") : ""}`
: "";
const [actionMessage, setActionMessage] = useState("");
const batchFlight = useRef(false),
stopBatch = useRef(false),
mounted = useRef(true);
@@ -205,7 +196,7 @@ export function FirstImportReview({
batchFlight.current = true;
stopBatch.current = false;
setChanging(true);
setActionResult(null);
setActionMessage("");
let confirmed = 0,
failed = 0;
try {
@@ -271,7 +262,9 @@ export function FirstImportReview({
}
}
if (mounted.current)
setActionResult({ stopped: stopBatch.current, confirmed, failed });
setActionMessage(
`${stopBatch.current ? "已停止后续操作;已发出的操作仍按回执核对。" : ""}已确认 ${confirmed} 项,${failed} 项未确认。${failed ? "已确认项保留,请核对未确认任务后重试。" : ""}`,
);
} finally {
batchFlight.current = false;
if (mounted.current) setChanging(false);
@@ -281,23 +274,21 @@ export function FirstImportReview({
return (
<R6ColdStartFrame
step={2}
title={text(title)}
eyebrow={text("导入进行中 · 顺手连接应用")}
title={title}
eyebrow="导入进行中 · 顺手连接应用"
footer={
<>
<div className="first-import-r6-footer-note">
<p>
{text(
allCompleted
? "对话已保存,记忆尚待生成。"
: displayImporting
? "导入在后台继续,无需等待。"
: stale
? "正在重新核对来源;已保存数量仍按任务回执显示。"
: allPaused
? "本次历史导入已暂停,已保存的对话还在。"
: "导入等待处理,已保存的对话还在。",
)}
{allCompleted
? "对话已保存,记忆尚待生成。"
: displayImporting
? "导入在后台继续,无需等待。"
: stale
? "正在重新核对来源;已保存数量仍按任务回执显示。"
: allPaused
? "本次历史导入已暂停,已保存的对话还在。"
: "导入等待处理,已保存的对话还在。"}
</p>
{(error || actionMessage) && (
<p
@@ -316,7 +307,7 @@ export function FirstImportReview({
: undefined
}
>
{error ? text(error) : actionMessage}
{error || actionMessage}
</p>
)}
</div>
@@ -327,7 +318,7 @@ export function FirstImportReview({
stopBatch.current = true;
}}
>
{text("停止后续操作")}
停止后续操作
</R6Button>
) : onChangeJob && (canPause || canResume) ? (
<R6Button
@@ -339,7 +330,7 @@ export function FirstImportReview({
) : (
<Play size={16} aria-hidden="true" />
)}
{text(canPause ? "暂停导入" : "继续导入")}
{canPause ? "暂停导入" : "继续导入"}
</R6Button>
) : null}
{recovery ? (
@@ -351,20 +342,18 @@ export function FirstImportReview({
recovery.onSkip();
}}
>
{text(
recovery.destination === "sources"
? "仍进入应用页"
: recovery.destination === "settings"
? "仍进入设置"
: "仍进入工作台",
)}
{recovery.destination === "sources"
? "仍进入应用页"
: recovery.destination === "settings"
? "仍进入设置"
: "仍进入工作台"}
</R6Button>
<R6Button
variant="primary"
disabled={busy}
onClick={() => leave(recovery.destination)}
>
{text("重试保存并进入")}
重试保存并进入
</R6Button>
</>
) : (
@@ -373,7 +362,7 @@ export function FirstImportReview({
disabled={busy}
onClick={() => leave("settings")}
>
{text("配置模型,沉淀记忆")}
配置模型,沉淀记忆
</R6Button>
)}
</div>
@@ -381,25 +370,19 @@ export function FirstImportReview({
}
>
<div className="first-import-r6-layout">
<section
className="first-import-r6-main"
aria-label={text("导入与原文")}
>
<section className="first-import-r6-main" aria-label="导入与原文">
<p className="first-import-r6-intro">
{text(
allCompleted
? "本次导入已结束。已保存的对话无需重复导入。"
: "对话先保存在本机。你可以同时设置后续连接。",
)}
{allCompleted
? "本次导入已结束。已保存的对话无需重复导入。"
: "对话先保存在本机。你可以同时设置后续连接。"}
</p>
<p className="first-import-r6-subintro">
{text(
"导入不会自动开放应用读取权限。配置模型后,才会开始生成记忆。",
)}
导入不会自动开放应用读取权限。配置模型后,才会开始生成记忆。
</p>
{historyWindowLimited && (
<p className="first-import-r6-history-window" role="status">
{t("onboardingHistoryWindow")}
检测到会话数据量较大。为控制用量并保持各应用时间线一致,本次所有应用仅导入最近
3 天的对话。
</p>
)}
<R6ImportProgress
@@ -408,24 +391,21 @@ export function FirstImportReview({
jobs={batch}
stale={progressStale}
/>
<section
className="first-import-r6-feed"
aria-label={text("导入说明")}
>
<h2>{text("正在导入你的会话")}</h2>
<section className="first-import-r6-feed" aria-label="导入说明">
<h2>正在导入你的会话</h2>
<p className="first-import-r6-feed-status">
{text("后台将持续为你导入,可以放心进入下一步")}
后台将持续为你导入,可以放心进入下一步
</p>
{Object.keys(failedSources).length > 0 && (
<section
className="first-import-r6-failures"
aria-label={text("未开始的导入")}
aria-label="未开始的导入"
>
{Object.keys(failedSources).map((id) => (
<p key={id}>
{sources.find((source) => source.source_id === id)
?.display_name ?? id}{" "}
{text("未能开始导入,其他已接受任务不受影响。")}
未能开始导入,其他已接受任务不受影响。
</p>
))}
<R6Button
@@ -433,30 +413,27 @@ export function FirstImportReview({
disabled={busy}
onClick={() => leave("sources")}
>
{text("查看未导入的应用")}
查看未导入的应用
</R6Button>
</section>
)}
</section>
</section>
<aside
className="first-import-r6-options"
aria-label={text("连接应用")}
>
<p className="first-import-r6-eyebrow">{text("让记忆继续生长")}</p>
<h2>{text("连接你的应用")}</h2>
<aside className="first-import-r6-options" aria-label="连接应用">
<p className="first-import-r6-eyebrow">让记忆继续生长</p>
<h2>连接你的应用</h2>
<p className="first-import-r6-options-intro">
{text("适用于本次选择且支持的应用。")}
适用于本次选择且支持的应用。
</p>
<SelectionTile
title={text(
hostConnectionAvailable ? "一键接入应用" : "测试实例不接入应用",
)}
body={text(
title={
hostConnectionAvailable ? "一键接入应用" : "测试实例不接入应用"
}
body={
hostConnectionAvailable
? "安装记忆连接,让所选应用用上 MemHub 中的相关记忆。"
: "当前使用独立测试数据目录,不会修改本机应用的连接配置。",
)}
: "当前使用独立测试数据目录,不会修改本机应用的连接配置。"
}
checked={
hostConnectionAvailable &&
!!hostEligible.length &&
@@ -479,16 +456,12 @@ export function FirstImportReview({
}
/>
{connectionNote && (
<p className="first-import-r6-options-note">
{text(connectionNote)}
</p>
<p className="first-import-r6-options-note">{connectionNote}</p>
)}
<p className="first-import-r6-options-note">
{text(
draftReady
? "点击底部按钮后,后台分别接入各应用;某个应用失败不影响其他应用。"
: "暂时无法连接应用,当前不会安装连接。",
)}
{draftReady
? "点击底部按钮后,后台分别接入各应用;某个应用失败不影响其他应用。"
: "暂时无法连接应用,当前不会安装连接。"}
</p>
</aside>
</div>
+1 -50
View File
@@ -1,58 +1,9 @@
// @vitest-environment jsdom
import type { MemoryDetail, SourceResponse } from "@one-memory/contracts";
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { FirstUseGuide, FirstUseInvitation } from "./FirstUseGuide";
import {
LANGUAGE_STORAGE_KEY,
LanguageProvider,
useGuiLanguage,
} from "./gui-language";
function GuideLanguageSwitch() {
const { setPreference } = useGuiLanguage();
return (
<button type="button" onClick={() => void setPreference("zh-CN")}>
Cambiar idioma
</button>
);
}
it("localizes the supported guide without changing original memory or copying", async () => {
window.oneMemory = undefined;
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
render(
<LanguageProvider>
<GuideLanguageSwitch />
<FirstUseGuide sources={[source]} onSources={vi.fn()} onClose={vi.fn()} />
</LanguageProvider>,
);
await screen.findByRole("heading", {
name: "Retoma tu próxima conversación",
});
await screen.findByText(detail.memory.content, { selector: "blockquote" });
expect(
screen.getByRole("button", { name: "Copiar mensaje inicial" }),
).toBeTruthy();
const reads = getMemory.mock.calls.length;
fireEvent.click(screen.getByText("Cambiar idioma"));
await screen.findByRole("heading", { name: "让下一次对话接得上" });
expect(getMemory).toHaveBeenCalledTimes(reads);
expect(
screen.getByText(detail.memory.content, { selector: "blockquote" }),
).toBeTruthy();
expect(copyText).not.toHaveBeenCalled();
expect(openHandoff).not.toHaveBeenCalled();
localStorage.clear();
});
const getMemories = vi.fn(),
getMemory = vi.fn(),
+90 -116
View File
@@ -3,7 +3,6 @@ import type {
MemoryDetail,
SourceResponse,
} from "@one-memory/contracts";
import type { MessageKey } from "@one-memory/i18n";
import {
ArrowLeft,
ArrowRight,
@@ -28,7 +27,6 @@ import {
sameGuideMemory,
} from "./first-use-model";
import type { RecallHostStatus } from "./global";
import { useGuiLanguage } from "./gui-language";
import { refreshRecallHosts, useRecallHosts } from "./host-state";
import { refreshSources } from "./source-state";
@@ -43,7 +41,6 @@ export function FirstUseGuide({
onSources(): void;
onClose(): void;
}) {
const { t, tn, locale } = useGuiLanguage();
const [scene, setScene] = useState<GuideScenario>("same");
const [route, setRoute] = useState<GuideRoute>("context");
const [memories, setMemories] = useState<GovernedMemory[]>([]);
@@ -55,12 +52,11 @@ export function FirstUseGuide({
const hosts = hostsError ? NO_HOSTS : (hostRead.data ?? NO_HOSTS);
const [loading, setLoading] = useState(true);
const [detailLoading, setDetailLoading] = useState(false);
const [error, setError] = useState<MessageKey | "">("");
const [error, setError] = useState("");
const [reload, setReload] = useState(0);
const [targetId, setTargetId] = useState("");
const [topic, setTopic] = useState("");
const [notice, setNotice] = useState<MessageKey | "">("");
const [noticeHost, setNoticeHost] = useState("");
const [notice, setNotice] = useState("");
const [copiedKey, setCopiedKey] = useState("");
const [busy, setBusy] = useState(false);
const [reported, setReported] = useState<"ok" | "missing" | null>(null);
@@ -112,7 +108,7 @@ export function FirstUseGuide({
if (!cancelled) {
setMemories([]);
setSelectedId("");
setError("guideReadError");
setError("暂时无法读取记忆,可稍后重试。");
}
})
.finally(() => {
@@ -147,7 +143,7 @@ export function FirstUseGuide({
);
})
.catch(() => {
if (!cancelled) setError("guideDetailError");
if (!cancelled) setError("这条记忆暂不可读,请换一条或刷新。");
})
.finally(() => {
if (!cancelled) setDetailLoading(false);
@@ -176,12 +172,7 @@ export function FirstUseGuide({
const nameOf = (id: string) =>
sources.find((item) => item.source_id === id)?.display_name ??
(id === "one-memory-manual" ? "端内添加" : id);
// The handoff draft is task content, not UI copy: retain its existing wording.
const sourceLabel = sourceIds.map(nameOf).join("、") || "原应用";
const displaySourceLabel =
sourceIds
.map((id) => (id === "one-memory-manual" ? t("guideManual") : nameOf(id)))
.join(t("guideListSeparator")) || t("guideOriginalApp");
const draft =
available && detail
? draftForScenario(scene, effectiveRoute, topic, detail, sourceLabel)
@@ -227,7 +218,7 @@ export function FirstUseGuide({
) {
setDetail(null);
setCopiedKey("");
setError("guideChanged");
setError("记忆内容或授权已变化,请刷新后重新核对。");
return;
}
if (
@@ -241,7 +232,7 @@ export function FirstUseGuide({
)
) {
setCopiedKey("");
setNotice("guideConnectionChanged");
setNotice("连接状态已变化,尚未复制。请重新选择方式。");
return;
}
validated = true;
@@ -251,24 +242,25 @@ export function FirstUseGuide({
if (open && host?.handoff === "open" && window.oneMemory) {
try {
const result = await window.oneMemory.openHandoff(host.hostId, draft);
if (current()) {
setNoticeHost(host.label);
if (current())
setNotice(
result.opened ? "guideCopiedOpened" : "guideCopiedNotFilled",
result.opened
? `已复制并请求打开 ${host.label}。尚未发送,请核对输入框。`
: "已完整复制,未能预填。可在目标应用粘贴,尚未发送。",
);
}
} catch {
if (current()) setNotice("guideCopiedNotOpened");
if (current())
setNotice("已完整复制,但未能打开目标应用。尚未发送。");
}
} else setNotice("guideCopied");
} else setNotice("已完整复制。尚未发送,也没有新增读取授权。");
} catch {
if (current()) {
if (!validated) {
setDetail(null);
setCopiedKey("");
setError("guideValidationError");
setError("无法核对当前记忆与授权,旧预览已收起。请刷新后重试。");
}
setNotice("guideCopyError");
setNotice("未能核对最新内容或完成复制。请重试;没有打开目标应用。");
}
} finally {
if (current()) setBusy(false);
@@ -279,16 +271,16 @@ export function FirstUseGuide({
<section className="view first-use-view" aria-labelledby="first-use-title">
<header className="first-use-heading">
<div>
<p className="first-use-eyebrow">{t("guideEyebrow")}</p>
<h1 id="first-use-title">{t("guideTitle")}</h1>
<p className="first-use-eyebrow">上手体验</p>
<h1 id="first-use-title">让下一次对话接得上</h1>
</div>
<button type="button" className="quiet" onClick={onClose}>
<ArrowLeft size={16} aria-hidden="true" />
{t("guideBack")}
返回记忆
</button>
</header>
<fieldset className="first-use-scenarios">
<legend className="sr-only">{t("guideChooseScene")}</legend>
<legend className="sr-only">选择使用场景</legend>
{GUIDE_SCENARIOS.map((item, index) => (
<label key={item.id} data-selected={scene === item.id}>
<input
@@ -304,61 +296,47 @@ export function FirstUseGuide({
}}
/>
<span className="first-use-number">0{index + 1}</span>
<span>
{t(
item.id === "same"
? "guideSame"
: item.id === "switch"
? "guideSwitch"
: "guideNew",
)}
</span>
<span>{item.label}</span>
</label>
))}
</fieldset>
<p className="first-use-description">
{t(
scenario.id === "same"
? "guideSameDescription"
: scenario.id === "switch"
? "guideSwitchDescription"
: "guideNewDescription",
)}
</p>
<p className="first-use-description">{scenario.description}</p>
{importedMessages > 0 && (
<p className="first-use-receipt">
{tn("guideSaved", importedMessages)}
本机已保存 {importedMessages.toLocaleString()} 条原始消息 ·{" "}
{loading
? t("guideReadingMemories")
: tn("guideLoaded", memories.length)}
{t("guideSeparate")}
? "正在读取已整理记忆"
: `本页已加载 ${memories.length} 条可用记忆`}
。两个阶段分别计数。
</p>
)}
<div className="first-use-path">
<span>{t("guideChooseBackground")}</span>
<span>选择背景</span>
<ArrowRight size={14} aria-hidden="true" />
<span>{t("guideCheckSources")}</span>
<span>核对来源</span>
<ArrowRight size={14} aria-hidden="true" />
<span>{t("guideTry")}</span>
<span>到新对话试用</span>
</div>
{error && (
<p className="error-banner" role="alert">
{t(error)}
{error}
</p>
)}
{loading ? (
<p role="status">{t("guideLoading")}</p>
<p role="status">正在读取本机已有记忆…</p>
) : !memories.length ? (
<section className="first-use-empty">
<FileText size={28} aria-hidden="true" />
<h2>{t("guideEmptyTitle")}</h2>
<p>{t("guideEmptyDescription")}</p>
<h2>先留下一条真正有用的背景</h2>
<p>
目前没有可用于这次体验的记忆。已导入的原文不等于已经整理好;候选内容也不会被自动带出。
</p>
<div>
<button type="button" onClick={onClose}>
{t("guideAdd")}
去添加记忆
</button>
<button type="button" className="secondary" onClick={onSources}>
{t("guideConnections")}
查看导入与连接
</button>
<button
type="button"
@@ -366,7 +344,7 @@ export function FirstUseGuide({
onClick={() => setReload((n) => n + 1)}
>
<RefreshCw size={15} />
{t("guideRefresh")}
刷新
</button>
</div>
</section>
@@ -377,12 +355,12 @@ export function FirstUseGuide({
aria-labelledby="guide-background-title"
>
<header>
<h2 id="guide-background-title">{t("guideBackgroundTitle")}</h2>
<h2 id="guide-background-title">这次用到的背景</h2>
<button
type="button"
className="quiet"
title={t("guideRefreshExample")}
aria-label={t("guideRefreshExample")}
title="刷新示例"
aria-label="刷新示例"
onClick={() => {
changed();
setDetail(null);
@@ -394,7 +372,7 @@ export function FirstUseGuide({
</button>
</header>
<label className="first-use-field">
{t("guideChooseMemory")}
选择一条记忆
<select
value={selectedId}
onChange={(event) => {
@@ -412,51 +390,46 @@ export function FirstUseGuide({
</select>
</label>
<p className="first-use-meta">
{tn("guideLoadedScope", memories.length)}
从当前已加载的 {memories.length}{" "}
条可用记忆中选择,不代表全部历史。
</p>
{detailLoading ? (
<p role="status">{t("guideCheckingEvidence")}</p>
<p role="status">正在核对原始依据…</p>
) : available && detail ? (
<>
<blockquote className="first-use-memory">
{detail.memory.content}
</blockquote>
<p className="first-use-meta">
{t("guideRevision", {
source: displaySourceLabel,
revision: detail.memory.revision.toLocaleString(locale),
})}
{sourceLabel} · 版本 {detail.memory.revision} ·
系统整理,需你核对
</p>
<p className="first-use-scope">
{t("guideScope")}
适用范围:
{detail.memory.scopes.includes("all-apps")
? t("guideAllApps")
? "已设为跨应用可用,不等于个人偏好"
: detail.memory.scopes.includes("private")
? t("guidePrivate")
: t("guideLimited")}
? "已停止外部记忆召回;手动带出需另行确认"
: "限定范围,未扩大外部召回范围"}
</p>
<details className="first-use-evidence" open>
<summary>
{tn("guideEvidence", detail.evidence.length)}
</summary>
<summary>原始依据 · {detail.evidence.length} 段</summary>
{detail.evidence.map((item) => (
<article key={item.evidence_id}>
<header>
<span>
{item.source_id
? nameOf(item.source_id)
: t("guideUnknownSource")}
{item.source_id ? nameOf(item.source_id) : "未知来源"}
</span>
<span>
{item.written_by === "person"
? t("guideYou")
? "你"
: item.written_by === "agent"
? "Agent"
: t("guideUnknownAuthor")}{" "}
: "作者未识别"}{" "}
·{" "}
{item.created_at
? new Date(item.created_at).toLocaleString(locale)
: t("guideUnknownTime")}
? new Date(item.created_at).toLocaleString()
: "时间未知"}
</span>
</header>
<p>{item.verbatim}</p>
@@ -465,16 +438,18 @@ export function FirstUseGuide({
</details>
</>
) : (
<p className="first-use-unavailable">{t("guideUnavailable")}</p>
<p className="first-use-unavailable">
原文或当前授权不可用。换一条背景或刷新后再试,不会带出这条内容。
</p>
)}
</section>
<section
className="first-use-destination"
aria-labelledby="guide-draft-title"
>
<h2 id="guide-draft-title">{t("guideDraftTitle")}</h2>
<h2 id="guide-draft-title">下一段对话的开场白</h2>
<label className="first-use-field">
{t("guideChooseApp")}
在哪个应用试用
<select
value={host?.hostId ?? ""}
onChange={(event) => {
@@ -483,7 +458,7 @@ export function FirstUseGuide({
setRoute("context");
}}
>
<option value="">{t("guideCopyOnly")}</option>
<option value="">自行选择应用 · 仅复制</option>
{destinations.map((item) => (
<option key={item.hostId} value={item.hostId}>
{item.label}
@@ -493,14 +468,14 @@ export function FirstUseGuide({
</label>
<p className="first-use-connection">
{hostsError
? t("guideHostsError")
? "暂时无法读取连接状态,可重试或仅复制背景。"
: !host
? t("guideNoApp")
? "尚未选择已连接的应用。手动带入不等于安装连接。"
: canQuery
? t("guideWired")
? "上次自检可达,本次是否用对仍需核对。"
: host.installed
? t("guideInstalled")
: t("guideNotInstalled")}
? "已安装连接,尚未验证读取。"
: "尚未安装读取连接。"}
</p>
<button
type="button"
@@ -508,10 +483,10 @@ export function FirstUseGuide({
onClick={onSources}
>
<Link2 size={15} aria-hidden="true" />
{t("guideManage")}
管理应用连接
</button>
<fieldset className="first-use-routes">
<legend className="sr-only">{t("guideChooseRoute")}</legend>
<legend className="sr-only">选择带入方式</legend>
<label>
<input
type="radio"
@@ -522,7 +497,7 @@ export function FirstUseGuide({
setRoute("context");
}}
/>
{t("guideContext")}
带入背景
</label>
<label>
<input
@@ -535,12 +510,12 @@ export function FirstUseGuide({
setRoute("query");
}}
/>
{t("guideQuery")}
让应用查询
</label>
</fieldset>
{effectiveRoute === "query" && (
<label className="first-use-field">
{t("guideTopic")}
要接续的主题
<input
value={topic}
maxLength={160}
@@ -553,17 +528,17 @@ export function FirstUseGuide({
)}
<p className="first-use-meta">
{effectiveRoute === "query"
? t("guideQueryHint")
? "只带查询,不把上方的预期答案塞进新会话。"
: scene === "new"
? t("guideNewHint")
: t("guideContextHint")}
? "仅带你选择的这一条,不自动生成个人画像。"
: "完整带入这一条背景与引用;这不是自动召回测试。"}
</p>
<textarea
className="first-use-draft"
aria-label={t("guidePreview")}
aria-label="开场白完整预览"
readOnly
value={draft}
placeholder={t("guidePreviewPlaceholder")}
placeholder="原始依据可用后显示开场白"
spellCheck={false}
/>
<div className="first-use-actions">
@@ -573,7 +548,7 @@ export function FirstUseGuide({
onClick={() => void carry(false)}
>
<Copy size={16} aria-hidden="true" />
{busy ? t("guideChecking") : t("guideCopy")}
{busy ? "正在核对…" : "复制开场白"}
</button>
{host?.handoff === "open" && (
<button
@@ -583,18 +558,18 @@ export function FirstUseGuide({
onClick={() => void carry(true)}
>
<ExternalLink size={16} aria-hidden="true" />
{t("guideCopyOpen", { host: host.label })}
复制并打开 {host.label}
</button>
)}
</div>
{notice && (
<p className="first-use-notice" role="status">
{t(notice, { host: noticeHost })}
{notice}
</p>
)}
{copiedKey === contextKey && (
<section className="first-use-result">
<p>{t("guideNoReceipt")}</p>
<p>尚无本次外部调用回执。新对话是否引用了正确的背景?</p>
<div>
<button
type="button"
@@ -602,21 +577,21 @@ export function FirstUseGuide({
onClick={() => setReported("ok")}
>
<Check size={15} />
{t("guideVerified")}
我已核对结果
</button>
<button
type="button"
className="quiet"
onClick={() => setReported("missing")}
>
{t("guideMissing")}
没有找回来
</button>
</div>
{reported && (
<p role="status">
{reported === "ok"
? t("guideFeedbackOk")
: t("guideFeedbackMissing")}
? "你的反馈:已核对。这不是系统自动验收记录。"
: "你的反馈:没有找回来。先检查来源是否导入、记忆范围与应用连接。"}
</p>
)}
</section>
@@ -635,19 +610,18 @@ export function FirstUseInvitation({
onOpen(): void;
onDismiss(): void;
}) {
const { t } = useGuiLanguage();
return (
<aside className="first-use-invitation" aria-label={t("guideInvitation")}>
<span>{t("guideInvitationHint")}</span>
<aside className="first-use-invitation" aria-label="上手体验邀请">
<span>用一条自己的记忆,试着接续下一次对话。</span>
<button type="button" className="quiet" onClick={onOpen}>
{t("guideTryOnce")}
试一次
<ArrowRight size={15} aria-hidden="true" />
</button>
<button
type="button"
className="quiet"
title={t("guideLater")}
aria-label={t("guideDismiss")}
title="稍后再看"
aria-label="关闭上手邀请"
onClick={onDismiss}
>
<X size={15} />
@@ -13,20 +13,6 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { GraphWidthPrompt } from "./GraphWidthPrompt";
import {
LANGUAGE_STORAGE_KEY,
LanguageProvider,
useGuiLanguage,
} from "./gui-language";
function LanguageSwitch() {
const { setPreference } = useGuiLanguage();
return (
<button type="button" onClick={() => void setPreference("zh-CN")}>
Cambiar idioma
</button>
);
}
const { getGraphWidth, decideGraphWidth } = vi.hoisted(() => ({
getGraphWidth: vi.fn(),
@@ -61,44 +47,6 @@ describe("GraphWidthPrompt", () => {
});
});
it("translates the mismatch and live switch without requesting or confirming twice", async () => {
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
const user = userEvent.setup();
render(
<LanguageProvider>
<LanguageSwitch />
<GraphWidthPrompt />
</LanguageProvider>,
);
await screen.findByRole("dialog", {
name: "La biblioteca y el modelo integrado no coinciden",
});
const keep = screen.getByRole("radio", {
name: /Conservar la biblioteca actual/,
}) as HTMLInputElement;
expect(keep.checked).toBe(true);
await user.click(
screen.getByRole("radio", { name: /Usar el modelo integrado/ }),
);
expect(decideGraphWidth).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Cambiar idioma" }));
await screen.findByRole("dialog", { name: "现有记忆库和内置模型对不上" });
expect(
(screen.getByRole("radio", { name: /改用内置模型/ }) as HTMLInputElement)
.checked,
).toBe(true);
expect(getGraphWidth).toHaveBeenCalledOnce();
expect(decideGraphWidth).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "就这么办" }));
await waitFor(() =>
expect(decideGraphWidth).toHaveBeenCalledExactlyOnceWith(
"rebuild_with_new",
expect.any(String),
),
);
window.localStorage.removeItem(LANGUAGE_STORAGE_KEY);
});
it("moves focus into the modal and keeps Tab inside it", async () => {
const user = userEvent.setup();
render(
+10 -23
View File
@@ -4,7 +4,6 @@ import type {
} from "@one-memory/contracts";
import { useEffect, useRef, useState } from "react";
import { decideGraphWidth, getGraphWidth } from "./api";
import { useGuiLanguage } from "./gui-language";
import { traceUiAction } from "./ui-trace";
import { isDefiniteRefusal, writeTraceStatus } from "./write-outcome";
@@ -21,7 +20,6 @@ export function GraphWidthPrompt({
}: {
onResolved?(): void;
}) {
const { t, locale } = useGuiLanguage();
const [status, setStatus] = useState<GraphWidthStatus | null>(null);
const [decision, setDecision] = useState<GraphWidthDecision>("keep_existing");
const [busy, setBusy] = useState(false);
@@ -90,7 +88,7 @@ export function GraphWidthPrompt({
? reason instanceof Error
? reason.message
: String(reason)
: "graphWidthUnknown",
: "宽度选择尚未确认,现有记忆库未被改动。请重试。",
);
} finally {
setBusy(false);
@@ -100,19 +98,15 @@ export function GraphWidthPrompt({
const choices: { value: GraphWidthDecision; label: string }[] = [
{
value: "keep_existing",
label: t("graphWidthKeep", {
count: status.memory_count.toLocaleString(locale),
}),
label: `保留现有记忆库(默认):继续用原来的 embedding 配置,${status.memory_count} 条记忆和关系图都不动`,
},
{
value: "rebuild_with_new",
label: t("graphWidthRebuild", {
count: status.memory_count.toLocaleString(locale),
}),
label: `改用内置模型:现有关系图会被归档(不删除),${status.memory_count} 条记忆的关系需要重新建一遍`,
},
{
value: "export_first",
label: t("graphWidthExport"),
label: "先导出再决定:现在什么都不动,导出之后再回来选",
},
];
@@ -121,7 +115,7 @@ export function GraphWidthPrompt({
className="graph-width-prompt"
role="dialog"
aria-modal="true"
aria-label={t("graphWidthTitle")}
aria-label="现有记忆库和内置模型对不上"
ref={panel}
onKeyDown={(event) => {
if (event.key !== "Tab" || !panel.current) return;
@@ -142,12 +136,9 @@ export function GraphWidthPrompt({
}
}}
>
<h2>{t("graphWidthTitle")}</h2>
<h2>现有记忆库和内置模型对不上</h2>
<p>
{t("graphWidthDescription", {
stored: stored_width.toLocaleString(locale),
expected: expected_width.toLocaleString(locale),
})}
{`你现在的记忆库按 ${stored_width} 维建的,内置模型出的是 ${expected_width} 维。两者接不上,不选一个的话抽取会一直失败。`}
</p>
<ul>
{choices.map((choice) => (
@@ -168,16 +159,12 @@ export function GraphWidthPrompt({
</ul>
{exportedPath ? (
<p className="notice">
{t("graphWidthExported", { path: exportedPath })}
</p>
) : null}
{error ? (
<p className="notice">
{error === "graphWidthUnknown" ? t("graphWidthUnknown") : error}
已导出到 <code>{exportedPath}</code>。原库没有动,现在可以再选一次。
</p>
) : null}
{error ? <p className="notice">{error}</p> : null}
<button type="button" disabled={busy} onClick={() => void confirm()}>
{t("graphWidthConfirm")}
就这么办
</button>
</div>
);
-22
View File
@@ -3,7 +3,6 @@ import type { GovernedMemory } from "@one-memory/contracts";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import { HandoffBar, promptFrom } from "./HandoffBar";
function memory(
@@ -68,27 +67,6 @@ afterEach(() => {
delete window.oneMemory;
});
it("traduce la vista previa sin modificar el prompt contractual chino", async () => {
delete window.oneMemory;
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
const memories = [memory("a", { content: "Contenido 中文 opaco" })];
render(
<LanguageProvider>
<HandoffBar memories={memories} matterLabel="Tema 中文" />
</LanguageProvider>,
);
await userEvent.click(
screen.getByRole("button", { name: "Llevar las memorias seleccionadas" }),
);
expect(
screen.getByRole("heading", { name: "Llevar a otras aplicaciones" }),
).toBeTruthy();
expect((screen.getByRole("textbox") as HTMLTextAreaElement).value).toBe(
promptFrom(memories, "Tema 中文"),
);
window.localStorage.removeItem(LANGUAGE_STORAGE_KEY);
});
describe("handoff preview", () => {
it("does not open a host after the preview was closed while copying", async () => {
let copied: () => void = () => undefined;
+20 -25
View File
@@ -2,7 +2,6 @@ import { Copy, ExternalLink, X } from "lucide-react";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { copyText } from "./copy-text";
import type { RecallHostStatus } from "./global";
import { useGuiLanguage } from "./gui-language";
import { useRecallHosts } from "./host-state";
const NO_HOSTS: RecallHostStatus[] = [];
@@ -24,7 +23,6 @@ export function HandoffBar({
answerText?: string;
suspended?: boolean;
}) {
const { t } = useGuiLanguage();
const hosts = (useRecallHosts({ read: true }).data ?? NO_HOSTS).filter(
(host) => host.present,
);
@@ -38,14 +36,14 @@ export function HandoffBar({
const prompt = promptFrom(usable, matterLabel, answerText);
if (!usable.length) return null;
return (
<section className="wb-handoff" aria-label={t("handoff.region")}>
<section className="wb-handoff" aria-label="带出记忆">
<button
type="button"
className="quiet"
onClick={() => setOpenedFor(prompt)}
>
<ExternalLink size={15} aria-hidden="true" />
{answerText ? t("handoff.answer") : t("handoff.selected")}
{answerText ? "带出回答与引用" : "带出所选记忆"}
</button>
{openedFor === prompt && (
<HandoffDialog
@@ -74,7 +72,6 @@ function HandoffDialog({
onClose(): void;
suspended?: boolean;
}) {
const { locale, t, tn } = useGuiLanguage();
const titleId = useId();
const panel = useRef<HTMLElement>(null);
const [hostId, setHostId] = useState("");
@@ -108,7 +105,7 @@ function HandoffDialog({
try {
await copyText(prompt);
} catch {
setNote(t("handoff.copyFailed"));
setNote("复制失败,未打开任何应用。可在预览中选中文本复制。");
setBusy(false);
return;
}
@@ -118,17 +115,15 @@ function HandoffDialog({
const result = await window.oneMemory.openHandoff(host.hostId, prompt);
setNote(
result.opened
? t("handoff.opened", { host: host.label })
: t("handoff.notPrefilled", { host: host.label }),
? `已复制并请求打开 ${host.label}。尚未发送;请核对目标输入框。`
: `已完整复制。${host.label} 未能预填,请在目标应用粘贴;尚未发送。`,
);
} catch {
setNote(t("handoff.openFailed", { host: host.label }));
setNote(`内容已完整复制,但未能打开 ${host.label}。尚未发送。`);
}
} else {
setNote(
t("handoff.copied", {
target: host ? t("handoff.pasteTarget", { host: host.label }) : "",
}),
`已完整复制${host ? `,可粘贴到 ${host.label}` : ""}。尚未发送。`,
);
}
setBusy(false);
@@ -159,30 +154,28 @@ function HandoffDialog({
}}
>
<header className="handoff-heading">
<h2 id={titleId}>{t("handoff.title")}</h2>
<h2 id={titleId}>带到其他应用</h2>
<button
type="button"
className="quiet"
title={t("handoff.closeTitle")}
aria-label={t("handoff.close")}
title="关闭预览"
aria-label="关闭带出预览"
onClick={onClose}
>
<X size={18} />
</button>
</header>
<p>
{tn("handoff.stats", count, {
characters: new Intl.NumberFormat(locale).format(prompt.length),
})}
{count} 条记忆 · {prompt.length.toLocaleString()} 字符 · 未省略
</p>
<textarea
aria-label={t("handoff.content")}
aria-label="将带出的完整内容"
readOnly
value={prompt}
spellCheck={false}
/>
<label className="handoff-target">
{t("handoff.target")}
目标应用
<select
value={hostId}
onChange={(event) => {
@@ -190,16 +183,18 @@ function HandoffDialog({
setNote("");
}}
>
<option value="">{t("handoff.other")}</option>
<option value="">其他应用 · 仅复制</option>
{hosts.map((item) => (
<option key={item.hostId} value={item.hostId}>
{item.label}
{item.handoff !== "open" ? t("handoff.copyOnly") : ""}
{item.handoff !== "open" ? " · 仅复制" : ""}
</option>
))}
</select>
</label>
<p>{t("handoff.boundary")}</p>
<p>
仅带出上方内容,不包含原始对话。复制不是授权连接,已带出的副本无法从其他应用撤回。
</p>
<div className="consent-actions">
<button
type="button"
@@ -208,7 +203,7 @@ function HandoffDialog({
onClick={() => void send(false)}
>
<Copy size={15} aria-hidden="true" />
{t("handoff.copy")}
复制内容
</button>
{host?.handoff === "open" && (
<button
@@ -217,7 +212,7 @@ function HandoffDialog({
onClick={() => void send(true)}
>
<ExternalLink size={15} aria-hidden="true" />
{t("handoff.open", { host: host.label })}
复制并打开 {host.label}
</button>
)}
</div>
+1 -65
View File
@@ -12,11 +12,6 @@ import {
import { StrictMode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
LANGUAGE_STORAGE_KEY,
LanguageProvider,
useGuiLanguage,
} from "./gui-language";
import { HealthPanel } from "./HealthPanel";
import type { ImportSourcesController } from "./use-import-sources";
@@ -30,37 +25,6 @@ vi.mock("./api", () => ({
setDetailedLogging: (enabled: boolean) => setDetailedLogging(enabled),
}));
function HealthLanguageSwitch() {
const { setPreference } = useGuiLanguage();
return (
<button type="button" onClick={() => void setPreference("zh-CN")}>
Cambiar idioma
</button>
);
}
it("shows supported diagnostics in Spanish without toggling logging", async () => {
window.oneMemory = undefined;
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
render(
<LanguageProvider>
<HealthLanguageSwitch />
<HealthPanel imports={imports()} />
</LanguageProvider>,
);
await screen.findByRole("heading", { name: "Diagnóstico" });
expect(
screen.getByRole("button", { name: "Volver a leer el estado" }),
).toBeTruthy();
expect(screen.getByRole("heading", { name: "Servicio local" })).toBeTruthy();
const reads = getMemoryProcessing.mock.calls.length;
fireEvent.click(screen.getByText("Cambiar idioma"));
await screen.findByRole("heading", { name: "运行诊断" });
expect(getMemoryProcessing).toHaveBeenCalledTimes(reads);
expect(setDetailedLogging).not.toHaveBeenCalled();
localStorage.clear();
});
function source(
sourceId: string,
overrides: Partial<SourceResponse> = {},
@@ -187,9 +151,7 @@ describe("HealthPanel", () => {
render(<HealthPanel imports={imports()} />);
const details = await screen.findByLabelText("导入与整理详情");
expect(details.textContent).toContain(
"100,000 条原文 · 50,000 个完整 turn",
);
expect(details.textContent).toContain("100000 条原文 · 50000 个完整 turn");
expect(details.textContent).toContain(
"120 次调用 · 100 完成 · 1 结果不确定 · 2 失败 · 3 取消",
);
@@ -679,32 +641,6 @@ describe("HealthPanel", () => {
expect(block.textContent).toContain("暂无需要处理的问题");
});
it("read receipt timestamps follow the Spanish interface language", async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
try {
render(
<LanguageProvider>
<HealthPanel imports={imports()} />
</LanguageProvider>,
);
await settle();
getMemoryProcessing.mockRejectedValue(new Error("offline"));
await act(async () => {
await vi.advanceTimersByTimeAsync(5_000);
});
const stamps = Array.from(
document.querySelectorAll(".health-stamp time"),
);
expect(stamps.length).toBeGreaterThan(0);
for (const stamp of stamps) {
expect(stamp.textContent).toMatch(/^Hoy /);
expect(stamp.textContent).not.toContain("今天");
}
} finally {
localStorage.clear();
}
});
it("sourceSnapshot 过期不继续报服务运行中或来源空", async () => {
render(
<HealthPanel
+85 -168
View File
@@ -1,6 +1,6 @@
import { type GuiLocale, translate } from "@one-memory/i18n";
import { Download, Flag, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
getDetailedLogging,
getMemoryProcessing,
@@ -13,16 +13,16 @@ import {
import { clockStamp } from "./clock-stamp";
import { exportDiagnosticsFile } from "./diagnostics-export";
import type { RecallHostStatus } from "./global";
import { useGuiLanguage } from "./gui-language";
import { refreshRecallHosts } from "./host-state";
import { memoryText } from "./memory-language";
import {
blockReasonLabel,
retryingBlockLabel,
splitBlockedStages,
taskKindLabel,
} from "./memory-processing-labels";
import { subscribePoll } from "./poll-scheduler";
import { R6Button, R6Switch } from "./R6Controls";
import { LINK_WORDING } from "./recall-host-wording";
import type { useImportSources } from "./use-import-sources";
type ImportState = ReturnType<typeof useImportSources>;
@@ -98,11 +98,10 @@ async function readHosts() {
}
function ReadTime({ label, value }: { label: string; value: string }) {
const { locale } = useGuiLanguage();
return (
<p className="health-stamp">
{label}
<time dateTime={value}>{clockStamp(value, locale)}</time>
<time dateTime={value}>{clockStamp(value)}</time>
</p>
);
}
@@ -112,26 +111,23 @@ function ReadReceipt({
confirmedAt,
failedAt,
}: Pick<Reading<unknown>, "pending" | "confirmedAt" | "failedAt">) {
const { t } = useGuiLanguage();
return (
<>
{failedAt ? (
<p role="status">
{t("healthReadFailed")}
{confirmedAt ? t("healthStale") : "。"}
暂时无法读取
{confirmedAt ? " · 数据已过期,以下为上次读取结果。" : "。"}
</p>
) : null}
{pending ? (
<p role="status">
{confirmedAt ? t("healthRereading") : t("healthReading")}
{confirmedAt ? "正在重新读取,以下为上次读取结果…" : "正在读取状态…"}
</p>
) : null}
{confirmedAt ? (
<ReadTime label={t("healthReadSuccessTime")} value={confirmedAt} />
) : null}
{failedAt ? (
<ReadTime label={t("healthReadFailedTime")} value={failedAt} />
<ReadTime label="上次成功读取" value={confirmedAt} />
) : null}
{failedAt ? <ReadTime label="读取失败时间" value={failedAt} /> : null}
</>
);
}
@@ -147,8 +143,6 @@ function ReadReceipt({
* the table the originating screen already uses.
*/
export function HealthPanel({ imports }: { imports: ImportState }) {
const { t, tn, tnPlain, locale } = useGuiLanguage();
const n = (value: number) => value.toLocaleString(locale);
const [detailedLogging, setDetailedLoggingState] = useState(false);
const [detailedLoggingPending, setDetailedLoggingPending] = useState(false);
const backgroundRead = useDiagnosticRead(readBackgroundHealth);
@@ -205,13 +199,13 @@ export function HealthPanel({ imports }: { imports: ImportState }) {
async function markIncident() {
if (!window.oneMemory?.markDiagnosticIncident) {
setDiagnosticMessage(t("healthIncidentDesktop"));
setDiagnosticMessage("请在桌面 App 中记录问题时间点。");
return;
}
setDiagnosticAction("incident");
setDiagnosticMessage("");
try {
const description = window.prompt(t("healthIncidentPrompt"), "");
const description = window.prompt("简单描述刚才遇到的问题(可不填)", "");
if (description === null) {
setDiagnosticAction(null);
return;
@@ -219,11 +213,9 @@ export function HealthPanel({ imports }: { imports: ImportState }) {
const receipt =
await window.oneMemory.markDiagnosticIncident(description);
await window.oneMemory.copyText?.(receipt.incident_id);
setDiagnosticMessage(
t("healthIncidentCopied", { id: receipt.incident_id }),
);
setDiagnosticMessage(`问题编号已复制:${receipt.incident_id}`);
} catch {
setDiagnosticMessage(t("healthIncidentError"));
setDiagnosticMessage("问题时间点未能记录,请重试。");
} finally {
setDiagnosticAction(null);
}
@@ -231,13 +223,13 @@ export function HealthPanel({ imports }: { imports: ImportState }) {
async function exportDiagnostics() {
if (!window.oneMemory?.exportDiagnostics) {
setDiagnosticMessage(t("diagnosticExportDesktop"));
setDiagnosticMessage("请在桌面 App 中导出诊断信息。");
return;
}
setDiagnosticAction("export");
setDiagnosticMessage("");
try {
const result = await exportDiagnosticsFile(locale);
const result = await exportDiagnosticsFile();
setDiagnosticMessage(result.message);
} finally {
setDiagnosticAction(null);
@@ -252,23 +244,20 @@ export function HealthPanel({ imports }: { imports: ImportState }) {
const sourceReceipt = (
<>
{imports.loading ? (
<p role="status">{t("healthReadingSources")}</p>
<p role="status">正在读取本地服务与来源状态…</p>
) : sourceUnavailable ? (
<p role="status">
{t("healthReadFailed")}
暂时无法读取
{sourceConfirmedAt || imports.health || imports.sources.length
? t("healthStale")
? " · 数据已过期,以下为上次读取结果。"
: "。"}
</p>
) : null}
{sourceConfirmedAt ? (
<ReadTime
label={t("healthReadSuccessTime")}
value={sourceConfirmedAt}
/>
<ReadTime label="上次成功读取" value={sourceConfirmedAt} />
) : null}
{sourceUnavailable && sourceFailedAt ? (
<ReadTime label={t("healthReadFailedTime")} value={sourceFailedAt} />
<ReadTime label="读取失败时间" value={sourceFailedAt} />
) : null}
</>
);
@@ -285,7 +274,7 @@ export function HealthPanel({ imports }: { imports: ImportState }) {
<section className="view health-view" aria-labelledby="health-title">
<header className="view-header">
<div>
<h1 id="health-title">{t("healthTitle")}</h1>
<h1 id="health-title">运行诊断</h1>
</div>
</header>
@@ -296,34 +285,26 @@ export function HealthPanel({ imports }: { imports: ImportState }) {
onClick={() => void exportDiagnostics()}
>
<Download size={16} aria-hidden="true" />
{t("nativeExportLogs")}
导出日志(发给开发排查)
</R6Button>
</div>
<section className="health-block" aria-label={t("healthLocal")}>
<h2>{t("healthLocal")}</h2>
<section className="health-block" aria-label="本地服务">
<h2>本地服务</h2>
{sourceReceipt}
{imports.health ? (
<p>
{t("healthCoreVersion", {
state: t(
sourceUnavailable || imports.loading
? "healthPrevious"
: "healthRunning",
),
version: imports.health.version,
instance: imports.health.instance_id,
})}
{`${sourceUnavailable || imports.loading ? "上次读取" : "运行中"} · 版本 ${imports.health.version} · 实例 ${imports.health.instance_id}`}
</p>
) : !sourceUnavailable && !imports.loading ? (
<p>{t("healthCoreDisconnected")}</p>
<p>还没连上本地 Core。</p>
) : null}
</section>
<section className="health-block" aria-label={t("healthBackground")}>
<h2>{t("healthBackground")}</h2>
<section className="health-block" aria-label="后台服务">
<h2>后台服务</h2>
{backgroundRead.value === "no-bridge" ? (
<p>{t("healthBackgroundUnavailable")}</p>
<p>当前环境无法检查后台服务,请在支持此功能的桌面 App 中查看。</p>
) : (
<>
<ReadReceipt {...backgroundRead} />
@@ -331,18 +312,13 @@ export function HealthPanel({ imports }: { imports: ImportState }) {
<>
<p>
{backgroundRead.pending || backgroundRead.failedAt
? t("healthPreviousPrefix")
? "上次读取:"
: ""}
{backgroundVerdict(backgroundRead.value.state, locale)}
{backgroundVerdict(backgroundRead.value.state)}
</p>
<p>
{t("healthRuntime", {
core: runtimeStateLabel(backgroundRead.value.core, locale),
broker: runtimeStateLabel(
backgroundRead.value.broker,
locale,
),
})}
Core:{runtimeStateLabel(backgroundRead.value.core)} ·
宿主桥接:{runtimeStateLabel(backgroundRead.value.broker)}
</p>
</>
) : null}
@@ -350,8 +326,8 @@ export function HealthPanel({ imports }: { imports: ImportState }) {
)}
</section>
<section className="health-block" aria-label={t("healthSources")}>
<h2>{t("healthSources")}</h2>
<section className="health-block" aria-label="各来源累计条数">
<h2>各来源累计条数</h2>
{sourceReceipt}
{imports.sources.length ? (
<dl>
@@ -359,70 +335,41 @@ export function HealthPanel({ imports }: { imports: ImportState }) {
<div key={source.source_id}>
<dt>{source.display_name}</dt>
<dd>
{t("healthSourceCount", {
conversations: n(source.imported_conversations),
messages: n(source.imported_messages),
})}
{`${source.imported_conversations} 个会话 · ${source.imported_messages} 条消息`}
</dd>
</div>
))}
</dl>
) : !sourceUnavailable && !imports.loading ? (
<p>{t("healthNoSources")}</p>
<p>还没有已连接的来源。</p>
) : null}
</section>
<section className="health-block" aria-label={t("healthProcessing")}>
<h2>{t("healthProcessing")}</h2>
<section className="health-block" aria-label="记忆提炼">
<h2>记忆提炼</h2>
<ReadReceipt {...processingRead} />
{processing ? (
<>
<p>
{t("healthTaskCounts", {
running: n(processing.running),
pending: n(processing.pending),
completed: n(processing.completed),
failed: n(processing.failed),
})}
{`${processing.running} 处理中 · ${processing.pending} 排队 · ${processing.completed} 已完成 · ${processing.failed} 失败`}
</p>
{processing.metrics ? (
<dl aria-label={t("healthImportDetails")}>
<dl aria-label="导入与整理详情">
<div>
<dt>{t("healthRawTurns")}</dt>
<dd>
{t("healthRawCounts", {
raw: n(processing.metrics.raw_evidence),
turns: n(processing.metrics.complete_turns),
})}
</dd>
<dt>原文与完整对话轮次</dt>
<dd>{`${processing.metrics.raw_evidence} 条原文 · ${processing.metrics.complete_turns} 个完整 turn`}</dd>
</div>
<div>
<dt>{t("healthHy")}</dt>
<dd>
{t("healthHyCounts", {
calls: n(processing.metrics.hy_calls),
completed: n(processing.metrics.completed),
unknown: n(processing.metrics.unknown),
failed: n(processing.metrics.failed),
cancelled: n(processing.metrics.cancelled),
})}
</dd>
<dt>HY 整理</dt>
<dd>{`${processing.metrics.hy_calls} 次调用 · ${processing.metrics.completed} 完成 · ${processing.metrics.unknown} 结果不确定 · ${processing.metrics.failed} 失败 · ${processing.metrics.cancelled} 取消`}</dd>
</div>
<div>
<dt>{t("healthOutput")}</dt>
<dd>
{tn("healthMemories", processing.metrics.governed_memories)}
</dd>
<dt>产出</dt>
<dd>{`${processing.metrics.governed_memories} 条记忆`}</dd>
</div>
<div>
<dt>{t("healthWorkbuddy")}</dt>
<dd>
{t("healthWorkbuddyCounts", {
injected: n(processing.metrics.workbuddy.injected),
timedOut: n(processing.metrics.workbuddy.timed_out),
unavailable: n(processing.metrics.workbuddy.unavailable),
})}
</dd>
<dt>WorkBuddy 查记忆</dt>
<dd>{`${processing.metrics.workbuddy.injected} 次注入 · ${processing.metrics.workbuddy.timed_out} 次超时 · ${processing.metrics.workbuddy.unavailable} 次不可用`}</dd>
</div>
</dl>
) : null}
@@ -431,35 +378,26 @@ export function HealthPanel({ imports }: { imports: ImportState }) {
{splitBlockedStages(processing.blocked_stages).named.map(
(block) => (
<li key={`${block.kind}:${block.reason}`}>
{t("healthBlocked", {
kind: memoryText(locale, taskKindLabel(block.kind)),
reason: memoryText(
locale,
blockReasonLabel(block.reason),
),
count: n(block.count),
})}
{`${taskKindLabel(block.kind)}卡在 ${blockReasonLabel(block.reason)}(${block.count} 条)`}
</li>
),
)}
{retrying ? (
<li>{tnPlain("healthRetrying", retrying)}</li>
) : null}
{retrying ? <li>{retryingBlockLabel(retrying)}</li> : null}
</ul>
) : !processingRead.failedAt && !processingRead.pending ? (
<p>{t("healthNoIssues")}</p>
<p>暂无需要处理的问题。</p>
) : null}
</>
) : null}
</section>
<section className="health-block" aria-label={t("healthHosts")}>
<h2>{t("healthHosts")}</h2>
<section className="health-block" aria-label="AI 接通情况">
<h2>AI 接通情况</h2>
{/* Host state lives in the desktop shell. In a browser it is not
false, it is unavailable, and reporting it as 未接通 would send
the user to repair links that may well be fine. */}
{hosts === "no-bridge" || !window.oneMemory ? (
<p>{t("healthHostsUnavailable")}</p>
<p>在浏览器里读不到;请在桌面 App 里查看。</p>
) : (
<>
<ReadReceipt {...hostsRead} />
@@ -470,34 +408,34 @@ export function HealthPanel({ imports }: { imports: ImportState }) {
<dt>{host.label}</dt>
<dd>
{hostsRead.failedAt || hostsRead.pending
? t("healthPreviousPrefix")
? "上次读取:"
: ""}
{hostVerdict(host, locale)}
{hostVerdict(host)}
</dd>
</div>
))}
</dl>
) : hosts && !hostsRead.failedAt && !hostsRead.pending ? (
<p>{t("healthNoHosts")}</p>
<p>本机没有检测到可接入的 AI 工具。</p>
) : null}
</>
)}
</section>
<section className="health-block" aria-label={t("healthDetailed")}>
<section className="health-block" aria-label="详细日志">
<div className="health-setting-row">
<div>
<h2>{t("healthDetailedTitle")}</h2>
<p>{t("healthDetailedHint")}</p>
<h2>详细日志(排查用)</h2>
<p>默认关闭。仅在开发人员请你协助排查时开启。</p>
</div>
<R6Switch
label={t("healthDetailedTitle")}
label="详细日志(排查用)"
checked={detailedLogging}
pending={detailedLoggingPending}
onCheckedChange={(enabled) => {
setDetailedLoggingPending(true);
void setDetailedLogging(enabled)
.then(setDetailedLoggingState)
.catch(() => setDiagnosticMessage(t("healthDetailedError")))
.catch(() => setDiagnosticMessage("详细日志设置失败,请重试。"))
.finally(() => setDetailedLoggingPending(false));
}}
/>
@@ -510,7 +448,7 @@ export function HealthPanel({ imports }: { imports: ImportState }) {
onClick={() => void refresh()}
>
<RefreshCw size={16} aria-hidden="true" />
{t("healthRefresh")}
重新读取状态
</R6Button>
<R6Button
variant="quiet"
@@ -518,7 +456,7 @@ export function HealthPanel({ imports }: { imports: ImportState }) {
onClick={() => void markIncident()}
>
<Flag size={16} aria-hidden="true" />
{t("healthIncident")}
记录问题时间点
</R6Button>
</div>
{diagnosticMessage ? (
@@ -532,52 +470,31 @@ export function HealthPanel({ imports }: { imports: ImportState }) {
/** A host with no self-check on record has not been shown to work, and the
* install flow's own wording for that is 未接通. */
function hostVerdict(host: RecallHostStatus, locale: GuiLocale): string {
return translate(
locale,
!host.installed
? "healthUnreachable"
: host.selfCheck?.state === "wired"
? "healthWired"
: host.selfCheck?.state === "reachable_but_blind"
? "healthBlind"
: "healthUnreachable",
);
function hostVerdict(host: RecallHostStatus): string {
if (!host.installed) return LINK_WORDING.unreachable;
const state = host.selfCheck?.state;
return (state ? LINK_WORDING[state] : undefined) ?? LINK_WORDING.unreachable;
}
function backgroundVerdict(
state: BackgroundSnapshot["state"],
locale: GuiLocale,
): string {
return translate(
locale,
(
{
healthy: "healthBackgroundHealthy",
stopped: "healthBackgroundStopped",
degraded: "healthBackgroundDegraded",
untrusted: "healthBackgroundUntrusted",
unknown: "healthBackgroundUnknown",
} as const
)[state],
);
function backgroundVerdict(state: BackgroundSnapshot["state"]): string {
return {
healthy: "后台服务可用",
stopped: "后台服务已停止",
degraded: "后台服务部分不可用",
untrusted: "后台服务身份未通过核验",
unknown: "后台状态尚未确认",
}[state];
}
function runtimeStateLabel(
state: BackgroundSnapshot["core"] | BackgroundSnapshot["broker"],
locale: GuiLocale,
): string {
return translate(
locale,
(
{
running: "healthRunning",
stopped: "healthStopped",
unhealthy: "healthUnhealthy",
untrusted: "healthUntrusted",
unknown: "healthUnknown",
not_checked: "healthNotChecked",
} as const
)[state],
);
return {
running: "运行中",
stopped: "已停止",
unhealthy: "无法连接",
untrusted: "身份未通过核验",
unknown: "尚未确认",
not_checked: "未检查",
}[state];
}
+4 -7
View File
@@ -1,14 +1,9 @@
import { Component, type ReactNode } from "react";
import { useApplicationsLanguage } from "./applications-language";
type Props = { label: string; children: ReactNode; fallback?: ReactNode };
type Props = { label: string; children: ReactNode };
type State = { crashed: boolean };
const RESET_MS = 15_000;
function CardFallback({ label }: { label: string }) {
const { a } = useApplicationsLanguage();
return <p>{a("cardUnavailable", { label })}</p>;
}
/**
* 2026-10-12: 首页任何一张卡片渲染出错时,只塌这一张卡,不塌整页。
@@ -41,7 +36,9 @@ export class HomeCardBoundary extends Component<Props, State> {
if (this.state.crashed) {
return (
<section className="r6-home-auto-failure" role="status">
{this.props.fallback ?? <CardFallback label={this.props.label} />}
<p>
{this.props.label}暂时显示不了,其他功能不受影响,稍后会自动恢复。
</p>
</section>
);
}
@@ -12,11 +12,6 @@ import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
LANGUAGE_STORAGE_KEY,
LanguageProvider,
useGuiLanguage,
} from "./gui-language";
import { ImportProgressStrip } from "./ImportProgressStrip";
function job(overrides: Partial<ImportJob> = {}): ImportJob {
@@ -89,57 +84,6 @@ function deferred() {
describe("ImportProgressStrip", () => {
afterEach(cleanup);
it("translates user-facing progress and live settings CTA without invoking actions", async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
const open = vi.fn();
function Switch() {
const { setPreference } = useGuiLanguage();
return (
<button type="button" onClick={() => void setPreference("zh-CN")}>
Cambiar idioma
</button>
);
}
const view = render(
<LanguageProvider>
<Switch />
<ImportProgressStrip
userFacing
jobs={[job()]}
sources={[
source("cursor", "Cursor", { imported_conversations: 12345 }),
]}
backfilling={false}
onOpenSources={open}
/>
</LanguageProvider>,
);
expect((await screen.findByRole("status")).textContent).toContain(
"12.345 conversaciones",
);
view.rerender(
<LanguageProvider>
<Switch />
<ImportProgressStrip
userFacing
jobs={[job({ status: "failed" })]}
sources={sources}
backfilling={false}
onOpenSources={open}
onConfigureModel={open}
/>
</LanguageProvider>,
);
await screen.findByRole("button", { name: "Ir a configuración" });
expect(open).not.toHaveBeenCalled();
await userEvent.click(
screen.getByRole("button", { name: "Cambiar idioma" }),
);
await screen.findByRole("button", { name: "去设置" });
expect(open).not.toHaveBeenCalled();
localStorage.clear();
});
it("keeps daily sync silent in the user-facing shell", () => {
const { container } = render(
<ImportProgressStrip
+4 -6
View File
@@ -5,7 +5,6 @@ import type {
} from "@one-memory/contracts";
import { LoaderCircle, Pause, Play, RefreshCw, Settings2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useGuiLanguage } from "./gui-language";
import {
type MemoryArrival,
memoryArrivalTitle,
@@ -86,7 +85,6 @@ export function ImportProgressStrip({
showProgressDetails = true,
userFacing = false,
}: ImportProgressStripProps) {
const { t, tn } = useGuiLanguage();
const [refreshing, setRefreshing] = useState(false);
const [refreshError, setRefreshError] = useState<{
epoch: number;
@@ -254,10 +252,10 @@ export function ImportProgressStrip({
0,
);
const message = firstImport
? tn("importUserProgress", imported)
? `正在导入你的历史对话,已导入 ${imported.toLocaleString("zh-CN")} 个会话。记忆会陆续生成,你可以先正常使用。`
: currentArrival?.phase === "model-required"
? t("importUserModel")
: t("importUserIncomplete");
? "模型还没有配置好,新的对话暂时不会整理成记忆。"
: "有些内容没有处理完成,请前往设置查看。";
return (
<div
className="import-strip"
@@ -279,7 +277,7 @@ export function ImportProgressStrip({
onClick={onConfigureModel}
>
<Settings2 size={15} aria-hidden="true" />
{t("importUserSettings")}
去设置
</button>
)}
</div>
@@ -1,98 +0,0 @@
// @vitest-environment jsdom
import { translate } from "@one-memory/i18n";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import { LanguageSelector } from "./LanguageSelector";
afterEach(() => {
cleanup();
localStorage.clear();
vi.restoreAllMocks();
});
it.each(["zh-CN", "en", "es"] as const)(
"hides the compact label while preserving its accessible name in %s",
(locale) => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
render(
<LanguageProvider>
<LanguageSelector compact />
</LanguageProvider>,
);
const name = translate(locale, "languageTitle");
const selector = screen.getByRole("combobox", { name });
const label = screen.getByText(name) as HTMLLabelElement;
expect(label.hidden).toBe(true);
expect(label.htmlFor).toBe(selector.id);
expect(screen.getByRole("region", { name })).toBeTruthy();
expect(screen.getByRole("option", { name: "Español" })).toBeTruthy();
expect(screen.getByRole("option", { name: "English" })).toBeTruthy();
expect(screen.getByRole("option", { name: "简体中文" })).toBeTruthy();
},
);
it.each(["zh-CN", "en", "es"] as const)(
"keeps the normal settings label visible and accessible in %s",
(locale) => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
render(
<LanguageProvider>
<LanguageSelector />
</LanguageProvider>,
);
const name = translate(locale, "languageLabel");
const selector = screen.getByRole("combobox", { name });
const label = screen.getByText(name) as HTMLLabelElement;
expect(label.hidden).toBe(false);
expect(label.htmlFor).toBe(selector.id);
expect(selector.getAttribute("aria-label")).toBeNull();
expect(
screen.getByRole("heading", { name: translate(locale, "languageTitle") }),
).toBeTruthy();
expect(
screen.getByText(translate(locale, "languageDescription")),
).toBeTruthy();
},
);
it("offers an accessible selector, persists and updates its label", async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
render(
<LanguageProvider>
<LanguageSelector />
</LanguageProvider>,
);
const selector = screen.getByRole("combobox", {
name: translate("zh-CN", "languageLabel"),
});
expect(screen.getAllByRole("option")).toHaveLength(4);
expect(selector.getAttribute("aria-describedby")).toBeTruthy();
await userEvent.selectOptions(selector, "es");
expect(localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe("es");
expect(
screen.getByRole("combobox", { name: translate("es", "languageLabel") }),
).toBe(selector);
expect(document.documentElement.lang).toBe("es");
});
it("announces storage errors without replacing the confirmed choice", async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
render(
<LanguageProvider>
<LanguageSelector />
</LanguageProvider>,
);
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("denied");
});
await userEvent.selectOptions(screen.getByRole("combobox"), "es");
expect(screen.getByRole("alert").textContent).toBe(
translate("zh-CN", "languageSaveError"),
);
expect((screen.getByRole("combobox") as HTMLSelectElement).value).toBe(
"zh-CN",
);
});
-58
View File
@@ -1,58 +0,0 @@
import { isLanguagePreference } from "@one-memory/i18n";
import { useId, useState } from "react";
import { useGuiLanguage } from "./gui-language";
export function LanguageSelector({ compact = false }: { compact?: boolean }) {
const id = useId();
const { preference, setPreference, t, error } = useGuiLanguage();
const [pending, setPending] = useState(false);
return (
<section
aria-labelledby={compact ? `${id}-label` : `${id}-title`}
className={
compact ? "r6-language-selector--compact" : "r6-language-setting"
}
>
{!compact && <h2 id={`${id}-title`}>{t("languageTitle")}</h2>}
<div className={compact ? "r6-language-selector-row" : "r6-setting-row"}>
<label id={`${id}-label`} htmlFor={`${id}-select`} hidden={compact}>
{t(compact ? "languageTitle" : "languageLabel")}
</label>
<select
id={`${id}-select`}
aria-label={compact ? t("languageTitle") : undefined}
aria-describedby={compact ? undefined : `${id}-description`}
value={preference}
disabled={pending}
onChange={async (event) => {
const value = event.currentTarget.value;
if (!isLanguagePreference(value)) return;
setPending(true);
try {
await setPreference(value);
} finally {
setPending(false);
}
}}
>
<option value="system">{t("languageSystem")}</option>
<option value="zh-CN" lang={compact ? "zh-CN" : undefined}>
{compact ? "简体中文" : t("languageChinese")}
</option>
<option value="en" lang={compact ? "en" : undefined}>
{compact ? "English" : t("languageEnglish")}
</option>
<option value="es" lang={compact ? "es" : undefined}>
{compact ? "Español" : t("languageSpanish")}
</option>
</select>
</div>
{!compact && <p id={`${id}-description`}>{t("languageDescription")}</p>}
{error && (
<p role="alert" className="r6-settings-error">
{t(error)}
</p>
)}
</section>
);
}
+1 -3
View File
@@ -5,7 +5,6 @@ import {
useRef,
useState,
} from "react";
import { useMemoryLanguage } from "./memory-language";
import { useMediaPreference } from "./use-media-preference";
type Phase = "closed" | "opening" | "open" | "closing";
@@ -21,7 +20,6 @@ export function MemoryEvidencePanel({
children: ReactNode;
suspended?: boolean;
}) {
const { text } = useMemoryLanguage();
const compact = useMediaPreference("(max-width: 1100px)");
const reduceMotion = useMediaPreference("(prefers-reduced-motion: reduce)");
const reduceTransparency = useMediaPreference(
@@ -137,7 +135,7 @@ export function MemoryEvidencePanel({
className="map-inspector"
{...(modal ? { role: "dialog", "aria-modal": !suspended } : {})}
inert={suspended}
aria-label={text("记忆与原文")}
aria-label="记忆与原文"
data-tour-id="memory-original-pane"
tabIndex={-1}
hidden={!present || (compact && suspended)}
+8 -10
View File
@@ -11,7 +11,6 @@ import {
subscribeDisplayPreference,
} from "./display-preferences";
import { graphEase } from "./graph-motion";
import { useMemoryLanguage } from "./memory-language";
/** 力导向迭代总步数。原实现在挂载时一次算完,现改为分帧推进。 */
const LAYOUT_ITERATIONS = 90;
@@ -36,7 +35,6 @@ export function MemoryGraph3D({
selectedNodeId: string | null;
onSelectNode(node: MemoryGraphNode): void;
}) {
const { text } = useMemoryLanguage();
const host = useRef<HTMLDivElement>(null);
const labelHost = useRef<HTMLDivElement>(null);
const selectRef = useRef(onSelectNode);
@@ -628,7 +626,7 @@ export function MemoryGraph3D({
<div className="graph-labels" ref={labelHost} aria-hidden="true" />
{!available ? (
<div className="memory-graph-fallback">
<p>{text("这台设备打不开 3D 视图,已显示关联列表。")}</p>
<p>这台设备打不开 3D 视图,已显示关联列表。</p>
{graph.nodes.map((node) => (
<button
type="button"
@@ -646,13 +644,13 @@ export function MemoryGraph3D({
<div
className="memory-graph-controls"
role="toolbar"
aria-label={text("图谱工具")}
aria-label="图谱工具"
>
<button
type="button"
className="secondary"
title={text("放大")}
aria-label={text("放大图谱")}
title="放大"
aria-label="放大图谱"
onClick={() => actionsRef.current?.zoom(0.8)}
>
<Plus size={16} />
@@ -660,8 +658,8 @@ export function MemoryGraph3D({
<button
type="button"
className="secondary"
title={text("缩小")}
aria-label={text("缩小图谱")}
title="缩小"
aria-label="缩小图谱"
onClick={() => actionsRef.current?.zoom(1.25)}
>
<Minus size={16} />
@@ -669,8 +667,8 @@ export function MemoryGraph3D({
<button
type="button"
className="secondary"
title={text("重置视角")}
aria-label={text("重置图谱视角")}
title="重置视角"
aria-label="重置图谱视角"
onClick={() => actionsRef.current?.reset()}
>
<Focus size={16} />
-17
View File
@@ -18,23 +18,6 @@ import {
within,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
it("traduce los controles del mapa en español sin cambiar etiquetas importadas", () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
const { container } = render(
<LanguageProvider>
<Host simplified />
</LanguageProvider>,
);
expect(screen.getByLabelText("Filtrar memorias del mapa")).toBeTruthy();
expect(container.textContent).not.toContain("全部记忆");
expect(container.textContent).not.toContain("当前已加载");
expect(container.textContent).not.toContain("来源视角");
expect(container.textContent).not.toContain("段原文");
localStorage.removeItem(LANGUAGE_STORAGE_KEY);
});
import { useState } from "react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import {
+124 -190
View File
@@ -5,7 +5,6 @@ import type {
MemoryLibraryPage,
RecentConversationItem,
} from "@one-memory/contracts";
import type { GuiLocale } from "@one-memory/i18n";
import {
ArrowUp,
ChevronLeft,
@@ -30,7 +29,6 @@ import {
type MemoryArrival,
resolveMemoryArrival,
} from "./memory-arrival-state";
import { memoryText, useMemoryLanguage } from "./memory-language";
import {
type MapAxis,
mapGroups,
@@ -98,12 +96,8 @@ function authorLabel(writtenBy: string) {
return "未知";
}
function dayStamp(
value: string | null | undefined,
locale: GuiLocale = "zh-CN",
) {
if (!value || !Number.isFinite(Date.parse(value)))
return memoryText(locale, "时间未知");
function dayStamp(value: string | null | undefined) {
if (!value || !Number.isFinite(Date.parse(value))) return "时间未知";
const date = new Date(value);
const now = new Date();
if (
@@ -111,8 +105,8 @@ function dayStamp(
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate()
)
return memoryText(locale, "今天");
return date.toLocaleDateString(locale);
return "今天";
return date.toLocaleDateString("zh-CN");
}
/**
@@ -122,37 +116,28 @@ function dayStamp(
*/
export const DIALOGUE_PANE_VISIBLE = false;
/** "10月2日" / "2 oct", with the year only when it is not this year. */
function monthDay(value: string, locale: GuiLocale): string | null {
/** "10月2日", with the year only when it is not this year. */
function monthDay(value: string): string | null {
if (!Number.isFinite(Date.parse(value))) return null;
const date = new Date(value);
return new Intl.DateTimeFormat(locale, {
day: "numeric",
month: "short",
...(date.getFullYear() !== new Date().getFullYear()
? { year: "numeric" }
: {}),
}).format(date);
const day = `${date.getMonth() + 1}月${date.getDate()}日`;
return date.getFullYear() === new Date().getFullYear()
? day
: `${date.getFullYear()}年${day}`;
}
/** D96: when the cited conversation happened, not when the memory was generated. */
function sourceStamp(
time: { at: string; basis: "conversation" | "import" } | undefined,
fallback: string | null | undefined,
locale: GuiLocale = "zh-CN",
): string {
const day = time ? monthDay(time.at, locale) : null;
const day = time ? monthDay(time.at) : null;
if (time && day)
return memoryText(
locale,
time.basis === "conversation" ? "对话于 {day}" : "导入于 {day}",
{ day },
);
return dayStamp(fallback, locale);
return time.basis === "conversation" ? `对话于 ${day}` : `导入于 ${day}`;
return dayStamp(fallback);
}
export function MemoryMap(props: Props) {
const { locale, text, count, plain } = useMemoryLanguage();
const {
memories,
graph: baseGraph,
@@ -337,16 +322,14 @@ export function MemoryMap(props: Props) {
if (library) {
const ids = library.page?.source_ids_by_memory?.[memoryId];
return ids?.length
? ids
.map((id) => sourceNameOr(sources, id, text("来源待核对")))
.join("、")
: text("来源待核对");
? ids.map((id) => sourceNameOr(sources, id, "来源待核对")).join("、")
: "来源待核对";
}
return (
sourceGroups
.filter((entry) => entry.memoryIds.includes(memoryId))
.map((entry) => entry.label)
.join(text("列表分隔符")) || text("来源待核对")
.join("、") || "来源待核对"
);
}
const filtered = useMemo(
@@ -611,7 +594,7 @@ export function MemoryMap(props: Props) {
(source) =>
source.authorized && groupKey === `source:${source.source_id}`,
)?.display_name ??
text("全部记忆");
"全部记忆";
if (
props.simplified &&
(!library ||
@@ -631,31 +614,28 @@ export function MemoryMap(props: Props) {
<div>
<h2>
{running
? text("已开始处理所选对话")
? "已开始处理所选对话"
: queued
? text("处理请求已受理")
: text("还没有已生成的记忆")}
? "处理请求已受理"
: "还没有已生成的记忆"}
</h2>
<p>
{running || queued
? text("模型连接已通过。首批记忆生成后,会逐步出现在记忆列表里。")
? "模型连接已通过。首批记忆生成后,会逐步出现在记忆列表里。"
: arrival.importedConversations > 0
? text("已导入 {count} 段对话,整理成记忆后会出现在这里。", {
count: count(arrival.importedConversations),
})
? `已导入 ${arrival.importedConversations.toLocaleString()} 段对话,整理成记忆后会出现在这里。`
: arrival.importedMessages > 0
? text("历史已保存在本机,记忆就绪后会出现在这里。")
: text("连接应用并导入历史后,可以开始沉淀记忆。")}
? "历史已保存在本机,记忆就绪后会出现在这里。"
: "连接应用并导入历史后,可以开始沉淀记忆。"}
{(running || queued) && (
<small>
{text(
"当前已生成 0 条记忆。请求已受理不等于已经完成,原始对话、模型处理任务和记忆数量分别统计。",
)}
当前已生成 0
条记忆。请求已受理不等于已经完成,原始对话、模型处理任务和记忆数量分别统计。
</small>
)}
</p>
<div>
<R6Button onClick={props.onConnect}>{text("返回应用")}</R6Button>
<R6Button onClick={props.onConnect}>返回应用</R6Button>
</div>
</div>
{running && (
@@ -668,7 +648,7 @@ export function MemoryMap(props: Props) {
<section
id={id}
className="core-memory-map"
aria-label={text("记忆工作台")}
aria-label="记忆工作台"
hidden={hidden}
inert={hidden}
onKeyDown={(e) => {
@@ -683,12 +663,12 @@ export function MemoryMap(props: Props) {
clear();
}}
>
<nav className="map-library" aria-label={text("来源与主题")} ref={pane}>
<nav className="map-library" aria-label="来源与主题" ref={pane}>
{props.simplified && DIALOGUE_PANE_VISIBLE ? (
<div
className="map-library-pane"
role="tablist"
aria-label={text("记忆或原始对话")}
aria-label="记忆或原始对话"
onKeyDown={(event) => {
const order = ["memories", "dialogues"] as const;
const current = order.indexOf(libraryPane);
@@ -725,20 +705,20 @@ export function MemoryMap(props: Props) {
aria-controls={`memory-pane-${value}`}
onClick={() => setLibraryPane(value)}
>
{text(title)}
{title}
</button>
))}
</div>
) : null}
{props.simplified ? (
<div className="selected-source-filter">
<label htmlFor="selected-source-filter">{text("来源")}</label>
<label htmlFor="selected-source-filter">来源</label>
<OptionMenu
id="selected-source-filter"
className="om-option-menu--plain"
value={controlledSource}
options={[
{ value: "", label: text("全部应用") },
{ value: "", label: "全部应用" },
...(library?.source &&
!sources.some(
(source) =>
@@ -747,7 +727,7 @@ export function MemoryMap(props: Props) {
? [
{
value: `source:${library.source}`,
label: text("所选来源待核对"),
label: "所选来源待核对",
},
]
: []),
@@ -767,7 +747,7 @@ export function MemoryMap(props: Props) {
/>
</div>
) : (
<fieldset className="map-axis" aria-label={text("地图组织方式")}>
<fieldset className="map-axis" aria-label="地图组织方式">
{(
[
["source", "来源"],
@@ -784,7 +764,7 @@ export function MemoryMap(props: Props) {
setGroupKey("");
}}
>
{text(title)}
{title}
</button>
))}
</fieldset>
@@ -792,15 +772,15 @@ export function MemoryMap(props: Props) {
<div className="map-search">
<Search size={16} aria-hidden="true" />
<input
aria-label={text("筛选地图记忆")}
aria-label="筛选地图记忆"
name="map-memory-search"
autoComplete="off"
placeholder={
libraryPane === "dialogues"
? text("搜索原始对话")
? "搜索原始对话"
: props.simplified
? text("搜索记忆")
: text("筛选记忆…")
? "搜索记忆"
: "筛选记忆…"
}
value={
libraryPane === "dialogues"
@@ -835,30 +815,22 @@ export function MemoryMap(props: Props) {
<span>
{library.stale
? library.page && library.page.total > 0
? text(
"共 {count} 条 · 正在核对来源…",
{ count: plain(library.page.total) },
library.page.total,
)
: text("正在核对来源…")
? `共 ${library.page.total} 条 · 正在核对来源…`
: "正在核对来源…"
: library.failed
? text("读取失败")
? "读取失败"
: library.loading && library.page?.total === 0
? text("正在读取…")
? "正在读取…"
: library.page
? `${text(
"共 {count} 条",
{ count: plain(library.page.total) },
library.page.total,
)}${refreshing ? text(" · 正在核对…") : ""}`
: text("正在读取…")}
? `共 ${library.page.total} 条${refreshing ? " · 正在核对…" : ""}`
: "正在读取…"}
</span>
{!library.stale && (
<button
type="button"
className="quiet map-tool"
aria-label={text("刷新地图记忆")}
title={text("刷新地图记忆")}
aria-label="刷新地图记忆"
title="刷新地图记忆"
disabled={library.loading || hidden}
onClick={library.onRetry}
>
@@ -875,7 +847,7 @@ export function MemoryMap(props: Props) {
aria-pressed={!groupKey}
onClick={() => chooseGroup("")}
>
{text("全部记忆")} <span>{active.length}</span>
全部记忆 <span>{active.length}</span>
</button>
{groups.map((g) => (
<button
@@ -895,22 +867,16 @@ export function MemoryMap(props: Props) {
{!props.simplified && (
<div className="map-list-heading">
<span>{label}</span>
<span>{text("{count} 条", { count: filtered.length })}</span>
<span>{filtered.length} 条</span>
</div>
)}
{arrivalCount > 0 && (
<div className="map-arrival-notice">
<span role="status">
{text(
"新增 {count} 条记忆",
{ count: plain(arrivalCount) },
arrivalCount,
)}
</span>
<span role="status">新增 {arrivalCount} 条记忆</span>
<button
type="button"
title={text("关闭新增记忆提示")}
aria-label={text("关闭新增记忆提示")}
title="关闭新增记忆提示"
aria-label="关闭新增记忆提示"
onClick={() => setArrivedIds([])}
>
<X size={15} aria-hidden="true" />
@@ -919,7 +885,7 @@ export function MemoryMap(props: Props) {
)}
{filterNotice && (
<p className="map-filter-notice" role="status">
{text(filterNotice)}
{filterNotice}
</p>
)}
{libraryPane === "dialogues" ? (
@@ -927,10 +893,10 @@ export function MemoryMap(props: Props) {
<div className="map-list-heading" aria-live="polite">
<span>
{dialogueFailed
? text("读取失败")
? "读取失败"
: dialoguePage
? text("最近 {count} 条", { count: dialoguePage.length })
: text("正在读取…")}
? `最近 ${dialoguePage.length} 条`
: "正在读取…"}
</span>
</div>
<ul
@@ -959,23 +925,19 @@ export function MemoryMap(props: Props) {
</li>
))}
</ul>
{dialogueFailed ? (
<p role="alert">{text("原始对话暂时无法读取")}</p>
) : null}
{dialogueFailed ? <p role="alert">原始对话暂时无法读取</p> : null}
{dialoguePage && !dialoguePage.length ? (
<p className="map-empty">
{text(
dialogueFilter.trim()
? "没有匹配的原始对话"
: "还没有导入的原始对话",
)}
{dialogueFilter.trim()
? "没有匹配的原始对话"
: "还没有导入的原始对话"}
</p>
) : null}
</>
) : null}
<ul
className="map-memory-list"
aria-label={text("地图记忆列表")}
aria-label="地图记忆列表"
id="memory-pane-memories"
hidden={libraryPane === "dialogues"}
>
@@ -995,19 +957,15 @@ export function MemoryMap(props: Props) {
{`${sourceLabel(m.memory_id)} · ${sourceStamp(
library?.page?.source_time_by_memory?.[m.memory_id],
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)}
沉淀于 {dayStamp(m.updated_at)}
</span>
</>
) : (
text("已可用 · {count} 段原文", {
count: m.evidence_ids.length,
})
<>已可用 · {m.evidence_ids.length} 段原文</>
)}
</small>
</button>
@@ -1016,10 +974,8 @@ export function MemoryMap(props: Props) {
</ul>
{libraryPane === "memories" && library?.failed && (
<div role="alert">
<p>{text("记忆读取失败,请重试。")}</p>
<R6Button onClick={library.onRetry}>
{text("重新读取地图记忆")}
</R6Button>
<p>记忆读取失败,请重试。</p>
<R6Button onClick={library.onRetry}>重新读取地图记忆</R6Button>
</div>
)}
{libraryPane === "memories" &&
@@ -1030,10 +986,10 @@ export function MemoryMap(props: Props) {
!filtered.length && (
<p className="map-empty">
{library.query || library.source
? text("没有匹配的记忆")
? "没有匹配的记忆"
: groupKey
? text("本页没有匹配的主题记忆")
: text("还没有已生成的记忆")}
? "本页没有匹配的主题记忆"
: "还没有已生成的记忆"}
</p>
)}
{libraryPane === "memories" &&
@@ -1041,12 +997,12 @@ export function MemoryMap(props: Props) {
!library.loading &&
!library.failed &&
!library.stale && (
<nav aria-label={text("地图记忆分页")} className="map-list-heading">
<nav aria-label="地图记忆分页" className="map-list-heading">
<button
type="button"
className="quiet map-tool"
aria-label={text("上一页记忆")}
title={text("上一页记忆")}
aria-label="上一页记忆"
title="上一页记忆"
disabled={library.page.offset === 0}
onClick={() => {
clear();
@@ -1058,25 +1014,18 @@ export function MemoryMap(props: Props) {
<ChevronLeft size={16} aria-hidden="true" />
</button>
<span>
{text(
"共 {count} 条,第 {page} / {pages} 页",
{
count: count(library.page.total),
page:
library.page.total === 0
? 0
: Math.floor(library.page.offset / library.page.limit) +
1,
pages: Math.ceil(library.page.total / library.page.limit),
},
library.page.total,
)}
共 {library.page.total.toLocaleString()} 条,第{" "}
{library.page.total === 0
? 0
: Math.floor(library.page.offset / library.page.limit) +
1}{" "}
/ {Math.ceil(library.page.total / library.page.limit)} 页
</span>
<button
type="button"
className="quiet map-tool"
aria-label={text("下一页记忆")}
title={text("下一页记忆")}
aria-label="下一页记忆"
title="下一页记忆"
disabled={
library.page.offset + library.page.items.length >=
library.page.total
@@ -1093,7 +1042,7 @@ export function MemoryMap(props: Props) {
</nav>
)}
{!library && loaded && !filtered.length && (search || groupKey) && (
<p className="map-empty">{text("没有匹配的记忆")}</p>
<p className="map-empty">没有匹配的记忆</p>
)}
{(!props.simplified || (!library && !active.length)) && (
<MemoryArrivalPanel
@@ -1107,26 +1056,26 @@ export function MemoryMap(props: Props) {
/>
)}
</nav>
<section className="map-stage" aria-label={text("记忆关系图")}>
<section className="map-stage" aria-label="记忆关系图">
<header className="map-stage-heading">
<div>
<span className="map-kicker" hidden={props.simplified}>
{text(axis === "topic" ? "主题视角(非事项)" : "来源视角")}
{axis === "topic" ? "主题视角(非事项)" : "来源视角"}
</span>
<h2>
{libraryPane === "dialogues"
? text("原始对话")
? "原始对话"
: props.simplified
? text("记忆关联")
? "记忆关联"
: label}
</h2>
{props.simplified && (
<p className="selected-graph-subtitle">
{libraryPane === "dialogues"
? text("刚导入的原话,还不是沉淀后的记忆")
? "刚导入的原话,还不是沉淀后的记忆"
: projection.nodes.length
? text("来源归属")
: text("可在沉淀记忆后查看视图")}
? "来源归属"
: "可在沉淀记忆后查看视图"}
</p>
)}
</div>
@@ -1134,10 +1083,10 @@ export function MemoryMap(props: Props) {
<button
type="button"
className="quiet map-tool"
aria-label={text(
selectedSource ? "查看选中来源原文" : "查看选中记忆原文",
)}
title={text("查看原文")}
aria-label={
selectedSource ? "查看选中来源原文" : "查看选中记忆原文"
}
title="查看原文"
onClick={() => setInspectorOpen(true)}
>
<FileText size={17} />
@@ -1148,7 +1097,7 @@ export function MemoryMap(props: Props) {
type="button"
className="quiet map-tool"
title="整理已有关系"
aria-label={text("整理已有关系")}
aria-label="整理已有关系"
disabled={busy || !graph?.nodes.length}
onClick={props.onBuildRelations}
>
@@ -1171,9 +1120,9 @@ export function MemoryMap(props: Props) {
))}
</ol>
) : selectedDialogueId ? (
<p>{text("正在读取原文…")}</p>
<p>正在读取原文…</p>
) : (
<p>{text("选择一条原始对话")}</p>
<p>选择一条原始对话</p>
)}
</div>
) : projection.nodes.length && (!props.simplified || !hidden) ? (
@@ -1190,19 +1139,19 @@ export function MemoryMap(props: Props) {
<Network size={32} strokeWidth={1} />
<p>
{library?.stale
? text("可在沉淀记忆后查看视图")
? "可在沉淀记忆后查看视图"
: library?.failed
? text("记忆暂时无法读取")
? "记忆暂时无法读取"
: library?.page &&
(library.query || library.source || groupKey)
? text("当前筛选没有可显示的记忆")
? "当前筛选没有可显示的记忆"
: !loaded
? text("正在读取记忆…")
? "正在读取记忆…"
: active.length
? text("当前筛选没有可显示的记忆")
? "当前筛选没有可显示的记忆"
: arrival.importedMessages > 0
? text("已保存的对话还未生成记忆")
: text("还没有可显示的记忆")}
? "已保存的对话还未生成记忆"
: "还没有可显示的记忆"}
</p>
</div>
)}
@@ -1212,20 +1161,18 @@ export function MemoryMap(props: Props) {
something else left it dangling under a view it cannot describe. */}
{!props.simplified && libraryPane === "memories" && (
<div className="map-legend">
<span data-kind="memory">{text("记忆")}</span>
<span data-kind="topic">{text("主题")}</span>
<span data-kind="source">{text("来源")}</span>
<span data-kind="schema">{text("归纳")}</span>
<span data-kind="memory">记忆</span>
<span data-kind="topic">主题</span>
<span data-kind="source">来源</span>
<span data-kind="schema">归纳</span>
</div>
)}
{props.simplified ? (
projection.nodes.length ? (
<span>{text("连线表示来源归属")}</span>
<span>连线表示来源归属</span>
) : null
) : (
<span>
{text("{count} 条记忆 · 当前已加载", { count: filtered.length })}
</span>
<span>{filtered.length} 条记忆 · 当前已加载</span>
)}
</footer>
{!props.simplified && (
@@ -1240,10 +1187,10 @@ export function MemoryMap(props: Props) {
}}
>
<input
aria-label={text("向记忆提问")}
aria-label="向记忆提问"
name="map-question"
autoComplete="off"
placeholder={text("询问全部已授权可用记忆…")}
placeholder="询问全部已授权可用记忆…"
value={question}
onCompositionStart={() => {
composing.current = true;
@@ -1262,8 +1209,8 @@ export function MemoryMap(props: Props) {
/>
<button
type="submit"
title={text("发送问题")}
aria-label={text("发送问题")}
title="发送问题"
aria-label="发送问题"
disabled={!question.trim() || !!props.streaming}
>
<ArrowUp size={17} />
@@ -1301,19 +1248,19 @@ export function MemoryMap(props: Props) {
<header className="map-inspector-heading">
<h2>
{inspectorDetail
? text("记忆详情")
? "记忆详情"
: selectedSource
? text("来源原文")
? "来源原文"
: visibleGroupNode
? text("关联节点")
: text("记忆详情")}
? "关联节点"
: "记忆详情"}
</h2>
{(activeId || visibleGroupNode) && (
<button
type="button"
className="quiet map-tool"
aria-label={text("关闭记忆详情")}
title={text("关闭详情")}
aria-label="关闭记忆详情"
title="关闭详情"
onClick={closeInspector}
>
<X size={17} />
@@ -1364,7 +1311,7 @@ export function MemoryMap(props: Props) {
<i />
<i />
</div>
<p role="status">{text("正在读取这条记忆…")}</p>
<p role="status">正在读取这条记忆…</p>
</div>
) : (
<SelectedMemoryDetail
@@ -1419,15 +1366,13 @@ export function MemoryMap(props: Props) {
{props.detailLoad?.memoryId === activeId &&
props.detailLoad.status === "failed" ? (
<>
<p role="alert">
{text("这条记忆的原文暂时无法读取,选择已保留。")}
</p>
<p role="alert">这条记忆的原文暂时无法读取,选择已保留。</p>
<button
type="button"
className="secondary"
onClick={() => activeId && onOpenMemory(activeId)}
>
{text("重试读取原文")}
重试读取原文
</button>
</>
) : props.simplified && !activeId && filtered.length ? (
@@ -1437,26 +1382,15 @@ export function MemoryMap(props: Props) {
// single-column surface needs it — the three-column one already
// lists the same memories in the pane beside this one.
<>
<p>
{text(
"已沉淀 {count} 条记忆",
{ count: plain(filtered.length) },
filtered.length,
)}
</p>
<ul
className="map-settled-list"
aria-label={text("已沉淀的记忆")}
>
<p>{`已沉淀 ${filtered.length} 条记忆`}</p>
<ul className="map-settled-list" aria-label="已沉淀的记忆">
{filtered.map((m) => (
<li key={m.memory_id}>{m.content}</li>
))}
</ul>
</>
) : (
<p>
{text(activeId ? "正在读取记忆详情…" : "沉淀记忆后查看详情")}
</p>
<p>{activeId ? "正在读取记忆详情…" : "沉淀记忆后查看详情"}</p>
)}
</div>
)}
@@ -3,40 +3,8 @@ import type { MemoryNoteReceipt } from "@one-memory/contracts";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import { MemoryNoteComposer } from "./MemoryNoteComposer";
it("opens a Spanish composer without saving, processing or enabling anything", async () => {
delete window.oneMemory;
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
render(
<LanguageProvider>
<MemoryNoteComposer
onSaved={vi.fn()}
onOpenMemory={vi.fn()}
onConfigureModel={vi.fn()}
/>
</LanguageProvider>,
);
await userEvent.click(
await screen.findByRole("button", { name: "Añadir memoria" }),
);
expect(screen.getByRole("dialog", { name: "Añadir memoria" })).toBeTruthy();
expect(
screen.getByRole("textbox", { name: "¿Qué quieres recordar?" }),
).toBeTruthy();
expect(
(screen.getByRole("button", { name: "Guardar texto" }) as HTMLButtonElement)
.disabled,
).toBe(true);
await userEvent.click(
screen.getByRole("button", { name: "Cerrar nueva memoria" }),
);
expect(api.saveMemoryNote).not.toHaveBeenCalled();
expect(api.processMemoryNote).not.toHaveBeenCalled();
localStorage.clear();
});
const api = vi.hoisted(() => ({
getMemoryNotes: vi.fn(),
getMemoryNoteOperation: vi.fn(),
+20 -27
View File
@@ -8,7 +8,6 @@ import {
processMemoryNote,
saveMemoryNote,
} from "./api";
import { useMemoryLanguage } from "./memory-language";
import { memoryWriteErrorText } from "./model-error-text";
import { subscribePoll } from "./poll-scheduler";
import { traceUiAction } from "./ui-trace";
@@ -43,7 +42,6 @@ export function MemoryNoteComposer({
onPendingChange?(note: PendingMemoryNote | null): void;
suspended?: boolean;
}) {
const { text: copy } = useMemoryLanguage();
const [open, setOpen] = useState(false);
const [text, setText] = useState("");
const [notes, setNotes] = useState<MemoryNoteReceipt[]>([]);
@@ -276,11 +274,11 @@ export function MemoryNoteComposer({
data-tour-id="memory-note-trigger"
onClick={() => setOpen(true)}
>
<FilePlus2 size={16} aria-hidden="true" /> {copy("添加记忆")}
<FilePlus2 size={16} aria-hidden="true" /> 添加记忆
</button>
{savedToast && (
<p className="memory-note-toast" role="status">
{copy(savedToast)}
{savedToast}
</p>
)}
{open && (
@@ -315,12 +313,12 @@ export function MemoryNoteComposer({
}}
>
<header className="memory-note-heading">
<h2 id="memory-note-title">{copy("添加记忆")}</h2>
<h2 id="memory-note-title">添加记忆</h2>
<button
className="quiet"
type="button"
aria-label={copy("关闭新增记忆")}
title={copy("关闭")}
aria-label="关闭新增记忆"
title="关闭"
disabled={busy}
onClick={close}
>
@@ -334,7 +332,7 @@ export function MemoryNoteComposer({
}}
>
<label className="memory-note-label" htmlFor="memory-note-text">
{copy("记下这件事")}
记下这件事
</label>
<textarea
id="memory-note-text"
@@ -342,20 +340,20 @@ export function MemoryNoteComposer({
maxLength={12000}
value={text}
disabled={busy}
placeholder={copy("例如:项目周会不排在周五。")}
placeholder="例如:项目周会不排在周五。"
onChange={(event) => setText(event.target.value)}
/>
<p className="memory-note-model">
{copy("整理模型:")}
整理模型:
{model?.name ??
copy(modelRead ? "尚未配置,原话仍可保存" : "正在读取")}
(modelRead ? "尚未配置,原话仍可保存" : "正在读取")}
</p>
{error && (
<p className="error-banner" role="alert">
{copy(error)}
{error}
</p>
)}
{notice && <p role="status">{copy(notice)}</p>}
{notice && <p role="status">{notice}</p>}
<div className="consent-actions">
{modelRead && !model && (
<button
@@ -367,35 +365,30 @@ export function MemoryNoteComposer({
onConfigureModel();
}}
>
{copy("配置模型")}
配置模型
</button>
)}
<button type="submit" disabled={busy || !text.trim()}>
{copy(busy ? "保存中…" : "保存原话")}
{busy ? "保存中…" : "保存原话"}
</button>
</div>
</form>
{notes.length > 0 && (
<section
className="memory-note-recent"
aria-label={copy("最近添加")}
>
<h3>{copy("最近添加")}</h3>
<section className="memory-note-recent" aria-label="最近添加">
<h3>最近添加</h3>
<ul>
{notes.map((note) => (
<li key={note.evidence_id}>
<p>{note.text}</p>
<small>{copy(states[note.state])}</small>
<small>{states[note.state]}</small>
{memoryWriteErrorText(note.error) && (
<p className="error-banner">
{copy(memoryWriteErrorText(note.error) ?? "")}
{memoryWriteErrorText(note.error)}
</p>
)}
{note.error === "hy_memory_model_payment_required" && (
<p className="error-banner">
{copy(
"模型服务要求检查余额或付款状态,原话仍已保存。",
)}
模型服务要求检查余额或付款状态,原话仍已保存。
</p>
)}
<div className="memory-note-actions">
@@ -409,7 +402,7 @@ export function MemoryNoteComposer({
onOpenMemory(id);
}}
>
{copy("查看记忆")}
查看记忆
{note.memory_ids.length > 1 ? ` ${index + 1}` : ""}
</button>
))}
@@ -423,7 +416,7 @@ export function MemoryNoteComposer({
onClick={() => void retry(note)}
>
<RefreshCw size={14} aria-hidden="true" />
{copy("重新整理")}
重新整理
</button>
)}
</div>
-108
View File
@@ -8,87 +8,9 @@ import {
waitFor,
} from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import { DIALOGUE_PANE_VISIBLE } from "./MemoryMap";
import { MemorySpace } from "./MemorySpace";
it("translates memory filters and detail affordances live without refetching", async () => {
setup();
delete window.oneMemory;
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
render(
<LanguageProvider>
<MemorySpace simplified />
</LanguageProvider>,
);
await screen.findByRole("heading", { name: "Memorias", level: 1 });
await screen.findByText("已保存的记忆");
expect(
screen.getByRole("textbox", { name: "Filtrar memorias del mapa" }),
).toBeTruthy();
expect(
screen.getByRole("button", { name: "Actualizar memorias" }),
).toBeTruthy();
const calls = reads.mapLibrary.mock.calls.length;
act(() => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
window.dispatchEvent(
new StorageEvent("storage", { key: LANGUAGE_STORAGE_KEY }),
);
});
expect(screen.getByRole("heading", { name: "记忆", level: 1 })).toBeTruthy();
expect(reads.mapLibrary).toHaveBeenCalledTimes(calls);
localStorage.clear();
});
it("localiza la navegación de gobierno de la biblioteca", async () => {
setup();
delete window.oneMemory;
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
render(
<LanguageProvider>
<MemorySpace simplified />
</LanguageProvider>,
);
await screen.findByRole("heading", { name: "Memorias", level: 1 });
expect(
screen.getByRole("navigation", {
name: "Estado de las memorias",
hidden: true,
}),
).toBeTruthy();
localStorage.clear();
});
it("localiza cola y estimación sin alterar las memorias", async () => {
setup();
delete window.oneMemory;
reads.processing.mockResolvedValue({
running: 1,
pending: 2,
fresh_pending: 1,
backlog_pending: 1,
in_flight_count: 1,
active_queue: 1,
seconds_per_completion: 60,
completed: 0,
blocked_stages: [],
});
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
render(
<LanguageProvider>
<MemorySpace />
</LanguageProvider>,
);
expect(
await screen.findByText(
/1 en procesamiento · 1 recientes pendientes · 1 del historial pendientes/,
),
).toBeTruthy();
expect(screen.getByText(/aprox. 2 minutos/)).toBeTruthy();
localStorage.clear();
});
const { reads } = vi.hoisted(() => ({
reads: {
memories: vi.fn(),
@@ -151,36 +73,6 @@ afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
it("translates the model-required prompt without configuring a model", async () => {
setup();
reads.processing.mockResolvedValue({
running: 0,
pending: 0,
completed: 0,
blocked_stages: [
{ kind: "extract", reason: "waiting_for_model", count: 4 },
],
});
delete window.oneMemory;
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
const configure = vi.fn();
render(
<LanguageProvider>
<MemorySpace simplified onConfigureModel={configure} />
</LanguageProvider>,
);
expect(
await screen.findByRole("status", {
name: "Generación de memorias detenida",
}),
).toHaveProperty(
"textContent",
"Aún no se generan memorias: Esperando un modelo disponible (4 pendientes)Configurar modelo de generación",
);
expect(configure).not.toHaveBeenCalled();
localStorage.clear();
});
function setup() {
const items = [
{
File diff suppressed because it is too large. Load diff
+1 -40
View File
@@ -13,7 +13,7 @@ import {
import userEvent from "@testing-library/user-event";
import { createRef, useState } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import {
ModelSettings,
type ModelSettingsActions,
@@ -22,45 +22,6 @@ import {
import type { PendingModelProbe } from "./pending-model-probe";
import { PROVIDER_PRESETS } from "./provider-presets";
it("keeps the edited model draft and does not mutate on live language changes", async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
profiles = [profile];
const user = userEvent.setup();
render(
<LanguageProvider>
<ModelSettings />
</LanguageProvider>,
);
const field = await screen.findByLabelText(
"Identificador del modelo de generación",
);
await user.type(field, "synthetic-model");
const before = [
create.mock.calls.length,
update.mock.calls.length,
activate.mock.calls.length,
probeProfile.mock.calls.length,
];
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
await act(async () =>
window.dispatchEvent(
new StorageEvent("storage", { key: LANGUAGE_STORAGE_KEY }),
),
);
expect(await screen.findByLabelText("生成模型名")).toHaveProperty(
"value",
"synthetic-model",
);
expect([
create.mock.calls.length,
update.mock.calls.length,
activate.mock.calls.length,
probeProfile.mock.calls.length,
]).toEqual(before);
cleanup();
localStorage.clear();
});
const create = vi.fn();
const probeProfile = vi.fn();
const activate = vi.fn();
+134 -167
View File
@@ -17,7 +17,6 @@ import {
useState,
} from "react";
import { OptionMenu } from "./OptionMenu";
import { useOnboardingTranslation } from "./onboarding-language";
import { PROVIDER_PRESETS } from "./provider-presets";
import { useModelProfiles } from "./use-model-profiles";
import "./model-setup-next.css";
@@ -232,7 +231,6 @@ export function ModelSettings({
compactSingle?: boolean;
onSetupComplete?(): void;
} = {}) {
const { text } = useOnboardingTranslation();
const models = useModelProfiles();
const [provider, setProvider] = useState<ProviderKind>("openai_compatible");
const [providerPreset, setProviderPreset] = useState("custom");
@@ -700,28 +698,25 @@ export function ModelSettings({
<div className="section-heading">
<div>
<p className="section-index">02</p>
<h2 id="models-title">{text("记忆模型")}</h2>
<h2 id="models-title">记忆模型</h2>
</div>
<p>
{text(
"只需配置生成模型。向量嵌入已内置在本机,不为 System 1、System 2 分开选模型。",
)}
只需配置生成模型。向量嵌入已内置在本机,不为 System 1、System 2
分开选模型。
</p>
</div>
{models.error ? (
<p className="error-banner" role="alert">
{text(models.error)}
{models.error}
</p>
) : null}
{(models.pendingProbe || models.probeStorageFailed) && (
<div role="status" className="model-refresh-notice">
<p>
{text(
models.probeStorageFailed
? "无法读取测试恢复记录,暂不发送新的测试。"
: "上次模型测试结果待核对,暂不修改或启用配置。",
)}
{models.probeStorageFailed
? "无法读取测试恢复记录,暂不发送新的测试。"
: "上次模型测试结果待核对,暂不修改或启用配置。"}
</p>
{models.pendingProbe && (
<button
@@ -736,7 +731,7 @@ export function ModelSettings({
if (alive.current) onProfilesChanged?.();
}}
>
{text("核对测试结果")}
核对测试结果
</button>
)}
</div>
@@ -744,11 +739,9 @@ export function ModelSettings({
{(models.pendingCreation || models.creationStorageFailed) && (
<div role="status" className="model-refresh-notice">
<p>
{text(
models.creationMissing
? "未找到上次创建记录。确认放弃旧记录后可重新配置;系统不会自动重试。"
: "上次模型创建结果待核对,暂不开始新的操作。",
)}
{models.creationMissing
? "未找到上次创建记录。确认放弃旧记录后可重新配置;系统不会自动重试。"
: "上次模型创建结果待核对,暂不开始新的操作。"}
</p>
<button
type="button"
@@ -764,7 +757,7 @@ export function ModelSettings({
onProfilesChanged?.();
}}
>
{text("核对创建结果")}
核对创建结果
</button>
{models.pendingCreation && (
<button
@@ -778,28 +771,26 @@ export function ModelSettings({
setSaveError(null);
}}
>
{text("放弃旧记录,重新配置")}
放弃旧记录,重新配置
</button>
)}
</div>
)}
{setupPhase && (
<p className="model-test-notice" role="status">
{text(
setupPhase === "testing"
? "配置已保存,正在测试连接…"
: "连接通过,正在启用模型…",
)}
{setupPhase === "testing"
? "配置已保存,正在测试连接…"
: "连接通过,正在启用模型…"}
</p>
)}
{setupError && (
<p className="model-save-error" role="alert">
{text(setupError)}
{setupError}
</p>
)}
{models.refreshError && (
<div className="model-refresh-notice" role="status">
<p>{text(models.refreshError)}</p>
<p>{models.refreshError}</p>
<button
type="button"
className="secondary"
@@ -807,7 +798,7 @@ export function ModelSettings({
onClick={() => void models.refreshProfiles()}
>
<RefreshCw size={16} aria-hidden="true" />
{text("刷新配置列表")}
刷新配置列表
</button>
</div>
)}
@@ -846,7 +837,7 @@ export function ModelSettings({
} else newProfile();
}}
>
{text("自定义一个模型")}
自定义一个模型
</button>
</div>
) : null}
@@ -854,7 +845,7 @@ export function ModelSettings({
{!compactSingle && savedProfile && (
<article
className="model-saved-card"
aria-label={text("已保存的模型配置")}
aria-label="已保存的模型配置"
tabIndex={-1}
ref={savedCard}
data-tour-id="model-form"
@@ -862,37 +853,34 @@ export function ModelSettings({
<div className="model-saved-heading">
<Check size={18} aria-hidden="true" />
<strong>
{text(
isReady(savedProfile) && savedProfile.is_active
? "已保存,已启用"
: isReady(savedProfile)
? "已保存,连接通过"
: models.probeFor(savedProfile)
? "已保存,测试未通过"
: "已保存,待测试",
)}
{isReady(savedProfile) && savedProfile.is_active
? "已保存,已启用"
: isReady(savedProfile)
? "已保存,连接通过"
: models.probeFor(savedProfile)
? "已保存,测试未通过"
: "已保存,待测试"}
</strong>
</div>
<h3>{savedProfile.name}</h3>
<p>
{savedProfile.model} · {text(endpointLabel(savedProfile.base_url))}{" "}
· {text("版本")} {savedProfile.revision}
{savedProfile.model} · {endpointLabel(savedProfile.base_url)} · 版本{" "}
{savedProfile.revision}
</p>
{savedProfile.credential_state === "unavailable" && (
<p role="status">
{text(
"需要允许 MemHub 访问钥匙串。系统询问时请选择“始终允许”;已保存的配置不会丢失。",
)}
需要允许 MemHub
访问钥匙串。系统询问时请选择“始终允许”;已保存的配置不会丢失。
</p>
)}
<p>{text("测试只向此接口发送测试内容,不发送历史对话。")}</p>
<p>测试只向此接口发送测试内容,不发送历史对话。</p>
<div className="source-actions">
<button
type="button"
disabled={locked}
onClick={() => void resumeSetup(savedProfile)}
>
{text(setupError ? "重试测试并启用" : "测试并启用")}
{setupError ? "重试测试并启用" : "测试并启用"}
</button>
<button
type="button"
@@ -901,7 +889,7 @@ export function ModelSettings({
onClick={() => editProfile(savedProfile)}
>
<Pencil size={16} aria-hidden="true" />
{text("编辑配置")}
编辑配置
</button>
<button
type="button"
@@ -910,7 +898,7 @@ export function ModelSettings({
onClick={newProfile}
>
<Plus size={16} aria-hidden="true" />
{text("新增配置")}
新增配置
</button>
</div>
<ProbeFeedback
@@ -932,20 +920,18 @@ export function ModelSettings({
>
{editing && (
<p className="model-test-notice">
{text("编辑")} {editing.name} · {text("版本")} {editing.revision}
{text("。修改后需重新测试并设为当前模型,内容授权不会自动更新。")}
编辑 {editing.name} · 版本 {editing.revision}
。修改后需重新测试并设为当前模型,内容授权不会自动更新。
</p>
)}
{editChanged && (
<p role="alert" className="model-save-error">
{text(
"配置已在其他位置变化,草稿已保留。请取消编辑后重新打开核对。",
)}
配置已在其他位置变化,草稿已保留。请取消编辑后重新打开核对。
</p>
)}
{saveError && (
<p className="model-save-error" role="alert">
{text(saveError)}
{saveError}
</p>
)}
{unknownCreation && !editing && (
@@ -954,13 +940,13 @@ export function ModelSettings({
disabled={formLocked}
onClick={() => void models.refreshProfiles()}
>
{text("刷新配置列表")}
刷新配置列表
</button>
)}
<div className="model-form-field">
{text("服务商预设")}
服务商预设
<OptionMenu
ariaLabel={text("服务商预设")}
ariaLabel="服务商预设"
describedBy="model-preset-help"
value={providerPreset}
disabled={formLocked}
@@ -969,7 +955,7 @@ export function ModelSettings({
value: item.id,
label: item.label,
})),
{ value: "custom", label: text("自定义兼容服务") },
{ value: "custom", label: "自定义兼容服务" },
]}
onChange={(value) => {
const next = PROVIDER_PRESETS.find((item) => item.id === value);
@@ -991,16 +977,15 @@ export function ModelSettings({
}}
/>
<small id="model-preset-help">
{text(
"只是帮你填好接口地址和一个模型名,不代表这个模型当前可用。 请对照服务商的模型列表核对后再保存。",
)}
只是帮你填好接口地址和一个模型名,不代表这个模型当前可用。
请对照服务商的模型列表核对后再保存。
</small>
</div>
<label>
{text("协议")}
协议
<select
name="provider"
aria-label={text("协议")}
aria-label="协议"
aria-describedby="model-provider-help"
value={provider}
disabled={formLocked}
@@ -1016,19 +1001,19 @@ export function ModelSettings({
* the reader parse a parenthesis to find out they were dead. */}
{PROVIDERS.filter((item) => item.activatable).map((item) => (
<option key={item.kind} value={item.kind}>
{text(item.label)}
{item.label}
</option>
))}
</select>
<small id="model-provider-help">
{text("与模型服务提供的接口协议保持一致。")}
与模型服务提供的接口协议保持一致。
</small>
</label>
<label>
{text("配置名称")}
配置名称
<input
name="name"
aria-label={text("配置名称")}
aria-label="配置名称"
aria-describedby="model-name-help"
autoComplete="off"
maxLength={128}
@@ -1039,15 +1024,13 @@ export function ModelSettings({
setName(event.target.value);
}}
/>
<small id="model-name-help">
{text("用于区分配置,不影响模型请求。")}
</small>
<small id="model-name-help">用于区分配置,不影响模型请求。</small>
</label>
<label>
{text("生成模型名")}
生成模型名
<input
name="model"
aria-label={text("生成模型名")}
aria-label="生成模型名"
aria-describedby={`model-identifier-help${fields.model ? " model-field-error" : ""}`}
aria-invalid={!!fields.model}
autoComplete="off"
@@ -1060,14 +1043,14 @@ export function ModelSettings({
setProviderPreset("custom");
if (!nameEdited) setName(suggestedName(event.target.value));
}}
placeholder={text("例如:gpt-4o-mini…")}
placeholder="例如:gpt-4o-mini…"
/>
<small id="model-identifier-help">
{text("填写服务方提供的完整模型标识。")}
填写服务方提供的完整模型标识。
</small>
{fields.model && (
<small id="model-field-error" className="model-field-error">
{text(fields.model)}
{fields.model}
</small>
)}
</label>
@@ -1080,12 +1063,12 @@ export function ModelSettings({
disabled={formLocked}
onClick={() => setAdvancedEmbedding(!advancedEmbedding)}
>
{text("高级:自行配置向量模型")}
高级:自行配置向量模型
</button>
{advancedEmbedding ? (
<>
<label>
{text("向量配置模式")}
向量配置模式
<select
name="embedding-mode"
value={embeddingMode}
@@ -1102,14 +1085,14 @@ export function ModelSettings({
}));
}}
>
<option value="local">{text("本地内置")}</option>
<option value="custom">{text("自定义向量模型")}</option>
<option value="local">本地内置</option>
<option value="custom">自定义向量模型</option>
</select>
</label>
{embeddingMode === "custom" ? (
<>
<label>
{text("向量模型名")}
向量模型名
<input
name="embedding-model"
autoComplete="off"
@@ -1126,19 +1109,19 @@ export function ModelSettings({
onChange={(event) =>
setEmbeddingModel(event.target.value)
}
placeholder={text("例如:text-embedding-3-small…")}
placeholder="例如:text-embedding-3-small…"
/>
{fields["embedding-model"] && (
<small
id="model-embedding-error"
className="model-field-error"
>
{text(fields["embedding-model"])}
{fields["embedding-model"]}
</small>
)}
</label>
<label>
{text("向量模型来源")}
向量模型来源
<select
name="embedding-provider"
value={embeddingProvider}
@@ -1150,14 +1133,14 @@ export function ModelSettings({
setEmbeddingProvider(next);
}}
>
<option value="ollama">{text("本机 Ollama")}</option>
<option value="ollama">本机 Ollama</option>
<option value="openai_compatible">
{text("同源 OpenAI-compatible(共用 API Key)")}
同源 OpenAI-compatible(共用 API Key)
</option>
</select>
</label>
<label>
{text("向量模型 Base URL")}
向量模型 Base URL
<input
name="embedding-base-url"
type="url"
@@ -1186,27 +1169,27 @@ export function ModelSettings({
id="model-embedding-url-error"
className="model-field-error"
>
{text(fields["embedding-base-url"])}
{fields["embedding-base-url"]}
</small>
)}
</label>
{embeddingProvider === "openai_compatible" ? (
<small>
{text("为避免凭据外泄,向量地址必须与生成模型同源。")}
为避免凭据外泄,向量地址必须与生成模型同源。
</small>
) : null}
</>
) : (
<small>
{text("使用内置本地向量模型;不会发送内容给远程向量服务。")}
使用内置本地向量模型;不会发送内容给远程向量服务。
</small>
)}
</>
) : null}
{!advancedEmbedding && embeddingMode === "custom" ? (
<p className="model-help">
{text("自定义向量")}: {embeddingModel || text("尚未填写模型")} ·{" "}
{embeddingBaseUrl || text("尚未填写地址")}
自定义向量:{embeddingModel || "尚未填写模型"} ·{" "}
{embeddingBaseUrl || "尚未填写地址"}
</p>
) : null}
</div>
@@ -1229,19 +1212,19 @@ export function ModelSettings({
}}
/>
<small id="model-url-help">
{text("填写服务接口地址,不是聊天网页地址。")}
填写服务接口地址,不是聊天网页地址。
</small>
{fields["base-url"] && (
<small id="model-url-error" className="model-field-error">
{text(fields["base-url"])}
{fields["base-url"]}
</small>
)}
</label>
<label>
API Key{keyRequired ? "" : text("(可选)")}
API Key{keyRequired ? "" : "(可选)"}
<input
name="api-key"
aria-label={`API Key${keyRequired ? "" : text("(可选)")}`}
aria-label={`API Key${keyRequired ? "" : "(可选)"}`}
aria-describedby={`model-key-help${fields["api-key"] ? " model-key-error" : ""}`}
aria-invalid={!!fields["api-key"]}
type="password"
@@ -1250,20 +1233,18 @@ export function ModelSettings({
value={apiKey}
disabled={formLocked}
onChange={(event) => setApiKey(event.target.value)}
placeholder={text("保存后不会再次显示…")}
placeholder="保存后不会再次显示…"
/>
<small id="model-key-help">
{text(
editing && !endpointMoved
? "留空保留已保存密钥;填写新密钥后需重新测试和启用。"
: editing && keyRequired
? "换了服务商后需要填写新的 API Key,已保存的密钥不会带到新地址。"
: "保存在系统安全存储中,不随安装包分发。",
)}
{editing && !endpointMoved
? "留空保留已保存密钥;填写新密钥后需重新测试和启用。"
: editing && keyRequired
? "换了服务商后需要填写新的 API Key,已保存的密钥不会带到新地址。"
: "保存在系统安全存储中,不随安装包分发。"}
</small>
{fields["api-key"] && (
<small id="model-key-error" className="model-field-error">
{text(fields["api-key"])}
{fields["api-key"]}
</small>
)}
</label>
@@ -1275,7 +1256,7 @@ export function ModelSettings({
disabled={formLocked}
onChange={(event) => setDisableThinking(event.target.checked)}
/>
{text("关闭思考链(向支持的厂商下发 thinking-off 参数)")}
关闭思考链(向支持的厂商下发 thinking-off 参数)
</label>
<div className="form-submit">
{!compactSingle && (
@@ -1285,7 +1266,7 @@ export function ModelSettings({
locked || editChanged || (!editing && unknownCreation)
}
>
{text(editing ? "保存修改" : "保存配置")}
{editing ? "保存修改" : "保存配置"}
</button>
)}
<button
@@ -1293,15 +1274,13 @@ export function ModelSettings({
disabled={locked || editChanged || (!editing && unknownCreation)}
onClick={() => void persist(true)}
>
{text(
setupPhase === "testing"
? "正在测试连接…"
: setupPhase === "activating"
? "正在启用配置…"
: compactSingle
? "测试并完成配置"
: "保存并测试启用",
)}
{setupPhase === "testing"
? "正在测试连接…"
: setupPhase === "activating"
? "正在启用配置…"
: compactSingle
? "测试并完成配置"
: "保存并测试启用"}
</button>
{editing && (
<button
@@ -1316,12 +1295,10 @@ export function ModelSettings({
setFields({});
}}
>
{text("取消编辑")}
取消编辑
</button>
)}
<small>
{text("密钥保存在系统安全存储;测试仅发送测试内容。")}
</small>
<small>密钥保存在系统安全存储;测试仅发送测试内容。</small>
</div>
</form>
) : null}
@@ -1338,16 +1315,14 @@ export function ModelSettings({
<article key={profile.profile_id} className="profile-row">
{profile.credential_state === "unavailable" && (
<p className="error-banner" role="status">
{text(
"需要允许 MemHub 访问钥匙串。系统询问时请选择“始终允许”;已保存的配置不会丢失。",
)}
需要允许 MemHub
访问钥匙串。系统询问时请选择“始终允许”;已保存的配置不会丢失。
</p>
)}
<div>
<strong>{profile.name}</strong>
<p>
{text(providerLabel(profile.provider_kind))} ·{" "}
{profile.model} ·
{providerLabel(profile.provider_kind)} · {profile.model} ·
{/* Core fills in the built-in vector model on every profile,
* so printing it unconditionally put a line about
* bge-small-zh-v1.5 under a profile whose owner never chose
@@ -1356,17 +1331,17 @@ export function ModelSettings({
{profile.embedding_model &&
profile.embedding_provider_kind &&
profile.embedding_provider_kind !== BUILTIN_EMBEDDING_KIND
? ` ${text("自定义向量")}: ${profile.embedding_model} (${text(
providerLabel(profile.embedding_provider_kind),
? ` 向量: ${profile.embedding_model} (${providerLabel(
profile.embedding_provider_kind,
)}) ·`
: ""}{" "}
revision {profile.revision}
</p>
</div>
<span>{text(profile.is_active ? "当前使用" : "未启用")}</span>
<span>{profile.is_active ? "当前使用" : "未启用"}</span>
<p className="model-test-notice">
{text("测试目标")}: {text(endpointLabel(profile.base_url))}
{text("。仅发送测试内容,不发送历史对话。")}
测试目标:{endpointLabel(profile.base_url)}
。仅发送测试内容,不发送历史对话。
</p>
<div className="source-actions">
<button
@@ -1374,7 +1349,7 @@ export function ModelSettings({
disabled={locked}
onClick={() => void resumeSetup(profile)}
>
{text(setupError ? "重试测试并启用" : "测试并启用")}
{setupError ? "重试测试并启用" : "测试并启用"}
</button>
<button
type="button"
@@ -1383,7 +1358,7 @@ export function ModelSettings({
onClick={() => editProfile(profile)}
>
<Pencil size={16} aria-hidden="true" />
{text("编辑配置")}
编辑配置
</button>
<button
type="button"
@@ -1391,7 +1366,7 @@ export function ModelSettings({
disabled={locked}
onClick={() => models.probeProfile(profile.profile_id)}
>
{text("测试连接")}
测试连接
</button>
{!profile.is_active ? (
<>
@@ -1400,7 +1375,7 @@ export function ModelSettings({
disabled={locked || !isReady(profile)}
onClick={() => models.activate(profile.profile_id)}
>
{text("设为当前模型")}
设为当前模型
</button>
<button
type="button"
@@ -1408,7 +1383,7 @@ export function ModelSettings({
disabled={locked}
onClick={() => models.remove(profile.profile_id)}
>
{text("删除")}
删除
</button>
</>
) : null}
@@ -1460,7 +1435,6 @@ function PresetProfileCard({
probe: ModelProbeResult | null;
probeError: string | null;
}) {
const { text } = useOnboardingTranslation();
const [key, setKey] = useState("");
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
@@ -1509,25 +1483,21 @@ function PresetProfileCard({
<section className="preset-profile" aria-label={profile.name}>
<div>
<strong>{profile.name}</strong>
<p>{text(presetStatus(profile))}</p>
<p>{presetStatus(profile)}</p>
</div>
{discloseRecipients && (
<p>
{text("生成接收方")}: {text(endpointLabel(profile.base_url))};{" "}
{text("向量接收方")}:
生成接收方:{endpointLabel(profile.base_url)};向量接收方:
{profile.embedding_provider_kind === BUILTIN_EMBEDDING_KIND
? text("本机")
: text(
endpointLabel(profile.embedding_base_url ?? profile.base_url),
)}
{text("。启用此配置后按上方选择处理历史及未来允许来源。")}
? "本机"
: endpointLabel(profile.embedding_base_url ?? profile.base_url)}
。启用此配置后按上方选择处理历史及未来允许来源。
</p>
)}
{profile.credential_state === "unavailable" && (
<p role="status">
{text(
"需要允许 MemHub 访问钥匙串。系统询问时请选择“始终允许”;已保存的配置不会丢失。",
)}
需要允许 MemHub
访问钥匙串。系统询问时请选择“始终允许”;已保存的配置不会丢失。
</p>
)}
{profile.has_api_key ? null : (
@@ -1541,7 +1511,7 @@ function PresetProfileCard({
noValidate
>
<label>
{text(keyLabel)}
{keyLabel}
<input
name="preset-api-key"
type="password"
@@ -1554,7 +1524,7 @@ function PresetProfileCard({
}
value={key}
onChange={(event) => setKey(event.target.value)}
placeholder={text("保存后不会再次显示…")}
placeholder="保存后不会再次显示…"
/>
{error && (
<small
@@ -1562,12 +1532,12 @@ function PresetProfileCard({
className="model-field-error"
role="alert"
>
{text(error)}
{error}
</small>
)}
</label>
<button type="submit" disabled={busy || saving}>
{text("保存 key")}
保存 key
</button>
<button
type="button"
@@ -1575,11 +1545,11 @@ function PresetProfileCard({
disabled={busy || saving || !canEnable}
onClick={() => void save(true)}
>
{text("保存并测试启用")}
保存并测试启用
</button>
{applyHref ? (
<a href={applyHref} target="_blank" rel="noreferrer">
{text("还没有 key?去申请")}
还没有 key?去申请
</a>
) : null}
</form>
@@ -1592,21 +1562,21 @@ function PresetProfileCard({
disabled={busy || !canEnable}
onClick={onContinue}
>
{text("测试并启用")}
测试并启用
</button>
<p className="model-test-notice">
{text("测试目标")}: {text(endpointLabel(profile.base_url))}
{text("。仅发送测试内容,不发送历史对话。")}
测试目标:{endpointLabel(profile.base_url)}
。仅发送测试内容,不发送历史对话。
</p>
<button type="button" disabled={busy} onClick={onTest}>
{text("测试连接")}
测试连接
</button>
<button
type="button"
disabled={busy || !canEnable || !ready(profile, probe, probeError)}
onClick={onActivate}
>
{text("设为当前模型")}
设为当前模型
</button>
<button
type="button"
@@ -1614,7 +1584,7 @@ function PresetProfileCard({
disabled={busy}
onClick={onRemove}
>
{text("删除")}
删除
</button>
</div>
) : null}
@@ -1631,7 +1601,7 @@ function PresetProfileCard({
disabled={busy}
onClick={onRemove}
>
{text("删除这张卡")}
删除这张卡
</button>
</div>
)}
@@ -1669,7 +1639,6 @@ function ProbeFeedback({
result: ModelProbeResult | null;
error: string | null;
}) {
const { text } = useOnboardingTranslation();
const matching =
result?.profile_id === profile.profile_id &&
result.profile_revision === profile.revision
@@ -1679,22 +1648,20 @@ function ProbeFeedback({
return (
<section
className="probe-result"
aria-label={`${profile.name} ${text("版本")} ${profile.revision} ${text("测试结果")}`}
aria-label={`${profile.name} 版本 ${profile.revision} 测试结果`}
>
<strong>
{profile.name} · {text("版本")} {profile.revision} · {text("测试连接")}
{profile.name} · 版本 {profile.revision} · 连接测试
</strong>
<p>
{text("测试目标")}: {text(endpointLabel(profile.base_url))} ·{" "}
{profile.model}
测试目标:{endpointLabel(profile.base_url)} · {profile.model}
</p>
{error && <p role="alert">{text(error)}</p>}
{error && <p role="alert">{error}</p>}
{matching && (
<ul>
{matching.stages.map((stage) => (
<li key={stage.stage}>
{stage.stage}: {text(probeLabel(stage.code))} · {stage.latency_ms}
ms
{stage.stage}: {probeLabel(stage.code)} · {stage.latency_ms}ms
</li>
))}
</ul>
+24 -28
View File
@@ -1,18 +1,16 @@
import type { MessageKey } from "@one-memory/i18n";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
getModelUsage,
type ModelUsageMinute,
type ModelUsageReport,
} from "./api";
import { useGuiLanguage } from "./gui-language";
const RANGES: { minutes: number; label: MessageKey }[] = [
{ minutes: 15, label: "usageRange15m" },
{ minutes: 60, label: "usageRange1h" },
{ minutes: 360, label: "usageRange6h" },
{ minutes: 1_440, label: "usageRange24h" },
{ minutes: 10_080, label: "usageRange7d" },
const RANGES = [
{ minutes: 15, label: "最近 15 分钟" },
{ minutes: 60, label: "最近 1 小时" },
{ minutes: 360, label: "最近 6 小时" },
{ minutes: 1_440, label: "最近 24 小时" },
{ minutes: 10_080, label: "最近 7 天" },
];
const STAGE_LABELS: Record<string, string> = {
@@ -43,7 +41,6 @@ interface ModelUsageViewProps {
}
export function ModelUsageView({ live, refreshToken }: ModelUsageViewProps) {
const { t, tnPlain } = useGuiLanguage();
const [rangeMinutes, setRangeMinutes] = useState(60);
const [report, setReport] = useState<ModelUsageReport | null>(null);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
@@ -79,33 +76,33 @@ export function ModelUsageView({ live, refreshToken }: ModelUsageViewProps) {
<div className="model-usage-view">
<div className="model-usage-toolbar">
<label>
<span>{t("usageRange")}</span>
<span>时间范围</span>
<select
aria-label={t("usageRangeLabel")}
aria-label="模型用量时间范围"
value={rangeMinutes}
onChange={(event) => setRangeMinutes(Number(event.target.value))}
>
{RANGES.map((range) => (
<option key={range.minutes} value={range.minutes}>
{t(range.label)}
{range.label}
</option>
))}
</select>
</label>
<span className={`model-usage-state state-${state}`}>
{state === "loading"
? t("usageLoading")
? "正在读取…"
: state === "error"
? t("usageReadError")
? "读取失败"
: live
? t("usageLive")
: t("debugPaused")}
? "每 5 秒刷新"
: "已暂停"}
</span>
</div>
<div className="model-usage-metrics">
<Metric
label={t("usageTotalTokens")}
label="总 Tokens"
value={formatTokens(report?.summary.total_tokens)}
/>
<Metric
@@ -117,26 +114,26 @@ export function ModelUsageView({ live, refreshToken }: ModelUsageViewProps) {
value={formatTokens(report?.summary.avg_completion_tokens)}
/>
<Metric
label={t("usageRequests")}
label="请求数"
value={String(report?.summary.request_count ?? 0)}
/>
</div>
<section
className="model-usage-chart-card"
aria-label={t("usageChartRegion")}
aria-label="各 Prompt 每分钟 Token 曲线"
>
<div className="model-usage-section-title">
<strong>{t("usageChartTitle")}</strong>
<span>{t("usageChartSubtitle")}</span>
<strong>各 Prompt 花费曲线</strong>
<span>每分钟 · Total Tokens</span>
</div>
<UsageChart rows={report?.by_minute ?? []} />
</section>
<section className="model-usage-stage-card">
<div className="model-usage-section-title">
<strong>{t("usageBreakdown")}</strong>
<span>{t("usageCurrentRange")}</span>
<strong>Prompt 明细</strong>
<span>当前时间范围</span>
</div>
<div className="model-usage-stage-list">
{stages.length ? (
@@ -144,14 +141,14 @@ export function ModelUsageView({ live, refreshToken }: ModelUsageViewProps) {
<div className="model-usage-stage-row" key={stage.name}>
<i style={{ background: stageColor(stage.name) }} />
<strong>{stageLabel(stage.name)}</strong>
<span>{tnPlain("usageRequestCount", stage.requests)}</span>
<span>{stage.requests} 次</span>
<span>Prompt {formatTokens(stage.prompt)}</span>
<span>Completion {formatTokens(stage.completion)}</span>
<b>{formatTokens(stage.total)}</b>
</div>
))
) : (
<p className="debug-log-empty">{t("usageEmpty")}</p>
<p className="debug-log-empty">这个时间范围内没有沉淀模型调用</p>
)}
</div>
</section>
@@ -169,7 +166,6 @@ function Metric({ label, value }: { label: string; value: string }) {
}
function UsageChart({ rows }: { rows: ModelUsageMinute[] }) {
const { t } = useGuiLanguage();
const minutes = [...new Set(rows.map((row) => row.minute))].sort();
const stages = [...new Set(rows.map((row) => row.prompt_stage))].sort();
const width = 640;
@@ -193,7 +189,7 @@ function UsageChart({ rows }: { rows: ModelUsageMinute[] }) {
pad.top + plotHeight - (value / max) * plotHeight;
if (!rows.length)
return <div className="model-usage-chart-empty">{t("usageNoChart")}</div>;
return <div className="model-usage-chart-empty">暂无曲线数据</div>;
return (
<>
@@ -201,7 +197,7 @@ function UsageChart({ rows }: { rows: ModelUsageMinute[] }) {
className="model-usage-chart"
viewBox={`0 0 ${width} ${height}`}
role="img"
aria-label={t("usageChartAria")}
aria-label="按分钟显示各 Prompt 的 Token 花费"
>
{[0, 0.5, 1].map((ratio) => (
<g key={ratio}>
@@ -1,492 +0,0 @@
// @vitest-environment jsdom
import type { ImportJob, SourceResponse } from "@one-memory/contracts";
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { completeOnboarding } from "./api";
import { FirstImportReview } from "./FirstImportReview";
import {
LANGUAGE_STORAGE_KEY,
LanguageProvider,
useGuiLanguage,
} from "./gui-language";
import { OnboardingFlow } from "./OnboardingFlow";
import { OnboardingModelController } from "./OnboardingModelController";
import { R6ImportProgress } from "./R6ImportProgress";
import type { ImportSourcesController } from "./use-import-sources";
vi.mock("./api", () => ({
completeOnboarding: vi.fn(),
getModelProfiles: vi.fn(),
}));
const models = {
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 }));
const source: SourceResponse = {
source_id: "cursor",
display_name: "Cursor",
available: true,
authorized: false,
detected: false,
storage_kinds: ["jsonl"],
root_label: null,
candidate_path: "/fixture/cursor",
candidate_kind: "default",
candidate_root_id: "fixture-root",
location_state: "found",
imported_messages: 0,
imported_conversations: 0,
sync_enabled: false,
sync_completeness: "additions_only",
capabilities: {
historical_import: "implemented",
incremental_import: "implemented",
live_sync: "not_implemented",
recall: "not_implemented",
installer: "not_implemented",
verification: "fixture_only",
},
};
function controller(overrides: Partial<SourceResponse> = {}) {
const sources = [{ ...source, ...overrides }];
return {
sources,
jobs: [],
loading: false,
refresh: vi.fn().mockResolvedValue({ sources, jobs: [], health: null }),
connectForOnboarding: vi.fn(),
detect: vi.fn(),
} as unknown as ImportSourcesController;
}
function SwitchLanguage() {
const { setPreference } = useGuiLanguage();
return (
<button type="button" onClick={() => void setPreference("zh-CN")}>
中文
</button>
);
}
const state = {
status: "not_started" as const,
selected_source_ids: [],
started_at: null,
completed_at: null,
};
beforeEach(() => {
delete window.oneMemory;
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
localStorage.clear();
delete window.oneMemory;
});
it("changes the first-screen language without losing source choices or starting an import", async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
const imports = controller();
const finish = vi.fn();
const user = userEvent.setup();
render(
<LanguageProvider>
<OnboardingFlow state={state} imports={imports} onFinish={finish} />
</LanguageProvider>,
);
const selector = screen.getByRole("combobox", { name: "界面语言" });
await waitFor(() =>
expect(screen.getByLabelText("导入 Cursor 历史")).toHaveProperty(
"checked",
true,
),
);
await user.click(screen.getByLabelText("导入 Cursor 历史"));
await user.selectOptions(selector, "es");
expect(screen.getByRole("combobox", { name: "Idioma de la interfaz" })).toBe(
selector,
);
expect(
screen.getByLabelText("Importar el historial de Cursor"),
).toHaveProperty("checked", false);
await user.click(screen.getByLabelText("Importar el historial de Cursor"));
await user.selectOptions(selector, "zh-CN");
expect(screen.getByLabelText("导入 Cursor 历史")).toHaveProperty(
"checked",
true,
);
expect(imports.refresh).toHaveBeenCalledOnce();
expect(imports.connectForOnboarding).not.toHaveBeenCalled();
expect(imports.detect).not.toHaveBeenCalled();
expect(completeOnboarding).not.toHaveBeenCalled();
expect(finish).not.toHaveBeenCalled();
});
it("translates discovery, progress and privacy without importing or authorizing on language changes", async () => {
const imports = controller();
render(
<LanguageProvider>
<SwitchLanguage />
<OnboardingFlow state={state} imports={imports} onFinish={vi.fn()} />
</LanguageProvider>,
);
expect(
await screen.findByRole("heading", {
name: "Trae aquí el contexto que ya tienes",
}),
).toBeTruthy();
await waitFor(() =>
expect(
screen.getByLabelText<HTMLInputElement>("Importar el historial de Cursor")
.checked,
).toBe(true),
);
expect(
screen.getByRole("navigation", {
name: "Progreso de la configuración inicial",
}),
).toBeTruthy();
expect(
screen.getByText(
"Antes de confirmar, solo se comprueba la información de las aplicaciones. No se leen conversaciones ni se instalan conexiones.",
),
).toBeTruthy();
expect(
screen.getByText("/fixture/cursor · Ubicación predeterminada"),
).toBeTruthy();
fireEvent.click(screen.getByText("中文"));
await screen.findByRole("heading", { name: "把熟悉的背景,带到这里" });
expect(
screen.getByLabelText<HTMLInputElement>("导入 Cursor 历史").checked,
).toBe(true);
expect(imports.refresh).toHaveBeenCalledTimes(1);
expect(imports.connectForOnboarding).not.toHaveBeenCalled();
expect(imports.detect).not.toHaveBeenCalled();
});
it("translates stored discovery errors live without repeating discovery", async () => {
const imports = controller();
vi.mocked(imports.refresh).mockRejectedValue(new Error("fixture-offline"));
render(
<LanguageProvider>
<SwitchLanguage />
<OnboardingFlow state={state} imports={imports} onFinish={vi.fn()} />
</LanguageProvider>,
);
await waitFor(() =>
expect(screen.getByRole("alert").textContent).toContain(
"No se completó la detección",
),
);
fireEvent.click(screen.getByText("中文"));
await waitFor(() =>
expect(screen.getByRole("alert").textContent).toContain("位置检测未完成"),
);
expect(imports.refresh).toHaveBeenCalledTimes(1);
});
it("translates custom-location consent without granting access", async () => {
const imports = controller({ candidate_kind: "environment_override" });
render(
<LanguageProvider>
<OnboardingFlow state={state} imports={imports} onFinish={vi.fn()} />
</LanguageProvider>,
);
await waitFor(() =>
expect(
screen.getByLabelText("Importar el historial de Cursor"),
).toHaveProperty("disabled", false),
);
fireEvent.click(screen.getByLabelText("Importar el historial de Cursor"));
expect(
screen.getByRole("dialog", { name: "Confirmar ubicación personalizada" }),
).toBeTruthy();
expect(
screen.getByLabelText(
"Confirmo que esta es la ubicación del historial de Cursor",
),
).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Cancelar" }));
expect(imports.connectForOnboarding).not.toHaveBeenCalled();
});
it("translates import review and preserves independent permission disclosure", async () => {
const change = vi.fn();
render(
<LanguageProvider>
<FirstImportReview
sources={[{ ...source, authorized: true }]}
sourceIds={[source.source_id]}
jobs={[]}
onContinue={change}
/>
</LanguageProvider>,
);
expect(
await screen.findByRole("heading", {
name: "Tus conversaciones llegan; tus memorias empiezan aquí",
}),
).toBeTruthy();
expect(
screen.getByText(
"Importar no concede automáticamente acceso de lectura a las aplicaciones. Las memorias comenzarán a generarse cuando configures un modelo.",
),
).toBeTruthy();
expect(
screen.getByRole("region", {
name: "Progreso de importación de las aplicaciones",
}),
).toBeTruthy();
expect(change).not.toHaveBeenCalled();
});
it("uses compact Spanish actions and preserves Chinese copy and permissions", async () => {
const next = vi.fn();
const draft = vi.fn();
render(
<LanguageProvider>
<SwitchLanguage />
<FirstImportReview
sources={[{ ...source, authorized: true }]}
sourceIds={[source.source_id]}
jobs={[]}
connectionDraft={{ hostSourceIds: [] }}
onConnectionDraftChange={draft}
onContinue={next}
/>
</LanguageProvider>,
);
const navigation = screen.getByRole("navigation");
expect(navigation.textContent).toContain("Conectar apps");
expect(
screen.getByRole("button", {
name: "Configurar un modelo para crear memorias",
}),
).toBeTruthy();
expect(screen.getByText(/Importar no concede automáticamente/)).toBeTruthy();
expect(
screen.getByText(/Solo para las apps seleccionadas y compatibles/),
).toBeTruthy();
expect(screen.getByText(/Si una falla, las demás siguen/)).toBeTruthy();
fireEvent.click(screen.getByText("中文"));
expect(
screen.getByRole("button", { name: "配置模型,沉淀记忆" }),
).toBeTruthy();
expect(navigation.textContent).toContain("连接应用");
expect(screen.getByText(/不会自动开放应用读取权限/)).toBeTruthy();
expect(next).not.toHaveBeenCalled();
expect(draft).not.toHaveBeenCalled();
});
it.each([
["pending", "Importando"],
["running", "Importando"],
["paused", "En pausa"],
["interrupted", "Interrumpida"],
["failed", "Problema de importación"],
["cancelled", "Cancelada"],
["completed", "Importación completada"],
] as const)(
"translates %s progress receipts without changing counts",
async (status, label) => {
const job = {
job_id: "fixture-job",
source_id: "cursor",
status,
stored: 17,
errors: 2,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:01:00Z",
} as ImportJob;
render(
<LanguageProvider>
<R6ImportProgress
sourceIds={["cursor"]}
sources={[{ ...source, authorized: true }]}
jobs={[job]}
/>
</LanguageProvider>,
);
expect(
await screen.findByRole("article", {
name: "Progreso de importación de Cursor",
}),
).toBeTruthy();
expect(screen.getByText("Mensajes guardados: 17")).toBeTruthy();
expect(screen.getByRole("status").textContent).toBe(
`${label} · Errores de lectura: 2`,
);
},
);
it.each(["es", "zh-CN"] as const)(
"preserves three multiline progress cards and receipt statuses in %s",
(locale) => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
const sources = ["cursor", "claude-code", "codex"].map((id, i) => ({
...source,
source_id: id,
display_name: `${locale === "es" ? "Aplicación con nombre largo" : "名称较长的应用"} ${i + 1}`,
authorized: true,
}));
const jobs = sources.map((item, i) => ({
job_id: `fixture-${i}`,
source_id: item.source_id,
status: ["completed", "failed", "paused"][i],
stored: 12345,
errors: i,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:01:00Z",
})) as ImportJob[];
render(
<LanguageProvider>
<R6ImportProgress
sourceIds={sources.map((item) => item.source_id)}
sources={sources}
jobs={jobs}
/>
</LanguageProvider>,
);
expect(screen.getAllByRole("article")).toHaveLength(3);
const statuses = screen
.getAllByRole("status")
.map((item) => item.textContent);
expect(statuses).toEqual(
locale === "es"
? [
"Importación completada",
"Problema de importación · Errores de lectura: 1",
"En pausa · Errores de lectura: 2",
]
: ["导入完成", "导入遇到问题 · 1 条读取异常", "已暂停 · 2 条读取异常"],
);
},
);
it("translates completion recovery live without repeating completion writes", async () => {
vi.mocked(completeOnboarding).mockRejectedValueOnce(
new Error("fixture-offline"),
);
const imports = controller();
const finish = vi.fn();
render(
<LanguageProvider>
<SwitchLanguage />
<OnboardingFlow state={state} imports={imports} onFinish={finish} />
</LanguageProvider>,
);
await waitFor(() =>
expect(
screen.getByRole("button", { name: "Entrar y añadir más tarde" }),
).toHaveProperty("disabled", false),
);
fireEvent.click(
screen.getByRole("button", { name: "Entrar y añadir más tarde" }),
);
expect(
await screen.findByRole("button", { name: "Reintentar guardar y entrar" }),
).toBeTruthy();
expect(screen.getByRole("alert").textContent).toContain(
"aún no está confirmada",
);
fireEvent.click(screen.getByText("中文"));
expect(
await screen.findByRole("button", { name: "重试保存并进入" }),
).toBeTruthy();
expect(completeOnboarding).toHaveBeenCalledTimes(1);
expect(finish).not.toHaveBeenCalled();
});
it("translates model configuration, validation and advanced fields without activating permissions", async () => {
render(
<LanguageProvider>
<SwitchLanguage />
<OnboardingModelController sourceNames={["Cursor"]} onBack={vi.fn()} />
</LanguageProvider>,
);
expect(
await screen.findByRole("heading", {
name: "Elige un modelo para crear memorias",
}),
).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Probar conexión" }));
expect(await screen.findByText("Introduce una API Key.")).toBeTruthy();
expect(screen.getByLabelText("Tiempo de espera (segundos)")).toBeTruthy();
expect(models.create).not.toHaveBeenCalled();
expect(models.activate).not.toHaveBeenCalled();
await act(async () => fireEvent.click(screen.getByText("中文")));
expect(screen.getByText("请填写 API Key。")).toBeTruthy();
expect(screen.getByLabelText("超时(秒)")).toBeTruthy();
});
it.each([
[
"completed",
null,
"Mensajes examinados: 5; no hay conversaciones para guardar",
"已扫描 5 条,本次没有可保存的对话",
],
[
"completed",
"2026-01-01T00:00:00Z",
"Mensajes examinados: 5; no hay conversaciones dentro del periodo elegido",
"已扫描 5 条,本次没有符合时间范围的对话",
],
[
"running",
"2026-01-01T00:00:00Z",
"Mensajes examinados: 5; filtrando los últimos 3 días",
"已扫描 5 条,正在筛选最近 3 天",
],
[
"running",
null,
"Mensajes examinados: 5; filtrando",
"已扫描 5 条,正在筛选",
],
[
"paused",
null,
"Mensajes examinados: 5; aún sin guardar",
"已扫描 5 条,尚未保存",
],
] as const)(
"words a %s scan with history_after=%s in both languages",
async (status, historyAfter, spanish, chinese) => {
const job = {
job_id: "fixture-job",
source_id: "cursor",
status,
stored: 0,
discovered: 5,
errors: 0,
history_after: historyAfter,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:01:00Z",
} as unknown as ImportJob;
for (const [locale, expected] of [
["es", spanish],
["zh-CN", chinese],
] as const) {
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
const view = render(
<LanguageProvider>
<R6ImportProgress
sourceIds={["cursor"]}
sources={[{ ...source, authorized: true }]}
jobs={[job]}
/>
</LanguageProvider>,
);
expect(await screen.findByText(expected)).toBeTruthy();
view.unmount();
}
},
);
@@ -261,7 +261,7 @@ it("keeps the single-source submit frozen when another source arrives during fin
it("empty discovery shows zero and allows explicit entry without a connection request", async () => {
await ready();
await waitFor(() => expect(count()).toBe("0"));
expect(count()).toBe("0");
expect(screen.getByRole("button", { name: "开始导入" })).toHaveProperty(
"disabled",
true,
+58 -95
View File
@@ -29,7 +29,6 @@ import {
hostWriteReason,
} from "./host-state";
import { OnboardingModelController } from "./OnboardingModelController";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6ColdStartFrame } from "./R6ColdStartFrame";
import { R6Button } from "./R6Controls";
import { traceUiAction } from "./ui-trace";
@@ -64,7 +63,6 @@ export function OnboardingFlow({
imports: ImportSourcesController;
onFinish(destination: OnboardingDestination): void;
}) {
const { text, t } = useOnboardingTranslation();
const [sources, setSources] = useState(imports.sources);
const sourceGeneration = useRef({ value: imports.sources, epoch: 0 });
if (sourceGeneration.current.value !== imports.sources) {
@@ -101,21 +99,7 @@ export function OnboardingFlow({
"idle" | "applying" | "applied" | "partial" | "failed"
>("idle");
const [applyTick, setApplyTick] = useState(0);
const [connectionNote, setConnectionNote] = useState<
string | { success: number; failures: [string, string | null][] }
>("");
const renderedConnectionNote =
typeof connectionNote === "string"
? text(connectionNote)
: t("onboardingConnectionPartial", {
success: connectionNote.success,
failed: connectionNote.failures.length,
names: connectionNote.failures
.map(([name, reason]) =>
reason ? `${name}(${text(reason)})` : name,
)
.join("、"),
});
const [connectionNote, setConnectionNote] = useState("");
const flight = useRef(false);
const connectionAttempt = useRef<string | null>(null);
const connectionQueue = useRef(Promise.resolve());
@@ -260,10 +244,12 @@ export function OnboardingFlow({
}
if (failed.size && alive.current) {
connectionQueueFailed.current = true;
setConnectionNote({
success: apps.size - failed.size,
failures: [...failed],
});
const names = [...failed].map(([name, reason]) =>
reason ? `${name}(${reason})` : name,
);
setConnectionNote(
`${apps.size - failed.size} 个应用已接入,${failed.size} 个未完成:${names.join("、")}。可进入应用后单独处理。`,
);
}
try {
await refreshImports();
@@ -651,7 +637,7 @@ export function OnboardingFlow({
}}
connectionExecutionAvailable={connectionAvailable}
hostConnectionAvailable={hostConnectionAvailable}
connectionNote={renderedConnectionNote}
connectionNote={connectionNote}
acceptedJobIds={accepted?.jobs.map((job) => job.job_id)}
onChangeJob={imports.changeJob}
sourceIds={sourceIds}
@@ -660,7 +646,7 @@ export function OnboardingFlow({
failedSources={accepted?.failed_sources}
sourceSnapshot={imports.sourceSnapshot}
busy={busy === "leaving"}
error={error && text(error)}
error={error}
onContinue={(destination) => {
if (busy === "leaving") return;
const selection = {
@@ -724,8 +710,8 @@ export function OnboardingFlow({
return (
<R6ColdStartFrame
step={1}
title={text("把熟悉的背景,带到这里")}
eyebrow={text("你的记忆,从这里接上")}
title="把熟悉的背景,带到这里"
eyebrow="你的记忆,从这里接上"
footer={
<>
<R6Button
@@ -733,22 +719,20 @@ export function OnboardingFlow({
disabled={busy !== null}
onClick={() => finishOnce(exitDestination)}
>
{text(
exitDestination === "sources" ? "仍进入应用页" : "仍进入工作台",
)}
{exitDestination === "sources" ? "仍进入应用页" : "仍进入工作台"}
</R6Button>
<R6Button
variant="primary"
disabled={busy !== null}
onClick={() => void leave(exitDestination)}
>
{text("重试保存并进入")}
重试保存并进入
</R6Button>
</>
}
>
<p className="onboarding-r6-error" role="alert">
{error && text(error)}
{error}
</p>
</R6ColdStartFrame>
);
@@ -756,8 +740,8 @@ export function OnboardingFlow({
return (
<R6ColdStartFrame
step={1}
title={text("把熟悉的背景,带到这里")}
eyebrow={text("你的记忆,从这里接上")}
title="把熟悉的背景,带到这里"
eyebrow="你的记忆,从这里接上"
footer={
<>
<R6Button
@@ -765,11 +749,11 @@ export function OnboardingFlow({
disabled={locked}
onClick={() => void leave("sources")}
>
{text("稍后添加,先进入")}
稍后添加,先进入
</R6Button>
{error && (
<p className="onboarding-r6-error" role="alert">
{error && text(error)}
{error}
</p>
)}
<R6Button
@@ -778,7 +762,7 @@ export function OnboardingFlow({
disabled={locked || count === 0}
onClick={() => void startImport()}
>
{text(busy === "submitting" ? "正在开始导入…" : "开始导入")}
{busy === "submitting" ? "正在开始导入…" : "开始导入"}
</R6Button>
</>
}
@@ -789,35 +773,33 @@ export function OnboardingFlow({
aria-labelledby="cold-start-phase"
>
<div className="onboarding-r6-intro">
<p>
{text("不用从头介绍自己。从常用的 App 开始,把已有背景带过来。")}
</p>
<p>{text("确认前只检查应用信息,不读取对话,也不安装连接。")}</p>
<p>不用从头介绍自己。从常用的 App 开始,把已有背景带过来。</p>
<p>确认前只检查应用信息,不读取对话,也不安装连接。</p>
</div>
<div className="onboarding-r6-list-heading">
<h2 id="cold-start-phase" ref={heading} tabIndex={-1}>
{text("本机 AI 应用")}
本机 AI 应用
</h2>
<button
type="button"
className="onboarding-r6-quiet"
aria-label={text("重新检测位置")}
aria-label="重新检测位置"
disabled={locked}
onClick={() => void checkLocations()}
>
{text(busy === "checking" ? "检测中…" : "重新检测")}
{busy === "checking" ? "检测中…" : "重新检测"}
</button>
</div>
<SourceList
className="onboarding-r6-list"
aria-label={text("候选应用")}
aria-label="候选应用"
aria-busy={busy === "checking"}
count={visibleApps.length}
status={
locationCheck === "checking"
? text("正在检测其他应用…")
? "正在检测其他应用…"
: locationCheck === "failed"
? text("检测未完成")
? "检测未完成"
: undefined
}
selection={
@@ -827,7 +809,7 @@ export function OnboardingFlow({
disabled={locked || allSelected || readableApps.length === 0}
onClick={selectAllSources}
>
{text(allSelected ? "已全选" : "全选")}
{allSelected ? "已全选" : "全选"}
</button>
}
>
@@ -836,9 +818,7 @@ export function OnboardingFlow({
<label>
<input
type="checkbox"
aria-label={t("onboardingImportHistory", {
name: visibleApps[0].display_name,
})}
aria-label={`导入 ${visibleApps[0].display_name} 历史`}
checked
disabled
readOnly
@@ -846,11 +826,11 @@ export function OnboardingFlow({
<R6SourceIcon id={visibleApps[0].source_id} />
<span className="onboarding-r6-row-info">
<strong>{visibleApps[0].display_name}</strong>
<small>{text("正在核对本机保存位置")}</small>
<small>正在核对本机保存位置</small>
</span>
<span className="onboarding-r6-badge" data-found="true">
<i aria-hidden="true" />
{text("已发现")}
已发现
</span>
</label>
</li>
@@ -862,7 +842,6 @@ export function OnboardingFlow({
);
const note =
locationCheck === "ready" ? locationNote(item) : null;
const translatedNote = note ? text(note) : null;
return (
<li
className={`onboarding-r6-row${checked ? " is-selected" : ""}`}
@@ -874,9 +853,7 @@ export function OnboardingFlow({
<label>
<input
type="checkbox"
aria-label={t("onboardingImportHistory", {
name: item.display_name,
})}
aria-label={`导入 ${item.display_name} 历史`}
checked={checked}
disabled={
locked || locationCheck !== "ready" || !canRead(item)
@@ -888,13 +865,13 @@ export function OnboardingFlow({
<R6SourceIcon id={item.source_id} />
<span className="onboarding-r6-row-info">
<strong>{item.display_name}</strong>
<small title={translatedNote ?? item.candidate_path}>
{item.candidate_path || text("位置待核对")} ·{" "}
{text(pathKind(item))}
<small title={note ?? item.candidate_path}>
{item.candidate_path || "位置待核对"} ·{" "}
{pathKind(item)}
</small>
{itemErrors[item.source_id] && (
<small className="onboarding-r6-error">
{failureLabel(itemErrors[item.source_id], text, t)}
{failureLabel(itemErrors[item.source_id])}
</small>
)}
</span>
@@ -904,10 +881,10 @@ export function OnboardingFlow({
locationCheck === "ready" &&
item.location_state === "found"
}
title={translatedNote ?? undefined}
title={note ?? undefined}
>
<i aria-hidden="true" />
{text(locationLabel(item, locationCheck))}
{locationLabel(item, locationCheck)}
</span>
</label>
</li>
@@ -930,30 +907,28 @@ export function OnboardingFlow({
{!visibleApps.length && busy !== "checking" && (
<li className="onboarding-r6-empty">
{locationCheck === "ready"
? text("当前没有候选来源。可以稍后在应用页添加历史包。")
: text("候选来源尚未加载。可以检测位置,或先进入工作台。")}
? "当前没有候选来源。可以稍后在应用页添加历史包。"
: "候选来源尚未加载。可以检测位置,或先进入工作台。"}
</li>
)}
</SourceList>
</section>
<aside className="onboarding-r6-summary" aria-label={text("本次导入")}>
<p>{text("本次导入")}</p>
<aside className="onboarding-r6-summary" aria-label="本次导入">
<p>本次导入</p>
<p
className="onboarding-r6-count"
aria-live="polite"
data-unknown={locationCheck !== "ready"}
>
{text(summaryCount)}
{summaryCount}
</p>
<p>{text("个 App 的历史对话")}</p>
<p>个 App 的历史对话</p>
<h2>
{text("把零散的会话,")}
把零散的会话,
<br />
{text("汇成完整的记忆")}
汇成完整的记忆
</h2>
<p className="onboarding-r6-signoff">
{text("少一些重复交代,多一些理解。")}
</p>
<p className="onboarding-r6-signoff">少一些重复交代,多一些理解。</p>
</aside>
</div>
{custom && (
@@ -991,7 +966,6 @@ function SourceList({
status?: string;
selection: ReactNode;
}) {
const { t, tnPlain } = useOnboardingTranslation();
const list = useRef<HTMLUListElement>(null);
const [more, setMore] = useState(0);
const measure = useCallback(() => {
@@ -1040,9 +1014,9 @@ function SourceList({
<span data-scroll-hint={!status && more > 0 ? "true" : "false"}>
{status ??
(more > 0
? tnPlain("onboardingMoreApps", more)
? `向下查看更多 · 还有 ${more} 个应用`
: count > 3
? t("onboardingAllApps", { count })
? `已显示全部 ${count} 个应用`
: "")}
</span>
{selection}
@@ -1140,11 +1114,7 @@ function locationNote(source: SourceResponse) {
return null;
}
function failureLabel(
reason: string,
text: (value: string) => string,
t: ReturnType<typeof useOnboardingTranslation>["t"],
) {
function failureLabel(reason: string) {
const labels: Record<string, string> = {
source_unavailable: "来源目录暂时不可读取,请核对位置和权限。",
source_not_available: "来源目录暂时不可读取,请核对位置和权限。",
@@ -1154,9 +1124,7 @@ function failureLabel(
environment_override_confirmation_required: "自定义来源位置需要重新确认。",
imports_disabled: "当前 Core 未启用历史导入。",
};
return labels[reason]
? text(labels[reason])
: t("onboardingImportRefused", { reason });
return labels[reason] ?? `尚未获准导入(${reason})。请核对来源后重试。`;
}
// S1.3 supplied app marks; WorkBuddy keeps the source geometry with flat fills.
@@ -1181,7 +1149,6 @@ function CustomLocationDialog({
onCancel(): void;
onConfirm(): void;
}) {
const { text, t } = useOnboardingTranslation();
const [confirmed, setConfirmed] = useState(false);
const panel = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
@@ -1266,12 +1233,12 @@ function CustomLocationDialog({
tabIndex={-1}
>
<header>
<h2 id="cold-start-custom-title">{text("确认自定义位置")}</h2>
<h2 id="cold-start-custom-title">确认自定义位置</h2>
<button
type="button"
className="cold-start-icon-button"
aria-label={text("关闭位置确认")}
title={text("关闭位置确认")}
aria-label="关闭位置确认"
title="关闭位置确认"
onClick={onCancel}
>
<X size={18} aria-hidden="true" />
@@ -1279,9 +1246,7 @@ function CustomLocationDialog({
</header>
<div className="cold-start-dialog-body">
<p id="cold-start-custom-description">
{text(
"此位置来自当前配置,并非应用默认目录。确认位置不会开始读取历史。",
)}
此位置来自当前配置,并非应用默认目录。确认位置不会开始读取历史。
</p>
<strong>{source.display_name}</strong>
<code>{source.candidate_path}</code>
@@ -1291,9 +1256,7 @@ function CustomLocationDialog({
checked={confirmed}
onChange={(event) => setConfirmed(event.target.checked)}
/>
<span>
{t("onboardingConfirmHistory", { name: source.display_name })}
</span>
<span>确认这是 {source.display_name} 的历史位置</span>
</label>
</div>
<footer>
@@ -1302,7 +1265,7 @@ function CustomLocationDialog({
className="cold-start-secondary"
onClick={onCancel}
>
{text("取消")}
取消
</button>
<button
type="button"
@@ -1312,7 +1275,7 @@ function CustomLocationDialog({
}}
>
<Check size={16} aria-hidden="true" />
{text("确认此位置")}
确认此位置
</button>
</footer>
</div>
@@ -9,7 +9,6 @@ import {
type OnboardingModelDraft,
OnboardingModelStep,
} from "./OnboardingModelStep";
import { useOnboardingTranslation } from "./onboarding-language";
import { PROVIDER_PRESETS } from "./provider-presets";
import { traceUiAction } from "./ui-trace";
import { useModelProfiles } from "./use-model-profiles";
@@ -66,7 +65,6 @@ export function OnboardingModelController({
onEnabled?(): void;
connectionAlreadyApplied?: boolean;
}) {
const { text, t } = useOnboardingTranslation();
const models = useModelProfiles();
const [draft, setDraft] = useState(initial);
const [advanced, setAdvanced] = useState(false);
@@ -121,7 +119,7 @@ export function OnboardingModelController({
value < min ||
value > max
)
next[key] = "onboardingIntegerRange";
next[key] = `请输入 ${min} 至 ${max} 的整数。`;
return value;
};
const timeout = integer("timeout_seconds", 1, 600);
@@ -271,22 +269,8 @@ export function OnboardingModelController({
canTest={!models.pendingProbe && !models.probeStorageFailed}
canSaveDraft={!models.pendingCreation && !models.creationStorageFailed}
inputsLocked={flight.current}
fieldErrors={Object.fromEntries(
Object.entries(fields).map(([key, value]) => [
key,
value === "onboardingIntegerRange"
? t(
"onboardingIntegerRange",
key === "timeout_seconds"
? { min: 1, max: 600 }
: key === "max_concurrency"
? { min: 1, max: 32 }
: { min: 1024, max: 10_000_000 },
)
: text(value),
]),
)}
error={error ?? (models.error ? text(models.error) : null)}
fieldErrors={fields}
error={error ?? models.error}
probe={probe}
profile={
saved
@@ -9,93 +9,12 @@ import {
within,
} from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import {
OnboardingModelStep,
type OnboardingModelStepProps,
} from "./OnboardingModelStep";
afterEach(() => {
cleanup();
localStorage.clear();
});
it.each(["es", "zh-CN"] as const)(
"keeps %s footer actions, pending locks and permission boundaries",
async (locale) => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
const p = props();
p.onSkip = vi.fn();
let finish!: () => void;
p.onTest = vi.fn(
() =>
new Promise<void>((resolve) => {
finish = resolve;
}),
);
const labels =
locale === "es"
? [
"Omitir por ahora",
"Probar conexión",
"Confirmar y habilitar",
"Probando el modelo…",
]
: ["跳过,先进入应用", "测试模型连通性", "确认启用", "正在测试模型…"];
const draw = (probe?: OnboardingModelStepProps["probe"]) => (
<LanguageProvider>
<OnboardingModelStep
{...p}
capability={{ saveAndEnable: true }}
probe={probe}
advancedOpen
/>
</LanguageProvider>
);
const view = render(draw());
const skip = screen.getByRole("button", { name: labels[0] });
if (locale === "es") {
expect(
document.getElementById(skip.getAttribute("aria-describedby") ?? "")
?.textContent,
).toContain("Aplicaciones");
}
fireEvent.click(skip);
expect(p.onSkip).toHaveBeenCalledOnce();
expect(p.onBack).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: labels[2] })).toHaveProperty(
"disabled",
true,
);
fireEvent.click(screen.getByRole("button", { name: labels[1] }));
expect(screen.getByRole("button", { name: labels[3] })).toHaveProperty(
"disabled",
true,
);
expect(skip).toHaveProperty("disabled", true);
expect(p.onSaveAndEnable).not.toHaveBeenCalled();
await act(async () => finish());
view.rerender(draw({ state: "failed", message: "synthetic failure" }));
expect(screen.getByRole("button", { name: labels[2] })).toHaveProperty(
"disabled",
true,
);
expect(screen.getByLabelText("API Key")).toHaveProperty("value", "secret");
expect(p.onDraftChange).not.toHaveBeenCalled();
view.rerender(draw({ state: "passed", message: "synthetic success" }));
fireEvent.click(screen.getByRole("button", { name: labels[2] }));
await act(async () => {});
expect(p.onSaveAndEnable).toHaveBeenCalledOnce();
expect(p.onSaveDraft).not.toHaveBeenCalled();
expect(
screen.getByText(
locale === "es"
? /La prueba de conexión solo envía/
: /测试连接只发送测试内容/,
),
).toBeTruthy();
},
);
afterEach(cleanup);
function props(): OnboardingModelStepProps {
return {
draft: {
+81 -132
View File
@@ -2,7 +2,6 @@ import type { ProviderKind } from "@one-memory/contracts";
import { ChevronDown, ChevronUp } from "lucide-react";
import { useEffect, useId, useRef, useState } from "react";
import { OptionMenu } from "./OptionMenu";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6ColdStartFrame } from "./R6ColdStartFrame";
import { R6Button } from "./R6Controls";
import "./onboarding-model-r6.css";
@@ -72,13 +71,8 @@ export interface OnboardingModelStepProps {
}
export function OnboardingModelStep(props: OnboardingModelStepProps) {
const { text, t, locale } = useOnboardingTranslation();
const { draft, advancedOpen, onDraftChange, fieldErrors = {}, phase } = props;
const id = useId();
// The Chinese label already names the destination; shorter translated labels
// need it as a description for assistive technology.
const skipDestination =
locale === "zh-CN" ? "" : t("onboardingModelSkipDestination");
const fields = useRef<HTMLElement>(null);
const advancedToggle = useRef<HTMLButtonElement>(null);
const restoreToggleFocus = useRef(false);
@@ -119,10 +113,7 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
const sourceSummary =
props.sourceNames.length <= 3
? props.sourceNames.join(" · ")
: t("onboardingSourcesSummary", {
names: props.sourceNames.slice(0, 3).join(" · "),
count: props.sourceNames.length,
});
: `${props.sourceNames.slice(0, 3).join(" · ")} 等 ${props.sourceNames.length} 个应用`;
async function run(action: Action) {
if (flight.current || locked) return;
if (action === "save-and-enable" && !canEnable) return;
@@ -162,11 +153,11 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
const error = fieldErrors[key];
return (
<label className="onboarding-model-field" htmlFor={`${id}-${key}`}>
<span>{text(label)}</span>
<span>{label}</span>
<input
id={`${id}-${key}`}
name={key}
aria-label={text(label)}
aria-label={label}
type={options.type ?? "text"}
autoComplete="off"
spellCheck={false}
@@ -178,9 +169,7 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
max={options.max}
maxLength={options.maxLength}
required={options.required}
placeholder={
options.placeholder ? text(options.placeholder) : undefined
}
placeholder={options.placeholder}
onChange={(event) => {
if (!flight.current && !fieldsLocked)
onDraftChange({ [key]: event.target.value });
@@ -188,7 +177,7 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
/>
{error && (
<small id={`${id}-${key}-error`} className="onboarding-model-error">
{text(error)}
{error}
</small>
)}
</label>
@@ -226,57 +215,43 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
>
<R6ColdStartFrame
step={3}
eyebrow={text("沉淀记忆 · 使用你选择的模型")}
title={text(pageTitle)}
eyebrow="沉淀记忆 · 使用你选择的模型"
title={pageTitle}
footer={
<>
<p className="onboarding-model-footer-note">
{text(
!props.capability.saveAndEnable
? "当前无法启用模型,已填写内容保留。"
: props.probe?.state === "passed"
? "模型连接测试通过,可以启用并开始沉淀记忆。"
: "先测试模型连通性;测试通过后再启用。",
)}
{!props.capability.saveAndEnable
? "当前无法启用模型,已填写内容保留。"
: props.probe?.state === "passed"
? "模型连接测试通过,可以启用并开始沉淀记忆。"
: "先测试模型连通性;测试通过后再启用。"}
</p>
<div className="onboarding-model-footer-actions">
{skipDestination && (
<span id={`${id}-skip-destination`} hidden>
{skipDestination}
</span>
)}
<R6Button
aria-describedby={
skipDestination ? `${id}-skip-destination` : undefined
}
disabled={locked}
onClick={() => {
if (!flight.current && !locked)
(props.onSkip ?? props.onBack)();
}}
>
{text("跳过,先进入应用")}
跳过,先进入应用
</R6Button>
<R6Button
disabled={locked || !props.canTest}
onClick={() => void run("test")}
>
{text(
displayedPhase === "testing"
? "正在测试模型…"
: (props.testActionLabel ?? "测试模型连通性"),
)}
{displayedPhase === "testing"
? "正在测试模型…"
: (props.testActionLabel ?? "测试模型连通性")}
</R6Button>
<R6Button
variant="primary"
disabled={locked || !canEnable}
onClick={() => void run("save-and-enable")}
>
{text(
displayedPhase === "enabling"
? "正在启用…"
: (props.enableActionLabel ?? "确认启用"),
)}
{displayedPhase === "enabling"
? "正在启用…"
: (props.enableActionLabel ?? "确认启用")}
</R6Button>
</div>
</>
@@ -285,29 +260,27 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
<div className="onboarding-model-layout">
<section
className="onboarding-model-form"
aria-label={text("模型配置")}
aria-label="模型配置"
data-advanced={advancedOpen}
>
<section
ref={fields}
id={`${id}-fields`}
className="onboarding-model-fields"
aria-label={text("模型参数")}
aria-label="模型参数"
// biome-ignore lint/a11y/noNoninteractiveTabindex: The parameter pane needs keyboard scrolling.
tabIndex={0}
>
{!advancedOpen ? (
<>
<p className="onboarding-model-intro">
{text("选择服务商,填入 API Key。核对已有配置后再继续。")}
选择服务商,填入 API Key。核对已有配置后再继续。
</p>
<label
className="onboarding-model-field onboarding-model-provider"
htmlFor={`${id}-provider`}
>
<span id={`${id}-provider-label`}>
{text("模型服务商")}
</span>
<span id={`${id}-provider-label`}>模型服务商</span>
<OptionMenu
id={`${id}-provider`}
labelledBy={`${id}-provider-label`}
@@ -315,11 +288,11 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
disabled={fieldsLocked}
options={[
...(!provider
? [{ value: "", label: text("选择服务商") }]
? [{ value: "", label: "选择服务商" }]
: []),
...props.providers.map((item) => ({
value: item.id,
label: text(item.label),
label: item.label,
disabled: !item.supported,
})),
]}
@@ -344,15 +317,13 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
className="onboarding-model-key-note"
data-state={connectionFailed ? "failed" : "idle"}
>
{text(
connectionFailed
? "连接未通过,请检查 API Key、模型与接口地址后重试。配置已保留。"
: props.profile?.credential_state === "unavailable"
? "需要允许 MemHub 访问钥匙串。系统询问时请选择“始终允许”。"
: props.profile?.has_configured_api_key
? "留空保留已保存密钥;填写新密钥后需重新测试。"
: "密钥保存在系统安全存储中,不写入对话或日志。",
)}
{connectionFailed
? "连接未通过,请检查 API Key、模型与接口地址后重试。配置已保留。"
: props.profile?.credential_state === "unavailable"
? "需要允许 MemHub 访问钥匙串。系统询问时请选择“始终允许”。"
: props.profile?.has_configured_api_key
? "留空保留已保存密钥;填写新密钥后需重新测试。"
: "密钥保存在系统安全存储中,不写入对话或日志。"}
</p>
<button
ref={advancedToggle}
@@ -363,27 +334,24 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
disabled={fieldsLocked}
onClick={() => changeAdvanced(true)}
>
{text("配置详情与高级设置")}{" "}
配置详情与高级设置{" "}
<ChevronDown size={16} aria-hidden="true" />
</button>
<p className="onboarding-model-profile">
{provider ? text(provider.label) : text("服务商待选择")} ·{" "}
{draft.model || text("模型待填写")}
{provider?.label ?? "服务商待选择"} ·{" "}
{draft.model || "模型待填写"}
<br />
{text(safeEndpoint(draft.base_url))} ·{" "}
{safeEndpoint(draft.base_url)} ·{" "}
{draft.embedding_mode === "local"
? text("本地向量模型")
: draft.embedding_model || text("向量模型待填写")}{" "}
·{" "}
{text(draft.disable_thinking ? "思考已关闭" : "思考未关闭")}
? "本地向量模型"
: draft.embedding_model || "向量模型待填写"}{" "}
· {draft.disable_thinking ? "思考已关闭" : "思考未关闭"}
</p>
</>
) : (
<>
<div className="onboarding-model-advanced-head">
<p>
{text("预设已填好,只有自定义服务或特殊需求才需要修改。")}
</p>
<p>预设已填好,只有自定义服务或特殊需求才需要修改。</p>
<button
ref={advancedToggle}
type="button"
@@ -393,8 +361,7 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
disabled={fieldsLocked}
onClick={() => changeAdvanced(false)}
>
{text("返回服务商选择")}{" "}
<ChevronUp size={16} aria-hidden="true" />
返回服务商选择 <ChevronUp size={16} aria-hidden="true" />
</button>
</div>
<div className="onboarding-model-advanced-grid">
@@ -423,17 +390,15 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
className="onboarding-model-key-note"
data-state={connectionFailed ? "failed" : "idle"}
>
{text(
props.profile?.has_configured_api_key
? "留空保留已保存密钥;填写新密钥后需重新测试。"
: "密钥保存在系统安全存储中,不写入对话或日志。",
)}
{props.profile?.has_configured_api_key
? "留空保留已保存密钥;填写新密钥后需重新测试。"
: "密钥保存在系统安全存储中,不写入对话或日志。"}
</p>
</div>
<label className="onboarding-model-field">
<span>{text("协议")}</span>
<span>协议</span>
<select
aria-label={text("协议")}
aria-label="协议"
value={draft.provider_kind}
disabled={fieldsLocked}
onChange={(e) => {
@@ -448,17 +413,17 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
</option>
<option value="ollama">Ollama</option>
<option value="anthropic" disabled>
{text("Anthropic(暂不支持)")}
Anthropic(暂不支持)
</option>
<option value="hy_default" disabled>
{text("HY 托管(当前不可用)")}
HY 托管(当前不可用)
</option>
</select>
</label>
<label className="onboarding-model-field">
<span>{text("向量模型")}</span>
<span>向量模型</span>
<select
aria-label={text("向量模型")}
aria-label="向量模型"
value={draft.embedding_mode}
disabled={fieldsLocked}
onChange={(e) => {
@@ -470,10 +435,8 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
});
}}
>
<option value="local">
{text("本地 ONNX · 内置")}
</option>
<option value="custom">{text("自定义向量")}</option>
<option value="local">本地 ONNX · 内置</option>
<option value="custom">自定义向量</option>
</select>
</label>
<div className="onboarding-model-numbers">
@@ -506,14 +469,12 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
});
}}
/>
{text("关闭思考模式")}
关闭思考模式
</label>
<p className="onboarding-model-vector-note">
{text(
"自定义向量配置可展开;远程向量服务须遵守凭据边界。",
)}
自定义向量配置可展开;远程向量服务须遵守凭据边界。
<br />
{text("切换服务商或模型后,需要重新测试并启用。")}
切换服务商或模型后,需要重新测试并启用。
</p>
{draft.embedding_mode === "custom" && (
<>
@@ -522,9 +483,9 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
maxLength: 256,
})}
<label className="onboarding-model-field">
<span>{text("向量协议")}</span>
<span>向量协议</span>
<select
aria-label={text("向量协议")}
aria-label="向量协议"
value={draft.embedding_provider_kind}
disabled={fieldsLocked}
onChange={(e) => {
@@ -537,11 +498,9 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
}}
>
<option value="openai_compatible">
{text("同源 OpenAI-compatible(共用 Key)")}
</option>
<option value="ollama">
{text("本机 Ollama")}
同源 OpenAI-compatible(共用 Key)
</option>
<option value="ollama">本机 Ollama</option>
</select>
</label>
<div className="onboarding-model-wide">
@@ -550,9 +509,7 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
required: true,
})}
<small>
{text(
"远程向量地址须与生成服务同源;本机地址仍需校验。",
)}
远程向量地址须与生成服务同源;本机地址仍需校验。
</small>
</div>
</>
@@ -567,7 +524,7 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
disabled={locked}
onClick={() => void props.onRecoverCreation?.()}
>
{text("核对创建结果")}
核对创建结果
</R6Button>
)}
{props.onDiscardMissingCreation && (
@@ -576,19 +533,17 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
disabled={locked}
onClick={() => void props.onDiscardMissingCreation?.()}
>
{text("放弃旧记录,重新配置")}
放弃旧记录,重新配置
</R6Button>
)}
</div>
<div className="onboarding-model-feedback" tabIndex={-1}>
{(displayedPhase === "testing" ||
displayedPhase === "enabling") && (
<p role="status" aria-label={text("模型提交状态")}>
{text(
displayedPhase === "testing"
? "正在测试连接…"
: "正在提交保存与启用,请等待回执…",
)}
<p role="status" aria-label="模型提交状态">
{displayedPhase === "testing"
? "正在测试连接…"
: "正在提交保存与启用,请等待回执…"}
</p>
)}
{props.probe && (
@@ -597,60 +552,54 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
data-state={props.probe.state}
role="status"
>
{text(props.probe.message)}
{props.probe.message}
</p>
)}
{(props.error || callbackFailed) && (
<p className="onboarding-model-error" role="alert">
{text(
props.error ||
"操作结果未确认,已填写内容保留,请核对后重试。",
)}
{props.error ||
"操作结果未确认,已填写内容保留,请核对后重试。"}
</p>
)}
</div>
</section>
<aside
className="onboarding-model-summary"
aria-label={text("本次沉淀")}
aria-label="本次沉淀"
data-full-height={advancedOpen || connectionFailed}
// biome-ignore lint/a11y/noNoninteractiveTabindex: Keyboard users must be able to scroll the full permission disclosure.
tabIndex={0}
>
<div className="onboarding-model-summary-heading">
<p>{text("本次沉淀")}</p>
<h2>{text("让记忆从这里开始")}</h2>
<p>本次沉淀</p>
<h2>让记忆从这里开始</h2>
</div>
<div className="onboarding-model-connections">
<p>{text("对话来源")}</p>
<strong>{sourceSummary || text("尚无已选来源")}</strong>
<p>对话来源</p>
<strong>{sourceSummary || "尚无已选来源"}</strong>
<small>
{t("onboardingImportedConversations", {
count: props.importedConversationCount ?? 0,
})}
已导入 {props.importedConversationCount ?? 0}{" "}
条对话;来源开关继续控制之后同步到本机的内容。
</small>
</div>
<div className="onboarding-model-summary-status">
{connectionReceipt && (
<small className="onboarding-model-connection-receipt">
{text(connectionReceipt)}
{connectionReceipt}
</small>
)}
<small>
{text("处理服务:")}
{text(provider?.label ?? "待选择")}
处理服务:{provider?.label ?? "待选择"}
<br />
{text("相关对话会发送给所选服务商。")}
相关对话会发送给所选服务商。
</small>
<small>
{text(
"测试连接只发送测试内容。启用后会使用此模型整理已导入和之后同步的会话。",
)}
测试连接只发送测试内容。启用后会使用此模型整理已导入和之后同步的会话。
</small>
</div>
{!props.capability.saveAndEnable && (
<p role="status">
{text(props.capability.reason || "当前不能在此启用模型。")}
{props.capability.reason || "当前不能在此启用模型。"}
</p>
)}
</aside>
+1 -3
View File
@@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useGuiLanguage } from "./gui-language";
export interface OptionMenuItem {
value: string;
@@ -19,7 +18,6 @@ export function OptionMenu(props: {
options: readonly OptionMenuItem[];
onChange: (value: string) => void;
}) {
const { t } = useGuiLanguage();
const [open, setOpen] = useState(false);
const root = useRef<HTMLDivElement>(null);
const selected = props.options.find((item) => item.value === props.value);
@@ -62,7 +60,7 @@ export function OptionMenu(props: {
}
}}
>
{selected?.label ?? t("wb.select")}
{selected?.label ?? "请选择"}
</button>
{open && (
<div className="om-option-menu-panel" role="listbox">
@@ -1,153 +0,0 @@
// @vitest-environment jsdom
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it } from "vitest";
import { LanguageProvider } from "./gui-language";
import { R6ColdStartFrame } from "./R6ColdStartFrame";
afterEach(() => {
cleanup();
localStorage.clear();
});
function declaration(file: string, selector: string): string {
const css = readFileSync(join(process.cwd(), "src", file), "utf8");
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return css.match(new RegExp(`${escaped}\\s*\\{([^}]*)\\}`))?.[1] ?? "";
}
it("lets multiline headings grow above the separator in normal flow", () => {
const head = declaration("onboarding-r6.css", ".onboarding-r6-head");
expect(head).toMatch(/flex:\s*0 0 auto/);
expect(head).toMatch(/display:\s*flex/);
expect(head).toMatch(/flex-wrap:\s*wrap/);
expect(
declaration("onboarding-r6.css", ".onboarding-r6-head .r6-stepper"),
).not.toMatch(/position:\s*absolute/);
});
it("allows step labels to wrap in separate flexible tracks", () => {
const list = declaration("r6-controls.css", ".r6-stepper ol");
const step = declaration("r6-controls.css", ".r6-stepper li");
expect(list).toMatch(
/grid-template-columns:\s*repeat\(3, minmax\(0, 1fr\)\)/,
);
expect(step).toMatch(/white-space:\s*normal/);
expect(step).toMatch(/height:\s*auto/);
expect(step).not.toMatch(/width:\s*84px/);
});
it("sizes the import action to its label and wraps footer content", () => {
const submit = declaration("onboarding-r6.css", ".onboarding-r6-submit");
expect(submit).toMatch(/width:\s*auto/);
expect(submit).toMatch(/min-width:\s*138px/);
expect(declaration("onboarding-r6.css", ".onboarding-r6-foot")).toMatch(
/flex-wrap:\s*wrap/,
);
});
it("keeps progress cards content-sized and the step-two footer in normal flow", () => {
const card = declaration(
"r6-import-progress.css",
".r6-import-progress-card",
);
expect(card).toMatch(/min-height:\s*218px/);
expect(card).not.toMatch(/(?:^|;)\s*height:/);
expect(
declaration(
"first-import-r6.css",
".first-import-r6-main > .r6-import-progress",
),
).toMatch(/flex:\s*0 0 auto/);
expect(declaration("first-import-r6.css", ".first-import-r6-layout")).toMatch(
/height:\s*auto/,
);
expect(
declaration("first-import-r6.css", ".first-import-r6-footer-note"),
).not.toMatch(/max-height|overflow/);
expect(declaration("onboarding-r6.css", ".onboarding-r6-body")).toMatch(
/overflow-y:\s*auto/,
);
});
it("removes the duplicate native drag overlay above the language selector", () => {
expect(
declaration(
"onboarding-r6.css",
".desktop-frame:has(.onboarding-r6) > .window-drag-region",
),
).toMatch(/display:\s*none/);
expect(
declaration(
"onboarding-r6.css",
".onboarding-r6-titlebar .r6-language-selector--compact",
),
).toMatch(/-webkit-app-region:\s*no-drag/);
});
it("keeps selection status outside decorative fades and readable when disabled", () => {
expect(
declaration("onboarding-r6.css", ".onboarding-r6-list-area::after"),
).toBe("");
expect(
declaration(
"onboarding-r6.css",
".onboarding-r6-list-status .onboarding-r6-quiet:disabled",
),
).toMatch(/opacity:\s*1/);
});
it("keeps the native selector keyboard-focusable and switches both languages", async () => {
render(
<LanguageProvider>
<R6ColdStartFrame
step={1}
title="Fixture"
eyebrow="Fixture"
footer={null}
>
<p>Fixture body</p>
</R6ColdStartFrame>
</LanguageProvider>,
);
const user = userEvent.setup();
const selector = screen.getByRole("combobox");
await user.tab();
expect(document.activeElement).toBe(selector);
await user.selectOptions(selector, "es");
expect(document.documentElement.lang).toBe("es");
expect(screen.getByRole("combobox").getAttribute("id")).toBe(selector.id);
await user.selectOptions(selector, "zh-CN");
expect(document.documentElement.lang).toBe("zh-CN");
expect(screen.getByRole("combobox", { name: "界面语言" })).toBe(selector);
});
it.each([1, 2, 3] as const)(
"offers a compact language selector on step %s",
(step) => {
render(
<LanguageProvider>
<R6ColdStartFrame
step={step}
title="Fixture"
eyebrow="Fixture"
footer={null}
>
<p>Fixture body</p>
</R6ColdStartFrame>
</LanguageProvider>,
);
const selector = screen.getByRole("combobox", { name: "界面语言" });
expect(selector.closest(".r6-language-selector--compact")).toBeTruthy();
expect(
screen.getByRole("option", { name: "简体中文" }).getAttribute("lang"),
).toBe("zh-CN");
expect(
screen.getByRole("option", { name: "Español" }).getAttribute("lang"),
).toBe("es");
expect(screen.queryByRole("heading", { name: "界面语言" })).toBeNull();
expect(selector.getAttribute("aria-describedby")).toBeNull();
},
);
+3 -7
View File
@@ -1,5 +1,4 @@
import type { ReactNode } from "react";
import { LanguageSelector } from "./LanguageSelector";
import { R6LocalStorage, R6Stepper } from "./R6Controls";
import "./onboarding-r6.css";
@@ -22,12 +21,9 @@ export function R6ColdStartFrame({
left of this bar and a "第一次见面" caption on the right. The user
removed both deliberately on 2026-09-20 — this is a product decision,
not a missing implementation, so do not "restore" them when comparing
against the canvas. The bar itself stays: it is the window drag
region (at least 58px) and reserves room for native traffic lights.
The language control opts out of dragging. */}
<header className="onboarding-r6-titlebar">
<LanguageSelector compact />
</header>
against the canvas. The bar itself stays: it is the 58px window drag
region and reserves room for the native traffic lights. */}
<header className="onboarding-r6-titlebar" />
<div className="onboarding-r6-stage">
<section className="onboarding-r6-shell" data-step={step}>
<header className="onboarding-r6-head">
+4 -9
View File
@@ -3,7 +3,6 @@ import {
type InputHTMLAttributes,
useId,
} from "react";
import { useOnboardingTranslation } from "./onboarding-language";
import "./r6-controls.css";
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
@@ -36,16 +35,15 @@ export function R6Button({
}
export function R6Stepper({ active }: { active: 1 | 2 | 3 }) {
const { text } = useOnboardingTranslation();
return (
<nav className="r6-stepper" aria-label={text("首次设置进度")}>
<nav className="r6-stepper" aria-label="首次设置进度">
<ol>
{["导入历史", "连接应用", "沉淀记忆"].map((label, index) => (
<li
key={label}
aria-current={active === index + 1 ? "step" : undefined}
>
<span aria-hidden="true">0{index + 1}</span> {text(label)}
<span aria-hidden="true">0{index + 1}</span> {label}
</li>
))}
</ol>
@@ -117,16 +115,13 @@ export function R6Field({
}
export function R6LocalStorage() {
const { text } = useOnboardingTranslation();
return (
<span
className="r6-local-storage"
title={text(
"对话与记忆保存在本机;使用云模型或已授权的助手时,相关内容会发送给对应服务。",
)}
title="对话与记忆保存在本机;使用云模型或已授权的助手时,相关内容会发送给对应服务。"
>
<span aria-hidden="true" />
{text("本地存储")}
本地存储
</span>
);
}
+22 -45
View File
@@ -1,37 +1,21 @@
import type { ImportJob, SourceResponse } from "@one-memory/contracts";
import { useEffect, useLayoutEffect, useRef } from "react";
import { AppMark } from "./app-mark";
import { useOnboardingTranslation } from "./onboarding-language";
import "./r6-import-progress.css";
type Translate = ReturnType<typeof useOnboardingTranslation>["t"];
function progressText(
job: ImportJob,
t: Translate,
locale: string,
): string | null {
if (job.stored > 0)
return t("onboardingSavedMessages", {
count: job.stored.toLocaleString(locale),
});
function progressText(job: ImportJob): string | null {
if (job.stored > 0) return `已保存 ${job.stored.toLocaleString()} 条消息`;
if (job.discovered <= 0) return null;
const count = job.discovered.toLocaleString(locale);
const scanned = `已扫描 ${job.discovered.toLocaleString()} 条`;
if (job.status === "completed")
return t(
job.history_after
? "onboardingScannedOutsideRange"
: "onboardingScannedNothingToSave",
{ count },
);
return job.history_after
? `${scanned},本次没有符合时间范围的对话`
: `${scanned},本次没有可保存的对话`;
if (job.status === "pending" || job.status === "running")
return t(
job.history_after
? "onboardingScannedFilteringRecent"
: "onboardingScannedFiltering",
{ count },
);
return t("onboardingScannedNotSaved", { count });
return job.history_after
? `${scanned},正在筛选最近 3 天`
: `${scanned},正在筛选`;
return `${scanned},尚未保存`;
}
export function R6ImportProgress({
@@ -45,7 +29,6 @@ export function R6ImportProgress({
jobs: ImportJob[];
stale?: boolean;
}) {
const { text, t, locale } = useOnboardingTranslation();
const scroller = useRef<HTMLElement>(null);
const userScrollLeft = useRef(0);
const userScrollIntent = useRef(false);
@@ -73,7 +56,7 @@ export function R6ImportProgress({
return (
<section
className="r6-import-progress"
aria-label={text("本次应用导入进度")}
aria-label="本次应用导入进度"
data-count={ids.length <= 2 ? String(ids.length) : "many"}
ref={scroller}
onPointerDown={markUserScrollIntent}
@@ -120,14 +103,12 @@ export function R6ImportProgress({
cancelled: "已取消",
completed: "导入完成",
};
const progress = job ? progressText(job, t, locale) : null;
const progress = job ? progressText(job) : null;
return (
<article
className="r6-import-progress-card"
key={id}
aria-label={t("onboardingAppProgress", {
name: source?.display_name ?? id,
})}
aria-label={`${source?.display_name ?? id} 导入进度`}
data-running={running ? "true" : "false"}
>
<div className="r6-import-progress-orbit" aria-hidden="true">
@@ -140,22 +121,18 @@ export function R6ImportProgress({
{progress ? (
<p>{progress}</p>
) : noImportedHistory ? (
<p>{text("没有可保存的历史对话")}</p>
<p>没有可保存的历史对话</p>
) : null}
<small role="status">
{text(
stale
? "状态待核对"
: source?.authorized !== true
? "读取授权不可用"
: job
? names[job.status]
: "正在导入",
)}
{stale
? "状态待核对"
: source?.authorized !== true
? "读取授权不可用"
: job
? names[job.status]
: "正在导入"}
{job && job.errors > 0
? t("onboardingReadErrors", {
count: job.errors.toLocaleString(locale),
})
? ` · ${job.errors.toLocaleString()} 条读取异常`
: ""}
</small>
</article>
-26
View File
@@ -5,32 +5,9 @@ import userEvent from "@testing-library/user-event";
import type { Ref } from "react";
import { afterEach, expect, it, vi } from "vitest";
import { getModelProfiles, getSedimentation, setSedimentation } from "./api";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import { queryClient, resetClient } from "./query-client";
import { R6Settings } from "./R6Settings";
it("changes settings copy live without rereading profiles or changing permissions", async () => {
const user = userEvent.setup();
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
const { getModelProfiles } = await import("./api");
render(
<LanguageProvider>
<R6Settings onDiagnostics={vi.fn()} />
</LanguageProvider>,
);
expect(
await screen.findByRole("heading", { name: "Modelo de memorias" }),
).toBeTruthy();
expect(
screen.queryByRole("switch", {
name: "Permitir que el modelo configurado lea las conversaciones locales",
}),
).toBeNull();
const reads = vi.mocked(getModelProfiles).mock.calls.length;
await user.selectOptions(screen.getByRole("combobox"), "zh-CN");
expect(await screen.findByRole("heading", { name: "记忆模型" })).toBeTruthy();
expect(vi.mocked(getModelProfiles).mock.calls).toHaveLength(reads);
});
vi.mock("./api", () => ({
getModelProfiles: vi.fn().mockResolvedValue([
{
@@ -78,7 +55,6 @@ vi.mock("./diagnostics-export", () => ({
}));
afterEach(() => {
cleanup();
localStorage.clear();
resetClient();
vi.clearAllMocks();
window.oneMemory = undefined;
@@ -94,8 +70,6 @@ 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();
expect(screen.getAllByRole("option")).toHaveLength(4);
await user.click(screen.getByRole("button", { name: "帮助与诊断" }));
expect(screen.getByRole("heading", { name: "帮助与诊断" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "导出日志" })).toBeTruthy();
+46 -62
View File
@@ -11,9 +11,7 @@ import {
import { getModelProfiles, setSedimentation } from "./api";
import { ConnectionFallback } from "./ConnectionFallback";
import { exportDiagnosticsFile } from "./diagnostics-export";
import { LanguageSelector } from "./LanguageSelector";
import { ModelSettings, type ModelSettingsActions } from "./ModelSettings";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Button, R6Switch } from "./R6Controls";
import {
refreshLoginPreference,
@@ -57,7 +55,6 @@ export function R6Settings({
showDiagnostics?: boolean;
}) {
const id = useId();
const { t, text, locale } = useOnboardingTranslation();
const groups = showDiagnostics
? [...primaryGroups, "help" as const]
: primaryGroups;
@@ -102,7 +99,7 @@ export function R6Settings({
setExportingLogs(true);
setExportMessage("");
try {
const result = await exportDiagnosticsFile(locale);
const result = await exportDiagnosticsFile();
setExportMessage(result.message);
} finally {
setExportingLogs(false);
@@ -266,11 +263,10 @@ export function R6Settings({
}
return (
<section className="r6-settings" aria-label={t("settings")}>
<h1>{t("settings")}</h1>
<LanguageSelector />
<section className="r6-settings" aria-label="设置">
<h1>设置</h1>
<div className="r6-settings-layout">
<nav aria-label={text("设置分组")}>
<nav aria-label="设置分组">
{groups.map((key) => (
<button
type="button"
@@ -281,7 +277,7 @@ export function R6Settings({
onKeyDown={moveGroupFocus}
onClick={() => setGroup(key)}
>
{text(labels[key])}
{labels[key]}
</button>
))}
</nav>
@@ -292,23 +288,20 @@ export function R6Settings({
hidden={group !== "model"}
className="r6-settings-model"
>
<h2>{text("记忆模型")}</h2>
<h2>记忆模型</h2>
<div className="r6-model-overview">
<p className="r6-model-caption">{text("当前模型")}</p>
<p className="r6-model-caption">当前模型</p>
<h3 className="r6-current-model">
{currentModel?.name ??
text(
profiles === null
? "正在读取模型…"
: modelError
? "模型状态暂不可读"
: "尚未配置模型",
)}
(profiles === null
? "正在读取模型…"
: modelError
? "模型状态暂不可读"
: "尚未配置模型")}
</h3>
{currentModel && (
<p className="r6-model-state">
{currentModel.model} ·{" "}
{text(safeEndpoint(currentModel.base_url))}
{currentModel.model} · {safeEndpoint(currentModel.base_url)}
</p>
)}
<div className="r6-model-actions">
@@ -317,12 +310,12 @@ export function R6Settings({
disabled={profiles === null}
onClick={openConfiguration}
>
{text(currentModel ? "修改模型" : "配置模型")}
{currentModel ? "修改模型" : "配置模型"}
</R6Button>
<R6Button
variant="quiet"
aria-label={text("刷新模型状态")}
title={text("刷新模型状态")}
aria-label="刷新模型状态"
title="刷新模型状态"
onClick={() => setModelVersion((value) => value + 1)}
>
<RefreshCw size={16} aria-hidden="true" />
@@ -330,32 +323,29 @@ export function R6Settings({
</div>
{modelError && (
<p role="alert" className="r6-settings-error">
{text(
profiles === null
? "模型状态未能读取,请重试。"
: "模型状态刷新未成功,仍显示上次确认的模型状态;请重试。",
)}
{profiles === null
? "模型状态未能读取,请重试。"
: "模型状态刷新未成功,仍显示上次确认的模型状态;请重试。"}
</p>
)}
{configurationRefusal && (
<p role="alert" className="r6-settings-error">
{text(
"上次模型测试或创建还待核对,配置暂时不能改动。请先在下方模型配置区核对结果; 现有配置和原文不会被改动。",
)}
上次模型测试或创建还待核对,配置暂时不能改动。请先在下方模型配置区核对结果;
现有配置和原文不会被改动。
</p>
)}
{currentModel && (
<div className="r6-setting-row">
<div>
<h3>{text("用模型沉淀记忆")}</h3>
<h3>用模型沉淀记忆</h3>
<p>
{text(
"开启后,导入和同步到本机的会话会交给当前模型整理成记忆,会产生模型调用。 停止后不再调用模型、不再整理记忆,但会话仍会照常导入和保存;已排队的内容会留着,重新开启后继续。 模型配置始终保留,重新开启不需要重新填写。",
)}
开启后,导入和同步到本机的会话会交给当前模型整理成记忆,会产生模型调用。
停止后不再调用模型、不再整理记忆,但会话仍会照常导入和保存;已排队的内容会留着,重新开启后继续。
模型配置始终保留,重新开启不需要重新填写。
</p>
</div>
<R6Switch
label={text("用模型沉淀记忆")}
label="用模型沉淀记忆"
checked={sedimentation === true}
disabled={
sedimentation === null ||
@@ -370,21 +360,19 @@ export function R6Settings({
)}
{sedimentationError && (
<p role="alert" className="r6-settings-error">
{text(sedimentationError)}
{sedimentationError}
</p>
)}
{sedimentationUnavailable && (
<p role="alert" className="r6-settings-error">
{text(
"当前安装还不能读取这个开关,请确认后台服务已启动后重试。",
)}
当前安装还不能读取这个开关,请确认后台服务已启动后重试。
</p>
)}
</div>
<section
className="r6-model-owner"
hidden={!configurationOpen}
aria-label={text("模型配置表单")}
aria-label="模型配置表单"
>
<ModelSettings
compactSingle
@@ -400,7 +388,7 @@ export function R6Settings({
hidden={group !== "connections"}
className="r6-settings-connections"
>
<h2>{text("连接其他应用")}</h2>
<h2>连接其他应用</h2>
<ConnectionFallback />
</section>
{showDiagnostics && (
@@ -410,14 +398,14 @@ export function R6Settings({
hidden={group !== "help"}
className="r6-settings-help"
>
<h2>{text("帮助与诊断")}</h2>
<h2>帮助与诊断</h2>
<div className="r6-setting-row">
<div>
<h3>{text("登录后启动后台服务")}</h3>
<p>{text("无需打开界面,持续同步、整理和提供记忆。")}</p>
<h3>登录后启动后台服务</h3>
<p>无需打开界面,持续同步、整理和提供记忆。</p>
</div>
<R6Switch
label={text("登录后启动后台服务")}
label="登录后启动后台服务"
checked={login?.enabled === true}
disabled={
!dataReady ||
@@ -430,17 +418,15 @@ export function R6Settings({
</div>
{(dataError || readError) && (
<p role="alert" className="r6-settings-error">
{text(dataError || readError)}
{dataError || readError}
</p>
)}
<div className="r6-setting-row">
<div>
<h3>{text("导出日志")}</h3>
<h3>导出日志</h3>
<p>
{text(
exportMessage ||
"把本机运行日志打包成一个文件,发给开发排查。",
)}
{exportMessage ||
"把本机运行日志打包成一个文件,发给开发排查。"}
</p>
</div>
<R6Button
@@ -449,15 +435,15 @@ export function R6Settings({
onClick={() => void handleExportLogs()}
>
<Download size={16} aria-hidden="true" />
{text("导出日志")}
导出日志
</R6Button>
</div>
<div className="r6-setting-row r6-settings-about">
<div>
<h3>{text("关于")}</h3>
<h3>关于</h3>
<dl>
<div>
<dt>{text("版本")}</dt>
<dt>版本</dt>
<dd>{buildInfo.version}</dd>
</div>
<div>
@@ -511,25 +497,23 @@ export function R6Settings({
}
}}
>
<h2 id={`${id}-quit-title`}>
{text("确定退出 One Memory 界面吗?")}
</h2>
<p>{text("后台服务会继续同步、整理和提供记忆。")}</p>
<h2 id={`${id}-quit-title`}>确定退出 One Memory 界面吗?</h2>
<p>后台服务会继续同步、整理和提供记忆。</p>
{quitError && (
<p role="alert" className="r6-settings-error">
{text(quitError)}
{quitError}
</p>
)}
<footer>
<R6Button disabled={quitting} onClick={() => setQuitOpen(false)}>
{text("取消")}
取消
</R6Button>
<R6Button
variant="primary"
pending={quitting}
onClick={() => void quitApplication()}
>
{text("退出界面")}
退出界面
</R6Button>
</footer>
</section>
+1 -21
View File
@@ -18,7 +18,6 @@ vi.mock("./api", () => ({
updateSdkMemory,
}));
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import { SdkMemoryLibrary } from "./SdkMemoryLibrary";
const memory = {
@@ -53,26 +52,7 @@ describe("SDK memory library", () => {
);
deleteSdkMemory.mockResolvedValue(undefined);
});
afterEach(() => {
cleanup();
localStorage.clear();
});
it("localizes controls while leaving memory content unchanged", async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
render(
<LanguageProvider>
<SdkMemoryLibrary active />
</LanguageProvider>,
);
expect(
await screen.findByRole("navigation", {
name: "Estado de la memoria",
}),
).toBeTruthy();
expect(screen.getByRole("button", { name: "original" })).toBeTruthy();
expect(screen.getByText("1 memoria")).toBeTruthy();
});
afterEach(cleanup);
it("edits the same memory id and physically deletes without revision or undo", async () => {
render(<SdkMemoryLibrary active />);
+18 -24
View File
@@ -1,12 +1,11 @@
import type { SdkMemoryRecord, SdkMemoryStatus } from "@one-memory/contracts";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import {
deleteSdkMemory,
getSdkMemory,
getSdkMemoryLibrary,
updateSdkMemory,
} from "./api";
import { useGuiLanguage } from "./gui-language";
import { R6Button } from "./R6Controls";
export function SdkMemoryLibrary({
@@ -18,9 +17,6 @@ export function SdkMemoryLibrary({
onChanged?(): void;
focusedMemoryId?: string | null;
}) {
const { t, tn } = useGuiLanguage();
const translation = useRef(t);
translation.current = t;
const [status, setStatus] = useState<SdkMemoryStatus>("active");
const [offset, setOffset] = useState(0);
const [rows, setRows] = useState<SdkMemoryRecord[]>([]);
@@ -46,7 +42,7 @@ export function SdkMemoryLibrary({
if (offset >= page.total && offset > 0)
setOffset(Math.max(0, offset - limit));
} catch {
setError(translation.current("sdk.memory.loadFailed"));
setError("记忆库读取失败,请重试。");
} finally {
setBusy(false);
}
@@ -64,7 +60,7 @@ export function SdkMemoryLibrary({
setSelected(memory);
setDraft(memory.content);
} catch {
setError(translation.current("sdk.memory.detailLoadFailed"));
setError("记忆详情读取失败。");
} finally {
setBusy(false);
}
@@ -84,7 +80,7 @@ export function SdkMemoryLibrary({
await refresh();
onChanged?.();
} catch {
setError(t("sdk.memory.updateFailed"));
setError("修改未完成,请重试。");
} finally {
setBusy(false);
}
@@ -100,19 +96,19 @@ export function SdkMemoryLibrary({
await refresh();
onChanged?.();
} catch {
setError(t("sdk.memory.deleteFailed"));
setError("删除未完成,请重试。");
} finally {
setBusy(false);
}
}
return (
<section className="sdk-memory-library" aria-label={t("sdk.memory.title")}>
<nav aria-label={t("sdk.memory.status")}>
<section className="sdk-memory-library" aria-label="SDK 记忆库">
<nav aria-label="记忆状态">
{(
[
["active", t("sdk.memory.status.active")],
["superseded", t("sdk.memory.status.superseded")],
["active", "已可用"],
["superseded", "已被取代"],
] as const
).map(([value, label]) => (
<button
@@ -130,11 +126,9 @@ export function SdkMemoryLibrary({
</button>
))}
</nav>
<p aria-live="polite">{tn("sdk.memory.total", total)}</p>
<p aria-live="polite">共 {total} 条</p>
{error && <p role="alert">{error}</p>}
{busy && !rows.length ? (
<p role="status">{t("sdk.memory.loading")}</p>
) : null}
{busy && !rows.length ? <p role="status">正在读取记忆库…</p> : null}
<ul>
{rows.map((memory) => (
<li key={memory.memory_id}>
@@ -144,29 +138,29 @@ export function SdkMemoryLibrary({
</li>
))}
</ul>
<nav aria-label={t("sdk.memory.pagination")}>
<nav aria-label="SDK 记忆库分页">
<R6Button
variant="quiet"
disabled={busy || offset === 0}
onClick={() => setOffset(Math.max(0, offset - limit))}
>
{t("sdk.memory.previous")}
上一页
</R6Button>
<R6Button
variant="quiet"
disabled={busy || offset + limit >= total}
onClick={() => setOffset(offset + limit)}
>
{t("sdk.memory.next")}
下一页
</R6Button>
</nav>
{selected ? (
<aside aria-label={t("sdk.memory.detail")}>
<aside aria-label="SDK 记忆详情">
<p>{selected.layer}</p>
{selected.status === "active" ? (
<>
<textarea
aria-label={t("sdk.memory.content")}
aria-label="记忆内容"
value={draft}
disabled={busy}
onChange={(event) => setDraft(event.target.value)}
@@ -177,10 +171,10 @@ export function SdkMemoryLibrary({
pending={busy}
onClick={() => void save()}
>
{t("sdk.memory.save")}
保存修改
</R6Button>
<R6Button pending={busy} onClick={() => void remove()}>
{t("sdk.memory.delete")}
删除记忆
</R6Button>
</>
) : (
+1 -37
View File
@@ -9,45 +9,9 @@ const { getSdkRaw, searchSdkRaw } = vi.hoisted(() => ({
}));
vi.mock("./api", () => ({ getSdkRaw, searchSdkRaw }));
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import { SdkRawHistory } from "./SdkRawHistory";
afterEach(() => {
cleanup();
localStorage.clear();
});
it("localizes controls without translating original conversation fields", async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
getSdkRaw.mockResolvedValue({
items: [
{
raw_id: "raw-es",
content: "原始内容 sin traducir",
user_id: "local",
agent_id: "one-memory",
session_id: "sesión-original",
source: "cursor",
memory_at: null,
gmt_created: null,
custom: {},
},
],
total: 1,
offset: 0,
limit: 100,
});
render(
<LanguageProvider>
<SdkRawHistory />
</LanguageProvider>,
);
expect(
await screen.findByLabelText("Buscar conversaciones originales"),
).toBeTruthy();
expect(screen.getByText("原始内容 sin traducir")).toBeTruthy();
expect(screen.getByText(/sesión-original/)).toBeTruthy();
});
afterEach(cleanup);
it("lists and searches SDK raw without legacy conversation APIs", async () => {
getSdkRaw.mockResolvedValue({
+8 -14
View File
@@ -1,12 +1,8 @@
import type { SdkRawContextItem, SdkRawRecord } from "@one-memory/contracts";
import { type FormEvent, useEffect, useRef, useState } from "react";
import { type FormEvent, useEffect, useState } from "react";
import { getSdkRaw, searchSdkRaw } from "./api";
import { useGuiLanguage } from "./gui-language";
export function SdkRawHistory() {
const { locale, t, tn } = useGuiLanguage();
const translation = useRef(t);
translation.current = t;
const [recent, setRecent] = useState<SdkRawRecord[]>([]);
const [results, setResults] = useState<SdkRawContextItem[] | null>(null);
const [query, setQuery] = useState("");
@@ -20,7 +16,7 @@ export function SdkRawHistory() {
if (current) setRecent(page.items);
},
() => {
if (current) setError(translation.current("sdk.raw.loadFailed"));
if (current) setError("原始对话暂时无法读取。");
},
);
return () => {
@@ -36,7 +32,7 @@ export function SdkRawHistory() {
try {
setResults(await searchSdkRaw(query.trim()));
} catch {
setError(t("sdk.raw.searchFailed"));
setError("原始对话搜索失败,请重试。");
} finally {
setBusy(false);
}
@@ -44,24 +40,22 @@ export function SdkRawHistory() {
const items = results ?? recent.map(asContextItem);
return (
<section aria-label={t("sdk.raw.title")}>
<section aria-label="SDK 原始对话">
<form onSubmit={(event) => void submit(event)}>
<label>
{t("sdk.raw.searchLabel")}
搜索原始对话
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</label>
<button type="submit" disabled={busy || !query.trim()}>
{busy ? t("sdk.raw.searching") : t("sdk.raw.search")}
{busy ? "搜索中…" : "搜索"}
</button>
</form>
{error ? <p role="alert">{error}</p> : null}
<p aria-live="polite">
{results === null
? t("sdk.raw.recent")
: tn("sdk.raw.found", items.length)}
{results === null ? "最近原始对话" : `找到 ${items.length} 条`}
</p>
<ol>
{items.map((item) => (
@@ -70,7 +64,7 @@ export function SdkRawHistory() {
<small>
{item.source} · {item.session_id}
{item.memory_at
? ` · ${new Date(item.memory_at).toLocaleString(locale)}`
? ` · ${new Date(item.memory_at).toLocaleString("zh-CN")}`
: ""}
</small>
</li>
@@ -11,32 +11,8 @@ import {
} from "@testing-library/react";
import { StrictMode } from "react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import { SelectedMemoryDetail } from "./SelectedMemoryDetail";
it("localizes informational details and dates while preserving evidence and memory", async () => {
delete window.oneMemory;
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
render(
<LanguageProvider>
<SelectedMemoryDetail
detail={detail}
sources={[]}
onChanged={vi.fn()}
hideActions
/>
</LanguageProvider>,
);
expect(await screen.findByText("Guardada · Versión 2")).toBeTruthy();
expect(
screen.getByRole("heading", { name: "Evidencia original" }),
).toBeTruthy();
expect(screen.getByText("这次先确认范围。")).toBeTruthy();
expect(correct).not.toHaveBeenCalled();
expect(remove).not.toHaveBeenCalled();
localStorage.clear();
});
const { correct, read, remove, errorCode } = vi.hoisted(() => ({
correct: vi.fn(),
read: vi.fn(),
+60 -86
View File
@@ -3,7 +3,6 @@ import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { apiErrorCode, correctMemory, getMemory, mutateMemory } from "./api";
import { clockStamp } from "./clock-stamp";
import { useGuiLanguage } from "./gui-language";
import { R6Button } from "./R6Controls";
import { sourceNameOr } from "./source-label";
import { traceUiAction } from "./ui-trace";
@@ -28,7 +27,6 @@ export function SelectedMemoryDetail({
/** D99: when the cited conversation happened, in the list's own terms. */
conversationAt?: { at: string; basis: "conversation" | "import" } | null;
}) {
const { locale, t } = useGuiLanguage();
const [page, setPage] = useState<Page>("read");
const [draft, setDraft] = useState(detail.memory.content);
const [base, setBase] = useState(detail.memory.revision);
@@ -52,17 +50,13 @@ export function SelectedMemoryDetail({
const evidence = detail.evidence[0];
const statusLabel =
memory.status === "confirmed"
? t("selectedMemory.status.confirmed")
? "已沉淀"
: memory.status === "deleted"
? t("selectedMemory.status.deleted")
? "已删除"
: memory.status === "superseded"
? t("selectedMemory.status.superseded")
: t("selectedMemory.status.unknown");
const source = sourceNameOr(
sources,
evidence?.source_id,
t("selectedMemory.sourceUnknown"),
);
? "已被取代"
: "状态待核对";
const source = sourceNameOr(sources, evidence?.source_id, "来源待核对");
const dirty = page === "edit" && draft !== memory.content;
useEffect(() => {
if (page === "edit" && memory.revision > base) setConflictFound(true);
@@ -91,7 +85,7 @@ export function SelectedMemoryDetail({
}, [dirty]);
function close() {
if (busy) return;
if (dirty && !window.confirm(t("selectedMemory.discardDraft"))) return;
if (dirty && !window.confirm("放弃尚未保存的修改?")) return;
setPage("read");
setError("");
setConflictFound(false);
@@ -104,7 +98,7 @@ export function SelectedMemoryDetail({
const latest = await getMemory(memory.memory_id);
if (alive.current) setConflict(latest);
} catch {
if (alive.current) setError(t("selectedMemory.newVersionReadFailed"));
if (alive.current) setError("暂时无法读取新版本,草稿已保留。");
} finally {
if (alive.current) setBusy(false);
}
@@ -144,8 +138,8 @@ export function SelectedMemoryDetail({
setConflictFound(true);
setError("");
} else if (isDefiniteRefusal(reason))
setError(t("selectedMemory.updateRejected"));
else setError(t("selectedMemory.updateUnknown"));
setError("修改未提交;草稿已保留。请按提示修正后重试。");
else setError("修改尚未确认,草稿已保留。请先刷新核对,避免重复提交。");
}
} finally {
flight.current = false;
@@ -181,8 +175,8 @@ export function SelectedMemoryDetail({
if (alive.current)
setError(
refused
? t("selectedMemory.deleteRejected")
: t("selectedMemory.deleteUnknown"),
? "删除未提交。请核对这条记忆的状态后重试。"
: "删除尚未确认。请重新核对记忆状态,不会自动扩大删除范围。",
);
} finally {
flight.current = false;
@@ -213,8 +207,8 @@ export function SelectedMemoryDetail({
if (alive.current)
setError(
refused
? t("selectedMemory.undoRejected")
: t("selectedMemory.undoUnknown"),
? "撤销未提交。请按提示修正后重试。"
: "撤销尚未确认,请先核对当前记忆版本,避免重复撤销。",
);
} finally {
flight.current = false;
@@ -223,11 +217,11 @@ export function SelectedMemoryDetail({
}
const author =
evidence?.written_by === "person" || evidence?.written_by === "user"
? t("selectedMemory.author.you")
? "你说"
: evidence?.written_by === "agent" || evidence?.written_by === "assistant"
? t("selectedMemory.author.assistant")
: t("selectedMemory.author.unknown");
const timestamp = clockStamp(evidence?.created_at, locale);
? "助手原话"
: "作者待核对";
const timestamp = clockStamp(evidence?.created_at);
const split = memory.content.search(/[。\n]/);
const heading =
split > 0 && split < 85
@@ -242,24 +236,17 @@ export function SelectedMemoryDetail({
data-hide-actions={hideActions ? "true" : "false"}
>
<p className="selected-memory-status">
{statusLabel} ·{" "}
{t("selectedMemory.version", {
revision: new Intl.NumberFormat(locale).format(memory.revision),
})}
{statusLabel} · 第 {memory.revision} 版
</p>
<h3>{heading}</h3>
{body && <p className="selected-memory-body">{body}</p>}
<p className="selected-memory-source">
{t("selectedMemory.sourceLine", { source, time: timestamp })}
来源 {source} · {timestamp}
</p>
<p className="selected-memory-times">
{t("selectedMemory.timestamps", {
conversation: clockStamp(
conversationAt?.at ?? evidence?.created_at,
locale,
),
created: clockStamp(memory.updated_at, locale),
})}
对话时间 {clockStamp(conversationAt?.at ?? evidence?.created_at)}
{" · "}
沉淀时间 {clockStamp(memory.updated_at)}
</p>
{!hideActions && (
<R6Button
@@ -273,18 +260,16 @@ export function SelectedMemoryDetail({
setPage("edit");
}}
>
{t("selectedMemory.edit")}
编辑
</R6Button>
)}
<section className="selected-memory-evidence">
<h4>{t("selectedMemory.evidence")}</h4>
<h4>原始依据</h4>
<small>
{t("selectedMemory.evidenceLine", { author, time: timestamp })}
{author} · 原对话 {timestamp}:
</small>
<blockquote>
{evidence?.available
? evidence.verbatim
: t("selectedMemory.evidenceUnavailable")}
{evidence?.available ? evidence.verbatim : "这段原文当前不可查看。"}
</blockquote>
{!hideActions && (
<R6Button
@@ -293,24 +278,20 @@ export function SelectedMemoryDetail({
setPage("original");
}}
>
{t("selectedMemory.viewOriginal")}
查看原文
</R6Button>
)}
</section>
{!hideActions && (
<R6Button variant="quiet" onClick={() => setShowHistory((v) => !v)}>
{t("selectedMemory.history")}
版本记录
</R6Button>
)}
{!hideActions && showHistory && (
<ol className="selected-memory-history">
{detail.history.map((v) => (
<li key={v.revision}>
<small>
{t("selectedMemory.version", {
revision: new Intl.NumberFormat(locale).format(v.revision),
})}
</small>
<small>第 {v.revision} 版</small>
<p>{v.content}</p>
</li>
))}
@@ -318,10 +299,10 @@ export function SelectedMemoryDetail({
)}
{!hideActions && (
<details className="selected-memory-more">
<summary>{t("selectedMemory.more")}</summary>
<summary>更多管理</summary>
{detail.history.length > 1 && (
<R6Button pending={busy} onClick={() => void govern("undo")}>
{t("selectedMemory.undo")}
撤销上次修改
</R6Button>
)}
<R6Button
@@ -331,7 +312,7 @@ export function SelectedMemoryDetail({
setPage("delete");
}}
>
{t("selectedMemory.delete")}
删除记忆
</R6Button>
</details>
)}
@@ -344,10 +325,10 @@ export function SelectedMemoryDetail({
hidden={suspended}
aria-label={
page === "edit"
? t("selectedMemory.editTitle")
? "编辑记忆"
: page === "original"
? t("selectedMemory.originalTitle")
: t("selectedMemory.delete")
? "原始对话"
: "删除记忆"
}
onKeyDown={(event) => {
if (event.key === "Tab") {
@@ -379,20 +360,20 @@ export function SelectedMemoryDetail({
<header>
<h1 ref={focus} tabIndex={-1}>
{page === "edit"
? t("selectedMemory.editTitle")
? "编辑记忆"
: page === "original"
? t("selectedMemory.originalTitle")
: t("selectedMemory.delete")}
? "原始对话"
: "删除记忆"}
</h1>
{page === "original" && (
<R6Button onClick={close}>{t("selectedMemory.back")}</R6Button>
<R6Button onClick={close}>返回记忆</R6Button>
)}
</header>
{page === "edit" ? (
<div className="selected-editor">
<p>{t("selectedMemory.editDescription")}</p>
<p>修改这条记忆,原始对话保持不变。</p>
<textarea
aria-label={t("selectedMemory.content")}
aria-label="记忆内容"
name="memory-content"
value={draft}
disabled={busy}
@@ -402,16 +383,16 @@ export function SelectedMemoryDetail({
request.current = null;
}}
/>
<p>{t("selectedMemory.scopeUnchanged")}</p>
<p>使用范围保持不变</p>
{conflictFound && (
<section className="selected-conflict">
<h2>{t("selectedMemory.conflictTitle")}</h2>
<p>{t("selectedMemory.conflictDescription")}</p>
<h2>已有新版本</h2>
<p>你的草稿已保留,先查看新版本再决定如何修改。</p>
<R6Button
pending={busy}
onClick={() => void refreshConflict()}
>
{t("selectedMemory.viewNewVersion")}
查看新版本
</R6Button>
{conflict && (
<>
@@ -423,7 +404,7 @@ export function SelectedMemoryDetail({
request.current = null;
}}
>
{t("selectedMemory.continueFromVersion")}
基于此版本继续编辑
</R6Button>
</>
)}
@@ -431,7 +412,7 @@ export function SelectedMemoryDetail({
)}
<div className="selected-page-actions">
<R6Button disabled={busy} onClick={close}>
{t("selectedMemory.cancel")}
取消
</R6Button>
<R6Button
variant="primary"
@@ -439,7 +420,7 @@ export function SelectedMemoryDetail({
disabled={!draft.trim() || conflictFound}
onClick={() => void save()}
>
{t("selectedMemory.save")}
保存修改
</R6Button>
</div>
</div>
@@ -452,50 +433,43 @@ export function SelectedMemoryDetail({
{detail.evidence.map((item) => (
<section key={item.evidence_id}>
<small>
{sourceNameOr(
sources,
item.source_id,
t("selectedMemory.sourceUnknown"),
)}{" "}
·{" "}
{sourceNameOr(sources, item.source_id, "来源待核对")} ·{" "}
{item.written_by === "person"
? t("selectedMemory.author.user")
? "你"
: item.written_by === "agent"
? t("selectedMemory.author.agent")
: t("selectedMemory.author.unknown")}
? "助手"
: "作者待核对"}
</small>
<blockquote>
{item.available
? item.verbatim
: t("selectedMemory.sourceUnavailable")}
: "来源权限已变化或原文不可用。"}
</blockquote>
</section>
))}
<small>{t("selectedMemory.related")}</small>
<small>对应记忆</small>
<h2>{heading}</h2>
<R6Button onClick={close}>
{t("selectedMemory.viewMemory")}
</R6Button>
<R6Button onClick={close}>查看记忆</R6Button>
</div>
) : (
<div className="selected-delete">
<h2>{t("selectedMemory.deleteTitle")}</h2>
<h2>删除这条记忆?</h2>
<p>{heading}</p>
<p>
{t("selectedMemory.deleteDescription")}
删除后不再用于新的召回,来源 App 中的原始对话不会删除。
<br />
{t("selectedMemory.sentCannotRecall")}
已经发送给应用的内容无法远程收回。
</p>
<div className="selected-page-actions">
<R6Button disabled={busy} onClick={close}>
{t("selectedMemory.cancel")}
取消
</R6Button>
<R6Button
variant="primary"
pending={busy}
onClick={() => void remove()}
>
{t("selectedMemory.delete")}
删除记忆
</R6Button>
</div>
</div>
+43 -60
View File
@@ -3,11 +3,9 @@ import type {
SourceImportReceipt as Receipt,
SourceResponse,
} from "@one-memory/contracts";
import type { GuiLocale } from "@one-memory/i18n";
import { FileText, RefreshCw, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { getConversation, getSourceImportReceipt } from "./api";
import { memoryText, useMemoryLanguage } from "./memory-language";
export function SourceImportReceipt({ source }: { source: SourceResponse }) {
return source.authorized ? (
@@ -16,7 +14,6 @@ export function SourceImportReceipt({ source }: { source: SourceResponse }) {
}
function ReceiptBody({ source }: { source: SourceResponse }) {
const { locale, text, count } = useMemoryLanguage();
const [receipt, setReceipt] = useState<Receipt | null>(null);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
@@ -100,96 +97,85 @@ function ReceiptBody({ source }: { source: SourceResponse }) {
}
}}
>
<summary>{text("查看已读内容")}</summary>
<summary>查看已读内容</summary>
{opened && (
<div className="source-receipt-body">
<header>
<span>
{text("已保存原文")} · {source.display_name}
</span>
<span>已保存原文 · {source.display_name}</span>
<button
type="button"
className="quiet"
title={text("刷新已读内容")}
aria-label={text("刷新已读内容")}
title="刷新已读内容"
aria-label="刷新已读内容"
disabled={loading}
onClick={() => void load()}
>
<RefreshCw size={15} />
</button>
</header>
{loading && <p role="status">{text("正在核对本机已保存记录…")}</p>}
{error && <p role="alert">{text(error)}</p>}
{loading && <p role="status">正在核对本机已保存记录…</p>}
{error && <p role="alert">{error}</p>}
{receipt && (
<>
<dl className="source-receipt-counts">
<div>
<dt>{text("原始消息")}</dt>
<dd>{receipt.messages.toLocaleString(locale)}</dd>
<dt>原始消息</dt>
<dd>{receipt.messages.toLocaleString()}</dd>
</div>
<div>
<dt>{text("会话")}</dt>
<dd>{receipt.conversations.toLocaleString(locale)}</dd>
<dt>会话</dt>
<dd>{receipt.conversations.toLocaleString()}</dd>
</div>
<div>
<dt>{text("用户发言")}</dt>
<dd>{receipt.person_messages.toLocaleString(locale)}</dd>
<dt>用户发言</dt>
<dd>{receipt.person_messages.toLocaleString()}</dd>
</div>
<div>
<dt>{text("Agent 发言")}</dt>
<dd>{receipt.agent_messages.toLocaleString(locale)}</dd>
<dt>Agent 发言</dt>
<dd>{receipt.agent_messages.toLocaleString()}</dd>
</div>
<div>
<dt>{text("作者未知")}</dt>
<dd>
{receipt.unknown_author_messages.toLocaleString(locale)}
</dd>
<dt>作者未知</dt>
<dd>{receipt.unknown_author_messages.toLocaleString()}</dd>
</div>
<div>
<dt>{text("原始时间未知")}</dt>
<dd>
{receipt.unknown_time_messages.toLocaleString(locale)}
</dd>
<dt>原始时间未知</dt>
<dd>{receipt.unknown_time_messages.toLocaleString()}</dd>
</div>
{receipt.tool_messages > 0 && (
<div>
<dt>{text("工具记录")}</dt>
<dd>{receipt.tool_messages.toLocaleString(locale)}</dd>
<dt>工具记录</dt>
<dd>{receipt.tool_messages.toLocaleString()}</dd>
</div>
)}
</dl>
<p className="source-receipt-note">
{receipt.unknown_time_messages > 0
? text("{count} 条记录没有原始时间,未用导入时间代替。", {
count: count(receipt.unknown_time_messages),
})
: text("原始时间来自已保存记录。")}{" "}
{text(
"作者数量按当前记录分类统计,不代表记忆抽取已完成或语义已核实。",
)}
? `${receipt.unknown_time_messages.toLocaleString()} 条记录没有原始时间,未用导入时间代替。`
: "原始时间来自已保存记录。"}{" "}
作者数量按当前记录分类统计,不代表记忆抽取已完成或语义已核实。
</p>
<p className="source-receipt-meta">
{text("统计于 ")}
{time(receipt.measured_at, locale)}
统计于 {time(receipt.measured_at)}
{receipt.messages !== source.imported_messages
? text(" · 导入数量仍在更新,可刷新核对")
? " · 导入数量仍在更新,可刷新核对"
: ""}
</p>
{receipt.examples.length > 0 ? (
<section
className="source-receipt-examples"
aria-label={text("已保存的原文样例")}
aria-label="已保存的原文样例"
>
<h3>{text("已保存的原文样例")}</h3>
<h3>已保存的原文样例</h3>
<p className="source-receipt-meta">
{text("最多展示 3 段,不代表全部历史或推荐记忆。")}
最多展示 3 段,不代表全部历史或推荐记忆。
</p>
{receipt.examples.map((example) => (
<article key={example.evidence_id}>
<header>
<span>
{text(author(example.written_by))} ·{" "}
{time(example.created_at, locale)}
{author(example.written_by)} ·{" "}
{time(example.created_at)}
</span>
<button
type="button"
@@ -197,37 +183,35 @@ function ReceiptBody({ source }: { source: SourceResponse }) {
onClick={() => void read(example.evidence_id)}
>
<FileText size={14} aria-hidden="true" />
{text("查看完整对话")}
查看完整对话
</button>
</header>
<p className="source-receipt-quote">{example.excerpt}</p>
{example.truncated && (
<small>
{text("原文较长,此处仅展示前 600 个字符。")}
</small>
<small>原文较长,此处仅展示前 600 个字符。</small>
)}
</article>
))}
</section>
) : (
<p>{text("这个来源还没有保存原文。")}</p>
<p>这个来源还没有保存原文。</p>
)}
</>
)}
{reading && <p role="status">{text("正在读取完整对话…")}</p>}
{readError && <p role="alert">{text(readError)}</p>}
{reading && <p role="status">正在读取完整对话…</p>}
{readError && <p role="alert">{readError}</p>}
{transcript && (
<section
className="source-receipt-conversation"
aria-label={text("完整对话")}
aria-label="完整对话"
>
<header>
<h3>{text("完整对话")}</h3>
<h3>完整对话</h3>
<button
type="button"
className="quiet"
title={text("关闭完整对话")}
aria-label={text("关闭完整对话")}
title="关闭完整对话"
aria-label="关闭完整对话"
onClick={() => {
conversationGeneration.current++;
setTranscript(null);
@@ -240,8 +224,7 @@ function ReceiptBody({ source }: { source: SourceResponse }) {
{transcript.messages.map((message) => (
<li key={message.evidence_id}>
<small>
{text(author(message.written_by))} ·{" "}
{time(message.created_at, locale)}
{author(message.written_by)} · {time(message.created_at)}
</small>
<p>{message.verbatim}</p>
</li>
@@ -264,8 +247,8 @@ function author(value: string) {
? "工具"
: "作者未知";
}
function time(value: string | null, locale: GuiLocale) {
function time(value: string | null) {
return value && Number.isFinite(Date.parse(value))
? new Date(value).toLocaleString(locale)
: memoryText(locale, "时间未知");
? new Date(value).toLocaleString()
: "时间未知";
}
+1 -108
View File
@@ -18,40 +18,8 @@ import {
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import { OptionMenu } from "./OptionMenu";
import { Answer, Workbench } from "./Workbench";
it("localiza la selección vacía del menú auxiliar", () => {
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
render(
<LanguageProvider>
<OptionMenu value="" options={[]} onChange={vi.fn()} />
</LanguageProvider>,
);
expect(
screen.getByRole("button", { name: "Selecciona una opción" }),
).toBeTruthy();
window.localStorage.removeItem(LANGUAGE_STORAGE_KEY);
});
it("traduce la respuesta visible sin traducir la respuesta del modelo", () => {
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
render(
<LanguageProvider>
<Answer
answer={null}
streaming={{ text: "Respuesta 中文 opaca", memories: [] }}
onOpenMemory={vi.fn()}
onSaveAnswer={vi.fn()}
asPage={false}
/>
</LanguageProvider>,
);
expect(screen.getByText("Respuesta 中文 opaca")).toBeTruthy();
expect(screen.getByText("Se recuperaron 0 memorias")).toBeTruthy();
window.localStorage.removeItem(LANGUAGE_STORAGE_KEY);
});
import { Workbench } from "./Workbench";
const getConversation = vi.fn();
@@ -254,49 +222,6 @@ function mount(props: Partial<Parameters<typeof Workbench>[0]> = {}) {
return { ...handlers, view };
}
it("localiza el espacio vacío y sus controles", () => {
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
const props = {
memories: [],
graph: null,
sources: [],
processing: null,
loaded: true,
detail: null,
draft: "",
scopes: "",
busy: false,
answer: null,
streaming: null,
requestIds: { current: new Map() },
onDraft: vi.fn(),
onScopes: vi.fn(),
onOpenMemory: vi.fn(),
onCloseDetail: vi.fn(),
onSave: vi.fn(),
onAction: vi.fn(),
onSyncIndex: vi.fn(),
onAsk: vi.fn(),
onSaveAnswer: vi.fn(),
onBuildRelations: vi.fn(),
onConnect: vi.fn(),
onConfigureModel: vi.fn(),
};
render(
<LanguageProvider>
<Workbench {...props} />
</LanguageProvider>,
);
expect(screen.getByRole("navigation", { name: "Temas" })).toBeTruthy();
expect(
screen.getByRole("button", { name: "Autorizar una fuente" }),
).toBeTruthy();
expect(
screen.getByRole("textbox", { name: "Preguntar sobre este asunto" }),
).toBeTruthy();
window.localStorage.removeItem(LANGUAGE_STORAGE_KEY);
});
it("选中的图节点把记忆库与详情联到一起(图 → 列表 → 原文)", async () => {
// The goal requires the graph, the memory list and the original text to be
// linked in both directions. The list→graph direction is covered elsewhere
@@ -871,36 +796,4 @@ describe("按主题聚类", () => {
expect(panel.textContent).toContain("同一个主题");
expect(panel.textContent).not.toContain("原话来源");
});
it("Core 送来未知的节点类型或连线来头时,仍然说得出一个词而不是留空", async () => {
const user = userEvent.setup();
mount({
graph: {
total_memories: 1,
projected_memories: 1,
hy_graph_available: false,
nodes: [
{ node_id: "future:x", node_type: "future_kind", label: "新节点" },
{ node_id: "memory:a", node_type: "memory", label: "统一走 axios" },
],
edges: [
{
edge_id: "e1",
source_node_id: "future:x",
target_node_id: "memory:a",
relation: "related_to",
origin: "future_origin",
},
],
} as unknown as MemoryGraphResponse,
});
await user.click(screen.getByRole("button", { name: "新节点" }));
const panel = screen.getByLabelText("这个节点");
expect(panel.querySelector(".wb-status")?.textContent).toBe("记忆");
expect(panel.querySelector(".wb-trace-origin")?.textContent).toBe(
"原话来源",
);
});
});
+149 -260
View File
@@ -11,7 +11,6 @@ import type {
SdkAskMemory,
SourceResponse,
} from "@one-memory/contracts";
import { isMessageKey } from "@one-memory/i18n";
import { Check, PencilLine, RotateCcw, Trash2 } from "lucide-react";
import {
type FormEvent,
@@ -21,9 +20,9 @@ import {
useState,
} from "react";
import { getConversation } from "./api";
import { useGuiLanguage } from "./gui-language";
import { HandoffBar } from "./HandoffBar";
import { MemoryGraph3D } from "./MemoryGraph3D";
import { memoryProvenanceLabel } from "./MemoryRecallScope";
import { extractionStateLabel } from "./memory-processing-labels";
import {
type Finding,
@@ -33,10 +32,6 @@ import {
} from "./workbench-findings";
import { singletonTags, tagCounts } from "./workbench-tags";
type Translate = ReturnType<typeof useGuiLanguage>["t"];
type TranslateCount = ReturnType<typeof useGuiLanguage>["tn"];
type GuiLocale = ReturnType<typeof useGuiLanguage>["locale"];
/** The memory tab: three columns over one graph.
*
* Left is what the memories are about, centre is how they relate and where
@@ -102,7 +97,6 @@ export function Workbench({
* reuses the request id prepared for text the user has since replaced. */
requestIds: RefObject<Map<string, string>>;
}) {
const { locale, t, tn } = useGuiLanguage();
const [matter, setMatter] = useState("");
const [selectedNode, setSelectedNode] = useState<MemoryGraphNode | null>(
null,
@@ -128,14 +122,8 @@ export function Workbench({
[memories, matter],
);
const findings = useMemo(
() =>
findingsOf({
memories: inMatter,
processing,
now: new Date(),
locale,
}),
[inMatter, processing, locale],
() => findingsOf({ memories: inMatter, processing, now: new Date() }),
[inMatter, processing],
);
// Counted across the whole library, not this matter. A tag used once here
// and once elsewhere is a tag used twice; scoping this to the matter would
@@ -149,8 +137,8 @@ export function Workbench({
// fails. Keying the skeleton on null meant the one screen it existed for
// never saw it.
const drawn = graph ? narrow(graph, inMatter, matter) : null;
return drawn?.nodes.length ? drawn : skeleton(sources, graph, tn);
}, [graph, inMatter, matter, sources, tn]);
return drawn?.nodes.length ? drawn : skeleton(sources, graph);
}, [graph, inMatter, matter, sources]);
const evidence = useMemo(
() => orderEvidence(detail, highlighted, memories),
[detail, highlighted, memories],
@@ -169,16 +157,14 @@ export function Workbench({
setTranscript(await getConversation(evidenceId));
} catch {
setTranscript(null);
setTranscriptError(t("wb.transcriptGone"));
setTranscriptError("这段对话的原文已经不在库里了。");
}
}
const nameOf = (sourceId: string | null) =>
sources.find((item) => item.source_id === sourceId)?.display_name ??
sourceId ??
t("wb.unknownSource");
const matterLabel = (scope: string, label: string) =>
scope ? label : t("wb.all");
"未知来源";
return (
<div className="workbench">
@@ -190,16 +176,16 @@ export function Workbench({
belongs to — is a workspace scope, and its plaintext name lives on
the evidence rows in Core; nothing here can recover it from the
hash, so calling this column 事项 named something it was not. */}
<nav className="wb-matters" aria-label={t("wb.topics")}>
<p className="wb-column-label">{t("wb.topics")}</p>
<nav className="wb-matters" aria-label="主题">
<p className="wb-column-label">主题</p>
{allTags.length ? (
// Says the two numbers the column actually shows: how many
// topics took shape, and how many tags did not. Reporting one
// total meant reading "22 个主题标签" above six rows.
<p className="wb-tag-tally">
{onceOnly.size === 0
? tn("wb.topicCount", allTags.length - onceOnly.size)
: `${tn("wb.topicCount", allTags.length - onceOnly.size)}${tn("wb.singletons", onceOnly.size)}`}
? `${allTags.length - onceOnly.size} 个主题成形`
: `${allTags.length - onceOnly.size} 个主题成形,另有 ${onceOnly.size} 个标签只出现过一次`}
</p>
) : null}
{matters.map((item) => (
@@ -215,12 +201,10 @@ export function Workbench({
setTranscript(null);
}}
>
{matterLabel(item.scope, item.label)}
{item.label}
<small>
{tn("wb.memories", item.memoryCount)}
{item.sourceCount
? ` · ${tn("wb.sources", item.sourceCount)}`
: ""}
{item.memoryCount} 条记忆
{item.sourceCount ? ` · ${item.sourceCount} 个来源` : ""}
</small>
</button>
{/* The graph is a canvas: clicking a circle is a mouse-only
@@ -265,7 +249,9 @@ export function Workbench({
className="wb-tag"
data-once={onceOnly.has(tag) ? "true" : undefined}
title={
onceOnly.has(tag) ? t("wb.once") : undefined
onceOnly.has(tag)
? "这个主题只出现在这一条上。可能是它只发生过一次,也可能是同一个主题被拆成了两个名字。"
: undefined
}
>
{tag}
@@ -284,25 +270,14 @@ export function Workbench({
<div className="wb-stage">
<div className="wb-graph" data-testid="workbench-graph">
<p className="wb-graph-title">
{shown.nodes.length
? t("wb.graphTitle", {
label: matterLabel(
matter,
matters.find((item) => item.scope === matter)?.label ??
t("wb.all"),
),
})
: matterLabel(
matter,
matters.find((item) => item.scope === matter)?.label ??
t("wb.all"),
)}
{matters.find((item) => item.scope === matter)?.label ?? "全部"}
{shown.nodes.length ? " 的关系图" : ""}
</p>
<div className="wb-graph-legend">
<span data-kind="memory">{t("wb.type.memory")}</span>
<span data-kind="schema">{t("wb.type.schema")}</span>
<span data-kind="topic">{t("wb.type.topic")}</span>
<span data-kind="source">{t("wb.type.source")}</span>
<span data-kind="memory">记忆</span>
<span data-kind="schema">HY 归纳</span>
<span data-kind="topic">主题</span>
<span data-kind="source">来源 App</span>
</div>
{/* Laid out by the row, not by offsets. Both were absolutely
positioned with a right edge each, and measuring them found
@@ -317,16 +292,16 @@ export function Workbench({
className="quiet"
disabled={busy}
onClick={() => {
const label = matterLabel(
matter,
const label =
matters.find((item) => item.scope === matter)?.label ??
t("wb.all"),
);
"全部";
setPageOf(matter);
onAsk(t("wb.summaryPrompt", { label }));
onAsk(
`把「${label}」这件事讲清楚:现在到哪一步了、已经定下来的有哪些、还有什么没定。`,
);
}}
>
{t("wb.readSummary")}
读这个事项的综述
</button>
) : null}
<button
@@ -335,7 +310,7 @@ export function Workbench({
disabled={busy || !graph?.nodes.length}
onClick={onBuildRelations}
>
{busy ? t("wb.building") : t("wb.build")}
{busy ? "正在归纳…" : "用 HY Memory 生成关系"}
</button>
</div>
{shown.nodes.length ? (
@@ -363,16 +338,10 @@ export function Workbench({
</div>
{graph?.nodes.length ? (
<p className="wb-receipt">
{t("wb.drawn", {
shown: new Intl.NumberFormat(locale).format(
shown.nodes.filter((node) => node.node_type === "memory")
.length,
),
total: new Intl.NumberFormat(locale).format(
graph.total_memories,
),
})}
<span>{graphReceipt(shown, t, tn)}</span>
画的是{" "}
{shown.nodes.filter((node) => node.node_type === "memory").length}{" "}
/ 全部 {graph.total_memories} 条记忆
<span>{graphReceipt(shown)}</span>
</p>
) : null}
@@ -382,11 +351,9 @@ export function Workbench({
detail={detail}
draft={draft}
busy={busy}
matterLabel={matterLabel(
matter,
matters.find((item) => item.scope === matter)?.label ??
t("wb.all"),
)}
matterLabel={
matters.find((item) => item.scope === matter)?.label ?? "全部"
}
onDraft={onDraft}
onSave={onSave}
onAction={onAction}
@@ -409,7 +376,7 @@ export function Workbench({
}}
>
<div className="wb-chips">
{chipsFor(inMatter, t).map((chip) => (
{chipsFor(inMatter).map((chip) => (
<button
key={chip}
type="button"
@@ -425,13 +392,13 @@ export function Workbench({
</div>
<div className="wb-ask-row">
<input
aria-label={t("wb.askLabel")}
placeholder={t("wb.askPlaceholder")}
aria-label="就着这个事项提问"
placeholder="就着这个事项问,回答只用已确认的记忆"
value={question}
onChange={(event) => setQuestion(event.target.value)}
/>
<button type="submit" disabled={!question.trim()}>
{t("wb.ask")}
问
</button>
</div>
{answer || streaming ? (
@@ -449,20 +416,18 @@ export function Workbench({
memories={inMatter.filter(
(memory) => memory.memory_id === detail?.memory.memory_id,
)}
matterLabel={matterLabel(
matter,
matters.find((item) => item.scope === matter)?.label ??
t("wb.all"),
)}
matterLabel={
matters.find((item) => item.scope === matter)?.label ?? "全部"
}
/>
</div>
<aside className="wb-evidence" aria-label={t("wb.originals")}>
<p className="wb-column-label">{t("wb.originals")}</p>
<aside className="wb-evidence" aria-label="原始对话">
<p className="wb-column-label">原始对话</p>
<p className="wb-evidence-note">
{detail
? tn("wb.evidenceNote", evidence.length)
: t("wb.pickCircle")}
? `这条记忆出自下面 ${evidence.length} 段原话,按时间排`
: "点图上任意一个圈,这里显示它出自哪些原话"}
</p>
{evidence.map((item) => (
<article
@@ -483,19 +448,16 @@ export function Workbench({
>
<span className="wb-evidence-meta">
<b>{nameOf(item.source_id)}</b>
<span>{when(item.created_at, locale, t)}</span>
<span>{when(item.created_at)}</span>
</span>
{item.extraction_state &&
item.extraction_state !== "extracted" ? (
<small className="wb-evidence-state">
{localizedMemoryText(
extractionStateLabel(item.extraction_state),
t,
)}
{extractionStateLabel(item.extraction_state)}
</small>
) : null}
<span className="wb-evidence-excerpt">
{item.available ? item.verbatim : t("wb.unavailable")}
{item.available ? item.verbatim : "来源原话已不可读取"}
</span>
</button>
{openEvidence === item.evidence_id ? (
@@ -505,7 +467,7 @@ export function Workbench({
className="secondary"
onClick={() => void openTranscript(item.evidence_id)}
>
{t("wb.fullConversation")}
看完整对话
</button>
{transcriptError ? (
<p className="wb-transcript-error">{transcriptError}</p>
@@ -515,9 +477,7 @@ export function Workbench({
{transcript.messages.map((message) => (
<li key={message.evidence_id}>
<span className="wb-transcript-role">
{message.written_by === "user"
? t("wb.you")
: t("wb.assistant")}
{message.written_by === "user" ? "你" : "助手"}
</span>
{message.verbatim}
</li>
@@ -529,16 +489,18 @@ export function Workbench({
</article>
))}
{!detail && !memories.length && loaded ? (
<p className="wb-evidence-note">{t("wb.noMemories")}</p>
<p className="wb-evidence-note">
还没有记忆。已经导入的原话可以在「历史对话」里直接搜。
</p>
) : null}
</aside>
</div>
{processing?.blocked_stages.length ? (
<p className="wb-floor">
{t("wb.waiting")}
有对话排在队里等一个条件。原文已经在库里,随时搜得到。
<button type="button" className="quiet" onClick={onConfigureModel}>
{t("wb.conditions")}
去看看缺什么
</button>
</p>
) : null}
@@ -553,13 +515,12 @@ function Findings({
findings: Finding[];
onPick(finding: Finding): void;
}) {
const { t } = useGuiLanguage();
if (!findings.length) return null;
return (
<section className="wb-findings" aria-label={t("wb.findings")}>
<section className="wb-findings" aria-label="我替你看出这几件事">
<div className="wb-findings-head">
<b>{t("wb.findings")}</b>
<span>{t("wb.findingsNote")}</span>
<b>我替你看出这几件事</b>
<span>都是本机记录算出来的,点一条会在图上标出它依据的记忆</span>
</div>
{findings.map((finding) => (
<div key={finding.id} className="wb-finding">
@@ -573,7 +534,7 @@ function Findings({
className="quiet wb-finding-link"
onClick={() => onPick(finding)}
>
{t("wb.showGraph")}
在图上看
</button>
) : null}
</div>
@@ -614,24 +575,19 @@ export function NodeDetail({
onClose(): void;
compact?: boolean;
}) {
const { locale, t } = useGuiLanguage();
const [editing, setEditing] = useState(!compact);
const formId = useId();
if (detail) {
const memory = detail.memory;
return (
<article className="wb-detail" aria-label={t("wb.memory")}>
<article className="wb-detail" aria-label="这条记忆">
<div className="wb-detail-head">
<span className={`wb-status s-${memory.status}`}>
{statusLabel(memory.status, t)}
</span>
<span className="wb-detail-rev">
{t("wb.revision", {
revision: new Intl.NumberFormat(locale).format(memory.revision),
})}
{statusLabel(memory.status)}
</span>
<span className="wb-detail-rev">第 {memory.revision} 版</span>
<button type="button" className="quiet" onClick={onClose}>
{t("wb.collapse")}
收起
</button>
</div>
{detail.index_sync?.status === "pending" ? (
@@ -639,13 +595,13 @@ export function NodeDetail({
// the memory here reads correctly and search does not, and nobody
// would guess that from a sync badge.
<p className="notice" role="status">
{t("wb.oldSearch")}
搜索里还是改之前那句话。
{detail.index_sync.last_error_code ===
"hy_memory_update_rejected" ? (
// HY has no such memory to re-embed, so retrying cannot end
// differently. Offering the button anyway would have the user
// pressing it until they gave up.
t("wb.missingIndex")
"HY Memory 里没有这条记忆,重试也补不上。"
) : (
<button
type="button"
@@ -653,7 +609,7 @@ export function NodeDetail({
disabled={busy}
onClick={onSyncIndex}
>
{t("wb.retrySync")}
重试同步
</button>
)}
</p>
@@ -662,15 +618,15 @@ export function NodeDetail({
<p className="map-memory-reading">{memory.content}</p>
)}
{compact && !editing && (
<p className="map-origin">{localizedProvenance(detail, t)}</p>
<p className="map-origin">{memoryProvenanceLabel(detail)}</p>
)}
{editing && memory.status === "confirmed" ? (
<form id={formId} onSubmit={onSave} aria-label={t("wb.correctForm")}>
<form id={formId} onSubmit={onSave} aria-label="纠正记忆内容">
<label>
{t("wb.content")}
记忆内容
<textarea
name="memory-content"
aria-label={t("wb.content")}
aria-label="记忆内容"
autoComplete="off"
required
rows={3}
@@ -691,13 +647,13 @@ export function NodeDetail({
onClick={() => setEditing(true)}
>
<PencilLine size={14} aria-hidden="true" />
{t("wb.correct")}
纠正
</button>
)}
{editing && memory.status === "confirmed" ? (
<button type="submit" form={formId} disabled={busy}>
<Check size={14} aria-hidden="true" />
{t("wb.saveCorrection")}
保存纠正
</button>
) : null}
{compact && editing && (
@@ -711,7 +667,7 @@ export function NodeDetail({
setEditing(false);
}}
>
{t("wb.cancelEdit")}
取消编辑
</button>
)}
{detail.history.length > 1 && memory.status !== "deleted" ? (
@@ -720,8 +676,8 @@ export function NodeDetail({
className="secondary"
disabled={busy}
onClick={() => onAction("undo")}
title={t("wb.undo")}
aria-label={t("wb.undo")}
title="撤销上次修改"
aria-label="撤销上次修改"
>
<RotateCcw size={15} aria-hidden="true" />
</button>
@@ -734,7 +690,7 @@ export function NodeDetail({
onClick={() => onAction("delete")}
>
<Trash2 size={14} aria-hidden="true" />
{t("wb.delete")}
删除
</button>
) : null}
</div>
@@ -746,28 +702,24 @@ export function NodeDetail({
const trace = graph ? traceFrom(graph, node.node_id, 2) : [];
const direct = trace.filter((step) => step.hop === 1);
return (
<article className="wb-detail" aria-label={t("wb.node")}>
<article className="wb-detail" aria-label="这个节点">
<div className="wb-detail-head">
<span className="wb-status">{nodeTypeLabel(node.node_type, t)}</span>
<span className="wb-status">{nodeTypeLabel(node.node_type)}</span>
<strong>{node.label}</strong>
</div>
<p className="wb-detail-note">
{t("wb.nodeNote", {
direct: new Intl.NumberFormat(locale).format(direct.length),
indirect: new Intl.NumberFormat(locale).format(
trace.length - direct.length,
),
})}
这不是一条记忆,是记忆挂靠的东西。直接关系 {direct.length} 条,
两跳可达 {trace.length - direct.length} 条。
</p>
<ul className="wb-trace">
{trace.slice(0, 8).map((step) => (
<li key={`${step.edge.edge_id}:${step.hop}`}>
<span className="wb-trace-hop">
{step.hop === 1 ? t("wb.direct") : t("wb.twoHops")}
{step.hop === 1 ? "直接" : "两跳"}
</span>
{relationLabel(step.edge.relation, t)} · {step.node.label}
{relationLabel(step.edge.relation)} · {step.node.label}
<span className="wb-trace-origin">
{edgeOriginLabel(step.edge.origin, t)}
{edgeOriginLabel(step.edge.origin)}
</span>
</li>
))}
@@ -777,11 +729,13 @@ export function NodeDetail({
}
return (
<article className="wb-detail wb-detail-idle" aria-label={t("wb.matter")}>
<article className="wb-detail wb-detail-idle" aria-label="这个事项">
<div className="wb-detail-head">
<strong>{matterLabel}</strong>
</div>
<p className="wb-detail-note">{t("wb.idle")}</p>
<p className="wb-detail-note">
图上的圈是记忆,线是它们被同一段对话提到过。选中一个,这里会变成它的来历和改法。左边换一个事项,图跟着换。
</p>
</article>
);
}
@@ -817,7 +771,6 @@ export function Answer({
asPage: boolean;
suspended?: boolean;
}) {
const { t, tn } = useGuiLanguage();
const shownMemories = (answer ?? streaming)?.memories ?? [];
// Only a finished answer, and only one that stands on something. Filing
// back what a stream is halfway through writing would keep a sentence that
@@ -832,18 +785,11 @@ export function Answer({
groundedAnswer.answer.length > ANSWER_AS_MEMORY_LIMIT;
return (
<div className="wb-answer" aria-live="polite">
<p>{answer ? answer.answer : streaming?.text || t("wb.searching")}</p>
<p>{answer ? answer.answer : streaming?.text || "正在检索记忆…"}</p>
<p className="wb-answer-source">
{answer
? tn("wb.answerSource", shownMemories.length, {
mode:
answer.mode === "generated"
? t("wb.generated")
: answer.mode === "extractive"
? t("wb.extractive")
: t("wb.noHit"),
})
: tn("wb.recalled", shownMemories.length)}
? `${answer.mode === "generated" ? "模型综合" : answer.mode === "extractive" ? "原文摘录" : "没有命中"} · 依据 ${shownMemories.length} 条已确认记忆`
: `已召回 ${shownMemories.length} 条`}
</p>
{groundedAnswer && !tooLong ? (
<button
@@ -862,17 +808,23 @@ export function Answer({
})
}
>
{t("wb.saveMemory")}
存为记忆
</button>
) : null}
{tooLong ? <p className="wb-answer-source">{t("wb.tooLong")}</p> : null}
{tooLong ? (
<p className="wb-answer-source">
这个回答太长,超过当前保存上限,暂不能存为记忆。
</p>
) : null}
{asPage ? (
// Not offered as a memory, and said out loud why. A synthesis is
// rebuilt from the memories under it every time it is read, which is
// the only reason it is safe to let a model write it: a stored
// summary drifts from what it summarised and nothing notices, and a
// rebuilt one cannot.
<p className="wb-answer-source">{t("wb.summary")}</p>
<p className="wb-answer-source">
这是综述,不是记忆。每次都会重新生成,所以它不会和下面这些记忆走散。
</p>
) : null}
<ol>
{shownMemories.map((memory) => (
@@ -891,7 +843,7 @@ export function Answer({
<HandoffBar
memories={shownMemories}
suspended={suspended}
matterLabel={t("wb.thisAnswer")}
matterLabel="这次回答"
answerText={answer.answer}
/>
)}
@@ -906,21 +858,22 @@ function EmptyStage({
loaded: boolean;
onConnect(): void;
}) {
const { t } = useGuiLanguage();
if (!loaded) {
return (
<div className="wb-graph-empty">
<p>{t("wb.loading")}</p>
<small>{t("wb.coreStarting")}</small>
<p>正在读取记忆…</p>
<small>本地 Core 启动后会自动出现,不用刷新。</small>
</div>
);
}
return (
<div className="wb-graph-empty">
<p>{t("wb.nothingToConnect")}</p>
<small>{t("wb.skeletonNote")}</small>
<p>这里还没有东西可以连起来。</p>
<small>
授权一个来源之后,先出现的是来源和主题的骨架,记忆会陆续挂上去。
</small>
<button type="button" onClick={onConnect}>
{t("wb.authorize")}
去授权一个来源
</button>
</div>
);
@@ -959,7 +912,6 @@ function narrow(
function skeleton(
sources: SourceResponse[],
graph: MemoryGraphResponse | null,
tn: TranslateCount,
): MemoryGraphResponse {
const withHistory = sources.filter(
(item) => item.authorized && item.imported_conversations > 0,
@@ -971,10 +923,7 @@ function skeleton(
nodes: withHistory.map((item) => ({
node_id: `source:${item.source_id}`,
node_type: "source" as const,
label: `${item.display_name} · ${tn(
"wb.conversations",
item.imported_conversations,
)}`,
label: `${item.display_name} · ${item.imported_conversations} 段`,
memory_id: null,
source_id: item.source_id,
kind: null,
@@ -1022,46 +971,34 @@ function evidenceOf(
/** Questions built from what is actually in this matter, so a chip never
* offers to answer something there is no record of. */
function chipsFor(memories: GovernedMemory[], t: Translate): string[] {
function chipsFor(memories: GovernedMemory[]): string[] {
if (!memories.length) return [];
const chips = [t("wb.chipChanges")];
if (memories.length >= 3) chips.push(t("wb.chipSummarize"));
const chips = ["这里最近有什么变化"];
if (memories.length >= 3) chips.push("把这些归纳成几条");
return chips;
}
function when(iso: string | null, locale: GuiLocale, t: Translate): string {
if (!iso) return t("wb.unknownTime");
function when(iso: string | null): string {
if (!iso) return "时间不明";
const at = new Date(iso);
if (Number.isNaN(at.getTime())) return t("wb.unknownTime");
return new Intl.DateTimeFormat(locale, {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(at);
if (Number.isNaN(at.getTime())) return "时间不明";
return `${at.getMonth() + 1}/${at.getDate()} ${String(at.getHours()).padStart(2, "0")}:${String(at.getMinutes()).padStart(2, "0")}`;
}
function statusLabel(status: GovernedMemory["status"], t: Translate): string {
if (status === "confirmed") return t("wb.available");
if (status === "superseded") return t("wb.superseded");
return t("wb.deleted");
function statusLabel(status: GovernedMemory["status"]): string {
if (status === "confirmed") return "已可用";
if (status === "superseded") return "已被取代";
return "已删除";
}
function nodeTypeLabel(
type: MemoryGraphNode["node_type"],
t: Translate,
): string {
if (type === "source") return t("wb.type.source");
if (type === "topic") return t("wb.type.topic");
if (type === "schema") return t("wb.type.schema");
return t("wb.type.memory");
function nodeTypeLabel(type: MemoryGraphNode["node_type"]): string {
if (type === "source") return "来源 App";
if (type === "topic") return "主题";
if (type === "schema") return "HY 归纳";
return "记忆";
}
function graphReceipt(
graph: MemoryGraphResponse,
t: Translate,
tn: TranslateCount,
): string {
function graphReceipt(graph: MemoryGraphResponse): string {
const counts = {
provenance: 0,
same_conversation: 0,
@@ -1070,91 +1007,43 @@ function graphReceipt(
};
for (const edge of graph.edges) counts[edge.origin] += 1;
const parts = [
tn("wb.edgeCount", counts.provenance, {
label: t("wb.origin.provenance"),
}),
tn("wb.edgeCount", counts.same_conversation, {
label: t("wb.origin.same_conversation"),
}),
`原话来源 ${counts.provenance} 条`,
`同一次对话 ${counts.same_conversation} 条`,
];
// Only mentioned once there is one. A young library has no shared tag yet,
// and a line reading 0 would be one more thing to read past.
if (counts.shared_tag > 0)
parts.push(
tn("wb.edgeCount", counts.shared_tag, {
label: t("wb.origin.shared_tag"),
}),
);
if (counts.shared_tag > 0) parts.push(`同一个主题 ${counts.shared_tag} 条`);
if (counts.hy_relation > 0) {
parts.push(
tn("wb.edgeCount", counts.hy_relation, {
label: t("wb.origin.hy_relation"),
}),
);
parts.push(`HY Memory 归纳 ${counts.hy_relation} 条`);
return parts.join(" · ");
}
parts.push(
graph.hy_graph_available ? t("wb.noRelations") : t("wb.noModelRelations"),
graph.hy_graph_available
? "HY Memory 还没归纳出关系,点击上方按钮运行 System 2"
: "还没配置可用模型,无法读取 HY Memory 的归纳关系",
);
return parts.join(" · ");
}
function edgeOriginLabel(
origin: MemoryGraphEdge["origin"],
t: Translate,
): string {
if (origin === "hy_relation") return t("wb.origin.hy_relation");
if (origin === "same_conversation") return t("wb.origin.same_conversation");
function edgeOriginLabel(origin: MemoryGraphEdge["origin"]): string {
if (origin === "hy_relation") return "HY Memory 归纳";
if (origin === "same_conversation") return "同一次对话";
// Named before the fallthrough. The chain ends in 原话来源, so a missing
// case here is not an error — it is a line that says the wrong thing.
if (origin === "shared_tag") return t("wb.origin.shared_tag");
return t("wb.origin.provenance");
if (origin === "shared_tag") return "同一个主题";
return "原话来源";
}
/** Walk out from one node so each drawn line can be checked by hand. */
function relationLabel(relation: string, t: Translate): string {
const labels: Record<string, Parameters<Translate>[0]> = {
evidence_source: "wb.relation.evidence_source",
same_conversation: "wb.relation.same_conversation",
shared_tag: "wb.relation.shared_tag",
derived_from: "wb.relation.derived_from",
tagged_with: "wb.relation.tagged_with",
related_to: "wb.relation.related_to",
function relationLabel(relation: string): string {
const labels: Record<string, string> = {
evidence_source: "来源于",
same_conversation: "同一次对话",
shared_tag: "同一个主题",
derived_from: "归纳自",
tagged_with: "属于主题",
related_to: "相关",
};
const key = labels[relation];
return key ? t(key) : relation.replaceAll("_", " ");
}
function localizedProvenance(detail: MemoryDetail, t: Translate): string {
const evidence = detail.evidence.filter((item) => item.available);
if (
evidence.length &&
evidence.every((item) => item.source_id === "memory-content-export")
) {
const authors = new Set(
evidence.map((item) => item.written_by ?? "unknown"),
);
const author =
authors.size !== 1
? t("provenance.multiple")
: authors.has("person")
? t("provenance.person")
: authors.has("agent")
? t("provenance.agent")
: t("provenance.unknown");
return t("provenance.imported", { author });
}
return detail.memory.origin === "manual"
? t("provenance.manual")
: detail.memory.origin === "answered"
? t("provenance.answered")
: detail.memory.origin === "agent"
? t("provenance.generated")
: t("provenance.conversation");
}
function localizedMemoryText(text: string, t: Translate): string {
const key = `memory:${text}`;
return isMessageKey(key) ? t(key) : text;
return labels[relation] ?? relation.replaceAll("_", " ");
}
/** Two numbers rather than one total.
+2 -17
View File
@@ -139,9 +139,7 @@
display: inline-flex;
align-items: center;
gap: 8px;
white-space: normal;
overflow-wrap: anywhere;
text-align: end;
white-space: nowrap;
cursor: pointer;
}
.r6-app-detail-status .r6-button-spinner {
@@ -215,20 +213,7 @@
padding: 20px 16px;
}
.r6-app-settings {
padding: 0;
margin: 0;
}
.r6-app-detail > .r6-button:first-of-type {
position: static;
width: auto;
max-width: 100%;
}
.r6-app-detail > header {
margin: 24px 0;
min-width: 0;
}
.r6-app-detail > header h2 {
overflow-wrap: anywhere;
padding: 0 18px;
}
}
@media (prefers-reduced-transparency: reduce) {
+7 -26
View File
@@ -339,9 +339,7 @@
min-width: 0;
}
.r6-home-apps > header {
min-height: 56px;
flex-wrap: wrap;
padding-block: 8px;
height: 56px;
}
button.r6-home-expand {
padding: 8px;
@@ -413,19 +411,18 @@ button.r6-home-expand:active:not(:disabled) {
flex: 0 0 auto;
}
.r6-home-app-row {
min-height: 96px;
height: 96px;
box-sizing: border-box;
border-bottom: 1px solid #d0dde5;
}
.r6-home[data-expanded="true"] .r6-home-app-row {
min-height: 112px;
height: 112px;
}
.r6-home-row-content {
display: flex;
align-items: center;
gap: 20px;
min-height: inherit;
padding-block: 12px;
height: 100%;
}
.r6-home-app-row img,
.r6-home-row-content > svg {
@@ -450,8 +447,6 @@ button.r6-home-expand:active:not(:disabled) {
}
.r6-home-app-row .r6-home-app-meta {
width: 220px;
max-width: 30%;
overflow-wrap: anywhere;
flex-shrink: 0;
margin: 0;
text-align: right;
@@ -461,9 +456,8 @@ button.r6-home-expand:active:not(:disabled) {
}
.r6-home-app-row .r6-button {
min-width: 78px;
flex-shrink: 0;
min-height: 32px;
height: auto;
min-height: 26px;
height: 26px;
padding: 0;
font-size: 12px;
}
@@ -516,20 +510,7 @@ button.r6-home-expand:focus-visible,
padding: 0 18px 12px;
}
.r6-home-row-content {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 8px 12px;
}
.r6-home-app-row .r6-home-app-meta {
grid-column: 2;
grid-row: 2;
width: auto;
max-width: none;
text-align: left;
}
.r6-home-row-content > .r6-button {
grid-column: 3;
grid-row: 1 / 3;
gap: 12px;
}
.r6-home-heading {
flex-wrap: wrap;
Loaded 100 of 200 files, more files were not shown because too many files have changed in this diff. Show more