fix(desktop): localize output style descriptions (#966)

Localize built-in output style names and descriptions in desktop settings while preserving custom project/user/plugin style text. Also localize the provider model ID placeholder.

Tested:
- cd desktop && bun run test -- src/pages/SettingsOutputStyle.test.tsx
- cd desktop && bun run test -- src/__tests__/generalSettings.test.tsx -t "localizes the main model placeholder"
- bun run check:desktop

Scope-risk: narrow
Confidence: high
This commit is contained in:
程序员阿江(Relakkes) 2026-07-03 19:08:48 +08:00
parent ce2c908161
commit 3e685eb6c2
8 changed files with 162 additions and 6 deletions

View File

@ -1651,6 +1651,34 @@ describe('Settings > Providers tab', () => {
expect(within(dialog).getByText('Requests will be translated via the local proxy')).toBeInTheDocument()
})
it('localizes the main model placeholder in the provider form', () => {
useSettingsStore.setState({ locale: 'zh' })
providerStoreState.presets = [
{
id: 'custom',
name: 'Custom',
baseUrl: 'https://api.example.com/anthropic',
apiFormat: 'anthropic',
defaultModels: {
main: '',
haiku: '',
sonnet: '',
opus: '',
},
needsApiKey: true,
websiteUrl: '',
},
]
render(<Settings />)
fireEvent.click(screen.getByRole('button', { name: /添加服务商/i }))
const dialog = screen.getByRole('dialog')
expect(within(dialog).getByPlaceholderText('模型 ID')).toBeInTheDocument()
expect(within(dialog).queryByPlaceholderText('Model ID')).not.toBeInTheDocument()
})
it('normalizes blank model mappings to the main model when saving a provider', async () => {
providerStoreState.createProvider = vi.fn().mockResolvedValue({
id: 'provider-new',

View File

@ -416,6 +416,7 @@ export const en = {
'settings.providers.sonnetModel': 'Sonnet Model',
'settings.providers.opusModel': 'Opus Model',
'settings.providers.model1mSupportShort': '1M',
'settings.providers.modelIdPlaceholder': 'Model ID',
'settings.providers.sameAsMain': 'Same as main',
'settings.providers.contextSettingsTitle': 'Context and auto-compact',
'settings.providers.contextSettingsDesc': 'Preset limits are applied automatically. Edit only when the provider changes limits or you use a custom model ID.',
@ -969,6 +970,12 @@ export const en = {
'settings.general.responseLangDefault': 'Default (English)',
'settings.general.outputStyleTitle': 'Output Style',
'settings.general.outputStyleDescription': 'Choose how Claude communicates in new or restarted sessions.',
'settings.general.outputStyleBuiltin.default.label': 'Default',
'settings.general.outputStyleBuiltin.default.description': 'Claude completes coding tasks efficiently and provides concise responses',
'settings.general.outputStyleBuiltin.explanatory.label': 'Explanatory',
'settings.general.outputStyleBuiltin.explanatory.description': 'Claude explains its implementation choices and codebase patterns',
'settings.general.outputStyleBuiltin.learning.label': 'Learning',
'settings.general.outputStyleBuiltin.learning.description': 'Claude pauses and asks you to write small pieces of code for hands-on practice',
'settings.general.outputStyleSelectLabel': 'Select output style',
'settings.general.outputStyleLoading': 'Loading output styles...',
'settings.general.outputStyleSaved': 'Output style saved.',

View File

@ -418,6 +418,7 @@ export const jp: Record<TranslationKey, string> = {
'settings.providers.sonnetModel': 'Sonnet モデル',
'settings.providers.opusModel': 'Opus モデル',
'settings.providers.model1mSupportShort': '1M',
'settings.providers.modelIdPlaceholder': 'モデル ID',
'settings.providers.sameAsMain': 'メインと同じ',
'settings.providers.contextSettingsTitle': 'コンテキストと自動コンパクト',
'settings.providers.contextSettingsDesc': 'プリセットの上限は自動的に適用されます。プロバイダーが上限を変更した場合やカスタムモデル ID を使用する場合のみ編集してください。',
@ -971,6 +972,12 @@ export const jp: Record<TranslationKey, string> = {
'settings.general.responseLangDefault': 'デフォルト(英語)',
'settings.general.outputStyleTitle': '出力スタイル',
'settings.general.outputStyleDescription': '新しいセッションまたは再起動後のセッションで Claude がどのように応答するかを選択します。',
'settings.general.outputStyleBuiltin.default.label': 'デフォルト',
'settings.general.outputStyleBuiltin.default.description': 'Claude はコーディングタスクを効率よく完了し、簡潔に応答します',
'settings.general.outputStyleBuiltin.explanatory.label': '解説型',
'settings.general.outputStyleBuiltin.explanatory.description': 'Claude は実装上の判断とコードベースのパターンを説明します',
'settings.general.outputStyleBuiltin.learning.label': '学習型',
'settings.general.outputStyleBuiltin.learning.description': 'Claude は一時停止し、実践練習として短いコードの記述を依頼します',
'settings.general.outputStyleSelectLabel': '出力スタイルを選択',
'settings.general.outputStyleLoading': '出力スタイルを読み込み中...',
'settings.general.outputStyleSaved': '出力スタイルを保存しました。',

View File

@ -418,6 +418,7 @@ export const kr: Record<TranslationKey, string> = {
'settings.providers.sonnetModel': 'Sonnet 모델',
'settings.providers.opusModel': 'Opus 모델',
'settings.providers.model1mSupportShort': '1M',
'settings.providers.modelIdPlaceholder': '모델 ID',
'settings.providers.sameAsMain': '메인과 동일',
'settings.providers.contextSettingsTitle': '컨텍스트 및 자동 압축',
'settings.providers.contextSettingsDesc': '사전 설정 한도가 자동으로 적용됩니다. 공급자가 한도를 변경하거나 사용자 지정 모델 ID를 사용할 때만 편집하세요.',
@ -971,6 +972,12 @@ export const kr: Record<TranslationKey, string> = {
'settings.general.responseLangDefault': '기본값(영어)',
'settings.general.outputStyleTitle': '출력 스타일',
'settings.general.outputStyleDescription': '새 세션 또는 다시 시작한 세션에서 Claude가 응답하는 방식을 선택합니다.',
'settings.general.outputStyleBuiltin.default.label': '기본값',
'settings.general.outputStyleBuiltin.default.description': 'Claude가 코딩 작업을 효율적으로 완료하고 간결하게 응답합니다',
'settings.general.outputStyleBuiltin.explanatory.label': '설명형',
'settings.general.outputStyleBuiltin.explanatory.description': 'Claude가 구현 선택과 코드베이스 패턴을 설명합니다',
'settings.general.outputStyleBuiltin.learning.label': '학습형',
'settings.general.outputStyleBuiltin.learning.description': 'Claude가 잠시 멈추고 실습을 위해 짧은 코드를 작성하도록 요청합니다',
'settings.general.outputStyleSelectLabel': '출력 스타일 선택',
'settings.general.outputStyleLoading': '출력 스타일 불러오는 중...',
'settings.general.outputStyleSaved': '출력 스타일이 저장되었습니다.',

View File

@ -418,6 +418,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.providers.sonnetModel': 'Sonnet 模型',
'settings.providers.opusModel': 'Opus 模型',
'settings.providers.model1mSupportShort': '1M',
'settings.providers.modelIdPlaceholder': '模型 ID',
'settings.providers.sameAsMain': '與主模型相同',
'settings.providers.contextSettingsTitle': '上下文與自動壓縮',
'settings.providers.contextSettingsDesc': '預設會自動應用模型視窗。只有服務商調整限制,或使用自定義模型 ID 時才需要修改。',
@ -971,6 +972,12 @@ export const zh: Record<TranslationKey, string> = {
'settings.general.responseLangDefault': '預設(英語)',
'settings.general.outputStyleTitle': '輸出風格',
'settings.general.outputStyleDescription': '選擇 Claude 在新會話或重啟後的會話中如何表達。',
'settings.general.outputStyleBuiltin.default.label': '預設',
'settings.general.outputStyleBuiltin.default.description': 'Claude 會高效完成編碼任務,並提供簡潔回覆',
'settings.general.outputStyleBuiltin.explanatory.label': '解釋型',
'settings.general.outputStyleBuiltin.explanatory.description': 'Claude 會解釋實作選擇和程式碼庫模式',
'settings.general.outputStyleBuiltin.learning.label': '學習型',
'settings.general.outputStyleBuiltin.learning.description': 'Claude 會暫停並請你編寫小段程式碼進行實戰練習',
'settings.general.outputStyleSelectLabel': '選擇輸出風格',
'settings.general.outputStyleLoading': '正在載入輸出風格...',
'settings.general.outputStyleSaved': '輸出風格已儲存。',

View File

@ -418,6 +418,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.providers.sonnetModel': 'Sonnet 模型',
'settings.providers.opusModel': 'Opus 模型',
'settings.providers.model1mSupportShort': '1M',
'settings.providers.modelIdPlaceholder': '模型 ID',
'settings.providers.sameAsMain': '与主模型相同',
'settings.providers.contextSettingsTitle': '上下文与自动压缩',
'settings.providers.contextSettingsDesc': '预设会自动应用模型窗口。只有服务商调整限制,或使用自定义模型 ID 时才需要修改。',
@ -971,6 +972,12 @@ export const zh: Record<TranslationKey, string> = {
'settings.general.responseLangDefault': '默认(英语)',
'settings.general.outputStyleTitle': '输出风格',
'settings.general.outputStyleDescription': '选择 Claude 在新会话或重启后的会话中如何表达。',
'settings.general.outputStyleBuiltin.default.label': '默认',
'settings.general.outputStyleBuiltin.default.description': 'Claude 会高效完成编码任务,并提供简洁回复',
'settings.general.outputStyleBuiltin.explanatory.label': '解释型',
'settings.general.outputStyleBuiltin.explanatory.description': 'Claude 会解释实现选择和代码库模式',
'settings.general.outputStyleBuiltin.learning.label': '学习型',
'settings.general.outputStyleBuiltin.learning.description': 'Claude 会暂停并请你编写小段代码进行实战练习',
'settings.general.outputStyleSelectLabel': '选择输出风格',
'settings.general.outputStyleLoading': '正在加载输出风格...',
'settings.general.outputStyleSaved': '输出风格已保存。',

View File

@ -83,6 +83,20 @@ const NETWORK_TIMEOUT_MIN_SECONDS = 30
const NETWORK_TIMEOUT_MAX_SECONDS = 1800
const NETWORK_TIMEOUT_STEP_SECONDS = 30
const SETTINGS_CHECKBOX_INPUT_CLASS = 'settings-checkbox-input peer'
const BUILT_IN_OUTPUT_STYLE_TRANSLATION_KEYS = {
default: {
label: 'settings.general.outputStyleBuiltin.default.label',
description: 'settings.general.outputStyleBuiltin.default.description',
},
Explanatory: {
label: 'settings.general.outputStyleBuiltin.explanatory.label',
description: 'settings.general.outputStyleBuiltin.explanatory.description',
},
Learning: {
label: 'settings.general.outputStyleBuiltin.learning.label',
description: 'settings.general.outputStyleBuiltin.learning.description',
},
} satisfies Record<string, { label: TranslationKey; description: TranslationKey }>
function buildH5LaunchUrl(baseUrl: string | null, token: string | null): string | null {
if (!baseUrl) return null
@ -1763,7 +1777,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
required={slot === 'main'}
value={models[slot]}
onChange={(e) => handleModelChange(slot, e.target.value)}
placeholder={slot === 'main' ? 'Model ID' : t('settings.providers.sameAsMain')}
placeholder={slot === 'main' ? t('settings.providers.modelIdPlaceholder') : t('settings.providers.sameAsMain')}
/>
<label className="mt-1 inline-flex h-6 w-fit cursor-pointer items-center gap-1.5 rounded-[var(--radius-sm)] px-1 text-xs text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]">
<input
@ -2156,11 +2170,17 @@ export function GeneralSettings() {
RESPONSE_LANGUAGES.find(({ value }) => value === responseLanguage)?.label ?? RESPONSE_LANGUAGES[0]!.label
const outputStyleItems = outputStyles.map((style) => ({
value: style.value,
label: style.label,
description: `${style.description} · ${getOutputStyleSourceLabel(style.source, t)}`,
label: getOutputStyleLabel(style, t),
description: `${getOutputStyleDescription(style, t)} · ${getOutputStyleSourceLabel(style.source, t)}`,
}))
const selectedOutputStyle =
outputStyles.find((style) => style.value === outputStyle) ?? outputStyles[0]
const selectedOutputStyleLabel = selectedOutputStyle
? getOutputStyleLabel(selectedOutputStyle, t)
: outputStyle
const selectedOutputStyleDescription = selectedOutputStyle
? getOutputStyleDescription(selectedOutputStyle, t)
: ''
const outputStyleScopeLabel = outputStyleScope === 'localSettings'
? t('settings.general.outputStyleScopeLocal')
: t('settings.general.outputStyleScopeUser')
@ -2633,11 +2653,11 @@ export function GeneralSettings() {
<span className="block truncate font-medium">
{outputStylesLoading
? t('settings.general.outputStyleLoading')
: selectedOutputStyle?.label ?? outputStyle}
: selectedOutputStyleLabel}
</span>
{selectedOutputStyle?.description && (
{selectedOutputStyleDescription && (
<span className="mt-0.5 block truncate text-xs text-[var(--color-text-tertiary)]">
{selectedOutputStyle.description}
{selectedOutputStyleDescription}
</span>
)}
</span>
@ -3280,6 +3300,40 @@ export function GeneralSettings() {
)
}
function getBuiltInOutputStyleTranslationKeys(style: {
value: string
source: OutputStyleSource
}) {
if (style.source !== 'built-in') return null
return BUILT_IN_OUTPUT_STYLE_TRANSLATION_KEYS[
style.value as keyof typeof BUILT_IN_OUTPUT_STYLE_TRANSLATION_KEYS
] ?? null
}
function getOutputStyleLabel(
style: {
value: string
label: string
source: OutputStyleSource
},
t: (key: TranslationKey) => string,
) {
const keys = getBuiltInOutputStyleTranslationKeys(style)
return keys ? t(keys.label) : style.label
}
function getOutputStyleDescription(
style: {
value: string
description: string
source: OutputStyleSource
},
t: (key: TranslationKey) => string,
) {
const keys = getBuiltInOutputStyleTranslationKeys(style)
return keys ? t(keys.description) : style.description
}
function getOutputStyleSourceLabel(
source: OutputStyleSource,
t: (key: TranslationKey) => string,

View File

@ -125,4 +125,43 @@ describe('GeneralSettings output style', () => {
})
expect(useSettingsStore.getState().outputStyle).toBe('Learning')
})
it('localizes built-in output style descriptions in Chinese', async () => {
useSettingsStore.setState({ locale: 'zh' })
settingsApiMock.getOutputStyles.mockResolvedValueOnce({
outputStyle: 'default',
scope: 'localSettings',
workDir: '/repo',
styles: [
{
value: 'default',
label: 'Default',
description: 'Claude completes coding tasks efficiently and provides concise responses',
source: 'built-in',
},
{
value: 'Learning',
label: 'Learning',
description: 'Claude pauses and asks you to write small pieces of code for hands-on practice',
source: 'built-in',
},
{
value: 'Project Style',
label: 'Project Style',
description: 'Project custom voice',
source: 'projectSettings',
},
],
})
render(<GeneralSettings />)
expect(await screen.findByText('Claude 会高效完成编码任务,并提供简洁回复')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '选择输出风格' }))
expect(screen.getByText('Claude 会暂停并请你编写小段代码进行实战练习 · 内置')).toBeInTheDocument()
expect(screen.getByText('Project custom voice · 项目风格')).toBeInTheDocument()
expect(screen.queryByText(/Claude completes coding tasks efficiently/)).not.toBeInTheDocument()
})
})