Spaces:
Configuration error
Configuration error
| <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> | |