task-28: edge intelligence — Transformers.js triage, Nostr signing, cost preview, sentiment moods

- js/edge-worker.js: new — browser-side classify() + sentiment() via Transformers.js
- js/nostr-identity.js: new — NIP-07 extension + localStorage keypair fallback,
  challenge→sign→verify flow, token caching
- js/agents.js: export setMood() for sentiment-driven face expressions
- js/ui.js: local triage badge, cost preview via /api/estimate, sentiment on send
- js/payment.js: X-Nostr-Token injection on POST /jobs
- js/session.js: X-Nostr-Token injection on session create + request, sentiment mood
- js/main.js: initNostrIdentity() + warmupEdgeWorker() on firstInit
- vite.config.js: optimizeDeps.exclude @xenova/transformers
This commit is contained in:
Replit Agent
2026-03-19 18:09:44 +00:00
parent 484583004a
commit af3c938c6e
10 changed files with 1528 additions and 12 deletions

View File

@@ -552,6 +552,24 @@ export function setFaceEmotion(mood) {
timmy._overrideMood = MOOD_ALIASES[mood] ?? 'idle';
}
/**
* setMood — convenience alias accepted by Task #28 spec.
* Maps sentiment labels to appropriate Timmy face states:
* POSITIVE → curious (active) — wide eyes, smile
* NEGATIVE → focused (thinking) — squint, flat mouth
* NEUTRAL → contemplative (idle) — half-lid, neutral
* Also accepts raw mood names passed through to setFaceEmotion().
*/
export function setMood(moodOrSentiment) {
if (!moodOrSentiment) { setFaceEmotion(null); return; }
const m = String(moodOrSentiment).toUpperCase();
if (m === 'POSITIVE') { setFaceEmotion('curious'); return; }
if (m === 'NEGATIVE') { setFaceEmotion('focused'); return; }
if (m === 'NEUTRAL') { setFaceEmotion('contemplative'); return; }
// Fall through to alias lookup for raw mood names
setFaceEmotion(moodOrSentiment);
}
// ── _updateRagdoll — integrated per-frame from updateAgents ──────────────────
// Controls group.rotation and group.position.y for all ragdoll states.
// In RD_STAND it runs the residual micro-spring; all other states run the

View File

