Files
devsune/index.html
2025-08-16 01:47:41 -07:00

159 lines
29 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
<title>ChatGPT — Mobile Clone (Light)</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5.5.1/github-markdown-light.min.css"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github.min.css"/>
<style>:root{--safe-bottom:env(safe-area-inset-bottom)}::-webkit-scrollbar{height:8px;width:8px}::-webkit-scrollbar-thumb{background:#e5e7eb;border-radius:999px}.no-scrollbar::-webkit-scrollbar{display:none}.no-scrollbar{-ms-overflow-style:none;scrollbar-width:none}.markdown-body{font-size:14px;line-height:1.6}.markdown-body pre{overflow:auto}.msg-bubble{overflow-x:auto}.copy-btn{position:absolute;top:.5rem;right:.5rem;background:#0f172a;color:#fff;border-radius:.5rem;padding:.25rem .5rem;font-size:12px;opacity:.85}.copy-btn:hover{opacity:1}.msg-avatar{font-size:16px}.menu-card{position:fixed;z-index:60;min-width:12rem;border-radius:0.75rem;border:1px solid #e5e7eb;background:#fff;box-shadow:0 10px 20px rgba(0,0,0,.08)}.menu-item{width:100%;text-align:left;padding:.5rem .75rem;font-size:.875rem;display:flex;align-items:center;gap:.5rem}.menu-item:hover{background:#f9fafb}</style>
</head>
<body class="bg-white text-gray-900 selection:bg-black/10" x-data="app()" x-init="init()" @resize.window="hideHistoryMenu();kbUpdate()">
<div class="flex flex-col h-dvh max-h-dvh">
<header class="sticky top-0 z-20 bg-white/80 backdrop-blur border-b border-gray-200">
<div class="mx-auto w-full max-w-none px-4 py-3 grid grid-cols-3 items-center">
<button class="h-8 w-8 rounded-xl bg-gray-100 hover:bg-gray-200 active:scale-[.99] transition flex items-center justify-center" title="Sunes" @click="openSidebar()"><i data-lucide="panel-left" class="h-5 w-5"></i></button>
<button class="justify-self-center h-8 w-8 rounded-full bg-gray-200 text-gray-900 flex items-center justify-center hover:bg-gray-300 active:scale-[.99] transition" :title="'Settings — '+activeSune().name" @click="openSettings()">🤖</button>
<div class="justify-self-end"><button class="h-8 w-8 rounded-xl bg-gray-100 hover:bg-gray-200 active:scale-[.99] transition flex items-center justify-center" title="Threads" @click="openHistory()"><i data-lucide="panel-right" class="h-5 w-5"></i></button></div>
</div>
</header>
<main id="chat" class="flex-1 overflow-y-auto no-scrollbar" x-ref="chat"><div class="mx-auto w-full max-w-none px-0 py-4 sm:py-6 space-y-4" x-ref="messages">
<template x-for="(m,i) in messages" :key="i">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2 px-4">
<div class="msg-avatar shrink-0 h-7 w-7 rounded-full flex items-center justify-center" :class="m.role==='user'?'bg-gray-900 text-white':'bg-gray-200 text-gray-900'" x-text="m.role==='user'?'🧑':'🤖'"></div>
<div class="text-xs font-medium text-gray-500" x-text="m.role==='user'?'You':(m.sune_name+' · '+modelShort(m.model))"></div>
</div>
<div class="msg-bubble markdown-body rounded-none px-4 py-3 w-full" :class="m.role==='user'?'bg-gray-50 border border-gray-200':'bg-gray-100'" x-html="m.rendered"></div>
</div>
</template>
</div><div class="h-24"></div></main>
<footer class="sticky bottom-0 z-10 bg-gradient-to-t from-white via-white/95 to-white/40 pt-3 pb-[calc(12px+var(--safe-bottom))] border-t border-gray-200" x-ref="footer">
<div class="mx-auto w-full max-w-none px-0">
<form class="group relative flex items-end gap-2 px-4" @submit.prevent="send()">
<textarea rows="1" placeholder="Send a message" spellcheck="false" autocapitalize="none" autocomplete="off" autocorrect="off" inputmode="text" enterkeyhint="send" class="flex-1 resize-none rounded-2xl border border-gray-300 bg-white px-4 py-3 text-[14px] leading-6 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-black/20 focus:border-gray-300 max-h-40 overflow-hidden" x-model.trim="draft" x-ref="input" @input="fitRAF()" @paste="setTimeout(fit,0)" @focus="kbUpdate()" @click="kbUpdate()"></textarea>
<button type="submit" aria-label="Send" class="shrink-0 rounded-2xl bg-black text-white h-10 w-10 inline-flex items-center justify-center shadow-sm hover:bg-black/90 active:scale-[.98] transition" :data-mode="busy?'stop':'send'" :type="busy?'button':'submit'" @click.prevent="busy?abort():null" x-html="busy?icons.stop:icons.send"></button>
</form>
</div>
</footer>
</div>
<div class="fixed inset-0 z-40 bg-black/20" x-show="sidebarOpen" x-transition.opacity @click="closeSidebar()"></div>
<aside class="fixed inset-y-0 left-0 z-50 w-72 max-w-[85vw] bg-white border-r border-gray-200 shadow-xl transform transition-transform duration-200 ease-out flex flex-col" :class="sidebarOpen?'translate-x-0':'-translate-x-full'">
<div class="p-3 border-b flex items-center gap-2"><button class="px-3 py-2 rounded-xl bg-black text-white text-sm hover:bg-black/90" @click="newSune()">New sune</button><span class="text-xs text-gray-500">Click name to equip</span></div>
<div class="flex-1 overflow-y-auto divide-y">
<template x-for="s in sunes" :key="s.id"><button class="w-full text-left px-3 py-2 hover:bg-gray-50 flex items-center gap-2" :class="s.id===activeId?'bg-gray-100':''" @click="setActive(s.id)"><span class="h-6 w-6 rounded-full bg-gray-200 flex items-center justify-center">🤖</span><span class="truncate" x-text="s.name"></span></button></template>
</div>
<div class="p-3 border-t relative">
<button class="w-full flex items-center justify-between px-3 py-2 rounded-xl bg-gray-100 hover:bg-gray-200 active:scale-[.99] transition" @click.stop="userMenuOpen=!userMenuOpen"><span class="flex items-center gap-2"><span class="h-6 w-6 rounded-full bg-gray-900 text-white flex items-center justify-center">👤</span><span class="text-sm">Account & Backup</span></span><i data-lucide="chevron-down" class="h-4 w-4"></i></button>
<div class="absolute left-3 right-3 bottom-16 translate-y-2 rounded-xl border border-gray-200 bg-white shadow-lg" x-show="userMenuOpen" @click.outside="userMenuOpen=false">
<button class="menu-item" @click="setApiKey()">Enter OpenRouter API key</button>
<button class="menu-item" @click="triggerImport('sunes')">Import sunes (.json)</button>
<button class="menu-item" @click="exportSunes()">Export sunes (.json)</button>
<button class="menu-item" @click="triggerImport('threads')">Import threads (.json)</button>
<button class="menu-item" @click="exportThreads()">Export threads (.json)</button>
<button class="menu-item text-red-600" @click="dedupThreads()">Deduplicate threads</button>
</div>
<input type="file" accept="application/json,.json" class="hidden" x-ref="importInput" @change="onImport()"/>
</div>
</aside>
<div class="fixed inset-0 z-40 bg-black/20" x-show="historyOpen" x-transition.opacity @click="closeHistory()"></div>
<aside class="fixed inset-y-0 right-0 z-50 w-80 max-w-[90vw] bg-white border-l border-gray-200 shadow-xl transform transition-transform duration-200 ease-out flex flex-col" :class="historyOpen?'translate-x-0':'translate-x-full'">
<div class="p-3 border-b text-sm font-medium flex items-center justify-between"><span>Threads</span><button class="p-1 rounded hover:bg-gray-100" aria-label="Close" @click="closeHistory()"><svg viewBox="0 0 24 24" class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg></button></div>
<div class="flex-1 overflow-y-auto divide-y" x-ref="historyList">
<template x-for="t in threads" :key="t.id"><div class="relative flex items-center gap-2 px-3 py-2" :class="t.pinned?'bg-yellow-50':''">
<button class="flex-1 text-left truncate" @click="openThread(t.id)" x-text="(t.pinned?'📌 ':'')+t.title"></button>
<button class="h-8 w-8 rounded hover:bg-gray-100 flex items-center justify-center" title="More" @click.stop="openHistoryMenu($event,t.id)"><i data-lucide="more-horizontal" class="h-4 w-4"></i></button>
</div></template>
</div>
</aside>
<div class="menu-card" x-ref="historyMenu" x-show="historyMenuOpen" @click.outside="hideHistoryMenu()">
<button data-act="pin" class="menu-item" @click="historyAction('pin')"><i data-lucide="pin" class="h-4 w-4"></i><span>Pin to top</span></button>
<button data-act="rename" class="menu-item" @click="historyAction('rename')"><i data-lucide="edit-3" class="h-4 w-4"></i><span>Rename</span></button>
<button data-act="delete" class="menu-item text-red-600" @click="historyAction('delete')"><i data-lucide="trash-2" class="h-4 w-4"></i><span>Delete</span></button>
</div>
<div class="fixed inset-0 z-50" x-show="settingsOpen" x-transition.opacity>
<div class="absolute inset-0 bg-black/30" @click="closeSettings()"></div>
<div class="absolute inset-x-0 top-12 mx-auto w-full max-w-md px-4">
<div class="rounded-2xl bg-white shadow-xl border border-gray-200 overflow-hidden">
<div class="px-4 py-3 border-b text-sm font-semibold flex items-center justify-between"><span>Sune Settings</span><button class="p-1 rounded hover:bg-gray-100" aria-label="Close" @click="closeSettings()"><svg viewBox="0 0 24 24" class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg></button></div>
<form class="text-sm" @submit.prevent="saveSettings()">
<div class="border-b flex text-sm font-medium"><button type="button" class="flex-1 py-2 px-3 text-center border-b-2" :class="tab==='model'?'border-black':'border-transparent hover:border-gray-300'" @click="tab='model'">Model & Sampling</button><button type="button" class="flex-1 py-2 px-3 text-center border-b-2" :class="tab==='prompt'?'border-black':'border-transparent hover:border-gray-300'" @click="tab='prompt'">System Prompt</button></div>
<div class="p-4 space-y-4" x-show="tab==='model'">
<div><label class="block text-gray-700 font-medium mb-1">Model name</label><input type="text" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="openai/gpt-4o" x-model="form.model"></div>
<div class="grid grid-cols-2 gap-3">
<template x-for="f in sliders" :key="f.k"><div><label class="block text-gray-700 font-medium mb-1" x-html="f.lbl"></label><input :id="'set_'+f.k" :type="f.type" :min="f.min" :max="f.max" :step="f.step" class="w-full rounded-xl border border-gray-300 px-3 py-2" x-model.number="form[f.k]"><p class="mt-1 text-xs text-gray-500" x-text="f.help"></p></div></template>
</div>
</div>
<div class="p-4 space-y-4" x-show="tab==='prompt'"><div><label class="block text-gray-700 font-medium mb-1">System Prompt</label><textarea rows="8" class="w-full rounded-xl border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-black/20" placeholder="Enter a system prompt to guide the sune" x-model="form.system_prompt"></textarea><p class="mt-1 text-xs text-gray-500">Saved per sune.</p></div></div>
<div class="flex items-center justify-between gap-2 px-4 py-3 border-t">
<button type="button" class="inline-flex items-center gap-2 px-3 py-2 rounded-xl border border-red-200 text-red-700 hover:bg-red-50" @click="deleteSune()"><svg viewBox="0 0 24 24" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M3 6h18M8 6v12a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2V6m-9 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg><span>Delete sune</span></button>
<div class="flex items-center justify-end gap-2"><button type="button" class="px-3 py-2 rounded-xl border bg-white hover:bg-gray-50" @click="closeSettings()">Cancel</button><button type="submit" class="px-3 py-2 rounded-xl bg-black text-white hover:bg-black/90">Save</button></div>
</div>
</form>
</div>
</div>
</div>
<script src="https://unpkg.com/lucide@latest"></script>
<script src="https://cdn.jsdelivr.net/npm/markdown-it@13.0.1/dist/markdown-it.min.js"></script>
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script>
const app=()=>({DEFAULT_MODEL:'openai/gpt-4o',DEFAULT_API_KEY:'',icons:{send:'<svg viewBox="0 0 24 24" class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M12 5l7 7-7 7"/></svg>',stop:'<svg viewBox="0 0 24 24" class="h-5 w-5" fill="currentColor"><rect x="7" y="7" width="10" height="10" rx="1"/></svg>'},draft:'',messages:[],busy:false,controller:null,abortRequested:false,sidebarOpen:false,historyOpen:false,settingsOpen:false,userMenuOpen:false,historyMenuOpen:false,historyMenuId:null,activeId:null,sunes:[],threads:[],currentThreadId:null,tab:'model',form:{},sliders:[{k:'temperature',lbl:'Temperature <span class="text-gray-400">(02)</span>',type:'number',min:0,max:2,step:0.1,help:'Variety. Lower = predictable.'},{k:'top_p',lbl:'Top P <span class="text-gray-400">(01)</span>',type:'number',min:0,max:1,step:0.01,help:'Nucleus sampling.'},{k:'top_k',lbl:'Top K',type:'number',min:0,max:999,step:1,help:'Token shortlist size.'},{k:'frequency_penalty',lbl:'Frequency Penalty <span class="text-gray-400">(-22)</span>',type:'number',min:-2,max:2,step:0.1,help:'Discourage repeats by count.'},{k:'presence_penalty',lbl:'Presence Penalty <span class="text-gray-400">(-22)</span>',type:'number',min:-2,max:2,step:0.1,help:'Discourage seen tokens.'},{k:'repetition_penalty',lbl:'Repetition Penalty <span class="text-gray-400">(02)</span>',type:'number',min:0,max:2,step:0.1,help:'Reduce verbatim echoes.'},{k:'min_p',lbl:'Min P <span class="text-gray-400">(01)</span>',type:'number',min:0,max:1,step:0.01,help:'Minimum token prob vs best.'},{k:'top_a',lbl:'Top A <span class="text-gray-400">(01)</span>',type:'number',min:0,max:1,step:0.01,help:'Adaptive nucleus filter.'}],md:window.markdownit({html:false,linkify:true,typographer:true,breaks:true}),db:null,rafId:null,
get apiKey(){return localStorage.getItem('openrouter_api_key')||this.DEFAULT_API_KEY||''},set apiKey(v){localStorage.setItem('openrouter_api_key',v||'')},
activeSune(){return this.sunes.find(s=>s.id===this.activeId)||this.sunes[0]},
modelShort(m){const mm=(m||this.activeSune().settings.model||'');return mm.includes('/')?mm.split('/').pop():mm},
esc(s){return String(s).replace(/[&<>\'"`]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;","`":"&#96;"}[c]))},
clamp(v,a,b){v=+v;return isNaN(v)?a:Math.max(a,Math.min(b,v))},
num(v,d){v=+v;return isNaN(v)?d:v},
int(v,d){v=parseInt(v);return isNaN(v)?d:v},
gid(){return Math.random().toString(36).slice(2,9)},
ts(){const d=new Date(),p=n=>(''+n).padStart(2,'0');return `${d.getFullYear()}${p(d.getMonth()+1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`},
fit(){const i=this.$refs.input,large=i.value.length>5000||i.value.split('\n').length>200;i.style.overflowY=large?'auto':'hidden';i.style.height='auto';i.style.height=(large?160:Math.min(i.scrollHeight,160))+'px'},
fitRAF(){if(this.rafId)cancelAnimationFrame(this.rafId);this.rafId=requestAnimationFrame(()=>{this.rafId=null;this.fit()})},
scrollToBottom(){this.$nextTick(()=>this.$refs.chat.scrollTo({top:this.$refs.chat.scrollHeight,behavior:'smooth'}))},
renderMarkdown(m,enh=true,hl=true){m.rendered=this.md.render(m.content);this.$nextTick(()=>{if(enh)this.enhanceCodeBlocks(this.$refs.messages,hl)})},
enhanceCodeBlocks(root,doHL=true){root.querySelectorAll('pre>code').forEach(code=>{if(code.dataset._enh)return;code.dataset._enh=1;const pre=code.parentElement;pre.classList.add('relative','rounded-xl','border','border-gray-200');if(!pre.querySelector('.copy-btn')){const btn=document.createElement('button');btn.className='copy-btn';btn.textContent='Copy';btn.addEventListener('click',async e=>{e.stopPropagation();try{await navigator.clipboard.writeText(code.innerText);btn.textContent='Copied';setTimeout(()=>btn.textContent='Copy',1200)}catch{}});pre.appendChild(btn)}if(doHL&&window.hljs&&code.textContent.length<100000)hljs.highlightElement(code)})},
localDemoReply(p){const tips=['Tip: open the sidebar → Account & Backup to set your OpenRouter API key.','Click 🤖 to change model & sampling.','New chats are stateless here—no history is kept.'],tip=tips[Math.floor(Math.random()*tips.length)],mir=p.split(/\s+/).slice(0,24).join(' ');return `Local demo mode. You said: "${mir}"\n\n${tip}`},
payloadWithSampling(b){const s=this.activeSune().settings;return Object.assign({},b,{temperature:s.temperature,top_p:s.top_p,top_k:s.top_k,frequency_penalty:s.frequency_penalty,presence_penalty:s.presence_penalty,repetition_penalty:s.repetition_penalty,min_p:s.min_p,top_a:s.top_a})},
openSidebar(){this.sidebarOpen=true},closeSidebar(){this.sidebarOpen=false},openHistory(){this.historyOpen=true;this.renderHistory()},closeHistory(){this.historyOpen=false},
openSettings(){const s=this.activeSune().settings;this.form=JSON.parse(JSON.stringify(s));this.tab='model';this.settingsOpen=true},closeSettings(){this.settingsOpen=false},
saveSettings(){const s=this.activeSune().settings;s.model=(this.form.model||this.DEFAULT_MODEL).trim();s.temperature=this.clamp(this.num(this.form.temperature,1),0,2);s.top_p=this.clamp(this.num(this.form.top_p,1),0,1);s.top_k=Math.max(0,this.int(this.form.top_k,0));s.frequency_penalty=this.clamp(this.num(this.form.frequency_penalty,0),-2,2);s.presence_penalty=this.clamp(this.num(this.form.presence_penalty,0),-2,2);s.repetition_penalty=this.clamp(this.num(this.form.repetition_penalty,1),0,2);s.min_p=this.clamp(this.num(this.form.min_p,0),0,1);s.top_a=this.clamp(this.num(this.form.top_a,0),0,1);s.system_prompt=(this.form.system_prompt||'').trim();this.saveSunes();this.closeSettings()},
newSune(){const id=this.gid();this.sunes.unshift({id,name:(prompt('Name your sune:')||'Default').trim(),settings:{model:this.DEFAULT_MODEL,temperature:1,top_p:1,top_k:0,frequency_penalty:0,presence_penalty:0,repetition_penalty:1,min_p:0,top_a:0,system_prompt:''}});this.activeId=id;this.saveSunes();this.renderSidebar();this.clearChat();this.closeSidebar()},
setActive(id){this.activeId=id;localStorage.setItem('active_sune_id',id||'');this.renderSidebar();this.clearChat();this.closeSidebar()},
deleteSune(){const a=this.activeSune(),name=a?.name||'this sune';if(!confirm(`Delete "${name}"?`))return;this.sunes=this.sunes.filter(x=>x.id!==a.id);if(!this.sunes.length){const id=this.gid();this.sunes=[{id,name:'Default',settings:{model:this.DEFAULT_MODEL,temperature:1,top_p:1,top_k:0,frequency_penalty:0,presence_penalty:0,repetition_penalty:1,min_p:0,top_a:0,system_prompt:''}}];this.activeId=this.sunes[0].id}else this.activeId=this.sunes[0].id;this.saveSunes();this.renderSidebar();this.clearChat();this.closeSettings()},
saveSunes(){localStorage.setItem('sunes_v1',JSON.stringify(this.sunes||[]));localStorage.setItem('active_sune_id',this.activeId||'')},
renderSidebar(){$nextTick(()=>{if(window.lucide)lucide.createIcons()})},
clearChat(){this.messages=[]},
async send(){if(this.busy)return;const text=this.draft.trim();if(!text)return;if(!this.currentThreadId)this.currentThreadId=null;await this.ensureThreadOnFirstUser(text);this.draft='';this.fit();this.messages.push({role:'user',content:text,rendered:''});this.renderMarkdown(this.messages[this.messages.length-1]);this.busy=true;this.controller=null;this.abortRequested=false;const meta={sune_name:this.activeSune().name,model:this.activeSune().settings.model};this.messages.push({role:'assistant',content:'',rendered:'',...meta});this.scrollToBottom();const idx=this.messages.length-1;const onDelta=(d,done)=>{this.messages[idx].content+=d;this.messages[idx].rendered=this.md.render(this.messages[idx].content);if(done){this.busy=false;this.enhanceCodeBlocks(this.$refs.messages,true);this.persistThread();this.scrollToBottom()}};await this.askOpenRouterStreaming(onDelta)},
abort(){this.abortRequested=true;this.controller?.abort?.()},
async askOpenRouterStreaming(onDelta){const apiKey=this.apiKey,model=this.activeSune().settings.model;if(!apiKey){const t=this.localDemoReply(this.messages[this.messages.length-2]?.content||'');onDelta(t,true);return{ok:true,text:t}}try{this.controller=new AbortController();const msgs=[];const sp=this.activeSune().settings.system_prompt;if(sp)msgs.push({role:'system',content:sp});msgs.push(...this.messages.filter(m=>m.role!=='system').map(m=>({role:m.role,content:m.content})));const body=this.payloadWithSampling({model,messages:msgs,stream:true});const res=await fetch('https://openrouter.ai/api/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+apiKey},body:JSON.stringify(body),signal:this.controller.signal});if(!res.ok){const errText=await res.text().catch(()=> '');throw new Error(errText||('HTTP '+res.status))}const reader=res.body.getReader(),decoder=new TextDecoder('utf-8');let buffer='',finished=false;const doneOnce=()=>{if(finished)return;finished=true;onDelta('',true)};while(true){const {value,done}=await reader.read();if(done)break;buffer+=decoder.decode(value,{stream:true});let idx;while((idx=buffer.indexOf('\n\n'))!==-1){const chunk=buffer.slice(0,idx).trim();buffer=buffer.slice(idx+2);if(!chunk)continue;if(chunk.startsWith('data:')){const data=chunk.slice(5).trim();if(data==='[DONE]'){doneOnce();continue}try{const json=JSON.parse(data);const delta=json.choices?.[0]?.delta?.content??'';if(delta)onDelta(delta,false);const finish=json.choices?.[0]?.finish_reason;if(finish)doneOnce()}catch{}}}}doneOnce();return{ok:true}}catch(e){const msg=String(e?.message||e),aborted=e?.name==='AbortError'||/abort/i.test(msg)||this.controller?.signal?.aborted||this.abortRequested;if(aborted){onDelta('',true);return{ok:false,aborted:true}}let hint='Request failed.';if(/401|unauthorized/i.test(msg))hint='Unauthorized (check API key).';else if(/429|rate/i.test(msg))hint='Rate limited (slow down or upgrade).';else if(/access|forbidden|403/i.test(msg))hint='Forbidden (model or key scope).';onDelta('\n\n'+hint,true);return{ok:false}}finally{this.controller=null;this.abortRequested=false}},
kbUpdate(){const vv=window.visualViewport,ov=vv?Math.max(0,(window.innerHeight-(vv.height+vv.offsetTop))):0;document.documentElement.style.setProperty('--kb',ov+'px');const fh=this.$refs.footer.getBoundingClientRect().height;document.documentElement.style.setProperty('--footer-h',fh+'px');this.$refs.footer.style.transform='translateY('+(-ov)+'px)';this.$refs.chat.style.scrollPaddingBottom=(fh+ov+16)+'px'},
setApiKey(){const cur=this.apiKey?'********':'';const input=prompt('Enter OpenRouter API key (stored locally):',cur);if(input!==null){this.apiKey=input==='********'?this.apiKey:input.trim();alert(this.apiKey?'API key saved locally.':'API key cleared.')}} ,
dl(name,obj){const blob=new Blob([JSON.stringify(obj,null,2)],{type:'application/json'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=name;document.body.appendChild(a);a.click();a.remove();URL.revokeObjectURL(url)},
triggerImport(mode){this.importMode=mode;this.$refs.importInput.value='';this.$refs.importInput.click()},
onImport:async function(){const f=this.$refs.importInput.files?.[0];if(!f)return;try{const text=await f.text();const data=JSON.parse(text);if(this.importMode==='sunes'){const list=Array.isArray(data)?data:(Array.isArray(data.sunes)?data.sunes:[]);if(!list.length)throw 0;this.sunes=list.map(a=>({id:a.id||this.gid(),name:a.name||'Imported',settings:Object.assign({model:this.DEFAULT_MODEL,temperature:1,top_p:1,top_k:0,frequency_penalty:0,presence_penalty:0,repetition_penalty:1,min_p:0,top_a:0,system_prompt:''},a.settings||{})}));this.activeId=(data.activeId&&this.sunes.some(x=>x.id===data.activeId))?data.activeId:this.sunes[0]?.id||null;this.saveSunes();this.renderSidebar();this.clearChat();alert('Sunes imported.')}else if(this.importMode==='threads'){const arr=Array.isArray(data)?data:(Array.isArray(data.threads)?data.threads:[]);if(!arr.length)throw 0;for(const t of arr){const th={id:this.gid(),title:this.titleFrom(t.title||this.titleFrom(t.messages?.find?.(m=>m.role==='user')?.content||'')),pinned:!!t.pinned,createdAt:t.createdAt||Date.now(),updatedAt:t.updatedAt||Date.now(),messages:Array.isArray(t.messages)?t.messages.filter(m=>m&&m.role&&m.content):[]};await this.idbPut(th)}await this.renderHistory();alert('Threads imported.')}this.userMenuOpen=false}catch{alert('Import failed')}finally{this.importMode=null}},
exportSunes(){this.dl(`sunes-${this.ts()}.json`,{version:1,sunes:this.sunes,activeId:this.activeId});this.userMenuOpen=false},
exportThreads:async function(){const all=await this.idbAll();this.dl(`threads-${this.ts()}.json`,{version:1,threads:all});this.userMenuOpen=false},
dedupThreads:async function(){this.userMenuOpen=false;const all=(await this.idbAll()).sort((a,b)=>b.updatedAt-a.updatedAt);const seen=new Set();let removed=0;for(const t of all){const key=JSON.stringify((t.messages||[]).map(m=>[m.role,m.content]));if(seen.has(key)){await this.idbDel(t.id);removed+=1;if(this.currentThreadId===t.id){this.currentThreadId=null;this.clearChat()}}else seen.add(key)}await this.renderHistory();alert(`${removed} duplicate${removed===1?'':'s'} removed.`)},
renderHistory:async function(){this.threads=(await this.idbAll()).sort((a,b)=>(b.pinned-a.pinned)||(b.updatedAt-a.updatedAt));this.$nextTick(()=>{if(window.lucide)lucide.createIcons()})},
openThread:async function(id){const th=await this.idbGet(id);if(!th)return;this.currentThreadId=id;this.clearChat();this.messages=Array.isArray(th.messages)?th.messages.map(m=>Object.assign(m,{rendered:this.md.render(m.content)})):[].concat();this.scrollToBottom();this.closeHistory();this.hideHistoryMenu()},
openHistoryMenu(e,id){this.historyMenuId=id;const r=e.currentTarget.getBoundingClientRect();const menu=this.$refs.historyMenu;menu.style.top=(r.bottom+4)+'px';menu.style.left=Math.min(window.innerWidth-220,r.right-200)+'px';this.historyMenuOpen=true;this.$nextTick(()=>{if(window.lucide)lucide.createIcons()})},
hideHistoryMenu(){this.historyMenuOpen=false;this.historyMenuId=null},
historyAction:async function(act){if(!this.historyMenuId)return;const id=this.historyMenuId,th=await this.idbGet(id);if(!th)return;if(act==='pin'){th.pinned=!th.pinned;await this.idbPut(th)}else if(act==='rename'){const nv=prompt('Rename to:',th.title);if(nv!=null){th.title=this.titleFrom(nv);await this.idbPut(th)}}else if(act==='delete'){if(confirm('Delete this chat?')){await this.idbDel(th.id);if(this.currentThreadId===th.id){this.currentThreadId=null;this.clearChat()}}}this.hideHistoryMenu();this.renderHistory()},
ensureThreadOnFirstUser:async function(text){let needNew=!this.currentThreadId;if(this.messages.length===0)this.currentThreadId=null;if(this.currentThreadId){const existing=await this.idbGet(this.currentThreadId);if(!existing)needNew=true}if(!needNew)return;const id=this.gid(),now=Date.now();const th={id,title:this.titleFrom(text),pinned:false,createdAt:now,updatedAt:now,messages:[]};this.currentThreadId=id;this.threads.unshift(th);await this.idbPut(th);await this.renderHistory()},
async persistThread(){if(!this.currentThreadId)return;let th=await this.idbGet(this.currentThreadId);if(!th)return;th.messages=this.messages.map(({rendered,...m})=>m);th.updatedAt=Date.now();th.title=this.titleFrom(th.messages.find(m=>m.role==='user')?.content||th.title);await this.idbPut(th);await this.renderHistory()},
titleFrom(t){return (t||'').replace(/\s+/g,' ').trim().slice(0,60)||'Untitled'},
async init(){await this.idbOpen();try{this.sunes=JSON.parse(localStorage.getItem('sunes_v1')||'[]')}catch{this.sunes=[]}if(!this.sunes.length){const id=this.gid();this.sunes=[{id,name:'Default',settings:{model:this.DEFAULT_MODEL,temperature:1,top_p:1,top_k:0,frequency_penalty:0,presence_penalty:0,repetition_penalty:1,min_p:0,top_a:0,system_prompt:''}}];this.activeId=id;this.saveSunes()}this.activeId=localStorage.getItem('active_sune_id')||this.sunes[0].id;this.renderSidebar();this.clearChat();if(window.lucide)lucide.createIcons();this.fit();this.kbBind();this.kbUpdate();await this.renderHistory()},
kbBind(){if(window.visualViewport){['resize','scroll'].forEach(ev=>visualViewport.addEventListener(ev,()=>this.kbUpdate(),{passive:true}))}['resize','orientationchange'].forEach(ev=>window.addEventListener(ev,()=>setTimeout(()=>this.kbUpdate(),50),{passive:true}))},
idbOpen(){return new Promise((res,rej)=>{const r=indexedDB.open('chat_history_v1',1);r.onupgradeneeded=()=>{r.result.createObjectStore('threads',{keyPath:'id'})};r.onsuccess=()=>{this.db=r.result;res()};r.onerror=()=>rej(r.error)})},
idbAll(){return new Promise((res,rej)=>{const tx=this.db.transaction('threads').objectStore('threads').getAll();tx.onsuccess=()=>res(tx.result||[]);tx.onerror=()=>rej(tx.error)})},
idbGet(id){return new Promise((res,rej)=>{const tx=this.db.transaction('threads').objectStore('threads').get(id);tx.onsuccess=()=>res(tx.result||null);tx.onerror=()=>rej(tx.error)})},
idbPut(v){return new Promise((res,rej)=>{const tx=this.db.transaction('threads','readwrite').objectStore('threads').put(v);tx.onsuccess=()=>res();tx.onerror=()=>rej(tx.error)})},
idbDel(id){return new Promise((res,rej)=>{const tx=this.db.transaction('threads','readwrite').objectStore('threads').delete(id);tx.onsuccess=()=>res();tx.onerror=()=>rej(tx.error)})}
})
</script>
</body>
</html>