Upload 9 modified files from chat-switchboard-v0.5.0.zip
This commit is contained in:
439
src/js/api.js
439
src/js/api.js
@@ -1,251 +1,240 @@
|
||||
// ==========================================
|
||||
// API Functions
|
||||
// Chat Switchboard – API Client (v0.5.0)
|
||||
// ==========================================
|
||||
// Backend-only mode. Handles auth tokens and
|
||||
// all HTTP calls. No offline fallback.
|
||||
// ==========================================
|
||||
|
||||
async function fetchModels(showInModal = true) {
|
||||
const btn = showInModal
|
||||
? document.getElementById('fetchModelsBtn')
|
||||
: document.getElementById('quickFetchBtn');
|
||||
if (btn) {
|
||||
btn.dataset.originalText = btn.innerHTML;
|
||||
btn.innerHTML = '⏳';
|
||||
btn.disabled = true;
|
||||
}
|
||||
const API = {
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
user: null,
|
||||
_refreshing: null,
|
||||
|
||||
try {
|
||||
let models = [];
|
||||
// ── Bootstrap ────────────────────────────
|
||||
|
||||
if (Backend.isManaged) {
|
||||
// Managed mode: fetch admin-curated enabled models
|
||||
const data = await Backend.listEnabledModels();
|
||||
models = (data.models || []).map(m => ({
|
||||
id: m.model_id || m.id,
|
||||
owned_by: m.provider_name || m.provider || null,
|
||||
config_id: m.config_id || null
|
||||
}));
|
||||
// Fallback: if no curated models, try raw aggregation
|
||||
if (models.length === 0) {
|
||||
const raw = await Backend.listAllModels();
|
||||
models = (raw.models || []).map(m => ({
|
||||
id: m.id || m.name,
|
||||
owned_by: m.owned_by || m.provider || null,
|
||||
config_id: m.config_id || null
|
||||
}));
|
||||
loadTokens() {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem('sb_auth') || 'null');
|
||||
if (saved) {
|
||||
this.accessToken = saved.accessToken || null;
|
||||
this.refreshToken = saved.refreshToken || null;
|
||||
this.user = saved.user || 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;
|
||||
} catch (e) { /* corrupt storage */ }
|
||||
},
|
||||
|
||||
if (!endpoint || !apiKey) {
|
||||
showToast('⚠️ Configure API endpoint and key first', 'warning');
|
||||
if (!showInModal) openSettings();
|
||||
return;
|
||||
saveTokens() {
|
||||
localStorage.setItem('sb_auth', JSON.stringify({
|
||||
accessToken: this.accessToken,
|
||||
refreshToken: this.refreshToken,
|
||||
user: this.user
|
||||
}));
|
||||
},
|
||||
|
||||
clearTokens() {
|
||||
this.accessToken = null;
|
||||
this.refreshToken = null;
|
||||
this.user = null;
|
||||
localStorage.removeItem('sb_auth');
|
||||
},
|
||||
|
||||
get isAuthed() { return !!this.accessToken; },
|
||||
get isAdmin() { return this.user?.role === 'admin'; },
|
||||
|
||||
// ── Auth ─────────────────────────────────
|
||||
|
||||
async health() {
|
||||
const resp = await fetch('/api/v1/health', { signal: AbortSignal.timeout(8000) });
|
||||
if (!resp.ok) throw new Error(`Health: ${resp.status}`);
|
||||
return resp.json();
|
||||
},
|
||||
|
||||
async login(login, password) {
|
||||
const data = await this._post('/api/v1/auth/login', { login, password }, true);
|
||||
this._setAuth(data);
|
||||
return data;
|
||||
},
|
||||
|
||||
async register(username, email, password) {
|
||||
const data = await this._post('/api/v1/auth/register', { username, email, password }, true);
|
||||
this._setAuth(data);
|
||||
return data;
|
||||
},
|
||||
|
||||
async logout() {
|
||||
try { await this._post('/api/v1/auth/logout', { refresh_token: this.refreshToken }, true); }
|
||||
catch (e) { /* best effort */ }
|
||||
this.clearTokens();
|
||||
},
|
||||
|
||||
async refresh() {
|
||||
if (this._refreshing) return this._refreshing;
|
||||
this._refreshing = (async () => {
|
||||
try {
|
||||
const data = await this._post('/api/v1/auth/refresh', { refresh_token: this.refreshToken }, true);
|
||||
this._setAuth(data);
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.clearTokens();
|
||||
return false;
|
||||
} finally {
|
||||
this._refreshing = null;
|
||||
}
|
||||
})();
|
||||
return this._refreshing;
|
||||
},
|
||||
|
||||
const response = await fetch(endpoint.replace(/\/$/, '') + '/models', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
_setAuth(data) {
|
||||
this.accessToken = data.access_token;
|
||||
this.refreshToken = data.refresh_token;
|
||||
this.user = data.user;
|
||||
this.saveTokens();
|
||||
},
|
||||
|
||||
if (!response.ok) throw new Error(`API Error: ${response.status}`);
|
||||
// ── Chats ────────────────────────────────
|
||||
|
||||
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
|
||||
}))
|
||||
: [];
|
||||
}
|
||||
listChats(page = 1, perPage = 100) {
|
||||
return this._get(`/api/v1/chats?page=${page}&per_page=${perPage}`);
|
||||
},
|
||||
createChat(title, model, systemPrompt) {
|
||||
const body = { title };
|
||||
if (model) body.model = model;
|
||||
if (systemPrompt) body.system_prompt = systemPrompt;
|
||||
return this._post('/api/v1/chats', body);
|
||||
},
|
||||
getChat(id) { return this._get(`/api/v1/chats/${id}`); },
|
||||
updateChat(id, updates) { return this._put(`/api/v1/chats/${id}`, updates); },
|
||||
deleteChat(id) { return this._del(`/api/v1/chats/${id}`); },
|
||||
|
||||
State.models = models.sort((a, b) => a.id.localeCompare(b.id));
|
||||
saveModels();
|
||||
// ── Messages ─────────────────────────────
|
||||
|
||||
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 {
|
||||
if (btn) {
|
||||
btn.innerHTML = btn.dataset.originalText || '🔄';
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
listMessages(chatId, page = 1, perPage = 200) {
|
||||
return this._get(`/api/v1/chats/${chatId}/messages?page=${page}&per_page=${perPage}`);
|
||||
},
|
||||
|
||||
async function sendApiRequest(messages) {
|
||||
const chatContainer = document.getElementById('chatMessages');
|
||||
// ── Completions ──────────────────────────
|
||||
|
||||
// Add typing indicator
|
||||
const typingDiv = document.createElement('div');
|
||||
typingDiv.className = 'message assistant';
|
||||
typingDiv.id = 'typingIndicator';
|
||||
typingDiv.innerHTML = `
|
||||
<div class="message-content">
|
||||
<div class="message-avatar">🤖</div>
|
||||
<div class="message-body">
|
||||
<div class="message-role">Assistant</div>
|
||||
<div class="typing-indicator"><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
chatContainer.appendChild(typingDiv);
|
||||
scrollToBottom();
|
||||
async streamCompletion(chatId, content, model, signal) {
|
||||
const body = { chat_id: chatId, content, stream: true };
|
||||
if (model) body.model = model;
|
||||
|
||||
State.isGenerating = true;
|
||||
State.abortController = new AbortController();
|
||||
document.getElementById('stopBtn').classList.add('visible');
|
||||
document.getElementById('sendBtn').disabled = true;
|
||||
|
||||
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 = '';
|
||||
const isStream = Backend.isManaged ? true : State.settings.stream;
|
||||
|
||||
if (isStream) {
|
||||
const assistantDiv = document.createElement('div');
|
||||
assistantDiv.className = 'message assistant';
|
||||
assistantDiv.innerHTML = `
|
||||
<div class="message-content">
|
||||
<div class="message-avatar">🤖</div>
|
||||
<div class="message-body">
|
||||
<div class="message-header">
|
||||
<span class="message-role">Assistant</span>
|
||||
</div>
|
||||
<div class="message-text" id="streamingContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
chatContainer.appendChild(assistantDiv);
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let sseBuffer = ''; // Buffer for incomplete SSE lines
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
sseBuffer += decoder.decode(value, { stream: true });
|
||||
const lines = sseBuffer.split('\n');
|
||||
// Keep the last (potentially incomplete) line in the buffer
|
||||
sseBuffer = lines.pop() || '';
|
||||
|
||||
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 || '';
|
||||
assistantContent += content;
|
||||
document.getElementById('streamingContent').innerHTML = formatMessage(assistantContent);
|
||||
scrollToBottom();
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const data = await response.json();
|
||||
assistantContent = data.choices?.[0]?.message?.content || 'No response';
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: assistantContent,
|
||||
timestamp: new Date().toISOString()
|
||||
let resp = await fetch('/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: this._authHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
signal
|
||||
});
|
||||
|
||||
// In unmanaged mode, persist locally + to backend if connected
|
||||
await updateChat(State.currentChatId, { messages, model });
|
||||
if (!Backend.isManaged) {
|
||||
await onAssistantResponse(assistantContent, model);
|
||||
if (resp.status === 401 && this.refreshToken) {
|
||||
if (await this.refresh()) {
|
||||
resp = await fetch('/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: this._authHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
signal
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!isStream) {
|
||||
renderMessages(messages);
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.error || `HTTP ${resp.status}`);
|
||||
}
|
||||
return resp;
|
||||
},
|
||||
|
||||
updateRegenerateButton(messages);
|
||||
// ── Models ───────────────────────────────
|
||||
|
||||
} catch (error) {
|
||||
document.getElementById('typingIndicator')?.remove();
|
||||
listEnabledModels() { return this._get('/api/v1/models/enabled'); },
|
||||
listAllModels() { return this._get('/api/v1/models'); },
|
||||
|
||||
if (error.name === 'AbortError') {
|
||||
showToast('⚠️ Generation stopped', 'warning');
|
||||
} else {
|
||||
console.error('API Error:', error);
|
||||
showToast(`❌ ${error.message}`, 'error');
|
||||
// ── API Configs (user providers) ─────────
|
||||
|
||||
listConfigs() { return this._get('/api/v1/api-configs'); },
|
||||
createConfig(name, provider, endpoint, apiKey, modelDefault) {
|
||||
return this._post('/api/v1/api-configs', {
|
||||
name, provider, endpoint, api_key: apiKey, model_default: modelDefault
|
||||
});
|
||||
},
|
||||
deleteConfig(id) { return this._del(`/api/v1/api-configs/${id}`); },
|
||||
|
||||
// ── Profile & Settings ───────────────────
|
||||
|
||||
getProfile() { return this._get('/api/v1/profile'); },
|
||||
updateProfile(updates) { return this._put('/api/v1/profile', updates); },
|
||||
changePassword(current, newPw) {
|
||||
return this._post('/api/v1/profile/password', { current_password: current, new_password: newPw });
|
||||
},
|
||||
getSettings() { return this._get('/api/v1/settings'); },
|
||||
updateSettings(settings) { return this._put('/api/v1/settings', settings); },
|
||||
|
||||
// ── Admin ────────────────────────────────
|
||||
|
||||
adminListUsers(p, pp) { return this._get(`/api/v1/admin/users?page=${p||1}&per_page=${pp||50}`); },
|
||||
adminCreateUser(username, email, password, role) {
|
||||
return this._post('/api/v1/admin/users', { username, email, password, role });
|
||||
},
|
||||
adminResetPassword(id, pw) { return this._post(`/api/v1/admin/users/${id}/reset-password`, { new_password: pw }); },
|
||||
adminUpdateRole(id, role) { return this._put(`/api/v1/admin/users/${id}/role`, { role }); },
|
||||
adminToggleActive(id, active) { return this._put(`/api/v1/admin/users/${id}/active`, { is_active: active }); },
|
||||
adminDeleteUser(id) { return this._del(`/api/v1/admin/users/${id}`); },
|
||||
adminGetSettings() { return this._get('/api/v1/admin/settings'); },
|
||||
adminUpdateSetting(key, value) { return this._put(`/api/v1/admin/settings/${key}`, value); },
|
||||
adminGetStats() { return this._get('/api/v1/admin/stats'); },
|
||||
|
||||
adminListGlobalConfigs() { return this._get('/api/v1/admin/configs'); },
|
||||
adminCreateGlobalConfig(name, provider, endpoint, apiKey, modelDefault) {
|
||||
return this._post('/api/v1/admin/configs', {
|
||||
name, provider, endpoint, api_key: apiKey, model_default: modelDefault
|
||||
});
|
||||
},
|
||||
adminDeleteGlobalConfig(id) { return this._del(`/api/v1/admin/configs/${id}`); },
|
||||
|
||||
adminListModels() { return this._get('/api/v1/admin/models'); },
|
||||
adminFetchModels() { return this._post('/api/v1/admin/models/fetch', {}); },
|
||||
adminUpdateModel(id, updates) { return this._put(`/api/v1/admin/models/${id}`, updates); },
|
||||
adminDeleteModel(id) { return this._del(`/api/v1/admin/models/${id}`); },
|
||||
|
||||
// ── HTTP Internals ───────────────────────
|
||||
|
||||
_authHeaders() {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.accessToken}`
|
||||
};
|
||||
},
|
||||
|
||||
async _get(path) { return this._authed(path); },
|
||||
async _post(path, body, skipAuth) { return skipAuth ? this._raw(path, 'POST', body) : this._authed(path, 'POST', body); },
|
||||
async _put(path, body) { return this._authed(path, 'PUT', body); },
|
||||
async _del(path) { return this._authed(path, 'DELETE'); },
|
||||
|
||||
async _authed(path, method = 'GET', body) {
|
||||
try {
|
||||
return await this._raw(path, method, body, true);
|
||||
} catch (e) {
|
||||
if (e.status === 401 && this.refreshToken) {
|
||||
if (await this.refresh()) {
|
||||
return this._raw(path, method, body, true);
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
} finally {
|
||||
State.isGenerating = false;
|
||||
State.abortController = null;
|
||||
document.getElementById('stopBtn').classList.remove('visible');
|
||||
document.getElementById('sendBtn').disabled = false;
|
||||
},
|
||||
|
||||
async _raw(path, method = 'GET', body, auth = false) {
|
||||
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
||||
if (auth) opts.headers['Authorization'] = `Bearer ${this.accessToken}`;
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
|
||||
const resp = await fetch(path, opts);
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
const err = new Error(data.error || `HTTP ${resp.status}`);
|
||||
err.status = resp.status;
|
||||
throw err;
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
}
|
||||
|
||||
function stopGeneration() {
|
||||
if (State.abortController) {
|
||||
State.abortController.abort();
|
||||
State.abortController = null;
|
||||
}
|
||||
State.isGenerating = false;
|
||||
document.getElementById('stopBtn').classList.remove('visible');
|
||||
document.getElementById('sendBtn').disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user