4088 lines
170 KiB
TypeScript
4088 lines
170 KiB
TypeScript
/// <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,'"')})">${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(/("[^&]*?"|'[^&]*?')/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(/<(\/?)(pre|code|strong|b|em|i|li|ul|ol|h[1-6]|br|p)>/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) || ' ';
|
||
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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
}
|
||
|
||
async function hbQuickReminder() {
|
||
const instr = prompt('Reminder text for Rose:');
|
||
if (!instr) return;
|
||
await api('/api/heartbeats', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ source: 'webui', action: 'rose_continue', instruction: instr, minutes: 5, priority: 'high' })
|
||
});
|
||
showToast('Reminder set');
|
||
await loadHeartbeatsPanel();
|
||
}
|
||
|
||
async function hbQuickDailyCheck() {
|
||
await api('/api/heartbeats', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
source: 'webui', action: 'rose_continue',
|
||
instruction: 'Daily calendar check — any meetings or conflicts today?',
|
||
minutes: 0, recurring: true, interval_minutes: 1440, max_iterations: 30
|
||
})
|
||
});
|
||
showToast('Daily check created (30 days)');
|
||
await loadHeartbeatsPanel();
|
||
}
|
||
|
||
async function hbQuickMemoryCheck() {
|
||
await api('/api/heartbeats', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
source: 'webui', action: 'rose_continue',
|
||
instruction: 'Memory Health Check — run /search memory health',
|
||
minutes: 60, recurring: true, interval_minutes: 60, max_iterations: 999
|
||
})
|
||
});
|
||
showToast('Hourly memory check created');
|
||
await loadHeartbeatsPanel();
|
||
}
|
||
|
||
async function hbCreate() {
|
||
const instruction = document.getElementById('hb-instruction')?.value.trim();
|
||
const action = document.getElementById('hb-action')?.value;
|
||
const minutes = parseInt(document.getElementById('hb-minutes')?.value) || 5;
|
||
const priority = document.getElementById('hb-priority')?.value || undefined;
|
||
const recurring = document.getElementById('hb-recurring')?.checked;
|
||
const interval_minutes = parseInt(document.getElementById('hb-interval')?.value) || minutes;
|
||
const max_iterations = parseInt(document.getElementById('hb-maxiter')?.value) || undefined;
|
||
const target_agent = action === 'delegate' ? document.getElementById('hb-agent')?.value : undefined;
|
||
|
||
if (!instruction) {
|
||
showToast('Please enter an instruction');
|
||
return;
|
||
}
|
||
|
||
const body = { source: 'webui', action, instruction, minutes, recurring };
|
||
if (priority) (body as any).priority = priority;
|
||
if (recurring) {
|
||
(body as any).interval_minutes = interval_minutes;
|
||
if (max_iterations) (body as any).max_iterations = max_iterations;
|
||
}
|
||
if (target_agent) (body as any).target_agent = target_agent;
|
||
|
||
try {
|
||
await api('/api/heartbeats', {
|
||
method: 'POST',
|
||
body: JSON.stringify(body)
|
||
});
|
||
showToast('Heartbeat created');
|
||
document.getElementById('hb-instruction').value = '';
|
||
await loadHeartbeatsPanel();
|
||
} catch (e) {
|
||
showToast('Error: ' + e.message);
|
||
}
|
||
}
|
||
|
||
async function hbCancel(id) {
|
||
if (!confirm('Cancel heartbeat ' + id + '?')) return;
|
||
try {
|
||
await api('/api/heartbeats/' + id, { method: 'DELETE' });
|
||
showToast('Cancelled');
|
||
await loadHeartbeatsPanel();
|
||
} catch (e) {
|
||
showToast('Error: ' + e.message);
|
||
}
|
||
}
|
||
|
||
function _nextInProject(dateStr) {
|
||
if (!dateStr) return '—';
|
||
const diff = new Date(dateStr).getTime() - Date.now();
|
||
if (diff <= 0) return 'now';
|
||
const mins = Math.floor(diff / 60000);
|
||
if (mins < 60) return `in ${mins}m`;
|
||
const hours = Math.floor(mins / 60);
|
||
if (hours < 24) return `in ${hours}h ${mins % 60}m`;
|
||
return `in ${Math.floor(hours / 24)}d`;
|
||
}
|
||
|
||
// ── Projects panel ──────────────────────────────────────────────────────────
|
||
|
||
let projectsState = {
|
||
projects: [],
|
||
allTasks: [],
|
||
filter: { type: 'all', priority: null },
|
||
expanded: false
|
||
};
|
||
|
||
async function loadProjectsPanel() {
|
||
try {
|
||
const [projectsRes, tasksRes, statsRes] = await Promise.all([
|
||
api('/api/projects'),
|
||
api('/api/projects/tasks'),
|
||
api('/api/projects/stats')
|
||
]);
|
||
|
||
// Merge projects from projects/ folder (via our api) with tasks
|
||
projectsState.projects = projectsRes.projects || [];
|
||
projectsState.allTasks = (tasksRes.tasks || []).map(t => ({
|
||
...t,
|
||
status: (t.status === 'pending' || !t.status) ? 'todo' : t.status
|
||
}));
|
||
|
||
renderProjectsStats(statsRes);
|
||
renderProjectsList();
|
||
renderKanban();
|
||
// Set "All" filter button as active
|
||
document.querySelectorAll('.filter-btn[data-filter]').forEach(btn => {
|
||
btn.classList.toggle('active', btn.dataset.filter === 'all');
|
||
});
|
||
} catch(e) {
|
||
console.error('loadProjectsPanel:', e);
|
||
}
|
||
}
|
||
|
||
function renderProjectsStats(stats) {
|
||
// Header stats bar
|
||
const headerEl = $('projectsHeaderStats');
|
||
if (headerEl) {
|
||
headerEl.innerHTML = `
|
||
<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);
|
||
}
|
||
}
|