localm / attempts /chat-copil2-web.html
mihailik's picture
Moving to a proper app.
811916b
<!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>