Merge pull request 'Fusion AI, powered by HAL 8: red eye, Standard | Incognito, code, media and voice' (#2) from feat/fusion-ai-hal8 into main
This commit is contained in:
30
README.md
30
README.md
@@ -69,16 +69,30 @@ Two things are fetched at runtime rather than bundled:
|
||||
- **three.js** `0.160.0` from `cdn.jsdelivr.net` — powers the Fusion
|
||||
Chamber 3D visual (§12). If the CDN is unreachable that section
|
||||
degrades; the rest of the page is unaffected.
|
||||
- **The AI assistant widget** posts to `window.FUSION_AI_ENDPOINT`,
|
||||
falling back to `text.pollinations.ai`. See the note below.
|
||||
- **Fusion AI**, the assistant widget: `fusion/fusion.js`, loaded on
|
||||
demand. It posts to HAL 8's public route (see the note below), and its
|
||||
Python **Run** button loads Pyodide from `cdn.jsdelivr.net` on first use.
|
||||
|
||||
## Note on the AI assistant
|
||||
## Fusion AI (powered by HAL 8)
|
||||
|
||||
The chat widget in the bottom-right sends visitor messages to a
|
||||
third-party inference endpoint by default. `window.FUSION_AI_ENDPOINT`
|
||||
can be repointed at HAL 8's own OpenAI-compatible API
|
||||
(`https://hal8.odyssey-works.io/v1`) so conversations stay on
|
||||
Odyssey Works infrastructure.
|
||||
The red-eye button in the bottom-right is Fusion AI. The page holds no key.
|
||||
It posts to `https://hal8.odyssey-works.io/v1/public/fusion/chat`, and
|
||||
`window.FUSION_AI_ENDPOINT` overrides that for testing. The route runs
|
||||
HAL 8's headless pipeline under its own capped account: $1 a day for
|
||||
everyone, and per visitor 40 messages, 6 images and 1 video a day. Its full
|
||||
contract is `docs/FUSION.md` in the HAL 8 repository.
|
||||
|
||||
- **Standard | Incognito** is the only control.
|
||||
- Standard is `hal8-auto`; the conversation is kept in this browser only.
|
||||
- Incognito is a Venice private model; nothing is kept, and generated
|
||||
media is deleted after a day.
|
||||
- Chat, code, web search, images, video and voice are always on.
|
||||
- Python code blocks run in the visitor's browser.
|
||||
|
||||
The widget is `fusion/fusion.js` and `fusion/fusion.css`. After editing
|
||||
either, run `python3 tools/fusion_inject.py`: it inlines the CSS and
|
||||
re-stamps the module version inside `index.html`, and re-running it
|
||||
replaces rather than duplicates.
|
||||
|
||||
## Markets section — live feeds
|
||||
|
||||
|
||||
78
fusion/fusion.css
Normal file
78
fusion/fusion.css
Normal 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}
|
||||
}
|
||||
291
fusion/fusion.js
Normal file
291
fusion/fusion.js
Normal file
@@ -0,0 +1,291 @@
|
||||
// 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 - a Venice private model; no conversation is kept, here or on HAL 8, and the
|
||||
// pictures and clips it makes are deleted after a day.
|
||||
|
||||
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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);
|
||||
// HAL 8's signed file links carry no extension: a HAL 8 file linked as "video" / "clip" is one
|
||||
const isHal8File = u => u.startsWith(ORIGIN + '/api/v1/files/');
|
||||
const namedVideo = (label, u) => isHal8File(u) && /^\s*(video|clip|watch)\b/i.test(label);
|
||||
|
||||
// ------------------------------------------------------------------ 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(/&/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(/&/g, '&'); const ok = safeUrl(u); if (!ok) return t;
|
||||
return isVideo(ok) || namedVideo(t, 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'} · 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">▶ 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">×</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">×</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 · nothing is saved · pictures vanish in a day</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…</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');
|
||||
// a HAL 8 file embedded as an image that will not decode is a clip: play it instead
|
||||
body.addEventListener('error', e => {
|
||||
const img = e.target;
|
||||
if (img.tagName !== 'IMG' || !img.closest('.f8-media') || !isHal8File(img.src)) return;
|
||||
const v = document.createElement('video');
|
||||
Object.assign(v, { src: img.src, controls: true, playsInline: true, preload: 'metadata' });
|
||||
img.replaceWith(v);
|
||||
const tag = v.parentElement?.querySelector('.f8-media-bar span');
|
||||
if (tag) tag.innerHTML = 'Video · HAL 8';
|
||||
}, true);
|
||||
|
||||
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… <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…'; 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…';
|
||||
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
67
tools/fusion_inject.py
Normal 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}")
|
||||
Reference in New Issue
Block a user