Quellcode durchsuchen

fix(web): localize Chinese model copy

Turtle vor 1 Woche
Ursprung
Commit
555c04bdfc

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md
-2026-08-23-locale-owned-client-ui-copy.md: 623189b8faa45f0ef2a1b724d3e050d33f7d59bd
-2026-08-23-locale-owned-client-ui-copy.zh.md: e2a6f49de89e6e6ba2734dd69cf536a3e13d2aea
+2026-08-23-locale-owned-client-ui-copy.md: bc4d8efbce1f246f712e3afe6af40d1518e8cb17
+2026-08-23-locale-owned-client-ui-copy.zh.md: ce996a4aafc392e739bec2eccd8714cfeff7d8e9

+ 3 - 1
.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md

@@ -12,6 +12,8 @@ Typed locale namespaces and bilingual dictionary parity proved that registered d
 
 **Locale dictionaries own all product-authored client UI wording.** Visible text, accessibility names, tooltips, placeholders, empty states, status labels, units, and formatting templates reach presentation through a typed `t` seat or an already-localized prop. A value authored by a user, model, provider, plugin, wire peer, or operating system remains data and renders verbatim; protocol tags, tool names, paths, URLs, JSON/JavaScript literals, and stable internal ids are not translated.
 
+Product-owned catalog descriptions follow the same rule. The client maps an exact built-in provider, model, and description to a locale key; a changed description or an external provider description remains provider data and renders verbatim.
+
 **Cordis-free primitives require complete localized copy props and own no language fallback.** `MarkdownText`, `JsonTree`, `TerminalBlock`, `DiffBlock`, `ReadBlock`, `SearchBlock`, `WebBlock`, `CodeBlock`, `JsonBlock`, `HoverCard`, and `ConnectionIndicator` receive their chrome from the feature render site. This preserves the primitive package's runtime independence while making omission a type error instead of silently selecting Chinese or English. Shared words live in the `common` namespace; feature-specific phrases stay with the feature that decides their meaning.
 
 **Localized display text is never an identity.** Models and stores retain discriminants, stable ids, and non-display markers. Renderers translate after matching, and request maps carry stable group membership into the trajectory ledger. A client-synthesized error that must survive in a view model uses a stable marker and is translated only when displayed. Language switching therefore changes wording without changing selection, grouping, search identity, or lifecycle state.
@@ -22,7 +24,7 @@ The product-authored error and design-literal exclusions, primitive defaults, an
 
 ## Verification
 
-The AST check's own Vitest spec pins direct JSX, template branches, semantic copy props, label data, formatter returns, locale-key calls, structural attributes, and dictionary owners. Locale dictionary parity pins identical `zh`/`en` keys. Client component suites exercise both direct translated seats and locale-prop adapters, and the assembled web replay plus the required real-server GIF demonstrate the shipped locale switch on the actual trajectory surface.
+The AST check's own Vitest spec pins direct JSX, template branches, semantic copy props, label data, formatter returns, locale-key calls, structural attributes, and dictionary owners. Locale dictionary parity pins identical `zh`/`en` keys. Client suites exercise direct translated seats, locale-prop adapters, and built-in catalog-description localization without altering external descriptions. The assembled web replay plus the required real-server GIF demonstrate the shipped locale switch on the actual trajectory surface.
 
 ## Alternatives considered
 

+ 3 - 1
.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md

@@ -12,6 +12,8 @@ typed locale namespace 与双语字典对等性可以证明已注册字典完整
 
 **所有产品编写的 client UI 措辞都由 locale 字典持有。** 可见文本、无障碍名称、tooltip、placeholder、空状态、状态标签、单位和格式模板必须经 typed `t` 席位或已本地化 prop 到达展示层。由用户、模型、提供方、插件、wire 对端或操作系统编写的值仍是数据并原样渲染;协议 tag、工具名称、路径、URL、JSON/JavaScript 字面量和稳定内部 id 不翻译。
 
