feat(ui): Little Blue view — caretaker chat + actions panel
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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')
|
||||
};
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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: [] }
|
||||
];
|
||||
|
||||
76
public/views/little_blue.js
Normal file
76
public/views/little_blue.js
Normal file
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user