Changeset 0.5.0 (#35)
This commit is contained in:
487
src/js/admin.js
487
src/js/admin.js
@@ -1,487 +0,0 @@
|
||||
// ==========================================
|
||||
// Admin Panel
|
||||
// ==========================================
|
||||
|
||||
let _adminListenersInit = false;
|
||||
|
||||
function initAdmin() {
|
||||
if (Backend.user && Backend.user.role === 'admin') {
|
||||
document.getElementById('adminBtn').style.display = '';
|
||||
} else {
|
||||
document.getElementById('adminBtn').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function openAdmin() {
|
||||
document.getElementById('adminModal').classList.add('active');
|
||||
initAdminListeners();
|
||||
switchAdminTab('users');
|
||||
loadAdminUsers();
|
||||
}
|
||||
|
||||
function closeAdmin() {
|
||||
document.getElementById('adminModal').classList.remove('active');
|
||||
}
|
||||
|
||||
function initAdminListeners() {
|
||||
if (_adminListenersInit) return;
|
||||
_adminListenersInit = true;
|
||||
|
||||
document.getElementById('adminShowAddUser').addEventListener('click', () => {
|
||||
document.getElementById('adminAddUserForm').style.display = '';
|
||||
document.getElementById('adminShowAddUser').style.display = 'none';
|
||||
});
|
||||
document.getElementById('adminCancelAddUser').addEventListener('click', () => {
|
||||
document.getElementById('adminAddUserForm').style.display = 'none';
|
||||
document.getElementById('adminShowAddUser').style.display = '';
|
||||
clearAddUserForm();
|
||||
});
|
||||
document.getElementById('adminCreateUserBtn').addEventListener('click', handleCreateUser);
|
||||
document.getElementById('adminResetCancelBtn').addEventListener('click', closeResetDialog);
|
||||
document.getElementById('adminResetConfirmBtn').addEventListener('click', handleResetPassword);
|
||||
|
||||
// Admin providers
|
||||
document.getElementById('adminProviderShowAddBtn').addEventListener('click', () => {
|
||||
document.getElementById('adminProviderAddForm').style.display = '';
|
||||
document.getElementById('adminProviderShowAddBtn').style.display = 'none';
|
||||
});
|
||||
document.getElementById('adminProviderCancelBtn').addEventListener('click', () => {
|
||||
document.getElementById('adminProviderAddForm').style.display = 'none';
|
||||
document.getElementById('adminProviderShowAddBtn').style.display = '';
|
||||
clearAdminProviderForm();
|
||||
});
|
||||
document.getElementById('adminProviderCreateBtn').addEventListener('click', handleCreateAdminProvider);
|
||||
|
||||
// Admin models
|
||||
document.getElementById('adminFetchModelsBtn').addEventListener('click', handleAdminFetchModels);
|
||||
|
||||
document.getElementById('adminResetNewPw').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') handleResetPassword();
|
||||
});
|
||||
}
|
||||
|
||||
function switchAdminTab(tab) {
|
||||
document.querySelectorAll('.admin-tab').forEach(t => {
|
||||
t.classList.toggle('active', t.dataset.tab === tab);
|
||||
});
|
||||
document.getElementById('adminUsersTab').style.display = tab === 'users' ? '' : 'none';
|
||||
document.getElementById('adminModelsTab').style.display = tab === 'models' ? '' : 'none';
|
||||
document.getElementById('adminProvidersTab').style.display = tab === 'providers' ? '' : 'none';
|
||||
document.getElementById('adminSettingsTab').style.display = tab === 'settings' ? '' : 'none';
|
||||
document.getElementById('adminStatsTab').style.display = tab === 'stats' ? '' : 'none';
|
||||
document.getElementById('adminResetDialog').style.display = 'none';
|
||||
|
||||
if (tab === 'models') loadAdminModels();
|
||||
if (tab === 'providers') loadAdminProviders();
|
||||
if (tab === 'settings') loadAdminSettings();
|
||||
if (tab === 'stats') loadAdminStats();
|
||||
}
|
||||
|
||||
// ── Add User ────────────────────────────────
|
||||
|
||||
function clearAddUserForm() {
|
||||
document.getElementById('adminNewUsername').value = '';
|
||||
document.getElementById('adminNewEmail').value = '';
|
||||
document.getElementById('adminNewPassword').value = '';
|
||||
document.getElementById('adminNewRole').value = 'user';
|
||||
}
|
||||
|
||||
async function handleCreateUser() {
|
||||
const username = document.getElementById('adminNewUsername').value.trim();
|
||||
const email = document.getElementById('adminNewEmail').value.trim();
|
||||
const password = document.getElementById('adminNewPassword').value;
|
||||
const role = document.getElementById('adminNewRole').value;
|
||||
|
||||
if (!username || !email || !password) {
|
||||
showToast('⚠️ Fill in all fields', 'warning');
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
showToast('⚠️ Password must be at least 8 characters', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Backend.adminCreateUser(username, email, password, role);
|
||||
showToast('✅ User "' + username + '" created', 'success');
|
||||
clearAddUserForm();
|
||||
document.getElementById('adminAddUserForm').style.display = 'none';
|
||||
document.getElementById('adminShowAddUser').style.display = '';
|
||||
loadAdminUsers();
|
||||
} catch (e) {
|
||||
showToast('❌ ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Users List ──────────────────────────────
|
||||
|
||||
async function loadAdminUsers() {
|
||||
const container = document.getElementById('adminUserList');
|
||||
container.innerHTML = '<div class="admin-loading">Loading users...</div>';
|
||||
|
||||
try {
|
||||
const data = await Backend.adminListUsers(1, 100);
|
||||
const users = data.data || [];
|
||||
|
||||
if (users.length === 0) {
|
||||
container.innerHTML = '<div class="admin-empty">No users found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = users.map(u => {
|
||||
const isSelf = u.id === Backend.user.id;
|
||||
const actions = isSelf ? '<span class="admin-you">(you)</span>' : `
|
||||
<select class="admin-role-select" onchange="changeUserRole('${u.id}', this.value)">
|
||||
<option value="user" ${u.role === 'user' ? 'selected' : ''}>user</option>
|
||||
<option value="admin" ${u.role === 'admin' ? 'selected' : ''}>admin</option>
|
||||
<option value="moderator" ${u.role === 'moderator' ? 'selected' : ''}>moderator</option>
|
||||
</select>
|
||||
<button class="btn btn-small btn-secondary" onclick="openResetDialog('${u.id}', '${escapeHtml(u.username)}')" title="Reset password">🔑</button>
|
||||
<button class="btn btn-small ${u.is_active ? 'btn-warning' : 'btn-success'}" onclick="toggleUserActive('${u.id}', ${!u.is_active})">${u.is_active ? 'Disable' : 'Enable'}</button>
|
||||
<button class="btn btn-small btn-danger" onclick="deleteUser('${u.id}', '${escapeHtml(u.username)}')">Delete</button>
|
||||
`;
|
||||
|
||||
return `
|
||||
<div class="admin-user-row" data-id="${u.id}">
|
||||
<div class="admin-user-info">
|
||||
<span class="admin-user-name">${escapeHtml(u.username)}</span>
|
||||
<span class="admin-user-email">${escapeHtml(u.email)}</span>
|
||||
</div>
|
||||
<div class="admin-user-meta">
|
||||
<span class="admin-badge admin-badge-${u.role}">${u.role}</span>
|
||||
<span class="admin-badge ${u.is_active ? 'admin-badge-active' : 'admin-badge-inactive'}">${u.is_active ? 'active' : 'disabled'}</span>
|
||||
</div>
|
||||
<div class="admin-user-actions">${actions}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div class="admin-error">Failed to load users: ' + e.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function changeUserRole(userId, role) {
|
||||
try {
|
||||
await Backend.adminUpdateUserRole(userId, role);
|
||||
showToast('✅ Role updated to ' + role, 'success');
|
||||
} catch (e) {
|
||||
showToast('❌ ' + e.message, 'error');
|
||||
loadAdminUsers();
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleUserActive(userId, isActive) {
|
||||
try {
|
||||
await Backend.adminToggleUserActive(userId, isActive);
|
||||
showToast('✅ User ' + (isActive ? 'enabled' : 'disabled'), 'success');
|
||||
loadAdminUsers();
|
||||
} catch (e) {
|
||||
showToast('❌ ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(userId, username) {
|
||||
if (!confirm('Delete user "' + username + '"? This removes all their chats and data.')) return;
|
||||
try {
|
||||
await Backend.adminDeleteUser(userId);
|
||||
showToast('✅ User deleted', 'success');
|
||||
loadAdminUsers();
|
||||
} catch (e) {
|
||||
showToast('❌ ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reset Password ──────────────────────────
|
||||
|
||||
let _resetUserId = null;
|
||||
|
||||
function openResetDialog(userId, username) {
|
||||
_resetUserId = userId;
|
||||
document.getElementById('adminResetTarget').textContent = 'Set new password for: ' + username;
|
||||
document.getElementById('adminResetNewPw').value = '';
|
||||
document.getElementById('adminResetDialog').style.display = '';
|
||||
document.getElementById('adminResetNewPw').focus();
|
||||
}
|
||||
|
||||
function closeResetDialog() {
|
||||
_resetUserId = null;
|
||||
document.getElementById('adminResetDialog').style.display = 'none';
|
||||
}
|
||||
|
||||
async function handleResetPassword() {
|
||||
if (!_resetUserId) return;
|
||||
const pw = document.getElementById('adminResetNewPw').value;
|
||||
if (!pw || pw.length < 8) {
|
||||
showToast('⚠️ Password must be at least 8 characters', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Backend.adminResetPassword(_resetUserId, pw);
|
||||
showToast('✅ Password reset', 'success');
|
||||
closeResetDialog();
|
||||
} catch (e) {
|
||||
showToast('❌ ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Settings ────────────────────────────────
|
||||
|
||||
async function loadAdminSettings() {
|
||||
try {
|
||||
const data = await Backend.adminListSettings();
|
||||
const settings = data.settings || [];
|
||||
|
||||
for (const s of settings) {
|
||||
if (s.key === 'registration') {
|
||||
document.getElementById('adminRegToggle').checked = s.value.enabled !== false;
|
||||
}
|
||||
if (s.key === 'site') {
|
||||
document.getElementById('adminSiteName').value = s.value.name || 'Chat Switchboard';
|
||||
document.getElementById('adminTagline').value = s.value.tagline || 'Multi-Model AI Chat';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('❌ Failed to load settings: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAdminSettings() {
|
||||
try {
|
||||
await Backend.adminUpdateSetting('registration', {
|
||||
enabled: document.getElementById('adminRegToggle').checked
|
||||
});
|
||||
await Backend.adminUpdateSetting('site', {
|
||||
name: document.getElementById('adminSiteName').value.trim(),
|
||||
tagline: document.getElementById('adminTagline').value.trim()
|
||||
});
|
||||
showToast('✅ Admin settings saved', 'success');
|
||||
} catch (e) {
|
||||
showToast('❌ ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stats ───────────────────────────────────
|
||||
|
||||
async function loadAdminStats() {
|
||||
const container = document.getElementById('adminStats');
|
||||
container.innerHTML = '<div class="admin-loading">Loading stats...</div>';
|
||||
|
||||
try {
|
||||
const stats = await Backend.adminGetStats();
|
||||
container.innerHTML = '<div class="admin-stats-grid">' +
|
||||
statCard(stats.total_users, 'Total Users') +
|
||||
statCard(stats.active_users, 'Active Users') +
|
||||
statCard(stats.total_chats, 'Chats') +
|
||||
statCard(stats.total_messages, 'Messages') +
|
||||
statCard(stats.api_configs, 'API Configs') +
|
||||
'</div>';
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div class="admin-error">Failed to load stats: ' + e.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function statCard(value, label) {
|
||||
return '<div class="admin-stat-card"><div class="admin-stat-value">' +
|
||||
(value || 0) + '</div><div class="admin-stat-label">' + label + '</div></div>';
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// ── Admin Models ────────────────────────────
|
||||
|
||||
const CAPS = ['tool', 'thinking', 'vision', 'code'];
|
||||
|
||||
async function loadAdminModels() {
|
||||
const container = document.getElementById('adminModelList');
|
||||
container.innerHTML = '<div class="admin-loading">Loading models...</div>';
|
||||
|
||||
try {
|
||||
const data = await Backend.adminListModels();
|
||||
const models = data.models || [];
|
||||
|
||||
if (models.length === 0) {
|
||||
container.innerHTML = '<div class="admin-empty">No models configured. Add a provider first, then click "Fetch Models".</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by provider
|
||||
const grouped = {};
|
||||
for (const m of models) {
|
||||
const key = m.provider_name || 'Unknown';
|
||||
if (!grouped[key]) grouped[key] = [];
|
||||
grouped[key].push(m);
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (const [provider, provModels] of Object.entries(grouped)) {
|
||||
html += '<div class="admin-model-group">';
|
||||
html += '<div class="admin-model-group-header">' + escapeHtml(provider) + '</div>';
|
||||
for (const m of provModels) {
|
||||
const caps = m.capabilities || {};
|
||||
const name = m.display_name || m.model_id;
|
||||
html += '<div class="admin-model-row">';
|
||||
html += ' <div class="admin-model-toggle">';
|
||||
html += ' <label class="toggle-label-compact">';
|
||||
html += ' <input type="checkbox" ' + (m.is_enabled ? 'checked' : '') +
|
||||
' onchange="toggleModelEnabled(\'' + m.id + '\', this.checked)">';
|
||||
html += ' </label>';
|
||||
html += ' </div>';
|
||||
html += ' <div class="admin-model-info">';
|
||||
html += ' <span class="admin-model-name">' + escapeHtml(name) + '</span>';
|
||||
html += ' <span class="admin-model-id">' + escapeHtml(m.model_id) + '</span>';
|
||||
html += ' </div>';
|
||||
html += ' <div class="admin-model-caps">';
|
||||
for (const cap of CAPS) {
|
||||
const active = caps[cap] === true;
|
||||
html += ' <button class="cap-badge ' + (active ? 'cap-active' : 'cap-inactive') + '"' +
|
||||
' onclick="toggleModelCap(\'' + m.id + '\', \'' + cap + '\', ' + !active + ', \'' + escapeHtml(JSON.stringify(caps)) + '\')"' +
|
||||
' title="' + cap + '">' + cap + '</button>';
|
||||
}
|
||||
html += ' </div>';
|
||||
html += ' <div class="admin-model-actions">';
|
||||
html += ' <button class="btn btn-small btn-danger" onclick="deleteAdminModel(\'' + m.id + '\', \'' + escapeHtml(m.model_id) + '\')">✕</button>';
|
||||
html += ' </div>';
|
||||
html += '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div class="admin-error">Failed to load models: ' + e.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdminFetchModels() {
|
||||
const btn = document.getElementById('adminFetchModelsBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Fetching...';
|
||||
|
||||
try {
|
||||
const data = await Backend.adminFetchModels();
|
||||
const added = data.total_added || 0;
|
||||
const results = data.results || [];
|
||||
const errors = results.filter(r => r.error);
|
||||
|
||||
if (errors.length > 0) {
|
||||
showToast('⚠️ Fetched with ' + errors.length + ' error(s). ' + added + ' new models.', 'warning');
|
||||
} else {
|
||||
showToast('✅ Fetched ' + added + ' new models', 'success');
|
||||
}
|
||||
loadAdminModels();
|
||||
} catch (e) {
|
||||
showToast('❌ ' + e.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🔄 Fetch Models';
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleModelEnabled(modelId, enabled) {
|
||||
try {
|
||||
await Backend.adminUpdateModel(modelId, { is_enabled: enabled });
|
||||
} catch (e) {
|
||||
showToast('❌ ' + e.message, 'error');
|
||||
loadAdminModels();
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleModelCap(modelId, cap, value, capsJson) {
|
||||
try {
|
||||
const caps = JSON.parse(capsJson);
|
||||
caps[cap] = value;
|
||||
await Backend.adminUpdateModel(modelId, { capabilities: caps });
|
||||
loadAdminModels();
|
||||
} catch (e) {
|
||||
showToast('❌ ' + e.message, 'error');
|
||||
loadAdminModels();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAdminModel(modelId, modelName) {
|
||||
if (!confirm('Remove model "' + modelName + '"? It can be re-fetched later.')) return;
|
||||
try {
|
||||
await Backend.adminDeleteModel(modelId);
|
||||
showToast('✅ Model removed', 'success');
|
||||
loadAdminModels();
|
||||
} catch (e) {
|
||||
showToast('❌ ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAdminProviders() {
|
||||
const container = document.getElementById('adminProviderList');
|
||||
container.innerHTML = '<div class="admin-loading">Loading...</div>';
|
||||
|
||||
try {
|
||||
const data = await Backend.adminListGlobalConfigs();
|
||||
const configs = data.configs || [];
|
||||
|
||||
if (configs.length === 0) {
|
||||
container.innerHTML = '<div class="provider-empty">No global providers. Users must add their own.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = configs.map(c => `
|
||||
<div class="provider-row">
|
||||
<div class="provider-info">
|
||||
<span class="provider-name">${escapeHtml(c.name)}</span>
|
||||
<span class="provider-meta">${c.provider} · ${c.model_default || 'no default'}</span>
|
||||
</div>
|
||||
<div class="provider-actions">
|
||||
<span class="admin-badge admin-badge-active">${c.has_key ? '🔑' : '⚠️'}</span>
|
||||
<button class="btn btn-small btn-danger" onclick="deleteAdminProvider('${c.id}', '${escapeHtml(c.name)}')">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div class="admin-error">Failed to load providers: ' + e.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function clearAdminProviderForm() {
|
||||
document.getElementById('adminProviderName').value = '';
|
||||
document.getElementById('adminProviderType').value = 'openai';
|
||||
document.getElementById('adminProviderEndpoint').value = '';
|
||||
document.getElementById('adminProviderApiKey').value = '';
|
||||
document.getElementById('adminProviderDefaultModel').value = '';
|
||||
}
|
||||
|
||||
async function handleCreateAdminProvider() {
|
||||
const name = document.getElementById('adminProviderName').value.trim();
|
||||
const provider = document.getElementById('adminProviderType').value;
|
||||
const endpoint = document.getElementById('adminProviderEndpoint').value.trim();
|
||||
const apiKey = document.getElementById('adminProviderApiKey').value.trim();
|
||||
const modelDefault = document.getElementById('adminProviderDefaultModel').value.trim();
|
||||
|
||||
if (!name || !endpoint || !apiKey) {
|
||||
showToast('⚠️ Name, endpoint, and API key are required', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Backend.adminCreateGlobalConfig(name, provider, endpoint, apiKey, modelDefault || null);
|
||||
showToast('✅ Global provider added', 'success');
|
||||
clearAdminProviderForm();
|
||||
document.getElementById('adminProviderAddForm').style.display = 'none';
|
||||
document.getElementById('adminProviderShowAddBtn').style.display = '';
|
||||
loadAdminProviders();
|
||||
} catch (e) {
|
||||
showToast('❌ ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAdminProvider(configId, name) {
|
||||
if (!confirm('Remove global provider "' + name + '"?')) return;
|
||||
try {
|
||||
await Backend.adminDeleteGlobalConfig(configId);
|
||||
showToast('✅ Global provider removed', 'success');
|
||||
loadAdminProviders();
|
||||
} catch (e) {
|
||||
showToast('❌ ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,8 @@ const API = {
|
||||
async streamCompletion(chatId, content, model, signal) {
|
||||
const body = { chat_id: chatId, content, stream: true };
|
||||
if (model) body.model = model;
|
||||
// Only send max_tokens if user explicitly set it (non-zero = override)
|
||||
if (App.settings.maxTokens > 0) body.max_tokens = App.settings.maxTokens;
|
||||
|
||||
let resp = await fetch('/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
|
||||
138
src/js/app.js
138
src/js/app.js
@@ -1,5 +1,5 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – Application (v0.5.2)
|
||||
// Chat Switchboard – Application (v0.5.4)
|
||||
// ==========================================
|
||||
|
||||
const App = {
|
||||
@@ -14,7 +14,7 @@ const App = {
|
||||
stream: true,
|
||||
showThinking: true,
|
||||
systemPrompt: '',
|
||||
maxTokens: 4096,
|
||||
maxTokens: 0, // 0 = auto from model capabilities
|
||||
temperature: 0.7,
|
||||
},
|
||||
};
|
||||
@@ -64,6 +64,14 @@ async function startApp() {
|
||||
UI.updateUser();
|
||||
UI.showAdminButton(API.isAdmin);
|
||||
initListeners();
|
||||
|
||||
// Connect EventBus WebSocket (non-blocking, graceful if /ws not available yet)
|
||||
try {
|
||||
Events.connect('/ws');
|
||||
} catch (e) {
|
||||
console.warn('EventBus WebSocket not available:', e.message);
|
||||
}
|
||||
|
||||
console.log('✅ Chat Switchboard ready');
|
||||
}
|
||||
|
||||
@@ -94,28 +102,104 @@ async function saveSettings() {
|
||||
|
||||
// ── Models ───────────────────────────────────
|
||||
|
||||
// Client-side known model capabilities — used when backend hasn't synced yet.
|
||||
// Mirrors server/providers/capabilities.go knownModels table.
|
||||
const KNOWN_MODELS = {
|
||||
'claude-opus-4': { streaming:true, tool_calling:true, vision:true, thinking:true, max_context:200000, max_output_tokens:32000 },
|
||||
'claude-sonnet-4': { streaming:true, tool_calling:true, vision:true, thinking:true, max_context:200000, max_output_tokens:16000 },
|
||||
'claude-3-5-sonnet': { streaming:true, tool_calling:true, vision:true, max_context:200000, max_output_tokens:8192 },
|
||||
'claude-3-5-haiku': { streaming:true, tool_calling:true, vision:true, max_context:200000, max_output_tokens:8192 },
|
||||
'claude-3-opus': { streaming:true, tool_calling:true, vision:true, max_context:200000, max_output_tokens:4096 },
|
||||
'claude-3-haiku': { streaming:true, tool_calling:true, vision:true, max_context:200000, max_output_tokens:4096 },
|
||||
'gpt-4o': { streaming:true, tool_calling:true, vision:true, max_context:128000, max_output_tokens:16384 },
|
||||
'gpt-4o-mini': { streaming:true, tool_calling:true, vision:true, max_context:128000, max_output_tokens:16384 },
|
||||
'gpt-4-turbo': { streaming:true, tool_calling:true, vision:true, max_context:128000, max_output_tokens:4096 },
|
||||
'o1': { streaming:true, reasoning:true, max_context:200000, max_output_tokens:100000 },
|
||||
'o3': { streaming:true, tool_calling:true, reasoning:true, max_context:200000, max_output_tokens:100000 },
|
||||
'o3-mini': { streaming:true, tool_calling:true, reasoning:true, max_context:200000, max_output_tokens:65536 },
|
||||
'o4-mini': { streaming:true, tool_calling:true, reasoning:true, max_context:200000, max_output_tokens:100000 },
|
||||
'gemini-2.5-pro': { streaming:true, tool_calling:true, vision:true, thinking:true, max_context:1048576, max_output_tokens:65536 },
|
||||
'gemini-2.5-flash': { streaming:true, tool_calling:true, vision:true, thinking:true, max_context:1048576, max_output_tokens:65536 },
|
||||
'gemini-2.0-flash': { streaming:true, tool_calling:true, vision:true, max_context:1048576, max_output_tokens:8192 },
|
||||
'deepseek-r1': { streaming:true, reasoning:true, max_context:65536, max_output_tokens:8192 },
|
||||
'deepseek-v3': { streaming:true, tool_calling:true, max_context:65536, max_output_tokens:8192 },
|
||||
'deepseek-chat': { streaming:true, tool_calling:true, max_context:65536, max_output_tokens:8192 },
|
||||
'llama-3.1-405b': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:4096 },
|
||||
'llama-3.1-70b': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:4096 },
|
||||
'llama-3.3-70b': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:4096 },
|
||||
'llama-4-maverick': { streaming:true, tool_calling:true, vision:true, max_context:1048576, max_output_tokens:16384 },
|
||||
'llama-4-scout': { streaming:true, tool_calling:true, vision:true, max_context:524288, max_output_tokens:16384 },
|
||||
'mistral-large': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:8192 },
|
||||
'codestral': { streaming:true, tool_calling:true, code_optimized:true, max_context:262144, max_output_tokens:8192 },
|
||||
'qwen-2.5-72b': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:8192 },
|
||||
'qwq-32b': { streaming:true, reasoning:true, max_context:131072, max_output_tokens:8192 },
|
||||
};
|
||||
|
||||
// Look up client-side capabilities by model ID with prefix matching
|
||||
function lookupKnownCaps(modelId) {
|
||||
const id = modelId.toLowerCase().replace(/^[^/]+\//, ''); // strip provider prefix
|
||||
// Exact match
|
||||
if (KNOWN_MODELS[id]) return { ...KNOWN_MODELS[id] };
|
||||
// Prefix match (longest wins)
|
||||
let best = null, bestLen = 0;
|
||||
for (const key of Object.keys(KNOWN_MODELS)) {
|
||||
if (id.startsWith(key) && key.length > bestLen) {
|
||||
best = key; bestLen = key.length;
|
||||
}
|
||||
}
|
||||
return best ? { ...KNOWN_MODELS[best] } : null;
|
||||
}
|
||||
|
||||
// Merge: backend/provider caps are authoritative, client-side fills gaps only
|
||||
function resolveCapabilities(backendCaps, modelId) {
|
||||
const known = lookupKnownCaps(modelId) || {};
|
||||
if (!backendCaps || Object.keys(backendCaps).length === 0) return known;
|
||||
|
||||
// Backend is authoritative — start with it
|
||||
const caps = { ...backendCaps };
|
||||
|
||||
// Fill gaps (fields backend didn't report) from known table
|
||||
for (const [k, v] of Object.entries(known)) {
|
||||
if (caps[k] === undefined || caps[k] === null) {
|
||||
caps[k] = v;
|
||||
}
|
||||
}
|
||||
return caps;
|
||||
}
|
||||
|
||||
async function fetchModels() {
|
||||
try {
|
||||
const data = await API.listEnabledModels();
|
||||
App.models = (data.models || []).map(m => ({
|
||||
id: m.model_id || m.id,
|
||||
provider: m.provider_name || m.provider || '',
|
||||
configId: m.config_id || null
|
||||
}));
|
||||
App.models = (data.models || []).map(m => {
|
||||
const id = m.model_id || m.id;
|
||||
return {
|
||||
id,
|
||||
name: m.display_name || id,
|
||||
provider: m.provider_name || m.provider || '',
|
||||
configId: m.config_id || null,
|
||||
capabilities: resolveCapabilities(m.capabilities, id),
|
||||
};
|
||||
});
|
||||
|
||||
if (App.models.length === 0) {
|
||||
const raw = await API.listAllModels();
|
||||
App.models = (raw.models || []).map(m => ({
|
||||
id: m.id || m.name,
|
||||
provider: m.owned_by || m.provider || '',
|
||||
configId: m.config_id || null
|
||||
}));
|
||||
App.models = (raw.models || []).map(m => {
|
||||
const id = m.id || m.name;
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
provider: m.owned_by || m.provider || '',
|
||||
configId: m.config_id || null,
|
||||
capabilities: resolveCapabilities(m.capabilities, id),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
App.models.sort((a, b) => a.id.localeCompare(b.id));
|
||||
console.log(`📋 Loaded ${App.models.length} models`);
|
||||
} catch (e) { console.warn('Model fetch failed:', e.message); }
|
||||
UI.updateModelSelector();
|
||||
UI.updateCapabilityBadges();
|
||||
}
|
||||
|
||||
// ── Chat Management ──────────────────────────
|
||||
@@ -216,6 +300,7 @@ async function sendMessage() {
|
||||
const assistantContent = await UI.streamResponse(resp, chat.messages);
|
||||
chat.messages.push({ role: 'assistant', content: assistantContent, model, timestamp: new Date().toISOString() });
|
||||
chat.messageCount = chat.messages.length;
|
||||
UI.renderMessages(chat.messages);
|
||||
UI.showRegenerate(true);
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') {
|
||||
@@ -263,6 +348,7 @@ async function regenerate() {
|
||||
const resp = await API.streamCompletion(App.currentChatId, lastUser.content, model, App.abortController.signal);
|
||||
const content = await UI.streamResponse(resp, chat.messages);
|
||||
chat.messages.push({ role: 'assistant', content, model, timestamp: new Date().toISOString() });
|
||||
UI.renderMessages(chat.messages);
|
||||
UI.showRegenerate(true);
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
@@ -317,6 +403,8 @@ async function handleRegister() {
|
||||
|
||||
async function handleLogout() {
|
||||
if (!confirm('Sign out?')) return;
|
||||
Events.disconnect();
|
||||
Events.clear();
|
||||
await API.logout();
|
||||
App.chats = [];
|
||||
App.currentChatId = null;
|
||||
@@ -375,6 +463,7 @@ function initListeners() {
|
||||
// Model selector
|
||||
document.getElementById('modelSelect').addEventListener('change', function() {
|
||||
if (this.value) { App.settings.model = this.value; saveSettings(); }
|
||||
UI.updateCapabilityBadges();
|
||||
});
|
||||
document.getElementById('fetchModelsBtn').addEventListener('click', async () => {
|
||||
await fetchModels();
|
||||
@@ -384,6 +473,16 @@ function initListeners() {
|
||||
// Settings modal
|
||||
document.getElementById('settingsCloseBtn').addEventListener('click', UI.closeSettings);
|
||||
document.getElementById('settingsSaveBtn').addEventListener('click', handleSaveSettings);
|
||||
document.getElementById('settingsModel').addEventListener('change', function() {
|
||||
const model = App.models.find(m => m.id === this.value);
|
||||
const caps = model?.capabilities || lookupKnownCaps(this.value) || {};
|
||||
const hint = document.getElementById('settingsMaxHint');
|
||||
if (hint) {
|
||||
hint.textContent = caps.max_output_tokens > 0
|
||||
? `(model max: ${(caps.max_output_tokens / 1000).toFixed(0)}K)`
|
||||
: '';
|
||||
}
|
||||
});
|
||||
document.getElementById('profileChangePwBtn').addEventListener('click', () => {
|
||||
document.getElementById('profileChangePwForm').style.display = '';
|
||||
});
|
||||
@@ -391,6 +490,18 @@ function initListeners() {
|
||||
document.getElementById('providerShowAddBtn').addEventListener('click', UI.showProviderForm);
|
||||
document.getElementById('providerCancelBtn').addEventListener('click', UI.hideProviderForm);
|
||||
document.getElementById('providerCreateBtn').addEventListener('click', handleCreateProvider);
|
||||
document.getElementById('providerType').addEventListener('change', function() {
|
||||
const endpoints = {
|
||||
openai: 'https://api.openai.com/v1',
|
||||
anthropic: 'https://api.anthropic.com',
|
||||
venice: 'https://api.venice.ai/api/v1',
|
||||
openrouter: 'https://openrouter.ai/api/v1',
|
||||
};
|
||||
const ep = document.getElementById('providerEndpoint');
|
||||
if (!ep.value || Object.values(endpoints).includes(ep.value)) {
|
||||
ep.value = endpoints[this.value] || '';
|
||||
}
|
||||
});
|
||||
|
||||
// Admin modal
|
||||
document.getElementById('adminCloseBtn').addEventListener('click', UI.closeAdmin);
|
||||
@@ -427,11 +538,12 @@ function initListeners() {
|
||||
function handleSaveSettings() {
|
||||
App.settings.model = document.getElementById('settingsModel').value || App.settings.model;
|
||||
App.settings.systemPrompt = document.getElementById('settingsSystemPrompt').value.trim();
|
||||
App.settings.maxTokens = parseInt(document.getElementById('settingsMaxTokens').value) || 4096;
|
||||
App.settings.maxTokens = parseInt(document.getElementById('settingsMaxTokens').value) || 0; // 0 = auto
|
||||
App.settings.temperature = parseFloat(document.getElementById('settingsTemp').value) || 0.7;
|
||||
App.settings.showThinking = document.getElementById('settingsThinking').checked;
|
||||
saveSettings();
|
||||
UI.updateModelSelector();
|
||||
UI.updateCapabilityBadges();
|
||||
UI.closeSettings();
|
||||
UI.toast('Settings saved', 'success');
|
||||
}
|
||||
|
||||
@@ -1,456 +0,0 @@
|
||||
// ==========================================
|
||||
// Switchboard Backend API Client
|
||||
// ==========================================
|
||||
// Handles auth + chat/message CRUD against the
|
||||
// Switchboard backend. Token refresh is automatic.
|
||||
// ==========================================
|
||||
|
||||
const Backend = {
|
||||
baseUrl: '',
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
user: null,
|
||||
_refreshPromise: null,
|
||||
|
||||
// ── Connection ──────────────────────────
|
||||
|
||||
get isConnected() {
|
||||
return !!(this.baseUrl && this.accessToken);
|
||||
},
|
||||
|
||||
get isManaged() {
|
||||
return this.isConnected;
|
||||
},
|
||||
|
||||
// ── Init ────────────────────────────────
|
||||
|
||||
init() {
|
||||
// Auto-detect: if we're served from a domain with /api/v1,
|
||||
// the backend is co-located. Otherwise check saved URL.
|
||||
const saved = Storage.get('switchboard_backend');
|
||||
if (saved) {
|
||||
this.baseUrl = saved.baseUrl || '';
|
||||
this.accessToken = saved.accessToken || null;
|
||||
this.refreshToken = saved.refreshToken || null;
|
||||
this.user = saved.user || null;
|
||||
}
|
||||
},
|
||||
|
||||
save() {
|
||||
Storage.set('switchboard_backend', {
|
||||
baseUrl: this.baseUrl,
|
||||
accessToken: this.accessToken,
|
||||
refreshToken: this.refreshToken,
|
||||
user: this.user
|
||||
});
|
||||
},
|
||||
|
||||
clear() {
|
||||
this.accessToken = null;
|
||||
this.refreshToken = null;
|
||||
this.user = null;
|
||||
this.save();
|
||||
},
|
||||
|
||||
// ── Auth ────────────────────────────────
|
||||
|
||||
async register(username, email, password) {
|
||||
const data = await this._fetch('/api/v1/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, email, password })
|
||||
}, true);
|
||||
this._setAuth(data);
|
||||
return data;
|
||||
},
|
||||
|
||||
async login(login, password) {
|
||||
const data = await this._fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ login, password })
|
||||
}, true);
|
||||
this._setAuth(data);
|
||||
return data;
|
||||
},
|
||||
|
||||
async logout() {
|
||||
try {
|
||||
await this._fetch('/api/v1/auth/logout', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ refresh_token: this.refreshToken })
|
||||
}, true);
|
||||
} catch (e) {
|
||||
// Best-effort
|
||||
}
|
||||
this.clear();
|
||||
},
|
||||
|
||||
async refresh() {
|
||||
// Deduplicate concurrent refresh calls
|
||||
if (this._refreshPromise) return this._refreshPromise;
|
||||
|
||||
this._refreshPromise = (async () => {
|
||||
try {
|
||||
const data = await this._fetch('/api/v1/auth/refresh', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ refresh_token: this.refreshToken })
|
||||
}, true);
|
||||
this._setAuth(data);
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.clear();
|
||||
return false;
|
||||
} finally {
|
||||
this._refreshPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return this._refreshPromise;
|
||||
},
|
||||
|
||||
_setAuth(data) {
|
||||
this.accessToken = data.access_token;
|
||||
this.refreshToken = data.refresh_token;
|
||||
this.user = data.user;
|
||||
this.save();
|
||||
},
|
||||
|
||||
// ── Health ───────────────────────────────
|
||||
|
||||
async checkHealth(url) {
|
||||
try {
|
||||
const resp = await fetch(url + '/api/v1/health', {
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
return await resp.json();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
// ── Chats ───────────────────────────────
|
||||
|
||||
async listChats(page = 1, perPage = 50) {
|
||||
return this._authedFetch(`/api/v1/chats?page=${page}&per_page=${perPage}`);
|
||||
},
|
||||
|
||||
async createChat(title, model, systemPrompt) {
|
||||
const body = { title };
|
||||
if (model) body.model = model;
|
||||
if (systemPrompt) body.system_prompt = systemPrompt;
|
||||
return this._authedFetch('/api/v1/chats', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
},
|
||||
|
||||
async getChat(chatId) {
|
||||
return this._authedFetch(`/api/v1/chats/${chatId}`);
|
||||
},
|
||||
|
||||
async updateChat(chatId, updates) {
|
||||
return this._authedFetch(`/api/v1/chats/${chatId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updates)
|
||||
});
|
||||
},
|
||||
|
||||
async deleteChat(chatId) {
|
||||
return this._authedFetch(`/api/v1/chats/${chatId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
},
|
||||
|
||||
// ── Messages ────────────────────────────
|
||||
|
||||
async listMessages(chatId, page = 1, perPage = 100) {
|
||||
return this._authedFetch(
|
||||
`/api/v1/chats/${chatId}/messages?page=${page}&per_page=${perPage}`
|
||||
);
|
||||
},
|
||||
|
||||
async createMessage(chatId, role, content, model) {
|
||||
const body = { role, content };
|
||||
if (model) body.model = model;
|
||||
return this._authedFetch(`/api/v1/chats/${chatId}/messages`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
},
|
||||
|
||||
// ── 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'
|
||||
});
|
||||
},
|
||||
|
||||
// ── Profile & Settings ──────────────────
|
||||
|
||||
async getProfile() {
|
||||
return this._authedFetch('/api/v1/profile');
|
||||
},
|
||||
|
||||
async updateProfile(updates) {
|
||||
return this._authedFetch('/api/v1/profile', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updates)
|
||||
});
|
||||
},
|
||||
|
||||
async changePassword(currentPassword, newPassword) {
|
||||
return this._authedFetch('/api/v1/profile/password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
async getSettings() {
|
||||
return this._authedFetch('/api/v1/settings');
|
||||
},
|
||||
|
||||
async updateSettings(settings) {
|
||||
return this._authedFetch('/api/v1/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
},
|
||||
|
||||
// ── Admin ───────────────────────────────
|
||||
|
||||
async adminListUsers(page = 1, perPage = 50) {
|
||||
return this._authedFetch(`/api/v1/admin/users?page=${page}&per_page=${perPage}`);
|
||||
},
|
||||
|
||||
async adminCreateUser(username, email, password, role) {
|
||||
return this._authedFetch('/api/v1/admin/users', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, email, password, role })
|
||||
});
|
||||
},
|
||||
|
||||
async adminResetPassword(userId, newPassword) {
|
||||
return this._authedFetch(`/api/v1/admin/users/${userId}/reset-password`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ new_password: newPassword })
|
||||
});
|
||||
},
|
||||
|
||||
async adminUpdateUserRole(userId, role) {
|
||||
return this._authedFetch(`/api/v1/admin/users/${userId}/role`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ role })
|
||||
});
|
||||
},
|
||||
|
||||
async adminToggleUserActive(userId, isActive) {
|
||||
return this._authedFetch(`/api/v1/admin/users/${userId}/active`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ is_active: isActive })
|
||||
});
|
||||
},
|
||||
|
||||
async adminDeleteUser(userId) {
|
||||
return this._authedFetch(`/api/v1/admin/users/${userId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
},
|
||||
|
||||
async adminListSettings() {
|
||||
return this._authedFetch('/api/v1/admin/settings');
|
||||
},
|
||||
|
||||
async adminGetSetting(key) {
|
||||
return this._authedFetch(`/api/v1/admin/settings/${key}`);
|
||||
},
|
||||
|
||||
async adminUpdateSetting(key, value) {
|
||||
return this._authedFetch(`/api/v1/admin/settings/${key}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(value)
|
||||
});
|
||||
},
|
||||
|
||||
async adminGetStats() {
|
||||
return this._authedFetch('/api/v1/admin/stats');
|
||||
},
|
||||
|
||||
// Admin Global Configs
|
||||
async adminListGlobalConfigs() {
|
||||
return this._authedFetch('/api/v1/admin/configs');
|
||||
},
|
||||
|
||||
async adminCreateGlobalConfig(name, provider, endpoint, apiKey, modelDefault) {
|
||||
return this._authedFetch('/api/v1/admin/configs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name, provider, endpoint,
|
||||
api_key: apiKey,
|
||||
model_default: modelDefault
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
async adminDeleteGlobalConfig(configId) {
|
||||
return this._authedFetch(`/api/v1/admin/configs/${configId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
},
|
||||
|
||||
// Admin Model Configs
|
||||
async adminListModels() {
|
||||
return this._authedFetch('/api/v1/admin/models');
|
||||
},
|
||||
|
||||
async adminFetchModels() {
|
||||
return this._authedFetch('/api/v1/admin/models/fetch', {
|
||||
method: 'POST'
|
||||
});
|
||||
},
|
||||
|
||||
async adminUpdateModel(modelId, updates) {
|
||||
return this._authedFetch(`/api/v1/admin/models/${modelId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updates)
|
||||
});
|
||||
},
|
||||
|
||||
async adminDeleteModel(modelId) {
|
||||
return this._authedFetch(`/api/v1/admin/models/${modelId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
},
|
||||
|
||||
// Enabled models (for quick selector in managed mode)
|
||||
async listEnabledModels() {
|
||||
return this._authedFetch('/api/v1/models/enabled');
|
||||
},
|
||||
|
||||
// ── 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 = {}) {
|
||||
try {
|
||||
return await this._fetch(path, {
|
||||
...opts,
|
||||
headers: {
|
||||
...opts.headers,
|
||||
'Authorization': `Bearer ${this.accessToken}`
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// If 401, try refresh once
|
||||
if (e.status === 401 && this.refreshToken) {
|
||||
const refreshed = await this.refresh();
|
||||
if (refreshed) {
|
||||
return this._fetch(path, {
|
||||
...opts,
|
||||
headers: {
|
||||
...opts.headers,
|
||||
'Authorization': `Bearer ${this.accessToken}`
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
async _fetch(path, opts = {}, skipAuth = false) {
|
||||
const url = this.baseUrl + path;
|
||||
const resp = await fetch(url, {
|
||||
...opts,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...opts.headers
|
||||
}
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
const err = new Error(body.error || `HTTP ${resp.status}`);
|
||||
err.status = resp.status;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return resp.json();
|
||||
}
|
||||
};
|
||||
@@ -232,12 +232,21 @@ const DebugLog = {
|
||||
// localStorage keys
|
||||
try {
|
||||
snap.storageKeys = Object.keys(localStorage).filter(k =>
|
||||
k.startsWith('chatSwitchboard') || k.startsWith('switchboard')
|
||||
k.startsWith('chatSwitchboard') || k.startsWith('switchboard') || k.startsWith('sb_')
|
||||
);
|
||||
} catch (e) {
|
||||
snap.storageKeys = '(error reading)';
|
||||
}
|
||||
|
||||
// EventBus state
|
||||
if (typeof Events !== 'undefined') {
|
||||
snap.eventBus = {
|
||||
wsConnected: Events.connected,
|
||||
subscriptions: Events.debug(),
|
||||
queueLength: Events._wsQueue?.length || 0
|
||||
};
|
||||
}
|
||||
|
||||
return snap;
|
||||
},
|
||||
|
||||
@@ -246,8 +255,8 @@ const DebugLog = {
|
||||
async runDiagnostics() {
|
||||
this.log('DIAG', '── Starting connection diagnostics ──');
|
||||
|
||||
const baseUrl = (typeof Backend !== 'undefined' && Backend.baseUrl)
|
||||
? Backend.baseUrl
|
||||
const baseUrl = (typeof API !== 'undefined' && API._base)
|
||||
? API._base
|
||||
: window.location.origin;
|
||||
|
||||
// Test 1: Basic fetch to same origin
|
||||
@@ -297,12 +306,12 @@ const DebugLog = {
|
||||
}
|
||||
|
||||
// Test 3: Token validity
|
||||
if (typeof Backend !== 'undefined' && Backend.accessToken) {
|
||||
if (typeof API !== 'undefined' && API.accessToken) {
|
||||
this.log('DIAG', 'Test 3: Token validation');
|
||||
try {
|
||||
const resp = await this._origFetch(baseUrl + '/api/v1/profile', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${Backend.accessToken}`,
|
||||
'Authorization': `Bearer ${API.accessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
signal: AbortSignal.timeout(5000)
|
||||
@@ -318,6 +327,13 @@ const DebugLog = {
|
||||
this.log('DIAG', 'Test 3: Skipped (no token)');
|
||||
}
|
||||
|
||||
// Test 4: WebSocket connectivity
|
||||
this.log('DIAG', `Test 4: EventBus WebSocket = ${typeof Events !== 'undefined' ? (Events.connected ? 'connected' : 'disconnected') : 'N/A'}`);
|
||||
if (typeof Events !== 'undefined') {
|
||||
this.log('DIAG', ` Subscriptions: ${JSON.stringify(Events.debug())}`);
|
||||
this.log('DIAG', ` Queue depth: ${Events._wsQueue?.length || 0}`);
|
||||
}
|
||||
|
||||
this.log('DIAG', '── Diagnostics complete ──');
|
||||
this.render();
|
||||
},
|
||||
@@ -325,24 +341,7 @@ const DebugLog = {
|
||||
// ── Badge ───────────────────────────────
|
||||
|
||||
_injectBadge() {
|
||||
const badge = document.createElement('div');
|
||||
badge.id = 'debugBadge';
|
||||
badge.innerHTML = '🐛';
|
||||
badge.title = 'Debug Log (Ctrl+Shift+L)';
|
||||
badge.style.cssText = `
|
||||
position: fixed; bottom: 12px; left: 12px; z-index: 100000;
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
background: var(--bg-secondary, #2a2a2a); border: 1px solid var(--border, #444);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer; font-size: 18px; opacity: 0.7;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
`;
|
||||
badge.addEventListener('mouseenter', () => { badge.style.opacity = '1'; badge.style.transform = 'scale(1.1)'; });
|
||||
badge.addEventListener('mouseleave', () => { badge.style.opacity = this._errorCount > 0 ? '0.9' : '0.7'; badge.style.transform = ''; });
|
||||
badge.addEventListener('click', () => openDebugModal());
|
||||
document.body.appendChild(badge);
|
||||
|
||||
// Keyboard shortcut
|
||||
// Keyboard shortcut only — visual indicator is the avatar 🐛
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.ctrlKey && e.shiftKey && e.key === 'L') {
|
||||
e.preventDefault();
|
||||
@@ -352,13 +351,17 @@ const DebugLog = {
|
||||
},
|
||||
|
||||
_updateBadge() {
|
||||
const badge = document.getElementById('debugBadge');
|
||||
if (!badge) return;
|
||||
// Show error count on avatar bug in sidebar
|
||||
const bug = document.querySelector('.avatar-bug');
|
||||
if (!bug) return;
|
||||
if (this._errorCount > 0) {
|
||||
badge.style.opacity = '0.9';
|
||||
badge.style.background = 'var(--danger, #c0392b)';
|
||||
badge.innerHTML = `🐛<span style="position:absolute;top:-4px;right:-4px;background:#e74c3c;color:#fff;font-size:10px;border-radius:50%;width:16px;height:16px;display:flex;align-items:center;justify-content:center;">${this._errorCount > 99 ? '99+' : this._errorCount}</span>`;
|
||||
badge.style.position = 'fixed'; // keep relative positioning for the counter
|
||||
bug.textContent = '🐛';
|
||||
bug.title = `${this._errorCount} error${this._errorCount > 1 ? 's' : ''} — click for Debug Log`;
|
||||
bug.classList.add('has-errors');
|
||||
} else {
|
||||
bug.textContent = '🐛';
|
||||
bug.title = 'Debug available';
|
||||
bug.classList.remove('has-errors');
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
327
src/js/events.js
Normal file
327
src/js/events.js
Normal file
@@ -0,0 +1,327 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – EventBus (v0.5.3)
|
||||
// ==========================================
|
||||
// Labeled event bus with WebSocket bridge.
|
||||
// Components subscribe by label (supports * wildcard).
|
||||
// Events route: local-only, FE→BE, BE→FE, or both.
|
||||
//
|
||||
// Usage:
|
||||
// Events.on('chat.message.*', (payload, meta) => { ... });
|
||||
// Events.emit('chat.typing.abc123', { user: 'jeff' });
|
||||
// Events.connect('/ws');
|
||||
// ==========================================
|
||||
|
||||
const Events = {
|
||||
|
||||
_handlers: new Map(), // label → Set<{fn, once}>
|
||||
_ws: null,
|
||||
_wsUrl: null,
|
||||
_wsReconnectMs: 1000,
|
||||
_wsMaxReconnectMs: 30000,
|
||||
_wsReconnectTimer: null,
|
||||
_wsConnected: false,
|
||||
_wsQueue: [], // buffered while disconnected
|
||||
|
||||
// Labels that should NOT cross the wire
|
||||
_localOnly: new Set([
|
||||
'ui.modal.open', 'ui.modal.close',
|
||||
'ui.sidebar.toggle', 'ui.sidebar.collapse',
|
||||
'ui.toast',
|
||||
]),
|
||||
|
||||
// Labels the FE should never receive from BE
|
||||
// (enforced server-side, this is defense-in-depth)
|
||||
_serverOnly: new Set([
|
||||
'plugin.hook.pre_completion',
|
||||
'plugin.hook.post_completion',
|
||||
'internal.',
|
||||
'db.',
|
||||
]),
|
||||
|
||||
// ── Subscribe ────────────────────────────
|
||||
|
||||
/**
|
||||
* Subscribe to events matching a label pattern.
|
||||
* Supports exact match and trailing wildcard:
|
||||
* 'chat.message.abc123' — exact
|
||||
* 'chat.message.*' — any chat.message.{id}
|
||||
* 'chat.*' — any chat.{anything}
|
||||
*
|
||||
* @param {string} label - Event label or pattern
|
||||
* @param {Function} fn - Handler(payload, meta)
|
||||
* @returns {Function} unsubscribe function
|
||||
*/
|
||||
on(label, fn) {
|
||||
if (!this._handlers.has(label)) this._handlers.set(label, new Set());
|
||||
const entry = { fn, once: false };
|
||||
this._handlers.get(label).add(entry);
|
||||
return () => this._handlers.get(label)?.delete(entry);
|
||||
},
|
||||
|
||||
/**
|
||||
* Subscribe for a single event, then auto-unsubscribe.
|
||||
*/
|
||||
once(label, fn) {
|
||||
if (!this._handlers.has(label)) this._handlers.set(label, new Set());
|
||||
const entry = { fn, once: true };
|
||||
this._handlers.get(label).add(entry);
|
||||
return () => this._handlers.get(label)?.delete(entry);
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove all handlers for a label, or a specific handler.
|
||||
*/
|
||||
off(label, fn) {
|
||||
if (!fn) {
|
||||
this._handlers.delete(label);
|
||||
} else {
|
||||
const set = this._handlers.get(label);
|
||||
if (set) {
|
||||
for (const entry of set) {
|
||||
if (entry.fn === fn) { set.delete(entry); break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── Emit ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Emit an event. Dispatches locally and sends via WebSocket
|
||||
* unless the label is in _localOnly.
|
||||
*
|
||||
* @param {string} label - Event label
|
||||
* @param {*} payload - Event data
|
||||
* @param {object} opts - { localOnly: bool, room: string }
|
||||
*/
|
||||
emit(label, payload, opts = {}) {
|
||||
const meta = {
|
||||
event: label,
|
||||
room: opts.room || null,
|
||||
ts: Date.now(),
|
||||
local: true,
|
||||
};
|
||||
|
||||
// Local dispatch
|
||||
this._dispatch(label, payload, meta);
|
||||
|
||||
// Send over WebSocket unless local-only
|
||||
if (!opts.localOnly && !this._isLocalOnly(label)) {
|
||||
this._send({ event: label, room: meta.room, payload, ts: meta.ts });
|
||||
}
|
||||
},
|
||||
|
||||
// ── Internal dispatch ────────────────────
|
||||
|
||||
_dispatch(label, payload, meta) {
|
||||
// Check every registered pattern against this label
|
||||
for (const [pattern, entries] of this._handlers) {
|
||||
if (this._match(label, pattern)) {
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
entry.fn(payload, meta);
|
||||
} catch (e) {
|
||||
console.error(`[EventBus] Handler error for ${pattern}:`, e);
|
||||
}
|
||||
if (entry.once) entries.delete(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Match a concrete label against a subscription pattern.
|
||||
* 'chat.message.abc' matches:
|
||||
* 'chat.message.abc' (exact)
|
||||
* 'chat.message.*' (wildcard last segment)
|
||||
* 'chat.*' (wildcard all after chat.)
|
||||
* '*' (match everything)
|
||||
*/
|
||||
_match(label, pattern) {
|
||||
if (pattern === '*') return true;
|
||||
if (pattern === label) return true;
|
||||
if (!pattern.endsWith('*')) return false;
|
||||
const prefix = pattern.slice(0, -1); // 'chat.message.' from 'chat.message.*'
|
||||
return label.startsWith(prefix);
|
||||
},
|
||||
|
||||
_isLocalOnly(label) {
|
||||
if (this._localOnly.has(label)) return true;
|
||||
for (const prefix of this._localOnly) {
|
||||
if (prefix.endsWith('.') && label.startsWith(prefix)) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
// ── WebSocket Bridge ─────────────────────
|
||||
|
||||
/**
|
||||
* Connect to WebSocket endpoint. Auto-reconnects on drop.
|
||||
* @param {string} url - WebSocket URL (e.g. '/ws' or 'wss://host/ws')
|
||||
*/
|
||||
connect(url) {
|
||||
this._wsUrl = url;
|
||||
this._wsReconnectMs = 1000;
|
||||
this._wsRetries = 0;
|
||||
this._wsMaxRetries = 5;
|
||||
this._doConnect();
|
||||
},
|
||||
|
||||
disconnect() {
|
||||
if (this._wsReconnectTimer) clearTimeout(this._wsReconnectTimer);
|
||||
this._wsReconnectTimer = null;
|
||||
this._wsRetries = 0;
|
||||
if (this._ws) {
|
||||
this._ws.onclose = null; // prevent reconnect
|
||||
this._ws.close();
|
||||
this._ws = null;
|
||||
}
|
||||
this._wsConnected = false;
|
||||
this._dispatch('ws.disconnected', {}, { event: 'ws.disconnected', ts: Date.now(), local: true });
|
||||
},
|
||||
|
||||
_doConnect() {
|
||||
if (!this._wsUrl) return;
|
||||
|
||||
// Build full URL
|
||||
let url = this._wsUrl;
|
||||
if (url.startsWith('/')) {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
url = `${proto}//${location.host}${url}`;
|
||||
}
|
||||
|
||||
// Add auth token as query param (WebSocket doesn't support headers)
|
||||
const tokens = JSON.parse(localStorage.getItem('sb_auth') || '{}');
|
||||
if (tokens.access) {
|
||||
url += (url.includes('?') ? '&' : '?') + `token=${encodeURIComponent(tokens.access)}`;
|
||||
} else {
|
||||
console.warn('[EventBus] No auth token — skipping WebSocket');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this._ws = new WebSocket(url);
|
||||
} catch (e) {
|
||||
console.warn('[EventBus] WebSocket creation failed:', e);
|
||||
this._scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
this._ws.onopen = () => {
|
||||
console.log('[EventBus] WebSocket connected');
|
||||
this._wsConnected = true;
|
||||
this._wsReconnectMs = 1000;
|
||||
this._wsRetries = 0;
|
||||
|
||||
// Flush queued events
|
||||
while (this._wsQueue.length > 0) {
|
||||
const msg = this._wsQueue.shift();
|
||||
this._ws.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
this._dispatch('ws.connected', {}, { event: 'ws.connected', ts: Date.now(), local: true });
|
||||
};
|
||||
|
||||
this._ws.onmessage = (evt) => {
|
||||
try {
|
||||
const msg = JSON.parse(evt.data);
|
||||
|
||||
// Server pong
|
||||
if (msg.event === 'pong') return;
|
||||
|
||||
// Drop events that shouldn't reach the frontend
|
||||
for (const prefix of this._serverOnly) {
|
||||
if (msg.event.startsWith(prefix)) return;
|
||||
}
|
||||
|
||||
const meta = {
|
||||
event: msg.event,
|
||||
room: msg.room || null,
|
||||
ts: msg.ts || Date.now(),
|
||||
local: false,
|
||||
};
|
||||
|
||||
this._dispatch(msg.event, msg.payload, meta);
|
||||
} catch (e) {
|
||||
console.warn('[EventBus] Bad message:', evt.data);
|
||||
}
|
||||
};
|
||||
|
||||
this._ws.onclose = (evt) => {
|
||||
this._wsConnected = false;
|
||||
this._ws = null;
|
||||
if (evt.code !== 1000) {
|
||||
// Abnormal close — log with context
|
||||
console.log(`[EventBus] WebSocket closed (code=${evt.code}, reason=${evt.reason || 'none'})`);
|
||||
}
|
||||
this._dispatch('ws.disconnected', { code: evt.code }, { event: 'ws.disconnected', ts: Date.now(), local: true });
|
||||
this._scheduleReconnect();
|
||||
};
|
||||
|
||||
this._ws.onerror = () => {
|
||||
// onclose will fire after this — don't double-log
|
||||
};
|
||||
|
||||
// Heartbeat
|
||||
this._startHeartbeat();
|
||||
},
|
||||
|
||||
_scheduleReconnect() {
|
||||
if (!this._wsUrl) return;
|
||||
if (this._wsReconnectTimer) return;
|
||||
|
||||
this._wsRetries++;
|
||||
if (this._wsRetries > this._wsMaxRetries) {
|
||||
console.warn(`[EventBus] WebSocket failed after ${this._wsMaxRetries} attempts — giving up. Real-time features unavailable.`);
|
||||
this._dispatch('ws.failed', { retries: this._wsRetries }, { event: 'ws.failed', ts: Date.now(), local: true });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[EventBus] Reconnecting in ${this._wsReconnectMs}ms (attempt ${this._wsRetries}/${this._wsMaxRetries})`);
|
||||
this._wsReconnectTimer = setTimeout(() => {
|
||||
this._wsReconnectTimer = null;
|
||||
this._doConnect();
|
||||
}, this._wsReconnectMs);
|
||||
|
||||
// Exponential backoff with cap
|
||||
this._wsReconnectMs = Math.min(this._wsReconnectMs * 2, this._wsMaxReconnectMs);
|
||||
},
|
||||
|
||||
_send(msg) {
|
||||
if (this._wsConnected && this._ws?.readyState === WebSocket.OPEN) {
|
||||
this._ws.send(JSON.stringify(msg));
|
||||
} else {
|
||||
// Buffer up to 100 events while disconnected
|
||||
if (this._wsQueue.length < 100) this._wsQueue.push(msg);
|
||||
}
|
||||
},
|
||||
|
||||
_heartbeatInterval: null,
|
||||
_startHeartbeat() {
|
||||
if (this._heartbeatInterval) clearInterval(this._heartbeatInterval);
|
||||
this._heartbeatInterval = setInterval(() => {
|
||||
if (this._wsConnected) {
|
||||
this._send({ event: 'ping', payload: {}, ts: Date.now() });
|
||||
}
|
||||
}, 30000);
|
||||
},
|
||||
|
||||
// ── Utility ──────────────────────────────
|
||||
|
||||
/** Check if WebSocket is connected */
|
||||
get connected() { return this._wsConnected; },
|
||||
|
||||
/** Remove all handlers (for cleanup / logout) */
|
||||
clear() {
|
||||
this._handlers.clear();
|
||||
},
|
||||
|
||||
/** Debug: list all subscriptions */
|
||||
debug() {
|
||||
const subs = {};
|
||||
for (const [label, entries] of this._handlers) {
|
||||
subs[label] = entries.size;
|
||||
}
|
||||
return subs;
|
||||
}
|
||||
};
|
||||
263
src/js/state.js
263
src/js/state.js
@@ -1,263 +0,0 @@
|
||||
// ==========================================
|
||||
// State Management (Mode-Aware)
|
||||
// ==========================================
|
||||
// In unmanaged mode: localStorage (same as before)
|
||||
// In managed mode: backend API with local cache
|
||||
// ==========================================
|
||||
|
||||
const State = {
|
||||
settings: {
|
||||
apiEndpoint: '',
|
||||
apiKey: '',
|
||||
model: 'gpt-3.5-turbo',
|
||||
stream: true,
|
||||
saveHistory: true,
|
||||
showThinking: true,
|
||||
systemPrompt: '',
|
||||
maxTokens: 4096,
|
||||
temperature: 0.7,
|
||||
topP: 1,
|
||||
presencePenalty: 0
|
||||
},
|
||||
models: [],
|
||||
chats: [],
|
||||
currentChatId: null,
|
||||
isGenerating: false,
|
||||
abortController: null
|
||||
};
|
||||
|
||||
// ── Settings (localStorage + backend sync) ──
|
||||
|
||||
function loadSettings() {
|
||||
const saved = Storage.get('chatSwitchboard_settings');
|
||||
if (saved) {
|
||||
State.settings = { ...State.settings, ...saved };
|
||||
}
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
Storage.set('chatSwitchboard_settings', State.settings);
|
||||
// Fire-and-forget sync to backend
|
||||
syncSettingsToBackend();
|
||||
}
|
||||
|
||||
async function syncSettingsToBackend() {
|
||||
if (!Backend.isManaged) return;
|
||||
try {
|
||||
// Only sync preferences, not apiEndpoint/apiKey (those live in api_configs)
|
||||
const prefs = {
|
||||
model: State.settings.model,
|
||||
stream: State.settings.stream,
|
||||
saveHistory: State.settings.saveHistory,
|
||||
showThinking: State.settings.showThinking,
|
||||
systemPrompt: State.settings.systemPrompt,
|
||||
maxTokens: State.settings.maxTokens,
|
||||
temperature: State.settings.temperature,
|
||||
topP: State.settings.topP,
|
||||
presencePenalty: State.settings.presencePenalty
|
||||
};
|
||||
await Backend.updateSettings(prefs);
|
||||
} catch (e) {
|
||||
console.warn('Failed to sync settings to backend:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSettingsFromBackend() {
|
||||
if (!Backend.isManaged) return;
|
||||
try {
|
||||
const remote = await Backend.getSettings();
|
||||
if (remote && Object.keys(remote).length > 0) {
|
||||
// Merge remote prefs over local, but keep apiEndpoint/apiKey local
|
||||
const localEndpoint = State.settings.apiEndpoint;
|
||||
const localKey = State.settings.apiKey;
|
||||
State.settings = { ...State.settings, ...remote };
|
||||
State.settings.apiEndpoint = localEndpoint;
|
||||
State.settings.apiKey = localKey;
|
||||
Storage.set('chatSwitchboard_settings', State.settings);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load settings from backend:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Models (always localStorage) ────────────
|
||||
|
||||
function loadModels() {
|
||||
State.models = Storage.get('chatSwitchboard_models', []);
|
||||
}
|
||||
|
||||
function saveModels() {
|
||||
Storage.set('chatSwitchboard_models', State.models);
|
||||
}
|
||||
|
||||
// ── Chats ───────────────────────────────────
|
||||
|
||||
async function loadChats() {
|
||||
if (Backend.isManaged) {
|
||||
try {
|
||||
const resp = await Backend.listChats(1, 100);
|
||||
State.chats = (resp.data || []).map(c => ({
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
model: c.model || '',
|
||||
systemPrompt: c.system_prompt || '',
|
||||
isArchived: c.is_archived,
|
||||
isPinned: c.is_pinned,
|
||||
messageCount: c.message_count || 0,
|
||||
messages: [], // loaded on demand
|
||||
createdAt: c.created_at,
|
||||
updatedAt: c.updated_at
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Failed to load chats from backend:', e);
|
||||
showToast('⚠️ Failed to load chats', 'error');
|
||||
}
|
||||
} else {
|
||||
State.chats = Storage.get('chatSwitchboard_chats', []);
|
||||
}
|
||||
}
|
||||
|
||||
function saveChats() {
|
||||
// In managed mode, individual operations handle persistence.
|
||||
// In unmanaged mode, write to localStorage.
|
||||
if (!Backend.isManaged && State.settings.saveHistory) {
|
||||
Storage.set('chatSwitchboard_chats', State.chats);
|
||||
}
|
||||
}
|
||||
|
||||
async function createChat(title, messages) {
|
||||
if (Backend.isManaged) {
|
||||
try {
|
||||
const resp = await Backend.createChat(
|
||||
title,
|
||||
State.settings.model,
|
||||
State.settings.systemPrompt
|
||||
);
|
||||
const chat = {
|
||||
id: resp.id,
|
||||
title: resp.title,
|
||||
model: resp.model || '',
|
||||
systemPrompt: resp.system_prompt || '',
|
||||
messages: messages || [],
|
||||
messageCount: 0,
|
||||
createdAt: resp.created_at,
|
||||
updatedAt: resp.updated_at
|
||||
};
|
||||
State.chats.unshift(chat);
|
||||
|
||||
// Persist any initial messages (system prompt, first user msg)
|
||||
for (const msg of (messages || [])) {
|
||||
await Backend.createMessage(chat.id, msg.role, msg.content, msg.model);
|
||||
}
|
||||
chat.messageCount = (messages || []).length;
|
||||
|
||||
return chat;
|
||||
} catch (e) {
|
||||
console.error('Failed to create chat:', e);
|
||||
showToast('⚠️ Failed to create chat', 'error');
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
const chat = {
|
||||
id: Date.now().toString(),
|
||||
title,
|
||||
messages: messages || [],
|
||||
model: State.settings.model,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
State.chats.unshift(chat);
|
||||
if (State.chats.length > 100) {
|
||||
State.chats = State.chats.slice(0, 100);
|
||||
}
|
||||
saveChats();
|
||||
return chat;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateChat(chatId, updates) {
|
||||
const chat = State.chats.find(c => c.id === chatId);
|
||||
if (!chat) return;
|
||||
|
||||
if (Backend.isManaged) {
|
||||
// Separate backend-updateable fields from local-only fields
|
||||
const backendUpdates = {};
|
||||
if (updates.title !== undefined) backendUpdates.title = updates.title;
|
||||
if (updates.model !== undefined) backendUpdates.model = updates.model;
|
||||
if (updates.is_archived !== undefined) backendUpdates.is_archived = updates.is_archived;
|
||||
if (updates.is_pinned !== undefined) backendUpdates.is_pinned = updates.is_pinned;
|
||||
|
||||
if (Object.keys(backendUpdates).length > 0) {
|
||||
try {
|
||||
await Backend.updateChat(chatId, backendUpdates);
|
||||
} catch (e) {
|
||||
console.error('Failed to update chat:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Update local cache
|
||||
Object.assign(chat, updates, { updatedAt: new Date().toISOString() });
|
||||
} else {
|
||||
Object.assign(chat, updates, { updatedAt: new Date().toISOString() });
|
||||
saveChats();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteChat(chatId) {
|
||||
if (Backend.isManaged) {
|
||||
try {
|
||||
await Backend.deleteChat(chatId);
|
||||
} catch (e) {
|
||||
console.error('Failed to delete chat:', e);
|
||||
showToast('⚠️ Failed to delete chat', 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
State.chats = State.chats.filter(c => c.id !== chatId);
|
||||
saveChats();
|
||||
}
|
||||
|
||||
function getChat(chatId) {
|
||||
return State.chats.find(c => c.id === chatId);
|
||||
}
|
||||
|
||||
function getCurrentChat() {
|
||||
return State.currentChatId ? getChat(State.currentChatId) : null;
|
||||
}
|
||||
|
||||
// ── Messages (managed mode) ─────────────────
|
||||
|
||||
async function loadMessages(chatId) {
|
||||
if (!Backend.isManaged) return;
|
||||
|
||||
const chat = getChat(chatId);
|
||||
if (!chat) return;
|
||||
|
||||
// Skip if already loaded
|
||||
if (chat.messages && chat.messages.length > 0) return;
|
||||
|
||||
try {
|
||||
const resp = await Backend.listMessages(chatId, 1, 200);
|
||||
chat.messages = (resp.data || []).map(m => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
model: m.model || '',
|
||||
timestamp: m.created_at,
|
||||
_backendId: m.id
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Failed to load messages:', e);
|
||||
showToast('⚠️ Failed to load messages', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function persistMessage(chatId, role, content, model) {
|
||||
if (!Backend.isManaged) return;
|
||||
|
||||
try {
|
||||
await Backend.createMessage(chatId, role, content, model);
|
||||
} catch (e) {
|
||||
console.error('Failed to persist message:', e);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
// ==========================================
|
||||
// Storage Utilities
|
||||
// ==========================================
|
||||
|
||||
const Storage = {
|
||||
get(key, defaultValue = null) {
|
||||
try {
|
||||
const item = localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : defaultValue;
|
||||
} catch (e) {
|
||||
console.error('Storage get error:', e);
|
||||
return defaultValue;
|
||||
}
|
||||
},
|
||||
|
||||
set(key, value) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Storage set error:', e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
remove(key) {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Storage remove error:', e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
clear() {
|
||||
try {
|
||||
localStorage.clear();
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Storage clear error:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
90
src/js/ui.js
90
src/js/ui.js
@@ -1,5 +1,5 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – UI (v0.5.2)
|
||||
// Chat Switchboard – UI (v0.5.4)
|
||||
// ==========================================
|
||||
|
||||
const UI = {
|
||||
@@ -187,7 +187,8 @@ const UI = {
|
||||
App.models.forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.id;
|
||||
opt.textContent = m.id + (m.provider ? ` (${m.provider})` : '');
|
||||
const label = m.name || m.id;
|
||||
opt.textContent = label + (m.provider ? ` (${m.provider})` : '');
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
@@ -213,6 +214,56 @@ const UI = {
|
||||
}
|
||||
},
|
||||
|
||||
// ── Capability Badges ────────────────────
|
||||
|
||||
getSelectedModelCaps() {
|
||||
const modelId = document.getElementById('modelSelect').value || App.settings.model;
|
||||
if (!modelId) return {};
|
||||
const model = App.models.find(m => m.id === modelId);
|
||||
// Model in list has resolved caps; fallback to client-side lookup
|
||||
if (model?.capabilities && Object.keys(model.capabilities).length > 0) {
|
||||
return model.capabilities;
|
||||
}
|
||||
return (typeof lookupKnownCaps === 'function' ? lookupKnownCaps(modelId) : null) || {};
|
||||
},
|
||||
|
||||
updateCapabilityBadges() {
|
||||
const el = document.getElementById('modelCaps');
|
||||
if (!el) return;
|
||||
|
||||
const caps = this.getSelectedModelCaps();
|
||||
if (!caps || Object.keys(caps).length === 0) {
|
||||
el.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const badges = [];
|
||||
|
||||
// Output tokens
|
||||
if (caps.max_output_tokens > 0) {
|
||||
const k = caps.max_output_tokens >= 1000
|
||||
? (caps.max_output_tokens / 1000).toFixed(0) + 'K'
|
||||
: caps.max_output_tokens;
|
||||
badges.push(`<span class="cap-badge cap-context" title="Max output: ${caps.max_output_tokens.toLocaleString()} tokens">${k} out</span>`);
|
||||
}
|
||||
|
||||
// Context window
|
||||
if (caps.max_context > 0) {
|
||||
const k = (caps.max_context / 1000).toFixed(0) + 'K';
|
||||
badges.push(`<span class="cap-badge cap-context" title="Context window: ${caps.max_context.toLocaleString()} tokens">${k} ctx</span>`);
|
||||
}
|
||||
|
||||
// Capability flags
|
||||
if (caps.tool_calling) badges.push('<span class="cap-badge cap-accent" title="Supports tool/function calling">🔧 tools</span>');
|
||||
if (caps.vision) badges.push('<span class="cap-badge cap-accent" title="Supports image/vision input">👁 vision</span>');
|
||||
if (caps.thinking) badges.push('<span class="cap-badge cap-accent" title="Supports extended thinking">💭 thinking</span>');
|
||||
if (caps.reasoning) badges.push('<span class="cap-badge cap-accent" title="Reasoning model (chain-of-thought)">🧠 reasoning</span>');
|
||||
if (caps.code_optimized) badges.push('<span class="cap-badge" title="Optimized for code generation">⟨/⟩ code</span>');
|
||||
if (caps.web_search) badges.push('<span class="cap-badge" title="Supports web search">🔍 search</span>');
|
||||
|
||||
el.innerHTML = badges.join('');
|
||||
},
|
||||
|
||||
// ── User / Connection ────────────────────
|
||||
|
||||
updateUser() {
|
||||
@@ -268,10 +319,23 @@ const UI = {
|
||||
openSettings() {
|
||||
document.getElementById('settingsModel').value = App.settings.model;
|
||||
document.getElementById('settingsSystemPrompt').value = App.settings.systemPrompt;
|
||||
document.getElementById('settingsMaxTokens').value = App.settings.maxTokens;
|
||||
document.getElementById('settingsMaxTokens').value = App.settings.maxTokens || '';
|
||||
document.getElementById('settingsTemp').value = App.settings.temperature;
|
||||
document.getElementById('settingsThinking').checked = App.settings.showThinking;
|
||||
document.getElementById('profileChangePwForm').style.display = 'none';
|
||||
|
||||
// Show model's max output in the hint
|
||||
const caps = this.getSelectedModelCaps();
|
||||
const hint = document.getElementById('settingsMaxHint');
|
||||
if (hint) {
|
||||
if (caps.max_output_tokens > 0) {
|
||||
const k = (caps.max_output_tokens / 1000).toFixed(0) + 'K';
|
||||
hint.textContent = `(model max: ${k})`;
|
||||
} else {
|
||||
hint.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
UI.loadProfileIntoSettings();
|
||||
UI.loadProviderList();
|
||||
document.getElementById('settingsModal').classList.add('active');
|
||||
@@ -375,9 +439,23 @@ const UI = {
|
||||
try {
|
||||
const data = await API.adminListModels();
|
||||
const list = data.models || data.data || data || [];
|
||||
el.innerHTML = (Array.isArray(list) ? list : []).map(m => `
|
||||
<div class="admin-model-row"><span>${esc(m.model_id || m.id)}</span><span class="provider-meta">${esc(m.provider_name || '')}</span><span>${m.is_enabled ? '✅' : '⬜'}</span></div>
|
||||
`).join('') || '<div class="empty-hint">No models</div>';
|
||||
el.innerHTML = (Array.isArray(list) ? list : []).map(m => {
|
||||
const caps = m.capabilities || {};
|
||||
const badges = [];
|
||||
if (caps.max_output_tokens > 0) {
|
||||
badges.push(`<span class="cap-badge cap-context">${(caps.max_output_tokens/1000).toFixed(0)}K out</span>`);
|
||||
}
|
||||
if (caps.tool_calling) badges.push('<span class="cap-badge cap-accent">🔧</span>');
|
||||
if (caps.vision) badges.push('<span class="cap-badge cap-accent">👁</span>');
|
||||
if (caps.thinking) badges.push('<span class="cap-badge cap-accent">💭</span>');
|
||||
if (caps.reasoning) badges.push('<span class="cap-badge cap-accent">🧠</span>');
|
||||
return `<div class="admin-model-row">
|
||||
<span>${esc(m.model_id || m.id)}</span>
|
||||
<span class="model-caps-inline">${badges.join('')}</span>
|
||||
<span class="provider-meta">${esc(m.provider_name || '')}</span>
|
||||
<span>${m.is_enabled ? '✅' : '⬜'}</span>
|
||||
</div>`;
|
||||
}).join('') || '<div class="empty-hint">No models — use Fetch Models to sync from providers</div>';
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
/**
|
||||
* Chat Switchboard - Version Configuration
|
||||
* Single source of truth: VERSION file at repo root
|
||||
* Injected by build.sh at build time
|
||||
*/
|
||||
const SWITCHBOARD_VERSION = '__VERSION__';
|
||||
const APP_NAME = 'Chat Switchboard';
|
||||
Reference in New Issue
Block a user