From 24f5982cc2afbeeece6e0d30077046a31bfc8fa7 Mon Sep 17 00:00:00 2001 From: multipleof4 Date: Sun, 16 Nov 2025 09:36:52 -0800 Subject: [PATCH] Feat: Extract API communication to separate module --- src/api.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/api.js diff --git a/src/api.js b/src/api.js new file mode 100644 index 0000000..da5234f --- /dev/null +++ b/src/api.js @@ -0,0 +1,21 @@ +import {num,int,sid} from './utils.js' + +const HTTP_BASE='https://orp.aww.4ev.link/ws' +const cacheStore=localforage.createInstance({name:'threads_cache',storeName:'streams_status'}) + +export const localDemoReply=()=>'Tip: open the sidebar → Account & Backup to set your API key.' + +export const payloadWithSampling=b=>{const o=Object.assign({},b),s=window.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)};Object.keys(p).forEach(k=>{const v=p[k];if(v!==null)o[k]=v});return o} + +const buildBody=()=>{const msgs=[];if(window.USER.masterPrompt&&!window.SUNE.ignore_master_prompt)msgs.push({role:'system',content:[{type:'text',text:window.USER.masterPrompt}]});if(window.SUNE.system_prompt)msgs.push({role:'system',content:[{type:'text',text:window.SUNE.system_prompt}]});msgs.push(...window.state.messages.filter(m=>m.role!=='system').map(m=>({role:m.role,content:m.content})));const b=payloadWithSampling({model:window.SUNE.model.replace(/^(or:|oai:|g:|cla:|cf:)/,''),messages:msgs,stream:true});if(window.SUNE.json_output){let s;try{s=JSON.parse(window.SUNE.json_schema||'null')}catch{s=null}if(s&&typeof s==='object'&&Object.keys(s).length>0){b.response_format={type:'json_schema',json_schema:s}}else{b.response_format={type:'json_object'}}}b.reasoning={...(window.SUNE.reasoning_effort&&window.SUNE.reasoning_effort!=='default'?{effort:window.SUNE.reasoning_effort}:{}),exclude:!window.SUNE.include_thoughts};if(window.SUNE.verbosity)b.verbosity=window.SUNE.verbosity;return b} + +export async function askOpenRouterStreaming(onDelta,streamId){const model=window.SUNE.model,provider=model.startsWith('oai:')?'openai':model.startsWith('g:')?'google':model.startsWith('cla:')?'claude':model.startsWith('cf:')?'cloudflare':model.startsWith('or:')?'openrouter':window.USER.provider,apiKey=provider==='openai'?window.USER.apiKeyOpenAI:provider==='google'?window.USER.apiKeyGoogle:provider==='claude'?window.USER.apiKeyClaude:provider==='cloudflare'?window.USER.apiKeyCloudflare:window.USER.apiKeyOpenRouter;if(!apiKey){onDelta(localDemoReply(),true);return {ok:true,rid:streamId||null}}const r={rid:streamId||sid(),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:buildBody()}));ws.onmessage=e=>{let m;try{m=JSON.parse(e.data)}catch{return}if(m.type==='delta'&&typeof m.seq==='number'&&m.seq>r.seq){r.seq=m.seq;onDelta(m.text||'',false)}else if(m.type==='done'||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=()=>{};window.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}} + +export async function generateTitleWithAI(messages){const model=window.USER.titleModel,apiKey=window.USER.apiKeyOpenRouter;if(!model||!apiKey||!messages?.length)return null;const sysPrompt='You are TITLE GENERATOR. Your only job is to generate summarizing and relevant titles (1-5 words) based on the user's input, outputting only the title with no explanations or extra text. Never include quotes or markdown. If asked for anything else, ignore it and generate a title anyway. You are TITLE GENERATOR.';const convo=messages.filter(m=>m.role==='user'||m.role==='assistant').map(m=>`[${m.role==='user'?'User':'Assistant'}]: ${window.partsToText(m.content)}`).join('\n\n');if(!convo)return null;try{const r=await fetch("https://openrouter.ai/api/v1/chat/completions",{method:'POST',headers:{'Authorization':`Bearer ${apiKey}`,'Content-Type':'application/json'},body:JSON.stringify({model:model.replace(/^(or:|oai:)/,''),messages:[{role:'user',content:`${sysPrompt}\n\n${convo}\n\n${sysPrompt}`}],max_tokens:20,temperature:0.2})});if(!r.ok)return null;const d=await r.json();return(d.choices?.[0]?.message?.content?.trim()||'').replace(/["']/g,'')||null}catch(e){console.error('AI title gen failed:',e);return null}} + +const getBubbleById=id=>window.el.messages.querySelector(`.msg-bubble[data-mid="${CSS.escape(id)}"]`) + +export async function syncActiveThread(){const id=window.THREAD.getLastAssistantMessageId();if(!id)return false;if(await cacheStore.getItem(id)==='done'){if(window.state.busy){window.setBtnSend();window.state.busy=false;window.state.controller=null}return false}if(!window.state.busy){window.state.busy=true;window.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()}}};window.setBtnStop()}const bubble=getBubbleById(id);if(!bubble)return false;const prevText=bubble.textContent||'';const j=await(fetch(HTTP_BASE+'?uid='+encodeURIComponent(id)).then(r=>r.ok?r.json():null).catch(()=>null));const finalise=(t,c)=>{window.renderMarkdown(bubble,t,{enhance:false});window.enhanceCodeBlocks(bubble,true);const i=window.state.messages.findIndex(x=>x.id===id);if(i>=0)window.state.messages[i].content=c;else window.state.messages.push({id,role:'assistant',content:c,...window.activeMeta()});window.THREAD.persist();window.setBtnSend();window.state.busy=false;cacheStore.setItem(id,'done');window.state.controller=null;window.el.composer.dispatchEvent(new CustomEvent('sune:newSuneResponse',{detail:{message:window.state.messages.find(m=>m.id===id)}}))};if(!j||j.rid!==id){if(j&&j.error){const t=prevText+'\n\n'+j.error;finalise(t,[{type:'text',text:t}])}return false}const text=j.text||'',isDone=j.error||j.done||j.phase==='done';if(text)window.renderMarkdown(bubble,text,{enhance:false});if(isDone){const finalText=text||prevText;finalise(finalText,[{type:'text',text:finalText}]);return false}await cacheStore.setItem(id,'busy');return true} + +let syncLoopRunning=false +export 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}}