From f66461aa5cc6205f527b8a0b4a38b1a57981ffee Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 8 Oct 2026 12:04:39 +0000 Subject: [PATCH] Use i18next for sandbox translations --- apps/web/src/features/fleet/DiagnosticTip.tsx | 2 +- apps/web/src/features/sandbox/NodeDetail.tsx | 4 +-- .../src/features/sandbox/NodeEditDialog.tsx | 4 +-- .../src/features/sandbox/NodeEnrollment.tsx | 2 +- apps/web/src/features/sandbox/NodeList.tsx | 4 +-- .../features/sandbox/NodeRolloutStatus.tsx | 4 +-- .../sandbox/SandboxDeploymentPage.tsx | 4 +-- .../sandbox/SandboxDeploymentSettings.tsx | 6 ++-- .../features/sandbox/SandboxManagerView.tsx | 4 +-- .../features/sandbox/SandboxSetupWizard.tsx | 8 ++--- .../src/features/sandbox/node-enrollment.ts | 4 +-- apps/web/src/i18n/README.md | 9 ++--- .../locales/zh-CN/first-run.ts} | 2 +- .../locales/zh-CN/sandbox.ts} | 7 ++-- apps/web/src/i18n/resources.ts | 8 ++--- apps/web/src/lib/locale.test.ts | 22 ++++++------- apps/web/src/lib/locale.ts | 5 --- apps/web/src/lib/sandbox-diagnostic.test.ts | 6 ++-- apps/web/src/lib/sandbox-diagnostic.ts | 11 ++++--- apps/web/src/lib/sandbox-labels.test.ts | 8 ++--- apps/web/src/lib/sandbox-labels.ts | 33 ++++++++----------- 21 files changed, 72 insertions(+), 85 deletions(-) rename apps/web/src/{lib/console-auth-strings.ts => i18n/locales/zh-CN/first-run.ts} (97%) rename apps/web/src/{lib/locale-strings.ts => i18n/locales/zh-CN/sandbox.ts} (99%) delete mode 100644 apps/web/src/lib/locale.ts diff --git a/apps/web/src/features/fleet/DiagnosticTip.tsx b/apps/web/src/features/fleet/DiagnosticTip.tsx index 6151e8386..cbb6a909e 100644 --- a/apps/web/src/features/fleet/DiagnosticTip.tsx +++ b/apps/web/src/features/fleet/DiagnosticTip.tsx @@ -6,7 +6,7 @@ import { sandboxDiagnosticMessage } from "../../lib/sandbox-diagnostic"; /** The specific reason behind a node's status, in the help tip beside it: what is wrong, then how to fix it. */ export function DiagnosticTip({ code }: { code: string }) { const { i18n } = useTranslation(); - const message = sandboxDiagnosticMessage(code, i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en"); + const message = sandboxDiagnosticMessage(code, i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en"); if (!message) return null; return ( diff --git a/apps/web/src/features/sandbox/NodeDetail.tsx b/apps/web/src/features/sandbox/NodeDetail.tsx index 6425e9766..ee1043cbe 100644 --- a/apps/web/src/features/sandbox/NodeDetail.tsx +++ b/apps/web/src/features/sandbox/NodeDetail.tsx @@ -19,7 +19,7 @@ function nodeDiagnostic(node: SandboxNode): string { function Diagnostic({ value }: { value: string }) { const { i18n } = useTranslation("sandbox"); - const message = sandboxDiagnosticMessage(value, i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en"); + const message = sandboxDiagnosticMessage(value, i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en"); if (!message) return <>{MISSING}; return {message.label}{message.advice}; } @@ -49,7 +49,7 @@ export function NodeDetail({ node, allocations, coreUrl, targetGeneration, stale }) { const { t, i18n } = useTranslation("sandbox"); const locale = i18n.resolvedLanguage; - const shortLocale = locale?.startsWith("zh") ? "zh" : "en"; + const shortLocale = locale?.startsWith("zh") ? "zh-CN" : "en"; const now = Math.floor(Date.now() / 1000); const own = allocations.filter((allocation) => allocation.node_id === node.id); const reporting = !stale && node.online; diff --git a/apps/web/src/features/sandbox/NodeEditDialog.tsx b/apps/web/src/features/sandbox/NodeEditDialog.tsx index 0cfe46ec8..8740159f6 100644 --- a/apps/web/src/features/sandbox/NodeEditDialog.tsx +++ b/apps/web/src/features/sandbox/NodeEditDialog.tsx @@ -30,7 +30,7 @@ export function NodeEditDialog({ client, node, size, suspends, onClose, onSaved onSaved: () => void; }) { const { t, i18n } = useTranslation("sandbox"); - const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en"; + const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en"; const { t: tCommon } = useTranslation("common"); const id = useId(); const [name, setName] = useState(node?.name ?? ""); @@ -61,7 +61,7 @@ export function NodeEditDialog({ client, node, size, suspends, onClose, onSaved t("Host: {{host}}.", { host: measure(hostCpus, hostMemory) }), ...(size ? [t("Each sandbox: {{size}}.", { size: measure(size.cpus, size.memory_mib * 2 ** 20) })] : []), ...(fit !== null && fit > 0 ? [t("Suggested: at most {{count}} at once.", { count: fit })] : []), - ].join(locale === "zh" ? "" : " ") : null; + ].join(locale === "zh-CN" ? "" : " ") : null; async function save() { if (!ready || !node) return; diff --git a/apps/web/src/features/sandbox/NodeEnrollment.tsx b/apps/web/src/features/sandbox/NodeEnrollment.tsx index 8247faea1..86d0dbe2b 100644 --- a/apps/web/src/features/sandbox/NodeEnrollment.tsx +++ b/apps/web/src/features/sandbox/NodeEnrollment.tsx @@ -69,7 +69,7 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, onRefresh: () => Promise; }) { const { t, i18n } = useTranslation("sandbox"); - const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en"; + const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en"; const { t: tCommon } = useTranslation("common"); const id = useId(); const [active, setActive] = useState(DEFAULT_ACTIVE); diff --git a/apps/web/src/features/sandbox/NodeList.tsx b/apps/web/src/features/sandbox/NodeList.tsx index 055c9b700..d24f73383 100644 --- a/apps/web/src/features/sandbox/NodeList.tsx +++ b/apps/web/src/features/sandbox/NodeList.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { HelpTip, StatusDot, type Tone } from "../../components/console-ui"; import { NameCell, RowActions } from "../../components/list-ui"; import { epochSeconds, formatDateTime, formatRelative } from "../../lib/format"; -import type { MessageKey } from "../../lib/locale-strings"; +import type { ParseKeys } from "i18next"; import { nodeProviderDiagnostic } from "../../lib/sandbox-diagnostic"; import { DiagnosticTip } from "../fleet/DiagnosticTip"; import { nodeHealth, suspendedSandboxes } from "../fleet/fleet-model"; @@ -36,7 +36,7 @@ export function nodeState(node: SandboxNode, allocations: readonly SandboxAlloca } const stateTone: Record = { unconfirmed: "neutral", old_address: "warning", offline: "danger", degraded: "warning", attention: "warning", available: "ok" }; -const stateLabel: Record = { +const stateLabel: Record> = { unconfirmed: "Status unconfirmed", old_address: "Old address", offline: "Offline", diff --git a/apps/web/src/features/sandbox/NodeRolloutStatus.tsx b/apps/web/src/features/sandbox/NodeRolloutStatus.tsx index f7a074c2a..8a12cf4a6 100644 --- a/apps/web/src/features/sandbox/NodeRolloutStatus.tsx +++ b/apps/web/src/features/sandbox/NodeRolloutStatus.tsx @@ -1,10 +1,10 @@ import type { SandboxNode, SandboxNodeRollout } from "@oac/agents-client"; import { useTranslation } from "react-i18next"; import { HelpTip, StatusDot, type Tone } from "../../components/console-ui"; -import type { MessageKey } from "../../lib/locale-strings"; +import type { ParseKeys } from "i18next"; import { DiagnosticTip } from "../fleet/DiagnosticTip"; -const labels: Record = { ready: "Ready for target", preparing: "Preparing target", failed: "Preparation failed", update_required: "Node software incompatible", unknown: "Target readiness unknown" }; +const labels: Record> = { ready: "Ready for target", preparing: "Preparing target", failed: "Preparation failed", update_required: "Node software incompatible", unknown: "Target readiness unknown" }; const tones: Record = { ready: "ok", preparing: "neutral", failed: "warning", update_required: "warning", unknown: "neutral" }; /** A serving pin is historical ownership, not proof of a live connection or capacity. */ diff --git a/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx b/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx index 6b071309a..b92c4b3d5 100644 --- a/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx +++ b/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx @@ -34,7 +34,7 @@ function DeploymentHeader({ actions }: { actions?: ReactNode }) { /** System's secondary page is the sole owner of deployment configuration controls. */ export function SandboxDeploymentPage() { const { i18n } = useTranslation("sandbox"); - return
+ return
; } @@ -42,7 +42,7 @@ export function SandboxDeploymentPage() { function DeploymentConfiguration() { const { t, i18n } = useTranslation("sandbox"); const { t: tNavigation } = useTranslation("sandboxNavigation"); - const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en"; + const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en"; const { navigate } = useConsoleNavigation(); const queryClient = useQueryClient(); const { deploymentQuery, snapshot, installation, loading, busy, setupNeedsRefresh, confirmed, fresh, refresh, configurationKey } = useSandboxPageState(); diff --git a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx index 94e585299..4b581ace3 100644 --- a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx +++ b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { Modal } from "../../components/Modal"; import { HelpTip, RefreshButton } from "../../components/console-ui"; import { formatBytes, formatPeriod, MISSING } from "../../lib/format"; -import type { MessageKey } from "../../lib/locale-strings"; +import type { ParseKeys } from "i18next"; import { sandboxProviderLabel } from "../../lib/sandbox-labels"; import { sandboxSize, templateBuildSize, templateBuildStatus } from "./deployment-specification"; import { SandboxRolloutSummary } from "./SandboxRolloutSummary"; @@ -12,7 +12,7 @@ import { SandboxResetControls } from "./SandboxResetControls"; import { SandboxSetupWizard } from "./SandboxSetupWizard"; const MIB = 2 ** 20; -const buildStatusLabel: Record, MessageKey> = { ready: "Ready", notReady: "Not ready", unknown: "Unknown state" }; +const buildStatusLabel: Record, ParseKeys<"sandbox">> = { ready: "Ready", notReady: "Not ready", unknown: "Unknown state" }; export function SandboxDeploymentSettings({ deployment, disabled, fresh, onReset, onCancelReset, onUpdate, writeFailure, onRefresh, refreshing, pending }: { deployment: SandboxDeployment; @@ -28,7 +28,7 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onReset }) { const { t, i18n } = useTranslation("sandbox"); const { t: tNavigation } = useTranslation("sandboxNavigation"); - const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en"; + const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en"; const [changing, setChanging] = useState(false); const [editKey, setEditKey] = useState(0); const spec = deployment.specification; diff --git a/apps/web/src/features/sandbox/SandboxManagerView.tsx b/apps/web/src/features/sandbox/SandboxManagerView.tsx index ace738101..82596e9cc 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.tsx +++ b/apps/web/src/features/sandbox/SandboxManagerView.tsx @@ -25,7 +25,7 @@ import "./SandboxManagerView.css"; /** Nodes owns node enrollment, the list and individual node management. */ export function SandboxManagerView() { const { t, i18n } = useTranslation("sandbox"); - const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en"; + const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en"; const { data: config, isError, isFetching, refetch } = useQuery(sandboxConsoleConfigQuery); return
{config ? : <> @@ -53,7 +53,7 @@ function NodesPageHeader({ title, count, back, actions, headingRef }: { title?: function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig }) { const { t, i18n } = useTranslation("sandbox"); - const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en"; + const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en"; const { params, navigate, back: goBack } = useConsoleNavigation(); const client = sandboxAdmin; const queryClient = useQueryClient(); diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx index c05c26921..1a63bf98d 100644 --- a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx +++ b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx @@ -13,7 +13,7 @@ import { useConsoleNavigation } from "../../lib/console-navigation"; import { coreFieldError } from "../../lib/core-error"; import { formatBytes } from "../../lib/format"; import { installationQuery } from "../../lib/installation"; -import type { MessageKey } from "../../lib/locale-strings"; +import type { ParseKeys } from "i18next"; import { sandboxConfigurationRejection, sandboxProviderLabel } from "../../lib/sandbox-labels"; import { defaultSandboxResources, distributionRuntime, isRuntimeRelease, isRuntimeReleaseField, RUNTIME_RELEASE_FIELDS, savedSpecification, validSandboxResources } from "./deployment-specification"; import { e2bKeyReady, e2bUpdateSelection } from "./sandbox-update"; @@ -81,7 +81,7 @@ function presets(provider: SandboxProvider): Record | return { small: scale(0.5), standard, large: scale(2) }; } -const releaseLabels: Record = { +const releaseLabels: Record> = { source_commit: "Source commit", image_id: "Image ID", image_manifest_digest: "Image manifest digest", @@ -125,7 +125,7 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab const { t, i18n } = useTranslation("sandbox"); const { t: tCommon } = useTranslation("common"); const id = useId(); - const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en"; + const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en"; const currentMode: Where | null = current ? deploymentContract.providers[current.provider].mode : null; const [step, setStep] = useState(editing ? currentMode === "direct" ? "e2b" : "size" : "where"); const [where, setWhere] = useState(currentMode); @@ -542,7 +542,7 @@ function NumberField({ id, label, value, onChange, error }: { error?: string | n ); } -function Nav({ onBack, onNext, nextDisabled, t }: { onBack: () => void; onNext?: () => void; nextDisabled?: boolean; t: (key: MessageKey) => string }) { +function Nav({ onBack, onNext, nextDisabled, t }: { onBack: () => void; onNext?: () => void; nextDisabled?: boolean; t: (key: ParseKeys<"sandbox">) => string }) { return (
diff --git a/apps/web/src/features/sandbox/node-enrollment.ts b/apps/web/src/features/sandbox/node-enrollment.ts index 33b2e3ff5..b68c450ae 100644 --- a/apps/web/src/features/sandbox/node-enrollment.ts +++ b/apps/web/src/features/sandbox/node-enrollment.ts @@ -1,6 +1,6 @@ import type { SandboxEnrollment, SandboxNode } from "@oac/agents-client"; -import type { MessageKey } from "../../lib/locale-strings"; +import type { ParseKeys } from "i18next"; import { nodeProviderDiagnostic } from "../../lib/sandbox-diagnostic"; /** @@ -11,7 +11,7 @@ import { nodeProviderDiagnostic } from "../../lib/sandbox-diagnostic"; export const NODE_READY_WAIT_MS = 60_000; export interface HostPrerequisite { - label: MessageKey; + label: ParseKeys<"sandbox">; } /** diff --git a/apps/web/src/i18n/README.md b/apps/web/src/i18n/README.md index 4fba2e457..51cda8d31 100644 --- a/apps/web/src/i18n/README.md +++ b/apps/web/src/i18n/README.md @@ -5,7 +5,7 @@ The console uses `i18next` and `react-i18next`. English is the fallback language Translations are split by namespace, registered in `resources.ts`: - `locales/en/*.ts` and `locales/zh-CN/*.ts` hold one file per feature namespace: `common` (reusable actions and labels, with the Core error messages of `core-errors.ts` nested inside it), `navigation` (the shell), `pages`, `agents`, `templates`, `vaults`, `files`, `skills`, `keys`, `sessions`, `diagnostics`, `dashboard`, `overview`, `metrics`, `system`, `sandbox-navigation` (registered as `sandboxNavigation`) and `onboarding`. -- The `sandbox` and `firstRun` namespaces come from `src/lib/locale-strings.ts` and `src/lib/console-auth-strings.ts`. Their keys are the English text and their values the Chinese translation. Sandbox status and node diagnostic formatting in `src/lib/sandbox-labels.ts` and `src/lib/sandbox-diagnostic.ts` reads the same strings. +- `sandbox` and `firstRun` use English text as keys in `locales/zh-CN/sandbox.ts` and `locales/zh-CN/first-run.ts`; `resources.ts` projects those keys into the English resources. Keep these keys unchanged when moving copy. Add a namespace when a feature grows beyond page-level labels; do not grow one application-wide translation object. @@ -13,8 +13,9 @@ When adding or changing copy: 1. Add the English key and the `zh-CN` translation in matching namespace files. 2. Consume the key with `useTranslation(namespace)` in React components. -3. Use interpolation for dynamic values instead of concatenating translated text. -4. Keep API values, identifiers, paths, commands and user-provided content out of translation resources. -5. Run the Web tests. The resource parity test rejects keys missing from either language. +3. Outside React, use `i18n.getFixedT(language, namespace)` with `SupportedLanguage` for an explicit language without changing the console language; use i18next’s `ParseKeys` for typed key maps. +4. Use interpolation for dynamic values instead of concatenating translated text. +5. Keep API values, identifiers, paths, commands and user-provided content out of translation resources. +6. Run the Web tests. The resource parity test rejects keys missing from either language. The initial language follows the browser preference (`zh*` selects `zh-CN`) unless the user has chosen a language in the console menu. The choice is stored in local storage; the console still works when browser storage is unavailable. diff --git a/apps/web/src/lib/console-auth-strings.ts b/apps/web/src/i18n/locales/zh-CN/first-run.ts similarity index 97% rename from apps/web/src/lib/console-auth-strings.ts rename to apps/web/src/i18n/locales/zh-CN/first-run.ts index c6490daef..eacaec837 100644 --- a/apps/web/src/lib/console-auth-strings.ts +++ b/apps/web/src/i18n/locales/zh-CN/first-run.ts @@ -1,4 +1,4 @@ -export const consoleAuthChinese = { +export const firstRun = { "Try again": "重试", "Sign in to OpenAgentCore": "登录 OpenAgentCore", "Core key": "Core Key", diff --git a/apps/web/src/lib/locale-strings.ts b/apps/web/src/i18n/locales/zh-CN/sandbox.ts similarity index 99% rename from apps/web/src/lib/locale-strings.ts rename to apps/web/src/i18n/locales/zh-CN/sandbox.ts index 6d2141165..cd38572cd 100644 --- a/apps/web/src/lib/locale-strings.ts +++ b/apps/web/src/i18n/locales/zh-CN/sandbox.ts @@ -1,5 +1,5 @@ -import { consoleAuthChinese } from "./console-auth-strings"; -export const chinese = { +import { firstRun } from "./first-run"; +export const sandbox = { "E2B provider": "E2B 服务商", "Other E2B-compatible provider": "其他兼容 E2B 的服务商", "Loading templates…": "正在读取模板…", @@ -13,7 +13,7 @@ export const chinese = { "Select a ready build": "选择 ready 构建", "Core validates the exact ready build again when you save.": "保存时 Core 会再次校验所选的 ready 构建。", "The selected provider supplies a default. You can edit it for a compatible endpoint.": "服务商已带出默认地址,也可以修改为兼容接口。", - ...consoleAuthChinese, + ...firstRun, "Hosted Sessions will be archived permanently. Deployment configuration and node registrations will be cleared. Unsaved workspace contents may be lost.": "托管 Session 将归档且无法恢复,部署配置与节点注册将清除。未保存的工作区内容可能丢失。", "What reset affects": "重置影响范围", "How automatic reset works": "自动清理规则", @@ -421,4 +421,3 @@ export const chinese = { "{{name}} is still bound to an old Core address. Remove it and add it again.": "{{name}} 仍绑定在旧的 Core 地址上,需要移除后重新添加。", "{{count}} nodes are still bound to an old Core address: {{names}}. Remove them and add them again.": "有 {{count}} 个节点仍绑定在旧的 Core 地址上:{{names}}。需要移除后重新添加。", } as const; -export type MessageKey = keyof typeof chinese; diff --git a/apps/web/src/i18n/resources.ts b/apps/web/src/i18n/resources.ts index d4774c8b1..2d970c6e5 100644 --- a/apps/web/src/i18n/resources.ts +++ b/apps/web/src/i18n/resources.ts @@ -32,17 +32,13 @@ import { skills as zhCNSkills } from "./locales/zh-CN/skills"; import { keys as zhCNKeys } from "./locales/zh-CN/keys"; import { system as zhCNSystem } from "./locales/zh-CN/system"; import { onboarding as zhCNOnboarding } from "./locales/zh-CN/onboarding"; -import { consoleAuthChinese } from "../lib/console-auth-strings"; -import { chinese as zhCNSandbox } from "../lib/locale-strings"; +import { firstRun as zhCNFirstRun } from "./locales/zh-CN/first-run"; +import { sandbox as zhCNSandbox } from "./locales/zh-CN/sandbox"; const enSandbox = Object.fromEntries( Object.keys(zhCNSandbox).map((key) => [key, key]), ) as { [K in keyof typeof zhCNSandbox]: K }; -const zhCNFirstRun = { - ...consoleAuthChinese, -} as const; - const enFirstRun = Object.fromEntries( Object.keys(zhCNFirstRun).map((key) => [key, key]), ) as { [K in keyof typeof zhCNFirstRun]: K }; diff --git a/apps/web/src/lib/locale.test.ts b/apps/web/src/lib/locale.test.ts index 1d61ce7e1..ad570bde6 100644 --- a/apps/web/src/lib/locale.test.ts +++ b/apps/web/src/lib/locale.test.ts @@ -6,29 +6,29 @@ import { sandboxDiagnosticMessage } from "./sandbox-diagnostic"; describe("sandbox localization", () => { it("localizes every persisted allocation and compute state without exposing unknown values", () => { for (const state of ["creating", "running", "cleanup_pending", "released", "disabled", "quiescing", "suspending", "suspended", "restoring", "waking"]) { - expect(sandboxStateLabel(state, "zh")).toMatch(/[\u4e00-\u9fff]/); - expect(sandboxStateLabel(state, "zh")).not.toBe("未知状态"); + expect(sandboxStateLabel(state, "zh-CN")).toMatch(/[\u4e00-\u9fff]/); + expect(sandboxStateLabel(state, "zh-CN")).not.toBe("未知状态"); } - expect(sandboxStateLabel("internal-value", "zh")).toBe("未知状态"); + expect(sandboxStateLabel("internal-value", "zh-CN")).toBe("未知状态"); }); it("localizes all diagnostic labels and advice", () => { for (const code of ["node_unavailable", "resource_missing", "compute_unconfirmed", "ownership_mismatch", "provider_unavailable", "host_unsupported", "artifacts_unavailable", "runtime_download_failed", "runtime_image_unavailable", "capacity_insufficient", "unknown"]) { - const message = sandboxDiagnosticMessage(code, "zh"); + const message = sandboxDiagnosticMessage(code, "zh-CN"); expect(message?.label).toMatch(/[\u4e00-\u9fff]/); expect(message?.advice).toMatch(/[\u4e00-\u9fff]/); } - expect(sandboxDiagnosticMessage("", "zh")).toBeNull(); + expect(sandboxDiagnosticMessage("", "zh-CN")).toBeNull(); }); it("shows Core's reason for a refusal and keeps other failures to the console's words", () => { // A code with one exact meaning keeps the console's localized words. - expect(sandboxRequestError(new AgentCoreError("Node node-edge still has allocations.", 409, "runtime_node_in_use"), "zh")).toBe("节点仍有活跃分配或保留资源。请先清理资源分配、快照、预留资源和待清理项,再移除节点。"); + expect(sandboxRequestError(new AgentCoreError("Node node-edge still has allocations.", 409, "runtime_node_in_use"), "zh-CN")).toBe("节点仍有活跃分配或保留资源。请先清理资源分配、快照、预留资源和待清理项,再移除节点。"); // A refusal is Core's to explain: one code, such as a 409 conflict, covers several reasons. - expect(sandboxRequestError(new AgentCoreError("This console is read-only.", 403), "zh")).toBe("This console is read-only."); - expect(sandboxRequestError(new AgentCoreError("The session is busy.", 409, "conflict_error"), "zh")).toBe("The session is busy."); + expect(sandboxRequestError(new AgentCoreError("This console is read-only.", 403), "zh-CN")).toBe("This console is read-only."); + expect(sandboxRequestError(new AgentCoreError("The session is busy.", 409, "conflict_error"), "zh-CN")).toBe("The session is busy."); // A sandbox refusal whose reason the client withheld is named without it. - expect(sandboxRequestError(new AgentCoreError("withheld", 400, "sandbox_configuration_unconfirmed"), "zh")).toBe("Core 拒绝了这个沙箱配置。"); - expect(sandboxRequestError(new AgentCoreError("raw secret", 502), "zh")).not.toContain("raw secret"); - expect(sandboxRequestError(new Error("raw secret"), "zh")).not.toContain("raw secret"); + expect(sandboxRequestError(new AgentCoreError("withheld", 400, "sandbox_configuration_unconfirmed"), "zh-CN")).toBe("Core 拒绝了这个沙箱配置。"); + expect(sandboxRequestError(new AgentCoreError("raw secret", 502), "zh-CN")).not.toContain("raw secret"); + expect(sandboxRequestError(new Error("raw secret"), "zh-CN")).not.toContain("raw secret"); }); }); diff --git a/apps/web/src/lib/locale.ts b/apps/web/src/lib/locale.ts deleted file mode 100644 index 9f02335c9..000000000 --- a/apps/web/src/lib/locale.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { chinese, type MessageKey } from "./locale-strings"; -export type Locale = "en" | "zh"; -export function translate(locale: Locale, key: MessageKey): string { - return locale === "zh" ? chinese[key] : key; -} diff --git a/apps/web/src/lib/sandbox-diagnostic.test.ts b/apps/web/src/lib/sandbox-diagnostic.test.ts index 5c6c5a0c6..65a830b86 100644 --- a/apps/web/src/lib/sandbox-diagnostic.test.ts +++ b/apps/web/src/lib/sandbox-diagnostic.test.ts @@ -39,11 +39,11 @@ describe("sandbox diagnostics", () => { label: "Runtime download failed", advice: "Runtime files could not be downloaded or verified. Check the node's network access and the configured Runtime release.", }); - expect(sandboxDiagnosticMessage(diagnostic, "zh")).toEqual({ + expect(sandboxDiagnosticMessage(diagnostic, "zh-CN")).toEqual({ label: "Runtime 下载失败", advice: "Runtime 文件下载或验证失败。请检查节点网络连接及配置的 Runtime 发布版本。", }); - for (const locale of ["en", "zh"] as const) { + for (const locale of ["en", "zh-CN"] as const) { expect(sandboxDiagnosticMessage(diagnostic, locale)).not.toEqual(sandboxDiagnosticMessage("runtime_image_unavailable", locale)); } }); @@ -53,7 +53,7 @@ describe("shared node readiness diagnostics", () => { it.each(sandboxNodeDiagnostics)("preserves %s with localized messages", (diagnostic) => { expect(nodeProviderDiagnostic({ online: true, provider_ready: false, diagnostic })).toBe(diagnostic); const english = sandboxDiagnosticMessage(diagnostic, "en"); - const chinese = sandboxDiagnosticMessage(diagnostic, "zh"); + const chinese = sandboxDiagnosticMessage(diagnostic, "zh-CN"); expect(english?.label).not.toBe("Sandbox state needs attention"); expect(english?.advice).not.toBe("Inspect the assigned node and resource, then refresh."); expect(chinese?.label).not.toBe(english?.label); diff --git a/apps/web/src/lib/sandbox-diagnostic.ts b/apps/web/src/lib/sandbox-diagnostic.ts index 37d350819..aaf2ecbd0 100644 --- a/apps/web/src/lib/sandbox-diagnostic.ts +++ b/apps/web/src/lib/sandbox-diagnostic.ts @@ -1,9 +1,9 @@ import { normalizeSandboxNodeDiagnostic, type SandboxNode } from "@oac/agents-client"; -import { translate, type Locale } from "./locale"; -import type { MessageKey } from "./locale-strings"; +import i18n, { type SupportedLanguage } from "../i18n"; +import type { ParseKeys } from "i18next"; export interface SandboxDiagnosticMessage { label: string; advice: string } -const diagnostics: Record = { +const diagnostics: Record; advice: ParseKeys<"sandbox"> }> = { node_unavailable: { label: "Node disconnected", advice: "Reconnect the assigned node, then refresh. Existing resources stay assigned to this node; Core does not move the Session automatically.", @@ -57,11 +57,12 @@ export function nodeProviderDiagnostic(node: Pick { it("uses fixed bilingual E2B errors without reflecting provider text or secrets", () => { for (const [code, status] of [["sandbox_credential_ownership", 409], ["sandbox_credential_invalid", 400], ["sandbox_configuration_invalid", 400], ["sandbox_verification_unconfirmed", 503]] as const) { const error = new AgentCoreError("secret-provider-response", status, code); - for (const locale of ["en", "zh"] as const) { + for (const locale of ["en", "zh-CN"] as const) { expect(sandboxRequestError(error, locale)).not.toContain("secret-provider-response"); if (status < 500) expect(sandboxConfigurationRejection(error, locale)).toBe(sandboxRequestError(error, locale)); else expect(sandboxConfigurationRejection(error, locale)).toBeNull(); @@ -31,16 +31,16 @@ describe("reset conflict recovery", () => { it("explains reset-required configuration changes without assuming a different backend or team", () => { const error = new AgentCoreError("untrusted-provider-detail", 409, "sandbox_reset_required"); expect(sandboxConfigurationRejection(error, "en")).toBe("Reset the sandbox deployment before changing this configuration."); - expect(sandboxRequestError(error, "zh")).toBe("请先重置沙箱部署,再更改此配置。"); + expect(sandboxRequestError(error, "zh-CN")).toBe("请先重置沙箱部署,再更改此配置。"); }); it("gives bilingual safe recovery for known state conflicts without reflecting a server payload", () => { for (const code of ["sandbox_generation_stale", "sandbox_reset_required", "sandbox_reset_in_progress", "sandbox_not_configured", "sandbox_in_use"]) { const error = new AgentCoreError("untrusted-secret-like-payload", 409, code); - for (const locale of ["en", "zh"] as const) expect(sandboxRequestError(error, locale)).not.toContain("untrusted-secret-like-payload"); + for (const locale of ["en", "zh-CN"] as const) expect(sandboxRequestError(error, locale)).not.toContain("untrusted-secret-like-payload"); expect(sandboxWriteUncertain(error)).toBe(false); } expect(sandboxRequestError(new AgentCoreError("stale", 409, "sandbox_generation_stale"), "en")).toContain("Refresh and review"); - expect(sandboxRequestError(new AgentCoreError("reset", 409, "sandbox_reset_in_progress"), "zh")).toContain("正在重置"); + expect(sandboxRequestError(new AgentCoreError("reset", 409, "sandbox_reset_in_progress"), "zh-CN")).toContain("正在重置"); }); }); diff --git a/apps/web/src/lib/sandbox-labels.ts b/apps/web/src/lib/sandbox-labels.ts index 0995dd7aa..d537c6106 100644 --- a/apps/web/src/lib/sandbox-labels.ts +++ b/apps/web/src/lib/sandbox-labels.ts @@ -1,22 +1,21 @@ -import { AgentCoreError, sandboxConfigurationUnconfirmed, type SandboxNode, type SandboxProvider } from "@oac/agents-client"; -import i18n from "../i18n"; +import { AgentCoreError, sandboxConfigurationUnconfirmed, type SandboxProvider } from "@oac/agents-client"; +import i18n, { type SupportedLanguage } from "../i18n"; import { knownCoreError } from "./core-error"; -import { translate, type Locale } from "./locale"; -import type { MessageKey } from "./locale-strings"; +import type { ParseKeys } from "i18next"; -const states: Record = { +const states: Record> = { reserved: "Reserved state", creating: "Creating", active: "Active", releasing: "Releasing", released: "Released", failed: "Failed", pending: "Pending", cleanup_pending: "Cleanup pending", disabled: "Disabled", waking: "Waking", running: "Running", quiescing: "Quiescing", suspending: "Suspending", suspended: "Suspended", restoring: "Restoring", stopped: "Stopped", }; -export function sandboxStateLabel(state: string, locale: Locale): string { - return translate(locale, Object.hasOwn(states, state) ? states[state]! : "Unknown state"); +export function sandboxStateLabel(state: string, locale: SupportedLanguage): string { + return i18n.getFixedT(locale, "sandbox")(Object.hasOwn(states, state) ? states[state]! : "Unknown state"); } /** Localized catalog errors first; unknown refusals retain Core’s message. */ -export function sandboxRequestError(error: unknown, locale: Locale): string { - const known = knownCoreError(error, i18n.getFixedT(locale === "zh" ? "zh-CN" : "en", "common"), "bytes"); +export function sandboxRequestError(error: unknown, locale: SupportedLanguage): string { + const known = knownCoreError(error, i18n.getFixedT(locale, "common"), "bytes"); if (known) return known; - let key: MessageKey = "The sandbox request failed. Refresh to check the current state before trying again."; + let key: ParseKeys<"sandbox"> = "The sandbox request failed. Refresh to check the current state before trying again."; if (error instanceof AgentCoreError) { const refused = !sandboxWriteUncertain(error); if (error.code === sandboxConfigurationUnconfirmed) { if (refused) key = "Core rejected the sandbox configuration."; else if (error.status >= 500) key = "The sandbox service is unavailable. Refresh to check the current state."; } @@ -25,7 +24,7 @@ export function sandboxRequestError(error: unknown, locale: Locale): string { key = "The sandbox request was rejected. Refresh to check the current state."; } else if (error.status >= 500) key = "The sandbox service is unavailable. Refresh to check the current state."; } else if (error instanceof Error && error.message === "removal_unconfirmed") key = "Core did not confirm node removal. Refresh to check its state."; - return translate(locale, key); + return i18n.getFixedT(locale, "sandbox")(key); } /** @@ -45,20 +44,16 @@ export function sandboxWriteUncertain(error: unknown): boolean { * such as E2B with a loopback public_url; null for any other failure. Nothing * was saved, so the administrator corrects the cause and saves again. */ -export function sandboxConfigurationRejection(error: unknown, locale: Locale = "en"): string | null { +export function sandboxConfigurationRejection(error: unknown, locale: SupportedLanguage = "en"): string | null { if (error instanceof AgentCoreError && !sandboxWriteUncertain(error) && error.code) { - const known = knownCoreError(error, i18n.getFixedT(locale === "zh" ? "zh-CN" : "en", "common")); + const known = knownCoreError(error, i18n.getFixedT(locale, "common")); if (known) return known; } return error instanceof AgentCoreError && error.status === 409 && error.code === "sandbox_configuration_error" && error.message ? error.message : null; } -export function sandboxNodeStatus(node: SandboxNode, stale: boolean, locale: Locale): string { - return translate(locale, stale ? "Status unconfirmed" : !node.online ? "Offline" : node.provider_ready ? "Available" : "Unavailable"); -} - -export function sandboxProviderLabel(provider: SandboxProvider | "", locale: Locale): string { +export function sandboxProviderLabel(provider: SandboxProvider | "", locale: SupportedLanguage): string { if (provider === "docker") return "Docker"; if (provider === "microsandbox") return "microsandbox"; - return translate(locale, provider === "e2b" ? "E2B cloud" : "Unknown state"); + return i18n.getFixedT(locale, "sandbox")(provider === "e2b" ? "E2B cloud" : "Unknown state"); }