Merge branch 'changeset-0.5.0'

This commit is contained in:
2026-02-18 17:59:50 -05:00
11 changed files with 1911 additions and 3094 deletions

58
Dockerfile Normal file
View File

@@ -0,0 +1,58 @@
# ============================================
# Chat Switchboard v0.5 - Unified Dockerfile
# ============================================
# Stage 1: Build Go backend
# Stage 2: Download JS vendor libs (marked, DOMPurify)
# Stage 3: Production image (nginx + backend)
#
# Vendor libs are baked in during build so the
# app works in disconnected (SCIF) environments
# with no CDN access.
# ============================================
# ── Stage 1: Go backend build ────────────────
FROM golang:1.22-bookworm AS backend
WORKDIR /app
COPY server/go.mod server/go.sum* ./
COPY server/ .
RUN go mod download && go mod tidy && go mod verify
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /bin/switchboard .
# ── Stage 2: Vendor JS libs ─────────────────
FROM node:20-alpine AS vendor
WORKDIR /vendor
RUN npm pack marked@16.3.0 && \
tar xzf marked-16.3.0.tgz && \
cp package/lib/marked.umd.js marked.min.js && \
rm -rf package marked-16.3.0.tgz
RUN npm pack dompurify@3.2.4 && \
tar xzf dompurify-3.2.4.tgz && \
cp package/dist/purify.min.js purify.min.js && \
rm -rf package dompurify-3.2.4.tgz
# ── Stage 3: Production ─────────────────────
FROM nginx:1-alpine
RUN apk add --no-cache bash
# Go backend binary
COPY --from=backend /bin/switchboard /usr/local/bin/switchboard
# Frontend static files
COPY src/ /usr/share/nginx/html/
# Vendor libs (overwrite any existing copies in src/vendor/)
COPY --from=vendor /vendor/marked.min.js /usr/share/nginx/html/vendor/marked.min.js
COPY --from=vendor /vendor/purify.min.js /usr/share/nginx/html/vendor/purify.min.js
# nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Entrypoint: start Go backend, then nginx
COPY docker-entrypoint.sh /docker-entrypoint.d/90-start-backend.sh
RUN chmod +x /docker-entrypoint.d/90-start-backend.sh
EXPOSE 80

View File

@@ -1 +1 @@
0.4.1 0.5.2

28
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,28 @@
#!/bin/bash
# ============================================
# Chat Switchboard - Backend Launcher
# ============================================
# Runs as an nginx entrypoint.d hook.
# Starts the Go backend in the background
# before nginx begins accepting connections.
# ============================================
set -e
echo "🔀 Starting Chat Switchboard backend..."
# Launch Go backend in background
/usr/local/bin/switchboard &
BACKEND_PID=$!
# Wait for backend to be ready (max 10s)
for i in $(seq 1 20); do
if wget -q --spider http://localhost:${PORT:-8080}/health 2>/dev/null; then
echo "✅ Backend ready (PID ${BACKEND_PID})"
exit 0
fi
sleep 0.5
done
echo "❌ Backend failed to start within 10s"
exit 1

File diff suppressed because it is too large Load Diff

View File

