Files
devsune/index.html

247 lines
59 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 name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
<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>
.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}
.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:0.875rem;display:flex;align-items:center;gap:.5rem}
#htmlEditor,#extensionHtmlEditor{outline:none;white-space:pre!important;font-size:11px;line-height:1.5;}
.tab-btn{-webkit-tap-highlight-color:transparent}
</style>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
</head>
<body class="bg-white text-gray-900 selection:bg-black/10">
<div class="flex flex-col h-dvh max-h-dvh">
<header id="topbar" 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 id="sidebarBtnLeft" 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" hx-on="click:document.getElementById('sidebarLeft').classList.remove('-translate-x-full');document.getElementById('sidebarOverlayLeft').classList.remove('hidden')"><i data-lucide="panel-left" class="h-5 w-5"></i></button>
<button id="suneBtnTop" 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="Sune settings"></button>
<div class="justify-self-end"><button id="sidebarBtnRight" 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" hx-on="click:renderThreads();document.getElementById('sidebarRight').classList.remove('translate-x-full');document.getElementById('sidebarOverlayRight').classList.remove('hidden')"><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"><section id="suneHtml" class="px-1 border-b border-gray-200 hidden"></section><div id="messages" class="mx-auto w-full max-w-none px-0 py-4 sm:py-6 space-y-4" hx-on="click: if(event.target.closest('.msg-avatar')){document.getElementById('sidebarLeft').classList.remove('-translate-x-full');document.getElementById('sidebarOverlayLeft').classList.remove('hidden')}"></div><div class="h-24"></div></main>
<footer id="footer" class="sticky bottom-0 z-10 bg-gradient-to-t from-white via-white/95 to-white/40 pt-2 pb-[calc(12px+var(--safe-bottom))] border-t border-gray-200">
<div class="mx-auto w-full max-w-none px-0">
<form id="composer" class="group relative flex items-start gap-2 px-3">
<textarea id="input" rows="1" placeholder="Send a message" spellcheck="false" autocapitalize="none" autocomplete="off" autocorrect="off" inputmode="text" enterkeyhint="enter" class="flex-1 resize-none rounded-2xl border-none bg-white px-3 py-2 text-[14px] leading-6 placeholder:text-gray-400 focus:outline-none focus:ring-0 max-h-52 overflow-y-auto min-h-[96px]"></textarea>
<div class="flex flex-col gap-2 self-stretch justify-center">
<button id="sendBtn" 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"><i data-lucide="sparkles" class="h-5 w-5"></i></button>
<button id="attachBtn" type="button" aria-label="Attach" class="relative shrink-0 rounded-2xl bg-gray-100 text-gray-900 h-10 w-10 inline-flex items-center justify-center shadow-sm hover:bg-gray-200 active:scale-[.98] transition"><i data-lucide="paperclip" class="h-5 w-5"></i><span id="attachBadge" class="hidden absolute -top-1 -right-1 h-4 min-w-4 px-1 rounded-full bg-black text-white text-[10px] leading-4 text-center"></span></button>
</div>
<input id="fileInput" type="file" class="hidden" multiple accept="image/png,image/jpeg,image/webp,image/gif,application/pdf,audio/wav,audio/x-wav,audio/mpeg,audio/mp3"/>
</form>
</div>
</footer>
</div>
<div id="sidebarOverlayLeft" class="fixed inset-0 z-40 bg-black/20 hidden" hx-on="click:document.getElementById('sidebarLeft').classList.add('-translate-x-full');this.classList.add('hidden');document.getElementById('sidebarRight').classList.add('translate-x-full');document.getElementById('sidebarOverlayRight').classList.add('hidden');hideThreadPopover();hideSunePopover()"></div>
<aside id="sidebarLeft" class="fixed inset-y-0 left-0 z-50 w-72 max-w-[85vw] bg-white border-r border-gray-200 shadow-xl transform -translate-x-full transition-transform duration-200 ease-out flex flex-col">
<div class="p-3 border-b flex items-center gap-2"><button id="newSuneBtn" class="px-3 py-2 rounded-xl bg-black text-white text-sm hover:bg-black/90">New sune</button><span class="text-xs text-gray-500">Click name to equip</span></div>
<div id="suneList" class="flex-1 overflow-y-auto divide-y"></div>
<div class="p-3 border-t relative">
<button id="userMenuBtn" class="w-full flex items-center justify-between px-3 py-2 rounded-xl bg-gray-100 hover:bg-gray-200 active:scale-[.99] transition" hx-on="click:event.stopPropagation();document.getElementById('userMenu').classList.toggle('hidden')"><span class="flex items-center gap-2"><span class="h-6 w-6 rounded-full bg-gray-900 text-white flex items-center justify-center">👤</span><span class="text-sm">Account & Backup</span></span><i data-lucide="chevron-down" class="h-4 w-4"></i></button>
<div id="userMenu" class="absolute left-3 right-3 bottom-16 translate-y-2 rounded-xl border border-gray-200 bg-white shadow-lg hidden overflow-hidden">
<button id="accountSettingsOption" class="menu-item"><i data-lucide="settings" class="h-4 w-4"></i><span>Settings</span></button>
<button id="sunesImportOption" class="menu-item">Import sunes (.json)</button>
<button id="sunesExportOption" class="menu-item">Export sunes (.sune)</button>
<button id="threadsImportOption" class="menu-item">Import threads (.json)</button>
<button id="threadsExportOption" class="menu-item">Export threads (.json)</button>
</div>
<input id="importInput" type="file" accept="application/json,.json" class="hidden"/>
</div>
</aside>
<div id="sidebarOverlayRight" class="fixed inset-0 z-40 bg-black/20 hidden" hx-on="click:this.classList.add('hidden');document.getElementById('sidebarRight').classList.add('translate-x-full')"></div>
<aside id="sidebarRight" class="fixed inset-y-0 right-0 z-50 w-80 max-w-[90vw] bg-white border-l border-gray-200 shadow-xl transform translate-x-full transition-transform duration-200 ease-out flex flex-col">
<div class="p-3 border-b text-sm font-medium flex items-center justify-between"><span>Threads</span><button id="closeThreads" class="p-1 rounded hover:bg-gray-100" aria-label="Close" hx-on="click:document.getElementById('sidebarOverlayRight').classList.add('hidden');document.getElementById('sidebarRight').classList.add('translate-x-full')"><svg viewBox="0 0 24 24" class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg></button></div>
<div id="threadList" class="flex-1 overflow-y-auto divide-y"></div>
</aside>
<div id="threadPopover" class="menu-card hidden">
<button data-action="pin" class="menu-item"><i data-lucide="pin" class="h-4 w-4"></i><span>Pin to top</span></button>
<button data-action="rename" class="menu-item"><i data-lucide="edit-3" class="h-4 w-4"></i><span>Rename</span></button>
<button data-action="delete" class="menu-item text-red-600"><i data-lucide="trash-2" class="h-4 w-4"></i><span>Delete</span></button>
<button data-action="count_tokens" class="menu-item"><i data-lucide="hash" class="h-4 w-4"></i><span>Count tokens (approx.)</span></button>
</div>
<div id="sunePopover" class="menu-card hidden">
<button data-action="pin" class="menu-item"><i data-lucide="pin" class="h-4 w-4"></i><span>Pin to top</span></button>
<button data-action="rename" class="menu-item"><i data-lucide="edit-3" class="h-4 w-4"></i><span>Rename</span></button>
<button data-action="pfp" class="menu-item"><i data-lucide="image" class="h-4 w-4"></i><span>Change pfp</span></button>
<button data-action="export" class="menu-item"><i data-lucide="download" class="h-4 w-4"></i><span>Export sune (.sune)</span></button>
</div>
<div id="suneModal" class="hidden fixed inset-0 z-50">
<div class="absolute inset-0 bg-black/30"></div>
<div class="absolute inset-x-0 top-12 mx-auto w-full max-w-md px-4">
<div class="rounded-2xl bg-white shadow-xl border border-gray-200 overflow-hidden">
<div class="px-4 py-3 border-b text-sm font-semibold flex items-center gap-2"><input id="suneURL" type="text" placeholder="" class="flex-1 min-w-0 h-10 rounded-xl border-0 bg-gray-50 px-3 text-gray-400 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-200 focus:bg-white text-xs font-mono focus:text-black"/><button id="syncSune" class="p-1.5 rounded hover:bg-gray-100" aria-label="Refresh"><i data-lucide="refresh-cw" class="h-5 w-5"></i></button></div>
<form id="settingsForm" class="text-sm">
<div class="border-b flex text-xs font-medium"><button type="button" id="tabModel" class="tab-btn flex-1 py-2 px-3 text-center border-b-2">Model & Sampling</button><button type="button" id="tabPrompt" class="tab-btn flex-1 py-2 px-3 text-center border-b-2">System Prompt</button><button type="button" id="tabScript" class="tab-btn flex-1 py-2 px-3 text-center border-b-2">HTML</button></div>
<div id="panelModel" class="p-4 space-y-4">
<div class="grid grid-cols-2 gap-3"><div><label class="block text-gray-700 font-medium mb-1">Model name</label><input id="set_model" type="text" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="openai/gpt-5"/><p class="mt-1 text-xs text-gray-500">Optional: prefix with or: or oai:</p></div><div><label class="block text-gray-700 font-medium mb-1">Reasoning Effort</label><select id="set_reasoning_effort" class="w-full rounded-xl border border-gray-300 px-3 py-2"><option value="default">Omitted</option><option value="low">Low</option><option value="medium">Medium</option><option value="high">High</option></select><p class="mt-1 text-xs text-gray-500">If supported by the model.</p></div></div>
<div class="grid grid-cols-2 gap-3">
<div><label class="block text-gray-700 font-medium mb-1">Temperature <span class="text-gray-400">(02)</span></label><input id="set_temperature" type="number" min="0" max="2" step="0.1" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="1.0"/><p class="mt-1 text-xs text-gray-500">Variety. Lower = predictable.</p></div>
<div><label class="block text-gray-700 font-medium mb-1">Top P <span class="text-gray-400">(01)</span></label><input id="set_top_p" type="number" min="0" max="1" step="0.01" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="1.0"/><p class="mt-1 text-xs text-gray-500">Nucleus sampling.</p></div>
<div><label class="block text-gray-700 font-medium mb-1">Top K</label><input id="set_top_k" type="number" min="0" step="1" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="0"/><p class="mt-1 text-xs text-gray-500">Token shortlist size.</p></div>
<div><label class="block text-gray-700 font-medium mb-1">Frequency Penalty <span class="text-gray-400">(-22)</span></label><input id="set_frequency_penalty" type="number" min="-2" max="2" step="0.1" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="0.0"/><p class="mt-1 text-xs text-gray-500">Discourage repeats by count.</p></div>
<div><label class="block text-gray-700 font-medium mb-1">Presence Penalty <span class="text-gray-400">(-22)</span></label><input id="set_presence_penalty" type="number" min="-2" max="2" step="0.1" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="0.0"/><p class="mt-1 text-xs text-gray-500">Discourage seen tokens.</p></div>
<div><label class="block text-gray-700 font-medium mb-1">Repetition Penalty <span class="text-gray-400">(02)</span></label><input id="set_repetition_penalty" type="number" min="0" max="2" step="0.1" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="1.0"/><p class="mt-1 text-xs text-gray-500">Reduce verbatim echoes.</p></div>
<div><label class="block text-gray-700 font-medium mb-1">Min P <span class="text-gray-400">(01)</span></label><input id="set_min_p" type="number" min="0" max="1" step="0.01" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="0.0"/><p class="mt-1 text-xs text-gray-500">Minimum token prob vs best.</p></div>
<div><label class="block text-gray-700 font-medium mb-1">Top A <span class="text-gray-400">(01)</span></label><input id="set_top_a" type="number" min="0" max="1" step="0.01" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="0.0"/><p class="mt-1 text-xs text-gray-500">Adaptive nucleus filter.</p></div>
<div><label class="block text-gray-700 font-medium mb-1">Max Tokens</label><input id="set_max_tokens" type="number" min="1" step="1" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder=""/><p class="mt-1 text-xs text-gray-500">Leave blank to omit.</p></div>
<div><label class="block text-gray-700 font-medium mb-1">Verbosity</label><select id="set_verbosity" class="w-full rounded-xl border border-gray-300 px-3 py-2"><option value="">Omitted</option><option value="low">Low</option><option value="medium">Medium</option><option value="high">High</option></select><p class="mt-1 text-xs text-gray-500">OpenAI param.</p></div>
</div>
</div>
<div id="panelPrompt" class="p-4 space-y-4 hidden">
<div><label class="block text-gray-700 font-medium mb-1">System Prompt</label><textarea id="set_system_prompt" rows="8" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="Enter a system prompt to guide the sune"></textarea><div class="mt-2 flex gap-2"><button type="button" id="copySystemPrompt" class="px-3 py-1.5 rounded-lg bg-gray-100 hover:bg-gray-200">Copy</button><button type="button" id="pasteSystemPrompt" class="px-3 py-1.5 rounded-lg bg-gray-100 hover:bg-gray-200">Paste</button></div><p class="mt-1 text-xs text-gray-500">Saved per sune.</p></div>
</div>
<div id="panelScript" class="p-1 hidden">
<div class="border-b flex text-xs font-medium"><button type="button" id="htmlTab_index" class="tab-btn flex-1 py-2 px-3 text-center border-b-2"></button><button type="button" id="htmlTab_extension" class="tab-btn flex-1 py-2 px-3 text-center border-b-2"></button></div>
<div class="pt-0">
<pre id="htmlEditor" class="w-full h-[50vh] p-3 rounded-xl border border-gray-300 bg-white overflow-auto font-mono text-[12px] leading-5" contenteditable="plaintext-only" spellcheck="false"></pre>
<pre id="extensionHtmlEditor" class="w-full h-[50vh] p-3 rounded-xl border border-gray-300 bg-white overflow-auto font-mono text-[12px] leading-5 hidden" contenteditable="plaintext-only" spellcheck="false"></pre>
</div>
<div class="mt-2 flex gap-2"><button type="button" id="copyHTML" class="px-3 py-1.5 rounded-lg bg-gray-100 hover:bg-gray-200">Copy</button><button type="button" id="pasteHTML" class="px-3 py-1.5 rounded-lg bg-gray-100 hover:bg-gray-200">Paste</button></div>
<p class="mt-1 text-xs text-gray-500">Scripts also run. extension.html runs after index.html.</p>
</div>
<div class="flex items-center justify-between gap-2 px-4 py-3 border-t">
<button type="button" id="deleteSuneBtn" class="inline-flex items-center gap-2 px-3 py-2 rounded-xl border border-red-200 text-red-700 hover:bg-red-50"><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" id="cancelSettings" class="px-3 py-2 rounded-xl border bg-white hover:bg-gray-50">Cancel</button><button type="submit" class="px-3 py-2 rounded-xl bg-black text-white hover:bg-black/90">Save</button></div>
</div>
</form>
</div>
</div>
</div>
<div id="accountSettingsModal" class="hidden fixed inset-0 z-50">
<div class="absolute inset-0 bg-black/30"></div>
<div class="absolute inset-x-0 top-16 mx-auto w-full max-w-md px-4">
<div class="rounded-2xl bg-white shadow-xl border border-gray-200 overflow-hidden">
<div class="px-4 py-3 border-b text-sm font-semibold flex items-center justify-between"><span>Account Settings</span><button id="closeAccountSettings" class="p-1 rounded hover:bg-gray-100" aria-label="Close"><svg viewBox="0 0 24 24" class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg></button></div>
<form id="accountSettingsForm" class="text-sm">
<div class="border-b flex text-xs font-medium"><button type="button" id="accountTabGeneral" class="tab-btn flex-1 py-2 px-3 text-center border-b-2">General</button><button type="button" id="accountTabAPI" class="tab-btn flex-1 py-2 px-3 text-center border-b-2">API</button></div>
<div id="accountPanelGeneral" class="p-4 space-y-4">
<div><label class="block text-gray-700 font-medium mb-1">Provider</label><select id="set_provider" class="w-full rounded-xl border border-gray-300 px-3 py-2"><option value="openrouter">OpenRouter</option><option value="openai">OpenAI</option></select><p class="mt-1 text-xs text-gray-500">Or you can just prefix names with or: or oai: to override.</p></div>
<div><label class="block text-gray-700 font-medium mb-1">Master Prompt</label><textarea id="set_master_prompt" rows="6" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="Applies to all sunes on this device"></textarea><p class="mt-1 text-xs text-gray-500">Stored locally.</p></div>
<div><label class="block text-gray-700 font-medium mb-1">Model preference for titles</label><input id="set_title_model" type="text" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="or:google/gemma-3-12b-it" value="or:google/gemma-3-12b-it"/><p class="mt-1 text-xs text-gray-500">Used for auto-generating thread titles.</p></div>
</div>
<div id="accountPanelAPI" class="p-4 space-y-4 hidden">
<div><label class="block text-gray-700 font-medium mb-1">OpenRouter API Key</label><input id="set_api_key_or" type="password" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="sk-or-..."/><p class="mt-1 text-xs text-gray-500">Stored locally.</p></div>
<div><label class="block text-gray-700 font-medium mb-1">OpenAI API Key</label><input id="set_api_key_oai" type="password" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="sk-..."/><p class="mt-1 text-xs text-gray-500">Stored locally.</p></div>
<div><label class="block text-gray-700 font-medium mb-1">Github Token</label><input id="set_gh_token" type="password" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="ghp_... (optional)"/><p class="mt-1 text-xs text-gray-500">Optional. For future use.</p></div>
</div>
<div class="flex items-center justify-between gap-2 px-4 py-3 border-t">
<div class="flex items-center gap-2"><button type="button" id="importAccountSettings" class="text-xs px-2.5 py-1.5 rounded-lg border bg-white hover:bg-gray-50">Import</button><button type="button" id="exportAccountSettings" class="text-xs px-2.5 py-1.5 rounded-lg border bg-white hover:bg-gray-50">Export</button></div>
<div class="flex items-center justify-end gap-2"><button type="button" id="cancelAccountSettings" class="px-3 py-2 rounded-xl border bg-white hover:bg-gray-50">Cancel</button><button type="submit" class="px-3 py-2 rounded-xl bg-black text-white hover:bg-black/90">Save</button></div>
</div>
</form>
</div>
</div>
</div>
<input id="importAccountSettingsInput" type="file" class="hidden" accept="application/json,.json">
<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 src="https://cdn.jsdelivr.net/npm/localforage@1.10.0/dist/localforage.min.js"></script>
<script>
const DEFAULT_MODEL='openai/gpt-5',DEFAULT_API_KEY='',doc=document,ls=localStorage
const el=window.el=['topbar','chat','messages','composer','input','sendBtn','suneBtnTop','suneModal','suneURL','settingsForm','cancelSettings','tabModel','tabPrompt','tabScript','panelModel','panelPrompt','panelScript','set_model','set_temperature','set_top_p','set_top_k','set_frequency_penalty','set_presence_penalty','set_repetition_penalty','set_min_p','set_top_a','set_max_tokens','set_verbosity','set_reasoning_effort','set_system_prompt','deleteSuneBtn','sidebarLeft','sidebarOverlayLeft','sidebarBtnLeft','suneList','newSuneBtn','userMenuBtn','userMenu','accountSettingsOption','sunesImportOption','sunesExportOption','threadsImportOption','threadsExportOption','importInput','sidebarBtnRight','sidebarRight','sidebarOverlayRight','threadList','closeThreads','threadPopover','sunePopover','footer','attachBtn','attachBadge','fileInput','htmlEditor','extensionHtmlEditor','htmlTab_index','htmlTab_extension','suneHtml','accountSettingsModal','accountSettingsForm','closeAccountSettings','cancelAccountSettings','set_master_prompt','set_provider','set_api_key_or','set_api_key_oai','set_title_model','copySystemPrompt','pasteSystemPrompt','copyHTML','pasteHTML','accountTabGeneral','accountTabAPI','accountPanelGeneral','accountPanelAPI','set_gh_token','importAccountSettings','exportAccountSettings','importAccountSettingsInput','syncSune'].reduce((o,i)=>(o[i]=doc.getElementById(i),o),{})
const icons=()=>window.lucide?.createIcons(),haptic=()=>/android/i.test(navigator.userAgent)&&navigator.vibrate?.(4)
const clamp=(v,m,M)=>Math.max(m,Math.min(M,v)),num=(v,d)=>v==null||v===''||isNaN(+v)?d:+v,int=(v,d)=>isNaN(v=parseInt(v))?d:v,gid=()=>(Math.random().toString(36)+'0000000').slice(2,9),esc=s=>(''+s).replace(/[&<>'"`]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;","`":"&#96;"}[c]))
const sid=()=>Date.now().toString(36)+Math.random().toString(36).slice(2,6),fmtSize=b=>{const u=['B','KB','MB','GB','TB'],i=b>0?Math.floor(Math.log(b)/Math.log(1024)):0;return parseFloat((b/Math.pow(1024,i)).toFixed(i>0?1:0))+' '+u[i]},asDataURL=f=>new Promise(r=>{const fr=new FileReader();fr.onload=()=>r(fr.result+'');fr.readAsDataURL(f)}),b64=x=>x.split(',')[1]||''
const globalStore={get provider(){return ls.getItem('provider')||'openrouter'},set provider(v){ls.setItem('provider',v==='openai'?'openai':'openrouter')},get apiKeyOR(){return ls.getItem('openrouter_api_key')||DEFAULT_API_KEY||''},set apiKeyOR(v){ls.setItem('openrouter_api_key',v||'')},get apiKeyOAI(){return ls.getItem('openai_api_key')||''},set apiKeyOAI(v){ls.setItem('openai_api_key',v||'')},get masterPrompt(){return ls.getItem('master_prompt')||''},set masterPrompt(v){ls.setItem('master_prompt',v||'')},get titleModel(){return ls.getItem('title_model')??'or:meta-llama/llama-3.2-3b-instruct'},set titleModel(v){ls.setItem('title_model',v||'')},get ghToken(){return ls.getItem('gh_token')||''},set ghToken(v){ls.setItem('gh_token',v||'')}}
const su={key:'sunes_v1',activeKey:'active_sune_id',load(){try{return JSON.parse(ls.getItem(this.key)||'[]')}catch{return[]}},save(l){ls.setItem(this.key,JSON.stringify(l||[]))},getActiveId(){return ls.getItem(this.activeKey)||null},setActiveId(id){ls.setItem(this.activeKey,id||'')}}
const defaultSettings={model:DEFAULT_MODEL,temperature:1,top_p:0.96,top_k:0,frequency_penalty:0,presence_penalty:0,repetition_penalty:1,min_p:0,top_a:0,max_tokens:0,verbosity:'',reasoning_effort:'default',system_prompt:'',html:'',extension_html:''}
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:{...defaultSettings,...p.settings||{}}})
let sunes=(su.load()||[]).map(makeSune)
const SUNE=window.SUNE=new Proxy({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);return s},delete(id){const c=this.id;sunes=sunes.filter(s=>s.id!==id);su.save(sunes);if(!sunes.length)this.setActive(this.create({name:'Default'}).id);else if(c===id)this.setActive(sunes[0].id)},save:()=>su.save(sunes)},{get:(t,p)=>{if(p==='getThread')return id=>threads.find(t=>t.id===id)||null;if(p==='setThreadTitle')return async(id,title)=>{const th=threads.find(t=>t.id===id);if(th&&title){th.title=titleFrom(title);th.updatedAt=Date.now();await tsave(threads);await renderThreads()}};if(p==='attach')return async(files,opts={})=>{const clean=(await Promise.all(Array.from(files||[]).map(toAttach))).filter(Boolean);if(!clean.length)return;await ensureThreadOnFirstUser(clean[0]?.name||'(attachments)');const{toAPI=true,tree=true}=typeof opts==='boolean'?{toAPI:opts,tree:true}:opts||{};if(toAPI)addMessage({role:'assistant',content:clean.map(a=>a.part),...activeMeta()});if(tree)addAttachmentTree('assistant',clean);await persistThread()};if(p==='log')return async s=>{const txt=(s||'').trim();if(txt){await ensureThreadOnFirstUser(txt);addMessage({role:'assistant',content:[{type:'text',text:txt}],...activeMeta()});await persistThread()}};if(p in t)return t[p];if(p==='provider')return globalStore.provider;if(p==='apiKey')return globalStore.provider==='openai'?globalStore.apiKeyOAI:globalStore.apiKeyOR;if(p==='apiKeyOR')return globalStore.apiKeyOR;if(p==='apiKeyOAI')return globalStore.apiKeyOAI;if(p==='masterPrompt')return globalStore.masterPrompt;if(p==='titleModel')return globalStore.titleModel;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)=>{if(p==='provider'){globalStore.provider=v;return!0}if(p==='apiKey'){if(globalStore.provider==='openai')globalStore.apiKeyOAI=v;else globalStore.apiKeyOR=v;return!0}if(p==='apiKeyOR'){globalStore.apiKeyOR=v;return!0}if(p==='apiKeyOAI'){globalStore.apiKeyOAI=v;return!0}if(p==='masterPrompt'){globalStore.masterPrompt=v;return!0}if(p==='titleModel'){globalStore.titleModel=v;return!0}const a=t.active;if(!a)return!1;const i=sunes.findIndex(s=>s.id===a.id);if(i<0)return!1;if(['name','avatar','url','pinned'].includes(p))sunes[i][p]=v;else if(p==='model')sunes[i].settings.model=v||DEFAULT_MODEL;else if(p==='system_prompt')sunes[i].settings.system_prompt=v||'';else sunes[i].settings[p]=v;sunes[i].updatedAt=Date.now();su.save(sunes);return!0}})
if(!sunes.length)SUNE.setActive(SUNE.create({name:'Default'}).id)
const state=window.state={messages:[],busy:!1,controller:null,currentThreadId:null,abortRequested:!1,attachments:[],stream:{rid:null,bubble:null,meta:null,text:'',done:!1}}
const getModelShort=m=>(m||SUNE.model||'').split('/').pop(),resolveSuneSrc=src=>src?.startsWith('gh://')?`https://raw.githubusercontent.com/${src.slice(5).split('/').slice(0,2).join('/')}/main/${src.slice(5).split('/').slice(2).join('/')}`:src
const processSuneIncludes=async(html,d=0)=>{if(d>5||!html)return d>5?'<!-- Sune include depth limit reached -->':'';const c=doc.createElement('div');c.innerHTML=html;for(const n of c.querySelectorAll('sune')){if(n.hasAttribute('src')){if(n.hasAttribute('private')&&d>0){n.remove();continue}const u=resolveSuneSrc(n.getAttribute('src'));if(!u){n.replaceWith(doc.createComment(` Invalid src: ${esc(n.getAttribute('src'))} `));continue}try{const r=await fetch(u);if(!r.ok)throw new Error(`HTTP ${r.status}`);const o=await r.json(),h=[o?.settings?.html||'',o?.settings?.extension_html||''].join('\n');n.replaceWith(doc.createRange().createContextualFragment(await processSuneIncludes(h,d+1)))}catch(e){n.replaceWith(doc.createComment(` Fetch failed: ${esc(u)} `))}}else n.replaceWith(doc.createRange().createContextualFragment(n.innerHTML))}return c.innerHTML}
const renderSuneHTML=async()=>{const finalHtml=await processSuneIncludes([SUNE.html,SUNE.extension_html].map(x=>(x||'').trim()).join('\n'));const c=el.suneHtml;c.innerHTML='';c.classList.toggle('hidden',!finalHtml.trim());if(!finalHtml.trim())return;c.insertAdjacentHTML('afterbegin',finalHtml);const scripts=[...c.querySelectorAll('script:not([type]),script[type="text/javascript"]')].map(s=>(s.remove(),s.textContent));if(scripts.length)setTimeout(()=>scripts.forEach(code=>{try{new Function(code)()}catch(e){console.error('Sune script error:',e)}}),0)}
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-8 w-8 rounded-full object-cover"/>`:'✺';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-6 w-6 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=()=>{(el.suneList.innerHTML=[...SUNE.list].sort((a,b)=>(b.pinned-a.pinned)).map(suneRow).join(''));icons()}
function enhanceCodeBlocks(root,doHL=!0){root.querySelectorAll('pre>code').forEach(c=>{if(c.textContent.length>200000)return;const p=c.parentElement;p.classList.add('relative','rounded-xl','border','border-gray-200');if(p.querySelector('.copy-btn'))return;const b=doc.createElement('button');b.className='copy-btn';b.textContent='Copy';b.onclick=async e=>{e.stopPropagation();try{await navigator.clipboard.writeText(c.innerText);b.textContent='Copied';setTimeout(()=>b.textContent='Copy',1200)}catch{}};p.appendChild(b);if(doHL&&window.hljs&&c.textContent.length<100000)hljs.highlightElement(c)})}
const md=window.markdownit({html:!1,linkify:!0,typographer:!0,breaks:!0}),getSuneLabel=m=>`${(m?.sune_name)||SUNE.name} · ${getModelShort(m?.model)}`
function msgRow(m){const role=typeof m==='string'?m:m?.role||'assistant',meta=typeof m==='string'?{}:m||{},isUser=role==='user',row=doc.createElement('div');row.className='flex flex-col gap-2';const head=doc.createElement('div');head.className='flex items-center gap-2 px-4';const avatar=doc.createElement('div');avatar.className='msg-avatar shrink-0 h-7 w-7 rounded-full flex items-center justify-center';if(isUser){avatar.classList.add('bg-gray-900','text-white');avatar.textContent='🧑'}else if(meta.avatar){avatar.classList.add('overflow-hidden');const img=doc.createElement('img');img.src=meta.avatar;img.className='h-full w-full object-cover';avatar.append(img)}else{avatar.classList.add('bg-gray-200','text-gray-900');avatar.textContent='✺'}const name=doc.createElement('div');name.className='text-xs font-medium text-gray-500';name.textContent=isUser?'You':getSuneLabel(meta);const delBtn=doc.createElement('button');delBtn.className='ml-auto p-1.5 rounded-lg hover:bg-gray-200 text-gray-400 hover:text-red-500';delBtn.title='Delete message';delBtn.innerHTML='<i data-lucide="eraser" class="h-4 w-4"></i>';delBtn.onclick=async e=>{e.stopPropagation();if(confirm('Delete message?')){state.messages=state.messages.filter(msg=>msg.id!==m.id);row.remove();await persistThread()}};const copyBtn=doc.createElement('button');copyBtn.className='p-1.5 rounded-lg hover:bg-gray-200 text-gray-400 hover:text-gray-600';copyBtn.title='Copy message';copyBtn.innerHTML='<i data-lucide="copy" class="h-4 w-4"></i>';copyBtn.onclick=async function(e){e.stopPropagation();try{await navigator.clipboard.writeText(this.parentElement.nextElementSibling.innerText);this.innerHTML='<i data-lucide="check" class="h-4 w-4 text-green-500"></i>';icons();setTimeout(()=>{this.innerHTML='<i data-lucide="copy" class="h-4 w-4"></i>';icons()},1200)}catch{}};head.append(avatar,name,delBtn,copyBtn);const bubble=doc.createElement('div');bubble.className=`${isUser?'bg-gray-50 border border-gray-200':'bg-gray-100'} msg-bubble markdown-body rounded-none px-4 py-3 w-full`;row.append(head,bubble);el.messages.append(row);queueMicrotask(()=>{el.chat.scrollTo({top:el.chat.scrollHeight,behavior:'smooth'});icons()});return bubble}
const renderMarkdown=window.renderMarkdown=(node,text,opt={enhance:!0,highlight:!0})=>{node.innerHTML=md.render(text);if(opt.enhance)enhanceCodeBlocks(node,opt.highlight)}
const partsToText=parts=>parts?.map?parts.map(p=>p?.type==='text'?p.text:(p?.type==='image_url'?`![](${p.image_url?.url||''})`:(p?.type==='file'?`[${p.file?.filename||'file'}]`:(p?.type==='input_audio'?`(audio:${p.input_audio?.format||''})`:'')))).join('\n'):''+(parts||'')
const addMessage=window.addMessage=(m,track=!0)=>{m.id=m.id||gid();if(m.content&&!m.content.map)m.content=[{type:'text',text:''+m.content}];const bubble=msgRow(m);bubble.dataset.mid=m.id;renderMarkdown(bubble,partsToText(m.content));if(track)state.messages.push(m);return bubble}
const addSuneBubbleStreaming=meta=>msgRow({role:'assistant',...meta}),clearChat=()=>{state.messages=[];el.messages.innerHTML='';state.attachments=[];updateAttachBadge();el.fileInput.value=''}
const payloadWithSampling=b=>{const o={...b,temperature:SUNE.temperature,top_p:SUNE.top_p,top_k:SUNE.top_k,frequency_penalty:SUNE.frequency_penalty,presence_penalty:SUNE.presence_penalty,repetition_penalty:SUNE.repetition_penalty,min_p:SUNE.min_p,top_a:SUNE.top_a};const mt=Math.max(0,int(SUNE.max_tokens||0,0));if(mt)o.max_tokens=mt;return o}
const setBtn=isStop=>{const b=el.sendBtn;Object.assign(b,{type:isStop?'button':'submit','aria-label':isStop?'Stop':'Send',innerHTML:isStop?'<i data-lucide="square" class="h-5 w-5"></i>':'<i data-lucide="sparkles" class="h-5 w-5"></i>',onclick:isStop?()=>{state.abortRequested=!0;state.controller?.abort?.()}:null});icons()}
const localDemoReply=()=>'Tip: open the sidebar → Account & Backup to set your API key.'
let threads=[],titleFrom=t=>(t||'').replace(/\s+/g,' ').trim().slice(0,60)||'Untitled'
const TKEY='threads_v1',tload=()=>localforage.getItem(TKEY).then(v=>[].concat(v||[])),tsave=v=>localforage.setItem(TKEY,v),cacheStore=localforage.createInstance({name:'master_cache'})
async function ensureThreadOnFirstUser(text){let needNew=!state.currentThreadId||(state.currentThreadId&&!threads.some(x=>x.id===state.currentThreadId));if(state.messages.length===0)state.currentThreadId=null;if(!needNew)return;const id=gid(),now=Date.now(),th={id,title:window.doCreateTitle?titleFrom(text):'',pinned:!1,updatedAt:now,messages:[]};state.currentThreadId=id;threads.unshift(th);await tsave(threads);await renderThreads();doc.dispatchEvent(new CustomEvent('sune:new-thread',{detail:{threadId:id}}))}
async function persistThread(full=!0){if(!state.currentThreadId)return;let th=threads.find(x=>x.id===state.currentThreadId);if(!th)return;th.messages=[...state.messages];if(full){th.updatedAt=Date.now();if(window.doCreateTitle)th.title=titleFrom(partsToText(th.messages.find(m=>m.role==='user')?.content)||th.title)}await tsave(threads);if(full)await renderThreads()}
const threadRow=t=>`<div class="relative flex items-center gap-2 px-3 py-2 ${t.pinned?'bg-yellow-50':''}"><button data-open-thread="${t.id}" class="flex-1 text-left truncate">${t.pinned?'📌 ':''}${esc(t.title)}</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>`
async function renderThreads(){el.threadList.innerHTML=[...threads].sort((a,b)=>(b.pinned-a.pinned)||(b.updatedAt-a.updatedAt)).map(threadRow).join('');icons()}
let menuThreadId=null,menuSuneId=null;const hideThreadPopover=()=>{el.threadPopover.classList.add('hidden');menuThreadId=null},hideSunePopover=()=>{el.sunePopover.classList.add('hidden');menuSuneId=null}
const showPopover=(pop,btn,idCb)=>{const r=btn.getBoundingClientRect();pop.style.top=r.bottom+4+'px';pop.style.left=Math.min(innerWidth-220,r.right-200)+'px';pop.classList.remove('hidden');icons();idCb()}
el.threadList.addEventListener('click',async e=>{const openBtn=e.target.closest('[data-open-thread]'),menuBtn=e.target.closest('[data-thread-menu]');if(openBtn){const id=openBtn.dataset.openThread,th=threads.find(t=>t.id===id);if(!th)return;if(id!==state.currentThreadId&&state.busy){state.controller?.disconnect?.();setBtn(!1);state.busy=!1;state.controller=null}if(id===state.currentThreadId){el.sidebarRight.classList.add('translate-x-full');el.sidebarOverlayRight.classList.add('hidden');hideThreadPopover();return}state.currentThreadId=id;clearChat();state.messages=[...(th.messages||[])];for(const m of state.messages){const b=msgRow(m);b.dataset.mid=m.id||'';renderMarkdown(b,partsToText(m.content))}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()}else if(menuBtn){e.stopPropagation();showPopover(el.threadPopover,menuBtn,()=>menuThreadId=menuBtn.dataset.threadMenu)}})
el.threadPopover.addEventListener('click',async e=>{const act=e.target.closest('[data-action]')?.dataset.action,th=menuThreadId&&threads.find(t=>t.id===menuThreadId);if(!act||!th)return;if(act==='pin')th.pinned=!th.pinned;else if(act==='rename'){const nv=prompt('Rename to:',th.title);if(nv!=null){th.title=titleFrom(nv);th.updatedAt=Date.now()}}else if(act==='delete'){if(confirm('Delete this chat?')){threads=threads.filter(x=>x.id!==th.id);if(state.currentThreadId===th.id){state.currentThreadId=null;clearChat()}}}else if(act==='count_tokens'){const totalChars=(th.messages||[]).reduce((sum,m)=>(m&&m.role&&m.role!=='system')?sum+(partsToText(m.content||'').length):sum,0),tokens=Math.ceil(totalChars/4);alert(`${tokens} tokens (${tokens>=1e3?`${Math.round(tokens/1e3)}k`:tokens})`)}hideThreadPopover();await tsave(threads);renderThreads()})
el.suneList.addEventListener('click',async e=>{const menuBtn=e.target.closest('[data-sune-menu]');if(menuBtn){e.stopPropagation();showPopover(el.sunePopover,menuBtn,()=>menuSuneId=menuBtn.dataset.suneMenu);return}const btn=e.target.closest('[data-sune-id]');if(!btn)return;const id=btn.dataset.suneId;if(id){if(state.busy){state.controller?.disconnect?.();setBtn(!1);state.busy=!1;state.controller=null}SUNE.setActive(id);renderSidebar();await reflectActiveSune();state.currentThreadId=null;clearChat();el.sidebarLeft.classList.add('-translate-x-full');el.sidebarOverlayLeft.classList.add('hidden')}})
el.sunePopover.addEventListener('click',async e=>{const act=e.target.closest('[data-action]')?.dataset.action,s=menuSuneId&&SUNE.get(menuSuneId);if(!act||!s)return;const updateAndRender=async()=>{s.updatedAt=Date.now();SUNE.save();renderSidebar();await reflectActiveSune()};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=doc.createElement('input');i.type='file';i.accept='image/*';i.onchange=()=>{const f=i.files?.[0];if(!f)return;const img=new Image;img.onload=async()=>{const c=doc.createElement('canvas'),ctx=c.getContext('2d'),D=144;let{width:w,height:h}=img;if(Math.max(w,h)>D)w>h?(h=D*h/w,w=D):(w=D*w/h,h=D);c.width=w;c.height=h;ctx.drawImage(img,0,0,w,h);s.avatar=c.toDataURL('image/webp',.84);await updateAndRender();URL.revokeObjectURL(img.src)};img.src=URL.createObjectURL(f)};i.click()}else if(act==='export')dl(`sune-${(s.name||'sune').replace(/\W/g,'_')}-${ts()}.sune`,[s]);hideSunePopover()})
const updateAttachBadge=()=>{const n=state.attachments.length;el.attachBadge.textContent=n+'';el.attachBadge.classList.toggle('hidden',!n)}
async function toAttach(f){if(!f)return null;const pick=(name,bytes,mime,data,mode,part)=>({name,bytes,mime,data,mode,part});if(f instanceof File){const name=f.name||'file',mime=(f.type||'application/octet-stream').toLowerCase(),bytes=f.size||0;if(/^image\//.test(mime)||/\.(png|jpe?g|webp|gif)$/i.test(name)){const d=await asDataURL(f);return pick(name,bytes,mime,d,'dataURL',{type:'image_url',image_url:{url:d}})}if(mime==='application/pdf'||/\.pdf$/i.test(name)){const d=await asDataURL(f),bin=b64(d);return pick(name.endsWith('.pdf')?name:name+'.pdf',bytes,'application/pdf',bin,'base64',{type:'file',file:{filename:name,file_data:bin}})}const d=await asDataURL(f),bin=b64(d),fmt=/mp3/.test(mime)||/\.mp3$/i.test(name)?'mp3':'wav';if(/^audio\//.test(mime)||/\.(wav|mp3)$/i.test(name))return pick(name,bytes,mime,bin,'base64',{type:'input_audio',input_audio:{data:bin,format:fmt}});return pick(name,bytes,mime,bin,'base64',{type:'file',file:{filename:name,file_data:bin}})}if(f&&f.name==null&&f.data){const name=f.name||'file',mime=(f.mime||'application/octet-stream').toLowerCase(),bytes=f.size||0,fmt=/mp3/.test(mime)?'mp3':'wav';const url=`data:${mime};base64,${f.data}`;if(/^image\//.test(mime))return pick(name,bytes,mime,url,'dataURL',{type:'image_url',image_url:{url}});if(mime==='application/pdf')return pick(name,bytes,mime,f.data,'base64',{type:'file',file:{filename:name,file_data:f.data}});if(/^audio\//.test(mime))return pick(name,bytes,mime,f.data,'base64',{type:'input_audio',input_audio:{data:f.data,format:fmt}});return pick(name,bytes,mime,f.data,'base64',{type:'file',file:{filename:name,file_data:f.data}})}return null}
const attachmentsText=(id,arr)=>`**Attachments**\n${arr.map((a,i)=>`- [${esc(a.name)}${fmtSize(a.bytes)}](#dl-${id}-${i})`).join('\n')}`
function addAttachmentTree(role,arr){if(!arr?.length)return;const id=gid(),text=attachmentsText(id,arr),meta={role,content:[{type:'text',text}],id,kind:'attachments',attachmentsMeta:arr.map(a=>({name:a.name,bytes:a.bytes,mime:a.mime,mode:a.mode,data:a.data}))};addMessage(meta,!0).dataset.mid=id}
el.attachBtn.onclick=()=>{if(state.busy)return;if(state.attachments.length){state.attachments=[];updateAttachBadge();el.fileInput.value=''};el.fileInput.click()}
el.fileInput.onchange=async()=>{for(const f of el.fileInput.files||[]){const at=await toAttach(f).catch(()=>null);if(at)state.attachments.push(at)}updateAttachBadge()}
el.messages.addEventListener('click',async e=>{const a=e.target.closest('a[href^="#dl-"]');if(!a)return;e.preventDefault();const m=a.getAttribute('href').match(/^#dl-([^-]+)-(\d+)$/);if(!m)return;const[,id,i]=m,msg=state.messages.find(x=>x.id===id),meta=msg?.attachmentsMeta?.[+i];if(!meta)return;const blob=meta.mode==='dataURL'?await(await fetch(meta.data)).blob():new Blob([Uint8Array.from(atob(meta.data),c=>c.charCodeAt(0))],{type:meta.mime||'application/octet-stream'}),url=URL.createObjectURL(blob),dl=doc.createElement('a');dl.href=url;dl.download=meta.name||'download';doc.body.append(dl);dl.click();dl.remove();URL.revokeObjectURL(url)})
el.composer.addEventListener('submit',async e=>{e.preventDefault();if(state.busy)return;const text=el.input.value.trim();if(!text&&!state.attachments.length)return;await ensureThreadOnFirstUser(text||'(attachments)');el.input.value='';const parts=[];if(text)parts.push({type:'text',text});state.attachments.forEach(a=>parts.push(a.part));addMessage({role:'user',content:parts.length?parts:[{type:'text',text:text||'(sent attachments)'}]});if(state.attachments.length)addAttachmentTree('user',state.attachments);state.busy=!0;setBtn(!0);const a=SUNE.active,suneMeta={sune_name:a.name,model:SUNE.model,avatar:a.avatar||''},suneBubble=addSuneBubbleStreaming(suneMeta),streamId=sid();suneBubble.dataset.mid=streamId;const assistantMsg={id:streamId,role:'assistant',content:[{type:'text',text:''}],...suneMeta};state.messages.push(assistantMsg);persistThread(!1);state.stream={rid:streamId,bubble:suneBubble,meta:suneMeta,text:'',done:!1};let buf='',completed=!1;const onDelta=(delta,done)=>{buf+=delta;state.stream.text=buf;renderMarkdown(suneBubble,buf,{enhance:!1});assistantMsg.content[0].text=buf;if(done&&!completed){completed=!0;setBtn(!1);state.busy=!1;enhanceCodeBlocks(suneBubble,!0);persistThread(!0);state.stream={rid:null,bubble:null,meta:null,text:'',done:!1}}else if(!done)persistThread(!1)};await askOpenRouterStreaming(onDelta,streamId);state.attachments=[];updateAttachBadge()})
let jars={html:null,extension:null},openedHTML=!1
const ensureJars=async()=>{if(jars.html&&jars.extension)return jars;const{CodeJar:CJ}=await import('https://medv.io/codejar/codejar.js'),hl=e=>e.innerHTML=hljs.highlight(e.textContent,{language:'xml'}).value;if(!jars.html)jars.html=CJ(el.htmlEditor,hl,{tab:' '});if(!jars.extension)jars.extension=CJ(el.extensionHtmlEditor,hl,{tab:' '});return jars}
const tabHandler=(tabs,key,cb)=>{for(const k in tabs){const[btnId,paneId]=tabs[k],active=k===key;el[btnId].classList.toggle('border-black',active);el[btnId].classList.toggle('border-transparent',!active);el[btnId].classList.toggle('hover:border-gray-300',!active);el[paneId].classList.toggle('hidden',!active)}cb?.(key)},suneTabs={Model:['tabModel','panelModel'],Prompt:['tabPrompt','panelPrompt'],Script:['tabScript','panelScript']},accountTabs={General:['accountTabGeneral','accountPanelGeneral'],API:['accountTabAPI','accountPanelAPI']},htmlTabs={index:['htmlTab_index','htmlEditor'],extension:['htmlTab_extension','extensionHtmlEditor']}
const showSuneTab=key=>tabHandler(suneTabs,key,k=>{if(k==='Script'){openedHTML=!0;showHtmlTab('index');ensureJars().then(({html,ext})=>{const s=SUNE.settings;html.updateCode(s.html||'');ext.updateCode(s.extension_html||'')})}}),showAccountTab=key=>tabHandler(accountTabs,key),showHtmlTab=key=>tabHandler(htmlTabs,key)
const openSettings=()=>{const a=SUNE.active,s=a.settings;openedHTML=!1;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_presence_penalty.value=s.presence_penalty;el.set_repetition_penalty.value=s.repetition_penalty;el.set_min_p.value=s.min_p;el.set_top_a.value=s.top_a;el.set_max_tokens.value=s.max_tokens||'';el.set_verbosity.value=s.verbosity||'';el.set_reasoning_effort.value=s.reasoning_effort||'default';el.set_system_prompt.value=s.system_prompt;showSuneTab('Model');el.suneModal.classList.remove('hidden')},closeSettings=()=>el.suneModal.classList.add('hidden')
el.suneBtnTop.onclick=openSettings,el.cancelSettings.onclick=closeSettings,el.suneModal.onclick=e=>{if(e.target===el.suneModal||e.target.classList.contains('bg-black/30'))closeSettings()}
el.tabModel.onclick=()=>showSuneTab('Model'),el.tabPrompt.onclick=()=>showSuneTab('Prompt'),el.tabScript.onclick=()=>showSuneTab('Script')
el.settingsForm.addEventListener('submit',async e=>{e.preventDefault();SUNE.url=(el.suneURL.value||'').trim();SUNE.model=(el.set_model.value||DEFAULT_MODEL).trim();SUNE.temperature=clamp(num(el.set_temperature.value,1),0,2);SUNE.top_p=clamp(num(el.set_top_p.value,1),0,1);SUNE.top_k=Math.max(0,int(el.set_top_k.value,0));SUNE.frequency_penalty=clamp(num(el.set_frequency_penalty.value,0),-2,2);SUNE.presence_penalty=clamp(num(el.set_presence_penalty.value,0),-2,2);SUNE.repetition_penalty=clamp(num(el.set_repetition_penalty.value,1),0,2);SUNE.min_p=clamp(num(el.set_min_p.value,0),0,1);SUNE.top_a=clamp(num(el.set_top_a.value,0),0,1);SUNE.max_tokens=Math.max(0,int(el.set_max_tokens.value,0));SUNE.verbosity=el.set_verbosity.value||'';SUNE.reasoning_effort=el.set_reasoning_effort.value||'default';SUNE.system_prompt=el.set_system_prompt.value.trim();if(openedHTML){SUNE.html=el.htmlEditor.textContent;SUNE.extension_html=el.extensionHtmlEditor.textContent}closeSettings();await reflectActiveSune()})
el.deleteSuneBtn.onclick=async()=>{if(confirm(`Delete "${SUNE.name||'this sune'}"?`)){SUNE.delete(SUNE.id);renderSidebar();await reflectActiveSune();state.currentThreadId=null;clearChat();closeSettings()}}
el.newSuneBtn.onclick=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();el.sidebarLeft.classList.add('-translate-x-full');el.sidebarOverlayLeft.classList.add('hidden')}
const dl=(name,obj)=>Object.assign(doc.createElement('a'),{href:URL.createObjectURL(new Blob([JSON.stringify(obj,null,2)],{type:name.endsWith('.sune')?'application/octet-stream':'application/json'})),download:name}).click()
const ts=()=>{const d=new Date(),p=n=>(n<10?'0':'')+n;return`${d.getFullYear()}${p(d.getMonth()+1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`}
let importMode=null;el.sunesExportOption.onclick=()=>{dl(`sunes-${ts()}.sune`,{version:1,sunes:SUNE.list,activeId:SUNE.id});el.userMenu.classList.add('hidden')}
el.sunesImportOption.onclick=()=>{importMode='sunes';el.importInput.value='';el.importInput.click()}
el.threadsExportOption.onclick=()=>{dl(`threads-${ts()}.json`,{version:1,threads});el.userMenu.classList.add('hidden')}
el.threadsImportOption.onclick=()=>{importMode='threads';el.importInput.value='';el.importInput.click()}
el.importInput.onchange=async()=>{const f=el.importInput.files?.[0];if(!f)return;try{const data=JSON.parse(await f.text());if(importMode==='sunes'){const list=[].concat(data?.sunes||data||[]),map={};list.map(makeSune).forEach(s=>{if(!s.id)s.id=gid();const k=s.id,p=map[k];map[k]=!p||(+s.updatedAt>+p.updatedAt)?s:p});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'){const norm=t=>({id:t.id||gid(),title:titleFrom(t.title||titleFrom(t.messages?.find?.(m=>m.role==='user')?.content||'')),pinned:!!t.pinned,updatedAt:t.updatedAt||Date.now(),messages:[].concat(t.messages||[]).filter(m=>m&&m.role&&m.content)}),best={};[].concat(data?.threads||data||[]).forEach(t=>{const n=norm(t),k=n.id,p=best[k];best[k]=!p||(+n.updatedAt>+p.updatedAt)?n:p});let kept=0,skipped=0;const idx=Object.fromEntries(threads.map(t=>[t.id,t]));for(const th of Object.values(best)){const ex=idx[th.id];if(ex&&+ex.updatedAt>=+th.updatedAt){skipped++;continue}if(!ex)threads.push(th);else Object.assign(ex,th);kept++}await tsave(threads);await renderThreads();alert(`${kept} imported, ${skipped} skipped (older).`)}el.userMenu.classList.add('hidden')}catch(e){alert('Import failed')}finally{importMode=null}}
function kbUpdate(){const v=window.visualViewport,o=v?Math.max(0,innerHeight-v.height-v.offsetTop):0,fh=el.footer.getBoundingClientRect().height;doc.documentElement.style.setProperty('--kb',o+'px');doc.documentElement.style.setProperty('--footer-h',fh+'px');el.footer.style.transform=`translateY(${-o}px)`;el.chat.style.scrollPaddingBottom=fh+o+16+'px'}
function kbBind(){if(visualViewport)['resize','scroll'].forEach(e=>visualViewport.addEventListener(e,kbUpdate,{passive:!0}));['resize','orientationchange'].forEach(e=>addEventListener(e,()=>setTimeout(kbUpdate,50),{passive:!0}));['focus','click'].forEach(e=>el.input.addEventListener(e,()=>{setTimeout(()=>{kbUpdate();el.input.scrollIntoView({block:'nearest',behavior:'smooth'})},0)}))}
const activeMeta=()=>({sune_name:SUNE.name,model:SUNE.model,avatar:SUNE.avatar})
window.USER={log:async s=>{const t=(s||'').trim();if(t){await ensureThreadOnFirstUser(t);addMessage({role:'user',content:[{type:'text',text:t}]});await persistThread()}},get PAT(){return globalStore.ghToken}}
async function init(){threads=await tload();await renderThreads();renderSidebar();await reflectActiveSune();clearChat();icons();kbBind();kbUpdate()}
doc.addEventListener('click',e=>{const T=e.target;T.closest('button')&&haptic();!el.threadPopover.contains(T)&&!T.closest('[data-thread-menu]')&&hideThreadPopover();!el.sunePopover.contains(T)&&!T.closest('[data-sune-menu]')&&hideSunePopover();!el.userMenu.contains(T)&&!el.userMenuBtn.contains(T)&&el.userMenu.classList.add('hidden')})
window.addEventListener('resize',()=>{hideThreadPopover();hideSunePopover()})
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 HTTP_BASE='https://orp.awww.workers.dev/ws'
async function askOpenRouterStreaming(onDelta,streamId){const model=SUNE.model,provider=model.startsWith('oai:')?'openai':model.startsWith('or:')?'openrouter':SUNE.provider,apiKey=provider==='openai'?SUNE.apiKeyOAI:SUNE.apiKeyOR;if(!apiKey)return onDelta(localDemoReply(),!0);const r={rid:streamId||gid(),seq:-1,done:!1,ws:null},signal=()=>!r.done&&(r.done=!0,onDelta('',!0));await cacheStore.setItem(r.rid,'busy');const ws=new WebSocket(`${HTTP_BASE.replace('https','wss')}?uid=${encodeURIComponent(r.rid)}`);r.ws=ws;ws.onopen=()=>{const msgs=[];if(SUNE.masterPrompt)msgs.push({role:'system',content:[{type:'text',text:SUNE.masterPrompt}]});if(SUNE.system_prompt)msgs.push({role:'system',content:[{type:'text',text:SUNE.system_prompt}]});msgs.push(...state.messages.filter(m=>m.role!=='system'));const b=payloadWithSampling({model:model.replace(/^(or:|oai:)/,''),messages:msgs,stream:!0});if(SUNE.reasoning_effort&&SUNE.reasoning_effort!=='default')b.reasoning={effort:SUNE.reasoning_effort};if(SUNE.verbosity)b.verbosity=SUNE.verbosity;ws.send(JSON.stringify({type:'begin',rid:r.rid,provider,apiKey,or_body:b}))};ws.onmessage=e=>{try{const m=JSON.parse(e.data);if(m.type==='delta'&&m.seq>r.seq){r.seq=m.seq;onDelta(m.text||'',!1)}else if(m.type==='done'||m.type==='err'){cacheStore.setItem(r.rid,'done');signal();ws.close()}}catch{}};ws.onclose=ws.onerror=()=>{};state.controller={abort:()=>{try{ws.readyState===1&&ws.send(JSON.stringify({type:'stop',rid:r.rid}))}catch{}finally{cacheStore.setItem(r.rid,'done');signal()}},disconnect:()=>ws.close()}}
const openAccountSettings=()=>{el.set_provider.value=SUNE.provider||'openrouter';el.set_api_key_or.value=SUNE.apiKeyOR||'';el.set_api_key_oai.value=SUNE.apiKeyOAI||'';el.set_master_prompt.value=SUNE.masterPrompt||'';el.set_title_model.value=SUNE.titleModel;el.set_gh_token.value=globalStore.ghToken||'';showAccountTab('General');el.accountSettingsModal.classList.remove('hidden')},closeAccountSettings=()=>el.accountSettingsModal.classList.add('hidden')
el.accountSettingsOption.onclick=()=>{el.userMenu.classList.add('hidden');openAccountSettings()}
el.closeAccountSettings.onclick=closeAccountSettings,el.cancelAccountSettings.onclick=closeAccountSettings
el.accountSettingsModal.onclick=e=>{if(e.target===el.accountSettingsModal||e.target.classList.contains('bg-black/30'))closeAccountSettings()}
el.accountSettingsForm.addEventListener('submit',e=>{e.preventDefault();SUNE.provider=el.set_provider.value||'openrouter';SUNE.apiKeyOR=(el.set_api_key_or.value||'').trim();SUNE.apiKeyOAI=(el.set_api_key_oai.value||'').trim();SUNE.masterPrompt=(el.set_master_prompt.value||'').trim();SUNE.titleModel=(el.set_title_model.value||'').trim();globalStore.ghToken=(el.set_gh_token.value||'').trim();closeAccountSettings()})
el.accountTabGeneral.onclick=()=>showAccountTab('General');el.accountTabAPI.onclick=()=>showAccountTab('API');
el.exportAccountSettings.onclick=()=>dl(`sune-account-${ts()}.json`,{v:1,provider:globalStore.provider,apiKeyOR:globalStore.apiKeyOR,apiKeyOAI:globalStore.apiKeyOAI,masterPrompt:globalStore.masterPrompt,titleModel:globalStore.titleModel,ghToken:globalStore.ghToken});
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'Invalid';Object.entries({provider:'string',apiKeyOR:'string',apiKeyOAI:'string',masterPrompt:'string',titleModel:'string',ghToken:'string'}).forEach(([k,t])=>{if(typeof d[k]===t)k==='ghToken'?globalStore[k]=d[k]:SUNE[k]=d[k]});openAccountSettings();alert('Imported.')}catch{alert('Import failed')}};
const lastAssistantId=()=>{const a=[...el.messages.querySelectorAll('.msg-bubble')].reverse();for(const b of a){const h=b.previousElementSibling;if(h&&!/^\s*You\b/.test(h.textContent||''))return b.dataset.mid||null}return null},getBubbleById=id=>el.messages.querySelector(`.msg-bubble[data-mid="${CSS.escape(id)}"]`)
async function syncActiveThread(){const id=lastAssistantId();if(!id||await cacheStore.getItem(id)==='done'){if(state.busy){setBtn(!1);state.busy=!1;state.controller=null}return!1}if(!state.busy){state.busy=!0;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()}}};setBtn(!0)}const bubble=getBubbleById(id);if(!bubble)return!1;const pTxt=bubble.textContent||'',j=await(fetch(`${HTTP_BASE}?uid=${encodeURIComponent(id)}`).then(r=>r.ok?r.json():null).catch(()=>null));const fin=(t,c)=>{renderMarkdown(bubble,t,{enhance:!1});enhanceCodeBlocks(bubble,!0);const i=state.messages.findIndex(x=>x.id===id);if(i>=0)state.messages[i].content=c;else state.messages.push({id,role:'assistant',content:c,...activeMeta()});persistThread();setBtn(!1);state.busy=!1;cacheStore.setItem(id,'done');state.controller=null};if(!j||j.rid!==id){if(j&&j.error)fin(`${pTxt}\n\n${j.error}`,[{type:'text',text:`${pTxt}\n\n${j.error}`}]);return!1}const text=j.text||'',isDone=j.error||j.done||j.phase==='done';if(text)renderMarkdown(bubble,text,{enhance:!1});if(isDone){fin(text||pTxt,[{type:'text',text:text||pTxt}]);return!1}await cacheStore.setItem(id,'busy');return!0}
let syncLoopRunning=!1;async function syncWhileBusy(){if(syncLoopRunning||doc.visibilityState==='hidden')return;syncLoopRunning=!0;try{while(await syncActiveThread())await new Promise(r=>setTimeout(r,1200))}finally{syncLoopRunning=!1}}
const onForeground=()=>{if(doc.visibilityState!=='visible')return;state.controller?.disconnect?.();if(state.busy)syncWhileBusy()}
doc.addEventListener('visibilitychange',onForeground)
el.copySystemPrompt.onclick=()=>navigator.clipboard.writeText(el.set_system_prompt.value||''),el.pasteSystemPrompt.onclick=async()=>el.set_system_prompt.value=await navigator.clipboard.readText()
const getActiveHtmlParts=()=>!el.htmlEditor.classList.contains('hidden')?[el.htmlEditor,jars.html]:[el.extensionHtmlEditor,jars.extension]
el.copyHTML.onclick=()=>navigator.clipboard.writeText(getActiveHtmlParts()[0].textContent||'')
el.pasteHTML.onclick=async()=>{try{const t=await navigator.clipboard.readText(),[ed,jar]=getActiveHtmlParts();jar?.updateCode?jar.updateCode(t):ed&&(ed.textContent=t)}catch{}}
init()
</script>
</body>
</html>