Update index.html

This commit is contained in:
2025-08-22 23:28:51 -07:00
committed by GitHub
parent 5bd104eb56
commit 17596794c8

View File

@@ -133,7 +133,9 @@ let sunes=(su.load()||[]).map(makeSune)
if(!sunes.length){const def=makeSune({name:'Default'});sunes=[def];su.save(sunes);su.setActiveId(def.id)}
const getActiveSune=()=>sunes.find(a=>a.id===su.getActiveId())||sunes[0],createDefaultSune=()=>makeSune({name:'Default'})
const store=new Proxy({},{get(_,p){if(p==='apiKey')return globalStore.apiKey;const a=getActiveSune();if(p==='model')return a.settings.model;if(p in a.settings)return a.settings[p];if(p==='system_prompt')return a.settings.system_prompt},set(_,p,v){if(p==='apiKey'){globalStore.apiKey=v;return true}const i=sunes.findIndex(a=>a.id===getActiveSune().id);if(i>=0){if(p==='model')sunes[i].settings.model=v||DEFAULT_MODEL;else if(p==='system_prompt')sunes[i].settings.system_prompt=v||'';else sunes[i].settings[p]=v;sunes[i].updatedAt=Date.now();su.save(sunes);return true}return false}})
const state={messages:[],busy:false,controller:null,currentThreadId:null,abortRequested:false,attachments:[]}
const state={messages:[],busy:false,controller:null,currentThreadId:null,abortRequested:false,attachments:[],swId:null}
/* WARNING: SW STREAM BRIDGE — purpose: route chat streaming through the PWA Service Worker so streams survive tab background/suspension and can resume. Contract with sw.js: postMessage RPC types 'hello','stream-openrouter','stream-cancel','stream-replay' and BroadcastChannel 'llm-stream' messages {id,delta,done,error,off}. Do not rename/change these or resume will break. Falls back to direct fetch if SW unavailable. */
const SW={ready:false,supports:false,ch:null,streams:new Map(),id:()=>Math.random().toString(36).slice(2),rpc:(type,data)=>new Promise((res,rej)=>{const mc=new MessageChannel();mc.port1.onmessage=e=>{const d=e.data||{};d&&d.error?rej(new Error(d.error)):res(d)};navigator.serviceWorker.controller?.postMessage({type,data},[mc.port2])}),init:async()=>{if(!('serviceWorker'in navigator))return;try{await navigator.serviceWorker.ready;SW.ready=true;SW.ch=new BroadcastChannel('llm-stream');SW.ch.onmessage=e=>{const m=e.data||{},h=SW.streams.get(m.id);if(!h)return;if(m.error){h.reject(new Error(m.error));SW.streams.delete(m.id);return}if(m.delta!=null){h.off=m.off??((h.off||0)+m.delta.length);h.onDelta(m.delta,!!m.done)}if(m.done){h.resolve('');SW.streams.delete(m.id)}};const hello=await SW.rpc('hello',{v:1,features:['stream']}).catch(()=>null);SW.supports=!!hello&&!!hello.stream}catch{}},start:async(req,onDelta)=>{const id=SW.id();const h={onDelta,off:0,resolve:()=>{},reject:()=>{}};const p=new Promise((res,rej)=>{h.resolve=res;h.reject=rej});SW.streams.set(id,h);await SW.rpc('stream-openrouter',{id,req});return{id,p,stop:()=>SW.rpc('stream-cancel',{id}).catch(()=>{})}}
const getModelShort=m=>{const mm=m||store.model||'';return mm.includes('/')?mm.split('/').pop():mm}
const renderSuneHTML=()=>{const m=el.suneHtml,h=(getActiveSune().settings.html||'').trim();m.innerHTML='';m.classList.toggle('hidden',!h);if(!h)return;m.insertAdjacentHTML('afterbegin',h);m.querySelectorAll('script').forEach(s=>{const n=document.createElement('script');[...s.attributes].forEach(a=>n.setAttribute(a.name,a.value));n.text=s.text; s.replaceWith(n)})}
const reflectActiveSune=()=>{const a=getActiveSune();el.settingsBtnTop.title=`Settings — ${a.name}`;el.settingsBtnTop.innerHTML=a.avatar?`<img src="${esc(a.avatar)}" alt="" class="h-8 w-8 rounded-full object-cover"/>`:'✺';icons();renderSuneHTML()}
@@ -150,7 +152,8 @@ const clearChat=()=>{state.messages=[];el.messages.innerHTML='';state.attachment
const payloadWithSampling=b=>Object.assign({},b,{temperature:store.temperature,top_p:store.top_p,top_k:store.top_k,frequency_penalty:store.frequency_penalty,presence_penalty:store.presence_penalty,repetition_penalty:store.repetition_penalty,min_p:store.min_p,top_a:store.top_a})
function setBtnStop(){const b=el.sendBtn;b.dataset.mode='stop';b.type='button';b.setAttribute('aria-label','Stop');b.innerHTML='<i data-lucide="square" class="h-5 w-5"></i>';icons();b.onclick=()=>{state.abortRequested=true;state.controller?.abort?.()}}
function setBtnSend(){const b=el.sendBtn;b.dataset.mode='send';b.type='submit';b.setAttribute('aria-label','Send');b.innerHTML='<i data-lucide="sparkles" class="h-5 w-5"></i>';icons();b.onclick=null}
async function askOpenRouterStreaming(onDelta){const apiKey=store.apiKey,model=store.model;if(!apiKey){const text=localDemoReply(state.messages[state.messages.length-1]?.content||'');onDelta(text,true);return {ok:true,text}}try{state.controller=new AbortController();const msgs=[];if(store.system_prompt)msgs.push({role:'system',content:store.system_prompt});msgs.push(...state.messages.filter(m=>m.role!=='system').map(m=>({role:m.role,content:m.contentParts||m.content})));let body=payloadWithSampling({model,messages:msgs,stream:true});const re=store.reasoning_effort; if(re&&re!=='default')body.reasoning={effort:re};const res=await fetch('https://openrouter.ai/api/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+apiKey},body:JSON.stringify(body),signal:state.controller.signal});if(!res.ok){const errText=await res.text().catch(()=> '');throw new Error(errText||('HTTP '+res.status))}const reader=res.body.getReader(),decoder=new TextDecoder('utf-8');let buffer='',full='',finished=false;const doneOnce=()=>{if(finished)return;finished=true;onDelta('',true)};while(true){const {value,done}=await reader.read();if(done)break;buffer+=decoder.decode(value,{stream:true});let idx;while((idx=buffer.indexOf('\n\n'))!==-1){const chunk=buffer.slice(0,idx).trim();buffer=buffer.slice(idx+2);if(!chunk)continue;if(chunk.startsWith('data:')){const data=chunk.slice(5).trim();if(data==='[DONE]'){doneOnce();continue}try{const json=JSON.parse(data);const delta=json.choices?.[0]?.delta?.content??'';if(delta){full+=delta;onDelta(delta,false)}const finish=json.choices?.[0]?.finish_reason;if(finish)doneOnce()}catch{}}}}doneOnce();return {ok:true,text:full}}catch(e){const msg=String(e?.message||e),aborted=e?.name==='AbortError'||/abort/i.test(msg)||state.controller?.signal?.aborted||state.abortRequested;if(aborted){onDelta('',true);return {ok:false,text:'',aborted:true}}let hint='Request failed.';if(/401|unauthorized/i.test(msg))hint='Unauthorized (check API key).';else if(/429|rate/i.test(msg))hint='Rate limited (slow down or upgrade).';else if(/access|forbidden|403/i.test(msg))hint='Forbidden (model or key scope).';const fallback='\n\n'+hint;onDelta(fallback,true);return {ok:false,text:fallback}}finally{state.controller=null;state.abortRequested=false}}
async function askOpenRouterFetch(onDelta,req){try{state.controller=new AbortController();const res=await fetch(req.url,{method:req.method,headers:req.headers,body:JSON.stringify(req.body),signal:state.controller.signal});if(!res.ok){const errText=await res.text().catch(()=> '');throw new Error(errText||('HTTP '+res.status))}const reader=res.body.getReader(),decoder=new TextDecoder('utf-8');let buffer='',full='',finished=false;const doneOnce=()=>{if(finished)return;finished=true;onDelta('',true)};while(true){const {value,done}=await reader.read();if(done)break;buffer+=decoder.decode(value,{stream:true});let idx;while((idx=buffer.indexOf('\n\n'))!==-1){const chunk=buffer.slice(0,idx).trim();buffer=buffer.slice(idx+2);if(!chunk)continue;if(chunk.startsWith('data:')){const data=chunk.slice(5).trim();if(data==='[DONE]'){doneOnce();continue}try{const json=JSON.parse(data);const delta=json.choices?.[0]?.delta?.content??'';if(delta){full+=delta;onDelta(delta,false)}const finish=json.choices?.[0]?.finish_reason;if(finish)doneOnce()}catch{}}}}doneOnce();return {ok:true,text:full}}catch(e){const msg=String(e?.message||e),aborted=e?.name==='AbortError'||/abort/i.test(msg)||state.controller?.signal?.aborted||state.abortRequested;if(aborted){onDelta('',true);return {ok:false,text:'',aborted:true}}let hint='Request failed.';if(/401|unauthorized/i.test(msg))hint='Unauthorized (check API key).';else if(/429|rate/i.test(msg))hint='Rate limited (slow down or upgrade).';else if(/access|forbidden|403/i.test(msg))hint='Forbidden (model or key scope).';const fallback='\n\n'+hint;onDelta(fallback,true);return {ok:false,text:fallback}}finally{state.controller=null;state.abortRequested=false}}
async function askOpenRouterStreaming(onDelta){const apiKey=store.apiKey,model=store.model;if(!apiKey){const text=localDemoReply(state.messages[state.messages.length-1]?.content||'');onDelta(text,true);return {ok:true,text}}const msgs=[];if(store.system_prompt)msgs.push({role:'system',content:store.system_prompt});msgs.push(...state.messages.filter(m=>m.role!=='system').map(m=>({role:m.role,content:m.contentParts||m.content})));let body=payloadWithSampling({model,messages:msgs,stream:true});const re=store.reasoning_effort;if(re&&re!=='default')body.reasoning={effort:re};const req={url:'https://openrouter.ai/api/v1/chat/completions',method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+apiKey},body};if(SW.ready&&SW.supports){try{const run=await SW.start(req,(delta,done)=>onDelta(delta,done));state.swId=run.id;state.controller={abort(){state.abortRequested=true;run.stop()}};const text=await run.p;onDelta('',true);return {ok:true,text}}catch{}finally{state.controller=null;state.abortRequested=false;state.swId=null}}return await askOpenRouterFetch(onDelta,req)}
function localDemoReply(){return 'Tip: open the sidebar → Account & Backup to set your OpenRouter API key.'}
let threads=[];const titleFrom=t=>(t||'').replace(/\s+/g,' ').trim().slice(0,60)||'Untitled'
const TKEY='threads_v1',tload=()=>localforage.getItem(TKEY).then(v=>Array.isArray(v)?v:[]),tsave=v=>localforage.setItem(TKEY,v)
@@ -213,7 +216,8 @@ function kbUpdate(){const vv=window.visualViewport;const overlap=vv?Math.max(0,(
function kbBind(){if(window.visualViewport){['resize','scroll'].forEach(ev=>visualViewport.addEventListener(ev,()=>kbUpdate(),{passive:true}))}['resize','orientationchange'].forEach(ev=>window.addEventListener(ev,()=>setTimeout(kbUpdate,50),{passive:true}));['focus','click'].forEach(ev=>el.input.addEventListener(ev,()=>{setTimeout(()=>{kbUpdate();el.input.scrollIntoView({block:'nearest',behavior:'smooth'})},0)}))}
function activeMeta(){const a=getActiveSune();return {sune_name:a.name,model:store.model,avatar:a.avatar||''}}
window.suneAttach=async(files,opts={toAPI:true,tree:true})=>{const arr=[];for(const f of files||[])arr.push(await toAttach(f));const clean=arr.filter(Boolean);if(!clean.length)return;const meta=activeMeta();if(opts.toAPI){const m={role:'assistant',content:'(files attached)',contentParts:clean.map(a=>a.part),...meta};addMessage(m);state.messages.push(m)} if(opts.tree)addAttachmentTree('assistant',clean);await persistThread()}
async function init(){threads=await tload();await renderHistory();renderSidebar();reflectActiveSune();clearChat();icons();kbBind();kbUpdate()}
async function init(){await SW.init();threads=await tload();await renderHistory();renderSidebar();reflectActiveSune();clearChat();icons();kbBind();kbUpdate()}
document.addEventListener('visibilitychange',()=>{if(document.visibilityState==='visible'&&state.swId){const h=SW.streams.get(state.swId);SW.rpc('stream-replay',{id:state.swId,from:h?.off||0}).catch(()=>{})}})
window.addEventListener('resize',()=>{hideHistoryMenu();hideSuneMenu()})
init()
</script>