@@ -7,518 +7,229 @@
<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.2">
</head> </head>
<body> <body>
<div id="appContainer" class="app-hidden" style="display:none">
<header class="header"> <!-- ── App Shell ───────────────────────────── -->
<h1>🔀 Chat Switchboard</h1> <div id="appContainer" class="app" style="display:none">
<div class="header-actions">
<div class="connection-status offline" id="connectionStatus" title="Unmanaged mode"> <!-- Sidebar -->
<span class="status-dot"></span> Offline <aside class="sidebar" id="sidebar">
</div> <div class="sidebar-top">
<button class="btn btn-secondary" id="toggleSidebarBtn" title="Toggle Sidebar (Ctrl+B)"> <button class="sb-btn" id="sidebarToggle" title="Toggle sidebar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>
<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> </button>
<div class="dropdown"> <button class="sb-btn" id="newChatBtn" title="New Chat">
<button class="btn btn-secondary" id="exportBtn" title="Export Chat"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
📥 Export <span class="sb-label">New Chat</span>
</button>
<div class="dropdown-content" id="exportDropdown">
<button class="dropdown-item" onclick="exportChat('markdown')">📄 Markdown</button>
<button class="dropdown-item" onclick="exportChat('json')">📋 JSON</button>
<button class="dropdown-item" onclick="exportChat('text')">📝 Plain Text</button>
</div>
</div>
<button class="btn btn-secondary admin-only" id="adminBtn" title="Admin Panel" style="display:none">
🛡️ Admin
</button>
<button class="btn btn-secondary" id="settingsBtn" title="Settings (Ctrl+,)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
Settings
</button> </button>
</div> </div>
</header>
<div class="main-container"> <div class="sidebar-chats" id="chatHistory"></div>
<aside class="sidebar" id="sidebar">
<div class="sidebar-header"> <!-- User area -->
<span class="sidebar-title">Chat History</span> <div class="sidebar-bottom">
</div> <button class="user-btn" id="userMenuBtn">
<button class="btn btn-primary new-chat-btn" id="newChatBtn"> <div class="user-avatar" id="userAvatar"><span id="avatarLetter">?</span><span class="avatar-dot"></span></div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <span class="sb-label user-name" id="userName">User</span>
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
New Chat
</button> </button>
<div class="chat-history" id="chatHistory"></div> <div class="user-flyout" id="userFlyout">
</aside> <button class="flyout-item" id="menuSettings">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
<span>Settings</span>
</button>
<button class="flyout-item" id="menuAdmin" style="display:none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
<span>Admin Panel</span>
</button>
<button class="flyout-item" id="menuDebug">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 11V6a2 2 0 0 0-2-2h-1a2 2 0 0 0-2 2"/><path d="M9 6a2 2 0 0 0-2 2v3"/><path d="M4 14h4"/><path d="M16 14h4"/><path d="M12 18v-6"/><circle cx="12" cy="14" r="6"/></svg>
<span>Debug Log</span>
</button>
<div class="flyout-divider"></div>
<button class="flyout-item flyout-danger" id="menuSignout">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
<span>Sign Out</span>
</button>
</div>
</div>
</aside>
<main class="chat-area"> <!-- Main Chat Area -->
<div class="chat-messages" id="chatMessages"> <main class="chat-area">
<div class="empty-state" id="emptyState"> <div class="model-bar">
<div class="empty-state-icon"> <select id="modelSelect" title="Select model"><option value="">Select a model</option></select>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <button class="icon-btn" id="fetchModelsBtn" title="Refresh models">
<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 width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
</svg> </button>
</div> </div>
<h2>Start a Conversation</h2>
<p>Configure your API settings and start chatting</p> <div class="messages" id="chatMessages">
<button class="btn btn-primary" style="margin-top: 1.5rem;" id="configureBtn">⚙️ Configure API</button> <div class="empty-state">
<div class="empty-logo">🔀</div>
<h2>Chat Switchboard</h2>
<p>Select a model and start chatting</p>
</div>
</div>
<div class="input-area">
<div class="input-wrap">
<textarea id="messageInput" placeholder="Send a message..." rows="1"></textarea>
<div class="input-actions">
<button class="action-btn" id="regenerateBtn" style="display:none" title="Regenerate">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
</button>
<button class="action-btn stop-btn" id="stopBtn" title="Stop">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
</button>
<button class="action-btn send-btn" id="sendBtn" title="Send">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
</div> </div>
</div> </div>
</div>
</main>
</div>
<div class="input-area"> <!-- ── Auth Splash ─────────────────────────── -->
<div class="input-container"> <div class="splash" id="splashGate">
<div class="input-top-bar"> <div class="splash-card">
<div class="model-selector"> <div class="splash-brand">
<label for="quickModel">Model:</label> <div class="splash-logo">🔀</div>
<select id="quickModel"> <h1>Chat Switchboard</h1>
<option value="">-- Configure in Settings --</option> <p>Multi-Model AI Chat</p>
</select> </div>
<button class="btn btn-small btn-secondary" id="quickFetchBtn" title="Refresh models">🔄</button> <div class="auth-tabs">
</div> <button class="auth-tab active" id="authTabLogin" onclick="switchAuthTab('login')">Sign In</button>
<div class="input-shortcuts"> <button class="auth-tab" id="authTabRegister" onclick="switchAuthTab('register')">Register</button>
<kbd>Enter</kbd> send · <kbd>Shift+Enter</kbd> newline · <kbd>Esc</kbd> stop </div>
</div> <div id="authLoginForm">
</div> <div class="form-group"><label>Username or Email</label><input type="text" id="authLogin" autocomplete="username"></div>
<div class="input-wrapper"> <div class="form-group"><label>Password</label><input type="password" id="authPassword" autocomplete="current-password" onkeydown="if(event.key==='Enter')handleLogin()"></div>
<textarea id="messageInput" placeholder="Type your message..." rows="1"></textarea> </div>
<div class="input-actions"> <div id="authRegisterForm" style="display:none">
<div class="input-left"> <div class="form-group"><label>Username</label><input type="text" id="authUsername" autocomplete="username"></div>
<button class="btn btn-small btn-secondary" id="regenerateBtn" title="Regenerate last response" style="display: none;">🔄 Regenerate</button> <div class="form-group"><label>Email</label><input type="email" id="authEmail" autocomplete="email"></div>
</div> <div class="form-group"><label>Password</label><input type="password" id="authRegPassword" autocomplete="new-password" onkeydown="if(event.key==='Enter')handleRegister()"></div>
<div style="display: flex; gap: 0.5rem;"> </div>
<button class="stop-btn" id="stopBtn">⏹ Stop</button> <div class="auth-error" id="authError"></div>
<button class="send-btn" id="sendBtn"> <div class="splash-error" id="splashError"></div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <div class="auth-actions">
<line x1="22" y1="2" x2="11" y2="13"></line> <button class="btn-primary btn-full" id="authLoginBtn" data-label="Sign In" onclick="handleLogin()">Sign In</button>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon> <button class="btn-primary btn-full" id="authRegisterBtn" data-label="Create Account" onclick="handleRegister()" style="display:none">Create Account</button>
</svg> </div>
Send
</button>
</div>
</div>
</div>
</div>
</div>
</main>
</div> </div>
</div>
<!-- Settings Modal --> <!-- ── Settings Modal ──────────────────────── -->
<div class="modal-overlay" id="settingsModal"> <div class="modal-overlay" id="settingsModal">
<div class="modal"> <div class="modal">
<div class="modal-header"> <div class="modal-header"><h2>Settings</h2><button class="modal-close" id="settingsCloseBtn"></button></div>
<h2>⚙️ Settings</h2> <div class="modal-body">
<button class="modal-close" id="closeModalBtn"></button> <section class="settings-section">
</div> <h3>Profile</h3>
<div class="modal-body"> <div class="form-group"><label>Display Name</label><input type="text" id="profileDisplayName"></div>
<!-- Mode indicator --> <div class="form-group"><label>Email</label><input type="email" id="profileEmail"></div>
<div class="settings-mode-badge" id="settingsModeBadge"></div> <button class="btn-small" id="profileChangePwBtn">Change Password</button>
<div id="profileChangePwForm" style="display:none">
<!-- Profile (managed mode only) --> <div class="form-group"><label>Current Password</label><input type="password" id="profileCurrentPw"></div>
<div class="settings-section" id="profileSection" style="display:none"> <div class="form-group"><label>New Password</label><input type="password" id="profileNewPw"></div>
<h3 class="settings-section-title">Profile</h3> <button class="btn-small btn-primary" id="profileSavePwBtn">Save Password</button>
<div class="form-group">
<label for="profileDisplayName">Display Name</label>
<input type="text" id="profileDisplayName" placeholder="How you want to be called">
</div>
<div class="form-group">
<label for="profileEmail">Email</label>
<input type="email" id="profileEmail" placeholder="you@example.com">
</div>
<div class="profile-password-row">
<button class="btn btn-secondary btn-small" id="profileChangePwBtn">🔑 Change Password</button>
</div>
<div id="profileChangePwForm" style="display:none">
<div class="form-group">
<label for="profileCurrentPw">Current Password</label>
<input type="password" id="profileCurrentPw" placeholder="••••••••">
</div>
<div class="form-group">
<label for="profileNewPw">New Password</label>
<input type="password" id="profileNewPw" placeholder="At least 8 characters">
</div>
<button class="btn btn-primary btn-small" id="profileSavePwBtn">Update Password</button>
</div>
<hr class="settings-divider">
</div> </div>
</section>
<!-- API Settings (unmanaged / offline only) --> <section class="settings-section">
<div id="unmanagedSettings"> <h3>Chat</h3>
<div class="form-group"> <div class="form-group"><label>Default Model</label><select id="settingsModel"></select></div>
<label for="apiEndpoint">API Endpoint</label> <div class="form-group"><label>System Prompt</label><textarea id="settingsSystemPrompt" rows="3"></textarea></div>
<input type="text" id="apiEndpoint" placeholder="https://api.openai.com/v1">
<small>Base URL for the OpenAI-compatible API</small>
</div>
<div class="form-group">
<label for="apiKey">API Key</label>
<input type="password" id="apiKey" placeholder="sk-...">
<small>Your API key for authentication</small>
</div>
</div> <!-- /unmanagedSettings -->
<!-- API Providers (managed mode only) -->
<div id="managedProviders" style="display:none">
<h3 class="settings-section-title">API Providers</h3>
<div id="providerList" class="provider-list"></div>
<div class="provider-add-toggle">
<button class="btn btn-secondary btn-small" id="providerShowAddBtn">+ Add Provider</button>
</div>
<div class="provider-add-form" id="providerAddForm" style="display:none">
<div class="form-group">
<label for="providerName">Name</label>
<input type="text" id="providerName" placeholder="e.g. My OpenAI Key">
</div>
<div class="form-group">
<label for="providerType">Provider</label>
<select id="providerType">
<option value="openai">OpenAI-compatible</option>
<option value="anthropic">Anthropic</option>
</select>
</div>
<div class="form-group">
<label for="providerEndpoint">Endpoint</label>
<input type="text" id="providerEndpoint" placeholder="https://api.openai.com/v1">
</div>
<div class="form-group">
<label for="providerApiKey">API Key</label>
<input type="password" id="providerApiKey" placeholder="sk-...">
</div>
<div class="form-group">
<label for="providerDefaultModel">Default Model (optional)</label>
<input type="text" id="providerDefaultModel" placeholder="gpt-4o">
</div>
<div class="admin-form-row">
<button class="btn btn-primary btn-small" id="providerCreateBtn">Save</button>
<button class="btn btn-secondary btn-small" id="providerCancelBtn">Cancel</button>
</div>
</div>
<hr class="settings-divider">
</div>
<div class="form-group">
<label for="model">Default Model</label>
<div style="display: flex; gap: 0.5rem;">
<select id="model" style="flex: 1;">
<option value="">-- Select or type below --</option>
</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-row">
<div class="form-group"> <div class="form-group"><label>Max Tokens</label><input type="number" id="settingsMaxTokens" value="4096"></div>
<label for="maxTokens">Max Tokens</label> <div class="form-group"><label>Temperature</label><input type="number" id="settingsTemp" value="0.7" step="0.1" min="0" max="2"></div>
<input type="number" id="maxTokens" value="4096" min="1" max="32768">
</div>
<div class="form-group">
<label for="temperature">Temperature</label>
<input type="number" id="temperature" value="0.7" min="0" max="2" step="0.1">
</div>
</div> </div>
<label class="checkbox-label"><input type="checkbox" id="settingsThinking" checked> Show thinking blocks</label>
</section>
<section class="settings-section">
<h3>API Providers</h3>
<div id="providerList"></div>
<button class="btn-small" id="providerShowAddBtn">+ Add Provider</button>
<div id="providerAddForm" style="display:none">
<div class="form-group"><label>Name</label><input type="text" id="providerName" placeholder="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></div>
<div class="form-group"><label>Endpoint</label><input type="text" id="providerEndpoint" placeholder="https://api.openai.com/v1"></div>
<div class="form-group"><label>API Key</label><input type="password" id="providerApiKey" placeholder="sk-..."></div>
<div class="form-group"><label>Default Model</label><input type="text" id="providerDefaultModel" placeholder="gpt-4o"></div>
<div class="form-row"><button class="btn-small btn-primary" id="providerCreateBtn">Save</button><button class="btn-small" id="providerCancelBtn">Cancel</button></div>
</div>
</section>
</div>
<div class="modal-footer"><button class="btn-primary" id="settingsSaveBtn">Save Settings</button></div>
</div>
</div>
<div class="form-row"> <!-- ── 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"><h2>Admin Panel</h2><button class="modal-close" id="adminCloseBtn"></button></div>
</div> <div class="admin-tabs">
<div class="form-group"> <button class="admin-tab active" data-tab="users">Users</button>
<label for="presencePenalty">Presence Penalty</label> <button class="admin-tab" data-tab="providers">Providers</button>
<input type="number" id="presencePenalty" value="0" min="-2" max="2" step="0.1"> <button class="admin-tab" data-tab="models">Models</button>
</div> <button class="admin-tab" data-tab="settings">Settings</button>
</div> <button class="admin-tab" data-tab="stats">Stats</button>
</div>
<div class="modal-body">
<div class="admin-tab-content" id="adminUsersTab"><div id="adminUserList"></div></div>
<div class="admin-tab-content" id="adminProvidersTab" style="display:none"><div id="adminProviderList"></div></div>
<div class="admin-tab-content" id="adminModelsTab" style="display:none"><div id="adminModelList"></div></div>
<div class="admin-tab-content" id="adminSettingsTab" style="display:none">
<label class="checkbox-label"><input type="checkbox" id="adminRegToggle" checked> Allow registration</label>
<button class="btn-primary btn-small" id="adminSaveSettings">Save</button>
</div> </div>
<div class="modal-footer"> <div class="admin-tab-content" id="adminStatsTab" style="display:none"><div id="adminStats"></div></div>
<button class="btn btn-secondary" id="cancelBtn">Cancel</button> </div>
<button class="btn btn-primary" id="saveBtn">💾 Save Settings</button> </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">
<div class="debug-tab-content" id="debugConsoleTab">
<div class="debug-toolbar">
<label class="debug-check"><input type="checkbox" id="debugFilterErrors"> Errors only</label>
<label class="debug-check"><input type="checkbox" id="debugAutoScroll" checked> Auto-scroll</label>
</div>
<div id="debugConsoleContent" class="debug-content"></div>
</div>
<div class="debug-tab-content" id="debugNetworkTab" style="display:none"><div id="debugNetworkContent" class="debug-content"></div></div>
<div class="debug-tab-content" id="debugStateTab" style="display:none"><div id="debugStateContent" class="debug-content"></div></div>
</div>
<div class="modal-footer debug-footer">
<button class="btn-primary btn-small" onclick="runDebugDiagnostics()">Diagnostics</button>
<div style="margin-left:auto;display:flex;gap:0.5rem">
<button class="btn-small" onclick="clearDebugLog()">Clear</button>
<button class="btn-small" onclick="copyDebugLog()">Copy</button>
<button class="btn-small" onclick="exportDebugLog()">Export</button>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Admin Panel Modal --> <div class="toast-container" id="toastContainer"></div>
<div class="modal-overlay" id="adminModal">
<div class="modal admin-modal">
<div class="modal-header">
<h2>🛡️ Admin Panel</h2>
<button class="modal-close" id="adminCloseBtn"></button>
</div>
<div class="modal-body">
<div class="admin-tabs">
<button class="admin-tab active" data-tab="users">Users</button>
<button class="admin-tab" data-tab="models">Models</button>
<button class="admin-tab" data-tab="providers">Providers</button>
<button class="admin-tab" data-tab="settings">Settings</button>
<button class="admin-tab" data-tab="stats">Stats</button>
</div>
<!-- Users Tab --> <!-- Vendor libs -->
<div class="admin-tab-content active" id="adminUsersTab"> <script src="vendor/marked.min.js" onerror="this.onerror=null;this.src='https://cdnjs.cloudflare.com/ajax/libs/marked/16.3.0/lib/marked.umd.min.js'"></script>
<div class="admin-add-user-toggle"> <script src="vendor/purify.min.js" onerror="this.onerror=null;this.src='https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.4/purify.min.js'"></script>
<button class="btn btn-primary btn-small" id="adminShowAddUser">+ Add User</button>
</div>
<div class="admin-add-user-form" id="adminAddUserForm" style="display:none">
<div class="admin-form-row">
<input type="text" id="adminNewUsername" placeholder="Username" class="admin-input">
<input type="email" id="adminNewEmail" placeholder="Email" class="admin-input">
</div>
<div class="admin-form-row">
<input type="password" id="adminNewPassword" placeholder="Password (min 8)" class="admin-input">
<select id="adminNewRole" class="admin-role-select">
<option value="user">user</option>
<option value="admin">admin</option>
<option value="moderator">moderator</option>
</select>
</div>
<div class="admin-form-row">
<button class="btn btn-primary btn-small" id="adminCreateUserBtn">Create</button>
<button class="btn btn-secondary btn-small" id="adminCancelAddUser">Cancel</button>
</div>
</div>
<div id="adminUserList" class="admin-user-list">Loading...</div>
</div>
<!-- Reset Password Dialog (inline overlay) --> <script src="js/debug.js?v=0.5.2"></script>
<div class="admin-reset-dialog" id="adminResetDialog" style="display:none"> <script src="js/api.js?v=0.5.2"></script>
<div class="admin-reset-card"> <script src="js/ui.js?v=0.5.2"></script>
<h3>Reset Password</h3> <script src="js/app.js?v=0.5.2"></script>
<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> <!-- /appContainer -->
<!-- ── Auth Splash Gate ────────────────── -->
<div class="splash-gate" id="splashGate" style="display:none">
<div class="splash-card">
<div class="splash-brand">
<div class="splash-logo">🔀</div>
<h1>Chat Switchboard</h1>
<p class="splash-tagline">Multi-Model AI Chat</p>
</div>
<div class="splash-form">
<div class="auth-tabs">
<button class="auth-tab active" id="authTabLogin">Sign In</button>
<button class="auth-tab" id="authTabRegister">Register</button>
</div>
<div id="authLoginForm">
<div class="form-group">
<label for="authLogin">Username or Email</label>
<input type="text" id="authLogin" placeholder="jeff or jeff@example.com" autocomplete="username">
</div>
<div class="form-group">
<label for="authPassword">Password</label>
<input type="password" id="authPassword" placeholder="••••••••" autocomplete="current-password">
</div>
</div>
<div id="authRegisterForm" style="display:none;">
<div class="form-group">
<label for="authUsername">Username</label>
<input type="text" id="authUsername" placeholder="Choose a username" autocomplete="username">
</div>
<div class="form-group">
<label for="authEmail">Email</label>
<input type="email" id="authEmail" placeholder="you@example.com" autocomplete="email">
</div>
<div class="form-group">
<label for="authRegPassword">Password</label>
<input type="password" id="authRegPassword" placeholder="At least 8 characters" autocomplete="new-password">
</div>
</div>
<div class="auth-error" id="authError"></div>
<div class="splash-actions">
<button class="btn btn-primary btn-full" id="authLoginBtn">Sign In</button>
<button class="btn btn-primary btn-full" id="authRegisterBtn" style="display:none;">Create Account</button>
<div class="splash-divider"><span>or</span></div>
<button class="btn btn-secondary btn-full" id="authSkipBtn">Use Offline</button>
</div>
</div>
</div>
</div>
<!-- Debug Modal (Ctrl+Shift+L) -->
<div class="modal-overlay" id="debugModal">
<div class="modal debug-modal">
<div class="modal-header">
<h2>🐛 Debug Log</h2>
<button class="modal-close" onclick="closeDebugModal()"></button>
</div>
<div class="debug-tabs">
<button class="debug-tab active" data-tab="console" onclick="switchDebugTab('console')">
Console (<span id="debugConsoleCount">0</span>)
</button>
<button class="debug-tab" data-tab="network" onclick="switchDebugTab('network')">
Network (<span id="debugNetworkCount">0</span>)
</button>
<button class="debug-tab" data-tab="state" onclick="switchDebugTab('state')">
State
</button>
</div>
<div class="modal-body debug-modal-body">
<!-- Console Tab -->
<div class="debug-tab-content" id="debugConsoleTab">
<div class="debug-toolbar">
<label class="debug-check">
<input type="checkbox" id="debugFilterErrors"> Errors only
</label>
<label class="debug-check">
<input type="checkbox" id="debugAutoScroll" checked> Auto-scroll
</label>
</div>
<div id="debugConsoleContent" class="debug-content"></div>
</div>
<!-- Network Tab -->
<div class="debug-tab-content" id="debugNetworkTab" style="display:none">
<div id="debugNetworkContent" class="debug-content"></div>
</div>
<!-- State Tab -->
<div class="debug-tab-content" id="debugStateTab" style="display:none">
<div id="debugStateContent" class="debug-content"></div>
</div>
</div>
<div class="modal-footer debug-footer">
<button class="btn btn-primary btn-small" onclick="runDebugDiagnostics()">🔍 Run Diagnostics</button>
<div style="margin-left: auto; display: flex; gap: 0.5rem;">
<button class="btn btn-secondary btn-small" onclick="clearDebugLog()">🗑️ Clear</button>
<button class="btn btn-secondary btn-small" onclick="copyDebugLog()">📋 Copy</button>
<button class="btn btn-secondary btn-small" onclick="exportDebugLog()">📥 Export</button>
</div>
</div>
</div>
</div>
<div class="toast-container" id="toastContainer"></div>
<script src="js/debug.js?v=0.4.1"></script>
<script src="js/storage.js?v=0.4.1"></script>
<script src="js/backend.js?v=0.4.1"></script>
<script src="js/state.js?v=0.4.1"></script>
<script src="js/api.js?v=0.4.1"></script>
<script src="js/ui.js?v=0.4.1"></script>
<script src="js/admin.js?v=0.4.1"></script>
<script src="js/app.js?v=0.4.1"></script>
</body> </body>
</html> </html>

