| <!DOCTYPE html> |
| <html lang="en"> |
|
|
| <head> |
| <meta charset="UTF-8" /> |
| <title>On-Device AI Chat</title> |
| <style> |
| body { |
| margin: 0; |
| font-family: sans-serif; |
| background: #111; |
| color: #eee; |
| display: flex; |
| flex-direction: column; |
| height: 100vh; |
| } |
| |
| header { |
| padding: 10px; |
| background: #222; |
| display: flex; |
| gap: 10px; |
| align-items: center; |
| } |
| |
| #chat { |
| flex: 1; |
| overflow-y: auto; |
| padding: 10px; |
| display: flex; |
| flex-direction: column; |
| gap: 8px; |
| } |
| |
| .msg { |
| display: flex; |
| gap: 8px; |
| align-items: flex-start; |
| } |
| |
| .avatar { |
| width: 24px; |
| height: 24px; |
| border-radius: 50%; |
| background: #444; |
| color: #ccc; |
| font-size: 12px; |
| font-weight: bold; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| } |
| |
| .bubble { |
| background: #333; |
| padding: 8px 10px; |
| border-radius: 8px; |
| max-width: 80%; |
| white-space: pre-wrap; |
| } |
| |
| .user .bubble { |
| background: #2a2a2a; |
| } |
| |
| .error .bubble { |
| background: #4a1a1a; |
| color: #f88; |
| } |
| |
| form { |
| padding: 10px; |
| background: #222; |
| display: flex; |
| gap: 10px; |
| } |
| |
| textarea { |
| flex: 1; |
| padding: 8px; |
| border-radius: 6px; |
| border: none; |
| background: #1a1a1a; |
| color: #eee; |
| } |
| |
| button { |
| padding: 6px 10px; |
| background: #444; |
| color: #eee; |
| border: none; |
| border-radius: 6px; |
| } |
| </style> |
| </head> |
|
|
| <body> |
| <header> |
| <strong>Model:</strong> |
| <select id="model"> |
| <option value="Xenova/distilgpt2">distilgpt2</option> |
| <option value="Xenova/phi-3-mini-4k-instruct">phi-3-mini</option> |
| <option value="Xenova/t5-small">t5-small</option> |
| </select> |
| <span id="status">Idle</span> |
| </header> |
|
|
| <div id="chat" aria-live="polite" aria-busy="false"></div> |
|
|
| <form id="composer"> |
| <textarea id="input" placeholder="Say something…" rows="2"></textarea> |
| <button id="send" type="submit">Send</button> |
| </form> |
|
|
| <script type="module"> |
| (async function () { |
| const elChat = document.getElementById('chat'); |
| const elInput = document.getElementById('input'); |
| const elSend = document.getElementById('send'); |
| const elForm = document.getElementById('composer'); |
| const elModel = document.getElementById('model'); |
| const elStatus = document.getElementById('status'); |
| |
| let pipeline, AutoTokenizer; |
| try { |
| const lib = await import('https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.7.1'); |
| pipeline = lib.pipeline; |
| AutoTokenizer = lib.AutoTokenizer; |
| } catch (err) { |
| const div = document.createElement('div'); |
| div.className = 'msg error'; |
| div.innerHTML = `<div class="avatar">!</div><div class="bubble">❌ Failed to load Transformers.js: ${err.message}</div>`; |
| elChat.appendChild(div); |
| console.error('Transformers.js load error:', err); |
| return; |
| } |
| |
| const state = { |
| busy: false, |
| modelId: elModel.value, |
| history: [], |
| resources: new Map(), |
| }; |
| |
| const setBusy = (busy, label = '') => { |
| state.busy = busy; |
| [elInput, elSend, elModel].forEach(el => el.disabled = busy); |
| elChat.setAttribute('aria-busy', String(busy)); |
| elStatus.textContent = busy ? label : 'Idle'; |
| }; |
| |
| const addMessage = (role, text, variant = '') => { |
| const msg = document.createElement('div'); |
| msg.className = `msg ${role} ${variant}`; |
| const avatar = document.createElement('div'); |
| avatar.className = 'avatar'; |
| avatar.textContent = role === 'user' ? 'U' : (variant === 'error' ? '!' : 'AI'); |
| const bubble = document.createElement('div'); |
| bubble.className = 'bubble'; |
| bubble.textContent = text; |
| msg.append(avatar, bubble); |
| elChat.appendChild(msg); |
| elChat.scrollTop = elChat.scrollHeight; |
| return bubble; |
| }; |
| |
| const pushError = (err, context = 'Error') => { |
| const msg = `${context}: ${err?.message || err}`; |
| console.error(context, err); |
| addMessage('assistant', msg, 'error'); |
| state.history.push({ role: 'assistant', content: msg }); |
| elStatus.textContent = 'Error'; |
| }; |
| |
| const ensureModelLoaded = async (modelId) => { |
| if (state.resources.has(modelId)) return state.resources.get(modelId); |
| setBusy(true, 'Loading…'); |
| const task = modelId.includes('t5') ? 'text2text-generation' : 'text-generation'; |
| const pipe = await pipeline(task, modelId); |
| const tokenizer = await AutoTokenizer.from_pretrained(modelId).catch(() => null); |
| const res = { task, pipe, tokenizer }; |
| state.resources.set(modelId, res); |
| setBusy(false); |
| return res; |
| }; |
| |
| const buildPrompt = (tokenizer, task, history) => { |
| const messages = [...history]; |
| if (tokenizer?.apply_chat_template) { |
| try { |
| return tokenizer.apply_chat_template(messages, { add_generation_prompt: true, tokenize: false }); |
| } catch {} |
| } |
| return messages.map(m => `${m.role}: ${m.content}`).join('\n') + '\nassistant:'; |
| }; |
| |
| const generateReply = async (text) => { |
| addMessage('user', text); |
| state.history.push({ role: 'user', content: text }); |
| const botBubble = addMessage('assistant', '…'); |
| |
| try { |
| const { task, pipe, tokenizer } = await ensureModelLoaded(state.modelId); |
| setBusy(true, 'Generating…'); |
| const prompt = buildPrompt(tokenizer, task, state.history); |
| const output = await pipe(prompt, { |
| max_new_tokens: 100, |
| temperature: 0.7, |
| top_p: 0.95, |
| do_sample: true, |
| repetition_penalty: 1.1, |
| return_full_text: false, |
| }); |
| |
| const result = output?.[0] || {}; |
| const textOut = result.generated_text || result.summary_text || result.translation_text || '(no output)'; |
| botBubble.textContent = textOut.trim(); |
| state.history.push({ role: 'assistant', content: textOut.trim() }); |
| } catch (err) { |
| pushError(err, 'Generation failed'); |
| botBubble.textContent = '(error)'; |
| } finally { |
| setBusy(false); |
| } |
| }; |
| |
| elForm.addEventListener('submit', async (e) => { |
| e.preventDefault(); |
| const text = elInput.value.trim(); |
| if (!text || state.busy) return; |
| elInput.value = ''; |
| try { |
| await generateReply(text); |
| } catch (err) { |
| pushError(err, 'Submit error'); |
| } |
| }); |
| |
| elInput.addEventListener('keydown', (e) => { |
| if (e.key === 'Enter' && !e.shiftKey) { |
| e.preventDefault(); |
| elForm.requestSubmit(); |
| } |
| }); |
| |
| elModel.addEventListener('change', async () => { |
| if (state.busy) return; |
| state.modelId = elModel.value; |
| addMessage('assistant', `Switched to model: ${state.modelId}`); |
| try { |
| await ensureModelLoaded(state.modelId); |
| } catch (err) { |
| pushError(err, 'Model load failed'); |
| } |
| }); |
| |
| window.addEventListener('error', e => pushError(e.error || e.message, 'Uncaught error')); |
| window.addEventListener('unhandledrejection', e => pushError(e.reason, 'Unhandled rejection')); |
| |
| addMessage('assistant', 'Hello! Choose a model and say something.'); |
| setTimeout(() => ensureModelLoaded(state.modelId).catch(err => pushError(err, 'Initial load')), 100); |
| })(); |
| </script> |
| </body> |
|
|
| </html> |