206
src/js/api.js
206
src/js/api.js
@@ -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 {
|
||||
|
||||
@@ -237,10 +237,13 @@ async function sendMessage() {
|
||||
|
||||
if (!message || State.isGenerating) return;
|
||||
|
||||
if (!State.settings.apiEndpoint || !State.settings.apiKey) {
|
||||
showToast('⚠️ Configure API settings first', 'warning');
|
||||
openSettings();
|
||||
return;
|
||||
// In managed mode, backend has the keys. In unmanaged, user must configure.
|
||||
if (!Backend.isManaged) {
|
||||
if (!State.settings.apiEndpoint || !State.settings.apiKey) {
|
||||
showToast('⚠️ Configure API settings first', 'warning');
|
||||
openSettings();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
input.value = '';
|
||||
@@ -270,8 +273,11 @@ async function sendMessage() {
|
||||
State.currentChatId = chat.id;
|
||||
} else {
|
||||
await updateChat(State.currentChatId, { messages });
|
||||
// Persist user message to backend
|
||||
await persistMessage(State.currentChatId, 'user', message);
|
||||
// In unmanaged mode, persist user message to backend (if connected)
|
||||
// In managed mode, the completion proxy persists both messages
|
||||
if (!Backend.isManaged) {
|
||||
await persistMessage(State.currentChatId, 'user', message);
|
||||
}
|
||||
}
|
||||
|
||||
renderMessages(messages);
|
||||
|
||||
@@ -178,6 +178,89 @@ const Backend = {
|
||||
});
|
||||
},
|
||||
|
||||
// ── API Configs ─────────────────────────
|
||||
|
||||
async listConfigs() {
|
||||
return this._authedFetch('/api/v1/api-configs');
|
||||
},
|
||||
|
||||
async createConfig(name, provider, endpoint, apiKey, modelDefault) {
|
||||
return this._authedFetch('/api/v1/api-configs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name, provider, endpoint,
|
||||
api_key: apiKey,
|
||||
model_default: modelDefault
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
async deleteConfig(configId) {
|
||||
return this._authedFetch(`/api/v1/api-configs/${configId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
},
|
||||
|
||||
// ── Models ──────────────────────────────
|
||||
|
||||
async listAllModels() {
|
||||
return this._authedFetch('/api/v1/models');
|
||||
},
|
||||
|
||||
// ── Chat Completion (streaming) ─────────
|
||||
// Returns the raw Response so the caller can read the SSE stream.
|
||||
// The backend persists user + assistant messages automatically.
|
||||
|
||||
async streamCompletion(chatId, content, model, signal) {
|
||||
const url = this.baseUrl + '/api/v1/chat/completions';
|
||||
const body = { chat_id: chatId, content, stream: true };
|
||||
if (model) body.model = model;
|
||||
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.accessToken}`
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal
|
||||
});
|
||||
|
||||
if (resp.status === 401 && this.refreshToken) {
|
||||
const refreshed = await this.refresh();
|
||||
if (refreshed) {
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.accessToken}`
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
const errBody = await resp.json().catch(() => ({}));
|
||||
throw new Error(errBody.error || `HTTP ${resp.status}`);
|
||||
}
|
||||
|
||||
return resp;
|
||||
},
|
||||
|
||||
async syncCompletion(chatId, content, model) {
|
||||
return this._authedFetch('/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId,
|
||||
content,
|
||||
model,
|
||||
stream: false
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
// ── HTTP Layer ──────────────────────────
|
||||
|
||||
async _authedFetch(path, opts = {}) {
|
||||
|
||||
Reference in New Issue
Block a user