View File

@@ -1,251 +1,240 @@
// ========================================== // ==========================================
// API Functions // Chat Switchboard API Client (v0.5.0)
// ==========================================
// Backend-only mode. Handles auth tokens and
// all HTTP calls. No offline fallback.
// ========================================== // ==========================================
async function fetchModels(showInModal = true) { const 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;
}

View File

@@ -1,490 +1,488 @@
// ========================================== // ==========================================
// Chat Switchboard - Main Application // Chat Switchboard Application (v0.5.2)
// ========================================== // ==========================================
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();
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 (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'); UI.restoreSidebar();
splash.classList.remove('visible'); await loadSettings();
splash.style.display = 'none'; // Hide splash await loadChats();
app.classList.remove('app-hidden'); await fetchModels();
app.style.display = ''; // Show app UI.renderChatList();
UI.updateModelSelector();
UI.updateUser();
UI.showAdminButton(API.isAdmin);
initListeners();
console.log('✅ Chat Switchboard ready');
} }
async function authSuccess() { // ── Settings ─────────────────────────────────
hideSplashGate();
await loadSettingsFromBackend(); async function loadSettings() {
await initApp(); try {
showToast(`✅ Welcome, ${Backend.user?.display_name || Backend.user?.username || 'user'}!`, 'success'); const remote = await API.getSettings();
if (remote && typeof remote === 'object') {
if (remote.model) App.settings.model = remote.model;
if (remote.system_prompt !== undefined) App.settings.systemPrompt = remote.system_prompt;
if (remote.max_tokens) App.settings.maxTokens = remote.max_tokens;
if (remote.temperature !== undefined) App.settings.temperature = remote.temperature;
}
} catch (e) { console.warn('Settings load failed:', e.message); }
} }
function initializeEventListeners() { async function saveSettings() {
// Settings modal try {
document.getElementById('settingsBtn').addEventListener('click', openSettings); await API.updateSettings({
document.getElementById('configureBtn').addEventListener('click', openSettings); model: App.settings.model,
document.getElementById('closeModalBtn').addEventListener('click', closeSettings); system_prompt: App.settings.systemPrompt,
document.getElementById('cancelBtn').addEventListener('click', closeSettings); max_tokens: App.settings.maxTokens,
document.getElementById('saveBtn').addEventListener('click', handleSaveSettings); temperature: App.settings.temperature,
document.getElementById('fetchModelsBtn').addEventListener('click', () => fetchModels(true)); });
} catch (e) { console.warn('Settings sync failed:', e.message); }
// Profile
document.getElementById('profileChangePwBtn').addEventListener('click', () => {
document.getElementById('profileChangePwForm').style.display = '';
});
document.getElementById('profileSavePwBtn').addEventListener('click', handleChangePassword);
// Providers (managed mode)
document.getElementById('providerShowAddBtn').addEventListener('click', showProviderForm);
document.getElementById('providerCancelBtn').addEventListener('click', hideProviderForm);
document.getElementById('providerCreateBtn').addEventListener('click', handleCreateProvider);
document.getElementById('providerType').addEventListener('change', updateProviderEndpointHint);
// Chat controls
document.getElementById('newChatBtn').addEventListener('click', newChat);
document.getElementById('sendBtn').addEventListener('click', sendMessage);
document.getElementById('stopBtn').addEventListener('click', stopGeneration);
document.getElementById('regenerateBtn').addEventListener('click', regenerateResponse);
document.getElementById('toggleSidebarBtn').addEventListener('click', toggleSidebar);
document.getElementById('quickFetchBtn').addEventListener('click', () => fetchModels(false));
// Quick model selector
document.getElementById('quickModel').addEventListener('change', function() {
if (this.value) {
State.settings.model = this.value;
saveSettings();
}
});
// Export dropdown
document.getElementById('exportBtn').addEventListener('click', function(e) {
e.stopPropagation();
document.getElementById('exportDropdown').classList.toggle('show');
});
document.addEventListener('click', function() {
document.getElementById('exportDropdown').classList.remove('show');
});
// Message input
const messageInput = document.getElementById('messageInput');
messageInput.addEventListener('keydown', handleKeyDown);
messageInput.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 200) + 'px';
});
// Settings modal model sync
document.getElementById('model').addEventListener('change', function() {
if (this.value) document.getElementById('modelCustom').value = '';
});
document.getElementById('modelCustom').addEventListener('input', function() {
if (this.value) document.getElementById('model').value = '';
});
// Close modals on overlay click
document.getElementById('settingsModal').addEventListener('click', function(e) {
if (e.target === this) closeSettings();
});
// Connection status click
document.getElementById('connectionStatus').addEventListener('click', handleConnectionClick);
// Admin panel
document.getElementById('adminBtn').addEventListener('click', openAdmin);
document.getElementById('adminCloseBtn').addEventListener('click', closeAdmin);
document.querySelectorAll('.admin-tab').forEach(tab => {
tab.addEventListener('click', () => switchAdminTab(tab.dataset.tab));
});
document.getElementById('adminSaveSettings').addEventListener('click', saveAdminSettings);
document.getElementById('adminModal').addEventListener('click', function(e) {
if (e.target === this) closeAdmin();
});
// Global keyboard shortcuts
document.addEventListener('keydown', function(e) {
if (e.ctrlKey && e.key === ',') {
e.preventDefault();
openSettings();
}
if (e.ctrlKey && e.key === 'b') {
e.preventDefault();
toggleSidebar();
}
if (e.key === 'Escape') {
if (State.isGenerating) {
stopGeneration();
} else if (document.getElementById('settingsModal').classList.contains('active')) {
closeSettings();
}
}
});
} }
function handleKeyDown(e) { // ── Models ───────────────────────────────────
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); async function fetchModels() {
sendMessage(); try {
const data = await API.listEnabledModels();
App.models = (data.models || []).map(m => ({
id: m.model_id || m.id,
provider: m.provider_name || m.provider || '',
configId: m.config_id || null
}));
if (App.models.length === 0) {
const raw = await API.listAllModels();
App.models = (raw.models || []).map(m => ({
id: m.id || m.name,
provider: m.owned_by || m.provider || '',
configId: m.config_id || null
}));
}
App.models.sort((a, b) => a.id.localeCompare(b.id));
console.log(`📋 Loaded ${App.models.length} models`);
} catch (e) { console.warn('Model fetch failed:', e.message); }
UI.updateModelSelector();
}
// ── Chat Management ──────────────────────────
async function loadChats() {
try {
const resp = await API.listChats();
App.chats = (resp.data || []).map(c => ({
id: c.id,
title: c.title,
model: c.model || '',
messageCount: c.message_count || 0,
messages: [],
updatedAt: c.updated_at
}));
} catch (e) {
console.error('Failed to load chats:', e.message);
UI.toast('Failed to load chats', 'error');
} }
} }
function handleSaveSettings() { async function selectChat(chatId) {
State.settings.apiEndpoint = document.getElementById('apiEndpoint').value.trim(); App.currentChatId = chatId;
State.settings.apiKey = document.getElementById('apiKey').value.trim(); UI.renderChatList();
const modelCustom = document.getElementById('modelCustom').value.trim(); const chat = App.chats.find(c => c.id === chatId);
const modelSelect = document.getElementById('model').value; if (!chat) return;
State.settings.model = modelCustom || modelSelect || 'gpt-3.5-turbo';
State.settings.stream = document.getElementById('streamResponse').checked; if (chat.messages.length === 0 && chat.messageCount > 0) {
State.settings.saveHistory = document.getElementById('saveHistory').checked; try {
State.settings.showThinking = document.getElementById('showThinking').checked; const resp = await API.listMessages(chatId);
State.settings.systemPrompt = document.getElementById('systemPrompt').value.trim(); chat.messages = (resp.data || []).map(m => ({
State.settings.maxTokens = parseInt(document.getElementById('maxTokens').value) || 4096; role: m.role, content: m.content, model: m.model || '', timestamp: m.created_at
State.settings.temperature = parseFloat(document.getElementById('temperature').value) || 0.7; }));
State.settings.topP = parseFloat(document.getElementById('topP').value) || 1; } catch (e) {
State.settings.presencePenalty = parseFloat(document.getElementById('presencePenalty').value) || 0; console.error('Failed to load messages:', e.message);
UI.toast('Failed to load messages', 'error');
}
}
saveSettings(); UI.renderMessages(chat.messages);
saveProfile(); // async, fire-and-forget UI.showRegenerate(chat.messages.some(m => m.role === 'assistant'));
updateQuickModelSelector();
closeSettings();
showToast('✅ Settings saved', 'success');
} }
function newChat() { async function newChat() {
State.currentChatId = null; App.currentChatId = null;
document.getElementById('chatMessages').innerHTML = ` UI.renderChatList();
<div class="empty-state"> UI.showEmptyState();
<div class="empty-state-icon"> UI.showRegenerate(false);
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
</div>
<h2>Start a Conversation</h2>
<p>Type a message below to begin</p>
</div>
`;
document.getElementById('regenerateBtn').style.display = 'none';
renderChatHistory();
document.getElementById('messageInput').focus(); 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) newChat();
// In managed mode, fetch messages if not cached UI.renderChatList();
if (Backend.isManaged && chat.messages.length === 0 && chat.messageCount > 0) { } catch (e) { UI.toast('Failed to delete: ' + e.message, 'error'); }
await loadMessages(chatId);
}
if (chat.model) {
State.settings.model = chat.model;
updateQuickModelSelector();
}
renderMessages(chat.messages);
renderChatHistory();
updateRegenerateButton(chat.messages);
} }
async function handleDeleteChat(chatId, event) { // ── Send Message ─────────────────────────────
event.stopPropagation();
if (confirm('Delete this chat?')) {
await deleteChat(chatId);
if (State.currentChatId === chatId) {
newChat();
}
renderChatHistory();
showToast('🗑️ Chat deleted', 'success');
}
}
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) { if (!App.currentChatId) {
messages.push({ role: 'system', content: State.settings.systemPrompt }); try {
const title = text.slice(0, 50) + (text.length > 50 ? '...' : '');
const resp = await API.createChat(title, model, App.settings.systemPrompt);
const chat = { id: resp.id, title: resp.title, model, messages: [], messageCount: 0, updatedAt: resp.updated_at };
App.chats.unshift(chat);
App.currentChatId = chat.id;
UI.renderChatList();
} catch (e) { UI.toast('Failed to create chat: ' + e.message, 'error'); return; }
} }
if (State.currentChatId) { const chat = App.chats.find(c => c.id === App.currentChatId);
const chat = getChat(State.currentChatId); if (!chat) return;
if (chat) messages = [...chat.messages];
}
messages.push({ chat.messages.push({ role: 'user', content: text, timestamp: new Date().toISOString() });
role: 'user', UI.renderMessages(chat.messages);
content: message,
timestamp: new Date().toISOString()
});
if (!State.currentChatId) { App.isGenerating = true;
const title = message.substring(0, 50) + (message.length > 50 ? '...' : ''); App.abortController = new AbortController();
const chat = await createChat(title, messages); UI.setGenerating(true);
if (!chat) return;
State.currentChatId = chat.id; try {
} else { const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal);
await updateChat(State.currentChatId, { messages }); const assistantContent = await UI.streamResponse(resp, chat.messages);
// In unmanaged mode, persist user message to backend (if connected) chat.messages.push({ role: 'assistant', content: assistantContent, model, timestamp: new Date().toISOString() });
// In managed mode, the completion proxy persists both messages chat.messageCount = chat.messages.length;
if (!Backend.isManaged) { UI.showRegenerate(true);
await persistMessage(State.currentChatId, 'user', message); } catch (e) {
if (e.name === 'AbortError') {
UI.toast('Generation stopped', 'warning');
} else {
console.error('Completion error:', e);
const msg = e.message || '';
if (msg.includes('provider error') && msg.includes('401')) {
UI.toast('Provider API key rejected — check Settings → Providers', 'error');
} else if (msg.includes('provider error') && msg.includes('429')) {
UI.toast('Provider rate limit — wait and retry', 'warning');
} else if (msg.includes('no API config') || msg.includes('no model')) {
UI.toast('No provider configured — add one in Settings', 'error');
} else {
UI.toast(msg, 'error');
}
} }
} finally {
App.isGenerating = false;
App.abortController = null;
UI.setGenerating(false);
} }
renderMessages(messages);
renderChatHistory();
await sendApiRequest(messages);
} }
async function regenerateResponse() { function stopGeneration() { if (App.abortController) App.abortController.abort(); }
if (State.isGenerating || !State.currentChatId) return;
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) while (chat.messages.length && chat.messages[chat.messages.length - 1].role === 'assistant') chat.messages.pop();
while (chat.messages.length > 0 && chat.messages[chat.messages.length - 1].role === 'assistant') {
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); const lastUser = chat.messages[chat.messages.length - 1];
if (lastUser.role !== 'user') return;
await sendApiRequest(chat.messages); 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') {
const msg = e.message || '';
if (msg.includes('provider error') && msg.includes('401')) {
UI.toast('Provider API key rejected — check Settings', 'error');
} else { UI.toast(msg, '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; if (health && health.registration_enabled === false) {
document.getElementById('authTabRegister').style.display = 'none';
document.getElementById('authLoginBtn').addEventListener('click', handleLogin); }
document.getElementById('authRegisterBtn').addEventListener('click', handleRegister);
document.getElementById('authTabLogin').addEventListener('click', () => switchAuthTab('login'));
document.getElementById('authTabRegister').addEventListener('click', () => switchAuthTab('register'));
document.getElementById('authSkipBtn').addEventListener('click', skipAuth);
// Enter key in auth forms
document.getElementById('authPassword').addEventListener('keydown', function(e) {
if (e.key === 'Enter') handleLogin();
});
document.getElementById('authRegPassword').addEventListener('keydown', function(e) {
if (e.key === 'Enter') handleRegister();
});
} }
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 API.login(login, password); await startApp(); }
await Backend.login(login, password); catch (e) { setAuthError(e.message); }
await authSuccess(); finally { setAuthLoading(false); }
} catch (e) {
document.getElementById('authError').textContent = e.message;
} finally {
setAuthLoading(false);
}
} }
async function handleRegister() { 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 API.register(username, email, password); await startApp(); }
await Backend.register(username, email, password); catch (e) { setAuthError(e.message); }
await authSuccess(); finally { setAuthLoading(false); }
} catch (e) {
document.getElementById('authError').textContent = e.message;
} finally {
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;
// Sidebar
document.getElementById('sidebarToggle').addEventListener('click', UI.toggleSidebar);
document.getElementById('newChatBtn').addEventListener('click', newChat);
// User flyout
document.getElementById('userMenuBtn').addEventListener('click', (e) => {
e.stopPropagation();
UI.toggleUserMenu();
});
document.addEventListener('click', (e) => {
if (!e.target.closest('.sidebar-bottom')) UI.closeUserMenu();
});
// Flyout items
document.getElementById('menuSettings').addEventListener('click', () => { UI.closeUserMenu(); UI.openSettings(); });
document.getElementById('menuAdmin').addEventListener('click', () => { UI.closeUserMenu(); UI.openAdmin(); });
document.getElementById('menuDebug').addEventListener('click', () => { UI.closeUserMenu(); openDebugModal(); });
document.getElementById('menuSignout').addEventListener('click', () => { UI.closeUserMenu(); handleLogout(); });
// Chat actions
document.getElementById('sendBtn').addEventListener('click', sendMessage);
document.getElementById('stopBtn').addEventListener('click', stopGeneration);
document.getElementById('regenerateBtn').addEventListener('click', regenerate);
// Model selector
document.getElementById('modelSelect').addEventListener('change', function() {
if (this.value) { App.settings.model = this.value; saveSettings(); }
});
document.getElementById('fetchModelsBtn').addEventListener('click', async () => {
await fetchModels();
UI.toast(`Loaded ${App.models.length} models`, 'success');
});
// Settings modal
document.getElementById('settingsCloseBtn').addEventListener('click', UI.closeSettings);
document.getElementById('settingsSaveBtn').addEventListener('click', handleSaveSettings);
document.getElementById('profileChangePwBtn').addEventListener('click', () => {
document.getElementById('profileChangePwForm').style.display = '';
});
document.getElementById('profileSavePwBtn').addEventListener('click', handleChangePassword);
document.getElementById('providerShowAddBtn').addEventListener('click', UI.showProviderForm);
document.getElementById('providerCancelBtn').addEventListener('click', UI.hideProviderForm);
document.getElementById('providerCreateBtn').addEventListener('click', handleCreateProvider);
// Admin modal
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);
// 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';
});
// Close modals on overlay click
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) { saveSettings();
el.className = 'connection-status available'; UI.updateModelSelector();
el.innerHTML = '<span class="status-dot"></span> Sign in'; UI.closeSettings();
el.title = 'Backend available click to sign in'; UI.toast('Settings saved', 'success');
} else {
el.className = 'connection-status offline';
el.innerHTML = '<span class="status-dot"></span> Offline';
el.title = 'Unmanaged mode data stored locally';
}
} }
async function handleConnectionClick() { async function handleChangePassword() {
if (Backend.isManaged) { const cur = document.getElementById('profileCurrentPw').value;
if (confirm('Sign out? Your chats are saved on the server.')) { const pw = document.getElementById('profileNewPw').value;
await Backend.logout(); if (!cur || !pw) return UI.toast('Fill in both fields', 'warning');
State.chats = []; if (pw.length < 8) return UI.toast('Min 8 characters', 'warning');
State.currentChatId = null; try {
showSplashGate(null); await API.changePassword(cur, pw);
initAuthListeners(); UI.toast('Password updated', 'success');
showToast('👋 Signed out', 'success'); document.getElementById('profileChangePwForm').style.display = 'none';
} document.getElementById('profileCurrentPw').value = '';
} else if (Backend.baseUrl) { document.getElementById('profileNewPw').value = '';
showSplashGate(null); } catch (e) { UI.toast(e.message, 'error'); }
initAuthListeners();
}
} }
// ── Post-LLM Persistence ──────────────────── async function handleCreateProvider() {
// Called by api.js after assistant response is complete. const name = document.getElementById('providerName').value.trim();
const provider = document.getElementById('providerType').value;
async function onAssistantResponse(content, model) { const endpoint = document.getElementById('providerEndpoint').value.trim();
if (Backend.isManaged && State.currentChatId) { const apiKey = document.getElementById('providerApiKey').value.trim();
await persistMessage(State.currentChatId, 'assistant', content, model); const model = document.getElementById('providerDefaultModel').value.trim();
} if (!name || !endpoint || !apiKey) return UI.toast('Fill in required fields', 'warning');
try {
await API.createConfig(name, provider, endpoint, apiKey, model);
UI.toast('Provider added', 'success');
UI.hideProviderForm();
UI.loadProviderList();
fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
} }
// Initialize on DOM ready async function deleteProvider(id, name) {
if (!confirm(`Remove provider "${name}"?`)) return;
try {
await API.deleteConfig(id);
UI.toast('Provider removed', 'success');
UI.loadProviderList();
fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
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);

