Completions api (#8) (#29)

This commit is contained in:
2026-02-16 17:00:11 +00:00
parent d83217504c
commit d9ab162dfb
13 changed files with 1885 additions and 113 deletions

View File

@@ -3,68 +3,82 @@
// ==========================================
async function fetchModels(showInModal = true) {
const endpoint = showInModal
? document.getElementById('apiEndpoint').value.trim()
: State.settings.apiEndpoint;
const apiKey = showInModal
? document.getElementById('apiKey').value.trim()
: State.settings.apiKey;
if (!endpoint || !apiKey) {
showToast('⚠️ Configure API endpoint and key first', 'warning');
if (!showInModal) openSettings();
return;
}
const btn = showInModal
? document.getElementById('fetchModelsBtn')
: document.getElementById('quickFetchBtn');
const originalText = btn.innerHTML;
btn.innerHTML = '⏳';
btn.disabled = true;
if (btn) {
btn.dataset.originalText = btn.innerHTML;
btn.innerHTML = '⏳';
btn.disabled = true;
}
try {
const response = await fetch(endpoint.replace(/\/$/, '') + '/models', {
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
let models = [];
if (Backend.isManaged) {
// Managed mode: fetch from backend aggregated models endpoint
const data = await Backend.listAllModels();
models = (data.models || []).map(m => ({
id: m.id || m.name,
owned_by: m.owned_by || m.provider || null,
config_id: m.config_id || null
}));
} else {
// Unmanaged mode: direct provider call
const endpoint = showInModal
? document.getElementById('apiEndpoint').value.trim()
: State.settings.apiEndpoint;
const apiKey = showInModal
? document.getElementById('apiKey').value.trim()
: State.settings.apiKey;
if (!endpoint || !apiKey) {
showToast('⚠️ Configure API endpoint and key first', 'warning');
if (!showInModal) openSettings();
return;
}
});
if (!response.ok) {
throw new Error(`API Error: ${response.status}`);
const response = await fetch(endpoint.replace(/\/$/, '') + '/models', {
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) throw new Error(`API Error: ${response.status}`);
const data = await response.json();
const raw = data.data || data.models || data || [];
models = Array.isArray(raw)
? raw.map(m => ({
id: m.id || m.name || m,
owned_by: m.owned_by || null
}))
: [];
}
const data = await response.json();
const models = data.data || data.models || data || [];
State.models = Array.isArray(models)
? models.map(m => ({
id: m.id || m.name || m,
owned_by: m.owned_by || null
})).sort((a, b) => a.id.localeCompare(b.id))
: [];
State.models = models.sort((a, b) => a.id.localeCompare(b.id));
saveModels();
if (showInModal) {
populateModelSelect(document.getElementById('model'));
}
updateQuickModelSelector();
showToast(`✅ Loaded ${State.models.length} models`, 'success');
} catch (error) {
console.error('Fetch models error:', error);
showToast(`❌ Failed: ${error.message}`, 'error');
} finally {
btn.innerHTML = originalText;
btn.disabled = false;
if (btn) {
btn.innerHTML = btn.dataset.originalText || '🔄';
btn.disabled = false;
}
}
}
async function sendApiRequest(messages) {
const chatContainer = document.getElementById('chatMessages');
// Add typing indicator
const typingDiv = document.createElement('div');
typingDiv.className = 'message assistant';
@@ -80,44 +94,60 @@ async function sendApiRequest(messages) {
`;
chatContainer.appendChild(typingDiv);
scrollToBottom();
State.isGenerating = true;
State.abortController = new AbortController();
document.getElementById('stopBtn').classList.add('visible');
document.getElementById('sendBtn').disabled = true;
try {
const endpoint = State.settings.apiEndpoint.replace(/\/$/, '') + '/chat/completions';
const model = document.getElementById('quickModel').value || State.settings.model;
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${State.settings.apiKey}`
},
body: JSON.stringify({
model,
messages: messages.map(m => ({ role: m.role, content: m.content })),
max_tokens: State.settings.maxTokens,
temperature: State.settings.temperature,
top_p: State.settings.topP,
presence_penalty: State.settings.presencePenalty,
stream: State.settings.stream
}),
signal: State.abortController.signal
});
if (!response.ok) {
const error = await response.text();
throw new Error(`API Error: ${response.status} - ${error}`);
const model = document.getElementById('quickModel').value || State.settings.model;
const userContent = messages[messages.length - 1]?.content || '';
try {
let response;
if (Backend.isManaged) {
// ── Managed mode: route through backend proxy ──
// Backend loads conversation from DB, persists both messages.
response = await Backend.streamCompletion(
State.currentChatId,
userContent,
model,
State.abortController.signal
);
} else {
// ── Unmanaged mode: direct provider call ──
const endpoint = State.settings.apiEndpoint.replace(/\/$/, '') + '/chat/completions';
response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${State.settings.apiKey}`
},
body: JSON.stringify({
model,
messages: messages.map(m => ({ role: m.role, content: m.content })),
max_tokens: State.settings.maxTokens,
temperature: State.settings.temperature,
top_p: State.settings.topP,
presence_penalty: State.settings.presencePenalty,
stream: State.settings.stream
}),
signal: State.abortController.signal
});
if (!response.ok) {
const error = await response.text();
throw new Error(`API Error: ${response.status} - ${error}`);
}
}
document.getElementById('typingIndicator')?.remove();
let assistantContent = '';
if (State.settings.stream) {
const isStream = Backend.isManaged ? true : State.settings.stream;
if (isStream) {
const assistantDiv = document.createElement('div');
assistantDiv.className = 'message assistant';
assistantDiv.innerHTML = `
@@ -132,22 +162,22 @@ async function sendApiRequest(messages) {
</div>
`;
chatContainer.appendChild(assistantDiv);
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
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 || '';
@@ -162,26 +192,28 @@ async function sendApiRequest(messages) {
const data = await response.json();
assistantContent = data.choices?.[0]?.message?.content || 'No response';
}
messages.push({
role: 'assistant',
messages.push({
role: 'assistant',
content: assistantContent,
timestamp: new Date().toISOString()
});
updateChat(State.currentChatId, { messages, model });
// Persist assistant response to backend (managed mode)
await onAssistantResponse(assistantContent, model);
if (!State.settings.stream) {
// In unmanaged mode, persist locally + to backend if connected
await updateChat(State.currentChatId, { messages, model });
if (!Backend.isManaged) {
await onAssistantResponse(assistantContent, model);
}
if (!isStream) {
renderMessages(messages);
}
updateRegenerateButton(messages);
} catch (error) {
document.getElementById('typingIndicator')?.remove();
if (error.name === 'AbortError') {
showToast('⚠️ Generation stopped', 'warning');
} else {