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

File diff suppressed because it is too large Load Diff

View File

@@ -7,518 +7,299 @@
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
<link rel="apple-touch-icon" sizes="192x192" href="favicon-192.png">
<link rel="stylesheet" href="css/styles.css?v=0.4.1">
<link rel="stylesheet" href="css/styles.css?v=0.5.0">
</head>
<body>
<div id="appContainer" class="app-hidden" style="display:none">
<!-- ── App ─────────────────────────────────── -->
<div id="appContainer" style="display:none">
<header class="header">
<h1>🔀 Chat Switchboard</h1>
<h1 class="header-title">🔀 Chat Switchboard</h1>
<div class="header-actions">
<div class="connection-status offline" id="connectionStatus" title="Unmanaged mode">
<div class="connection-status" id="connectionStatus">
<span class="status-dot"></span> Offline
</div>
<button class="btn btn-secondary" id="toggleSidebarBtn" title="Toggle Sidebar (Ctrl+B)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="9" y1="3" x2="9" y2="21"></line>
</svg>
</button>
<div class="dropdown">
<button class="btn btn-secondary" id="exportBtn" title="Export Chat">
📥 Export
</button>
<div class="dropdown-content" id="exportDropdown">
<button class="dropdown-item" onclick="exportChat('markdown')">📄 Markdown</button>
<button class="dropdown-item" onclick="exportChat('json')">📋 JSON</button>
<button class="dropdown-item" onclick="exportChat('text')">📝 Plain Text</button>
<button class="btn-icon" id="exportBtn" title="Export">📥</button>
<div class="dropdown-menu" id="exportDropdown">
<button onclick="UI.exportChat('md')">Markdown</button>
<button onclick="UI.exportChat('json')">JSON</button>
<button onclick="UI.exportChat('txt')">Plain Text</button>
</div>
</div>
<button class="btn btn-secondary admin-only" id="adminBtn" title="Admin Panel" style="display:none">
🛡️ Admin
</button>
<button class="btn btn-secondary" id="settingsBtn" title="Settings (Ctrl+,)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
Settings
</button>
<button class="btn-icon" id="adminBtn" title="Admin" style="display:none">🛡️</button>
<button class="btn-icon" id="settingsBtn" title="Settings">⚙️</button>
</div>
</header>
<div class="main-container">
<div class="main">
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<span class="sidebar-title">Chat History</span>
</div>
<button class="btn btn-primary new-chat-btn" id="newChatBtn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
New Chat
</button>
<button class="btn-primary new-chat-btn" id="newChatBtn">+ New Chat</button>
<div class="chat-history" id="chatHistory"></div>
</aside>
<main class="chat-area">
<div class="chat-messages" id="chatMessages">
<div class="empty-state" id="emptyState">
<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>
<div class="empty-state">
<div class="empty-icon">💬</div>
<h2>Start a Conversation</h2>
<p>Configure your API settings and start chatting</p>
<button class="btn btn-primary" style="margin-top: 1.5rem;" id="configureBtn">⚙️ Configure API</button>
<p>Type a message below to begin</p>
</div>
</div>
<div class="input-area">
<div class="input-container">
<div class="input-top-bar">
<div class="model-selector">
<label for="quickModel">Model:</label>
<select id="quickModel">
<option value="">-- Configure in Settings --</option>
</select>
<button class="btn btn-small btn-secondary" id="quickFetchBtn" title="Refresh models">🔄</button>
</div>
<div class="input-shortcuts">
<kbd>Enter</kbd> send · <kbd>Shift+Enter</kbd> newline · <kbd>Esc</kbd> stop
</div>
<div class="input-bar">
<div class="model-selector">
<label>Model:</label>
<select id="modelSelect"><option value=""></option></select>
<button class="btn-icon btn-small" id="fetchModelsBtn" title="Refresh models">🔄</button>
</div>
<div class="input-wrapper">
<textarea id="messageInput" placeholder="Type your message..." rows="1"></textarea>
<div class="input-actions">
<div class="input-left">
<button class="btn btn-small btn-secondary" id="regenerateBtn" title="Regenerate last response" style="display: none;">🔄 Regenerate</button>
</div>
<div style="display: flex; gap: 0.5rem;">
<button class="stop-btn" id="stopBtn">⏹ Stop</button>
<button class="send-btn" id="sendBtn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>
Send
</button>
</div>
</div>
</div>
<div class="input-row">
<textarea id="messageInput" placeholder="Type your message..." rows="1"></textarea>
<div class="input-actions">
<button class="btn-regen" id="regenerateBtn" style="display:none" title="Regenerate">🔄</button>
<button class="btn-stop" id="stopBtn">⏹ Stop</button>
<button class="btn-send" id="sendBtn">Send ▸</button>
</div>
</div>
</div>
</main>
</div>
</div>
<!-- Settings Modal -->
<div class="modal-overlay" id="settingsModal">
<div class="modal">
<div class="modal-header">
<h2>⚙️ Settings</h2>
<button class="modal-close" id="closeModalBtn"></button>
<!-- ── Auth Splash ─────────────────────────── -->
<div class="splash" id="splashGate">
<div class="splash-card">
<div class="splash-brand">
<div class="splash-logo">🔀</div>
<h1>Chat Switchboard</h1>
<p>Multi-Model AI Chat</p>
</div>
<div class="auth-tabs">
<button class="auth-tab active" id="authTabLogin" onclick="switchAuthTab('login')">Sign In</button>
<button class="auth-tab" id="authTabRegister" onclick="switchAuthTab('register')">Register</button>
</div>
<div id="authLoginForm">
<div class="form-group">
<label>Username or Email</label>
<input type="text" id="authLogin" autocomplete="username">
</div>
<div class="modal-body">
<!-- Mode indicator -->
<div class="settings-mode-badge" id="settingsModeBadge"></div>
<div class="form-group">
<label>Password</label>
<input type="password" id="authPassword" autocomplete="current-password"
onkeydown="if(event.key==='Enter')handleLogin()">
</div>
</div>
<div id="authRegisterForm" style="display:none">
<div class="form-group">
<label>Username</label>
<input type="text" id="authUsername" autocomplete="username">
</div>
<div class="form-group">
<label>Email</label>
<input type="email" id="authEmail" autocomplete="email">
</div>
<div class="form-group">
<label>Password</label>
<input type="password" id="authRegPassword" autocomplete="new-password"
onkeydown="if(event.key==='Enter')handleRegister()">
</div>
</div>
<div class="auth-error" id="authError"></div>
<div class="splash-error" id="splashError"></div>
<div class="auth-actions">
<button class="btn-primary btn-full" id="authLoginBtn" data-label="Sign In" onclick="handleLogin()">Sign In</button>
<button class="btn-primary btn-full" id="authRegisterBtn" data-label="Create Account" onclick="handleRegister()" style="display:none">Create Account</button>
</div>
</div>
</div>
<!-- Profile (managed mode only) -->
<div class="settings-section" id="profileSection" style="display:none">
<h3 class="settings-section-title">Profile</h3>
<!-- ── Settings Modal ──────────────────────── -->
<div class="modal-overlay" id="settingsModal">
<div class="modal">
<div class="modal-header">
<h2>⚙️ Settings</h2>
<button class="modal-close" id="settingsCloseBtn"></button>
</div>
<div class="modal-body">
<!-- Profile -->
<section class="settings-section">
<h3>Profile</h3>
<div class="form-group">
<label>Display Name</label>
<input type="text" id="profileDisplayName">
</div>
<div class="form-group">
<label>Email</label>
<input type="email" id="profileEmail">
</div>
<button class="btn-small" id="profileChangePwBtn">🔑 Change Password</button>
<div id="profileChangePwForm" style="display:none">
<div class="form-group">
<label for="profileDisplayName">Display Name</label>
<input type="text" id="profileDisplayName" placeholder="How you want to be called">
<label>Current Password</label>
<input type="password" id="profileCurrentPw">
</div>
<div class="form-group">
<label for="profileEmail">Email</label>
<input type="email" id="profileEmail" placeholder="you@example.com">
<label>New Password</label>
<input type="password" id="profileNewPw">
</div>
<div class="profile-password-row">
<button class="btn btn-secondary btn-small" id="profileChangePwBtn">🔑 Change Password</button>
</div>
<div id="profileChangePwForm" style="display:none">
<div class="form-group">
<label for="profileCurrentPw">Current Password</label>
<input type="password" id="profileCurrentPw" placeholder="••••••••">
</div>
<div class="form-group">
<label for="profileNewPw">New Password</label>
<input type="password" id="profileNewPw" placeholder="At least 8 characters">
</div>
<button class="btn btn-primary btn-small" id="profileSavePwBtn">Update Password</button>
</div>
<hr class="settings-divider">
<button class="btn-small btn-primary" id="profileSavePwBtn">Save Password</button>
</div>
</section>
<!-- API Settings (unmanaged / offline only) -->
<div id="unmanagedSettings">
<!-- Model & Chat -->
<section class="settings-section">
<h3>Chat Settings</h3>
<div class="form-group">
<label for="apiEndpoint">API Endpoint</label>
<input type="text" id="apiEndpoint" placeholder="https://api.openai.com/v1">
<small>Base URL for the OpenAI-compatible API</small>
<label>Model</label>
<select id="settingsModel"></select>
</div>
<div class="form-group">
<label for="apiKey">API Key</label>
<input type="password" id="apiKey" placeholder="sk-...">
<small>Your API key for authentication</small>
<label>System Prompt</label>
<textarea id="settingsSystemPrompt" rows="3"></textarea>
</div>
</div> <!-- /unmanagedSettings -->
<!-- API Providers (managed mode only) -->
<div id="managedProviders" style="display:none">
<h3 class="settings-section-title">API Providers</h3>
<div id="providerList" class="provider-list"></div>
<div class="provider-add-toggle">
<button class="btn btn-secondary btn-small" id="providerShowAddBtn">+ Add Provider</button>
<div class="form-row">
<div class="form-group">
<label>Max Tokens</label>
<input type="number" id="settingsMaxTokens" value="4096">
</div>
<div class="provider-add-form" id="providerAddForm" style="display:none">
<div class="form-group">
<label for="providerName">Name</label>
<input type="text" id="providerName" placeholder="e.g. My OpenAI Key">
</div>
<div class="form-group">
<label for="providerType">Provider</label>
<select id="providerType">
<option value="openai">OpenAI-compatible</option>
<option value="anthropic">Anthropic</option>
</select>
</div>
<div class="form-group">
<label for="providerEndpoint">Endpoint</label>
<input type="text" id="providerEndpoint" placeholder="https://api.openai.com/v1">
</div>
<div class="form-group">
<label for="providerApiKey">API Key</label>
<input type="password" id="providerApiKey" placeholder="sk-...">
</div>
<div class="form-group">
<label for="providerDefaultModel">Default Model (optional)</label>
<input type="text" id="providerDefaultModel" placeholder="gpt-4o">
</div>
<div class="admin-form-row">
<button class="btn btn-primary btn-small" id="providerCreateBtn">Save</button>
<button class="btn btn-secondary btn-small" id="providerCancelBtn">Cancel</button>
</div>
<div class="form-group">
<label>Temperature</label>
<input type="number" id="settingsTemp" value="0.7" step="0.1" min="0" max="2">
</div>
<hr class="settings-divider">
</div>
<label class="checkbox-label">
<input type="checkbox" id="settingsThinking" checked> Show thinking blocks
</label>
</section>
<div class="form-group">
<label for="model">Default Model</label>
<div style="display: flex; gap: 0.5rem;">
<select id="model" style="flex: 1;">
<option value="">-- Select or type below --</option>
<!-- Providers -->
<section class="settings-section">
<h3>API Providers</h3>
<div id="providerList"></div>
<button class="btn-small" id="providerShowAddBtn">+ Add Provider</button>
<div id="providerAddForm" style="display:none">
<div class="form-group">
<label>Name</label>
<input type="text" id="providerName" placeholder="e.g. My OpenAI Key">
</div>
<div class="form-group">
<label>Provider</label>
<select id="providerType">
<option value="openai">OpenAI-compatible</option>
<option value="anthropic">Anthropic</option>
</select>
<button type="button" class="btn btn-secondary" id="fetchModelsBtn" title="Fetch available models">
🔄 Fetch
</button>
</div>
<input type="text" id="modelCustom" placeholder="Or type custom model name..." style="margin-top: 0.5rem;">
<small>Select from list or enter custom model name</small>
</div>
<div class="toggle-group">
<span class="toggle-label">Stream Responses</span>
<label class="toggle-switch">
<input type="checkbox" id="streamResponse" checked>
<span class="toggle-slider"></span>
</label>
</div>
<div class="toggle-group">
<span class="toggle-label">Save Chat History</span>
<label class="toggle-switch">
<input type="checkbox" id="saveHistory" checked>
<span class="toggle-slider"></span>
</label>
</div>
<div class="toggle-group">
<span class="toggle-label">Show Thinking Blocks</span>
<label class="toggle-switch">
<input type="checkbox" id="showThinking" checked>
<span class="toggle-slider"></span>
</label>
</div>
<div class="form-group">
<label for="systemPrompt">System Prompt (Optional)</label>
<textarea id="systemPrompt" rows="3" placeholder="You are a helpful AI assistant."></textarea>
<small>Instructions that define the AI's behavior</small>
</div>
<div class="form-row">
<div class="form-group">
<label for="maxTokens">Max Tokens</label>
<input type="number" id="maxTokens" value="4096" min="1" max="32768">
</div>
<div class="form-group">
<label for="temperature">Temperature</label>
<input type="number" id="temperature" value="0.7" min="0" max="2" step="0.1">
<label>Endpoint</label>
<input type="text" id="providerEndpoint" placeholder="https://api.openai.com/v1">
</div>
<div class="form-group">
<label>API Key</label>
<input type="password" id="providerApiKey" placeholder="sk-...">
</div>
<div class="form-group">
<label>Default Model</label>
<input type="text" id="providerDefaultModel" placeholder="gpt-4o">
</div>
<div class="form-row">
<button class="btn-small btn-primary" id="providerCreateBtn">Save</button>
<button class="btn-small" id="providerCancelBtn">Cancel</button>
</div>
</div>
</section>
</div>
<div class="modal-footer">
<button class="btn-primary" id="settingsSaveBtn">💾 Save Settings</button>
</div>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="topP">Top P</label>
<input type="number" id="topP" value="1" min="0" max="1" step="0.1">
</div>
<div class="form-group">
<label for="presencePenalty">Presence Penalty</label>
<input type="number" id="presencePenalty" value="0" min="-2" max="2" step="0.1">
</div>
</div>
<!-- ── Admin Modal ─────────────────────────── -->
<div class="modal-overlay" id="adminModal">
<div class="modal modal-wide">
<div class="modal-header">
<h2>🛡️ Admin</h2>
<button class="modal-close" id="adminCloseBtn"></button>
</div>
<div class="admin-tabs">
<button class="admin-tab active" data-tab="users">Users</button>
<button class="admin-tab" data-tab="providers">Providers</button>
<button class="admin-tab" data-tab="models">Models</button>
<button class="admin-tab" data-tab="settings">Settings</button>
<button class="admin-tab" data-tab="stats">Stats</button>
</div>
<div class="modal-body">
<div class="admin-tab-content" id="adminUsersTab">
<div id="adminUserList"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="cancelBtn">Cancel</button>
<button class="btn btn-primary" id="saveBtn">💾 Save Settings</button>
<div class="admin-tab-content" id="adminProvidersTab" style="display:none">
<div id="adminProviderList"></div>
</div>
<div class="admin-tab-content" id="adminModelsTab" style="display:none">
<div id="adminModelList"></div>
</div>
<div class="admin-tab-content" id="adminSettingsTab" style="display:none">
<label class="checkbox-label">
<input type="checkbox" id="adminRegToggle" checked> Allow registration
</label>
<button class="btn-primary btn-small" id="adminSaveSettings">Save</button>
</div>
<div class="admin-tab-content" id="adminStatsTab" style="display:none">
<div id="adminStats"></div>
</div>
</div>
</div>
</div>
<!-- Admin Panel Modal -->
<div class="modal-overlay" id="adminModal">
<div class="modal admin-modal">
<div class="modal-header">
<h2>🛡️ Admin Panel</h2>
<button class="modal-close" id="adminCloseBtn"></button>
<!-- ── Debug Modal (Ctrl+Shift+L) ──────────── -->
<div class="modal-overlay" id="debugModal">
<div class="modal debug-modal">
<div class="modal-header">
<h2>🐛 Debug Log</h2>
<button class="modal-close" onclick="closeDebugModal()"></button>
</div>
<div class="debug-tabs">
<button class="debug-tab active" data-tab="console" onclick="switchDebugTab('console')">
Console (<span id="debugConsoleCount">0</span>)
</button>
<button class="debug-tab" data-tab="network" onclick="switchDebugTab('network')">
Network (<span id="debugNetworkCount">0</span>)
</button>
<button class="debug-tab" data-tab="state" onclick="switchDebugTab('state')">State</button>
</div>
<div class="modal-body debug-modal-body">
<div class="debug-tab-content" id="debugConsoleTab">
<div class="debug-toolbar">
<label class="debug-check"><input type="checkbox" id="debugFilterErrors"> Errors only</label>
<label class="debug-check"><input type="checkbox" id="debugAutoScroll" checked> Auto-scroll</label>
</div>
<div id="debugConsoleContent" class="debug-content"></div>
</div>
<div class="modal-body">
<div class="admin-tabs">
<button class="admin-tab active" data-tab="users">Users</button>
<button class="admin-tab" data-tab="models">Models</button>
<button class="admin-tab" data-tab="providers">Providers</button>
<button class="admin-tab" data-tab="settings">Settings</button>
<button class="admin-tab" data-tab="stats">Stats</button>
</div>
<!-- Users Tab -->
<div class="admin-tab-content active" id="adminUsersTab">
<div class="admin-add-user-toggle">
<button class="btn btn-primary btn-small" id="adminShowAddUser">+ Add User</button>
</div>
<div class="admin-add-user-form" id="adminAddUserForm" style="display:none">
<div class="admin-form-row">
<input type="text" id="adminNewUsername" placeholder="Username" class="admin-input">
<input type="email" id="adminNewEmail" placeholder="Email" class="admin-input">
</div>
<div class="admin-form-row">
<input type="password" id="adminNewPassword" placeholder="Password (min 8)" class="admin-input">
<select id="adminNewRole" class="admin-role-select">
<option value="user">user</option>
<option value="admin">admin</option>
<option value="moderator">moderator</option>
</select>
</div>
<div class="admin-form-row">
<button class="btn btn-primary btn-small" id="adminCreateUserBtn">Create</button>
<button class="btn btn-secondary btn-small" id="adminCancelAddUser">Cancel</button>
</div>
</div>
<div id="adminUserList" class="admin-user-list">Loading...</div>
</div>
<!-- Reset Password Dialog (inline overlay) -->
<div class="admin-reset-dialog" id="adminResetDialog" style="display:none">
<div class="admin-reset-card">
<h3>Reset Password</h3>
<p id="adminResetTarget"></p>
<input type="password" id="adminResetNewPw" placeholder="New password (min 8)" class="admin-input">
<div class="admin-form-row">
<button class="btn btn-primary btn-small" id="adminResetConfirmBtn">Reset</button>
<button class="btn btn-secondary btn-small" id="adminResetCancelBtn">Cancel</button>
</div>
</div>
</div>
<!-- Models Tab -->
<div class="admin-tab-content" id="adminModelsTab" style="display:none">
<div class="admin-models-header">
<p class="admin-hint">Manage which models are available to users. Fetch from configured providers, then enable/disable and tag capabilities.</p>
<button class="btn btn-primary btn-small" id="adminFetchModelsBtn">🔄 Fetch Models</button>
</div>
<div id="adminModelList" class="admin-model-list">
<div class="admin-empty">No models configured. Add a provider first, then fetch models.</div>
</div>
</div>
<!-- Providers Tab -->
<div class="admin-tab-content" id="adminProvidersTab" style="display:none">
<p class="admin-hint">Global providers are available to all users. Users can also add their own.</p>
<div id="adminProviderList" class="provider-list"></div>
<div class="provider-add-toggle">
<button class="btn btn-secondary btn-small" id="adminProviderShowAddBtn">+ Add Global Provider</button>
</div>
<div class="provider-add-form" id="adminProviderAddForm" style="display:none">
<div class="form-group">
<label for="adminProviderName">Name</label>
<input type="text" id="adminProviderName" placeholder="e.g. Shared OpenAI">
</div>
<div class="form-group">
<label for="adminProviderType">Provider</label>
<select id="adminProviderType">
<option value="openai">OpenAI-compatible</option>
<option value="anthropic">Anthropic</option>
</select>
</div>
<div class="form-group">
<label for="adminProviderEndpoint">Endpoint</label>
<input type="text" id="adminProviderEndpoint" placeholder="https://api.openai.com/v1">
</div>
<div class="form-group">
<label for="adminProviderApiKey">API Key</label>
<input type="password" id="adminProviderApiKey" placeholder="sk-...">
</div>
<div class="form-group">
<label for="adminProviderDefaultModel">Default Model (optional)</label>
<input type="text" id="adminProviderDefaultModel" placeholder="gpt-4o">
</div>
<div class="admin-form-row">
<button class="btn btn-primary btn-small" id="adminProviderCreateBtn">Save</button>
<button class="btn btn-secondary btn-small" id="adminProviderCancelBtn">Cancel</button>
</div>
</div>
</div>
<!-- Settings Tab -->
<div class="admin-tab-content" id="adminSettingsTab" style="display:none">
<div class="form-group">
<label class="toggle-label">
<input type="checkbox" id="adminRegToggle" checked>
<span>Allow new user registration</span>
</label>
</div>
<div class="form-group">
<label for="adminSiteName">Site Name</label>
<input type="text" id="adminSiteName" value="Chat Switchboard">
</div>
<div class="form-group">
<label for="adminTagline">Tagline</label>
<input type="text" id="adminTagline" value="Multi-Model AI Chat">
</div>
<button class="btn btn-primary" id="adminSaveSettings">💾 Save</button>
</div>
<!-- Stats Tab -->
<div class="admin-tab-content" id="adminStatsTab" style="display:none">
<div id="adminStats" class="admin-stats">Loading...</div>
</div>
<div class="debug-tab-content" id="debugNetworkTab" style="display:none">
<div id="debugNetworkContent" class="debug-content"></div>
</div>
<div class="debug-tab-content" id="debugStateTab" style="display:none">
<div id="debugStateContent" class="debug-content"></div>
</div>
</div>
<div class="modal-footer debug-footer">
<button class="btn-primary btn-small" onclick="runDebugDiagnostics()">🔍 Diagnostics</button>
<div style="margin-left:auto;display:flex;gap:0.5rem">
<button class="btn-small" onclick="clearDebugLog()">🗑️ Clear</button>
<button class="btn-small" onclick="copyDebugLog()">📋 Copy</button>
<button class="btn-small" onclick="exportDebugLog()">📥 Export</button>
</div>
</div>
</div>
</div>
</div> <!-- /appContainer -->
<div class="toast-container" id="toastContainer"></div>
<!-- ── Auth Splash Gate ────────────────── -->
<div class="splash-gate" id="splashGate" style="display:none">
<div class="splash-card">
<div class="splash-brand">
<div class="splash-logo">🔀</div>
<h1>Chat Switchboard</h1>
<p class="splash-tagline">Multi-Model AI Chat</p>
</div>
<div class="splash-form">
<div class="auth-tabs">
<button class="auth-tab active" id="authTabLogin">Sign In</button>
<button class="auth-tab" id="authTabRegister">Register</button>
</div>
<div id="authLoginForm">
<div class="form-group">
<label for="authLogin">Username or Email</label>
<input type="text" id="authLogin" placeholder="jeff or jeff@example.com" autocomplete="username">
</div>
<div class="form-group">
<label for="authPassword">Password</label>
<input type="password" id="authPassword" placeholder="••••••••" autocomplete="current-password">
</div>
</div>
<div id="authRegisterForm" style="display:none;">
<div class="form-group">
<label for="authUsername">Username</label>
<input type="text" id="authUsername" placeholder="Choose a username" autocomplete="username">
</div>
<div class="form-group">
<label for="authEmail">Email</label>
<input type="email" id="authEmail" placeholder="you@example.com" autocomplete="email">
</div>
<div class="form-group">
<label for="authRegPassword">Password</label>
<input type="password" id="authRegPassword" placeholder="At least 8 characters" autocomplete="new-password">
</div>
</div>
<div class="auth-error" id="authError"></div>
<div class="splash-actions">
<button class="btn btn-primary btn-full" id="authLoginBtn">Sign In</button>
<button class="btn btn-primary btn-full" id="authRegisterBtn" style="display:none;">Create Account</button>
<div class="splash-divider"><span>or</span></div>
<button class="btn btn-secondary btn-full" id="authSkipBtn">Use Offline</button>
</div>
</div>
</div>
</div>
<!-- Debug Modal (Ctrl+Shift+L) -->
<div class="modal-overlay" id="debugModal">
<div class="modal debug-modal">
<div class="modal-header">
<h2>🐛 Debug Log</h2>
<button class="modal-close" onclick="closeDebugModal()"></button>
</div>
<div class="debug-tabs">
<button class="debug-tab active" data-tab="console" onclick="switchDebugTab('console')">
Console (<span id="debugConsoleCount">0</span>)
</button>
<button class="debug-tab" data-tab="network" onclick="switchDebugTab('network')">
Network (<span id="debugNetworkCount">0</span>)
</button>
<button class="debug-tab" data-tab="state" onclick="switchDebugTab('state')">
State
</button>
</div>
<div class="modal-body debug-modal-body">
<!-- Console Tab -->
<div class="debug-tab-content" id="debugConsoleTab">
<div class="debug-toolbar">
<label class="debug-check">
<input type="checkbox" id="debugFilterErrors"> Errors only
</label>
<label class="debug-check">
<input type="checkbox" id="debugAutoScroll" checked> Auto-scroll
</label>
</div>
<div id="debugConsoleContent" class="debug-content"></div>
</div>
<!-- Network Tab -->
<div class="debug-tab-content" id="debugNetworkTab" style="display:none">
<div id="debugNetworkContent" class="debug-content"></div>
</div>
<!-- State Tab -->
<div class="debug-tab-content" id="debugStateTab" style="display:none">
<div id="debugStateContent" class="debug-content"></div>
</div>
</div>
<div class="modal-footer debug-footer">
<button class="btn btn-primary btn-small" onclick="runDebugDiagnostics()">🔍 Run Diagnostics</button>
<div style="margin-left: auto; display: flex; gap: 0.5rem;">
<button class="btn btn-secondary btn-small" onclick="clearDebugLog()">🗑️ Clear</button>
<button class="btn btn-secondary btn-small" onclick="copyDebugLog()">📋 Copy</button>
<button class="btn btn-secondary btn-small" onclick="exportDebugLog()">📥 Export</button>
</div>
</div>
</div>
</div>
<div class="toast-container" id="toastContainer"></div>
<script src="js/debug.js?v=0.4.1"></script>
<script src="js/storage.js?v=0.4.1"></script>
<script src="js/backend.js?v=0.4.1"></script>
<script src="js/state.js?v=0.4.1"></script>
<script src="js/api.js?v=0.4.1"></script>
<script src="js/ui.js?v=0.4.1"></script>
<script src="js/admin.js?v=0.4.1"></script>
<script src="js/app.js?v=0.4.1"></script>
<script src="js/debug.js?v=0.5.0"></script>
<script src="js/api.js?v=0.5.0"></script>
<script src="js/ui.js?v=0.5.0"></script>
<script src="js/app.js?v=0.5.0"></script>
</body>
</html>

