491 lines
18 KiB
JavaScript
491 lines
18 KiB
JavaScript
// ==========================================
|
||
// 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);
|