pdf-chatbot-browser / index.html
ghostofpk20's picture
Upload index.html
09827fb verified
Raw
History Blame Contribute Delete
25.1 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PDF Chatbot - Local AI (Runs in Browser)</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh;display:flex;justify-content:center;padding:20px}
.app{width:100%;max-width:520px;display:flex;flex-direction:column;gap:12px}
.header{background:linear-gradient(135deg,#6366f1 0%,#8b5cf6 100%);padding:20px;border-radius:16px;text-align:center}
.header h1{font-size:22px;font-weight:700;margin-bottom:4px}
.badge{display:inline-block;background:rgba(255,255,255,0.2);padding:3px 12px;border-radius:12px;font-size:12px;font-weight:500}
.status-panel{display:flex;gap:16px;padding:12px 16px;background:#1e293b;border-radius:12px;font-size:13px;justify-content:center}
.status-item{display:flex;align-items:center;gap:8px}
.status-dot{width:10px;height:10px;border-radius:50%;background:#64748b}
.status-dot.ready{background:#22c55e}.status-dot.warn{background:#f59e0b}
.status-dot.loading{background:#3b82f6;animation:pulse 1s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
.tabs{display:flex;background:#1e293b;border-radius:12px;overflow:hidden}
.tab-btn{flex:1;padding:14px;background:transparent;border:none;color:#94a3b8;cursor:pointer;font-size:14px;font-weight:600;transition:all .2s}
.tab-btn:hover{color:#e2e8f0;background:#334155}
.tab-btn.active{color:#e2e8f0;border-bottom:2px solid #6366f1;background:#1e293b}
.tab-content{display:none;flex:1;flex-direction:column;gap:12px}
.tab-content.active{display:flex}
.chat-container{flex:1;min-height:350px;max-height:500px;overflow-y:auto;padding:16px;background:#1e293b;border-radius:12px;display:flex;flex-direction:column;gap:12px}
.welcome-msg{text-align:center;padding:40px 20px;color:#94a3b8}
.welcome-icon{font-size:56px;margin-bottom:16px}
.welcome-msg h3{color:#e2e8f0;font-size:18px;margin-bottom:12px}
.welcome-msg p{font-size:14px;line-height:1.6;margin-bottom:10px}
.welcome-msg .small{font-size:12px;color:#64748b}
.msg{max-width:92%;padding:12px 16px;border-radius:16px;font-size:14px;line-height:1.6;word-wrap:break-word;animation:fadeIn .3s ease}
@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
.msg.user{align-self:flex-end;background:linear-gradient(135deg,#6366f1 0%,#8b5cf6 100%);color:#fff;border-bottom-right-radius:4px}
.msg.bot{align-self:flex-start;background:#334155;color:#e2e8f0;border-bottom-left-radius:4px}
.msg.system{align-self:center;background:#1e293b;color:#64748b;font-size:12px;padding:6px 14px;border-radius:10px}
.typing{display:flex;gap:5px;padding:14px 16px}
.typing-dot{width:8px;height:8px;background:#64748b;border-radius:50%;animation:bounce 1.4s infinite}
.typing-dot:nth-child(2){animation-delay:.2s}
.typing-dot:nth-child(3){animation-delay:.4s}
@keyframes bounce{0%,80%,100%{transform:scale(.8)}40%{transform:scale(1.2)}}
.input-area{padding:12px;background:#1e293b;border-radius:12px}
.input-row{display:flex;gap:10px;margin-bottom:8px}
.input-row:last-child{margin-bottom:0}
#docSelector,#chatInput{flex:1;padding:10px 14px;background:#0f172a;border:1px solid #334155;border-radius:10px;color:#e2e8f0;font-size:14px;outline:none}
#docSelector:focus,#chatInput:focus{border-color:#6366f1}
#sendBtn{padding:10px 20px;background:linear-gradient(135deg,#6366f1 0%,#8b5cf6 100%);border:none;border-radius:10px;color:#fff;font-weight:600;cursor:pointer;transition:opacity .2s}
#sendBtn:hover{opacity:.9}#sendBtn:disabled{opacity:.4;cursor:not-allowed}
.upload-area{padding:30px;background:#1e293b;border:2px dashed #475569;border-radius:12px;text-align:center;cursor:pointer;transition:all .2s}
.upload-area:hover,.upload-area.dragover{border-color:#6366f1;background:rgba(99,102,241,.05)}
.upload-icon{font-size:40px;margin-bottom:10px}
.upload-area p{font-size:14px;color:#94a3b8}
.docs-list{display:flex;flex-direction:column;gap:10px}
.empty-msg{text-align:center;color:#64748b;font-size:14px;padding:20px}
.doc-item{display:flex;align-items:center;gap:12px;padding:12px;background:#1e293b;border:1px solid #334155;border-radius:12px;transition:all .2s}
.doc-item:hover{border-color:#475569}
.doc-icon{font-size:28px}
.doc-info{flex:1;min-width:0}
.doc-name{font-size:14px;font-weight:600;color:#e2e8f0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.doc-meta{font-size:12px;color:#64748b;margin-top:3px}
.doc-actions{display:flex;gap:8px}
.doc-btn{padding:5px 12px;background:#334155;border:none;border-radius:8px;color:#94a3b8;font-size:12px;cursor:pointer;transition:all .2s}
.doc-btn:hover{background:#475569;color:#e2e8f0}
.doc-btn.delete{background:rgba(239,68,68,.15);color:#ef4444}
.doc-btn.delete:hover{background:rgba(239,68,68,.25)}
.loading-overlay{position:fixed;inset:0;background:rgba(15,23,42,.95);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:20px;z-index:100}
.loading-overlay.hidden{display:none}
.spinner{width:48px;height:48px;border:4px solid #334155;border-top-color:#6366f1;border-radius:50%;animation:spin 1s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
#loadingText{font-size:16px;color:#94a3b8}
.progress-bar{width:240px;height:8px;background:#334155;border-radius:4px;overflow:hidden}
.progress-fill{height:100%;width:0%;background:linear-gradient(90deg,#6366f1,#8b5cf6);border-radius:4px;transition:width .3s ease}
.banner{padding:10px 14px;border-radius:10px;font-size:13px;margin:0 4px}
.banner.error{background:rgba(239,68,68,.15);color:#fca5a5}
.banner.warn{background:rgba(245,158,11,.15);color:#fcd34d}
.banner.info{background:rgba(59,130,246,.15);color:#93c5fd}
.sources{margin-top:10px;padding-top:10px;border-top:1px solid rgba(255,255,255,.08);font-size:12px;color:#94a3b8}
.sources summary{cursor:pointer;color:#64748b}
::-webkit-scrollbar{width:6px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:#475569;border-radius:3px}
::-webkit-scrollbar-thumb:hover{background:#64748b}
</style>
</head>
<body>
<div class="app">
<div class="header">
<h1>📄 PDF Chatbot</h1>
<span class="badge">Runs entirely in your browser — zero data sent to servers</span>
</div>
<div class="status-panel">
<div class="status-item"><span class="status-dot loading" id="modelDot"></span><span id="modelText">Loading AI model...</span></div>
<div class="status-item"><span class="status-dot loading" id="webgpuDot"></span><span id="webgpuText">Checking acceleration...</span></div>
</div>
<div class="tabs">
<button class="tab-btn active" data-tab="chat">💬 Chat</button>
<button class="tab-btn" data-tab="docs">📁 Documents</button>
</div>
<div class="tab-content active" id="chatTab">
<div class="chat-container" id="chatBox">
<div class="welcome-msg">
<div class="welcome-icon">🤖</div>
<h3>Welcome to PDF Chatbot</h3>
<p>Upload a PDF in the <strong>Documents</strong> tab, then ask questions about it here.</p>
<p class="small">All processing runs locally in your browser using AI models. No data leaves your device.</p>
</div>
</div>
<div class="input-area">
<div class="input-row">
<select id="docSelector"><option value="">Select a document...</option></select>
</div>
<div class="input-row">
<input type="text" id="chatInput" placeholder="Ask a question about your PDF..." disabled>
<button id="sendBtn" disabled>Send</button>
</div>
</div>
</div>
<div class="tab-content" id="docsTab">
<div class="upload-area" id="uploadArea">
<div class="upload-icon">📁</div>
<p>Drop PDFs here or click to browse</p>
<input type="file" id="pdfInput" accept=".pdf" multiple style="display:none">
</div>
<div class="docs-list" id="docsList"><p class="empty-msg">No documents uploaded yet</p></div>
</div>
<div class="loading-overlay hidden" id="loadingOverlay">
<div class="spinner"></div>
<p id="loadingText">Processing...</p>
<div class="progress-bar"><div class="progress-fill" id="progressFill"></div></div>
</div>
</div>
<script type="module">
// ============================================
// PDF Chatbot - Runs entirely in your browser
// Using Transformers.js + PDF.js (loaded from CDN)
// No server needed. No data sent anywhere.
// ============================================
const CONFIG = {
EMBED_MODEL: 'Xenova/all-MiniLM-L6-v2',
CHUNK_SIZE: 400,
CHUNK_OVERLAP: 80,
TOP_K: 5,
MAX_CONTEXT_TOKENS: 1500,
SIMILARITY_THRESHOLD: 0.25,
};
const state = {
isReady: false,
webgpuAvailable: false,
docs: [],
extractor: null,
vectorStore: {},
messages: [],
selectedDocId: '',
};
const $ = s => document.querySelector(s);
const $$ = s => document.querySelectorAll(s);
// ========== WebGPU Check ==========
async function checkWebGPU() {
const dot = $('#webgpuDot');
const txt = $('#webgpuText');
try {
if (navigator.gpu) {
const adapter = await navigator.gpu.requestAdapter();
if (adapter) {
dot.classList.replace('loading','ready');
txt.textContent = 'WebGPU acceleration active';
state.webgpuAvailable = true;
return;
}
}
dot.classList.replace('loading','warn');
txt.textContent = 'WebGPU unavailable (WASM fallback - slower)';
} catch(e) {
dot.classList.replace('loading','warn');
txt.textContent = 'WebGPU unavailable';
}
}
// ========== Load Transformers.js ==========
async function loadModels() {
const dot = $('#modelDot');
const txt = $('#modelText');
try {
const { pipeline, env } = await import('https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.1.0/dist/transformers.min.js');
env.allowLocalModels = false;
env.useBrowserCache = true;
state.extractor = await pipeline('feature-extraction', CONFIG.EMBED_MODEL, {
dtype: 'q8',
device: state.webgpuAvailable ? 'webgpu' : 'cpu',
});
dot.classList.replace('loading','ready');
txt.textContent = 'Embedding model ready';
state.isReady = true;
updateInputState();
console.log('[App] Model loaded');
} catch(e) {
console.error('[App] Model load failed:', e);
dot.classList.replace('loading','warn');
txt.textContent = 'Model load failed - using keyword fallback';
showBanner('warn', 'Could not load AI embedding model. Using keyword-based search fallback. For full AI, ensure you have internet for the first download, then it works offline.');
updateInputState();
}
}
// ========== PDF.js ==========
let pdfjsLib = null;
async function initPDFJS() {
try {
pdfjsLib = await import('https://cdn.jsdelivr.net/npm/pdfjs-dist@4.7.76/build/pdf.min.mjs');
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@4.7.76/build/pdf.worker.min.mjs';
return true;
} catch(e) {
console.warn('[PDF] PDF.js load failed:', e);
return false;
}
}
async function extractPdfText(arrayBuffer) {
if (!pdfjsLib && !(await initPDFJS())) {
return extractTextFallback(arrayBuffer);
}
try {
const pdf = await pdfjsLib.getDocument({ data: new Uint8Array(arrayBuffer) }).promise;
let fullText = '';
const totalPages = Math.min(pdf.numPages, 200);
for (let i = 1; i <= totalPages; i++) {
updateLoading(`Extracting page ${i}/${totalPages}...`, Math.round((i/totalPages)*30));
const page = await pdf.getPage(i);
const textContent = await page.getTextContent();
const pageText = textContent.items.map(item => item.str).join(' ');
fullText += pageText + '\n\n';
}
return fullText;
} catch(e) {
return extractTextFallback(arrayBuffer);
}
}
function extractTextFallback(arrayBuffer) {
try {
const text = new TextDecoder('iso-8859-1').decode(new Uint8Array(arrayBuffer));
const matches = text.match(/\(([^)]{10,500})\)/g);
if (matches && matches.length > 5) {
return matches.map(m => m.slice(1,-1).replace(/\\n/g,' ').replace(/\\r/g,' ')).join(' ');
}
return text.replace(/[^\x20-\x7E\n]/g,' ').substring(0,50000);
} catch(e) { return ''; }
}
// ========== Text Chunking ==========
function chunkText(text, size, overlap) {
const clean = text.replace(/\r\n/g,'\n').replace(/\n+/g,'\n').replace(/\s+/g,' ').trim();
const sentences = clean.split(/(?<=[.!?])\s+/).map(s=>s.trim()).filter(s=>s.length>5);
const chunks=[]; let cur=[], len=0;
for(const s of sentences){
if(len+s.length>size && cur.length>0){
chunks.push(cur.join(' '));
let olen=0, over=[];
for(let i=cur.length-1;i>=0;i--){
if(olen+cur[i].length>overlap)break;
over.unshift(cur[i]); olen+=cur[i].length;
}
cur=over; len=olen;
}
cur.push(s); len+=s.length+1;
}
if(cur.length) chunks.push(cur.join(' '));
return chunks.filter(c=>c.trim().length>20);
}
// ========== Embeddings ==========
async function computeEmbeddings(chunks) {
if(!state.extractor) return computeKeywordVectors(chunks);
const embs=[];
for(let i=0;i<chunks.length;i+=8){
updateLoading(`Computing embeddings... ${Math.round((i/chunks.length)*100)}%`, 60+Math.round((i/chunks.length)*30));
const batch = chunks.slice(i,i+8);
try{
const res = await state.extractor(batch,{pooling:'mean',normalize:true});
const vecs = res.tolist ? res.tolist() : Array.from(res);
embs.push(...vecs);
}catch(e){
const kw = computeKeywordVectors(batch);
embs.push(...kw);
}
}
return embs;
}
const STOP_WORDS = new Set(['the','a','an','is','are','was','were','be','been','being','have','has','had','do','does','did','will','would','could','should','may','might','must','shall','can','need','dare','ought','used','to','of','in','for','on','with','at','by','from','as','into','through','during','before','after','above','below','between','under','again','further','then','once','here','there','when','where','why','how','all','any','both','each','few','more','most','other','some','such','no','nor','not','only','own','same','so','than','too','very','just','and','but','if','or','because','until','while','about','against','out','up','down','off','over','under','again','further','then','once']);
function computeKeywordVectors(chunks){
const vocab=new Map();
const docWords=chunks.map(chunk=>{
return chunk.toLowerCase().replace(/[^a-z0-9\s]/g,' ').split(/\s+/).filter(w=>w.length>2&&w.length<20&&!STOP_WORDS.has(w));
});
docWords.forEach(words=>words.forEach(w=>{if(!vocab.has(w))vocab.set(w,vocab.size)}));
const vs=Math.min(vocab.size,2000);
const idf={};
vocab.forEach((idx,word)=>{
if(idx>=vs)return;
const df=docWords.filter(d=>d.includes(word)).length;
idf[idx]=Math.log((docWords.length+1)/(df+0.5));
});
return docWords.map(words=>{
const vec=new Float32Array(vs);
words.forEach(w=>{const idx=vocab.get(w);if(idx!==undefined&&idx<vs)vec[idx]+=(idf[idx]||1);});
let norm=0;for(let i=0;i<vs;i++)norm+=vec[i]*vec[i];norm=Math.sqrt(norm);
if(norm>0)for(let i=0;i<vs;i++)vec[i]/=norm;
return Array.from(vec);
});
}
// ========== RAG ==========
async function getAnswer(question, docId){
const chunks=await retrieveChunks(question,docId,CONFIG.TOP_K);
if(chunks.length===0||(chunks[0].score<CONFIG.SIMILARITY_THRESHOLD&&state.extractor)){
return{text:"I couldn't find relevant information in the document for that question. Try rephrasing or asking about something mentioned in the PDF.",sources:[]};
}
let context='',cc=0,used=[];
for(const c of chunks){
if(cc+c.text.length>CONFIG.MAX_CONTEXT_TOKENS)break;
context+=`\n[Excerpt ${c.index+1}]: ${c.text}\n`;cc+=c.text.length;
used.push({text:c.text,score:c.score,index:c.index});
}
return{text:generateExtractiveAnswer(question,context,used),sources:used};
}
async function retrieveChunks(query, docId, topK){
const store=state.vectorStore[docId];
if(!store || store.length===0) return [];
let qemb;
if(state.extractor){
try{const res=await state.extractor([query],{pooling:'mean',normalize:true});qemb=res.tolist?res.tolist()[0]:Array.from(res)[0];}
catch(e){qemb=keywordVector(query,store);}
}else{qemb=keywordVector(query,store);}
const scored=store.map(item=>({text:item.chunk,index:item.index,score:cosSim(item.embedding,qemb)}));
scored.sort((a,b)=>b.score-a.score);
return scored.slice(0,topK);
}
function cosSim(a,b){
let dot=0,na=0,nb=0;const len=Math.min(a.length,b.length);
for(let i=0;i<len;i++){dot+=a[i]*b[i];na+=a[i]*a[i];nb+=b[i]*b[i];}
return dot/(Math.sqrt(na)*Math.sqrt(nb)+1e-10);
}
function keywordVector(text,store){
const words=text.toLowerCase().replace(/[^a-z0-9\s]/g,' ').split(/\s+/).filter(w=>w.length>2&&!STOP_WORDS.has(w));
const vs=store[0]?.embedding?.length||2000;
const vec=new Float32Array(vs);
words.forEach(w=>{let h=0;for(let i=0;i<w.length;i++)h=((h<<5)-h)+w.charCodeAt(i);const idx=Math.abs(h)%vs;vec[idx]+=1;});
let norm=0;for(let i=0;i<vs;i++)norm+=vec[i]*vec[i];norm=Math.sqrt(norm);
if(norm>0)for(let i=0;i<vs;i++)vec[i]/=norm;
return Array.from(vec);
}
function generateExtractiveAnswer(question,context,sources){
const qw=new Set(question.toLowerCase().replace(/[^a-z0-9\s]/g,'').split(/\s+/).filter(w=>w.length>2&&!STOP_WORDS.has(w)));
const allSents=[];
for(const src of sources){
const sents=src.text.split(/(?<=[.!?])\s+/).filter(s=>s.length>15&&s.length<400);
for(const sent of sents){
const sw=new Set(sent.toLowerCase().replace(/[^a-z0-9\s]/g,'').split(/\s+/).filter(w=>w.length>2));
let ov=0;qw.forEach(w=>{if(sw.has(w))ov++;});
allSents.push({sent:sent.trim(),score:ov/Math.max(sw.size,1)});
}
}
allSents.sort((a,b)=>b.score-a.score);
const used=new Set();let ans='';
for(const item of allSents.slice(0,8)){
const k=item.sent.toLowerCase().trim().substring(0,40);
if(!used.has(k)&&ans.length<1200){ans+=item.sent+' ';used.add(k);}
}
if(ans.trim().length<30) ans=sources[0]?.text?.substring(0,700)||"I found relevant content but couldn't formulate a precise answer.";
return ans.trim();
}
// ========== Chat UI ==========
function addMessage(role,text,sources=null){
const box=$('#chatBox');
if(state.messages.length===0){const w=box.querySelector('.welcome-msg');if(w)w.remove();}
const msg=document.createElement('div');msg.className=`msg ${role}`;
msg.innerHTML=escapeHtml(text).replace(/\n/g,'<br>');
if(sources&&sources.length>0){
msg.insertAdjacentHTML('beforeend',`<details class="sources"><summary>Sources (${sources.length} chunks)</summary><ul>${sources.map(s=>`<li>"${escapeHtml(s.text.substring(0,140))}..." <span style="color:#64748b">(score:${(s.score).toFixed(2)})</span></li>`).join('')}</ul></details>`);
}
box.appendChild(msg);box.scrollTop=box.scrollHeight;
state.messages.push({role,text,sources});
}
function showTyping(){
const box=$('#chatBox');
const t=document.createElement('div');t.className='msg bot typing';t.id='typingInd';
t.innerHTML='<div class="typing-dot"></div><div class="typing-dot"></div><div class="typing-dot"></div>';
box.appendChild(t);box.scrollTop=box.scrollHeight;
}
function removeTyping(){const t=$('#typingInd');if(t)t.remove();}
// ========== Upload ==========
function initUpload(){
const area=$('#uploadArea'),input=$('#pdfInput');
area.addEventListener('click',()=>input.click());
area.addEventListener('dragover',e=>{e.preventDefault();area.classList.add('dragover');});
area.addEventListener('dragleave',()=>area.classList.remove('dragover'));
area.addEventListener('drop',e=>{e.preventDefault();area.classList.remove('dragover');handleFiles(e.dataTransfer.files);});
input.addEventListener('change',e=>handleFiles(e.target.files));
}
async function handleFiles(files){
for(const file of files){if(file.name.toLowerCase().endsWith('.pdf'))await processPdf(file);}
}
async function processPdf(file){
showLoading(`Extracting text from "${file.name}"...`);
try{
const ab=await file.arrayBuffer();
const text=await extractPdfText(ab);
if(!text||text.trim().length<50){hideLoading();showBanner('warn',`Could not extract text from ${file.name}. Scanned/image PDFs are not supported.`);return;}
updateLoading('Chunking text...',40);
const chunks=chunkText(text,CONFIG.CHUNK_SIZE,CONFIG.CHUNK_OVERLAP);
const docId='doc_'+Date.now()+'_'+Math.random().toString(36).slice(2,8);
const docInfo={docId,name:file.name,size:file.size,chunkCount:chunks.length,charCount:text.length,indexed:false,date:new Date().toISOString()};
updateLoading(`Indexing "${file.name}"...`,60);
const embeddings=await computeEmbeddings(chunks);
docInfo.indexed=true;
state.docs.push(docInfo);
state.vectorStore[docId]=chunks.map((chunk,i)=>({chunk,embedding:embeddings[i],index:i}));
updateDocList();updateDocSelector();
state.selectedDocId=docId;$('#docSelector').value=docId;updateInputState();
hideLoading();showBanner('info',`"${file.name}" ready with ${chunks.length} chunks`);
$$('.tab-btn')[0].click();
}catch(err){hideLoading();showBanner('error',`Failed: ${err.message}`);console.error(err);}
}
// ========== Doc Management ==========
function updateDocList(){
const list=$('#docsList');
if(state.docs.length===0){list.innerHTML='<p class="empty-msg">No documents uploaded yet</p>';return;}
list.innerHTML=state.docs.map(doc=>`
<div class="doc-item">
<div class="doc-icon">📄</div>
<div class="doc-info">
<div class="doc-name">${escapeHtml(doc.name)}</div>
<div class="doc-meta">${doc.chunkCount} chunks · ${formatBytes(doc.size)} · ${doc.indexed?'✓ Indexed':'⋯ Pending'}</div>
</div>
<div class="doc-actions">
<button class="doc-btn" data-action="select" data-id="${doc.docId}">Chat</button>
<button class="doc-btn delete" data-action="delete" data-id="${doc.docId}">Remove</button>
</div>
</div>
`).join('');
list.querySelectorAll('.doc-btn').forEach(btn=>{
btn.addEventListener('click',async ()=>{
const id=btn.dataset.id;
if(btn.dataset.action==='select'){state.selectedDocId=id;$('#docSelector').value=id;updateInputState();$$('.tab-btn')[0].click();}
else if(btn.dataset.action==='delete'){state.docs=state.docs.filter(d=>d.docId!==id);delete state.vectorStore[id];if(state.selectedDocId===id){state.selectedDocId=state.docs[0]?.docId||'';}updateDocList();updateDocSelector();updateInputState();}
});
});
}
function updateDocSelector(){
const sel=$('#docSelector');
sel.innerHTML='<option value="">Select a document...</option>'+state.docs.map(d=>`<option value="${d.docId}">${escapeHtml(d.name)} (${d.chunkCount} chunks)</option>`).join('');
if(state.selectedDocId)sel.value=state.selectedDocId;
}
function updateInputState(){
const hasDoc=state.selectedDocId&&state.docs.find(d=>d.docId===state.selectedDocId)?.indexed;
$('#chatInput').disabled=!hasDoc;$('#sendBtn').disabled=!hasDoc;$('#docSelector').disabled=state.docs.length===0;
}
// ========== Tabs ==========
function initTabs(){
$$('.tab-btn').forEach(btn=>{
btn.addEventListener('click',()=>{
$$('.tab-btn').forEach(b=>b.classList.remove('active'));
$$('.tab-content').forEach(t=>t.classList.remove('active'));
btn.classList.add('active');$(`#${btn.dataset.tab}Tab`).classList.add('active');
});
});
}
// ========== Chat ==========
function initChat(){
$('#chatInput').addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendMsg();}});
$('#sendBtn').addEventListener('click',sendMsg);
$('#docSelector').addEventListener('change',e=>{state.selectedDocId=e.target.value;updateInputState();});
}
async function sendMsg(){
const input=$('#chatInput');const text=input.value.trim();if(!text||!state.selectedDocId)return;
input.value='';addMessage('user',text);showTyping();
try{const ans=await getAnswer(text,state.selectedDocId);removeTyping();addMessage('bot',ans.text,ans.sources);}
catch(err){removeTyping();addMessage('system',`Error: ${err.message}`);console.error(err);}
}
// ========== Utilities ==========
function escapeHtml(str){if(!str)return'';const d=document.createElement('div');d.textContent=str;return d.innerHTML;}
function formatBytes(b){if(b<1024)return b+' B';if(b<1048576)return(b/1024).toFixed(1)+' KB';return(b/1048576).toFixed(1)+' MB';}
function showLoading(text){$('#loadingText').textContent=text;$('#progressFill').style.width='0%';$('#loadingOverlay').classList.remove('hidden');}
function updateLoading(text,pct){$('#loadingText').textContent=text;$('#progressFill').style.width=Math.min(pct,100)+'%';}
function hideLoading(){$('#loadingOverlay').classList.add('hidden');}
function showBanner(type,msg){
const app=$('.app');
const b=document.createElement('div');b.className=`banner ${type}`;b.textContent=msg;
app.insertBefore(b,$('.tabs'));setTimeout(()=>b.remove(),12000);
}
// ========== Init ==========
async function init(){
initTabs();initChat();initUpload();
await checkWebGPU();await loadModels();
}
init();
</script>
</body>
</html>