Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,9 @@ notes. Entries describe what users experience, not internal refactors.
detector is no longer shipped.
- The local history page no longer downloads the editor to show its list
(122 KB instead of 148 KB compressed).
- The editor and history pages load only the interface language you use rather
than all seven, so the editor starts on 105 KB instead of 162 KB compressed,
and the history page on 61 KB instead of 121 KB.
- Voice controls stay in place while recording or finishing. Explanations appear
without moving the draft, and cancelling preserves the text typed before dictation.
- **Configured assistants reopen in chat.** Enable a downloaded model or reconnect
Expand Down
7 changes: 5 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,10 @@ packages/ # pnpm workspace,供 ran 生态三处站点共享(包
src/i18n/messages/ # 一个语言一个文件:zh-CN / en / ja / ko / de / es / pt,
# 七张表都是完整的(编辑器 UI 语言另由 vendor 45 语言包提供)。
# 2026-09-12 从 1263 行的单文件拆开:加一条文案是改七个文件而
# 不是同一个文件的七处,加一种语言就是加一个文件
# 不是同一个文件的七处,加一种语言就是加一个文件(外加 i18n.ts
# `loaders` 一行)。启动包里只有 en;当前语言由 i18n.ts 顶层 await
# 按需加载,所以 `t()` 仍是同步的——别改回静态 import,也别改成
# 入口里显式 init(见 docs/explorations/2026-10-24-active-locale-only.md)
converter/ # 格式转换(2026-09-12 从 1130 行的单文件拆开,公开导出面不变)
document-converter.ts # X2TConverter 本体:加载、转换、媒体、退出码分类
x2t-loading.ts # canStreamWasm / fetchWasmResponse(含重试)/ x2tInstantiateError
Expand Down Expand Up @@ -639,7 +642,7 @@ docs/explorations/2026-08-19-ci-e2e-sharding.md。
类型决定**——需要横向比较属性的表格/卡片网格用宽版,竖向找目标的列表用读式
(`/history` 就是后者:按"它是列表页"归到 1152 时实测每行有 236px 死空隙);
**正文行宽 45–75 字符**(用 `max-width: 60–72ch` 限制段落,不是限制容器)、
**内容页有顶栏+页脚,应用页有顶栏无页脚**(`/history`、`/embed-demo` 属后者)、
**内容页有顶栏 + 页脚,应用页有顶栏无页脚**(`/history`、`/embed-demo` 属后者)、
**顶栏页脚只有一份样式**(`landing.css`,页面复用其类名而不是抄数值)。取值与业界依据见
[docs/design-system.md](docs/design-system.md),`node bin/design-audit.mjs <baseUrl>`
渲染所有页面打印这些数字,`test/unit/design-contract.test.ts` 把宽度与顶栏钉死。
Expand Down
131 changes: 131 additions & 0 deletions docs/explorations/2026-10-24-active-locale-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# Startup payload, round three: only the active message table

Follow-up to `2026-10-24-jschardet-and-history-payload.md`. After that round,
the largest item in the startup graph of both `/editor` and `/history` was the
same: every message table in `packages/shared/src/i18n/messages/`.

## What it cost

`packages/shared/src/i18n.ts` imported all seven tables statically, so they
shared one chunk (`utils-*.js`, 289 KB) with the rest of `@ranuts/shared` and
`ranuts`. That chunk is in the static graph of both pages.

