File size: 6,781 Bytes
f85c67a
 
 
9bc9167
 
 
 
cc7da9d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9bc9167
f85c67a
 
9bc9167
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6c612c2
f85c67a
9bc9167
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cc7da9d
9bc9167
 
 
 
 
 
 
 
 
 
 
 
 
cc7da9d
9bc9167
 
 
 
 
 
 
 
 
6c612c2
 
9bc9167
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cc7da9d
 
 
 
 
 
 
 
 
 
 
9bc9167
 
 
 
 
 
 
cc7da9d
9bc9167
 
 
 
 
cc7da9d
 
 
 
 
 
 
 
9bc9167
 
 
f85c67a
9bc9167
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
<!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>