Changeset 0.5.0 (#35)

This commit is contained in:
2026-02-19 15:03:20 +00:00
parent a93a6b9635
commit 30d0c11219
65 changed files with 5345 additions and 8070 deletions

View File

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

View File

@@ -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',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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