Fusion AI, powered by HAL 8

The hub's assistant becomes a consumer HAL 8 chat:
- The button is HAL 8's red eye (the real logo); the header reads POWERED BY HAL 8.
- One control, a Standard | Incognito pill. Standard keeps the conversation in this browser;
  Incognito keeps nothing and asks the server for no retention.
- Always on, no toggles: chat and code (copy; Python runs in the browser via Pyodide), images and
  video inline, web answers with their sources, voice in (speech recognition) and read-aloud.
- Streams from HAL 8's public Fusion endpoint (hal8.odyssey-works.io/v1/public/fusion/chat); the
  browser never holds a key. Limits come back as a friendly "see you tomorrow" card.
- A small safe markdown renderer: everything escaped, https-only links and media, HAL 8-relative
  file links resolved against HAL 8.
- tools/fusion_inject.py replaces the old assistant block idempotently (between /*F8-BEGIN*/ and
  /*F8-END*/) and inlines fusion/fusion.css.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 18:50:44 -05:00
parent f50457b1c1
commit 716615a1d2
4 changed files with 423 additions and 1 deletions

78
fusion/fusion.css Normal file
View File

@@ -0,0 +1,78 @@
.ai-fab,.ai-pane{display:none!important}
.f8-root button{font:inherit;cursor:pointer}
.f8-fab{position:fixed;right:28px;bottom:28px;width:58px;height:58px;border-radius:15px;padding:0;border:0;background:#000;z-index:101;overflow:hidden;
box-shadow:0 0 0 1px rgba(255,255,255,.08),0 10px 30px rgba(0,0,0,.5),0 0 34px -6px rgba(255,42,26,.75);animation:f8breath 3.6s ease-in-out infinite;transition:transform .2s}
.f8-fab:hover{transform:scale(1.05)}
.f8-fab img{width:100%;height:100%;display:block}
.f8-fab-x{display:none;position:absolute;inset:0;align-items:center;justify-content:center;font-size:28px;color:var(--ink);background:var(--paper-2)}
.f8-open .f8-fab{animation:none;box-shadow:0 8px 24px rgba(0,0,0,.4),0 0 0 1px var(--rule)}
.f8-open .f8-fab-x{display:flex}
@keyframes f8breath{50%{box-shadow:0 0 0 1px rgba(255,255,255,.08),0 10px 30px rgba(0,0,0,.5),0 0 48px -2px rgba(255,42,26,.95)}}
.f8-pane{position:fixed;right:28px;bottom:100px;width:420px;max-width:calc(100vw - 40px);height:640px;max-height:calc(100vh - 130px);z-index:100;
background:var(--paper-2);border:1px solid var(--rule);border-radius:14px;box-shadow:0 24px 70px rgba(0,0,0,.65);display:flex;flex-direction:column;overflow:hidden}
.f8-pane[hidden]{display:none}
.f8-head{display:flex;justify-content:space-between;align-items:flex-start;padding:18px 18px 10px}
.f8-title{font-size:24px;line-height:1;letter-spacing:-.01em}
.f8-power{display:flex;align-items:center;gap:7px;margin-top:8px;font-family:var(--f-mono);font-size:9.5px;letter-spacing:.22em;color:var(--ink-3)}
.f8-power img{width:14px;height:14px;border-radius:3px}
.f8-head-actions{display:flex;gap:4px}
.f8-head-actions button{width:32px;height:32px;display:flex;align-items:center;justify-content:center;color:var(--ink-3);background:none;border:0;border-radius:8px;font-size:22px;padding:0}
.f8-head-actions button:hover{background:rgba(255,255,255,.05);color:var(--ink)}
.f8-modebar{padding:4px 18px 12px;border-bottom:1px solid var(--rule-2)}
.f8-pill{display:inline-flex;padding:4px;gap:2px;border-radius:999px;background:rgba(255,255,255,.025);border:1px solid var(--rule)}
.f8-pill button{display:inline-flex;align-items:center;gap:7px;padding:8px 18px;border-radius:999px;border:0;background:none;color:var(--ink-3);font-family:var(--f-display);font-size:14px;transition:background .2s,color .2s}
.f8-pill button.on{background:#EAF3EC;color:#07100C;font-weight:500}
.f8-incog-note{display:none;align-items:center;gap:8px;padding:9px 18px;font-family:var(--f-mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ink-2);background:rgba(255,255,255,.03);border-bottom:1px solid var(--rule-2)}
.f8-incognito .f8-incog-note{display:flex}
.f8-incognito .f8-pane{background:#0A1411}
.f8-body{flex:1;overflow:auto;padding:18px;display:flex;flex-direction:column;gap:14px}
.f8-empty p{font-size:19px;line-height:1.4;margin:6px 0 18px;color:var(--ink)}
.f8-sugg{display:grid;gap:8px}
.f8-sugg button{text-align:left;padding:11px 13px;border:1px solid var(--rule);border-radius:10px;background:rgba(255,255,255,.02);color:var(--ink-2);font-size:13.5px;transition:border-color .2s}
.f8-sugg button:hover{border-color:var(--emerald)}
.f8-sugg b{display:block;font-family:var(--f-mono);font-weight:400;font-size:9.5px;letter-spacing:.2em;text-transform:uppercase;color:var(--emerald);margin-bottom:3px}
.f8-u{align-self:flex-end;max-width:82%;padding:10px 13px;border-radius:12px 12px 4px 12px;background:rgba(255,255,255,.06);font-size:14px;line-height:1.45;white-space:pre-wrap;word-break:break-word}
.f8-a{font-size:14px;line-height:1.55;color:var(--ink);word-break:break-word}
.f8-a p{margin:0 0 8px} .f8-a ul,.f8-a ol{margin:0 0 8px;padding-left:20px;color:var(--ink-2)} .f8-a h4{margin:10px 0 6px;font-family:var(--f-display);font-size:15px}
.f8-a a{color:var(--emerald)} .f8-a code{font-family:var(--f-mono);font-size:12.5px;background:rgba(255,255,255,.06);padding:1px 5px;border-radius:4px}
.f8-meta{display:flex;gap:4px;margin-top:4px}
.f8-meta button,.f8-media-bar a{background:none;border:0;color:var(--ink-4);padding:4px;display:inline-flex}
.f8-meta button:hover,.f8-media-bar a:hover{color:var(--ink)}
.f8-code{border:1px solid var(--rule);border-radius:10px;overflow:hidden;background:#07100C;margin:6px 0}
.f8-code-h{display:flex;justify-content:space-between;align-items:center;padding:7px 10px;font-family:var(--f-mono);font-size:10px;letter-spacing:.14em;color:var(--ink-3);border-bottom:1px solid var(--rule-2);text-transform:uppercase}
.f8-code-act{display:flex;gap:6px}
.f8-code-act button{display:inline-flex;gap:5px;align-items:center;font-family:var(--f-mono);font-size:10px;letter-spacing:.1em;color:var(--ink-2);background:rgba(255,255,255,.04);border:1px solid var(--rule);border-radius:6px;padding:3px 8px}
.f8-code pre{margin:0;padding:12px;font-family:var(--f-mono);font-size:12px;line-height:1.55;color:#CFEEE2;white-space:pre-wrap;overflow-x:auto}
.f8-out{border-top:1px solid var(--rule-2);padding:8px 12px;font-family:var(--f-mono);font-size:12px;color:var(--emerald);white-space:pre-wrap}
.f8-out span{display:block;font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-4);margin-bottom:2px}
.f8-media{position:relative;display:block;border-radius:12px;overflow:hidden;border:1px solid var(--rule);max-width:320px;margin:6px 0}
.f8-media img,.f8-media video{display:block;width:100%;max-height:360px;object-fit:cover;background:#000}
.f8-media-bar{display:flex;justify-content:space-between;align-items:center;padding:6px 10px;font-family:var(--f-mono);font-size:10px;letter-spacing:.12em;color:var(--ink-3);background:#07100C}
.f8-limit{padding:12px 14px;border:1px solid var(--rule);border-radius:10px;background:rgba(255,255,255,.03);color:var(--ink-2)}
.f8-status{display:flex;align-items:center;gap:8px;font-family:var(--f-mono);font-size:10.5px;letter-spacing:.1em;color:var(--ink-3)}
.f8-dots{display:inline-flex;gap:4px;align-items:center;padding:4px 0}
.f8-dots i{width:6px;height:6px;border-radius:50%;background:#FF3B24;opacity:.35;animation:f8dot 1.2s infinite}
.f8-dots i:nth-child(2){animation-delay:.2s}.f8-dots i:nth-child(3){animation-delay:.4s}
@keyframes f8dot{40%{opacity:1}}
.f8-compose{display:flex;align-items:flex-end;gap:8px;padding:12px;border-top:1px solid var(--rule-2);position:relative}
.f8-compose textarea{flex:1;resize:none;min-height:44px;max-height:120px;padding:12px 14px;border-radius:12px;border:1px solid var(--rule);background:rgba(255,255,255,.03);color:var(--ink);font:inherit;font-size:14px;outline:none;line-height:1.4}
.f8-compose textarea:focus{border-color:var(--ink-4)}
.f8-mic,.f8-send{width:44px;height:44px;border-radius:12px;border:1px solid var(--rule);display:flex;align-items:center;justify-content:center;flex:none;padding:0}
.f8-mic{background:rgba(255,255,255,.03);color:var(--ink-2)}
.f8-send{background:var(--emerald);border-color:var(--emerald);color:#04110b}
.f8-listen{position:absolute;left:12px;right:112px;top:12px;bottom:12px;display:flex;align-items:center;gap:10px;padding:0 14px;border-radius:12px;background:#140b0a;border:1px solid rgba(255,42,26,.45);font-size:13px;color:var(--ink-2);overflow:hidden;white-space:nowrap}
.f8-listen[hidden]{display:none}
.f8-listen em{color:var(--ink);font-style:normal}
.f8-voice .f8-mic{background:#FF2A1A;border-color:#FF2A1A;color:#fff;animation:f8pulse 1.2s ease-in-out infinite}
@keyframes f8pulse{50%{box-shadow:0 0 0 7px rgba(255,42,26,.18)}}
.f8-wave{display:inline-flex;gap:3px;align-items:center;height:20px}
.f8-wave i{width:3px;border-radius:2px;background:#FF5A47;animation:f8wave .9s ease-in-out infinite}
.f8-wave i:nth-child(1){height:8px}.f8-wave i:nth-child(2){height:16px;animation-delay:.1s}.f8-wave i:nth-child(3){height:11px;animation-delay:.2s}.f8-wave i:nth-child(4){height:19px;animation-delay:.3s}.f8-wave i:nth-child(5){height:9px;animation-delay:.4s}.f8-wave i:nth-child(6){height:14px;animation-delay:.5s}.f8-wave i:nth-child(7){height:7px;animation-delay:.6s}
@keyframes f8wave{50%{transform:scaleY(.4)}}
.f8-toast{position:absolute;left:12px;right:12px;bottom:74px;padding:10px 12px;border-radius:10px;background:#1a2a23;border:1px solid var(--rule);font-size:12.5px;color:var(--ink);z-index:3}
@media (prefers-reduced-motion:reduce){.f8-fab,.f8-dots i,.f8-wave i,.f8-voice .f8-mic{animation:none}}
@media (max-width:700px){
.f8-pane{left:0;right:0;bottom:0;top:0;width:auto;max-width:none;height:auto;max-height:none;border-radius:0;border:0}
.f8-fab{right:18px;bottom:18px}
.f8-open .f8-fab{display:none}
}

277
fusion/fusion.js Normal file
View File

@@ -0,0 +1,277 @@
// Fusion AI, powered by HAL 8: the hub's consumer assistant.
// One control (Standard | Incognito); everything else is always on: chat and code, images and
// video, web search, a Python interpreter that runs in the visitor's browser, and voice.
// The browser never holds a key: it talks to HAL 8's public Fusion endpoint, which runs the
// request through HAL 8's headless pipeline under its own capped service account.
// Standard - hal8-auto; the conversation is kept in this browser (localStorage) until cleared.
// Incognito - nothing is kept: not here, and the request is marked no-retention on the server.
const EYE = 'universe/logos/hal8-eye.png';
const STORE = 'f8.chat.v1';
const VISITOR = 'f8.visitor';
const PYODIDE = 'https://cdn.jsdelivr.net/pyodide/v0.26.4/full/';
const SUGGEST = {
standard: [['Ask', 'How do I stake AP3X?'], ['Code', 'A Python snippet that converts DFM to AP3X'],
['Image', 'The AP3X star rising over PRIME'], ['Video', 'A five-second flight through an asteroid belt']],
incognito: [['Ask', 'Explain AP3X tokenomics simply'], ['Image', 'A night sky over the Nexus world']],
};
const I = {
incog: '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3l18 18"/><path d="M10.6 5.1A10.4 10.4 0 0 1 12 5c5 0 9 4.5 10 7-.4 1-1.2 2.3-2.4 3.5M6.2 6.3C4.2 7.6 2.7 9.6 2 12c1 2.5 5 7 10 7 1.7 0 3.3-.5 4.6-1.3"/><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2"/></svg>',
mic: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3"/></svg>',
send: '<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg>',
stop: '<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>',
plus: '<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>',
copy: '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.7"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3"/></svg>',
speak: '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M11 5 6 9H3v6h3l5 4zM15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13"/></svg>',
dl: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M12 4v11M7 10l5 5 5-5M5 20h14"/></svg>',
};
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
// media and links: https only; paths HAL 8 returns relative to itself resolve against the endpoint
let ORIGIN = 'https://hal8.odyssey-works.io';
const safeUrl = u => {
if (/^\/(?!\/)/.test(u)) return ORIGIN + u;
if (/^https:\/\//i.test(u)) return u;
if (ORIGIN.startsWith('http://127.0.0.1') && /^http:\/\/127\.0\.0\.1[:/]/.test(u)) return u; // local testing only
return null;
};
const isVideo = u => /\.(mp4|webm|mov)(\?|#|$)/i.test(u) || /[?&](type|kind)=video/i.test(u);
// ------------------------------------------------------------------ a small, safe markdown renderer
// Everything is escaped first; only code fences, inline code, emphasis, headings, lists, links,
// images and videos (https only) are turned back into markup.
export function renderMarkdown(src) {
const blocks = [];
let text = String(src ?? '').replace(/```([\w+-]*)[^\n]*\n([\s\S]*?)(```|$)/g, (_, lang, code) => {
blocks.push({ lang: (lang || '').toLowerCase(), code: code.replace(/\n$/, '') });
return `\u0000${blocks.length - 1}\u0000`;
});
text = esc(text);
const inline = s => s
.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (m, alt, u) => { u = u.replace(/&amp;/g, '&'); const ok = safeUrl(u); if (!ok) return esc(alt);
return isVideo(ok) ? media(ok, 'video', alt) : media(ok, 'image', alt); })
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, t, u) => { u = u.replace(/&amp;/g, '&'); const ok = safeUrl(u); if (!ok) return t;
return isVideo(ok) ? media(ok, 'video', t) : `<a href="${esc(ok)}" target="_blank" rel="noopener nofollow">${t}</a>`; })
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>').replace(/(^|[\s(])\*([^*\n]+)\*/g, '$1<i>$2</i>');
const out = [];
let list = null;
const flush = () => { if (list) { out.push(`<${list.t}>${list.items.map(x => `<li>${inline(x)}</li>`).join('')}</${list.t}>`); list = null; } };
for (const raw of text.split('\n')) {
const line = raw.trimEnd();
const code = line.match(/^\u0000(\d+)\u0000$/);
if (code) { flush(); out.push(codeBlock(blocks[+code[1]])); continue; }
const h = line.match(/^(#{1,4})\s+(.*)$/);
if (h) { flush(); out.push(`<h4>${inline(h[2])}</h4>`); continue; }
const ul = line.match(/^\s*[-*•]\s+(.*)$/), ol = line.match(/^\s*\d+[.)]\s+(.*)$/);
if (ul || ol) { const t = ul ? 'ul' : 'ol'; if (!list || list.t !== t) { flush(); list = { t, items: [] }; } list.items.push((ul || ol)[1]); continue; }
flush();
if (!line.trim()) continue;
out.push(`<p>${inline(line).replace(/\u0000(\d+)\u0000/g, (_, n) => codeBlock(blocks[+n]))}</p>`);
}
flush();
return out.join('');
}
function media(u, kind, label) {
const body = kind === 'video' ? `<video src="${esc(u)}" controls playsinline preload="metadata"></video>` : `<img src="${esc(u)}" alt="${esc(label || '')}" loading="lazy">`;
return `<span class="f8-media">${body}<span class="f8-media-bar"><span>${kind === 'video' ? 'Video' : 'Image'} &middot; HAL 8</span><a href="${esc(u)}" download target="_blank" rel="noopener" title="Download">${I.dl}</a></span></span>`;
}
function codeBlock(b) {
const runnable = ['python', 'py', ''].includes(b.lang) && /print|=|def |import /.test(b.code);
return `<div class="f8-code" data-lang="${esc(b.lang || 'code')}"><div class="f8-code-h"><span>${esc(b.lang || 'code')}</span><span class="f8-code-act">` +
(runnable ? '<button data-f8="run">&#9654; Run</button>' : '') + `<button data-f8="copy-code">${I.copy} Copy</button></span></div><pre>${esc(b.code)}</pre></div>`;
}
// ------------------------------------------------------------------ the widget
export function mount(root, opts = {}) {
const endpoint = opts.endpoint;
try { ORIGIN = new URL(endpoint).origin; } catch { /* keep HAL 8 */ }
const state = { open: false, mode: 'standard', msgs: { standard: load(), incognito: [] }, busy: false, ctrl: null, voiceTurn: false };
root.classList.add('f8-root');
root.innerHTML = `
<button class="f8-fab" aria-label="Ask Fusion AI" aria-expanded="false" aria-controls="f8-pane"><img src="${EYE}" alt="HAL 8"><span class="f8-fab-x" aria-hidden="true">&times;</span></button>
<div class="f8-pane" id="f8-pane" role="dialog" aria-label="Fusion AI" hidden>
<div class="f8-head">
<div><div class="f8-title serif">Fusion <span class="serif-i">AI</span></div><div class="f8-power"><img src="${EYE}" alt="">POWERED BY HAL 8</div></div>
<div class="f8-head-actions"><button data-f8="new" title="New chat" aria-label="New chat">${I.plus}</button><button data-f8="close" class="f8-x" title="Close" aria-label="Close">&times;</button></div>
</div>
<div class="f8-modebar"><div class="f8-pill" role="tablist" aria-label="Mode">
<button role="tab" data-mode="standard" aria-selected="true" class="on">Standard</button>
<button role="tab" data-mode="incognito" aria-selected="false">${I.incog} Incognito</button></div></div>
<div class="f8-incog-note">${I.incog} Incognito &middot; nothing is saved, here or on the server</div>
<div class="f8-body" aria-live="polite"></div>
<div class="f8-compose">
<div class="f8-listen" hidden><span class="f8-wave"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></span><span class="f8-heard">Listening&hellip;</span></div>
<textarea rows="1" placeholder="Ask anything, or tap the mic" aria-label="Message Fusion AI"></textarea>
<button class="f8-mic" data-f8="mic" title="Speak" aria-label="Speak">${I.mic}</button>
<button class="f8-send" data-f8="send" title="Send" aria-label="Send">${I.send}</button>
</div>
</div>`;
const $ = s => root.querySelector(s);
const fab = $('.f8-fab'), pane = $('.f8-pane'), body = $('.f8-body'), ta = $('textarea'), sendBtn = $('.f8-send'), micBtn = $('.f8-mic');
function load() { try { return JSON.parse(localStorage.getItem(STORE) || '[]').slice(-60); } catch { return []; } }
function save() { if (state.mode !== 'standard') return; try { localStorage.setItem(STORE, JSON.stringify(state.msgs.standard.slice(-60))); } catch { /* storage off */ } }
function visitor() {
try { let v = localStorage.getItem(VISITOR); if (!v) { v = crypto.randomUUID(); localStorage.setItem(VISITOR, v); } return v; } catch { return 'anon'; }
}
const msgs = () => state.msgs[state.mode];
function setOpen(o) {
state.open = o; pane.hidden = !o; root.classList.toggle('f8-open', o); fab.setAttribute('aria-expanded', String(o));
if (o) { draw(); setTimeout(() => ta.focus(), 30); } else { stopVoice(); speechSynthesis?.cancel?.(); }
}
function setMode(m) {
if (m === state.mode) return;
state.ctrl?.abort();
if (state.mode === 'incognito') state.msgs.incognito = []; // leaving incognito forgets it
state.mode = m; root.classList.toggle('f8-incognito', m === 'incognito');
root.querySelectorAll('.f8-pill button').forEach(b => { const on = b.dataset.mode === m; b.classList.toggle('on', on); b.setAttribute('aria-selected', String(on)); });
draw();
}
function draw() {
const list = msgs();
if (!list.length) {
const s = SUGGEST[state.mode];
body.innerHTML = `<div class="f8-empty"><p class="serif">${state.mode === 'incognito' ? "You're incognito. <span class=\"serif-i\">Nothing you say here is kept.</span>" : "Hi, I'm Fusion AI. <span class=\"serif-i\">Ask, code, create or just talk.</span>"}</p>
<div class="f8-sugg">${s.map(([k, t]) => `<button data-f8="suggest"><b>${k}</b>${esc(t)}</button>`).join('')}</div></div>`;
return;
}
body.innerHTML = list.map((m, i) => bubble(m, i)).join('');
body.scrollTop = body.scrollHeight;
}
function bubble(m, i) {
if (m.role === 'user') return `<div class="f8-u">${esc(m.content)}</div>`;
if (m.kind === 'limit') return `<div class="f8-a f8-limit">${esc(m.content)}</div>`;
const tools = m.content && !m.pending ? `<div class="f8-meta"><button data-f8="copy" data-i="${i}" title="Copy">${I.copy}</button><button data-f8="speak" data-i="${i}" title="Read aloud">${I.speak}</button></div>` : '';
const status = m.pending && m.status ? `<div class="f8-status"><span class="f8-dots"><i></i><i></i><i></i></span>${esc(m.status)}</div>` : '';
const html = m.content ? renderMarkdown(m.content) : '';
return `<div class="f8-a${m.pending && !m.content ? ' f8-thinking' : ''}">${html}${status || (m.pending && !m.content ? '<span class="f8-dots"><i></i><i></i><i></i></span>' : '')}${tools}</div>`;
}
function redrawLast() {
const list = msgs(), el = body.lastElementChild;
if (!el || !list.length) return draw();
const tmp = document.createElement('div'); tmp.innerHTML = bubble(list[list.length - 1], list.length - 1);
el.replaceWith(tmp.firstElementChild); body.scrollTop = body.scrollHeight;
}
// ---------------------------------------------------------------- sending, streamed
async function send(text) {
text = (text || '').trim();
if (!text || state.busy) return;
const list = msgs();
list.push({ role: 'user', content: text });
const reply = { role: 'assistant', content: '', pending: true };
list.push(reply);
ta.value = ''; autoGrow(); state.busy = true; sendBtn.innerHTML = I.stop; sendBtn.title = 'Stop'; draw();
const ctrl = new AbortController(); state.ctrl = ctrl;
const history = list.filter(m => !m.pending && m.kind !== 'limit').slice(-16).map(m => ({ role: m.role, content: m.content }));
try {
const r = await fetch(endpoint, {
method: 'POST', signal: ctrl.signal, headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
body: JSON.stringify({ mode: state.mode, messages: history, visitor: state.mode === 'incognito' ? undefined : visitor(), stream: true }),
});
if (!r.ok) {
let e = {}; try { e = (await r.json()).error || {}; } catch { /* not json */ }
reply.pending = false;
if (r.status === 429) { reply.kind = 'limit'; reply.content = e.message || "That's today's allowance for Fusion AI. It resets at 00:00 UTC; see you then."; }
else reply.content = e.message || "I couldn't reach HAL 8 just now. Try again in a moment.";
return;
}
const ct = r.headers.get('content-type') || '';
if (ct.includes('text/event-stream') && r.body) {
const rd = r.body.getReader(), dec = new TextDecoder(); let buf = '';
for (;;) {
const { value, done } = await rd.read(); if (done) break;
buf += dec.decode(value, { stream: true });
let k;
while ((k = buf.indexOf('\n\n')) >= 0) {
const evt = buf.slice(0, k); buf = buf.slice(k + 2);
for (const ln of evt.split('\n')) {
if (!ln.startsWith('data:')) continue;
const d = ln.slice(5).trim(); if (!d || d === '[DONE]') continue;
let j; try { j = JSON.parse(d); } catch { continue; }
if (j.error) { reply.kind = j.error.code && /limit|budget/.test(j.error.code) ? 'limit' : ''; reply.content = j.error.message || 'Something went wrong.'; continue; }
if (j.status) reply.status = j.status; // e.g. "Searching the web", "Rendering video"
const delta = j.choices?.[0]?.delta?.content ?? j.choices?.[0]?.message?.content ?? '';
if (delta) reply.content += delta;
}
redrawLast();
}
}
} else {
const j = await r.json();
reply.content = j.choices?.[0]?.message?.content || j.text || '';
}
if (!reply.content) reply.content = 'Hmm, an empty answer. Try asking again.';
} catch (e) {
if (e.name !== 'AbortError') reply.content = reply.content || "I couldn't reach HAL 8 just now. Try again in a moment.";
} finally {
reply.pending = false; reply.status = ''; state.busy = false; state.ctrl = null; sendBtn.innerHTML = I.send; sendBtn.title = 'Send';
draw(); save();
if (state.voiceTurn && reply.content && reply.kind !== 'limit') speak(reply.content);
state.voiceTurn = false;
}
}
// ---------------------------------------------------------------- voice: speak in, read out
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
let rec = null;
function startVoice() {
if (!SR) { toast('Voice needs Chrome, Edge or Safari on this device.'); return; }
speechSynthesis?.cancel?.();
rec = new SR(); rec.lang = navigator.language || 'en-US'; rec.interimResults = true; rec.continuous = false;
let finalText = '';
const heard = $('.f8-heard'); $('.f8-listen').hidden = false; root.classList.add('f8-voice');
rec.onresult = ev => { let interim = ''; for (let i = ev.resultIndex; i < ev.results.length; i++) { const t = ev.results[i][0].transcript; if (ev.results[i].isFinal) finalText += t; else interim += t; }
heard.innerHTML = `Listening&hellip; <em>${esc((finalText + ' ' + interim).trim())}</em>`; };
rec.onerror = ev => { if (ev.error === 'not-allowed') toast('Allow the microphone to talk to Fusion AI.'); };
rec.onend = () => { stopVoice(); if (finalText.trim()) { state.voiceTurn = true; send(finalText); } };
rec.start();
}
function stopVoice() { root.classList.remove('f8-voice'); $('.f8-listen').hidden = true; $('.f8-heard').innerHTML = 'Listening&hellip;'; if (rec) { try { rec.stop(); } catch { /* already stopped */ } rec = null; } }
function speak(md) {
if (!('speechSynthesis' in window)) return;
const plain = md.replace(/```[\s\S]*?```/g, ' (code shown on screen) ').replace(/!?\[([^\]]*)\]\([^)]+\)/g, '$1').replace(/[#*`>_]/g, '').slice(0, 1800);
speechSynthesis.cancel(); const u = new SpeechSynthesisUtterance(plain); u.lang = navigator.language || 'en-US'; speechSynthesis.speak(u);
}
// ---------------------------------------------------------------- the code interpreter (Pyodide, in this browser)
let py = null;
async function runPython(btn) {
const box = btn.closest('.f8-code'); const code = box.querySelector('pre').textContent;
let out = box.querySelector('.f8-out'); if (!out) { out = document.createElement('div'); out.className = 'f8-out'; box.appendChild(out); }
out.innerHTML = '<span>output</span>Starting Python&hellip;';
try {
if (!py) py = (async () => { await new Promise((res, rej) => { const s = document.createElement('script'); s.src = PYODIDE + 'pyodide.js'; s.onload = res; s.onerror = rej; document.head.appendChild(s); }); return window.loadPyodide({ indexURL: PYODIDE }); })();
const p = await py; let buf = '';
p.setStdout({ batched: s => { buf += s + '\n'; } }); p.setStderr({ batched: s => { buf += s + '\n'; } });
await p.loadPackagesFromImports(code);
const res = await p.runPythonAsync(code);
if (res !== undefined && res !== null && !buf) buf = String(res);
out.innerHTML = `<span>output</span>${esc(buf.trim() || '(no output)')}`;
} catch (e) { out.innerHTML = `<span>error</span>${esc(String(e.message || e).split('\n').slice(-3).join('\n'))}`; }
}
// ---------------------------------------------------------------- events
function toast(t) { const el = document.createElement('div'); el.className = 'f8-toast'; el.textContent = t; pane.appendChild(el); setTimeout(() => el.remove(), 3200); }
function autoGrow() { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 120) + 'px'; }
fab.addEventListener('click', () => setOpen(!state.open));
ta.addEventListener('input', autoGrow);
ta.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(ta.value); } });
root.addEventListener('keydown', e => { if (e.key === 'Escape' && state.open) setOpen(false); });
root.addEventListener('click', e => {
const b = e.target.closest('[data-f8],[data-mode]'); if (!b) return;
if (b.dataset.mode) return setMode(b.dataset.mode);
const act = b.dataset.f8, list = msgs();
if (act === 'close') setOpen(false);
else if (act === 'new') { state.ctrl?.abort(); state.msgs[state.mode] = []; if (state.mode === 'standard') { try { localStorage.removeItem(STORE); } catch { /* */ } } draw(); }
else if (act === 'send') { if (state.busy) state.ctrl?.abort(); else send(ta.value); }
else if (act === 'mic') { if (root.classList.contains('f8-voice')) stopVoice(); else startVoice(); }
else if (act === 'suggest') send(b.childNodes[1]?.textContent || b.textContent);
else if (act === 'copy') navigator.clipboard?.writeText(list[+b.dataset.i]?.content || '');
else if (act === 'speak') speak(list[+b.dataset.i]?.content || '');
else if (act === 'copy-code') navigator.clipboard?.writeText(b.closest('.f8-code').querySelector('pre').textContent);
else if (act === 'run') runPython(b);
});
return { open: () => setOpen(true), destroy() { state.ctrl?.abort(); stopVoice(); root.innerHTML = ''; } };
}

File diff suppressed because one or more lines are too long

67
tools/fusion_inject.py Normal file
View File

@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Put Fusion AI (powered by HAL 8) into the hub, replacing the old assistant pane.
python3 tools/fusion_inject.py
The widget itself is fusion/fusion.js (served as a file, loaded on demand) and fusion/fusion.css
(inlined into the bundle). The old block, from its "FUSION AI — assistant pane" banner to the end of
the FusionAI component, is replaced by a small component between /*F8-BEGIN*/ and /*F8-END*/;
re-running replaces that block and the style block again, so the script is idempotent.
The endpoint is HAL 8's public Fusion route; window.FUSION_AI_ENDPOINT still overrides it.
"""
import hashlib
import re
import subprocess
import sys
from pathlib import Path
SITE = Path(__file__).resolve().parents[1]
ENDPOINT = "https://hal8.odyssey-works.io/v1/public/fusion/chat"
tool = SITE / "tools" / "bundle.py"
tmp = SITE / "tools" / "_page.html"
subprocess.run([sys.executable, str(tool), "decode", str(SITE / "index.html"), str(tmp)], check=True, capture_output=True)
page = tmp.read_text("utf-8")
VER = hashlib.sha1((SITE / "fusion" / "fusion.js").read_bytes()).hexdigest()[:10]
COMPONENT = f"""/*F8-BEGIN*/
// ============================================================================
// FUSION AI — powered by HAL 8 (fusion/fusion.js; the key lives on HAL 8, never here)
// ============================================================================
function FusionAI() {{
const ref = useRef(null);
useEffect(() => {{
let api = null, dead = false;
import("./fusion/fusion.js?v={VER}")
.then(m => {{ if (!dead) api = m.mount(ref.current, {{ endpoint: window.FUSION_AI_ENDPOINT || "{ENDPOINT}" }}); }})
.catch(e => console.warn("[Fusion AI]", e));
return () => {{ dead = true; if (api) api.destroy(); }};
}}, []);
return MG("div", {{ ref }});
}}
/*F8-END*/
"""
if "/*F8-BEGIN*/" in page:
i = page.index("/*F8-BEGIN*/")
j = page.index("/*F8-END*/\n", i) + len("/*F8-END*/\n")
else:
banner = page.index("// FUSION AI — assistant pane")
i = page.rfind("// ====", 0, banner)
f = page.index("function FusionAI()", banner)
j = re.compile(r"\n}\n").search(page, f).end()
old = page[i:j]
assert old.count("function ") == 1 and "SYSTEM_PROMPT" in old and "Powered by Claude" in old, "unexpected old block"
page = page[:i] + COMPONENT + page[j:]
assert "Powered by Claude" not in page and page.count("function FusionAI()") == 1
css = (SITE / "fusion" / "fusion.css").read_text("utf-8")
page = re.sub(r"<style>/\*f8-css-begin\*/.*?/\*f8-css-end\*/</style>\n", "", page, flags=re.S)
k = page.rfind("</head>")
page = page[:k] + "<style>/*f8-css-begin*/" + css + "/*f8-css-end*/</style>\n" + page[k:]
tmp.write_text(page, "utf-8")
r = subprocess.run([sys.executable, str(tool), "encode", str(SITE / "index.html"), str(tmp), str(SITE / "index.html")], capture_output=True, text=True)
tmp.unlink()
print(r.stdout.strip() or r.stderr.strip())
print(f"fusion.js v{VER}")