From c317fbc489d0e9aed3c542861761241b0a83a08d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:04:56 +0800 Subject: [PATCH 1/3] feat(client): typed locale standard seat in the slot framework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registrations declare a dictionary namespace (locale: NS) and the renderer synthesizes a typed t prop for the entry's component from the installed LocaleFace; the seat binding is re-derived per locale revision, so a language switch hands out fresh t references and memoized consumers re-render through ordinary shallow comparison. LocaleNamespaceMap is the declare-merge table (namespace -> dictionary key union); TranslateNS<'ns'> is the namespace-addressed translate type (namespace keys plus the shared common vocabulary), carried by the t seat and by the locale service's typed bind. LocaleService implements the face (lookup ns -> common -> zh -> key, revision-carrying snapshots with subscriber isolation) and installs it through the boot-once slots.installLocale seam, mirroring the renderer install. The typed register(ns, {zh, en}) overload checks each dictionary against the namespace's key union and requires every shipped locale, so a missing or extra key and an unbalanced translation are compile errors. Dictionary registration bumps the face revision without emitting locale/change — the event now means exactly 'the active locale switched', so registration-heavy boot cannot storm event listeners. --- docs/module-graph.md | 19 +- packages/client/locale/README.i18n.yaml | 6 +- packages/client/locale/README.md | 6 +- packages/client/locale/README.zh.md | 6 +- .../client/locale/src/client/LanguageRow.tsx | 11 +- packages/client/locale/src/client/index.ts | 190 ++++++++++++++---- packages/client/locale/src/locales/en.ts | 31 ++- packages/client/locale/src/locales/index.ts | 8 + .../client/locale/src/locales/settings.ts | 14 ++ packages/client/locale/src/locales/zh.ts | 32 ++- packages/client/locale/tests/apply.spec.ts | 8 +- packages/client/locale/tests/locale.spec.ts | 59 ++++++ packages/client/runtime/src/client/slots.ts | 29 ++- packages/client/ui-model/package.json | 3 + .../ui-model/src/client/ModelSelect.tsx | 46 +++-- packages/client/ui-model/src/client/index.ts | 49 ++++- .../client/ui-model/src/client/locales.ts | 48 +++++ .../ui-model/tests/browser-plugin.spec.ts | 13 +- .../ui-model/tests/model-select.spec.tsx | 21 +- packages/client/ui-model/tsconfig.json | 3 + packages/client/ui-question/package.json | 2 + .../src/client/QuestionComposer.tsx | 39 ++-- .../ui-question/src/client/contract/slots.ts | 11 +- .../client/ui-question/src/client/index.ts | 39 +++- .../client/ui-question/src/client/locales.ts | 38 ++++ .../ui-question/tests/browser-plugin.spec.ts | 9 +- .../tests/question-composer.spec.tsx | 6 +- packages/client/ui-question/tsconfig.json | 3 + packages/client/ui-sidebar/package.json | 5 +- .../ui-sidebar/src/client/SidebarRoot.tsx | 13 +- .../ui-sidebar/src/client/contract/slots.ts | 8 +- .../client/ui-sidebar/src/client/index.ts | 19 +- .../client/ui-sidebar/src/client/locales.ts | 20 ++ .../client/ui-sidebar/tests/apply.spec.tsx | 6 +- .../ui-sidebar/tests/sidebar-root.spec.tsx | 7 +- .../tests/sidebar-snapshot.spec.tsx | 7 + packages/client/ui-sidebar/tsconfig.json | 3 + packages/client/ui-slots/src/index.ts | 92 ++++++++- packages/client/ui-slots/src/renderer.ts | 33 ++- .../ui-theme/src/client/AppearanceRow.tsx | 13 +- packages/client/ui-theme/src/client/index.ts | 40 ++-- packages/client/ui-theme/tests/apply.spec.ts | 3 +- .../client/web-react/src/scoped-slots.tsx | 87 +++++++- pnpm-lock.yaml | 9 + 44 files changed, 925 insertions(+), 189 deletions(-) create mode 100644 packages/client/locale/src/locales/index.ts create mode 100644 packages/client/locale/src/locales/settings.ts create mode 100644 packages/client/ui-model/src/client/locales.ts create mode 100644 packages/client/ui-question/src/client/locales.ts create mode 100644 packages/client/ui-sidebar/src/client/locales.ts diff --git a/docs/module-graph.md b/docs/module-graph.md index b0f1111826..de40e66fcc 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -256,7 +256,6 @@ flowchart TD pkg_client_modules --> pkg_invariants pkg_client_runtime --> pkg_invariants pkg_client_ui_primitives --> pkg_invariants - pkg_client_ui_question --> pkg_invariants pkg_client_ui_slots --> pkg_invariants pkg_client_ui_trajectory --> pkg_invariants pkg_client_web --> pkg_invariants @@ -290,10 +289,6 @@ flowchart TD pkg_client_ui_settings --> pkg_client_ui_primitives pkg_client_ui_settings --> pkg_client_ui_slots pkg_client_ui_settings --> pkg_invariants - pkg_client_ui_sidebar --> pkg_client_runtime - pkg_client_ui_sidebar --> pkg_client_ui_primitives - pkg_client_ui_sidebar --> pkg_client_ui_slots - pkg_client_ui_sidebar --> pkg_invariants pkg_client_ui_slash --> pkg_client_runtime pkg_client_ui_slash --> pkg_client_ui_slots pkg_client_ui_slash --> pkg_invariants @@ -335,12 +330,19 @@ flowchart TD pkg_client_ui_conversation --> pkg_client_ui_slash pkg_client_ui_conversation --> pkg_client_ui_slots pkg_client_ui_conversation --> pkg_invariants + pkg_client_ui_question --> pkg_client_locale + pkg_client_ui_question --> pkg_invariants pkg_client_ui_settings_general --> pkg_client_locale pkg_client_ui_settings_general --> pkg_client_runtime pkg_client_ui_settings_general --> pkg_client_ui_primitives pkg_client_ui_settings_general --> pkg_client_ui_settings pkg_client_ui_settings_general --> pkg_client_ui_slots pkg_client_ui_settings_general --> pkg_invariants + pkg_client_ui_sidebar --> pkg_client_locale + pkg_client_ui_sidebar --> pkg_client_runtime + pkg_client_ui_sidebar --> pkg_client_ui_primitives + pkg_client_ui_sidebar --> pkg_client_ui_slots + pkg_client_ui_sidebar --> pkg_invariants pkg_client_ui_skill --> pkg_client_connection pkg_client_ui_skill --> pkg_client_runtime pkg_client_ui_skill --> pkg_client_ui_slash @@ -496,6 +498,7 @@ flowchart TD pkg_user_interaction --> pkg_invariants pkg_user_interaction --> pkg_llm pkg_client_ui_model --> pkg_client_connection + pkg_client_ui_model --> pkg_client_locale pkg_client_ui_model --> pkg_client_runtime pkg_client_ui_model --> pkg_client_ui_command pkg_client_ui_model --> pkg_client_ui_conversation @@ -955,7 +958,6 @@ flowchart TD | [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) | | [`client-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/support/invariants) | -| [`client-ui-question`](../packages/client/ui-question) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-slots`](../packages/client/ui-slots) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`invariants`](../packages/support/invariants) | | [`client-web`](../packages/client/web) | `client` | [`invariants`](../packages/support/invariants) | @@ -973,7 +975,6 @@ flowchart TD | [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | @@ -988,7 +989,9 @@ flowchart TD | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) | | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | @@ -1030,7 +1033,7 @@ flowchart TD | [`commands`](../packages/ui/commands) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | diff --git a/packages/client/locale/README.i18n.yaml b/packages/client/locale/README.i18n.yaml index 699c9561cc..52e45518f0 100644 --- a/packages/client/locale/README.i18n.yaml +++ b/packages/client/locale/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 -README.md: 9015af2b44a33771b06863ace139fe97695df616 -README.zh.md: 6b129bcabbef5b5a00c5073ebc9142a0e406ddba +# pnpm run verify-translation-pairing --write packages/client/locale/README.md +README.md: c2adbcabc77def740094288da4643032873aa5b8 +README.zh.md: 6232a98168a0a8fef3f8209ef6af8a3ce411f2be diff --git a/packages/client/locale/README.md b/packages/client/locale/README.md index 9015af2b44..c2adbcabc7 100644 --- a/packages/client/locale/README.md +++ b/packages/client/locale/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Locale plugin: LocaleService — the browser locale preference (`zh`/`en`, persisted under `dsh.locale`, getter/setter with `locale/change` snapshots) plus the ns×locale dictionary registry (`bind(ns)`→t with a stable function identity; lookup chain active → zh → key). +Locale plugin: LocaleService — the browser locale preference (`zh`/`en`, persisted under `dsh.locale`; `locale/change` fires on switches only) plus the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS`; lookup chain ns → common → zh → key). The service implements the slot system's `LocaleFace` and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). ## Model Experience @@ -14,5 +14,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Only the Settings surface is translated** — other pages keep inline copy; repo-wide extraction into dictionaries is deferred. -- **Locale switching re-renders subscribed consumers only** — sections not wired to `locale/change` keep their rendered text until remount. +- **Most surfaces keep inline copy** — the standard seat is adopted by the Settings rows, sidebar, question composer, and model select; the remaining packages migrate in follow-up PRs. +- **Registry-held text reads its translation once** — copy captured at registration time outside the slot render path (e.g. the `/model` command description in the command registry) keeps the language it was registered under until re-registration; slot-rendered copy follows switches live. diff --git a/packages/client/locale/README.zh.md b/packages/client/locale/README.zh.md index 6b129bcabb..6232a98168 100644 --- a/packages/client/locale/README.zh.md +++ b/packages/client/locale/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -locale 插件:LocaleService 包含浏览器 locale 偏好(`zh`/`en`,以 `dsh.locale` 为键持久化;提供 getter/setter,并生成 `locale/change` 快照),以及 ns×locale 字典注册表(`bind(ns)`→t 的函数标识稳定;查找链为 active → zh → key)。 +locale 插件:LocaleService——浏览器 locale 偏好(`zh`/`en`,以 `dsh.locale` 持久化;`locale/change` 仅在切换语言时触发),加上 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS`;查找链 ns → common → zh → key)。该服务实现 slot 系统的 `LocaleFace` 并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。 ## 模型体验 @@ -14,5 +14,5 @@ locale 插件:LocaleService 包含浏览器 locale 偏好(`zh`/`en`,以 ## 已知限制与暂缓事项 -- **只有设置界面完成翻译**:其他页面仍保留内联文案;将全仓文案提取到字典的工作暂缓。 -- **切换 locale 只重新渲染已订阅的消费方**:未接入 `locale/change` 的分区会保留已渲染文本,直到重新挂载。 +- **多数界面仍保留内联文案**——标准席位已由设置行、侧边栏、问题作答器和模型选择接入;其余包在后续 PR 中迁移。 +- **注册表持有的文本只读取一次翻译**——在 slot 渲染路径之外于注册时捕获的文案(例如 command 注册表中的 `/model` 命令描述)在重新注册前保持注册时的语言;slot 渲染的文案随切换实时更新。 diff --git a/packages/client/locale/src/client/LanguageRow.tsx b/packages/client/locale/src/client/LanguageRow.tsx index a824bc6752..febf732792 100644 --- a/packages/client/locale/src/client/LanguageRow.tsx +++ b/packages/client/locale/src/client/LanguageRow.tsx @@ -5,23 +5,22 @@ * settings surface. */ import { useState } from 'react' -import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' import type {} from './settings-contract.ts' import type { createLanguageRowStore } from './settings-store.ts' import css from './LanguageRow.module.css' -/** Injected business face: namespace-bound translate + the preference write. */ +/** Injected business face: the preference write (t rides the standard locale seat). */ export interface LanguageRowInjected { - /** Translate a `settings.locale` dictionary key to the active-locale text. */ - t: (key: string) => string /** Switch the active locale (a registered locale id). */ setLocale: (id: string) => void } -/** Full component props: runtime share + store share + injected face. */ +/** Full component props: runtime share + store share + locale seat + injected face. */ export type LanguageRowComponentProps = - PropsRuntime<'settings.general.item'> & PropsStore> & LanguageRowInjected + PropsRuntime<'settings.general.item'> & PropsStore> + & PropsLocale<'settings.locale'> & LanguageRowInjected /** * Render the Language row. diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index cb6bb6f861..7b4014e340 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -4,11 +4,21 @@ * preference row into the settings General section — the locale feature owns * its own settings surface. */ +/* eslint-disable @typescript-eslint/no-redundant-type-constituents -- + * `keyof LocaleNamespaceMap & string` is the declare-merge key pattern (see + * ui-slots): in THIS unit the map holds only this package's own merges, but + * consumers merge more namespaces in and the intersection keeps them + * string-typed. The rule fires on the narrow-map view, not real redundancy. */ import type { Context } from 'cordis' -import { deferRegistration, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots' +import { + deferRegistration, + type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS, +} from '@deepseek-ai/dsh-client-ui-slots' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' -import { en } from '../locales/en.ts' -import { zh } from '../locales/zh.ts' +import { en, zh, type CommonKey } from '../locales/index.ts' +import { + en as settingsEn, zh as settingsZh, type SettingsLocaleKey, +} from '../locales/settings.ts' import type { LanguageRowInjected } from './LanguageRow.tsx' import { LanguageRow } from './LanguageRow.tsx' import { createLanguageRowStore } from './settings-store.ts' @@ -16,9 +26,21 @@ import { createLanguageRowStore } from './settings-store.ts' export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageRow.tsx' export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts' export type { SettingsGeneralItemOwnerProps } from './settings-contract.ts' +export type { CommonKey } from '../locales/index.ts' -/** Translate a key with optional params. */ -export type Translate = (key: string, params?: Record) => string +// The translate currency lives in ui-slots (the render machinery synthesizes +// the seat); re-exported here so dictionary owners import one package. +// TranslateNS<'model'> is the namespace-addressed developer-facing form. +export type { Translate, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Shared cross-feature vocabulary, consulted by the lookup chain after the entry's own namespace misses. */ + common: CommonKey + /** This feature's own settings-row copy (the Language row). */ + 'settings.locale': SettingsLocaleKey + } +} /** Locale dictionary: flat key to template string ({name} placeholders). */ export type LocaleDict = Record @@ -50,7 +72,10 @@ declare module 'cordis' { } interface Events { /** - * Locale state changed (active locale switched or registry updated). + * The active locale switched. Dictionary registrations do NOT emit this + * event (listeners may re-register slots in response, and boot registers + * one namespace per package); continuous render refresh rides the + * LocaleFace revision instead. * @param snapshot - Current immutable locale snapshot. * @mode emit */ @@ -77,16 +102,20 @@ const LOCALES: readonly LocaleDefinition[] = Object.freeze([ ]) /** - * Dictionary registry plus locale preference. Lookup chain per key: active - * locale -> zh fallback -> the key itself (missing text stays visible, fail - * loud in the UI rather than blank). Reads go through {@link getLocale}; - * writes only through {@link setLocale}; continuous sync only through the - * `locale/change` event. + * Dictionary registry plus locale preference. Lookup chain per key: the + * entry's namespace in the active locale -> that namespace's zh fallback -> + * the shared common namespace (active, then zh) -> the key itself (missing + * text stays visible, fail loud in the UI rather than blank). Reads go + * through {@link getLocale}; writes only through {@link setLocale}; + * continuous sync through the `locale/change` event, or through the + * LocaleFace getSnapshot/subscribe pair the render machinery consumes + * (installed via `ctx.slots.installLocale`). */ export class LocaleService { private dicts = new Map>() private bound = new Map() private snapshot: LocaleSnapshot + private listeners = new Set<() => void>() private readonly ctx: Context /** @@ -105,6 +134,27 @@ export class LocaleService { return this.snapshot } + /** + * LocaleFace getSnapshot: the current snapshot (carries `revision`; stable + * reference between changes, uSES-safe). + * @returns the current snapshot. + */ + getSnapshot(): LocaleSnapshot { + return this.snapshot + } + + /** + * LocaleFace subscribe: notified on every snapshot change (locale switch + * or dictionary registration — registrations bump the revision so already + * rendered outlets pick up late-arriving dictionaries). + * @param fn - change callback. + * @returns unsubscribe. + */ + subscribe(fn: () => void): () => void { + this.listeners.add(fn) + return () => { this.listeners.delete(fn) } + } + /** * Switch the active locale — the only preference write entry. Persists the * id and emits `locale/change`. @@ -114,44 +164,80 @@ export class LocaleService { const match = this.snapshot.locales.find(l => l.id === id) if (match === undefined) throw new Error(`locale "${id}" is not registered`) if (this.snapshot.active === match.id) return - this.snapshot = Object.freeze({ - active: match.id, - locales: this.snapshot.locales, - revision: this.snapshot.revision + 1, - }) persistPreference(match.id) - this.ctx.emit('locale/change', this.snapshot) + this.publish(match.id, true) } /** - * Register a dictionary for a namespace and locale. Duplicate (ns, locale) - * throws (single occupant; a namespace's texts have one owner). + * Register a declared namespace's dictionaries, all locales in one call — + * the typed form: each dictionary is checked against the namespace's + * {@link LocaleNamespaceMap} key union (a missing or extra key is a + * compile error), and every shipped locale is required (bilingual balance + * enforced at the seam). Duplicate (ns, locale) throws (single occupant; a + * namespace's texts have one owner). Registration bumps the revision so + * mounted outlets pick up late-arriving dictionaries. + * @param ns - a namespace merged into LocaleNamespaceMap. + * @param dicts - complete dictionaries keyed by locale id. + * @returns disposer removing every locale registered by this call (idempotent). + */ + register(ns: N, dicts: Record>): () => void + /** + * Single-locale untyped form for namespaces outside the merge table + * (dynamic composition, tests). * @param ns - namespace. - * @param locale - locale tag (zh/en to start). + * @param locale - locale tag. * @param dict - dictionary. * @returns disposer (idempotent). */ - register(ns: string, locale: string, dict: LocaleDict): () => void { + register(ns: string, locale: string, dict: LocaleDict): () => void + register(ns: string, localeOrDicts: string | Record, dict?: LocaleDict): () => void { + const pairs: [string, LocaleDict][] = typeof localeOrDicts === 'string' + // Overload guarantees dict on the single-locale arm. + ? [[localeOrDicts, dict as LocaleDict]] + : Object.entries(localeOrDicts) let locales = this.dicts.get(ns) if (!locales) { locales = new Map() this.dicts.set(ns, locales) } - if (locales.has(locale)) throw new Error(`locale namespace "${ns}" already has locale "${locale}"`) - locales.set(locale, dict) + for (const [locale] of pairs) { + if (locales.has(locale)) throw new Error(`locale namespace "${ns}" already has locale "${locale}"`) + } + for (const [locale, entries] of pairs) locales.set(locale, entries) + this.publish(this.snapshot.active, false) return () => { const owner = this.dicts.get(ns) - if (owner?.get(locale) === dict) owner.delete(locale) + /* v8 ignore next -- defensive: a namespace's locales map is created on + * first register and never removed, so the disposer always finds it. */ + if (!owner) return + let removed = false + for (const [locale, entries] of pairs) { + if (owner.get(locale) === entries) { + owner.delete(locale) + removed = true + } + } + if (removed) this.publish(this.snapshot.active, false) } } /** - * Bind a namespace to a translate function. The returned reference is - * stable per namespace (repeat binds return the same function), so it can - * ride inject surfaces without breaking memoization. - * @param ns - namespace. - * @returns the translate function (reads the active locale at call time). + * Bind a declared namespace to a translate function typed to its + * dictionary key union (plus the shared common vocabulary) — the same key + * domain the framework-injected `t` seat carries. The returned reference + * is stable per namespace (repeat binds return the same function), so it + * can ride inject surfaces without breaking memoization. + * @param ns - a namespace merged into LocaleNamespaceMap. + * @returns the typed translate function (reads the active locale at call time). */ + bind(ns: N): TranslateNS + /** + * Untyped form for namespaces outside the merge table (dynamic + * composition, tests). + * @param ns - namespace. + * @returns the translate function. + */ + bind(ns: string): Translate bind(ns: string): Translate { let t = this.bound.get(ns) if (!t) { @@ -163,14 +249,43 @@ export class LocaleService { } private translate(ns: string, key: string, params?: Record): string { - const locales = this.dicts.get(ns) - const template = locales?.get(this.snapshot.active)?.[key] - ?? locales?.get(FALLBACK_LOCALE)?.[key] + 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, (match, name: string) => name in params ? String(params[name]) : match) } + + private lookup(ns: string, key: string): string | undefined { + const locales = this.dicts.get(ns) + return locales?.get(this.snapshot.active)?.[key] ?? locales?.get(FALLBACK_LOCALE)?.[key] + } + + /** + * Advance the snapshot revision and notify LocaleFace subscribers (render + * refresh). Only an active-locale switch additionally emits + * `locale/change` — dictionary registrations stay off the event so + * registration-heavy boot cannot storm event listeners (which may + * re-register slots in response). + */ + private publish(active: LocaleId, localeChanged: boolean): void { + this.snapshot = Object.freeze({ + active, + locales: this.snapshot.locales, + revision: this.snapshot.revision + 1, + }) + if (localeChanged) this.ctx.emit('locale/change', this.snapshot) + for (const fn of [...this.listeners]) { + try { + fn() + } catch (error) { + // One throwing subscriber must not strand the rest on a stale + // revision (outlets would keep the previous language). + console.error('locale subscriber crashed:', error) + } + } + } } /** Read the persisted locale id; unknown or unreadable values fall back to zh. */ @@ -208,11 +323,12 @@ export const inject = ['slots'] */ export function apply(ctx: ClientContext): void { const locale = new LocaleService(ctx) - locale.register(COMMON_NS, 'zh', zh) - locale.register(COMMON_NS, 'en', en) - locale.register(SETTINGS_NS, 'zh', { 'language.title': '语言' }) - locale.register(SETTINGS_NS, 'en', { 'language.title': 'Language' }) + locale.register(COMMON_NS, { zh, en }) + locale.register(SETTINGS_NS, { zh: settingsZh, en: settingsEn }) ctx.provide('locale', locale) + // The service IS the LocaleFace (bind + getSnapshot/subscribe): install it + // so the render machinery can synthesize the `t` standard seat. + ctx.slots.installLocale(locale) const store = createLanguageRowStore() let bound: BoundActions | undefined @@ -230,7 +346,6 @@ export function apply(ctx: ClientContext): void { // first render (the store's revision guard drops stale duplicates). sync(locale.getLocale()) return { - t: locale.bind(SETTINGS_NS), setLocale: (id) => { locale.setLocale(id) }, } } @@ -241,6 +356,7 @@ export function apply(ctx: ClientContext): void { id: 'language', order: 0, store, + locale: SETTINGS_NS, inject: injected, }, LanguageRow)) return () => { deferred.dispose() } diff --git a/packages/client/locale/src/locales/en.ts b/packages/client/locale/src/locales/en.ts index f649177ac0..b12965c6f5 100644 --- a/packages/client/locale/src/locales/en.ts +++ b/packages/client/locale/src/locales/en.ts @@ -1,2 +1,29 @@ -/** en base dictionary for the common namespace (starter skeleton; texts land with their features). */ -export const en: Record = {} +import type { CommonKey } from './zh.ts' + +/** en base dictionary for the common namespace, checked complete against the zh key set. */ +export const en = { + 'ok': 'OK', + 'cancel': 'Cancel', + 'close': 'Close', + 'copy': 'Copy', + 'copied': 'Copied', + 'retry': 'Retry', + 'loading': 'Loading…', + 'load.failed': 'Failed to load', + 'submit': 'Submit', + 'submitting': 'Submitting…', + 'next': 'Next', + 'previous': 'Previous', + 'skip': 'Skip', + 'delete': 'Delete', + 'edit': 'Edit', + 'save': 'Save', + 'search': 'Search', + 'more': 'More', + 'collapse': 'Collapse', + 'expand': 'Expand', + 'back': 'Back', + 'unknown': 'Unknown', + 'none': 'None', + 'truncated': 'Truncated', +} satisfies Record diff --git a/packages/client/locale/src/locales/index.ts b/packages/client/locale/src/locales/index.ts new file mode 100644 index 0000000000..6ac5335f5b --- /dev/null +++ b/packages/client/locale/src/locales/index.ts @@ -0,0 +1,8 @@ +/** + * The common-namespace dictionary pair. zh is the source of truth for the + * key set (Chinese-first repo convention); en is checked complete against it + * — a missing or extra en key is a compile error. + */ +export { zh } from './zh.ts' +export { en } from './en.ts' +export type { CommonKey } from './zh.ts' diff --git a/packages/client/locale/src/locales/settings.ts b/packages/client/locale/src/locales/settings.ts new file mode 100644 index 0000000000..0419b60095 --- /dev/null +++ b/packages/client/locale/src/locales/settings.ts @@ -0,0 +1,14 @@ +/** `settings.locale` namespace dictionaries (the Language row's copy). */ + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'language.title': '语言', +} satisfies Record + +/** The settings.locale namespace key union. */ +export type SettingsLocaleKey = keyof typeof zh + +/** English dictionary, checked complete against the zh key set. */ +export const en = { + 'language.title': 'Language', +} satisfies Record diff --git a/packages/client/locale/src/locales/zh.ts b/packages/client/locale/src/locales/zh.ts index f3ff22d2b8..5bb62c4344 100644 --- a/packages/client/locale/src/locales/zh.ts +++ b/packages/client/locale/src/locales/zh.ts @@ -1,2 +1,30 @@ -/** zh base dictionary for the common namespace (starter skeleton; texts land with their features). */ -export const zh: Record = {} +/** zh base dictionary for the common namespace: cross-feature standard words. */ +export const zh = { + 'ok': '确定', + 'cancel': '取消', + 'close': '关闭', + 'copy': '复制', + 'copied': '复制成功', + 'retry': '重试', + 'loading': '加载中…', + 'load.failed': '加载失败', + 'submit': '提交', + 'submitting': '正在提交…', + 'next': '下一步', + 'previous': '上一步', + 'skip': '跳过', + 'delete': '删除', + 'edit': '编辑', + 'save': '保存', + 'search': '搜索', + 'more': '更多', + 'collapse': '收起', + 'expand': '展开', + 'back': '返回', + 'unknown': '未知', + 'none': '无', + 'truncated': '已截断', +} satisfies Record + +/** The common vocabulary key union (zh is the key-set source of truth). */ +export type CommonKey = keyof typeof zh diff --git a/packages/client/locale/tests/apply.spec.ts b/packages/client/locale/tests/apply.spec.ts index 25dbdfe239..c603cbc5f0 100644 --- a/packages/client/locale/tests/apply.spec.ts +++ b/packages/client/locale/tests/apply.spec.ts @@ -69,16 +69,18 @@ describe('locale apply', () => { // An event ahead of any inject hits the unbound-actions arm. locale.setLocale('en') - const { instance, face } = faceOf(b.slots) + const { entry, instance, face } = faceOf(b.slots) // The inject-time re-sync sealed the init window: the mirror is current. expect(instance.getSnapshot().active).toBe('en') expect(instance.getSnapshot().options.map(o => o.id)).toEqual(['zh', 'en']) - expect(face.t('language.title')).toBe('Language') + // Copy rides the standard locale seat: the entry declares the namespace. + expect(entry.locale).toBe(SETTINGS_NS) + expect(locale.bind(SETTINGS_NS)('language.title')).toBe('Language') face.setLocale('zh') expect(locale.getLocale().active).toBe('zh') expect(instance.getSnapshot().active).toBe('zh') - expect(face.t('language.title')).toBe('语言') + expect(locale.bind(SETTINGS_NS)('language.title')).toBe('语言') }) it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => { diff --git a/packages/client/locale/tests/locale.spec.ts b/packages/client/locale/tests/locale.spec.ts index 3f9efaed19..80fe699e34 100644 --- a/packages/client/locale/tests/locale.spec.ts +++ b/packages/client/locale/tests/locale.spec.ts @@ -29,6 +29,24 @@ describe('LocaleService', () => { expect(t('missing.key')).toBe('missing.key') }) + it('falls through to the common vocabulary after the namespace misses (production keys)', () => { + const { svc } = make() + // The shipped common pair is registered by apply; the bench registers it + // directly to pin the production chain: ns -> common -> zh -> key. + svc.register('common', 'zh', { retry: '重试' }) + svc.register('common', 'en', { retry: 'Retry' }) + svc.register('ns', 'zh', { own: '自有' }) + const t = svc.bind('ns') + expect(t('retry')).toBe('重试') + svc.setLocale('en') + expect(t('retry')).toBe('Retry') + expect(t('own')).toBe('自有') + // common itself must not recurse: a miss inside common echoes the key. + // (Wide-string ns hits the untyped bind overload — the typed one rejects + // unknown keys at compile time, which is the point of the seam.) + expect(svc.bind('common' as string)('nope')).toBe('nope') + }) + it('interpolates {name} params and leaves unknown placeholders intact', () => { const { svc } = make() svc.register('ns', 'zh', { greet: '你好,{name}!第 {n} 次', partial: '{known} 与 {unknown}' }) @@ -56,6 +74,47 @@ describe('LocaleService', () => { expect(t('k')).toBe('v2') }) + it('serves the LocaleFace: snapshot revision moves on switch and registration, subscribers fire, unsubscribe stops them', () => { + const { svc } = make() + const seen: number[] = [] + const off = svc.subscribe(() => { seen.push(svc.getSnapshot().revision) }) + expect(svc.getSnapshot()).toBe(svc.getLocale()) + const r0 = svc.getSnapshot().revision + svc.register('ns', 'zh', { k: 'v' }) + expect(svc.getSnapshot().revision).toBe(r0 + 1) + svc.setLocale('en') + expect(seen).toEqual([r0 + 1, r0 + 2]) + off() + svc.setLocale('zh') + expect(seen).toHaveLength(2) + }) + + it('isolates a throwing subscriber: the rest still see the new revision', () => { + const { svc } = make() + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const seen: number[] = [] + svc.subscribe(() => { throw new Error('boom') }) + svc.subscribe(() => { seen.push(svc.getSnapshot().revision) }) + svc.setLocale('en') + expect(seen).toEqual([1]) + expect(spy).toHaveBeenCalledOnce() + } finally { + spy.mockRestore() + } + }) + + it('register disposer republishes (mounted outlets drop the dead dictionary)', () => { + const { svc } = make() + const dispose = svc.register('ns', 'zh', { k: 'v' }) + const before = svc.getSnapshot().revision + dispose() + expect(svc.getSnapshot().revision).toBe(before + 1) + // Second run hits the idempotent arm: nothing removed, no republish. + dispose() + expect(svc.getSnapshot().revision).toBe(before + 1) + }) + it('setLocale persists, republishes an immutable snapshot, and no-ops on same value', () => { const { svc, events } = make() svc.setLocale('en') diff --git a/packages/client/runtime/src/client/slots.ts b/packages/client/runtime/src/client/slots.ts index d18377e052..d5df10f8c3 100644 --- a/packages/client/runtime/src/client/slots.ts +++ b/packages/client/runtime/src/client/slots.ts @@ -18,7 +18,7 @@ import { Service } from 'cordis' import type { Context } from 'cordis' import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots' import type { - OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost, + LocaleFace, OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost, SlotScope, SlotSpec, StoreDecl, StoredEntry, StoreInstanceLike, } from '@deepseek-ai/dsh-client-ui-slots' @@ -70,6 +70,8 @@ interface ErasedRegisterOptions { select?: (owner: never) => unknown /** Chain-slot explicit ordering override (ascending; registration order otherwise). */ priority?: number + /** Declared dictionary namespace (the renderer synthesizes the `t` seat from it). */ + locale?: string registrant?: string } @@ -82,6 +84,7 @@ export class SlotsService extends Service { /** Store-instance axis: handle -> mounted scope, refcount, resolved instances. */ private readonly _stores = new Map() private _renderer: SlotRenderer | undefined + private _locale: LocaleFace | undefined private _host: SlotRendererHost | undefined /** @@ -127,6 +130,23 @@ export class SlotsService extends Service { }, 'slots.install()') } + /** + * Install the locale face backing the `t` standard seat (the locale + * plugin's product; same boot-once discipline as the renderer install). + * Runs through the caller's ctx.effect, so the installing fiber's unload + * uninstalls the face. + * @param face - namespace binder + revision observable. + */ + installLocale(face: LocaleFace): void { + if (this._locale !== undefined) throw new Error('locale face already installed (installLocale() is boot-once)') + this.ctx.effect(() => { + this._locale = face + return () => { + if (this._locale === face) this._locale = undefined + } + }, 'slots.installLocale()') + } + /** * The single ctx-level render entry: the shell renders 'root'; every other * key renders inside components through the props renderSlot face. All @@ -246,6 +266,12 @@ export class SlotsService extends Service { if (workspaces === undefined) { throw new Error("renderSlot('root') before the workspaces service mounted — boot order puts runtime apply first") } + // `locale` is a live getter: the face installs (and, under HMR, swaps) + // on the locale plugin's own fiber lifetime, while this host object is + // built once — a captured value would strand renders on a dead face. The + // alias is required: `this` inside the getter is the host literal. + // eslint-disable-next-line @typescript-eslint/no-this-alias + const service = this this._host = { subscribe: (key, fn) => this._core.subscribe(key, fn), getVersion: key => this._core.getVersion(key), @@ -259,6 +285,7 @@ export class SlotsService extends Service { provideInfo: sessions.currentProvideInfo, }, workspaces: { list: workspaces.list }, + get locale() { return service._locale }, } return this._host } diff --git a/packages/client/ui-model/package.json b/packages/client/ui-model/package.json index a4d412dffd..2e9a199805 100644 --- a/packages/client/ui-model/package.json +++ b/packages/client/ui-model/package.json @@ -24,6 +24,7 @@ }, "dshClient": { "inject": [ + "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-command" ], @@ -36,6 +37,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-client-connection": "^0.0.1", + "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-command": "^0.0.1", "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", @@ -49,6 +51,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-command": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", diff --git a/packages/client/ui-model/src/client/ModelSelect.tsx b/packages/client/ui-model/src/client/ModelSelect.tsx index 6e4aa3be11..c170fd06f0 100644 --- a/packages/client/ui-model/src/client/ModelSelect.tsx +++ b/packages/client/ui-model/src/client/ModelSelect.tsx @@ -18,6 +18,7 @@ import type { ModelReasoningEffort, ModelTarget } from '@deepseek-ai/dsh-client- import { IconCheckOutline16, IconChevronDownOutline14, IconChevronRightOutline14, } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ModelSelectInjected } from './slots.ts' import css from './ModelSelect.module.css' @@ -34,10 +35,13 @@ interface EffortChoice { /** * Render the composer model seat. - * @param props - owner share (locked) + injected face (shared directory store/verbs). + * @param props - owner share (locked) + injected face (shared directory + * store/verbs) + the standard locale seat. * @returns the trigger and, while open, the two-level menu. */ -export function ModelSelect({ locked, directory, load, select }: ModelSelectInjected & { locked: boolean }) { +export function ModelSelect( + { locked, directory, load, select, t }: ModelSelectInjected & { locked: boolean } & PropsLocale<'model'>, +) { const state = useSyncExternalStore( fn => directory.subscribe(fn), () => directory.getSnapshot(), @@ -70,13 +74,13 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje const effortLabel = reasoning === undefined ? undefined : effectiveEffort === undefined - ? 'Provider default' + ? t('effort.providerDefault') : reasoning.efforts.find(level => level.id === effectiveEffort)?.name ?? effectiveEffort const effortChoices = useMemo(() => reasoning === undefined ? [] : [ ...reasoning.defaultEffort === undefined - ? [{ key: 'provider-default', effort: undefined, label: 'Provider default' }] + ? [{ key: 'provider-default', effort: undefined, label: t('effort.providerDefault') }] : [], ...reasoning.efforts.map((effort: ModelReasoningEffort) => ({ key: `effort:${effort.id}`, @@ -84,7 +88,7 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje label: effort.name, ...effort.description === undefined ? {} : { description: effort.description }, })), - ], [reasoning]) + ], [reasoning, t]) const busy = state.status === 'selecting' // Mount-time load resolves the trigger label; every open refreshes. @@ -165,7 +169,7 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje }) } - const modelLabel = choices[selectedIndex]?.model.name ?? state.current?.model ?? '选择模型' + const modelLabel = choices[selectedIndex]?.model.name ?? state.current?.model ?? t('trigger.fallback') const triggerLabel = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}` itemRefs.current = [] let itemIndex = 0 @@ -180,7 +184,9 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje ref={triggerRef} type="button" className={css.trigger} - aria-label={`选择模型,当前 ${modelLabel}${effortLabel === undefined ? '' : `,推理等级 ${effortLabel}`}`} + aria-label={effortLabel === undefined + ? t('trigger.aria', { model: modelLabel }) + : t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel })} aria-haspopup="menu" aria-expanded={open} aria-controls={open ? `${id}-menu` : undefined} @@ -204,19 +210,19 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje id={`${id}-menu`} className={css.menu} role="menu" - aria-label="模型与推理等级" + aria-label={t('menu.aria')} aria-busy={state.status === 'loading' || busy} > {pane === 'root' && ( <> {reasoning !== undefined && ( @@ -227,18 +233,18 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje {pane === 'model' && ( <> {state.status === 'loading' && ( -
正在刷新模型列表…
+
{t('status.loading')}
)} {state.error !== null && (
- 模型操作失败:{state.error} - + {t('error.action', { message: state.error })} +
)} {state.failures.map(failure => (
- {failure.name} 加载失败:{failure.message} - + {t('warning.groupLoad', { name: failure.name, message: failure.message })} +
))}
@@ -267,7 +273,7 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje {model.description} )} {model.unlisted === true && ( - 当前模型 · 未列入目录 + {t('option.currentUnlisted')} )} @@ -281,7 +287,7 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje })}
{state.status === 'ready' && choices.length === 0 && ( -
没有可用的模型。
+
{t('empty.models')}
)} )} @@ -290,12 +296,12 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje <> {state.error !== null && (
- 模型操作失败:{state.error} - + {t('error.action', { message: state.error })} +
)} {effortChoices.length === 0 - ?
当前模型未提供推理等级。
+ ?
{t('empty.efforts')}
: effortChoices.map(level => ( )} {draft.customOpen && ( @@ -265,7 +272,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) { value={draft.custom} disabled={busy !== null} rows={2} - placeholder="输入你的答案" + placeholder={t('custom.placeholder')} onChange={(event) => { const value = event.target.value updateDraft(current => ({ @@ -288,15 +295,15 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
{error}
diff --git a/packages/client/ui-question/src/client/contract/slots.ts b/packages/client/ui-question/src/client/contract/slots.ts index e3c3e815bf..54e87c016d 100644 --- a/packages/client/ui-question/src/client/contract/slots.ts +++ b/packages/client/ui-question/src/client/contract/slots.ts @@ -6,7 +6,7 @@ * cancelled error encoding, receipt checks — lives HERE, with the package * that consumes it. */ -import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' // Also pulls ui-conversation's SlotMap merge (the 'conversation.composer' // entry) into every program that sees this contract, so PropsRuntime resolves. import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -70,8 +70,9 @@ export class PendingQuestion { /** * Full component props: the framework runtime share (chain currency + * session/global standard kit) plus the chain `matched` share — the entry's - * selector result, already narrowed to the question carrier. No injected - * share: the carrier plus the domain face above carry the whole behavior - * surface. + * selector result, already narrowed to the question carrier — plus the + * standard locale seat; the carrier plus the domain face above carry the + * whole behavior surface. */ -export type QuestionComposerProps = PropsRuntime<'conversation.composer'> & { matched: QuestionWait } +export type QuestionComposerProps = + PropsRuntime<'conversation.composer'> & { matched: QuestionWait } & PropsLocale<'question'> diff --git a/packages/client/ui-question/src/client/index.ts b/packages/client/ui-question/src/client/index.ts index 328fa6c6ce..63f7517c3a 100644 --- a/packages/client/ui-question/src/client/index.ts +++ b/packages/client/ui-question/src/client/index.ts @@ -1,18 +1,32 @@ /** * Web question plugin, browser half: QuestionComposer registered as a - * selector-routed entry of the conversation-declared composer chain. Pure - * consumer — the selector narrows the owner's currency to the question - * carrier (matched prop), and the whole behavior surface rides the carrier - * (domain encoding in contract/slots.ts PendingQuestion); no inject face, no - * service dependency beyond slots. Export discipline: packages/client/AGENTS.md. + * selector-routed entry of the conversation-declared composer chain, plus the + * `question` dictionaries. The selector narrows the owner's currency to the + * question carrier (matched prop), and the whole behavior surface rides the + * carrier (domain encoding in contract/slots.ts PendingQuestion); copy rides + * the standard locale seat. Export discipline: packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' import type { QuestionWait } from './contract/slots.ts' import { QuestionComposer } from './QuestionComposer.tsx' +import { en, zh, type QuestionKey } from './locales.ts' export { PendingQuestion } from './contract/slots.ts' export type { QuestionAnswer, QuestionComposerProps, QuestionWait } from './contract/slots.ts' +export type { QuestionKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** The question composer's copy. */ + question: QuestionKey + } +} + +/** Dictionary namespace owned by this plugin. */ +const NS = 'question' /** * Required services (cordis fiber inject). 'conversation' is an ordering @@ -20,7 +34,7 @@ export type { QuestionAnswer, QuestionComposerProps, QuestionWait } from './cont * declared by ui-conversation's apply, and register() into an undeclared * slot throws — service waiting orders this apply after the declaring one. */ -export const inject = ['slots', 'conversation'] +export const inject = ['slots', 'conversation', 'locale'] /** Chain routing: claim the composer while a question wait is pending (pure — owner props only). */ function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | null { @@ -28,14 +42,19 @@ function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | nu } /** - * Client plugin body: register the question composer into the composer chain. - * Zero business face — data and verbs both live on the matched carrier. + * Client plugin body: register the `question` dictionaries and the question + * composer into the composer chain. Zero business face — data and verbs live + * on the matched carrier; t rides the standard locale seat. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - const slots = ctx.slots + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-question: dictionaries') + ctx.effect( - () => slots.register({ name: 'conversation.composer', select: selectQuestion }, QuestionComposer), + () => ctx.slots.register( + { name: 'conversation.composer', select: selectQuestion, locale: NS }, + QuestionComposer, + ), 'ui-question: composer chain registration', ) } diff --git a/packages/client/ui-question/src/client/locales.ts b/packages/client/ui-question/src/client/locales.ts new file mode 100644 index 0000000000..fd4156759b --- /dev/null +++ b/packages/client/ui-question/src/client/locales.ts @@ -0,0 +1,38 @@ +/** `question` namespace dictionaries. */ + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'error.incomplete': '请先完成这道问题。', + 'error.unanswered': '请选择一个选项或填写自定义答案。', + 'title.multi': '可多选', + 'nav.prev': '上一题', + 'nav.next': '下一题', + 'nav.cancel': '放弃整组问题', + 'option.recommended': '推荐', + 'option.custom': '其他,请填写自定义答案', + 'custom.placeholder': '输入你的答案', + 'action.skip': '跳过本题', + 'action.submitting': '正在提交…', + 'action.submit': '提交', + 'action.next': '下一题', +} satisfies Record + +/** The question namespace key union. */ +export type QuestionKey = keyof typeof zh + +/** English dictionary, checked complete against the zh key set. */ +export const en: Record = { + 'error.incomplete': 'Please complete this question first.', + 'error.unanswered': 'Please select an option or enter a custom answer.', + 'title.multi': 'Multi-select', + 'nav.prev': 'Previous question', + 'nav.next': 'Next question', + 'nav.cancel': 'Dismiss all questions', + 'option.recommended': 'Recommended', + 'option.custom': 'Other — enter a custom answer', + 'custom.placeholder': 'Type your answer', + 'action.skip': 'Skip this question', + 'action.submitting': 'Submitting…', + 'action.submit': 'Submit', + 'action.next': 'Next', +} diff --git a/packages/client/ui-question/tests/browser-plugin.spec.ts b/packages/client/ui-question/tests/browser-plugin.spec.ts index 832da15318..0acc7fac82 100644 --- a/packages/client/ui-question/tests/browser-plugin.spec.ts +++ b/packages/client/ui-question/tests/browser-plugin.spec.ts @@ -9,6 +9,7 @@ import { Context } from 'cordis' import { describe, expect, it } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { QuestionComposer } from '../src/client/QuestionComposer.tsx' import { apply, inject } from '../src/client/index.ts' @@ -24,12 +25,13 @@ async function bench() { // 'conversation' inject is an ordering edge (the declaring plugin provides // it after declaring the chain); the bench declares the chain itself. ctx.provide('conversation', {}) + ctx.provide('locale', new LocaleService(ctx)) return { ctx, slots } } describe('apply', () => { it('declares the services it binds', () => { - expect(inject).toEqual(['slots', 'conversation']) + expect(inject).toEqual(['slots', 'conversation', 'locale']) }) it('fails loud when no live entry has declared the composer slot', async () => { @@ -38,6 +40,7 @@ describe('apply', () => { // Satisfy the ordering inject without declaring the chain: apply must // then hit the undeclared-slot throw, not sit waiting on the service. ctx.provide('conversation', {}) + ctx.provide('locale', new LocaleService(ctx)) await expect(ctx.plugin({ inject: [...inject], apply })) .rejects.toThrow(/slot "conversation.composer" is not declared/) }) @@ -47,8 +50,10 @@ describe('apply', () => { await ctx.plugin({ inject: [...inject], apply }).await() const entry = slots.entries('conversation.composer')[0]! expect(entry.component).toBe(QuestionComposer) - // The whole behavior surface rides the matched carrier: no business face. + // The whole behavior surface rides the matched carrier: no business face; + // copy rides the standard locale seat. expect(entry.inject).toBeUndefined() + expect(entry.locale).toBe('question') // The selector narrows the chain currency: question wait in → that wait; none → null. const select = entry.select as (owner: { interactions: readonly { kind: string }[] }) => unknown const question = { kind: 'question' } diff --git a/packages/client/ui-question/tests/question-composer.spec.tsx b/packages/client/ui-question/tests/question-composer.spec.tsx index 7df9f2bde9..3e5d766c6a 100644 --- a/packages/client/ui-question/tests/question-composer.spec.tsx +++ b/packages/client/ui-question/tests/question-composer.spec.tsx @@ -8,10 +8,11 @@ import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' import type { RpcReceipt } from '@deepseek-ai/dsh-client-connection/client' import { RpcId } from '@deepseek-ai/dsh-client-connection/client' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' -import { PendingQuestion } from '../src/client/contract/slots.ts' +import { PendingQuestion, type QuestionComposerProps } from '../src/client/contract/slots.ts' import { QuestionComposer, parseQuestionTitle, parseRecommendedLabel, } from '../src/client/QuestionComposer.tsx' +import { zh } from '../src/client/locales.ts' afterEach(cleanup) @@ -28,6 +29,9 @@ const kit = { useProjection: (() => undefined) as never, useInput: (() => { throw new Error('unused') }) as never, inputActions: { setDraft: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') } } as never, + // The seat's key domain is question ∪ common; the stub answers from the + // package dictionary and falls back to the key like the real chain. + t: (key => (zh as Record)[key] ?? key) as QuestionComposerProps['t'], } const QUESTIONS = [ diff --git a/packages/client/ui-question/tsconfig.json b/packages/client/ui-question/tsconfig.json index 4c4138b80d..6b5b0acc3a 100644 --- a/packages/client/ui-question/tsconfig.json +++ b/packages/client/ui-question/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../connection" }, + { + "path": "../locale" + }, { "path": "../runtime" }, diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index 7d85b111cf..7bff9523ed 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -25,7 +25,8 @@ "dshClient": { "inject": [ "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-layout" + "@deepseek-ai/dsh-client-ui-layout", + "@deepseek-ai/dsh-client-locale" ], "platform": "web" }, @@ -38,6 +39,7 @@ "clsx": "^2.0.0" }, "peerDependencies": { + "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", @@ -46,6 +48,7 @@ "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index 30d705c780..f7b83c29b6 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -32,6 +32,7 @@ export function SidebarRoot({ width, startSession, toggleSidebar, + t, renderSlot, }: SidebarRootComponentProps) { // Wide content stays mounted while the collapse animates (fading via @@ -67,7 +68,7 @@ export function SidebarRoot({ diff --git a/packages/client/ui-sidebar/src/client/contract/slots.ts b/packages/client/ui-sidebar/src/client/contract/slots.ts index 4362b7d071..dea4fea6c9 100644 --- a/packages/client/ui-sidebar/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar/src/client/contract/slots.ts @@ -6,7 +6,7 @@ * `sidebar.workspaces` registrant's (ui-workspace), and the foot is the * `sidebar.settings` registrant's (ui-settings). */ -import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pulls ui-layout's SlotMap merge (the 'sidebar' entry) into every // program that sees this contract, so PropsRuntime<'sidebar'> resolves. import type {} from '@deepseek-ai/dsh-client-ui-layout/client' @@ -68,7 +68,9 @@ export type SidebarRootInjected = { /** * Full component props: layout owner state/actions plus the declared holes' - * render shares and this package's injected callbacks. No store is registered. + * render shares, this package's injected callbacks, and the standard locale + * seat. No store is registered. */ export type SidebarRootComponentProps = - PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspaces' | 'sidebar.settings'> & SidebarRootInjected + PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspaces' | 'sidebar.settings'> + & SidebarRootInjected & PropsLocale<'sidebar'> diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index 061f587dbd..3d7ed23aa4 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -1,17 +1,33 @@ /** Registers the sidebar shell into the layout-owned slot. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' import type { SidebarRootInjected } from './contract/slots.ts' import { SidebarRoot } from './SidebarRoot.tsx' +import { en, zh, type SidebarKey } from './locales.ts' export type { SidebarRootComponentProps, SidebarRootInjected, SidebarSectionOwnerProps, SidebarSettingsOwnerProps } from './contract/slots.ts' +export type { SidebarKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Sidebar shell controls copy. */ + sidebar: SidebarKey + } +} + +/** Dictionary namespace owned by this plugin (shell controls copy). */ +const NS = 'sidebar' /** Services required by the sidebar plugin. */ -export const inject = ['slots', 'layout', 'sessions', 'workspaces'] +export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale'] /** Registers the sidebar shell and its service callbacks. * @param ctx - Client root context. */ export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-sidebar: dictionaries') + const injectProps = (): SidebarRootInjected => ({ // The shell's New Session button rides the runtime's shared action // (recent-Workspace targeting; explicit Workspace wins for scoped actions). @@ -21,6 +37,7 @@ export function apply(ctx: ClientContext): void { ctx.effect( () => ctx.slots.register({ name: 'sidebar', + locale: NS, // The shell owns geometry; ui-workspace registers the whole browsing // region (header, search, session list, workspace dialogs), ui-settings // registers the foot trigger + settings panel. diff --git a/packages/client/ui-sidebar/src/client/locales.ts b/packages/client/ui-sidebar/src/client/locales.ts new file mode 100644 index 0000000000..21a359c15b --- /dev/null +++ b/packages/client/ui-sidebar/src/client/locales.ts @@ -0,0 +1,20 @@ +/** `sidebar` namespace dictionaries: shell controls (brand row, New Session, fold toggle). */ + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'session.new': '新会话', + 'session.new.label': '新建会话', + 'toggle.open': '打开侧边栏', + 'toggle.collapse': '收起侧边栏', +} satisfies Record + +/** The sidebar namespace key union. */ +export type SidebarKey = keyof typeof zh + +/** English dictionary, checked complete against the zh key set. */ +export const en: Record = { + 'session.new': 'New Session', + 'session.new.label': 'New session', + 'toggle.open': 'Open sidebar', + 'toggle.collapse': 'Collapse sidebar', +} diff --git a/packages/client/ui-sidebar/tests/apply.spec.tsx b/packages/client/ui-sidebar/tests/apply.spec.tsx index c21cd5a53c..ccd997be76 100644 --- a/packages/client/ui-sidebar/tests/apply.spec.tsx +++ b/packages/client/ui-sidebar/tests/apply.spec.tsx @@ -2,6 +2,7 @@ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client' import type { SidebarRootInjected } from '@deepseek-ai/dsh-client-ui-sidebar/client' @@ -14,6 +15,7 @@ async function bench(declare = true) { ctx.provide('layout', layout) ctx.provide('sessions', sessions as never) ctx.provide('workspaces', workspaces as never) + ctx.provide('locale', new LocaleService(ctx)) const slots = ctx.get('slots') as SlotsService if (declare) { slots.register( @@ -26,7 +28,7 @@ async function bench(declare = true) { describe('ui-sidebar apply', () => { it('declares only the services it uses', () => { - expect(inject).toEqual(['slots', 'layout', 'sessions', 'workspaces']) + expect(inject).toEqual(['slots', 'layout', 'sessions', 'workspaces', 'locale']) }) it('registers the shell and declares the browsing-region hole', async () => { @@ -34,6 +36,8 @@ describe('ui-sidebar apply', () => { await b.ctx.plugin({ inject: [...inject], apply }).await() expect(b.slots.entries('sidebar')).toHaveLength(1) expect(b.slots.spec('sidebar.workspaces')).toEqual({ kind: 'single', scope: 'root' }) + // Copy rides the standard locale seat, not the inject face. + expect(b.slots.entries('sidebar')[0]!.locale).toBe('sidebar') const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)() expect(Object.keys(injected)).toEqual(['startSession', 'toggleSidebar']) // Both arms delegate to the runtime's shared New Session action. diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx index 3c8086e4ce..925c18f8f3 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx @@ -3,6 +3,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SidebarRootComponentProps, SidebarSectionOwnerProps, SidebarSettingsOwnerProps } from '../src/client/contract/slots.ts' import { SidebarRoot } from '../src/client/SidebarRoot.tsx' +import { en } from '../src/client/locales.ts' + +// English-dictionary translate stub: the shell renders the same copy the +// assertions below query by accessible name. +const t: SidebarRootComponentProps['t'] = key => (en as Record)[key] ?? key afterEach(() => { cleanup() @@ -23,7 +28,7 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w { if (key === 'sidebar.settings') { settingsOwner = owner diff --git a/packages/client/ui-sidebar/tests/sidebar-snapshot.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-snapshot.spec.tsx index d9b1bcd473..2145b02f9e 100644 --- a/packages/client/ui-sidebar/tests/sidebar-snapshot.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-snapshot.spec.tsx @@ -11,6 +11,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, waitFor } from '@testing-library/react' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client' afterEach(cleanup) @@ -18,6 +19,12 @@ afterEach(cleanup) async function bench() { const runtime = await SlotTestRuntime.create() runtime.provide('layout', { toggleSidebar: vi.fn() }) + // English locale pins the snapshots to the copy they were recorded with; + // the installed face backs the entry's standard `t` seat. + const locale = new LocaleService(runtime.ctx) + locale.setLocale('en') + runtime.provide('locale', locale) + runtime.slots.installLocale(locale) await runtime.declare({ 'sidebar': { kind: 'single', scope: 'root' } }) await runtime.mount({ inject: [...inject], apply }) return runtime diff --git a/packages/client/ui-sidebar/tsconfig.json b/packages/client/ui-sidebar/tsconfig.json index c48fe2567f..d976720dbd 100644 --- a/packages/client/ui-sidebar/tsconfig.json +++ b/packages/client/ui-sidebar/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../ui-layout" }, + { + "path": "../locale" + }, { "path": "../../support/invariants" } diff --git a/packages/client/ui-slots/src/index.ts b/packages/client/ui-slots/src/index.ts index 7b980571ef..2e4ce278b5 100644 --- a/packages/client/ui-slots/src/index.ts +++ b/packages/client/ui-slots/src/index.ts @@ -24,6 +24,67 @@ export * from './deferred.ts' /** Slot contract table. Owners extend via declaration merging; entries are {@link SlotEntryDef}. */ export interface SlotMap {} +/** + * Locale namespace table. Dictionary owners extend via declaration merging + * (exactly like {@link SlotMap}, and declared in this entry module for the + * same lexical-merge reason): the key is the namespace string, the value is + * the union of its dictionary keys. Register sites declare one of these + * namespaces (`locale:`), which puts the typed `t` standard seat on the + * component props. + */ +export interface LocaleNamespaceMap {} + +/** + * Translate a dictionary key with optional `{name}` template params. + * `K` narrows the accepted keys to the owning namespace's dictionary union + * (plus the shared common vocabulary where composed). + */ +export type Translate = + (key: K, params?: Record) => string + +/** + * The shared `common` vocabulary keys as merged by the locale plugin; + * resolves to `never` in programs without the merge (this package's tests), + * keeping the union collapse harmless. + */ +export type CommonKeyOf = LocaleNamespaceMap extends { common: infer C } ? C & string : never + +/** + * Key domain of a namespace-bound translate: the namespace's own dictionary + * union plus the shared common vocabulary (the lookup chain consults common + * after the namespace misses). + */ +export type LocaleKeysOf = + (LocaleNamespaceMap[N] & string) | CommonKeyOf + +/** + * Namespace-addressed translate — the developer-facing alias over + * {@link Translate}: `TranslateNS<'model'>` is the translate function of the + * `model` namespace (key domain = its dictionary union plus the shared + * common vocabulary), the exact type of the framework-injected `t` seat and + * of the locale service's typed `bind`. + */ +export type TranslateNS = Translate> + +/** + * Dictionary shape for a declared namespace: exactly the keys the namespace + * merged into {@link LocaleNamespaceMap} — a missing or extra key at a typed + * registration site is a compile error. + */ +export type LocaleDictOf = + Record + +/** + * Locale share of the composed component props: the framework-injected `t` + * seat, present exactly on entries whose registration declares `locale:`. + */ +export type PropsLocale = N extends keyof LocaleNamespaceMap & string + ? { + /** Translate a dictionary key of the declared namespace (or the shared common vocabulary). */ + t: TranslateNS + } + : object + /** Slot cardinality: single occupant, ordered list, key-dispatched, or selector-routed chain. */ export type SlotKind = 'single' | 'list' | 'keyed' | 'chain' @@ -244,10 +305,11 @@ export type InjectFace = I extends { hooks: infer HS extends HooksSources } ? Omit & PropsHooks : I /** - * The four-share component props intersection: runtime share (SlotMap) + + * The composed component props intersection: runtime share (SlotMap) + * child-render share (children declaration) + store share (declared handle) + * the registrant's injected business face (its hooks compartment bound, see - * {@link InjectFace}). Each share derives from its single source of truth; + * {@link InjectFace}) + the locale `t` seat (declared namespace, see + * {@link PropsLocale}). Each share derives from its single source of truth; * components reference this composition, never re-type it. */ export type ComposedProps< @@ -256,7 +318,8 @@ export type ComposedProps< H, I extends object, M = never, -> = PropsRuntime & PropsRenderSlots & PropsStore & InjectFace & MatchedShare + N = undefined, +> = PropsRuntime & PropsRenderSlots & PropsStore & InjectFace & MatchedShare & PropsLocale /** * Inject factory parameter list, derived from the registration's declaration: @@ -303,13 +366,20 @@ type RendersCheck = : unknown /** Common register options share (see {@link SlotCore.register} for semantics). */ -type BaseOptions = { +type BaseOptions = { /** Target slot key (the entry contributes INTO this slot). */ name: K /** Child-slot declaration + render authorization + runtime spec, in one table. */ children?: D /** Store seat: a shared handle (apply-constructed) or an exclusive factory (framework-called per entry x scope). */ store?: H + /** + * Dictionary namespace of this entry's copy. Declaring it puts the + * framework-synthesized `t` seat (typed to the namespace's dictionary + * union) on the component props; rendering requires an installed locale + * face — fails loud otherwise. + */ + locale?: N /** Registrant identity label for diagnostics (the runtime Service wrapper stamps the caller's fiber name). */ registrant?: string } & KindOptions @@ -330,6 +400,8 @@ export interface StoredEntry { children?: Readonly>> | undefined /** Declared store seat (instance resolution and lifecycle live with the host machinery). */ store?: StoreDecl | undefined + /** Declared dictionary namespace (the render machinery synthesizes the `t` seat from it). */ + locale?: string | undefined /** Diagnostics label of who registered. */ registrant?: string | undefined } @@ -350,6 +422,7 @@ interface ErasedOptions { priority?: number | undefined children?: Record> | undefined store?: StoreDecl | undefined + locale?: string | undefined /* eslint-disable-next-line @typescript-eslint/no-explicit-any -- * implementation-signature position only (both public overloads type inject * exactly); `never[]` would fail overload-to-implementation compatibility @@ -432,11 +505,12 @@ export class SlotCore { const D extends ChildrenDecl = Record, H extends StoreDecl | undefined = undefined, M = never, + N extends (keyof LocaleNamespaceMap & string) | undefined = undefined, C extends SlotComponent = SlotComponent, >( - options: BaseOptions & { inject?: undefined }, + options: BaseOptions & { inject?: undefined }, component: C - & SlotComponent & keyof SlotMap & string, HandleOf>, object, NoInfer>> + & SlotComponent & keyof SlotMap & string, HandleOf>, object, NoInfer, NoInfer>> & RendersCheck, ): () => void /** @@ -455,11 +529,12 @@ export class SlotCore { const D extends ChildrenDecl = Record, H extends StoreDecl | undefined = undefined, M = never, + N extends (keyof LocaleNamespaceMap & string) | undefined = undefined, C extends SlotComponent = SlotComponent, >( - options: BaseOptions & { inject: (...args: InjectParams) => I }, + options: BaseOptions & { inject: (...args: InjectParams) => I }, component: C - & SlotComponent & keyof SlotMap & string, HandleOf>, I, NoInfer>> + & SlotComponent & keyof SlotMap & string, HandleOf>, I, NoInfer, NoInfer>> & RendersCheck, ): () => void register(options: ErasedOptions, component: unknown): () => void { @@ -523,6 +598,7 @@ export class SlotCore { ...(options.inject !== undefined ? { inject: options.inject } : {}), ...(options.children !== undefined ? { children: options.children } : {}), ...(options.store !== undefined ? { store: options.store } : {}), + ...(options.locale !== undefined ? { locale: options.locale } : {}), ...(options.registrant !== undefined ? { registrant: options.registrant } : {}), } const next = [...rec.entries, entry] diff --git a/packages/client/ui-slots/src/renderer.ts b/packages/client/ui-slots/src/renderer.ts index 1e180eff7d..3bcb864a9d 100644 --- a/packages/client/ui-slots/src/renderer.ts +++ b/packages/client/ui-slots/src/renderer.ts @@ -1,6 +1,31 @@ /** React-free contracts between the slot host and an installed renderer. */ import type { ReactNode } from 'react' -import type { SlotEntryDef, SlotSpec, StoredEntry } from './index.ts' +import type { SlotEntryDef, SlotSpec, StoredEntry, Translate } from './index.ts' + +/** + * The locale face the render machinery consumes: namespace binding plus an + * observable revision (getSnapshot/subscribe pair — the same HostObservable + * currency as every other standard-kit source). The revision moves on every + * active-locale or registry change; the renderer re-derives each entry's `t` + * from (namespace, revision), so a locale switch hands out NEW function + * references and memoized components re-render naturally. Implemented by the + * locale plugin, installed through the runtime SlotsService (installLocale). + * Install before the first render that needs the seat: outlets bind their + * revision subscription at mount, and a face appearing later has no channel + * to notify already-mounted outlets (the locale plugin is immediately-tier + * infrastructure, so normal compositions install during boot). + */ +export interface LocaleFace extends HostObservable<{ revision: number }> { + /** + * Bind a namespace to a translate function reading the active locale at + * call time. Identity may be stable per namespace — freshness of rendered + * text is carried by the renderer's (ns, revision) seat derivation, not by + * this binding. + * @param ns - dictionary namespace. + * @returns the namespace-bound translate function. + */ + bind(ns: string): Translate +} /** Minimal observable surface for host-provided standard-kit data sources. */ export interface HostObservable { @@ -128,6 +153,12 @@ export interface SlotRendererHost { /** Workspace list source backing the useWorkspaces standard hook. */ list: HostObservable } + /** + * Installed locale face backing the `t` standard seat (absent until the + * locale plugin installs one; rendering an entry that declared `locale:` + * without it is an assembly failure). + */ + locale?: LocaleFace | undefined } /** The install seam: runtime owns install()/renderSlot(); web-react implements rendering. */ diff --git a/packages/client/ui-theme/src/client/AppearanceRow.tsx b/packages/client/ui-theme/src/client/AppearanceRow.tsx index b4aad1725d..465fc07b4a 100644 --- a/packages/client/ui-theme/src/client/AppearanceRow.tsx +++ b/packages/client/ui-theme/src/client/AppearanceRow.tsx @@ -9,26 +9,25 @@ import clsx from 'clsx' import { IconDarkOutline16, IconFollowsystemOutline16, IconLightOutline16, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import type { ThemePreference } from './index.ts' import type {} from './settings-contract.ts' import type { createAppearanceRowStore } from './settings-store.ts' import css from './AppearanceRow.module.css' -/** Injected business face: namespace-bound translate + the preference write. */ +/** Injected business face: the preference write (t rides the standard locale seat). */ export interface AppearanceRowInjected { - /** Translate a `settings.theme` dictionary key to the active-locale text. */ - t: (key: string) => string /** Switch the theme preference. */ setTheme: (id: ThemePreference) => void } -/** Full component props: runtime share + store share + injected face. */ +/** Full component props: runtime share + store share + locale seat + injected face. */ export type AppearanceRowComponentProps = - PropsRuntime<'settings.general.item'> & PropsStore> & AppearanceRowInjected + PropsRuntime<'settings.general.item'> & PropsStore> + & PropsLocale<'settings.theme'> & AppearanceRowInjected /** Cube order and icons (figma 501:30015-30017: Light, Dark, System). */ -const CUBES: readonly { id: ThemePreference; labelKey: string; Icon: typeof IconLightOutline16 }[] = [ +const CUBES: readonly { id: ThemePreference; labelKey: 'appearance.light' | 'appearance.dark' | 'appearance.system'; Icon: typeof IconLightOutline16 }[] = [ { id: 'light', labelKey: 'appearance.light', Icon: IconLightOutline16 }, { id: 'dark', labelKey: 'appearance.dark', Icon: IconDarkOutline16 }, { id: 'system', labelKey: 'appearance.system', Icon: IconFollowsystemOutline16 }, diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index dd11c98f06..f833a7392a 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -21,6 +21,13 @@ export type { AppearanceRowState } from './settings-store.ts' /** Namespace owning this feature's settings-row copy. */ export const SETTINGS_NS = 'settings.theme' +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** The Appearance settings row's copy. */ + 'settings.theme': 'appearance.title' | 'appearance.light' | 'appearance.dark' | 'appearance.system' + } +} + /** Theme token dictionary: --dsw-alias-* overrides keyed by variable name. */ export type ThemeTokens = Record @@ -228,23 +235,20 @@ export function apply(ctx: ClientContext): void { const theme = new ThemeService(ctx) ctx.provide('theme', theme) - ctx.effect(() => { - const disposers = [ - ctx.locale.register(SETTINGS_NS, 'zh', { - 'appearance.title': '外观', - 'appearance.light': '浅色', - 'appearance.dark': '深色', - 'appearance.system': '跟随系统', - }), - ctx.locale.register(SETTINGS_NS, 'en', { - 'appearance.title': 'Appearance', - 'appearance.light': 'Light', - 'appearance.dark': 'Dark', - 'appearance.system': 'System', - }), - ] - return () => { for (const dispose of disposers) dispose() } - }, 'ui-theme: settings row dictionaries') + ctx.effect(() => ctx.locale.register(SETTINGS_NS, { + zh: { + 'appearance.title': '外观', + 'appearance.light': '浅色', + 'appearance.dark': '深色', + 'appearance.system': '跟随系统', + }, + en: { + 'appearance.title': 'Appearance', + 'appearance.light': 'Light', + 'appearance.dark': 'Dark', + 'appearance.system': 'System', + }, + }), 'ui-theme: settings row dictionaries') const store = createAppearanceRowStore() let bound: BoundActions | undefined @@ -258,7 +262,6 @@ export function apply(ctx: ClientContext): void { // first render (the store's revision guard drops stale duplicates). sync(theme.getTheme()) return { - t: ctx.locale.bind(SETTINGS_NS), setTheme: (id) => { theme.setTheme(id) }, } } @@ -269,6 +272,7 @@ export function apply(ctx: ClientContext): void { id: 'appearance', order: 10, store, + locale: SETTINGS_NS, inject: injected, }, AppearanceRow)) return () => { deferred.dispose() } diff --git a/packages/client/ui-theme/tests/apply.spec.ts b/packages/client/ui-theme/tests/apply.spec.ts index 9852b93e66..ea9da5cfde 100644 --- a/packages/client/ui-theme/tests/apply.spec.ts +++ b/packages/client/ui-theme/tests/apply.spec.ts @@ -73,7 +73,8 @@ describe('ui-theme apply', () => { const { instance, face } = faceOf(b.slots) // The inject-time re-sync sealed the init window: the mirror is current. expect(instance.getSnapshot().preference).toBe('dark') - expect(face.t('appearance.dark')).toBe('深色') + // Copy rides the standard locale seat: the entry declares the namespace. + expect(b.slots.entries(SLOT).find(e => e.component === AppearanceRow)!.locale).toBe(SETTINGS_NS) face.setTheme('system') expect(theme.getTheme().preference).toBe('system') diff --git a/packages/client/web-react/src/scoped-slots.tsx b/packages/client/web-react/src/scoped-slots.tsx index 3829480b31..950dfd4a1f 100644 --- a/packages/client/web-react/src/scoped-slots.tsx +++ b/packages/client/web-react/src/scoped-slots.tsx @@ -5,8 +5,9 @@ import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react' import { SlotOwnershipError, StaleAuthorizationError, - type ChainRenderOpts, type HostObservable, type RenderOpts, type SessionMaybeProvideInfo, - type SessionProvideInfo, type SlotRenderer, type SlotRendererHost, type SlotScope, type StoredEntry, + type ChainRenderOpts, type HostObservable, type LocaleFace, type RenderOpts, + type SessionMaybeProvideInfo, type SessionProvideInfo, type SlotRenderer, type SlotRendererHost, + type SlotScope, type StoredEntry, type Translate, } from '@deepseek-ai/dsh-client-ui-slots' import { HostContext, SessionMaybeProvider, SessionProvider, SlotAssemblyError, maybeObservableHook, @@ -159,6 +160,74 @@ function cachedSessionMaybeInject( return props } +/** + * Locale `t` seat bindings, cached per (face, namespace, revision). The + * revision is part of the cache key ON PURPOSE: a locale switch mints a NEW + * function reference per namespace, so `React.memo` components taking `t` + * re-render through ordinary shallow comparison — freshness rides identity, + * no extra invalidation channel. Within one revision the reference is stable + * (memoized children do not churn on unrelated re-renders). + */ +const localeSeatCache = new WeakMap>() + +function localeSeat(face: LocaleFace, ns: string): Translate { + let perNs = localeSeatCache.get(face) + if (!perNs) { + perNs = new Map() + localeSeatCache.set(face, perNs) + } + const revision = face.getSnapshot().revision + const cached = perNs.get(ns) + if (cached && cached.revision === revision) return cached.t + const bound = face.bind(ns) + // Fresh wrapper per revision: bind() itself may return a stable reference. + const t: Translate = (key, params) => bound(key, params) + perNs.set(ns, { revision, t }) + return t +} + +const noopSubscribe = (): (() => void) => () => {} +const zeroRevision = (): number => 0 + +/** + * Per-face subscribe/getSnapshot closure pair. Cached by face identity: the + * face is one global source shared by every outlet, and uSES resubscribes + * whenever the subscribe reference changes — fresh closures per render would + * churn one unsubscribe/resubscribe pair per outlet per render. + */ +const localeSubscriptionCache = new WeakMap void) => () => void + getRevision: () => number +}>() + +function localeSubscription(face: LocaleFace): { subscribe: (fn: () => void) => () => void; getRevision: () => number } { + let cached = localeSubscriptionCache.get(face) + if (!cached) { + cached = { + subscribe: fn => face.subscribe(fn), + getRevision: () => face.getSnapshot().revision, + } + localeSubscriptionCache.set(face, cached) + } + return cached +} + +/** + * Subscribe an outlet to the installed locale face's revision (0 while none + * is installed — exactly one uSES call either way, keeping hook order + * stable). Every outlet re-renders on a locale switch; entry bodies then + * re-derive their `t` seat at the new revision. The face must be installed + * before the first render that needs it — a face appearing later has no + * notification channel to already-mounted outlets. + */ +function useLocaleRevision(face: LocaleFace | undefined): number { + const subscription = face !== undefined ? localeSubscription(face) : undefined + return useSyncExternalStore( + subscription?.subscribe ?? noopSubscribe, + subscription?.getRevision ?? zeroRevision, + ) +} + /** * Entry-identity React keys for chain boundaries. A chain outlet renders ONE * elected entry through an error boundary; without a key, a boundary that @@ -242,6 +311,16 @@ function standardKit( // reader, bound per provide bundle (cached by info identity). kit['useProjection'] = projectionHook(info) } + if (entry.locale !== undefined) { + const face = host.locale + // Loud assembly failure: locale is immediately-tier infrastructure; a + // declared namespace with no installed face is a miswired composition. + if (face === undefined) { + throw new SlotAssemblyError( + `entry declares locale namespace '${entry.locale}' but no locale face is installed (locale plugin missing from the composition?)`) + } + kit['t'] = localeSeat(face, entry.locale) + } const store = scope === 'session-maybe' && info?.sessionId === undefined ? undefined : host.storeOf(entry, info?.sessionId) @@ -329,6 +408,9 @@ function SlotOutlet({ slotKey, ownerProps, opts }: { fn => host.subscribe(slotKey, fn), () => host.getVersion(slotKey), ) + // Locale revision tick: a locale switch re-renders every outlet, and entry + // bodies re-derive their `t` seat at the new revision (fresh identity). + useLocaleRevision(host.locale) const sessionInfo = useSessionMaybeProvideInfo() const spec = host.specOf(slotKey) // Undeclared (or no-longer-declared) keys render empty: a declaring entry's @@ -435,6 +517,7 @@ function RootOutlet({ ownerProps }: { ownerProps: object }) { fn => host.subscribe('root', fn), () => host.getVersion('root'), ) + useLocaleRevision(host.locale) const entry = host.entriesOf('root')[0] if (!entry) throw new SlotAssemblyError("renderSlot('root') before any 'root' registration (boot order)") return ( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed70444992..33507a1e9b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1130,6 +1130,9 @@ importers: '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime @@ -1291,6 +1294,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -1377,6 +1383,9 @@ importers: specifier: ^2.0.0 version: 2.1.1 devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime From 2c5114c0603ea683c7a292d7e4f2093441f76638 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:04:58 +0800 Subject: [PATCH 2/3] feat(client): adopt the locale seat in theme, sidebar, question, and model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each package ships its zh/en dictionaries as satisfies-typed pairs (zh is the key-set source of truth; en is checked complete against it), merges its namespace into LocaleNamespaceMap, and declares locale: NS at register — components read the framework-injected typed t seat instead of a hand-carried inject member. Overlapping verbatim words (retry, submit, submitting) drop out of package dictionaries in favor of the shared common vocabulary; the question composer stores validation feedback as dictionary keys so shown feedback follows a locale switch. --- .../ui-model/src/client/ModelSelect.tsx | 4 ++-- .../client/ui-model/src/client/locales.ts | 6 ++---- .../ui-model/tests/model-select.spec.tsx | 9 ++++++--- packages/client/ui-question/package.json | 1 + .../src/client/QuestionComposer.tsx | 19 ++++++++++-------- .../client/ui-question/src/client/locales.ts | 8 ++------ .../tests/question-composer.spec.tsx | 9 ++++++--- .../client/ui-sidebar/src/client/locales.ts | 4 ++-- .../ui-theme/src/client/AppearanceRow.tsx | 3 ++- packages/client/ui-theme/src/client/index.ts | 19 ++++-------------- .../client/ui-theme/src/client/locales.ts | 20 +++++++++++++++++++ 11 files changed, 58 insertions(+), 44 deletions(-) create mode 100644 packages/client/ui-theme/src/client/locales.ts diff --git a/packages/client/ui-model/src/client/ModelSelect.tsx b/packages/client/ui-model/src/client/ModelSelect.tsx index c170fd06f0..4cc2687832 100644 --- a/packages/client/ui-model/src/client/ModelSelect.tsx +++ b/packages/client/ui-model/src/client/ModelSelect.tsx @@ -238,13 +238,13 @@ export function ModelSelect( {state.error !== null && (
{t('error.action', { message: state.error })} - +
)} {state.failures.map(failure => (
{t('warning.groupLoad', { name: failure.name, message: failure.message })} - +
))}
diff --git a/packages/client/ui-model/src/client/locales.ts b/packages/client/ui-model/src/client/locales.ts index d93e9b76d5..5a835f2b81 100644 --- a/packages/client/ui-model/src/client/locales.ts +++ b/packages/client/ui-model/src/client/locales.ts @@ -14,7 +14,6 @@ export const zh = { 'effort.providerDefault': '服务商默认', 'status.loading': '正在刷新模型列表…', 'error.action': '模型操作失败:{message}', - 'action.retry': '重试', 'action.reload': '重新加载', 'warning.groupLoad': '{name} 加载失败:{message}', 'option.currentUnlisted': '当前模型 · 未列入目录', @@ -26,7 +25,7 @@ export const zh = { export type ModelKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ -export const en: Record = { +export const en = { 'command.description': 'Select the model for this conversation', 'option.unlisted': '{group} · Not in catalog', 'option.loadError': 'Catalog failed to load: {message}', @@ -39,10 +38,9 @@ export const en: Record = { 'effort.providerDefault': 'Provider default', 'status.loading': 'Refreshing model list…', 'error.action': 'Model operation failed: {message}', - 'action.retry': 'Retry', 'action.reload': 'Reload', 'warning.groupLoad': '{name} failed to load: {message}', 'option.currentUnlisted': 'Current model · Not in catalog', 'empty.models': 'No models available.', 'empty.efforts': 'This model provides no reasoning effort levels.', -} +} satisfies Record diff --git a/packages/client/ui-model/tests/model-select.spec.tsx b/packages/client/ui-model/tests/model-select.spec.tsx index f2d9db3f18..533587aa6c 100644 --- a/packages/client/ui-model/tests/model-select.spec.tsx +++ b/packages/client/ui-model/tests/model-select.spec.tsx @@ -7,11 +7,14 @@ import type { ComponentProps } from 'react' import type { ModelDirectoryState } from '../src/client/directory.ts' import { ModelSelect } from '../src/client/ModelSelect.tsx' import { zh } from '../src/client/locales.ts' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -// The seat's key domain is model ∪ common; the stub answers from the package -// dictionary (with template params) and falls back to the key like the real chain. +// The seat's key domain is model ∪ common; the stub mirrors the real lookup +// chain: package dictionary, then common vocabulary, then the key. const t: ComponentProps['t'] = (key, params) => { - const template = (zh as Record)[key] ?? key + const template = (zh as Record)[key] + ?? (commonZh as Record)[key] + ?? key return params === undefined ? template : template.replace(/\{(\w+)\}/g, (match, name: string) => name in params ? String(params[name]) : match) diff --git a/packages/client/ui-question/package.json b/packages/client/ui-question/package.json index f23c3d8fc8..fbf1dbb098 100644 --- a/packages/client/ui-question/package.json +++ b/packages/client/ui-question/package.json @@ -45,6 +45,7 @@ "react": "^18.2.0" }, "peerDependencies": { + "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, diff --git a/packages/client/ui-question/src/client/QuestionComposer.tsx b/packages/client/ui-question/src/client/QuestionComposer.tsx index 055a3e5af6..c30737cfea 100644 --- a/packages/client/ui-question/src/client/QuestionComposer.tsx +++ b/packages/client/ui-question/src/client/QuestionComposer.tsx @@ -65,7 +65,10 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick(null) - const [error, setError] = useState(null) + // Validation feedback is stored as a dictionary KEY and translated at + // render, so already-shown feedback follows a locale switch; runtime + // failure messages (finished strings from the wire) pass through verbatim. + const [error, setError] = useState<{ key: 'error.incomplete' | 'error.unanswered' } | { text: string } | null>(null) // index stays in bounds (every setIndex site clamps) and drafts mirrors questions 1:1. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const question = questions[index]! @@ -78,7 +81,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick { setBusy(null) - setError(cause instanceof Error ? cause.message : String(cause)) + setError({ text: cause instanceof Error ? cause.message : String(cause) }) }) } @@ -114,7 +117,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick !completed(item)) if (missing >= 0) { setIndex(missing) - setError(t('error.incomplete')) + setError({ key: 'error.incomplete' }) return } const answer: QuestionAnswer = { @@ -133,13 +136,13 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick { setBusy(null) - setError(cause instanceof Error ? cause.message : String(cause)) + setError({ text: cause instanceof Error ? cause.message : String(cause) }) }) } const continueFlow = (): void => { if (!answered(draft)) { - setError(t('error.unanswered')) + setError({ key: 'error.unanswered' }) return } if (index < questions.length - 1) { @@ -292,7 +295,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick
-
{error}
+
{error === null ? null : 'key' in error ? t(error.key) : error.text}
diff --git a/packages/client/ui-question/src/client/locales.ts b/packages/client/ui-question/src/client/locales.ts index fd4156759b..95465f4af2 100644 --- a/packages/client/ui-question/src/client/locales.ts +++ b/packages/client/ui-question/src/client/locales.ts @@ -12,8 +12,6 @@ export const zh = { 'option.custom': '其他,请填写自定义答案', 'custom.placeholder': '输入你的答案', 'action.skip': '跳过本题', - 'action.submitting': '正在提交…', - 'action.submit': '提交', 'action.next': '下一题', } satisfies Record @@ -21,7 +19,7 @@ export const zh = { export type QuestionKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ -export const en: Record = { +export const en = { 'error.incomplete': 'Please complete this question first.', 'error.unanswered': 'Please select an option or enter a custom answer.', 'title.multi': 'Multi-select', @@ -32,7 +30,5 @@ export const en: Record = { 'option.custom': 'Other — enter a custom answer', 'custom.placeholder': 'Type your answer', 'action.skip': 'Skip this question', - 'action.submitting': 'Submitting…', - 'action.submit': 'Submit', 'action.next': 'Next', -} +} satisfies Record diff --git a/packages/client/ui-question/tests/question-composer.spec.tsx b/packages/client/ui-question/tests/question-composer.spec.tsx index 3e5d766c6a..4a8983571c 100644 --- a/packages/client/ui-question/tests/question-composer.spec.tsx +++ b/packages/client/ui-question/tests/question-composer.spec.tsx @@ -13,6 +13,7 @@ import { QuestionComposer, parseQuestionTitle, parseRecommendedLabel, } from '../src/client/QuestionComposer.tsx' import { zh } from '../src/client/locales.ts' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' afterEach(cleanup) @@ -29,9 +30,11 @@ const kit = { useProjection: (() => undefined) as never, useInput: (() => { throw new Error('unused') }) as never, inputActions: { setDraft: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') } } as never, - // The seat's key domain is question ∪ common; the stub answers from the - // package dictionary and falls back to the key like the real chain. - t: (key => (zh as Record)[key] ?? key) as QuestionComposerProps['t'], + // The seat's key domain is question ∪ common; the stub mirrors the real + // lookup chain: package dictionary, then common vocabulary, then the key. + t: (key => (zh as Record)[key] + ?? (commonZh as Record)[key] + ?? key) as QuestionComposerProps['t'], } const QUESTIONS = [ diff --git a/packages/client/ui-sidebar/src/client/locales.ts b/packages/client/ui-sidebar/src/client/locales.ts index 21a359c15b..8cf5ac6d7b 100644 --- a/packages/client/ui-sidebar/src/client/locales.ts +++ b/packages/client/ui-sidebar/src/client/locales.ts @@ -12,9 +12,9 @@ export const zh = { export type SidebarKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ -export const en: Record = { +export const en = { 'session.new': 'New Session', 'session.new.label': 'New session', 'toggle.open': 'Open sidebar', 'toggle.collapse': 'Collapse sidebar', -} +} satisfies Record diff --git a/packages/client/ui-theme/src/client/AppearanceRow.tsx b/packages/client/ui-theme/src/client/AppearanceRow.tsx index 465fc07b4a..a0e04b67a6 100644 --- a/packages/client/ui-theme/src/client/AppearanceRow.tsx +++ b/packages/client/ui-theme/src/client/AppearanceRow.tsx @@ -11,6 +11,7 @@ import { } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import type { ThemePreference } from './index.ts' +import type { ThemeKey } from './locales.ts' import type {} from './settings-contract.ts' import type { createAppearanceRowStore } from './settings-store.ts' import css from './AppearanceRow.module.css' @@ -27,7 +28,7 @@ export type AppearanceRowComponentProps = & PropsLocale<'settings.theme'> & AppearanceRowInjected /** Cube order and icons (figma 501:30015-30017: Light, Dark, System). */ -const CUBES: readonly { id: ThemePreference; labelKey: 'appearance.light' | 'appearance.dark' | 'appearance.system'; Icon: typeof IconLightOutline16 }[] = [ +const CUBES: readonly { id: ThemePreference; labelKey: ThemeKey; Icon: typeof IconLightOutline16 }[] = [ { id: 'light', labelKey: 'appearance.light', Icon: IconLightOutline16 }, { id: 'dark', labelKey: 'appearance.dark', Icon: IconDarkOutline16 }, { id: 'system', labelKey: 'appearance.system', Icon: IconFollowsystemOutline16 }, diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index f833a7392a..133436c693 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -14,9 +14,11 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type { AppearanceRowInjected } from './AppearanceRow.tsx' import { AppearanceRow } from './AppearanceRow.tsx' import { createAppearanceRowStore } from './settings-store.ts' +import { en, zh, type ThemeKey } from './locales.ts' export type { AppearanceRowComponentProps, AppearanceRowInjected } from './AppearanceRow.tsx' export type { AppearanceRowState } from './settings-store.ts' +export type { ThemeKey } from './locales.ts' /** Namespace owning this feature's settings-row copy. */ export const SETTINGS_NS = 'settings.theme' @@ -24,7 +26,7 @@ export const SETTINGS_NS = 'settings.theme' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { /** The Appearance settings row's copy. */ - 'settings.theme': 'appearance.title' | 'appearance.light' | 'appearance.dark' | 'appearance.system' + 'settings.theme': ThemeKey } } @@ -235,20 +237,7 @@ export function apply(ctx: ClientContext): void { const theme = new ThemeService(ctx) ctx.provide('theme', theme) - ctx.effect(() => ctx.locale.register(SETTINGS_NS, { - zh: { - 'appearance.title': '外观', - 'appearance.light': '浅色', - 'appearance.dark': '深色', - 'appearance.system': '跟随系统', - }, - en: { - 'appearance.title': 'Appearance', - 'appearance.light': 'Light', - 'appearance.dark': 'Dark', - 'appearance.system': 'System', - }, - }), 'ui-theme: settings row dictionaries') + ctx.effect(() => ctx.locale.register(SETTINGS_NS, { zh, en }), 'ui-theme: settings row dictionaries') const store = createAppearanceRowStore() let bound: BoundActions | undefined diff --git a/packages/client/ui-theme/src/client/locales.ts b/packages/client/ui-theme/src/client/locales.ts new file mode 100644 index 0000000000..6df56ceb96 --- /dev/null +++ b/packages/client/ui-theme/src/client/locales.ts @@ -0,0 +1,20 @@ +/** `settings.theme` namespace dictionaries (the Appearance row's copy). */ + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'appearance.title': '外观', + 'appearance.light': '浅色', + 'appearance.dark': '深色', + 'appearance.system': '跟随系统', +} satisfies Record + +/** The settings.theme namespace key union. */ +export type ThemeKey = keyof typeof zh + +/** English dictionary, checked complete against the zh key set. */ +export const en = { + 'appearance.title': 'Appearance', + 'appearance.light': 'Light', + 'appearance.dark': 'Dark', + 'appearance.system': 'System', +} satisfies Record From 622450192d253655cce284ee1fd83683585f4954 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:40:10 +0800 Subject: [PATCH 3/3] fix(client): mark the parallel register overloads for the clone gate The two overload declarations differ only in the inject share; folding them would lose per-overload inference of I, so the duplication is deliberate. --- packages/client/ui-slots/src/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/client/ui-slots/src/index.ts b/packages/client/ui-slots/src/index.ts index 2e4ce278b5..1f5c1ebd57 100644 --- a/packages/client/ui-slots/src/index.ts +++ b/packages/client/ui-slots/src/index.ts @@ -500,6 +500,9 @@ export class SlotCore { * @returns disposer removing the registration and its declarations * (idempotent; stale disposers after a cascade are no-ops). */ + /* jscpd:ignore-start -- the two register overloads are deliberately + * parallel declarations differing only in the inject share; folding them + * would lose the per-overload inference of I. */ register< K extends keyof SlotMap & string, const D extends ChildrenDecl = Record, @@ -537,6 +540,7 @@ export class SlotCore { & SlotComponent & keyof SlotMap & string, HandleOf>, I, NoInfer, NoInfer>> & RendersCheck, ): () => void + /* jscpd:ignore-end */ register(options: ErasedOptions, component: unknown): () => void { const rec = this.records.get(options.name) if (!rec?.spec) {