This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/src/js/app.js
2026-02-18 17:47:21 +00:00

491 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==========================================
// Chat Switchboard - Main Application
// ==========================================
async function init() {
console.log('🔀 Chat Switchboard initializing...');
loadSettings();
loadModels();
Backend.init();
// Auto-detect backend at same origin
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');
}
}
// 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';
} else {
registerTab.style.display = '';
}
}
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 authSuccess() {
hideSplashGate();
await loadSettingsFromBackend();
await initApp();
showToast(`✅ Welcome, ${Backend.user?.display_name || Backend.user?.username || 'user'}!`, 'success');
}
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();
}
});
// 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();
}
}
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');
}
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();
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) {
newChat();
}
renderChatHistory();
showToast('🗑️ Chat deleted', 'success');
}
}
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;
}
}
input.value = '';
input.style.height = 'auto';
let messages = [];
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);
}
}
renderMessages(messages);
renderChatHistory();
await sendApiRequest(messages);
}
async function regenerateResponse() {
if (State.isGenerating || !State.currentChatId) return;
const chat = getChat(State.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') {
chat.messages.pop();
}
if (chat.messages.length === 0) return;
await updateChat(State.currentChatId, { messages: chat.messages });
renderMessages(chat.messages);
await sendApiRequest(chat.messages);
}
// ── Auth Flow ───────────────────────────────
let _authListenersInit = false;
function initAuthListeners() {
if (_authListenersInit) return;
_authListenersInit = true;
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();
});
}
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 = '';
}
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;
}
setAuthLoading(true);
try {
await Backend.login(login, password);
await authSuccess();
} catch (e) {
document.getElementById('authError').textContent = e.message;
} finally {
setAuthLoading(false);
}
}
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;
}
setAuthLoading(true);
try {
await Backend.register(username, email, password);
await authSuccess();
} catch (e) {
document.getElementById('authError').textContent = 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 skipAuth() {
Backend.clear();
hideSplashGate();
await initApp();
showToast('📴 Running in offline mode', 'success');
}
// ── Connection Status ───────────────────────
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';
}
}
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();
}
}
// ── 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);
}
}
// Initialize on DOM ready
document.addEventListener('DOMContentLoaded', init);