Changeset 0.6.0 (#36)
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500&display=swap');
|
||||
|
||||
/* ==========================================
|
||||
Chat Switchboard v0.5.2
|
||||
Chat Switchboard v0.6.1
|
||||
Clean utility layout · Open WebUI inspired
|
||||
========================================== */
|
||||
|
||||
@@ -19,25 +21,56 @@
|
||||
--danger: #ef4444;
|
||||
--success: #22c55e;
|
||||
--warning: #eab308;
|
||||
--purple: #a78bfa;
|
||||
--purple-dim: rgba(167,139,250,0.10);
|
||||
--radius: 8px;
|
||||
--radius-lg: 12px;
|
||||
--sidebar-w: 260px;
|
||||
--sidebar-rail: 52px;
|
||||
--font: 'Söhne', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--mono: 'Söhne Mono', 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||||
--font: 'DM Sans', 'Söhne', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--mono: 'JetBrains Mono', 'Söhne Mono', 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||||
--transition: 180ms ease;
|
||||
|
||||
/* Banner system — set by JS from /api/v1/admin/settings/banner */
|
||||
--banner-top-height: 0px;
|
||||
--banner-bottom-height: 0px;
|
||||
--banner-bg: transparent;
|
||||
--banner-fg: transparent;
|
||||
}
|
||||
|
||||
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body { font-family: var(--font); background: var(--bg); color: var(--text); overflow: hidden; font-size: 14px; }
|
||||
|
||||
/* Global scrollbar styling */
|
||||
* { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--border-light); }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
::selection { background: var(--accent-dim); }
|
||||
|
||||
/* ── App Shell ───────────────────────────── */
|
||||
|
||||
.app { display: flex; height: 100vh; }
|
||||
.app { display: flex; height: 100vh; flex-direction: column; }
|
||||
.app-body { display: flex; flex: 1; min-height: 0; }
|
||||
|
||||
/* ── Environment Banners ──────────────────── */
|
||||
|
||||
.banner {
|
||||
display: none;
|
||||
width: 100%; text-align: center;
|
||||
font-size: 12px; font-weight: 700; letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 0; line-height: 1.4;
|
||||
background: var(--banner-bg); color: var(--banner-fg);
|
||||
flex-shrink: 0; z-index: 9999;
|
||||
}
|
||||
.banner.active { display: block; }
|
||||
.banner-top { order: -1; }
|
||||
.banner-bottom { order: 999; }
|
||||
|
||||
/* ── Sidebar ─────────────────────────────── */
|
||||
|
||||
@@ -85,13 +118,105 @@ a:hover { text-decoration: underline; }
|
||||
.sidebar.collapsed .sb-label { opacity: 0; width: 0; }
|
||||
.sidebar.collapsed .brand-text { opacity: 0; width: 0; }
|
||||
|
||||
/* Chat list */
|
||||
/* Collapsed centering */
|
||||
.sidebar.collapsed .sidebar-top { padding: 10px 0 8px; align-items: center; }
|
||||
.sidebar.collapsed .sidebar-bottom { padding: 8px 0; display: flex; flex-direction: column; align-items: center; }
|
||||
.sidebar.collapsed .sb-brand { justify-content: center; padding: 8px 0; gap: 0; }
|
||||
.sidebar.collapsed .sb-btn { justify-content: center; padding: 8px 0; gap: 0; }
|
||||
.sidebar.collapsed .split-btn-main { justify-content: center; padding: 8px 0; gap: 0; }
|
||||
.sidebar.collapsed .user-btn { justify-content: center; padding: 6px 0; gap: 0; }
|
||||
.sidebar.collapsed .user-flyout {
|
||||
position: fixed;
|
||||
left: var(--sidebar-rail);
|
||||
bottom: 8px;
|
||||
right: auto;
|
||||
width: 200px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.sidebar.collapsed .avatar-bug { display: none; }
|
||||
|
||||
/* Split button (New Chat + dropdown) */
|
||||
.split-btn { display: flex; width: 100%; gap: 0; }
|
||||
.split-btn-main {
|
||||
flex: 1; display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 10px; border-radius: var(--radius) 0 0 var(--radius);
|
||||
background: none; border: none; color: var(--text-2);
|
||||
cursor: pointer; font-size: 13px; white-space: nowrap;
|
||||
transition: background var(--transition), color var(--transition);
|
||||
}
|
||||
.split-btn-main:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.split-btn-main svg { flex-shrink: 0; }
|
||||
|
||||
.split-btn-drop {
|
||||
display: flex; align-items: center; padding: 8px 6px;
|
||||
border-radius: 0 var(--radius) var(--radius) 0;
|
||||
background: none; border: none; border-left: 1px solid var(--border);
|
||||
color: var(--text-3); cursor: pointer;
|
||||
transition: background var(--transition), color var(--transition);
|
||||
}
|
||||
.split-btn-drop:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.sidebar.collapsed .split-btn-drop { display: none; }
|
||||
|
||||
/* Dropdown menu */
|
||||
.split-dropdown {
|
||||
display: none; position: absolute; left: 6px; right: 6px;
|
||||
top: 100%; z-index: 200;
|
||||
background: var(--bg-raised); border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius); padding: 4px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||
}
|
||||
.split-dropdown.open { display: block; }
|
||||
.split-dropdown-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 7px 10px; border-radius: 4px; width: 100%;
|
||||
background: none; border: none; color: var(--text-2);
|
||||
cursor: pointer; font-size: 12px; white-space: nowrap;
|
||||
transition: background var(--transition), color var(--transition);
|
||||
}
|
||||
.split-dropdown-item:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.split-dropdown-item.disabled { opacity: 0.4; pointer-events: none; }
|
||||
.split-dropdown-item .dd-hint {
|
||||
margin-left: auto; font-size: 10px; color: var(--text-3);
|
||||
}
|
||||
|
||||
/* Chat/Channel list */
|
||||
.sidebar-chats {
|
||||
flex: 1; overflow-y: auto; overflow-x: hidden;
|
||||
padding: 8px 6px;
|
||||
}
|
||||
.sidebar-chats::-webkit-scrollbar { width: 4px; }
|
||||
.sidebar-chats::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||
|
||||
/* Channels section header */
|
||||
.sidebar-section-header {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 8px 10px 4px; cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.sidebar-section-header .section-arrow {
|
||||
font-size: 10px; color: var(--text-3); transition: transform var(--transition);
|
||||
}
|
||||
.sidebar-section-header.collapsed .section-arrow { transform: rotate(-90deg); }
|
||||
.sidebar-section-header .section-label {
|
||||
font-size: 11px; font-weight: 600; color: var(--text-3);
|
||||
text-transform: uppercase; letter-spacing: 0.5px;
|
||||
}
|
||||
.sidebar-section-header .section-count {
|
||||
font-size: 10px; color: var(--text-3); margin-left: auto;
|
||||
}
|
||||
.sidebar.collapsed .sidebar-section-header { display: none; }
|
||||
|
||||
.channel-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 7px 10px; border-radius: var(--radius);
|
||||
cursor: pointer; font-size: 13px; color: var(--text-2);
|
||||
transition: background var(--transition); position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.channel-item:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.channel-item.active { background: var(--bg-raised); color: var(--text); }
|
||||
.channel-item .channel-hash { color: var(--text-3); font-weight: 600; flex-shrink: 0; }
|
||||
.channel-item .channel-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sidebar.collapsed .channel-item .channel-name { display: none; }
|
||||
|
||||
.chat-group-label {
|
||||
font-size: 11px; font-weight: 600; color: var(--text-3);
|
||||
@@ -231,8 +356,6 @@ a:hover { text-decoration: underline; }
|
||||
|
||||
/* Messages */
|
||||
.messages { flex: 1; overflow-y: auto; scroll-behavior: smooth; }
|
||||
.messages::-webkit-scrollbar { width: 6px; }
|
||||
.messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
|
||||
.message { padding: 1.25rem 0; }
|
||||
.message:not(:last-child) { border-bottom: 1px solid rgba(255,255,255,0.03); }
|
||||
@@ -413,35 +536,144 @@ button { font-family: var(--font); cursor: pointer; }
|
||||
/* ── Auth Splash ─────────────────────────── */
|
||||
|
||||
.splash {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: 100vh; background: var(--bg);
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
.splash-card {
|
||||
background: var(--bg-surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg); padding: 2rem;
|
||||
width: 380px; max-width: 90vw;
|
||||
}
|
||||
.splash-brand { text-align: center; margin-bottom: 1.5rem; }
|
||||
.splash-logo { font-size: 3rem; margin-bottom: 0.5rem; }
|
||||
.splash-brand h1 { font-size: 1.2rem; font-weight: 600; }
|
||||
.splash-brand p { color: var(--text-2); font-size: 0.85rem; }
|
||||
.splash-error { color: var(--danger); font-size: 12px; text-align: center; margin-top: 0.5rem; }
|
||||
|
||||
.auth-tabs { display: flex; margin-bottom: 1rem; border-bottom: 1px solid var(--border); }
|
||||
/* Hero Panel (left) */
|
||||
.splash-hero {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 4rem 3.5rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 60% at 30% 40%, rgba(108,159,255,0.06), transparent),
|
||||
radial-gradient(ellipse 60% 50% at 70% 70%, rgba(167,139,250,0.05), transparent),
|
||||
var(--bg);
|
||||
}
|
||||
.splash-hero::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -50%;
|
||||
background-image:
|
||||
linear-gradient(rgba(108,159,255,0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(108,159,255,0.04) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
animation: gridDrift 30s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes gridDrift { to { transform: translate(48px, 48px); } }
|
||||
.splash-hero::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 320px; height: 320px;
|
||||
bottom: -80px; right: -60px;
|
||||
background: radial-gradient(circle, rgba(167,139,250,0.08), transparent 70%);
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-content { position: relative; z-index: 1; max-width: 520px; }
|
||||
.hero-logo-row { display: flex; align-items: center; gap: 14px; margin-bottom: 1.5rem; }
|
||||
.hero-logo-mark { width: 48px; height: 48px; flex-shrink: 0; }
|
||||
.hero-wordmark { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.03em; line-height: 1.1; }
|
||||
.hero-wordmark span { color: var(--accent); }
|
||||
.hero-headline {
|
||||
font-size: 2.4rem; font-weight: 700; line-height: 1.15;
|
||||
letter-spacing: -0.03em; margin-bottom: 1rem;
|
||||
background: linear-gradient(135deg, var(--text) 0%, var(--text-2) 100%);
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
|
||||
}
|
||||
.hero-sub { font-size: 1.05rem; color: var(--text-2); line-height: 1.6; margin-bottom: 2.5rem; max-width: 440px; }
|
||||
|
||||
.hero-features { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.hero-pill {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
padding: 7px 14px; background: var(--bg-surface);
|
||||
border: 1px solid var(--border); border-radius: 100px;
|
||||
font-size: 0.8rem; font-weight: 500; color: var(--text-2);
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.hero-pill:hover { border-color: var(--border-light); color: var(--text); }
|
||||
.hero-pill .pill-icon { font-size: 0.95rem; line-height: 1; }
|
||||
.hero-pill.accent { border-color: rgba(108,159,255,0.2); color: var(--accent); }
|
||||
.hero-pill.purple { border-color: rgba(167,139,250,0.2); color: var(--purple); }
|
||||
.hero-version { margin-top: 3rem; font-size: 0.72rem; font-family: var(--mono); color: var(--text-3); letter-spacing: 0.04em; }
|
||||
|
||||
/* Auth Panel (right) */
|
||||
.splash-auth {
|
||||
width: 440px; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 2rem; background: var(--bg-surface);
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
.auth-card { width: 100%; max-width: 340px; }
|
||||
.auth-card-header { margin-bottom: 2rem; }
|
||||
.auth-card-header h2 { font-size: 1.25rem; font-weight: 600; letter-spacing: -0.02em; margin-bottom: 4px; }
|
||||
.auth-card-header p { font-size: 0.85rem; color: var(--text-3); }
|
||||
|
||||
.auth-tabs { display: flex; margin-bottom: 1.5rem; border-bottom: 1px solid var(--border); }
|
||||
.auth-tab {
|
||||
flex: 1; background: none; border: none; color: var(--text-3);
|
||||
padding: 8px; cursor: pointer; font-size: 13px;
|
||||
padding: 10px 0; cursor: pointer; font-family: var(--font);
|
||||
font-size: 0.85rem; font-weight: 500;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color var(--transition);
|
||||
}
|
||||
.auth-tab:hover { color: var(--text-2); }
|
||||
.auth-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
.auth-error { color: var(--danger); font-size: 12px; min-height: 1.2em; margin: 0.5rem 0; }
|
||||
.auth-actions { display: flex; flex-direction: column; gap: 0.5rem; margin-top: 1rem; }
|
||||
.auth-error { color: var(--danger); font-size: 0.78rem; min-height: 1.2em; margin: 0.5rem 0; }
|
||||
.splash-error { color: var(--danger); font-size: 0.78rem; text-align: center; margin-top: 0.5rem; }
|
||||
.auth-actions { margin-top: 1.5rem; }
|
||||
.auth-footer { margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--border); text-align: center; }
|
||||
.auth-footer p { font-size: 0.75rem; color: var(--text-3); line-height: 1.6; }
|
||||
|
||||
/* Splash entrance animation */
|
||||
.hero-logo-row, .hero-headline, .hero-sub, .hero-features, .hero-version,
|
||||
.auth-card-header, .splash .auth-tabs, #authLoginForm, .splash .auth-actions {
|
||||
opacity: 0; transform: translateY(12px);
|
||||
animation: fadeUp 0.5s ease forwards;
|
||||
}
|
||||
.hero-logo-row { animation-delay: 0.05s; }
|
||||
.hero-headline { animation-delay: 0.12s; }
|
||||
.hero-sub { animation-delay: 0.19s; }
|
||||
.hero-features { animation-delay: 0.26s; }
|
||||
.hero-version { animation-delay: 0.33s; }
|
||||
.auth-card-header { animation-delay: 0.15s; }
|
||||
.splash .auth-tabs { animation-delay: 0.22s; }
|
||||
#authLoginForm { animation-delay: 0.29s; }
|
||||
.splash .auth-actions { animation-delay: 0.36s; }
|
||||
@keyframes fadeUp { to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
/* Splash responsive */
|
||||
@media (max-width: 860px) {
|
||||
.splash { flex-direction: column; }
|
||||
.splash-hero { padding: 2.5rem 1.75rem 2rem; min-height: auto; }
|
||||
.hero-headline { font-size: 1.8rem; }
|
||||
.hero-sub { font-size: 0.95rem; margin-bottom: 1.5rem; }
|
||||
.hero-version { margin-top: 1.5rem; }
|
||||
.splash-auth { width: 100%; border-left: none; border-top: 1px solid var(--border); padding: 2rem 1.75rem; }
|
||||
.auth-card { max-width: 400px; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.splash-hero { padding: 2rem 1.25rem 1.5rem; }
|
||||
.hero-logo-row { gap: 10px; margin-bottom: 1rem; }
|
||||
.hero-wordmark { font-size: 1.3rem; }
|
||||
.hero-headline { font-size: 1.5rem; }
|
||||
.hero-sub { font-size: 0.88rem; }
|
||||
.hero-features { gap: 6px; }
|
||||
.hero-pill { padding: 5px 10px; font-size: 0.72rem; }
|
||||
.splash-auth { padding: 1.5rem 1.25rem; }
|
||||
}
|
||||
|
||||
/* ── Forms ────────────────────────────────── */
|
||||
|
||||
.form-group { margin-bottom: 0.75rem; }
|
||||
.form-group label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 4px; font-weight: 500; }
|
||||
.form-group label { display: block; font-size: 13px; color: var(--text-2); margin-bottom: 4px; font-weight: 500; }
|
||||
.form-group input, .form-group select, .form-group textarea {
|
||||
width: 100%; background: var(--bg-raised); border: 1px solid var(--border);
|
||||
color: var(--text); padding: 8px 12px; border-radius: var(--radius);
|
||||
@@ -454,7 +686,7 @@ button { font-family: var(--font); cursor: pointer; }
|
||||
.form-row { display: flex; gap: 0.75rem; }
|
||||
.form-row .form-group { flex: 1; }
|
||||
.form-hint { font-weight: 400; color: var(--text-3); margin-left: 4px; }
|
||||
.checkbox-label { display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer; margin: 0.5rem 0; color: var(--text-2); }
|
||||
.checkbox-label { display: flex; align-items: center; gap: 8px; font-size: 14px; cursor: pointer; margin: 0.5rem 0; color: var(--text-2); }
|
||||
|
||||
/* ── Modal ────────────────────────────────── */
|
||||
|
||||
@@ -478,7 +710,7 @@ button { font-family: var(--font); cursor: pointer; }
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 20px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.modal-header h2 { font-size: 15px; font-weight: 600; }
|
||||
.modal-header h2 { font-size: 16px; font-weight: 600; }
|
||||
.modal-close {
|
||||
background: none; border: none; color: var(--text-3); font-size: 18px;
|
||||
cursor: pointer; padding: 2px 6px; border-radius: 4px;
|
||||
@@ -493,7 +725,25 @@ button { font-family: var(--font); cursor: pointer; }
|
||||
|
||||
.settings-section { margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border); }
|
||||
.settings-section:last-child { border-bottom: none; margin-bottom: 0; }
|
||||
.settings-section h3 { font-size: 13px; font-weight: 600; color: var(--text-2); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.settings-section h3 { font-size: 12px; font-weight: 600; color: var(--text-3); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
|
||||
/* Settings tabs */
|
||||
.settings-tabs { display: flex; border-bottom: 1px solid var(--border); padding: 0 16px; background: var(--bg-raised); gap: 0; }
|
||||
.settings-tabs .settings-tab {
|
||||
padding: 10px 14px; background: none; border: none; border-radius: 0;
|
||||
color: var(--text-3); cursor: pointer; font-size: 13px; font-family: var(--font);
|
||||
border-bottom: 2px solid transparent; transition: color var(--transition);
|
||||
outline: none; -webkit-appearance: none; appearance: none;
|
||||
}
|
||||
.settings-tabs .settings-tab:hover { color: var(--text-2); }
|
||||
.settings-tabs .settings-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
|
||||
.settings-notice {
|
||||
background: rgba(234,179,8,0.08); border: 1px solid rgba(234,179,8,0.2);
|
||||
border-radius: var(--radius); padding: 10px 14px; font-size: 13px;
|
||||
color: var(--warning); display: flex; align-items: center; gap: 8px;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Providers */
|
||||
.provider-row {
|
||||
@@ -508,22 +758,95 @@ button { font-family: var(--font); cursor: pointer; }
|
||||
/* Admin tabs */
|
||||
.admin-tabs { display: flex; border-bottom: 1px solid var(--border); padding: 0 16px; background: var(--bg-raised); }
|
||||
.admin-tab {
|
||||
padding: 8px 12px; background: none; border: none; color: var(--text-3);
|
||||
cursor: pointer; font-size: 12px; border-bottom: 2px solid transparent;
|
||||
padding: 10px 14px; background: none; border: none; color: var(--text-3);
|
||||
cursor: pointer; font-size: 13px; border-bottom: 2px solid transparent;
|
||||
}
|
||||
.admin-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
|
||||
.admin-user-row { padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
|
||||
.admin-user-email { font-size: 11px; color: var(--text-3); }
|
||||
.admin-model-row { display: flex; align-items: center; gap: 12px; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 12px; }
|
||||
.admin-model-row .model-caps-inline { display: flex; gap: 3px; margin-left: auto; }
|
||||
.admin-model-row .provider-meta { min-width: 80px; text-align: right; }
|
||||
.admin-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 1rem; }
|
||||
.admin-hint { font-size: 12px; color: var(--text-3); }
|
||||
.admin-inline-form {
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 1rem; margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.admin-user-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 0; border-bottom: 1px solid var(--border); font-size: 14px;
|
||||
min-height: 52px;
|
||||
}
|
||||
.admin-user-info { flex: 1; }
|
||||
.admin-user-email { font-size: 12px; color: var(--text-3); margin-top: 2px; }
|
||||
.admin-user-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
||||
.admin-user-actions button { background: none; border: 1px solid var(--border); color: var(--text-2); border-radius: 4px; padding: 4px 10px; font-size: 12px; cursor: pointer; min-width: 68px; text-align: center; }
|
||||
.admin-user-actions button:hover { border-color: var(--border-light); color: var(--text); }
|
||||
.admin-user-actions .btn-danger { color: var(--danger); }
|
||||
.admin-user-actions .btn-danger:hover { border-color: var(--danger); }
|
||||
|
||||
.admin-model-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); font-size: 14px; min-height: 44px; }
|
||||
.admin-model-row .model-name { flex: 1; font-weight: 500; }
|
||||
.admin-model-row .model-caps-inline { display: flex; gap: 3px; }
|
||||
.admin-model-row .provider-meta { min-width: 80px; text-align: right; color: var(--text-3); font-size: 12px; }
|
||||
.admin-model-toggle { background: none; border: 1px solid var(--border); border-radius: 4px; padding: 4px 10px; font-size: 12px; cursor: pointer; color: var(--text-2); min-width: 82px; text-align: center; }
|
||||
.admin-model-toggle:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.admin-model-toggle.enabled { border-color: var(--success); color: var(--success); }
|
||||
|
||||
.admin-provider-row {
|
||||
display: flex; align-items: center; gap: 12px; padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border); font-size: 14px;
|
||||
}
|
||||
.admin-provider-row .provider-name { font-weight: 500; flex: 1; }
|
||||
.admin-provider-row .provider-endpoint { font-size: 12px; color: var(--text-3); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.admin-provider-row .btn-delete { background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 16px; padding: 2px 6px; }
|
||||
.admin-provider-row .btn-delete:hover { color: var(--danger); }
|
||||
|
||||
/* Stats cards */
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; }
|
||||
.stat-card {
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 14px 16px; text-align: center;
|
||||
}
|
||||
.stat-card .stat-value { font-size: 1.6rem; font-weight: 700; color: var(--text); line-height: 1.2; }
|
||||
.stat-card .stat-label { font-size: 0.78rem; color: var(--text-3); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
|
||||
/* Banner preview */
|
||||
.banner-preview {
|
||||
margin-top: 0.75rem; padding: 6px 16px; text-align: center;
|
||||
font-size: 12px; font-weight: 700; letter-spacing: 0.06em;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.color-input-wrap { display: flex; align-items: center; gap: 8px; }
|
||||
.color-input-wrap input[type="color"] {
|
||||
width: 36px; height: 32px; border: 1px solid var(--border);
|
||||
border-radius: var(--radius); background: var(--bg);
|
||||
cursor: pointer; padding: 2px;
|
||||
}
|
||||
.color-hex {
|
||||
width: 80px; background: var(--bg-raised); border: 1px solid var(--border);
|
||||
color: var(--text); padding: 6px 10px; border-radius: var(--radius);
|
||||
font-family: var(--mono); font-size: 12px;
|
||||
}
|
||||
.section-hint { font-size: 13px; color: var(--text-3); margin-bottom: 0.75rem; }
|
||||
|
||||
/* User model list */
|
||||
.model-list-grid { display: flex; flex-direction: column; gap: 2px; }
|
||||
.model-list-item {
|
||||
display: flex; align-items: center; gap: 10px; padding: 8px 10px;
|
||||
border-radius: var(--radius); font-size: 13px; cursor: default;
|
||||
}
|
||||
.model-list-item:hover { background: var(--bg-hover); }
|
||||
.model-list-item .model-name { flex: 1; font-weight: 500; }
|
||||
.model-list-item .model-provider { font-size: 12px; color: var(--text-3); }
|
||||
.model-list-item .model-caps-inline { display: flex; gap: 3px; }
|
||||
|
||||
.badge-admin { background: var(--accent); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
|
||||
.badge-user { background: var(--bg-raised); color: var(--text-3); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
|
||||
.badge-inactive { background: var(--danger); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
|
||||
.loading { color: var(--text-3); font-size: 12px; padding: 0.5rem; }
|
||||
.error-hint { color: var(--danger); font-size: 12px; padding: 0.5rem; }
|
||||
.empty-hint { color: var(--text-3); font-size: 12px; text-align: center; padding: 1rem; }
|
||||
.badge-pending { background: rgba(234,179,8,0.85); color: #000; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
|
||||
.admin-user-row.user-inactive { opacity: 0.7; }
|
||||
.loading { color: var(--text-3); font-size: 13px; padding: 0.5rem; }
|
||||
.error-hint { color: var(--danger); font-size: 13px; padding: 0.5rem; }
|
||||
.empty-hint { color: var(--text-3); font-size: 13px; text-align: center; padding: 1rem; }
|
||||
|
||||
/* ── Toast ────────────────────────────────── */
|
||||
|
||||
|
||||
300
src/index.html
300
src/index.html
@@ -3,17 +3,23 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<base href="%%BASE_HREF%%">
|
||||
<script>window.__BASE__ = '%%BASE_PATH%%';</script>
|
||||
<title>Chat Switchboard</title>
|
||||
<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.5.4">
|
||||
<link rel="stylesheet" href="css/styles.css?v=0.6.2">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ── App Shell ───────────────────────────── -->
|
||||
<div id="appContainer" class="app" style="display:none">
|
||||
|
||||
<!-- Environment Banner (top) -->
|
||||
<div class="banner banner-top" id="bannerTop"></div>
|
||||
|
||||
<div class="app-body">
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-top">
|
||||
@@ -22,10 +28,29 @@
|
||||
<svg class="brand-collapse" 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>
|
||||
<span class="sb-label brand-text">Chat Switchboard</span>
|
||||
</button>
|
||||
<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 class="split-btn" style="position:relative">
|
||||
<button class="split-btn-main" 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>
|
||||
<button class="split-btn-drop" id="newChatDropBtn" title="More options">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
<div class="split-dropdown" id="newChatDropdown">
|
||||
<button class="split-dropdown-item" onclick="newChat()">
|
||||
<svg width="14" height="14" 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>
|
||||
New Chat
|
||||
</button>
|
||||
<button class="split-dropdown-item disabled" title="Coming soon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
Group Chat <span class="dd-hint">soon</span>
|
||||
</button>
|
||||
<button class="split-dropdown-item disabled" title="Coming soon">
|
||||
<svg width="14" height="14" 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"/></svg>
|
||||
New Channel <span class="dd-hint">soon</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-chats" id="chatHistory"></div>
|
||||
@@ -95,34 +120,68 @@
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div><!-- .app-body -->
|
||||
|
||||
<!-- Environment Banner (bottom) -->
|
||||
<div class="banner banner-bottom" id="bannerBottom"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── Auth Splash ─────────────────────────── -->
|
||||
<div class="splash" id="splashGate">
|
||||
<div class="splash-card">
|
||||
<div class="splash-brand">
|
||||
<div class="splash-logo">🔀</div>
|
||||
<h1>Chat Switchboard</h1>
|
||||
<p>Multi-Model AI Chat</p>
|
||||
<div class="splash-hero">
|
||||
<div class="hero-content">
|
||||
<div class="hero-logo-row">
|
||||
<svg class="hero-logo-mark" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="64" height="64" rx="12" fill="#1a1a2e"/>
|
||||
<path d="M12 48 L36 16" stroke="#6c9fff" stroke-width="5.5" stroke-linecap="round" fill="none"/>
|
||||
<polygon points="42,12 34,14 38,22" fill="#6c9fff"/>
|
||||
<path d="M12 16 L24 30" stroke="#a78bfa" stroke-width="5.5" stroke-linecap="round" fill="none"/>
|
||||
<path d="M30 38 L36 48" stroke="#a78bfa" stroke-width="5.5" stroke-linecap="round" fill="none"/>
|
||||
<polygon points="42,52 34,50 38,42" fill="#a78bfa"/>
|
||||
</svg>
|
||||
<div class="hero-wordmark">Chat <span>Switchboard</span></div>
|
||||
</div>
|
||||
<h1 class="hero-headline">One interface.<br>Every AI model.</h1>
|
||||
<p class="hero-sub">Route conversations to OpenAI, Anthropic, and any OpenAI-compatible provider from a single, self-hosted chat interface. Bring your own keys. Keep your data.</p>
|
||||
<div class="hero-features">
|
||||
<div class="hero-pill accent"><span class="pill-icon">⚡</span> Streaming responses</div>
|
||||
<div class="hero-pill purple"><span class="pill-icon">🔀</span> Multi-provider routing</div>
|
||||
<div class="hero-pill"><span class="pill-icon">🔑</span> Bring your own API keys</div>
|
||||
<div class="hero-pill"><span class="pill-icon">🏠</span> Self-hosted</div>
|
||||
<div class="hero-pill"><span class="pill-icon">✈️</span> Air-gap ready</div>
|
||||
<div class="hero-pill"><span class="pill-icon">🧠</span> Thinking blocks</div>
|
||||
</div>
|
||||
<div class="hero-version">v0.6.2</div>
|
||||
</div>
|
||||
<div class="auth-tabs">
|
||||
<button class="auth-tab active" id="authTabLogin" onclick="switchAuthTab('login')">Sign In</button>
|
||||
<button class="auth-tab" id="authTabRegister" onclick="switchAuthTab('register')">Register</button>
|
||||
</div>
|
||||
<div id="authLoginForm">
|
||||
<div class="form-group"><label>Username or Email</label><input type="text" id="authLogin" autocomplete="username"></div>
|
||||
<div class="form-group"><label>Password</label><input type="password" id="authPassword" autocomplete="current-password" onkeydown="if(event.key==='Enter')handleLogin()"></div>
|
||||
</div>
|
||||
<div id="authRegisterForm" style="display:none">
|
||||
<div class="form-group"><label>Username</label><input type="text" id="authUsername" autocomplete="username"></div>
|
||||
<div class="form-group"><label>Email</label><input type="email" id="authEmail" autocomplete="email"></div>
|
||||
<div class="form-group"><label>Password</label><input type="password" id="authRegPassword" autocomplete="new-password" onkeydown="if(event.key==='Enter')handleRegister()"></div>
|
||||
</div>
|
||||
<div class="auth-error" id="authError"></div>
|
||||
<div class="splash-error" id="splashError"></div>
|
||||
<div class="auth-actions">
|
||||
<button class="btn-primary btn-full" id="authLoginBtn" data-label="Sign In" onclick="handleLogin()">Sign In</button>
|
||||
<button class="btn-primary btn-full" id="authRegisterBtn" data-label="Create Account" onclick="handleRegister()" style="display:none">Create Account</button>
|
||||
</div>
|
||||
<div class="splash-auth">
|
||||
<div class="auth-card">
|
||||
<div class="auth-card-header">
|
||||
<h2>Welcome back</h2>
|
||||
<p>Sign in to continue to your workspace</p>
|
||||
</div>
|
||||
<div class="auth-tabs">
|
||||
<button class="auth-tab active" id="authTabLogin" onclick="switchAuthTab('login')">Sign In</button>
|
||||
<button class="auth-tab" id="authTabRegister" onclick="switchAuthTab('register')">Register</button>
|
||||
</div>
|
||||
<div id="authLoginForm">
|
||||
<div class="form-group"><label>Username or Email</label><input type="text" id="authLogin" autocomplete="username" placeholder="you@example.com"></div>
|
||||
<div class="form-group"><label>Password</label><input type="password" id="authPassword" autocomplete="current-password" placeholder="••••••••" 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" placeholder="Choose a username"></div>
|
||||
<div class="form-group"><label>Email</label><input type="email" id="authEmail" autocomplete="email" placeholder="you@example.com"></div>
|
||||
<div class="form-group"><label>Password</label><input type="password" id="authRegPassword" autocomplete="new-password" placeholder="Min 8 characters" onkeydown="if(event.key==='Enter')handleRegister()"></div>
|
||||
</div>
|
||||
<div class="auth-error" id="authError"></div>
|
||||
<div class="splash-error" id="splashError"></div>
|
||||
<div class="auth-actions">
|
||||
<button class="btn-primary btn-full" id="authLoginBtn" data-label="Sign In" onclick="handleLogin()">Sign In</button>
|
||||
<button class="btn-primary btn-full" id="authRegisterBtn" data-label="Create Account" onclick="handleRegister()" style="display:none">Create Account</button>
|
||||
</div>
|
||||
<div class="auth-footer">
|
||||
<p>Self-hosted AI chat · Your keys, your data</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,44 +190,62 @@
|
||||
<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="settings-tabs">
|
||||
<button class="settings-tab active" data-stab="general" onclick="UI.switchSettingsTab('general')">General</button>
|
||||
<button class="settings-tab" data-stab="providers" onclick="UI.switchSettingsTab('providers')">Providers</button>
|
||||
<button class="settings-tab" data-stab="models" onclick="UI.switchSettingsTab('models')">Models</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 <span class="form-hint" id="settingsMaxHint"></span></label>
|
||||
<input type="number" id="settingsMaxTokens" placeholder="Auto (from model)">
|
||||
<!-- General Tab -->
|
||||
<div class="settings-tab-content" id="settingsGeneralTab">
|
||||
<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>
|
||||
<div class="form-group"><label>Temperature</label><input type="number" id="settingsTemp" value="0.7" step="0.1" min="0" max="2"></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 <span class="form-hint" id="settingsMaxHint"></span></label>
|
||||
<input type="number" id="settingsMaxTokens" placeholder="Auto (from model)">
|
||||
</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>
|
||||
</div>
|
||||
<!-- Providers Tab -->
|
||||
<div class="settings-tab-content" id="settingsProvidersTab" style="display:none">
|
||||
<section class="settings-section">
|
||||
<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><option value="venice">Venice.ai</option><option value="openrouter">OpenRouter</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 id="userProvidersDisabled" class="settings-notice" style="display:none">
|
||||
<span>⚠️</span> User-configured providers have been disabled by the administrator.
|
||||
</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><option value="venice">Venice.ai</option><option value="openrouter">OpenRouter</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>
|
||||
<!-- Models Tab -->
|
||||
<div class="settings-tab-content" id="settingsModelsTab" style="display:none">
|
||||
<p class="section-hint">Models available from your providers and global configs.</p>
|
||||
<div id="userModelList" class="model-list-grid"><div class="empty-hint">Loading models...</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer"><button class="btn-primary" id="settingsSaveBtn">Save Settings</button></div>
|
||||
</div>
|
||||
@@ -186,12 +263,89 @@
|
||||
<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="adminUsersTab">
|
||||
<div class="admin-toolbar">
|
||||
<button class="btn-small btn-primary" id="adminAddUserBtn">+ Add User</button>
|
||||
</div>
|
||||
<div id="adminAddUserForm" style="display:none" class="admin-inline-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Username</label><input type="text" id="adminNewUsername" placeholder="username"></div>
|
||||
<div class="form-group"><label>Email</label><input type="email" id="adminNewEmail" placeholder="email@example.com"></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Password</label><input type="password" id="adminNewPassword" placeholder="min 8 chars"></div>
|
||||
<div class="form-group"><label>Role</label><select id="adminNewRole"><option value="user">User</option><option value="admin">Admin</option></select></div>
|
||||
</div>
|
||||
<div class="form-row"><button class="btn-small btn-primary" id="adminCreateUserBtn">Create</button><button class="btn-small" id="adminCancelUserBtn">Cancel</button></div>
|
||||
</div>
|
||||
<div id="adminUserList"></div>
|
||||
</div>
|
||||
<div class="admin-tab-content" id="adminProvidersTab" style="display:none">
|
||||
<div class="admin-toolbar">
|
||||
<button class="btn-small btn-primary" id="adminAddProviderBtn">+ Add Global Provider</button>
|
||||
</div>
|
||||
<div id="adminAddProviderForm" style="display:none" class="admin-inline-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Name</label><input type="text" id="adminProvName" placeholder="OpenAI Production"></div>
|
||||
<div class="form-group"><label>Provider</label><select id="adminProvType"><option value="openai">OpenAI-compatible</option><option value="anthropic">Anthropic</option><option value="venice">Venice.ai</option><option value="openrouter">OpenRouter</option></select></div>
|
||||
</div>
|
||||
<div class="form-group"><label>Endpoint</label><input type="text" id="adminProvEndpoint" placeholder="https://api.openai.com/v1"></div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>API Key</label><input type="password" id="adminProvKey" placeholder="sk-..."></div>
|
||||
<div class="form-group"><label>Default Model</label><input type="text" id="adminProvModel" placeholder="gpt-4o"></div>
|
||||
</div>
|
||||
<div class="form-row"><button class="btn-small btn-primary" id="adminCreateProvBtn">Save</button><button class="btn-small" id="adminCancelProvBtn">Cancel</button></div>
|
||||
</div>
|
||||
<div id="adminProviderList"></div>
|
||||
</div>
|
||||
<div class="admin-tab-content" id="adminModelsTab" style="display:none">
|
||||
<div class="admin-toolbar">
|
||||
<button class="btn-small btn-primary" id="adminFetchModelsBtn">⟳ Fetch Models</button>
|
||||
<button class="btn-small" onclick="bulkToggleModels(true)">Enable All</button>
|
||||
<button class="btn-small" onclick="bulkToggleModels(false)">Disable All</button>
|
||||
<span class="admin-hint" id="adminModelsHint"></span>
|
||||
</div>
|
||||
<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>
|
||||
<section class="settings-section">
|
||||
<h3>Registration</h3>
|
||||
<label class="checkbox-label"><input type="checkbox" id="adminRegToggle" checked> Allow new user registration</label>
|
||||
<div class="form-group" style="margin-top: 8px;">
|
||||
<label>Default state for new accounts</label>
|
||||
<select id="adminRegDefaultState">
|
||||
<option value="active">Active — immediate access</option>
|
||||
<option value="pending">Pending — require admin approval</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
<section class="settings-section">
|
||||
<h3>User Providers</h3>
|
||||
<label class="checkbox-label"><input type="checkbox" id="adminUserProvidersToggle" checked> Allow users to configure their own API providers</label>
|
||||
<p class="section-hint">When disabled, users can only use admin-configured global providers.</p>
|
||||
</section>
|
||||
<section class="settings-section">
|
||||
<h3>Environment Banner</h3>
|
||||
<label class="checkbox-label"><input type="checkbox" id="adminBannerEnabled"> Show environment banner</label>
|
||||
<div id="bannerConfigFields" style="display:none">
|
||||
<div class="form-group">
|
||||
<label>Preset</label>
|
||||
<select id="adminBannerPreset"><option value="">Custom</option></select>
|
||||
</div>
|
||||
<div class="form-group"><label>Banner Text</label><input type="text" id="adminBannerText" placeholder="DEVELOPMENT"></div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Position</label>
|
||||
<select id="adminBannerPosition"><option value="both">Top & Bottom</option><option value="top">Top only</option><option value="bottom">Bottom only</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Background</label><div class="color-input-wrap"><input type="color" id="adminBannerBg" value="#007a33"><input type="text" id="adminBannerBgHex" class="color-hex" value="#007a33" maxlength="7"></div></div>
|
||||
<div class="form-group"><label>Text Color</label><div class="color-input-wrap"><input type="color" id="adminBannerFg" value="#ffffff"><input type="text" id="adminBannerFgHex" class="color-hex" value="#ffffff" maxlength="7"></div></div>
|
||||
</div>
|
||||
<div class="banner-preview" id="bannerPreview">PREVIEW</div>
|
||||
</div>
|
||||
</section>
|
||||
<button class="btn-primary btn-small" id="adminSaveSettings">Save Settings</button>
|
||||
</div>
|
||||
<div class="admin-tab-content" id="adminStatsTab" style="display:none"><div id="adminStats"></div></div>
|
||||
</div>
|
||||
@@ -235,10 +389,10 @@
|
||||
<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.4"></script>
|
||||
<script src="js/events.js?v=0.5.4"></script>
|
||||
<script src="js/api.js?v=0.5.4"></script>
|
||||
<script src="js/ui.js?v=0.5.4"></script>
|
||||
<script src="js/app.js?v=0.5.4"></script>
|
||||
<script src="js/debug.js?v=0.6.2"></script>
|
||||
<script src="js/events.js?v=0.6.2"></script>
|
||||
<script src="js/api.js?v=0.6.2"></script>
|
||||
<script src="js/ui.js?v=0.6.2"></script>
|
||||
<script src="js/app.js?v=0.6.2"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – API Client (v0.5.0)
|
||||
// Chat Switchboard – API Client (v0.6.2)
|
||||
// ==========================================
|
||||
// Backend-only mode. Handles auth tokens and
|
||||
// all HTTP calls. No offline fallback.
|
||||
//
|
||||
// BASE_PATH: injected via index.html <script>
|
||||
// at container startup. All API paths are
|
||||
// prefixed automatically.
|
||||
// ==========================================
|
||||
|
||||
const BASE = window.__BASE__ || '';
|
||||
const _storageKey = BASE ? `sb_auth_${BASE.replace(/\//g, '')}` : 'sb_auth';
|
||||
|
||||
const API = {
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
@@ -15,7 +22,7 @@ const API = {
|
||||
|
||||
loadTokens() {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem('sb_auth') || 'null');
|
||||
const saved = JSON.parse(localStorage.getItem(_storageKey) || 'null');
|
||||
if (saved) {
|
||||
this.accessToken = saved.accessToken || null;
|
||||
this.refreshToken = saved.refreshToken || null;
|
||||
@@ -25,7 +32,7 @@ const API = {
|
||||
},
|
||||
|
||||
saveTokens() {
|
||||
localStorage.setItem('sb_auth', JSON.stringify({
|
||||
localStorage.setItem(_storageKey, JSON.stringify({
|
||||
accessToken: this.accessToken,
|
||||
refreshToken: this.refreshToken,
|
||||
user: this.user
|
||||
@@ -36,7 +43,7 @@ const API = {
|
||||
this.accessToken = null;
|
||||
this.refreshToken = null;
|
||||
this.user = null;
|
||||
localStorage.removeItem('sb_auth');
|
||||
localStorage.removeItem(_storageKey);
|
||||
},
|
||||
|
||||
get isAuthed() { return !!this.accessToken; },
|
||||
@@ -45,7 +52,7 @@ const API = {
|
||||
// ── Auth ─────────────────────────────────
|
||||
|
||||
async health() {
|
||||
const resp = await fetch('/api/v1/health', { signal: AbortSignal.timeout(8000) });
|
||||
const resp = await fetch(BASE + '/api/v1/health', { signal: AbortSignal.timeout(8000) });
|
||||
if (!resp.ok) throw new Error(`Health: ${resp.status}`);
|
||||
return resp.json();
|
||||
},
|
||||
@@ -58,7 +65,7 @@ const API = {
|
||||
|
||||
async register(username, email, password) {
|
||||
const data = await this._post('/api/v1/auth/register', { username, email, password }, true);
|
||||
this._setAuth(data);
|
||||
if (!data.pending) this._setAuth(data);
|
||||
return data;
|
||||
},
|
||||
|
||||
@@ -92,36 +99,39 @@ const API = {
|
||||
this.saveTokens();
|
||||
},
|
||||
|
||||
// ── Chats ────────────────────────────────
|
||||
// ── Channels ──────────────────────────────
|
||||
|
||||
listChats(page = 1, perPage = 100) {
|
||||
return this._get(`/api/v1/chats?page=${page}&per_page=${perPage}`);
|
||||
listChannels(page = 1, perPage = 100, type = '') {
|
||||
let url = `/api/v1/channels?page=${page}&per_page=${perPage}`;
|
||||
if (type) url += `&type=${type}`;
|
||||
return this._get(url);
|
||||
},
|
||||
createChat(title, model, systemPrompt) {
|
||||
const body = { title };
|
||||
createChannel(title, model, systemPrompt, type = 'direct') {
|
||||
const body = { title, type };
|
||||
if (model) body.model = model;
|
||||
if (systemPrompt) body.system_prompt = systemPrompt;
|
||||
return this._post('/api/v1/chats', body);
|
||||
return this._post('/api/v1/channels', 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}`); },
|
||||
getChannel(id) { return this._get(`/api/v1/channels/${id}`); },
|
||||
updateChannel(id, updates) { return this._put(`/api/v1/channels/${id}`, updates); },
|
||||
deleteChannel(id) { return this._del(`/api/v1/channels/${id}`); },
|
||||
|
||||
// ── Messages ─────────────────────────────
|
||||
|
||||
listMessages(chatId, page = 1, perPage = 200) {
|
||||
return this._get(`/api/v1/chats/${chatId}/messages?page=${page}&per_page=${perPage}`);
|
||||
listMessages(channelId, page = 1, perPage = 200) {
|
||||
return this._get(`/api/v1/channels/${channelId}/messages?page=${page}&per_page=${perPage}`);
|
||||
},
|
||||
|
||||
// ── Completions ──────────────────────────
|
||||
|
||||
async streamCompletion(chatId, content, model, signal) {
|
||||
const body = { chat_id: chatId, content, stream: true };
|
||||
async streamCompletion(channelId, content, model, signal, apiConfigId) {
|
||||
const body = { channel_id: channelId, content, stream: true };
|
||||
if (model) body.model = model;
|
||||
if (apiConfigId) body.api_config_id = apiConfigId;
|
||||
// Only send max_tokens if user explicitly set it (non-zero = override)
|
||||
if (App.settings.maxTokens > 0) body.max_tokens = App.settings.maxTokens;
|
||||
|
||||
let resp = await fetch('/api/v1/chat/completions', {
|
||||
let resp = await fetch(BASE + '/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: this._authHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
@@ -130,7 +140,7 @@ const API = {
|
||||
|
||||
if (resp.status === 401 && this.refreshToken) {
|
||||
if (await this.refresh()) {
|
||||
resp = await fetch('/api/v1/chat/completions', {
|
||||
resp = await fetch(BASE + '/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: this._authHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
@@ -182,6 +192,7 @@ const API = {
|
||||
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'); },
|
||||
getPublicSettings() { return this._get('/api/v1/settings/public'); },
|
||||
adminUpdateSetting(key, value) { return this._put(`/api/v1/admin/settings/${key}`, value); },
|
||||
adminGetStats() { return this._get('/api/v1/admin/stats'); },
|
||||
|
||||
@@ -196,8 +207,12 @@ const API = {
|
||||
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); },
|
||||
adminBulkUpdateModels(isEnabled) { return this._put('/api/v1/admin/models/bulk', { is_enabled: isEnabled }); },
|
||||
adminDeleteModel(id) { return this._del(`/api/v1/admin/models/${id}`); },
|
||||
|
||||
// ── User Models ──────────────────────────
|
||||
listEnabledModels() { return this._get('/api/v1/models/enabled'); },
|
||||
|
||||
// ── HTTP Internals ───────────────────────
|
||||
|
||||
_authHeaders() {
|
||||
@@ -230,7 +245,7 @@ const API = {
|
||||
if (auth) opts.headers['Authorization'] = `Bearer ${this.accessToken}`;
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
|
||||
const resp = await fetch(path, opts);
|
||||
const resp = await fetch(BASE + path, opts);
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
const err = new Error(data.error || `HTTP ${resp.status}`);
|
||||
|
||||
269
src/js/app.js
269
src/js/app.js
@@ -1,5 +1,5 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – Application (v0.5.4)
|
||||
// Chat Switchboard – Application (v0.6.1)
|
||||
// ==========================================
|
||||
|
||||
const App = {
|
||||
@@ -8,6 +8,7 @@ const App = {
|
||||
models: [],
|
||||
isGenerating: false,
|
||||
abortController: null,
|
||||
serverSettings: {},
|
||||
|
||||
settings: {
|
||||
model: '',
|
||||
@@ -59,6 +60,7 @@ async function startApp() {
|
||||
await loadSettings();
|
||||
await loadChats();
|
||||
await fetchModels();
|
||||
await initBanners();
|
||||
UI.renderChatList();
|
||||
UI.updateModelSelector();
|
||||
UI.updateUser();
|
||||
@@ -67,7 +69,7 @@ async function startApp() {
|
||||
|
||||
// Connect EventBus WebSocket (non-blocking, graceful if /ws not available yet)
|
||||
try {
|
||||
Events.connect('/ws');
|
||||
Events.connect((window.__BASE__ || '') + '/ws');
|
||||
} catch (e) {
|
||||
console.warn('EventBus WebSocket not available:', e.message);
|
||||
}
|
||||
@@ -206,10 +208,11 @@ async function fetchModels() {
|
||||
|
||||
async function loadChats() {
|
||||
try {
|
||||
const resp = await API.listChats();
|
||||
const resp = await API.listChannels(1, 100, 'direct');
|
||||
App.chats = (resp.data || []).map(c => ({
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
type: c.type || 'direct',
|
||||
model: c.model || '',
|
||||
messageCount: c.message_count || 0,
|
||||
messages: [],
|
||||
@@ -255,7 +258,7 @@ async function newChat() {
|
||||
async function deleteChat(chatId) {
|
||||
if (!confirm('Delete this chat?')) return;
|
||||
try {
|
||||
await API.deleteChat(chatId);
|
||||
await API.deleteChannel(chatId);
|
||||
App.chats = App.chats.filter(c => c.id !== chatId);
|
||||
if (App.currentChatId === chatId) newChat();
|
||||
UI.renderChatList();
|
||||
@@ -277,8 +280,8 @@ async function sendMessage() {
|
||||
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 };
|
||||
const resp = await API.createChannel(title, model, App.settings.systemPrompt, 'direct');
|
||||
const chat = { id: resp.id, title: resp.title, type: 'direct', model, messages: [], messageCount: 0, updatedAt: resp.updated_at };
|
||||
App.chats.unshift(chat);
|
||||
App.currentChatId = chat.id;
|
||||
UI.renderChatList();
|
||||
@@ -295,8 +298,9 @@ async function sendMessage() {
|
||||
App.abortController = new AbortController();
|
||||
UI.setGenerating(true);
|
||||
|
||||
const modelInfo = App.models.find(m => m.id === model);
|
||||
try {
|
||||
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal);
|
||||
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId);
|
||||
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;
|
||||
@@ -340,12 +344,13 @@ async function regenerate() {
|
||||
if (lastUser.role !== 'user') return;
|
||||
|
||||
const model = document.getElementById('modelSelect').value || App.settings.model;
|
||||
const modelInfo = App.models.find(m => m.id === 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 resp = await API.streamCompletion(App.currentChatId, lastUser.content, model, App.abortController.signal, modelInfo?.configId);
|
||||
const content = await UI.streamResponse(resp, chat.messages);
|
||||
chat.messages.push({ role: 'assistant', content, model, timestamp: new Date().toISOString() });
|
||||
UI.renderMessages(chat.messages);
|
||||
@@ -396,7 +401,18 @@ async function handleRegister() {
|
||||
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 API.register(username, email, password); await startApp(); }
|
||||
try {
|
||||
const resp = await API.register(username, email, password);
|
||||
if (resp.pending) {
|
||||
setAuthError('');
|
||||
const errEl = document.getElementById('authError');
|
||||
errEl.textContent = 'Account created — pending admin approval. You will be able to sign in once approved.';
|
||||
errEl.style.color = 'var(--accent)';
|
||||
setTimeout(() => { errEl.style.color = ''; switchAuthTab('login'); }, 5000);
|
||||
} else {
|
||||
await startApp();
|
||||
}
|
||||
}
|
||||
catch (e) { setAuthError(e.message); }
|
||||
finally { setAuthLoading(false); }
|
||||
}
|
||||
@@ -418,6 +434,13 @@ function switchAuthTab(tab) {
|
||||
document.getElementById('authRegisterForm').style.display = tab === 'register' ? '' : 'none';
|
||||
document.getElementById('authLoginBtn').style.display = tab === 'login' ? '' : 'none';
|
||||
document.getElementById('authRegisterBtn').style.display = tab === 'register' ? '' : 'none';
|
||||
const hdr = document.querySelector('.auth-card-header');
|
||||
if (hdr) {
|
||||
hdr.querySelector('h2').textContent = tab === 'login' ? 'Welcome back' : 'Create account';
|
||||
hdr.querySelector('p').textContent = tab === 'login'
|
||||
? 'Sign in to continue to your workspace'
|
||||
: 'Get started with Chat Switchboard';
|
||||
}
|
||||
setAuthError('');
|
||||
}
|
||||
|
||||
@@ -440,6 +463,17 @@ function initListeners() {
|
||||
document.getElementById('sidebarToggle').addEventListener('click', UI.toggleSidebar);
|
||||
document.getElementById('newChatBtn').addEventListener('click', newChat);
|
||||
|
||||
// Split button dropdown
|
||||
document.getElementById('newChatDropBtn').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
document.getElementById('newChatDropdown').classList.toggle('open');
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.split-btn')) {
|
||||
document.getElementById('newChatDropdown').classList.remove('open');
|
||||
}
|
||||
});
|
||||
|
||||
// User flyout
|
||||
document.getElementById('userMenuBtn').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -451,8 +485,8 @@ function initListeners() {
|
||||
|
||||
// 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('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
|
||||
@@ -465,7 +499,7 @@ function initListeners() {
|
||||
if (this.value) { App.settings.model = this.value; saveSettings(); }
|
||||
UI.updateCapabilityBadges();
|
||||
});
|
||||
document.getElementById('fetchModelsBtn').addEventListener('click', async () => {
|
||||
document.getElementById('fetchModelsBtn')?.addEventListener('click', async () => {
|
||||
await fetchModels();
|
||||
UI.toast(`Loaded ${App.models.length} models`, 'success');
|
||||
});
|
||||
@@ -473,6 +507,9 @@ function initListeners() {
|
||||
// Settings modal
|
||||
document.getElementById('settingsCloseBtn').addEventListener('click', UI.closeSettings);
|
||||
document.getElementById('settingsSaveBtn').addEventListener('click', handleSaveSettings);
|
||||
document.querySelectorAll('.settings-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => UI.switchSettingsTab(tab.dataset.stab));
|
||||
});
|
||||
document.getElementById('settingsModel').addEventListener('change', function() {
|
||||
const model = App.models.find(m => m.id === this.value);
|
||||
const caps = model?.capabilities || lookupKnownCaps(this.value) || {};
|
||||
@@ -503,12 +540,55 @@ function initListeners() {
|
||||
}
|
||||
});
|
||||
|
||||
// Admin modal
|
||||
document.getElementById('adminCloseBtn').addEventListener('click', UI.closeAdmin);
|
||||
// Admin modal (elements may not exist for non-admin users)
|
||||
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);
|
||||
document.getElementById('adminSaveSettings')?.addEventListener('click', handleSaveAdminSettings);
|
||||
|
||||
// Admin — users
|
||||
document.getElementById('adminAddUserBtn')?.addEventListener('click', () => {
|
||||
const f = document.getElementById('adminAddUserForm');
|
||||
f.style.display = f.style.display === 'none' ? '' : 'none';
|
||||
});
|
||||
document.getElementById('adminCancelUserBtn')?.addEventListener('click', () => { document.getElementById('adminAddUserForm').style.display = 'none'; });
|
||||
document.getElementById('adminCreateUserBtn')?.addEventListener('click', createAdminUser);
|
||||
|
||||
// Admin — providers
|
||||
document.getElementById('adminAddProviderBtn')?.addEventListener('click', () => {
|
||||
const f = document.getElementById('adminAddProviderForm');
|
||||
f.style.display = f.style.display === 'none' ? '' : 'none';
|
||||
});
|
||||
document.getElementById('adminCancelProvBtn')?.addEventListener('click', () => { document.getElementById('adminAddProviderForm').style.display = 'none'; });
|
||||
document.getElementById('adminCreateProvBtn')?.addEventListener('click', createGlobalProvider);
|
||||
|
||||
// Admin — models
|
||||
document.getElementById('adminFetchModelsBtn')?.addEventListener('click', fetchAdminModels);
|
||||
|
||||
// Admin — banner controls
|
||||
document.getElementById('adminBannerEnabled')?.addEventListener('change', (e) => {
|
||||
document.getElementById('bannerConfigFields').style.display = e.target.checked ? '' : 'none';
|
||||
});
|
||||
document.getElementById('adminBannerPreset')?.addEventListener('change', (e) => {
|
||||
const preset = UI._bannerPresets[e.target.value];
|
||||
if (preset) {
|
||||
document.getElementById('adminBannerText').value = preset.text || '';
|
||||
document.getElementById('adminBannerBg').value = preset.bg || '#007a33';
|
||||
document.getElementById('adminBannerBgHex').value = preset.bg || '#007a33';
|
||||
document.getElementById('adminBannerFg').value = preset.fg || '#ffffff';
|
||||
document.getElementById('adminBannerFgHex').value = preset.fg || '#ffffff';
|
||||
UI.updateBannerPreview();
|
||||
}
|
||||
});
|
||||
['adminBannerText', 'adminBannerBg', 'adminBannerFg'].forEach(id => {
|
||||
document.getElementById(id)?.addEventListener('input', UI.updateBannerPreview);
|
||||
});
|
||||
// Sync color pickers with hex inputs
|
||||
document.getElementById('adminBannerBg')?.addEventListener('input', (e) => { document.getElementById('adminBannerBgHex').value = e.target.value; });
|
||||
document.getElementById('adminBannerFg')?.addEventListener('input', (e) => { document.getElementById('adminBannerFgHex').value = e.target.value; });
|
||||
document.getElementById('adminBannerBgHex')?.addEventListener('input', (e) => { if (/^#[0-9a-f]{6}$/i.test(e.target.value)) { document.getElementById('adminBannerBg').value = e.target.value; UI.updateBannerPreview(); }});
|
||||
document.getElementById('adminBannerFgHex')?.addEventListener('input', (e) => { if (/^#[0-9a-f]{6}$/i.test(e.target.value)) { document.getElementById('adminBannerFg').value = e.target.value; UI.updateBannerPreview(); }});
|
||||
|
||||
// Input
|
||||
const input = document.getElementById('messageInput');
|
||||
@@ -590,11 +670,168 @@ async function deleteProvider(id, name) {
|
||||
|
||||
async function handleSaveAdminSettings() {
|
||||
try {
|
||||
// Registration
|
||||
const reg = document.getElementById('adminRegToggle').checked;
|
||||
await API.adminUpdateSetting('registration_enabled', { value: reg });
|
||||
UI.toast('Admin settings saved', 'success');
|
||||
|
||||
// Registration default state
|
||||
const regState = document.getElementById('adminRegDefaultState').value;
|
||||
await API.adminUpdateSetting('registration_default_state', { value: regState });
|
||||
|
||||
// User providers
|
||||
const userProviders = document.getElementById('adminUserProvidersToggle').checked;
|
||||
await API.adminUpdateSetting('user_providers_enabled', { value: userProviders });
|
||||
|
||||
// Banner
|
||||
const banner = {
|
||||
enabled: document.getElementById('adminBannerEnabled').checked,
|
||||
text: document.getElementById('adminBannerText').value,
|
||||
position: document.getElementById('adminBannerPosition').value,
|
||||
bg: document.getElementById('adminBannerBg').value,
|
||||
fg: document.getElementById('adminBannerFg').value,
|
||||
};
|
||||
await API.adminUpdateSetting('banner', { value: banner });
|
||||
|
||||
UI.toast('Settings saved', 'success');
|
||||
// Live-apply banner
|
||||
initBanners();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Admin Actions ────────────────────────────
|
||||
|
||||
function _adminScroll() { return document.querySelector('#adminModal .modal-body'); }
|
||||
function _restoreScroll(el, pos) { if (el) requestAnimationFrame(() => { el.scrollTop = pos; }); }
|
||||
async function toggleUserActive(id, active) {
|
||||
try {
|
||||
const el = _adminScroll(), pos = el?.scrollTop || 0;
|
||||
await API.adminToggleActive(id, active); UI.toast(`User ${active ? 'enabled' : 'disabled'}`, 'success'); await UI.loadAdminUsers();
|
||||
_restoreScroll(el, pos);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
async function toggleUserRole(id, role) {
|
||||
try {
|
||||
const el = _adminScroll(), pos = el?.scrollTop || 0;
|
||||
await API.adminUpdateRole(id, role); UI.toast(`Role updated to ${role}`, 'success'); await UI.loadAdminUsers();
|
||||
_restoreScroll(el, pos);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
async function deleteUser(id, username) {
|
||||
if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return;
|
||||
try { await API.adminDeleteUser(id); UI.toast('User deleted', 'success'); await UI.loadAdminUsers(); }
|
||||
catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
async function createAdminUser() {
|
||||
try {
|
||||
const u = document.getElementById('adminNewUsername').value.trim();
|
||||
const e = document.getElementById('adminNewEmail').value.trim();
|
||||
const p = document.getElementById('adminNewPassword').value;
|
||||
const r = document.getElementById('adminNewRole').value;
|
||||
if (!u || !e || !p) { UI.toast('All fields required', 'warning'); return; }
|
||||
await API.adminCreateUser(u, e, p, r);
|
||||
document.getElementById('adminAddUserForm').style.display = 'none';
|
||||
UI.toast('User created', 'success');
|
||||
await UI.loadAdminUsers();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteGlobalProvider(id) {
|
||||
if (!confirm('Delete this global provider?')) return;
|
||||
try { await API.adminDeleteGlobalConfig(id); UI.toast('Provider deleted', 'success'); await UI.loadAdminProviders(); }
|
||||
catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
async function createGlobalProvider() {
|
||||
try {
|
||||
const name = document.getElementById('adminProvName').value.trim();
|
||||
const prov = document.getElementById('adminProvType').value;
|
||||
const ep = document.getElementById('adminProvEndpoint').value.trim();
|
||||
const key = document.getElementById('adminProvKey').value;
|
||||
const model = document.getElementById('adminProvModel').value.trim();
|
||||
if (!name || !ep || !key) { UI.toast('Name, endpoint, and API key required', 'warning'); return; }
|
||||
await API.adminCreateGlobalConfig(name, prov, ep, key, model);
|
||||
document.getElementById('adminAddProviderForm').style.display = 'none';
|
||||
UI.toast('Provider added', 'success');
|
||||
await UI.loadAdminProviders();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function fetchAdminModels() {
|
||||
const hint = document.getElementById('adminModelsHint');
|
||||
hint.textContent = 'Fetching...';
|
||||
try { await API.adminFetchModels(); UI.toast('Models synced', 'success'); hint.textContent = ''; await UI.loadAdminModels(); }
|
||||
catch (e) { UI.toast(e.message, 'error'); hint.textContent = 'Failed'; }
|
||||
}
|
||||
async function toggleModel(id, enabled) {
|
||||
try {
|
||||
const el = _adminScroll(), pos = el?.scrollTop || 0;
|
||||
await API.adminUpdateModel(id, { is_enabled: enabled });
|
||||
await UI.loadAdminModels();
|
||||
_restoreScroll(el, pos);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function bulkToggleModels(enabled) {
|
||||
const hint = document.getElementById('adminModelsHint');
|
||||
hint.textContent = enabled ? 'Enabling all...' : 'Disabling all...';
|
||||
try {
|
||||
const resp = await API.adminBulkUpdateModels(enabled);
|
||||
hint.textContent = `${resp.count || 'All'} models ${enabled ? 'enabled' : 'disabled'}`;
|
||||
setTimeout(() => { hint.textContent = ''; }, 3000);
|
||||
await UI.loadAdminModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); hint.textContent = 'Failed'; }
|
||||
}
|
||||
|
||||
// ── Banners ──────────────────────────────────
|
||||
|
||||
async function initBanners() {
|
||||
try {
|
||||
const data = await API.getPublicSettings?.() || [];
|
||||
const settings = data.settings || data || [];
|
||||
const arr = Array.isArray(settings) ? settings : [];
|
||||
|
||||
// Store globally for user-facing checks — unwrap {value: X} wrapper
|
||||
App.serverSettings = {};
|
||||
arr.forEach(s => {
|
||||
const v = s.value;
|
||||
App.serverSettings[s.key] = (v && typeof v === 'object' && 'value' in v) ? v.value : v;
|
||||
});
|
||||
|
||||
const root = document.documentElement;
|
||||
const banner = App.serverSettings.banner;
|
||||
|
||||
// Clear previous banner state
|
||||
['bannerTop', 'bannerBottom'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) { el.classList.remove('active'); el.textContent = ''; }
|
||||
});
|
||||
root.style.setProperty('--banner-top-height', '0px');
|
||||
root.style.setProperty('--banner-bottom-height', '0px');
|
||||
|
||||
if (!banner || !banner.enabled) return;
|
||||
|
||||
root.style.setProperty('--banner-bg', banner.bg || '#007a33');
|
||||
root.style.setProperty('--banner-fg', banner.fg || '#ffffff');
|
||||
|
||||
const text = banner.text || '';
|
||||
const pos = banner.position || 'both';
|
||||
|
||||
if (pos === 'top' || pos === 'both') {
|
||||
const el = document.getElementById('bannerTop');
|
||||
el.textContent = text;
|
||||
el.classList.add('active');
|
||||
root.style.setProperty('--banner-top-height', '22px');
|
||||
}
|
||||
if (pos === 'bottom' || pos === 'both') {
|
||||
const el = document.getElementById('bannerBottom');
|
||||
el.textContent = text;
|
||||
el.classList.add('active');
|
||||
root.style.setProperty('--banner-bottom-height', '22px');
|
||||
}
|
||||
} catch (e) {
|
||||
// Banners are optional — non-admin users may not have access to settings
|
||||
console.debug('Banner init skipped:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Boot ─────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
@@ -191,7 +191,7 @@ const Events = {
|
||||
}
|
||||
|
||||
// Add auth token as query param (WebSocket doesn't support headers)
|
||||
const tokens = JSON.parse(localStorage.getItem('sb_auth') || '{}');
|
||||
const tokens = JSON.parse(localStorage.getItem(typeof _storageKey !== 'undefined' ? _storageKey : 'sb_auth') || '{}');
|
||||
if (tokens.access) {
|
||||
url += (url.includes('?') ? '&' : '?') + `token=${encodeURIComponent(tokens.access)}`;
|
||||
} else {
|
||||
|
||||
189
src/js/ui.js
189
src/js/ui.js
@@ -1,5 +1,5 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – UI (v0.5.4)
|
||||
// Chat Switchboard – UI (v0.6.1)
|
||||
// ==========================================
|
||||
|
||||
const UI = {
|
||||
@@ -337,11 +337,33 @@ const UI = {
|
||||
}
|
||||
|
||||
UI.loadProfileIntoSettings();
|
||||
UI.loadProviderList();
|
||||
UI.switchSettingsTab('general');
|
||||
document.getElementById('settingsModal').classList.add('active');
|
||||
},
|
||||
closeSettings() { document.getElementById('settingsModal').classList.remove('active'); },
|
||||
|
||||
switchSettingsTab(tab) {
|
||||
document.querySelectorAll('.settings-tab').forEach(t => t.classList.toggle('active', t.dataset.stab === tab));
|
||||
document.querySelectorAll('.settings-tab-content').forEach(c => c.style.display = 'none');
|
||||
const panel = document.getElementById(`settings${tab.charAt(0).toUpperCase() + tab.slice(1)}Tab`);
|
||||
if (panel) panel.style.display = '';
|
||||
|
||||
if (tab === 'providers') {
|
||||
UI.loadProviderList();
|
||||
UI.checkUserProvidersAllowed();
|
||||
}
|
||||
if (tab === 'models') UI.loadUserModels();
|
||||
},
|
||||
|
||||
async checkUserProvidersAllowed() {
|
||||
const notice = document.getElementById('userProvidersDisabled');
|
||||
const addBtn = document.getElementById('providerShowAddBtn');
|
||||
if (!notice) return;
|
||||
const allowed = App.serverSettings?.user_providers_enabled !== false;
|
||||
notice.style.display = allowed ? 'none' : '';
|
||||
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
|
||||
},
|
||||
|
||||
async loadProfileIntoSettings() {
|
||||
try {
|
||||
const p = await API.getProfile();
|
||||
@@ -398,64 +420,173 @@ const UI = {
|
||||
if (tab === 'stats') await this.loadAdminStats();
|
||||
if (tab === 'providers') await this.loadAdminProviders();
|
||||
if (tab === 'models') await this.loadAdminModels();
|
||||
if (tab === 'settings') await this.loadAdminSettings();
|
||||
},
|
||||
|
||||
async loadAdminUsers() {
|
||||
async loadAdminUsers(quiet) {
|
||||
const el = document.getElementById('adminUserList');
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
try {
|
||||
const resp = await API.adminListUsers();
|
||||
const users = resp.data || [];
|
||||
el.innerHTML = users.map(u => `
|
||||
<div class="admin-user-row">
|
||||
<div><strong>${esc(u.username)}</strong> <span class="badge-${u.role}">${u.role}</span>
|
||||
${!u.is_active ? '<span class="badge-inactive">disabled</span>' : ''}</div>
|
||||
<div class="admin-user-email">${esc(u.email)}</div>
|
||||
<div class="admin-user-row${!u.is_active ? ' user-inactive' : ''}">
|
||||
<div class="admin-user-info">
|
||||
<div><strong>${esc(u.username)}</strong> <span class="badge-${u.role}">${u.role}</span>
|
||||
${!u.is_active ? '<span class="badge-pending">pending</span>' : ''}</div>
|
||||
<div class="admin-user-email">${esc(u.email)}</div>
|
||||
</div>
|
||||
<div class="admin-user-actions">
|
||||
<button onclick="toggleUserActive('${u.id}', ${!u.is_active})">${u.is_active ? 'Disable' : 'Approve'}</button>
|
||||
<button onclick="toggleUserRole('${u.id}', '${u.role === 'admin' ? 'user' : 'admin'}')">${u.role === 'admin' ? 'Demote' : 'Promote'}</button>
|
||||
<button class="btn-danger" onclick="deleteUser('${u.id}', '${esc(u.username)}')">Delete</button>
|
||||
</div>
|
||||
</div>`).join('') || '<div class="empty-hint">No users</div>';
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async loadAdminStats() {
|
||||
const el = document.getElementById('adminStats');
|
||||
try { const s = await API.adminGetStats(); el.innerHTML = `<pre>${esc(JSON.stringify(s, null, 2))}</pre>`; }
|
||||
catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async loadAdminProviders() {
|
||||
const el = document.getElementById('adminProviderList');
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
try {
|
||||
const data = await API.adminListGlobalConfigs();
|
||||
const list = data.configs || data.data || data || [];
|
||||
el.innerHTML = (Array.isArray(list) ? list : []).map(c => `
|
||||
<div class="provider-row"><span class="provider-name">${esc(c.name)}</span><span class="provider-meta">${esc(c.provider)}</span></div>
|
||||
`).join('') || '<div class="empty-hint">No global providers</div>';
|
||||
const s = await API.adminGetStats();
|
||||
const labels = { total_users: 'Users', active_users: 'Active Users', api_configs: 'Providers', total_channels: 'Channels', total_messages: 'Messages' };
|
||||
el.innerHTML = '<div class="stats-grid">' +
|
||||
Object.entries(s).map(([k, v]) => `
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${typeof v === 'number' ? v.toLocaleString() : esc(String(v))}</div>
|
||||
<div class="stat-label">${labels[k] || k.replace(/_/g, ' ')}</div>
|
||||
</div>`).join('') +
|
||||
'</div>';
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async loadAdminModels() {
|
||||
async loadAdminProviders(quiet) {
|
||||
const el = document.getElementById('adminProviderList');
|
||||
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
try {
|
||||
const data = await API.adminListGlobalConfigs();
|
||||
const list = data.configs || data.data || data || [];
|
||||
const arr = Array.isArray(list) ? list : [];
|
||||
el.innerHTML = arr.map(c => `
|
||||
<div class="admin-provider-row">
|
||||
<span class="provider-name">${esc(c.name)}</span>
|
||||
<span class="provider-meta">${esc(c.provider)}</span>
|
||||
<span class="provider-endpoint">${esc(c.endpoint || '')}</span>
|
||||
<button class="btn-delete" onclick="deleteGlobalProvider('${c.id}')" title="Delete">✕</button>
|
||||
</div>`).join('') || '<div class="empty-hint">No global providers — add one above</div>';
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async loadAdminModels(quiet) {
|
||||
const el = document.getElementById('adminModelList');
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
if (!quiet) 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 => {
|
||||
const arr = Array.isArray(list) ? list : [];
|
||||
el.innerHTML = arr.map(m => {
|
||||
const caps = m.capabilities || {};
|
||||
const badges = [];
|
||||
if (caps.max_output_tokens > 0) {
|
||||
badges.push(`<span class="cap-badge cap-context">${(caps.max_output_tokens/1000).toFixed(0)}K out</span>`);
|
||||
}
|
||||
if (caps.max_output_tokens > 0) badges.push(`<span class="cap-badge cap-context">${(caps.max_output_tokens/1000).toFixed(0)}K</span>`);
|
||||
if (caps.tool_calling) badges.push('<span class="cap-badge cap-accent">🔧</span>');
|
||||
if (caps.vision) badges.push('<span class="cap-badge cap-accent">👁</span>');
|
||||
if (caps.thinking) badges.push('<span class="cap-badge cap-accent">💭</span>');
|
||||
if (caps.reasoning) badges.push('<span class="cap-badge cap-accent">🧠</span>');
|
||||
return `<div class="admin-model-row">
|
||||
<span>${esc(m.model_id || m.id)}</span>
|
||||
<span class="model-name">${esc(m.model_id || m.id)}</span>
|
||||
<span class="model-caps-inline">${badges.join('')}</span>
|
||||
<span class="provider-meta">${esc(m.provider_name || '')}</span>
|
||||
<span>${m.is_enabled ? '✅' : '⬜'}</span>
|
||||
<button class="admin-model-toggle ${m.is_enabled ? 'enabled' : ''}" onclick="toggleModel('${m.id}', ${!m.is_enabled})">${m.is_enabled ? '✓ Enabled' : 'Disabled'}</button>
|
||||
</div>`;
|
||||
}).join('') || '<div class="empty-hint">No models — use Fetch Models to sync from providers</div>';
|
||||
}).join('') || '<div class="empty-hint">No models — add a provider first, then click Fetch Models</div>';
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async loadAdminSettings() {
|
||||
try {
|
||||
const data = await API.adminGetSettings();
|
||||
const settings = data.settings || data || [];
|
||||
const arr = Array.isArray(settings) ? settings : [];
|
||||
|
||||
// Unwrap {value: X} wrapper from backend storage
|
||||
const get = (key, fallback) => {
|
||||
const s = arr.find(s => s.key === key);
|
||||
if (!s) return fallback;
|
||||
const v = s.value;
|
||||
return (v && typeof v === 'object' && 'value' in v) ? v.value : v;
|
||||
};
|
||||
|
||||
// Registration
|
||||
document.getElementById('adminRegToggle').checked = get('registration_enabled', true) !== false;
|
||||
|
||||
// Registration default state
|
||||
document.getElementById('adminRegDefaultState').value = get('registration_default_state', 'active') || 'active';
|
||||
|
||||
// User providers
|
||||
document.getElementById('adminUserProvidersToggle').checked = get('user_providers_enabled', true) !== false;
|
||||
|
||||
// Banner
|
||||
const banner = get('banner', {}) || {};
|
||||
document.getElementById('adminBannerEnabled').checked = !!banner.enabled;
|
||||
document.getElementById('adminBannerText').value = banner.text || '';
|
||||
document.getElementById('adminBannerPosition').value = banner.position || 'both';
|
||||
document.getElementById('adminBannerBg').value = banner.bg || '#007a33';
|
||||
document.getElementById('adminBannerBgHex').value = banner.bg || '#007a33';
|
||||
document.getElementById('adminBannerFg').value = banner.fg || '#ffffff';
|
||||
document.getElementById('adminBannerFgHex').value = banner.fg || '#ffffff';
|
||||
document.getElementById('bannerConfigFields').style.display = banner.enabled ? '' : 'none';
|
||||
UI.updateBannerPreview();
|
||||
|
||||
// Load presets
|
||||
const presets = get('banner_presets', {}) || {};
|
||||
const sel = document.getElementById('adminBannerPreset');
|
||||
sel.innerHTML = '<option value="">Custom</option>';
|
||||
Object.entries(presets).forEach(([key, p]) => {
|
||||
sel.innerHTML += `<option value="${esc(key)}">${esc(p.text || key)}</option>`;
|
||||
});
|
||||
UI._bannerPresets = presets;
|
||||
} catch (e) { console.debug('Failed to load admin settings:', e); }
|
||||
},
|
||||
|
||||
_bannerPresets: {},
|
||||
|
||||
updateBannerPreview() {
|
||||
const prev = document.getElementById('bannerPreview');
|
||||
if (!prev) return;
|
||||
const text = document.getElementById('adminBannerText').value || 'PREVIEW';
|
||||
const bg = document.getElementById('adminBannerBg').value;
|
||||
const fg = document.getElementById('adminBannerFg').value;
|
||||
prev.textContent = text;
|
||||
prev.style.background = bg;
|
||||
prev.style.color = fg;
|
||||
},
|
||||
|
||||
// ── User Model List ─────────────────────
|
||||
|
||||
async loadUserModels() {
|
||||
const el = document.getElementById('userModelList');
|
||||
if (!el) return;
|
||||
el.innerHTML = '<div class="loading">Loading models...</div>';
|
||||
try {
|
||||
const data = await API.listEnabledModels();
|
||||
const models = data.models || [];
|
||||
if (!models.length) {
|
||||
el.innerHTML = '<div class="empty-hint">No models available — add a provider in the section above</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = models.map(m => {
|
||||
const caps = m.capabilities || {};
|
||||
const badges = [];
|
||||
if (caps.max_output_tokens > 0) badges.push(`<span class="cap-badge cap-context">${(caps.max_output_tokens/1000).toFixed(0)}K</span>`);
|
||||
if (caps.tool_calling) badges.push('<span class="cap-badge cap-accent">🔧</span>');
|
||||
if (caps.vision) badges.push('<span class="cap-badge cap-accent">👁</span>');
|
||||
if (caps.thinking) badges.push('<span class="cap-badge cap-accent">💭</span>');
|
||||
return `<div class="model-list-item">
|
||||
<span class="model-name">${esc(m.model_id || m.id)}</span>
|
||||
<span class="model-caps-inline">${badges.join('')}</span>
|
||||
<span class="model-provider">${esc(m.provider_name || m.provider || '')}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user