Files
webui-develop/static/panels.ts

4088 lines
170 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// <reference path="./global.d.ts" />
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 = `<div style="padding:16px;color:var(--muted);font-size:12px">${esc(t('cron_no_jobs'))}</div>`;
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 = `
<div class="cron-header" onclick="toggleCron('${job.id}')">
<div class="cron-left">
<div class="cron-name" title="${esc(job.name)}">${esc(job.name)}</div>
<div class="cron-meta">
<span class="cron-badge-sched">${esc(scheduleFriendly)}</span>
</div>
</div>
<div class="cron-right">
<div class="cron-next-time"><span class="cron-time-label">N:</span> ${esc(next)}</div>
<div class="cron-last-time"><span class="cron-time-label">L:</span> ${esc(last)}</div>
<span class="cron-status ${statusClass}">${esc(statusLabel)}</span>
</div>
</div>
<div class="cron-body" id="cron-body-${job.id}">
<div class="cron-detail-grid">
<div class="cron-detail-row">
<span class="cron-detail-label">${esc(t('cron_label_schedule'))}</span>
<span class="cron-detail-value">${esc(schedule)}</span>
</div>
<div class="cron-detail-row">
<span class="cron-detail-label">${esc(t('cron_label_next_run'))}</span>
<span class="cron-detail-value cron-next-val">${esc(next)}</span>
</div>
<div class="cron-detail-row">
<span class="cron-detail-label">${esc(t('cron_label_last_ran'))}</span>
<span class="cron-detail-value">${esc(last)}</span>
</div>
</div>
${promptPreview ? `<div class="cron-prompt-preview"><span class="cron-prompt-label">${esc(t('cron_label_prompt'))}</span><span class="cron-prompt-text">${esc(promptPreview)}</span></div>` : ''}
<div class="cron-actions">
<button class="cron-btn run" onclick="cronRun('${job.id}')">${li('play',12)} ${esc(t('cron_run_now'))}</button>
${job.state==='paused'
? `<button class="cron-btn" onclick="cronResume('${job.id}')">${li('play',12)} ${esc(t('cron_resume'))}</button>`
: `<button class="cron-btn pause" onclick="cronPause('${job.id}')">${li('pause',12)} ${esc(t('cron_pause'))}</button>`}
<button class="cron-btn" onclick="cronEditOpen('${job.id}',${JSON.stringify(job).replace(/"/g,'&quot;')})">${li('pencil',12)} ${esc(t('cron_label_edit'))}</button>
</div>
<div class="cron-dangers">
<button class="cron-btn danger" onclick="cronDelete('${job.id}')">${li('trash-2',12)} ${esc(t('cron_label_delete'))}</button>
</div>
<div id="cron-output-${job.id}">
<div class="cron-last-header">
<span>${esc(t('cron_last_output'))}</span>
<button class="cron-btn" style="padding:1px 8px;font-size:10px" onclick="loadCronHistory('${job.id}',this)">${esc(t('cron_all_runs'))}</button>
</div>
<div class="cron-last" id="cron-out-text-${job.id}"><span class="cron-spinner"></span></div>
<div id="cron-history-${job.id}" style="display:none"></div>
</div>
</div>`;
box.appendChild(item);
loadCronOutput(job.id);
}
} catch(e) { box.innerHTML = `<div style="padding:12px;color:var(--accent);font-size:12px">${esc(t('error_prefix'))}${esc(e.message)}</div>`; }
}
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<string, unknown> = {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 = `<div style="font-size:11px;color:var(--muted);padding:4px 0">${esc(t('cron_no_runs_yet'))}</div>`;
} 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 `<div style="border-top:1px solid var(--border);padding:6px 0">
<div style="display:flex;align-items:center;justify-content:space-between;cursor:pointer" onclick="document.getElementById('${id}').style.display=document.getElementById('${id}').style.display==='none'?'':'none'">
<span style="font-size:11px;font-weight:600;color:var(--muted)">${esc(ts)}</span>
<span style="font-size:10px;color:var(--muted);opacity:.6">▸</span>
</div>
<div id="${id}" style="display:none;font-size:11px;color:var(--muted);white-space:pre-wrap;line-height:1.5;margin-top:4px;max-height:200px;overflow-y:auto">${esc(snippet)}</div>
</div>`;
}).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 = '<span class="cron-running-text">⏳ '+esc(t('cron_running'))+'</span>';
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<string, unknown> = {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 = `<div style="padding:12px;color:var(--accent);font-size:12px">Error: ${esc(e.message)}</div>`; }
}
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<string, any[]> = {};
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 = `<div style="padding:12px;color:var(--muted);font-size:12px">${esc(t('skills_no_match'))}</div>`; 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 = `
<div class="skills-cat-header" onclick="toggleSkillCat(this)" data-cat="${esc(cat)}" style="cursor:pointer;user-select:none">
<span class="cat-chevron" style="display:inline-block;width:12px;text-align:center;transition:transform .15s">${collapsed ? '▶' : '▼'}</span>
${li('folder',12)} ${esc(displayName)} <span style="opacity:.5">(${items.length})</span>
</div>
<div class="skills-cat-items" style="${collapsed?'display:none':''}"></div>`;
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 = `
<div class="skill-main" onclick="openSkill('${esc(skill.name)}', this.parentElement)">
<span class="skill-name">${esc(skill.name)}</span>
<span class="skill-desc">${esc(skill.description||'')}</span>
</div>
<div class="skill-actions">
<span class="skill-btn edit" onclick="event.stopPropagation();skillEdit('${esc(skill.name)}')" title="Bearbeiten">${li('pencil',12)}</span>
<span class="skill-btn delete" onclick="event.stopPropagation();skillDelete('${esc(skill.name)}')" title="Löschen">${li('trash-2',12)}</span>
</div>`;
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 += `<div class="skill-linked-files"><div style="font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:8px">${esc(t('linked_files'))}</div>`;
for (const [cat, files] of categories) {
html += `<div class="skill-linked-section"><h4>${esc(cat)}</h4>`;
for (const f of (files as any)) {
html += `<a class="skill-linked-file" href="#" data-skill-name="${esc(name)}" data-skill-file="${esc(f)}">${esc(f)}</a>`;
}
html += '</div>';
}
html += '</div>';
}
$('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 '<span class="code-line"><span class="hl-comment">' + line + '</span></span>';
}
// Highlight inline comments (after a value)
line = line.replace(/(#.*)$/, '<span class="hl-comment">$1</span>');
// Highlight strings (double or single quoted)
line = line.replace(/(&quot;[^&]*?&quot;|&#39;[^&]*?&#39;)/g, '<span class="hl-string">$1</span>');
// Highlight key: value pairs — key before the colon
line = line.replace(/^(\s*)([\w._-]+)(:)/, '$1<span class="hl-key">$2</span><span class="hl-value">$3</span>');
return '<span class="code-line">' + line + '</span>';
}).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 =>
`<div class="skill-cat-opt" onclick="selectSkillCat('${esc(c)}')" style="padding:6px 10px;cursor:pointer;font-size:12px;color:var(--muted);transition:background .1s">${esc(c)}</div>`
).join('');
if (q && !cats.some(c => c.toLowerCase() === q)) {
dropdown.innerHTML += `<div class="skill-cat-opt" onclick="selectSkillCat('${esc(q)}')" style="padding:6px 10px;cursor:pointer;font-size:12px;color:var(--blue);font-style:italic">+ "${q}" erstellen</div>`;
}
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=`<span class="ws-opt-icon">${iconSvg}</span><span><span class="ws-opt-name">${esc(label)}</span>${meta?`<span class="ws-opt-meta">${esc(meta)}</span>`:''}</span>`;
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=`<span class="ws-opt-name">${esc(w.name)}</span><span class="ws-opt-path">${esc(w.path)}</span>`;
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='<div style="padding:12px 16px;font-size:12px;color:var(--muted)">Keine Workspaces</div>';
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=`<div style="font-weight:${isActive?'700':'500'};${isActive?'color:var(--blue)':''}">${esc(w.name||w.path)}</div><div style="font-size:10px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:180px">${esc(w.path)}</div>`;
const right=document.createElement('div');
if(isActive) right.innerHTML=`<span style="color:var(--blue);font-size:11px">✓</span>`;
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=`
<div class="ws-row-info">
<div class="ws-row-name">${esc(w.name)}</div>
<div class="ws-row-path">${esc(w.path)}</div>
</div>
<div class="ws-row-actions">
<button class="ws-action-btn" title="${esc(t('workspace_use_title'))}" onclick="switchToWorkspace('${esc(w.path)}','${esc(w.name)}')">${li('arrow-right',12)} ${esc(t('workspace_use'))}</button>
<button class="ws-action-btn danger" title="${esc(t('remove'))}" onclick="removeWorkspace('${esc(w.path)}')">${li('x',12)}</button>
</div>`;
panel.appendChild(row);
}
const addRow=document.createElement('div');addRow.className='ws-add-row';
addRow.innerHTML=`
<input id="wsAddInput" placeholder="${esc(t('workspace_add_path_placeholder'))}" style="flex:1;background:rgba(255,255,255,.06);border:1px solid var(--border2);border-radius:7px;color:var(--text);padding:7px 10px;font-size:12px;outline:none;">
<button class="ws-action-btn" onclick="addWorkspace()">${li('plus',12)} ${esc(t('add'))}</button>`;
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 = `<div style="padding:16px;color:var(--muted);font-size:12px">${esc(t('profiles_no_profiles'))}</div>`;
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
? `<span class="profile-opt-badge running" title="${esc(t('profile_gateway_running'))}"></span>`
: `<span class="profile-opt-badge stopped" title="${esc(t('profile_gateway_stopped'))}"></span>`;
const isActive = p.name === data.active;
const activeBadge = isActive ? `<span style="color:var(--link);font-size:10px;font-weight:600;margin-left:6px">${esc(t('profile_active'))}</span>` : '';
card.innerHTML = `
<div class="profile-card-header">
<div style="min-width:0;flex:1">
<div class="profile-card-name${isActive ? ' is-active' : ''}">${gwDot}${esc(p.name)}${p.is_default ? ' <span style="opacity:.5">(default)</span>' : ''}${activeBadge}</div>
${meta.length ? `<div class="profile-card-meta">${esc(meta.join(' \u00b7 '))}</div>` : `<div class="profile-card-meta">${esc(t('profile_no_configuration'))}</div>`}
</div>
<div class="profile-card-actions">
${!isActive ? `<button class="ws-action-btn" onclick="switchToProfile('${esc(p.name)}')" title="${esc(t('profile_switch_title'))}">${esc(t('profile_use'))}</button>` : ''}
${!p.is_default ? `<button class="ws-action-btn danger" onclick="deleteProfile('${esc(p.name)}')" title="${esc(t('profile_delete_title'))}">${li('x',12)}</button>` : ''}
</div>
</div>`;
panel.appendChild(card);
}
} catch (e) {
panel.innerHTML = `<div style="color:var(--accent);font-size:12px;padding:12px">Error: ${esc(e.message)}</div>`;
}
}
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 = `<span class="profile-opt-badge ${p.gateway_running ? 'running' : 'stopped'}"></span>`;
const checkmark = p.name === active ? ' <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--link)" stroke-width="3" style="vertical-align:-1px"><polyline points="20 6 9 17 4 12"/></svg>' : '';
opt.innerHTML = `<div class="profile-opt-name">${gwDot}${esc(p.name)}${p.is_default ? ' <span style="opacity:.5;font-weight:400">(default)</span>' : ''}${checkmark}</div>` +
(meta.length ? `<div class="profile-opt-meta">${esc(meta.join(' \u00b7 '))}</div>` : '');
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<string, unknown> = { 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 = `<div style="padding:16px;color:var(--muted);font-size:12px">${esc(t('gateways_no_gateways'))}</div>`;
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
? `<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--link);margin-right:6px"></span>`
: `<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--muted);margin-right:6px"></span>`;
const statusText = gw.running ? esc(t('gateway_running')) : esc(t('gateway_stopped'));
const typeMeta = gw.type ? `<span style="opacity:.7">${esc(gw.type)}</span>` : '';
card.innerHTML = `
<div class="profile-card-header">
<div style="min-width:0;flex:1">
<div class="profile-card-name">${statusDot}${esc(gw.name)}</div>
<div class="profile-card-meta">${statusText}${typeMeta ? ' · ' + typeMeta : ''}</div>
</div>
<div class="profile-card-actions">
${gw.running
? `<button class="ws-action-btn danger" onclick="stopGateway('${esc(gw.name)}')" title="${esc(t('gateway_stop_title'))}">${esc(t('gateway_stop'))}</button>`
: `<button class="ws-action-btn" onclick="startGateway('${esc(gw.name)}')" title="${esc(t('gateway_start_title'))}">${esc(t('gateway_start'))}</button>`
}
<button class="ws-action-btn" onclick="restartGateway('${esc(gw.name)}')" title="${esc(t('gateway_restart_title'))}">${esc(t('gateway_restart'))}</button>
</div>
</div>
${gw.info ? `<div style="font-size:11px;color:var(--muted);padding:4px 0 8px">${esc(gw.info)}</div>` : ''}`;
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 = `<div style="color:var(--accent);font-size:12px;padding:12px">Error: ${esc(e.message)}</div>`;
}
}
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 = `
<div class="memory-section">
<div class="memory-section-title">
<span style="display:inline-flex;align-items:center;gap:6px">${li('brain',14)} ${esc(t('my_notes'))}</span>
<span class="memory-mtime">${fmtTime(data.memory_mtime)}</span>
</div>
${data.memory
? `<div class="memory-content preview-md">${renderMd(maskSensitive(data.memory))}</div>`
: `<div class="memory-empty">${esc(t('no_notes_yet'))}</div>`}
</div>
<div class="memory-section">
<div class="memory-section-title">
<span style="display:inline-flex;align-items:center;gap:6px">${li('user',14)} ${esc(t('user_profile'))}</span>
<span class="memory-mtime">${fmtTime(data.user_mtime)}</span>
</div>
${data.user
? `<div class="memory-content preview-md">${renderMd(maskSensitive(data.user))}</div>`
: `<div class="memory-empty">${esc(t('no_profile_yet'))}</div>`}
</div>`;
} catch(e) { panel.innerHTML = `<div style="color:var(--accent);font-size:12px">${esc(t('error_prefix'))}${esc(e.message)}</div>`; }
}
// 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 = `<span style="color:var(--text)">${esc(t('settings_unsaved_changes'))}</span>`
+ '<span style="display:flex;gap:8px">'
+ `<button onclick="_discardSettings()" style="padding:5px 12px;border-radius:6px;border:1px solid var(--border2);background:rgba(255,255,255,.06);color:var(--muted);cursor:pointer;font-size:12px;font-weight:600">${esc(t('discard'))}</button>`
+ `<button onclick="saveSettings(true)" style="padding:5px 12px;border-radius:6px;border:none;background:var(--accent);color:#fff;cursor:pointer;font-size:12px;font-weight:600">${esc(t('save'))}</button>`
+ '</span>';
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<string, unknown> = {};
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=`<span>\u26a0 ${esc(msg)}</span><div style="display:flex;gap:6px;flex-shrink:0"><button class="reconnect-btn" onclick="navigateToErrorSession()">${esc(t('view'))}</button><button class="reconnect-btn" onclick="dismissErrorBanner()">${esc(t('dismiss'))}</button></div>`;
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 = [
`<button onclick="filterMCTasks('all')" style="background:var(--input-bg);border:1px solid var(--border);border-radius:20px;padding:3px 10px;font-size:10px;cursor:pointer;color:var(--text)" id="mcFilterAll">All</button>`,
...priorities.map(p =>
`<button onclick="filterMCTasks(${p.id})" style="background:var(--input-bg);border:1px solid var(--border);border-radius:20px;padding:3px 10px;font-size:10px;cursor:pointer;color:var(--text);display:flex;align-items:center;gap:3px" id="mcFilter${p.id}">${priorityEmoji[p.id] || '•'} ${esc(p.name)}</button>`
)
].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 = '<div style="color:var(--muted);font-size:11px;text-align:center;padding:20px">No tasks yet.<br><span style="font-size:10px">Add one above ↑</span></div>';
} 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 `<div style="background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:10px 12px;margin-bottom:6px">
<div style="display:flex;align-items:flex-start;gap:8px">
<span style="font-size:16px;flex-shrink:0;cursor:pointer" onclick="toggleMCTask(${t.id},'${t.status}')" title="Toggle status">${meta.icon}</span>
<div style="flex:1;min-width:0">
<div style="font-size:12px;color:var(--text);${t.status === 'done' ? 'text-decoration:line-through;opacity:0.5' : ''};word-break:break-word">${esc(t.title)}</div>
<div style="display:flex;align-items:center;gap:6px;margin-top:4px">
<span style="font-size:10px;${t.status === 'done' ? 'color:#4caf50' : t.status === 'progress' ? 'color:#ff9800' : 'color:var(--muted)'}">${meta.label}</span>
<span style="width:6px;height:6px;border-radius:50%;background:${p.color};flex-shrink:0"></span>
<span style="font-size:10px;color:var(--muted)">${emoji} ${esc(p.name)}</span>
</div>
</div>
<select onchange="updateMCTask(${t.id}, this.value)" style="background:var(--input-bg);border:1px solid var(--border);border-radius:6px;padding:3px 6px;font-size:9px;color:var(--text);cursor:pointer;flex-shrink:0">
<option value="backlog" ${t.status === 'backlog' ? 'selected' : ''}>○ Backlog</option>
<option value="progress" ${t.status === 'progress' ? 'selected' : ''}>◐ Progress</option>
<option value="done" ${t.status === 'done' ? 'selected' : ''}>● Done</option>
</select>
</div>
</div>`;
}).join('');
}
// ── Feed ──
const feedEl = $('mcFeed');
if (feed.length === 0) {
feedEl.innerHTML = '<div style="opacity:0.5;font-size:10px">No recent activity</div>';
} else {
feedEl.innerHTML = feed.map(f => {
const d = new Date(f.timestamp);
const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return `<div style="padding:2px 0;border-bottom:1px solid var(--border)">
<span style="color:var(--text);font-size:10px">${esc(f.event)}</span>
<span style="opacity:0.5;font-size:9px;margin-left:6px">${time}</span>
</div>`;
}).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 = `<div style="padding:12px;color:var(--accent);font-size:12px">Error: ${esc(e.message)}</div>`;
}
}
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'
? '<span class="agent-tier-badge tier-1">🌹 Tier-1</span>'
: '<span class="agent-tier-badge tier-2">Tier-2</span>';
const inboxBadge = a.inbox_count > 0
? `<span class="agent-inbox-badge">${a.inbox_count}</span>`
: '';
const disabled = a.disabled ? 'opacity:0.5;' : '';
return `<div class="agent-card${a.disabled ? ' agent-card-disabled' : ''}" onclick="openAgentDetail('${a.id}')" style="cursor:pointer;${disabled}">
<div class="agent-card-left">
<span style="font-size:28px;line-height:1">${a.emoji}</span>
</div>
<div class="agent-card-body">
<div class="agent-card-name">${esc(a.name)}</div>
<div class="agent-card-domain">${esc(a.domain)}</div>
<div class="agent-card-meta">
<span class="agent-status-dot" style="background:${color}"></span>
<span style="color:${color};font-size:10px;font-weight:600">${label}</span>
${tierBadge}
</div>
<div style="font-size:9px;color:var(--muted);margin-top:2px">${_relTime(a.last_activity)}</div>
</div>
<div class="agent-card-right">
${inboxBadge}
<span style="opacity:0.3;font-size:18px"></span>
</div>
</div>`;
}).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 = `<div style="padding:16px;color:var(--accent)">Error loading agent: ${esc(e.message)}</div><div style="padding:16px"><button onclick="closeAgentInbox()" class="cron-btn">Close</button></div>`;
box.style.display = 'block';
return;
}
const color = STATUS_COLORS[agent.status] || STATUS_COLORS.offline;
const tierBadge = agent.tier === 'orchestrator'
? '<span class="agent-tier-badge tier-1">🌹 Tier-1</span>'
: '<span class="agent-tier-badge tier-2">Tier-2</span>';
const lastAct = agent.last_activity ? new Date(agent.last_activity).toLocaleString() : 'N/A';
const canEdit = agentId !== 'rose';
box.innerHTML = `
<div class="agent-detail-header">
<div class="agent-detail-title">
<span style="font-size:28px">${agent.emoji}</span>
<div>
<div style="font-weight:700;font-size:15px">${esc(agent.name)}</div>
<div style="font-size:10px;opacity:0.6">${esc(agent.domain)}</div>
<div style="margin-top:4px">${tierBadge}</div>
</div>
</div>
<button onclick="closeAgentInbox()" style="background:rgba(255,255,255,.06);border:1px solid var(--border);border-radius:8px;padding:6px 10px;cursor:pointer;color:var(--muted);font-size:12px;flex-shrink:0">× Close</button>
</div>
<div class="agent-detail-status">
<div class="agent-status-row">
<span class="agent-status-dot lg" style="background:${color}"></span>
<span style="color:${color};font-weight:600;font-size:12px">${STATUS_LABELS[agent.status] || 'Offline'}</span>
${agent.pid ? `<span style="font-size:9px;color:var(--muted);margin-left:4px">PID ${agent.pid}</span>` : ''}
</div>
<div style="font-size:10px;color:var(--muted)">Last active: ${esc(lastAct)}</div>
${agent.default_model ? `<div style="font-size:10px;color:var(--muted)">Model: ${esc(agent.default_model)}</div>` : ''}
</div>
${agentId !== 'rose' ? `
<div class="agent-toggle-row">
<span style="font-size:11px;color:var(--muted)">Agent ${agent.disabled ? 'disabled' : 'enabled'}</span>
<button class="agent-toggle-btn${agent.disabled ? '' : ' on'}" onclick="toggleAgentEnabled('${agentId}', ${!agent.disabled})">
<span class="agent-toggle-knob"></span>
</button>
</div>
` : ''}
<div class="agent-detail-actions">
<button class="agent-action-btn primary" onclick="chatWithAgent('${agentId}')">
💬 Direct Chat
</button>
</div>
<div class="agent-tabs">
<button class="agent-tab${_agentTab==='overview'?' active':''}" onclick="switchAgentTab('overview')">Overview</button>
<button class="agent-tab${_agentTab==='soul'?' active':''}" onclick="switchAgentTab('soul')">soul.md</button>
<button class="agent-tab${_agentTab==='memory'?' active':''}" onclick="switchAgentTab('memory')">memory.md</button>
<button class="agent-tab${_agentTab==='inbox'?' active':''}" onclick="switchAgentTab('inbox')">
Inbox${agent.inbox_count > 0 ? ` <span class="agent-inbox-badge sm">${agent.inbox_count}</span>` : ''}
</button>
<button class="agent-tab${_agentTab==='activity'?' active':''}" onclick="switchAgentTab('activity')">Activity</button>
<button class="agent-tab${_agentTab==='errors'?' active':''}" onclick="switchAgentTab('errors')">Errors</button>
<button class="agent-tab${_agentTab==='chat'?' active':''}" onclick="switchAgentTab('chat')">Chat History</button>
<button class="agent-tab${_agentTab==='tasks'?' active':''}" onclick="switchAgentTab('tasks')">Tasks</button>
<button class="agent-tab${_agentTab==='bus'?' active':''}" onclick="switchAgentTab('bus')">Message Bus</button>
<button class="agent-tab${_agentTab==='usage'?' active':''}" onclick="switchAgentTab('usage')">Usage</button>
<button class="agent-tab${_agentTab==='topology'?' active':''}" onclick="switchAgentTab('topology')">Topology</button>
</div>
<div id="agentTabContent" class="agent-tab-content">
<div style="color:var(--muted);font-size:12px;text-align:center;padding:20px">Loading...</div>
</div>
`;
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 = `
<div class="agent-overview">
<div class="agent-info-row">
<span class="agent-info-label">Status</span>
<span style="display:flex;align-items:center;gap:4px">
<span class="agent-status-dot" style="background:${color}"></span>
<span style="color:${color};font-size:11px;font-weight:600">${STATUS_LABELS[agent.status] || 'Offline'}</span>
</span>
</div>
<div class="agent-info-row">
<span class="agent-info-label">Domain</span>
<span style="font-size:11px">${esc(agent.domain)}</span>
</div>
<div class="agent-info-row">
<span class="agent-info-label">Tier</span>
<span style="font-size:11px">${agent.tier === 'orchestrator' ? '🌹 Orchestrator (Tier-1)' : 'Tier-2 Domain Agent'}</span>
</div>
<div class="agent-info-row">
<span class="agent-info-label">Last Active</span>
<span style="font-size:11px">${agent.last_activity ? new Date(agent.last_activity).toLocaleString() : 'N/A'}</span>
</div>
${agent.default_model ? `
<div class="agent-info-row">
<span class="agent-info-label">Model</span>
<span style="font-size:11px;font-family:monospace">${esc(agent.default_model)}</span>
</div>` : ''}
${agent.inbox_count > 0 ? `
<div class="agent-info-row">
<span class="agent-info-label">Inbox</span>
<span style="font-size:11px"><span class="agent-inbox-badge">${agent.inbox_count}</span> unread messages</span>
</div>` : ''}
${agent.pid ? `
<div class="agent-info-row">
<span class="agent-info-label">Process</span>
<span style="font-size:11px;font-family:monospace">PID ${agent.pid}</span>
</div>` : ''}
</div>
`;
// 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 ? `<div class="health-bar"><div class="health-bar-fill" style="width:${Math.min(health.memory_mb / 512 * 100, 100)}%"></div></div>` : '';
const uptime = health.uptime_seconds > 0 ? _formatUptime(health.uptime_seconds) : 'N/A';
content.innerHTML += `
<div class="health-metrics" style="margin-top:12px;padding:12px;background:var(--card-bg);border:1px solid var(--border);border-radius:8px">
<div style="font-size:10px;font-weight:600;color:var(--muted);margin-bottom:8px;text-transform:uppercase">System Health</div>
<div class="agent-info-row">
<span class="agent-info-label">CPU</span>
<span style="font-size:11px">${health.cpu_percent}%</span>
</div>
<div class="agent-info-row">
<span class="agent-info-label">Memory</span>
<span style="font-size:11px">${health.memory_mb} MB ${memBar}</span>
</div>
<div class="agent-info-row">
<span class="agent-info-label">Threads</span>
<span style="font-size:11px">${health.threads}</span>
</div>
</div>`;
}
} catch(e) {}
} catch(e) {
content.innerHTML = `<div style="padding:12px;color:var(--accent);font-size:12px">Error: ${esc(e.message)}</div>`;
}
}
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 = `<div style="padding:16px;text-align:center;color:var(--muted);font-size:12px">
<div style="font-size:24px;margin-bottom:8px">📄</div>
<div>No soul.md found</div>
</div>`;
return;
}
content.innerHTML = `
<div id="soulView">
${canEdit ? `<button class="agent-edit-btn" onclick="editAgentSoul('${agentId}')">✏️ Edit</button>` : ''}
<div class="agent-md-content">${renderMarkdown(soul)}</div>
</div>
<div id="soulEdit" style="display:none">
<textarea id="soulEditArea" rows="18" style="width:100%;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:8px;padding:10px;font-family:monospace;font-size:11px;line-height:1.6;resize:vertical;outline:none;box-sizing:border-box">${esc(soul)}</textarea>
<div style="display:flex;gap:6px;margin-top:8px">
<button class="cron-btn run" onclick="saveAgentSoul('${agentId}')">💾 Save</button>
<button class="cron-btn" onclick="cancelEditSoul('${agentId}')">Cancel</button>
</div>
<div id="soulEditError" style="color:var(--accent);font-size:11px;margin-top:6px;display:none"></div>
</div>
`;
} catch(e) {
content.innerHTML = `<div style="padding:12px;color:var(--accent);font-size:12px">Error: ${esc(e.message)}</div>`;
}
}
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 = `
<div style="padding:0 0 8px;border-bottom:1px solid var(--border);margin-bottom:8px">
<div style="display:flex;gap:6px;align-items:center">
<input id="memSearchInput" placeholder="Search memory... (ChromaDB)" style="flex:1;background:var(--input-bg);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:6px 8px;font-size:11px;outline:none" onkeydown="if(event.key==='Enter')searchAgentMemory('${agentId}')">
<button class="cron-btn run" style="flex-shrink:0;padding:5px 10px" onclick="searchAgentMemory('${agentId}')">Search</button>
</div>
<div id="memSearchResults" style="display:none;margin-top:8px"></div>
</div>
<div id="memoryView">
${canEdit ? `<button class="agent-edit-btn" onclick="editAgentMemory('${agentId}')">✏️ Edit</button>` : ''}
<div class="agent-md-content">${memory ? renderMarkdown(memory) : '<div style="color:var(--muted);font-size:12px;text-align:center;padding:16px">No memory.md found</div>'}</div>
</div>
<div id="memoryEdit" style="display:none">
<textarea id="memoryEditArea" rows="18" style="width:100%;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:8px;padding:10px;font-family:monospace;font-size:11px;line-height:1.6;resize:vertical;outline:none;box-sizing:border-box">${esc(memory)}</textarea>
<div style="display:flex;gap:6px;margin-top:8px">
<button class="cron-btn run" onclick="saveAgentMemory('${agentId}')">💾 Save</button>
<button class="cron-btn" onclick="cancelEditMemory('${agentId}')">Cancel</button>
</div>
<div id="memoryEditError" style="color:var(--accent);font-size:11px;margin-top:6px;display:none"></div>
</div>
`;
} catch(e) {
content.innerHTML = `<div style="padding:12px;color:var(--accent);font-size:12px">Error: ${esc(e.message)}</div>`;
}
}
async function searchAgentMemory(agentId) {
const q = document.getElementById('memSearchInput').value.trim();
const resultsBox = document.getElementById('memSearchResults');
if (!q) return;
resultsBox.style.display = '';
resultsBox.innerHTML = `<div style="font-size:10px;color:var(--muted);padding:4px 0">Searching...</div>`;
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 = `<div style="font-size:11px;color:var(--muted);padding:4px 0">No results for "${esc(q)}"</div>`;
return;
}
resultsBox.innerHTML = `
<div style="font-size:10px;color:var(--muted);margin-bottom:6px;text-transform:uppercase;font-weight:600">${results.length} result${results.length!==1?'s':''}</div>
${results.map(r => `
<div style="border:1px solid var(--border);border-radius:8px;padding:8px;margin-bottom:6px;background:rgba(255,255,255,.02)">
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:4px">
<span style="font-size:11px;font-weight:600;color:var(--blue)">${esc(r.topic)}</span>
<span style="font-size:9px;color:var(--muted);opacity:.7">${(r.confidence*100).toFixed(0)}%</span>
</div>
<div style="font-size:10px;color:var(--muted);margin-bottom:4px">${r.agent ? 'Agent: '+esc(r.agent)+' · ' : ''}Topic: ${esc(r.topic)}</div>
<div style="font-size:10px;color:var(--text);line-height:1.4;max-height:60px;overflow:hidden">${esc((r.content||'').slice(0,200))}</div>
</div>
`).join('')}
`;
} catch(e) {
resultsBox.innerHTML = `<div style="font-size:11px;color:var(--accent)">Error: ${esc(e.message)}</div>`;
}
}
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 = `
<div style="padding:24px;text-align:center;color:var(--muted);font-size:12px">
<div style="font-size:28px;margin-bottom:8px">📭</div>
<div>No messages in inbox</div>
<div style="font-size:10px;margin-top:4px;opacity:0.6">Messages from other agents appear here</div>
</div>
<div style="padding:12px;border-top:1px solid var(--border)">
<div style="font-size:10px;color:var(--muted);margin-bottom:6px;text-transform:uppercase;font-weight:600">Send Message</div>
<input id="msgToAgentSubject" placeholder="Subject" style="width:100%;background:var(--input-bg);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:6px 8px;font-size:11px;margin-bottom:4px;outline:none;box-sizing:border-box">
<textarea id="msgToAgentBody" rows="3" placeholder="Message body..." style="width:100%;background:var(--input-bg);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:6px 8px;font-size:11px;margin-bottom:4px;resize:none;outline:none;font-family:inherit;box-sizing:border-box"></textarea>
<button class="cron-btn run" style="width:100%" onclick="sendToAgent('${agentId}')">Send</button>
<div id="sendToAgentError" style="color:var(--accent);font-size:11px;margin-top:4px;display:none"></div>
</div>
`;
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 `
<div class="inbox-msg${isUnread ? ' unread' : ''}" onclick="toggleInboxMsg(this)">
<div class="inbox-msg-header">
<span style="font-size:9px;font-weight:700;color:${typeColor}">${typeLabel}</span>
<span style="font-size:9px;color:var(--muted)">← ${esc(m.from || 'unknown')}</span>
<span style="font-size:9px;color:var(--muted);margin-left:auto">${esc(ts)}</span>
</div>
<div class="inbox-msg-subject">${esc(m.subject || '(no subject)')}</div>
<div class="inbox-msg-body">${esc(String(m.content || '').slice(0,200))}</div>
<div class="inbox-msg-actions">
${isUnread ? `<button class="cron-btn" style="padding:2px 8px;font-size:9px" onclick="event.stopPropagation();ackMsg('${agentId}','${m.id}')">✓ Acknowledge</button>` : ''}
</div>
</div>`;
}).join('');
content.innerHTML = `
<div class="inbox-messages-list">
${msgsHtml}
</div>
<div style="padding:12px;border-top:1px solid var(--border);margin-top:8px">
<div style="font-size:10px;color:var(--muted);margin-bottom:6px;text-transform:uppercase;font-weight:600">Send Message</div>
<input id="msgToAgentSubject" placeholder="Subject" style="width:100%;background:var(--input-bg);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:6px 8px;font-size:11px;margin-bottom:4px;outline:none;box-sizing:border-box">
<textarea id="msgToAgentBody" rows="3" placeholder="Message body..." style="width:100%;background:var(--input-bg);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:6px 8px;font-size:11px;margin-bottom:4px;resize:none;outline:none;font-family:inherit;box-sizing:border-box"></textarea>
<button class="cron-btn run" style="width:100%" onclick="sendToAgent('${agentId}')">Send</button>
<div id="sendToAgentError" style="color:var(--accent);font-size:11px;margin-top:4px;display:none"></div>
</div>
`;
} catch(e) {
content.innerHTML = `<div style="padding:12px;color:var(--accent);font-size:12px">Error: ${esc(e.message)}</div>`;
}
}
async function loadAgentActivity(agentId, content) {
try {
const data = await api(`/api/agents/${agentId}/activity`);
const events = data.events || [];
if (events.length === 0) {
content.innerHTML = `
<div style="padding:24px;text-align:center;color:var(--muted);font-size:12px">
<div style="font-size:28px;margin-bottom:8px">📋</div>
<div>No activity recorded yet</div>
<div style="font-size:10px;margin-top:4px;opacity:0.6">Events like messages, tasks and updates appear here</div>
</div>`;
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 ? `<span style="font-size:10px;color:var(--muted);margin-left:6px">${esc(e.details)}</span>` : '';
return `
<div class="activity-event-row">
<span style="font-size:14px;flex-shrink:0">${icon}</span>
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
<span style="font-size:10px;font-weight:600;color:${color}">${esc(e.type)}</span>
${details}
</div>
<div style="font-size:9px;color:var(--muted)">${esc(ts)} · ${rel}</div>
</div>
</div>`;
}).join('');
content.innerHTML = `<div class="activity-list">${rows}</div>`;
} catch(e) {
content.innerHTML = `<div style="padding:12px;color:var(--accent);font-size:12px">Error: ${esc(e.message)}</div>`;
}
}
async function loadAgentErrors(agentId, content) {
try {
const data = await api(`/api/agents/${agentId}/errors`);
const errors = data.errors || [];
if (errors.length === 0) {
content.innerHTML = `
<div style="padding:24px;text-align:center;color:#4caf50;font-size:12px">
<div style="font-size:28px;margin-bottom:8px">✅</div>
<div>No errors recorded</div>
<div style="font-size:10px;margin-top:4px;opacity:0.6">All good — this agent has no logged errors</div>
</div>`;
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 `
<div class="error-event-row">
<span style="font-size:14px;flex-shrink:0">⚠️</span>
<div style="flex:1;min-width:0">
<div style="font-size:11px;color:#f44336;font-weight:600">${esc(e.details || 'Unknown error')}</div>
<div style="font-size:9px;color:var(--muted)">${esc(ts)} · ${rel}</div>
</div>
</div>`;
}).join('');
content.innerHTML = `
<div style="padding:8px 0 8px;font-size:10px;color:#f44336;margin-bottom:4px">
⚠️ ${errors.length} error${errors.length !== 1 ? 's' : ''} total
</div>
<div class="error-list">${rows}</div>`;
} catch(e) {
content.innerHTML = `<div style="padding:12px;color:var(--accent);font-size:12px">Error: ${esc(e.message)}</div>`;
}
}
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 = `
<div style="padding:24px;text-align:center;color:var(--muted);font-size:12px">
<div style="font-size:28px;margin-bottom:8px">💬</div>
<div>No chat history yet</div>
<div style="font-size:10px;margin-top:4px;opacity:0.6">Your conversations with ${agentId} appear here</div>
</div>`;
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 `
<div class="chat-history-row" onclick="openAgentChatSession('${agentId}','${s.session_id}')">
<div class="chat-history-title">${esc(s.title)}</div>
<div class="chat-history-meta">
<span style="font-size:10px;color:var(--muted)">${created} · ${rel}</span>
<span style="font-size:9px;padding:1px 5px;background:var(--code-bg);border-radius:4px;color:var(--muted);font-family:monospace">${esc(model)}</span>
<span style="font-size:9px;color:var(--muted)">${s.message_count} msgs</span>
</div>
</div>`;
}).join('');
content.innerHTML = `<div class="chat-history-list">${rows}</div>`;
} catch(e) {
content.innerHTML = `<div style="padding:12px;color:var(--accent);font-size:12px">Error: ${esc(e.message)}</div>`;
}
}
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 = `
<div style="padding:24px;text-align:center;color:var(--muted);font-size:12px">
<div style="font-size:28px;margin-bottom:8px">📋</div>
<div>No tasks in queue</div>
<div style="font-size:10px;margin-top:4px;opacity:0.6">Tasks will appear here when agents are working on something</div>
</div>`;
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 `
<div class="task-row">
<span class="task-status-dot" style="background:${color}"></span>
<div style="flex:1;min-width:0">
<div style="font-size:12px">${esc(t.description || 'Task')}</div>
<div style="font-size:9px;color:var(--muted);margin-top:2px">${esc(ts)} · <span style="color:${color}">${esc(t.status)}</span></div>
</div>
</div>`;
}).join('');
content.innerHTML = `
<div style="padding:8px 0 8px;font-size:10px;color:var(--muted)">${tasks.length} task${tasks.length !== 1 ? 's' : ''}</div>
<div class="task-list">${rows}</div>`;
} catch(e) {
content.innerHTML = `<div style="padding:12px;color:var(--accent);font-size:12px">Error: ${esc(e.message)}</div>`;
}
}
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 = `
<div style="padding:24px;text-align:center;color:var(--muted);font-size:12px">
<div style="font-size:28px;margin-bottom:8px">🚌</div>
<div>No messages in the bus</div>
<div style="font-size:10px;margin-top:4px;opacity:0.6">Messages between agents appear here</div>
</div>
<div style="padding:12px;border-top:1px solid var(--border);margin-top:8px">
<div style="font-size:10px;color:var(--muted);margin-bottom:6px;text-transform:uppercase;font-weight:600">Send Message via Bus</div>
<input id="busSubject" placeholder="Subject" style="width:100%;background:var(--input-bg);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:6px 8px;font-size:11px;margin-bottom:4px;outline:none;box-sizing:border-box">
<textarea id="busContent" rows="3" placeholder="Message content..." style="width:100%;background:var(--input-bg);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:6px 8px;font-size:11px;margin-bottom:4px;resize:none;outline:none;font-family:inherit;box-sizing:border-box"></textarea>
<button class="cron-btn run" style="width:100%" onclick="sendBusMessage('${agentId}')">Send via Bus</button>
</div>`;
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 `
<div class="bus-msg${isOutgoing ? ' outgoing' : ''}">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px">
<span style="font-size:12px">${dirIcon}</span>
<span style="font-size:9px;font-weight:700;color:${typeColor}">${esc(m.type || '').toUpperCase()}</span>
<span style="font-size:9px;color:var(--muted)">${dirLabel}</span>
<span style="font-size:9px;color:var(--muted);margin-left:auto">${esc(ts)} · ${rel}</span>
</div>
<div style="font-size:11px;font-weight:500;margin-bottom:2px">${esc(m.subject || '(no subject)')}</div>
<div style="font-size:10px;color:var(--muted)">${esc(String(m.content || '').slice(0, 120))}</div>
</div>`;
}).join('');
content.innerHTML = `
<div class="bus-list">${rows}</div>
<div style="padding:12px;border-top:1px solid var(--border);margin-top:8px">
<div style="font-size:10px;color:var(--muted);margin-bottom:6px;text-transform:uppercase;font-weight:600">Send Message via Bus</div>
<input id="busSubject" placeholder="Subject" style="width:100%;background:var(--input-bg);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:6px 8px;font-size:11px;margin-bottom:4px;outline:none;box-sizing:border-box">
<textarea id="busContent" rows="3" placeholder="Message content..." style="width:100%;background:var(--input-bg);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:6px 8px;font-size:11px;margin-bottom:4px;resize:none;outline:none;font-family:inherit;box-sizing:border-box"></textarea>
<button class="cron-btn run" style="width:100%" onclick="sendBusMessage('${agentId}')">Send via Bus</button>
</div>`;
} catch(e) {
content.innerHTML = `<div style="padding:12px;color:var(--accent);font-size:12px">Error: ${esc(e.message)}</div>`;
}
}
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 viewBox="-200 -160 400 320" style="width:100%;max-width:500px;display:block;margin:0 auto;background:transparent">';
svg += '<defs><filter id="glow"><feGaussianBlur stdDeviation="3" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs>';
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 += '<line x1="' + (from.x * scale) + '" y1="' + (from.y * scale) + '" x2="' + (to.x * scale) + '" y2="' + (to.y * scale) + '" stroke="rgba(255,255,255,0.15)" stroke-width="1.5"/>';
}
});
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 += '<circle cx="' + px + '" cy="' + py + '" r="' + (r + 8) + '" fill="' + color + '" opacity="0.3"><animate attributeName="r" values="' + (r+8) + ';' + (r+14) + ';' + (r+8) + '" dur="2s" repeatCount="indefinite"/><animate attributeName="opacity" values="0.3;0.15;0.3" dur="2s" repeatCount="indefinite"/></circle>';
}
svg += '<circle cx="' + px + '" cy="' + py + '" r="' + r + '" fill="' + color + '" stroke="' + (isActive ? '#fff' : 'rgba(255,255,255,0.3)') + '" stroke-width="' + (isActive ? 2 : 1) + '" filter="' + (isActive ? 'url(#glow)' : '') + '"/>';
svg += '<text x="' + px + '" y="' + (py + 5) + '" text-anchor="middle" font-size="16">' + agent.emoji + '</text>';
svg += '<text x="' + px + '" y="' + (py + r + 14) + '" text-anchor="middle" font-size="9" fill="rgba(255,255,255,0.7)" font-family="system-ui,sans-serif">' + agent.name + '</text>';
});
svg += '</svg>';
content.innerHTML = '<div class="topology-view">' + svg + '</div>';
}
async function loadAgentUsage(agentId, content) {
try {
const data = await api(`/api/agents/${agentId}/usage`);
if (data.error) {
content.innerHTML = `<div style="padding:24px;text-align:center;color:var(--muted);font-size:12px">${esc(data.error)}</div>`;
return;
}
const fmt = (n) => n.toLocaleString();
const fmtCost = (c) => '$' + c.toFixed(4);
const historyRows = (data.history || []).slice(0, 14).map(h => `
<tr>
<td style="font-size:10px;padding:4px 8px">${esc(h.date)}</td>
<td style="font-size:10px;padding:4px 8px;text-align:right">${fmt(h.total_tokens || 0)}</td>
<td style="font-size:10px;padding:4px 8px;text-align:right">${fmt(h.prompt_tokens || 0)}</td>
<td style="font-size:10px;padding:4px 8px;text-align:right">${fmt(h.completion_tokens || 0)}</td>
<td style="font-size:10px;padding:4px 8px;text-align:right;color:#4caf50">${fmtCost(h.cost_usd || 0)}</td>
</tr>`).join('');
content.innerHTML = `
<div style="padding:16px">
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:20px">
<div style="background:var(--card-bg);border:1px solid var(--border);border-radius:10px;padding:14px;text-align:center">
<div style="font-size:9px;text-transform:uppercase;color:var(--muted);margin-bottom:6px;font-weight:600">Today</div>
<div style="font-size:20px;font-weight:700;color:var(--text)">${fmt(data.today.tokens)}</div>
<div style="font-size:11px;color:#4caf50;margin-top:2px">${fmtCost(data.today.cost)}</div>
</div>
<div style="background:var(--card-bg);border:1px solid var(--border);border-radius:10px;padding:14px;text-align:center">
<div style="font-size:9px;text-transform:uppercase;color:var(--muted);margin-bottom:6px;font-weight:600">This Week</div>
<div style="font-size:20px;font-weight:700;color:var(--text)">${fmt(data.week.tokens)}</div>
<div style="font-size:11px;color:#4caf50;margin-top:2px">${fmtCost(data.week.cost)}</div>
</div>
<div style="background:var(--card-bg);border:1px solid var(--border);border-radius:10px;padding:14px;text-align:center">
<div style="font-size:9px;text-transform:uppercase;color:var(--muted);margin-bottom:6px;font-weight:600">This Month</div>
<div style="font-size:20px;font-weight:700;color:var(--text)">${fmt(data.month.tokens)}</div>
<div style="font-size:11px;color:#4caf50;margin-top:2px">${fmtCost(data.month.cost)}</div>
</div>
</div>
${historyRows ? `
<div style="font-size:10px;font-weight:600;color:var(--muted);text-transform:uppercase;margin-bottom:8px">Recent History</div>
<table style="width:100%;border-collapse:collapse">
<thead>
<tr style="border-bottom:1px solid var(--border)">
<th style="text-align:left;font-size:9px;padding:4px 8px;color:var(--muted)">Date</th>
<th style="text-align:right;font-size:9px;padding:4px 8px;color:var(--muted)">Total</th>
<th style="text-align:right;font-size:9px;padding:4px 8px;color:var(--muted)">Prompt</th>
<th style="text-align:right;font-size:9px;padding:4px 8px;color:var(--muted)">Completion</th>
<th style="text-align:right;font-size:9px;padding:4px 8px;color:var(--muted)">Cost</th>
</tr>
</thead>
<tbody>${historyRows}</tbody>
</table>` : `
<div style="text-align:center;padding:24px;color:var(--muted);font-size:12px">
<div style="font-size:28px;margin-bottom:8px">📊</div>
<div>No usage data recorded yet</div>
<div style="font-size:10px;margin-top:4px;opacity:0.6">Token usage will appear here once recorded</div>
</div>`}
</div>`;
} catch(e) {
content.innerHTML = `<div style="padding:24px;text-align:center;color:#e94560;font-size:12px">Failed to load usage: ${esc(e.message)}</div>`;
}
}
// 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 => `
<div class="agent-sel-item${a.id === current ? ' active' : ''}" onclick="selectChatAgent('${a.id}')" style="padding:10px 14px;display:flex;align-items:center;gap:10px;cursor:pointer;border-bottom:1px solid var(--border);transition:background .15s">
<span style="font-size:18px">${a.emoji}</span>
<div style="flex:1;min-width:0">
<div style="font-size:13px;font-weight:500;color:var(--text)">${a.name}</div>
<div style="font-size:10px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${a.domain}</div>
</div>
<div style="width:8px;height:8px;border-radius:50%;background:${a.color}"></div>
</div>
`).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(/&lt;(\/?)(pre|code|strong|b|em|i|li|ul|ol|h[1-6]|br|p)&gt;/gi, '<$1$2>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`(.+?)`/g, '<code style="background:rgba(255,255,255,.08);padding:1px 4px;border-radius:3px;font-size:.9em">$1</code>')
.replace(/^### (.+)$/gm, '<h4 style="font-size:12px;font-weight:700;margin:8px 0 4px">$1</h3>')
.replace(/^## (.+)$/gm, '<h3 style="font-size:13px;font-weight:700;margin:10px 0 4px">$1</h3>')
.replace(/^# (.+)$/gm, '<h2 style="font-size:14px;font-weight:700;margin:12px 0 6px">$1</h2>')
.replace(/^- (.+)$/gm, '<li style="margin-left:12px">$1</li>')
.replace(/^(\d+)\. (.+)$/gm, '<li style="margin-left:12px;list-style:decimal">$2</li>')
.replace(/\n/g, '<br>');
}
// ── 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 = '<div style="color:var(--muted);font-size:12px;padding:8px">Loading...</div>';
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 = `
<div class="logs-sidebar-name">${esc(log.name)}</div>
<div class="logs-sidebar-meta">${log.missing ? 'Missing' : log.size_human + ' • ' + (log.modified ? _formatDate(log.modified) : 'Unknown')}</div>
`;
el.appendChild(item);
});
} catch(e) {
el.innerHTML = '<div style="color:var(--accent);font-size:12px;padding:8px">Failed to load logs.</div>';
}
}
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 = '<div style="color:var(--muted);font-size:12px;padding:12px">Loading...</div>';
// 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 = '<div style="color:var(--accent);font-size:12px">Failed to load log.</div>';
}
// 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 = '<span style="color:var(--muted)">(no matches)</span>';
return;
}
// Escape first, then highlight search terms
const highlighted = lines.map((line, i) => {
const escaped = esc(line) || '&nbsp;';
if (searchLower) {
// Highlight all occurrences of search term (case-insensitive, preserves case in display)
const regex = new RegExp(escRegex(_currentLogSearch), 'gi');
return `<span class="log-line" data-idx="${i}">${escaped.replace(regex, m => `<mark class="log-highlight">${m}</mark>`)}</span>`;
}
return `<span class="log-line" data-idx="${i}">${escaped}</span>`;
}).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 = '<div class="panel-error">Failed to load heartbeats: ' + e.message + '</div>';
}
}
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 = `
<div class="heartbeats-header">
<div class="hb-stats">
<span class="hb-stat"><b>${total}</b> total</span>
<span class="hb-stat"><b>${data.by_status?.pending || 0}</b> pending</span>
<span class="hb-stat ${pending > 0 ? 'hb-stat-warn' : ''}"><b>${pending}</b> due now</span>
</div>
<div class="hb-manager-status">
<span class="hb-dot ${manager.running ? 'hb-dot-ok' : 'hb-dot-dead'}"></span>
Manager ${manager.running ? 'running (PID ' + manager.pid + ')' : 'stopped'}
</div>
</div>
<div class="hb-quick-actions">
<button class="btn-hb-action" onclick="hbQuickReminder()">🍅 Rose Reminder</button>
<button class="btn-hb-action" onclick="hbQuickDailyCheck()">📅 Daily Check</button>
<button class="btn-hb-action" onclick="hbQuickMemoryCheck()">🧠 Memory Check</button>
</div>
<div class="hb-create-section">
<h4>New Heartbeat</h4>
<div class="hb-form">
<input id="hb-instruction" class="hb-input" placeholder="Instruction (what should happen?)" />
<div class="hb-form-row">
<select id="hb-action" class="hb-select">
<option value="rose_continue">rose_continue — Notify Rose</option>
<option value="delegate">delegate — Send to Agent</option>
<option value="notify_user">notify_user — Message User</option>
<option value="follow_up">follow_up — Ask Follow-up</option>
<option value="execute">execute — Run Command</option>
</select>
<select id="hb-agent" class="hb-select hb-agent-select" style="display:none">
<option value="">Select agent…</option>
<option value="sunflower">Sunflower (Finance)</option>
<option value="lotus">Lotus (Health)</option>
<option value="forget-me-not">Forget-me-not (Calendar)</option>
<option value="iris">Iris (Career)</option>
<option value="ivy">Ivy (Smart Home)</option>
<option value="dandelion">Dandelion (Communication)</option>
<option value="root">Root (DevOps)</option>
</select>
</div>
<div class="hb-form-row">
<input id="hb-minutes" class="hb-input hb-mini" type="number" placeholder="Min" value="5" min="0" />
<select id="hb-priority" class="hb-select hb-mini">
<option value="">Auto</option>
<option value="critical">🔴 Critical</option>
<option value="high">🟠 High</option>
<option value="normal">🟡 Normal</option>
<option value="low">⚪ Low</option>
</select>
<label class="hb-checkbox"><input id="hb-recurring" type="checkbox" /> Recurring</label>
<input id="hb-interval" class="hb-input hb-mini" type="number" placeholder="Intvl" min="1" style="display:none" />
<input id="hb-maxiter" class="hb-input hb-mini" type="number" placeholder="Max" min="1" style="display:none" />
</div>
<button class="btn btn-primary" onclick="hbCreate()">Create Heartbeat</button>
</div>
</div>
<div class="hb-list-section">
<h4>Active Heartbeats (${hb.length})</h4>
${hb.length === 0 ? '<div class="hb-empty">No heartbeats yet</div>' : ''}
</div>
`;
for (const h of hb) {
const isRecurring = h.recurring;
const iterationInfo = isRecurring && h.max_iterations
? ` <span class="hb-iter">(${h.iteration_count || 0}/${h.max_iterations})</span>`
: isRecurring ? ' <span class="hb-iter">(∞)</span>' : '';
const statusClass = h.status === 'pending' ? 'hb-item-pending' : 'hb-item-' + h.status;
const dueInfo = h.status === 'pending' ? `${_nextIn(h.trigger_at)}` : h.status;
html += `
<div class="hb-item ${statusClass}" id="hb-${h.id}">
<div class="hb-item-main">
<span class="hb-action-badge">${h.action}</span>
<span class="hb-source">${h.source}</span>
<span class="hb-priority">${h.priority || 'n'}</span>
<span class="hb-due">${dueInfo}</span>
${iterationInfo}
</div>
<div class="hb-item-sub">${_escapeHtml(h.instruction || h.user_message || '')}</div>
<button class="hb-cancel-btn" onclick="hbCancel('${h.id}')" title="Cancel">✕</button>
</div>`;
}
html += '</div>';
// 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
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 = `
<span style="color:var(--muted)">📋</span> ${stats.total_tasks || 0}
<span style="color:var(--muted);margin-left:8px">✅</span> ${stats.done || 0}
<span style="color:var(--muted);margin-left:8px">🎯</span> ${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 => `
<div class="project-item" data-project="${p.id}" onclick="openProjectEditModal('${p.id}')" style="--project-color:${p.color || '#6366f1'}">
<span class="project-name">${p.name || p.id}</span>
<span class="project-task-count">${(p.tasks || []).length}</span>
</div>
`).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('')
: '<div class="empty-hint">No daily tasks</div>';
}
// 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('')
: '<div class="empty-hint">No recurring tasks</div>';
}
}
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 ? `<span class="task-due">📅 ${t.due}</span>` : '';
const tags = (t.tags || []).map(tag => `<span class="task-tag">${esc(tag)}</span>`).join('');
const ownerBadgeMap = { 'user': '👤', 'rose': '🌹', 'agent:lotus': '🪷', 'agent:sunflower': '🌻', 'agent:iris': '⚜️', 'agent:ivy': '🌿', 'agent:dandelion': '🛡️', 'agent:root': '🌳' };
const ownerBadge = ownerBadgeMap[t.owner] || '';
return `
<div class="task-item" data-task-id="${t.id}" onclick="openTaskEditModal('${t.id}')">
<span class="task-check" onclick="event.stopPropagation();toggleTaskStatus('${t.id}')">${done}</span>
<span class="task-title ${doneCls}">${esc(t.title)}</span>
<span class="task-owner">${ownerBadge}</span>
<span class="task-prio">${prio}</span>
${due}
${tags}
</div>
`;
}
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 ? `<span class="kanban-project">${esc(t.project_name)}</span>` : '';
const today = new Date().toISOString().slice(0, 10);
const isOverdue = t.due && t.due < today && t.status !== 'done';
const due = t.due ? `<span class="kanban-due${isOverdue ? ' overdue' : ''}">📅 ${esc(t.due)}</span>` : '';
const tags = (t.tags || []).slice(0, 2).map(tag => `<span class="kanban-tag">${esc(tag)}</span>`).join('');
const prioLabel = { p1: '🔴', p2: '🟡', p3: '🟢' }[t.priority] || '';
const ownerBadge = t.owner ? `<span class="owner-badge ${esc(t.owner)}">${esc(t.owner)}</span>` : '';
return `
<div class="kanban-card" style="border-left: 3px solid ${borderColor}"
data-task-id="${t.id}" data-status="${t.status}"
draggable="true"
ondragstart="onKanbanDragStart(event, '${t.id}')"
ondragend="onKanbanDragEnd(event)"
onclick="openTaskEditModal('${t.id}')">
<div class="kanban-card-header">${prioLabel} ${esc(t.title)}</div>
${projectLabel}
${ownerBadge}
<div class="kanban-card-footer">
${due}
${tags}
</div>
</div>
`;
}
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 = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="10" y1="14" x2="21" y2="3"/><line x1="3" y1="21" x2="14" y2="10"/></svg>`;
btn.title = 'Collapse';
} else {
btn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>`;
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 = '<option value="">— None —</option>';
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);
}
}