@@ -0,0 +1,168 @@
/**
* edge-worker.js — Browser-side lightweight AI triage using Transformers.js.
*
* Provides two functions:
* classify(text) → { label: 'local'|'server', score, reason }
* sentiment(text) → { label: 'POSITIVE'|'NEGATIVE'|'NEUTRAL', score }
*
* 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).
*
* "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.
*/
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; // 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?",
"*adjusts hat* Hello there! The crystal ball is warm and ready.",
"Well met! Timmy Tower is open for business. What shall we conjure today?",
"Hail! The generosity pool glimmers. What brings you to my tower?",
];
function _randomReply() {
return LOCAL_REPLIES[Math.floor(Math.random() * LOCAL_REPLIES.length)];
}
// ── Lazy-load helpers ─────────────────────────────────────────────────────────
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;
}
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;
}
// ── Public API ────────────────────────────────────────────────────────────────
/**
* 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) {
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)) {
return { label: 'local', score: 0.99, reason: 'greeting-heuristic', localReply: _randomReply() };
}
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 topLabel = result.labels[0];
const topScore = result.scores[0];
const isLocal = LOCAL_LABELS.includes(topLabel) && topScore >= LOCAL_THRESHOLD;
return {
label: isLocal ? 'local' : 'server',
score: topScore,
reason: topLabel,
...(isLocal ? { localReply: _randomReply() } : {}),
};
} catch (err) {
console.warn('[edge-worker] classify error', err);
return { label: 'server', score: 0, reason: 'classify-error' };
}
}
/**
* 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) {
try {
const pipe = await _getSentimentPipe();
if (!pipe) return { label: 'NEUTRAL', score: 0.5 };
const [result] = await pipe(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 };
}
return { label: label.toUpperCase(), score };
} catch (err) {
console.warn('[edge-worker] sentiment error', err);
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();
}

View File

@@ -10,6 +10,8 @@ import { initInteraction, disposeInteraction, registerSlapTarget } from './inter
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';
let running = false;
let canvas = null;
@@ -31,6 +33,10 @@ function buildWorld(firstInit, stateSnapshot) {
initWebSocket(scene);
initPaymentPanel();
initSessionPanel();
// Nostr identity init (async — non-blocking)
void initNostrIdentity('/api');
// Warm up edge-worker models in the background after page loads
warmupEdgeWorker();
}
const ac = new AbortController();

View File

@@ -0,0 +1,215 @@
/**
* nostr-identity.js — Browser-side Nostr identity for Timmy Tower World.
*
* 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).
*
* Public API:
* initNostrIdentity() → Promise<void> — call once on page load.
* getNostrToken() → string|null — current signed JWT-style token.
* getPubkey() → string|null — hex pubkey.
* refreshToken(apiBase) → Promise<string|null> — re-auth and return new token.
* hasIdentity() → boolean
*/
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
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;
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 _useNip07 = false; // true if window.nostr is available
// ── Init ──────────────────────────────────────────────────────────────────────
export async function initNostrIdentity(apiBase = '/api') {
// Prefer NIP-07 extension
if (typeof window !== 'undefined' && window.nostr) {
try {
_pubkey = await window.nostr.getPublicKey();
_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);
_useNip07 = false;
}
}
// Fallback: generated keypair in localStorage
if (!_pubkey) {
_pubkey = _loadOrGenerateKeypair();
console.info('[nostr] Using local keypair, pubkey:', _pubkey.slice(0, 8) + '…');
}
// Try to restore cached token
_loadCachedToken();
// If no valid token, authenticate now
if (!_isTokenValid()) {
await refreshToken(apiBase);
}
}
// ── Public API ────────────────────────────────────────────────────────────────
export function getPubkey() { return _pubkey; }
export function getNostrToken() { return _isTokenValid() ? _token : null; }
export function hasIdentity() { return !!_pubkey; }
/**
* refreshToken — run the challenge→sign→verify flow with the API.
* Returns the new token or null on failure.
*/
export async function refreshToken(apiBase = '/api') {
if (!_pubkey) return null;
try {
// 1. GET /api/nostr/challenge?pubkey=<hex>
const challengeRes = await fetch(
`${apiBase}/nostr/challenge?pubkey=${encodeURIComponent(_pubkey)}`
);
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; }
// 2. Sign the challenge as a Nostr event kind 27235 (NIP-98 style)
const event = await _signChallenge(challenge);
if (!event) { console.warn('[nostr] signing failed'); return null; }
// 3. POST /api/nostr/verify
const verifyRes = await fetch(`${apiBase}/nostr/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pubkey: _pubkey, challenge, 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; }
_token = token;
_tokenExp = Date.now();
_saveCachedToken();
return token;
} catch (err) {
console.warn('[nostr] refreshToken error', err);
return null;
}
}
/**
* 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);
}
// ── Keypair helpers ───────────────────────────────────────────────────────────
function _loadOrGenerateKeypair() {
try {
const stored = localStorage.getItem(LS_KEYPAIR_KEY);
if (stored) {
const { privkey, pubkey } = JSON.parse(stored);
if (privkey && pubkey) {
return pubkey;
}
}
} catch { /* corrupted storage */ }
// Generate fresh keypair
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 */ }
return pubkeyHex;
}
function _getPrivkeyBytes() {
try {
const stored = localStorage.getItem(LS_KEYPAIR_KEY);
if (!stored) return null;
const { privkey } = JSON.parse(stored);
return privkey ? hexToBytes(privkey) : null;
} catch {
return null;
}
}
// ── Signing ───────────────────────────────────────────────────────────────────
async function _signChallenge(challenge) {
const eventTemplate = {
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [['challenge', challenge]],
content: `Timmy Tower auth: ${challenge}`,
};
if (_useNip07 && window.nostr) {
try {
const signed = await window.nostr.signEvent(eventTemplate);
return signed;
} catch (err) {
console.warn('[nostr] NIP-07 signEvent failed, falling back to local key', err);
_useNip07 = false;
}
}
// Local keypair signing
const privkeyBytes = _getPrivkeyBytes();
if (!privkeyBytes) {
console.warn('[nostr] no private key available for signing');
return null;
}
try {
const signed = finalizeEvent({ ...eventTemplate, pubkey: _pubkey }, privkeyBytes);
return signed;
} catch (err) {
console.warn('[nostr] finalizeEvent failed', err);
return null;
}
}
// ── Token caching ─────────────────────────────────────────────────────────────
function _isTokenValid() {
if (!_token || !_tokenExp) return false;
const ageMs = Date.now() - _tokenExp;
return ageMs < TOKEN_TTL_SECONDS * 1000;
}
function _saveCachedToken() {
try {
localStorage.setItem(LS_TOKEN_KEY, JSON.stringify({ token: _token, exp: _tokenExp }));
} catch { /* storage unavailable */ }
}
function _loadCachedToken() {
try {
const raw = localStorage.getItem(LS_TOKEN_KEY);
if (!raw) return;
const { token, exp } = JSON.parse(raw);
if (token && exp) { _token = token; _tokenExp = exp; }
} catch { /* ignore */ }
}

View File

@@ -9,6 +9,8 @@
* 5. Result appears in panel + chat feed
*/
import { getOrRefreshToken } from './nostr-identity.js';
const API_BASE = '/api';
const POLL_INTERVAL_MS = 2000;
const POLL_TIMEOUT_MS = 60000;
@@ -81,9 +83,13 @@ async function submitJob() {
document.getElementById('job-submit-btn').disabled = true;
try {
const token = await getOrRefreshToken('/api');
const headers = { 'Content-Type': 'application/json' };
if (token) headers['X-Nostr-Token'] = token;
const res = await fetch(`${API_BASE}/jobs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers,
body: JSON.stringify({ request }),
});
const data = await res.json();

