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:
@@ -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
|
||||
|
||||
168
the-matrix/js/edge-worker.js
Normal file
168
the-matrix/js/edge-worker.js
Normal 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();
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
215
the-matrix/js/nostr-identity.js
Normal file
215
the-matrix/js/nostr-identity.js
Normal 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 */ }
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
987
the-matrix/package-lock.json
generated
987
the-matrix/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,8 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xenova/transformers": "^2.17.2",
|
||||
"nostr-tools": "^2.23.3",
|
||||
"three": "0.171.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user