let _currentPanel = 'chat'; let _skillsData = null; // cached skills list async function switchPanel(name) { _currentPanel = name; // Update nav tabs document.querySelectorAll('.nav-tab').forEach(t => t.classList.toggle('active', t.dataset.panel === name)); // Update panel views document.querySelectorAll('.panel-view').forEach(p => p.classList.remove('active')); const panelEl = $('panel' + name.charAt(0).toUpperCase() + name.slice(1)); if (panelEl) panelEl.classList.add('active'); // Lazy-load panel data if (name === 'tasks') await loadCrons(); if (name === 'skills') await loadSkills(); if (name === 'memory') await loadMemory(); if (name === 'workspaces') await loadWorkspacesPanel(); if (name === 'profiles') await loadProfilesPanel(); if (name === 'todos') loadTodos(); } // ── Cron panel ── async function loadCrons(animate) { const box = $('cronList'); const refreshBtn = $('cronRefreshBtn'); if (animate && refreshBtn) { refreshBtn.style.opacity = '0.5'; refreshBtn.disabled = true; } try { const data = await api('/api/crons'); if (!data.jobs || !data.jobs.length) { box.innerHTML = `
${esc(t('cron_no_jobs'))}
`; return; } box.innerHTML = ''; for (const job of data.jobs) { const item = document.createElement('div'); item.className = 'cron-item'; item.id = 'cron-' + job.id; const statusClass = job.enabled === false ? 'disabled' : job.state === 'paused' ? 'paused' : job.last_status === 'error' ? 'error' : 'active'; const statusLabel = job.enabled === false ? t('cron_status_off') : job.state === 'paused' ? t('cron_status_paused') : job.last_status === 'error' ? t('cron_status_error') : t('cron_status_active'); const nextRun = job.next_run_at ? new Date(job.next_run_at).toLocaleString() : t('not_available'); const lastRun = job.last_run_at ? new Date(job.last_run_at).toLocaleString() : t('never'); item.innerHTML = `
${esc(job.name)} ${statusLabel}
${li('clock',12)} ${esc(job.schedule_display || job.schedule?.expression || '')}  |  ${esc(t('cron_next'))}: ${esc(nextRun)}  |  ${esc(t('cron_last'))}: ${esc(lastRun)}
${esc((job.prompt||'').slice(0,300))}${(job.prompt||'').length>300?'…':''}
${job.state==='paused' ? `` : ``}
${esc(t('cron_last_output'))}
${esc(t('loading'))}
`; box.appendChild(item); // Eagerly load last output for visible items loadCronOutput(job.id); } } catch(e) { box.innerHTML = `
${esc(t('error_prefix'))}${esc(e.message)}
`; } finally { if (animate && refreshBtn) { refreshBtn.style.opacity = ''; refreshBtn.disabled = false; } } } let _cronSelectedSkills=[]; let _cronSkillsCache=null; function toggleCronForm(){ const form=$('cronCreateForm'); if(!form)return; const open=form.style.display!=='none'; form.style.display=open?'none':''; if(!open){ $('cronFormName').value=''; $('cronFormSchedule').value=''; $('cronFormPrompt').value=''; $('cronFormDeliver').value='local'; $('cronFormError').style.display='none'; _cronSelectedSkills=[]; _renderCronSkillTags(); const search=$('cronFormSkillSearch'); if(search)search.value=''; // Always re-fetch skills to avoid stale cache _cronSkillsCache=null; api('/api/skills').then(d=>{_cronSkillsCache=d.skills||[];}).catch(()=>{}); $('cronFormName').focus(); } } function _renderCronSkillTags(){ const wrap=$('cronFormSkillTags'); if(!wrap)return; wrap.innerHTML=''; for(const name of _cronSelectedSkills){ const tag=document.createElement('span'); tag.className='skill-tag'; tag.dataset.skill=name; const rm=document.createElement('span'); rm.className='remove-tag';rm.textContent='×'; rm.onclick=()=>{_cronSelectedSkills=_cronSelectedSkills.filter(s=>s!==name);tag.remove();}; tag.appendChild(document.createTextNode(name)); tag.appendChild(rm); wrap.appendChild(tag); } } // Skill search input handler (function(){ const setup=()=>{ const search=$('cronFormSkillSearch'); const dropdown=$('cronFormSkillDropdown'); if(!search||!dropdown)return; search.oninput=()=>{ const q=search.value.trim().toLowerCase(); if(!q||!_cronSkillsCache){dropdown.style.display='none';return;} const matches=_cronSkillsCache.filter(s=> !_cronSelectedSkills.includes(s.name)&& (s.name.toLowerCase().includes(q)||(s.category||'').toLowerCase().includes(q)) ).slice(0,8); if(!matches.length){dropdown.style.display='none';return;} dropdown.innerHTML=''; for(const s of matches){ const opt=document.createElement('div'); opt.className='skill-opt'; opt.textContent=s.name+(s.category?' ('+s.category+')':''); opt.onclick=()=>{ _cronSelectedSkills.push(s.name); _renderCronSkillTags(); search.value=''; dropdown.style.display='none'; }; dropdown.appendChild(opt); } dropdown.style.display=''; }; search.onblur=()=>setTimeout(()=>{dropdown.style.display='none';},150); }; if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',setup); else setTimeout(setup,0); })(); async function submitCronCreate(){ const name=$('cronFormName').value.trim(); const schedule=$('cronFormSchedule').value.trim(); const prompt=$('cronFormPrompt').value.trim(); const deliver=$('cronFormDeliver').value; const errEl=$('cronFormError'); errEl.style.display='none'; if(!schedule){errEl.textContent=t('cron_schedule_required_example');errEl.style.display='';return;} if(!prompt){errEl.textContent=t('cron_prompt_required');errEl.style.display='';return;} try{ const body={schedule,prompt,deliver}; if(name)body.name=name; if(_cronSelectedSkills.length)body.skills=_cronSelectedSkills; await api('/api/crons/create',{method:'POST',body:JSON.stringify(body)}); toggleCronForm(); showToast(t('cron_job_created')); await loadCrons(); }catch(e){ errEl.textContent=t('error_prefix')+e.message;errEl.style.display=''; } } function _cronOutputSnippet(content) { // Extract the response body from a cron output .md file const lines = content.split('\n'); const responseIdx = lines.findIndex(l => l.startsWith('## Response') || l.startsWith('# Response')); const body = (responseIdx >= 0 ? lines.slice(responseIdx + 1) : lines).join('\n').trim(); return body.slice(0, 600) || '(empty)'; } async function loadCronOutput(jobId) { try { const data = await api(`/api/crons/output?job_id=${encodeURIComponent(jobId)}&limit=1`); const el = $('cron-out-text-' + jobId); if (!el) return; if (!data.outputs || !data.outputs.length) { el.textContent = t('cron_no_runs_yet'); return; } const out = data.outputs[0]; const ts = out.filename.replace('.md','').replace(/_/g,' '); el.textContent = ts + '\n\n' + _cronOutputSnippet(out.content); } catch(e) { /* ignore */ } } async function loadCronHistory(jobId, btn) { const histEl = $('cron-history-' + jobId); if (!histEl) return; // Toggle: if already open, close it if (histEl.style.display !== 'none') { histEl.style.display = 'none'; if (btn) btn.textContent = t('cron_all_runs'); return; } if (btn) btn.textContent = t('loading'); try { const data = await api(`/api/crons/output?job_id=${encodeURIComponent(jobId)}&limit=20`); if (!data.outputs || !data.outputs.length) { histEl.innerHTML = `
${esc(t('cron_no_runs_yet'))}
`; } else { histEl.innerHTML = data.outputs.map((out, i) => { const ts = out.filename.replace('.md','').replace(/_/g,' '); const snippet = _cronOutputSnippet(out.content); const id = `cron-hist-run-${jobId}-${i}`; return `
${esc(ts)}
`; }).join(''); } histEl.style.display = ''; if (btn) btn.textContent = t('cron_hide_runs'); } catch(e) { if (btn) btn.textContent = t('cron_all_runs'); } } function toggleCron(id) { const body = $('cron-body-' + id); if (body) body.classList.toggle('open'); } async function cronRun(id) { try { await api('/api/crons/run', {method:'POST', body: JSON.stringify({job_id: id})}); showToast(t('cron_job_triggered')); setTimeout(() => loadCronOutput(id), 5000); } catch(e) { showToast(t('failed_colon') + e.message, 4000); } } async function cronPause(id) { try { await api('/api/crons/pause', {method:'POST', body: JSON.stringify({job_id: id})}); showToast(t('cron_job_paused')); await loadCrons(); } catch(e) { showToast(t('failed_colon') + e.message, 4000); } } async function cronResume(id) { try { await api('/api/crons/resume', {method:'POST', body: JSON.stringify({job_id: id})}); showToast(t('cron_job_resumed')); await loadCrons(); } catch(e) { showToast(t('failed_colon') + e.message, 4000); } } function cronEditOpen(id, job) { const form = $('cron-edit-' + id); if (!form) return; $('cron-edit-name-' + id).value = job.name || ''; $('cron-edit-schedule-' + id).value = job.schedule_display || (job.schedule && job.schedule.expression) || job.schedule || ''; $('cron-edit-prompt-' + id).value = job.prompt || ''; const errEl = $('cron-edit-err-' + id); if (errEl) errEl.style.display = 'none'; form.style.display = ''; } function cronEditClose(id) { const form = $('cron-edit-' + id); if (form) form.style.display = 'none'; } async function cronEditSave(id) { const name = $('cron-edit-name-' + id).value.trim(); const schedule = $('cron-edit-schedule-' + id).value.trim(); const prompt = $('cron-edit-prompt-' + id).value.trim(); const errEl = $('cron-edit-err-' + id); if (!schedule) { errEl.textContent = t('cron_schedule_required'); errEl.style.display = ''; return; } if (!prompt) { errEl.textContent = t('cron_prompt_required'); errEl.style.display = ''; return; } try { const updates = {job_id: id, schedule, prompt}; if (name) updates.name = name; await api('/api/crons/update', {method:'POST', body: JSON.stringify(updates)}); showToast(t('cron_job_updated')); await loadCrons(); } catch(e) { errEl.textContent = t('error_prefix') + e.message; errEl.style.display = ''; } } async function cronDelete(id) { const _delCron=await showConfirmDialog({title:t('cron_delete_confirm_title'),message:t('cron_delete_confirm_message'),confirmLabel:t('delete_title'),danger:true,focusCancel:true}); if(!_delCron) return; try { await api('/api/crons/delete', {method:'POST', body: JSON.stringify({job_id: id})}); showToast(t('cron_job_deleted')); await loadCrons(); } catch(e) { showToast(t('delete_failed') + e.message, 4000); } } function loadTodos() { const panel = $('todoPanel'); if (!panel) return; const sourceMessages = (S.session && Array.isArray(S.session.messages) && S.session.messages.length) ? S.session.messages : S.messages; // Parse the most recent todo state from message history let todos = []; for (let i = sourceMessages.length - 1; i >= 0; i--) { const m = sourceMessages[i]; if (m && m.role === 'tool') { try { const d = JSON.parse(typeof m.content === 'string' ? m.content : JSON.stringify(m.content)); if (d && Array.isArray(d.todos) && d.todos.length) { todos = d.todos; break; } } catch(e) {} } } if (!todos.length) { panel.innerHTML = `
${esc(t('todos_no_active'))}
`; return; } const statusIcon = {pending:li('square',14), in_progress:li('loader',14), completed:li('check',14), cancelled:li('x',14)}; const statusColor = {pending:'var(--muted)', in_progress:'var(--blue)', completed:'rgba(100,200,100,.8)', cancelled:'rgba(200,100,100,.5)'}; panel.innerHTML = todos.map(t => `
${statusIcon[t.status]||li('square',14)}
${esc(t.content)}
${esc(t.id)} · ${esc(t.status)}
`).join(''); } async function clearConversation() { if(!S.session) return; const _clrMsg=await showConfirmDialog({title:t('clear_conversation_title'),message:t('clear_conversation_message'),confirmLabel:t('clear'),danger:true,focusCancel:true}); if(!_clrMsg) return; try { const data = await api('/api/session/clear', {method:'POST', body: JSON.stringify({session_id: S.session.session_id})}); S.session = data.session; S.messages = []; S.toolCalls = []; syncTopbar(); renderMessages(); showToast(t('conversation_cleared')); } catch(e) { setStatus(t('clear_failed') + e.message); } } // ── Skills panel ── async function loadSkills() { if (_skillsData) { renderSkills(_skillsData); return; } const box = $('skillsList'); try { const data = await api('/api/skills'); _skillsData = data.skills || []; renderSkills(_skillsData); } catch(e) { box.innerHTML = `
Error: ${esc(e.message)}
`; } } function renderSkills(skills) { const query = ($('skillsSearch').value || '').toLowerCase(); const filtered = query ? skills.filter(s => (s.name||'').toLowerCase().includes(query) || (s.description||'').toLowerCase().includes(query) || (s.category||'').toLowerCase().includes(query) ) : skills; // Group by category const cats = {}; for (const s of filtered) { const cat = s.category || '(general)'; if (!cats[cat]) cats[cat] = []; cats[cat].push(s); } const box = $('skillsList'); box.innerHTML = ''; if (!filtered.length) { box.innerHTML = `
${esc(t('skills_no_match'))}
`; return; } for (const [cat, items] of Object.entries(cats).sort()) { const sec = document.createElement('div'); sec.className = 'skills-category'; sec.innerHTML = `
${li('folder',12)} ${esc(cat)} (${items.length})
`; for (const skill of items.sort((a,b) => a.name.localeCompare(b.name))) { const el = document.createElement('div'); el.className = 'skill-item'; el.innerHTML = `${esc(skill.name)}${esc(skill.description||'')}`; el.onclick = () => openSkill(skill.name, el); sec.appendChild(el); } box.appendChild(sec); } } function filterSkills() { if (_skillsData) renderSkills(_skillsData); } async function openSkill(name, el) { // Highlight active skill document.querySelectorAll('.skill-item').forEach(e => e.classList.remove('active')); if (el) el.classList.add('active'); // Ensure the workspace panel is open so the skill content is actually visible (#643) if (typeof ensureWorkspacePreviewVisible === 'function') ensureWorkspacePreviewVisible(); try { const data = await api(`/api/skills/content?name=${encodeURIComponent(name)}`); // Show skill content in right panel preview $('previewPathText').textContent = name + '.md'; $('previewBadge').textContent = 'skill'; $('previewBadge').className = 'preview-badge md'; showPreview('md'); let html = renderMd(data.content || '(no content)'); // Render linked files section if present const lf = data.linked_files || {}; const categories = Object.entries(lf).filter(([,files]) => files && files.length > 0); if (categories.length) { html += `
${esc(t('linked_files'))}
`; for (const [cat, files] of categories) { html += `

