fbe02b1956
- msg-toolbar now rendered for all roles, not just assistant - addCopyButtons and addMessageToolbar applied to any message with content
1700 lines
92 KiB
HTML
1700 lines
92 KiB
HTML
<!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; position:relative; }
|
||
.main::before, .main::after { content:''; position:absolute; top:0; bottom:0; width:24px; z-index:1; pointer-events:none; background-image:radial-gradient(circle at 12px 14px, rgba(0,0,0,0.25) 2px, transparent 2px),radial-gradient(circle at 12px 14px, var(--bg-primary) 5px, var(--bg-tertiary) 5.5px); background-size:24px 28px; background-repeat:repeat-y; border:1px solid var(--border); background-color:var(--bg-tertiary); }
|
||
.main::before { left:0; border-left:none; border-radius:0 4px 4px 0; }
|
||
.main::after { right:0; border-right:none; border-radius:4px 0 0 4px; }
|
||
.topbar { display:flex; align-items:center; justify-content:space-between; padding:8px 28px; 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); }
|
||
.role-label .msg-time { color:var(--text-muted); font-weight:400; font-size:10px; margin-left:6px; }
|
||
.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 30px; display:flex; flex-direction:column; gap:8px; background-image:radial-gradient(rgba(255,255,255,0.015) 1px, transparent 1px); background-size:24px 24px; }
|
||
.msg-pair { display:flex; flex-direction:column; gap:8px; border-radius:var(--radius); padding:12px 12px 8px; border-left:3px solid transparent; }
|
||
.msg-pair:nth-child(odd) { background:rgba(255,255,255,0.015); border-left-color:rgba(0,136,187,0.25); }
|
||
.msg-pair:nth-child(even) { background:rgba(255,255,255,0.035); border-left-color:rgba(243,156,18,0.25); }
|
||
.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.stopped span { background:var(--text-muted); animation:none; opacity:0.4; }
|
||
.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; }
|
||
.conf-badge { display:inline-block; padding:2px 8px; border-radius:10px; font-family:var(--font-mono); font-size:10px; margin-left:8px; }
|
||
.conf-badge.high { background:rgba(46,204,113,0.15); border:1px solid rgba(46,204,113,0.3); color:var(--success); }
|
||
.conf-badge.medium { background:rgba(243,156,18,0.15); border:1px solid rgba(243,156,18,0.3); color:var(--warning); }
|
||
.conf-badge.low { 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 28px; 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 16px; background:var(--warning); border:none; border-radius:var(--radius); color:#fff; font-family:var(--font-mono); font-size:13px; font-weight:600; cursor:pointer; white-space:nowrap; 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 32px; }
|
||
.chat-container { padding:12px 28px; }
|
||
.input-area { padding:10px 28px; }
|
||
}
|
||
|
||
.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">⚙</button>
|
||
<button class="drawer-btn danger" onclick="deleteAllConversations()" title="Delete all">🗑</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()">×</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">☰</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>
|
||
<span class="tc-item"><span class="tc-label">TOK</span> <span id="tokValue">0</span> / <span id="tokPct" class="tc-val green">0%</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">📄</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">×</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">📎</button>
|
||
<textarea id="userInput" placeholder="Type a message... (Shift+Enter to search, Enter to send)" 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">web 🔍</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()">×</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;
|
||
try { await authFetch(`/api/memories/${rowid}`, { method: 'DELETE' }); } catch(e) { return; }
|
||
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; }
|
||
try { 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' }) }); } catch(e) {}
|
||
}
|
||
|
||
async function saveDefaultModel() {
|
||
if (currentRole !== 'admin') { requireAdminNotice(); return; }
|
||
const model = document.getElementById('defaultModelSetting').value;
|
||
try { await authFetch('/api/settings', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ default_model: model }) }); } catch(e) {}
|
||
}
|
||
|
||
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;
|
||
try { await authFetch('/api/profile', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ content: content }) }); } catch(e) { return; }
|
||
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;
|
||
try { await authFetch(`/api/skills/${encodeURIComponent(skillKey)}`, {
|
||
method: 'PUT',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({ enabled: nextEnabled })
|
||
}); } catch(e) { return; }
|
||
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;
|
||
try { await authFetch('/api/presets', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({name, prompt: p}) }); } catch(e) { return; }
|
||
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;
|
||
try { await authFetch(`/api/presets/${id}`, { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({name, prompt: p}) }); } catch(e) { return; }
|
||
await loadPresets();
|
||
}
|
||
|
||
async function deletePreset(id) {
|
||
if (currentRole !== 'admin') { requireAdminNotice(); return; }
|
||
if (!confirm('Delete this preset?')) return;
|
||
try { await authFetch(`/api/presets/${id}`, { method: 'DELETE' }); } catch(e) { return; }
|
||
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;
|
||
try { await authFetch(`/api/conversations/${convId}`, { method: 'DELETE' }); } catch(e) { return; }
|
||
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;
|
||
try { await authFetch('/api/conversations', { method: 'DELETE' }); } catch(e) { return; }
|
||
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() {
|
||
resetScrollLock();
|
||
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;
|
||
let tokenCount = 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; tokenCount = 0; } fullText += data.token; tokenCount++; 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>`;
|
||
}
|
||
let usedPct = 0;
|
||
if (tokenCount > 0) {
|
||
const pt = data.prompt_tokens || 0;
|
||
const totalTokens = pt + tokenCount;
|
||
const ctxLen = data.context_length || 4096;
|
||
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);
|
||
cumulativeTokens += tokenCount; sessionTokens += tokenCount; updateTopbarTokens(usedPct);
|
||
await loadConversations();
|
||
}
|
||
} catch(e) { console.log('Parse error:', e); }
|
||
}
|
||
}
|
||
} catch (e) {
|
||
if (e.name === 'AbortError') textEl.innerHTML = '<div class="typing-indicator stopped"><span></span><span></span><span></span></div><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() {
|
||
resetScrollLock();
|
||
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; const conf = Math.round((1 / ppl) * 100); const cls = conf >= 80 ? 'high' : conf >= 20 ? 'medium' : 'low'; roleLabel.innerHTML += `<span class="conf-badge ${cls}">${conf}%</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>`;
|
||
}
|
||
let usedPct = 0;
|
||
if (tokenCount > 0 && roleLabel) {
|
||
const pt = data.prompt_tokens || 0;
|
||
const totalTokens = pt + tokenCount;
|
||
const ctxLen = data.context_length || 4096;
|
||
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);
|
||
cumulativeTokens += tokenCount; sessionTokens += tokenCount; updateTopbarTokens(usedPct);
|
||
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 = '<div class="typing-indicator stopped"><span></span><span></span><span></span></div><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';
|
||
const now = new Date();
|
||
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||
const ms = now.getMilliseconds();
|
||
const timeStr = months[now.getMonth()] + ' ' + String(now.getDate()).padStart(2,'0') + ', ' + now.getFullYear() + ' ' + String(now.getHours()).padStart(2,'0') + ':' + String(now.getMinutes()).padStart(2,'0') + ':' + String(now.getSeconds()).padStart(2,'0') + '.' + String(Math.floor(ms / 10)).padStart(2,'0');
|
||
div.innerHTML = `<div class="avatar">${role === 'user' ? 'YOU' : 'AI'}</div><div class="content"><div class="role-label">${role}${role === 'user' ? ' <span class="msg-time">' + timeStr + '</span>' : ''}</div><div class="text">${content ? renderMarkdown(content) : ''}</div><div class="msg-toolbar"></div></div>`;
|
||
if (role === 'user') {
|
||
const pair = document.createElement('div');
|
||
pair.className = 'msg-pair' + (isSearch ? ' search-pair' : '');
|
||
pair.appendChild(div);
|
||
container.prepend(pair);
|
||
} else if (role === 'assistant') {
|
||
const pair = afterEl ? afterEl.closest('.msg-pair') : container.querySelector('.msg-pair');
|
||
if (pair) {
|
||
pair.appendChild(div);
|
||
} else {
|
||
container.prepend(div);
|
||
}
|
||
} else {
|
||
container.prepend(div);
|
||
}
|
||
if (content) { 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());
|
||
if (!msgDiv.classList.contains('search-result')) {
|
||
const rating = { value: 0 };
|
||
const upBtn = document.createElement('button');
|
||
upBtn.innerHTML = '👍';
|
||
upBtn.title = 'Rate up';
|
||
upBtn.onclick = () => {
|
||
rating.value = rating.value === 1 ? 0 : 1;
|
||
upBtn.classList.toggle('active', rating.value === 1);
|
||
dnBtn.classList.toggle('active', false);
|
||
};
|
||
const dnBtn = document.createElement('button');
|
||
dnBtn.innerHTML = '👎';
|
||
dnBtn.title = 'Rate down';
|
||
dnBtn.onclick = () => {
|
||
rating.value = rating.value === -1 ? 0 : -1;
|
||
dnBtn.classList.toggle('active', rating.value === -1);
|
||
upBtn.classList.toggle('active', false);
|
||
};
|
||
toolbar.append(upBtn, dnBtn);
|
||
}
|
||
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; }
|
||
let _userScrolledAway = false;
|
||
document.getElementById('chatContainer')?.addEventListener('scroll', function() {
|
||
_userScrolledAway = this.scrollTop > 100;
|
||
}, { passive: true });
|
||
function scrollToBottom() { const c = document.getElementById('chatContainer'); c.scrollTop = c.scrollHeight; }
|
||
function scrollToTop() {
|
||
if (_userScrolledAway) return;
|
||
const c = document.getElementById('chatContainer');
|
||
if (c) c.scrollTop = 0;
|
||
}
|
||
function resetScrollLock() { _userScrolledAway = false; }
|
||
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 = '';
|
||
let cumulativeTokens = 0;
|
||
let sessionTokens = 0;
|
||
let lastContextPct = 0;
|
||
|
||
function updateTopbarTokens(pct) {
|
||
if (typeof pct === 'number') lastContextPct = pct;
|
||
const countEl = document.getElementById('tokValue');
|
||
const pctEl = document.getElementById('tokPct');
|
||
if (countEl) countEl.textContent = cumulativeTokens.toLocaleString();
|
||
if (pctEl) {
|
||
const cls = lastContextPct >= 95 ? 'red' : lastContextPct >= 80 ? 'orange' : 'green';
|
||
pctEl.textContent = lastContextPct + '%';
|
||
pctEl.className = 'tc-val ' + cls;
|
||
}
|
||
}
|
||
updateTopbarTokens(0);
|
||
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.ctrlKey) { e.preventDefault(); sendMessage(); return; }
|
||
if (e.key === 'Enter' && e.ctrlKey) { e.preventDefault(); sendSearch(); 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>
|