Merge branch 'changeset-0.5.0'
This commit is contained in:
58
Dockerfile
Normal file
58
Dockerfile
Normal 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
|
||||
28
docker-entrypoint.sh
Normal file
28
docker-entrypoint.sh
Normal 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
|
||||
1916
src/css/styles.css
1916
src/css/styles.css
File diff suppressed because it is too large
Load Diff
637
src/index.html
637
src/index.html
@@ -7,518 +7,229 @@
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="favicon-192.png">
|
||||
<link rel="stylesheet" href="css/styles.css?v=0.4.1">
|
||||
<link rel="stylesheet" href="css/styles.css?v=0.5.2">
|
||||
</head>
|
||||
<body>
|
||||
<div id="appContainer" class="app-hidden" style="display:none">
|
||||
<header class="header">
|
||||
<h1>🔀 Chat Switchboard</h1>
|
||||
<div class="header-actions">
|
||||
<div class="connection-status offline" id="connectionStatus" title="Unmanaged mode">
|
||||
<span class="status-dot"></span> Offline
|
||||
</div>
|
||||
<button class="btn btn-secondary" id="toggleSidebarBtn" title="Toggle Sidebar (Ctrl+B)">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="9" y1="3" x2="9" y2="21"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-secondary" id="exportBtn" title="Export Chat">
|
||||
📥 Export
|
||||
</button>
|
||||
<div class="dropdown-content" id="exportDropdown">
|
||||
<button class="dropdown-item" onclick="exportChat('markdown')">📄 Markdown</button>
|
||||
<button class="dropdown-item" onclick="exportChat('json')">📋 JSON</button>
|
||||
<button class="dropdown-item" onclick="exportChat('text')">📝 Plain Text</button>
|
||||
</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>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="main-container">
|
||||
<!-- ── App Shell ───────────────────────────── -->
|
||||
<div id="appContainer" class="app" style="display:none">
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<span class="sidebar-title">Chat History</span>
|
||||
</div>
|
||||
<button class="btn btn-primary new-chat-btn" id="newChatBtn">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
New Chat
|
||||
<div class="sidebar-top">
|
||||
<button class="sb-btn" id="sidebarToggle" title="Toggle sidebar">
|
||||
<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>
|
||||
</button>
|
||||
<div class="chat-history" id="chatHistory"></div>
|
||||
<button class="sb-btn" id="newChatBtn" title="New 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>
|
||||
<span class="sb-label">New Chat</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-chats" id="chatHistory"></div>
|
||||
|
||||
<!-- User area -->
|
||||
<div class="sidebar-bottom">
|
||||
<button class="user-btn" id="userMenuBtn">
|
||||
<div class="user-avatar" id="userAvatar"><span id="avatarLetter">?</span><span class="avatar-dot"></span></div>
|
||||
<span class="sb-label user-name" id="userName">User</span>
|
||||
</button>
|
||||
<div class="user-flyout" id="userFlyout">
|
||||
<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 Chat Area -->
|
||||
<main class="chat-area">
|
||||
<div class="chat-messages" id="chatMessages">
|
||||
<div class="empty-state" id="emptyState">
|
||||
<div class="empty-state-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
||||
</svg>
|
||||
<div class="model-bar">
|
||||
<select id="modelSelect" title="Select model"><option value="">Select a model</option></select>
|
||||
<button class="icon-btn" id="fetchModelsBtn" title="Refresh models">
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
<h2>Start a Conversation</h2>
|
||||
<p>Configure your API settings and start chatting</p>
|
||||
<button class="btn btn-primary" style="margin-top: 1.5rem;" id="configureBtn">⚙️ Configure API</button>
|
||||
|
||||
<div class="messages" id="chatMessages">
|
||||
<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-container">
|
||||
<div class="input-top-bar">
|
||||
<div class="model-selector">
|
||||
<label for="quickModel">Model:</label>
|
||||
<select id="quickModel">
|
||||
<option value="">-- Configure in Settings --</option>
|
||||
</select>
|
||||
<button class="btn btn-small btn-secondary" id="quickFetchBtn" title="Refresh models">🔄</button>
|
||||
</div>
|
||||
<div class="input-shortcuts">
|
||||
<kbd>Enter</kbd> send · <kbd>Shift+Enter</kbd> newline · <kbd>Esc</kbd> stop
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-wrapper">
|
||||
<textarea id="messageInput" placeholder="Type your message..." rows="1"></textarea>
|
||||
<div class="input-wrap">
|
||||
<textarea id="messageInput" placeholder="Send a message..." rows="1"></textarea>
|
||||
<div class="input-actions">
|
||||
<div class="input-left">
|
||||
<button class="btn btn-small btn-secondary" id="regenerateBtn" title="Regenerate last response" style="display: none;">🔄 Regenerate</button>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<button class="stop-btn" id="stopBtn">⏹ Stop</button>
|
||||
<button class="send-btn" id="sendBtn">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="22" y1="2" x2="11" y2="13"></line>
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
||||
</svg>
|
||||
Send
|
||||
<button 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>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
<div class="modal-overlay" id="settingsModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h2>⚙️ Settings</h2>
|
||||
<button class="modal-close" id="closeModalBtn">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Mode indicator -->
|
||||
<div class="settings-mode-badge" id="settingsModeBadge"></div>
|
||||
|
||||
<!-- Profile (managed mode only) -->
|
||||
<div class="settings-section" id="profileSection" style="display:none">
|
||||
<h3 class="settings-section-title">Profile</h3>
|
||||
<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>
|
||||
|
||||
<!-- API Settings (unmanaged / offline only) -->
|
||||
<div id="unmanagedSettings">
|
||||
<div class="form-group">
|
||||
<label for="apiEndpoint">API Endpoint</label>
|
||||
<input type="text" id="apiEndpoint" placeholder="https://api.openai.com/v1">
|
||||
<small>Base URL for the OpenAI-compatible API</small>
|
||||
</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-group">
|
||||
<label for="maxTokens">Max Tokens</label>
|
||||
<input type="number" id="maxTokens" value="4096" min="1" max="32768">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="temperature">Temperature</label>
|
||||
<input type="number" id="temperature" value="0.7" min="0" max="2" step="0.1">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="topP">Top P</label>
|
||||
<input type="number" id="topP" value="1" min="0" max="1" step="0.1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="presencePenalty">Presence Penalty</label>
|
||||
<input type="number" id="presencePenalty" value="0" min="-2" max="2" step="0.1">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" id="cancelBtn">Cancel</button>
|
||||
<button class="btn btn-primary" id="saveBtn">💾 Save Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin Panel Modal -->
|
||||
<div class="modal-overlay" id="adminModal">
|
||||
<div class="modal admin-modal">
|
||||
<div class="modal-header">
|
||||
<h2>🛡️ Admin Panel</h2>
|
||||
<button class="modal-close" id="adminCloseBtn">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="admin-tabs">
|
||||
<button class="admin-tab active" data-tab="users">Users</button>
|
||||
<button class="admin-tab" data-tab="models">Models</button>
|
||||
<button class="admin-tab" data-tab="providers">Providers</button>
|
||||
<button class="admin-tab" data-tab="settings">Settings</button>
|
||||
<button class="admin-tab" data-tab="stats">Stats</button>
|
||||
</div>
|
||||
|
||||
<!-- Users Tab -->
|
||||
<div class="admin-tab-content active" id="adminUsersTab">
|
||||
<div class="admin-add-user-toggle">
|
||||
<button class="btn btn-primary btn-small" id="adminShowAddUser">+ Add User</button>
|
||||
</div>
|
||||
<div class="admin-add-user-form" id="adminAddUserForm" style="display:none">
|
||||
<div class="admin-form-row">
|
||||
<input type="text" id="adminNewUsername" placeholder="Username" class="admin-input">
|
||||
<input type="email" id="adminNewEmail" placeholder="Email" class="admin-input">
|
||||
</div>
|
||||
<div class="admin-form-row">
|
||||
<input type="password" id="adminNewPassword" placeholder="Password (min 8)" class="admin-input">
|
||||
<select id="adminNewRole" class="admin-role-select">
|
||||
<option value="user">user</option>
|
||||
<option value="admin">admin</option>
|
||||
<option value="moderator">moderator</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="admin-form-row">
|
||||
<button class="btn btn-primary btn-small" id="adminCreateUserBtn">Create</button>
|
||||
<button class="btn btn-secondary btn-small" id="adminCancelAddUser">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="adminUserList" class="admin-user-list">Loading...</div>
|
||||
</div>
|
||||
|
||||
<!-- Reset Password Dialog (inline overlay) -->
|
||||
<div class="admin-reset-dialog" id="adminResetDialog" style="display:none">
|
||||
<div class="admin-reset-card">
|
||||
<h3>Reset Password</h3>
|
||||
<p id="adminResetTarget"></p>
|
||||
<input type="password" id="adminResetNewPw" placeholder="New password (min 8)" class="admin-input">
|
||||
<div class="admin-form-row">
|
||||
<button class="btn btn-primary btn-small" id="adminResetConfirmBtn">Reset</button>
|
||||
<button class="btn btn-secondary btn-small" id="adminResetCancelBtn">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Models Tab -->
|
||||
<div class="admin-tab-content" id="adminModelsTab" style="display:none">
|
||||
<div class="admin-models-header">
|
||||
<p class="admin-hint">Manage which models are available to users. Fetch from configured providers, then enable/disable and tag capabilities.</p>
|
||||
<button class="btn btn-primary btn-small" id="adminFetchModelsBtn">🔄 Fetch Models</button>
|
||||
</div>
|
||||
<div id="adminModelList" class="admin-model-list">
|
||||
<div class="admin-empty">No models configured. Add a provider first, then fetch models.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Providers Tab -->
|
||||
<div class="admin-tab-content" id="adminProvidersTab" style="display:none">
|
||||
<p class="admin-hint">Global providers are available to all users. Users can also add their own.</p>
|
||||
<div id="adminProviderList" class="provider-list"></div>
|
||||
<div class="provider-add-toggle">
|
||||
<button class="btn btn-secondary btn-small" id="adminProviderShowAddBtn">+ Add Global Provider</button>
|
||||
</div>
|
||||
<div class="provider-add-form" id="adminProviderAddForm" style="display:none">
|
||||
<div class="form-group">
|
||||
<label for="adminProviderName">Name</label>
|
||||
<input type="text" id="adminProviderName" placeholder="e.g. Shared OpenAI">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="adminProviderType">Provider</label>
|
||||
<select id="adminProviderType">
|
||||
<option value="openai">OpenAI-compatible</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="adminProviderEndpoint">Endpoint</label>
|
||||
<input type="text" id="adminProviderEndpoint" placeholder="https://api.openai.com/v1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="adminProviderApiKey">API Key</label>
|
||||
<input type="password" id="adminProviderApiKey" placeholder="sk-...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="adminProviderDefaultModel">Default Model (optional)</label>
|
||||
<input type="text" id="adminProviderDefaultModel" placeholder="gpt-4o">
|
||||
</div>
|
||||
<div class="admin-form-row">
|
||||
<button class="btn btn-primary btn-small" id="adminProviderCreateBtn">Save</button>
|
||||
<button class="btn btn-secondary btn-small" id="adminProviderCancelBtn">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Tab -->
|
||||
<div class="admin-tab-content" id="adminSettingsTab" style="display:none">
|
||||
<div class="form-group">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="adminRegToggle" checked>
|
||||
<span>Allow new user registration</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="adminSiteName">Site Name</label>
|
||||
<input type="text" id="adminSiteName" value="Chat Switchboard">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="adminTagline">Tagline</label>
|
||||
<input type="text" id="adminTagline" value="Multi-Model AI Chat">
|
||||
</div>
|
||||
<button class="btn btn-primary" id="adminSaveSettings">💾 Save</button>
|
||||
</div>
|
||||
|
||||
<!-- Stats Tab -->
|
||||
<div class="admin-tab-content" id="adminStatsTab" style="display:none">
|
||||
<div id="adminStats" class="admin-stats">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div> <!-- /appContainer -->
|
||||
|
||||
<!-- ── Auth Splash Gate ────────────────── -->
|
||||
<div class="splash-gate" id="splashGate" style="display:none">
|
||||
<!-- ── Auth Splash ─────────────────────────── -->
|
||||
<div class="splash" id="splashGate">
|
||||
<div class="splash-card">
|
||||
<div class="splash-brand">
|
||||
<div class="splash-logo">🔀</div>
|
||||
<h1>Chat Switchboard</h1>
|
||||
<p class="splash-tagline">Multi-Model AI Chat</p>
|
||||
<p>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>
|
||||
<button class="auth-tab active" id="authTabLogin" onclick="switchAuthTab('login')">Sign In</button>
|
||||
<button class="auth-tab" id="authTabRegister" onclick="switchAuthTab('register')">Register</button>
|
||||
</div>
|
||||
<div id="authLoginForm">
|
||||
<div class="form-group">
|
||||
<label 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 class="form-group"><label>Username or Email</label><input type="text" id="authLogin" autocomplete="username"></div>
|
||||
<div class="form-group"><label>Password</label><input type="password" id="authPassword" autocomplete="current-password" onkeydown="if(event.key==='Enter')handleLogin()"></div>
|
||||
</div>
|
||||
<div id="authRegisterForm" style="display:none">
|
||||
<div class="form-group"><label>Username</label><input type="text" id="authUsername" autocomplete="username"></div>
|
||||
<div class="form-group"><label>Email</label><input type="email" id="authEmail" autocomplete="email"></div>
|
||||
<div class="form-group"><label>Password</label><input type="password" id="authRegPassword" autocomplete="new-password" onkeydown="if(event.key==='Enter')handleRegister()"></div>
|
||||
</div>
|
||||
<div class="auth-error" id="authError"></div>
|
||||
<div class="splash-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 class="splash-error" id="splashError"></div>
|
||||
<div class="auth-actions">
|
||||
<button class="btn-primary btn-full" id="authLoginBtn" data-label="Sign In" onclick="handleLogin()">Sign In</button>
|
||||
<button class="btn-primary btn-full" id="authRegisterBtn" data-label="Create Account" onclick="handleRegister()" style="display:none">Create Account</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<!-- ── Settings Modal ──────────────────────── -->
|
||||
<div class="modal-overlay" id="settingsModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h2>Settings</h2><button class="modal-close" id="settingsCloseBtn">✕</button></div>
|
||||
<div class="modal-body">
|
||||
<section class="settings-section">
|
||||
<h3>Profile</h3>
|
||||
<div class="form-group"><label>Display Name</label><input type="text" id="profileDisplayName"></div>
|
||||
<div class="form-group"><label>Email</label><input type="email" id="profileEmail"></div>
|
||||
<button class="btn-small" id="profileChangePwBtn">Change Password</button>
|
||||
<div id="profileChangePwForm" style="display:none">
|
||||
<div class="form-group"><label>Current Password</label><input type="password" id="profileCurrentPw"></div>
|
||||
<div class="form-group"><label>New Password</label><input type="password" id="profileNewPw"></div>
|
||||
<button class="btn-small btn-primary" id="profileSavePwBtn">Save Password</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="settings-section">
|
||||
<h3>Chat</h3>
|
||||
<div class="form-group"><label>Default Model</label><select id="settingsModel"></select></div>
|
||||
<div class="form-group"><label>System Prompt</label><textarea id="settingsSystemPrompt" rows="3"></textarea></div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Max Tokens</label><input type="number" id="settingsMaxTokens" value="4096"></div>
|
||||
<div class="form-group"><label>Temperature</label><input type="number" id="settingsTemp" value="0.7" step="0.1" min="0" max="2"></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>
|
||||
|
||||
<!-- ── Admin Modal ─────────────────────────── -->
|
||||
<div class="modal-overlay" id="adminModal">
|
||||
<div class="modal modal-wide">
|
||||
<div class="modal-header"><h2>Admin Panel</h2><button class="modal-close" id="adminCloseBtn">✕</button></div>
|
||||
<div class="admin-tabs">
|
||||
<button class="admin-tab active" data-tab="users">Users</button>
|
||||
<button class="admin-tab" data-tab="providers">Providers</button>
|
||||
<button class="admin-tab" data-tab="models">Models</button>
|
||||
<button class="admin-tab" data-tab="settings">Settings</button>
|
||||
<button class="admin-tab" data-tab="stats">Stats</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="admin-tab-content" id="adminUsersTab"><div id="adminUserList"></div></div>
|
||||
<div class="admin-tab-content" id="adminProvidersTab" style="display:none"><div id="adminProviderList"></div></div>
|
||||
<div class="admin-tab-content" id="adminModelsTab" style="display:none"><div id="adminModelList"></div></div>
|
||||
<div class="admin-tab-content" id="adminSettingsTab" style="display:none">
|
||||
<label class="checkbox-label"><input type="checkbox" id="adminRegToggle" checked> Allow registration</label>
|
||||
<button class="btn-primary btn-small" id="adminSaveSettings">Save</button>
|
||||
</div>
|
||||
<div class="admin-tab-content" id="adminStatsTab" style="display:none"><div id="adminStats"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<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>
|
||||
<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 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 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>
|
||||
<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 class="toast-container" id="toastContainer"></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>
|
||||
<!-- Vendor libs -->
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<script src="js/debug.js?v=0.5.2"></script>
|
||||
<script src="js/api.js?v=0.5.2"></script>
|
||||
<script src="js/ui.js?v=0.5.2"></script>
|
||||
<script src="js/app.js?v=0.5.2"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
429
src/js/api.js
429
src/js/api.js
@@ -1,251 +1,240 @@
|
||||
// ==========================================
|
||||
// API Functions
|
||||
// Chat Switchboard – API Client (v0.5.0)
|
||||
// ==========================================
|
||||
// Backend-only mode. Handles auth tokens and
|
||||
// all HTTP calls. No offline fallback.
|
||||
// ==========================================
|
||||
|
||||
async function fetchModels(showInModal = true) {
|
||||
const btn = showInModal
|
||||
? document.getElementById('fetchModelsBtn')
|
||||
: document.getElementById('quickFetchBtn');
|
||||
if (btn) {
|
||||
btn.dataset.originalText = btn.innerHTML;
|
||||
btn.innerHTML = '⏳';
|
||||
btn.disabled = true;
|
||||
}
|
||||
const API = {
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
user: null,
|
||||
_refreshing: null,
|
||||
|
||||
// ── Bootstrap ────────────────────────────
|
||||
|
||||
loadTokens() {
|
||||
try {
|
||||
let models = [];
|
||||
|
||||
if (Backend.isManaged) {
|
||||
// Managed mode: fetch admin-curated enabled models
|
||||
const data = await Backend.listEnabledModels();
|
||||
models = (data.models || []).map(m => ({
|
||||
id: m.model_id || m.id,
|
||||
owned_by: m.provider_name || m.provider || null,
|
||||
config_id: m.config_id || null
|
||||
}));
|
||||
// Fallback: if no curated models, try raw aggregation
|
||||
if (models.length === 0) {
|
||||
const raw = await Backend.listAllModels();
|
||||
models = (raw.models || []).map(m => ({
|
||||
id: m.id || m.name,
|
||||
owned_by: m.owned_by || m.provider || null,
|
||||
config_id: m.config_id || null
|
||||
}));
|
||||
const saved = JSON.parse(localStorage.getItem('sb_auth') || 'null');
|
||||
if (saved) {
|
||||
this.accessToken = saved.accessToken || null;
|
||||
this.refreshToken = saved.refreshToken || null;
|
||||
this.user = saved.user || null;
|
||||
}
|
||||
} else {
|
||||
// Unmanaged mode: direct provider call
|
||||
const endpoint = showInModal
|
||||
? document.getElementById('apiEndpoint').value.trim()
|
||||
: State.settings.apiEndpoint;
|
||||
const apiKey = showInModal
|
||||
? document.getElementById('apiKey').value.trim()
|
||||
: State.settings.apiKey;
|
||||
|
||||
if (!endpoint || !apiKey) {
|
||||
showToast('⚠️ Configure API endpoint and key first', 'warning');
|
||||
if (!showInModal) openSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint.replace(/\/$/, '') + '/models', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`API Error: ${response.status}`);
|
||||
|
||||
const data = await response.json();
|
||||
const raw = data.data || data.models || data || [];
|
||||
models = Array.isArray(raw)
|
||||
? raw.map(m => ({
|
||||
id: m.id || m.name || m,
|
||||
owned_by: m.owned_by || null
|
||||
}))
|
||||
: [];
|
||||
}
|
||||
|
||||
State.models = models.sort((a, b) => a.id.localeCompare(b.id));
|
||||
saveModels();
|
||||
|
||||
if (showInModal) {
|
||||
populateModelSelect(document.getElementById('model'));
|
||||
}
|
||||
updateQuickModelSelector();
|
||||
showToast(`✅ Loaded ${State.models.length} models`, 'success');
|
||||
} catch (error) {
|
||||
console.error('Fetch models error:', error);
|
||||
showToast(`❌ Failed: ${error.message}`, 'error');
|
||||
} finally {
|
||||
if (btn) {
|
||||
btn.innerHTML = btn.dataset.originalText || '🔄';
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendApiRequest(messages) {
|
||||
const chatContainer = document.getElementById('chatMessages');
|
||||
|
||||
// Add typing indicator
|
||||
const typingDiv = document.createElement('div');
|
||||
typingDiv.className = 'message assistant';
|
||||
typingDiv.id = 'typingIndicator';
|
||||
typingDiv.innerHTML = `
|
||||
<div class="message-content">
|
||||
<div class="message-avatar">🤖</div>
|
||||
<div class="message-body">
|
||||
<div class="message-role">Assistant</div>
|
||||
<div class="typing-indicator"><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
chatContainer.appendChild(typingDiv);
|
||||
scrollToBottom();
|
||||
|
||||
State.isGenerating = true;
|
||||
State.abortController = new AbortController();
|
||||
document.getElementById('stopBtn').classList.add('visible');
|
||||
document.getElementById('sendBtn').disabled = true;
|
||||
|
||||
const model = document.getElementById('quickModel').value || State.settings.model;
|
||||
const userContent = messages[messages.length - 1]?.content || '';
|
||||
|
||||
try {
|
||||
let response;
|
||||
|
||||
if (Backend.isManaged) {
|
||||
// ── Managed mode: route through backend proxy ──
|
||||
// Backend loads conversation from DB, persists both messages.
|
||||
response = await Backend.streamCompletion(
|
||||
State.currentChatId,
|
||||
userContent,
|
||||
model,
|
||||
State.abortController.signal
|
||||
);
|
||||
} else {
|
||||
// ── Unmanaged mode: direct provider call ──
|
||||
const endpoint = State.settings.apiEndpoint.replace(/\/$/, '') + '/chat/completions';
|
||||
response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${State.settings.apiKey}`
|
||||
} catch (e) { /* corrupt storage */ }
|
||||
},
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
saveTokens() {
|
||||
localStorage.setItem('sb_auth', JSON.stringify({
|
||||
accessToken: this.accessToken,
|
||||
refreshToken: this.refreshToken,
|
||||
user: this.user
|
||||
}));
|
||||
},
|
||||
|
||||
document.getElementById('typingIndicator')?.remove();
|
||||
clearTokens() {
|
||||
this.accessToken = null;
|
||||
this.refreshToken = null;
|
||||
this.user = null;
|
||||
localStorage.removeItem('sb_auth');
|
||||
},
|
||||
|
||||
let assistantContent = '';
|
||||
const isStream = Backend.isManaged ? true : State.settings.stream;
|
||||
get isAuthed() { return !!this.accessToken; },
|
||||
get isAdmin() { return this.user?.role === 'admin'; },
|
||||
|
||||
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);
|
||||
// ── Auth ─────────────────────────────────
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let sseBuffer = ''; // Buffer for incomplete SSE lines
|
||||
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();
|
||||
},
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
async login(login, password) {
|
||||
const data = await this._post('/api/v1/auth/login', { login, password }, true);
|
||||
this._setAuth(data);
|
||||
return data;
|
||||
},
|
||||
|
||||
sseBuffer += decoder.decode(value, { stream: true });
|
||||
const lines = sseBuffer.split('\n');
|
||||
// Keep the last (potentially incomplete) line in the buffer
|
||||
sseBuffer = lines.pop() || '';
|
||||
async register(username, email, password) {
|
||||
const data = await this._post('/api/v1/auth/register', { username, email, password }, true);
|
||||
this._setAuth(data);
|
||||
return data;
|
||||
},
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') continue;
|
||||
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 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';
|
||||
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;
|
||||
},
|
||||
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: assistantContent,
|
||||
timestamp: new Date().toISOString()
|
||||
_setAuth(data) {
|
||||
this.accessToken = data.access_token;
|
||||
this.refreshToken = data.refresh_token;
|
||||
this.user = data.user;
|
||||
this.saveTokens();
|
||||
},
|
||||
|
||||
// ── Chats ────────────────────────────────
|
||||
|
||||
listChats(page = 1, perPage = 100) {
|
||||
return this._get(`/api/v1/chats?page=${page}&per_page=${perPage}`);
|
||||
},
|
||||
createChat(title, model, systemPrompt) {
|
||||
const body = { title };
|
||||
if (model) body.model = model;
|
||||
if (systemPrompt) body.system_prompt = systemPrompt;
|
||||
return this._post('/api/v1/chats', body);
|
||||
},
|
||||
getChat(id) { return this._get(`/api/v1/chats/${id}`); },
|
||||
updateChat(id, updates) { return this._put(`/api/v1/chats/${id}`, updates); },
|
||||
deleteChat(id) { return this._del(`/api/v1/chats/${id}`); },
|
||||
|
||||
// ── Messages ─────────────────────────────
|
||||
|
||||
listMessages(chatId, page = 1, perPage = 200) {
|
||||
return this._get(`/api/v1/chats/${chatId}/messages?page=${page}&per_page=${perPage}`);
|
||||
},
|
||||
|
||||
// ── Completions ──────────────────────────
|
||||
|
||||
async streamCompletion(chatId, content, model, signal) {
|
||||
const body = { chat_id: chatId, content, stream: true };
|
||||
if (model) body.model = model;
|
||||
|
||||
let resp = await fetch('/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: this._authHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
signal
|
||||
});
|
||||
|
||||
// In unmanaged mode, persist locally + to backend if connected
|
||||
await updateChat(State.currentChatId, { messages, model });
|
||||
if (!Backend.isManaged) {
|
||||
await onAssistantResponse(assistantContent, model);
|
||||
if (resp.status === 401 && this.refreshToken) {
|
||||
if (await this.refresh()) {
|
||||
resp = await fetch('/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: this._authHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
signal
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!isStream) {
|
||||
renderMessages(messages);
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.error || `HTTP ${resp.status}`);
|
||||
}
|
||||
return resp;
|
||||
},
|
||||
|
||||
updateRegenerateButton(messages);
|
||||
// ── Models ───────────────────────────────
|
||||
|
||||
} catch (error) {
|
||||
document.getElementById('typingIndicator')?.remove();
|
||||
listEnabledModels() { return this._get('/api/v1/models/enabled'); },
|
||||
listAllModels() { return this._get('/api/v1/models'); },
|
||||
|
||||
if (error.name === 'AbortError') {
|
||||
showToast('⚠️ Generation stopped', 'warning');
|
||||
} else {
|
||||
console.error('API Error:', error);
|
||||
showToast(`❌ ${error.message}`, 'error');
|
||||
// ── API Configs (user providers) ─────────
|
||||
|
||||
listConfigs() { return this._get('/api/v1/api-configs'); },
|
||||
createConfig(name, provider, endpoint, apiKey, modelDefault) {
|
||||
return this._post('/api/v1/api-configs', {
|
||||
name, provider, endpoint, api_key: apiKey, model_default: modelDefault
|
||||
});
|
||||
},
|
||||
deleteConfig(id) { return this._del(`/api/v1/api-configs/${id}`); },
|
||||
|
||||
// ── Profile & Settings ───────────────────
|
||||
|
||||
getProfile() { return this._get('/api/v1/profile'); },
|
||||
updateProfile(updates) { return this._put('/api/v1/profile', updates); },
|
||||
changePassword(current, newPw) {
|
||||
return this._post('/api/v1/profile/password', { current_password: current, new_password: newPw });
|
||||
},
|
||||
getSettings() { return this._get('/api/v1/settings'); },
|
||||
updateSettings(settings) { return this._put('/api/v1/settings', settings); },
|
||||
|
||||
// ── Admin ────────────────────────────────
|
||||
|
||||
adminListUsers(p, pp) { return this._get(`/api/v1/admin/users?page=${p||1}&per_page=${pp||50}`); },
|
||||
adminCreateUser(username, email, password, role) {
|
||||
return this._post('/api/v1/admin/users', { username, email, password, role });
|
||||
},
|
||||
adminResetPassword(id, pw) { return this._post(`/api/v1/admin/users/${id}/reset-password`, { new_password: pw }); },
|
||||
adminUpdateRole(id, role) { return this._put(`/api/v1/admin/users/${id}/role`, { role }); },
|
||||
adminToggleActive(id, active) { return this._put(`/api/v1/admin/users/${id}/active`, { is_active: active }); },
|
||||
adminDeleteUser(id) { return this._del(`/api/v1/admin/users/${id}`); },
|
||||
adminGetSettings() { return this._get('/api/v1/admin/settings'); },
|
||||
adminUpdateSetting(key, value) { return this._put(`/api/v1/admin/settings/${key}`, value); },
|
||||
adminGetStats() { return this._get('/api/v1/admin/stats'); },
|
||||
|
||||
adminListGlobalConfigs() { return this._get('/api/v1/admin/configs'); },
|
||||
adminCreateGlobalConfig(name, provider, endpoint, apiKey, modelDefault) {
|
||||
return this._post('/api/v1/admin/configs', {
|
||||
name, provider, endpoint, api_key: apiKey, model_default: modelDefault
|
||||
});
|
||||
},
|
||||
adminDeleteGlobalConfig(id) { return this._del(`/api/v1/admin/configs/${id}`); },
|
||||
|
||||
adminListModels() { return this._get('/api/v1/admin/models'); },
|
||||
adminFetchModels() { return this._post('/api/v1/admin/models/fetch', {}); },
|
||||
adminUpdateModel(id, updates) { return this._put(`/api/v1/admin/models/${id}`, updates); },
|
||||
adminDeleteModel(id) { return this._del(`/api/v1/admin/models/${id}`); },
|
||||
|
||||
// ── HTTP Internals ───────────────────────
|
||||
|
||||
_authHeaders() {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.accessToken}`
|
||||
};
|
||||
},
|
||||
|
||||
async _get(path) { return this._authed(path); },
|
||||
async _post(path, body, skipAuth) { return skipAuth ? this._raw(path, 'POST', body) : this._authed(path, 'POST', body); },
|
||||
async _put(path, body) { return this._authed(path, 'PUT', body); },
|
||||
async _del(path) { return this._authed(path, 'DELETE'); },
|
||||
|
||||
async _authed(path, method = 'GET', body) {
|
||||
try {
|
||||
return await this._raw(path, method, body, true);
|
||||
} catch (e) {
|
||||
if (e.status === 401 && this.refreshToken) {
|
||||
if (await this.refresh()) {
|
||||
return this._raw(path, method, body, true);
|
||||
}
|
||||
} finally {
|
||||
State.isGenerating = false;
|
||||
State.abortController = null;
|
||||
document.getElementById('stopBtn').classList.remove('visible');
|
||||
document.getElementById('sendBtn').disabled = false;
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
function stopGeneration() {
|
||||
if (State.abortController) {
|
||||
State.abortController.abort();
|
||||
State.abortController = null;
|
||||
async _raw(path, method = 'GET', body, auth = false) {
|
||||
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
||||
if (auth) opts.headers['Authorization'] = `Bearer ${this.accessToken}`;
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
|
||||
const resp = await fetch(path, opts);
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
const err = new Error(data.error || `HTTP ${resp.status}`);
|
||||
err.status = resp.status;
|
||||
throw err;
|
||||
}
|
||||
State.isGenerating = false;
|
||||
document.getElementById('stopBtn').classList.remove('visible');
|
||||
document.getElementById('sendBtn').disabled = false;
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
};
|
||||
|
||||
800
src/js/app.js
800
src/js/app.js
@@ -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() {
|
||||
console.log('🔀 Chat Switchboard initializing...');
|
||||
API.loadTokens();
|
||||
|
||||
loadSettings();
|
||||
loadModels();
|
||||
Backend.init();
|
||||
|
||||
// Auto-detect backend at same origin
|
||||
let health = null;
|
||||
if (!Backend.baseUrl) {
|
||||
health = await Backend.checkHealth('');
|
||||
if (health && health.database) {
|
||||
Backend.baseUrl = window.location.origin;
|
||||
Backend.save();
|
||||
console.log('🔗 Backend detected at same origin');
|
||||
try {
|
||||
health = await API.health();
|
||||
console.log('✅ Backend reachable:', health.version);
|
||||
} catch (e) {
|
||||
console.error('❌ Backend unreachable:', e.message);
|
||||
document.getElementById('splashError').textContent = 'Cannot reach server — check connection';
|
||||
showSplash(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (API.isAuthed) {
|
||||
try {
|
||||
await API.getProfile();
|
||||
console.log('✅ Session valid for', API.user?.username);
|
||||
} catch (e) {
|
||||
console.warn('⚠️ Session expired, clearing');
|
||||
API.clearTokens();
|
||||
}
|
||||
}
|
||||
|
||||
// If we have saved auth, validate it
|
||||
if (Backend.accessToken) {
|
||||
health = health || await Backend.checkHealth(Backend.baseUrl);
|
||||
if (!health) {
|
||||
console.log('⚠️ Backend unreachable, falling back to unmanaged');
|
||||
Backend.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Decide: splash gate or straight to app
|
||||
if (Backend.baseUrl && !Backend.accessToken) {
|
||||
// Online but not authenticated → show splash, hide app
|
||||
showSplashGate(health);
|
||||
initAuthListeners();
|
||||
return; // Don't init app yet — authSuccess() will do it
|
||||
}
|
||||
|
||||
// Already authed or no backend → go straight to app
|
||||
hideSplashGate();
|
||||
await loadSettingsFromBackend();
|
||||
await initApp();
|
||||
}
|
||||
|
||||
async function initApp() {
|
||||
await loadChats();
|
||||
initializeEventListeners();
|
||||
|
||||
// In managed mode, fetch models from backend (ignore localStorage cache)
|
||||
if (Backend.isManaged) {
|
||||
fetchModels(false); // async, non-blocking
|
||||
}
|
||||
|
||||
updateQuickModelSelector();
|
||||
renderChatHistory();
|
||||
updateConnectionStatus();
|
||||
initAdmin();
|
||||
console.log(`✅ Chat Switchboard ready (${Backend.isManaged ? 'managed' : 'unmanaged'} mode)`);
|
||||
}
|
||||
|
||||
function showSplashGate(health) {
|
||||
const splash = document.getElementById('splashGate');
|
||||
const app = document.getElementById('appContainer');
|
||||
splash.classList.add('visible');
|
||||
splash.style.display = 'flex'; // Fallback for cached CSS
|
||||
app.classList.add('app-hidden');
|
||||
app.style.display = 'none'; // Fallback for cached CSS
|
||||
|
||||
// Hide register tab if registration disabled
|
||||
const registerTab = document.getElementById('authTabRegister');
|
||||
if (health && health.registration_enabled === false) {
|
||||
registerTab.style.display = 'none';
|
||||
if (API.isAuthed) {
|
||||
await startApp();
|
||||
} else {
|
||||
registerTab.style.display = '';
|
||||
showSplash(health);
|
||||
}
|
||||
}
|
||||
|
||||
function hideSplashGate() {
|
||||
const splash = document.getElementById('splashGate');
|
||||
const app = document.getElementById('appContainer');
|
||||
splash.classList.remove('visible');
|
||||
splash.style.display = 'none'; // Hide splash
|
||||
app.classList.remove('app-hidden');
|
||||
app.style.display = ''; // Show app
|
||||
async function startApp() {
|
||||
hideSplash();
|
||||
UI.restoreSidebar();
|
||||
await loadSettings();
|
||||
await loadChats();
|
||||
await fetchModels();
|
||||
UI.renderChatList();
|
||||
UI.updateModelSelector();
|
||||
UI.updateUser();
|
||||
UI.showAdminButton(API.isAdmin);
|
||||
initListeners();
|
||||
console.log('✅ Chat Switchboard ready');
|
||||
}
|
||||
|
||||
async function authSuccess() {
|
||||
hideSplashGate();
|
||||
await loadSettingsFromBackend();
|
||||
await initApp();
|
||||
showToast(`✅ Welcome, ${Backend.user?.display_name || Backend.user?.username || 'user'}!`, 'success');
|
||||
// ── Settings ─────────────────────────────────
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const remote = await API.getSettings();
|
||||
if (remote && typeof remote === 'object') {
|
||||
if (remote.model) App.settings.model = remote.model;
|
||||
if (remote.system_prompt !== undefined) App.settings.systemPrompt = remote.system_prompt;
|
||||
if (remote.max_tokens) App.settings.maxTokens = remote.max_tokens;
|
||||
if (remote.temperature !== undefined) App.settings.temperature = remote.temperature;
|
||||
}
|
||||
} catch (e) { console.warn('Settings load failed:', e.message); }
|
||||
}
|
||||
|
||||
function initializeEventListeners() {
|
||||
// Settings modal
|
||||
document.getElementById('settingsBtn').addEventListener('click', openSettings);
|
||||
document.getElementById('configureBtn').addEventListener('click', openSettings);
|
||||
document.getElementById('closeModalBtn').addEventListener('click', closeSettings);
|
||||
document.getElementById('cancelBtn').addEventListener('click', closeSettings);
|
||||
document.getElementById('saveBtn').addEventListener('click', handleSaveSettings);
|
||||
document.getElementById('fetchModelsBtn').addEventListener('click', () => fetchModels(true));
|
||||
|
||||
// Profile
|
||||
document.getElementById('profileChangePwBtn').addEventListener('click', () => {
|
||||
document.getElementById('profileChangePwForm').style.display = '';
|
||||
});
|
||||
document.getElementById('profileSavePwBtn').addEventListener('click', handleChangePassword);
|
||||
|
||||
// Providers (managed mode)
|
||||
document.getElementById('providerShowAddBtn').addEventListener('click', showProviderForm);
|
||||
document.getElementById('providerCancelBtn').addEventListener('click', hideProviderForm);
|
||||
document.getElementById('providerCreateBtn').addEventListener('click', handleCreateProvider);
|
||||
document.getElementById('providerType').addEventListener('change', updateProviderEndpointHint);
|
||||
|
||||
// Chat controls
|
||||
document.getElementById('newChatBtn').addEventListener('click', newChat);
|
||||
document.getElementById('sendBtn').addEventListener('click', sendMessage);
|
||||
document.getElementById('stopBtn').addEventListener('click', stopGeneration);
|
||||
document.getElementById('regenerateBtn').addEventListener('click', regenerateResponse);
|
||||
document.getElementById('toggleSidebarBtn').addEventListener('click', toggleSidebar);
|
||||
document.getElementById('quickFetchBtn').addEventListener('click', () => fetchModels(false));
|
||||
|
||||
// Quick model selector
|
||||
document.getElementById('quickModel').addEventListener('change', function() {
|
||||
if (this.value) {
|
||||
State.settings.model = this.value;
|
||||
saveSettings();
|
||||
}
|
||||
});
|
||||
|
||||
// Export dropdown
|
||||
document.getElementById('exportBtn').addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
document.getElementById('exportDropdown').classList.toggle('show');
|
||||
});
|
||||
|
||||
document.addEventListener('click', function() {
|
||||
document.getElementById('exportDropdown').classList.remove('show');
|
||||
});
|
||||
|
||||
// Message input
|
||||
const messageInput = document.getElementById('messageInput');
|
||||
messageInput.addEventListener('keydown', handleKeyDown);
|
||||
messageInput.addEventListener('input', function() {
|
||||
this.style.height = 'auto';
|
||||
this.style.height = Math.min(this.scrollHeight, 200) + 'px';
|
||||
});
|
||||
|
||||
// Settings modal model sync
|
||||
document.getElementById('model').addEventListener('change', function() {
|
||||
if (this.value) document.getElementById('modelCustom').value = '';
|
||||
});
|
||||
document.getElementById('modelCustom').addEventListener('input', function() {
|
||||
if (this.value) document.getElementById('model').value = '';
|
||||
});
|
||||
|
||||
// Close modals on overlay click
|
||||
document.getElementById('settingsModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) closeSettings();
|
||||
});
|
||||
|
||||
// Connection status click
|
||||
document.getElementById('connectionStatus').addEventListener('click', handleConnectionClick);
|
||||
|
||||
// Admin panel
|
||||
document.getElementById('adminBtn').addEventListener('click', openAdmin);
|
||||
document.getElementById('adminCloseBtn').addEventListener('click', closeAdmin);
|
||||
document.querySelectorAll('.admin-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => switchAdminTab(tab.dataset.tab));
|
||||
});
|
||||
document.getElementById('adminSaveSettings').addEventListener('click', saveAdminSettings);
|
||||
document.getElementById('adminModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) closeAdmin();
|
||||
});
|
||||
|
||||
// Global keyboard shortcuts
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.ctrlKey && e.key === ',') {
|
||||
e.preventDefault();
|
||||
openSettings();
|
||||
}
|
||||
if (e.ctrlKey && e.key === 'b') {
|
||||
e.preventDefault();
|
||||
toggleSidebar();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
if (State.isGenerating) {
|
||||
stopGeneration();
|
||||
} else if (document.getElementById('settingsModal').classList.contains('active')) {
|
||||
closeSettings();
|
||||
}
|
||||
}
|
||||
async function saveSettings() {
|
||||
try {
|
||||
await API.updateSettings({
|
||||
model: App.settings.model,
|
||||
system_prompt: App.settings.systemPrompt,
|
||||
max_tokens: App.settings.maxTokens,
|
||||
temperature: App.settings.temperature,
|
||||
});
|
||||
} catch (e) { console.warn('Settings sync failed:', e.message); }
|
||||
}
|
||||
|
||||
function handleKeyDown(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
// ── Models ───────────────────────────────────
|
||||
|
||||
async function fetchModels() {
|
||||
try {
|
||||
const data = await API.listEnabledModels();
|
||||
App.models = (data.models || []).map(m => ({
|
||||
id: m.model_id || m.id,
|
||||
provider: m.provider_name || m.provider || '',
|
||||
configId: m.config_id || null
|
||||
}));
|
||||
|
||||
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() {
|
||||
State.settings.apiEndpoint = document.getElementById('apiEndpoint').value.trim();
|
||||
State.settings.apiKey = document.getElementById('apiKey').value.trim();
|
||||
async function selectChat(chatId) {
|
||||
App.currentChatId = chatId;
|
||||
UI.renderChatList();
|
||||
|
||||
const modelCustom = document.getElementById('modelCustom').value.trim();
|
||||
const modelSelect = document.getElementById('model').value;
|
||||
State.settings.model = modelCustom || modelSelect || 'gpt-3.5-turbo';
|
||||
const chat = App.chats.find(c => c.id === chatId);
|
||||
if (!chat) return;
|
||||
|
||||
State.settings.stream = document.getElementById('streamResponse').checked;
|
||||
State.settings.saveHistory = document.getElementById('saveHistory').checked;
|
||||
State.settings.showThinking = document.getElementById('showThinking').checked;
|
||||
State.settings.systemPrompt = document.getElementById('systemPrompt').value.trim();
|
||||
State.settings.maxTokens = parseInt(document.getElementById('maxTokens').value) || 4096;
|
||||
State.settings.temperature = parseFloat(document.getElementById('temperature').value) || 0.7;
|
||||
State.settings.topP = parseFloat(document.getElementById('topP').value) || 1;
|
||||
State.settings.presencePenalty = parseFloat(document.getElementById('presencePenalty').value) || 0;
|
||||
if (chat.messages.length === 0 && chat.messageCount > 0) {
|
||||
try {
|
||||
const resp = await API.listMessages(chatId);
|
||||
chat.messages = (resp.data || []).map(m => ({
|
||||
role: m.role, content: m.content, model: m.model || '', timestamp: m.created_at
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Failed to load messages:', e.message);
|
||||
UI.toast('Failed to load messages', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
saveSettings();
|
||||
saveProfile(); // async, fire-and-forget
|
||||
updateQuickModelSelector();
|
||||
closeSettings();
|
||||
showToast('✅ Settings saved', 'success');
|
||||
UI.renderMessages(chat.messages);
|
||||
UI.showRegenerate(chat.messages.some(m => m.role === 'assistant'));
|
||||
}
|
||||
|
||||
function newChat() {
|
||||
State.currentChatId = null;
|
||||
document.getElementById('chatMessages').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>Start a Conversation</h2>
|
||||
<p>Type a message below to begin</p>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('regenerateBtn').style.display = 'none';
|
||||
renderChatHistory();
|
||||
async function newChat() {
|
||||
App.currentChatId = null;
|
||||
UI.renderChatList();
|
||||
UI.showEmptyState();
|
||||
UI.showRegenerate(false);
|
||||
document.getElementById('messageInput').focus();
|
||||
}
|
||||
|
||||
async function loadChat(chatId) {
|
||||
const chat = getChat(chatId);
|
||||
if (!chat) return;
|
||||
|
||||
State.currentChatId = chatId;
|
||||
|
||||
// In managed mode, fetch messages if not cached
|
||||
if (Backend.isManaged && chat.messages.length === 0 && chat.messageCount > 0) {
|
||||
await loadMessages(chatId);
|
||||
}
|
||||
|
||||
if (chat.model) {
|
||||
State.settings.model = chat.model;
|
||||
updateQuickModelSelector();
|
||||
}
|
||||
renderMessages(chat.messages);
|
||||
renderChatHistory();
|
||||
updateRegenerateButton(chat.messages);
|
||||
async function deleteChat(chatId) {
|
||||
if (!confirm('Delete this chat?')) return;
|
||||
try {
|
||||
await API.deleteChat(chatId);
|
||||
App.chats = App.chats.filter(c => c.id !== chatId);
|
||||
if (App.currentChatId === chatId) newChat();
|
||||
UI.renderChatList();
|
||||
} catch (e) { UI.toast('Failed to delete: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function handleDeleteChat(chatId, event) {
|
||||
event.stopPropagation();
|
||||
if (confirm('Delete this chat?')) {
|
||||
await deleteChat(chatId);
|
||||
if (State.currentChatId === chatId) {
|
||||
newChat();
|
||||
}
|
||||
renderChatHistory();
|
||||
showToast('🗑️ Chat deleted', 'success');
|
||||
}
|
||||
}
|
||||
// ── Send Message ─────────────────────────────
|
||||
|
||||
async function sendMessage() {
|
||||
const input = document.getElementById('messageInput');
|
||||
const message = input.value.trim();
|
||||
|
||||
if (!message || State.isGenerating) return;
|
||||
|
||||
// In managed mode, backend has the keys. In unmanaged, user must configure.
|
||||
if (!Backend.isManaged) {
|
||||
if (!State.settings.apiEndpoint || !State.settings.apiKey) {
|
||||
showToast('⚠️ Configure API settings first', 'warning');
|
||||
openSettings();
|
||||
return;
|
||||
}
|
||||
}
|
||||
const text = input.value.trim();
|
||||
if (!text || App.isGenerating) return;
|
||||
|
||||
input.value = '';
|
||||
input.style.height = 'auto';
|
||||
|
||||
let messages = [];
|
||||
const model = document.getElementById('modelSelect').value || App.settings.model;
|
||||
|
||||
if (State.settings.systemPrompt) {
|
||||
messages.push({ role: 'system', content: State.settings.systemPrompt });
|
||||
if (!App.currentChatId) {
|
||||
try {
|
||||
const title = text.slice(0, 50) + (text.length > 50 ? '...' : '');
|
||||
const resp = await API.createChat(title, model, App.settings.systemPrompt);
|
||||
const chat = { id: resp.id, title: resp.title, model, 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 = getChat(State.currentChatId);
|
||||
if (chat) messages = [...chat.messages];
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
if (!State.currentChatId) {
|
||||
const title = message.substring(0, 50) + (message.length > 50 ? '...' : '');
|
||||
const chat = await createChat(title, messages);
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
if (!chat) return;
|
||||
State.currentChatId = chat.id;
|
||||
|
||||
chat.messages.push({ role: 'user', content: text, timestamp: new Date().toISOString() });
|
||||
UI.renderMessages(chat.messages);
|
||||
|
||||
App.isGenerating = true;
|
||||
App.abortController = new AbortController();
|
||||
UI.setGenerating(true);
|
||||
|
||||
try {
|
||||
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal);
|
||||
const assistantContent = await UI.streamResponse(resp, chat.messages);
|
||||
chat.messages.push({ role: 'assistant', content: assistantContent, model, timestamp: new Date().toISOString() });
|
||||
chat.messageCount = chat.messages.length;
|
||||
UI.showRegenerate(true);
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') {
|
||||
UI.toast('Generation stopped', 'warning');
|
||||
} else {
|
||||
await updateChat(State.currentChatId, { messages });
|
||||
// In unmanaged mode, persist user message to backend (if connected)
|
||||
// In managed mode, the completion proxy persists both messages
|
||||
if (!Backend.isManaged) {
|
||||
await persistMessage(State.currentChatId, 'user', message);
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
renderMessages(messages);
|
||||
renderChatHistory();
|
||||
|
||||
await sendApiRequest(messages);
|
||||
} finally {
|
||||
App.isGenerating = false;
|
||||
App.abortController = null;
|
||||
UI.setGenerating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function regenerateResponse() {
|
||||
if (State.isGenerating || !State.currentChatId) return;
|
||||
function stopGeneration() { if (App.abortController) App.abortController.abort(); }
|
||||
|
||||
const chat = getChat(State.currentChatId);
|
||||
async function regenerate() {
|
||||
if (App.isGenerating || !App.currentChatId) return;
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
if (!chat || chat.messages.length === 0) return;
|
||||
|
||||
// Remove last assistant message(s)
|
||||
while (chat.messages.length > 0 && chat.messages[chat.messages.length - 1].role === 'assistant') {
|
||||
chat.messages.pop();
|
||||
}
|
||||
|
||||
while (chat.messages.length && chat.messages[chat.messages.length - 1].role === 'assistant') chat.messages.pop();
|
||||
if (chat.messages.length === 0) return;
|
||||
|
||||
await updateChat(State.currentChatId, { messages: chat.messages });
|
||||
renderMessages(chat.messages);
|
||||
UI.renderMessages(chat.messages);
|
||||
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 initAuthListeners() {
|
||||
if (_authListenersInit) return;
|
||||
_authListenersInit = true;
|
||||
|
||||
document.getElementById('authLoginBtn').addEventListener('click', handleLogin);
|
||||
document.getElementById('authRegisterBtn').addEventListener('click', handleRegister);
|
||||
document.getElementById('authTabLogin').addEventListener('click', () => switchAuthTab('login'));
|
||||
document.getElementById('authTabRegister').addEventListener('click', () => switchAuthTab('register'));
|
||||
document.getElementById('authSkipBtn').addEventListener('click', skipAuth);
|
||||
|
||||
// Enter key in auth forms
|
||||
document.getElementById('authPassword').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') handleLogin();
|
||||
});
|
||||
document.getElementById('authRegPassword').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') handleRegister();
|
||||
});
|
||||
function showSplash(health) {
|
||||
document.getElementById('splashGate').style.display = 'flex';
|
||||
document.getElementById('appContainer').style.display = 'none';
|
||||
if (health && health.registration_enabled === false) {
|
||||
document.getElementById('authTabRegister').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function switchAuthTab(tab) {
|
||||
document.getElementById('authTabLogin').classList.toggle('active', tab === 'login');
|
||||
document.getElementById('authTabRegister').classList.toggle('active', tab === 'register');
|
||||
document.getElementById('authLoginForm').style.display = tab === 'login' ? 'block' : 'none';
|
||||
document.getElementById('authRegisterForm').style.display = tab === 'register' ? 'block' : 'none';
|
||||
document.getElementById('authLoginBtn').style.display = tab === 'login' ? 'block' : 'none';
|
||||
document.getElementById('authRegisterBtn').style.display = tab === 'register' ? 'block' : 'none';
|
||||
document.getElementById('authError').textContent = '';
|
||||
function hideSplash() {
|
||||
document.getElementById('splashGate').style.display = 'none';
|
||||
document.getElementById('appContainer').style.display = '';
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
const login = document.getElementById('authLogin').value.trim();
|
||||
const password = document.getElementById('authPassword').value;
|
||||
|
||||
if (!login || !password) {
|
||||
document.getElementById('authError').textContent = 'Please fill in all fields';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!login || !password) return setAuthError('Fill in all fields');
|
||||
setAuthLoading(true);
|
||||
try {
|
||||
await Backend.login(login, password);
|
||||
await authSuccess();
|
||||
} catch (e) {
|
||||
document.getElementById('authError').textContent = e.message;
|
||||
} finally {
|
||||
setAuthLoading(false);
|
||||
}
|
||||
try { await API.login(login, password); await startApp(); }
|
||||
catch (e) { setAuthError(e.message); }
|
||||
finally { setAuthLoading(false); }
|
||||
}
|
||||
|
||||
async function handleRegister() {
|
||||
const username = document.getElementById('authUsername').value.trim();
|
||||
const email = document.getElementById('authEmail').value.trim();
|
||||
const password = document.getElementById('authRegPassword').value;
|
||||
|
||||
if (!username || !email || !password) {
|
||||
document.getElementById('authError').textContent = 'Please fill in all fields';
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
document.getElementById('authError').textContent = 'Password must be at least 8 characters';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!username || !email || !password) return setAuthError('Fill in all fields');
|
||||
if (password.length < 8) return setAuthError('Password must be at least 8 characters');
|
||||
setAuthLoading(true);
|
||||
try {
|
||||
await Backend.register(username, email, password);
|
||||
await authSuccess();
|
||||
} catch (e) {
|
||||
document.getElementById('authError').textContent = e.message;
|
||||
} finally {
|
||||
setAuthLoading(false);
|
||||
}
|
||||
try { await API.register(username, email, password); await startApp(); }
|
||||
catch (e) { setAuthError(e.message); }
|
||||
finally { setAuthLoading(false); }
|
||||
}
|
||||
|
||||
function setAuthLoading(loading) {
|
||||
const btns = document.querySelectorAll('#authLoginBtn, #authRegisterBtn');
|
||||
btns.forEach(btn => {
|
||||
btn.disabled = loading;
|
||||
if (loading) btn.dataset.origText = btn.textContent;
|
||||
btn.textContent = loading ? '⏳ Please wait...' : (btn.dataset.origText || btn.textContent);
|
||||
async function handleLogout() {
|
||||
if (!confirm('Sign out?')) return;
|
||||
await API.logout();
|
||||
App.chats = [];
|
||||
App.currentChatId = null;
|
||||
location.reload();
|
||||
}
|
||||
|
||||
function switchAuthTab(tab) {
|
||||
document.getElementById('authTabLogin').classList.toggle('active', tab === 'login');
|
||||
document.getElementById('authTabRegister').classList.toggle('active', tab === 'register');
|
||||
document.getElementById('authLoginForm').style.display = tab === 'login' ? '' : 'none';
|
||||
document.getElementById('authRegisterForm').style.display = tab === 'register' ? '' : 'none';
|
||||
document.getElementById('authLoginBtn').style.display = tab === 'login' ? '' : 'none';
|
||||
document.getElementById('authRegisterBtn').style.display = tab === 'register' ? '' : 'none';
|
||||
setAuthError('');
|
||||
}
|
||||
|
||||
function setAuthError(msg) { document.getElementById('authError').textContent = msg; }
|
||||
function setAuthLoading(on) {
|
||||
document.querySelectorAll('#authLoginBtn, #authRegisterBtn').forEach(btn => {
|
||||
btn.disabled = on;
|
||||
btn.textContent = on ? 'Please wait...' : btn.dataset.label;
|
||||
});
|
||||
}
|
||||
|
||||
async function skipAuth() {
|
||||
Backend.clear();
|
||||
hideSplashGate();
|
||||
await initApp();
|
||||
showToast('📴 Running in offline mode', 'success');
|
||||
// ── Event Listeners ──────────────────────────
|
||||
|
||||
let _listenersInit = false;
|
||||
function initListeners() {
|
||||
if (_listenersInit) return;
|
||||
_listenersInit = true;
|
||||
|
||||
// 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() {
|
||||
const el = document.getElementById('connectionStatus');
|
||||
if (Backend.isManaged) {
|
||||
el.className = 'connection-status managed';
|
||||
el.innerHTML = `<span class="status-dot"></span> ${Backend.user?.display_name || Backend.user?.username || 'Connected'}`;
|
||||
el.title = 'Managed mode – click to sign out';
|
||||
} else if (Backend.baseUrl) {
|
||||
el.className = 'connection-status available';
|
||||
el.innerHTML = '<span class="status-dot"></span> Sign in';
|
||||
el.title = 'Backend available – click to sign in';
|
||||
} else {
|
||||
el.className = 'connection-status offline';
|
||||
el.innerHTML = '<span class="status-dot"></span> Offline';
|
||||
el.title = 'Unmanaged mode – data stored locally';
|
||||
}
|
||||
function handleSaveSettings() {
|
||||
App.settings.model = document.getElementById('settingsModel').value || App.settings.model;
|
||||
App.settings.systemPrompt = document.getElementById('settingsSystemPrompt').value.trim();
|
||||
App.settings.maxTokens = parseInt(document.getElementById('settingsMaxTokens').value) || 4096;
|
||||
App.settings.temperature = parseFloat(document.getElementById('settingsTemp').value) || 0.7;
|
||||
App.settings.showThinking = document.getElementById('settingsThinking').checked;
|
||||
saveSettings();
|
||||
UI.updateModelSelector();
|
||||
UI.closeSettings();
|
||||
UI.toast('Settings saved', 'success');
|
||||
}
|
||||
|
||||
async function handleConnectionClick() {
|
||||
if (Backend.isManaged) {
|
||||
if (confirm('Sign out? Your chats are saved on the server.')) {
|
||||
await Backend.logout();
|
||||
State.chats = [];
|
||||
State.currentChatId = null;
|
||||
showSplashGate(null);
|
||||
initAuthListeners();
|
||||
showToast('👋 Signed out', 'success');
|
||||
}
|
||||
} else if (Backend.baseUrl) {
|
||||
showSplashGate(null);
|
||||
initAuthListeners();
|
||||
}
|
||||
async function handleChangePassword() {
|
||||
const cur = document.getElementById('profileCurrentPw').value;
|
||||
const pw = document.getElementById('profileNewPw').value;
|
||||
if (!cur || !pw) return UI.toast('Fill in both fields', 'warning');
|
||||
if (pw.length < 8) return UI.toast('Min 8 characters', 'warning');
|
||||
try {
|
||||
await API.changePassword(cur, pw);
|
||||
UI.toast('Password updated', 'success');
|
||||
document.getElementById('profileChangePwForm').style.display = 'none';
|
||||
document.getElementById('profileCurrentPw').value = '';
|
||||
document.getElementById('profileNewPw').value = '';
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Post-LLM Persistence ────────────────────
|
||||
// Called by api.js after assistant response is complete.
|
||||
|
||||
async function onAssistantResponse(content, model) {
|
||||
if (Backend.isManaged && State.currentChatId) {
|
||||
await persistMessage(State.currentChatId, 'assistant', content, model);
|
||||
}
|
||||
async function handleCreateProvider() {
|
||||
const name = document.getElementById('providerName').value.trim();
|
||||
const provider = document.getElementById('providerType').value;
|
||||
const endpoint = document.getElementById('providerEndpoint').value.trim();
|
||||
const apiKey = document.getElementById('providerApiKey').value.trim();
|
||||
const model = document.getElementById('providerDefaultModel').value.trim();
|
||||
if (!name || !endpoint || !apiKey) return UI.toast('Fill in required fields', 'warning');
|
||||
try {
|
||||
await API.createConfig(name, provider, endpoint, apiKey, model);
|
||||
UI.toast('Provider added', 'success');
|
||||
UI.hideProviderForm();
|
||||
UI.loadProviderList();
|
||||
fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -199,34 +199,32 @@ const DebugLog = {
|
||||
getStateSnapshot() {
|
||||
const snap = {};
|
||||
|
||||
// Backend state (redact tokens)
|
||||
if (typeof Backend !== 'undefined') {
|
||||
snap.backend = {
|
||||
baseUrl: Backend.baseUrl || '(empty)',
|
||||
hasAccessToken: !!Backend.accessToken,
|
||||
hasRefreshToken: !!Backend.refreshToken,
|
||||
user: Backend.user ? {
|
||||
username: Backend.user.username,
|
||||
role: Backend.user.role,
|
||||
display_name: Backend.user.display_name
|
||||
// API client state (redact tokens)
|
||||
if (typeof API !== 'undefined') {
|
||||
snap.api = {
|
||||
hasAccessToken: !!API.accessToken,
|
||||
hasRefreshToken: !!API.refreshToken,
|
||||
user: API.user ? {
|
||||
username: API.user.username,
|
||||
role: API.user.role,
|
||||
display_name: API.user.display_name
|
||||
} : null,
|
||||
isConnected: Backend.isConnected,
|
||||
isManaged: Backend.isManaged
|
||||
isAuthed: API.isAuthed,
|
||||
isAdmin: API.isAdmin
|
||||
};
|
||||
}
|
||||
|
||||
// App state
|
||||
if (typeof State !== 'undefined') {
|
||||
snap.state = {
|
||||
chatCount: State.chats?.length || 0,
|
||||
currentChatId: State.currentChatId,
|
||||
modelCount: State.models?.length || 0,
|
||||
isGenerating: State.isGenerating,
|
||||
if (typeof App !== 'undefined') {
|
||||
snap.app = {
|
||||
chatCount: App.chats?.length || 0,
|
||||
currentChatId: App.currentChatId,
|
||||
modelCount: App.models?.length || 0,
|
||||
isGenerating: App.isGenerating,
|
||||
settings: {
|
||||
model: State.settings?.model || '?',
|
||||
stream: State.settings?.stream,
|
||||
apiEndpoint: State.settings?.apiEndpoint ? '(set)' : '(empty)',
|
||||
apiKey: State.settings?.apiKey ? '(set)' : '(empty)',
|
||||
model: App.settings?.model || '?',
|
||||
stream: App.settings?.stream,
|
||||
showThinking: App.settings?.showThinking
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -332,7 +330,7 @@ const DebugLog = {
|
||||
badge.innerHTML = '🐛';
|
||||
badge.title = 'Debug Log (Ctrl+Shift+L)';
|
||||
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%;
|
||||
background: var(--bg-secondary, #2a2a2a); border: 1px solid var(--border, #444);
|
||||
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-content').forEach(c => c.style.display = 'none');
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
873
src/js/ui.js
873
src/js/ui.js
@@ -1,515 +1,512 @@
|
||||
// ==========================================
|
||||
// UI Functions
|
||||
// Chat Switchboard – UI (v0.5.2)
|
||||
// ==========================================
|
||||
|
||||
function updateSettingsUI() {
|
||||
document.getElementById('apiEndpoint').value = State.settings.apiEndpoint || '';
|
||||
document.getElementById('apiKey').value = State.settings.apiKey || '';
|
||||
const UI = {
|
||||
|
||||
const modelSelect = document.getElementById('model');
|
||||
const modelCustom = document.getElementById('modelCustom');
|
||||
const savedModel = State.settings.model || 'gpt-3.5-turbo';
|
||||
// ── Sidebar ──────────────────────────────
|
||||
|
||||
populateModelSelect(modelSelect);
|
||||
toggleSidebar() {
|
||||
document.getElementById('sidebar').classList.toggle('collapsed');
|
||||
localStorage.setItem('sb_sidebar', document.getElementById('sidebar').classList.contains('collapsed') ? '1' : '0');
|
||||
},
|
||||
|
||||
const optionExists = Array.from(modelSelect.options).some(opt => opt.value === savedModel);
|
||||
if (optionExists) {
|
||||
modelSelect.value = savedModel;
|
||||
modelCustom.value = '';
|
||||
} else {
|
||||
modelSelect.value = '';
|
||||
modelCustom.value = savedModel;
|
||||
restoreSidebar() {
|
||||
if (localStorage.getItem('sb_sidebar') === '1') {
|
||||
document.getElementById('sidebar').classList.add('collapsed');
|
||||
}
|
||||
},
|
||||
|
||||
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;
|
||||
}
|
||||
// ── User Menu Flyout ─────────────────────
|
||||
|
||||
function populateModelSelect(selectElement) {
|
||||
const currentValue = selectElement.value;
|
||||
selectElement.innerHTML = '<option value="">-- Select a model --</option>';
|
||||
toggleUserMenu() {
|
||||
const flyout = document.getElementById('userFlyout');
|
||||
flyout.classList.toggle('open');
|
||||
},
|
||||
|
||||
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);
|
||||
});
|
||||
closeUserMenu() {
|
||||
document.getElementById('userFlyout').classList.remove('open');
|
||||
},
|
||||
|
||||
if (currentValue) {
|
||||
selectElement.value = currentValue;
|
||||
}
|
||||
}
|
||||
// ── Chat List ────────────────────────────
|
||||
|
||||
function updateQuickModelSelector() {
|
||||
const quickSelect = document.getElementById('quickModel');
|
||||
const currentModel = State.settings.model;
|
||||
|
||||
quickSelect.innerHTML = '';
|
||||
|
||||
if (State.models.length === 0) {
|
||||
quickSelect.innerHTML = '<option value="">-- Fetch models first --</option>';
|
||||
if (currentModel) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = currentModel;
|
||||
opt.textContent = currentModel;
|
||||
quickSelect.appendChild(opt);
|
||||
quickSelect.value = currentModel;
|
||||
}
|
||||
renderChatList() {
|
||||
const el = document.getElementById('chatHistory');
|
||||
if (App.chats.length === 0) {
|
||||
el.innerHTML = '<div class="sidebar-empty">No conversations yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
State.models.forEach(model => {
|
||||
const option = document.createElement('option');
|
||||
option.value = model.id;
|
||||
option.textContent = model.id;
|
||||
quickSelect.appendChild(option);
|
||||
// Group by time
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterday = new Date(today - 86400000);
|
||||
const weekAgo = new Date(today - 7 * 86400000);
|
||||
|
||||
const groups = { today: [], yesterday: [], week: [], older: [] };
|
||||
|
||||
App.chats.forEach(c => {
|
||||
const d = new Date(c.updatedAt);
|
||||
if (d >= today) groups.today.push(c);
|
||||
else if (d >= yesterday) groups.yesterday.push(c);
|
||||
else if (d >= weekAgo) groups.week.push(c);
|
||||
else groups.older.push(c);
|
||||
});
|
||||
|
||||
if (currentModel) {
|
||||
const exists = Array.from(quickSelect.options).some(opt => opt.value === currentModel);
|
||||
let html = '';
|
||||
const renderGroup = (label, chats) => {
|
||||
if (chats.length === 0) return;
|
||||
html += `<div class="chat-group-label">${label}</div>`;
|
||||
chats.forEach(c => {
|
||||
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>`;
|
||||
});
|
||||
};
|
||||
|
||||
renderGroup('Today', groups.today);
|
||||
renderGroup('Yesterday', groups.yesterday);
|
||||
renderGroup('Previous 7 days', groups.week);
|
||||
renderGroup('Older', groups.older);
|
||||
|
||||
el.innerHTML = html;
|
||||
},
|
||||
|
||||
// ── Messages ─────────────────────────────
|
||||
|
||||
renderMessages(messages) {
|
||||
const el = document.getElementById('chatMessages');
|
||||
if (!messages || messages.length === 0) {
|
||||
this.showEmptyState();
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = messages
|
||||
.filter(m => m.role !== 'system')
|
||||
.map((m, i) => this._messageHTML(m, i))
|
||||
.join('');
|
||||
this._scrollToBottom(true);
|
||||
},
|
||||
|
||||
_messageHTML(msg, index) {
|
||||
const isUser = msg.role === 'user';
|
||||
const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
|
||||
return `
|
||||
<div class="message ${msg.role}">
|
||||
<div class="msg-inner">
|
||||
<div class="msg-avatar">${isUser ? '👤' : '🤖'}</div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-head">
|
||||
<span class="msg-role">${isUser ? 'You' : 'Assistant'}</span>
|
||||
${time ? `<span class="msg-time">${time}</span>` : ''}
|
||||
<div class="msg-actions">
|
||||
<button class="msg-action-btn" onclick="UI.copyMessage(${index})" title="Copy">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="msg-text">${formatMessage(msg.content)}</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) {
|
||||
quickSelect.value = currentModel;
|
||||
sel.value = current;
|
||||
} else {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = currentModel;
|
||||
opt.textContent = currentModel + ' (custom)';
|
||||
quickSelect.insertBefore(opt, quickSelect.firstChild);
|
||||
quickSelect.value = currentModel;
|
||||
opt.value = current;
|
||||
opt.textContent = current + ' (custom)';
|
||||
sel.insertBefore(opt, sel.firstChild);
|
||||
sel.value = current;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
updateSettingsUI();
|
||||
updateProfileSection();
|
||||
updateProviderSection();
|
||||
// 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'); },
|
||||
|
||||
function updateProfileSection() {
|
||||
const profileSection = document.getElementById('profileSection');
|
||||
const unmanagedSettings = document.getElementById('unmanagedSettings');
|
||||
const modeBadge = document.getElementById('settingsModeBadge');
|
||||
|
||||
if (Backend.isManaged) {
|
||||
profileSection.style.display = '';
|
||||
unmanagedSettings.style.display = 'none';
|
||||
modeBadge.textContent = '🔗 Managed Mode — API keys are configured by your admin or in Providers below';
|
||||
modeBadge.className = 'settings-mode-badge mode-managed';
|
||||
loadProfileIntoSettings();
|
||||
} else {
|
||||
profileSection.style.display = 'none';
|
||||
unmanagedSettings.style.display = '';
|
||||
modeBadge.textContent = '📱 Offline Mode — Configure your own API endpoint and key';
|
||||
modeBadge.className = 'settings-mode-badge mode-unmanaged';
|
||||
}
|
||||
document.getElementById('profileChangePwForm').style.display = 'none';
|
||||
}
|
||||
|
||||
async function loadProfileIntoSettings() {
|
||||
async loadProfileIntoSettings() {
|
||||
try {
|
||||
const profile = await Backend.getProfile();
|
||||
document.getElementById('profileDisplayName').value = profile.display_name || '';
|
||||
document.getElementById('profileEmail').value = profile.email || '';
|
||||
} catch (e) {
|
||||
console.warn('Failed to load profile:', e);
|
||||
}
|
||||
}
|
||||
const p = await API.getProfile();
|
||||
document.getElementById('profileDisplayName').value = p.display_name || '';
|
||||
document.getElementById('profileEmail').value = p.email || '';
|
||||
} catch (e) { /* optional */ }
|
||||
},
|
||||
|
||||
async function saveProfile() {
|
||||
if (!Backend.isManaged) return;
|
||||
|
||||
const displayName = document.getElementById('profileDisplayName').value.trim();
|
||||
const email = document.getElementById('profileEmail').value.trim();
|
||||
|
||||
const updates = {};
|
||||
if (displayName !== (Backend.user.display_name || '')) {
|
||||
updates.display_name = displayName || null;
|
||||
}
|
||||
if (email && email !== Backend.user.email) {
|
||||
updates.email = email;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) return;
|
||||
// ── Providers ────────────────────────────
|
||||
|
||||
async loadProviderList() {
|
||||
const el = document.getElementById('providerList');
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
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 : [];
|
||||
|
||||
const data = await API.listConfigs();
|
||||
const list = Array.isArray(data) ? data : (data.configs || data.data || []);
|
||||
if (list.length === 0) {
|
||||
container.innerHTML = '<div class="provider-empty">No providers configured. Add one to start chatting.</div>';
|
||||
el.innerHTML = '<div class="empty-hint">No providers configured.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = list.map(c => `
|
||||
el.innerHTML = list.map(c => `
|
||||
<div class="provider-row">
|
||||
<div class="provider-info">
|
||||
<span class="provider-name">${escapeHtmlUI(c.name)}</span>
|
||||
<span class="provider-meta">${escapeHtmlUI(c.provider)} · ${escapeHtmlUI(c.model_default || 'no default')}</span>
|
||||
<div>
|
||||
<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">
|
||||
<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>'}
|
||||
${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('');
|
||||
</div>`).join('');
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div class="admin-error">Failed to load providers: ' + escapeHtmlUI(e.message) + '</div>';
|
||||
el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
function showProviderForm() {
|
||||
document.getElementById('providerAddForm').style.display = '';
|
||||
document.getElementById('providerShowAddBtn').style.display = 'none';
|
||||
// Set default endpoint based on provider type
|
||||
updateProviderEndpointHint();
|
||||
}
|
||||
showProviderForm() { document.getElementById('providerAddForm').style.display = ''; },
|
||||
hideProviderForm() { document.getElementById('providerAddForm').style.display = 'none'; },
|
||||
|
||||
function hideProviderForm() {
|
||||
document.getElementById('providerAddForm').style.display = 'none';
|
||||
document.getElementById('providerShowAddBtn').style.display = '';
|
||||
clearProviderForm();
|
||||
}
|
||||
// ── Admin Modal ──────────────────────────
|
||||
|
||||
function clearProviderForm() {
|
||||
document.getElementById('providerName').value = '';
|
||||
document.getElementById('providerType').value = 'openai';
|
||||
document.getElementById('providerEndpoint').value = '';
|
||||
document.getElementById('providerApiKey').value = '';
|
||||
document.getElementById('providerDefaultModel').value = '';
|
||||
}
|
||||
openAdmin() { document.getElementById('adminModal').classList.add('active'); UI.switchAdminTab('users'); },
|
||||
closeAdmin() { document.getElementById('adminModal').classList.remove('active'); },
|
||||
|
||||
function updateProviderEndpointHint() {
|
||||
const type = document.getElementById('providerType').value;
|
||||
const endpoint = document.getElementById('providerEndpoint');
|
||||
if (!endpoint.value) {
|
||||
endpoint.placeholder = type === 'anthropic'
|
||||
? 'https://api.anthropic.com'
|
||||
: 'https://api.openai.com/v1';
|
||||
}
|
||||
}
|
||||
async 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 = '';
|
||||
|
||||
async function handleCreateProvider() {
|
||||
const name = document.getElementById('providerName').value.trim();
|
||||
const provider = document.getElementById('providerType').value;
|
||||
const endpoint = document.getElementById('providerEndpoint').value.trim();
|
||||
const apiKey = document.getElementById('providerApiKey').value.trim();
|
||||
const modelDefault = document.getElementById('providerDefaultModel').value.trim();
|
||||
|
||||
if (!name || !endpoint || !apiKey) {
|
||||
showToast('⚠️ Name, endpoint, and API key are required', 'warning');
|
||||
return;
|
||||
}
|
||||
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 {
|
||||
await Backend.createConfig(name, provider, endpoint, apiKey, modelDefault || null);
|
||||
showToast('✅ Provider added', 'success');
|
||||
hideProviderForm();
|
||||
loadProviderList();
|
||||
fetchModels(false); // Refresh model list
|
||||
} catch (e) {
|
||||
showToast('❌ ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
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 function deleteProvider(configId, name) {
|
||||
if (!confirm('Remove provider "' + name + '"?')) return;
|
||||
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 {
|
||||
await Backend.deleteConfig(configId);
|
||||
showToast('✅ Provider removed', 'success');
|
||||
loadProviderList();
|
||||
fetchModels(false);
|
||||
} catch (e) {
|
||||
showToast('❌ ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
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>`; }
|
||||
},
|
||||
|
||||
function escapeHtmlUI(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str || '';
|
||||
return div.innerHTML;
|
||||
}
|
||||
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>`; }
|
||||
},
|
||||
|
||||
function closeSettings() {
|
||||
document.getElementById('settingsModal').classList.remove('active');
|
||||
}
|
||||
// ── Export ────────────────────────────────
|
||||
|
||||
function toggleSidebar() {
|
||||
document.getElementById('sidebar').classList.toggle('collapsed');
|
||||
}
|
||||
exportChat(format) {
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
if (!chat) return UI.toast('No chat to export', 'warning');
|
||||
|
||||
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;
|
||||
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';
|
||||
}
|
||||
|
||||
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('');
|
||||
}
|
||||
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);
|
||||
},
|
||||
|
||||
function renderMessages(messages) {
|
||||
const container = document.getElementById('chatMessages');
|
||||
if (!messages || messages.length === 0) {
|
||||
newChat();
|
||||
return;
|
||||
// ── 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'));
|
||||
}
|
||||
},
|
||||
|
||||
container.innerHTML = messages
|
||||
.filter(m => m.role !== 'system')
|
||||
.map((msg, idx) => createMessageHTML(msg, idx))
|
||||
.join('');
|
||||
scrollToBottom();
|
||||
}
|
||||
// ── Scroll ───────────────────────────────
|
||||
|
||||
function createMessageHTML(message, index) {
|
||||
const isUser = message.role === 'user';
|
||||
const time = message.timestamp ? new Date(message.timestamp).toLocaleTimeString() : '';
|
||||
const formattedContent = formatMessage(message.content);
|
||||
_isNearBottom() {
|
||||
const el = document.getElementById('chatMessages');
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
||||
},
|
||||
|
||||
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>
|
||||
${time ? `<div class="message-time">${time}</div>` : ''}
|
||||
<div class="message-text">${formattedContent}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
_scrollToBottom(force) {
|
||||
if (force || this._isNearBottom()) {
|
||||
const el = document.getElementById('chatMessages');
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ── Formatting ───────────────────────────────
|
||||
|
||||
function formatMessage(content) {
|
||||
let formatted = content;
|
||||
if (!content) return '';
|
||||
|
||||
const thinkingBlocks = [];
|
||||
let text = content;
|
||||
|
||||
// Extract thinking blocks FIRST (before escaping)
|
||||
if (State.settings.showThinking) {
|
||||
formatted = formatted.replace(/<thinking>([\s\S]*?)<\/thinking>/gi, (match, thinkingContent) => {
|
||||
const blockId = 'thinking-' + Math.random().toString(36).substr(2, 9);
|
||||
// Store raw content; we'll escape it when reinserting
|
||||
thinkingBlocks.push({ id: blockId, content: thinkingContent.trim() });
|
||||
return `__THINKPLACEHOLDER_${blockId}__`;
|
||||
text = text.replace(/<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi, (_, inner) => {
|
||||
const id = 'think-' + Math.random().toString(36).slice(2, 9);
|
||||
thinkingBlocks.push({ id, content: inner.trim() });
|
||||
return `THINK_PLACEHOLDER_${id}`;
|
||||
});
|
||||
|
||||
let html;
|
||||
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
|
||||
html = _formatMarked(text);
|
||||
} else {
|
||||
formatted = formatted.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '');
|
||||
html = _formatBasic(text);
|
||||
}
|
||||
|
||||
// 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>
|
||||
`);
|
||||
for (const b of thinkingBlocks) {
|
||||
const inner = esc(b.content).replace(/\n/g, '<br>');
|
||||
const thinkHTML = App.settings.showThinking
|
||||
? `<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);
|
||||
html = html.replace(`THINK_PLACEHOLDER_${b.id}`, thinkHTML);
|
||||
}
|
||||
|
||||
// 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>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
function _formatMarked(text) {
|
||||
const rendered = marked.parse(text, { breaks: true, gfm: true });
|
||||
let html = DOMPurify.sanitize(rendered, { ADD_TAGS: ['details', 'summary'], ADD_ATTR: ['id', 'class', 'onclick'] });
|
||||
|
||||
html = html.replace(/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g, (_, attrs, code) => {
|
||||
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>`;
|
||||
});
|
||||
|
||||
// Inline code
|
||||
formatted = formatted.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
|
||||
// Bold
|
||||
formatted = formatted.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
|
||||
// Italic
|
||||
formatted = formatted.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
||||
|
||||
// Line breaks (but not inside pre/code)
|
||||
formatted = formatted.replace(/\n/g, '<br>');
|
||||
|
||||
return formatted;
|
||||
return html;
|
||||
}
|
||||
|
||||
function toggleThinkingBlock(blockId) {
|
||||
const block = document.getElementById(blockId);
|
||||
if (block) {
|
||||
block.classList.toggle('collapsed');
|
||||
}
|
||||
function _formatBasic(text) {
|
||||
let html = esc(text);
|
||||
html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => {
|
||||
const id = 'code-' + Math.random().toString(36).slice(2, 9);
|
||||
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>');
|
||||
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
||||
html = html.replace(/\n/g, '<br>');
|
||||
return html;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
function esc(s) {
|
||||
if (!s) return '';
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
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'));
|
||||
}
|
||||
}
|
||||
|
||||
function copyCode(codeId) {
|
||||
const codeElement = document.getElementById(codeId);
|
||||
if (codeElement) {
|
||||
navigator.clipboard.writeText(codeElement.textContent)
|
||||
.then(() => showToast('📋 Code copied', 'success'))
|
||||
.catch(() => showToast('❌ Failed to copy', 'error'));
|
||||
}
|
||||
}
|
||||
|
||||
function exportChat(format) {
|
||||
const chat = getCurrentChat();
|
||||
if (!chat) {
|
||||
showToast('⚠️ No chat to export', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
let content, filename, mimeType;
|
||||
const title = chat.title.replace(/[^a-z0-9]/gi, '_');
|
||||
|
||||
switch (format) {
|
||||
case 'markdown':
|
||||
content = `# ${chat.title}\n\n` + chat.messages
|
||||
.filter(m => m.role !== 'system')
|
||||
.map(m => `## ${m.role === 'user' ? 'You' : 'Assistant'}\n\n${m.content}`)
|
||||
.join('\n\n---\n\n');
|
||||
filename = `${title}.md`;
|
||||
mimeType = 'text/markdown';
|
||||
break;
|
||||
case 'json':
|
||||
content = JSON.stringify(chat, null, 2);
|
||||
filename = `${title}.json`;
|
||||
mimeType = 'application/json';
|
||||
break;
|
||||
case 'text':
|
||||
content = chat.messages
|
||||
.filter(m => m.role !== 'system')
|
||||
.map(m => `[${m.role === 'user' ? 'You' : 'Assistant'}]\n${m.content}`)
|
||||
.join('\n\n');
|
||||
filename = `${title}.txt`;
|
||||
mimeType = 'text/plain';
|
||||
break;
|
||||
}
|
||||
|
||||
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');
|
||||
// ── Helpers ──────────────────────────────────
|
||||
|
||||
function _relativeTime(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = (now - d) / 1000;
|
||||
|
||||
if (diff < 60) return 'now';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
|
||||
if (diff < 604800) return `${Math.floor(diff / 86400)}d`;
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
74
src/vendor/marked.min.js
vendored
Normal file
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
3
src/vendor/purify.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user