Local_mind / index.html
SiddhJagani's picture
Update index.html
d899bb1 verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LOCAL MIND β€” On-Device AI</title>
<script type="module">
import { CreateMLCEngine } from "https://esm.run/@mlc-ai/web-llm";
// ─── CONFIG ───────────────────────────────────────────────────────────────
const MODEL_ID = "Qwen3-0.6B-q4f16_1-MLC";
let engine = null;
let isLoaded = false;
let chatHistory = [
{
role: 'system',
content: 'You are a helpful, friendly, and knowledgeable assistant. Answer questions clearly and concisely. Be direct and accurate. Do not include excessive preamble or repeat the question back. Respond naturally in the language the user uses.'
}
];
const $ = id => document.getElementById(id);
const statusEl = $('status');
const progressEl = $('progress');
const progressBar = $('progress-bar');
const progressText = $('progress-text');
const chatContainer = $('chat-container');
const inputEl = $('user-input');
const sendBtn = $('send-btn');
const loadBtn = $('load-btn');
const storageInfo = $('storage-info');
const cacheIndicator = $('cache-indicator');
// ─── CHECK CACHE ──────────────────────────────────────────────────────────
async function checkCache() {
try {
const mlcCache = await caches.open('webllm/model');
const keys = await mlcCache.keys();
const modelCached = keys.some(r => r.url.includes('Qwen3-0.6B'));
if (modelCached) {
cacheIndicator.innerHTML = `<span class="dot cached"></span> Model cached locally`;
cacheIndicator.classList.add('has-cache');
loadBtn.textContent = 'Load (From Cache)';
} else {
cacheIndicator.innerHTML = `<span class="dot"></span> Not cached β€” will download once`;
}
if ('storage' in navigator && 'estimate' in navigator.storage) {
const est = await navigator.storage.estimate();
const usedMB = ((est.usage || 0) / 1024 / 1024).toFixed(0);
const quotaGB = ((est.quota || 0) / 1024 / 1024 / 1024).toFixed(1);
storageInfo.textContent = `Browser storage: ${usedMB}MB used / ${quotaGB}GB available`;
}
} catch(e) {
cacheIndicator.innerHTML = `<span class="dot"></span> Cache status unknown`;
}
}
// ─── LOAD MODEL ───────────────────────────────────────────────────────────
async function loadModel() {
loadBtn.disabled = true;
loadBtn.textContent = 'Loading...';
progressEl.style.display = 'flex';
statusEl.textContent = 'Initializing WebGPU engine...';
$('welcome').style.display = 'none';
try {
engine = await CreateMLCEngine(MODEL_ID, {
initProgressCallback: (report) => {
const pct = Math.round((report.progress || 0) * 100);
progressBar.style.width = `${pct}%`;
const msg = (report.text || `${pct}%`).substring(0, 60);
progressText.textContent = msg;
if (report.progress >= 1) {
statusEl.textContent = 'Model ready β€” running fully on your device';
progressEl.style.display = 'none';
isLoaded = true;
inputEl.disabled = false;
sendBtn.disabled = false;
inputEl.placeholder = 'Ask anything...';
loadBtn.style.display = 'none';
checkCache();
addSystemMessage('βœ“ Model loaded & cached. Everything runs on-device. Zero data leaves your browser.');
}
}
});
} catch(err) {
statusEl.textContent = `Error: ${err.message}`;
progressEl.style.display = 'none';
loadBtn.disabled = false;
loadBtn.textContent = 'Retry Load';
if (err.message && err.message.includes('WebGPU')) {
addSystemMessage('⚠️ WebGPU not supported. Use Chrome 113+ or Edge 113+.');
} else {
addSystemMessage(`Error loading model: ${err.message}`);
}
}
}
// ─── MESSAGES ─────────────────────────────────────────────────────────────
function addSystemMessage(text) {
const div = document.createElement('div');
div.className = 'msg system-msg';
div.textContent = text;
chatContainer.appendChild(div);
chatContainer.scrollTop = chatContainer.scrollHeight;
}
function addMessage(role, content) {
const div = document.createElement('div');
div.className = `msg ${role}-msg`;
const label = document.createElement('span');
label.className = 'msg-label';
label.textContent = role === 'user' ? 'YOU' : 'AI';
const contentWrapper = document.createElement('div');
contentWrapper.className = 'msg-content-wrapper';
// Thinking animation (shown while inside <think> block)
const thinkingEl = document.createElement('div');
thinkingEl.className = 'thinking-indicator';
// Label next to dots
const thinkingLabel = document.createElement('span');
thinkingLabel.className = 'thinking-label';
thinkingLabel.textContent = 'Reasoning';
const dotsWrap = document.createElement('div');
dotsWrap.className = 'thinking-dots';
for (let i = 0; i < 3; i++) {
const dot = document.createElement('span');
dot.className = 'thinking-dot';
dotsWrap.appendChild(dot);
}
thinkingEl.appendChild(thinkingLabel);
thinkingEl.appendChild(dotsWrap);
thinkingEl.style.display = role === 'assistant' ? 'flex' : 'none';
// Main response text
const text = document.createElement('p');
text.className = 'msg-text';
text.textContent = content;
// Reasoning dropdown (hidden until thinking is done)
const reasoningToggle = document.createElement('button');
reasoningToggle.className = 'reasoning-toggle';
reasoningToggle.innerHTML = 'β–Ά Reasoning';
reasoningToggle.style.display = 'none';
const reasoningContent = document.createElement('div');
reasoningContent.className = 'reasoning-content';
reasoningContent.style.display = 'none';
reasoningToggle.addEventListener('click', () => {
const isHidden = reasoningContent.style.display === 'none';
reasoningContent.style.display = isHidden ? 'block' : 'none';
reasoningToggle.innerHTML = isHidden ? 'β–Ό Reasoning' : 'β–Ά Reasoning';
});
contentWrapper.appendChild(thinkingEl);
contentWrapper.appendChild(text);
contentWrapper.appendChild(reasoningToggle);
contentWrapper.appendChild(reasoningContent);
div.appendChild(label);
div.appendChild(contentWrapper);
chatContainer.appendChild(div);
chatContainer.scrollTop = chatContainer.scrollHeight;
return { text, thinkingEl, reasoningToggle, reasoningContent };
}
// ─── PARSE RESPONSE ───────────────────────────────────────────────────────
// Qwen3 thinking models output: <think>...reasoning...</think> actual response
// We parse this in real-time as tokens stream in.
function parseResponse(fullText, elements) {
const { reasoningContent, reasoningToggle, thinkingEl } = elements || {};
const THINK_OPEN = '<think>';
const THINK_CLOSE = '</think>';
const startIdx = fullText.indexOf(THINK_OPEN);
// No <think> tag found at all β€” plain response
if (startIdx === -1) {
if (thinkingEl) thinkingEl.style.display = 'none';
return fullText.trim();
}
const endIdx = fullText.indexOf(THINK_CLOSE);
if (endIdx === -1) {
// Still streaming inside <think>...</think> β€” show animation, populate reasoning
if (thinkingEl) thinkingEl.style.display = 'flex';
const reasoning = fullText.substring(startIdx + THINK_OPEN.length);
if (reasoningContent) reasoningContent.textContent = reasoning;
// Don't show toggle yet β€” we're still thinking
if (reasoningToggle) reasoningToggle.style.display = 'none';
return ''; // No visible response yet
}
// </think> found β€” thinking is complete, extract both parts
if (thinkingEl) thinkingEl.style.display = 'none';
const reasoning = fullText.substring(startIdx + THINK_OPEN.length, endIdx).trim();
const response = fullText.substring(endIdx + THINK_CLOSE.length).trim();
if (reasoningContent) reasoningContent.textContent = reasoning;
if (reasoningToggle && reasoning.length > 0) {
reasoningToggle.style.display = 'inline-flex';
}
return response;
}
// ─── SEND ─────────────────────────────────────────────────────────────────
async function sendMessage() {
if (!isLoaded || !engine) return;
const userText = inputEl.value.trim();
if (!userText) return;
inputEl.value = '';
inputEl.style.height = 'auto';
sendBtn.disabled = true;
inputEl.disabled = true;
addMessage('user', userText);
chatHistory.push({ role: 'user', content: userText });
const aiElements = addMessage('assistant', '');
const { text, thinkingEl, reasoningToggle, reasoningContent } = aiElements;
try {
const stream = await engine.chat.completions.create({
messages: chatHistory,
stream: true,
temperature: 0.7,
max_tokens: 1024,
});
let fullResponse = '';
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content || '';
fullResponse += delta;
const displayText = parseResponse(fullResponse, {
reasoningContent,
reasoningToggle,
thinkingEl
});
text.textContent = displayText;
chatContainer.scrollTop = chatContainer.scrollHeight;
}
// Final parse pass
const finalText = parseResponse(fullResponse, {
reasoningContent,
reasoningToggle,
thinkingEl
});
text.textContent = finalText;
// Ensure thinking anim is hidden
thinkingEl.style.display = 'none';
// Store only the actual response (no <think> tags) in history
chatHistory.push({ role: 'assistant', content: finalText });
} catch(err) {
thinkingEl.style.display = 'none';
text.textContent = `Error: ${err.message}`;
}
sendBtn.disabled = false;
inputEl.disabled = false;
inputEl.focus();
}
// ─── EVENTS ───────────────────────────────────────────────────────────────
loadBtn.addEventListener('click', loadModel);
sendBtn.addEventListener('click', sendMessage);
inputEl.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
$('clear-btn').addEventListener('click', () => {
chatHistory = [
{
role: 'system',
content: 'You are a helpful, friendly, and knowledgeable assistant. Answer questions clearly and concisely. Be direct and accurate. Do not include excessive preamble or repeat the question back. Respond naturally in the language the user uses.'
}
];
chatContainer.innerHTML = '';
addSystemMessage('Conversation cleared. Model still loaded.');
});
// ─── INIT ─────────────────────────────────────────────────────────────────
window.addEventListener('load', () => {
checkCache();
if (!navigator.gpu) {
statusEl.textContent = '⚠️ WebGPU required β€” use Chrome 113+ / Edge 113+';
loadBtn.disabled = true;
loadBtn.title = 'WebGPU not available in this browser';
}
});
</script>
<style>
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Bebas+Neue&family=DM+Sans:ital,wght@0,300;0,400;0,500;1,300&display=swap');
:root {
--bg: #080808;
--surface: #0f0f0f;
--surface2: #141414;
--border: #1e1e1e;
--border2: #2a2a2a;
--accent: #c8ff00;
--accent2: #00ffc8;
--text: #ddd;
--muted: #444;
--ai-border: #2a3a0a;
--ai-bg: #0d1205;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: 'DM Sans', sans-serif;
font-weight: 300;
height: 100dvh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* HEADER */
header {
padding: 14px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
background: var(--surface);
flex-shrink: 0;
}
.logo { display: flex; align-items: baseline; gap: 10px; }
.logo-text {
font-family: 'Bebas Neue', sans-serif;
font-size: 26px;
letter-spacing: 4px;
color: var(--accent);
line-height: 1;
}
.logo-badge {
font-family: 'DM Mono', monospace;
font-size: 9px;
color: #000;
background: var(--accent);
padding: 2px 6px;
letter-spacing: 1px;
}
.header-right { display: flex; flex-direction: column; align-items: flex-end; gap: 3px; }
#cache-indicator {
font-family: 'DM Mono', monospace;
font-size: 10px;
color: var(--muted);
display: flex;
align-items: center;
gap: 6px;
transition: color 0.4s;
}
#cache-indicator.has-cache { color: var(--accent); }
.dot {
width: 5px; height: 5px; border-radius: 50%;
background: var(--muted); display: inline-block;
animation: blink 2s infinite;
}
.dot.cached { background: var(--accent); animation: none; }
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.2} }
#storage-info {
font-family: 'DM Mono', monospace;
font-size: 9px;
color: #2a2a2a;
}
/* STATUS BAR */
.status-bar {
padding: 7px 20px;
background: var(--surface2);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 14px;
flex-shrink: 0;
min-height: 40px;
}
#status {
font-family: 'DM Mono', monospace;
font-size: 10px;
color: var(--muted);
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#progress { display: none; align-items: center; gap: 10px; width: 260px; flex-shrink: 0; }
.progress-track {
flex: 1; height: 2px;
background: var(--border2);
border-radius: 1px; overflow: hidden;
}
#progress-bar {
height: 100%; background: var(--accent); width: 0%;
transition: width 0.4s ease;
box-shadow: 0 0 6px var(--accent);
}
#progress-text {
font-family: 'DM Mono', monospace;
font-size: 9px;
color: var(--accent);
width: 50px; text-align: right;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.model-tag {
font-family: 'DM Mono', monospace;
font-size: 9px;
color: #2e2e2e;
border: 1px solid #1a1a1a;
padding: 3px 8px;
flex-shrink: 0;
}
#load-btn {
font-family: 'Bebas Neue', sans-serif;
font-size: 14px;
letter-spacing: 2px;
background: var(--accent);
color: #000;
border: none;
padding: 7px 18px;
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
}
#load-btn:hover:not(:disabled) {
background: var(--accent2);
box-shadow: 0 0 18px rgba(200,255,0,0.25);
}
#load-btn:disabled { opacity: 0.3; cursor: not-allowed; }
/* CHAT */
#chat-container {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 14px;
position: relative;
}
#chat-container::-webkit-scrollbar { width: 3px; }
#chat-container::-webkit-scrollbar-thumb { background: #1e1e1e; }
/* WELCOME */
#welcome {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
pointer-events: none;
user-select: none;
}
.welcome-title {
font-family: 'Bebas Neue', sans-serif;
font-size: clamp(48px, 8vw, 80px);
letter-spacing: 8px;
color: #141414;
line-height: 1;
text-align: center;
}
.welcome-features {
display: flex;
gap: 0;
border: 1px solid #141414;
}
.wf {
padding: 8px 16px;
border-right: 1px solid #141414;
text-align: center;
}
.wf:last-child { border-right: none; }
.wf-icon { font-size: 16px; margin-bottom: 4px; opacity: 0.3; }
.wf-text {
font-family: 'DM Mono', monospace;
font-size: 9px;
color: #1e1e1e;
letter-spacing: 1px;
text-transform: uppercase;
line-height: 1.6;
}
/* MESSAGES */
.msg { max-width: 680px; animation: fadeUp 0.2s ease; }
@keyframes fadeUp {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.system-msg {
font-family: 'DM Mono', monospace;
font-size: 10px;
color: #2a2a2a;
align-self: center;
text-align: center;
max-width: 100%;
padding: 4px 0;
}
.user-msg {
align-self: flex-end;
background: #131313;
border: 1px solid var(--border2);
border-radius: 1px 1px 0 1px;
padding: 12px 16px;
}
.assistant-msg {
align-self: flex-start;
background: var(--ai-bg);
border: 1px solid var(--ai-border);
border-left: 2px solid var(--accent);
border-radius: 1px 1px 1px 0;
padding: 12px 16px;
}
.msg-label {
font-family: 'DM Mono', monospace;
font-size: 8px;
letter-spacing: 2px;
color: var(--muted);
display: block;
margin-bottom: 6px;
}
.assistant-msg .msg-label { color: var(--accent); opacity: 0.5; }
.msg p {
font-size: 13.5px;
line-height: 1.75;
font-weight: 300;
white-space: pre-wrap;
word-break: break-word;
}
.msg-content-wrapper {
display: flex;
flex-direction: column;
gap: 8px;
}
/* ── THINKING ANIMATION ── */
.thinking-indicator {
display: none; /* shown via JS when inside <think> */
align-items: center;
gap: 8px;
padding: 2px 0 4px;
}
.thinking-label {
font-family: 'DM Mono', monospace;
font-size: 9px;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--accent);
opacity: 0.6;
}
.thinking-dots { display: flex; gap: 4px; align-items: center; }
.thinking-dot {
width: 5px;
height: 5px;
background: var(--accent);
border-radius: 50%;
animation: thinkBounce 1.3s ease-in-out infinite both;
}
.thinking-dot:nth-child(1) { animation-delay: 0s; }
.thinking-dot:nth-child(2) { animation-delay: 0.18s; }
.thinking-dot:nth-child(3) { animation-delay: 0.36s; }
@keyframes thinkBounce {
0%, 80%, 100% { transform: scale(0.55); opacity: 0.25; }
40% { transform: scale(1); opacity: 1; }
}
/* ── REASONING DROPDOWN ── */
.reasoning-toggle {
display: none; /* shown via JS after </think> */
align-items: center;
gap: 5px;
background: transparent;
border: 1px solid var(--border2);
color: var(--muted);
font-family: 'DM Mono', monospace;
font-size: 9px;
padding: 4px 10px;
cursor: pointer;
border-radius: 2px;
align-self: flex-start;
transition: all 0.2s;
text-transform: uppercase;
letter-spacing: 1px;
}
.reasoning-toggle:hover {
border-color: var(--accent);
color: var(--accent);
}
.reasoning-content {
margin-top: 2px;
padding: 10px 12px;
background: rgba(0,0,0,0.3);
border: 1px solid var(--border2);
border-left: 2px solid var(--accent2);
border-radius: 2px;
font-size: 11.5px;
line-height: 1.6;
color: #666;
white-space: pre-wrap;
word-break: break-word;
font-family: 'DM Mono', monospace;
}
/* INPUT */
.input-area {
border-top: 1px solid var(--border);
padding: 14px 20px;
background: var(--surface);
display: flex;
gap: 10px;
align-items: flex-end;
flex-shrink: 0;
}
#user-input {
flex: 1;
background: var(--surface2);
border: 1px solid var(--border2);
color: var(--text);
font-family: 'DM Sans', sans-serif;
font-size: 13.5px;
font-weight: 300;
padding: 11px 14px;
resize: none;
outline: none;
transition: border-color 0.2s;
min-height: 42px;
max-height: 120px;
border-radius: 0;
line-height: 1.5;
}
#user-input:focus { border-color: var(--accent); }
#user-input:disabled { opacity: 0.25; }
#user-input::placeholder { color: #2a2a2a; }
#send-btn {
font-family: 'Bebas Neue', sans-serif;
font-size: 14px;
letter-spacing: 2px;
background: transparent;
color: var(--accent);
border: 1px solid var(--accent);
padding: 9px 18px;
cursor: pointer;
transition: all 0.15s;
height: 42px;
flex-shrink: 0;
}
#send-btn:hover:not(:disabled) {
background: var(--accent);
color: #000;
box-shadow: 0 0 14px rgba(200,255,0,0.15);
}
#send-btn:disabled { opacity: 0.15; cursor: not-allowed; }
#clear-btn {
font-family: 'DM Mono', monospace;
font-size: 9px;
letter-spacing: 1px;
background: transparent;
color: var(--muted);
border: 1px solid var(--border2);
padding: 9px 12px;
cursor: pointer;
height: 42px;
transition: all 0.15s;
flex-shrink: 0;
text-transform: uppercase;
}
#clear-btn:hover { border-color: #444; color: #888; }
/* SPECS BAR */
.specs {
display: flex;
border-top: 1px solid var(--border);
flex-shrink: 0;
overflow-x: auto;
}
.spec-item {
flex: 1;
padding: 7px 14px;
border-right: 1px solid var(--border);
min-width: 90px;
}
.spec-item:last-child { border-right: none; }
.spec-label {
font-family: 'DM Mono', monospace;
font-size: 8px;
color: #222;
letter-spacing: 1px;
text-transform: uppercase;
margin-bottom: 2px;
}
.spec-val {
font-family: 'DM Mono', monospace;
font-size: 10px;
color: #333;
}
.spec-val.green { color: #5a8a00; }
</style>
</head>
<body>
<header>
<div class="logo">
<span class="logo-text">LOCAL MIND</span>
<span class="logo-badge">OFFLINE</span>
</div>
<div class="header-right">
<div id="cache-indicator"><span class="dot"></span> Checking cache...</div>
<div id="storage-info"></div>
</div>
</header>
<div class="status-bar">
<span id="status">Ready β€” click Load Model to initialize</span>
<div id="progress">
<div class="progress-track"><div id="progress-bar"></div></div>
<span id="progress-text"></span>
</div>
<div class="model-tag">Qwen3-0.6B Β· q4f16_1 Β· MLC</div>
<button id="load-btn">Load Model</button>
</div>
<div id="chat-container">
<div id="welcome">
<div class="welcome-title">YOUR BRAIN<br>YOUR DEVICE</div>
<div class="welcome-features">
<div class="wf">
<div class="wf-icon">⚑</div>
<div class="wf-text">WebGPU<br>Accelerated</div>
</div>
<div class="wf">
<div class="wf-icon">πŸ’Ύ</div>
<div class="wf-text">One-Time<br>Download</div>
</div>
<div class="wf">
<div class="wf-icon">πŸ”’</div>
<div class="wf-text">Zero Data<br>Leaves Browser</div>
</div>
<div class="wf">
<div class="wf-icon">♾️</div>
<div class="wf-text">Cached<br>Forever</div>
</div>
</div>
</div>
</div>
<div class="input-area">
<textarea
id="user-input"
placeholder="Load model first..."
disabled
rows="1"
oninput="this.style.height='auto';this.style.height=Math.min(this.scrollHeight,120)+'px'"
></textarea>
<button id="clear-btn">Clear</button>
<button id="send-btn" disabled>Send</button>
</div>
<div class="specs">
<div class="spec-item">
<div class="spec-label">Inference</div>
<div class="spec-val green">WebGPU</div>
</div>
<div class="spec-item">
<div class="spec-label">Cache</div>
<div class="spec-val green">Browser Cache API</div>
</div>
<div class="spec-item">
<div class="spec-label">Privacy</div>
<div class="spec-val green">100% Local</div>
</div>
<div class="spec-item">
<div class="spec-label">Download</div>
<div class="spec-val">One-Time ~600MB</div>
</div>
<div class="spec-item">
<div class="spec-label">Runtime</div>
<div class="spec-val">MLC / WebLLM</div>
</div>
<div class="spec-item">
<div class="spec-label">Build</div>
<div class="spec-val">Zero β€” Open in Browser</div>
</div>
</div>
</body>
</html>