Add Mistral AI as inference provider with Voxtral Small transcription model

- Add support for multiple API providers (Groq and Mistral)
- Add collapsible API keys section in settings for both providers
- Label all models with their provider (Groq/Mistral)
- Add Voxtral Small (Mistral) transcription model
- Add Mistral Small and Mistral Medium AI refinement models
- Use user-friendly model names while maintaining correct API model values
- Update transcriber to route requests to correct provider based on model
- Update config to store separate API keys for each provider
- Add Mistral brand colors to CSS
- Style API keys dropdown section

Co-authored-by: jahruz67 <jahruz67@users.noreply.github.com>
This commit is contained in:
Vibe Nuage Agent
2026-07-29 22:06:10 +00:00
co-authored by jahruz67
parent f658ab0de3
commit 9c9ad5ef87
5 changed files with 347 additions and 95 deletions
+32 -12
View File
@@ -344,8 +344,12 @@ func (a *App) processRecording(recordingPath string) {
isLocal := strings.HasPrefix(a.config.WhisperModel, "local-") isLocal := strings.HasPrefix(a.config.WhisperModel, "local-")
// IDIOT-PROOFING: Validate API key before attempting cloud transcription // IDIOT-PROOFING: Validate API key before attempting cloud transcription
if !isLocal && !strings.HasPrefix(a.config.APIKey, "gsk_") { if !isLocal {
err = fmt.Errorf("invalid API key - must start with gsk_") // Get the appropriate API key for the whisper model
apiKey := a.config.GetAPIKey(transcriber.GetProviderForModel(a.config.WhisperModel))
if apiKey == "" {
err = fmt.Errorf("API key is missing for the selected provider")
}
} else if isLocal { } else if isLocal {
// Use local whisper.cpp // Use local whisper.cpp
if a.whisperManager == nil { if a.whisperManager == nil {
@@ -424,16 +428,25 @@ func (a *App) processRecording(recordingPath string) {
// GetSettings returns the current configuration // GetSettings returns the current configuration
func (a *App) GetSettings() map[string]interface{} { func (a *App) GetSettings() map[string]interface{} {
conf := make(map[string]interface{}) conf := make(map[string]interface{})
// Mask the API key: only reveal the last 4 characters so the user can verify // Mask the API keys: only reveal the last 4 characters so the user can verify
// which key is configured without exposing the full secret to the frontend. // which key is configured without exposing the full secret to the frontend.
if a.config.APIKey != "" { if a.config.GroqAPIKey != "" {
key := a.config.APIKey key := a.config.GroqAPIKey
if len(key) > 4 { if len(key) > 4 {
key = "****" + key[len(key)-4:] key = "****" + key[len(key)-4:]
} }
conf["api_key"] = key conf["groq_api_key"] = key
} else { } else {
conf["api_key"] = "" conf["groq_api_key"] = ""
}
if a.config.MistralAPIKey != "" {
key := a.config.MistralAPIKey
if len(key) > 4 {
key = "****" + key[len(key)-4:]
}
conf["mistral_api_key"] = key
} else {
conf["mistral_api_key"] = ""
} }
conf["shortcut"] = a.config.Shortcut conf["shortcut"] = a.config.Shortcut
conf["whisper_model"] = a.config.WhisperModel conf["whisper_model"] = a.config.WhisperModel
@@ -459,12 +472,18 @@ func (a *App) GetSettings() map[string]interface{} {
// SaveSettings updates the configuration // SaveSettings updates the configuration
func (a *App) SaveSettings(settings map[string]interface{}) string { func (a *App) SaveSettings(settings map[string]interface{}) string {
if val, ok := settings["api_key"].(string); ok { if val, ok := settings["groq_api_key"].(string); ok {
// Only update the API key if it's not the masked value returned by GetSettings. // Only update the API key if it's not the masked value returned by GetSettings.
// GetSettings masks the key as "****abcd" so the frontend can show the last 4 chars. // GetSettings masks the key as "****abcd" so the frontend can show the last 4 chars.
// If the user didn't change it and sent back the masked value, preserve the real key. // If the user didn't change it and sent back the masked value, preserve the real key.
if !strings.HasPrefix(val, "****") { if !strings.HasPrefix(val, "****") {
a.config.APIKey = val a.config.GroqAPIKey = val
}
}
if val, ok := settings["mistral_api_key"].(string); ok {
// Only update the API key if it's not the masked value returned by GetSettings.
if !strings.HasPrefix(val, "****") {
a.config.MistralAPIKey = val
} }
} }
if val, ok := settings["shortcut"].(string); ok { if val, ok := settings["shortcut"].(string); ok {
@@ -520,10 +539,10 @@ func (a *App) SaveSettings(settings map[string]interface{}) string {
} }
// Re-init transcriber with new settings. // Re-init transcriber with new settings.
// Note: a.config.APIKey is already updated by the api_key field above. // Note: a.config.GroqAPIKey is already updated by the api_key field above.
// Since GetSettings masks the key, we only update if it's not still the masked value. // Since GetSettings masks the key, we only update if it's not still the masked value.
a.transcriber = transcriber.NewClient( a.transcriber = transcriber.NewClient(
a.config.APIKey, a.config.GroqAPIKey,
a.config.WhisperModel, a.config.WhisperModel,
a.config.AIModel, a.config.AIModel,
a.config.AIPrompt, a.config.AIPrompt,
@@ -613,7 +632,8 @@ func (a *App) startupHeadless() {
// Initialize transcriber // Initialize transcriber
a.transcriber = transcriber.NewClient( a.transcriber = transcriber.NewClient(
a.config.APIKey, a.config.GroqAPIKey,
a.config.MistralAPIKey,
a.config.WhisperModel, a.config.WhisperModel,
a.config.AIModel, a.config.AIModel,
a.config.AIPrompt, a.config.AIPrompt,
+99 -34
View File
@@ -24,21 +24,50 @@
<div class="section-group-label">Authentication</div> <div class="section-group-label">Authentication</div>
<!-- API Key --> <!-- API Keys Dropdown Section -->
<div class="section"> <div class="section">
<label>Groq API Key</label> <details class="api-keys-details">
<summary class="api-keys-summary">
<span>API Keys</span>
<span id="apiKeysStatus" style="color: var(--text-2); font-size: 12px; margin-left: 8px;">
<span id="groqKeyStatus">No Groq key</span> | <span id="mistralKeyStatus">No Mistral key</span>
</span>
</summary>
<div class="api-keys-content">
<!-- Groq API Key -->
<div class="api-key-section">
<label style="margin-top: 12px; margin-bottom: 8px; display: block;">Groq API Key</label>
<div class="form-control"> <div class="form-control">
<div class="flex-row"> <div class="flex-row">
<div class="input-wrapper"> <div class="input-wrapper">
<input type="password" id="apiKey" placeholder="gsk_..."> <input type="password" id="groqApiKey" placeholder="gsk_...">
<button class="eye-btn" onclick="toggleApiKey()" id="eyeBtn">👁️</button> <button class="eye-btn" onclick="toggleGroqApiKey()" id="groqEyeBtn">👁️</button>
</div> </div>
<button onclick="saveApiKey()">Save</button> <button onclick="saveGroqApiKey()">Save</button>
</div> </div>
</div> </div>
<p class="hint">Get your free key at <a href="#" onclick="window.runtime.BrowserOpenURL('https://console.groq.com/keys'); return false;" style="color: var(--accent);">console.groq.com/keys</a></p> <p class="hint">Get your free key at <a href="#" onclick="window.runtime.BrowserOpenURL('https://console.groq.com/keys'); return false;" style="color: var(--accent);">console.groq.com/keys</a></p>
</div> </div>
<!-- Mistral API Key -->
<div class="api-key-section">
<label style="margin-top: 12px; margin-bottom: 8px; display: block;">Mistral API Key</label>
<div class="form-control">
<div class="flex-row">
<div class="input-wrapper">
<input type="password" id="mistralApiKey" placeholder="mx_...">
<button class="eye-btn" onclick="toggleMistralApiKey()" id="mistralEyeBtn">👁️</button>
</div>
<button onclick="saveMistralApiKey()">Save</button>
</div>
</div>
<p class="hint">Get your key at <a href="#" onclick="window.runtime.BrowserOpenURL('https://console.mistral.ai/api-keys'); return false;" style="color: var(--accent);">console.mistral.ai/api-keys</a></p>
</div>
</div>
</details>
</div>
<!-- Global Hotkey (hidden on Linux because Linux uses the system shortcut command below) --> <!-- Global Hotkey (hidden on Linux because Linux uses the system shortcut command below) -->
<div class="section" id="shortcutSection"> <div class="section" id="shortcutSection">
<label>Global Hotkey</label> <label>Global Hotkey</label>
@@ -74,8 +103,8 @@
<button onclick="copyLinuxPressCommand()">Copy</button> <button onclick="copyLinuxPressCommand()">Copy</button>
</div> </div>
</div> </div>
<p class="hint" style="margin-top: 10px;">GNOME: Settings Keyboard Custom Shortcuts Add a shortcut with the copied command.</p> <p class="hint" style="margin-top: 10px;">GNOME: Settings  Keyboard  Custom Shortcuts  Add a shortcut with the copied command.</p>
<p class="hint">KDE: System Settings Shortcuts Command/URL Add a shortcut with the copied command.</p> <p class="hint">KDE: System Settings  Shortcuts  Command/URL  Add a shortcut with the copied command.</p>
</details> </details>
</div> </div>
@@ -93,8 +122,9 @@
<div class="section"> <div class="section">
<label>Whisper Model</label> <label>Whisper Model</label>
<select id="whisperModel" onchange="saveWhisperModel()" class="form-control"> <select id="whisperModel" onchange="saveWhisperModel()" class="form-control">
<option value="whisper-large-v3-turbo">whisper-large-v3-turbo (Recommended)</option> <option value="whisper-large-v3-turbo">Whisper Large v3 Turbo (Groq)</option>
<option value="whisper-large-v3">whisper-large-v3</option> <option value="whisper-large-v3">Whisper Large v3 (Groq)</option>
<option value="voxtral-small-latest">Voxtral Small (Mistral)</option>
</select> </select>
</div> </div>
@@ -116,12 +146,14 @@
<label>AI Model</label> <label>AI Model</label>
<select id="aiModel" onchange="saveAiModel()" class="form-control"> <select id="aiModel" onchange="saveAiModel()" class="form-control">
<option value="None">None — skip refinement</option> <option value="None">None — skip refinement</option>
<option value="openai/gpt-oss-120b-high">openai/gpt-oss-120b (high reasoning)</option> <option value="openai/gpt-oss-120b-high">GPT OSS 120B High (Groq)</option>
<option value="openai/gpt-oss-120b">openai/gpt-oss-120b (low reasoning)</option> <option value="openai/gpt-oss-120b">GPT OSS 120B (Groq)</option>
<option value="openai/gpt-oss-20b-high">openai/gpt-oss-20b (high reasoning)</option> <option value="openai/gpt-oss-20b-high">GPT OSS 20B High (Groq)</option>
<option value="openai/gpt-oss-20b">openai/gpt-oss-20b (low reasoning)</option> <option value="openai/gpt-oss-20b">GPT OSS 20B (Groq)</option>
<option value="llama-3.3-70b-versatile">llama-3.3-70b-versatile</option> <option value="llama-3.3-70b-versatile">Llama 3.3 70B Versatile (Groq)</option>
<option value="llama-3.1-8b-instant">llama-3.1-8b-instant (faster)</option> <option value="llama-3.1-8b-instant">Llama 3.1 8B Instant (Groq)</option>
<option value="mistral-small-latest">Mistral Small (Mistral)</option>
<option value="mistral-medium-latest">Mistral Medium (Mistral)</option>
</select> </select>
</div> </div>
@@ -189,7 +221,7 @@
<div id="whisperInstalled" style="display: none;"> <div id="whisperInstalled" style="display: none;">
<div style="background: var(--green-dim); border: 1px solid rgba(61, 186, 110, 0.25); border-radius: 7px; padding: 13px 16px; margin-bottom: 14px;"> <div style="background: var(--green-dim); border: 1px solid rgba(61, 186, 110, 0.25); border-radius: 7px; padding: 13px 16px; margin-bottom: 14px;">
<div style="color: var(--green); font-weight: 500; font-size: 13px;"> Offline Whisper installed</div> <div style="color: var(--green); font-weight: 500; font-size: 13px;"> Offline Whisper installed</div>
<div style="font-size: 12px; color: var(--text-2); margin-top: 4px;"> <div style="font-size: 12px; color: var(--text-2); margin-top: 4px;">
Model: <span id="installedModel"></span> Model: <span id="installedModel"></span>
</div> </div>
@@ -208,18 +240,56 @@
</div> </div>
<script type="module"> <script type="module">
let apiKeyVisible = false; let groqApiKeyVisible = false;
let mistralApiKeyVisible = false;
let linuxShortcutMode = false; let linuxShortcutMode = false;
// Toggle API key visibility // Toggle Groq API key visibility
window.toggleApiKey = function () { window.toggleGroqApiKey = function () {
const input = document.getElementById('apiKey'); const input = document.getElementById('groqApiKey');
const btn = document.getElementById('eyeBtn'); const btn = document.getElementById('groqEyeBtn');
apiKeyVisible = !apiKeyVisible; groqApiKeyVisible = !groqApiKeyVisible;
input.type = apiKeyVisible ? 'text' : 'password'; input.type = groqApiKeyVisible ? 'text' : 'password';
btn.textContent = apiKeyVisible ? '🙈' : '👁️'; btn.textContent = groqApiKeyVisible ? '👁️' : '👁️';
}; };
// Toggle Mistral API key visibility
window.toggleMistralApiKey = function () {
const input = document.getElementById('mistralApiKey');
const btn = document.getElementById('mistralEyeBtn');
mistralApiKeyVisible = !mistralApiKeyVisible;
input.type = mistralApiKeyVisible ? 'text' : 'password';
btn.textContent = mistralApiKeyVisible ? '👁️' : '👁️';
};
// Save Groq API key
window.saveGroqApiKey = async function () {
const key = document.getElementById('groqApiKey').value.trim();
await window.go.main.App.SaveSettings({ groq_api_key: key });
showSaveStatus();
updateApiKeyStatus();
};
// Save Mistral API key
window.saveMistralApiKey = async function () {
const key = document.getElementById('mistralApiKey').value.trim();
await window.go.main.App.SaveSettings({ mistral_api_key: key });
showSaveStatus();
updateApiKeyStatus();
};
// Update API key status display
function updateApiKeyStatus() {
const groqKey = document.getElementById('groqApiKey').value;
const mistralKey = document.getElementById('mistralApiKey').value;
const groqStatus = document.getElementById('groqKeyStatus');
const mistralStatus = document.getElementById('mistralKeyStatus');
groqStatus.textContent = groqKey ? (groqKey.length > 4 ? 'Groq key set' : 'Groq key') : 'No Groq key';
mistralStatus.textContent = mistralKey ? (mistralKey.length > 4 ? 'Mistral key set' : 'Mistral key') : 'No Mistral key';
}
// Record shortcut // Record shortcut
window.recordShortcut = function () { window.recordShortcut = function () {
if (linuxShortcutMode) { if (linuxShortcutMode) {
@@ -397,7 +467,8 @@
try { try {
const settings = await window.go.main.App.GetSettings(); const settings = await window.go.main.App.GetSettings();
document.getElementById('apiKey').value = settings.api_key || ''; document.getElementById('groqApiKey').value = settings.groq_api_key || '';
document.getElementById('mistralApiKey').value = settings.mistral_api_key || '';
const shortcutInput = document.getElementById('shortcutInput'); const shortcutInput = document.getElementById('shortcutInput');
if (shortcutInput) { if (shortcutInput) {
shortcutInput.value = settings.shortcut || 'alt+z'; shortcutInput.value = settings.shortcut || 'alt+z';
@@ -434,6 +505,7 @@
document.getElementById('appVersion').textContent = ver === 'dev' ? 'dev' : ('v' + ver); document.getElementById('appVersion').textContent = ver === 'dev' ? 'dev' : ('v' + ver);
renderHistoryList(settings.history || []); renderHistoryList(settings.history || []);
updateApiKeyStatus();
} catch (err) { } catch (err) {
console.error("Failed to load settings:", err); console.error("Failed to load settings:", err);
} }
@@ -457,13 +529,6 @@
} }
} }
// Save functions
window.saveApiKey = async function () {
const key = document.getElementById('apiKey').value.trim();
await window.go.main.App.SaveSettings({ api_key: key });
showSaveStatus();
};
function showSaveStatus(message = 'Saved') { function showSaveStatus(message = 'Saved') {
const el = document.getElementById('saveStatus'); const el = document.getElementById('saveStatus');
el.textContent = message; el.textContent = message;
@@ -540,7 +605,7 @@
statusEl.style.color = 'var(--green)'; statusEl.style.color = 'var(--green)';
statusEl.style.border = '1px solid rgba(61, 186, 110, 0.2)'; statusEl.style.border = '1px solid rgba(61, 186, 110, 0.2)';
} else { } else {
statusEl.innerHTML = '● Offline install local Whisper for transcription'; statusEl.innerHTML = '● Offline \u2014 install local Whisper for transcription';
statusEl.style.background = 'var(--red-dim)'; statusEl.style.background = 'var(--red-dim)';
statusEl.style.color = 'var(--red)'; statusEl.style.color = 'var(--red)';
statusEl.style.border = '1px solid rgba(224, 82, 82, 0.2)'; statusEl.style.border = '1px solid rgba(224, 82, 82, 0.2)';
@@ -580,7 +645,7 @@
// Add local option to whisper dropdown // Add local option to whisper dropdown
const localOption = document.createElement('option'); const localOption = document.createElement('option');
localOption.value = 'local-' + info.model; localOption.value = 'local-' + info.model;
localOption.textContent = ' Local ' + info.model + ' (offline)'; localOption.textContent = ' Local \u2014 ' + info.model + ' (offline)';
localOption.style.fontWeight = 'bold'; localOption.style.fontWeight = 'bold';
whisperSelect.insertBefore(localOption, whisperSelect.firstChild); whisperSelect.insertBefore(localOption, whisperSelect.firstChild);
} else { } else {
+97 -16
View File
@@ -18,6 +18,8 @@
--red-dim: rgba(224, 82, 82, 0.12); --red-dim: rgba(224, 82, 82, 0.12);
--radius: 10px; --radius: 10px;
--radius-sm: 7px; --radius-sm: 7px;
--mistral: #8a5cf0;
--mistral-dim: rgba(138, 92, 240, 0.15);
color-scheme: dark; color-scheme: dark;
} }
@@ -35,14 +37,14 @@ body {
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
/* ── Layout ── */ /* Layout */
.container { .container {
max-width: 680px; max-width: 680px;
margin: 0 auto; margin: 0 auto;
padding: 32px 28px 60px; padding: 32px 28px 60px;
} }
/* ── Header ── */ /* Header */
h1 { h1 {
font-size: 20px; font-size: 20px;
font-weight: 600; font-weight: 600;
@@ -64,7 +66,7 @@ h1 {
flex-wrap: nowrap; flex-wrap: nowrap;
} }
/* ── Section cards ── */ /* Section cards */
.section { .section {
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -78,7 +80,7 @@ h1 {
border-color: var(--border-hover); border-color: var(--border-hover);
} }
/* ── Labels ── */ /* Labels */
label { label {
display: block; display: block;
font-size: 13px; font-size: 13px;
@@ -96,7 +98,7 @@ label {
line-height: 1.5; line-height: 1.5;
} }
/* ── Inputs ── */ /* Inputs */
input[type="text"], input[type="text"],
input[type="password"], input[type="password"],
select, select,
@@ -157,12 +159,12 @@ input[type="checkbox"] {
flex-shrink: 0; flex-shrink: 0;
} }
/* ── Form control width ── */ /* Form control width */
.form-control { .form-control {
width: 100%; width: 100%;
} }
/* ── Input wrapper (for eye-btn) ── */ /* Input wrapper (for eye-btn) */
.input-wrapper { .input-wrapper {
position: relative; position: relative;
flex: 1; flex: 1;
@@ -197,7 +199,7 @@ input[type="checkbox"] {
box-shadow: none !important; box-shadow: none !important;
} }
/* ── Buttons ── */ /* Buttons */
button { button {
background: var(--accent); background: var(--accent);
color: #fff; color: #fff;
@@ -254,7 +256,7 @@ button:active {
opacity: 1 !important; opacity: 1 !important;
} }
/* ── Save status toast ── */ /* Save status toast */
.save-status { .save-status {
position: fixed; position: fixed;
bottom: 24px; bottom: 24px;
@@ -279,7 +281,7 @@ button:active {
opacity: 1; opacity: 1;
} }
/* ── History ── */ /* History */
.history-list { .history-list {
max-height: 240px; max-height: 240px;
overflow-y: auto; overflow-y: auto;
@@ -308,7 +310,7 @@ button:active {
display: block; display: block;
} }
/* ── Inline status badges (online/offline) ── */ /* Inline status badges (online/offline) */
#connectionStatus { #connectionStatus {
border-radius: var(--radius-sm) !important; border-radius: var(--radius-sm) !important;
font-size: 13px !important; font-size: 13px !important;
@@ -333,16 +335,95 @@ button:active {
background: var(--accent-hover) !important; background: var(--accent-hover) !important;
} }
/* ── Offline Whisper section separator ── */ /* Offline Whisper section separator */
#offlineWhisperSection { #offlineWhisperSection {
border-top: 1px solid var(--border) !important; border-top: 1px solid var(--border) !important;
margin-top: 20px !important; margin-top: 20px !important;
padding-top: 20px !important; padding-top: 20px !important;
} }
/* API Keys Dropdown Section */
.api-keys-details {
color: var(--text);
}
.api-keys-summary {
cursor: pointer;
font-weight: 500;
font-size: 13px;
color: var(--text);
padding: 2px 0;
user-select: none;
list-style: none;
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
/* ── Collapsible fallback details ── */ .api-keys-summary::-webkit-details-marker {
display: none;
}
.api-keys-summary::after {
content: '\u25b6';
font-size: 10px;
color: var(--text-3);
transition: transform 0.15s ease;
display: inline-block;
margin-left: 8px;
}
.api-keys-details[open] > .api-keys-summary::after {
transform: rotate(90deg);
}
.api-keys-summary:hover {
color: var(--text);
}
.api-keys-content {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.api-key-section {
margin-bottom: 12px;
}
.api-key-section:last-child {
margin-bottom: 0;
}
/* Provider badges for model options */
select option[data-provider]::before {
content: attr(data-provider);
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
margin-right: 8px;
opacity: 0.8;
}
select option[data-provider="groq"]::before {
background: var(--accent-dim);
color: var(--accent);
}
select option[data-provider="mistral"]::before {
background: var(--mistral-dim);
color: var(--mistral);
}
select option[data-provider="local"]::before {
background: var(--green-dim);
color: var(--green);
}
/* Collapsible fallback details */
.fallback-details { .fallback-details {
color: var(--text); color: var(--text);
} }
@@ -365,7 +446,7 @@ button:active {
} }
.fallback-summary::before { .fallback-summary::before {
content: ''; content: '\u25b6';
font-size: 10px; font-size: 10px;
color: var(--text-3); color: var(--text-3);
transition: transform 0.15s ease; transition: transform 0.15s ease;
@@ -380,7 +461,7 @@ button:active {
color: var(--text); color: var(--text);
} }
/* ── Section group header divider ── */ /* Section group header divider */
.section-group-label { .section-group-label {
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
@@ -390,7 +471,7 @@ button:active {
margin: 22px 0 8px; margin: 22px 0 8px;
} }
/* ── Scrollbar ── */ /* Scrollbar */
::-webkit-scrollbar { width: 5px; } ::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
+26 -2
View File
@@ -31,7 +31,8 @@ type HistoryItem struct {
// Config represents the complete application configuration. // Config represents the complete application configuration.
type Config struct { type Config struct {
Version int `json:"version"` Version int `json:"version"`
APIKey string `json:"api_key"` GroqAPIKey string `json:"groq_api_key"`
MistralAPIKey string `json:"mistral_api_key"`
Shortcut string `json:"shortcut"` Shortcut string `json:"shortcut"`
WhisperModel string `json:"whisper_model"` WhisperModel string `json:"whisper_model"`
AIModel string `json:"ai_model"` AIModel string `json:"ai_model"`
@@ -50,7 +51,8 @@ var saveMu sync.Mutex
// DefaultConfig returns a new configuration with sensible default values. // DefaultConfig returns a new configuration with sensible default values.
func DefaultConfig() *Config { func DefaultConfig() *Config {
return &Config{ return &Config{
APIKey: "", GroqAPIKey: "",
MistralAPIKey: "",
Shortcut: DefaultShortcut, Shortcut: DefaultShortcut,
WhisperModel: DefaultWhisperModel, WhisperModel: DefaultWhisperModel,
AIModel: DefaultAIModel, AIModel: DefaultAIModel,
@@ -197,3 +199,25 @@ func (c *Config) AddHistoryItem(text, timestamp string) {
func (c *Config) ClearHistory() { func (c *Config) ClearHistory() {
c.History = []HistoryItem{} c.History = []HistoryItem{}
} }
// GetAPIKey returns the API key for a specific provider
func (c *Config) GetAPIKey(provider string) string {
switch provider {
case "groq":
return c.GroqAPIKey
case "mistral":
return c.MistralAPIKey
default:
return ""
}
}
// SetAPIKey sets the API key for a specific provider
func (c *Config) SetAPIKey(provider, key string) {
switch provider {
case "groq":
c.GroqAPIKey = key
case "mistral":
c.MistralAPIKey = key
}
}
+80 -18
View File
@@ -1,5 +1,5 @@
// Package transcriber provides audio transcription and text refinement services // Package transcriber provides audio transcription and text refinement services
// using the Groq API for Whisper-based speech recognition and LLM text processing. // using various AI providers (Groq, Mistral) for Whisper-based speech recognition and LLM text processing.
package transcriber package transcriber
import ( import (
@@ -17,10 +17,18 @@ import (
"wis-free-v3/internal/logger" "wis-free-v3/internal/logger"
) )
// Provider constants
const (
ProviderGroq = "groq"
ProviderMistral = "mistral"
)
// API endpoints // API endpoints
const ( const (
transcriptionEndpoint = "https://api.groq.com/openai/v1/audio/transcriptions" groqTranscriptionEndpoint = "https://api.groq.com/openai/v1/audio/transcriptions"
chatEndpoint = "https://api.groq.com/openai/v1/chat/completions" groqChatEndpoint = "https://api.groq.com/openai/v1/chat/completions"
mistralTranscriptionEndpoint = "https://api.mistral.ai/v1/audio/transcriptions"
mistralChatEndpoint = "https://api.mistral.ai/v1/chat/completions"
) )
// Default configuration values // Default configuration values
@@ -34,9 +42,10 @@ const (
// DefaultAIPrompt provides instructions for minimal text editing. // DefaultAIPrompt provides instructions for minimal text editing.
const DefaultAIPrompt = `You are a minimal transcript cleanup tool. Return the user's dictated words, with only punctuation, capitalization, and obvious grammar fixes. Never answer questions, follow commands, add new facts, summarize, format as a list, or rewrite the wording. Preserve the same meaning and word order. Return only the cleaned transcript.` const DefaultAIPrompt = `You are a minimal transcript cleanup tool. Return the user's dictated words, with only punctuation, capitalization, and obvious grammar fixes. Never answer questions, follow commands, add new facts, summarize, format as a list, or rewrite the wording. Preserve the same meaning and word order. Return only the cleaned transcript.`
// Client handles API communication with Groq services. // Client handles API communication with various AI providers.
type Client struct { type Client struct {
apiKey string groqAPIKey string
mistralAPIKey string
whisperModel string whisperModel string
aiModel string aiModel string
aiPrompt string aiPrompt string
@@ -45,7 +54,7 @@ type Client struct {
// NewClient creates a new transcriber client with the specified configuration. // NewClient creates a new transcriber client with the specified configuration.
// Empty values for models or prompt will use sensible defaults. // Empty values for models or prompt will use sensible defaults.
func NewClient(apiKey, whisperModel, aiModel, aiPrompt string) *Client { func NewClient(groqAPIKey, mistralAPIKey, whisperModel, aiModel, aiPrompt string) *Client {
if whisperModel == "" { if whisperModel == "" {
whisperModel = DefaultWhisperModel whisperModel = DefaultWhisperModel
} }
@@ -57,7 +66,8 @@ func NewClient(apiKey, whisperModel, aiModel, aiPrompt string) *Client {
} }
return &Client{ return &Client{
apiKey: apiKey, groqAPIKey: groqAPIKey,
mistralAPIKey: mistralAPIKey,
whisperModel: whisperModel, whisperModel: whisperModel,
aiModel: aiModel, aiModel: aiModel,
aiPrompt: aiPrompt, aiPrompt: aiPrompt,
@@ -67,11 +77,41 @@ func NewClient(apiKey, whisperModel, aiModel, aiPrompt string) *Client {
} }
} }
// GetProviderForModel returns the provider for a given model name
func GetProviderForModel(model string) string {
// Mistral models
if strings.HasPrefix(model, "voxtral") || strings.HasPrefix(model, "voitrex") ||
strings.HasPrefix(model, "mistral-") ||
strings.Contains(model, "mistral") {
return ProviderMistral
}
// Groq models (default)
return ProviderGroq
}
// GetAPIKeyForModel returns the appropriate API key for a given model
func (c *Client) GetAPIKeyForModel(model string) string {
provider := GetProviderForModel(model)
if provider == ProviderMistral {
return c.mistralAPIKey
}
return c.groqAPIKey
}
// TranscribeAudio converts an audio file to text using Whisper. // TranscribeAudio converts an audio file to text using Whisper.
// Returns the transcribed text or an error if the operation fails. // Returns the transcribed text or an error if the operation fails.
func (c *Client) TranscribeAudio(audioFilePath, language string) (string, error) { func (c *Client) TranscribeAudio(audioFilePath, language string) (string, error) {
if c.apiKey == "" { // Check if using local whisper
return "", fmt.Errorf("API key is missing - please configure it in Settings") isLocal := strings.HasPrefix(c.whisperModel, "local-")
if isLocal {
return "", fmt.Errorf("local whisper should be handled separately")
}
// Get the appropriate API key for the model
apiKey := c.GetAPIKeyForModel(c.whisperModel)
if apiKey == "" {
provider := GetProviderForModel(c.whisperModel)
return "", fmt.Errorf("API key is missing for %s provider - please configure it in Settings", provider)
} }
// Validate file exists and get size for logging // Validate file exists and get size for logging
@@ -87,12 +127,19 @@ func (c *Client) TranscribeAudio(audioFilePath, language string) (string, error)
return "", err return "", err
} }
// Determine which endpoint to use based on the model
provider := GetProviderForModel(c.whisperModel)
endpoint := groqTranscriptionEndpoint
if provider == ProviderMistral {
endpoint = mistralTranscriptionEndpoint
}
// Create and send request // Create and send request
req, err := http.NewRequest(http.MethodPost, transcriptionEndpoint, body) req, err := http.NewRequest(http.MethodPost, endpoint, body)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to create request: %w", err) return "", fmt.Errorf("failed to create request: %w", err)
} }
req.Header.Set("Authorization", "Bearer "+c.apiKey) req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", contentType) req.Header.Set("Content-Type", contentType)
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
@@ -103,7 +150,7 @@ func (c *Client) TranscribeAudio(audioFilePath, language string) (string, error)
// Handle response // Handle response
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return "", c.handleAPIError(resp, "transcription") return "", c.handleAPIError(resp, "transcription", provider)
} }
var result struct { var result struct {
@@ -120,7 +167,15 @@ func (c *Client) TranscribeAudio(audioFilePath, language string) (string, error)
// RefineText uses an LLM to clean up and correct transcribed text. // RefineText uses an LLM to clean up and correct transcribed text.
// If the AI model is set to "None" or the API key is missing, returns the original text. // If the AI model is set to "None" or the API key is missing, returns the original text.
func (c *Client) RefineText(text string, activeContext string) (string, error) { func (c *Client) RefineText(text string, activeContext string) (string, error) {
if c.apiKey == "" || c.aiModel == "None" { if c.aiModel == "None" {
return text, nil
}
// Get the appropriate API key for the AI model
apiKey := c.GetAPIKeyForModel(c.aiModel)
if apiKey == "" {
provider := GetProviderForModel(c.aiModel)
logger.Error("API key missing for %s provider - skipping refinement", provider)
return text, nil return text, nil
} }
@@ -165,12 +220,19 @@ func (c *Client) RefineText(text string, activeContext string) (string, error) {
return text, nil // Return original text on error return text, nil // Return original text on error
} }
req, err := http.NewRequest(http.MethodPost, chatEndpoint, bytes.NewBuffer(payloadBytes)) // Determine which endpoint to use based on the AI model
provider := GetProviderForModel(c.aiModel)
endpoint := groqChatEndpoint
if provider == ProviderMistral {
endpoint = mistralChatEndpoint
}
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(payloadBytes))
if err != nil { if err != nil {
logger.Error("Failed to create refinement request: %v", err) logger.Error("Failed to create refinement request: %v", err)
return text, nil return text, nil
} }
req.Header.Set("Authorization", "Bearer "+c.apiKey) req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
@@ -181,7 +243,7 @@ func (c *Client) RefineText(text string, activeContext string) (string, error) {
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
c.handleAPIError(resp, "refinement") c.handleAPIError(resp, "refinement", provider)
return text, nil return text, nil
} }
@@ -325,13 +387,13 @@ func (c *Client) prepareAudioRequest(audioFilePath, language string) (*bytes.Buf
// handleAPIError logs and formats API error responses. // handleAPIError logs and formats API error responses.
// Truncates the body to avoid leaking secrets if the API echoes request data. // Truncates the body to avoid leaking secrets if the API echoes request data.
func (c *Client) handleAPIError(resp *http.Response, operation string) error { func (c *Client) handleAPIError(resp *http.Response, operation string, provider string) error {
bodyBytes, _ := io.ReadAll(resp.Body) bodyBytes, _ := io.ReadAll(resp.Body)
const maxLogLen = 200 const maxLogLen = 200
bodyStr := string(bodyBytes) bodyStr := string(bodyBytes)
if len(bodyStr) > maxLogLen { if len(bodyStr) > maxLogLen {
bodyStr = bodyStr[:maxLogLen] + "...(truncated)" bodyStr = bodyStr[:maxLogLen] + "...(truncated)"
} }
logger.Error("API %s error: status=%d body=%s", operation, resp.StatusCode, bodyStr) logger.Error("API %s error (%s): status=%d body=%s", operation, provider, resp.StatusCode, bodyStr)
return fmt.Errorf("%s failed with status %d", operation, resp.StatusCode) return fmt.Errorf("%s failed with status %d", operation, resp.StatusCode)
} }