Upload 7 modified files from chat-switchboard-v0.5.2.zip

This commit is contained in:
2026-02-18 22:14:35 +00:00
parent a6b55a0352
commit 92f4f87b6a
5 changed files with 750 additions and 788 deletions

View File

@@ -1 +1 @@
0.5.1 0.5.2

View File

@@ -1,398 +1,529 @@
/* ========================================== /* ==========================================
Chat Switchboard Styles (v0.5.0) Chat Switchboard v0.5.2
Clean utility layout · Open WebUI inspired
========================================== */ ========================================== */
:root { :root {
--bg: #0f0f13; --bg: #0e0e10;
--bg-secondary: #1a1a24; --bg-surface: #18181b;
--bg-tertiary: #252533; --bg-raised: #222227;
--border: #2a2a3a; --bg-hover: #2a2a30;
--text: #e4e4ef; --border: #2e2e35;
--text-secondary: #8888a0; --border-light: #3a3a42;
--accent: #6c9fff; --text: #e8e8ed;
--accent-hover: #8ab4ff; --text-2: #9898a8;
--danger: #e74c3c; --text-3: #6b6b7b;
--success: #2ecc71; --accent: #6c9fff;
--warning: #f39c12; --accent-hover: #84b0ff;
--radius: 8px; --accent-dim: rgba(108,159,255,0.12);
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; --danger: #ef4444;
--mono: 'SF Mono', 'Fira Code', Consolas, monospace; --success: #22c55e;
--warning: #eab308;
--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;
--transition: 180ms ease;
} }
* { margin: 0; padding: 0; box-sizing: border-box; } *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: var(--font); background: var(--bg); color: var(--text); height: 100vh; overflow: hidden; } html, body { height: 100%; }
a { color: var(--accent); } body { font-family: var(--font); background: var(--bg); color: var(--text); overflow: hidden; font-size: 14px; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
::selection { background: var(--accent-dim); }
/* ── Layout ──────────────────────────────── */ /* ── App Shell ───────────────────────────── */
#appContainer { display: flex; flex-direction: column; height: 100vh; } .app { display: flex; height: 100vh; }
.header {
display: flex; align-items: center; justify-content: space-between;
padding: 0 1rem; height: 48px; background: var(--bg-secondary);
border-bottom: 1px solid var(--border); flex-shrink: 0;
}
.header-title { font-size: 1rem; font-weight: 600; }
.header-actions { display: flex; align-items: center; gap: 0.5rem; }
.main { display: flex; flex: 1; overflow: hidden; }
/* ── Sidebar ─────────────────────────────── */ /* ── Sidebar ─────────────────────────────── */
.sidebar { .sidebar {
width: 260px; background: var(--bg-secondary); border-right: 1px solid var(--border); width: var(--sidebar-w); background: var(--bg-surface);
display: flex; flex-direction: column; padding: 0.75rem; gap: 0.5rem; flex-shrink: 0; border-right: 1px solid var(--border);
overflow-y: auto; display: flex; flex-direction: column;
transition: width var(--transition);
overflow: hidden; flex-shrink: 0;
} }
.new-chat-btn { width: 100%; } .sidebar.collapsed { width: var(--sidebar-rail); }
.chat-history { flex: 1; overflow-y: auto; }
.sidebar-top {
display: flex; flex-direction: column; gap: 2px;
padding: 10px 10px 8px;
border-bottom: 1px solid var(--border);
}
.sb-btn {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: 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);
width: 100%;
}
.sb-btn:hover { background: var(--bg-hover); color: var(--text); }
.sb-btn svg { flex-shrink: 0; }
.sb-label { overflow: hidden; transition: opacity var(--transition); }
.sidebar.collapsed .sb-label { opacity: 0; width: 0; }
/* Chat 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; }
.chat-group-label {
font-size: 11px; font-weight: 600; color: var(--text-3);
padding: 12px 10px 4px; text-transform: uppercase; letter-spacing: 0.5px;
white-space: nowrap; overflow: hidden;
}
.sidebar.collapsed .chat-group-label { display: none; }
.chat-item { .chat-item {
display: flex; align-items: center; padding: 0.5rem 0.75rem; border-radius: var(--radius); display: flex; align-items: center; gap: 8px;
cursor: pointer; font-size: 0.85rem; gap: 0.5rem; transition: background 0.15s; padding: 7px 10px; border-radius: var(--radius);
cursor: pointer; font-size: 13px; color: var(--text-2);
transition: background var(--transition); position: relative;
overflow: hidden;
} }
.chat-item:hover { background: var(--bg-tertiary); } .chat-item:hover { background: var(--bg-hover); color: var(--text); }
.chat-item.active { background: var(--bg-tertiary); border-left: 2px solid var(--accent); } .chat-item.active { background: var(--bg-raised); color: var(--text); }
.chat-item-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .chat-item-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chat-item-time {
font-size: 10px; color: var(--text-3); flex-shrink: 0;
white-space: nowrap;
}
.chat-item-delete { .chat-item-delete {
opacity: 0; background: none; border: none; color: var(--text-secondary); opacity: 0; position: absolute; right: 6px; background: var(--bg-raised);
cursor: pointer; font-size: 0.75rem; padding: 2px 4px; transition: opacity 0.15s; border: none; color: var(--text-3); cursor: pointer; font-size: 11px;
padding: 2px 6px; border-radius: 4px; transition: opacity var(--transition);
} }
.chat-item:hover .chat-item-delete { opacity: 1; } .chat-item:hover .chat-item-delete { opacity: 1; }
.chat-item:hover .chat-item-time { display: none; }
.sidebar.collapsed .chat-item-title,
.sidebar.collapsed .chat-item-time,
.sidebar.collapsed .chat-item-delete { display: none; }
.sidebar-empty {
color: var(--text-3); font-size: 12px; text-align: center; padding: 2rem 0.5rem;
}
.sidebar.collapsed .sidebar-empty { display: none; }
/* User area */
.sidebar-bottom {
border-top: 1px solid var(--border);
padding: 8px 10px; position: relative;
}
.user-btn {
display: flex; align-items: center; gap: 10px;
padding: 6px 8px; border-radius: var(--radius);
background: none; border: none; color: var(--text);
cursor: pointer; width: 100%;
transition: background var(--transition);
}
.user-btn:hover { background: var(--bg-hover); }
.user-avatar {
width: 30px; height: 30px; border-radius: 50%;
background: var(--bg-raised); display: flex; align-items: center;
justify-content: center; font-size: 13px; font-weight: 600;
color: var(--accent); flex-shrink: 0; position: relative;
}
.avatar-dot {
position: absolute; bottom: -1px; right: -1px;
width: 9px; height: 9px; border-radius: 50%;
background: var(--success); border: 2px solid var(--bg-surface);
}
.user-name { font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Flyout */
.user-flyout {
display: none; position: absolute; bottom: 100%; left: 8px; right: 8px;
background: var(--bg-raised); border: 1px solid var(--border-light);
border-radius: var(--radius-lg); padding: 4px; margin-bottom: 4px;
box-shadow: 0 -4px 24px rgba(0,0,0,0.5); z-index: 200;
}
.user-flyout.open { display: block; }
.flyout-item {
display: flex; align-items: center; gap: 10px;
padding: 8px 12px; border-radius: var(--radius);
background: none; border: none; color: var(--text-2);
cursor: pointer; font-size: 13px; width: 100%;
transition: background var(--transition), color var(--transition);
}
.flyout-item:hover { background: var(--bg-hover); color: var(--text); }
.flyout-item svg { flex-shrink: 0; }
.flyout-danger:hover { color: var(--danger); }
.flyout-divider { height: 1px; background: var(--border); margin: 4px 8px; }
/* ── Chat Area ───────────────────────────── */ /* ── Chat Area ───────────────────────────── */
.chat-area { flex: 1; display: flex; flex-direction: column; min-width: 0; overflow: hidden; } .chat-area { flex: 1; display: flex; flex-direction: column; min-width: 0; background: var(--bg); }
.chat-messages { flex: 1; overflow-y: auto; padding: 1rem; }
.message { margin-bottom: 1rem; } .model-bar {
.message-inner { display: flex; gap: 0.75rem; max-width: 800px; margin: 0 auto; } display: flex; align-items: center; gap: 6px;
.message-avatar { font-size: 1.25rem; flex-shrink: 0; } padding: 8px 16px; flex-shrink: 0;
.message-body { flex: 1; min-width: 0; }
.message-head { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.25rem; }
.message-role { font-weight: 600; font-size: 0.85rem; }
.message-time { font-size: 0.7rem; color: var(--text-secondary); }
.message-copy {
background: none; border: none; cursor: pointer; font-size: 0.75rem;
opacity: 0; transition: opacity 0.15s; margin-left: auto;
} }
.message:hover .message-copy { opacity: 0.7; } .model-bar select {
.message-text { font-size: 0.9rem; line-height: 1.6; word-wrap: break-word; } background: transparent; border: 1px solid transparent;
color: var(--text); font-size: 14px; font-weight: 500;
/* ── Markdown Rendered Content ────────────── */ padding: 4px 8px; border-radius: var(--radius); cursor: pointer;
font-family: var(--font); max-width: 300px;
.message-text p { margin: 0.4em 0; } transition: border-color var(--transition);
.message-text p:first-child { margin-top: 0; }
.message-text p:last-child { margin-bottom: 0; }
.message-text pre {
background: var(--bg-tertiary); padding: 0.75rem; border-radius: var(--radius);
overflow-x: auto; margin: 0.5rem 0; position: relative; font-size: 0.8rem;
} }
.message-text code { font-family: var(--mono); font-size: 0.85em; background: var(--bg-tertiary); padding: 0.15em 0.35em; border-radius: 3px; } .model-bar select:hover { border-color: var(--border); }
.message-text pre code { font-size: inherit; background: none; padding: 0; } .model-bar select:focus { outline: none; border-color: var(--accent); }
.model-bar select option { background: var(--bg-surface); color: var(--text); }
.icon-btn {
background: none; border: none; color: var(--text-3); cursor: pointer;
padding: 4px; border-radius: 4px;
transition: color var(--transition);
}
.icon-btn:hover { color: var(--text); }
/* 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); }
.msg-inner { max-width: 768px; margin: 0 auto; padding: 0 1.5rem; display: flex; gap: 1rem; }
.msg-avatar {
width: 28px; height: 28px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 14px; flex-shrink: 0; margin-top: 2px;
}
.message.user .msg-avatar { background: var(--accent-dim); }
.message.assistant .msg-avatar { background: var(--bg-raised); }
.msg-body { flex: 1; min-width: 0; }
.msg-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.msg-role { font-size: 13px; font-weight: 600; }
.msg-time { font-size: 11px; color: var(--text-3); }
.msg-actions {
margin-left: auto; display: flex; gap: 2px; opacity: 0;
transition: opacity var(--transition);
}
.message:hover .msg-actions { opacity: 1; }
.msg-action-btn {
background: none; border: none; color: var(--text-3); cursor: pointer;
padding: 2px 6px; border-radius: 4px; font-size: 11px;
transition: background var(--transition), color var(--transition);
}
.msg-action-btn:hover { background: var(--bg-hover); color: var(--text); }
.msg-text { font-size: 14px; line-height: 1.7; }
/* ── Markdown Content ────────────────────── */
.msg-text p { margin: 0.4em 0; }
.msg-text p:first-child { margin-top: 0; }
.msg-text p:last-child { margin-bottom: 0; }
.msg-text pre {
background: var(--bg-surface); border: 1px solid var(--border);
padding: 0.75rem 1rem; border-radius: var(--radius);
overflow-x: auto; margin: 0.75rem 0; position: relative;
font-size: 13px;
}
.msg-text code {
font-family: var(--mono); font-size: 0.9em;
background: var(--bg-raised); padding: 0.15em 0.4em;
border-radius: 4px;
}
.msg-text pre code { background: none; padding: 0; font-size: inherit; }
.copy-code-btn { .copy-code-btn {
position: absolute; top: 4px; right: 4px; background: var(--bg-secondary); position: absolute; top: 6px; right: 6px; background: var(--bg-raised);
border: 1px solid var(--border); color: var(--text-secondary); border-radius: 4px; border: 1px solid var(--border); color: var(--text-3); border-radius: 4px;
padding: 2px 8px; font-size: 0.7rem; cursor: pointer; opacity: 0; transition: opacity 0.15s; padding: 2px 10px; font-size: 11px; cursor: pointer;
opacity: 0; transition: opacity var(--transition);
} }
.message-text pre:hover .copy-code-btn { opacity: 1; } .msg-text pre:hover .copy-code-btn { opacity: 1; }
.copy-code-btn:hover { background: var(--border); color: var(--text); } .copy-code-btn:hover { color: var(--text); border-color: var(--border-light); }
.message-text ul, .message-text ol { margin: 0.4em 0; padding-left: 1.5em; } .msg-text ul, .msg-text ol { margin: 0.4em 0; padding-left: 1.5em; }
.message-text li { margin: 0.2em 0; } .msg-text li { margin: 0.2em 0; }
.message-text li > p { margin: 0.2em 0; } .msg-text li > p { margin: 0.2em 0; }
.message-text blockquote { .msg-text blockquote {
border-left: 3px solid var(--accent); padding: 0.25em 0.75em; margin: 0.4em 0; border-left: 3px solid var(--accent); padding: 0.25em 0.75em; margin: 0.5em 0;
color: var(--text-secondary); background: var(--bg-tertiary); border-radius: 0 4px 4px 0; color: var(--text-2); background: var(--accent-dim); border-radius: 0 var(--radius) var(--radius) 0;
} }
.message-text a { color: var(--accent); text-decoration: none; } .msg-text hr { border: none; border-top: 1px solid var(--border); margin: 0.75em 0; }
.message-text a:hover { text-decoration: underline; }
.message-text hr { border: none; border-top: 1px solid var(--border); margin: 0.75em 0; } .msg-text table { border-collapse: collapse; width: 100%; margin: 0.5em 0; font-size: 13px; }
.msg-text th, .msg-text td { border: 1px solid var(--border); padding: 0.4em 0.7em; text-align: left; }
.msg-text th { background: var(--bg-raised); font-weight: 600; }
.message-text table { border-collapse: collapse; width: 100%; margin: 0.5em 0; font-size: 0.95em; } .msg-text h1, .msg-text h2, .msg-text h3, .msg-text h4 { margin: 0.75em 0 0.3em; font-weight: 600; }
.message-text th, .message-text td { border: 1px solid var(--border); padding: 0.35em 0.6em; text-align: left; } .msg-text h1 { font-size: 1.4em; }
.message-text th { background: var(--bg-tertiary); font-weight: 600; } .msg-text h2 { font-size: 1.2em; }
.msg-text h3 { font-size: 1.05em; }
.message-text img { max-width: 100%; border-radius: 4px; } .msg-text a { color: var(--accent); }
.msg-text img { max-width: 100%; border-radius: var(--radius); }
.message-text h1, .message-text h2, .message-text h3,
.message-text h4, .message-text h5, .message-text h6 {
margin: 0.75em 0 0.35em; font-weight: 600;
}
.message-text h1 { font-size: 1.3em; }
.message-text h2 { font-size: 1.15em; }
.message-text h3 { font-size: 1.05em; }
/* ── Thinking Blocks ─────────────────────── */
/* Thinking blocks */
.thinking-block { .thinking-block {
border: 1px solid rgba(108, 159, 255, 0.15); border-radius: var(--radius); border: 1px solid rgba(108,159,255,0.12); border-radius: var(--radius);
margin: 0.5rem 0; background: rgba(108, 159, 255, 0.04); margin: 0.5rem 0; background: rgba(108,159,255,0.03);
} }
.thinking-block summary { .thinking-block summary {
padding: 0.4rem 0.75rem; cursor: pointer; font-size: 0.8rem; padding: 0.4rem 0.75rem; cursor: pointer; font-size: 12px;
color: var(--text-secondary); user-select: none; color: var(--text-3); user-select: none;
transition: color var(--transition);
} }
.thinking-block summary:hover { color: var(--text); } .thinking-block summary:hover { color: var(--text-2); }
.thinking-block[open] summary { border-bottom: 1px solid rgba(108, 159, 255, 0.1); } .thinking-block[open] summary { border-bottom: 1px solid rgba(108,159,255,0.08); }
.thinking-content { .thinking-content {
padding: 0.5rem 0.75rem; font-size: 0.8rem; color: var(--text-secondary); padding: 0.5rem 0.75rem; font-size: 12px; color: var(--text-3);
max-height: 300px; overflow-y: auto; line-height: 1.5; max-height: 300px; overflow-y: auto; line-height: 1.6;
background: rgba(108, 159, 255, 0.02);
} }
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: var(--text-secondary); } /* Empty state */
.empty-icon { font-size: 3rem; margin-bottom: 1rem; } .empty-state {
.empty-hint { color: var(--text-secondary); font-size: 0.8rem; text-align: center; padding: 1rem; } display: flex; flex-direction: column; align-items: center;
.error-hint { color: var(--danger); font-size: 0.8rem; padding: 0.5rem; } justify-content: center; height: 100%; color: var(--text-3); gap: 0.25rem;
.loading { color: var(--text-secondary); font-size: 0.8rem; padding: 0.5rem; } }
.empty-logo { font-size: 3rem; margin-bottom: 0.5rem; }
.empty-state h2 { font-size: 1.25rem; font-weight: 600; color: var(--text-2); }
.empty-state p { font-size: 0.85rem; }
/* Typing indicator */
.typing-dots { display: flex; gap: 4px; padding: 0.5rem 0; }
.typing-dots span {
width: 6px; height: 6px; border-radius: 50%; background: var(--text-3);
animation: dot-pulse 1.2s infinite;
}
.typing-dots span:nth-child(2) { animation-delay: 0.2s; }
.typing-dots span:nth-child(3) { animation-delay: 0.4s; }
@keyframes dot-pulse { 0%,60%,100% { opacity: 0.4; } 30% { opacity: 1; } }
/* ── Input Area ──────────────────────────── */ /* ── Input Area ──────────────────────────── */
.input-area { .input-area { padding: 0 1rem 1rem; flex-shrink: 0; }
border-top: 1px solid var(--border); padding: 0.5rem 1rem 0.75rem; .input-wrap {
background: var(--bg-secondary); flex-shrink: 0; max-width: 768px; margin: 0 auto;
background: var(--bg-surface); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 0;
display: flex; align-items: flex-end;
transition: border-color var(--transition);
} }
.input-bar { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; } .input-wrap:focus-within { border-color: var(--border-light); }
.model-selector { display: flex; align-items: center; gap: 0.5rem; font-size: 0.8rem; }
.model-selector label { color: var(--text-secondary); } .input-wrap textarea {
.model-selector select { flex: 1; resize: none; background: none; border: none;
background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text); color: var(--text); padding: 12px 0 12px 16px;
padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.8rem; max-width: 300px; font-family: var(--font); font-size: 14px;
max-height: 200px; line-height: 1.5;
} }
.input-row { display: flex; gap: 0.5rem; align-items: flex-end; } .input-wrap textarea:focus { outline: none; }
.input-row textarea { .input-wrap textarea::placeholder { color: var(--text-3); }
flex: 1; resize: none; background: var(--bg-tertiary); border: 1px solid var(--border);
color: var(--text); padding: 0.5rem 0.75rem; border-radius: var(--radius); .input-actions { display: flex; align-items: center; gap: 2px; padding: 6px 8px; }
font-family: var(--font); font-size: 0.9rem; max-height: 200px; line-height: 1.5;
.action-btn {
background: none; border: none; color: var(--text-3); cursor: pointer;
padding: 6px; border-radius: var(--radius);
transition: background var(--transition), color var(--transition);
display: flex; align-items: center; justify-content: center;
} }
.input-row textarea:focus { outline: none; border-color: var(--accent); } .action-btn:hover { background: var(--bg-hover); color: var(--text); }
.input-actions { display: flex; gap: 0.25rem; align-items: center; }
.send-btn { color: var(--accent); }
.send-btn:hover { background: var(--accent-dim); color: var(--accent-hover); }
.send-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.stop-btn { display: none; color: var(--warning); }
.stop-btn.visible { display: flex; }
/* ── Buttons ─────────────────────────────── */ /* ── Buttons ─────────────────────────────── */
button { font-family: var(--font); cursor: pointer; } button { font-family: var(--font); cursor: pointer; }
.btn-primary { .btn-primary {
background: var(--accent); color: #fff; border: none; padding: 0.5rem 1rem; background: var(--accent); color: #fff; border: none;
border-radius: var(--radius); font-weight: 600; font-size: 0.85rem; padding: 8px 16px; border-radius: var(--radius);
transition: background 0.15s; font-weight: 500; font-size: 13px;
transition: background var(--transition);
} }
.btn-primary:hover { background: var(--accent-hover); } .btn-primary:hover { background: var(--accent-hover); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-full { width: 100%; } .btn-full { width: 100%; }
.btn-small {
.btn-small { font-size: 0.8rem; padding: 0.35rem 0.75rem; } background: var(--bg-raised); border: 1px solid var(--border);
.btn-danger { background: var(--danger); color: #fff; border: none; border-radius: 4px; } color: var(--text-2); padding: 6px 12px; border-radius: var(--radius);
font-size: 12px; transition: all var(--transition);
.btn-icon {
background: none; border: none; color: var(--text-secondary); font-size: 1rem;
padding: 0.25rem; transition: color 0.15s;
} }
.btn-icon:hover { color: var(--text); } .btn-small:hover { background: var(--bg-hover); color: var(--text); }
.btn-small.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn-send { .btn-danger { background: var(--danger); color: #fff; border: none; border-radius: var(--radius); }
background: var(--accent); color: #fff; border: none; padding: 0.5rem 1rem;
border-radius: var(--radius); font-weight: 600; font-size: 0.85rem;
}
.btn-send:disabled { opacity: 0.5; }
.btn-stop { display: none; background: var(--warning); color: #000; border: none; padding: 0.5rem 0.75rem; border-radius: var(--radius); font-size: 0.85rem; }
.btn-stop.visible { display: block; }
.btn-regen { background: none; border: 1px solid var(--border); color: var(--text-secondary); border-radius: var(--radius); padding: 0.35rem; }
/* ── Connection Status ───────────────────── */
.connection-status {
display: flex; align-items: center; gap: 0.4rem; font-size: 0.8rem;
color: var(--text-secondary); cursor: pointer; padding: 0.25rem 0.5rem;
border-radius: var(--radius); transition: background 0.15s;
}
.connection-status:hover { background: var(--bg-tertiary); }
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--success); }
.connection-status.online .status-dot { background: var(--success); }
/* ── Typing Indicator ────────────────────── */
.typing-indicator { display: flex; gap: 4px; padding: 0.5rem 0; }
.typing-indicator span {
width: 6px; height: 6px; border-radius: 50%; background: var(--text-secondary);
animation: typing 1.2s infinite;
}
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing { 0%, 60%, 100% { transform: translateY(0); } 30% { transform: translateY(-4px); } }
/* ── Dropdown ────────────────────────────── */
.dropdown { position: relative; }
.dropdown-menu {
display: none; position: absolute; top: 100%; right: 0; background: var(--bg-secondary);
border: 1px solid var(--border); border-radius: var(--radius); padding: 0.25rem;
z-index: 100; min-width: 140px;
}
.dropdown-menu.show { display: block; }
.dropdown-menu button {
display: block; width: 100%; text-align: left; background: none; border: none;
color: var(--text); padding: 0.4rem 0.75rem; font-size: 0.8rem; border-radius: 4px;
}
.dropdown-menu button:hover { background: var(--bg-tertiary); }
/* ── Auth Splash ─────────────────────────── */ /* ── Auth Splash ─────────────────────────── */
.splash { .splash {
display: flex; align-items: center; justify-content: center; height: 100vh; display: flex; align-items: center; justify-content: center;
background: var(--bg); height: 100vh; background: var(--bg);
} }
.splash-card { .splash-card {
background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 12px; background: var(--bg-surface); border: 1px solid var(--border);
padding: 2rem; width: 380px; max-width: 90vw; border-radius: var(--radius-lg); padding: 2rem;
width: 380px; max-width: 90vw;
} }
.splash-brand { text-align: center; margin-bottom: 1.5rem; } .splash-brand { text-align: center; margin-bottom: 1.5rem; }
.splash-logo { font-size: 3rem; margin-bottom: 0.5rem; } .splash-logo { font-size: 3rem; margin-bottom: 0.5rem; }
.splash-brand h1 { font-size: 1.25rem; } .splash-brand h1 { font-size: 1.2rem; font-weight: 600; }
.splash-brand p { color: var(--text-secondary); font-size: 0.85rem; } .splash-brand p { color: var(--text-2); font-size: 0.85rem; }
.splash-error { color: var(--danger); font-size: 0.8rem; text-align: center; margin-top: 0.5rem; } .splash-error { color: var(--danger); font-size: 12px; text-align: center; margin-top: 0.5rem; }
.auth-tabs { display: flex; gap: 0; margin-bottom: 1rem; border-bottom: 1px solid var(--border); } .auth-tabs { display: flex; margin-bottom: 1rem; border-bottom: 1px solid var(--border); }
.auth-tab { .auth-tab {
flex: 1; background: none; border: none; color: var(--text-secondary); padding: 0.5rem; flex: 1; background: none; border: none; color: var(--text-3);
cursor: pointer; border-bottom: 2px solid transparent; font-size: 0.9rem; padding: 8px; cursor: pointer; font-size: 13px;
border-bottom: 2px solid transparent;
transition: color var(--transition);
} }
.auth-tab.active { color: var(--accent); border-bottom-color: var(--accent); } .auth-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.auth-error { color: var(--danger); font-size: 0.8rem; min-height: 1.2em; margin: 0.5rem 0; } .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-actions { display: flex; flex-direction: column; gap: 0.5rem; margin-top: 1rem; }
/* ── Forms ────────────────────────────────── */ /* ── Forms ────────────────────────────────── */
.form-group { margin-bottom: 0.75rem; } .form-group { margin-bottom: 0.75rem; }
.form-group label { display: block; font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 0.25rem; } .form-group label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 4px; font-weight: 500; }
.form-group input, .form-group select, .form-group textarea { .form-group input, .form-group select, .form-group textarea {
width: 100%; background: var(--bg-tertiary); border: 1px solid var(--border); width: 100%; background: var(--bg-raised); border: 1px solid var(--border);
color: var(--text); padding: 0.5rem 0.75rem; border-radius: var(--radius); color: var(--text); padding: 8px 12px; border-radius: var(--radius);
font-family: var(--font); font-size: 0.85rem; font-family: var(--font); font-size: 13px;
transition: border-color var(--transition);
} }
.form-group input:focus, .form-group select:focus, .form-group textarea:focus { .form-group input:focus, .form-group select:focus, .form-group textarea:focus {
outline: none; border-color: var(--accent); outline: none; border-color: var(--accent);
} }
.form-row { display: flex; gap: 0.75rem; } .form-row { display: flex; gap: 0.75rem; }
.form-row .form-group { flex: 1; } .form-row .form-group { flex: 1; }
.checkbox-label { display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; cursor: pointer; margin: 0.5rem 0; } .checkbox-label { display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer; margin: 0.5rem 0; color: var(--text-2); }
/* ── Modal ────────────────────────────────── */ /* ── Modal ────────────────────────────────── */
.modal-overlay { .modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.7); display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6);
align-items: center; justify-content: center; z-index: 1000; display: none; align-items: center; justify-content: center;
z-index: 1000; backdrop-filter: blur(2px);
} }
.modal-overlay.active { display: flex; } .modal-overlay.active { display: flex; }
.modal { .modal {
background: var(--bg-secondary); border-radius: 12px; width: 90%; max-width: 560px; background: var(--bg-surface); border: 1px solid var(--border);
border-radius: var(--radius-lg); width: 90%; max-width: 520px;
max-height: 85vh; display: flex; flex-direction: column; max-height: 85vh; display: flex; flex-direction: column;
animation: modalIn 0.2s ease; animation: modal-in 0.15s ease;
} }
.modal-wide { max-width: 750px; } .modal-wide { max-width: 700px; }
@keyframes modalIn { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } } @keyframes modal-in { from { opacity: 0; transform: translateY(-8px); } }
.modal-header { .modal-header {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
padding: 1rem 1.25rem; border-bottom: 1px solid var(--border); padding: 16px 20px; border-bottom: 1px solid var(--border);
}
.modal-header h2 { font-size: 15px; font-weight: 600; }
.modal-close {
background: none; border: none; color: var(--text-3); font-size: 18px;
cursor: pointer; padding: 2px 6px; border-radius: 4px;
transition: color var(--transition);
}
.modal-close:hover { color: var(--text); }
.modal-body { flex: 1; overflow-y: auto; padding: 16px 20px; }
.modal-footer {
padding: 12px 20px; border-top: 1px solid var(--border);
display: flex; justify-content: flex-end; gap: 0.5rem;
} }
.modal-header h2 { font-size: 1rem; }
.modal-close { background: none; border: none; color: var(--text-secondary); font-size: 1.25rem; cursor: pointer; }
.modal-body { flex: 1; overflow-y: auto; padding: 1rem 1.25rem; }
.modal-footer { padding: 0.75rem 1.25rem; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 0.5rem; }
.settings-section { margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border); } .settings-section { margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border); }
.settings-section:last-child { border-bottom: none; } .settings-section:last-child { border-bottom: none; margin-bottom: 0; }
.settings-section h3 { font-size: 0.9rem; margin-bottom: 0.75rem; color: var(--accent); } .settings-section h3 { font-size: 13px; font-weight: 600; color: var(--text-2); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
/* ── Providers ───────────────────────────── */
/* Providers */
.provider-row { .provider-row {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
padding: 0.5rem 0; border-bottom: 1px solid var(--border); padding: 8px 0; border-bottom: 1px solid var(--border);
} }
.provider-info { flex: 1; } .provider-name { font-weight: 500; font-size: 13px; }
.provider-name { font-weight: 600; font-size: 0.85rem; } .provider-meta { font-size: 11px; color: var(--text-3); margin-left: 8px; }
.provider-meta { font-size: 0.75rem; color: var(--text-secondary); margin-left: 0.5rem; } .provider-actions { display: flex; align-items: center; gap: 8px; }
.provider-actions { display: flex; align-items: center; gap: 0.5rem; } .badge-global { font-size: 10px; color: var(--text-3); background: var(--bg-raised); padding: 2px 8px; border-radius: 4px; }
.badge-global { font-size: 0.7rem; color: var(--text-secondary); background: var(--bg-tertiary); padding: 2px 6px; border-radius: 4px; }
/* ── Admin ────────────────────────────────── */ /* Admin tabs */
.admin-tabs { display: flex; border-bottom: 1px solid var(--border); padding: 0 16px; background: var(--bg-raised); }
.admin-tabs {
display: flex; border-bottom: 1px solid var(--border); padding: 0 1rem;
background: var(--bg-tertiary);
}
.admin-tab { .admin-tab {
padding: 0.5rem 0.75rem; background: none; border: none; color: var(--text-secondary); padding: 8px 12px; background: none; border: none; color: var(--text-3);
cursor: pointer; font-size: 0.8rem; border-bottom: 2px solid transparent; cursor: pointer; font-size: 12px; border-bottom: 2px solid transparent;
} }
.admin-tab.active { color: var(--accent); border-bottom-color: var(--accent); } .admin-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.admin-user-row { .admin-user-row { padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
padding: 0.5rem 0; border-bottom: 1px solid var(--border); font-size: 0.85rem; .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-user-email { font-size: 0.75rem; color: var(--text-secondary); } .badge-admin { background: var(--accent); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.admin-model-row { .badge-user { background: var(--bg-raised); color: var(--text-3); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
display: flex; align-items: center; gap: 1rem; padding: 0.4rem 0; .badge-inactive { background: var(--danger); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
border-bottom: 1px solid var(--border); font-size: 0.8rem; .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-admin { background: var(--accent); color: #fff; font-size: 0.65rem; padding: 1px 6px; border-radius: 4px; }
.badge-user { background: var(--bg-tertiary); color: var(--text-secondary); font-size: 0.65rem; padding: 1px 6px; border-radius: 4px; }
.badge-inactive { background: var(--danger); color: #fff; font-size: 0.65rem; padding: 1px 6px; border-radius: 4px; }
/* ── Toast ────────────────────────────────── */ /* ── Toast ────────────────────────────────── */
.toast-container { position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%); z-index: 10000; display: flex; flex-direction: column; gap: 0.5rem; } .toast-container { position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%); z-index: 10000; display: flex; flex-direction: column; gap: 0.5rem; }
.toast { .toast {
background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-surface); border: 1px solid var(--border);
padding: 0.5rem 1rem; font-size: 0.85rem; display: flex; align-items: center; gap: 0.5rem; border-radius: var(--radius); padding: 8px 16px; font-size: 13px;
animation: toastIn 0.3s ease; display: flex; align-items: center; gap: 8px;
animation: toast-in 0.25s ease; box-shadow: 0 4px 12px rgba(0,0,0,0.3);
} }
.toast.error { border-color: var(--danger); } .toast.error { border-color: var(--danger); }
.toast.warning { border-color: var(--warning); } .toast.warning { border-color: var(--warning); }
.toast.success { border-color: var(--success); } .toast.success { border-color: var(--success); }
.toast button { background: none; border: none; color: var(--text-secondary); cursor: pointer; } .toast button { background: none; border: none; color: var(--text-3); cursor: pointer; }
@keyframes toastIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } @keyframes toast-in { from { opacity: 0; transform: translateY(8px); } }
/* ── Debug Modal ─────────────────────────── */ /* ── Debug Modal ─────────────────────────── */
.debug-modal { max-width: 900px; height: 80vh; overflow: hidden; } .debug-modal { max-width: 900px; height: 80vh; overflow: hidden; }
.debug-tabs { display: flex; border-bottom: 1px solid var(--border); padding: 0 1rem; background: var(--bg-tertiary); } .debug-tabs { display: flex; border-bottom: 1px solid var(--border); padding: 0 16px; background: var(--bg-raised); }
.debug-tab { .debug-tab { padding: 8px 12px; background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 12px; border-bottom: 2px solid transparent; }
padding: 0.5rem 0.75rem; background: none; border: none; color: var(--text-secondary);
cursor: pointer; font-size: 0.8rem; border-bottom: 2px solid transparent;
}
.debug-tab.active { color: var(--accent); border-bottom-color: var(--accent); } .debug-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.debug-modal-body { flex: 1; overflow: hidden; padding: 0 !important; } .debug-modal-body { flex: 1; overflow: hidden; padding: 0 !important; }
.debug-tab-content { height: 100%; display: flex; flex-direction: column; } .debug-tab-content { height: 100%; display: flex; flex-direction: column; }
.debug-toolbar { display: flex; gap: 1rem; padding: 0.4rem 1rem; border-bottom: 1px solid var(--border); font-size: 0.75rem; } .debug-toolbar { display: flex; gap: 1rem; padding: 6px 12px; border-bottom: 1px solid var(--border); font-size: 11px; }
.debug-check { display: flex; align-items: center; gap: 0.3rem; color: var(--text-secondary); cursor: pointer; } .debug-check { display: flex; align-items: center; gap: 4px; color: var(--text-3); cursor: pointer; }
.debug-content { flex: 1; overflow-y: auto; padding: 0.5rem; font-family: var(--mono); font-size: 0.72rem; line-height: 1.5; } .debug-content { flex: 1; overflow-y: auto; padding: 6px; font-family: var(--mono); font-size: 11px; line-height: 1.5; }
.debug-entry { padding: 1px 0.5rem; border-bottom: 1px solid rgba(128,128,128,0.08); white-space: pre-wrap; word-break: break-all; } .debug-entry { padding: 1px 6px; border-bottom: 1px solid rgba(128,128,128,0.06); white-space: pre-wrap; word-break: break-all; }
.debug-time { color: var(--text-secondary); opacity: 0.6; font-size: 0.68rem; } .debug-time { color: var(--text-3); opacity: 0.6; font-size: 10px; }
.debug-msg { color: var(--text); } .debug-msg { color: var(--text); }
.debug-empty { color: var(--text-secondary); text-align: center; padding: 2rem; } .debug-empty { color: var(--text-3); text-align: center; padding: 2rem; }
.debug-net-entry { border-bottom: 1px solid var(--border); } .debug-net-entry { border-bottom: 1px solid var(--border); }
.debug-net-entry summary { padding: 0.4rem 0.5rem; cursor: pointer; font-size: 0.75rem; } .debug-net-entry summary { padding: 6px; cursor: pointer; font-size: 11px; }
.debug-net-method { font-weight: bold; color: var(--accent); } .debug-net-method { font-weight: bold; color: var(--accent); }
.debug-net-url { color: var(--text); word-break: break-all; } .debug-net-url { color: var(--text); word-break: break-all; }
.debug-net-detail { padding: 0.5rem 1rem; font-size: 0.72rem; } .debug-net-detail { padding: 6px 12px; font-size: 11px; }
.debug-pre { white-space: pre-wrap; word-break: break-all; max-height: 200px; overflow: auto; padding: 0.5rem; background: rgba(0,0,0,0.2); border-radius: 4px; margin: 0.3rem 0; } .debug-pre { white-space: pre-wrap; word-break: break-all; max-height: 200px; overflow: auto; padding: 6px; background: rgba(0,0,0,0.2); border-radius: 4px; margin: 4px 0; }
.debug-footer { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 1rem; border-top: 1px solid var(--border); } .debug-footer { display: flex; align-items: center; }
/* ── Responsive ──────────────────────────── */ /* ── Responsive ──────────────────────────── */
@media (max-width: 768px) { @media (max-width: 768px) {
.sidebar { width: 220px; } .sidebar { position: fixed; z-index: 100; height: 100%; }
.header-title { font-size: 0.9rem; } .sidebar.collapsed { width: 0; border: none; }
} .msg-inner { padding: 0 1rem; }
@media (max-width: 640px) {
.sidebar { display: none; }
} }

View File

@@ -7,65 +7,89 @@
<link rel="icon" type="image/svg+xml" href="favicon.svg"> <link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png"> <link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
<link rel="apple-touch-icon" sizes="192x192" href="favicon-192.png"> <link rel="apple-touch-icon" sizes="192x192" href="favicon-192.png">
<link rel="stylesheet" href="css/styles.css?v=0.5.1-2"> <link rel="stylesheet" href="css/styles.css?v=0.5.2">
</head> </head>
<body> <body>
<!-- ── App ─────────────────────────────────── --> <!-- ── App Shell ───────────────────────────── -->
<div id="appContainer" style="display:none"> <div id="appContainer" class="app" style="display:none">
<header class="header">
<h1 class="header-title">🔀 Chat Switchboard</h1> <!-- Sidebar -->
<div class="header-actions"> <aside class="sidebar" id="sidebar">
<div class="connection-status" id="connectionStatus"> <div class="sidebar-top">
<span class="status-dot"></span> Offline <button class="sb-btn" id="sidebarToggle" title="Toggle sidebar">
</div> <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>
<div class="dropdown"> </button>
<button class="btn-icon" id="exportBtn" title="Export">📥</button> <button class="sb-btn" id="newChatBtn" title="New Chat">
<div class="dropdown-menu" id="exportDropdown"> <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>
<button onclick="UI.exportChat('md')">Markdown</button> <span class="sb-label">New Chat</span>
<button onclick="UI.exportChat('json')">JSON</button> </button>
<button onclick="UI.exportChat('txt')">Plain Text</button>
</div>
</div>
<button class="btn-icon" id="adminBtn" title="Admin" style="display:none">🛡️</button>
<button class="btn-icon" id="settingsBtn" title="Settings">⚙️</button>
</div> </div>
</header>
<div class="main"> <div class="sidebar-chats" id="chatHistory"></div>
<aside class="sidebar" id="sidebar">
<button class="btn-primary new-chat-btn" id="newChatBtn">+ New Chat</button>
<div class="chat-history" id="chatHistory"></div>
</aside>
<main class="chat-area"> <!-- User area -->
<div class="chat-messages" id="chatMessages"> <div class="sidebar-bottom">
<div class="empty-state"> <button class="user-btn" id="userMenuBtn">
<div class="empty-icon">💬</div> <div class="user-avatar" id="userAvatar"><span id="avatarLetter">?</span><span class="avatar-dot"></span></div>
<h2>Start a Conversation</h2> <span class="sb-label user-name" id="userName">User</span>
<p>Type a message below to begin</p> </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="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>
<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-wrap">
<textarea id="messageInput" placeholder="Send a message..." rows="1"></textarea>
<div class="input-actions">
<button class="action-btn" id="regenerateBtn" style="display:none" title="Regenerate">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
</button>
<button class="action-btn stop-btn" id="stopBtn" title="Stop">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
</button>
<button class="action-btn send-btn" id="sendBtn" title="Send">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
</div> </div>
</div> </div>
</div>
<div class="input-area"> </main>
<div class="input-bar">
<div class="model-selector">
<label>Model:</label>
<select id="modelSelect"><option value=""></option></select>
<button class="btn-icon btn-small" id="fetchModelsBtn" title="Refresh models">🔄</button>
</div>
</div>
<div class="input-row">
<textarea id="messageInput" placeholder="Type your message..." rows="1"></textarea>
<div class="input-actions">
<button class="btn-regen" id="regenerateBtn" style="display:none" title="Regenerate">🔄</button>
<button class="btn-stop" id="stopBtn">⏹ Stop</button>
<button class="btn-send" id="sendBtn">Send ▸</button>
</div>
</div>
</div>
</main>
</div>
</div> </div>
<!-- ── Auth Splash ─────────────────────────── --> <!-- ── Auth Splash ─────────────────────────── -->
@@ -81,30 +105,13 @@
<button class="auth-tab" id="authTabRegister" onclick="switchAuthTab('register')">Register</button> <button class="auth-tab" id="authTabRegister" onclick="switchAuthTab('register')">Register</button>
</div> </div>
<div id="authLoginForm"> <div id="authLoginForm">
<div class="form-group"> <div class="form-group"><label>Username or Email</label><input type="text" id="authLogin" autocomplete="username"></div>
<label>Username or Email</label> <div class="form-group"><label>Password</label><input type="password" id="authPassword" autocomplete="current-password" onkeydown="if(event.key==='Enter')handleLogin()"></div>
<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>
<div id="authRegisterForm" style="display:none"> <div id="authRegisterForm" style="display:none">
<div class="form-group"> <div class="form-group"><label>Username</label><input type="text" id="authUsername" autocomplete="username"></div>
<label>Username</label> <div class="form-group"><label>Email</label><input type="email" id="authEmail" autocomplete="email"></div>
<input type="text" id="authUsername" autocomplete="username"> <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="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>
<div class="auth-error" id="authError"></div> <div class="auth-error" id="authError"></div>
<div class="splash-error" id="splashError"></div> <div class="splash-error" id="splashError"></div>
@@ -118,111 +125,51 @@
<!-- ── Settings Modal ──────────────────────── --> <!-- ── Settings Modal ──────────────────────── -->
<div class="modal-overlay" id="settingsModal"> <div class="modal-overlay" id="settingsModal">
<div class="modal"> <div class="modal">
<div class="modal-header"> <div class="modal-header"><h2>Settings</h2><button class="modal-close" id="settingsCloseBtn"></button></div>
<h2>⚙️ Settings</h2>
<button class="modal-close" id="settingsCloseBtn"></button>
</div>
<div class="modal-body"> <div class="modal-body">
<!-- Profile -->
<section class="settings-section"> <section class="settings-section">
<h3>Profile</h3> <h3>Profile</h3>
<div class="form-group"> <div class="form-group"><label>Display Name</label><input type="text" id="profileDisplayName"></div>
<label>Display Name</label> <div class="form-group"><label>Email</label><input type="email" id="profileEmail"></div>
<input type="text" id="profileDisplayName"> <button class="btn-small" id="profileChangePwBtn">Change Password</button>
</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 id="profileChangePwForm" style="display:none">
<div class="form-group"> <div class="form-group"><label>Current Password</label><input type="password" id="profileCurrentPw"></div>
<label>Current Password</label> <div class="form-group"><label>New Password</label><input type="password" id="profileNewPw"></div>
<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> <button class="btn-small btn-primary" id="profileSavePwBtn">Save Password</button>
</div> </div>
</section> </section>
<!-- Model & Chat -->
<section class="settings-section"> <section class="settings-section">
<h3>Chat Settings</h3> <h3>Chat</h3>
<div class="form-group"> <div class="form-group"><label>Default Model</label><select id="settingsModel"></select></div>
<label>Model</label> <div class="form-group"><label>System Prompt</label><textarea id="settingsSystemPrompt" rows="3"></textarea></div>
<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-row">
<div class="form-group"> <div class="form-group"><label>Max Tokens</label><input type="number" id="settingsMaxTokens" value="4096"></div>
<label>Max Tokens</label> <div class="form-group"><label>Temperature</label><input type="number" id="settingsTemp" value="0.7" step="0.1" min="0" max="2"></div>
<input type="number" id="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> </div>
<label class="checkbox-label"> <label class="checkbox-label"><input type="checkbox" id="settingsThinking" checked> Show thinking blocks</label>
<input type="checkbox" id="settingsThinking" checked> Show thinking blocks
</label>
</section> </section>
<!-- Providers -->
<section class="settings-section"> <section class="settings-section">
<h3>API Providers</h3> <h3>API Providers</h3>
<div id="providerList"></div> <div id="providerList"></div>
<button class="btn-small" id="providerShowAddBtn">+ Add Provider</button> <button class="btn-small" id="providerShowAddBtn">+ Add Provider</button>
<div id="providerAddForm" style="display:none"> <div id="providerAddForm" style="display:none">
<div class="form-group"> <div class="form-group"><label>Name</label><input type="text" id="providerName" placeholder="My OpenAI Key"></div>
<label>Name</label> <div class="form-group"><label>Provider</label><select id="providerType"><option value="openai">OpenAI-compatible</option><option value="anthropic">Anthropic</option></select></div>
<input type="text" id="providerName" placeholder="e.g. My OpenAI Key"> <div class="form-group"><label>Endpoint</label><input type="text" id="providerEndpoint" placeholder="https://api.openai.com/v1"></div>
</div> <div class="form-group"><label>API Key</label><input type="password" id="providerApiKey" placeholder="sk-..."></div>
<div class="form-group"> <div class="form-group"><label>Default Model</label><input type="text" id="providerDefaultModel" placeholder="gpt-4o"></div>
<label>Provider</label> <div class="form-row"><button class="btn-small btn-primary" id="providerCreateBtn">Save</button><button class="btn-small" id="providerCancelBtn">Cancel</button></div>
<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> </div>
</section> </section>
</div> </div>
<div class="modal-footer"> <div class="modal-footer"><button class="btn-primary" id="settingsSaveBtn">Save Settings</button></div>
<button class="btn-primary" id="settingsSaveBtn">💾 Save Settings</button>
</div>
</div> </div>
</div> </div>
<!-- ── Admin Modal ─────────────────────────── --> <!-- ── Admin Modal ─────────────────────────── -->
<div class="modal-overlay" id="adminModal"> <div class="modal-overlay" id="adminModal">
<div class="modal modal-wide"> <div class="modal modal-wide">
<div class="modal-header"> <div class="modal-header"><h2>Admin Panel</h2><button class="modal-close" id="adminCloseBtn"></button></div>
<h2>🛡️ Admin</h2>
<button class="modal-close" id="adminCloseBtn"></button>
</div>
<div class="admin-tabs"> <div class="admin-tabs">
<button class="admin-tab active" data-tab="users">Users</button> <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="providers">Providers</button>
@@ -231,42 +178,25 @@
<button class="admin-tab" data-tab="stats">Stats</button> <button class="admin-tab" data-tab="stats">Stats</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="admin-tab-content" id="adminUsersTab"> <div class="admin-tab-content" id="adminUsersTab"><div id="adminUserList"></div></div>
<div id="adminUserList"></div> <div class="admin-tab-content" id="adminProvidersTab" style="display:none"><div id="adminProviderList"></div></div>
</div> <div class="admin-tab-content" id="adminModelsTab" style="display:none"><div id="adminModelList"></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"> <div class="admin-tab-content" id="adminSettingsTab" style="display:none">
<label class="checkbox-label"> <label class="checkbox-label"><input type="checkbox" id="adminRegToggle" checked> Allow registration</label>
<input type="checkbox" id="adminRegToggle" checked> Allow registration
</label>
<button class="btn-primary btn-small" id="adminSaveSettings">Save</button> <button class="btn-primary btn-small" id="adminSaveSettings">Save</button>
</div> </div>
<div class="admin-tab-content" id="adminStatsTab" style="display:none"> <div class="admin-tab-content" id="adminStatsTab" style="display:none"><div id="adminStats"></div></div>
<div id="adminStats"></div>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- ── Debug Modal (Ctrl+Shift+L) ──────────── --> <!-- Debug Modal (Ctrl+Shift+L) -->
<div class="modal-overlay" id="debugModal"> <div class="modal-overlay" id="debugModal">
<div class="modal debug-modal"> <div class="modal debug-modal">
<div class="modal-header"> <div class="modal-header"><h2>Debug Log</h2><button class="modal-close" onclick="closeDebugModal()"></button></div>
<h2>🐛 Debug Log</h2>
<button class="modal-close" onclick="closeDebugModal()"></button>
</div>
<div class="debug-tabs"> <div class="debug-tabs">
<button class="debug-tab active" data-tab="console" onclick="switchDebugTab('console')"> <button class="debug-tab active" data-tab="console" onclick="switchDebugTab('console')">Console (<span id="debugConsoleCount">0</span>)</button>
Console (<span id="debugConsoleCount">0</span>) <button class="debug-tab" data-tab="network" onclick="switchDebugTab('network')">Network (<span id="debugNetworkCount">0</span>)</button>
</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" data-tab="state" onclick="switchDebugTab('state')">State</button>
</div> </div>
<div class="modal-body debug-modal-body"> <div class="modal-body debug-modal-body">
@@ -277,19 +207,15 @@
</div> </div>
<div id="debugConsoleContent" class="debug-content"></div> <div id="debugConsoleContent" class="debug-content"></div>
</div> </div>
<div class="debug-tab-content" id="debugNetworkTab" style="display:none"> <div class="debug-tab-content" id="debugNetworkTab" style="display:none"><div id="debugNetworkContent" class="debug-content"></div></div>
<div id="debugNetworkContent" class="debug-content"></div> <div class="debug-tab-content" id="debugStateTab" style="display:none"><div id="debugStateContent" class="debug-content"></div></div>
</div>
<div class="debug-tab-content" id="debugStateTab" style="display:none">
<div id="debugStateContent" class="debug-content"></div>
</div>
</div> </div>
<div class="modal-footer debug-footer"> <div class="modal-footer debug-footer">
<button class="btn-primary btn-small" onclick="runDebugDiagnostics()">🔍 Diagnostics</button> <button class="btn-primary btn-small" onclick="runDebugDiagnostics()">Diagnostics</button>
<div style="margin-left:auto;display:flex;gap:0.5rem"> <div style="margin-left:auto;display:flex;gap:0.5rem">
<button class="btn-small" onclick="clearDebugLog()">🗑️ Clear</button> <button class="btn-small" onclick="clearDebugLog()">Clear</button>
<button class="btn-small" onclick="copyDebugLog()">📋 Copy</button> <button class="btn-small" onclick="copyDebugLog()">Copy</button>
<button class="btn-small" onclick="exportDebugLog()">📥 Export</button> <button class="btn-small" onclick="exportDebugLog()">Export</button>
</div> </div>
</div> </div>
</div> </div>
@@ -297,17 +223,13 @@
<div class="toast-container" id="toastContainer"></div> <div class="toast-container" id="toastContainer"></div>
<!-- Markdown: local vendor first, CDN fallback for connected envs --> <!-- Vendor libs -->
<script src="vendor/marked.min.js" <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>
onerror="this.onerror=null; this.src='https://cdnjs.cloudflare.com/ajax/libs/marked/16.3.0/lib/marked.umd.min.js'"> <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>
<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.1-2"></script> <script src="js/debug.js?v=0.5.2"></script>
<script src="js/api.js?v=0.5.1-2"></script> <script src="js/api.js?v=0.5.2"></script>
<script src="js/ui.js?v=0.5.1-2"></script> <script src="js/ui.js?v=0.5.2"></script>
<script src="js/app.js?v=0.5.1-2"></script> <script src="js/app.js?v=0.5.2"></script>
</body> </body>
</html> </html>

View File

@@ -1,5 +1,5 @@
// ========================================== // ==========================================
// Chat Switchboard Application (v0.5.0) // Chat Switchboard Application (v0.5.2)
// ========================================== // ==========================================
const App = { const App = {
@@ -25,7 +25,6 @@ async function init() {
console.log('🔀 Chat Switchboard initializing...'); console.log('🔀 Chat Switchboard initializing...');
API.loadTokens(); API.loadTokens();
// Always check backend health first
let health = null; let health = null;
try { try {
health = await API.health(); health = await API.health();
@@ -37,20 +36,13 @@ async function init() {
return; return;
} }
// If we have tokens, validate them
if (API.isAuthed) { if (API.isAuthed) {
try { try {
await API.getProfile(); await API.getProfile();
console.log('✅ Session valid for', API.user?.username); console.log('✅ Session valid for', API.user?.username);
} catch (e) { } catch (e) {
if (e.status === 401) { console.warn('⚠️ Session expired, clearing');
console.warn('⚠️ Session expired, clearing'); API.clearTokens();
API.clearTokens();
} else {
// 404 = profile not created yet, other errors = transient
// Token is still valid, proceed
console.log(' Profile fetch returned', e.status || e.message, '— session OK');
}
} }
} }
@@ -63,12 +55,13 @@ async function init() {
async function startApp() { async function startApp() {
hideSplash(); hideSplash();
UI.restoreSidebar();
await loadSettings(); await loadSettings();
await loadChats(); await loadChats();
await fetchModels(); await fetchModels();
UI.renderChatList(); UI.renderChatList();
UI.updateModelSelector(); UI.updateModelSelector();
UI.updateConnectionStatus(); UI.updateUser();
UI.showAdminButton(API.isAdmin); UI.showAdminButton(API.isAdmin);
initListeners(); initListeners();
console.log('✅ Chat Switchboard ready'); console.log('✅ Chat Switchboard ready');
@@ -85,9 +78,7 @@ async function loadSettings() {
if (remote.max_tokens) App.settings.maxTokens = remote.max_tokens; if (remote.max_tokens) App.settings.maxTokens = remote.max_tokens;
if (remote.temperature !== undefined) App.settings.temperature = remote.temperature; if (remote.temperature !== undefined) App.settings.temperature = remote.temperature;
} }
} catch (e) { } catch (e) { console.warn('Settings load failed:', e.message); }
console.warn('Settings load failed:', e.message);
}
} }
async function saveSettings() { async function saveSettings() {
@@ -98,9 +89,7 @@ async function saveSettings() {
max_tokens: App.settings.maxTokens, max_tokens: App.settings.maxTokens,
temperature: App.settings.temperature, temperature: App.settings.temperature,
}); });
} catch (e) { } catch (e) { console.warn('Settings sync failed:', e.message); }
console.warn('Settings sync failed:', e.message);
}
} }
// ── Models ─────────────────────────────────── // ── Models ───────────────────────────────────
@@ -114,7 +103,6 @@ async function fetchModels() {
configId: m.config_id || null configId: m.config_id || null
})); }));
// Fallback to raw aggregation if no curated models
if (App.models.length === 0) { if (App.models.length === 0) {
const raw = await API.listAllModels(); const raw = await API.listAllModels();
App.models = (raw.models || []).map(m => ({ App.models = (raw.models || []).map(m => ({
@@ -126,9 +114,7 @@ async function fetchModels() {
App.models.sort((a, b) => a.id.localeCompare(b.id)); App.models.sort((a, b) => a.id.localeCompare(b.id));
console.log(`📋 Loaded ${App.models.length} models`); console.log(`📋 Loaded ${App.models.length} models`);
} catch (e) { } catch (e) { console.warn('Model fetch failed:', e.message); }
console.warn('Model fetch failed:', e.message);
}
UI.updateModelSelector(); UI.updateModelSelector();
} }
@@ -142,7 +128,7 @@ async function loadChats() {
title: c.title, title: c.title,
model: c.model || '', model: c.model || '',
messageCount: c.message_count || 0, messageCount: c.message_count || 0,
messages: [], // lazy loaded messages: [],
updatedAt: c.updated_at updatedAt: c.updated_at
})); }));
} catch (e) { } catch (e) {
@@ -158,15 +144,11 @@ async function selectChat(chatId) {
const chat = App.chats.find(c => c.id === chatId); const chat = App.chats.find(c => c.id === chatId);
if (!chat) return; if (!chat) return;
// Lazy-load messages
if (chat.messages.length === 0 && chat.messageCount > 0) { if (chat.messages.length === 0 && chat.messageCount > 0) {
try { try {
const resp = await API.listMessages(chatId); const resp = await API.listMessages(chatId);
chat.messages = (resp.data || []).map(m => ({ chat.messages = (resp.data || []).map(m => ({
role: m.role, role: m.role, content: m.content, model: m.model || '', timestamp: m.created_at
content: m.content,
model: m.model || '',
timestamp: m.created_at
})); }));
} catch (e) { } catch (e) {
console.error('Failed to load messages:', e.message); console.error('Failed to load messages:', e.message);
@@ -182,6 +164,7 @@ async function newChat() {
App.currentChatId = null; App.currentChatId = null;
UI.renderChatList(); UI.renderChatList();
UI.showEmptyState(); UI.showEmptyState();
UI.showRegenerate(false);
document.getElementById('messageInput').focus(); document.getElementById('messageInput').focus();
} }
@@ -190,13 +173,9 @@ async function deleteChat(chatId) {
try { try {
await API.deleteChat(chatId); await API.deleteChat(chatId);
App.chats = App.chats.filter(c => c.id !== chatId); App.chats = App.chats.filter(c => c.id !== chatId);
if (App.currentChatId === chatId) { if (App.currentChatId === chatId) newChat();
newChat();
}
UI.renderChatList(); UI.renderChatList();
} catch (e) { } catch (e) { UI.toast('Failed to delete: ' + e.message, 'error'); }
UI.toast('Failed to delete: ' + e.message, 'error');
}
} }
// ── Send Message ───────────────────────────── // ── Send Message ─────────────────────────────
@@ -211,69 +190,45 @@ async function sendMessage() {
const model = document.getElementById('modelSelect').value || App.settings.model; const model = document.getElementById('modelSelect').value || App.settings.model;
// Create chat on first message
if (!App.currentChatId) { if (!App.currentChatId) {
try { try {
const title = text.slice(0, 50) + (text.length > 50 ? '...' : ''); const title = text.slice(0, 50) + (text.length > 50 ? '...' : '');
const resp = await API.createChat(title, model, App.settings.systemPrompt); const resp = await API.createChat(title, model, App.settings.systemPrompt);
const chat = { const chat = { id: resp.id, title: resp.title, model, messages: [], messageCount: 0, updatedAt: resp.updated_at };
id: resp.id,
title: resp.title,
model: model,
messages: [],
messageCount: 0,
updatedAt: resp.updated_at
};
App.chats.unshift(chat); App.chats.unshift(chat);
App.currentChatId = chat.id; App.currentChatId = chat.id;
UI.renderChatList(); UI.renderChatList();
} catch (e) { } catch (e) { UI.toast('Failed to create chat: ' + e.message, 'error'); return; }
UI.toast('Failed to create chat: ' + e.message, 'error');
return;
}
} }
// Add user message to local state + render
const chat = App.chats.find(c => c.id === App.currentChatId); const chat = App.chats.find(c => c.id === App.currentChatId);
if (!chat) return; if (!chat) return;
chat.messages.push({ role: 'user', content: text, timestamp: new Date().toISOString() }); chat.messages.push({ role: 'user', content: text, timestamp: new Date().toISOString() });
UI.renderMessages(chat.messages); UI.renderMessages(chat.messages);
// Stream the completion
App.isGenerating = true; App.isGenerating = true;
App.abortController = new AbortController(); App.abortController = new AbortController();
UI.setGenerating(true); UI.setGenerating(true);
try { try {
const resp = await API.streamCompletion( const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal);
App.currentChatId, text, model, App.abortController.signal
);
const assistantContent = await UI.streamResponse(resp, chat.messages); const assistantContent = await UI.streamResponse(resp, chat.messages);
chat.messages.push({ role: 'assistant', content: assistantContent, model, timestamp: new Date().toISOString() });
chat.messages.push({
role: 'assistant',
content: assistantContent,
model: model,
timestamp: new Date().toISOString()
});
chat.messageCount = chat.messages.length; chat.messageCount = chat.messages.length;
UI.showRegenerate(true); UI.showRegenerate(true);
} catch (e) { } catch (e) {
if (e.name === 'AbortError') { if (e.name === 'AbortError') {
UI.toast('Generation stopped', 'warning'); UI.toast('Generation stopped', 'warning');
} else { } else {
console.error('Completion error:', e); console.error('Completion error:', e);
// Distinguish provider-side auth errors from session issues
const msg = e.message || ''; const msg = e.message || '';
if (msg.includes('provider error') && msg.includes('401')) { if (msg.includes('provider error') && msg.includes('401')) {
UI.toast('Provider API key rejected — check your key in Settings → Providers', 'error'); UI.toast('Provider API key rejected — check Settings → Providers', 'error');
} else if (msg.includes('provider error') && msg.includes('429')) { } else if (msg.includes('provider error') && msg.includes('429')) {
UI.toast('Provider rate limit hit — wait a moment and retry', 'warning'); UI.toast('Provider rate limit — wait and retry', 'warning');
} else if (msg.includes('no API config') || msg.includes('no model')) { } else if (msg.includes('no API config') || msg.includes('no model')) {
UI.toast('No provider configured — add one in Settings → Providers', 'error'); UI.toast('No provider configured — add one in Settings', 'error');
} else { } else {
UI.toast(msg, 'error'); UI.toast(msg, 'error');
} }
@@ -285,39 +240,27 @@ async function sendMessage() {
} }
} }
function stopGeneration() { function stopGeneration() { if (App.abortController) App.abortController.abort(); }
if (App.abortController) {
App.abortController.abort();
}
}
async function regenerate() { async function regenerate() {
if (App.isGenerating || !App.currentChatId) return; if (App.isGenerating || !App.currentChatId) return;
const chat = App.chats.find(c => c.id === App.currentChatId); const chat = App.chats.find(c => c.id === App.currentChatId);
if (!chat || chat.messages.length === 0) return; if (!chat || chat.messages.length === 0) return;
// Pop trailing assistant messages while (chat.messages.length && 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; if (chat.messages.length === 0) return;
UI.renderMessages(chat.messages); UI.renderMessages(chat.messages);
// Re-send the last user message
const lastUser = chat.messages[chat.messages.length - 1]; const lastUser = chat.messages[chat.messages.length - 1];
if (lastUser.role !== 'user') return; if (lastUser.role !== 'user') return;
const model = document.getElementById('modelSelect').value || App.settings.model; const model = document.getElementById('modelSelect').value || App.settings.model;
App.isGenerating = true; App.isGenerating = true;
App.abortController = new AbortController(); App.abortController = new AbortController();
UI.setGenerating(true); UI.setGenerating(true);
try { try {
const resp = await API.streamCompletion( const resp = await API.streamCompletion(App.currentChatId, lastUser.content, model, App.abortController.signal);
App.currentChatId, lastUser.content, model, App.abortController.signal
);
const content = await UI.streamResponse(resp, chat.messages); const content = await UI.streamResponse(resp, chat.messages);
chat.messages.push({ role: 'assistant', content, model, timestamp: new Date().toISOString() }); chat.messages.push({ role: 'assistant', content, model, timestamp: new Date().toISOString() });
UI.showRegenerate(true); UI.showRegenerate(true);
@@ -325,10 +268,8 @@ async function regenerate() {
if (e.name !== 'AbortError') { if (e.name !== 'AbortError') {
const msg = e.message || ''; const msg = e.message || '';
if (msg.includes('provider error') && msg.includes('401')) { if (msg.includes('provider error') && msg.includes('401')) {
UI.toast('Provider API key rejected — check Settings → Providers', 'error'); UI.toast('Provider API key rejected — check Settings', 'error');
} else { } else { UI.toast(msg, 'error'); }
UI.toast(msg, 'error');
}
} }
} finally { } finally {
App.isGenerating = false; App.isGenerating = false;
@@ -342,7 +283,6 @@ async function regenerate() {
function showSplash(health) { function showSplash(health) {
document.getElementById('splashGate').style.display = 'flex'; document.getElementById('splashGate').style.display = 'flex';
document.getElementById('appContainer').style.display = 'none'; document.getElementById('appContainer').style.display = 'none';
if (health && health.registration_enabled === false) { if (health && health.registration_enabled === false) {
document.getElementById('authTabRegister').style.display = 'none'; document.getElementById('authTabRegister').style.display = 'none';
} }
@@ -357,16 +297,10 @@ async function handleLogin() {
const login = document.getElementById('authLogin').value.trim(); const login = document.getElementById('authLogin').value.trim();
const password = document.getElementById('authPassword').value; const password = document.getElementById('authPassword').value;
if (!login || !password) return setAuthError('Fill in all fields'); if (!login || !password) return setAuthError('Fill in all fields');
setAuthLoading(true); setAuthLoading(true);
try { try { await API.login(login, password); await startApp(); }
await API.login(login, password); catch (e) { setAuthError(e.message); }
await startApp(); finally { setAuthLoading(false); }
} catch (e) {
setAuthError(e.message);
} finally {
setAuthLoading(false);
}
} }
async function handleRegister() { async function handleRegister() {
@@ -375,16 +309,10 @@ async function handleRegister() {
const password = document.getElementById('authRegPassword').value; const password = document.getElementById('authRegPassword').value;
if (!username || !email || !password) return setAuthError('Fill in all fields'); if (!username || !email || !password) return setAuthError('Fill in all fields');
if (password.length < 8) return setAuthError('Password must be at least 8 characters'); if (password.length < 8) return setAuthError('Password must be at least 8 characters');
setAuthLoading(true); setAuthLoading(true);
try { try { await API.register(username, email, password); await startApp(); }
await API.register(username, email, password); catch (e) { setAuthError(e.message); }
await startApp(); finally { setAuthLoading(false); }
} catch (e) {
setAuthError(e.message);
} finally {
setAuthLoading(false);
}
} }
async function handleLogout() { async function handleLogout() {
@@ -409,7 +337,7 @@ function setAuthError(msg) { document.getElementById('authError').textContent =
function setAuthLoading(on) { function setAuthLoading(on) {
document.querySelectorAll('#authLoginBtn, #authRegisterBtn').forEach(btn => { document.querySelectorAll('#authLoginBtn, #authRegisterBtn').forEach(btn => {
btn.disabled = on; btn.disabled = on;
btn.textContent = on ? 'Please wait...' : btn.dataset.label; btn.textContent = on ? 'Please wait...' : btn.dataset.label;
}); });
} }
@@ -420,43 +348,56 @@ function initListeners() {
if (_listenersInit) return; if (_listenersInit) return;
_listenersInit = true; _listenersInit = true;
// Sidebar
document.getElementById('sidebarToggle').addEventListener('click', UI.toggleSidebar);
document.getElementById('newChatBtn').addEventListener('click', newChat); 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('sendBtn').addEventListener('click', sendMessage);
document.getElementById('stopBtn').addEventListener('click', stopGeneration); document.getElementById('stopBtn').addEventListener('click', stopGeneration);
document.getElementById('regenerateBtn').addEventListener('click', regenerate); document.getElementById('regenerateBtn').addEventListener('click', regenerate);
// Settings
document.getElementById('settingsBtn').addEventListener('click', UI.openSettings);
document.getElementById('settingsCloseBtn').addEventListener('click', UI.closeSettings);
document.getElementById('settingsSaveBtn').addEventListener('click', handleSaveSettings);
document.getElementById('fetchModelsBtn').addEventListener('click', async () => {
await fetchModels();
UI.toast(`Loaded ${App.models.length} models`, 'success');
});
// Profile
document.getElementById('profileChangePwBtn').addEventListener('click', () => {
document.getElementById('profileChangePwForm').style.display = '';
});
document.getElementById('profileSavePwBtn').addEventListener('click', handleChangePassword);
// Providers
document.getElementById('providerShowAddBtn').addEventListener('click', UI.showProviderForm);
document.getElementById('providerCancelBtn').addEventListener('click', UI.hideProviderForm);
document.getElementById('providerCreateBtn').addEventListener('click', handleCreateProvider);
// Admin
document.getElementById('adminBtn').addEventListener('click', UI.openAdmin);
document.getElementById('adminCloseBtn').addEventListener('click', UI.closeAdmin);
document.querySelectorAll('.admin-tab').forEach(tab => {
tab.addEventListener('click', () => UI.switchAdminTab(tab.dataset.tab));
});
document.getElementById('adminSaveSettings').addEventListener('click', handleSaveAdminSettings);
// Model selector // Model selector
document.getElementById('modelSelect').addEventListener('change', function() { document.getElementById('modelSelect').addEventListener('change', function() {
if (this.value) { App.settings.model = this.value; saveSettings(); } 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 // Input
const input = document.getElementById('messageInput'); const input = document.getElementById('messageInput');
@@ -468,19 +409,7 @@ function initListeners() {
this.style.height = Math.min(this.scrollHeight, 200) + 'px'; this.style.height = Math.min(this.scrollHeight, 200) + 'px';
}); });
// Connection status // Close modals on overlay click
document.getElementById('connectionStatus').addEventListener('click', handleLogout);
// Export dropdown
document.getElementById('exportBtn').addEventListener('click', (e) => {
e.stopPropagation();
document.getElementById('exportDropdown').classList.toggle('show');
});
document.addEventListener('click', () => {
document.getElementById('exportDropdown').classList.remove('show');
});
// Close modals on overlay
document.querySelectorAll('.modal-overlay').forEach(overlay => { document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', (e) => { overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.classList.remove('active'); if (e.target === overlay) overlay.classList.remove('active');
@@ -501,7 +430,6 @@ function handleSaveSettings() {
App.settings.maxTokens = parseInt(document.getElementById('settingsMaxTokens').value) || 4096; App.settings.maxTokens = parseInt(document.getElementById('settingsMaxTokens').value) || 4096;
App.settings.temperature = parseFloat(document.getElementById('settingsTemp').value) || 0.7; App.settings.temperature = parseFloat(document.getElementById('settingsTemp').value) || 0.7;
App.settings.showThinking = document.getElementById('settingsThinking').checked; App.settings.showThinking = document.getElementById('settingsThinking').checked;
saveSettings(); saveSettings();
UI.updateModelSelector(); UI.updateModelSelector();
UI.closeSettings(); UI.closeSettings();
@@ -513,16 +441,13 @@ async function handleChangePassword() {
const pw = document.getElementById('profileNewPw').value; const pw = document.getElementById('profileNewPw').value;
if (!cur || !pw) return UI.toast('Fill in both fields', 'warning'); if (!cur || !pw) return UI.toast('Fill in both fields', 'warning');
if (pw.length < 8) return UI.toast('Min 8 characters', 'warning'); if (pw.length < 8) return UI.toast('Min 8 characters', 'warning');
try { try {
await API.changePassword(cur, pw); await API.changePassword(cur, pw);
UI.toast('Password updated', 'success'); UI.toast('Password updated', 'success');
document.getElementById('profileChangePwForm').style.display = 'none'; document.getElementById('profileChangePwForm').style.display = 'none';
document.getElementById('profileCurrentPw').value = ''; document.getElementById('profileCurrentPw').value = '';
document.getElementById('profileNewPw').value = ''; document.getElementById('profileNewPw').value = '';
} catch (e) { } catch (e) { UI.toast(e.message, 'error'); }
UI.toast(e.message, 'error');
}
} }
async function handleCreateProvider() { async function handleCreateProvider() {
@@ -531,18 +456,14 @@ async function handleCreateProvider() {
const endpoint = document.getElementById('providerEndpoint').value.trim(); const endpoint = document.getElementById('providerEndpoint').value.trim();
const apiKey = document.getElementById('providerApiKey').value.trim(); const apiKey = document.getElementById('providerApiKey').value.trim();
const model = document.getElementById('providerDefaultModel').value.trim(); const model = document.getElementById('providerDefaultModel').value.trim();
if (!name || !endpoint || !apiKey) return UI.toast('Fill in required fields', 'warning'); if (!name || !endpoint || !apiKey) return UI.toast('Fill in required fields', 'warning');
try { try {
await API.createConfig(name, provider, endpoint, apiKey, model); await API.createConfig(name, provider, endpoint, apiKey, model);
UI.toast('Provider added', 'success'); UI.toast('Provider added', 'success');
UI.hideProviderForm(); UI.hideProviderForm();
UI.loadProviderList(); UI.loadProviderList();
fetchModels(); fetchModels();
} catch (e) { } catch (e) { UI.toast(e.message, 'error'); }
UI.toast(e.message, 'error');
}
} }
async function deleteProvider(id, name) { async function deleteProvider(id, name) {
@@ -552,23 +473,16 @@ async function deleteProvider(id, name) {
UI.toast('Provider removed', 'success'); UI.toast('Provider removed', 'success');
UI.loadProviderList(); UI.loadProviderList();
fetchModels(); fetchModels();
} catch (e) { } catch (e) { UI.toast(e.message, 'error'); }
UI.toast(e.message, 'error');
}
} }
// ── Admin Handlers ───────────────────────────
async function handleSaveAdminSettings() { async function handleSaveAdminSettings() {
try { try {
const reg = document.getElementById('adminRegToggle').checked; const reg = document.getElementById('adminRegToggle').checked;
await API.adminUpdateSetting('registration_enabled', { value: reg }); await API.adminUpdateSetting('registration_enabled', { value: reg });
UI.toast('Admin settings saved', 'success'); UI.toast('Admin settings saved', 'success');
} catch (e) { } catch (e) { UI.toast(e.message, 'error'); }
UI.toast(e.message, 'error');
}
} }
// ── Boot ───────────────────────────────────── // ── Boot ─────────────────────────────────────
document.addEventListener('DOMContentLoaded', init); document.addEventListener('DOMContentLoaded', init);

View File

@@ -1,25 +1,80 @@
// ========================================== // ==========================================
// Chat Switchboard UI (v0.5.0) // Chat Switchboard UI (v0.5.2)
// ========================================== // ==========================================
const UI = { const UI = {
// ── Sidebar ──────────────────────────────
toggleSidebar() {
document.getElementById('sidebar').classList.toggle('collapsed');
localStorage.setItem('sb_sidebar', document.getElementById('sidebar').classList.contains('collapsed') ? '1' : '0');
},
restoreSidebar() {
if (localStorage.getItem('sb_sidebar') === '1') {
document.getElementById('sidebar').classList.add('collapsed');
}
},
// ── User Menu Flyout ─────────────────────
toggleUserMenu() {
const flyout = document.getElementById('userFlyout');
flyout.classList.toggle('open');
},
closeUserMenu() {
document.getElementById('userFlyout').classList.remove('open');
},
// ── Chat List ──────────────────────────── // ── Chat List ────────────────────────────
renderChatList() { renderChatList() {
const el = document.getElementById('chatHistory'); const el = document.getElementById('chatHistory');
if (App.chats.length === 0) { if (App.chats.length === 0) {
el.innerHTML = '<div class="empty-hint">No conversations yet</div>'; el.innerHTML = '<div class="sidebar-empty">No conversations yet</div>';
return; return;
} }
el.innerHTML = App.chats.map(c => `
<div class="chat-item ${c.id === App.currentChatId ? 'active' : ''}" // Group by time
onclick="selectChat('${c.id}')"> const now = new Date();
<span class="chat-item-title">${esc(c.title)}</span> const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
<button class="chat-item-delete" onclick="deleteChat('${c.id}');event.stopPropagation();" const yesterday = new Date(today - 86400000);
title="Delete">✕</button> const weekAgo = new Date(today - 7 * 86400000);
</div>
`).join(''); 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);
});
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 ───────────────────────────── // ── Messages ─────────────────────────────
@@ -35,23 +90,25 @@ const UI = {
.filter(m => m.role !== 'system') .filter(m => m.role !== 'system')
.map((m, i) => this._messageHTML(m, i)) .map((m, i) => this._messageHTML(m, i))
.join(''); .join('');
this._scrollToBottom(); this._scrollToBottom(true);
}, },
_messageHTML(msg, index) { _messageHTML(msg, index) {
const isUser = msg.role === 'user'; const isUser = msg.role === 'user';
const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString() : ''; const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
return ` return `
<div class="message ${msg.role}"> <div class="message ${msg.role}">
<div class="message-inner"> <div class="msg-inner">
<div class="message-avatar">${isUser ? '👤' : '🤖'}</div> <div class="msg-avatar">${isUser ? '👤' : '🤖'}</div>
<div class="message-body"> <div class="msg-body">
<div class="message-head"> <div class="msg-head">
<span class="message-role">${isUser ? 'You' : 'Assistant'}</span> <span class="msg-role">${isUser ? 'You' : 'Assistant'}</span>
${time ? `<span class="message-time">${time}</span>` : ''} ${time ? `<span class="msg-time">${time}</span>` : ''}
<button class="message-copy" onclick="UI.copyMessage(${index})" title="Copy">📋</button> <div class="msg-actions">
<button class="msg-action-btn" onclick="UI.copyMessage(${index})" title="Copy">Copy</button>
</div>
</div> </div>
<div class="message-text">${formatMessage(msg.content)}</div> <div class="msg-text">${formatMessage(msg.content)}</div>
</div> </div>
</div> </div>
</div>`; </div>`;
@@ -60,9 +117,9 @@ const UI = {
showEmptyState() { showEmptyState() {
document.getElementById('chatMessages').innerHTML = ` document.getElementById('chatMessages').innerHTML = `
<div class="empty-state"> <div class="empty-state">
<div class="empty-icon">💬</div> <div class="empty-logo">🔀</div>
<h2>Start a Conversation</h2> <h2>Chat Switchboard</h2>
<p>Type a message below to begin</p> <p>Select a model and start chatting</p>
</div>`; </div>`;
}, },
@@ -70,21 +127,16 @@ const UI = {
async streamResponse(response, messages) { async streamResponse(response, messages) {
const container = document.getElementById('chatMessages'); const container = document.getElementById('chatMessages');
// Remove typing indicator if present
document.getElementById('typingIndicator')?.remove(); document.getElementById('typingIndicator')?.remove();
// Create streaming message element
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'message assistant'; div.className = 'message assistant';
div.innerHTML = ` div.innerHTML = `
<div class="message-inner"> <div class="msg-inner">
<div class="message-avatar">🤖</div> <div class="msg-avatar">🤖</div>
<div class="message-body"> <div class="msg-body">
<div class="message-head"> <div class="msg-head"><span class="msg-role">Assistant</span></div>
<span class="message-role">Assistant</span> <div class="msg-text" id="streamContent"></div>
</div>
<div class="message-text" id="streamContent"></div>
</div> </div>
</div>`; </div>`;
container.appendChild(div); container.appendChild(div);
@@ -144,7 +196,7 @@ const UI = {
const exists = [...sel.options].some(o => o.value === current); const exists = [...sel.options].some(o => o.value === current);
if (exists) { if (exists) {
sel.value = current; sel.value = current;
} else if (current) { } else {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = current; opt.value = current;
opt.textContent = current + ' (custom)'; opt.textContent = current + ' (custom)';
@@ -153,7 +205,7 @@ const UI = {
} }
} }
// Also update settings modal model selector if open // Sync settings modal selector
const settingsSel = document.getElementById('settingsModel'); const settingsSel = document.getElementById('settingsModel');
if (settingsSel) { if (settingsSel) {
settingsSel.innerHTML = sel.innerHTML; settingsSel.innerHTML = sel.innerHTML;
@@ -161,18 +213,17 @@ const UI = {
} }
}, },
// ── Connection Status ──────────────────── // ── User / Connection ────────────────────
updateConnectionStatus() { updateUser() {
const el = document.getElementById('connectionStatus'); const name = API.user?.display_name || API.user?.username || 'User';
const name = API.user?.display_name || API.user?.username || 'Connected'; const letter = (name[0] || '?').toUpperCase();
el.className = 'connection-status online'; document.getElementById('avatarLetter').textContent = letter;
el.innerHTML = `<span class="status-dot"></span> ${esc(name)}`; document.getElementById('userName').textContent = name;
el.title = 'Click to sign out';
}, },
showAdminButton(show) { showAdminButton(show) {
document.getElementById('adminBtn').style.display = show ? '' : 'none'; document.getElementById('menuAdmin').style.display = show ? '' : 'none';
}, },
// ── Generating State ───────────────────── // ── Generating State ─────────────────────
@@ -181,20 +232,17 @@ const UI = {
document.getElementById('stopBtn').classList.toggle('visible', on); document.getElementById('stopBtn').classList.toggle('visible', on);
document.getElementById('sendBtn').disabled = on; document.getElementById('sendBtn').disabled = on;
if (on) { if (on) {
// Show typing indicator
const container = document.getElementById('chatMessages'); const container = document.getElementById('chatMessages');
const typing = document.createElement('div'); const typing = document.createElement('div');
typing.id = 'typingIndicator'; typing.id = 'typingIndicator';
typing.className = 'message assistant'; typing.className = 'message assistant';
typing.innerHTML = ` typing.innerHTML = `
<div class="message-inner"> <div class="msg-inner">
<div class="message-avatar">🤖</div> <div class="msg-avatar">🤖</div>
<div class="message-body"> <div class="msg-body"><div class="typing-dots"><span></span><span></span><span></span></div></div>
<div class="typing-indicator"><span></span><span></span><span></span></div>
</div>
</div>`; </div>`;
container.appendChild(typing); container.appendChild(typing);
this._scrollToBottom(); this._scrollToBottom(true);
} else { } else {
document.getElementById('typingIndicator')?.remove(); document.getElementById('typingIndicator')?.remove();
} }
@@ -224,15 +272,11 @@ const UI = {
document.getElementById('settingsTemp').value = App.settings.temperature; document.getElementById('settingsTemp').value = App.settings.temperature;
document.getElementById('settingsThinking').checked = App.settings.showThinking; document.getElementById('settingsThinking').checked = App.settings.showThinking;
document.getElementById('profileChangePwForm').style.display = 'none'; document.getElementById('profileChangePwForm').style.display = 'none';
UI.loadProfileIntoSettings(); UI.loadProfileIntoSettings();
UI.loadProviderList(); UI.loadProviderList();
document.getElementById('settingsModal').classList.add('active'); document.getElementById('settingsModal').classList.add('active');
}, },
closeSettings() { document.getElementById('settingsModal').classList.remove('active'); },
closeSettings() {
document.getElementById('settingsModal').classList.remove('active');
},
async loadProfileIntoSettings() { async loadProfileIntoSettings() {
try { try {
@@ -251,12 +295,12 @@ const UI = {
const data = await API.listConfigs(); const data = await API.listConfigs();
const list = Array.isArray(data) ? data : (data.configs || data.data || []); const list = Array.isArray(data) ? data : (data.configs || data.data || []);
if (list.length === 0) { if (list.length === 0) {
el.innerHTML = '<div class="empty-hint">No providers. Add one to start chatting.</div>'; el.innerHTML = '<div class="empty-hint">No providers configured.</div>';
return; return;
} }
el.innerHTML = list.map(c => ` el.innerHTML = list.map(c => `
<div class="provider-row"> <div class="provider-row">
<div class="provider-info"> <div>
<span class="provider-name">${esc(c.name)}</span> <span class="provider-name">${esc(c.name)}</span>
<span class="provider-meta">${esc(c.provider)} · ${esc(c.model_default || 'no default')}</span> <span class="provider-meta">${esc(c.provider)} · ${esc(c.model_default || 'no default')}</span>
</div> </div>
@@ -266,39 +310,25 @@ const UI = {
? `<button class="btn-small btn-danger" onclick="deleteProvider('${c.id}','${esc(c.name)}')">Remove</button>` ? `<button class="btn-small btn-danger" onclick="deleteProvider('${c.id}','${esc(c.name)}')">Remove</button>`
: '<span class="badge-global">global</span>'} : '<span class="badge-global">global</span>'}
</div> </div>
</div> </div>`).join('');
`).join('');
} catch (e) { } catch (e) {
el.innerHTML = `<div class="error-hint">Failed: ${esc(e.message)}</div>`; el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
} }
}, },
showProviderForm() { showProviderForm() { document.getElementById('providerAddForm').style.display = ''; },
document.getElementById('providerAddForm').style.display = ''; hideProviderForm() { document.getElementById('providerAddForm').style.display = 'none'; },
document.getElementById('providerName').value = '';
document.getElementById('providerEndpoint').value = '';
document.getElementById('providerApiKey').value = '';
document.getElementById('providerDefaultModel').value = '';
},
hideProviderForm() {
document.getElementById('providerAddForm').style.display = 'none';
},
// ── Admin Modal ────────────────────────── // ── Admin Modal ──────────────────────────
openAdmin() { openAdmin() { document.getElementById('adminModal').classList.add('active'); UI.switchAdminTab('users'); },
document.getElementById('adminModal').classList.add('active'); closeAdmin() { document.getElementById('adminModal').classList.remove('active'); },
UI.switchAdminTab('users');
},
closeAdmin() {
document.getElementById('adminModal').classList.remove('active');
},
async switchAdminTab(tab) { async switchAdminTab(tab) {
document.querySelectorAll('.admin-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab)); document.querySelectorAll('.admin-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
document.querySelectorAll('.admin-tab-content').forEach(c => c.style.display = 'none'); document.querySelectorAll('.admin-tab-content').forEach(c => c.style.display = 'none');
document.getElementById(`admin${tab.charAt(0).toUpperCase() + tab.slice(1)}Tab`).style.display = ''; const panel = document.getElementById(`admin${tab.charAt(0).toUpperCase() + tab.slice(1)}Tab`);
if (panel) panel.style.display = '';
if (tab === 'users') await this.loadAdminUsers(); if (tab === 'users') await this.loadAdminUsers();
if (tab === 'stats') await this.loadAdminStats(); if (tab === 'stats') await this.loadAdminStats();
@@ -314,26 +344,17 @@ const UI = {
const users = resp.data || []; const users = resp.data || [];
el.innerHTML = users.map(u => ` el.innerHTML = users.map(u => `
<div class="admin-user-row"> <div class="admin-user-row">
<div> <div><strong>${esc(u.username)}</strong> <span class="badge-${u.role}">${u.role}</span>
<strong>${esc(u.username)}</strong> <span class="badge-${u.role}">${u.role}</span> ${!u.is_active ? '<span class="badge-inactive">disabled</span>' : ''}</div>
${!u.is_active ? '<span class="badge-inactive">disabled</span>' : ''}
</div>
<div class="admin-user-email">${esc(u.email)}</div> <div class="admin-user-email">${esc(u.email)}</div>
</div> </div>`).join('') || '<div class="empty-hint">No users</div>';
`).join('') || '<div class="empty-hint">No users</div>'; } catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
} catch (e) {
el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
}
}, },
async loadAdminStats() { async loadAdminStats() {
const el = document.getElementById('adminStats'); const el = document.getElementById('adminStats');
try { try { const s = await API.adminGetStats(); el.innerHTML = `<pre>${esc(JSON.stringify(s, null, 2))}</pre>`; }
const stats = await API.adminGetStats(); catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
el.innerHTML = `<pre>${esc(JSON.stringify(stats, null, 2))}</pre>`;
} catch (e) {
el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
}
}, },
async loadAdminProviders() { async loadAdminProviders() {
@@ -343,14 +364,9 @@ const UI = {
const data = await API.adminListGlobalConfigs(); const data = await API.adminListGlobalConfigs();
const list = data.configs || data.data || data || []; const list = data.configs || data.data || data || [];
el.innerHTML = (Array.isArray(list) ? list : []).map(c => ` el.innerHTML = (Array.isArray(list) ? list : []).map(c => `
<div class="provider-row"> <div class="provider-row"><span class="provider-name">${esc(c.name)}</span><span class="provider-meta">${esc(c.provider)}</span></div>
<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>'; `).join('') || '<div class="empty-hint">No global providers</div>';
} catch (e) { } catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
}
}, },
async loadAdminModels() { async loadAdminModels() {
@@ -360,15 +376,9 @@ const UI = {
const data = await API.adminListModels(); const data = await API.adminListModels();
const list = data.models || data.data || data || []; const list = data.models || data.data || data || [];
el.innerHTML = (Array.isArray(list) ? list : []).map(m => ` el.innerHTML = (Array.isArray(list) ? list : []).map(m => `
<div class="admin-model-row"> <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>
<span>${esc(m.model_id || m.id)}</span> `).join('') || '<div class="empty-hint">No models</div>';
<span class="provider-meta">${esc(m.provider_name || '')}</span> } catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
<span>${m.is_enabled ? '✅' : '⬜'}</span>
</div>
`).join('') || '<div class="empty-hint">No models. Fetch from providers first.</div>';
} catch (e) {
el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
}
}, },
// ── Export ──────────────────────────────── // ── Export ────────────────────────────────
@@ -380,28 +390,19 @@ const UI = {
let content, ext, mime; let content, ext, mime;
const safeName = chat.title.replace(/[^a-z0-9]/gi, '_'); const safeName = chat.title.replace(/[^a-z0-9]/gi, '_');
if (format === 'json') { if (format === 'json') { content = JSON.stringify(chat, null, 2); ext = 'json'; mime = 'application/json'; }
content = JSON.stringify(chat, null, 2); else if (format === 'md') {
ext = 'json'; mime = 'application/json'; content = `# ${chat.title}\n\n` + chat.messages.filter(m => m.role !== 'system')
} else if (format === 'md') { .map(m => `## ${m.role === 'user' ? 'You' : 'Assistant'}\n\n${m.content}`).join('\n\n---\n\n');
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'; ext = 'md'; mime = 'text/markdown';
} else { } else {
content = chat.messages.filter(m => m.role !== 'system') content = chat.messages.filter(m => m.role !== 'system').map(m => `[${m.role === 'user' ? 'You' : 'Assistant'}]\n${m.content}`).join('\n\n');
.map(m => `[${m.role === 'user' ? 'You' : 'Assistant'}]\n${m.content}`)
.join('\n\n');
ext = 'txt'; mime = 'text/plain'; ext = 'txt'; mime = 'text/plain';
} }
const blob = new Blob([content], { type: mime }); const blob = new Blob([content], { type: mime });
const a = document.createElement('a'); const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
a.href = URL.createObjectURL(blob); a.download = `${safeName}.${ext}`; a.click(); URL.revokeObjectURL(a.href);
a.download = `${safeName}.${ext}`;
a.click();
URL.revokeObjectURL(a.href);
}, },
// ── Message Actions ────────────────────── // ── Message Actions ──────────────────────
@@ -415,35 +416,35 @@ const UI = {
} }
}, },
// ── Helpers ────────────────────────────── // ── Scroll ───────────────────────────────
_scrollToBottom() { _isNearBottom() {
const el = document.getElementById('chatMessages'); const el = document.getElementById('chatMessages');
el.scrollTop = el.scrollHeight; return el.scrollHeight - el.scrollTop - el.clientHeight < 80;
},
_scrollToBottom(force) {
if (force || this._isNearBottom()) {
const el = document.getElementById('chatMessages');
el.scrollTop = el.scrollHeight;
}
} }
}; };
// ── Formatting ─────────────────────────────── // ── Formatting ───────────────────────────────
/**
* Format message content with markdown rendering.
* Uses marked.js + DOMPurify when available, regex fallback otherwise.
*/
function formatMessage(content) { function formatMessage(content) {
if (!content) return ''; if (!content) return '';
// ── Extract thinking blocks (both <thinking> and <think> tags) ──
const thinkingBlocks = []; const thinkingBlocks = [];
let text = content; let text = content;
// Pull out thinking blocks before any processing
text = text.replace(/<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi, (_, inner) => { text = text.replace(/<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi, (_, inner) => {
const id = 'think-' + Math.random().toString(36).slice(2, 9); const id = 'think-' + Math.random().toString(36).slice(2, 9);
thinkingBlocks.push({ id, content: inner.trim() }); thinkingBlocks.push({ id, content: inner.trim() });
return `THINK_PLACEHOLDER_${id}`; return `THINK_PLACEHOLDER_${id}`;
}); });
// ── Render markdown ──
let html; let html;
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') { if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
html = _formatMarked(text); html = _formatMarked(text);
@@ -451,14 +452,11 @@ function formatMessage(content) {
html = _formatBasic(text); html = _formatBasic(text);
} }
// ── Restore thinking blocks ──
for (const b of thinkingBlocks) { for (const b of thinkingBlocks) {
const inner = esc(b.content).replace(/\n/g, '<br>'); const inner = esc(b.content).replace(/\n/g, '<br>');
const thinkHTML = App.settings.showThinking const thinkHTML = App.settings.showThinking
? `<details class="thinking-block"><summary>💭 Thinking</summary><div class="thinking-content">${inner}</div></details>` ? `<details class="thinking-block"><summary>💭 Thinking</summary><div class="thinking-content">${inner}</div></details>`
: ''; : '';
// marked may wrap placeholder in <p> tags
html = html.replace(new RegExp(`<p>\\s*THINK_PLACEHOLDER_${b.id}\\s*</p>`, 'g'), thinkHTML); html = html.replace(new RegExp(`<p>\\s*THINK_PLACEHOLDER_${b.id}\\s*</p>`, 'g'), thinkHTML);
html = html.replace(`THINK_PLACEHOLDER_${b.id}`, thinkHTML); html = html.replace(`THINK_PLACEHOLDER_${b.id}`, thinkHTML);
} }
@@ -466,22 +464,10 @@ function formatMessage(content) {
return html; return html;
} }
/**
* Full markdown rendering via marked.js + DOMPurify
*/
function _formatMarked(text) { function _formatMarked(text) {
const rendered = marked.parse(text, { const rendered = marked.parse(text, { breaks: true, gfm: true });
breaks: true, let html = DOMPurify.sanitize(rendered, { ADD_TAGS: ['details', 'summary'], ADD_ATTR: ['id', 'class', 'onclick'] });
gfm: true
});
// Sanitize but allow code structure
let html = DOMPurify.sanitize(rendered, {
ADD_TAGS: ['details', 'summary'],
ADD_ATTR: ['id', 'class', 'onclick']
});
// Inject copy buttons into <pre><code> blocks
html = html.replace(/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g, (_, attrs, code) => { html = html.replace(/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g, (_, attrs, code) => {
const codeId = 'code-' + Math.random().toString(36).slice(2, 9); const codeId = 'code-' + Math.random().toString(36).slice(2, 9);
return `<pre><code id="${codeId}"${attrs}>${code}</code><button class="copy-code-btn" onclick="navigator.clipboard.writeText(document.getElementById('${codeId}').textContent).then(()=>UI.toast('Copied','success'))">Copy</button></pre>`; return `<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>`;
@@ -490,18 +476,12 @@ function _formatMarked(text) {
return html; return html;
} }
/**
* Regex fallback when marked/DOMPurify unavailable
*/
function _formatBasic(text) { function _formatBasic(text) {
let html = esc(text); let html = esc(text);
// Code blocks with copy button
html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => { html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => {
const id = 'code-' + Math.random().toString(36).slice(2, 9); 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>`; 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, '<code>$1</code>');
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>'); html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>'); html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
@@ -515,3 +495,18 @@ function esc(s) {
d.textContent = s; d.textContent = s;
return d.innerHTML; return d.innerHTML;
} }
// ── 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' });
}