Observer KB: full knowledge base interface

- Conversations tab: chat-style view with user/AI/page messages, page numbers
- Search tab: semantic search via HNSW, save findings to journal
- Ask AI tab: RAG query against all conversations, shows sources with scores
- Map tab: visual heatmap of conversations by size, click to open
- Upload tab: drag & drop PDFs
- Sidebar: conversation list with page counts
- Save to KB: bookmark search results and AI answers to event journal

Pure HTML/JS, no WASM. Loads instantly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-03-27 22:57:19 -05:00
co-authored by Claude Opus 4.6
parent 8ca4ba5717
commit 0756075c6d
+259 -143
View File
@@ -5,174 +5,290 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Observer KB</title>
<style>
:root{--bg:#0a0a0f;--surface:#12121a;--border:#2a2a3a;--text:#e0e0e8;--dim:#888898;--accent:#6c5ce7;--success:#00d2a0;--error:#ff6b6b;--mono:'JetBrains Mono','Fira Code',monospace}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:var(--mono);background:var(--bg);color:var(--text);font-size:13px;min-height:100vh}
.app{max-width:1100px;margin:0 auto;padding:24px}
header{display:flex;align-items:center;justify-content:space-between;padding:16px 0;border-bottom:1px solid var(--border);margin-bottom:24px}
header h1{font-size:20px;color:var(--accent)}
.stats{font-size:11px;color:var(--dim)}
.tabs{display:flex;gap:2px;margin-bottom:24px}
.tab{background:none;border:none;color:var(--dim);font-family:var(--mono);font-size:13px;padding:10px 20px;cursor:pointer;border-bottom:2px solid transparent}
.tab:hover{color:var(--text)}.tab.active{color:var(--accent);border-bottom-color:var(--accent)}
.upload-zone{border:2px dashed var(--border);border-radius:12px;padding:48px 24px;text-align:center;cursor:pointer;transition:all .2s;margin-bottom:24px}
.upload-zone:hover,.upload-zone.dragover{border-color:var(--accent);background:rgba(108,92,231,.05)}
.upload-zone h3{font-size:16px;margin-bottom:8px}
.upload-zone p{color:var(--dim);font-size:12px}
.file-list{display:flex;flex-direction:column;gap:8px}
.file-item{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px 18px;display:grid;grid-template-columns:1fr 100px 120px;align-items:center;gap:12px}
.file-name{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.file-size{color:var(--dim);font-size:11px;text-align:right}
.file-status{font-size:11px;text-align:right}
.uploading{color:var(--accent)}.done{color:var(--success)}.failed{color:var(--error)}
.progress-bar{height:3px;background:var(--border);border-radius:2px;margin-top:6px;grid-column:1/-1}
.progress-fill{height:100%;background:var(--accent);border-radius:2px;transition:width .3s}
.search-bar{display:flex;gap:8px;margin-bottom:20px}
.search-bar input{flex:1;background:var(--surface);border:2px solid var(--border);color:var(--text);font-family:var(--mono);font-size:14px;padding:12px 16px;border-radius:8px;outline:none}
.search-bar input:focus{border-color:var(--accent)}
.btn{background:var(--accent);color:white;border:none;padding:10px 20px;border-radius:6px;font-family:var(--mono);font-size:13px;cursor:pointer;font-weight:600}
.btn:hover{opacity:.9}
.ds-item{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:16px;margin-bottom:8px;cursor:pointer;transition:all .15s}
.ds-item:hover{border-color:var(--accent)}
.ds-title{font-weight:600;font-size:14px;margin-bottom:4px}
.ds-meta{font-size:11px;color:var(--dim)}
:root{--bg:#0a0a0f;--s1:#12121a;--s2:#1a1a28;--b:#2a2a3a;--t:#e0e0e8;--d:#888898;--a:#6c5ce7;--a2:#4a3db8;--ok:#00d2a0;--err:#ff6b6b;--user:#2d5aa0;--ai:#1a8a5a;--f:'JetBrains Mono','Fira Code',monospace}
*{margin:0;padding:0;box-sizing:border-box}body{font-family:var(--f);background:var(--bg);color:var(--t);font-size:13px}
.app{display:grid;grid-template-columns:280px 1fr;grid-template-rows:52px 1fr;height:100vh;overflow:hidden}
.header{grid-column:1/-1;display:flex;align-items:center;padding:0 20px;background:var(--s1);border-bottom:1px solid var(--b);gap:20px}
.header h1{font-size:18px;color:var(--a);letter-spacing:.08em}
.tabs{display:flex;gap:2px}.tab{background:none;border:none;color:var(--d);font-family:var(--f);font-size:12px;padding:8px 16px;cursor:pointer;border-bottom:2px solid transparent}
.tab:hover{color:var(--t)}.tab.active{color:var(--a);border-bottom-color:var(--a)}
.hdr-stats{margin-left:auto;font-size:11px;color:var(--d)}
/* Sidebar */
.sidebar{background:var(--s1);border-right:1px solid var(--b);overflow-y:auto;padding:8px}
.conv-btn{display:block;width:100%;text-align:left;background:none;border:1px solid transparent;color:var(--t);font-family:var(--f);font-size:12px;padding:10px 12px;border-radius:6px;cursor:pointer;margin-bottom:4px}
.conv-btn:hover{border-color:var(--b);background:var(--s2)}
.conv-btn.active{border-color:var(--a);background:rgba(108,92,231,.1)}
.conv-btn .name{font-weight:600;margin-bottom:2px}.conv-btn .meta{font-size:10px;color:var(--d)}
/* Main area */
.main{overflow:hidden;display:flex;flex-direction:column}
/* Chat view */
.chat-area{flex:1;overflow-y:auto;padding:20px;display:flex;flex-direction:column;gap:12px}
.msg{max-width:85%;padding:12px 16px;border-radius:12px;font-size:12px;line-height:1.7;white-space:pre-wrap;word-wrap:break-word}
.msg.user{background:var(--user);align-self:flex-end;border-bottom-right-radius:4px;color:#e8eef8}
.msg.assistant,.msg.ai{background:var(--ai);align-self:flex-start;border-bottom-left-radius:4px;color:#e0f5ec}
.msg.page{background:var(--s2);align-self:flex-start;border-left:3px solid var(--a);color:var(--t)}
.msg-role{font-size:9px;text-transform:uppercase;letter-spacing:.1em;opacity:.7;margin-bottom:4px}
.msg-page{font-size:9px;color:var(--d);margin-top:4px}
/* Search panel */
.search-panel{padding:20px;overflow-y:auto}
.search-row{display:flex;gap:8px;margin-bottom:16px}
.search-row input{flex:1;background:var(--s1);border:2px solid var(--b);color:var(--t);font-family:var(--f);font-size:14px;padding:12px 16px;border-radius:8px;outline:none}
.search-row input:focus{border-color:var(--a)}
.btn{background:var(--a);color:white;border:none;padding:10px 20px;border-radius:6px;font-family:var(--f);font-size:12px;cursor:pointer;font-weight:600}
.btn:hover{opacity:.9}.btn:disabled{opacity:.5}
.btn-sm{padding:6px 12px;font-size:11px}
.btn-save{background:var(--ok)}
.result{background:var(--s2);border:1px solid var(--b);border-radius:8px;padding:14px;margin-bottom:8px;border-left:3px solid var(--a)}
.result-score{font-size:10px;color:var(--a);margin-bottom:4px}
.result-src{font-size:10px;color:var(--d)}
.result-text{font-size:12px;line-height:1.6;margin:8px 0;color:var(--t)}
.result-actions{display:flex;gap:6px;margin-top:8px}
/* Ask AI panel */
.ask-panel{padding:20px;overflow-y:auto}
.ask-input{width:100%;background:var(--s1);border:2px solid var(--b);color:var(--t);font-family:var(--f);font-size:13px;padding:12px;border-radius:8px;resize:vertical;min-height:80px;outline:none;margin-bottom:8px}
.ask-input:focus{border-color:var(--a)}
.ai-response{background:var(--s2);border:1px solid var(--b);border-radius:8px;padding:16px;margin-top:12px;line-height:1.7;white-space:pre-wrap}
.ai-meta{font-size:10px;color:var(--d);margin-top:8px}
.sources{margin-top:12px;padding-top:12px;border-top:1px solid var(--b)}
.sources h4{font-size:10px;color:var(--d);text-transform:uppercase;margin-bottom:8px}
/* Heatmap */
.heatmap{padding:20px;overflow-y:auto}
.heatmap h2{font-size:14px;color:var(--d);margin-bottom:12px;text-transform:uppercase;letter-spacing:.08em}
.heat-grid{display:flex;flex-wrap:wrap;gap:4px}
.heat-cell{width:32px;height:32px;border-radius:4px;display:flex;align-items:center;justify-content:center;font-size:9px;cursor:pointer;transition:transform .1s}
.heat-cell:hover{transform:scale(1.3)}
.heat-0{background:var(--s2);color:var(--d)}.heat-1{background:#1a2a3a;color:var(--a)}.heat-2{background:#2a3a5a;color:var(--a)}
.heat-3{background:#3a4a7a;color:white}.heat-4{background:var(--a);color:white}
/* Upload */
.upload-zone{border:2px dashed var(--b);border-radius:12px;padding:40px;text-align:center;cursor:pointer;margin:20px}
.upload-zone:hover{border-color:var(--a);background:rgba(108,92,231,.03)}
.upload-zone h3{margin-bottom:6px}.upload-zone p{color:var(--d);font-size:11px}
.file-item{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;border-bottom:1px solid var(--b);font-size:12px}
.done{color:var(--ok)}.failed{color:var(--err)}.uploading{color:var(--a)}
.empty{color:var(--d);text-align:center;padding:40px;font-size:12px}
.hidden{display:none}
.empty{color:var(--dim);text-align:center;padding:32px}
.result-item{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px;margin-bottom:8px;border-left:3px solid var(--accent)}
.result-score{font-size:11px;color:var(--accent);margin-bottom:4px}
.result-text{font-size:12px;line-height:1.6;color:var(--dim)}
#output{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:16px;margin-top:16px;font-size:12px;line-height:1.6;max-height:500px;overflow-y:auto;white-space:pre-wrap}
.saved-badge{display:inline-block;background:var(--ok);color:white;font-size:9px;padding:2px 6px;border-radius:3px;margin-left:6px}
</style>
</head>
<body>
<div class="app">
<header><h1>OBSERVER KB</h1><div class="stats" id="stats">loading...</div></header>
<div class="header">
<h1>OBSERVER KB</h1>
<div class="tabs">
<button class="tab active" id="t-upload" onclick="showTab('upload')">Upload</button>
<button class="tab" id="t-browse" onclick="showTab('browse')">Browse</button>
<button class="tab" id="t-search" onclick="showTab('search')">Search</button>
<button class="tab active" onclick="showView('chat')">Conversations</button>
<button class="tab" onclick="showView('search')">Search</button>
<button class="tab" onclick="showView('ask')">Ask AI</button>
<button class="tab" onclick="showView('heatmap')">Map</button>
<button class="tab" onclick="showView('upload')">Upload</button>
</div>
<div class="hdr-stats" id="stats"></div>
</div>
<div id="tab-upload">
<div class="upload-zone" id="dropZone">
<h3>Drop PDF files here</h3>
<p>ChatGPT exports, Claude logs, any PDF — or click to browse</p>
<input type="file" id="fileInput" multiple accept=".pdf,.txt,.csv,.json" style="display:none">
</div>
<div class="file-list" id="fileList"></div>
</div>
<div class="sidebar" id="sidebar"></div>
<div id="tab-browse" class="hidden">
<div id="dsList"></div>
</div>
<div id="tab-search" class="hidden">
<div class="search-bar">
<input type="text" id="searchInput" placeholder="Search across all knowledge..." />
<button class="btn" id="searchBtn">Search</button>
</div>
<div id="searchResults"></div>
<div class="main">
<div id="v-chat" class="chat-area"><div class="empty">Select a conversation from the sidebar</div></div>
<div id="v-search" class="search-panel hidden"></div>
<div id="v-ask" class="ask-panel hidden"></div>
<div id="v-heatmap" class="heatmap hidden"></div>
<div id="v-upload" class="hidden" style="padding:20px"></div>
</div>
</div>
<script>
const API=`http://${location.hostname}:3100`;
const $=id=>document.getElementById(id);
let CONVS=[];let CUR=null;
function showTab(n){
document.querySelectorAll('[id^="tab-"]').forEach(t=>t.classList.add('hidden'));
$('tab-'+n).classList.remove('hidden');
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
$('t-'+n).classList.add('active');
if(n==='browse')loadDatasets();
}
$('dropZone').onclick=()=>$('fileInput').click();
$('dropZone').ondragover=e=>{e.preventDefault();$('dropZone').classList.add('dragover')};
$('dropZone').ondragleave=()=>$('dropZone').classList.remove('dragover');
$('dropZone').ondrop=e=>{e.preventDefault();$('dropZone').classList.remove('dragover');[...e.dataTransfer.files].forEach(uploadFile)};
$('fileInput').onchange=e=>[...e.target.files].forEach(uploadFile);
$('searchBtn').onclick=doSearch;
$('searchInput').onkeydown=e=>{if(e.key==='Enter')doSearch()};
function uploadFile(file){
const id='f'+Date.now()+Math.random().toString(36).substr(2,5);
const item=document.createElement('div');
item.className='file-item';
item.id=id;
const nameDiv=document.createElement('div');nameDiv.className='file-name';nameDiv.textContent=file.name;
const sizeDiv=document.createElement('div');sizeDiv.className='file-size';sizeDiv.textContent=(file.size/1024/1024).toFixed(1)+' MB';
const statusDiv=document.createElement('div');statusDiv.className='file-status uploading';statusDiv.textContent='uploading...';
const progBar=document.createElement('div');progBar.className='progress-bar';
const progFill=document.createElement('div');progFill.className='progress-fill';progFill.style.width='0%';
progBar.appendChild(progFill);
item.append(nameDiv,sizeDiv,statusDiv,progBar);
$('fileList').prepend(item);
const fd=new FormData();fd.append('file',file);
const xhr=new XMLHttpRequest();
xhr.open('POST',API+'/ingest/file');
xhr.upload.onprogress=e=>{if(e.lengthComputable){const p=Math.round(e.loaded/e.total*100);progFill.style.width=p+'%';statusDiv.textContent=p+'%'}};
xhr.onload=()=>{
if(xhr.status<300){statusDiv.className='file-status done';statusDiv.textContent='done';progFill.style.background='var(--success)';loadStats()}
else{statusDiv.className='file-status failed';statusDiv.textContent='failed';progFill.style.background='var(--error)'}
};
xhr.onerror=()=>{statusDiv.className='file-status failed';statusDiv.textContent='error'};
xhr.send(fd);
}
async function loadStats(){
try{const r=await(await fetch(API+'/catalog/datasets')).json();$('stats').textContent=r.length+' datasets'}
catch{$('stats').textContent='offline'}
}
async function loadDatasets(){
// Init
async function init(){
const old=['call_log','placements','clients','threat_intel','timesheets','meta_runs','candidates','job_orders','lab_trials','users','email_log','new_candidates','schema_test_v1','schema_test_v2','stress_test_ingest','stress_ingest'];
try{
const ds=await(await fetch(API+'/catalog/datasets')).json();
const el=$('dsList');el.textContent='';
if(!ds.length){el.textContent='No data yet';return}
ds.forEach(d=>{
const item=document.createElement('div');item.className='ds-item';
const title=document.createElement('div');title.className='ds-title';title.textContent=d.name;
const meta=document.createElement('div');meta.className='ds-meta';
meta.textContent=`${d.row_count||'?'} rows · ${d.objects.length} file(s) · ${(d.sensitivity||'unclassified')}`;
item.append(title,meta);
item.onclick=()=>previewDS(d.name);
el.appendChild(item);
});
}catch(e){$('dsList').textContent='Error: '+e}
CONVS=ds.filter(d=>!old.includes(d.name));
$('stats').textContent=CONVS.length+' conversations · '+CONVS.reduce((s,d)=>(d.row_count||0)+s,0)+' pages';
renderSidebar();
}catch(e){$('stats').textContent='offline'}
}
async function previewDS(name){
try{
const r=await(await fetch(API+'/query/sql',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({sql:`SELECT * FROM ${name} LIMIT 20`})})).json();
const el=$('dsList');el.textContent='';
const btn=document.createElement('button');btn.className='btn';btn.textContent='Back';btn.onclick=loadDatasets;
const pre=document.createElement('div');pre.id='output';
pre.textContent=JSON.stringify(r.rows,null,2);
el.append(btn,pre);
}catch(e){alert(e)}
function renderSidebar(){
const sb=$('sidebar');sb.textContent='';
CONVS.forEach(c=>{
const b=document.createElement('button');b.className='conv-btn'+(CUR===c.name?' active':'');
const n=document.createElement('div');n.className='name';n.textContent=c.name.replace(/_/g,' ');
const m=document.createElement('div');m.className='meta';m.textContent=(c.row_count||'?')+' pages';
b.append(n,m);b.onclick=()=>loadConv(c.name);sb.appendChild(b);
});
}
async function doSearch(){
const q=$('searchInput').value;if(!q)return;
const el=$('searchResults');el.textContent='searching...';
try{
const r=await fetch(API+'/vectors/hnsw/search',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({index_name:'observer_kb',query:q,top_k:10})});
if(r.ok){
const d=await r.json();el.textContent='';
d.results.forEach(hit=>{
const item=document.createElement('div');item.className='result-item';
const score=document.createElement('div');score.className='result-score';score.textContent=`[${hit.score.toFixed(3)}] ${hit.source} / ${hit.doc_id}`;
const text=document.createElement('div');text.className='result-text';text.textContent=hit.chunk_text.substring(0,300);
item.append(score,text);el.appendChild(item);
function showView(v){
['chat','search','ask','heatmap','upload'].forEach(id=>{
$('v-'+id).classList.toggle('hidden',id!==v);
});
if(!d.results.length)el.textContent='No results';
}else{el.textContent='Vector index not built yet — upload PDFs first'}
document.querySelectorAll('.tab').forEach((t,i)=>t.classList.toggle('active',['chat','search','ask','heatmap','upload'][i]===v));
if(v==='search')renderSearch();
if(v==='ask')renderAsk();
if(v==='heatmap')renderHeatmap();
if(v==='upload')renderUpload();
}
// Chat view
async function loadConv(name){
CUR=name;renderSidebar();
const el=$('v-chat');el.textContent='';
el.scrollTop=0;
try{
const r=await(await fetch(API+'/query/sql',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({sql:`SELECT turn_number, role, content, page_number FROM ${name} ORDER BY turn_number`})})).json();
r.rows.forEach(row=>{
const msg=document.createElement('div');
msg.className='msg '+(row.role||'page');
const role=document.createElement('div');role.className='msg-role';
role.textContent=(row.role==='user'?'You':(row.role==='assistant'?'ChatGPT':'Page '+row.turn_number));
const content=document.createElement('div');
content.textContent=(row.content||'').substring(0,2000);
const pg=document.createElement('div');pg.className='msg-page';
pg.textContent='page '+row.page_number;
msg.append(role,content,pg);el.appendChild(msg);
});
if(!r.rows.length)el.textContent='No content';
}catch(e){el.textContent='Error: '+e}
}
loadStats();
// Search view
function renderSearch(){
const el=$('v-search');el.textContent='';
const row=document.createElement('div');row.className='search-row';
const input=document.createElement('input');input.placeholder='Search across all conversations...';input.id='sinput';
const btn=document.createElement('button');btn.className='btn';btn.textContent='Search';
btn.onclick=doSearch;input.onkeydown=e=>{if(e.key==='Enter')doSearch()};
row.append(input,btn);
const results=document.createElement('div');results.id='sresults';
el.append(row,results);
}
async function doSearch(){
const q=$('sinput').value;if(!q)return;
const el=$('sresults');el.textContent='searching...';
try{
let r;
try{r=await(await fetch(API+'/vectors/hnsw/search',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({index_name:'observer_kb',query:q,top_k:10})})).json()}
catch{r=await(await fetch(API+'/vectors/search',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({index_name:'observer_kb',query:q,top_k:10})})).json()}
el.textContent='';
(r.results||[]).forEach(hit=>{
const item=document.createElement('div');item.className='result';
const score=document.createElement('div');score.className='result-score';score.textContent='['+hit.score.toFixed(3)+']';
const src=document.createElement('div');src.className='result-src';src.textContent=hit.doc_id;
const text=document.createElement('div');text.className='result-text';text.textContent=hit.chunk_text.substring(0,400);
const actions=document.createElement('div');actions.className='result-actions';
const saveBtn=document.createElement('button');saveBtn.className='btn btn-sm btn-save';saveBtn.textContent='Save to KB';
saveBtn.onclick=()=>{saveToKB(hit);saveBtn.textContent='Saved';saveBtn.disabled=true};
actions.appendChild(saveBtn);
item.append(score,src,text,actions);el.appendChild(item);
});
if(!(r.results||[]).length){el.textContent='No results — try different terms'}
}catch(e){el.textContent='Error: '+e}
}
async function saveToKB(hit){
try{await fetch(API+'/journal/event',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({entity_type:'saved_finding',entity_id:hit.doc_id,action:'save',field:'content',new_value:hit.chunk_text.substring(0,500),actor:'user',source:'observer_search'})})}catch{}
}
// Ask AI view
function renderAsk(){
const el=$('v-ask');el.textContent='';
const input=document.createElement('textarea');input.className='ask-input';input.id='ainput';
input.placeholder='Ask a question about your conversations...';
const btn=document.createElement('button');btn.className='btn';btn.textContent='Ask AI';btn.id='abtn';
btn.onclick=doAsk;
const resp=document.createElement('div');resp.id='aresponse';
el.append(input,btn,resp);
}
async function doAsk(){
const q=$('ainput').value;if(!q)return;
$('abtn').disabled=true;$('abtn').textContent='thinking...';
const el=$('aresponse');el.textContent='searching knowledge base...';
try{
const r=await(await fetch(API+'/vectors/rag',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({index_name:'observer_kb',question:q,top_k:5})})).json();
el.textContent='';
const resp=document.createElement('div');resp.className='ai-response';resp.textContent=r.answer;
const meta=document.createElement('div');meta.className='ai-meta';meta.textContent='model: '+r.model+(r.tokens_generated?' · '+r.tokens_generated+' tokens':'');
const sources=document.createElement('div');sources.className='sources';
const h4=document.createElement('h4');h4.textContent='Sources';sources.appendChild(h4);
(r.sources||[]).forEach(s=>{
const item=document.createElement('div');item.className='result';item.style.marginBottom='6px';
const sc=document.createElement('div');sc.className='result-score';sc.textContent='['+s.score.toFixed(3)+'] '+s.doc_id;
const tx=document.createElement('div');tx.className='result-text';tx.textContent=s.chunk_text.substring(0,200);
item.append(sc,tx);sources.appendChild(item);
});
const saveBtn=document.createElement('button');saveBtn.className='btn btn-sm btn-save';saveBtn.textContent='Save Answer';saveBtn.style.marginTop='12px';
saveBtn.onclick=()=>{saveToKB({doc_id:'ai_answer',chunk_text:q+'\n\n'+r.answer});saveBtn.textContent='Saved';saveBtn.disabled=true};
el.append(resp,meta,sources,saveBtn);
}catch(e){el.textContent='Error: '+e}
$('abtn').disabled=false;$('abtn').textContent='Ask AI';
}
// Heatmap view
function renderHeatmap(){
const el=$('v-heatmap');el.textContent='';
const h2=document.createElement('h2');h2.textContent='Knowledge Map';
const desc=document.createElement('p');desc.style.cssText='color:var(--d);font-size:11px;margin-bottom:16px';
desc.textContent='Size = pages extracted. Click to open conversation.';
el.append(h2,desc);
const grid=document.createElement('div');grid.style.cssText='display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px';
CONVS.forEach(c=>{
const card=document.createElement('div');
const pages=c.row_count||0;
const level=pages>100?4:pages>50?3:pages>20?2:pages>5?1:0;
card.className='heat-cell';
card.style.cssText=`width:auto;height:auto;padding:16px;border-radius:8px;cursor:pointer;background:var(--s2);border:2px solid ${level>=3?'var(--a)':level>=2?'var(--b)':'transparent'}`;
const name=document.createElement('div');name.style.cssText='font-weight:600;font-size:13px;margin-bottom:4px';name.textContent=c.name.replace(/_/g,' ');
const meta=document.createElement('div');meta.style.cssText='font-size:11px;color:var(--d)';meta.textContent=pages+' pages';
const bar=document.createElement('div');bar.style.cssText=`height:4px;background:var(--a);border-radius:2px;margin-top:8px;width:${Math.min(100,pages)}%`;
card.append(name,meta,bar);
card.onclick=()=>{showView('chat');loadConv(c.name)};
grid.appendChild(card);
});
el.appendChild(grid);
}
// Upload view
function renderUpload(){
const el=$('v-upload');el.textContent='';
const zone=document.createElement('div');zone.className='upload-zone';
zone.onclick=()=>{const i=document.createElement('input');i.type='file';i.multiple=true;i.accept='.pdf,.txt,.csv,.json';i.onchange=e=>[...e.target.files].forEach(uploadFile);i.click()};
zone.ondragover=e=>{e.preventDefault();zone.style.borderColor='var(--a)'};
zone.ondragleave=()=>zone.style.borderColor='';
zone.ondrop=e=>{e.preventDefault();zone.style.borderColor='';[...e.dataTransfer.files].forEach(uploadFile)};
const h3=document.createElement('h3');h3.textContent='Drop files here or click to browse';
const p=document.createElement('p');p.textContent='PDF, CSV, JSON, Text';
zone.append(h3,p);
const list=document.createElement('div');list.id='ulist';
el.append(zone,list);
}
function uploadFile(file){
const item=document.createElement('div');item.className='file-item';
const name=document.createElement('span');name.textContent=file.name;
const status=document.createElement('span');status.className='uploading';status.textContent='uploading...';
item.append(name,status);$('ulist').prepend(item);
const fd=new FormData();fd.append('file',file);
const xhr=new XMLHttpRequest();xhr.open('POST',API+'/ingest/file');
xhr.onload=()=>{status.className=xhr.status<300?'done':'failed';status.textContent=xhr.status<300?'done':'failed';init()};
xhr.onerror=()=>{status.className='failed';status.textContent='error'};
xhr.send(fd);
}
init();
</script>
</body>
</html>