| <!doctype html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8"/> |
| <meta name="viewport" content="width=device-width, initial-scale=1"/> |
| <title>Local Chat with Transformers.js</title> |
| <style> |
| body{margin:0;font-family:system-ui,sans-serif;background:#0f1115;color:#e8eaf0;display:grid;grid-template-rows:auto 1fr auto;height:100vh;} |
| header,footer{background:#171a21;padding:8px 12px;border-bottom:1px solid #2a2f39;} |
| header{display:flex;gap:8px;align-items:center;flex-wrap:wrap;} |
| select,button,textarea,input{background:#11141a;color:#e8eaf0;border:1px solid #2a2f39;border-radius:6px;padding:6px 8px;font:inherit;} |
| button{cursor:pointer;} |
| main{overflow:auto;padding:10px;} |
| .chat{display:flex;flex-direction:column;gap:10px;max-width:900px;margin:auto;} |
| .msg{display:flex;} |
| .msg.user{justify-content:flex-end;} |
| .bubble{padding:8px 10px;border-radius:8px;max-width:80%;white-space:pre-wrap;word-break:break-word;} |
| .msg.user .bubble{background:#1b2433;} |
| .msg.assistant .bubble{background:#141923;} |
| .toast{position:fixed;bottom:12px;left:50%;transform:translateX(-50%);background:#11141a;padding:6px 10px;border:1px solid #2a2f39;border-radius:6px;opacity:0;transition:opacity .2s;} |
| .toast.show{opacity:1;} |
| .modal-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.5);display:grid;place-items:center;} |
| .modal{background:#171a21;padding:16px;border-radius:8px;width:min(400px,90%);} |
| </style> |
| </head> |
| <body> |
| <header> |
| <label for="model">Model:</label> |
| <select id="model"> |
| <option value="Xenova/distilGPT2">Xenova/distilGPT2</option> |
| <option value="Xenova/TinyLlama-1.1B-Chat-v1.0">Xenova/TinyLlama-1.1B-Chat-v1.0</option> |
| <option value="Xenova/Mistral-7B-Instruct-v0.2">Xenova/Mistral-7B-Instruct-v0.2</option> |
| </select> |
| <button id="set-token">Set token</button> |
| <span id="status">Idle</span> |
| </header> |
| <main><div id="chat" class="chat"></div></main> |
| <footer> |
| <textarea id="input" placeholder="Type here…" rows="2" style="flex:1;"></textarea> |
| <button id="send">Send</button> |
| </footer> |
|
|
| <div id="toast" class="toast"></div> |
|
|
| <div id="token-modal" class="modal-backdrop" hidden> |
| <div class="modal"> |
| <h3>Enter HF token</h3> |
| <input id="token-input" type="password" placeholder="hf_xxx" style="width:100%"/> |
| <div style="margin-top:8px;text-align:right;"> |
| <button id="token-cancel">Cancel</button> |
| <button id="token-save">Save</button> |
| </div> |
| </div> |
| </div> |
|
|
| <script src="https://cdn.jsdelivr.net/npm/@xenova/transformers"></script> |
| <script> |
| const { pipeline, env } = window.transformers; |
| const chatEl = document.getElementById('chat'); |
| const inputEl = document.getElementById('input'); |
| const sendBtn = document.getElementById('send'); |
| const modelSel = document.getElementById('model'); |
| const statusEl = document.getElementById('status'); |
| const toastEl = document.getElementById('toast'); |
| const tokenModal = document.getElementById('token-modal'); |
| const tokenInput = document.getElementById('token-input'); |
| |
| let state = { pipe:null, modelId:null, task:'text-generation' }; |
| const savedToken = localStorage.getItem('hf_token'); if(savedToken){env.HF_TOKEN=savedToken;} |
| |
| function addMessage(role,text){ |
| const row=document.createElement('div'); |
| row.className='msg '+role; |
| const bub=document.createElement('div'); |
| bub.className='bubble'; |
| bub.textContent=text; |
| row.appendChild(bub); |
| chatEl.appendChild(row); |
| chatEl.scrollTop=chatEl.scrollHeight; |
| return bub; |
| } |
| function showToast(msg){ |
| toastEl.textContent=msg; |
| toastEl.classList.add('show'); |
| setTimeout(()=>toastEl.classList.remove('show'),2000); |
| } |
| function showTokenModal(){ |
| tokenModal.hidden=false; |
| tokenInput.value=localStorage.getItem('hf_token')||''; |
| } |
| function hideTokenModal(){ tokenModal.hidden=true; } |
| function setToken(tok){ |
| if(tok){ env.HF_TOKEN=tok; localStorage.setItem('hf_token',tok);} |
| else{ delete env.HF_TOKEN; localStorage.removeItem('hf_token');} |
| } |
| function isUnauthorizedError(err){ |
| return (err && (err.message||String(err))).includes('Unauthorized access to file'); |
| } |
| async function withAuthRetry(fn){ |
| try { |
| return await fn(); |
| } catch(e){ |
| if(isUnauthorizedError(e)){ |
| return new Promise((resolve,reject)=>{ |
| showTokenModal(); |
| tokenModal.querySelector('#token-save').onclick=()=>{ |
| const val=tokenInput.value.trim(); |
| hideTokenModal(); |
| if(val){ setToken(val); fn().then(resolve).catch(reject);} |
| else{ reject(e);} |
| }; |
| tokenModal.querySelector('#token-cancel').onclick=()=>{ |
| hideTokenModal(); |
| reject(e); |
| }; |
| }); |
| } |
| throw e; |
| } |
| } |
| async function ensurePipeline(modelId,task){ |
| if(state.pipe && state.modelId===modelId && state.task===task) return state.pipe; |
| statusEl.textContent='Loading model…'; |
| const pipe=await withAuthRetry(()=>pipeline(task,modelId,{device:'webgpu'})); |
| state.pipe=pipe; state.modelId=modelId; state.task=task; |
| statusEl.textContent='Ready'; |
| return pipe; |
| } |
| function isAsyncIterable(obj){ return obj && typeof obj[Symbol.asyncIterator]==='function'; } |
| async function generate(text,bubble){ |
| const modelId=modelSel.value; |
| await ensurePipeline(modelId,'text-generation'); |
| const opts={max_new_tokens:128,temperature:0.7,top_p:0.9,repetition_penalty:1.1}; |
| let streamObj; try{ streamObj=state.pipe(text,{...opts,stream:true}); }catch{} |
| if(isAsyncIterable(streamObj)){ |
| let full=''; try{ |
| for await (const out of streamObj){ |
| const t=(out.token && out.token.text)||out.text||''; full+=t; bubble.textContent=full; |
| } |
| return; |
| }catch(e){ |
| if(isUnauthorizedError(e)){ |
| await withAuthRetry(()=>generate(text,bubble)); return; |
| } |
| throw e; |
| } |
| } |
| const out=await withAuthRetry(()=>state.pipe(text,{...opts,stream:false})); |
| const textOut=Array.isArray(out)?(out[0]?.generated_text||out[0]?.text): (out.generated_text||out.text)||''; |
| bubble.textContent=textOut; |
| } |
| async function onSend(){ |
| const val=inputEl.value.trim(); if(!val) return; |
| inputEl.value=''; addMessage('user',val); const bub=addMessage('assistant','…'); |
| try { await generate(val,bub); } |
| catch(e){ bub.textContent='Error: '+(e.message||e); } |
| } |
| sendBtn.onclick=onSend; |
| inputEl.onkeydown=e=>{ if(e.key==='Enter'&&!e.shiftKey){ e.preventDefault(); onSend(); } }; |
| document.getElementById('set-token').onclick=()=>{ |
| showTokenModal(); |
| tokenModal.querySelector('#token-save').onclick=()=>{ |
| const val=tokenInput.value.trim(); |
| hideTokenModal(); setToken(val); showToast(val?'Token saved':'Token cleared'); |
| }; |
| tokenModal.querySelector('#token-cancel').onclick=()=>hideTokenModal(); |
| }; |
| |
| addMessage('assistant','Hello! Fully local via Transformers.js. Choose model and send a message.'); |
| </script> |
| </body> |
| </html> |
|
|