Files
Void-Homelab/public/views/little_blue.js
root 16f2083253 feat(ui): blackflame theming pass — edit toggle, md tables, back button, Little Blue action cards
- markdown_editor Edit toggle uses themed ghost button
- .md-preview gets full blackflame styling incl. tables (migrated BookStack tables now render as tables)
- reusable back button on page/reference/project/resource reading views
- Little Blue actions regrouped into themed cards, pairing Start/Stop per guest

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 23:02:32 +10:00

102 lines
4.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// #/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 whats wrong…' });
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)),
actionsPanel));
const chat = wireAgentChat({
logEl: log, inputEl: input,
historyUrl: '/api/little-blue', turnUrl: '/api/little-blue/turn',
agentName: 'Little Blue', showDrafts: false, toolLabels: BLUE_LABELS
});
await chat.load();
await renderActions(actionsPanel, toastHost);
}