feat(desktop): add i18n infrastructure with react-intl (#8105)

This commit is contained in:
jh-block
2026-03-27 19:39:50 +01:00
committed by GitHub
parent d596113e72
commit a6765fbcd9
172 changed files with 13793 additions and 2054 deletions
@@ -16,8 +16,73 @@ import {
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '../../ui/dropdown-menu';
import { defineMessages, useIntl } from '../../../i18n';
const i18n = defineMessages({
voiceDictationProvider: {
id: 'dictationSettings.voiceDictationProvider',
defaultMessage: 'Voice Dictation Provider',
},
chooseVoiceConversion: {
id: 'dictationSettings.chooseVoiceConversion',
defaultMessage: 'Choose how voice is converted to text',
},
disabled: {
id: 'dictationSettings.disabled',
defaultMessage: 'Disabled',
},
notConfigured: {
id: 'dictationSettings.notConfigured',
defaultMessage: '(not configured)',
},
configureApiKey: {
id: 'dictationSettings.configureApiKey',
defaultMessage: 'Configure the API key in <b>{settingsPath}</b>',
},
configuredIn: {
id: 'dictationSettings.configuredIn',
defaultMessage: '✓ Configured in {settingsPath}',
},
apiKey: {
id: 'dictationSettings.apiKey',
defaultMessage: 'API Key',
},
requiredForTranscription: {
id: 'dictationSettings.requiredForTranscription',
defaultMessage: 'Required for transcription',
},
configured: {
id: 'dictationSettings.configured',
defaultMessage: '(Configured)',
},
updateApiKey: {
id: 'dictationSettings.updateApiKey',
defaultMessage: 'Update API Key',
},
addApiKey: {
id: 'dictationSettings.addApiKey',
defaultMessage: 'Add API Key',
},
removeApiKey: {
id: 'dictationSettings.removeApiKey',
defaultMessage: 'Remove API Key',
},
enterApiKey: {
id: 'dictationSettings.enterApiKey',
defaultMessage: 'Enter your API key',
},
save: {
id: 'dictationSettings.save',
defaultMessage: 'Save',
},
cancel: {
id: 'dictationSettings.cancel',
defaultMessage: 'Cancel',
},
});
export const DictationSettings = () => {
const intl = useIntl();
const { localInference, isLoading: isFeaturesLoading } = useFeatures();
const [provider, setProvider] = useState<DictationProvider | null>(null);
const [providerStatuses, setProviderStatuses] = useState<Record<string, DictationProviderStatus>>(
@@ -110,7 +175,7 @@ export const DictationSettings = () => {
};
const getProviderLabel = (p: DictationProvider | null): string => {
if (!p) return 'Disabled';
if (!p) return intl.formatMessage(i18n.disabled);
return p.charAt(0).toUpperCase() + p.slice(1);
};
@@ -122,9 +187,9 @@ export const DictationSettings = () => {
<div className="space-y-4">
<div className="flex items-center justify-between py-2 px-2 hover:bg-background-secondary rounded-lg transition-all">
<div>
<h3 className="text-text-primary">Voice Dictation Provider</h3>
<h3 className="text-text-primary">{intl.formatMessage(i18n.voiceDictationProvider)}</h3>
<p className="text-xs text-text-secondary max-w-md mt-[2px]">
Choose how voice is converted to text
{intl.formatMessage(i18n.chooseVoiceConversion)}
</p>
</div>
<DropdownMenu onOpenChange={(open) => open && refreshStatuses()}>
@@ -137,12 +202,12 @@ export const DictationSettings = () => {
value={provider ?? 'disabled'}
onValueChange={handleProviderChange}
>
<DropdownMenuRadioItem value="disabled">Disabled</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="disabled">{intl.formatMessage(i18n.disabled)}</DropdownMenuRadioItem>
{visibleProviders.map((p) => (
<DropdownMenuRadioItem key={p} value={p}>
{getProviderLabel(p)}
{!providerStatuses[p]?.configured && (
<span className="text-xs ml-1 text-text-secondary">(not configured)</span>
<span className="text-xs ml-1 text-text-secondary">{intl.formatMessage(i18n.notConfigured)}</span>
)}
</DropdownMenuRadioItem>
))}
@@ -161,22 +226,22 @@ export const DictationSettings = () => {
<div className="py-2 px-2 bg-background-secondary rounded-lg">
{!providerStatuses[provider].configured ? (
<p className="text-xs text-text-secondary">
Configure the API key in <b>{providerStatuses[provider].settings_path}</b>
{intl.formatMessage(i18n.configureApiKey, { settingsPath: providerStatuses[provider].settings_path, b: (chunks: React.ReactNode) => <b>{chunks}</b> })}
</p>
) : (
<p className="text-xs text-green-600">
✓ Configured in {providerStatuses[provider].settings_path}
{intl.formatMessage(i18n.configuredIn, { settingsPath: providerStatuses[provider].settings_path })}
</p>
)}
</div>
) : (
<div className="py-2 px-2 bg-background-secondary rounded-lg">
<div className="mb-2">
<h4 className="text-text-primary text-sm">API Key</h4>
<h4 className="text-text-primary text-sm">{intl.formatMessage(i18n.apiKey)}</h4>
<p className="text-xs text-text-secondary mt-[2px]">
Required for transcription
{intl.formatMessage(i18n.requiredForTranscription)}
{providerStatuses[provider]?.configured && (
<span className="text-green-600 ml-2">(Configured)</span>
<span className="text-green-600 ml-2">{intl.formatMessage(i18n.configured)}</span>
)}
</p>
</div>
@@ -184,11 +249,11 @@ export const DictationSettings = () => {
{!isEditingKey ? (
<div className="flex gap-2 flex-wrap">
<Button variant="outline" size="sm" onClick={() => setIsEditingKey(true)}>
{providerStatuses[provider]?.configured ? 'Update API Key' : 'Add API Key'}
{providerStatuses[provider]?.configured ? intl.formatMessage(i18n.updateApiKey) : intl.formatMessage(i18n.addApiKey)}
</Button>
{providerStatuses[provider]?.configured && (
<Button variant="destructive" size="sm" onClick={handleRemoveKey}>
Remove API Key
{intl.formatMessage(i18n.removeApiKey)}
</Button>
)}
</div>
@@ -198,16 +263,16 @@ export const DictationSettings = () => {
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="Enter your API key"
placeholder={intl.formatMessage(i18n.enterApiKey)}
className="max-w-md"
autoFocus
/>
<div className="flex gap-2">
<Button size="sm" onClick={handleSaveKey}>
Save
{intl.formatMessage(i18n.save)}
</Button>
<Button variant="outline" size="sm" onClick={handleCancelEdit}>
Cancel
{intl.formatMessage(i18n.cancel)}
</Button>
</div>
</div>
@@ -11,6 +11,51 @@ import {
type WhisperModelResponse,
type DownloadProgress,
} from '../../../api';
import { defineMessages, useIntl } from '../../../i18n';
const i18n = defineMessages({
gpuAcceleration: {
id: 'localModelManager.gpuAcceleration',
defaultMessage:
'Supports GPU acceleration (CUDA for NVIDIA, Metal for Apple Silicon). GPU features must be enabled at build time for hardware acceleration.',
},
recommended: {
id: 'localModelManager.recommended',
defaultMessage: 'Recommended',
},
active: {
id: 'localModelManager.active',
defaultMessage: 'Active',
},
recommendedForHardware: {
id: 'localModelManager.recommendedForHardware',
defaultMessage: 'Recommended for your hardware',
},
downloaded: {
id: 'localModelManager.downloaded',
defaultMessage: 'Downloaded',
},
download: {
id: 'localModelManager.download',
defaultMessage: 'Download',
},
deleteConfirm: {
id: 'localModelManager.deleteConfirm',
defaultMessage: 'Delete this model? You can re-download it later.',
},
showRecommendedOnly: {
id: 'localModelManager.showRecommendedOnly',
defaultMessage: 'Show recommended only',
},
showAllModels: {
id: 'localModelManager.showAllModels',
defaultMessage: 'Show all models ({count} more)',
},
noModels: {
id: 'localModelManager.noModels',
defaultMessage: 'No models available',
},
});
const LOCAL_WHISPER_MODEL_CONFIG_KEY = 'LOCAL_WHISPER_MODEL';
@@ -26,6 +71,7 @@ const capitalize = (str: string): string => {
};
export const LocalModelManager = () => {
const intl = useIntl();
const [models, setModels] = useState<WhisperModelResponse[]>([]);
const [downloads, setDownloads] = useState<Map<string, DownloadProgress>>(new Map());
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
@@ -118,7 +164,7 @@ export const LocalModelManager = () => {
};
const deleteModel = async (modelId: string) => {
if (!window.confirm('Delete this model? You can re-download it later.')) return;
if (!window.confirm(intl.formatMessage(i18n.deleteConfirm))) return;
try {
await deleteModelApi({ path: { model_id: modelId } });
@@ -144,8 +190,7 @@ export const LocalModelManager = () => {
<div className="space-y-3">
<div className="text-xs text-text-secondary mb-2">
<p>
Supports GPU acceleration (CUDA for NVIDIA, Metal for Apple Silicon). GPU features must be
enabled at build time for hardware acceleration.
{intl.formatMessage(i18n.gpuAcceleration)}
</p>
</div>
@@ -182,12 +227,12 @@ export const LocalModelManager = () => {
<span className="text-xs text-text-secondary">{model.size_mb}MB</span>
{model.recommended && (
<span className="text-xs bg-blue-500 text-white px-2 py-0.5 rounded">
Recommended
{intl.formatMessage(i18n.recommended)}
</span>
)}
{isSelected && (
<span className="text-xs bg-background-inverse text-white px-2 py-0.5 rounded">
Active
{intl.formatMessage(i18n.active)}
</span>
)}
</div>
@@ -195,7 +240,7 @@ export const LocalModelManager = () => {
<p className="text-xs text-text-secondary mt-1">{model.description}</p>
{model.recommended && (
<p className="text-xs text-blue-600 mt-1 font-medium">
Recommended for your hardware
{intl.formatMessage(i18n.recommendedForHardware)}
</p>
)}
</div>
@@ -205,7 +250,7 @@ export const LocalModelManager = () => {
<>
<div className="flex items-center gap-1 text-xs text-green-600">
<Check className="w-4 h-4" />
<span>Downloaded</span>
<span>{intl.formatMessage(i18n.downloaded)}</span>
</div>
<Button
variant="ghost"
@@ -228,7 +273,7 @@ export const LocalModelManager = () => {
) : (
<Button variant="outline" size="sm" onClick={() => startDownload(model.id)}>
<Download className="w-4 h-4 mr-1" />
Download
{intl.formatMessage(i18n.download)}
</Button>
)}
</div>
@@ -269,19 +314,19 @@ export const LocalModelManager = () => {
{showAllModels ? (
<>
<ChevronUp className="w-4 h-4 mr-1" />
Show recommended only
{intl.formatMessage(i18n.showRecommendedOnly)}
</>
) : (
<>
<ChevronDown className="w-4 h-4 mr-1" />
Show all models ({models.length - displayedModels.length} more)
{intl.formatMessage(i18n.showAllModels, { count: models.length - displayedModels.length })}
</>
)}
</Button>
)}
{models.length === 0 && (
<div className="text-center py-6 text-text-secondary text-sm">No models available</div>
<div className="text-center py-6 text-text-secondary text-sm">{intl.formatMessage(i18n.noModels)}</div>
)}
</div>
);
@@ -8,6 +8,50 @@ import {
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '../../ui/dropdown-menu';
import { defineMessages, useIntl } from '../../../i18n';
const i18n = defineMessages({
microphone: {
id: 'microphoneSelector.microphone',
defaultMessage: 'Microphone',
},
grantAccessDescription: {
id: 'microphoneSelector.grantAccessDescription',
defaultMessage: 'Grant access to see available microphones',
},
grantAccess: {
id: 'microphoneSelector.grantAccess',
defaultMessage: 'Grant Access',
},
chooseDescription: {
id: 'microphoneSelector.chooseDescription',
defaultMessage: 'Choose which microphone to use for dictation',
},
systemDefault: {
id: 'microphoneSelector.systemDefault',
defaultMessage: 'System Default',
},
selectedMicrophone: {
id: 'microphoneSelector.selectedMicrophone',
defaultMessage: 'Selected Microphone',
},
microphoneLabel: {
id: 'microphoneSelector.microphoneLabel',
defaultMessage: 'Microphone {index}',
},
stop: {
id: 'microphoneSelector.stop',
defaultMessage: 'Stop',
},
test: {
id: 'microphoneSelector.test',
defaultMessage: 'Test',
},
speakToTest: {
id: 'microphoneSelector.speakToTest',
defaultMessage: 'Speak to test your microphone ({seconds}s)',
},
});
interface MicrophoneSelectorProps {
selectedDeviceId: string | null;
@@ -20,6 +64,7 @@ export const MicrophoneSelector = ({
selectedDeviceId,
onDeviceChange,
}: MicrophoneSelectorProps) => {
const intl = useIntl();
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [hasPermission, setHasPermission] = useState(false);
const [isTesting, setIsTesting] = useState(false);
@@ -120,27 +165,27 @@ export const MicrophoneSelector = ({
}, [stopTest]);
const getDeviceLabel = (device: MediaDeviceInfo, index: number): string => {
return device.label || `Microphone ${index + 1}`;
return device.label || intl.formatMessage(i18n.microphoneLabel, { index: index + 1 });
};
const selectedLabel = (): string => {
if (!selectedDeviceId) return 'System Default';
if (!selectedDeviceId) return intl.formatMessage(i18n.systemDefault);
const device = devices.find((d) => d.deviceId === selectedDeviceId);
if (device) return device.label || 'Selected Microphone';
return 'System Default';
if (device) return device.label || intl.formatMessage(i18n.selectedMicrophone);
return intl.formatMessage(i18n.systemDefault);
};
if (!hasPermission) {
return (
<div className="flex items-center justify-between py-2 px-2 hover:bg-background-secondary rounded-lg transition-all">
<div>
<h3 className="text-text-primary text-sm">Microphone</h3>
<h3 className="text-text-primary text-sm">{intl.formatMessage(i18n.microphone)}</h3>
<p className="text-xs text-text-secondary max-w-md mt-[2px]">
Grant access to see available microphones
{intl.formatMessage(i18n.grantAccessDescription)}
</p>
</div>
<Button variant="outline" size="sm" onClick={requestPermission}>
Grant Access
{intl.formatMessage(i18n.grantAccess)}
</Button>
</div>
);
@@ -150,9 +195,9 @@ export const MicrophoneSelector = ({
<div className="space-y-3">
<div className="flex items-center justify-between py-2 px-2 hover:bg-background-secondary rounded-lg transition-all">
<div>
<h3 className="text-text-primary text-sm">Microphone</h3>
<h3 className="text-text-primary text-sm">{intl.formatMessage(i18n.microphone)}</h3>
<p className="text-xs text-text-secondary max-w-md mt-[2px]">
Choose which microphone to use for dictation
{intl.formatMessage(i18n.chooseDescription)}
</p>
</div>
<div className="flex items-center gap-2">
@@ -166,7 +211,7 @@ export const MicrophoneSelector = ({
value={selectedDeviceId ?? 'system_default'}
onValueChange={(v) => onDeviceChange(v === 'system_default' ? null : v)}
>
<DropdownMenuRadioItem value="system_default">System Default</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="system_default">{intl.formatMessage(i18n.systemDefault)}</DropdownMenuRadioItem>
{devices.map((device, i) => (
<DropdownMenuRadioItem key={device.deviceId} value={device.deviceId}>
<span className="truncate">{getDeviceLabel(device, i)}</span>
@@ -182,7 +227,7 @@ export const MicrophoneSelector = ({
className="shrink-0"
>
<Mic className="w-4 h-4 mr-1" />
{isTesting ? 'Stop' : 'Test'}
{isTesting ? intl.formatMessage(i18n.stop) : intl.formatMessage(i18n.test)}
</Button>
</div>
</div>
@@ -196,7 +241,7 @@ export const MicrophoneSelector = ({
/>
</div>
<p className="text-xs text-text-secondary mt-1">
Speak to test your microphone ({Math.ceil(TEST_DURATION_MS / 1000)}s)
{intl.formatMessage(i18n.speakToTest, { seconds: Math.ceil(TEST_DURATION_MS / 1000) })}
</p>
</div>
)}