2217 lines
122 KiB
HTML
2217 lines
122 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: var(--accent-dim);
|
||
--scrollbar-hover: var(--accent);
|
||
--scrollbar-track: rgba(0,136,187,0.08);
|
||
--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: 10px; }
|
||
::-webkit-scrollbar-track { background: var(--scrollbar-track); }
|
||
::-webkit-scrollbar-thumb { background: var(--scrollbar); border-radius: 5px; border: 2px solid transparent; background-clip: content-box; }
|
||
::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-hover); border: 2px solid transparent; background-clip: content-box; }
|
||
|
||
.chat-container { scrollbar-width: auto; scrollbar-color: var(--scrollbar) var(--scrollbar-track); }
|
||
|
||
.chat-container::-webkit-scrollbar { width: 10px; }
|
||
.chat-container::-webkit-scrollbar-track { background: var(--scrollbar-track); }
|
||
.chat-container::-webkit-scrollbar-thumb { background: var(--scrollbar); border-radius: 5px; border: 2px solid transparent; background-clip: content-box; }
|
||
.chat-container::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-hover); border: 2px solid transparent; background-clip: content-box; }
|
||
|
||
/* --- 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; }
|
||
.sys-stats { display:flex; align-items:center; justify-content:center; gap:16px; padding:4px 28px; background:var(--bg-primary); border-top:1px solid var(--border); font-size:11px; font-family:var(--font-mono); color:var(--text-muted); flex-shrink:0; overflow-x:auto; white-space:nowrap; }
|
||
.sys-stats .ss-item { display:flex; align-items:center; gap:3px; }
|
||
.sys-stats .ss-label { color:var(--text-muted); text-transform:uppercase; }
|
||
.sys-stats .ss-val { transition:color 0.3s; }
|
||
.sys-stats .ss-val.green { color:var(--success); }
|
||
.sys-stats .ss-val.orange { color:var(--warning); }
|
||
.sys-stats .ss-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; display:flex; align-items:center; gap:6px; }
|
||
.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); }
|
||
.theme-picker { position:relative; }
|
||
.theme-dropdown { display:none; position:absolute; top:100%; right:0; margin-top:6px; background:var(--bg-secondary); border:1px solid var(--border); border-radius:var(--radius); min-width:150px; z-index:2000; box-shadow:0 4px 20px rgba(0,0,0,0.4); }
|
||
.theme-dropdown.open { display:block; }
|
||
.theme-option { display:flex; align-items:center; gap:8px; padding:8px 14px; font-size:12px; font-family:var(--font-mono); color:var(--text-secondary); cursor:pointer; transition:background 0.12s; }
|
||
.theme-option:hover { background:var(--bg-hover); color:var(--text-primary); }
|
||
.theme-option.active { color:var(--accent); background:var(--accent-glow); }
|
||
.status-menu { position:relative; }
|
||
.status-menu-btn { 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; }
|
||
.status-menu-btn:hover { background:var(--bg-hover); border-color:var(--accent-dim); color:var(--accent); }
|
||
.status-dropdown { display:none; position:absolute; top:100%; right:0; margin-top:6px; background:var(--bg-secondary); border:1px solid var(--border); border-radius:var(--radius); min-width:180px; z-index:2000; box-shadow:0 4px 20px rgba(0,0,0,0.4); font-family:var(--font-mono); font-size:12px; }
|
||
.status-dropdown.open { display:block; }
|
||
.status-dropdown-item { display:flex; align-items:center; justify-content:space-between; padding:8px 14px; color:var(--text-secondary); cursor:pointer; transition:background 0.12s; gap:10px; }
|
||
.status-dropdown-item:hover { background:var(--bg-hover); color:var(--text-primary); }
|
||
.status-dropdown-item .sdi-label { flex:1; white-space:nowrap; }
|
||
.status-dropdown-item .sdi-toggle { font-size:10px; padding:2px 8px; border-radius:8px; border:1px solid; }
|
||
.status-dropdown-item .sdi-toggle.on { border-color:rgba(0,255,102,0.3); color:var(--success); background:rgba(0,255,102,0.08); }
|
||
.status-dropdown-item .sdi-toggle.off { border-color:rgba(255,51,85,0.3); color:var(--danger); background:rgba(255,51,85,0.08); }
|
||
.status-dropdown-divider { height:1px; background:var(--border); margin:4px 0; }
|
||
.theme-swatch { width:14px; height:14px; border-radius:3px; border:1px solid var(--border); flex-shrink:0; }
|
||
.swatch-ibm { background:#0088bb; }
|
||
.swatch-green { background:#00cc33; }
|
||
.swatch-dark { background:#7b68ee; }
|
||
.swatch-light { background:#e0e0e0; }
|
||
.swatch-amber { background:#cc8800; }
|
||
.swatch-trip { background:linear-gradient(135deg,#ff00ff,#ffff00,#00ff66); }
|
||
.info-icon { display:inline-flex; align-items:center; justify-content:center; width:18px; height:18px; border-radius:50%; border:1px solid var(--border); color:var(--text-muted); font-size:11px; cursor:pointer; user-select:none; transition:all 0.15s; }
|
||
.info-icon:hover { border-color:var(--accent-dim); color:var(--accent); }
|
||
.info-popup-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:1000; display:flex; align-items:center; justify-content:center; }
|
||
.info-popup-modal { background:var(--bg-secondary); border:1px solid var(--border); border-radius:12px; padding:24px; max-width:400px; width:90%; font-size:13px; line-height:1.7; color:var(--text-primary); position:relative; box-shadow:0 8px 32px rgba(0,0,0,0.5); }
|
||
.info-popup-close { position:absolute; top:8px; right:12px; background:none; border:none; color:var(--text-muted); font-size:22px; cursor:pointer; line-height:1; }
|
||
.info-popup-close:hover { color:var(--text-primary); }
|
||
.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; }
|
||
.rag-stats-bar { display:flex; flex-wrap:wrap; gap:12px 24px; padding:10px 14px; background:var(--bg-tertiary); border-radius:var(--radius); margin-bottom:14px; font-size:11px; font-family:var(--font-mono); }
|
||
.rag-stat-item { display:flex; gap:4px; align-items:center; }
|
||
.rag-stat-label { color:var(--text-muted); text-transform:uppercase; }
|
||
.rag-stat-value { color:var(--text-primary); font-weight:600; }
|
||
.rag-toolbar { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:12px; flex-wrap:wrap; }
|
||
.rag-toolbar-left { display:flex; gap:8px; align-items:center; flex:1; }
|
||
.rag-toolbar-right { display:flex; gap:8px; align-items:center; flex-shrink:0; }
|
||
.rag-search-input { flex:1; min-width:160px; background:var(--bg-tertiary); border:1px solid var(--border); color:var(--text-primary); font-family:var(--font-mono); font-size:12px; padding:8px 12px; border-radius:var(--radius); }
|
||
.rag-search-input:focus { outline:none; border-color:var(--accent-dim); }
|
||
.rag-search-hint { font-size:10px; color:var(--text-muted); margin:-6px 0 12px 0; font-family:var(--font-mono); line-height:1.4; }
|
||
.rag-source-select { background:var(--bg-tertiary); border:1px solid var(--border); color:var(--text-primary); font-family:var(--font-mono); font-size:12px; padding:7px 10px; border-radius:var(--radius); cursor:pointer; }
|
||
.rag-source-select:focus { outline:none; border-color:var(--accent-dim); }
|
||
.rag-table-wrap { overflow-x:auto; margin-bottom:12px; }
|
||
.rag-table { width:100%; border-collapse:collapse; font-size:11px; font-family:var(--font-mono); }
|
||
.rag-table th { text-align:left; padding:8px 6px; border-bottom:1px solid var(--border); color:var(--text-secondary); text-transform:uppercase; letter-spacing:0.5px; font-size:10px; white-space:nowrap; }
|
||
.rag-table td { padding:8px 6px; border-bottom:1px solid var(--border); color:var(--text-primary); vertical-align:top; }
|
||
.rag-table tr:hover td { background:var(--bg-hover); }
|
||
.rag-col-id { width:80px; }
|
||
.rag-col-text { min-width:200px; }
|
||
.rag-col-source { width:100px; }
|
||
.rag-col-type { width:80px; }
|
||
.rag-col-date { width:140px; }
|
||
.rag-col-retr { width:50px; text-align:center; }
|
||
.rag-col-actions { width:80px; text-align:center; white-space:nowrap; }
|
||
.rag-text-snippet { display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; line-height:1.4; word-break:break-word; cursor:pointer; }
|
||
.rag-text-snippet.expanded { -webkit-line-clamp:unset; }
|
||
.rag-action-btn { background:none; border:1px solid var(--border); border-radius:var(--radius); cursor:pointer; font-size:11px; padding:3px 7px; color:var(--text-muted); transition:all 0.15s; }
|
||
.rag-action-btn:hover { border-color:var(--accent-dim); color:var(--accent); }
|
||
.rag-action-btn.danger:hover { border-color:var(--danger); color:var(--danger); }
|
||
.rag-pagination { display:flex; align-items:center; justify-content:center; gap:12px; padding:8px 0; }
|
||
.rag-page-info { font-size:11px; color:var(--text-muted); font-family:var(--font-mono); }
|
||
.rag-edit-modal { max-width:700px; }
|
||
.rag-edit-textarea { width:100%; min-height:200px; 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; }
|
||
.rag-edit-textarea:focus { outline:none; border-color:var(--accent-dim); }
|
||
.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; margin-right:28px; }
|
||
.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; 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-bottom: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); }
|
||
.user-copy-btn { display:inline; cursor:pointer; font-size:13px; margin-left:6px; opacity:0.4; transition:opacity 0.15s; user-select:none; }
|
||
.user-copy-btn:hover { opacity:1; }
|
||
.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:var(--bg-tertiary); 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:8px 14px; gap:6px; }
|
||
.chat-container { padding:12px 14px; }
|
||
.input-area { padding:8px 14px; }
|
||
.sys-stats { padding:3px 14px; gap:12px; font-size:10px; }
|
||
.topbar-brand { font-size:14px; }
|
||
.topbar-brand .version { font-size:11px; }
|
||
}
|
||
|
||
.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="openRagAdmin()" title="RAG corpus management">R</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="ragAdminModal">
|
||
<div class="modal" style="max-width:960px;">
|
||
<div class="modal-header">
|
||
<h2>RAG Corpus Management</h2>
|
||
<button class="modal-close" onclick="closeRagAdmin()">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="rag-stats-bar" id="ragStatsBar">Loading stats...</div>
|
||
<div class="rag-toolbar">
|
||
<div class="rag-toolbar-left">
|
||
<input type="text" id="ragSearchInput" class="rag-search-input" placeholder="Search RAG text (semantic vector search)..." onkeydown="if(event.key==='Enter')loadRagPoints()">
|
||
<select id="ragSourceFilter" class="rag-source-select" onchange="loadRagPoints()">
|
||
<option value="">All sources</option>
|
||
</select>
|
||
</div>
|
||
<div class="rag-toolbar-right">
|
||
<button class="btn-small btn-save" onclick="loadRagPoints()" id="ragRefreshBtn">↻ Refresh</button>
|
||
<button class="btn-small btn-reset" onclick="flushRagCollection()" style="color:var(--danger);border-color:var(--danger);">Flush All</button>
|
||
</div>
|
||
</div>
|
||
<div class="rag-search-hint">Leave search blank to browse all entries. Search uses vector similarity against uploaded/ingested document text.</div>
|
||
<div class="rag-table-wrap">
|
||
<table class="rag-table" id="ragTable">
|
||
<thead>
|
||
<tr>
|
||
<th class="rag-col-id">ID</th>
|
||
<th class="rag-col-text">Text</th>
|
||
<th class="rag-col-source">Source</th>
|
||
<th class="rag-col-type">Type</th>
|
||
<th class="rag-col-date">Date</th>
|
||
<th class="rag-col-retr">Retr</th>
|
||
<th class="rag-col-actions">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="ragTableBody">
|
||
<tr><td colspan="7" style="text-align:center;color:var(--text-muted);padding:40px;">No points loaded</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div class="rag-pagination" id="ragPagination" style="display:none;">
|
||
<button class="btn-small" id="ragPrevBtn" onclick="ragPage(-1)">◀ Prev</button>
|
||
<span class="rag-page-info" id="ragPageInfo">Page 1</span>
|
||
<button class="btn-small" id="ragNextBtn" onclick="ragPage(1)">Next ▶</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<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 class="theme-picker"><button class="badge" id="themeBtn" onclick="toggleThemePicker()" title="Color scheme">🎨</button><div class="theme-dropdown" id="themeDropdown"><div class="theme-option active" data-theme="ibm-blue" onclick="applyTheme('ibm-blue')"><span class="theme-swatch swatch-ibm"></span>IBM Blue</div><div class="theme-option" data-theme="green-bg" onclick="applyTheme('green-bg')"><span class="theme-swatch swatch-green"></span>Green Ln</div><div class="theme-option" data-theme="dark" onclick="applyTheme('dark')"><span class="theme-swatch swatch-dark"></span>Dark</div><div class="theme-option" data-theme="light" onclick="applyTheme('light')"><span class="theme-swatch swatch-light"></span>Light</div><div class="theme-option" data-theme="amber" onclick="applyTheme('amber')"><span class="theme-swatch swatch-amber"></span>Amber</div><div class="theme-option" data-theme="trippin" onclick="applyTheme('trippin')"><span class="theme-swatch swatch-trip"></span>Trippin</div></div></span></span>
|
||
</div>
|
||
|
||
<div class="topbar-right">
|
||
<button class="badge admin" id="authActionBtn" onclick="handleAuthAction()" title="Unlock admin">ADMIN</button>
|
||
<div class="status-menu">
|
||
<button class="status-menu-btn" id="statusMenuBtn" onclick="toggleStatusMenu()" title="Settings & status">⋮</button>
|
||
<div class="status-dropdown" id="statusDropdown">
|
||
<div class="status-dropdown-item" onclick="toggleMemory()">
|
||
<span class="sdi-label">Memory</span>
|
||
<span class="sdi-toggle on" id="memoryBadge">MEM</span>
|
||
</div>
|
||
<div class="status-dropdown-item" onclick="toggleSearch()">
|
||
<span class="sdi-label">Web Search</span>
|
||
<span class="sdi-toggle on" id="searchBadge">SEARCH</span>
|
||
</div>
|
||
<div class="status-dropdown-item" onclick="toggleProfile()">
|
||
<span class="sdi-label">Profile</span>
|
||
<span class="sdi-toggle on" id="profileBadge">PROFILE</span>
|
||
</div>
|
||
<div class="status-dropdown-item" onclick="toggleDirection()">
|
||
<span class="sdi-label">Sort</span>
|
||
<span class="sdi-toggle on" id="directionBadge">↓ NEW</span>
|
||
</div>
|
||
<div class="status-dropdown-item" onclick="togglePrivate()">
|
||
<span class="sdi-label">Privacy</span>
|
||
<span class="sdi-toggle off" id="privateBadge">OFF</span>
|
||
</div>
|
||
<div class="status-dropdown-divider"></div>
|
||
<div class="status-dropdown-item" onclick="togglePrivateInfo(event)">
|
||
<span class="sdi-label" style="color:var(--text-muted);">ⓘ About Privacy</span>
|
||
<span></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="info-popup-overlay" id="privateInfoOverlay" style="display:none" onclick="togglePrivateInfo(event)">
|
||
<div class="info-popup-modal" onclick="event.stopPropagation()">
|
||
<button class="info-popup-close" onclick="togglePrivateInfo(event)">×</button>
|
||
<strong>🔒 Privacy Mode</strong><br><br>
|
||
<b>When enabled:</b><br>
|
||
• Nothing is saved to the database<br>
|
||
• No memory or RAG context is injected<br>
|
||
• Web search is disabled (no external queries)<br>
|
||
• Messages are not auto-ingested as facts<br>
|
||
• No conversation history is kept after refresh<br><br>
|
||
<b>When disabled:</b><br>
|
||
• All conversations are persisted to disk (encrypted at rest)<br>
|
||
• Queries may be stored for context and memory<br>
|
||
• Web search available (SearXNG)
|
||
</div>
|
||
</div>
|
||
</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... (Ctrl+Enter to Online Search, Enter to Private Search)" 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">WEB 🔍</button>
|
||
</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>
|
||
|
||
<div class="sys-stats" id="sysStats">
|
||
<span class="ss-item"><span class="ss-label">CPU</span> <span id="cpuValue" class="ss-val">--%</span></span>
|
||
<span class="ss-item"><span class="ss-label">MEM</span> <span id="memValue" class="ss-val">--%</span></span>
|
||
<span class="ss-item"><span class="ss-label">GPU</span> <span id="gpuValue" class="ss-val">--%</span></span>
|
||
<span class="ss-item"><span class="ss-label">VRAM</span> <span id="vramValue" class="ss-val">--%</span></span>
|
||
<span class="ss-item"><span class="ss-label">TOK</span> <span id="tokValue">0</span> / <span id="tokPct" class="ss-val green">0%</span></span>
|
||
</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>
|
||
</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 privateEnabled = false;
|
||
let presets = [];
|
||
let skillsRegistry = [];
|
||
let currentModel = '';
|
||
function modelLabel() {
|
||
if (!currentModel) return 'assistant';
|
||
return currentModel.replace(/^(\w[\w.]*)[-\/:](\d+)[bB]?[-:](.*)$/, (m, fam, size, variant) => {
|
||
const v = variant.replace(/^(instruct|code|text|chat).*$/i, (_, v) => v === 'code' ? 'c' : 'i');
|
||
return `${fam}:${size}B:${v}`;
|
||
});
|
||
}
|
||
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;
|
||
const savedTheme = localStorage.getItem('caic-theme') || 'ibm-blue';
|
||
applyTheme(savedTheme);
|
||
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; }
|
||
showToast('Memory deleted');
|
||
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 = 'ss-val ' + colorClass(data.cpu_percent);
|
||
document.getElementById('memValue').textContent = data.memory_percent + '%';
|
||
document.getElementById('memValue').className = 'ss-val ' + colorClass(data.memory_percent);
|
||
if (data.gpu_available) {
|
||
document.getElementById('gpuValue').textContent = data.gpu_percent + '%';
|
||
document.getElementById('gpuValue').className = 'ss-val ' + colorClass(data.gpu_percent);
|
||
document.getElementById('vramValue').textContent = data.vram_percent + '%';
|
||
document.getElementById('vramValue').className = 'ss-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.disabled = !data.available;
|
||
} catch(e) {
|
||
document.getElementById('searchStatus').innerHTML = '<span class="status-dot offline"></span> search: error';
|
||
const searchBtn = document.getElementById('searchBtn');
|
||
if (searchBtn) searchBtn.disabled = true;
|
||
}
|
||
}
|
||
|
||
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();
|
||
showToast('Profile saved');
|
||
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();
|
||
showToast('Profile reset');
|
||
} catch(e) { showToast('Profile reset failed'); }
|
||
}
|
||
|
||
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 togglePrivate() {
|
||
privateEnabled = !privateEnabled;
|
||
const badge = document.getElementById('privateBadge');
|
||
badge.className = 'sdi-toggle ' + (privateEnabled ? 'on' : 'off');
|
||
badge.textContent = privateEnabled ? 'ON' : 'OFF';
|
||
const searchBtn = document.getElementById('searchBtn');
|
||
if (privateEnabled) {
|
||
if (searchBtn) searchBtn.disabled = true;
|
||
showToast('Privacy: nothing stored, no external queries');
|
||
} else {
|
||
if (searchBtn && !isStreaming) searchBtn.disabled = false;
|
||
showToast('Standard mode: conversations persisted (encrypted at rest)');
|
||
}
|
||
}
|
||
|
||
function togglePrivateInfo(e) {
|
||
const overlay = document.getElementById('privateInfoOverlay');
|
||
overlay.style.display = overlay.style.display === 'none' ? 'flex' : 'none';
|
||
}
|
||
|
||
// ---------- Theme system ----------
|
||
const THEMES = {
|
||
'ibm-blue': {
|
||
'--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',
|
||
},
|
||
'green-bg': {
|
||
'--bg-primary': '#000000', '--bg-secondary': '#0a0a0a', '--bg-tertiary': '#111111', '--bg-hover': '#1a1a1a',
|
||
'--text-primary': '#00ff41', '--text-secondary': '#00cc33', '--text-muted': '#008800',
|
||
'--accent': '#00ff41', '--accent-dim': '#00aa22', '--accent-glow': 'rgba(0,255,65,0.1)',
|
||
'--danger': '#ff3355', '--danger-hover': '#cc0022', '--success': '#00ff66', '--warning': '#ffaa00',
|
||
'--border': '#003300',
|
||
},
|
||
'dark': {
|
||
'--bg-primary': '#1a1a2e', '--bg-secondary': '#222240', '--bg-tertiary': '#2a2a50', '--bg-hover': '#333366',
|
||
'--text-primary': '#e0e0ff', '--text-secondary': '#b0b0dd', '--text-muted': '#8080aa',
|
||
'--accent': '#bb86fc', '--accent-dim': '#8855cc', '--accent-glow': 'rgba(187,134,252,0.1)',
|
||
'--danger': '#cf6679', '--danger-hover': '#aa4455', '--success': '#00ff66', '--warning': '#ffaa00',
|
||
'--border': '#333366',
|
||
},
|
||
'light': {
|
||
'--bg-primary': '#f5f5f5', '--bg-secondary': '#ffffff', '--bg-tertiary': '#e8e8e8', '--bg-hover': '#dddddd',
|
||
'--text-primary': '#222222', '--text-secondary': '#555555', '--text-muted': '#888888',
|
||
'--accent': '#0066cc', '--accent-dim': '#004488', '--accent-glow': 'rgba(0,102,204,0.1)',
|
||
'--danger': '#cc0033', '--danger-hover': '#990022', '--success': '#008844', '--warning': '#cc8800',
|
||
'--border': '#cccccc',
|
||
},
|
||
'amber': {
|
||
'--bg-primary': '#000000', '--bg-secondary': '#0d0800', '--bg-tertiary': '#1a1000', '--bg-hover': '#2a1a00',
|
||
'--text-primary': '#ffb000', '--text-secondary': '#cc8800', '--text-muted': '#886600',
|
||
'--accent': '#ffb000', '--accent-dim': '#cc8800', '--accent-glow': 'rgba(255,176,0,0.1)',
|
||
'--danger': '#ff3355', '--danger-hover': '#cc0022', '--success': '#00ff66', '--warning': '#ffaa00',
|
||
'--border': '#332200',
|
||
},
|
||
'trippin': {
|
||
'--bg-primary': '#0a001a', '--bg-secondary': '#15002a', '--bg-tertiary': '#20003a', '--bg-hover': '#300050',
|
||
'--text-primary': '#ff66ff', '--text-secondary': '#ff33cc', '--text-muted': '#cc0099',
|
||
'--accent': '#ff00ff', '--accent-dim': '#cc00cc', '--accent-glow': 'rgba(255,0,255,0.15)',
|
||
'--danger': '#ff0066', '--danger-hover': '#cc0044', '--success': '#00ff66', '--warning': '#ffff00',
|
||
'--border': '#440066',
|
||
},
|
||
};
|
||
|
||
function applyTheme(name) {
|
||
const theme = THEMES[name];
|
||
if (!theme) return;
|
||
const root = document.documentElement;
|
||
for (const [key, val] of Object.entries(theme)) {
|
||
root.style.setProperty(key, val);
|
||
}
|
||
const scrollbarTrack = name === 'light' ? 'rgba(0,68,136,0.08)' :
|
||
name === 'trippin' ? 'rgba(204,0,204,0.08)' :
|
||
name === 'green-bg' ? 'rgba(0,170,34,0.08)' :
|
||
name === 'amber' ? 'rgba(204,136,0,0.08)' :
|
||
name === 'dark' ? 'rgba(136,85,204,0.08)' :
|
||
'rgba(0,136,187,0.08)';
|
||
root.style.setProperty('--scrollbar-track', scrollbarTrack);
|
||
root.style.setProperty('--scrollbar', theme['--accent-dim']);
|
||
root.style.setProperty('--scrollbar-hover', theme['--accent']);
|
||
|
||
document.querySelectorAll('.theme-option').forEach(el => {
|
||
el.classList.toggle('active', el.dataset.theme === name);
|
||
});
|
||
localStorage.setItem('caic-theme', name);
|
||
}
|
||
|
||
function toggleThemePicker() {
|
||
document.getElementById('themeDropdown').classList.toggle('open');
|
||
}
|
||
function toggleStatusMenu() {
|
||
document.getElementById('statusDropdown').classList.toggle('open');
|
||
document.getElementById('privateInfoOverlay').style.display = 'none';
|
||
}
|
||
document.addEventListener('click', function(e) {
|
||
const picker = document.querySelector('.theme-picker');
|
||
if (picker && !picker.contains(e.target)) {
|
||
document.getElementById('themeDropdown')?.classList.remove('open');
|
||
}
|
||
const menu = document.querySelector('.status-menu');
|
||
if (menu && !menu.contains(e.target)) {
|
||
document.getElementById('statusDropdown')?.classList.remove('open');
|
||
}
|
||
});
|
||
|
||
function updateProfileUI() {
|
||
const badge = document.getElementById('profileBadge');
|
||
const toggle = document.getElementById('profileToggle');
|
||
badge.className = 'sdi-toggle ' + (profileEnabled ? 'on' : 'off');
|
||
badge.textContent = profileEnabled ? 'ON' : 'OFF';
|
||
if (toggle) toggle.className = 'toggle-switch' + (profileEnabled ? ' on' : '');
|
||
}
|
||
|
||
function updateSearchUI() {
|
||
const badge = document.getElementById('searchBadge');
|
||
const toggle = document.getElementById('searchToggle');
|
||
badge.className = 'sdi-toggle ' + (searchEnabled ? 'on' : 'off');
|
||
badge.innerHTML = searchEnabled ? 'ON' : 'OFF';
|
||
if (toggle) toggle.className = 'toggle-switch' + (searchEnabled ? ' on' : '');
|
||
}
|
||
|
||
function updateMemoryUI() {
|
||
const badge = document.getElementById('memoryBadge');
|
||
const toggle = document.getElementById('memoryToggle');
|
||
badge.className = 'sdi-toggle ' + (memoryEnabled ? 'on' : 'off');
|
||
badge.innerHTML = memoryEnabled ? 'ON' : '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; }
|
||
showToast('Preset added');
|
||
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; }
|
||
showToast('Preset saved');
|
||
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; }
|
||
showToast('Preset deleted');
|
||
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(); });
|
||
|
||
// ---------- RAG Admin ----------
|
||
let _ragPageOffset = null;
|
||
let _ragPrevOffsets = [];
|
||
let _ragNextOffset = null;
|
||
let _ragTotal = 0;
|
||
|
||
function openRagAdmin() {
|
||
if (currentRole !== 'admin') { requireAdminNotice(); return; }
|
||
document.getElementById('ragAdminModal').classList.add('visible');
|
||
_ragPageOffset = null;
|
||
_ragPrevOffsets = [];
|
||
_ragNextOffset = null;
|
||
_ragTotal = 0;
|
||
loadRagStats();
|
||
loadRagPoints();
|
||
populateRagSourceFilter();
|
||
}
|
||
function closeRagAdmin() { document.getElementById('ragAdminModal').classList.remove('visible'); }
|
||
document.getElementById('ragAdminModal').addEventListener('click', e => { if (e.target.id === 'ragAdminModal') closeRagAdmin(); });
|
||
|
||
async function loadRagStats() {
|
||
try {
|
||
const resp = await authFetch('/api/rag/stats');
|
||
const data = await resp.json();
|
||
const bar = document.getElementById('ragStatsBar');
|
||
bar.innerHTML = `
|
||
<span class="rag-stat-item"><span class="rag-stat-label">Vectors</span> <span class="rag-stat-value">${data.vector_count}</span></span>
|
||
<span class="rag-stat-item"><span class="rag-stat-label">Max</span> <span class="rag-stat-value">${data.max_vectors}</span></span>
|
||
<span class="rag-stat-item"><span class="rag-stat-label">Full</span> <span class="rag-stat-value">${data.percent_full}%</span></span>
|
||
<span class="rag-stat-item"><span class="rag-stat-label">Pinned</span> <span class="rag-stat-value">${data.pinned_count}</span></span>
|
||
<span class="rag-stat-item"><span class="rag-stat-label">Avg Retr</span> <span class="rag-stat-value">${data.avg_retrieval_count}</span></span>
|
||
<span class="rag-stat-item"><span class="rag-stat-label">At Risk</span> <span class="rag-stat-value">${data.at_risk_count}</span></span>
|
||
<span class="rag-stat-item"><span class="rag-stat-label">Evictions (1m)</span> <span class="rag-stat-value">${data.eviction_counts_last_1m}</span></span>
|
||
`;
|
||
} catch(e) {
|
||
document.getElementById('ragStatsBar').textContent = 'Failed to load stats';
|
||
}
|
||
}
|
||
|
||
async function populateRagSourceFilter() {
|
||
const sel = document.getElementById('ragSourceFilter');
|
||
if (sel.options.length > 1) return;
|
||
try {
|
||
const resp = await authFetch('/api/rag/points?limit=100');
|
||
const data = await resp.json();
|
||
const sources = new Set();
|
||
(data.points || []).forEach(p => { if (p.source) sources.add(p.source); });
|
||
const sorted = Array.from(sources).sort();
|
||
sorted.forEach(s => {
|
||
const opt = document.createElement('option');
|
||
opt.value = s;
|
||
opt.textContent = s;
|
||
sel.appendChild(opt);
|
||
});
|
||
} catch(e) {}
|
||
}
|
||
|
||
async function loadRagPoints() {
|
||
const table = document.getElementById('ragTableBody');
|
||
table.innerHTML = '<tr><td colspan="7" style="text-align:center;color:var(--text-muted);padding:40px;">Loading...</td></tr>';
|
||
try {
|
||
const search = document.getElementById('ragSearchInput').value.trim();
|
||
const source = document.getElementById('ragSourceFilter').value;
|
||
let url = `/api/rag/points?limit=20`;
|
||
if (search) url += `&search=${encodeURIComponent(search)}`;
|
||
if (source) url += `&source=${encodeURIComponent(source)}`;
|
||
if (_ragPageOffset !== null && !search) url += `&offset=${encodeURIComponent(_ragPageOffset)}`;
|
||
|
||
const resp = await authFetch(url);
|
||
const data = await resp.json();
|
||
_ragTotal = data.total || 0;
|
||
_ragNextOffset = data.next_offset || null;
|
||
|
||
table.innerHTML = '';
|
||
if (!data.points || data.points.length === 0) {
|
||
const emptyMsg = document.getElementById('ragSearchInput').value.trim()
|
||
? 'No results match your search query.'
|
||
: 'RAG corpus is empty. Upload a file with the 📎 button (mode=ingest or both), or use /api/ingest from the terminal.';
|
||
table.innerHTML = `<tr><td colspan="7" style="text-align:center;color:var(--text-muted);padding:30px;line-height:1.6;">${emptyMsg}</td></tr>`;
|
||
document.getElementById('ragPagination').style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
data.points.forEach(p => {
|
||
const tr = document.createElement('tr');
|
||
const textSnippet = (p.text || '').substring(0, 200);
|
||
tr.innerHTML = `
|
||
<td class="rag-col-id" title="${escapeHtml(p.id)}">${escapeHtml((p.id || '').substring(0, 24))}${(p.id || '').length > 24 ? '...' : ''}</td>
|
||
<td class="rag-col-text"><div class="rag-text-snippet" onclick="this.classList.toggle('expanded')" title="Click to expand/collapse">${escapeHtml(textSnippet)}</div></td>
|
||
<td class="rag-col-source">${escapeHtml(p.source || '')}</td>
|
||
<td class="rag-col-type">${escapeHtml(p.type || '')}</td>
|
||
<td class="rag-col-date" style="white-space:nowrap;">${escapeHtml((p.date || '').substring(0, 16))}</td>
|
||
<td class="rag-col-retr">${p.retrieval_count || 0}</td>
|
||
<td class="rag-col-actions">
|
||
<button class="rag-action-btn" onclick="editRagPoint('${escapeHtml(p.id)}', this)" title="Edit">✎</button>
|
||
<button class="rag-action-btn danger" onclick="deleteRagPoint('${escapeHtml(p.id)}')" title="Delete">✕</button>
|
||
</td>
|
||
`;
|
||
table.appendChild(tr);
|
||
});
|
||
|
||
const pagination = document.getElementById('ragPagination');
|
||
if (!search) {
|
||
pagination.style.display = 'flex';
|
||
document.getElementById('ragPageInfo').textContent = `Page ${_ragPrevOffsets.length + 1} (${_ragTotal} total)`;
|
||
document.getElementById('ragPrevBtn').disabled = _ragPrevOffsets.length === 0;
|
||
document.getElementById('ragNextBtn').disabled = !_ragNextOffset;
|
||
} else {
|
||
pagination.style.display = 'flex';
|
||
document.getElementById('ragPageInfo').textContent = `${data.points.length} results`;
|
||
document.getElementById('ragPrevBtn').disabled = true;
|
||
document.getElementById('ragNextBtn').disabled = true;
|
||
}
|
||
} catch(e) {
|
||
table.innerHTML = '<tr><td colspan="7" style="text-align:center;color:var(--danger);padding:40px;">Failed to load points</td></tr>';
|
||
}
|
||
}
|
||
|
||
function ragPage(dir) {
|
||
if (dir === -1 && _ragPrevOffsets.length > 0) {
|
||
_ragPageOffset = _ragPrevOffsets.pop();
|
||
loadRagPoints();
|
||
} else if (dir === 1 && _ragNextOffset) {
|
||
_ragPrevOffsets.push(_ragPageOffset);
|
||
_ragPageOffset = _ragNextOffset;
|
||
_ragNextOffset = null;
|
||
loadRagPoints();
|
||
}
|
||
}
|
||
|
||
async function deleteRagPoint(pointId) {
|
||
if (!confirm('Delete this RAG point?')) return;
|
||
try {
|
||
const resp = await authFetch(`/api/rag/point/${encodeURIComponent(pointId)}`, { method: 'DELETE' });
|
||
if (!resp.ok) { showToast('Delete failed'); return; }
|
||
showToast('Point deleted');
|
||
loadRagStats();
|
||
loadRagPoints();
|
||
} catch(e) { showToast('Delete error'); }
|
||
}
|
||
|
||
async function editRagPoint(pointId, btn) {
|
||
const row = btn.closest('tr');
|
||
const currentText = row ? row.querySelector('.rag-text-snippet')?.textContent : '';
|
||
const newText = prompt('Edit RAG text:', currentText || '');
|
||
if (newText === null || newText.trim() === '' || newText === currentText) return;
|
||
try {
|
||
const resp = await authFetch(`/api/rag/point/${encodeURIComponent(pointId)}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ text: newText }),
|
||
});
|
||
if (!resp.ok) { showToast('Update failed'); return; }
|
||
showToast('Point updated, re-embedded');
|
||
loadRagStats();
|
||
loadRagPoints();
|
||
} catch(e) { showToast('Update error'); }
|
||
}
|
||
|
||
async function flushRagCollection() {
|
||
if (!confirm('PERMANENTLY DELETE ALL RAG VECTORS? This cannot be undone.')) return;
|
||
if (!confirm('Are you absolutely sure? All RAG corpus entries will be lost.')) return;
|
||
try {
|
||
const resp = await authFetch('/api/rag/flush', { method: 'POST' });
|
||
const data = await resp.json();
|
||
showToast(`Flushed ${data.deleted_count} points`);
|
||
loadRagStats();
|
||
loadRagPoints();
|
||
} catch(e) { showToast('Flush failed'); }
|
||
}
|
||
|
||
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, false, null, msg.perplexity); conversationHistory.push({ role: msg.role, content: msg.content }); });
|
||
scrollToLatest();
|
||
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; }
|
||
showToast('Conversation deleted');
|
||
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; }
|
||
showToast('All conversations deleted');
|
||
currentConvId = null;
|
||
conversationHistory = [];
|
||
showWelcome();
|
||
await loadConversations();
|
||
}
|
||
|
||
function newChat() {
|
||
currentConvId = null;
|
||
conversationHistory = [];
|
||
showWelcome();
|
||
document.querySelectorAll('.conv-item').forEach(el => el.classList.remove('active'));
|
||
showToast('New conversation');
|
||
}
|
||
|
||
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() {
|
||
if (privateEnabled) { showToast('Web search is disabled in private chat mode'); return; }
|
||
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();
|
||
const userDiv = appendMessage('user', '🔍 ' + query, true);
|
||
conversationHistory.push({ role: 'user', content: '🔍 ' + query });
|
||
input.value = '';
|
||
input.style.height = 'auto';
|
||
const assistantDiv = appendMessage('assistant', '', true, true, userDiv);
|
||
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); scrollToLatest(); }
|
||
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 = '';
|
||
showToast('File removed');
|
||
}
|
||
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();
|
||
showToast('Attachment deleted');
|
||
}
|
||
} catch(e) { showToast('Delete failed'); }
|
||
}
|
||
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();
|
||
const userDiv = 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, userDiv);
|
||
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;
|
||
if (privateEnabled) chatBody.private_chat = true;
|
||
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); scrollToLatest(); }
|
||
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(Math.max(0, Math.min(100, (1 - (ppl - 1) / 10) * 100))); const cls = conf >= 80 ? 'high' : conf >= 15 ? '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 = privateEnabled;
|
||
}
|
||
}
|
||
|
||
function appendMessage(role, content, animate, isSearch = false, afterEl = null, perplexity = 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');
|
||
const textHtml = content ? renderMarkdown(content) : '';
|
||
const copyIcon = role === 'user' && content ? `<span class="user-copy-btn" data-content="${escapeHtml(content).replace(/"/g,'"')}" onclick="var el=this;event.stopPropagation();var t=el.getAttribute('data-content');function d(){el.textContent='✓';setTimeout(function(){el.innerHTML='📋'},1500);showToast('Copied')}if(navigator.clipboard){navigator.clipboard.writeText(t).then(d).catch(function(){execCopy(t);d()})}else{execCopy(t);d()}">📋</span>` : '';
|
||
let roleLabelHtml = role === 'assistant' ? modelLabel() : role;
|
||
roleLabelHtml += role === 'user' ? ' <span class="msg-time">' + timeStr + '</span>' : '';
|
||
if (role === 'assistant' && perplexity != null) {
|
||
const ppl = parseFloat(perplexity);
|
||
if (!isNaN(ppl)) {
|
||
const conf = Math.round(Math.max(0, Math.min(100, (1 - (ppl - 1) / 10) * 100)));
|
||
const cls = conf >= 80 ? 'high' : conf >= 15 ? 'medium' : 'low';
|
||
roleLabelHtml += `<span class="conf-badge ${cls}">${conf}%</span>`;
|
||
}
|
||
}
|
||
div.innerHTML = `<div class="avatar">${role === 'user' ? 'YOU' : 'cAIc'}</div><div class="content"><div class="role-label">${roleLabelHtml}</div><div class="text">${textHtml}${copyIcon}</div>${role === 'assistant' ? '<div class="msg-toolbar"></div>' : ''}</div>`;
|
||
if (role === 'user') {
|
||
const pair = document.createElement('div');
|
||
pair.className = 'msg-pair' + (isSearch ? ' search-pair' : '');
|
||
pair.appendChild(div);
|
||
if (_waterfallDirection === 'newest') {
|
||
container.prepend(pair);
|
||
} else {
|
||
container.appendChild(pair);
|
||
}
|
||
} else if (role === 'assistant') {
|
||
const pair = afterEl ? afterEl.closest('.msg-pair') : container.querySelector('.msg-pair');
|
||
if (pair) {
|
||
pair.appendChild(div);
|
||
} else if (_waterfallDirection === 'newest') {
|
||
container.prepend(div);
|
||
} else {
|
||
container.appendChild(div);
|
||
}
|
||
} else if (_waterfallDirection === 'newest') {
|
||
container.prepend(div);
|
||
} else {
|
||
container.appendChild(div);
|
||
}
|
||
if (content && role === 'assistant') { addCopyButtons(div); addMessageToolbar(div); }
|
||
scrollToLatest();
|
||
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();
|
||
const done = () => { copyBtn.textContent = '✓'; setTimeout(() => copyBtn.innerHTML = '📋', 1500); showToast('Copied'); };
|
||
if (navigator.clipboard) {
|
||
navigator.clipboard.writeText(t).then(done).catch(() => { execCopy(t); done(); });
|
||
} else { execCopy(t); done(); }
|
||
};
|
||
|
||
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 = () => {
|
||
try {
|
||
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);
|
||
showToast('Saved');
|
||
} catch(e) { showToast('Save failed'); }
|
||
};
|
||
toolbar.append(copyBtn, printBtn, saveBtn);
|
||
}
|
||
|
||
function escapeHtml(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; }
|
||
let _waterfallDirection = localStorage.getItem('waterfallDirection') || 'newest';
|
||
function toggleDirection() {
|
||
_waterfallDirection = _waterfallDirection === 'newest' ? 'oldest' : 'newest';
|
||
localStorage.setItem('waterfallDirection', _waterfallDirection);
|
||
const badge = document.getElementById('directionBadge');
|
||
if (badge) badge.textContent = _waterfallDirection === 'newest' ? '↓ NEW' : '↑ OLD';
|
||
const container = document.getElementById('chatContainer');
|
||
const items = Array.from(container.children);
|
||
items.reverse().forEach(el => container.appendChild(el));
|
||
scrollToLatest();
|
||
}
|
||
let _userScrolledAway = false;
|
||
document.getElementById('chatContainer')?.addEventListener('scroll', function() {
|
||
if (_waterfallDirection === 'newest') {
|
||
_userScrolledAway = this.scrollTop > 100;
|
||
} else {
|
||
_userScrolledAway = this.scrollTop < this.scrollHeight - this.clientHeight - 60;
|
||
}
|
||
}, { passive: true });
|
||
function scrollToLatest() {
|
||
const c = document.getElementById('chatContainer');
|
||
if (!c) return;
|
||
if (_waterfallDirection === 'newest') {
|
||
if (_userScrolledAway) return;
|
||
c.scrollTop = 0;
|
||
} else {
|
||
requestAnimationFrame(() => {
|
||
const cc = document.getElementById('chatContainer');
|
||
if (cc) cc.scrollTop = cc.scrollHeight;
|
||
});
|
||
}
|
||
}
|
||
function resetScrollLock() { _userScrolledAway = false; }
|
||
function scrollToBottom() { scrollToLatest(); }
|
||
function execCopy(text) {
|
||
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);
|
||
}
|
||
function showToast(msg) {
|
||
const el = document.createElement('div');
|
||
el.className = 'toast-error';
|
||
el.textContent = msg;
|
||
document.body.appendChild(el);
|
||
setTimeout(() => el.remove(), 2000);
|
||
}
|
||
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);
|
||
if (btn) btn.textContent = 'copied!';
|
||
if (btn) 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 = 'ss-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>
|