Merge pull request #13 from sssdesu/dev3

Dev3
This commit is contained in:
sss
2025-08-14 00:27:09 -07:00
committed by GitHub
2 changed files with 830 additions and 890 deletions

View File

@@ -1,447 +1,417 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
<title>ChatGPT — Mobile Clone (Light)</title> <title>ChatGPT — Mobile Clone (Light)</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<style> <style>
:root{--safe-bottom:env(safe-area-inset-bottom)} :root{--safe-bottom:env(safe-area-inset-bottom)}
::-webkit-scrollbar{height:8px;width:8px} ::-webkit-scrollbar{height:8px;width:8px}
::-webkit-scrollbar-thumb{background:#e5e7eb;border-radius:999px} ::-webkit-scrollbar-thumb{background:#e5e7eb;border-radius:999px}
.no-scrollbar::-webkit-scrollbar{display:none} .no-scrollbar::-webkit-scrollbar{display:none}
.no-scrollbar{-ms-overflow-style:none;scrollbar-width:none} .no-scrollbar{-ms-overflow-style:none;scrollbar-width:none}
</style> </style>
</head> </head>
<body class="bg-white text-gray-900 selection:bg-black/10"> <body class="bg-white text-gray-900 selection:bg-black/10">
<div class="flex flex-col h-dvh max-h-dvh"> <div class="flex flex-col h-dvh max-h-dvh">
<!-- Header --> <header class="sticky top-0 z-20 bg-white/80 backdrop-blur border-b border-gray-200">
<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-2xl px-4 py-3 grid grid-cols-3 items-center">
<div class="mx-auto w-full max-w-2xl 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="Assistants">
<!-- Sidebar toggle (left) --> <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="M4 6h16M4 12h16M4 18h16"/></svg>
<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="Assistants"> </button>
<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="M4 6h16M4 12h16M4 18h16"/></svg> <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="Assistant settings">🤖</button>
</button> <div></div>
<!-- Active assistant (center) --> </div>
<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="Assistant settings">🤖</button> </header>
<!-- API key badge (right) --> <main id="chat" class="flex-1 overflow-y-auto no-scrollbar">
<button id="apiBadge" title="Set OpenRouter API key" class="justify-self-end h-8 min-w-8 px-3 rounded-full border text-xs font-medium inline-flex items-center justify-center gap-1 transition bg-gray-100 text-gray-700 border-gray-200 hover:bg-gray-200"> <div id="messages" class="mx-auto w-full max-w-2xl px-4 py-4 sm:py-6 space-y-4"></div>
<svg viewBox="0 0 24 24" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M15 7a4 4 0 11-7.999.001A4 4 0 0115 7zm-.293 6.707L9 19.414V22h2.586l5.707-5.707a1 1 0 000-1.414l-1.586-1.586a1 1 0 00-1.414 0z"/></svg> <div class="h-24"></div>
<span id="statusText" class="sr-only">offline</span> </main>
</button> <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">
</div> <div class="mx-auto w-full max-w-2xl px-4">
</header><!-- Messages --> <form id="composer" class="group relative flex items-end gap-2">
<main id="chat" class="flex-1 overflow-y-auto no-scrollbar"> <textarea id="input" rows="1" placeholder="Send a message" spellcheck="true" class="flex-1 resize-none rounded-2xl border border-gray-300 bg-white px-4 py-3 text-[15px] leading-6 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-black/20 focus:border-gray-300 max-h-40"></textarea>
<div id="messages" class="mx-auto w-full max-w-2xl px-4 py-4 sm:py-6 space-y-4"></div> <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 disabled:opacity-40 disabled:cursor-not-allowed">
<div class="h-24"></div> <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>
</main> <!-- Input Dock --> <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"> </button>
<div class="mx-auto w-full max-w-2xl px-4"> </form>
<form id="composer" class="group relative flex items-end gap-2"> <div class="mt-2 flex items-center justify-end text-xs text-gray-500">
<textarea id="input" rows="1" placeholder="Send a message" spellcheck="true" class="flex-1 resize-none rounded-2xl border border-gray-300 bg-white px-4 py-3 text-[15px] leading-6 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-black/20 focus:border-gray-300 max-h-40"></textarea> <div class="flex items-center gap-3">
<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 disabled:opacity-40 disabled:cursor-not-allowed"> <button id="stopBtn" class="underline decoration-dotted hover:text-gray-700 hidden">Stop</button>
<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 id="clearBtn" class="underline decoration-dotted hover:text-gray-700">Clear</button>
</button> </div>
</form> </div>
<div class="mt-2 flex items-center justify-end text-xs text-gray-500"> </div>
<div class="flex items-center gap-3"> </footer>
<button id="stopBtn" class="underline decoration-dotted hover:text-gray-700 hidden">Stop</button> </div>
<button id="clearBtn" class="underline decoration-dotted hover:text-gray-700">Clear</button> <div id="sidebarOverlay" class="fixed inset-0 z-40 bg-black/20 hidden"></div>
</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> <div class="p-3 border-b flex items-center gap-2">
</div> <button id="newAssistantBtn" class="px-3 py-2 rounded-xl bg-black text-white text-sm hover:bg-black/90">New assistant</button>
</footer> </div> <!-- Sidebar --> <div id="sidebarOverlay" class="fixed inset-0 z-40 bg-black/20 hidden"></div> <span class="text-xs text-gray-500">Click name to equip</span>
<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>
<div class="p-3 border-b flex items-center gap-2"> <div id="assistantList" class="flex-1 overflow-y-auto divide-y"></div>
<button id="newAssistantBtn" class="px-3 py-2 rounded-xl bg-black text-white text-sm hover:bg-black/90">New assistant</button> <div class="p-3 border-t">
<span class="text-xs text-gray-500">Click name to equip</span> <div class="relative">
</div> <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">
<div id="assistantList" class="flex-1 overflow-y-auto divide-y"></div> <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>
</aside> <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="M6 9l6 6 6-6"/></svg>
<!-- Settings Dialog --> </button>
<div id="settingsModal" class="hidden fixed inset-0 z-50"> <div id="userMenu" class="absolute left-0 right-0 bottom-12 translate-y-2 rounded-xl border border-gray-200 bg-white shadow-lg hidden">
<div class="absolute inset-0 bg-black/30"></div> <button id="apiKeyOption" class="w-full text-left px-3 py-2 text-sm hover:bg-gray-50">Enter OpenRouter API key</button>
<div class="absolute inset-x-0 top-12 mx-auto w-full max-w-md px-4"> <button id="importOption" class="w-full text-left px-3 py-2 text-sm hover:bg-gray-50">Import backup (.json)</button>
<div class="rounded-2xl bg-white shadow-xl border border-gray-200 overflow-hidden"> <button id="exportOption" class="w-full text-left px-3 py-2 text-sm hover:bg-gray-50">Export backup (.json)</button>
<div class="px-4 py-3 border-b text-sm font-semibold flex items-center justify-between"> </div>
<span>Assistant Settings</span> <input id="importInput" type="file" accept="application/json,.json" class="hidden"/>
<button id="closeSettings" class="p-1 rounded hover:bg-gray-100" aria-label="Close"> </div>
<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> </div>
</button> </aside>
</div> <div id="settingsModal" class="hidden fixed inset-0 z-50">
<form id="settingsForm" class="text-sm"> <div class="absolute inset-0 bg-black/30"></div>
<div class="border-b flex text-sm font-medium"> <div class="absolute inset-x-0 top-12 mx-auto w-full max-w-md px-4">
<button type="button" id="tabModel" class="flex-1 py-2 px-3 text-center border-b-2 border-black">Model & Sampling</button> <div class="rounded-2xl bg-white shadow-xl border border-gray-200 overflow-hidden">
<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 class="px-4 py-3 border-b text-sm font-semibold flex items-center justify-between">
</div> <span>Assistant Settings</span>
<div id="panelModel" class="p-4 space-y-4"> <button id="closeSettings" class="p-1 rounded hover:bg-gray-100" aria-label="Close">
<div> <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>
<label class="block text-gray-700 font-medium mb-1">Model name</label> </button>
<input id="set_model" type="text" class="w-full rounded-xl border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-black/20" placeholder="openai/gpt-4o"/> </div>
</div> <form id="settingsForm" class="text-sm">
<div class="grid grid-cols-2 gap-3"> <div class="border-b flex text-sm font-medium">
<div><label class="block text-gray-700 font-medium mb-1">Temperature <span class="text-gray-400">(02)</span></label><input id="set_temperature" type="number" min="0" max="2" step="0.1" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="1.0"/><p class="mt-1 text-xs text-gray-500">Variety. Lower = predictable.</p></div> <button type="button" id="tabModel" class="flex-1 py-2 px-3 text-center border-b-2 border-black">Model & Sampling</button>
<div><label class="block text-gray-700 font-medium mb-1">Top P <span class="text-gray-400">(01)</span></label><input id="set_top_p" type="number" min="0" max="1" step="0.01" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="1.0"/><p class="mt-1 text-xs text-gray-500">Nucleus sampling.</p></div> <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><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>
<div><label class="block text-gray-700 font-medium mb-1">Frequency Penalty <span class="text-gray-400">(-22)</span></label><input id="set_frequency_penalty" type="number" min="-2" max="2" step="0.1" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="0.0"/><p class="mt-1 text-xs text-gray-500">Discourage repeats by count.</p></div> <div id="panelModel" class="p-4 space-y-4">
<div><label class="block text-gray-700 font-medium mb-1">Presence Penalty <span class="text-gray-400">(-22)</span></label><input id="set_presence_penalty" type="number" min="-2" max="2" step="0.1" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="0.0"/><p class="mt-1 text-xs text-gray-500">Discourage seen tokens.</p></div> <div>
<div><label class="block text-gray-700 font-medium mb-1">Repetition Penalty <span class="text-gray-400">(02)</span></label><input id="set_repetition_penalty" type="number" min="0" max="2" step="0.1" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="1.0"/><p class="mt-1 text-xs text-gray-500">Reduce verbatim echoes.</p></div> <label class="block text-gray-700 font-medium mb-1">Model name</label>
<div><label class="block text-gray-700 font-medium mb-1">Min P <span class="text-gray-400">(01)</span></label><input id="set_min_p" type="number" min="0" max="1" step="0.01" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="0.0"/><p class="mt-1 text-xs text-gray-500">Minimum token prob vs best.</p></div> <input id="set_model" type="text" class="w-full rounded-xl border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-black/20" placeholder="openai/gpt-4o"/>
<div><label class="block text-gray-700 font-medium mb-1">Top A <span class="text-gray-400">(01)</span></label><input id="set_top_a" type="number" min="0" max="1" step="0.01" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="0.0"/><p class="mt-1 text-xs text-gray-500">Adaptive nucleus filter.</p></div> </div>
</div> <div class="grid grid-cols-2 gap-3">
</div> <div><label class="block text-gray-700 font-medium mb-1">Temperature <span class="text-gray-400">(02)</span></label><input id="set_temperature" type="number" min="0" max="2" step="0.1" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="1.0"/><p class="mt-1 text-xs text-gray-500">Variety. Lower = predictable.</p></div>
<div id="panelPrompt" class="p-4 space-y-4 hidden"> <div><label class="block text-gray-700 font-medium mb-1">Top P <span class="text-gray-400">(01)</span></label><input id="set_top_p" type="number" min="0" max="1" step="0.01" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="1.0"/><p class="mt-1 text-xs text-gray-500">Nucleus sampling.</p></div>
<div> <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>
<label class="block text-gray-700 font-medium mb-1">System Prompt</label> <div><label class="block text-gray-700 font-medium mb-1">Frequency Penalty <span class="text-gray-400">(-22)</span></label><input id="set_frequency_penalty" type="number" min="-2" max="2" step="0.1" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="0.0"/><p class="mt-1 text-xs text-gray-500">Discourage repeats by count.</p></div>
<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 assistant"></textarea> <div><label class="block text-gray-700 font-medium mb-1">Presence Penalty <span class="text-gray-400">(-22)</span></label><input id="set_presence_penalty" type="number" min="-2" max="2" step="0.1" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="0.0"/><p class="mt-1 text-xs text-gray-500">Discourage seen tokens.</p></div>
<p class="mt-1 text-xs text-gray-500">Saved per assistant.</p> <div><label class="block text-gray-700 font-medium mb-1">Repetition Penalty <span class="text-gray-400">(02)</span></label><input id="set_repetition_penalty" type="number" min="0" max="2" step="0.1" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="1.0"/><p class="mt-1 text-xs text-gray-500">Reduce verbatim echoes.</p></div>
</div> <div><label class="block text-gray-700 font-medium mb-1">Min P <span class="text-gray-400">(01)</span></label><input id="set_min_p" type="number" min="0" max="1" step="0.01" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="0.0"/><p class="mt-1 text-xs text-gray-500">Minimum token prob vs best.</p></div>
</div> <div><label class="block text-gray-700 font-medium mb-1">Top A <span class="text-gray-400">(01)</span></label><input id="set_top_a" type="number" min="0" max="1" step="0.01" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="0.0"/><p class="mt-1 text-xs text-gray-500">Adaptive nucleus filter.</p></div>
<!-- Footer actions: Delete (left), Cancel/Save (right) --> </div>
<div class="flex items-center justify-between gap-2 px-4 py-3 border-t"> </div>
<button type="button" id="deleteAssistantBtn" class="inline-flex items-center gap-2 px-3 py-2 rounded-xl border border-red-200 text-red-700 hover:bg-red-50"> <div id="panelPrompt" class="p-4 space-y-4 hidden">
<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> <div>
<span>Delete assistant</span> <label class="block text-gray-700 font-medium mb-1">System Prompt</label>
</button> <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 assistant"></textarea>
<div class="flex items-center justify-end gap-2"> <p class="mt-1 text-xs text-gray-500">Saved per assistant.</p>
<button type="button" id="cancelSettings" class="px-3 py-2 rounded-xl border bg-white hover:bg-gray-50">Cancel</button> </div>
<button type="submit" class="px-3 py-2 rounded-xl bg-black text-white hover:bg-black/90">Save</button> </div>
</div> <div class="flex items-center justify-between gap-2 px-4 py-3 border-t">
</div> <button type="button" id="deleteAssistantBtn" class="inline-flex items-center gap-2 px-3 py-2 rounded-xl border border-red-200 text-red-700 hover:bg-red-50">
</form> <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>
</div> <span>Delete assistant</span>
</div> </button>
</div> <script> <div class="flex items-center justify-end gap-2">
// === Constants === <button type="button" id="cancelSettings" class="px-3 py-2 rounded-xl border bg-white hover:bg-gray-50">Cancel</button>
const DEFAULT_MODEL = 'openai/gpt-4o'; <button type="submit" class="px-3 py-2 rounded-xl bg-black text-white hover:bg-black/90">Save</button>
const DEFAULT_API_KEY = ''; </div>
</div>
// === Elements === </form>
const el = Object.fromEntries([ </div>
'chat','messages','composer','input','sendBtn','stopBtn','clearBtn','apiBadge','statusText','settingsBtnTop', </div>
'settingsModal','settingsForm','closeSettings','cancelSettings','tabModel','tabPrompt','panelModel','panelPrompt', </div>
'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', <script>
'sidebar','sidebarOverlay','sidebarBtn','assistantList','newAssistantBtn','deleteAssistantBtn' const DEFAULT_MODEL = 'openai/gpt-4o'
].map(id => [id, document.getElementById(id)])); const DEFAULT_API_KEY = ''
const el = Object.fromEntries([
// === Utilities === 'chat','messages','composer','input','sendBtn','stopBtn','clearBtn','settingsBtnTop',
const clamp = (v, min, max) => Math.max(min, Math.min(max, v)); 'settingsModal','settingsForm','closeSettings','cancelSettings','tabModel','tabPrompt','panelModel','panelPrompt',
const num = (v, d) => (v == null || v === '' || isNaN(+v)) ? d : +v; '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',
const int = (v, d) => (v == null || v === '' || isNaN(parseInt(v))) ? d : parseInt(v); 'sidebar','sidebarOverlay','sidebarBtn','assistantList','newAssistantBtn','deleteAssistantBtn','userMenuBtn','userMenu','importInput','importOption','exportOption','apiKeyOption'
const gid = () => Math.random().toString(36).slice(2,9); ].map(id => [id, document.getElementById(id)]))
const clamp = (v, min, max) => Math.max(min, Math.min(max, v))
// === Global store (API key only) === const num = (v, d) => (v == null || v === '' || isNaN(+v)) ? d : +v
const globalStore = { const int = (v, d) => (v == null || v === '' || isNaN(parseInt(v))) ? d : parseInt(v)
get apiKey(){ return localStorage.getItem('openrouter_api_key') || DEFAULT_API_KEY || '' }, const gid = () => Math.random().toString(36).slice(2,9)
set apiKey(v){ localStorage.setItem('openrouter_api_key', v || ''); updateStatus(); }, const globalStore = {
}; get apiKey(){ return localStorage.getItem('openrouter_api_key') || DEFAULT_API_KEY || '' },
set apiKey(v){ localStorage.setItem('openrouter_api_key', v || ''); updateStatus() },
// === Assistants store (per-assistant settings) === }
const as = { const as = {
key: 'assistants_v1', key: 'assistants_v1',
activeKey: 'active_assistant_id', activeKey: 'active_assistant_id',
load(){ try{ return JSON.parse(localStorage.getItem(this.key)||'[]') }catch{ return [] } }, load(){ try{ return JSON.parse(localStorage.getItem(this.key)||'[]') }catch{ return [] } },
save(list){ localStorage.setItem(this.key, JSON.stringify(list||[])) }, save(list){ localStorage.setItem(this.key, JSON.stringify(list||[])) },
getActiveId(){ return localStorage.getItem(this.activeKey) || null }, getActiveId(){ return localStorage.getItem(this.activeKey) || null },
setActiveId(id){ localStorage.setItem(this.activeKey, id||'') }, setActiveId(id){ localStorage.setItem(this.activeKey, id||'') },
}; }
let assistants = as.load()
let assistants = as.load(); if (!assistants.length){
if (!assistants.length){ const id = gid()
const id = gid(); assistants = [{ id, 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:'' } }]
assistants = [{ id, 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:'' } }]; as.save(assistants)
as.save(assistants); as.setActiveId(id)
as.setActiveId(id); }
} const getActive = () => assistants.find(a => a.id === as.getActiveId()) || assistants[0]
const setActive = (id) => { as.setActiveId(id); renderSidebar(); reflectActiveAssistant(); clearChat() }
// Derived helpers for current assistant const createDefaultAssistant = () => ({ 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 getActive = () => assistants.find(a => a.id === as.getActiveId()) || assistants[0]; const store = new Proxy({}, {
const setActive = (id) => { as.setActiveId(id); renderSidebar(); reflectActiveAssistant(); clearChat(); }; get(_, prop){
if (prop === 'apiKey') return globalStore.apiKey
const createDefaultAssistant = () => ({ 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 a = getActive()
if (prop === 'model') return a.settings.model
// === Legacy-compatible settings proxy bound to active assistant === if (prop in a.settings) return a.settings[prop]
const store = new Proxy({}, { if (prop === 'system_prompt') return a.settings.system_prompt
get(_, prop){ return undefined
if (prop === 'apiKey') return globalStore.apiKey; },
const a = getActive(); set(_, prop, val){
if (prop === 'model') return a.settings.model; if (prop === 'apiKey'){ globalStore.apiKey = val; return true }
if (prop in a.settings) return a.settings[prop]; const idx = assistants.findIndex(a => a.id === getActive().id)
if (prop === 'system_prompt') return a.settings.system_prompt; if (idx >= 0){
return undefined; if (prop === 'model') assistants[idx].settings.model = val || DEFAULT_MODEL
}, else if (prop === 'system_prompt') assistants[idx].settings.system_prompt = val || ''
set(_, prop, val){ else assistants[idx].settings[prop] = val
if (prop === 'apiKey'){ globalStore.apiKey = val; return true; } as.save(assistants)
const idx = assistants.findIndex(a => a.id === getActive().id); return true
if (idx >= 0){ }
if (prop === 'model') assistants[idx].settings.model = val || DEFAULT_MODEL; return false
else if (prop === 'system_prompt') assistants[idx].settings.system_prompt = val || ''; }
else assistants[idx].settings[prop] = val; })
as.save(assistants); const state = { messages: [], busy: false, controller: null }
return true; const getModelShort = () => { const m = store.model || ''; return m.includes('/') ? m.split('/').pop() : m }
} function reflectActiveAssistant(){ const a = getActive(); el.settingsBtnTop.title = `Settings — ${a.name}` }
return false; function renderSidebar(){
} const activeId = as.getActiveId()
}); el.assistantList.innerHTML = assistants.map(a => `
<button data-asst-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':''}">
// === Runtime state === <span class="h-6 w-6 rounded-full bg-gray-200 flex items-center justify-center">🤖</span>
const state = { messages: [], busy: false, controller: null }; <span class="truncate">${a.name}</span>
</button>
// === UI helpers === `).join('')
const getModelShort = () => { }
const m = store.model || ''; function addMessage(role, content){
return m.includes('/') ? m.split('/').pop() : m; const row = document.createElement('div')
}; row.className = 'flex gap-3'
const avatar = document.createElement('div')
function reflectActiveAssistant(){ avatar.className = 'cursor-pointer shrink-0 h-8 w-8 rounded-full flex items-center justify-center ' + (role === 'user' ? 'bg-gray-900 text-white' : 'bg-gray-200 text-gray-900')
const a = getActive(); avatar.textContent = role === 'user' ? '🧑' : '🤖'
el.settingsBtnTop.title = `Settings — ${a.name}`; if (role !== 'user') avatar.setAttribute('data-assistant-avatar','')
} const right = document.createElement('div')
right.className = 'flex flex-col'
function renderSidebar(){ if (role !== 'user'){
const activeId = as.getActiveId(); const name = document.createElement('div')
el.assistantList.innerHTML = assistants.map(a => ` name.className = 'text-xs font-medium text-gray-500 mb-0.5'
<button data-asst-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':''}"> name.textContent = getModelShort()
<span class="h-6 w-6 rounded-full bg-gray-200 flex items-center justify-center">🤖</span> right.appendChild(name)
<span class="truncate">${a.name}</span> }
</button> const bubble = document.createElement('div')
`).join(''); bubble.className = 'rounded-2xl px-4 py-3 text-[15px] leading-relaxed whitespace-pre-wrap ' + (role === 'user' ? 'bg-gray-50 border border-gray-200' : 'bg-gray-100')
} bubble.textContent = content
right.appendChild(bubble)
function addMessage(role, content){ row.appendChild(avatar); row.appendChild(right)
const row = document.createElement('div'); el.messages.appendChild(row)
row.className = 'flex gap-3'; state.messages.push({ role, content })
const avatar = document.createElement('div'); queueMicrotask(() => el.chat.scrollTo({ top: el.chat.scrollHeight, behavior: 'smooth' }))
avatar.className = 'cursor-pointer shrink-0 h-8 w-8 rounded-full flex items-center justify-center ' + (role === 'user' ? 'bg-gray-900 text-white' : 'bg-gray-200 text-gray-900'); return bubble
avatar.textContent = role === 'user' ? '🧑' : '🤖'; }
if (role !== 'user') avatar.setAttribute('data-assistant-avatar',''); function addAssistantBubbleStreaming(){
const right = document.createElement('div'); const row = document.createElement('div'); row.className = 'flex gap-3'
right.className = 'flex flex-col'; const avatar = document.createElement('div'); avatar.className = 'cursor-pointer shrink-0 h-8 w-8 rounded-full flex items-center justify-center bg-gray-200 text-gray-900'; avatar.textContent='🤖'; avatar.setAttribute('data-assistant-avatar','')
if (role !== 'user'){ const right = document.createElement('div'); right.className='flex flex-col'
const name = document.createElement('div'); const name = document.createElement('div'); name.className='text-xs font-medium text-gray-500 mb-0.5'; name.textContent=getModelShort()
name.className = 'text-xs font-medium text-gray-500 mb-0.5'; const bubble = document.createElement('div'); bubble.className='rounded-2xl px-4 py-3 text-[15px] leading-relaxed bg-gray-100 text-gray-800 whitespace-pre-wrap'; bubble.textContent=''
name.textContent = getModelShort(); right.appendChild(name); right.appendChild(bubble)
right.appendChild(name); row.appendChild(avatar); row.appendChild(right)
} el.messages.appendChild(row)
const bubble = document.createElement('div'); queueMicrotask(() => el.chat.scrollTo({ top: el.chat.scrollHeight }))
bubble.className = 'rounded-2xl px-4 py-3 text-[15px] leading-relaxed whitespace-pre-wrap ' + (role === 'user' ? 'bg-gray-50 border border-gray-200' : 'bg-gray-100'); return bubble
bubble.textContent = content; }
right.appendChild(bubble); function clearChat(){ state.messages=[]; el.messages.innerHTML='' }
row.appendChild(avatar); row.appendChild(right); function updateStatus(){}
el.messages.appendChild(row); function payloadWithSampling(base){
state.messages.push({ role, content }); return Object.assign({}, base, {
queueMicrotask(() => el.chat.scrollTo({ top: el.chat.scrollHeight, behavior: 'smooth' })); temperature: store.temperature,
return bubble; top_p: store.top_p,
} top_k: store.top_k,
frequency_penalty: store.frequency_penalty,
function addAssistantBubbleStreaming(){ presence_penalty: store.presence_penalty,
const row = document.createElement('div'); row.className = 'flex gap-3'; repetition_penalty: store.repetition_penalty,
const avatar = document.createElement('div'); avatar.className = 'cursor-pointer shrink-0 h-8 w-8 rounded-full flex items-center justify-center bg-gray-200 text-gray-900'; avatar.textContent='🤖'; avatar.setAttribute('data-assistant-avatar',''); min_p: store.min_p,
const right = document.createElement('div'); right.className='flex flex-col'; top_a: store.top_a,
const name = document.createElement('div'); name.className='text-xs font-medium text-gray-500 mb-0.5'; name.textContent=getModelShort(); })
const bubble = document.createElement('div'); bubble.className='rounded-2xl px-4 py-3 text-[15px] leading-relaxed bg-gray-100 text-gray-800 whitespace-pre-wrap'; bubble.textContent=''; }
right.appendChild(name); right.appendChild(bubble); async function askOpenRouterStreaming(onDelta){
row.appendChild(avatar); row.appendChild(right); const apiKey = store.apiKey; const model = store.model
el.messages.appendChild(row); if (!apiKey){ const text = localDemoReply(state.messages[state.messages.length-1]?.content || ''); onDelta(text,true); return { ok:true, text } }
queueMicrotask(() => el.chat.scrollTo({ top: el.chat.scrollHeight })); try{
return bubble; state.controller = new AbortController()
} const msgs = []
if (store.system_prompt) msgs.push({ role:'system', content: store.system_prompt })
function clearChat(){ state.messages=[]; el.messages.innerHTML=''; } msgs.push(...state.messages.filter(m=>m.role!=='system'))
const body = payloadWithSampling({ model, messages: msgs, stream: true })
function updateStatus(){ const res = await fetch('https://openrouter.ai/api/v1/chat/completions',{
const online = !!store.apiKey; method:'POST', headers:{ 'Content-Type':'application/json','Authorization':'Bearer '+apiKey }, body: JSON.stringify(body), signal: state.controller.signal
el.apiBadge.className = 'justify-self-end h-8 min-w-8 px-3 rounded-full border text-xs font-medium inline-flex items-center justify-center gap-1 transition ' + (online ? 'bg-black text-white border-black hover:bg-black/90' : 'bg-gray-100 text-gray-700 border-gray-200 hover:bg-gray-200'); })
el.statusText.textContent = online ? 'online' : 'offline'; if (!res.ok){ const errText = await res.text().catch(()=> ''); throw new Error(errText || ('HTTP '+res.status)) }
} const reader = res.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer=''; let full=''
while(true){ const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value,{stream:true});
// === Networking === 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]') 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){ onDelta('',true) } }catch{} } }
function payloadWithSampling(base){ }
return Object.assign({}, base, { return { ok:true, text: full }
temperature: store.temperature, }catch(e){
top_p: store.top_p, const msg = String(e?.message||e)
top_k: store.top_k, let hint = 'Request failed.'
frequency_penalty: store.frequency_penalty, if (/401|unauthorized/i.test(msg)) hint = 'Unauthorized (check API key).'
presence_penalty: store.presence_penalty, else if (/429|rate/i.test(msg)) hint = 'Rate limited (slow down or upgrade).'
repetition_penalty: store.repetition_penalty, else if (/access|forbidden|403/i.test(msg)) hint = 'Forbidden (model or key scope).'
min_p: store.min_p, const fallback = `\n\n${hint}\nSwitching to local demo.\n\n` + localDemoReply(state.messages[state.messages.length-1]?.content || '')
top_a: store.top_a, onDelta(fallback,true)
}); return { ok:false, text: fallback }
} }finally{ state.controller = null }
}
async function askOpenRouterStreaming(onDelta){ function localDemoReply(prompt){
const apiKey = store.apiKey; const model = store.model; 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.' ]
if (!apiKey){ const text = localDemoReply(state.messages[state.messages.length-1]?.content || ''); onDelta(text,true); return { ok:true, text } } const tip = tips[Math.floor(Math.random()*tips.length)]
try{ const mirrored = prompt.split(/\s+/).slice(0,24).join(' ')
state.controller = new AbortController(); return `Local demo mode. You said: "${mirrored}"\n\n${tip}`
const msgs = []; }
if (store.system_prompt) msgs.push({ role:'system', content: store.system_prompt }); function openSettings(){
msgs.push(...state.messages.filter(m=>m.role!=='system')); const a = getActive(); const s = a.settings
const body = payloadWithSampling({ model, messages: msgs, stream: true }); el.set_model.value = s.model
const res = await fetch('https://openrouter.ai/api/v1/chat/completions',{ el.set_temperature.value = s.temperature
method:'POST', headers:{ 'Content-Type':'application/json','Authorization':'Bearer '+apiKey }, body: JSON.stringify(body), signal: state.controller.signal el.set_top_p.value = s.top_p
}); el.set_top_k.value = s.top_k
if (!res.ok){ const errText = await res.text().catch(()=> ''); throw new Error(errText || ('HTTP '+res.status)); } el.set_frequency_penalty.value = s.frequency_penalty
const reader = res.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer=''; let full=''; el.set_presence_penalty.value = s.presence_penalty
while(true){ const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value,{stream:true}); el.set_repetition_penalty.value = s.repetition_penalty
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]') 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){ onDelta('',true); } }catch{} } } el.set_min_p.value = s.min_p
} el.set_top_a.value = s.top_a
return { ok:true, text: full }; el.set_system_prompt.value = s.system_prompt
}catch(e){ showModelTab()
const msg = String(e?.message||e); el.settingsModal.classList.remove('hidden')
let hint = 'Request failed.'; }
if (/401|unauthorized/i.test(msg)) hint = 'Unauthorized (check API key).'; function closeSettings(){ el.settingsModal.classList.add('hidden') }
else if (/429|rate/i.test(msg)) hint = 'Rate limited (slow down or upgrade).'; function showModelTab(){ el.tabModel.classList.add('border-black'); el.tabPrompt.classList.remove('border-black'); el.panelModel.classList.remove('hidden'); el.panelPrompt.classList.add('hidden') }
else if (/access|forbidden|403/i.test(msg)) hint = 'Forbidden (model or key scope).'; function showPromptTab(){ el.tabPrompt.classList.add('border-black'); el.tabModel.classList.remove('border-black'); el.panelPrompt.classList.remove('hidden'); el.panelModel.classList.add('hidden') }
const fallback = `\n\n${hint}\nSwitching to local demo.\n\n` + localDemoReply(state.messages[state.messages.length-1]?.content || ''); el.closeSettings.addEventListener('click', closeSettings)
onDelta(fallback,true); el.cancelSettings.addEventListener('click', closeSettings)
return { ok:false, text: fallback }; el.settingsModal.addEventListener('click', e => { if (e.target === el.settingsModal || e.target.classList.contains('bg-black/30')) closeSettings() })
}finally{ state.controller = null } el.tabModel.addEventListener('click', showModelTab)
} el.tabPrompt.addEventListener('click', showPromptTab)
el.settingsForm.addEventListener('submit', (e) => {
function localDemoReply(prompt){ e.preventDefault()
const tips = [ 'Tip: click the key badge to set your OpenRouter API key.', 'Click 🤖 to change model & sampling.', 'New chats are stateless here—no history is kept.' ]; const a = getActive(); const s = a.settings
const tip = tips[Math.floor(Math.random()*tips.length)]; s.model = (el.set_model.value || DEFAULT_MODEL).trim()
const mirrored = prompt.split(/\s+/).slice(0,24).join(' '); s.temperature = clamp(num(el.set_temperature.value,1.0),0,2)
return `Local demo mode. You said: "${mirrored}"\n\n${tip}`; 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)
// === Settings modal logic (per assistant) === s.presence_penalty = clamp(num(el.set_presence_penalty.value,0.0),-2,2)
function openSettings(){ s.repetition_penalty = clamp(num(el.set_repetition_penalty.value,1.0),0,2)
const a = getActive(); const s = a.settings; s.min_p = clamp(num(el.set_min_p.value,0.0),0,1)
el.set_model.value = s.model; s.top_a = clamp(num(el.set_top_a.value,0.0),0,1)
el.set_temperature.value = s.temperature; s.system_prompt = el.set_system_prompt.value.trim()
el.set_top_p.value = s.top_p; as.save(assistants)
el.set_top_k.value = s.top_k; closeSettings()
el.set_frequency_penalty.value = s.frequency_penalty; reflectActiveAssistant()
el.set_presence_penalty.value = s.presence_penalty; })
el.set_repetition_penalty.value = s.repetition_penalty; el.deleteAssistantBtn.addEventListener('click', () => {
el.set_min_p.value = s.min_p; const activeId = as.getActiveId()
el.set_top_a.value = s.top_a; const active = getActive()
el.set_system_prompt.value = s.system_prompt; const name = active?.name || 'this assistant'
showModelTab(); if (!confirm(`Delete "${name}"?`)) return
el.settingsModal.classList.remove('hidden'); assistants = assistants.filter(a => a.id !== activeId)
} as.save(assistants)
function closeSettings(){ el.settingsModal.classList.add('hidden') } if (assistants.length === 0){
const def = createDefaultAssistant()
function showModelTab(){ el.tabModel.classList.add('border-black'); el.tabPrompt.classList.remove('border-black'); el.panelModel.classList.remove('hidden'); el.panelPrompt.classList.add('hidden'); } assistants = [def]
function showPromptTab(){ el.tabPrompt.classList.add('border-black'); el.tabModel.classList.remove('border-black'); el.panelPrompt.classList.remove('hidden'); el.panelModel.classList.add('hidden'); } as.save(assistants)
as.setActiveId(def.id)
// === Events === }else{
el.closeSettings.addEventListener('click', closeSettings); as.setActiveId(assistants[0].id)
el.cancelSettings.addEventListener('click', closeSettings); }
el.settingsModal.addEventListener('click', e => { if (e.target === el.settingsModal || e.target.classList.contains('bg-black/30')) closeSettings(); }); renderSidebar()
el.tabModel.addEventListener('click', showModelTab); reflectActiveAssistant()
el.tabPrompt.addEventListener('click', showPromptTab); clearChat()
closeSettings()
el.settingsForm.addEventListener('submit', (e) => { })
e.preventDefault(); el.composer.addEventListener('submit', async (e) => {
const a = getActive(); const s = a.settings; e.preventDefault(); if (state.busy) return; const text = el.input.value.trim(); if (!text) return
s.model = (el.set_model.value || DEFAULT_MODEL).trim(); el.input.value=''; el.input.style.height='auto'; addMessage('user', text)
s.temperature = clamp(num(el.set_temperature.value,1.0),0,2); state.busy = true; el.sendBtn.disabled = true; el.stopBtn.classList.remove('hidden')
s.top_p = clamp(num(el.set_top_p.value,1.0),0,1); const assistantBubble = addAssistantBubbleStreaming()
s.top_k = Math.max(0, int(el.set_top_k.value,0)); await askOpenRouterStreaming((delta,done)=>{
s.frequency_penalty = clamp(num(el.set_frequency_penalty.value,0.0),-2,2); assistantBubble.textContent += delta
s.presence_penalty = clamp(num(el.set_presence_penalty.value,0.0),-2,2); if (done){ el.sendBtn.disabled=false; el.stopBtn.classList.add('hidden'); state.busy=false; state.messages.push({ role:'assistant', content: assistantBubble.textContent }); queueMicrotask(()=> el.chat.scrollTo({ top: el.chat.scrollHeight, behavior:'smooth' })) }
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); el.stopBtn.addEventListener('click', ()=>{ if (state.controller) state.controller.abort(); el.stopBtn.classList.add('hidden'); el.sendBtn.disabled=false; state.busy=false })
s.system_prompt = el.set_system_prompt.value.trim(); el.messages.addEventListener('click', (e) => { const target = e.target.closest('[data-assistant-avatar]'); if (target) openSettings() })
as.save(assistants); el.settingsBtnTop.addEventListener('click', openSettings)
closeSettings(); el.input.addEventListener('input', () => { el.input.style.height='auto'; el.input.style.height = Math.min(el.input.scrollHeight,160) + 'px' })
reflectActiveAssistant(); el.clearBtn.addEventListener('click', () => clearChat())
}); function openApiKeyPrompt(){
const currentMasked = store.apiKey ? '********' : ''
// NEW: Delete assistant (bottom-left of settings) const input = prompt('Enter OpenRouter API key (stored locally):', currentMasked)
el.deleteAssistantBtn.addEventListener('click', () => { if (input === null) return
const activeId = as.getActiveId(); store.apiKey = input === '********' ? store.apiKey : (input.trim())
const active = getActive(); alert(store.apiKey ? 'API key saved locally.' : 'API key cleared.')
const name = active?.name || 'this assistant'; }
if (!confirm(`Delete "${name}"?`)) return; function 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') } }
// Remove active from list el.userMenuBtn.addEventListener('click', (e)=>{ e.stopPropagation(); toggleUserMenu() })
assistants = assistants.filter(a => a.id !== activeId); document.addEventListener('click', (e)=>{ if (!el.userMenu.contains(e.target) && !el.userMenuBtn.contains(e.target)) toggleUserMenu(false) })
// Persist el.apiKeyOption.addEventListener('click', ()=>{ toggleUserMenu(false); openApiKeyPrompt() })
as.save(assistants); el.exportOption.addEventListener('click', ()=>{
// Decide next active const payload = { version:1, activeId: as.getActiveId(), assistants }
if (assistants.length === 0){ const blob = new Blob([JSON.stringify(payload,null,2)], { type:'application/json' })
const def = createDefaultAssistant(); const url = URL.createObjectURL(blob)
assistants = [def]; const a = document.createElement('a')
as.save(assistants); const ts = new Date()
as.setActiveId(def.id); const pad = n=>String(n).padStart(2,'0')
}else{ const fname = `assistants-backup-${ts.getFullYear()}${pad(ts.getMonth()+1)}${pad(ts.getDate())}-${pad(ts.getHours())}${pad(ts.getMinutes())}${pad(ts.getSeconds())}.json`
as.setActiveId(assistants[0].id); a.href = url; a.download = fname; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url)
} toggleUserMenu(false)
// Refresh UI })
renderSidebar(); el.importOption.addEventListener('click', ()=>{ el.importInput.value=''; el.importInput.click() })
reflectActiveAssistant(); el.importInput.addEventListener('change', async ()=>{
clearChat(); const file = el.importInput.files?.[0]; if (!file) return
closeSettings(); try{
}); const text = await file.text()
const data = JSON.parse(text)
// Composer const list = Array.isArray(data) ? data : (Array.isArray(data.assistants) ? data.assistants : null)
el.composer.addEventListener('submit', async (e) => { if (!list) throw new Error('Invalid backup format')
e.preventDefault(); if (state.busy) return; const text = el.input.value.trim(); if (!text) return; assistants = list
el.input.value=''; el.input.style.height='auto'; addMessage('user', text); as.save(assistants)
state.busy = true; el.sendBtn.disabled = true; el.stopBtn.classList.remove('hidden'); const activeId = data.activeId && assistants.some(a=>a.id===data.activeId) ? data.activeId : (assistants[0]?.id || null)
const assistantBubble = addAssistantBubbleStreaming(); as.setActiveId(activeId)
await askOpenRouterStreaming((delta,done)=>{ renderSidebar(); reflectActiveAssistant(); clearChat(); toggleUserMenu(false); closeSidebar();
assistantBubble.textContent += delta; alert('Backup imported.')
if (done){ el.sendBtn.disabled=false; el.stopBtn.classList.add('hidden'); state.busy=false; state.messages.push({ role:'assistant', content: assistantBubble.textContent }); queueMicrotask(()=> el.chat.scrollTo({ top: el.chat.scrollHeight, behavior:'smooth' })); } }catch(err){ alert('Import failed: ' + (err?.message||err)) }
}); })
}); function openSidebar(){ el.sidebar.classList.remove('-translate-x-full'); el.sidebarOverlay.classList.remove('hidden') }
function closeSidebar(){ el.sidebar.classList.add('-translate-x-full'); el.sidebarOverlay.classList.add('hidden') }
// Stop streaming el.sidebarBtn.addEventListener('click', openSidebar)
el.stopBtn.addEventListener('click', ()=>{ if (state.controller) state.controller.abort(); el.stopBtn.classList.add('hidden'); el.sendBtn.disabled=false; state.busy=false; }); el.sidebarOverlay.addEventListener('click', closeSidebar)
el.newAssistantBtn.addEventListener('click', () => {
// Open settings on any assistant avatar click const name = prompt('Name your assistant:')
el.messages.addEventListener('click', (e) => { const target = e.target.closest('[data-assistant-avatar]'); if (target) openSettings(); }); if (!name) return
const id = gid()
// Top bar assistant icon opens settings assistants.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:'' } })
el.settingsBtnTop.addEventListener('click', openSettings); as.save(assistants)
setActive(id)
// Auto-resize textarea renderSidebar()
el.input.addEventListener('input', () => { el.input.style.height='auto'; el.input.style.height = Math.min(el.input.scrollHeight,160) + 'px'; }); closeSidebar()
reflectActiveAssistant()
// Clear keeps chat blank })
el.clearBtn.addEventListener('click', () => clearChat()); el.assistantList.addEventListener('click', (e) => {
const btn = e.target.closest('[data-asst-id]')
// API badge click if (!btn) return
el.apiBadge.addEventListener('click', () => { const id = btn.getAttribute('data-asst-id')
const currentMasked = store.apiKey ? '********' : ''; if (id){ setActive(id); renderSidebar(); closeSidebar(); reflectActiveAssistant() }
const input = prompt('Enter OpenRouter API key (stored locally):', currentMasked); })
if (input === null) return; function initFromQuery(){ const url = new URL(location.href); const key = url.searchParams.get('key'); const model = url.searchParams.get('model'); if (key) globalStore.apiKey = key; if (model){ const a=getActive(); a.settings.model = model; as.save(assistants)} }
store.apiKey = input === '********' ? store.apiKey : (input.trim()); function init(){ initFromQuery(); updateStatus(); renderSidebar(); reflectActiveAssistant(); clearChat() }
alert(store.apiKey ? 'API key saved locally.' : 'API key cleared.'); init()
}); </script>
</body>
// === Sidebar interactions === </html>
function openSidebar(){ el.sidebar.classList.remove('-translate-x-full'); el.sidebarOverlay.classList.remove('hidden'); }
function closeSidebar(){ el.sidebar.classList.add('-translate-x-full'); el.sidebarOverlay.classList.add('hidden'); }
el.sidebarBtn.addEventListener('click', openSidebar);
el.sidebarOverlay.addEventListener('click', closeSidebar);
el.newAssistantBtn.addEventListener('click', () => {
const name = prompt('Name your assistant:');
if (!name) return;
const id = gid();
assistants.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:'' } });
as.save(assistants);
setActive(id);
renderSidebar();
closeSidebar();
reflectActiveAssistant();
});
el.assistantList.addEventListener('click', (e) => {
const btn = e.target.closest('[data-asst-id]');
if (!btn) return;
const id = btn.getAttribute('data-asst-id');
if (id){ setActive(id); renderSidebar(); closeSidebar(); reflectActiveAssistant(); }
});
// === Init ===
function initFromQuery(){ const url = new URL(location.href); const key = url.searchParams.get('key'); const model = url.searchParams.get('model'); if (key) store.apiKey = key; if (model){ const a=getActive(); a.settings.model = model; as.save(assistants);} }
function init(){ initFromQuery(); updateStatus(); renderSidebar(); reflectActiveAssistant(); clearChat(); }
init();
</script></body></html>

View File

@@ -1,447 +1,417 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
<title>ChatGPT — Mobile Clone (Light)</title> <title>ChatGPT — Mobile Clone (Light)</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<style> <style>
:root{--safe-bottom:env(safe-area-inset-bottom)} :root{--safe-bottom:env(safe-area-inset-bottom)}
::-webkit-scrollbar{height:8px;width:8px} ::-webkit-scrollbar{height:8px;width:8px}
::-webkit-scrollbar-thumb{background:#e5e7eb;border-radius:999px} ::-webkit-scrollbar-thumb{background:#e5e7eb;border-radius:999px}
.no-scrollbar::-webkit-scrollbar{display:none} .no-scrollbar::-webkit-scrollbar{display:none}
.no-scrollbar{-ms-overflow-style:none;scrollbar-width:none} .no-scrollbar{-ms-overflow-style:none;scrollbar-width:none}
</style> </style>
</head> </head>
<body class="bg-white text-gray-900 selection:bg-black/10"> <body class="bg-white text-gray-900 selection:bg-black/10">
<div class="flex flex-col h-dvh max-h-dvh"> <div class="flex flex-col h-dvh max-h-dvh">
<!-- Header --> <header class="sticky top-0 z-20 bg-white/80 backdrop-blur border-b border-gray-200">
<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-2xl px-4 py-3 grid grid-cols-3 items-center">
<div class="mx-auto w-full max-w-2xl 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="Assistants">
<!-- Sidebar toggle (left) --> <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="M4 6h16M4 12h16M4 18h16"/></svg>
<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="Assistants"> </button>
<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="M4 6h16M4 12h16M4 18h16"/></svg> <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="Assistant settings">🤖</button>
</button> <div></div>
<!-- Active assistant (center) --> </div>
<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="Assistant settings">🤖</button> </header>
<!-- API key badge (right) --> <main id="chat" class="flex-1 overflow-y-auto no-scrollbar">
<button id="apiBadge" title="Set OpenRouter API key" class="justify-self-end h-8 min-w-8 px-3 rounded-full border text-xs font-medium inline-flex items-center justify-center gap-1 transition bg-gray-100 text-gray-700 border-gray-200 hover:bg-gray-200"> <div id="messages" class="mx-auto w-full max-w-2xl px-4 py-4 sm:py-6 space-y-4"></div>
<svg viewBox="0 0 24 24" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M15 7a4 4 0 11-7.999.001A4 4 0 0115 7zm-.293 6.707L9 19.414V22h2.586l5.707-5.707a1 1 0 000-1.414l-1.586-1.586a1 1 0 00-1.414 0z"/></svg> <div class="h-24"></div>
<span id="statusText" class="sr-only">offline</span> </main>
</button> <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">
</div> <div class="mx-auto w-full max-w-2xl px-4">
</header><!-- Messages --> <form id="composer" class="group relative flex items-end gap-2">
<main id="chat" class="flex-1 overflow-y-auto no-scrollbar"> <textarea id="input" rows="1" placeholder="Send a message" spellcheck="true" class="flex-1 resize-none rounded-2xl border border-gray-300 bg-white px-4 py-3 text-[15px] leading-6 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-black/20 focus:border-gray-300 max-h-40"></textarea>
<div id="messages" class="mx-auto w-full max-w-2xl px-4 py-4 sm:py-6 space-y-4"></div> <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 disabled:opacity-40 disabled:cursor-not-allowed">
<div class="h-24"></div> <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>
</main> <!-- Input Dock --> <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"> </button>
<div class="mx-auto w-full max-w-2xl px-4"> </form>
<form id="composer" class="group relative flex items-end gap-2"> <div class="mt-2 flex items-center justify-end text-xs text-gray-500">
<textarea id="input" rows="1" placeholder="Send a message" spellcheck="true" class="flex-1 resize-none rounded-2xl border border-gray-300 bg-white px-4 py-3 text-[15px] leading-6 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-black/20 focus:border-gray-300 max-h-40"></textarea> <div class="flex items-center gap-3">
<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 disabled:opacity-40 disabled:cursor-not-allowed"> <button id="stopBtn" class="underline decoration-dotted hover:text-gray-700 hidden">Stop</button>
<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 id="clearBtn" class="underline decoration-dotted hover:text-gray-700">Clear</button>
</button> </div>
</form> </div>
<div class="mt-2 flex items-center justify-end text-xs text-gray-500"> </div>
<div class="flex items-center gap-3"> </footer>
<button id="stopBtn" class="underline decoration-dotted hover:text-gray-700 hidden">Stop</button> </div>
<button id="clearBtn" class="underline decoration-dotted hover:text-gray-700">Clear</button> <div id="sidebarOverlay" class="fixed inset-0 z-40 bg-black/20 hidden"></div>
</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> <div class="p-3 border-b flex items-center gap-2">
</div> <button id="newAssistantBtn" class="px-3 py-2 rounded-xl bg-black text-white text-sm hover:bg-black/90">New assistant</button>
</footer> </div> <!-- Sidebar --> <div id="sidebarOverlay" class="fixed inset-0 z-40 bg-black/20 hidden"></div> <span class="text-xs text-gray-500">Click name to equip</span>
<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>
<div class="p-3 border-b flex items-center gap-2"> <div id="assistantList" class="flex-1 overflow-y-auto divide-y"></div>
<button id="newAssistantBtn" class="px-3 py-2 rounded-xl bg-black text-white text-sm hover:bg-black/90">New assistant</button> <div class="p-3 border-t">
<span class="text-xs text-gray-500">Click name to equip</span> <div class="relative">
</div> <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">
<div id="assistantList" class="flex-1 overflow-y-auto divide-y"></div> <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>
</aside> <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="M6 9l6 6 6-6"/></svg>
<!-- Settings Dialog --> </button>
<div id="settingsModal" class="hidden fixed inset-0 z-50"> <div id="userMenu" class="absolute left-0 right-0 bottom-12 translate-y-2 rounded-xl border border-gray-200 bg-white shadow-lg hidden">
<div class="absolute inset-0 bg-black/30"></div> <button id="apiKeyOption" class="w-full text-left px-3 py-2 text-sm hover:bg-gray-50">Enter OpenRouter API key</button>
<div class="absolute inset-x-0 top-12 mx-auto w-full max-w-md px-4"> <button id="importOption" class="w-full text-left px-3 py-2 text-sm hover:bg-gray-50">Import backup (.json)</button>
<div class="rounded-2xl bg-white shadow-xl border border-gray-200 overflow-hidden"> <button id="exportOption" class="w-full text-left px-3 py-2 text-sm hover:bg-gray-50">Export backup (.json)</button>
<div class="px-4 py-3 border-b text-sm font-semibold flex items-center justify-between"> </div>
<span>Assistant Settings</span> <input id="importInput" type="file" accept="application/json,.json" class="hidden"/>
<button id="closeSettings" class="p-1 rounded hover:bg-gray-100" aria-label="Close"> </div>
<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> </div>
</button> </aside>
</div> <div id="settingsModal" class="hidden fixed inset-0 z-50">
<form id="settingsForm" class="text-sm"> <div class="absolute inset-0 bg-black/30"></div>
<div class="border-b flex text-sm font-medium"> <div class="absolute inset-x-0 top-12 mx-auto w-full max-w-md px-4">
<button type="button" id="tabModel" class="flex-1 py-2 px-3 text-center border-b-2 border-black">Model & Sampling</button> <div class="rounded-2xl bg-white shadow-xl border border-gray-200 overflow-hidden">
<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 class="px-4 py-3 border-b text-sm font-semibold flex items-center justify-between">
</div> <span>Assistant Settings</span>
<div id="panelModel" class="p-4 space-y-4"> <button id="closeSettings" class="p-1 rounded hover:bg-gray-100" aria-label="Close">
<div> <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>
<label class="block text-gray-700 font-medium mb-1">Model name</label> </button>
<input id="set_model" type="text" class="w-full rounded-xl border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-black/20" placeholder="openai/gpt-4o"/> </div>
</div> <form id="settingsForm" class="text-sm">
<div class="grid grid-cols-2 gap-3"> <div class="border-b flex text-sm font-medium">
<div><label class="block text-gray-700 font-medium mb-1">Temperature <span class="text-gray-400">(02)</span></label><input id="set_temperature" type="number" min="0" max="2" step="0.1" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="1.0"/><p class="mt-1 text-xs text-gray-500">Variety. Lower = predictable.</p></div> <button type="button" id="tabModel" class="flex-1 py-2 px-3 text-center border-b-2 border-black">Model & Sampling</button>
<div><label class="block text-gray-700 font-medium mb-1">Top P <span class="text-gray-400">(01)</span></label><input id="set_top_p" type="number" min="0" max="1" step="0.01" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="1.0"/><p class="mt-1 text-xs text-gray-500">Nucleus sampling.</p></div> <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><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>
<div><label class="block text-gray-700 font-medium mb-1">Frequency Penalty <span class="text-gray-400">(-22)</span></label><input id="set_frequency_penalty" type="number" min="-2" max="2" step="0.1" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="0.0"/><p class="mt-1 text-xs text-gray-500">Discourage repeats by count.</p></div> <div id="panelModel" class="p-4 space-y-4">
<div><label class="block text-gray-700 font-medium mb-1">Presence Penalty <span class="text-gray-400">(-22)</span></label><input id="set_presence_penalty" type="number" min="-2" max="2" step="0.1" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="0.0"/><p class="mt-1 text-xs text-gray-500">Discourage seen tokens.</p></div> <div>
<div><label class="block text-gray-700 font-medium mb-1">Repetition Penalty <span class="text-gray-400">(02)</span></label><input id="set_repetition_penalty" type="number" min="0" max="2" step="0.1" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="1.0"/><p class="mt-1 text-xs text-gray-500">Reduce verbatim echoes.</p></div> <label class="block text-gray-700 font-medium mb-1">Model name</label>
<div><label class="block text-gray-700 font-medium mb-1">Min P <span class="text-gray-400">(01)</span></label><input id="set_min_p" type="number" min="0" max="1" step="0.01" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="0.0"/><p class="mt-1 text-xs text-gray-500">Minimum token prob vs best.</p></div> <input id="set_model" type="text" class="w-full rounded-xl border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-black/20" placeholder="openai/gpt-4o"/>
<div><label class="block text-gray-700 font-medium mb-1">Top A <span class="text-gray-400">(01)</span></label><input id="set_top_a" type="number" min="0" max="1" step="0.01" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="0.0"/><p class="mt-1 text-xs text-gray-500">Adaptive nucleus filter.</p></div> </div>
</div> <div class="grid grid-cols-2 gap-3">
</div> <div><label class="block text-gray-700 font-medium mb-1">Temperature <span class="text-gray-400">(02)</span></label><input id="set_temperature" type="number" min="0" max="2" step="0.1" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="1.0"/><p class="mt-1 text-xs text-gray-500">Variety. Lower = predictable.</p></div>
<div id="panelPrompt" class="p-4 space-y-4 hidden"> <div><label class="block text-gray-700 font-medium mb-1">Top P <span class="text-gray-400">(01)</span></label><input id="set_top_p" type="number" min="0" max="1" step="0.01" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="1.0"/><p class="mt-1 text-xs text-gray-500">Nucleus sampling.</p></div>
<div> <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>
<label class="block text-gray-700 font-medium mb-1">System Prompt</label> <div><label class="block text-gray-700 font-medium mb-1">Frequency Penalty <span class="text-gray-400">(-22)</span></label><input id="set_frequency_penalty" type="number" min="-2" max="2" step="0.1" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="0.0"/><p class="mt-1 text-xs text-gray-500">Discourage repeats by count.</p></div>
<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 assistant"></textarea> <div><label class="block text-gray-700 font-medium mb-1">Presence Penalty <span class="text-gray-400">(-22)</span></label><input id="set_presence_penalty" type="number" min="-2" max="2" step="0.1" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="0.0"/><p class="mt-1 text-xs text-gray-500">Discourage seen tokens.</p></div>
<p class="mt-1 text-xs text-gray-500">Saved per assistant.</p> <div><label class="block text-gray-700 font-medium mb-1">Repetition Penalty <span class="text-gray-400">(02)</span></label><input id="set_repetition_penalty" type="number" min="0" max="2" step="0.1" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="1.0"/><p class="mt-1 text-xs text-gray-500">Reduce verbatim echoes.</p></div>
</div> <div><label class="block text-gray-700 font-medium mb-1">Min P <span class="text-gray-400">(01)</span></label><input id="set_min_p" type="number" min="0" max="1" step="0.01" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="0.0"/><p class="mt-1 text-xs text-gray-500">Minimum token prob vs best.</p></div>
</div> <div><label class="block text-gray-700 font-medium mb-1">Top A <span class="text-gray-400">(01)</span></label><input id="set_top_a" type="number" min="0" max="1" step="0.01" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="0.0"/><p class="mt-1 text-xs text-gray-500">Adaptive nucleus filter.</p></div>
<!-- Footer actions: Delete (left), Cancel/Save (right) --> </div>
<div class="flex items-center justify-between gap-2 px-4 py-3 border-t"> </div>
<button type="button" id="deleteAssistantBtn" class="inline-flex items-center gap-2 px-3 py-2 rounded-xl border border-red-200 text-red-700 hover:bg-red-50"> <div id="panelPrompt" class="p-4 space-y-4 hidden">
<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> <div>
<span>Delete assistant</span> <label class="block text-gray-700 font-medium mb-1">System Prompt</label>
</button> <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 assistant"></textarea>
<div class="flex items-center justify-end gap-2"> <p class="mt-1 text-xs text-gray-500">Saved per assistant.</p>
<button type="button" id="cancelSettings" class="px-3 py-2 rounded-xl border bg-white hover:bg-gray-50">Cancel</button> </div>
<button type="submit" class="px-3 py-2 rounded-xl bg-black text-white hover:bg-black/90">Save</button> </div>
</div> <div class="flex items-center justify-between gap-2 px-4 py-3 border-t">
</div> <button type="button" id="deleteAssistantBtn" class="inline-flex items-center gap-2 px-3 py-2 rounded-xl border border-red-200 text-red-700 hover:bg-red-50">
</form> <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>
</div> <span>Delete assistant</span>
</div> </button>
</div> <script> <div class="flex items-center justify-end gap-2">
// === Constants === <button type="button" id="cancelSettings" class="px-3 py-2 rounded-xl border bg-white hover:bg-gray-50">Cancel</button>
const DEFAULT_MODEL = 'openai/gpt-4o'; <button type="submit" class="px-3 py-2 rounded-xl bg-black text-white hover:bg-black/90">Save</button>
const DEFAULT_API_KEY = ''; </div>
</div>
// === Elements === </form>
const el = Object.fromEntries([ </div>
'chat','messages','composer','input','sendBtn','stopBtn','clearBtn','apiBadge','statusText','settingsBtnTop', </div>
'settingsModal','settingsForm','closeSettings','cancelSettings','tabModel','tabPrompt','panelModel','panelPrompt', </div>
'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', <script>
'sidebar','sidebarOverlay','sidebarBtn','assistantList','newAssistantBtn','deleteAssistantBtn' const DEFAULT_MODEL = 'openai/gpt-4o'
].map(id => [id, document.getElementById(id)])); const DEFAULT_API_KEY = ''
const el = Object.fromEntries([
// === Utilities === 'chat','messages','composer','input','sendBtn','stopBtn','clearBtn','settingsBtnTop',
const clamp = (v, min, max) => Math.max(min, Math.min(max, v)); 'settingsModal','settingsForm','closeSettings','cancelSettings','tabModel','tabPrompt','panelModel','panelPrompt',
const num = (v, d) => (v == null || v === '' || isNaN(+v)) ? d : +v; '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',
const int = (v, d) => (v == null || v === '' || isNaN(parseInt(v))) ? d : parseInt(v); 'sidebar','sidebarOverlay','sidebarBtn','assistantList','newAssistantBtn','deleteAssistantBtn','userMenuBtn','userMenu','importInput','importOption','exportOption','apiKeyOption'
const gid = () => Math.random().toString(36).slice(2,9); ].map(id => [id, document.getElementById(id)]))
const clamp = (v, min, max) => Math.max(min, Math.min(max, v))
// === Global store (API key only) === const num = (v, d) => (v == null || v === '' || isNaN(+v)) ? d : +v
const globalStore = { const int = (v, d) => (v == null || v === '' || isNaN(parseInt(v))) ? d : parseInt(v)
get apiKey(){ return localStorage.getItem('openrouter_api_key') || DEFAULT_API_KEY || '' }, const gid = () => Math.random().toString(36).slice(2,9)
set apiKey(v){ localStorage.setItem('openrouter_api_key', v || ''); updateStatus(); }, const globalStore = {
}; get apiKey(){ return localStorage.getItem('openrouter_api_key') || DEFAULT_API_KEY || '' },
set apiKey(v){ localStorage.setItem('openrouter_api_key', v || ''); updateStatus() },
// === Assistants store (per-assistant settings) === }
const as = { const as = {
key: 'assistants_v1', key: 'assistants_v1',
activeKey: 'active_assistant_id', activeKey: 'active_assistant_id',
load(){ try{ return JSON.parse(localStorage.getItem(this.key)||'[]') }catch{ return [] } }, load(){ try{ return JSON.parse(localStorage.getItem(this.key)||'[]') }catch{ return [] } },
save(list){ localStorage.setItem(this.key, JSON.stringify(list||[])) }, save(list){ localStorage.setItem(this.key, JSON.stringify(list||[])) },
getActiveId(){ return localStorage.getItem(this.activeKey) || null }, getActiveId(){ return localStorage.getItem(this.activeKey) || null },
setActiveId(id){ localStorage.setItem(this.activeKey, id||'') }, setActiveId(id){ localStorage.setItem(this.activeKey, id||'') },
}; }
let assistants = as.load()
let assistants = as.load(); if (!assistants.length){
if (!assistants.length){ const id = gid()
const id = gid(); assistants = [{ id, 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:'' } }]
assistants = [{ id, 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:'' } }]; as.save(assistants)
as.save(assistants); as.setActiveId(id)
as.setActiveId(id); }
} const getActive = () => assistants.find(a => a.id === as.getActiveId()) || assistants[0]
const setActive = (id) => { as.setActiveId(id); renderSidebar(); reflectActiveAssistant(); clearChat() }
// Derived helpers for current assistant const createDefaultAssistant = () => ({ 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 getActive = () => assistants.find(a => a.id === as.getActiveId()) || assistants[0]; const store = new Proxy({}, {
const setActive = (id) => { as.setActiveId(id); renderSidebar(); reflectActiveAssistant(); clearChat(); }; get(_, prop){
if (prop === 'apiKey') return globalStore.apiKey
const createDefaultAssistant = () => ({ 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 a = getActive()
if (prop === 'model') return a.settings.model
// === Legacy-compatible settings proxy bound to active assistant === if (prop in a.settings) return a.settings[prop]
const store = new Proxy({}, { if (prop === 'system_prompt') return a.settings.system_prompt
get(_, prop){ return undefined
if (prop === 'apiKey') return globalStore.apiKey; },
const a = getActive(); set(_, prop, val){
if (prop === 'model') return a.settings.model; if (prop === 'apiKey'){ globalStore.apiKey = val; return true }
if (prop in a.settings) return a.settings[prop]; const idx = assistants.findIndex(a => a.id === getActive().id)
if (prop === 'system_prompt') return a.settings.system_prompt; if (idx >= 0){
return undefined; if (prop === 'model') assistants[idx].settings.model = val || DEFAULT_MODEL
}, else if (prop === 'system_prompt') assistants[idx].settings.system_prompt = val || ''
set(_, prop, val){ else assistants[idx].settings[prop] = val
if (prop === 'apiKey'){ globalStore.apiKey = val; return true; } as.save(assistants)
const idx = assistants.findIndex(a => a.id === getActive().id); return true
if (idx >= 0){ }
if (prop === 'model') assistants[idx].settings.model = val || DEFAULT_MODEL; return false
else if (prop === 'system_prompt') assistants[idx].settings.system_prompt = val || ''; }
else assistants[idx].settings[prop] = val; })
as.save(assistants); const state = { messages: [], busy: false, controller: null }
return true; const getModelShort = () => { const m = store.model || ''; return m.includes('/') ? m.split('/').pop() : m }
} function reflectActiveAssistant(){ const a = getActive(); el.settingsBtnTop.title = `Settings — ${a.name}` }
return false; function renderSidebar(){
} const activeId = as.getActiveId()
}); el.assistantList.innerHTML = assistants.map(a => `
<button data-asst-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':''}">
// === Runtime state === <span class="h-6 w-6 rounded-full bg-gray-200 flex items-center justify-center">🤖</span>
const state = { messages: [], busy: false, controller: null }; <span class="truncate">${a.name}</span>
</button>
// === UI helpers === `).join('')
const getModelShort = () => { }
const m = store.model || ''; function addMessage(role, content){
return m.includes('/') ? m.split('/').pop() : m; const row = document.createElement('div')
}; row.className = 'flex gap-3'
const avatar = document.createElement('div')
function reflectActiveAssistant(){ avatar.className = 'cursor-pointer shrink-0 h-8 w-8 rounded-full flex items-center justify-center ' + (role === 'user' ? 'bg-gray-900 text-white' : 'bg-gray-200 text-gray-900')
const a = getActive(); avatar.textContent = role === 'user' ? '🧑' : '🤖'
el.settingsBtnTop.title = `Settings — ${a.name}`; if (role !== 'user') avatar.setAttribute('data-assistant-avatar','')
} const right = document.createElement('div')
right.className = 'flex flex-col'
function renderSidebar(){ if (role !== 'user'){
const activeId = as.getActiveId(); const name = document.createElement('div')
el.assistantList.innerHTML = assistants.map(a => ` name.className = 'text-xs font-medium text-gray-500 mb-0.5'
<button data-asst-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':''}"> name.textContent = getModelShort()
<span class="h-6 w-6 rounded-full bg-gray-200 flex items-center justify-center">🤖</span> right.appendChild(name)
<span class="truncate">${a.name}</span> }
</button> const bubble = document.createElement('div')
`).join(''); bubble.className = 'rounded-2xl px-4 py-3 text-[15px] leading-relaxed whitespace-pre-wrap ' + (role === 'user' ? 'bg-gray-50 border border-gray-200' : 'bg-gray-100')
} bubble.textContent = content
right.appendChild(bubble)
function addMessage(role, content){ row.appendChild(avatar); row.appendChild(right)
const row = document.createElement('div'); el.messages.appendChild(row)
row.className = 'flex gap-3'; state.messages.push({ role, content })
const avatar = document.createElement('div'); queueMicrotask(() => el.chat.scrollTo({ top: el.chat.scrollHeight, behavior: 'smooth' }))
avatar.className = 'cursor-pointer shrink-0 h-8 w-8 rounded-full flex items-center justify-center ' + (role === 'user' ? 'bg-gray-900 text-white' : 'bg-gray-200 text-gray-900'); return bubble
avatar.textContent = role === 'user' ? '🧑' : '🤖'; }
if (role !== 'user') avatar.setAttribute('data-assistant-avatar',''); function addAssistantBubbleStreaming(){
const right = document.createElement('div'); const row = document.createElement('div'); row.className = 'flex gap-3'
right.className = 'flex flex-col'; const avatar = document.createElement('div'); avatar.className = 'cursor-pointer shrink-0 h-8 w-8 rounded-full flex items-center justify-center bg-gray-200 text-gray-900'; avatar.textContent='🤖'; avatar.setAttribute('data-assistant-avatar','')
if (role !== 'user'){ const right = document.createElement('div'); right.className='flex flex-col'
const name = document.createElement('div'); const name = document.createElement('div'); name.className='text-xs font-medium text-gray-500 mb-0.5'; name.textContent=getModelShort()
name.className = 'text-xs font-medium text-gray-500 mb-0.5'; const bubble = document.createElement('div'); bubble.className='rounded-2xl px-4 py-3 text-[15px] leading-relaxed bg-gray-100 text-gray-800 whitespace-pre-wrap'; bubble.textContent=''
name.textContent = getModelShort(); right.appendChild(name); right.appendChild(bubble)
right.appendChild(name); row.appendChild(avatar); row.appendChild(right)
} el.messages.appendChild(row)
const bubble = document.createElement('div'); queueMicrotask(() => el.chat.scrollTo({ top: el.chat.scrollHeight }))
bubble.className = 'rounded-2xl px-4 py-3 text-[15px] leading-relaxed whitespace-pre-wrap ' + (role === 'user' ? 'bg-gray-50 border border-gray-200' : 'bg-gray-100'); return bubble
bubble.textContent = content; }
right.appendChild(bubble); function clearChat(){ state.messages=[]; el.messages.innerHTML='' }
row.appendChild(avatar); row.appendChild(right); function updateStatus(){}
el.messages.appendChild(row); function payloadWithSampling(base){
state.messages.push({ role, content }); return Object.assign({}, base, {
queueMicrotask(() => el.chat.scrollTo({ top: el.chat.scrollHeight, behavior: 'smooth' })); temperature: store.temperature,
return bubble; top_p: store.top_p,
} top_k: store.top_k,
frequency_penalty: store.frequency_penalty,
function addAssistantBubbleStreaming(){ presence_penalty: store.presence_penalty,
const row = document.createElement('div'); row.className = 'flex gap-3'; repetition_penalty: store.repetition_penalty,
const avatar = document.createElement('div'); avatar.className = 'cursor-pointer shrink-0 h-8 w-8 rounded-full flex items-center justify-center bg-gray-200 text-gray-900'; avatar.textContent='🤖'; avatar.setAttribute('data-assistant-avatar',''); min_p: store.min_p,
const right = document.createElement('div'); right.className='flex flex-col'; top_a: store.top_a,
const name = document.createElement('div'); name.className='text-xs font-medium text-gray-500 mb-0.5'; name.textContent=getModelShort(); })
const bubble = document.createElement('div'); bubble.className='rounded-2xl px-4 py-3 text-[15px] leading-relaxed bg-gray-100 text-gray-800 whitespace-pre-wrap'; bubble.textContent=''; }
right.appendChild(name); right.appendChild(bubble); async function askOpenRouterStreaming(onDelta){
row.appendChild(avatar); row.appendChild(right); const apiKey = store.apiKey; const model = store.model
el.messages.appendChild(row); if (!apiKey){ const text = localDemoReply(state.messages[state.messages.length-1]?.content || ''); onDelta(text,true); return { ok:true, text } }
queueMicrotask(() => el.chat.scrollTo({ top: el.chat.scrollHeight })); try{
return bubble; state.controller = new AbortController()
} const msgs = []
if (store.system_prompt) msgs.push({ role:'system', content: store.system_prompt })
function clearChat(){ state.messages=[]; el.messages.innerHTML=''; } msgs.push(...state.messages.filter(m=>m.role!=='system'))
const body = payloadWithSampling({ model, messages: msgs, stream: true })
function updateStatus(){ const res = await fetch('https://openrouter.ai/api/v1/chat/completions',{
const online = !!store.apiKey; method:'POST', headers:{ 'Content-Type':'application/json','Authorization':'Bearer '+apiKey }, body: JSON.stringify(body), signal: state.controller.signal
el.apiBadge.className = 'justify-self-end h-8 min-w-8 px-3 rounded-full border text-xs font-medium inline-flex items-center justify-center gap-1 transition ' + (online ? 'bg-black text-white border-black hover:bg-black/90' : 'bg-gray-100 text-gray-700 border-gray-200 hover:bg-gray-200'); })
el.statusText.textContent = online ? 'online' : 'offline'; if (!res.ok){ const errText = await res.text().catch(()=> ''); throw new Error(errText || ('HTTP '+res.status)) }
} const reader = res.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer=''; let full=''
while(true){ const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value,{stream:true});
// === Networking === 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]') 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){ onDelta('',true) } }catch{} } }
function payloadWithSampling(base){ }
return Object.assign({}, base, { return { ok:true, text: full }
temperature: store.temperature, }catch(e){
top_p: store.top_p, const msg = String(e?.message||e)
top_k: store.top_k, let hint = 'Request failed.'
frequency_penalty: store.frequency_penalty, if (/401|unauthorized/i.test(msg)) hint = 'Unauthorized (check API key).'
presence_penalty: store.presence_penalty, else if (/429|rate/i.test(msg)) hint = 'Rate limited (slow down or upgrade).'
repetition_penalty: store.repetition_penalty, else if (/access|forbidden|403/i.test(msg)) hint = 'Forbidden (model or key scope).'
min_p: store.min_p, const fallback = `\n\n${hint}\nSwitching to local demo.\n\n` + localDemoReply(state.messages[state.messages.length-1]?.content || '')
top_a: store.top_a, onDelta(fallback,true)
}); return { ok:false, text: fallback }
} }finally{ state.controller = null }
}
async function askOpenRouterStreaming(onDelta){ function localDemoReply(prompt){
const apiKey = store.apiKey; const model = store.model; 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.' ]
if (!apiKey){ const text = localDemoReply(state.messages[state.messages.length-1]?.content || ''); onDelta(text,true); return { ok:true, text } } const tip = tips[Math.floor(Math.random()*tips.length)]
try{ const mirrored = prompt.split(/\s+/).slice(0,24).join(' ')
state.controller = new AbortController(); return `Local demo mode. You said: "${mirrored}"\n\n${tip}`
const msgs = []; }
if (store.system_prompt) msgs.push({ role:'system', content: store.system_prompt }); function openSettings(){
msgs.push(...state.messages.filter(m=>m.role!=='system')); const a = getActive(); const s = a.settings
const body = payloadWithSampling({ model, messages: msgs, stream: true }); el.set_model.value = s.model
const res = await fetch('https://openrouter.ai/api/v1/chat/completions',{ el.set_temperature.value = s.temperature
method:'POST', headers:{ 'Content-Type':'application/json','Authorization':'Bearer '+apiKey }, body: JSON.stringify(body), signal: state.controller.signal el.set_top_p.value = s.top_p
}); el.set_top_k.value = s.top_k
if (!res.ok){ const errText = await res.text().catch(()=> ''); throw new Error(errText || ('HTTP '+res.status)); } el.set_frequency_penalty.value = s.frequency_penalty
const reader = res.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer=''; let full=''; el.set_presence_penalty.value = s.presence_penalty
while(true){ const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value,{stream:true}); el.set_repetition_penalty.value = s.repetition_penalty
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]') 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){ onDelta('',true); } }catch{} } } el.set_min_p.value = s.min_p
} el.set_top_a.value = s.top_a
return { ok:true, text: full }; el.set_system_prompt.value = s.system_prompt
}catch(e){ showModelTab()
const msg = String(e?.message||e); el.settingsModal.classList.remove('hidden')
let hint = 'Request failed.'; }
if (/401|unauthorized/i.test(msg)) hint = 'Unauthorized (check API key).'; function closeSettings(){ el.settingsModal.classList.add('hidden') }
else if (/429|rate/i.test(msg)) hint = 'Rate limited (slow down or upgrade).'; function showModelTab(){ el.tabModel.classList.add('border-black'); el.tabPrompt.classList.remove('border-black'); el.panelModel.classList.remove('hidden'); el.panelPrompt.classList.add('hidden') }
else if (/access|forbidden|403/i.test(msg)) hint = 'Forbidden (model or key scope).'; function showPromptTab(){ el.tabPrompt.classList.add('border-black'); el.tabModel.classList.remove('border-black'); el.panelPrompt.classList.remove('hidden'); el.panelModel.classList.add('hidden') }
const fallback = `\n\n${hint}\nSwitching to local demo.\n\n` + localDemoReply(state.messages[state.messages.length-1]?.content || ''); el.closeSettings.addEventListener('click', closeSettings)
onDelta(fallback,true); el.cancelSettings.addEventListener('click', closeSettings)
return { ok:false, text: fallback }; el.settingsModal.addEventListener('click', e => { if (e.target === el.settingsModal || e.target.classList.contains('bg-black/30')) closeSettings() })
}finally{ state.controller = null } el.tabModel.addEventListener('click', showModelTab)
} el.tabPrompt.addEventListener('click', showPromptTab)
el.settingsForm.addEventListener('submit', (e) => {
function localDemoReply(prompt){ e.preventDefault()
const tips = [ 'Tip: click the key badge to set your OpenRouter API key.', 'Click 🤖 to change model & sampling.', 'New chats are stateless here—no history is kept.' ]; const a = getActive(); const s = a.settings
const tip = tips[Math.floor(Math.random()*tips.length)]; s.model = (el.set_model.value || DEFAULT_MODEL).trim()
const mirrored = prompt.split(/\s+/).slice(0,24).join(' '); s.temperature = clamp(num(el.set_temperature.value,1.0),0,2)
return `Local demo mode. You said: "${mirrored}"\n\n${tip}`; 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)
// === Settings modal logic (per assistant) === s.presence_penalty = clamp(num(el.set_presence_penalty.value,0.0),-2,2)
function openSettings(){ s.repetition_penalty = clamp(num(el.set_repetition_penalty.value,1.0),0,2)
const a = getActive(); const s = a.settings; s.min_p = clamp(num(el.set_min_p.value,0.0),0,1)
el.set_model.value = s.model; s.top_a = clamp(num(el.set_top_a.value,0.0),0,1)
el.set_temperature.value = s.temperature; s.system_prompt = el.set_system_prompt.value.trim()
el.set_top_p.value = s.top_p; as.save(assistants)
el.set_top_k.value = s.top_k; closeSettings()
el.set_frequency_penalty.value = s.frequency_penalty; reflectActiveAssistant()
el.set_presence_penalty.value = s.presence_penalty; })
el.set_repetition_penalty.value = s.repetition_penalty; el.deleteAssistantBtn.addEventListener('click', () => {
el.set_min_p.value = s.min_p; const activeId = as.getActiveId()
el.set_top_a.value = s.top_a; const active = getActive()
el.set_system_prompt.value = s.system_prompt; const name = active?.name || 'this assistant'
showModelTab(); if (!confirm(`Delete "${name}"?`)) return
el.settingsModal.classList.remove('hidden'); assistants = assistants.filter(a => a.id !== activeId)
} as.save(assistants)
function closeSettings(){ el.settingsModal.classList.add('hidden') } if (assistants.length === 0){
const def = createDefaultAssistant()
function showModelTab(){ el.tabModel.classList.add('border-black'); el.tabPrompt.classList.remove('border-black'); el.panelModel.classList.remove('hidden'); el.panelPrompt.classList.add('hidden'); } assistants = [def]
function showPromptTab(){ el.tabPrompt.classList.add('border-black'); el.tabModel.classList.remove('border-black'); el.panelPrompt.classList.remove('hidden'); el.panelModel.classList.add('hidden'); } as.save(assistants)
as.setActiveId(def.id)
// === Events === }else{
el.closeSettings.addEventListener('click', closeSettings); as.setActiveId(assistants[0].id)
el.cancelSettings.addEventListener('click', closeSettings); }
el.settingsModal.addEventListener('click', e => { if (e.target === el.settingsModal || e.target.classList.contains('bg-black/30')) closeSettings(); }); renderSidebar()
el.tabModel.addEventListener('click', showModelTab); reflectActiveAssistant()
el.tabPrompt.addEventListener('click', showPromptTab); clearChat()
closeSettings()
el.settingsForm.addEventListener('submit', (e) => { })
e.preventDefault(); el.composer.addEventListener('submit', async (e) => {
const a = getActive(); const s = a.settings; e.preventDefault(); if (state.busy) return; const text = el.input.value.trim(); if (!text) return
s.model = (el.set_model.value || DEFAULT_MODEL).trim(); el.input.value=''; el.input.style.height='auto'; addMessage('user', text)
s.temperature = clamp(num(el.set_temperature.value,1.0),0,2); state.busy = true; el.sendBtn.disabled = true; el.stopBtn.classList.remove('hidden')
s.top_p = clamp(num(el.set_top_p.value,1.0),0,1); const assistantBubble = addAssistantBubbleStreaming()
s.top_k = Math.max(0, int(el.set_top_k.value,0)); await askOpenRouterStreaming((delta,done)=>{
s.frequency_penalty = clamp(num(el.set_frequency_penalty.value,0.0),-2,2); assistantBubble.textContent += delta
s.presence_penalty = clamp(num(el.set_presence_penalty.value,0.0),-2,2); if (done){ el.sendBtn.disabled=false; el.stopBtn.classList.add('hidden'); state.busy=false; state.messages.push({ role:'assistant', content: assistantBubble.textContent }); queueMicrotask(()=> el.chat.scrollTo({ top: el.chat.scrollHeight, behavior:'smooth' })) }
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); el.stopBtn.addEventListener('click', ()=>{ if (state.controller) state.controller.abort(); el.stopBtn.classList.add('hidden'); el.sendBtn.disabled=false; state.busy=false })
s.system_prompt = el.set_system_prompt.value.trim(); el.messages.addEventListener('click', (e) => { const target = e.target.closest('[data-assistant-avatar]'); if (target) openSettings() })
as.save(assistants); el.settingsBtnTop.addEventListener('click', openSettings)
closeSettings(); el.input.addEventListener('input', () => { el.input.style.height='auto'; el.input.style.height = Math.min(el.input.scrollHeight,160) + 'px' })
reflectActiveAssistant(); el.clearBtn.addEventListener('click', () => clearChat())
}); function openApiKeyPrompt(){
const currentMasked = store.apiKey ? '********' : ''
// NEW: Delete assistant (bottom-left of settings) const input = prompt('Enter OpenRouter API key (stored locally):', currentMasked)
el.deleteAssistantBtn.addEventListener('click', () => { if (input === null) return
const activeId = as.getActiveId(); store.apiKey = input === '********' ? store.apiKey : (input.trim())
const active = getActive(); alert(store.apiKey ? 'API key saved locally.' : 'API key cleared.')
const name = active?.name || 'this assistant'; }
if (!confirm(`Delete "${name}"?`)) return; function 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') } }
// Remove active from list el.userMenuBtn.addEventListener('click', (e)=>{ e.stopPropagation(); toggleUserMenu() })
assistants = assistants.filter(a => a.id !== activeId); document.addEventListener('click', (e)=>{ if (!el.userMenu.contains(e.target) && !el.userMenuBtn.contains(e.target)) toggleUserMenu(false) })
// Persist el.apiKeyOption.addEventListener('click', ()=>{ toggleUserMenu(false); openApiKeyPrompt() })
as.save(assistants); el.exportOption.addEventListener('click', ()=>{
// Decide next active const payload = { version:1, activeId: as.getActiveId(), assistants }
if (assistants.length === 0){ const blob = new Blob([JSON.stringify(payload,null,2)], { type:'application/json' })
const def = createDefaultAssistant(); const url = URL.createObjectURL(blob)
assistants = [def]; const a = document.createElement('a')
as.save(assistants); const ts = new Date()
as.setActiveId(def.id); const pad = n=>String(n).padStart(2,'0')
}else{ const fname = `assistants-backup-${ts.getFullYear()}${pad(ts.getMonth()+1)}${pad(ts.getDate())}-${pad(ts.getHours())}${pad(ts.getMinutes())}${pad(ts.getSeconds())}.json`
as.setActiveId(assistants[0].id); a.href = url; a.download = fname; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url)
} toggleUserMenu(false)
// Refresh UI })
renderSidebar(); el.importOption.addEventListener('click', ()=>{ el.importInput.value=''; el.importInput.click() })
reflectActiveAssistant(); el.importInput.addEventListener('change', async ()=>{
clearChat(); const file = el.importInput.files?.[0]; if (!file) return
closeSettings(); try{
}); const text = await file.text()
const data = JSON.parse(text)
// Composer const list = Array.isArray(data) ? data : (Array.isArray(data.assistants) ? data.assistants : null)
el.composer.addEventListener('submit', async (e) => { if (!list) throw new Error('Invalid backup format')
e.preventDefault(); if (state.busy) return; const text = el.input.value.trim(); if (!text) return; assistants = list
el.input.value=''; el.input.style.height='auto'; addMessage('user', text); as.save(assistants)
state.busy = true; el.sendBtn.disabled = true; el.stopBtn.classList.remove('hidden'); const activeId = data.activeId && assistants.some(a=>a.id===data.activeId) ? data.activeId : (assistants[0]?.id || null)
const assistantBubble = addAssistantBubbleStreaming(); as.setActiveId(activeId)
await askOpenRouterStreaming((delta,done)=>{ renderSidebar(); reflectActiveAssistant(); clearChat(); toggleUserMenu(false); closeSidebar();
assistantBubble.textContent += delta; alert('Backup imported.')
if (done){ el.sendBtn.disabled=false; el.stopBtn.classList.add('hidden'); state.busy=false; state.messages.push({ role:'assistant', content: assistantBubble.textContent }); queueMicrotask(()=> el.chat.scrollTo({ top: el.chat.scrollHeight, behavior:'smooth' })); } }catch(err){ alert('Import failed: ' + (err?.message||err)) }
}); })
}); function openSidebar(){ el.sidebar.classList.remove('-translate-x-full'); el.sidebarOverlay.classList.remove('hidden') }
function closeSidebar(){ el.sidebar.classList.add('-translate-x-full'); el.sidebarOverlay.classList.add('hidden') }
// Stop streaming el.sidebarBtn.addEventListener('click', openSidebar)
el.stopBtn.addEventListener('click', ()=>{ if (state.controller) state.controller.abort(); el.stopBtn.classList.add('hidden'); el.sendBtn.disabled=false; state.busy=false; }); el.sidebarOverlay.addEventListener('click', closeSidebar)
el.newAssistantBtn.addEventListener('click', () => {
// Open settings on any assistant avatar click const name = prompt('Name your assistant:')
el.messages.addEventListener('click', (e) => { const target = e.target.closest('[data-assistant-avatar]'); if (target) openSettings(); }); if (!name) return
const id = gid()
// Top bar assistant icon opens settings assistants.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:'' } })
el.settingsBtnTop.addEventListener('click', openSettings); as.save(assistants)
setActive(id)
// Auto-resize textarea renderSidebar()
el.input.addEventListener('input', () => { el.input.style.height='auto'; el.input.style.height = Math.min(el.input.scrollHeight,160) + 'px'; }); closeSidebar()
reflectActiveAssistant()
// Clear keeps chat blank })
el.clearBtn.addEventListener('click', () => clearChat()); el.assistantList.addEventListener('click', (e) => {
const btn = e.target.closest('[data-asst-id]')
// API badge click if (!btn) return
el.apiBadge.addEventListener('click', () => { const id = btn.getAttribute('data-asst-id')
const currentMasked = store.apiKey ? '********' : ''; if (id){ setActive(id); renderSidebar(); closeSidebar(); reflectActiveAssistant() }
const input = prompt('Enter OpenRouter API key (stored locally):', currentMasked); })
if (input === null) return; function initFromQuery(){ const url = new URL(location.href); const key = url.searchParams.get('key'); const model = url.searchParams.get('model'); if (key) globalStore.apiKey = key; if (model){ const a=getActive(); a.settings.model = model; as.save(assistants)} }
store.apiKey = input === '********' ? store.apiKey : (input.trim()); function init(){ initFromQuery(); updateStatus(); renderSidebar(); reflectActiveAssistant(); clearChat() }
alert(store.apiKey ? 'API key saved locally.' : 'API key cleared.'); init()
}); </script>
</body>
// === Sidebar interactions === </html>
function openSidebar(){ el.sidebar.classList.remove('-translate-x-full'); el.sidebarOverlay.classList.remove('hidden'); }
function closeSidebar(){ el.sidebar.classList.add('-translate-x-full'); el.sidebarOverlay.classList.add('hidden'); }
el.sidebarBtn.addEventListener('click', openSidebar);
el.sidebarOverlay.addEventListener('click', closeSidebar);
el.newAssistantBtn.addEventListener('click', () => {
const name = prompt('Name your assistant:');
if (!name) return;
const id = gid();
assistants.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:'' } });
as.save(assistants);
setActive(id);
renderSidebar();
closeSidebar();
reflectActiveAssistant();
});
el.assistantList.addEventListener('click', (e) => {
const btn = e.target.closest('[data-asst-id]');
if (!btn) return;
const id = btn.getAttribute('data-asst-id');
if (id){ setActive(id); renderSidebar(); closeSidebar(); reflectActiveAssistant(); }
});
// === Init ===
function initFromQuery(){ const url = new URL(location.href); const key = url.searchParams.get('key'); const model = url.searchParams.get('model'); if (key) store.apiKey = key; if (model){ const a=getActive(); a.settings.model = model; as.save(assistants);} }
function init(){ initFromQuery(); updateStatus(); renderSidebar(); reflectActiveAssistant(); clearChat(); }
init();
</script></body></html>