2026-03-18 22:15:46 -04:00
|
|
|
|
import { sendVisitorMessage } from './websocket.js';
|
2026-03-19 18:16:40 +00:00
|
|
|
|
import { classify } from './edge-worker-client.js';
|
2026-03-19 19:10:46 +00:00
|
|
|
|
import { setMood, setSpeechBubble } from './agents.js';
|
2026-03-23 22:54:07 +00:00
|
|
|
|
import { getOrRefreshToken, getPubkey, disconnectNostrIdentity, showIdentityPrompt } from './nostr-identity.js';
|
2026-03-18 21:01:13 -04:00
|
|
|
|
|
|
|
|
|
|
const $fps = document.getElementById('fps');
|
2026-03-18 22:15:46 -04:00
|
|
|
|
const $activeJobs = document.getElementById('active-jobs');
|
2026-03-18 21:01:13 -04:00
|
|
|
|
const $connStatus = document.getElementById('connection-status');
|
2026-03-18 22:15:46 -04:00
|
|
|
|
const $log = document.getElementById('event-log');
|
2026-03-18 21:01:13 -04:00
|
|
|
|
|
2026-03-18 22:15:46 -04:00
|
|
|
|
const MAX_LOG = 6;
|
|
|
|
|
|
const logEntries = [];
|
2026-03-18 21:01:13 -04:00
|
|
|
|
let uiInitialized = false;
|
|
|
|
|
|
|
Task #23: Workshop session mode UI — fund once, ask many
## What was done
- **`the-matrix/js/session.js`** (new module): Full session mode UI lifecycle:
- Create session flow: amount presets → POST /api/sessions → deposit invoice step
- Deposit payment: stub simulate → 2s polling until state=active
- macaroon + sessionId stored in localStorage (`timmy_session_v1`)
- Request submission: intercepts input bar when session active → POST /api/sessions/:id/request
→ Timmy speech bubble shows result, balance updates in HUD
- Low-balance (< 50 sats): paused state, low-balance notice shown, topup quick-button
- Topup flow: preset amount → POST /api/sessions/:id/topup → topup invoice → stub pay → poll
- Restore from localStorage on page reload: validates session via GET, restores full UI state
- Session expiry / 401 macaroon rejection: clears storage, resets to unfunded state
- **`the-matrix/js/ui.js`**: Added `setSessionSendHandler(fn)` + `setInputBarSessionMode(active, placeholder)` exports; send() routes to session handler when active, falls back to WS visitor_message
- **`the-matrix/index.html`**:
- `#top-buttons` flex container: "⚡ SUBMIT JOB" (blue) + "⚡ FUND SESSION" (teal) side-by-side
- `#session-hud` balance line in HUD (green, hidden until session active)
- `#session-panel` left-side slide-in panel: fund / invoice / active / topup steps
- `.session-amount-btn` presets (200, 500, 1000, 2000, 5000 sats) with active state
- `#visitor-input.session-active` CSS: green border + 3s pulse keyframe animation
- `#low-balance-notice` strip above input bar with Top Up quick-button
- `.primary-green` / `.muted` panel button variants for session panel theme
- `#session-panel` inherits shared `.panel-btn`, `.invoice-box`, `.copy-btn` with green overrides
- **`the-matrix/js/main.js`**: Import + call `initSessionPanel()` in firstInit block
## Verification
- `npm run build` in the-matrix → clean build (0 errors)
- Full testkit: 27/27 PASS (all session tests 11–16, 22 still green)
2026-03-19 03:50:34 +00:00
|
|
|
|
// ── Session-mode send override ────────────────────────────────────────────────
|
|
|
|
|
|
let _sessionSendHandler = null;
|
|
|
|
|
|
|
|
|
|
|
|
export function setSessionSendHandler(fn) {
|
|
|
|
|
|
_sessionSendHandler = fn;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function setInputBarSessionMode(active, placeholder) {
|
|
|
|
|
|
const $input = document.getElementById('visitor-input');
|
|
|
|
|
|
if (!$input) return;
|
|
|
|
|
|
if (active) {
|
|
|
|
|
|
$input.classList.add('session-active');
|
|
|
|
|
|
$input.placeholder = placeholder || 'Ask Timmy (session active)…';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
$input.classList.remove('session-active');
|
|
|
|
|
|
$input.placeholder = 'Say something to Timmy…';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-19 18:16:40 +00:00
|
|
|
|
// ── Model-ready indicator ─────────────────────────────────────────────────────
|
|
|
|
|
|
// A small badge on the input bar showing when local AI is warm and ready.
|
|
|
|
|
|
// Hidden until the first `ready` event from the edge worker.
|
|
|
|
|
|
|
|
|
|
|
|
let $readyBadge = null;
|
|
|
|
|
|
|
|
|
|
|
|
export function setEdgeWorkerReady() {
|
|
|
|
|
|
if (!$readyBadge) {
|
|
|
|
|
|
$readyBadge = document.createElement('span');
|
|
|
|
|
|
$readyBadge.id = 'edge-ready-badge';
|
|
|
|
|
|
$readyBadge.title = 'Local AI active — trivial queries answered without Lightning payment';
|
|
|
|
|
|
$readyBadge.style.cssText = [
|
|
|
|
|
|
'font-size:10px;color:#44cc88;border:1px solid #226644',
|
|
|
|
|
|
'border-radius:3px;padding:1px 5px;margin-left:6px',
|
|
|
|
|
|
'vertical-align:middle;cursor:default',
|
|
|
|
|
|
].join(';');
|
|
|
|
|
|
$readyBadge.textContent = '⚡ local AI';
|
|
|
|
|
|
const $input = document.getElementById('visitor-input');
|
|
|
|
|
|
$input?.insertAdjacentElement('afterend', $readyBadge);
|
|
|
|
|
|
// Fallback: append to send button area
|
|
|
|
|
|
if (!$readyBadge.isConnected) {
|
|
|
|
|
|
document.getElementById('send-btn')?.insertAdjacentElement('afterend', $readyBadge);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
$readyBadge.style.display = '';
|
2026-03-18 21:01:13 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-19 18:16:40 +00:00
|
|
|
|
// ── Cost preview badge ────────────────────────────────────────────────────────
|
|
|
|
|
|
// Shown beneath the input bar: "~N sats" / "FREE" / "answered locally".
|
2026-03-19 18:09:44 +00:00
|
|
|
|
// Fetched from GET /api/estimate once the user stops typing (300 ms debounce).
|
|
|
|
|
|
|
|
|
|
|
|
let _estimateTimer = null;
|
2026-03-19 18:16:40 +00:00
|
|
|
|
let $costPreview = null;
|
|
|
|
|
|
|
|
|
|
|
|
function _ensureCostPreview() {
|
|
|
|
|
|
if ($costPreview) return $costPreview;
|
|
|
|
|
|
$costPreview = document.getElementById('timmy-cost-preview');
|
|
|
|
|
|
if (!$costPreview) {
|
|
|
|
|
|
$costPreview = document.createElement('div');
|
|
|
|
|
|
$costPreview.id = 'timmy-cost-preview';
|
|
|
|
|
|
$costPreview.style.cssText = 'font-size:11px;color:#88aacc;margin-top:3px;min-height:14px;transition:opacity .3s;opacity:0;';
|
|
|
|
|
|
const $input = document.getElementById('visitor-input');
|
|
|
|
|
|
$input?.parentElement?.appendChild($costPreview);
|
|
|
|
|
|
}
|
|
|
|
|
|
return $costPreview;
|
2026-03-19 18:09:44 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function _showCostPreview(text, color = '#88aacc') {
|
2026-03-19 18:16:40 +00:00
|
|
|
|
const el = _ensureCostPreview();
|
|
|
|
|
|
el.textContent = text;
|
|
|
|
|
|
el.style.color = color;
|
|
|
|
|
|
el.style.opacity = '1';
|
2026-03-19 18:09:44 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function _hideCostPreview() {
|
2026-03-19 18:16:40 +00:00
|
|
|
|
const el = _ensureCostPreview();
|
|
|
|
|
|
el.style.opacity = '0';
|
2026-03-19 18:09:44 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function _fetchEstimate(text) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const token = await getOrRefreshToken('/api');
|
|
|
|
|
|
const params = new URLSearchParams({ request: text });
|
task-28 fix2: common Nostr key discovery, header-only token transport, explicit model caching
1. nostr-identity.js: _scanExistingNostrKeys() discovers pre-existing Nostr keys
in localStorage using common patterns: nsec1/npub1 bech32, raw hex privkey,
JSON objects with nsec/npub/privkey fields. Scans common client key names
(nostr_privkey, privkey, nsec, nostr-nsec, nostrKeys, etc.) before showing
the identity prompt. Keys discovered are re-saved in app format for next load.
2. ui.js: _fetchEstimate() now sends nostr_token as X-Nostr-Token header instead
of query param, consistent with all other authenticated API calls.
3. edge-worker.js: explicit env.useBrowserCache=true + env.allowLocalModels=false
so model weights are cached in browser Cache API after first download.
2026-03-19 18:20:13 +00:00
|
|
|
|
const fetchOpts = {};
|
|
|
|
|
|
if (token) {
|
|
|
|
|
|
fetchOpts.headers = { 'X-Nostr-Token': token };
|
|
|
|
|
|
}
|
2026-03-19 18:09:44 +00:00
|
|
|
|
|
task-28 fix2: common Nostr key discovery, header-only token transport, explicit model caching
1. nostr-identity.js: _scanExistingNostrKeys() discovers pre-existing Nostr keys
in localStorage using common patterns: nsec1/npub1 bech32, raw hex privkey,
JSON objects with nsec/npub/privkey fields. Scans common client key names
(nostr_privkey, privkey, nsec, nostr-nsec, nostrKeys, etc.) before showing
the identity prompt. Keys discovered are re-saved in app format for next load.
2. ui.js: _fetchEstimate() now sends nostr_token as X-Nostr-Token header instead
of query param, consistent with all other authenticated API calls.
3. edge-worker.js: explicit env.useBrowserCache=true + env.allowLocalModels=false
so model weights are cached in browser Cache API after first download.
2026-03-19 18:20:13 +00:00
|
|
|
|
const res = await fetch(`/api/estimate?${params}`, fetchOpts);
|
2026-03-19 18:09:44 +00:00
|
|
|
|
if (!res.ok) return;
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
|
|
|
|
|
|
const ft = data.identity?.free_tier;
|
|
|
|
|
|
if (ft?.serve === 'free') {
|
|
|
|
|
|
_showCostPreview('FREE via generosity pool', '#44dd88');
|
|
|
|
|
|
} else if (ft?.serve === 'partial') {
|
|
|
|
|
|
_showCostPreview(`~${ft.chargeSats} sats (${ft.absorbSats} absorbed)`, '#ffdd44');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const sats = data.estimatedSats ?? '?';
|
|
|
|
|
|
_showCostPreview(`~${sats} sats estimated`, '#88aacc');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
_hideCostPreview();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-19 19:06:47 +00:00
|
|
|
|
// Fast trivial heuristic — same pattern as edge-worker.js _isGreeting().
|
|
|
|
|
|
// Prevents /api/estimate network calls for greeting messages on every keypress.
|
|
|
|
|
|
const _TRIVIAL_RE = /^(hi|hey|hello|howdy|greetings|yo|sup|hiya|what'?s up)[!?.,]?\s*$/i;
|
|
|
|
|
|
|
2026-03-19 18:09:44 +00:00
|
|
|
|
function _scheduleCostPreview(text) {
|
|
|
|
|
|
clearTimeout(_estimateTimer);
|
|
|
|
|
|
if (!text || text.length < 4) { _hideCostPreview(); return; }
|
2026-03-19 19:06:47 +00:00
|
|
|
|
// Skip estimate entirely for trivially local messages — zero network calls
|
|
|
|
|
|
if (_TRIVIAL_RE.test(text.trim())) {
|
|
|
|
|
|
_showCostPreview('answered locally ⚡ 0 sats', '#44dd88');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-19 18:09:44 +00:00
|
|
|
|
_estimateTimer = setTimeout(() => _fetchEstimate(text), 300);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-23 20:01:26 +00:00
|
|
|
|
// ── Live cost ticker ──────────────────────────────────────────────────────────
|
|
|
|
|
|
// Shown in the top-right HUD during active paid interactions.
|
|
|
|
|
|
// Updated via WebSocket `cost_update` messages from the backend.
|
|
|
|
|
|
|
|
|
|
|
|
let $costTicker = null;
|
|
|
|
|
|
let _tickerHideTimer = null;
|
|
|
|
|
|
|
|
|
|
|
|
function _ensureCostTicker() {
|
|
|
|
|
|
if ($costTicker) return $costTicker;
|
|
|
|
|
|
$costTicker = document.getElementById('timmy-cost-ticker');
|
|
|
|
|
|
if (!$costTicker) {
|
|
|
|
|
|
$costTicker = document.createElement('div');
|
|
|
|
|
|
$costTicker.id = 'timmy-cost-ticker';
|
|
|
|
|
|
$costTicker.style.cssText = [
|
|
|
|
|
|
'position:fixed;top:36px;right:16px',
|
|
|
|
|
|
'font-size:11px;font-family:"Courier New",monospace',
|
|
|
|
|
|
'color:#ffcc44;text-shadow:0 0 6px #aa8822',
|
|
|
|
|
|
'letter-spacing:1px',
|
|
|
|
|
|
'pointer-events:none;z-index:10',
|
|
|
|
|
|
'transition:opacity .4s;opacity:0',
|
|
|
|
|
|
].join(';');
|
|
|
|
|
|
document.body.appendChild($costTicker);
|
|
|
|
|
|
}
|
|
|
|
|
|
return $costTicker;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function showCostTicker(sats) {
|
|
|
|
|
|
clearTimeout(_tickerHideTimer);
|
|
|
|
|
|
const el = _ensureCostTicker();
|
|
|
|
|
|
el.textContent = `⚡ ~${sats} sats`;
|
|
|
|
|
|
el.style.opacity = '1';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function updateCostTicker(sats, isFinal = false) {
|
|
|
|
|
|
clearTimeout(_tickerHideTimer);
|
|
|
|
|
|
const el = _ensureCostTicker();
|
|
|
|
|
|
el.textContent = isFinal ? `⚡ ${sats} sats charged` : `⚡ ~${sats} sats`;
|
|
|
|
|
|
el.style.opacity = '1';
|
|
|
|
|
|
if (isFinal) {
|
|
|
|
|
|
_tickerHideTimer = setTimeout(hideCostTicker, 5000);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function hideCostTicker() {
|
|
|
|
|
|
if (!$costTicker) return;
|
|
|
|
|
|
$costTicker.style.opacity = '0';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-23 22:54:07 +00:00
|
|
|
|
// ── Nostr identity UI ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
let _nostrStatusEl = null;
|
|
|
|
|
|
let _connectNostrBtn = null;
|
|
|
|
|
|
let _disconnectNostrBtn = null;
|
|
|
|
|
|
let _nostrPubkeyDisplay = null;
|
|
|
|
|
|
let _getAlbyBtn = null;
|
|
|
|
|
|
|
|
|
|
|
|
export function initNostrIdentityUI() {
|
|
|
|
|
|
_nostrStatusEl = document.getElementById('nostr-identity-status');
|
|
|
|
|
|
if (!_nostrStatusEl) return;
|
|
|
|
|
|
|
|
|
|
|
|
_nostrStatusEl.innerHTML = `
|
|
|
|
|
|
<button id="connect-nostr-btn" class="nostr-btn">⚡ Connect Nostr</button>
|
|
|
|
|
|
<span id="nostr-pubkey-display" class="nostr-pubkey"></span>
|
|
|
|
|
|
<button id="disconnect-nostr-btn" class="nostr-btn nostr-btn-sm">Disconnect</button>
|
|
|
|
|
|
<button id="get-alby-btn" class="nostr-btn nostr-btn-sm">Get Alby</button>
|
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
|
|
_connectNostrBtn = document.getElementById('connect-nostr-btn');
|
|
|
|
|
|
_disconnectNostrBtn = document.getElementById('disconnect-nostr-btn');
|
|
|
|
|
|
_nostrPubkeyDisplay = document.getElementById('nostr-pubkey-display');
|
|
|
|
|
|
_getAlbyBtn = document.getElementById('get-alby-btn');
|
|
|
|
|
|
|
|
|
|
|
|
if (_connectNostrBtn) {
|
|
|
|
|
|
_connectNostrBtn.addEventListener('click', () => {
|
|
|
|
|
|
showIdentityPrompt('/api');
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (_disconnectNostrBtn) {
|
|
|
|
|
|
_disconnectNostrBtn.addEventListener('click', () => {
|
|
|
|
|
|
disconnectNostrIdentity();
|
|
|
|
|
|
_updateNostrIdentityUI(null);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
window.addEventListener('nostr:identity-ready', e => {
|
|
|
|
|
|
_updateNostrIdentityUI(e.detail.pubkey);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
window.addEventListener('nostr:identity-disconnected', () => {
|
|
|
|
|
|
_updateNostrIdentityUI(null);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
_updateNostrIdentityUI(getPubkey());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function _updateNostrIdentityUI(pubkey) {
|
|
|
|
|
|
const hasNip07 = typeof window !== 'undefined' && !!window.nostr;
|
|
|
|
|
|
|
|
|
|
|
|
if (pubkey) {
|
|
|
|
|
|
const formattedPubkey = pubkey.slice(0, 8) + '…' + pubkey.slice(-4);
|
|
|
|
|
|
if (_nostrPubkeyDisplay) {
|
|
|
|
|
|
_nostrPubkeyDisplay.textContent = `⚡ ${formattedPubkey}`;
|
|
|
|
|
|
_nostrPubkeyDisplay.style.display = 'inline-block';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (_connectNostrBtn) _connectNostrBtn.style.display = 'none';
|
|
|
|
|
|
if (_disconnectNostrBtn) _disconnectNostrBtn.style.display = 'inline-block';
|
|
|
|
|
|
if (_getAlbyBtn) _getAlbyBtn.style.display = 'none';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (_nostrPubkeyDisplay) _nostrPubkeyDisplay.style.display = 'none';
|
|
|
|
|
|
if (_disconnectNostrBtn) _disconnectNostrBtn.style.display = 'none';
|
|
|
|
|
|
|
|
|
|
|
|
if (hasNip07) {
|
|
|
|
|
|
if (_connectNostrBtn) {
|
|
|
|
|
|
_connectNostrBtn.textContent = '⚡ Connect Nostr';
|
|
|
|
|
|
_connectNostrBtn.style.display = 'inline-block';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (_getAlbyBtn) _getAlbyBtn.style.display = 'none';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (_connectNostrBtn) _connectNostrBtn.style.display = 'none';
|
|
|
|
|
|
if (_getAlbyBtn) {
|
|
|
|
|
|
_getAlbyBtn.textContent = 'Get Alby';
|
|
|
|
|
|
_getAlbyBtn.style.display = 'inline-block';
|
|
|
|
|
|
_getAlbyBtn.title = 'Install Alby or another NIP-07 extension to connect your Nostr identity';
|
|
|
|
|
|
_getAlbyBtn.onclick = () => window.open('https://getalby.com/', '_blank');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-19 18:09:44 +00:00
|
|
|
|
// ── Input bar ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-03-19 18:16:40 +00:00
|
|
|
|
export function initUI() {
|
|
|
|
|
|
if (uiInitialized) return;
|
|
|
|
|
|
uiInitialized = true;
|
|
|
|
|
|
initInputBar();
|
2026-03-23 20:35:47 +00:00
|
|
|
|
initHeatmap();
|
2026-03-23 22:54:07 +00:00
|
|
|
|
initNostrIdentityUI();
|
2026-03-19 18:16:40 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 22:15:46 -04:00
|
|
|
|
function initInputBar() {
|
2026-03-19 18:09:44 +00:00
|
|
|
|
const $input = document.getElementById('visitor-input');
|
2026-03-18 22:15:46 -04:00
|
|
|
|
const $sendBtn = document.getElementById('send-btn');
|
|
|
|
|
|
if (!$input || !$sendBtn) return;
|
|
|
|
|
|
|
2026-03-19 18:09:44 +00:00
|
|
|
|
$input.addEventListener('input', () => _scheduleCostPreview($input.value.trim()));
|
|
|
|
|
|
|
|
|
|
|
|
async function send() {
|
2026-03-18 22:15:46 -04:00
|
|
|
|
const text = $input.value.trim();
|
|
|
|
|
|
if (!text) return;
|
|
|
|
|
|
$input.value = '';
|
2026-03-19 18:09:44 +00:00
|
|
|
|
_hideCostPreview();
|
|
|
|
|
|
|
2026-03-19 19:10:46 +00:00
|
|
|
|
// ── Edge triage — runs in BOTH session mode and WebSocket mode ─────────────
|
|
|
|
|
|
// Worker returns { complexity:'trivial'|'moderate'|'complex', score, reason, localReply? }
|
2026-03-19 18:16:40 +00:00
|
|
|
|
const cls = await classify(text);
|
2026-03-19 18:09:44 +00:00
|
|
|
|
|
task-28 fix3: complexity contract, consistent token headers, npub-only prompt
1. edge-worker.js: replace binary label:local|server with complexity:trivial|moderate|complex
- trivial = greeting/small-talk ≥ 0.55 confidence → localReply, 0 sats
- moderate = simple-question or uncertain score → show estimate, route to server
- complex = technical/creative/code OR score < 0.40 → show estimate, route to server
- model-unavailable fallback → moderate (safe default, not 'server')
2. edge-worker-client.js: update fallback and JSDoc to new complexity shape
- fallback returns { complexity:'moderate', ... } instead of { label:'server', ... }
3. ui.js: triage driven by cls.complexity, not cls.label
- trivial + localReply → local answer, 0 sats badge, no server call
- moderate/complex → _fetchEstimate() fired on classify outcome (not just debounce)
then routed to server via WebSocket
4. session.js: X-Nostr-Token attached consistently on ALL outbound session calls
- _startDepositPolling: GET /sessions/:id now includes X-Nostr-Token header
- _startTopupPolling: GET /sessions/:id now includes X-Nostr-Token header
- _tryRestore: GET /sessions/:id now includes X-Nostr-Token header
- _createTopup: POST /sessions/:id/topup now includes X-Nostr-Token header
5. nostr-identity.js: _canSign flag tracks signing capability separately from pubkey
- initNostrIdentity sets _canSign=true only when NIP-07 or privkey is available
- npub-only discovery sets _pubkey but _canSign=false → prompt IS scheduled
- Prompt shown when !_pubkey || !_canSign (not just !_pubkey)
- Prompt click handlers set _canSign=true after connecting NIP-07 or generating key
- refreshToken only called when _pubkey && _canSign (avoids silent failures)
2026-03-19 19:02:45 +00:00
|
|
|
|
if (cls.complexity === 'trivial' && cls.localReply) {
|
2026-03-19 19:10:46 +00:00
|
|
|
|
// Greeting / small-talk → answer locally, 0 sats, no network call in any mode
|
Task #23: Workshop session mode UI — fund once, ask many
## What was done
- **`the-matrix/js/session.js`** (new module): Full session mode UI lifecycle:
- Create session flow: amount presets → POST /api/sessions → deposit invoice step
- Deposit payment: stub simulate → 2s polling until state=active
- macaroon + sessionId stored in localStorage (`timmy_session_v1`)
- Request submission: intercepts input bar when session active → POST /api/sessions/:id/request
→ Timmy speech bubble shows result, balance updates in HUD
- Low-balance (< 50 sats): paused state, low-balance notice shown, topup quick-button
- Topup flow: preset amount → POST /api/sessions/:id/topup → topup invoice → stub pay → poll
- Restore from localStorage on page reload: validates session via GET, restores full UI state
- Session expiry / 401 macaroon rejection: clears storage, resets to unfunded state
- **`the-matrix/js/ui.js`**: Added `setSessionSendHandler(fn)` + `setInputBarSessionMode(active, placeholder)` exports; send() routes to session handler when active, falls back to WS visitor_message
- **`the-matrix/index.html`**:
- `#top-buttons` flex container: "⚡ SUBMIT JOB" (blue) + "⚡ FUND SESSION" (teal) side-by-side
- `#session-hud` balance line in HUD (green, hidden until session active)
- `#session-panel` left-side slide-in panel: fund / invoice / active / topup steps
- `.session-amount-btn` presets (200, 500, 1000, 2000, 5000 sats) with active state
- `#visitor-input.session-active` CSS: green border + 3s pulse keyframe animation
- `#low-balance-notice` strip above input bar with Top Up quick-button
- `.primary-green` / `.muted` panel button variants for session panel theme
- `#session-panel` inherits shared `.panel-btn`, `.invoice-box`, `.copy-btn` with green overrides
- **`the-matrix/js/main.js`**: Import + call `initSessionPanel()` in firstInit block
## Verification
- `npm run build` in the-matrix → clean build (0 errors)
- Full testkit: 27/27 PASS (all session tests 11–16, 22 still green)
2026-03-19 03:50:34 +00:00
|
|
|
|
appendSystemMessage(`you: ${text}`);
|
2026-03-19 19:10:46 +00:00
|
|
|
|
setSpeechBubble(`${cls.localReply} ⚡ local`);
|
2026-03-19 18:09:44 +00:00
|
|
|
|
_showCostPreview('answered locally ⚡ 0 sats', '#44dd88');
|
|
|
|
|
|
setTimeout(_hideCostPreview, 3000);
|
|
|
|
|
|
return;
|
Task #23: Workshop session mode UI — fund once, ask many
## What was done
- **`the-matrix/js/session.js`** (new module): Full session mode UI lifecycle:
- Create session flow: amount presets → POST /api/sessions → deposit invoice step
- Deposit payment: stub simulate → 2s polling until state=active
- macaroon + sessionId stored in localStorage (`timmy_session_v1`)
- Request submission: intercepts input bar when session active → POST /api/sessions/:id/request
→ Timmy speech bubble shows result, balance updates in HUD
- Low-balance (< 50 sats): paused state, low-balance notice shown, topup quick-button
- Topup flow: preset amount → POST /api/sessions/:id/topup → topup invoice → stub pay → poll
- Restore from localStorage on page reload: validates session via GET, restores full UI state
- Session expiry / 401 macaroon rejection: clears storage, resets to unfunded state
- **`the-matrix/js/ui.js`**: Added `setSessionSendHandler(fn)` + `setInputBarSessionMode(active, placeholder)` exports; send() routes to session handler when active, falls back to WS visitor_message
- **`the-matrix/index.html`**:
- `#top-buttons` flex container: "⚡ SUBMIT JOB" (blue) + "⚡ FUND SESSION" (teal) side-by-side
- `#session-hud` balance line in HUD (green, hidden until session active)
- `#session-panel` left-side slide-in panel: fund / invoice / active / topup steps
- `.session-amount-btn` presets (200, 500, 1000, 2000, 5000 sats) with active state
- `#visitor-input.session-active` CSS: green border + 3s pulse keyframe animation
- `#low-balance-notice` strip above input bar with Top Up quick-button
- `.primary-green` / `.muted` panel button variants for session panel theme
- `#session-panel` inherits shared `.panel-btn`, `.invoice-box`, `.copy-btn` with green overrides
- **`the-matrix/js/main.js`**: Import + call `initSessionPanel()` in firstInit block
## Verification
- `npm run build` in the-matrix → clean build (0 errors)
- Full testkit: 27/27 PASS (all session tests 11–16, 22 still green)
2026-03-19 03:50:34 +00:00
|
|
|
|
}
|
2026-03-19 18:09:44 +00:00
|
|
|
|
|
2026-03-19 19:10:46 +00:00
|
|
|
|
// Non-trivial: delegate to session handler (if active) or WebSocket
|
|
|
|
|
|
if (_sessionSendHandler) {
|
|
|
|
|
|
// moderate/complex — fire estimate async for cost preview, then hand off
|
|
|
|
|
|
if (cls.complexity === 'moderate' || cls.complexity === 'complex') {
|
|
|
|
|
|
_fetchEstimate(text);
|
|
|
|
|
|
}
|
|
|
|
|
|
_sessionSendHandler(text);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
task-28 fix3: complexity contract, consistent token headers, npub-only prompt
1. edge-worker.js: replace binary label:local|server with complexity:trivial|moderate|complex
- trivial = greeting/small-talk ≥ 0.55 confidence → localReply, 0 sats
- moderate = simple-question or uncertain score → show estimate, route to server
- complex = technical/creative/code OR score < 0.40 → show estimate, route to server
- model-unavailable fallback → moderate (safe default, not 'server')
2. edge-worker-client.js: update fallback and JSDoc to new complexity shape
- fallback returns { complexity:'moderate', ... } instead of { label:'server', ... }
3. ui.js: triage driven by cls.complexity, not cls.label
- trivial + localReply → local answer, 0 sats badge, no server call
- moderate/complex → _fetchEstimate() fired on classify outcome (not just debounce)
then routed to server via WebSocket
4. session.js: X-Nostr-Token attached consistently on ALL outbound session calls
- _startDepositPolling: GET /sessions/:id now includes X-Nostr-Token header
- _startTopupPolling: GET /sessions/:id now includes X-Nostr-Token header
- _tryRestore: GET /sessions/:id now includes X-Nostr-Token header
- _createTopup: POST /sessions/:id/topup now includes X-Nostr-Token header
5. nostr-identity.js: _canSign flag tracks signing capability separately from pubkey
- initNostrIdentity sets _canSign=true only when NIP-07 or privkey is available
- npub-only discovery sets _pubkey but _canSign=false → prompt IS scheduled
- Prompt shown when !_pubkey || !_canSign (not just !_pubkey)
- Prompt click handlers set _canSign=true after connecting NIP-07 or generating key
- refreshToken only called when _pubkey && _canSign (avoids silent failures)
2026-03-19 19:02:45 +00:00
|
|
|
|
// moderate or complex — fetch cost estimate (driven by complexity outcome),
|
2026-03-19 19:10:46 +00:00
|
|
|
|
// then route to server via WebSocket.
|
task-28 fix3: complexity contract, consistent token headers, npub-only prompt
1. edge-worker.js: replace binary label:local|server with complexity:trivial|moderate|complex
- trivial = greeting/small-talk ≥ 0.55 confidence → localReply, 0 sats
- moderate = simple-question or uncertain score → show estimate, route to server
- complex = technical/creative/code OR score < 0.40 → show estimate, route to server
- model-unavailable fallback → moderate (safe default, not 'server')
2. edge-worker-client.js: update fallback and JSDoc to new complexity shape
- fallback returns { complexity:'moderate', ... } instead of { label:'server', ... }
3. ui.js: triage driven by cls.complexity, not cls.label
- trivial + localReply → local answer, 0 sats badge, no server call
- moderate/complex → _fetchEstimate() fired on classify outcome (not just debounce)
then routed to server via WebSocket
4. session.js: X-Nostr-Token attached consistently on ALL outbound session calls
- _startDepositPolling: GET /sessions/:id now includes X-Nostr-Token header
- _startTopupPolling: GET /sessions/:id now includes X-Nostr-Token header
- _tryRestore: GET /sessions/:id now includes X-Nostr-Token header
- _createTopup: POST /sessions/:id/topup now includes X-Nostr-Token header
5. nostr-identity.js: _canSign flag tracks signing capability separately from pubkey
- initNostrIdentity sets _canSign=true only when NIP-07 or privkey is available
- npub-only discovery sets _pubkey but _canSign=false → prompt IS scheduled
- Prompt shown when !_pubkey || !_canSign (not just !_pubkey)
- Prompt click handlers set _canSign=true after connecting NIP-07 or generating key
- refreshToken only called when _pubkey && _canSign (avoids silent failures)
2026-03-19 19:02:45 +00:00
|
|
|
|
if (cls.complexity === 'moderate' || cls.complexity === 'complex') {
|
|
|
|
|
|
_fetchEstimate(text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Route to server via WebSocket
|
2026-03-19 18:09:44 +00:00
|
|
|
|
sendVisitorMessage(text);
|
|
|
|
|
|
appendSystemMessage(`you: ${text}`);
|
2026-03-18 21:01:13 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 22:15:46 -04:00
|
|
|
|
$sendBtn.addEventListener('click', send);
|
|
|
|
|
|
$input.addEventListener('keydown', e => {
|
|
|
|
|
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
|
2026-03-18 21:01:13 -04:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 22:15:46 -04:00
|
|
|
|
export function updateUI({ fps, jobCount, connectionState }) {
|
|
|
|
|
|
if ($fps) $fps.textContent = `FPS: ${fps}`;
|
|
|
|
|
|
if ($activeJobs) $activeJobs.textContent = `JOBS: ${jobCount}`;
|
|
|
|
|
|
|
|
|
|
|
|
if ($connStatus) {
|
|
|
|
|
|
if (connectionState === 'connected') {
|
|
|
|
|
|
$connStatus.textContent = '● CONNECTED';
|
|
|
|
|
|
$connStatus.className = 'connected';
|
|
|
|
|
|
} else if (connectionState === 'connecting') {
|
|
|
|
|
|
$connStatus.textContent = '◌ CONNECTING...';
|
|
|
|
|
|
$connStatus.className = '';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
$connStatus.textContent = '○ OFFLINE';
|
|
|
|
|
|
$connStatus.className = '';
|
|
|
|
|
|
}
|
2026-03-18 21:01:13 -04:00
|
|
|
|
}
|
2026-03-18 22:15:46 -04:00
|
|
|
|
}
|
2026-03-18 21:01:13 -04:00
|
|
|
|
|
2026-03-23 19:39:47 -04:00
|
|
|
|
export function updateVisitorCount(count) {
|
|
|
|
|
|
const $visitorCountDisplay = document.querySelector('#visitor-count-display .count-number');
|
|
|
|
|
|
if ($visitorCountDisplay) {
|
|
|
|
|
|
$visitorCountDisplay.textContent = count;
|
|
|
|
|
|
const $desktopOnly = document.querySelector('#visitor-count-display .desktop-only');
|
|
|
|
|
|
if (window.innerWidth > 600) {
|
|
|
|
|
|
if ($desktopOnly) $desktopOnly.textContent = `VISITORS:`;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if ($desktopOnly) $desktopOnly.textContent = ``; // Hide 'VISITORS:' text on mobile
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 22:15:46 -04:00
|
|
|
|
export function appendSystemMessage(text) {
|
|
|
|
|
|
if (!$log) return;
|
|
|
|
|
|
const el = document.createElement('div');
|
|
|
|
|
|
el.className = 'log-entry';
|
|
|
|
|
|
el.textContent = text;
|
|
|
|
|
|
logEntries.push(el);
|
|
|
|
|
|
if (logEntries.length > MAX_LOG) {
|
|
|
|
|
|
const removed = logEntries.shift();
|
|
|
|
|
|
$log.removeChild(removed);
|
2026-03-18 21:01:13 -04:00
|
|
|
|
}
|
2026-03-18 22:15:46 -04:00
|
|
|
|
$log.appendChild(el);
|
|
|
|
|
|
$log.scrollTop = $log.scrollHeight;
|
2026-03-18 21:01:13 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 22:15:46 -04:00
|
|
|
|
export function appendChatMessage(agentLabel, message, cssColor, agentId) {
|
|
|
|
|
|
void agentLabel; void cssColor; void agentId;
|
|
|
|
|
|
appendSystemMessage(message);
|
2026-03-18 21:01:13 -04:00
|
|
|
|
}
|
2026-03-18 22:15:46 -04:00
|
|
|
|
|
2026-03-23 01:07:52 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* Render a debate argument or verdict in the event log (#21).
|
|
|
|
|
|
* Visually distinct from regular chat: colored by agent with a debate prefix.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function appendDebateMessage(agent, argument, isVerdict, accepted) {
|
|
|
|
|
|
if (!$log) return;
|
|
|
|
|
|
const el = document.createElement('div');
|
|
|
|
|
|
el.className = 'log-entry debate-entry';
|
|
|
|
|
|
if (isVerdict) {
|
|
|
|
|
|
el.classList.add('debate-verdict');
|
|
|
|
|
|
el.classList.add(accepted ? 'debate-accepted' : 'debate-rejected');
|
|
|
|
|
|
el.textContent = `⚖ ${agent}: ${argument}`;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
el.classList.add(agent === 'Beta-A' ? 'debate-a' : 'debate-b');
|
|
|
|
|
|
el.textContent = `⚖ ${agent}: ${(argument || '').slice(0, 120)}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
logEntries.push(el);
|
|
|
|
|
|
if (logEntries.length > MAX_LOG) {
|
|
|
|
|
|
const removed = logEntries.shift();
|
|
|
|
|
|
$log.removeChild(removed);
|
|
|
|
|
|
}
|
|
|
|
|
|
$log.appendChild(el);
|
|
|
|
|
|
$log.scrollTop = $log.scrollHeight;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 22:15:46 -04:00
|
|
|
|
export function loadChatHistory() { return []; }
|
|
|
|
|
|
export function saveChatHistory() {}
|
2026-03-23 20:35:47 +00:00
|
|
|
|
|
|
|
|
|
|
// ── Activity heatmap (#9) ─────────────────────────────────────────────────────
|
|
|
|
|
|
// Fetches /api/stats/activity and renders a 24-segment heatmap.
|
|
|
|
|
|
// Auto-refreshes every 5 minutes. On mobile, collapses to an icon that opens
|
|
|
|
|
|
// a full-screen overlay.
|
|
|
|
|
|
|
|
|
|
|
|
const HEATMAP_REFRESH_MS = 5 * 60 * 1000; // 5 minutes
|
|
|
|
|
|
let _heatmapTimer = null;
|
|
|
|
|
|
let _lastHours = null; // number[24] cached for overlay re-render
|
|
|
|
|
|
|
|
|
|
|
|
/** Convert an hour index (0 = oldest, 23 = current) to a UTC hour label like "3pm" or "midnight". */
|
|
|
|
|
|
function _hourLabel(hourIndex) {
|
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
const currentHour = now.getUTCHours();
|
|
|
|
|
|
// slot 23 = current UTC hour, slot 0 = 23 hours ago
|
|
|
|
|
|
const h = ((currentHour - (23 - hourIndex)) % 24 + 24) % 24;
|
|
|
|
|
|
if (h === 0) return 'midnight';
|
|
|
|
|
|
if (h === 12) return 'noon';
|
|
|
|
|
|
return h < 12 ? `${h}am` : `${h - 12}pm`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Interpolate from dim blue (#111133) to bright blue-white (#88ccff) based on 0–1 intensity. */
|
|
|
|
|
|
function _segmentColor(intensity) {
|
|
|
|
|
|
// dim: [17, 17, 51] bright: [136, 204, 255]
|
|
|
|
|
|
const r = Math.round(17 + (136 - 17) * intensity);
|
|
|
|
|
|
const g = Math.round(17 + (204 - 17) * intensity);
|
|
|
|
|
|
const b = Math.round(51 + (255 - 51) * intensity);
|
|
|
|
|
|
return `rgb(${r},${g},${b})`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function _renderSegments(hours, container, isMobile) {
|
|
|
|
|
|
container.innerHTML = '';
|
|
|
|
|
|
const max = Math.max(...hours, 1); // avoid div-by-zero
|
|
|
|
|
|
const currentSlot = 23;
|
|
|
|
|
|
|
|
|
|
|
|
hours.forEach((count, i) => {
|
|
|
|
|
|
const seg = document.createElement('div');
|
|
|
|
|
|
seg.className = 'hm-seg' + (i === currentSlot ? ' hm-seg-current' : '');
|
|
|
|
|
|
const intensity = count / max;
|
|
|
|
|
|
const color = _segmentColor(intensity);
|
|
|
|
|
|
seg.style.background = color;
|
|
|
|
|
|
if (i === currentSlot) seg.style.color = color; // used by pulse animation
|
|
|
|
|
|
seg.dataset.index = String(i);
|
|
|
|
|
|
seg.dataset.count = String(count);
|
|
|
|
|
|
if (isMobile) {
|
|
|
|
|
|
seg.style.width = '14px';
|
|
|
|
|
|
seg.style.height = '28px';
|
|
|
|
|
|
}
|
|
|
|
|
|
container.appendChild(seg);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function _initHeatmapTooltip(barEl) {
|
|
|
|
|
|
const $tip = document.getElementById('heatmap-tooltip');
|
|
|
|
|
|
if (!$tip) return;
|
|
|
|
|
|
|
|
|
|
|
|
barEl.addEventListener('mousemove', e => {
|
|
|
|
|
|
const seg = e.target.closest('.hm-seg');
|
|
|
|
|
|
if (!seg) { $tip.style.display = 'none'; return; }
|
|
|
|
|
|
const i = Number(seg.dataset.index);
|
|
|
|
|
|
const count = Number(seg.dataset.count);
|
|
|
|
|
|
const label = _hourLabel(i);
|
|
|
|
|
|
$tip.textContent = `${label}: ${count} job${count !== 1 ? 's' : ''} submitted`;
|
|
|
|
|
|
$tip.style.display = 'block';
|
|
|
|
|
|
$tip.style.left = `${e.clientX + 10}px`;
|
|
|
|
|
|
$tip.style.top = `${e.clientY - 24}px`;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
barEl.addEventListener('mouseleave', () => { $tip.style.display = 'none'; });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function _fetchAndRenderHeatmap() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch('/api/stats/activity');
|
|
|
|
|
|
if (!res.ok) return;
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
const hours = Array.isArray(data.hours) ? data.hours : [];
|
|
|
|
|
|
if (hours.length !== 24) return;
|
|
|
|
|
|
_lastHours = hours;
|
|
|
|
|
|
|
|
|
|
|
|
const $bar = document.getElementById('heatmap-bar');
|
|
|
|
|
|
if ($bar) _renderSegments(hours, $bar, false);
|
|
|
|
|
|
|
|
|
|
|
|
const $overlayBar = document.getElementById('heatmap-overlay-bar');
|
|
|
|
|
|
if ($overlayBar) _renderSegments(hours, $overlayBar, true);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// silently ignore fetch errors
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function initHeatmap() {
|
|
|
|
|
|
const $bar = document.getElementById('heatmap-bar');
|
|
|
|
|
|
const $iconBtn = document.getElementById('heatmap-icon-btn');
|
|
|
|
|
|
const $overlay = document.getElementById('heatmap-overlay');
|
|
|
|
|
|
const $closeBtn = document.getElementById('heatmap-overlay-close');
|
|
|
|
|
|
|
|
|
|
|
|
if ($bar) _initHeatmapTooltip($bar);
|
|
|
|
|
|
|
|
|
|
|
|
if ($iconBtn && $overlay) {
|
|
|
|
|
|
$iconBtn.addEventListener('click', () => {
|
|
|
|
|
|
$overlay.classList.add('open');
|
|
|
|
|
|
if (_lastHours) {
|
|
|
|
|
|
const $overlayBar = document.getElementById('heatmap-overlay-bar');
|
|
|
|
|
|
if ($overlayBar) _renderSegments(_lastHours, $overlayBar, true);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if ($closeBtn && $overlay) {
|
|
|
|
|
|
$closeBtn.addEventListener('click', () => $overlay.classList.remove('open'));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Initial fetch then schedule refresh
|
|
|
|
|
|
void _fetchAndRenderHeatmap();
|
|
|
|
|
|
_heatmapTimer = setInterval(_fetchAndRenderHeatmap, HEATMAP_REFRESH_MS);
|
|
|
|
|
|
}
|