// #/little-blue — the caretaker page: Little Blue's chat (reuses agent_chat) + // a manual Actions panel. Actions are grouped into cards (Start/Stop of the same // guest pair together); risky ones queue for owner approval. import { el, mount, clear } from '../dom.js'; import { api } from '../api.js'; import { wireAgentChat } from '../components/agent_chat.js'; const BLUE_LABELS = { search: '🔍 looking', list_actions: '📋 checking what she can do', propose_action: '🔧 taking action' }; const VERB = { start: 'Start', stop: 'Stop', shutdown: 'Shutdown', reboot: 'Reboot' }; const stripVerb = (l) => ((l || '').replace(/^(Start|Stop|Restart|Reboot|Shutdown)\s+/i, '').trim() || l); function toast(host, msg, kind) { const t = el('div', { class: 'lb-toast' + (kind === 'err' ? ' err' : '') }, msg); host.appendChild(t); setTimeout(() => t.remove(), 4000); } function actionButton(a, panel, toastHost) { const verb = a.kind === 'guest_power' ? (VERB[a.op] || a.op) : 'Restart'; const btn = el('button', { class: 'lb-act ' + (a.tier === 'risky' ? 'risky' : 'safe'), title: a.tier === 'risky' ? 'Needs your approval' : 'Runs immediately', onclick: async () => { btn.disabled = true; try { const r = await api.post(`/api/actions/${a.id}/run`); toast(toastHost, r.executed ? `Ran: ${a.label}` : `Queued for approval: ${a.label}`); renderActions(panel, toastHost); } catch (e) { toast(toastHost, 'Failed: ' + e.message, 'err'); btn.disabled = false; } } }, verb); return btn; } async function renderActions(panel, toastHost) { clear(panel); let actions = [], pending = []; try { ({ actions } = await api.get('/api/actions')); } catch { /* */ } try { ({ pending } = await api.get('/api/actions/pending')); } catch { /* */ } panel.appendChild(el('div', { class: 'lb-sec' }, 'Actions')); if (!actions.length) panel.appendChild(el('p', { class: 'muted' }, 'No actions configured yet.')); // Group guest power by guest (pairs Start/Stop); service restarts stand alone. const groups = new Map(); for (const a of actions) { const key = a.kind === 'guest_power' ? `g:${a.node}:${a.vmid}` : `s:${a.id}`; if (!groups.has(key)) groups.set(key, { name: stripVerb(a.label), acts: [] }); groups.get(key).acts.push(a); } if (actions.length) { const cards = el('div', { class: 'lb-cards' }); for (const { name, acts } of groups.values()) { acts.sort((x, y) => (x.op === 'start' ? -1 : y.op === 'start' ? 1 : 0)); cards.appendChild(el('div', { class: 'card lb-card' }, el('div', { class: 'lb-card-title' }, name), el('div', { class: 'lb-btn-row' }, acts.map(a => actionButton(a, panel, toastHost))))); } panel.appendChild(cards); } if (pending.length) { panel.appendChild(el('div', { class: 'lb-sec' }, 'Awaiting approval')); const resolve = async (id, verb) => { try { await api.post(`/api/actions/pending/${id}/${verb}`); toast(toastHost, verb === 'approve' ? 'Approved' : 'Rejected'); renderActions(panel, toastHost); } catch (e) { toast(toastHost, 'Failed: ' + e.message, 'err'); } }; const pcards = el('div', { class: 'lb-cards' }); for (const p of pending) { pcards.appendChild(el('div', { class: 'card lb-pending-card' }, el('div', { class: 'lb-card-title' }, p.action_id), el('div', { class: 'lb-btn-row' }, el('button', { class: 'lb-act safe', onclick: () => resolve(p.id, 'approve') }, 'Approve'), el('button', { class: 'lb-act risky', onclick: () => resolve(p.id, 'reject') }, 'Reject')))); } panel.appendChild(pcards); } } export async function render(main) { const log = el('div', { class: 'rail-log lb-log' }); const input = el('textarea', { class: 'rail-input', rows: 1, placeholder: 'Tell Little Blue what’s wrong…' }); const sendBtn = el('button', { class: 'rail-send', type: 'button', title: 'Send', 'aria-label': 'Send' }, '➤'); const actionsPanel = el('div', { class: 'lb-actions' }); const toastHost = el('div', { class: 'lb-toasts' }); mount(main, el('h1', { class: 'view-h1' }, '◆ Little Blue — Caretaker'), el('p', { class: 'view-sub' }, 'She keeps the lab alive. Safe fixes run on her word; risky ones wait for yours.'), toastHost, el('div', { class: 'lb-grid' }, el('div', { class: 'lb-chat' }, log, el('div', { class: 'rail-inputwrap' }, input, sendBtn)), actionsPanel)); const chat = wireAgentChat({ logEl: log, inputEl: input, sendBtnEl: sendBtn, historyUrl: '/api/little-blue', turnUrl: '/api/little-blue/turn', agentName: 'Little Blue', showDrafts: false, toolLabels: BLUE_LABELS }); await chat.load(); await renderActions(actionsPanel, toastHost); }