View File

@@ -12,8 +12,10 @@
* 8. On page reload: localStorage is read, session is validated, UI restored
*/
import { setSpeechBubble, setAgentState } from './agents.js';
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';
const API = '/api';
const LS_KEY = 'timmy_session_v1';
@@ -110,13 +112,24 @@ 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 = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${_macaroon}`,
};
if (nostrToken) reqHeaders['X-Nostr-Token'] = nostrToken;
try {
const res = await fetch(`${API}/sessions/${_sessionId}/request`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${_macaroon}`,
},
headers: reqHeaders,
body: JSON.stringify({ request: text }),
});
@@ -203,9 +216,13 @@ async function _createSession() {
_btn('session-create-btn', true);
try {
const nostrToken = await getOrRefreshToken('/api');
const createHeaders = { 'Content-Type': 'application/json' };
if (nostrToken) createHeaders['X-Nostr-Token'] = nostrToken;
const res = await fetch(`${API}/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: createHeaders,
body: JSON.stringify({ amount_sats: _selectedSats }),
});
const data = await res.json();

View File

@@ -1,4 +1,7 @@
import { sendVisitorMessage } from './websocket.js';
import { classify, sentiment } from './edge-worker.js';
import { setMood } from './agents.js';
import { getOrRefreshToken } from './nostr-identity.js';
const $fps = document.getElementById('fps');
const $activeJobs = document.getElementById('active-jobs');
@@ -34,21 +37,111 @@ export function initUI() {
initInputBar();
}
// ── Cost preview ──────────────────────────────────────────────────────────────
// Shown as a small badge beneath the input bar: "~N sats" or "FREE".
// 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;
});
function _orCreate(id, factory) {
const existing = document.getElementById(id);
return existing ?? factory();
}
function _showCostPreview(text, color = '#88aacc') {
if (!$costPreview) return;
$costPreview.textContent = text;
$costPreview.style.color = color;
$costPreview.style.opacity = '1';
}
function _hideCostPreview() {
if ($costPreview) $costPreview.style.opacity = '0';
}
async function _fetchEstimate(text) {
try {
const token = await getOrRefreshToken('/api');
const params = new URLSearchParams({ request: text });
if (token) params.set('nostr_token', token);
const res = await fetch(`/api/estimate?${params}`);
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();
}
}
function _scheduleCostPreview(text) {
clearTimeout(_estimateTimer);
if (!text || text.length < 4) { _hideCostPreview(); return; }
_estimateTimer = setTimeout(() => _fetchEstimate(text), 300);
}
// ── Input bar ─────────────────────────────────────────────────────────────────
function initInputBar() {
const $input = document.getElementById('visitor-input');
const $input = document.getElementById('visitor-input');
const $sendBtn = document.getElementById('send-btn');
if (!$input || !$sendBtn) return;
function send() {
// Cost preview on typing
$input.addEventListener('input', () => _scheduleCostPreview($input.value.trim()));
async function send() {
const text = $input.value.trim();
if (!text) return;
$input.value = '';
_hideCostPreview();
if (_sessionSendHandler) {
_sessionSendHandler(text);
} else {
sendVisitorMessage(text);
appendSystemMessage(`you: ${text}`);
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);
if (cls.label === 'local' && cls.localReply) {
// Trivial message — 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
sendVisitorMessage(text);
appendSystemMessage(`you: ${text}`);
}
$sendBtn.addEventListener('click', send);

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,8 @@
"preview": "vite preview"
},
"dependencies": {
"@xenova/transformers": "^2.17.2",
"nostr-tools": "^2.23.3",
"three": "0.171.0"
},
"devDependencies": {

View File

@@ -47,4 +47,8 @@ export default defineConfig({
'/api': 'http://localhost:8080',
},
},
optimizeDeps: {
// @xenova/transformers uses dynamic imports + WASM; exclude from pre-bundling
exclude: ['@xenova/transformers'],
},
});