Upload 9 modified files from chat-switchboard-v0.5.0.zip
This commit is contained in:
1742
src/css/styles.css
1742
src/css/styles.css
File diff suppressed because it is too large
Load Diff
681
src/index.html
681
src/index.html
@@ -7,518 +7,299 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
|
<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="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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="appContainer" class="app-hidden" style="display:none">
|
|
||||||
|
<!-- ── App ─────────────────────────────────── -->
|
||||||
|
<div id="appContainer" style="display:none">
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<h1>🔀 Chat Switchboard</h1>
|
<h1 class="header-title">🔀 Chat Switchboard</h1>
|
||||||
<div class="header-actions">
|
<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
|
<span class="status-dot"></span> Offline
|
||||||
</div>
|
</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">
|
<div class="dropdown">
|
||||||
<button class="btn btn-secondary" id="exportBtn" title="Export Chat">
|
<button class="btn-icon" id="exportBtn" title="Export">📥</button>
|
||||||
📥 Export
|
<div class="dropdown-menu" id="exportDropdown">
|
||||||
</button>
|
<button onclick="UI.exportChat('md')">Markdown</button>
|
||||||
<div class="dropdown-content" id="exportDropdown">
|
<button onclick="UI.exportChat('json')">JSON</button>
|
||||||
<button class="dropdown-item" onclick="exportChat('markdown')">📄 Markdown</button>
|
<button onclick="UI.exportChat('txt')">Plain Text</button>
|
||||||
<button class="dropdown-item" onclick="exportChat('json')">📋 JSON</button>
|
|
||||||
<button class="dropdown-item" onclick="exportChat('text')">📝 Plain Text</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-secondary admin-only" id="adminBtn" title="Admin Panel" style="display:none">
|
<button class="btn-icon" id="adminBtn" title="Admin" style="display:none">🛡️</button>
|
||||||
🛡️ Admin
|
<button class="btn-icon" id="settingsBtn" title="Settings">⚙️</button>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="main-container">
|
<div class="main">
|
||||||
<aside class="sidebar" id="sidebar">
|
<aside class="sidebar" id="sidebar">
|
||||||
<div class="sidebar-header">
|
<button class="btn-primary new-chat-btn" id="newChatBtn">+ New Chat</button>
|
||||||
<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>
|
|
||||||
<div class="chat-history" id="chatHistory"></div>
|
<div class="chat-history" id="chatHistory"></div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="chat-area">
|
<main class="chat-area">
|
||||||
<div class="chat-messages" id="chatMessages">
|
<div class="chat-messages" id="chatMessages">
|
||||||
<div class="empty-state" id="emptyState">
|
<div class="empty-state">
|
||||||
<div class="empty-state-icon">
|
<div class="empty-icon">💬</div>
|
||||||
<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>
|
<h2>Start a Conversation</h2>
|
||||||
<p>Configure your API settings and start chatting</p>
|
<p>Type a message below to begin</p>
|
||||||
<button class="btn btn-primary" style="margin-top: 1.5rem;" id="configureBtn">⚙️ Configure API</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="input-area">
|
<div class="input-area">
|
||||||
<div class="input-container">
|
<div class="input-bar">
|
||||||
<div class="input-top-bar">
|
<div class="model-selector">
|
||||||
<div class="model-selector">
|
<label>Model:</label>
|
||||||
<label for="quickModel">Model:</label>
|
<select id="modelSelect"><option value="">—</option></select>
|
||||||
<select id="quickModel">
|
<button class="btn-icon btn-small" id="fetchModelsBtn" title="Refresh models">🔄</button>
|
||||||
<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>
|
</div>
|
||||||
<div class="input-wrapper">
|
</div>
|
||||||
<textarea id="messageInput" placeholder="Type your message..." rows="1"></textarea>
|
<div class="input-row">
|
||||||
<div class="input-actions">
|
<textarea id="messageInput" placeholder="Type your message..." rows="1"></textarea>
|
||||||
<div class="input-left">
|
<div class="input-actions">
|
||||||
<button class="btn btn-small btn-secondary" id="regenerateBtn" title="Regenerate last response" style="display: none;">🔄 Regenerate</button>
|
<button class="btn-regen" id="regenerateBtn" style="display:none" title="Regenerate">🔄</button>
|
||||||
</div>
|
<button class="btn-stop" id="stopBtn">⏹ Stop</button>
|
||||||
<div style="display: flex; gap: 0.5rem;">
|
<button class="btn-send" id="sendBtn">Send ▸</button>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Settings Modal -->
|
<!-- ── Auth Splash ─────────────────────────── -->
|
||||||
<div class="modal-overlay" id="settingsModal">
|
<div class="splash" id="splashGate">
|
||||||
<div class="modal">
|
<div class="splash-card">
|
||||||
<div class="modal-header">
|
<div class="splash-brand">
|
||||||
<h2>⚙️ Settings</h2>
|
<div class="splash-logo">🔀</div>
|
||||||
<button class="modal-close" id="closeModalBtn">✕</button>
|
<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>
|
||||||
<div class="modal-body">
|
<div class="form-group">
|
||||||
<!-- Mode indicator -->
|
<label>Password</label>
|
||||||
<div class="settings-mode-badge" id="settingsModeBadge"></div>
|
<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) -->
|
<!-- ── Settings Modal ──────────────────────── -->
|
||||||
<div class="settings-section" id="profileSection" style="display:none">
|
<div class="modal-overlay" id="settingsModal">
|
||||||
<h3 class="settings-section-title">Profile</h3>
|
<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">
|
<div class="form-group">
|
||||||
<label for="profileDisplayName">Display Name</label>
|
<label>Current Password</label>
|
||||||
<input type="text" id="profileDisplayName" placeholder="How you want to be called">
|
<input type="password" id="profileCurrentPw">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="profileEmail">Email</label>
|
<label>New Password</label>
|
||||||
<input type="email" id="profileEmail" placeholder="you@example.com">
|
<input type="password" id="profileNewPw">
|
||||||
</div>
|
</div>
|
||||||
<div class="profile-password-row">
|
<button class="btn-small btn-primary" id="profileSavePwBtn">Save Password</button>
|
||||||
<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">
|
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- API Settings (unmanaged / offline only) -->
|
<!-- Model & Chat -->
|
||||||
<div id="unmanagedSettings">
|
<section class="settings-section">
|
||||||
|
<h3>Chat Settings</h3>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="apiEndpoint">API Endpoint</label>
|
<label>Model</label>
|
||||||
<input type="text" id="apiEndpoint" placeholder="https://api.openai.com/v1">
|
<select id="settingsModel"></select>
|
||||||
<small>Base URL for the OpenAI-compatible API</small>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="apiKey">API Key</label>
|
<label>System Prompt</label>
|
||||||
<input type="password" id="apiKey" placeholder="sk-...">
|
<textarea id="settingsSystemPrompt" rows="3"></textarea>
|
||||||
<small>Your API key for authentication</small>
|
|
||||||
</div>
|
</div>
|
||||||
</div> <!-- /unmanagedSettings -->
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
<!-- API Providers (managed mode only) -->
|
<label>Max Tokens</label>
|
||||||
<div id="managedProviders" style="display:none">
|
<input type="number" id="settingsMaxTokens" value="4096">
|
||||||
<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>
|
</div>
|
||||||
<div class="provider-add-form" id="providerAddForm" style="display:none">
|
<div class="form-group">
|
||||||
<div class="form-group">
|
<label>Temperature</label>
|
||||||
<label for="providerName">Name</label>
|
<input type="number" id="settingsTemp" value="0.7" step="0.1" min="0" max="2">
|
||||||
<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>
|
</div>
|
||||||
<hr class="settings-divider">
|
|
||||||
</div>
|
</div>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" id="settingsThinking" checked> Show thinking blocks
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="form-group">
|
<!-- Providers -->
|
||||||
<label for="model">Default Model</label>
|
<section class="settings-section">
|
||||||
<div style="display: flex; gap: 0.5rem;">
|
<h3>API Providers</h3>
|
||||||
<select id="model" style="flex: 1;">
|
<div id="providerList"></div>
|
||||||
<option value="">-- Select or type below --</option>
|
<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>
|
</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>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="temperature">Temperature</label>
|
<label>Endpoint</label>
|
||||||
<input type="number" id="temperature" value="0.7" min="0" max="2" step="0.1">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn-primary" id="settingsSaveBtn">💾 Save Settings</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-row">
|
<!-- ── Admin Modal ─────────────────────────── -->
|
||||||
<div class="form-group">
|
<div class="modal-overlay" id="adminModal">
|
||||||
<label for="topP">Top P</label>
|
<div class="modal modal-wide">
|
||||||
<input type="number" id="topP" value="1" min="0" max="1" step="0.1">
|
<div class="modal-header">
|
||||||
</div>
|
<h2>🛡️ Admin</h2>
|
||||||
<div class="form-group">
|
<button class="modal-close" id="adminCloseBtn">✕</button>
|
||||||
<label for="presencePenalty">Presence Penalty</label>
|
</div>
|
||||||
<input type="number" id="presencePenalty" value="0" min="-2" max="2" step="0.1">
|
<div class="admin-tabs">
|
||||||
</div>
|
<button class="admin-tab active" data-tab="users">Users</button>
|
||||||
</div>
|
<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>
|
||||||
<div class="modal-footer">
|
<div class="admin-tab-content" id="adminProvidersTab" style="display:none">
|
||||||
<button class="btn btn-secondary" id="cancelBtn">Cancel</button>
|
<div id="adminProviderList"></div>
|
||||||
<button class="btn btn-primary" id="saveBtn">💾 Save Settings</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Admin Panel Modal -->
|
<!-- ── Debug Modal (Ctrl+Shift+L) ──────────── -->
|
||||||
<div class="modal-overlay" id="adminModal">
|
<div class="modal-overlay" id="debugModal">
|
||||||
<div class="modal admin-modal">
|
<div class="modal debug-modal">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2>🛡️ Admin Panel</h2>
|
<h2>🐛 Debug Log</h2>
|
||||||
<button class="modal-close" id="adminCloseBtn">✕</button>
|
<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>
|
||||||
<div class="modal-body">
|
<div class="debug-tab-content" id="debugNetworkTab" style="display:none">
|
||||||
<div class="admin-tabs">
|
<div id="debugNetworkContent" class="debug-content"></div>
|
||||||
<button class="admin-tab active" data-tab="users">Users</button>
|
</div>
|
||||||
<button class="admin-tab" data-tab="models">Models</button>
|
<div class="debug-tab-content" id="debugStateTab" style="display:none">
|
||||||
<button class="admin-tab" data-tab="providers">Providers</button>
|
<div id="debugStateContent" class="debug-content"></div>
|
||||||
<button class="admin-tab" data-tab="settings">Settings</button>
|
</div>
|
||||||
<button class="admin-tab" data-tab="stats">Stats</button>
|
</div>
|
||||||
</div>
|
<div class="modal-footer debug-footer">
|
||||||
|
<button class="btn-primary btn-small" onclick="runDebugDiagnostics()">🔍 Diagnostics</button>
|
||||||
<!-- Users Tab -->
|
<div style="margin-left:auto;display:flex;gap:0.5rem">
|
||||||
<div class="admin-tab-content active" id="adminUsersTab">
|
<button class="btn-small" onclick="clearDebugLog()">🗑️ Clear</button>
|
||||||
<div class="admin-add-user-toggle">
|
<button class="btn-small" onclick="copyDebugLog()">📋 Copy</button>
|
||||||
<button class="btn btn-primary btn-small" id="adminShowAddUser">+ Add User</button>
|
<button class="btn-small" onclick="exportDebugLog()">📥 Export</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div> <!-- /appContainer -->
|
<div class="toast-container" id="toastContainer"></div>
|
||||||
|
|
||||||
<!-- ── Auth Splash Gate ────────────────── -->
|
<script src="js/debug.js?v=0.5.0"></script>
|
||||||
<div class="splash-gate" id="splashGate" style="display:none">
|
<script src="js/api.js?v=0.5.0"></script>
|
||||||
<div class="splash-card">
|
<script src="js/ui.js?v=0.5.0"></script>
|
||||||
<div class="splash-brand">
|
<script src="js/app.js?v=0.5.0"></script>
|
||||||
<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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
439
src/js/api.js
439
src/js/api.js
@@ -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 API = {
|
||||||
const btn = showInModal
|
accessToken: null,
|
||||||
? document.getElementById('fetchModelsBtn')
|
refreshToken: null,
|
||||||
: document.getElementById('quickFetchBtn');
|
user: null,
|
||||||
if (btn) {
|
_refreshing: null,
|
||||||
btn.dataset.originalText = btn.innerHTML;
|
|
||||||
btn.innerHTML = '⏳';
|
|
||||||
btn.disabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
// ── Bootstrap ────────────────────────────
|
||||||
let models = [];
|
|
||||||
|
|
||||||
if (Backend.isManaged) {
|
loadTokens() {
|
||||||
// Managed mode: fetch admin-curated enabled models
|
try {
|
||||||
const data = await Backend.listEnabledModels();
|
const saved = JSON.parse(localStorage.getItem('sb_auth') || 'null');
|
||||||
models = (data.models || []).map(m => ({
|
if (saved) {
|
||||||
id: m.model_id || m.id,
|
this.accessToken = saved.accessToken || null;
|
||||||
owned_by: m.provider_name || m.provider || null,
|
this.refreshToken = saved.refreshToken || null;
|
||||||
config_id: m.config_id || null
|
this.user = saved.user || 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
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
} else {
|
} catch (e) { /* corrupt storage */ }
|
||||||
// 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;
|
|
||||||
|
|
||||||
if (!endpoint || !apiKey) {
|
saveTokens() {
|
||||||
showToast('⚠️ Configure API endpoint and key first', 'warning');
|
localStorage.setItem('sb_auth', JSON.stringify({
|
||||||
if (!showInModal) openSettings();
|
accessToken: this.accessToken,
|
||||||
return;
|
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', {
|
_setAuth(data) {
|
||||||
headers: {
|
this.accessToken = data.access_token;
|
||||||
'Authorization': `Bearer ${apiKey}`,
|
this.refreshToken = data.refresh_token;
|
||||||
'Content-Type': 'application/json'
|
this.user = data.user;
|
||||||
}
|
this.saveTokens();
|
||||||
});
|
},
|
||||||
|
|
||||||
if (!response.ok) throw new Error(`API Error: ${response.status}`);
|
// ── Chats ────────────────────────────────
|
||||||
|
|
||||||
const data = await response.json();
|
listChats(page = 1, perPage = 100) {
|
||||||
const raw = data.data || data.models || data || [];
|
return this._get(`/api/v1/chats?page=${page}&per_page=${perPage}`);
|
||||||
models = Array.isArray(raw)
|
},
|
||||||
? raw.map(m => ({
|
createChat(title, model, systemPrompt) {
|
||||||
id: m.id || m.name || m,
|
const body = { title };
|
||||||
owned_by: m.owned_by || null
|
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));
|
// ── Messages ─────────────────────────────
|
||||||
saveModels();
|
|
||||||
|
|
||||||
if (showInModal) {
|
listMessages(chatId, page = 1, perPage = 200) {
|
||||||
populateModelSelect(document.getElementById('model'));
|
return this._get(`/api/v1/chats/${chatId}/messages?page=${page}&per_page=${perPage}`);
|
||||||
}
|
},
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendApiRequest(messages) {
|
// ── Completions ──────────────────────────
|
||||||
const chatContainer = document.getElementById('chatMessages');
|
|
||||||
|
|
||||||
// Add typing indicator
|
async streamCompletion(chatId, content, model, signal) {
|
||||||
const typingDiv = document.createElement('div');
|
const body = { chat_id: chatId, content, stream: true };
|
||||||
typingDiv.className = 'message assistant';
|
if (model) body.model = model;
|
||||||
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();
|
|
||||||
|
|
||||||
State.isGenerating = true;
|
let resp = await fetch('/api/v1/chat/completions', {
|
||||||
State.abortController = new AbortController();
|
method: 'POST',
|
||||||
document.getElementById('stopBtn').classList.add('visible');
|
headers: this._authHeaders(),
|
||||||
document.getElementById('sendBtn').disabled = true;
|
body: JSON.stringify(body),
|
||||||
|
signal
|
||||||
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()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// In unmanaged mode, persist locally + to backend if connected
|
if (resp.status === 401 && this.refreshToken) {
|
||||||
await updateChat(State.currentChatId, { messages, model });
|
if (await this.refresh()) {
|
||||||
if (!Backend.isManaged) {
|
resp = await fetch('/api/v1/chat/completions', {
|
||||||
await onAssistantResponse(assistantContent, model);
|
method: 'POST',
|
||||||
|
headers: this._authHeaders(),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isStream) {
|
if (!resp.ok) {
|
||||||
renderMessages(messages);
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || `HTTP ${resp.status}`);
|
||||||
}
|
}
|
||||||
|
return resp;
|
||||||
|
},
|
||||||
|
|
||||||
updateRegenerateButton(messages);
|
// ── Models ───────────────────────────────
|
||||||
|
|
||||||
} catch (error) {
|
listEnabledModels() { return this._get('/api/v1/models/enabled'); },
|
||||||
document.getElementById('typingIndicator')?.remove();
|
listAllModels() { return this._get('/api/v1/models'); },
|
||||||
|
|
||||||
if (error.name === 'AbortError') {
|
// ── API Configs (user providers) ─────────
|
||||||
showToast('⚠️ Generation stopped', 'warning');
|
|
||||||
} else {
|
listConfigs() { return this._get('/api/v1/api-configs'); },
|
||||||
console.error('API Error:', error);
|
createConfig(name, provider, endpoint, apiKey, modelDefault) {
|
||||||
showToast(`❌ ${error.message}`, 'error');
|
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;
|
async _raw(path, method = 'GET', body, auth = false) {
|
||||||
document.getElementById('stopBtn').classList.remove('visible');
|
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
||||||
document.getElementById('sendBtn').disabled = false;
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
839
src/js/app.js
839
src/js/app.js
@@ -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() {
|
async function init() {
|
||||||
console.log('🔀 Chat Switchboard initializing...');
|
console.log('🔀 Chat Switchboard initializing...');
|
||||||
|
API.loadTokens();
|
||||||
|
|
||||||
loadSettings();
|
// Always check backend health first
|
||||||
loadModels();
|
|
||||||
Backend.init();
|
|
||||||
|
|
||||||
// Auto-detect backend at same origin
|
|
||||||
let health = null;
|
let health = null;
|
||||||
if (!Backend.baseUrl) {
|
try {
|
||||||
health = await Backend.checkHealth('');
|
health = await API.health();
|
||||||
if (health && health.database) {
|
console.log('✅ Backend reachable:', health.version);
|
||||||
Backend.baseUrl = window.location.origin;
|
} catch (e) {
|
||||||
Backend.save();
|
console.error('❌ Backend unreachable:', e.message);
|
||||||
console.log('🔗 Backend detected at same origin');
|
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 (API.isAuthed) {
|
||||||
if (Backend.accessToken) {
|
await startApp();
|
||||||
health = health || await Backend.checkHealth(Backend.baseUrl);
|
|
||||||
if (!health) {
|
|
||||||
console.log('⚠️ Backend unreachable, falling back to unmanaged');
|
|
||||||
Backend.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decide: splash gate or straight to app
|
|
||||||
if (Backend.baseUrl && !Backend.accessToken) {
|
|
||||||
// Online but not authenticated → show splash, hide app
|
|
||||||
showSplashGate(health);
|
|
||||||
initAuthListeners();
|
|
||||||
return; // Don't init app yet — authSuccess() will do it
|
|
||||||
}
|
|
||||||
|
|
||||||
// Already authed or no backend → go straight to app
|
|
||||||
hideSplashGate();
|
|
||||||
await loadSettingsFromBackend();
|
|
||||||
await initApp();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function initApp() {
|
|
||||||
await loadChats();
|
|
||||||
initializeEventListeners();
|
|
||||||
|
|
||||||
// In managed mode, fetch models from backend (ignore localStorage cache)
|
|
||||||
if (Backend.isManaged) {
|
|
||||||
fetchModels(false); // async, non-blocking
|
|
||||||
}
|
|
||||||
|
|
||||||
updateQuickModelSelector();
|
|
||||||
renderChatHistory();
|
|
||||||
updateConnectionStatus();
|
|
||||||
initAdmin();
|
|
||||||
console.log(`✅ Chat Switchboard ready (${Backend.isManaged ? 'managed' : 'unmanaged'} mode)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function showSplashGate(health) {
|
|
||||||
const splash = document.getElementById('splashGate');
|
|
||||||
const app = document.getElementById('appContainer');
|
|
||||||
splash.classList.add('visible');
|
|
||||||
splash.style.display = 'flex'; // Fallback for cached CSS
|
|
||||||
app.classList.add('app-hidden');
|
|
||||||
app.style.display = 'none'; // Fallback for cached CSS
|
|
||||||
|
|
||||||
// Hide register tab if registration disabled
|
|
||||||
const registerTab = document.getElementById('authTabRegister');
|
|
||||||
if (health && health.registration_enabled === false) {
|
|
||||||
registerTab.style.display = 'none';
|
|
||||||
} else {
|
} else {
|
||||||
registerTab.style.display = '';
|
showSplash(health);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideSplashGate() {
|
async function startApp() {
|
||||||
const splash = document.getElementById('splashGate');
|
hideSplash();
|
||||||
const app = document.getElementById('appContainer');
|
await loadSettings();
|
||||||
splash.classList.remove('visible');
|
await loadChats();
|
||||||
splash.style.display = 'none'; // Hide splash
|
await fetchModels();
|
||||||
app.classList.remove('app-hidden');
|
UI.renderChatList();
|
||||||
app.style.display = ''; // Show app
|
UI.updateModelSelector();
|
||||||
|
UI.updateConnectionStatus();
|
||||||
|
UI.showAdminButton(API.isAdmin);
|
||||||
|
initListeners();
|
||||||
|
console.log('✅ Chat Switchboard ready');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function authSuccess() {
|
// ── Settings ─────────────────────────────────
|
||||||
hideSplashGate();
|
|
||||||
await loadSettingsFromBackend();
|
|
||||||
await initApp();
|
|
||||||
showToast(`✅ Welcome, ${Backend.user?.display_name || Backend.user?.username || 'user'}!`, 'success');
|
|
||||||
}
|
|
||||||
|
|
||||||
function initializeEventListeners() {
|
async function loadSettings() {
|
||||||
// Settings modal
|
try {
|
||||||
document.getElementById('settingsBtn').addEventListener('click', openSettings);
|
const remote = await API.getSettings();
|
||||||
document.getElementById('configureBtn').addEventListener('click', openSettings);
|
if (remote && typeof remote === 'object') {
|
||||||
document.getElementById('closeModalBtn').addEventListener('click', closeSettings);
|
if (remote.model) App.settings.model = remote.model;
|
||||||
document.getElementById('cancelBtn').addEventListener('click', closeSettings);
|
if (remote.system_prompt !== undefined) App.settings.systemPrompt = remote.system_prompt;
|
||||||
document.getElementById('saveBtn').addEventListener('click', handleSaveSettings);
|
if (remote.max_tokens) App.settings.maxTokens = remote.max_tokens;
|
||||||
document.getElementById('fetchModelsBtn').addEventListener('click', () => fetchModels(true));
|
if (remote.temperature !== undefined) App.settings.temperature = remote.temperature;
|
||||||
|
|
||||||
// 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();
|
|
||||||
}
|
}
|
||||||
});
|
} catch (e) {
|
||||||
|
console.warn('Settings load failed:', e.message);
|
||||||
// Export dropdown
|
|
||||||
document.getElementById('exportBtn').addEventListener('click', function(e) {
|
|
||||||
e.stopPropagation();
|
|
||||||
document.getElementById('exportDropdown').classList.toggle('show');
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('click', function() {
|
|
||||||
document.getElementById('exportDropdown').classList.remove('show');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Message input
|
|
||||||
const messageInput = document.getElementById('messageInput');
|
|
||||||
messageInput.addEventListener('keydown', handleKeyDown);
|
|
||||||
messageInput.addEventListener('input', function() {
|
|
||||||
this.style.height = 'auto';
|
|
||||||
this.style.height = Math.min(this.scrollHeight, 200) + 'px';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Settings modal model sync
|
|
||||||
document.getElementById('model').addEventListener('change', function() {
|
|
||||||
if (this.value) document.getElementById('modelCustom').value = '';
|
|
||||||
});
|
|
||||||
document.getElementById('modelCustom').addEventListener('input', function() {
|
|
||||||
if (this.value) document.getElementById('model').value = '';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close modals on overlay click
|
|
||||||
document.getElementById('settingsModal').addEventListener('click', function(e) {
|
|
||||||
if (e.target === this) closeSettings();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Connection status click
|
|
||||||
document.getElementById('connectionStatus').addEventListener('click', handleConnectionClick);
|
|
||||||
|
|
||||||
// Admin panel
|
|
||||||
document.getElementById('adminBtn').addEventListener('click', openAdmin);
|
|
||||||
document.getElementById('adminCloseBtn').addEventListener('click', closeAdmin);
|
|
||||||
document.querySelectorAll('.admin-tab').forEach(tab => {
|
|
||||||
tab.addEventListener('click', () => switchAdminTab(tab.dataset.tab));
|
|
||||||
});
|
|
||||||
document.getElementById('adminSaveSettings').addEventListener('click', saveAdminSettings);
|
|
||||||
document.getElementById('adminModal').addEventListener('click', function(e) {
|
|
||||||
if (e.target === this) closeAdmin();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Global keyboard shortcuts
|
|
||||||
document.addEventListener('keydown', function(e) {
|
|
||||||
if (e.ctrlKey && e.key === ',') {
|
|
||||||
e.preventDefault();
|
|
||||||
openSettings();
|
|
||||||
}
|
|
||||||
if (e.ctrlKey && e.key === 'b') {
|
|
||||||
e.preventDefault();
|
|
||||||
toggleSidebar();
|
|
||||||
}
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
if (State.isGenerating) {
|
|
||||||
stopGeneration();
|
|
||||||
} else if (document.getElementById('settingsModal').classList.contains('active')) {
|
|
||||||
closeSettings();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeyDown(e) {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
sendMessage();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSaveSettings() {
|
async function saveSettings() {
|
||||||
State.settings.apiEndpoint = document.getElementById('apiEndpoint').value.trim();
|
try {
|
||||||
State.settings.apiKey = document.getElementById('apiKey').value.trim();
|
await API.updateSettings({
|
||||||
|
model: App.settings.model,
|
||||||
const modelCustom = document.getElementById('modelCustom').value.trim();
|
system_prompt: App.settings.systemPrompt,
|
||||||
const modelSelect = document.getElementById('model').value;
|
max_tokens: App.settings.maxTokens,
|
||||||
State.settings.model = modelCustom || modelSelect || 'gpt-3.5-turbo';
|
temperature: App.settings.temperature,
|
||||||
|
});
|
||||||
State.settings.stream = document.getElementById('streamResponse').checked;
|
} catch (e) {
|
||||||
State.settings.saveHistory = document.getElementById('saveHistory').checked;
|
console.warn('Settings sync failed:', e.message);
|
||||||
State.settings.showThinking = document.getElementById('showThinking').checked;
|
}
|
||||||
State.settings.systemPrompt = document.getElementById('systemPrompt').value.trim();
|
|
||||||
State.settings.maxTokens = parseInt(document.getElementById('maxTokens').value) || 4096;
|
|
||||||
State.settings.temperature = parseFloat(document.getElementById('temperature').value) || 0.7;
|
|
||||||
State.settings.topP = parseFloat(document.getElementById('topP').value) || 1;
|
|
||||||
State.settings.presencePenalty = parseFloat(document.getElementById('presencePenalty').value) || 0;
|
|
||||||
|
|
||||||
saveSettings();
|
|
||||||
saveProfile(); // async, fire-and-forget
|
|
||||||
updateQuickModelSelector();
|
|
||||||
closeSettings();
|
|
||||||
showToast('✅ Settings saved', 'success');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function newChat() {
|
// ── Models ───────────────────────────────────
|
||||||
State.currentChatId = null;
|
|
||||||
document.getElementById('chatMessages').innerHTML = `
|
async function fetchModels() {
|
||||||
<div class="empty-state">
|
try {
|
||||||
<div class="empty-state-icon">
|
const data = await API.listEnabledModels();
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
App.models = (data.models || []).map(m => ({
|
||||||
<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>
|
id: m.model_id || m.id,
|
||||||
</svg>
|
provider: m.provider_name || m.provider || '',
|
||||||
</div>
|
configId: m.config_id || null
|
||||||
<h2>Start a Conversation</h2>
|
}));
|
||||||
<p>Type a message below to begin</p>
|
|
||||||
</div>
|
// Fallback to raw aggregation if no curated models
|
||||||
`;
|
if (App.models.length === 0) {
|
||||||
document.getElementById('regenerateBtn').style.display = 'none';
|
const raw = await API.listAllModels();
|
||||||
renderChatHistory();
|
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();
|
document.getElementById('messageInput').focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadChat(chatId) {
|
async function deleteChat(chatId) {
|
||||||
const chat = getChat(chatId);
|
if (!confirm('Delete this chat?')) return;
|
||||||
if (!chat) return;
|
try {
|
||||||
|
await API.deleteChat(chatId);
|
||||||
State.currentChatId = chatId;
|
App.chats = App.chats.filter(c => c.id !== chatId);
|
||||||
|
if (App.currentChatId === chatId) {
|
||||||
// In managed mode, fetch messages if not cached
|
|
||||||
if (Backend.isManaged && chat.messages.length === 0 && chat.messageCount > 0) {
|
|
||||||
await loadMessages(chatId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chat.model) {
|
|
||||||
State.settings.model = chat.model;
|
|
||||||
updateQuickModelSelector();
|
|
||||||
}
|
|
||||||
renderMessages(chat.messages);
|
|
||||||
renderChatHistory();
|
|
||||||
updateRegenerateButton(chat.messages);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeleteChat(chatId, event) {
|
|
||||||
event.stopPropagation();
|
|
||||||
if (confirm('Delete this chat?')) {
|
|
||||||
await deleteChat(chatId);
|
|
||||||
if (State.currentChatId === chatId) {
|
|
||||||
newChat();
|
newChat();
|
||||||
}
|
}
|
||||||
renderChatHistory();
|
UI.renderChatList();
|
||||||
showToast('🗑️ Chat deleted', 'success');
|
} catch (e) {
|
||||||
|
UI.toast('Failed to delete: ' + e.message, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Send Message ─────────────────────────────
|
||||||
|
|
||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
const input = document.getElementById('messageInput');
|
const input = document.getElementById('messageInput');
|
||||||
const message = input.value.trim();
|
const text = input.value.trim();
|
||||||
|
if (!text || App.isGenerating) return;
|
||||||
if (!message || State.isGenerating) return;
|
|
||||||
|
|
||||||
// In managed mode, backend has the keys. In unmanaged, user must configure.
|
|
||||||
if (!Backend.isManaged) {
|
|
||||||
if (!State.settings.apiEndpoint || !State.settings.apiKey) {
|
|
||||||
showToast('⚠️ Configure API settings first', 'warning');
|
|
||||||
openSettings();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
input.value = '';
|
input.value = '';
|
||||||
input.style.height = 'auto';
|
input.style.height = 'auto';
|
||||||
|
|
||||||
let messages = [];
|
const model = document.getElementById('modelSelect').value || App.settings.model;
|
||||||
|
|
||||||
if (State.settings.systemPrompt) {
|
// Create chat on first message
|
||||||
messages.push({ role: 'system', content: State.settings.systemPrompt });
|
if (!App.currentChatId) {
|
||||||
}
|
try {
|
||||||
|
const title = text.slice(0, 50) + (text.length > 50 ? '...' : '');
|
||||||
if (State.currentChatId) {
|
const resp = await API.createChat(title, model, App.settings.systemPrompt);
|
||||||
const chat = getChat(State.currentChatId);
|
const chat = {
|
||||||
if (chat) messages = [...chat.messages];
|
id: resp.id,
|
||||||
}
|
title: resp.title,
|
||||||
|
model: model,
|
||||||
messages.push({
|
messages: [],
|
||||||
role: 'user',
|
messageCount: 0,
|
||||||
content: message,
|
updatedAt: resp.updated_at
|
||||||
timestamp: new Date().toISOString()
|
};
|
||||||
});
|
App.chats.unshift(chat);
|
||||||
|
App.currentChatId = chat.id;
|
||||||
if (!State.currentChatId) {
|
UI.renderChatList();
|
||||||
const title = message.substring(0, 50) + (message.length > 50 ? '...' : '');
|
} catch (e) {
|
||||||
const chat = await createChat(title, messages);
|
UI.toast('Failed to create chat: ' + e.message, 'error');
|
||||||
if (!chat) return;
|
return;
|
||||||
State.currentChatId = chat.id;
|
|
||||||
} else {
|
|
||||||
await updateChat(State.currentChatId, { messages });
|
|
||||||
// In unmanaged mode, persist user message to backend (if connected)
|
|
||||||
// In managed mode, the completion proxy persists both messages
|
|
||||||
if (!Backend.isManaged) {
|
|
||||||
await persistMessage(State.currentChatId, 'user', message);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
renderMessages(messages);
|
// Add user message to local state + render
|
||||||
renderChatHistory();
|
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() {
|
function stopGeneration() {
|
||||||
if (State.isGenerating || !State.currentChatId) return;
|
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;
|
if (!chat || chat.messages.length === 0) return;
|
||||||
|
|
||||||
// Remove last assistant message(s)
|
// Pop trailing assistant messages
|
||||||
while (chat.messages.length > 0 && chat.messages[chat.messages.length - 1].role === 'assistant') {
|
while (chat.messages.length && chat.messages[chat.messages.length - 1].role === 'assistant') {
|
||||||
chat.messages.pop();
|
chat.messages.pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chat.messages.length === 0) return;
|
if (chat.messages.length === 0) return;
|
||||||
|
|
||||||
await updateChat(State.currentChatId, { messages: chat.messages });
|
UI.renderMessages(chat.messages);
|
||||||
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 showSplash(health) {
|
||||||
function initAuthListeners() {
|
document.getElementById('splashGate').style.display = 'flex';
|
||||||
if (_authListenersInit) return;
|
document.getElementById('appContainer').style.display = 'none';
|
||||||
_authListenersInit = true;
|
|
||||||
|
|
||||||
document.getElementById('authLoginBtn').addEventListener('click', handleLogin);
|
if (health && health.registration_enabled === false) {
|
||||||
document.getElementById('authRegisterBtn').addEventListener('click', handleRegister);
|
document.getElementById('authTabRegister').style.display = 'none';
|
||||||
document.getElementById('authTabLogin').addEventListener('click', () => switchAuthTab('login'));
|
}
|
||||||
document.getElementById('authTabRegister').addEventListener('click', () => switchAuthTab('register'));
|
|
||||||
document.getElementById('authSkipBtn').addEventListener('click', skipAuth);
|
|
||||||
|
|
||||||
// Enter key in auth forms
|
|
||||||
document.getElementById('authPassword').addEventListener('keydown', function(e) {
|
|
||||||
if (e.key === 'Enter') handleLogin();
|
|
||||||
});
|
|
||||||
document.getElementById('authRegPassword').addEventListener('keydown', function(e) {
|
|
||||||
if (e.key === 'Enter') handleRegister();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchAuthTab(tab) {
|
function hideSplash() {
|
||||||
document.getElementById('authTabLogin').classList.toggle('active', tab === 'login');
|
document.getElementById('splashGate').style.display = 'none';
|
||||||
document.getElementById('authTabRegister').classList.toggle('active', tab === 'register');
|
document.getElementById('appContainer').style.display = '';
|
||||||
document.getElementById('authLoginForm').style.display = tab === 'login' ? 'block' : 'none';
|
|
||||||
document.getElementById('authRegisterForm').style.display = tab === 'register' ? 'block' : 'none';
|
|
||||||
document.getElementById('authLoginBtn').style.display = tab === 'login' ? 'block' : 'none';
|
|
||||||
document.getElementById('authRegisterBtn').style.display = tab === 'register' ? 'block' : 'none';
|
|
||||||
document.getElementById('authError').textContent = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLogin() {
|
async function handleLogin() {
|
||||||
const login = document.getElementById('authLogin').value.trim();
|
const login = document.getElementById('authLogin').value.trim();
|
||||||
const password = document.getElementById('authPassword').value;
|
const password = document.getElementById('authPassword').value;
|
||||||
|
if (!login || !password) return setAuthError('Fill in all fields');
|
||||||
if (!login || !password) {
|
|
||||||
document.getElementById('authError').textContent = 'Please fill in all fields';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setAuthLoading(true);
|
setAuthLoading(true);
|
||||||
try {
|
try {
|
||||||
await Backend.login(login, password);
|
await API.login(login, password);
|
||||||
await authSuccess();
|
await startApp();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.getElementById('authError').textContent = e.message;
|
setAuthError(e.message);
|
||||||
} finally {
|
} finally {
|
||||||
setAuthLoading(false);
|
setAuthLoading(false);
|
||||||
}
|
}
|
||||||
@@ -405,86 +350,202 @@ async function handleRegister() {
|
|||||||
const username = document.getElementById('authUsername').value.trim();
|
const username = document.getElementById('authUsername').value.trim();
|
||||||
const email = document.getElementById('authEmail').value.trim();
|
const email = document.getElementById('authEmail').value.trim();
|
||||||
const password = document.getElementById('authRegPassword').value;
|
const password = document.getElementById('authRegPassword').value;
|
||||||
|
if (!username || !email || !password) return setAuthError('Fill in all fields');
|
||||||
if (!username || !email || !password) {
|
if (password.length < 8) return setAuthError('Password must be at least 8 characters');
|
||||||
document.getElementById('authError').textContent = 'Please fill in all fields';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (password.length < 8) {
|
|
||||||
document.getElementById('authError').textContent = 'Password must be at least 8 characters';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setAuthLoading(true);
|
setAuthLoading(true);
|
||||||
try {
|
try {
|
||||||
await Backend.register(username, email, password);
|
await API.register(username, email, password);
|
||||||
await authSuccess();
|
await startApp();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.getElementById('authError').textContent = e.message;
|
setAuthError(e.message);
|
||||||
} finally {
|
} finally {
|
||||||
setAuthLoading(false);
|
setAuthLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setAuthLoading(loading) {
|
async function handleLogout() {
|
||||||
const btns = document.querySelectorAll('#authLoginBtn, #authRegisterBtn');
|
if (!confirm('Sign out?')) return;
|
||||||
btns.forEach(btn => {
|
await API.logout();
|
||||||
btn.disabled = loading;
|
App.chats = [];
|
||||||
if (loading) btn.dataset.origText = btn.textContent;
|
App.currentChatId = null;
|
||||||
btn.textContent = loading ? '⏳ Please wait...' : (btn.dataset.origText || btn.textContent);
|
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() {
|
// ── Event Listeners ──────────────────────────
|
||||||
Backend.clear();
|
|
||||||
hideSplashGate();
|
let _listenersInit = false;
|
||||||
await initApp();
|
function initListeners() {
|
||||||
showToast('📴 Running in offline mode', 'success');
|
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() {
|
function handleSaveSettings() {
|
||||||
const el = document.getElementById('connectionStatus');
|
App.settings.model = document.getElementById('settingsModel').value || App.settings.model;
|
||||||
if (Backend.isManaged) {
|
App.settings.systemPrompt = document.getElementById('settingsSystemPrompt').value.trim();
|
||||||
el.className = 'connection-status managed';
|
App.settings.maxTokens = parseInt(document.getElementById('settingsMaxTokens').value) || 4096;
|
||||||
el.innerHTML = `<span class="status-dot"></span> ${Backend.user?.display_name || Backend.user?.username || 'Connected'}`;
|
App.settings.temperature = parseFloat(document.getElementById('settingsTemp').value) || 0.7;
|
||||||
el.title = 'Managed mode – click to sign out';
|
App.settings.showThinking = document.getElementById('settingsThinking').checked;
|
||||||
} else if (Backend.baseUrl) {
|
|
||||||
el.className = 'connection-status available';
|
saveSettings();
|
||||||
el.innerHTML = '<span class="status-dot"></span> Sign in';
|
UI.updateModelSelector();
|
||||||
el.title = 'Backend available – click to sign in';
|
UI.closeSettings();
|
||||||
} else {
|
UI.toast('Settings saved', 'success');
|
||||||
el.className = 'connection-status offline';
|
}
|
||||||
el.innerHTML = '<span class="status-dot"></span> Offline';
|
|
||||||
el.title = 'Unmanaged mode – data stored locally';
|
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() {
|
async function handleCreateProvider() {
|
||||||
if (Backend.isManaged) {
|
const name = document.getElementById('providerName').value.trim();
|
||||||
if (confirm('Sign out? Your chats are saved on the server.')) {
|
const provider = document.getElementById('providerType').value;
|
||||||
await Backend.logout();
|
const endpoint = document.getElementById('providerEndpoint').value.trim();
|
||||||
State.chats = [];
|
const apiKey = document.getElementById('providerApiKey').value.trim();
|
||||||
State.currentChatId = null;
|
const model = document.getElementById('providerDefaultModel').value.trim();
|
||||||
showSplashGate(null);
|
|
||||||
initAuthListeners();
|
if (!name || !endpoint || !apiKey) return UI.toast('Fill in required fields', 'warning');
|
||||||
showToast('👋 Signed out', 'success');
|
|
||||||
}
|
try {
|
||||||
} else if (Backend.baseUrl) {
|
await API.createConfig(name, provider, endpoint, apiKey, model);
|
||||||
showSplashGate(null);
|
UI.toast('Provider added', 'success');
|
||||||
initAuthListeners();
|
UI.hideProviderForm();
|
||||||
|
UI.loadProviderList();
|
||||||
|
fetchModels();
|
||||||
|
} catch (e) {
|
||||||
|
UI.toast(e.message, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Post-LLM Persistence ────────────────────
|
async function deleteProvider(id, name) {
|
||||||
// Called by api.js after assistant response is complete.
|
if (!confirm(`Remove provider "${name}"?`)) return;
|
||||||
|
try {
|
||||||
async function onAssistantResponse(content, model) {
|
await API.deleteConfig(id);
|
||||||
if (Backend.isManaged && State.currentChatId) {
|
UI.toast('Provider removed', 'success');
|
||||||
await persistMessage(State.currentChatId, 'assistant', content, model);
|
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);
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
|||||||
@@ -199,34 +199,32 @@ const DebugLog = {
|
|||||||
getStateSnapshot() {
|
getStateSnapshot() {
|
||||||
const snap = {};
|
const snap = {};
|
||||||
|
|
||||||
// Backend state (redact tokens)
|
// API client state (redact tokens)
|
||||||
if (typeof Backend !== 'undefined') {
|
if (typeof API !== 'undefined') {
|
||||||
snap.backend = {
|
snap.api = {
|
||||||
baseUrl: Backend.baseUrl || '(empty)',
|
hasAccessToken: !!API.accessToken,
|
||||||
hasAccessToken: !!Backend.accessToken,
|
hasRefreshToken: !!API.refreshToken,
|
||||||
hasRefreshToken: !!Backend.refreshToken,
|
user: API.user ? {
|
||||||
user: Backend.user ? {
|
username: API.user.username,
|
||||||
username: Backend.user.username,
|
role: API.user.role,
|
||||||
role: Backend.user.role,
|
display_name: API.user.display_name
|
||||||
display_name: Backend.user.display_name
|
|
||||||
} : null,
|
} : null,
|
||||||
isConnected: Backend.isConnected,
|
isAuthed: API.isAuthed,
|
||||||
isManaged: Backend.isManaged
|
isAdmin: API.isAdmin
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// App state
|
// App state
|
||||||
if (typeof State !== 'undefined') {
|
if (typeof App !== 'undefined') {
|
||||||
snap.state = {
|
snap.app = {
|
||||||
chatCount: State.chats?.length || 0,
|
chatCount: App.chats?.length || 0,
|
||||||
currentChatId: State.currentChatId,
|
currentChatId: App.currentChatId,
|
||||||
modelCount: State.models?.length || 0,
|
modelCount: App.models?.length || 0,
|
||||||
isGenerating: State.isGenerating,
|
isGenerating: App.isGenerating,
|
||||||
settings: {
|
settings: {
|
||||||
model: State.settings?.model || '?',
|
model: App.settings?.model || '?',
|
||||||
stream: State.settings?.stream,
|
stream: App.settings?.stream,
|
||||||
apiEndpoint: State.settings?.apiEndpoint ? '(set)' : '(empty)',
|
showThinking: App.settings?.showThinking
|
||||||
apiKey: State.settings?.apiKey ? '(set)' : '(empty)',
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
912
src/js/ui.js
912
src/js/ui.js
@@ -1,515 +1,475 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// UI Functions
|
// Chat Switchboard – UI (v0.5.0)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
function updateSettingsUI() {
|
const UI = {
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function populateModelSelect(selectElement) {
|
// ── Chat List ────────────────────────────
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateQuickModelSelector() {
|
renderChatList() {
|
||||||
const quickSelect = document.getElementById('quickModel');
|
const el = document.getElementById('chatHistory');
|
||||||
const currentModel = State.settings.model;
|
if (App.chats.length === 0) {
|
||||||
|
el.innerHTML = '<div class="empty-hint">No conversations yet</div>';
|
||||||
quickSelect.innerHTML = '';
|
return;
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
return;
|
el.innerHTML = App.chats.map(c => `
|
||||||
}
|
<div class="chat-item ${c.id === App.currentChatId ? 'active' : ''}"
|
||||||
|
onclick="selectChat('${c.id}')">
|
||||||
State.models.forEach(model => {
|
<span class="chat-item-title">${esc(c.title)}</span>
|
||||||
const option = document.createElement('option');
|
<button class="chat-item-delete" onclick="deleteChat('${c.id}');event.stopPropagation();"
|
||||||
option.value = model.id;
|
title="Delete">✕</button>
|
||||||
option.textContent = model.id;
|
</div>
|
||||||
quickSelect.appendChild(option);
|
`).join('');
|
||||||
});
|
},
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openSettings() {
|
// ── Messages ─────────────────────────────
|
||||||
updateSettingsUI();
|
|
||||||
updateProfileSection();
|
|
||||||
updateProviderSection();
|
|
||||||
document.getElementById('settingsModal').classList.add('active');
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateProfileSection() {
|
renderMessages(messages) {
|
||||||
const profileSection = document.getElementById('profileSection');
|
const el = document.getElementById('chatMessages');
|
||||||
const unmanagedSettings = document.getElementById('unmanagedSettings');
|
if (!messages || messages.length === 0) {
|
||||||
const modeBadge = document.getElementById('settingsModeBadge');
|
this.showEmptyState();
|
||||||
|
|
||||||
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>';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = list.map(c => `
|
el.innerHTML = messages
|
||||||
<div class="provider-row">
|
.filter(m => m.role !== 'system')
|
||||||
<div class="provider-info">
|
.map((m, i) => this._messageHTML(m, i))
|
||||||
<span class="provider-name">${escapeHtmlUI(c.name)}</span>
|
.join('');
|
||||||
<span class="provider-meta">${escapeHtmlUI(c.provider)} · ${escapeHtmlUI(c.model_default || 'no default')}</span>
|
this._scrollToBottom();
|
||||||
</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>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showProviderForm() {
|
_messageHTML(msg, index) {
|
||||||
document.getElementById('providerAddForm').style.display = '';
|
const isUser = msg.role === 'user';
|
||||||
document.getElementById('providerShowAddBtn').style.display = 'none';
|
const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString() : '';
|
||||||
// Set default endpoint based on provider type
|
return `
|
||||||
updateProviderEndpointHint();
|
<div class="message ${msg.role}">
|
||||||
}
|
<div class="message-inner">
|
||||||
|
<div class="message-avatar">${isUser ? '👤' : '🤖'}</div>
|
||||||
function hideProviderForm() {
|
<div class="message-body">
|
||||||
document.getElementById('providerAddForm').style.display = 'none';
|
<div class="message-head">
|
||||||
document.getElementById('providerShowAddBtn').style.display = '';
|
<span class="message-role">${isUser ? 'You' : 'Assistant'}</span>
|
||||||
clearProviderForm();
|
${time ? `<span class="message-time">${time}</span>` : ''}
|
||||||
}
|
<button class="message-copy" onclick="UI.copyMessage(${index})" title="Copy">📋</button>
|
||||||
|
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="message-text">${formatMessage(msg.content)}</div>
|
||||||
</div>
|
</div>
|
||||||
${time ? `<div class="message-time">${time}</div>` : ''}
|
|
||||||
<div class="message-text">${formattedContent}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>`;
|
||||||
</div>
|
},
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMessage(content) {
|
showEmptyState() {
|
||||||
let formatted = content;
|
document.getElementById('chatMessages').innerHTML = `
|
||||||
const thinkingBlocks = [];
|
<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)
|
// ── Streaming ────────────────────────────
|
||||||
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, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Escape HTML for the entire string (placeholders are plain text, safe to escape)
|
async streamResponse(response, messages) {
|
||||||
formatted = escapeHtml(formatted);
|
const container = document.getElementById('chatMessages');
|
||||||
|
|
||||||
// Restore thinking blocks with properly-escaped content (single escape only)
|
// Remove typing indicator if present
|
||||||
for (const block of thinkingBlocks) {
|
document.getElementById('typingIndicator')?.remove();
|
||||||
const escapedContent = escapeHtml(block.content).replace(/\n/g, '<br>');
|
|
||||||
formatted = formatted.replace(`__THINKPLACEHOLDER_${block.id}__`, `
|
// Create streaming message element
|
||||||
<div class="thinking-block" id="${block.id}">
|
const div = document.createElement('div');
|
||||||
<div class="thinking-header" onclick="toggleThinkingBlock('${block.id}')">
|
div.className = 'message assistant';
|
||||||
<span class="thinking-toggle">▼</span>
|
div.innerHTML = `
|
||||||
<span class="thinking-label">💭 Thinking</span>
|
<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>
|
||||||
<div class="thinking-content">${escapedContent}</div>
|
</div>`;
|
||||||
</div>
|
container.appendChild(div);
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Code blocks with copy button
|
const reader = response.body.getReader();
|
||||||
formatted = formatted.replace(/```(\w*)\n?([\s\S]*?)```/g, (match, lang, code) => {
|
const decoder = new TextDecoder();
|
||||||
const codeId = 'code-' + Math.random().toString(36).substr(2, 9);
|
let buffer = '';
|
||||||
return `<pre><code id="${codeId}" class="language-${lang}">${code.trim()}</code><button class="copy-code-btn" onclick="copyCode('${codeId}')">Copy</button></pre>`;
|
let content = '';
|
||||||
});
|
|
||||||
|
|
||||||
// Inline code
|
while (true) {
|
||||||
formatted = formatted.replace(/`([^`]+)`/g, '<code>$1</code>');
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
// Bold
|
buffer += decoder.decode(value, { stream: true });
|
||||||
formatted = formatted.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || '';
|
||||||
|
|
||||||
// Italic
|
for (const line of lines) {
|
||||||
formatted = formatted.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
if (!line.startsWith('data: ')) continue;
|
||||||
|
const data = line.slice(6);
|
||||||
|
if (data === '[DONE]') continue;
|
||||||
|
|
||||||
// Line breaks (but not inside pre/code)
|
try {
|
||||||
formatted = formatted.replace(/\n/g, '<br>');
|
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) {
|
// ── Model Selector ───────────────────────
|
||||||
const block = document.getElementById(blockId);
|
|
||||||
if (block) {
|
|
||||||
block.classList.toggle('collapsed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(text) {
|
updateModelSelector() {
|
||||||
const div = document.createElement('div');
|
const sel = document.getElementById('modelSelect');
|
||||||
div.textContent = text;
|
const current = App.settings.model;
|
||||||
return div.innerHTML;
|
sel.innerHTML = '';
|
||||||
}
|
|
||||||
|
|
||||||
function scrollToBottom() {
|
if (App.models.length === 0) {
|
||||||
const container = document.getElementById('chatMessages');
|
sel.innerHTML = '<option value="">No models loaded</option>';
|
||||||
container.scrollTop = container.scrollHeight;
|
} 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) {
|
if (current) {
|
||||||
const btn = document.getElementById('regenerateBtn');
|
const exists = [...sel.options].some(o => o.value === current);
|
||||||
const hasAssistantMessage = messages && messages.some(m => m.role === 'assistant');
|
if (exists) {
|
||||||
btn.style.display = hasAssistantMessage ? 'flex' : 'none';
|
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') {
|
// Also update settings modal model selector if open
|
||||||
const container = document.getElementById('toastContainer');
|
const settingsSel = document.getElementById('settingsModel');
|
||||||
const toast = document.createElement('div');
|
if (settingsSel) {
|
||||||
toast.className = `toast ${type}`;
|
settingsSel.innerHTML = sel.innerHTML;
|
||||||
toast.innerHTML = `
|
if (current) settingsSel.value = current;
|
||||||
<span>${message}</span>
|
}
|
||||||
<button class="toast-close" onclick="this.parentElement.remove()">✕</button>
|
},
|
||||||
`;
|
|
||||||
container.appendChild(toast);
|
|
||||||
setTimeout(() => toast.remove(), 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Message actions
|
// ── Connection Status ────────────────────
|
||||||
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'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyCode(codeId) {
|
updateConnectionStatus() {
|
||||||
const codeElement = document.getElementById(codeId);
|
const el = document.getElementById('connectionStatus');
|
||||||
if (codeElement) {
|
const name = API.user?.display_name || API.user?.username || 'Connected';
|
||||||
navigator.clipboard.writeText(codeElement.textContent)
|
el.className = 'connection-status online';
|
||||||
.then(() => showToast('📋 Code copied', 'success'))
|
el.innerHTML = `<span class="status-dot"></span> ${esc(name)}`;
|
||||||
.catch(() => showToast('❌ Failed to copy', 'error'));
|
el.title = 'Click to sign out';
|
||||||
}
|
},
|
||||||
}
|
|
||||||
|
|
||||||
function exportChat(format) {
|
showAdminButton(show) {
|
||||||
const chat = getCurrentChat();
|
document.getElementById('adminBtn').style.display = show ? '' : 'none';
|
||||||
if (!chat) {
|
},
|
||||||
showToast('⚠️ No chat to export', 'warning');
|
|
||||||
return;
|
// ── Generating State ─────────────────────
|
||||||
}
|
|
||||||
|
setGenerating(on) {
|
||||||
let content, filename, mimeType;
|
document.getElementById('stopBtn').classList.toggle('visible', on);
|
||||||
const title = chat.title.replace(/[^a-z0-9]/gi, '_');
|
document.getElementById('sendBtn').disabled = on;
|
||||||
|
if (on) {
|
||||||
switch (format) {
|
// Show typing indicator
|
||||||
case 'markdown':
|
const container = document.getElementById('chatMessages');
|
||||||
content = `# ${chat.title}\n\n` + chat.messages
|
const typing = document.createElement('div');
|
||||||
.filter(m => m.role !== 'system')
|
typing.id = 'typingIndicator';
|
||||||
.map(m => `## ${m.role === 'user' ? 'You' : 'Assistant'}\n\n${m.content}`)
|
typing.className = 'message assistant';
|
||||||
.join('\n\n---\n\n');
|
typing.innerHTML = `
|
||||||
filename = `${title}.md`;
|
<div class="message-inner">
|
||||||
mimeType = 'text/markdown';
|
<div class="message-avatar">🤖</div>
|
||||||
break;
|
<div class="message-body">
|
||||||
case 'json':
|
<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);
|
content = JSON.stringify(chat, null, 2);
|
||||||
filename = `${title}.json`;
|
ext = 'json'; mime = 'application/json';
|
||||||
mimeType = 'application/json';
|
} else if (format === 'md') {
|
||||||
break;
|
content = `# ${chat.title}\n\n` +
|
||||||
case 'text':
|
chat.messages.filter(m => m.role !== 'system')
|
||||||
content = chat.messages
|
.map(m => `## ${m.role === 'user' ? 'You' : 'Assistant'}\n\n${m.content}`)
|
||||||
.filter(m => m.role !== 'system')
|
.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}`)
|
.map(m => `[${m.role === 'user' ? 'You' : 'Assistant'}]\n${m.content}`)
|
||||||
.join('\n\n');
|
.join('\n\n');
|
||||||
filename = `${title}.txt`;
|
ext = 'txt'; mime = 'text/plain';
|
||||||
mimeType = 'text/plain';
|
}
|
||||||
break;
|
|
||||||
|
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);
|
// ── Formatting ───────────────────────────────
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
function formatMessage(content) {
|
||||||
a.download = filename;
|
if (!content) return '';
|
||||||
a.click();
|
const thinkingBlocks = [];
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
|
let text = content;
|
||||||
showToast(`📥 Exported as ${format}`, 'success');
|
|
||||||
document.getElementById('exportDropdown').classList.remove('show');
|
// 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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user