diff --git a/memhub/apps/web/src/App.tsx b/memhub/apps/web/src/App.tsx index fc03e27..bbe1336 100644 --- a/memhub/apps/web/src/App.tsx +++ b/memhub/apps/web/src/App.tsx @@ -35,6 +35,7 @@ import { OnboardingFlow } from "./OnboardingFlow"; import { R6Button, R6LocalStorage } from "./R6Controls"; import { R6Settings } from "./R6Settings"; import { prefetchDataRuntime } from "./runtime-state"; +import { EmbeddingSwitchProvider } from "./use-embedding-switch"; import "./styles.css"; import "./core-visual.css"; import "./r6-shell.css"; @@ -48,10 +49,12 @@ const rendererStartedAt = Date.now(); export function App() { return ( - - - - + + + + + + ); } diff --git a/memhub/apps/web/src/EmbeddingSwitchExperience.test.tsx b/memhub/apps/web/src/EmbeddingSwitchExperience.test.tsx index f7e7151..aa9e73f 100644 --- a/memhub/apps/web/src/EmbeddingSwitchExperience.test.tsx +++ b/memhub/apps/web/src/EmbeddingSwitchExperience.test.tsx @@ -27,6 +27,7 @@ import { EmbeddingSwitchConfirmDialog, EmbeddingSwitchGlobalStatus, } from "./EmbeddingSwitchExperience"; +import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language"; const NOW = "2026-11-03T09:00:00Z"; const space = (id: string | null, model: string) => ({ @@ -101,9 +102,59 @@ const job: EmbeddingSwitchJob = { afterEach(() => { cleanup(); current = null; + localStorage.removeItem(LANGUAGE_STORAGE_KEY); vi.clearAllMocks(); }); +it("switches Chinese, Spanish, and English without leaking Chinese", async () => { + localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-Hans"); + const view = render( + + + , + ); + + await waitFor(() => + expect( + screen.getByRole("heading", { name: "更新记忆搜索索引?" }), + ).toBeTruthy(), + ); + + localStorage.setItem(LANGUAGE_STORAGE_KEY, "es"); + window.dispatchEvent( + new StorageEvent("storage", { key: LANGUAGE_STORAGE_KEY }), + ); + await waitFor(() => + expect( + screen.getByRole("heading", { + name: "¿Actualizar el índice de búsqueda de memorias?", + }), + ).toBeTruthy(), + ); + expect(view.container.textContent).not.toMatch(/[一-鿿]/); + expect(view.container.textContent).toContain("8421"); + + localStorage.setItem(LANGUAGE_STORAGE_KEY, "en"); + window.dispatchEvent( + new StorageEvent("storage", { key: LANGUAGE_STORAGE_KEY }), + ); + await waitFor(() => + expect( + screen.getByRole("heading", { + name: "Update the memory search index?", + }), + ).toBeTruthy(), + ); + expect(view.container.textContent).not.toMatch(/[一-鿿]/); + expect(view.container.textContent).toContain("8,421"); +}); + it("explains the index update in user terms before exposing technical details", async () => { const onStart = vi.fn(); render( diff --git a/memhub/apps/web/src/EmbeddingSwitchExperience.tsx b/memhub/apps/web/src/EmbeddingSwitchExperience.tsx index 2032d22..0888465 100644 --- a/memhub/apps/web/src/EmbeddingSwitchExperience.tsx +++ b/memhub/apps/web/src/EmbeddingSwitchExperience.tsx @@ -3,50 +3,68 @@ import type { EmbeddingSwitchJob, EmbeddingSwitchPlan, } from "@one-memory/contracts"; +import type { GuiLocale, MessageKey } from "@one-memory/i18n"; import { ChevronDown, CircleAlert, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { deleteRetiredEmbeddingSpace } from "./api"; +import { useGuiLanguage } from "./gui-language"; import { R6Button } from "./R6Controls"; import { useEmbeddingSwitch } from "./use-embedding-switch"; import "./embedding-switch.css"; -function count(value: number | null | undefined) { - return new Intl.NumberFormat().format(value ?? 0); +type Translator = ( + key: MessageKey, + params?: Record, +) => string; + +function count(locale: GuiLocale, value: number | null | undefined) { + return new Intl.NumberFormat(locale).format(value ?? 0); } -function duration(seconds: number | null) { - if (seconds === null) return "暂时无法估算"; - if (seconds < 60) return `约 ${Math.max(1, Math.round(seconds))} 秒`; - return `约 ${Math.max(1, Math.round(seconds / 60))} 分钟`; +function duration(locale: GuiLocale, t: Translator, seconds: number | null) { + if (seconds === null) return t("embeddingSwitch.duration.unknown"); + const value = Math.max(1, Math.round(seconds < 60 ? seconds : seconds / 60)); + return t( + seconds < 60 + ? "embeddingSwitch.duration.seconds" + : "embeddingSwitch.duration.minutes", + { count: count(locale, value) }, + ); } -function size(bytes: number) { - if (bytes < 1024 * 1024) return `${Math.ceil(bytes / 1024)} KB`; - return `${Math.ceil(bytes / 1024 / 1024)} MB`; +function size(locale: GuiLocale, bytes: number) { + const megabytes = bytes >= 1024 * 1024; + const value = Math.ceil(bytes / 1024 / (megabytes ? 1024 : 1)); + return `${count(locale, value)} ${megabytes ? "MB" : "KB"}`; } -const blockerCopy: Record = { - embedding_probe_required: "请先测试新的搜索模型连接。", +const blockerKeys: Record = { + embedding_probe_required: "embeddingSwitch.blocker.embedding_probe_required", embedding_dimensions_unknown: - "还无法确认新搜索模型的索引规格,请重新测试连接。", + "embeddingSwitch.blocker.embedding_dimensions_unknown", embedding_switch_requires_migration: - "旧版本数据仍在升级,请等待升级完成后再更换。", - sdk_reembed_unavailable: "当前安装版本还不能安全更新记忆索引。", - insufficient_disk_space: "本机剩余空间不足,无法安全建立新索引。", - embedding_switch_running: "另一次索引更新正在进行。", - memory_lifecycle_busy: "记忆正在整合,请等待完成后再更换搜索模型。", + "embeddingSwitch.blocker.embedding_switch_requires_migration", + sdk_reembed_unavailable: "embeddingSwitch.blocker.sdk_reembed_unavailable", + insufficient_disk_space: "embeddingSwitch.blocker.insufficient_disk_space", + embedding_switch_running: "embeddingSwitch.blocker.embedding_switch_running", + memory_lifecycle_busy: "embeddingSwitch.blocker.memory_lifecycle_busy", }; -const failureCopy: Record = { - writes_did_not_drain: "等待正在进行的记忆整理结束超时。", - embedding_service_failed: "搜索模型服务连续请求失败。", - embedding_dimensions_mismatch: "搜索服务返回的索引规格与测试结果不一致。", - embedding_verification_failed: "新的记忆索引没有通过完整性校验。", - target_profile_changed: "更新期间搜索模型配置发生了变化。", - embedding_switch_cancelled: "索引更新已取消。", - model_secret_unavailable: "无法读取搜索服务的 API Key。", - secure_storage_unavailable: "系统安全存储暂时不可用。", - embedding_switch_clients_busy: "记忆服务一直被占用,暂时无法完成切换。", +const failureKeys: Record = { + writes_did_not_drain: "embeddingSwitch.failure.writes_did_not_drain", + embedding_service_failed: "embeddingSwitch.failure.embedding_service_failed", + embedding_dimensions_mismatch: + "embeddingSwitch.failure.embedding_dimensions_mismatch", + embedding_verification_failed: + "embeddingSwitch.failure.embedding_verification_failed", + target_profile_changed: "embeddingSwitch.failure.target_profile_changed", + embedding_switch_cancelled: + "embeddingSwitch.failure.embedding_switch_cancelled", + model_secret_unavailable: "embeddingSwitch.failure.model_secret_unavailable", + secure_storage_unavailable: + "embeddingSwitch.failure.secure_storage_unavailable", + embedding_switch_clients_busy: + "embeddingSwitch.failure.embedding_switch_clients_busy", }; export function EmbeddingSwitchConfirmDialog({ @@ -62,9 +80,10 @@ export function EmbeddingSwitchConfirmDialog({ onCancel(): void; onStart(): void; }) { + const { locale, t } = useGuiLanguage(); const panel = useRef(null); - const blockers = plan.blockers.map( - (code) => blockerCopy[code] ?? "当前状态暂时不允许更换搜索模型。", + const blockers = plan.blockers.map((code) => + t(blockerKeys[code] ?? "embeddingSwitch.blocker.default"), ); useEffect(() => { @@ -113,78 +132,88 @@ export function EmbeddingSwitchConfirmDialog({ >
-

更新记忆搜索索引?

-

- 不同搜索模型建立的索引不能混用。为了继续找到已有记忆,需要重新建立索引。 -

+

+ {t("embeddingSwitch.confirm.title")} +

+

{t("embeddingSwitch.confirm.description")}

-
已有记忆
+
{t("embeddingSwitch.impact.memories")}
- {count(plan.total_vectors)} 条 + {t("embeddingSwitch.impact.memoryCount", { + count: count(locale, plan.total_vectors), + })} {plan.users && plan.users > 1 - ? ` · ${count(plan.users)} 位用户` + ? t("embeddingSwitch.impact.userCount", { + count: count(locale, plan.users), + }) : ""}
-
预计耗时
-
{duration(plan.estimate_s)}
+
{t("embeddingSwitch.impact.duration")}
+
{duration(locale, t, plan.estimate_s)}
-
远程请求
+
{t("embeddingSwitch.impact.requests")}
{plan.target_local - ? "不会产生" + ? t("embeddingSwitch.requests.none") : plan.requests === null - ? "按服务商计费" - : `约 ${count(plan.requests)} 次`} + ? t("embeddingSwitch.requests.billed") + : t("embeddingSwitch.requests.approx", { + count: count(locale, plan.requests), + })}
- 更新期间仍可搜索记忆 -

- MemHub - 会继续使用原来的搜索模型。新对话仍会保存,更新完成后再整理成记忆。 -

- 失败不会影响已有数据 -

- 只有全部更新并校验通过后才会切换;失败或取消时仍使用原来的模型。 -

+ {t("embeddingSwitch.safety.searchTitle")} +

{t("embeddingSwitch.safety.searchBody")}

+ {t("embeddingSwitch.safety.dataTitle")} +

{t("embeddingSwitch.safety.dataBody")}

-
当前搜索模型
-
{plan.source.model ?? "旧版本索引"}
-
-
-
新的搜索模型
-
{plan.target.model ?? "待确认"}
-
-
-
索引维度
+
{t("embeddingSwitch.details.currentModel")}
- {plan.source.dims ?? "未知"} → {plan.target.dims ?? "未知"} + {plan.source.model ?? t("embeddingSwitch.details.legacyIndex")}
-
额外磁盘空间
-
{size(plan.disk_bytes_needed)}
+
{t("embeddingSwitch.details.newModel")}
+
+ {plan.target.model ?? t("embeddingSwitch.details.pending")} +
-
旧索引保留
-
{plan.retention_days} 天
+
{t("embeddingSwitch.details.dimensions")}
+
+ {plan.source.dims ?? t("embeddingSwitch.details.unknown")} →{" "} + {plan.target.dims ?? t("embeddingSwitch.details.unknown")} +
+
+
+
{t("embeddingSwitch.details.disk")}
+
{size(locale, plan.disk_bytes_needed)}
+
+
+
{t("embeddingSwitch.details.retention")}
+
+ {t("embeddingSwitch.details.days", { + count: count(locale, plan.retention_days), + })} +
@@ -200,15 +229,15 @@ export function EmbeddingSwitchConfirmDialog({