Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0a4fd60f2 | ||
|
|
aae877b880 | ||
|
|
670c7c6cb1 | ||
|
|
aa633623da | ||
|
|
37d8662b93 | ||
|
|
9459a1107b | ||
|
|
2c6f269170 | ||
|
|
7a2ff2f4c4 | ||
|
|
35128f2c84 | ||
|
|
f12a95ce7a | ||
|
|
e786657a0b |
No files matched your search
@@ -4,8 +4,46 @@ Hy Memory and MemHub are versioned and released independently.
|
||||
|
||||
## Unreleased
|
||||
|
||||
## MemHub 1.0.6 — 2026-11-02
|
||||
|
||||
Build 37, on `hy-memory==2.0.11`. Linux x64/arm64 Runtime and macOS arm64
|
||||
App and Runtime.
|
||||
|
||||
- Interfaces in 11 languages: English, Simplified and Traditional Chinese,
|
||||
Japanese, Spanish, German, Portuguese, French, Korean, Russian and
|
||||
Indonesian. Choosing any language other than Simplified Chinese also sets
|
||||
the SDK prompt language; Simplified Chinese keeps the automatic zh/en
|
||||
detection.
|
||||
- Memory modes: lite (no LLM, keeps raw conversations), pro (LLM extraction)
|
||||
and ultra (pro plus the knowledge graph and a nightly evolve run). Set with
|
||||
`GET`/`PUT /api/v1/settings/memory-mode` or `memhub settings mode`. Raw
|
||||
conversations are now kept permanently. `POST /api/v2/evolve` and
|
||||
`memhub evolve` run System 2 on demand.
|
||||
- Safe embedding-model switching: an embedding-space registry, a switch that
|
||||
pauses writes, re-embeds every user's vectors and the graph, verifies and
|
||||
commits atomically, and resumes after a crash or an App update. Every start
|
||||
scans the vector store and merges data left in other spaces. When the
|
||||
embedding service is unavailable, search returns `embedding_unavailable`.
|
||||
- Multi-user Runtime: memory APIs and the CLI take `user_id` (default
|
||||
`local`); records are isolated per user; Hermes profiles can be connected as
|
||||
separate users. The App keeps using `local`.
|
||||
- Configurable SDK features through the CLI and API (`memhub settings
|
||||
features`): graph rerank, episode recall, session episodes, episode
|
||||
summaries, intention and few-shot. Defaults are unchanged.
|
||||
- Search and recall pass through the SDK's search parameters with server-side
|
||||
score floors; the memory library filters by layer; `/api/v2/raw` returns
|
||||
data again; v2 source and import routes; profile version history.
|
||||
- Upgrade fixes: migration no longer stops without a model profile (#234) or
|
||||
on a pre-existing readback mismatch (#229, #240); source grants work after
|
||||
the cutover (#233); reads use the active embedding (#242); the embedding
|
||||
endpoint no longer follows the generation `base_url`.
|
||||
- Hermes import drops gateway notices and probes and unwraps relayed
|
||||
messages; `memhub logs` no longer reports valid older records as damaged.
|
||||
|
||||
## Hy Memory 2.0.11 — 2026-11-01
|
||||
|
||||
- Published
|
||||
[`hy-memory==2.0.11`](https://pypi.org/project/hy-memory/2.0.11/).
|
||||
- LLM prompts now ship in 11 languages: `en`, `zh-Hans`,
|
||||
`zh-Hant`, `ja`, `es`, `de`, `pt`, `fr`, `ko`, `ru` and `id`. They live in
|
||||
one registry, `hy_memory/prompts/`, with one module per language. Call
|
||||
|
||||
@@ -33,6 +33,7 @@ English | [中文](README.zh.md)
|
||||
## Latest News
|
||||
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Open-sourced** quantized **FP8** (**[Hy-Memory-30B-A3B-FP8](https://huggingface.co/tencent/Hy-Memory-30B-A3B-FP8)**) and **GGUF** (**[Hy-Memory-30B-A3B-GGUF](https://huggingface.co/tencent/Hy-Memory-30B-A3B-GGUF)**) versions of **Hy-Memory-30B-A3B** on Hugging Face!
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-11]**: 📦 **Released [MemHub v1.0.6](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.6)** with interfaces in 11 languages, memory modes (lite / pro / ultra with nightly evolve), safe embedding-model switching, multi-user support for the CLI and API, and more reliable upgrades, on **`hy-memory==2.0.11`**. See the release notes for details.
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **Released [MemHub v1.0.5](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.5)** with Chinese, English and Spanish interfaces, one-time pairing codes for more custom Harnesses, and smoother browsing, search, startup and recovery, on **`hy-memory==2.0.8`**. See the release notes for details.
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **Released [MemHub v1.0.4](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.4)** and **[memhub-cli 1.0.5](https://www.npmjs.com/package/memhub-cli/v/1.0.5)**, with a signed macOS desktop app and verified macOS/Linux Runtimes!
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Released** **Hy Memory 2.0 (`hy-memory==2.0.4`)** on [PyPI](https://pypi.org/project/hy-memory/2.0.4/) and open-sourced the **Hy-Memory** monorepo together with **[MemHub](memhub/README.md)**!
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
## 最新动态
|
||||
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **模型开源** 在 Hugging Face 发布 **Hy-Memory-30B-A3B** 的 **FP8**(**[Hy-Memory-30B-A3B-FP8](https://huggingface.co/tencent/Hy-Memory-30B-A3B-FP8)**)与 **GGUF**(**[Hy-Memory-30B-A3B-GGUF](https://huggingface.co/tencent/Hy-Memory-30B-A3B-GGUF)**)量化版本模型!
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-11]**: 📦 **正式发布 [MemHub v1.0.6](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.6)**:界面支持 11 种语言,新增记忆模式(lite / pro / ultra,ultra 每晚自动整理),可安全切换嵌入模型,CLI 和 API 支持多用户,升级更可靠;底层使用 **`hy-memory==2.0.11`**。详见发布说明。
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **正式发布 [MemHub v1.0.5](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.5)**:支持中文、英文和西班牙语界面,可通过一次性配对码连接更多自定义 Harness,并优化了记忆浏览、搜索、启动和异常恢复;底层使用 **`hy-memory==2.0.8`**。详见发布说明。
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **正式发布 [MemHub v1.0.4](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.4)** 与 **[memhub-cli 1.0.5](https://www.npmjs.com/package/memhub-cli/v/1.0.5)**,提供已签名的 macOS 桌面应用及经过验证的 macOS/Linux Runtime!
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **正式发布** **Hy Memory 2.0 (`hy-memory==2.0.4`)**(已上线 [PyPI](https://pypi.org/project/hy-memory/2.0.4/)),并开源 **Hy-Memory** monorepo 与 **[MemHub](memhub/README.md)**!
|
||||
|
||||
+4
-4
@@ -18,13 +18,13 @@ If you run into a problem, please open an Issue with the version, platform,repro
|
||||
|
||||
## 发行与平台合同
|
||||
|
||||
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.10`。MemHub 源码版本为 `1.0.5`
|
||||
(build 36),GitHub 仓库为
|
||||
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.11`。MemHub 源码版本为 `1.0.6`
|
||||
(build 37),GitHub 仓库为
|
||||
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
|
||||
`memhub-cli`。当前代码架构见
|
||||
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
|
||||
|
||||
`v1.0.5` 的发行目标是 Linux x64/arm64 Runtime 与 macOS arm64 App 和 Runtime。
|
||||
`v1.0.6` 的发行目标是 Linux x64/arm64 Runtime 与 macOS arm64 App 和 Runtime。
|
||||
Linux 制品由 GitHub Actions 在原生架构 runner 上构建并完成生命周期验收;macOS
|
||||
制品由发行流水线完成 Developer ID 签名、公证和验收后手动上传。Windows 不支持。
|
||||
只有同时出现在 GitHub Release 与 `release-manifest.json` 中、且通过公开校验的制品才属于
|
||||
@@ -118,7 +118,7 @@ MemHub 的 SQLite 数据默认位于 `~/.one-memory`,不在 npm 包或 Runtime
|
||||
|
||||
## Hy Memory
|
||||
|
||||
SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.10`。SDK 配置、权限边界及 search
|
||||
SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.11`。SDK 配置、权限边界及 search
|
||||
返回格式见 [`MEMORY.md`](MEMORY.md)。HY search 返回的 `content` 已包含 SDK 统一生成的
|
||||
逻辑时间和演化上下文;调用方不应再次转换时间戳或重复拼接 `When`。
|
||||
|
||||
|
||||
@@ -2352,7 +2352,7 @@
|
||||
{
|
||||
"SPDXID": "SPDXRef-Package-hy-memory",
|
||||
"name": "hy-memory",
|
||||
"versionInfo": "2.0.10",
|
||||
"versionInfo": "2.0.11",
|
||||
"downloadLocation": "NOASSERTION",
|
||||
"filesAnalyzed": false,
|
||||
"licenseConcluded": "Apache-2.0",
|
||||
|
||||
@@ -9,7 +9,7 @@ Per-package license texts are not reproduced here yet; the license
|
||||
column is filled in as part of release review, and the SBOM records
|
||||
`NOASSERTION` until then rather than guessing.
|
||||
|
||||
## hy-memory 2.0.10
|
||||
## hy-memory 2.0.11
|
||||
|
||||
License: Apache-2.0
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@one-memory/cli",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.6",
|
||||
"description": "MemHub bootstrap CLI for installing and managing the local-first shared memory runtime",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -203,6 +203,13 @@ import {
|
||||
renderFeaturesText,
|
||||
runFeaturesCommand,
|
||||
} from "./memory-features-flow.js";
|
||||
import {
|
||||
type LanguageDocument,
|
||||
languageExitCode,
|
||||
parseLanguageCommand,
|
||||
renderLanguageText,
|
||||
runLanguageCommand,
|
||||
} from "./memory-language-flow.js";
|
||||
import {
|
||||
type EvolveDocument,
|
||||
evolveExitCode,
|
||||
@@ -2379,6 +2386,40 @@ async function runCommand(
|
||||
);
|
||||
return featuresExitCode(document);
|
||||
}
|
||||
if (
|
||||
named === "settings" &&
|
||||
arguments_.slice(1).find((value) => value !== "--json") === "language"
|
||||
) {
|
||||
const parsed = parseLanguageCommand(
|
||||
arguments_
|
||||
.slice(1)
|
||||
.filter((value, index, all) => index !== all.indexOf("language")),
|
||||
);
|
||||
if (!parsed) return usage();
|
||||
const connected = await connectRuntime(environment);
|
||||
if (!connected.ok) {
|
||||
const document: LanguageDocument = {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
command: "settings language",
|
||||
state: "unavailable",
|
||||
code: connected.code,
|
||||
};
|
||||
emit(
|
||||
asJson ? `${JSON.stringify(document)}\n` : renderLanguageText(document),
|
||||
);
|
||||
return connected.exitCode;
|
||||
}
|
||||
const document = await runLanguageCommand(
|
||||
parsed,
|
||||
connected.reader,
|
||||
connected.operations,
|
||||
);
|
||||
emit(
|
||||
asJson ? `${JSON.stringify(document)}\n` : renderLanguageText(document),
|
||||
);
|
||||
return languageExitCode(document);
|
||||
}
|
||||
if (named === "settings") {
|
||||
const parsed = parseSettingsCommand(arguments_.slice(1));
|
||||
if (!parsed) return usage();
|
||||
|
||||
@@ -461,12 +461,13 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
{
|
||||
name: "settings",
|
||||
summary:
|
||||
"查看或切换记忆模式(lite / pro / ultra)。lite 不调用模型,只在原始对话里检索;pro 由模型提取记忆(System 1);ultra 在 pro 之上每晚自动进化(System 2,使用知识图谱)。任何模式都保留全部原始对话。切换不迁移数据:已处理的不会重做,排队中的和新的按新模式处理;需 --yes,不加时只显示计划。环境变量 ONE_MEMORY_GRAPH_STORE 会强制 ultra。settings features 查看或开关 SDK 的可选检索与提取功能(全局设置,默认全部保持 SDK 默认值):graph_rerank 图谱重排(仅 ultra)、same_series_timeline 同系列时间线(需 graph_rerank)、episode_recall 片段召回(pro / ultra,需先开 session_episodes 写入片段)、session_episodes 片段写入、episode_summary 片段摘要(每个片段一次模型调用)、intention 意图提取(默认开)、few_shot 提取示例;set 需 --yes,不加时只显示计划;reset 恢复默认值。",
|
||||
"查看或切换记忆模式(lite / pro / ultra)。lite 不调用模型,只在原始对话里检索;pro 由模型提取记忆(System 1);ultra 在 pro 之上每晚自动进化(System 2,使用知识图谱)。任何模式都保留全部原始对话。切换不迁移数据:已处理的不会重做,排队中的和新的按新模式处理;需 --yes,不加时只显示计划。环境变量 ONE_MEMORY_GRAPH_STORE 会强制 ultra。settings features 查看或开关 SDK 的可选检索与提取功能(全局设置,默认全部保持 SDK 默认值):graph_rerank 图谱重排(仅 ultra)、same_series_timeline 同系列时间线(需 graph_rerank)、episode_recall 片段召回(pro / ultra,需先开 session_episodes 写入片段)、session_episodes 片段写入、episode_summary 片段摘要(每个片段一次模型调用)、intention 意图提取(默认开)、few_shot 提取示例;set 需 --yes,不加时只显示计划;reset 恢复默认值。settings language 查看或设置界面语言(App 切换语言时会自动设置):简体中文(zh-Hans)和 system 不指定 SDK 语言,SDK 照旧自动识别中文或英文;其余语言(en、zh-Hant、ja、es、de、pt、fr、ko、ru、id)会让提取等 SDK 提示使用该语言,立即生效。",
|
||||
usage:
|
||||
"memhub settings mode [lite|pro|ultra] [--yes] [--json]\n" +
|
||||
" memhub settings features [--json]\n" +
|
||||
" memhub settings features set <name>=<true|false> [...] [--yes] [--json]\n" +
|
||||
" memhub settings features reset [<name> ...] [--json]",
|
||||
" memhub settings features reset [<name> ...] [--json]\n" +
|
||||
" memhub settings language [<code>|system] [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "mode [lite|pro|ultra]",
|
||||
@@ -481,6 +482,11 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
flag: "features reset [<name> ...]",
|
||||
summary: "把指定功能(不写则全部)恢复为 SDK 默认值。",
|
||||
},
|
||||
{
|
||||
flag: "language [<code>|system]",
|
||||
summary:
|
||||
"不带参数显示界面语言和 SDK 提示语言;带语言代码(en、zh-Hans、zh-Hant、ja、es、de、pt、fr、ko、ru、id)或 system 时设置。",
|
||||
},
|
||||
CONFIRM_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
|
||||
@@ -23,6 +23,7 @@ const READ_ONLY_PATHS = [
|
||||
"/api/v2/user-profile/versions",
|
||||
"/api/v1/settings/memory-mode",
|
||||
"/api/v1/settings/memory-features",
|
||||
"/api/v1/settings/ui-language",
|
||||
"/api/v2/evolve",
|
||||
] as const;
|
||||
/** One evolve run's receipt (`memhub evolve --wait`). */
|
||||
|
||||
@@ -31,6 +31,8 @@ const EVOLVE_PATH = "/api/v2/evolve";
|
||||
const MEMORY_MODE_PATH = "/api/v1/settings/memory-mode";
|
||||
/** `memhub settings features set|reset` sets the SDK features. */
|
||||
const MEMORY_FEATURES_PATH = "/api/v1/settings/memory-features";
|
||||
/** `memhub settings language <code>` sets the display language Core uses. */
|
||||
const UI_LANGUAGE_PATH = "/api/v1/settings/ui-language";
|
||||
const ONBOARDING_COMPLETE_PATH = "/api/v1/onboarding/complete";
|
||||
const EMBEDDING_SWITCH_PATHS = [
|
||||
"/api/v1/embedding-switches",
|
||||
@@ -165,6 +167,7 @@ export function createCoreOperations(attachment: {
|
||||
if (
|
||||
path !== MEMORY_MODE_PATH &&
|
||||
path !== MEMORY_FEATURES_PATH &&
|
||||
path !== UI_LANGUAGE_PATH &&
|
||||
(!SDK_MEMORY_PATH.test(path) || path.includes("?"))
|
||||
) {
|
||||
throw new Error("core_operation_path_not_allowed");
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
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 ? "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("界面语言:未设置(SDK 自动识别)");
|
||||
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,185 @@
|
||||
/**
|
||||
* `memhub settings language [<code>|system]`.
|
||||
*
|
||||
* The display language the App reports to the Runtime (`ui_language`), and
|
||||
* the SDK prompt language it implies. Every code, zh-Hans and en included,
|
||||
* makes extraction and the other SDK prompts use that language; "system"
|
||||
* (no language set) passes none, so the SDK keeps detecting Chinese or
|
||||
* English as before. The App sends the language it shows, so it is only
|
||||
* unset on an install without the App. 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
|
||||
? "未设置(SDK 自动识别)"
|
||||
: `${state.ui_language}(${NAMES[state.ui_language]})`
|
||||
}`,
|
||||
`SDK 提示语言:${sdkText(state.sdk_language)}`,
|
||||
];
|
||||
if (document.state === "ok") {
|
||||
lines.push(
|
||||
`可选:${UI_LANGUAGES.join("、")}、system。任一语言(包括 zh-Hans、en)都会让 SDK 使用该语言;system 不指定语言,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 =
|
||||
#: none set (the App sends the language it shows, even following 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 = none set (CLI/API-only, or `system`).
|
||||
ui_language: UiLanguage | None
|
||||
#: What SDK clients are built with: `auto` (no language set: 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.
|
||||
|
||||
Every code (zh-Hans and en included) is passed as
|
||||
`MemoryConfig.language`; only null passes no language, so the SDK
|
||||
keeps detecting zh/en as before. 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 (no language set) sets nothing: the SDK detects zh/en as it always
|
||||
did. Every display language, zh-Hans included, is set explicitly. Like a feature, the language never touches `vector_store`, so
|
||||
the collection identity never depends on it.
|
||||
"""
|
||||
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 the App shows (its choice, or the system language
|
||||
#: it follows); None = none set. 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,77 @@
|
||||
"""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 means no language was set (a CLI/API-only install, or
|
||||
`settings language system`). An App that follows the system sends the
|
||||
language it resolved and shows, not null.
|
||||
|
||||
The owner's rule for the SDK:
|
||||
|
||||
- Any supported code, zh-Hans and en included: pass it as
|
||||
`MemoryConfig.language`, so extraction and the other prompts use that
|
||||
language. Every display language is passed explicitly (#243).
|
||||
- No language at all (never set, as on a CLI/API-only install): pass no
|
||||
language. The SDK keeps detecting Chinese or English from the text, exactly
|
||||
as before this setting existed. The App does not leave it unset: it sends
|
||||
the language it shows, including when it follows the system.
|
||||
|
||||
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"}
|
||||
#: 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.
|
||||
|
||||
Every supported code is passed as is; only an unset (or unknown) value
|
||||
leaves the SDK to detect zh/en.
|
||||
"""
|
||||
return normalize_ui_language(ui_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,331 @@
|
||||
"""The App's display language, and the SDK prompt language it implies.
|
||||
|
||||
The owner's rule (#243): every display language, zh-Hans and en included, is
|
||||
passed as `MemoryConfig.language` (hy-memory 2.0.11); only an unset language
|
||||
passes none, so the SDK keeps detecting zh/en exactly as before. 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 is passed to the SDK, zh-Hans included.
|
||||
PASSED = ("en", "zh-Hans", "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 PASSED == 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_an_unset_language_leaves_the_sdk_to_detect() -> None:
|
||||
assert sdk_language(None) is None
|
||||
assert sdk_language("system") is None
|
||||
assert sdk_language("xx") is None
|
||||
# zh-Hans is passed like every other code; the legacy zh-CN maps to it.
|
||||
assert sdk_language("zh-Hans") == "zh-Hans"
|
||||
assert sdk_language("zh-CN") == "zh-Hans"
|
||||
for code in PASSED:
|
||||
assert sdk_language(code) == code
|
||||
|
||||
|
||||
def test_no_language_builds_the_config_of_before(tmp_path) -> None:
|
||||
assert _config(tmp_path, None).language is None
|
||||
assert _config(tmp_path, sdk_language("zh-Hans")).language == "zh-Hans"
|
||||
|
||||
|
||||
@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 is passed explicitly: Chinese prompts, handles rebuilt (#243).
|
||||
body = (
|
||||
await client.put("/api/v1/settings/ui-language", json={"ui_language": "zh-Hans"})
|
||||
).json()
|
||||
assert body == {
|
||||
**current,
|
||||
"ui_language": "zh-Hans",
|
||||
"sdk_language": "zh-Hans",
|
||||
"rebuilt": True,
|
||||
}
|
||||
assert reads.current_language() == "zh-Hans" and writes.sdk_language() == "zh-Hans"
|
||||
assert retired == ["zh-Hans"]
|
||||
processing = (await client.get("/api/v1/memory-processing")).json()
|
||||
assert processing["pipeline"]["sdk_language"] == "zh-Hans"
|
||||
|
||||
# The same value again changes nothing.
|
||||
body = (
|
||||
await client.put("/api/v1/settings/ui-language", json={"ui_language": "zh-Hans"})
|
||||
).json()
|
||||
assert body["rebuilt"] is False and retired == ["zh-Hans"]
|
||||
|
||||
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 == ["zh-Hans", "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"
|
||||
|
||||
# Unset (CLI `settings language 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",
|
||||
),
|
||||
),
|
||||
|
||||
@@ -15,9 +15,13 @@ it("bakes fallback release identity when the packaging pipeline omits it", async
|
||||
expect(config).toMatch(
|
||||
/__ONE_MEMORY_BUILD_NUMBER__: JSON\.stringify\(\s*String\(releaseBuildNumber\)/,
|
||||
);
|
||||
expect(config).toMatch(
|
||||
/process\.env\.ONE_MEMORY_COMMIT\?\.trim\(\) \|\| readSourceCommit\(\)/,
|
||||
expect(config).toContain(
|
||||
"const releaseCommit = resolveReleaseCommit(process.env.ONE_MEMORY_COMMIT)",
|
||||
);
|
||||
expect(config).toMatch(/if \(!trimmed\) return readSourceCommit\(\);/);
|
||||
// A supplied commit must be the full 40-character form the runtime accepts.
|
||||
expect(config).toContain("/^[0-9a-f]{40}$/i.test(trimmed)");
|
||||
expect(config).toContain("return trimmed.toLowerCase();");
|
||||
expect(config).toMatch(
|
||||
/execFileSync\("git", \["rev-parse", "HEAD"\],[\s\S]*?encoding: "utf8"/,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { GuiLocale, 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 the language the GUI shows, system included", () => {
|
||||
const shown = (
|
||||
preference: LanguageSnapshot["preference"],
|
||||
locale: GuiLocale,
|
||||
) => coreUiLanguage({ preference, locale, revision: 0 });
|
||||
expect(shown("system", "zh-Hans")).toBe("zh-Hans");
|
||||
expect(shown("system", "en")).toBe("en");
|
||||
expect(shown("zh-Hans", "zh-Hans")).toBe("zh-Hans");
|
||||
expect(shown("en", "en")).toBe("en");
|
||||
expect(shown("ja", "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"], ["en"]]);
|
||||
dispose();
|
||||
language.change({ preference: "de", locale: "de", revision: 3 });
|
||||
await settle();
|
||||
expect(send).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("an untouched install sends the system language the GUI shows", async () => {
|
||||
const language = source({
|
||||
preference: "system",
|
||||
locale: "zh-Hans",
|
||||
revision: 0,
|
||||
});
|
||||
const send = vi.fn(async () => {});
|
||||
installCoreLanguageSync({ language, send });
|
||||
await settle();
|
||||
expect(send.mock.calls).toEqual([["zh-Hans"]]);
|
||||
// Choosing the language already shown changes nothing Core sees.
|
||||
language.change({ preference: "zh-Hans", locale: "zh-Hans", revision: 1 });
|
||||
await settle();
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
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) => 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,50 @@
|
||||
import type { GuiLocale, LanguageSnapshot } from "@one-memory/i18n";
|
||||
import type { NativeLanguageSource } from "./native-language";
|
||||
|
||||
/**
|
||||
* What Core is told: the language the GUI shows. "Follow the system" sends
|
||||
* the language it resolved to, so the memories match the interface a person
|
||||
* sees; the App never leaves the SDK to detect zh/en (#243).
|
||||
*/
|
||||
export function coreUiLanguage(snapshot: LanguageSnapshot): GuiLocale {
|
||||
return snapshot.locale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep Core's `ui_language` setting on the display language the GUI shows.
|
||||
*
|
||||
* Core builds every SDK client with it and passes it, zh-Hans included, as
|
||||
* the SDK prompt language. Sent once when installed, so a choice made before
|
||||
* this existed, or the system language followed at this launch, 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): Promise<void>;
|
||||
reportError?(error: unknown): void;
|
||||
}): () => void {
|
||||
let closed = false;
|
||||
let sent: GuiLocale | undefined;
|
||||
let queue: Promise<void> = Promise.resolve();
|
||||
const push = () => {
|
||||
queue = queue.then(async () => {
|
||||
if (closed) return;
|
||||
const value = coreUiLanguage(options.language.read());
|
||||
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.
|
||||
}
|
||||
|
||||
@@ -526,7 +526,22 @@ describe("hourly isolated desktop launch", () => {
|
||||
expect(resolvePackagedBuildNumber("-1")).toBeUndefined();
|
||||
expect(resolvePackagedBuildNumber(undefined, "3907")).toBe(3907);
|
||||
expect(resolvePackagedBuildNumber("3908", "3907")).toBe(3908);
|
||||
expect(resolvePackagedCommit(undefined, "388fd0e9")).toBe("388fd0e9");
|
||||
expect(resolvePackagedCommit("not-a-sha", "388fd0e9")).toBe("388fd0e9");
|
||||
});
|
||||
|
||||
it("resolves only a full 40-character commit and lowercases it", () => {
|
||||
// Every downstream check (packaged-runtime install, local install, release
|
||||
// download, release scripts) requires 40 lowercase hex characters.
|
||||
const embedded = "388fd0e9b3c41a2d5e6f7a8b9c0d1e2f3a4b5c6d";
|
||||
expect(resolvePackagedCommit(undefined, embedded)).toBe(embedded);
|
||||
expect(resolvePackagedCommit(embedded.toUpperCase())).toBe(embedded);
|
||||
expect(
|
||||
resolvePackagedCommit(` ${embedded.toUpperCase()} `, undefined),
|
||||
).toBe(embedded);
|
||||
expect(resolvePackagedCommit("388fd0e9", embedded)).toBe(embedded);
|
||||
expect(resolvePackagedCommit("388fd0e9")).toBeUndefined();
|
||||
expect(resolvePackagedCommit("not-a-sha", embedded)).toBe(embedded);
|
||||
expect(resolvePackagedCommit(`${embedded}0`, embedded)).toBe(embedded);
|
||||
expect(resolvePackagedCommit("g".repeat(40), embedded)).toBe(embedded);
|
||||
expect(resolvePackagedCommit(undefined, "unknown")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -143,6 +143,12 @@ export function resolvePackagedBuildNumber(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The packaged-runtime install, local install, release download and release
|
||||
* scripts all require a full 40-character lowercase commit. Normalise here so
|
||||
* that one format crosses this boundary: an uppercase full SHA is lowercased,
|
||||
* and a short or malformed value is skipped so it cannot shadow the next source.
|
||||
*/
|
||||
export function resolvePackagedCommit(
|
||||
environmentValue: unknown,
|
||||
embeddedValue?: unknown,
|
||||
@@ -150,7 +156,7 @@ export function resolvePackagedCommit(
|
||||
for (const value of [environmentValue, embeddedValue]) {
|
||||
if (typeof value !== "string") continue;
|
||||
const trimmed = value.trim();
|
||||
if (/^[0-9a-f]{7,40}$/i.test(trimmed)) return trimmed;
|
||||
if (/^[0-9a-f]{40}$/i.test(trimmed)) return trimmed.toLowerCase();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -11,8 +11,20 @@ const { resolveBuildNumber, resolveBuildVersion } =
|
||||
};
|
||||
const releaseVersion = resolveBuildVersion(process.env);
|
||||
const releaseBuildNumber = resolveBuildNumber(process.env);
|
||||
const releaseCommit =
|
||||
process.env.ONE_MEMORY_COMMIT?.trim() || readSourceCommit();
|
||||
const releaseCommit = resolveReleaseCommit(process.env.ONE_MEMORY_COMMIT);
|
||||
|
||||
// The runtime accepts only a full 40-character commit, so an explicitly
|
||||
// supplied malformed value fails the build instead of being baked and skipped.
|
||||
function resolveReleaseCommit(explicit: string | undefined): string {
|
||||
const trimmed = explicit?.trim();
|
||||
if (!trimmed) return readSourceCommit();
|
||||
if (!/^[0-9a-f]{40}$/i.test(trimmed)) {
|
||||
throw new Error(
|
||||
`ONE_MEMORY_COMMIT must be a full 40-character hex commit, got ${JSON.stringify(trimmed)}`,
|
||||
);
|
||||
}
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
function readSourceCommit(): string {
|
||||
try {
|
||||
|
||||
@@ -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 每次启动也会同步一次界面实际显示的语言(App 选“跟随系统”时,发送它按系统语言解析出的语言,例如 `zh-Hans`)。全局设置,存于 `config.json` 的 `ui_language`;从未设置时不写入。
|
||||
- 所有语言(包括 `zh-Hans` 和 `en`)都作为 `MemoryConfig.language` 传给 SDK,提取等提示使用该语言;只有未设置(`system`,或只用 CLI/API、从未打开 App 的安装)时不向 SDK 传语言,SDK 照旧自动识别中文或英文。
|
||||
- 修改会像功能开关一样重建 SDK 客户端;不迁移数据,不改集合名(需要 hy-memory 2.0.11 的 `MemoryConfig.language`)。
|
||||
- `status --json` 的 `pipeline.ui_language` / `pipeline.sdk_language`(`auto` 或语言代码;`zh-Hans` 用户显示 `zh-Hans`)。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",
|
||||
);
|
||||
|
||||
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user