feat(client): adopt the locale seat in theme, sidebar, question, and model
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.
This commit is contained in:
@@ -238,13 +238,13 @@ export function ModelSelect(
|
||||
{state.error !== null && (
|
||||
<div className={css.error}>
|
||||
<span>{t('error.action', { message: state.error })}</span>
|
||||
<button type="button" className={css.retry} onClick={() => { load() }}>{t('action.retry')}</button>
|
||||
<button type="button" className={css.retry} onClick={() => { load() }}>{t('retry')}</button>
|
||||
</div>
|
||||
)}
|
||||
{state.failures.map(failure => (
|
||||
<div className={css.warning} key={failure.id}>
|
||||
<span>{t('warning.groupLoad', { name: failure.name, message: failure.message })}</span>
|
||||
<button type="button" className={css.retry} onClick={() => { load() }}>{t('action.retry')}</button>
|
||||
<button type="button" className={css.retry} onClick={() => { load() }}>{t('retry')}</button>
|
||||
</div>
|
||||
))}
|
||||
<div className={clsx(css.groups, 'scrollable')}>
|
||||
|
||||
@@ -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<ModelKey, string> = {
|
||||
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<ModelKey, string> = {
|
||||
'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<ModelKey, string>
|
||||
|
||||
@@ -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<typeof ModelSelect>['t'] = (key, params) => {
|
||||
const template = (zh as Record<string, string>)[key] ?? key
|
||||
const template = (zh as Record<string, string>)[key]
|
||||
?? (commonZh as Record<string, string>)[key]
|
||||
?? key
|
||||
return params === undefined
|
||||
? template
|
||||
: template.replace(/\{(\w+)\}/g, (match, name: string) => name in params ? String(params[name]) : match)
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -65,7 +65,10 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
|
||||
selected: [], custom: '', customOpen: (question.options?.length ?? 0) === 0, skipped: false,
|
||||
})))
|
||||
const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
|
||||
const [error, setError] = useState<string | null>(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<Questi
|
||||
setError(null)
|
||||
void pending.cancel().catch((cause: unknown) => {
|
||||
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<Questi
|
||||
const missing = values.findIndex(item => !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<Questi
|
||||
setError(null)
|
||||
void pending.answer(answer).catch((cause: unknown) => {
|
||||
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<Questi
|
||||
</div>
|
||||
|
||||
<footer className={css.footer}>
|
||||
<div className={css.feedback} role="status">{error}</div>
|
||||
<div className={css.feedback} role="status">{error === null ? null : 'key' in error ? t(error.key) : error.text}</div>
|
||||
<div className={css.footerActions}>
|
||||
<Button variant="ghost" size="sm" disabled={busy !== null} onClick={skipQuestion}>
|
||||
{t('action.skip')}
|
||||
@@ -302,8 +305,8 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
|
||||
disabled={busy !== null || !answered(draft)} onClick={continueFlow}
|
||||
>
|
||||
{busy === 'answer'
|
||||
? t('action.submitting')
|
||||
: index === questions.length - 1 ? t('action.submit') : t('action.next')}
|
||||
? t('submitting')
|
||||
: index === questions.length - 1 ? t('submit') : t('action.next')}
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -12,8 +12,6 @@ export const zh = {
|
||||
'option.custom': '其他,请填写自定义答案',
|
||||
'custom.placeholder': '输入你的答案',
|
||||
'action.skip': '跳过本题',
|
||||
'action.submitting': '正在提交…',
|
||||
'action.submit': '提交',
|
||||
'action.next': '下一题',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
@@ -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<QuestionKey, string> = {
|
||||
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<QuestionKey, string> = {
|
||||
'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<QuestionKey, string>
|
||||
|
||||
@@ -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<string, string>)[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<string, string>)[key]
|
||||
?? (commonZh as Record<string, string>)[key]
|
||||
?? key) as QuestionComposerProps['t'],
|
||||
}
|
||||
|
||||
const QUESTIONS = [
|
||||
|
||||
@@ -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<SidebarKey, string> = {
|
||||
export const en = {
|
||||
'session.new': 'New Session',
|
||||
'session.new.label': 'New session',
|
||||
'toggle.open': 'Open sidebar',
|
||||
'toggle.collapse': 'Collapse sidebar',
|
||||
}
|
||||
} satisfies Record<SidebarKey, string>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<typeof store> | undefined
|
||||
|
||||
20
packages/client/ui-theme/src/client/locales.ts
Normal file
20
packages/client/ui-theme/src/client/locales.ts
Normal file
@@ -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<string, string>
|
||||
|
||||
/** 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<ThemeKey, string>
|
||||
Reference in New Issue
Block a user