deepseek-harness/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md

9.4 KiB
Raw Blame History

Agent Note: 全新浏览器打开的设置语言由浏览器决定

Status: implemented

English | 中文

Problem

设置里的语言行在每一次首访时都以中文开场:LocaleRuntime 从 localStorage 读取 dsh.locale,读不到就直接回落到 zh。浏览器本已声明其使用者阅读哪些语言——navigator.languages 就是这份声明——而应用对此视而不见,于是英文读者迎面撞上一个中文产品,还得先找到一行中文标签的设置项才能脱身。回落值当时同时承担两份职责:既是无法解析出 locale 时的最后兜底,也是所有从未做过选择的用户拿到的答案。

读取浏览器可以让浏览器声明了已注册语言的读者获得对应界面,但当当前目录没有匹配项时,产品仍需一个稳定的最终选择。若目录中只有内置语言,请求既非 zh 也非 en 的浏览器(fr、de)就会进入这种情形,而这些读者恰恰最不可能阅读中文。

Decision

暂定 locale 先经浏览器、再经 FALLBACK_LOCALE(en)解析;显式 Host 偏好会实时替换它。 packages/client/locale/src/client/index.ts 中的 resolveInitialLocale() 在服务构造时和每次语言目录变化后运行,依据当时已注册的定义表达浏览器/回落顺序。随后,非阻塞 settings 生命周期会应用 $DSH_HOME/settings.yaml 中可选的 locale.preference;若该值缺失,则继续使用由浏览器派生的值;若已保存的 id 暂不可用,则保留待采用状态,并在对应语言注册后生效。

开场时的最终回落与字典链终点共用一个常量。 FALLBACK_LOCALE 同时回答「浏览器未声明任何已注册语言时,界面以哪种语言开场」与「每条已声明的字典 fallback 链必须在哪里结束」。这是两个不同的问题,若其中任一答案必须不同,拆成两个常量才是对的。外部语言可以贡献不完整字典并声明中间 fallback,但每条链最终仍到达 en。每一对内置 zh/en 字典都声明完全相同的 key 集合,因此最后一次回落能够解析;scripts/locale-dictionary-parity.spec.ts 会拒绝只加在内置一侧的 key,避免它日后在运行中的界面里显现为形如 list.aria 的裸 key。

浏览器匹配使用已注册目录和浏览器的有序列表。 detectBrowserLocale() 遍历 [...(navigator.languages ?? []), navigator.language]。每个浏览器标签先精确匹配已注册 id,再按主子标签匹配,因此已注册的 pt-BR 会响应同名请求;zh-Hans-CN 与未精确命中的 zh-TW 会落到内置 zh,en-GB 会落到 en。若浏览器只请求未注册语言(在只有内置目录时如 fr、de),匹配不会产生结果,并由 FALLBACK_LOCALE 接管。语言注册或移除时会重新计算这一暂定结果。navigator.language 排在列表之后,并兜住那些 Navigator 上没有 languages 的宿主;容忍该运行时缺失与 localStorage 守卫表达的环境边界不信任同源。

判定浏览器用的是 window 而非 navigator。 Node ≥ 21 暴露全局 navigator 并报告机器自身语言,因此以 navigator 把关会让 node 启动客户端树时解析成机器语言,而非文档约定的回落值。以 window 把关可使所有非浏览器运行都停留在 FALLBACK_LOCALE。

显式选择具有持久性。 setLocale 通过 Host settings API 写入,因此选过语言的用户可在共享同一 DSH home 的不同浏览器 origin 与系统语言之间保留原选择。没有任何代码把探测到的 locale 写回:探测在每次启动时重新推导,对「用户是否做过选择」这一问题始终不可见。

<html lang> 跟随解析出的 locale,而所服务的 markup 做不到这一点。 apps/web/index.html 是一份静态文件,服务所有访问者,因此它声明什么都必然对某些人是错的:解析发生在客户端,在文档被解析之后。于是由 locale 插件依据当前 locale 设置 document.documentElement.lang——激活时设置一次,因为探测结果或已采纳的 Host 偏好可能已与 markup 不一致;此后每次切换再设置一次。markup 声明产品默认值(en),使启动前的文档不至于主动误导。无障碍技术与浏览器功能(发音规则、翻译提示、字体回退、拼写检查)都读取该属性,因此陈旧的值是在误报文档语言,而不只是看起来不整齐。外部语言 id 本身就是 BCP 47 标签,会原样进入该属性;内置 zh 简写是唯一例外,它声明为 zh-CN,因为单独的 zh 会使文字(script)含义不明。