${esc(cat)}

`; for (const f of files) { html += `${esc(f)}`; } html += '
'; } html += '
'; } $('previewMd').innerHTML = html; // Wire linked-file clicks via data attributes (avoids inline JS XSS with apostrophes) $('previewMd').querySelectorAll('.skill-linked-file').forEach(a=>{ a.addEventListener('click',e=>{e.preventDefault();openSkillFile(a.dataset.skillName,a.dataset.skillFile);}); }); $('previewArea').classList.add('visible'); $('fileTree').style.display = 'none'; } catch(e) { setStatus(t('skill_load_failed') + e.message); } } async function openSkillFile(skillName, filePath) { try { const data = await api(`/api/skills/content?name=${encodeURIComponent(skillName)}&file=${encodeURIComponent(filePath)}`); $('previewPathText').textContent = skillName + ' / ' + filePath; $('previewBadge').textContent = filePath.split('.').pop() || 'file'; $('previewBadge').className = 'preview-badge code'; const ext = filePath.split('.').pop() || ''; if (['md','markdown'].includes(ext)) { showPreview('md'); $('previewMd').innerHTML = renderMd(data.content || ''); } else { showPreview('code'); $('previewCode').textContent = data.content || ''; requestAnimationFrame(() => highlightCode()); } } catch(e) { setStatus(t('skill_file_load_failed') + e.message); } } // ── Skill create/edit form ── let _editingSkillName = null; function toggleSkillForm(prefillName, prefillCategory, prefillContent) { const form = $('skillCreateForm'); if (!form) return; const open = form.style.display !== 'none'; if (open) { form.style.display = 'none'; _editingSkillName = null; return; } $('skillFormName').value = prefillName || ''; $('skillFormCategory').value = prefillCategory || ''; $('skillFormContent').value = prefillContent || ''; $('skillFormError').style.display = 'none'; _editingSkillName = prefillName || null; form.style.display = ''; $('skillFormName').focus(); } async function submitSkillSave() { const name = ($('skillFormName').value||'').trim().toLowerCase().replace(/\s+/g, '-'); const category = ($('skillFormCategory').value||'').trim(); const content = $('skillFormContent').value; const errEl = $('skillFormError'); errEl.style.display = 'none'; if (!name) { errEl.textContent = t('skill_name_required'); errEl.style.display = ''; return; } if (!content.trim()) { errEl.textContent = t('content_required'); errEl.style.display = ''; return; } try { await api('/api/skills/save', {method:'POST', body: JSON.stringify({name, category: category||undefined, content})}); showToast(_editingSkillName ? t('skill_updated') : t('skill_created')); _skillsData = null; _cronSkillsCache = null; toggleSkillForm(); await loadSkills(); } catch(e) { errEl.textContent = t('error_prefix') + e.message; errEl.style.display = ''; } } // ── Memory inline edit ── let _memoryData = null; function toggleMemoryEdit() { const form = $('memoryEditForm'); if (!form) return; const open = form.style.display !== 'none'; if (open) { form.style.display = 'none'; return; } $('memEditSection').textContent = t('memory_notes_label'); $('memEditContent').value = _memoryData ? (_memoryData.memory || '') : ''; $('memEditError').style.display = 'none'; form.style.display = ''; } function closeMemoryEdit() { const form = $('memoryEditForm'); if (form) form.style.display = 'none'; } async function submitMemorySave() { const content = $('memEditContent').value; const errEl = $('memEditError'); errEl.style.display = 'none'; try { await api('/api/memory/write', {method:'POST', body: JSON.stringify({section: 'memory', content})}); showToast(t('memory_saved')); closeMemoryEdit(); await loadMemory(true); } catch(e) { errEl.textContent = t('error_prefix') + e.message; errEl.style.display = ''; } } // ── Workspace management ── let _workspaceList = []; // cached from /api/workspaces let _wsSuggestTimer = null; let _wsSuggestReq = 0; let _wsSuggestIndex = -1; function closeWorkspacePathSuggestions(){ const box=$('wsAddSuggestions'); if(box){ box.innerHTML=''; box.style.display='none'; } _wsSuggestIndex=-1; } function _applyWorkspaceSuggestion(path){ const input=$('wsAddInput'); const next=(path||'').endsWith('/')?(path||''):`${path||''}/`; if(input){ input.value=next; input.focus(); input.setSelectionRange(next.length, next.length); } scheduleWorkspacePathSuggestions(); } function _highlightWorkspaceSuggestion(idx){ const box=$('wsAddSuggestions'); if(!box)return; const items=[...box.querySelectorAll('.ws-suggest-item')]; items.forEach((el,i)=>{ const active=i===idx; el.classList.toggle('active', active); if(active) el.scrollIntoView({block:'nearest'}); }); } function _renderWorkspacePathSuggestions(paths){ const box=$('wsAddSuggestions'); if(!box)return; box.innerHTML=''; if(!paths || !paths.length){ box.style.display='none'; _wsSuggestIndex=-1; return; } paths.forEach((path, idx)=>{ const pathParts=(path||'').split('/').filter(Boolean); const leaf=pathParts[pathParts.length-1]||path; const parent=pathParts.length>1?`/${pathParts.slice(0,-1).join('/')}`:'/'; const item=document.createElement('button'); item.type='button'; item.className='ws-suggest-item'; item.innerHTML=`${esc(leaf)}${esc(parent)}`; item.dataset.path=path; item.onmouseenter=()=>{_wsSuggestIndex=idx;_highlightWorkspaceSuggestion(idx);}; item.onmousedown=(e)=>{e.preventDefault();_applyWorkspaceSuggestion(path);}; box.appendChild(item); }); box.style.display='block'; _wsSuggestIndex=0; _highlightWorkspaceSuggestion(_wsSuggestIndex); } async function _loadWorkspacePathSuggestions(prefix){ const reqId=++_wsSuggestReq; try{ const qs=new URLSearchParams({prefix:prefix||''}).toString(); const data=await api(`/api/workspaces/suggest?${qs}`); if(reqId!==_wsSuggestReq)return; _renderWorkspacePathSuggestions(data.suggestions||[]); }catch(_){ if(reqId!==_wsSuggestReq)return; closeWorkspacePathSuggestions(); } } function scheduleWorkspacePathSuggestions(){ const input=$('wsAddInput'); if(!input)return; const prefix=input.value.trim(); if(!prefix){ closeWorkspacePathSuggestions(); return; } if(_wsSuggestTimer) clearTimeout(_wsSuggestTimer); _wsSuggestTimer=setTimeout(()=>{ _loadWorkspacePathSuggestions(prefix); }, 120); } function getWorkspaceFriendlyName(path){ // Look up the friendly name from the workspace list cache, fallback to last path segment if(_workspaceList && _workspaceList.length){ const match=_workspaceList.find(w=>w.path===path); if(match && match.name) return match.name; } return path.split('/').filter(Boolean).pop()||path; } function syncWorkspaceDisplays(){ const hasSession=!!(S.session&&S.session.workspace); // Fall back to the profile default workspace when no session is active yet. // S._profileDefaultWorkspace is set during boot and profile switches from /api/settings. const defaultWs=(typeof S._profileDefaultWorkspace==='string'&&S._profileDefaultWorkspace)||''; const ws=hasSession?S.session.workspace:(defaultWs||''); const hasWorkspace=!!(ws); const label=hasWorkspace?getWorkspaceFriendlyName(ws):t('no_workspace'); const sidebarName=$('sidebarWsName'); const sidebarPath=$('sidebarWsPath'); if(sidebarName) sidebarName.textContent=label; if(sidebarPath) sidebarPath.textContent=ws; const composerChip=$('composerWorkspaceChip'); const composerLabel=$('composerWorkspaceLabel'); const composerDropdown=$('composerWsDropdown'); if(!hasWorkspace && composerDropdown) composerDropdown.classList.remove('open'); // Only show workspace label once boot has finished to prevent // flash of "No workspace" before the saved session finishes loading. if(composerLabel) composerLabel.textContent=S._bootReady?label:''; if(composerChip){ composerChip.disabled=!hasWorkspace; composerChip.title=hasWorkspace?ws:t('no_workspace'); composerChip.classList.toggle('active',!!(composerDropdown&&composerDropdown.classList.contains('open'))); } } async function loadWorkspaceList(){ try{ const data = await api('/api/workspaces'); _workspaceList = data.workspaces || []; syncWorkspaceDisplays(); return data; }catch(e){ return {workspaces:[], last:''}; } } function _renderWorkspaceAction(label, meta, iconSvg, onClick){ const opt=document.createElement('div'); opt.className='ws-opt ws-opt-action'; opt.innerHTML=`${iconSvg}${esc(label)}${meta?`${esc(meta)}`:''}`; opt.onclick=onClick; return opt; } function _positionComposerWsDropdown(){ const dd=$('composerWsDropdown'); const chip=$('composerWorkspaceChip'); const footer=document.querySelector('.composer-footer'); if(!dd||!chip||!footer)return; const chipRect=chip.getBoundingClientRect(); const footerRect=footer.getBoundingClientRect(); let left=chipRect.left-footerRect.left; const maxLeft=Math.max(0, footer.clientWidth-dd.offsetWidth); left=Math.max(0, Math.min(left, maxLeft)); dd.style.left=`${left}px`; } function _positionProfileDropdown(){ const dd=$('profileDropdown'); const chip=$('profileChip'); const footer=document.querySelector('.composer-footer'); if(!dd||!chip||!footer)return; const chipRect=chip.getBoundingClientRect(); const footerRect=footer.getBoundingClientRect(); let left=chipRect.left-footerRect.left; const maxLeft=Math.max(0, footer.clientWidth-dd.offsetWidth); left=Math.max(0, Math.min(left, maxLeft)); dd.style.left=`${left}px`; } function renderWorkspaceDropdownInto(dd, workspaces, currentWs){ if(!dd)return; dd.innerHTML=''; for(const w of workspaces){ const opt=document.createElement('div'); opt.className='ws-opt'+(w.path===currentWs?' active':''); opt.innerHTML=`${esc(w.name)}${esc(w.path)}`; opt.onclick=()=>switchToWorkspace(w.path,w.name); dd.appendChild(opt); } dd.appendChild(document.createElement('div')).className='ws-divider'; dd.appendChild(_renderWorkspaceAction( t('workspace_choose_path'), t('workspace_choose_path_meta'), li('folder',12), ()=>promptWorkspacePath() )); const div=document.createElement('div');div.className='ws-divider';dd.appendChild(div); dd.appendChild(_renderWorkspaceAction( t('workspace_manage'), t('workspace_manage_meta'), li('settings',12), ()=>{closeWsDropdown();mobileSwitchPanel('workspaces');} )); } function toggleWsDropdown(){ const dd=$('wsDropdown'); if(!dd)return; const open=dd.classList.contains('open'); if(open){closeWsDropdown();} else{ closeProfileDropdown(); // close profile dropdown if open loadWorkspaceList().then(data=>{ renderWorkspaceDropdownInto(dd, data.workspaces, S.session?S.session.workspace:''); dd.classList.add('open'); }); } } function toggleComposerWsDropdown(){ const dd=$('composerWsDropdown'); const chip=$('composerWorkspaceChip'); if(!dd||!chip||chip.disabled)return; const open=dd.classList.contains('open'); if(open){closeWsDropdown();} else{ closeProfileDropdown(); if(typeof closeModelDropdown==='function') closeModelDropdown(); loadWorkspaceList().then(data=>{ renderWorkspaceDropdownInto(dd, data.workspaces, S.session?S.session.workspace:''); dd.classList.add('open'); _positionComposerWsDropdown(); chip.classList.add('active'); }); } } function closeWsDropdown(){ const dd=$('wsDropdown'); const composerDd=$('composerWsDropdown'); const composerChip=$('composerWorkspaceChip'); if(dd)dd.classList.remove('open'); if(composerDd)composerDd.classList.remove('open'); if(composerChip)composerChip.classList.remove('active'); } document.addEventListener('click',e=>{ if( !e.target.closest('#composerWorkspaceChip') && !e.target.closest('#composerWsDropdown') ) closeWsDropdown(); }); window.addEventListener('resize',()=>{ const dd=$('composerWsDropdown'); if(dd&&dd.classList.contains('open')) _positionComposerWsDropdown(); }); async function loadWorkspacesPanel(){ const panel=$('workspacesPanel'); if(!panel)return; const data=await loadWorkspaceList(); renderWorkspacesPanel(data.workspaces); } function renderWorkspacesPanel(workspaces){ const panel=$('workspacesPanel'); panel.innerHTML=''; for(const w of workspaces){ const row=document.createElement('div');row.className='ws-row'; row.innerHTML=`
${esc(w.name)}
${esc(w.path)}
`; panel.appendChild(row); } const addRow=document.createElement('div');addRow.className='ws-add-row'; addRow.innerHTML=`
`; panel.appendChild(addRow); const suggestBox=document.createElement('div'); suggestBox.id='wsAddSuggestions'; suggestBox.className='ws-suggestions'; suggestBox.style.display='none'; panel.appendChild(suggestBox); const hint=document.createElement('div'); hint.style.cssText='font-size:11px;color:var(--muted);padding:4px 0 8px'; hint.textContent=t('workspace_paths_validated_hint'); panel.appendChild(hint); const input=$('wsAddInput'); if(input){ input.oninput=()=>scheduleWorkspacePathSuggestions(); input.onfocus=()=>{ if(input.value.trim()) scheduleWorkspacePathSuggestions(); else closeWorkspacePathSuggestions(); }; input.onkeydown=(e)=>{ const box=$('wsAddSuggestions'); const items=box?[...box.querySelectorAll('.ws-suggest-item')]:[]; if(!items.length){ if(e.key==='Enter'){ e.preventDefault(); addWorkspace(); } return; } if(e.key==='ArrowDown'){ e.preventDefault(); _wsSuggestIndex=Math.min(items.length-1,Math.max(-1,_wsSuggestIndex)+1); _highlightWorkspaceSuggestion(_wsSuggestIndex); return; } if(e.key==='ArrowUp'){ e.preventDefault(); _wsSuggestIndex=_wsSuggestIndex<=0?0:_wsSuggestIndex-1; _highlightWorkspaceSuggestion(_wsSuggestIndex); return; } if(e.key==='Escape'){ e.preventDefault(); closeWorkspacePathSuggestions(); return; } if(e.key==='Enter'){ e.preventDefault(); if(_wsSuggestIndex>=0 && items[_wsSuggestIndex]){ _applyWorkspaceSuggestion(items[_wsSuggestIndex].dataset.path||''); }else{ addWorkspace(); } return; } if(e.key==='Tab' && _wsSuggestIndex>=0 && items[_wsSuggestIndex]){ e.preventDefault(); _applyWorkspaceSuggestion(items[_wsSuggestIndex].dataset.path||''); return; } }; } } async function addWorkspace(){ const input=$('wsAddInput'); const path=(input?input.value:'').trim(); if(!path)return; try{ const data=await api('/api/workspaces/add',{method:'POST',body:JSON.stringify({path})}); _workspaceList=data.workspaces; renderWorkspacesPanel(data.workspaces); if(input)input.value=''; closeWorkspacePathSuggestions(); showToast(t('workspace_added')); }catch(e){setStatus(t('add_failed')+e.message);} } document.addEventListener('click',e=>{ if(!e.target.closest('.ws-add-input-wrap')) closeWorkspacePathSuggestions(); }); async function removeWorkspace(path){ const _rmWs=await showConfirmDialog({title:t('workspace_remove_confirm_title'),message:t('workspace_remove_confirm_message',path),confirmLabel:t('remove'),danger:true,focusCancel:true}); if(!_rmWs) return; try{ const data=await api('/api/workspaces/remove',{method:'POST',body:JSON.stringify({path})}); _workspaceList=data.workspaces; renderWorkspacesPanel(data.workspaces); showToast(t('workspace_removed')); }catch(e){setStatus(t('remove_failed')+e.message);} } async function promptWorkspacePath(){ // Opus review Q6: if called from blank page (no session), auto-create one first. if(!S.session){ const ws=(typeof S._profileDefaultWorkspace==='string'&&S._profileDefaultWorkspace)||''; if(!ws)return; try{ const r=await api('/api/session/new',{method:'POST',body:JSON.stringify({workspace:ws})}); if(r&&r.session){S.session=r.session;S.messages=[];if(typeof syncTopbar==='function')syncTopbar();if(typeof renderMessages==='function')renderMessages();if(typeof renderSessionList==='function')await renderSessionList();} }catch(e){showToast(t('workspace_switch_failed')+e.message);return;} if(!S.session)return; } const value=await showPromptDialog({ title:t('workspace_switch_prompt_title'), message:t('workspace_switch_prompt_message'), confirmLabel:t('workspace_switch_prompt_confirm'), placeholder:t('workspace_switch_prompt_placeholder'), value:S.session.workspace||'' }); const path=(value||'').trim(); if(!path)return; try{ const data=await api('/api/workspaces/add',{method:'POST',body:JSON.stringify({path})}); _workspaceList=data.workspaces||[]; const target=_workspaceList[_workspaceList.length-1]; if(!target) throw new Error(t('workspace_not_added')); await switchToWorkspace(target.path,target.name); }catch(e){ if(String(e.message||'').includes('Workspace already in list')){ showToast(t('workspace_already_saved')); return; } showToast(t('workspace_switch_failed')+e.message); } } async function switchToWorkspace(path,name){ // Opus review Q6: if called from blank page, auto-create a session bound to // the requested workspace so the switch doesn't silently no-op. if(!S.session){ const ws=path||(typeof S._profileDefaultWorkspace==='string'&&S._profileDefaultWorkspace)||''; if(!ws){showToast(t('no_workspace'));return;} try{ const r=await api('/api/session/new',{method:'POST',body:JSON.stringify({workspace:ws})}); if(r&&r.session){S.session=r.session;S.messages=[];if(typeof syncTopbar==='function')syncTopbar();if(typeof renderMessages==='function')renderMessages();if(typeof renderSessionList==='function')await renderSessionList();} }catch(e){if(typeof setStatus==='function')setStatus(t('switch_failed')+e.message);return;} if(!S.session)return; } if(S.busy){ showToast(t('workspace_busy_switch')); return; } if(typeof _previewDirty!=='undefined'&&_previewDirty){ const discard=await showConfirmDialog({ title:t('discard_file_edits_title'), message:t('discard_file_edits_message'), confirmLabel:t('discard'), danger:true }); if(!discard)return; if(typeof cancelEditMode==='function')cancelEditMode(); if(typeof clearPreview==='function')clearPreview(); } try{ closeWsDropdown(); await api('/api/session/update',{method:'POST',body:JSON.stringify({ session_id:S.session.session_id, workspace:path, model:S.session.model })}); S.session.workspace=path; // Explicit workspace switch = user overriding any pending profile-switch default. // Clear the one-shot flag so a subsequent newSession() inherits this choice instead. S._profileSwitchWorkspace=null; syncTopbar(); await loadDir('.'); showToast(t('workspace_switched_to',name||getWorkspaceFriendlyName(path))); }catch(e){setStatus(t('switch_failed')+e.message);} } // ── Profile panel + dropdown ── let _profilesCache = null; async function loadProfilesPanel() { const panel = $('profilesPanel'); if (!panel) return; try { const data = await api('/api/profiles'); _profilesCache = data; panel.innerHTML = ''; if (!data.profiles || !data.profiles.length) { panel.innerHTML = `
${esc(t('profiles_no_profiles'))}
`; return; } const activeName = (S.activeProfile && data.profiles.some(p => p.name === S.activeProfile)) ? S.activeProfile : (data.active || 'default'); for (const p of data.profiles) { const card = document.createElement('div'); card.className = 'profile-card'; const meta = []; if (p.model) meta.push(p.model.split('/').pop()); if (p.provider) meta.push(p.provider); if (p.skill_count) meta.push(t('profile_skill_count', p.skill_count)); if (p.has_env) meta.push(t('profile_api_keys_configured')); const gwDot = p.gateway_running ? `` : ``; const isActive = p.name === activeName; const activeBadge = isActive ? `${esc(t('profile_active'))}` : ''; const defaultBadge = p.is_default ? ` ${esc(t('profile_default_label'))}` : ''; card.innerHTML = `
${gwDot}${esc(p.name)}${defaultBadge}${activeBadge}
${meta.length ? `
${esc(meta.join(' \u00b7 '))}
` : `
${esc(t('profile_no_configuration'))}
`}
${!isActive ? `` : ''} ${!p.is_default ? `` : ''}
`; panel.appendChild(card); } } catch (e) { panel.innerHTML = `
${esc(t('error_prefix'))}${esc(e.message)}
`; } } function renderProfileDropdown(data) { const dd = $('profileDropdown'); if (!dd) return; dd.innerHTML = ''; const profiles = data.profiles || []; const active = (S.activeProfile && profiles.some(p => p.name === S.activeProfile)) ? S.activeProfile : (data.active || 'default'); for (const p of profiles) { const opt = document.createElement('div'); opt.className = 'profile-opt' + (p.name === active ? ' active' : ''); const meta = []; if (p.model) meta.push(p.model.split('/').pop()); if (p.skill_count) meta.push(t('profile_skill_count', p.skill_count)); const gwDot = ``; const checkmark = p.name === active ? ' ' : ''; const defaultBadge = p.is_default ? ` ${esc(t('profile_default_label'))}` : ''; opt.innerHTML = `
${gwDot}${esc(p.name)}${defaultBadge}${checkmark}
` + (meta.length ? `
${esc(meta.join(' \u00b7 '))}
` : ''); opt.onclick = async () => { closeProfileDropdown(); if (p.name === active) return; await switchToProfile(p.name); }; dd.appendChild(opt); } // Divider + Manage link const div = document.createElement('div'); div.className = 'ws-divider'; dd.appendChild(div); const mgmt = document.createElement('div'); mgmt.className = 'profile-opt ws-manage'; mgmt.innerHTML = `${li('settings',12)} ${esc(t('manage_profiles'))}`; mgmt.onclick = () => { closeProfileDropdown(); mobileSwitchPanel('profiles'); }; dd.appendChild(mgmt); } function toggleProfileDropdown() { const dd = $('profileDropdown'); if (!dd) return; if (dd.classList.contains('open')) { closeProfileDropdown(); return; } closeWsDropdown(); // close workspace dropdown if open if(typeof closeModelDropdown==='function') closeModelDropdown(); api('/api/profiles').then(data => { renderProfileDropdown(data); dd.classList.add('open'); _positionProfileDropdown(); const chip=$('profileChip'); if(chip) chip.classList.add('active'); }).catch(e => { showToast(t('profiles_load_failed')); }); } function closeProfileDropdown() { const dd = $('profileDropdown'); if (dd) dd.classList.remove('open'); const chip=$('profileChip'); if(chip) chip.classList.remove('active'); } document.addEventListener('click', e => { if (!e.target.closest('#profileChipWrap') && !e.target.closest('#profileDropdown')) closeProfileDropdown(); }); window.addEventListener('resize',()=>{ const dd=$('profileDropdown'); if(dd&&dd.classList.contains('open')) _positionProfileDropdown(); }); async function switchToProfile(name) { if (S.busy) { showToast(t('profiles_busy_switch')); return; } // Determine whether the current session has any messages. // A session with messages is "in progress" and belongs to the current profile — // we must not retag it. We'll start a fresh session for the new profile instead. const sessionInProgress = S.session && S.messages && S.messages.length > 0; try { const data = await api('/api/profile/switch', { method: 'POST', body: JSON.stringify({ name }) }); S.activeProfile = data.active || name; // ── Model ────────────────────────────────────────────────────────────── localStorage.removeItem('hermes-webui-model'); _skillsData = null; await populateModelDropdown(); if (data.default_model) { const sel = $('modelSelect'); const resolved = _applyModelToDropdown(data.default_model, sel); const modelToUse = resolved || data.default_model; S._pendingProfileModel = modelToUse; // Only patch the in-memory session model if we're NOT about to replace the session if (S.session && !sessionInProgress) { S.session.model = modelToUse; } } // ── Workspace ────────────────────────────────────────────────────────── _workspaceList = null; await loadWorkspaceList(); if (data.default_workspace) { // Always store the persistent profile default — used for blank-page display // and workspace auto-bind throughout the session lifecycle (#804, #823). S._profileDefaultWorkspace = data.default_workspace; // Also set the one-shot flag consumed by newSession() so the first new // session after a profile switch inherits this workspace (#424). S._profileSwitchWorkspace = data.default_workspace; if (S.session && !sessionInProgress) { // Empty session (no messages yet) — safe to update it in place try { await api('/api/session/update', { method: 'POST', body: JSON.stringify({ session_id: S.session.session_id, workspace: data.default_workspace, model: S.session.model, })}); S.session.workspace = data.default_workspace; } catch (_) {} } } // ── Session ──────────────────────────────────────────────────────────── _showAllProfiles = false; if (sessionInProgress) { // The current session has messages and belongs to the previous profile. // Start a new session for the new profile so nothing gets cross-tagged. await newSession(false); // Apply profile default workspace to the newly created session (fixes #424) if (S._profileDefaultWorkspace && S.session) { try { await api('/api/session/update', { method: 'POST', body: JSON.stringify({ session_id: S.session.session_id, workspace: S._profileDefaultWorkspace, model: S.session.model, })}); S.session.workspace = S._profileDefaultWorkspace; } catch (_) {} } // Keep topbar chips (workspace/profile) in sync after creating the // new profile-scoped session. syncTopbar(); await renderSessionList(); showToast(t('profile_switched_new_conversation', name)); } else { // No messages yet — just refresh the list and topbar in place await renderSessionList(); syncTopbar(); showToast(t('profile_switched', name)); } // ── Sidebar panels ───────────────────────────────────────────────────── if (_currentPanel === 'skills') await loadSkills(); if (_currentPanel === 'memory') await loadMemory(); if (_currentPanel === 'tasks') await loadCrons(); if (_currentPanel === 'profiles') await loadProfilesPanel(); if (_currentPanel === 'workspaces') await loadWorkspacesPanel(); } catch (e) { showToast(t('switch_failed') + e.message); } } function toggleProfileForm() { const form = $('profileCreateForm'); if (!form) return; form.style.display = form.style.display === 'none' ? '' : 'none'; if (form.style.display !== 'none') { $('profileFormName').value = ''; $('profileFormClone').checked = false; if ($('profileFormBaseUrl')) $('profileFormBaseUrl').value = ''; if ($('profileFormApiKey')) $('profileFormApiKey').value = ''; const errEl = $('profileFormError'); if (errEl) errEl.style.display = 'none'; $('profileFormName').focus(); } } async function submitProfileCreate() { const name = ($('profileFormName').value || '').trim().toLowerCase(); const cloneConfig = $('profileFormClone').checked; const errEl = $('profileFormError'); if (!name) { errEl.textContent = t('name_required'); errEl.style.display = ''; return; } if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(name)) { errEl.textContent = t('profile_name_rule'); errEl.style.display = ''; return; } try { const baseUrl = (($('profileFormBaseUrl') && $('profileFormBaseUrl').value) || '').trim(); const apiKey = (($('profileFormApiKey') && $('profileFormApiKey').value) || '').trim(); if (baseUrl && !/^https?:\/\//.test(baseUrl)) { errEl.textContent = t('profile_base_url_rule'); errEl.style.display = ''; return; } const payload = { name, clone_config: cloneConfig }; if (baseUrl) payload.base_url = baseUrl; if (apiKey) payload.api_key = apiKey; await api('/api/profile/create', { method: 'POST', body: JSON.stringify(payload) }); toggleProfileForm(); await loadProfilesPanel(); showToast(t('profile_created', name)); } catch (e) { errEl.textContent = e.message || t('create_failed'); errEl.style.display = ''; } } async function deleteProfile(name) { const _delProf=await showConfirmDialog({title:t('profile_delete_confirm_title',name),message:t('profile_delete_confirm_message'),confirmLabel:t('delete_title'),danger:true,focusCancel:true}); if(!_delProf) return; try { await api('/api/profile/delete', { method: 'POST', body: JSON.stringify({ name }) }); await loadProfilesPanel(); showToast(t('profile_deleted', name)); } catch (e) { showToast(t('delete_failed') + e.message); } } // ── Memory panel ── async function loadMemory(force) { const panel = $('memoryPanel'); try { const data = await api('/api/memory'); _memoryData = data; // cache for edit form const fmtTime = ts => ts ? new Date(ts*1000).toLocaleString() : ''; panel.innerHTML = `
${li('brain',14)} ${esc(t('my_notes'))} ${fmtTime(data.memory_mtime)}
${data.memory ? `
${renderMd(data.memory)}
` : `
${esc(t('no_notes_yet'))}
`}
${li('user',14)} ${esc(t('user_profile'))} ${fmtTime(data.user_mtime)}
${data.user ? `
${renderMd(data.user)}
` : `
${esc(t('no_profile_yet'))}
`}
`; } catch(e) { panel.innerHTML = `
${esc(t('error_prefix'))}${esc(e.message)}
`; } } // Drag and drop const wrap=$('composerWrap');let dragCounter=0; document.addEventListener('dragover',e=>e.preventDefault()); document.addEventListener('dragenter',e=>{e.preventDefault();if(e.dataTransfer.types.includes('Files')){dragCounter++;wrap.classList.add('drag-over');}}); document.addEventListener('dragleave',e=>{dragCounter--;if(dragCounter<=0){dragCounter=0;wrap.classList.remove('drag-over');}}); document.addEventListener('drop',e=>{e.preventDefault();dragCounter=0;wrap.classList.remove('drag-over');const files=Array.from(e.dataTransfer.files);if(files.length){addFiles(files);$('msg').focus();}}); // ── Settings panel ─────────────────────────────────────────────────────────── let _settingsDirty = false; let _settingsThemeOnOpen = null; // track theme at open time for discard revert let _settingsSkinOnOpen = null; // track skin at open time for discard revert let _settingsFontSizeOnOpen = null; // track font size at open time for discard revert let _settingsHermesDefaultModelOnOpen = ''; let _settingsSection = 'conversation'; function switchSettingsSection(name){ const section=(name==='appearance'||name==='preferences'||name==='providers'||name==='system')?name:'conversation'; _settingsSection=section; const map={conversation:'Conversation',appearance:'Appearance',preferences:'Preferences',providers:'Providers',system:'System'}; ['conversation','appearance','preferences','providers','system'].forEach(key=>{ const tab=$('settingsTab'+map[key]); const pane=$('settingsPane'+map[key]); const active=key===section; if(tab){ tab.classList.toggle('active',active); tab.setAttribute('aria-selected',active?'true':'false'); } if(pane) pane.classList.toggle('active',active); }); // Lazy-load providers when the tab is opened if(section==='providers') loadProvidersPanel(); } function _syncHermesPanelSessionActions(){ const hasSession=!!S.session; const visibleMessages=hasSession?(S.messages||[]).filter(m=>m&&m.role&&m.role!=='tool').length:0; const title=hasSession?(S.session.title||t('untitled')):t('active_conversation_none'); const meta=$('hermesSessionMeta'); if(meta){ meta.textContent=hasSession ? t('active_conversation_meta', title, visibleMessages) : t('active_conversation_none'); } const setDisabled=(id,disabled)=>{ const el=$(id); if(!el)return; el.disabled=!!disabled; el.classList.toggle('disabled',!!disabled); }; setDisabled('btnDownload',!hasSession||visibleMessages===0); setDisabled('btnExportJSON',!hasSession); setDisabled('btnClearConvModal',!hasSession||visibleMessages===0); } function toggleSettings(){ const overlay=$('settingsOverlay'); if(!overlay) return; if(overlay.style.display==='none'){ _settingsDirty = false; _settingsThemeOnOpen = localStorage.getItem('hermes-theme') || 'dark'; _settingsSkinOnOpen = localStorage.getItem('hermes-skin') || 'default'; _settingsFontSizeOnOpen = localStorage.getItem('hermes-font-size') || 'default'; _settingsSection = 'conversation'; overlay.style.display=''; loadSettingsPanel(); } else { _closeSettingsPanel(); } } function _resetSettingsPanelState(){ _settingsSection = 'conversation'; switchSettingsSection('conversation'); const bar=$('settingsUnsavedBar'); if(bar) bar.style.display='none'; } function _hideSettingsPanel(){ const overlay=$('settingsOverlay'); if(!overlay) return; _resetSettingsPanelState(); overlay.style.display='none'; } // Close with unsaved-changes check. If dirty, show a confirm dialog. function _closeSettingsPanel(){ if(!_settingsDirty){ // Nothing changed -- revert any live preview and close _revertSettingsPreview(); _hideSettingsPanel(); return; } // Dirty -- show inline confirm bar _showSettingsUnsavedBar(); } // Revert live DOM/localStorage to what they were when the panel opened function _revertSettingsPreview(){ if(_settingsThemeOnOpen){ localStorage.setItem('hermes-theme', _settingsThemeOnOpen); if(typeof _applyTheme==='function') _applyTheme(_settingsThemeOnOpen); } if(_settingsSkinOnOpen){ localStorage.setItem('hermes-skin', _settingsSkinOnOpen); if(typeof _applySkin==='function') _applySkin(_settingsSkinOnOpen); } if(_settingsFontSizeOnOpen){ localStorage.setItem('hermes-font-size', _settingsFontSizeOnOpen); if(typeof _applyFontSize==='function') _applyFontSize(_settingsFontSizeOnOpen); } } // Show the "Unsaved changes" bar inside the settings panel function _showSettingsUnsavedBar(){ let bar = $('settingsUnsavedBar'); if(bar){ bar.style.display=''; return; } // Create it bar = document.createElement('div'); bar.id = 'settingsUnsavedBar'; bar.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;background:rgba(233,69,96,.12);border:1px solid rgba(233,69,96,.3);border-radius:8px;padding:10px 14px;margin:0 0 12px;font-size:13px;'; bar.innerHTML = `${esc(t('settings_unsaved_changes'))}` + '' + `` + `` + ''; const body = document.querySelector('.settings-main') || document.querySelector('.settings-body') || document.querySelector('.settings-panel'); if(body) body.prepend(bar); } function _discardSettings(){ _revertSettingsPreview(); _settingsDirty = false; _hideSettingsPanel(); } // Mark settings as dirty whenever anything changes function _markSettingsDirty(){ _settingsDirty = true; } async function loadSettingsPanel(){ try{ const settings=await api('/api/settings'); // Populate the version badge from the server — keeps it in sync with git // tags automatically without any manual release step. const vbadge=document.querySelector('.settings-version-badge'); if(vbadge && settings.webui_version) vbadge.textContent=settings.webui_version; // Hydrate appearance controls first so a slow /api/models request // cannot overwrite an in-progress theme/skin selection. const themeSel=$('settingsTheme'); const themeVal=settings.theme||'dark'; if(themeSel) themeSel.value=themeVal; if(typeof _syncThemePicker==='function') _syncThemePicker(themeVal); const skinVal=(settings.skin||'default').toLowerCase(); const skinSel=$('settingsSkin'); if(skinSel) skinSel.value=skinVal; if(typeof _buildSkinPicker==='function') _buildSkinPicker(skinVal); const fontSizeVal=localStorage.getItem('hermes-font-size')||'default'; const fontSizeSel=$('settingsFontSize'); if(fontSizeSel) fontSizeSel.value=fontSizeVal; if(typeof _syncFontSizePicker==='function') _syncFontSizePicker(fontSizeVal); const resolvedLanguage=(typeof resolvePreferredLocale==='function') ? resolvePreferredLocale(settings.language, localStorage.getItem('hermes-lang')) : (settings.language || localStorage.getItem('hermes-lang') || 'en'); // Keep settings modal and current page strings in sync with the resolved locale. if(typeof setLocale==='function'){ setLocale(resolvedLanguage); if(typeof applyLocaleToDOM==='function') applyLocaleToDOM(); } // Populate model dropdown from /api/models const modelSel=$('settingsModel'); if(modelSel){ modelSel.innerHTML=''; let models=null; try{ models=await api('/api/models'); for(const g of ((models||{}).groups||[])){ const og=document.createElement('optgroup'); og.label=g.provider; for(const m of g.models){ const opt=document.createElement('option'); opt.value=m.id;opt.textContent=m.label; og.appendChild(opt); } modelSel.appendChild(og); } }catch(e){} _settingsHermesDefaultModelOnOpen=(models&&models.default_model)||''; modelSel.value=_settingsHermesDefaultModelOnOpen; modelSel.addEventListener('change',_markSettingsDirty,{once:false}); } // Send key preference const sendKeySel=$('settingsSendKey'); if(sendKeySel){sendKeySel.value=settings.send_key||'enter';sendKeySel.addEventListener('change',_markSettingsDirty,{once:false});} // Language preference — populate from LOCALES bundle const langSel=$('settingsLanguage'); if(langSel){ langSel.innerHTML=''; if(typeof LOCALES!=='undefined'){ for(const [code,bundle] of Object.entries(LOCALES)){ const opt=document.createElement('option'); opt.value=code;opt.textContent=bundle._label||code; langSel.appendChild(opt); } } langSel.value=resolvedLanguage; langSel.addEventListener('change',_markSettingsDirty,{once:false}); } const showUsageCb=$('settingsShowTokenUsage'); if(showUsageCb){showUsageCb.checked=!!settings.show_token_usage;showUsageCb.addEventListener('change',_markSettingsDirty,{once:false});} const showCliCb=$('settingsShowCliSessions'); if(showCliCb){showCliCb.checked=!!settings.show_cli_sessions;showCliCb.addEventListener('change',_markSettingsDirty,{once:false});} const syncCb=$('settingsSyncInsights'); if(syncCb){syncCb.checked=!!settings.sync_to_insights;syncCb.addEventListener('change',_markSettingsDirty,{once:false});} const updateCb=$('settingsCheckUpdates'); if(updateCb){updateCb.checked=settings.check_for_updates!==false;updateCb.addEventListener('change',_markSettingsDirty,{once:false});} const soundCb=$('settingsSoundEnabled'); if(soundCb){soundCb.checked=!!settings.sound_enabled;soundCb.addEventListener('change',_markSettingsDirty,{once:false});} const notifCb=$('settingsNotificationsEnabled'); if(notifCb){notifCb.checked=!!settings.notifications_enabled;notifCb.addEventListener('change',_markSettingsDirty,{once:false});} // show_thinking has no settings panel checkbox — controlled via /reasoning show|hide const sidebarDensitySel=$('settingsSidebarDensity'); if(sidebarDensitySel){ sidebarDensitySel.value=settings.sidebar_density==='detailed'?'detailed':'compact'; sidebarDensitySel.addEventListener('change',_markSettingsDirty,{once:false}); } // Bot name const botNameField=$('settingsBotName'); if(botNameField){botNameField.value=settings.bot_name||'Hermes';botNameField.addEventListener('input',_markSettingsDirty,{once:false});} // Password field: always blank (we don't send hash back) const pwField=$('settingsPassword'); if(pwField){pwField.value='';pwField.addEventListener('input',_markSettingsDirty,{once:false});} // Show auth buttons only when auth is active try{ const authStatus=await api('/api/auth/status'); _setSettingsAuthButtonsVisible(!!authStatus.auth_enabled); }catch(e){} _syncHermesPanelSessionActions(); loadProvidersPanel(); // load provider cards in background switchSettingsSection(_settingsSection); }catch(e){ showToast(t('settings_load_failed')+e.message); } } // ── Providers panel ─────────────────────────────────────────────────────── const _providerCardEls = new Map(); // providerId → {card, statusDot, input, saveBtn, removeBtn} async function loadProvidersPanel(){ const list=$('providersList'); const empty=$('providersEmpty'); if(!list) return; try{ const data=await api('/api/providers'); const providers=(data.providers||[]).filter(p=>p.configurable); list.innerHTML=''; _providerCardEls.clear(); if(providers.length===0){ list.style.display='none'; if(empty) empty.style.display=''; return; } if(empty) empty.style.display='none'; list.style.display=''; for(const p of providers){ list.appendChild(_buildProviderCard(p)); } }catch(e){ list.innerHTML='
Failed to load providers: '+e.message+'
'; } } function _buildProviderCard(p){ const card=document.createElement('div'); card.className='provider-card'; card.dataset.provider=p.id; const isOauth=p.key_source==='oauth'; const statusColor=p.has_key?'var(--ok, #4ade80)':'var(--muted)'; const statusTitle=p.has_key?t('providers_status_configured'):t('providers_status_not_configured'); // Header row const header=document.createElement('div'); header.className='provider-card-header'; const info=document.createElement('div'); info.className='provider-card-info'; info.style.cssText='display:flex;align-items:center;gap:8px;'; const nameEl=document.createElement('span'); nameEl.className='provider-card-name'; nameEl.style.cssText='font-weight:600;font-size:13px;'; nameEl.textContent=p.display_name; const dot=document.createElement('span'); dot.className='provider-card-dot'; dot.title=statusTitle; dot.style.cssText='width:8px;height:8px;border-radius:50%;background:'+statusColor+';display:inline-block;flex-shrink:0'; const sourceEl=document.createElement('span'); sourceEl.className='provider-card-source'; sourceEl.style.cssText='font-size:11px;color:var(--muted)'; sourceEl.textContent=isOauth?t('providers_status_oauth'):(p.has_key?t('providers_status_api_key'):t('providers_status_not_configured_label')); info.appendChild(nameEl); info.appendChild(dot); info.appendChild(sourceEl); header.appendChild(info); card.appendChild(header); if(isOauth){ const hint=document.createElement('div'); hint.style.cssText='font-size:11px;color:var(--muted);margin-top:4px;padding-left:2px'; hint.textContent=t('providers_oauth_hint'); card.appendChild(hint); }else{ const actions=document.createElement('div'); actions.className='provider-card-actions'; actions.style.cssText='margin-top:6px;display:flex;gap:6px;align-items:center'; const input=document.createElement('input'); input.type='password'; input.placeholder=p.has_key?t('providers_key_placeholder_replace'):t('providers_key_placeholder_new'); input.style.cssText='flex:1;padding:6px 8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:12px;font-family:monospace'; input.autocomplete='off'; const saveBtn=document.createElement('button'); saveBtn.className='sm-btn provider-save-btn'; saveBtn.style.cssText='padding:5px 12px;font-size:12px;white-space:nowrap'; saveBtn.textContent=t('providers_save'); saveBtn.onclick=()=>_saveProviderKey(p.id); actions.appendChild(input); actions.appendChild(saveBtn); if(p.has_key){ const removeBtn=document.createElement('button'); removeBtn.className='sm-btn'; removeBtn.style.cssText='padding:5px 10px;font-size:12px;color:var(--error);border-color:rgba(233,69,96,.25);white-space:nowrap'; removeBtn.textContent=t('providers_remove'); removeBtn.onclick=()=>_removeProviderKey(p.id); actions.appendChild(removeBtn); } card.appendChild(actions); _providerCardEls.set(p.id,{card,input,saveBtn,hasKey:p.has_key}); input.addEventListener('input',()=>{saveBtn.disabled=!input.value.trim();}); saveBtn.disabled=true; } return card; } async function _saveProviderKey(providerId){ const els=_providerCardEls.get(providerId); if(!els) return; const key=els.input.value.trim(); if(!key){ showToast(t('providers_enter_key')); return; } els.saveBtn.disabled=true; els.saveBtn.textContent=t('providers_saving'); try{ const res=await api('/api/providers',{method:'POST',body:JSON.stringify({provider:providerId,api_key:key})}); if(res.ok){ showToast(res.provider+' key '+res.action); els.input.value=''; await loadProvidersPanel(); // refresh list }else{ showToast(res.error||'Failed to save key'); els.saveBtn.disabled=false; els.saveBtn.textContent=t('providers_save'); } }catch(e){ showToast('Error: '+e.message); els.saveBtn.disabled=false; els.saveBtn.textContent=t('providers_save'); } } async function _removeProviderKey(providerId){ const els=_providerCardEls.get(providerId); if(!els) return; if(els.saveBtn){els.saveBtn.disabled=true;els.saveBtn.textContent=t('providers_removing');} try{ const res=await api('/api/providers/delete',{method:'POST',body:JSON.stringify({provider:providerId})}); if(res.ok){ showToast(res.provider+' key '+t('providers_key_removed').toLowerCase()); await loadProvidersPanel(); // refresh list }else{ showToast(res.error||'Failed to remove key'); if(els.saveBtn){els.saveBtn.disabled=false;els.saveBtn.textContent=t('providers_save');} } }catch(e){ showToast('Error: '+e.message); if(els.saveBtn){els.saveBtn.disabled=false;els.saveBtn.textContent=t('providers_save');} } } function _setSettingsAuthButtonsVisible(active){ const signOutBtn=$('btnSignOut'); if(signOutBtn) signOutBtn.style.display=active?'':'none'; const disableBtn=$('btnDisableAuth'); if(disableBtn) disableBtn.style.display=active?'':'none'; } function _applySavedSettingsUi(saved, body, opts){ const {sendKey,showTokenUsage,showCliSessions,theme,skin,language,sidebarDensity}=opts; window._sendKey=sendKey||'enter'; window._showTokenUsage=showTokenUsage; window._showCliSessions=showCliSessions; window._soundEnabled=body.sound_enabled; window._notificationsEnabled=body.notifications_enabled; window._showThinking=body.show_thinking!==false; window._sidebarDensity=sidebarDensity==='detailed'?'detailed':'compact'; window._botName=body.bot_name||'Hermes'; if(typeof applyBotName==='function') applyBotName(); if(typeof setLocale==='function') setLocale(language); if(typeof applyLocaleToDOM==='function') applyLocaleToDOM(); if(typeof startGatewaySSE==='function'){ if(showCliSessions) startGatewaySSE(); else if(typeof stopGatewaySSE==='function') stopGatewaySSE(); } _setSettingsAuthButtonsVisible(!!saved.auth_enabled); _settingsDirty=false; _settingsThemeOnOpen=theme; _settingsSkinOnOpen=skin||'default'; const bar=$('settingsUnsavedBar'); if(bar) bar.style.display='none'; _settingsHermesDefaultModelOnOpen=body.default_model||_settingsHermesDefaultModelOnOpen||''; renderMessages(); if(typeof syncTopbar==='function') syncTopbar(); if(typeof renderSessionList==='function') renderSessionList(); } async function saveSettings(andClose){ const model=($('settingsModel')||{}).value; const modelChanged=(model||'')!==(_settingsHermesDefaultModelOnOpen||''); const sendKey=($('settingsSendKey')||{}).value; const showTokenUsage=!!($('settingsShowTokenUsage')||{}).checked; const showCliSessions=!!($('settingsShowCliSessions')||{}).checked; const pw=($('settingsPassword')||{}).value; const theme=($('settingsTheme')||{}).value||'dark'; const skin=($('settingsSkin')||{}).value||'default'; const language=($('settingsLanguage')||{}).value||'en'; const sidebarDensity=($('settingsSidebarDensity')||{}).value==='detailed'?'detailed':'compact'; const body={}; if(sendKey) body.send_key=sendKey; body.theme=theme; body.skin=skin; body.language=language; body.show_token_usage=showTokenUsage; body.show_cli_sessions=showCliSessions; body.sync_to_insights=!!($('settingsSyncInsights')||{}).checked; body.check_for_updates=!!($('settingsCheckUpdates')||{}).checked; body.sound_enabled=!!($('settingsSoundEnabled')||{}).checked; body.notifications_enabled=!!($('settingsNotificationsEnabled')||{}).checked; body.show_thinking=window._showThinking!==false; body.sidebar_density=sidebarDensity; const botName=(($('settingsBotName')||{}).value||'').trim(); body.bot_name=botName||'Hermes'; // Password: only act if the field has content; blank = leave auth unchanged if(pw && pw.trim()){ try{ const saved=await api('/api/settings',{method:'POST',body:JSON.stringify({...body,_set_password:pw.trim()})}); if(modelChanged && model){ try{ await api('/api/default-model',{method:'POST',body:JSON.stringify({model})}); body.default_model=model; }catch(_modelErr){ if(typeof showToast==='function') showToast('Failed to update default model — settings saved'); } } _applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showCliSessions,theme,skin,language,sidebarDensity}); showToast(t(saved.auth_just_enabled?'settings_saved_pw':'settings_saved_pw_updated')); _hideSettingsPanel(); return; }catch(e){showToast(t('settings_save_failed')+e.message);return;} } try{ const saved=await api('/api/settings',{method:'POST',body:JSON.stringify(body)}); if(modelChanged && model){ try{ await api('/api/default-model',{method:'POST',body:JSON.stringify({model})}); body.default_model=model; }catch(_modelErr){ if(typeof showToast==='function') showToast('Failed to update default model — settings saved'); } } _applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showCliSessions,theme,skin,language,sidebarDensity}); showToast(t('settings_saved')); _hideSettingsPanel(); }catch(e){ showToast(t('settings_save_failed')+e.message); } } async function signOut(){ try{ await api('/api/auth/logout',{method:'POST',body:'{}'}); window.location.href='login'; }catch(e){ showToast(t('sign_out_failed')+e.message); } } async function disableAuth(){ const _disAuth=await showConfirmDialog({title:t('disable_auth_confirm_title'),message:t('disable_auth_confirm_message'),confirmLabel:t('disable'),danger:true,focusCancel:true}); if(!_disAuth) return; try{ await api('/api/settings',{method:'POST',body:JSON.stringify({_clear_password:true})}); showToast(t('auth_disabled')); // Hide both auth buttons since auth is now off const disableBtn=$('btnDisableAuth'); if(disableBtn) disableBtn.style.display='none'; const signOutBtn=$('btnSignOut'); if(signOutBtn) signOutBtn.style.display='none'; }catch(e){ showToast(t('disable_auth_failed')+e.message); } } // Close settings on overlay click (not panel click) -- with unsaved-changes check document.addEventListener('click',e=>{ const overlay=$('settingsOverlay'); if(overlay&&e.target===overlay) _closeSettingsPanel(); }); // ── Cron completion alerts ──────────────────────────────────────────────────── let _cronPollSince=Date.now()/1000; // track from page load let _cronPollTimer=null; let _cronUnreadCount=0; // Auto-refresh the cron list when a job is created from chat or any external source. // The chat path dispatches this event when the agent response mentions cron creation. window.addEventListener('hermes:cron_created', () => { if ($('cronList')) loadCrons(); }); function startCronPolling(){ if(_cronPollTimer) return; _cronPollTimer=setInterval(async()=>{ if(document.hidden) return; // don't poll when tab is in background try{ const data=await api(`/api/crons/recent?since=${_cronPollSince}`); if(data.completions&&data.completions.length>0){ for(const c of data.completions){ showToast(t('cron_completion_status', c.name, c.status==='error' ? t('status_failed') : t('status_completed')),4000); _cronPollSince=Math.max(_cronPollSince,c.completed_at); } _cronUnreadCount+=data.completions.length; updateCronBadge(); } }catch(e){} },30000); } function updateCronBadge(){ const tab=document.querySelector('.nav-tab[data-panel="tasks"]'); if(!tab) return; let badge=tab.querySelector('.cron-badge'); if(_cronUnreadCount>0){ if(!badge){ badge=document.createElement('span'); badge.className='cron-badge'; tab.style.position='relative'; tab.appendChild(badge); } badge.textContent=_cronUnreadCount>9?'9+':_cronUnreadCount; badge.style.display=''; }else if(badge){ badge.style.display='none'; } } // Clear cron badge when Tasks tab is opened const _origSwitchPanel=switchPanel; switchPanel=async function(name){ if(name==='tasks'){_cronUnreadCount=0;updateCronBadge();} return _origSwitchPanel(name); }; // Start polling on page load startCronPolling(); // ── Background agent error tracking ────────────────────────────────────────── const _backgroundErrors=[]; // {session_id, title, message, ts} function trackBackgroundError(sessionId, title, message){ // Only track if user is NOT currently viewing this session if(S.session&&S.session.session_id===sessionId) return; _backgroundErrors.push({session_id:sessionId, title:title||t('untitled'), message, ts:Date.now()}); showErrorBanner(); } function showErrorBanner(){ let banner=$('bgErrorBanner'); if(!banner){ banner=document.createElement('div'); banner.id='bgErrorBanner'; banner.className='bg-error-banner'; const msgs=document.querySelector('.messages'); if(msgs) msgs.parentNode.insertBefore(banner,msgs); else document.body.appendChild(banner); } const latest=_backgroundErrors[0]; // FIFO: show oldest (first) error if(!latest){banner.style.display='none';return;} const count=_backgroundErrors.length; const msg=count>1?t('bg_error_multi',count):t('bg_error_single',latest.title); banner.innerHTML=`\u26a0 ${esc(msg)}
`; banner.style.display=''; } function navigateToErrorSession(){ const latest=_backgroundErrors.shift(); // FIFO: show oldest error first if(latest){ loadSession(latest.session_id);renderSessionList(); } if(_backgroundErrors.length===0) dismissErrorBanner(); else showErrorBanner(); } function dismissErrorBanner(){ _backgroundErrors.length=0; const banner=$('bgErrorBanner'); if(banner) banner.style.display='none'; } // Event wiring