/// let _currentPanel = 'chat'; let _skillsData = null; // cached skills list let _skillsSort = localStorage.getItem('skills-sort') || 'az'; // 'az' | 'za' | 'uncat' async function switchPanel(name) { _currentPanel = name; // Update nav tabs document.querySelectorAll('.nav-tab').forEach(t => t.classList.toggle('active', (t as unknown as HTMLElement).dataset.panel === name)); // Update panel views — explicitly set display to override any CSS specificity issues document.querySelectorAll('.panel-view').forEach(p => { p.classList.remove('active'); // Defensive: force display:none on inactive panels (prevents projects panel // from leaking into other tabs due to ID-selector CSS specificity bugs) if (!p.classList.contains('active')) { (p as unknown as HTMLElement).style.display = 'none'; } }); const panelEl = $('panel' + name.charAt(0).toUpperCase() + name.slice(1)); if (panelEl) { panelEl.classList.add('active'); (panelEl as unknown as HTMLElement).style.display = ''; } // Lazy-load panel data if (name === 'tasks') await loadCrons(); if (name === 'skills') await loadSkills(); if (name === 'memory') await loadMemory(true); if (name === 'profiles') await loadProfilesPanel(); if (name === 'agents') await loadAgentsPanel(); if (name === 'heartbeats') await loadHeartbeatsPanel(); if (name === 'projects') await loadProjectsPanel(); } // ── Cron panel ── // ── Relative time helpers ────────────────────────────────────────────────── function _relTime(dateStr) { if (!dateStr) return null; const diff = Date.now() - new Date(dateStr).getTime(); const abs = Math.abs(diff); const mins = Math.floor(abs / 60000); const hours = Math.floor(abs / 3600000); const days = Math.floor(abs / 86400000); const future = diff < 0; if (mins < 1) return future ? 'gleich' : 'gerade eben'; if (mins < 60) return future ? `in ${mins} Min.` : `vor ${mins} Min.`; if (hours < 24) return future ? `in ${hours} Std.` : `vor ${hours} Std.`; if (days === 1) return future ? 'morgen' : 'gestern'; return future ? `in ${days} T.` : `vor ${days} T.`; } function _nextIn(dateStr) { if (!dateStr) return '—'; const diff = new Date(dateStr).getTime() - Date.now(); if (diff <= 0) return 'jetzt'; const mins = Math.floor(diff / 60000); if (mins < 60) return `in ${mins} Min.`; const hours = Math.floor(mins / 60); if (hours < 24) return `in ${hours} Std. ${mins % 60 > 0 ? (mins % 60) + ' Min.' : ''}`.trim(); return `in ${Math.floor(hours / 24)} T.`; } function _friendlySchedule(expr) { if (!expr) return ''; // humanize common patterns if (/^\d+\s+h$/.test(expr)) return `Alle ${parseInt(expr)} Std.`; if (/^\d+\s+m$/.test(expr)) return `Alle ${parseInt(expr)} Min.`; if (/\bat\b|\b\d{1,2}:\d{2}/.test(expr)) return expr; return expr; } async function loadCrons() { const box = $('cronList'); 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 ? 'OFF' : job.state === 'paused' ? 'PAUSIERT' : job.last_status === 'error' ? 'FEHLER' : 'AKTIV'; const next = job.next_run_at ? _nextIn(job.next_run_at) : '—'; const last = job.last_run_at ? _relTime(job.last_run_at) : t('cron_label_never'); const schedule = job.schedule_display || job.schedule?.expression || ''; const scheduleFriendly = _friendlySchedule(schedule); const prompt = job.prompt || ''; const promptPreview = prompt.length > 120 ? prompt.slice(0, 120) + '…' : prompt; item.innerHTML = `
${esc(job.name)}
${esc(scheduleFriendly)}
N: ${esc(next)}
L: ${esc(last)}
${esc(statusLabel)}
${esc(t('cron_label_schedule'))} ${esc(schedule)}
${esc(t('cron_label_next_run'))} ${esc(next)}
${esc(t('cron_label_last_ran'))} ${esc(last)}
${promptPreview ? `
${esc(t('cron_label_prompt'))}${esc(promptPreview)}
` : ''}
${job.state==='paused' ? `` : ``}
${esc(t('cron_last_output'))}
`; box.appendChild(item); loadCronOutput(job.id); } } catch(e) { box.innerHTML = `
${esc(t('error_prefix'))}${esc(e.message)}
`; } } 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); setupSkillCatDropdown(); })(); 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: Record = {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) { const el = $('cron-out-text-' + jobId); if (!el) return; try { const data = await api(`/api/crons/output?job_id=${encodeURIComponent(jobId)}&limit=1`); 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) { el.textContent = 'Fehler beim Laden'; } } 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 { const el = $('cron-out-text-' + id); if (el) el.innerHTML = '⏳ '+esc(t('cron_running'))+''; 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: Record = {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); } } 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: Record = {}; 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; } const sortedCats = Object.entries(cats).sort(([a, aItems], [b, bItems]) => { if (_skillsSort === 'az') return a.localeCompare(b); if (_skillsSort === 'za') return b.localeCompare(a); // uncat: 'general'/'uncategorized' first, then A-Z const uncats = ['general', 'uncategorized']; const aU = uncats.includes(a.toLowerCase()); const bU = uncats.includes(b.toLowerCase()); if (aU && !bU) return -1; if (!aU && bU) return 1; return a.localeCompare(b); }); for (const [cat, items] of sortedCats) { const sec = document.createElement('div'); sec.className = 'skills-category'; const displayName = cat === 'general' ? 'General' : cat; // Collapsible category header const collapsed = _collapsedCats && _collapsedCats.has(cat); sec.innerHTML = `
${collapsed ? '▶' : '▼'} ${li('folder',12)} ${esc(displayName)} (${items.length})
`; const itemsDiv = sec.querySelector('.skills-cat-items'); 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||'')}
${li('pencil',12)} ${li('trash-2',12)}
`; itemsDiv.appendChild(el); } box.appendChild(sec); } } // Track collapsed categories let _collapsedCats = new Set(); function toggleSkillCat(headerEl) { const cat = headerEl.dataset.cat; const itemsDiv = headerEl.nextElementSibling; const chevron = headerEl.querySelector('.cat-chevron'); const collapsed = itemsDiv.style.display === 'none'; if (collapsed) { itemsDiv.style.display = ''; chevron.textContent = '▼'; _collapsedCats.delete(cat); } else { itemsDiv.style.display = 'none'; chevron.textContent = '▶'; _collapsedCats.add(cat); } } function filterSkills() { if (_skillsData) renderSkills(_skillsData); } function setSkillsSort(val) { _skillsSort = val; localStorage.setItem('skills-sort', val); 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'); 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 as any).filter(([,files]:[string,any]) => 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 as any)) { 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'; const wsSearchSkill=$('wsSearchWrap');if(wsSearchSkill)wsSearchSkill.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'); if (['yml','yaml'].includes(ext)) { $('previewCode').textContent = ''; $('previewCode').className = 'preview-code hl-yaml'; $('previewCode').innerHTML = highlightYAML(data.content || ''); } else { $('previewCode').className = 'preview-code'; $('previewCode').textContent = data.content || ''; } requestAnimationFrame(() => highlightCode()); } } catch(e) { setStatus(t('skill_file_load_failed') + e.message); } } // ── YAML syntax highlighter with line numbers ── function highlightYAML(text) { const lines = text.split('\n'); return lines.map(raw => { // Escape HTML first let line = esc(raw); // Highlight full-line comments if (/^\s*#/.test(line)) { return '' + line + ''; } // Highlight inline comments (after a value) line = line.replace(/(#.*)$/, '$1'); // Highlight strings (double or single quoted) line = line.replace(/("[^&]*?"|'[^&]*?')/g, '$1'); // Highlight key: value pairs — key before the colon line = line.replace(/^(\s*)([\w._-]+)(:)/, '$1$2$3'); return '' + line + ''; }).join('\n'); } // ── Skill create/edit form ── let _editingSkillName = null; async function skillEdit(name) { try { const data = await api(`/api/skills/content?name=${encodeURIComponent(name)}`); toggleSkillForm(name, data.category || '', data.content || ''); $('skillFormTitle')?.textContent || updateSkillFormTitle(true); } catch(e) { showToast('Fehler beim Laden: ' + e.message); } } async function skillDelete(name) { if (!confirm(`Skill "${name}" wirklich löschen?`)) return; try { await api('/api/skills/delete', {method:'POST', body: JSON.stringify({name})}); showToast(`"${name}" gelöscht`, 3500); _skillsData = null; _cronSkillsCache = null; await loadSkills(); } catch(e) { showToast('Löschen fehlgeschlagen: ' + e.message, 4000); } } function updateSkillFormTitle(isEdit) { const titleEl = $('skillFormTitle'); if (titleEl) titleEl.textContent = isEdit ? 'Skill bearbeiten' : 'Neuer Skill'; } // ── Category dropdown for skill form ── function setupSkillCatDropdown() { const input = $('skillFormCategory'); const dropdown = $('skillCatDropdown'); if (!input || !dropdown) return; input.addEventListener('focus', () => renderSkillCatDropdown()); input.addEventListener('input', () => renderSkillCatDropdown(input.value.trim())); document.addEventListener('click', (e) => { if (!input.contains(e.target) && !dropdown.contains(e.target)) { dropdown.style.display = 'none'; } }); } function renderSkillCatDropdown(filter = '') { const dropdown = $('skillCatDropdown'); const input = $('skillFormCategory'); if (!dropdown || !input) return; if (!_skillsData) return; const cats = [...new Set(_skillsData.map(s => s.category).filter(Boolean))].sort(); const q = filter.toLowerCase(); const matches = cats.filter(c => !q || c.toLowerCase().includes(q)); if (!matches.length && !q) { dropdown.style.display = 'none'; return; } dropdown.innerHTML = matches.map(c => `
${esc(c)}
` ).join(''); if (q && !cats.some(c => c.toLowerCase() === q)) { dropdown.innerHTML += `
+ "${q}" erstellen
`; } dropdown.style.display = matches.length ? '' : 'none'; } function selectSkillCat(cat) { const input = $('skillFormCategory'); const dropdown = $('skillCatDropdown'); if (input) input.value = cat; if (dropdown) dropdown.style.display = 'none'; } 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; updateSkillFormTitle(false); return; } $('skillFormName').value = prefillName || ''; $('skillFormName').readOnly = !!prefillName; $('skillFormCategory').value = prefillCategory || ''; $('skillFormContent').value = prefillContent || ''; $('skillFormError').style.display = 'none'; _editingSkillName = prefillName || null; form.style.display = ''; updateSkillFormTitle(!!prefillName); $('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 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); const ws=hasSession?S.session.workspace:''; const label=hasSession?getWorkspaceFriendlyName(ws):t('no_workspace'); const sidebarName=$('sidebarWsName'); const sidebarPath=$('sidebarWsPath'); if(sidebarName) sidebarName.textContent=label; if(sidebarPath) sidebarPath.textContent=ws; // Topbar wsSelector const wsSelLabel=$('wsSelectorLabel'); const wsSelChip=$('wsSelectorChip'); if(wsSelLabel) wsSelLabel.textContent=label; if(wsSelChip) wsSelChip.title=hasSession?ws:t('no_workspace'); const composerChip=$('composerWorkspaceChip'); const composerLabel=$('composerWorkspaceLabel'); const composerDropdown=$('composerWsDropdown'); if(!hasSession && composerDropdown) composerDropdown.classList.remove('open'); if(composerLabel) composerLabel.textContent=label; if(composerChip){ composerChip.disabled=!hasSession; composerChip.title=hasSession?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:''}; } } // Expose globally for boot.ts which loads as separate script (window as any).loadWorkspaceList = loadWorkspaceList; 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'); }); } } // ── Topbar Workspace Selector Dropdown ── let _wsSelectorOpen = false; function toggleWsSelectorDropdown(){ const dd=$('wsSelectorDropdown'); const chip=$('wsSelectorChip'); if(!dd)return; _wsSelectorOpen=!_wsSelectorOpen; if(_wsSelectorOpen){ loadWorkspaceList().then(data=>{ renderWsSelectorList(data.workspaces||[]); dd.style.display=''; chip.classList.add('active'); }); }else{ closeWsSelectorDropdown(); } } function closeWsSelectorDropdown(){ const dd=$('wsSelectorDropdown'); const chip=$('wsSelectorChip'); _wsSelectorOpen=false; if(dd)dd.style.display='none'; if(chip)chip.classList.remove('active'); } function renderWsSelectorList(workspaces){ const dd=$('wsSelectorDropdown'); if(!dd)return; const current=S.session?S.session.workspace:''; dd.innerHTML=''; if(!workspaces.length){ dd.innerHTML='
Keine Workspaces
'; return; } for(const w of workspaces){ const row=document.createElement('div'); row.style.cssText='display:flex;align-items:center;justify-content:space-between;padding:8px 14px;cursor:pointer;font-size:12px;color:var(--text);border-radius:6px;margin:2px 4px;transition:background .1s'; row.onmouseenter=()=>row.style.background='var(--hover-bg)'; row.onmouseleave=()=>row.style.background=''; const isActive=w.path===current||w.path===current; const left=document.createElement('div'); left.style.cssText='display:flex;flex-direction:column;gap:2px;min-width:0'; left.innerHTML=`
${esc(w.name||w.path)}
${esc(w.path)}
`; const right=document.createElement('div'); if(isActive) right.innerHTML=``; row.appendChild(left); row.appendChild(right); row.onclick=()=>{switchToWorkspace(w.path,w.name||w.path);closeWsSelectorDropdown();}; dd.appendChild(row); } } document.addEventListener('click',e=>{ if(!(e.target as Element).closest('#wsSelectorWrap')) closeWsSelectorDropdown(); }); 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 as Element).closest('#composerWorkspaceChip') && !(e.target as Element).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 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); } 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=''; showToast(t('workspace_added')); }catch(e){setStatus(t('add_failed')+e.message);} } 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(){ 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 as string||'').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){ 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; 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; } 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 === data.active; const activeBadge = isActive ? `${esc(t('profile_active'))}` : ''; card.innerHTML = `
${gwDot}${esc(p.name)}${p.is_default ? ' (default)' : ''}${activeBadge}
${meta.length ? `
${esc(meta.join(' \u00b7 '))}
` : `
${esc(t('profile_no_configuration'))}
`}
${!isActive ? `` : ''} ${!p.is_default ? `` : ''}
`; panel.appendChild(card); } } catch (e) { panel.innerHTML = `
Error: ${esc(e.message)}
`; } } function renderProfileDropdown(data) { const dd = $('profileDropdown'); if (!dd) return; dd.innerHTML = ''; const profiles = data.profiles || []; const active = 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 ? ' ' : ''; opt.innerHTML = `
${gwDot}${esc(p.name)}${p.is_default ? ' (default)' : ''}${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 => { const target = e.target as Element | null; if (!target?.closest('#profileChipWrap') && !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 profile default for new sessions S._profileDefaultWorkspace = 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 (_) {} } updateWorkspaceChip(); 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(true); 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: Record = { 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); } } // ── Gateway panel ── let _gatewaysCache = null; async function loadGatewaysPanel() { const panel = $('gatewaysPanel'); if (!panel) return; try { const data = await api('/api/gateways'); _gatewaysCache = data; panel.innerHTML = ''; if (!data.gateways || !data.gateways.length) { panel.innerHTML = `
${esc(t('gateways_no_gateways'))}
`; return; } for (const gw of data.gateways) { const card = document.createElement('div'); card.className = 'profile-card'; const statusColor = gw.running ? 'var(--link)' : 'var(--muted)'; const statusDot = gw.running ? `` : ``; const statusText = gw.running ? esc(t('gateway_running')) : esc(t('gateway_stopped')); const typeMeta = gw.type ? `${esc(gw.type)}` : ''; card.innerHTML = `
${statusDot}${esc(gw.name)}
${statusText}${typeMeta ? ' · ' + typeMeta : ''}
${gw.running ? `` : `` }
${gw.info ? `
${esc(gw.info)}
` : ''}`; panel.appendChild(card); } // Add gateway button const addBtn = document.createElement('button'); addBtn.className = 'cron-btn run'; addBtn.style.cssText = 'width:100%;margin-top:8px;padding:6px'; addBtn.innerHTML = `+ ${esc(t('gateway_add'))}`; addBtn.onclick = () => showAddGatewayDialog(); panel.appendChild(addBtn); } catch (e) { panel.innerHTML = `
Error: ${esc(e.message)}
`; } } async function startGateway(name) { try { await api('/api/gateway/start', { method: 'POST', body: JSON.stringify({ name }) }); showToast(t('gateway_started', name)); await loadGatewaysPanel(); } catch (e) { showToast(t('gateway_start_failed') + e.message); } } async function stopGateway(name) { try { await api('/api/gateway/stop', { method: 'POST', body: JSON.stringify({ name }) }); showToast(t('gateway_stopped_msg', name)); await loadGatewaysPanel(); } catch (e) { showToast(t('gateway_stop_failed') + e.message); } } async function restartGateway(name) { try { await api('/api/gateway/restart', { method: 'POST', body: JSON.stringify({ name }) }); showToast(t('gateway_restarted', name)); await loadGatewaysPanel(); } catch (e) { showToast(t('gateway_restart_failed') + e.message); } } async function showAddGatewayDialog() { const name = await showPromptDialog({ title: t('gateway_add_title'), message: t('gateway_add_message'), confirmLabel: t('gateway_add'), placeholder: 'telegram' }); if (!name) return; try { await api('/api/gateway/add', { method: 'POST', body: JSON.stringify({ name, type: 'telegram' }) }); showToast(t('gateway_added', name)); await loadGatewaysPanel(); } catch (e) { showToast(t('gateway_add_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() : ''; // Mask sensitive data (API tokens, passwords, keys) in displayed memory function maskSensitive(md) { if (!md) return md; return md .replace(/([A-Za-z0-9]{32,})\.(?:[A-Za-z0-9+\/=]{10,})/g, '***REDACTED***') // API tokens like Z.ai .replace(/([Aa]pi[_\s]?[Tt]oken[:\s]*)\S{20,}/g, '$1***REDACTED***') // API Token: xxx .replace(/([Aa]PI[_\s]?[Kk]ey[:\s]*)\S{20,}/g, '$1***REDACTED***') // API Key: xxx .replace(/([Pp]assword[:\s]*)\S{8,}/g, '$1***REDACTED***') // Password: xxx .replace(/([Tt]oken[:\s]*)[a-f0-9]{32,}/g, '$1***REDACTED***') // token: hex .replace(/([Ss]ecret[:\s]*)\S{16,}/g, '$1***REDACTED***'); // secret: xxx } panel.innerHTML = `
${li('brain',14)} ${esc(t('my_notes'))} ${fmtTime(data.memory_mtime)}
${data.memory ? `
${renderMd(maskSensitive(data.memory))}
` : `
${esc(t('no_notes_yet'))}
`}
${li('user',14)} ${esc(t('user_profile'))} ${fmtTime(data.user_mtime)}
${data.user ? `
${renderMd(maskSensitive(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 _settingsSection = 'conversation'; function switchSettingsSection(name){ const section=(name==='preferences'||name==='system'||name==='gateways'||name==='logs'||name==='heartbeats')?name:'conversation'; _settingsSection=section; const map={conversation:'Conversation',preferences:'Preferences',system:'System',gateways:'Gateways',logs:'Logs',heartbeats:'Heartbeats'}; ['conversation','preferences','system','gateways','logs','heartbeats'].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); }); if(section==='logs') loadLogsPanel(); if(section==='heartbeats') loadHeartbeatsPanel(); } 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') || document.documentElement.dataset.theme || 'dark'; _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); else document.documentElement.dataset.theme = _settingsThemeOnOpen; } } // 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 as Node); } 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'); 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=''; try{ const 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){} modelSel.value=settings.default_model||''; 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});} // Theme preference const themeSel=$('settingsTheme'); if(themeSel){themeSel.value=settings.theme||'dark';themeSel.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 as any)._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});} const bubbleCb=$('settingsBubbleLayout'); if(bubbleCb){bubbleCb.checked=s.bubble_layout!==false;bubbleCb.addEventListener('change',_markSettingsDirty,{once:false});} // Bot name const botNameField=$('settingsBotName'); if(botNameField){botNameField.value=settings.bot_name||'Hermes';botNameField.addEventListener('input',_markSettingsDirty,{once:false});} // User profile const userEmojiField=$('settingsUserEmoji'); if(userEmojiField){userEmojiField.value=settings.user_emoji||'🙂';userEmojiField.addEventListener('input',_markSettingsDirty,{once:false});} const userNameField=$('settingsUserName'); if(userNameField){userNameField.value=settings.user_name||'You';userNameField.addEventListener('input',_markSettingsDirty,{once:false});} if(userEmojiField)updateUserAvatarPreview(userEmojiField.value||'🙂'); // 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(); switchSettingsSection(_settingsSection); if(_settingsSection==='gateways') loadGatewaysPanel(); }catch(e){ showToast(t('settings_load_failed')+e.message); } } 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,language}=opts; window._sendKey=sendKey||'enter'; window._showTokenUsage=showTokenUsage; window._showCliSessions=showCliSessions; window._soundEnabled=body.sound_enabled; window._notificationsEnabled=body.notifications_enabled; window._botName=body.bot_name||'Hermes'; window._userEmoji=saved.user_emoji||body.user_emoji||'🙂'; window._userName=saved.user_name||body.user_name||'You'; document.body.classList.toggle('bubble-layout', !!body.bubble_layout); 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; const bar=$('settingsUnsavedBar'); if(bar) bar.style.display='none'; renderMessages(); if(typeof syncTopbar==='function') syncTopbar(); if(typeof renderSessionList==='function') renderSessionList(); } async function saveSettings(andClose){ const model=($('settingsModel') as HTMLInputElement | null)?.value; const sendKey=($('settingsSendKey') as HTMLInputElement | null)?.value; const showTokenUsage=!!($('settingsShowTokenUsage') as HTMLInputElement | null)?.checked; const showCliSessions=!!($('settingsShowCliSessions') as HTMLInputElement | null)?.checked; const pw=($('settingsPassword') as HTMLInputElement | null)?.value; const theme=($('settingsTheme') as HTMLInputElement | null)?.value||'dark'; const language=($('settingsLanguage') as HTMLInputElement | null)?.value||'en'; const body: Record = {}; if(model) body.default_model=model; if(sendKey) body.send_key=sendKey; body.theme=theme; body.language=language; body.show_token_usage=showTokenUsage; body.show_cli_sessions=showCliSessions; body.sync_to_insights=!!($('settingsSyncInsights') as HTMLInputElement | null)?.checked; body.check_for_updates=!!($('settingsCheckUpdates') as HTMLInputElement | null)?.checked; body.sound_enabled=!!($('settingsSoundEnabled') as HTMLInputElement | null)?.checked; body.notifications_enabled=!!($('settingsNotificationsEnabled') as HTMLInputElement | null)?.checked; body.bubble_layout=!!($('settingsBubbleLayout') as HTMLInputElement | null)?.checked; document.body.classList.toggle('bubble-layout', body.bubble_layout as boolean); const botName=((($('settingsBotName') as HTMLInputElement | null)?.value)||'').trim(); body.bot_name=botName||'Hermes'; const userEmoji=((($('settingsUserEmoji') as HTMLInputElement | null)?.value)||'').trim(); const userName=((($('settingsUserName') as HTMLInputElement | null)?.value)||'').trim(); body.user_emoji=userEmoji||'🙂'; body.user_name=userName||'You'; // 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()})}); _applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showCliSessions,theme,language}); 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)}); _applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showCliSessions,theme,language}); 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; 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') as unknown as Element; badge.className='cron-badge'; tab.style.position='relative'; tab.appendChild(badge as Node); } badge.textContent=String(_cronUnreadCount>9?'9+':_cronUnreadCount); badge.style.display=''; }else if(badge){ badge.style.display='none'; } } // Clear cron badge when Tasks tab is opened, load Projects panel // (original switchPanel handles all panel logic - no override needed) // 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.insertAdjacentElement('beforebegin', banner as unknown as Element); 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'; } // ── Mission Control panel ── let _mcInterval = null; async function loadMissionControl() { clearInterval(_mcInterval); await refreshMC(); _mcInterval = setInterval(refreshMC, 15000); } async function refreshMC() { const [statusRes, tasksRes, feedRes, prioritiesRes] = await Promise.all([ api('/api/mc/status'), api('/api/mc/tasks'), api('/api/mc/feed?limit=10'), api('/api/mc/priorities'), ]); const status = statusRes; const tasks = tasksRes.tasks || []; const feed = feedRes.feed || []; const priorities = prioritiesRes.priorities || []; // ── Health Badge ── const h = status.dashboard_health || 'empty'; const healthColors = { healthy: '#4caf50', active: '#ff9800', warning: '#ff5722', empty: '#9e9e9e', ok: '#4caf50' }; const healthLabels = { healthy: 'Healthy', active: 'Active', warning: 'Warning', empty: 'No Data', ok: 'OK' }; const $hb = $('mcHealthBadge'); $hb.textContent = healthLabels[h] || 'Unknown'; $hb.style.color = healthColors[h] || '#9e9e9e'; $hb.style.background = (healthColors[h] || '#9e9e9e') + '22'; // ── Stats Cards ── const totalTasks = tasks.length; const doneTasks = tasks.filter(t => t.status === 'done').length; const progressPct = totalTasks > 0 ? Math.round((doneTasks / totalTasks) * 100) : 0; $('mcTasksCount').textContent = `${doneTasks}/${totalTasks}`; $('mcTasksLabel').textContent = `${doneTasks} done · ${totalTasks - doneTasks} open`; $('mcPrioritiesCount').textContent = priorities.length; $('mcPrioritiesLabel').textContent = `${priorities.filter(p => p.done).length} done`; // ── Progress Bar ── $('mcProgressBar').style.width = progressPct + '%'; $('mcProgressPct').textContent = progressPct + '%'; // ── Priority Filters ── const priorityMap = {}; priorities.forEach(p => { priorityMap[p.id] = p; }); const priorityEmoji = { 1: '🔴', 2: '🟠', 3: '🟡', 4: '🟢' }; const pfEl = $('mcPriorityFilters'); pfEl.innerHTML = [ ``, ...priorities.map(p => `` ) ].join(''); // ── Tasks List ── const listEl = $('mcTasksList'); const activeFilter = window._mcPriorityFilter || 'all'; const filteredTasks = activeFilter === 'all' ? tasks : tasks.filter(t => t.priority === activeFilter); if (filteredTasks.length === 0) { listEl.innerHTML = '
No tasks yet.
Add one above ↑
'; } else { const statusMeta = { backlog: { icon: '○', color: 'var(--muted)', label: 'Backlog' }, progress: { icon: '◐', color: '#ff9800', label: 'In Progress' }, done: { icon: '●', color: '#4caf50', label: 'Done' } }; listEl.innerHTML = filteredTasks.map(t => { const meta = statusMeta[t.status] || statusMeta.backlog; const p = priorityMap[t.priority] || { name: 'Unknown', color: '#808080' }; const emoji = priorityEmoji[t.priority] || '•'; return `
${meta.icon}
${esc(t.title)}
${meta.label} ${emoji} ${esc(p.name)}
`; }).join(''); } // ── Feed ── const feedEl = $('mcFeed'); if (feed.length === 0) { feedEl.innerHTML = '
No recent activity
'; } else { feedEl.innerHTML = feed.map(f => { const d = new Date(f.timestamp); const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); return `
${esc(f.event)} ${time}
`; }).join(''); } } function toggleMCTask(id, currentStatus) { const next = currentStatus === 'done' ? 'backlog' : currentStatus === 'backlog' ? 'progress' : 'done'; updateMCTask(id, next); } function filterMCTasks(priorityId) { window._mcPriorityFilter = priorityId; refreshMC(); } async function createMCTask() { const title = $('mcNewTaskTitle').value.trim(); if (!title) return; const priority = parseInt($('mcNewTaskPriority').value); const status = $('mcNewTaskStatus').value; $('mcNewTaskTitle').value = ''; await api('/api/mc/task/create', { method: 'POST', body: JSON.stringify({ title, priority, status }) }); await refreshMC(); } async function updateMCTask(id, status) { await api('/api/mc/task/update', { method: 'POST', body: JSON.stringify({ id, status }) }); await refreshMC(); } // ── Priority Management ── async function createMCPriority() { const name = prompt('Priority name:'); if (!name) return; const color = prompt('Color (hex, e.g. #ff0000):', '#808080'); if (!color) return; await api('/api/mc/priority/create', { method: 'POST', body: JSON.stringify({ name, color }) }); await refreshMC(); } async function deleteMCPriority(id) { if (!confirm('Delete this priority?')) return; await api('/api/mc/priority/delete', { method: 'POST', body: JSON.stringify({ id }) }); await refreshMC(); } // ── Agents Panel (Rose + Tier-2) ───────────────────────────────────────────── let _agentsInterval = null; let _selectedAgent = null; let _agentTab = 'overview'; // current tab in detail overlay const STATUS_COLORS = { active: '#4caf50', idle: '#ff9800', offline: '#9e9e9e' }; const STATUS_LABELS = { active: 'Active', idle: 'Idle', offline: 'Offline' }; function _relTimeAgent(ts) { if (!ts) return 'N/A'; try { const d = new Date(ts); const diff = (Date.now() - d.getTime()) / 1000; if (diff < 60) return 'Just now'; if (diff < 3600) return `${Math.floor(diff/60)}m ago`; if (diff < 86400) return `${Math.floor(diff/3600)}h ago`; return d.toLocaleDateString(); } catch { return ts; } } async function loadAgentsPanel() { clearInterval(_agentsInterval); await refreshAgents(); _agentsInterval = setInterval(refreshAgents, 15000); } async function refreshAgents() { try { const data = await api('/api/agents'); renderAgentsList(data.agents || []); } catch(e) { const box = $('agentsList'); if (box) box.innerHTML = `
Error: ${esc(e.message)}
`; } } function renderAgentsList(agents) { const box = $('agentsList'); if (!box) return; const html = agents.map(a => { const color = STATUS_COLORS[a.status] || STATUS_COLORS.offline; const label = STATUS_LABELS[a.status] || 'Offline'; const tierBadge = a.tier === 'orchestrator' ? '🌹 Tier-1' : 'Tier-2'; const inboxBadge = a.inbox_count > 0 ? `${a.inbox_count}` : ''; const disabled = a.disabled ? 'opacity:0.5;' : ''; return `
${a.emoji}
${esc(a.name)}
${esc(a.domain)}
${label} ${tierBadge}
${_relTime(a.last_activity)}
${inboxBadge}
`; }).join(''); box.innerHTML = html; } async function openAgentDetail(agentId) { _selectedAgent = agentId; _agentTab = 'overview'; // Highlight card document.querySelectorAll('.agent-card').forEach(el => el.classList.remove('selected')); const cards = document.querySelectorAll('.agent-card'); const agentsData = await api('/api/agents'); const idx = agentsData.agents.findIndex(a => a.id === agentId); if (cards[idx]) cards[idx].classList.add('selected'); const box = $('agentInbox'); // Fetch full agent data let agent; try { agent = await api(`/api/agents/${agentId}`); } catch(e) { box.innerHTML = `
Error loading agent: ${esc(e.message)}
`; box.style.display = 'block'; return; } const color = STATUS_COLORS[agent.status] || STATUS_COLORS.offline; const tierBadge = agent.tier === 'orchestrator' ? '🌹 Tier-1' : 'Tier-2'; const lastAct = agent.last_activity ? new Date(agent.last_activity).toLocaleString() : 'N/A'; const canEdit = agentId !== 'rose'; box.innerHTML = `
${agent.emoji}
${esc(agent.name)}
${esc(agent.domain)}
${tierBadge}
${STATUS_LABELS[agent.status] || 'Offline'} ${agent.pid ? `PID ${agent.pid}` : ''}
Last active: ${esc(lastAct)}
${agent.default_model ? `
Model: ${esc(agent.default_model)}
` : ''}
${agentId !== 'rose' ? `
Agent ${agent.disabled ? 'disabled' : 'enabled'}
` : ''}
Loading...
`; box.style.display = 'block'; // Load first tab content await switchAgentTab('overview'); } async function switchAgentTab(tab) { _agentTab = tab; const agentId = _selectedAgent; if (!agentId) return; // Update tab buttons document.querySelectorAll('.agent-tab').forEach((el, i) => { const tabs = ['overview', 'soul', 'memory', 'inbox', 'activity', 'errors', 'chat', 'tasks', 'bus', 'usage', 'topology']; el.classList.toggle('active', tabs[i] === tab); }); const content = $('agentTabContent'); switch(tab) { case 'overview': await loadAgentOverview(agentId, content); break; case 'soul': await loadAgentSoul(agentId, content); break; case 'memory': await loadAgentMemory(agentId, content); break; case 'inbox': await loadAgentInboxTab(agentId, content); break; case 'activity': await loadAgentActivity(agentId, content); break; case 'errors': await loadAgentErrors(agentId, content); break; case 'chat': await loadAgentChatHistory(agentId, content); break; case 'tasks': await loadAgentTasks(agentId, content); break; case 'bus': await loadAgentBus(agentId, content); break; case 'usage': await loadAgentUsage(agentId, content); break; case 'topology': await loadAgentTopology(agentId, content); break; } } async function loadAgentOverview(agentId, content) { try { const agent = await api(`/api/agents/${agentId}`); const color = STATUS_COLORS[agent.status] || STATUS_COLORS.offline; content.innerHTML = `
Status ${STATUS_LABELS[agent.status] || 'Offline'}
Domain ${esc(agent.domain)}
Tier ${agent.tier === 'orchestrator' ? '🌹 Orchestrator (Tier-1)' : 'Tier-2 Domain Agent'}
Last Active ${agent.last_activity ? new Date(agent.last_activity).toLocaleString() : 'N/A'}
${agent.default_model ? `
Model ${esc(agent.default_model)}
` : ''} ${agent.inbox_count > 0 ? `
Inbox ${agent.inbox_count} unread messages
` : ''} ${agent.pid ? `
Process PID ${agent.pid}
` : ''}
`; // Fetch health metrics in parallel try { const health = await api(`/api/agents/${agentId}/health`); if (!health.error && health.status !== 'offline') { const memBar = health.memory_mb > 0 ? `
` : ''; const uptime = health.uptime_seconds > 0 ? _formatUptime(health.uptime_seconds) : 'N/A'; content.innerHTML += `
System Health
CPU ${health.cpu_percent}%
Memory ${health.memory_mb} MB ${memBar}
Threads ${health.threads}
`; } } catch(e) {} } catch(e) { content.innerHTML = `
Error: ${esc(e.message)}
`; } } function _formatUptime(seconds) { if (!seconds || seconds <= 0) return 'N/A'; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); return h > 0 ? `${h}h ${m}m` : `${m}m`; } async function loadAgentSoul(agentId, content) { const canEdit = agentId !== 'rose'; try { const agent = await api(`/api/agents/${agentId}`); const soul = agent.soul || ''; if (!soul) { content.innerHTML = `
📄
No soul.md found
`; return; } content.innerHTML = `
${canEdit ? `` : ''}
${renderMarkdown(soul)}
`; } catch(e) { content.innerHTML = `
Error: ${esc(e.message)}
`; } } async function loadAgentMemory(agentId, content) { const canEdit = agentId !== 'rose'; // Fetch memory.md + render search bar try { const agent = await api(`/api/agents/${agentId}`); const memory = agent.memory || ''; content.innerHTML = `
${canEdit ? `` : ''}
${memory ? renderMarkdown(memory) : '
No memory.md found
'}
`; } catch(e) { content.innerHTML = `
Error: ${esc(e.message)}
`; } } async function searchAgentMemory(agentId) { const q = document.getElementById('memSearchInput').value.trim(); const resultsBox = document.getElementById('memSearchResults'); if (!q) return; resultsBox.style.display = ''; resultsBox.innerHTML = `
Searching...
`; try { const endpoint = agentId === 'rose' ? `/api/agents/memory/search?q=${encodeURIComponent(q)}` : `/api/agents/${agentId}/memory/search?q=${encodeURIComponent(q)}`; const data = await api(endpoint); const results = data.results || []; if (!results.length) { resultsBox.innerHTML = `
No results for "${esc(q)}"
`; return; } resultsBox.innerHTML = `
${results.length} result${results.length!==1?'s':''}
${results.map(r => `
${esc(r.topic)} ${(r.confidence*100).toFixed(0)}%
${r.agent ? 'Agent: '+esc(r.agent)+' · ' : ''}Topic: ${esc(r.topic)}
${esc((r.content||'').slice(0,200))}
`).join('')} `; } catch(e) { resultsBox.innerHTML = `
Error: ${esc(e.message)}
`; } } async function loadAgentInboxTab(agentId, content) { try { const data = await api(`/api/agents/${agentId}/inbox`); const messages = data.messages || []; const agentName = data.agent_name || agentId; if (messages.length === 0) { content.innerHTML = `
📭
No messages in inbox
Messages from other agents appear here
Send Message
`; return; } const msgsHtml = messages.map(m => { const ts = m.timestamp ? new Date(m.timestamp).toLocaleString() : ''; const isUnread = m.status !== 'read'; const typeColor = m.type === 'request' ? '#ff9800' : '#4caf50'; const typeLabel = m.type === 'request' ? '📨 REQUEST' : '✅ REPLY'; return `
${typeLabel} ← ${esc(m.from || 'unknown')} ${esc(ts)}
${esc(m.subject || '(no subject)')}
${esc(String(m.content || '').slice(0,200))}
${isUnread ? `` : ''}
`; }).join(''); content.innerHTML = `
${msgsHtml}
Send Message
`; } catch(e) { content.innerHTML = `
Error: ${esc(e.message)}
`; } } async function loadAgentActivity(agentId, content) { try { const data = await api(`/api/agents/${agentId}/activity`); const events = data.events || []; if (events.length === 0) { content.innerHTML = `
📋
No activity recorded yet
Events like messages, tasks and updates appear here
`; return; } const EVENT_COLORS = { 'agent_started': '#4caf50', 'agent_stopped': '#9e9e9e', 'message_sent': '#2196f3', 'message_received': '#00bcd4', 'task_started': '#ff9800', 'task_completed': '#4caf50', 'task_failed': '#f44336', 'soul_updated': '#9c27b0', 'memory_updated': '#3f51b5', 'chat_started': '#ff9800', 'chat_ended': '#795548', 'health_check': '#4caf50', 'config_updated': '#607d8b', 'error': '#f44336', }; const EVENT_ICONS = { 'agent_started': '🟢', 'agent_stopped': '⚫', 'message_sent': '📤', 'message_received': '📥', 'task_started': '▶️', 'task_completed': '✅', 'task_failed': '❌', 'soul_updated': '✏️', 'memory_updated': '🧠', 'chat_started': '💬', 'chat_ended': '💬', 'health_check': '❤️', 'config_updated': '⚙️', 'error': '⚠️', }; const rows = events.map(e => { const color = EVENT_COLORS[e.type] || '#9e9e9e'; const icon = EVENT_ICONS[e.type] || '•'; const ts = e.timestamp ? new Date(e.timestamp).toLocaleString() : 'N/A'; const rel = e.timestamp ? _relTime(e.timestamp) : ''; const details = e.details ? `${esc(e.details)}` : ''; return `
${icon}
${esc(e.type)} ${details}
${esc(ts)} · ${rel}
`; }).join(''); content.innerHTML = `
${rows}
`; } catch(e) { content.innerHTML = `
Error: ${esc(e.message)}
`; } } async function loadAgentErrors(agentId, content) { try { const data = await api(`/api/agents/${agentId}/errors`); const errors = data.errors || []; if (errors.length === 0) { content.innerHTML = `
No errors recorded
All good — this agent has no logged errors
`; return; } const rows = errors.map(e => { const ts = e.timestamp ? new Date(e.timestamp).toLocaleString() : 'N/A'; const rel = e.timestamp ? _relTime(e.timestamp) : ''; return `
⚠️
${esc(e.details || 'Unknown error')}
${esc(ts)} · ${rel}
`; }).join(''); content.innerHTML = `
⚠️ ${errors.length} error${errors.length !== 1 ? 's' : ''} total
${rows}
`; } catch(e) { content.innerHTML = `
Error: ${esc(e.message)}
`; } } function toggleInboxMsg(el) { el.classList.toggle('expanded'); } async function loadAgentChatHistory(agentId, content) { try { const data = await api(`/api/agents/${agentId}/chat-history`); const sessions = data.sessions || []; if (sessions.length === 0) { content.innerHTML = `
💬
No chat history yet
Your conversations with ${agentId} appear here
`; return; } const rows = sessions.map(s => { const created = s.created_at ? new Date(s.created_at).toLocaleString() : 'N/A'; const rel = s.created_at ? _relTime(s.created_at) : ''; const model = s.model || 'unknown'; return `
${esc(s.title)}
${created} · ${rel} ${esc(model)} ${s.message_count} msgs
`; }).join(''); content.innerHTML = `
${rows}
`; } catch(e) { content.innerHTML = `
Error: ${esc(e.message)}
`; } } function openAgentChatSession(agentId, sessionId) { // Switch to the chat panel and load this session closeAgentDetail(); if (typeof switchToChatPanel === 'function') switchToChatPanel(); if (typeof loadSession === 'function') loadSession(sessionId); showToast(`Loading chat session...`); } async function loadAgentTasks(agentId, content) { try { const data = await api(`/api/agents/${agentId}/tasks`); const tasks = data.tasks || []; if (tasks.length === 0) { content.innerHTML = `
📋
No tasks in queue
Tasks will appear here when agents are working on something
`; return; } const TASK_STATUS_COLORS = { 'running': '#4caf50', 'queued': '#ff9800', 'completed': '#9e9e9e', 'failed': '#f44336' }; const rows = tasks.map(t => { const color = TASK_STATUS_COLORS[t.status] || '#9e9e9e'; const ts = t.created_at ? new Date(t.created_at).toLocaleString() : ''; return `
${esc(t.description || 'Task')}
${esc(ts)} · ${esc(t.status)}
`; }).join(''); content.innerHTML = `
${tasks.length} task${tasks.length !== 1 ? 's' : ''}
${rows}
`; } catch(e) { content.innerHTML = `
Error: ${esc(e.message)}
`; } } async function loadAgentBus(agentId, content) { try { const data = await api(`/api/agents/message-bus`); const bus = data.bus || {}; // Collect all messages across agents, filter to those involving agentId const allMsgs = []; for (const [aId, aData] of Object.entries(bus)) { const msgs = (aData as any).messages || []; for (const m of msgs) { if (m.from === agentId || m.to === agentId) { allMsgs.push({ ...m, _agent: aId }); } } } // Sort newest first allMsgs.sort((a, b) => (b.timestamp || '').localeCompare(a.timestamp || '')); if (allMsgs.length === 0) { content.innerHTML = `
🚌
No messages in the bus
Messages between agents appear here
Send Message via Bus
`; return; } const rows = allMsgs.map(m => { const ts = m.timestamp ? new Date(m.timestamp).toLocaleString() : 'N/A'; const rel = m.timestamp ? _relTime(m.timestamp) : ''; const isOutgoing = m.from === agentId; const dirIcon = isOutgoing ? '📤' : '📥'; const dirLabel = isOutgoing ? `→ ${m.to}` : `← ${m.from}`; const typeColor = m.type === 'request' ? '#ff9800' : '#4caf50'; return `
${dirIcon} ${esc(m.type || '').toUpperCase()} ${dirLabel} ${esc(ts)} · ${rel}
${esc(m.subject || '(no subject)')}
${esc(String(m.content || '').slice(0, 120))}
`; }).join(''); content.innerHTML = `
${rows}
Send Message via Bus
`; } catch(e) { content.innerHTML = `
Error: ${esc(e.message)}
`; } } async function sendBusMessage(toAgent) { const subject = document.getElementById('busSubject').value.trim(); const content = document.getElementById('busContent').value.trim(); if (!subject && !content) { showToast('Please enter a subject or message'); return; } try { const r = await api(`/api/agents/${toAgent}/bus-message`, { method: 'POST', body: JSON.stringify({ from_agent: 'rose', subject, content }), }); if (!r.ok) throw new Error(r.error || 'Send failed'); showToast('Message sent via bus'); document.getElementById('busSubject').value = ''; document.getElementById('busContent').value = ''; // Refresh await switchAgentTab('bus'); } catch(e) { showToast('Error: ' + e.message); } } async function loadAgentTopology(agentId, content) { const agents = [ { id: 'rose', name: 'rose', emoji: '🌹', tier: 'orchestrator', x: 0, y: 0 }, { id: 'lotus', name: 'lotus', emoji: '🪷', tier: 'tier2', x: 0, y: -1 }, { id: 'forget-me-not', name: 'forget-me-not', emoji: '🌼', tier: 'tier2', x: 0.7, y: -0.7 }, { id: 'sunflower', name: 'sunflower', emoji: '🌻', tier: 'tier2', x: -1, y: 0 }, { id: 'iris', name: 'iris', emoji: '⚜️', tier: 'tier2', x: 0, y: 1 }, { id: 'ivy', name: 'ivy', emoji: '🌿', tier: 'tier2', x: -0.7, y: 0.7 }, { id: 'dandelion', name: 'dandelion', emoji: '🛡️', tier: 'tier2', x: 0.7, y: 0.7 }, { id: 'root', name: 'root', emoji: '🌳', tier: 'tier2', x: 0, y: 0.7 } ]; const connections = [ { from: 'rose', to: 'lotus' }, { from: 'rose', to: 'forget-me-not' }, { from: 'rose', to: 'sunflower' }, { from: 'rose', to: 'iris' }, { from: 'rose', to: 'ivy' }, { from: 'rose', to: 'dandelion' }, { from: 'rose', to: 'root' }, { from: 'lotus', to: 'forget-me-not' }, { from: 'sunflower', to: 'lotus' }, { from: 'iris', to: 'rose' }, { from: 'ivy', to: 'rose' }, { from: 'dandelion', to: 'rose' }, { from: 'root', to: 'rose' } ]; const scale = 80; let svg = ''; svg += ''; connections.forEach(conn => { const from = agents.find(a => a.id === conn.from); const to = agents.find(a => a.id === conn.to); if (from && to) { svg += ''; } }); agents.forEach(agent => { const px = agent.x * scale; const py = agent.y * scale; const isRose = agent.tier === 'orchestrator'; const color = isRose ? '#F5C542' : '#5B8FA8'; const r = 28; const isActive = agentId === agent.id; if (isActive) { svg += ''; } svg += ''; svg += '' + agent.emoji + ''; svg += '' + agent.name + ''; }); svg += ''; content.innerHTML = '
' + svg + '
'; } async function loadAgentUsage(agentId, content) { try { const data = await api(`/api/agents/${agentId}/usage`); if (data.error) { content.innerHTML = `
${esc(data.error)}
`; return; } const fmt = (n) => n.toLocaleString(); const fmtCost = (c) => '$' + c.toFixed(4); const historyRows = (data.history || []).slice(0, 14).map(h => ` ${esc(h.date)} ${fmt(h.total_tokens || 0)} ${fmt(h.prompt_tokens || 0)} ${fmt(h.completion_tokens || 0)} ${fmtCost(h.cost_usd || 0)} `).join(''); content.innerHTML = `
Today
${fmt(data.today.tokens)}
${fmtCost(data.today.cost)}
This Week
${fmt(data.week.tokens)}
${fmtCost(data.week.cost)}
This Month
${fmt(data.month.tokens)}
${fmtCost(data.month.cost)}
${historyRows ? `
Recent History
${historyRows}
Date Total Prompt Completion Cost
` : `
📊
No usage data recorded yet
Token usage will appear here once recorded
`}
`; } catch(e) { content.innerHTML = `
Failed to load usage: ${esc(e.message)}
`; } } // Edit handlers function editAgentSoul(agentId) { document.getElementById('soulView').style.display = 'none'; document.getElementById('soulEdit').style.display = 'block'; } function cancelEditSoul(agentId) { document.getElementById('soulView').style.display = 'block'; document.getElementById('soulEdit').style.display = 'none'; } async function saveAgentSoul(agentId) { const content = document.getElementById('soulEditArea').value; const errEl = document.getElementById('soulEditError'); errEl.style.display = 'none'; try { const r = await api(`/api/agents/${agentId}/soul`, { method: 'PUT', body: JSON.stringify({ content }) }); if (!r.ok) throw new Error(r.error || 'Save failed'); showToast('soul.md saved'); await switchAgentTab('soul'); } catch(e) { errEl.textContent = e.message; errEl.style.display = 'block'; } } function editAgentMemory(agentId) { document.getElementById('memoryView').style.display = 'none'; document.getElementById('memoryEdit').style.display = 'block'; } function cancelEditMemory(agentId) { document.getElementById('memoryView').style.display = 'block'; document.getElementById('memoryEdit').style.display = 'none'; } async function saveAgentMemory(agentId) { const content = document.getElementById('memoryEditArea').value; const errEl = document.getElementById('memoryEditError'); errEl.style.display = 'none'; try { const r = await api(`/api/agents/${agentId}/memory`, { method: 'PUT', body: JSON.stringify({ content }) }); if (!r.ok) throw new Error(r.error || 'Save failed'); showToast('memory.md saved'); await switchAgentTab('memory'); } catch(e) { errEl.textContent = e.message; errEl.style.display = 'block'; } } async function sendToAgent(agentId) { const subject = document.getElementById('msgToAgentSubject').value.trim(); const body = document.getElementById('msgToAgentBody').value.trim(); const errEl = document.getElementById('sendToAgentError'); errEl.style.display = 'none'; if (!body) { errEl.textContent = 'Message body is required'; errEl.style.display = 'block'; return; } try { const r = await api(`/api/agents/${agentId}/message`, { method: 'POST', body: JSON.stringify({ from: 'rose', type: 'request', subject, content: body }), }); if (!r.ok) throw new Error(r.error || 'Send failed'); showToast('Message sent'); document.getElementById('msgToAgentSubject').value = ''; document.getElementById('msgToAgentBody').value = ''; await switchAgentTab('inbox'); } catch(e) { errEl.textContent = e.message; errEl.style.display = 'block'; } } async function ackMsg(agentId, msgId) { try { await api(`/api/agents/${agentId}/ack/${msgId}`, { method: 'POST' }); await switchAgentTab('inbox'); await refreshAgents(); } catch(e) { showToast('Ack failed: ' + e.message); } } async function toggleAgentEnabled(agentId, enable) { try { const r = await api(`/api/agents/${agentId}/${enable ? 'enable' : 'disable'}`, { method: 'POST' }); if (!r.ok) throw new Error(r.error || 'Toggle failed'); showToast(`Agent ${enable ? 'enabled' : 'disabled'}`); await openAgentDetail(agentId); await refreshAgents(); } catch(e) { showToast('Error: ' + e.message); } } function chatWithAgent(agentId) { localStorage.setItem('hermes.chat_agent', agentId); closeAgentInbox(); switchPanel('chat'); } function closeAgentInbox() { $('agentInbox').style.display = 'none'; _selectedAgent = null; document.querySelectorAll('.agent-card').forEach(el => el.classList.remove('selected')); } // ── Agent Selector (Chat) ───────────────────────────────────────────────────── let _agentSelectorOpen = false; async function toggleAgentSelectorDropdown() { const dd = $('agentSelectorDropdown'); if (_agentSelectorOpen) { dd.style.display = 'none'; _agentSelectorOpen = false; return; } _agentSelectorOpen = true; await renderAgentSelectorDropdown(); dd.style.display = 'block'; } async function renderAgentSelectorDropdown() { const dd = $('agentSelectorDropdown'); const current = localStorage.getItem('hermes.chat_agent') || 'rose'; // Rose always first const agents = [ { id: 'rose', name: 'Rose', emoji: '🌹', domain: 'Orchestrator & Main Interface', color: '#f44336' }, ]; // Add Tier-2 agents try { const data = await api('/api/agents'); if (data.agents) { for (const a of data.agents) { if (a.id !== 'rose') { agents.push({ id: a.id, name: a.name, emoji: a.emoji, domain: a.domain, color: a.color || '#888' }); } } } } catch(e) {} dd.innerHTML = agents.map(a => `
${a.emoji}
${a.name}
${a.domain}
`).join(''); // Add active style const style = document.createElement('style'); style.textContent = `.agent-sel-item:hover{background:var(--hover)}.agent-sel-item.active{background:rgba(255,255,255,.06)}`; if (!document.querySelector('#agent-sel-styles')) { style.id = 'agent-sel-styles'; document.head.appendChild(style); } } function selectChatAgent(agentId) { $('agentSelectorDropdown').style.display = 'none'; _agentSelectorOpen = false; // Use the existing composer selector logic (syncs session + localStorage + UI) if (typeof selectAgentFromDropdown === 'function') { selectAgentFromDropdown(agentId); } else { // Fallback localStorage.setItem('hermes.chat_agent', agentId); localStorage.setItem('hermes-webui-agent', agentId); const sel = $('agentSelect'); if (sel && Array.from((sel as unknown as HTMLSelectElement).options).some(o => o.value === agentId)) { (sel as unknown as HTMLSelectElement).value = agentId; } if (typeof syncAgentChip === 'function') syncAgentChip(); } // Update topbar display updateAgentSelectorDisplay(); // Switch to new session for agent-specific chat (clear old messages) if (agentId !== 'rose') { switchToAgentChat(agentId); } } async function switchToAgentChat(agentId) { // Create a fresh session for this agent if (typeof newSession === 'function') { await newSession(); } // Update topbar title to show agent context const topMeta = $('topbarMeta'); if (topMeta && AGENT_META && AGENT_META[agentId]) { const meta = AGENT_META[agentId]; topMeta.textContent = `Chatting with ${meta.emoji} ${meta.name}`; } } async function updateAgentSelectorDisplay() { const agentId = localStorage.getItem('hermes.chat_agent') || 'rose'; const icon = $('agentSelectorIcon'); const label = $('agentSelectorLabel'); if (agentId === 'rose') { icon.textContent = '🌹'; label.textContent = 'Rose'; } else { try { const data = await api(`/api/agents/${agentId}`); if (data.agent) { icon.textContent = data.agent.emoji || '🤖'; label.textContent = data.agent.name || agentId; } } catch(e) { label.textContent = agentId; } } } async function showAgentChatMode(agentId) { // Switch main chat to agent-specific mode // For now, just reload the chat panel if (typeof switchPanel === 'function') { switchPanel('chat'); } // Reload messages to show agent context if (typeof loadSession === 'function') { await loadSession(S.session?.session_id); } } // Close dropdown when clicking outside document.addEventListener('click', function(e) { const wrap = $('agentSelectorWrap'); if (wrap && !wrap.contains(e.target as Node) && _agentSelectorOpen) { $('agentSelectorDropdown').style.display = 'none'; _agentSelectorOpen = false; } }); // Simple markdown renderer (bold, italic, code, headers, lists, linebreaks) function renderMarkdown(text) { if (!text) return ''; return esc(text) .replace(/<(\/?)(pre|code|strong|b|em|i|li|ul|ol|h[1-6]|br|p)>/gi, '<$1$2>') .replace(/\*\*(.+?)\*\*/g, '$1') .replace(/\*(.+?)\*/g, '$1') .replace(/`(.+?)`/g, '$1') .replace(/^### (.+)$/gm, '

$1

') .replace(/^## (.+)$/gm, '

$1

') .replace(/^# (.+)$/gm, '

$1

') .replace(/^- (.+)$/gm, '
  • $1
  • ') .replace(/^(\d+)\. (.+)$/gm, '
  • $2
  • ') .replace(/\n/g, '
    '); } // ── Logs Panel ──────────────────────────────────────────────────────────────── let _currentLogFile = null; let _currentLogContent = ''; let _currentLogLevel = 'all'; let _currentLogSearch = ''; let _logAutoRefreshInterval = null; async function loadLogsPanel() { const el = $('logsFileList'); if (!el) return; el.innerHTML = '
    Loading...
    '; try { const data = await api('/api/logs'); if (!data.logs) return; el.innerHTML = ''; data.logs.forEach(log => { const item = document.createElement('div'); item.className = 'logs-sidebar-item' + (log.missing ? ' missing' : '') + (_currentLogFile === log.name ? ' active' : ''); item.onclick = () => { if (!log.missing) selectLog(log.name); }; item.innerHTML = `
    ${esc(log.name)}
    ${log.missing ? 'Missing' : log.size_human + ' • ' + (log.modified ? _formatDate(log.modified) : 'Unknown')}
    `; el.appendChild(item); }); } catch(e) { el.innerHTML = '
    Failed to load logs.
    '; } } async function selectLog(name) { _currentLogFile = name; _currentLogLevel = 'all'; _currentLogSearch = ''; $('logsSearchInput').value = ''; $('logsFileName').textContent = name; // Show toolbar controls $('logsSearchInput').style.display = ''; $('logsLevelBtns').style.display = ''; $('logsAutoRefreshLabel').style.display = ''; $('btnRefreshLog').style.display = ''; $('logsFooter').style.display = ''; $('logsContent').innerHTML = '
    Loading...
    '; // Reset level buttons document.querySelectorAll('.log-level-btn').forEach(b => b.classList.toggle('active', b.dataset.level === 'all')); try { const data = await api('/api/logs/' + encodeURIComponent(name)); _currentLogContent = data.content || ''; _applyLogFilter(); } catch(e) { $('logsContent').innerHTML = '
    Failed to load log.
    '; } // Update active state in list document.querySelectorAll('.logs-sidebar-item').forEach(el => { el.classList.toggle('active', el.querySelector('.logs-sidebar-name').textContent === name); }); // Stop auto-refresh if running for different log if (_logAutoRefreshInterval) { clearInterval(_logAutoRefreshInterval); _logAutoRefreshInterval = null; } } function _applyLogFilter() { let content = _currentLogContent; let lines = content.split('\n'); // Filter by level if (_currentLogLevel !== 'all') { const levelMap = { ERROR: ['ERROR', 'CRITICAL', 'FATAL'], WARN: ['WARNING', 'WARN'], INFO: ['INFO', 'DEBUG', 'TRACE'] }; const allowed = levelMap[_currentLogLevel] || [_currentLogLevel]; lines = lines.filter(line => allowed.some(l => line.toUpperCase().includes(l))); } // Filter by search const searchLower = _currentLogSearch ? _currentLogSearch.toLowerCase() : ''; if (searchLower) { lines = lines.filter(line => line.toLowerCase().includes(searchLower)); } // Render with highlighting const total = _currentLogContent.split('\n').length; const shown = lines.length; $('logsMatchCount').textContent = searchLower || _currentLogLevel !== 'all' ? `${shown} of ${total} lines shown` : `${total} lines`; if (lines.length === 0) { $('logsContent').innerHTML = '(no matches)'; return; } // Escape first, then highlight search terms const highlighted = lines.map((line, i) => { const escaped = esc(line) || ' '; if (searchLower) { // Highlight all occurrences of search term (case-insensitive, preserves case in display) const regex = new RegExp(escRegex(_currentLogSearch), 'gi'); return `${escaped.replace(regex, m => `${m}`)}`; } return `${escaped}`; }).join('\n'); $('logsContent').innerHTML = highlighted; // Auto-scroll to first match when searching if (searchLower) { const pre = $('logsContent'); const first = pre.querySelector('.log-highlight'); if (first) { first.scrollIntoView({ behavior: 'smooth', block: 'center' }); } } else { // Normal: scroll to bottom (newest) const pre = $('logsContent'); pre.scrollTop = pre.scrollHeight; } } // Escape special regex characters function escRegex(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function filterLogContent() { _currentLogSearch = $('logsSearchInput').value; _applyLogFilter(); } function setLogLevel(level) { _currentLogLevel = level; document.querySelectorAll('.log-level-btn').forEach(b => b.classList.toggle('active', b.dataset.level === level)); _applyLogFilter(); } function toggleLogAutoRefresh() { const enabled = $('logsAutoRefresh').checked; if (enabled) { if (!_logAutoRefreshInterval) { _logAutoRefreshInterval = setInterval(() => refreshLog(), 5000); } } else { if (_logAutoRefreshInterval) { clearInterval(_logAutoRefreshInterval); _logAutoRefreshInterval = null; } } } async function refreshLog() { if (!_currentLogFile) return; try { const data = await api('/api/logs/' + encodeURIComponent(_currentLogFile)); _currentLogContent = data.content || ''; _applyLogFilter(); // Auto-scroll to bottom if near bottom const pre = $('logsContent'); if (pre.scrollHeight - pre.scrollTop - pre.clientHeight < 100) { pre.scrollTop = pre.scrollHeight; } } catch(e) { // Silent fail on auto-refresh } } async function refreshLogManual() { if (!_currentLogFile) return; try { const data = await api('/api/logs/' + encodeURIComponent(_currentLogFile)); _currentLogContent = data.content || ''; _applyLogFilter(); const pre = $('logsContent'); pre.scrollTop = pre.scrollHeight; showToast('Log refreshed'); } catch(e) { showToast('Refresh failed'); } } function _formatDate(ts) { if (!ts) return ''; const d = new Date(ts * 1000); return d.toLocaleDateString(undefined, {month:'short', day:'numeric'}) + ' ' + d.toLocaleTimeString(undefined, {hour:'2-digit', minute:'2-digit'}); } // ── Heartbeats panel ───────────────────────────────────────────────────────── let _heartbeatsCache = null; async function loadHeartbeatsPanel() { const panel = $('heartbeatsPanelContent'); if (!panel) return; try { _heartbeatsCache = await api('/api/heartbeats'); renderHeartbeatsList(_heartbeatsCache); } catch (e) { panel.innerHTML = '
    Failed to load heartbeats: ' + e.message + '
    '; } } function renderHeartbeatsList(data) { const panel = $('heartbeatsPanelContent'); if (!panel) return; const hb = data.heartbeats || []; const total = data.total || 0; const pending = data.pending_due_count || 0; const manager = data._manager || {}; let html = `
    ${total} total ${data.by_status?.pending || 0} pending ${pending} due now
    Manager ${manager.running ? 'running (PID ' + manager.pid + ')' : 'stopped'}

    New Heartbeat

    Active Heartbeats (${hb.length})

    ${hb.length === 0 ? '
    No heartbeats yet
    ' : ''}
    `; for (const h of hb) { const isRecurring = h.recurring; const iterationInfo = isRecurring && h.max_iterations ? ` (${h.iteration_count || 0}/${h.max_iterations})` : isRecurring ? ' (∞)' : ''; const statusClass = h.status === 'pending' ? 'hb-item-pending' : 'hb-item-' + h.status; const dueInfo = h.status === 'pending' ? `→ ${_nextIn(h.trigger_at)}` : h.status; html += `
    ${h.action} ${h.source} ${h.priority || 'n'} ${dueInfo} ${iterationInfo}
    ${_escapeHtml(h.instruction || h.user_message || '')}
    `; } html += ''; // Also attach manager info from separate call panel.innerHTML = html; // Toggle recurring fields const recurringCheck = document.getElementById('hb-recurring'); const intervalInput = document.getElementById('hb-interval'); const maxiterInput = document.getElementById('hb-maxiter'); const agentSelect = document.getElementById('hb-agent'); const actionSelect = document.getElementById('hb-action'); if (recurringCheck) recurringCheck.addEventListener('change', () => { const show = recurringCheck.checked; intervalInput.style.display = show ? '' : 'none'; maxiterInput.style.display = show ? '' : 'none'; }); if (actionSelect) actionSelect.addEventListener('change', () => { agentSelect.style.display = actionSelect.value === 'delegate' ? '' : 'none'; }); } function _escapeHtml(s) { return String(s).replace(/&/g,'&').replace(//g,'>'); } async function hbQuickReminder() { const instr = prompt('Reminder text for Rose:'); if (!instr) return; await api('/api/heartbeats', { method: 'POST', body: JSON.stringify({ source: 'webui', action: 'rose_continue', instruction: instr, minutes: 5, priority: 'high' }) }); showToast('Reminder set'); await loadHeartbeatsPanel(); } async function hbQuickDailyCheck() { await api('/api/heartbeats', { method: 'POST', body: JSON.stringify({ source: 'webui', action: 'rose_continue', instruction: 'Daily calendar check — any meetings or conflicts today?', minutes: 0, recurring: true, interval_minutes: 1440, max_iterations: 30 }) }); showToast('Daily check created (30 days)'); await loadHeartbeatsPanel(); } async function hbQuickMemoryCheck() { await api('/api/heartbeats', { method: 'POST', body: JSON.stringify({ source: 'webui', action: 'rose_continue', instruction: 'Memory Health Check — run /search memory health', minutes: 60, recurring: true, interval_minutes: 60, max_iterations: 999 }) }); showToast('Hourly memory check created'); await loadHeartbeatsPanel(); } async function hbCreate() { const instruction = document.getElementById('hb-instruction')?.value.trim(); const action = document.getElementById('hb-action')?.value; const minutes = parseInt(document.getElementById('hb-minutes')?.value) || 5; const priority = document.getElementById('hb-priority')?.value || undefined; const recurring = document.getElementById('hb-recurring')?.checked; const interval_minutes = parseInt(document.getElementById('hb-interval')?.value) || minutes; const max_iterations = parseInt(document.getElementById('hb-maxiter')?.value) || undefined; const target_agent = action === 'delegate' ? document.getElementById('hb-agent')?.value : undefined; if (!instruction) { showToast('Please enter an instruction'); return; } const body = { source: 'webui', action, instruction, minutes, recurring }; if (priority) (body as any).priority = priority; if (recurring) { (body as any).interval_minutes = interval_minutes; if (max_iterations) (body as any).max_iterations = max_iterations; } if (target_agent) (body as any).target_agent = target_agent; try { await api('/api/heartbeats', { method: 'POST', body: JSON.stringify(body) }); showToast('Heartbeat created'); document.getElementById('hb-instruction').value = ''; await loadHeartbeatsPanel(); } catch (e) { showToast('Error: ' + e.message); } } async function hbCancel(id) { if (!confirm('Cancel heartbeat ' + id + '?')) return; try { await api('/api/heartbeats/' + id, { method: 'DELETE' }); showToast('Cancelled'); await loadHeartbeatsPanel(); } catch (e) { showToast('Error: ' + e.message); } } function _nextInProject(dateStr) { if (!dateStr) return '—'; const diff = new Date(dateStr).getTime() - Date.now(); if (diff <= 0) return 'now'; const mins = Math.floor(diff / 60000); if (mins < 60) return `in ${mins}m`; const hours = Math.floor(mins / 60); if (hours < 24) return `in ${hours}h ${mins % 60}m`; return `in ${Math.floor(hours / 24)}d`; } // ── Projects panel ────────────────────────────────────────────────────────── let projectsState = { projects: [], allTasks: [], filter: { type: 'all', priority: null }, expanded: false }; async function loadProjectsPanel() { try { const [projectsRes, tasksRes, statsRes] = await Promise.all([ api('/api/projects'), api('/api/projects/tasks'), api('/api/projects/stats') ]); // Merge projects from projects/ folder (via our api) with tasks projectsState.projects = projectsRes.projects || []; projectsState.allTasks = (tasksRes.tasks || []).map(t => ({ ...t, status: (t.status === 'pending' || !t.status) ? 'todo' : t.status })); renderProjectsStats(statsRes); renderProjectsList(); renderKanban(); // Set "All" filter button as active document.querySelectorAll('.filter-btn[data-filter]').forEach(btn => { btn.classList.toggle('active', btn.dataset.filter === 'all'); }); } catch(e) { console.error('loadProjectsPanel:', e); } } function renderProjectsStats(stats) { // Header stats bar const headerEl = $('projectsHeaderStats'); if (headerEl) { headerEl.innerHTML = ` 📋 ${stats.total_tasks || 0} ${stats.done || 0} 🎯 ${stats.today_completed || 0} `; } // Filter bar streak & overdue const streakEl = $('filterStreak'); if (streakEl) streakEl.textContent = stats.streak ? `🔥 ${stats.streak}` : ''; const overdueEl = $('filterOverdue'); if (overdueEl) { if (stats.overdue > 0) { overdueEl.textContent = `⚠️ ${stats.overdue}`; overdueEl.style.display = ''; } else { overdueEl.style.display = 'none'; } } } function renderProjectsList() { // Projects sidebar const list = $('projectsList'); if (list) { list.innerHTML = projectsState.projects.map(p => `
    ${p.name || p.id} ${(p.tasks || []).length}
    `).join(''); } // Daily tasks const dailyList = $('dailyTasksList'); const dailyTasks = projectsState.allTasks.filter(t => t.task_type === 'daily'); if (dailyList) { dailyList.innerHTML = dailyTasks.length ? dailyTasks.map(t => renderTaskItem(t)).join('') : '
    No daily tasks
    '; } // Recurring tasks const recList = $('recurringTasksList'); const recTasks = projectsState.allTasks.filter(t => t.task_type === 'recurring'); if (recList) { recList.innerHTML = recTasks.length ? recTasks.map(t => renderTaskItem(t)).join('') : '
    No recurring tasks
    '; } } function renderTaskItem(t) { const prioColors = { p1: '🔴', p2: '🟡', p3: '🟢' }; const prio = prioColors[t.priority] || '⚪'; const done = t.status === 'done' ? '✓' : '○'; const doneCls = t.status === 'done' ? 'done' : ''; const due = t.due ? `📅 ${t.due}` : ''; const tags = (t.tags || []).map(tag => `${esc(tag)}`).join(''); const ownerBadgeMap = { 'user': '👤', 'rose': '🌹', 'agent:lotus': '🪷', 'agent:sunflower': '🌻', 'agent:iris': '⚜️', 'agent:ivy': '🌿', 'agent:dandelion': '🛡️', 'agent:root': '🌳' }; const ownerBadge = ownerBadgeMap[t.owner] || ''; return `
    ${done} ${esc(t.title)} ${ownerBadge} ${prio} ${due} ${tags}
    `; } function renderKanban() { const tasks = getFilteredTasks(); const cols = { todo: tasks.filter(t => t.status === 'todo'), in_progress: tasks.filter(t => t.status === 'in_progress'), review: tasks.filter(t => t.status === 'review'), done: tasks.filter(t => t.status === 'done') }; for (const [colId, colTasks] of Object.entries(cols)) { const el = $(`kanban${colId.split('_').map((p, i) => i === 0 ? p : p.charAt(0).toUpperCase() + p.slice(1)).join('')}Content`); if (el) { el.innerHTML = colTasks.map(t => renderKanbanCard(t)).join(''); } // Update column count badge const countEl = $(`kanban${colId.split('_').map((p, i) => i === 0 ? p : p.charAt(0).toUpperCase() + p.slice(1)).join('')}Count`); if (countEl) countEl.textContent = String(colTasks.length || ''); } } function renderKanbanCard(t) { const prioColors = { p1: 'var(--accent)', p2: 'var(--warning)', p3: 'var(--success)' }; const borderColor = prioColors[t.priority] || 'var(--border)'; const projectLabel = t.project_name ? `${esc(t.project_name)}` : ''; const today = new Date().toISOString().slice(0, 10); const isOverdue = t.due && t.due < today && t.status !== 'done'; const due = t.due ? `📅 ${esc(t.due)}` : ''; const tags = (t.tags || []).slice(0, 2).map(tag => `${esc(tag)}`).join(''); const prioLabel = { p1: '🔴', p2: '🟡', p3: '🟢' }[t.priority] || ''; const ownerBadge = t.owner ? `${esc(t.owner)}` : ''; return `
    ${prioLabel} ${esc(t.title)}
    ${projectLabel} ${ownerBadge}
    `; } function getFilteredTasks() { let tasks = projectsState.allTasks; const f = projectsState.filter; if (f.type === 'daily') { tasks = projectsState.allTasks.filter(t => t.task_type === 'daily'); } else if (f.type === 'recurring') { tasks = projectsState.allTasks.filter(t => t.task_type === 'recurring'); } else if (f.type === 'project') { tasks = projectsState.allTasks.filter(t => t.task_type === 'project'); } // 'all' shows everything (no filter) if (f.priority) { tasks = tasks.filter(t => t.priority === f.priority); } return tasks; } function filterTasks(type) { if (['all', 'project', 'daily', 'recurring'].includes(type)) { projectsState.filter.type = type; document.querySelectorAll('.filter-btn[data-filter]').forEach(btn => { if (['all', 'project', 'daily', 'recurring'].includes(btn.dataset.filter)) { btn.classList.toggle('active', btn.dataset.filter === type); } }); } else if (['p1', 'p2', 'p3'].includes(type)) { if (projectsState.filter.priority === type) { projectsState.filter.priority = null; } else { projectsState.filter.priority = type; } document.querySelectorAll('.filter-btn[data-filter]').forEach(btn => { if (['p1', 'p2', 'p3'].includes(btn.dataset.filter)) { btn.classList.toggle('active', btn.dataset.filter === projectsState.filter.priority); } }); } renderKanban(); } async function quickAddTask() { const input = $('quickAddInput'); const typeSelect = $('quickAddType'); const dueInput = $('quickAddDue'); const title = input.value.trim(); if (!title) return; const task = { title, task_type: typeSelect.value, status: 'todo', priority: 'p2', project_id: projectsState.projects[0]?.id || null, due: dueInput.value || null, tags: [] }; try { await api('/api/projects/tasks', { method: 'POST', body: JSON.stringify(task) }); input.value = ''; dueInput.value = ''; await loadProjectsPanel(); } catch(e) { console.error('quickAddTask:', e); showToast('Error: ' + e.message); } } async function toggleTaskStatus(taskId) { const task = projectsState.allTasks.find(t => t.id === taskId); if (!task) return; const newStatus = task.status === 'done' ? 'todo' : 'done'; const completed = newStatus === 'done' ? new Date().toISOString() : null; try { await api(`/api/projects/tasks/${taskId}`, { method: 'PUT', body: JSON.stringify({ status: newStatus, completed }) }); await loadProjectsPanel(); } catch(e) { console.error('toggleTaskStatus:', e); } } let _currentTaskId = null; let _originalTaskStatus = null; function openTaskModal(taskId) { const task = projectsState.allTasks.find(t => t.id === taskId); if (!task) return; _currentTaskId = taskId; _originalTaskStatus = task.status; const modal = $('taskDetailModal'); const title = $('taskModalTitle'); const inputTitle = $('taskModalInputTitle'); const selStatus = $('taskModalSelectStatus'); const selPrio = $('taskModalSelectPrio'); const inputDue = $('taskModalInputDue'); const selOwner = $('taskModalSelectOwner'); const inputTags = $('taskModalInputTags'); const meta = $('taskModalMeta'); const ownerLabels = { 'user': '👤 Sabo', 'rose': '🌹 Rose', 'agent:lotus': '🪷 Lotus', 'agent:sunflower': '🌻 Sunflower', 'agent:iris': '⚜️ Iris', 'agent:ivy': '🌿 Ivy', 'agent:dandelion': '🛡️ Dandelion', 'agent:root': '🌳 Root' }; title.textContent = task.title; inputTitle.value = task.title; selStatus.value = task.status || 'todo'; selPrio.value = task.priority || 'p2'; inputDue.value = task.due || ''; selOwner.value = task.owner || 'user'; inputTags.value = (task.tags || []).join(', '); const created = task.created ? new Date(task.created).toLocaleDateString() : '—'; const updated = task.updated ? new Date(task.updated).toLocaleDateString() : '—'; meta.textContent = `ID: ${task.id} | Created: ${created} | Updated: ${updated}`; modal.style.display = 'flex'; } function closeTaskModal() { $('taskDetailModal').style.display = 'none'; _currentTaskId = null; } async function saveTaskFromModal() { if (!_currentTaskId) return; const title = $('taskModalInputTitle').value.trim(); if (!title) { showToast('Title cannot be empty'); return; } const updates = { title, status: $('taskModalSelectStatus').value, priority: $('taskModalSelectPrio').value, due: $('taskModalInputDue').value || null, owner: $('taskModalSelectOwner').value, tags: $('taskModalInputTags').value.split(',').map(t => t.trim()).filter(Boolean) }; if (updates.status === 'done' && _originalTaskStatus !== 'done') { (updates as any).completed = new Date().toISOString(); } try { await api(`/api/projects/tasks/${_currentTaskId}`, { method: 'PUT', body: JSON.stringify(updates) }); closeTaskModal(); await loadProjectsPanel(); showToast('Task saved'); } catch(e) { showToast('Error saving task: ' + e.message); } } async function deleteTaskFromModal() { if (!_currentTaskId) return; const task = projectsState.allTasks.find(t => t.id === _currentTaskId); if (!confirm(`Delete task "${task?.title}"? This cannot be undone.`)) return; try { await api(`/api/projects/tasks/${_currentTaskId}`, { method: 'DELETE' }); closeTaskModal(); await loadProjectsPanel(); showToast('Task deleted'); } catch(e) { showToast('Error deleting task: ' + e.message); } } // Legacy: Kanban card click opens modal function openTaskDetail(taskId) { openTaskModal(taskId); } function expandPanel(panelName) { const panelEl = $(`panel${panelName.charAt(0).toUpperCase() + panelName.slice(1)}`); if (!panelEl) return; const isExpanded = panelEl.classList.contains('panel-expanded'); panelEl.classList.toggle('panel-expanded', !isExpanded); const btn = $(`btnExpand${panelName.charAt(0).toUpperCase() + panelName.slice(1)}`); if (btn) { if (!isExpanded) { btn.innerHTML = ``; btn.title = 'Collapse'; } else { btn.innerHTML = ``; btn.title = 'Expand'; } } if (!isExpanded) { document.addEventListener('keydown', _escHandler = (e) => { if (e.key === 'Escape') { expandPanel(panelName); document.removeEventListener('keydown', _escHandler); } }); } } // Event wiring // ── Kanban Drag & Drop ──────────────────────────────────────────────────────── let draggedTaskId = null; function onKanbanDragStart(e, taskId) { draggedTaskId = taskId; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', taskId); e.target.style.opacity = '0.5'; } function onKanbanDragEnd(e) { e.target.style.opacity = '1'; draggedTaskId = null; } function onKanbanDragOver(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } async function onKanbanDrop(e, newStatus) { e.preventDefault(); if (!draggedTaskId) return; const taskId = draggedTaskId; draggedTaskId = null; try { await api(`/api/projects/tasks/${taskId}`, { method: 'PUT', body: JSON.stringify({ status: newStatus }) }); await loadProjectsPanel(); } catch(err) { showToast('Move failed: ' + err.message); } } // ── Task Edit Modal ──────────────────────────────────────────────────────────── let editingTaskId = null; let selectedProjectColor = '#6366f1'; function openTaskEditModal(taskId) { const task = (projectsState as any).tasks?.find((t: any) => t.id === taskId) || projectsState.allTasks.find((t: any) => t.id === taskId); if (!task) return; editingTaskId = taskId; $('editTaskTitle').value = task.title || ''; $('editTaskType').value = task.task_type || 'project'; $('editTaskPriority').value = task.priority || 'p2'; $('editTaskDue').value = task.due || ''; // Populate project dropdown const projSelect = $('editTaskProject'); projSelect.innerHTML = ''; projectsState.projects.forEach(p => { const opt = document.createElement('option'); opt.value = p.id; opt.textContent = p.name; if (p.id === task.project_id) opt.selected = true; projSelect.appendChild(opt); }); // Recurring options visibility $('editTaskRecurringOpts').style.display = task.task_type === 'recurring' ? 'block' : 'none'; if (task.recurring) { $('editRecInterval').value = task.recurring.interval || 1; $('editRecUnit').value = task.recurring.unit || 'days'; } $('taskEditModal').style.display = 'flex'; } function closeTaskEditModal() { $('taskEditModal').style.display = 'none'; editingTaskId = null; } function onEditTypeChange() { $('editTaskRecurringOpts').style.display = $('editTaskType').value === 'recurring' ? 'block' : 'none'; } async function saveTaskEdit() { if (!editingTaskId) return; const updated = { title: $('editTaskTitle').value.trim(), task_type: $('editTaskType').value, priority: $('editTaskPriority').value, project_id: $('editTaskProject').value || null, due: $('editTaskDue').value || null, }; if ($('editTaskType').value === 'recurring') { (updated as any).recurring = { interval: parseInt($('editRecInterval').value) || 1, unit: $('editRecUnit').value }; } try { await api(`/api/projects/tasks/${editingTaskId}`, { method: 'PUT', body: JSON.stringify(updated) }); closeTaskEditModal(); await loadProjectsPanel(); } catch(e) { showToast('Error saving: ' + e.message); } } async function deleteTask(taskId) { if (!confirm('Delete this task?')) return; try { await api(`/api/projects/tasks/${taskId}`, { method: 'DELETE' }); await loadProjectsPanel(); } catch(e) { showToast('Error deleting: ' + e.message); } } // ── Project Edit Modal ───────────────────────────────────────────────────────── let editingProjectId = null; function openProjectEditModal(projectId) { const project = projectsState.projects.find(p => p.id === projectId); if (!project) return; editingProjectId = projectId; $('editProjectName').value = project.name || ''; selectedProjectColor = project.color || '#6366f1'; document.querySelectorAll('.color-dot').forEach(btn => { btn.classList.toggle('selected', btn.dataset.color === selectedProjectColor); }); $('projectEditModal').style.display = 'flex'; } function closeProjectEditModal() { $('projectEditModal').style.display = 'none'; editingProjectId = null; } function selectProjectColor(btn) { selectedProjectColor = btn.dataset.color; document.querySelectorAll('.color-dot').forEach(b => b.classList.remove('selected')); btn.classList.add('selected'); } async function saveProjectEdit() { if (!editingProjectId) return; const updated = { name: $('editProjectName').value.trim(), color: selectedProjectColor }; try { await api(`/api/projects/projects/${editingProjectId}`, { method: 'PUT', body: JSON.stringify(updated) }); closeProjectEditModal(); await loadProjectsPanel(); } catch(e) { showToast('Error saving: ' + e.message); } } async function deleteProject(projectId) { if (!confirm('Delete this project and all its tasks?')) return; try { await api(`/api/projects/projects/${projectId}`, { method: 'DELETE' }); await loadProjectsPanel(); } catch(e) { showToast('Error deleting: ' + e.message); } }