diff --git a/public/app.js b/public/app.js index 4236018..8a5f0c9 100644 --- a/public/app.js +++ b/public/app.js @@ -23,6 +23,7 @@ const VIEWS = { inbox: () => import('./views/inbox.js'), 'sacred-valley': () => import('./views/sacred_valley.js'), sentinel: () => import('./views/sentinel.js'), + 'little-blue': () => import('./views/little_blue.js'), jobs: () => import('./views/jobs.js') }; diff --git a/public/components/sidebar.js b/public/components/sidebar.js index 9269bf2..edc585b 100644 --- a/public/components/sidebar.js +++ b/public/components/sidebar.js @@ -92,6 +92,7 @@ export function renderSidebar(root) { el('div', { class: 'sb-title' }, 'Navigate'), navItem('Sacred Valley', '/sacred-valley'), navItem('Sentinel', '/sentinel'), + navItem('Little Blue', '/little-blue'), navItem('Search', '/search'), inboxItem, navItem('Jobs', '/jobs'), diff --git a/public/router.js b/public/router.js index d475705..85d4de5 100644 --- a/public/router.js +++ b/public/router.js @@ -9,6 +9,7 @@ // #/inbox pending changes // #/sacred-valley dashboard placeholder // #/sentinel Yerin security view +// #/little-blue Little Blue caretaker view // Anything unrecognized falls through to the home handler. const ROUTES = [ @@ -21,6 +22,7 @@ const ROUTES = [ { name: 'inbox', re: /^\/inbox$/, keys: [] }, { name: 'sacred-valley', re: /^\/sacred-valley$/, keys: [] }, { name: 'sentinel', re: /^\/sentinel$/, keys: [] }, + { name: 'little-blue', re: /^\/little-blue$/, keys: [] }, { name: 'jobs', re: /^\/jobs$/, keys: [] }, { name: 'home', re: /^\/?$/, keys: [] } ]; diff --git a/public/views/little_blue.js b/public/views/little_blue.js new file mode 100644 index 0000000..79f4920 --- /dev/null +++ b/public/views/little_blue.js @@ -0,0 +1,76 @@ +// #/little-blue — the caretaker page: Little Blue's chat (reuses agent_chat) + +// a manual Actions panel (run whitelisted actions; approve/reject risky ones). +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' +}; + +function toast(host, msg, kind) { + const t = el('div', { class: 'lb-toast' + (kind === 'err' ? ' err' : '') }, msg); + host.appendChild(t); + setTimeout(() => t.remove(), 4000); +} + +async function renderActions(panel, toastHost) { + clear(panel); + let actions = [], pending = []; + try { ({ actions } = await api.get('/api/actions')); } catch (e) { actions = []; } + try { ({ pending } = await api.get('/api/actions/pending')); } catch (e) { pending = []; } + + panel.appendChild(el('div', { class: 'lb-sec-t' }, 'Actions')); + if (!actions.length) { + panel.appendChild(el('div', { class: 'muted' }, 'No actions configured yet (populate config/actions.json).')); + } + for (const a of actions) { + panel.appendChild(el('div', { class: 'lb-action' }, + el('span', { class: 'lb-a-label' }, a.label || a.id), + el('span', { class: 'lb-a-tier ' + a.tier }, a.tier), + el('button', { class: 'lb-run', onclick: async (ev) => { + ev.target.disabled = true; + try { + const r = await api.post(`/api/actions/${a.id}/run`); + toast(toastHost, r.executed ? `Ran "${a.label || a.id}"` : `Queued "${a.label || a.id}" for approval`); + renderActions(panel, toastHost); + } catch (e) { toast(toastHost, 'Failed: ' + e.message, 'err'); ev.target.disabled = false; } + } }, a.tier === 'risky' ? 'Request' : 'Run'))); + } + + if (pending.length) { + panel.appendChild(el('div', { class: 'lb-sec-t' }, 'Awaiting approval')); + for (const p of pending) { + const row = el('div', { class: 'lb-pending' }, + el('span', { class: 'lb-a-label' }, p.action_id), + el('button', { class: 'ok', onclick: () => resolve(p.id, 'approve') }, 'Approve'), + el('button', { class: 'no', onclick: () => resolve(p.id, 'reject') }, 'Reject')); + panel.appendChild(row); + async function resolve(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'); } + } + } + } +} + +export async function render(main) { + const log = el('div', { class: 'rail-log sentinel-log' }); + const input = el('textarea', { class: 'rail-input', rows: 1, placeholder: 'Tell Little Blue what’s 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); +}