mirror of
https://github.com/sune-org/sune.git
synced 2026-10-13 14:15:21 +00:00
Videos are compressed with Mediabunny to 360p at 4 fps (HEVC when the device can encode it, H.264 otherwise) with mono 32 kbps AAC, then stored as video_url data URLs. Frames are resized after the frame-rate drop, and rotation is baked in during that same step, so phone videos stay on the fast path. The attach badge shows compression progress, sending waits for pending compressions, and "Clear all attachments" cancels them. Messages show video thumbnails that open a fullscreen player. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
387 lines
55 KiB
JavaScript
387 lines
55 KiB
JavaScript
import {streamChat,HTTP_BASE} from './streaming.js';
|
|
import {SUNE_LOGO_SVG} from './sune-logo.js';
|
|
import {STICKY_SUNES} from './sticky-sunes.js';
|
|
import {generateTitleWithAI} from './title-generator.js';
|
|
import { el } from './dom.js';
|
|
import { clamp, num, int, gid, esc, positionPopover, sid, fmtSize, asDataURL, imgToWebp, b64, utob, btou, dl, ts, partsToText, copyToClipboard } from './utils.js';
|
|
import { ghApi, parseGhUrl, ghGetFileContent } from './github.js';
|
|
import { USER } from './user.js';
|
|
import { md, enhanceCodeBlocks, renderMarkdown } from './markdown.js';
|
|
import { kbUpdate, kbBind } from './keyboard.js';
|
|
import { toAttach } from './attachments.js';
|
|
import { videoStrip, revokeVideoUrls } from './video.js';
|
|
import { titleFrom, serializeThreadName, deserializeThreadName } from './threads-utils.js';
|
|
import { resolveSuneSrc, processSuneIncludes, renderSuneHTML } from './sune-html.js';
|
|
|
|
(()=>{let k,v=visualViewport;const f=()=>{removeEventListener('popstate',f),document.activeElement?.blur()};v.onresize=()=>{let o=v.height<innerHeight;o!=k&&((k=o)?(history.pushState({k:1},''),addEventListener('popstate',f)):(removeEventListener('popstate',f),history.state?.k&&history.back()))}})()
|
|
const DEFAULT_MODEL='openrouter/free'
|
|
const icons=()=>window.lucide&&lucide.createIcons()
|
|
const haptic=()=>/android/i.test(navigator.userAgent)&&navigator.vibrate?.(1)
|
|
|
|
const SYSTEM_KEYS=new Set(['sunes_v1','active_sune_id','thread_repo_url','sune_repo_url','sunes_updated_at','user_name','user_avatar','provider','openrouter_api_key','openai_api_key','google_api_key','claude_api_key','cloudflare_api_key','master_prompt','title_model','gh_token','custom_key_1'])
|
|
const gcStorage=()=>{try{const alive=new Set(sunes.map(s=>s.id));Object.keys(localStorage).forEach(k=>{if(SYSTEM_KEYS.has(k)||/^(t_|rem_t_|rem_index_|localforage|threads_)/.test(k))return;const m=k.match(/^sune_([^_]+)_/);if(m&&alive.has(m[1]))return;localStorage.removeItem(k)})}catch{}}
|
|
const cleanSuneStorage=id=>{if(!id)return;const p=`sune_${id}_`;Object.keys(localStorage).forEach(k=>{if(k.startsWith(p))localStorage.removeItem(k)})}
|
|
|
|
let isSyncPulling=false,suneSyncStatus='idle',remoteSha=null;
|
|
const getLocalSunesUpdatedAt=()=>num(localStorage.getItem('sunes_updated_at'),0);
|
|
const setLocalSunesUpdatedAt=(ts=Date.now())=>localStorage.setItem('sunes_updated_at',ts);
|
|
const markLocalDirty=()=>{if(isSyncPulling)return;setLocalSunesUpdatedAt();setSuneSyncStatus('desynced')};
|
|
const isSuneStorageKey=k=>!SYSTEM_KEYS.has(k)&&/^sune_[^_]+_/.test(k);
|
|
const _origSetItem=localStorage.setItem.bind(localStorage),_origRemoveItem=localStorage.removeItem.bind(localStorage);
|
|
localStorage.setItem=(k,v)=>{_origSetItem(k,v);if(!isSyncPulling&&isSuneStorageKey(k))markLocalDirty()};
|
|
localStorage.removeItem=k=>{_origRemoveItem(k);if(!isSyncPulling&&isSuneStorageKey(k))markLocalDirty()};
|
|
|
|
function setSuneSyncStatus(status){
|
|
suneSyncStatus=status;
|
|
const b=el.suneSyncBadge;
|
|
if(!b)return;
|
|
const u=(el.suneRepoInput?.value||'').trim();
|
|
if(!u.startsWith('gh://')){b.className='hidden';return}
|
|
b.className='absolute -top-1 -right-1 block h-2.5 w-2.5 rounded-full ring-2 ring-white';
|
|
switch(status){
|
|
case 'checking':b.classList.add('bg-blue-500','animate-pulse');break;
|
|
case 'synced':b.classList.add('bg-green-500');break;
|
|
case 'desynced':b.classList.add('bg-amber-500');break;
|
|
case 'error':b.classList.add('bg-red-500');break;
|
|
default:b.classList.add('bg-gray-400');
|
|
}
|
|
el.suneSyncBtn?.querySelector('svg')?.classList.toggle('animate-spin',status==='checking');
|
|
}
|
|
|
|
const suneStorage={
|
|
get(k,def=null){const id=SUNE.id;if(!id)return def;const v=localStorage.getItem(`sune_${id}_${k}`);if(v===null)return def;try{return JSON.parse(v)}catch{return v}},
|
|
set(k,v){const id=SUNE.id;if(id)localStorage.setItem(`sune_${id}_${k}`,JSON.stringify(v))},
|
|
remove(k){const id=SUNE.id;if(id)localStorage.removeItem(`sune_${id}_${k}`)},
|
|
clear(){const id=SUNE.id;if(id)cleanSuneStorage(id)},
|
|
keys(){const id=SUNE.id;if(!id)return[];const p=`sune_${id}_`;return Object.keys(localStorage).filter(k=>k.startsWith(p)).map(k=>k.slice(p.length))},
|
|
key(k){const id=SUNE.id;return id?`sune_${id}_${k}`:k}
|
|
}
|
|
|
|
const su={key:'sunes_v1',activeKey:'active_sune_id',load(){try{return JSON.parse(localStorage.getItem(this.key)||'[]')}catch{return[]}},save(list){localStorage.setItem(this.key,JSON.stringify(list||[]))},getActiveId(){return localStorage.getItem(this.activeKey)||null},setActiveId(id){localStorage.setItem(this.activeKey,id||'')}}
|
|
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:"<sune src='https://raw.githubusercontent.com/sune-org/store/refs/heads/main/sync.sune' private></sune>",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}})
|
|
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 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?`<img src="${esc(a.avatar)}" alt="" class="h-10 w-10 rounded-full object-cover"/>`:'✺';el.footer.classList.toggle('hidden',!!a.settings.hide_composer);await renderSuneHTML();icons()}
|
|
const suneRow=a=>`<div class="relative flex items-center gap-2 px-3 py-2 ${a.pinned?'bg-yellow-50':''}"><button data-sune-id="${a.id}" class="flex-1 text-left flex items-center gap-2 ${a.id===SUNE.id?'font-medium':''}">${a.avatar?`<img src="${esc(a.avatar)}" alt="" class="h-8 w-8 rounded-full object-cover"/>`:`<span class="h-6 w-6 rounded-full bg-gray-200 flex items-center justify-center">✺</span>`}<span class="truncate">${a.pinned?'📌 ':''}${esc(a.name)}</span></button><button data-sune-menu="${a.id}" class="h-8 w-8 rounded hover:bg-gray-100 flex items-center justify-center" title="More"><i data-lucide="more-horizontal" class="h-4 w-4"></i></button></div>`
|
|
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=`<img src="${esc(a)}" class="h-full w-full object-cover"/>`}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=$('<div class="flex flex-col gap-2"></div>'),$head=$('<div class="flex items-center gap-2 px-4"></div>'),$avatar=$('<div></div>');const uAva=isUser?USER.avatar:meta.avatar;uAva?$avatar.attr('class','msg-avatar shrink-0 h-7 w-7 rounded-full overflow-hidden').html(`<img src="${esc(uAva)}" class="h-full w-full object-cover">`):$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=$('<div class="text-xs font-medium text-gray-500"></div>').text(isUser?USER.name:getSuneLabel(meta));const $deleteBtn=$('<button class="p-1.5 rounded-lg hover:bg-gray-200 text-gray-400 hover:text-red-500" title="Delete message"><i data-lucide="x" class="h-4 w-4"></i></button>').on('click',async e=>{e.stopPropagation();state.messages=state.messages.filter(msg=>msg.id!==m.id);$row.remove();await THREAD.persist()});const $copyBtn=$('<button class="ml-auto p-1.5 rounded-lg hover:bg-gray-200 text-gray-400 hover:text-gray-600" title="Copy message"><i data-lucide="copy" class="h-4 w-4"></i></button>').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('<i data-lucide="check" class="h-4 w-4 text-green-500"></i>');icons();setTimeout(()=>{$(this).html('<i data-lucide="copy" class="h-4 w-4"></i>');icons()},1200)}});$head.append($avatar,$name,$copyBtn,$deleteBtn);const $bubble=$(`<div class="${(isUser?'bg-gray-50 border border-gray-200':'bg-gray-100')+' msg-bubble markdown-body rounded-none px-4 py-3 w-full'}"></div>`);$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 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='<i data-lucide="square" class="h-5 w-5"></i>';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='<i data-lucide="sparkles" class="h-5 w-5"></i>';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'});
|
|
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 `<div class=\"relative flex items-center gap-2 px-3 py-2 ${t.pinned?'bg-yellow-50':''}\"><button data-open-thread=\"${t.id}\" data-type=\"${t.type||'thread'}\" class=\"flex-1 text-left truncate flex items-center gap-2\">${icon?`<i data-lucide="${icon}" class="h-4 w-4"></i>`:''}${t.pinned?'📌 ':''}${esc(t.title||'Untitled')}${t.status==='modified'?'*':(t.status==='new'?'+':'')}</button><button data-thread-menu=\"${t.id}\" class=\"h-8 w-8 rounded hover:bg-gray-100 flex items-center justify-center\" title=\"More\"><i data-lucide=\"more-horizontal\" class="h-4 w-4"></i></button></div>`}
|
|
let sortedThreads=[],isAddingThreads=false;const THREAD_PAGE_SIZE=50;
|
|
async function renderThreads(){
|
|
sortedThreads=[...THREAD.list].filter(t=>t.status!=='deleted').sort((a,b)=>{
|
|
const r=t=>t.type==='folder'?0:t.type==='file'?1:2;
|
|
return (r(a)-r(b))||(b.pinned-a.pinned)||(b.updatedAt-a.updatedAt);
|
|
});
|
|
el.threadList.innerHTML=sortedThreads.slice(0,THREAD_PAGE_SIZE).map(threadRow).join('');
|
|
el.threadList.scrollTop=0;
|
|
isAddingThreads=false;
|
|
icons()
|
|
}
|
|
let menuThreadId=null;const hideThreadPopover=()=>{el.threadPopover.classList.add('hidden');menuThreadId=null}
|
|
function showThreadPopover(btn,id){menuThreadId=id;el.threadPopover.classList.remove('hidden');positionPopover(btn,el.threadPopover);icons()}
|
|
let menuSuneId=null;const hideSunePopover=()=>{el.sunePopover.classList.add('hidden');menuSuneId=null}
|
|
function showSunePopover(btn,id){menuSuneId=id;el.sunePopover.classList.remove('hidden');positionPopover(btn,el.sunePopover);icons()}
|
|
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('scroll',()=>{
|
|
if(isAddingThreads||el.threadList.scrollTop+el.threadList.clientHeight<el.threadList.scrollHeight-200)return;
|
|
const c=el.threadList.children.length;
|
|
if(c>=sortedThreads.length)return;
|
|
isAddingThreads=true;
|
|
const b=sortedThreads.slice(c,c+THREAD_PAGE_SIZE);
|
|
if(b.length){
|
|
el.threadList.insertAdjacentHTML('beforeend',b.map(threadRow).join(''));
|
|
icons();
|
|
}
|
|
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.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
|
|
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(()=>{})}
|
|
const hideAttachPopover=()=>el.attachPopover.classList.add('hidden')
|
|
function showAttachPopover(){if((!state.attachments.length&&!attachJob)||!el.attachPopover.classList.contains('hidden'))return;haptic();el.attachPopover.classList.remove('hidden');positionPopover(el.attachBtn,el.attachPopover);icons()}
|
|
let attachHold=null,attachHeld=false;const holdAttach=()=>{attachHeld=true;showAttachPopover()}
|
|
$(el.attachBtn).on('pointerdown',e=>{attachHeld=false;if(e.pointerType!=='mouse')attachHold=setTimeout(holdAttach,500)}).on('pointerup pointercancel pointerleave',()=>clearTimeout(attachHold)).on('contextmenu',e=>{e.preventDefault();holdAttach()}).on('click',()=>{if(attachHeld)return attachHeld=false;hideAttachPopover();el.fileInput.click()})
|
|
$(el.attachPopover).on('click',e=>{if(!e.target.closest('[data-action="clear"]'))return;clearAttachments();hideAttachPopover()})
|
|
$(document).on('click',e=>{if(!el.attachPopover.contains(e.target)&&!el.attachBtn.contains(e.target))hideAttachPopover()})
|
|
$(window).on('resize',()=>{if(!el.attachPopover.classList.contains('hidden'))positionPopover(el.attachBtn,el.attachPopover)})
|
|
$(el.fileInput).on('change',()=>{const files=[...(el.fileInput.files||[])];el.fileInput.value='';addAttachments(files)})
|
|
$(el.composer).on('paste',async e=>{
|
|
const cbd=e.clipboardData||e.originalEvent?.clipboardData,files=[...(cbd?.files||[])];
|
|
if(!files.length)return;
|
|
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)})
|
|
let jars={html:null,extension:null};
|
|
const ensureJars=async()=>{
|
|
if(jars.html&&jars.extension)return jars;
|
|
const mod=await import('https://medv.io/codejar/codejar.js'),CodeJar=mod.CodeJar||mod.default;
|
|
const hl=e=>{
|
|
const code=e.innerText;
|
|
e.innerHTML=hljs.highlight(code,{language:'xml'}).value;
|
|
};
|
|
if(!jars.html)jars.html=CodeJar(el.htmlEditor,hl,{tab:' '});
|
|
if(!jars.extension)jars.extension=CodeJar(el.extensionHtmlEditor,hl,{tab:' '});
|
|
return jars;
|
|
}
|
|
let openedHTML=false
|
|
function openSettings(){const a=SUNE.active,s=a.settings;openedHTML=false;el.suneURL.value=a.url||'';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_repetition_penalty.value=s.repetition_penalty;el.set_min_p.value=s.min_p;el.set_top_a.value=s.top_a;el.set_max_tokens.value=s.max_tokens||'';el.set_reasoning_effort.value=s.reasoning_effort||'';el.set_system_prompt.value=s.system_prompt;el.set_hide_composer.checked=!!s.hide_composer;el.set_img_output.checked=!!s.img_output;el.set_aspect_ratio.value=s.aspect_ratio||'1:1';el.set_image_size.value=s.image_size||'1K';el.aspectRatioContainer.classList.toggle('hidden',!s.img_output);el.set_include_thoughts.checked=!!s.include_thoughts;el.set_ignore_master_prompt.checked=!!s.ignore_master_prompt;showTab('Model');el.suneModal.classList.remove('hidden')}
|
|
const closeSettings=()=>{el.suneModal.classList.add('hidden')}
|
|
const tabs={Model:['tabModel','panelModel'],Prompt:['tabPrompt','panelPrompt'],Script:['tabScript','panelScript']}
|
|
function showTab(key){Object.entries(tabs).forEach(([k,[tb,pn]])=>{el[tb].classList.toggle('border-black',k===key);el[pn].classList.toggle('hidden',k!==key)});if(key==='Script'){openedHTML=true;showHtmlTab('index');ensureJars().then(({html,extension})=>{const s=SUNE.settings;html.updateCode(s.html||'');extension.updateCode(s.extension_html||'')})}}
|
|
$(el.suneBtnTop).on('click',openSettings)
|
|
$(el.cancelSettings).on('click',closeSettings)
|
|
$(el.suneModal).on('click',e=>{if(e.target===el.suneModal||e.target.classList.contains('bg-black/30'))closeSettings()})
|
|
$(el.tabModel).on('click',()=>showTab('Model'))
|
|
$(el.tabPrompt).on('click',()=>showTab('Prompt'))
|
|
$(el.tabScript).on('click',()=>showTab('Script'))
|
|
$(el.set_img_output).on('change',e=>el.aspectRatioContainer.classList.toggle('hidden',!e.target.checked))
|
|
$(el.settingsForm).on('submit',async e=>{e.preventDefault();SUNE.url=(el.suneURL.value||'').trim();SUNE.model=(el.set_model.value||'').trim();['temperature','top_p','top_k','frequency_penalty','repetition_penalty','min_p','top_a','max_tokens'].forEach(k=>SUNE[k]=el[`set_${k}`].value.trim());SUNE.reasoning_effort=(el.set_reasoning_effort.value||'');SUNE.system_prompt=el.set_system_prompt.value.trim();SUNE.hide_composer=el.set_hide_composer.checked;SUNE.img_output=el.set_img_output.checked;SUNE.aspect_ratio=el.set_aspect_ratio.value;SUNE.image_size=el.set_image_size.value;SUNE.include_thoughts=el.set_include_thoughts.checked;SUNE.ignore_master_prompt=el.set_ignore_master_prompt.checked;if(openedHTML){SUNE.html=jars.html.toString();SUNE.extension_html=jars.extension.toString()}closeSettings();await reflectActiveSune()})
|
|
$(el.deleteSuneBtn).on('click',async()=>{const activeId=SUNE.id,name=SUNE.name||'this sune';if(!confirm(`Delete "${name}"?`))return;SUNE.delete(activeId);renderSidebar();await reflectActiveSune();state.currentThreadId=null;clearChat();closeSettings()})
|
|
$(el.newSuneBtn).on('click',async()=>{const name=prompt('Name your sune:');if(!name)return;const sune=SUNE.create({name:name.trim()});SUNE.setActive(sune.id);renderSidebar();await reflectActiveSune();state.currentThreadId=null;clearChat();document.getElementById('sidebarLeft').classList.add('-translate-x-full');document.getElementById('sidebarOverlayLeft').classList.add('hidden')})
|
|
let importMode=null
|
|
$(el.sunesExportOption).on('click',()=>{dl(`sunes-${ts()}.sune`,{version:1,sunes:SUNE.list,activeId:SUNE.id});el.userMenu.classList.add('hidden')})
|
|
$(el.sunesImportOption).on('click',()=>{importMode='sunes';el.importInput.value='';el.importInput.click()})
|
|
$(el.threadsImportOption).on('click',()=>{importMode='threads';el.importInput.value='';el.importInput.click()})
|
|
$(el.importInput).on('change',async()=>{const file=el.importInput.files?.[0];if(!file)return;try{const text=await file.text();const data=JSON.parse(text);if(importMode==='sunes'){const list=Array.isArray(data)?data:(Array.isArray(data.sunes)?data.sunes:[]);if(!list.length)throw new Error('No sunes');const incoming=list.map(a=>makeSune(a||{}));const map={};incoming.forEach(s=>{if(!s.id)s.id=gid();const k=s.id,prev=map[k];map[k]=!prev||(+s.updatedAt>+prev.updatedAt)?s:prev});let added=0,updated=0;const idx=Object.fromEntries(sunes.map(s=>[s.id,s]));Object.values(map).forEach(s=>{const ex=idx[s.id];if(!ex){sunes.push(s);added++}else if(+s.updatedAt>+ex.updatedAt){Object.assign(ex,s);updated++}});SUNE.save();if(data.activeId&&sunes.some(x=>x.id===data.activeId))SUNE.setActive(data.activeId);renderSidebar();await reflectActiveSune();state.currentThreadId=null;clearChat();alert(`${added} new, ${updated} updated.`)}else if(importMode==='threads'){if(!data||!data.id||!Array.isArray(data.messages))throw new Error('Invalid thread format');const u=el.threadRepoInput.value.trim(),prefix=u.startsWith('gh://')?'rem_t_':'t_';const norm=t=>({id:t.id||gid(),title:titleFrom(t.title||t.messages),pinned:!!t.pinned,updatedAt:num(t.updatedAt,Date.now()),type:'thread',...(u.startsWith('gh://')?{status:'new'}:{})});const n=norm(data),msgs=data.messages,idx=THREAD.list.findIndex(x=>x.id===n.id);if(idx>-1){if(n.updatedAt>THREAD.list[idx].updatedAt){THREAD.list[idx]=n;await localforage.setItem(prefix+n.id,msgs)}}else{THREAD.list.unshift(n);await localforage.setItem(prefix+n.id,msgs)}await THREAD.save();await renderThreads();alert('Thread imported.')}el.userMenu.classList.add('hidden')}catch{alert('Import failed')}finally{importMode=null}})
|
|
function activeMeta(){return {sune_name:SUNE.name,model:SUNE.model,avatar:SUNE.avatar}}
|
|
|
|
window.USER = USER;
|
|
USER.log = async s => {
|
|
const t = String(s ?? '').trim();
|
|
if (!t) return;
|
|
await ensureThreadOnFirstUser(t);
|
|
addMessage({ role: 'user', content: [{ type: 'text', text: t }] });
|
|
await THREAD.persist();
|
|
};
|
|
USER.logMany = async msgs => {
|
|
if (!Array.isArray(msgs) || !msgs.length) return;
|
|
const clean = msgs.map(s => String(s ?? '').trim()).filter(Boolean);
|
|
if (!clean.length) return;
|
|
await ensureThreadOnFirstUser(clean[0]);
|
|
const newMsgs = clean.map(t => ({ id: gid(), role: 'user', content: [{ type: 'text', text: t }] }));
|
|
state.messages.push(...newMsgs);
|
|
const frag = document.createDocumentFragment();
|
|
const newEls = newMsgs.map(m => {
|
|
const $row = _createMessageRow(m), bubble = $row.find('.msg-bubble')[0];
|
|
bubble.dataset.mid = m.id; return { rowEl: $row[0], bubbleEl: bubble, message: m };
|
|
});
|
|
newEls.forEach(item => frag.appendChild(item.rowEl));
|
|
el.messages.appendChild(frag);
|
|
queueMicrotask(() => {
|
|
newEls.forEach(item => { renderMarkdown(item.bubbleEl, partsToText(item.message)) });
|
|
el.chat.scrollTo({ top: el.chat.scrollHeight, behavior: 'smooth' });
|
|
icons();
|
|
});
|
|
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()}
|
|
$(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)})}
|
|
el.htmlTab_index.textContent='index.html';el.htmlTab_extension.textContent='extension.html';
|
|
el.htmlTab_index.onclick=()=>showHtmlTab('index');el.htmlTab_extension.onclick=()=>showHtmlTab('extension');
|
|
const pullThreads=async(invalidateAll=false)=>{
|
|
const u=el.threadRepoInput.value.trim();if(!u.startsWith('gh://'))return;
|
|
const info=parseGhUrl(u);
|
|
try{
|
|
const items=await ghApi(`${info.apiPath}?ref=${info.branch}`);
|
|
const local=await localforage.getItem('rem_index_'+u.substring(5))||[];
|
|
const remote=(items||[]).map(i=>{
|
|
if(i.type==='dir')return {id:i.name,title:i.name,type:'folder',updatedAt:0};
|
|
if(i.type==='file'&&i.name.endsWith('.md'))return {id:i.path,title:i.name,type:'file',updatedAt:0};
|
|
const d=deserializeThreadName(i.name);return d?{...d,status:'synced'}:null;
|
|
}).filter(Boolean);
|
|
for(const t of invalidateAll?[...local,...remote]:local){
|
|
if(t.type!=='thread')continue;
|
|
const next=remote.find(r=>r.type==='thread'&&r.id===t.id);
|
|
if(invalidateAll||t.status!=='synced'||!next||next.updatedAt>t.updatedAt)await localforage.removeItem('rem_t_'+t.id);
|
|
}
|
|
THREAD.list=remote;
|
|
await THREAD.save();
|
|
await renderThreads();
|
|
return true;
|
|
}catch(e){console.error('Auto-pull failed:',e);return false}
|
|
};
|
|
$(el.threadRepoInput).on('change',async()=>{const u=el.threadRepoInput.value.trim();localStorage.setItem('thread_repo_url',u);if(state.currentThreadId){state.currentThreadId=null;clearChat()}el.threadFolderBtn.classList.toggle('hidden',!u.startsWith('gh://'));el.threadBackBtn.classList.toggle('hidden',!u.startsWith('gh://')||u.split('/').length<=3);if(u.startsWith('gh://'))await pullThreads();else{await THREAD.load();await renderThreads()}});
|
|
$(el.threadBackBtn).on('click',()=>{const u=el.threadRepoInput.value.trim();if(!u.startsWith('gh://'))return;const p=u.split('/');if(p.length>3){p.pop();el.threadRepoInput.value=p.join('/');el.threadRepoInput.dispatchEvent(new Event('change'))}});
|
|
$(el.threadFolderBtn).on('click',async()=>{const n=prompt('Folder name:');if(!n)return;THREAD.list.unshift({id:n.trim(),title:n.trim(),type:'folder',updatedAt:Date.now()});await THREAD.save();await renderThreads()});
|
|
$(el.threadSyncBtn).on('click',async()=>{const u=el.threadRepoInput.value.trim();if(!u.startsWith('gh://'))return;const mode=confirm('Sync Threads:\nOK = Upload (Push)\nCancel = Download (Pull)');const info=parseGhUrl(u);try{if(mode){const remoteItems=await ghApi(`${info.apiPath}?ref=${info.branch}`)||[],remoteMap={};remoteItems.forEach(i=>{const d=deserializeThreadName(i.name);if(d)remoteMap[d.id]={name:i.name,sha:i.sha}});const toRemove=[];for(const t of THREAD.list){if(t.status==='deleted'){if(remoteMap[t.id]){await ghApi(`${info.apiPath}/${remoteMap[t.id].name}`,'DELETE',{message:`Delete thread ${t.id}`,sha:remoteMap[t.id].sha,branch:info.branch});await localforage.removeItem('rem_t_'+t.id)}toRemove.push(t.id);continue}if(t.type!=='thread')continue;if(t.status==='modified'||t.status==='new'){const newName=serializeThreadName(t);let msgs=await localforage.getItem('rem_t_'+t.id);if((!msgs||!Array.isArray(msgs))&&remoteMap[t.id]){const text=await ghGetFileContent(info,remoteMap[t.id].name);if(text){try{msgs=JSON.parse(text);await localforage.setItem('rem_t_'+t.id,msgs)}catch(e){console.error(e)}}}if(remoteMap[t.id]&&remoteMap[t.id].name!==newName){await ghApi(`${info.apiPath}/${remoteMap[t.id].name}`,'DELETE',{message:`Rename thread ${t.id}`,sha:remoteMap[t.id].sha,branch:info.branch})}const x=await ghApi(`${info.apiPath}/${newName}?ref=${info.branch}`);await ghApi(`${info.apiPath}/${newName}`,'PUT',{message:`Sync thread ${t.id}`,content:utob(JSON.stringify(msgs||[],null,2)),branch:info.branch,sha:x?.sha});t.status='synced'}}THREAD.list=THREAD.list.filter(x=>!toRemove.includes(x.id));await THREAD.save();alert('Pushed to GitHub.')}else if(await pullThreads(true)){state.currentThreadId=null;clearChat();alert('Pulled from GitHub.')}await renderThreads()}catch(e){alert('Sync failed: '+e.message)}});
|
|
|
|
let suneSyncBusy=false;
|
|
const checkSuneSyncStatus=async()=>{
|
|
const u=el.suneRepoInput.value.trim();
|
|
if(!u.startsWith('gh://'))return setSuneSyncStatus('idle');
|
|
if(suneSyncBusy)return;
|
|
suneSyncBusy=true;
|
|
setSuneSyncStatus('checking');
|
|
const info=parseGhUrl(u);
|
|
try{
|
|
const res=await ghApi(`${info.apiPath}/sunes.json?ref=${info.branch}`);
|
|
if(!res){remoteSha=null;return setSuneSyncStatus('desynced')}
|
|
remoteSha=res.sha;
|
|
let remoteData=null;
|
|
if(res.content&&res.encoding==='base64'){try{remoteData=JSON.parse(btou(res.content))}catch{}}
|
|
if(!remoteData&&res.sha){
|
|
const text=await ghGetFileContent(info,'sunes.json');
|
|
if(text){try{remoteData=JSON.parse(text)}catch{}}
|
|
}
|
|
if(!remoteData)return setSuneSyncStatus('error');
|
|
const remoteUp=num(remoteData.updatedAt,0),localUp=getLocalSunesUpdatedAt(),diff=remoteUp-localUp;
|
|
if(diff>10000)await performSuneDownload(true,remoteData,res.sha);
|
|
else if(diff<-10000)setSuneSyncStatus('desynced');
|
|
else setSuneSyncStatus('synced');
|
|
}catch{
|
|
setSuneSyncStatus('error');
|
|
}finally{
|
|
suneSyncBusy=false;
|
|
}
|
|
};
|
|
|
|
const performSuneDownload=async(isAuto=false,prefData=null,prefSha=null)=>{
|
|
const u=el.suneRepoInput.value.trim();
|
|
if(!u.startsWith('gh://'))return;
|
|
if(!isAuto&&!confirm('Overwrite local sunes with remote version from GitHub?'))return;
|
|
setSuneSyncStatus('checking');
|
|
const info=parseGhUrl(u);
|
|
try{
|
|
let data=prefData,sha=prefSha;
|
|
if(!data){
|
|
const res=await ghApi(`${info.apiPath}/sunes.json?ref=${info.branch}`);
|
|
if(!res)throw new Error('sunes.json not found');
|
|
sha=res.sha;
|
|
if(res.content&&res.encoding==='base64'){try{data=JSON.parse(btou(res.content))}catch{}}
|
|
if(!data){
|
|
const text=await ghGetFileContent(info,'sunes.json');
|
|
if(!text)throw new Error('Could not read sunes.json');
|
|
data=JSON.parse(text);
|
|
}
|
|
}
|
|
if(!data)throw new Error('Invalid data');
|
|
isSyncPulling=true;
|
|
try{
|
|
if(Array.isArray(data.sunes)){
|
|
sunes=data.sunes.map(makeSune);
|
|
su.save(sunes);
|
|
const nextActive=data.activeId||(sunes.some(s=>s.id===su.getActiveId())?su.getActiveId():sunes[0]?.id);
|
|
if(nextActive)SUNE.setActive(nextActive);
|
|
}
|
|
if(data.storage&&typeof data.storage==='object'){
|
|
Object.keys(localStorage).forEach(k=>{if(isSuneStorageKey(k))localStorage.removeItem(k)});
|
|
Object.entries(data.storage).forEach(([k,v])=>{if(isSuneStorageKey(k))localStorage.setItem(k,v)});
|
|
}
|
|
setLocalSunesUpdatedAt(num(data.updatedAt,Date.now()));
|
|
remoteSha=sha;
|
|
}finally{
|
|
isSyncPulling=false;
|
|
}
|
|
renderSidebar();
|
|
await reflectActiveSune();
|
|
setSuneSyncStatus('synced');
|
|
if(!isAuto)alert('Sunes pulled.');
|
|
}catch(e){
|
|
setSuneSyncStatus('error');
|
|
if(!isAuto)alert('Pull failed: '+e.message);
|
|
}
|
|
};
|
|
|
|
const performSuneUpload=async()=>{
|
|
const u=el.suneRepoInput.value.trim();
|
|
if(!u.startsWith('gh://'))return;
|
|
if(!confirm('Overwrite remote file on GitHub with local version?'))return;
|
|
setSuneSyncStatus('checking');
|
|
const info=parseGhUrl(u);
|
|
try{
|
|
const now=Date.now();
|
|
const data={version:1,updatedAt:now,sunes:SUNE.list,activeId:SUNE.id,storage:{}};
|
|
SUNE.list.forEach(s=>{
|
|
const p=`sune_${s.id}_`;
|
|
Object.keys(localStorage).forEach(k=>{if(k.startsWith(p))data.storage[k]=localStorage.getItem(k)});
|
|
});
|
|
const x=await ghApi(`${info.apiPath}/sunes.json?ref=${info.branch}`);
|
|
const res=await ghApi(`${info.apiPath}/sunes.json`,'PUT',{
|
|
message:'Sync Sunes',
|
|
content:utob(JSON.stringify(data,null,2)),
|
|
branch:info.branch,
|
|
sha:x?.sha
|
|
});
|
|
setLocalSunesUpdatedAt(now);
|
|
remoteSha=res?.content?.sha||null;
|
|
setSuneSyncStatus('synced');
|
|
alert('Sunes pushed.');
|
|
}catch(e){
|
|
setSuneSyncStatus('error');
|
|
alert('Push failed: '+e.message);
|
|
}
|
|
};
|
|
|
|
$(el.suneRepoInput).on('change',()=>{
|
|
localStorage.setItem('sune_repo_url',el.suneRepoInput.value.trim());
|
|
checkSuneSyncStatus();
|
|
});
|
|
$(el.suneSyncBtn).on('click',e=>{
|
|
e.stopPropagation();
|
|
const u=el.suneRepoInput.value.trim();
|
|
if(!u.startsWith('gh://'))return;
|
|
showSunesSyncPopover(el.suneSyncBtn);
|
|
});
|
|
$(el.sidebarBtnLeft).on('click',()=>{
|
|
if(el.suneRepoInput.value.trim().startsWith('gh://'))checkSuneSyncStatus();
|
|
});
|
|
init()
|
|
const accountTabs={General:['accountTabGeneral','accountPanelGeneral'],API:['accountTabAPI','accountPanelAPI'],User:['accountTabUser','accountPanelUser']};function showAccountTab(key){Object.entries(accountTabs).forEach(([k,[tb,pn]])=>{el[tb].classList.toggle('border-black',k===key);el[pn].classList.toggle('hidden',k!==key)})}
|
|
function openAccountSettings(){el.set_provider.value=USER.provider||'openrouter';el.set_api_key_or.value=USER.apiKeyOpenRouter||'';el.set_api_key_oai.value=USER.apiKeyOpenAI||'';el.set_api_key_g.value=USER.apiKeyGoogle||'';el.set_api_key_claude.value=USER.apiKeyClaude||'';el.set_api_key_cf.value=USER.apiKeyCloudflare||'';el.set_api_key_custom1.value=USER.customKey1||'';el.set_api_key_custom1.value=USER.customKey1||'';el.set_master_prompt.value=USER.masterPrompt||'';el.set_title_model.value=USER.titleModel;el.set_gh_token.value=USER.githubToken||'';el.set_user_name.value=USER.name;el.userAvatarPreview.src=USER.avatar||'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=';el.userAvatarPreview.classList.toggle('bg-gray-200',!USER.avatar);showAccountTab('General');el.accountSettingsModal.classList.remove('hidden')}
|
|
function closeAccountSettings(){el.accountSettingsModal.classList.add('hidden')}
|
|
$(el.accountSettingsOption).on('click',()=>{el.userMenu.classList.add('hidden');openAccountSettings()})
|
|
$(el.closeAccountSettings).on('click',closeAccountSettings)
|
|
$(el.cancelAccountSettings).on('click',closeAccountSettings)
|
|
$(el.accountSettingsModal).on('click',e=>{if(e.target===el.accountSettingsModal||e.target.classList.contains('bg-black/30'))closeAccountSettings()})
|
|
$(el.setUserAvatarBtn).on('click',()=>el.userAvatarInput.click());
|
|
$(el.userAvatarInput).on('change',async()=>{const f=el.userAvatarInput.files?.[0];if(!f)return;try{const v=await imgToWebp(f);USER.avatar=v;el.userAvatarPreview.src=v;el.userAvatarPreview.classList.remove('bg-gray-200');renderUserUI()}catch{}});
|
|
$(el.accountSettingsForm).on('submit',e=>{e.preventDefault();USER.provider=el.set_provider.value||'openrouter';USER.apiKeyOpenRouter=String(el.set_api_key_or.value||'').trim();USER.apiKeyOpenAI=String(el.set_api_key_oai.value||'').trim();USER.apiKeyGoogle=String(el.set_api_key_g.value||'').trim();USER.apiKeyClaude=String(el.set_api_key_claude.value||'').trim();USER.apiKeyCloudflare=String(el.set_api_key_cf.value||'').trim();USER.customKey1=String(el.set_api_key_custom1.value||'').trim();USER.masterPrompt=String(el.set_master_prompt.value||'').trim();USER.titleModel=String(el.set_title_model.value||'').trim();USER.githubToken=String(el.set_gh_token.value||'').trim();USER.name=String(el.set_user_name.value||'').trim();renderUserUI();closeAccountSettings()})
|
|
$(el.accountPanelAPI).on('click',e=>{const b=e.target.closest('[data-reveal-for]');if(!b)return;const i=document.getElementById(b.dataset.revealFor);if(!i)return;const p=i.type==='password';i.type=p?'text':'password';b.querySelector('i').setAttribute('data-lucide',p?'eye-off':'eye');lucide.createIcons()});
|
|
el.accountTabGeneral.onclick=()=>showAccountTab('General');el.accountTabAPI.onclick=()=>showAccountTab('API');el.accountTabUser.onclick=()=>showAccountTab('User')
|
|
el.exportAccountSettings.onclick=()=>dl(`sune-account-${ts()}.json`,{v:1,provider:USER.provider,apiKeyOpenRouter:USER.apiKeyOpenRouter,apiKeyOpenAI:USER.apiKeyOpenAI,apiKeyGoogle:USER.apiKeyGoogle,apiKeyClaude:USER.apiKeyClaude,apiKeyCloudflare:USER.apiKeyCloudflare,customKey1:USER.customKey1,masterPrompt:USER.masterPrompt,titleModel:USER.titleModel,githubToken:USER.githubToken,userName:USER.name,userAvatar:USER.avatar});
|
|
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});
|