import { pipeline } from 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2'; const MODEL_ID = 'Xenova/tinyllama-1.1b-chat-v1.0'; const inputEl = document.getElementById('input'); const chatEl = document.getElementById('chat'); const executeBtn = document.getElementById('execute'); const statusEl = document.getElementById('status'); const chatListEl = document.getElementById('chat-list'); const newChatBtn = document.getElementById('new-chat-btn'); const chatTitleEl = document.getElementById('chat-title'); const sidebar = document.getElementById('sidebar'); const sidebarOverlay = document.getElementById('sidebar-overlay'); const menuBtn = document.getElementById('menu-btn'); let generator = null; let isLoading = false; let currentChatId = null; let chats = JSON.parse(localStorage.getItem('bob_chats') || '{}'); const customTools = { get_crypto_price: async (symbol) => { try { const response = await fetch(`https://api.coingecko.com/api/v3/simple/price?ids=${symbol.toLowerCase()}&vs_currencies=usd`); const data = await response.json(); if (data[symbol.toLowerCase()]) return `$${data[symbol.toLowerCase()].usd.toLocaleString()}`; return `Price unavailable for ${symbol}`; } catch { return `Error fetching price for ${symbol}`; } }, multiply_numbers: (a, b) => a * b }; function saveChats() { localStorage.setItem('bob_chats', JSON.stringify(chats)); } function createChat() { const id = Date.now().toString(); chats[id] = { id, title: 'New Chat', messages: [], created: Date.now(), updated: Date.now() }; saveChats(); return id; } function deleteChat(id) { delete chats[id]; saveChats(); renderChatList(); } function switchChat(id) { currentChatId = id; renderChatList(); renderChat(); sidebar.classList.remove('open'); sidebarOverlay.classList.remove('open'); } function updateChatTitle(id, title) { if (chats[id]) { chats[id].title = title; chats[id].updated = Date.now(); saveChats(); renderChatList(); } } function renderChatList() { const sorted = Object.values(chats).sort((a, b) => b.updated - a.updated); if (sorted.length === 0) { chatListEl.innerHTML = '
No chats yet.
Click "NEW CHAT" to start.
'; return; } chatListEl.innerHTML = sorted.map(c => `
${escapeHtml(c.title)}
${escapeHtml(c.messages[0]?.content?.slice(0, 40) || '')}
${new Date(c.updated).toLocaleString()}
`).join(''); chatListEl.querySelectorAll('.chat-item').forEach(el => { el.addEventListener('click', () => switchChat(el.dataset.id)); }); } function escapeHtml(s) { return s.replace(/[&<>]/g, c => ({"&":"&","<":"<",">":">"}[c])); } function renderChat() { if (!currentChatId || !chats[currentChatId]) { showWelcome(); return; } const chat = chats[currentChatId]; chatTitleEl.textContent = chat.title; chatEl.innerHTML = ''; if (chat.messages.length === 0) { showWelcome(); return; } chat.messages.forEach(msg => appendMessage(msg.role, msg.content, false)); chatEl.scrollTop = chatEl.scrollHeight; } function showWelcome() { chatTitleEl.textContent = currentChatId ? chats[currentChatId]?.title || 'New Chat' : 'Select a chat or start new'; chatEl.innerHTML = `

BOB

Local-first AI running 100% in your browser via WebAssembly.
No servers. No cloud. No tracking.

