Skip to content

feat(i18n): add English and Vietnamese alongside Chinese - #402

Open
thotam wants to merge 12 commits into
basketikun:mainfrom
thotam:pr/i18n
Open

thotam wants to merge 12 commits into
basketikun:mainfrom
thotam:pr/i18n

Conversation

@thotam

@thotam thotam commented Sep 11, 2026

Copy link
Copy Markdown

Adds English and Vietnamese to the dashboard, the client-facing API messages and the README, alongside the existing Chinese.

Existing Chinese users see no change. The fallback locale is zh, never en, and every zh catalog value is the original string copied byte-for-byte. A request with no Accept-Language, a browser that reports no supported language, and a client with JavaScript disabled all get exactly what they get today.

What is in here

  • Web UI: 1024 keys per locale across 8 namespaces, covering every screen.
  • Backend: 42 keys per locale. Messages that reach a client are negotiated from Accept-Language.
  • Docs: README.en.md and README.vi.md, plus a one-line language bar at the top of all three.

Design decisions worth knowing before reviewing

Client-side i18next, not next-intl. web/next.config.ts sets output: 'export', so there is no server at runtime, no middleware, and no server-side locale negotiation. Per its own docs, next-intl under a static export requires prefix routing (/zh/, /en/, /vi/), which would change every URL and triple the static output. i18next runs in the browser instead and routing is untouched.

The instance is pinned to the zh fallback at init so the prerendered HTML matches on hydration; the detected locale is applied after mount. The visible cost is one frame of Chinese on first load, which is inherent to baking page text into static HTML.

Account status values stay Chinese. AccountStatus = "正常" | "限流" | "异常" | "禁用" is a wire format: account_service.py, model_service.py and editable_file_task_service.py compare against those literals. Only the rendered label is translated, through a key map in web/src/lib/account-status.ts. The same split applies to the changelog category badges.

Re-login progress renders in the reader's language, not the writer's. That worker runs in a detached thread with no request context, and its records are polled later by a different request. Storing a translated sentence would freeze the writer's locale, so the worker stores a message key and the polling endpoint renders it against the reader's Accept-Language.

The browser tab title renders from a client component. Static metadata is evaluated at build time and cannot read a client-side locale. An imperative document.title assignment loses a race with React reconciling the <title> element, on hydration and on every navigation. Rendering the element instead makes React the owner. The prerendered HTML still carries the Chinese title.

Backend locale comes from a pure ASGI middleware, not BaseHTTPMiddleware, so token-by-token SSE responses pay no per-chunk overhead.

If you do not want Vietnamese

The last two commits are Vietnamese-only. Drop them and you have a working Chinese-plus-English branch: I verified at that exact commit that npm run check:locales, npm run build and tsc --noEmit all pass with two locales.

What is deliberately still Chinese

Ten files under web/src hold Chinese outside the zh catalogs, all on purpose: the language self-name 中文; the fixed-language SKILL.md templates in the debug panel, which are meant to be shown side by side in Chinese and English; the AccountStatus wire values; comparison literals; and code comments. Python comments and log lines are untouched throughout.

Three things I would rather flag than hide

  1. settings/components/proxy-settings.tsx and proxy-settings-card.tsx are not imported anywhere. They were translated because they are part of the settings tree, which adds 108 catalog entries nothing renders, and is why a few proxy strings exist in triplicate. Happy to delete both components and their sections if you confirm they are dead.

  2. npm run check:locales now runs inside the Docker build, so a catalog mismatch fails the image build. The only workflow is tag-triggered, so that check first fires at release time. A small pull_request job running the same command would move the failure earlier. I did not add one because touching CI felt out of scope for a translation PR.

  3. docs/feature-status.en.md carries an .en suffix but is written in Chinese, and all three READMEs link to it. That predates this PR. An English reader following that link lands on a Chinese page. Renaming it to feature-status.md and adding real translations would fix it, but renaming one of your files inside a translation PR seemed presumptuous, so I left it and am asking instead.

Verification

  • npm run check:locales, npm run build, npx tsc --noEmit: clean.
  • Python suite: no new failures against the base commit.
  • Every fenced code block in the three READMEs is byte-identical, so commands, paths, environment variables and endpoint identifiers can be pasted from any of them.
  • Docker image builds successfully from this branch on a fork, with the locale check running inside it.

Translated catalogs drift silently: a key added to one locale file and
forgotten in another only surfaces at runtime as a missing string. Add a
script that flattens every locale directory under src/i18n/locales
against the zh reference (i18next plural suffixes like _one/_other are
stripped before comparing, since those differ per locale by design) and
fails on any missing key, unexpected key, or missing file.

The script discovers locale directories dynamically instead of hardcoding
a list, so it keeps working as locales are added or removed. Wire it into
the Docker image build ahead of the Next.js build so a drifted catalog
fails CI instead of shipping.
next.config.ts sets output: 'export', so there is no server to negotiate
the locale and next-intl would require prefix routing that changes every
URL. i18next runs in the browser instead and routing is untouched.

The instance is pinned to the zh fallback at init so the prerendered HTML
matches on hydration; the detected locale is applied after mount by
I18nProvider, which also renders the document title from the active
locale so it stays correct across route changes instead of racing the
static metadata title that next/head would otherwise own.

Only the common namespace exists yet; the feature screens add their own
namespaces to the resources map as they migrate.
… a language switcher

