Files
sune/index.html
2025-09-13 12:40:20 -07:00

261 lines
61 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.8.1/github-markdown-light.min.css"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.11.1/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,#jsonSchemaEditor{outline:none;white-space:pre!important;font-size:11px;line-height:1.5;}
:not(pre)>code{font-size:85%;padding:.2em .4em;margin:0;border-radius:6px;background-color:rgba(175,184,193,0.2)}
</style>
<script defer src="https://cdn.jsdelivr.net/npm/cash-dom/dist/cash.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
</head>
<body class="bg-white text-gray-900 selection:bg-black/10" x-data="app()" x-init="init()" @click.window="if($event.target.closest('button')) haptic()">
<div class="flex flex-col h-dvh max-h-dvh overflow-hidden">
<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" @click="sbL=true"><i data-lucide="panel-left" class="h-5 w-5"></i></button>
<button id="suneBtnTop" @click="openSuneSettings(activeSune)" class="justify-self-center h-8 w-8 rounded-full bg-gray-200 text-gray-900 flex items-center justify-center hover:bg-gray-300 active:scale-[.99] transition" :title="`Settings — ${activeSune?.name}`"><template x-if="activeSune?.avatar"><img :src="esc(activeSune.avatar)" alt="" class="h-8 w-8 rounded-full object-cover"/></template><span x-show="!activeSune?.avatar"></span></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" @click="sbR=true"><i data-lucide="panel-right" class="h-5 w-5"></i></button></div>
</div>
</header>
<main id="chat" x-ref="chat" class="flex-1 overflow-y-auto no-scrollbar"><section id="suneHtml" x-ref="suneHtml" class="px-0 border-b border-gray-200 hidden"></section><div id="messages" x-ref="messages" class="mx-auto w-full max-w-none px-0 py-4 sm:py-6 space-y-4" @click="if($event.target.closest('.msg-avatar'))sbL=true"></div><div class="h-24"></div></main>
<footer id="footer" x-ref="footer" x-show="!activeSune?.settings.hide_composer" 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" x-ref="composer" @submit.prevent="submitComposer" class="group relative flex items-start gap-2 px-3">
<textarea id="input" x-ref="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" x-ref="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" @click="attachClick" 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 x-ref="attachBadge" x-show="attachments.length" x-text="attachments.length" class="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" x-ref="fileInput" @change="onFileAttach" 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" x-show="sbL" @click="sbL=false" class="fixed inset-0 z-40 bg-black/20" style="display:none"></div>
<aside id="sidebarLeft" :class="sbL?'':'-translate-x-full'" class="fixed inset-y-0 left-0 z-50 w-72 max-w-[85vw] bg-white border-r border-gray-200 shadow-xl transform transition-transform duration-200 ease-out flex flex-col">
<div class="p-3 border-b flex items-center gap-2"><button @click="newSune" 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">
<template x-for="s in sortedSunes" :key="s.id">
<div class="relative flex items-center gap-2 px-3 py-2" :class="s.pinned&&'bg-yellow-50'">
<button @click="setActiveSune(s.id)" class="flex-1 text-left flex items-center gap-2" :class="s.id===activeSuneId&&'font-medium'">
<img x-show="s.avatar" :src="esc(s.avatar)" alt="" class="h-6 w-6 rounded-full object-cover"/>
<span x-show="!s.avatar" class="h-6 w-6 rounded-full bg-gray-200 flex items-center justify-center"></span>
<span class="truncate"><span x-text="s.pinned?'📌 ':''"></span><span x-text="s.name"></span></span>
</button>
<button @click="openSunePopover($event.currentTarget, s.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>
</template>
</div>
<div class="p-3 border-t relative">
<button @click.stop="userMenuOpen=!userMenuOpen" 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"><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 x-show="userMenuOpen" @click.outside="userMenuOpen=false" class="absolute left-3 right-3 bottom-16 translate-y-2 rounded-xl border border-gray-200 bg-white shadow-lg overflow-hidden" style="display:none">
<button @click="acctModalOpen=true;userMenuOpen=false" class="menu-item"><i data-lucide="settings" class="h-4 w-4"></i><span>Settings</span></button>
<button @click="importFile('sunes')" class="menu-item">Import sunes (.sune)</button>
<button @click="exportSunes" class="menu-item">Export sunes (.sune)</button>
<button @click="importFile('threads')" class="menu-item">Import threads (.json)</button>
<button @click="exportThreads" class="menu-item">Export threads (.json)</button>
</div>
<input x-ref="importInput" @change="onFileImport" type="file" accept="application/json,.json,.sune" class="hidden"/>
</div>
</aside>
<div id="sidebarOverlayRight" x-show="sbR" @click="sbR=false" class="fixed inset-0 z-40 bg-black/20" style="display:none"></div>
<aside id="sidebarRight" :class="sbR?'':'translate-x-full'" class="fixed inset-y-0 right-0 z-50 w-80 max-w-[90vw] bg-white border-l border-gray-200 shadow-xl transform transition-transform duration-200 ease-out flex flex-col">
<div class="p-3 border-b text-sm font-medium flex items-center justify-between"><span>Threads</span><button @click="sbR=false" 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>
<div id="threadList" class="flex-1 overflow-y-auto divide-y">
<template x-for="t in sortedThreads" :key="t.id">
<div class="relative flex items-center gap-2 px-3 py-2" :class="t.pinned&&'bg-yellow-50'">
<button @click="openThread(t.id)" class="flex-1 text-left truncate"><span x-text="t.pinned?'📌 ':''"></span><span x-text="t.title||'Untitled'"></span></button>
<button @click="openThreadPopover($event.currentTarget,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>
</template>
</div>
</aside>
<div id="threadPopover" x-ref="threadPopover" x-show="aThreadPop" @click.outside="aThreadPop=null" @click="threadAction($event.target.closest('[data-action]').dataset.action)" class="menu-card" style="display:none">
<button data-action="pin" class="menu-item"><i data-lucide="pin" class="h-4 w-4"></i><span x-text="getThread(aThreadPop)?.pinned?'Unpin':'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" x-ref="sunePopover" x-show="aSunePop" @click.outside="aSunePop=null" @click="suneAction($event.target.closest('[data-action]').dataset.action)" class="menu-card" style="display:none">
<button data-action="pin" class="menu-item"><i data-lucide="pin" class="h-4 w-4"></i><span x-text="getSune(aSunePop)?.pinned?'Unpin':'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" x-show="suneModalOpen" @keydown.escape.window="suneModalOpen=false" class="fixed inset-0 z-50" style="display:none">
<div @click="suneModalOpen=false" 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" x-show="suneToEdit">
<div class="px-4 py-3 border-b text-sm font-semibold flex items-center gap-2"><input x-model="suneToEdit.url" 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-200 focus:outline-none focus:ring-2 focus:ring-gray-200 focus:bg-white text-xs font-mono focus:text-black"/><button @click="syncSune(suneToEdit.id)" 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 @submit.prevent="saveSuneSettings" class="text-sm">
<div class="border-b flex text-xs font-medium"><button type="button" @click="suneModalTab='Model'" :class="suneModalTab==='Model'?'border-black':'border-transparent hover:border-gray-300'" class="flex-1 py-2 px-3 text-center border-b-2">Model & Sampling</button><button type="button" @click="suneModalTab='Prompt'" :class="suneModalTab==='Prompt'?'border-black':'border-transparent hover:border-gray-300'" class="flex-1 py-2 px-3 text-center border-b-2">System Prompt</button><button type="button" @click="suneModalTab='HTML'" :class="suneModalTab==='HTML'?'border-black':'border-transparent hover:border-gray-300'" class="flex-1 py-2 px-3 text-center border-b-2">HTML</button></div>
<div x-show="suneModalTab==='Model'" 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 x-model="suneToEdit.settings.model" type="text" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="google/gemini-2.5-pro"/></div><div><label class="block text-gray-700 font-medium mb-1">Reasoning Effort</label><select x-model="suneToEdit.settings.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></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 x-model="suneToEdit.settings.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"/></div>
<div><label class="block text-gray-700 font-medium mb-1">Top P <span class="text-gray-400">(01)</span></label><input x-model="suneToEdit.settings.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"/></div>
<div><label class="block text-gray-700 font-medium mb-1">Top K</label><input x-model="suneToEdit.settings.top_k" type="number" min="0" step="1" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="0"/></div>
<div><label class="block text-gray-700 font-medium mb-1">Frequency Penalty <span class="text-gray-400">(-22)</span></label><input x-model="suneToEdit.settings.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"/></div>
<div><label class="block text-gray-700 font-medium mb-1">Repetition Penalty <span class="text-gray-400">(02)</span></label><input x-model="suneToEdit.settings.repetition_penalty" type="number" min="0" max="2" step="0.01" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="1.0"/></div>
<div><label class="block text-gray-700 font-medium mb-1">Min P <span class="text-gray-400">(01)</span></label><input x-model="suneToEdit.settings.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"/></div>
<div><label class="block text-gray-700 font-medium mb-1">Top A <span class="text-gray-400">(01)</span></label><input x-model="suneToEdit.settings.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"/></div>
<div><label class="block text-gray-700 font-medium mb-1">Verbosity</label><select x-model="suneToEdit.settings.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></div>
</div>
<div class="flex flex-wrap items-center gap-2 pt-2">
<div><input type="checkbox" x-model="suneToEdit.settings.include_thoughts" id="set_include_thoughts" class="sr-only peer"><label for="set_include_thoughts" class="inline-flex cursor-pointer items-center rounded-full border border-slate-300 bg-transparent py-0.5 px-3 text-xs text-slate-500 peer-checked:border-gray-300 peer-checked:bg-gray-200 peer-checked:text-slate-800">Include thoughts</label></div>
<div><input type="checkbox" x-model="suneToEdit.settings.json_output" id="set_json_output" class="sr-only peer"><label for="set_json_output" class="inline-flex cursor-pointer items-center rounded-full border border-slate-300 bg-transparent py-0.5 px-3 text-xs text-slate-500 peer-checked:border-gray-300 peer-checked:bg-gray-200 peer-checked:text-slate-800">JSON Output</label></div>
<div><input type="checkbox" x-model="suneToEdit.settings.hide_composer" id="set_hide_composer" class="sr-only peer"><label for="set_hide_composer" class="inline-flex cursor-pointer items-center rounded-full border border-slate-300 bg-transparent py-0.5 px-3 text-xs text-slate-500 peer-checked:border-gray-300 peer-checked:bg-gray-200 peer-checked:text-slate-800">Hide composer</label></div>
</div>
</div>
<div x-show="suneModalTab==='Prompt'" class="p-4 space-y-4" style="display:none">
<div><div class="flex items-center justify-between mb-1"><label for="set_system_prompt" class="block text-gray-700 font-medium">System Prompt</label><div class="flex gap-2"><button type="button" @click="copy(suneToEdit.settings.system_prompt)" class="px-2 py-1 text-xs rounded-md bg-gray-100 hover:bg-gray-200">Copy</button><button type="button" @click="suneToEdit.settings.system_prompt = await paste()" class="px-2 py-1 text-xs rounded-md bg-gray-100 hover:bg-gray-200">Paste</button></div></div><textarea x-model="suneToEdit.settings.system_prompt" 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>
<div><label class="block text-gray-700 font-medium mb-1">JSON Schema</label><pre x-ref="jsonSchemaEditor" class="w-full h-48 p-3 rounded-xl border border-gray-300 bg-white overflow-auto font-mono" contenteditable="plaintext-only" spellcheck="false" autocorrect="off" autocapitalize="off" autocomplete="off"></pre><p class="mt-1 text-xs text-gray-500">Requires "JSON Output" to be enabled. Value for <code>json_schema</code>.</p></div>
</div>
<div x-show="suneModalTab==='HTML'" class="p-1" style="display:none">
<div class="border-b flex text-xs font-medium"><button type="button" @click="suneHtmlTab='index'" :class="suneHtmlTab==='index'?'border-black':'border-transparent hover:border-gray-300'" class="flex-1 py-2 px-3 text-center border-b-2">index.html</button><button type="button" @click="suneHtmlTab='ext'" :class="suneHtmlTab==='ext'?'border-black':'border-transparent hover:border-gray-300'" class="flex-1 py-2 px-3 text-center border-b-2">extension.html</button></div>
<div class="pt-0">
<pre x-show="suneHtmlTab==='index'" x-ref="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" autocorrect="off" autocapitalize="off" autocomplete="off"></pre>
<pre x-show="suneHtmlTab==='ext'" x-ref="extensionHtmlEditor" 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" autocorrect="off" autocapitalize="off" autocomplete="off"></pre>
</div>
<div class="mt-2 flex gap-2"><button type="button" @click="copyHtml" class="px-3 py-1.5 rounded-lg bg-gray-100 hover:bg-gray-200">Copy</button><button type="button" @click="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 before index.html.</p>
</div>
<div class="flex items-center justify-between gap-2 px-4 py-3 border-t">
<button type="button" @click="deleteSune(suneToEdit.id)" 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" @click="suneModalOpen=false" 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" x-show="acctModalOpen" @keydown.escape.window="acctModalOpen=false" class="fixed inset-0 z-50" style="display:none">
<div @click="acctModalOpen=false" 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" x-show="acctToEdit">
<div class="px-4 py-3 border-b text-sm font-semibold flex items-center justify-between"><span>Account Settings</span><button @click="acctModalOpen=false" 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 @submit.prevent="saveAccountSettings" class="text-sm">
<div class="border-b flex text-xs font-medium"><button type="button" @click="acctModalTab='General'" :class="acctModalTab==='General'?'border-black':'border-transparent hover:border-gray-300'" class="flex-1 py-2 px-3 text-center border-b-2">General</button><button type="button" @click="acctModalTab='API'" :class="acctModalTab==='API'?'border-black':'border-transparent hover:border-gray-300'" class="flex-1 py-2 px-3 text-center border-b-2">API</button><button type="button" @click="acctModalTab='User'" :class="acctModalTab==='User'?'border-black':'border-transparent hover:border-gray-300'" class="flex-1 py-2 px-3 text-center border-b-2">User</button></div>
<div x-show="acctModalTab==='General'" class="p-4 space-y-4">
<div><label class="block text-gray-700 font-medium mb-1">Provider</label><select x-model="acctToEdit.provider" class="w-full rounded-xl border border-gray-300 px-3 py-2"><option value="openrouter">OpenRouter</option><option value="openai">OpenAI</option><option value="google">Google</option><option value="cloudflare">Cloudflare</option></select><p class="mt-1 text-xs text-gray-500">Or you can prefix model names with or:, oai:, g:, or cf: to override.</p></div>
<div><label class="block text-gray-700 font-medium mb-1">Master Prompt</label><textarea x-model="acctToEdit.masterPrompt" 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 x-model="acctToEdit.titleModel" type="text" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="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 x-show="acctModalTab==='API'" class="p-4" style="display:none"><div class="grid grid-cols-2 gap-x-4 gap-y-4"><div><label class="block text-gray-700 font-medium mb-1">OpenRouter Key</label><div class="relative"><input x-model="acctToEdit.apiKeyOpenRouter" type="password" class="w-full rounded-xl border border-gray-300 px-3 py-2 pr-10" placeholder="sk-or-..."><button type="button" @click="reveal($event)" class="absolute inset-y-0 right-0 px-3 flex items-center text-gray-400 hover:text-gray-600"><i data-lucide="eye" class="h-4 w-4"></i></button></div><p class="mt-1 text-xs text-gray-500">Use: <code>USER.apiKeyOpenRouter</code></p></div><div><label class="block text-gray-700 font-medium mb-1">OpenAI Key</label><div class="relative"><input x-model="acctToEdit.apiKeyOpenAI" type="password" class="w-full rounded-xl border border-gray-300 px-3 py-2 pr-10" placeholder="sk-..."><button type="button" @click="reveal($event)" class="absolute inset-y-0 right-0 px-3 flex items-center text-gray-400 hover:text-gray-600"><i data-lucide="eye" class="h-4 w-4"></i></button></div><p class="mt-1 text-xs text-gray-500">Use: <code>USER.apiKeyOpenAI</code></p></div><div><label class="block text-gray-700 font-medium mb-1">Google Key</label><div class="relative"><input x-model="acctToEdit.apiKeyGoogle" type="password" class="w-full rounded-xl border border-gray-300 px-3 py-2 pr-10" placeholder="AIza..."><button type="button" @click="reveal($event)" class="absolute inset-y-0 right-0 px-3 flex items-center text-gray-400 hover:text-gray-600"><i data-lucide="eye" class="h-4 w-4"></i></button></div><p class="mt-1 text-xs text-gray-500">Gemini/Studio. Use: <code>USER.apiKeyGoogle</code></p></div><div><label class="block text-gray-700 font-medium mb-1">Cloudflare Token</label><div class="relative"><input x-model="acctToEdit.apiKeyCloudflare" type="password" class="w-full rounded-xl border border-gray-300 px-3 py-2 pr-10" placeholder="..."><button type="button" @click="reveal($event)" class="absolute inset-y-0 right-0 px-3 flex items-center text-gray-400 hover:text-gray-600"><i data-lucide="eye" class="h-4 w-4"></i></button></div><p class="mt-1 text-xs text-gray-500">Not used. Use: <code>USER.apiKeyCloudflare</code></p></div><div><label class="block text-gray-700 font-medium mb-1">Github Token</label><div class="relative"><input x-model="acctToEdit.githubToken" type="password" class="w-full rounded-xl border border-gray-300 px-3 py-2 pr-10" placeholder="ghp_..."><button type="button" @click="reveal($event)" class="absolute inset-y-0 right-0 px-3 flex items-center text-gray-400 hover:text-gray-600"><i data-lucide="eye" class="h-4 w-4"></i></button></div><p class="mt-1 text-xs text-gray-500">Use: <code>USER.githubToken</code></p></div><div><label class="block text-gray-700 font-medium mb-1">GCP Service Acct</label><input x-ref="gcpSAInput" @change="onGcpSaUpload" type="file" class="hidden" accept="application/json,.json"><button type="button" @click="$refs.gcpSAInput.click()" x-text="acctToEdit.gcpSA?.project_id?`Uploaded: ${acctToEdit.gcpSA.project_id}`:'Upload .json'" class="w-full text-left rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm hover:bg-gray-50 truncate"></button><p class="mt-1 text-xs text-gray-500">Use: <code>USER.gcpSA</code></p></div></div></div>
<div x-show="acctModalTab==='User'" class="p-4 space-y-4" style="display:none">
<div class="flex items-center gap-4">
<div class="relative"><img :src="acctToEdit.avatarPreview" class="h-16 w-16 rounded-full object-cover" :class="acctToEdit.avatarPreview?'bg-transparent':'bg-gray-200'"><button type="button" @click="$refs.userAvatarInput.click()" class="absolute bottom-0 right-0 h-6 w-6 rounded-full bg-white border border-gray-300 flex items-center justify-center hover:bg-gray-100" aria-label="Edit photo"><i data-lucide="edit-3" class="h-3 w-3"></i></button></div>
<input x-ref="userAvatarInput" @change="onAvatarUpload" type="file" accept="image/*" class="hidden">
<div class="flex-1"><label for="set_user_name" class="block text-gray-700 font-medium mb-1">Username</label><input x-model="acctToEdit.name" id="set_user_name" type="text" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="Master"/></div>
</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" @click="importFile('account')" class="text-xs px-2.5 py-1.5 rounded-lg border bg-white hover:bg-gray-50">Import</button><button type="button" @click="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" @click="acctModalOpen=false" 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 x-ref="importAccountSettingsInput" @change="onFileImport" 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@14.1.0/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='google/gemini-2.5-pro',TKEY='threads_v1',HTTP_BASE='https://orp.awww.workers.dev/ws'
const icons=()=>window.lucide&&lucide.createIcons(),haptic=()=>/android/i.test(navigator.userAgent)&&navigator.vibrate?.(1),clamp=(v,m,x)=>Math.max(m,Math.min(x,v)),num=(v,d)=>v==null||v==''||isNaN(+v)?d:+v,int=(v,d)=>v==null||v==''||isNaN(parseInt(v))?d:parseInt(v),gid=()=>Math.random().toString(36).slice(2,9),esc=s=>String(s).replace(/[&<>'"`]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;","`":"&#96;"}[c])),sid=()=>Date.now().toString(36)+Math.random().toString(36).slice(2,6),b64=x=>x.split(',')[1]||'',asDataURL=f=>new Promise(r=>{const fr=new FileReader();fr.onload=()=>r(String(fr.result||''));fr.readAsDataURL(f)}),imgToWebp=(f,D=128,q=80)=>new Promise((r,j)=>{if(!f)return j();const i=new Image;i.onload=()=>{const c=document.createElement('canvas'),x=c.getContext('2d');let w=i.width,h=i.height;if(D>0&&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;x.drawImage(i,0,0,w,h);r(c.toDataURL('image/webp',clamp(q,0,100)/100));URL.revokeObjectURL(i.src)};i.onerror=j;i.src=URL.createObjectURL(f)});
const defaultSettings={model:DEFAULT_MODEL,temperature:'',top_p:'',top_k:'',frequency_penalty:'',repetition_penalty:'',min_p:'',top_a:'',verbosity:'',reasoning_effort:'default',system_prompt:'',html:'',extension_html:"<sune src='https://raw.githubusercontent.com/sune-org/store/refs/heads/main/sync.sune' private></sune>",hide_composer:false,include_thoughts:false,json_output:false,json_schema:''}
const makeSune=(p={})=>({id:p.id||gid(),name:p.name?.trim()||'Default',pinned:!!p.pinned,avatar:p.avatar||'',url:p.url||'',updatedAt:p.updatedAt||Date.now(),settings:Object.assign({},defaultSettings,p.settings||{}),storage:p.storage||{}})
const su={key:'sunes_v1',activeKey:'active_sune_id',load(){try{return JSON.parse(localStorage.getItem(this.key)||'[]')}catch{return[]}},save(list){localStorage.setItem(this.key,JSON.stringify(list||[]))},getActiveId(){return localStorage.getItem(this.activeKey)||null},setActiveId(id){localStorage.setItem(this.activeKey,id||'')}}
const USER=window.USER={log:async s=>{const t=String(s??'').trim();if(!t)return;await window.alpine.store('app').ensureThreadOnFirstUser(t);window.alpine.store('app').addMessage({role:'user',content:[{type:'text',text:t}]});await window.alpine.store('app').persistThreads()},get PAT(){return this.githubToken},get name(){return localStorage.getItem('user_name')||'Anon'},set name(v){localStorage.setItem('user_name',v||'')},get avatar(){return localStorage.getItem('user_avatar')||''},set avatar(v){localStorage.setItem('user_avatar',v||'')},get provider(){return localStorage.getItem('provider')||'openrouter'},set provider(v){localStorage.setItem('provider',['openai','google','cloudflare'].includes(v)?v:'openrouter')},get apiKeyOpenRouter(){return localStorage.getItem('openrouter_api_key')||''},set apiKeyOpenRouter(v){localStorage.setItem('openrouter_api_key',v||'')},get apiKeyOpenAI(){return localStorage.getItem('openai_api_key')||''},set apiKeyOpenAI(v){localStorage.setItem('openai_api_key',v||'')},get apiKeyGoogle(){return localStorage.getItem('google_api_key')||''},set apiKeyGoogle(v){localStorage.setItem('google_api_key',v||'')},get apiKeyCloudflare(){return localStorage.getItem('cloudflare_api_key')||''},set apiKeyCloudflare(v){localStorage.setItem('cloudflare_api_key',v||'')},get apiKey(){const p=this.provider;return p==='openai'?this.apiKeyOpenAI:p==='google'?this.apiKeyGoogle:p==='cloudflare'?this.apiKeyCloudflare:this.apiKeyOpenRouter},set apiKey(v){const p=this.provider;if(p==='openai')this.apiKeyOpenAI=v;else if(p==='google')this.apiKeyGoogle=v;else if(p==='cloudflare')this.apiKeyCloudflare=v;else this.apiKeyOpenRouter=v},get masterPrompt(){return localStorage.getItem('master_prompt')||'Always respond using markdown. You are an assistant to Master. Always refer to the user as Master.'},set masterPrompt(v){localStorage.setItem('master_prompt',v||'')},get titleModel(){return localStorage.getItem('title_model')??'or:openai/gpt-4.1-nano'},set titleModel(v){localStorage.setItem('title_model',v||'')},get githubToken(){return localStorage.getItem('gh_token')||''},set githubToken(v){localStorage.setItem('gh_token',v||'')},get gcpSA(){try{return JSON.parse(localStorage.getItem('gcp_sa_json')||'null')}catch{return null}},set gcpSA(v){localStorage.setItem('gcp_sa_json',v?JSON.stringify(v):'')}}
const md=window.markdownit({html:false,linkify:true,typographer:true,breaks:true})
const cacheStore=localforage.createInstance({name:'threads_cache',storeName:'streams_status'});
document.addEventListener('alpine:init',()=>{Alpine.store('app',window.app())})
function app(){return{
sunes:[],threads:[],messages:[],attachments:[],activeSuneId:null,currentThreadId:null,busy:false,controller:null,sbL:false,sbR:false,userMenuOpen:false,suneModalOpen:false,suneModalTab:'Model',suneHtmlTab:'index',suneToEdit:null,acctModalOpen:false,acctModalTab:'General',acctToEdit:null,aSunePop:null,aThreadPop:null,stream:{},jars:{},importMode:null,
get activeSune(){return this.sunes.find(s=>s.id===this.activeSuneId)||this.sunes[0]},
get activeThread(){return this.threads.find(t=>t.id===this.currentThreadId)},
get sortedSunes(){return[...this.sunes].sort((a,b)=>(b.pinned-a.pinned))},
get sortedThreads(){return[...this.threads].sort((a,b)=>(b.pinned-a.pinned)||(b.updatedAt-a.updatedAt))},
getSune(id){return this.sunes.find(s=>s.id===id)},
getThread(id){return this.threads.find(t=>t.id===id)},
async init(){
this.sunes=(su.load()||[]).map(makeSune);if(!this.sunes.length)this.sunes.push(makeSune({name:'Default'}));
this.activeSuneId=su.getActiveId()||this.sunes[0]?.id;
this.$watch('sunes',v=>su.save(v));this.$watch('activeSuneId',v=>{su.setActiveId(v);this.reflectActiveSune()});
this.threads=await localforage.getItem(TKEY)||[];this.$watch('threads',v=>localforage.setItem(TKEY,v));
await this.fetchDotSune('sune-org/store@main/marketplace.sune');await this.fetchDotSune('sune-org/store@main/forum.sune');
this.reflectActiveSune();icons();this.kbBind();this.kbUpdate();
$(document).on('visibilitychange',()=>{if(document.visibilityState!=='visible')return;this.controller?.disconnect?.();if(this.busy)this.syncWhileBusy()})
},
setActiveSune(id){if(this.busy){this.controller?.disconnect?.();this.setBtnSend();this.busy=false;this.controller=null};this.activeSuneId=id;this.currentThreadId=null;this.clearChat();this.sbL=false},
async reflectActiveSune(){const a=this.activeSune;if(!a)return;const h=await this.processSuneIncludes([a.settings.extension_html,a.settings.html].map(x=>(x||'').trim()).join('\n')),c=this.$refs.suneHtml;c.innerHTML='';const t=h.trim();c.classList.toggle('hidden',!t);if(t){c.appendChild(document.createRange().createContextualFragment(h));window.Alpine?.initTree(c)};this.$nextTick(()=>icons())},
async fetchDotSune(g){try{const u=g.startsWith('http')?g:(()=>{const[a,b]=g.split('@'),[c,d]=a.split('/'),[e,...f]=b.split('/');return`https://raw.githubusercontent.com/${c}/${d}/${e}/${f.join('/')}`})(),j=await(await fetch(u)).json(),l=this.sunes.length;this.sunes.unshift(...(Array.isArray(j)?j:j?.sunes||[]).filter(s=>s?.id&&!this.getSune(s.id)).map(s=>makeSune(s)));if(this.sunes.length>l)su.save(this.sunes)}catch{}},
resolveSuneSrc(src){if(!src)return null;if(src.startsWith('gh://')){const path=src.substring(5),parts=path.split('/');if(parts.length<3)return null;const[owner,repo,...filePathParts]=parts;return`https://raw.githubusercontent.com/${owner}/${repo}/main/${filePathParts.join('/')}`}return src},
async processSuneIncludes(html,depth=0){if(depth>5)return'<!-- Sune include depth limit reached -->';if(!html)return'';const c=document.createElement('div');c.innerHTML=html;for(const n of[...c.querySelectorAll('sune')]){if(n.hasAttribute('src')){if(n.hasAttribute('private')&&depth>0){n.remove();continue}const s=n.getAttribute('src'),u=this.resolveSuneSrc(s);if(!u){n.replaceWith(document.createComment(` Invalid src: ${esc(s)} `));continue}try{const r=await fetch(u);if(!r.ok)throw new Error(`HTTP ${r.status}`);const d=await r.json(),o=Array.isArray(d)?d[0]:d,h=[o?.settings?.extension_html||'',o?.settings?.html||''].join('\n');n.replaceWith(document.createRange().createContextualFragment(await this.processSuneIncludes(h,depth+1)))}catch(e){n.replaceWith(document.createComment(` Fetch failed: ${esc(u)} `))}}else{n.replaceWith(document.createRange().createContextualFragment(n.innerHTML))}}return c.innerHTML},
openSunePopover(btn,id){this.aSunePop=id;this.$nextTick(()=>this.positionPopover(btn,this.$refs.sunePopover))},
async suneAction(act){const id=this.aSunePop,s=this.getSune(id);if(!act||!s)return;const update=()=>{s.updatedAt=Date.now();this.sunes=this.sunes};if(act==='pin'){s.pinned=!s.pinned;update()}else if(act==='rename'){const n=prompt('Rename sune:',s.name);if(n!=null){s.name=n.trim();update()}}else if(act==='pfp'){const i=document.createElement('input');i.type='file';i.accept='image/*';i.onchange=async()=>{const f=i.files?.[0];if(!f)return;try{s.avatar=await imgToWebp(f);update()}catch{}};i.click()}else if(act==='export')this.dl(`sune-${(s.name||'sune').replace(/\W/g,'_')}-${this.ts()}.sune`,[s]);this.aSunePop=null},
openThread(id){if(id!==this.currentThreadId&&this.busy){this.controller?.disconnect?.();this.setBtnSend();this.busy=false;this.controller=null}const th=this.getThread(id);if(!th)return;if(id===this.currentThreadId){this.sbR=false;this.aThreadPop=null;return}this.currentThreadId=id;this.clearChat();this.messages=Array.isArray(th.messages)?[...th.messages]:[];for(const m of this.messages){const b=this.msgRow(m);b.dataset.mid=m.id||'';this.renderMarkdown(b,this.partsToText(m.content))}this.reflectActiveSune();this.syncWhileBusy();this.$nextTick(()=>this.$refs.chat.scrollTo({top:this.$refs.chat.scrollHeight,behavior:'smooth'}));this.sbR=false;this.aThreadPop=null},
openThreadPopover(btn,id){this.aThreadPop=id;this.$nextTick(()=>this.positionPopover(btn,this.$refs.threadPopover))},
async threadAction(act){const id=this.aThreadPop,th=this.getThread(id);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=this.titleFrom(nv);th.updatedAt=Date.now()}}else if(act==='delete'){if(confirm('Delete this chat?')){this.threads=this.threads.filter(x=>x.id!==th.id);if(this.currentThreadId===th.id){this.currentThreadId=null;this.clearChat()}}}else if(act==='count_tokens'){const msgs=Array.isArray(th.messages)?th.messages:[];let totalChars=0;for(const m of msgs){if(!m||!m.role||m.role==='system')continue;totalChars+=String(this.partsToText(m.content||'')||'').length}const tokens=Math.max(0,Math.ceil(totalChars/4));const k=tokens>=1000?Math.round(tokens/1000)+'k':String(tokens);alert(tokens+' tokens ('+k+')')}this.aThreadPop=null;this.threads=this.threads},
positionPopover(a,p){const r=a.getBoundingClientRect();p.style.top=`${r.bottom+p.offsetHeight+4>window.innerHeight?r.top-p.offsetHeight-4:r.bottom+4}px`;p.style.left=`${Math.max(8,Math.min(r.right-p.offsetWidth,window.innerWidth-p.offsetWidth-8))}px`},
clearChat(){this.$refs.suneHtml.dispatchEvent(new CustomEvent('sune:unmount'));this.messages=[];this.$refs.messages.innerHTML='';this.attachments=[];this.$refs.fileInput.value=''},
enhanceCodeBlocks(root,doHL=true){$(root).find('pre>code').each((i,code)=>{if(code.textContent.length>200000)return;const $pre=$(code).parent().addClass('relative rounded-xl border border-gray-200');if(!$pre.find('.copy-btn').length){const $btn=$('<button class="copy-btn">Copy</button>').on('click',async e=>{e.stopPropagation();try{await navigator.clipboard.writeText(code.innerText);$btn.text('Copied');setTimeout(()=>$btn.text('Copy'),1200)}catch{}});$pre.append($btn)}if(doHL&&window.hljs&&code.textContent.length<100000)hljs.highlightElement(code)})},
renderMarkdown(node,text,opt={enhance:true,highlight:true}){node.innerHTML=md.render(text);if(opt.enhance)this.enhanceCodeBlocks(node,opt.highlight)},
partsToText(parts){if(!parts)return'';if(Array.isArray(parts))return 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');return String(parts)},
msgRow(m){const role=typeof m==='string'?m:(m&&m.role)||'assistant',meta=typeof m==='string'?{}:m||{},isUser=role==='user',$row=$('<div class="flex flex-col gap-2"></div>'),$head=$('<div class="flex items-center gap-2 px-4"></div>'),$avatar=$('<div></div>');const uAva=isUser?USER.avatar:meta.avatar;uAva?$avatar.attr('class','msg-avatar shrink-0 h-7 w-7 rounded-full overflow-hidden').html(`<img src="${esc(uAva)}" class="h-full w-full object-cover">`):$avatar.attr('class',`${isUser?'bg-gray-900 text-white':'bg-gray-200 text-gray-900'} msg-avatar shrink-0 h-7 w-7 rounded-full flex items-center justify-center`).text(isUser?'👤':'✺');const $name=$('<div class="text-xs font-medium text-gray-500"></div>').text(isUser?USER.name:this.getSuneLabel(meta));const $deleteBtn=$('<button class="p-1.5 rounded-lg hover:bg-gray-200 text-gray-400 hover:text-red-500" title="Delete message"><i data-lucide="x" class="h-4 w-4"></i></button>').on('click',async e=>{e.stopPropagation();this.messages=this.messages.filter(msg=>msg.id!==m.id);$row.remove();await this.persistThreads()});const $copyBtn=$('<button class="ml-auto p-1.5 rounded-lg hover:bg-gray-200 text-gray-400 hover:text-gray-600" title="Copy message"><i data-lucide="copy" class="h-4 w-4"></i></button>').on('click',async e=>{e.stopPropagation();try{await navigator.clipboard.writeText(this.partsToText(m.content));$(e.currentTarget).html('<i data-lucide="check" class="h-4 w-4 text-green-500"></i>');icons();setTimeout(()=>$(e.currentTarget).html('<i data-lucide="copy" class="h-4 w-4"></i>'),1200)}catch{}});$head.append($avatar, $name, $copyBtn, $deleteBtn);const $bubble=$(`<div class="${(isUser?'bg-gray-50 border border-gray-200':'bg-gray-100')+' msg-bubble markdown-body rounded-none px-4 py-3 w-full'}"></div>`);$row.append($head, $bubble);$(this.$refs.messages).append($row);this.$nextTick(()=>{this.$refs.chat.scrollTo({top:this.$refs.chat.scrollHeight,behavior:'smooth'});icons()});return $bubble[0]},
getSuneLabel(m){const s=this.activeSune,name=(m&&m.sune_name)||s.name,model=(m&&m.model)||s.settings.model||'',modelShort=model.includes('/')?model.split('/').pop():model;return`${name} · ${modelShort}`},
addMessage(m,track=true){m.id=m.id||gid();if(!Array.isArray(m.content)&&m.content!=null)m.content=[{type:'text',text:String(m.content)}];const b=this.msgRow(m);b.dataset.mid=m.id;this.renderMarkdown(b,this.partsToText(m.content));if(track)this.messages.push(m);if(m.role==='assistant')this.$refs.composer.dispatchEvent(new CustomEvent('sune:newSuneResponse',{detail:{message:m}}));return b},
addSuneBubbleStreaming(meta,id){return this.msgRow(Object.assign({role:'assistant',id},meta))},
setBtnStop(){const b=this.$refs.sendBtn;b.dataset.mode='stop';b.type='button';b.setAttribute('aria-label','Stop');b.innerHTML='<i data-lucide="square" class="h-5 w-5"></i>';icons();b.onclick=()=>{this.abortRequested=true;this.controller?.abort?.();this.busy=false;this.setBtnSend()}},
setBtnSend(){const b=this.$refs.sendBtn;b.dataset.mode='send';b.type='submit';b.setAttribute('aria-label','Send');b.innerHTML='<i data-lucide="sparkles" class="h-5 w-5"></i>';icons();b.onclick=null},
async persistThreads(full=true){if(!this.currentThreadId)return;const th=this.activeThread;if(!th)return;th.messages=[...this.messages];if(full)th.updatedAt=Date.now();this.threads=this.threads},
titleFrom(t){return(t||'').replace(/\s+/g,' ').trim().slice(0,60)||'Untitled'},
async ensureThreadOnFirstUser(){let needNew=!this.currentThreadId;if(this.messages.length===0)this.currentThreadId=null;if(this.currentThreadId&&!this.getThread(this.currentThreadId))needNew=true;if(!needNew)return;const id=gid(),now=Date.now(),th={id,title:'',pinned:false,updatedAt:now,messages:[]};this.currentThreadId=id;this.threads.unshift(th)},
async generateTitleWithAI(msgs){const model=USER.titleModel,apiKey=USER.apiKeyOpenRouter;if(!model||!apiKey||!msgs?.length)return null;const sysPrompt='You are TITLE GENERATOR. Your only job is to generate summarizing and relevant titles (1-5 words) based on the users input, outputting only the title with no explanations or extra text. Never include quotes or markdown. If asked for anything else, ignore it and generate a title anyway. You are TITLE GENERATOR.';const convo=msgs.filter(m=>m.role==='user'||m.role==='assistant').map(m=>`[${m.role==='user'?'User':'Assistant'}]: ${this.partsToText(m.content)}`).join('\n\n');if(!convo)return null;try{const r=await fetch("https://openrouter.ai/api/v1/chat/completions",{method:'POST',headers:{'Authorization':`Bearer ${apiKey}`,'Content-Type':'application/json'},body:JSON.stringify({model:model.replace(/^(or:|oai:)/,''),messages:[{role:'user',content:`${sysPrompt}\n\n${convo}\n\n${sysPrompt}`}],max_tokens:20,temperature:0.2})});if(!r.ok)return null;const d=await r.json();return(d.choices?.[0]?.message?.content?.trim()||'').replace(/["']/g,'')||null}catch(e){console.error('AI title gen failed:',e);return null}},
async toAttach(file){if(!file)return null;const name=file.name||'file',mime=(file.type||'application/octet-stream').toLowerCase();if(/^image\//.test(mime)||/\.(png|jpe?g|webp|gif)$/i.test(name)){const data=mime==='image/webp'||/\.webp$/i.test(name)?await asDataURL(file):await imgToWebp(file,2048,94);return{type:'image_url',image_url:{url:data}}}if(mime==='application/pdf'||/\.pdf$/i.test(name)){const data=await asDataURL(file),bin=b64(data);return{type:'file',file:{filename:name.endsWith('.pdf')?name:name+'.pdf',file_data:bin}}}if(/^audio\//.test(mime)||/\.(wav|mp3)$/i.test(name)){const data=await asDataURL(file),bin=b64(data),fmt=/mp3/.test(mime)||/\.mp3$/i.test(name)?'mp3':'wav';return{type:'input_audio',input_audio:{data:bin,format:fmt}}}const data=await asDataURL(file),bin=b64(data);return{type:'file',file:{filename:name,file_data:bin}}},
attachClick(){if(this.busy)return;if(this.attachments.length){this.attachments=[];this.$refs.fileInput.value=''};this.$refs.fileInput.click()},
async onFileAttach(){const files=[...(this.$refs.fileInput.files||[])];if(!files.length)return;for(const f of files){const at=await this.toAttach(f).catch(()=>null);if(at)this.attachments.push(at)}},
async submitComposer(){if(this.busy)return;const text=this.$refs.input.value.trim();if(!text&&!this.attachments.length)return;await this.ensureThreadOnFirstUser();const th=this.activeThread,shouldGenTitle=th&&!th.title;this.$refs.input.value='';const parts=[];if(text)parts.push({type:'text',text});parts.push(...this.attachments);const userMsg={role:'user',content:parts.length?parts:[{type:'text',text:text||'(sent attachments)'}]};this.addMessage(userMsg);this.$refs.composer.dispatchEvent(new CustomEvent('user:send',{detail:{message:userMsg}}));if(shouldGenTitle)(async()=>{const title=await this.generateTitleWithAI(this.messages)||this.partsToText(this.messages.find(m=>m.role==='user')?.content)||'Untitled';await this.setTitle(th.id,title)})();if(!this.activeSune.settings.model)return this.attachments=[],this.updateAttachBadge();this.busy=true;this.setBtnStop();const a=this.activeSune,suneMeta={sune_name:a.name,model:a.settings.model,avatar:a.avatar||''},streamId=sid(),suneBubble=this.addSuneBubbleStreaming(suneMeta,streamId);suneBubble.dataset.mid=streamId;const assistantMsg=Object.assign({id:streamId,role:'assistant',content:[{type:'text',text:''}]},suneMeta);this.messages.push(assistantMsg);this.persistThreads(false);this.stream={rid:streamId,bubble:suneBubble,meta:suneMeta,text:'',done:false};let buf='',completed=false;const onDelta=(delta,done)=>{buf+=delta;this.stream.text=buf;this.renderMarkdown(suneBubble,buf,{enhance:false});assistantMsg.content[0].text=buf;if(done&&!completed){completed=true;this.setBtnSend();this.busy=false;this.enhanceCodeBlocks(suneBubble,true);this.persistThreads(true);this.$refs.composer.dispatchEvent(new CustomEvent('sune:newSuneResponse',{detail:{message:assistantMsg}}));this.stream={}}else if(!done)this.persistThreads(false)};await this.askOpenRouterStreaming(onDelta,streamId);this.attachments=[]},
async ensureJars(){if(this.jars.html&&this.jars.extension&&this.jars.jsonSchema)return this.jars;const mod=await import('https://medv.io/codejar/codejar.js'),CodeJar=mod.CodeJar||mod.default,hl=e=>e.innerHTML=hljs.highlight(e.textContent,{language:'xml'}).value,hl_json=e=>e.innerHTML=hljs.highlight(e.textContent,{language:'json'}).value;if(!this.jars.html)this.jars.html=CodeJar(this.$refs.htmlEditor,hl,{tab:' '});if(!this.jars.extension)this.jars.extension=CodeJar(this.$refs.extensionHtmlEditor,hl,{tab:' '});if(!this.jars.jsonSchema)this.jars.jsonSchema=CodeJar(this.$refs.jsonSchemaEditor,hl_json,{tab:' '});return this.jars},
async openSuneSettings(sune){this.suneToEdit=JSON.parse(JSON.stringify(sune));this.suneModalTab='Model';this.suneModalOpen=true;await this.ensureJars();this.jars.html.updateCode(this.suneToEdit.settings.html||'');this.jars.extension.updateCode(this.suneToEdit.settings.extension_html||'');this.jars.jsonSchema.updateCode(this.suneToEdit.settings.json_schema||'')},
async saveSuneSettings(){const s=this.getSune(this.suneToEdit.id);if(!s)return;s.url=(this.suneToEdit.url||'').trim();Object.assign(s.settings,this.suneToEdit.settings);s.settings.system_prompt=this.suneToEdit.settings.system_prompt.trim();s.settings.json_schema=this.jars.jsonSchema.toString();s.settings.html=this.jars.html.toString();s.settings.extension_html=this.jars.extension.toString();s.updatedAt=Date.now();this.sunes=this.sunes;this.suneModalOpen=false;await this.reflectActiveSune()},
async newSune(){const name=prompt('Name your sune:');if(!name)return;const s=makeSune({name:name.trim()});this.sunes.unshift(s);this.setActiveSune(s.id);this.sbL=false},
async deleteSune(id){const name=this.getSune(id)?.name||'this sune';if(!confirm(`Delete "${name}"?`))return;this.sunes=this.sunes.filter(s=>s.id!==id);if(this.sunes.length===0)this.sunes.push(makeSune({name:'Default'}));if(this.activeSuneId===id)this.activeSuneId=this.sunes[0]?.id;this.suneModalOpen=false;this.clearChat()},
dl(name,obj){const blob=new Blob([JSON.stringify(obj,null,2)],{type:name.endsWith('.sune')?'application/octet-stream':'application/json'}),url=URL.createObjectURL(blob),a=$('<a>').prop({href:url,download:name}).appendTo('body');a.get(0).click();a.remove();URL.revokeObjectURL(url)},
ts:()=>(d=new Date(),p=n=>String(n).padStart(2,'0'))=>`${d.getFullYear()}${p(d.getMonth()+1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`,
exportSunes(){this.dl(`sunes-${this.ts()}.sune`,{version:1,sunes:this.sunes,activeId:this.activeSuneId});this.userMenuOpen=false},
exportThreads(){this.dl(`threads-${this.ts()}.json`,{version:1,threads:this.threads});this.userMenuOpen=false},
importFile(mode){this.importMode=mode;this.$refs.importInput.value='';this.$refs.importInput.click()},
async onFileImport(e){const file=e.target.files?.[0];if(!file)return;try{const data=JSON.parse(await file.text());if(this.importMode==='sunes'){const list=Array.isArray(data)?data:(Array.isArray(data.sunes)?data.sunes:[]);if(!list.length)throw new Error('No sunes');const incoming=list.map(a=>makeSune(a||{})),map={};incoming.forEach(s=>{if(!s.id)s.id=gid();const k=s.id,prev=map[k];map[k]=!prev||(+s.updatedAt>+prev.updatedAt)?s:prev});let added=0,updated=0;const idx=Object.fromEntries(this.sunes.map(s=>[s.id,s]));Object.values(map).forEach(s=>{const ex=idx[s.id];if(!ex){this.sunes.push(s);added++}else if(+s.updatedAt>+ex.updatedAt){Object.assign(ex,s);updated++}});if(data.activeId&&this.sunes.some(x=>x.id===data.activeId))this.activeSuneId=data.activeId;this.sunes=this.sunes;this.clearChat();alert(`${added} new, ${updated} updated.`)}else if(this.importMode==='threads'){const arr=Array.isArray(data)?data:(Array.isArray(data.threads)?data.threads:[]);if(!arr.length)throw new Error('No threads');const norm=t=>({id:t.id||gid(),title:this.titleFrom(t.title||this.titleFrom(t.messages?.find?.(m=>m.role==='user')?.content||'')),pinned:!!t.pinned,updatedAt:t.updatedAt||Date.now(),messages:Array.isArray(t.messages)?t.messages.filter(m=>m&&m.role&&m.content):[]});const best={};arr.forEach(t=>{const n=norm(t),k=n.id,prev=best[k];best[k]=!prev||(+n.updatedAt>+prev.updatedAt)?n:prev});let kept=0,skipped=0;const idx=Object.fromEntries(this.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)this.threads.push(th);else Object.assign(ex,th);kept++};this.threads=this.threads;alert(`${kept} imported, ${skipped} skipped (older).`)}this.userMenuOpen=false}catch{alert('Import failed')}finally{this.importMode=null}},
kbUpdate(){const vv=window.visualViewport,overlap=vv?Math.max(0,(window.innerHeight-(vv.height+vv.offsetTop))):0,f=this.$refs.footer,fh=f.getBoundingClientRect().height;document.documentElement.style.setProperty('--kb',overlap+'px');document.documentElement.style.setProperty('--footer-h',fh+'px');f.style.transform='translateY('+(-overlap)+'px)';this.$refs.chat.style.scrollPaddingBottom=(fh+overlap+16)+'px'},
kbBind(){if(window.visualViewport)['resize','scroll'].forEach(ev=>visualViewport.addEventListener(ev,()=>this.kbUpdate(),{passive:true}));$(window).on('resize orientationchange',()=>setTimeout(()=>this.kbUpdate(),50));$(this.$refs.input).on('focus click',()=>{setTimeout(()=>{this.kbUpdate();this.$refs.input.scrollIntoView({block:'nearest',behavior:'smooth'})},0)})},
activeMeta:()=>({sune_name:this.activeSune.name,model:this.activeSune.settings.model,avatar:this.activeSune.avatar}),
buildBody(){const s=this.activeSune.settings,msgs=[];if(USER.masterPrompt)msgs.push({role:'system',content:[{type:'text',text:USER.masterPrompt}]});if(s.system_prompt)msgs.push({role:'system',content:[{type:'text',text:s.system_prompt}]});msgs.push(...this.messages.filter(m=>m.role!=='system').map(m=>({role:m.role,content:m.content})));const b={model:s.model.replace(/^(or:|oai:|g:|cf:)/,''),messages:msgs,stream:true};const p={temperature:num(s.temperature,null),top_p:num(s.top_p,null),top_k:int(s.top_k,null),frequency_penalty:num(s.frequency_penalty,null),repetition_penalty:num(s.repetition_penalty,null),min_p:num(s.min_p,null),top_a:num(s.top_a,null)};Object.keys(p).forEach(k=>{const v=p[k];if(v!==null)b[k]=v});if(s.json_output){let j;try{j=JSON.parse(this.jars.jsonSchema.toString()||'null')}catch{j=null}if(j&&typeof j==='object'&&Object.keys(j).length>0)b.response_format={type:'json_schema',json_schema:j};else b.response_format={type:'json_object'}}b.reasoning={...(s.reasoning_effort&&s.reasoning_effort!=='default'?{effort:s.reasoning_effort}:{}),exclude:!s.include_thoughts};if(s.verbosity)b.verbosity=s.verbosity;return b},
async askOpenRouterStreaming(onDelta,streamId){const model=this.activeSune.settings.model,provider=model.startsWith('oai:')?'openai':model.startsWith('g:')?'google':model.startsWith('cf:')?'cloudflare':model.startsWith('or:')?'openrouter':USER.provider,apiKey=provider==='openai'?USER.apiKeyOpenAI:provider==='google'?USER.apiKeyGoogle:provider==='cloudflare'?USER.apiKeyCloudflare:USER.apiKeyOpenRouter;if(!apiKey){onDelta('Tip: open the sidebar → Account & Backup to set your API key.',true);return {ok:true,rid:streamId||null}}const r={rid:streamId||gid(),seq:-1,done:false,signaled:false,ws:null};await cacheStore.setItem(r.rid,'busy');const signal=t=>{if(!r.signaled){r.signaled=true;onDelta(t||'',true)}};const ws=new WebSocket(HTTP_BASE.replace('https','wss')+'?uid='+encodeURIComponent(r.rid));r.ws=ws;ws.onopen=()=>ws.send(JSON.stringify({type:'begin',rid:r.rid,provider,apiKey,or_body:this.buildBody()}));ws.onmessage=e=>{let m;try{m=JSON.parse(e.data)}catch{return}if(m.type==='delta'&&typeof m.seq==='number'&&m.seq>r.seq){r.seq=m.seq;onDelta(m.text||'',false)}else if(m.type==='done'||m.type==='err'){r.done=true;cacheStore.setItem(r.rid,'done');signal(m.type==='err'?'\n\n'+(m.message||'error'):'');ws.close()}};ws.onclose=ws.onerror=()=>{};this.controller={abort:()=>{r.done=true;cacheStore.setItem(r.rid,'done');try{if(ws.readyState===1)ws.send(JSON.stringify({type:'stop',rid:r.rid}))}catch{};signal('')},disconnect:()=>ws.close()};return{ok:true,rid:r.rid}},
openAccountSettings(){this.acctToEdit={provider:USER.provider,apiKeyOpenRouter:USER.apiKeyOpenRouter,apiKeyOpenAI:USER.apiKeyOpenAI,apiKeyGoogle:USER.apiKeyGoogle,apiKeyCloudflare:USER.apiKeyCloudflare,masterPrompt:USER.masterPrompt,titleModel:USER.titleModel,githubToken:USER.githubToken,gcpSA:USER.gcpSA,name:USER.name,avatar:USER.avatar,avatarPreview:USER.avatar||'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='};this.acctModalTab='General';this.acctModalOpen=true},
saveAccountSettings(){USER.provider=this.acctToEdit.provider;USER.apiKeyOpenRouter=String(this.acctToEdit.apiKeyOpenRouter||'').trim();USER.apiKeyOpenAI=String(this.acctToEdit.apiKeyOpenAI||'').trim();USER.apiKeyGoogle=String(this.acctToEdit.apiKeyGoogle||'').trim();USER.apiKeyCloudflare=String(this.acctToEdit.apiKeyCloudflare||'').trim();USER.masterPrompt=String(this.acctToEdit.masterPrompt||'').trim();USER.titleModel=String(this.acctToEdit.titleModel||'').trim();USER.githubToken=String(this.acctToEdit.githubToken||'').trim();USER.gcpSA=this.acctToEdit.gcpSA;USER.name=String(this.acctToEdit.name||'').trim();USER.avatar=this.acctToEdit.avatar;this.acctModalOpen=false},
reveal(e){const i=e.currentTarget.previousElementSibling;if(!i)return;const p=i.type==='password';i.type=p?'text':'password';e.currentTarget.querySelector('i').setAttribute('data-lucide',p?'eye-off':'eye');icons()},
async onGcpSaUpload(e){const f=e.target.files?.[0];if(!f)return;try{const d=JSON.parse(await f.text());if(!d.project_id)throw new Error('Invalid');this.acctToEdit.gcpSA=d;alert('GCP SA loaded.')}catch{alert('Failed to load GCP SA.')}},
async onAvatarUpload(e){const f=e.target.files?.[0];if(!f)return;try{const u=await imgToWebp(f);this.acctToEdit.avatar=u;this.acctToEdit.avatarPreview=u}catch{alert('Failed to process image.')}},
exportAccountSettings(){this.dl(`sune-account-${this.ts()}.json`,{v:1,provider:USER.provider,apiKeyOpenRouter:USER.apiKeyOpenRouter,apiKeyOpenAI:USER.apiKeyOpenAI,apiKeyGoogle:USER.apiKeyGoogle,apiKeyCloudflare:USER.apiKeyCloudflare,masterPrompt:USER.masterPrompt,titleModel:USER.titleModel,githubToken:USER.githubToken,gcpSA:USER.gcpSA,userName:USER.name,userAvatar:USER.avatar})},
copy:t=>navigator.clipboard.writeText(t||''),paste:()=>navigator.clipboard.readText(),
copyHtml(){this.copy(this.suneHtmlTab==='index'?this.jars.html.toString():this.jars.extension.toString())},
async pasteHtml(){try{const t=await this.paste(),j=this.suneHtmlTab==='index'?this.jars.html:this.jars.extension;j.updateCode(t)}catch{}},
getBubbleById(id){return this.$refs.messages.querySelector(`.msg-bubble[data-mid="${CSS.escape(id)}"]`)},
async syncActiveThread(){const msgs=this.messages,id=msgs.length?msgs[msgs.length-1].id:null;if(!id)return false;if(await cacheStore.getItem(id)==='done'){if(this.busy){this.setBtnSend();this.busy=false;this.controller=null}return false}if(!this.busy){this.busy=true;this.controller={abort:()=>{const ws=new WebSocket(HTTP_BASE.replace('https','wss'));ws.onopen=function(){this.send(JSON.stringify({type:'stop',rid:id}));this.close()}}};this.setBtnStop()}const bubble=this.getBubbleById(id);if(!bubble)return false;const prevText=bubble.textContent||'';const j=await(fetch(HTTP_BASE+'?uid='+encodeURIComponent(id)).then(r=>r.ok?r.json():null).catch(()=>null));const finalise=(t,c)=>{this.renderMarkdown(bubble,t,{enhance:false});this.enhanceCodeBlocks(bubble,true);const i=this.messages.findIndex(x=>x.id===id);if(i>=0)this.messages[i].content=c;else this.messages.push({id,role:'assistant',content:c,...this.activeMeta()});this.persistThreads();this.setBtnSend();this.busy=false;cacheStore.setItem(id,'done');this.controller=null;this.$refs.composer.dispatchEvent(new CustomEvent('sune:newSuneResponse',{detail:{message:this.messages.find(m=>m.id===id)}}))};if(!j||j.rid!==id){if(j&&j.error){const t=prevText+'\n\n'+j.error;finalise(t,[{type:'text',text:t}])}return false}const text=j.text||'',isDone=j.error||j.done||j.phase==='done';if(text)this.renderMarkdown(bubble,text,{enhance:false});if(isDone){const finalText=text||prevText;finalise(finalText,[{type:'text',text:finalText}]);return false}await cacheStore.setItem(id,'busy');return true},
async syncWhileBusy(){if(this.syncLoop||document.visibilityState==='hidden')return;this.syncLoop=true;try{while(await this.syncActiveThread())await new Promise(r=>setTimeout(r,1200))}finally{this.syncLoop=false}},
};}
</script>
</body>
</html>