docs(webui): spec for bilingual i18n (next-intl URL locale segment, code-mapped errors)

This commit is contained in:
m4
2026-08-11 13:58:31 +08:00
parent 79cfeae8d8
commit 493b2bfc52
@@ -0,0 +1,158 @@
# i18n (English + Chinese) — Design
Date: 2026-08-11
Status: Approved (section-by-section approval; approach C: URL locale segment; error strategy: sub2api code mapping)
## Goal
Add bilingual (English/中文) support to the EvoScientist WebUI using
`next-intl` with URL locale segments (`/en/...`, `/zh/...`). All UI static
text is translated; API error messages are localized client-side by mapping
stable server error codes (the sub2api pattern). Agent output, workspace
content, thread titles, and admin-configured branding/terms text are data,
not UI copy, and are not translated.
Decisions locked in during brainstorming:
- **Languages:** `en` + `zh`.
- **Approach:** URL locale segment (user chose C over cookie-only).
`localePrefix: "always"` — `/en`, `/zh` always visible; `/` redirects.
- **Default:** first visit negotiates from `Accept-Language` (`zh*` → zh,
else en); next-intl's `NEXT_LOCALE` cookie remembers preference for
subsequent root visits. No localStorage, no account/DB storage, no
System-config site default.
- **Switcher:** header dropdown (English / 中文), modeled on sub2api's
LocaleSwitcher but built with our shadcn DropdownMenu. Switching replaces
the URL prefix and preserves query params (`threadId`, `view`, ...).
- **Errors:** server responses carry stable `{ code, message }`; the client
maps `code` → `errors.<code>` catalog entry, falling back to the server's
English `message`. Server logs / errorLogStore stay English.
- **Reference implementation:** sub2api (`~/Projects/EvoSci/sub2api/sub2api`)
conventions borrowed: per-domain locale modules, aggregation with
key-collision test, parity tests, code-mapping error localization.
## 1. Architecture & locale resolution
- **next-intl routing** (`defineRouting({ locales: ["en", "zh"], defaultLocale: "en", localePrefix: "always" })`).
`defaultLocale: "en"` is only the fallback when `Accept-Language`
negotiation finds no `zh` — matching the locked browser-detection rule.
- **Directory migration:** all pages move under `src/app/[locale]/`
(`page.tsx`, `login/page.tsx`, `layout.tsx`). **API routes stay at
`src/app/api/`** — outside the locale segment.
- **Proxy composition:** `src/proxy.ts` combines the existing auth proxy
with next-intl middleware. Auth redirects become locale-aware
(`/login` → `/zh/login`). Public-path allowlist logic is preserved;
unprefixed legacy URLs 301 to the prefixed form with query preserved
(handled by next-intl middleware).
- **Root layout:** `src/app/[locale]/layout.tsx` validates the locale param
(`notFound()` on unknown), provides `NextIntlClientProvider` with the
locale's messages. Compatible with the System config feature's
`force-dynamic` + `generateMetadata` (dynamic rendering; next-intl
resolves locale per-request). The configured wordmark/favicon metadata
logic carries over unchanged.
- **Navigation:** all `Link`/`useRouter`/`usePathname` imports switch to
`@/i18n/navigation` (next-intl `createNavigation`) so internal
navigation retains the current locale automatically.
- **Server components & route handlers:** `getTranslations` in RSC;
API routes do not translate user-facing messages (see §4).
## 2. Message catalog
- `src/i18n/messages/{en,zh}/` with per-domain modules:
`common` (shared buttons/states), `header`, `threadList`, `chat`,
`dialogs` (config / imageModels / systemConfig / account / changePassword),
`panels` (skills / memory / schedule / workspace / inspector), `login`,
`errors` (API code mapping). `index.ts` aggregates into one Messages
object per locale.
- **Keys:** semantic (`chat.inputPlaceholder`, `systemConfig.tabs.security`),
never English source text. Interpolation via ICU (`{count}`,
`{min}`/`{max}`). Both trees structurally identical.
- **Type safety:** `IntlMessages` type augmentation (next-intl) so `t()`
key typos are compile errors.
- **Formatting:** dates/numbers via next-intl `useFormatter` /
`Intl.DateTimeFormat` per locale — never hand-rolled.
## 3. Client integration
- Components use `useTranslations("<domain>")` + `t("key")`; RSC uses
`getTranslations`.
- **LocaleSwitcher** in the header next to `ThemeToggle`: dropdown listing
English / 中文 with a check on the active locale; on select,
`router.replace(pathname, { locale })` from `@/i18n/navigation` —
query string preserved.
- Login page is covered by the same `[locale]/layout.tsx` provider
(unauthenticated). Branding wordmark and login-terms content come from
`/api/system/config/public` and are admin-entered **data — not
translated**.
- Not translated: agent output, workspace file content, thread titles,
error-log store entries, server logs.
## 4. Error message localization (sub2api code-mapping pattern)
- **Server:** `routeErrorResponse` already emits `{ code, message }`.
Audit the remaining hand-written `NextResponse.json({ error: ... })`
routes and unify them onto stable `{ code, message }` with an enumerated
code set (`INVALID_REQUEST`, `UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`,
`PAYLOAD_TOO_LARGE`, `UNSUPPORTED_MEDIA_TYPE`, `CONFLICT`, `INTERNAL`,
plus domain codes already in use). The English `message` stays as the
fallback. No per-request locale negotiation on the server.
- **Client:** `src/lib/i18nError.ts` — `localizedErrorMessage(err, t, namespace?)`:
extract `code` from the error body → look up `errors.<code>` (or
`<domain>.errors.<code>` when a namespace is given) with ICU metadata
interpolation → on miss, fall back to the server `message`.
- **Single entry point:** `errorToast` routes through the mapper so every
existing dialog/panel toast benefits without per-call-site changes; the
few components rendering `body.error` directly are converted one by one
in batch 6.
## 5. Migration batches
Six batches, each independently deliverable with the suite green
(baseline: 340 passed / 1 skipped, `tsc` clean):
1. **Infrastructure:** next-intl dependency, `[locale]` directory migration
(pure move, no translation), proxy composition, `@/i18n/navigation`,
empty en/zh catalog skeletons + the three catalog tests. Verify legacy
301s, build, and `force-dynamic` compatibility.
2. **Chrome + switcher:** layout, header, login page, navigation menus into
the catalog; LocaleSwitcher ships. Untranslated areas still English.
3. **Main chat:** ChatInterface, ThreadList, message rendering, composer.
4. **Dialogs:** Config, ImageModels, SystemConfig (4 tabs), Account,
ChangePassword, plus their toasts.
5. **Panels:** Skills, Memory, Schedule, Workspace, Inspector, empty states.
6. **Errors:** server code audit/unification + `errors.*` catalog +
errorToast mapping + stray direct-`error` renderers.
## Error handling
- Unknown locale in URL → `notFound()`.
- Missing key in one locale → parity test fails in CI (prevented, not
handled at runtime); next-intl dev-mode onError warns.
- Unmapped error code → server English message displayed (never blank).
- Corrupt/missing catalog module → build failure (imports are static).
## Testing & verification
- **Catalog tests (vitest, land in batch 1):**
1. en/zh key-set parity + ICU variable-set parity.
2. Aggregation key-collision (sub2api pattern — spread modules must not
shadow each other).
3. `errors.*` covers every code `routeErrorResponse` can emit.
- Existing tests asserting English copy are updated per batch (assert keys
or parametrize both locales). Suite stays green throughout.
- Manual gate per batch (dev :4716): switched areas render in both
languages; batch 6 ends with a full-site pass in both languages,
including: switcher preserves `threadId` query, legacy unprefixed link
301s, login page + terms card, System config dialog in zh, error toast
localization (e.g. oversize logo upload), auth redirect → `/zh/login`.
## Out of scope
- Server-side message translation via Accept-Language (rejected in favor
of code mapping, 2026-08-11).
- Locale-prefixed API routes; translating agent/backend-generated content.
- Translating admin-entered data (branding wordmark, login terms).
- localStorage/account-persisted locale (URL + cookie chosen).
- More than two locales; RTL languages.
- Translating errorLogStore entries / server logs (admin diagnostics stay
English).