|
<!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> |
|
|