From 65ad214fcd62393a619311aa66bccee4dfc2aaed Mon Sep 17 00:00:00 2001 From: m4 Date: Tue, 11 Aug 2026 14:33:17 +0800 Subject: [PATCH] docs(webui): implementation plan for i18n (en + zh) --- docs/superpowers/plans/2026-08-11-i18n.md | 1499 +++++++++++++++++++++ 1 file changed, 1499 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-11-i18n.md diff --git a/docs/superpowers/plans/2026-08-11-i18n.md b/docs/superpowers/plans/2026-08-11-i18n.md new file mode 100644 index 0000000..013d0a3 --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-i18n.md @@ -0,0 +1,1499 @@ +# i18n (English + Chinese) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add bilingual (English/中文) support to the EvoScientist WebUI using next-intl with URL locale segments (`/en/...`, `/zh/...`), translating all UI static text and localizing API errors client-side via stable server error codes. + +**Architecture:** next-intl v4 App Router integration with a top-level `[locale]` segment (`localePrefix: "always"`); the existing auth `proxy.ts` is composed with the next-intl middleware. Messages live in per-domain TS modules aggregated per locale, guarded by parity/collision/coverage tests. API routes stay at `src/app/api/` (no locale segment) and emit stable `{ code, message }` bodies; the client maps `code` → `errors.` with the server's English `message` as fallback. + +**Tech Stack:** Next.js 16.2.6 (App Router, `output: "standalone"`), next-intl ^4.13.6, React 19, vitest, TypeScript. + +**Spec:** `docs/superpowers/specs/2026-08-11-i18n-design.md` (approved). + +## Pre-flight (before Task 2) + +The working tree currently carries uncommitted user WIP in `src/app/page.tsx` +and other files. Task 2 `git mv`s `page.tsx`, `login/`, and `layout.tsx` into +`src/app/[locale]/` — **`git mv` on dirty files loses track of the WIP**. The +user must commit (or stash) their in-progress changes before Task 2 starts. +Task 1 is safe with a dirty tree (it only adds new files + edits +`next.config.ts`/`package.json`). + +## Global Constraints + +- Locales are exactly `["en", "zh"]`; `defaultLocale: "en"`; + `localePrefix: "always"` (locked decisions, spec header). +- API routes stay at `src/app/api/` — never under `[locale]`. +- Server stays English: logs, `errorLogStore`, and the `message` field of + error bodies. Only the client localizes, via `errors.`. +- Never translate data: agent output, workspace content, thread titles, + admin-entered branding wordmark/logo/favicon, login terms markdown. +- Key style: semantic (`chat.inputPlaceholder`), never English source text. + Both locale trees structurally identical at all times (parity test gates). +- Suite stays green throughout (baseline: 340 passed / 1 skipped, `tsc` + clean, `eslint` clean). Each task ends green. +- Commits directly on `main`, message prefix `feat(webui):` / + `fix(webui):`. **Never `git add -A` or `git add .`** — add files by name. +- Manual verification uses the dev server on port 4716 (`npm run dev`). + +## Documented deviations from the spec + +Approved-equivalent adjustments discovered during plan research — reviewers +must NOT flag these: + +1. **Spec §1 says "all `Link`/`useRouter`/`usePathname` imports switch to + `@/i18n/navigation`"** — the codebase has **zero** `next/navigation` / + `next/link` usage; navigation is `window.location`-based + (`src/lib/authRedirect.ts`, `page.tsx` sign-out, `login/page.tsx`). The + equivalent work is a `src/lib/localePath.ts` helper + locale-aware call + sites (Task 2). `@/i18n/navigation` is still created (LocaleSwitcher uses + it). +2. **Spec says legacy unprefixed URLs "301"** — the next-intl middleware + issues **307** redirects (correct for content negotiation). Tests assert + the redirect target, not the status code. +3. **Spec §5 puts the `errors.*` catalog in batch 6** — it lands in Task 1 + instead, because spec §Testing requires the `errors.*`-coverage catalog + test in batch 1, and the test needs the catalog. +4. **`localizedErrorMessage` signature** is + `(body: {code?, message?}, messages: AbstractIntlMessages, namespace?: string)` + (via `useMessages()`), not `(err, t, namespace?)`. Reason: next-intl `t()` + throws on missing keys and `t.has` availability is version-dependent; + direct message-tree lookup is pure, unit-testable, and degrades to the + server `message` on any miss — exactly the spec's fallback rule. + +--- + +### Task 1: next-intl scaffolding, catalog skeletons, catalog tests + +**Files:** +- Modify: `package.json` (add dependency), `next.config.ts` (wrap plugin) +- Create: `src/i18n/routing.ts`, `src/i18n/navigation.ts`, + `src/i18n/request.ts`, `src/i18n/types.ts` +- Create: `src/i18n/messages/en/{common,header,threadList,chat,dialogs,panels,login,errors,index}.ts` +- Create: `src/i18n/messages/zh/{common,header,threadList,chat,dialogs,panels,login,errors,index}.ts` +- Create: `src/lib/server/errorCodes.ts` +- Test: `src/i18n/messages/messages.test.ts` + +**Interfaces:** +- Produces: + - `routing` (from `src/i18n/routing.ts`): `defineRouting({ locales: ["en","zh"], defaultLocale: "en", localePrefix: "always" })`; also `type Locale`. + - `Link, redirect, usePathname, useRouter, getPathname` from `@/i18n/navigation` (Task 3's LocaleSwitcher consumes `usePathname`/`useRouter`). + - `API_ERROR_CODES: readonly string[]` from `@/lib/server/errorCodes.ts` (Task 7 consumes; the catalog test consumes now). + - Message domains (top-level keys): `common`, `header`, `threadList`, `chat`, `dialogs`, `panels`, `login`, `errors`. Later tasks add keys inside these; the aggregation/collision test pins one top-level key per module. + +- [ ] **Step 1: Install next-intl** + +```bash +npm install next-intl@^4.13.6 +``` + +Expected: `package.json` gains `"next-intl": "^4.13.6"` under `dependencies`; +peer resolution succeeds against `next@^16.2.5`. + +- [ ] **Step 2: Write the failing catalog test and errorCodes module** + +Create `src/lib/server/errorCodes.ts`: + +```ts +import "server-only"; + +// Stable error codes emitted by WebUI API routes (spec §4). Clients map these +// to localized strings via the errors.* catalog; the English `message` field +// remains the fallback. Python-backend codes are forwarded separately by +// routeErrorResponse and are NOT part of this set. Task 7 may append auth +// codes (e.g. INVALID_CREDENTIALS) discovered during route unification. +export const API_ERROR_CODES = [ + "INVALID_REQUEST", + "UNAUTHENTICATED", + "FORBIDDEN", + "NOT_FOUND", + "PAYLOAD_TOO_LARGE", + "UNSUPPORTED_MEDIA_TYPE", + "CONFLICT", + "INTERNAL", +] as const; + +export type ApiErrorCode = (typeof API_ERROR_CODES)[number]; +``` + +Create `src/i18n/messages/messages.test.ts`: + +```ts +import { describe, expect, it, vi } from "vitest"; +import en from "./en"; +import zh from "./zh"; +import enCommon from "./en/common"; +import enHeader from "./en/header"; +import enThreadList from "./en/threadList"; +import enChat from "./en/chat"; +import enDialogs from "./en/dialogs"; +import enPanels from "./en/panels"; +import enLogin from "./en/login"; +import enErrors from "./en/errors"; + +vi.mock("server-only", () => ({})); + +const { API_ERROR_CODES } = await import("@/lib/server/errorCodes"); + +type Tree = Record; + +function leafPaths(tree: Tree, prefix = ""): string[] { + return Object.entries(tree).flatMap(([key, value]) => { + const path = prefix ? `${prefix}.${key}` : key; + if (value !== null && typeof value === "object") { + return leafPaths(value as Tree, path); + } + return [path]; + }); +} + +function leafAt(tree: Tree, path: string): string { + let node: unknown = tree; + for (const key of path.split(".")) node = (node as Tree)[key]; + return node as string; +} + +function icuVars(message: string): string[] { + return [...message.matchAll(/\{(\w+)[,}]/g)].map((m) => m[1]).sort(); +} + +describe("message catalogs", () => { + it("en and zh have identical key sets", () => { + expect(leafPaths(zh).sort()).toEqual(leafPaths(en).sort()); + }); + + it("each leaf has the same ICU variables in both locales", () => { + for (const path of leafPaths(en)) { + expect(icuVars(leafAt(zh, path)), path).toEqual( + icuVars(leafAt(en, path)) + ); + } + }); + + it("domain modules do not shadow each other at the top level", () => { + const modules = [ + enCommon, + enHeader, + enThreadList, + enChat, + enDialogs, + enPanels, + enLogin, + enErrors, + ]; + const total = modules.reduce((n, m) => n + Object.keys(m).length, 0); + expect(Object.keys(en).length).toBe(total); + }); + + it("errors.* covers every code the WebUI API can emit", () => { + const errors = (en as unknown as Tree).errors as Tree; + for (const code of API_ERROR_CODES) { + expect(typeof errors[code], code).toBe("string"); + } + }); +}); +``` + +Run: `npx vitest run src/i18n/messages/messages.test.ts` +Expected: FAIL — `./en` and friends do not exist. + +- [ ] **Step 3: Create the catalog skeletons (en + zh)** + +Every domain module default-exports an object with exactly one top-level key +(its domain). Skeleton content: `common` gets real shared strings, `errors` +gets the full code set (both locales), the other six start as empty domain +objects (filled by Tasks 3–6; the parity test keeps en/zh in lockstep). + +`src/i18n/messages/en/common.ts`: + +```ts +export default { + common: { + loading: "Loading…", + save: "Save", + cancel: "Cancel", + close: "Close", + delete: "Delete", + confirm: "Confirm", + retry: "Retry", + copy: "Copy", + copied: "Copied", + notFound: "Page not found.", + metadataDescription: + "Web UI for EvoScientist — a self-evolving AI scientist built on DeepAgents/LangGraph.", + }, +}; +``` + +`src/i18n/messages/zh/common.ts`: + +```ts +export default { + common: { + loading: "加载中…", + save: "保存", + cancel: "取消", + close: "关闭", + delete: "删除", + confirm: "确认", + retry: "重试", + copy: "复制", + copied: "已复制", + notFound: "页面不存在。", + metadataDescription: + "EvoScientist 的 Web 界面 —— 一个基于 DeepAgents/LangGraph 的自我进化 AI 科学家。", + }, +}; +``` + +`src/i18n/messages/en/errors.ts`: + +```ts +export default { + errors: { + INVALID_REQUEST: "The request is invalid.", + UNAUTHENTICATED: "Your session has expired. Please sign in again.", + FORBIDDEN: "You don't have permission to do this.", + NOT_FOUND: "The requested resource was not found.", + PAYLOAD_TOO_LARGE: "The upload is too large.", + UNSUPPORTED_MEDIA_TYPE: "This file type is not supported.", + CONFLICT: "This conflicts with the current state. Refresh and try again.", + INTERNAL: "Something went wrong on the server. Please try again.", + }, +}; +``` + +`src/i18n/messages/zh/errors.ts`: + +```ts +export default { + errors: { + INVALID_REQUEST: "请求无效。", + UNAUTHENTICATED: "登录已过期,请重新登录。", + FORBIDDEN: "没有执行此操作的权限。", + NOT_FOUND: "请求的资源不存在。", + PAYLOAD_TOO_LARGE: "上传内容过大。", + UNSUPPORTED_MEDIA_TYPE: "不支持的文件类型。", + CONFLICT: "与当前状态冲突,请刷新后重试。", + INTERNAL: "服务器出错,请稍后重试。", + }, +}; +``` + +Empty skeleton modules — same shape in both locales. `en/header.ts` shown; +repeat verbatim (with the domain key renamed) for `threadList`, `chat`, +`dialogs`, `panels`, `login` in BOTH `en/` and `zh/` (12 files total): + +```ts +export default { + header: {}, +}; +``` + +`src/i18n/messages/en/index.ts` (zh version is identical except the import +paths — `./common` etc. resolve within `zh/`): + +```ts +import common from "./common"; +import header from "./header"; +import threadList from "./threadList"; +import chat from "./chat"; +import dialogs from "./dialogs"; +import panels from "./panels"; +import login from "./login"; +import errors from "./errors"; + +export default { + ...common, + ...header, + ...threadList, + ...chat, + ...dialogs, + ...panels, + ...login, + ...errors, +}; +``` + +Run: `npx vitest run src/i18n/messages/messages.test.ts` +Expected: PASS (4 tests). + +- [ ] **Step 4: Wire next-intl into the app config** + +Create `src/i18n/routing.ts`: + +```ts +import { defineRouting } from "next-intl/routing"; + +export const routing = defineRouting({ + locales: ["en", "zh"], + defaultLocale: "en", + localePrefix: "always", +}); + +export type Locale = (typeof routing.locales)[number]; +``` + +Create `src/i18n/navigation.ts`: + +```ts +import { createNavigation } from "next-intl/navigation"; +import { routing } from "./routing"; + +// Locale-aware wrappers around Next.js navigation APIs. The codebase's +// existing navigation is window.location-based (see src/lib/localePath.ts); +// these are used by LocaleSwitcher and any future SPA navigation. +export const { Link, redirect, usePathname, useRouter, getPathname } = + createNavigation(routing); +``` + +Create `src/i18n/request.ts`: + +```ts +import { getRequestConfig } from "next-intl/server"; +import { hasLocale } from "next-intl"; +import { routing } from "./routing"; + +export default getRequestConfig(async ({ requestLocale }) => { + const requested = await requestLocale; + const locale = hasLocale(routing.locales, requested) + ? requested + : routing.defaultLocale; + return { + locale, + messages: (await import(`./messages/${locale}/index.ts`)).default, + }; +}); +``` + +Create `src/i18n/types.ts`: + +```ts +import type { routing } from "./routing"; +import type en from "./messages/en"; + +// Makes useTranslations()/getTranslations() key-checked against the en +// catalog — key typos become compile errors (spec §2). +declare module "next-intl" { + interface AppConfig { + Locale: (typeof routing.locales)[number]; + Messages: typeof en; + } +} +``` + +Modify `next.config.ts` — add the import and wrap the export: + +```ts +import type { NextConfig } from "next"; +import createNextIntlPlugin from "next-intl/plugin"; + +const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts"); + +const nextConfig: NextConfig = { + // ... existing body unchanged ... +}; + +export default withNextIntl(nextConfig); +``` + +- [ ] **Step 5: Verify the scaffold compiles and nothing regressed** + +Run, each must pass: +1. `npx vitest run src/i18n/messages/messages.test.ts` → 4 passed +2. `npm run test` → full suite green (340 passed / 1 skipped baseline, plus + the 4 new tests) +3. `npx tsc --noEmit` → clean (validates the `AppConfig` augmentation) +4. `npm run build` → succeeds (validates plugin wiring against the + standalone output) + +- [ ] **Step 6: Commit** + +```bash +git add package.json package-lock.json next.config.ts src/i18n src/lib/server/errorCodes.ts +git commit -m "feat(webui): next-intl scaffolding + en/zh catalog skeletons" +``` + +--- + +### Task 2: `[locale]` directory migration + proxy composition + +**Files:** +- Create: `src/lib/localePath.ts` +- Test: `src/lib/localePath.test.ts` +- Move: `src/app/layout.tsx` → `src/app/[locale]/layout.tsx` (rewritten below) +- Move: `src/app/page.tsx` → `src/app/[locale]/page.tsx` (one-line edit below) +- Move: `src/app/login/` → `src/app/[locale]/login/` (one-line edit below) +- Create: `src/app/[locale]/not-found.tsx` +- Modify: `src/proxy.ts` (compose auth + intl middleware) +- Modify: `src/lib/authRedirect.ts` (locale-aware) +- Modify: `src/lib/authRedirect.test.ts` if it exists, else create (locale cases) + +**Interfaces:** +- Consumes: `routing` from `@/i18n/routing` (Task 1). +- Produces: + - `stripLocalePrefix(pathname: string): string` — `"/zh/login"` → `"/login"`, `"/zh"` → `"/"`, unprefixed/non-locale (`/fr`, `/zho`) unchanged. + - `localePrefixOf(pathname: string): string` — `"/zh/x"` → `"/zh"`, unprefixed → `"/en"` (default). + - `localizedPath(path: string, currentPathname: string): string` — `localizedPath("/login", "/zh/chat")` → `"/zh/login"`; `localizedPath("/", "/zh/chat")` → `"/zh"`. + - `hasFileExtension(pathname: string): boolean` — `"/icon.png"` → true, `"/zh/chat"` → false. + - All four from `@/lib/localePath`, client-safe (no `server-only`), consumed by `src/proxy.ts`, `src/lib/authRedirect.ts`, `[locale]/page.tsx`, `[locale]/login/page.tsx`. + +**Pre-flight:** confirm `git status --short src/app/page.tsx src/app/login src/app/layout.tsx` is clean. If not, STOP and ask the user to commit their WIP first. + +- [ ] **Step 1: Write the failing localePath test** + +Create `src/lib/localePath.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { + hasFileExtension, + localePrefixOf, + localizedPath, + stripLocalePrefix, +} from "./localePath"; + +describe("stripLocalePrefix", () => { + it.each([ + ["/zh/login", "/login"], + ["/en", "/"], + ["/zh", "/"], + ["/zh/", "/"], + ["/en/chat", "/chat"], + ["/login", "/login"], + ["/", "/"], + ["/fr/login", "/fr/login"], + ["/zho", "/zho"], + ["/api/system/config", "/api/system/config"], + ])("%s → %s", (input, expected) => { + expect(stripLocalePrefix(input)).toBe(expected); + }); +}); + +describe("localePrefixOf", () => { + it.each([ + ["/zh/login", "/zh"], + ["/zh", "/zh"], + ["/en/chat", "/en"], + ["/login", "/en"], + ["/", "/en"], + ])("%s → %s", (input, expected) => { + expect(localePrefixOf(input)).toBe(expected); + }); +}); + +describe("localizedPath", () => { + it("prefixes a locale-less path with the current locale", () => { + expect(localizedPath("/login", "/zh/chat")).toBe("/zh/login"); + expect(localizedPath("/", "/zh/chat")).toBe("/zh"); + expect(localizedPath("/login", "/chat")).toBe("/en/login"); + }); +}); + +describe("hasFileExtension", () => { + it.each([ + ["/icon.png", true], + ["/evoscientist-logo.png", true], + ["/zh/chat", false], + ["/zh", false], + ["/api/workspace/render/x.html", true], + ])("%s → %s", (input, expected) => { + expect(hasFileExtension(input)).toBe(expected); + }); +}); +``` + +Run: `npx vitest run src/lib/localePath.test.ts` +Expected: FAIL — `./localePath` does not exist. + +- [ ] **Step 2: Implement `src/lib/localePath.ts`** + +```ts +import { routing } from "@/i18n/routing"; + +const LOCALES_RE = new RegExp(`^/(${routing.locales.join("|")})(?=/|$)`); + +/** "/zh/login" → "/login"; paths without an en/zh prefix are returned as-is. */ +export function stripLocalePrefix(pathname: string): string { + const match = LOCALES_RE.exec(pathname); + return match ? pathname.slice(match[0].length) || "/" : pathname; +} + +/** "/zh/x" → "/zh"; unprefixed paths yield the default locale's prefix. */ +export function localePrefixOf(pathname: string): string { + const match = LOCALES_RE.exec(pathname); + return match ? `/${match[1]}` : `/${routing.defaultLocale}`; +} + +/** Prefix a locale-less app path with the locale found in currentPathname. */ +export function localizedPath(path: string, currentPathname: string): string { + return `${localePrefixOf(currentPathname)}${path === "/" ? "" : path}`; +} + +/** Static-asset lookalikes ("/icon.png") bypass the intl middleware. */ +export function hasFileExtension(pathname: string): boolean { + return /\.[^/]+$/.test(pathname); +} +``` + +Run: `npx vitest run src/lib/localePath.test.ts` → PASS. + +- [ ] **Step 3: Move pages/layout under `[locale]`** + +```bash +mkdir -p 'src/app/[locale]' +git mv src/app/layout.tsx 'src/app/[locale]/layout.tsx' +git mv src/app/page.tsx 'src/app/[locale]/page.tsx' +git mv src/app/login 'src/app/[locale]/login' +``` + +`src/app/api/`, `src/app/components/`, `src/app/hooks/`, `src/app/fonts/`, +`src/app/globals.css`, `src/app/icon.png`, `src/app/types/`, `src/app/utils/` +stay put (only routed pages move). **Do not create** `src/app/layout.tsx` — +`[locale]/layout.tsx` is the root layout (next-intl convention; the proxy +redirects every page path into a locale). + +- [ ] **Step 4: Rewrite `src/app/[locale]/layout.tsx`** + +Full new content (changes vs. the moved file: relative import depth, +`params`/`hasLocale` guard, ``, `NextIntlClientProvider`, +description now from the catalog): + +```tsx +import type { Metadata, Viewport } from "next"; +import { notFound } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import { hasLocale, NextIntlClientProvider } from "next-intl"; +import { NuqsAdapter } from "nuqs/adapters/next/app"; +import { ThemeProvider, ThemedToaster } from "@/providers/ThemeProvider"; +import { AuthRedirectInstaller } from "../components/AuthRedirectInstaller"; +import { THEME_STORAGE_KEY } from "@/lib/theme"; +import { routing } from "@/i18n/routing"; +import "katex/dist/katex.min.css"; +import "@mescius/spread-sheets/styles/gc.spread.sheets.excel2013white.css"; +import localFont from "next/font/local"; +import "../globals.css"; + +export const dynamic = "force-dynamic"; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }>; +}): Promise { + const { locale } = await params; + const { getSystemConfig, brandingVersion } = await import( + "@/lib/server/systemConfig" + ); + const t = await getTranslations({ locale, namespace: "common" }); + const config = getSystemConfig(); + const faviconVersion = brandingVersion("favicon"); + return { + title: `${config.branding.wordmark} WebUI`, + description: t("metadataDescription"), + icons: + faviconVersion > 0 + ? { icon: `/api/system/branding/asset/favicon?v=${faviconVersion}` } + : undefined, + }; +} + +export const viewport: Viewport = { + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "#fafafa" }, + { media: "(prefers-color-scheme: dark)", color: "#09090b" }, + ], + colorScheme: "light dark", +}; + +// Self-hosted Inter variable font; exposes --font-inter, consumed by globals.css. +// No CJK glyphs — Chinese text falls through to PingFang/YaHei in the stack. +const inter = localFont({ + src: "../fonts/InterVariable.woff2", + variable: "--font-inter", + display: "swap", +}); + +// Runs before paint so the right theme class is on immediately — no flash +// of the wrong theme. Mirrors ThemeProvider's resolution (default: follow the +// system); ThemeProvider takes over once React mounts. Kept inline + minimal. +const themeScript = `(function(){var k=${JSON.stringify( + THEME_STORAGE_KEY +)};var t="system";try{t=localStorage.getItem(k)||"system";}catch(_){}var d=t==="dark"||(t!=="light"&&window.matchMedia("(prefers-color-scheme: dark)").matches);var e=document.documentElement;e.classList.toggle("dark",d);e.style.colorScheme=d?"dark":"light";})();`; + +export default async function RootLayout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + if (!hasLocale(routing.locales, locale)) { + notFound(); + } + return ( + + +