feat: add Mistral AI as first-class alternative provider

This commit is contained in:
2026-03-01 14:41:42 +01:00
parent 886083ebc9
commit c911ec2354
22 changed files with 1425 additions and 167 deletions

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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')}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",