diff --git a/package.json b/package.json index 7860d3a..b9f5f60 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@huggingface/transformers": "^3.8.1", "electron-squirrel-startup": "^1.0.1", "ollama": "^0.6.3", + "openai": "^6.22.0", "p-retry": "^4.6.2", "ws": "^8.19.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 64d14f4..27608cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: ollama: specifier: ^0.6.3 version: 0.6.3 + openai: + specifier: ^6.22.0 + version: 6.22.0(ws@8.19.0) p-retry: specifier: 4.6.2 version: 4.6.2 @@ -1750,6 +1753,18 @@ packages: onnxruntime-web@1.22.0-dev.20250409-89f8206ba4: resolution: {integrity: sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ==} + openai@6.22.0: + resolution: {integrity: sha512-7Yvy17F33Bi9RutWbsaYt5hJEEJ/krRPOrwan+f9aCPuMat1WVsb2VNSII5W1EksKT6fF69TG/xj4XzodK3JZw==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + ora@5.4.1: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} @@ -4522,6 +4537,10 @@ snapshots: platform: 1.3.6 protobufjs: 7.5.4 + openai@6.22.0(ws@8.19.0): + optionalDependencies: + ws: 8.19.0 + ora@5.4.1: dependencies: bl: 4.1.0 diff --git a/src/components/views/MainView.js b/src/components/views/MainView.js index 1182d82..3f9b199 100644 --- a/src/components/views/MainView.js +++ b/src/components/views/MainView.js @@ -417,6 +417,9 @@ export class MainView extends LitElement { _openaiCompatibleApiKey: { state: true }, _openaiCompatibleBaseUrl: { state: true }, _openaiCompatibleModel: { state: true }, + _availableModels: { state: true }, + _loadingModels: { state: true }, + _manualModelInput: { state: true }, _responseProvider: { state: true }, _tokenError: { state: true }, _keyError: { state: true }, @@ -444,6 +447,9 @@ export class MainView extends LitElement { this._openaiCompatibleApiKey = ''; this._openaiCompatibleBaseUrl = ''; this._openaiCompatibleModel = ''; + this._availableModels = []; + this._loadingModels = false; + this._manualModelInput = false; this._responseProvider = 'gemini'; this._tokenError = false; this._keyError = false; @@ -491,6 +497,11 @@ export class MainView extends LitElement { this._whisperModel = prefs.whisperModel || 'Xenova/whisper-small'; this.requestUpdate(); + + // Auto-load models if OpenAI-compatible is selected and URL is set + if (this._responseProvider === 'openai-compatible' && this._openaiCompatibleBaseUrl) { + this._loadModels(); + } } catch (e) { console.error('Error loading MainView storage:', e); } @@ -505,6 +516,7 @@ export class MainView extends LitElement { super.disconnectedCallback(); document.removeEventListener('keydown', this.boundKeydownHandler); if (this._animId) cancelAnimationFrame(this._animId); + if (this._loadModelsTimeout) clearTimeout(this._loadModelsTimeout); } updated(changedProperties) { @@ -656,6 +668,8 @@ export class MainView extends LitElement { this._openaiCompatibleModel ); this.requestUpdate(); + // Auto-load models when both key and URL are set + this._debouncedLoadModels(); } async _saveOpenAICompatibleBaseUrl(val) { @@ -666,6 +680,8 @@ export class MainView extends LitElement { this._openaiCompatibleModel ); this.requestUpdate(); + // Auto-load models when both key and URL are set + this._debouncedLoadModels(); } async _saveOpenAICompatibleModel(val) { @@ -682,6 +698,79 @@ export class MainView extends LitElement { this._responseProvider = val; await cheatingDaddy.storage.updatePreference('responseProvider', val); this.requestUpdate(); + + // Auto-load models when switching to openai-compatible + if (val === 'openai-compatible' && this._openaiCompatibleBaseUrl) { + this._loadModels(); + } + } + + async _loadModels() { + if (this._responseProvider !== 'openai-compatible' || !this._openaiCompatibleBaseUrl) { + return; + } + + this._loadingModels = true; + this._availableModels = []; + this.requestUpdate(); + + try { + let modelsUrl = this._openaiCompatibleBaseUrl.trim(); + modelsUrl = modelsUrl.replace(/\/$/, ''); + if (!modelsUrl.includes('/models')) { + modelsUrl = modelsUrl.includes('/v1') ? modelsUrl + '/models' : modelsUrl + '/v1/models'; + } + + console.log('Loading models from:', modelsUrl); + + const headers = { + 'Content-Type': 'application/json' + }; + + if (this._openaiCompatibleApiKey) { + headers['Authorization'] = `Bearer ${this._openaiCompatibleApiKey}`; + } + + const response = await fetch(modelsUrl, { headers }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json(); + + if (data.data && Array.isArray(data.data)) { + this._availableModels = data.data.map(m => m.id || m.model || m.name).filter(Boolean); + } else if (Array.isArray(data)) { + this._availableModels = data.map(m => m.id || m.model || m.name || m).filter(Boolean); + } + + console.log('Loaded models:', this._availableModels.length); + + if (this._availableModels.length > 0 && !this._availableModels.includes(this._openaiCompatibleModel)) { + await this._saveOpenAICompatibleModel(this._availableModels[0]); + } + } catch (error) { + console.log('Could not load models:', error.message); + this._availableModels = []; + } finally { + this._loadingModels = false; + this.requestUpdate(); + } + } + + _debouncedLoadModels() { + if (this._loadModelsTimeout) { + clearTimeout(this._loadModelsTimeout); + } + this._loadModelsTimeout = setTimeout(() => { + this._loadModels(); + }, 500); + } + + _toggleManualInput() { + this._manualModelInput = !this._manualModelInput; + this.requestUpdate(); } async _saveOllamaHost(val) { @@ -824,15 +913,57 @@ export class MainView extends LitElement { .value=${this._openaiCompatibleBaseUrl} @input=${e => this._saveOpenAICompatibleBaseUrl(e.target.value)} /> - this._saveOpenAICompatibleModel(e.target.value)} - /> + ${this._loadingModels ? html` + + ` : this._availableModels.length > 0 && !this._manualModelInput ? html` +
+ + +
+ ` : html` +
+ this._saveOpenAICompatibleModel(e.target.value)} + /> + ${this._availableModels.length > 0 ? html` + + ` : ''} +
+ `}
- Use OpenRouter, DeepSeek, Together AI, or any OpenAI-compatible API + ${this._loadingModels ? 'Loading available models...' : + this._availableModels.length > 0 ? `${this._availableModels.length} models available` : + 'Use OpenRouter, DeepSeek, Together AI, or any OpenAI-compatible API'}
` : ''} diff --git a/src/utils/gemini.js b/src/utils/gemini.js index a06315c..5d034e3 100644 --- a/src/utils/gemini.js +++ b/src/utils/gemini.js @@ -4,6 +4,7 @@ const { spawn } = require('child_process'); const { saveDebugAudio } = require('../audioUtils'); const { getSystemPrompt } = require('./prompts'); const { getAvailableModel, incrementLimitCount, getApiKey, getGroqApiKey, getOpenAICompatibleConfig, incrementCharUsage, getModelForToday } = require('../storage'); +const OpenAI = require('openai'); // Lazy-loaded to avoid circular dependency (localai.js imports from gemini.js) let _localai = null; @@ -380,72 +381,35 @@ async function sendToOpenAICompatible(transcription) { } try { - // Ensure baseUrl ends with /v1/chat/completions or contains the full endpoint - let apiUrl = config.baseUrl.trim(); - if (!apiUrl.includes('/chat/completions')) { - // Remove trailing slash if present - apiUrl = apiUrl.replace(/\/$/, ''); - // Add OpenAI-compatible endpoint path - apiUrl = `${apiUrl}/v1/chat/completions`; - } - - console.log(`Using OpenAI-compatible endpoint: ${apiUrl}`); - - const response = await fetch(apiUrl, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${config.apiKey}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - model: config.model, - messages: [ - { role: 'system', content: currentSystemPrompt || 'You are a helpful assistant.' }, - ...groqConversationHistory - ], - stream: true, - temperature: 0.7, - max_tokens: 2048 - }) + const client = new OpenAI({ + apiKey: config.apiKey, + baseURL: config.baseUrl.trim().replace(/\/$/, ''), + dangerouslyAllowBrowser: false }); - if (!response.ok) { - const errorText = await response.text(); - console.error('OpenAI-compatible API error:', response.status, errorText); - sendToRenderer('update-status', `OpenAI API error: ${response.status}`); - return; - } + console.log(`Using OpenAI-compatible base URL: ${config.baseUrl}`); + + const stream = await client.chat.completions.create({ + model: config.model, + messages: [ + { role: 'system', content: currentSystemPrompt || 'You are a helpful assistant.' }, + ...groqConversationHistory + ], + stream: true, + temperature: 0.7, + max_tokens: 2048 + }); - const reader = response.body.getReader(); - const decoder = new TextDecoder(); let fullText = ''; let isFirst = true; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - const chunk = decoder.decode(value, { stream: true }); - const lines = chunk.split('\n').filter(line => line.trim() !== ''); - - for (const line of lines) { - if (line.startsWith('data: ')) { - const data = line.slice(6); - if (data === '[DONE]') continue; - - try { - const parsed = JSON.parse(data); - const content = parsed.choices?.[0]?.delta?.content; - - if (content) { - fullText += content; - sendToRenderer(isFirst ? 'new-response' : 'update-response', fullText); - isFirst = false; - } - } catch (e) { - // Ignore JSON parse errors from partial chunks - } - } + for await (const chunk of stream) { + const content = chunk.choices?.[0]?.delta?.content; + + if (content) { + fullText += content; + sendToRenderer(isFirst ? 'new-response' : 'update-response', fullText); + isFirst = false; } } @@ -629,32 +593,32 @@ async function initializeGeminiSession(apiKey, customPrompt = '', profile = 'int // if (message.serverContent?.outputTranscription?.text) { ... } if (message.serverContent?.generationComplete) { - console.log('✅ Generation complete. Current transcription:', `"${currentTranscription}"`); + console.log('Generation complete. Current transcription:', `"${currentTranscription}"`); if (currentTranscription.trim() !== '') { // Use explicit user choice for response provider if (currentResponseProvider === 'openai-compatible') { if (hasOpenAICompatibleConfig()) { - console.log('📤 Sending to OpenAI-compatible API (user selected)'); + console.log('Sending to OpenAI-compatible API (user selected)'); sendToOpenAICompatible(currentTranscription); } else { - console.log('⚠️ OpenAI-compatible selected but not configured, falling back to Gemini'); + console.log('OpenAI-compatible selected but not configured, falling back to Gemini'); sendToGemma(currentTranscription); } } else if (currentResponseProvider === 'groq') { if (hasGroqKey()) { - console.log('📤 Sending to Groq (user selected)'); + console.log('Sending to Groq (user selected)'); sendToGroq(currentTranscription); } else { - console.log('⚠️ Groq selected but not configured, falling back to Gemini'); + console.log('Groq selected but not configured, falling back to Gemini'); sendToGemma(currentTranscription); } } else { - console.log('📤 Sending to Gemini (user selected)'); + console.log('Sending to Gemini (user selected)'); sendToGemma(currentTranscription); } currentTranscription = ''; } else { - console.log('⚠️ Transcription is empty, not sending to LLM'); + console.log('Transcription is empty, not sending to LLM'); } messageBuffer = ''; }