feat(epic222): Workshop — Timmy as wizard presence, world state, WS bootstrap (#31)

This commit is contained in:
2026-03-18 22:15:46 -04:00
parent ea4cddc2ad
commit a70898e939
13 changed files with 863 additions and 703 deletions

View File

@@ -0,0 +1,52 @@
export interface TimmyState {
mood: string;
activity: string;
}
export interface WorldState {
timmyState: TimmyState;
agentStates: Record<string, string>;
updatedAt: string;
}
const DEFAULT_TIMMY: TimmyState = {
mood: "contemplative",
activity: "idle",
};
const _state: WorldState = {
timmyState: { ...DEFAULT_TIMMY },
agentStates: { alpha: "idle", beta: "idle", gamma: "idle", delta: "idle" },
updatedAt: new Date().toISOString(),
};
export function getWorldState(): WorldState {
return {
timmyState: { ..._state.timmyState },
agentStates: { ..._state.agentStates },
updatedAt: _state.updatedAt,
};
}
export function setAgentStateInWorld(agentId: string, agentState: string): void {
_state.agentStates[agentId] = agentState;
_state.updatedAt = new Date().toISOString();
_deriveTimmy();
}
function _deriveTimmy(): void {
const states = Object.values(_state.agentStates);
if (states.includes("working")) {
_state.timmyState.activity = "working";
_state.timmyState.mood = "focused";
} else if (states.includes("thinking") || states.includes("evaluating")) {
_state.timmyState.activity = "thinking";
_state.timmyState.mood = "curious";
} else if (states.some((s) => s !== "idle")) {
_state.timmyState.activity = "active";
_state.timmyState.mood = "attentive";
} else {
_state.timmyState.activity = "idle";
_state.timmyState.mood = "contemplative";
}
}

View File

@@ -1,40 +1,77 @@
/**
* /api/ws — WebSocket bridge from the internal EventBus to connected Matrix clients.
* /api/ws — WebSocket bridge from the internal EventBus to connected Workshop clients.
*
* Protocol (server → client):
* { type: "world_state", timmyState, agentStates, recentEvents, updatedAt }
* { type: "agent_state", agentId: string, state: "idle"|"active"|"working"|"thinking" }
* { type: "job_started", jobId: string, agentId: string }
* { type: "job_completed", jobId: string, agentId: string }
* { type: "chat", agentId: string, text: string }
* { type: "visitor_count", count: number }
* { type: "ping" }
*
* Protocol (client → server):
* { type: "subscribe", channel: "agents", clientId: string }
* { type: "visitor_enter", visitorId: string, visitorName?: string }
* { type: "visitor_leave", visitorId: string }
* { type: "visitor_message", visitorId: string, text: string }
* { type: "pong" }
*
* Agent mapping (Matrix IDs → Timmy roles):
* Agent mapping (Workshop):
* alpha — orchestrator (overall job lifecycle)
* beta — eval (Haiku judge)
* gamma — work (Sonnet executor)
* delta — lightning (payment monitor / invoice watcher)
*/
import { randomUUID } from "crypto";
import type { IncomingMessage } from "http";
import type { WebSocket } from "ws";
import { WebSocketServer } from "ws";
import type { Server } from "http";
import { eventBus, type BusEvent } from "../lib/event-bus.js";
import { makeLogger } from "../lib/logger.js";
import { getWorldState, setAgentStateInWorld } from "../lib/world-state.js";
import { db, worldEvents } from "@workspace/db";
const logger = makeLogger("ws-events");
const PING_INTERVAL_MS = 30_000;
function updateAgentWorld(agentId: string, state: string): void {
try {
setAgentStateInWorld(agentId, state);
} catch {
/* non-fatal */
}
}
async function logWorldEvent(
type: string,
summary: string,
agentId?: string,
jobId?: string,
): Promise<void> {
try {
await db.insert(worldEvents).values({
id: randomUUID(),
type,
summary,
agentId: agentId ?? null,
jobId: jobId ?? null,
});
} catch {
/* non-fatal — don't crash WS on DB error */
}
}
function translateEvent(ev: BusEvent): object | null {
switch (ev.type) {
// ── Mode 1 job lifecycle ─────────────────────────────────────────────────
case "job:state": {
if (ev.state === "evaluating") {
updateAgentWorld("alpha", "active");
updateAgentWorld("beta", "thinking");
void logWorldEvent("job:evaluating", `Evaluating job ${ev.jobId.slice(0, 8)}`, "beta", ev.jobId);
return [
{ type: "agent_state", agentId: "alpha", state: "active" },
{ type: "agent_state", agentId: "beta", state: "thinking" },
@@ -42,21 +79,30 @@ function translateEvent(ev: BusEvent): object | null {
];
}
if (ev.state === "awaiting_eval_payment") {
updateAgentWorld("alpha", "active");
return { type: "agent_state", agentId: "alpha", state: "active" };
}
if (ev.state === "awaiting_work_payment") {
updateAgentWorld("beta", "idle");
updateAgentWorld("delta", "active");
return [
{ type: "agent_state", agentId: "beta", state: "idle" },
{ type: "agent_state", agentId: "delta", state: "active" },
];
}
if (ev.state === "executing") {
updateAgentWorld("delta", "idle");
updateAgentWorld("gamma", "working");
void logWorldEvent("job:executing", `Working on job ${ev.jobId.slice(0, 8)}`, "gamma", ev.jobId);
return [
{ type: "agent_state", agentId: "delta", state: "idle" },
{ type: "agent_state", agentId: "gamma", state: "working" },
];
}
if (ev.state === "complete") {
updateAgentWorld("gamma", "idle");
updateAgentWorld("alpha", "idle");
void logWorldEvent("job:complete", `Completed job ${ev.jobId.slice(0, 8)}`, "alpha", ev.jobId);
return [
{ type: "agent_state", agentId: "gamma", state: "idle" },
{ type: "agent_state", agentId: "alpha", state: "idle" },
@@ -64,6 +110,8 @@ function translateEvent(ev: BusEvent): object | null {
];
}
if (ev.state === "rejected" || ev.state === "failed") {
["alpha", "beta", "gamma", "delta"].forEach(a => updateAgentWorld(a, "idle"));
void logWorldEvent(`job:${ev.state}`, `Job ${ev.jobId.slice(0, 8)} ${ev.state}`, "alpha", ev.jobId);
return [
{ type: "agent_state", agentId: "beta", state: "idle" },
{ type: "agent_state", agentId: "gamma", state: "idle" },
@@ -74,12 +122,15 @@ function translateEvent(ev: BusEvent): object | null {
return null;
}
case "job:completed":
updateAgentWorld("gamma", "idle");
updateAgentWorld("alpha", "idle");
return [
{ type: "agent_state", agentId: "gamma", state: "idle" },
{ type: "agent_state", agentId: "alpha", state: "idle" },
{ type: "chat", agentId: "gamma", text: `Job ${ev.jobId.slice(0, 8)} complete` },
];
case "job:failed":
["alpha", "beta", "gamma", "delta"].forEach(a => updateAgentWorld(a, "idle"));
return [
{ type: "agent_state", agentId: "alpha", state: "idle" },
{ type: "agent_state", agentId: "beta", state: "idle" },
@@ -89,6 +140,9 @@ function translateEvent(ev: BusEvent): object | null {
];
case "job:paid":
if (ev.invoiceType === "eval") {
updateAgentWorld("delta", "idle");
updateAgentWorld("beta", "thinking");
void logWorldEvent("payment:eval", "Eval payment confirmed", "delta");
return [
{ type: "agent_state", agentId: "delta", state: "idle" },
{ type: "agent_state", agentId: "beta", state: "thinking" },
@@ -96,6 +150,9 @@ function translateEvent(ev: BusEvent): object | null {
];
}
if (ev.invoiceType === "work") {
updateAgentWorld("delta", "idle");
updateAgentWorld("gamma", "working");
void logWorldEvent("payment:work", "Work payment confirmed", "delta");
return [
{ type: "agent_state", agentId: "delta", state: "idle" },
{ type: "agent_state", agentId: "gamma", state: "working" },
@@ -146,6 +203,25 @@ function broadcast(socket: WebSocket, ev: BusEvent): void {
}
}
async function sendWorldStateBootstrap(socket: WebSocket): Promise<void> {
try {
const state = getWorldState();
const { desc } = await import("drizzle-orm");
const recent = await db
.select()
.from(worldEvents)
.orderBy(desc(worldEvents.createdAt))
.limit(20);
send(socket, {
type: "world_state",
...state,
recentEvents: recent.reverse(),
});
} catch {
send(socket, { type: "world_state", ...getWorldState(), recentEvents: [] });
}
}
export function attachWebSocketServer(server: Server): void {
const wss = new WebSocketServer({ server, path: "/api/ws" });
@@ -153,6 +229,8 @@ export function attachWebSocketServer(server: Server): void {
const ip = req.headers["x-forwarded-for"] ?? req.socket.remoteAddress ?? "unknown";
logger.info("ws client connected", { ip, clients: wss.clients.size });
void sendWorldStateBootstrap(socket);
const busHandler = (ev: BusEvent) => broadcast(socket, ev);
eventBus.on("bus", busHandler);
@@ -162,11 +240,34 @@ export function attachWebSocketServer(server: Server): void {
socket.on("message", (raw) => {
try {
const msg = JSON.parse(raw.toString()) as { type?: string };
const msg = JSON.parse(raw.toString()) as { type?: string; text?: string; visitorId?: string };
if (msg.type === "pong") return;
if (msg.type === "subscribe") {
send(socket, { type: "agent_count", count: wss.clients.size });
}
if (msg.type === "visitor_enter") {
wss.clients.forEach(c => {
if (c !== socket && c.readyState === 1) {
c.send(JSON.stringify({ type: "visitor_count", count: wss.clients.size }));
}
});
send(socket, { type: "visitor_count", count: wss.clients.size });
}
if (msg.type === "visitor_leave") {
wss.clients.forEach(c => {
if (c !== socket && c.readyState === 1) {
c.send(JSON.stringify({ type: "visitor_count", count: Math.max(0, wss.clients.size - 1) }));
}
});
}
if (msg.type === "visitor_message" && msg.text) {
const text = String(msg.text).slice(0, 500);
wss.clients.forEach(c => {
if (c.readyState === 1) {
c.send(JSON.stringify({ type: "chat", agentId: "visitor", text }));
}
});
}
} catch {
/* ignore malformed messages */
}

View File

@@ -9,6 +9,7 @@ import testkitRouter from "./testkit.js";
import uiRouter from "./ui.js";
import nodeDiagnosticsRouter from "./node-diagnostics.js";
import metricsRouter from "./metrics.js";
import worldRouter from "./world.js";
const router: IRouter = Router();
@@ -21,6 +22,7 @@ router.use(demoRouter);
router.use(testkitRouter);
router.use(uiRouter);
router.use(nodeDiagnosticsRouter);
router.use(worldRouter);
if (process.env.NODE_ENV !== "production") {
router.use(devRouter);

View File

@@ -0,0 +1,29 @@
import { Router, type Request, type Response } from "express";
import { db, worldEvents } from "@workspace/db";
import { desc } from "drizzle-orm";
import { getWorldState } from "../lib/world-state.js";
import { makeLogger } from "../lib/logger.js";
const logger = makeLogger("world");
const router = Router();
router.get("/world/state", async (_req: Request, res: Response) => {
try {
const state = getWorldState();
const recent = await db
.select()
.from(worldEvents)
.orderBy(desc(worldEvents.createdAt))
.limit(20);
res.json({
...state,
recentEvents: recent.reverse(),
});
} catch (err) {
logger.error("GET /api/world/state failed", { error: String(err) });
res.status(500).json({ error: "world_state_error" });
}
});
export default router;

View File

@@ -3,4 +3,5 @@ export * from "./invoices";
export * from "./conversations";
export * from "./messages";
export * from "./bootstrap-jobs";
export * from "./world-events";
export * from "./sessions";

View File

@@ -0,0 +1,10 @@
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
export const worldEvents = pgTable("world_events", {
id: text("id").primaryKey(),
type: text("type").notNull(),
agentId: text("agent_id"),
summary: text("summary").notNull(),
jobId: text("job_id"),
createdAt: timestamp("created_at").notNull().defaultNow(),
});

View File

@@ -2,236 +2,256 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Timmy Tower World</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>The Workshop — Timmy</title>
<link rel="manifest" href="/tower/manifest.json" />
<meta name="theme-color" content="#00ff41" />
<meta name="theme-color" content="#0a0610" />
<!-- iOS PWA -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Timmy Tower World" />
<meta name="apple-mobile-web-app-title" content="The Workshop" />
<link rel="apple-touch-icon" href="/tower/icons/icon-192.png" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #000; overflow: hidden; font-family: 'Courier New', monospace; }
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #080610;
overflow: hidden;
font-family: 'Courier New', monospace;
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
canvas { display: block; }
/* ── HUD ──────────────────────────────────────────────────────────────── */
#ui-overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none; z-index: 10;
}
/* ── HUD ─────────────────────────────────────────────────────────── */
#hud {
position: fixed; top: 16px; left: 16px;
color: #00ff41; font-size: 12px; line-height: 1.6;
text-shadow: 0 0 8px #00ff41;
pointer-events: none;
color: #5588bb; font-size: 11px; line-height: 1.7;
text-shadow: 0 0 6px #2244aa;
pointer-events: none; z-index: 10;
}
#hud h1 { font-size: 16px; letter-spacing: 4px; margin-bottom: 8px; color: #00ff88; }
#status-panel {
position: fixed; top: 16px; right: 16px;
color: #00ff41; font-size: 11px; line-height: 1.8;
text-shadow: 0 0 6px #00ff41; max-width: 240px;
#hud h1 {
font-size: 13px; letter-spacing: 3px; margin-bottom: 4px;
color: #7799cc; text-shadow: 0 0 10px #4466aa;
}
#chat-panel {
position: fixed; bottom: 56px; left: 16px;
width: 320px; max-height: 160px; overflow-y: auto;
color: #00ff41; font-size: 11px; line-height: 1.6;
text-shadow: 0 0 4px #00ff41;
pointer-events: none;
}
.chat-entry { opacity: 0.85; }
.chat-entry .agent-name { color: #00ff88; font-weight: bold; }
.chat-ts { color: #004d18; font-size: 10px; }
#connection-status {
position: fixed; bottom: 16px; right: 16px;
font-size: 11px; color: #555;
pointer-events: none;
position: fixed; top: 16px; right: 16px;
font-size: 11px; color: #333355;
pointer-events: none; z-index: 10;
text-shadow: none;
}
#connection-status.connected { color: #00ff41; text-shadow: 0 0 6px #00ff41; }
#chat-clear-btn {
position: fixed; bottom: 16px; right: 110px;
font-family: 'Courier New', monospace;
font-size: 10px; color: #004d18;
background: transparent; border: 1px solid #004d18;
padding: 2px 6px; cursor: pointer;
pointer-events: all; z-index: 20;
transition: color 0.2s, border-color 0.2s;
}
#chat-clear-btn:hover { color: #00ff41; border-color: #00ff41; }
/* WebGL context-loss recovery overlay */
#webgl-recovery-overlay {
display: none;
position: fixed; inset: 0; z-index: 200;
background: rgba(0, 0, 0, 0.88);
justify-content: center; align-items: center;
pointer-events: none;
}
#webgl-recovery-overlay .recovery-text {
color: #00ff41; font-family: 'Courier New', monospace;
font-size: 16px; letter-spacing: 3px;
text-shadow: 0 0 18px #00ff41, 0 0 6px #00ff41;
animation: ctx-blink 1.2s step-end infinite;
}
@keyframes ctx-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.25; }
#connection-status.connected {
color: #5588bb;
text-shadow: 0 0 6px #3366aa;
}
/* ── Open panel button ────────────────────────────────────────────────── */
/* ── Event log ────────────────────────────────────────────────────── */
#event-log {
position: fixed; bottom: 80px; left: 16px;
width: 280px; max-height: 100px; overflow-y: auto;
color: #445566; font-size: 10px; line-height: 1.6;
pointer-events: none; z-index: 10;
}
.log-entry { opacity: 0.7; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* ── Open payment panel button ───────────────────────────────────── */
#open-panel-btn {
position: fixed; bottom: 16px; left: 16px;
font-family: 'Courier New', monospace; font-size: 12px; font-weight: bold;
color: #000; background: #00ff88; border: none;
padding: 8px 18px; cursor: pointer; z-index: 20; letter-spacing: 2px;
box-shadow: 0 0 16px #00ff88, 0 0 4px #00ff41;
position: fixed; top: 16px; left: 50%; transform: translateX(-50%);
font-family: 'Courier New', monospace; font-size: 11px; font-weight: bold;
color: #000; background: #4466aa; border: none;
padding: 7px 18px; cursor: pointer; z-index: 20; letter-spacing: 2px;
box-shadow: 0 0 14px #2244aa66;
transition: background 0.15s, box-shadow 0.15s;
border-radius: 2px;
min-height: 36px;
}
#open-panel-btn:hover {
background: #00ffcc;
box-shadow: 0 0 24px #00ffcc, 0 0 8px #00ff88;
#open-panel-btn:hover, #open-panel-btn:active {
background: #5577cc;
box-shadow: 0 0 20px #3355aa88;
}
/* ── Payment panel ────────────────────────────────────────────────────── */
/* ── Input bar ───────────────────────────────────────────────────── */
#input-bar {
position: fixed; bottom: 0; left: 0; right: 0;
display: flex; align-items: center; gap: 8px;
padding: 10px 16px;
background: rgba(8, 6, 16, 0.88);
border-top: 1px solid #1a1a2e;
z-index: 20;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
#visitor-input {
flex: 1;
background: rgba(20, 16, 36, 0.9);
border: 1px solid #2a2a44;
color: #aabbdd;
font-family: 'Courier New', monospace;
font-size: 14px;
padding: 10px 12px;
outline: none;
min-height: 44px;
border-radius: 3px;
transition: border-color 0.2s;
-webkit-appearance: none;
}
#visitor-input::placeholder { color: #333355; }
#visitor-input:focus { border-color: #4466aa; }
#send-btn {
background: rgba(30, 40, 80, 0.9);
border: 1px solid #2a2a44;
color: #5577aa;
font-family: 'Courier New', monospace;
font-size: 16px;
width: 44px; height: 44px;
cursor: pointer;
border-radius: 3px;
transition: background 0.15s, border-color 0.15s, color 0.15s;
display: flex; align-items: center; justify-content: center;
}
#send-btn:hover, #send-btn:active {
background: rgba(50, 70, 140, 0.9);
border-color: #4466aa;
color: #88aadd;
}
/* ── Payment panel ────────────────────────────────────────────────── */
#payment-panel {
position: fixed; top: 0; right: -420px;
width: 400px; height: 100%;
background: rgba(0, 4, 0, 0.95);
border-left: 1px solid #004d18;
background: rgba(5, 3, 12, 0.97);
border-left: 1px solid #1a1a2e;
padding: 24px 20px;
overflow-y: auto; z-index: 100;
font-family: 'Courier New', monospace;
transition: right 0.35s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: -8px 0 32px rgba(0,255,65,0.1);
box-shadow: -8px 0 32px rgba(40, 60, 120, 0.15);
}
#payment-panel.open { right: 0; }
#payment-panel h2 {
font-size: 14px; letter-spacing: 4px; color: #00ff88;
text-shadow: 0 0 12px #00ff88;
margin-bottom: 20px; border-bottom: 1px solid #004d18; padding-bottom: 10px;
font-size: 13px; letter-spacing: 3px; color: #6688bb;
text-shadow: 0 0 10px #2244aa;
margin-bottom: 20px; border-bottom: 1px solid #1a1a2e; padding-bottom: 10px;
}
#payment-close {
position: absolute; top: 16px; right: 16px;
background: transparent; border: 1px solid #004d18;
color: #004d18; font-family: 'Courier New', monospace;
background: transparent; border: 1px solid #1a1a2e;
color: #333355; font-family: 'Courier New', monospace;
font-size: 16px; width: 28px; height: 28px;
cursor: pointer; transition: color 0.2s, border-color 0.2s;
}
#payment-close:hover { color: #00ff41; border-color: #00ff41; }
#payment-close:hover { color: #6688bb; border-color: #4466aa; }
.panel-label {
font-size: 10px; letter-spacing: 2px; color: #007722;
margin-bottom: 6px; margin-top: 16px;
}
.panel-label { font-size: 10px; letter-spacing: 2px; color: #334466; margin-bottom: 6px; margin-top: 16px; }
#job-input {
width: 100%; background: #000d00; border: 1px solid #004d18;
color: #00ff41; font-family: 'Courier New', monospace; font-size: 12px;
width: 100%; background: #060310; border: 1px solid #1a1a2e;
color: #aabbdd; font-family: 'Courier New', monospace; font-size: 12px;
padding: 10px; resize: vertical; min-height: 90px;
outline: none; transition: border-color 0.2s;
}
#job-input:focus { border-color: #00ff88; }
#job-input::placeholder { color: #004d18; }
#job-input:focus { border-color: #4466aa; }
#job-input::placeholder { color: #1a1a2e; }
.panel-btn {
width: 100%; margin-top: 12px;
background: transparent; border: 1px solid #00ff41;
color: #00ff41; font-family: 'Courier New', monospace;
background: transparent; border: 1px solid #334466;
color: #5577aa; font-family: 'Courier New', monospace;
font-size: 12px; letter-spacing: 2px; padding: 10px;
cursor: pointer; transition: all 0.2s;
}
.panel-btn:hover:not(:disabled) {
background: #00ff41; color: #000; box-shadow: 0 0 16px #00ff41;
}
.panel-btn:hover:not(:disabled) { background: #334466; color: #aabbdd; }
.panel-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.panel-btn.primary {
border-color: #00ff88; color: #00ff88;
}
.panel-btn.primary:hover:not(:disabled) {
background: #00ff88; color: #000; box-shadow: 0 0 20px #00ff88;
}
.panel-btn.danger {
border-color: #ff6600; color: #ff6600;
}
.panel-btn.primary { border-color: #4466aa; color: #7799cc; }
.panel-btn.primary:hover:not(:disabled) { background: #4466aa; color: #fff; }
.panel-btn.danger { border-color: #663333; color: #995555; }
#job-status { font-size: 11px; margin-top: 8px; color: #00ff41; min-height: 16px; }
#job-error { font-size: 11px; margin-top: 4px; min-height: 16px; }
#job-status { font-size: 11px; margin-top: 8px; color: #5577aa; min-height: 16px; }
#job-error { font-size: 11px; margin-top: 4px; min-height: 16px; color: #994444; }
.invoice-box {
background: #000d00; border: 1px solid #004d18;
padding: 10px; margin-top: 8px;
font-size: 10px; color: #007722;
background: #060310; border: 1px solid #1a1a2e;
padding: 10px; margin-top: 8px; font-size: 10px; color: #334466;
word-break: break-all; max-height: 80px; overflow-y: auto;
}
.copy-row { display: flex; gap: 8px; margin-top: 6px; align-items: stretch; }
.copy-row .invoice-box { flex: 1; margin-top: 0; }
.copy-btn {
background: transparent; border: 1px solid #004d18; color: #004d18;
font-family: 'Courier New', monospace; font-size: 10px; letter-spacing: 1px;
background: transparent; border: 1px solid #1a1a2e; color: #334466;
font-family: 'Courier New', monospace; font-size: 10px;
padding: 0 10px; cursor: pointer; transition: all 0.2s; white-space: nowrap;
}
.copy-btn:hover { border-color: #00ff41; color: #00ff41; }
.copy-btn:hover { border-color: #4466aa; color: #6688bb; }
.amount-tag {
display: inline-block; background: #001a00;
border: 1px solid #007722; color: #00ff88;
display: inline-block; background: #0a0820;
border: 1px solid #334466; color: #6688bb;
font-size: 16px; font-weight: bold; letter-spacing: 2px;
padding: 6px 14px; margin-top: 8px;
text-shadow: 0 0 8px #00ff88;
}
#job-result {
background: #000d00; border: 1px solid #004d18;
color: #00ff41; padding: 12px; font-size: 12px;
background: #060310; border: 1px solid #1a1a2e;
color: #aabbdd; padding: 12px; font-size: 12px;
line-height: 1.6; margin-top: 8px;
white-space: pre-wrap; max-height: 260px; overflow-y: auto;
}
/* api-ui link */
.panel-link {
display: block; text-align: center; margin-top: 20px;
font-size: 10px; letter-spacing: 1px; color: #004d18;
font-size: 10px; letter-spacing: 1px; color: #1a1a2e;
text-decoration: none; transition: color 0.2s;
}
.panel-link:hover { color: #00ff41; }
.panel-link:hover { color: #5577aa; }
/* ── WebGL recovery overlay ──────────────────────────────────────── */
#webgl-recovery-overlay {
display: none; position: fixed; inset: 0; z-index: 200;
background: rgba(5, 3, 12, 0.92);
justify-content: center; align-items: center;
pointer-events: none;
}
#webgl-recovery-overlay .recovery-text {
color: #5577aa; font-family: 'Courier New', monospace;
font-size: 15px; letter-spacing: 3px;
animation: ctx-blink 1.2s step-end infinite;
}
@keyframes ctx-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.2; }
}
</style>
</head>
<body>
<div id="ui-overlay">
<div id="hud">
<h1>TIMMY TOWER WORLD</h1>
<div id="agent-count">AGENTS: 0</div>
<div id="active-jobs">JOBS: 0</div>
<div id="fps">FPS: --</div>
</div>
<div id="status-panel">
<div id="agent-list"></div>
</div>
<div id="chat-panel"></div>
<div id="connection-status">OFFLINE</div>
<div id="hud">
<h1>THE WORKSHOP</h1>
<div id="fps">FPS: --</div>
<div id="active-jobs">JOBS: 0</div>
</div>
<button id="open-panel-btn">⚡ SUBMIT JOB</button>
<button id="chat-clear-btn" title="Clear chat history">CLEAR</button>
<div id="connection-status">OFFLINE</div>
<div id="event-log"></div>
<!-- ── Payment panel ──────────────────────────────────────────────────── -->
<button id="open-panel-btn">⚡ SUBMIT JOB</button>
<!-- ── Input bar ──────────────────────────────────────────────────── -->
<div id="input-bar">
<input type="text" id="visitor-input" placeholder="Say something to Timmy…" autocomplete="off" autocorrect="off" spellcheck="false" />
<button id="send-btn" aria-label="Send"></button>
</div>
<!-- ── Payment panel ──────────────────────────────────────────────── -->
<div id="payment-panel">
<button id="payment-close"></button>
<h2>⚡ TIMMY TOWER — JOB SUBMISSION</h2>
<h2>⚡ TIMMY — JOB SUBMISSION</h2>
<!-- Step: input -->
<div data-step="input">
<div class="panel-label">YOUR REQUEST</div>
<textarea id="job-input" maxlength="500"
placeholder="Ask Timmy anything… (max 500 chars)"></textarea>
<textarea id="job-input" maxlength="500" placeholder="Ask Timmy anything… (max 500 chars)"></textarea>
<button class="panel-btn primary" id="job-submit-btn">CREATE JOB →</button>
<a class="panel-link" href="/api/ui" target="_blank">Open full UI ↗</a>
</div>
<!-- Step: eval invoice -->
<div data-step="eval-invoice" style="display:none">
<div class="panel-label">EVAL FEE</div>
<div class="amount-tag" id="eval-amount">10 sats</div>
@@ -244,9 +264,8 @@
<button class="panel-btn primary" id="pay-eval-btn">⚡ SIMULATE PAYMENT</button>
</div>
<!-- Step: work invoice -->
<div data-step="work-invoice" style="display:none">
<div class="panel-label">WORK FEE (token-based)</div>
<div class="panel-label">WORK FEE</div>
<div class="amount-tag" id="work-amount">-- sats</div>
<div class="panel-label" style="margin-top:12px">LIGHTNING INVOICE</div>
<div class="copy-row">
@@ -257,16 +276,14 @@
<button class="panel-btn primary" id="pay-work-btn">⚡ SIMULATE PAYMENT</button>
</div>
<!-- Step: result -->
<div data-step="result" style="display:none">
<div class="panel-label" id="result-label">AI RESULT</div>
<pre id="job-result"></pre>
<button class="panel-btn" id="new-job-btn" style="margin-top:16px">← NEW JOB</button>
</div>
<!-- Shared status / error (outside steps so always visible) -->
<div id="job-status" style="font-size:11px;margin-top:12px;min-height:16px;"></div>
<div id="job-error" style="font-size:11px;margin-top:4px;min-height:16px;"></div>
<div id="job-status"></div>
<div id="job-error"></div>
</div>
<div id="webgl-recovery-overlay">

View File

@@ -1,237 +1,273 @@
import * as THREE from 'three';
import { AGENT_DEFS, colorToCss } from './agent-defs.js';
const agents = new Map();
let scene;
let connectionLines = [];
const TIMMY_POS = new THREE.Vector3(0, 0, -2);
const CRYSTAL_POS = new THREE.Vector3(0.6, 1.15, -4.1);
class Agent {
constructor(def) {
this.id = def.id;
this.label = def.label;
this.color = def.color;
this.role = def.role;
this.position = new THREE.Vector3(def.x, 0, def.z);
this.state = 'idle';
this.pulsePhase = Math.random() * Math.PI * 2;
const agentStates = { alpha: 'idle', beta: 'idle', gamma: 'idle', delta: 'idle' };
this.group = new THREE.Group();
this.group.position.copy(this.position);
this._buildMeshes();
this._buildLabel();
}
_buildMeshes() {
const mat = new THREE.MeshStandardMaterial({
color: this.color,
emissive: this.color,
emissiveIntensity: 0.4,
roughness: 0.3,
metalness: 0.8,
});
const geo = new THREE.IcosahedronGeometry(0.7, 1);
this.core = new THREE.Mesh(geo, mat);
this.group.add(this.core);
const ringGeo = new THREE.TorusGeometry(1.1, 0.04, 8, 32);
const ringMat = new THREE.MeshBasicMaterial({ color: this.color, transparent: true, opacity: 0.5 });
this.ring = new THREE.Mesh(ringGeo, ringMat);
this.ring.rotation.x = Math.PI / 2;
this.group.add(this.ring);
const glowGeo = new THREE.SphereGeometry(1.3, 16, 16);
const glowMat = new THREE.MeshBasicMaterial({
color: this.color,
transparent: true,
opacity: 0.05,
side: THREE.BackSide,
});
this.glow = new THREE.Mesh(glowGeo, glowMat);
this.group.add(this.glow);
const light = new THREE.PointLight(this.color, 1.5, 10);
this.group.add(light);
this.light = light;
}
_buildLabel() {
const canvas = document.createElement('canvas');
canvas.width = 256; canvas.height = 64;
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'rgba(0,0,0,0)';
ctx.fillRect(0, 0, 256, 64);
ctx.font = 'bold 22px Courier New';
ctx.fillStyle = colorToCss(this.color);
ctx.textAlign = 'center';
ctx.fillText(this.label, 128, 28);
ctx.font = '14px Courier New';
ctx.fillStyle = '#007722';
ctx.fillText(this.role.toUpperCase(), 128, 50);
const tex = new THREE.CanvasTexture(canvas);
const spriteMat = new THREE.SpriteMaterial({ map: tex, transparent: true });
this.sprite = new THREE.Sprite(spriteMat);
this.sprite.scale.set(2.4, 0.6, 1);
this.sprite.position.y = 2;
this.group.add(this.sprite);
}
update(time) {
const pulse = Math.sin(time * 0.002 + this.pulsePhase);
const pulse2 = Math.sin(time * 0.005 + this.pulsePhase * 1.3);
let intensity, lightIntensity, ringSpeed, glowOpacity, scaleAmp;
switch (this.state) {
case 'working':
intensity = 1.0 + pulse * 0.3;
lightIntensity = 3.5 + pulse2 * 0.8;
ringSpeed = 0.07;
glowOpacity = 0.18 + pulse * 0.08;
scaleAmp = 0.14;
break;
case 'thinking':
intensity = 0.7 + pulse2 * 0.5;
lightIntensity = 2.2 + pulse * 0.5;
ringSpeed = 0.045;
glowOpacity = 0.12 + pulse2 * 0.06;
scaleAmp = 0.10;
break;
case 'active':
intensity = 0.6 + pulse * 0.4;
lightIntensity = 2.0 + pulse;
ringSpeed = 0.03;
glowOpacity = 0.08 + pulse * 0.04;
scaleAmp = 0.08;
break;
default:
intensity = 0.2 + pulse * 0.1;
lightIntensity = 0.8 + pulse * 0.3;
ringSpeed = 0.008;
glowOpacity = 0.03 + pulse * 0.02;
scaleAmp = 0.03;
}
this.core.material.emissiveIntensity = intensity;
this.light.intensity = lightIntensity;
this.core.scale.setScalar(1 + pulse * scaleAmp);
this.ring.rotation.y += ringSpeed;
this.ring.material.opacity = 0.3 + pulse * 0.2;
this.glow.material.opacity = glowOpacity;
this.group.position.y = this.position.y + Math.sin(time * 0.001 + this.pulsePhase) * 0.15;
}
setState(state) {
this.state = state;
}
dispose() {
this.core.geometry.dispose();
this.core.material.dispose();
this.ring.geometry.dispose();
this.ring.material.dispose();
this.glow.geometry.dispose();
this.glow.material.dispose();
if (this.sprite.material.map) this.sprite.material.map.dispose();
this.sprite.material.dispose();
}
function deriveTimmyState() {
if (agentStates.gamma === 'working') return 'working';
if (agentStates.beta === 'thinking' || agentStates.alpha === 'thinking') return 'thinking';
if (Object.values(agentStates).some(s => s !== 'idle')) return 'active';
return 'idle';
}
let scene = null;
let timmy = null;
export function initAgents(sceneRef) {
scene = sceneRef;
AGENT_DEFS.forEach(def => {
const agent = new Agent(def);
agents.set(def.id, agent);
scene.add(agent.group);
});
buildConnectionLines();
timmy = buildTimmy(scene);
}
function buildConnectionLines() {
connectionLines.forEach(l => scene.remove(l));
connectionLines = [];
function buildTimmy(sc) {
const group = new THREE.Group();
group.position.copy(TIMMY_POS);
const agentList = [...agents.values()];
const lineMat = new THREE.LineBasicMaterial({
color: 0x003300,
const robeMat = new THREE.MeshStandardMaterial({ color: 0x2d1b4e, roughness: 0.82, metalness: 0.08 });
const robe = new THREE.Mesh(new THREE.CylinderGeometry(0.32, 0.72, 2.2, 8), robeMat);
robe.position.y = 1.1;
robe.castShadow = true;
group.add(robe);
const headMat = new THREE.MeshStandardMaterial({ color: 0xf2d0a0, roughness: 0.7 });
const head = new THREE.Mesh(new THREE.SphereGeometry(0.38, 16, 16), headMat);
head.position.y = 2.6;
head.castShadow = true;
group.add(head);
const hatMat = new THREE.MeshStandardMaterial({ color: 0x1a0a2e, roughness: 0.82 });
const brim = new THREE.Mesh(new THREE.TorusGeometry(0.46, 0.07, 6, 24), hatMat);
brim.position.y = 2.94;
brim.rotation.x = Math.PI / 2;
group.add(brim);
const hat = new THREE.Mesh(new THREE.ConeGeometry(0.33, 0.78, 8), hatMat.clone());
hat.position.y = 3.33;
group.add(hat);
const starMat = new THREE.MeshStandardMaterial({ color: 0xffd700, emissive: 0xffaa00, emissiveIntensity: 1.0 });
const star = new THREE.Mesh(new THREE.OctahedronGeometry(0.07, 0), starMat);
star.position.y = 3.76;
group.add(star);
const eyeMat = new THREE.MeshBasicMaterial({ color: 0x1a3a5c });
const eyeGeo = new THREE.SphereGeometry(0.07, 8, 8);
const eyeL = new THREE.Mesh(eyeGeo, eyeMat);
eyeL.position.set(-0.15, 2.65, 0.31);
group.add(eyeL);
const eyeR = new THREE.Mesh(eyeGeo, eyeMat.clone());
eyeR.position.set(0.15, 2.65, 0.31);
group.add(eyeR);
const beltMat = new THREE.MeshStandardMaterial({ color: 0xffd700, emissive: 0xaa8800, emissiveIntensity: 0.4 });
const belt = new THREE.Mesh(new THREE.TorusGeometry(0.52, 0.04, 6, 24), beltMat);
belt.position.y = 0.72;
belt.rotation.x = Math.PI / 2;
group.add(belt);
sc.add(group);
const crystalGroup = new THREE.Group();
crystalGroup.position.copy(CRYSTAL_POS);
const cbMat = new THREE.MeshPhysicalMaterial({
color: 0x88ddff,
emissive: 0x004466,
emissiveIntensity: 0.5,
roughness: 0.0,
metalness: 0.0,
transmission: 0.65,
transparent: true,
opacity: 0.4,
opacity: 0.88,
});
const cb = new THREE.Mesh(new THREE.SphereGeometry(0.34, 32, 32), cbMat);
crystalGroup.add(cb);
for (let i = 0; i < agentList.length; i++) {
for (let j = i + 1; j < agentList.length; j++) {
const a = agentList[i];
const b = agentList[j];
if (a.position.distanceTo(b.position) <= 8) {
const points = [a.position.clone(), b.position.clone()];
const geo = new THREE.BufferGeometry().setFromPoints(points);
const line = new THREE.Line(geo, lineMat.clone());
connectionLines.push(line);
scene.add(line);
}
}
}
const pedMat = new THREE.MeshStandardMaterial({ color: 0x4a3020, roughness: 0.85 });
const ped = new THREE.Mesh(new THREE.CylinderGeometry(0.14, 0.19, 0.24, 8), pedMat);
ped.position.y = -0.3;
crystalGroup.add(ped);
const crystalLight = new THREE.PointLight(0x44bbff, 0.8, 5);
crystalGroup.add(crystalLight);
sc.add(crystalGroup);
const pipMat = new THREE.MeshStandardMaterial({
color: 0xffaa00,
emissive: 0xff6600,
emissiveIntensity: 0.85,
roughness: 0.3,
});
const pip = new THREE.Mesh(new THREE.TetrahedronGeometry(0.17, 0), pipMat);
pip.position.set(2.5, 1.6, -1);
const pipLight = new THREE.PointLight(0xffaa00, 0.7, 5);
pip.add(pipLight);
sc.add(pip);
const bubbleCanvas = document.createElement('canvas');
bubbleCanvas.width = 512;
bubbleCanvas.height = 128;
const bubbleTex = new THREE.CanvasTexture(bubbleCanvas);
const bubbleMat = new THREE.SpriteMaterial({ map: bubbleTex, transparent: true, opacity: 0 });
const bubble = new THREE.Sprite(bubbleMat);
bubble.scale.set(3.2, 0.8, 1);
bubble.position.set(TIMMY_POS.x, 5.4, TIMMY_POS.z);
sc.add(bubble);
return {
group, robe, head, hat, star, cb, cbMat, crystalGroup, crystalLight,
pip, pipLight, pipMat,
bubble, bubbleCanvas, bubbleTex, bubbleMat,
pulsePhase: Math.random() * Math.PI * 2,
speechTimer: 0,
};
}
export function updateAgents(time) {
agents.forEach(agent => agent.update(time));
}
if (!timmy) return;
const t = time * 0.001;
const vs = deriveTimmyState();
const pulse = Math.sin(t * 1.8 + timmy.pulsePhase);
const pulse2 = Math.sin(t * 3.5 + timmy.pulsePhase * 1.5);
export function getAgentCount() {
return agents.size;
}
let bodyBob, headTilt, crystalI, cbPulseSpeed, robeGlow;
switch (vs) {
case 'working':
bodyBob = Math.sin(t * 4.2) * 0.11;
headTilt = Math.sin(t * 2.8) * 0.09;
crystalI = 3.8 + pulse * 1.4;
cbPulseSpeed = 3.2;
robeGlow = 0.18;
break;
case 'thinking':
bodyBob = Math.sin(t * 1.6) * 0.06;
headTilt = Math.sin(t * 1.1) * 0.13;
crystalI = 1.9 + pulse2 * 0.7;
cbPulseSpeed = 1.6;
robeGlow = 0.09;
break;
case 'active':
bodyBob = Math.sin(t * 2.2) * 0.07;
headTilt = Math.sin(t * 0.9) * 0.05;
crystalI = 1.3 + pulse * 0.5;
cbPulseSpeed = 1.3;
robeGlow = 0.05;
break;
default:
bodyBob = Math.sin(t * 0.85) * 0.04;
headTilt = Math.sin(t * 0.42) * 0.025;
crystalI = 0.45 + pulse * 0.2;
cbPulseSpeed = 0.55;
robeGlow = 0.0;
}
export function setAgentState(agentId, state) {
const agent = agents.get(agentId);
if (agent) agent.setState(state);
}
timmy.group.position.y = bodyBob;
timmy.head.rotation.z = headTilt;
export function getAgentDefs() {
return [...agents.values()].map(a => ({
id: a.id, label: a.label, role: a.role, color: a.color, state: a.state,
}));
}
timmy.crystalLight.intensity = crystalI;
timmy.cbMat.emissiveIntensity = 0.28 + (crystalI / 6) * 0.72;
timmy.crystalGroup.rotation.y += 0.004 * cbPulseSpeed;
const cbScale = 1 + Math.sin(t * cbPulseSpeed) * 0.022;
timmy.cb.scale.setScalar(cbScale);
/**
* Return a snapshot of each agent's current runtime state.
* Call before teardown so the state can be reapplied after reinit.
* @returns {Object.<string, string>} — e.g. { alpha: 'active', beta: 'idle' }
*/
export function getAgentStates() {
const snapshot = {};
agents.forEach((agent, id) => { snapshot[id] = agent.state; });
return snapshot;
}
timmy.robe.material.emissive = new THREE.Color(0x4400aa);
timmy.robe.material.emissiveIntensity = robeGlow;
/**
* Apply a previously captured state snapshot to freshly-created agents.
* Call immediately after initAgents() during context-restore reinit.
* @param {Object.<string, string>} snapshot
*/
export function applyAgentStates(snapshot) {
if (!snapshot) return;
for (const [id, state] of Object.entries(snapshot)) {
const agent = agents.get(id);
if (agent) agent.setState(state);
const pipX = Math.sin(t * 0.38 + 1.4) * 3.2;
const pipZ = Math.sin(t * 0.65 + 0.8) * 2.2 - 1.8;
const pipY = 1.55 + Math.sin(t * 1.6) * 0.32;
timmy.pip.position.set(pipX, pipY, pipZ);
timmy.pip.rotation.x += 0.022;
timmy.pip.rotation.y += 0.031;
timmy.pipLight.intensity = 0.55 + Math.sin(t * 2.1) * 0.2;
if (timmy.speechTimer > 0) {
timmy.speechTimer -= 0.016;
const fadeStart = 1.5;
timmy.bubbleMat.opacity = timmy.speechTimer > fadeStart ? 1.0 : Math.max(0, timmy.speechTimer / fadeStart);
if (timmy.speechTimer <= 0) timmy.bubbleMat.opacity = 0;
}
}
/**
* Dispose all agent GPU resources (geometries, materials, textures).
* Called before context-loss teardown.
*/
export function setAgentState(agentId, state) {
if (agentId in agentStates) agentStates[agentId] = state;
}
export function setSpeechBubble(text) {
if (!timmy) return;
const canvas = timmy.bubbleCanvas;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = 'rgba(8, 6, 16, 0.9)';
_roundRect(ctx, 6, 6, canvas.width - 12, canvas.height - 12, 14);
ctx.fill();
ctx.strokeStyle = '#5599dd';
ctx.lineWidth = 2;
_roundRect(ctx, 6, 6, canvas.width - 12, canvas.height - 12, 14);
ctx.stroke();
ctx.fillStyle = '#aaddff';
ctx.font = 'bold 21px Courier New';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const words = text.split(' ');
const lines = [];
let line = '';
for (const w of words) {
const test = line ? `${line} ${w}` : w;
if (test.length > 42) { lines.push(line); line = w; } else line = test;
}
if (line) lines.push(line);
const lineH = 30;
const startY = canvas.height / 2 - ((lines.length - 1) * lineH) / 2;
lines.slice(0, 3).forEach((l, i) => ctx.fillText(l, canvas.width / 2, startY + i * lineH));
timmy.bubbleTex.needsUpdate = true;
timmy.bubbleMat.opacity = 1;
timmy.speechTimer = 9;
}
function _roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
export function getAgentCount() { return 1; }
export function getAgentStates() { return { ...agentStates }; }
export function applyAgentStates(snapshot) {
if (!snapshot) return;
for (const [k, v] of Object.entries(snapshot)) {
if (k in agentStates) agentStates[k] = v;
}
}
export function getAgentDefs() {
return [{ id: 'timmy', label: 'TIMMY', role: 'wizard', color: 0x5599ff, state: deriveTimmyState() }];
}
export function disposeAgents() {
agents.forEach(agent => agent.dispose());
agents.clear();
connectionLines.forEach(l => {
l.geometry.dispose();
l.material.dispose();
if (!timmy) return;
[timmy.robe, timmy.head, timmy.hat, timmy.cb, timmy.ped, timmy.pip].forEach(m => {
if (m) { m.geometry?.dispose(); if (Array.isArray(m.material)) m.material.forEach(x => x.dispose()); else m.material?.dispose(); }
});
connectionLines = [];
timmy.bubbleTex?.dispose();
timmy.bubbleMat?.dispose();
timmy = null;
scene = null;
}

View File

@@ -1,99 +1,79 @@
import * as THREE from 'three';
let rainParticles;
let rainPositions;
let rainVelocities;
const RAIN_COUNT = 2000;
let dustParticles = null;
let dustPositions = null;
let dustVelocities = null;
const DUST_COUNT = 600;
export function initEffects(scene) {
initMatrixRain(scene);
initStarfield(scene);
initDustMotes(scene);
}
function initMatrixRain(scene) {
function initDustMotes(scene) {
const geo = new THREE.BufferGeometry();
const positions = new Float32Array(RAIN_COUNT * 3);
const velocities = new Float32Array(RAIN_COUNT);
const colors = new Float32Array(RAIN_COUNT * 3);
const positions = new Float32Array(DUST_COUNT * 3);
const colors = new Float32Array(DUST_COUNT * 3);
const velocities = new Float32Array(DUST_COUNT);
for (let i = 0; i < RAIN_COUNT; i++) {
positions[i * 3] = (Math.random() - 0.5) * 100;
positions[i * 3 + 1] = Math.random() * 50 + 5;
positions[i * 3 + 2] = (Math.random() - 0.5) * 100;
velocities[i] = 0.05 + Math.random() * 0.15;
for (let i = 0; i < DUST_COUNT; i++) {
positions[i * 3] = (Math.random() - 0.5) * 22;
positions[i * 3 + 1] = Math.random() * 10;
positions[i * 3 + 2] = (Math.random() - 0.5) * 16 - 2;
velocities[i] = 0.008 + Math.random() * 0.012;
const brightness = 0.3 + Math.random() * 0.7;
colors[i * 3] = 0;
colors[i * 3 + 1] = brightness;
colors[i * 3 + 2] = 0;
const roll = Math.random();
if (roll < 0.6) {
colors[i * 3] = 0.9 + Math.random() * 0.1;
colors[i * 3 + 1] = 0.7 + Math.random() * 0.2;
colors[i * 3 + 2] = 0.3 + Math.random() * 0.3;
} else {
const b = 0.3 + Math.random() * 0.5;
colors[i * 3] = 0;
colors[i * 3 + 1] = b;
colors[i * 3 + 2] = 0;
}
}
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
rainPositions = positions;
rainVelocities = velocities;
dustPositions = positions;
dustVelocities = velocities;
const mat = new THREE.PointsMaterial({
size: 0.12,
size: 0.06,
vertexColors: true,
transparent: true,
opacity: 0.7,
opacity: 0.55,
sizeAttenuation: true,
});
rainParticles = new THREE.Points(geo, mat);
scene.add(rainParticles);
dustParticles = new THREE.Points(geo, mat);
scene.add(dustParticles);
}
function initStarfield(scene) {
const count = 500;
const geo = new THREE.BufferGeometry();
const positions = new Float32Array(count * 3);
export function updateEffects(time) {
if (!dustParticles) return;
const t = time * 0.001;
for (let i = 0; i < count; i++) {
positions[i * 3] = (Math.random() - 0.5) * 300;
positions[i * 3 + 1] = Math.random() * 80 + 10;
positions[i * 3 + 2] = (Math.random() - 0.5) * 300;
}
for (let i = 0; i < DUST_COUNT; i++) {
dustPositions[i * 3 + 1] += dustVelocities[i];
dustPositions[i * 3] += Math.sin(t * 0.5 + i * 0.1) * 0.002;
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const mat = new THREE.PointsMaterial({
color: 0x003300,
size: 0.08,
transparent: true,
opacity: 0.5,
});
const stars = new THREE.Points(geo, mat);
scene.add(stars);
}
export function updateEffects(_time) {
if (!rainParticles) return;
for (let i = 0; i < RAIN_COUNT; i++) {
rainPositions[i * 3 + 1] -= rainVelocities[i];
if (rainPositions[i * 3 + 1] < -1) {
rainPositions[i * 3 + 1] = 40 + Math.random() * 20;
rainPositions[i * 3] = (Math.random() - 0.5) * 100;
rainPositions[i * 3 + 2] = (Math.random() - 0.5) * 100;
if (dustPositions[i * 3 + 1] > 10) {
dustPositions[i * 3 + 1] = 0;
dustPositions[i * 3] = (Math.random() - 0.5) * 22;
dustPositions[i * 3 + 2] = (Math.random() - 0.5) * 16 - 2;
}
}
rainParticles.geometry.attributes.position.needsUpdate = true;
dustParticles.geometry.attributes.position.needsUpdate = true;
}
/**
* Release GPU resources held by rain and starfield.
* Called before context-loss teardown.
*/
export function disposeEffects() {
if (rainParticles) {
rainParticles.geometry.dispose();
rainParticles.material.dispose();
rainParticles = null;
if (dustParticles) {
dustParticles.geometry.dispose();
dustParticles.material.dispose();
dustParticles = null;
}
rainPositions = null;
rainVelocities = null;
dustPositions = null;
dustVelocities = null;
}

View File

@@ -12,15 +12,6 @@ import { initPaymentPanel } from './payment.js';
let running = false;
let canvas = null;
/**
* Build (or rebuild) the Three.js world.
*
* @param {boolean} firstInit
* true — first page load: also starts UI and WebSocket
* false — context-restore reinit: skips UI/WS (they survive context loss)
* @param {Object.<string,string>|null} stateSnapshot
* Agent state map captured just before teardown; reapplied after initAgents.
*/
function buildWorld(firstInit, stateSnapshot) {
const { scene, camera, renderer } = initWorld(canvas);
canvas = renderer.domElement;
@@ -28,9 +19,7 @@ function buildWorld(firstInit, stateSnapshot) {
initEffects(scene);
initAgents(scene);
if (stateSnapshot) {
applyAgentStates(stateSnapshot);
}
if (stateSnapshot) applyAgentStates(stateSnapshot);
initInteraction(camera, renderer);
@@ -74,7 +63,6 @@ function buildWorld(firstInit, stateSnapshot) {
}
animate();
return { scene, renderer, ac };
}
@@ -89,7 +77,6 @@ function teardown({ scene, renderer, ac }) {
function main() {
const $overlay = document.getElementById('webgl-recovery-overlay');
let handle = buildWorld(true, null);
canvas.addEventListener('webglcontextlost', event => {

View File

@@ -1,178 +1,75 @@
import { getAgentDefs } from './agents.js';
import { AGENT_DEFS, colorToCss } from './agent-defs.js';
import { sendVisitorMessage } from './websocket.js';
const $agentCount = document.getElementById('agent-count');
const $activeJobs = document.getElementById('active-jobs');
const $fps = document.getElementById('fps');
const $agentList = document.getElementById('agent-list');
const $activeJobs = document.getElementById('active-jobs');
const $connStatus = document.getElementById('connection-status');
const $chatPanel = document.getElementById('chat-panel');
const $clearBtn = document.getElementById('chat-clear-btn');
const $log = document.getElementById('event-log');
const MAX_CHAT_ENTRIES = 12;
const MAX_STORED = 100;
const STORAGE_PREFIX = 'matrix:chat:';
const chatEntries = [];
const chatHistory = {};
const MAX_LOG = 6;
const logEntries = [];
let uiInitialized = false;
function storageKey(agentId) {
return STORAGE_PREFIX + agentId;
}
export function loadChatHistory(agentId) {
try {
const raw = localStorage.getItem(storageKey(agentId));
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(m =>
m && typeof m.agentLabel === 'string' && typeof m.text === 'string'
);
} catch {
return [];
}
}
export function saveChatHistory(agentId, messages) {
try {
localStorage.setItem(storageKey(agentId), JSON.stringify(messages.slice(-MAX_STORED)));
} catch {
}
}
function formatTimestamp(ts) {
const d = new Date(ts);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
return `${hh}:${mm}`;
}
function buildChatEntry(agentLabel, message, cssColor, timestamp) {
const color = cssColor || '#00ff41';
const entry = document.createElement('div');
entry.className = 'chat-entry';
const ts = timestamp ? `<span class="chat-ts">[${formatTimestamp(timestamp)}]</span> ` : '';
entry.innerHTML = `${ts}<span class="agent-name" style="color:${color}">${agentLabel}</span>: ${escapeHtml(message)}`;
return entry;
}
function loadAllHistories() {
const all = [];
const agentIds = [...AGENT_DEFS.map(d => d.id), 'sys'];
for (const id of agentIds) {
const msgs = loadChatHistory(id);
chatHistory[id] = msgs;
all.push(...msgs);
}
all.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
for (const msg of all.slice(-MAX_CHAT_ENTRIES)) {
const entry = buildChatEntry(msg.agentLabel, msg.text, msg.cssColor, msg.timestamp);
chatEntries.push(entry);
$chatPanel.appendChild(entry);
}
$chatPanel.scrollTop = $chatPanel.scrollHeight;
}
function clearAllHistories() {
const agentIds = [...AGENT_DEFS.map(d => d.id), 'sys'];
for (const id of agentIds) {
localStorage.removeItem(storageKey(id));
chatHistory[id] = [];
}
while ($chatPanel.firstChild) $chatPanel.removeChild($chatPanel.firstChild);
chatEntries.length = 0;
}
export function initUI() {
if (uiInitialized) return;
uiInitialized = true;
renderAgentList();
loadAllHistories();
if ($clearBtn) {
$clearBtn.addEventListener('click', clearAllHistories);
}
initInputBar();
}
function renderAgentList() {
const defs = getAgentDefs();
$agentList.innerHTML = defs.map(a => {
const css = colorToCss(a.color);
return `<div class="agent-row">
<span class="label">[</span>
<span style="color:${css}">${a.label}</span>
<span class="label">]</span>
<span id="agent-state-${a.id}" style="color:#003300"> IDLE</span>
</div>`;
}).join('');
}
function initInputBar() {
const $input = document.getElementById('visitor-input');
const $sendBtn = document.getElementById('send-btn');
if (!$input || !$sendBtn) return;
export function updateUI({ fps, agentCount, jobCount, connectionState }) {
$fps.textContent = `FPS: ${fps}`;
$agentCount.textContent = `AGENTS: ${agentCount}`;
$activeJobs.textContent = `JOBS: ${jobCount}`;
if (connectionState === 'connected') {
$connStatus.textContent = '● CONNECTED';
$connStatus.className = 'connected';
} else if (connectionState === 'connecting') {
$connStatus.textContent = '◌ CONNECTING...';
$connStatus.className = '';
} else {
$connStatus.textContent = '○ OFFLINE';
$connStatus.className = '';
function send() {
const text = $input.value.trim();
if (!text) return;
$input.value = '';
sendVisitorMessage(text);
appendSystemMessage(`you: ${text}`);
}
const defs = getAgentDefs();
defs.forEach(a => {
const el = document.getElementById(`agent-state-${a.id}`);
if (el) {
el.textContent = ` ${a.state.toUpperCase()}`;
el.style.color = a.state === 'active' ? '#00ff41' : '#003300';
}
$sendBtn.addEventListener('click', send);
$input.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
});
}
/**
* Append a message to the chat panel and optionally persist it.
* @param {string} agentLabel — display name
* @param {string} message — raw text (HTML-escaped before insertion)
* @param {string} cssColor — CSS color string e.g. '#00ff88'
* @param {string} [agentId] — storage key; omit to skip persistence
*/
export function appendChatMessage(agentLabel, message, cssColor, agentId) {
const timestamp = Date.now();
const entry = buildChatEntry(agentLabel, message, cssColor, timestamp);
chatEntries.push(entry);
export function updateUI({ fps, jobCount, connectionState }) {
if ($fps) $fps.textContent = `FPS: ${fps}`;
if ($activeJobs) $activeJobs.textContent = `JOBS: ${jobCount}`;
if (chatEntries.length > MAX_CHAT_ENTRIES) {
const removed = chatEntries.shift();
$chatPanel.removeChild(removed);
}
$chatPanel.appendChild(entry);
$chatPanel.scrollTop = $chatPanel.scrollHeight;
if (agentId) {
if (!chatHistory[agentId]) chatHistory[agentId] = [];
chatHistory[agentId].push({ agentLabel, text: message, cssColor, agentId, timestamp });
if (chatHistory[agentId].length > MAX_STORED) {
chatHistory[agentId] = chatHistory[agentId].slice(-MAX_STORED);
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 = '';
}
saveChatHistory(agentId, chatHistory[agentId]);
}
}
function escapeHtml(str) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
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);
}
export function loadChatHistory() { return []; }
export function saveChatHistory() {}

View File

@@ -1,13 +1,6 @@
import { AGENT_DEFS, colorToCss } from './agent-defs.js';
import { setAgentState } from './agents.js';
import { appendChatMessage } from './ui.js';
import { setAgentState, setSpeechBubble, applyAgentStates } from './agents.js';
import { appendSystemMessage } from './ui.js';
/**
* WebSocket URL resolution order:
* 1. VITE_WS_URL env var (set at build time for custom deployments)
* 2. Auto-derived from window.location — works when the Matrix is served from
* the same host as the API (the default: /tower served by the API server)
*/
function resolveWsUrl() {
const explicit = import.meta.env.VITE_WS_URL;
if (explicit) return explicit;
@@ -17,24 +10,20 @@ function resolveWsUrl() {
const WS_URL = resolveWsUrl();
const agentById = Object.fromEntries(AGENT_DEFS.map(d => [d.id, d]));
let ws = null;
let connectionState = 'disconnected';
let jobCount = 0;
let reconnectTimer = null;
let visitorId = null;
const RECONNECT_DELAY_MS = 5000;
export function initWebSocket(_scene) {
visitorId = crypto.randomUUID();
connect();
}
function connect() {
if (ws) {
ws.onclose = null;
ws.close();
}
if (ws) { ws.onclose = null; ws.close(); }
connectionState = 'connecting';
try {
@@ -48,28 +37,15 @@ function connect() {
ws.onopen = () => {
connectionState = 'connected';
clearTimeout(reconnectTimer);
ws.send(JSON.stringify({
type: 'subscribe',
channel: 'agents',
clientId: crypto.randomUUID(),
}));
send({ type: 'visitor_enter', visitorId, visitorName: 'visitor' });
};
ws.onmessage = (event) => {
try {
handleMessage(JSON.parse(event.data));
} catch {
}
ws.onmessage = event => {
try { handleMessage(JSON.parse(event.data)); } catch { /* ignore */ }
};
ws.onerror = () => {
connectionState = 'disconnected';
};
ws.onclose = () => {
connectionState = 'disconnected';
scheduleReconnect();
};
ws.onerror = () => { connectionState = 'disconnected'; };
ws.onclose = () => { connectionState = 'disconnected'; scheduleReconnect(); };
}
function scheduleReconnect() {
@@ -77,53 +53,67 @@ function scheduleReconnect() {
reconnectTimer = setTimeout(connect, RECONNECT_DELAY_MS);
}
function send(payload) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
}
}
function handleMessage(msg) {
switch (msg.type) {
case 'ping':
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'pong' }));
}
send({ type: 'pong' });
break;
case 'agent_state': {
if (msg.agentId && msg.state) {
setAgentState(msg.agentId, msg.state);
case 'world_state': {
if (msg.agentStates) applyAgentStates(msg.agentStates);
if (msg.recentEvents) {
const last = msg.recentEvents.slice(-3);
last.forEach(ev => appendSystemMessage(ev.summary || ev.type));
}
break;
}
case 'timmy_state': {
break;
}
case 'agent_state': {
if (msg.agentId && msg.state) setAgentState(msg.agentId, msg.state);
break;
}
case 'job_started': {
jobCount++;
if (msg.agentId) setAgentState(msg.agentId, 'active');
logEvent(`JOB ${(msg.jobId || '').slice(0, 8)} started`);
appendSystemMessage(`job ${(msg.jobId || '').slice(0, 8)} started`);
break;
}
case 'job_completed': {
if (jobCount > 0) jobCount--;
if (msg.agentId) setAgentState(msg.agentId, 'idle');
logEvent(`JOB ${(msg.jobId || '').slice(0, 8)} complete`);
appendSystemMessage(`job ${(msg.jobId || '').slice(0, 8)} complete`);
break;
}
case 'chat': {
const def = agentById[msg.agentId];
if (def && msg.text) {
appendChatMessage(def.label, msg.text, colorToCss(def.color), def.id);
}
if (msg.text) setSpeechBubble(msg.text);
break;
}
case 'agent_count':
case 'visitor_count':
break;
default:
break;
}
}
function logEvent(text) {
appendChatMessage('SYS', text, '#003300', 'sys');
export function sendVisitorMessage(text) {
send({ type: 'visitor_message', visitorId, text });
}
export function getConnectionState() {
return connectionState;
}
export function getJobCount() {
return jobCount;
}
export function getConnectionState() { return connectionState; }
export function getJobCount() { return jobCount; }

View File

@@ -1,23 +1,18 @@
import * as THREE from 'three';
let scene, camera, renderer;
const _worldObjects = [];
/**
* @param {HTMLCanvasElement|null} existingCanvas — pass the saved canvas on
* re-init so Three.js reuses the same DOM element instead of creating a new one
*/
export function initWorld(existingCanvas) {
_worldObjects.length = 0;
scene = new THREE.Scene();
scene.background = new THREE.Color(0x000000);
scene.fog = new THREE.FogExp2(0x000000, 0.035);
scene.background = new THREE.Color(0x080610);
scene.fog = new THREE.FogExp2(0x080610, 0.038);
camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 500);
camera.position.set(0, 12, 28);
camera.lookAt(0, 0, 0);
camera = new THREE.PerspectiveCamera(58, window.innerWidth / window.innerHeight, 0.1, 200);
camera.position.set(0, 7, 14);
camera.lookAt(0, 2, -2);
renderer = new THREE.WebGLRenderer({
antialias: true,
@@ -26,68 +21,131 @@ export function initWorld(existingCanvas) {
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
if (!existingCanvas) {
document.body.prepend(renderer.domElement);
}
addLights(scene);
addGrid(scene);
buildRoom(scene);
return { scene, camera, renderer };
}
/**
* Dispose only world-owned geometries, materials, and the renderer.
* Agent and effect objects are disposed by their own modules before this runs.
*/
function addLights(scene) {
const ambient = new THREE.AmbientLight(0x0a2010, 0.45);
scene.add(ambient);
const fireplace = new THREE.PointLight(0xff7722, 4.5, 32);
fireplace.position.set(-9, 3.5, 6);
fireplace.castShadow = true;
scene.add(fireplace);
const candle = new THREE.PointLight(0xffcc77, 1.8, 8);
candle.position.set(0.7, 1.9, -3.6);
scene.add(candle);
const fill = new THREE.DirectionalLight(0x001a00, 0.2);
fill.position.set(0, 12, 8);
scene.add(fill);
}
function buildRoom(scene) {
const stoneMat = new THREE.MeshStandardMaterial({ color: 0x181820, roughness: 0.95, metalness: 0.0 });
const woodMat = new THREE.MeshStandardMaterial({ color: 0x3d2506, roughness: 0.92 });
const floor = new THREE.Mesh(new THREE.PlaneGeometry(28, 28), stoneMat.clone());
floor.rotation.x = -Math.PI / 2;
floor.receiveShadow = true;
scene.add(floor);
_worldObjects.push(floor);
const grid = new THREE.GridHelper(28, 14, 0x28283a, 0x1c1c28);
grid.position.y = 0.005;
scene.add(grid);
_worldObjects.push(grid);
buildDesk(scene, woodMat);
buildShelves(scene, woodMat);
buildFireplaceGlow(scene);
}
function buildDesk(scene, woodMat) {
const top = new THREE.Mesh(new THREE.BoxGeometry(2.8, 0.1, 1.3), woodMat.clone());
top.position.set(0, 1.05, -4.1);
top.castShadow = true;
top.receiveShadow = true;
scene.add(top);
_worldObjects.push(top);
const legGeo = new THREE.CylinderGeometry(0.055, 0.055, 1.05, 8);
[[-1.25, -3.55], [1.25, -3.55], [-1.25, -4.65], [1.25, -4.65]].forEach(([x, z]) => {
const leg = new THREE.Mesh(legGeo, woodMat.clone());
leg.position.set(x, 0.525, z);
leg.castShadow = true;
scene.add(leg);
_worldObjects.push(leg);
});
const scrollMat = new THREE.MeshStandardMaterial({ color: 0xe8d5a0, roughness: 1.0 });
const scroll = new THREE.Mesh(new THREE.BoxGeometry(0.9, 0.02, 0.65), scrollMat);
scroll.position.set(-0.65, 1.12, -4.1);
scroll.rotation.y = 0.18;
scene.add(scroll);
_worldObjects.push(scroll);
const scroll2 = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.015, 0.4), scrollMat.clone());
scroll2.position.set(0.3, 1.12, -4.3);
scroll2.rotation.y = -0.25;
scene.add(scroll2);
_worldObjects.push(scroll2);
}
function buildShelves(scene, woodMat) {
const bookColors = [0x8b0000, 0x00008b, 0x004400, 0x6b3300, 0x440066, 0x884400, 0x005566, 0x663300];
[2.6, 3.7, 4.8].forEach(y => {
const shelf = new THREE.Mesh(new THREE.BoxGeometry(3.8, 0.07, 0.48), woodMat.clone());
shelf.position.set(-8.5, y, -2.2);
shelf.rotation.y = 0.12;
shelf.castShadow = true;
scene.add(shelf);
_worldObjects.push(shelf);
for (let i = 0; i < 7; i++) {
const h = 0.38 + Math.random() * 0.22;
const bookMat = new THREE.MeshStandardMaterial({ color: bookColors[i % bookColors.length], roughness: 0.95 });
const book = new THREE.Mesh(new THREE.BoxGeometry(0.11, h, 0.34), bookMat);
book.position.set(-10.0 + i * 0.43, y + h / 2 + 0.04, -2.2 + Math.random() * 0.04);
book.rotation.y = 0.12 + (Math.random() - 0.5) * 0.08;
scene.add(book);
_worldObjects.push(book);
}
});
}
function buildFireplaceGlow(scene) {
const glowMat = new THREE.MeshBasicMaterial({ color: 0xff5500, transparent: true, opacity: 0.1 });
const glow = new THREE.Mesh(new THREE.PlaneGeometry(3.5, 3), glowMat);
glow.position.set(-11.5, 2.5, 4);
glow.rotation.y = Math.PI / 2;
scene.add(glow);
_worldObjects.push(glow);
}
export function disposeWorld(renderer, _scene) {
for (const obj of _worldObjects) {
if (obj.geometry) obj.geometry.dispose();
if (obj.material) {
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
mats.forEach(m => {
if (m.map) m.map.dispose();
m.dispose();
});
mats.forEach(m => { if (m.map) m.map.dispose(); m.dispose(); });
}
}
_worldObjects.length = 0;
renderer.dispose();
}
function addLights(scene) {
const ambient = new THREE.AmbientLight(0x001a00, 0.6);
scene.add(ambient);
const point = new THREE.PointLight(0x00ff41, 2, 80);
point.position.set(0, 20, 0);
scene.add(point);
const fill = new THREE.DirectionalLight(0x003300, 0.4);
fill.position.set(-10, 10, 10);
scene.add(fill);
}
function addGrid(scene) {
const grid = new THREE.GridHelper(100, 40, 0x003300, 0x001a00);
grid.position.y = -0.01;
scene.add(grid);
_worldObjects.push(grid);
const planeGeo = new THREE.PlaneGeometry(100, 100);
const planeMat = new THREE.MeshBasicMaterial({
color: 0x000a00,
transparent: true,
opacity: 0.5,
});
const plane = new THREE.Mesh(planeGeo, planeMat);
plane.rotation.x = -Math.PI / 2;
plane.position.y = -0.02;
scene.add(plane);
_worldObjects.push(plane);
}
export function onWindowResize(camera, renderer) {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();