diff --git a/VERSION b/VERSION index 4b9fcbe..cb0c939 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.1 +0.5.2 diff --git a/src/css/styles.css b/src/css/styles.css index 7c543f1..1afb765 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -1,398 +1,529 @@ /* ========================================== - Chat Switchboard – Styles (v0.5.0) + Chat Switchboard v0.5.2 + Clean utility layout · Open WebUI inspired ========================================== */ :root { - --bg: #0f0f13; - --bg-secondary: #1a1a24; - --bg-tertiary: #252533; - --border: #2a2a3a; - --text: #e4e4ef; - --text-secondary: #8888a0; - --accent: #6c9fff; - --accent-hover: #8ab4ff; - --danger: #e74c3c; - --success: #2ecc71; - --warning: #f39c12; - --radius: 8px; - --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - --mono: 'SF Mono', 'Fira Code', Consolas, monospace; + --bg: #0e0e10; + --bg-surface: #18181b; + --bg-raised: #222227; + --bg-hover: #2a2a30; + --border: #2e2e35; + --border-light: #3a3a42; + --text: #e8e8ed; + --text-2: #9898a8; + --text-3: #6b6b7b; + --accent: #6c9fff; + --accent-hover: #84b0ff; + --accent-dim: rgba(108,159,255,0.12); + --danger: #ef4444; + --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; } -body { font-family: var(--font); background: var(--bg); color: var(--text); height: 100vh; overflow: hidden; } -a { color: var(--accent); } +*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } +html, body { height: 100%; } +body { font-family: var(--font); background: var(--bg); color: var(--text); overflow: hidden; font-size: 14px; } +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; } - -.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; } +.app { display: flex; height: 100vh; } /* ── Sidebar ─────────────────────────────── */ .sidebar { - width: 260px; background: var(--bg-secondary); border-right: 1px solid var(--border); - display: flex; flex-direction: column; padding: 0.75rem; gap: 0.5rem; flex-shrink: 0; - overflow-y: auto; + width: var(--sidebar-w); background: var(--bg-surface); + border-right: 1px solid var(--border); + display: flex; flex-direction: column; + transition: width var(--transition); + overflow: hidden; flex-shrink: 0; } -.new-chat-btn { width: 100%; } -.chat-history { flex: 1; overflow-y: auto; } +.sidebar.collapsed { width: var(--sidebar-rail); } + +.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 { - display: flex; align-items: center; padding: 0.5rem 0.75rem; border-radius: var(--radius); - cursor: pointer; font-size: 0.85rem; gap: 0.5rem; transition: background 0.15s; + display: flex; align-items: center; gap: 8px; + padding: 7px 10px; border-radius: var(--radius); + cursor: pointer; font-size: 13px; color: var(--text-2); + transition: background var(--transition); position: relative; + overflow: hidden; } -.chat-item:hover { background: var(--bg-tertiary); } -.chat-item.active { background: var(--bg-tertiary); border-left: 2px solid var(--accent); } +.chat-item:hover { background: var(--bg-hover); color: var(--text); } +.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-time { + font-size: 10px; color: var(--text-3); flex-shrink: 0; + white-space: nowrap; +} .chat-item-delete { - opacity: 0; background: none; border: none; color: var(--text-secondary); - cursor: pointer; font-size: 0.75rem; padding: 2px 4px; transition: opacity 0.15s; + opacity: 0; position: absolute; right: 6px; background: var(--bg-raised); + 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-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 { flex: 1; display: flex; flex-direction: column; min-width: 0; overflow: hidden; } -.chat-messages { flex: 1; overflow-y: auto; padding: 1rem; } +.chat-area { flex: 1; display: flex; flex-direction: column; min-width: 0; background: var(--bg); } -.message { margin-bottom: 1rem; } -.message-inner { display: flex; gap: 0.75rem; max-width: 800px; margin: 0 auto; } -.message-avatar { font-size: 1.25rem; 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; +.model-bar { + display: flex; align-items: center; gap: 6px; + padding: 8px 16px; flex-shrink: 0; } -.message:hover .message-copy { opacity: 0.7; } -.message-text { font-size: 0.9rem; line-height: 1.6; word-wrap: break-word; } - -/* ── Markdown Rendered Content ────────────── */ - -.message-text p { margin: 0.4em 0; } -.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; +.model-bar select { + background: transparent; border: 1px solid transparent; + color: var(--text); font-size: 14px; font-weight: 500; + padding: 4px 8px; border-radius: var(--radius); cursor: pointer; + font-family: var(--font); max-width: 300px; + transition: border-color var(--transition); } -.message-text code { font-family: var(--mono); font-size: 0.85em; background: var(--bg-tertiary); padding: 0.15em 0.35em; border-radius: 3px; } -.message-text pre code { font-size: inherit; background: none; padding: 0; } +.model-bar select:hover { border-color: var(--border); } +.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 { - position: absolute; top: 4px; right: 4px; background: var(--bg-secondary); - border: 1px solid var(--border); color: var(--text-secondary); border-radius: 4px; - padding: 2px 8px; font-size: 0.7rem; cursor: pointer; opacity: 0; transition: opacity 0.15s; + position: absolute; top: 6px; right: 6px; background: var(--bg-raised); + border: 1px solid var(--border); color: var(--text-3); border-radius: 4px; + padding: 2px 10px; font-size: 11px; cursor: pointer; + opacity: 0; transition: opacity var(--transition); } -.message-text pre:hover .copy-code-btn { opacity: 1; } -.copy-code-btn:hover { background: var(--border); color: var(--text); } +.msg-text pre:hover .copy-code-btn { opacity: 1; } +.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; } -.message-text li { margin: 0.2em 0; } -.message-text li > p { margin: 0.2em 0; } +.msg-text ul, .msg-text ol { margin: 0.4em 0; padding-left: 1.5em; } +.msg-text li { margin: 0.2em 0; } +.msg-text li > p { margin: 0.2em 0; } -.message-text blockquote { - border-left: 3px solid var(--accent); padding: 0.25em 0.75em; margin: 0.4em 0; - color: var(--text-secondary); background: var(--bg-tertiary); border-radius: 0 4px 4px 0; +.msg-text blockquote { + border-left: 3px solid var(--accent); padding: 0.25em 0.75em; margin: 0.5em 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; } -.message-text a:hover { text-decoration: underline; } +.msg-text hr { border: none; border-top: 1px solid var(--border); margin: 0.75em 0; } -.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; } -.message-text th, .message-text td { border: 1px solid var(--border); padding: 0.35em 0.6em; text-align: left; } -.message-text th { background: var(--bg-tertiary); font-weight: 600; } +.msg-text h1, .msg-text h2, .msg-text h3, .msg-text h4 { margin: 0.75em 0 0.3em; font-weight: 600; } +.msg-text h1 { font-size: 1.4em; } +.msg-text h2 { font-size: 1.2em; } +.msg-text h3 { font-size: 1.05em; } -.message-text img { max-width: 100%; border-radius: 4px; } - -.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 ─────────────────────── */ +.msg-text a { color: var(--accent); } +.msg-text img { max-width: 100%; border-radius: var(--radius); } +/* Thinking blocks */ .thinking-block { - border: 1px solid rgba(108, 159, 255, 0.15); border-radius: var(--radius); - margin: 0.5rem 0; background: rgba(108, 159, 255, 0.04); + border: 1px solid rgba(108,159,255,0.12); border-radius: var(--radius); + margin: 0.5rem 0; background: rgba(108,159,255,0.03); } .thinking-block summary { - padding: 0.4rem 0.75rem; cursor: pointer; font-size: 0.8rem; - color: var(--text-secondary); user-select: none; + padding: 0.4rem 0.75rem; cursor: pointer; font-size: 12px; + color: var(--text-3); user-select: none; + transition: color var(--transition); } -.thinking-block summary:hover { color: var(--text); } -.thinking-block[open] summary { border-bottom: 1px solid rgba(108, 159, 255, 0.1); } +.thinking-block summary:hover { color: var(--text-2); } +.thinking-block[open] summary { border-bottom: 1px solid rgba(108,159,255,0.08); } .thinking-content { - padding: 0.5rem 0.75rem; font-size: 0.8rem; color: var(--text-secondary); - max-height: 300px; overflow-y: auto; line-height: 1.5; - background: rgba(108, 159, 255, 0.02); + padding: 0.5rem 0.75rem; font-size: 12px; color: var(--text-3); + max-height: 300px; overflow-y: auto; line-height: 1.6; } -.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: var(--text-secondary); } -.empty-icon { font-size: 3rem; margin-bottom: 1rem; } -.empty-hint { color: var(--text-secondary); font-size: 0.8rem; text-align: center; padding: 1rem; } -.error-hint { color: var(--danger); font-size: 0.8rem; padding: 0.5rem; } -.loading { color: var(--text-secondary); font-size: 0.8rem; padding: 0.5rem; } +/* Empty state */ +.empty-state { + display: flex; flex-direction: column; align-items: center; + justify-content: center; height: 100%; color: var(--text-3); gap: 0.25rem; +} +.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 { - border-top: 1px solid var(--border); padding: 0.5rem 1rem 0.75rem; - background: var(--bg-secondary); flex-shrink: 0; +.input-area { padding: 0 1rem 1rem; flex-shrink: 0; } +.input-wrap { + 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; } -.model-selector { display: flex; align-items: center; gap: 0.5rem; font-size: 0.8rem; } -.model-selector label { color: var(--text-secondary); } -.model-selector select { - background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text); - padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.8rem; max-width: 300px; +.input-wrap:focus-within { border-color: var(--border-light); } + +.input-wrap textarea { + flex: 1; resize: none; background: none; border: none; + color: var(--text); padding: 12px 0 12px 16px; + 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-row textarea { - 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); - font-family: var(--font); font-size: 0.9rem; max-height: 200px; line-height: 1.5; +.input-wrap textarea:focus { outline: none; } +.input-wrap textarea::placeholder { color: var(--text-3); } + +.input-actions { display: flex; align-items: center; gap: 2px; padding: 6px 8px; } + +.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); } -.input-actions { display: flex; gap: 0.25rem; align-items: center; } +.action-btn:hover { background: var(--bg-hover); color: var(--text); } + +.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 ─────────────────────────────── */ button { font-family: var(--font); cursor: pointer; } .btn-primary { - background: var(--accent); color: #fff; border: none; padding: 0.5rem 1rem; - border-radius: var(--radius); font-weight: 600; font-size: 0.85rem; - transition: background 0.15s; + background: var(--accent); color: #fff; border: none; + padding: 8px 16px; border-radius: var(--radius); + font-weight: 500; font-size: 13px; + transition: background var(--transition); } .btn-primary:hover { background: var(--accent-hover); } .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } .btn-full { width: 100%; } - -.btn-small { font-size: 0.8rem; padding: 0.35rem 0.75rem; } -.btn-danger { background: var(--danger); color: #fff; border: none; border-radius: 4px; } - -.btn-icon { - background: none; border: none; color: var(--text-secondary); font-size: 1rem; - padding: 0.25rem; transition: color 0.15s; +.btn-small { + background: var(--bg-raised); border: 1px solid var(--border); + color: var(--text-2); padding: 6px 12px; border-radius: var(--radius); + font-size: 12px; transition: all var(--transition); } -.btn-icon:hover { color: var(--text); } - -.btn-send { - 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); } +.btn-small:hover { background: var(--bg-hover); color: var(--text); } +.btn-small.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; } +.btn-danger { background: var(--danger); color: #fff; border: none; border-radius: var(--radius); } /* ── Auth Splash ─────────────────────────── */ .splash { - display: flex; align-items: center; justify-content: center; height: 100vh; - background: var(--bg); + display: flex; align-items: center; justify-content: center; + height: 100vh; background: var(--bg); } .splash-card { - background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 12px; - padding: 2rem; width: 380px; max-width: 90vw; + background: var(--bg-surface); border: 1px solid var(--border); + border-radius: var(--radius-lg); padding: 2rem; + width: 380px; max-width: 90vw; } .splash-brand { text-align: center; margin-bottom: 1.5rem; } .splash-logo { font-size: 3rem; margin-bottom: 0.5rem; } -.splash-brand h1 { font-size: 1.25rem; } -.splash-brand p { color: var(--text-secondary); font-size: 0.85rem; } -.splash-error { color: var(--danger); font-size: 0.8rem; text-align: center; margin-top: 0.5rem; } +.splash-brand h1 { font-size: 1.2rem; font-weight: 600; } +.splash-brand p { color: var(--text-2); font-size: 0.85rem; } +.splash-error { color: var(--danger); font-size: 12px; text-align: center; margin-top: 0.5rem; } -.auth-tabs { display: flex; 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 { - flex: 1; background: none; border: none; color: var(--text-secondary); padding: 0.5rem; - cursor: pointer; border-bottom: 2px solid transparent; font-size: 0.9rem; + flex: 1; background: none; border: none; color: var(--text-3); + 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-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; } /* ── Forms ────────────────────────────────── */ .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 { - width: 100%; background: var(--bg-tertiary); border: 1px solid var(--border); - color: var(--text); padding: 0.5rem 0.75rem; border-radius: var(--radius); - font-family: var(--font); font-size: 0.85rem; + width: 100%; background: var(--bg-raised); border: 1px solid var(--border); + color: var(--text); padding: 8px 12px; border-radius: var(--radius); + font-family: var(--font); font-size: 13px; + transition: border-color var(--transition); } .form-group input:focus, .form-group select:focus, .form-group textarea:focus { outline: none; border-color: var(--accent); } .form-row { display: flex; gap: 0.75rem; } .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-overlay { - position: fixed; inset: 0; background: rgba(0,0,0,0.7); display: none; - align-items: center; justify-content: center; z-index: 1000; + position: fixed; inset: 0; background: rgba(0,0,0,0.6); + display: none; align-items: center; justify-content: center; + z-index: 1000; backdrop-filter: blur(2px); } .modal-overlay.active { display: flex; } .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; - animation: modalIn 0.2s ease; + animation: modal-in 0.15s ease; } -.modal-wide { max-width: 750px; } -@keyframes modalIn { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } } +.modal-wide { max-width: 700px; } +@keyframes modal-in { from { opacity: 0; transform: translateY(-8px); } } .modal-header { 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:last-child { border-bottom: none; } -.settings-section h3 { font-size: 0.9rem; margin-bottom: 0.75rem; color: var(--accent); } - -/* ── Providers ───────────────────────────── */ +.settings-section:last-child { border-bottom: none; margin-bottom: 0; } +.settings-section h3 { font-size: 13px; font-weight: 600; color: var(--text-2); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; } +/* Providers */ .provider-row { 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: 600; font-size: 0.85rem; } -.provider-meta { font-size: 0.75rem; color: var(--text-secondary); margin-left: 0.5rem; } -.provider-actions { display: flex; align-items: center; gap: 0.5rem; } -.badge-global { font-size: 0.7rem; color: var(--text-secondary); background: var(--bg-tertiary); padding: 2px 6px; border-radius: 4px; } +.provider-name { font-weight: 500; font-size: 13px; } +.provider-meta { font-size: 11px; color: var(--text-3); margin-left: 8px; } +.provider-actions { display: flex; align-items: center; gap: 8px; } +.badge-global { font-size: 10px; color: var(--text-3); background: var(--bg-raised); padding: 2px 8px; border-radius: 4px; } -/* ── Admin ────────────────────────────────── */ - -.admin-tabs { - display: flex; border-bottom: 1px solid var(--border); padding: 0 1rem; - background: var(--bg-tertiary); -} +/* Admin tabs */ +.admin-tabs { display: flex; border-bottom: 1px solid var(--border); padding: 0 16px; background: var(--bg-raised); } .admin-tab { - padding: 0.5rem 0.75rem; background: none; border: none; color: var(--text-secondary); - cursor: pointer; font-size: 0.8rem; border-bottom: 2px solid transparent; + padding: 8px 12px; background: none; border: none; color: var(--text-3); + cursor: pointer; font-size: 12px; border-bottom: 2px solid transparent; } .admin-tab.active { color: var(--accent); border-bottom-color: var(--accent); } -.admin-user-row { - padding: 0.5rem 0; border-bottom: 1px solid var(--border); font-size: 0.85rem; -} -.admin-user-email { font-size: 0.75rem; color: var(--text-secondary); } -.admin-model-row { - display: flex; align-items: center; gap: 1rem; padding: 0.4rem 0; - border-bottom: 1px solid var(--border); font-size: 0.8rem; -} - -.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; } +.admin-user-row { padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 13px; } +.admin-user-email { font-size: 11px; color: var(--text-3); } +.admin-model-row { display: flex; align-items: center; gap: 12px; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 12px; } +.badge-admin { background: var(--accent); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; } +.badge-user { background: var(--bg-raised); color: var(--text-3); font-size: 10px; padding: 1px 8px; border-radius: 4px; } +.badge-inactive { background: var(--danger); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; } +.loading { color: var(--text-3); font-size: 12px; padding: 0.5rem; } +.error-hint { color: var(--danger); font-size: 12px; padding: 0.5rem; } +.empty-hint { color: var(--text-3); font-size: 12px; text-align: center; padding: 1rem; } /* ── Toast ────────────────────────────────── */ .toast-container { position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%); z-index: 10000; display: flex; flex-direction: column; gap: 0.5rem; } .toast { - background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); - padding: 0.5rem 1rem; font-size: 0.85rem; display: flex; align-items: center; gap: 0.5rem; - animation: toastIn 0.3s ease; + background: var(--bg-surface); border: 1px solid var(--border); + border-radius: var(--radius); padding: 8px 16px; font-size: 13px; + 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.warning { border-color: var(--warning); } .toast.success { border-color: var(--success); } -.toast button { background: none; border: none; color: var(--text-secondary); cursor: pointer; } -@keyframes toastIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } +.toast button { background: none; border: none; color: var(--text-3); cursor: pointer; } +@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } } /* ── Debug Modal ─────────────────────────── */ .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-tab { - 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-tabs { display: flex; border-bottom: 1px solid var(--border); padding: 0 16px; background: var(--bg-raised); } +.debug-tab { padding: 8px 12px; background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 12px; border-bottom: 2px solid transparent; } .debug-tab.active { color: var(--accent); border-bottom-color: var(--accent); } .debug-modal-body { flex: 1; overflow: hidden; padding: 0 !important; } .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-check { display: flex; align-items: center; gap: 0.3rem; color: var(--text-secondary); 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-entry { padding: 1px 0.5rem; border-bottom: 1px solid rgba(128,128,128,0.08); white-space: pre-wrap; word-break: break-all; } -.debug-time { color: var(--text-secondary); opacity: 0.6; font-size: 0.68rem; } +.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: 4px; color: var(--text-3); cursor: pointer; } +.debug-content { flex: 1; overflow-y: auto; padding: 6px; font-family: var(--mono); font-size: 11px; line-height: 1.5; } +.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-3); opacity: 0.6; font-size: 10px; } .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 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-url { color: var(--text); word-break: break-all; } -.debug-net-detail { padding: 0.5rem 1rem; font-size: 0.72rem; } -.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-footer { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 1rem; border-top: 1px solid var(--border); } +.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: 6px; background: rgba(0,0,0,0.2); border-radius: 4px; margin: 4px 0; } +.debug-footer { display: flex; align-items: center; } /* ── Responsive ──────────────────────────── */ @media (max-width: 768px) { - .sidebar { width: 220px; } - .header-title { font-size: 0.9rem; } -} -@media (max-width: 640px) { - .sidebar { display: none; } + .sidebar { position: fixed; z-index: 100; height: 100%; } + .sidebar.collapsed { width: 0; border: none; } + .msg-inner { padding: 0 1rem; } } diff --git a/src/index.html b/src/index.html index c414102..68cde0e 100644 --- a/src/index.html +++ b/src/index.html @@ -7,65 +7,89 @@ - + - -
-
- - -
-
- - -
+
+
@@ -118,111 +125,51 @@ - + @@ -297,17 +223,13 @@
- - - + + + - - - - + + + + diff --git a/src/js/app.js b/src/js/app.js index 08c4c9c..476e532 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -1,5 +1,5 @@ // ========================================== -// Chat Switchboard – Application (v0.5.0) +// Chat Switchboard – Application (v0.5.2) // ========================================== const App = { @@ -25,7 +25,6 @@ async function init() { console.log('🔀 Chat Switchboard initializing...'); API.loadTokens(); - // Always check backend health first let health = null; try { health = await API.health(); @@ -37,20 +36,13 @@ async function init() { return; } - // If we have tokens, validate them if (API.isAuthed) { try { await API.getProfile(); console.log('✅ Session valid for', API.user?.username); } catch (e) { - if (e.status === 401) { - console.warn('⚠️ Session expired, clearing'); - 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'); - } + console.warn('⚠️ Session expired, clearing'); + API.clearTokens(); } } @@ -63,12 +55,13 @@ async function init() { async function startApp() { hideSplash(); + UI.restoreSidebar(); await loadSettings(); await loadChats(); await fetchModels(); UI.renderChatList(); UI.updateModelSelector(); - UI.updateConnectionStatus(); + UI.updateUser(); UI.showAdminButton(API.isAdmin); initListeners(); console.log('✅ Chat Switchboard ready'); @@ -85,9 +78,7 @@ async function loadSettings() { if (remote.max_tokens) App.settings.maxTokens = remote.max_tokens; if (remote.temperature !== undefined) App.settings.temperature = remote.temperature; } - } catch (e) { - console.warn('Settings load failed:', e.message); - } + } catch (e) { console.warn('Settings load failed:', e.message); } } async function saveSettings() { @@ -98,9 +89,7 @@ async function saveSettings() { max_tokens: App.settings.maxTokens, temperature: App.settings.temperature, }); - } catch (e) { - console.warn('Settings sync failed:', e.message); - } + } catch (e) { console.warn('Settings sync failed:', e.message); } } // ── Models ─────────────────────────────────── @@ -114,7 +103,6 @@ async function fetchModels() { configId: m.config_id || null })); - // Fallback to raw aggregation if no curated models if (App.models.length === 0) { const raw = await API.listAllModels(); App.models = (raw.models || []).map(m => ({ @@ -126,9 +114,7 @@ async function fetchModels() { App.models.sort((a, b) => a.id.localeCompare(b.id)); console.log(`📋 Loaded ${App.models.length} models`); - } catch (e) { - console.warn('Model fetch failed:', e.message); - } + } catch (e) { console.warn('Model fetch failed:', e.message); } UI.updateModelSelector(); } @@ -142,7 +128,7 @@ async function loadChats() { title: c.title, model: c.model || '', messageCount: c.message_count || 0, - messages: [], // lazy loaded + messages: [], updatedAt: c.updated_at })); } catch (e) { @@ -158,15 +144,11 @@ async function selectChat(chatId) { const chat = App.chats.find(c => c.id === chatId); if (!chat) return; - // Lazy-load messages if (chat.messages.length === 0 && chat.messageCount > 0) { try { const resp = await API.listMessages(chatId); chat.messages = (resp.data || []).map(m => ({ - role: m.role, - content: m.content, - model: m.model || '', - timestamp: m.created_at + role: m.role, content: m.content, model: m.model || '', timestamp: m.created_at })); } catch (e) { console.error('Failed to load messages:', e.message); @@ -182,6 +164,7 @@ async function newChat() { App.currentChatId = null; UI.renderChatList(); UI.showEmptyState(); + UI.showRegenerate(false); document.getElementById('messageInput').focus(); } @@ -190,13 +173,9 @@ async function deleteChat(chatId) { try { await API.deleteChat(chatId); App.chats = App.chats.filter(c => c.id !== chatId); - if (App.currentChatId === chatId) { - newChat(); - } + if (App.currentChatId === chatId) newChat(); UI.renderChatList(); - } catch (e) { - UI.toast('Failed to delete: ' + e.message, 'error'); - } + } catch (e) { UI.toast('Failed to delete: ' + e.message, 'error'); } } // ── Send Message ───────────────────────────── @@ -211,69 +190,45 @@ async function sendMessage() { const model = document.getElementById('modelSelect').value || App.settings.model; - // Create chat on first message if (!App.currentChatId) { try { const title = text.slice(0, 50) + (text.length > 50 ? '...' : ''); const resp = await API.createChat(title, model, App.settings.systemPrompt); - const chat = { - id: resp.id, - title: resp.title, - model: model, - messages: [], - messageCount: 0, - updatedAt: resp.updated_at - }; + const chat = { id: resp.id, title: resp.title, model, messages: [], messageCount: 0, updatedAt: resp.updated_at }; App.chats.unshift(chat); App.currentChatId = chat.id; UI.renderChatList(); - } catch (e) { - UI.toast('Failed to create chat: ' + e.message, 'error'); - return; - } + } catch (e) { 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); if (!chat) return; chat.messages.push({ role: 'user', content: text, timestamp: new Date().toISOString() }); UI.renderMessages(chat.messages); - // Stream the completion App.isGenerating = true; App.abortController = new AbortController(); UI.setGenerating(true); try { - const resp = await API.streamCompletion( - App.currentChatId, text, model, App.abortController.signal - ); - + const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal); const assistantContent = await UI.streamResponse(resp, chat.messages); - - chat.messages.push({ - role: 'assistant', - content: assistantContent, - model: model, - timestamp: new Date().toISOString() - }); + chat.messages.push({ role: 'assistant', content: assistantContent, model, timestamp: new Date().toISOString() }); chat.messageCount = chat.messages.length; UI.showRegenerate(true); - } catch (e) { if (e.name === 'AbortError') { UI.toast('Generation stopped', 'warning'); } else { console.error('Completion error:', e); - // Distinguish provider-side auth errors from session issues const msg = e.message || ''; 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')) { - 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')) { - UI.toast('No provider configured — add one in Settings → Providers', 'error'); + UI.toast('No provider configured — add one in Settings', 'error'); } else { UI.toast(msg, 'error'); } @@ -285,39 +240,27 @@ async function sendMessage() { } } -function stopGeneration() { - if (App.abortController) { - App.abortController.abort(); - } -} +function stopGeneration() { if (App.abortController) App.abortController.abort(); } async function regenerate() { if (App.isGenerating || !App.currentChatId) return; const chat = App.chats.find(c => c.id === App.currentChatId); if (!chat || chat.messages.length === 0) return; - // 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; UI.renderMessages(chat.messages); - - // Re-send the last user message const lastUser = chat.messages[chat.messages.length - 1]; if (lastUser.role !== 'user') return; const model = document.getElementById('modelSelect').value || App.settings.model; - App.isGenerating = true; App.abortController = new AbortController(); UI.setGenerating(true); try { - const resp = await API.streamCompletion( - App.currentChatId, lastUser.content, model, App.abortController.signal - ); + const resp = await API.streamCompletion(App.currentChatId, lastUser.content, model, App.abortController.signal); const content = await UI.streamResponse(resp, chat.messages); chat.messages.push({ role: 'assistant', content, model, timestamp: new Date().toISOString() }); UI.showRegenerate(true); @@ -325,10 +268,8 @@ async function regenerate() { if (e.name !== 'AbortError') { const msg = e.message || ''; if (msg.includes('provider error') && msg.includes('401')) { - UI.toast('Provider API key rejected — check Settings → Providers', 'error'); - } else { - UI.toast(msg, 'error'); - } + UI.toast('Provider API key rejected — check Settings', 'error'); + } else { UI.toast(msg, 'error'); } } } finally { App.isGenerating = false; @@ -342,7 +283,6 @@ async function regenerate() { function showSplash(health) { document.getElementById('splashGate').style.display = 'flex'; document.getElementById('appContainer').style.display = 'none'; - if (health && health.registration_enabled === false) { document.getElementById('authTabRegister').style.display = 'none'; } @@ -357,16 +297,10 @@ async function handleLogin() { const login = document.getElementById('authLogin').value.trim(); const password = document.getElementById('authPassword').value; if (!login || !password) return setAuthError('Fill in all fields'); - setAuthLoading(true); - try { - await API.login(login, password); - await startApp(); - } catch (e) { - setAuthError(e.message); - } finally { - setAuthLoading(false); - } + try { await API.login(login, password); await startApp(); } + catch (e) { setAuthError(e.message); } + finally { setAuthLoading(false); } } async function handleRegister() { @@ -375,16 +309,10 @@ async function handleRegister() { const password = document.getElementById('authRegPassword').value; if (!username || !email || !password) return setAuthError('Fill in all fields'); if (password.length < 8) return setAuthError('Password must be at least 8 characters'); - setAuthLoading(true); - try { - await API.register(username, email, password); - await startApp(); - } catch (e) { - setAuthError(e.message); - } finally { - setAuthLoading(false); - } + try { await API.register(username, email, password); await startApp(); } + catch (e) { setAuthError(e.message); } + finally { setAuthLoading(false); } } async function handleLogout() { @@ -409,7 +337,7 @@ function setAuthError(msg) { document.getElementById('authError').textContent = function setAuthLoading(on) { document.querySelectorAll('#authLoginBtn, #authRegisterBtn').forEach(btn => { 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; _listenersInit = true; + // Sidebar + document.getElementById('sidebarToggle').addEventListener('click', UI.toggleSidebar); document.getElementById('newChatBtn').addEventListener('click', newChat); + + // User flyout + document.getElementById('userMenuBtn').addEventListener('click', (e) => { + e.stopPropagation(); + UI.toggleUserMenu(); + }); + document.addEventListener('click', (e) => { + if (!e.target.closest('.sidebar-bottom')) UI.closeUserMenu(); + }); + + // Flyout items + document.getElementById('menuSettings').addEventListener('click', () => { UI.closeUserMenu(); UI.openSettings(); }); + document.getElementById('menuAdmin').addEventListener('click', () => { UI.closeUserMenu(); UI.openAdmin(); }); + document.getElementById('menuDebug').addEventListener('click', () => { UI.closeUserMenu(); openDebugModal(); }); + document.getElementById('menuSignout').addEventListener('click', () => { UI.closeUserMenu(); handleLogout(); }); + + // Chat actions document.getElementById('sendBtn').addEventListener('click', sendMessage); document.getElementById('stopBtn').addEventListener('click', stopGeneration); document.getElementById('regenerateBtn').addEventListener('click', regenerate); - // Settings - document.getElementById('settingsBtn').addEventListener('click', UI.openSettings); - document.getElementById('settingsCloseBtn').addEventListener('click', UI.closeSettings); - document.getElementById('settingsSaveBtn').addEventListener('click', handleSaveSettings); - document.getElementById('fetchModelsBtn').addEventListener('click', async () => { - await fetchModels(); - UI.toast(`Loaded ${App.models.length} models`, 'success'); - }); - - // Profile - document.getElementById('profileChangePwBtn').addEventListener('click', () => { - document.getElementById('profileChangePwForm').style.display = ''; - }); - document.getElementById('profileSavePwBtn').addEventListener('click', handleChangePassword); - - // Providers - document.getElementById('providerShowAddBtn').addEventListener('click', UI.showProviderForm); - document.getElementById('providerCancelBtn').addEventListener('click', UI.hideProviderForm); - document.getElementById('providerCreateBtn').addEventListener('click', handleCreateProvider); - - // Admin - document.getElementById('adminBtn').addEventListener('click', UI.openAdmin); - document.getElementById('adminCloseBtn').addEventListener('click', UI.closeAdmin); - document.querySelectorAll('.admin-tab').forEach(tab => { - tab.addEventListener('click', () => UI.switchAdminTab(tab.dataset.tab)); - }); - document.getElementById('adminSaveSettings').addEventListener('click', handleSaveAdminSettings); - // Model selector document.getElementById('modelSelect').addEventListener('change', function() { if (this.value) { App.settings.model = this.value; saveSettings(); } }); + document.getElementById('fetchModelsBtn').addEventListener('click', async () => { + await fetchModels(); + UI.toast(`Loaded ${App.models.length} models`, 'success'); + }); + + // Settings modal + document.getElementById('settingsCloseBtn').addEventListener('click', UI.closeSettings); + document.getElementById('settingsSaveBtn').addEventListener('click', handleSaveSettings); + document.getElementById('profileChangePwBtn').addEventListener('click', () => { + document.getElementById('profileChangePwForm').style.display = ''; + }); + document.getElementById('profileSavePwBtn').addEventListener('click', handleChangePassword); + document.getElementById('providerShowAddBtn').addEventListener('click', UI.showProviderForm); + document.getElementById('providerCancelBtn').addEventListener('click', UI.hideProviderForm); + document.getElementById('providerCreateBtn').addEventListener('click', handleCreateProvider); + + // Admin modal + document.getElementById('adminCloseBtn').addEventListener('click', UI.closeAdmin); + document.querySelectorAll('.admin-tab').forEach(tab => { + tab.addEventListener('click', () => UI.switchAdminTab(tab.dataset.tab)); + }); + document.getElementById('adminSaveSettings').addEventListener('click', handleSaveAdminSettings); // Input const input = document.getElementById('messageInput'); @@ -468,19 +409,7 @@ function initListeners() { this.style.height = Math.min(this.scrollHeight, 200) + 'px'; }); - // Connection status - document.getElementById('connectionStatus').addEventListener('click', handleLogout); - - // Export dropdown - document.getElementById('exportBtn').addEventListener('click', (e) => { - e.stopPropagation(); - document.getElementById('exportDropdown').classList.toggle('show'); - }); - document.addEventListener('click', () => { - document.getElementById('exportDropdown').classList.remove('show'); - }); - - // Close modals on overlay + // Close modals on overlay click document.querySelectorAll('.modal-overlay').forEach(overlay => { overlay.addEventListener('click', (e) => { 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.temperature = parseFloat(document.getElementById('settingsTemp').value) || 0.7; App.settings.showThinking = document.getElementById('settingsThinking').checked; - saveSettings(); UI.updateModelSelector(); UI.closeSettings(); @@ -513,16 +441,13 @@ async function handleChangePassword() { const pw = document.getElementById('profileNewPw').value; if (!cur || !pw) return UI.toast('Fill in both fields', 'warning'); if (pw.length < 8) return UI.toast('Min 8 characters', 'warning'); - try { await API.changePassword(cur, pw); UI.toast('Password updated', 'success'); document.getElementById('profileChangePwForm').style.display = 'none'; document.getElementById('profileCurrentPw').value = ''; document.getElementById('profileNewPw').value = ''; - } catch (e) { - UI.toast(e.message, 'error'); - } + } catch (e) { UI.toast(e.message, 'error'); } } async function handleCreateProvider() { @@ -531,18 +456,14 @@ async function handleCreateProvider() { const endpoint = document.getElementById('providerEndpoint').value.trim(); const apiKey = document.getElementById('providerApiKey').value.trim(); const model = document.getElementById('providerDefaultModel').value.trim(); - if (!name || !endpoint || !apiKey) return UI.toast('Fill in required fields', 'warning'); - try { await API.createConfig(name, provider, endpoint, apiKey, model); UI.toast('Provider added', 'success'); UI.hideProviderForm(); UI.loadProviderList(); fetchModels(); - } catch (e) { - UI.toast(e.message, 'error'); - } + } catch (e) { UI.toast(e.message, 'error'); } } async function deleteProvider(id, name) { @@ -552,23 +473,16 @@ async function deleteProvider(id, name) { UI.toast('Provider removed', 'success'); UI.loadProviderList(); fetchModels(); - } catch (e) { - UI.toast(e.message, 'error'); - } + } catch (e) { UI.toast(e.message, 'error'); } } -// ── Admin Handlers ─────────────────────────── - async function handleSaveAdminSettings() { try { const reg = document.getElementById('adminRegToggle').checked; await API.adminUpdateSetting('registration_enabled', { value: reg }); UI.toast('Admin settings saved', 'success'); - } catch (e) { - UI.toast(e.message, 'error'); - } + } catch (e) { UI.toast(e.message, 'error'); } } // ── Boot ───────────────────────────────────── - document.addEventListener('DOMContentLoaded', init); diff --git a/src/js/ui.js b/src/js/ui.js index 38258e6..4c1d14f 100644 --- a/src/js/ui.js +++ b/src/js/ui.js @@ -1,25 +1,80 @@ // ========================================== -// Chat Switchboard – UI (v0.5.0) +// Chat Switchboard – UI (v0.5.2) // ========================================== 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 ──────────────────────────── renderChatList() { const el = document.getElementById('chatHistory'); if (App.chats.length === 0) { - el.innerHTML = '
No conversations yet
'; + el.innerHTML = ''; return; } - el.innerHTML = App.chats.map(c => ` -
- ${esc(c.title)} - -
- `).join(''); + + // Group by time + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterday = new Date(today - 86400000); + const weekAgo = new Date(today - 7 * 86400000); + + const groups = { today: [], yesterday: [], week: [], older: [] }; + + App.chats.forEach(c => { + const d = new Date(c.updatedAt); + if (d >= today) groups.today.push(c); + else if (d >= yesterday) groups.yesterday.push(c); + else if (d >= weekAgo) groups.week.push(c); + else groups.older.push(c); + }); + + let html = ''; + const renderGroup = (label, chats) => { + if (chats.length === 0) return; + html += `
${label}
`; + chats.forEach(c => { + const time = _relativeTime(c.updatedAt); + html += ` +
+ ${esc(c.title)} + ${time} + +
`; + }); + }; + + renderGroup('Today', groups.today); + renderGroup('Yesterday', groups.yesterday); + renderGroup('Previous 7 days', groups.week); + renderGroup('Older', groups.older); + + el.innerHTML = html; }, // ── Messages ───────────────────────────── @@ -35,23 +90,25 @@ const UI = { .filter(m => m.role !== 'system') .map((m, i) => this._messageHTML(m, i)) .join(''); - this._scrollToBottom(); + this._scrollToBottom(true); }, _messageHTML(msg, index) { 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 `
-
-
${isUser ? '👤' : '🤖'}
-
-
- ${isUser ? 'You' : 'Assistant'} - ${time ? `${time}` : ''} - +
+
${isUser ? '👤' : '🤖'}
+
+
+ ${isUser ? 'You' : 'Assistant'} + ${time ? `${time}` : ''} +
+ +
-
${formatMessage(msg.content)}
+
${formatMessage(msg.content)}
`; @@ -60,9 +117,9 @@ const UI = { showEmptyState() { document.getElementById('chatMessages').innerHTML = `
-
💬
-

Start a Conversation

-

Type a message below to begin

+ +

Chat Switchboard

+

Select a model and start chatting

`; }, @@ -70,21 +127,16 @@ const UI = { async streamResponse(response, messages) { const container = document.getElementById('chatMessages'); - - // Remove typing indicator if present document.getElementById('typingIndicator')?.remove(); - // Create streaming message element const div = document.createElement('div'); div.className = 'message assistant'; div.innerHTML = ` -
-
🤖
-
-
- Assistant -
-
+
+
🤖
+
+
Assistant
+
`; container.appendChild(div); @@ -144,7 +196,7 @@ const UI = { const exists = [...sel.options].some(o => o.value === current); if (exists) { sel.value = current; - } else if (current) { + } else { const opt = document.createElement('option'); opt.value = current; 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'); if (settingsSel) { settingsSel.innerHTML = sel.innerHTML; @@ -161,18 +213,17 @@ const UI = { } }, - // ── Connection Status ──────────────────── + // ── User / Connection ──────────────────── - updateConnectionStatus() { - const el = document.getElementById('connectionStatus'); - const name = API.user?.display_name || API.user?.username || 'Connected'; - el.className = 'connection-status online'; - el.innerHTML = ` ${esc(name)}`; - el.title = 'Click to sign out'; + updateUser() { + const name = API.user?.display_name || API.user?.username || 'User'; + const letter = (name[0] || '?').toUpperCase(); + document.getElementById('avatarLetter').textContent = letter; + document.getElementById('userName').textContent = name; }, showAdminButton(show) { - document.getElementById('adminBtn').style.display = show ? '' : 'none'; + document.getElementById('menuAdmin').style.display = show ? '' : 'none'; }, // ── Generating State ───────────────────── @@ -181,20 +232,17 @@ const UI = { document.getElementById('stopBtn').classList.toggle('visible', on); document.getElementById('sendBtn').disabled = on; if (on) { - // Show typing indicator const container = document.getElementById('chatMessages'); const typing = document.createElement('div'); typing.id = 'typingIndicator'; typing.className = 'message assistant'; typing.innerHTML = ` -
-
🤖
-
-
-
+
+
🤖
+
`; container.appendChild(typing); - this._scrollToBottom(); + this._scrollToBottom(true); } else { document.getElementById('typingIndicator')?.remove(); } @@ -224,15 +272,11 @@ const UI = { document.getElementById('settingsTemp').value = App.settings.temperature; document.getElementById('settingsThinking').checked = App.settings.showThinking; document.getElementById('profileChangePwForm').style.display = 'none'; - UI.loadProfileIntoSettings(); UI.loadProviderList(); document.getElementById('settingsModal').classList.add('active'); }, - - closeSettings() { - document.getElementById('settingsModal').classList.remove('active'); - }, + closeSettings() { document.getElementById('settingsModal').classList.remove('active'); }, async loadProfileIntoSettings() { try { @@ -251,12 +295,12 @@ const UI = { const data = await API.listConfigs(); const list = Array.isArray(data) ? data : (data.configs || data.data || []); if (list.length === 0) { - el.innerHTML = '
No providers. Add one to start chatting.
'; + el.innerHTML = '
No providers configured.
'; return; } el.innerHTML = list.map(c => `
-
+
${esc(c.name)} ${esc(c.provider)} · ${esc(c.model_default || 'no default')}
@@ -266,39 +310,25 @@ const UI = { ? `` : 'global'}
-
- `).join(''); +
`).join(''); } catch (e) { - el.innerHTML = `
Failed: ${esc(e.message)}
`; + el.innerHTML = `
${esc(e.message)}
`; } }, - showProviderForm() { - document.getElementById('providerAddForm').style.display = ''; - document.getElementById('providerName').value = ''; - document.getElementById('providerEndpoint').value = ''; - document.getElementById('providerApiKey').value = ''; - document.getElementById('providerDefaultModel').value = ''; - }, - - hideProviderForm() { - document.getElementById('providerAddForm').style.display = 'none'; - }, + showProviderForm() { document.getElementById('providerAddForm').style.display = ''; }, + hideProviderForm() { document.getElementById('providerAddForm').style.display = 'none'; }, // ── Admin Modal ────────────────────────── - openAdmin() { - document.getElementById('adminModal').classList.add('active'); - UI.switchAdminTab('users'); - }, - closeAdmin() { - document.getElementById('adminModal').classList.remove('active'); - }, + openAdmin() { document.getElementById('adminModal').classList.add('active'); UI.switchAdminTab('users'); }, + closeAdmin() { document.getElementById('adminModal').classList.remove('active'); }, async switchAdminTab(tab) { document.querySelectorAll('.admin-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab)); document.querySelectorAll('.admin-tab-content').forEach(c => c.style.display = 'none'); - document.getElementById(`admin${tab.charAt(0).toUpperCase() + tab.slice(1)}Tab`).style.display = ''; + const panel = document.getElementById(`admin${tab.charAt(0).toUpperCase() + tab.slice(1)}Tab`); + if (panel) panel.style.display = ''; if (tab === 'users') await this.loadAdminUsers(); if (tab === 'stats') await this.loadAdminStats(); @@ -314,26 +344,17 @@ const UI = { const users = resp.data || []; el.innerHTML = users.map(u => `
-
- ${esc(u.username)} ${u.role} - ${!u.is_active ? 'disabled' : ''} -
+
${esc(u.username)} ${u.role} + ${!u.is_active ? 'disabled' : ''}
${esc(u.email)}
-
- `).join('') || '
No users
'; - } catch (e) { - el.innerHTML = `
${esc(e.message)}
`; - } +
`).join('') || '
No users
'; + } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } }, async loadAdminStats() { const el = document.getElementById('adminStats'); - try { - const stats = await API.adminGetStats(); - el.innerHTML = `
${esc(JSON.stringify(stats, null, 2))}
`; - } catch (e) { - el.innerHTML = `
${esc(e.message)}
`; - } + try { const s = await API.adminGetStats(); el.innerHTML = `
${esc(JSON.stringify(s, null, 2))}
`; } + catch (e) { el.innerHTML = `
${esc(e.message)}
`; } }, async loadAdminProviders() { @@ -343,14 +364,9 @@ const UI = { const data = await API.adminListGlobalConfigs(); const list = data.configs || data.data || data || []; el.innerHTML = (Array.isArray(list) ? list : []).map(c => ` -
- ${esc(c.name)} - ${esc(c.provider)} -
+
${esc(c.name)}${esc(c.provider)}
`).join('') || '
No global providers
'; - } catch (e) { - el.innerHTML = `
${esc(e.message)}
`; - } + } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } }, async loadAdminModels() { @@ -360,15 +376,9 @@ const UI = { const data = await API.adminListModels(); const list = data.models || data.data || data || []; el.innerHTML = (Array.isArray(list) ? list : []).map(m => ` -
- ${esc(m.model_id || m.id)} - ${esc(m.provider_name || '')} - ${m.is_enabled ? '✅' : '⬜'} -
- `).join('') || '
No models. Fetch from providers first.
'; - } catch (e) { - el.innerHTML = `
${esc(e.message)}
`; - } +
${esc(m.model_id || m.id)}${esc(m.provider_name || '')}${m.is_enabled ? '✅' : '⬜'}
+ `).join('') || '
No models
'; + } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } }, // ── Export ──────────────────────────────── @@ -380,28 +390,19 @@ const UI = { let content, ext, mime; const safeName = chat.title.replace(/[^a-z0-9]/gi, '_'); - if (format === 'json') { - content = JSON.stringify(chat, null, 2); - ext = 'json'; mime = 'application/json'; - } else if (format === 'md') { - content = `# ${chat.title}\n\n` + - chat.messages.filter(m => m.role !== 'system') - .map(m => `## ${m.role === 'user' ? 'You' : 'Assistant'}\n\n${m.content}`) - .join('\n\n---\n\n'); + if (format === 'json') { content = JSON.stringify(chat, null, 2); ext = 'json'; mime = 'application/json'; } + else if (format === 'md') { + content = `# ${chat.title}\n\n` + chat.messages.filter(m => m.role !== 'system') + .map(m => `## ${m.role === 'user' ? 'You' : 'Assistant'}\n\n${m.content}`).join('\n\n---\n\n'); ext = 'md'; mime = 'text/markdown'; } else { - content = chat.messages.filter(m => m.role !== 'system') - .map(m => `[${m.role === 'user' ? 'You' : 'Assistant'}]\n${m.content}`) - .join('\n\n'); + content = chat.messages.filter(m => m.role !== 'system').map(m => `[${m.role === 'user' ? 'You' : 'Assistant'}]\n${m.content}`).join('\n\n'); ext = 'txt'; mime = 'text/plain'; } const blob = new Blob([content], { type: mime }); - const a = document.createElement('a'); - a.href = URL.createObjectURL(blob); - a.download = `${safeName}.${ext}`; - a.click(); - URL.revokeObjectURL(a.href); + const a = document.createElement('a'); a.href = URL.createObjectURL(blob); + a.download = `${safeName}.${ext}`; a.click(); URL.revokeObjectURL(a.href); }, // ── Message Actions ────────────────────── @@ -415,35 +416,35 @@ const UI = { } }, - // ── Helpers ────────────────────────────── + // ── Scroll ─────────────────────────────── - _scrollToBottom() { + _isNearBottom() { 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 ─────────────────────────────── -/** - * Format message content with markdown rendering. - * Uses marked.js + DOMPurify when available, regex fallback otherwise. - */ function formatMessage(content) { if (!content) return ''; - // ── Extract thinking blocks (both and tags) ── const thinkingBlocks = []; let text = content; - // Pull out thinking blocks before any processing text = text.replace(/<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi, (_, inner) => { const id = 'think-' + Math.random().toString(36).slice(2, 9); thinkingBlocks.push({ id, content: inner.trim() }); return `THINK_PLACEHOLDER_${id}`; }); - // ── Render markdown ── let html; if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') { html = _formatMarked(text); @@ -451,14 +452,11 @@ function formatMessage(content) { html = _formatBasic(text); } - // ── Restore thinking blocks ── for (const b of thinkingBlocks) { const inner = esc(b.content).replace(/\n/g, '
'); const thinkHTML = App.settings.showThinking ? `
💭 Thinking
${inner}
` : ''; - - // marked may wrap placeholder in

tags html = html.replace(new RegExp(`

\\s*THINK_PLACEHOLDER_${b.id}\\s*

`, 'g'), thinkHTML); html = html.replace(`THINK_PLACEHOLDER_${b.id}`, thinkHTML); } @@ -466,22 +464,10 @@ function formatMessage(content) { return html; } -/** - * Full markdown rendering via marked.js + DOMPurify - */ function _formatMarked(text) { - const rendered = marked.parse(text, { - breaks: true, - gfm: true - }); + const rendered = marked.parse(text, { breaks: true, gfm: true }); + let html = DOMPurify.sanitize(rendered, { ADD_TAGS: ['details', 'summary'], ADD_ATTR: ['id', 'class', 'onclick'] }); - // Sanitize but allow code structure - let html = DOMPurify.sanitize(rendered, { - ADD_TAGS: ['details', 'summary'], - ADD_ATTR: ['id', 'class', 'onclick'] - }); - - // Inject copy buttons into
 blocks
     html = html.replace(/
]*)>([\s\S]*?)<\/code><\/pre>/g, (_, attrs, code) => {
         const codeId = 'code-' + Math.random().toString(36).slice(2, 9);
         return `
${code}
`; @@ -490,18 +476,12 @@ function _formatMarked(text) { return html; } -/** - * Regex fallback when marked/DOMPurify unavailable - */ function _formatBasic(text) { let html = esc(text); - - // Code blocks with copy button html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => { const id = 'code-' + Math.random().toString(36).slice(2, 9); return `
${code.trim()}
`; }); - html = html.replace(/`([^`]+)`/g, '$1'); html = html.replace(/\*\*([^*]+)\*\*/g, '$1'); html = html.replace(/\*([^*]+)\*/g, '$1'); @@ -515,3 +495,18 @@ function esc(s) { d.textContent = s; 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' }); +}