mirror of
https://github.com/jahruz67/wisp-open.git
synced 2026-08-08 18:14:08 +00:00
Merge pull request #10 from jahruz67/vibe/mistral-provider-4e9fb5
Add Mistral AI as inference provider with Voxtral Small transcription model
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
+104
-39
@@ -24,19 +24,48 @@
|
|||||||
|
|
||||||
<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">
|
||||||
<div class="form-control">
|
<summary class="api-keys-summary">
|
||||||
<div class="flex-row">
|
<span>API Keys</span>
|
||||||
<div class="input-wrapper">
|
<span id="apiKeysStatus" style="color: var(--text-2); font-size: 12px; margin-left: 8px;">
|
||||||
<input type="password" id="apiKey" placeholder="gsk_...">
|
<span id="groqKeyStatus">No Groq key</span> | <span id="mistralKeyStatus">No Mistral key</span>
|
||||||
<button class="eye-btn" onclick="toggleApiKey()" id="eyeBtn">👁️</button>
|
</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>
|
</div>
|
||||||
<button onclick="saveApiKey()">Save</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</details>
|
||||||
<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>
|
||||||
|
|
||||||
<!-- 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) -->
|
||||||
@@ -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 {
|
||||||
|
|||||||
+98
-17
@@ -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,11 +471,11 @@ 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 {
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
border-radius: 10px;
|
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.
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,18 +42,19 @@ 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
|
||||||
whisperModel string
|
mistralAPIKey string
|
||||||
aiModel string
|
whisperModel string
|
||||||
aiPrompt string
|
aiModel string
|
||||||
httpClient *http.Client
|
aiPrompt string
|
||||||
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,21 +66,52 @@ func NewClient(apiKey, whisperModel, aiModel, aiPrompt string) *Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &Client{
|
return &Client{
|
||||||
apiKey: apiKey,
|
groqAPIKey: groqAPIKey,
|
||||||
whisperModel: whisperModel,
|
mistralAPIKey: mistralAPIKey,
|
||||||
aiModel: aiModel,
|
whisperModel: whisperModel,
|
||||||
aiPrompt: aiPrompt,
|
aiModel: aiModel,
|
||||||
|
aiPrompt: aiPrompt,
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: HTTPTimeout,
|
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.
|
// 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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user