+产品持有的目录说明遵循同一规则。client 将完全匹配的内置提供方、模型与说明映射到 locale key;说明发生变化或来自外部提供方时,它仍是提供方数据并原样渲染。
+
 **Cordis-free 原子组件要求完整的本地化文案 prop,且自身不持有语言回落值。** `MarkdownText`、`JsonTree`、`TerminalBlock`、`DiffBlock`、`ReadBlock`、`SearchBlock`、`WebBlock`、`CodeBlock`、`JsonBlock`、`HoverCard` 与 `ConnectionIndicator` 的 chrome 均由功能渲染点传入。这样既保留原子组件包的运行时独立性,也让遗漏成为类型错误,而不是静默选择中文或英文。共享用词进入 `common` namespace;功能专属短语留在决定其语义的功能侧。
 
 **本地化展示文本绝不承担身份。** 模型与存储保留判别字段、稳定 id 和非展示 marker。渲染器先匹配再翻译,请求映射通过稳定的组成员关系进入 trajectory ledger。必须保存在视图模型中的 client 合成错误使用稳定 marker,只在展示时翻译。因此语言切换只改变措辞,不改变选择、分组、搜索身份或生命周期状态。
@@ -22,7 +24,7 @@ typed locale namespace 与双语字典对等性可以证明已注册字典完整
 
 ## Verification
 
-AST 检查自身的 Vitest spec 固定直接 JSX、模板分支、语义文案 prop、label 数据、格式化函数返回值、locale key 调用、结构属性和字典 owner。locale 字典对等性固定 `zh`/`en` key 一致。client 组件测试同时覆盖直接翻译席位与 locale prop 适配器;组装 web 回放和规定的真实服务器 GIF 在实际 trajectory 界面上展示发布的语言切换。
+AST 检查自身的 Vitest spec 固定直接 JSX、模板分支、语义文案 prop、label 数据、格式化函数返回值、locale key 调用、结构属性和字典 owner。locale 字典对等性固定 `zh`/`en` key 一致。client 测试覆盖直接翻译席位、locale prop 适配器,以及不改变外部说明的内置目录说明本地化。组装 web 回放和规定的真实服务器 GIF 在实际 trajectory 界面上展示发布的语言切换。
 
 ## Alternatives considered
 

+ 2 - 2
apps/web/tests/expected/settings-chrome/dialog.expected.md

@@ -41,8 +41,8 @@
   - button "减小字号":
     - img
   - text: px 对话显示 控制已完成轮次的过程内容
-  - button "Compact":
-    - text: Compact
+  - button "紧凑":
+    - text: 紧凑
     - img
   - text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为
   - button "排队发送":

+ 7 - 7
apps/web/tests/settings-chrome.e2e.ts