Ctrl+Enter to send ยท Try "What's Bitcoin price?" or "Multiply 144 by 37"
`; } function appendMessage(role, content, save = true) { if (chatEl.querySelector('.welcome')) chatEl.innerHTML = ''; const wrapper = document.createElement('div'); wrapper.className = `message ${role}`; const label = document.createElement('div'); label.className = 'message-label'; label.textContent = role === 'user' ? 'YOU' : 'BOB'; wrapper.appendChild(label); const contentDiv = document.createElement('div'); contentDiv.className = 'message-content'; contentDiv.textContent = content; wrapper.appendChild(contentDiv); chatEl.appendChild(wrapper); chatEl.scrollTop = chatEl.scrollHeight; if (save && currentChatId && chats[currentChatId]) { chats[currentChatId].messages.push({ role, content, time: Date.now() }); chats[currentChatId].updated = Date.now(); if (chats[currentChatId].messages.length === 1) { chats[currentChatId].title = content.slice(0, 40); } saveChats(); renderChatList(); } } function appendToolResult(toolName, result) { const div = document.createElement('div'); div.className = 'message tool-result'; div.textContent = `[TOOL: ${toolName} => ${result}]`; chatEl.appendChild(div); chatEl.scrollTop = chatEl.scrollHeight; } async function initModel() { if (generator) return generator; isLoading = true; executeBtn.disabled = true; executeBtn.textContent = 'LOADING...'; statusEl.textContent = `Loading ${MODEL_ID}... (first run downloads ~600MB)`; try { generator = await pipeline('text-generation', MODEL_ID, { dtype: 'q4', progress_callback: (progress) => { if (progress.status === 'downloading') { const mb = (progress.loaded / 1024 / 1024).toFixed(1); const totalMb = (progress.total / 1024 / 1024).toFixed(1); statusEl.textContent = `Downloading: ${mb} / ${totalMb} MB`; } else if (progress.status === 'progress') { statusEl.textContent = `Initializing: ${Math.round(progress.progress * 100)}%`; } } }); statusEl.textContent = 'Ready. Model loaded locally.'; } catch (err) { statusEl.textContent = `Error: ${err.message}`; console.error(err); } finally { isLoading = false; executeBtn.disabled = false; executeBtn.textContent = 'SEND'; } return generator; } function formatPrompt(userInput, history = []) { let prompt = `<|begin_of_text|><|start_header_id|>system<|end_header_id|> You are Bob, a local AI assistant running in the browser. You have access to tools. When you need to use a tool, respond with EXACTLY this format: function_name({"arg": "value"}) Available tools: - get_crypto_price: Get crypto price. Args: {"symbol": "BTC"} - multiply_numbers: Multiply two numbers. Args: {"a": 5, "b": 10} Only call tools when needed. Respond normally for regular questions. <|eot_id|>`; for (const msg of history.slice(-6)) { prompt += `<|start_header_id|>${msg.role}<|end_header_id|>\n${msg.content}<|eot_id|>`; } prompt += `<|start_header_id|>user<|end_header_id|>\n${userInput}<|eot_id|>\n<|start_header_id|>assistant<|end_header_id|>\n`; return prompt; } async function executeToolCalls(text) { const toolCallRegex = /(\w+)\((\{.*?\})\)<\/call_tool>/g; let result = text; let match; while ((match = toolCallRegex.exec(text)) !== null) { const funcName = match[1]; let args; try { args = JSON.parse(match[2]); } catch { continue; } if (customTools[funcName]) { try { const toolResult = await customTools[funcName](...Object.values(args)); result = result.replace(match[0], `[TOOL RESULT: ${funcName} => ${toolResult}]`); appendToolResult(funcName, toolResult); } catch (err) { result = result.replace(match[0], `[TOOL ERROR: ${err.message}]`); } } } return result; } async function runInference(prompt, history) { if (!generator) await initModel(); if (!generator) return 'Model not loaded.'; const formatted = formatPrompt(prompt, history); statusEl.textContent = 'Processing locally...'; try { const output = await generator(formatted, { max_new_tokens: 256, temperature: 0.1, top_p: 0.9, do_sample: true, return_full_text: false, repetition_penalty: 1.2 }); let generated = output[0].generated_text.trim(); generated = generated .replace(/^.*?<\|assistant\|>\s*/s, '') .replace(/^.*?<\|user\|>\s*/s, '') .replace(/^.*?<\|system\|>\s*/s, '') .replace(/^.*?You are Bob.*?\n/s, '') .replace(/Available tools:[\s\S]*?Only call tools[\s\S]*?\n/, '') .trim(); const toolCallRegex = /(\w+)\((\{.*?\})\)<\/call_tool>/g; let match; while ((match = toolCallRegex.exec(generated)) !== null) { const funcName = match[1]; let args; try { args = JSON.parse(match[2]); } catch { continue; } if (customTools[funcName]) { try { const toolResult = await customTools[funcName](...Object.values(args)); generated = generated.replace(match[0], `[TOOL RESULT: ${funcName} => ${toolResult}]`); } catch (err) { generated = generated.replace(match[0], `[TOOL ERROR: ${err.message}]`); } } } return generated.trim(); } catch (err) { console.error(err); return `Error: ${err.message}`; } } async function handleSend() { console.log('handleSend called, isLoading:', isLoading); if (isLoading) { console.log('Model still loading'); return; } const prompt = inputEl.value.trim(); console.log('Prompt:', prompt); if (!prompt) return; if (!currentChatId) currentChatId = createChat(); appendMessage('user', prompt); inputEl.value = ''; executeBtn.disabled = true; executeBtn.textContent = 'THINKING...'; const history = chats[currentChatId]?.messages.slice(-6) || []; console.log('History:', history); const result = await runInference(prompt, history); console.log('Result:', result); appendMessage('assistant', result); executeBtn.disabled = false; executeBtn.textContent = 'SEND'; statusEl.textContent = 'Ready.'; } executeBtn.addEventListener('click', handleSend); inputEl.addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleSend(); }); newChatBtn.addEventListener('click', () => { currentChatId = createChat(); switchChat(currentChatId); inputEl.focus(); }); menuBtn.addEventListener('click', () => { sidebar.classList.toggle('open'); sidebarOverlay.classList.toggle('open'); }); sidebarOverlay.addEventListener('click', () => { sidebar.classList.remove('open'); sidebarOverlay.classList.remove('open'); }); renderChatList(); if (Object.keys(chats).length > 0) { const latest = Object.values(chats).sort((a,b)=>b.updated-a.updated)[0]; switchChat(latest.id); } initModel();