This build was committed by a bot.

This commit is contained in:
github-actions
2025-08-14 18:58:42 +00:00
parent 2cdb6dcb2e
commit 34907f1f5d
2 changed files with 55 additions and 298 deletions

View File

@@ -1 +1 @@
sune.planetrenox.com
sune.planetrenox.com

View File

@@ -33,16 +33,10 @@
<div class="mx-auto w-full max-w-2xl px-4">
<form id="composer" class="group relative flex items-end gap-2">
<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>
<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="sendBtn" type="submit" aria-label="Send" class="shrink-0 rounded-2xl bg-black text-white h-10 w-10 inline-flex items-center justify-center shadow-sm hover:bg-black/90 active:scale-[.98] transition">
<svg viewBox="0 0 24 24" class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M12 5l7 7-7 7"/></svg>
</button>
</form>
<div class="mt-2 flex items-center justify-end text-xs text-gray-500">
<div class="flex items-center gap-3">
<button id="stopBtn" class="underline decoration-dotted hover:text-gray-700 hidden">Stop</button>
<button id="clearBtn" class="underline decoration-dotted hover:text-gray-700">Clear</button>
</div>
</div>
</div>
</footer>
</div>
@@ -86,7 +80,7 @@
<div id="panelModel" class="p-4 space-y-4">
<div>
<label class="block text-gray-700 font-medium mb-1">Model name</label>
<input id="set_model" type="text" class="w-full rounded-xl border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-black/20" placeholder="openai/gpt-4o"/>
<input id="set_model" type="text" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="openai/gpt-4o"/>
</div>
<div class="grid grid-cols-2 gap-3">
<div><label class="block text-gray-700 font-medium mb-1">Temperature <span class="text-gray-400">(02)</span></label><input id="set_temperature" type="number" min="0" max="2" step="0.1" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="1.0"/><p class="mt-1 text-xs text-gray-500">Variety. Lower = predictable.</p></div>
@@ -121,296 +115,59 @@
</div>
</div>
<script>
const DEFAULT_MODEL = 'openai/gpt-4o'
const DEFAULT_API_KEY = ''
const el = Object.fromEntries([
'chat','messages','composer','input','sendBtn','stopBtn','clearBtn','settingsBtnTop',
'settingsModal','settingsForm','closeSettings','cancelSettings','tabModel','tabPrompt','panelModel','panelPrompt',
'set_model','set_temperature','set_top_p','set_top_k','set_frequency_penalty','set_presence_penalty','set_repetition_penalty','set_min_p','set_top_a','set_system_prompt',
'sidebar','sidebarOverlay','sidebarBtn','assistantList','newAssistantBtn','deleteAssistantBtn','userMenuBtn','userMenu','importInput','importOption','exportOption','apiKeyOption'
].map(id => [id, document.getElementById(id)]))
const clamp = (v, min, max) => Math.max(min, Math.min(max, v))
const num = (v, d) => (v == null || v === '' || isNaN(+v)) ? d : +v
const int = (v, d) => (v == null || v === '' || isNaN(parseInt(v))) ? d : parseInt(v)
const gid = () => Math.random().toString(36).slice(2,9)
const globalStore = {
get apiKey(){ return localStorage.getItem('openrouter_api_key') || DEFAULT_API_KEY || '' },
set apiKey(v){ localStorage.setItem('openrouter_api_key', v || ''); updateStatus() },
}
const as = {
key: 'assistants_v1',
activeKey: 'active_assistant_id',
load(){ try{ return JSON.parse(localStorage.getItem(this.key)||'[]') }catch{ return [] } },
save(list){ localStorage.setItem(this.key, JSON.stringify(list||[])) },
getActiveId(){ return localStorage.getItem(this.activeKey) || null },
setActiveId(id){ localStorage.setItem(this.activeKey, id||'') },
}
let assistants = as.load()
if (!assistants.length){
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:'' } }]
as.save(assistants)
as.setActiveId(id)
}
const getActive = () => assistants.find(a => a.id === as.getActiveId()) || assistants[0]
const setActive = (id) => { as.setActiveId(id); renderSidebar(); reflectActiveAssistant(); clearChat() }
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 store = new Proxy({}, {
get(_, prop){
if (prop === 'apiKey') return globalStore.apiKey
const a = getActive()
if (prop === 'model') return a.settings.model
if (prop in a.settings) return a.settings[prop]
if (prop === 'system_prompt') return a.settings.system_prompt
return undefined
},
set(_, prop, val){
if (prop === 'apiKey'){ globalStore.apiKey = val; return true }
const idx = assistants.findIndex(a => a.id === getActive().id)
if (idx >= 0){
if (prop === 'model') assistants[idx].settings.model = val || DEFAULT_MODEL
else if (prop === 'system_prompt') assistants[idx].settings.system_prompt = val || ''
else assistants[idx].settings[prop] = val
as.save(assistants)
return true
}
return false
}
})
const state = { messages: [], busy: false, controller: null }
const getModelShort = () => { const m = store.model || ''; return m.includes('/') ? m.split('/').pop() : m }
function reflectActiveAssistant(){ const a = getActive(); el.settingsBtnTop.title = `Settings — ${a.name}` }
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':''}">
<span class="h-6 w-6 rounded-full bg-gray-200 flex items-center justify-center">🤖</span>
<span class="truncate">${a.name}</span>
</button>
`).join('')
}
function addMessage(role, content){
const row = document.createElement('div')
row.className = 'flex gap-3'
const avatar = document.createElement('div')
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')
avatar.textContent = role === 'user' ? '🧑' : '🤖'
if (role !== 'user') avatar.setAttribute('data-assistant-avatar','')
const right = document.createElement('div')
right.className = 'flex flex-col'
if (role !== 'user'){
const name = document.createElement('div')
name.className = 'text-xs font-medium text-gray-500 mb-0.5'
name.textContent = getModelShort()
right.appendChild(name)
}
const bubble = document.createElement('div')
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)
row.appendChild(avatar); row.appendChild(right)
el.messages.appendChild(row)
state.messages.push({ role, content })
queueMicrotask(() => el.chat.scrollTo({ top: el.chat.scrollHeight, behavior: 'smooth' }))
return bubble
}
function addAssistantBubbleStreaming(){
const row = document.createElement('div'); row.className = 'flex gap-3'
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','')
const right = document.createElement('div'); right.className='flex flex-col'
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)
row.appendChild(avatar); row.appendChild(right)
el.messages.appendChild(row)
queueMicrotask(() => el.chat.scrollTo({ top: el.chat.scrollHeight }))
return bubble
}
function clearChat(){ state.messages=[]; el.messages.innerHTML='' }
const DEFAULT_MODEL='openai/gpt-4o',DEFAULT_API_KEY='';
const el=Object.fromEntries(['chat','messages','composer','input','sendBtn','settingsBtnTop','settingsModal','settingsForm','closeSettings','cancelSettings','tabModel','tabPrompt','panelModel','panelPrompt','set_model','set_temperature','set_top_p','set_top_k','set_frequency_penalty','set_presence_penalty','set_repetition_penalty','set_min_p','set_top_a','set_system_prompt','sidebar','sidebarOverlay','sidebarBtn','assistantList','newAssistantBtn','deleteAssistantBtn','userMenuBtn','userMenu','importInput','importOption','exportOption','apiKeyOption'].map(id=>[id,document.getElementById(id)]));
const clamp=(v,min,max)=>Math.max(min,Math.min(max,v)),num=(v,d)=>v==null||v===''||isNaN(+v)?d:+v,int=(v,d)=>v==null||v===''||isNaN(parseInt(v))?d:parseInt(v),gid=()=>Math.random().toString(36).slice(2,9);
const globalStore={get apiKey(){return localStorage.getItem('openrouter_api_key')||DEFAULT_API_KEY||''},set apiKey(v){localStorage.setItem('openrouter_api_key',v||'');updateStatus()}};
const as={key:'assistants_v1',activeKey:'active_assistant_id',load(){try{return JSON.parse(localStorage.getItem(this.key)||'[]')}catch{return[]}},save(list){localStorage.setItem(this.key,JSON.stringify(list||[]))},getActiveId(){return localStorage.getItem(this.activeKey)||null},setActiveId(id){localStorage.setItem(this.activeKey,id||'')}};
let assistants=as.load();
if(!assistants.length){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:''}}];as.save(assistants);as.setActiveId(id)}
const getActive=()=>assistants.find(a=>a.id===as.getActiveId())||assistants[0],setActive=id=>{as.setActiveId(id);renderSidebar();reflectActiveAssistant();clearChat()},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 store=new Proxy({},{get(_,p){if(p==='apiKey')return globalStore.apiKey;const a=getActive();if(p==='model')return a.settings.model;if(p in a.settings)return a.settings[p];if(p==='system_prompt')return a.settings.system_prompt},set(_,p,v){if(p==='apiKey'){globalStore.apiKey=v;return true}const i=assistants.findIndex(a=>a.id===getActive().id);if(i>=0){if(p==='model')assistants[i].settings.model=v||DEFAULT_MODEL;else if(p==='system_prompt')assistants[i].settings.system_prompt=v||'';else assistants[i].settings[p]=v;as.save(assistants);return true}return false}});
const state={messages:[],busy:false,controller:null};
const getModelShort=()=>{const m=store.model||'';return m.includes('/')?m.split('/').pop():m};
function reflectActiveAssistant(){const a=getActive();el.settingsBtnTop.title=`Settings — ${a.name}`}
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':''}"><span class="h-6 w-6 rounded-full bg-gray-200 flex items-center justify-center">🤖</span><span class="truncate">${a.name}</span></button>`).join('')}
function addMessage(role,content){const row=document.createElement('div');row.className='flex gap-3';const avatar=document.createElement('div');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');avatar.textContent=role==='user'?'🧑':'🤖';if(role!=='user')avatar.setAttribute('data-assistant-avatar','');const right=document.createElement('div');right.className='flex flex-col';if(role!=='user'){const name=document.createElement('div');name.className='text-xs font-medium text-gray-500 mb-0.5';name.textContent=getModelShort();right.appendChild(name)}const bubble=document.createElement('div');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);row.appendChild(avatar);row.appendChild(right);el.messages.appendChild(row);state.messages.push({role,content});queueMicrotask(()=>el.chat.scrollTo({top:el.chat.scrollHeight,behavior:'smooth'}));return bubble}
function addAssistantBubbleStreaming(){const row=document.createElement('div');row.className='flex gap-3';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','');const right=document.createElement('div');right.className='flex flex-col';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);row.appendChild(avatar);row.appendChild(right);el.messages.appendChild(row);queueMicrotask(()=>el.chat.scrollTo({top:el.chat.scrollHeight}));return bubble}
function clearChat(){state.messages=[];el.messages.innerHTML=''}
function updateStatus(){}
function payloadWithSampling(base){
return Object.assign({}, base, {
temperature: store.temperature,
top_p: store.top_p,
top_k: store.top_k,
frequency_penalty: store.frequency_penalty,
presence_penalty: store.presence_penalty,
repetition_penalty: store.repetition_penalty,
min_p: store.min_p,
top_a: store.top_a,
})
}
async function askOpenRouterStreaming(onDelta){
const apiKey = store.apiKey; const model = store.model
if (!apiKey){ const text = localDemoReply(state.messages[state.messages.length-1]?.content || ''); onDelta(text,true); return { ok:true, text } }
try{
state.controller = new AbortController()
const msgs = []
if (store.system_prompt) msgs.push({ role:'system', content: store.system_prompt })
msgs.push(...state.messages.filter(m=>m.role!=='system'))
const body = payloadWithSampling({ model, messages: msgs, stream: true })
const res = await fetch('https://openrouter.ai/api/v1/chat/completions',{
method:'POST', headers:{ 'Content-Type':'application/json','Authorization':'Bearer '+apiKey }, body: JSON.stringify(body), signal: state.controller.signal
})
if (!res.ok){ const errText = await res.text().catch(()=> ''); throw new Error(errText || ('HTTP '+res.status)) }
const reader = res.body.getReader(); 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});
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{} } }
}
return { ok:true, text: full }
}catch(e){
const msg = String(e?.message||e)
let hint = 'Request failed.'
if (/401|unauthorized/i.test(msg)) hint = 'Unauthorized (check API key).'
else if (/429|rate/i.test(msg)) hint = 'Rate limited (slow down or upgrade).'
else if (/access|forbidden|403/i.test(msg)) hint = 'Forbidden (model or key scope).'
const fallback = `\n\n${hint}\nSwitching to local demo.\n\n` + localDemoReply(state.messages[state.messages.length-1]?.content || '')
onDelta(fallback,true)
return { ok:false, text: fallback }
}finally{ state.controller = null }
}
function localDemoReply(prompt){
const tips = [ 'Tip: open the sidebar → Account & Backup to set your OpenRouter API key.', 'Click 🤖 to change model & sampling.', 'New chats are stateless here—no history is kept.' ]
const tip = tips[Math.floor(Math.random()*tips.length)]
const mirrored = prompt.split(/\s+/).slice(0,24).join(' ')
return `Local demo mode. You said: "${mirrored}"\n\n${tip}`
}
function openSettings(){
const a = getActive(); const s = a.settings
el.set_model.value = s.model
el.set_temperature.value = s.temperature
el.set_top_p.value = s.top_p
el.set_top_k.value = s.top_k
el.set_frequency_penalty.value = s.frequency_penalty
el.set_presence_penalty.value = s.presence_penalty
el.set_repetition_penalty.value = s.repetition_penalty
el.set_min_p.value = s.min_p
el.set_top_a.value = s.top_a
el.set_system_prompt.value = s.system_prompt
showModelTab()
el.settingsModal.classList.remove('hidden')
}
function closeSettings(){ el.settingsModal.classList.add('hidden') }
function showModelTab(){ el.tabModel.classList.add('border-black'); el.tabPrompt.classList.remove('border-black'); el.panelModel.classList.remove('hidden'); el.panelPrompt.classList.add('hidden') }
function showPromptTab(){ el.tabPrompt.classList.add('border-black'); el.tabModel.classList.remove('border-black'); el.panelPrompt.classList.remove('hidden'); el.panelModel.classList.add('hidden') }
el.closeSettings.addEventListener('click', closeSettings)
el.cancelSettings.addEventListener('click', closeSettings)
el.settingsModal.addEventListener('click', e => { if (e.target === el.settingsModal || e.target.classList.contains('bg-black/30')) closeSettings() })
el.tabModel.addEventListener('click', showModelTab)
el.tabPrompt.addEventListener('click', showPromptTab)
el.settingsForm.addEventListener('submit', (e) => {
e.preventDefault()
const a = getActive(); const s = a.settings
s.model = (el.set_model.value || DEFAULT_MODEL).trim()
s.temperature = clamp(num(el.set_temperature.value,1.0),0,2)
s.top_p = clamp(num(el.set_top_p.value,1.0),0,1)
s.top_k = Math.max(0, int(el.set_top_k.value,0))
s.frequency_penalty = clamp(num(el.set_frequency_penalty.value,0.0),-2,2)
s.presence_penalty = clamp(num(el.set_presence_penalty.value,0.0),-2,2)
s.repetition_penalty = clamp(num(el.set_repetition_penalty.value,1.0),0,2)
s.min_p = clamp(num(el.set_min_p.value,0.0),0,1)
s.top_a = clamp(num(el.set_top_a.value,0.0),0,1)
s.system_prompt = el.set_system_prompt.value.trim()
as.save(assistants)
closeSettings()
reflectActiveAssistant()
})
el.deleteAssistantBtn.addEventListener('click', () => {
const activeId = as.getActiveId()
const active = getActive()
const name = active?.name || 'this assistant'
if (!confirm(`Delete "${name}"?`)) return
assistants = assistants.filter(a => a.id !== activeId)
as.save(assistants)
if (assistants.length === 0){
const def = createDefaultAssistant()
assistants = [def]
as.save(assistants)
as.setActiveId(def.id)
}else{
as.setActiveId(assistants[0].id)
}
renderSidebar()
reflectActiveAssistant()
clearChat()
closeSettings()
})
el.composer.addEventListener('submit', async (e) => {
e.preventDefault(); if (state.busy) return; const text = el.input.value.trim(); if (!text) return
el.input.value=''; el.input.style.height='auto'; addMessage('user', text)
state.busy = true; el.sendBtn.disabled = true; el.stopBtn.classList.remove('hidden')
const assistantBubble = addAssistantBubbleStreaming()
await askOpenRouterStreaming((delta,done)=>{
assistantBubble.textContent += delta
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' })) }
})
})
el.stopBtn.addEventListener('click', ()=>{ if (state.controller) state.controller.abort(); el.stopBtn.classList.add('hidden'); el.sendBtn.disabled=false; state.busy=false })
el.messages.addEventListener('click', (e) => { const target = e.target.closest('[data-assistant-avatar]'); if (target) openSettings() })
el.settingsBtnTop.addEventListener('click', openSettings)
el.input.addEventListener('input', () => { el.input.style.height='auto'; el.input.style.height = Math.min(el.input.scrollHeight,160) + 'px' })
el.clearBtn.addEventListener('click', () => clearChat())
function openApiKeyPrompt(){
const currentMasked = store.apiKey ? '********' : ''
const input = prompt('Enter OpenRouter API key (stored locally):', currentMasked)
if (input === null) return
store.apiKey = input === '********' ? store.apiKey : (input.trim())
alert(store.apiKey ? 'API key saved locally.' : 'API key cleared.')
}
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') } }
el.userMenuBtn.addEventListener('click', (e)=>{ e.stopPropagation(); toggleUserMenu() })
document.addEventListener('click', (e)=>{ if (!el.userMenu.contains(e.target) && !el.userMenuBtn.contains(e.target)) toggleUserMenu(false) })
el.apiKeyOption.addEventListener('click', ()=>{ toggleUserMenu(false); openApiKeyPrompt() })
el.exportOption.addEventListener('click', ()=>{
const payload = { version:1, activeId: as.getActiveId(), assistants }
const blob = new Blob([JSON.stringify(payload,null,2)], { type:'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
const ts = new Date()
const pad = n=>String(n).padStart(2,'0')
const fname = `assistants-backup-${ts.getFullYear()}${pad(ts.getMonth()+1)}${pad(ts.getDate())}-${pad(ts.getHours())}${pad(ts.getMinutes())}${pad(ts.getSeconds())}.json`
a.href = url; a.download = fname; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url)
toggleUserMenu(false)
})
el.importOption.addEventListener('click', ()=>{ el.importInput.value=''; el.importInput.click() })
el.importInput.addEventListener('change', async ()=>{
const file = el.importInput.files?.[0]; if (!file) return
try{
const text = await file.text()
const data = JSON.parse(text)
const list = Array.isArray(data) ? data : (Array.isArray(data.assistants) ? data.assistants : null)
if (!list) throw new Error('Invalid backup format')
assistants = list
as.save(assistants)
const activeId = data.activeId && assistants.some(a=>a.id===data.activeId) ? data.activeId : (assistants[0]?.id || null)
as.setActiveId(activeId)
renderSidebar(); reflectActiveAssistant(); clearChat(); toggleUserMenu(false); closeSidebar();
alert('Backup imported.')
}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') }
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() }
})
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)} }
function init(){ initFromQuery(); updateStatus(); renderSidebar(); reflectActiveAssistant(); clearChat() }
function payloadWithSampling(b){return Object.assign({},b,{temperature:store.temperature,top_p:store.top_p,top_k:store.top_k,frequency_penalty:store.frequency_penalty,presence_penalty:store.presence_penalty,repetition_penalty:store.repetition_penalty,min_p:store.min_p,top_a:store.top_a})}
function setBtnStop(){const b=el.sendBtn;b.dataset.mode='stop';b.type='button';b.setAttribute('aria-label','Stop');b.innerHTML='<svg viewBox="0 0 24 24" class="h-5 w-5" fill="currentColor"><rect x="7" y="7" width="10" height="10" rx="1"/></svg>';b.onclick=()=>{if(state.controller)state.controller.abort()}}
function setBtnSend(){const b=el.sendBtn;b.dataset.mode='send';b.type='submit';b.setAttribute('aria-label','Send');b.innerHTML='<svg viewBox="0 0 24 24" class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M12 5l7 7-7 7"/></svg>';b.onclick=null}
async function askOpenRouterStreaming(onDelta){const apiKey=store.apiKey,model=store.model;if(!apiKey){const text=localDemoReply(state.messages[state.messages.length-1]?.content||'');onDelta(text,true);return {ok:true,text}}try{state.controller=new AbortController();const msgs=[];if(store.system_prompt)msgs.push({role:'system',content:store.system_prompt});msgs.push(...state.messages.filter(m=>m.role!=='system'));const body=payloadWithSampling({model,messages:msgs,stream:true});const res=await fetch('https://openrouter.ai/api/v1/chat/completions',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+apiKey},body:JSON.stringify(body),signal:state.controller.signal});if(!res.ok){const errText=await res.text().catch(()=> '');throw new Error(errText||('HTTP '+res.status))}const reader=res.body.getReader(),decoder=new TextDecoder('utf-8');let buffer='',full='';while(true){const {value,done}=await reader.read();if(done)break;buffer+=decoder.decode(value,{stream:true});let idx;while((idx=buffer.indexOf('\n\n'))!==-1){const chunk=buffer.slice(0,idx).trim();buffer=buffer.slice(idx+2);if(!chunk)continue;if(chunk.startsWith('data:')){const data=chunk.slice(5).trim();if(data==='[DONE]')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{}}}}return {ok:true,text:full}}catch(e){const msg=String(e?.message||e);if(/AbortError/i.test(msg)){onDelta('',true);return {ok:false,text:''}}let hint='Request failed.';if(/401|unauthorized/i.test(msg))hint='Unauthorized (check API key).';else if(/429|rate/i.test(msg))hint='Rate limited (slow down or upgrade).';else if(/access|forbidden|403/i.test(msg))hint='Forbidden (model or key scope).';const fallback=`\n\n${hint}\nSwitching to local demo.\n\n`+localDemoReply(state.messages[state.messages.length-1]?.content||'');onDelta(fallback,true);return {ok:false,text:fallback}}finally{state.controller=null}}
function localDemoReply(prompt){const tips=['Tip: open the sidebar → Account & Backup to set your OpenRouter API key.','Click 🤖 to change model & sampling.','New chats are stateless here—no history is kept.'],tip=tips[Math.floor(Math.random()*tips.length)],mirrored=prompt.split(/\s+/).slice(0,24).join(' ');return `Local demo mode. You said: "${mirrored}"\n\n${tip}`}
function openSettings(){const a=getActive(),s=a.settings;el.set_model.value=s.model;el.set_temperature.value=s.temperature;el.set_top_p.value=s.top_p;el.set_top_k.value=s.top_k;el.set_frequency_penalty.value=s.frequency_penalty;el.set_presence_penalty.value=s.presence_penalty;el.set_repetition_penalty.value=s.repetition_penalty;el.set_min_p.value=s.min_p;el.set_top_a.value=s.top_a;el.set_system_prompt.value=s.system_prompt;showModelTab();el.settingsModal.classList.remove('hidden')}
function closeSettings(){el.settingsModal.classList.add('hidden')}
function showModelTab(){el.tabModel.classList.add('border-black');el.tabPrompt.classList.remove('border-black');el.panelModel.classList.remove('hidden');el.panelPrompt.classList.add('hidden')}
function showPromptTab(){el.tabPrompt.classList.add('border-black');el.tabModel.classList.remove('border-black');el.panelPrompt.classList.remove('hidden');el.panelModel.classList.add('hidden')}
el.closeSettings.addEventListener('click',closeSettings);
el.cancelSettings.addEventListener('click',closeSettings);
el.settingsModal.addEventListener('click',e=>{if(e.target===el.settingsModal||e.target.classList.contains('bg-black/30'))closeSettings()});
el.tabModel.addEventListener('click',showModelTab);
el.tabPrompt.addEventListener('click',showPromptTab);
el.settingsForm.addEventListener('submit',e=>{e.preventDefault();const a=getActive(),s=a.settings;s.model=(el.set_model.value||DEFAULT_MODEL).trim();s.temperature=clamp(num(el.set_temperature.value,1.0),0,2);s.top_p=clamp(num(el.set_top_p.value,1.0),0,1);s.top_k=Math.max(0,int(el.set_top_k.value,0));s.frequency_penalty=clamp(num(el.set_frequency_penalty.value,0.0),-2,2);s.presence_penalty=clamp(num(el.set_presence_penalty.value,0.0),-2,2);s.repetition_penalty=clamp(num(el.set_repetition_penalty.value,1.0),0,2);s.min_p=clamp(num(el.set_min_p.value,0.0),0,1);s.top_a=clamp(num(el.set_top_a.value,0.0),0,1);s.system_prompt=el.set_system_prompt.value.trim();as.save(assistants);closeSettings();reflectActiveAssistant()});
el.deleteAssistantBtn.addEventListener('click',()=>{const activeId=as.getActiveId(),active=getActive(),name=active?.name||'this assistant';if(!confirm(`Delete "${name}"?`))return;assistants=assistants.filter(a=>a.id!==activeId);as.save(assistants);if(assistants.length===0){const def=createDefaultAssistant();assistants=[def];as.save(assistants);as.setActiveId(def.id)}else{as.setActiveId(assistants[0].id)}renderSidebar();reflectActiveAssistant();clearChat();closeSettings()});
el.composer.addEventListener('submit',async e=>{e.preventDefault();if(state.busy)return;const text=el.input.value.trim();if(!text)return;el.input.value='';el.input.style.height='auto';addMessage('user',text);state.busy=true;setBtnStop();const assistantBubble=addAssistantBubbleStreaming();await askOpenRouterStreaming((delta,done)=>{assistantBubble.textContent+=delta;if(done){setBtnSend();state.busy=false;state.messages.push({role:'assistant',content:assistantBubble.textContent});queueMicrotask(()=>el.chat.scrollTo({top:el.chat.scrollHeight,behavior:'smooth'}))}})});
el.messages.addEventListener('click',e=>{const t=e.target.closest('[data-assistant-avatar]');if(t)openSettings()});
el.settingsBtnTop.addEventListener('click',openSettings);
el.input.addEventListener('input',()=>{el.input.style.height='auto';el.input.style.height=Math.min(el.input.scrollHeight,160)+'px'});
function openApiKeyPrompt(){const cur=store.apiKey?'********':'';const input=prompt('Enter OpenRouter API key (stored locally):',cur);if(input===null)return;store.apiKey=input==='********'?store.apiKey:input.trim();alert(store.apiKey?'API key saved locally.':'API key cleared.')}
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')}
el.userMenuBtn.addEventListener('click',e=>{e.stopPropagation();toggleUserMenu()});
document.addEventListener('click',e=>{if(!el.userMenu.contains(e.target)&&!el.userMenuBtn.contains(e.target))toggleUserMenu(false)});
el.apiKeyOption.addEventListener('click',()=>{toggleUserMenu(false);openApiKeyPrompt()});
el.exportOption.addEventListener('click',()=>{const payload={version:1,activeId:as.getActiveId(),assistants},blob=new Blob([JSON.stringify(payload,null,2)],{type:'application/json'}),url=URL.createObjectURL(blob),a=document.createElement('a'),ts=new Date(),pad=n=>String(n).padStart(2,'0'),fname=`assistants-backup-${ts.getFullYear()}${pad(ts.getMonth()+1)}${pad(ts.getDate())}-${pad(ts.getHours())}${pad(ts.getMinutes())}${pad(ts.getSeconds())}.json`;a.href=url;a.download=fname;document.body.appendChild(a);a.click();a.remove();URL.revokeObjectURL(url);toggleUserMenu(false)});
el.importOption.addEventListener('click',()=>{el.importInput.value='';el.importInput.click()});
el.importInput.addEventListener('change',async()=>{const file=el.importInput.files?.[0];if(!file)return;try{const text=await file.text(),data=JSON.parse(text),list=Array.isArray(data)?data:(Array.isArray(data.assistants)?data.assistants:null);if(!list)throw new Error('Invalid backup format');assistants=list;as.save(assistants);const activeId=data.activeId&&assistants.some(a=>a.id===data.activeId)?data.activeId:(assistants[0]?.id||null);as.setActiveId(activeId);renderSidebar();reflectActiveAssistant();clearChat();toggleUserMenu(false);closeSidebar();alert('Backup imported.')}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')}
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()}});
function initFromQuery(){const url=new URL(location.href),key=url.searchParams.get('key'),model=url.searchParams.get('model');if(key)globalStore.apiKey=key;if(model){const a=getActive();a.settings.model=model;as.save(assistants)}}
function init(){initFromQuery();updateStatus();renderSidebar();reflectActiveAssistant();clearChat()}
init()
</script>
</body>