feat: add Mistral AI as first-class alternative provider
This commit is contained in:
@@ -85,6 +85,21 @@
|
||||
color: var(--vscode-list-activeSelectionForeground);
|
||||
}
|
||||
|
||||
.model-group-header {
|
||||
padding: 6px 12px 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
pointer-events: none;
|
||||
border-top: 1px solid var(--vscode-dropdown-border);
|
||||
}
|
||||
|
||||
.model-group-header:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -355,15 +355,31 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
</button>
|
||||
{showModelSelector && (
|
||||
<div className="model-dropdown">
|
||||
{availableModels.map(model => (
|
||||
<button
|
||||
key={model.id}
|
||||
className={`model-option ${conversation?.model === model.id ? 'active' : ''}`}
|
||||
onClick={() => handleModelChange(model.id)}
|
||||
>
|
||||
{model.name}
|
||||
</button>
|
||||
))}
|
||||
{(() => {
|
||||
// Group models by provider for visual separation
|
||||
const groups: Record<string, ChatModel[]> = {};
|
||||
for (const model of availableModels) {
|
||||
const p = model.provider || 'other';
|
||||
if (!groups[p]) groups[p] = [];
|
||||
groups[p].push(model);
|
||||
}
|
||||
return Object.entries(groups).map(([provider, models]) => (
|
||||
<React.Fragment key={provider}>
|
||||
{Object.keys(groups).length > 1 && (
|
||||
<div className="model-group-header">{provider === 'mistral' ? 'Mistral' : 'OpenCode'}</div>
|
||||
)}
|
||||
{models.map(model => (
|
||||
<button
|
||||
key={model.id}
|
||||
className={`model-option ${conversation?.model === model.id ? 'active' : ''}`}
|
||||
onClick={() => handleModelChange(model.id)}
|
||||
>
|
||||
{model.name}
|
||||
</button>
|
||||
))}
|
||||
</React.Fragment>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1371,15 +1371,30 @@ const TaxonomySection: React.FC<{
|
||||
</button>
|
||||
{showModelSelector && (
|
||||
<div className="taxonomy-model-dropdown">
|
||||
{availableModels.map(model => (
|
||||
<button
|
||||
key={model.id}
|
||||
className="taxonomy-model-option"
|
||||
onClick={() => handleAnalyze(model.id)}
|
||||
>
|
||||
{model.name}
|
||||
</button>
|
||||
))}
|
||||
{(() => {
|
||||
const groups: Record<string, ChatModel[]> = {};
|
||||
for (const model of availableModels) {
|
||||
const p = model.provider || 'other';
|
||||
if (!groups[p]) groups[p] = [];
|
||||
groups[p].push(model);
|
||||
}
|
||||
return Object.entries(groups).map(([provider, models]) => (
|
||||
<React.Fragment key={provider}>
|
||||
{Object.keys(groups).length > 1 && (
|
||||
<div className="model-group-header">{provider === 'mistral' ? 'Mistral' : 'OpenCode'}</div>
|
||||
)}
|
||||
{models.map(model => (
|
||||
<button
|
||||
key={model.id}
|
||||
className="taxonomy-model-option"
|
||||
onClick={() => handleAnalyze(model.id)}
|
||||
>
|
||||
{model.name}
|
||||
</button>
|
||||
))}
|
||||
</React.Fragment>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useAppStore } from '../../store';
|
||||
import { showToast } from '../Toast';
|
||||
import { useI18n } from '../../i18n';
|
||||
@@ -242,7 +242,12 @@ export const SettingsView: React.FC = () => {
|
||||
const [aiApiKeyMasked, setAiApiKeyMasked] = useState('');
|
||||
const [aiHasApiKey, setAiHasApiKey] = useState(false);
|
||||
const [newApiKey, setNewApiKey] = useState('');
|
||||
const [availableModels, setAvailableModels] = useState<{id: string; name: string}[]>([]);
|
||||
const [aiHasMistralKey, setAiHasMistralKey] = useState(false);
|
||||
const [aiMistralKeyMasked, setAiMistralKeyMasked] = useState('');
|
||||
const [newMistralKey, setNewMistralKey] = useState('');
|
||||
const [titleModel, setTitleModel] = useState('claude-haiku-4-5');
|
||||
const [imageAnalysisModel, setImageAnalysisModel] = useState('claude-sonnet-4-5');
|
||||
const [availableModels, setAvailableModels] = useState<{id: string; name: string; provider?: string; vision?: boolean}[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState('');
|
||||
const [modelCatalog, setModelCatalog] = useState<Map<string, {
|
||||
maxOutputTokens: number | null;
|
||||
@@ -403,6 +408,23 @@ export const SettingsView: React.FC = () => {
|
||||
setSelectedModel(modelsResult.selectedModel || '');
|
||||
}
|
||||
|
||||
// Load Mistral API key status
|
||||
const mistralKeyResult = await window.electronAPI?.chat.getMistralApiKey();
|
||||
if (mistralKeyResult) {
|
||||
setAiHasMistralKey(mistralKeyResult.hasKey);
|
||||
setAiMistralKeyMasked(mistralKeyResult.maskedKey || '');
|
||||
}
|
||||
|
||||
// Load per-purpose model preferences
|
||||
const titleModelResult = await window.electronAPI?.chat.getTitleModel();
|
||||
if (titleModelResult?.success && titleModelResult.modelId) {
|
||||
setTitleModel(titleModelResult.modelId);
|
||||
}
|
||||
const imageModelResult = await window.electronAPI?.chat.getImageAnalysisModel();
|
||||
if (imageModelResult?.success && imageModelResult.modelId) {
|
||||
setImageAnalysisModel(imageModelResult.modelId);
|
||||
}
|
||||
|
||||
// Load model catalog metadata
|
||||
const catalogResult = await window.electronAPI?.chat.getModelCatalog();
|
||||
if (catalogResult?.success && catalogResult.entries) {
|
||||
@@ -1080,6 +1102,13 @@ export const SettingsView: React.FC = () => {
|
||||
setAiApiKeyMasked('•'.repeat(Math.max(0, newApiKey.length - 4)) + newApiKey.slice(-4));
|
||||
setNewApiKey('');
|
||||
showToast.success(t('settings.toast.apiKeySaved'));
|
||||
|
||||
// Refresh models after key change
|
||||
const modelsResult = await window.electronAPI?.chat.getAvailableModels();
|
||||
if (modelsResult?.success && modelsResult.models) {
|
||||
setAvailableModels(modelsResult.models);
|
||||
setSelectedModel(modelsResult.selectedModel || '');
|
||||
}
|
||||
} else {
|
||||
showToast.error(t('settings.toast.apiKeyInvalid'));
|
||||
}
|
||||
@@ -1089,6 +1118,54 @@ export const SettingsView: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveMistralApiKey = async () => {
|
||||
if (!newMistralKey.trim()) return;
|
||||
try {
|
||||
const validateResult = await window.electronAPI?.chat.validateMistralApiKey(newMistralKey.trim());
|
||||
if (validateResult?.isValid) {
|
||||
await window.electronAPI?.chat.setMistralApiKey(newMistralKey.trim());
|
||||
setAiHasMistralKey(true);
|
||||
setAiMistralKeyMasked('•'.repeat(Math.max(0, newMistralKey.length - 4)) + newMistralKey.slice(-4));
|
||||
setNewMistralKey('');
|
||||
showToast.success(t('settings.toast.apiKeySaved'));
|
||||
|
||||
// Refresh models after key change
|
||||
const modelsResult = await window.electronAPI?.chat.getAvailableModels();
|
||||
if (modelsResult?.success && modelsResult.models) {
|
||||
setAvailableModels(modelsResult.models);
|
||||
setSelectedModel(modelsResult.selectedModel || '');
|
||||
}
|
||||
} else {
|
||||
showToast.error(t('settings.toast.apiKeyInvalid'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save Mistral API key:', error);
|
||||
showToast.error(t('settings.toast.apiKeySaveFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleTitleModelChange = async (modelId: string) => {
|
||||
try {
|
||||
const result = await window.electronAPI?.chat.setTitleModel(modelId);
|
||||
if (result?.success) {
|
||||
setTitleModel(modelId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to set title model:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageAnalysisModelChange = async (modelId: string) => {
|
||||
try {
|
||||
const result = await window.electronAPI?.chat.setImageAnalysisModel(modelId);
|
||||
if (result?.success) {
|
||||
setImageAnalysisModel(modelId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to set image analysis model:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModelChange = async (modelId: string) => {
|
||||
try {
|
||||
const result = await window.electronAPI?.chat.setDefaultModel(modelId);
|
||||
@@ -1137,6 +1214,37 @@ export const SettingsView: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Group models by provider for optgroup display
|
||||
const groupedModels = useMemo(() => {
|
||||
const groups: Record<string, typeof availableModels> = {};
|
||||
for (const model of availableModels) {
|
||||
const provider = model.provider || 'other';
|
||||
if (!groups[provider]) groups[provider] = [];
|
||||
groups[provider].push(model);
|
||||
}
|
||||
return groups;
|
||||
}, [availableModels]);
|
||||
|
||||
const providerLabel = (provider: string) => {
|
||||
if (provider === 'anthropic' || provider === 'openai' || provider === 'google' || provider === 'other') return t('settings.ai.providerOpenCode');
|
||||
if (provider === 'mistral') return t('settings.ai.providerMistral');
|
||||
return provider;
|
||||
};
|
||||
|
||||
// Render a model <select> with optgroup by provider
|
||||
const renderModelSelect = (id: string, value: string, onChange: (v: string) => void, disabled?: boolean) => (
|
||||
<select id={id} value={value} onChange={(e) => onChange(e.target.value)} disabled={disabled}>
|
||||
{availableModels.length === 0 && <option value="">{t('settings.ai.noModels')}</option>}
|
||||
{Object.entries(groupedModels).map(([provider, models]) => (
|
||||
<optgroup key={provider} label={providerLabel(provider)}>
|
||||
{models.map(model => (
|
||||
<option key={model.id} value={model.id}>{model.name}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
|
||||
const renderAISettings = () => (
|
||||
<SettingSection
|
||||
id="settings-section-ai"
|
||||
@@ -1185,27 +1293,58 @@ export const SettingsView: React.FC = () => {
|
||||
)}
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
id="ai-mistral-key"
|
||||
label={t('settings.ai.mistralApiKeyLabel')}
|
||||
description={t('settings.ai.mistralApiKeyDescription')}
|
||||
>
|
||||
<div className="setting-input-group">
|
||||
{aiHasMistralKey ? (
|
||||
<>
|
||||
<input
|
||||
id="ai-mistral-key"
|
||||
type="text"
|
||||
value={aiMistralKeyMasked}
|
||||
disabled
|
||||
placeholder={t('settings.ai.mistralApiKeyConfigured')}
|
||||
/>
|
||||
<span className="setting-status-badge success">{t('settings.ai.configured')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
id="ai-mistral-key"
|
||||
type="password"
|
||||
value={newMistralKey}
|
||||
onChange={(e) => setNewMistralKey(e.target.value)}
|
||||
placeholder={t('chat.apiKeyPlaceholder')}
|
||||
/>
|
||||
<button className="primary" onClick={handleSaveMistralApiKey} disabled={!newMistralKey.trim()}>
|
||||
{t('chat.apiKeySave')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{aiHasMistralKey && (
|
||||
<div className="setting-inline-action">
|
||||
<button className="text-button" onClick={() => { setAiHasMistralKey(false); setAiMistralKeyMasked(''); }}>
|
||||
{t('settings.ai.changeMistralApiKey')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
id="ai-model"
|
||||
label={t('settings.ai.defaultModelLabel')}
|
||||
description={t('settings.ai.defaultModelDescription')}
|
||||
>
|
||||
<div className="setting-input-group">
|
||||
<select
|
||||
id="ai-model"
|
||||
value={selectedModel}
|
||||
onChange={(e) => handleModelChange(e.target.value)}
|
||||
disabled={!aiHasApiKey}
|
||||
>
|
||||
{availableModels.length === 0 && <option value="">{t('settings.ai.noModels')}</option>}
|
||||
{availableModels.map(model => (
|
||||
<option key={model.id} value={model.id}>{model.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{renderModelSelect('ai-model', selectedModel, handleModelChange, !aiHasApiKey && !aiHasMistralKey)}
|
||||
<button
|
||||
className="secondary"
|
||||
onClick={handleRefreshModelCatalog}
|
||||
disabled={refreshingCatalog || !aiHasApiKey}
|
||||
disabled={refreshingCatalog || (!aiHasApiKey && !aiHasMistralKey)}
|
||||
title={t('settings.ai.refreshModelCatalog')}
|
||||
>
|
||||
{refreshingCatalog ? t('settings.ai.refreshing') : t('settings.ai.refreshModelCatalog')}
|
||||
@@ -1232,6 +1371,22 @@ export const SettingsView: React.FC = () => {
|
||||
})()}
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
id="ai-title-model"
|
||||
label={t('settings.ai.titleModelLabel')}
|
||||
description={t('settings.ai.titleModelDescription')}
|
||||
>
|
||||
{renderModelSelect('ai-title-model', titleModel, handleTitleModelChange, !aiHasApiKey && !aiHasMistralKey)}
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
id="ai-image-analysis-model"
|
||||
label={t('settings.ai.imageAnalysisModelLabel')}
|
||||
description={t('settings.ai.imageAnalysisModelDescription')}
|
||||
>
|
||||
{renderModelSelect('ai-image-analysis-model', imageAnalysisModel, handleImageAnalysisModelChange, !aiHasApiKey && !aiHasMistralKey)}
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
id="ai-system-prompt"
|
||||
label={t('settings.ai.systemPromptLabel')}
|
||||
|
||||
@@ -733,6 +733,18 @@
|
||||
"settings.ai.modelInfoOutputPrice": "Ausgabe",
|
||||
"settings.ai.modelInfoTokens": "Token",
|
||||
"settings.ai.modelInfoPerMTok": "/MTok",
|
||||
"settings.ai.mistralApiKeyLabel": "Mistral API-Schlüssel",
|
||||
"settings.ai.mistralApiKeyDescription": "Ihr API-Schlüssel von Mistral AI. Ermöglicht Mistral-Modelle als Alternative zu OpenCode.",
|
||||
"settings.ai.mistralApiKeyConfigured": "Mistral API-Schlüssel konfiguriert",
|
||||
"settings.ai.changeMistralApiKey": "Mistral API-Schlüssel ändern",
|
||||
"settings.ai.titleModelLabel": "Titelgenerierungsmodell",
|
||||
"settings.ai.titleModelDescription": "Modell zur automatischen Generierung von Konversationstiteln.",
|
||||
"settings.ai.imageAnalysisModelLabel": "Bildanalyse-Modell",
|
||||
"settings.ai.imageAnalysisModelDescription": "Modell für die automatische Bildanalyse (Titel, Alt-Text, Bildunterschrift).",
|
||||
"settings.ai.providerOpenCode": "OpenCode",
|
||||
"settings.ai.providerMistral": "Mistral",
|
||||
"settings.ai.providerOther": "Andere",
|
||||
"chat.providerKeyMissing": "Das Modell '{{model}}' benötigt einen {{provider}} API-Schlüssel. Konfigurieren Sie ihn in den Einstellungen.",
|
||||
"settings.toast.modelCatalogRefreshed": "Modellkatalog aktualisiert ({{count}} Modelle)",
|
||||
"settings.toast.modelCatalogUpToDate": "Modellkatalog ist bereits aktuell",
|
||||
"settings.toast.modelCatalogRefreshFailed": "Modellkatalog konnte nicht aktualisiert werden",
|
||||
|
||||
@@ -733,6 +733,18 @@
|
||||
"settings.ai.modelInfoOutputPrice": "Output",
|
||||
"settings.ai.modelInfoTokens": "tokens",
|
||||
"settings.ai.modelInfoPerMTok": "/MTok",
|
||||
"settings.ai.mistralApiKeyLabel": "Mistral API Key",
|
||||
"settings.ai.mistralApiKeyDescription": "Your API key from Mistral AI. Enables Mistral models as an alternative to OpenCode.",
|
||||
"settings.ai.mistralApiKeyConfigured": "Mistral API key configured",
|
||||
"settings.ai.changeMistralApiKey": "Change Mistral API Key",
|
||||
"settings.ai.titleModelLabel": "Title Generation Model",
|
||||
"settings.ai.titleModelDescription": "Model used to generate conversation titles automatically.",
|
||||
"settings.ai.imageAnalysisModelLabel": "Image Analysis Model",
|
||||
"settings.ai.imageAnalysisModelDescription": "Model used for automatic image analysis (title, alt text, caption).",
|
||||
"settings.ai.providerOpenCode": "OpenCode",
|
||||
"settings.ai.providerMistral": "Mistral",
|
||||
"settings.ai.providerOther": "Other",
|
||||
"chat.providerKeyMissing": "The model '{{model}}' requires a {{provider}} API key. Configure it in Settings.",
|
||||
"settings.toast.modelCatalogRefreshed": "Model catalog updated ({{count}} models)",
|
||||
"settings.toast.modelCatalogUpToDate": "Model catalog already up to date",
|
||||
"settings.toast.modelCatalogRefreshFailed": "Failed to refresh model catalog",
|
||||
|
||||
@@ -733,6 +733,18 @@
|
||||
"settings.ai.modelInfoOutputPrice": "Salida",
|
||||
"settings.ai.modelInfoTokens": "tokens",
|
||||
"settings.ai.modelInfoPerMTok": "/MTok",
|
||||
"settings.ai.mistralApiKeyLabel": "Clave API de Mistral",
|
||||
"settings.ai.mistralApiKeyDescription": "Su clave API de Mistral AI. Habilita los modelos Mistral como alternativa a OpenCode.",
|
||||
"settings.ai.mistralApiKeyConfigured": "Clave API de Mistral configurada",
|
||||
"settings.ai.changeMistralApiKey": "Cambiar clave API de Mistral",
|
||||
"settings.ai.titleModelLabel": "Modelo de generación de títulos",
|
||||
"settings.ai.titleModelDescription": "Modelo utilizado para generar títulos de conversación automáticamente.",
|
||||
"settings.ai.imageAnalysisModelLabel": "Modelo de análisis de imágenes",
|
||||
"settings.ai.imageAnalysisModelDescription": "Modelo utilizado para el análisis automático de imágenes (título, texto alternativo, leyenda).",
|
||||
"settings.ai.providerOpenCode": "OpenCode",
|
||||
"settings.ai.providerMistral": "Mistral",
|
||||
"settings.ai.providerOther": "Otro",
|
||||
"chat.providerKeyMissing": "El modelo '{{model}}' requiere una clave API de {{provider}}. Configúrela en Ajustes.",
|
||||
"settings.toast.modelCatalogRefreshed": "Catálogo actualizado ({{count}} modelos)",
|
||||
"settings.toast.modelCatalogUpToDate": "El catálogo ya está actualizado",
|
||||
"settings.toast.modelCatalogRefreshFailed": "No se pudo actualizar el catálogo",
|
||||
|
||||
@@ -731,6 +731,18 @@
|
||||
"settings.ai.modelInfoOutputPrice": "Sortie",
|
||||
"settings.ai.modelInfoTokens": "tokens",
|
||||
"settings.ai.modelInfoPerMTok": "/MTok",
|
||||
"settings.ai.mistralApiKeyLabel": "Clé API Mistral",
|
||||
"settings.ai.mistralApiKeyDescription": "Votre clé API Mistral AI. Permet d'utiliser les modèles Mistral comme alternative à OpenCode.",
|
||||
"settings.ai.mistralApiKeyConfigured": "Clé API Mistral configurée",
|
||||
"settings.ai.changeMistralApiKey": "Modifier la clé API Mistral",
|
||||
"settings.ai.titleModelLabel": "Modèle de génération de titres",
|
||||
"settings.ai.titleModelDescription": "Modèle utilisé pour générer automatiquement les titres de conversation.",
|
||||
"settings.ai.imageAnalysisModelLabel": "Modèle d'analyse d'images",
|
||||
"settings.ai.imageAnalysisModelDescription": "Modèle utilisé pour l'analyse automatique d'images (titre, texte alternatif, légende).",
|
||||
"settings.ai.providerOpenCode": "OpenCode",
|
||||
"settings.ai.providerMistral": "Mistral",
|
||||
"settings.ai.providerOther": "Autre",
|
||||
"chat.providerKeyMissing": "Le modèle '{{model}}' nécessite une clé API {{provider}}. Configurez-la dans les paramètres.",
|
||||
"settings.toast.modelCatalogRefreshed": "Catalogue mis à jour ({{count}} modèles)",
|
||||
"settings.toast.modelCatalogUpToDate": "Le catalogue est déjà à jour",
|
||||
"settings.toast.modelCatalogRefreshFailed": "Échec de l'actualisation du catalogue",
|
||||
|
||||
@@ -731,6 +731,18 @@
|
||||
"settings.ai.modelInfoOutputPrice": "Output",
|
||||
"settings.ai.modelInfoTokens": "token",
|
||||
"settings.ai.modelInfoPerMTok": "/MTok",
|
||||
"settings.ai.mistralApiKeyLabel": "Chiave API Mistral",
|
||||
"settings.ai.mistralApiKeyDescription": "La tua chiave API Mistral AI. Abilita i modelli Mistral come alternativa a OpenCode.",
|
||||
"settings.ai.mistralApiKeyConfigured": "Chiave API Mistral configurata",
|
||||
"settings.ai.changeMistralApiKey": "Cambia chiave API Mistral",
|
||||
"settings.ai.titleModelLabel": "Modello di generazione titoli",
|
||||
"settings.ai.titleModelDescription": "Modello utilizzato per generare automaticamente i titoli delle conversazioni.",
|
||||
"settings.ai.imageAnalysisModelLabel": "Modello di analisi immagini",
|
||||
"settings.ai.imageAnalysisModelDescription": "Modello utilizzato per l'analisi automatica delle immagini (titolo, testo alternativo, didascalia).",
|
||||
"settings.ai.providerOpenCode": "OpenCode",
|
||||
"settings.ai.providerMistral": "Mistral",
|
||||
"settings.ai.providerOther": "Altro",
|
||||
"chat.providerKeyMissing": "Il modello '{{model}}' richiede una chiave API {{provider}}. Configurala nelle Impostazioni.",
|
||||
"settings.toast.modelCatalogRefreshed": "Catalogo aggiornato ({{count}} modelli)",
|
||||
"settings.toast.modelCatalogUpToDate": "Il catalogo è già aggiornato",
|
||||
"settings.toast.modelCatalogRefreshFailed": "Aggiornamento del catalogo non riuscito",
|
||||
|
||||
Reference in New Issue
Block a user