Bytes attributed through the sourcemaps (build of PR #292's branch, `39ae686f`):

| Table | Minified bytes |
| ----- | -------------- |
| zh-CN | 29.4 KB |
| en | 30.4 KB |
| ja | 38.9 KB |
| ko | 35.3 KB |
| de | 34.3 KB |
| es | 33.1 KB |
| pt | 32.9 KB |
| Total | 234 KB |

Gzipped, that is about 73 KB: 45% of the editor's 162 KB and 60% of the
history page's 121 KB. A visitor reads one of them.

## Design

Two options were considered:

- **A (chosen):** English stays in the bundle, because it is the per-key
fallback for every other table. The other six become chunks loaded through a
literal `import()` per locale, and `i18n.ts` awaits the active one at module
top level.
- **B:** the same split, but each entry calls `await initI18n()` before it
renders.

The network cost is the same: one extra request after the shared chunk runs,
for anyone not reading English. A is easier to maintain. Under ES module
semantics, nothing that imports `i18n.ts` runs before that top-level await
resolves, so `t()` stays synchronous and correct. None of the 34 files that
call it changed. With B, any `t()` call that runs before `initI18n()` (module
scope, import-time code, a future third entry) returns English and reports no
error, and tests run in English, so they would miss it.

Details:

- `loadLocale(lang)` is exported. It is a no-op for English or a loaded table,
and it rejects when the chunk cannot be fetched.
- If loading the active table fails, the module still resolves. The shell then
reads in English. A failed chunk must not keep the editor from starting.
- `setLanguage` has no production callers (only tests). It still switches
synchronously. If the table is not loaded yet, strings read in English until
it arrives, and then `languagechange` fires a second time.
- Each locale's loader is a literal `import('./i18n/messages/<file>')`, so the
tsc-built `dist` and Rollup both see it. `Record<Exclude<Language, EN>, …>`
makes a missing loader a type error. Adding a language is one more line
there, next to the existing `LanguageCode` / `SHELL_LOCALES` edits.
- `@ranuts/shared` is not published to npm (registry 404) and chaxus/ran does
not import it, so the change does not affect anyone outside this repo.
- Offline still works. `bin/offline-assets.mjs` follows `dynamicImports` into
the core precache, and a stamped `sw.js` lists all six locale chunks
(checked on this build).
- `getMessages(lang)` for a table that is not loaded now returns English.
Only one caller depended on the old behavior. A fresh-context review of this
diff found it: `displayError` (`lib/agent-plugin/ui/presentation.ts`) checks
error text saved in restored assistant history against every language's
table. With only the page's table loaded, an error saved in Japanese and
reopened on a German page would show `agentRequestFailed`. The assistant is
not in the startup graph, so `presentation.ts` now awaits all tables at its
own top level (`Promise.allSettled`, so a failed chunk does not block the
panel). That costs about 65 KB gzip, and only when the assistant opens.
`loadLocale` keeps the in-flight promise, so concurrent calls share one load.
- Unit tests that switch languages synchronously preload every table first
(`test/unit/lib/locales.ts`). Tests on a fresh module (`vi.resetModules`)
load only the active table, the same as a real page.

## Measurements

The static startup graph, summed per chunk from `.vite/manifest.json`
(gzip level 9):

| Page | Before (`39ae686f`) | After |
| ---------- | ------------------------ | ------------------------ |
| `/editor` | 537 KB raw / 162 KB gzip | 337 KB raw / 105 KB gzip |
| `/history` | 400 KB raw / 121 KB gzip | 198 KB raw / 61 KB gzip |

The shared chunk drops from 289 KB to 86 KB. Of the 234 KB of tables, 30 KB
(English) remains. A non-English visitor also fetches one locale chunk of
29–39 KB raw, about 11 KB gzip, so their net saving is about 46 KB gzip on the
editor and about 49 KB gzip on history.

Rolldown also splits the startup graph into more files (editor 6 → 18, history
7 → 15). Total bytes are consistent with removing the tables:
537 − 234 + 30 ≈ 337. Vite preloads all of them in parallel through
`modulepreload` links.

Remaining items in the startup graph that belong to the ran ecosystem (the full
MIME table in `ranuts/utils/mimeType.js`, about 6.8 KB gzip on both pages, and
`@alixex/ranview`'s `mocks` chunk) were handed to a separate session for a fix
in chaxus/ran.

## Tests

- `test/e2e/editor-startup-payload.spec.ts`: neither page's startup scripts
contain a string from the ja or de table. `/history?locale=de` renders its
heading in German without a reload. The heading is rendered once, so a table
that arrived late would leave it in English.
- `test/unit/i18n.test.ts`: a fresh module opened with `?locale=ja` reads
Japanese as soon as the import resolves. Switching to an unloaded table reads
English and then announces the translation. A failing chunk leaves the page
in English and makes `loadLocale` reject.

Reverse validation, run against two variants:

1. With the static imports of `i18n.ts` restored, both startup-payload
cases fail: `ja message table is in …/assets/utils-*.js`.
2. With the loaders kept but the top-level await replaced by a fire-and-forget
`void loadLocale(…)` (what option B looks like when an entry forgets to
await), the unit case "reads Japanese as soon as the module resolves" fails.
The E2E heading case also fails, with `Received: "Saved documents"`.

3. Without the top-level await in `presentation.ts`, the new
`agent-presentation` case fails. That case is a fresh module on
`?locale=de` with no preload, given a Japanese quota error.

With the fix restored, all of them pass again: 252 unit files (6805 tests),
`lint:ts`, and 43 E2E cases (startup payload, editor locales, history page,
assistant history and locale and offline assets).
6 changes: 5 additions & 1 deletion lib/agent-plugin/ui/presentation.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { requestErrorKey } from './request-error';
import { isDocumentToolName } from '../document-tool-plan';
import { t, getLanguage, i18n, SHELL_LOCALES } from '@ranuts/shared/i18n';
import { t, getLanguage, i18n, loadLocale, SHELL_LOCALES } from '@ranuts/shared/i18n';
import type { AgentEvent, AgentRunResult } from '@ranuts/agent-core/runtime';

// `displayError` recognises error text saved in any shell language (restored
// history), so the assistant holds every table, not only the page's one.
await Promise.allSettled(SHELL_LOCALES.map(loadLocale));

/** Count host-verified mutations, never read results or a model's completion prose. */
export function isVerifiedDocumentChange(event: AgentEvent): boolean {
if (event.type !== 'tool_result' || event.isError || event.readOnlyHint !== false) return false;
Expand Down
91 changes: 61 additions & 30 deletions packages/shared/src/i18n.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
import type { I18nMessages } from './i18n/types';
import { zhCN } from './i18n/messages/zh-CN';
import { en } from './i18n/messages/en';
import { ja } from './i18n/messages/ja';
import { ko } from './i18n/messages/ko';
import { de } from './i18n/messages/de';
import { es } from './i18n/messages/es';
import { pt } from './i18n/messages/pt';
import { getAllQueryString, getCookie, localStorageGetItem, localStorageSetItem } from 'ranuts/utils';

/**
Expand Down Expand Up @@ -193,29 +187,48 @@ export function resolveEditorLocale(tag: string | null | undefined): string | nu
export type { I18nMessages } from './i18n/types';

/**
* Which languages promise a complete table.
* English is the only table in the startup bundle: it is the per-key fallback
* every other locale needs anyway. The other six are separate chunks, and the
* module awaits the active one at the bottom of this file, so nothing that
* imports `t()` runs before its table is in place.
*
* en and zh-CN are the reference pair: every key exists in both, so a new UI
* string is a two-language change rather than an eight-language one, and a
* missing translation elsewhere shows in English rather than as a raw key.
* The rest are complete too today; the type stays Partial so that staying
* complete is a choice the i18n tests enforce rather than one the compiler
* makes impossible to relax.
* en and zh-CN are the reference pair: every key exists in both (both files
* are typed `I18nMessages`), so a new UI string is a two-language change, and
* a missing translation elsewhere shows in English rather than as a raw key.
*/
const completeMessages: Record<LanguageCode.ZH | LanguageCode.EN, I18nMessages> = {
[LanguageCode.ZH]: zhCN,
[LanguageCode.EN]: en,
const loaders: Record<Exclude<Language, LanguageCode.EN>, () => Promise<Partial<I18nMessages>>> = {
[LanguageCode.ZH]: () => import('./i18n/messages/zh-CN').then((m) => m.zhCN),
[LanguageCode.JA]: () => import('./i18n/messages/ja').then((m) => m.ja),
[LanguageCode.KO]: () => import('./i18n/messages/ko').then((m) => m.ko),
[LanguageCode.DE]: () => import('./i18n/messages/de').then((m) => m.de),
[LanguageCode.ES]: () => import('./i18n/messages/es').then((m) => m.es),
[LanguageCode.PT]: () => import('./i18n/messages/pt').then((m) => m.pt),
};

const partialMessages: Record<Exclude<Language, LanguageCode.ZH | LanguageCode.EN>, Partial<I18nMessages>> = {
[LanguageCode.JA]: ja,
[LanguageCode.KO]: ko,
[LanguageCode.DE]: de,
[LanguageCode.ES]: es,
[LanguageCode.PT]: pt,
};
const messages: Partial<Record<Language, Partial<I18nMessages>>> = { [LanguageCode.EN]: en };
const pending = new Map<Language, Promise<void>>();

const messages: Record<Language, Partial<I18nMessages>> = { ...completeMessages, ...partialMessages };
/**
* Fetch a locale's table; a no-op once it is loaded. Rejects when the chunk
* cannot be fetched. Until then `getMessages(lang)` and `t()` read English.
*/
export function loadLocale(lang: Language): Promise<void> {
if (messages[lang] || lang === LanguageCode.EN) return Promise.resolve();
let load = pending.get(lang);
if (!load) {
load = loaders[lang]().then(
(table) => {
messages[lang] = table;
},
(error: unknown) => {
pending.delete(lang);
throw error;
},
);
pending.set(lang, load);
}
return load;
}

/**
* Values for a message's `{name}` placeholders.
Expand Down Expand Up @@ -289,35 +302,45 @@ class I18n {
}

/**
* Set language
* Set language. Takes effect at once; strings of a table that is not loaded
* yet read in English until it arrives, then `languagechange` fires again.
*/
setLanguage(lang: Language): void {
if ((SHELL_LOCALES as readonly string[]).includes(lang)) {
if (!messages[lang] && !pending.has(lang)) {
void loadLocale(lang).then(
() => this.currentLanguage === lang && this.announce(lang),
() => undefined,
);
}
this.currentLanguage = lang;
// An explicit shell choice also decides the editor language (falling back
// to English when the vendor ships no locale for it, e.g. fa).
this.editorLocale = resolveEditorLocale(lang) || OnlyOfficeLanguageCode.EN;
localStorageSetItem('document-lang', lang);
// Trigger language change event
// eslint-disable-next-line n/no-unsupported-features/node-builtins
window.dispatchEvent(new CustomEvent('languagechange', { detail: { language: lang } }));
this.announce(lang);
}
}

private announce(lang: Language): void {
// eslint-disable-next-line n/no-unsupported-features/node-builtins
window.dispatchEvent(new CustomEvent('languagechange', { detail: { language: lang } }));
}

/**
* Get translated text
*/
t(key: keyof I18nMessages, vars?: TemplateVars): string {
// Partial locales fall back to English key by key (see `messages`).
const text = messages[this.currentLanguage]?.[key] || completeMessages[LanguageCode.EN][key] || key;
const text = messages[this.currentLanguage]?.[key] || en[key] || key;
return vars ? interpolate(text, vars) : text;
}

/**
* Get all messages
*/
getMessages(language: Language = this.currentLanguage): I18nMessages {
return { ...completeMessages[LanguageCode.EN], ...messages[language] };
return { ...en, ...messages[language] };
}

/**
Expand All @@ -336,6 +359,14 @@ class I18n {
// Export singleton
export const i18n = new I18n();

// Hold every importer until the active table is in. A chunk that fails to load
// must not keep the page from starting: the shell then reads in English.
try {
await loadLocale(i18n.getLanguage());
} catch {
// English is already in place.
}

// Export convenience functions
export const t = (key: keyof I18nMessages, vars?: TemplateVars): string => i18n.t(key, vars);
export const getLanguage = (): Language => i18n.getLanguage();
Expand Down
23 changes: 19 additions & 4 deletions test/e2e/editor-startup-payload.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ const JSCHARDET = { owner: 'jschardet', probe: 'windows-1255' };
// An error the save pipeline (lib/onlyoffice/save-stream.ts) raises: the
// history page lists and reopens records, it never saves through the editor.
const EDITOR_STACK = { owner: 'editor save pipeline', probe: 'Save conversion failed' };
// Two message tables the page does not speak: only English and the active
// locale belong at startup (packages/shared/src/i18n.ts awaits the latter).
const OTHER_LOCALES = [
{ owner: 'ja message table', probe: 'ドキュメントを開く / 編集' },
{ owner: 'de message table', probe: 'Dokument öffnen / bearbeiten' },
];

/** Vite writes an entry and its whole static import graph into the HTML as the
* module script plus modulepreload links: that is the startup payload. */
Expand All @@ -37,14 +43,23 @@ async function expectAbsent(page: Page, deferred: Array<{ owner: string; probe:
}
}

test('the editor startup scripts leave the agent tools, the PWA prompt and jschardet out', async ({ page }) => {
test('the editor startup scripts leave the agent tools, the PWA prompt, jschardet and other locales out', async ({
page,
}) => {
await page.goto('/editor?new=docx');
await expectAbsent(page, [AGENT_TOOLS, PWA_INSTALL, JSCHARDET]);
await expectAbsent(page, [AGENT_TOOLS, PWA_INSTALL, JSCHARDET, ...OTHER_LOCALES]);
});

test('the history page startup scripts leave the editor stack and jschardet out', async ({ page }) => {
test('the history page startup scripts leave the editor stack, jschardet and other locales out', async ({ page }) => {
await page.goto('/history');
await expectAbsent(page, [EDITOR_STACK, JSCHARDET]);
await expectAbsent(page, [EDITOR_STACK, JSCHARDET, ...OTHER_LOCALES]);
});

test('a page opened in another language renders that language without reloading', async ({ page }) => {
await page.goto('/history?locale=de');
await expect(page.locator('html')).toHaveAttribute('lang', 'de');
// Rendered once, at startup: a table that arrived late would leave it English.
await expect(page.locator('h1.history-title')).toHaveText('Gespeicherte Dokumente');
});

test('the PWA install element is still defined and mounted after startup', async ({ page }) => {
Expand Down
3 changes: 3 additions & 0 deletions test/unit/agent-panel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { ChatView } from '@ranuts/chat-ui';
import type { EditorApi } from '../../lib/agent-plugin/editor-bridge';
import { afterEach, expect, it, vi } from 'vitest';
import { createAgentPanel, setAgentPanelOpen, setAgentPanelEnabled } from '../../lib/agent-plugin/ui/panel';
import { loadAllLocales } from './lib/locales';

await loadAllLocales();
HTMLElement.prototype.scrollTo = vi.fn();
const editorState = vi.hoisted(() => ({ api: null as null | Partial<EditorApi> }));
vi.mock('@ranuts/agent-core/llm/local', () => ({
Expand Down
Loading
Loading