Files
cAIc/templates/index.html
T

1619 lines
86 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>cAIc</title>
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--bg-primary: #080c1a;
--bg-secondary: #0d1428;
--bg-tertiary: #121d3a;
--bg-hover: #182850;
--text-primary: #a0b8ff;
--text-secondary: #7890d8;
--text-muted: #5068a0;
--accent: #33ddff;
--accent-dim: #0088bb;
--accent-glow: rgba(51,221,255,0.1);
--danger: #ff3355;
--danger-hover: #cc0022;
--success: #00ff66;
--warning: #ffaa00;
--border: #1a3060;
--scrollbar: #1a3060;
--radius: 6px;
--font-body: 'JetBrains Mono', 'Consolas', monospace;
--font-mono: 'JetBrains Mono', 'Consolas', monospace;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: var(--font-body); background: var(--bg-primary); color: var(--text-primary); height: 100vh; overflow: hidden; display: flex; font-size: 13px; }
::selection { background: var(--accent); color: var(--bg-primary); }
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--scrollbar); border-radius: 2px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
/* --- slide-out drawer (replaces sidebar) --- */
.drawer-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.55); z-index:90; display:none; }
.drawer-overlay.visible { display:block; }
.drawer { position:fixed; left:-300px; top:0; width:300px; height:100vh; background:var(--bg-secondary); border-right:1px solid var(--border); z-index:100; display:flex; flex-direction:column; transition:left 0.22s cubic-bezier(0.4,0,0.2,1); }
.drawer.open { left:0; }
.drawer-header { display:flex; align-items:center; justify-content:space-between; padding:14px 16px; border-bottom:1px solid var(--border); flex-shrink:0; }
.drawer-title { font-family:var(--font-body); font-size:12px; color:var(--text-primary); text-transform:uppercase; letter-spacing:1px; }
.drawer-title .version { color:var(--text-muted); }
.drawer-actions { display:flex; gap:4px; }
.drawer-btn { background:transparent; border:1px solid var(--border); color:var(--text-secondary); width:28px; height:28px; border-radius:var(--radius); cursor:pointer; font-family:var(--font-body); font-size:14px; display:flex; align-items:center; justify-content:center; transition:all 0.15s; }
.drawer-btn:hover { background:var(--bg-hover); border-color:var(--accent-dim); color:var(--accent); }
.drawer-btn.danger:hover { border-color:var(--danger); color:var(--danger); }
.drawer-conv-list { flex:1; overflow-y:auto; padding:6px 10px; }
.conv-item { padding:8px 10px; border-radius:var(--radius); cursor:pointer; margin-bottom:1px; display:flex; align-items:center; gap:8px; font-size:12px; color:var(--text-secondary); transition:background 0.12s; }
.conv-item:hover { background:var(--bg-hover); color:var(--text-primary); }
.conv-item.active { background:var(--bg-tertiary); color:var(--text-primary); border-left:2px solid var(--accent); border-radius:0 var(--radius) var(--radius) 0; }
.conv-item .conv-trash { color:var(--text-muted); cursor:pointer; font-size:13px; flex-shrink:0; opacity:0; transition:opacity 0.12s; }
.conv-item:hover .conv-trash { opacity:1; }
.conv-item .conv-trash:hover { color:var(--danger); }
.conv-item .conv-title { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; min-width:0; }
.conv-attach { color:var(--text-muted); cursor:pointer; font-size:12px; flex-shrink:0; opacity:0; transition:opacity 0.12s; }
.conv-item:hover .conv-attach { opacity:1; }
.conv-attach.has-attachments { color:var(--accent-dim); opacity:1; }
.drawer-footer { padding:10px 14px; border-top:1px solid var(--border); flex-shrink:0; }
.df-row { display:flex; align-items:center; gap:6px; font-size:10px; color:var(--text-muted); margin-bottom:3px; }
.df-row .df-label { text-transform:uppercase; color:var(--text-muted); width:40px; flex-shrink:0; }
.stats-compact { display:flex; gap:12px; margin-top:8px; padding-top:8px; border-top:1px solid var(--border); flex-wrap:wrap; }
.sc-item { font-size:10px; color:var(--text-muted); display:flex; gap:4px; align-items:center; }
.sc-label { color:var(--text-muted); text-transform:uppercase; }
.cluster-panel { margin-top:6px; }
.cluster-node-row { display:flex; align-items:center; gap:6px; padding:2px 0; font-size:10px; }
.cluster-node-name { color:var(--text-primary); min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.cluster-node-model { color:var(--text-muted); }
.cluster-dot { width:6px; height:6px; border-radius:50%; flex-shrink:0; }
.cluster-dot.active { background:var(--success); }
.cluster-dot.swapping { background:var(--warning); animation:pulse 1s infinite; }
.cluster-dot.error { background:var(--danger); }
.cluster-dot.offline { background:var(--danger); }
.main { flex:1; display:flex; flex-direction:column; height:100vh; min-width:0; }
.topbar { display:flex; align-items:center; justify-content:space-between; padding:8px 16px; border-bottom:1px solid var(--border); background:var(--bg-secondary); flex-shrink:0; gap:12px; }
.topbar-left { display:flex; align-items:center; gap:10px; min-width:0; }
.topbar-center { display:flex; align-items:center; gap:12px; font-size:12px; font-family:var(--font-mono); color:var(--text-muted); }
.tc-item { white-space:nowrap; }
.tc-label { color:var(--text-muted); text-transform:uppercase; margin-right:2px; }
.tc-val { transition:color 0.3s; }
.tc-val.green { color:var(--success); }
.tc-val.orange { color:var(--warning); }
.tc-val.red { color:var(--danger); }
.topbar-right { display:flex; align-items:center; gap:6px; }
.hamburger { background:transparent; border:1px solid var(--border); color:var(--text-secondary); width:30px; height:30px; border-radius:var(--radius); cursor:pointer; font-size:16px; display:flex; align-items:center; justify-content:center; transition:all 0.15s; flex-shrink:0; }
.hamburger:hover { background:var(--bg-hover); border-color:var(--accent-dim); color:var(--accent); }
.topbar-brand { font-family:var(--font-body); font-size:16px; color:#e0e8ff; white-space:nowrap; letter-spacing:0.5px; }
.topbar-brand .version { color:var(--text-muted); font-size:13px; }
.badge { font-size:12px; padding:3px 10px; border-radius:10px; font-family:var(--font-body); cursor:pointer; border:1px solid var(--border); background:transparent; color:var(--text-muted); transition:all 0.15s; letter-spacing:0.5px; }
.badge.on { border-color:rgba(0,255,102,0.3); color:var(--success); background:rgba(0,255,102,0.08); }
.badge.off { border-color:rgba(255,51,85,0.3); color:var(--danger); background:rgba(255,51,85,0.08); }
.badge.admin { border-color:var(--border); color:var(--text-muted); }
.badge.admin:hover { border-color:var(--accent-dim); color:var(--accent); }
.status-dot { width:6px; height:6px; border-radius:50%; background:var(--success); display:inline-block; flex-shrink:0; }
.status-dot.offline { background:var(--danger); }
.status-dot.warning { background:var(--warning); }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
.modal-overlay { display:none; position:fixed; top:0;left:0;right:0;bottom:0; background:rgba(0,0,0,0.7); z-index:1000; align-items:center; justify-content:center; }
.modal-overlay.visible { display:flex; }
.modal { background:var(--bg-secondary); border:1px solid var(--border); border-radius:12px; width:90%; max-width:700px; max-height:85vh; overflow-y:auto; }
.modal-header { display:flex; justify-content:space-between; align-items:center; padding:20px 24px 16px; border-bottom:1px solid var(--border); position:sticky; top:0; background:var(--bg-secondary); z-index:1; }
.modal-header h2 { font-family:var(--font-mono); font-size:16px; color:var(--accent); }
.modal-close { background:none; border:none; color:var(--text-muted); font-size:24px; cursor:pointer; }
.modal-close:hover { color:var(--text-primary); }
.modal-body { padding: 20px 24px; }
.modal-section { margin-bottom: 24px; }
.modal-section h3 { font-family:var(--font-mono); font-size:13px; color:var(--text-secondary); text-transform:uppercase; letter-spacing:1px; margin-bottom:8px; }
.modal-section p.desc { font-size:12px; color:var(--text-muted); margin-bottom:10px; line-height:1.5; }
.modal-section textarea { width:100%; background:var(--bg-tertiary); border:1px solid var(--border); color:var(--text-primary); font-family:var(--font-mono); font-size:12px; padding:12px; border-radius:var(--radius); resize:vertical; line-height:1.6; }
.modal-section textarea:focus { outline:none; border-color:var(--accent-dim); }
.token-count { font-size:11px; color:var(--text-muted); font-family:var(--font-mono); margin-top:4px; text-align:right; }
.toggle-row { display:flex; align-items:center; justify-content:space-between; padding:8px 0; }
.toggle-label { font-size:13px; }
.toggle-switch { position:relative; width:44px; height:24px; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:12px; cursor:pointer; transition:background 0.2s; }
.toggle-switch.on { background:var(--accent-dim); border-color:var(--accent-dim); }
.toggle-switch::after { content:''; position:absolute; top:2px; left:2px; width:18px; height:18px; background:var(--text-primary); border-radius:50%; transition:transform 0.2s; }
.toggle-switch.on::after { transform:translateX(20px); }
.btn-small { padding:6px 14px; border-radius:var(--radius); font-family:var(--font-mono); font-size:12px; cursor:pointer; border:1px solid var(--border); transition:all 0.2s; }
.btn-save { background:var(--accent-dim); color:#fff; border-color:var(--accent-dim); }
.btn-save:hover { background:var(--accent); }
.btn-reset { background:transparent; color:var(--text-muted); }
.btn-reset:hover { color:var(--danger); border-color:var(--danger); }
.btn-bar { display:flex; gap:8px; margin-top:10px; }
.preset-item { display:flex; align-items:center; gap:8px; padding:8px 10px; background:var(--bg-tertiary); border-radius:var(--radius); margin-bottom:6px; font-size:13px; }
.preset-item .preset-name { flex:1; color:var(--text-primary); }
.preset-item button { background:none; border:none; color:var(--text-muted); cursor:pointer; font-size:13px; padding:2px 4px; }
.preset-item button:hover { color:var(--text-primary); }
.memory-item { display:flex; align-items:flex-start; gap:8px; padding:8px 10px; background:var(--bg-tertiary); border-radius:var(--radius); margin-bottom:6px; font-size:12px; }
.memory-item .memory-fact { flex:1; color:var(--text-primary); line-height:1.4; }
.memory-item .memory-topic { font-size:10px; color:var(--accent); background:var(--accent-glow); padding:2px 6px; border-radius:4px; }
.memory-item .memory-delete { color:var(--danger); cursor:pointer; opacity:0.5; }
.memory-item .memory-delete:hover { opacity:1; }
.memory-stats { font-size:11px; color:var(--text-muted); margin-bottom:10px; font-family:var(--font-mono); }
.skills-status { font-size:11px; color:var(--text-muted); margin-bottom:10px; font-family:var(--font-mono); }
.skill-item { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; padding:8px 10px; background:var(--bg-tertiary); border-radius:var(--radius); margin-bottom:6px; }
.skill-meta { min-width:0; }
.skill-name { font-size:12px; color:var(--text-primary); font-family:var(--font-mono); margin-bottom:3px; }
.skill-desc { font-size:11px; color:var(--text-muted); line-height:1.4; }
.skill-risk { display:inline-block; margin-left:8px; padding:1px 6px; border-radius:10px; font-size:10px; text-transform:uppercase; border:1px solid var(--border); color:var(--text-secondary); }
.skill-item.disabled .skill-meta { opacity:0.6; }
.chat-container { flex:1; overflow-y:auto; padding:20px; display:flex; flex-direction:column; gap:16px; }
.welcome-screen { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; color:var(--text-muted); text-align:center; gap:12px; }
.welcome-screen .logo { font-family:var(--font-mono); font-size:48px; color:var(--accent-dim); opacity:0.5; }
.welcome-screen .welcome-logo { max-width:70%; height:auto; object-fit:contain; opacity:0.85; }
.welcome-screen p { font-size:14px; max-width:420px; line-height:1.6; }
.ws-hint { font-size:12px; color:var(--text-muted); display:block; margin-top:8px; }
.ws-hint code { background:var(--bg-tertiary); padding:1px 5px; border-radius:3px; font-family:var(--font-mono); font-size:11px; }
.message { display:flex; gap:12px; max-width:900px; width:100%; margin:0 auto; animation:fadeIn 0.2s ease; }
@keyframes fadeIn { from{opacity:0;transform:translateY(6px)} to{opacity:1;transform:translateY(0)} }
.message .avatar { width:32px; height:32px; min-width:32px; border-radius:6px; display:flex; align-items:center; justify-content:center; font-family:var(--font-mono); font-size:13px; font-weight:600; margin-top:2px; }
.message.user .avatar { background:#1a3a5c; color:var(--accent); }
.message.assistant .avatar { background:var(--accent-dim); color:#fff; }
.message .content { flex:1; min-width:0; }
.message .content .role-label { font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:0.5px; margin-bottom:4px; color:var(--text-muted); font-family:var(--font-mono); }
.message .content .text { font-size:14px; line-height:1.65; word-wrap:break-word; overflow-wrap:break-word; }
.message .content .text pre { background:var(--bg-primary); border:1px solid var(--border); border-radius:var(--radius); padding:12px; margin:8px 0; overflow-x:auto; font-family:var(--font-mono); font-size:13px; line-height:1.5; position:relative; }
.message .content .text code { font-family:var(--font-mono); background:var(--bg-primary); padding:2px 5px; border-radius:3px; font-size:13px; }
.message .content .text pre code { background:none; padding:0; }
.copy-btn { position:absolute; top:6px; right:6px; background:var(--bg-tertiary); border:1px solid var(--border); color:var(--text-muted); font-family:var(--font-mono); font-size:11px; padding:3px 8px; border-radius:4px; cursor:pointer; }
.copy-btn:hover { color:var(--text-primary); }
.typing-indicator { display:inline-flex; gap:4px; padding:4px 0; }
.typing-indicator span { width:6px; height:6px; background:var(--accent-dim); border-radius:50%; animation:blink 1.4s infinite; }
.typing-indicator span:nth-child(2) { animation-delay:0.2s; }
.typing-indicator span:nth-child(3) { animation-delay:0.4s; }
@keyframes blink { 0%,80%,100%{opacity:0.3} 40%{opacity:1} }
.search-indicator { display:inline-flex; align-items:center; gap:8px; padding:8px 12px; background:rgba(243,156,18,0.15); border:1px solid rgba(243,156,18,0.3); border-radius:var(--radius); color:var(--warning); font-family:var(--font-mono); font-size:12px; margin:8px 0; }
.search-indicator .spinner { width:14px; height:14px; border:2px solid rgba(243,156,18,0.3); border-top-color:var(--warning); border-radius:50%; animation:spin 1s linear infinite; }
@keyframes spin { to{transform:rotate(360deg)} }
.search-badge-inline { display:inline-block; padding:2px 8px; background:rgba(243,156,18,0.15); border:1px solid rgba(243,156,18,0.3); border-radius:10px; color:var(--warning); font-family:var(--font-mono); font-size:10px; margin-left:8px; }
.memory-badge-inline { display:inline-block; padding:2px 8px; background:rgba(155,89,182,0.15); border:1px solid rgba(155,89,182,0.3); border-radius:10px; color:#9b59b6; font-family:var(--font-mono); font-size:10px; margin-left:8px; }
.perplexity-badge { display:inline-block; padding:2px 8px; border-radius:10px; font-family:var(--font-mono); font-size:10px; margin-left:8px; }
.perplexity-badge.low { background:rgba(46,204,113,0.15); border:1px solid rgba(46,204,113,0.3); color:var(--success); }
.perplexity-badge.medium { background:rgba(243,156,18,0.15); border:1px solid rgba(243,156,18,0.3); color:var(--warning); }
.perplexity-badge.high { background:rgba(231,76,60,0.15); border:1px solid rgba(231,76,60,0.3); color:var(--danger); }
.ttr-badge { display:inline-block; padding:2px 8px; border-radius:10px; font-family:var(--font-mono); font-size:10px; margin-left:8px; }
.ttr-badge.low { background:rgba(46,204,113,0.15); border:1px solid rgba(46,204,113,0.3); color:var(--success); }
.ttr-badge.medium { background:rgba(243,156,18,0.15); border:1px solid rgba(243,156,18,0.3); color:var(--warning); }
.ttr-badge.high { background:rgba(231,76,60,0.15); border:1px solid rgba(231,76,60,0.3); color:var(--danger); }
.ttr-badge.search { background:rgba(230,126,34,0.15); border:1px solid rgba(230,126,34,0.3); color:#e67e22; }
.tps-badge { display:inline-block; padding:2px 8px; border-radius:10px; font-family:var(--font-mono); font-size:10px; margin-left:8px; background:rgba(72,181,224,0.15); border:1px solid rgba(72,181,224,0.3); color:var(--accent); }
.tok-badge { display:inline-block; padding:2px 8px; border-radius:10px; font-family:var(--font-mono); font-size:10px; margin-left:8px; background:rgba(52,152,219,0.15); border:1px solid rgba(52,152,219,0.3); color:#3498db; }
.msg-toolbar { display:flex; gap:2px; margin-top:8px; align-items:center; }
.msg-toolbar button { background:none; border:none; color:var(--text-muted); cursor:pointer; padding:4px 6px; border-radius:4px; font-size:13px; line-height:1; display:flex; align-items:center; gap:4px; transition:color 0.15s,background 0.15s; }
.msg-toolbar button:hover { color:var(--text-primary); background:var(--bg-tertiary); }
.msg-toolbar button.active { color:var(--accent); }
.msg-toolbar .sep { width:1px; height:14px; background:var(--border); margin:0 4px; }
.msg-toolbar .toolbar-label { font-size:10px; font-family:var(--font-mono); color:var(--text-muted); margin-right:4px; }
.input-area { padding:16px 20px; border-top:1px solid var(--border); background:var(--bg-secondary); }
.input-row { max-width:900px; margin:0 auto; display:flex; gap:10px; align-items:flex-end; }
.input-row textarea { flex:1; background:var(--bg-tertiary); border:1px solid var(--border); color:var(--text-primary); font-family:var(--font-body); font-size:14px; padding:12px 14px; border-radius:var(--radius); resize:none; min-height:44px; max-height:200px; line-height:1.5; }
.input-row textarea:focus { outline:none; border-color:var(--accent-dim); }
.input-row textarea::placeholder { color:var(--text-muted); }
.paperclip-btn { padding:12px 12px; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:var(--radius); color:var(--text-secondary); font-size:18px; cursor:pointer; transition:all 0.2s; line-height:1; }
.paperclip-btn:hover { background:var(--bg-hover); color:var(--accent); border-color:var(--accent-dim); }
.send-btn { padding:12px 20px; background:var(--accent-dim); border:none; border-radius:var(--radius); color:#fff; font-family:var(--font-mono); font-size:13px; font-weight:600; cursor:pointer; white-space:nowrap; }
.send-btn:hover { background:var(--accent); }
.stop-btn { padding:12px 20px; background:var(--danger); border:none; border-radius:var(--radius); color:#fff; font-family:var(--font-mono); font-size:13px; font-weight:600; cursor:pointer; }
.stop-btn:hover { background:var(--danger-hover); }
.search-btn { padding:12px 14px; background:var(--warning); border:none; border-radius:var(--radius); color:#fff; font-size:16px; cursor:pointer; transition:background 0.2s; }
.search-btn:hover { background:#e67e22; }
.search-btn:disabled { background:var(--text-muted); cursor:not-allowed; }
.file-preview { max-width:900px; margin:0 auto 8px; display:none; align-items:center; gap:10px; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:var(--radius); padding:8px 12px; }
.file-preview.visible { display:flex; }
.file-preview-thumb { width:48px; height:48px; object-fit:cover; border-radius:4px; border:1px solid var(--border); }
.file-preview-icon { font-size:28px; flex-shrink:0; color:var(--text-muted); }
.file-preview-info { flex:1; min-width:0; }
.file-preview-name { font-size:13px; color:var(--text-primary); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.file-preview-size { font-size:11px; color:var(--text-muted); }
.file-preview-clear { background:none; border:none; color:var(--text-muted); font-size:18px; cursor:pointer; padding:4px; line-height:1; transition:color 0.15s; }
.file-preview-clear:hover { color:var(--danger); }
.file-preview-notice { font-size:11px; color:var(--warning); }
.conv-attach { color:var(--text-muted); cursor:pointer; padding:2px 2px; font-size:14px; flex-shrink:0; transition:color 0.15s; margin-left:2px; }
.conv-attach:hover { color:var(--accent); }
.conv-attach.has-attachments { color:var(--accent-dim); }
.gallery-overlay { position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.85); z-index:10000; display:none; justify-content:center; align-items:center; }
.gallery-overlay.visible { display:flex; }
.gallery-panel { background:var(--bg-secondary); border:1px solid var(--border); border-radius:var(--radius); width:90%; max-width:700px; max-height:85vh; display:flex; flex-direction:column; }
.gallery-header { display:flex; align-items:center; justify-content:space-between; padding:16px 20px; border-bottom:1px solid var(--border); }
.gallery-header h3 { font-size:16px; color:var(--text-primary); font-family:var(--font-mono); }
.gallery-close { background:none; border:none; color:var(--text-muted); font-size:24px; cursor:pointer; padding:4px; line-height:1; transition:color 0.15s; }
.gallery-close:hover { color:var(--text-primary); }
.gallery-body { flex:1; overflow-y:auto; padding:12px 20px; }
.gallery-empty { text-align:center; padding:40px 20px; color:var(--text-muted); font-size:14px; }
.gallery-item { display:flex; align-items:center; gap:12px; padding:12px; border-radius:var(--radius); margin-bottom:8px; background:var(--bg-tertiary); transition:background 0.15s; }
.gallery-item:hover { background:var(--bg-hover); }
.gallery-item-thumb { width:60px; height:60px; object-fit:cover; border-radius:4px; border:1px solid var(--border); flex-shrink:0; }
.gallery-item-icon { font-size:32px; flex-shrink:0; color:var(--text-muted); text-align:center; width:60px; }
.gallery-item-info { flex:1; min-width:0; }
.gallery-item-name { font-size:14px; color:var(--text-primary); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.gallery-item-meta { font-size:11px; color:var(--text-muted); margin-top:2px; }
.gallery-item-delete { background:none; border:1px solid var(--danger); border-radius:var(--radius); color:var(--danger); font-size:12px; cursor:pointer; padding:6px 12px; transition:all 0.15s; flex-shrink:0; }
.gallery-item-delete:hover { background:var(--danger); color:#fff; }
.message.assistant.search-result .content { background:rgba(243,156,18,0.08); border:1px solid rgba(243,156,18,0.2); border-radius:var(--radius); padding:12px; }
.raw-results { margin-top:12px; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:var(--radius); padding:8px 12px; font-size:12px; }
.raw-results summary { cursor:pointer; color:var(--accent); font-family:var(--font-mono); }
.raw-results ul { margin:8px 0 0 0; padding-left:20px; list-style:none; }
.raw-results li { margin-bottom:8px; }
.raw-results a { color:var(--accent); text-decoration:none; }
.raw-results a:hover { text-decoration:underline; }
.raw-results small { color:var(--text-muted); display:block; margin-top:2px; }
@media (max-width:768px) {
.topbar { padding:10px 14px; }
.chat-container { padding:12px; }
.input-area { padding:10px 12px; }
}
.auth-screen { position: fixed; inset: 0; width: 100%; height: 100vh; display: none; align-items: center; justify-content: center; background: rgba(0,0,0,0.62); z-index: 3000; }
.auth-card { width: 100%; max-width: 360px; margin: 0 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 12px; padding: 22px; box-shadow: 0 10px 28px rgba(0,0,0,0.35); }
.auth-title { font-family: var(--font-mono); font-size: 18px; color: var(--accent); margin-bottom: 6px; }
.auth-subtitle { font-size: 12px; color: var(--text-muted); margin-bottom: 14px; }
.auth-warning { margin-bottom: 12px; font-size: 12px; color: #ff8f8f; background: rgba(231,76,60,0.14); border: 1px solid rgba(231,76,60,0.35); border-radius: var(--radius); padding: 8px 10px; line-height: 1.4; }
.pin-input { width: 100%; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text-primary); font-family: var(--font-mono); font-size: 22px; letter-spacing: 6px; text-align: center; padding: 12px; margin-bottom: 10px; }
.pin-input:focus { outline: none; border-color: var(--accent-dim); }
.auth-btn { width: 100%; padding: 11px 14px; background: var(--accent-dim); border: none; border-radius: var(--radius); color: #fff; font-family: var(--font-mono); font-size: 13px; font-weight: 600; cursor: pointer; }
.auth-btn:hover { background: var(--accent); }
.auth-error { min-height: 18px; margin-top: 10px; font-size: 12px; color: var(--danger); text-align: center; }
.logout-btn { padding: 8px 10px; background: transparent; border: 1px solid var(--danger); border-radius: var(--radius); color: var(--danger); font-family: var(--font-mono); font-size: 11px; cursor: pointer; }
.logout-btn:hover { background: rgba(231,76,60,0.12); }
</style>
</head>
<body>
<div class="auth-screen" id="authScreen">
<div class="auth-card">
<div class="auth-title">cAIc Unlock</div>
<div class="auth-subtitle">Enter 4-digit admin PIN to unlock advanced actions</div>
<div class="auth-warning">Security warning: PIN 1234 is weak. Use a non-trivial 4-digit PIN.</div>
<input id="pinInput" class="pin-input" type="password" inputmode="numeric" maxlength="4" autocomplete="off" />
<button id="unlockBtn" class="auth-btn" onclick="unlockWithPin()">UNLOCK</button>
<div id="authError" class="auth-error"></div>
</div>
</div>
<div id="drawerOverlay" class="drawer-overlay" onclick="toggleDrawer()"></div>
<aside class="drawer" id="convDrawer">
<div class="drawer-header">
<span class="drawer-title">cAIc <span class="version">{{ version }}</span></span>
<div class="drawer-actions">
<button class="drawer-btn" onclick="newChat()" title="New chat">+</button>
<button class="drawer-btn" onclick="openSettings()" title="Settings">&#9881;</button>
<button class="drawer-btn danger" onclick="deleteAllConversations()" title="Delete all">&#128465;</button>
</div>
</div>
<div class="drawer-conv-list" id="convList"></div>
<div class="drawer-footer">
<div class="df-row" id="ollamaStatus"><span class="status-dot offline"></span> <span class="df-label">llm</span> checking...</div>
<div class="df-row" id="searchStatus"><span class="status-dot offline"></span> <span class="df-label">search</span> checking...</div>
<div class="df-row" id="memoryStatus"><span class="status-dot"></span> <span class="df-label">memory</span> -- entries</div>
<div class="df-row" id="clusterStatus"><span class="status-dot"></span> <span class="df-label">cluster</span> --</div>
<div class="cluster-panel" id="clusterPanel" style="display:none"></div>
</div>
</aside>
<div class="modal-overlay" id="settingsModal">
<div class="modal">
<div class="modal-header">
<h2>Settings</h2>
<button class="modal-close" onclick="closeSettings()">&times;</button>
</div>
<div class="modal-body">
<div class="modal-section">
<h3>Profile / Memory</h3>
<p class="desc">This context is injected as a system prompt into every conversation.</p>
<div class="toggle-row">
<span class="toggle-label">Inject profile into all chats</span>
<div class="toggle-switch on" id="profileToggle" onclick="toggleProfile()"></div>
</div>
<textarea id="profileEditor" rows="18" spellcheck="false"></textarea>
<div class="token-count" id="profileTokenCount"></div>
<div class="btn-bar">
<button class="btn-small btn-save" id="saveProfileBtn" onclick="saveProfile()">Save Profile</button>
<button class="btn-small btn-reset" onclick="resetProfile()">Reset to Default</button>
</div>
</div>
<div class="modal-section">
<h3>Memory System (FTS5)</h3>
<p class="desc">Memories are automatically injected based on relevance to your message. Say "remember that..." to add memories.</p>
<div class="toggle-row">
<span class="toggle-label">Enable memory injection</span>
<div class="toggle-switch on" id="memoryToggle" onclick="toggleMemory()"></div>
</div>
<div class="memory-stats" id="memoryStats">Loading...</div>
<div id="memoryList"></div>
</div>
<div class="modal-section">
<h3>Web Search (SearXNG)</h3>
<p class="desc">When enabled, cAIc will automatically search the web if the model indicates uncertainty. Use the 🔍 button to force a web search.</p>
<div class="toggle-row">
<span class="toggle-label">Enable automatic web search</span>
<div class="toggle-switch on" id="searchToggle" onclick="toggleSearch()"></div>
</div>
</div>
<div class="modal-section">
<h3>System Prompt Presets</h3>
<div id="presetList"></div>
<div class="btn-bar" style="margin-top:12px;">
<button class="btn-small btn-save" onclick="addPreset()">+ Add Preset</button>
</div>
</div>
<div class="modal-section">
<h3>Skills (Phase 1)</h3>
<p class="desc">Toggle built-in local skills used for tool-aware prompt injection. Master toggle disables all skills globally.</p>
<div class="toggle-row">
<span class="toggle-label">Enable skills framework</span>
<div class="toggle-switch on" id="skillsMasterToggle" onclick="toggleSkillsMaster()"></div>
</div>
<div class="skills-status" id="skillsStatus">Loading skills...</div>
<div id="skillsList"></div>
</div>
<div class="modal-section">
<h3>General</h3>
<div class="toggle-row">
<span class="toggle-label">Default model</span>
<select id="defaultModelSetting" onchange="saveDefaultModel()"></select>
</div>
</div>
</div>
</div>
</div>
<main class="main">
<div class="topbar">
<div class="topbar-left">
<button class="hamburger" id="hamburgerBtn" onclick="toggleDrawer()" title="Conversations">&#9776;</button>
<span class="topbar-brand">cAIc <span class="version">{{ version }}</span></span>
</div>
<div class="topbar-center">
<span class="tc-item"><span class="tc-label">CPU</span> <span id="cpuValue" class="tc-val">--%</span></span>
<span class="tc-item"><span class="tc-label">MEM</span> <span id="memValue" class="tc-val">--%</span></span>
<span class="tc-item"><span class="tc-label">GPU</span> <span id="gpuValue" class="tc-val">--%</span></span>
<span class="tc-item"><span class="tc-label">VRAM</span> <span id="vramValue" class="tc-val">--%</span></span>
</div>
<div class="topbar-right">
<button class="badge on" id="memoryBadge" onclick="toggleMemory()" title="Toggle memory injection">MEM</button>
<button class="badge on" id="searchBadge" onclick="toggleSearch()" title="Toggle auto web search">SEARCH</button>
<button class="badge on" id="profileBadge" onclick="toggleProfile()" title="Toggle profile injection">PROFILE</button>
<button class="badge admin" id="authActionBtn" onclick="handleAuthAction()" title="Unlock admin">ADMIN</button>
</div>
</div>
<div class="input-area">
<div class="file-preview" id="filePreview">
<img class="file-preview-thumb" id="filePreviewThumb" src="" alt="" style="display:none">
<span class="file-preview-icon" id="filePreviewIcon">&#128196;</span>
<div class="file-preview-info">
<div class="file-preview-name" id="filePreviewName"></div>
<div class="file-preview-size" id="filePreviewSize"></div>
<div class="file-preview-notice" id="filePreviewNotice"></div>
</div>
<button class="file-preview-clear" onclick="clearFileSelection()" title="Remove file">&times;</button>
</div>
<div class="input-row">
<input type="file" id="fileInput" style="display:none" accept=".txt,.md,.pdf,.json,.py,.html,.png,.jpg,.jpeg,.gif,.svg,.webp" onchange="onFileSelected(event)">
<button class="paperclip-btn" id="paperclipBtn" onclick="document.getElementById('fileInput').click()" title="Attach file">&#128206;</button>
<textarea id="userInput" placeholder="Type a message... (Shift+Enter for new line)" rows="1" autofocus></textarea>
<button class="send-btn" id="sendBtn" onclick="sendMessage()">SEND</button>
<button class="search-btn" id="searchBtn" onclick="sendSearch()" title="Search the web" style="display:none">&#128269;</button>
</div>
</div>
<div class="gallery-overlay" id="galleryOverlay" onclick="closeGallery(event)">
<div class="gallery-panel" onclick="event.stopPropagation()">
<div class="gallery-header">
<h3>Attachments</h3>
<button class="gallery-close" onclick="closeGallery()">&times;</button>
</div>
<div class="gallery-body" id="galleryBody"></div>
</div>
</div>
<div class="chat-container" id="chatContainer">
<div class="welcome-screen" id="welcomeScreen">
<img src="/static/caic-bg.png" alt="cAIc" class="welcome-logo">
</div>
</div>
</main>
<script>
let currentConvId = null;
let isStreaming = false;
let abortController = null;
let selectedFile = null;
let profileEnabled = true;
let searchEnabled = true;
let memoryEnabled = true;
let skillsEnabled = true;
let presets = [];
let skillsRegistry = [];
let currentModel = '';
let modelContextSize = 8192;
let cachedProfile = '';
let conversationHistory = [];
let appInitialized = false;
let heartbeatIntervalId = null;
let currentRole = 'guest';
const SESSION_KEY = 'jc_session_id';
function toggleDrawer() {
const drawer = document.getElementById('convDrawer');
const overlay = document.getElementById('drawerOverlay');
const isOpen = drawer.classList.toggle('open');
overlay.classList.toggle('visible', isOpen);
}
document.addEventListener('DOMContentLoaded', async () => {
document.getElementById('pinInput').addEventListener('keydown', e => { if (e.key === 'Enter') unlockWithPin(); });
await bootstrapAuth();
});
window.addEventListener('pagehide', () => {
// Best-effort server-side revoke on tab close; session timeout is the fallback if beacon is dropped.
const sid = sessionStorage.getItem(SESSION_KEY);
if (!sid) return;
navigator.sendBeacon('/api/auth/logout', sid);
sessionStorage.removeItem(SESSION_KEY);
});
function showAuthScreen() {
document.getElementById('authScreen').style.display = 'flex';
document.getElementById('pinInput').value = '';
document.getElementById('authError').textContent = '';
document.getElementById('pinInput').focus();
}
function hideAuthScreen() {
document.getElementById('authScreen').style.display = 'none';
}
function showMainScreen() {}
function applyRoleUI() {
// Guest mode keeps chat available while hiding controls that mutate system state.
const isAdmin = currentRole === 'admin';
const authBtn = document.getElementById('authActionBtn');
const memoryBadge = document.getElementById('memoryBadge');
const searchBadge = document.getElementById('searchBadge');
const profileBadge = document.getElementById('profileBadge');
authBtn.textContent = isAdmin ? 'LOGOUT' : 'ADMIN';
authBtn.title = isAdmin ? 'Logout admin mode' : 'Unlock admin mode';
memoryBadge.style.display = isAdmin ? 'inline-block' : 'none';
searchBadge.style.display = isAdmin ? 'inline-block' : 'none';
profileBadge.style.display = isAdmin ? 'inline-block' : 'none';
if (!isAdmin) closeSettings();
}
function requireAdminNotice() {
// Reuse the same PIN modal as a capability escalation prompt.
showAuthScreen();
document.getElementById('authError').textContent = 'Admin PIN required for this action';
}
function handleAuthExpired() {
sessionStorage.removeItem(SESSION_KEY);
currentRole = 'guest';
if (heartbeatIntervalId) {
clearInterval(heartbeatIntervalId);
heartbeatIntervalId = null;
}
bootstrapAuth();
}
async function authFetch(url, options = {}) {
const sid = sessionStorage.getItem(SESSION_KEY);
const headers = { ...(options.headers || {}) };
if (sid) headers['X-Session-ID'] = sid;
const response = await fetch(url, { ...options, headers });
if (response.status === 401) {
// Session missing/expired/revoked: return to bootstrap flow and recreate guest session.
handleAuthExpired();
throw new Error('Authentication required');
}
if (response.status === 403) {
// Authenticated but insufficient capability (guest hitting admin action).
requireAdminNotice();
throw new Error('Admin PIN required');
}
return response;
}
async function createGuestSession() {
// Guest session is the default so conversational UX works without PIN friction.
const resp = await fetch('/api/auth/guest', { method: 'POST' });
const data = await resp.json();
if (!resp.ok) throw new Error(data.detail || 'Unable to create guest session');
sessionStorage.setItem(SESSION_KEY, data.session_id);
currentRole = data.role || 'guest';
}
async function bootstrapAuth() {
const sid = sessionStorage.getItem(SESSION_KEY);
try {
if (sid) {
// Restore prior tab session when possible to avoid unnecessary re-prompts.
const resp = await fetch('/api/auth/session', {
headers: { 'X-Session-ID': sid }
});
const data = await resp.json();
if (resp.ok && data.authenticated) {
currentRole = data.role || 'guest';
} else {
sessionStorage.removeItem(SESSION_KEY);
await createGuestSession();
}
} else {
await createGuestSession();
}
showMainScreen();
hideAuthScreen();
applyRoleUI();
startHeartbeat();
await initializeMainApp();
} catch (e) {
showMainScreen();
showAuthScreen();
}
}
async function unlockWithPin() {
const pinInput = document.getElementById('pinInput');
const authError = document.getElementById('authError');
const unlockBtn = document.getElementById('unlockBtn');
const pin = (pinInput.value || '').trim();
if (!/^\d{4}$/.test(pin)) {
authError.textContent = 'PIN must be 4 digits';
return;
}
unlockBtn.disabled = true;
authError.textContent = '';
try {
const resp = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pin })
});
const data = await resp.json();
if (!resp.ok) {
authError.textContent = data.detail || 'Login failed';
unlockBtn.disabled = false;
pinInput.select();
return;
}
sessionStorage.setItem(SESSION_KEY, data.session_id);
// Admin session replaces guest session in this tab only.
currentRole = data.role || 'admin';
showMainScreen();
hideAuthScreen();
applyRoleUI();
startHeartbeat();
await initializeMainApp();
} catch (e) {
authError.textContent = 'Unable to reach auth endpoint';
} finally {
unlockBtn.disabled = false;
}
}
async function logoutToGuest() {
try {
await authFetch('/api/auth/logout', { method: 'POST' });
} catch (e) {
// Ignore; local client state cleanup still proceeds.
}
// Drop elevated session and immediately re-issue a guest token for continued chat use.
sessionStorage.removeItem(SESSION_KEY);
await createGuestSession();
currentRole = 'guest';
applyRoleUI();
hideAuthScreen();
}
function handleAuthAction() {
if (currentRole === 'admin') {
logoutToGuest();
return;
}
showAuthScreen();
}
async function sendHeartbeat() {
try {
await authFetch('/api/auth/heartbeat', { method: 'POST' });
} catch (e) {
// authFetch handles invalid session case.
}
}
function startHeartbeat() {
if (heartbeatIntervalId) clearInterval(heartbeatIntervalId);
heartbeatIntervalId = setInterval(sendHeartbeat, 30000);
}
async function initializeMainApp() {
if (appInitialized) return;
appInitialized = true;
await loadModels();
await loadSettings();
await loadSkills();
await loadProfile();
await loadPresets();
await loadConversations();
await loadMemoryStats();
checkOllamaStatus();
checkSearchStatus();
updateClusterStatus();
updateSystemStats();
setInterval(checkOllamaStatus, 30000);
setInterval(checkSearchStatus, 60000);
setInterval(updateClusterStatus, 15000);
setInterval(updateSystemStats, 2000);
}
async function loadMemoryStats() {
try {
const resp = await authFetch('/api/memories/stats');
const data = await resp.json();
document.getElementById('memoryStats').textContent = `Total: ${data.total} memories`;
document.getElementById('memoryStatus').innerHTML = `<span class="status-dot"></span> memory: ${data.total} entries`;
const listResp = await authFetch('/api/memories?limit=20');
const listData = await listResp.json();
const container = document.getElementById('memoryList');
container.innerHTML = '';
listData.memories.slice(0, 10).forEach(m => {
const div = document.createElement('div');
div.className = 'memory-item';
div.innerHTML = `<span class="memory-topic">${m.topic}</span><span class="memory-fact">${m.fact}</span><span class="memory-delete" data-id="${m.rowid}" onclick="deleteMemory(${m.rowid})">×</span>`;
container.appendChild(div);
});
} catch(e) { console.log('Memory stats error:', e); }
}
async function deleteMemory(rowid) {
if (currentRole !== 'admin') { requireAdminNotice(); return; }
if (!confirm('Delete this memory?')) return;
await authFetch(`/api/memories/${rowid}`, { method: 'DELETE' });
await loadMemoryStats();
}
async function updateSystemStats() {
try {
const resp = await authFetch('/api/stats');
const data = await resp.json();
const colorClass = v => v >= 95 ? 'red' : v >= 80 ? 'orange' : 'green';
document.getElementById('cpuValue').textContent = data.cpu_percent + '%';
document.getElementById('cpuValue').className = 'tc-val ' + colorClass(data.cpu_percent);
document.getElementById('memValue').textContent = data.memory_percent + '%';
document.getElementById('memValue').className = 'tc-val ' + colorClass(data.memory_percent);
if (data.gpu_available) {
document.getElementById('gpuValue').textContent = data.gpu_percent + '%';
document.getElementById('gpuValue').className = 'tc-val ' + colorClass(data.gpu_percent);
document.getElementById('vramValue').textContent = data.vram_percent + '%';
document.getElementById('vramValue').className = 'tc-val ' + colorClass(data.vram_percent);
}
} catch(e) {}
}
async function checkOllamaStatus() {
try {
const resp = await authFetch('/api/ps');
const data = await resp.json();
const el = document.getElementById('ollamaStatus');
const models = data.models || [];
el.innerHTML = models.length > 0 ? '<span class="status-dot"></span> ' + models.map(m => m.name).join(', ') : '<span class="status-dot"></span> Ollama ready';
} catch(e) {
document.getElementById('ollamaStatus').innerHTML = '<span class="status-dot offline"></span> Ollama offline';
}
}
async function checkSearchStatus() {
try {
const resp = await authFetch('/api/search/status');
const data = await resp.json();
document.getElementById('searchStatus').innerHTML = data.available ? '<span class="status-dot"></span> search: ready' : '<span class="status-dot warning"></span> search: unavailable';
const searchBtn = document.getElementById('searchBtn');
if (searchBtn) searchBtn.style.display = data.available ? '' : 'none';
} catch(e) {
document.getElementById('searchStatus').innerHTML = '<span class="status-dot offline"></span> search: error';
const searchBtn = document.getElementById('searchBtn');
if (searchBtn) searchBtn.style.display = 'none';
}
}
async function updateClusterStatus() {
try {
const resp = await authFetch('/api/cluster');
const data = await resp.json();
const statusEl = document.getElementById('clusterStatus');
const panel = document.getElementById('clusterPanel');
const count = data.node_count || 0;
statusEl.innerHTML = `<span class="status-dot${count === 0 ? ' offline' : ''}"></span> cluster: ${count} node${count !== 1 ? 's' : ''}`;
if (count === 0) {
panel.style.display = 'none';
return;
}
panel.style.display = 'block';
const now = Date.now();
let html = '';
for (const [name, node] of Object.entries(data.nodes)) {
const lastSeen = node.last_seen ? new Date(node.last_seen).getTime() : 0;
const ageSec = (now - lastSeen) / 1000;
let dotClass = 'active';
if (node.status === 'swapping') dotClass = 'swapping';
else if (node.status === 'error') dotClass = 'error';
else if (ageSec > 60) dotClass = 'offline';
const modelName = node.active_model ? (node.active_model.name || 'unknown') : 'idle';
html += `<div class="cluster-node-row"><span class="cluster-dot ${dotClass}"></span><span class="cluster-node-name">${escapeHtml(name)}</span><span class="cluster-node-model">${escapeHtml(modelName)}</span></div>`;
}
panel.innerHTML = html;
} catch(e) {
document.getElementById('clusterStatus').innerHTML = '<span class="status-dot offline"></span> cluster: error';
}
}
async function loadModels() {
try {
const resp = await authFetch('/api/models');
const data = await resp.json();
const settingSelect = document.getElementById('defaultModelSetting');
settingSelect.innerHTML = '';
(data.models || []).forEach(m => {
settingSelect.add(new Option(m.name, m.name));
});
if (!currentModel && data.models?.length) {
currentModel = data.models[0].name;
await fetchModelContextSize();
}
} catch(e) {}
}
async function fetchModelContextSize() {
const model = currentModel;
if (!model) return;
try {
const resp = await authFetch('/api/show', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ name: model }) });
const data = await resp.json();
if (data.model_info && data.model_info['context_length']) modelContextSize = data.model_info['context_length'];
else if (data.parameters) { const match = data.parameters.match(/num_ctx\s+(\d+)/); if (match) modelContextSize = parseInt(match[1]); }
} catch(e) {}
}
async function loadSettings() {
try {
const resp = await authFetch('/api/settings');
const s = await resp.json();
profileEnabled = s.profile_enabled !== 'false';
searchEnabled = s.search_enabled !== 'false';
memoryEnabled = s.memory_enabled !== 'false';
skillsEnabled = s.skills_enabled !== 'false';
updateProfileUI();
updateSearchUI();
updateMemoryUI();
updateSkillsUI();
if (s.default_model) {
currentModel = s.default_model;
document.getElementById('defaultModelSetting').value = s.default_model;
}
} catch(e) {}
}
async function saveSettings() {
if (currentRole !== 'admin') { requireAdminNotice(); return; }
await authFetch('/api/settings', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ profile_enabled: profileEnabled ? 'true' : 'false', search_enabled: searchEnabled ? 'true' : 'false', memory_enabled: memoryEnabled ? 'true' : 'false', skills_enabled: skillsEnabled ? 'true' : 'false' }) });
}
async function saveDefaultModel() {
if (currentRole !== 'admin') { requireAdminNotice(); return; }
const model = document.getElementById('defaultModelSetting').value;
await authFetch('/api/settings', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ default_model: model }) });
}
async function loadProfile() {
try {
const resp = await authFetch('/api/profile');
const data = await resp.json();
cachedProfile = data.content || '';
document.getElementById('profileEditor').value = cachedProfile;
updateTokenCount();
} catch(e) {}
}
async function saveProfile() {
if (currentRole !== 'admin') { requireAdminNotice(); return; }
const content = document.getElementById('profileEditor').value;
await authFetch('/api/profile', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ content: content }) });
cachedProfile = content;
updateTokenCount();
const btn = document.getElementById('saveProfileBtn');
btn.textContent = 'Saved!';
setTimeout(() => btn.textContent = 'Save Profile', 1500);
}
async function resetProfile() {
if (currentRole !== 'admin') { requireAdminNotice(); return; }
if (!confirm('Reset profile to default?')) return;
try {
const resp = await authFetch('/api/profile/default');
const data = await resp.json();
document.getElementById('profileEditor').value = data.content;
await saveProfile();
} catch(e) {}
}
function toggleProfile() { if (currentRole !== 'admin') { requireAdminNotice(); return; } profileEnabled = !profileEnabled; updateProfileUI(); saveSettings(); }
function toggleSearch() { if (currentRole !== 'admin') { requireAdminNotice(); return; } searchEnabled = !searchEnabled; updateSearchUI(); saveSettings(); }
function toggleMemory() { if (currentRole !== 'admin') { requireAdminNotice(); return; } memoryEnabled = !memoryEnabled; updateMemoryUI(); saveSettings(); }
function updateProfileUI() {
const badge = document.getElementById('profileBadge');
const toggle = document.getElementById('profileToggle');
badge.className = 'badge ' + (profileEnabled ? 'on' : 'off');
badge.textContent = profileEnabled ? 'PROFILE' : 'PROFILE OFF';
if (toggle) toggle.className = 'toggle-switch' + (profileEnabled ? ' on' : '');
}
function updateSearchUI() {
const badge = document.getElementById('searchBadge');
const toggle = document.getElementById('searchToggle');
badge.className = 'badge ' + (searchEnabled ? 'on' : 'off');
badge.innerHTML = searchEnabled ? 'SEARCH' : 'SEARCH OFF';
if (toggle) toggle.className = 'toggle-switch' + (searchEnabled ? ' on' : '');
}
function updateMemoryUI() {
const badge = document.getElementById('memoryBadge');
const toggle = document.getElementById('memoryToggle');
badge.className = 'badge ' + (memoryEnabled ? 'on' : 'off');
badge.innerHTML = memoryEnabled ? 'MEM' : 'MEM OFF';
if (toggle) toggle.className = 'toggle-switch' + (memoryEnabled ? ' on' : '');
}
function updateSkillsUI() {
const master = document.getElementById('skillsMasterToggle');
if (master) master.className = 'toggle-switch' + (skillsEnabled ? ' on' : '');
}
async function loadSkills() {
try {
const resp = await authFetch('/api/skills');
const data = await resp.json();
skillsRegistry = data.skills || [];
renderSkills();
} catch(e) {
const status = document.getElementById('skillsStatus');
if (status) status.textContent = 'Unable to load skills';
}
}
function renderSkills() {
const container = document.getElementById('skillsList');
const status = document.getElementById('skillsStatus');
if (!container || !status) return;
container.innerHTML = '';
if (!skillsRegistry.length) {
status.textContent = 'No skills registered';
return;
}
const enabledCount = skillsRegistry.filter(s => s.enabled).length;
status.textContent = `${enabledCount}/${skillsRegistry.length} skills enabled${skillsEnabled ? '' : ' (master toggle OFF)'}`;
skillsRegistry.forEach(skill => {
const row = document.createElement('div');
row.className = 'skill-item' + (skillsEnabled ? '' : ' disabled');
const meta = document.createElement('div');
meta.className = 'skill-meta';
const name = document.createElement('div');
name.className = 'skill-name';
const risk = (skill.risk || 'low').toUpperCase();
name.innerHTML = `${skill.name} <span class="skill-risk">${risk}</span>`;
const desc = document.createElement('div');
desc.className = 'skill-desc';
desc.textContent = `${skill.key} - ${skill.description || ''}`;
meta.appendChild(name);
meta.appendChild(desc);
const toggle = document.createElement('div');
const active = !!skill.enabled && skillsEnabled;
toggle.className = 'toggle-switch' + (active ? ' on' : '');
toggle.title = skillsEnabled ? `Toggle ${skill.name}` : 'Enable skills framework first';
toggle.addEventListener('click', () => toggleSkill(skill.key));
row.appendChild(meta);
row.appendChild(toggle);
container.appendChild(row);
});
}
async function toggleSkillsMaster() {
if (currentRole !== 'admin') { requireAdminNotice(); return; }
skillsEnabled = !skillsEnabled;
updateSkillsUI();
renderSkills();
await saveSettings();
}
async function toggleSkill(skillKey) {
if (currentRole !== 'admin') { requireAdminNotice(); return; }
if (!skillsEnabled) return;
const skill = skillsRegistry.find(s => s.key === skillKey);
if (!skill) return;
const nextEnabled = !skill.enabled;
await authFetch(`/api/skills/${encodeURIComponent(skillKey)}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ enabled: nextEnabled })
});
skill.enabled = nextEnabled;
renderSkills();
}
function updateTokenCount() {
const text = document.getElementById('profileEditor').value;
cachedProfile = text;
const tokens = Math.round(text.length / 4);
document.getElementById('profileTokenCount').textContent = '~' + tokens + ' tokens';
}
function estimateTokens(text) { return Math.round((text || '').length / 4); }
document.getElementById('profileEditor').addEventListener('input', updateTokenCount);
async function loadPresets() {
try {
const resp = await authFetch('/api/presets');
presets = await resp.json();
renderPresetList();
renderPresetSelect();
} catch(e) {}
}
function renderPresetList() {
const container = document.getElementById('presetList');
container.innerHTML = '';
presets.forEach(p => {
const div = document.createElement('div');
div.className = 'preset-item';
div.innerHTML = `<span class="preset-name">${p.name}</span><button onclick="editPreset('${p.id}')">✎</button>${!p.is_default ? `<button onclick="deletePreset('${p.id}')">×</button>` : ''}`;
container.appendChild(div);
});
}
function renderPresetSelect() {}
async function addPreset() {
if (currentRole !== 'admin') { requireAdminNotice(); return; }
const name = prompt('Preset name:');
if (!name) return;
const p = prompt('System prompt text:');
if (!p) return;
await authFetch('/api/presets', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({name, prompt: p}) });
await loadPresets();
}
async function editPreset(id) {
if (currentRole !== 'admin') { requireAdminNotice(); return; }
const preset = presets.find(p => p.id === id);
if (!preset) return;
const name = prompt('Preset name:', preset.name);
if (!name) return;
const p = prompt('System prompt:', preset.prompt);
if (p === null) return;
await authFetch(`/api/presets/${id}`, { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({name, prompt: p}) });
await loadPresets();
}
async function deletePreset(id) {
if (currentRole !== 'admin') { requireAdminNotice(); return; }
if (!confirm('Delete this preset?')) return;
await authFetch(`/api/presets/${id}`, { method: 'DELETE' });
await loadPresets();
}
function getSelectedPresetPrompt() { return ''; }
function openSettings() {
if (currentRole !== 'admin') { requireAdminNotice(); return; }
document.getElementById('settingsModal').classList.add('visible');
loadProfile();
loadMemoryStats();
loadSkills();
}
function closeSettings() { document.getElementById('settingsModal').classList.remove('visible'); }
document.getElementById('settingsModal').addEventListener('click', e => { if (e.target.id === 'settingsModal') closeSettings(); });
async function loadConversations() {
try {
const resp = await authFetch('/api/conversations');
const convs = await resp.json();
const list = document.getElementById('convList');
list.innerHTML = '';
convs.forEach(c => {
const div = document.createElement('div');
div.className = 'conv-item' + (c.id === currentConvId ? ' active' : '');
const attachIcon = c.attachment_count > 0 ? `<span class="conv-attach has-attachments" onclick="event.stopPropagation();showGallery('${c.id}')" title="${c.attachment_count} attachment(s)">📎</span>` : '';
div.innerHTML = `<span class="conv-trash" onclick="event.stopPropagation();deleteConversation('${c.id}')" title="Delete conversation">🗑</span>${attachIcon}<span class="conv-title" onclick="loadConversation('${c.id}')">${c.title}</span>`;
list.appendChild(div);
});
} catch(e) {}
}
async function loadConversation(convId) {
try {
const resp = await authFetch(`/api/conversations/${convId}`);
const data = await resp.json();
currentConvId = convId;
currentModel = data.conversation.model;
fetchModelContextSize();
const container = document.getElementById('chatContainer');
container.innerHTML = '';
conversationHistory = [];
data.messages.forEach(msg => { appendMessage(msg.role, msg.content, false); conversationHistory.push({ role: msg.role, content: msg.content }); });
scrollToTop();
await loadConversations();
} catch(e) {}
}
async function deleteConversation(convId) {
if (currentRole !== 'admin') { requireAdminNotice(); return; }
if (!confirm('Delete this conversation?')) return;
await authFetch(`/api/conversations/${convId}`, { method: 'DELETE' });
if (currentConvId === convId) { currentConvId = null; showWelcome(); }
await loadConversations();
}
async function deleteAllConversations() {
if (currentRole !== 'admin') { requireAdminNotice(); return; }
if (!confirm('Delete ALL conversations? This cannot be undone.')) return;
await authFetch('/api/conversations', { method: 'DELETE' });
currentConvId = null;
conversationHistory = [];
showWelcome();
await loadConversations();
}
function newChat() {
currentConvId = null;
conversationHistory = [];
showWelcome();
document.querySelectorAll('.conv-item').forEach(el => el.classList.remove('active'));
}
function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; }
function showWelcome() {
document.getElementById('chatContainer').innerHTML = '<div class="welcome-screen" id="welcomeScreen"><img src="/static/caic-bg.png" alt="cAIc" class="welcome-logo"></div>';
}
async function sendSearch() {
const input = document.getElementById('userInput');
const query = input.value.trim();
if (!query || isStreaming) return;
const model = currentModel;
const welcome = document.getElementById('welcomeScreen');
if (welcome) welcome.remove();
appendMessage('user', '🔍 ' + query, true);
conversationHistory.push({ role: 'user', content: '🔍 ' + query });
input.value = '';
input.style.height = 'auto';
const assistantDiv = appendMessage('assistant', '', true, true, document.querySelector('.message.user'));
const textEl = assistantDiv.querySelector('.text');
textEl.innerHTML = '<div class="search-indicator"><div class="spinner"></div>Searching the web...</div>';
setStreamingState(true);
const ttrStart = performance.now();
let ttr = 0;
try {
abortController = new AbortController();
const resp = await authFetch('/api/search', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ conversation_id: currentConvId, query, model }), signal: abortController.signal });
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let fullText = '';
let buffer = '';
let firstToken = true;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
try {
const data = JSON.parse(line.slice(6));
if (data.error) { textEl.textContent = 'Error: ' + data.error; setStreamingState(false); return; }
if (data.conversation_id && !currentConvId) { currentConvId = data.conversation_id; await loadConversations(); }
if (data.search_results) { textEl.innerHTML = '<div class="search-indicator">🔍 Found ' + data.search_results + ' results, summarizing...</div>'; }
if (data.token) { if (firstToken) { textEl.innerHTML = ''; firstToken = false; ttr = performance.now() - ttrStart; } fullText += data.token; textEl.innerHTML = renderMarkdown(fullText); scrollToTop(); }
if (data.raw_results) {
let rawHtml = '<details class="raw-results"><summary>🔍 View raw search results (' + data.raw_results.length + ')</summary><ul>';
data.raw_results.forEach(r => {
const safeUrl = sanitizeUrl(r.url || '');
rawHtml += '<li>';
if (safeUrl) {
rawHtml += `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener">${escapeHtml(r.title)}</a>`;
} else {
rawHtml += `<span>${escapeHtml(r.title)}</span>`;
}
if (r.content) rawHtml += `<small>${escapeHtml(r.content)}</small>`;
rawHtml += '</li>';
});
rawHtml += '</ul></details>';
textEl.innerHTML += rawHtml;
}
if (data.done) {
const roleLabel = assistantDiv.querySelector('.role-label');
if (roleLabel) {
roleLabel.innerHTML += '<span class="search-badge-inline">🔍 web</span>';
if (ttr > 0) {
const ttrSec = ttr / 1000;
roleLabel.innerHTML += `<span class="ttr-badge search">ttr: ${ttrSec.toFixed(1)}s</span>`;
}
}
conversationHistory.push({ role: 'assistant', content: fullText });
addCopyButtons(assistantDiv);
addMessageToolbar(assistantDiv);
setStreamingState(false);
await loadConversations();
}
} catch(e) { console.log('Parse error:', e); }
}
}
} catch (e) {
if (e.name === 'AbortError') textEl.innerHTML += '<br><em style="color:var(--text-muted)">[stopped]</em>';
else textEl.textContent = 'Error: ' + e.message;
setStreamingState(false);
}
}
function onFileSelected(event) {
const file = event.target.files[0];
if (!file) return;
selectedFile = file;
const preview = document.getElementById('filePreview');
const thumb = document.getElementById('filePreviewThumb');
const icon = document.getElementById('filePreviewIcon');
const nameEl = document.getElementById('filePreviewName');
const sizeEl = document.getElementById('filePreviewSize');
const notice = document.getElementById('filePreviewNotice');
nameEl.textContent = file.name;
sizeEl.textContent = (file.size / 1024).toFixed(1) + ' KB';
if (file.type.startsWith('image/')) {
thumb.style.display = 'block';
thumb.src = URL.createObjectURL(file);
icon.style.display = 'none';
notice.textContent = 'Model does not support image input — filename only will be used as context.';
} else {
thumb.style.display = 'none';
icon.style.display = 'inline';
icon.textContent = '📄';
notice.textContent = '';
}
preview.classList.add('visible');
event.target.value = '';
}
function clearFileSelection() {
selectedFile = null;
const preview = document.getElementById('filePreview');
preview.classList.remove('visible');
const thumb = document.getElementById('filePreviewThumb');
thumb.src = '';
}
function showGallery(convId) {
document.getElementById('galleryOverlay').classList.add('visible');
loadGallery(convId);
}
function closeGallery(event) {
if (event && event.target !== event.currentTarget) return;
document.getElementById('galleryOverlay').classList.remove('visible');
}
async function loadGallery(convId) {
const body = document.getElementById('galleryBody');
body.innerHTML = '<div class="gallery-empty">Loading...</div>';
try {
const resp = await authFetch('/api/upload/by-conversation/' + encodeURIComponent(convId));
const items = await resp.json();
if (!items || items.length === 0) {
body.innerHTML = '<div class="gallery-empty">No attachments in this conversation.</div>';
return;
}
body.innerHTML = '';
for (const item of items) {
const div = document.createElement('div');
div.className = 'gallery-item';
const isImage = item.content_type && item.content_type.startsWith('image/');
const thumbHtml = isImage
? '<span class="gallery-item-icon">🖼</span>'
: '<span class="gallery-item-icon">📄</span>';
const expires = new Date(item.expires_at);
const expiresStr = expires.toLocaleString();
div.innerHTML = thumbHtml + '<div class="gallery-item-info"><div class="gallery-item-name">' + escapeHtml(item.filename) + '</div><div class="gallery-item-meta">Expires: ' + expiresStr + '</div></div><button class="gallery-item-delete" onclick="deleteGalleryItem(' + item.id + ')">Delete</button>';
body.appendChild(div);
}
} catch(e) {
body.innerHTML = '<div class="gallery-empty">Error loading attachments.</div>';
}
}
async function deleteGalleryItem(contextId) {
if (!confirm('Delete this attachment? It will also be removed from the RAG corpus.')) return;
try {
const resp = await authFetch('/api/upload/' + contextId, { method: 'DELETE' });
if (resp.ok) {
await loadGallery(currentConvId);
await loadConversations();
}
} catch(e) {}
}
async function sendMessage() {
const input = document.getElementById('userInput');
const message = input.value.trim();
if (!message || isStreaming) return;
const model = currentModel;
const welcome = document.getElementById('welcomeScreen');
if (welcome) welcome.remove();
appendMessage('user', message, true);
conversationHistory.push({ role: 'user', content: message });
messageHistory.push(message);
if (messageHistory.length > 100) messageHistory.shift();
historyIndex = -1;
input.value = '';
input.style.height = 'auto';
const assistantDiv = appendMessage('assistant', '', true, false, document.querySelector('.message.user'));
const textEl = assistantDiv.querySelector('.text');
textEl.innerHTML = '<div class="typing-indicator"><span></span><span></span><span></span></div>';
setStreamingState(true);
let searchTriggered = false;
const ttrStart = performance.now();
let ttr = 0;
let tokenCount = 0;
try {
abortController = new AbortController();
let uploadContextId = null;
if (selectedFile) {
const formData = new FormData();
formData.append('file', selectedFile, selectedFile.name);
formData.append('mode', 'both');
if (currentConvId) formData.append('conversation_id', currentConvId);
try {
const uploadResp = await authFetch('/api/upload', { method: 'POST', body: formData, signal: abortController.signal });
const uploadData = await uploadResp.json();
if (uploadResp.ok && uploadData.context_id) {
uploadContextId = uploadData.context_id;
if (!currentConvId && uploadData.conversation_id) currentConvId = uploadData.conversation_id;
} else {
throw new Error(uploadData.detail || 'Upload failed');
}
} catch(e) {
if (e.name !== 'AbortError') textEl.textContent = 'Error uploading file: ' + e.message;
setStreamingState(false);
return;
}
clearFileSelection();
await loadConversations();
}
const chatBody = { conversation_id: currentConvId, message, model };
if (uploadContextId) chatBody.upload_context_id = uploadContextId;
const resp = await authFetch('/api/chat', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(chatBody), signal: abortController.signal });
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let fullText = '';
let firstToken = true;
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
try {
const data = JSON.parse(line.slice(6));
if (data.error) { textEl.textContent = 'Error: ' + data.error; setStreamingState(false); return; }
if (data.conversation_id && !currentConvId) {
currentConvId = data.conversation_id;
if (uploadContextId) {
authFetch('/api/upload/' + uploadContextId + '/link', { method: 'PATCH', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ conversation_id: currentConvId }) }).catch(()=>{});
}
await loadConversations();
}
if (data.searching) { textEl.innerHTML = fullText ? renderMarkdown(fullText) + '<div class="search-indicator"><div class="spinner"></div>Searching...</div>' : '<div class="search-indicator"><div class="spinner"></div>Searching...</div>'; searchTriggered = true; }
if (data.search_results) { textEl.innerHTML = '<div class="search-indicator">🔍 Found ' + data.search_results + ' results...</div>'; fullText = ''; firstToken = true; }
if (data.token) { if (firstToken) { textEl.innerHTML = ''; firstToken = false; ttr = performance.now() - ttrStart; tokenCount = 0; } fullText += data.token; tokenCount++; textEl.innerHTML = renderMarkdown(fullText); scrollToTop(); }
if (data.done) {
const roleLabel = assistantDiv.querySelector('.role-label');
if (data.searched && roleLabel) roleLabel.innerHTML += '<span class="search-badge-inline">🔍 web</span>';
if (typeof data.perplexity === 'number' && roleLabel) { const ppl = data.perplexity; roleLabel.innerHTML += `<span class="perplexity-badge ${ppl >= 15 ? 'high' : ppl >= 8 ? 'medium' : 'low'}">ppl: ${ppl.toFixed(1)}</span>`; }
if (typeof data.tokens_per_sec === 'number' && data.tokens_per_sec > 0 && roleLabel) roleLabel.innerHTML += `<span class="tps-badge">${data.tokens_per_sec.toFixed(1)} t/s</span>`;
if (roleLabel && ttr > 0) {
const ttrSec = ttr / 1000;
const ttrClass = data.searched ? 'search' : (ttrSec <= 2 ? 'low' : ttrSec < 11 ? 'medium' : 'high');
roleLabel.innerHTML += `<span class="ttr-badge ${ttrClass}">ttr: ${ttrSec.toFixed(1)}s</span>`;
}
if (tokenCount > 0 && roleLabel) {
const pt = data.prompt_tokens || 0;
const ct = data.completion_tokens || 0;
const totalTokens = pt + ct;
const ctxLen = data.context_length || 4096;
const usedPct = Math.round((totalTokens / ctxLen) * 100);
roleLabel.innerHTML += `<span class="tok-badge">tok: ${totalTokens}/${ctxLen} ${usedPct}%</span>`;
}
conversationHistory.push({ role: 'assistant', content: fullText });
addCopyButtons(assistantDiv);
addMessageToolbar(assistantDiv);
setStreamingState(false);
if (data.rag_update_suggestion && data.rag_update_suggestion.conflicts) {
showFactConflictBanner(data.rag_update_suggestion.conflicts, data.conversation_id);
}
await loadConversations();
await loadMemoryStats();
checkOllamaStatus();
}
} catch(e) {}
}
}
} catch (e) {
if (e.name === 'AbortError') textEl.innerHTML += '<br><em style="color:var(--text-muted)">[stopped]</em>';
else textEl.textContent = 'Error: ' + e.message;
setStreamingState(false);
}
}
function setStreamingState(streaming) {
isStreaming = streaming;
const sendBtn = document.getElementById('sendBtn');
const searchBtn = document.getElementById('searchBtn');
if (streaming) {
sendBtn.textContent = 'STOP';
sendBtn.className = 'stop-btn';
sendBtn.onclick = () => { if (abortController) abortController.abort(); setStreamingState(false); };
if (searchBtn) searchBtn.disabled = true;
} else {
sendBtn.textContent = 'SEND';
sendBtn.className = 'send-btn';
sendBtn.onclick = sendMessage;
if (searchBtn) searchBtn.disabled = false;
}
}
function appendMessage(role, content, animate, isSearch = false, afterEl = null) {
const container = document.getElementById('chatContainer');
const div = document.createElement('div');
div.className = 'message ' + role + (isSearch && role === 'assistant' ? ' search-result' : '');
if (!animate) div.style.animation = 'none';
div.innerHTML = `<div class="avatar">${role === 'user' ? 'YOU' : 'AI'}</div><div class="content"><div class="role-label">${role}</div><div class="text">${content ? renderMarkdown(content) : ''}</div>${role === 'assistant' ? '<div class="msg-toolbar"></div>' : ''}</div>`;
if (afterEl) {
afterEl.parentNode.insertBefore(div, afterEl.nextSibling);
} else {
container.prepend(div);
}
if (content && role === 'assistant') { addCopyButtons(div); addMessageToolbar(div); }
scrollToTop();
return div;
}
function renderMarkdown(text) {
let blocks = [];
text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (m, lang, code) => { blocks.push(`<pre data-lang="${lang}"><code>${escapeHtml(code)}</code></pre>`); return '\x00BLOCK' + (blocks.length - 1) + '\x00'; });
text = text.replace(/```([\s\S]*?)```/g, (m, code) => { blocks.push(`<pre><code>${escapeHtml(code)}</code></pre>`); return '\x00BLOCK' + (blocks.length - 1) + '\x00'; });
let h = escapeHtml(text);
h = h.replace(/`([^`]+)`/g, '<code>$1</code>');
h = h.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
h = h.replace(/\*(.+?)\*/g, '<em>$1</em>');
h = h.replace(/\n/g, '<br>');
h = h.replace(/\x00BLOCK(\d+)\x00/g, (m, idx) => blocks[parseInt(idx)]);
return h;
}
function sanitizeUrl(url) {
if (!url) return '';
try {
const parsed = new URL(url, window.location.origin);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return parsed.href;
} catch (e) {
return '';
}
return '';
}
function addCopyButtons(msgDiv) {
msgDiv.querySelectorAll('pre').forEach(pre => {
if (pre.querySelector('.copy-btn')) return;
const btn = document.createElement('button');
btn.className = 'copy-btn';
btn.textContent = 'copy';
btn.onclick = () => {
const text = pre.querySelector('code')?.textContent || pre.textContent;
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(() => { btn.textContent = 'copied!'; setTimeout(() => btn.textContent = 'copy', 1500); }).catch(() => fallbackCopy(text, btn));
} else { fallbackCopy(text, btn); }
};
pre.style.position = 'relative';
pre.appendChild(btn);
});
}
function addMessageToolbar(msgDiv) {
const toolbar = msgDiv.querySelector('.msg-toolbar');
if (!toolbar || toolbar.hasChildNodes()) return;
const textDiv = msgDiv.querySelector('.text');
const getText = () => textDiv ? textDiv.textContent || textDiv.innerText : '';
const copyBtn = document.createElement('button');
copyBtn.innerHTML = '📋';
copyBtn.title = 'Copy response';
copyBtn.onclick = () => {
const t = getText();
if (navigator.clipboard) {
navigator.clipboard.writeText(t).then(() => { copyBtn.textContent = '✓'; setTimeout(() => copyBtn.innerHTML = '📋', 1500); });
}
};
const printBtn = document.createElement('button');
printBtn.innerHTML = '🖨️';
printBtn.title = 'Print response';
printBtn.onclick = () => {
const w = window.open('', '_blank', 'width=800,height=600');
w.document.write(`<!DOCTYPE html><html><head><title>Print</title><style>body{font-family:system-ui;max-width:700px;margin:40px auto;padding:0 20px;line-height:1.6;font-size:14px}pre{background:#f4f4f4;padding:12px;border-radius:6px;overflow-x:auto}code{background:#f4f4f4;padding:2px 5px;border-radius:3px}pre code{background:none;padding:0}img{max-width:100%}@media print{body{margin:0}}</style></head><body>${textDiv ? textDiv.innerHTML : ''}</body></html>`);
w.document.close();
setTimeout(() => { w.focus(); w.print(); }, 500);
};
const saveBtn = document.createElement('button');
saveBtn.innerHTML = '💾';
saveBtn.title = 'Save as .md';
saveBtn.onclick = () => {
const t = getText();
const blob = new Blob([t], { type: 'text/markdown' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `caic-response-${Date.now()}.md`;
a.click();
URL.revokeObjectURL(a.href);
};
toolbar.append(copyBtn, printBtn, saveBtn, sep());
function sep() { const s = document.createElement('span'); s.className = 'sep'; return s; }
}
function escapeHtml(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; }
function scrollToBottom() { const c = document.getElementById('chatContainer'); c.scrollTop = c.scrollHeight; }
function scrollToTop() { const c = document.getElementById('chatContainer'); c.scrollTop = 0; }
function fallbackCopy(text, btn) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
btn.textContent = 'copied!';
setTimeout(() => btn.textContent = 'copy', 1500);
}
const userInput = document.getElementById('userInput');
const messageHistory = [];
let historyIndex = -1;
let historyDraft = '';
userInput.addEventListener('input', function() { this.style.height = 'auto'; this.style.height = Math.min(this.scrollHeight, 200) + 'px'; });
userInput.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); return; }
if (e.key === 'ArrowUp') {
e.preventDefault();
if (messageHistory.length === 0) return;
if (historyIndex === -1) historyDraft = userInput.value;
if (historyIndex < messageHistory.length - 1) {
historyIndex++;
userInput.value = messageHistory[messageHistory.length - 1 - historyIndex];
}
userInput.selectionStart = userInput.selectionEnd = userInput.value.length;
userInput.dispatchEvent(new Event('input'));
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
if (historyIndex === -1) return;
historyIndex--;
userInput.value = historyIndex === -1 ? historyDraft : messageHistory[messageHistory.length - 1 - historyIndex];
userInput.selectionStart = userInput.selectionEnd = userInput.value.length;
userInput.dispatchEvent(new Event('input'));
return;
}
});
const toastStyle = document.createElement('style');
toastStyle.textContent = '.toast-error { position:fixed; bottom:80px; left:50%; transform:translateX(-50%); background:var(--danger); color:#fff; padding:12px 24px; border-radius:var(--radius); font-family:var(--font-body); font-size:14px; z-index:9999; animation:fadeIn 0.2s; } @keyframes fadeIn { from{opacity:0;transform:translateX(-50%) translateY(10px)} to{opacity:1;transform:translateX(-50%) translateY(0)} } .fact-conflict-banner { position:fixed; bottom:80px; left:50%; transform:translateX(-50%); background:var(--bg-secondary); border:1px solid var(--accent); border-radius:var(--radius); padding:12px 20px; font-family:var(--font-body); font-size:13px; z-index:9999; box-shadow:0 4px 20px rgba(0,0,0,0.3); max-width:600px; line-height:1.5; } .fact-conflict-banner .actions { margin-top:8px; display:flex; gap:8px; } .fact-conflict-banner button { padding:4px 14px; border-radius:var(--radius); cursor:pointer; font-size:12px; } .fact-conflict-banner .btn-confirm { background:var(--accent); color:var(--bg-primary); border:none; } .fact-conflict-banner .btn-dismiss { background:transparent; color:var(--text-muted); border:1px solid var(--border); }';
document.head.appendChild(toastStyle);
function showFactConflictBanner(conflicts, convId) {
if (!conflicts || !conflicts.length) return;
const existing = document.querySelector('.fact-conflict-banner');
if (existing) existing.remove();
let html = '<div class="fact-conflict-banner">';
html += '<strong>📝 Knowledge conflict detected</strong>';
html += '<div style="margin-top:6px;font-size:12px;color:var(--text-muted);">';
conflicts.forEach((c, i) => {
html += `<div style="margin-top:${i>0?'8':'4'}px">`;
html += `<div><span style="color:var(--text-muted)">Stored:</span> ${escapeHtml(c.old_fact)}</div>`;
html += `<div><span style="color:var(--accent)">Now:</span> ${escapeHtml(c.new_fact)}</div>`;
html += '<div class="actions">';
html += `<button class="btn-confirm" onclick="confirmFactUpdate(${c.memory_id},${Date.now()},this)">Update knowledge</button>`;
html += '<button class="btn-dismiss" onclick="this.closest(\'.fact-conflict-banner\').remove()">Dismiss</button>';
html += '</div></div>';
});
html += '</div></div>';
document.body.insertAdjacentHTML('beforeend', html);
}
async function confirmFactUpdate(memoryId, ts, btn) {
const banner = btn.closest('.fact-conflict-banner');
const text = banner ? banner.textContent : '';
const lines = text.split('\n').map(l => l.trim()).filter(l => l);
const newFact = lines.length > 0 ? lines[lines.length-1].replace(/^Now:\s*/, '') : '';
btn.textContent = 'Updating...';
btn.disabled = true;
try {
const resp = await authFetch('/api/memories/confirm-update', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
memory_id: memoryId,
new_fact: newFact,
old_fact: '',
user_message: '',
assistant_message: '',
}),
});
if (resp.ok) {
btn.textContent = '✓ Updated';
setTimeout(() => { if (banner) banner.remove(); }, 1500);
} else {
btn.textContent = 'Failed';
btn.style.background = 'var(--danger)';
}
} catch(e) {
btn.textContent = 'Error';
btn.style.background = 'var(--danger)';
}
}
userInput.addEventListener('paste', e => {
const hasImage = Array.from(e.clipboardData.items).some(i => i.type.startsWith('image/'));
if (hasImage) {
e.preventDefault();
const toast = document.createElement('div');
toast.className = 'toast-error';
toast.textContent = 'Use the 📎 button to attach images — paste is text only.';
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
});
</script>
</body>
</html>