Some checks failed
CI / Typecheck & Lint (pull_request) Failing after 0s
At the start of job execution, a Haiku call decomposes the user's request into 2-4 named sub-steps. Steps are broadcast via WebSocket as `job_steps` and `job_step_update` events. The Workshop renders a live checklist panel near Gamma that checks off steps as streaming progresses using a character-count heuristic, then collapses to "Done" on completion. Steps are stored with the job record. - agent.ts: add `decomposeRequest` (Haiku, stub-safe) - event-bus.ts: add `DecompositionEvent` types (job:steps, job:step_update) - jobs.ts: call decompose before streaming; advance steps heuristically - events.ts: translate new bus events to WS messages - websocket.js: handle job_steps / job_step_update / collapse on complete - ui.js: showJobSteps / updateJobStep / collapseJobSteps panel - jobs schema: decomposition_steps text column - migration 0010: add decomposition_steps column Fixes #5 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
434 lines
15 KiB
JavaScript
434 lines
15 KiB
JavaScript
import { sendVisitorMessage } from './websocket.js';
|
|
import { classify } from './edge-worker-client.js';
|
|
import { setMood, setSpeechBubble } from './agents.js';
|
|
import { getOrRefreshToken } from './nostr-identity.js';
|
|
|
|
const $fps = document.getElementById('fps');
|
|
const $activeJobs = document.getElementById('active-jobs');
|
|
const $connStatus = document.getElementById('connection-status');
|
|
const $log = document.getElementById('event-log');
|
|
|
|
const MAX_LOG = 6;
|
|
const logEntries = [];
|
|
let uiInitialized = false;
|
|
|
|
// ── Session-mode send override ────────────────────────────────────────────────
|
|
let _sessionSendHandler = null;
|
|
|
|
export function setSessionSendHandler(fn) {
|
|
_sessionSendHandler = fn;
|
|
}
|
|
|
|
export function setInputBarSessionMode(active, placeholder) {
|
|
const $input = document.getElementById('visitor-input');
|
|
if (!$input) return;
|
|
if (active) {
|
|
$input.classList.add('session-active');
|
|
$input.placeholder = placeholder || 'Ask Timmy (session active)…';
|
|
} else {
|
|
$input.classList.remove('session-active');
|
|
$input.placeholder = 'Say something to Timmy…';
|
|
}
|
|
}
|
|
|
|
// ── 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 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;
|
|
let $costPreview = null;
|
|
|
|
function _ensureCostPreview() {
|
|
if ($costPreview) return $costPreview;
|
|
$costPreview = document.getElementById('timmy-cost-preview');
|
|
if (!$costPreview) {
|
|
$costPreview = document.createElement('div');
|
|
$costPreview.id = 'timmy-cost-preview';
|
|
$costPreview.style.cssText = 'font-size:11px;color:#88aacc;margin-top:3px;min-height:14px;transition:opacity .3s;opacity:0;';
|
|
const $input = document.getElementById('visitor-input');
|
|
$input?.parentElement?.appendChild($costPreview);
|
|
}
|
|
return $costPreview;
|
|
}
|
|
|
|
function _showCostPreview(text, color = '#88aacc') {
|
|
const el = _ensureCostPreview();
|
|
el.textContent = text;
|
|
el.style.color = color;
|
|
el.style.opacity = '1';
|
|
}
|
|
|
|
function _hideCostPreview() {
|
|
const el = _ensureCostPreview();
|
|
el.style.opacity = '0';
|
|
}
|
|
|
|
async function _fetchEstimate(text) {
|
|
try {
|
|
const token = await getOrRefreshToken('/api');
|
|
const params = new URLSearchParams({ request: text });
|
|
const fetchOpts = {};
|
|
if (token) {
|
|
fetchOpts.headers = { 'X-Nostr-Token': token };
|
|
}
|
|
|
|
const res = await fetch(`/api/estimate?${params}`, fetchOpts);
|
|
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();
|
|
}
|
|
}
|
|
|
|
// Fast trivial heuristic — same pattern as edge-worker.js _isGreeting().
|
|
// Prevents /api/estimate network calls for greeting messages on every keypress.
|
|
const _TRIVIAL_RE = /^(hi|hey|hello|howdy|greetings|yo|sup|hiya|what'?s up)[!?.,]?\s*$/i;
|
|
|
|
function _scheduleCostPreview(text) {
|
|
clearTimeout(_estimateTimer);
|
|
if (!text || text.length < 4) { _hideCostPreview(); return; }
|
|
// Skip estimate entirely for trivially local messages — zero network calls
|
|
if (_TRIVIAL_RE.test(text.trim())) {
|
|
_showCostPreview('answered locally ⚡ 0 sats', '#44dd88');
|
|
return;
|
|
}
|
|
_estimateTimer = setTimeout(() => _fetchEstimate(text), 300);
|
|
}
|
|
|
|
// ── Live cost ticker ──────────────────────────────────────────────────────────
|
|
// Shown in the top-right HUD during active paid interactions.
|
|
// Updated via WebSocket `cost_update` messages from the backend.
|
|
|
|
let $costTicker = null;
|
|
let _tickerHideTimer = null;
|
|
|
|
function _ensureCostTicker() {
|
|
if ($costTicker) return $costTicker;
|
|
$costTicker = document.getElementById('timmy-cost-ticker');
|
|
if (!$costTicker) {
|
|
$costTicker = document.createElement('div');
|
|
$costTicker.id = 'timmy-cost-ticker';
|
|
$costTicker.style.cssText = [
|
|
'position:fixed;top:36px;right:16px',
|
|
'font-size:11px;font-family:"Courier New",monospace',
|
|
'color:#ffcc44;text-shadow:0 0 6px #aa8822',
|
|
'letter-spacing:1px',
|
|
'pointer-events:none;z-index:10',
|
|
'transition:opacity .4s;opacity:0',
|
|
].join(';');
|
|
document.body.appendChild($costTicker);
|
|
}
|
|
return $costTicker;
|
|
}
|
|
|
|
export function showCostTicker(sats) {
|
|
clearTimeout(_tickerHideTimer);
|
|
const el = _ensureCostTicker();
|
|
el.textContent = `⚡ ~${sats} sats`;
|
|
el.style.opacity = '1';
|
|
}
|
|
|
|
export function updateCostTicker(sats, isFinal = false) {
|
|
clearTimeout(_tickerHideTimer);
|
|
const el = _ensureCostTicker();
|
|
el.textContent = isFinal ? `⚡ ${sats} sats charged` : `⚡ ~${sats} sats`;
|
|
el.style.opacity = '1';
|
|
if (isFinal) {
|
|
_tickerHideTimer = setTimeout(hideCostTicker, 5000);
|
|
}
|
|
}
|
|
|
|
export function hideCostTicker() {
|
|
if (!$costTicker) return;
|
|
$costTicker.style.opacity = '0';
|
|
}
|
|
|
|
// ── 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;
|
|
|
|
$input.addEventListener('input', () => _scheduleCostPreview($input.value.trim()));
|
|
|
|
async function send() {
|
|
const text = $input.value.trim();
|
|
if (!text) return;
|
|
$input.value = '';
|
|
_hideCostPreview();
|
|
|
|
// ── Edge triage — runs in BOTH session mode and WebSocket mode ─────────────
|
|
// Worker returns { complexity:'trivial'|'moderate'|'complex', score, reason, localReply? }
|
|
const cls = await classify(text);
|
|
|
|
if (cls.complexity === 'trivial' && cls.localReply) {
|
|
// Greeting / small-talk → answer locally, 0 sats, no network call in any mode
|
|
appendSystemMessage(`you: ${text}`);
|
|
setSpeechBubble(`${cls.localReply} ⚡ local`);
|
|
_showCostPreview('answered locally ⚡ 0 sats', '#44dd88');
|
|
setTimeout(_hideCostPreview, 3000);
|
|
return;
|
|
}
|
|
|
|
// Non-trivial: delegate to session handler (if active) or WebSocket
|
|
if (_sessionSendHandler) {
|
|
// moderate/complex — fire estimate async for cost preview, then hand off
|
|
if (cls.complexity === 'moderate' || cls.complexity === 'complex') {
|
|
_fetchEstimate(text);
|
|
}
|
|
_sessionSendHandler(text);
|
|
return;
|
|
}
|
|
|
|
// moderate or complex — fetch cost estimate (driven by complexity outcome),
|
|
// then route to server via WebSocket.
|
|
if (cls.complexity === 'moderate' || cls.complexity === 'complex') {
|
|
_fetchEstimate(text);
|
|
}
|
|
|
|
// Route to server via WebSocket
|
|
sendVisitorMessage(text);
|
|
appendSystemMessage(`you: ${text}`);
|
|
}
|
|
|
|
$sendBtn.addEventListener('click', send);
|
|
$input.addEventListener('keydown', e => {
|
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
|
|
});
|
|
}
|
|
|
|
export function updateUI({ fps, jobCount, connectionState }) {
|
|
if ($fps) $fps.textContent = `FPS: ${fps}`;
|
|
if ($activeJobs) $activeJobs.textContent = `JOBS: ${jobCount}`;
|
|
|
|
if ($connStatus) {
|
|
if (connectionState === 'connected') {
|
|
$connStatus.textContent = '● CONNECTED';
|
|
$connStatus.className = 'connected';
|
|
} else if (connectionState === 'connecting') {
|
|
$connStatus.textContent = '◌ CONNECTING...';
|
|
$connStatus.className = '';
|
|
} else {
|
|
$connStatus.textContent = '○ OFFLINE';
|
|
$connStatus.className = '';
|
|
}
|
|
}
|
|
}
|
|
|
|
export function appendSystemMessage(text) {
|
|
if (!$log) return;
|
|
const el = document.createElement('div');
|
|
el.className = 'log-entry';
|
|
el.textContent = text;
|
|
logEntries.push(el);
|
|
if (logEntries.length > MAX_LOG) {
|
|
const removed = logEntries.shift();
|
|
$log.removeChild(removed);
|
|
}
|
|
$log.appendChild(el);
|
|
$log.scrollTop = $log.scrollHeight;
|
|
}
|
|
|
|
export function appendChatMessage(agentLabel, message, cssColor, agentId) {
|
|
void agentLabel; void cssColor; void agentId;
|
|
appendSystemMessage(message);
|
|
}
|
|
|
|
/**
|
|
* Render a debate argument or verdict in the event log (#21).
|
|
* Visually distinct from regular chat: colored by agent with a debate prefix.
|
|
*/
|
|
export function appendDebateMessage(agent, argument, isVerdict, accepted) {
|
|
if (!$log) return;
|
|
const el = document.createElement('div');
|
|
el.className = 'log-entry debate-entry';
|
|
if (isVerdict) {
|
|
el.classList.add('debate-verdict');
|
|
el.classList.add(accepted ? 'debate-accepted' : 'debate-rejected');
|
|
el.textContent = `⚖ ${agent}: ${argument}`;
|
|
} else {
|
|
el.classList.add(agent === 'Beta-A' ? 'debate-a' : 'debate-b');
|
|
el.textContent = `⚖ ${agent}: ${(argument || '').slice(0, 120)}`;
|
|
}
|
|
logEntries.push(el);
|
|
if (logEntries.length > MAX_LOG) {
|
|
const removed = logEntries.shift();
|
|
$log.removeChild(removed);
|
|
}
|
|
$log.appendChild(el);
|
|
$log.scrollTop = $log.scrollHeight;
|
|
}
|
|
|
|
export function loadChatHistory() { return []; }
|
|
export function saveChatHistory() {}
|
|
|
|
// ── Task decomposition checklist panel (#5) ───────────────────────────────────
|
|
// Appears near the active Gamma agent during job execution.
|
|
// Steps are checked off as streaming progresses, then collapses to "Done".
|
|
|
|
let $stepsPanel = null;
|
|
let _stepElements = [];
|
|
let _collapseTimer = null;
|
|
|
|
function _ensureStepsPanel() {
|
|
if ($stepsPanel) return $stepsPanel;
|
|
$stepsPanel = document.getElementById('timmy-steps-panel');
|
|
if (!$stepsPanel) {
|
|
$stepsPanel = document.createElement('div');
|
|
$stepsPanel.id = 'timmy-steps-panel';
|
|
$stepsPanel.style.cssText = [
|
|
'position:fixed;bottom:80px;right:16px',
|
|
'background:rgba(0,20,40,0.88);border:1px solid #224466',
|
|
'border-radius:6px;padding:8px 12px',
|
|
'font-size:11px;font-family:"Courier New",monospace',
|
|
'color:#88bbdd;min-width:200px;max-width:280px',
|
|
'pointer-events:none;z-index:10',
|
|
'transition:opacity .4s;opacity:0',
|
|
].join(';');
|
|
document.body.appendChild($stepsPanel);
|
|
}
|
|
return $stepsPanel;
|
|
}
|
|
|
|
/**
|
|
* Show the checklist panel with the given step labels.
|
|
* Called on `job_steps` WebSocket message.
|
|
*/
|
|
export function showJobSteps(steps) {
|
|
clearTimeout(_collapseTimer);
|
|
const panel = _ensureStepsPanel();
|
|
|
|
// Clear previous content
|
|
panel.innerHTML = '';
|
|
_stepElements = [];
|
|
|
|
const header = document.createElement('div');
|
|
header.textContent = '⚙ Working…';
|
|
header.style.cssText = 'color:#aaccee;font-weight:bold;margin-bottom:5px;font-size:10px;letter-spacing:1px;';
|
|
panel.appendChild(header);
|
|
|
|
steps.forEach((label, i) => {
|
|
const row = document.createElement('div');
|
|
row.style.cssText = 'display:flex;align-items:center;gap:5px;margin:2px 0;transition:color .3s;';
|
|
|
|
const check = document.createElement('span');
|
|
check.textContent = '○';
|
|
check.style.cssText = 'min-width:12px;color:#336655;';
|
|
|
|
const text = document.createElement('span');
|
|
text.textContent = label;
|
|
|
|
row.appendChild(check);
|
|
row.appendChild(text);
|
|
panel.appendChild(row);
|
|
_stepElements.push({ row, check, text, index: i });
|
|
});
|
|
|
|
panel.style.opacity = '1';
|
|
}
|
|
|
|
/**
|
|
* Advance the visual state of steps.
|
|
* activeStep = currently running step index (-1 = all done).
|
|
* completedSteps = array of step indices that are finished.
|
|
* Called on `job_step_update` WebSocket message.
|
|
*/
|
|
export function updateJobStep(activeStep, completedSteps) {
|
|
if (!$stepsPanel || _stepElements.length === 0) return;
|
|
|
|
const completedSet = new Set(completedSteps);
|
|
|
|
_stepElements.forEach(({ row, check, text, index }) => {
|
|
if (completedSet.has(index)) {
|
|
// Completed: green checkmark
|
|
check.textContent = '✓';
|
|
check.style.color = '#44cc88';
|
|
text.style.color = '#667788';
|
|
row.style.opacity = '0.7';
|
|
} else if (index === activeStep) {
|
|
// Active: pulsing indicator
|
|
check.textContent = '▶';
|
|
check.style.color = '#ffcc44';
|
|
text.style.color = '#eeddaa';
|
|
row.style.opacity = '1';
|
|
// Pulse animation via inline keyframe trick
|
|
row.style.animation = 'none';
|
|
void row.offsetHeight; // reflow
|
|
check.style.transition = 'opacity 0.5s';
|
|
} else {
|
|
// Pending
|
|
check.textContent = '○';
|
|
check.style.color = '#336655';
|
|
text.style.color = '#88bbdd';
|
|
row.style.opacity = '1';
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Collapse the checklist panel to a single "Done" line, then fade out.
|
|
* Called on `job_completed` WebSocket message.
|
|
*/
|
|
export function collapseJobSteps() {
|
|
if (!$stepsPanel) return;
|
|
clearTimeout(_collapseTimer);
|
|
|
|
// Replace content with a Done summary
|
|
$stepsPanel.innerHTML = '';
|
|
const done = document.createElement('div');
|
|
done.textContent = '✓ Done';
|
|
done.style.cssText = 'color:#44cc88;font-weight:bold;letter-spacing:1px;';
|
|
$stepsPanel.appendChild(done);
|
|
$stepsPanel.style.opacity = '1';
|
|
|
|
// Fade out after 2.5 s
|
|
_collapseTimer = setTimeout(() => {
|
|
if ($stepsPanel) $stepsPanel.style.opacity = '0';
|
|
_stepElements = [];
|
|
}, 2500);
|
|
}
|