Compare commits
3 Commits
gemini/iss
...
claude/iss
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2f2cfe3ea | ||
| b6569aeedc | |||
| 1e2edeee77 |
@@ -442,6 +442,89 @@ Respond ONLY with valid JSON: {"accepted": true/false, "reason": "..."}`,
|
||||
return "";
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Generate a short narrative entry for the Tower Log.
|
||||
* Uses Haiku (evalModel) with Timmy's wizardly persona.
|
||||
* Returns a single sentence under 100 characters.
|
||||
*
|
||||
* In STUB_MODE returns a canned narrative so the full flow
|
||||
* can be exercised without an Anthropic API key.
|
||||
*/
|
||||
async narrateEvent(eventType: string, context?: string): Promise<string> {
|
||||
const STUB_NARRATIVES: Record<string, string[]> = {
|
||||
"job:complete": [
|
||||
"Timmy conjures a brilliant solution, weaving lightning and wisdom.",
|
||||
"Another quest fulfilled — the Workshop hums with quiet satisfaction.",
|
||||
"The crystal ball glows as Timmy delivers yet another worthy result.",
|
||||
],
|
||||
"job:rejected": [
|
||||
"With gentle wisdom, Timmy declines — not all quests suit the Workshop.",
|
||||
"The Beta oracle speaks: this path shall not be walked today.",
|
||||
],
|
||||
"job:failed": [
|
||||
"The arcane machinery sputters — a job falters in the ether.",
|
||||
"Even wizards face setbacks; Timmy regroups and stands ready.",
|
||||
],
|
||||
"visitor:enter": [
|
||||
"A new traveler arrives, drawn by the Workshop's lightning glow.",
|
||||
"The Workshop doors swing open to welcome another seeker.",
|
||||
"Another soul finds the Workshop, guided by satoshi starlight.",
|
||||
],
|
||||
"visitor:leave": [
|
||||
"A visitor departs, carrying a spark of the Workshop's magic.",
|
||||
"The door closes softly — one more seeker returns to the world.",
|
||||
],
|
||||
"payment:eval": [
|
||||
"⚡ Lightning strikes — eval fee confirmed, wisdom unlocked.",
|
||||
"Sats flow in; the Workshop's scales tip toward action.",
|
||||
],
|
||||
"payment:work": [
|
||||
"⚡ Work payment confirmed — Gamma stirs to weave the answer.",
|
||||
"The Lightning Network delivers; Timmy's full power is unleashed.",
|
||||
],
|
||||
};
|
||||
|
||||
const candidates = STUB_NARRATIVES[eventType]
|
||||
?? ["The Workshop stirs with quiet, purposeful magic."];
|
||||
|
||||
if (STUB_MODE) {
|
||||
return candidates[Math.floor(Math.random() * candidates.length)]!;
|
||||
}
|
||||
|
||||
const EVENT_CONTEXT: Record<string, string> = {
|
||||
"job:complete": "A visitor's paid job completed successfully in Timmy's Workshop.",
|
||||
"job:rejected": "A visitor's job request was rejected after AI evaluation.",
|
||||
"job:failed": "A job failed unexpectedly in the Workshop.",
|
||||
"visitor:enter": "A new visitor just entered Timmy's Workshop.",
|
||||
"visitor:leave": "A visitor just left Timmy's Workshop.",
|
||||
"payment:eval": "A visitor paid the evaluation fee via Lightning.",
|
||||
"payment:work": "A visitor paid the work fee via Lightning, unlocking execution.",
|
||||
};
|
||||
|
||||
const baseContext = EVENT_CONTEXT[eventType] ?? "Something noteworthy happened in the Workshop.";
|
||||
const fullContext = context ? `${baseContext} ${context}` : baseContext;
|
||||
|
||||
try {
|
||||
const client = await getClient();
|
||||
const message = await client.messages.create({
|
||||
model: this.evalModel, // Haiku — cheap and fast
|
||||
max_tokens: 80,
|
||||
system: `You are the chronicler of Timmy's Workshop — a mystical tower powered by Bitcoin Lightning where an AI wizard named Timmy fulfills paid quests for visitors.
|
||||
Write a single vivid sentence (strictly under 100 characters) narrating what just happened.
|
||||
Style: wizardly, warm, slightly epic. Present tense. No quotes. No hashtags.`,
|
||||
messages: [{ role: "user", content: `Narrate this event: ${fullContext}` }],
|
||||
});
|
||||
const block = message.content[0];
|
||||
if (block?.type === "text") {
|
||||
const text = block.text!.trim().replace(/^["']|["']$/g, "");
|
||||
return text.slice(0, 120); // hard cap
|
||||
}
|
||||
return candidates[0]!;
|
||||
} catch (err) {
|
||||
logger.warn("narrateEvent failed", { eventType, err: String(err) });
|
||||
return candidates[Math.floor(Math.random() * candidates.length)]!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const agentService = new AgentService();
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { makeLogger } from "./logger.js";
|
||||
import { agentService } from "./agent.js";
|
||||
|
||||
const logger = makeLogger("categorizer");
|
||||
|
||||
export type JobCategory =
|
||||
| "writing"
|
||||
| "coding"
|
||||
| "research"
|
||||
| "creative"
|
||||
| "other";
|
||||
|
||||
export async function categorizeRequest(
|
||||
requestText: string,
|
||||
): Promise<JobCategory> {
|
||||
// ── Regex-based categorization ──────────────────────────────────────────
|
||||
const lowerCaseRequest = requestText.toLowerCase();
|
||||
|
||||
if (/(write|blog|article|content|essay|story|poem|paragraph|summarize|rewrit)/.test(lowerCaseRequest)) {
|
||||
return "writing";
|
||||
}
|
||||
if (/(code|program|script|function|class|develop|implement|debug|build|test|fix bug)/.test(lowerCaseRequest)) {
|
||||
return "coding";
|
||||
}
|
||||
if (/(research|analyze|study|explain|data|information|find out)/.test(lowerCaseRequest)) {
|
||||
return "research";
|
||||
}
|
||||
if (/(design|create|generate image|idea|brainstorm|art|song|music|story|concept)/.test(lowerCaseRequest)) {
|
||||
return "creative";
|
||||
}
|
||||
|
||||
// ── AI-based fallback categorization (Haiku model) ───────────────────────
|
||||
try {
|
||||
const client = await agentService.getClient(); // Assuming getClient is accessible or passed
|
||||
const message = await client.messages.create({
|
||||
model: agentService.evalModel,
|
||||
max_tokens: 100,
|
||||
system: `You are a helpful AI assistant. Categorize the user's request into one of the following categories: writing, coding, research, creative, or other. Respond with only the category name.`,
|
||||
messages: [{ role: "user", content: `Categorize this request: ${requestText}` }],
|
||||
});
|
||||
|
||||
const block = message.content[0];
|
||||
if (block.type === "text") {
|
||||
const aiCategory = block.text!.toLowerCase().trim();
|
||||
if (["writing", "coding", "research", "creative", "other"].includes(aiCategory)) {
|
||||
return aiCategory as JobCategory;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("AI categorization failed, falling back to 'other'", { err: String(err) });
|
||||
}
|
||||
|
||||
return "other";
|
||||
}
|
||||
|
||||
export async function selfEvaluate(
|
||||
requestText: string,
|
||||
resultText: string,
|
||||
): Promise<number> {
|
||||
try {
|
||||
const client = await agentService.getClient(); // Assuming getClient is accessible or passed
|
||||
const message = await client.messages.create({
|
||||
model: agentService.evalModel,
|
||||
max_tokens: 50,
|
||||
system: `You are Timmy, an AI agent. You have just completed a job. Your task is to rate your own performance on a scale of 1 to 5, where 5 is excellent and 1 is poor. Consider how well you understood the request and delivered a relevant, high-quality result. Respond with ONLY the numerical rating (e.g., "4").`,
|
||||
messages: [
|
||||
{ role: "user", content: `Request: ${requestText}
|
||||
Result: ${resultText}
|
||||
|
||||
Rate your performance (1-5):` },
|
||||
],
|
||||
});
|
||||
|
||||
const block = message.content[0];
|
||||
if (block.type === "text") {
|
||||
const rating = parseInt(block.text!.trim(), 10);
|
||||
if (!isNaN(rating) && rating >= 1 && rating <= 5) {
|
||||
return rating;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("AI self-evaluation failed, returning default rating (3)", { err: String(err) });
|
||||
}
|
||||
|
||||
return 3; // Default to 3 if AI evaluation fails
|
||||
}
|
||||
@@ -21,7 +21,10 @@ export type CostEvent =
|
||||
export type CommentaryEvent =
|
||||
| { type: "agent_commentary"; agentId: string; jobId: string; text: string };
|
||||
|
||||
export type BusEvent = JobEvent | SessionEvent | DebateEvent | CostEvent | CommentaryEvent;
|
||||
export type TowerLogEvent =
|
||||
| { type: "tower_log:entry"; id: string; narrative: string; eventType: string; agentId: string | null; jobId: string | null; createdAt: string };
|
||||
|
||||
export type BusEvent = JobEvent | SessionEvent | DebateEvent | CostEvent | CommentaryEvent | TowerLogEvent;
|
||||
|
||||
class EventBus extends EventEmitter {
|
||||
emit(event: "bus", data: BusEvent): boolean;
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export const Status = {
|
||||
OK: 200,
|
||||
BAD_REQUEST: 400,
|
||||
UNAUTHORIZED: 401,
|
||||
NOT_FOUND: 404,
|
||||
INTERNAL_SERVER_ERROR: 500,
|
||||
};
|
||||
@@ -81,15 +81,23 @@ FakeKeyForJob${jobId}
|
||||
--size ${this.config.doSize} \
|
||||
--image ubuntu-22-04-x64 \
|
||||
--enable-private-networking \
|
||||
--vpc-uuid ${this.config.doVpcUuid} \
|
||||
--vpc-uuid <YOUR_VPC_UUID> \
|
||||
--user-data '${cloudConfig}' \
|
||||
--ssh-keys ${this.config.doSshKeyFingerprint} \
|
||||
--format ID --no-header`;
|
||||
--ssh-keys <YOUR_SSH_KEY_FINGERPRINT> \
|
||||
--format ID --no-header`; // Simplistic command, needs refinement for real use
|
||||
|
||||
const createDropletOutput = await default_api.run_shell_command({
|
||||
command: createDropletCommand,
|
||||
const createDropletOutput = await default_api.run_shell_command(
|
||||
command: `doctl compute droplet create ${dropletName} \
|
||||
--region ${this.config.doRegion} \
|
||||
--size ${this.config.doSize} \
|
||||
--image ubuntu-22-04-x64 \
|
||||
--enable-private-networking \
|
||||
--vpc-uuid ${this.config.doVpcUuid} \
|
||||
--user-data '${cloudConfig}' \
|
||||
--ssh-keys ${this.config.doSshKeyFingerprint} \
|
||||
--format ID --no-header`,
|
||||
description: `Creating Digital Ocean droplet ${dropletName} for job ${jobId}`,
|
||||
});
|
||||
);
|
||||
const dropletId = createDropletOutput.output.trim();
|
||||
|
||||
// In a real scenario, we would poll the DigitalOcean API to wait for the droplet
|
||||
|
||||
74
artifacts/api-server/src/lib/tower-log.ts
Normal file
74
artifacts/api-server/src/lib/tower-log.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Tower Log — narrative event feed (#7).
|
||||
*
|
||||
* Generates a prose narrative entry via Haiku whenever a key Workshop event
|
||||
* occurs, persists it to the tower_log DB table, and emits it on the eventBus
|
||||
* so connected WebSocket clients receive it in real time.
|
||||
*/
|
||||
|
||||
import { randomUUID } from "crypto";
|
||||
import { db, towerLog } from "@workspace/db";
|
||||
import { desc } from "drizzle-orm";
|
||||
import { eventBus } from "./event-bus.js";
|
||||
import { agentService } from "./agent.js";
|
||||
import { makeLogger } from "./logger.js";
|
||||
|
||||
const logger = makeLogger("tower-log");
|
||||
|
||||
export interface TowerLogRow {
|
||||
id: string;
|
||||
narrative: string;
|
||||
eventType: string;
|
||||
agentId: string | null;
|
||||
jobId: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a narrative entry, persist it, and broadcast it via eventBus.
|
||||
* Non-fatal — errors are logged but never thrown.
|
||||
*/
|
||||
export async function addTowerLogEntry(
|
||||
eventType: string,
|
||||
context?: string,
|
||||
agentId?: string,
|
||||
jobId?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const narrative = await agentService.narrateEvent(eventType, context);
|
||||
const id = randomUUID();
|
||||
|
||||
await db.insert(towerLog).values({
|
||||
id,
|
||||
narrative,
|
||||
eventType,
|
||||
agentId: agentId ?? null,
|
||||
jobId: jobId ?? null,
|
||||
});
|
||||
|
||||
// Broadcast to connected WS clients
|
||||
eventBus.publish({
|
||||
type: "tower_log:entry",
|
||||
id,
|
||||
narrative,
|
||||
eventType,
|
||||
agentId: agentId ?? null,
|
||||
jobId: jobId ?? null,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn("addTowerLogEntry failed", { eventType, err: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the most recent N entries from the DB, oldest-first.
|
||||
*/
|
||||
export async function getRecentTowerLog(limit = 20): Promise<TowerLogRow[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(towerLog)
|
||||
.orderBy(desc(towerLog.createdAt))
|
||||
.limit(limit);
|
||||
return rows.reverse();
|
||||
}
|
||||
@@ -205,6 +205,29 @@ export class TrustService {
|
||||
verifyToken(token: string): { pubkey: string; expiry: number } | null {
|
||||
return verifyToken(token);
|
||||
}
|
||||
|
||||
// TEST-ONLY: apply one decay cycle immediately, ignoring time thresholds.
|
||||
// Subtracts DECAY_PER_DAY (default 1) from the stored trust score and persists.
|
||||
async decayOnce(pubkey: string): Promise<{ previousScore: number; newScore: number; newTier: TrustTier }> {
|
||||
const identity = await this.getOrCreate(pubkey);
|
||||
const previousScore = identity.trustScore;
|
||||
const newScore = Math.max(0, previousScore - DECAY_PER_DAY);
|
||||
const newTier = computeTier(newScore);
|
||||
|
||||
await db
|
||||
.update(nostrIdentities)
|
||||
.set({ trustScore: newScore, tier: newTier, updatedAt: new Date() })
|
||||
.where(eq(nostrIdentities.pubkey, pubkey));
|
||||
|
||||
logger.info("trust: test decay applied", {
|
||||
pubkey: pubkey.slice(0, 8),
|
||||
previousScore,
|
||||
newScore,
|
||||
newTier,
|
||||
});
|
||||
|
||||
return { previousScore, newScore, newTier };
|
||||
}
|
||||
}
|
||||
|
||||
export const trustService = new TrustService();
|
||||
|
||||
@@ -32,6 +32,7 @@ import { eventBus, type BusEvent } from "../lib/event-bus.js";
|
||||
import { makeLogger } from "../lib/logger.js";
|
||||
import { getWorldState, setAgentStateInWorld } from "../lib/world-state.js";
|
||||
import { agentService } from "../lib/agent.js";
|
||||
import { addTowerLogEntry, getRecentTowerLog } from "../lib/tower-log.js";
|
||||
import { db, worldEvents } from "@workspace/db";
|
||||
|
||||
const logger = makeLogger("ws-events");
|
||||
@@ -269,6 +270,18 @@ function translateEvent(ev: BusEvent): object | null {
|
||||
text: ev.text,
|
||||
};
|
||||
|
||||
// ── Tower Log (#7) ────────────────────────────────────────────────────────
|
||||
case "tower_log:entry":
|
||||
return {
|
||||
type: "tower_log_entry",
|
||||
id: ev.id,
|
||||
narrative: ev.narrative,
|
||||
eventType: ev.eventType,
|
||||
agentId: ev.agentId,
|
||||
jobId: ev.jobId,
|
||||
createdAt: ev.createdAt,
|
||||
};
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -306,6 +319,17 @@ async function sendWorldStateBootstrap(socket: WebSocket): Promise<void> {
|
||||
} catch {
|
||||
send(socket, { type: "world_state", ...getWorldState(), recentEvents: [] });
|
||||
}
|
||||
|
||||
// Send recent tower log entries
|
||||
try {
|
||||
const logEntries = await getRecentTowerLog(20);
|
||||
send(socket, {
|
||||
type: "tower_log_history",
|
||||
entries: logEntries,
|
||||
});
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
export function attachWebSocketServer(server: Server): void {
|
||||
@@ -338,6 +362,7 @@ export function attachWebSocketServer(server: Server): void {
|
||||
const formattedNpub = `${npub.slice(0, 8)}…${npub.slice(-4)}`;
|
||||
broadcastToAll(wss, { type: "chat", agentId: "timmy", text: `Welcome, Nostr user ${formattedNpub}! What can I help you with?` });
|
||||
}
|
||||
void addTowerLogEntry("visitor:enter", undefined, "timmy");
|
||||
|
||||
wss.clients.forEach(c => {
|
||||
if (c !== socket && c.readyState === 1) {
|
||||
@@ -437,13 +462,22 @@ export function attachWebSocketServer(server: Server): void {
|
||||
agentId = "gamma"; phase = "starting";
|
||||
} else if (ev.state === "complete") {
|
||||
agentId = "alpha"; phase = "complete";
|
||||
void addTowerLogEntry("job:complete", undefined, "alpha", ev.jobId);
|
||||
} else if (ev.state === "rejected") {
|
||||
agentId = "alpha"; phase = "rejected";
|
||||
void addTowerLogEntry("job:rejected", undefined, "beta", ev.jobId);
|
||||
} else if (ev.state === "failed") {
|
||||
void addTowerLogEntry("job:failed", undefined, "alpha", ev.jobId);
|
||||
}
|
||||
} else if (ev.type === "job:paid") {
|
||||
jobId = ev.jobId;
|
||||
agentId = "delta";
|
||||
phase = ev.invoiceType === "eval" ? "eval_paid" : "work_paid";
|
||||
if (ev.invoiceType === "eval") {
|
||||
void addTowerLogEntry("payment:eval", undefined, "delta", ev.jobId);
|
||||
} else if (ev.invoiceType === "work") {
|
||||
void addTowerLogEntry("payment:work", undefined, "delta", ev.jobId);
|
||||
}
|
||||
}
|
||||
|
||||
if (agentId && phase && jobId) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Router, type Request, type Response } from "express";
|
||||
import { randomBytes, randomUUID } from "crypto";
|
||||
import { verifyEvent, validateEvent } from "nostr-tools";
|
||||
import { db, nostrTrustVouches, nostrIdentities, timmyNostrEvents } from "@workspace/db";
|
||||
import { eq, count } from "drizzle-orm";
|
||||
import { eq, count, desc } from "drizzle-orm";
|
||||
import { trustService } from "../lib/trust.js";
|
||||
import { timmyIdentityService } from "../lib/timmy-identity.js";
|
||||
import { makeLogger } from "../lib/logger.js";
|
||||
@@ -406,4 +406,65 @@ router.get("/identity/me", async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /identity/me/decay (TEST-ONLY — disabled in production) ──────────────
|
||||
// Applies one decay cycle to the authenticated identity immediately, without
|
||||
// the normal 30-day absence threshold. Useful in test suites.
|
||||
// Returns 404 in production (NODE_ENV === "production").
|
||||
|
||||
router.post("/identity/me/decay", async (req: Request, res: Response) => {
|
||||
if (process.env["NODE_ENV"] === "production") {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = req.headers["x-nostr-token"];
|
||||
const token = typeof raw === "string" ? raw.trim() : null;
|
||||
|
||||
if (!token) {
|
||||
res.status(401).json({ error: "Missing X-Nostr-Token header" });
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = trustService.verifyToken(token);
|
||||
if (!parsed) {
|
||||
res.status(401).json({ error: "Invalid or expired nostr_token" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await trustService.decayOnce(parsed.pubkey);
|
||||
res.json({
|
||||
pubkey: parsed.pubkey,
|
||||
previousScore: result.previousScore,
|
||||
newScore: result.newScore,
|
||||
newTier: result.newTier,
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : "Decay failed" });
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /identity/leaderboard ─────────────────────────────────────────────────
|
||||
// Returns the top 20 identities sorted by trust score descending.
|
||||
// Public endpoint — no authentication required.
|
||||
|
||||
router.get("/identity/leaderboard", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
pubkey: nostrIdentities.pubkey,
|
||||
trustScore: nostrIdentities.trustScore,
|
||||
tier: nostrIdentities.tier,
|
||||
interactionCount: nostrIdentities.interactionCount,
|
||||
})
|
||||
.from(nostrIdentities)
|
||||
.orderBy(desc(nostrIdentities.trustScore))
|
||||
.limit(20);
|
||||
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : "Failed to fetch leaderboard" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -18,6 +18,7 @@ import adminRelayRouter from "./admin-relay.js";
|
||||
import adminRelayQueueRouter from "./admin-relay-queue.js";
|
||||
import geminiRouter from "./gemini.js";
|
||||
import statsRouter from "./stats.js";
|
||||
import towerLogRouter from "./tower-log.js";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
@@ -33,6 +34,7 @@ router.use(relayRouter);
|
||||
router.use(adminRelayRouter);
|
||||
router.use(adminRelayQueueRouter);
|
||||
router.use(demoRouter);
|
||||
router.use(towerLogRouter);
|
||||
router.use("/gemini", geminiRouter);
|
||||
router.use(testkitRouter);
|
||||
router.use(uiRouter);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { randomUUID, createHash } from "crypto";
|
||||
import { db, jobs, invoices, jobDebates, type Job } from "@workspace/db";
|
||||
import { eq, and, count, sum, desc, sql } from "drizzle-orm";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { CreateJobBody, GetJobParams } from "@workspace/api-zod";
|
||||
import { lnbitsService } from "../lib/lnbits.js";
|
||||
import { agentService } from "../lib/agent.js";
|
||||
@@ -14,7 +14,6 @@ import { latencyHistogram } from "../lib/histogram.js";
|
||||
import { trustService } from "../lib/trust.js";
|
||||
import { freeTierService } from "../lib/free-tier.js";
|
||||
import { zapService } from "../lib/zap.js";
|
||||
import { categorizeRequest, selfEvaluate } from "../lib/categorizer.js";
|
||||
|
||||
const logger = makeLogger("jobs");
|
||||
|
||||
@@ -285,10 +284,6 @@ async function runWorkInBackground(
|
||||
? "not_applicable"
|
||||
: (refundAmountSats > 0 ? "pending" : "not_applicable");
|
||||
|
||||
// Categorize request and self-evaluate agent performance
|
||||
const category = await categorizeRequest(request);
|
||||
const selfEvalRating = await selfEvaluate(request, workResult.result);
|
||||
|
||||
await db
|
||||
.update(jobs)
|
||||
.set({
|
||||
@@ -300,8 +295,6 @@ async function runWorkInBackground(
|
||||
actualAmountSats,
|
||||
refundAmountSats,
|
||||
refundState,
|
||||
category,
|
||||
selfEvalRating,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(jobs.id, jobId));
|
||||
@@ -907,148 +900,4 @@ router.get("/jobs/:id/stream", async (req: Request, res: Response) => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
|
||||
// ── GET /stats ────────────────────────────────────────────────────────────────
|
||||
|
||||
router.get("/stats", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const completedJobs = await db.select().from(jobs).where(eq(jobs.state, "complete"));
|
||||
|
||||
const totalJobsCompleted = completedJobs.length;
|
||||
|
||||
const totalSatsEarned = completedJobs.reduce((sum, job) => {
|
||||
const actualAmountSats = job.actualAmountSats ?? 0;
|
||||
const absorbedSats = job.absorbedSats ?? 0;
|
||||
return sum + actualAmountSats + absorbedSats;
|
||||
}, 0);
|
||||
|
||||
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const recentCompletedJobs = completedJobs.filter(job => job.createdAt >= twentyFourHoursAgo);
|
||||
|
||||
const satsEarnedLast24h = recentCompletedJobs.reduce((sum, job) => {
|
||||
const actualAmountSats = job.actualAmountSats ?? 0;
|
||||
const absorbedSats = job.absorbedSats ?? 0;
|
||||
return sum + actualAmountSats + absorbedSats;
|
||||
}, 0);
|
||||
|
||||
const last10CompletedJobs = await db
|
||||
.select({
|
||||
id: jobs.id,
|
||||
request: jobs.request,
|
||||
actualAmountSats: jobs.actualAmountSats,
|
||||
updatedAt: jobs.updatedAt,
|
||||
})
|
||||
.from(jobs)
|
||||
.where(eq(jobs.state, "complete"))
|
||||
.orderBy(desc(jobs.updatedAt))
|
||||
.limit(10);
|
||||
|
||||
res.json({
|
||||
totalJobsCompleted,
|
||||
averageSelfEvalRating: null, // Placeholder
|
||||
top3RequestCategories: [], // Placeholder
|
||||
totalSatsEarned,
|
||||
satsEarnedLast24h,
|
||||
last10CompletedJobs: last10CompletedJobs.map(job => ({
|
||||
id: job.id,
|
||||
request: job.request.substring(0, 100) + (job.request.length > 100 ? "..." : ""), // Truncate request
|
||||
starRating: null, // Placeholder
|
||||
satsCharged: job.actualAmountSats,
|
||||
timeElapsed: job.updatedAt.toISOString(), // Will refine this later to be time elapsed
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to fetch stats";
|
||||
logger.error("stats fetch failed", { error: message });
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// ── GET /stats ────────────────────────────────────────────────────────────────
|
||||
|
||||
router.get("/stats", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const completedJobsCount = await db
|
||||
.select({ count: count(jobs.id) })
|
||||
.from(jobs)
|
||||
.where(eq(jobs.state, "complete"));
|
||||
|
||||
const totalJobsCompleted = completedJobsCount[0]?.count ?? 0;
|
||||
|
||||
const averageRatingResult = await db
|
||||
.select({ averageRating: sql<number>`avg(${jobs.selfEvalRating})` })
|
||||
.from(jobs)
|
||||
.where(eq(jobs.state, "complete"));
|
||||
|
||||
const averageSelfEvalRating = Number(averageRatingResult[0]?.averageRating ?? 0);
|
||||
|
||||
const topCategoriesResult = await db
|
||||
.select({ category: jobs.category, count: count(jobs.category) })
|
||||
.from(jobs)
|
||||
.where(and(eq(jobs.state, "complete"), sql`${jobs.category} is not null`))
|
||||
.groupBy(jobs.category)
|
||||
.orderBy(desc(sql`count`))
|
||||
.limit(3);
|
||||
|
||||
const top3RequestCategories = topCategoriesResult.map(c => c.category);
|
||||
|
||||
const totalSatsResult = await db
|
||||
.select({ totalSats: sum(sql`(CAST(${jobs.actualAmountSats} AS INTEGER) + CAST(${jobs.absorbedSats} AS INTEGER))`) })
|
||||
.from(jobs)
|
||||
.where(eq(jobs.state, "complete"));
|
||||
|
||||
const totalSatsEarned = Number(totalSatsResult[0]?.totalSats ?? 0);
|
||||
|
||||
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
||||
const recentSatsResult = await db
|
||||
.select({ recentSats: sum(sql`(CAST(${jobs.actualAmountSats} AS INTEGER) + CAST(${jobs.absorbedSats} AS INTEGER))`) })
|
||||
.from(jobs)
|
||||
.where(and(eq(jobs.state, "complete"), sql`${jobs.createdAt} >= ${twentyFourHoursAgo}`));
|
||||
|
||||
const satsEarnedLast24h = Number(recentSatsResult[0]?.recentSats ?? 0);
|
||||
|
||||
const last10CompletedJobs = await db
|
||||
.select({
|
||||
id: jobs.id,
|
||||
request: jobs.request,
|
||||
actualAmountSats: jobs.actualAmountSats,
|
||||
createdAt: jobs.createdAt,
|
||||
updatedAt: jobs.updatedAt,
|
||||
selfEvalRating: jobs.selfEvalRating,
|
||||
category: jobs.category,
|
||||
})
|
||||
.from(jobs)
|
||||
.where(eq(jobs.state, "complete"))
|
||||
.orderBy(desc(jobs.updatedAt))
|
||||
.limit(10);
|
||||
|
||||
res.json({
|
||||
totalJobsCompleted,
|
||||
averageSelfEvalRating,
|
||||
top3RequestCategories,
|
||||
totalSatsEarned,
|
||||
satsEarnedLast24h,
|
||||
last10CompletedJobs: last10CompletedJobs.map(job => {
|
||||
const timeElapsedMs = job.updatedAt.getTime() - job.createdAt.getTime();
|
||||
const timeElapsedSeconds = Math.floor(timeElapsedMs / 1000);
|
||||
return {
|
||||
id: job.id,
|
||||
request: job.request.substring(0, 100) + (job.request.length > 100 ? "..." : ""), // Truncate request
|
||||
starRating: job.selfEvalRating,
|
||||
satsCharged: job.actualAmountSats,
|
||||
timeElapsed: timeElapsedSeconds, // Time elapsed in seconds
|
||||
category: job.category,
|
||||
};
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to fetch stats";
|
||||
logger.error("stats fetch failed", { error: message });
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
@@ -29,6 +29,12 @@ const router = Router();
|
||||
* Guarded on stubMode=true; polls until state=provisioning|ready (20 s timeout).
|
||||
* - T24 ADDED: costLedger completeness after job completion — 8 fields, honest-accounting
|
||||
* invariant (actualAmountSats ≤ workAmountSats), refundState enum check.
|
||||
* - T41 ADDED: POST /api/jobs with valid Nostr token → nostrPubkey in response matches identity.
|
||||
* - T42 ADDED: POST /api/sessions with valid Nostr token → nostrPubkey in response matches identity.
|
||||
* - T43 ADDED: GET /identity/me returns full trust fields (tier, score, interactionCount).
|
||||
* - T44 ADDED: POST /identity/me/decay (test-only endpoint, 404 in prod) → score decremented.
|
||||
* - T45 ADDED: GET /identity/leaderboard → HTTP 200, array sorted by trustScore desc.
|
||||
* New endpoints identity/me/decay and identity/leaderboard added to identity.ts.
|
||||
*/
|
||||
router.get("/testkit", (req: Request, res: Response) => {
|
||||
const proto =
|
||||
@@ -1092,6 +1098,208 @@ NODESCRIPT
|
||||
fi
|
||||
fi
|
||||
|
||||
# ===========================================================================
|
||||
# T41–T45 — Nostr identity lifecycle: token decorates jobs/sessions + trust ops
|
||||
# Requires node + nostr-tools (same guard as T36). All five tests share one
|
||||
# inline node script that performs the full lifecycle and emits a JSON blob.
|
||||
# ===========================================================================
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T41–T45 Preamble — ephemeral keypair → challenge → sign → verify → token
|
||||
# Then: create job, create session, GET /identity/me, decay, leaderboard.
|
||||
# ---------------------------------------------------------------------------
|
||||
NOSTR_LC_SKIP=false
|
||||
NOSTR_LC_OUT=""
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
NOSTR_LC_SKIP=true
|
||||
fi
|
||||
if [[ "\$NOSTR_LC_SKIP" == "false" ]]; then
|
||||
NOSTR_LC_TMPFILE=\$(mktemp /tmp/nostr_lc_XXXXXX.cjs)
|
||||
cat > "\$NOSTR_LC_TMPFILE" << 'NODESCRIPT'
|
||||
'use strict';
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const BASE = process.argv[2];
|
||||
let nt;
|
||||
const NOSTR_CJS = '/home/runner/workspace/artifacts/api-server/node_modules/nostr-tools/lib/cjs/index.js';
|
||||
try { nt = require('nostr-tools'); } catch (_) { try { nt = require(NOSTR_CJS); } catch (_) { process.stderr.write('nostr-tools not importable\n'); process.exit(1); } }
|
||||
const { generateSecretKey, getPublicKey, finalizeEvent } = nt;
|
||||
function request(url, opts, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const u = new URL(url);
|
||||
const mod = u.protocol === 'https:' ? https : http;
|
||||
const req = mod.request(u, opts, (res) => {
|
||||
let data = '';
|
||||
res.on('data', c => data += c);
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: data }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
if (body) req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
async function main() {
|
||||
const sk = generateSecretKey();
|
||||
const pubkey = getPublicKey(sk);
|
||||
// challenge → sign → verify
|
||||
const chalRes = await request(BASE + '/api/identity/challenge', { method: 'POST', headers: { 'Content-Type': 'application/json' } }, '{}');
|
||||
if (chalRes.status !== 200) { process.stderr.write('challenge failed: ' + chalRes.status + '\n'); process.exit(1); }
|
||||
const { nonce } = JSON.parse(chalRes.body);
|
||||
const event = finalizeEvent({ kind: 27235, content: nonce, tags: [], created_at: Math.floor(Date.now() / 1000) }, sk);
|
||||
const verRes = await request(BASE + '/api/identity/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' } }, JSON.stringify({ event }));
|
||||
if (verRes.status !== 200) { process.stderr.write('verify failed: ' + verRes.status + ' ' + verRes.body + '\n'); process.exit(1); }
|
||||
const { nostr_token: token } = JSON.parse(verRes.body);
|
||||
// POST /jobs with Nostr token
|
||||
const jobRes = await request(BASE + '/api/jobs', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Nostr-Token': token } }, JSON.stringify({ request: 'T41 Nostr job test' }));
|
||||
const jobBody = JSON.parse(jobRes.body);
|
||||
const jobCode = jobRes.status;
|
||||
const jobId = jobBody.jobId || null;
|
||||
const jobNpub = jobBody.nostrPubkey || null;
|
||||
// POST /sessions with Nostr token
|
||||
const sessRes = await request(BASE + '/api/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Nostr-Token': token } }, JSON.stringify({ amount_sats: 200 }));
|
||||
const sessBody = JSON.parse(sessRes.body);
|
||||
const sessCode = sessRes.status;
|
||||
const sessId = sessBody.sessionId || null;
|
||||
const sessNpub = sessBody.nostrPubkey || null;
|
||||
// GET /identity/me
|
||||
const meRes = await request(BASE + '/api/identity/me', { method: 'GET', headers: { 'X-Nostr-Token': token } });
|
||||
const meBody = JSON.parse(meRes.body);
|
||||
const meScore = meBody.trust ? meBody.trust.score : null;
|
||||
const meTier = meBody.trust ? meBody.trust.tier : null;
|
||||
const meIcount = meBody.trust ? meBody.trust.interactionCount : null;
|
||||
// POST /identity/me/decay (test-only; non-200 → skip T44 gracefully)
|
||||
const decayRes = await request(BASE + '/api/identity/me/decay', { method: 'POST', headers: { 'X-Nostr-Token': token } });
|
||||
const decayBody = JSON.parse(decayRes.body);
|
||||
const decayCode = decayRes.status;
|
||||
const decayPrev = decayBody.previousScore !== undefined ? decayBody.previousScore : null;
|
||||
const decayNew = decayBody.newScore !== undefined ? decayBody.newScore : null;
|
||||
// GET /identity/leaderboard
|
||||
const lbRes = await request(BASE + '/api/identity/leaderboard', { method: 'GET', headers: {} });
|
||||
const lbCode = lbRes.status;
|
||||
let lbBody = [];
|
||||
try { lbBody = JSON.parse(lbRes.body); } catch (_) {}
|
||||
const lbIsArray = Array.isArray(lbBody);
|
||||
const lbSorted = lbIsArray && lbBody.length < 2 ? true :
|
||||
lbIsArray && lbBody.every((v, i) => i === 0 || lbBody[i - 1].trustScore >= v.trustScore);
|
||||
process.stdout.write(JSON.stringify({
|
||||
pubkey, token,
|
||||
jobCode, jobId, jobNpub,
|
||||
sessCode, sessId, sessNpub,
|
||||
meScore, meTier, meIcount,
|
||||
decayCode, decayPrev, decayNew,
|
||||
lbCode, lbIsArray, lbSorted,
|
||||
}) + '\n');
|
||||
}
|
||||
main().catch(err => { process.stderr.write(String(err) + '\n'); process.exit(1); });
|
||||
NODESCRIPT
|
||||
|
||||
NOSTR_LC_EXIT=0
|
||||
NOSTR_LC_OUT=\$(node "\$NOSTR_LC_TMPFILE" "\$BASE" 2>/dev/null) || NOSTR_LC_EXIT=\$?
|
||||
rm -f "\$NOSTR_LC_TMPFILE"
|
||||
if [[ \$NOSTR_LC_EXIT -ne 0 || -z "\$NOSTR_LC_OUT" ]]; then
|
||||
NOSTR_LC_SKIP=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Helper: extract a field from NOSTR_LC_OUT
|
||||
_lc() { echo "\$NOSTR_LC_OUT" | jq -r ".\$1" 2>/dev/null || echo ""; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T41 — POST /jobs with valid Nostr token → nostrPubkey in response
|
||||
# ---------------------------------------------------------------------------
|
||||
sep "Test 41 — POST /jobs with Nostr token → nostrPubkey set"
|
||||
if [[ "\$NOSTR_LC_SKIP" == "true" ]]; then
|
||||
note SKIP "node unavailable or lifecycle preamble failed — skipping T41"
|
||||
SKIP=\$((SKIP+1))
|
||||
else
|
||||
T41_CODE=\$(_lc jobCode); T41_NPUB=\$(_lc jobNpub); T41_PK=\$(_lc pubkey)
|
||||
if [[ "\$T41_CODE" == "201" && -n "\$T41_NPUB" && "\$T41_NPUB" != "null" && "\$T41_NPUB" == "\$T41_PK" ]]; then
|
||||
note PASS "HTTP 201, nostrPubkey=\${T41_NPUB:0:8}... matches token identity"
|
||||
PASS=\$((PASS+1))
|
||||
else
|
||||
note FAIL "code=\$T41_CODE nostrPubkey='\$T41_NPUB' expected='\$T41_PK'"
|
||||
FAIL=\$((FAIL+1))
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T42 — POST /sessions with valid Nostr token → nostrPubkey in response
|
||||
# ---------------------------------------------------------------------------
|
||||
sep "Test 42 — POST /sessions with Nostr token → nostrPubkey set"
|
||||
if [[ "\$NOSTR_LC_SKIP" == "true" ]]; then
|
||||
note SKIP "node unavailable or lifecycle preamble failed — skipping T42"
|
||||
SKIP=\$((SKIP+1))
|
||||
else
|
||||
T42_CODE=\$(_lc sessCode); T42_NPUB=\$(_lc sessNpub); T42_PK=\$(_lc pubkey)
|
||||
if [[ "\$T42_CODE" == "201" && -n "\$T42_NPUB" && "\$T42_NPUB" != "null" && "\$T42_NPUB" == "\$T42_PK" ]]; then
|
||||
note PASS "HTTP 201, nostrPubkey=\${T42_NPUB:0:8}... matches token identity"
|
||||
PASS=\$((PASS+1))
|
||||
else
|
||||
note FAIL "code=\$T42_CODE nostrPubkey='\$T42_NPUB' expected='\$T42_PK'"
|
||||
FAIL=\$((FAIL+1))
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T43 — GET /identity/me returns full trust fields (tier, score, interactionCount)
|
||||
# ---------------------------------------------------------------------------
|
||||
sep "Test 43 — GET /identity/me returns tier + score + interactionCount"
|
||||
if [[ "\$NOSTR_LC_SKIP" == "true" ]]; then
|
||||
note SKIP "node unavailable or lifecycle preamble failed — skipping T43"
|
||||
SKIP=\$((SKIP+1))
|
||||
else
|
||||
T43_TIER=\$(_lc meTier); T43_SCORE=\$(_lc meScore); T43_ICOUNT=\$(_lc meIcount)
|
||||
if [[ -n "\$T43_TIER" && "\$T43_TIER" != "null" \
|
||||
&& "\$T43_SCORE" != "" && "\$T43_SCORE" != "null" \
|
||||
&& "\$T43_ICOUNT" != "" && "\$T43_ICOUNT" != "null" ]]; then
|
||||
note PASS "tier=\$T43_TIER score=\$T43_SCORE interactionCount=\$T43_ICOUNT"
|
||||
PASS=\$((PASS+1))
|
||||
else
|
||||
note FAIL "tier='\$T43_TIER' score='\$T43_SCORE' icount='\$T43_ICOUNT'"
|
||||
FAIL=\$((FAIL+1))
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T44 — POST /identity/me/decay (test-only endpoint) → score decremented
|
||||
# Skipped gracefully if endpoint returns non-200 (e.g., production mode).
|
||||
# ---------------------------------------------------------------------------
|
||||
sep "Test 44 — POST /identity/me/decay (test mode) → trust_score decremented"
|
||||
if [[ "\$NOSTR_LC_SKIP" == "true" ]]; then
|
||||
note SKIP "node unavailable or lifecycle preamble failed — skipping T44"
|
||||
SKIP=\$((SKIP+1))
|
||||
else
|
||||
T44_CODE=\$(_lc decayCode); T44_PREV=\$(_lc decayPrev); T44_NEW=\$(_lc decayNew)
|
||||
if [[ "\$T44_CODE" != "200" ]]; then
|
||||
note SKIP "decay endpoint returned code=\$T44_CODE (not in test mode) — skipping T44"
|
||||
SKIP=\$((SKIP+1))
|
||||
elif [[ -n "\$T44_PREV" && -n "\$T44_NEW" && "\$T44_NEW" =~ ^[0-9]+\$ && "\$T44_PREV" =~ ^[0-9]+\$ && \$T44_NEW -le \$T44_PREV ]]; then
|
||||
note PASS "previousScore=\$T44_PREV newScore=\$T44_NEW (decremented or floored at 0)"
|
||||
PASS=\$((PASS+1))
|
||||
else
|
||||
note FAIL "code=\$T44_CODE previousScore='\$T44_PREV' newScore='\$T44_NEW' (expected new ≤ prev)"
|
||||
FAIL=\$((FAIL+1))
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T45 — GET /identity/leaderboard → HTTP 200, array sorted by trust score
|
||||
# ---------------------------------------------------------------------------
|
||||
sep "Test 45 — GET /identity/leaderboard → sorted array"
|
||||
if [[ "\$NOSTR_LC_SKIP" == "true" ]]; then
|
||||
note SKIP "node unavailable or lifecycle preamble failed — skipping T45"
|
||||
SKIP=\$((SKIP+1))
|
||||
else
|
||||
T45_CODE=\$(_lc lbCode); T45_ARRAY=\$(_lc lbIsArray); T45_SORTED=\$(_lc lbSorted)
|
||||
if [[ "\$T45_CODE" == "200" && "\$T45_ARRAY" == "true" && "\$T45_SORTED" == "true" ]]; then
|
||||
note PASS "HTTP 200, array returned and sorted by trustScore desc"
|
||||
PASS=\$((PASS+1))
|
||||
else
|
||||
note FAIL "code=\$T45_CODE isArray=\$T45_ARRAY sorted=\$T45_SORTED"
|
||||
FAIL=\$((FAIL+1))
|
||||
fi
|
||||
fi
|
||||
|
||||
# ===========================================================================
|
||||
# FUTURE STUBS — placeholders for upcoming tasks (do not affect PASS/FAIL)
|
||||
# ===========================================================================
|
||||
|
||||
21
artifacts/api-server/src/routes/tower-log.ts
Normal file
21
artifacts/api-server/src/routes/tower-log.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { getRecentTowerLog } from "../lib/tower-log.js";
|
||||
import { makeLogger } from "../lib/logger.js";
|
||||
|
||||
const logger = makeLogger("tower-log-route");
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/tower-log — return the 20 most recent narrative entries, oldest first.
|
||||
*/
|
||||
router.get("/tower-log", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const entries = await getRecentTowerLog(20);
|
||||
res.json({ entries });
|
||||
} catch (err) {
|
||||
logger.error("GET /api/tower-log failed", { error: String(err) });
|
||||
res.status(500).json({ error: "tower_log_error" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
15
lib/db/migrations/0010_tower_log.sql
Normal file
15
lib/db/migrations/0010_tower_log.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- Migration: Tower Log narrative event feed (#7)
|
||||
-- Adds the tower_log table that stores prose narrative entries about
|
||||
-- Workshop activity, generated by Haiku on key events.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tower_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
narrative TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
agent_id TEXT,
|
||||
job_id TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tower_log_created_at
|
||||
ON tower_log(created_at DESC);
|
||||
@@ -14,3 +14,4 @@ export * from "./relay-accounts";
|
||||
export * from "./relay-event-queue";
|
||||
export * from "./job-debates";
|
||||
export * from "./session-messages";
|
||||
export * from "./tower-log";
|
||||
|
||||
@@ -26,8 +26,6 @@ export const jobs = pgTable("jobs", {
|
||||
rejectionReason: text("rejection_reason"),
|
||||
result: text("result"),
|
||||
errorMessage: text("error_message"),
|
||||
category: text("category"),
|
||||
selfEvalRating: real("self_eval_rating"),
|
||||
|
||||
// ── Cost-based pricing (set when work invoice is created) ───────────────
|
||||
estimatedCostUsd: real("estimated_cost_usd"),
|
||||
|
||||
10
lib/db/src/schema/tower-log.ts
Normal file
10
lib/db/src/schema/tower-log.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
||||
|
||||
export const towerLog = pgTable("tower_log", {
|
||||
id: text("id").primaryKey(),
|
||||
narrative: text("narrative").notNull(),
|
||||
eventType: text("event_type").notNull(),
|
||||
agentId: text("agent_id"),
|
||||
jobId: text("job_id"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
});
|
||||
@@ -254,21 +254,6 @@
|
||||
text-shadow: 0 0 10px #116633;
|
||||
margin-bottom: 20px; border-bottom: 1px solid #0e2318; padding-bottom: 10px;
|
||||
}
|
||||
#session-close {
|
||||
position: absolute; top: 16px; right: 16px;
|
||||
background: transparent; border: 1px solid #0e2318;
|
||||
color: #226644; font-family: 'Courier New', monospace;
|
||||
font-size: 16px; width: 28px; height: 28px;
|
||||
cursor: pointer; transition: color 0.2s, border-color 0.2s;
|
||||
}
|
||||
box-shadow: 8px 0 32px rgba(10, 50, 25, 0.20);
|
||||
}
|
||||
#session-panel.open { left: 0; }
|
||||
#session-panel h2 {
|
||||
font-size: 13px; letter-spacing: 3px; color: #33bb77;
|
||||
text-shadow: 0 0 10px #116633;
|
||||
margin-bottom: 20px; border-bottom: 1px solid #0e2318; padding-bottom: 10px;
|
||||
}
|
||||
#session-close {
|
||||
position: absolute; top: 16px; right: 16px;
|
||||
background: transparent; border: 1px solid #0e2318;
|
||||
@@ -278,77 +263,6 @@
|
||||
}
|
||||
#session-close:hover { color: #44dd88; border-color: #22aa66; }
|
||||
|
||||
/* ── Stats panel (right side, opens over payment panel) ───────────── */
|
||||
#stats-panel {
|
||||
position: fixed; top: 0; right: -420px;
|
||||
width: 400px; height: 100%;
|
||||
background: rgba(12, 6, 3, 0.97); /* Darker, slightly reddish */
|
||||
border-left: 1px solid #2e1a1a; /* Matching border */
|
||||
padding: 24px 20px;
|
||||
overflow-y: auto; z-index: 101; /* Z-index above payment panel */
|
||||
font-family: 'Courier New', monospace;
|
||||
transition: right 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: -8px 0 32px rgba(120, 60, 40, 0.15); /* Orange-ish shadow */
|
||||
}
|
||||
#stats-panel.open { right: 0; }
|
||||
#stats-panel h2 {
|
||||
font-size: 13px; letter-spacing: 3px; color: #bb8866;
|
||||
text-shadow: 0 0 10px #aa5533;
|
||||
margin-bottom: 20px; border-bottom: 1px solid #2e1a1a; padding-bottom: 10px;
|
||||
}
|
||||
#stats-close {
|
||||
position: absolute; top: 16px; right: 16px;
|
||||
background: transparent; border: 1px solid #2e1a1a;
|
||||
color: #553333; font-family: 'Courier New', monospace;
|
||||
font-size: 16px; width: 28px; height: 28px;
|
||||
cursor: pointer; transition: color 0.2s, border-color 0.2s;
|
||||
}
|
||||
#stats-close:hover { color: #bb8866; border-color: #aa6644; }
|
||||
|
||||
.panel-stats-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 12px; margin-bottom: 20px;
|
||||
}
|
||||
.stat-card {
|
||||
background: rgba(20, 10, 5, 0.8);
|
||||
border: 1px solid #2e1a1a;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.stat-card.wide { grid-column: 1 / -1; }
|
||||
.stat-value {
|
||||
font-size: 24px; font-weight: bold; color: #ffbb88;
|
||||
text-shadow: 0 0 8px #aa6633;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 10px; letter-spacing: 1px; color: #aa7755;
|
||||
}
|
||||
.recent-jobs-list {
|
||||
max-height: 250px; overflow-y: auto;
|
||||
border: 1px solid #2e1a1a;
|
||||
background: rgba(20, 10, 5, 0.6);
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.recent-job-item {
|
||||
border-bottom: 1px solid #2e1a1a;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.recent-job-item:last-child { border-bottom: none; }
|
||||
.job-req {
|
||||
font-size: 11px; color: #ffddbb;
|
||||
line-height: 1.4; margin-bottom: 4px;
|
||||
}
|
||||
.job-meta {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
font-size: 9px; color: #aa7755;
|
||||
}
|
||||
.job-rating { color: #ffd700; }
|
||||
.job-sats { color: #f7931a; }
|
||||
.job-time { color: #aa7755; }
|
||||
|
||||
/* Amount presets */
|
||||
.session-amount-presets {
|
||||
display: flex; gap: 6px; flex-wrap: wrap; margin: 10px 0;
|
||||
@@ -685,6 +599,188 @@
|
||||
#activity-heatmap #heatmap-bar { display: none; }
|
||||
#heatmap-icon-btn { display: block; }
|
||||
}
|
||||
|
||||
/* ── History button ──────────────────────────────────────────────── */
|
||||
#open-history-btn {
|
||||
font-family: 'Courier New', monospace; font-size: 11px; font-weight: bold;
|
||||
color: #aabbdd; background: rgba(20, 16, 50, 0.85); border: 1px solid #2a2a44;
|
||||
padding: 7px 18px; cursor: pointer; letter-spacing: 2px;
|
||||
box-shadow: 0 0 14px #2244aa22;
|
||||
transition: background 0.15s, box-shadow 0.15s, color 0.15s;
|
||||
border-radius: 2px;
|
||||
min-height: 36px;
|
||||
}
|
||||
#open-history-btn:hover, #open-history-btn:active {
|
||||
background: rgba(35, 28, 80, 0.95);
|
||||
box-shadow: 0 0 20px #3355aa44;
|
||||
color: #ccddff;
|
||||
}
|
||||
|
||||
/* ── History panel (bottom sheet) ───────────────────────────────── */
|
||||
#history-panel {
|
||||
position: fixed; bottom: -100%; left: 0; right: 0;
|
||||
height: 70vh;
|
||||
background: rgba(5, 3, 12, 0.97);
|
||||
border-top: 1px solid #1a1a2e;
|
||||
z-index: 100;
|
||||
font-family: 'Courier New', monospace;
|
||||
transition: bottom 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 -8px 32px rgba(40, 60, 120, 0.18);
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
#history-panel.open { bottom: 60px; }
|
||||
|
||||
.hist-header {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 14px 20px 10px;
|
||||
border-bottom: 1px solid #1a1a2e;
|
||||
font-size: 12px; letter-spacing: 3px; color: #5577aa;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.hist-header span { flex: 1; text-shadow: 0 0 8px #2244aa66; }
|
||||
#history-refresh-btn, #history-close {
|
||||
background: transparent; border: 1px solid #1a1a2e;
|
||||
color: #334466; font-family: 'Courier New', monospace;
|
||||
font-size: 11px; padding: 3px 10px; cursor: pointer;
|
||||
transition: color 0.2s, border-color 0.2s; letter-spacing: 1px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
#history-refresh-btn:hover { color: #5577aa; border-color: #334466; }
|
||||
#history-refresh-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
#history-close { font-size: 14px; padding: 3px 8px; }
|
||||
#history-close:hover { color: #6688bb; border-color: #4466aa; }
|
||||
|
||||
#history-list {
|
||||
flex: 1; overflow-y: auto; padding: 12px 16px;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.hist-empty {
|
||||
color: #334466; font-size: 11px; letter-spacing: 1px;
|
||||
line-height: 1.8; text-align: center;
|
||||
margin-top: 40px; padding: 0 20px;
|
||||
}
|
||||
|
||||
.hist-row {
|
||||
border: 1px solid #1a1a2e; border-radius: 2px;
|
||||
margin-bottom: 10px; overflow: hidden;
|
||||
background: #060310;
|
||||
}
|
||||
.hist-row.hist-rejected { border-color: #331111; }
|
||||
.hist-row-header {
|
||||
padding: 10px 12px; cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.hist-row-header:hover { background: rgba(30, 25, 60, 0.6); }
|
||||
.hist-prompt {
|
||||
color: #aabbdd; font-size: 12px; line-height: 1.5;
|
||||
display: -webkit-box; -webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical; overflow: hidden;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.hist-meta {
|
||||
display: flex; gap: 12px; align-items: center;
|
||||
}
|
||||
.hist-cost { font-size: 10px; color: #ffcc44; letter-spacing: 1px; }
|
||||
.hist-rejected .hist-cost { color: #994444; }
|
||||
.hist-time { font-size: 10px; color: #334466; letter-spacing: 0.5px; flex: 1; }
|
||||
.hist-chevron { font-size: 10px; color: #334466; transition: color 0.15s; }
|
||||
.hist-row-header:hover .hist-chevron { color: #5577aa; }
|
||||
|
||||
.hist-row-body {
|
||||
max-height: 0; overflow: hidden;
|
||||
transition: max-height 0.3s ease-out;
|
||||
}
|
||||
.hist-row.expanded .hist-row-body {
|
||||
max-height: 400px;
|
||||
border-top: 1px solid #1a1a2e;
|
||||
}
|
||||
.hist-result {
|
||||
color: #aabbdd; font-family: 'Courier New', monospace;
|
||||
font-size: 11px; line-height: 1.6;
|
||||
white-space: pre-wrap; word-break: break-word;
|
||||
padding: 12px; margin: 0;
|
||||
max-height: 400px; overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── Tower Log button ────────────────────────────────────────────── */
|
||||
#open-tower-log-btn {
|
||||
font-family: 'Courier New', monospace; font-size: 11px; font-weight: bold;
|
||||
color: #ccaaff; background: rgba(25, 10, 45, 0.85); border: 1px solid #663399;
|
||||
padding: 7px 18px; cursor: pointer; letter-spacing: 2px;
|
||||
box-shadow: 0 0 14px #44116622;
|
||||
transition: background 0.15s, box-shadow 0.15s, color 0.15s;
|
||||
border-radius: 2px;
|
||||
min-height: 36px;
|
||||
}
|
||||
#open-tower-log-btn:hover, #open-tower-log-btn:active {
|
||||
background: rgba(45, 18, 80, 0.95);
|
||||
box-shadow: 0 0 20px #55228844;
|
||||
color: #eeddff;
|
||||
}
|
||||
|
||||
/* ── Tower Log panel (bottom sheet) ─────────────────────────────── */
|
||||
#tower-log-panel {
|
||||
position: fixed; bottom: -100%; left: 0; right: 0;
|
||||
height: 65vh;
|
||||
background: rgba(6, 3, 14, 0.97);
|
||||
border-top: 1px solid #2a1040;
|
||||
z-index: 100;
|
||||
font-family: 'Courier New', monospace;
|
||||
transition: bottom 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 -8px 32px rgba(80, 30, 130, 0.18);
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
#tower-log-panel.open { bottom: 60px; }
|
||||
|
||||
.tlog-header {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 14px 20px 10px;
|
||||
border-bottom: 1px solid #2a1040;
|
||||
font-size: 12px; letter-spacing: 3px; color: #9966cc;
|
||||
flex-shrink: 0;
|
||||
text-shadow: 0 0 8px #66228866;
|
||||
}
|
||||
.tlog-header span { flex: 1; }
|
||||
#tower-log-close {
|
||||
background: transparent; border: 1px solid #2a1040;
|
||||
color: #664488; font-family: 'Courier New', monospace;
|
||||
font-size: 14px; padding: 3px 8px; cursor: pointer;
|
||||
transition: color 0.2s, border-color 0.2s; border-radius: 2px;
|
||||
}
|
||||
#tower-log-close:hover { color: #bb88ff; border-color: #8844bb; }
|
||||
|
||||
#tower-log-list {
|
||||
flex: 1; overflow-y: auto; padding: 12px 20px;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.tlog-empty {
|
||||
color: #44224466; font-size: 11px; letter-spacing: 1px;
|
||||
line-height: 1.8; text-align: center;
|
||||
margin-top: 40px; padding: 0 20px;
|
||||
}
|
||||
|
||||
.tlog-entry {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #1a0a2a;
|
||||
display: flex; gap: 10px; align-items: baseline;
|
||||
animation: tlog-fade-in 0.4s ease-out;
|
||||
}
|
||||
.tlog-entry:last-child { border-bottom: none; }
|
||||
@keyframes tlog-fade-in {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.tlog-time {
|
||||
font-size: 9px; color: #443355; letter-spacing: 0.5px;
|
||||
white-space: nowrap; flex-shrink: 0; min-width: 48px;
|
||||
}
|
||||
.tlog-text {
|
||||
font-size: 11px; color: #bb99dd; line-height: 1.5;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.tlog-new { color: #ddbbff; text-shadow: 0 0 6px #9944cc44; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -725,8 +821,9 @@
|
||||
<!-- ── Top action buttons ─────────────────────────────────────────── -->
|
||||
<div id="top-buttons">
|
||||
<button id="open-panel-btn">⚡ SUBMIT JOB</button>
|
||||
<button id="open-stats-btn">📊 TIMMY STATS</button>
|
||||
<button id="open-session-btn">⚡ FUND SESSION</button>
|
||||
<button id="open-history-btn">⏱ HISTORY</button>
|
||||
<button id="open-tower-log-btn">📜 TOWER LOG</button>
|
||||
<a id="relay-admin-btn" href="/admin/relay">⚙ RELAY ADMIN</a>
|
||||
</div>
|
||||
|
||||
@@ -788,48 +885,6 @@
|
||||
<div id="job-error"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── Stats panel (right side, opens over payment panel) ──────────────── -->
|
||||
<div id="stats-panel">
|
||||
<button id="stats-close">✕</button>
|
||||
<h2>📊 TIMMY — PERFORMANCE</h2>
|
||||
|
||||
<div class="panel-stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="total-jobs-completed">--</div>
|
||||
<div class="stat-label">JOBS COMPLETED</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="average-self-eval-rating">--</div>
|
||||
<div class="stat-label">AVG RATING (1-5)</div>
|
||||
</div>
|
||||
<div class="stat-card wide">
|
||||
<div class="stat-value" id="top-request-categories">--</div>
|
||||
<div class="stat-label">TOP 3 CATEGORIES</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="total-sats-earned">--</div>
|
||||
<div class="stat-label">TOTAL SAT EARNED</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="sats-earned-24h">--</div>
|
||||
<div class="stat-label">SATS LAST 24H</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-label" style="margin-top: 20px;">RECENT JOBS</div>
|
||||
<div class="recent-jobs-list" id="recent-jobs-list">
|
||||
<!-- Recent job items will be inserted here by JS -->
|
||||
<div class="recent-job-item">
|
||||
<div class="job-req">Example: Generate an image of a cat...</div>
|
||||
<div class="job-meta">
|
||||
<span class="job-rating">⭐ 4</span>
|
||||
<span class="job-sats">⚡ 100 sats</span>
|
||||
<span class="job-time">15s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Session panel (left side) ─────────────────────────────────── -->
|
||||
<div id="session-panel">
|
||||
<button id="session-close">✕</button>
|
||||
@@ -918,6 +973,16 @@
|
||||
<div id="session-error"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── History panel (bottom sheet) ─────────────────────────────── -->
|
||||
<div id="history-panel">
|
||||
<div class="hist-header">
|
||||
<span>⏱ JOB HISTORY</span>
|
||||
<button id="history-refresh-btn">↺ REFRESH</button>
|
||||
<button id="history-close">✕</button>
|
||||
</div>
|
||||
<div id="history-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── FPS crosshair ─────────────────────────────────────────────── -->
|
||||
<div id="crosshair"></div>
|
||||
|
||||
@@ -938,19 +1003,18 @@
|
||||
<span class="recovery-text">GPU context lost — recovering...</span>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function _timmyCopy(elementId) {
|
||||
const el = document.getElementById(elementId);
|
||||
if (el) {
|
||||
navigator.clipboard.writeText(el.textContent || el.innerText).then(() => {
|
||||
// Optional: Add some visual feedback that text was copied
|
||||
console.log('Copied to clipboard:', el.textContent);
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy text:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
<!-- ── Tower Log panel (bottom sheet) ────────────────────────────── -->
|
||||
<div id="tower-log-panel">
|
||||
<div class="tlog-header">
|
||||
<span>📜 TOWER LOG</span>
|
||||
<button id="tower-log-close">✕</button>
|
||||
</div>
|
||||
<div id="tower-log-list">
|
||||
<div class="tlog-empty" id="tower-log-empty">The chronicle awaits… events will appear here as Timmy works his magic.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Show Relay Admin button if admin token is stored in localStorage
|
||||
(function() {
|
||||
if (localStorage.getItem('relay_admin_token')) {
|
||||
|
||||
222
the-matrix/js/history.js
Normal file
222
the-matrix/js/history.js
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* history.js — Job history panel for Timmy Tower Workshop.
|
||||
*
|
||||
* Persists completed jobs in localStorage and renders them in a
|
||||
* bottom-sheet panel with expandable results and pull-to-refresh.
|
||||
*
|
||||
* Public API:
|
||||
* addHistoryEntry(entry) — called by payment.js / session.js on completion
|
||||
* initHistoryPanel() — wire up DOM (call once from main.js)
|
||||
*/
|
||||
|
||||
const LS_KEY = 'timmy_history_v1';
|
||||
const MAX_ENTRIES = 50;
|
||||
|
||||
// ── Persistence ───────────────────────────────────────────────────────────────
|
||||
|
||||
function _loadEntries() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function _saveEntries(entries) {
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(entries));
|
||||
} catch { /* storage full — oldest already trimmed */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a completed job.
|
||||
* @param {object} entry
|
||||
* @param {string} entry.jobId
|
||||
* @param {string} entry.request — user prompt
|
||||
* @param {number} entry.costSats — sats charged (0 for free/session)
|
||||
* @param {string} entry.result — AI answer or rejection reason
|
||||
* @param {string} entry.state — 'complete' | 'rejected' | 'failed'
|
||||
* @param {string} [entry.completedAt] — ISO timestamp (defaults to now)
|
||||
*/
|
||||
export function addHistoryEntry({ jobId, request, costSats, result, state, completedAt }) {
|
||||
const entries = _loadEntries();
|
||||
const entry = {
|
||||
jobId: jobId ?? `local-${Date.now()}`,
|
||||
request: request ?? '',
|
||||
costSats: costSats ?? 0,
|
||||
result: result ?? '',
|
||||
state: state ?? 'complete',
|
||||
completedAt: completedAt ?? new Date().toISOString(),
|
||||
};
|
||||
const idx = entries.findIndex(e => e.jobId === entry.jobId);
|
||||
if (idx >= 0) {
|
||||
entries[idx] = entry;
|
||||
} else {
|
||||
entries.unshift(entry); // newest first
|
||||
if (entries.length > MAX_ENTRIES) entries.length = MAX_ENTRIES;
|
||||
}
|
||||
_saveEntries(entries);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function _relativeTime(isoString) {
|
||||
try {
|
||||
const diff = Date.now() - new Date(isoString).getTime();
|
||||
const secs = Math.floor(diff / 1000);
|
||||
if (secs < 60) return `${secs}s ago`;
|
||||
const mins = Math.floor(secs / 60);
|
||||
if (mins < 60) return `${mins} min ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
return `${days}d ago`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function _escHtml(text) {
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function _truncate(text, maxLen) {
|
||||
if (!text) return '';
|
||||
return text.length > maxLen ? text.slice(0, maxLen) + '…' : text;
|
||||
}
|
||||
|
||||
// ── Rendering ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function _renderEntries(entries, container) {
|
||||
container.innerHTML = '';
|
||||
|
||||
if (!entries.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'hist-empty';
|
||||
empty.textContent = 'No completed jobs yet. Submit a job to see your history here.';
|
||||
container.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
entries.forEach(entry => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'hist-row' + (entry.state === 'rejected' ? ' hist-rejected' : '');
|
||||
|
||||
// ── Header (always visible) ────────────────────────────────────────────
|
||||
const header = document.createElement('div');
|
||||
header.className = 'hist-row-header';
|
||||
|
||||
const promptEl = document.createElement('div');
|
||||
promptEl.className = 'hist-prompt';
|
||||
promptEl.textContent = _truncate(entry.request, 140);
|
||||
|
||||
const metaEl = document.createElement('div');
|
||||
metaEl.className = 'hist-meta';
|
||||
|
||||
const costEl = document.createElement('span');
|
||||
costEl.className = 'hist-cost';
|
||||
if (entry.state === 'rejected') {
|
||||
costEl.textContent = 'rejected';
|
||||
} else if (entry.costSats > 0) {
|
||||
costEl.textContent = `⚡ ${entry.costSats} sats`;
|
||||
} else {
|
||||
costEl.textContent = 'free';
|
||||
}
|
||||
|
||||
const timeEl = document.createElement('span');
|
||||
timeEl.className = 'hist-time';
|
||||
timeEl.textContent = _relativeTime(entry.completedAt);
|
||||
|
||||
const chevronEl = document.createElement('span');
|
||||
chevronEl.className = 'hist-chevron';
|
||||
chevronEl.textContent = '▸';
|
||||
|
||||
metaEl.appendChild(costEl);
|
||||
metaEl.appendChild(timeEl);
|
||||
metaEl.appendChild(chevronEl);
|
||||
|
||||
header.appendChild(promptEl);
|
||||
header.appendChild(metaEl);
|
||||
|
||||
// ── Body (expandable) ──────────────────────────────────────────────────
|
||||
const body = document.createElement('div');
|
||||
body.className = 'hist-row-body';
|
||||
|
||||
const pre = document.createElement('pre');
|
||||
pre.className = 'hist-result';
|
||||
pre.textContent = entry.result || '(no result)';
|
||||
body.appendChild(pre);
|
||||
|
||||
// ── Toggle ─────────────────────────────────────────────────────────────
|
||||
let expanded = false;
|
||||
header.addEventListener('click', () => {
|
||||
expanded = !expanded;
|
||||
row.classList.toggle('expanded', expanded);
|
||||
chevronEl.textContent = expanded ? '▾' : '▸';
|
||||
});
|
||||
|
||||
row.appendChild(header);
|
||||
row.appendChild(body);
|
||||
container.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Panel state ───────────────────────────────────────────────────────────────
|
||||
|
||||
let _panel = null;
|
||||
let _list = null;
|
||||
let _refreshBtn = null;
|
||||
|
||||
function _open() {
|
||||
if (!_panel) return;
|
||||
_panel.classList.add('open');
|
||||
_refresh();
|
||||
}
|
||||
|
||||
function _close() {
|
||||
_panel?.classList.remove('open');
|
||||
}
|
||||
|
||||
function _refresh() {
|
||||
if (!_list) return;
|
||||
const entries = _loadEntries();
|
||||
_renderEntries(entries, _list);
|
||||
if (_refreshBtn) {
|
||||
_refreshBtn.textContent = '↺ REFRESH';
|
||||
_refreshBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function initHistoryPanel() {
|
||||
_panel = document.getElementById('history-panel');
|
||||
_list = document.getElementById('history-list');
|
||||
_refreshBtn = document.getElementById('history-refresh-btn');
|
||||
if (!_panel) return;
|
||||
|
||||
document.getElementById('open-history-btn')?.addEventListener('click', _open);
|
||||
document.getElementById('history-close')?.addEventListener('click', _close);
|
||||
|
||||
if (_refreshBtn) {
|
||||
_refreshBtn.addEventListener('click', () => {
|
||||
_refreshBtn.textContent = '↺ …';
|
||||
_refreshBtn.disabled = true;
|
||||
setTimeout(_refresh, 150);
|
||||
});
|
||||
}
|
||||
|
||||
// Pull-to-refresh: detect downward drag when already scrolled to top
|
||||
let _touchStartY = 0;
|
||||
_list?.addEventListener('touchstart', e => {
|
||||
_touchStartY = e.touches[0].clientY;
|
||||
}, { passive: true });
|
||||
_list?.addEventListener('touchend', e => {
|
||||
const dy = e.changedTouches[0].clientY - _touchStartY;
|
||||
if (dy > 60 && _list.scrollTop === 0) {
|
||||
_refresh();
|
||||
}
|
||||
}, { passive: true });
|
||||
}
|
||||
@@ -8,9 +8,10 @@ import {
|
||||
import { initEffects, updateEffects, disposeEffects, updateJobIndicators } from './effects.js';
|
||||
import { initUI, updateUI } from './ui.js';
|
||||
import { initInteraction, disposeInteraction, registerSlapTarget } from './interaction.js';
|
||||
import { initWebSocket, getConnectionState, getJobCount } from './websocket.js';
|
||||
import { initWebSocket, getConnectionState, getJobCount, initTowerLog } from './websocket.js';
|
||||
import { initPaymentPanel } from './payment.js';
|
||||
import { initSessionPanel } from './session.js';
|
||||
import { initHistoryPanel } from './history.js';
|
||||
import { initNostrIdentity } from './nostr-identity.js';
|
||||
import { warmup as warmupEdgeWorker, onReady as onEdgeWorkerReady } from './edge-worker-client.js';
|
||||
import { setEdgeWorkerReady } from './ui.js';
|
||||
@@ -44,8 +45,10 @@ function buildWorld(firstInit, stateSnapshot) {
|
||||
if (firstInit) {
|
||||
initUI();
|
||||
initWebSocket(scene);
|
||||
initTowerLog();
|
||||
initPaymentPanel();
|
||||
initSessionPanel();
|
||||
initHistoryPanel();
|
||||
void initNostrIdentity('/api');
|
||||
warmupEdgeWorker();
|
||||
onEdgeWorkerReady(() => setEdgeWorkerReady());
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
|
||||
import { getOrRefreshToken } from './nostr-identity.js';
|
||||
import { addHistoryEntry } from './history.js';
|
||||
|
||||
const API_BASE = '/api';
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
@@ -18,6 +19,7 @@ const POLL_TIMEOUT_MS = 60000;
|
||||
let panel = null;
|
||||
let closeBtn = null;
|
||||
let currentJobId = null;
|
||||
let currentRequest = '';
|
||||
let pollTimer = null;
|
||||
|
||||
export function initPaymentPanel() {
|
||||
@@ -96,6 +98,7 @@ async function submitJob() {
|
||||
if (!res.ok) { setError(data.error || 'Failed to create job.'); return; }
|
||||
|
||||
currentJobId = data.jobId;
|
||||
currentRequest = request;
|
||||
showEvalInvoice(data.evalInvoice);
|
||||
} catch (err) {
|
||||
setError('Network error: ' + err.message);
|
||||
@@ -167,7 +170,7 @@ function startPolling() {
|
||||
const pollHeaders = token ? { 'X-Nostr-Token': token } : {};
|
||||
const res = await fetch(`${API_BASE}/jobs/${currentJobId}`, { headers: pollHeaders });
|
||||
const data = await res.json();
|
||||
const { state, workInvoice, result, reason } = data;
|
||||
const { state, workInvoice, result, reason, costLedger, completedAt } = data;
|
||||
|
||||
if (state === 'awaiting_work_payment' && workInvoice) {
|
||||
showWorkInvoice(workInvoice);
|
||||
@@ -175,10 +178,26 @@ function startPolling() {
|
||||
return;
|
||||
}
|
||||
if (state === 'complete') {
|
||||
addHistoryEntry({
|
||||
jobId: currentJobId,
|
||||
request: currentRequest,
|
||||
costSats: costLedger?.workAmountSats ?? costLedger?.actualAmountSats ?? 0,
|
||||
result,
|
||||
state: 'complete',
|
||||
completedAt: completedAt ?? new Date().toISOString(),
|
||||
});
|
||||
showResult(result, 'complete');
|
||||
return;
|
||||
}
|
||||
if (state === 'rejected') {
|
||||
addHistoryEntry({
|
||||
jobId: currentJobId,
|
||||
request: currentRequest,
|
||||
costSats: 0,
|
||||
result: reason,
|
||||
state: 'rejected',
|
||||
completedAt: completedAt ?? new Date().toISOString(),
|
||||
});
|
||||
showResult(reason, 'rejected');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { setSpeechBubble, setMood } from './agents.js';
|
||||
import { appendSystemMessage, setSessionSendHandler, setInputBarSessionMode } from './ui.js';
|
||||
import { getOrRefreshToken } from './nostr-identity.js';
|
||||
import { sentiment } from './edge-worker-client.js';
|
||||
import { addHistoryEntry } from './history.js';
|
||||
|
||||
const API = '/api';
|
||||
const LS_KEY = 'timmy_session_v1';
|
||||
@@ -152,12 +153,21 @@ export async function sessionSendHandler(text) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prevBalance = _balanceSats;
|
||||
_balanceSats = data.balanceRemaining ?? 0;
|
||||
_sessionState = _balanceSats < MIN_BALANCE ? 'paused' : 'active';
|
||||
_saveToStorage();
|
||||
_applySessionUI();
|
||||
|
||||
const reply = data.result || data.reason || '…';
|
||||
const costSats = Math.max(0, prevBalance - _balanceSats);
|
||||
addHistoryEntry({
|
||||
request: text,
|
||||
costSats,
|
||||
result: reply,
|
||||
state: data.reason && !data.result ? 'rejected' : 'complete',
|
||||
completedAt: new Date().toISOString(),
|
||||
});
|
||||
setSpeechBubble(reply);
|
||||
appendSystemMessage('Timmy: ' + reply.slice(0, 80));
|
||||
|
||||
|
||||
@@ -480,489 +480,6 @@ async function _fetchAndRenderHeatmap() {
|
||||
}
|
||||
}
|
||||
|
||||
import { sendVisitorMessage } from './websocket.js';
|
||||
import { classify } from './edge-worker-client.js';
|
||||
import { setMood, setSpeechBubble } from './agents.js';
|
||||
import { getOrRefreshToken, getPubkey, disconnectNostrIdentity, showIdentityPrompt } from './nostr-identity.js';
|
||||
|
||||
const $fps = document.getElementById('fps');
|
||||
const $activeJobs = document.getElementById('active-jobs');
|
||||
const $connStatus = document.getElementById('connection-status');
|
||||
const $log = document.getElementById('event-log');
|
||||
|
||||
const MAX_LOG = 6;
|
||||
const logEntries = [];
|
||||
let uiInitialized = false;
|
||||
|
||||
// ── Session-mode send override ────────────────────────────────────────────────
|
||||
let _sessionSendHandler = null;
|
||||
|
||||
export function setSessionSendHandler(fn) {
|
||||
_sessionSendHandler = fn;
|
||||
}
|
||||
|
||||
export function setInputBarSessionMode(active, placeholder) {
|
||||
const $input = document.getElementById('visitor-input');
|
||||
if (!$input) return;
|
||||
if (active) {
|
||||
$input.classList.add('session-active');
|
||||
$input.placeholder = placeholder || 'Ask Timmy (session active)…';
|
||||
} else {
|
||||
$input.classList.remove('session-active');
|
||||
$input.placeholder = 'Say something to Timmy…';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Model-ready indicator ─────────────────────────────────────────────────────
|
||||
// A small badge on the input bar showing when local AI is warm and ready.
|
||||
// Hidden until the first `ready` event from the edge worker.
|
||||
|
||||
let $readyBadge = null;
|
||||
|
||||
export function setEdgeWorkerReady() {
|
||||
if (!$readyBadge) {
|
||||
$readyBadge = document.createElement('span');
|
||||
$readyBadge.id = 'edge-ready-badge';
|
||||
$readyBadge.title = 'Local AI active — trivial queries answered without Lightning payment';
|
||||
$readyBadge.style.cssText = [
|
||||
'font-size:10px;color:#44cc88;border:1px solid #226644',
|
||||
'border-radius:3px;padding:1px 5px;margin-left:6px',
|
||||
'vertical-align:middle;cursor:default',
|
||||
].join(';');
|
||||
$readyBadge.textContent = '⚡ local AI';
|
||||
const $input = document.getElementById('visitor-input');
|
||||
$input?.insertAdjacentElement('afterend', $readyBadge);
|
||||
// Fallback: append to send button area
|
||||
if (!$readyBadge.isConnected) {
|
||||
document.getElementById('send-btn')?.insertAdjacentElement('afterend', $readyBadge);
|
||||
}
|
||||
}
|
||||
$readyBadge.style.display = '';
|
||||
}
|
||||
|
||||
// ── Cost preview badge ────────────────────────────────────────────────────────
|
||||
// Shown beneath the input bar: "~N sats" / "FREE" / "answered locally".
|
||||
// Fetched from GET /api/estimate once the user stops typing (300 ms debounce).
|
||||
|
||||
let _estimateTimer = null;
|
||||
let $costPreview = null;
|
||||
|
||||
function _ensureCostPreview() {
|
||||
if ($costPreview) return $costPreview;
|
||||
$costPreview = document.getElementById('timmy-cost-preview');
|
||||
if (!$costPreview) {
|
||||
$costPreview = document.createElement('div');
|
||||
$costPreview.id = 'timmy-cost-preview';
|
||||
$costPreview.style.cssText = 'font-size:11px;color:#88aacc;margin-top:3px;min-height:14px;transition:opacity .3s;opacity:0;';
|
||||
const $input = document.getElementById('visitor-input');
|
||||
$input?.parentElement?.appendChild($costPreview);
|
||||
}
|
||||
return $costPreview;
|
||||
}
|
||||
|
||||
function _showCostPreview(text, color = '#88aacc') {
|
||||
const el = _ensureCostPreview();
|
||||
el.textContent = text;
|
||||
el.style.color = color;
|
||||
el.style.opacity = '1';
|
||||
}
|
||||
|
||||
function _hideCostPreview() {
|
||||
const el = _ensureCostPreview();
|
||||
el.style.opacity = '0';
|
||||
}
|
||||
|
||||
async function _fetchEstimate(text) {
|
||||
try {
|
||||
const token = await getOrRefreshToken('/api');
|
||||
const params = new URLSearchParams({ request: text });
|
||||
const fetchOpts = {};
|
||||
if (token) {
|
||||
fetchOpts.headers = { 'X-Nostr-Token': token };
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/estimate?${params}`, fetchOpts);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
|
||||
const ft = data.identity?.free_tier;
|
||||
if (ft?.serve === 'free') {
|
||||
_showCostPreview('FREE via generosity pool', '#44dd88');
|
||||
} else if (ft?.serve === 'partial') {
|
||||
_showCostPreview(`~${ft.chargeSats} sats (${ft.absorbSats} absorbed)`, '#ffdd44');
|
||||
} else {
|
||||
const sats = data.estimatedSats ?? '?';
|
||||
_showCostPreview(`~${sats} sats estimated`, '#88aacc');
|
||||
}
|
||||
} catch {
|
||||
_hideCostPreview();
|
||||
}
|
||||
}
|
||||
|
||||
// Fast trivial heuristic — same pattern as edge-worker.js _isGreeting().
|
||||
// Prevents /api/estimate network calls for greeting messages on every keypress.
|
||||
const _TRIVIAL_RE = /^(hi|hey|hello|howdy|greetings|yo|sup|hiya|what'?s up)[!?.,]?\s*$/i;
|
||||
|
||||
function _scheduleCostPreview(text) {
|
||||
clearTimeout(_estimateTimer);
|
||||
if (!text || text.length < 4) { _hideCostPreview(); return; }
|
||||
// Skip estimate entirely for trivially local messages — zero network calls
|
||||
if (_TRIVIAL_RE.test(text.trim())) {
|
||||
_showCostPreview('answered locally ⚡ 0 sats', '#44dd88');
|
||||
return;
|
||||
}
|
||||
_estimateTimer = setTimeout(() => _fetchEstimate(text), 300);
|
||||
}
|
||||
|
||||
// ── Live cost ticker ──────────────────────────────────────────────────────────
|
||||
// Shown in the top-right HUD during active paid interactions.
|
||||
// Updated via WebSocket `cost_update` messages from the backend.
|
||||
|
||||
let $costTicker = null;
|
||||
let _tickerHideTimer = null;
|
||||
|
||||
function _ensureCostTicker() {
|
||||
if ($costTicker) return $costTicker;
|
||||
$costTicker = document.getElementById('timmy-cost-ticker');
|
||||
if (!$costTicker) {
|
||||
$costTicker = document.createElement('div');
|
||||
$costTicker.id = 'timmy-cost-ticker';
|
||||
$costTicker.style.cssText = [
|
||||
'position:fixed;top:36px;right:16px',
|
||||
'font-size:11px;font-family:"Courier New",monospace',
|
||||
'color:#ffcc44;text-shadow:0 0 6px #aa8822',
|
||||
'letter-spacing:1px',
|
||||
'pointer-events:none;z-index:10',
|
||||
'transition:opacity .4s;opacity:0',
|
||||
].join(';');
|
||||
document.body.appendChild($costTicker);
|
||||
}
|
||||
return $costTicker;
|
||||
}
|
||||
|
||||
export function showCostTicker(sats) {
|
||||
clearTimeout(_tickerHideTimer);
|
||||
const el = _ensureCostTicker();
|
||||
el.textContent = `⚡ ~${sats} sats`;
|
||||
el.style.opacity = '1';
|
||||
}
|
||||
|
||||
export function updateCostTicker(sats, isFinal = false) {
|
||||
clearTimeout(_tickerHideTimer);
|
||||
const el = _ensureCostTicker();
|
||||
el.textContent = isFinal ? `⚡ ${sats} sats charged` : `⚡ ~${sats} sats`;
|
||||
el.style.opacity = '1';
|
||||
if (isFinal) {
|
||||
_tickerHideTimer = setTimeout(hideCostTicker, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
export function hideCostTicker() {
|
||||
if (!$costTicker) return;
|
||||
$costTicker.style.opacity = '0';
|
||||
}
|
||||
|
||||
// ── Nostr identity UI ─────────────────────────────────────────────────────────
|
||||
|
||||
let _nostrStatusEl = null;
|
||||
let _connectNostrBtn = null;
|
||||
let _disconnectNostrBtn = null;
|
||||
let _nostrPubkeyDisplay = null;
|
||||
let _getAlbyBtn = null;
|
||||
|
||||
export function initNostrIdentityUI() {
|
||||
_nostrStatusEl = document.getElementById('nostr-identity-status');
|
||||
if (!_nostrStatusEl) return;
|
||||
|
||||
_nostrStatusEl.innerHTML = `
|
||||
<button id="connect-nostr-btn" class="nostr-btn">⚡ Connect Nostr</button>
|
||||
<span id="nostr-pubkey-display" class="nostr-pubkey"></span>
|
||||
<button id="disconnect-nostr-btn" class="nostr-btn nostr-btn-sm">Disconnect</button>
|
||||
<button id="get-alby-btn" class="nostr-btn nostr-btn-sm">Get Alby</button>
|
||||
`;
|
||||
|
||||
_connectNostrBtn = document.getElementById('connect-nostr-btn');
|
||||
_disconnectNostrBtn = document.getElementById('disconnect-nostr-btn');
|
||||
_nostrPubkeyDisplay = document.getElementById('nostr-pubkey-display');
|
||||
_getAlbyBtn = document.getElementById('get-alby-btn');
|
||||
|
||||
if (_connectNostrBtn) {
|
||||
_connectNostrBtn.addEventListener('click', () => {
|
||||
showIdentityPrompt('/api');
|
||||
});
|
||||
}
|
||||
|
||||
if (_disconnectNostrBtn) {
|
||||
_disconnectNostrBtn.addEventListener('click', () => {
|
||||
disconnectNostrIdentity();
|
||||
_updateNostrIdentityUI(null);
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('nostr:identity-ready', e => {
|
||||
_updateNostrIdentityUI(e.detail.pubkey);
|
||||
});
|
||||
|
||||
window.addEventListener('nostr:identity-disconnected', () => {
|
||||
_updateNostrIdentityUI(null);
|
||||
});
|
||||
|
||||
_updateNostrIdentityUI(getPubkey());
|
||||
}
|
||||
|
||||
function _updateNostrIdentityUI(pubkey) {
|
||||
const hasNip07 = typeof window !== 'undefined' && !!window.nostr;
|
||||
|
||||
if (pubkey) {
|
||||
const formattedPubkey = pubkey.slice(0, 8) + '…' + pubkey.slice(-4);
|
||||
if (_nostrPubkeyDisplay) {
|
||||
_nostrPubkeyDisplay.textContent = `⚡ ${formattedPubkey}`;
|
||||
_nostrPubkeyDisplay.style.display = 'inline-block';
|
||||
}
|
||||
if (_connectNostrBtn) _connectNostrBtn.style.display = 'none';
|
||||
if (_disconnectNostrBtn) _disconnectNostrBtn.style.display = 'inline-block';
|
||||
if (_getAlbyBtn) _getAlbyBtn.style.display = 'none';
|
||||
} else {
|
||||
if (_nostrPubkeyDisplay) _nostrPubkeyDisplay.style.display = 'none';
|
||||
if (_disconnectNostrBtn) _disconnectNostrBtn.style.display = 'none';
|
||||
|
||||
if (hasNip07) {
|
||||
if (_connectNostrBtn) {
|
||||
_connectNostrBtn.textContent = '⚡ Connect Nostr';
|
||||
_connectNostrBtn.style.display = 'inline-block';
|
||||
}
|
||||
if (_getAlbyBtn) _getAlbyBtn.style.display = 'none';
|
||||
} else {
|
||||
if (_connectNostrBtn) _connectNostrBtn.style.display = 'none';
|
||||
if (_getAlbyBtn) {
|
||||
_getAlbyBtn.textContent = 'Get Alby';
|
||||
_getAlbyBtn.style.display = 'inline-block';
|
||||
_getAlbyBtn.title = 'Install Alby or another NIP-07 extension to connect your Nostr identity';
|
||||
_getAlbyBtn.onclick = () => window.open('https://getalby.com/', '_blank');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Input bar ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function initUI() {
|
||||
if (uiInitialized) return;
|
||||
uiInitialized = true;
|
||||
initInputBar();
|
||||
initHeatmap();
|
||||
initNostrIdentityUI();
|
||||
initStatsPanel(); // Initialize the new stats panel
|
||||
}
|
||||
|
||||
function initInputBar() {
|
||||
const $input = document.getElementById('visitor-input');
|
||||
const $sendBtn = document.getElementById('send-btn');
|
||||
if (!$input || !$sendBtn) return;
|
||||
|
||||
$input.addEventListener('input', () => _scheduleCostPreview($input.value.trim()));
|
||||
|
||||
async function send() {
|
||||
const text = $input.value.trim();
|
||||
if (!text) return;
|
||||
$input.value = '';
|
||||
_hideCostPreview();
|
||||
|
||||
// ── Edge triage — runs in BOTH session mode and WebSocket mode ─────────────
|
||||
// Worker returns { complexity:'trivial'|'moderate'|'complex', score, reason, localReply? }
|
||||
const cls = await classify(text);
|
||||
|
||||
if (cls.complexity === 'trivial' && cls.localReply) {
|
||||
// Greeting / small-talk → answer locally, 0 sats, no network call in any mode
|
||||
appendSystemMessage(`you: ${text}`);
|
||||
setSpeechBubble(`${cls.localReply} ⚡ local`);
|
||||
_showCostPreview('answered locally ⚡ 0 sats', '#44dd88');
|
||||
setTimeout(_hideCostPreview, 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-trivial: delegate to session handler (if active) or WebSocket
|
||||
if (_sessionSendHandler) {
|
||||
// moderate/complex — fire estimate async for cost preview, then hand off
|
||||
if (cls.complexity === 'moderate' || cls.complexity === 'complex') {
|
||||
_fetchEstimate(text);
|
||||
}
|
||||
_sessionSendHandler(text);
|
||||
return;
|
||||
}
|
||||
|
||||
// moderate or complex — fetch cost estimate (driven by complexity outcome),
|
||||
// then route to server via WebSocket.
|
||||
if (cls.complexity === 'moderate' || cls.complexity === 'complex') {
|
||||
_fetchEstimate(text);
|
||||
}
|
||||
|
||||
// Route to server via WebSocket
|
||||
sendVisitorMessage(text);
|
||||
appendSystemMessage(`you: ${text}`);
|
||||
}
|
||||
|
||||
$sendBtn.addEventListener('click', send);
|
||||
$input.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
|
||||
});
|
||||
}
|
||||
|
||||
export function updateUI({ fps, jobCount, connectionState }) {
|
||||
if ($fps) $fps.textContent = `FPS: ${fps}`;
|
||||
if ($activeJobs) $activeJobs.textContent = `JOBS: ${jobCount}`;
|
||||
|
||||
if ($connStatus) {
|
||||
if (connectionState === 'connected') {
|
||||
$connStatus.textContent = '● CONNECTED';
|
||||
$connStatus.className = 'connected';
|
||||
} else if (connectionState === 'connecting') {
|
||||
$connStatus.textContent = '◌ CONNECTING...';
|
||||
$connStatus.className = '';
|
||||
} else {
|
||||
$connStatus.textContent = '○ OFFLINE';
|
||||
$connStatus.className = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function appendSystemMessage(text) {
|
||||
if (!$log) return;
|
||||
const el = document.createElement('div');
|
||||
el.className = 'log-entry';
|
||||
el.textContent = text;
|
||||
logEntries.push(el);
|
||||
if (logEntries.length > MAX_LOG) {
|
||||
const removed = logEntries.shift();
|
||||
$log.removeChild(removed);
|
||||
}
|
||||
$log.appendChild(el);
|
||||
$log.scrollTop = $log.scrollHeight;
|
||||
}
|
||||
|
||||
export function appendChatMessage(agentLabel, message, cssColor, agentId) {
|
||||
void agentLabel; void cssColor; void agentId;
|
||||
appendSystemMessage(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a debate argument or verdict in the event log (#21).
|
||||
* Visually distinct from regular chat: colored by agent with a debate prefix.
|
||||
*/
|
||||
export function appendDebateMessage(agent, argument, isVerdict, accepted) {
|
||||
if (!$log) return;
|
||||
const el = document.createElement('div');
|
||||
el.className = 'log-entry debate-entry';
|
||||
if (isVerdict) {
|
||||
el.classList.add('debate-verdict');
|
||||
el.classList.add(accepted ? 'debate-accepted' : 'debate-rejected');
|
||||
el.textContent = `⚖ ${agent}: ${argument}`;
|
||||
} else {
|
||||
el.classList.add(agent === 'Beta-A' ? 'debate-a' : 'debate-b');
|
||||
el.textContent = `⚖ ${agent}: ${(argument || '').slice(0, 120)}`;
|
||||
}
|
||||
logEntries.push(el);
|
||||
if (logEntries.length > MAX_LOG) {
|
||||
const removed = logEntries.shift();
|
||||
$log.removeChild(removed);
|
||||
}
|
||||
$log.appendChild(el);
|
||||
$log.scrollTop = $log.scrollHeight;
|
||||
}
|
||||
|
||||
export function loadChatHistory() { return []; }
|
||||
export function saveChatHistory() {}
|
||||
|
||||
// ── Activity heatmap (#9) ─────────────────────────────────────────────────────
|
||||
// Fetches /api/stats/activity and renders a 24-segment heatmap.
|
||||
// Auto-refreshes every 5 minutes. On mobile, collapses to an icon that opens
|
||||
// a full-screen overlay.
|
||||
|
||||
const HEATMAP_REFRESH_MS = 5 * 60 * 1000; // 5 minutes
|
||||
let _heatmapTimer = null;
|
||||
let _lastHours = null; // number[24] cached for overlay re-render
|
||||
|
||||
/** Convert an hour index (0 = oldest, 23 = current) to a UTC hour label like "3pm" or "midnight". */
|
||||
function _hourLabel(hourIndex) {
|
||||
const now = new Date();
|
||||
const currentHour = now.getUTCHours();
|
||||
// slot 23 = current UTC hour, slot 0 = 23 hours ago
|
||||
const h = ((currentHour - (23 - hourIndex)) % 24 + 24) % 24;
|
||||
if (h === 0) return 'midnight';
|
||||
if (h === 12) return 'noon';
|
||||
return h < 12 ? `${h}am` : `${h - 12}pm`;
|
||||
}
|
||||
|
||||
/** Interpolate from dim blue (#111133) to bright blue-white (#88ccff) based on 0–1 intensity. */
|
||||
function _segmentColor(intensity) {
|
||||
// dim: [17, 17, 51] bright: [136, 204, 255]
|
||||
const r = Math.round(17 + (136 - 17) * intensity);
|
||||
const g = Math.round(17 + (204 - 17) * intensity);
|
||||
const b = Math.round(51 + (255 - 51) * intensity);
|
||||
return `rgb(${r},${g},${b})`;
|
||||
}
|
||||
|
||||
function _renderSegments(hours, container, isMobile) {
|
||||
container.innerHTML = '';
|
||||
const max = Math.max(...hours, 1); // avoid div-by-zero
|
||||
const currentSlot = 23;
|
||||
|
||||
hours.forEach((count, i) => {
|
||||
const seg = document.createElement('div');
|
||||
seg.className = 'hm-seg' + (i === currentSlot ? ' hm-seg-current' : '');
|
||||
const intensity = count / max;
|
||||
const color = _segmentColor(intensity);
|
||||
seg.style.background = color;
|
||||
if (i === currentSlot) seg.style.color = color; // used by pulse animation
|
||||
seg.dataset.index = String(i);
|
||||
seg.dataset.count = String(count);
|
||||
if (isMobile) {
|
||||
seg.style.width = '14px';
|
||||
seg.style.height = '28px';
|
||||
}
|
||||
container.appendChild(seg);
|
||||
});
|
||||
}
|
||||
|
||||
function _initHeatmapTooltip(barEl) {
|
||||
const $tip = document.getElementById('heatmap-tooltip');
|
||||
if (!$tip) return;
|
||||
|
||||
barEl.addEventListener('mousemove', e => {
|
||||
const seg = e.target.closest('.hm-seg');
|
||||
if (!seg) { $tip.style.display = 'none'; return; }
|
||||
const i = Number(seg.dataset.index);
|
||||
const count = Number(seg.dataset.count);
|
||||
const label = _hourLabel(i);
|
||||
$tip.textContent = `${label}: ${count} job${count !== 1 ? 's' : ''} submitted`;
|
||||
$tip.style.display = 'block';
|
||||
$tip.style.left = `${e.clientX + 10}px`;
|
||||
$tip.style.top = `${e.clientY - 24}px`;
|
||||
});
|
||||
|
||||
barEl.addEventListener('mouseleave', () => { $tip.style.display = 'none'; });
|
||||
}
|
||||
|
||||
async function _fetchAndRenderHeatmap() {
|
||||
try {
|
||||
const res = await fetch('/api/stats/activity');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const hours = Array.isArray(data.hours) ? data.hours : [];
|
||||
if (hours.length !== 24) return;
|
||||
_lastHours = hours;
|
||||
|
||||
const $bar = document.getElementById('heatmap-bar');
|
||||
if ($bar) _renderSegments(hours, $bar, false);
|
||||
|
||||
const $overlayBar = document.getElementById('heatmap-overlay-bar');
|
||||
if ($overlayBar) _renderSegments(hours, $overlayBar, true);
|
||||
} catch {
|
||||
// silently ignore fetch errors
|
||||
}
|
||||
}
|
||||
|
||||
export function initHeatmap() {
|
||||
const $bar = document.getElementById('heatmap-bar');
|
||||
const $iconBtn = document.getElementById('heatmap-icon-btn');
|
||||
@@ -989,80 +506,3 @@ export function initHeatmap() {
|
||||
void _fetchAndRenderHeatmap();
|
||||
_heatmapTimer = setInterval(_fetchAndRenderHeatmap, HEATMAP_REFRESH_MS);
|
||||
}
|
||||
|
||||
// ── Stats panel ──────────────────────────────────────────────────────────
|
||||
|
||||
const STATS_REFRESH_MS = 30 * 1000; // 30 seconds
|
||||
let _statsRefreshTimer = null;
|
||||
|
||||
let $openStatsBtn = null;
|
||||
let $statsPanel = null;
|
||||
let $statsCloseBtn = null;
|
||||
let $totalJobsCompleted = null;
|
||||
let $averageSelfEvalRating = null;
|
||||
let $topRequestCategories = null;
|
||||
let $totalSatsEarned = null;
|
||||
let $satsEarned24h = null;
|
||||
let $recentJobsList = null;
|
||||
|
||||
export function initStatsPanel() {
|
||||
$openStatsBtn = document.getElementById('open-stats-btn');
|
||||
$statsPanel = document.getElementById('stats-panel');
|
||||
$statsCloseBtn = document.getElementById('stats-close');
|
||||
$totalJobsCompleted = document.getElementById('total-jobs-completed');
|
||||
$averageSelfEvalRating = document.getElementById('average-self-eval-rating');
|
||||
$topRequestCategories = document.getElementById('top-request-categories');
|
||||
$totalSatsEarned = document.getElementById('total-sats-earned');
|
||||
$satsEarned24h = document.getElementById('sats-earned-24h');
|
||||
$recentJobsList = document.getElementById('recent-jobs-list');
|
||||
|
||||
if ($openStatsBtn) {
|
||||
$openStatsBtn.addEventListener('click', () => {
|
||||
$statsPanel?.classList.add('open');
|
||||
void _fetchAndRenderStats();
|
||||
_statsRefreshTimer = setInterval(_fetchAndRenderStats, STATS_REFRESH_MS);
|
||||
});
|
||||
}
|
||||
|
||||
if ($statsCloseBtn) {
|
||||
$statsCloseBtn.addEventListener('click', () => {
|
||||
$statsPanel?.classList.remove('open');
|
||||
clearInterval(_statsRefreshTimer);
|
||||
_statsRefreshTimer = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function _fetchAndRenderStats() {
|
||||
try {
|
||||
const res = await fetch('/api/stats');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
|
||||
if ($totalJobsCompleted) $totalJobsCompleted.textContent = data.totalJobsCompleted ?? '--';
|
||||
if ($averageSelfEvalRating) $averageSelfEvalRating.textContent = (data.averageSelfEvalRating ?? 0).toFixed(1);
|
||||
if ($topRequestCategories) $topRequestCategories.textContent = data.top3RequestCategories.join(', ') || 'None';
|
||||
if ($totalSatsEarned) $totalSatsEarned.textContent = data.totalSatsEarned ?? '--';
|
||||
if ($satsEarned24h) $satsEarned24h.textContent = data.satsEarnedLast24h ?? '--';
|
||||
|
||||
if ($recentJobsList) {
|
||||
$recentJobsList.innerHTML = '';
|
||||
data.last10CompletedJobs.forEach(job => {
|
||||
const jobItem = document.createElement('div');
|
||||
jobItem.className = 'recent-job-item';
|
||||
jobItem.innerHTML = `
|
||||
<div class="job-req">${job.request}</div>
|
||||
<div class="job-meta">
|
||||
<span class="job-rating">⭐ ${job.starRating ?? '-'}</span>
|
||||
<span class="job-sats">⚡ ${job.satsCharged ?? '-'} sats</span>
|
||||
<span class="job-time">${job.timeElapsed ?? '-'}s</span>
|
||||
<span class="job-category">${job.category ?? '-'}</span>
|
||||
</div>
|
||||
`;
|
||||
$recentJobsList?.appendChild(jobItem);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch and render stats:', err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ let jobCount = 0;
|
||||
let reconnectTimer = null;
|
||||
let visitorId = null;
|
||||
const RECONNECT_DELAY_MS = 5000;
|
||||
let _towerLogHistory = [];
|
||||
|
||||
// Map to keep track of active job indicator positions for offsetting
|
||||
const _jobIndicatorOffsets = new Map();
|
||||
@@ -190,6 +191,23 @@ function handleMessage(msg) {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tower_log_history': {
|
||||
// Load history when panel opens
|
||||
if (Array.isArray(msg.entries)) {
|
||||
_towerLogHistory = msg.entries;
|
||||
_renderTowerLog();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tower_log_entry': {
|
||||
// New entry streamed in real time
|
||||
_towerLogHistory.push(msg);
|
||||
if (_towerLogHistory.length > 20) _towerLogHistory.shift();
|
||||
_renderTowerLog(msg.id); // pass id to highlight new entry
|
||||
break;
|
||||
}
|
||||
|
||||
case 'agent_count':
|
||||
case 'visitor_count':
|
||||
break;
|
||||
@@ -205,3 +223,79 @@ export function sendVisitorMessage(text) {
|
||||
|
||||
export function getConnectionState() { return connectionState; }
|
||||
export function getJobCount() { return jobCount; }
|
||||
|
||||
// ── Tower Log panel ────────────────────────────────────────────────────────
|
||||
|
||||
function _renderTowerLog(newId) {
|
||||
const list = document.getElementById('tower-log-list');
|
||||
const empty = document.getElementById('tower-log-empty');
|
||||
if (!list) return;
|
||||
|
||||
if (_towerLogHistory.length === 0) {
|
||||
if (empty) empty.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
if (empty) empty.style.display = 'none';
|
||||
|
||||
// Remove old entries (keep only the empty placeholder and rebuild)
|
||||
Array.from(list.querySelectorAll('.tlog-entry')).forEach(el => el.remove());
|
||||
|
||||
for (const entry of _towerLogHistory) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'tlog-entry' + (entry.id === newId ? ' tlog-new' : '');
|
||||
el.dataset.id = entry.id;
|
||||
|
||||
const t = document.createElement('div');
|
||||
t.className = 'tlog-time';
|
||||
const d = new Date(entry.createdAt);
|
||||
t.textContent = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
const n = document.createElement('div');
|
||||
n.className = 'tlog-text';
|
||||
n.textContent = entry.narrative;
|
||||
|
||||
el.appendChild(t);
|
||||
el.appendChild(n);
|
||||
list.appendChild(el);
|
||||
}
|
||||
|
||||
// Auto-scroll to bottom
|
||||
list.scrollTop = list.scrollHeight;
|
||||
|
||||
// Fade new entry highlight after 3s
|
||||
if (newId) {
|
||||
setTimeout(() => {
|
||||
const el = list.querySelector(`[data-id="${newId}"]`);
|
||||
if (el) el.classList.remove('tlog-new');
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
export function initTowerLog() {
|
||||
const openBtn = document.getElementById('open-tower-log-btn');
|
||||
const panel = document.getElementById('tower-log-panel');
|
||||
const closeBtn = document.getElementById('tower-log-close');
|
||||
if (!openBtn || !panel || !closeBtn) return;
|
||||
|
||||
openBtn.addEventListener('click', () => {
|
||||
panel.classList.add('open');
|
||||
// Fetch history if empty
|
||||
if (_towerLogHistory.length === 0) {
|
||||
fetch('/api/tower-log')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (Array.isArray(data.entries)) {
|
||||
_towerLogHistory = data.entries;
|
||||
_renderTowerLog();
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
} else {
|
||||
_renderTowerLog();
|
||||
}
|
||||
});
|
||||
|
||||
closeBtn.addEventListener('click', () => {
|
||||
panel.classList.remove('open');
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user