Upload 9 modified files from chat-switchboard-v0.5.0.zip

This commit is contained in:
2026-02-18 20:28:28 +00:00
parent d79a43145e
commit 490fe5c6a3
7 changed files with 1617 additions and 3040 deletions

View File

@@ -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;
}
};