浏览器 e2e 车道固定浏览器语言。 断言中文文案的场景(access-confirmation、models-settings、onboarding-deepseek-config、settings-chrome)以 apps/web/tests/support.ts 的 locale: ZH_BROWSER_LOCALE 打开页面;newEnglishPage 声明 en-US。settings-chrome.e2e.ts 两次使用没有显式 locale 的全新 Host home:en-US 浏览器与 fr-FR 浏览器都会抵达英文界面。真正钉住回落值的是 fr-FR 那个场景——en-US 浏览器无论走探测还是走回落都会落在英文,因此只有本应用不提供的语言才能区分二者,而中文场景则证明探测仍然覆盖回落值。

Alternatives considered

  • Intl.DateTimeFormat().resolvedOptions().locale 或单读 navigator.language:两者都把用户的有序偏好列表塌缩成一个标签,于是 ['de', 'en', 'zh'] 的读者拿到的是 zh 而非 en。列表恰恰是浏览器这份声明里最值得读的部分。
  • 首次启动即持久化探测结果:那会把探测变成一次性事件,让一次陈旧的首访凌驾于此后改变的浏览器语言之上,也摧毁了整个解析顺序所依赖的区分——存储值将不再意味着「用户选了它」。
  • 完整的 BCP 47 协商(Intl.LocaleMatcher 式查找、地区与文字权重):语言注册会提供明确的 id,字典 fallback 也有独立的显式配置。先精确匹配 id、再匹配主子标签,既保留了内置行为,也无需在外部注册的变体之间虚构隐式距离策略。
  • 为回落 locale 增加一个 Cordis 配置键:此处部署之间并无差异——回落值是产品对「完全没有信号」给出的答案,不是旋钮。仓库策略把 Config 字段留给有当前消费方、且随部署变化的选择。
  • 拆成两个常量,一个管开场 locale、一个管字典回落:它区分了两个确实不同的问题,若两个答案不同也确有必要。但它们并不不同:字典是对称的,因此两者都是 en,第二个常量只会是同一个值的两个名字,外加一条无人强制的规则。对称性本身值得强制,所以直接为它设门禁。
  • 开场用 en、字典回落仍保留 zh:这看起来是保守选择,但在字典对称的前提下,它能解析的 key 与 en 完全相同,因此毫无收益;而在它真正会起作用的情形——某个 key 只存在于 zh——在整体英文的界面里渲染出中文文本,比让 reviewer 一眼看见裸 key 更糟。
  • 让 e2e 车道的中文场景继续钉存储项(dsh.locale=zh):那会让套件保持绿色,却抹掉浏览器推导路径在组装后应用中唯一的运行处;改钉浏览器语言才能端到端地演练新的解析过程。
  • 按请求服务 <html lang>,或干脆不管这个静态属性:在服务端计算它需要用请求的 Accept-Language 去重新推导客户端本就会解析的结果,使同一条规则在两处重复,而且仍会输给服务端并不读取的存储偏好。放任其保持静态,正是该属性对某一种语言永远错误的原因。依据解析出的 locale 来设置,可保持单一真源。

Consequences

  • 首次访问会从浏览器的有序列表中选择第一个匹配的已注册语言。若目录中只有内置语言,英文浏览器进入英文界面,中文浏览器进入中文界面,两者皆未声明的浏览器则进入英文而非中文界面;外部注册项会加入同一个语言行与匹配过程。
  • 字典解析最终到达 en:内置 zh 缺失 key 时直接到达它,外部语言则先按自己声明的链逐 key 回落。内置字典对称性保证已提供的文案完整,这正是对称性门禁存在的原因。
  • <html lang> 现在在两个方向上都如实报告屏幕上的语言,这也关闭了 #2160。若某个客户端从未激活 locale 插件,则保留所服务的默认值,因此该属性退化为旧的静态行为,而不会退化为空值。
  • 客户端树的非浏览器运行(node 启动、非 jsdom 单测车道)现在以 en 开场。断言已提供中文文案的用例必须在其构造的 runtime 上显式调用 setLocale('zh');套件级的 usePinnedBrowserLanguages('zh-CN') 仅在同时声明了 @vitest-environment jsdom 的文件中生效,因为没有 window 时探测路径根本不会读取 navigator。此前有七个 *.client.spec.ts 文件带着这样一条失效的固定语句,实际依赖的是旧的 zh 回落值。
  • 探测的代价是每次服务构造或语言目录变化时遍历一次数组,且不会隐式写入 settings;插件激活后或待采用语言注册时,显式 Host 偏好可能引发一次实时收敛。