Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
384c7476ad | ||
|
|
059125a09f | ||
|
|
670c7c6cb1 | ||
|
|
aa633623da | ||
|
|
37d8662b93 | ||
|
|
9459a1107b | ||
|
|
2c6f269170 | ||
|
|
7a2ff2f4c4 | ||
|
|
35128f2c84 | ||
|
|
f12a95ce7a | ||
|
|
e786657a0b |
No files matched your search
@@ -4,8 +4,46 @@ Hy Memory and MemHub are versioned and released independently.
|
||||
|
||||
## Unreleased
|
||||
|
||||
## MemHub 1.0.6 — 2026-11-02
|
||||
|
||||
Build 37, on `hy-memory==2.0.11`. Linux x64/arm64 Runtime and macOS arm64
|
||||
App and Runtime.
|
||||
|
||||
- Interfaces in 11 languages: English, Simplified and Traditional Chinese,
|
||||
Japanese, Spanish, German, Portuguese, French, Korean, Russian and
|
||||
Indonesian. Choosing any language other than Simplified Chinese also sets
|
||||
the SDK prompt language; Simplified Chinese keeps the automatic zh/en
|
||||
detection.
|
||||
- Memory modes: lite (no LLM, keeps raw conversations), pro (LLM extraction)
|
||||
and ultra (pro plus the knowledge graph and a nightly evolve run). Set with
|
||||
`GET`/`PUT /api/v1/settings/memory-mode` or `memhub settings mode`. Raw
|
||||
conversations are now kept permanently. `POST /api/v2/evolve` and
|
||||
`memhub evolve` run System 2 on demand.
|
||||
- Safe embedding-model switching: an embedding-space registry, a switch that
|
||||
pauses writes, re-embeds every user's vectors and the graph, verifies and
|
||||
commits atomically, and resumes after a crash or an App update. Every start
|
||||
scans the vector store and merges data left in other spaces. When the
|
||||
embedding service is unavailable, search returns `embedding_unavailable`.
|
||||
- Multi-user Runtime: memory APIs and the CLI take `user_id` (default
|
||||
`local`); records are isolated per user; Hermes profiles can be connected as
|
||||
separate users. The App keeps using `local`.
|
||||
- Configurable SDK features through the CLI and API (`memhub settings
|
||||
features`): graph rerank, episode recall, session episodes, episode
|
||||
summaries, intention and few-shot. Defaults are unchanged.
|
||||
- Search and recall pass through the SDK's search parameters with server-side
|
||||
score floors; the memory library filters by layer; `/api/v2/raw` returns
|
||||
data again; v2 source and import routes; profile version history.
|
||||
- Upgrade fixes: migration no longer stops without a model profile (#234) or
|
||||
on a pre-existing readback mismatch (#229, #240); source grants work after
|
||||
the cutover (#233); reads use the active embedding (#242); the embedding
|
||||
endpoint no longer follows the generation `base_url`.
|
||||
- Hermes import drops gateway notices and probes and unwraps relayed
|
||||
messages; `memhub logs` no longer reports valid older records as damaged.
|
||||
|
||||
## Hy Memory 2.0.11 — 2026-11-01
|
||||
|
||||
- Published
|
||||
[`hy-memory==2.0.11`](https://pypi.org/project/hy-memory/2.0.11/).
|
||||
- LLM prompts now ship in 11 languages: `en`, `zh-Hans`,
|
||||
`zh-Hant`, `ja`, `es`, `de`, `pt`, `fr`, `ko`, `ru` and `id`. They live in
|
||||
one registry, `hy_memory/prompts/`, with one module per language. Call
|
||||
|
||||
@@ -33,6 +33,7 @@ English | [中文](README.zh.md)
|
||||
## Latest News
|
||||
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Open-sourced** quantized **FP8** (**[Hy-Memory-30B-A3B-FP8](https://huggingface.co/tencent/Hy-Memory-30B-A3B-FP8)**) and **GGUF** (**[Hy-Memory-30B-A3B-GGUF](https://huggingface.co/tencent/Hy-Memory-30B-A3B-GGUF)**) versions of **Hy-Memory-30B-A3B** on Hugging Face!
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-11]**: 📦 **Released [MemHub v1.0.6](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.6)** with interfaces in 11 languages, memory modes (lite / pro / ultra with nightly evolve), safe embedding-model switching, multi-user support for the CLI and API, and more reliable upgrades, on **`hy-memory==2.0.11`**. See the release notes for details.
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **Released [MemHub v1.0.5](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.5)** with Chinese, English and Spanish interfaces, one-time pairing codes for more custom Harnesses, and smoother browsing, search, startup and recovery, on **`hy-memory==2.0.8`**. See the release notes for details.
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **Released [MemHub v1.0.4](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.4)** and **[memhub-cli 1.0.5](https://www.npmjs.com/package/memhub-cli/v/1.0.5)**, with a signed macOS desktop app and verified macOS/Linux Runtimes!
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Released** **Hy Memory 2.0 (`hy-memory==2.0.4`)** on [PyPI](https://pypi.org/project/hy-memory/2.0.4/) and open-sourced the **Hy-Memory** monorepo together with **[MemHub](memhub/README.md)**!
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
## 最新动态
|
||||
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **模型开源** 在 Hugging Face 发布 **Hy-Memory-30B-A3B** 的 **FP8**(**[Hy-Memory-30B-A3B-FP8](https://huggingface.co/tencent/Hy-Memory-30B-A3B-FP8)**)与 **GGUF**(**[Hy-Memory-30B-A3B-GGUF](https://huggingface.co/tencent/Hy-Memory-30B-A3B-GGUF)**)量化版本模型!
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-11]**: 📦 **正式发布 [MemHub v1.0.6](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.6)**:界面支持 11 种语言,新增记忆模式(lite / pro / ultra,ultra 每晚自动整理),可安全切换嵌入模型,CLI 和 API 支持多用户,升级更可靠;底层使用 **`hy-memory==2.0.11`**。详见发布说明。
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **正式发布 [MemHub v1.0.5](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.5)**:支持中文、英文和西班牙语界面,可通过一次性配对码连接更多自定义 Harness,并优化了记忆浏览、搜索、启动和异常恢复;底层使用 **`hy-memory==2.0.8`**。详见发布说明。
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **正式发布 [MemHub v1.0.4](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.4)** 与 **[memhub-cli 1.0.5](https://www.npmjs.com/package/memhub-cli/v/1.0.5)**,提供已签名的 macOS 桌面应用及经过验证的 macOS/Linux Runtime!
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **正式发布** **Hy Memory 2.0 (`hy-memory==2.0.4`)**(已上线 [PyPI](https://pypi.org/project/hy-memory/2.0.4/)),并开源 **Hy-Memory** monorepo 与 **[MemHub](memhub/README.md)**!
|
||||
|
||||
+4
-4
@@ -18,13 +18,13 @@ If you run into a problem, please open an Issue with the version, platform,repro
|
||||
|
||||
## 发行与平台合同
|
||||
|
||||
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.10`。MemHub 源码版本为 `1.0.5`
|
||||
(build 36),GitHub 仓库为
|
||||
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.11`。MemHub 源码版本为 `1.0.6`
|
||||
(build 37),GitHub 仓库为
|
||||
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
|
||||
`memhub-cli`。当前代码架构见
|
||||
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
|
||||
|
||||
`v1.0.5` 的发行目标是 Linux x64/arm64 Runtime 与 macOS arm64 App 和 Runtime。
|
||||
`v1.0.6` 的发行目标是 Linux x64/arm64 Runtime 与 macOS arm64 App 和 Runtime。
|
||||
Linux 制品由 GitHub Actions 在原生架构 runner 上构建并完成生命周期验收;macOS
|
||||
制品由发行流水线完成 Developer ID 签名、公证和验收后手动上传。Windows 不支持。
|
||||
只有同时出现在 GitHub Release 与 `release-manifest.json` 中、且通过公开校验的制品才属于
|
||||
@@ -118,7 +118,7 @@ MemHub 的 SQLite 数据默认位于 `~/.one-memory`,不在 npm 包或 Runtime
|
||||
|
||||
## Hy Memory
|
||||
|
||||
SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.10`。SDK 配置、权限边界及 search
|
||||
SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.11`。SDK 配置、权限边界及 search
|
||||
返回格式见 [`MEMORY.md`](MEMORY.md)。HY search 返回的 `content` 已包含 SDK 统一生成的
|
||||
逻辑时间和演化上下文;调用方不应再次转换时间戳或重复拼接 `When`。
|
||||
|
||||
|
||||
@@ -2352,7 +2352,7 @@
|
||||
{
|
||||
"SPDXID": "SPDXRef-Package-hy-memory",
|
||||
"name": "hy-memory",
|
||||
"versionInfo": "2.0.10",
|
||||
"versionInfo": "2.0.11",
|
||||
"downloadLocation": "NOASSERTION",
|
||||
"filesAnalyzed": false,
|
||||
"licenseConcluded": "Apache-2.0",
|
||||
|
||||
@@ -9,7 +9,7 @@ Per-package license texts are not reproduced here yet; the license
|
||||
column is filled in as part of release review, and the SBOM records
|
||||
`NOASSERTION` until then rather than guessing.
|
||||
|
||||
## hy-memory 2.0.10
|
||||
## hy-memory 2.0.11
|
||||
|
||||
License: Apache-2.0
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@one-memory/cli",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.6",
|
||||
"description": "MemHub bootstrap CLI for installing and managing the local-first shared memory runtime",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -203,6 +203,13 @@ import {
|
||||
renderFeaturesText,
|
||||
runFeaturesCommand,
|
||||
} from "./memory-features-flow.js";
|
||||
import {
|
||||
type LanguageDocument,
|
||||
languageExitCode,
|
||||
parseLanguageCommand,
|
||||
renderLanguageText,
|
||||
runLanguageCommand,
|
||||
} from "./memory-language-flow.js";
|
||||
import {
|
||||
type EvolveDocument,
|
||||
evolveExitCode,
|
||||
@@ -2379,6 +2386,40 @@ async function runCommand(
|
||||
);
|
||||
return featuresExitCode(document);
|
||||
}
|
||||
if (
|
||||
named === "settings" &&
|
||||
arguments_.slice(1).find((value) => value !== "--json") === "language"
|
||||
) {
|
||||
const parsed = parseLanguageCommand(
|
||||
arguments_
|
||||
.slice(1)
|
||||
.filter((value, index, all) => index !== all.indexOf("language")),
|
||||
);
|
||||
if (!parsed) return usage();
|
||||
const connected = await connectRuntime(environment);
|
||||
if (!connected.ok) {
|
||||
const document: LanguageDocument = {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
command: "settings language",
|
||||
state: "unavailable",
|
||||
code: connected.code,
|
||||
};
|
||||
emit(
|
||||
asJson ? `${JSON.stringify(document)}\n` : renderLanguageText(document),
|
||||
);
|
||||
return connected.exitCode;
|
||||
}
|
||||
const document = await runLanguageCommand(
|
||||
parsed,
|
||||
connected.reader,
|
||||
connected.operations,
|
||||
);
|
||||
emit(
|
||||
asJson ? `${JSON.stringify(document)}\n` : renderLanguageText(document),
|
||||
);
|
||||
return languageExitCode(document);
|
||||
}
|
||||
if (named === "settings") {
|
||||
const parsed = parseSettingsCommand(arguments_.slice(1));
|
||||
if (!parsed) return usage();
|
||||
|
||||
@@ -461,12 +461,13 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
{
|
||||
name: "settings",
|
||||
summary:
|
||||
"查看或切换记忆模式(lite / pro / ultra)。lite 不调用模型,只在原始对话里检索;pro 由模型提取记忆(System 1);ultra 在 pro 之上每晚自动进化(System 2,使用知识图谱)。任何模式都保留全部原始对话。切换不迁移数据:已处理的不会重做,排队中的和新的按新模式处理;需 --yes,不加时只显示计划。环境变量 ONE_MEMORY_GRAPH_STORE 会强制 ultra。settings features 查看或开关 SDK 的可选检索与提取功能(全局设置,默认全部保持 SDK 默认值):graph_rerank 图谱重排(仅 ultra)、same_series_timeline 同系列时间线(需 graph_rerank)、episode_recall 片段召回(pro / ultra,需先开 session_episodes 写入片段)、session_episodes 片段写入、episode_summary 片段摘要(每个片段一次模型调用)、intention 意图提取(默认开)、few_shot 提取示例;set 需 --yes,不加时只显示计划;reset 恢复默认值。",
|
||||
"查看或切换记忆模式(lite / pro / ultra)。lite 不调用模型,只在原始对话里检索;pro 由模型提取记忆(System 1);ultra 在 pro 之上每晚自动进化(System 2,使用知识图谱)。任何模式都保留全部原始对话。切换不迁移数据:已处理的不会重做,排队中的和新的按新模式处理;需 --yes,不加时只显示计划。环境变量 ONE_MEMORY_GRAPH_STORE 会强制 ultra。settings features 查看或开关 SDK 的可选检索与提取功能(全局设置,默认全部保持 SDK 默认值):graph_rerank 图谱重排(仅 ultra)、same_series_timeline 同系列时间线(需 graph_rerank)、episode_recall 片段召回(pro / ultra,需先开 session_episodes 写入片段)、session_episodes 片段写入、episode_summary 片段摘要(每个片段一次模型调用)、intention 意图提取(默认开)、few_shot 提取示例;set 需 --yes,不加时只显示计划;reset 恢复默认值。settings language 查看或设置界面语言(App 切换语言时会自动设置):简体中文(zh-Hans)和 system 不指定 SDK 语言,SDK 照旧自动识别中文或英文;其余语言(en、zh-Hant、ja、es、de、pt、fr、ko、ru、id)会让提取等 SDK 提示使用该语言,立即生效。",
|
||||
usage:
|
||||
"memhub settings mode [lite|pro|ultra] [--yes] [--json]\n" +
|
||||
" memhub settings features [--json]\n" +
|
||||
" memhub settings features set <name>=<true|false> [...] [--yes] [--json]\n" +
|
||||
" memhub settings features reset [<name> ...] [--json]",
|
||||
" memhub settings features reset [<name> ...] [--json]\n" +
|
||||
" memhub settings language [<code>|system] [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "mode [lite|pro|ultra]",
|
||||
@@ -481,6 +482,11 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
flag: "features reset [<name> ...]",
|
||||
summary: "把指定功能(不写则全部)恢复为 SDK 默认值。",
|
||||
},
|
||||
{
|
||||
flag: "language [<code>|system]",
|
||||
summary:
|
||||
"不带参数显示界面语言和 SDK 提示语言;带语言代码(en、zh-Hans、zh-Hant、ja、es、de、pt、fr、ko、ru、id)或 system 时设置。",
|
||||
},
|
||||
CONFIRM_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
|
||||
@@ -23,6 +23,7 @@ const READ_ONLY_PATHS = [
|
||||
"/api/v2/user-profile/versions",
|
||||
"/api/v1/settings/memory-mode",
|
||||
"/api/v1/settings/memory-features",
|
||||
"/api/v1/settings/ui-language",
|
||||
"/api/v2/evolve",
|
||||
] as const;
|
||||
/** One evolve run's receipt (`memhub evolve --wait`). */
|
||||
|
||||
@@ -31,6 +31,8 @@ const EVOLVE_PATH = "/api/v2/evolve";
|
||||
const MEMORY_MODE_PATH = "/api/v1/settings/memory-mode";
|
||||
/** `memhub settings features set|reset` sets the SDK features. */
|
||||
const MEMORY_FEATURES_PATH = "/api/v1/settings/memory-features";
|
||||
/** `memhub settings language <code>` sets the display language Core uses. */
|
||||
const UI_LANGUAGE_PATH = "/api/v1/settings/ui-language";
|
||||
const ONBOARDING_COMPLETE_PATH = "/api/v1/onboarding/complete";
|
||||
const EMBEDDING_SWITCH_PATHS = [
|
||||
"/api/v1/embedding-switches",
|
||||
@@ -165,6 +167,7 @@ export function createCoreOperations(attachment: {
|
||||
if (
|
||||
path !== MEMORY_MODE_PATH &&
|
||||
path !== MEMORY_FEATURES_PATH &&
|
||||
path !== UI_LANGUAGE_PATH &&
|
||||
(!SDK_MEMORY_PATH.test(path) || path.includes("?"))
|
||||
) {
|
||||
throw new Error("core_operation_path_not_allowed");
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { run } from "./cli.js";
|
||||
import { parseLanguageCommand } from "./memory-language-flow.js";
|
||||
import { composeStatusReport, renderStatusText } from "./status-report.js";
|
||||
|
||||
const transport = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
}));
|
||||
vi.mock("axios", () => ({
|
||||
default: {
|
||||
create: vi.fn(() => transport),
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
isAxiosError: () => false,
|
||||
},
|
||||
}));
|
||||
|
||||
const runtime = vi.hoisted(() => ({
|
||||
inspectRuntimeStatus: vi.fn(),
|
||||
discoverRuntimeAttachment: vi.fn(),
|
||||
}));
|
||||
vi.mock("@one-memory/runtime-control", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@one-memory/runtime-control")>()),
|
||||
...runtime,
|
||||
}));
|
||||
|
||||
function languageState(uiLanguage: string | null, extra = {}) {
|
||||
const sdk =
|
||||
uiLanguage === null || uiLanguage === "zh-Hans" ? "auto" : uiLanguage;
|
||||
return {
|
||||
ui_language: uiLanguage,
|
||||
sdk_language: sdk,
|
||||
languages: [
|
||||
"en",
|
||||
"zh-Hans",
|
||||
"zh-Hant",
|
||||
"ja",
|
||||
"es",
|
||||
"de",
|
||||
"pt",
|
||||
"fr",
|
||||
"ko",
|
||||
"ru",
|
||||
"id",
|
||||
],
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
for (const method of Object.values(transport)) method.mockReset();
|
||||
runtime.inspectRuntimeStatus.mockResolvedValue({
|
||||
state: "running",
|
||||
code: "runtime_verified",
|
||||
instanceId: "instance-1",
|
||||
host: "127.0.0.1",
|
||||
port: 8765,
|
||||
version: "1.2.3",
|
||||
});
|
||||
runtime.discoverRuntimeAttachment.mockResolvedValue({
|
||||
instanceId: "instance-1",
|
||||
apiToken: "synthetic-runtime-token",
|
||||
host: "127.0.0.1",
|
||||
port: 8765,
|
||||
servicePid: 4242,
|
||||
health: {
|
||||
status: "ok",
|
||||
service: "one-memory-core",
|
||||
version: "1.2.3",
|
||||
instance_id: "instance-1",
|
||||
},
|
||||
});
|
||||
transport.get.mockImplementation(async (url: string) =>
|
||||
url === "/api/v1/settings/ui-language"
|
||||
? { status: 200, data: languageState(null) }
|
||||
: { status: 404, data: {} },
|
||||
);
|
||||
});
|
||||
|
||||
describe("memhub settings language", () => {
|
||||
it("shows the display language and the SDK prompt language", async () => {
|
||||
const result = await capturedRun(["settings", "language", "--json"]);
|
||||
expect(result.code).toBe(0);
|
||||
const json = JSON.parse(result.stdout);
|
||||
expect(json).toMatchObject({
|
||||
command: "settings language",
|
||||
state: "ok",
|
||||
language: { ui_language: null, sdk_language: "auto" },
|
||||
});
|
||||
expect(transport.put).not.toHaveBeenCalled();
|
||||
const text = (await capturedRun(["settings", "language"])).stdout;
|
||||
expect(text).toContain("界面语言:跟随系统(未选择)");
|
||||
expect(text).toContain("SDK 提示语言:auto");
|
||||
});
|
||||
|
||||
it("sets a language at once and reports the SDK language", async () => {
|
||||
transport.put.mockResolvedValue({
|
||||
status: 200,
|
||||
data: languageState("ja", { rebuilt: true }),
|
||||
});
|
||||
const result = await capturedRun(["settings", "language", "ja", "--json"]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(transport.put).toHaveBeenCalledWith(
|
||||
"/api/v1/settings/ui-language",
|
||||
{ ui_language: "ja" },
|
||||
undefined,
|
||||
);
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
state: "committed",
|
||||
rebuilt: true,
|
||||
language: { ui_language: "ja", sdk_language: "ja" },
|
||||
});
|
||||
const text = (await capturedRun(["settings", "language", "ja"])).stdout;
|
||||
expect(text).toContain("界面语言:ja(日本語)");
|
||||
});
|
||||
|
||||
it("sends null for system and maps the legacy zh-CN code", async () => {
|
||||
transport.put.mockResolvedValue({
|
||||
status: 200,
|
||||
data: languageState("zh-Hans", { rebuilt: false }),
|
||||
});
|
||||
await capturedRun(["settings", "language", "zh-CN"]);
|
||||
expect(transport.put).toHaveBeenLastCalledWith(
|
||||
"/api/v1/settings/ui-language",
|
||||
{ ui_language: "zh-Hans" },
|
||||
undefined,
|
||||
);
|
||||
transport.get.mockResolvedValue({
|
||||
status: 200,
|
||||
data: languageState("en"),
|
||||
});
|
||||
transport.put.mockResolvedValue({
|
||||
status: 200,
|
||||
data: languageState(null, { rebuilt: true }),
|
||||
});
|
||||
await capturedRun(["settings", "language", "system"]);
|
||||
expect(transport.put).toHaveBeenLastCalledWith(
|
||||
"/api/v1/settings/ui-language",
|
||||
{ ui_language: null },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not write a language Core already has", async () => {
|
||||
const result = await capturedRun(["settings", "language", "system"]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(
|
||||
JSON.parse(
|
||||
(await capturedRun(["settings", "language", "system", "--json"]))
|
||||
.stdout,
|
||||
).state,
|
||||
).toBe("unchanged");
|
||||
expect(transport.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses unknown codes before the Runtime", async () => {
|
||||
for (const bad of [
|
||||
["settings", "language", "it"],
|
||||
["settings", "language", "EN"],
|
||||
["settings", "language", "ja", "ko"],
|
||||
]) {
|
||||
expect((await capturedRun(bad)).code, bad.join(" ")).toBe(2);
|
||||
}
|
||||
expect(transport.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("parses the forms the help shows", () => {
|
||||
expect(parseLanguageCommand([])).toEqual({ kind: "show" });
|
||||
expect(parseLanguageCommand(["system"])).toEqual({
|
||||
kind: "set",
|
||||
language: null,
|
||||
});
|
||||
expect(parseLanguageCommand(["zh-Hant", "--json"])).toEqual({
|
||||
kind: "set",
|
||||
language: "zh-Hant",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("status language", () => {
|
||||
it.each([
|
||||
[null, "auto", "跟随系统", "auto(自动识别中/英)"],
|
||||
["ja", "ja", "ja", "SDK 提示语言 ja"],
|
||||
])(
|
||||
"shows the UI language %s and the SDK language %s",
|
||||
async (uiLanguage, sdkLanguage, uiText, sdkText) => {
|
||||
const processing = {
|
||||
pending: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
blocked_stages: [],
|
||||
pipeline: {
|
||||
mode: "pro",
|
||||
configured_mode: "pro",
|
||||
forced_by_env: false,
|
||||
last_evolve_at: null,
|
||||
graph_build: { state: "idle", users: {} },
|
||||
evolve: { mode: "pro", schedule: null, running: [], users: {} },
|
||||
features: {},
|
||||
ui_language: uiLanguage,
|
||||
sdk_language: sdkLanguage,
|
||||
},
|
||||
};
|
||||
const report = await composeStatusReport({
|
||||
dataDir: "/tmp/isolated",
|
||||
runtime: { state: "running", code: "ok", host: "127.0.0.1", port: 1 },
|
||||
background: null,
|
||||
reader: (async (path: string) =>
|
||||
path === "/api/v1/memory-processing"
|
||||
? { state: "ok", data: processing }
|
||||
: { state: "unavailable", code: "x" }) as never,
|
||||
});
|
||||
expect(report.pipeline?.ui_language).toBe(uiLanguage);
|
||||
expect(report.pipeline?.sdk_language).toBe(sdkLanguage);
|
||||
const text = renderStatusText(report, "MemHub");
|
||||
expect(text).toContain(uiText);
|
||||
expect(text).toContain(sdkText);
|
||||
},
|
||||
);
|
||||
|
||||
it("reads an older Runtime without the setting as auto", async () => {
|
||||
const report = await composeStatusReport({
|
||||
dataDir: "/tmp/isolated",
|
||||
runtime: { state: "running", code: "ok", host: "127.0.0.1", port: 1 },
|
||||
background: null,
|
||||
reader: (async () => ({
|
||||
state: "ok",
|
||||
data: {
|
||||
pipeline: {
|
||||
mode: "pro",
|
||||
configured_mode: "pro",
|
||||
evolve: {},
|
||||
graph_build: {},
|
||||
},
|
||||
},
|
||||
})) as never,
|
||||
});
|
||||
expect(report.pipeline?.sdk_language).toBe("auto");
|
||||
expect(report.pipeline?.ui_language).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
async function capturedRun(arguments_: string[]) {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const stdoutSpy = vi
|
||||
.spyOn(process.stdout, "write")
|
||||
.mockImplementation((chunk) => {
|
||||
stdout += String(chunk);
|
||||
return true;
|
||||
});
|
||||
const stderrSpy = vi
|
||||
.spyOn(process.stderr, "write")
|
||||
.mockImplementation((chunk) => {
|
||||
stderr += String(chunk);
|
||||
return true;
|
||||
});
|
||||
try {
|
||||
return {
|
||||
code: await run(arguments_, { ONE_MEMORY_DATA_DIR: "/synthetic/data" }),
|
||||
stdout,
|
||||
stderr,
|
||||
};
|
||||
} finally {
|
||||
stdoutSpy.mockRestore();
|
||||
stderrSpy.mockRestore();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* `memhub settings language [<code>|system]`.
|
||||
*
|
||||
* The display language the App reports to the Runtime (`ui_language`), and
|
||||
* the SDK prompt language it implies. zh-Hans and "system" (no explicit
|
||||
* choice) pass no language, so the SDK keeps detecting Chinese or English as
|
||||
* before; every other code, en included, makes extraction and the other SDK
|
||||
* prompts use that language. A change applies at once: a write in flight
|
||||
* finishes in the old language, the next ones use the new one. The App sends
|
||||
* its own choice again whenever it changes there.
|
||||
*/
|
||||
|
||||
import type { CoreReader } from "./core-client.js";
|
||||
import type { CorePutOperations } from "./core-operations.js";
|
||||
import { PRODUCT } from "./version.js";
|
||||
|
||||
export const UI_LANGUAGES = [
|
||||
"en",
|
||||
"zh-Hans",
|
||||
"zh-Hant",
|
||||
"ja",
|
||||
"es",
|
||||
"de",
|
||||
"pt",
|
||||
"fr",
|
||||
"ko",
|
||||
"ru",
|
||||
"id",
|
||||
] as const;
|
||||
export type UiLanguage = (typeof UI_LANGUAGES)[number];
|
||||
|
||||
/** Codes earlier builds used, and the code each one is now. */
|
||||
const LEGACY: Record<string, UiLanguage> = { "zh-CN": "zh-Hans" };
|
||||
|
||||
const NAMES: Record<UiLanguage, string> = {
|
||||
en: "English",
|
||||
"zh-Hans": "简体中文",
|
||||
"zh-Hant": "繁體中文",
|
||||
ja: "日本語",
|
||||
es: "Español",
|
||||
de: "Deutsch",
|
||||
pt: "Português",
|
||||
fr: "Français",
|
||||
ko: "한국어",
|
||||
ru: "Русский",
|
||||
id: "Bahasa Indonesia",
|
||||
};
|
||||
|
||||
export type LanguageCommand =
|
||||
| { kind: "show" }
|
||||
| { kind: "set"; language: UiLanguage | null };
|
||||
|
||||
function isUiLanguage(value: string): value is UiLanguage {
|
||||
return (UI_LANGUAGES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/** Arguments after `settings language`; null = usage. */
|
||||
export function parseLanguageCommand(
|
||||
values: readonly string[],
|
||||
): LanguageCommand | null {
|
||||
const rest = values.filter((value) => value !== "--json");
|
||||
if (rest.length === 0) return { kind: "show" };
|
||||
if (rest.length !== 1) return null;
|
||||
const raw = rest[0] ?? "";
|
||||
const value: string = Object.hasOwn(LEGACY, raw) ? LEGACY[raw] : raw;
|
||||
if (value === "system") return { kind: "set", language: null };
|
||||
return isUiLanguage(value) ? { kind: "set", language: value } : null;
|
||||
}
|
||||
|
||||
export interface LanguageState {
|
||||
ui_language: UiLanguage | null;
|
||||
sdk_language: string;
|
||||
rebuilt?: boolean;
|
||||
}
|
||||
|
||||
function languageState(value: unknown): LanguageState | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const item = value as Record<string, unknown>;
|
||||
if (
|
||||
!(item.ui_language === null || isUiLanguage(String(item.ui_language))) ||
|
||||
typeof item.sdk_language !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
ui_language: item.ui_language as UiLanguage | null,
|
||||
sdk_language: item.sdk_language,
|
||||
...(typeof item.rebuilt === "boolean" ? { rebuilt: item.rebuilt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export type LanguageDocument = {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
command: "settings language";
|
||||
} & (
|
||||
| { state: "ok" | "unchanged"; language: LanguageState }
|
||||
| { state: "committed"; language: LanguageState; rebuilt: boolean }
|
||||
| { state: "rejected" | "unknown" | "unavailable"; code: string }
|
||||
);
|
||||
|
||||
const BASE = {
|
||||
schema_version: 1 as const,
|
||||
product: PRODUCT,
|
||||
command: "settings language" as const,
|
||||
};
|
||||
|
||||
const PATH = "/api/v1/settings/ui-language";
|
||||
|
||||
export async function runLanguageCommand(
|
||||
command: LanguageCommand,
|
||||
reader: CoreReader,
|
||||
operations: CorePutOperations,
|
||||
): Promise<LanguageDocument> {
|
||||
const read = await reader<unknown>(PATH);
|
||||
if (read.state !== "ok") {
|
||||
return { ...BASE, state: "unavailable", code: read.code };
|
||||
}
|
||||
const current = languageState(read.data);
|
||||
if (current === null) {
|
||||
return { ...BASE, state: "unavailable", code: "response_invalid" };
|
||||
}
|
||||
if (command.kind === "show") {
|
||||
return { ...BASE, state: "ok", language: current };
|
||||
}
|
||||
if (current.ui_language === command.language) {
|
||||
return { ...BASE, state: "unchanged", language: current };
|
||||
}
|
||||
const result = await operations.put<unknown>(PATH, {
|
||||
ui_language: command.language,
|
||||
});
|
||||
if (result.state !== "committed") {
|
||||
return { ...BASE, state: result.state, code: result.code };
|
||||
}
|
||||
const changed = languageState(result.data);
|
||||
if (changed === null) {
|
||||
return { ...BASE, state: "unknown", code: "response_invalid" };
|
||||
}
|
||||
return {
|
||||
...BASE,
|
||||
state: "committed",
|
||||
language: changed,
|
||||
rebuilt: changed.rebuilt === true,
|
||||
};
|
||||
}
|
||||
|
||||
export function languageExitCode(document: LanguageDocument): number {
|
||||
return ["ok", "unchanged", "committed"].includes(document.state) ? 0 : 1;
|
||||
}
|
||||
|
||||
function sdkText(sdkLanguage: string): string {
|
||||
return sdkLanguage === "auto"
|
||||
? "auto(SDK 自动识别中文或英文)"
|
||||
: `${sdkLanguage}(提取等 SDK 提示使用该语言)`;
|
||||
}
|
||||
|
||||
export function renderLanguageText(document: LanguageDocument): string {
|
||||
if ("code" in document) {
|
||||
return `界面语言未能${document.state === "unavailable" ? "读取" : "修改"}:${document.code}\n`;
|
||||
}
|
||||
const state = document.language;
|
||||
const lines = [
|
||||
`界面语言:${
|
||||
state.ui_language === null
|
||||
? "跟随系统(未选择)"
|
||||
: `${state.ui_language}(${NAMES[state.ui_language]})`
|
||||
}`,
|
||||
`SDK 提示语言:${sdkText(state.sdk_language)}`,
|
||||
];
|
||||
if (document.state === "ok") {
|
||||
lines.push(
|
||||
`可选:${UI_LANGUAGES.join("、")}、system。简体中文和 system 不指定语言,其余语言(包括 en)会让 SDK 使用该语言。`,
|
||||
);
|
||||
}
|
||||
if (document.state === "unchanged") lines.push("没有需要改动的设置。");
|
||||
if (document.state === "committed") {
|
||||
lines.push(
|
||||
document.rebuilt
|
||||
? "已更新。正在进行的写入按原语言完成,之后的写入和检索使用新语言。"
|
||||
: "已保存,SDK 提示语言没有变化。",
|
||||
);
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
@@ -142,7 +142,7 @@ describe("memhub settings mode", () => {
|
||||
["settings", "mode", "max"],
|
||||
["settings", "mode", "--yes"],
|
||||
["settings", "mode", "lite", "pro"],
|
||||
["settings", "language", "en"],
|
||||
["settings", "theme", "dark"],
|
||||
]) {
|
||||
expect((await capturedRun(bad)).code, bad.join(" ")).toBe(2);
|
||||
}
|
||||
|
||||
@@ -86,6 +86,10 @@ export interface PipelineSummary {
|
||||
graph_build: string | null;
|
||||
/** SDK features (`memory_features`) whose value differs from the default. */
|
||||
features: Record<string, boolean>;
|
||||
/** The App's display language; null = never chosen or "follow the system". */
|
||||
ui_language: string | null;
|
||||
/** The SDK prompt language: `auto` (zh/en detection) or the code passed. */
|
||||
sdk_language: string;
|
||||
}
|
||||
|
||||
const MODES = new Set(["lite", "pro", "ultra"]);
|
||||
@@ -158,6 +162,10 @@ export function describePipeline(value: unknown): PipelineSummary | null {
|
||||
evolve_users: users,
|
||||
graph_build: typeof graph.state === "string" ? graph.state : null,
|
||||
features,
|
||||
ui_language: typeof item.ui_language === "string" ? item.ui_language : null,
|
||||
// An older Runtime has no setting: it always left the SDK on auto.
|
||||
sdk_language:
|
||||
typeof item.sdk_language === "string" ? item.sdk_language : "auto",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -353,6 +361,13 @@ export function pipelineLine(pipeline: PipelineSummary): string {
|
||||
.join("、")}`,
|
||||
);
|
||||
}
|
||||
parts.push(
|
||||
`;界面语言 ${pipeline.ui_language ?? "跟随系统"},SDK 提示语言 ${
|
||||
pipeline.sdk_language === "auto"
|
||||
? "auto(自动识别中/英)"
|
||||
: pipeline.sdk_language
|
||||
}`,
|
||||
);
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "one-memory"
|
||||
version = "1.0.5"
|
||||
version = "1.0.6"
|
||||
description = "MemHub local-first memory core"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"fastapi>=0.141.1",
|
||||
"filelock>=3.32.4",
|
||||
# Public SDK version validated against MemHub's permission and recall contracts.
|
||||
"hy-memory==2.0.10",
|
||||
"hy-memory==2.0.11",
|
||||
# chromadb arrives through the HY SDK; locked here so an unrelated upgrade
|
||||
# cannot move the SDK's store. Built-in embedding consumes numpy directly.
|
||||
"chromadb==1.5.9",
|
||||
|
||||
@@ -300,6 +300,12 @@ from one_memory.source_read_access import SourceReadAccess
|
||||
from one_memory.source_totals import SourceTotals
|
||||
from one_memory.strict_query import forbid_unknown_query_params
|
||||
from one_memory.target_schema import bootstrap_fresh_management_schema
|
||||
from one_memory.ui_language import (
|
||||
SDK_LANGUAGE_AUTO,
|
||||
UI_LANGUAGES,
|
||||
UiLanguage,
|
||||
sdk_language,
|
||||
)
|
||||
from one_memory.user_scope import (
|
||||
DEFAULT_USER_ID,
|
||||
MULTI_USER_SOURCES,
|
||||
@@ -759,6 +765,38 @@ class PipelineStatus(BaseModel):
|
||||
#: The SDK features (`memory_features`) whose value differs from the SDK
|
||||
#: default; empty while every feature is at its default.
|
||||
features: dict[MemoryFeatureName, bool] = Field(default_factory=dict)
|
||||
#: The display language the App last reported (`ui_language`); null =
|
||||
#: never chosen or "follow the system".
|
||||
ui_language: UiLanguage | None = None
|
||||
#: The SDK prompt language: `auto` (the SDK detects zh/en) or a code.
|
||||
sdk_language: str = SDK_LANGUAGE_AUTO
|
||||
|
||||
|
||||
class UiLanguageState(BaseModel):
|
||||
"""`GET /api/v1/settings/ui-language`."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
#: What the App reported; null = never chosen or "follow the system".
|
||||
ui_language: UiLanguage | None
|
||||
#: What SDK clients are built with: `auto` (zh-Hans or no choice: the SDK
|
||||
#: detects zh/en, as before) or the code passed as `MemoryConfig.language`.
|
||||
sdk_language: str
|
||||
languages: tuple[UiLanguage, ...] = UI_LANGUAGES
|
||||
|
||||
|
||||
class UiLanguageUpdate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
#: A supported code, or null for "follow the system" (SDK auto-detect).
|
||||
ui_language: UiLanguage | None
|
||||
|
||||
|
||||
class UiLanguageChange(UiLanguageState):
|
||||
"""`PUT /api/v1/settings/ui-language`."""
|
||||
|
||||
#: Whether the SDK language changed, so the SDK clients were rebuilt.
|
||||
rebuilt: bool
|
||||
|
||||
|
||||
class MemoryFeatureState(BaseModel):
|
||||
@@ -1303,6 +1341,7 @@ def create_app(
|
||||
memory_features=lambda: _memory_features(app),
|
||||
spaces=app.state.embedding_spaces,
|
||||
writes_open=lambda: _embedding_writes_open(app),
|
||||
sdk_language=lambda: _sdk_language(app),
|
||||
)
|
||||
app.state.sdk_memory_runtime = SdkMemoryRuntime(
|
||||
coordinator=app.state.sdk_write_coordinator,
|
||||
@@ -4560,6 +4599,53 @@ def create_app(
|
||||
changed=changed,
|
||||
)
|
||||
|
||||
@app.get(
|
||||
"/api/v1/settings/ui-language",
|
||||
response_model=UiLanguageState,
|
||||
dependencies=strict,
|
||||
)
|
||||
def get_ui_language() -> UiLanguageState:
|
||||
"""The App's display language and the SDK prompt language it implies."""
|
||||
return _ui_language_state(app)
|
||||
|
||||
@app.put(
|
||||
"/api/v1/settings/ui-language",
|
||||
response_model=UiLanguageChange,
|
||||
dependencies=strict,
|
||||
)
|
||||
async def put_ui_language(request: UiLanguageUpdate) -> UiLanguageChange:
|
||||
"""Record the App's display language; one global setting, not per user.
|
||||
|
||||
zh-Hans and null pass no language to the SDK, which keeps detecting
|
||||
zh/en as before; every other code (en included) is passed as
|
||||
`MemoryConfig.language`. A change of that SDK language rebuilds the
|
||||
SDK clients like a feature change: a write in flight finishes in the
|
||||
old language. Nothing stored is migrated and no collection is renamed.
|
||||
"""
|
||||
store = _config_store(app)
|
||||
async with app.state.memory_mode_lock:
|
||||
previous = _sdk_language(app)
|
||||
if store.ui_language() != request.ui_language:
|
||||
await asyncio.to_thread(store.set_ui_language, request.ui_language)
|
||||
current = _sdk_language(app)
|
||||
rebuilt = current != previous
|
||||
if rebuilt:
|
||||
coordinator = getattr(app.state, "sdk_write_coordinator", None)
|
||||
if coordinator is not None:
|
||||
await coordinator.retire_stale_handles()
|
||||
# Reads rebuild on their next request (`ActiveProfileReadHandle`).
|
||||
diagnostic_event(
|
||||
level="info",
|
||||
component="memory.language",
|
||||
event="sdk_language.changed",
|
||||
outcome="succeeded",
|
||||
details={
|
||||
"sdk_language": current or SDK_LANGUAGE_AUTO,
|
||||
"previous": previous or SDK_LANGUAGE_AUTO,
|
||||
},
|
||||
)
|
||||
return UiLanguageChange(**_ui_language_state(app).model_dump(), rebuilt=rebuilt)
|
||||
|
||||
@app.get(
|
||||
"/api/v1/settings/evolve-schedule",
|
||||
response_model=EvolveSchedule,
|
||||
@@ -5922,6 +6008,7 @@ def _sdk_client_handle(app: FastAPI) -> SdkClientHandle:
|
||||
memory_mode=lambda: _memory_mode(app),
|
||||
features=lambda: _memory_features(app),
|
||||
spaces=getattr(app.state, "embedding_spaces", None),
|
||||
language=lambda: _sdk_language(app),
|
||||
)
|
||||
app.state.sdk_client_handle = handle
|
||||
return handle
|
||||
@@ -5964,6 +6051,20 @@ def _memory_features(app: FastAPI) -> FeatureOverrides:
|
||||
return store.memory_features().overrides() if store is not None else ()
|
||||
|
||||
|
||||
def _sdk_language(app: FastAPI) -> str | None:
|
||||
"""The SDK prompt language every client is built with; None = auto-detect."""
|
||||
store = getattr(app.state, "config_store", None)
|
||||
return sdk_language(store.ui_language()) if store is not None else None
|
||||
|
||||
|
||||
def _ui_language_state(app: FastAPI) -> UiLanguageState:
|
||||
store = getattr(app.state, "config_store", None)
|
||||
return UiLanguageState(
|
||||
ui_language=store.ui_language() if store is not None else None,
|
||||
sdk_language=_sdk_language(app) or SDK_LANGUAGE_AUTO,
|
||||
)
|
||||
|
||||
|
||||
def _memory_features_state(
|
||||
app: FastAPI, defaults: dict[str, bool] | None = None
|
||||
) -> MemoryFeaturesState:
|
||||
@@ -6019,6 +6120,7 @@ def _pipeline_status(app: FastAPI) -> PipelineStatus | None:
|
||||
dict[MemoryFeatureName, bool],
|
||||
non_default_features(_memory_features(app), sdk_feature_defaults()),
|
||||
),
|
||||
**_ui_language_state(app).model_dump(exclude={"languages"}),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from pathlib import Path
|
||||
from typing import Literal, cast
|
||||
|
||||
HY_MEMORY_DISTRIBUTION = "hy-memory"
|
||||
HY_MEMORY_VERSION = "2.0.10"
|
||||
HY_MEMORY_VERSION = "2.0.11"
|
||||
HY_MEMORY_NAMESPACE = "governed-v2"
|
||||
DiagnosticLogLevel = Literal["error", "warning", "info", "debug"]
|
||||
|
||||
|
||||
@@ -503,7 +503,7 @@ def _ensure_coding_schema(path: Path) -> None:
|
||||
CodingMemoryStore,
|
||||
)
|
||||
|
||||
# Relies on the pinned SDK's internals (hy-memory==2.0.10 is pinned exactly).
|
||||
# Relies on the pinned SDK's internals (hy-memory==2.0.11 is pinned exactly).
|
||||
store = CodingMemoryStore.__new__(CodingMemoryStore)
|
||||
store._db_path = str(path)
|
||||
store._conn = None
|
||||
|
||||
@@ -15,7 +15,7 @@ from collections.abc import Callable, Coroutine, Iterable, Iterator, Mapping
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar, Literal, cast
|
||||
from typing import Any, ClassVar, Literal, TypedDict, cast
|
||||
|
||||
from hy_memory import ( # type: ignore[import-untyped]
|
||||
AmbiguousMemoryIdError as AmbiguousMemoryIdError, # re-export for api.py
|
||||
@@ -191,6 +191,7 @@ class SdkClientHandle:
|
||||
space: EmbeddingSpace | None = None,
|
||||
mode: str | None = None,
|
||||
features: FeatureOverrides = (),
|
||||
language: str | None = None,
|
||||
) -> None:
|
||||
self._profile = profile
|
||||
self._api_key = api_key
|
||||
@@ -201,6 +202,8 @@ class SdkClientHandle:
|
||||
self.mode = mode or effective_memory_mode()
|
||||
#: The SDK features set on its config (`memory_features`), fixed likewise.
|
||||
self.features = features
|
||||
#: The SDK prompt language (`ui_language`); None = SDK auto-detect.
|
||||
self.language = language
|
||||
self._lease: ClientLease | None = None
|
||||
self._constructing: asyncio.Future[ClientLease] | None = None
|
||||
self._lock = asyncio.Lock()
|
||||
@@ -232,6 +235,7 @@ class SdkClientHandle:
|
||||
http_transport_factory=self._http_transport_factory,
|
||||
mode=self.mode,
|
||||
features=self.features,
|
||||
**language_kwargs(self.language),
|
||||
)
|
||||
)
|
||||
self._constructing = constructing
|
||||
@@ -347,6 +351,31 @@ def apply_sdk_features(
|
||||
return config
|
||||
|
||||
|
||||
class LanguageKwargs(TypedDict, total=False):
|
||||
language: str
|
||||
|
||||
|
||||
def language_kwargs(language: str | None) -> LanguageKwargs:
|
||||
"""`language=` for a client constructor, only when there is one.
|
||||
|
||||
Without a language every call is the one it was before the setting
|
||||
existed, so stand-ins written for that call keep working.
|
||||
"""
|
||||
return {"language": language} if language is not None else {}
|
||||
|
||||
|
||||
def apply_sdk_language(config: MemoryConfig, language: str | None) -> MemoryConfig:
|
||||
"""Set the SDK prompt language (`MemoryConfig.language`), when there is one.
|
||||
|
||||
None (zh-Hans, or no choice) sets nothing: the SDK detects zh/en as it
|
||||
always did. Like a feature, the language never touches `vector_store`, so
|
||||
the collection identity never depends on it.
|
||||
"""
|
||||
if language is not None:
|
||||
config.language = language
|
||||
return config
|
||||
|
||||
|
||||
def build_sdk_config(
|
||||
profile: ModelProfileSnapshot,
|
||||
space: EmbeddingSpace | None = None,
|
||||
@@ -355,6 +384,7 @@ def build_sdk_config(
|
||||
api_key: str | None,
|
||||
data_dir: Path,
|
||||
features: Mapping[str, bool] | Iterable[tuple[str, bool]] = (),
|
||||
language: str | None = None,
|
||||
) -> MemoryConfig:
|
||||
"""Map a model snapshot and its embedding space to an SDK configuration.
|
||||
|
||||
@@ -365,7 +395,8 @@ def build_sdk_config(
|
||||
the registry existed is used: offline tools on a home with no registry.
|
||||
`mode` never changes a name, and neither do `features`
|
||||
(`memory_features`), which are set on top; with none the config is
|
||||
exactly what it was before the setting existed.
|
||||
exactly what it was before the setting existed. So is `language`
|
||||
(`apply_sdk_language`); None leaves the SDK's own zh/en detection.
|
||||
"""
|
||||
if mode is not None and mode not in {"lite", "pro", "ultra"}:
|
||||
raise HySdkRuntimeError("hy_sdk_memory_mode_invalid")
|
||||
@@ -453,7 +484,7 @@ def build_sdk_config(
|
||||
"coding": {"db_path": str(root / "coding.sqlite3")},
|
||||
}
|
||||
)
|
||||
return apply_sdk_features(config, features)
|
||||
return apply_sdk_language(apply_sdk_features(config, features), language)
|
||||
|
||||
|
||||
def _acquire_for_handle(
|
||||
@@ -465,6 +496,7 @@ def _acquire_for_handle(
|
||||
http_transport_factory: Callable[[], Any] | None,
|
||||
mode: str | None,
|
||||
features: FeatureOverrides = (),
|
||||
language: str | None = None,
|
||||
) -> ClientLease:
|
||||
# `acquire_client` is looked up at call time, so a stand-in replaces every
|
||||
# construction. A handle without a space keeps the call it always made.
|
||||
@@ -477,6 +509,7 @@ def _acquire_for_handle(
|
||||
mode=mode,
|
||||
features=features,
|
||||
**named,
|
||||
**language_kwargs(language),
|
||||
)
|
||||
|
||||
|
||||
@@ -489,6 +522,7 @@ def acquire_client(
|
||||
http_transport_factory: Callable[[], Any] | None = None,
|
||||
mode: str | None = None,
|
||||
features: FeatureOverrides = (),
|
||||
language: str | None = None,
|
||||
) -> ClientLease:
|
||||
"""Construct a public SDK client and return its explicit runtime lease.
|
||||
|
||||
@@ -496,7 +530,8 @@ def acquire_client(
|
||||
pipeline mode (lite / pro / ultra) and never changes a name; None keeps
|
||||
the pre-setting default (ultra when `ONE_MEMORY_GRAPH_STORE` is set, else
|
||||
pro). `features` are the `memory_features` set on its config; none keeps
|
||||
every SDK default. Waits while `hold_stores_offline` holds this home's stores.
|
||||
every SDK default. `language` is the SDK prompt language; None
|
||||
auto-detects. Waits while `hold_stores_offline` holds this home's stores.
|
||||
"""
|
||||
key = data_dir.resolve()
|
||||
with _STORE_GATE:
|
||||
@@ -513,6 +548,7 @@ def acquire_client(
|
||||
mode=mode or effective_memory_mode(),
|
||||
features=features,
|
||||
**named,
|
||||
**language_kwargs(language),
|
||||
)
|
||||
finally:
|
||||
with _STORE_GATE:
|
||||
@@ -568,6 +604,7 @@ def _acquire_client(
|
||||
http_transport_factory: Callable[[], Any] | None,
|
||||
mode: str = "pro",
|
||||
features: FeatureOverrides = (),
|
||||
language: str | None = None,
|
||||
) -> ClientLease:
|
||||
config = build_sdk_config(
|
||||
profile,
|
||||
@@ -576,6 +613,7 @@ def _acquire_client(
|
||||
api_key=api_key,
|
||||
data_dir=data_dir,
|
||||
features=features,
|
||||
language=language,
|
||||
)
|
||||
config.http_transport_factory = http_transport_factory
|
||||
mismatch = inspect_graph_width_mismatch(config)
|
||||
|
||||
@@ -19,6 +19,7 @@ from pydantic import BaseModel, ConfigDict, Field, StrictBool, field_validator
|
||||
|
||||
from one_memory.memory_features import MemoryFeatures
|
||||
from one_memory.memory_queue import ExtractionWindowPreset
|
||||
from one_memory.ui_language import UI_LANGUAGES, UiLanguage
|
||||
|
||||
_SOURCE_ID = re.compile(r"^[a-z0-9][a-z0-9-]{0,127}$")
|
||||
|
||||
@@ -73,6 +74,10 @@ class ConfigDocument(BaseModel):
|
||||
#: SDK retrieval and extraction features a person set; an unset one keeps
|
||||
#: the SDK default. See `one_memory.memory_features`.
|
||||
memory_features: MemoryFeatures = Field(default_factory=MemoryFeatures)
|
||||
#: The display language a person chose in the App; None = never chosen or
|
||||
#: "follow the system". See `one_memory.ui_language` for what it does to
|
||||
#: the SDK.
|
||||
ui_language: UiLanguage | None = None
|
||||
|
||||
@field_validator("sources")
|
||||
@classmethod
|
||||
@@ -95,7 +100,7 @@ class ConfigFileError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
_WRITTEN_WHEN_SET = frozenset({"memory_mode", "evolve_schedule", "memory_features"})
|
||||
_WRITTEN_WHEN_SET = frozenset({"memory_mode", "evolve_schedule", "memory_features", "ui_language"})
|
||||
|
||||
|
||||
class ConfigStore:
|
||||
@@ -183,6 +188,19 @@ class ConfigStore:
|
||||
self._document = updated
|
||||
return updated
|
||||
|
||||
def ui_language(self) -> UiLanguage | None:
|
||||
with self._lock:
|
||||
return self._document.ui_language
|
||||
|
||||
def set_ui_language(self, language: UiLanguage | None) -> ConfigDocument:
|
||||
if language is not None and language not in UI_LANGUAGES:
|
||||
raise ValueError("invalid_ui_language")
|
||||
with self._lock:
|
||||
updated = self._document.model_copy(update={"ui_language": language})
|
||||
self._write(updated)
|
||||
self._document = updated
|
||||
return updated
|
||||
|
||||
def migrate_extraction_window_preset(self, preset: ExtractionWindowPreset) -> ConfigDocument:
|
||||
"""Adopt the legacy SQL value only when config.json has no value."""
|
||||
if not isinstance(preset, ExtractionWindowPreset):
|
||||
|
||||
@@ -17,9 +17,9 @@ task (one HTTP request, one retention pass).
|
||||
|
||||
The memory mode (lite / pro / ultra) is fixed per SDK client, so a mode change
|
||||
rebuilds and retires the same way, and so do the SDK features
|
||||
(`memory_features`): handles are keyed by (space, profile, mode, features).
|
||||
Neither mode nor features names a collection; both are separate client
|
||||
parameters.
|
||||
(`memory_features`) and the SDK prompt language (`ui_language`): handles are
|
||||
keyed by (space, profile, mode, features, language). None of mode, features
|
||||
or language names a collection; each is a separate client parameter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -42,6 +42,7 @@ from one_memory.hy_sdk_runtime import (
|
||||
SdkClientHandle,
|
||||
builtin_retrieval_snapshot,
|
||||
embedding_endpoint,
|
||||
language_kwargs,
|
||||
retrieval_view,
|
||||
)
|
||||
from one_memory.hy_sdk_types import HySdkRuntimeError
|
||||
@@ -50,8 +51,8 @@ from one_memory.model_profiles import ModelProfileSnapshot, ModelProfileStore, P
|
||||
from one_memory.secrets import SecretStore, SecretStoreUnavailableError
|
||||
|
||||
_BUILTIN = (BUILTIN_RETRIEVAL_PROFILE_ID, 0)
|
||||
#: (space id, profile id, revision, memory mode, features)
|
||||
_HandleKey = tuple[str, str, int, str, FeatureOverrides]
|
||||
#: (space id, profile id, revision, memory mode, features, SDK language)
|
||||
_HandleKey = tuple[str, str, int, str, FeatureOverrides, str | None]
|
||||
|
||||
|
||||
class ActiveProfileReadHandle(SdkClientHandle):
|
||||
@@ -67,6 +68,7 @@ class ActiveProfileReadHandle(SdkClientHandle):
|
||||
features: Callable[[], FeatureOverrides] = lambda: (),
|
||||
spaces: EmbeddingSpaceStore | None = None,
|
||||
health: EmbeddingHealth | None = None,
|
||||
language: Callable[[], str | None] = lambda: None,
|
||||
) -> None:
|
||||
super().__init__(builtin_retrieval_snapshot(), api_key=None, data_dir=data_dir)
|
||||
self.profiles = profiles
|
||||
@@ -74,6 +76,7 @@ class ActiveProfileReadHandle(SdkClientHandle):
|
||||
self.data_dir = data_dir
|
||||
self.memory_mode = memory_mode
|
||||
self.current_features = features
|
||||
self.current_language = language
|
||||
#: None only for a profile store without a database (test doubles).
|
||||
self.spaces = spaces if spaces is not None else registry_for(profiles)
|
||||
self.health = health or EmbeddingHealth()
|
||||
@@ -111,13 +114,22 @@ class ActiveProfileReadHandle(SdkClientHandle):
|
||||
"""The handle of the live space, held for the asking task."""
|
||||
space, identity = await asyncio.to_thread(self._resolve)
|
||||
mode, features = self.memory_mode(), self.current_features()
|
||||
key = (space.space_id if space is not None else "", *identity, mode, features)
|
||||
language = self.current_language()
|
||||
key = (
|
||||
space.space_id if space is not None else "",
|
||||
*identity,
|
||||
mode,
|
||||
features,
|
||||
language,
|
||||
)
|
||||
async with self._handle_lock:
|
||||
handle = self._handles.get(key)
|
||||
if handle is None:
|
||||
# Only on a change of space or profile: resolving a key can
|
||||
# reach the system keychain, so it is not done on every read.
|
||||
handle = await asyncio.to_thread(self._build, space, identity, mode, features)
|
||||
handle = await asyncio.to_thread(
|
||||
self._build, space, identity, mode, features, language
|
||||
)
|
||||
self._handles[key] = handle
|
||||
self._retired.update(value for name, value in self._handles.items() if name != key)
|
||||
self._handles = {key: handle}
|
||||
@@ -204,10 +216,12 @@ class ActiveProfileReadHandle(SdkClientHandle):
|
||||
identity: tuple[str, int],
|
||||
mode: str,
|
||||
features: FeatureOverrides = (),
|
||||
language: str | None = None,
|
||||
) -> SdkClientHandle:
|
||||
# Without a registry (a profile store with no database) the handle
|
||||
# opens the space its profile names, exactly as before.
|
||||
named: dict[str, Any] = {"space": space} if space is not None else {}
|
||||
named.update(language_kwargs(language))
|
||||
if identity == _BUILTIN:
|
||||
return SdkClientHandle(
|
||||
builtin_retrieval_snapshot(),
|
||||
|
||||
@@ -16,7 +16,11 @@ from one_memory.embedding_spaces import (
|
||||
registry_for,
|
||||
)
|
||||
from one_memory.hy_graph_admin import effective_memory_mode
|
||||
from one_memory.hy_sdk_runtime import SdkClientHandle, builtin_retrieval_snapshot
|
||||
from one_memory.hy_sdk_runtime import (
|
||||
SdkClientHandle,
|
||||
builtin_retrieval_snapshot,
|
||||
language_kwargs,
|
||||
)
|
||||
from one_memory.imports.models import EvidenceContentKind, EvidenceOrigin, EvidenceRecord
|
||||
from one_memory.input_sanitization import sanitize_model_input
|
||||
from one_memory.memory_features import FeatureOverrides
|
||||
@@ -153,10 +157,11 @@ class SdkWriteCoordinator:
|
||||
the SharedRuntime closed mid-`add`, the job failed with AttributeError
|
||||
after HY was invoked, and it was left `unknown`.
|
||||
|
||||
Handles are keyed by profile, memory mode and the SDK features
|
||||
(`memory_features`), so a mode or feature change retires handles the same
|
||||
way. In lite no generation model is needed: without an
|
||||
active profile the built-in local embedder writes raw turns.
|
||||
Handles are keyed by profile, memory mode, the SDK features
|
||||
(`memory_features`) and the SDK language (`ui_language`), so a mode,
|
||||
feature or language change retires handles the same way. In lite no
|
||||
generation model is needed: without an active profile the built-in
|
||||
local embedder writes raw turns.
|
||||
|
||||
`exclusive_for_evolve(user_id)` keeps evolve and that user's writes
|
||||
apart: it waits for the user's write in flight, and while it runs that
|
||||
@@ -182,6 +187,7 @@ class SdkWriteCoordinator:
|
||||
memory_features: Callable[[], FeatureOverrides] = lambda: (),
|
||||
spaces: EmbeddingSpaceStore | None = None,
|
||||
writes_open: Callable[[], bool] = lambda: True,
|
||||
sdk_language: Callable[[], str | None] = lambda: None,
|
||||
) -> None:
|
||||
self.profiles = profiles
|
||||
self.secrets = secrets
|
||||
@@ -190,8 +196,12 @@ class SdkWriteCoordinator:
|
||||
self.sedimentation_enabled = sedimentation_enabled
|
||||
self.memory_mode = memory_mode
|
||||
self.memory_features = memory_features
|
||||
#: Keyed by (profile id, revision, space id, memory mode, features).
|
||||
self._handles: dict[tuple[str, int, str, str, FeatureOverrides], SdkClientHandle] = {}
|
||||
self.sdk_language = sdk_language
|
||||
#: Keyed by (profile id, revision, space id, memory mode, features,
|
||||
#: SDK language).
|
||||
self._handles: dict[
|
||||
tuple[str, int, str, str, FeatureOverrides, str | None], SdkClientHandle
|
||||
] = {}
|
||||
#: Users evolve (or the graph backfill) is running for, the writes in
|
||||
#: flight per user, and one exclusive run per user at a time. Writes
|
||||
#: of one user still run side by side; only evolve waits for them.
|
||||
@@ -244,13 +254,13 @@ class SdkWriteCoordinator:
|
||||
self._writes_idle.notify_all()
|
||||
|
||||
async def retire_stale_handles(self) -> None:
|
||||
"""After a mode or feature change: retire handles built otherwise.
|
||||
"""After a mode, feature or language change: retire handles built otherwise.
|
||||
|
||||
The idle ones close now. A handle still writing is closed when its
|
||||
last use ends (#218), so a write that started in the old mode, or
|
||||
with the old features, finishes in it.
|
||||
"""
|
||||
build = (self.memory_mode(), self.memory_features())
|
||||
build = (self.memory_mode(), self.memory_features(), self.sdk_language())
|
||||
async with self._lock:
|
||||
stale = {key: handle for key, handle in self._handles.items() if key[3:] != build}
|
||||
for key in stale:
|
||||
@@ -332,10 +342,17 @@ class SdkWriteCoordinator:
|
||||
"""Use the active profile's handle; retire the others, closing the unused."""
|
||||
mode = self.memory_mode()
|
||||
features = self.memory_features()
|
||||
language = self.sdk_language()
|
||||
profile_identity, profile, secret, space = await asyncio.to_thread(
|
||||
self._read_active_credentials, mode
|
||||
)
|
||||
key = (*profile_identity, space.space_id if space is not None else "", mode, features)
|
||||
key = (
|
||||
*profile_identity,
|
||||
space.space_id if space is not None else "",
|
||||
mode,
|
||||
features,
|
||||
language,
|
||||
)
|
||||
async with self._lock:
|
||||
handle = self._handles.get(key)
|
||||
if handle is None:
|
||||
@@ -347,6 +364,7 @@ class SdkWriteCoordinator:
|
||||
mode=mode,
|
||||
features=features,
|
||||
space=space,
|
||||
**language_kwargs(language),
|
||||
)
|
||||
self._handles[key] = handle
|
||||
self._retired.update(value for name, value in self._handles.items() if name != key)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""The display language a person chose, and the SDK prompt language it implies.
|
||||
|
||||
The App owns the choice (its language selector) and tells Core whenever it
|
||||
changes, so Core can build every SDK client with it. It is one global value in
|
||||
`config.json` (`ui_language`), beside `memory_mode` and `memory_features`;
|
||||
absent (never chosen, or "follow the system") means no explicit choice.
|
||||
|
||||
The owner's rule for the SDK:
|
||||
|
||||
- zh-Hans, or no explicit choice: pass no language. The SDK keeps detecting
|
||||
Chinese or English from the text, exactly as before this setting existed.
|
||||
- Any other code, including en: pass it as `MemoryConfig.language`, so
|
||||
extraction and the other prompts use that language.
|
||||
|
||||
The language never names a collection; a change rebuilds the SDK handles the
|
||||
way a mode or feature change does (#218 use count).
|
||||
|
||||
No SDK import here: only `hy_sdk_runtime` and `hy_sdk_migration` import it.
|
||||
`config_state` imports this module, so it imports nothing from there.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
UiLanguage = Literal[
|
||||
"en",
|
||||
"zh-Hans",
|
||||
"zh-Hant",
|
||||
"ja",
|
||||
"es",
|
||||
"de",
|
||||
"pt",
|
||||
"fr",
|
||||
"ko",
|
||||
"ru",
|
||||
"id",
|
||||
]
|
||||
UI_LANGUAGES: tuple[UiLanguage, ...] = (
|
||||
"en",
|
||||
"zh-Hans",
|
||||
"zh-Hant",
|
||||
"ja",
|
||||
"es",
|
||||
"de",
|
||||
"pt",
|
||||
"fr",
|
||||
"ko",
|
||||
"ru",
|
||||
"id",
|
||||
)
|
||||
#: Codes earlier App builds stored, and the code each one is now.
|
||||
LEGACY_UI_LANGUAGES: dict[str, UiLanguage] = {"zh-CN": "zh-Hans"}
|
||||
#: Display languages that leave the SDK's own zh/en detection in charge.
|
||||
AUTO_DETECTED: frozenset[str] = frozenset({"zh-Hans"})
|
||||
#: What status shows when no language is passed to the SDK.
|
||||
SDK_LANGUAGE_AUTO = "auto"
|
||||
|
||||
|
||||
def normalize_ui_language(value: object) -> UiLanguage | None:
|
||||
"""A supported code (legacy ones mapped), or None for anything else."""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
value = LEGACY_UI_LANGUAGES.get(value, value)
|
||||
return value if value in UI_LANGUAGES else None
|
||||
|
||||
|
||||
def sdk_language(ui_language: str | None) -> str | None:
|
||||
"""The `MemoryConfig.language` for a display language; None = SDK auto-detect."""
|
||||
language = normalize_ui_language(ui_language)
|
||||
if language is None or language in AUTO_DETECTED:
|
||||
return None
|
||||
return language
|
||||
@@ -304,6 +304,7 @@
|
||||
| `test_sync_facade.py` | `contract` | A2: the sync facade must keep the sync shape call sites already use. |
|
||||
| `test_tail_metadata_lane.py` | `contract` | Post-response metadata retries must be bounded and never block the route. |
|
||||
| `test_two_level_discovery.py` | `contract` | 3925a kernel item 1: two-level discovery. |
|
||||
| `test_ui_language.py` | `contract` | The App's display language, and the SDK prompt language it implies. |
|
||||
| `test_ui_session.py` | `contract` | 覆盖 exchange is single use and creates a session、expired exchange cannot create a session、exchange is bound to the core instance 等 5 个场景。 |
|
||||
| `test_ui_session_api.py` | `integration` | 覆盖 ui session exchange sets httponly strict cookie、ui session requires bearer for exchange、ui session reports missing web assets 场景。 |
|
||||
| `test_unanswered_turn.py` | `contract` | BUG-06: which conversations end on a question nobody answered. |
|
||||
@@ -317,4 +318,4 @@
|
||||
| `test_workspace_recall.py` | `contract` | Workspace memories were unreachable from every host; these pin the way in. |
|
||||
| `test_write_queue_lease_renewal.py` | `contract` | Rule 8: a lease must outlive the work it covers, and be renewed while it runs. |
|
||||
|
||||
共 312 个测试文件。
|
||||
共 313 个测试文件。
|
||||
@@ -14,8 +14,8 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
PRIMARY_MARKERS = ("unit", "contract", "integration", "performance", "live")
|
||||
EXPECTED_TEST_FILE_COUNT = 312
|
||||
EXPECTED_TEST_FILES_SHA256 = "7d0dc6efd88d71d5275bea8016a0ff087ff7568abcb8fd01e5f82c77a08a484f"
|
||||
EXPECTED_TEST_FILE_COUNT = 313
|
||||
EXPECTED_TEST_FILES_SHA256 = "dcada5d5421767196a79e415dd47c2b6c6d67681972dbe81481b31867d318928"
|
||||
|
||||
# Pure transformations, validation models, parity checks and state machines.
|
||||
# This list is intentionally small: uncertain files default to contract.
|
||||
|
||||
@@ -91,7 +91,7 @@ def test_bundle_contains_management_sdk_config_and_verified_manifest(tmp_path):
|
||||
assert manifest["bundle_id"] == "migration-1"
|
||||
assert manifest["sdk_namespace"] == "governed-v2"
|
||||
assert manifest["runtime_build"] == "build-13"
|
||||
assert manifest["snapshot_sdk_version"] == "2.0.10"
|
||||
assert manifest["snapshot_sdk_version"] == "2.0.11"
|
||||
paths = {item["path"]: item for item in manifest["files"]}
|
||||
assert {
|
||||
"management/one-memory.sqlite3",
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
"""The App's display language, and the SDK prompt language it implies.
|
||||
|
||||
The owner's rule: zh-Hans, or no explicit choice, passes no language, so the
|
||||
SDK keeps detecting zh/en exactly as before; every other code (en included)
|
||||
is passed as `MemoryConfig.language` (hy-memory 2.0.11). A change rebuilds the
|
||||
handles the way a mode or feature change does, and the language never touches
|
||||
the vector collection identity.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import json
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from pydantic import SecretStr, ValidationError
|
||||
|
||||
from one_memory import hy_sdk_runtime, sdk_read_handle, sdk_write_processor
|
||||
from one_memory.api import create_app
|
||||
from one_memory.config import Settings
|
||||
from one_memory.memory_server.config_state import ConfigDocument, ConfigStore
|
||||
from one_memory.secrets import InMemorySecretStore
|
||||
from one_memory.ui_language import UI_LANGUAGES, normalize_ui_language, sdk_language
|
||||
|
||||
TOKEN = "ui-language-test"
|
||||
#: Every code except zh-Hans is passed to the SDK.
|
||||
PASSED = ("en", "zh-Hant", "ja", "es", "de", "pt", "fr", "ko", "ru", "id")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_sdk_env(monkeypatch):
|
||||
"""The SDK's own `MEMORY_LANGUAGE` feeds its default; tests start without it."""
|
||||
monkeypatch.delenv("MEMORY_LANGUAGE", raising=False)
|
||||
|
||||
|
||||
def _config(tmp_path: Path, language: str | None):
|
||||
return hy_sdk_runtime.build_sdk_config(
|
||||
hy_sdk_runtime.builtin_retrieval_snapshot(),
|
||||
api_key=None,
|
||||
data_dir=tmp_path,
|
||||
language=language,
|
||||
)
|
||||
|
||||
|
||||
# --- the rule -------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_eleven_codes_and_the_legacy_chinese_code() -> None:
|
||||
assert ("en", "zh-Hans", *PASSED[1:]) == UI_LANGUAGES
|
||||
assert len(UI_LANGUAGES) == 11
|
||||
# Earlier App builds stored zh-CN; it is zh-Hans now.
|
||||
assert normalize_ui_language("zh-CN") == "zh-Hans"
|
||||
assert normalize_ui_language("system") is None
|
||||
assert normalize_ui_language("xx") is None
|
||||
assert normalize_ui_language(None) is None
|
||||
|
||||
|
||||
def test_only_zh_hans_and_no_choice_leave_the_sdk_to_detect() -> None:
|
||||
assert sdk_language(None) is None
|
||||
assert sdk_language("zh-Hans") is None
|
||||
assert sdk_language("zh-CN") is None
|
||||
assert sdk_language("system") is None
|
||||
for code in PASSED:
|
||||
assert sdk_language(code) == code
|
||||
|
||||
|
||||
def test_zh_hans_and_no_choice_build_the_config_of_before(tmp_path) -> None:
|
||||
before = repr(dataclasses.asdict(_config(tmp_path, None)))
|
||||
assert _config(tmp_path, None).language is None
|
||||
assert repr(dataclasses.asdict(_config(tmp_path, sdk_language("zh-Hans")))) == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", PASSED)
|
||||
def test_a_passed_code_reaches_the_memory_config(tmp_path, code) -> None:
|
||||
config = _config(tmp_path, sdk_language(code))
|
||||
assert config.language == code
|
||||
|
||||
|
||||
def test_the_language_never_changes_the_collection_identity(tmp_path) -> None:
|
||||
def identity(config):
|
||||
return (
|
||||
dataclasses.asdict(config.vector_store),
|
||||
config.embedder.model,
|
||||
config.embedder.embedding_dims,
|
||||
config.graph_store.db_path,
|
||||
config.cache.db_path,
|
||||
)
|
||||
|
||||
baseline = identity(_config(tmp_path, None))
|
||||
for code in PASSED:
|
||||
assert identity(_config(tmp_path, code)) == baseline
|
||||
|
||||
|
||||
def test_acquire_client_passes_the_language_only_when_there_is_one(tmp_path, monkeypatch) -> None:
|
||||
calls: list[dict] = []
|
||||
monkeypatch.setattr(
|
||||
hy_sdk_runtime, "_acquire_client", lambda profile, **kwargs: calls.append(kwargs)
|
||||
)
|
||||
profile = hy_sdk_runtime.builtin_retrieval_snapshot()
|
||||
hy_sdk_runtime.acquire_client(profile, api_key=None, data_dir=tmp_path, mode="pro")
|
||||
hy_sdk_runtime.acquire_client(
|
||||
profile, api_key=None, data_dir=tmp_path, mode="pro", language="ja"
|
||||
)
|
||||
assert "language" not in calls[0]
|
||||
assert calls[1]["language"] == "ja"
|
||||
|
||||
|
||||
# --- the stored setting -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_setting_persists_and_an_untouched_install_writes_none(tmp_path) -> None:
|
||||
store = ConfigStore(tmp_path)
|
||||
store.set_sync("cursor", True)
|
||||
assert "ui_language" not in json.loads((tmp_path / "config.json").read_text())
|
||||
assert store.ui_language() is None
|
||||
store.set_ui_language("ja")
|
||||
assert json.loads((tmp_path / "config.json").read_text())["ui_language"] == "ja"
|
||||
assert ConfigStore(tmp_path).ui_language() == "ja"
|
||||
store.set_ui_language(None)
|
||||
assert ConfigStore(tmp_path).ui_language() is None
|
||||
with pytest.raises(ValueError, match="invalid_ui_language"):
|
||||
store.set_ui_language("zh-CN") # type: ignore[arg-type]
|
||||
with pytest.raises(ValidationError):
|
||||
ConfigDocument.model_validate({"ui_language": "klingon"})
|
||||
|
||||
|
||||
# --- rebuilding handles -------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeHandle:
|
||||
def __init__(self, language: str | None) -> None:
|
||||
self.language = language
|
||||
self.closed = False
|
||||
|
||||
async def client(self):
|
||||
return self
|
||||
|
||||
async def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
def test_the_read_handle_rebuilds_its_client_when_the_language_changes(
|
||||
monkeypatch, tmp_path
|
||||
) -> None:
|
||||
language: list[str | None] = [None]
|
||||
made: list[_FakeHandle] = []
|
||||
|
||||
def handle_for(profile, *, api_key, data_dir, mode=None, features=(), language=None):
|
||||
made.append(_FakeHandle(language))
|
||||
return made[-1]
|
||||
|
||||
monkeypatch.setattr(sdk_read_handle, "SdkClientHandle", handle_for)
|
||||
|
||||
class _NoProfile:
|
||||
def active_identity(self):
|
||||
return None
|
||||
|
||||
reads = sdk_read_handle.ActiveProfileReadHandle(
|
||||
profiles=_NoProfile(), # type: ignore[arg-type]
|
||||
secrets=None, # type: ignore[arg-type]
|
||||
data_dir=tmp_path,
|
||||
memory_mode=lambda: "pro",
|
||||
language=lambda: language[0],
|
||||
)
|
||||
|
||||
async def scenario():
|
||||
# A use lasts as long as the task that asked, so each read is a task.
|
||||
old = await asyncio.create_task(reads.client())
|
||||
assert old.language is None
|
||||
assert await asyncio.create_task(reads.client()) is old
|
||||
language[0] = "ja"
|
||||
fresh = await asyncio.create_task(reads.client())
|
||||
assert fresh is not old and fresh.language == "ja"
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0)
|
||||
assert old.closed and not fresh.closed
|
||||
await reads.close()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
class _Profiles:
|
||||
def active_identity(self):
|
||||
return ("profile-a", 1)
|
||||
|
||||
def snapshot(self, profile_id, revision):
|
||||
return SimpleNamespace(profile_id=profile_id, has_api_key=False)
|
||||
|
||||
def resolve_snapshot_secret(self, profile_id, revision, secrets):
|
||||
return None
|
||||
|
||||
|
||||
def test_a_language_change_keeps_a_busy_write_handle_until_its_write_ends(monkeypatch) -> None:
|
||||
language: list[str | None] = [None]
|
||||
opened: list[_FakeHandle] = []
|
||||
|
||||
def handle(profile, **kwargs):
|
||||
opened.append(_FakeHandle(kwargs.get("language")))
|
||||
return opened[-1]
|
||||
|
||||
monkeypatch.setattr(sdk_write_processor, "SdkClientHandle", handle)
|
||||
coordinator = sdk_write_processor.SdkWriteCoordinator(
|
||||
profiles=_Profiles(), # type: ignore[arg-type]
|
||||
secrets=None, # type: ignore[arg-type]
|
||||
data_dir=None,
|
||||
transport_factory=lambda _profile: None,
|
||||
sedimentation_enabled=lambda: True,
|
||||
memory_mode=lambda: "pro",
|
||||
sdk_language=lambda: language[0],
|
||||
)
|
||||
|
||||
async def scenario():
|
||||
async with coordinator.use_client() as client:
|
||||
assert client.language is None
|
||||
language[0] = "de"
|
||||
await coordinator.retire_stale_handles()
|
||||
# In flight: it finishes in the language it started with (#218).
|
||||
assert not opened[0].closed
|
||||
assert opened[0].closed
|
||||
async with coordinator.use_client() as client:
|
||||
assert client.language == "de"
|
||||
language[0] = None
|
||||
await coordinator.retire_stale_handles()
|
||||
assert opened[1].closed
|
||||
await coordinator.close()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
# --- the HTTP surface -----------------------------------------------------------------
|
||||
|
||||
|
||||
def run_app(tmp_path, monkeypatch, scenario) -> None:
|
||||
async def main() -> None:
|
||||
settings = Settings(
|
||||
data_dir=tmp_path / "data",
|
||||
source_home=tmp_path,
|
||||
api_token=SecretStr(TOKEN),
|
||||
imports_enabled=False,
|
||||
automatic_memory_processing=False,
|
||||
live_sync_enabled=False,
|
||||
)
|
||||
app = create_app(settings, secret_store=InMemorySecretStore())
|
||||
|
||||
@asynccontextmanager
|
||||
async def write_client():
|
||||
yield SimpleNamespace()
|
||||
|
||||
async with app.router.lifespan_context(app):
|
||||
monkeypatch.setattr(app.state.sdk_write_coordinator, "use_client", write_client)
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
headers={"Authorization": f"Bearer {TOKEN}"},
|
||||
) as client:
|
||||
await scenario(app, client)
|
||||
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_ui_language_api_get_put_and_status(tmp_path, monkeypatch) -> None:
|
||||
async def scenario(app, client):
|
||||
reads = app.state.sdk_client_handle
|
||||
writes = app.state.sdk_write_coordinator
|
||||
retired: list[str | None] = []
|
||||
retire = writes.retire_stale_handles
|
||||
|
||||
async def counting():
|
||||
retired.append(writes.sdk_language())
|
||||
await retire()
|
||||
|
||||
monkeypatch.setattr(writes, "retire_stale_handles", counting)
|
||||
|
||||
current = (await client.get("/api/v1/settings/ui-language")).json()
|
||||
assert current["ui_language"] is None and current["sdk_language"] == "auto"
|
||||
assert current["languages"] == list(UI_LANGUAGES)
|
||||
assert reads.current_language() is None and writes.sdk_language() is None
|
||||
|
||||
# zh-Hans: recorded, but the SDK still detects zh/en; nothing rebuilt.
|
||||
body = (
|
||||
await client.put("/api/v1/settings/ui-language", json={"ui_language": "zh-Hans"})
|
||||
).json()
|
||||
assert body == {**current, "ui_language": "zh-Hans", "rebuilt": False}
|
||||
assert retired == [] and reads.current_language() is None
|
||||
|
||||
body = (await client.put("/api/v1/settings/ui-language", json={"ui_language": "ja"})).json()
|
||||
assert body["sdk_language"] == "ja" and body["rebuilt"] is True
|
||||
assert reads.current_language() == "ja" and writes.sdk_language() == "ja"
|
||||
assert retired == ["ja"]
|
||||
|
||||
# An explicit English choice forces English prompts.
|
||||
body = (await client.put("/api/v1/settings/ui-language", json={"ui_language": "en"})).json()
|
||||
assert body["sdk_language"] == "en" and body["rebuilt"] is True
|
||||
|
||||
processing = (await client.get("/api/v1/memory-processing")).json()
|
||||
assert processing["pipeline"]["ui_language"] == "en"
|
||||
assert processing["pipeline"]["sdk_language"] == "en"
|
||||
|
||||
# Back to "follow the system": auto again.
|
||||
body = (await client.put("/api/v1/settings/ui-language", json={"ui_language": None})).json()
|
||||
assert body["sdk_language"] == "auto" and body["rebuilt"] is True
|
||||
assert reads.current_language() is None
|
||||
saved = json.loads((tmp_path / "data" / "config.json").read_text())
|
||||
assert saved["ui_language"] is None
|
||||
|
||||
for bad in ({"ui_language": "zh-CN"}, {"ui_language": "xx"}, {"language": "ja"}, {}):
|
||||
response = await client.put("/api/v1/settings/ui-language", json=bad)
|
||||
assert response.status_code == 422, bad
|
||||
|
||||
run_app(tmp_path, monkeypatch, scenario)
|
||||
Generated
+5
-5
@@ -991,7 +991,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "hy-memory"
|
||||
version = "2.0.10"
|
||||
version = "2.0.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "chromadb" },
|
||||
@@ -1008,9 +1008,9 @@ dependencies = [
|
||||
{ name = "scikit-learn" },
|
||||
{ name = "tiktoken" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/86/ed/1e1264d5b8741237c34fdaf17ae68bf5d16d403ca73c054997a502cd06a3/hy_memory-2.0.10.tar.gz", hash = "sha256:0841a441862e2427f75898bb8c958ed22fa083d2c0326298687ee129bcb729a8", size = 881659, upload-time = "2026-11-01T11:33:33.742Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/93/aec7d243259f6a315f38e8ebc21a9eea153a48fc13648fda149be7672788/hy_memory-2.0.11.tar.gz", hash = "sha256:96d6bd606d61052f127cd274aa6f8de8e7151d6250f1e89e4ef5b90e18ec7cb0", size = 1245738, upload-time = "2026-11-01T14:15:02.474Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/d6/c07a9d2d3af14da5a90c2bc466a46fc77068c24570fafe8f5729e46e2cae/hy_memory-2.0.10-py3-none-any.whl", hash = "sha256:54528d61a1730c7ea0f464f15144de3d1952f5d9aab1875bc841c97a193914b1", size = 947165, upload-time = "2026-11-01T11:33:31.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/85/1f5e6a13507a0cfcdce26f67c590df00d327a0f7d5c5b5483c3bd0fa2f85/hy_memory-2.0.11-py3-none-any.whl", hash = "sha256:c0173444a83c1197a49acaccb123f8286cea205b4838c1e83d106974f5bad259", size = 1315871, upload-time = "2026-11-01T14:14:58.764Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1801,7 +1801,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "one-memory"
|
||||
version = "1.0.5"
|
||||
version = "1.0.6"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiomysql" },
|
||||
@@ -1837,7 +1837,7 @@ requires-dist = [
|
||||
{ name = "fastapi", specifier = ">=0.141.1" },
|
||||
{ name = "filelock", specifier = ">=3.32.4" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "hy-memory", specifier = "==2.0.10" },
|
||||
{ name = "hy-memory", specifier = "==2.0.11" },
|
||||
{ name = "keyring", specifier = ">=25.7.0" },
|
||||
{ name = "numpy", specifier = "==2.5.2" },
|
||||
{ name = "onnxruntime", specifier = "==1.23.2" },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@one-memory/desktop",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.6",
|
||||
"private": true,
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -30,7 +30,7 @@ it("rebuilds translated labels from committed language updates without exporting
|
||||
expect(buildMenu).toHaveBeenLastCalledWith(
|
||||
expect.arrayContaining([expect.objectContaining({ label: "Ayuda" })]),
|
||||
);
|
||||
snapshot.locale = "zh-CN";
|
||||
snapshot.locale = "zh-Hans";
|
||||
change?.();
|
||||
expect(read).toHaveBeenCalledTimes(2);
|
||||
expect(buildMenu).toHaveBeenCalledTimes(2);
|
||||
|
||||
@@ -16,14 +16,14 @@ export function installApplicationMenu(options: {
|
||||
} catch {
|
||||
options.reportError(
|
||||
translate(
|
||||
options.language?.read().locale ?? "zh-CN",
|
||||
options.language?.read().locale ?? "zh-Hans",
|
||||
"nativeExportError",
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
const refresh = () => {
|
||||
const locale = options.language?.read().locale ?? "zh-CN";
|
||||
const locale = options.language?.read().locale ?? "zh-Hans";
|
||||
options.setMenu(
|
||||
options.buildMenu([
|
||||
{
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
Tray,
|
||||
} from "electron";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { installBackgroundTray } from "./background-tray";
|
||||
import { installBackgroundTray, type RuntimeHosting } from "./background-tray";
|
||||
import type { LoginPreference } from "./login-preference";
|
||||
|
||||
async function flush() {
|
||||
@@ -27,9 +27,10 @@ function fixture(
|
||||
status: "disabled",
|
||||
};
|
||||
let changed: (() => void) | undefined;
|
||||
let hosting: RuntimeHosting | Error = "background-service";
|
||||
const languageSnapshot: LanguageSnapshot = {
|
||||
preference: "zh-CN",
|
||||
locale: "zh-CN",
|
||||
preference: "zh-Hans",
|
||||
locale: "zh-Hans",
|
||||
revision: 1,
|
||||
};
|
||||
const app = { isPackaged: packaged, quit: vi.fn() };
|
||||
@@ -50,6 +51,10 @@ function fixture(
|
||||
};
|
||||
return state;
|
||||
});
|
||||
const readRuntimeHosting = vi.fn(async () => {
|
||||
if (hosting instanceof Error) throw hosting;
|
||||
return hosting;
|
||||
});
|
||||
const open = vi.fn();
|
||||
const reportError = vi.fn();
|
||||
const install = () =>
|
||||
@@ -77,6 +82,7 @@ function fixture(
|
||||
reportError,
|
||||
readLoginPreference,
|
||||
setLoginPreference,
|
||||
readRuntimeHosting,
|
||||
...(trial
|
||||
? { allowLoginStartup: false, productName: "One Memory Trial" }
|
||||
: {}),
|
||||
@@ -91,6 +97,7 @@ function fixture(
|
||||
menus,
|
||||
readLoginPreference,
|
||||
setLoginPreference,
|
||||
readRuntimeHosting,
|
||||
open,
|
||||
reportError,
|
||||
install,
|
||||
@@ -103,6 +110,9 @@ function fixture(
|
||||
setState(value: LoginPreference) {
|
||||
state = value;
|
||||
},
|
||||
setHosting(value: RuntimeHosting | Error) {
|
||||
hosting = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -121,7 +131,7 @@ describe("native macOS background tray", () => {
|
||||
expect(f.setLoginPreference).not.toHaveBeenCalled();
|
||||
f.click(0);
|
||||
expect(f.open).toHaveBeenCalledOnce();
|
||||
f.changeLanguage("zh-CN");
|
||||
f.changeLanguage("zh-Hans");
|
||||
await flush();
|
||||
expect(f.menu()[0]?.label).toBe("打开 MemHub");
|
||||
f.click(3);
|
||||
@@ -179,6 +189,50 @@ describe("native macOS background tray", () => {
|
||||
expect(f.tray.eventNames()).toEqual([]);
|
||||
});
|
||||
|
||||
it("says quitting stops the memory service when the App hosts it", async () => {
|
||||
const f = fixture();
|
||||
f.setHosting("app-hosted");
|
||||
f.install();
|
||||
await flush();
|
||||
expect(f.menu()[3]?.label).toBe("退出 MemHub(同时停止记忆服务)");
|
||||
f.changeLanguage("es");
|
||||
await flush();
|
||||
expect(f.menu()[3]?.label).toBe(
|
||||
"Salir de MemHub (también detiene el servicio de memoria)",
|
||||
);
|
||||
});
|
||||
|
||||
it("re-reads who hosts the Runtime every time the menu opens", async () => {
|
||||
const f = fixture();
|
||||
f.install();
|
||||
await flush();
|
||||
expect(f.menu()[3]?.label).toContain("后台服务继续运行");
|
||||
|
||||
// The user turned the background service off in Settings.
|
||||
f.setHosting("app-hosted");
|
||||
f.tray.emit("mouse-down");
|
||||
await flush();
|
||||
expect(f.menu()[3]?.label).toContain("同时停止记忆服务");
|
||||
|
||||
f.setHosting("background-service");
|
||||
f.tray.emit("right-click");
|
||||
await flush();
|
||||
expect(f.menu()[3]?.label).toContain("后台服务继续运行");
|
||||
expect(f.readRuntimeHosting).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("never claims quit stops the service when the state is unknown", async () => {
|
||||
const f = fixture();
|
||||
f.setHosting(new Error("launchctl timed out"));
|
||||
f.install();
|
||||
await flush();
|
||||
expect(f.menu()[3]?.label).toContain("后台服务继续运行");
|
||||
f.setHosting("unknown");
|
||||
f.tray.emit("mouse-down");
|
||||
await flush();
|
||||
expect(f.menu()[3]?.label).toContain("后台服务继续运行");
|
||||
});
|
||||
|
||||
it("blocks login changes for trial, development and non-macOS builds", async () => {
|
||||
for (const f of [
|
||||
fixture(true, "darwin", true),
|
||||
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
import type { LoginPreference } from "./login-preference";
|
||||
import type { NativeLanguageSource } from "./native-language";
|
||||
|
||||
export type RuntimeHosting = "background-service" | "app-hosted" | "unknown";
|
||||
|
||||
export const LOGIN_SERVICE_NAME = "com.tencent.onememory.runtime.plist";
|
||||
|
||||
interface TrayOptions {
|
||||
@@ -24,6 +26,9 @@ interface TrayOptions {
|
||||
reportError(message: string): void;
|
||||
readLoginPreference(): Promise<LoginPreference>;
|
||||
setLoginPreference(enabled: boolean): Promise<LoginPreference>;
|
||||
/** Who keeps the Runtime alive, re-read on every menu refresh: the
|
||||
* background service (quit leaves it running) or this App (quit stops it). */
|
||||
readRuntimeHosting?(): Promise<RuntimeHosting>;
|
||||
allowLoginStartup?: boolean;
|
||||
productName?: string;
|
||||
language?: NativeLanguageSource;
|
||||
@@ -44,7 +49,7 @@ export function installBackgroundTray(options: TrayOptions): () => void {
|
||||
let generation = 0;
|
||||
const refresh = async () => {
|
||||
if (disposed) return;
|
||||
const locale = options.language?.read().locale ?? "zh-CN";
|
||||
const locale = options.language?.read().locale ?? "zh-Hans";
|
||||
const t = (key: MessageKey) =>
|
||||
translate(locale, key, { product: productName });
|
||||
const current = ++generation;
|
||||
@@ -67,6 +72,13 @@ export function installBackgroundTray(options: TrayOptions): () => void {
|
||||
} else {
|
||||
label += t("nativeDevelopmentUnavailable");
|
||||
}
|
||||
let hosting: RuntimeHosting = "unknown";
|
||||
try {
|
||||
hosting = (await options.readRuntimeHosting?.()) ?? "unknown";
|
||||
} catch {
|
||||
hosting = "unknown";
|
||||
}
|
||||
if (disposed || current !== generation) return;
|
||||
tray.setContextMenu(
|
||||
options.buildMenu([
|
||||
{ label: t("nativeOpen"), click: options.open },
|
||||
@@ -82,7 +94,7 @@ export function installBackgroundTray(options: TrayOptions): () => void {
|
||||
.catch(() =>
|
||||
options.reportError(
|
||||
translate(
|
||||
options.language?.read().locale ?? "zh-CN",
|
||||
options.language?.read().locale ?? "zh-Hans",
|
||||
"nativeLoginError",
|
||||
),
|
||||
),
|
||||
@@ -92,7 +104,10 @@ export function installBackgroundTray(options: TrayOptions): () => void {
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: t("nativeQuit"),
|
||||
// Unknown keeps the plain label: quit then stops nothing.
|
||||
label: t(
|
||||
hosting === "app-hosted" ? "nativeQuitStopsService" : "nativeQuit",
|
||||
),
|
||||
click: () => {
|
||||
if (!disposed) app.quit();
|
||||
},
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { LanguageSnapshot } from "@one-memory/i18n";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { coreUiLanguage, installCoreLanguageSync } from "./core-language-sync";
|
||||
|
||||
function source(initial: LanguageSnapshot) {
|
||||
let snapshot = initial;
|
||||
const listeners = new Set<(value: LanguageSnapshot) => void>();
|
||||
return {
|
||||
read: () => snapshot,
|
||||
subscribe(listener: (value: LanguageSnapshot) => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
change(next: LanguageSnapshot) {
|
||||
snapshot = next;
|
||||
for (const listener of listeners) listener(next);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
describe("Core language sync", () => {
|
||||
it("tells Core null for system and the code for an explicit choice", () => {
|
||||
expect(coreUiLanguage("system")).toBeNull();
|
||||
expect(coreUiLanguage("zh-Hans")).toBe("zh-Hans");
|
||||
expect(coreUiLanguage("en")).toBe("en");
|
||||
expect(coreUiLanguage("ja")).toBe("ja");
|
||||
});
|
||||
|
||||
it("sends the stored choice once at launch, then every change", async () => {
|
||||
const language = source({ preference: "es", locale: "es", revision: 0 });
|
||||
const send = vi.fn(async () => {});
|
||||
const dispose = installCoreLanguageSync({ language, send });
|
||||
await settle();
|
||||
expect(send.mock.calls).toEqual([["es"]]);
|
||||
language.change({ preference: "ja", locale: "ja", revision: 1 });
|
||||
await settle();
|
||||
language.change({ preference: "system", locale: "en", revision: 2 });
|
||||
await settle();
|
||||
expect(send.mock.calls).toEqual([["es"], ["ja"], [null]]);
|
||||
dispose();
|
||||
language.change({ preference: "de", locale: "de", revision: 3 });
|
||||
await settle();
|
||||
expect(send).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("an untouched install sends null, which leaves the SDK on auto", async () => {
|
||||
const language = source({
|
||||
preference: "system",
|
||||
locale: "ja",
|
||||
revision: 0,
|
||||
});
|
||||
const send = vi.fn(async () => {});
|
||||
installCoreLanguageSync({ language, send });
|
||||
await settle();
|
||||
expect(send.mock.calls).toEqual([[null]]);
|
||||
});
|
||||
|
||||
it("reports a failed send and retries it with the next change", async () => {
|
||||
const language = source({ preference: "fr", locale: "fr", revision: 0 });
|
||||
const send = vi
|
||||
.fn<(value: string | null) => Promise<void>>()
|
||||
.mockRejectedValueOnce(new Error("core_unavailable"))
|
||||
.mockResolvedValue(undefined);
|
||||
const reportError = vi.fn();
|
||||
installCoreLanguageSync({ language, send, reportError });
|
||||
await settle();
|
||||
expect(reportError).toHaveBeenCalledTimes(1);
|
||||
language.change({ preference: "fr", locale: "fr", revision: 1 });
|
||||
await settle();
|
||||
expect(send.mock.calls).toEqual([["fr"], ["fr"]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { GuiLocale, LanguagePreference } from "@one-memory/i18n";
|
||||
import type { NativeLanguageSource } from "./native-language";
|
||||
|
||||
/** What Core is told: a code, or null for "follow the system". */
|
||||
export function coreUiLanguage(
|
||||
preference: LanguagePreference,
|
||||
): GuiLocale | null {
|
||||
return preference === "system" ? null : preference;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep Core's `ui_language` setting on the display language a person chose.
|
||||
*
|
||||
* Core builds every SDK client with it: zh-Hans and "follow the system" pass
|
||||
* no language (the SDK keeps detecting zh/en), any other choice sets the SDK
|
||||
* prompt language. Sent once when installed, so a choice made before this
|
||||
* existed reaches Core, then on every committed change; a value Core already
|
||||
* has is not sent again. Best effort: a failed send is reported and retried
|
||||
* with the next change or launch, and never blocks the GUI.
|
||||
*/
|
||||
export function installCoreLanguageSync(options: {
|
||||
language: NativeLanguageSource;
|
||||
send(uiLanguage: GuiLocale | null): Promise<void>;
|
||||
reportError?(error: unknown): void;
|
||||
}): () => void {
|
||||
let closed = false;
|
||||
let sent: GuiLocale | null | undefined;
|
||||
let queue: Promise<void> = Promise.resolve();
|
||||
const push = () => {
|
||||
queue = queue.then(async () => {
|
||||
if (closed) return;
|
||||
const value = coreUiLanguage(options.language.read().preference);
|
||||
if (value === sent) return;
|
||||
try {
|
||||
await options.send(value);
|
||||
sent = value;
|
||||
} catch (error) {
|
||||
options.reportError?.(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
const unsubscribe = options.language.subscribe(push);
|
||||
push();
|
||||
return () => {
|
||||
closed = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}
|
||||
@@ -23,8 +23,8 @@ const fake = vi.hoisted(() => ({
|
||||
unload: undefined as unknown as EventTarget,
|
||||
packaged: true,
|
||||
userData: "",
|
||||
preferred: vi.fn(() => ["es-MX", "zh-CN"]),
|
||||
locale: vi.fn(() => "zh-CN"),
|
||||
preferred: vi.fn(() => ["es-MX", "zh-Hans"]),
|
||||
locale: vi.fn(() => "zh-Hans"),
|
||||
menu: vi.fn(),
|
||||
loadFailure: false,
|
||||
reduceMotion: false as unknown,
|
||||
@@ -301,10 +301,10 @@ describe("GUI language main IPC", () => {
|
||||
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");
|
||||
await write(eventFor(window), "zh-Hans");
|
||||
expect(window.frame.send).toHaveBeenCalledWith(
|
||||
"one-memory:language-preference-changed",
|
||||
{ preference: "zh-CN", locale: "zh-CN", revision: 1 },
|
||||
{ preference: "zh-Hans", locale: "zh-Hans", revision: 1 },
|
||||
);
|
||||
expect(foreign.frame.send).not.toHaveBeenCalled();
|
||||
window.frame.send.mockImplementation(() => {
|
||||
@@ -539,7 +539,7 @@ describe("language preference preload", () => {
|
||||
"es",
|
||||
);
|
||||
fake.invoke.mockClear();
|
||||
await expect(api.setLanguagePreference("fr")).rejects.toThrow();
|
||||
await expect(api.setLanguagePreference("it")).rejects.toThrow();
|
||||
expect(fake.invoke).not.toHaveBeenCalled();
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = api.onLanguagePreferenceChanged(listener);
|
||||
|
||||
@@ -91,7 +91,7 @@ describe("early desktop startup errors", () => {
|
||||
new Error("desktop_startup_failed"),
|
||||
);
|
||||
expect(showError).toHaveBeenCalledExactlyOnceWith(
|
||||
translate(locale ?? "zh-CN", "nativeErrorTitle"),
|
||||
translate(locale ?? "zh-Hans", "nativeErrorTitle"),
|
||||
stableRuntimeUserMessage(new Error("desktop_startup_failed")),
|
||||
);
|
||||
expect(exit).toHaveBeenCalledExactlyOnceWith(1);
|
||||
|
||||
@@ -59,9 +59,9 @@ 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";
|
||||
let locale: GuiLocale = "zh-Hans";
|
||||
try {
|
||||
locale = options.readLocale?.() ?? "zh-CN";
|
||||
locale = options.readLocale?.() ?? "zh-Hans";
|
||||
} catch {
|
||||
// Preferences may not exist yet; do not read files or guess the locale.
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("language preference IPC", () => {
|
||||
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", "it")).rejects.toThrow();
|
||||
await expect(write("trusted", "es", "extra")).rejects.toThrow();
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
expect(await read("trusted")).toEqual({
|
||||
|
||||
@@ -29,23 +29,42 @@ describe("GUI language preferences", () => {
|
||||
revision: 0,
|
||||
});
|
||||
expect(await readdir(root)).toEqual([]);
|
||||
expect(await store.set("zh-CN")).toEqual({
|
||||
preference: "zh-CN",
|
||||
locale: "zh-CN",
|
||||
expect(await store.set("zh-Hans")).toEqual({
|
||||
preference: "zh-Hans",
|
||||
locale: "zh-Hans",
|
||||
revision: 1,
|
||||
});
|
||||
expect(
|
||||
JSON.parse(await readFile(path.join(root, "gui-language.json"), "utf8")),
|
||||
).toEqual({ preference: "zh-CN" });
|
||||
).toEqual({ preference: "zh-Hans" });
|
||||
expect((await LanguagePreferences.open(root, "es")).read().preference).toBe(
|
||||
"zh-CN",
|
||||
"zh-Hans",
|
||||
);
|
||||
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", "[]"])(
|
||||
it.each([
|
||||
["zh-CN", "zh-Hans", "zh-Hans"],
|
||||
["es", "es", "es"],
|
||||
["en", "en", "en"],
|
||||
["ja", "ja", "ja"],
|
||||
["system", "system", "en"],
|
||||
])(
|
||||
"keeps an existing user's stored %s choice as %s",
|
||||
async (stored, preference, locale) => {
|
||||
const root = await profile();
|
||||
const file = path.join(root, "gui-language.json");
|
||||
const content = `${JSON.stringify({ preference: stored })}\n`;
|
||||
await writeFile(file, content);
|
||||
const store = await LanguagePreferences.open(root, "en-US");
|
||||
expect(store.read()).toEqual({ preference, locale, revision: 0 });
|
||||
// Read as today's code; the file is rewritten only by the next change.
|
||||
expect(await readFile(file, "utf8")).toBe(content);
|
||||
},
|
||||
);
|
||||
it.each(["{broken", '{"preference":"it"}', "null", "[]"])(
|
||||
"does not overwrite invalid input %s while loading",
|
||||
async (content) => {
|
||||
const root = await profile();
|
||||
@@ -69,7 +88,7 @@ describe("GUI language preferences", () => {
|
||||
});
|
||||
const listener = vi.fn();
|
||||
store.subscribe(listener);
|
||||
await expect(store.set("zh-CN")).rejects.toThrow(
|
||||
await expect(store.set("zh-Hans")).rejects.toThrow(
|
||||
"language_preference_save_failed",
|
||||
);
|
||||
expect(store.read()).toEqual({
|
||||
@@ -78,7 +97,7 @@ describe("GUI language preferences", () => {
|
||||
revision: 0,
|
||||
});
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
await store.set("zh-CN");
|
||||
await store.set("zh-Hans");
|
||||
expect(store.read().revision).toBe(1);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -97,7 +116,7 @@ describe("GUI language preferences", () => {
|
||||
});
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = store.subscribe(listener);
|
||||
const first = store.set("zh-CN");
|
||||
const first = store.set("zh-Hans");
|
||||
const second = store.set("es");
|
||||
await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(1));
|
||||
expect(store.read().revision).toBe(0);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
isLanguagePreference,
|
||||
type LanguagePreference,
|
||||
type LanguageSnapshot,
|
||||
normalizeLanguagePreference,
|
||||
resolveGuiLocale,
|
||||
} from "@one-memory/i18n";
|
||||
|
||||
@@ -58,14 +59,13 @@ export class LanguagePreferences {
|
||||
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;
|
||||
}
|
||||
// An earlier build's code (zh-CN) reads as today's (zh-Hans), so a
|
||||
// person keeps the language they chose; the next change writes it.
|
||||
const stored =
|
||||
data && typeof data === "object" && "preference" in data
|
||||
? normalizeLanguagePreference(data.preference)
|
||||
: null;
|
||||
if (stored !== null) preference = stored;
|
||||
} catch {
|
||||
// Missing, unreadable or corrupt preferences never block startup or get rewritten.
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
claimRuntimeServiceForApp,
|
||||
claimRuntimeServiceForCli,
|
||||
enableRuntimeService,
|
||||
type RuntimeServiceState,
|
||||
runtimeServiceEnableMarkerPath,
|
||||
} from "@one-memory/runtime-control";
|
||||
import { expect, it } from "vitest";
|
||||
@@ -98,3 +99,25 @@ it("rejects unsupported and unconfirmed writes", async () => {
|
||||
),
|
||||
).rejects.toThrow("login_preference_unsupported");
|
||||
});
|
||||
|
||||
it("reads a launchctl-disabled service as off and not switchable, asked fresh", async () => {
|
||||
const dataDir = await appOwned();
|
||||
let state: RuntimeServiceState = {
|
||||
state: "disabled",
|
||||
reason: "service-disabled-by-launchctl",
|
||||
jobPid: null,
|
||||
};
|
||||
const serviceState = async () => state;
|
||||
|
||||
await expect(
|
||||
readLoginPreference(dataDir, "darwin", true, serviceState),
|
||||
).resolves.toEqual({
|
||||
supported: false,
|
||||
enabled: false,
|
||||
status: "disabled-by-system",
|
||||
});
|
||||
state = { state: "enabled", reason: "service-enabled", jobPid: 4321 };
|
||||
await expect(
|
||||
readLoginPreference(dataDir, "darwin", true, serviceState),
|
||||
).resolves.toEqual({ supported: true, enabled: true, status: "enabled" });
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
disableRuntimeService,
|
||||
enableRuntimeService,
|
||||
type RuntimeServiceState,
|
||||
readRuntimeServiceOwner,
|
||||
runtimeServiceEnabled,
|
||||
setAppRuntimeStartAtLogin,
|
||||
@@ -12,10 +13,16 @@ export interface LoginPreference {
|
||||
status: string;
|
||||
}
|
||||
|
||||
/** "Launch at login" is the background service switch: it writes the enable
|
||||
* marker the LaunchAgent's `KeepAlive` waits on. On it means the Runtime
|
||||
* outlives the App and quitting leaves it running; off means quitting stops
|
||||
* it. `serviceState`, when given, is asked fresh so a `launchctl disable`
|
||||
* override (which the switch cannot undo) reads as off, not as on. */
|
||||
export async function readLoginPreference(
|
||||
dataDir: string,
|
||||
platform: NodeJS.Platform,
|
||||
supported: boolean,
|
||||
serviceState?: () => Promise<RuntimeServiceState>,
|
||||
): Promise<LoginPreference> {
|
||||
if (platform !== "darwin" || !supported) {
|
||||
return { supported: false, enabled: false, status: "unsupported" };
|
||||
@@ -28,6 +35,12 @@ export async function readLoginPreference(
|
||||
if (owner?.owner !== "app") {
|
||||
return { supported: false, enabled: false, status: "not-installed" };
|
||||
}
|
||||
if (
|
||||
serviceState &&
|
||||
(await serviceState()).reason === "service-disabled-by-launchctl"
|
||||
) {
|
||||
return { supported: false, enabled: false, status: "disabled-by-system" };
|
||||
}
|
||||
return {
|
||||
supported: true,
|
||||
enabled: owner.start_at_login && (await runtimeServiceEnabled(dataDir)),
|
||||
@@ -46,6 +59,7 @@ export async function setLoginPreference(
|
||||
platform: NodeJS.Platform,
|
||||
input: unknown,
|
||||
supported: boolean,
|
||||
serviceState?: () => Promise<RuntimeServiceState>,
|
||||
): Promise<LoginPreference> {
|
||||
if (
|
||||
!input ||
|
||||
@@ -63,5 +77,5 @@ export async function setLoginPreference(
|
||||
await setAppRuntimeStartAtLogin(dataDir, input.openAtLogin);
|
||||
if (input.openAtLogin) await enableRuntimeService(dataDir);
|
||||
else await disableRuntimeService(dataDir);
|
||||
return readLoginPreference(dataDir, platform, supported);
|
||||
return readLoginPreference(dataDir, platform, supported, serviceState);
|
||||
}
|
||||
+181
-25
@@ -23,7 +23,9 @@ import {
|
||||
type HostPermissionClient,
|
||||
installPackagedRuntime,
|
||||
installUserService,
|
||||
queryRuntimeServiceState,
|
||||
RecallBrokerSupervisor,
|
||||
type RuntimeServiceState,
|
||||
RuntimeSupervisor,
|
||||
readInstalledRuntimeSource,
|
||||
readPublishedCorePort,
|
||||
@@ -59,6 +61,7 @@ import { installApplicationMenu } from "./application-menu";
|
||||
import { installBackgroundHealthIpc } from "./background-health-ipc";
|
||||
import { installBackgroundTray } from "./background-tray";
|
||||
import { installConnectionSetupIpc } from "./connection-setup-ipc";
|
||||
import { installCoreLanguageSync } from "./core-language-sync";
|
||||
import {
|
||||
CoreSupervisor,
|
||||
ensureRuntimeProcess,
|
||||
@@ -109,6 +112,7 @@ import { showMigrationFailureDialog } from "./migration-failure-dialog";
|
||||
import { isAllowedNavigation } from "./navigation-policy";
|
||||
import { installDenyAllPermissionPolicy } from "./permissions";
|
||||
import { isHostPresent, recallHosts } from "./recall-hosts";
|
||||
import { reconcileRunningRuntime } from "./runtime-adoption";
|
||||
import { shutdownRuntime } from "./runtime-shutdown";
|
||||
import { createSourceChangeHandler } from "./source-change-handler";
|
||||
import { SourceDirectoryPicker } from "./source-directory-picker";
|
||||
@@ -139,7 +143,12 @@ let runtimeSupervisor: RuntimeSupervisor | null = null;
|
||||
// Where the Runtime keeps its receipt. Stopping it on a full quit needs this,
|
||||
// and `stop` runs outside `initialize`, where the directory is chosen.
|
||||
let runtimeDataDir: string | null = null;
|
||||
let preserveManagedRuntimeOnQuit = false;
|
||||
// Whether quitting stops the Runtime is asked of the service manager at quit
|
||||
// time (`currentRuntimeServiceState`), never remembered from startup. The one
|
||||
// startup fact kept is a Runtime whose build could not be read: this App never
|
||||
// stops one it could not compare with its own.
|
||||
let preserveRuntimeOnQuitReason: string | null = null;
|
||||
let runtimeServiceManagedByApp = false;
|
||||
let disposeTray: (() => void) | null = null;
|
||||
let disposeApplicationMenu: (() => void) | null = null;
|
||||
let languagePreferences: LanguagePreferences | null = null;
|
||||
@@ -151,6 +160,7 @@ let connectionSetup: ReturnType<typeof installConnectionSetupIpc> | null = null;
|
||||
let disposeBackgroundHealth: (() => void) | null = null;
|
||||
let stableRuntimeRepair: Promise<void> | null = null;
|
||||
let disposeDiagnostics: (() => void) | null = null;
|
||||
let disposeCoreLanguageSync: (() => void) | null = null;
|
||||
let desktopDiagnostics: DiagnosticLogger | null = null;
|
||||
let exportDiagnosticsFromMenu: (() => Promise<void>) | null = null;
|
||||
const desktopStartupStartedAt = Date.now();
|
||||
@@ -165,7 +175,7 @@ installEarlyStartupErrorHandler({
|
||||
process.env.ONE_MEMORY_DATA_DIR?.trim() ||
|
||||
path.join(app.getPath("home"), ".one-memory"),
|
||||
stderr: process.stderr,
|
||||
readLocale: () => languagePreferences?.read().locale ?? "zh-CN",
|
||||
readLocale: () => languagePreferences?.read().locale ?? "zh-Hans",
|
||||
showError: (title, message) => dialog.showErrorBox(title, message),
|
||||
exit: (code) => app.exit(code),
|
||||
});
|
||||
@@ -245,6 +255,25 @@ if (trialProfile) {
|
||||
app.setPath("userData", hourlyUserDataDir);
|
||||
}
|
||||
|
||||
/** Ask the service manager, now, whether the background service for this data
|
||||
* dir is on. Never cached: quit and the tray each re-read it, so a switch made
|
||||
* in Settings, by `memhub service`, or by `launchctl` counts immediately. */
|
||||
function currentRuntimeServiceState(): Promise<RuntimeServiceState> {
|
||||
if (!runtimeDataDir) {
|
||||
return Promise.resolve({
|
||||
state: "unknown",
|
||||
reason: "service-state-query-failed",
|
||||
jobPid: null,
|
||||
});
|
||||
}
|
||||
return queryRuntimeServiceState({
|
||||
dataDir: runtimeDataDir,
|
||||
home: app.getPath("home"),
|
||||
platform: process.platform,
|
||||
uid: process.getuid?.(),
|
||||
});
|
||||
}
|
||||
|
||||
function prepareProtocol(): void {
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
@@ -296,9 +325,9 @@ function installPackagedWebProtocol(): void {
|
||||
installAppProtocol(path.join(process.resourcesPath, "web"), {
|
||||
// Preferences are open by now: beforeNativeUi runs before this window.
|
||||
startupScreen: () => {
|
||||
let locale: GuiLocale = "zh-CN";
|
||||
let locale: GuiLocale = "zh-Hans";
|
||||
try {
|
||||
locale = languagePreferences?.read().locale ?? "zh-CN";
|
||||
locale = languagePreferences?.read().locale ?? "zh-Hans";
|
||||
} catch {
|
||||
// An unreadable preference keeps the Chinese placeholder.
|
||||
}
|
||||
@@ -496,6 +525,7 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
|
||||
!preview &&
|
||||
(hostIntegrationEnabled || desktopTestIsolation !== null) &&
|
||||
installedApplication;
|
||||
runtimeServiceManagedByApp = appManagedRuntime;
|
||||
const runningPackagedHost =
|
||||
app.isPackaged && !isolatedHourly
|
||||
? await resolveRunningPackagedHostPaths({
|
||||
@@ -849,14 +879,25 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
|
||||
);
|
||||
ipcMain.handle("one-memory:get-login-preference", (event) => {
|
||||
assertAllowedRenderer(event.senderFrame?.url);
|
||||
return readLoginPreference(dataDir, process.platform, appManagedRuntime);
|
||||
return readLoginPreference(
|
||||
dataDir,
|
||||
process.platform,
|
||||
appManagedRuntime,
|
||||
currentRuntimeServiceState,
|
||||
);
|
||||
});
|
||||
ipcMain.handle("one-memory:set-login-preference", (event, input: unknown) => {
|
||||
assertAllowedRenderer(event.senderFrame?.url);
|
||||
return observeDesktopAction(
|
||||
"login_preference",
|
||||
() =>
|
||||
setLoginPreference(dataDir, process.platform, input, appManagedRuntime),
|
||||
setLoginPreference(
|
||||
dataDir,
|
||||
process.platform,
|
||||
input,
|
||||
appManagedRuntime,
|
||||
currentRuntimeServiceState,
|
||||
),
|
||||
{ action: "set" },
|
||||
);
|
||||
});
|
||||
@@ -1167,7 +1208,6 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
|
||||
const startAtLogin = owner?.owner === "app" ? owner.start_at_login : true;
|
||||
const previousOwner = owner;
|
||||
let runtimeChanged = false;
|
||||
let transientRuntime = false;
|
||||
// Install whoever owns the service. Owner decides who starts and stops
|
||||
// it, not which build runs: skipping the install for a CLI owner (also
|
||||
// the App's own guess when its receipt is missing) left an upgraded App
|
||||
@@ -1237,12 +1277,68 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
|
||||
const frozen = await readSdkMigrationBlock(dataDir);
|
||||
if (frozen?.frozen !== undefined) throw new Error(frozen.code);
|
||||
}
|
||||
const alreadyRunning = runtimeChanged
|
||||
? false
|
||||
: await waitForRuntimeProcess({ dataDir, attempts: 4 }).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
// One Runtime per data dir. A Runtime already serving it is adopted
|
||||
// when it is this App's build or newer (silently; a newer one is never
|
||||
// stopped or downgraded at startup); an older one is replaced (through
|
||||
// launchd when it is the service's job), and one whose build cannot be
|
||||
// read is adopted but never stopped.
|
||||
const reconciled = await reconcileRunningRuntime({
|
||||
app: {
|
||||
...(packagedBuildNumber === undefined
|
||||
? {}
|
||||
: { buildNumber: packagedBuildNumber }),
|
||||
...(packagedCommit === undefined ? {} : { commit: packagedCommit }),
|
||||
},
|
||||
installedChanged: runtimeChanged,
|
||||
findRunning: () =>
|
||||
waitForRuntimeProcess({ dataDir, attempts: 4 }).then(
|
||||
(attachment) =>
|
||||
attachment.servicePid === null
|
||||
? null
|
||||
: {
|
||||
pid: attachment.servicePid,
|
||||
build: attachment.runtimeBuild,
|
||||
},
|
||||
() => null,
|
||||
),
|
||||
queryServiceState: currentRuntimeServiceState,
|
||||
stopRuntime: () => stopRuntimeProcess({ dataDir }),
|
||||
log: ({ decision, service, running }) =>
|
||||
diagnostics.log({
|
||||
level: decision.action === "adopt-unverified" ? "warn" : "info",
|
||||
component: "desktop.stable_runtime",
|
||||
event: "stable_runtime.adoption",
|
||||
outcome:
|
||||
decision.action === "adopt-unverified" ? "degraded" : "succeeded",
|
||||
details: {
|
||||
action: decision.action,
|
||||
reason:
|
||||
"reason" in decision
|
||||
? decision.reason
|
||||
: (service?.reason ?? "no-runtime-running"),
|
||||
status: service?.state ?? "not-queried",
|
||||
version:
|
||||
running === null
|
||||
? "none"
|
||||
: running.build === undefined
|
||||
? "legacy"
|
||||
: String(running.build.buildNumber ?? "unknown"),
|
||||
},
|
||||
}),
|
||||
});
|
||||
preserveRuntimeOnQuitReason = reconciled.preserveReason;
|
||||
if (reconciled.decision.action === "adopt-unverified") {
|
||||
void dialog
|
||||
.showMessageBox({
|
||||
type: "warning",
|
||||
message: "MemHub",
|
||||
detail: stableRuntimeFailureDetail(
|
||||
new Error("runtime_build_unknown"),
|
||||
),
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
const alreadyRunning = reconciled.adopted;
|
||||
if (owner?.owner !== "cli") {
|
||||
const appBundle = path.resolve(path.dirname(process.execPath), "../..");
|
||||
owner = await claimRuntimeServiceForApp({
|
||||
@@ -1256,8 +1352,13 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
|
||||
// A durable `launchctl disable` override is a state to report, not a
|
||||
// failure to undo: `installUserService` never re-enables a service the
|
||||
// user or the system disabled. An explicit App launch may still own a
|
||||
// transient Runtime for this UI session. Shutdown stops that process.
|
||||
const serviceMode = await installServiceWithDisabledFallback({
|
||||
// transient Runtime for this UI session; quit stops it because the
|
||||
// service is disabled, which it decides by asking launchd again then.
|
||||
// For an enabled service, `installUserService` restarts the job with
|
||||
// `kickstart -k` (or re-bootstraps a changed plist), so an older
|
||||
// Runtime is replaced through launchd rather than killed into a
|
||||
// respawn of the old binary.
|
||||
await installServiceWithDisabledFallback({
|
||||
install: async () => {
|
||||
await installUserService(layout, {
|
||||
uid: process.getuid?.(),
|
||||
@@ -1289,12 +1390,10 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
|
||||
});
|
||||
},
|
||||
});
|
||||
transientRuntime = serviceMode === "transient";
|
||||
}
|
||||
if (owner?.owner === "app" && !startAtLogin) {
|
||||
await disableRuntimeService(dataDir);
|
||||
}
|
||||
preserveManagedRuntimeOnQuit = !transientRuntime;
|
||||
} else if (standaloneRuntime) {
|
||||
if (
|
||||
!desktopBackgroundLaunch &&
|
||||
@@ -1404,16 +1503,16 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
|
||||
void dialog.showMessageBox({
|
||||
type: "warning",
|
||||
message: translate(
|
||||
languagePreferences?.read().locale ?? "zh-CN",
|
||||
languagePreferences?.read().locale ?? "zh-Hans",
|
||||
"nativeMoveTitle",
|
||||
),
|
||||
detail: translate(
|
||||
languagePreferences?.read().locale ?? "zh-CN",
|
||||
languagePreferences?.read().locale ?? "zh-Hans",
|
||||
"nativeMoveDetail",
|
||||
),
|
||||
buttons: [
|
||||
translate(
|
||||
languagePreferences?.read().locale ?? "zh-CN",
|
||||
languagePreferences?.read().locale ?? "zh-Hans",
|
||||
"nativeAcknowledge",
|
||||
),
|
||||
],
|
||||
@@ -1518,6 +1617,43 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
|
||||
};
|
||||
},
|
||||
});
|
||||
if (languagePreferences) {
|
||||
// Core builds the SDK clients with the display language (`ui_language`).
|
||||
disposeCoreLanguageSync = installCoreLanguageSync({
|
||||
language: languagePreferences,
|
||||
send: async (uiLanguage) => {
|
||||
const core = coreSupervisor;
|
||||
if (!core) throw new Error("core_supervisor_unavailable");
|
||||
const publishedPort = await readPublishedCorePort(dataDir).catch(
|
||||
() => null,
|
||||
);
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${publishedPort ?? core.port}/api/v1/settings/ui-language`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${core.apiToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ ui_language: uiLanguage }),
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
},
|
||||
);
|
||||
if (!response.ok)
|
||||
throw new Error(`ui_language_sync_http_${response.status}`);
|
||||
},
|
||||
reportError: (error) =>
|
||||
void diagnostics
|
||||
.log({
|
||||
level: "warn",
|
||||
component: "desktop.language",
|
||||
event: "ui_language.sync_failed",
|
||||
outcome: "failed",
|
||||
...diagnosticError(error),
|
||||
})
|
||||
.catch(() => {}),
|
||||
});
|
||||
}
|
||||
await diagnostics.log({
|
||||
level: "info",
|
||||
component: "desktop.lifecycle",
|
||||
@@ -1626,7 +1762,8 @@ if (desktopVersion !== null) {
|
||||
? readLoginPreference(
|
||||
runtimeDataDir,
|
||||
process.platform,
|
||||
preserveManagedRuntimeOnQuit,
|
||||
runtimeServiceManagedByApp,
|
||||
currentRuntimeServiceState,
|
||||
)
|
||||
: Promise.resolve({
|
||||
supported: false,
|
||||
@@ -1639,9 +1776,21 @@ if (desktopVersion !== null) {
|
||||
runtimeDataDir,
|
||||
process.platform,
|
||||
{ openAtLogin: enabled, userConfirmed: true },
|
||||
preserveManagedRuntimeOnQuit,
|
||||
runtimeServiceManagedByApp,
|
||||
currentRuntimeServiceState,
|
||||
);
|
||||
},
|
||||
readRuntimeHosting: async () => {
|
||||
if (!runtimeDataDir || !app.isPackaged) return "unknown";
|
||||
// A Runtime whose build could not be read is never stopped by it.
|
||||
if (preserveRuntimeOnQuitReason) return "unknown";
|
||||
const service = await currentRuntimeServiceState();
|
||||
return service.state === "enabled"
|
||||
? "background-service"
|
||||
: service.state === "disabled"
|
||||
? "app-hosted"
|
||||
: "unknown";
|
||||
},
|
||||
...(trialProfile
|
||||
? { allowLoginStartup: false, productName: app.getName() }
|
||||
: {}),
|
||||
@@ -1657,6 +1806,8 @@ if (desktopVersion !== null) {
|
||||
...(coreSupervisor ? { instance_id: coreSupervisor.instanceId } : {}),
|
||||
});
|
||||
// An admitted native effect must settle and journal before Runtime detach.
|
||||
disposeCoreLanguageSync?.();
|
||||
disposeCoreLanguageSync = null;
|
||||
disposeLanguagePreferences?.();
|
||||
disposeLanguagePreferences = null;
|
||||
await languagePreferences?.drain();
|
||||
@@ -1671,7 +1822,10 @@ if (desktopVersion !== null) {
|
||||
// stop and says so by taking the embedded path instead.
|
||||
dataDir: runtimeDataDir ?? "",
|
||||
standalone: app.isPackaged && runtimeDataDir !== null,
|
||||
preserveManagedRuntime: preserveManagedRuntimeOnQuit,
|
||||
// Asked now, not remembered from startup: the user may have switched
|
||||
// the background service on or off since.
|
||||
queryServiceState: currentRuntimeServiceState,
|
||||
preserveReason: preserveRuntimeOnQuitReason,
|
||||
stopSupervisor: async () => {
|
||||
const results = await Promise.allSettled([
|
||||
runtimeSupervisor?.stop() ?? coreSupervisor?.stop(),
|
||||
@@ -1708,6 +1862,8 @@ if (desktopVersion !== null) {
|
||||
});
|
||||
},
|
||||
dispose: () => {
|
||||
disposeCoreLanguageSync?.();
|
||||
disposeCoreLanguageSync = null;
|
||||
disposeLanguagePreferences?.();
|
||||
disposeLanguagePreferences = null;
|
||||
disposeApplicationMenu?.();
|
||||
@@ -1738,7 +1894,7 @@ if (desktopVersion !== null) {
|
||||
showMigrationFailureDialog({
|
||||
error,
|
||||
dataDir: runtimeDataDir,
|
||||
locale: languagePreferences?.read().locale ?? "zh-CN",
|
||||
locale: languagePreferences?.read().locale ?? "zh-Hans",
|
||||
dependencies: {
|
||||
showMessageBoxSync: (options) => dialog.showMessageBoxSync(options),
|
||||
showItemInFolder: (target) => shell.showItemInFolder(target),
|
||||
@@ -1766,7 +1922,7 @@ if (desktopVersion !== null) {
|
||||
return;
|
||||
dialog.showErrorBox(
|
||||
translate(
|
||||
languagePreferences?.read().locale ?? "zh-CN",
|
||||
languagePreferences?.read().locale ?? "zh-Hans",
|
||||
"nativeErrorTitle",
|
||||
),
|
||||
stableRuntimeFailureDetail(error),
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
MAX_ISSUE_URL_LENGTH,
|
||||
MIGRATION_ISSUE_URL,
|
||||
type MigrationDialogDependencies,
|
||||
migrationDialogText,
|
||||
migrationFailureCode,
|
||||
migrationIssueBody,
|
||||
migrationIssueUrl,
|
||||
@@ -41,7 +42,7 @@ describe("migration failure dialog", () => {
|
||||
const handled = showMigrationFailureDialog({
|
||||
error: new Error("sdk_migration_preflight_failed"),
|
||||
dataDir,
|
||||
locale: "zh-CN",
|
||||
locale: "zh-Hans",
|
||||
dependencies: deps,
|
||||
});
|
||||
|
||||
@@ -176,7 +177,7 @@ describe("migration failure dialog", () => {
|
||||
// Desktop's own migration throws the code the note records.
|
||||
error: new Error("sdk_data_plane_dispatch_failed"),
|
||||
dataDir,
|
||||
locale: "zh-CN",
|
||||
locale: "zh-Hans",
|
||||
dependencies: deps,
|
||||
environment: {
|
||||
appVersion: "2.0.6",
|
||||
@@ -217,6 +218,35 @@ describe("migration failure dialog", () => {
|
||||
expect(body).not.toContain(accountHome);
|
||||
});
|
||||
|
||||
it("speaks the display language in the dialog and the issue body", () => {
|
||||
const en = migrationDialogText("sdk_migration_x", "/tmp/m.log", "en", true);
|
||||
expect(en.detail).toBe(
|
||||
"To protect your data, MemHub will not start. Please attach the migration log to a GitHub issue and send it to us. (Error code: sdk_migration_x)" +
|
||||
"\n\nYour data was restored to its state before the upgrade." +
|
||||
"\n\nLog: /tmp/m.log",
|
||||
);
|
||||
const ja = migrationDialogText("sdk_migration_x", "/tmp/m.log", "ja");
|
||||
expect(ja.message).toBe(
|
||||
"MemHub のアップグレード中にデータ移行が完了しませんでした。",
|
||||
);
|
||||
expect([ja.openLog, ja.quit]).toEqual(["ログを開く", "終了"]);
|
||||
expect(ja.detail).toContain("sdk_migration_x");
|
||||
// A system language code resolves like the GUI does (de-AT → de).
|
||||
expect(migrationDialogText("c", "/l", "de-AT").quit).toBe("Beenden");
|
||||
const body = migrationIssueBody({
|
||||
code: "sdk_migration_preflight_failed",
|
||||
logPath: "~/.one-memory/logs/sdk-migration.log",
|
||||
dataRestored: false,
|
||||
arch: "arm64",
|
||||
locale: "fr",
|
||||
});
|
||||
expect(body).toContain("## Échec de la migration lors de la mise à jour");
|
||||
expect(body).toMatch(/- Code d’erreur\s: sdk_migration_preflight_failed/);
|
||||
expect(body).toContain(": non");
|
||||
expect(body).toMatch(/- Système\s: inconnu \(arm64\)/);
|
||||
expect(body).toContain("~/.one-memory/logs/sdk-migration.log");
|
||||
});
|
||||
|
||||
it("says 未知 for what the log does not know instead of guessing", () => {
|
||||
const body = migrationIssueBody({
|
||||
code: "sdk_migration_preflight_failed",
|
||||
@@ -296,7 +326,7 @@ describe("migration failure dialog", () => {
|
||||
showMigrationFailureDialog({
|
||||
error: new Error("installed_version_conflict"),
|
||||
dataDir: await home(),
|
||||
locale: "zh-CN",
|
||||
locale: "zh-Hans",
|
||||
dependencies: deps,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
@@ -8,6 +8,12 @@ import {
|
||||
readSync,
|
||||
} from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
type GuiLocale,
|
||||
isGuiLocale,
|
||||
resolveGuiLocale,
|
||||
translate,
|
||||
} from "@one-memory/i18n";
|
||||
|
||||
/**
|
||||
* What a person sees when an upgrade did not reach SDK authority.
|
||||
@@ -106,51 +112,70 @@ export interface MigrationIssueFacts {
|
||||
arch?: string | null;
|
||||
/** The local log path, already shown with the home directory as `~`. */
|
||||
logPath: string;
|
||||
/** The display language; Simplified Chinese when absent. */
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
const UNKNOWN = "未知";
|
||||
|
||||
function versionText(value: string | null | undefined): string {
|
||||
return typeof value === "string" && VERSION.test(value) ? value : UNKNOWN;
|
||||
/** A display language, or the one a system language code resolves to. */
|
||||
function dialogLocale(locale: string | undefined): GuiLocale {
|
||||
if (locale === undefined) return "zh-Hans";
|
||||
return isGuiLocale(locale) ? locale : resolveGuiLocale("system", locale);
|
||||
}
|
||||
|
||||
function buildText(value: number | null | undefined): string {
|
||||
function versionText(value: string | null | undefined): string | null {
|
||||
return typeof value === "string" && VERSION.test(value) ? value : null;
|
||||
}
|
||||
|
||||
function buildText(value: number | null | undefined): string | null {
|
||||
return typeof value === "number" && Number.isSafeInteger(value) && value > 0
|
||||
? String(value)
|
||||
: UNKNOWN;
|
||||
: null;
|
||||
}
|
||||
|
||||
function stepText(
|
||||
step: string | null | undefined,
|
||||
sub: string | null | undefined,
|
||||
) {
|
||||
if (typeof step !== "string" || !STEP.test(step)) return UNKNOWN;
|
||||
): string | null {
|
||||
if (typeof step !== "string" || !STEP.test(step)) return null;
|
||||
return typeof sub === "string" && STEP.test(sub) ? `${step} / ${sub}` : step;
|
||||
}
|
||||
|
||||
/** The issue body: short, content-free, and honest about what is unknown. */
|
||||
export function migrationIssueBody(facts: MigrationIssueFacts): string {
|
||||
const locale = dialogLocale(facts.locale);
|
||||
const unknown = translate(locale, "migration.unknown");
|
||||
const known = (value: string | null) => value ?? unknown;
|
||||
const restored =
|
||||
facts.dataRestored === true
|
||||
? "是"
|
||||
? translate(locale, "migration.yes")
|
||||
: facts.dataRestored === false
|
||||
? "否"
|
||||
: UNKNOWN;
|
||||
? translate(locale, "migration.no")
|
||||
: unknown;
|
||||
const system = [facts.osName, facts.osVersion]
|
||||
.map((part) => versionText(part))
|
||||
.filter((part) => part !== UNKNOWN)
|
||||
.filter((part) => part !== null)
|
||||
.join(" ");
|
||||
const arch = versionText(facts.arch);
|
||||
return [
|
||||
"## 升级迁移失败",
|
||||
`- 从 MemHub ${versionText(facts.fromVersion)}(build ${buildText(facts.fromBuild)})升级到 ${versionText(facts.toVersion)}(build ${buildText(facts.toBuild)}),迁移数据失败。`,
|
||||
`- 错误码:${CODE.test(facts.code) ? facts.code : UNKNOWN}`,
|
||||
`- 失败步骤:${stepText(facts.step, facts.subStep)}`,
|
||||
`- 数据已恢复到升级前状态:${restored}`,
|
||||
`- 系统:${system || UNKNOWN}${arch === UNKNOWN ? "" : `(${arch})`}`,
|
||||
`- 迁移日志(本地):${facts.logPath}`,
|
||||
translate(locale, "migration.issueHeading"),
|
||||
translate(locale, "migration.issueUpgrade", {
|
||||
fromVersion: known(versionText(facts.fromVersion)),
|
||||
fromBuild: known(buildText(facts.fromBuild)),
|
||||
toVersion: known(versionText(facts.toVersion)),
|
||||
toBuild: known(buildText(facts.toBuild)),
|
||||
}),
|
||||
translate(locale, "migration.issueCode", {
|
||||
code: CODE.test(facts.code) ? facts.code : unknown,
|
||||
}),
|
||||
translate(locale, "migration.issueStep", {
|
||||
step: known(stepText(facts.step, facts.subStep)),
|
||||
}),
|
||||
translate(locale, "migration.issueRestored", { restored }),
|
||||
translate(locale, "migration.issueSystem", { system: system || unknown }) +
|
||||
(arch === null ? "" : translate(locale, "migration.issueArch", { arch })),
|
||||
translate(locale, "migration.issueLog", { path: facts.logPath }),
|
||||
"",
|
||||
"请把上面这个日志文件拖到这里作为附件,再提交。",
|
||||
translate(locale, "migration.issueAttach"),
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -302,31 +327,18 @@ export function migrationDialogText(
|
||||
locale: string,
|
||||
dataRestored = false,
|
||||
): MigrationDialogText {
|
||||
if (locale.toLowerCase().startsWith("en")) {
|
||||
return {
|
||||
title: "MemHub",
|
||||
message: "The data migration for this MemHub upgrade did not finish.",
|
||||
detail:
|
||||
"To protect your data, MemHub will not start. Please attach the migration log to a GitHub issue and send it to us. " +
|
||||
`(Error code: ${code})` +
|
||||
(dataRestored
|
||||
? "\n\nYour data was restored to its state before the upgrade."
|
||||
: "") +
|
||||
`\n\nLog: ${logPath}`,
|
||||
openLog: "Open log",
|
||||
quit: "Quit",
|
||||
};
|
||||
}
|
||||
const language = dialogLocale(locale);
|
||||
return {
|
||||
title: "MemHub",
|
||||
message: "MemHub 升级时数据迁移没有完成。",
|
||||
message: translate(language, "migration.dialogMessage"),
|
||||
detail:
|
||||
"为保护你的数据,MemHub 不会启动。请把迁移日志附在 GitHub 反馈里发给我们。" +
|
||||
`(错误码:${code})` +
|
||||
(dataRestored ? "\n\n记忆数据已恢复到升级前的状态。" : "") +
|
||||
`\n\n日志:${logPath}`,
|
||||
openLog: "打开日志",
|
||||
quit: "退出",
|
||||
translate(language, "migration.dialogDetail", { code }) +
|
||||
(dataRestored
|
||||
? `\n\n${translate(language, "migration.dataRestored")}`
|
||||
: "") +
|
||||
`\n\n${translate(language, "migration.logPath", { path: logPath })}`,
|
||||
openLog: translate(language, "migration.openLog"),
|
||||
quit: translate(language, "migration.quit"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -434,6 +446,7 @@ export function showMigrationFailureDialog(options: {
|
||||
osVersion: environment.osVersion ?? null,
|
||||
arch: environment.arch ?? null,
|
||||
logPath: displayPath(logPath, environment.home ?? null),
|
||||
locale: options.locale,
|
||||
});
|
||||
void Promise.resolve(
|
||||
options.dependencies.openExternal(migrationIssueUrl(code, body)),
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import type { RuntimeServiceState } from "@one-memory/runtime-control";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
compareRuntimeBuild,
|
||||
decideRuntimeAdoption,
|
||||
type RunningRuntime,
|
||||
reconcileRunningRuntime,
|
||||
} from "./runtime-adoption";
|
||||
|
||||
const app = { buildNumber: 38, commit: "abcdef1234567" };
|
||||
const enabled = (jobPid: number | null): RuntimeServiceState => ({
|
||||
state: "enabled",
|
||||
reason: "service-enabled",
|
||||
jobPid,
|
||||
});
|
||||
const disabled: RuntimeServiceState = {
|
||||
state: "disabled",
|
||||
reason: "service-disabled-by-launchctl",
|
||||
jobPid: null,
|
||||
};
|
||||
const unknown: RuntimeServiceState = {
|
||||
state: "unknown",
|
||||
reason: "service-state-timeout",
|
||||
jobPid: null,
|
||||
};
|
||||
const running = (
|
||||
build: RunningRuntime["build"],
|
||||
pid = 4321,
|
||||
): RunningRuntime => ({ pid, build });
|
||||
|
||||
function reconcile(options: {
|
||||
running: RunningRuntime | null;
|
||||
service: RuntimeServiceState;
|
||||
installedChanged?: boolean;
|
||||
}) {
|
||||
const stopRuntime = vi.fn(async () => "stopped");
|
||||
const queryServiceState = vi.fn(async () => options.service);
|
||||
const result = reconcileRunningRuntime({
|
||||
app,
|
||||
installedChanged: options.installedChanged ?? false,
|
||||
findRunning: async () => options.running,
|
||||
queryServiceState,
|
||||
stopRuntime,
|
||||
});
|
||||
return { result, stopRuntime, queryServiceState };
|
||||
}
|
||||
|
||||
describe("comparing the running Runtime's build with the App's", () => {
|
||||
it("compares build numbers, then commits both sides know", () => {
|
||||
expect(compareRuntimeBuild(app, { buildNumber: 38, commit: null })).toBe(
|
||||
"same",
|
||||
);
|
||||
expect(
|
||||
compareRuntimeBuild(app, { buildNumber: 38, commit: "abcdef1" }),
|
||||
).toBe("same");
|
||||
expect(
|
||||
compareRuntimeBuild(app, { buildNumber: 38, commit: "0123456" }),
|
||||
).toBe("app-newer");
|
||||
expect(compareRuntimeBuild(app, { buildNumber: 37, commit: null })).toBe(
|
||||
"app-newer",
|
||||
);
|
||||
expect(compareRuntimeBuild(app, { buildNumber: 39, commit: null })).toBe(
|
||||
"runtime-newer",
|
||||
);
|
||||
});
|
||||
|
||||
it("reads a receipt without build fields as a Runtime older than this App", () => {
|
||||
expect(compareRuntimeBuild(app, undefined)).toBe("app-newer");
|
||||
});
|
||||
|
||||
it("is unknown when either side cannot say", () => {
|
||||
expect(compareRuntimeBuild(app, { buildNumber: null, commit: null })).toBe(
|
||||
"unknown",
|
||||
);
|
||||
expect(compareRuntimeBuild({}, { buildNumber: 38, commit: null })).toBe(
|
||||
"unknown",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("App start adopts or replaces a running Runtime", () => {
|
||||
it("starts one when nothing runs, without asking the service manager", async () => {
|
||||
const { result, stopRuntime, queryServiceState } = reconcile({
|
||||
running: null,
|
||||
service: enabled(null),
|
||||
});
|
||||
await expect(result).resolves.toMatchObject({
|
||||
decision: { action: "start" },
|
||||
adopted: false,
|
||||
});
|
||||
expect(queryServiceState).not.toHaveBeenCalled();
|
||||
expect(stopRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adopts the same build instead of starting a second Runtime", async () => {
|
||||
const { result, stopRuntime } = reconcile({
|
||||
running: running({ buildNumber: 38, commit: null }),
|
||||
service: disabled,
|
||||
});
|
||||
await expect(result).resolves.toEqual({
|
||||
decision: { action: "adopt" },
|
||||
adopted: true,
|
||||
preserveReason: null,
|
||||
});
|
||||
expect(stopRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adopts the service's own job when the service is enabled", async () => {
|
||||
const { result, stopRuntime } = reconcile({
|
||||
running: running({ buildNumber: 38, commit: null }, 4321),
|
||||
service: enabled(4321),
|
||||
});
|
||||
await expect(result).resolves.toMatchObject({
|
||||
decision: { action: "adopt" },
|
||||
adopted: true,
|
||||
});
|
||||
expect(stopRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ends with one Runtime when a stray holds the lease beside an enabled service", async () => {
|
||||
const { result, stopRuntime } = reconcile({
|
||||
running: running({ buildNumber: 38, commit: null }, 777),
|
||||
service: enabled(null),
|
||||
});
|
||||
await expect(result).resolves.toMatchObject({
|
||||
decision: { action: "stop-then-start", reason: "stray-runtime" },
|
||||
adopted: false,
|
||||
});
|
||||
expect(stopRuntime).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not treat a Runtime as a stray when the service state is unknown", async () => {
|
||||
const { result, stopRuntime } = reconcile({
|
||||
running: running({ buildNumber: 38, commit: null }, 777),
|
||||
service: unknown,
|
||||
});
|
||||
await expect(result).resolves.toMatchObject({
|
||||
decision: { action: "adopt" },
|
||||
});
|
||||
expect(stopRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replaces an older Runtime that is the service's job through the service manager", async () => {
|
||||
const { result, stopRuntime } = reconcile({
|
||||
running: running({ buildNumber: 37, commit: null }, 4321),
|
||||
service: enabled(4321),
|
||||
});
|
||||
await expect(result).resolves.toMatchObject({
|
||||
decision: { action: "replace-via-service" },
|
||||
adopted: false,
|
||||
preserveReason: null,
|
||||
});
|
||||
// No plain kill: launchd would answer it with a respawn of the old job.
|
||||
expect(stopRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replaces a legacy service job whose receipt has no build through launchd", async () => {
|
||||
const { result, stopRuntime } = reconcile({
|
||||
running: running(undefined, 4321),
|
||||
service: enabled(4321),
|
||||
});
|
||||
await expect(result).resolves.toMatchObject({
|
||||
decision: { action: "replace-via-service" },
|
||||
});
|
||||
expect(stopRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops an older transient or CLI-started Runtime, then starts the right one", async () => {
|
||||
const { result, stopRuntime } = reconcile({
|
||||
running: running({ buildNumber: 36, commit: null }, 999),
|
||||
service: disabled,
|
||||
});
|
||||
await expect(result).resolves.toMatchObject({
|
||||
decision: { action: "stop-then-start", reason: "app-newer" },
|
||||
adopted: false,
|
||||
});
|
||||
expect(stopRuntime).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("replaces a same-build Runtime when this launch moved `current`", () => {
|
||||
expect(
|
||||
decideRuntimeAdoption({
|
||||
running: running({ buildNumber: 38, commit: null }, 4321),
|
||||
app,
|
||||
service: enabled(4321),
|
||||
installedChanged: true,
|
||||
}),
|
||||
).toEqual({ action: "replace-via-service" });
|
||||
});
|
||||
|
||||
it("adopts a newer Runtime silently and leaves quit to the service state", async () => {
|
||||
const { result, stopRuntime } = reconcile({
|
||||
running: running({ buildNumber: 40, commit: null }, 999),
|
||||
service: disabled,
|
||||
});
|
||||
// No special quit pass: with the service disabled, quit stops it like any
|
||||
// adopted Runtime; with it enabled, quit preserves it.
|
||||
await expect(result).resolves.toEqual({
|
||||
decision: { action: "adopt-newer" },
|
||||
adopted: true,
|
||||
preserveReason: null,
|
||||
});
|
||||
expect(stopRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never stops or downgrades a newer Runtime at startup, even beside an enabled service", async () => {
|
||||
const { result, stopRuntime } = reconcile({
|
||||
running: running({ buildNumber: 40, commit: null }, 999),
|
||||
service: enabled(4321),
|
||||
installedChanged: true,
|
||||
});
|
||||
await expect(result).resolves.toMatchObject({
|
||||
decision: { action: "adopt-newer" },
|
||||
adopted: true,
|
||||
});
|
||||
expect(stopRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never kills a Runtime whose build cannot be read", async () => {
|
||||
const { result, stopRuntime } = reconcile({
|
||||
running: running({ buildNumber: null, commit: null }, 999),
|
||||
service: disabled,
|
||||
installedChanged: true,
|
||||
});
|
||||
await expect(result).resolves.toEqual({
|
||||
decision: { action: "adopt-unverified" },
|
||||
adopted: true,
|
||||
preserveReason: "runtime-build-unknown",
|
||||
});
|
||||
expect(stopRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import type {
|
||||
RuntimeReceiptBuild,
|
||||
RuntimeServiceState,
|
||||
} from "@one-memory/runtime-control";
|
||||
|
||||
/** The build this App carries. */
|
||||
export interface AppBuild {
|
||||
buildNumber?: number;
|
||||
commit?: string;
|
||||
}
|
||||
|
||||
/** The Runtime found serving the data dir when the App starts. */
|
||||
export interface RunningRuntime {
|
||||
pid: number;
|
||||
/** As published in its receipt; `undefined` for a receipt without the
|
||||
* fields, which only a Runtime older than this App writes. */
|
||||
build: RuntimeReceiptBuild | undefined;
|
||||
}
|
||||
|
||||
export type RuntimeVersionRelation =
|
||||
| "same"
|
||||
| "app-newer"
|
||||
| "runtime-newer"
|
||||
| "unknown";
|
||||
|
||||
export type RuntimeAdoption =
|
||||
/** Nothing is running: install or start this App's Runtime. */
|
||||
| { action: "start" }
|
||||
/** Same build: use it as it is. */
|
||||
| { action: "adopt" }
|
||||
/** Its build cannot be compared: use it, never stop it, tell the user. */
|
||||
| { action: "adopt-unverified" }
|
||||
/** Older, and it is the service manager's job: replace it through the
|
||||
* service manager, never with a plain kill launchd would answer with a
|
||||
* respawn of whatever it last loaded. */
|
||||
| { action: "replace-via-service" }
|
||||
/** Older and transient/CLI-started, or a stray beside an enabled service:
|
||||
* stop it gracefully, then install or start the right one. */
|
||||
| { action: "stop-then-start"; reason: "app-newer" | "stray-runtime" }
|
||||
/** Newer than this App: use it as it is, silently. Never stopped or
|
||||
* downgraded at startup and never joined by a second Runtime; quit treats
|
||||
* it like any adopted Runtime. */
|
||||
| { action: "adopt-newer" };
|
||||
|
||||
/** Compare by build number, then by commit when both sides know theirs. */
|
||||
export function compareRuntimeBuild(
|
||||
app: AppBuild,
|
||||
runtime: RuntimeReceiptBuild | undefined,
|
||||
): RuntimeVersionRelation {
|
||||
if (app.buildNumber === undefined) return "unknown";
|
||||
// Every Runtime that publishes its build carries the fields, even as null.
|
||||
// A receipt without them predates that contract, and so this App.
|
||||
if (runtime === undefined) return "app-newer";
|
||||
if (runtime.buildNumber === null) return "unknown";
|
||||
if (runtime.buildNumber < app.buildNumber) return "app-newer";
|
||||
if (runtime.buildNumber > app.buildNumber) return "runtime-newer";
|
||||
const appCommit = app.commit?.trim().toLowerCase();
|
||||
if (
|
||||
appCommit &&
|
||||
runtime.commit &&
|
||||
!appCommit.startsWith(runtime.commit) &&
|
||||
!runtime.commit.startsWith(appCommit)
|
||||
) {
|
||||
// Same build, different commit: the App's bundle takes over, exactly as
|
||||
// `installPackagedRuntime({ replaceSameBuild: true })` already does.
|
||||
return "app-newer";
|
||||
}
|
||||
return "same";
|
||||
}
|
||||
|
||||
export function decideRuntimeAdoption(input: {
|
||||
running: RunningRuntime | null;
|
||||
app: AppBuild;
|
||||
service: RuntimeServiceState;
|
||||
/** This launch just moved `current` to the App's bundled Runtime, so the
|
||||
* process still running was started from the install it replaced. */
|
||||
installedChanged?: boolean;
|
||||
}): RuntimeAdoption {
|
||||
const { running, service } = input;
|
||||
if (running === null) return { action: "start" };
|
||||
let relation = compareRuntimeBuild(input.app, running.build);
|
||||
if (relation === "same" && input.installedChanged) relation = "app-newer";
|
||||
if (relation === "runtime-newer") return { action: "adopt-newer" };
|
||||
if (relation === "unknown") return { action: "adopt-unverified" };
|
||||
const serviceJob = service.jobPid !== null && service.jobPid === running.pid;
|
||||
if (relation === "app-newer") {
|
||||
return serviceJob
|
||||
? { action: "replace-via-service" }
|
||||
: { action: "stop-then-start", reason: "app-newer" };
|
||||
}
|
||||
// One Runtime per data dir: with the service on, the one that stays is the
|
||||
// service's own job, not a transient or CLI process holding its lease.
|
||||
if (service.state === "enabled" && !serviceJob) {
|
||||
return { action: "stop-then-start", reason: "stray-runtime" };
|
||||
}
|
||||
return { action: "adopt" };
|
||||
}
|
||||
|
||||
export interface RuntimeReconcileDependencies {
|
||||
app: AppBuild;
|
||||
installedChanged?: boolean;
|
||||
findRunning(): Promise<RunningRuntime | null>;
|
||||
queryServiceState(): Promise<RuntimeServiceState>;
|
||||
/** Graceful stop of the one verified Runtime: in-flight writes finish. */
|
||||
stopRuntime(): Promise<unknown>;
|
||||
log?(event: {
|
||||
decision: RuntimeAdoption;
|
||||
service: RuntimeServiceState | null;
|
||||
running: RunningRuntime | null;
|
||||
}): Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface RuntimeReconcileResult {
|
||||
decision: RuntimeAdoption;
|
||||
/** The running Runtime stays and the caller must not start another. */
|
||||
adopted: boolean;
|
||||
/** Quit must leave the adopted Runtime alone whatever the service state:
|
||||
* this App could not prove it is its own build. */
|
||||
preserveReason: string | null;
|
||||
}
|
||||
|
||||
/** Decide what to do with a Runtime already serving the data dir at App start,
|
||||
* and do the part that has to happen before the normal start path runs. */
|
||||
export async function reconcileRunningRuntime(
|
||||
dependencies: RuntimeReconcileDependencies,
|
||||
): Promise<RuntimeReconcileResult> {
|
||||
const running = await dependencies.findRunning();
|
||||
const service =
|
||||
running === null ? null : await dependencies.queryServiceState();
|
||||
const decision = decideRuntimeAdoption({
|
||||
running,
|
||||
app: dependencies.app,
|
||||
service: service ?? {
|
||||
state: "unknown",
|
||||
reason: "service-state-query-failed",
|
||||
jobPid: null,
|
||||
},
|
||||
installedChanged: dependencies.installedChanged ?? false,
|
||||
});
|
||||
await dependencies.log?.({ decision, service, running });
|
||||
switch (decision.action) {
|
||||
case "start":
|
||||
case "replace-via-service":
|
||||
return { decision, adopted: false, preserveReason: null };
|
||||
case "stop-then-start":
|
||||
await dependencies.stopRuntime();
|
||||
return { decision, adopted: false, preserveReason: null };
|
||||
case "adopt":
|
||||
case "adopt-newer":
|
||||
return { decision, adopted: true, preserveReason: null };
|
||||
case "adopt-unverified":
|
||||
return {
|
||||
decision,
|
||||
adopted: true,
|
||||
preserveReason: "runtime-build-unknown",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,96 +1,162 @@
|
||||
import type {
|
||||
RuntimeServiceState,
|
||||
StopAllRuntimesResult,
|
||||
} from "@one-memory/runtime-control";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { shutdownRuntime } from "./runtime-shutdown";
|
||||
|
||||
const { stopRuntimeProcess } = vi.hoisted(() => ({
|
||||
stopRuntimeProcess: vi.fn<() => Promise<"stopped" | "not-running">>(
|
||||
async () => "stopped",
|
||||
),
|
||||
const { stopAllRuntimeProcesses } = vi.hoisted(() => ({
|
||||
stopAllRuntimeProcesses: vi.fn<
|
||||
(options: { dataDir: string }) => Promise<StopAllRuntimesResult>
|
||||
>(async () => ({ stopped: [4321], unverified: [] })),
|
||||
}));
|
||||
|
||||
vi.mock("@one-memory/runtime-control", () => ({ stopRuntimeProcess }));
|
||||
vi.mock("@one-memory/runtime-control", () => ({ stopAllRuntimeProcesses }));
|
||||
|
||||
const stopMock = vi.mocked(stopRuntimeProcess);
|
||||
const stopMock = vi.mocked(stopAllRuntimeProcesses);
|
||||
|
||||
const enabled: RuntimeServiceState = {
|
||||
state: "enabled",
|
||||
reason: "service-enabled",
|
||||
jobPid: 4321,
|
||||
};
|
||||
const disabled = (
|
||||
reason: RuntimeServiceState["reason"] = "service-enable-marker-absent",
|
||||
): RuntimeServiceState => ({ state: "disabled", reason, jobPid: null });
|
||||
const unknown: RuntimeServiceState = {
|
||||
state: "unknown",
|
||||
reason: "service-state-timeout",
|
||||
jobPid: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
stopMock.mockClear();
|
||||
stopMock.mockResolvedValue("stopped");
|
||||
stopMock.mockReset();
|
||||
stopMock.mockResolvedValue({ stopped: [4321], unverified: [] });
|
||||
});
|
||||
|
||||
describe("BUG-12: a full quit stops the standalone Runtime process", () => {
|
||||
it("detaches the UI without stopping a managed Runtime service", async () => {
|
||||
describe("App quit with the background service enabled", () => {
|
||||
it("never stops the Runtime", async () => {
|
||||
const stopSupervisor = vi.fn(async () => {});
|
||||
const events: string[] = [];
|
||||
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: true,
|
||||
preserveManagedRuntime: true,
|
||||
queryServiceState: async () => enabled,
|
||||
stopSupervisor,
|
||||
onDiagnostic: (event) => {
|
||||
events.push(event.detail);
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ runtime: "unchanged" });
|
||||
expect(result).toEqual({
|
||||
runtime: "preserved",
|
||||
reason: "background-service-enabled",
|
||||
});
|
||||
expect(stopSupervisor).toHaveBeenCalledOnce();
|
||||
expect(stopMock).not.toHaveBeenCalled();
|
||||
expect(events).toEqual(["managed-runtime-preserved"]);
|
||||
expect(events).toEqual(["runtime-preserved:background-service-enabled"]);
|
||||
});
|
||||
|
||||
it("stops the Runtime process in a packaged build", async () => {
|
||||
const stopSupervisor = vi.fn(async () => {});
|
||||
it("does not stop a transient Runtime this App started before the service was enabled", async () => {
|
||||
// Startup fell back to a transient child (service launchctl-disabled) and
|
||||
// the user re-enabled the service later. Nothing startup saw is an input
|
||||
// here: quit asks the service manager again and preserves the Runtime.
|
||||
const queryServiceState = vi.fn(async () => enabled);
|
||||
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: true,
|
||||
stopSupervisor,
|
||||
queryServiceState,
|
||||
stopSupervisor: async () => {},
|
||||
});
|
||||
|
||||
expect(queryServiceState).toHaveBeenCalledOnce();
|
||||
expect(result.runtime).toBe("preserved");
|
||||
expect(stopMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("App quit with the background service disabled", () => {
|
||||
it("stops every Runtime of the data dir: the App's child and an external one", async () => {
|
||||
stopMock.mockResolvedValue({ stopped: [1111, 2222], unverified: [] });
|
||||
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: true,
|
||||
queryServiceState: async () => disabled(),
|
||||
stopSupervisor: async () => {},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ runtime: "stopped" });
|
||||
expect(stopMock).toHaveBeenCalledWith({ dataDir: "/tmp/runtime-data" });
|
||||
expect(stopSupervisor).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not touch a separate Runtime process in an embedded build", async () => {
|
||||
const stopSupervisor = vi.fn(async () => {});
|
||||
it.each([
|
||||
"service-not-installed",
|
||||
"service-disabled-by-launchctl",
|
||||
"service-enable-marker-absent",
|
||||
] as const)("treats %s as disabled", async (reason) => {
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: true,
|
||||
queryServiceState: async () => disabled(reason),
|
||||
});
|
||||
|
||||
expect(result).toEqual({ runtime: "stopped" });
|
||||
expect(stopMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports a live lease owner it could not verify instead of signalling it", async () => {
|
||||
stopMock.mockResolvedValue({ stopped: [], unverified: [9999] });
|
||||
const events: string[] = [];
|
||||
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: false,
|
||||
stopSupervisor,
|
||||
standalone: true,
|
||||
queryServiceState: async () => disabled(),
|
||||
onDiagnostic: (event) => {
|
||||
events.push(`${event.outcome}:${event.detail}`);
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ runtime: "unchanged" });
|
||||
expect(stopMock).not.toHaveBeenCalled();
|
||||
expect(stopSupervisor).toHaveBeenCalledOnce();
|
||||
expect(result).toEqual({
|
||||
runtime: "failed",
|
||||
detail: "unverified-runtime-left-running",
|
||||
});
|
||||
expect(events).toEqual(["degraded:unverified-runtime-left-running"]);
|
||||
});
|
||||
|
||||
it("tears the supervisor down before it stops the process", async () => {
|
||||
const order: string[] = [];
|
||||
stopMock.mockImplementation(async () => {
|
||||
order.push("process");
|
||||
return "stopped";
|
||||
return { stopped: [4321], unverified: [] };
|
||||
});
|
||||
|
||||
await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: true,
|
||||
queryServiceState: async () => {
|
||||
order.push("query");
|
||||
return disabled();
|
||||
},
|
||||
stopSupervisor: async () => {
|
||||
order.push("supervisor");
|
||||
},
|
||||
});
|
||||
|
||||
expect(order).toEqual(["supervisor", "process"]);
|
||||
expect(order).toEqual(["supervisor", "query", "process"]);
|
||||
});
|
||||
|
||||
it("reports not-running instead of throwing when nothing is attached", async () => {
|
||||
stopMock.mockResolvedValue("not-running");
|
||||
it("reports not-running when nothing serves the data dir", async () => {
|
||||
stopMock.mockResolvedValue({ stopped: [], unverified: [] });
|
||||
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: true,
|
||||
stopSupervisor: async () => {},
|
||||
queryServiceState: async () => disabled(),
|
||||
});
|
||||
|
||||
expect(result).toEqual({ runtime: "not-running" });
|
||||
@@ -103,7 +169,7 @@ describe("BUG-12: a full quit stops the standalone Runtime process", () => {
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: true,
|
||||
stopSupervisor: async () => {},
|
||||
queryServiceState: async () => disabled(),
|
||||
onDiagnostic: async (event) => {
|
||||
events.push(event.outcome);
|
||||
},
|
||||
@@ -117,24 +183,100 @@ describe("BUG-12: a full quit stops the standalone Runtime process", () => {
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: true,
|
||||
queryServiceState: async () => disabled(),
|
||||
stopSupervisor: async () => {
|
||||
throw new Error("supervisor exploded");
|
||||
},
|
||||
});
|
||||
|
||||
// The process is what must not be left behind; a supervisor error is not
|
||||
// an excuse to skip it.
|
||||
expect(stopMock).toHaveBeenCalledOnce();
|
||||
expect(result).toEqual({
|
||||
runtime: "failed",
|
||||
detail: "supervisor exploded",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("App quit when the service state cannot be determined", () => {
|
||||
it("stops nothing on a timeout and says so", async () => {
|
||||
const events: string[] = [];
|
||||
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: true,
|
||||
queryServiceState: async () => unknown,
|
||||
onDiagnostic: (event) => {
|
||||
events.push(`${event.outcome}:${event.detail}`);
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
runtime: "preserved",
|
||||
reason: "service-state-unknown:service-state-timeout",
|
||||
});
|
||||
expect(stopMock).not.toHaveBeenCalled();
|
||||
expect(events).toEqual([
|
||||
"degraded:runtime-preserved:service-state-unknown:service-state-timeout",
|
||||
]);
|
||||
});
|
||||
|
||||
it("stops nothing when the query itself throws", async () => {
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: true,
|
||||
queryServiceState: async () => {
|
||||
throw new Error("launchctl exploded");
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
runtime: "preserved",
|
||||
reason: "service-state-unknown:service-state-query-failed",
|
||||
});
|
||||
expect(stopMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Runtimes quit never touches", () => {
|
||||
it("leaves a Runtime whose build could not be read, even with the service disabled", async () => {
|
||||
const queryServiceState = vi.fn(async () => disabled());
|
||||
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: true,
|
||||
queryServiceState,
|
||||
preserveReason: "runtime-build-unknown",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
runtime: "preserved",
|
||||
reason: "runtime-build-unknown",
|
||||
});
|
||||
expect(stopMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not touch a separate Runtime process in an embedded build", async () => {
|
||||
const stopSupervisor = vi.fn(async () => {});
|
||||
const queryServiceState = vi.fn(async () => disabled());
|
||||
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: false,
|
||||
queryServiceState,
|
||||
stopSupervisor,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ runtime: "unchanged" });
|
||||
expect(stopMock).not.toHaveBeenCalled();
|
||||
expect(queryServiceState).not.toHaveBeenCalled();
|
||||
expect(stopSupervisor).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports a supervisor failure in an embedded build too", async () => {
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: false,
|
||||
queryServiceState: async () => disabled(),
|
||||
stopSupervisor: async () => {
|
||||
throw new Error("supervisor exploded");
|
||||
},
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import { stopRuntimeProcess } from "@one-memory/runtime-control";
|
||||
import {
|
||||
type RuntimeServiceState,
|
||||
type StopAllRuntimesResult,
|
||||
stopAllRuntimeProcesses,
|
||||
} from "@one-memory/runtime-control";
|
||||
|
||||
export interface RuntimeShutdownOptions {
|
||||
dataDir: string;
|
||||
/** Packaged builds run the Runtime as their own process; a dev build's Core
|
||||
* is this process's child and is stopped by the supervisor. */
|
||||
standalone: boolean;
|
||||
/** A managed OS service outlives Electron; a transient App-spawned Runtime
|
||||
* is still stopped on quit. */
|
||||
preserveManagedRuntime?: boolean;
|
||||
/** Asks the service manager whether the background service is on. Called
|
||||
* at quit time, every time: what startup saw may no longer be true. */
|
||||
queryServiceState: () => Promise<RuntimeServiceState>;
|
||||
/** Set when startup adopted a Runtime whose build it could not read. This
|
||||
* App never stops such a Runtime. A newer Runtime gets no such pass: it
|
||||
* follows the service state like any adopted Runtime. */
|
||||
preserveReason?: string | null;
|
||||
stopRuntimes?: (options: {
|
||||
dataDir: string;
|
||||
}) => Promise<StopAllRuntimesResult>;
|
||||
/** Tear down this process's supervisor state (Core child, broker). */
|
||||
stopSupervisor?: () => Promise<void>;
|
||||
onDiagnostic?: (event: {
|
||||
@@ -19,16 +30,22 @@ export interface RuntimeShutdownOptions {
|
||||
export type RuntimeShutdownResult =
|
||||
| { runtime: "stopped" }
|
||||
| { runtime: "not-running" }
|
||||
| { runtime: "preserved"; reason: string }
|
||||
| { runtime: "unchanged" }
|
||||
| { runtime: "failed"; detail: string };
|
||||
|
||||
/** Stop everything a full quit has to stop.
|
||||
/** Stop everything a full quit has to stop, and nothing else.
|
||||
*
|
||||
* Quitting used to tear down this process's own handles and nothing else. In a
|
||||
* packaged build the Runtime is a separate process, so it outlived the app: the
|
||||
* window and the tray were gone and the Runtime was still there, and its
|
||||
* receipt still named a pid, so the next launch concluded it was already
|
||||
* running and attached to a process that no longer answered.
|
||||
* Exactly one Runtime serves a data dir. Whether quitting the App ends it is
|
||||
* decided here, at quit time, from the service manager's answer:
|
||||
*
|
||||
* - background service enabled: the Runtime is the user's background memory
|
||||
* service. Quit never stops it, whoever started the process, including a
|
||||
* transient Runtime this App started earlier.
|
||||
* - background service disabled: the Runtime lives only while the App does.
|
||||
* Quit stops every Runtime of this data dir: the App's own child, one
|
||||
* `memhub start` launched, a leftover from an earlier session.
|
||||
* - state unknown (query failed or timed out): stop nothing, and say so.
|
||||
*
|
||||
* A failure here is reported, never thrown. By the time this runs the app is
|
||||
* already quitting, and throwing would only turn a failure someone could see
|
||||
@@ -39,15 +56,6 @@ export async function shutdownRuntime(
|
||||
): Promise<RuntimeShutdownResult> {
|
||||
const stopSupervisor = options.stopSupervisor ?? (async () => {});
|
||||
const supervisorFailure = await settle(stopSupervisor);
|
||||
if (options.preserveManagedRuntime) {
|
||||
await options.onDiagnostic?.({
|
||||
outcome: supervisorFailure ? "degraded" : "succeeded",
|
||||
detail: supervisorFailure ?? "managed-runtime-preserved",
|
||||
});
|
||||
return supervisorFailure
|
||||
? { runtime: "failed", detail: supervisorFailure }
|
||||
: { runtime: "unchanged" };
|
||||
}
|
||||
if (!options.standalone) {
|
||||
if (supervisorFailure) {
|
||||
await options.onDiagnostic?.({
|
||||
@@ -58,16 +66,52 @@ export async function shutdownRuntime(
|
||||
}
|
||||
return { runtime: "unchanged" };
|
||||
}
|
||||
try {
|
||||
const result = await stopRuntimeProcess({ dataDir: options.dataDir });
|
||||
const preserved = async (
|
||||
reason: string,
|
||||
outcome: "succeeded" | "degraded",
|
||||
): Promise<RuntimeShutdownResult> => {
|
||||
await options.onDiagnostic?.({
|
||||
outcome: supervisorFailure ? "degraded" : "succeeded",
|
||||
detail: supervisorFailure ?? result,
|
||||
outcome: supervisorFailure ? "degraded" : outcome,
|
||||
detail: supervisorFailure ?? `runtime-preserved:${reason}`,
|
||||
});
|
||||
return supervisorFailure
|
||||
? { runtime: "failed", detail: supervisorFailure }
|
||||
: { runtime: "preserved", reason };
|
||||
};
|
||||
if (options.preserveReason) {
|
||||
return preserved(options.preserveReason, "succeeded");
|
||||
}
|
||||
const service = await options.queryServiceState().catch(
|
||||
(): RuntimeServiceState => ({
|
||||
state: "unknown",
|
||||
reason: "service-state-query-failed",
|
||||
jobPid: null,
|
||||
}),
|
||||
);
|
||||
if (service.state === "enabled") {
|
||||
return preserved("background-service-enabled", "succeeded");
|
||||
}
|
||||
if (service.state !== "disabled") {
|
||||
// Fail safe: a Runtime stopped by mistake takes the user's background
|
||||
// memory with it; one left running by mistake is only a process.
|
||||
return preserved(`service-state-unknown:${service.reason}`, "degraded");
|
||||
}
|
||||
try {
|
||||
const result = await (options.stopRuntimes ?? stopAllRuntimeProcesses)({
|
||||
dataDir: options.dataDir,
|
||||
});
|
||||
const stopped = result.stopped.length > 0 ? "stopped" : "not-running";
|
||||
const unverified =
|
||||
result.unverified.length > 0 ? "unverified-runtime-left-running" : null;
|
||||
await options.onDiagnostic?.({
|
||||
outcome: supervisorFailure || unverified ? "degraded" : "succeeded",
|
||||
detail: supervisorFailure ?? unverified ?? `${stopped}:${service.reason}`,
|
||||
});
|
||||
if (supervisorFailure) {
|
||||
return { runtime: "failed", detail: supervisorFailure };
|
||||
}
|
||||
return { runtime: result };
|
||||
if (unverified) return { runtime: "failed", detail: unverified };
|
||||
return { runtime: stopped };
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : "unknown";
|
||||
await options.onDiagnostic?.({ outcome: "degraded", detail });
|
||||
|
||||
@@ -143,6 +143,7 @@ const SAFE_STABLE_RUNTIME_ERROR_CODES = new Set([
|
||||
"sdk_migration_status_unavailable",
|
||||
"sdk_migration_incomplete",
|
||||
"runtime_service_not_ready",
|
||||
"runtime_build_unknown",
|
||||
]);
|
||||
|
||||
/** Every migration code is a stable token Core or the orchestrator chose;
|
||||
@@ -242,6 +243,8 @@ export const STABLE_RUNTIME_USER_MESSAGES: Record<string, string> = {
|
||||
"记忆数据格式升级没有完成,MemHub 暂时无法打开。请重新启动重试;如果仍然失败,请把这个提示反馈给我们,原有记忆数据不会丢失。",
|
||||
runtime_service_not_ready:
|
||||
"记忆服务仍在首次启动,请稍候后重试。你的记忆数据不会受到影响。",
|
||||
runtime_build_unknown:
|
||||
"无法确认正在运行的 MemHub 后台版本,App 已连接到它,但不会停止或替换它。如遇问题,请更新 App 或重启后台服务;你的记忆数据不会受到影响。",
|
||||
desktop_start_failed:
|
||||
"MemHub 启动失败。请重新启动;如果仍然打不开,请重新安装,你的记忆数据不会受到影响。",
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ describe("startup screen", () => {
|
||||
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('<html lang="zh-Hans">');
|
||||
expect(startupScreenHtml()).toContain("正在启动,正在准备记忆服务…");
|
||||
const spanish = startupScreenHtml("es");
|
||||
expect(spanish).toContain('<html lang="es">');
|
||||
|
||||
@@ -11,7 +11,7 @@ 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(locale: GuiLocale = "zh-Hans"): string {
|
||||
return `<!doctype html>
|
||||
<html lang="${locale}">
|
||||
<head>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@one-memory/site",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@one-memory/web",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -27,7 +27,7 @@ it("translates application detail live without triggering extra reads or writes"
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole("switch", { name: "Sincronización" })).toBeTruthy();
|
||||
await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1));
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-Hans");
|
||||
await act(async () =>
|
||||
window.dispatchEvent(
|
||||
new StorageEvent("storage", { key: LANGUAGE_STORAGE_KEY }),
|
||||
@@ -603,7 +603,7 @@ 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) {
|
||||
function switchLanguage(locale: "es" | "zh-Hans", revision: number) {
|
||||
act(() => languageListener?.({ preference: locale, locale, revision }));
|
||||
}
|
||||
async function failDisconnectInSpanish() {
|
||||
@@ -660,7 +660,7 @@ it("re-words a visible failure when the language changes, without new writes", a
|
||||
).getRecallHosts;
|
||||
const readCount = reads.mock.calls.length;
|
||||
const revokes = bridge.revokeRecallPermission.mock.calls.length;
|
||||
switchLanguage("zh-CN", 2);
|
||||
switchLanguage("zh-Hans", 2);
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"连接未能移除,记忆权限也未能撤销,OpenCode 仍可使用记忆,请重试。原因:应用控制通道暂不可用。",
|
||||
|
||||
@@ -23,8 +23,8 @@ 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";
|
||||
preference: "es" | "zh-Hans";
|
||||
locale: "es" | "zh-Hans";
|
||||
revision: number;
|
||||
}) => void)
|
||||
| undefined;
|
||||
@@ -52,7 +52,9 @@ it("localizes the whole home live without repeating reads or changing imported t
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Ver las 5 aplicaciones" }),
|
||||
);
|
||||
act(() => change?.({ preference: "zh-CN", locale: "zh-CN", revision: 2 }));
|
||||
act(() =>
|
||||
change?.({ preference: "zh-Hans", locale: "zh-Hans", revision: 2 }),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "全部应用", level: 1 }),
|
||||
).toBeTruthy();
|
||||
|
||||
@@ -166,7 +166,7 @@ it("traduce el historial en vivo sin cambiar nombres ni contenido importado", as
|
||||
).toBeTruthy();
|
||||
expect(screen.getAllByText("Consulta completada")).toHaveLength(2);
|
||||
expect(screen.getByText("Consulta de memorias")).toBeTruthy();
|
||||
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
|
||||
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-Hans");
|
||||
fireEvent(window, new StorageEvent("storage", { key: LANGUAGE_STORAGE_KEY }));
|
||||
expect(screen.getByRole("heading", { name: "这次查记忆" })).toBeTruthy();
|
||||
window.localStorage.removeItem(LANGUAGE_STORAGE_KEY);
|
||||
|
||||
@@ -205,7 +205,7 @@ function ConnectionMaterialFallback({
|
||||
? t("connectionFallback.promptShell", { shell: material.shell })
|
||||
: "";
|
||||
const prompt =
|
||||
locale === "zh-CN"
|
||||
locale === "zh-Hans"
|
||||
? material.prompt
|
||||
: t("connectionFallback.promptTemplate", {
|
||||
mcp: material.mcp,
|
||||
|
||||
@@ -111,7 +111,7 @@ afterEach(() => {
|
||||
function SwitchLanguage() {
|
||||
const { setPreference } = useGuiLanguage();
|
||||
return (
|
||||
<button type="button" onClick={() => void setPreference("zh-CN")}>
|
||||
<button type="button" onClick={() => void setPreference("zh-Hans")}>
|
||||
Cambiar idioma
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
function LanguageSwitch() {
|
||||
const { setPreference } = useGuiLanguage();
|
||||
return (
|
||||
<button type="button" onClick={() => void setPreference("zh-CN")}>
|
||||
<button type="button" onClick={() => void setPreference("zh-Hans")}>
|
||||
Cambiar idioma
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -33,7 +33,7 @@ vi.mock("./api", () => ({
|
||||
function HealthLanguageSwitch() {
|
||||
const { setPreference } = useGuiLanguage();
|
||||
return (
|
||||
<button type="button" onClick={() => void setPreference("zh-CN")}>
|
||||
<button type="button" onClick={() => void setPreference("zh-Hans")}>
|
||||
Cambiar idioma
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -95,7 +95,7 @@ describe("ImportProgressStrip", () => {
|
||||
function Switch() {
|
||||
const { setPreference } = useGuiLanguage();
|
||||
return (
|
||||
<button type="button" onClick={() => void setPreference("zh-CN")}>
|
||||
<button type="button" onClick={() => void setPreference("zh-Hans")}>
|
||||
Cambiar idioma
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { translate } from "@one-memory/i18n";
|
||||
import { LOCALE_NAMES, SUPPORTED_LOCALES, translate } from "@one-memory/i18n";
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
@@ -20,7 +20,7 @@ afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each(["zh-CN", "en", "es"] as const)(
|
||||
it.each(["zh-Hans", "en", "es", "ja", "de", "ru"] as const)(
|
||||
"hides the compact label while preserving its accessible name in %s",
|
||||
async (locale) => {
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
|
||||
@@ -42,7 +42,7 @@ it.each(["zh-CN", "en", "es"] as const)(
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["zh-CN", "en", "es"] as const)(
|
||||
it.each(["zh-Hans", "en", "es", "zh-Hant", "fr", "ko"] as const)(
|
||||
"keeps the normal settings label visible and accessible in %s",
|
||||
(locale) => {
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
|
||||
@@ -66,7 +66,7 @@ it.each(["zh-CN", "en", "es"] as const)(
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["system", "zh-CN", "en", "es"] as const)(
|
||||
it.each(["system", "zh-Hans", "en", "es", "pt", "id"] as const)(
|
||||
"keeps one stable settings row for the %s preference",
|
||||
async (preference) => {
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, preference);
|
||||
@@ -81,40 +81,46 @@ it.each(["system", "zh-CN", "en", "es"] as const)(
|
||||
expect(row).toBeTruthy();
|
||||
expect(row?.querySelector("label")?.hidden).toBe(false);
|
||||
await userEvent.click(selector);
|
||||
expect(
|
||||
screen
|
||||
.getByRole("option", {
|
||||
name: /简体中文|Simplified Chinese|Chino simplificado/,
|
||||
})
|
||||
.getAttribute("lang"),
|
||||
).toBe("zh-CN");
|
||||
expect(
|
||||
screen
|
||||
.getByRole("option", { name: /英语|English|Inglés/ })
|
||||
.getAttribute("lang"),
|
||||
).toBe("en");
|
||||
expect(
|
||||
screen
|
||||
.getByRole("option", { name: /西班牙语|Spanish|Español/ })
|
||||
.getAttribute("lang"),
|
||||
).toBe("es");
|
||||
// Every language by its own name, whatever the interface language.
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
expect(
|
||||
screen
|
||||
.getByRole("option", { name: LOCALE_NAMES[locale] })
|
||||
.getAttribute("lang"),
|
||||
).toBe(locale);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("offers an accessible selector, persists and updates its label", async () => {
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-Hans");
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<LanguageSelector />
|
||||
</LanguageProvider>,
|
||||
);
|
||||
const selector = screen.getByRole("combobox", {
|
||||
name: translate("zh-CN", "languageLabel"),
|
||||
name: translate("zh-Hans", "languageLabel"),
|
||||
});
|
||||
expect(selector.getAttribute("aria-describedby")).toBeTruthy();
|
||||
await userEvent.click(selector);
|
||||
expect(screen.getAllByRole("option")).toHaveLength(4);
|
||||
await userEvent.click(screen.getByRole("option", { name: "西班牙语" }));
|
||||
expect(
|
||||
screen.getAllByRole("option").map((option) => option.textContent),
|
||||
).toEqual([
|
||||
translate("zh-Hans", "languageSystem"),
|
||||
"English",
|
||||
"简体中文",
|
||||
"繁體中文",
|
||||
"日本語",
|
||||
"Español",
|
||||
"Deutsch",
|
||||
"Português",
|
||||
"Français",
|
||||
"한국어",
|
||||
"Русский",
|
||||
"Bahasa Indonesia",
|
||||
]);
|
||||
await userEvent.click(screen.getByRole("option", { name: "Español" }));
|
||||
expect(localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe("es");
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: translate("es", "languageLabel") }),
|
||||
@@ -143,12 +149,12 @@ it("supports keyboard selection without using the system select menu", async ()
|
||||
expect(selector.getAttribute("aria-expanded")).toBe("true");
|
||||
await userEvent.keyboard("{Escape}");
|
||||
await userEvent.keyboard("{ArrowDown}{ArrowDown}{Enter}");
|
||||
expect(localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe("zh-CN");
|
||||
expect(localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe("en");
|
||||
expect(selector.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("announces storage errors without replacing the confirmed choice", async () => {
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-Hans");
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<LanguageSelector />
|
||||
@@ -158,12 +164,12 @@ it("announces storage errors without replacing the confirmed choice", async () =
|
||||
throw new Error("denied");
|
||||
});
|
||||
await userEvent.click(screen.getByRole("combobox"));
|
||||
await userEvent.click(screen.getByRole("option", { name: "西班牙语" }));
|
||||
await userEvent.click(screen.getByRole("option", { name: "Español" }));
|
||||
expect(screen.getByRole("alert").textContent).toBe(
|
||||
translate("zh-CN", "languageSaveError"),
|
||||
translate("zh-Hans", "languageSaveError"),
|
||||
);
|
||||
expect((screen.getByRole("combobox") as HTMLButtonElement).value).toBe(
|
||||
"zh-CN",
|
||||
"zh-Hans",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { isLanguagePreference } from "@one-memory/i18n";
|
||||
import {
|
||||
isLanguagePreference,
|
||||
LOCALE_NAMES,
|
||||
SUPPORTED_LOCALES,
|
||||
} from "@one-memory/i18n";
|
||||
import { useId, useState } from "react";
|
||||
import { useGuiLanguage } from "./gui-language";
|
||||
import { OptionMenu } from "./OptionMenu";
|
||||
@@ -7,24 +11,15 @@ export function LanguageSelector({ compact = false }: { compact?: boolean }) {
|
||||
const id = useId();
|
||||
const { preference, setPreference, t, error } = useGuiLanguage();
|
||||
const [pending, setPending] = useState(false);
|
||||
// Each language by its own name, so a person can find theirs in any UI.
|
||||
const options = [
|
||||
{ value: "system", label: t("languageSystem") },
|
||||
{
|
||||
value: "zh-CN",
|
||||
label: compact ? "简体中文" : t("languageChinese"),
|
||||
lang: "zh-CN",
|
||||
},
|
||||
{
|
||||
value: "en",
|
||||
label: compact ? "English" : t("languageEnglish"),
|
||||
lang: "en",
|
||||
},
|
||||
{
|
||||
value: "es",
|
||||
label: compact ? "Español" : t("languageSpanish"),
|
||||
lang: "es",
|
||||
},
|
||||
] as const;
|
||||
...SUPPORTED_LOCALES.map((locale) => ({
|
||||
value: locale,
|
||||
label: LOCALE_NAMES[locale],
|
||||
lang: locale,
|
||||
})),
|
||||
];
|
||||
return (
|
||||
<section
|
||||
aria-labelledby={compact ? `${id}-label` : `${id}-title`}
|
||||
|
||||
@@ -114,7 +114,7 @@ type Props = {
|
||||
|
||||
function dayStamp(
|
||||
value: string | null | undefined,
|
||||
locale: GuiLocale = "zh-CN",
|
||||
locale: GuiLocale = "zh-Hans",
|
||||
) {
|
||||
if (!value || !Number.isFinite(Date.parse(value)))
|
||||
return memoryText(locale, "时间未知");
|
||||
@@ -146,7 +146,7 @@ function monthDay(value: string, locale: GuiLocale): string | null {
|
||||
function sourceStamp(
|
||||
time: { at: string; basis: "conversation" | "import" | "write" } | undefined,
|
||||
fallback: string | null | undefined,
|
||||
locale: GuiLocale = "zh-CN",
|
||||
locale: GuiLocale = "zh-Hans",
|
||||
): string {
|
||||
const day = time ? monthDay(time.at, locale) : null;
|
||||
if (time && day)
|
||||
|
||||
@@ -28,7 +28,7 @@ it("translates memory filters and detail affordances live without refetching", a
|
||||
expect(screen.getByRole("button", { name: "Buscar" })).toBeTruthy();
|
||||
const calls = reads.sdkLibrary.mock.calls.length;
|
||||
act(() => {
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-Hans");
|
||||
window.dispatchEvent(
|
||||
new StorageEvent("storage", { key: LANGUAGE_STORAGE_KEY }),
|
||||
);
|
||||
|
||||
@@ -41,7 +41,7 @@ it("keeps the edited model draft and does not mutate on live language changes",
|
||||
activate.mock.calls.length,
|
||||
probeProfile.mock.calls.length,
|
||||
];
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-Hans");
|
||||
await act(async () =>
|
||||
window.dispatchEvent(
|
||||
new StorageEvent("storage", { key: LANGUAGE_STORAGE_KEY }),
|
||||
|
||||
@@ -77,7 +77,7 @@ function controller(overrides: Partial<SourceResponse> = {}) {
|
||||
function SwitchLanguage() {
|
||||
const { setPreference } = useGuiLanguage();
|
||||
return (
|
||||
<button type="button" onClick={() => void setPreference("zh-CN")}>
|
||||
<button type="button" onClick={() => void setPreference("zh-Hans")}>
|
||||
中文
|
||||
</button>
|
||||
);
|
||||
@@ -100,7 +100,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
it("changes the first-screen language without losing source choices or starting an import", async () => {
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-Hans");
|
||||
const imports = controller();
|
||||
const finish = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
@@ -330,7 +330,7 @@ it.each([
|
||||
);
|
||||
},
|
||||
);
|
||||
it.each(["es", "zh-CN"] as const)(
|
||||
it.each(["es", "zh-Hans"] as const)(
|
||||
"preserves three multiline progress cards and receipt statuses in %s",
|
||||
(locale) => {
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
|
||||
@@ -475,7 +475,7 @@ it.each([
|
||||
} as unknown as ImportJob;
|
||||
for (const [locale, expected] of [
|
||||
["es", spanish],
|
||||
["zh-CN", chinese],
|
||||
["zh-Hans", chinese],
|
||||
] as const) {
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
|
||||
const view = render(
|
||||
|
||||
@@ -20,7 +20,7 @@ afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it.each(["es", "zh-CN"] as const)(
|
||||
it.each(["es", "zh-Hans"] as const)(
|
||||
"keeps %s footer actions, pending locks and permission boundaries",
|
||||
async (locale) => {
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
|
||||
|
||||
@@ -78,7 +78,7 @@ export function OnboardingModelStep(props: OnboardingModelStepProps) {
|
||||
// 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");
|
||||
locale === "zh-Hans" ? "" : t("onboardingModelSkipDestination");
|
||||
const fields = useRef<HTMLElement>(null);
|
||||
const advancedToggle = useRef<HTMLButtonElement>(null);
|
||||
const restoreToggleFocus = useRef(false);
|
||||
|
||||
@@ -87,6 +87,26 @@ it("removes the duplicate native drag overlay above the language selector", () =
|
||||
).toMatch(/-webkit-app-region:\s*no-drag/);
|
||||
});
|
||||
|
||||
it("keeps long language names on one line in the fixed-size selectors", () => {
|
||||
// "Utiliser la langue du système", "Systemsprache verwenden" and
|
||||
// "Bahasa Indonesia" are wider than the 144px title-bar button.
|
||||
for (const [file, selector] of [
|
||||
[
|
||||
"onboarding-r6.css",
|
||||
".onboarding-r6-titlebar .r6-language-option-menu .om-option-menu-button",
|
||||
],
|
||||
[
|
||||
"r6-settings.css",
|
||||
".r6-settings .r6-language-option-menu .om-option-menu-button",
|
||||
],
|
||||
] as const) {
|
||||
const rule = declaration(file, selector);
|
||||
expect(rule, selector).toMatch(/white-space:\s*nowrap/);
|
||||
expect(rule, selector).toMatch(/text-overflow:\s*ellipsis/);
|
||||
expect(rule, selector).toMatch(/overflow:\s*hidden/);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps selection status outside decorative fades and readable when disabled", () => {
|
||||
expect(
|
||||
declaration("onboarding-r6.css", ".onboarding-r6-list-area::after"),
|
||||
@@ -122,7 +142,7 @@ it("keeps the product language menu keyboard-focusable and switches both languag
|
||||
expect(screen.getByRole("combobox").getAttribute("id")).toBe(selector.id);
|
||||
await user.click(selector);
|
||||
await user.click(screen.getByRole("option", { name: "简体中文" }));
|
||||
expect(document.documentElement.lang).toBe("zh-CN");
|
||||
expect(document.documentElement.lang).toBe("zh-Hans");
|
||||
expect(screen.getByRole("combobox", { name: "界面语言" })).toBe(selector);
|
||||
});
|
||||
|
||||
@@ -146,7 +166,7 @@ it.each([1, 2, 3] as const)(
|
||||
await userEvent.click(selector);
|
||||
expect(
|
||||
screen.getByRole("option", { name: "简体中文" }).getAttribute("lang"),
|
||||
).toBe("zh-CN");
|
||||
).toBe("zh-Hans");
|
||||
expect(
|
||||
screen.getByRole("option", { name: "Español" }).getAttribute("lang"),
|
||||
).toBe("es");
|
||||
|
||||
@@ -28,7 +28,7 @@ it("changes settings copy live without rereading profiles or changing permission
|
||||
).toBeNull();
|
||||
const reads = vi.mocked(getModelProfiles).mock.calls.length;
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
await user.click(screen.getByRole("option", { name: "Chino simplificado" }));
|
||||
await user.click(screen.getByRole("option", { name: "简体中文" }));
|
||||
expect(await screen.findByRole("heading", { name: "记忆模型" })).toBeTruthy();
|
||||
expect(vi.mocked(getModelProfiles).mock.calls).toHaveLength(reads);
|
||||
});
|
||||
@@ -99,7 +99,7 @@ it("shows the help and diagnostics group by default and hides it when opted out"
|
||||
const view = render(<R6Settings onDiagnostics={vi.fn()} />);
|
||||
expect(screen.getByRole("combobox")).toBeTruthy();
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
expect(screen.getAllByRole("option")).toHaveLength(4);
|
||||
expect(screen.getAllByRole("option")).toHaveLength(12);
|
||||
await user.keyboard("{Escape}");
|
||||
await user.click(screen.getByRole("button", { name: "连接其他应用" }));
|
||||
expect(screen.getByTestId("connection-default").textContent).toBe("true");
|
||||
|
||||
@@ -1025,6 +1025,20 @@ export type SedimentationState = {
|
||||
imports_unaffected: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tell Core the display language (null = follow the system). zh-Hans and
|
||||
* null leave the SDK's zh/en detection; any other code sets its prompts.
|
||||
*/
|
||||
export async function setUiLanguage(
|
||||
uiLanguage: string | null,
|
||||
): Promise<{ ui_language: string | null; sdk_language: string }> {
|
||||
const response = await client.put<{
|
||||
ui_language: string | null;
|
||||
sdk_language: string;
|
||||
}>("/api/v1/settings/ui-language", { ui_language: uiLanguage });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getSedimentation(): Promise<SedimentationState> {
|
||||
const response = await client.get<SedimentationState>(
|
||||
"/api/v1/sedimentation",
|
||||
|
||||
@@ -65,7 +65,7 @@ export const CALL_HOSTS: Record<string, string> = {
|
||||
pi: "Pi",
|
||||
"deepseek-harness": "DeepSeek Harness",
|
||||
};
|
||||
export function callHostName(id: string | null, locale: GuiLocale = "zh-CN") {
|
||||
export function callHostName(id: string | null, locale: GuiLocale = "zh-Hans") {
|
||||
return id === null
|
||||
? translate(locale, "calls.directHost")
|
||||
: Object.hasOwn(CALL_HOSTS, id)
|
||||
@@ -82,7 +82,7 @@ const HEADLINES = {
|
||||
export function callEventHeadline(
|
||||
item: CallEvent,
|
||||
host?: string,
|
||||
locale: GuiLocale = "zh-CN",
|
||||
locale: GuiLocale = "zh-Hans",
|
||||
) {
|
||||
const name = host ?? callHostName(item.host_id, locale);
|
||||
const fixed = HEADLINES[item.state as keyof typeof HEADLINES];
|
||||
@@ -93,10 +93,13 @@ export function callEventHeadline(
|
||||
count: plainCount(locale, count),
|
||||
});
|
||||
}
|
||||
export function callRowHeadline(item: CallEvent, locale: GuiLocale = "zh-CN") {
|
||||
export function callRowHeadline(
|
||||
item: CallEvent,
|
||||
locale: GuiLocale = "zh-Hans",
|
||||
) {
|
||||
return callEventHeadline(item, undefined, locale);
|
||||
}
|
||||
export function callRowDetail(item: CallEvent, locale: GuiLocale = "zh-CN") {
|
||||
export function callRowDetail(item: CallEvent, locale: GuiLocale = "zh-Hans") {
|
||||
const host = callHostName(item.host_id, locale);
|
||||
const key: MessageKey =
|
||||
item.state === "timed_out"
|
||||
@@ -121,13 +124,13 @@ export function callRowDetail(item: CallEvent, locale: GuiLocale = "zh-CN") {
|
||||
export function callDetailHeadline(
|
||||
item: CallEvent,
|
||||
host?: string,
|
||||
locale: GuiLocale = "zh-CN",
|
||||
locale: GuiLocale = "zh-Hans",
|
||||
) {
|
||||
return callEventHeadline(item, host, locale);
|
||||
}
|
||||
export function callDeliveryLabel(
|
||||
item: CallEvent,
|
||||
locale: GuiLocale = "zh-CN",
|
||||
locale: GuiLocale = "zh-Hans",
|
||||
) {
|
||||
if (item.delivery === "delivered")
|
||||
return translate(locale, "calls.detail.delivered", {
|
||||
@@ -139,7 +142,7 @@ export function callDeliveryLabel(
|
||||
}
|
||||
export function callReturnDeliveryLabel(
|
||||
item: CallEvent,
|
||||
locale: GuiLocale = "zh-CN",
|
||||
locale: GuiLocale = "zh-Hans",
|
||||
) {
|
||||
if (
|
||||
item.state === "timed_out" ||
|
||||
|
||||
@@ -22,7 +22,7 @@ describe("clockStamp", () => {
|
||||
"reports an unknown time for %j",
|
||||
(value) => {
|
||||
expect(clockStamp(value)).toBe("时间未知");
|
||||
expect(clockStamp(value, "zh-CN")).toBe("时间未知");
|
||||
expect(clockStamp(value, "zh-Hans")).toBe("时间未知");
|
||||
expect(clockStamp(value, "es")).toBe("Hora desconocida");
|
||||
},
|
||||
);
|
||||
@@ -31,7 +31,7 @@ describe("clockStamp", () => {
|
||||
// 12:05 UTC is 09:05 local on the same day.
|
||||
const value = "2026-09-20T12:05:00Z";
|
||||
expect(clockStamp(value, "es")).toBe("Hoy 09:05");
|
||||
expect(clockStamp(value, "zh-CN")).toBe("今天 09:05");
|
||||
expect(clockStamp(value, "zh-Hans")).toBe("今天 09:05");
|
||||
});
|
||||
|
||||
it("defaults to Chinese", () => {
|
||||
@@ -42,7 +42,7 @@ describe("clockStamp", () => {
|
||||
// 02:00 UTC on the 20th is still the 19th in Buenos Aires.
|
||||
const value = "2026-09-20T02:00:00Z";
|
||||
expect(clockStamp(value, "es")).not.toMatch(/^Hoy/);
|
||||
expect(clockStamp(value, "zh-CN")).not.toMatch(/^今天/);
|
||||
expect(clockStamp(value, "zh-Hans")).not.toMatch(/^今天/);
|
||||
expect(clockStamp(value, "es")).toBe(
|
||||
new Intl.DateTimeFormat("es", {
|
||||
month: "2-digit",
|
||||
@@ -55,15 +55,15 @@ describe("clockStamp", () => {
|
||||
});
|
||||
|
||||
it("names the year of a date in another year", () => {
|
||||
expect(clockStamp("2254-01-13T13:54:00Z", "zh-CN")).toContain("2254");
|
||||
expect(clockStamp("2254-01-13T13:54:00Z", "zh-Hans")).toContain("2254");
|
||||
expect(clockStamp("2025-12-31T12:00:00Z", "es")).toContain("2025");
|
||||
expect(clockStamp("2026-08-03T15:07:00Z", "zh-CN")).not.toContain("2026");
|
||||
expect(clockStamp("2026-08-03T15:07:00Z", "zh-Hans")).not.toContain("2026");
|
||||
});
|
||||
|
||||
it("formats another date with month and day, without a today word", () => {
|
||||
const value = "2026-08-03T15:07:00Z";
|
||||
const es = clockStamp(value, "es");
|
||||
const zh = clockStamp(value, "zh-CN");
|
||||
const zh = clockStamp(value, "zh-Hans");
|
||||
// Spanish short date is day/month: 3 August. Padding varies across ICU
|
||||
// versions, so only the order is pinned.
|
||||
expect(es).toMatch(/^0?3\/0?8\b/);
|
||||
|
||||
@@ -18,7 +18,7 @@ import { type GuiLocale, translate } from "@one-memory/i18n";
|
||||
*/
|
||||
export function clockStamp(
|
||||
value: string | null | undefined,
|
||||
locale: GuiLocale = "zh-CN",
|
||||
locale: GuiLocale = "zh-Hans",
|
||||
): string {
|
||||
if (!value || !Number.isFinite(Date.parse(value)))
|
||||
return translate(locale, "clock.unknown");
|
||||
|
||||
@@ -9,7 +9,7 @@ export type DiagnosticsExportResult = {
|
||||
};
|
||||
|
||||
export async function exportDiagnosticsFile(
|
||||
locale: GuiLocale = "zh-CN",
|
||||
locale: GuiLocale = "zh-Hans",
|
||||
): Promise<DiagnosticsExportResult> {
|
||||
if (!window.oneMemory?.exportDiagnostics) {
|
||||
return { ok: false, message: translate(locale, "diagnosticExportDesktop") };
|
||||
|
||||
@@ -60,7 +60,7 @@ it("removes browser listeners on StrictMode cleanup and final unmount", () => {
|
||||
}
|
||||
});
|
||||
it("ignores unrelated storage and language changes for explicit preferences", async () => {
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-Hans");
|
||||
mount();
|
||||
vi.spyOn(navigator, "language", "get").mockReturnValue("es");
|
||||
act(() => {
|
||||
@@ -75,7 +75,7 @@ it("ignores unrelated storage and language changes for explicit preferences", as
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(screen.getByText("zh-CN:zh-CN")).toBeTruthy();
|
||||
expect(screen.getByText("zh-Hans:zh-Hans")).toBeTruthy();
|
||||
});
|
||||
it("shows errors for unavailable desktop bridges without using browser fallback", async () => {
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
|
||||
@@ -86,7 +86,7 @@ it("shows errors for unavailable desktop bridges without using browser fallback"
|
||||
expect(screen.getByRole("alert").textContent).toBe("languageLoadError");
|
||||
await userEvent.click(screen.getByRole("button"));
|
||||
expect(screen.getByRole("alert").textContent).toBe("languageSaveError");
|
||||
expect(screen.getByText("zh-CN:system")).toBeTruthy();
|
||||
expect(screen.getByText("zh-Hans:system")).toBeTruthy();
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
expect(write).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -130,11 +130,11 @@ it("validates native replies and keeps the last confirmed snapshot when saving f
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("alert").textContent).toBe("languageLoadError"),
|
||||
);
|
||||
act(() => listener({ preference: "system", locale: "zh-CN", revision: 2 }));
|
||||
act(() => listener({ preference: "system", locale: "zh-Hans", revision: 2 }));
|
||||
await userEvent.click(screen.getByRole("button"));
|
||||
expect(set).toHaveBeenCalledWith("es");
|
||||
expect(screen.getByRole("alert").textContent).toBe("languageSaveError");
|
||||
expect(screen.getByText("zh-CN:system")).toBeTruthy();
|
||||
act(() => listener({ preference: "system", locale: "zh-CN", revision: 2 }));
|
||||
expect(screen.getByText("zh-Hans:system")).toBeTruthy();
|
||||
act(() => listener({ preference: "system", locale: "zh-Hans", revision: 2 }));
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
@@ -71,7 +71,7 @@ function desktop(get: () => Promise<LanguageSnapshot>) {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
delete window.oneMemory;
|
||||
vi.spyOn(navigator, "language", "get").mockReturnValue("zh-CN");
|
||||
vi.spyOn(navigator, "language", "get").mockReturnValue("zh-Hans");
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -101,7 +101,7 @@ it("keeps confirmed language and exposes storage write errors", async () => {
|
||||
throw new Error("private storage");
|
||||
});
|
||||
await userEvent.click(screen.getByRole("button"));
|
||||
expect(screen.getByText("zh-CN:system")).toBeTruthy();
|
||||
expect(screen.getByText("zh-Hans:system")).toBeTruthy();
|
||||
expect(screen.getByRole("alert").textContent).toBe("languageSaveError");
|
||||
});
|
||||
it("exposes browser read errors without crashing", () => {
|
||||
@@ -114,7 +114,7 @@ it("exposes browser read errors without crashing", () => {
|
||||
it("validates browser storage and synchronizes tabs and principal navigator language", () => {
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, "invalid");
|
||||
mount();
|
||||
expect(screen.getByText("zh-CN:system")).toBeTruthy();
|
||||
expect(screen.getByText("zh-Hans:system")).toBeTruthy();
|
||||
act(() => {
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
|
||||
window.dispatchEvent(
|
||||
@@ -147,7 +147,7 @@ it("subscribes before reads, rejects stale snapshots and cleans StrictMode subsc
|
||||
});
|
||||
await act(async () => {
|
||||
for (const read of reads)
|
||||
read.resolve({ preference: "zh-CN", locale: "zh-CN", revision: 1 });
|
||||
read.resolve({ preference: "zh-Hans", locale: "zh-Hans", revision: 1 });
|
||||
});
|
||||
expect(screen.getByText("es:es")).toBeTruthy();
|
||||
view.unmount();
|
||||
@@ -164,7 +164,7 @@ it("never accesses browser persistence or browser locale in desktop mode and exp
|
||||
);
|
||||
vi.spyOn(navigator, "language", "get").mockReturnValue("es");
|
||||
act(() => window.dispatchEvent(new Event("languagechange")));
|
||||
expect(screen.getByText("zh-CN:system")).toBeTruthy();
|
||||
expect(screen.getByText("zh-Hans:system")).toBeTruthy();
|
||||
act(() => {
|
||||
for (const listener of listeners)
|
||||
listener({ preference: "system", locale: "es", revision: 3 });
|
||||
@@ -177,7 +177,7 @@ it("never accesses browser persistence or browser locale in desktop mode and exp
|
||||
it("does not surface an obsolete write failure after a newer save succeeds", async () => {
|
||||
const { bridge } = desktop(async () => ({
|
||||
preference: "system",
|
||||
locale: "zh-CN",
|
||||
locale: "zh-Hans",
|
||||
revision: 0,
|
||||
}));
|
||||
const first = deferred<LanguageSnapshot>();
|
||||
@@ -194,18 +194,18 @@ it("does not surface an obsolete write failure after a newer save succeeds", asy
|
||||
});
|
||||
|
||||
it("rejects conflicting native locale and explicit preference pairs", async () => {
|
||||
desktop(async () => ({ preference: "es", locale: "zh-CN", revision: 1 }));
|
||||
desktop(async () => ({ preference: "es", locale: "zh-Hans", revision: 1 }));
|
||||
mount();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("alert").textContent).toBe("languageLoadError"),
|
||||
);
|
||||
expect(screen.getByText("zh-CN:system")).toBeTruthy();
|
||||
expect(screen.getByText("zh-Hans:system")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not allow a late write reply to overwrite a newer native event", async () => {
|
||||
const { bridge, listeners } = desktop(async () => ({
|
||||
preference: "system",
|
||||
locale: "zh-CN",
|
||||
locale: "zh-Hans",
|
||||
revision: 0,
|
||||
}));
|
||||
const write = deferred<LanguageSnapshot>();
|
||||
@@ -215,12 +215,12 @@ it("does not allow a late write reply to overwrite a newer native event", async
|
||||
await userEvent.click(screen.getByRole("button"));
|
||||
act(() => {
|
||||
for (const listener of listeners)
|
||||
listener({ preference: "zh-CN", locale: "zh-CN", revision: 8 });
|
||||
listener({ preference: "zh-Hans", locale: "zh-Hans", revision: 8 });
|
||||
});
|
||||
await act(async () =>
|
||||
write.resolve({ preference: "es", locale: "es", revision: 7 }),
|
||||
);
|
||||
expect(screen.getByText("zh-CN:zh-CN")).toBeTruthy();
|
||||
expect(screen.getByText("zh-Hans:zh-Hans")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("groups counts in Chinese only where tn asks for it, never with tnPlain", () => {
|
||||
@@ -259,3 +259,67 @@ it("groups counts in Chinese only where tn asks for it, never with tnPlain", ()
|
||||
"1 no se pudo procesar esta vez; reintentando",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["zh-CN", "zh-Hans"],
|
||||
["es", "es"],
|
||||
["en", "en"],
|
||||
])(
|
||||
"keeps an existing browser user's stored %s choice as %s",
|
||||
(stored, preference) => {
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, stored);
|
||||
mount();
|
||||
expect(screen.getByText(`${preference}:${preference}`)).toBeTruthy();
|
||||
expect(localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe(preference);
|
||||
},
|
||||
);
|
||||
|
||||
it("tells Core the browser choice, null for the system language", async () => {
|
||||
const reportToCore = vi.fn().mockRejectedValueOnce(new Error("offline"));
|
||||
function Choose() {
|
||||
const language = useGuiLanguage();
|
||||
return (
|
||||
<>
|
||||
<output>{language.preference}</output>
|
||||
<button onClick={() => void language.setPreference("ja")} type="button">
|
||||
ja
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void language.setPreference("system")}
|
||||
type="button"
|
||||
>
|
||||
system
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(
|
||||
<LanguageProvider reportToCore={reportToCore}>
|
||||
<Choose />
|
||||
</LanguageProvider>,
|
||||
);
|
||||
// Opening the browser UI tells Core nothing; only a choice does.
|
||||
expect(reportToCore).not.toHaveBeenCalled();
|
||||
await userEvent.click(screen.getByRole("button", { name: "ja" }));
|
||||
// A failed report never undoes the saved display language.
|
||||
expect(screen.getByRole("status").textContent).toBe("ja");
|
||||
await userEvent.click(screen.getByRole("button", { name: "system" }));
|
||||
expect(reportToCore.mock.calls).toEqual([["ja"], [null]]);
|
||||
});
|
||||
|
||||
it("leaves telling Core to the Desktop main process", async () => {
|
||||
desktop(async () => ({
|
||||
preference: "system",
|
||||
locale: "zh-Hans",
|
||||
revision: 1,
|
||||
}));
|
||||
const reportToCore = vi.fn();
|
||||
render(
|
||||
<LanguageProvider reportToCore={reportToCore}>
|
||||
<Probe />
|
||||
</LanguageProvider>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Spanish" }));
|
||||
await waitFor(() => expect(screen.getByText("es:es")).toBeTruthy());
|
||||
expect(reportToCore).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
type GuiLocale,
|
||||
isLanguagePreference,
|
||||
isLanguageSnapshot,
|
||||
type LanguagePreference,
|
||||
type LanguageSnapshot,
|
||||
normalizeLanguagePreference,
|
||||
plainCount,
|
||||
resolveGuiLocale,
|
||||
translate,
|
||||
@@ -20,14 +22,16 @@ import {
|
||||
export const LANGUAGE_STORAGE_KEY = "memhub.ui-language";
|
||||
type LanguageError = "languageLoadError" | "languageSaveError" | null;
|
||||
type State = LanguageSnapshot & { error: LanguageError };
|
||||
/** Tells Core the display language (null = follow the system). */
|
||||
export type ReportUiLanguage = (language: GuiLocale | null) => Promise<unknown>;
|
||||
const initial: State = {
|
||||
preference: "system",
|
||||
locale: "zh-CN",
|
||||
locale: "zh-Hans",
|
||||
revision: 0,
|
||||
error: null,
|
||||
};
|
||||
|
||||
function createLanguageStore() {
|
||||
function createLanguageStore(reportToCore?: ReportUiLanguage) {
|
||||
let state = initial;
|
||||
let revision = -1;
|
||||
let generation = 0;
|
||||
@@ -54,7 +58,10 @@ function createLanguageStore() {
|
||||
const readBrowser = () => {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(LANGUAGE_STORAGE_KEY);
|
||||
const preference = isLanguagePreference(stored) ? stored : "system";
|
||||
const preference = normalizeLanguagePreference(stored) ?? "system";
|
||||
// An earlier build's code (zh-CN) is kept as today's (zh-Hans).
|
||||
if (stored !== null && stored !== preference && preference !== "system")
|
||||
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, preference);
|
||||
publish({
|
||||
preference,
|
||||
locale: resolveGuiLocale(preference, navigator.language),
|
||||
@@ -158,6 +165,11 @@ function createLanguageStore() {
|
||||
revision: state.revision + 1,
|
||||
error: null,
|
||||
});
|
||||
// Desktop tells Core from its main process; the browser UI does it
|
||||
// here. Best effort: the display language is already saved.
|
||||
void reportToCore?.(
|
||||
preference === "system" ? null : preference,
|
||||
).catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
if (generation === current && request === writeRequest)
|
||||
@@ -176,8 +188,14 @@ const LanguageContext = createContext<LanguageContextValue>({
|
||||
setPreference: async () => {},
|
||||
});
|
||||
|
||||
export function LanguageProvider({ children }: { children: ReactNode }) {
|
||||
const [store] = useState(createLanguageStore);
|
||||
export function LanguageProvider({
|
||||
children,
|
||||
reportToCore,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
reportToCore?: ReportUiLanguage;
|
||||
}) {
|
||||
const [store] = useState(() => createLanguageStore(reportToCore));
|
||||
const state = useSyncExternalStore(
|
||||
store.subscribe,
|
||||
store.getSnapshot,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import { App } from "./App";
|
||||
import { setUiLanguage } from "./api";
|
||||
import { LanguageProvider } from "./gui-language";
|
||||
import { installNativeDisplayStyles } from "./native-display-styles";
|
||||
import { installRendererDebugLogCapture } from "./renderer-debug-log";
|
||||
@@ -21,7 +22,7 @@ import.meta.hot?.dispose(stopRendererDebugLogs);
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<LanguageProvider>
|
||||
<LanguageProvider reportToCore={setUiLanguage}>
|
||||
<App />
|
||||
</LanguageProvider>
|
||||
</StrictMode>,
|
||||
|
||||
@@ -113,7 +113,7 @@ function onlyRetries(blocks: readonly MemoryProcessingBlock[]): boolean {
|
||||
|
||||
export function memoryArrivalTitle(
|
||||
arrival: MemoryArrival,
|
||||
locale: GuiLocale = "zh-CN",
|
||||
locale: GuiLocale = "zh-Hans",
|
||||
): string {
|
||||
switch (arrival.phase) {
|
||||
case "checking":
|
||||
|
||||
@@ -70,6 +70,10 @@
|
||||
color: inherit;
|
||||
background: #f7fafc;
|
||||
font: inherit;
|
||||
/* One line in the fixed-height button, even for long names (fr, de, ru). */
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.onboarding-r6-titlebar .r6-language-option-menu .om-option-menu-button::after {
|
||||
top: 10px;
|
||||
|
||||
@@ -61,6 +61,9 @@
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.r6-settings .r6-language-option-menu .om-option-menu-button::after {
|
||||
top: 16px;
|
||||
|
||||
@@ -24,7 +24,7 @@ describe("presentation adapters", () => {
|
||||
it.each(unknown)("memoryText returns unknown text %j untouched", (value) => {
|
||||
expect(memoryText("es", value)).toBe(value);
|
||||
expect(memoryText("en", value)).toBe(value);
|
||||
expect(memoryText("zh-CN", value)).toBe(value);
|
||||
expect(memoryText("zh-Hans", value)).toBe(value);
|
||||
});
|
||||
|
||||
it.each(unknown)(
|
||||
@@ -34,7 +34,7 @@ describe("presentation adapters", () => {
|
||||
const key = value as Parameters<typeof applicationsText>[1];
|
||||
expect(applicationsText("es", key)).toBe(value);
|
||||
expect(applicationsText("en", key)).toBe(value);
|
||||
expect(applicationsText("zh-CN", key)).toBe(value);
|
||||
expect(applicationsText("zh-Hans", key)).toBe(value);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -45,7 +45,7 @@ describe("presentation adapters", () => {
|
||||
expect(applicationsText("es", "viewAll", { count: 1 })).toBe(
|
||||
"Ver la aplicación",
|
||||
);
|
||||
expect(applicationsText("zh-CN", "viewAll", { count: 1 })).toBe(
|
||||
expect(applicationsText("zh-Hans", "viewAll", { count: 1 })).toBe(
|
||||
"查看全部 1 个应用",
|
||||
);
|
||||
expect(applicationsText("en", "viewAll", { count: 3 })).not.toMatch(
|
||||
@@ -93,7 +93,7 @@ describe("presentation adapters", () => {
|
||||
"没有匹配的原始对话",
|
||||
"还没有导入的原始对话",
|
||||
])("memoryText translates %j and keeps the Chinese as is", (label) => {
|
||||
expect(memoryText("zh-CN", label)).toBe(label);
|
||||
expect(memoryText("zh-Hans", label)).toBe(label);
|
||||
const spanish = memoryText("es", label);
|
||||
const english = memoryText("en", label);
|
||||
expect(spanish).not.toBe(label);
|
||||
@@ -103,11 +103,11 @@ describe("presentation adapters", () => {
|
||||
});
|
||||
|
||||
it("words an action as a verb inside sentences and as a noun in history", () => {
|
||||
const action = (locale: "zh-CN" | "en" | "es") =>
|
||||
const action = (locale: "zh-Hans" | "en" | "es") =>
|
||||
memoryText(locale, "删除(操作)");
|
||||
expect(action("zh-CN")).toBe("删除");
|
||||
expect(action("zh-Hans")).toBe("删除");
|
||||
expect(
|
||||
memoryText("zh-CN", "重试{action}", { action: action("zh-CN") }),
|
||||
memoryText("zh-Hans", "重试{action}", { action: action("zh-Hans") }),
|
||||
).toBe("重试删除");
|
||||
expect(memoryText("es", "重试{action}", { action: action("es") })).toBe(
|
||||
"Volver a eliminar",
|
||||
@@ -118,7 +118,7 @@ describe("presentation adapters", () => {
|
||||
error: "E",
|
||||
}),
|
||||
).toBe("No se pudo eliminar: E");
|
||||
expect(memoryText("zh-CN", "删除(记录)")).toBe("删除");
|
||||
expect(memoryText("zh-Hans", "删除(记录)")).toBe("删除");
|
||||
expect(memoryText("es", "删除(记录)")).toBe("Eliminación");
|
||||
expect(memoryText("es", "删除")).toBe("Eliminar");
|
||||
expect(memoryText("en", "删除(记录)")).toBe("Deletion");
|
||||
@@ -143,7 +143,7 @@ describe("source authorization notes", () => {
|
||||
for (const message of [note as string, `${note}完成后再开启持续同步。`]) {
|
||||
expect(isMessageKey(message), message).toBe(true);
|
||||
if (!isMessageKey(message)) continue;
|
||||
expect(translate("zh-CN", message)).toBe(message);
|
||||
expect(translate("zh-Hans", message)).toBe(message);
|
||||
expect(translate("es", message)).not.toMatch(/[一-鿿]/);
|
||||
expect(translate("en", message)).not.toMatch(/[一-鿿]/);
|
||||
}
|
||||
|
||||
@@ -282,7 +282,7 @@ Support is declared per immutable GitHub Release manifest:
|
||||
- Linux x64/arm64 Runtime on native GHA runners.
|
||||
- macOS arm64/x64 App/Runtime: Developer ID, notarization, native acceptance.
|
||||
- Windows is not supported.
|
||||
- `hy-memory==2.0.10` is the current stable SDK dependency; the release
|
||||
- `hy-memory==2.0.11` is the current stable SDK dependency; the release
|
||||
lock and [`../MEMORY.md`](../MEMORY.md) are authoritative when the pin changes.
|
||||
|
||||
See [`INSTALLATION.md`](INSTALLATION.md).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MemHub 安装与发行
|
||||
|
||||
MemHub `v1.0.5`(build 36)的发行目标如下:
|
||||
MemHub `v1.0.6`(build 37)的发行目标如下:
|
||||
|
||||
| 平台 | 发行物 | 构建与验收 |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -184,6 +184,20 @@ node apps/cli/dist/one-memory.cjs settings features reset
|
||||
- `status --json` / `progress --json` 的 `pipeline.features` 列出与默认值不同的功能。
|
||||
- 契约见 `docs/contracts/memory-features-v1.md`。
|
||||
|
||||
### 界面语言与 SDK 提示语言(`settings language`)
|
||||
|
||||
```bash
|
||||
node apps/cli/dist/one-memory.cjs settings language # 界面语言和 SDK 提示语言
|
||||
node apps/cli/dist/one-memory.cjs settings language ja # 设为日语,立即生效
|
||||
node apps/cli/dist/one-memory.cjs settings language system # 跟随系统(不指定 SDK 语言)
|
||||
```
|
||||
|
||||
- 可选 11 种:`en`、`zh-Hans`、`zh-Hant`、`ja`、`es`、`de`、`pt`、`fr`、`ko`、`ru`、`id`,以及 `system`;旧代码 `zh-CN` 按 `zh-Hans` 处理。
|
||||
- App 切换语言时会自动设置(Desktop 启动时也会同步一次已保存的选择)。全局设置,存于 `config.json` 的 `ui_language`;从未选择时不写入。
|
||||
- `zh-Hans` 和 `system` 不向 SDK 传语言,SDK 照旧自动识别中文或英文;其余语言(包括 `en`)作为 `MemoryConfig.language` 传给 SDK,提取等提示使用该语言。
|
||||
- 修改会像功能开关一样重建 SDK 客户端;不迁移数据,不改集合名(需要 hy-memory 2.0.11 的 `MemoryConfig.language`)。
|
||||
- `status --json` 的 `pipeline.ui_language` / `pipeline.sdk_language`(`auto` 或语言代码)。API:`GET`/`PUT /api/v1/settings/ui-language`。
|
||||
|
||||
## 8. 召回(recall)
|
||||
|
||||
`recall` 两条路径(raw / memory)此前已真机验证。文本输出会标明每条是「原始对话摘录」
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "one-memory",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.6",
|
||||
"release": {
|
||||
"build_number": 36,
|
||||
"build_number": 37,
|
||||
"macos_min_version": "13.0",
|
||||
"app_update": "manual",
|
||||
"runtime_manifest_schema": 1,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@one-memory/contracts",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"types": "./src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@one-memory/host-bridge",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@one-memory/i18n",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"types": "./src/index.ts",
|
||||
|
||||
@@ -151,7 +151,7 @@ describe("translateCount", () => {
|
||||
it("keeps the Chinese wording for every count", () => {
|
||||
for (const count of [0, 1, 3])
|
||||
expect(
|
||||
translateCount("zh-CN", "calls.headline.found", count, {
|
||||
translateCount("zh-Hans", "calls.headline.found", count, {
|
||||
host: "Cursor",
|
||||
count,
|
||||
}),
|
||||
|
||||
@@ -545,6 +545,7 @@ const baseEnglishMessages = {
|
||||
nativeDevelopmentUnavailable: " (unavailable in development)",
|
||||
nativeOpen: "Open {product}",
|
||||
nativeQuit: "Quit {product} (background service keeps running)",
|
||||
nativeQuitStopsService: "Quit {product} (also stops the memory service)",
|
||||
nativeLoginError: "Could not change the login startup setting. Please retry.",
|
||||
nativeMoveTitle: "Move MemHub to Applications first",
|
||||
nativeMoveDetail:
|
||||
@@ -1947,6 +1948,30 @@ const baseEnglishMessages = {
|
||||
startupPreparing: "Starting and preparing the memory service…",
|
||||
startupUpgradeHint:
|
||||
"The first launch or a memory-data upgrade can take a few minutes. MemHub opens automatically when it is done.",
|
||||
"migration.dialogMessage":
|
||||
"The data migration for this MemHub upgrade did not finish.",
|
||||
"migration.dialogDetail":
|
||||
"To protect your data, MemHub will not start. Please attach the migration log to a GitHub issue and send it to us. (Error code: {code})",
|
||||
"migration.dataRestored":
|
||||
"Your data was restored to its state before the upgrade.",
|
||||
"migration.logPath": "Log: {path}",
|
||||
"migration.openLog": "Open log",
|
||||
"migration.quit": "Quit",
|
||||
"migration.issueHeading": "## Upgrade migration failed",
|
||||
"migration.issueUpgrade":
|
||||
"- Upgrading MemHub from {fromVersion} (build {fromBuild}) to {toVersion} (build {toBuild}) failed to migrate the data.",
|
||||
"migration.issueCode": "- Error code: {code}",
|
||||
"migration.issueStep": "- Failed step: {step}",
|
||||
"migration.issueRestored":
|
||||
"- Data restored to its state before the upgrade: {restored}",
|
||||
"migration.issueSystem": "- System: {system}",
|
||||
"migration.issueArch": " ({arch})",
|
||||
"migration.issueLog": "- Migration log (local): {path}",
|
||||
"migration.issueAttach":
|
||||
"Please drag the log file above here as an attachment, then submit.",
|
||||
"migration.unknown": "unknown",
|
||||
"migration.yes": "yes",
|
||||
"migration.no": "no",
|
||||
} as const;
|
||||
|
||||
export const englishMessages = {
|
||||
|
||||
@@ -24,7 +24,7 @@ it("covers every health and guide key without accidental Chinese fallback", () =
|
||||
expect(translated, key).toBeTruthy();
|
||||
expect(params(translated), key).toEqual(params(value));
|
||||
expect(translate("es", key as MessageKey), key).toBe(translated);
|
||||
expect(translate("zh-CN", key as MessageKey), key).toBe(value);
|
||||
expect(translate("zh-Hans", key as MessageKey), key).toBe(value);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
guiMessages,
|
||||
type MessageKey,
|
||||
SUPPORTED_LOCALES,
|
||||
translate,
|
||||
} from "./gui-i18n.js";
|
||||
import { chineseMessages } from "./gui-i18n-messages.js";
|
||||
import { japaneseMessages } from "./locales/gui-i18n-ja.js";
|
||||
|
||||
const placeholders = (text: string) =>
|
||||
[...text.matchAll(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g)].map((m) => m[1]).sort();
|
||||
|
||||
/** Copy that is deliberately empty: Spanish shows no counter word. */
|
||||
const INTENTIONALLY_EMPTY: Partial<Record<string, readonly string[]>> = {
|
||||
es: ["apps.unit"],
|
||||
};
|
||||
|
||||
const keys = Object.keys(chineseMessages) as MessageKey[];
|
||||
|
||||
describe.each(SUPPORTED_LOCALES)("%s catalog", (locale) => {
|
||||
const catalog = guiMessages[locale];
|
||||
|
||||
it("has every key and no other", () => {
|
||||
expect(Object.keys(catalog).sort()).toEqual([...keys].sort());
|
||||
});
|
||||
|
||||
it("keeps every placeholder exactly", () => {
|
||||
for (const key of keys) {
|
||||
const translated = placeholders(catalog[key] ?? "");
|
||||
const expected = placeholders(chineseMessages[key]);
|
||||
if (key.endsWith(".one")) {
|
||||
// A singular may drop {count} ("Voir l’application"), never invent one.
|
||||
for (const name of translated) expect(expected, key).toContain(name);
|
||||
} else {
|
||||
expect(translated, `${locale} ${key}`).toEqual(expected);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("has no empty string", () => {
|
||||
const allowed = INTENTIONALLY_EMPTY[locale] ?? [];
|
||||
for (const key of keys) {
|
||||
if (allowed.includes(key)) continue;
|
||||
expect(catalog[key]?.trim(), `${locale} ${key}`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps product names untranslated", () => {
|
||||
for (const key of keys) {
|
||||
const english = guiMessages.en[key] ?? "";
|
||||
for (const name of ["MemHub", "MCP", "GitHub"]) {
|
||||
if (english.includes(name) && chineseMessages[key].includes(name))
|
||||
expect(catalog[key], `${locale} ${key}`).toContain(name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("translates through the shared translator", () => {
|
||||
expect(translate(locale, "settings")).toBe(catalog.settings);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fallback", () => {
|
||||
it("falls back to English when a locale lacks a key", () => {
|
||||
const key = "languageTitle";
|
||||
const saved = japaneseMessages[key];
|
||||
try {
|
||||
delete (japaneseMessages as Partial<typeof japaneseMessages>)[key];
|
||||
expect(translate("ja", key)).toBe(guiMessages.en[key]);
|
||||
} finally {
|
||||
japaneseMessages[key] = saved;
|
||||
}
|
||||
expect(translate("ja", key)).toBe(saved);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,10 @@ import {
|
||||
memoryChineseMessages,
|
||||
memorySpanishMessages,
|
||||
} from "./gui-i18n-memory.js";
|
||||
import {
|
||||
migrationChineseMessages,
|
||||
migrationSpanishMessages,
|
||||
} from "./gui-i18n-migration.js";
|
||||
import {
|
||||
onboardingChineseMessages,
|
||||
onboardingSpanishMessages,
|
||||
@@ -140,6 +144,11 @@ export const guiCatalogs: readonly Catalog[] = [
|
||||
chinese: languageChineseMessages,
|
||||
spanish: languageSpanishMessages,
|
||||
},
|
||||
{
|
||||
name: "migration",
|
||||
chinese: migrationChineseMessages,
|
||||
spanish: migrationSpanishMessages,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -180,6 +189,7 @@ export const chineseMessages = merged.chinese as typeof guideChineseMessages &
|
||||
typeof memoryChineseMessages &
|
||||
typeof callsChineseMessages &
|
||||
typeof languageChineseMessages &
|
||||
typeof migrationChineseMessages &
|
||||
typeof workbenchChineseMessages &
|
||||
typeof currentChineseMessages;
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* The migration failure dialog and its GitHub issue prefill (Desktop).
|
||||
* Product names, codes, versions and paths are placeholders, never copy.
|
||||
*/
|
||||
export const migrationChineseMessages = {
|
||||
"migration.dialogMessage": "MemHub 升级时数据迁移没有完成。",
|
||||
"migration.dialogDetail":
|
||||
"为保护你的数据,MemHub 不会启动。请把迁移日志附在 GitHub 反馈里发给我们。(错误码:{code})",
|
||||
"migration.dataRestored": "记忆数据已恢复到升级前的状态。",
|
||||
"migration.logPath": "日志:{path}",
|
||||
"migration.openLog": "打开日志",
|
||||
"migration.quit": "退出",
|
||||
"migration.issueHeading": "## 升级迁移失败",
|
||||
"migration.issueUpgrade":
|
||||
"- 从 MemHub {fromVersion}(build {fromBuild})升级到 {toVersion}(build {toBuild}),迁移数据失败。",
|
||||
"migration.issueCode": "- 错误码:{code}",
|
||||
"migration.issueStep": "- 失败步骤:{step}",
|
||||
"migration.issueRestored": "- 数据已恢复到升级前状态:{restored}",
|
||||
"migration.issueSystem": "- 系统:{system}",
|
||||
"migration.issueArch": "({arch})",
|
||||
"migration.issueLog": "- 迁移日志(本地):{path}",
|
||||
"migration.issueAttach": "请把上面这个日志文件拖到这里作为附件,再提交。",
|
||||
"migration.unknown": "未知",
|
||||
"migration.yes": "是",
|
||||
"migration.no": "否",
|
||||
} as const;
|
||||
|
||||
export const migrationSpanishMessages: Record<
|
||||
keyof typeof migrationChineseMessages,
|
||||
string
|
||||
> = {
|
||||
"migration.dialogMessage":
|
||||
"La migración de datos de esta actualización de MemHub no se completó.",
|
||||
"migration.dialogDetail":
|
||||
"Para proteger tus datos, MemHub no se iniciará. Adjunta el registro de migración a una incidencia de GitHub y envíanoslo. (Código de error: {code})",
|
||||
"migration.dataRestored":
|
||||
"Tus datos se restauraron al estado anterior a la actualización.",
|
||||
"migration.logPath": "Registro: {path}",
|
||||
"migration.openLog": "Abrir registro",
|
||||
"migration.quit": "Salir",
|
||||
"migration.issueHeading": "## Falló la migración de la actualización",
|
||||
"migration.issueUpgrade":
|
||||
"- Al actualizar MemHub de {fromVersion} (build {fromBuild}) a {toVersion} (build {toBuild}), falló la migración de datos.",
|
||||
"migration.issueCode": "- Código de error: {code}",
|
||||
"migration.issueStep": "- Paso fallido: {step}",
|
||||
"migration.issueRestored":
|
||||
"- Datos restaurados al estado anterior a la actualización: {restored}",
|
||||
"migration.issueSystem": "- Sistema: {system}",
|
||||
"migration.issueArch": " ({arch})",
|
||||
"migration.issueLog": "- Registro de migración (local): {path}",
|
||||
"migration.issueAttach":
|
||||
"Arrastra aquí el archivo de registro indicado arriba como adjunto y luego envía la incidencia.",
|
||||
"migration.unknown": "desconocido",
|
||||
"migration.yes": "sí",
|
||||
"migration.no": "no",
|
||||
};
|
||||
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user