src/ApplicationBundle/Modules/Ai/Resources/views/hivemind_widget.html.twig line 1

Open in your IDE?
  1. {#
  2.   Hivemind floating dashboard widget — included on every ERP page via the
  3.   master skeleton. Collapsed: a small "● HIVEMIND" pill bottom-right.
  4.   Expanded: a compact panel with input + transcript that talks to the same
  5.   /ai/command/parse + /ai/command/execute endpoints the full UI uses.
  6.   Complex flows (multi-step pickers, write confirms) hand off to the full
  7.   /ai/hivemind page via a "↗ open full" button.
  8. #}
  9. {% if app.user is not null or session is defined %}
  10. <style>
  11.   #hm-widget-root {
  12.     --hm-w-bg: #0c0d10;
  13.     --hm-w-bg-soft: #14161b;
  14.     --hm-w-fg: #e6edf3;
  15.     --hm-w-muted: #8a92a1;
  16.     --hm-w-rule: #232830;
  17.     --hm-w-signal: #4ade80;
  18.     --hm-w-signal-glow: rgba(74,222,128,0.5);
  19.     --hm-w-danger: #f87171;
  20.     --hm-w-success: #4ade80;
  21.     position: fixed;
  22.     bottom: 22px;
  23.     right: 22px;
  24.     z-index: 1050;
  25.     font-family: 'JetBrains Mono','Cascadia Code','Consolas','Courier New',monospace;
  26.     font-size: var(--cp-fs-sm, 12.5px);
  27.     color: var(--hm-w-fg);
  28.     transition: width 220ms ease, height 220ms ease;
  29.   }
  30.   #hm-widget-pill {
  31.     display: inline-flex;
  32.     align-items: center;
  33.     gap: 10px;
  34.     padding: 10px 16px 10px 14px;
  35.     background: var(--hm-w-bg);
  36.     border: 1px solid var(--hm-w-rule);
  37.     border-radius: 999px;
  38.     cursor: pointer;
  39.     user-select: none;
  40.     box-shadow: 0 6px 22px rgba(0,0,0,0.35);
  41.     transition: transform 160ms ease, box-shadow 160ms ease;
  42.     letter-spacing: 0.20em;
  43.     font-weight: 700;
  44.     font-size: 12px;
  45.   }
  46.   #hm-widget-pill:hover { transform: translateY(-2px); box-shadow: 0 10px 30px rgba(0,0,0,0.55); }
  47.   #hm-widget-pill .dot {
  48.     width: 8px; height: 8px; border-radius: 50%;
  49.     background: var(--hm-w-signal);
  50.     box-shadow: 0 0 8px var(--hm-w-signal-glow);
  51.     animation: hm-w-pulse 1.7s ease-in-out infinite;
  52.   }
  53.   @keyframes hm-w-pulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:0.5;transform:scale(1.25)} }
  54.   #hm-widget-panel {
  55.     display: none;
  56.     flex-direction: column;
  57.     width: 380px;
  58.     height: 520px;
  59.     background: var(--hm-w-bg);
  60.     border: 1px solid var(--hm-w-rule);
  61.     box-shadow: 0 14px 40px rgba(0,0,0,0.55), 0 0 0 1px rgba(255,255,255,0.04);
  62.     overflow: hidden;
  63.   }
  64.   #hm-widget-root.open #hm-widget-pill   { display: none; }
  65.   #hm-widget-root.open #hm-widget-panel  { display: flex; }
  66.   #hm-widget-panel .hm-w-head {
  67.     display: flex; align-items: center; justify-content: space-between;
  68.     padding: 10px 14px; border-bottom: 1px solid var(--hm-w-rule);
  69.   }
  70.   #hm-widget-panel .hm-w-brand {
  71.     display: flex; align-items: center; gap: 9px;
  72.     font-weight: 700; letter-spacing: 0.22em; font-size: 12px;
  73.   }
  74.   #hm-widget-panel .hm-w-brand .dot {
  75.     width: 7px; height: 7px; border-radius: 50%;
  76.     background: var(--hm-w-signal); box-shadow: 0 0 6px var(--hm-w-signal-glow);
  77.   }
  78.   #hm-widget-panel .hm-w-head-actions {
  79.     display: flex; gap: 6px;
  80.   }
  81.   #hm-widget-panel .hm-w-iconbtn {
  82.     background: transparent; color: var(--hm-w-muted); border: 1px solid var(--hm-w-rule);
  83.     cursor: pointer; padding: 3px 8px; font-family: inherit; font-size: 12px;
  84.     letter-spacing: 0.16em; text-transform: uppercase;
  85.   }
  86.   #hm-widget-panel .hm-w-iconbtn:hover { color: var(--hm-w-fg); border-color: var(--hm-w-fg); }
  87.   #hm-widget-panel .hm-w-body {
  88.     flex: 1; overflow-y: auto; padding: 12px 14px;
  89.     background: linear-gradient(180deg, var(--hm-w-bg) 0%, var(--hm-w-bg-soft) 100%);
  90.   }
  91.   #hm-widget-panel .hm-w-body::-webkit-scrollbar { width: 6px; }
  92.   #hm-widget-panel .hm-w-body::-webkit-scrollbar-thumb { background: var(--hm-w-rule); border-radius: 3px; }
  93.   .hm-w-line { margin-bottom: 5px; white-space: pre-wrap; line-height: 1.55; }
  94.   .hm-w-line .pre { color: var(--hm-w-muted); margin-right: 8px; }
  95.   .hm-w-line.user .pre { color: var(--hm-w-fg); font-weight: 700; }
  96.   .hm-w-line.error  { color: var(--hm-w-danger); }
  97.   .hm-w-line.success { color: var(--hm-w-success); }
  98.   .hm-w-card {
  99.     background: rgba(255,255,255,0.025);
  100.     border: 1px solid var(--hm-w-rule);
  101.     padding: 9px 11px; margin: 8px 0 10px 18px;
  102.     font-size: 12px; line-height: 1.6;
  103.   }
  104.   .hm-w-card .row { display: flex; justify-content: space-between; gap: 10px; padding: 3px 0; }
  105.   .hm-w-card .row + .row { border-top: 1px dashed var(--hm-w-rule); }
  106.   .hm-w-card .k { color: var(--hm-w-muted); }
  107.   .hm-w-card .v { font-weight: 600; }
  108.   #hm-widget-panel .hm-w-foot {
  109.     display: flex; align-items: center; gap: 8px;
  110.     padding: 10px 12px; border-top: 1px solid var(--hm-w-rule);
  111.   }
  112.   #hm-widget-panel .hm-w-prompt {
  113.     color: var(--hm-w-muted); letter-spacing: 0.14em; font-size: 12px; font-weight: 700;
  114.   }
  115.   #hm-widget-panel #hm-w-input {
  116.     flex: 1; background: transparent; color: var(--hm-w-fg); border: none; outline: none;
  117.     font-family: inherit; font-size: var(--cp-fs-sm, 12.5px);
  118.   }
  119.   #hm-widget-panel #hm-w-input:disabled { opacity: 0.4; }
  120.   #hm-widget-panel .hm-w-caret {
  121.     display: inline-block; width: 7px; height: 13px; background: var(--hm-w-fg);
  122.     animation: hm-w-blink 1s steps(1) infinite;
  123.   }
  124.   @keyframes hm-w-blink { 50%{opacity:0} }
  125.   /* ── Chat-bubble layout (General). Shares hb_hivemind_ui_mode with the full
  126.      page; the compact widget renders both general + messenger as bubbles. ── */
  127.   #hm-widget-root[data-ui-mode="general"] .hm-w-body,
  128.   #hm-widget-root[data-ui-mode="messenger"] .hm-w-body {
  129.     font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
  130.   }
  131.   #hm-widget-root[data-ui-mode="general"] .hm-w-line,
  132.   #hm-widget-root[data-ui-mode="messenger"] .hm-w-line {
  133.     max-width: 84%; width: fit-content; padding: 7px 11px; border-radius: 13px; margin: 5px 0;
  134.   }
  135.   #hm-widget-root[data-ui-mode="general"] .hm-w-line .pre,
  136.   #hm-widget-root[data-ui-mode="messenger"] .hm-w-line .pre { display: none; }
  137.   #hm-widget-root[data-ui-mode="general"] .hm-w-line.user,
  138.   #hm-widget-root[data-ui-mode="messenger"] .hm-w-line.user {
  139.     margin-left: auto; background: var(--hm-w-signal); color: var(--hm-w-bg); border-bottom-right-radius: 4px;
  140.   }
  141.   #hm-widget-root[data-ui-mode="general"] .hm-w-line:not(.user),
  142.   #hm-widget-root[data-ui-mode="messenger"] .hm-w-line:not(.user) {
  143.     margin-right: auto; background: var(--hm-w-bg-soft); border: 1px solid var(--hm-w-rule); border-bottom-left-radius: 4px;
  144.   }
  145.   #hm-widget-root[data-ui-mode="general"] .hm-w-card,
  146.   #hm-widget-root[data-ui-mode="messenger"] .hm-w-card {
  147.     margin: 6px auto 8px 0; max-width: 90%; border-radius: 12px;
  148.   }
  149. </style>
  150. <div id="hm-widget-root" data-ui-mode="terminal">
  151.   <div id="hm-widget-pill" title="Open Hivemind (Ctrl+`)">
  152.     <span class="dot"></span>HIVEMIND
  153.   </div>
  154.   <div id="hm-widget-panel">
  155.     <div class="hm-w-head">
  156.       <div class="hm-w-brand"><span class="dot"></span>HIVEMIND</div>
  157.       <div class="hm-w-head-actions">
  158.         <button class="hm-w-iconbtn" id="hm-w-uimode" title="Toggle chat layout (Terminal / General)" aria-label="Toggle chat layout">CHAT</button>
  159.         <a class="hm-w-iconbtn" href="{{ url('ai_hivemind') }}" title="Open full terminal">↗ FULL</a>
  160.         <button class="hm-w-iconbtn" id="hm-w-close" title="Minimise">─</button>
  161.       </div>
  162.     </div>
  163.     <div class="hm-w-body" id="hm-w-body"></div>
  164.     <div class="hm-w-foot">
  165.       <span class="hm-w-prompt">&gt;</span>
  166.       <input id="hm-w-input" type="text" placeholder="Ask something… /cash · /reports · /approvals" autocomplete="off" spellcheck="false" />
  167.       <span class="hm-w-caret"></span>
  168.     </div>
  169.   </div>
  170. </div>
  171. <script>
  172. (function () {
  173.   if (window.__hivemindWidgetInit) return;
  174.   window.__hivemindWidgetInit = true;
  175.   const root  = document.getElementById('hm-widget-root');
  176.   const pill  = document.getElementById('hm-widget-pill');
  177.   const closeBtn = document.getElementById('hm-w-close');
  178.   const body  = document.getElementById('hm-w-body');
  179.   const input = document.getElementById('hm-w-input');
  180.   const OPEN_KEY = 'hivemind.widget.open';
  181.   function setOpen(open) {
  182.     root.classList.toggle('open', !!open);
  183.     localStorage.setItem(OPEN_KEY, open ? '1' : '0');
  184.     if (open) setTimeout(() => input.focus(), 60);
  185.   }
  186.   setOpen(localStorage.getItem(OPEN_KEY) === '1');
  187.   // ── UI layout (Terminal / General) — shares hb_hivemind_ui_mode with the
  188.   //    full page. CSS only; the command pipeline is untouched. ──
  189.   const uiBtn = document.getElementById('hm-w-uimode');
  190.   const UI_MODE_KEY = 'hb_hivemind_ui_mode';
  191.   function applyUiMode(m) {
  192.     const bubble = (m === 'general' || m === 'messenger');
  193.     root.setAttribute('data-ui-mode', bubble ? 'general' : 'terminal');
  194.     if (uiBtn) { uiBtn.textContent = bubble ? 'TERM' : 'CHAT'; }
  195.   }
  196.   applyUiMode(localStorage.getItem(UI_MODE_KEY) || 'terminal');
  197.   if (uiBtn) uiBtn.addEventListener('click', () => {
  198.     const cur = localStorage.getItem(UI_MODE_KEY) || 'terminal';
  199.     const next = (cur === 'terminal') ? 'general' : 'terminal';
  200.     localStorage.setItem(UI_MODE_KEY, next);
  201.     applyUiMode(next);
  202.   });
  203.   // Reflect a change made on the full page (same key) if both are open.
  204.   window.addEventListener('storage', (e) => { if (e.key === UI_MODE_KEY) applyUiMode(e.newValue || 'terminal'); });
  205.   pill.addEventListener('click', () => setOpen(true));
  206.   closeBtn.addEventListener('click', () => setOpen(false));
  207.   // Keyboard shortcut: Ctrl + ` toggles
  208.   document.addEventListener('keydown', (e) => {
  209.     if (e.ctrlKey && e.key === '`') {
  210.       e.preventDefault();
  211.       setOpen(!root.classList.contains('open'));
  212.     }
  213.   });
  214.   function esc(s) {
  215.     return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
  216.   }
  217.   function fmt(n) {
  218.     return Number(n || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
  219.   }
  220.   function addLine(kind, text) {
  221.     const div = document.createElement('div');
  222.     div.className = 'hm-w-line ' + kind;
  223.     div.innerHTML = '<span class="pre">' + (kind === 'user' ? '$' : '&gt;') + '</span>' + esc(text);
  224.     body.appendChild(div); body.scrollTop = body.scrollHeight;
  225.     return div;
  226.   }
  227.   function addCard(html) {
  228.     const w = document.createElement('div'); w.className = 'hm-w-card'; w.innerHTML = html;
  229.     body.appendChild(w); body.scrollTop = body.scrollHeight;
  230.     return w;
  231.   }
  232.   function row(k, v) { return '<div class="row"><span class="k">' + esc(k) + '</span><span class="v">' + esc(v) + '</span></div>'; }
  233.   function renderSimpleResult(env, body0) {
  234.     const r = body0.result || {};
  235.     switch (env.tool) {
  236.       case 'get_cash_position':
  237.         addCard(row('Cash + bank total', fmt(r.grand_total))
  238.               + row('Ledger count', r.ledger_count));
  239.         break;
  240.       case 'get_party_balance':
  241.         addCard(row('Party', (r.name || '') + ' (#' + r.party_id + ')')
  242.               + row('Invoiced', fmt(r.invoiced))
  243.               + row('Received', fmt(r.received))
  244.               + row('Due',      fmt(r.due)));
  245.         break;
  246.       case 'get_overdue_invoices':
  247.         addCard(row('Overdue invoices', r.row_count)
  248.               + row('Total due',        fmt(r.total_due)));
  249.         break;
  250.       case 'search_party':
  251.         addCard(row('Matches', (r.matches || []).map(m => '#' + m.id + ' ' + (m.name || '')).slice(0,5).join('<br>') || '—'));
  252.         break;
  253.       case 'list_pending_approvals':
  254.         addCard(row('Pending', r.row_count)
  255.               + ((r.pending || []).slice(0,5).map(p => row(p.entity_type_name + ' #' + p.entity_id, p.document_hash || '')).join('') || ''));
  256.         break;
  257.       case 'run_report':
  258.         const insights = (r.insights || []).slice(0,3).map(i => row(i.label, typeof i.value === 'number' ? fmt(i.value) : String(i.value))).join('');
  259.         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>' : ''));
  260.         break;
  261.       default:
  262.         addCard(row('Result', JSON.stringify(r).slice(0, 200)));
  263.     }
  264.     if (body0.message) addLine('', body0.message);
  265.   }
  266.   async function postJson(url, payload) {
  267.     const res = await fetch(url, {
  268.       method: 'POST', credentials: 'same-origin',
  269.       headers: { 'Content-Type': 'application/json' },
  270.       body: JSON.stringify(payload),
  271.     });
  272.     let bod;
  273.     try { bod = await res.json(); } catch { bod = { message: 'Bad JSON' }; }
  274.     return { status: res.status, body: bod };
  275.   }
  276.   // Compact confirm-before-commit draft (CO6) — same approval rail as the full
  277.   // cockpit: preview here, confirm posts through /ai/command/execute (mode=confirm).
  278.   function renderWidgetDraft(draft) {
  279.     const rows = Object.entries(draft.preview || {})
  280.       .map(([k, v]) => row(k, typeof v === 'object' ? JSON.stringify(v) : String(v))).join('');
  281.     const card = addCard(
  282.       '<div style="font-weight:700;letter-spacing:.1em;color:var(--hm-w-signal);margin-bottom:6px">▸ DRAFT · '
  283.         + esc((draft.tool || '').replace(/_/g, ' ').toUpperCase()) + '</div>'
  284.       + rows
  285.       + '<div style="margin:6px 0;color:var(--hm-w-muted);font-size:11.5px">Nothing posts until you confirm.</div>'
  286.       + '<div class="hm-w-draft-actions" style="display:flex;gap:6px;margin-top:8px">'
  287.       + '<button class="hm-w-iconbtn hm-w-do-confirm">▸ CONFIRM</button>'
  288.       + '<button class="hm-w-iconbtn hm-w-do-cancel">CANCEL</button></div>');
  289.     const acts = card.querySelector('.hm-w-draft-actions');
  290.     card.querySelector('.hm-w-do-confirm').addEventListener('click', async () => {
  291.       acts.innerHTML = '<span style="color:var(--hm-w-muted);font-size:11.5px">posting…</span>';
  292.       const ex = await postJson('/ai/command/execute', {
  293.         tool: draft.tool, version: draft.version || '1.0', arguments: draft.arguments || {},
  294.         mode: 'confirm', draft_id: draft.draft_id, source: 'widget',
  295.       });
  296.       if (ex.status >= 200 && ex.status < 300 && ex.body && ex.body.success) {
  297.         addLine('success', (ex.body.message) || 'Posted.');
  298.       } else {
  299.         addLine('error', (ex.body && ex.body.message) || ('Confirm failed (HTTP ' + ex.status + ')'));
  300.       }
  301.     });
  302.     card.querySelector('.hm-w-do-cancel').addEventListener('click', () => {
  303.       acts.innerHTML = '<span style="color:var(--hm-w-muted);font-size:11.5px">cancelled</span>';
  304.     });
  305.   }
  306.   async function handle(text) {
  307.     if (!text.trim()) return;
  308.     addLine('user', text);
  309.     input.value = ''; input.disabled = true;
  310.     try {
  311.       const parseRes = await postJson('/ai/command/parse', { chat: text, conversation: [], current_page: 'widget' });
  312.       const data = parseRes.body || {};
  313.       // EL4b — chat intake pre-warm: known pitfalls for the resolved intent, up-front
  314.       // before the action runs. Count + real ids only; server attaches only when negatives
  315.       // exist (flag-gated + fail-open), so this is a no-op otherwise.
  316.       if (data.prewarm && data.prewarm.total > 0) {
  317.         const ids = (data.prewarm.items || []).map(n => '#' + n.id).join(', ');
  318.         addLine('', '⚠ heads-up — this kind of thing didn\'t work out ' + data.prewarm.total + '× before' + (ids ? ' (' + ids + ')' : ''));
  319.       }
  320.       if (data.status === 'chat') {
  321.         const reply = data.reply || data.follow_up_question || 'Hello.';
  322.         String(reply).split('\n').forEach(ln => addLine('', ln));
  323.         return;
  324.       }
  325.       if (data.status === 'error') {
  326.         addLine('error', data.follow_up_question || 'Parser error.');
  327.         return;
  328.       }
  329.       // ── AICOO: reasoning / analytical / write-by-conversation → SAME brain
  330.       //    as the full cockpit (one brain, two surfaces). ──
  331.       if (data.status === 'agent') {
  332.         const think = addLine('', '…thinking');
  333.         const ask = await postJson('/ai/command/ask', { message: text, history: [], source: 'widget' });
  334.         if (think && think.remove) think.remove();
  335.         const turn = ask.body || {};
  336.         if (!(ask.status >= 200 && ask.status < 300 && turn.success)) {
  337.           addLine('error', (turn && turn.message) || 'The assistant could not answer.');
  338.           return;
  339.         }
  340.         if (turn.answer) String(turn.answer).split('\n').forEach(ln => addLine('', ln));
  341.         const used = [...new Set((turn.sources || []).map(s => (s.tool || '').replace(/_/g, ' ')).filter(Boolean))];
  342.         if (used.length) addLine('', 'grounded on: ' + used.join(', '));
  343.         if (turn.draft && turn.draft.draft_id) renderWidgetDraft(turn.draft);
  344.         if (turn.reports && turn.reports.length) addLine('', '📊 ' + turn.reports.length + ' chart/table — ↗ open full for the visual');
  345.         return;
  346.       }
  347.       const env = data.envelope;
  348.       if (!env) { addLine('error', 'No envelope.'); return; }
  349.       // Complex flows (preview/confirm + multi-step pickers) hand off to full Hivemind.
  350.       const needsFull = env.next_intent
  351.         || env.tool === 'create_payment' || env.tool === 'create_receipt'
  352.         || env.tool === 'create_expense' || env.tool === 'create_journal_voucher'
  353.         || env.tool === 'create_contra_voucher' || env.tool === 'create_sales_order'
  354.         || env.tool === 'create_purchase_order' || env.tool === 'create_sales_proposal'
  355.         || env.tool === 'approve_document';
  356.       if (needsFull) {
  357.         addLine('', 'This needs the full terminal. Opening Hivemind…');
  358.         sessionStorage.setItem('hivemind.bootstrap_chat', text);
  359.         window.location.href = '{{ url('ai_hivemind') }}';
  360.         return;
  361.       }
  362.       if (data.status === 'needs_input') {
  363.         addLine('', data.follow_up_question || 'Need more details.');
  364.         return;
  365.       }
  366.       // Read-only flow: execute immediately
  367.       const exec = await postJson('/ai/command/execute', {
  368.         tool: env.tool, version: env.version || '1.0',
  369.         arguments: env.arguments || {}, mode: 'preview',
  370.         source: 'widget',
  371.         idempotency_key: 'widget-' + Date.now() + '-' + Math.random().toString(36).slice(2,8),
  372.       });
  373.       if (exec.status >= 200 && exec.status < 300 && exec.body.success) {
  374.         renderSimpleResult(env, exec.body);
  375.       } else {
  376.         addLine('error', exec.body.message || ('HTTP ' + exec.status));
  377.       }
  378.     } catch (e) {
  379.       addLine('error', 'Network error: ' + (e.message || String(e)));
  380.     } finally {
  381.       input.disabled = false;
  382.       input.focus();
  383.     }
  384.   }
  385.   input.addEventListener('keydown', (e) => {
  386.     if (e.key === 'Enter' && !e.shiftKey) {
  387.       e.preventDefault();
  388.       handle(input.value);
  389.     }
  390.   });
  391.   // Greet first-time openers.
  392.   if (body.children.length === 0) {
  393.     addLine('', 'Hivemind ready. Try /cash, /reports, /approvals, or ask in plain language.');
  394.   }
  395.   // If the user clicked something that needed full terminal, the widget left
  396.   // a bootstrap-chat token in sessionStorage — full Hivemind picks it up.
  397. })();
  398. </script>
  399. {% endif %}