mirror of
https://github.com/multipleof4/devsune.git
synced 2026-01-13 16:07:55 +00:00
Update index.html
This commit is contained in:
61
index.html
61
index.html
@@ -26,7 +26,7 @@
|
||||
</div>
|
||||
</header>
|
||||
<main id="chat" class="flex-1 overflow-y-auto no-scrollbar"><section id="suneHtml" class="px-4 py-3 border-b border-gray-200 hidden"></section><div id="messages" class="mx-auto w-full max-w-none px-0 py-4 sm:py-6 space-y-4" hx-on="click: if(event.target.closest('.msg-avatar')){document.getElementById('sidebar').classList.remove('-translate-x-full');document.getElementById('sidebarOverlay').classList.remove('hidden')}"></div><div class="h-24"></div></main>
|
||||
<footer id="footer" class="sticky bottom-0 z-10 bg-gradient-to-t from-white via-white/95 to-white/40 pt-2 pb-[calc(12px+var(--safe-bottom))] border-t border-gray-200">
|
||||
<footer id="footer" class="sticky bottom-0 z-10 bg-gradient-to-t from-white via-white/95 to-white/40 pt-2 pb=[calc(12px+var(--safe-bottom))] pb-[calc(12px+var(--safe-bottom))] border-t border-gray-200">
|
||||
<div class="mx-auto w-full max-w-none px-0">
|
||||
<form id="composer" class="group relative flex items-start gap-2 px-3">
|
||||
<textarea id="input" rows="1" placeholder="Send a message" spellcheck="false" autocapitalize="none" autocomplete="off" autocorrect="off" inputmode="text" enterkeyhint="enter" class="flex-1 resize-none rounded-2xl border-none bg-white px-3 py-2 text-[14px] leading-6 placeholder:text-gray-400 focus:outline-none focus:ring-0 max-h-52 overflow-y-auto min-h-[96px]"></textarea>
|
||||
@@ -46,8 +46,8 @@
|
||||
<div class="p-3 border-t relative">
|
||||
<button id="userMenuBtn" class="w-full flex items-center justify-between px-3 py-2 rounded-xl bg-gray-100 hover:bg-gray-200 active:scale-[.99] transition" hx-on="click:event.stopPropagation();document.getElementById('userMenu').classList.toggle('hidden')"><span class="flex items-center gap-2"><span class="h-6 w-6 rounded-full bg-gray-900 text-white flex items-center justify-center">👤</span><span class="text-sm">Account & Backup</span></span><i data-lucide="chevron-down" class="h-4 w-4"></i></button>
|
||||
<div id="userMenu" class="absolute left-3 right-3 bottom-16 translate-y-2 rounded-xl border border-gray-200 bg-white shadow-lg hidden overflow-hidden">
|
||||
<button id="accountSettingsOption" class="menu-item"><i data-lucide="settings" class="h-4 w-4"></i><span>Settings</span></button>
|
||||
<button id="apiKeyOption" class="menu-item">Enter OpenRouter API key</button>
|
||||
<button id="accountSettingsOption" class="menu-item">Settings</button>
|
||||
<button id="sunesImportOption" class="menu-item">Import sunes (.json)</button>
|
||||
<button id="sunesExportOption" class="menu-item">Export sunes (.json)</button>
|
||||
<button id="threadsImportOption" class="menu-item">Import threads (.json)</button>
|
||||
@@ -58,7 +58,7 @@
|
||||
</aside>
|
||||
<div id="historyOverlay" class="fixed inset-0 z-40 bg-black/20 hidden" hx-on="click:this.classList.add('hidden');document.getElementById('historyPanel').classList.add('translate-x-full')"></div>
|
||||
<aside id="historyPanel" class="fixed inset-y-0 right-0 z-50 w-80 max-w-[90vw] bg-white border-l border-gray-200 shadow-xl transform translate-x-full transition-transform duration-200 ease-out flex flex-col">
|
||||
<div class="p-3 border-b text-sm font-medium flex items-center justify-between"><span>Threads</span><button id="closeHistory" class="p-1 rounded hover:bg-gray-100" aria-label="Close" hx-on="click:document.getElementById('historyOverlay').classList.add('hidden');document.getElementById('historyPanel').classList.add('translate-x-full')"><svg viewBox="0 0 24 24" class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg></button></div>
|
||||
<div class="p-3 border-b text-sm font-medium flex items-center justify-between"><span>Threads</span><button id="closeHistory" class="p-1 rounded hover:bg-gray-100" aria-label="Close" hx-on="click:this.closest('#historyPanel').classList.add('translate-x-full');document.getElementById('historyOverlay').classList.add('hidden')"><svg viewBox="0 0 24 24" class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg></button></div>
|
||||
<div id="historyList" class="flex-1 overflow-y-auto divide-y"></div>
|
||||
</aside>
|
||||
<div id="historyMenu" class="menu-card hidden">
|
||||
@@ -112,26 +112,23 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="accountSettingsModal" class="hidden fixed inset-0 z-50">
|
||||
<div id="accountModal" class="hidden fixed inset-0 z-50">
|
||||
<div class="absolute inset-0 bg-black/30"></div>
|
||||
<div class="absolute inset-x-0 top-12 mx-auto w-full max-w-md px-4">
|
||||
<div class="rounded-2xl bg-white shadow-xl border border-gray-200 overflow-hidden">
|
||||
<div class="px-4 py-3 border-b text-sm font-semibold flex items-center justify-between"><span>Account Settings</span><button id="closeAccountSettings" class="p-1 rounded hover:bg-gray-100" aria-label="Close"><svg viewBox="0 0 24 24" class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg></button></div>
|
||||
<form id="accountSettingsForm" class="p-4 text-sm space-y-4">
|
||||
<div class="px-4 py-3 border-b text-sm font-semibold flex items-center justify-between"><span>App Settings</span><button id="closeAccount" class="p-1 rounded hover:bg-gray-100" aria-label="Close"><svg viewBox="0 0 24 24" class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg></button></div>
|
||||
<form id="accountForm" class="text-sm">
|
||||
<div class="p-4 space-y-4">
|
||||
<div>
|
||||
<div class="font-medium mb-1">Streaming method</div>
|
||||
<p class="text-xs text-gray-500 mb-3">Pick how tokens arrive from OpenRouter.</p>
|
||||
<label class="flex items-start gap-3 p-3 rounded-xl border cursor-pointer">
|
||||
<input id="streamModeDirect" type="radio" name="streamMode" class="mt-1">
|
||||
<span><span class="font-medium">Direct HTTP stream</span><br><span class="text-xs text-gray-500">Connects straight to OpenRouter.</span></span>
|
||||
</label>
|
||||
<label class="flex items-start gap-3 p-3 rounded-xl border cursor-pointer mt-2">
|
||||
<input id="streamModeWS" type="radio" name="streamMode" class="mt-1">
|
||||
<span><span class="font-medium">WebSocket via proxy</span><br><span class="text-xs text-gray-500">Keeps the stream alive if the app is backgrounded.</span></span>
|
||||
</label>
|
||||
<label class="block text-gray-700 font-medium mb-1">Streaming to OpenRouter</label>
|
||||
<select id="set_stream_mode" class="w-full rounded-xl border border-gray-300 px-3 py-2">
|
||||
<option value="direct">Direct HTTPS stream</option>
|
||||
<option value="ws">WebSocket via proxy (keeps stream alive if the app is backgrounded)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2 pt-2 border-t">
|
||||
<button type="button" id="cancelAccountSettings" class="px-3 py-2 rounded-xl border bg-white hover:bg-gray-50">Cancel</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2 px-4 py-3 border-t">
|
||||
<button type="button" id="cancelAccount" class="px-3 py-2 rounded-xl border bg-white hover:bg-gray-50">Cancel</button>
|
||||
<button type="submit" class="px-3 py-2 rounded-xl bg-black text-white hover:bg-black/90">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -144,7 +141,7 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/localforage@1.10.0/dist/localforage.min.js"></script>
|
||||
<script>
|
||||
const DEFAULT_MODEL='openai/gpt-5',DEFAULT_API_KEY=''
|
||||
const el=Object.fromEntries(['chat','messages','composer','input','sendBtn','settingsBtnTop','settingsModal','settingsForm','closeSettings','cancelSettings','tabModel','tabPrompt','tabScript','panelModel','panelPrompt','panelScript','set_model','set_temperature','set_top_p','set_top_k','set_frequency_penalty','set_presence_penalty','set_repetition_penalty','set_min_p','set_top_a','set_reasoning_effort','set_system_prompt','deleteSuneBtn','sidebar','sidebarOverlay','sidebarBtn','suneList','newSuneBtn','userMenuBtn','userMenu','apiKeyOption','sunesImportOption','sunesExportOption','threadsImportOption','threadsExportOption','importInput','historyBtn','historyPanel','historyOverlay','historyList','closeHistory','historyMenu','suneMenu','footer','attachBtn','attachBadge','fileInput','scriptEditor','htmlEditor','subTabHTML','subTabJS','panelHTML','panelJS','suneHtml','accountSettingsOption','accountSettingsModal','accountSettingsForm','closeAccountSettings','cancelAccountSettings','streamModeDirect','streamModeWS'].map(id=>[id,document.getElementById(id)]))
|
||||
const el=Object.fromEntries(['chat','messages','composer','input','sendBtn','settingsBtnTop','settingsModal','settingsForm','closeSettings','cancelSettings','tabModel','tabPrompt','tabScript','panelModel','panelPrompt','panelScript','set_model','set_temperature','set_top_p','set_top_k','set_frequency_penalty','set_presence_penalty','set_repetition_penalty','set_min_p','set_top_a','set_reasoning_effort','set_system_prompt','deleteSuneBtn','sidebar','sidebarOverlay','sidebarBtn','suneList','newSuneBtn','userMenuBtn','userMenu','apiKeyOption','accountSettingsOption','sunesImportOption','sunesExportOption','threadsImportOption','threadsExportOption','importInput','historyBtn','historyPanel','historyOverlay','historyList','closeHistory','historyMenu','suneMenu','footer','attachBtn','attachBadge','fileInput','scriptEditor','htmlEditor','subTabHTML','subTabJS','panelHTML','panelJS','suneHtml','accountModal','accountForm','closeAccount','cancelAccount','set_stream_mode'].map(id=>[id,document.getElementById(id)]))
|
||||
const icons=()=>window.lucide&&lucide.createIcons()
|
||||
const clamp=(v,min,max)=>Math.max(min,Math.min(max,v)),num=(v,d)=>v==null||v===''||isNaN(+v)?d:+v,int=(v,d)=>v==null||v===''||isNaN(parseInt(v))?d:parseInt(v),gid=()=>Math.random().toString(36).slice(2,9),esc=s=>String(s).replace(/[&<>'"`]/g,c=>({"&":"&","<":"<",">":">","\"":""","'":"'","`":"`"}[c]))
|
||||
const fmtSize=b=>{const u=['B','KB','MB','GB','TB'];let i=0,x=b;while(x>=1024&&i<u.length-1){x/=1024;i++}return (x>=10?Math.round(x):Math.round(x*10)/10)+' '+u[i]}
|
||||
@@ -157,7 +154,7 @@ const makeSune=(p={})=>({id:p.id||gid(),name:p.name?.trim()||'Default',pinned:!!
|
||||
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;if(p==='streamMode')return globalStore.streamMode;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}if(p==='streamMode'){globalStore.streamMode=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 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 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)})}
|
||||
@@ -192,13 +189,13 @@ el.historyMenu.addEventListener('click',async e=>{const act=e.target.closest('[d
|
||||
el.suneList.addEventListener('click',e=>{const menuBtn=e.target.closest('[data-sune-menu]');if(menuBtn){e.stopPropagation();showSuneMenu(menuBtn,menuBtn.getAttribute('[data-sune-menu]')?menuBtn.getAttribute('[data-sune-menu]'):menuBtn.getAttribute('data-sune-menu'));return}const btn=e.target.closest('[data-sune-id]');if(!btn)return;const id=btn.getAttribute('data-sune-id');if(id){su.setActiveId(id);renderSidebar();reflectActiveSune();state.currentThreadId=null;clearChat();document.getElementById('sidebar').classList.add('-translate-x-full');document.getElementById('sidebarOverlay').classList.add('hidden')}})
|
||||
el.suneMenu.addEventListener('click',e=>{const act=e.target.closest('[data-action]')?.getAttribute('data-action');if(!act||!menuSuneId)return;const s=sunes.find(x=>x.id===menuSuneId);if(!s)return;if(act==='pin')s.pinned=!s.pinned;else if(act==='rename'){const nv=prompt('Rename sune to:',s.name);if(nv!=null)s.name=nv.trim()}else if(act==='pfp'){const url=prompt('Image URL:',s.avatar||'');if(url!==null)s.avatar=url.trim()}s.updatedAt=Date.now();su.save(sunes);hideSuneMenu();renderSidebar();reflectActiveSune()})
|
||||
function updateAttachBadge(){const n=state.attachments.length;el.attachBadge.textContent=String(n);el.attachBadge.classList.toggle('hidden',n===0)}
|
||||
async function toAttach(file){if(!file)return null;const pick=(name,bytes,mime,data,mode,part)=>({name,bytes,mime,data,mode,part});if(file instanceof File){const name=file.name||'file',mime=(file.type||'application/octet-stream').toLowerCase(),bytes=file.size||0;if(/^image\//.test(mime)||/\.(png|jpe?g|webp|gif)$/i.test(name)){const data=await asDataURL(file);return pick(name,bytes,mime,data,'dataURL',{type:'image_url',image_url:{url:data}})}if(mime==='application/pdf'||/\.pdf$/i.test(name)){const data=await asDataURL(file),bin=b64(data);return pick(name.endsWith('.pdf')?name:name+'.pdf',bytes,'application/pdf',bin,'base64',{type:'file',file:{filename:name,file_data:bin}})}if(/^audio\//.test(mime)||\.(wav|mp3)$/i.test(name)){const data=await asDataURL(file),bin=b64(data),fmt=/mp3/.test(mime)||/\.mp3$/i.test(name)?'mp3':'wav';return pick(name,bytes,mime,bin,'base64',{type:'input_audio',input_audio:{data:bin,format:fmt}})}const data=await asDataURL(file),bin=b64(data);return pick(name,bytes,mime,bin,'base64',{type:'file',file:{filename:name,file_data:bin}})}if(file&&file.name==null&&file.data){const name=file.name||'file',mime=(file.mime||'application/octet-stream').toLowerCase(),bytes=file.size||0;if(/^image\//.test(mime)){const url=`data:${mime};base64,${file.data}`;return pick(name,bytes,mime,url,'dataURL',{type:'image_url',image_url:{url}})}if(mime==='application/pdf'){return pick(name,bytes,mime,file.data,'base64',{type:'file',file:{filename:name,file_data:file.data}})}if(/^audio\//.test(mime)){const fmt=/mp3/.test(mime)?'mp3':'wav';return pick(name,bytes,mime,file.data,'base64',{type:'input_audio',input_audio:{data:file.data,format:fmt}})}return pick(name,bytes,mime,file.data,'base64',{type:'file',file:{filename:name,file_data:file.data}})}return null}
|
||||
async function toAttach(file){if(!file)return null;const pick=(name,bytes,mime,data,mode,part)=>({name,bytes,mime,data,mode,part});if(file instanceof File){const name=file.name||'file',mime=(file.type||'application/octet-stream').toLowerCase(),bytes=file.size||0;if(/^image\//.test(mime)||/\.(png|jpe?g|webp|gif)$/i.test(name)){const data=await asDataURL(file);return pick(name,bytes,mime,data,'dataURL',{type:'image_url',image_url:{url:data}})}if(mime==='application/pdf'||/\.pdf$/i.test(name)){const data=await asDataURL(file),bin=b64(data);return pick(name.endsWith('.pdf')?name:name+'.pdf',bytes,'application/pdf',bin,'base64',{type:'file',file:{filename:name,file_data:bin}})}if(/^audio\//.test(mime)||/\.(wav|mp3)$/i.test(name)){const data=await asDataURL(file),bin=b64(data),fmt=/mp3/.test(mime)||/\.mp3$/i.test(name)?'mp3':'wav';return pick(name,bytes,mime,bin,'base64',{type:'input_audio',input_audio:{data:bin,format:fmt}})}const data=await asDataURL(file),bin=b64(data);return pick(name,bytes,mime,bin,'base64',{type:'file',file:{filename:name,file_data:bin}})}if(file&&file.name==null&&file.data){const name=file.name||'file',mime=(file.mime||'application/octet-stream').toLowerCase(),bytes=file.size||0;if(/^image\//.test(mime)){const url=`data:${mime};base64,${file.data}`;return pick(name,bytes,mime,url,'dataURL',{type:'image_url',image_url:{url}})}if(mime==='application/pdf'){return pick(name,bytes,mime,file.data,'base64',{type:'file',file:{filename:name,file_data:file.data}})}if(/^audio\//.test(mime)){const fmt=/mp3/.test(mime)?'mp3':'wav';return pick(name,bytes,mime,file.data,'base64',{type:'input_audio',input_audio:{data:file.data,format:fmt}})}return pick(name,bytes,mime,file.data,'base64',{type:'file',file:{filename:name,file_data:file.data}})}return null}
|
||||
function attachmentsText(id,arr){const head='**Attachments**',list=arr.map((a,i)=>`- [${esc(a.name)} • ${fmtSize(a.bytes)}](#dl-${id}-${i})`).join('\n');return head+'\n'+list}
|
||||
function addAttachmentTree(role,arr){if(!arr?.length)return;const id=gid(),text=attachmentsText(id,arr),meta={role,content:[{type:'text',text}],id,kind:'attachments',attachmentsMeta:arr.map(a=>({name:a.name,bytes:a.bytes,mime:a.mime,mode:a.mode,data:a.mode==='dataURL'?a.data:a.data}))};const b=addMessage(meta,true);b.dataset.mid=id}
|
||||
el.attachBtn.addEventListener('click',()=>{if(state.busy)return;if(state.attachments.length){state.attachments=[];updateAttachBadge();el.fileInput.value=''};el.fileInput.click()})
|
||||
el.fileInput.addEventListener('change',async()=>{const files=[...(el.fileInput.files||[])];if(!files.length)return;for(const f of files){const at=await toAttach(f).catch(()=>null);if(at)state.attachments.push(at)}updateAttachBadge()})
|
||||
el.messages.addEventListener('click',async e=>{const a=e.target.closest('a[href^="#dl-"]');if(!a)return; e.preventDefault();const m=a.getAttribute('href').match(/^#dl-([^-]+)-(\d+)$/);if(!m)return;const id=m[1],i=+m[2];const msg=state.messages.find(x=>x.id===id),meta=msg?.attachmentsMeta?.[i];if(!meta)return;let blob;if(meta.mode==='dataURL'){blob=await (await fetch(meta.data)).blob()}else{const bin=Uint8Array.from(atob(meta.data),c=>c.charCodeAt(0));blob=new Blob([bin],{type:meta.mime||'application/octet-stream'})}const url=URL.createObjectURL(blob),dl=document.createElement('a');dl.href=url;dl.download=meta.name||'download';document.body.appendChild(dl);dl.click();dl.remove();URL.revokeObjectURL(url)})
|
||||
el.composer.addEventListener('submit',async e=>{e.preventDefault();if(state.busy)return;const text=el.input.value.trim();if(!text&&!state.attachments.length)return;if(state.messages.length===0)state.currentThreadId=null;await ensureThreadOnFirstUser(text||'(attachments)');el.input.value='';const parts=[];if(text)parts.push({type:'text',text});state.attachments.forEach(a=>parts.push(a.part));addMessage({role:'user',content:parts.length?parts:[{type:'text',text:text||'(sent attachments)'}]});if(state.attachments.length)addAttachmentTree('user',state.attachments);state.busy=true;setBtnStop();const a=getActiveSune();const suneMeta={sune_name:a.name,model:store.model,avatar:a.avatar||''};const suneBubble=addSuneBubbleStreaming(suneMeta);let buf='',completed=false;await askOpenRouterStreaming(delta=>{buf+=delta;renderMarkdown(suneBubble,buf,{enhance:false})}).then(()=>{if(!completed){completed=true;setBtnSend();state.busy=false;enhanceCodeBlocks(suneBubble,true);state.messages.push(Object.assign({role:'assistant',content:[{type:'text',text:buf}]},suneMeta));persistThread();queueMicrotask(()=>el.chat.scrollTo({top:el.chat.scrollHeight,behavior:'smooth'}))}});state.attachments=[];updateAttachBadge()})
|
||||
el.composer.addEventListener('submit',async e=>{e.preventDefault();if(state.busy)return;const text=el.input.value.trim();if(!text&&!state.attachments.length)return;if(state.messages.length===0)state.currentThreadId=null;await ensureThreadOnFirstUser(text||'(attachments)');el.input.value='';const parts=[];if(text)parts.push({type:'text',text});state.attachments.forEach(a=>parts.push(a.part));addMessage({role:'user',content:parts.length?parts:[{type:'text',text:text||'(sent attachments)'}]});if(state.attachments.length)addAttachmentTree('user',state.attachments);state.busy=true;setBtnStop();const a=getActiveSune();const suneMeta={sune_name:a.name,model:store.model,avatar:a.avatar||''};const suneBubble=addSuneBubbleStreaming(suneMeta);let buf='',completed=false;await askOpenRouterStreaming(delta=>{buf+=delta;renderMarkdown(suneBubble,buf,{enhance:false});},).then(()=>{}).catch(()=>{});const finalize=()=>{if(completed)return;completed=true;setBtnSend();state.busy=false;enhanceCodeBlocks(suneBubble,true);state.messages.push(Object.assign({role:'assistant',content:[{type:'text',text:buf}]},suneMeta));persistThread();queueMicrotask(()=>el.chat.scrollTo({top:el.chat.scrollHeight,behavior:'smooth'}))};state.controller && (state.controller.abort=(()=>{const orig=state.controller.abort;return ()=>{try{orig()}catch{};finalize()}})());const chk=()=>{finalize()};state.controller && (state.controller._doneHook=chk);state.attachments=[];updateAttachBadge()})
|
||||
let jars={js:null,html:null};const ensureJars=async()=>{if(jars.js&&jars.html)return jars;const mod=await import('https://medv.io/codejar/codejar.js');const CodeJar=mod.CodeJar||mod.default;const mk=(elx,lang)=>CodeJar(elx,ed=>{ed.innerHTML=hljs.highlight(ed.textContent,{language:lang}).value},{tab:' '});if(!jars.js)jars.js=mk(el.scriptEditor,'javascript');if(!jars.html)jars.html=mk(el.htmlEditor,'xml');return jars}
|
||||
let openedJS=false,openedHTML=false
|
||||
function openSettings(){const a=getActiveSune(),s=a.settings;openedJS=false;openedHTML=false;el.set_model.value=s.model;el.set_temperature.value=s.temperature;el.set_top_p.value=s.top_p;el.set_top_k.value=s.top_k;el.set_frequency_penalty.value=s.frequency_penalty;el.set_presence_penalty.value=s.presence_penalty;el.set_repetition_penalty.value=s.repetition_penalty;el.set_min_p.value=s.min_p;el.set_top_a.value=s.top_a;el.set_reasoning_effort.value=s.reasoning_effort||'default';el.set_system_prompt.value=s.system_prompt;showTab('Model');el.settingsModal.classList.remove('hidden')}
|
||||
@@ -239,17 +236,15 @@ const WS_BASE='wss://orp.awww.workers.dev/ws'
|
||||
const WS_UID=(crypto&&crypto.randomUUID?crypto.randomUUID():Math.random().toString(36).slice(2))
|
||||
const buildBody=()=>{const msgs=[];if(store.system_prompt)msgs.push({role:'system',content:[{type:'text',text:store.system_prompt}]});msgs.push(...state.messages.filter(m=>m.role!=='system').map(m=>({role:m.role,content:m.content})));const b=payloadWithSampling({model:store.model,messages:msgs,stream:true});if(store.reasoning_effort&&store.reasoning_effort!=='default')b.reasoning={effort:store.reasoning_effort};return b}
|
||||
async function askOpenRouterStreamingWS(onDelta){if(!store.apiKey){const t=localDemoReply();onDelta(t,true);return {ok:true,text:t}}const r={rid:Math.random().toString(36).slice(2),seq:-1,done:false,manual:false,signaled:false,backoff:300,ws:null};const signal=t=>{if(!r.signaled){r.signaled=true;onDelta(t||'',true)}};const open=mode=>{if(r.done)return;r.ws=new WebSocket(WS_BASE+'?uid='+encodeURIComponent(WS_UID));r.ws.onopen=()=>r.ws.send(JSON.stringify(mode==='begin'?{type:'begin',rid:r.rid,apiKey:store.apiKey,or_body:buildBody()}:{type:'resume',rid:r.rid,after:r.seq}));r.ws.onmessage=e=>{let m;try{m=JSON.parse(e.data)}catch{return}if(m.type==='delta'&&typeof m.seq==='number'&&m.seq>r.seq){r.seq=m.seq;onDelta(m.text||'',false)}else if(m.type==='done'){r.done=true;signal('');r.ws.close()}else if(m.type==='err'){r.done=true;signal('\n\n'+(m.message||'error'));r.ws.close()}};r.ws.onclose=()=>{if(!r.done&&!r.manual)setTimeout(()=>open('resume'),r.backoff=Math.min(r.backoff*1.5,5000))};r.ws.onerror=()=>{}};state.controller={abort:()=>{r.manual=true;r.done=true;try{r.ws?.send(JSON.stringify({type:'stop',rid:r.rid}))}catch{}try{r.ws?.close()}catch{}signal('')}};open('begin');return {ok:true}}
|
||||
async function askOpenRouterStreamingHTTP(onDelta){const apiKey=store.apiKey,model=store.model;if(!apiKey){const text=localDemoReply();onDelta(text,true);return {ok:true,text}}try{state.controller=new AbortController();const msgs=[];if(store.system_prompt)msgs.push({role:'system',content:[{type:'text',text:store.system_prompt}]});msgs.push(...state.messages.filter(m=>m.role!=='system').map(m=>({role:m.role,content: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 askOpenRouterStreaming(onDelta){return store.streamMode==='direct'?askOpenRouterStreamingHTTP(onDelta):askOpenRouterStreamingWS(onDelta)}
|
||||
function hideHistoryMenu(){el.historyMenu.classList.add('hidden');menuThreadId=null}
|
||||
function hideSuneMenu(){el.suneMenu.classList.add('hidden');menuSuneId=null}
|
||||
function openAccountSettings(){el.userMenu.classList.add('hidden');el.streamModeDirect.checked=(store.streamMode==='direct');el.streamModeWS.checked=(store.streamMode!=='direct');el.accountSettingsModal.classList.remove('hidden')}
|
||||
function closeAccountSettings(){el.accountSettingsModal.classList.add('hidden')}
|
||||
el.accountSettingsOption.addEventListener('click',openAccountSettings)
|
||||
el.closeAccountSettings.addEventListener('click',closeAccountSettings)
|
||||
el.cancelAccountSettings.addEventListener('click',closeAccountSettings)
|
||||
el.accountSettingsModal.addEventListener('click',e=>{if(e.target===el.accountSettingsModal||e.target.classList.contains('bg-black/30'))closeAccountSettings()})
|
||||
el.accountSettingsForm.addEventListener('submit',e=>{e.preventDefault();store.streamMode=el.streamModeDirect.checked?'direct':'ws';closeAccountSettings();alert('Streaming method set to '+(store.streamMode==='direct'?'Direct HTTP stream':'WebSocket via proxy'))})
|
||||
async function askOpenRouterStreamingDirect(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:[{type:'text',text:store.system_prompt}]});msgs.push(...state.messages.filter(m=>m.role!=='system').map(m=>({role:m.role,content: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}}
|
||||
function askOpenRouterStreaming(onDelta){return (globalStore.streamMode==='direct'?askOpenRouterStreamingDirect:askOpenRouterStreamingWS)(delta=>{if(delta===undefined)delta='';onDelta(delta,delta==='')})}
|
||||
function openAccount(){el.set_stream_mode.value=globalStore.streamMode||'ws';el.accountModal.classList.remove('hidden')}
|
||||
function closeAccount(){el.accountModal.classList.add('hidden')}
|
||||
el.accountSettingsOption.addEventListener('click',()=>{el.userMenu.classList.add('hidden');openAccount()})
|
||||
el.closeAccount.addEventListener('click',closeAccount)
|
||||
el.cancelAccount.addEventListener('click',closeAccount)
|
||||
el.accountModal.addEventListener('click',e=>{if(e.target===el.accountModal||e.target.classList.contains('bg-black/30'))closeAccount()})
|
||||
el.accountForm.addEventListener('submit',e=>{e.preventDefault();globalStore.streamMode=el.set_stream_mode.value||'ws';closeAccount();alert('Settings saved.')})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user