mirror of
https://github.com/multipleof4/devsune.git
synced 2026-01-13 16:07:55 +00:00
247 lines
59 KiB
HTML
247 lines
59 KiB
HTML
<!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">(0–2)</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">(0–1)</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">(-2–2)</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">(-2–2)</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">(0–2)</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">(0–1)</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">(0–1)</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=>({"&":"&","<":"<",">":">","\"":""","'":"'","`":"`"}[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?.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>
|