Add OpenAI dependency and implement model loading in MainView for OpenAI-compatible API
This commit is contained in:
parent
8b216bbb33
commit
494e692738
@ -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
19
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
@ -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>
|
||||
` : ''}
|
||||
|
||||
@ -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 = '';
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user