View File

@@ -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)',
} }
}; };
} }
@@ -332,7 +330,7 @@ const DebugLog = {
badge.innerHTML = '🐛'; badge.innerHTML = '🐛';
badge.title = 'Debug Log (Ctrl+Shift+L)'; badge.title = 'Debug Log (Ctrl+Shift+L)';
badge.style.cssText = ` badge.style.cssText = `
position: fixed; bottom: 12px; right: 12px; z-index: 100000; position: fixed; bottom: 12px; left: 12px; z-index: 100000;
width: 36px; height: 36px; border-radius: 50%; width: 36px; height: 36px; border-radius: 50%;
background: var(--bg-secondary, #2a2a2a); border: 1px solid var(--border, #444); background: var(--bg-secondary, #2a2a2a); border: 1px solid var(--border, #444);
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
@@ -510,7 +508,8 @@ function switchDebugTab(tab) {
document.querySelectorAll('.debug-tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.debug-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.debug-tab-content').forEach(c => c.style.display = 'none'); document.querySelectorAll('.debug-tab-content').forEach(c => c.style.display = 'none');
document.querySelector(`.debug-tab[data-tab="${tab}"]`)?.classList.add('active'); document.querySelector(`.debug-tab[data-tab="${tab}"]`)?.classList.add('active');
document.getElementById(`debug${tab.charAt(0).toUpperCase() + tab.slice(1)}Tab`)?.style.display = ''; const panel = document.getElementById(`debug${tab.charAt(0).toUpperCase() + tab.slice(1)}Tab`);
if (panel) panel.style.display = '';
DebugLog.render(); DebugLog.render();
} }

View File

@@ -1,515 +1,512 @@
// ========================================== // ==========================================
// UI Functions // Chat Switchboard UI (v0.5.2)
// ========================================== // ==========================================
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) { // ── Sidebar ──────────────────────────────
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() { toggleSidebar() {
const quickSelect = document.getElementById('quickModel'); document.getElementById('sidebar').classList.toggle('collapsed');
const currentModel = State.settings.model; localStorage.setItem('sb_sidebar', document.getElementById('sidebar').classList.contains('collapsed') ? '1' : '0');
},
quickSelect.innerHTML = '';
restoreSidebar() {
if (State.models.length === 0) { if (localStorage.getItem('sb_sidebar') === '1') {
quickSelect.innerHTML = '<option value="">-- Fetch models first --</option>'; document.getElementById('sidebar').classList.add('collapsed');
if (currentModel) {
const opt = document.createElement('option');
opt.value = currentModel;
opt.textContent = currentModel;
quickSelect.appendChild(opt);
quickSelect.value = currentModel;
} }
return; },
}
State.models.forEach(model => {
const option = document.createElement('option');
option.value = model.id;
option.textContent = model.id;
quickSelect.appendChild(option);
});
if (currentModel) {
const exists = Array.from(quickSelect.options).some(opt => opt.value === currentModel);
if (exists) {
quickSelect.value = currentModel;
} else {
const opt = document.createElement('option');
opt.value = currentModel;
opt.textContent = currentModel + ' (custom)';
quickSelect.insertBefore(opt, quickSelect.firstChild);
quickSelect.value = currentModel;
}
}
}
function openSettings() { // ── User Menu Flyout ─────────────────────
updateSettingsUI();
updateProfileSection();
updateProviderSection();
document.getElementById('settingsModal').classList.add('active');
}
function updateProfileSection() { toggleUserMenu() {
const profileSection = document.getElementById('profileSection'); const flyout = document.getElementById('userFlyout');
const unmanagedSettings = document.getElementById('unmanagedSettings'); flyout.classList.toggle('open');
const modeBadge = document.getElementById('settingsModeBadge'); },
if (Backend.isManaged) { closeUserMenu() {
profileSection.style.display = ''; document.getElementById('userFlyout').classList.remove('open');
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() { // ── Chat List ────────────────────────────
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() { renderChatList() {
if (!Backend.isManaged) return; const el = document.getElementById('chatHistory');
if (App.chats.length === 0) {
const displayName = document.getElementById('profileDisplayName').value.trim(); el.innerHTML = '<div class="sidebar-empty">No conversations yet</div>';
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 => ` // Group by time
<div class="provider-row"> const now = new Date();
<div class="provider-info"> const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
<span class="provider-name">${escapeHtmlUI(c.name)}</span> const yesterday = new Date(today - 86400000);
<span class="provider-meta">${escapeHtmlUI(c.provider)} · ${escapeHtmlUI(c.model_default || 'no default')}</span> const weekAgo = new Date(today - 7 * 86400000);
</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() { const groups = { today: [], yesterday: [], week: [], older: [] };
document.getElementById('providerAddForm').style.display = '';
document.getElementById('providerShowAddBtn').style.display = 'none';
// Set default endpoint based on provider type
updateProviderEndpointHint();
}
function hideProviderForm() { App.chats.forEach(c => {
document.getElementById('providerAddForm').style.display = 'none'; const d = new Date(c.updatedAt);
document.getElementById('providerShowAddBtn').style.display = ''; if (d >= today) groups.today.push(c);
clearProviderForm(); else if (d >= yesterday) groups.yesterday.push(c);
} else if (d >= weekAgo) groups.week.push(c);
else groups.older.push(c);
});
function clearProviderForm() { let html = '';
document.getElementById('providerName').value = ''; const renderGroup = (label, chats) => {
document.getElementById('providerType').value = 'openai'; if (chats.length === 0) return;
document.getElementById('providerEndpoint').value = ''; html += `<div class="chat-group-label">${label}</div>`;
document.getElementById('providerApiKey').value = ''; chats.forEach(c => {
document.getElementById('providerDefaultModel').value = ''; const time = _relativeTime(c.updatedAt);
} html += `
<div class="chat-item ${c.id === App.currentChatId ? 'active' : ''}"
onclick="selectChat('${c.id}')">
<span class="chat-item-title">${esc(c.title)}</span>
<span class="chat-item-time">${time}</span>
<button class="chat-item-delete" onclick="deleteChat('${c.id}');event.stopPropagation();" title="Delete">✕</button>
</div>`;
});
};
function updateProviderEndpointHint() { renderGroup('Today', groups.today);
const type = document.getElementById('providerType').value; renderGroup('Yesterday', groups.yesterday);
const endpoint = document.getElementById('providerEndpoint'); renderGroup('Previous 7 days', groups.week);
if (!endpoint.value) { renderGroup('Older', groups.older);
endpoint.placeholder = type === 'anthropic'
? 'https://api.anthropic.com'
: 'https://api.openai.com/v1';
}
}
async function handleCreateProvider() { el.innerHTML = html;
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) { // ── Messages ─────────────────────────────
showToast('⚠️ Name, endpoint, and API key are required', 'warning');
return;
}
try { renderMessages(messages) {
await Backend.createConfig(name, provider, endpoint, apiKey, modelDefault || null); const el = document.getElementById('chatMessages');
showToast('✅ Provider added', 'success'); if (!messages || messages.length === 0) {
hideProviderForm(); this.showEmptyState();
loadProviderList(); return;
fetchModels(false); // Refresh model list }
} catch (e) {
showToast('❌ ' + e.message, 'error');
}
}
async function deleteProvider(configId, name) { el.innerHTML = messages
if (!confirm('Remove provider "' + name + '"?')) return; .filter(m => m.role !== 'system')
try { .map((m, i) => this._messageHTML(m, i))
await Backend.deleteConfig(configId); .join('');
showToast('✅ Provider removed', 'success'); this._scrollToBottom(true);
loadProviderList(); },
fetchModels(false);
} catch (e) {
showToast('❌ ' + e.message, 'error');
}
}
function escapeHtmlUI(str) { _messageHTML(msg, index) {
const div = document.createElement('div'); const isUser = msg.role === 'user';
div.textContent = str || ''; const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
return div.innerHTML; return `
} <div class="message ${msg.role}">
<div class="msg-inner">
function closeSettings() { <div class="msg-avatar">${isUser ? '👤' : '🤖'}</div>
document.getElementById('settingsModal').classList.remove('active'); <div class="msg-body">
} <div class="msg-head">
<span class="msg-role">${isUser ? 'You' : 'Assistant'}</span>
function toggleSidebar() { ${time ? `<span class="msg-time">${time}</span>` : ''}
document.getElementById('sidebar').classList.toggle('collapsed'); <div class="msg-actions">
} <button class="msg-action-btn" onclick="UI.copyMessage(${index})" title="Copy">Copy</button>
</div>
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="msg-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> },
`;
} showEmptyState() {
document.getElementById('chatMessages').innerHTML = `
<div class="empty-state">
<div class="empty-logo">🔀</div>
<h2>Chat Switchboard</h2>
<p>Select a model and start chatting</p>
</div>`;
},
// ── Streaming ────────────────────────────
async streamResponse(response, messages) {
const container = document.getElementById('chatMessages');
document.getElementById('typingIndicator')?.remove();
const div = document.createElement('div');
div.className = 'message assistant';
div.innerHTML = `
<div class="msg-inner">
<div class="msg-avatar">🤖</div>
<div class="msg-body">
<div class="msg-head"><span class="msg-role">Assistant</span></div>
<div class="msg-text" id="streamContent"></div>
</div>
</div>`;
container.appendChild(div);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let content = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') continue;
try {
const parsed = JSON.parse(data);
const delta = parsed.choices?.[0]?.delta?.content || '';
if (delta) {
content += delta;
document.getElementById('streamContent').innerHTML = formatMessage(content);
this._scrollToBottom();
}
} catch (e) { /* partial JSON */ }
}
}
return content;
},
// ── Model Selector ───────────────────────
updateModelSelector() {
const sel = document.getElementById('modelSelect');
const current = App.settings.model;
sel.innerHTML = '';
if (App.models.length === 0) {
sel.innerHTML = '<option value="">No models loaded</option>';
} else {
App.models.forEach(m => {
const opt = document.createElement('option');
opt.value = m.id;
opt.textContent = m.id + (m.provider ? ` (${m.provider})` : '');
sel.appendChild(opt);
});
}
if (current) {
const exists = [...sel.options].some(o => o.value === current);
if (exists) {
sel.value = current;
} else {
const opt = document.createElement('option');
opt.value = current;
opt.textContent = current + ' (custom)';
sel.insertBefore(opt, sel.firstChild);
sel.value = current;
}
}
// Sync settings modal selector
const settingsSel = document.getElementById('settingsModel');
if (settingsSel) {
settingsSel.innerHTML = sel.innerHTML;
if (current) settingsSel.value = current;
}
},
// ── User / Connection ────────────────────
updateUser() {
const name = API.user?.display_name || API.user?.username || 'User';
const letter = (name[0] || '?').toUpperCase();
document.getElementById('avatarLetter').textContent = letter;
document.getElementById('userName').textContent = name;
},
showAdminButton(show) {
document.getElementById('menuAdmin').style.display = show ? '' : 'none';
},
// ── Generating State ─────────────────────
setGenerating(on) {
document.getElementById('stopBtn').classList.toggle('visible', on);
document.getElementById('sendBtn').disabled = on;
if (on) {
const container = document.getElementById('chatMessages');
const typing = document.createElement('div');
typing.id = 'typingIndicator';
typing.className = 'message assistant';
typing.innerHTML = `
<div class="msg-inner">
<div class="msg-avatar">🤖</div>
<div class="msg-body"><div class="typing-dots"><span></span><span></span><span></span></div></div>
</div>`;
container.appendChild(typing);
this._scrollToBottom(true);
} 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 configured.</div>';
return;
}
el.innerHTML = list.map(c => `
<div class="provider-row">
<div>
<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">${esc(e.message)}</div>`;
}
},
showProviderForm() { document.getElementById('providerAddForm').style.display = ''; },
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');
const panel = document.getElementById(`admin${tab.charAt(0).toUpperCase() + tab.slice(1)}Tab`);
if (panel) panel.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 s = await API.adminGetStats(); el.innerHTML = `<pre>${esc(JSON.stringify(s, 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</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); ext = 'json'; mime = 'application/json'; }
else if (format === 'md') {
content = `# ${chat.title}\n\n` + chat.messages.filter(m => m.role !== 'system')
.map(m => `## ${m.role === 'user' ? 'You' : 'Assistant'}\n\n${m.content}`).join('\n\n---\n\n');
ext = 'md'; mime = 'text/markdown';
} else {
content = chat.messages.filter(m => m.role !== 'system').map(m => `[${m.role === 'user' ? 'You' : 'Assistant'}]\n${m.content}`).join('\n\n');
ext = 'txt'; mime = 'text/plain';
}
const blob = new Blob([content], { type: mime });
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
a.download = `${safeName}.${ext}`; a.click(); URL.revokeObjectURL(a.href);
},
// ── Message Actions ──────────────────────
copyMessage(index) {
const chat = App.chats.find(c => c.id === App.currentChatId);
if (chat?.messages[index]) {
navigator.clipboard.writeText(chat.messages[index].content)
.then(() => UI.toast('Copied', 'success'))
.catch(() => UI.toast('Copy failed', 'error'));
}
},
// ── Scroll ───────────────────────────────
_isNearBottom() {
const el = document.getElementById('chatMessages');
return el.scrollHeight - el.scrollTop - el.clientHeight < 80;
},
_scrollToBottom(force) {
if (force || this._isNearBottom()) {
const el = document.getElementById('chatMessages');
el.scrollTop = el.scrollHeight;
}
}
};
// ── Formatting ───────────────────────────────
function formatMessage(content) { function formatMessage(content) {
let formatted = content; if (!content) return '';
const thinkingBlocks = []; const thinkingBlocks = [];
let text = content;
// Extract thinking blocks FIRST (before escaping) text = text.replace(/<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi, (_, inner) => {
if (State.settings.showThinking) { const id = 'think-' + Math.random().toString(36).slice(2, 9);
formatted = formatted.replace(/<thinking>([\s\S]*?)<\/thinking>/gi, (match, thinkingContent) => { thinkingBlocks.push({ id, content: inner.trim() });
const blockId = 'thinking-' + Math.random().toString(36).substr(2, 9); return `THINK_PLACEHOLDER_${id}`;
// 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)
formatted = escapeHtml(formatted);
// Restore thinking blocks with properly-escaped content (single escape only)
for (const block of thinkingBlocks) {
const escapedContent = escapeHtml(block.content).replace(/\n/g, '<br>');
formatted = formatted.replace(`__THINKPLACEHOLDER_${block.id}__`, `
<div class="thinking-block" id="${block.id}">
<div class="thinking-header" onclick="toggleThinkingBlock('${block.id}')">
<span class="thinking-toggle">▼</span>
<span class="thinking-label">💭 Thinking</span>
</div>
<div class="thinking-content">${escapedContent}</div>
</div>
`);
}
// Code blocks with copy button
formatted = formatted.replace(/```(\w*)\n?([\s\S]*?)```/g, (match, lang, code) => {
const codeId = 'code-' + Math.random().toString(36).substr(2, 9);
return `<pre><code id="${codeId}" class="language-${lang}">${code.trim()}</code><button class="copy-code-btn" onclick="copyCode('${codeId}')">Copy</button></pre>`;
}); });
// Inline code let html;
formatted = formatted.replace(/`([^`]+)`/g, '<code>$1</code>'); if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
html = _formatMarked(text);
// Bold } else {
formatted = formatted.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>'); html = _formatBasic(text);
// Italic
formatted = formatted.replace(/\*([^*]+)\*/g, '<em>$1</em>');
// Line breaks (but not inside pre/code)
formatted = formatted.replace(/\n/g, '<br>');
return formatted;
}
function toggleThinkingBlock(blockId) {
const block = document.getElementById(blockId);
if (block) {
block.classList.toggle('collapsed');
} }
}
function escapeHtml(text) { for (const b of thinkingBlocks) {
const div = document.createElement('div'); const inner = esc(b.content).replace(/\n/g, '<br>');
div.textContent = text; const thinkHTML = App.settings.showThinking
return div.innerHTML; ? `<details class="thinking-block"><summary>💭 Thinking</summary><div class="thinking-content">${inner}</div></details>`
} : '';
html = html.replace(new RegExp(`<p>\\s*THINK_PLACEHOLDER_${b.id}\\s*</p>`, 'g'), thinkHTML);
function scrollToBottom() { html = html.replace(`THINK_PLACEHOLDER_${b.id}`, thinkHTML);
const container = document.getElementById('chatMessages');
container.scrollTop = container.scrollHeight;
}
function updateRegenerateButton(messages) {
const btn = document.getElementById('regenerateBtn');
const hasAssistantMessage = messages && messages.some(m => m.role === 'assistant');
btn.style.display = hasAssistantMessage ? 'flex' : 'none';
}
function showToast(message, type = 'success') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `
<span>${message}</span>
<button class="toast-close" onclick="this.parentElement.remove()">✕</button>
`;
container.appendChild(toast);
setTimeout(() => toast.remove(), 5000);
}
// Message actions
function copyMessage(index) {
const chat = getCurrentChat();
if (chat && chat.messages[index]) {
navigator.clipboard.writeText(chat.messages[index].content)
.then(() => showToast('📋 Copied to clipboard', 'success'))
.catch(() => showToast('❌ Failed to copy', 'error'));
} }
return html;
} }
function copyCode(codeId) { function _formatMarked(text) {
const codeElement = document.getElementById(codeId); const rendered = marked.parse(text, { breaks: true, gfm: true });
if (codeElement) { let html = DOMPurify.sanitize(rendered, { ADD_TAGS: ['details', 'summary'], ADD_ATTR: ['id', 'class', 'onclick'] });
navigator.clipboard.writeText(codeElement.textContent)
.then(() => showToast('📋 Code copied', 'success')) html = html.replace(/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g, (_, attrs, code) => {
.catch(() => showToast('❌ Failed to copy', 'error')); const codeId = 'code-' + Math.random().toString(36).slice(2, 9);
} return `<pre><code id="${codeId}"${attrs}>${code}</code><button class="copy-code-btn" onclick="navigator.clipboard.writeText(document.getElementById('${codeId}').textContent).then(()=>UI.toast('Copied','success'))">Copy</button></pre>`;
});
return html;
} }
function exportChat(format) { function _formatBasic(text) {
const chat = getCurrentChat(); let html = esc(text);
if (!chat) { html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => {
showToast('⚠️ No chat to export', 'warning'); const id = 'code-' + Math.random().toString(36).slice(2, 9);
return; return `<pre><code id="${id}" class="language-${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>`;
} });
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
let content, filename, mimeType; html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
const title = chat.title.replace(/[^a-z0-9]/gi, '_'); html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
html = html.replace(/\n/g, '<br>');
switch (format) { return html;
case 'markdown': }
content = `# ${chat.title}\n\n` + chat.messages
.filter(m => m.role !== 'system') function esc(s) {
.map(m => `## ${m.role === 'user' ? 'You' : 'Assistant'}\n\n${m.content}`) if (!s) return '';
.join('\n\n---\n\n'); const d = document.createElement('div');
filename = `${title}.md`; d.textContent = s;
mimeType = 'text/markdown'; return d.innerHTML;
break; }
case 'json':
content = JSON.stringify(chat, null, 2); // ── Helpers ──────────────────────────────────
filename = `${title}.json`;
mimeType = 'application/json'; function _relativeTime(dateStr) {
break; if (!dateStr) return '';
case 'text': const d = new Date(dateStr);
content = chat.messages const now = new Date();
.filter(m => m.role !== 'system') const diff = (now - d) / 1000;
.map(m => `[${m.role === 'user' ? 'You' : 'Assistant'}]\n${m.content}`)
.join('\n\n'); if (diff < 60) return 'now';
filename = `${title}.txt`; if (diff < 3600) return `${Math.floor(diff / 60)}m`;
mimeType = 'text/plain'; if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
break; if (diff < 604800) return `${Math.floor(diff / 86400)}d`;
} return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
showToast(`📥 Exported as ${format}`, 'success');
document.getElementById('exportDropdown').classList.remove('show');
} }

74
src/vendor/marked.min.js vendored Normal file

File diff suppressed because one or more lines are too long

3
src/vendor/purify.min.js vendored Normal file

File diff suppressed because one or more lines are too long