Skip to content

Web UI 通过一个服务 ctx.locale@deepseek-ai/dsh-client-locale)实现本地化。它的翻译单元是命名空间 → 按语言字典,扩展点是每个模块合并进去的、带类型的 LocaleNamespaceMap——即 SlotMap 的翻译孪生。

目录结构

字典是扁平的 key → 模板字符串 映射;参数用 {name} 占位。内置两种语言 id:LOCALE_IDS = ['zh', 'en']FALLBACK_LOCALE: LocaleId = 'zh'。一个命名空间的注册会一次性声明全部内置语言(双语对等性在注册时强制):

ts
// 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}.tspackages/client/ui-theme/src/client/locales.tspackages/client/ui-settings-models/src/client/locales.ts——每个属主都把键集合合并进 LocaleNamespaceMap,从而在带类型注册处缺键/多键即为编译错误。

字典就是字面量 key → 模板 string 的对象。主题外观文案的一段典型摘录:

ts
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 + LocaleNamespaceMapUI 调用 t(key) 用的活的 key → template 字符串
*.i18n.yaml 配对记录仓库根 + 各包成对文档 EN/ZH 的一致性哈希,而非文本

很容易误以为 README.i18n.yaml 装着译文——它装的是哈希,实际文字居于并排的 README.md / README.zh.md。见下文 .i18n.yaml 约定

LocaleRuntime

LocaleRuntimepackages/client/locale/src/client/index.ts)持有一张 Map<namespace, Map<locale, dict>> 与单调递增的 revision。其按键查找链(translate):

  1. 入口命名空间在当前语言下的字典,
  2. 该命名空间的 zh 兜底,
  3. 共享 common 命名空间(当前语言,再 zh),
  4. 原始键本身——缺失文案保持可见(在 UI 里响亮失败而非留白),
  5. 然后做 {name} 模板替换。
ts
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.languagesnavigator.language,按主子标签匹配,故 zh-Hans-CN → zh),否则用 FALLBACK_LOCALEzh)。显式宿主选择随后可能覆盖它:宿主插件注册持久化的 locale.preference 设置区(LocaleSettingsSchema,对两种 id 求 union 的 schemastery schema,可选),客户端通过 SettingsScope<LocaleSettings> 绑定它;当存储的 preference 不同时 adopt() 会切换当前语言。写入一律经 setLocale(id),其会通过 host.set(LOCALE_PREFERENCE_FIELD, id) 持久化。

txt
navigator.languages → detectBrowserLocale() → 临时语言(默认 zh)
   └─ 宿主设置区 locale.preference → adopt() 覆盖
   └─ LocaleRuntime.provisional / active  → 以 LocaleSnapshot 发布

切换语言时仅在真正的当前语言变化时发出 ctx.emit('locale/change', snapshot)——字典注册不会轰炸该事件(它们改为递增 LocaleFace 的 revision)。该设置区还承载 ui-theme 的外观行,因此语言与主题都堆在同一设置 General 栈里。

渲染器 LocaleFace 与 t

服务本身就是 LocaleFacebind + getSnapshot/subscribe),只安装一次:

ts
// 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)按顺序做三件事:

  1. 绑定持久化作用域:ctx.settingsScope.bind<LocaleSettings>({ namespace: LOCALE_SETTINGS_NAMESPACE })
  2. 创建 LocaleRuntime 并注册基础字典,
  3. 把服务安装为 LocaleFace,并把语言行贡献进 settings.general.item
ts
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(共享 + 语言行)commonsettings.localepackages/client/locale/src/locales/{en,zh}.tslocale apply
主题外观settings.themepackages/client/ui-theme/src/client/locales.tsui-theme apply
设置 General 壳 + chromesettingspackages/client/ui-settings-general/src/client/locales.tsui-settings-general
提供商编辑(Models 页)settings.modelspackages/client/ui-settings-models/src/client/locales.tsui-settings-models

注册一个尚未并入 LocaleNamespaceMap 的命名空间时用第二个、不带类型的 register 重载;带类型注册是已发布组合里的常态。

由于行内没有任何自身的翻译标签——每个选项的 label 都是该语言的自描述(中文 / English)——切换器在获知当前语言之前仍可用。apps/web/index.html 静态声明了 lang="zh-CN",为打包页面与辅助技术提供首屏语言提示。

.i18n.yaml 约定(文档配对)

切勿把运行时字典与仓库的 *.i18n.yaml 文件(CONTRIBUTING.i18n.yamlREADME.i18n.yaml,以及各包 README.i18n.yaml)混为一谈。后者是双语配对一致性记录,而非翻译载荷:每条记录某个成对文档的英文与中文两端在「最后一次确认一致」时的 git blob 哈希,供 pnpm run verify-translation-pairing 检测漂移。文档本身是以 EN/ZH 文件对编辑的(例如 README.md / README.zh.mdpackages/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.tsLocaleRuntimetranslateresolveInitialLocale
  • packages/client/locale/src/locales/{en,zh}.ts — 内置的 commonsettings.locale 字典。
  • 仓库根 README.i18n.yamlCONTRIBUTING.i18n.yaml — 配对记录格式。