The attribute was hardcoded to zh-CN, so screen readers and browser page
translation misread every non-Chinese page. Unlike page content, this one
attribute can be fixed before paint, so it follows the existing
theme-script.tsx pattern: an inline script run from <head> reads the
stored choice and the navigator languages, in the same candidate order
detectLocale() uses on the client, and sets the attribute before React
hydrates. zh keeps the zh-CN region tag; en has no region variant here.

The switcher sits beside the theme toggle in the header and persists to
localStorage under the same naming convention. It reads its options from
SUPPORTED_LOCALES, so it grows with the catalog instead of needing a
matching edit here. Language names stay written in their own language so
a user stranded in an unreadable locale can still find their way back.
The aria-label goes on SelectTrigger, the actual focusable combobox
button, not on the SelectValue span nested inside it, which is never a
button's accessible name.

top-nav.tsx's nav items, role label and third-party-app dialog move onto
the common catalog added in the previous commit.
First namespaces through the new i18n pipeline: login and logs, plus
the small components several screens share (date range filter, image
lightbox, theme toggle, version dialog, version check hook). The zh
catalogs copy the original strings verbatim so nothing changes for
existing zh users.

request.ts sends the active i18n language as Accept-Language on every
request and renders its generic failure message through the catalog
too, so a backend error without a specific message still comes back in
the reader's language.
Add the image-manager and debug namespaces and wire them into
image-manager/page.tsx and the six panels under app/debug.

The debug skill panel's two SKILL.md templates (one Chinese, one
English) are fixed bilingual content that must keep rendering in their
own language regardless of the dashboard's UI locale, not strings that
should translate with it, so they are read via i18n.getFixedT against
their own locale file instead of the active-locale t().
Adds the image namespace and wires it into the image page shell and its
composer, results and sidebar components. zh copies the original
strings verbatim.
Adds the accounts namespace and wires it into the accounts page and the
account import dialog.

Account status arrives from the API as a Chinese literal
(正常/限流/异常/禁用) that backend business logic compares against
directly, so the wire value stays untouched. account-status.ts maps
each literal to a catalog key and only the display goes through it.
Adds the settings namespace, by far the largest catalog, and wires it
into every settings card (core config, API docs, user keys, proxy,
backup, WebDAV image storage, CPA pools, import browser, sub2api
connections, third-party apps) plus the page shell and the Zustand
store's toasts. This completes the web UI: no Chinese literal remains
outside the zh catalogs.

Endpoint paths, JSON field names and example curl payload content in
api-docs-card stay byte-identical across locales, since translating
them would hand a reader a command that fails when pasted.

store.ts calls i18n.t() directly with an explicit ns option instead of
useTranslation(), since it is a Zustand store rather than a component
and has no hook to call.
The backend receives real HTTP headers, so it can negotiate a locale
directly from Accept-Language instead of depending on the browser to
relay one. A ContextVar in the new utils/i18n module holds the current
request's locale; t(key) resolves against it and falls back to zh, so
callers that do not thread a locale through keep producing the exact
output they always have.

LocaleMiddleware is a plain ASGI callable rather than
@app.middleware("http") (BaseHTTPMiddleware), which spawns the
downstream app in a separate task and pumps every response chunk
through a zero-capacity anyio stream. The primary path here is
token-by-token SSE, so that would add two task hops per chunk of every
streamed response just to localise a handful of error strings.
LocaleMiddleware runs in the request's own task and reads
Accept-Language straight from the ASGI scope.

Only strings that actually reach a client are touched; every one of the
localised exception sites was traced to confirm it is reachable from a
request. Chinese comments and log lines are left alone to keep the
diff reviewable. A few backup_service.py sites are also reachable from
the backup scheduler thread, which has no request in scope and
correctly falls back to zh there.

re_login_accounts runs on a detached thread polled by a later, separate
request, so it cannot bake a translated sentence into its stored
progress: that would freeze the writer's locale regardless of who reads
it later. It now stores a message key plus an error_is_key marker, and
_render_relogin_results in api/accounts.py renders that key against the
polling request's own locale at read time. The AccountStatus wire
values in the same records (跳过/异常/...) stay untouched since backend
business logic compares against them directly; only the accompanying
error text is localised.

The admin session's display name and the default names given to newly
created auth keys also move onto the catalog: top-nav.tsx renders
`roleLabel · displayName`, so a non-zh session showed a translated role
badge next to a raw Chinese name.

Also fixes a pre-existing UnboundLocalError in re_login_accounts: a
local `t` (threading.Thread) bound later in the function body shadowed
the module-level `t` (utils.i18n.t) for the entire function, including
the earlier unknown-account branch, aborting the whole re-login batch
whenever it ran.
README.md keeps its Chinese content and stays the default; the only
change to it is the new language navigation line at the top. Commands,
paths, environment variables and endpoint identifiers are byte-identical
across both files, so a reader can paste from either one.
Adds the vi locale directory (all eight namespaces) and declares vi
as a supported locale everywhere the zh/en list was hardcoded:
locale-script.tsx's before-paint script, i18n/config.ts's
SUPPORTED_LOCALES and resources map, and utils/i18n.py's
SUPPORTED_LOCALES and MESSAGES catalog. The language switcher, the
Accept-Language negotiation and every consumer of SUPPORTED_LOCALES
already read this list generically, so no other code changes.

This is deliberately the first commit that depends on Vietnamese: the
zh+en state up to this point is a complete, working product on its
own, and everything from here to the end of the branch exists only to
add Vietnamese on top of it.
Translated from the Chinese original rather than from the English, so
the two translations do not compound each other's drift. Structure,
code blocks and every identifier match the other two files. The
language navigation line in README.md and README.en.md now links to
all three.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant