Files
Void-Homelab/public/views/little_blue.js
root c2569cad76 feat(chat): add Send button to agent composers (mobile fix)
Soft keyboards have no reliable Enter-to-send, so chat was unsendable on
mobile browsers. Add an optional themed Send button wired through
wireAgentChat (Enter-to-send kept for desktop), applied to the Companion
rail, Yerin, and Little Blue composers. Blackflame-styled, flex-row layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 08:25:05 +10:00

103 lines
4.7 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 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);
}