diff --git a/the-matrix/js/edge-worker-client.js b/the-matrix/js/edge-worker-client.js new file mode 100644 index 0000000..5338229 --- /dev/null +++ b/the-matrix/js/edge-worker-client.js @@ -0,0 +1,100 @@ +/** + * edge-worker-client.js — Main-thread proxy for the edge-worker Web Worker. + * + * Spawns js/edge-worker.js as a module Worker and exposes: + * classify(text) → Promise<{ label:'local'|'server', score, reason, localReply? }> + * sentiment(text) → Promise<{ label:'POSITIVE'|'NEGATIVE'|'NEUTRAL', score }> + * onReady(fn) → register a callback fired when models finish loading + * isReady() → boolean — true once both models are warm + * + * If Web Workers are unavailable (SSR / old browser), all calls fall back to + * the naive "route to server" path so the app remains functional. + */ + +let _worker = null; +let _ready = false; +let _readyCb = null; +const _pending = new Map(); // id → { resolve, reject } +let _nextId = 1; + +function _init() { + if (_worker) return; + + try { + // Use import.meta.url so Vite can resolve the worker URL correctly. + // type:'module' is required for ESM imports inside the worker. + _worker = new Worker(new URL('./edge-worker.js', import.meta.url), { type: 'module' }); + + _worker.addEventListener('message', ({ data }) => { + // Lifecycle events have no id + if (data?.type === 'ready') { + _ready = true; + if (_readyCb) { _readyCb(); _readyCb = null; } + return; + } + if (data?.type === 'error') { + console.warn('[edge-worker] worker boot error:', data.message); + // Resolve all pending with fallback values + for (const [, { resolve }] of _pending) resolve(_fallback(null)); + _pending.clear(); + return; + } + + // Regular response: { id, result } + const { id, result } = data ?? {}; + const entry = _pending.get(id); + if (entry) { + _pending.delete(id); + entry.resolve(result); + } + }); + + _worker.addEventListener('error', (err) => { + console.warn('[edge-worker] worker error:', err.message); + }); + + } catch (err) { + console.warn('[edge-worker] Web Workers unavailable — using fallback routing:', err.message); + _worker = null; + } +} + +function _fallback(type) { + if (type === 'sentiment') return { label: 'NEUTRAL', score: 0.5 }; + return { label: 'server', score: 0, reason: 'worker-unavailable' }; +} + +function _send(type, text) { + if (!_worker) return Promise.resolve(_fallback(type)); + + const id = _nextId++; + return new Promise((resolve) => { + _pending.set(id, { resolve, reject: resolve }); + _worker.postMessage({ id, type, text }); + }); +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +export function classify(text) { + _init(); + return _send('classify', text); +} + +export function sentiment(text) { + _init(); + return _send('sentiment', text); +} + +export function onReady(fn) { + if (_ready) { fn(); return; } + _readyCb = fn; +} + +export function isReady() { return _ready; } + +/** + * warmup() — start the worker (and model loading) early so classify/sentiment + * calls on first user interaction don't stall waiting for models. + */ +export function warmup() { _init(); } diff --git a/the-matrix/js/edge-worker.js b/the-matrix/js/edge-worker.js index e9bc91a..d65e221 100644 --- a/the-matrix/js/edge-worker.js +++ b/the-matrix/js/edge-worker.js @@ -1,39 +1,25 @@ /** - * edge-worker.js — Browser-side lightweight AI triage using Transformers.js. + * edge-worker.js — Web Worker entry point for browser-side AI triage. * - * Provides two functions: - * classify(text) → { label: 'local'|'server', score, reason } - * sentiment(text) → { label: 'POSITIVE'|'NEGATIVE'|'NEUTRAL', score } + * Runs in a dedicated Web Worker thread via new Worker(url, {type:'module'}). + * Receives messages: { id, type: 'classify'|'sentiment', text } + * Replies with: { id, result } * - * Models load lazily on first call so the Three.js world starts instantly. - * Everything runs in the main thread (no SharedArrayBuffer worker needed for - * these small quantised models). + * Models: Xenova/mobilebert-uncased-mnli (zero-shot classification) + * Xenova/distilbert-base-uncased-finetuned-sst-2-english (sentiment) * - * "local" = trivial/conversational → answer client-side with a stock reply. - * "server" = substantive → route to the Timmy API. - * - * Classification heuristic: - * We use zero-shot-classification with candidate labels. - * If the top label is 'greeting|small-talk|simple-question' AND score > 0.55 - * → local. Otherwise → server. + * Lifecycle events (no id): + * { type: 'ready' } — both models loaded and warm + * { type: 'error', message } — fatal model-load failure */ import { pipeline } from '@xenova/transformers'; -// ── Model singletons — loaded once, reused thereafter ───────────────────────── - -let _classifier = null; -let _sentimentPipe = null; -let _classifierLoading = false; -let _sentimentLoading = false; - const LOCAL_LABELS = ['greeting', 'small-talk', 'simple-question']; const SERVER_LABELS = ['technical-task', 'creative-work', 'complex-question', 'code-request']; const ALL_LABELS = [...LOCAL_LABELS, ...SERVER_LABELS]; +const LOCAL_THRESHOLD = 0.55; -const LOCAL_THRESHOLD = 0.55; // top-label score needed to confidently serve locally - -// Simple stock answers for locally-handled messages const LOCAL_REPLIES = [ "Greetings, traveller! Ask me something arcane and I shall conjure wisdom from the ether.", "Ah, a visitor! I sense curious energies about you. What wisdom do you seek?", @@ -46,78 +32,40 @@ function _randomReply() { return LOCAL_REPLIES[Math.floor(Math.random() * LOCAL_REPLIES.length)]; } -// ── Lazy-load helpers ───────────────────────────────────────────────────────── +let _classifier = null; +let _sentimentPipe = null; -async function _getClassifier() { - if (_classifier) return _classifier; - if (_classifierLoading) { - // Wait for the in-progress load - while (_classifierLoading) await new Promise(r => setTimeout(r, 80)); - return _classifier; - } - _classifierLoading = true; - try { - _classifier = await pipeline( - 'zero-shot-classification', - 'Xenova/mobilebert-uncased-mnli', - { quantized: true }, - ); - } catch (err) { - console.warn('[edge-worker] classifier load failed, falling back to server routing', err); - _classifier = null; - } finally { - _classifierLoading = false; - } - return _classifier; +// ── Fast greeting heuristic ─────────────────────────────────────────────────── +function _isGreeting(text) { + return /^(hi|hey|hello|howdy|greetings|yo|sup|hiya|what'?s up)[!?.,]?\s*$/i.test(text.trim()); } -async function _getSentimentPipe() { - if (_sentimentPipe) return _sentimentPipe; - if (_sentimentLoading) { - while (_sentimentLoading) await new Promise(r => setTimeout(r, 80)); - return _sentimentPipe; - } - _sentimentLoading = true; - try { - _sentimentPipe = await pipeline( - 'sentiment-analysis', - 'Xenova/distilbert-base-uncased-finetuned-sst-2-english', - { quantized: true }, - ); - } catch (err) { - console.warn('[edge-worker] sentiment pipe load failed', err); - _sentimentPipe = null; - } finally { - _sentimentLoading = false; - } - return _sentimentPipe; +// ── Model loading ───────────────────────────────────────────────────────────── +async function _loadModels() { + [_classifier, _sentimentPipe] = await Promise.all([ + pipeline('zero-shot-classification', 'Xenova/mobilebert-uncased-mnli', { quantized: true }), + pipeline('sentiment-analysis', 'Xenova/distilbert-base-uncased-finetuned-sst-2-english', { quantized: true }), + ]); } -// ── Public API ──────────────────────────────────────────────────────────────── +// ── Handlers ────────────────────────────────────────────────────────────────── -/** - * classify(text) → { label: 'local'|'server', score, reason, localReply? } - * - * Falls back to 'server' if the model is unavailable or takes too long. - */ -export async function classify(text) { +async function _classify(text) { const trimmed = text.trim(); - // Fast heuristic for very short greetings (avoid model startup delay) - if (/^(hi|hey|hello|howdy|greetings|yo|sup|hiya|what'?s up)[!?.,]?\s*$/i.test(trimmed)) { + if (_isGreeting(trimmed)) { return { label: 'local', score: 0.99, reason: 'greeting-heuristic', localReply: _randomReply() }; } + if (!_classifier) { + return { label: 'server', score: 0, reason: 'model-unavailable' }; + } + try { - const clf = await _getClassifier(); - if (!clf) return { label: 'server', score: 0, reason: 'model-unavailable' }; - - const result = await clf(trimmed, ALL_LABELS, { multi_label: false }); - + const result = await _classifier(trimmed, ALL_LABELS, { multi_label: false }); const topLabel = result.labels[0]; const topScore = result.scores[0]; - - const isLocal = LOCAL_LABELS.includes(topLabel) && topScore >= LOCAL_THRESHOLD; + const isLocal = LOCAL_LABELS.includes(topLabel) && topScore >= LOCAL_THRESHOLD; return { label: isLocal ? 'local' : 'server', @@ -126,43 +74,38 @@ export async function classify(text) { ...(isLocal ? { localReply: _randomReply() } : {}), }; } catch (err) { - console.warn('[edge-worker] classify error', err); - return { label: 'server', score: 0, reason: 'classify-error' }; + return { label: 'server', score: 0, reason: 'classify-error', error: String(err) }; } } -/** - * sentiment(text) → { label: 'POSITIVE'|'NEGATIVE'|'NEUTRAL', score } - * - * Maps SST-2 (POSITIVE/NEGATIVE) to a three-way label by using score midpoint. - * Falls back to NEUTRAL if the model is unavailable. - */ -export async function sentiment(text) { +async function _sentiment(text) { + if (!_sentimentPipe) return { label: 'NEUTRAL', score: 0.5 }; + try { - const pipe = await _getSentimentPipe(); - if (!pipe) return { label: 'NEUTRAL', score: 0.5 }; - - const [result] = await pipe(text.trim()); + const [result] = await _sentimentPipe(text.trim()); const { label, score } = result; - - // Treat scores close to 0.5 as NEUTRAL (±0.15 band) - if (Math.abs(score - 0.5) < 0.15) { - return { label: 'NEUTRAL', score }; - } - + if (Math.abs(score - 0.5) < 0.15) return { label: 'NEUTRAL', score }; return { label: label.toUpperCase(), score }; - } catch (err) { - console.warn('[edge-worker] sentiment error', err); + } catch { return { label: 'NEUTRAL', score: 0.5 }; } } -/** - * warmup() — optionally pre-load both models in the background after the - * page has loaded (so first user interaction doesn't stall on model download). - */ -export function warmup() { - // Fire and forget — failures are already handled inside the loaders - void _getClassifier(); - void _getSentimentPipe(); -} +// ── Message dispatch ────────────────────────────────────────────────────────── + +self.addEventListener('message', async ({ data }) => { + const { id, type, text } = data ?? {}; + + if (type === 'classify') { + const result = await _classify(text ?? ''); + self.postMessage({ id, result }); + } else if (type === 'sentiment') { + const result = await _sentiment(text ?? ''); + self.postMessage({ id, result }); + } +}); + +// ── Boot: load models, then signal ready ───────────────────────────────────── +_loadModels() + .then(() => { self.postMessage({ type: 'ready' }); }) + .catch(err => { self.postMessage({ type: 'error', message: String(err) }); }); diff --git a/the-matrix/js/main.js b/the-matrix/js/main.js index c75410c..9f6e25b 100644 --- a/the-matrix/js/main.js +++ b/the-matrix/js/main.js @@ -11,7 +11,8 @@ import { initWebSocket, getConnectionState, getJobCount } from './websocket.js'; import { initPaymentPanel } from './payment.js'; import { initSessionPanel } from './session.js'; import { initNostrIdentity } from './nostr-identity.js'; -import { warmup as warmupEdgeWorker } from './edge-worker.js'; +import { warmup as warmupEdgeWorker, onReady as onEdgeWorkerReady } from './edge-worker-client.js'; +import { setEdgeWorkerReady } from './ui.js'; let running = false; let canvas = null; @@ -35,8 +36,9 @@ function buildWorld(firstInit, stateSnapshot) { initSessionPanel(); // Nostr identity init (async — non-blocking) void initNostrIdentity('/api'); - // Warm up edge-worker models in the background after page loads + // Warm up edge-worker models in the background; show ready badge when done warmupEdgeWorker(); + onEdgeWorkerReady(() => setEdgeWorkerReady()); } const ac = new AbortController(); diff --git a/the-matrix/js/nostr-identity.js b/the-matrix/js/nostr-identity.js index a35d4f3..c3d5eeb 100644 --- a/the-matrix/js/nostr-identity.js +++ b/the-matrix/js/nostr-identity.js @@ -4,14 +4,16 @@ * Supports two identity modes: * 1. NIP-07 extension (window.nostr) — preferred when available. * 2. Generated keypair stored in localStorage — fallback for users without - * a Nostr extension (nsec stored encrypted by the browser's own storage). + * a Nostr extension. The user is shown an opt-in prompt before a key + * is generated; keys are never generated silently. * * Public API: - * initNostrIdentity() → Promise — call once on page load. - * getNostrToken() → string|null — current signed JWT-style token. - * getPubkey() → string|null — hex pubkey. - * refreshToken(apiBase) → Promise — re-auth and return new token. - * hasIdentity() → boolean + * initNostrIdentity(apiBase) → Promise — call once on page load. + * getNostrToken() → string|null — current signed token. + * getPubkey() → string|null — hex pubkey. + * getOrRefreshToken(apiBase) → Promise — valid token or refresh. + * hasIdentity() → boolean + * showIdentityPrompt() → void — display the "Identify with Nostr" UI */ import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; @@ -20,13 +22,14 @@ import { hexToBytes, bytesToHex } from '@noble/hashes/utils.js'; const LS_KEYPAIR_KEY = 'timmy_nostr_keypair_v1'; const LS_TOKEN_KEY = 'timmy_nostr_token_v1'; -// Token lifetime: 23 h in seconds (server TTL is 24 h; refresh 1 h before expiry) -const TOKEN_TTL_SECONDS = 23 * 3600; +// Token lifetime: 23 h — server TTL is 24 h; refresh 1 h before expiry +const TOKEN_TTL_MS = 23 * 3600 * 1000; let _pubkey = null; // hex -let _token = null; // signed token string from the server -let _tokenExp = 0; // unix ms when _token was fetched (for client-side refresh check) +let _token = null; // nostr_token string from server +let _tokenExp = 0; // unix ms when token was fetched let _useNip07 = false; // true if window.nostr is available +let _identityPromptShown = false; // ── Init ────────────────────────────────────────────────────────────────────── @@ -38,24 +41,31 @@ export async function initNostrIdentity(apiBase = '/api') { _useNip07 = true; console.info('[nostr] Using NIP-07 extension, pubkey:', _pubkey.slice(0, 8) + '…'); } catch (err) { - console.warn('[nostr] NIP-07 getPublicKey failed, falling back to local keypair', err); + console.warn('[nostr] NIP-07 getPublicKey failed, will use local keypair', err); _useNip07 = false; } } - // Fallback: generated keypair in localStorage + // Try restoring an existing local keypair (consented previously) if (!_pubkey) { - _pubkey = _loadOrGenerateKeypair(); - console.info('[nostr] Using local keypair, pubkey:', _pubkey.slice(0, 8) + '…'); + _pubkey = _loadKeypair(); + if (_pubkey) { + console.info('[nostr] Restored local keypair, pubkey:', _pubkey.slice(0, 8) + '…'); + } } // Try to restore cached token _loadCachedToken(); - // If no valid token, authenticate now - if (!_isTokenValid()) { + // If we have a pubkey but no valid token, authenticate now + if (_pubkey && !_isTokenValid()) { await refreshToken(apiBase); } + + // If no identity at all, show the opt-in prompt so the user can choose + if (!_pubkey) { + _scheduleIdentityPrompt(apiBase); + } } // ── Public API ──────────────────────────────────────────────────────────────── @@ -64,45 +74,59 @@ export function getPubkey() { return _pubkey; } export function getNostrToken() { return _isTokenValid() ? _token : null; } export function hasIdentity() { return !!_pubkey; } +/** + * getOrRefreshToken — returns a valid token, refreshing if necessary. + * Returns null if no identity is established. + */ +export async function getOrRefreshToken(apiBase = '/api') { + if (!_pubkey) return null; + if (_isTokenValid()) return _token; + return refreshToken(apiBase); +} + /** * refreshToken — run the challenge→sign→verify flow with the API. - * Returns the new token or null on failure. + * + * Server API: + * POST /api/identity/challenge → { nonce, expiresAt } + * POST /api/identity/verify → { nostr_token, pubkey, trust } + * body: { event } where event.content = nonce, event.kind = 27235 */ export async function refreshToken(apiBase = '/api') { if (!_pubkey) return null; try { - // 1. GET /api/nostr/challenge?pubkey= - const challengeRes = await fetch( - `${apiBase}/nostr/challenge?pubkey=${encodeURIComponent(_pubkey)}` - ); + // 1. POST /api/identity/challenge — server returns a nonce + const challengeRes = await fetch(`${apiBase}/identity/challenge`, { method: 'POST' }); if (!challengeRes.ok) { console.warn('[nostr] challenge fetch failed', challengeRes.status); return null; } - const { challenge } = await challengeRes.json(); - if (!challenge) { console.warn('[nostr] no challenge in response'); return null; } + const { nonce } = await challengeRes.json(); + if (!nonce) { console.warn('[nostr] no nonce in challenge response'); return null; } - // 2. Sign the challenge as a Nostr event kind 27235 (NIP-98 style) - const event = await _signChallenge(challenge); + // 2. Sign the nonce as event.content (kind 27235 per NIP-98) + const event = await _signNonce(nonce); if (!event) { console.warn('[nostr] signing failed'); return null; } - // 3. POST /api/nostr/verify - const verifyRes = await fetch(`${apiBase}/nostr/verify`, { + // 3. POST /api/identity/verify — server checks sig and issues nostr_token + const verifyRes = await fetch(`${apiBase}/identity/verify`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ pubkey: _pubkey, challenge, event }), + body: JSON.stringify({ event }), }); if (!verifyRes.ok) { console.warn('[nostr] verify failed', verifyRes.status); return null; } - const { token } = await verifyRes.json(); - if (!token) { console.warn('[nostr] no token in verify response'); return null; } + const data = await verifyRes.json(); + const token = data.nostr_token; + if (!token) { console.warn('[nostr] no nostr_token in verify response'); return null; } _token = token; _tokenExp = Date.now(); _saveCachedToken(); + console.info('[nostr] token refreshed, tier:', data.trust?.tier ?? 'unknown'); return token; } catch (err) { console.warn('[nostr] refreshToken error', err); @@ -110,36 +134,104 @@ export async function refreshToken(apiBase = '/api') { } } -/** - * getOrRefreshToken — returns a valid token, refreshing if necessary. - * Use this before every authenticated API call. - */ -export async function getOrRefreshToken(apiBase = '/api') { - if (_isTokenValid()) return _token; - return refreshToken(apiBase); +// ── Identity prompt — optional Nostr identification UI ──────────────────────── +// Shown after a short delay if no identity is present. User can dismiss. + +export function showIdentityPrompt(apiBase = '/api') { + if (_identityPromptShown || _pubkey) return; + _identityPromptShown = true; + + const $prompt = document.createElement('div'); + $prompt.id = 'nostr-identity-prompt'; + $prompt.style.cssText = [ + 'position:fixed;bottom:80px;right:16px;z-index:900', + 'background:rgba(8,6,16,0.94);border:1px solid #3355aa', + 'border-radius:8px;padding:12px 14px;font-family:monospace', + 'font-size:12px;color:#88aadd;max-width:240px', + 'box-shadow:0 0 16px rgba(50,80,200,0.3)', + 'animation:fadeInUp .3s ease', + ].join(';'); + + const hasNip07 = typeof window !== 'undefined' && !!window.nostr; + $prompt.innerHTML = ` +
+ ⚡ Identify with Nostr +
+
+ ${hasNip07 + ? 'Connect your Nostr extension to unlock trust benefits and free-tier access.' + : 'Generate a local Nostr identity to unlock trust benefits and free-tier access.'} +
+
+ + +
+ `; + + document.body.appendChild($prompt); + + document.getElementById('nostr-prompt-connect')?.addEventListener('click', async () => { + $prompt.remove(); + if (hasNip07) { + // NIP-07 connect + try { + _pubkey = await window.nostr.getPublicKey(); + _useNip07 = true; + } catch { return; } + } else { + // Generate + store keypair (user consented by clicking) + _pubkey = _generateAndSaveKeypair(); + } + if (_pubkey) { + await refreshToken(apiBase); + _updateIdentityHUD(); + } + }); + + document.getElementById('nostr-prompt-dismiss')?.addEventListener('click', () => { + $prompt.remove(); + }); +} + +function _scheduleIdentityPrompt(apiBase) { + // Show prompt 4 s after page load — after Three.js world is running + setTimeout(() => showIdentityPrompt(apiBase), 4000); +} + +function _updateIdentityHUD() { + // Notify the session button / HUD area if session.js is connected. + // We dispatch a custom event so session.js can pick it up without a circular dep. + window.dispatchEvent(new CustomEvent('nostr:identity-ready', { detail: { pubkey: _pubkey } })); } // ── Keypair helpers ─────────────────────────────────────────────────────────── -function _loadOrGenerateKeypair() { +function _loadKeypair() { try { const stored = localStorage.getItem(LS_KEYPAIR_KEY); - if (stored) { - const { privkey, pubkey } = JSON.parse(stored); - if (privkey && pubkey) { - return pubkey; - } - } - } catch { /* corrupted storage */ } + if (!stored) return null; + const { pubkey } = JSON.parse(stored); + return pubkey ?? null; + } catch { + return null; + } +} - // Generate fresh keypair +function _generateAndSaveKeypair() { const privkeyBytes = generateSecretKey(); const privkeyHex = bytesToHex(privkeyBytes); const pubkeyHex = getPublicKey(privkeyBytes); try { localStorage.setItem(LS_KEYPAIR_KEY, JSON.stringify({ privkey: privkeyHex, pubkey: pubkeyHex })); - } catch { /* storage full / private browsing */ } + } catch { /* storage full or private browsing */ } return pubkeyHex; } @@ -157,25 +249,24 @@ function _getPrivkeyBytes() { // ── Signing ─────────────────────────────────────────────────────────────────── -async function _signChallenge(challenge) { +async function _signNonce(nonce) { + // event.content must be the nonce string per server contract const eventTemplate = { kind: 27235, created_at: Math.floor(Date.now() / 1000), - tags: [['challenge', challenge]], - content: `Timmy Tower auth: ${challenge}`, + tags: [], + content: nonce, }; if (_useNip07 && window.nostr) { try { - const signed = await window.nostr.signEvent(eventTemplate); - return signed; + return await window.nostr.signEvent(eventTemplate); } catch (err) { - console.warn('[nostr] NIP-07 signEvent failed, falling back to local key', err); + console.warn('[nostr] NIP-07 signEvent failed, trying local key', err); _useNip07 = false; } } - // Local keypair signing const privkeyBytes = _getPrivkeyBytes(); if (!privkeyBytes) { console.warn('[nostr] no private key available for signing'); @@ -183,8 +274,7 @@ async function _signChallenge(challenge) { } try { - const signed = finalizeEvent({ ...eventTemplate, pubkey: _pubkey }, privkeyBytes); - return signed; + return finalizeEvent({ ...eventTemplate, pubkey: _pubkey }, privkeyBytes); } catch (err) { console.warn('[nostr] finalizeEvent failed', err); return null; @@ -195,14 +285,13 @@ async function _signChallenge(challenge) { function _isTokenValid() { if (!_token || !_tokenExp) return false; - const ageMs = Date.now() - _tokenExp; - return ageMs < TOKEN_TTL_SECONDS * 1000; + return (Date.now() - _tokenExp) < TOKEN_TTL_MS; } function _saveCachedToken() { try { localStorage.setItem(LS_TOKEN_KEY, JSON.stringify({ token: _token, exp: _tokenExp })); - } catch { /* storage unavailable */ } + } catch { /* unavailable */ } } function _loadCachedToken() { diff --git a/the-matrix/js/session.js b/the-matrix/js/session.js index 86705b6..29c2cd3 100644 --- a/the-matrix/js/session.js +++ b/the-matrix/js/session.js @@ -15,7 +15,7 @@ import { setSpeechBubble, setMood } from './agents.js'; import { appendSystemMessage, setSessionSendHandler, setInputBarSessionMode } from './ui.js'; import { getOrRefreshToken } from './nostr-identity.js'; -import { sentiment } from './edge-worker.js'; +import { sentiment } from './edge-worker-client.js'; const API = '/api'; const LS_KEY = 'timmy_session_v1'; @@ -112,12 +112,6 @@ export async function sessionSendHandler(text) { _setSendBusy(true); appendSystemMessage(`you: ${text}`); - // Sentiment-driven mood — run in parallel with the API request - sentiment(text).then(s => { - setMood(s.label); - setTimeout(() => setMood(null), 8000); - }).catch(() => {}); - // Attach Nostr token if available const nostrToken = await getOrRefreshToken('/api'); const reqHeaders = { @@ -166,6 +160,12 @@ export async function sessionSendHandler(text) { setSpeechBubble(reply); appendSystemMessage('Timmy: ' + reply.slice(0, 80)); + // Sentiment-driven mood on inbound Timmy reply + sentiment(reply).then(s => { + setMood(s.label); + setTimeout(() => setMood(null), 10_000); + }).catch(() => {}); + // Update active-step balance if panel is open _updateActiveStep(); diff --git a/the-matrix/js/ui.js b/the-matrix/js/ui.js index 5942e7f..1d19713 100644 --- a/the-matrix/js/ui.js +++ b/the-matrix/js/ui.js @@ -1,5 +1,5 @@ import { sendVisitorMessage } from './websocket.js'; -import { classify, sentiment } from './edge-worker.js'; +import { classify } from './edge-worker-client.js'; import { setMood } from './agents.js'; import { getOrRefreshToken } from './nostr-identity.js'; @@ -31,41 +31,63 @@ export function setInputBarSessionMode(active, placeholder) { } } -export function initUI() { - if (uiInitialized) return; - uiInitialized = true; - initInputBar(); +// ── 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 = ''; } -// ── Cost preview ────────────────────────────────────────────────────────────── -// Shown as a small badge beneath the input bar: "~N sats" or "FREE". +// ── Cost preview badge ──────────────────────────────────────────────────────── +// Shown beneath the input bar: "~N sats" / "FREE" / "answered locally". // Fetched from GET /api/estimate once the user stops typing (300 ms debounce). let _estimateTimer = null; -const $costPreview = _orCreate('timmy-cost-preview', () => { - const el = document.createElement('div'); - el.id = 'timmy-cost-preview'; - el.style.cssText = 'font-size:11px;color:#88aacc;margin-top:3px;min-height:14px;transition:opacity .3s;opacity:0;'; - // Insert after send-btn's parent container - const $input = document.getElementById('visitor-input'); - $input?.parentElement?.appendChild(el); - return el; -}); +let $costPreview = null; -function _orCreate(id, factory) { - const existing = document.getElementById(id); - return existing ?? factory(); +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; } function _showCostPreview(text, color = '#88aacc') { - if (!$costPreview) return; - $costPreview.textContent = text; - $costPreview.style.color = color; - $costPreview.style.opacity = '1'; + const el = _ensureCostPreview(); + el.textContent = text; + el.style.color = color; + el.style.opacity = '1'; } function _hideCostPreview() { - if ($costPreview) $costPreview.style.opacity = '0'; + const el = _ensureCostPreview(); + el.style.opacity = '0'; } async function _fetchEstimate(text) { @@ -100,12 +122,17 @@ function _scheduleCostPreview(text) { // ── Input bar ───────────────────────────────────────────────────────────────── +export function initUI() { + if (uiInitialized) return; + uiInitialized = true; + initInputBar(); +} + function initInputBar() { const $input = document.getElementById('visitor-input'); const $sendBtn = document.getElementById('send-btn'); if (!$input || !$sendBtn) return; - // Cost preview on typing $input.addEventListener('input', () => _scheduleCostPreview($input.value.trim())); async function send() { @@ -119,27 +146,19 @@ function initInputBar() { return; } - // ── Edge triage — run classification + sentiment in parallel ────────────── - const [cls, sent] = await Promise.all([ - classify(text), - sentiment(text), - ]); - - // Sentiment-driven facial expression (auto-clears after 8 s) - setMood(sent.label); - setTimeout(() => setMood(null), 8000); + // ── Edge triage — classify text in the Web Worker ───────────────────────── + const cls = await classify(text); if (cls.label === 'local' && cls.localReply) { - // Trivial message — answer locally, no server round-trip + // Trivial/conversational — answer locally, no server round-trip appendSystemMessage(`you: ${text}`); appendSystemMessage(`Timmy [local]: ${cls.localReply}`); - // Show local badge in cost preview briefly _showCostPreview('answered locally ⚡ 0 sats', '#44dd88'); setTimeout(_hideCostPreview, 3000); return; } - // Non-trivial — send to server via WebSocket + // Substantive — route to server via WebSocket sendVisitorMessage(text); appendSystemMessage(`you: ${text}`); } diff --git a/the-matrix/js/websocket.js b/the-matrix/js/websocket.js index 9036ddb..1be6dd5 100644 --- a/the-matrix/js/websocket.js +++ b/the-matrix/js/websocket.js @@ -1,5 +1,6 @@ -import { setAgentState, setSpeechBubble, applyAgentStates } from './agents.js'; +import { setAgentState, setSpeechBubble, applyAgentStates, setMood } from './agents.js'; import { appendSystemMessage } from './ui.js'; +import { sentiment } from './edge-worker-client.js'; function resolveWsUrl() { const explicit = import.meta.env.VITE_WS_URL; @@ -102,6 +103,13 @@ function handleMessage(msg) { // Timmy's AI reply: show in speech bubble + event log if (msg.text) setSpeechBubble(msg.text); appendSystemMessage('Timmy: ' + (msg.text || '').slice(0, 80)); + // Sentiment-driven facial expression on inbound Timmy messages + if (msg.text) { + sentiment(msg.text).then(s => { + setMood(s.label); + setTimeout(() => setMood(null), 10_000); + }).catch(() => {}); + } } else if (msg.agentId === 'visitor') { // Another visitor's message: event log only (don't hijack the speech bubble) appendSystemMessage((msg.text || '').slice(0, 80)); diff --git a/the-matrix/vite.config.js b/the-matrix/vite.config.js index b09f3bc..ce82025 100644 --- a/the-matrix/vite.config.js +++ b/the-matrix/vite.config.js @@ -51,4 +51,8 @@ export default defineConfig({ // @xenova/transformers uses dynamic imports + WASM; exclude from pre-bundling exclude: ['@xenova/transformers'], }, + worker: { + // Bundle Web Workers as ES modules so they can use import() and ESM packages + format: 'es', + }, });