hugging2021/local-gemma4-rag
1
1<!DOCTYPE html>2<html lang="en">3<head>4 <meta charset="UTF-8">5 <meta name="viewport" content="width=device-width, initial-scale=1.0">6 <title>Gemma 4 Multimodal Agent | Standalone</title>7 <link rel="preconnect" href="https://fonts.googleapis.com">8 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>9 <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;800&display=swap" rel="stylesheet">10 11 <!-- Essential Libraries (CDN) -->12 <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>13 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/github-dark.min.css">14 <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js"></script>15 <script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.4.120/pdf.min.js"></script>16 <script>17 // Set worker for pdf.js (CDN)18 pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.4.120/pdf.worker.min.js';19 20 // Configure marked with highlight.js21 window.marked.setOptions({22 highlight: function(code, lang) {23 if (lang && hljs.getLanguage(lang)) {24 return hljs.highlight(code, { language: lang }).value;25 }26 return hljs.highlightAuto(code).value;27 },28 gfm: true,29 breaks: true30 });31 </script>32 <style>33 :root {34 --glass: rgba(255, 255, 255, 0.08);35 --glass-border: rgba(255, 255, 255, 0.12);36 --accent: #bb8bff;37 --accent-hover: #a370f7;38 --bg: #0f1115;39 --text: #e2e8f0;40 --text-muted: #94a3b8;41 --card-bg: rgba(30, 41, 59, 0.5);42 }43 * { box-sizing: border-box; margin: 0; padding: 0; }44 body {45 background: var(--bg);46 background-image: radial-gradient(circle at 50% 0%, #1e1b4b 0%, #0f1115 100%);47 color: var(--text);48 font-family: 'Inter', -apple-system, sans-serif;49 line-height: 1.6;50 min-height: 100vh;51 padding: 2rem;52 display: flex;53 flex-direction: column;54 }55 .container {56 max-width: 1400px;57 margin: 0 auto;58 width: 100%;59 flex-grow: 1;60 display: flex;61 flex-direction: column;62 }63 header {64 display: flex;65 justify-content: space-between;66 align-items: center;67 margin-bottom: 2rem;68 }69 h1 {70 font-size: 2.2rem;71 font-weight: 800;72 letter-spacing: -0.025em;73 background: linear-gradient(to right, #fff, var(--accent));74 -webkit-background-clip: text;75 background-clip: text;76 -webkit-text-fill-color: transparent;77 }78 .subtitle { color: var(--text-muted); font-size: 1rem; }79 .card {80 background: var(--card-bg);81 border-radius: 20px;82 padding: 2rem;83 border: 1px solid var(--glass-border);84 backdrop-filter: blur(12px);85 margin-bottom: 1.5rem;86 }87 .card.glass { box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37); }88 #setup-container {89 max-width: 600px;90 margin: 4rem auto;91 text-align: center;92 }93 .status-grid {94 display: grid;95 grid-template-columns: 1fr 1fr;96 gap: 1.5rem;97 margin: 2rem 0;98 }99 .status-label {100 font-size: 0.85rem;101 color: var(--text-muted);102 margin-bottom: 0.5rem;103 text-align: left;104 }105 .progress-bar-container {106 height: 6px;107 background: rgba(255, 255, 255, 0.05);108 border-radius: 10px;109 overflow: hidden;110 }111 .progress-fill {112 height: 100%;113 width: 0%;114 background: linear-gradient(to right, #7c3aed, var(--accent));115 transition: width 0.3s ease;116 box-shadow: 0 0 10px rgba(187, 139, 255, 0.5);117 }118 .model-selector-container { margin-bottom: 2rem; text-align: left; }119 .glass-select {120 width: 100%;121 padding: 0.75rem 1rem;122 background: rgba(255, 255, 255, 0.05);123 border: 1px solid var(--glass-border);124 border-radius: 12px;125 color: var(--text);126 font-size: 0.95rem;127 appearance: none;128 cursor: pointer;129 outline: none;130 }131 .glass-select option { background: #1e293b; color: #fff; }132 .primary-btn {133 background: var(--accent);134 color: #fff;135 border: none;136 padding: 1rem 2rem;137 border-radius: 12px;138 font-weight: 600;139 cursor: pointer;140 transition: all 0.2s ease;141 width: 100%;142 font-size: 1rem;143 }144 .primary-btn:hover:not(:disabled) {145 background: var(--accent-hover);146 transform: translateY(-2px);147 box-shadow: 0 10px 20px rgba(187, 139, 255, 0.3);148 }149 .primary-btn:disabled { opacity: 0.5; cursor: not-allowed; }150 #chat-container {151 display: grid;152 grid-template-columns: 280px 1fr 280px;153 gap: 1.5rem;154 flex-grow: 1;155 min-height: 0;156 }157 .hidden { display: none !important; }158 .sidebar {159 display: flex;160 flex-direction: column;161 gap: 1rem;162 height: 100%;163 max-height: 700px;164 overflow-y: auto;165 padding: 1.5rem;166 }167 .sidebar h3 { font-size: 1rem; color: var(--accent); margin-bottom: 1rem; }168 /* RAG Zone */169 .upload-rag-zone {170 display: flex;171 flex-direction: column;172 align-items: center;173 padding: 1.5rem;174 border: 2px dashed var(--glass-border);175 border-radius: 12px;176 color: var(--text-muted);177 cursor: pointer;178 text-align: center;179 font-size: 0.85rem;180 transition: all 0.2s ease;181 margin-bottom: 1rem;182 }183 .upload-rag-zone:hover { border-color: var(--accent); background: rgba(187,139,255,0.05); }184 /* Media Zone */185 .upload-media-zone {186 display: flex;187 flex-direction: column;188 align-items: center;189 padding: 1rem;190 border: 2px dashed rgba(52, 211, 153, 0.4);191 background: rgba(52, 211, 153, 0.05);192 border-radius: 12px;193 color: #a7f3d0;194 cursor: pointer;195 text-align: center;196 font-size: 0.85rem;197 transition: all 0.2s ease;198 }199 .upload-media-zone:hover { border-color: #34d399; background: rgba(52, 211, 153, 0.1); }200 .doc-item {201 display: flex; justify-content: space-between; align-items: center;202 padding: 0.6rem; background: rgba(255,255,255,0.03); border-radius: 8px; margin-bottom: 0.5rem; font-size: 0.85rem;203 }204 .doc-item button { background: none; border: none; color: #ef4444; cursor: pointer; padding: 4px; }205 .chat-main {206 display: flex;207 flex-direction: column;208 gap: 1rem;209 height: 100%;210 }211 .chat-card {212 flex-grow: 1;213 display: flex;214 flex-direction: column;215 height: 700px;216 padding: 0;217 overflow: hidden;218 position: relative;219 }220 .chat-header {221 padding: 1rem 1.5rem;222 background: rgba(0, 0, 0, 0.15);223 border-bottom: 1px solid var(--glass-border);224 display: flex; justify-content: space-between; align-items: center;225 }226 #clear-chat-btn {227 background: transparent; border: 1px solid rgba(239, 68, 68, 0.3); color: #ef4444;228 padding: 0.3rem 0.6rem; border-radius: 6px; font-size: 0.75rem; cursor: pointer;229 }230 #chat-history {231 flex-grow: 1; overflow-y: auto; padding: 1.5rem; display: flex; flex-direction: column; gap: 1.5rem;232 }233 .message { display: flex; gap: 1rem; max-width: 85%; }234 .message.user { align-self: flex-end; flex-direction: row-reverse; }235 .message .avatar {236 width: 32px; height: 32px; border-radius: 8px; background: var(--glass);237 display: flex; align-items: center; justify-content: center; font-size: 0.75rem; font-weight: 700; flex-shrink: 0;238 }239 .message.user .avatar { background: var(--accent); }240 .message .content {241 background: rgba(255, 255, 255, 0.05); padding: 1rem 1.5rem; border-radius: 18px; font-size: 0.95rem; word-break: break-word;242 }243 .message.user .content { background: var(--accent); color: #fff; }244 .message img, .message audio { max-width: 100%; border-radius: 8px; margin-top: 0.5rem; }245 .message audio { width: 300px; }246 .preview-tray {247 padding: 0.5rem 1.5rem;248 background: rgba(0,0,0,0.2);249 border-top: 1px solid var(--glass-border);250 display: flex;251 gap: 1rem;252 min-height: 0;253 transition: all 0.2s;254 }255 .preview-tray.empty { display: none; }256 .preview-item {257 position: relative;258 width: 60px; height: 60px;259 border-radius: 8px; overflow: hidden;260 border: 1px solid var(--glass-border);261 background: #000;262 }263 .preview-item img { width: 100%; height: 100%; object-fit: cover; }264 .preview-item audio { width: 100%; height: 100%; opacity: 0; }265 .preview-item .icon { position: absolute; inset: 0; display:flex; align-items:center; justify-content:center; font-size: 1.5rem; pointer-events: none;}266 .remove-preview {267 position: absolute; top: 2px; right: 2px; background: rgba(0,0,0,0.7); color: white;268 border: none; border-radius: 50%; width: 20px; height: 20px; font-size: 10px; cursor: pointer; display: flex; align-items:center; justify-content:center;269 padding:0;270 }271 .input-area {272 padding: 1rem 1.5rem; background: rgba(0, 0, 0, 0.2); display: flex; gap: 1rem;273 }274 .input-area input {275 flex-grow: 1; background: var(--glass); border: 1px solid var(--glass-border);276 border-radius: 12px; padding: 0.75rem 1.25rem; color: #fff; outline: none; transition: border-color 0.2s;277 }278 .input-area input:focus { border-color: var(--accent); }279 .input-area button {280 background: var(--accent); border: none; width: 45px; height: 45px; border-radius: 12px;281 color: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s;282 }283 .input-area button:hover:not(:disabled) { background: var(--accent-hover); transform: scale(1.05); }284 .error-text { color: #ef4444; font-size: 0.85rem; margin-top: 1rem; }285 </style>286</head>287<body>288 <div class="container">289 <header>290 <div class="header-content">291 <h1>Gemma 4 Multimodal Agent</h1>292 <p class="subtitle">Secure, local-first RAG + Vision + Audio via WebGPU</p>293 </div>294 </header>295 <main id="app">296 <div id="setup-container" class="card glass">297 <div class="status-box">298 <h2 id="status-text">Initialize AI Engine</h2>299 <p class="status-label">Downloads Models 100% in-browser. Fast inference via WebGPU.</p>300 </div>301 302 <div class="status-grid">303 <div class="status-item">304 <div class="status-label">Embedding (MiniLM-L6)</div>305 <div class="progress-bar-container"><div id="progress-embedding" class="progress-fill"></div></div>306 </div>307 <div class="status-item">308 <div class="status-label">Gemma 4 Multimodal</div>309 <div class="progress-bar-container"><div id="progress-llm" class="progress-fill"></div></div>310 </div>311 </div>312 <div class="model-selector-container">313 <label for="model-select" class="status-label">Select Model Variant:</label>314 <select id="model-select" class="glass-select">315 <option value="onnx-community/gemma-4-E2B-it-ONNX">Gemma 4 E2B (2B - Fast, Audio/Vision Support)</option>316 <option value="onnx-community/gemma-4-E4B-it-ONNX">Gemma 4 E4B (4B - Slower, Audio/Vision Support)</option>317 </select>318 </div>319 <button id="init-btn" class="primary-btn">Initialize Local AI Engine</button>320 <p id="error-message" class="error-text" style="display: none;"></p>321 </div>322 <div id="chat-container" class="hidden">323 <!-- RAG Context Sidebar -->324 <aside class="card glass sidebar">325 <h3>๐ Local Knowledge (RAG)</h3>326 <p class="status-label">Upload PDFs or TXTs to give the model persistent context memory.</p>327 328 <div class="upload-rag-zone" id="drop-zone-rag">329 ๐ Drop / Click to add Docs330 </div>331 <input type="file" id="file-input-rag" multiple accept=".txt,.md,.pdf" style="display: none;">332 333 <ul id="docs-list" style="list-style: none; margin-top: 1rem;">334 <p class="hint status-label">No documents indexed yet.</p>335 </ul>336 </aside>337 <!-- Central Chat Area -->338 <div class="chat-main">339 <div class="card glass chat-card">340 <div class="chat-header">341 <h3>Conversation</h3>342 <button id="clear-chat-btn">๐๏ธ Clear Chat</button>343 </div>344 345 <div id="chat-history">346 <div class="message ai">347 <div class="avatar">G4</div>348 <div class="content">Hello! I am ready. You can ask me questions about your RAG documents, or upload an image/audio on the right panel to analyze it!</div>349 </div>350 </div>351 352 <!-- Media Preview Tray -->353 <div class="preview-tray empty" id="preview-tray"></div>354 <div class="input-area">355 <input type="text" id="user-input" placeholder="Type a message or ask to describe attachments..." autocomplete="off">356 <button id="send-btn">357 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>358 </button>359 </div>360 </div>361 </div>362 <!-- Right Sidebar (Media Attachments for Multimodal) -->363 <aside class="card glass sidebar">364 <h3 style="color: #34d399">๐๏ธ๐จ๏ธ Attach Media</h3>365 <p class="status-label">Add an Image or Audio to prompt the multimodal capacities of Gemma 4.</p>366 367 <div class="upload-media-zone" id="drop-zone-media">368 ๐ผ๏ธ Drop / Click to attach Photo or Audio369 </div>370 <input type="file" id="file-input-media" accept="image/*,audio/wav" style="display: none;">371 372 <div style="margin-top: 1rem; font-size: 0.8rem; color: var(--text-muted);">373 * Attachments are passed uniquely in the next prompt, they do not persist like RAG docs.374 <br><br>375 * Supported Audio: .wav formats (max 30s)376 <br>377 * Supported Images: .jpg, .png, .webp378 </div>379 </aside>380 </div>381 </main>382 <footer style="text-align: center; margin-top: 1.5rem; margin-bottom: 1rem; color: var(--text-muted); font-size: 0.9rem;">383 Created by <strong>Norberto Martรญn Afonso</strong>. Follow me on X <a href="https://x.com/NorbertoMartnAf" target="_blank" style="color: var(--accent); text-decoration: none; font-weight: 500;">@NorbertoMartnAf</a> and on Github <a href="https://github.com/nmarafo" target="_blank" style="color: var(--accent); text-decoration: none; font-weight: 500;">@nmarafo</a>384 </footer>385 </div>386 387 <!-- MAIN APP SCRIPT -->388 <script type="module">389 import { create, insert, search } from 'https://cdn.jsdelivr.net/npm/@orama/orama@latest/+esm';390 391 // 1. Vector DB Implementation392 class VectorDB {393 constructor() { this.db = null; this.isInitialized = false; }394 async init() {395 if (this.isInitialized) return;396 this.db = await create({ 397 schema: { text: 'string', fileName: 'string', embedding: 'vector[384]' }, 398 indexStyles: { embedding: 'cosine' } 399 });400 this.isInitialized = true;401 }402 async addDocument(text, fileName, embedding) {403 if (!this.isInitialized) await this.init();404 return await insert(this.db, { text, fileName, embedding });405 }406 async query(vector, limit = 5) {407 const results = await search(this.db, { 408 mode: 'vector', 409 vector: { value: vector, property: 'embedding' }, 410 similarity: 0.5, 411 limit 412 });413 return results.hits.map(hit => ({ text: hit.document.text, fileName: hit.document.fileName, score: hit.score }));414 }415 }416 const db = new VectorDB();417 418 // 2. Web Worker logic419 const workerBlobCode = `420 let Transformers = null;421 let processor = null;422 let model = null;423 let embeddingPipeline = null;424 let CURRENT_LLM_MODEL = 'onnx-community/gemma-4-E2B-it-ONNX';425 426 self.onerror = (e) => {427 self.postMessage({ action: 'error', payload: { message: "Worker Global Error: " + e.message + ". TIP: If on file://, use a server." } });428 };429 430 self.onmessage = async (e) => {431 const { action, payload } = e.data;432 try {433 if (action === 'init') {434 if (payload?.modelId) CURRENT_LLM_MODEL = payload.modelId;435 self.postMessage({ action: 'status', payload: { text: 'Waking up AI Engine...' } });436 437 // --- DYNAMIC ESM IMPORT ---438 if (!Transformers) {439 try {440 Transformers = await import('https://cdn.jsdelivr.net/npm/@huggingface/transformers@latest');441 } catch (err) {442 throw new Error("Security policy blocked Transformers.js. Please use a local server like Live Server.");443 }444 }445 446 const { env } = Transformers;447 env.allowLocalModels = false;448 env.useBrowserCache = true;449 env.backends.onnx.wasm.wasmPaths = 'https://cdn.jsdelivr.net/npm/onnxruntime-web@latest/dist/';450 451 await initModels();452 } else if (action === 'embed') {453 const output = await embeddingPipeline(payload.text, { pooling: 'mean', normalize: true });454 self.postMessage({ action: 'embed_result', payload: { vector: Array.from(output.data) } });455 } else if (action === 'generate') {456 await generateResponse(payload.prompt, payload.context, payload.mediaItems);457 } else if (action === 'cleanup') {458 if (model) await model.dispose();459 if (embeddingPipeline) await embeddingPipeline.dispose();460 }461 } catch (err) {462 self.postMessage({ action: 'error', payload: { message: err.message } });463 }464 };465 466 async function initModels() {467 const { pipeline, AutoProcessor, Gemma4ForConditionalGeneration } = Transformers;468 469 self.postMessage({ action: 'status', payload: { text: 'Loading Embedding Model...' } });470 embeddingPipeline = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2', { 471 device: 'webgpu',472 progress_callback: (p) => { if(p.status === 'progress') self.postMessage({ action: 'progress', payload: { model: 'embedding', progress: p.progress } }); }473 });474 475 self.postMessage({ action: 'status', payload: { text: 'Loading Gemma 4 Processor...' } });476 processor = await AutoProcessor.from_pretrained(CURRENT_LLM_MODEL);477 478 self.postMessage({ action: 'status', payload: { text: 'Loading Gemma 4 Model...' } });479 model = await Gemma4ForConditionalGeneration.from_pretrained(CURRENT_LLM_MODEL, { 480 dtype: "q4f16",481 device: 'webgpu',482 progress_callback: (p) => { if(p.status === 'progress') self.postMessage({ action: 'progress', payload: { model: 'llm', progress: p.progress } }); }483 });484 485 self.postMessage({ action: 'ready' });486 }487 488 async function generateResponse(userPrompt, context, mediaItems = []) {489 try {490 const { load_image, read_audio, TextStreamer } = Transformers;491 492 const isGeneral = !context || context.includes('No context');493 const sysPrompt = isGeneral 494 ? "Act as a helpful assistant. Always respond in Spanish." 495 : "You are an assistant. Answer primarily using the provided context. Always respond in Spanish.\\n\\nCONTEXT:\\n" + context;496 497 let userContentBlock = [];498 let processedImages = [];499 let processedAudio = [];500 501 for(const media of mediaItems) {502 if(media.type === 'image') {503 const img = await load_image(media.data);504 processedImages.push(img);505 userContentBlock.push({ type: "image", image: img });506 } else if(media.type === 'audio') {507 const aud = await read_audio(media.data);508 processedAudio.push(aud);509 userContentBlock.push({ type: "audio", audio: aud });510 }511 }512 513 userContentBlock.push({ type: "text", text: userPrompt || "Describe the contents of this file." });514 515 const messages = [516 { role: 'system', content: sysPrompt },517 { role: 'user', content: userContentBlock }518 ];519 520 const prompt = processor.apply_chat_template(messages, { enable_thinking: false, add_generation_prompt: true });521 522 let inputs;523 const imgArg = processedImages.length === 1 ? processedImages[0] : processedImages.length > 1 ? processedImages : null;524 const audArg = processedAudio.length === 1 ? processedAudio[0] : processedAudio.length > 1 ? processedAudio : null;525 526 if (imgArg && audArg) inputs = await processor(prompt, imgArg, audArg);527 else if (imgArg) inputs = await processor(prompt, imgArg);528 else if (audArg) inputs = await processor(prompt, null, audArg);529 else inputs = await processor(prompt);530 531 let fullOutputText = "";532 const streamer = new TextStreamer(processor.tokenizer, {533 skip_prompt: true,534 skip_special_tokens: true,535 callback_function: (text) => {536 fullOutputText += text;537 self.postMessage({ action: 'chunk', payload: { text: fullOutputText } });538 }539 });540 541 await model.generate({542 ...inputs,543 max_new_tokens: 1024,544 do_sample: false,545 streamer: streamer546 });547 548 self.postMessage({ action: 'generate_complete', payload: { text: fullOutputText } });549 } catch (err) {550 self.postMessage({ action: 'error', payload: { message: "Generation error: " + err.message } });551 }552 }553 `;554 const blob = new Blob([workerBlobCode], { type: 'application/javascript' });555 const worker = new Worker(URL.createObjectURL(blob), { type: 'module' });556 557 // 3. UI Logic558 const setupContainer = document.getElementById('setup-container');559 const chatContainer = document.getElementById('chat-container');560 const initBtn = document.getElementById('init-btn');561 const statusText = document.getElementById('status-text');562 const errorMessage = document.getElementById('error-message');563 const progresses = { embedding: document.getElementById('progress-embedding'), llm: document.getElementById('progress-llm') };564 const userInput = document.getElementById('user-input');565 const sendBtn = document.getElementById('send-btn');566 const chatHistory = document.getElementById('chat-history');567 const previewTray = document.getElementById('preview-tray');568 const docsList = document.getElementById('docs-list');569 const clearChatBtn = document.getElementById('clear-chat-btn');570 571 let isReady = false;572 let activeMediaAttachments = [];573 574 initBtn.onclick = () => {575 initBtn.disabled = true; errorMessage.style.display='none';576 worker.postMessage({ action: 'init', payload: { modelId: document.getElementById('model-select').value } });577 };578 579 window.addEventListener('beforeunload', () => {580 if (worker) {581 worker.postMessage({ action: 'cleanup' });582 worker.terminate();583 }584 });585 586 worker.onmessage = async (e) => {587 const { action, payload } = e.data;588 if (action === 'progress') {589 progresses[payload.model].style.width = `${payload.progress}%`;590 } else if (action === 'status') {591 statusText.innerText = payload.text;592 } else if (action === 'ready') {593 isReady = true; setupContainer.classList.add('hidden'); chatContainer.classList.remove('hidden'); await db.init();594 } else if (action === 'chunk') {595 updateAIChat(payload.text);596 } else if (action === 'generate_complete') {597 // Done598 } else if (action === 'error') {599 if (isReady) {600 updateAIChat(`โ **Error:** ${payload.message}`);601 } else {602 errorMessage.innerText = payload.message; errorMessage.style.display = 'block'; statusText.innerText = "Error"; initBtn.disabled = false;603 }604 }605 };606 607 // RAG Handling608 const dropRAG = document.getElementById('drop-zone-rag');609 const inputRAG = document.getElementById('file-input-rag');610 dropRAG.onclick = () => inputRAG.click();611 612 inputRAG.onchange = async (e) => {613 for (const file of e.target.files) {614 const text = file.type === 'application/pdf' ? await extractPdf(file) : await file.text();615 const chunks = chunkText(text, 1000, 200);616 for (const chunk of chunks) {617 const vector = await requestEmbedding(chunk);618 await db.addDocument(chunk, file.name, vector);619 }620 const li = document.createElement('li'); li.className='doc-item'; li.innerHTML = `๐ ${file.name}`; docsList.appendChild(li);621 const hint = docsList.querySelector('.hint'); if(hint) hint.remove();622 }623 };624 625 async function extractPdf(file) {626 const pdf = await pdfjsLib.getDocument({ data: await file.arrayBuffer() }).promise;627 let text = '';628 for (let i = 1; i <= pdf.numPages; i++) {629 const page = await pdf.getPage(i);630 const content = await page.getTextContent();631 text += content.items.map(item => item.str).join(' ') + '\n';632 }633 return text;634 }635 636 function chunkText(text, size, overlap) {637 const chunks = [];638 for (let i = 0; i < text.length; i += size - overlap) {639 chunks.push(text.slice(i, i + size));640 if (i + size > text.length) break;641 }642 return chunks;643 }644 645 // Media Handling646 const dropMedia = document.getElementById('drop-zone-media');647 const inputMedia = document.getElementById('file-input-media');648 dropMedia.onclick = () => inputMedia.click();649 650 inputMedia.onchange = async (e) => {651 const file = e.target.files[0]; if(!file) return;652 const reader = new FileReader();653 reader.onload = (ev) => {654 const base64data = ev.target.result;655 const isImage = file.type.startsWith('image/');656 const isAudio = file.type.startsWith('audio/');657 if(!isImage && !isAudio) return;658 activeMediaAttachments.push({ type: isImage ? 'image' : 'audio', data: base64data });659 renderPreviewTray();660 };661 reader.readAsDataURL(file);662 };663 664 function renderPreviewTray() {665 previewTray.innerHTML = '';666 if(activeMediaAttachments.length === 0) { previewTray.classList.add('empty'); return; }667 previewTray.classList.remove('empty');668 activeMediaAttachments.forEach((m, idx) => {669 const div = document.createElement('div'); div.className = 'preview-item';670 div.innerHTML = m.type === 'image' 671 ? `<img src="${m.data}"/><button class="remove-preview" onclick="window.removeMedia(${idx})">X</button>`672 : `<div class="icon">๐ต</div><button class="remove-preview" onclick="window.removeMedia(${idx})">X</button>`;673 previewTray.appendChild(div);674 });675 }676 window.removeMedia = (idx) => { activeMediaAttachments.splice(idx, 1); renderPreviewTray(); };677 678 // Chat Logistics679 sendBtn.onclick = handleChat;680 userInput.onkeydown = (e) => { if (e.key === 'Enter') handleChat(); };681 682 async function handleChat() {683 const prompt = userInput.value.trim(); 684 if (!isReady || (!prompt && activeMediaAttachments.length === 0)) return;685 686 let attachmentsHtml = activeMediaAttachments.map(m => m.type==='image' ? `<img src="${m.data}" style="max-width:200px; display:block; margin: 10px 0;" />` : `<br>๐ต Audio Attached`).join('');687 appendUserMessage(prompt + attachmentsHtml);688 userInput.value = '';689 690 let context = 'No context.';691 if (prompt) {692 const vector = await requestEmbedding(prompt);693 const results = await db.query(vector, 3);694 if (results.length > 0) context = results.map(r => `[Source: ${r.fileName}]: ${r.text}`).join('\n---\n');695 }696 697 worker.postMessage({ action: 'generate', payload: { prompt, context, mediaItems: activeMediaAttachments } });698 activeMediaAttachments = [];699 renderPreviewTray();700 prepareAIChat();701 }702 703 function requestEmbedding(text) {704 return new Promise(res => {705 const h = (e) => { if (e.data.action === 'embed_result') { worker.removeEventListener('message', h); res(e.data.payload.vector); } };706 worker.addEventListener('message', h);707 worker.postMessage({ action: 'embed', payload: { text } });708 });709 }710 711 function appendUserMessage(html) {712 const div = document.createElement('div'); div.className = 'message user';713 div.innerHTML = `<div class="avatar">U</div><div class="content">${html}</div>`;714 chatHistory.appendChild(div); chatHistory.scrollTop = chatHistory.scrollHeight;715 }716 717 let currentChatResponse = null;718 function prepareAIChat() {719 currentChatResponse = document.createElement('div'); currentChatResponse.className = 'message ai';720 currentChatResponse.innerHTML = `<div class="avatar">G4</div><div class="content"><em>Thinking...</em></div>`;721 chatHistory.appendChild(currentChatResponse); chatHistory.scrollTop = chatHistory.scrollHeight;722 }723 724 function updateAIChat(text) {725 if (currentChatResponse) {726 currentChatResponse.querySelector('.content').innerHTML = window.marked.parse(text);727 chatHistory.scrollTop = chatHistory.scrollHeight;728 }729 }730 731 clearChatBtn.onclick = () => { if(confirm('Clear history?')) chatHistory.innerHTML = ''; };732 </script>733</body>734</html>735 