diff --git a/src/main.js b/src/main.js index 49188cb..af8f81b 100644 --- a/src/main.js +++ b/src/main.js @@ -1,4 +1,4 @@ -import {streamChat,HTTP_BASE} from './streaming.js'; +import {startReply,adoptThread,stopReply,dropReply,dropThread,curReply,syncBtn,restoreLive} from './streaming.js'; import {SUNE_LOGO_SVG} from './sune-logo.js'; import {STICKY_SUNES} from './sticky-sunes.js'; import {generateTitleWithAI} from './title-generator.js'; @@ -61,26 +61,26 @@ const su={key:'sunes_v1',activeKey:'active_sune_id',load(){try{return JSON.parse const defaultSettings={model:DEFAULT_MODEL,temperature:'',top_p:'',top_k:'',frequency_penalty:'',repetition_penalty:'',min_p:'',top_a:'',max_tokens:'',reasoning_effort:'',system_prompt:'',html:'',extension_html:"",hide_composer:false,include_thoughts:false,img_output:false,aspect_ratio:'1:1',image_size:'1K',ignore_master_prompt:false} const makeSune=(p={})=>({id:p.id||gid(),name:p.name?.trim()||'Default',pinned:!!p.pinned,avatar:p.avatar||'',url:p.url||'',updatedAt:p.updatedAt||Date.now(),settings:Object.assign({},defaultSettings,p.settings||{})}) let sunes=(su.load()||[]).map(makeSune) -const SUNE=window.SUNE=new Proxy({storage:suneStorage,get list(){return sunes},get id(){return su.getActiveId()},get active(){return sunes.find(a=>a.id===su.getActiveId())||sunes[0]},get:id=>sunes.find(s=>s.id===id),setActive:id=>su.setActiveId(id||''),create(p={}){const s=makeSune(p);sunes.unshift(s);su.save(sunes);markLocalDirty();return s},delete(id){const curId=this.id;sunes=sunes.filter(s=>s.id!==id);su.save(sunes);cleanSuneStorage(id);gcStorage();markLocalDirty();if(sunes.length===0){const def=this.create({name:'Default'});this.setActive(def.id)}else if(curId===id)this.setActive(sunes[0].id)},save:()=>su.save(sunes)},{get(t,p){if(p==='fetchDotSune')return async g=>{try{const u=g.startsWith('http')?g:(()=>{const[a,b]=g.split('@'),[c,d]=a.split('/'),[e,...f]=b.split('/');return`https://raw.githubusercontent.com/${c}/${d}/${e}/${f.join('/')}`})(),j=await(await fetch(u)).json(),l=sunes.length;sunes.unshift(...(Array.isArray(j)?j:j?.sunes||[]).filter(s=>s?.id&&!t.get(s.id)).map(s=>makeSune(s)));sunes.length>l&&t.save()}catch{}};if(p==='attach')return async files=>{const arr=[];for(const f of files||[])arr.push(await toAttach(f));const clean=arr.filter(Boolean);if(!clean.length)return;await ensureThreadOnFirstUser('(attachments)');addMessage({role:'assistant',content:clean,...activeMeta()});await THREAD.persist()};if(p==='log')return async s=>{const t=String(s??'').trim();if(!t)return;await ensureThreadOnFirstUser(t);addMessage({role:'assistant',content:[{type:'text',text:t}],...activeMeta()});await THREAD.persist()};if(p==='lastReply')return [...state.messages].reverse().find(m=>m.role==='assistant');if(p==='infer')return async()=>{if(state.busy||!SUNE.model||state.abortRequested){state.abortRequested=false;return};await ensureThreadOnFirstUser('Sune Inference');const th=THREAD.active;if(th&&!th.title)(async()=>THREAD.setTitle(th.id,await generateTitleWithAI(state.messages)||'Sune Inference'))();state.busy=true;setBtnStop();const a=SUNE.active,suneMeta={sune_name:a.name,model:SUNE.model,avatar:a.avatar||''},streamId=sid(),suneBubble=addSuneBubbleStreaming(suneMeta,streamId);suneBubble.dataset.mid=streamId;suneBubble.innerHTML=SUNE_LOGO_SVG;const assistantMsg=Object.assign({id:streamId,role:'assistant',content:[{type:'text',text:''}]},suneMeta);state.messages.push(assistantMsg);THREAD.persist(false);state.stream={rid:null,bubble:null,meta:null,text:'',done:false};let buf='',completed=false;const onDelta=(delta,done,imgs)=>{if(imgs){if(!assistantMsg.images)assistantMsg.images=[];assistantMsg.images.push(...imgs)}buf+=delta;state.stream.text=buf;assistantMsg.content[0].text=buf;renderMarkdown(suneBubble,partsToText(assistantMsg),{enhance:false});if(done&&!completed){completed=true;setBtnSend();state.busy=false;enhanceCodeBlocks(suneBubble,true);THREAD.persist(true);el.composer.dispatchEvent(new CustomEvent('sune:newSuneResponse',{detail:{message:assistantMsg}}));state.stream={rid:null,bubble:null,meta:null,text:'',done:false}}else if(!done)THREAD.persist(false)};await streamChat(onDelta,streamId)};if(p==='getByName')return n=>sunes.find(s=>s.name.toLowerCase()===(n||'').trim().toLowerCase());if(p==='handoff')return async n=>{await new Promise(r=>setTimeout(r,4000));const s=sunes.find(s=>s.name.toLowerCase()===(n||'').trim().toLowerCase());if(!s)return;SUNE.setActive(s.id);renderSidebar();await reflectActiveSune();await SUNE.infer()};if(p in t)return t[p];const a=t.active;if(!a)return;if(p in a.settings)return a.settings[p];if(p in a)return a[p]},set(t,p,v){const a=t.active;if(!a)return false;const i=sunes.findIndex(s=>s.id===a.id);if(i<0)return false;const isTopLevel=/^(name|avatar|url|pinned)$/.test(p),target=isTopLevel?sunes[i]:sunes[i].settings;let value=v;if(!isTopLevel){if(p==='system_prompt')value=v||''}if(target[p]!==value){target[p]=value;sunes[i].updatedAt=Date.now();su.save(sunes);markLocalDirty()}return true}}) +const SUNE=window.SUNE=new Proxy({storage:suneStorage,get list(){return sunes},get id(){return su.getActiveId()},get active(){return sunes.find(a=>a.id===su.getActiveId())||sunes[0]},get:id=>sunes.find(s=>s.id===id),setActive:id=>su.setActiveId(id||''),create(p={}){const s=makeSune(p);sunes.unshift(s);su.save(sunes);markLocalDirty();return s},delete(id){const curId=this.id;sunes=sunes.filter(s=>s.id!==id);su.save(sunes);cleanSuneStorage(id);gcStorage();markLocalDirty();if(sunes.length===0){const def=this.create({name:'Default'});this.setActive(def.id)}else if(curId===id)this.setActive(sunes[0].id)},save:()=>su.save(sunes)},{get(t,p){if(p==='fetchDotSune')return async g=>{try{const u=g.startsWith('http')?g:(()=>{const[a,b]=g.split('@'),[c,d]=a.split('/'),[e,...f]=b.split('/');return`https://raw.githubusercontent.com/${c}/${d}/${e}/${f.join('/')}`})(),j=await(await fetch(u)).json(),l=sunes.length;sunes.unshift(...(Array.isArray(j)?j:j?.sunes||[]).filter(s=>s?.id&&!t.get(s.id)).map(s=>makeSune(s)));sunes.length>l&&t.save()}catch{}};if(p==='attach')return async files=>{const arr=[];for(const f of files||[])arr.push(await toAttach(f));const clean=arr.filter(Boolean);if(!clean.length)return;await ensureThreadOnFirstUser('(attachments)');addMessage({role:'assistant',content:clean,...activeMeta()});await THREAD.persist()};if(p==='log')return async s=>{const t=String(s??'').trim();if(!t)return;await ensureThreadOnFirstUser(t);addMessage({role:'assistant',content:[{type:'text',text:t}],...activeMeta()});await THREAD.persist()};if(p==='lastReply')return [...state.messages].reverse().find(m=>m.role==='assistant');if(p==='infer')return async()=>{if(state.busy||sending||!SUNE.model||state.abortRequested){state.abortRequested=false;return};sending=true;try{await ensureThreadOnFirstUser('Sune Inference');const th=THREAD.active;if(th&&!th.title)(async()=>THREAD.setTitle(th.id,await generateTitleWithAI(state.messages)||'Sune Inference'))();reply()}finally{sending=false}};if(p==='getByName')return n=>sunes.find(s=>s.name.toLowerCase()===(n||'').trim().toLowerCase());if(p==='handoff')return async n=>{await new Promise(r=>setTimeout(r,4000));const s=sunes.find(s=>s.name.toLowerCase()===(n||'').trim().toLowerCase());if(!s)return;SUNE.setActive(s.id);renderSidebar();await reflectActiveSune();await SUNE.infer()};if(p in t)return t[p];const a=t.active;if(!a)return;if(p in a.settings)return a.settings[p];if(p in a)return a[p]},set(t,p,v){const a=t.active;if(!a)return false;const i=sunes.findIndex(s=>s.id===a.id);if(i<0)return false;const isTopLevel=/^(name|avatar|url|pinned)$/.test(p),target=isTopLevel?sunes[i]:sunes[i].settings;let value=v;if(!isTopLevel){if(p==='system_prompt')value=v||''}if(target[p]!==value){target[p]=value;sunes[i].updatedAt=Date.now();su.save(sunes);markLocalDirty()}return true}}) if(!sunes.length){const def=SUNE.create({name:'Default'});SUNE.setActive(def.id)} -const state=window.state={messages:[],busy:false,controller:null,currentThreadId:null,abortRequested:false,attachments:[],stream:{rid:null,bubble:null,meta:null,text:'',done:false}} +const state=window.state={messages:[],busy:false,currentThreadId:null,abortRequested:false,attachments:[]} +let sending=false,btnSwapAt=0 const getModelShort=m=>{const mm=m||SUNE.model||'';return mm.includes('/')?mm.split('/').pop():mm} const reflectActiveSune=async()=>{const a=SUNE.active;el.suneBtnTop.title=`Settings — ${a.name}`;el.suneBtnTop.innerHTML=a.avatar?``:'✺';el.footer.classList.toggle('hidden',!!a.settings.hide_composer);await renderSuneHTML();icons()} const suneRow=a=>`
` const renderSidebar=window.renderSidebar=()=>{const list=[...SUNE.list].sort((a,b)=>(b.pinned-a.pinned));el.suneList.innerHTML=list.map(suneRow).join('');icons()} const renderUserUI=window.renderUserUI=()=>{if(!el.userMenuAvatar)return;const a=USER.avatar;if(a){el.userMenuAvatar.className='h-6 w-6 rounded-full overflow-hidden shrink-0';el.userMenuAvatar.innerHTML=``}else{el.userMenuAvatar.className='h-6 w-6 rounded-full bg-gray-900 text-white flex items-center justify-center shrink-0 text-xs';el.userMenuAvatar.textContent='👤'}} const getSuneLabel=m=>{const name=(m&&m.sune_name)||SUNE.name,modelShort=getModelShort(m&&m.model);return `${name} · ${modelShort}`} -function _createMessageRow(m){const role=typeof m==='string'?m:(m&&m.role)||'assistant',meta=typeof m==='string'?{}:m||{},isUser=role==='user',$row=$('
'),$head=$('
'),$avatar=$('
');const uAva=isUser?USER.avatar:meta.avatar;uAva?$avatar.attr('class','msg-avatar shrink-0 h-7 w-7 rounded-full overflow-hidden').html(``):$avatar.attr('class',`${isUser?'bg-gray-900 text-white':'bg-gray-200 text-gray-900'} msg-avatar shrink-0 h-7 w-7 rounded-full flex items-center justify-center`).text(isUser?'👤':'✺');const $name=$('
').text(isUser?USER.name:getSuneLabel(meta));const $deleteBtn=$('').on('click',async e=>{e.stopPropagation();state.messages=state.messages.filter(msg=>msg.id!==m.id);$row.remove();await THREAD.persist()});const $copyBtn=$('').on('click',async function(e){e.stopPropagation();const cur=state.messages.find(x=>x.id===m.id)||m;if(await copyToClipboard(partsToText(cur,true))){$(this).html('');icons();setTimeout(()=>{$(this).html('');icons()},1200)}});$head.append($avatar,$name,$copyBtn,$deleteBtn);const $bubble=$(`
`);$row.append($head,$bubble);const vids=(Array.isArray(meta.content)?meta.content:[]).filter(p=>p?.type==='video_url');if(vids.length)$row.append(videoStrip(vids));return $row} +function _createMessageRow(m){const role=typeof m==='string'?m:(m&&m.role)||'assistant',meta=typeof m==='string'?{}:m||{},isUser=role==='user',$row=$('
'),$head=$('
'),$avatar=$('
');const uAva=isUser?USER.avatar:meta.avatar;uAva?$avatar.attr('class','msg-avatar shrink-0 h-7 w-7 rounded-full overflow-hidden').html(``):$avatar.attr('class',`${isUser?'bg-gray-900 text-white':'bg-gray-200 text-gray-900'} msg-avatar shrink-0 h-7 w-7 rounded-full flex items-center justify-center`).text(isUser?'👤':'✺');const $name=$('
').text(isUser?USER.name:getSuneLabel(meta));const $deleteBtn=$('').on('click',async e=>{e.stopPropagation();state.messages=state.messages.filter(msg=>msg.id!==m.id);$row.remove();dropReply(m.id);await THREAD.persist()});const $copyBtn=$('').on('click',async function(e){e.stopPropagation();const cur=state.messages.find(x=>x.id===m.id)||m;if(await copyToClipboard(partsToText(cur,true))){$(this).html('');icons();setTimeout(()=>{$(this).html('');icons()},1200)}});$head.append($avatar,$name,$copyBtn,$deleteBtn);const $bubble=$(`
`);$row.append($head,$bubble);const vids=(Array.isArray(meta.content)?meta.content:[]).filter(p=>p?.type==='video_url');if(vids.length)$row.append(videoStrip(vids));return $row} function msgRow(m){const $row=_createMessageRow(m);$(el.messages).append($row);queueMicrotask(()=>{el.chat.scrollTo({top:el.chat.scrollHeight,behavior:'smooth'});icons()});return $row.find('.msg-bubble')[0]} const addMessage=window.addMessage=function(m,track=true){m.id=m.id||gid();if(!Array.isArray(m.content)&&m.content!=null){m.content=[{type:'text',text:String(m.content)}]}const bubble=msgRow(m);bubble.dataset.mid=m.id;renderMarkdown(bubble,partsToText(m));if(track)state.messages.push(m);if(m.role==='assistant')el.composer.dispatchEvent(new CustomEvent('sune:newSuneResponse',{detail:{message:m}}));return bubble} const addSuneBubbleStreaming=(meta,id)=>msgRow(Object.assign({role:'assistant',id},meta)) -const clearChat=()=>{el.suneHtml.dispatchEvent(new CustomEvent('sune:unmount'));state.messages=[];el.messages.innerHTML='';clearAttachments();revokeVideoUrls()} +const clearChat=()=>{el.suneHtml.dispatchEvent(new CustomEvent('sune:unmount'));state.messages=[];el.messages.innerHTML='';clearAttachments();revokeVideoUrls();syncBtn()} const payloadWithSampling=b=>{const o=Object.assign({},b),s=SUNE,p={temperature:num(s.temperature,null),top_p:num(s.top_p,null),top_k:int(s.top_k,null),frequency_penalty:num(s.frequency_penalty,null),repetition_penalty:num(s.repetition_penalty,null),min_p:num(s.min_p,null),top_a:num(s.top_a,null),max_tokens:int(s.max_tokens,null)>0?int(s.max_tokens,null):null};Object.keys(p).forEach(k=>{const v=p[k];if(v!==null)o[k]=v});return o} -function setBtnStop(){const b=el.sendBtn;b.dataset.mode='stop';b.type='button';b.setAttribute('aria-label','Stop');b.innerHTML='';icons();b.onclick=()=>{state.abortRequested=true;state.controller?.abort?.();state.busy=false;setBtnSend()}} -function setBtnSend(){const b=el.sendBtn;b.dataset.mode='send';b.type='submit';b.setAttribute('aria-label','Send');b.innerHTML='';icons();b.onclick=null} +function setBtnStop(){const b=el.sendBtn;b.dataset.mode='stop';b.type='button';b.setAttribute('aria-label','Stop');b.innerHTML='';icons();b.onclick=()=>{state.abortRequested=true;stopReply(curReply());syncBtn()}} +function setBtnSend(){const b=el.sendBtn;if(b.dataset.mode==='stop')btnSwapAt=Date.now();b.dataset.mode='send';b.type='submit';b.setAttribute('aria-label','Send');b.innerHTML='';icons();b.onclick=null} function localDemoReply(){return 'Tip: open the sidebar → User to set your API key.'} -const TKEY='threads_v1',THREAD=window.THREAD={list:[],load:async function(){const u=el.threadRepoInput.value.trim();if(u.startsWith('gh://')){this.list=await localforage.getItem('rem_index_'+u.substring(5)).then(v=>Array.isArray(v)?v:[])||[]}else{this.list=await localforage.getItem(TKEY).then(v=>Array.isArray(v)?v:[])||[]}},save:async function(){const u=el.threadRepoInput.value.trim();if(u.startsWith('gh://')){await localforage.setItem('rem_index_'+u.substring(5),this.list.map(t=>{const n={...t};delete n.messages;return n}))}else{await localforage.setItem(TKEY,this.list.map(t=>{const n={...t};delete n.messages;return n}))}},get:function(id){return this.list.find(t=>t.id===id)},get active(){return this.get(state.currentThreadId)},persist:async function(full=true){const id=state.currentThreadId;if(!id)return;const meta=this.get(id);if(!meta)return;const u=el.threadRepoInput.value.trim(),prefix=u.startsWith('gh://')?'rem_t_':'t_';await localforage.setItem(prefix+id,[...state.messages]);if(full){meta.updatedAt=Date.now();if(u.startsWith('gh://')&&meta.status!=='new')meta.status='modified';await this.save();await renderThreads()}},setTitle:async function(id,title){const th=this.get(id);if(!th||!title)return;th.title=titleFrom(title);th.updatedAt=Date.now();const u=el.threadRepoInput.value.trim();if(u.startsWith('gh://')&&th.status!=='new')th.status='modified';await this.save();await renderThreads()},getLastAssistantMessageId:()=>{const a=[...el.messages.querySelectorAll('.msg-bubble')];for(let i=a.length-1;i>=0;i--){const b=a[i],h=b.previousElementSibling;if(!h)continue;if(!/^\s*You\b/.test(h.textContent||''))return b.dataset.mid||null}return null}} -const cacheStore=localforage.createInstance({name:'threads_cache',storeName:'streams_status'}); +const TKEY='threads_v1',THREAD=window.THREAD={list:[],load:async function(){const u=el.threadRepoInput.value.trim();if(u.startsWith('gh://')){this.list=await localforage.getItem('rem_index_'+u.substring(5)).then(v=>Array.isArray(v)?v:[])||[]}else{this.list=await localforage.getItem(TKEY).then(v=>Array.isArray(v)?v:[])||[]}},save:async function(){const u=el.threadRepoInput.value.trim();if(u.startsWith('gh://')){await localforage.setItem('rem_index_'+u.substring(5),this.list.map(t=>{const n={...t};delete n.messages;return n}))}else{await localforage.setItem(TKEY,this.list.map(t=>{const n={...t};delete n.messages;return n}))}},get:function(id){return this.list.find(t=>t.id===id)},get active(){return this.get(state.currentThreadId)},persist:async function(full=true){const id=state.currentThreadId;if(!id)return;const meta=this.get(id);if(!meta)return;const u=el.threadRepoInput.value.trim(),prefix=u.startsWith('gh://')?'rem_t_':'t_';await localforage.setItem(prefix+id,[...state.messages]);if(full){meta.updatedAt=Date.now();if(u.startsWith('gh://')&&meta.status!=='new')meta.status='modified';await this.save();await renderThreads()}},setTitle:async function(id,title){const th=this.get(id);if(!th||!title)return;th.title=titleFrom(title);th.updatedAt=Date.now();const u=el.threadRepoInput.value.trim();if(u.startsWith('gh://')&&th.status!=='new')th.status='modified';await this.save();await renderThreads()},getLastAssistantMessageId:()=>[...state.messages].reverse().find(m=>m.role==='assistant')?.id||null} async function ensureThreadOnFirstUser(text){let needNew=!state.currentThreadId;if(state.messages.length===0)state.currentThreadId=null;if(state.currentThreadId&&!THREAD.get(state.currentThreadId))needNew=true;if(!needNew)return;const id=gid(),now=Date.now(),u=el.threadRepoInput.value.trim(),th={id,title:'',pinned:false,updatedAt:now,type:'thread'};if(u.startsWith('gh://'))th.status='new';state.currentThreadId=id;THREAD.list.unshift(th);await THREAD.save();const prefix=u.startsWith('gh://')?'rem_t_':'t_';await localforage.setItem(prefix+id,[]);await renderThreads()} const threadRow=t=>{const icon=t.type==='folder'?'folder':(t.type==='file'?'file-text':'');return `
`} let sortedThreads=[],isAddingThreads=false;const THREAD_PAGE_SIZE=50; @@ -101,7 +101,7 @@ function showSunePopover(btn,id){menuSuneId=id;el.sunePopover.classList.remove(' const hideSunesSyncPopover=()=>{el.sunesSyncPopover.classList.add('hidden')},hideSuneSyncPopover=hideSunesSyncPopover; function showSunesSyncPopover(btn){el.sunesSyncPopover.classList.remove('hidden');positionPopover(btn||el.suneSyncBtn,el.sunesSyncPopover);icons()} const showSuneSyncPopover=showSunesSyncPopover; -$(el.threadList).on('click',async e=>{const openBtn=e.target.closest('[data-open-thread]'),menuBtn=e.target.closest('[data-thread-menu]');if(openBtn){const id=openBtn.getAttribute('data-open-thread'),type=openBtn.getAttribute('data-type');if(type==='file'){const u=el.threadRepoInput.value.trim();if(u.startsWith('gh://')){const info=parseGhUrl(u);window.open(`https://github.com/${info.owner}/${info.repo}/blob/${info.branch}/${id}`,'_blank')}return}if(type==='folder'){const u=el.threadRepoInput.value.trim();el.threadRepoInput.value=u+(u.endsWith('/')?'':'/')+id;el.threadRepoInput.dispatchEvent(new Event('change'));return}if(id!==state.currentThreadId&&state.busy){state.controller?.disconnect?.();setBtnSend();state.busy=false;state.controller=null}const th=THREAD.get(id);if(!th)return;if(id===state.currentThreadId){el.sidebarRight.classList.add('translate-x-full');el.sidebarOverlayRight.classList.add('hidden');hideThreadPopover();return}state.currentThreadId=id;clearChat();const u=el.threadRepoInput.value.trim(),prefix=u.startsWith('gh://')?'rem_t_':'t_';let msgs=await localforage.getItem(prefix+id);if((!msgs||!Array.isArray(msgs)||!msgs.length)&&u.startsWith('gh://')){try{const info=parseGhUrl(u),fileName=serializeThreadName(th),text=await ghGetFileContent(info,fileName);if(text){try{msgs=JSON.parse(text);await localforage.setItem(prefix+id,msgs);th.status='synced';await THREAD.save()}catch(pe){console.error('[Sune] Thread JSON parse failed for',fileName,'len',text.length,pe)}}else{console.warn('[Sune] Remote thread returned no content:',fileName)}}catch(e){console.error('[Sune] Remote fetch failed',e)}}state.messages=Array.isArray(msgs)?[...msgs]:[];for(const m of state.messages){const b=msgRow(m);b.dataset.mid=m.id||'';renderMarkdown(b,partsToText(m))}await renderSuneHTML();syncWhileBusy();queueMicrotask(()=>el.chat.scrollTo({top:el.chat.scrollHeight,behavior:'smooth'}));el.sidebarRight.classList.add('translate-x-full');el.sidebarOverlayRight.classList.add('hidden');hideThreadPopover();return}if(menuBtn){e.stopPropagation();showThreadPopover(menuBtn,menuBtn.getAttribute('[data-thread-menu]')?menuBtn.getAttribute('[data-thread-menu]'):menuBtn.getAttribute('data-thread-menu'))}}) +$(el.threadList).on('click',async e=>{const openBtn=e.target.closest('[data-open-thread]'),menuBtn=e.target.closest('[data-thread-menu]');if(openBtn){const id=openBtn.getAttribute('data-open-thread'),type=openBtn.getAttribute('data-type');if(type==='file'){const u=el.threadRepoInput.value.trim();if(u.startsWith('gh://')){const info=parseGhUrl(u);window.open(`https://github.com/${info.owner}/${info.repo}/blob/${info.branch}/${id}`,'_blank')}return}if(type==='folder'){const u=el.threadRepoInput.value.trim();el.threadRepoInput.value=u+(u.endsWith('/')?'':'/')+id;el.threadRepoInput.dispatchEvent(new Event('change'));return}const th=THREAD.get(id);if(!th)return;if(id===state.currentThreadId){el.sidebarRight.classList.add('translate-x-full');el.sidebarOverlayRight.classList.add('hidden');hideThreadPopover();return}state.currentThreadId=id;clearChat();const u=el.threadRepoInput.value.trim(),prefix=u.startsWith('gh://')?'rem_t_':'t_';let msgs=await localforage.getItem(prefix+id);if((!msgs||!Array.isArray(msgs)||!msgs.length)&&u.startsWith('gh://')){try{const info=parseGhUrl(u),fileName=serializeThreadName(th),text=await ghGetFileContent(info,fileName);if(text){try{msgs=JSON.parse(text);await localforage.setItem(prefix+id,msgs);th.status='synced';await THREAD.save()}catch(pe){console.error('[Sune] Thread JSON parse failed for',fileName,'len',text.length,pe)}}else{console.warn('[Sune] Remote thread returned no content:',fileName)}}catch(e){console.error('[Sune] Remote fetch failed',e)}}if(state.currentThreadId!==id)return;state.messages=Array.isArray(msgs)?[...msgs]:[];for(const m of state.messages){const b=msgRow(m);b.dataset.mid=m.id||'';renderMarkdown(b,partsToText(m))}adoptThread();await renderSuneHTML();queueMicrotask(()=>el.chat.scrollTo({top:el.chat.scrollHeight,behavior:'smooth'}));el.sidebarRight.classList.add('translate-x-full');el.sidebarOverlayRight.classList.add('hidden');hideThreadPopover();return}if(menuBtn){e.stopPropagation();showThreadPopover(menuBtn,menuBtn.getAttribute('[data-thread-menu]')?menuBtn.getAttribute('[data-thread-menu]'):menuBtn.getAttribute('data-thread-menu'))}}) $(el.threadList).on('scroll',()=>{ if(isAddingThreads||el.threadList.scrollTop+el.threadList.clientHeight{ } isAddingThreads=false; }); -$(el.threadPopover).on('click',async e=>{const act=e.target.closest('[data-action]')?.getAttribute('data-action');if(!act||!menuThreadId)return;const th=THREAD.get(menuThreadId);if(!th)return;const u=el.threadRepoInput.value.trim(),prefix=u.startsWith('gh://')?'rem_t_':'t_';if(act==='pin'){th.pinned=!th.pinned;if(u.startsWith('gh://')&&th.status!=='new')th.status='modified';}else if(act==='rename'){const nv=prompt('Rename to:',th.title);if(nv!=null){th.title=titleFrom(nv);th.updatedAt=Date.now();if(u.startsWith('gh://')&&th.status!=='new')th.status='modified'}}else if(act==='duplicate'){const newId=gid(),msgs=await localforage.getItem(prefix+th.id)||[];const newTh={...th,id:newId,title:th.title+' (Copy)',updatedAt:Date.now()};if(u.startsWith('gh://'))newTh.status='new';THREAD.list.unshift(newTh);await localforage.setItem(prefix+newId,msgs);await THREAD.save();await renderThreads()}else if(act==='delete'){if(confirm('Delete this chat?')){if(u.startsWith('gh://')){th.status='deleted';th.updatedAt=Date.now()}else{THREAD.list=THREAD.list.filter(x=>!th.id!==th.id);await localforage.removeItem(prefix+th.id)}if(state.currentThreadId===th.id){state.currentThreadId=null;clearChat()}}}else if(act==='count_tokens'){const msgs=await localforage.getItem(prefix+th.id)||[];let totalChars=0;for(const m of msgs){if(!m||!m.role||m.role==='system')continue;totalChars+=String(partsToText(m)||'').length}const tokens=Math.max(0,Math.ceil(totalChars/4));const k=tokens>=1000?Math.round(tokens/1000)+'k':String(tokens);alert(tokens+' tokens ('+k+')')}else if(act==='export'){const msgs=await localforage.getItem(prefix+th.id)||[];dl(`thread-${(th.title||'thread').replace(/\W/g,'_')}-${ts()}.json`,{...th,messages:msgs})}else if(act==='copy_path'){const u=el.threadRepoInput.value.trim();if(u.startsWith('gh://')){const info=parseGhUrl(u);if(await copyToClipboard(`${info.owner}/${info.repo}@${info.branch}/${th.id}`))alert('Path copied.')}}hideThreadPopover();await THREAD.save();renderThreads()}) -$(el.suneList).on('click',async e=>{const menuBtn=e.target.closest('[data-sune-menu]');if(menuBtn){e.stopPropagation();showSunePopover(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){if(state.busy){state.controller?.disconnect?.();setBtnSend();state.busy=false;state.controller=null};SUNE.setActive(id);renderSidebar();await reflectActiveSune();state.currentThreadId=null;clearChat();document.getElementById('sidebarLeft').classList.add('-translate-x-full');document.getElementById('sidebarOverlayLeft').classList.add('hidden')}}) +$(el.threadPopover).on('click',async e=>{const act=e.target.closest('[data-action]')?.getAttribute('data-action');if(!act||!menuThreadId)return;const th=THREAD.get(menuThreadId);if(!th)return;const u=el.threadRepoInput.value.trim(),prefix=u.startsWith('gh://')?'rem_t_':'t_';if(act==='pin'){th.pinned=!th.pinned;if(u.startsWith('gh://')&&th.status!=='new')th.status='modified';}else if(act==='rename'){const nv=prompt('Rename to:',th.title);if(nv!=null){th.title=titleFrom(nv);th.updatedAt=Date.now();if(u.startsWith('gh://')&&th.status!=='new')th.status='modified'}}else if(act==='duplicate'){const newId=gid(),msgs=await localforage.getItem(prefix+th.id)||[];const newTh={...th,id:newId,title:th.title+' (Copy)',updatedAt:Date.now()};if(u.startsWith('gh://'))newTh.status='new';THREAD.list.unshift(newTh);await localforage.setItem(prefix+newId,msgs);await THREAD.save();await renderThreads()}else if(act==='delete'){if(confirm('Delete this chat?')){if(u.startsWith('gh://')){th.status='deleted';th.updatedAt=Date.now()}else{THREAD.list=THREAD.list.filter(x=>x.id!==th.id);await localforage.removeItem(prefix+th.id)}dropThread(th.id);if(state.currentThreadId===th.id){state.currentThreadId=null;clearChat()}}}else if(act==='count_tokens'){const msgs=await localforage.getItem(prefix+th.id)||[];let totalChars=0;for(const m of msgs){if(!m||!m.role||m.role==='system')continue;totalChars+=String(partsToText(m)||'').length}const tokens=Math.max(0,Math.ceil(totalChars/4));const k=tokens>=1000?Math.round(tokens/1000)+'k':String(tokens);alert(tokens+' tokens ('+k+')')}else if(act==='export'){const msgs=await localforage.getItem(prefix+th.id)||[];dl(`thread-${(th.title||'thread').replace(/\W/g,'_')}-${ts()}.json`,{...th,messages:msgs})}else if(act==='copy_path'){const u=el.threadRepoInput.value.trim();if(u.startsWith('gh://')){const info=parseGhUrl(u);if(await copyToClipboard(`${info.owner}/${info.repo}@${info.branch}/${th.id}`))alert('Path copied.')}}hideThreadPopover();await THREAD.save();renderThreads()}) +$(el.suneList).on('click',async e=>{const menuBtn=e.target.closest('[data-sune-menu]');if(menuBtn){e.stopPropagation();showSunePopover(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){SUNE.setActive(id);renderSidebar();await reflectActiveSune();state.currentThreadId=null;clearChat();document.getElementById('sidebarLeft').classList.add('-translate-x-full');document.getElementById('sidebarOverlayLeft').classList.add('hidden')}}) $(el.sunePopover).on('click',async e=>{const act=e.target.closest('[data-action]')?.getAttribute('data-action');if(!act||!menuSuneId)return;const s=SUNE.get(menuSuneId);if(!s)return;const updateAndRender=async()=>{s.updatedAt=Date.now();SUNE.save();renderSidebar();await reflectActiveSune();markLocalDirty()};if(act==='pin'){s.pinned=!s.pinned;await updateAndRender()}else if(act==='rename'){const n=prompt('Rename sune to:',s.name);if(n!=null){s.name=n.trim();await updateAndRender()}}else if(act==='pfp'){const i=document.createElement('input');i.type='file';i.accept='image/*';i.onchange=async()=>{const f=i.files?.[0];if(!f)return;try{s.avatar=await imgToWebp(f);await updateAndRender()}catch{}};i.click()}else if(act==='export')dl(`sune-${(s.name||'sune').replace(/\W/g,'_')}-${ts()}.sune`,[s]);hideSunePopover()}) $(el.sunesSyncUploadBtn).on('click',()=>{hideSunesSyncPopover();performSuneUpload()}); $(el.sunesSyncDownloadBtn).on('click',()=>{hideSunesSyncPopover();performSuneDownload(false)}); -let attachGen=0,attachJob=null,attachQueue=Promise.resolve(),attachWait=false +let attachGen=0,attachJob=null,attachQueue=Promise.resolve() function updateAttachBadge(){const n=state.attachments.length;el.attachBadge.textContent=attachJob?Math.floor(attachJob.p*100)+'%':String(n);el.attachBadge.classList.toggle('hidden',!n&&!attachJob)} function clearAttachments(){attachGen++;attachJob?.ac.abort();attachJob=null;state.attachments=[];updateAttachBadge();el.fileInput.value=''} const addAttachments=files=>{const g=attachGen;attachQueue=attachQueue.then(async()=>{for(const f of files){if(g!==attachGen)return;const job=attachJob={ac:new AbortController,p:0};updateAttachBadge();const at=await toAttach(f,{signal:job.ac.signal,onProgress:p=>{job.p=p;updateAttachBadge()}}).catch(e=>{if(g===attachGen)alert(`Couldn't attach ${f.name||'file'}: ${e?.message||e}`)});if(g!==attachGen)return;attachJob=null;if(at)state.attachments.push(at);updateAttachBadge()}}).catch(()=>{})} @@ -137,7 +137,10 @@ $(el.composer).on('paste',async e=>{ e.preventDefault(); addAttachments(files); }) -$(el.composer).on('submit',async e=>{e.preventDefault();if(state.busy||attachWait)return;attachWait=true;await attachQueue;attachWait=false;const text=el.input.value.trim();if(!text&&!state.attachments.length)return SUNE.infer();await ensureThreadOnFirstUser(text||'(attachments)');const th=THREAD.active,shouldGenTitle=th&&!th.title;el.input.value='';const parts=[];if(text)parts.push({type:'text',text});parts.push(...state.attachments);state.attachments=[];updateAttachBadge();el.fileInput.value='';const userMsg={role:'user',content:parts.length?parts:[{type:'text',text:text||'(sent attachments)'}]};addMessage(userMsg);el.composer.dispatchEvent(new CustomEvent('user:send',{detail:{message:userMsg}}));if(shouldGenTitle)(async()=>{const title=await generateTitleWithAI(state.messages)||partsToText(state.messages.find(m=>m.role==='user')).replace(/!\[\]\(data:[^\)]+\)/g,'[Image]')||'Untitled';await THREAD.setTitle(th.id,title)})();if(!SUNE.model)return;state.busy=true;setBtnStop();const a=SUNE.active,suneMeta={sune_name:a.name,model:SUNE.model,avatar:a.avatar||''},streamId=sid(),suneBubble=addSuneBubbleStreaming(suneMeta,streamId);suneBubble.dataset.mid=streamId;suneBubble.innerHTML=SUNE_LOGO_SVG;const assistantMsg=Object.assign({id:streamId,role:'assistant',content:[{type:'text',text:''}]},suneMeta);state.messages.push(assistantMsg);THREAD.persist(false);state.stream={rid:streamId,bubble:suneBubble,meta:suneMeta,text:'',done:false};let buf='',completed=false;const onDelta=(delta,done,imgs)=>{if(imgs){if(!assistantMsg.images)assistantMsg.images=[];assistantMsg.images.push(...imgs)}buf+=delta;state.stream.text=buf;assistantMsg.content[0].text=buf;renderMarkdown(suneBubble,partsToText(assistantMsg),{enhance:false});if(done&&!completed){completed=true;setBtnSend();state.busy=false;enhanceCodeBlocks(suneBubble,true);THREAD.persist(true);el.composer.dispatchEvent(new CustomEvent('sune:newSuneResponse',{detail:{message:assistantMsg}}));state.stream={rid:null,bubble:null,meta:null,text:'',done:false}}else if(!done)THREAD.persist(false)};await streamChat(onDelta,streamId)}) +// Shared by send and SUNE.infer: adds the assistant bubble and starts its stream +function reply(){const a=SUNE.active,meta={sune_name:a.name,model:SUNE.model,avatar:a.avatar||''},id=sid(),msg=Object.assign({id,role:'assistant',content:[{type:'text',text:''}]},meta,{stream:{seq:-1}}),b=addSuneBubbleStreaming(meta,id);b.dataset.mid=id;b.innerHTML=SUNE_LOGO_SVG;state.messages.push(msg);THREAD.persist(false);startReply(msg)} +// The lock is taken before any await so a double tap can't send twice; a tap on the button just after it flipped from Stop is ignored +$(el.composer).on('submit',async e=>{e.preventDefault();if(state.busy||sending||(e.submitter===el.sendBtn&&Date.now()-btnSwapAt<400))return;state.abortRequested=false;sending=true;try{await attachQueue;const text=el.input.value.trim();if(!text&&!state.attachments.length){sending=false;return await SUNE.infer()}el.input.value='';const parts=[];if(text)parts.push({type:'text',text});parts.push(...state.attachments);state.attachments=[];updateAttachBadge();el.fileInput.value='';await ensureThreadOnFirstUser(text||'(attachments)');const th=THREAD.active,shouldGenTitle=th&&!th.title;const userMsg={role:'user',content:parts.length?parts:[{type:'text',text:text||'(sent attachments)'}]};addMessage(userMsg);el.composer.dispatchEvent(new CustomEvent('user:send',{detail:{message:userMsg}}));if(shouldGenTitle)(async()=>{const title=await generateTitleWithAI(state.messages)||partsToText(state.messages.find(m=>m.role==='user')).replace(/!\[\]\(data:[^\)]+\)/g,'[Image]')||'Untitled';await THREAD.setTitle(th.id,title)})();if(SUNE.model)reply()}finally{sending=false}}) let jars={html:null,extension:null}; const ensureJars=async()=>{ if(jars.html&&jars.extension)return jars; @@ -202,7 +205,7 @@ USER.logMany = async msgs => { await THREAD.persist(); }; -async function init(){gcStorage();const u=localStorage.getItem('thread_repo_url')||'',suR=localStorage.getItem('sune_repo_url')||'';el.threadRepoInput.value=u;el.suneRepoInput.value=suR;el.threadFolderBtn.classList.toggle('hidden',!u.startsWith('gh://'));el.threadBackBtn.classList.toggle('hidden',!u.startsWith('gh://')||u.split('/').length<=3);await THREAD.load();await renderThreads();await Promise.allSettled(STICKY_SUNES.map(s=>SUNE.fetchDotSune(s)));renderSidebar();renderUserUI();await reflectActiveSune();if(suR.startsWith('gh://'))checkSuneSyncStatus();clearChat();icons();kbBind();kbUpdate()} +async function init(){gcStorage();const u=localStorage.getItem('thread_repo_url')||'',suR=localStorage.getItem('sune_repo_url')||'';el.threadRepoInput.value=u;el.suneRepoInput.value=suR;el.threadFolderBtn.classList.toggle('hidden',!u.startsWith('gh://'));el.threadBackBtn.classList.toggle('hidden',!u.startsWith('gh://')||u.split('/').length<=3);await THREAD.load();await renderThreads();await Promise.allSettled(STICKY_SUNES.map(s=>SUNE.fetchDotSune(s)));renderSidebar();renderUserUI();await reflectActiveSune();if(suR.startsWith('gh://'))checkSuneSyncStatus();clearChat();icons();kbBind();kbUpdate();restoreLive();localforage.dropInstance({name:'threads_cache'}).catch(()=>{})} $(window).on('resize',()=>{hideThreadPopover();hideSunePopover();hideSunesSyncPopover()}) $(document).on('click',e=>{if(el.sunesSyncPopover&&!el.sunesSyncPopover.classList.contains('hidden')&&!el.sunesSyncPopover.contains(e.target)&&!el.suneSyncBtn.contains(e.target))hideSunesSyncPopover()}); const htmlTabs={index:['htmlTab_index','htmlEditor'],extension:['htmlTab_extension','extensionHtmlEditor']};function showHtmlTab(key){Object.entries(htmlTabs).forEach(([k,[tb,pn]])=>{const a=k===key;el[tb].classList.toggle('border-black',a);el[tb].classList.toggle('border-transparent',!a);el[tb].classList.toggle('hover:border-gray-300',!a);el[pn].classList.toggle('hidden',!a)})} @@ -372,15 +375,10 @@ el.exportAccountSettings.onclick=()=>dl(`sune-account-${ts()}.json`,{v:1,provide el.importAccountSettings.onclick=()=>{el.importAccountSettingsInput.value='';el.importAccountSettingsInput.click()}; el.importAccountSettingsInput.onchange=async e=>{const f=e.target.files?.[0];if(!f)return;try{const d=JSON.parse(await f.text());if(!d||typeof d!=='object')throw new Error('Invalid');const m={provider:'provider',apiKeyOpenRouter:'apiKeyOR',apiKeyOpenAI:'apiKeyOAI',apiKeyGoogle:'apiKeyG',apiKeyClaude:'apiKeyClaude',apiKeyCloudflare:'apiKeyCloudflare',customKey1:'customKey1',masterPrompt:'masterPrompt',titleModel:'titleModel',githubToken:'ghToken',name:'userName',avatar:'userAvatar'};Object.entries(m).forEach(([p,k])=>{const v=d[p]??d[k];if(typeof v==='string')USER[p]=v});renderUserUI();openAccountSettings();alert('Imported.')}catch{alert('Import failed')}}; const getBubbleById=id=>el.messages.querySelector(`.msg-bubble[data-mid="${CSS.escape(id)}"]`) -async function syncActiveThread(){const id=THREAD.getLastAssistantMessageId();if(!id)return false;if(await cacheStore.getItem(id)==='done'){if(state.busy){setBtnSend();state.busy=false;state.controller=null}return false}if(!state.busy){state.busy=true;state.controller={abort:()=>{const ws=new WebSocket(HTTP_BASE.replace('https','wss'));ws.onopen=function(){this.send(JSON.stringify({type:'stop',rid:id}));this.close()}}};setBtnStop()}const bubble=getBubbleById(id);if(!bubble){if(state.busy){setBtnSend();state.busy=false;state.controller=null;}return false;}const msgIdx=state.messages.findIndex(x=>x.id===id);const localText=msgIdx>=0?partsToText(state.messages[msgIdx]):(bubble.textContent||'');const j=await(fetch(HTTP_BASE+'?uid='+encodeURIComponent(id)).then(r=>r.ok?r.json():null).catch(()=>null));const finalise=(t,c,imgs)=>{const tempMsg={content:c,images:imgs};renderMarkdown(bubble,partsToText(tempMsg),{enhance:false});enhanceCodeBlocks(bubble,true);if(msgIdx>=0){state.messages[msgIdx].content=c;state.messages[msgIdx].images=imgs}else state.messages.push({id,role:'assistant',content:c,images:imgs,...activeMeta()});THREAD.persist();setBtnSend();state.busy=false;cacheStore.setItem(id,'done');state.controller=null;el.composer.dispatchEvent(new CustomEvent('sune:newSuneResponse',{detail:{message:state.messages.find(m=>m.id===id)}}))};if(!j||j.rid!==id){if(j&&j.error){const t=localText+'\n\n'+j.error;finalise(t,[{type:'text',text:t}])}else{await cacheStore.setItem(id,'done');if(state.busy){setBtnSend();state.busy=false;state.controller=null;}}return false}const serverText=j.text||'',isDone=j.error||j.done||j.phase==='done';const finalText=(serverText.length>=localText.length||isDone)?serverText:localText;const display=partsToText({content:[{type:'text',text:finalText}],images:j.images});if(display)renderMarkdown(bubble,display,{enhance:false});if(isDone){if(finalText!==localText){finalise(finalText,[{type:'text',text:finalText}],j.images)}else{await cacheStore.setItem(id,'done');if(state.busy){setBtnSend();state.busy=false;state.controller=null}}return false}await cacheStore.setItem(id,'busy');return true} -let syncLoopRunning=false -async function syncWhileBusy(){if(syncLoopRunning||document.visibilityState==='hidden')return;syncLoopRunning=true;try{while(await syncActiveThread())await new Promise(r=>setTimeout(r,1500))}finally{syncLoopRunning=false}} -const onForeground=()=>{if(document.visibilityState!=='visible')return;state.controller?.disconnect?.();if(state.busy)syncWhileBusy()} -$(document).on('visibilitychange',onForeground) $(el.copySystemPrompt).on('click',async()=>await copyToClipboard(el.set_system_prompt.value||'')) $(el.pasteSystemPrompt).on('click',async()=>{try{el.set_system_prompt.value=await navigator.clipboard.readText()}catch{}}) const getActiveJar=()=>!el.htmlEditor.classList.contains('hidden')?jars.html:jars.extension $(el.copyHTML).on('click',async()=>{const jar=getActiveJar();await copyToClipboard(jar?jar.toString():'')}) $(el.pasteHTML).on('click',async()=>{try{const t=await navigator.clipboard.readText();const jar=getActiveJar();if(jar)jar.updateCode(t)}catch{}}) -Object.assign(window,{icons,haptic,clamp,num,int,gid,esc,positionPopover,sid,fmtSize,asDataURL,b64,makeSune,getModelShort,resolveSuneSrc,processSuneIncludes,renderSuneHTML,reflectActiveSune,suneRow,renderUserUI,enhanceCodeBlocks,getSuneLabel,_createMessageRow,msgRow,partsToText,copyToClipboard,addSuneBubbleStreaming,clearChat,payloadWithSampling,setBtnStop,setBtnSend,localDemoReply,titleFrom,serializeThreadName,deserializeThreadName,ensureThreadOnFirstUser,generateTitleWithAI,threadRow,renderThreads,hideThreadPopover,showThreadPopover,hideSunePopover,showSunePopover,hideSuneSyncPopover,showSuneSyncPopover,hideSunesSyncPopover,showSunesSyncPopover,setSuneSyncStatus,checkSuneSyncStatus,performSuneDownload,performSuneUpload,updateAttachBadge,toAttach,ensureJars,openSettings,closeSettings,showTab,dl,ts,kbUpdate,kbBind,activeMeta,init,showHtmlTab,showAccountTab,openAccountSettings,closeAccountSettings,getBubbleById,syncActiveThread,syncWhileBusy,onForeground,getActiveJar,imgToWebp,cacheStore,ghApi,parseGhUrl,ghGetFileContent,pullThreads,suneStorage,gcStorage,cleanSuneStorage}); +Object.assign(window,{icons,haptic,clamp,num,int,gid,esc,positionPopover,sid,fmtSize,asDataURL,b64,makeSune,getModelShort,resolveSuneSrc,processSuneIncludes,renderSuneHTML,reflectActiveSune,suneRow,renderUserUI,enhanceCodeBlocks,getSuneLabel,_createMessageRow,msgRow,partsToText,copyToClipboard,addSuneBubbleStreaming,clearChat,payloadWithSampling,setBtnStop,setBtnSend,localDemoReply,titleFrom,serializeThreadName,deserializeThreadName,ensureThreadOnFirstUser,generateTitleWithAI,threadRow,renderThreads,hideThreadPopover,showThreadPopover,hideSunePopover,showSunePopover,hideSuneSyncPopover,showSuneSyncPopover,hideSunesSyncPopover,showSunesSyncPopover,setSuneSyncStatus,checkSuneSyncStatus,performSuneDownload,performSuneUpload,updateAttachBadge,toAttach,ensureJars,openSettings,closeSettings,showTab,dl,ts,kbUpdate,kbBind,activeMeta,init,showHtmlTab,showAccountTab,openAccountSettings,closeAccountSettings,getBubbleById,getActiveJar,imgToWebp,ghApi,parseGhUrl,ghGetFileContent,pullThreads,suneStorage,gcStorage,cleanSuneStorage}); diff --git a/src/streaming.js b/src/streaming.js index 2f8498e..09d2e7f 100644 --- a/src/streaming.js +++ b/src/streaming.js @@ -1,14 +1,20 @@ -export const HTTP_BASE='https://us.proxy.sune.chat/ws' +import { el } from './dom.js'; +import { partsToText } from './utils.js'; +import { renderMarkdown, enhanceCodeBlocks } from './markdown.js'; +import { SUNE_LOGO_SVG } from './sune-logo.js'; + +// The proxy's maxPayloadLength; it drops anything bigger with no useful close code +const WS_URL='wss://us.proxy.sune.chat/ws',MAX_REQ=100*1024*1024,LIVE_KEY='threads_live'; export const buildBody=()=>{ const {USER,SUNE,state,payloadWithSampling}=window; const msgs=[]; - + const mPrompt = (USER.masterPrompt || '').trim(); if(mPrompt && !SUNE.ignore_master_prompt) { msgs.push({role:'system', content: mPrompt}); } - + const modelName=SUNE.model.replace(/^(or:|oai:|g:|cla:)/,'').replace(/^[^/]*\//,'').replace(/(?::online|:free)+$/,''); const sPrompt = (SUNE.system_prompt || '').trim().replaceAll('{{model_name}}',()=>modelName); if(sPrompt) { @@ -17,10 +23,10 @@ export const buildBody=()=>{ state.messages.filter(m=>m.role!=='system').forEach(m=>{ let content = Array.isArray(m.content) ? [...m.content] : [{type:'text',text:String(m.content||'')}]; - + // Filter out empty text parts which cause 400 errors on strict providers like Moonshot content = content.filter(p => p.type !== 'text' || (p.text && p.text.trim().length > 0)); - + msgs.push({ role: m.role, content: content, @@ -43,24 +49,151 @@ export const buildBody=()=>{ return b } -async function streamORP(body,onDelta,streamId){ - const {USER,SUNE,state,gid,cacheStore}=window; - const model=SUNE.model,provider=model.startsWith('oai:')?'openai':model.startsWith('g:')?'google':model.startsWith('cla:')?'claude':model.startsWith('or:')?'openrouter':USER.provider; - const apiKey=provider==='openai'?USER.apiKeyOpenAI:provider==='google'?USER.apiKeyGoogle:provider==='claude'?USER.apiKeyClaude:USER.apiKeyOpenRouter; - if(!apiKey){onDelta(window.localDemoReply(),true);return {ok:true,rid:streamId||null}} - const r={rid:streamId||gid(),seq:-1,done:false,signaled:false,ws:null}; - await cacheStore.setItem(r.rid,'busy'); - const signal=t=>{if(!r.signaled){r.signaled=true;onDelta(t||'',true)}}; - const ws=new WebSocket(HTTP_BASE.replace('https','wss')+'?uid='+encodeURIComponent(r.rid)); - r.ws=ws; - ws.onopen=()=>ws.send(JSON.stringify({type:'begin',rid:r.rid,provider,apiKey,or_body:body})); - 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,m.images)}else if(m.type==='done'||m.type==='err'){r.done=true;cacheStore.setItem(r.rid,'done');signal(m.type==='err'?'\n\n'+(m.message||'error'):'');ws.close()}}; - ws.onclose=()=>{};ws.onerror=()=>{}; - state.controller={abort:()=>{r.done=true;cacheStore.setItem(r.rid,'done');try{if(ws.readyState===1)ws.send(JSON.stringify({type:'stop',rid:r.rid}))}catch{};signal('')},disconnect:()=>ws.close()}; - return {ok:true,rid:r.rid} +/* Replies stream over one socket each and outlive the view: they keep saving into their own thread while + you're elsewhere, reconnect on drops, and get picked back up after a reload. A streaming message carries + stream:{seq} (the last chunk it holds) until it's finished. */ +const live=new Map(); +const wsUrl=rid=>`${WS_URL}?rid=${encodeURIComponent(rid)}`; +const threadKey=tid=>(el.threadRepoInput.value.trim().startsWith('gh://')?'rem_t_':'t_')+tid; +const saveLive=()=>localStorage.setItem(LIVE_KEY,JSON.stringify([...live.values()].map(s=>[s.rid,s.tid,s.key]))); +const onScreen=s=>window.state.currentThreadId===s.tid&&window.state.messages.includes(s.msg); + +export const curReply=()=>[...live.values()].find(s=>s.tid===window.state.currentThreadId); + +export function syncBtn(){ + const on=!!curReply();window.state.busy=on; + if(el.sendBtn.dataset.mode!==(on?'stop':'send'))on?window.setBtnStop():window.setBtnSend() } -export async function streamChat(onDelta,streamId){ - const body=buildBody(); - return await streamORP(body,onDelta,streamId) +function paint(s,final){ + const b=onScreen(s)&&window.getBubbleById(s.rid);if(!b)return; + const t=partsToText(s.msg); + if(!t&&!final)return b.innerHTML=SUNE_LOGO_SVG; + renderMarkdown(b,t,{enhance:false});if(final)enhanceCodeBlocks(b,true) } + +async function write(s){ + const {THREAD}=window; + if(onScreen(s))return THREAD.persist(!s.msg.stream); + const a=await localforage.getItem(s.key),i=Array.isArray(a)?a.findIndex(m=>m?.id===s.rid):-1; + if(i<0)return drop(s); + a[i]=s.msg;await localforage.setItem(s.key,a); + const th=!s.msg.stream&&THREAD.get(s.tid); + if(th){th.updatedAt=Date.now();if(th.status==='synced')th.status='modified';await THREAD.save();window.renderThreads()} +} + +// Coalesced: at most one write waiting behind the running one, and it saves whatever is current when it runs +function save(s){ + if(s.queued)return;s.queued=1; + s.q=s.q.then(()=>{s.queued=0;return write(s)}).catch(e=>console.error('[Sune] Saving reply failed',e)) +} + +function end(s){ + s.done=true;clearTimeout(s.timer);clearTimeout(s.probe);live.delete(s.rid);saveLive(); + const ws=s.ws;s.ws=s.req=null;try{ws?.close()}catch{} +} + +function finish(s,note=''){ + if(s.done)return;end(s); + if(note)s.msg.content[0].text+=note; + delete s.msg.stream;paint(s,true);save(s);syncBtn(); + if(onScreen(s))el.composer.dispatchEvent(new CustomEvent('sune:newSuneResponse',{detail:{message:s.msg}})) +} + +// Stops the run server-side too; without an open socket a throwaway one carries the stop +function sendStop(s){ + if(s.ws?.readyState===1)return s.ws.send('{"type":"stop"}'); + const w=new WebSocket(wsUrl(s.rid));w.onopen=()=>{w.send('{"type":"stop"}');w.close()} +} + +// For a deleted message or thread: stop without saving anything +function drop(s){if(s.done)return;sendStop(s);end(s);syncBtn()} + +export function stopReply(s,note){if(!s||s.done)return;sendStop(s);finish(s,note)} +export const dropReply=rid=>live.has(rid)&&drop(live.get(rid)); +export const dropThread=tid=>live.forEach(s=>s.tid===tid&&drop(s)); + +function onMessage(s,m){ + clearTimeout(s.probe);s.probe=null;s.lostAt=null;s.tries=0; + switch(m.type){ + case 'ack':s.req=null;return; + case 'delta':{ + if(m.seq<=s.msg.stream.seq)return;s.req=null; + s.msg.content[0].text+=m.text||'';if(m.images?.length)(s.msg.images||=[]).push(...m.images); + s.msg.stream.seq=m.seq;paint(s);return save(s) + } + case 'done':return finish(s); + case 'gone':return finish(s,'\n\n_Reply interrupted: the server no longer has the rest of it._'); + case 'err':return finish(s,'\n\n'+(m.message||'error')) + } +} + +function connect(s){ + if(s.done)return;clearTimeout(s.timer); + const ws=s.ws=new WebSocket(wsUrl(s.rid)); + ws.onopen=()=>ws.send(s.req||JSON.stringify({type:'resume',after:s.msg.stream.seq})); + ws.onmessage=e=>{if(s.ws!==ws)return;let m;try{m=JSON.parse(e.data)}catch{return}onMessage(s,m)}; + ws.onclose=()=>{ + if(s.ws!==ws||s.done)return;s.ws=null;retry(s) + } +} + +// Backs off up to 15s; gives up after 30s if the run never started, else once the server can't still have it +function retry(s){ + s.lostAt??=Date.now(); + if(Date.now()-s.lostAt>(s.req?3e4:35*6e4))return stopReply(s,s.req?'\n\nCouldn’t reach the server.':'\n\n_Reply interrupted: lost the connection to it._'); + clearTimeout(s.timer);s.timer=setTimeout(()=>connect(s),Math.min(15e3,500*2**s.tries++)) +} + +// Reconnects now if the socket is gone, or pings an open one and replaces it if it's silently dead +function kick(s){ + if(s.done)return;const ws=s.ws; + if(!ws||ws.readyState>1)return connect(s); + if(ws.readyState===0||s.req||s.probe)return; + ws.send('{"type":"ping"}'); + s.probe=setTimeout(()=>{s.probe=null;if(s.ws!==ws||s.done)return;s.ws=null;try{ws.close()}catch{}connect(s)},5e3) +} + +function track(msg,tid,key){ + const s={rid:msg.id,tid,key,msg,req:null,q:Promise.resolve(),tries:0}; + live.set(s.rid,s);saveLive();return s +} + +export function startReply(msg){ + const {USER,SUNE,state}=window; + const model=SUNE.model,provider=model.startsWith('oai:')?'openai':model.startsWith('g:')?'google':model.startsWith('cla:')?'claude':model.startsWith('or:')?'openrouter':USER.provider; + const apiKey=provider==='openai'?USER.apiKeyOpenAI:provider==='google'?USER.apiKeyGoogle:provider==='claude'?USER.apiKeyClaude:USER.apiKeyOpenRouter; + const s=track(msg,state.currentThreadId,threadKey(state.currentThreadId)); + if(!apiKey){msg.content[0].text=window.localDemoReply();return finish(s)} + s.req=JSON.stringify({type:'begin',provider,apiKey,body:buildBody()}); + if(s.req.length>MAX_REQ-(1<<20))return finish(s,'\n\nThis request is over the server’s 100 MB limit.'); + connect(s);syncBtn() +} + +// Call after a thread's messages are rendered: swaps in the live copies of replies still streaming and resumes the rest +export function adoptThread(){ + const {state}=window,tid=state.currentThreadId; + state.messages=state.messages.map(m=>{const s=live.get(m.id);return s?.tid===tid?s.msg:m}); + for(const m of state.messages){ + if(!m.stream)continue; + let s=live.get(m.id); + if(!s)connect(s=track(m,tid,threadKey(tid))); + paint(s) + } + syncBtn() +} + +// Picks up replies that were streaming when the app closed, wherever their threads are +export async function restoreLive(){ + let list=[];try{list=JSON.parse(localStorage.getItem(LIVE_KEY)||'[]')}catch{} + for(const [rid,tid,key] of list){ + const a=!live.has(rid)&&await localforage.getItem(key),m=Array.isArray(a)&&a.find(x=>x?.id===rid); + if(m?.stream&&!live.has(rid))connect(track(m,tid,key)) + } + saveLive() +} + +const kickAll=()=>live.forEach(kick); +document.addEventListener('visibilitychange',()=>document.visibilityState==='visible'&&kickAll()); +addEventListener('online',kickAll); +setInterval(()=>document.visibilityState==='visible'&&kickAll(),2e4);