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

View File

@ -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"
},

19
pnpm-lock.yaml generated
View File

@ -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

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>
` : ''}

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;
for await (const chunk of stream) {
const content = chunk.choices?.[0]?.delta?.content;
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
}
}
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 = '';
}