Web UI 通过一个服务 ctx.locale(@deepseek-ai/dsh-client-locale)实现本地化。它的翻译单元是命名空间 → 按语言字典,扩展点是每个模块合并进去的、带类型的 LocaleNamespaceMap——即 SlotMap 的翻译孪生。
目录结构
字典是扁平的 key → 模板字符串 映射;参数用 {name} 占位。内置两种语言 id:LOCALE_IDS = ['zh', 'en'],FALLBACK_LOCALE: LocaleId = 'zh'。一个命名空间的注册会一次性声明全部内置语言(双语对等性在注册时强制):
// package client/index.ts
locale.register(COMMON_NS, { zh, en })
locale.register(SETTINGS_NS, { zh: settingsZh, en: settingsEn })COMMON_NS = 'common' 是共享的跨功能词汇表。模块字典紧挨着各自的 React 代码——例如 packages/client/locale/src/locales/{en,zh}.ts、packages/client/ui-theme/src/client/locales.ts、packages/client/ui-settings-models/src/client/locales.ts——每个属主都把键集合合并进 LocaleNamespaceMap,从而在带类型注册处缺键/多键即为编译错误。
字典就是字面量 key → 模板 string 的对象。主题外观文案的一段典型摘录:
export const en = {
appearanceTitle: 'Appearance',
preferenceLabel: 'Theme',
light: 'Light', dark: 'Dark', system: 'Use system',
}locale 包自己合并两个命名空间:common(共享词汇)与 settings.locale(语言行的自有文案)。由于 LocaleDictOf<N> 是 Record<LocaleNamespaceMap[N] & string, string>,一个命名空间的键并集是其唯一事实来源:在 en 字典里打错一个键,注册调用会在编译期失败而非在运行时 UI 里出错。
两个字典面:运行时 vs 文档
同一个「locale」一词横跨两种截然不同的人工产物:
| 产物 | 位置 | 内容 |
|---|---|---|
| 运行时字典 | packages/*/src/**/locales.ts + LocaleNamespaceMap | UI 调用 t(key) 用的活的 key → template 字符串 |
*.i18n.yaml 配对记录 | 仓库根 + 各包 | 成对文档 EN/ZH 的一致性哈希,而非文本 |
很容易误以为 README.i18n.yaml 装着译文——它装的是哈希,实际文字居于并排的 README.md / README.zh.md。见下文 .i18n.yaml 约定。
LocaleRuntime
LocaleRuntime(packages/client/locale/src/client/index.ts)持有一张 Map<namespace, Map<locale, dict>> 与单调递增的 revision。其按键查找链(translate):
- 入口命名空间在当前语言下的字典,
- 该命名空间的 zh 兜底,
- 共享
common命名空间(当前语言,再 zh), - 原始键本身——缺失文案保持可见(在 UI 里响亮失败而非留白),
- 然后做
{name}模板替换。
private translate(ns, key, params?) {
const template = this.lookup(ns, key)
?? (ns !== COMMON_NS ? this.lookup(COMMON_NS, key) : undefined)
?? key
if (!params) return template
return template.replace(/\{(\w+)\}/g, (m, name) =>
name in params ? String(params[name]) : m)
}bind(ns) 返回稳定的 Translate(已记忆化,因此可骑乘 inject 面);register 递增 revision,让已挂载的出口捕获迟到的字典。
语言解析
resolveInitialLocale() 在构造时运行:浏览器语言优先(detectBrowserLocale() 遍历 navigator.languages 再 navigator.language,按主子标签匹配,故 zh-Hans-CN → zh),否则用 FALLBACK_LOCALE(zh)。显式宿主选择随后可能覆盖它:宿主插件注册持久化的 locale.preference 设置区(LocaleSettingsSchema,对两种 id 求 union 的 schemastery schema,可选),客户端通过 SettingsScope<LocaleSettings> 绑定它;当存储的 preference 不同时 adopt() 会切换当前语言。写入一律经 setLocale(id),其会通过 host.set(LOCALE_PREFERENCE_FIELD, id) 持久化。
navigator.languages → detectBrowserLocale() → 临时语言(默认 zh)
└─ 宿主设置区 locale.preference → adopt() 覆盖
└─ LocaleRuntime.provisional / active → 以 LocaleSnapshot 发布切换语言时仅在真正的当前语言变化时发出 ctx.emit('locale/change', snapshot)——字典注册不会轰炸该事件(它们改为递增 LocaleFace 的 revision)。该设置区还承载 ui-theme 的外观行,因此语言与主题都堆在同一设置 General 栈里。
渲染器 LocaleFace 与 t 座
服务本身就是 LocaleFace(bind + getSnapshot/subscribe),只安装一次:
// locale client apply
ctx.slots.installLocale(locale)装上并激活某 LocaleFace 后,任何在注册时声明了 locale: 命名空间的 slot 注册,都会在组件 props 上得到一个框架合成的 t 座,其类型指向该命名空间的字典并集加上共享 common 词汇表(TranslateNS<N>)。当前语言随后自动跟随——组件调用 props.t('key', {name}),并在 LocaleFace revision 变化时重渲染。
语言偏好行
locale 功能拥有自己的设置界面,正如 ui-theme 拥有外观。apply(locale client)按顺序做三件事:
- 绑定持久化作用域:
ctx.settingsScope.bind<LocaleSettings>({ namespace: LOCALE_SETTINGS_NAMESPACE }), - 创建
LocaleRuntime并注册基础字典, - 把服务安装为 LocaleFace,并把语言行贡献进
settings.general.item:
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
name: 'settings.general.item',
id: 'language',
order: 0,
store,
locale: SETTINGS_NS, // → 框架合成的 `t` 座
inject: injected, // → { setLocale } 业务面
}, LanguageRow))LanguageRow.tsx / createLanguageRowStore 持有一份 { active, locales, revision } 快照;注入的 setLocale 代理到 locale.setLocale(id)。LOCALE_IDS 为 ['zh', 'en'],行内每个选项的 label 是该种语言的自描述(中文 / English)——因此切换器在知道当前语言之前就可用了。行订阅 locale/change 以重新同步当前值与 revision。
每包字典、每功能命名空间
由于跨插件协作只经服务(客户端 bundle 纯净门),每个包都只触及 ctx.locale、不导入任何东西;它只调用 register/bind。命名空间身份保证字典按命名空间隔离;两个功能即便键相同也能共存而不撞车,但带类型的范式是每个功能一个命名空间:
| 功能 | 命名空间 | 字典文件 | 注册处 |
|---|---|---|---|
| Locale(共享 + 语言行) | common、settings.locale | packages/client/locale/src/locales/{en,zh}.ts | locale apply |
| 主题外观 | settings.theme | packages/client/ui-theme/src/client/locales.ts | ui-theme apply |
| 设置 General 壳 + chrome | settings | packages/client/ui-settings-general/src/client/locales.ts | ui-settings-general |
| 提供商编辑(Models 页) | settings.models | packages/client/ui-settings-models/src/client/locales.ts | ui-settings-models |
注册一个尚未并入 LocaleNamespaceMap 的命名空间时用第二个、不带类型的 register 重载;带类型注册是已发布组合里的常态。
由于行内没有任何自身的翻译标签——每个选项的 label 都是该语言的自描述(中文 / English)——切换器在获知当前语言之前仍可用。apps/web/index.html 静态声明了 lang="zh-CN",为打包页面与辅助技术提供首屏语言提示。
.i18n.yaml 约定(文档配对)
切勿把运行时字典与仓库的 *.i18n.yaml 文件(CONTRIBUTING.i18n.yaml、README.i18n.yaml,以及各包 README.i18n.yaml)混为一谈。后者是双语配对一致性记录,而非翻译载荷:每条记录某个成对文档的英文与中文两端在「最后一次确认一致」时的 git blob 哈希,供 pnpm run verify-translation-pairing 检测漂移。文档本身是以 EN/ZH 文件对编辑的(例如 README.md / README.zh.md、packages/client/locale/README.md / README.zh.md)。
Web shell 的路由硬件本身也双语:apps/web/index.html 在该固定版本上声明了 lang="zh-CN"。
本页涉及的包
| 包 |
|---|
@deepseek-ai/dsh-client-locale |
@deepseek-ai/dsh-client-ui-theme |
@deepseek-ai/dsh-client-ui-slots |
@deepseek-ai/dsh-client-ui-settings |
@deepseek-ai/dsh-client-web |
延伸阅读
- 前端:UI 模块 —
LocaleNamespaceMap如何合并、t座如何接入 slot。 - 前端:Web 前端 —
installLocale、LocaleFace 与启动链。 - 前端:Schema 表单 — locale 与 theme 两行都要绑定的设置作用域。
packages/client/locale/src/client/index.ts—LocaleRuntime、translate、resolveInitialLocale。packages/client/locale/src/locales/{en,zh}.ts— 内置的common与settings.locale字典。- 仓库根
README.i18n.yaml与CONTRIBUTING.i18n.yaml— 配对记录格式。