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