Compare commits

..
Author SHA1 Message Date
jahruz67andGitHub b62d41d646 Merge pull request #11 from jahruz67/fix/html-parse-error
Fix HTML parsing error by removing control characters
2026-07-29 15:21:19 -07:00
Vibe Nuage Agentandjahruz67 62e679dc56 Fix transcriber.NewClient call: add missing MistralAPIKey parameter
The NewClient function expects 5 parameters (groqAPIKey, mistralAPIKey,
whisperModel, aiModel, aiPrompt) but was being called with only 4.

Fixes build error: not enough arguments in call to transcriber.NewClient
  have (string, string, string, string)
  want (string, string, string, string, string)

Co-authored-by: jahruz67 <jahruz67@users.noreply.github.com>
2026-07-29 22:17:56 +00:00
Vibe Nuage Agentandjahruz67 83ef777dc0 Fix HTML parsing error by removing control characters
Replace control characters (\u0012 Form Feed and \u0013 Carriage Return) with
proper HTML entities and clean text in index.html to fix parse5 parsing error.

- Line 106-107: Replace \u0012 with &gt; for menu navigation separators
- Line 224: Remove \u0013 control character

Fixes vite build error: Unable to parse HTML; parse5 error code
control-character-in-input-stream

Co-authored-by: jahruz67 <jahruz67@users.noreply.github.com>
2026-07-29 22:13:09 +00:00
jahruz67andGitHub 53a1559f2f Merge pull request #10 from jahruz67/vibe/mistral-provider-4e9fb5
Add Mistral AI as inference provider with Voxtral Small transcription model
2026-07-29 15:09:59 -07:00
Vibe Nuage Agentandjahruz67 9c9ad5ef87 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>
2026-07-29 22:06:10 +00:00
jahruz67andGitHub f658ab0de3 Merge pull request #9 from jahruz67/codex/upgrade-all-dependencies
chore: upgrade vite to ^3.2.11 in frontend package.json
2026-07-14 21:50:22 -07:00
jahruz67 4155c6d423 Remove repository gitignore 2026-07-14 21:47:20 -07:00
jahruz67andGitHub 644b3e5a22 Merge pull request #8 from jahruz67/codex/enhance-rpm-package-for-better-installation
Improve Linux package app store integration
2026-07-14 21:24:55 -07:00
6 changed files with 350 additions and 97 deletions
+33 -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,11 @@ 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.MistralAPIKey,
a.config.WhisperModel, a.config.WhisperModel,
a.config.AIModel, a.config.AIModel,
a.config.AIPrompt, a.config.AIPrompt,
@@ -613,7 +633,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 &gt; Keyboard &gt; Custom Shortcuts &gt; 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 &gt; Shortcuts &gt; Command/URL &gt; 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 {
+1 -1
View File
@@ -8,6 +8,6 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"devDependencies": { "devDependencies": {
"vite": "^3.0.7" "vite": "^3.2.11"
} }
} }
+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)
} }