| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Local WebGPU Chat (Paste Mode)</title> |
| <script src="https://cdn.tailwindcss.com"></script> |
| <style> |
| .no-scrollbar::-webkit-scrollbar { display: none; } |
| .no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; } |
| .typing-cursor::after { content: '▋'; animation: blink 1s step-start infinite; } |
| @keyframes blink { 50% { opacity: 0; } } |
| |
| details > summary { list-style: none; } |
| details > summary::-webkit-details-marker { display: none; } |
| </style> |
| </head> |
| <body class="bg-gray-900 text-gray-100 h-screen flex flex-col font-sans overflow-hidden"> |
|
|
| |
| <header class="p-3 border-b border-gray-700 bg-gray-800 flex justify-between items-center z-10"> |
| <div class="flex items-center gap-2"> |
| <h1 class="text-lg font-bold bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent"> |
| Local .MD Chat |
| </h1> |
| <span class="text-[10px] bg-blue-900 text-blue-300 px-1.5 py-0.5 rounded border border-blue-700 tracking-wider">WEBGPU</span> |
| </div> |
| <div class="text-xs text-gray-400" id="statusLabel">Model: Not Loaded</div> |
| </header> |
|
|
| |
| <div class="bg-gray-800 border-b border-gray-700 shadow-lg z-10"> |
| <details id="pasteDetails" open class="group"> |
| <summary class="p-3 cursor-pointer text-sm font-medium text-blue-300 hover:text-white flex justify-between items-center select-none bg-gray-750"> |
| <span>📄 Document Context (Paste Here)</span> |
| <span class="group-open:rotate-180 transition-transform duration-200">▼</span> |
| </summary> |
| <div class="p-3 pt-0"> |
| <textarea id="pasteInput" |
| class="w-full h-32 bg-gray-900 border border-gray-600 rounded p-2 text-xs font-mono text-gray-300 focus:outline-none focus:border-blue-500 resize-y" |
| placeholder="Paste your Markdown or Text content here..."></textarea> |
| <div class="flex justify-between items-center mt-2"> |
| <span id="charCount" class="text-xs text-gray-500">0 chars</span> |
| <button id="setContextBtn" class="bg-green-600 hover:bg-green-500 text-white text-xs px-4 py-1.5 rounded transition shadow-sm"> |
| Set Context |
| </button> |
| </div> |
| </div> |
| </details> |
| </div> |
|
|
| |
| <main id="chatContainer" class="flex-1 overflow-y-auto p-4 space-y-4 scroll-smooth pb-20"> |
| |
| <div class="flex flex-col items-center justify-center h-full text-center text-gray-500 space-y-4" id="welcomeMsg"> |
| <div class="p-4 bg-gray-800 rounded-full animate-pulse"> |
| <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-8 h-8"> |
| <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z" /> |
| </svg> |
| </div> |
| <div> |
| <h2 class="text-lg font-semibold text-gray-300">Initialize Engine</h2> |
| <p class="text-xs text-gray-500 max-w-xs mt-1 mx-auto"> |
| Downloads ~1.2GB (Llama 3.2 1B). <br>Stored in browser cache for next time. |
| </p> |
| </div> |
| <button id="initBtn" class="bg-blue-600 hover:bg-blue-500 text-white px-6 py-2 rounded-full font-medium transition text-sm shadow-lg shadow-blue-900/50"> |
| Load Model |
| </button> |
| |
| |
| <div id="progressContainer" class="hidden w-64 space-y-2"> |
| <div class="h-1.5 bg-gray-700 rounded-full overflow-hidden"> |
| <div id="progressBar" class="h-full bg-blue-500 w-0 transition-all duration-300"></div> |
| </div> |
| <p id="progressText" class="text-[10px] text-gray-400 font-mono">Initializing...</p> |
| </div> |
| </div> |
| </main> |
|
|
| |
| <footer class="p-3 border-t border-gray-700 bg-gray-800"> |
| <form id="chatForm" class="flex gap-2 max-w-5xl mx-auto"> |
| <input type="text" id="userInput" disabled |
| class="flex-1 bg-gray-900 border border-gray-600 text-white rounded px-3 py-2 text-sm focus:outline-none focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed" |
| placeholder="Wait for model..." autocomplete="off"> |
| <button type="submit" id="sendBtn" disabled |
| class="bg-blue-600 hover:bg-blue-500 disabled:bg-gray-700 disabled:text-gray-500 text-white px-4 py-2 rounded text-sm font-medium transition"> |
| Send |
| </button> |
| </form> |
| </footer> |
|
|
| |
| <script type="module"> |
| import { CreateMLCEngine } from "https://esm.run/@mlc-ai/web-llm"; |
| |
| const SELECTED_MODEL = "Llama-3.2-1B-Instruct-q4f16_1-MLC"; |
| |
| let engine = null; |
| let messages = []; |
| let isGenerating = false; |
| |
| |
| const initBtn = document.getElementById('initBtn'); |
| const progressContainer = document.getElementById('progressContainer'); |
| const progressBar = document.getElementById('progressBar'); |
| const progressText = document.getElementById('progressText'); |
| const welcomeMsg = document.getElementById('welcomeMsg'); |
| const chatContainer = document.getElementById('chatContainer'); |
| const chatForm = document.getElementById('chatForm'); |
| const userInput = document.getElementById('userInput'); |
| const sendBtn = document.getElementById('sendBtn'); |
| const statusLabel = document.getElementById('statusLabel'); |
| |
| |
| const pasteInput = document.getElementById('pasteInput'); |
| const setContextBtn = document.getElementById('setContextBtn'); |
| const pasteDetails = document.getElementById('pasteDetails'); |
| const charCount = document.getElementById('charCount'); |
| |
| |
| pasteInput.addEventListener('input', () => { |
| charCount.innerText = `${pasteInput.value.length} chars`; |
| }); |
| |
| |
| initBtn.addEventListener('click', async () => { |
| initBtn.classList.add('hidden'); |
| progressContainer.classList.remove('hidden'); |
| |
| try { |
| const initProgressCallback = (report) => { |
| progressText.innerText = report.text; |
| const match = report.text.match(/(\d+)%/); |
| if (match) progressBar.style.width = `${match[1]}%`; |
| if (report.text.includes("Finish")) progressBar.style.width = "100%"; |
| }; |
| |
| engine = await CreateMLCEngine( |
| SELECTED_MODEL, |
| { initProgressCallback: initProgressCallback } |
| ); |
| |
| welcomeMsg.remove(); |
| statusLabel.innerText = "Model: Ready"; |
| statusLabel.classList.add("text-green-400"); |
| appendSystemMessage("System: Model loaded. Paste your text above and click 'Set Context'."); |
| |
| } catch (error) { |
| progressText.innerText = "Error: " + error.message; |
| progressText.classList.add("text-red-400"); |
| } |
| }); |
| |
| |
| setContextBtn.addEventListener('click', () => { |
| if (!engine) { |
| alert("Please load the model first!"); |
| return; |
| } |
| |
| let text = pasteInput.value.trim(); |
| if (!text) return; |
| |
| |
| const LIMIT = 20000; |
| let truncated = false; |
| if (text.length > LIMIT) { |
| text = text.substring(0, LIMIT) + "\n...[TRUNCATED]..."; |
| truncated = true; |
| } |
| |
| |
| messages = [ |
| { |
| role: "system", |
| content: `You are a helpful assistant. Answer questions based ONLY on the following text. If the answer is not in the text, say you don't know.\n\n--- CONTEXT START ---\n${text}\n--- CONTEXT END ---` |
| } |
| ]; |
| |
| |
| chatContainer.innerHTML = ''; |
| |
| let msg = `Context updated (${text.length} chars).`; |
| if (truncated) msg += " ⚠️ Text was truncated to prevent crash."; |
| |
| appendSystemMessage(msg); |
| |
| |
| pasteDetails.removeAttribute("open"); |
| |
| |
| enableChat(); |
| }); |
| |
| |
| chatForm.addEventListener('submit', async (e) => { |
| e.preventDefault(); |
| const text = userInput.value.trim(); |
| if (!text || isGenerating) return; |
| |
| appendUserMessage(text); |
| messages.push({ role: "user", content: text }); |
| userInput.value = ''; |
| |
| isGenerating = true; |
| disableInput(); |
| |
| const responseElement = appendAIMessage(""); |
| let fullResponse = ""; |
| |
| try { |
| const chunks = await engine.chat.completions.create({ |
| messages: messages, |
| temperature: 0.6, |
| stream: true, |
| }); |
| |
| for await (const chunk of chunks) { |
| const content = chunk.choices[0]?.delta?.content || ""; |
| fullResponse += content; |
| responseElement.innerText = fullResponse; |
| chatContainer.scrollTop = chatContainer.scrollHeight; |
| } |
| |
| messages.push({ role: "assistant", content: fullResponse }); |
| |
| } catch (err) { |
| responseElement.innerText += "\n[Error: Memory limit reached or GPU error]"; |
| console.error(err); |
| } finally { |
| isGenerating = false; |
| enableInput(); |
| userInput.focus(); |
| } |
| }); |
| |
| |
| function enableChat() { |
| userInput.disabled = false; |
| sendBtn.disabled = false; |
| userInput.placeholder = "Ask about the text..."; |
| userInput.focus(); |
| } |
| |
| function disableInput() { |
| userInput.disabled = true; |
| sendBtn.disabled = true; |
| sendBtn.innerText = "..."; |
| } |
| |
| function enableInput() { |
| userInput.disabled = false; |
| sendBtn.disabled = false; |
| sendBtn.innerText = "Send"; |
| } |
| |
| function appendUserMessage(text) { |
| const div = document.createElement('div'); |
| div.className = "flex justify-end"; |
| div.innerHTML = `<div class="bg-blue-600 text-white px-4 py-2 rounded-2xl rounded-tr-sm max-w-[85%] shadow-md text-sm">${escapeHtml(text)}</div>`; |
| chatContainer.appendChild(div); |
| chatContainer.scrollTop = chatContainer.scrollHeight; |
| } |
| |
| function appendAIMessage(text) { |
| const div = document.createElement('div'); |
| div.className = "flex justify-start"; |
| div.innerHTML = `<div class="bg-gray-700 text-gray-200 px-4 py-2 rounded-2xl rounded-tl-sm max-w-[85%] shadow-md text-sm whitespace-pre-wrap leading-relaxed typing-cursor"></div>`; |
| chatContainer.appendChild(div); |
| return div.firstElementChild; |
| } |
| |
| function appendSystemMessage(text) { |
| const div = document.createElement('div'); |
| div.className = "flex justify-center my-4"; |
| div.innerHTML = `<span class="text-[10px] uppercase tracking-wide text-gray-500 bg-gray-800/50 border border-gray-700 px-3 py-1 rounded-full">${text}</span>`; |
| chatContainer.appendChild(div); |
| } |
| |
| function escapeHtml(text) { |
| const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; |
| return text.replace(/[&<>"']/g, function(m) { return map[m]; }); |
| } |
| </script> |
| </body> |
| </html> |