task-28 fix: proper Web Worker, correct Nostr endpoints, sentiment on inbound msgs
Addresses all code review rejections:
1. edge-worker.js → now a proper Web Worker entry point with postMessage API,
loads models in worker thread; signals {type:'ready'} when warm
2. edge-worker-client.js → new main-thread proxy: spawns Worker via
new Worker(url, {type:'module'}), wraps calls as Promises, falls back
to server routing if Workers unavailable; exports classify/sentiment/
warmup/onReady/isReady
3. nostr-identity.js → fixed endpoints: POST /identity/challenge (→ nonce),
POST /identity/verify (body:{event}, content=nonce → nostr_token);
keypair generation now requires explicit user consent via identity prompt
(no silent key generation); showIdentityPrompt() shows opt-in UI
4. ui.js → import from edge-worker-client; setEdgeWorkerReady() shows
'local AI' badge when worker signals ready; removed outbound sentiment
5. websocket.js → sentiment() on inbound Timmy chat messages drives setMood()
6. session.js → sentiment() on inbound reply (data.result), not outbound text
7. main.js → onEdgeWorkerReady(() => setEdgeWorkerReady()) wires ready badge
8. vite.config.js → worker.format:'es' for ESM Web Worker bundling
This commit is contained in:
100
the-matrix/js/edge-worker-client.js
Normal file
100
the-matrix/js/edge-worker-client.js
Normal file
@@ -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(); }
|
||||
@@ -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) }); });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<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
|
||||
* initNostrIdentity(apiBase) → Promise<void> — call once on page load.
|
||||
* getNostrToken() → string|null — current signed token.
|
||||
* getPubkey() → string|null — hex pubkey.
|
||||
* getOrRefreshToken(apiBase) → Promise<string|null> — 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=<hex>
|
||||
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 = `
|
||||
<div style="color:#aaccff;font-weight:bold;margin-bottom:6px">
|
||||
⚡ Identify with Nostr
|
||||
</div>
|
||||
<div style="line-height:1.5;margin-bottom:10px">
|
||||
${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.'}
|
||||
</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button id="nostr-prompt-connect"
|
||||
style="flex:1;background:#1a3a7a;color:#aaddff;border:1px solid #3366bb;
|
||||
border-radius:4px;padding:5px 8px;cursor:pointer;font-family:monospace;font-size:11px">
|
||||
${hasNip07 ? 'Connect Extension' : 'Generate Key'}
|
||||
</button>
|
||||
<button id="nostr-prompt-dismiss"
|
||||
style="background:transparent;color:#556688;border:none;cursor:pointer;font-size:11px">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user