Add OpenAI dependency and implement model loading in MainView for OpenAI-compatible API

This commit is contained in:
Илья Глазунов
2026-02-14 23:16:41 +03:00
parent 8b216bbb33
commit 494e692738
4 changed files with 189 additions and 74 deletions
+138 -7
View File
@@ -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)}
/>
<input
type="text"
placeholder="Model name (e.g., anthropic/claude-3.5-sonnet)"
.value=${this._openaiCompatibleModel}
@input=${e => this._saveOpenAICompatibleModel(e.target.value)}
/>
${this._loadingModels ? html`
<input
type="text"
placeholder="Loading models..."
disabled
style="opacity: 0.6;"
/>
` : this._availableModels.length > 0 && !this._manualModelInput ? html`
<div style="display: flex; gap: 4px;">
<select
style="flex: 1;"
.value=${this._openaiCompatibleModel}
@change=${e => this._saveOpenAICompatibleModel(e.target.value)}
>
${this._availableModels.map(model => html`
<option value="${model}" ?selected=${this._openaiCompatibleModel === model}>
${model}
</option>
`)}
</select>
<button
type="button"
@click=${() => this._toggleManualInput()}
style="padding: 8px 12px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius-sm); cursor: pointer; color: var(--text-muted); font-size: var(--font-size-xs);"
title="Enter model manually"
>✏️</button>
</div>
` : html`
<div style="display: flex; gap: 4px;">
<input
type="text"
placeholder="Model name (e.g., anthropic/claude-3.5-sonnet)"
style="flex: 1;"
.value=${this._openaiCompatibleModel}
@input=${e => this._saveOpenAICompatibleModel(e.target.value)}
/>
${this._availableModels.length > 0 ? html`
<button
type="button"
@click=${() => this._toggleManualInput()}
style="padding: 8px 12px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius-sm); cursor: pointer; color: var(--text-muted); font-size: var(--font-size-xs);"
title="Select from list"
>📋</button>
` : ''}
</div>
`}
</div>
<div class="form-hint">
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'}
</div>
</div>
` : ''}
+31 -67
View File
@@ -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 = '';
}