{#
Hivemind floating dashboard widget — included on every ERP page via the
master skeleton. Collapsed: a small "● HIVEMIND" pill bottom-right.
Expanded: a compact panel with input + transcript that talks to the same
/ai/command/parse + /ai/command/execute endpoints the full UI uses.
Complex flows (multi-step pickers, write confirms) hand off to the full
/ai/hivemind page via a "↗ open full" button.
#}
{% if app.user is not null or session is defined %}
<style>
#hm-widget-root {
--hm-w-bg: #0c0d10;
--hm-w-bg-soft: #14161b;
--hm-w-fg: #e6edf3;
--hm-w-muted: #8a92a1;
--hm-w-rule: #232830;
--hm-w-signal: #4ade80;
--hm-w-signal-glow: rgba(74,222,128,0.5);
--hm-w-danger: #f87171;
--hm-w-success: #4ade80;
position: fixed;
bottom: 22px;
right: 22px;
z-index: 1050;
font-family: 'JetBrains Mono','Cascadia Code','Consolas','Courier New',monospace;
font-size: var(--cp-fs-sm, 12.5px);
color: var(--hm-w-fg);
transition: width 220ms ease, height 220ms ease;
}
#hm-widget-pill {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 10px 16px 10px 14px;
background: var(--hm-w-bg);
border: 1px solid var(--hm-w-rule);
border-radius: 999px;
cursor: pointer;
user-select: none;
box-shadow: 0 6px 22px rgba(0,0,0,0.35);
transition: transform 160ms ease, box-shadow 160ms ease;
letter-spacing: 0.20em;
font-weight: 700;
font-size: 12px;
}
#hm-widget-pill:hover { transform: translateY(-2px); box-shadow: 0 10px 30px rgba(0,0,0,0.55); }
#hm-widget-pill .dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--hm-w-signal);
box-shadow: 0 0 8px var(--hm-w-signal-glow);
animation: hm-w-pulse 1.7s ease-in-out infinite;
}
@keyframes hm-w-pulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:0.5;transform:scale(1.25)} }
#hm-widget-panel {
display: none;
flex-direction: column;
width: 380px;
height: 520px;
background: var(--hm-w-bg);
border: 1px solid var(--hm-w-rule);
box-shadow: 0 14px 40px rgba(0,0,0,0.55), 0 0 0 1px rgba(255,255,255,0.04);
overflow: hidden;
}
#hm-widget-root.open #hm-widget-pill { display: none; }
#hm-widget-root.open #hm-widget-panel { display: flex; }
#hm-widget-panel .hm-w-head {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 14px; border-bottom: 1px solid var(--hm-w-rule);
}
#hm-widget-panel .hm-w-brand {
display: flex; align-items: center; gap: 9px;
font-weight: 700; letter-spacing: 0.22em; font-size: 12px;
}
#hm-widget-panel .hm-w-brand .dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--hm-w-signal); box-shadow: 0 0 6px var(--hm-w-signal-glow);
}
#hm-widget-panel .hm-w-head-actions {
display: flex; gap: 6px;
}
#hm-widget-panel .hm-w-iconbtn {
background: transparent; color: var(--hm-w-muted); border: 1px solid var(--hm-w-rule);
cursor: pointer; padding: 3px 8px; font-family: inherit; font-size: 12px;
letter-spacing: 0.16em; text-transform: uppercase;
}
#hm-widget-panel .hm-w-iconbtn:hover { color: var(--hm-w-fg); border-color: var(--hm-w-fg); }
#hm-widget-panel .hm-w-body {
flex: 1; overflow-y: auto; padding: 12px 14px;
background: linear-gradient(180deg, var(--hm-w-bg) 0%, var(--hm-w-bg-soft) 100%);
}
#hm-widget-panel .hm-w-body::-webkit-scrollbar { width: 6px; }
#hm-widget-panel .hm-w-body::-webkit-scrollbar-thumb { background: var(--hm-w-rule); border-radius: 3px; }
.hm-w-line { margin-bottom: 5px; white-space: pre-wrap; line-height: 1.55; }
.hm-w-line .pre { color: var(--hm-w-muted); margin-right: 8px; }
.hm-w-line.user .pre { color: var(--hm-w-fg); font-weight: 700; }
.hm-w-line.error { color: var(--hm-w-danger); }
.hm-w-line.success { color: var(--hm-w-success); }
.hm-w-card {
background: rgba(255,255,255,0.025);
border: 1px solid var(--hm-w-rule);
padding: 9px 11px; margin: 8px 0 10px 18px;
font-size: 12px; line-height: 1.6;
}
.hm-w-card .row { display: flex; justify-content: space-between; gap: 10px; padding: 3px 0; }
.hm-w-card .row + .row { border-top: 1px dashed var(--hm-w-rule); }
.hm-w-card .k { color: var(--hm-w-muted); }
.hm-w-card .v { font-weight: 600; }
#hm-widget-panel .hm-w-foot {
display: flex; align-items: center; gap: 8px;
padding: 10px 12px; border-top: 1px solid var(--hm-w-rule);
}
#hm-widget-panel .hm-w-prompt {
color: var(--hm-w-muted); letter-spacing: 0.14em; font-size: 12px; font-weight: 700;
}
#hm-widget-panel #hm-w-input {
flex: 1; background: transparent; color: var(--hm-w-fg); border: none; outline: none;
font-family: inherit; font-size: var(--cp-fs-sm, 12.5px);
}
#hm-widget-panel #hm-w-input:disabled { opacity: 0.4; }
#hm-widget-panel .hm-w-caret {
display: inline-block; width: 7px; height: 13px; background: var(--hm-w-fg);
animation: hm-w-blink 1s steps(1) infinite;
}
@keyframes hm-w-blink { 50%{opacity:0} }
/* ── Chat-bubble layout (General). Shares hb_hivemind_ui_mode with the full
page; the compact widget renders both general + messenger as bubbles. ── */
#hm-widget-root[data-ui-mode="general"] .hm-w-body,
#hm-widget-root[data-ui-mode="messenger"] .hm-w-body {
font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
}
#hm-widget-root[data-ui-mode="general"] .hm-w-line,
#hm-widget-root[data-ui-mode="messenger"] .hm-w-line {
max-width: 84%; width: fit-content; padding: 7px 11px; border-radius: 13px; margin: 5px 0;
}
#hm-widget-root[data-ui-mode="general"] .hm-w-line .pre,
#hm-widget-root[data-ui-mode="messenger"] .hm-w-line .pre { display: none; }
#hm-widget-root[data-ui-mode="general"] .hm-w-line.user,
#hm-widget-root[data-ui-mode="messenger"] .hm-w-line.user {
margin-left: auto; background: var(--hm-w-signal); color: var(--hm-w-bg); border-bottom-right-radius: 4px;
}
#hm-widget-root[data-ui-mode="general"] .hm-w-line:not(.user),
#hm-widget-root[data-ui-mode="messenger"] .hm-w-line:not(.user) {
margin-right: auto; background: var(--hm-w-bg-soft); border: 1px solid var(--hm-w-rule); border-bottom-left-radius: 4px;
}
#hm-widget-root[data-ui-mode="general"] .hm-w-card,
#hm-widget-root[data-ui-mode="messenger"] .hm-w-card {
margin: 6px auto 8px 0; max-width: 90%; border-radius: 12px;
}
</style>
<div id="hm-widget-root" data-ui-mode="terminal">
<div id="hm-widget-pill" title="Open Hivemind (Ctrl+`)">
<span class="dot"></span>HIVEMIND
</div>
<div id="hm-widget-panel">
<div class="hm-w-head">
<div class="hm-w-brand"><span class="dot"></span>HIVEMIND</div>
<div class="hm-w-head-actions">
<button class="hm-w-iconbtn" id="hm-w-uimode" title="Toggle chat layout (Terminal / General)" aria-label="Toggle chat layout">CHAT</button>
<a class="hm-w-iconbtn" href="{{ url('ai_hivemind') }}" title="Open full terminal">↗ FULL</a>
<button class="hm-w-iconbtn" id="hm-w-close" title="Minimise">─</button>
</div>
</div>
<div class="hm-w-body" id="hm-w-body"></div>
<div class="hm-w-foot">
<span class="hm-w-prompt">></span>
<input id="hm-w-input" type="text" placeholder="Ask something… /cash · /reports · /approvals" autocomplete="off" spellcheck="false" />
<span class="hm-w-caret"></span>
</div>
</div>
</div>
<script>
(function () {
if (window.__hivemindWidgetInit) return;
window.__hivemindWidgetInit = true;
const root = document.getElementById('hm-widget-root');
const pill = document.getElementById('hm-widget-pill');
const closeBtn = document.getElementById('hm-w-close');
const body = document.getElementById('hm-w-body');
const input = document.getElementById('hm-w-input');
const OPEN_KEY = 'hivemind.widget.open';
function setOpen(open) {
root.classList.toggle('open', !!open);
localStorage.setItem(OPEN_KEY, open ? '1' : '0');
if (open) setTimeout(() => input.focus(), 60);
}
setOpen(localStorage.getItem(OPEN_KEY) === '1');
// ── UI layout (Terminal / General) — shares hb_hivemind_ui_mode with the
// full page. CSS only; the command pipeline is untouched. ──
const uiBtn = document.getElementById('hm-w-uimode');
const UI_MODE_KEY = 'hb_hivemind_ui_mode';
function applyUiMode(m) {
const bubble = (m === 'general' || m === 'messenger');
root.setAttribute('data-ui-mode', bubble ? 'general' : 'terminal');
if (uiBtn) { uiBtn.textContent = bubble ? 'TERM' : 'CHAT'; }
}
applyUiMode(localStorage.getItem(UI_MODE_KEY) || 'terminal');
if (uiBtn) uiBtn.addEventListener('click', () => {
const cur = localStorage.getItem(UI_MODE_KEY) || 'terminal';
const next = (cur === 'terminal') ? 'general' : 'terminal';
localStorage.setItem(UI_MODE_KEY, next);
applyUiMode(next);
});
// Reflect a change made on the full page (same key) if both are open.
window.addEventListener('storage', (e) => { if (e.key === UI_MODE_KEY) applyUiMode(e.newValue || 'terminal'); });
pill.addEventListener('click', () => setOpen(true));
closeBtn.addEventListener('click', () => setOpen(false));
// Keyboard shortcut: Ctrl + ` toggles
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === '`') {
e.preventDefault();
setOpen(!root.classList.contains('open'));
}
});
function esc(s) {
return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
}
function fmt(n) {
return Number(n || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function addLine(kind, text) {
const div = document.createElement('div');
div.className = 'hm-w-line ' + kind;
div.innerHTML = '<span class="pre">' + (kind === 'user' ? '$' : '>') + '</span>' + esc(text);
body.appendChild(div); body.scrollTop = body.scrollHeight;
return div;
}
function addCard(html) {
const w = document.createElement('div'); w.className = 'hm-w-card'; w.innerHTML = html;
body.appendChild(w); body.scrollTop = body.scrollHeight;
return w;
}
function row(k, v) { return '<div class="row"><span class="k">' + esc(k) + '</span><span class="v">' + esc(v) + '</span></div>'; }
function renderSimpleResult(env, body0) {
const r = body0.result || {};
switch (env.tool) {
case 'get_cash_position':
addCard(row('Cash + bank total', fmt(r.grand_total))
+ row('Ledger count', r.ledger_count));
break;
case 'get_party_balance':
addCard(row('Party', (r.name || '') + ' (#' + r.party_id + ')')
+ row('Invoiced', fmt(r.invoiced))
+ row('Received', fmt(r.received))
+ row('Due', fmt(r.due)));
break;
case 'get_overdue_invoices':
addCard(row('Overdue invoices', r.row_count)
+ row('Total due', fmt(r.total_due)));
break;
case 'search_party':
addCard(row('Matches', (r.matches || []).map(m => '#' + m.id + ' ' + (m.name || '')).slice(0,5).join('<br>') || '—'));
break;
case 'list_pending_approvals':
addCard(row('Pending', r.row_count)
+ ((r.pending || []).slice(0,5).map(p => row(p.entity_type_name + ' #' + p.entity_id, p.document_hash || '')).join('') || ''));
break;
case 'run_report':
const insights = (r.insights || []).slice(0,3).map(i => row(i.label, typeof i.value === 'number' ? fmt(i.value) : String(i.value))).join('');
addCard('<div style="font-size:11px;letter-spacing:0.18em;color:var(--hm-w-muted);text-transform:uppercase;margin-bottom:5px">' + esc(r.title || r.marker || 'Report') + '</div>' + insights + (r.summary ? '<div style="color:var(--hm-w-muted);margin-top:5px">' + esc(r.summary) + '</div>' : ''));
break;
default:
addCard(row('Result', JSON.stringify(r).slice(0, 200)));
}
if (body0.message) addLine('', body0.message);
}
async function postJson(url, payload) {
const res = await fetch(url, {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
let bod;
try { bod = await res.json(); } catch { bod = { message: 'Bad JSON' }; }
return { status: res.status, body: bod };
}
// Compact confirm-before-commit draft (CO6) — same approval rail as the full
// cockpit: preview here, confirm posts through /ai/command/execute (mode=confirm).
function renderWidgetDraft(draft) {
const rows = Object.entries(draft.preview || {})
.map(([k, v]) => row(k, typeof v === 'object' ? JSON.stringify(v) : String(v))).join('');
const card = addCard(
'<div style="font-weight:700;letter-spacing:.1em;color:var(--hm-w-signal);margin-bottom:6px">▸ DRAFT · '
+ esc((draft.tool || '').replace(/_/g, ' ').toUpperCase()) + '</div>'
+ rows
+ '<div style="margin:6px 0;color:var(--hm-w-muted);font-size:11.5px">Nothing posts until you confirm.</div>'
+ '<div class="hm-w-draft-actions" style="display:flex;gap:6px;margin-top:8px">'
+ '<button class="hm-w-iconbtn hm-w-do-confirm">▸ CONFIRM</button>'
+ '<button class="hm-w-iconbtn hm-w-do-cancel">CANCEL</button></div>');
const acts = card.querySelector('.hm-w-draft-actions');
card.querySelector('.hm-w-do-confirm').addEventListener('click', async () => {
acts.innerHTML = '<span style="color:var(--hm-w-muted);font-size:11.5px">posting…</span>';
const ex = await postJson('/ai/command/execute', {
tool: draft.tool, version: draft.version || '1.0', arguments: draft.arguments || {},
mode: 'confirm', draft_id: draft.draft_id, source: 'widget',
});
if (ex.status >= 200 && ex.status < 300 && ex.body && ex.body.success) {
addLine('success', (ex.body.message) || 'Posted.');
} else {
addLine('error', (ex.body && ex.body.message) || ('Confirm failed (HTTP ' + ex.status + ')'));
}
});
card.querySelector('.hm-w-do-cancel').addEventListener('click', () => {
acts.innerHTML = '<span style="color:var(--hm-w-muted);font-size:11.5px">cancelled</span>';
});
}
async function handle(text) {
if (!text.trim()) return;
addLine('user', text);
input.value = ''; input.disabled = true;
try {
const parseRes = await postJson('/ai/command/parse', { chat: text, conversation: [], current_page: 'widget' });
const data = parseRes.body || {};
// EL4b — chat intake pre-warm: known pitfalls for the resolved intent, up-front
// before the action runs. Count + real ids only; server attaches only when negatives
// exist (flag-gated + fail-open), so this is a no-op otherwise.
if (data.prewarm && data.prewarm.total > 0) {
const ids = (data.prewarm.items || []).map(n => '#' + n.id).join(', ');
addLine('', '⚠ heads-up — this kind of thing didn\'t work out ' + data.prewarm.total + '× before' + (ids ? ' (' + ids + ')' : ''));
}
if (data.status === 'chat') {
const reply = data.reply || data.follow_up_question || 'Hello.';
String(reply).split('\n').forEach(ln => addLine('', ln));
return;
}
if (data.status === 'error') {
addLine('error', data.follow_up_question || 'Parser error.');
return;
}
// ── AICOO: reasoning / analytical / write-by-conversation → SAME brain
// as the full cockpit (one brain, two surfaces). ──
if (data.status === 'agent') {
const think = addLine('', '…thinking');
const ask = await postJson('/ai/command/ask', { message: text, history: [], source: 'widget' });
if (think && think.remove) think.remove();
const turn = ask.body || {};
if (!(ask.status >= 200 && ask.status < 300 && turn.success)) {
addLine('error', (turn && turn.message) || 'The assistant could not answer.');
return;
}
if (turn.answer) String(turn.answer).split('\n').forEach(ln => addLine('', ln));
const used = [...new Set((turn.sources || []).map(s => (s.tool || '').replace(/_/g, ' ')).filter(Boolean))];
if (used.length) addLine('', 'grounded on: ' + used.join(', '));
if (turn.draft && turn.draft.draft_id) renderWidgetDraft(turn.draft);
if (turn.reports && turn.reports.length) addLine('', '📊 ' + turn.reports.length + ' chart/table — ↗ open full for the visual');
return;
}
const env = data.envelope;
if (!env) { addLine('error', 'No envelope.'); return; }
// Complex flows (preview/confirm + multi-step pickers) hand off to full Hivemind.
const needsFull = env.next_intent
|| env.tool === 'create_payment' || env.tool === 'create_receipt'
|| env.tool === 'create_expense' || env.tool === 'create_journal_voucher'
|| env.tool === 'create_contra_voucher' || env.tool === 'create_sales_order'
|| env.tool === 'create_purchase_order' || env.tool === 'create_sales_proposal'
|| env.tool === 'approve_document';
if (needsFull) {
addLine('', 'This needs the full terminal. Opening Hivemind…');
sessionStorage.setItem('hivemind.bootstrap_chat', text);
window.location.href = '{{ url('ai_hivemind') }}';
return;
}
if (data.status === 'needs_input') {
addLine('', data.follow_up_question || 'Need more details.');
return;
}
// Read-only flow: execute immediately
const exec = await postJson('/ai/command/execute', {
tool: env.tool, version: env.version || '1.0',
arguments: env.arguments || {}, mode: 'preview',
source: 'widget',
idempotency_key: 'widget-' + Date.now() + '-' + Math.random().toString(36).slice(2,8),
});
if (exec.status >= 200 && exec.status < 300 && exec.body.success) {
renderSimpleResult(env, exec.body);
} else {
addLine('error', exec.body.message || ('HTTP ' + exec.status));
}
} catch (e) {
addLine('error', 'Network error: ' + (e.message || String(e)));
} finally {
input.disabled = false;
input.focus();
}
}
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handle(input.value);
}
});
// Greet first-time openers.
if (body.children.length === 0) {
addLine('', 'Hivemind ready. Try /cash, /reports, /approvals, or ask in plain language.');
}
// If the user clicked something that needed full terminal, the widget left
// a bootstrap-chat token in sessionStorage — full Hivemind picks it up.
})();
</script>
{% endif %}