mirror of
https://github.com/jahruz67/wisp-open.git
synced 2026-08-08 18:14:08 +00:00
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:
co-authored by
jahruz67
parent
f658ab0de3
commit
9c9ad5ef87
@@ -344,8 +344,12 @@ func (a *App) processRecording(recordingPath string) {
|
||||
isLocal := strings.HasPrefix(a.config.WhisperModel, "local-")
|
||||
|
||||
// IDIOT-PROOFING: Validate API key before attempting cloud transcription
|
||||
if !isLocal && !strings.HasPrefix(a.config.APIKey, "gsk_") {
|
||||
err = fmt.Errorf("invalid API key - must start with gsk_")
|
||||
if !isLocal {
|
||||
// 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 {
|
||||
// Use local whisper.cpp
|
||||
if a.whisperManager == nil {
|
||||
@@ -424,16 +428,25 @@ func (a *App) processRecording(recordingPath string) {
|
||||
// GetSettings returns the current configuration
|
||||
func (a *App) GetSettings() 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.
|
||||
if a.config.APIKey != "" {
|
||||
key := a.config.APIKey
|
||||
if a.config.GroqAPIKey != "" {
|
||||
key := a.config.GroqAPIKey
|
||||
if len(key) > 4 {
|
||||
key = "****" + key[len(key)-4:]
|
||||
}
|
||||
conf["api_key"] = key
|
||||
conf["groq_api_key"] = key
|
||||
} 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["whisper_model"] = a.config.WhisperModel
|
||||
@@ -459,12 +472,18 @@ func (a *App) GetSettings() map[string]interface{} {
|
||||
|
||||
// SaveSettings updates the configuration
|
||||
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.
|
||||
// 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 !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 {
|
||||
@@ -520,10 +539,10 @@ func (a *App) SaveSettings(settings map[string]interface{}) string {
|
||||
}
|
||||
|
||||
// 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.
|
||||
a.transcriber = transcriber.NewClient(
|
||||
a.config.APIKey,
|
||||
a.config.GroqAPIKey,
|
||||
a.config.WhisperModel,
|
||||
a.config.AIModel,
|
||||
a.config.AIPrompt,
|
||||
@@ -613,7 +632,8 @@ func (a *App) startupHeadless() {
|
||||
|
||||
// Initialize transcriber
|
||||
a.transcriber = transcriber.NewClient(
|
||||
a.config.APIKey,
|
||||
a.config.GroqAPIKey,
|
||||
a.config.MistralAPIKey,
|
||||
a.config.WhisperModel,
|
||||
a.config.AIModel,
|
||||
a.config.AIPrompt,
|
||||
|
||||
+104
-39
@@ -24,19 +24,48 @@
|
||||
|
||||
<div class="section-group-label">Authentication</div>
|
||||
|
||||
<!-- API Key -->
|
||||
<!-- API Keys Dropdown Section -->
|
||||
<div class="section">
|
||||
<label>Groq API Key</label>
|
||||
<div class="form-control">
|
||||
<div class="flex-row">
|
||||
<div class="input-wrapper">
|
||||
<input type="password" id="apiKey" placeholder="gsk_...">
|
||||
<button class="eye-btn" onclick="toggleApiKey()" id="eyeBtn">👁️</button>
|
||||
<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="flex-row">
|
||||
<div class="input-wrapper">
|
||||
<input type="password" id="groqApiKey" placeholder="gsk_...">
|
||||
<button class="eye-btn" onclick="toggleGroqApiKey()" id="groqEyeBtn">👁️</button>
|
||||
</div>
|
||||
<button onclick="saveGroqApiKey()">Save</button>
|
||||
</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>
|
||||
</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>
|
||||
<button onclick="saveApiKey()">Save</button>
|
||||
</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>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Global Hotkey (hidden on Linux because Linux uses the system shortcut command below) -->
|
||||
@@ -74,8 +103,8 @@
|
||||
<button onclick="copyLinuxPressCommand()">Copy</button>
|
||||
</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">KDE: System Settings → Shortcuts → Command/URL → 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>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@@ -93,8 +122,9 @@
|
||||
<div class="section">
|
||||
<label>Whisper Model</label>
|
||||
<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">whisper-large-v3</option>
|
||||
<option value="whisper-large-v3-turbo">Whisper Large v3 Turbo (Groq)</option>
|
||||
<option value="whisper-large-v3">Whisper Large v3 (Groq)</option>
|
||||
<option value="voxtral-small-latest">Voxtral Small (Mistral)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -116,12 +146,14 @@
|
||||
<label>AI Model</label>
|
||||
<select id="aiModel" onchange="saveAiModel()" class="form-control">
|
||||
<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">openai/gpt-oss-120b (low reasoning)</option>
|
||||
<option value="openai/gpt-oss-20b-high">openai/gpt-oss-20b (high reasoning)</option>
|
||||
<option value="openai/gpt-oss-20b">openai/gpt-oss-20b (low reasoning)</option>
|
||||
<option value="llama-3.3-70b-versatile">llama-3.3-70b-versatile</option>
|
||||
<option value="llama-3.1-8b-instant">llama-3.1-8b-instant (faster)</option>
|
||||
<option value="openai/gpt-oss-120b-high">GPT OSS 120B High (Groq)</option>
|
||||
<option value="openai/gpt-oss-120b">GPT OSS 120B (Groq)</option>
|
||||
<option value="openai/gpt-oss-20b-high">GPT OSS 20B High (Groq)</option>
|
||||
<option value="openai/gpt-oss-20b">GPT OSS 20B (Groq)</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 (Groq)</option>
|
||||
<option value="mistral-small-latest">Mistral Small (Mistral)</option>
|
||||
<option value="mistral-medium-latest">Mistral Medium (Mistral)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -189,7 +221,7 @@
|
||||
|
||||
<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="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;">
|
||||
Model: <span id="installedModel">—</span>
|
||||
</div>
|
||||
@@ -208,18 +240,56 @@
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
let apiKeyVisible = false;
|
||||
let groqApiKeyVisible = false;
|
||||
let mistralApiKeyVisible = false;
|
||||
let linuxShortcutMode = false;
|
||||
|
||||
// Toggle API key visibility
|
||||
window.toggleApiKey = function () {
|
||||
const input = document.getElementById('apiKey');
|
||||
const btn = document.getElementById('eyeBtn');
|
||||
apiKeyVisible = !apiKeyVisible;
|
||||
input.type = apiKeyVisible ? 'text' : 'password';
|
||||
btn.textContent = apiKeyVisible ? '🙈' : '👁️';
|
||||
// Toggle Groq API key visibility
|
||||
window.toggleGroqApiKey = function () {
|
||||
const input = document.getElementById('groqApiKey');
|
||||
const btn = document.getElementById('groqEyeBtn');
|
||||
groqApiKeyVisible = !groqApiKeyVisible;
|
||||
input.type = groqApiKeyVisible ? 'text' : 'password';
|
||||
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
|
||||
window.recordShortcut = function () {
|
||||
if (linuxShortcutMode) {
|
||||
@@ -397,7 +467,8 @@
|
||||
try {
|
||||
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');
|
||||
if (shortcutInput) {
|
||||
shortcutInput.value = settings.shortcut || 'alt+z';
|
||||
@@ -434,6 +505,7 @@
|
||||
document.getElementById('appVersion').textContent = ver === 'dev' ? 'dev' : ('v' + ver);
|
||||
|
||||
renderHistoryList(settings.history || []);
|
||||
updateApiKeyStatus();
|
||||
} catch (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') {
|
||||
const el = document.getElementById('saveStatus');
|
||||
el.textContent = message;
|
||||
@@ -540,7 +605,7 @@
|
||||
statusEl.style.color = 'var(--green)';
|
||||
statusEl.style.border = '1px solid rgba(61, 186, 110, 0.2)';
|
||||
} 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.color = 'var(--red)';
|
||||
statusEl.style.border = '1px solid rgba(224, 82, 82, 0.2)';
|
||||
@@ -580,7 +645,7 @@
|
||||
// Add local option to whisper dropdown
|
||||
const localOption = document.createElement('option');
|
||||
localOption.value = 'local-' + info.model;
|
||||
localOption.textContent = '⬡ Local — ' + info.model + ' (offline)';
|
||||
localOption.textContent = '↑ Local \u2014 ' + info.model + ' (offline)';
|
||||
localOption.style.fontWeight = 'bold';
|
||||
whisperSelect.insertBefore(localOption, whisperSelect.firstChild);
|
||||
} else {
|
||||
|
||||
+98
-17
@@ -18,6 +18,8 @@
|
||||
--red-dim: rgba(224, 82, 82, 0.12);
|
||||
--radius: 10px;
|
||||
--radius-sm: 7px;
|
||||
--mistral: #8a5cf0;
|
||||
--mistral-dim: rgba(138, 92, 240, 0.15);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@@ -35,14 +37,14 @@ body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ── Layout ── */
|
||||
/* Layout */
|
||||
.container {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 28px 60px;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
/* Header */
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
@@ -64,7 +66,7 @@ h1 {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
/* ── Section cards ── */
|
||||
/* Section cards */
|
||||
.section {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
@@ -78,7 +80,7 @@ h1 {
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
/* ── Labels ── */
|
||||
/* Labels */
|
||||
label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
@@ -96,7 +98,7 @@ label {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Inputs ── */
|
||||
/* Inputs */
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
select,
|
||||
@@ -157,12 +159,12 @@ input[type="checkbox"] {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Form control width ── */
|
||||
/* Form control width */
|
||||
.form-control {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ── Input wrapper (for eye-btn) ── */
|
||||
/* Input wrapper (for eye-btn) */
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
@@ -197,7 +199,7 @@ input[type="checkbox"] {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* ── Buttons ── */
|
||||
/* Buttons */
|
||||
button {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
@@ -254,7 +256,7 @@ button:active {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* ── Save status toast ── */
|
||||
/* Save status toast */
|
||||
.save-status {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
@@ -279,7 +281,7 @@ button:active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── History ── */
|
||||
/* History */
|
||||
.history-list {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
@@ -308,7 +310,7 @@ button:active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── Inline status badges (online/offline) ── */
|
||||
/* Inline status badges (online/offline) */
|
||||
#connectionStatus {
|
||||
border-radius: var(--radius-sm) !important;
|
||||
font-size: 13px !important;
|
||||
@@ -333,16 +335,95 @@ button:active {
|
||||
background: var(--accent-hover) !important;
|
||||
}
|
||||
|
||||
/* ── Offline Whisper section separator ── */
|
||||
/* Offline Whisper section separator */
|
||||
#offlineWhisperSection {
|
||||
border-top: 1px solid var(--border) !important;
|
||||
margin-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 {
|
||||
color: var(--text);
|
||||
}
|
||||
@@ -365,7 +446,7 @@ button:active {
|
||||
}
|
||||
|
||||
.fallback-summary::before {
|
||||
content: '▶';
|
||||
content: '\u25b6';
|
||||
font-size: 10px;
|
||||
color: var(--text-3);
|
||||
transition: transform 0.15s ease;
|
||||
@@ -380,7 +461,7 @@ button:active {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ── Section group header divider ── */
|
||||
/* Section group header divider */
|
||||
.section-group-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
@@ -390,11 +471,11 @@ button:active {
|
||||
margin: 22px 0 8px;
|
||||
}
|
||||
|
||||
/* ── Scrollbar ── */
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 5px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--surface-2);
|
||||
border-radius: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--border-hover); }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--border-hover); }
|
||||
|
||||
@@ -31,7 +31,8 @@ type HistoryItem struct {
|
||||
// Config represents the complete application configuration.
|
||||
type Config struct {
|
||||
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"`
|
||||
WhisperModel string `json:"whisper_model"`
|
||||
AIModel string `json:"ai_model"`
|
||||
@@ -50,7 +51,8 @@ var saveMu sync.Mutex
|
||||
// DefaultConfig returns a new configuration with sensible default values.
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
APIKey: "",
|
||||
GroqAPIKey: "",
|
||||
MistralAPIKey: "",
|
||||
Shortcut: DefaultShortcut,
|
||||
WhisperModel: DefaultWhisperModel,
|
||||
AIModel: DefaultAIModel,
|
||||
@@ -197,3 +199,25 @@ func (c *Config) AddHistoryItem(text, timestamp string) {
|
||||
func (c *Config) ClearHistory() {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// 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
|
||||
|
||||
import (
|
||||
@@ -17,10 +17,18 @@ import (
|
||||
"wis-free-v3/internal/logger"
|
||||
)
|
||||
|
||||
// Provider constants
|
||||
const (
|
||||
ProviderGroq = "groq"
|
||||
ProviderMistral = "mistral"
|
||||
)
|
||||
|
||||
// API endpoints
|
||||
const (
|
||||
transcriptionEndpoint = "https://api.groq.com/openai/v1/audio/transcriptions"
|
||||
chatEndpoint = "https://api.groq.com/openai/v1/chat/completions"
|
||||
groqTranscriptionEndpoint = "https://api.groq.com/openai/v1/audio/transcriptions"
|
||||
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
|
||||
@@ -34,18 +42,19 @@ const (
|
||||
// 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.`
|
||||
|
||||
// Client handles API communication with Groq services.
|
||||
// Client handles API communication with various AI providers.
|
||||
type Client struct {
|
||||
apiKey string
|
||||
whisperModel string
|
||||
aiModel string
|
||||
aiPrompt string
|
||||
httpClient *http.Client
|
||||
groqAPIKey string
|
||||
mistralAPIKey string
|
||||
whisperModel string
|
||||
aiModel string
|
||||
aiPrompt string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a new transcriber client with the specified configuration.
|
||||
// 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 == "" {
|
||||
whisperModel = DefaultWhisperModel
|
||||
}
|
||||
@@ -57,21 +66,52 @@ func NewClient(apiKey, whisperModel, aiModel, aiPrompt string) *Client {
|
||||
}
|
||||
|
||||
return &Client{
|
||||
apiKey: apiKey,
|
||||
whisperModel: whisperModel,
|
||||
aiModel: aiModel,
|
||||
aiPrompt: aiPrompt,
|
||||
groqAPIKey: groqAPIKey,
|
||||
mistralAPIKey: mistralAPIKey,
|
||||
whisperModel: whisperModel,
|
||||
aiModel: aiModel,
|
||||
aiPrompt: aiPrompt,
|
||||
httpClient: &http.Client{
|
||||
Timeout: HTTPTimeout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Returns the transcribed text or an error if the operation fails.
|
||||
func (c *Client) TranscribeAudio(audioFilePath, language string) (string, error) {
|
||||
if c.apiKey == "" {
|
||||
return "", fmt.Errorf("API key is missing - please configure it in Settings")
|
||||
// Check if using local whisper
|
||||
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
|
||||
@@ -87,12 +127,19 @@ func (c *Client) TranscribeAudio(audioFilePath, language string) (string, error)
|
||||
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
|
||||
req, err := http.NewRequest(http.MethodPost, transcriptionEndpoint, body)
|
||||
req, err := http.NewRequest(http.MethodPost, endpoint, body)
|
||||
if err != nil {
|
||||
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)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
@@ -103,7 +150,7 @@ func (c *Client) TranscribeAudio(audioFilePath, language string) (string, error)
|
||||
|
||||
// Handle response
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", c.handleAPIError(resp, "transcription")
|
||||
return "", c.handleAPIError(resp, "transcription", provider)
|
||||
}
|
||||
|
||||
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.
|
||||
// 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) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -165,12 +220,19 @@ func (c *Client) RefineText(text string, activeContext string) (string, 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 {
|
||||
logger.Error("Failed to create refinement request: %v", err)
|
||||
return text, nil
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
@@ -181,7 +243,7 @@ func (c *Client) RefineText(text string, activeContext string) (string, error) {
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
c.handleAPIError(resp, "refinement")
|
||||
c.handleAPIError(resp, "refinement", provider)
|
||||
return text, nil
|
||||
}
|
||||
|
||||
@@ -325,13 +387,13 @@ func (c *Client) prepareAudioRequest(audioFilePath, language string) (*bytes.Buf
|
||||
|
||||
// handleAPIError logs and formats API error responses.
|
||||
// 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)
|
||||
const maxLogLen = 200
|
||||
bodyStr := string(bodyBytes)
|
||||
if len(bodyStr) > maxLogLen {
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user