View File

@@ -1,251 +1,240 @@
// ==========================================
// API Functions
// Chat Switchboard API Client (v0.5.0)
// ==========================================
// Backend-only mode. Handles auth tokens and
// all HTTP calls. No offline fallback.
// ==========================================
async function fetchModels(showInModal = true) {
const btn = showInModal
? document.getElementById('fetchModelsBtn')
: document.getElementById('quickFetchBtn');
if (btn) {
btn.dataset.originalText = btn.innerHTML;
btn.innerHTML = '⏳';
btn.disabled = true;
}
const API = {
accessToken: null,
refreshToken: null,
user: null,
_refreshing: null,
try {
let models = [];
// ── Bootstrap ────────────────────────────
if (Backend.isManaged) {
// Managed mode: fetch admin-curated enabled models
const data = await Backend.listEnabledModels();
models = (data.models || []).map(m => ({
id: m.model_id || m.id,
owned_by: m.provider_name || m.provider || null,
config_id: m.config_id || null
}));
// Fallback: if no curated models, try raw aggregation
if (models.length === 0) {
const raw = await Backend.listAllModels();
models = (raw.models || []).map(m => ({
id: m.id || m.name,
owned_by: m.owned_by || m.provider || null,
config_id: m.config_id || null
}));
loadTokens() {
try {
const saved = JSON.parse(localStorage.getItem('sb_auth') || 'null');
if (saved) {
this.accessToken = saved.accessToken || null;
this.refreshToken = saved.refreshToken || null;
this.user = saved.user || null;
}
} else {
// Unmanaged mode: direct provider call
const endpoint = showInModal
? document.getElementById('apiEndpoint').value.trim()
: State.settings.apiEndpoint;
const apiKey = showInModal
? document.getElementById('apiKey').value.trim()
: State.settings.apiKey;
} catch (e) { /* corrupt storage */ }
},
if (!endpoint || !apiKey) {
showToast('⚠️ Configure API endpoint and key first', 'warning');
if (!showInModal) openSettings();
return;
saveTokens() {
localStorage.setItem('sb_auth', JSON.stringify({
accessToken: this.accessToken,
refreshToken: this.refreshToken,
user: this.user
}));
},
clearTokens() {
this.accessToken = null;
this.refreshToken = null;
this.user = null;
localStorage.removeItem('sb_auth');
},
get isAuthed() { return !!this.accessToken; },
get isAdmin() { return this.user?.role === 'admin'; },
// ── Auth ─────────────────────────────────
async health() {
const resp = await fetch('/api/v1/health', { signal: AbortSignal.timeout(8000) });
if (!resp.ok) throw new Error(`Health: ${resp.status}`);
return resp.json();
},
async login(login, password) {
const data = await this._post('/api/v1/auth/login', { login, password }, true);
this._setAuth(data);
return data;
},
async register(username, email, password) {
const data = await this._post('/api/v1/auth/register', { username, email, password }, true);
this._setAuth(data);
return data;
},
async logout() {
try { await this._post('/api/v1/auth/logout', { refresh_token: this.refreshToken }, true); }
catch (e) { /* best effort */ }
this.clearTokens();
},
async refresh() {
if (this._refreshing) return this._refreshing;
this._refreshing = (async () => {
try {
const data = await this._post('/api/v1/auth/refresh', { refresh_token: this.refreshToken }, true);
this._setAuth(data);
return true;
} catch (e) {
this.clearTokens();
return false;
} finally {
this._refreshing = null;
}
})();
return this._refreshing;
},
const response = await fetch(endpoint.replace(/\/$/, '') + '/models', {
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
}
});
_setAuth(data) {
this.accessToken = data.access_token;
this.refreshToken = data.refresh_token;
this.user = data.user;
this.saveTokens();
},
if (!response.ok) throw new Error(`API Error: ${response.status}`);
// ── Chats ────────────────────────────────
const data = await response.json();
const raw = data.data || data.models || data || [];
models = Array.isArray(raw)
? raw.map(m => ({
id: m.id || m.name || m,
owned_by: m.owned_by || null
}))
: [];
}
listChats(page = 1, perPage = 100) {
return this._get(`/api/v1/chats?page=${page}&per_page=${perPage}`);
},
createChat(title, model, systemPrompt) {
const body = { title };
if (model) body.model = model;
if (systemPrompt) body.system_prompt = systemPrompt;
return this._post('/api/v1/chats', body);
},
getChat(id) { return this._get(`/api/v1/chats/${id}`); },
updateChat(id, updates) { return this._put(`/api/v1/chats/${id}`, updates); },
deleteChat(id) { return this._del(`/api/v1/chats/${id}`); },
State.models = models.sort((a, b) => a.id.localeCompare(b.id));
saveModels();
// ── Messages ─────────────────────────────
if (showInModal) {
populateModelSelect(document.getElementById('model'));
}
updateQuickModelSelector();
showToast(`✅ Loaded ${State.models.length} models`, 'success');
} catch (error) {
console.error('Fetch models error:', error);
showToast(`❌ Failed: ${error.message}`, 'error');
} finally {
if (btn) {
btn.innerHTML = btn.dataset.originalText || '🔄';
btn.disabled = false;
}
}
}
listMessages(chatId, page = 1, perPage = 200) {
return this._get(`/api/v1/chats/${chatId}/messages?page=${page}&per_page=${perPage}`);
},
async function sendApiRequest(messages) {
const chatContainer = document.getElementById('chatMessages');
// ── Completions ──────────────────────────
// Add typing indicator
const typingDiv = document.createElement('div');
typingDiv.className = 'message assistant';
typingDiv.id = 'typingIndicator';
typingDiv.innerHTML = `
<div class="message-content">
<div class="message-avatar">🤖</div>
<div class="message-body">
<div class="message-role">Assistant</div>
<div class="typing-indicator"><span></span><span></span><span></span></div>
</div>
</div>
`;
chatContainer.appendChild(typingDiv);
scrollToBottom();
async streamCompletion(chatId, content, model, signal) {
const body = { chat_id: chatId, content, stream: true };
if (model) body.model = model;
State.isGenerating = true;
State.abortController = new AbortController();
document.getElementById('stopBtn').classList.add('visible');
document.getElementById('sendBtn').disabled = true;
const model = document.getElementById('quickModel').value || State.settings.model;
const userContent = messages[messages.length - 1]?.content || '';
try {
let response;
if (Backend.isManaged) {
// ── Managed mode: route through backend proxy ──
// Backend loads conversation from DB, persists both messages.
response = await Backend.streamCompletion(
State.currentChatId,
userContent,
model,
State.abortController.signal
);
} else {
// ── Unmanaged mode: direct provider call ──
const endpoint = State.settings.apiEndpoint.replace(/\/$/, '') + '/chat/completions';
response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${State.settings.apiKey}`
},
body: JSON.stringify({
model,
messages: messages.map(m => ({ role: m.role, content: m.content })),
max_tokens: State.settings.maxTokens,
temperature: State.settings.temperature,
top_p: State.settings.topP,
presence_penalty: State.settings.presencePenalty,
stream: State.settings.stream
}),
signal: State.abortController.signal
});
if (!response.ok) {
const error = await response.text();
throw new Error(`API Error: ${response.status} - ${error}`);
}
}
document.getElementById('typingIndicator')?.remove();
let assistantContent = '';
const isStream = Backend.isManaged ? true : State.settings.stream;
if (isStream) {
const assistantDiv = document.createElement('div');
assistantDiv.className = 'message assistant';
assistantDiv.innerHTML = `
<div class="message-content">
<div class="message-avatar">🤖</div>
<div class="message-body">
<div class="message-header">
<span class="message-role">Assistant</span>
</div>
<div class="message-text" id="streamingContent"></div>
</div>
</div>
`;
chatContainer.appendChild(assistantDiv);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let sseBuffer = ''; // Buffer for incomplete SSE lines
while (true) {
const { done, value } = await reader.read();
if (done) break;
sseBuffer += decoder.decode(value, { stream: true });
const lines = sseBuffer.split('\n');
// Keep the last (potentially incomplete) line in the buffer
sseBuffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') continue;
try {
const parsed = JSON.parse(data);
const content = parsed.choices?.[0]?.delta?.content || '';
assistantContent += content;
document.getElementById('streamingContent').innerHTML = formatMessage(assistantContent);
scrollToBottom();
} catch (e) {}
}
}
}
} else {
const data = await response.json();
assistantContent = data.choices?.[0]?.message?.content || 'No response';
}
messages.push({
role: 'assistant',
content: assistantContent,
timestamp: new Date().toISOString()
let resp = await fetch('/api/v1/chat/completions', {
method: 'POST',
headers: this._authHeaders(),
body: JSON.stringify(body),
signal
});
// In unmanaged mode, persist locally + to backend if connected
await updateChat(State.currentChatId, { messages, model });
if (!Backend.isManaged) {
await onAssistantResponse(assistantContent, model);
if (resp.status === 401 && this.refreshToken) {
if (await this.refresh()) {
resp = await fetch('/api/v1/chat/completions', {
method: 'POST',
headers: this._authHeaders(),
body: JSON.stringify(body),
signal
});
}
}
if (!isStream) {
renderMessages(messages);
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${resp.status}`);
}
return resp;
},
updateRegenerateButton(messages);
// ── Models ───────────────────────────────
} catch (error) {
document.getElementById('typingIndicator')?.remove();
listEnabledModels() { return this._get('/api/v1/models/enabled'); },
listAllModels() { return this._get('/api/v1/models'); },
if (error.name === 'AbortError') {
showToast('⚠️ Generation stopped', 'warning');
} else {
console.error('API Error:', error);
showToast(`${error.message}`, 'error');
// ── API Configs (user providers) ─────────
listConfigs() { return this._get('/api/v1/api-configs'); },
createConfig(name, provider, endpoint, apiKey, modelDefault) {
return this._post('/api/v1/api-configs', {
name, provider, endpoint, api_key: apiKey, model_default: modelDefault
});
},
deleteConfig(id) { return this._del(`/api/v1/api-configs/${id}`); },
// ── Profile & Settings ───────────────────
getProfile() { return this._get('/api/v1/profile'); },
updateProfile(updates) { return this._put('/api/v1/profile', updates); },
changePassword(current, newPw) {
return this._post('/api/v1/profile/password', { current_password: current, new_password: newPw });
},
getSettings() { return this._get('/api/v1/settings'); },
updateSettings(settings) { return this._put('/api/v1/settings', settings); },
// ── Admin ────────────────────────────────
adminListUsers(p, pp) { return this._get(`/api/v1/admin/users?page=${p||1}&per_page=${pp||50}`); },
adminCreateUser(username, email, password, role) {
return this._post('/api/v1/admin/users', { username, email, password, role });
},
adminResetPassword(id, pw) { return this._post(`/api/v1/admin/users/${id}/reset-password`, { new_password: pw }); },
adminUpdateRole(id, role) { return this._put(`/api/v1/admin/users/${id}/role`, { role }); },
adminToggleActive(id, active) { return this._put(`/api/v1/admin/users/${id}/active`, { is_active: active }); },
adminDeleteUser(id) { return this._del(`/api/v1/admin/users/${id}`); },
adminGetSettings() { return this._get('/api/v1/admin/settings'); },
adminUpdateSetting(key, value) { return this._put(`/api/v1/admin/settings/${key}`, value); },
adminGetStats() { return this._get('/api/v1/admin/stats'); },
adminListGlobalConfigs() { return this._get('/api/v1/admin/configs'); },
adminCreateGlobalConfig(name, provider, endpoint, apiKey, modelDefault) {
return this._post('/api/v1/admin/configs', {
name, provider, endpoint, api_key: apiKey, model_default: modelDefault
});
},
adminDeleteGlobalConfig(id) { return this._del(`/api/v1/admin/configs/${id}`); },
adminListModels() { return this._get('/api/v1/admin/models'); },
adminFetchModels() { return this._post('/api/v1/admin/models/fetch', {}); },
adminUpdateModel(id, updates) { return this._put(`/api/v1/admin/models/${id}`, updates); },
adminDeleteModel(id) { return this._del(`/api/v1/admin/models/${id}`); },
// ── HTTP Internals ───────────────────────
_authHeaders() {
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.accessToken}`
};
},
async _get(path) { return this._authed(path); },
async _post(path, body, skipAuth) { return skipAuth ? this._raw(path, 'POST', body) : this._authed(path, 'POST', body); },
async _put(path, body) { return this._authed(path, 'PUT', body); },
async _del(path) { return this._authed(path, 'DELETE'); },
async _authed(path, method = 'GET', body) {
try {
return await this._raw(path, method, body, true);
} catch (e) {
if (e.status === 401 && this.refreshToken) {
if (await this.refresh()) {
return this._raw(path, method, body, true);
}
}
throw e;
}
} finally {
State.isGenerating = false;
State.abortController = null;
document.getElementById('stopBtn').classList.remove('visible');
document.getElementById('sendBtn').disabled = false;
},
async _raw(path, method = 'GET', body, auth = false) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (auth) opts.headers['Authorization'] = `Bearer ${this.accessToken}`;
if (body) opts.body = JSON.stringify(body);
const resp = await fetch(path, opts);
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
const err = new Error(data.error || `HTTP ${resp.status}`);
err.status = resp.status;
throw err;
}
return resp.json();
}
}
function stopGeneration() {
if (State.abortController) {
State.abortController.abort();
State.abortController = null;
}
State.isGenerating = false;
document.getElementById('stopBtn').classList.remove('visible');
document.getElementById('sendBtn').disabled = false;
}
};

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

View File

@@ -199,34 +199,32 @@ const DebugLog = {
getStateSnapshot() {
const snap = {};
// Backend state (redact tokens)
if (typeof Backend !== 'undefined') {
snap.backend = {
baseUrl: Backend.baseUrl || '(empty)',
hasAccessToken: !!Backend.accessToken,
hasRefreshToken: !!Backend.refreshToken,
user: Backend.user ? {
username: Backend.user.username,
role: Backend.user.role,
display_name: Backend.user.display_name
// API client state (redact tokens)
if (typeof API !== 'undefined') {
snap.api = {
hasAccessToken: !!API.accessToken,
hasRefreshToken: !!API.refreshToken,
user: API.user ? {
username: API.user.username,
role: API.user.role,
display_name: API.user.display_name
} : null,
isConnected: Backend.isConnected,
isManaged: Backend.isManaged
isAuthed: API.isAuthed,
isAdmin: API.isAdmin
};
}
// App state
if (typeof State !== 'undefined') {
snap.state = {
chatCount: State.chats?.length || 0,
currentChatId: State.currentChatId,
modelCount: State.models?.length || 0,
isGenerating: State.isGenerating,
if (typeof App !== 'undefined') {
snap.app = {
chatCount: App.chats?.length || 0,
currentChatId: App.currentChatId,
modelCount: App.models?.length || 0,
isGenerating: App.isGenerating,
settings: {
model: State.settings?.model || '?',
stream: State.settings?.stream,
apiEndpoint: State.settings?.apiEndpoint ? '(set)' : '(empty)',
apiKey: State.settings?.apiKey ? '(set)' : '(empty)',
model: App.settings?.model || '?',
stream: App.settings?.stream,
showThinking: App.settings?.showThinking
}
};
}

View File

@@ -1,515 +1,475 @@
// ==========================================
// UI Functions
// Chat Switchboard UI (v0.5.0)
// ==========================================
function updateSettingsUI() {
document.getElementById('apiEndpoint').value = State.settings.apiEndpoint || '';
document.getElementById('apiKey').value = State.settings.apiKey || '';
const modelSelect = document.getElementById('model');
const modelCustom = document.getElementById('modelCustom');
const savedModel = State.settings.model || 'gpt-3.5-turbo';
populateModelSelect(modelSelect);
const optionExists = Array.from(modelSelect.options).some(opt => opt.value === savedModel);
if (optionExists) {
modelSelect.value = savedModel;
modelCustom.value = '';
} else {
modelSelect.value = '';
modelCustom.value = savedModel;
}
document.getElementById('streamResponse').checked = State.settings.stream;
document.getElementById('saveHistory').checked = State.settings.saveHistory;
document.getElementById('showThinking').checked = State.settings.showThinking;
document.getElementById('systemPrompt').value = State.settings.systemPrompt || '';
document.getElementById('maxTokens').value = State.settings.maxTokens;
document.getElementById('temperature').value = State.settings.temperature;
document.getElementById('topP').value = State.settings.topP;
document.getElementById('presencePenalty').value = State.settings.presencePenalty;
}
const UI = {
function populateModelSelect(selectElement) {
const currentValue = selectElement.value;
selectElement.innerHTML = '<option value="">-- Select a model --</option>';
State.models.forEach(model => {
const option = document.createElement('option');
option.value = model.id;
option.textContent = model.id + (model.owned_by ? ` (${model.owned_by})` : '');
selectElement.appendChild(option);
});
if (currentValue) {
selectElement.value = currentValue;
}
}
// ── Chat List ────────────────────────────
function updateQuickModelSelector() {
const quickSelect = document.getElementById('quickModel');
const currentModel = State.settings.model;
quickSelect.innerHTML = '';
if (State.models.length === 0) {
quickSelect.innerHTML = '<option value="">-- Fetch models first --</option>';
if (currentModel) {
const opt = document.createElement('option');
opt.value = currentModel;
opt.textContent = currentModel;
quickSelect.appendChild(opt);
quickSelect.value = currentModel;
renderChatList() {
const el = document.getElementById('chatHistory');
if (App.chats.length === 0) {
el.innerHTML = '<div class="empty-hint">No conversations yet</div>';
return;
}
return;
}
State.models.forEach(model => {
const option = document.createElement('option');
option.value = model.id;
option.textContent = model.id;
quickSelect.appendChild(option);
});
if (currentModel) {
const exists = Array.from(quickSelect.options).some(opt => opt.value === currentModel);
if (exists) {
quickSelect.value = currentModel;
} else {
const opt = document.createElement('option');
opt.value = currentModel;
opt.textContent = currentModel + ' (custom)';
quickSelect.insertBefore(opt, quickSelect.firstChild);
quickSelect.value = currentModel;
}
}
}
el.innerHTML = App.chats.map(c => `
<div class="chat-item ${c.id === App.currentChatId ? 'active' : ''}"
onclick="selectChat('${c.id}')">
<span class="chat-item-title">${esc(c.title)}</span>
<button class="chat-item-delete" onclick="deleteChat('${c.id}');event.stopPropagation();"
title="Delete">✕</button>
</div>
`).join('');
},
function openSettings() {
updateSettingsUI();
updateProfileSection();
updateProviderSection();
document.getElementById('settingsModal').classList.add('active');
}
// ── Messages ─────────────────────────────
function updateProfileSection() {
const profileSection = document.getElementById('profileSection');
const unmanagedSettings = document.getElementById('unmanagedSettings');
const modeBadge = document.getElementById('settingsModeBadge');
if (Backend.isManaged) {
profileSection.style.display = '';
unmanagedSettings.style.display = 'none';
modeBadge.textContent = '🔗 Managed Mode — API keys are configured by your admin or in Providers below';
modeBadge.className = 'settings-mode-badge mode-managed';
loadProfileIntoSettings();
} else {
profileSection.style.display = 'none';
unmanagedSettings.style.display = '';
modeBadge.textContent = '📱 Offline Mode — Configure your own API endpoint and key';
modeBadge.className = 'settings-mode-badge mode-unmanaged';
}
document.getElementById('profileChangePwForm').style.display = 'none';
}
async function loadProfileIntoSettings() {
try {
const profile = await Backend.getProfile();
document.getElementById('profileDisplayName').value = profile.display_name || '';
document.getElementById('profileEmail').value = profile.email || '';
} catch (e) {
console.warn('Failed to load profile:', e);
}
}
async function saveProfile() {
if (!Backend.isManaged) return;
const displayName = document.getElementById('profileDisplayName').value.trim();
const email = document.getElementById('profileEmail').value.trim();
const updates = {};
if (displayName !== (Backend.user.display_name || '')) {
updates.display_name = displayName || null;
}
if (email && email !== Backend.user.email) {
updates.email = email;
}
if (Object.keys(updates).length === 0) return;
try {
const profile = await Backend.updateProfile(updates);
// Update local user state
if (profile.display_name !== undefined) Backend.user.display_name = profile.display_name;
if (profile.email) Backend.user.email = profile.email;
Backend.save();
showToast('✅ Profile updated', 'success');
} catch (e) {
showToast('❌ ' + e.message, 'error');
}
}
async function handleChangePassword() {
const current = document.getElementById('profileCurrentPw').value;
const newPw = document.getElementById('profileNewPw').value;
if (!current || !newPw) {
showToast('⚠️ Fill in both fields', 'warning');
return;
}
if (newPw.length < 8) {
showToast('⚠️ New password must be at least 8 characters', 'warning');
return;
}
try {
await Backend.changePassword(current, newPw);
showToast('✅ Password updated', 'success');
document.getElementById('profileCurrentPw').value = '';
document.getElementById('profileNewPw').value = '';
document.getElementById('profileChangePwForm').style.display = 'none';
} catch (e) {
showToast('❌ ' + e.message, 'error');
}
}
// ── API Providers (managed mode) ────────────
function updateProviderSection() {
const managed = document.getElementById('managedProviders');
if (Backend.isManaged) {
managed.style.display = '';
loadProviderList();
} else {
managed.style.display = 'none';
}
}
async function loadProviderList() {
const container = document.getElementById('providerList');
container.innerHTML = '<div class="admin-loading">Loading...</div>';
try {
const data = await Backend.listConfigs();
const configs = data.configs || data.data || data || [];
const list = Array.isArray(configs) ? configs : [];
if (list.length === 0) {
container.innerHTML = '<div class="provider-empty">No providers configured. Add one to start chatting.</div>';
renderMessages(messages) {
const el = document.getElementById('chatMessages');
if (!messages || messages.length === 0) {
this.showEmptyState();
return;
}
container.innerHTML = list.map(c => `
<div class="provider-row">
<div class="provider-info">
<span class="provider-name">${escapeHtmlUI(c.name)}</span>
<span class="provider-meta">${escapeHtmlUI(c.provider)} · ${escapeHtmlUI(c.model_default || 'no default')}</span>
</div>
<div class="provider-actions">
<span class="admin-badge admin-badge-active">${c.has_key ? '🔑' : '⚠️'}</span>
${c.user_id ? `<button class="btn btn-small btn-danger" onclick="deleteProvider('${c.id}', '${escapeHtmlUI(c.name)}')">Remove</button>` : '<span class="admin-badge admin-badge-moderator">global</span>'}
</div>
</div>
`).join('');
} catch (e) {
container.innerHTML = '<div class="admin-error">Failed to load providers: ' + escapeHtmlUI(e.message) + '</div>';
}
}
el.innerHTML = messages
.filter(m => m.role !== 'system')
.map((m, i) => this._messageHTML(m, i))
.join('');
this._scrollToBottom();
},
function showProviderForm() {
document.getElementById('providerAddForm').style.display = '';
document.getElementById('providerShowAddBtn').style.display = 'none';
// Set default endpoint based on provider type
updateProviderEndpointHint();
}
function hideProviderForm() {
document.getElementById('providerAddForm').style.display = 'none';
document.getElementById('providerShowAddBtn').style.display = '';
clearProviderForm();
}
function clearProviderForm() {
document.getElementById('providerName').value = '';
document.getElementById('providerType').value = 'openai';
document.getElementById('providerEndpoint').value = '';
document.getElementById('providerApiKey').value = '';
document.getElementById('providerDefaultModel').value = '';
}
function updateProviderEndpointHint() {
const type = document.getElementById('providerType').value;
const endpoint = document.getElementById('providerEndpoint');
if (!endpoint.value) {
endpoint.placeholder = type === 'anthropic'
? 'https://api.anthropic.com'
: 'https://api.openai.com/v1';
}
}
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 modelDefault = document.getElementById('providerDefaultModel').value.trim();
if (!name || !endpoint || !apiKey) {
showToast('⚠️ Name, endpoint, and API key are required', 'warning');
return;
}
try {
await Backend.createConfig(name, provider, endpoint, apiKey, modelDefault || null);
showToast('✅ Provider added', 'success');
hideProviderForm();
loadProviderList();
fetchModels(false); // Refresh model list
} catch (e) {
showToast('❌ ' + e.message, 'error');
}
}
async function deleteProvider(configId, name) {
if (!confirm('Remove provider "' + name + '"?')) return;
try {
await Backend.deleteConfig(configId);
showToast('✅ Provider removed', 'success');
loadProviderList();
fetchModels(false);
} catch (e) {
showToast('❌ ' + e.message, 'error');
}
}
function escapeHtmlUI(str) {
const div = document.createElement('div');
div.textContent = str || '';
return div.innerHTML;
}
function closeSettings() {
document.getElementById('settingsModal').classList.remove('active');
}
function toggleSidebar() {
document.getElementById('sidebar').classList.toggle('collapsed');
}
function renderChatHistory() {
const container = document.getElementById('chatHistory');
if (State.chats.length === 0) {
container.innerHTML = '<div style="padding: 1rem; text-align: center; color: var(--text-secondary); font-size: 0.875rem;">No chat history</div>';
return;
}
container.innerHTML = State.chats.map(chat => `
<div class="chat-history-item ${chat.id === State.currentChatId ? 'active' : ''}"
onclick="loadChat('${chat.id}')">
<span class="title">${escapeHtml(chat.title)}</span>
<div class="item-actions">
<button class="item-btn delete" onclick="handleDeleteChat('${chat.id}', event)" title="Delete">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</div>
</div>
`).join('');
}
function renderMessages(messages) {
const container = document.getElementById('chatMessages');
if (!messages || messages.length === 0) {
newChat();
return;
}
container.innerHTML = messages
.filter(m => m.role !== 'system')
.map((msg, idx) => createMessageHTML(msg, idx))
.join('');
scrollToBottom();
}
function createMessageHTML(message, index) {
const isUser = message.role === 'user';
const time = message.timestamp ? new Date(message.timestamp).toLocaleTimeString() : '';
const formattedContent = formatMessage(message.content);
return `
<div class="message ${message.role}" data-index="${index}">
<div class="message-content">
<div class="message-avatar">${isUser ? '👤' : '🤖'}</div>
<div class="message-body">
<div class="message-header">
<span class="message-role">${isUser ? 'You' : 'Assistant'}</span>
<div class="message-actions">
<button class="message-action-btn" onclick="copyMessage(${index})" title="Copy">📋 Copy</button>
_messageHTML(msg, index) {
const isUser = msg.role === 'user';
const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString() : '';
return `
<div class="message ${msg.role}">
<div class="message-inner">
<div class="message-avatar">${isUser ? '👤' : '🤖'}</div>
<div class="message-body">
<div class="message-head">
<span class="message-role">${isUser ? 'You' : 'Assistant'}</span>
${time ? `<span class="message-time">${time}</span>` : ''}
<button class="message-copy" onclick="UI.copyMessage(${index})" title="Copy">📋</button>
</div>
<div class="message-text">${formatMessage(msg.content)}</div>
</div>
${time ? `<div class="message-time">${time}</div>` : ''}
<div class="message-text">${formattedContent}</div>
</div>
</div>
</div>
`;
}
</div>`;
},
function formatMessage(content) {
let formatted = content;
const thinkingBlocks = [];
showEmptyState() {
document.getElementById('chatMessages').innerHTML = `
<div class="empty-state">
<div class="empty-icon">💬</div>
<h2>Start a Conversation</h2>
<p>Type a message below to begin</p>
</div>`;
},
// Extract thinking blocks FIRST (before escaping)
if (State.settings.showThinking) {
formatted = formatted.replace(/<thinking>([\s\S]*?)<\/thinking>/gi, (match, thinkingContent) => {
const blockId = 'thinking-' + Math.random().toString(36).substr(2, 9);
// Store raw content; we'll escape it when reinserting
thinkingBlocks.push({ id: blockId, content: thinkingContent.trim() });
return `__THINKPLACEHOLDER_${blockId}__`;
});
} else {
formatted = formatted.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '');
}
// ── Streaming ────────────────────────────
// Escape HTML for the entire string (placeholders are plain text, safe to escape)
formatted = escapeHtml(formatted);
async streamResponse(response, messages) {
const container = document.getElementById('chatMessages');
// Restore thinking blocks with properly-escaped content (single escape only)
for (const block of thinkingBlocks) {
const escapedContent = escapeHtml(block.content).replace(/\n/g, '<br>');
formatted = formatted.replace(`__THINKPLACEHOLDER_${block.id}__`, `
<div class="thinking-block" id="${block.id}">
<div class="thinking-header" onclick="toggleThinkingBlock('${block.id}')">
<span class="thinking-toggle">▼</span>
<span class="thinking-label">💭 Thinking</span>
// Remove typing indicator if present
document.getElementById('typingIndicator')?.remove();
// Create streaming message element
const div = document.createElement('div');
div.className = 'message assistant';
div.innerHTML = `
<div class="message-inner">
<div class="message-avatar">🤖</div>
<div class="message-body">
<div class="message-head">
<span class="message-role">Assistant</span>
</div>
<div class="message-text" id="streamContent"></div>
</div>
<div class="thinking-content">${escapedContent}</div>
</div>
`);
}
</div>`;
container.appendChild(div);
// Code blocks with copy button
formatted = formatted.replace(/```(\w*)\n?([\s\S]*?)```/g, (match, lang, code) => {
const codeId = 'code-' + Math.random().toString(36).substr(2, 9);
return `<pre><code id="${codeId}" class="language-${lang}">${code.trim()}</code><button class="copy-code-btn" onclick="copyCode('${codeId}')">Copy</button></pre>`;
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let content = '';
// Inline code
formatted = formatted.replace(/`([^`]+)`/g, '<code>$1</code>');
while (true) {
const { done, value } = await reader.read();
if (done) break;
// Bold
formatted = formatted.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
// Italic
formatted = formatted.replace(/\*([^*]+)\*/g, '<em>$1</em>');
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') continue;
// Line breaks (but not inside pre/code)
formatted = formatted.replace(/\n/g, '<br>');
try {
const parsed = JSON.parse(data);
const delta = parsed.choices?.[0]?.delta?.content || '';
if (delta) {
content += delta;
document.getElementById('streamContent').innerHTML = formatMessage(content);
this._scrollToBottom();
}
} catch (e) { /* partial JSON */ }
}
}
return formatted;
}
return content;
},
function toggleThinkingBlock(blockId) {
const block = document.getElementById(blockId);
if (block) {
block.classList.toggle('collapsed');
}
}
// ── Model Selector ───────────────────────
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
updateModelSelector() {
const sel = document.getElementById('modelSelect');
const current = App.settings.model;
sel.innerHTML = '';
function scrollToBottom() {
const container = document.getElementById('chatMessages');
container.scrollTop = container.scrollHeight;
}
if (App.models.length === 0) {
sel.innerHTML = '<option value="">No models loaded</option>';
} else {
App.models.forEach(m => {
const opt = document.createElement('option');
opt.value = m.id;
opt.textContent = m.id + (m.provider ? ` (${m.provider})` : '');
sel.appendChild(opt);
});
}
function updateRegenerateButton(messages) {
const btn = document.getElementById('regenerateBtn');
const hasAssistantMessage = messages && messages.some(m => m.role === 'assistant');
btn.style.display = hasAssistantMessage ? 'flex' : 'none';
}
if (current) {
const exists = [...sel.options].some(o => o.value === current);
if (exists) {
sel.value = current;
} else if (current) {
const opt = document.createElement('option');
opt.value = current;
opt.textContent = current + ' (custom)';
sel.insertBefore(opt, sel.firstChild);
sel.value = current;
}
}
function showToast(message, type = 'success') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `
<span>${message}</span>
<button class="toast-close" onclick="this.parentElement.remove()">✕</button>
`;
container.appendChild(toast);
setTimeout(() => toast.remove(), 5000);
}
// Also update settings modal model selector if open
const settingsSel = document.getElementById('settingsModel');
if (settingsSel) {
settingsSel.innerHTML = sel.innerHTML;
if (current) settingsSel.value = current;
}
},
// Message actions
function copyMessage(index) {
const chat = getCurrentChat();
if (chat && chat.messages[index]) {
navigator.clipboard.writeText(chat.messages[index].content)
.then(() => showToast('📋 Copied to clipboard', 'success'))
.catch(() => showToast('❌ Failed to copy', 'error'));
}
}
// ── Connection Status ────────────────────
function copyCode(codeId) {
const codeElement = document.getElementById(codeId);
if (codeElement) {
navigator.clipboard.writeText(codeElement.textContent)
.then(() => showToast('📋 Code copied', 'success'))
.catch(() => showToast('❌ Failed to copy', 'error'));
}
}
updateConnectionStatus() {
const el = document.getElementById('connectionStatus');
const name = API.user?.display_name || API.user?.username || 'Connected';
el.className = 'connection-status online';
el.innerHTML = `<span class="status-dot"></span> ${esc(name)}`;
el.title = 'Click to sign out';
},
function exportChat(format) {
const chat = getCurrentChat();
if (!chat) {
showToast('⚠️ No chat to export', 'warning');
return;
}
let content, filename, mimeType;
const title = chat.title.replace(/[^a-z0-9]/gi, '_');
switch (format) {
case 'markdown':
content = `# ${chat.title}\n\n` + chat.messages
.filter(m => m.role !== 'system')
.map(m => `## ${m.role === 'user' ? 'You' : 'Assistant'}\n\n${m.content}`)
.join('\n\n---\n\n');
filename = `${title}.md`;
mimeType = 'text/markdown';
break;
case 'json':
showAdminButton(show) {
document.getElementById('adminBtn').style.display = show ? '' : 'none';
},
// ── Generating State ─────────────────────
setGenerating(on) {
document.getElementById('stopBtn').classList.toggle('visible', on);
document.getElementById('sendBtn').disabled = on;
if (on) {
// Show typing indicator
const container = document.getElementById('chatMessages');
const typing = document.createElement('div');
typing.id = 'typingIndicator';
typing.className = 'message assistant';
typing.innerHTML = `
<div class="message-inner">
<div class="message-avatar">🤖</div>
<div class="message-body">
<div class="typing-indicator"><span></span><span></span><span></span></div>
</div>
</div>`;
container.appendChild(typing);
this._scrollToBottom();
} else {
document.getElementById('typingIndicator')?.remove();
}
},
showRegenerate(show) {
document.getElementById('regenerateBtn').style.display = show ? 'flex' : 'none';
},
// ── Toast ────────────────────────────────
toast(message, type = 'success') {
const container = document.getElementById('toastContainer');
const el = document.createElement('div');
el.className = `toast ${type}`;
el.innerHTML = `<span>${esc(message)}</span><button onclick="this.parentElement.remove()">✕</button>`;
container.appendChild(el);
setTimeout(() => el.remove(), 5000);
},
// ── Settings Modal ───────────────────────
openSettings() {
document.getElementById('settingsModel').value = App.settings.model;
document.getElementById('settingsSystemPrompt').value = App.settings.systemPrompt;
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';
UI.loadProfileIntoSettings();
UI.loadProviderList();
document.getElementById('settingsModal').classList.add('active');
},
closeSettings() {
document.getElementById('settingsModal').classList.remove('active');
},
async loadProfileIntoSettings() {
try {
const p = await API.getProfile();
document.getElementById('profileDisplayName').value = p.display_name || '';
document.getElementById('profileEmail').value = p.email || '';
} catch (e) { /* optional */ }
},
// ── Providers ────────────────────────────
async loadProviderList() {
const el = document.getElementById('providerList');
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const data = await API.listConfigs();
const list = Array.isArray(data) ? data : (data.configs || data.data || []);
if (list.length === 0) {
el.innerHTML = '<div class="empty-hint">No providers. Add one to start chatting.</div>';
return;
}
el.innerHTML = list.map(c => `
<div class="provider-row">
<div class="provider-info">
<span class="provider-name">${esc(c.name)}</span>
<span class="provider-meta">${esc(c.provider)} · ${esc(c.model_default || 'no default')}</span>
</div>
<div class="provider-actions">
${c.has_key ? '🔑' : '⚠️'}
${c.user_id
? `<button class="btn-small btn-danger" onclick="deleteProvider('${c.id}','${esc(c.name)}')">Remove</button>`
: '<span class="badge-global">global</span>'}
</div>
</div>
`).join('');
} catch (e) {
el.innerHTML = `<div class="error-hint">Failed: ${esc(e.message)}</div>`;
}
},
showProviderForm() {
document.getElementById('providerAddForm').style.display = '';
document.getElementById('providerName').value = '';
document.getElementById('providerEndpoint').value = '';
document.getElementById('providerApiKey').value = '';
document.getElementById('providerDefaultModel').value = '';
},
hideProviderForm() {
document.getElementById('providerAddForm').style.display = 'none';
},
// ── Admin Modal ──────────────────────────
openAdmin() {
document.getElementById('adminModal').classList.add('active');
UI.switchAdminTab('users');
},
closeAdmin() {
document.getElementById('adminModal').classList.remove('active');
},
async switchAdminTab(tab) {
document.querySelectorAll('.admin-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
document.querySelectorAll('.admin-tab-content').forEach(c => c.style.display = 'none');
document.getElementById(`admin${tab.charAt(0).toUpperCase() + tab.slice(1)}Tab`).style.display = '';
if (tab === 'users') await this.loadAdminUsers();
if (tab === 'stats') await this.loadAdminStats();
if (tab === 'providers') await this.loadAdminProviders();
if (tab === 'models') await this.loadAdminModels();
},
async loadAdminUsers() {
const el = document.getElementById('adminUserList');
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const resp = await API.adminListUsers();
const users = resp.data || [];
el.innerHTML = users.map(u => `
<div class="admin-user-row">
<div>
<strong>${esc(u.username)}</strong> <span class="badge-${u.role}">${u.role}</span>
${!u.is_active ? '<span class="badge-inactive">disabled</span>' : ''}
</div>
<div class="admin-user-email">${esc(u.email)}</div>
</div>
`).join('') || '<div class="empty-hint">No users</div>';
} catch (e) {
el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
}
},
async loadAdminStats() {
const el = document.getElementById('adminStats');
try {
const stats = await API.adminGetStats();
el.innerHTML = `<pre>${esc(JSON.stringify(stats, null, 2))}</pre>`;
} catch (e) {
el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
}
},
async loadAdminProviders() {
const el = document.getElementById('adminProviderList');
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const data = await API.adminListGlobalConfigs();
const list = data.configs || data.data || data || [];
el.innerHTML = (Array.isArray(list) ? list : []).map(c => `
<div class="provider-row">
<span class="provider-name">${esc(c.name)}</span>
<span class="provider-meta">${esc(c.provider)}</span>
</div>
`).join('') || '<div class="empty-hint">No global providers</div>';
} catch (e) {
el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
}
},
async loadAdminModels() {
const el = document.getElementById('adminModelList');
el.innerHTML = '<div class="loading">Loading...</div>';
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. Fetch from providers first.</div>';
} catch (e) {
el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
}
},
// ── Export ────────────────────────────────
exportChat(format) {
const chat = App.chats.find(c => c.id === App.currentChatId);
if (!chat) return UI.toast('No chat to export', 'warning');
let content, ext, mime;
const safeName = chat.title.replace(/[^a-z0-9]/gi, '_');
if (format === 'json') {
content = JSON.stringify(chat, null, 2);
filename = `${title}.json`;
mimeType = 'application/json';
break;
case 'text':
content = chat.messages
.filter(m => m.role !== 'system')
ext = 'json'; mime = 'application/json';
} else if (format === 'md') {
content = `# ${chat.title}\n\n` +
chat.messages.filter(m => m.role !== 'system')
.map(m => `## ${m.role === 'user' ? 'You' : 'Assistant'}\n\n${m.content}`)
.join('\n\n---\n\n');
ext = 'md'; mime = 'text/markdown';
} else {
content = chat.messages.filter(m => m.role !== 'system')
.map(m => `[${m.role === 'user' ? 'You' : 'Assistant'}]\n${m.content}`)
.join('\n\n');
filename = `${title}.txt`;
mimeType = 'text/plain';
break;
ext = 'txt'; mime = 'text/plain';
}
const blob = new Blob([content], { type: mime });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `${safeName}.${ext}`;
a.click();
URL.revokeObjectURL(a.href);
},
// ── Message Actions ──────────────────────
copyMessage(index) {
const chat = App.chats.find(c => c.id === App.currentChatId);
if (chat?.messages[index]) {
navigator.clipboard.writeText(chat.messages[index].content)
.then(() => UI.toast('Copied', 'success'))
.catch(() => UI.toast('Copy failed', 'error'));
}
},
// ── Helpers ──────────────────────────────
_scrollToBottom() {
const el = document.getElementById('chatMessages');
el.scrollTop = el.scrollHeight;
}
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
showToast(`📥 Exported as ${format}`, 'success');
document.getElementById('exportDropdown').classList.remove('show');
};
// ── Formatting ───────────────────────────────
function formatMessage(content) {
if (!content) return '';
const thinkingBlocks = [];
let text = content;
// Extract thinking blocks
if (App.settings.showThinking) {
text = text.replace(/<thinking>([\s\S]*?)<\/thinking>/gi, (_, inner) => {
const id = 'think-' + Math.random().toString(36).slice(2, 9);
thinkingBlocks.push({ id, content: inner.trim() });
return `__THINK_${id}__`;
});
} else {
text = text.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '');
}
// Escape HTML
text = esc(text);
// Restore thinking blocks (single-escaped)
for (const b of thinkingBlocks) {
const inner = esc(b.content).replace(/\n/g, '<br>');
text = text.replace(`__THINK_${b.id}__`,
`<details class="thinking-block"><summary>💭 Thinking</summary><div class="thinking-content">${inner}</div></details>`);
}
// Code blocks
text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => {
const id = 'code-' + Math.random().toString(36).slice(2, 9);
return `<pre><code id="${id}" class="lang-${lang}">${code.trim()}</code><button class="copy-code-btn" onclick="navigator.clipboard.writeText(document.getElementById('${id}').textContent).then(()=>UI.toast('Copied','success'))">Copy</button></pre>`;
});
// Inline formatting
text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
text = text.replace(/\*([^*]+)\*/g, '<em>$1</em>');
text = text.replace(/\n/g, '<br>');
return text;
}
function esc(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}