@@ -424,9 +424,9 @@ describe('web e2e: settings modal and General preferences', () => {
     const dialog = page.getByRole('dialog', { name: '设置' })
     await dialog.waitFor({ timeout: 10_000 })
     await dialog.getByText('对话显示', { exact: true }).waitFor({ timeout: 10_000 })
-    await dialog.getByRole('button', { name: 'Compact', exact: true }).click()
-    await page.getByRole('menuitem', { name: 'Normal', exact: true }).click()
-    await dialog.getByRole('button', { name: 'Normal', exact: true }).waitFor({ timeout: 10_000 })
+    await dialog.getByRole('button', { name: '紧凑', exact: true }).click()
+    await page.getByRole('menuitem', { name: '标准', exact: true }).click()
+    await dialog.getByRole('button', { name: '标准', exact: true }).waitFor({ timeout: 10_000 })
     await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
       .toMatch(/ui-chat:\n\s+transcriptView: normal/)
     await page.keyboard.press('Escape')
@@ -437,11 +437,11 @@ describe('web e2e: settings modal and General preferences', () => {
     acknowledgeReloadConnectionLoss(tripwire, warningStart)
     await page.getByRole('button', { name: '设置', exact: true }).click()
     const reloaded = page.getByRole('dialog', { name: '设置' })
-    await reloaded.getByRole('button', { name: 'Normal', exact: true }).waitFor({ timeout: 10_000 })
+    await reloaded.getByRole('button', { name: '标准', exact: true }).waitFor({ timeout: 10_000 })
 
-    await reloaded.getByRole('button', { name: 'Normal', exact: true }).click()
-    await page.getByRole('menuitem', { name: 'Compact', exact: true }).click()
-    await reloaded.getByRole('button', { name: 'Compact', exact: true }).waitFor({ timeout: 10_000 })
+    await reloaded.getByRole('button', { name: '标准', exact: true }).click()
+    await page.getByRole('menuitem', { name: '紧凑', exact: true }).click()
+    await reloaded.getByRole('button', { name: '紧凑', exact: true }).waitFor({ timeout: 10_000 })
     await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
       .toMatch(/ui-chat:\n\s+transcriptView: compact/)
     await page.keyboard.press('Escape')

+ 2 - 2
packages/client/ui-chat/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md
-README.md: 91618e226dd25c8629ff103ac5a958e688aeed26
-README.zh.md: 4f9629c8974fbee9e56ed003ebd57bbc3b029577
+README.md: 62b6a8c8644953010ba61dc19780c5e8a5a01991
+README.zh.md: 9aa8cec85bde83130ecf06806f9831d491f09ea5

Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
packages/client/ui-chat/README.md


Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
packages/client/ui-chat/README.zh.md


+ 2 - 2
packages/client/ui-chat/src/client/locale.ts

@@ -29,8 +29,8 @@ export const zh = {
   'chat.turnNavigation.turn': '第 {turn} 轮',
   'settings.transcript.title': '对话显示',
   'settings.transcript.description': '控制已完成轮次的过程内容',
-  'settings.transcript.normal': 'Normal',
-  'settings.transcript.compact': 'Compact',
+  'settings.transcript.normal': '标准',
+  'settings.transcript.compact': '紧凑',
   'fileOpen.title': '无法打开文件',
   'fileOpen.unknown': '无法打开此文件',
   'message.extraBlock': '附加内容块',

+ 10 - 3
packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx

@@ -8,7 +8,7 @@ import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
 import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { TranscriptViewRow, type TranscriptViewRowProps } from '../src/client/settings/TranscriptViewRow.tsx'
-import { en } from '../src/client/locale.ts'
+import { en, zh } from '../src/client/locale.ts'
 
 afterEach(cleanup)
 
@@ -31,7 +31,7 @@ function noPendingInteraction() {
 // The resource hook the resources plugin merges into GlobalStandardProps; this row reads no address.
 const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
 
-function mount(mode: 'normal' | 'compact' = 'compact') {
+function mount(mode: 'normal' | 'compact' = 'compact', dictionary: typeof en | typeof zh = en) {
   const source = createSnapshotStore(mode)
   const setTranscriptView = vi.fn((next: 'normal' | 'compact') => { source.set(next) })
   const props: TranscriptViewRowProps = {
@@ -41,7 +41,7 @@ function mount(mode: 'normal' | 'compact' = 'compact') {
     useResource,
     useTranscriptView: bindSnapshotSelector(source),
     setTranscriptView,
-    t: makeTranslate(en),
+    t: makeTranslate(dictionary),
   }
   render(<TranscriptViewRow {...props} />)
   return { setTranscriptView }
@@ -66,4 +66,11 @@ describe('TranscriptViewRow', () => {
     fireEvent.pointerDown(document.body)
     expect(screen.queryByRole('menuitem', { name: 'Compact' })).toBeNull()
   })
+
+  it('shows the conversation-display values in Chinese', () => {
+    mount('compact', zh)
+    fireEvent.click(screen.getByRole('button', { name: '紧凑' }))
+    fireEvent.click(screen.getByRole('menuitem', { name: '标准' }))
+    expect(screen.getByRole('button', { name: '标准' })).toBeDefined()
+  })
 })

+ 2 - 2
packages/client/ui-model-selection/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-model-selection/README.md
-README.md: b050f1fa7e680b7669e208d88851754aa72f09e2
-README.zh.md: 85b4d44a1beb45a5a11fa946d2f36d83f9201e29
+README.md: ae939ce678bcc50aa69c6c260f1782830eea103f
+README.zh.md: d9826f55a48f141f8baa65f230e24ea8d0254e30

+ 1 - 1
packages/client/ui-model-selection/README.md

@@ -29,7 +29,7 @@ Mount this plugin alongside `ui-conversation` and the commands package; the comp
 
 ### Model and effort
 
-Models stay grouped by provider. The menu shows model and effort names only; catalog descriptions remain available to other consumers. The `/model` popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input.
+Models stay grouped by provider. The composer menu shows model and effort names only. The `/model` popup shows provider names and catalog descriptions; it localizes the two built-in DeepSeek descriptions and leaves external provider descriptions verbatim. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input.
 
 ### Unroutable sessions
 

+ 1 - 1
packages/client/ui-model-selection/README.zh.md

@@ -29,7 +29,7 @@ Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有
 
 ### 模型与推理强度
 
-模型按提供方分组。菜单只显示模型与推理强度名称;目录中的说明仍可供其他消费方使用。`/model` 弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。
+模型按提供方分组。composer 菜单只显示模型与推理强度名称。`/model` 弹窗显示提供方名称与目录说明;其中两个内置 DeepSeek 模型的说明使用当前语言,外部提供方说明保持原文。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。
 
 ### 不可路由的会话
 

+ 16 - 1
packages/client/ui-model-selection/src/client/index.ts

@@ -46,15 +46,30 @@ function rowId(providerId: string, modelId: string): string {
   return `${providerId}/${modelId}`
 }
 
+const BUILTIN_DESCRIPTION_KEYS: Readonly<Record<string, ModelKey>> = {
+  'deepseek-official/deepseek-v4-flash': 'option.deepseekV4Flash.description',
+  'deepseek-official/deepseek-v4-pro': 'option.deepseekV4Pro.description',
+}
+
+function descriptionOf(
+  providerId: string,
+  model: ModelDirectoryState['groups'][number]['models'][number],
+  t: TranslateNS<'model'>,
+): string | undefined {
+  const key = BUILTIN_DESCRIPTION_KEYS[rowId(providerId, model.id)]
+  return key !== undefined && model.description === en[key] ? t(key) : model.description
+}
+
 /** Flatten the directory into popup rows; failure rows are listed for visibility but never selectable. */
 function optionsOf(directory: ModelDirectoryState, t: TranslateNS<'model'>): SelectOption[] {
   const rows: SelectOption[] = []
   for (const group of directory.groups) {
     for (const model of group.models) {
+      const description = descriptionOf(group.id, model, t)
       rows.push({
         id: rowId(group.id, model.id),
         label: model.name,
-        detail: model.description !== undefined ? `${group.name} · ${model.description}` : group.name,
+        detail: description !== undefined ? `${group.name} · ${description}` : group.name,
         ...(directory.current !== null
           && directory.current.provider === group.id
           && directory.current.model === model.id

+ 4 - 0
packages/client/ui-model-selection/src/client/locales.ts

@@ -12,6 +12,8 @@
 export const zh = {
   'command.description': '选择本会话使用的模型',
   'option.loadError': '目录加载失败:{message}',
+  'option.deepseekV4Flash.description': '快速、高效且经济;适合目标明确、常规或并行任务。',
+  'option.deepseekV4Pro.description': '更强的自主编码、知识与复杂推理能力;适合复杂或质量优先的任务,但成本更高。',
   'trigger.fallback': '选择模型',
   'trigger.loading': '正在加载模型…',
   'trigger.selectAria': '选择模型',
@@ -37,6 +39,8 @@ export type ModelKey = keyof typeof zh
 export const en = {
   'command.description': 'Select the model for this conversation',
   'option.loadError': 'Catalog failed to load: {message}',
+  'option.deepseekV4Flash.description': 'Fast, efficient, and economical; suited to focused, routine, or parallel tasks.',
+  'option.deepseekV4Pro.description': 'Stronger agentic coding, knowledge, and difficult reasoning; suited to complex or quality-critical tasks at higher cost.',
   'trigger.fallback': 'Select model',
   'trigger.loading': 'Loading models…',
   'trigger.selectAria': 'Select model',

+ 35 - 8
packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts

@@ -30,6 +30,7 @@ const GROUPS = [{
     {
       id: 'deepseek-v4-flash',
       name: 'DeepSeek-V4-Flash',
+      description: 'Fast, efficient, and economical; suited to focused, routine, or parallel tasks.',
       reasoning: {
         efforts: [
           { id: 'off', name: 'Off' },
@@ -42,6 +43,7 @@ const GROUPS = [{
     {
       id: 'deepseek-v4-pro',
       name: 'DeepSeek-V4-Pro',
+      description: 'Stronger agentic coding, knowledge, and difficult reasoning; suited to complex or quality-critical tasks at higher cost.',
       reasoning: {
         efforts: [
           { id: 'off', name: 'Off' },
@@ -52,10 +54,18 @@ const GROUPS = [{
       },
     },
   ],
+}, {
+  id: 'external',
+  name: 'External Provider',
+  models: [{
+    id: 'deepseek-v4-flash',
+    name: 'External Flash',
+    description: 'Provider-authored description.',
+  }],
 }]
 
 /** Boot the plugin over fake faces + a stateful fake host (current moves on selectModel). */
-async function bench() {
+async function bench(locale: 'zh' | 'en' = 'zh') {
   const ctx = new Context()
   let defaultSelection: ModelSelection = { provider: 'deepseek-official', model: 'deepseek-v4-flash' }
   let selected = defaultSelection
@@ -117,10 +127,9 @@ async function bench() {
     },
   })
   const localeRuntime = new LocaleRuntime(ctx)
-  // This spec asserts the shipped Chinese copy. There is no jsdom `window` in
-  // this lane, so browser-language detection never runs and the locale comes
-  // from FALLBACK_LOCALE (en): state the asserted locale explicitly.
-  localeRuntime.setLocale('zh')
+  // There is no jsdom `window` in this lane, so browser-language detection
+  // never runs. Each bench states the locale its assertions require.
+  localeRuntime.setLocale(locale)
   ctx.provide('locale', localeRuntime)
   const scopes = new Map<SessionId, Context>()
   const addressed = new Set<SessionId>()
@@ -179,15 +188,33 @@ describe('ui-model-selection dual entry', () => {
     expect(b.seat().locale).toBe('model')
   })
 
-  it('popup options mark the host current active with the provider group in the detail', async () => {
+  it('localizes built-in descriptions and preserves external provider descriptions', async () => {
     const b = await bench()
     b.mint('s1')
     const options = await b.contribution().ui.options(projection('s1'), new AbortController().signal)
-    expect(options.map((o: SelectOption) => o.label)).toEqual(['DeepSeek-V4-Flash', 'DeepSeek-V4-Pro'])
-    expect(options[0]).toMatchObject({ active: true, detail: 'DeepSeek' })
+    expect(options.map((o: SelectOption) => o.label)).toEqual([
+      'DeepSeek-V4-Flash', 'DeepSeek-V4-Pro', 'External Flash',
+    ])
+    expect(options[0]).toMatchObject({
+      active: true,
+      detail: 'DeepSeek · 快速、高效且经济;适合目标明确、常规或并行任务。',
+    })
+    expect(options[1]?.detail)
+      .toBe('DeepSeek · 更强的自主编码、知识与复杂推理能力;适合复杂或质量优先的任务,但成本更高。')
+    expect(options[2]?.detail).toBe('External Provider · Provider-authored description.')
     expect(options[1]?.active).toBeUndefined()
   })
 
+  it('keeps built-in descriptions unchanged in English', async () => {
+    const b = await bench('en')
+    b.mint('s1')
+    const options = await b.contribution().ui.options(projection('s1'), new AbortController().signal)
+    expect(options[0]?.detail)
+      .toBe('DeepSeek · Fast, efficient, and economical; suited to focused, routine, or parallel tasks.')
+    expect(options[1]?.detail)
+      .toBe('DeepSeek · Stronger agentic coding, knowledge, and difficult reasoning; suited to complex or quality-critical tasks at higher cost.')
+  })
+
   it('a seat selection is the current the popup marks active next — one shared state', async () => {
     const b = await bench()
     b.mint('s1')

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.