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", "@huggingface/transformers": "^3.8.1",
"electron-squirrel-startup": "^1.0.1", "electron-squirrel-startup": "^1.0.1",
"ollama": "^0.6.3", "ollama": "^0.6.3",
"openai": "^6.22.0",
"p-retry": "^4.6.2", "p-retry": "^4.6.2",
"ws": "^8.19.0" "ws": "^8.19.0"
}, },

19
pnpm-lock.yaml generated
View File

@ -23,6 +23,9 @@ importers:
ollama: ollama:
specifier: ^0.6.3 specifier: ^0.6.3
version: 0.6.3 version: 0.6.3
openai:
specifier: ^6.22.0
version: 6.22.0(ws@8.19.0)
p-retry: p-retry:
specifier: 4.6.2 specifier: 4.6.2
version: 4.6.2 version: 4.6.2
@ -1750,6 +1753,18 @@ packages:
onnxruntime-web@1.22.0-dev.20250409-89f8206ba4: onnxruntime-web@1.22.0-dev.20250409-89f8206ba4:
resolution: {integrity: sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ==} 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: ora@5.4.1:
resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -4522,6 +4537,10 @@ snapshots:
platform: 1.3.6 platform: 1.3.6
protobufjs: 7.5.4 protobufjs: 7.5.4
openai@6.22.0(ws@8.19.0):
optionalDependencies:
ws: 8.19.0
ora@5.4.1: ora@5.4.1:
dependencies: dependencies:
bl: 4.1.0 bl: 4.1.0

View File

