feat(desktop): add i18n infrastructure with react-intl (#8105)
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user