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

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

View File

@@ -1,401 +1,346 @@
// ==========================================
// Chat Switchboard - Main Application
// Chat Switchboard Application (v0.5.0)
// ==========================================
const App = {
chats: [],
currentChatId: null,
models: [],
isGenerating: false,
abortController: null,
settings: {
model: '',
stream: true,
showThinking: true,
systemPrompt: '',
maxTokens: 4096,
temperature: 0.7,
},
};
// ── Init ─────────────────────────────────────
async function init() {
console.log('🔀 Chat Switchboard initializing...');
API.loadTokens();
loadSettings();
loadModels();
Backend.init();
// Auto-detect backend at same origin
// Always check backend health first
let health = null;
if (!Backend.baseUrl) {
health = await Backend.checkHealth('');
if (health && health.database) {
Backend.baseUrl = window.location.origin;
Backend.save();
console.log('🔗 Backend detected at same origin');
try {
health = await API.health();
console.log('✅ Backend reachable:', health.version);
} catch (e) {
console.error('❌ Backend unreachable:', e.message);
document.getElementById('splashError').textContent = 'Cannot reach server — check connection';
showSplash(null);
return;
}
// If we have tokens, validate them
if (API.isAuthed) {
try {
await API.getProfile();
console.log('✅ Session valid for', API.user?.username);
} catch (e) {
console.warn('⚠️ Session expired, clearing');
API.clearTokens();
}
}
// If we have saved auth, validate it
if (Backend.accessToken) {
health = health || await Backend.checkHealth(Backend.baseUrl);
if (!health) {
console.log('⚠️ Backend unreachable, falling back to unmanaged');
Backend.clear();
}
}
// Decide: splash gate or straight to app
if (Backend.baseUrl && !Backend.accessToken) {
// Online but not authenticated → show splash, hide app
showSplashGate(health);
initAuthListeners();
return; // Don't init app yet — authSuccess() will do it
}
// Already authed or no backend → go straight to app
hideSplashGate();
await loadSettingsFromBackend();
await initApp();
}
async function initApp() {
await loadChats();
initializeEventListeners();
// In managed mode, fetch models from backend (ignore localStorage cache)
if (Backend.isManaged) {
fetchModels(false); // async, non-blocking
}
updateQuickModelSelector();
renderChatHistory();
updateConnectionStatus();
initAdmin();
console.log(`✅ Chat Switchboard ready (${Backend.isManaged ? 'managed' : 'unmanaged'} mode)`);
}
function showSplashGate(health) {
const splash = document.getElementById('splashGate');
const app = document.getElementById('appContainer');
splash.classList.add('visible');
splash.style.display = 'flex'; // Fallback for cached CSS
app.classList.add('app-hidden');
app.style.display = 'none'; // Fallback for cached CSS
// Hide register tab if registration disabled
const registerTab = document.getElementById('authTabRegister');
if (health && health.registration_enabled === false) {
registerTab.style.display = 'none';
if (API.isAuthed) {
await startApp();
} else {
registerTab.style.display = '';
showSplash(health);
}
}
function hideSplashGate() {
const splash = document.getElementById('splashGate');
const app = document.getElementById('appContainer');
splash.classList.remove('visible');
splash.style.display = 'none'; // Hide splash
app.classList.remove('app-hidden');
app.style.display = ''; // Show app
async function startApp() {
hideSplash();
await loadSettings();
await loadChats();
await fetchModels();
UI.renderChatList();
UI.updateModelSelector();
UI.updateConnectionStatus();
UI.showAdminButton(API.isAdmin);
initListeners();
console.log('✅ Chat Switchboard ready');
}
async function authSuccess() {
hideSplashGate();
await loadSettingsFromBackend();
await initApp();
showToast(`✅ Welcome, ${Backend.user?.display_name || Backend.user?.username || 'user'}!`, 'success');
}
// ── Settings ─────────────────────────────────
function initializeEventListeners() {
// Settings modal
document.getElementById('settingsBtn').addEventListener('click', openSettings);
document.getElementById('configureBtn').addEventListener('click', openSettings);
document.getElementById('closeModalBtn').addEventListener('click', closeSettings);
document.getElementById('cancelBtn').addEventListener('click', closeSettings);
document.getElementById('saveBtn').addEventListener('click', handleSaveSettings);
document.getElementById('fetchModelsBtn').addEventListener('click', () => fetchModels(true));
// Profile
document.getElementById('profileChangePwBtn').addEventListener('click', () => {
document.getElementById('profileChangePwForm').style.display = '';
});
document.getElementById('profileSavePwBtn').addEventListener('click', handleChangePassword);
// Providers (managed mode)
document.getElementById('providerShowAddBtn').addEventListener('click', showProviderForm);
document.getElementById('providerCancelBtn').addEventListener('click', hideProviderForm);
document.getElementById('providerCreateBtn').addEventListener('click', handleCreateProvider);
document.getElementById('providerType').addEventListener('change', updateProviderEndpointHint);
// Chat controls
document.getElementById('newChatBtn').addEventListener('click', newChat);
document.getElementById('sendBtn').addEventListener('click', sendMessage);
document.getElementById('stopBtn').addEventListener('click', stopGeneration);
document.getElementById('regenerateBtn').addEventListener('click', regenerateResponse);
document.getElementById('toggleSidebarBtn').addEventListener('click', toggleSidebar);
document.getElementById('quickFetchBtn').addEventListener('click', () => fetchModels(false));
// Quick model selector
document.getElementById('quickModel').addEventListener('change', function() {
if (this.value) {
State.settings.model = this.value;
saveSettings();
async function loadSettings() {
try {
const remote = await API.getSettings();
if (remote && typeof remote === 'object') {
if (remote.model) App.settings.model = remote.model;
if (remote.system_prompt !== undefined) App.settings.systemPrompt = remote.system_prompt;
if (remote.max_tokens) App.settings.maxTokens = remote.max_tokens;
if (remote.temperature !== undefined) App.settings.temperature = remote.temperature;
}
});
// Export dropdown
document.getElementById('exportBtn').addEventListener('click', function(e) {
e.stopPropagation();
document.getElementById('exportDropdown').classList.toggle('show');
});
document.addEventListener('click', function() {
document.getElementById('exportDropdown').classList.remove('show');
});
// Message input
const messageInput = document.getElementById('messageInput');
messageInput.addEventListener('keydown', handleKeyDown);
messageInput.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 200) + 'px';
});
// Settings modal model sync
document.getElementById('model').addEventListener('change', function() {
if (this.value) document.getElementById('modelCustom').value = '';
});
document.getElementById('modelCustom').addEventListener('input', function() {
if (this.value) document.getElementById('model').value = '';
});
// Close modals on overlay click
document.getElementById('settingsModal').addEventListener('click', function(e) {
if (e.target === this) closeSettings();
});
// Connection status click
document.getElementById('connectionStatus').addEventListener('click', handleConnectionClick);
// Admin panel
document.getElementById('adminBtn').addEventListener('click', openAdmin);
document.getElementById('adminCloseBtn').addEventListener('click', closeAdmin);
document.querySelectorAll('.admin-tab').forEach(tab => {
tab.addEventListener('click', () => switchAdminTab(tab.dataset.tab));
});
document.getElementById('adminSaveSettings').addEventListener('click', saveAdminSettings);
document.getElementById('adminModal').addEventListener('click', function(e) {
if (e.target === this) closeAdmin();
});
// Global keyboard shortcuts
document.addEventListener('keydown', function(e) {
if (e.ctrlKey && e.key === ',') {
e.preventDefault();
openSettings();
}
if (e.ctrlKey && e.key === 'b') {
e.preventDefault();
toggleSidebar();
}
if (e.key === 'Escape') {
if (State.isGenerating) {
stopGeneration();
} else if (document.getElementById('settingsModal').classList.contains('active')) {
closeSettings();
}
}
});
}
function handleKeyDown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
} catch (e) {
console.warn('Settings load failed:', e.message);
}
}
function handleSaveSettings() {
State.settings.apiEndpoint = document.getElementById('apiEndpoint').value.trim();
State.settings.apiKey = document.getElementById('apiKey').value.trim();
const modelCustom = document.getElementById('modelCustom').value.trim();
const modelSelect = document.getElementById('model').value;
State.settings.model = modelCustom || modelSelect || 'gpt-3.5-turbo';
State.settings.stream = document.getElementById('streamResponse').checked;
State.settings.saveHistory = document.getElementById('saveHistory').checked;
State.settings.showThinking = document.getElementById('showThinking').checked;
State.settings.systemPrompt = document.getElementById('systemPrompt').value.trim();
State.settings.maxTokens = parseInt(document.getElementById('maxTokens').value) || 4096;
State.settings.temperature = parseFloat(document.getElementById('temperature').value) || 0.7;
State.settings.topP = parseFloat(document.getElementById('topP').value) || 1;
State.settings.presencePenalty = parseFloat(document.getElementById('presencePenalty').value) || 0;
saveSettings();
saveProfile(); // async, fire-and-forget
updateQuickModelSelector();
closeSettings();
showToast('✅ Settings saved', 'success');
async function saveSettings() {
try {
await API.updateSettings({
model: App.settings.model,
system_prompt: App.settings.systemPrompt,
max_tokens: App.settings.maxTokens,
temperature: App.settings.temperature,
});
} catch (e) {
console.warn('Settings sync failed:', e.message);
}
}
function newChat() {
State.currentChatId = null;
document.getElementById('chatMessages').innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
</div>
<h2>Start a Conversation</h2>
<p>Type a message below to begin</p>
</div>
`;
document.getElementById('regenerateBtn').style.display = 'none';
renderChatHistory();
// ── Models ───────────────────────────────────
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
}));
// Fallback to raw aggregation if no curated models
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.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();
}
// ── Chat Management ──────────────────────────
async function loadChats() {
try {
const resp = await API.listChats();
App.chats = (resp.data || []).map(c => ({
id: c.id,
title: c.title,
model: c.model || '',
messageCount: c.message_count || 0,
messages: [], // lazy loaded
updatedAt: c.updated_at
}));
} catch (e) {
console.error('Failed to load chats:', e.message);
UI.toast('Failed to load chats', 'error');
}
}
async function selectChat(chatId) {
App.currentChatId = chatId;
UI.renderChatList();
const chat = App.chats.find(c => c.id === chatId);
if (!chat) return;
// Lazy-load messages
if (chat.messages.length === 0 && chat.messageCount > 0) {
try {
const resp = await API.listMessages(chatId);
chat.messages = (resp.data || []).map(m => ({
role: m.role,
content: m.content,
model: m.model || '',
timestamp: m.created_at
}));
} catch (e) {
console.error('Failed to load messages:', e.message);
UI.toast('Failed to load messages', 'error');
}
}
UI.renderMessages(chat.messages);
UI.showRegenerate(chat.messages.some(m => m.role === 'assistant'));
}
async function newChat() {
App.currentChatId = null;
UI.renderChatList();
UI.showEmptyState();
document.getElementById('messageInput').focus();
}
async function loadChat(chatId) {
const chat = getChat(chatId);
if (!chat) return;
State.currentChatId = chatId;
// In managed mode, fetch messages if not cached
if (Backend.isManaged && chat.messages.length === 0 && chat.messageCount > 0) {
await loadMessages(chatId);
}
if (chat.model) {
State.settings.model = chat.model;
updateQuickModelSelector();
}
renderMessages(chat.messages);
renderChatHistory();
updateRegenerateButton(chat.messages);
}
async function handleDeleteChat(chatId, event) {
event.stopPropagation();
if (confirm('Delete this chat?')) {
await deleteChat(chatId);
if (State.currentChatId === chatId) {
async function deleteChat(chatId) {
if (!confirm('Delete this chat?')) return;
try {
await API.deleteChat(chatId);
App.chats = App.chats.filter(c => c.id !== chatId);
if (App.currentChatId === chatId) {
newChat();
}
renderChatHistory();
showToast('🗑️ Chat deleted', 'success');
UI.renderChatList();
} catch (e) {
UI.toast('Failed to delete: ' + e.message, 'error');
}
}
// ── Send Message ─────────────────────────────
async function sendMessage() {
const input = document.getElementById('messageInput');
const message = input.value.trim();
if (!message || State.isGenerating) return;
// In managed mode, backend has the keys. In unmanaged, user must configure.
if (!Backend.isManaged) {
if (!State.settings.apiEndpoint || !State.settings.apiKey) {
showToast('⚠️ Configure API settings first', 'warning');
openSettings();
return;
}
}
const text = input.value.trim();
if (!text || App.isGenerating) return;
input.value = '';
input.style.height = 'auto';
let messages = [];
const model = document.getElementById('modelSelect').value || App.settings.model;
if (State.settings.systemPrompt) {
messages.push({ role: 'system', content: State.settings.systemPrompt });
}
if (State.currentChatId) {
const chat = getChat(State.currentChatId);
if (chat) messages = [...chat.messages];
}
messages.push({
role: 'user',
content: message,
timestamp: new Date().toISOString()
});
if (!State.currentChatId) {
const title = message.substring(0, 50) + (message.length > 50 ? '...' : '');
const chat = await createChat(title, messages);
if (!chat) return;
State.currentChatId = chat.id;
} else {
await updateChat(State.currentChatId, { messages });
// In unmanaged mode, persist user message to backend (if connected)
// In managed mode, the completion proxy persists both messages
if (!Backend.isManaged) {
await persistMessage(State.currentChatId, 'user', message);
// Create chat on first message
if (!App.currentChatId) {
try {
const title = text.slice(0, 50) + (text.length > 50 ? '...' : '');
const resp = await API.createChat(title, model, App.settings.systemPrompt);
const chat = {
id: resp.id,
title: resp.title,
model: model,
messages: [],
messageCount: 0,
updatedAt: resp.updated_at
};
App.chats.unshift(chat);
App.currentChatId = chat.id;
UI.renderChatList();
} catch (e) {
UI.toast('Failed to create chat: ' + e.message, 'error');
return;
}
}
renderMessages(messages);
renderChatHistory();
// Add user message to local state + render
const chat = App.chats.find(c => c.id === App.currentChatId);
if (!chat) return;
await sendApiRequest(messages);
chat.messages.push({ role: 'user', content: text, timestamp: new Date().toISOString() });
UI.renderMessages(chat.messages);
// Stream the completion
App.isGenerating = true;
App.abortController = new AbortController();
UI.setGenerating(true);
try {
const resp = await API.streamCompletion(
App.currentChatId, text, model, App.abortController.signal
);
const assistantContent = await UI.streamResponse(resp, chat.messages);
chat.messages.push({
role: 'assistant',
content: assistantContent,
model: model,
timestamp: new Date().toISOString()
});
chat.messageCount = chat.messages.length;
UI.showRegenerate(true);
} catch (e) {
if (e.name === 'AbortError') {
UI.toast('Generation stopped', 'warning');
} else {
console.error('Completion error:', e);
UI.toast(e.message, 'error');
}
} finally {
App.isGenerating = false;
App.abortController = null;
UI.setGenerating(false);
}
}
async function regenerateResponse() {
if (State.isGenerating || !State.currentChatId) return;
function stopGeneration() {
if (App.abortController) {
App.abortController.abort();
}
}
const chat = getChat(State.currentChatId);
async function regenerate() {
if (App.isGenerating || !App.currentChatId) return;
const chat = App.chats.find(c => c.id === App.currentChatId);
if (!chat || chat.messages.length === 0) return;
// Remove last assistant message(s)
while (chat.messages.length > 0 && chat.messages[chat.messages.length - 1].role === 'assistant') {
// Pop trailing assistant messages
while (chat.messages.length && chat.messages[chat.messages.length - 1].role === 'assistant') {
chat.messages.pop();
}
if (chat.messages.length === 0) return;
await updateChat(State.currentChatId, { messages: chat.messages });
renderMessages(chat.messages);
UI.renderMessages(chat.messages);
await sendApiRequest(chat.messages);
// Re-send the last user message
const lastUser = chat.messages[chat.messages.length - 1];
if (lastUser.role !== 'user') return;
const model = document.getElementById('modelSelect').value || App.settings.model;
App.isGenerating = true;
App.abortController = new AbortController();
UI.setGenerating(true);
try {
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.showRegenerate(true);
} catch (e) {
if (e.name !== 'AbortError') UI.toast(e.message, 'error');
} finally {
App.isGenerating = false;
App.abortController = null;
UI.setGenerating(false);
}
}
// ── Auth Flow ───────────────────────────────
// ── Auth Flow ───────────────────────────────
let _authListenersInit = false;
function initAuthListeners() {
if (_authListenersInit) return;
_authListenersInit = true;
function showSplash(health) {
document.getElementById('splashGate').style.display = 'flex';
document.getElementById('appContainer').style.display = 'none';
document.getElementById('authLoginBtn').addEventListener('click', handleLogin);
document.getElementById('authRegisterBtn').addEventListener('click', handleRegister);
document.getElementById('authTabLogin').addEventListener('click', () => switchAuthTab('login'));
document.getElementById('authTabRegister').addEventListener('click', () => switchAuthTab('register'));
document.getElementById('authSkipBtn').addEventListener('click', skipAuth);
// Enter key in auth forms
document.getElementById('authPassword').addEventListener('keydown', function(e) {
if (e.key === 'Enter') handleLogin();
});
document.getElementById('authRegPassword').addEventListener('keydown', function(e) {
if (e.key === 'Enter') handleRegister();
});
if (health && health.registration_enabled === false) {
document.getElementById('authTabRegister').style.display = 'none';
}
}
function switchAuthTab(tab) {
document.getElementById('authTabLogin').classList.toggle('active', tab === 'login');
document.getElementById('authTabRegister').classList.toggle('active', tab === 'register');
document.getElementById('authLoginForm').style.display = tab === 'login' ? 'block' : 'none';
document.getElementById('authRegisterForm').style.display = tab === 'register' ? 'block' : 'none';
document.getElementById('authLoginBtn').style.display = tab === 'login' ? 'block' : 'none';
document.getElementById('authRegisterBtn').style.display = tab === 'register' ? 'block' : 'none';
document.getElementById('authError').textContent = '';
function hideSplash() {
document.getElementById('splashGate').style.display = 'none';
document.getElementById('appContainer').style.display = '';
}
async function handleLogin() {
const login = document.getElementById('authLogin').value.trim();
const password = document.getElementById('authPassword').value;
if (!login || !password) {
document.getElementById('authError').textContent = 'Please fill in all fields';
return;
}
if (!login || !password) return setAuthError('Fill in all fields');
setAuthLoading(true);
try {
await Backend.login(login, password);
await authSuccess();
await API.login(login, password);
await startApp();
} catch (e) {
document.getElementById('authError').textContent = e.message;
setAuthError(e.message);
} finally {
setAuthLoading(false);
}
@@ -405,86 +350,202 @@ async function handleRegister() {
const username = document.getElementById('authUsername').value.trim();
const email = document.getElementById('authEmail').value.trim();
const password = document.getElementById('authRegPassword').value;
if (!username || !email || !password) {
document.getElementById('authError').textContent = 'Please fill in all fields';
return;
}
if (password.length < 8) {
document.getElementById('authError').textContent = 'Password must be at least 8 characters';
return;
}
if (!username || !email || !password) return setAuthError('Fill in all fields');
if (password.length < 8) return setAuthError('Password must be at least 8 characters');
setAuthLoading(true);
try {
await Backend.register(username, email, password);
await authSuccess();
await API.register(username, email, password);
await startApp();
} catch (e) {
document.getElementById('authError').textContent = e.message;
setAuthError(e.message);
} finally {
setAuthLoading(false);
}
}
function setAuthLoading(loading) {
const btns = document.querySelectorAll('#authLoginBtn, #authRegisterBtn');
btns.forEach(btn => {
btn.disabled = loading;
if (loading) btn.dataset.origText = btn.textContent;
btn.textContent = loading ? '⏳ Please wait...' : (btn.dataset.origText || btn.textContent);
async function handleLogout() {
if (!confirm('Sign out?')) return;
await API.logout();
App.chats = [];
App.currentChatId = null;
location.reload();
}
function switchAuthTab(tab) {
document.getElementById('authTabLogin').classList.toggle('active', tab === 'login');
document.getElementById('authTabRegister').classList.toggle('active', tab === 'register');
document.getElementById('authLoginForm').style.display = tab === 'login' ? '' : 'none';
document.getElementById('authRegisterForm').style.display = tab === 'register' ? '' : 'none';
document.getElementById('authLoginBtn').style.display = tab === 'login' ? '' : 'none';
document.getElementById('authRegisterBtn').style.display = tab === 'register' ? '' : 'none';
setAuthError('');
}
function setAuthError(msg) { document.getElementById('authError').textContent = msg; }
function setAuthLoading(on) {
document.querySelectorAll('#authLoginBtn, #authRegisterBtn').forEach(btn => {
btn.disabled = on;
btn.textContent = on ? '⏳ Please wait...' : btn.dataset.label;
});
}
async function skipAuth() {
Backend.clear();
hideSplashGate();
await initApp();
showToast('📴 Running in offline mode', 'success');
// ── Event Listeners ──────────────────────────
let _listenersInit = false;
function initListeners() {
if (_listenersInit) return;
_listenersInit = true;
document.getElementById('newChatBtn').addEventListener('click', newChat);
document.getElementById('sendBtn').addEventListener('click', sendMessage);
document.getElementById('stopBtn').addEventListener('click', stopGeneration);
document.getElementById('regenerateBtn').addEventListener('click', regenerate);
// Settings
document.getElementById('settingsBtn').addEventListener('click', UI.openSettings);
document.getElementById('settingsCloseBtn').addEventListener('click', UI.closeSettings);
document.getElementById('settingsSaveBtn').addEventListener('click', handleSaveSettings);
document.getElementById('fetchModelsBtn').addEventListener('click', async () => {
await fetchModels();
UI.toast(`Loaded ${App.models.length} models`, 'success');
});
// Profile
document.getElementById('profileChangePwBtn').addEventListener('click', () => {
document.getElementById('profileChangePwForm').style.display = '';
});
document.getElementById('profileSavePwBtn').addEventListener('click', handleChangePassword);
// Providers
document.getElementById('providerShowAddBtn').addEventListener('click', UI.showProviderForm);
document.getElementById('providerCancelBtn').addEventListener('click', UI.hideProviderForm);
document.getElementById('providerCreateBtn').addEventListener('click', handleCreateProvider);
// Admin
document.getElementById('adminBtn').addEventListener('click', UI.openAdmin);
document.getElementById('adminCloseBtn').addEventListener('click', UI.closeAdmin);
document.querySelectorAll('.admin-tab').forEach(tab => {
tab.addEventListener('click', () => UI.switchAdminTab(tab.dataset.tab));
});
document.getElementById('adminSaveSettings').addEventListener('click', handleSaveAdminSettings);
// Model selector
document.getElementById('modelSelect').addEventListener('change', function() {
if (this.value) { App.settings.model = this.value; saveSettings(); }
});
// Input
const input = document.getElementById('messageInput');
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
input.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 200) + 'px';
});
// Connection status
document.getElementById('connectionStatus').addEventListener('click', handleLogout);
// Export dropdown
document.getElementById('exportBtn').addEventListener('click', (e) => {
e.stopPropagation();
document.getElementById('exportDropdown').classList.toggle('show');
});
document.addEventListener('click', () => {
document.getElementById('exportDropdown').classList.remove('show');
});
// Close modals on overlay
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.classList.remove('active');
});
});
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && App.isGenerating) stopGeneration();
});
}
// ── Connection Status ───────────────────────
// ── Settings Handlers ───────────────────────
function updateConnectionStatus() {
const el = document.getElementById('connectionStatus');
if (Backend.isManaged) {
el.className = 'connection-status managed';
el.innerHTML = `<span class="status-dot"></span> ${Backend.user?.display_name || Backend.user?.username || 'Connected'}`;
el.title = 'Managed mode click to sign out';
} else if (Backend.baseUrl) {
el.className = 'connection-status available';
el.innerHTML = '<span class="status-dot"></span> Sign in';
el.title = 'Backend available click to sign in';
} else {
el.className = 'connection-status offline';
el.innerHTML = '<span class="status-dot"></span> Offline';
el.title = 'Unmanaged mode data stored locally';
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.temperature = parseFloat(document.getElementById('settingsTemp').value) || 0.7;
App.settings.showThinking = document.getElementById('settingsThinking').checked;
saveSettings();
UI.updateModelSelector();
UI.closeSettings();
UI.toast('Settings saved', 'success');
}
async function handleChangePassword() {
const cur = document.getElementById('profileCurrentPw').value;
const pw = document.getElementById('profileNewPw').value;
if (!cur || !pw) return UI.toast('Fill in both fields', 'warning');
if (pw.length < 8) return UI.toast('Min 8 characters', 'warning');
try {
await API.changePassword(cur, pw);
UI.toast('Password updated', 'success');
document.getElementById('profileChangePwForm').style.display = 'none';
document.getElementById('profileCurrentPw').value = '';
document.getElementById('profileNewPw').value = '';
} catch (e) {
UI.toast(e.message, 'error');
}
}
async function handleConnectionClick() {
if (Backend.isManaged) {
if (confirm('Sign out? Your chats are saved on the server.')) {
await Backend.logout();
State.chats = [];
State.currentChatId = null;
showSplashGate(null);
initAuthListeners();
showToast('👋 Signed out', 'success');
}
} else if (Backend.baseUrl) {
showSplashGate(null);
initAuthListeners();
async function handleCreateProvider() {
const name = document.getElementById('providerName').value.trim();
const provider = document.getElementById('providerType').value;
const endpoint = document.getElementById('providerEndpoint').value.trim();
const apiKey = document.getElementById('providerApiKey').value.trim();
const model = document.getElementById('providerDefaultModel').value.trim();
if (!name || !endpoint || !apiKey) return UI.toast('Fill in required fields', 'warning');
try {
await API.createConfig(name, provider, endpoint, apiKey, model);
UI.toast('Provider added', 'success');
UI.hideProviderForm();
UI.loadProviderList();
fetchModels();
} catch (e) {
UI.toast(e.message, 'error');
}
}
// ── Post-LLM Persistence ────────────────────
// Called by api.js after assistant response is complete.
async function onAssistantResponse(content, model) {
if (Backend.isManaged && State.currentChatId) {
await persistMessage(State.currentChatId, 'assistant', content, model);
async function deleteProvider(id, name) {
if (!confirm(`Remove provider "${name}"?`)) return;
try {
await API.deleteConfig(id);
UI.toast('Provider removed', 'success');
UI.loadProviderList();
fetchModels();
} catch (e) {
UI.toast(e.message, 'error');
}
}
// Initialize on DOM ready
// ── Admin Handlers ───────────────────────────
async function handleSaveAdminSettings() {
try {
const reg = document.getElementById('adminRegToggle').checked;
await API.adminUpdateSetting('registration_enabled', { value: reg });
UI.toast('Admin settings saved', 'success');
} catch (e) {
UI.toast(e.message, 'error');
}
}
// ── Boot ─────────────────────────────────────
document.addEventListener('DOMContentLoaded', init);