feat(epic222): Workshop — Timmy as wizard presence, world state, WS bootstrap (#31)
This commit is contained in:
52
artifacts/api-server/src/lib/world-state.ts
Normal file
52
artifacts/api-server/src/lib/world-state.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
29
artifacts/api-server/src/routes/world.ts
Normal file
29
artifacts/api-server/src/routes/world.ts
Normal 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;
|
||||
@@ -3,4 +3,5 @@ export * from "./invoices";
|
||||
export * from "./conversations";
|
||||
export * from "./messages";
|
||||
export * from "./bootstrap-jobs";
|
||||
export * from "./world-events";
|
||||
export * from "./sessions";
|
||||
|
||||
10
lib/db/src/schema/world-events.ts
Normal file
10
lib/db/src/schema/world-events.ts
Normal 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(),
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
116
the-matrix/js/effects.js
vendored
116
the-matrix/js/effects.js
vendored
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
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() {}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user