Spaces:
Runtime error
Runtime error
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>DocChat · Document Intelligence</title> | |
| <style> | |
| :root { | |
| --bg:#0c0e14; --panel:#151823; --panel2:#1c2030; --border:#2a3040; | |
| --accent:#7c6cf0; --accent2:#5b8def; --text:#eef0f6; --muted:#98a1b5; | |
| --green:#3ecf8e; --red:#f06c7c; --amber:#f0b84c; --radius:12px; | |
| } | |
| * { box-sizing:border-box; } | |
| html,body { height:100%; } | |
| body { margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; | |
| background:radial-gradient(1200px 600px at 80% -10%, #1a1f3a 0%, var(--bg) 55%); color:var(--text); } | |
| header { display:flex; align-items:center; gap:12px; padding:14px 22px; border-bottom:1px solid var(--border); | |
| backdrop-filter:blur(6px); position:sticky; top:0; z-index:5; } | |
| .logo { width:30px;height:30px;border-radius:8px; | |
| background:linear-gradient(135deg,var(--accent),var(--accent2)); display:grid;place-items:center;font-size:16px; } | |
| header h1 { font-size:16px; font-weight:650; margin:0; letter-spacing:.2px; } | |
| header .sub { color:var(--muted); font-size:12px; margin-left:2px; } | |
| .health { margin-left:auto; display:flex; gap:14px; font-size:12px; color:var(--muted); align-items:center; } | |
| .dot { display:inline-block; width:8px;height:8px;border-radius:50%; margin-right:5px; background:var(--muted); vertical-align:middle; } | |
| .dot.ok{background:var(--green);} .dot.bad{background:var(--red);} .dot.warn{background:var(--amber);} | |
| .wrap { display:grid; grid-template-columns:330px 1fr; height:calc(100vh - 59px); } | |
| .side { border-right:1px solid var(--border); padding:18px; overflow-y:auto; display:flex; flex-direction:column; gap:18px; } | |
| .card { background:var(--panel); border:1px solid var(--border); border-radius:var(--radius); padding:16px; } | |
| h3 { margin:0 0 12px; font-size:12px; text-transform:uppercase; letter-spacing:.08em; color:var(--muted); } | |
| .drop { border:1.5px dashed var(--border); border-radius:10px; padding:22px 14px; text-align:center; cursor:pointer; | |
| transition:.15s; color:var(--muted); font-size:13px; } | |
| .drop:hover,.drop.drag { border-color:var(--accent); color:var(--text); background:var(--panel2); } | |
| .drop b { color:var(--accent); } | |
| .btn { background:linear-gradient(135deg,var(--accent),var(--accent2)); color:#fff; border:0; border-radius:9px; | |
| padding:10px 16px; cursor:pointer; font-weight:600; font-size:13px; width:100%; margin-top:10px; } | |
| .btn:disabled { opacity:.5; cursor:default; } | |
| .btn.ghost { background:transparent; border:1px solid var(--border); color:var(--muted); } | |
| .status { font-size:12px; color:var(--muted); margin-top:8px; min-height:16px; } | |
| .doc { background:var(--panel2); border:1px solid var(--border); border-radius:9px; padding:10px 11px; margin-bottom:8px; | |
| display:flex; gap:9px; align-items:flex-start; } | |
| .doc input { margin-top:3px; accent-color:var(--accent); } | |
| .doc .meta { flex:1; min-width:0; } | |
| .doc .name { font-size:13px; font-weight:550; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } | |
| .doc small { color:var(--muted); font-size:11px; } | |
| .doc .x { color:var(--muted); cursor:pointer; font-size:14px; padding:0 3px; } | |
| .doc .x:hover { color:var(--red); } | |
| .tag { display:inline-block; font-size:10px; padding:1px 6px; border-radius:6px; background:#2b3350; color:#aab4ff; margin-left:6px; } | |
| .scope { font-size:11px; color:var(--muted); margin-top:6px; } | |
| .main { display:flex; flex-direction:column; min-width:0; } | |
| .chat { flex:1; overflow-y:auto; padding:26px 28px; display:flex; flex-direction:column; gap:20px; } | |
| .empty { margin:auto; text-align:center; color:var(--muted); max-width:420px; } | |
| .empty h2 { color:var(--text); font-weight:600; margin-bottom:8px; } | |
| .suggest { display:flex; flex-wrap:wrap; gap:8px; justify-content:center; margin-top:16px; } | |
| .chip { background:var(--panel); border:1px solid var(--border); border-radius:20px; padding:7px 13px; font-size:12px; | |
| cursor:pointer; color:var(--muted); } .chip:hover{ border-color:var(--accent); color:var(--text); } | |
| .msg { display:flex; gap:12px; max-width:860px; } | |
| .msg.me { flex-direction:row-reverse; align-self:flex-end; } | |
| .av { width:30px;height:30px;border-radius:8px; flex-shrink:0; display:grid;place-items:center; font-size:14px; } | |
| .av.ai { background:linear-gradient(135deg,var(--accent),var(--accent2)); } | |
| .av.me { background:var(--panel2); border:1px solid var(--border); } | |
| .bubble { background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:13px 15px; line-height:1.6; | |
| font-size:14.5px; white-space:pre-wrap; word-wrap:break-word; } | |
| .me .bubble { background:#222a4a; } | |
| .bubble code { background:#0d1018; padding:1px 5px; border-radius:5px; font-size:13px; } | |
| .bubble pre { background:#0d1018; padding:10px; border-radius:8px; overflow-x:auto; } | |
| .sources { margin-top:10px; font-size:12px; } | |
| .sources summary { cursor:pointer; color:var(--muted); } | |
| .src { background:var(--panel2); border:1px solid var(--border); border-radius:8px; padding:8px 10px; margin-top:6px; } | |
| .src .h { color:#aab4ff; font-size:11px; margin-bottom:3px; } | |
| .src .t { color:var(--muted); font-size:12px; } | |
| .typing span { display:inline-block; width:6px;height:6px;border-radius:50%; background:var(--muted); margin:0 2px; | |
| animation:b 1.2s infinite; } .typing span:nth-child(2){animation-delay:.2s;} .typing span:nth-child(3){animation-delay:.4s;} | |
| @keyframes b { 0%,60%,100%{opacity:.3;transform:translateY(0)} 30%{opacity:1;transform:translateY(-3px)} } | |
| .composer { border-top:1px solid var(--border); padding:14px 22px; } | |
| .composer .row { display:flex; gap:10px; align-items:flex-end; background:var(--panel); border:1px solid var(--border); | |
| border-radius:12px; padding:8px 10px; } | |
| textarea#q { flex:1; background:transparent; border:0; color:var(--text); font-size:14.5px; resize:none; outline:none; | |
| max-height:140px; font-family:inherit; line-height:1.5; padding:6px; } | |
| .send { background:linear-gradient(135deg,var(--accent),var(--accent2)); border:0; border-radius:9px; width:40px;height:40px; | |
| cursor:pointer; color:#fff; font-size:17px; flex-shrink:0; } .send:disabled{opacity:.4;cursor:default;} | |
| .hint { font-size:11px; color:var(--muted); margin-top:6px; text-align:center; } | |
| @media (max-width:780px){ .wrap{grid-template-columns:1fr;} .side{display:none;} } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <div class="logo">📄</div> | |
| <div> | |
| <h1>DocChat <span class="sub">Document Intelligence & Chat</span></h1> | |
| </div> | |
| <div class="health" id="health"> | |
| <span><span class="dot" id="d-qdrant"></span>Qdrant</span> | |
| <span><span class="dot" id="d-mongo"></span>Mongo</span> | |
| <span><span class="dot" id="d-llm"></span>LLM</span> | |
| </div> | |
| </header> | |
| <div class="wrap"> | |
| <aside class="side"> | |
| <div class="card"> | |
| <h3>Upload a document</h3> | |
| <div class="drop" id="drop"> | |
| <div>Drop a file here or <b>browse</b></div> | |
| <div style="margin-top:6px;font-size:11px;">PDF · DOCX · images · TXT — OCR built in</div> | |
| </div> | |
| <input type="file" id="file" hidden /> | |
| <button class="btn" id="uploadBtn" onclick="upload()">Upload & Index</button> | |
| <div class="status" id="uploadStatus"></div> | |
| </div> | |
| <div class="card" style="flex:1;"> | |
| <h3 style="display:flex;justify-content:space-between;align-items:center;"> | |
| Your documents <span id="count" style="color:var(--accent);">0</span> | |
| </h3> | |
| <div id="docs"></div> | |
| <div class="scope" id="scope">Chatting across all documents.</div> | |
| </div> | |
| </aside> | |
| <main class="main"> | |
| <div class="chat" id="chat"> | |
| <div class="empty" id="empty"> | |
| <h2>Ask your documents anything</h2> | |
| <p>Upload a file on the left, then ask a question. Answers are grounded in | |
| your documents and cite their sources.</p> | |
| <div class="suggest"> | |
| <div class="chip" onclick="useChip(this)">Summarize the key points</div> | |
| <div class="chip" onclick="useChip(this)">What are the main risks?</div> | |
| <div class="chip" onclick="useChip(this)">List the action items</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="composer"> | |
| <div class="row"> | |
| <textarea id="q" rows="1" placeholder="Ask a question about your documents…" | |
| oninput="autogrow(this)" onkeydown="onKey(event)"></textarea> | |
| <button class="send" id="send" onclick="ask()">➤</button> | |
| </div> | |
| <div class="hint">Enter to send · Shift+Enter for newline</div> | |
| </div> | |
| </main> | |
| </div> | |
| <script> | |
| let DOCS = []; | |
| const $ = (id) => document.getElementById(id); | |
| /* ---------- health ---------- */ | |
| async function health() { | |
| try { | |
| const r = await fetch('/health'); const h = await r.json(); | |
| setDot('d-qdrant', h.qdrant === 'ok'); | |
| setDot('d-mongo', h.mongodb === 'ok'); | |
| setDot('d-llm', h.llm_key_configured); | |
| } catch { ['d-qdrant','d-mongo','d-llm'].forEach(d=>setDot(d,false)); } | |
| } | |
| function setDot(id, ok){ const e=$(id); e.className = 'dot ' + (ok?'ok':'bad'); } | |
| /* ---------- documents ---------- */ | |
| async function loadDocs() { | |
| try { | |
| const r = await fetch('/documents'); DOCS = await r.json(); | |
| } catch { DOCS = []; } | |
| $('count').textContent = DOCS.length; | |
| const el = $('docs'); | |
| if (!DOCS.length) { el.innerHTML = '<small>No documents yet — upload one above.</small>'; updateScope(); return; } | |
| el.innerHTML = DOCS.map(d => ` | |
| <div class="doc"> | |
| <input type="checkbox" class="docsel" value="${d.id}" onchange="updateScope()" checked /> | |
| <div class="meta"> | |
| <div class="name" title="${esc(d.filename)}">${esc(d.filename)}${d.ocr_used?'<span class="tag">OCR</span>':''}</div> | |
| <small>${d.num_chunks} chunks · ${new Date(d.created_at).toLocaleDateString()}</small> | |
| </div> | |
| <span class="x" title="Delete" onclick="del('${d.id}')">✕</span> | |
| </div>`).join(''); | |
| updateScope(); | |
| } | |
| function selectedIds(){ return [...document.querySelectorAll('.docsel:checked')].map(c=>c.value); } | |
| function updateScope(){ | |
| const sel = selectedIds(), total = DOCS.length; | |
| const s = $('scope'); | |
| if (!total) s.textContent = ''; | |
| else if (sel.length === total) s.textContent = 'Chatting across all documents.'; | |
| else if (sel.length === 0) s.textContent = '⚠ No documents selected — select at least one.'; | |
| else s.textContent = `Chatting across ${sel.length} of ${total} documents.`; | |
| } | |
| async function del(id){ | |
| if (!confirm('Delete this document and its vectors?')) return; | |
| await fetch('/documents/'+id, {method:'DELETE'}); await loadDocs(); | |
| } | |
| /* ---------- upload ---------- */ | |
| const drop = $('drop'), fileInput = $('file'); | |
| drop.onclick = () => fileInput.click(); | |
| ['dragover','dragenter'].forEach(e=>drop.addEventListener(e,ev=>{ev.preventDefault();drop.classList.add('drag');})); | |
| ['dragleave','drop'].forEach(e=>drop.addEventListener(e,ev=>{ev.preventDefault();drop.classList.remove('drag');})); | |
| drop.addEventListener('drop', ev=>{ if(ev.dataTransfer.files[0]){ fileInput.files=ev.dataTransfer.files; reflectFile(); }}); | |
| fileInput.onchange = reflectFile; | |
| function reflectFile(){ const f=fileInput.files[0]; if(f) drop.querySelector('div').innerHTML = '📎 '+esc(f.name); } | |
| async function upload(){ | |
| const f = fileInput.files[0]; const st = $('uploadStatus'); | |
| if (!f){ st.textContent='Choose a file first.'; return; } | |
| const btn = $('uploadBtn'); btn.disabled = true; st.textContent = '⏳ Extracting, embedding & indexing…'; | |
| const fd = new FormData(); fd.append('file', f); | |
| try { | |
| const r = await fetch('/documents/upload',{method:'POST',body:fd}); | |
| const d = await r.json(); | |
| st.textContent = r.ok ? ('✓ '+d.message) : ('⚠ '+(d.detail||'Upload failed')); | |
| if (r.ok){ fileInput.value=''; drop.querySelector('div').innerHTML='Drop a file here or <b>browse</b>'; await loadDocs(); } | |
| } catch(e){ st.textContent = '⚠ '+e; } | |
| btn.disabled = false; | |
| } | |
| /* ---------- chat ---------- */ | |
| function autogrow(t){ t.style.height='auto'; t.style.height=Math.min(t.scrollHeight,140)+'px'; } | |
| function onKey(e){ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); ask(); } } | |
| function useChip(el){ $('q').value = el.textContent; $('q').focus(); } | |
| function esc(s){ return (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); } | |
| function fmt(s){ // tiny markdown: code fences, inline code, bold | |
| s = esc(s); | |
| s = s.replace(/```([\s\S]*?)```/g,(m,c)=>'<pre>'+c+'</pre>'); | |
| s = s.replace(/`([^`]+)`/g,'<code>$1</code>'); | |
| s = s.replace(/\*\*([^*]+)\*\*/g,'<strong>$1</strong>'); | |
| return s; | |
| } | |
| function bubble(role, inner){ | |
| $('empty')?.remove(); | |
| const wrap = document.createElement('div'); wrap.className = 'msg '+(role==='me'?'me':''); | |
| wrap.innerHTML = `<div class="av ${role==='me'?'me':'ai'}">${role==='me'?'🙂':'📄'}</div> | |
| <div class="bubble">${inner}</div>`; | |
| $('chat').appendChild(wrap); $('chat').scrollTop = $('chat').scrollHeight; | |
| return wrap.querySelector('.bubble'); | |
| } | |
| async function ask(){ | |
| const t = $('q'); const q = t.value.trim(); if(!q) return; | |
| const ids = selectedIds(); | |
| if (DOCS.length && ids.length===0){ alert('Select at least one document to chat with.'); return; } | |
| t.value=''; autogrow(t); bubble('me', esc(q)); | |
| const b = bubble('ai', '<div class="typing"><span></span><span></span><span></span></div>'); | |
| $('send').disabled = true; | |
| try { | |
| const body = { question:q }; | |
| if (DOCS.length && ids.length < DOCS.length) body.document_ids = ids; | |
| const r = await fetch('/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}); | |
| const d = await r.json(); | |
| if (!r.ok){ b.innerHTML = '⚠ '+esc(d.detail||'Error'); $('send').disabled=false; return; } | |
| let html = fmt(d.answer||''); | |
| if (d.sources?.length){ | |
| const items = d.sources.map((s,i)=>`<div class="src"><div class="h">[${i+1}] ${esc(s.filename)} · chunk ${s.chunk_index} · ${(s.score*100).toFixed(0)}% match</div><div class="t">${esc(s.snippet)}</div></div>`).join(''); | |
| html += `<details class="sources"><summary>${d.sources.length} source${d.sources.length>1?'s':''}</summary>${items}</details>`; | |
| } | |
| b.innerHTML = html; | |
| } catch(e){ b.innerHTML = '⚠ '+esc(''+e); } | |
| $('send').disabled = false; $('chat').scrollTop = $('chat').scrollHeight; | |
| } | |
| health(); loadDocs(); setInterval(health, 30000); | |
| </script> | |
| </body> | |
| </html> | |