CoolFace
Apppublic

hugging2021/local-gemma4-rag

sourceHugging Faceupdated 6mo agoView on Hugging Face
1likes
index.html735 linesDownload Raw Back to root
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