Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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([
|
||||
{
|
||||
|
||||
@@ -28,8 +28,8 @@ function fixture(
|
||||
};
|
||||
let changed: (() => void) | undefined;
|
||||
const languageSnapshot: LanguageSnapshot = {
|
||||
preference: "zh-CN",
|
||||
locale: "zh-CN",
|
||||
preference: "zh-Hans",
|
||||
locale: "zh-Hans",
|
||||
revision: 1,
|
||||
};
|
||||
const app = { isPackaged: packaged, quit: vi.fn() };
|
||||
@@ -121,7 +121,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);
|
||||
|
||||
@@ -44,7 +44,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;
|
||||
@@ -82,7 +82,7 @@ export function installBackgroundTray(options: TrayOptions): () => void {
|
||||
.catch(() =>
|
||||
options.reportError(
|
||||
translate(
|
||||
options.language?.read().locale ?? "zh-CN",
|
||||
options.language?.read().locale ?? "zh-Hans",
|
||||
"nativeLoginError",
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -59,6 +59,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,
|
||||
@@ -151,6 +152,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 +167,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),
|
||||
});
|
||||
@@ -296,9 +298,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.
|
||||
}
|
||||
@@ -1404,16 +1406,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 +1520,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",
|
||||
@@ -1657,6 +1696,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();
|
||||
@@ -1708,6 +1749,8 @@ if (desktopVersion !== null) {
|
||||
});
|
||||
},
|
||||
dispose: () => {
|
||||
disposeCoreLanguageSync?.();
|
||||
disposeCoreLanguageSync = null;
|
||||
disposeLanguagePreferences?.();
|
||||
disposeLanguagePreferences = null;
|
||||
disposeApplicationMenu?.();
|
||||
@@ -1738,7 +1781,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 +1809,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)),
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -1947,6 +1947,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",
|
||||
};
|
||||
@@ -22,7 +22,7 @@ it("provides Spanish copy for every onboarding message with identical interpolat
|
||||
.map((match) => match[1])
|
||||
.sort(),
|
||||
);
|
||||
expect(translate("zh-CN", key)).toBe(chinese);
|
||||
expect(translate("zh-Hans", key)).toBe(chinese);
|
||||
expect(translate("es", key)).toBe(spanish);
|
||||
}
|
||||
});
|
||||
@@ -31,7 +31,7 @@ it("keeps product names, paths and error codes intact during interpolation", ()
|
||||
"Importar el historial de Cursor",
|
||||
);
|
||||
expect(
|
||||
translate("zh-CN", "onboardingImportHistory", { name: "Cursor" }),
|
||||
translate("zh-Hans", "onboardingImportHistory", { name: "Cursor" }),
|
||||
).toBe("导入 Cursor 历史");
|
||||
expect(
|
||||
translate("es", "onboardingModelOperationFailed", {
|
||||
|
||||
@@ -18,6 +18,6 @@ it("covers every recovery key in Spanish with identical interpolation parameters
|
||||
params(recoveryChineseMessages[key]),
|
||||
);
|
||||
expect(translate("es", key), key).toBe(recoverySpanishMessages[key]);
|
||||
expect(translate("zh-CN", key), key).toBe(recoveryChineseMessages[key]);
|
||||
expect(translate("zh-Hans", key), key).toBe(recoveryChineseMessages[key]);
|
||||
}
|
||||
});
|
||||
@@ -6,8 +6,11 @@ import {
|
||||
isLanguageSnapshot,
|
||||
type LanguagePreference,
|
||||
type LanguageSnapshot,
|
||||
LOCALE_NAMES,
|
||||
type MessageKey,
|
||||
normalizeLanguagePreference,
|
||||
resolveGuiLocale,
|
||||
SUPPORTED_LOCALES,
|
||||
translate,
|
||||
} from "./gui-i18n.js";
|
||||
|
||||
@@ -27,10 +30,20 @@ const messageKeys = [
|
||||
|
||||
describe("GUI language contracts", () => {
|
||||
it("pins the language preference and snapshot types", () => {
|
||||
expectTypeOf<LanguagePreference>().toEqualTypeOf<
|
||||
"system" | "zh-CN" | "en" | "es"
|
||||
expectTypeOf<GuiLocale>().toEqualTypeOf<
|
||||
| "en"
|
||||
| "zh-Hans"
|
||||
| "zh-Hant"
|
||||
| "ja"
|
||||
| "es"
|
||||
| "de"
|
||||
| "pt"
|
||||
| "fr"
|
||||
| "ko"
|
||||
| "ru"
|
||||
| "id"
|
||||
>();
|
||||
expectTypeOf<GuiLocale>().toEqualTypeOf<"zh-CN" | "en" | "es">();
|
||||
expectTypeOf<LanguagePreference>().toEqualTypeOf<"system" | GuiLocale>();
|
||||
expectTypeOf<LanguageSnapshot>().toEqualTypeOf<{
|
||||
preference: LanguagePreference;
|
||||
locale: GuiLocale;
|
||||
@@ -38,11 +51,41 @@ describe("GUI language contracts", () => {
|
||||
}>();
|
||||
});
|
||||
|
||||
it.each(["system", "zh-CN", "en", "es"])("accepts preference %s", (value) => {
|
||||
expect(isLanguagePreference(value)).toBe(true);
|
||||
it.each(["system", ...SUPPORTED_LOCALES])(
|
||||
"accepts preference %s",
|
||||
(value) => {
|
||||
expect(isLanguagePreference(value)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("lists eleven languages, each by its own name", () => {
|
||||
expect(SUPPORTED_LOCALES).toHaveLength(11);
|
||||
expect(SUPPORTED_LOCALES.map((locale) => LOCALE_NAMES[locale])).toEqual([
|
||||
"English",
|
||||
"简体中文",
|
||||
"繁體中文",
|
||||
"日本語",
|
||||
"Español",
|
||||
"Deutsch",
|
||||
"Português",
|
||||
"Français",
|
||||
"한국어",
|
||||
"Русский",
|
||||
"Bahasa Indonesia",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([undefined, null, "", "EN", "ES", "es-MX", {}, [], 1, true])(
|
||||
it("maps the legacy zh-CN preference so existing users keep their choice", () => {
|
||||
expect(isLanguagePreference("zh-CN")).toBe(false);
|
||||
expect(normalizeLanguagePreference("zh-CN")).toBe("zh-Hans");
|
||||
expect(normalizeLanguagePreference("es")).toBe("es");
|
||||
expect(normalizeLanguagePreference("en")).toBe("en");
|
||||
expect(normalizeLanguagePreference("system")).toBe("system");
|
||||
for (const value of [undefined, null, "", "zh", "EN", {}, 1])
|
||||
expect(normalizeLanguagePreference(value)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([undefined, null, "", "EN", "ES", "es-MX", "zh-CN", {}, [], 1, true])(
|
||||
"rejects invalid preferences: %j",
|
||||
(value) => expect(isLanguagePreference(value)).toBe(false),
|
||||
);
|
||||
@@ -60,8 +103,8 @@ describe("GUI language contracts", () => {
|
||||
).toBe(true);
|
||||
expect(
|
||||
isLanguageSnapshot({
|
||||
preference: "zh-CN",
|
||||
locale: "zh-CN",
|
||||
preference: "zh-Hans",
|
||||
locale: "zh-Hans",
|
||||
revision: Number.MAX_SAFE_INTEGER,
|
||||
}),
|
||||
).toBe(true);
|
||||
@@ -71,8 +114,9 @@ describe("GUI language contracts", () => {
|
||||
[],
|
||||
{},
|
||||
{ preference: "en", locale: "es", revision: 0 },
|
||||
{ preference: "es", locale: "zh-CN", revision: 0 },
|
||||
{ preference: "zh-CN", locale: "es", revision: 0 },
|
||||
{ preference: "es", locale: "zh-Hans", revision: 0 },
|
||||
{ preference: "zh-Hans", locale: "es", revision: 0 },
|
||||
{ preference: "zh-CN", locale: "zh-CN", revision: 0 },
|
||||
{ preference: "system", locale: "system", revision: 0 },
|
||||
{ preference: "system", locale: "es" },
|
||||
...[-1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, "0"].map(
|
||||
@@ -102,16 +146,36 @@ describe("GUI locale resolution", () => {
|
||||
(language) => expect(resolveGuiLocale("system", language)).toBe("en"),
|
||||
);
|
||||
|
||||
it.each(["zh", "zh-CN", "zh-SG", "zh-Hans"])(
|
||||
it.each(["zh", "zh-CN", "zh-SG", "zh-Hans", "zh-Hans-HK"])(
|
||||
"resolves Simplified Chinese family %s",
|
||||
(language) => expect(resolveGuiLocale("system", language)).toBe("zh-CN"),
|
||||
(language) => expect(resolveGuiLocale("system", language)).toBe("zh-Hans"),
|
||||
);
|
||||
|
||||
it.each(["zh-TW", "zh-HK", "zh-MO", "zh-Hant", "zh-Hant-TW"])(
|
||||
"resolves Traditional Chinese family %s",
|
||||
(language) => expect(resolveGuiLocale("system", language)).toBe("zh-Hant"),
|
||||
);
|
||||
|
||||
it.each([
|
||||
["ja-JP", "ja"],
|
||||
["de-AT", "de"],
|
||||
["pt-BR", "pt"],
|
||||
["pt-PT", "pt"],
|
||||
["fr-ES", "fr"],
|
||||
["fr-CA", "fr"],
|
||||
["ko-KR", "ko"],
|
||||
["ru-RU", "ru"],
|
||||
["id-ID", "id"],
|
||||
["in-ID", "id"],
|
||||
])("resolves %s to %s", (language, locale) =>
|
||||
expect(resolveGuiLocale("system", language)).toBe(locale),
|
||||
);
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
"",
|
||||
"zh-TW",
|
||||
"fr-ES",
|
||||
"it-IT",
|
||||
"nl",
|
||||
"en-US-u-rg-eszzzz",
|
||||
"es_MX",
|
||||
"es--ES",
|
||||
@@ -125,7 +189,8 @@ describe("GUI locale resolution", () => {
|
||||
|
||||
it("prioritizes explicit preferences over the system language", () => {
|
||||
expect(resolveGuiLocale("es", "zh-CN")).toBe("es");
|
||||
expect(resolveGuiLocale("zh-CN", "es-MX")).toBe("zh-CN");
|
||||
expect(resolveGuiLocale("zh-Hans", "es-MX")).toBe("zh-Hans");
|
||||
expect(resolveGuiLocale("ja", "zh-TW")).toBe("ja");
|
||||
expect(resolveGuiLocale("en", "es-MX")).toBe("en");
|
||||
expect(resolveGuiLocale("es", "invalid_locale")).toBe("es");
|
||||
});
|
||||
@@ -135,19 +200,19 @@ describe("GUI messages", () => {
|
||||
it.each(messageKeys)(
|
||||
"provides Chinese, English and Spanish text for %s",
|
||||
(key) => {
|
||||
expect(translate("zh-CN", key).length).toBeGreaterThan(0);
|
||||
expect(translate("zh-Hans", key).length).toBeGreaterThan(0);
|
||||
expect(translate("en", key).length).toBeGreaterThan(0);
|
||||
expect(translate("es", key).length).toBeGreaterThan(0);
|
||||
expect(translate("en", key)).not.toBe(translate("zh-CN", key));
|
||||
expect(translate("es", key)).not.toBe(translate("zh-CN", key));
|
||||
expect(translate("en", key)).not.toBe(translate("zh-Hans", key));
|
||||
expect(translate("es", key)).not.toBe(translate("zh-Hans", key));
|
||||
},
|
||||
);
|
||||
|
||||
it("provides stable settings and language names", () => {
|
||||
expect(translate("zh-CN", "settings")).toBe("设置");
|
||||
expect(translate("zh-Hans", "settings")).toBe("设置");
|
||||
expect(translate("en", "settings")).toBe("Settings");
|
||||
expect(translate("es", "settings")).toBe("Configuración");
|
||||
expect(translate("zh-CN", "nativeErrorTitle")).toBe("MemHub 无法启动");
|
||||
expect(translate("zh-Hans", "nativeErrorTitle")).toBe("MemHub 无法启动");
|
||||
expect(translate("es", "nativeErrorTitle")).toBe(
|
||||
"MemHub no pudo iniciarse",
|
||||
);
|
||||
|
||||
@@ -2,10 +2,51 @@ import i18next from "i18next";
|
||||
|
||||
import { englishMessages } from "./gui-i18n-english.js";
|
||||
import { chineseMessages, spanishMessages } from "./gui-i18n-messages.js";
|
||||
import { germanMessages } from "./locales/gui-i18n-de.js";
|
||||
import { frenchMessages } from "./locales/gui-i18n-fr.js";
|
||||
import { indonesianMessages } from "./locales/gui-i18n-id.js";
|
||||
import { japaneseMessages } from "./locales/gui-i18n-ja.js";
|
||||
import { koreanMessages } from "./locales/gui-i18n-ko.js";
|
||||
import { portugueseMessages } from "./locales/gui-i18n-pt.js";
|
||||
import { russianMessages } from "./locales/gui-i18n-ru.js";
|
||||
import { traditionalChineseMessages } from "./locales/gui-i18n-zh-hant.js";
|
||||
|
||||
export const SUPPORTED_LOCALES = ["zh-CN", "en", "es"] as const;
|
||||
/** Display languages, in the order the language selector lists them. */
|
||||
export const SUPPORTED_LOCALES = [
|
||||
"en",
|
||||
"zh-Hans",
|
||||
"zh-Hant",
|
||||
"ja",
|
||||
"es",
|
||||
"de",
|
||||
"pt",
|
||||
"fr",
|
||||
"ko",
|
||||
"ru",
|
||||
"id",
|
||||
] as const;
|
||||
|
||||
export type GuiLocale = (typeof SUPPORTED_LOCALES)[number];
|
||||
|
||||
/** Each language by its own name, whatever the current display language. */
|
||||
export const LOCALE_NAMES: Record<GuiLocale, string> = {
|
||||
en: "English",
|
||||
"zh-Hans": "简体中文",
|
||||
"zh-Hant": "繁體中文",
|
||||
ja: "日本語",
|
||||
es: "Español",
|
||||
de: "Deutsch",
|
||||
pt: "Português",
|
||||
fr: "Français",
|
||||
ko: "한국어",
|
||||
ru: "Русский",
|
||||
id: "Bahasa Indonesia",
|
||||
};
|
||||
|
||||
/** Codes earlier builds stored, and the code each one is now. */
|
||||
export const LEGACY_LOCALES: Readonly<Record<string, GuiLocale>> = {
|
||||
"zh-CN": "zh-Hans",
|
||||
};
|
||||
export type LanguagePreference = "system" | GuiLocale;
|
||||
export type MessageKey = keyof typeof chineseMessages;
|
||||
|
||||
@@ -16,16 +57,34 @@ export interface LanguageSnapshot {
|
||||
}
|
||||
|
||||
const resources = {
|
||||
"zh-CN": { translation: chineseMessages },
|
||||
en: { translation: englishMessages },
|
||||
"zh-Hans": { translation: chineseMessages },
|
||||
"zh-Hant": { translation: traditionalChineseMessages },
|
||||
ja: { translation: japaneseMessages },
|
||||
es: { translation: spanishMessages },
|
||||
de: { translation: germanMessages },
|
||||
pt: { translation: portugueseMessages },
|
||||
fr: { translation: frenchMessages },
|
||||
ko: { translation: koreanMessages },
|
||||
ru: { translation: russianMessages },
|
||||
id: { translation: indonesianMessages },
|
||||
} as const;
|
||||
|
||||
/** Every catalog by locale; English and the new locales are complete. */
|
||||
export const guiMessages: Readonly<
|
||||
Record<GuiLocale, Partial<Record<MessageKey, string>>>
|
||||
> = Object.fromEntries(
|
||||
Object.entries(resources).map(([locale, value]) => [
|
||||
locale,
|
||||
value.translation,
|
||||
]),
|
||||
) as Record<GuiLocale, Partial<Record<MessageKey, string>>>;
|
||||
|
||||
const translator = i18next.createInstance();
|
||||
void translator.init({
|
||||
resources,
|
||||
lng: "en",
|
||||
fallbackLng: ["en", "zh-CN"],
|
||||
fallbackLng: ["en", "zh-Hans"],
|
||||
supportedLngs: [...SUPPORTED_LOCALES],
|
||||
initImmediate: false,
|
||||
keySeparator: false,
|
||||
@@ -53,6 +112,18 @@ export function isLanguagePreference(
|
||||
return value === "system" || isGuiLocale(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* A stored preference in today's codes: legacy codes (zh-CN) are mapped, so
|
||||
* a person who chose a language before keeps it; anything else is null.
|
||||
*/
|
||||
export function normalizeLanguagePreference(
|
||||
value: unknown,
|
||||
): LanguagePreference | null {
|
||||
if (typeof value === "string" && Object.hasOwn(LEGACY_LOCALES, value))
|
||||
return LEGACY_LOCALES[value] ?? null;
|
||||
return isLanguagePreference(value) ? value : null;
|
||||
}
|
||||
|
||||
export function isLanguageSnapshot(value: unknown): value is LanguageSnapshot {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value))
|
||||
return false;
|
||||
@@ -79,14 +150,13 @@ export function resolveGuiLocale(
|
||||
const language = canonicalLanguage?.split("-")[0]?.toLowerCase();
|
||||
if (language === "zh") {
|
||||
const parts = canonicalLanguage?.split("-") ?? [];
|
||||
const unsupportedTraditional =
|
||||
if (parts.includes("Hans")) return "zh-Hans";
|
||||
const traditional =
|
||||
parts.includes("Hant") ||
|
||||
parts.some((part) => ["TW", "HK", "MO"].includes(part));
|
||||
return unsupportedTraditional ? "en" : "zh-CN";
|
||||
return traditional ? "zh-Hant" : "zh-Hans";
|
||||
}
|
||||
if (language === "es") return "es";
|
||||
if (language === "en") return "en";
|
||||
return "en";
|
||||
return isGuiLocale(language) ? language : "en";
|
||||
} catch {
|
||||
return "en";
|
||||
}
|
||||
@@ -123,7 +193,7 @@ export function translateCount(
|
||||
}
|
||||
|
||||
export function plainCount(locale: GuiLocale, value: number): string | number {
|
||||
return locale === "zh-CN"
|
||||
return locale === "zh-Hans"
|
||||
? value
|
||||
: new Intl.NumberFormat(locale).format(value);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user