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",
|
"@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
19
pnpm-lock.yaml
generated
@ -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
|
||||||
|
|||||||
@ -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)}
|
||||||
/>
|
/>
|
||||||
<input
|
${this._loadingModels ? html`
|
||||||
type="text"
|
<input
|
||||||
placeholder="Model name (e.g., anthropic/claude-3.5-sonnet)"
|
type="text"
|
||||||
.value=${this._openaiCompatibleModel}
|
placeholder="Loading models..."
|
||||||
@input=${e => this._saveOpenAICompatibleModel(e.target.value)}
|
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>
|
||||||
<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>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
|||||||
@ -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,72 +381,35 @@ 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}`);
|
|
||||||
|
|
||||||
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
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
console.log(`Using OpenAI-compatible base URL: ${config.baseUrl}`);
|
||||||
const errorText = await response.text();
|
|
||||||
console.error('OpenAI-compatible API error:', response.status, errorText);
|
const stream = await client.chat.completions.create({
|
||||||
sendToRenderer('update-status', `OpenAI API error: ${response.status}`);
|
model: config.model,
|
||||||
return;
|
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 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;
|
|
||||||
|
if (content) {
|
||||||
const chunk = decoder.decode(value, { stream: true });
|
fullText += content;
|
||||||
const lines = chunk.split('\n').filter(line => line.trim() !== '');
|
sendToRenderer(isFirst ? 'new-response' : 'update-response', fullText);
|
||||||
|
isFirst = false;
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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 = '';
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user