@ -417,6 +417,9 @@ export class MainView extends LitElement {
_openaiCompatibleApiKey: { state: true }, _openaiCompatibleApiKey: { state: true },
_openaiCompatibleBaseUrl: { state: true }, _openaiCompatibleBaseUrl: { state: true },
_openaiCompatibleModel: { state: true }, _openaiCompatibleModel: { state: true },
_availableModels: { state: true },
_loadingModels: { state: true },
_manualModelInput: { state: true },
_responseProvider: { state: true }, _responseProvider: { state: true },
_tokenError: { state: true }, _tokenError: { state: true },
_keyError: { state: true }, _keyError: { state: true },
@ -444,6 +447,9 @@ export class MainView extends LitElement {
this._openaiCompatibleApiKey = ''; this._openaiCompatibleApiKey = '';
this._openaiCompatibleBaseUrl = ''; this._openaiCompatibleBaseUrl = '';
this._openaiCompatibleModel = ''; this._openaiCompatibleModel = '';
this._availableModels = [];
this._loadingModels = false;
this._manualModelInput = false;
this._responseProvider = 'gemini'; this._responseProvider = 'gemini';
this._tokenError = false; this._tokenError = false;
this._keyError = false; this._keyError = false;
@ -491,6 +497,11 @@ export class MainView extends LitElement {
this._whisperModel = prefs.whisperModel || 'Xenova/whisper-small'; this._whisperModel = prefs.whisperModel || 'Xenova/whisper-small';
this.requestUpdate(); 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) { } catch (e) {
console.error('Error loading MainView storage:', e); console.error('Error loading MainView storage:', e);
} }
@ -505,6 +516,7 @@ export class MainView extends LitElement {
super.disconnectedCallback(); super.disconnectedCallback();
document.removeEventListener('keydown', this.boundKeydownHandler); document.removeEventListener('keydown', this.boundKeydownHandler);
if (this._animId) cancelAnimationFrame(this._animId); if (this._animId) cancelAnimationFrame(this._animId);
if (this._loadModelsTimeout) clearTimeout(this._loadModelsTimeout);
} }
updated(changedProperties) { updated(changedProperties) {
@ -656,6 +668,8 @@ export class MainView extends LitElement {
this._openaiCompatibleModel this._openaiCompatibleModel
); );
this.requestUpdate(); this.requestUpdate();
// Auto-load models when both key and URL are set
this._debouncedLoadModels();
} }
async _saveOpenAICompatibleBaseUrl(val) { async _saveOpenAICompatibleBaseUrl(val) {
@ -666,6 +680,8 @@ export class MainView extends LitElement {
this._openaiCompatibleModel this._openaiCompatibleModel
); );
this.requestUpdate(); this.requestUpdate();
// Auto-load models when both key and URL are set
this._debouncedLoadModels();
} }
async _saveOpenAICompatibleModel(val) { async _saveOpenAICompatibleModel(val) {
@ -682,6 +698,79 @@ export class MainView extends LitElement {
this._responseProvider = val; this._responseProvider = val;
await cheatingDaddy.storage.updatePreference('responseProvider', val); await cheatingDaddy.storage.updatePreference('responseProvider', val);
this.requestUpdate(); 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) { async _saveOllamaHost(val) {
@ -824,15 +913,57 @@ export class MainView extends LitElement {
.value=${this._openaiCompatibleBaseUrl} .value=${this._openaiCompatibleBaseUrl}
@input=${e => this._saveOpenAICompatibleBaseUrl(e.target.value)} @input=${e => this._saveOpenAICompatibleBaseUrl(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 <input
type="text" type="text"
placeholder="Model name (e.g., anthropic/claude-3.5-sonnet)" placeholder="Model name (e.g., anthropic/claude-3.5-sonnet)"
style="flex: 1;"
.value=${this._openaiCompatibleModel} .value=${this._openaiCompatibleModel}
@input=${e => this._saveOpenAICompatibleModel(e.target.value)} @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>
<div class="form-hint"> <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>
</div> </div>
` : ''} ` : ''}

View File

@ -4,6 +4,7 @@ const { spawn } = require('child_process');
const { saveDebugAudio } = require('../audioUtils'); const { saveDebugAudio } = require('../audioUtils');
const { getSystemPrompt } = require('./prompts'); const { getSystemPrompt } = require('./prompts');
const { getAvailableModel, incrementLimitCount, getApiKey, getGroqApiKey, getOpenAICompatibleConfig, incrementCharUsage, getModelForToday } = require('../storage'); 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) // Lazy-loaded to avoid circular dependency (localai.js imports from gemini.js)
let _localai = null; let _localai = null;
@ -380,24 +381,15 @@ async function sendToOpenAICompatible(transcription) {
} }
try { try {
// Ensure baseUrl ends with /v1/chat/completions or contains the full endpoint const client = new OpenAI({
let apiUrl = config.baseUrl.trim(); apiKey: config.apiKey,
if (!apiUrl.includes('/chat/completions')) { baseURL: config.baseUrl.trim().replace(/\/$/, ''),
// Remove trailing slash if present dangerouslyAllowBrowser: false
apiUrl = apiUrl.replace(/\/$/, ''); });
// Add OpenAI-compatible endpoint path
apiUrl = `${apiUrl}/v1/chat/completions`;
}
console.log(`Using OpenAI-compatible endpoint: ${apiUrl}`); console.log(`Using OpenAI-compatible base URL: ${config.baseUrl}`);
const response = await fetch(apiUrl, { const stream = await client.chat.completions.create({
method: 'POST',
headers: {
'Authorization': `Bearer ${config.apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: config.model, model: config.model,
messages: [ messages: [
{ role: 'system', content: currentSystemPrompt || 'You are a helpful assistant.' }, { role: 'system', content: currentSystemPrompt || 'You are a helpful assistant.' },
@ -406,47 +398,19 @@ async function sendToOpenAICompatible(transcription) {
stream: true, stream: true,
temperature: 0.7, temperature: 0.7,
max_tokens: 2048 max_tokens: 2048
})
}); });
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;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let fullText = ''; let fullText = '';
let isFirst = true; let isFirst = true;
while (true) { for await (const chunk of stream) {
const { done, value } = await reader.read(); const content = chunk.choices?.[0]?.delta?.content;
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) { if (content) {
fullText += content; fullText += content;
sendToRenderer(isFirst ? 'new-response' : 'update-response', fullText); sendToRenderer(isFirst ? 'new-response' : 'update-response', fullText);
isFirst = false; isFirst = false;
} }
} catch (e) {
// Ignore JSON parse errors from partial chunks
}
}
}
} }
// Clean up <think> tags if present (for DeepSeek-style reasoning models) // Clean up <think> tags if present (for DeepSeek-style reasoning models)
@ -629,32 +593,32 @@ async function initializeGeminiSession(apiKey, customPrompt = '', profile = 'int
// if (message.serverContent?.outputTranscription?.text) { ... } // if (message.serverContent?.outputTranscription?.text) { ... }
if (message.serverContent?.generationComplete) { if (message.serverContent?.generationComplete) {
console.log('Generation complete. Current transcription:', `"${currentTranscription}"`); console.log('Generation complete. Current transcription:', `"${currentTranscription}"`);
if (currentTranscription.trim() !== '') { if (currentTranscription.trim() !== '') {
// Use explicit user choice for response provider // Use explicit user choice for response provider
if (currentResponseProvider === 'openai-compatible') { if (currentResponseProvider === 'openai-compatible') {
if (hasOpenAICompatibleConfig()) { if (hasOpenAICompatibleConfig()) {
console.log('📤 Sending to OpenAI-compatible API (user selected)'); console.log('Sending to OpenAI-compatible API (user selected)');
sendToOpenAICompatible(currentTranscription); sendToOpenAICompatible(currentTranscription);
} else { } 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); sendToGemma(currentTranscription);
} }
} else if (currentResponseProvider === 'groq') { } else if (currentResponseProvider === 'groq') {
if (hasGroqKey()) { if (hasGroqKey()) {
console.log('📤 Sending to Groq (user selected)'); console.log('Sending to Groq (user selected)');
sendToGroq(currentTranscription); sendToGroq(currentTranscription);
} else { } 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); sendToGemma(currentTranscription);
} }
} else { } else {
console.log('📤 Sending to Gemini (user selected)'); console.log('Sending to Gemini (user selected)');
sendToGemma(currentTranscription); sendToGemma(currentTranscription);
} }
currentTranscription = ''; currentTranscription = '';
} else { } else {
console.log('⚠️ Transcription is empty, not sending to LLM'); console.log('Transcription is empty, not sending to LLM');
} }
messageBuffer = ''; messageBuffer = '';
} }