2 Commits

Author SHA1 Message Date
Alexander Whitestone
f13a1d0235 feat: Gemini image generation in Workshop chat (#19)
Some checks failed
CI / Typecheck & Lint (pull_request) Failing after 1s
- Add image intent detection (draw/illustrate/visualize/create an image)
  via `detectImageRequest()` in agent.ts; exports used by jobs and sessions
- Add `executeImageWork()` to AgentService: calls Gemini generateImage with
  graceful fallback stub PNG when Gemini credentials are absent
- Add `job_media` table (migration 0010) for base64 image storage with 7-day TTL;
  entity_id is polymorphic for both jobs and session requests
- Add `media_type TEXT` column to jobs table (flagged during eval phase)
- Add `calculateImageFeeSats()` / `calculateImageFeeUsd()` to PricingService;
  uses IMAGE_GENERATION_FLAT_RATE_USD env var (default $0.04)
- Jobs route: detect image jobs in eval phase, route to Gemini in execution,
  store image in job_media; expose GET /api/jobs/:id/media endpoint
- Sessions route: detect image requests, call executeImageWork, store in
  job_media, return mediaUrl and mediaType in response
- Estimate route: return image pricing and mediaType:'image' for image requests
- Event bus: add optional mediaUrl/mediaType to job:completed event
- Frontend session.js: render generated images inline with download button

Fixes #19

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 22:36:10 -04:00
1e2edeee77 [claude] Nostr identity lifecycle coverage T41–T45 (#55) (#106) 2026-03-24 02:20:34 +00:00
19 changed files with 721 additions and 1010 deletions

View File

@@ -2,6 +2,23 @@ import { makeLogger } from "./logger.js";
const logger = makeLogger("agent");
// ── Image request detection ───────────────────────────────────────────────────
const IMAGE_INTENT_RE =
/\b(draw|illustrate|create\s+an?\s+image\s+of|generate\s+an?\s+image\s+of|visualize|visualise|make\s+an?\s+image\s+of|paint\s+me|sketch|render\s+an?\s+image\s+of|picture\s+of)\b/i;
/**
* Returns true if the request text signals an image-generation intent.
*/
export function detectImageRequest(text: string): boolean {
return IMAGE_INTENT_RE.test(text);
}
export interface ImageWorkResult {
b64_json: string;
mimeType: string;
}
export interface EvalResult {
accepted: boolean;
reason: string;
@@ -442,6 +459,36 @@ Respond ONLY with valid JSON: {"accepted": true/false, "reason": "..."}`,
return "";
}
}
/**
* Generate an image via Gemini for the given prompt.
* Falls back to a stub 1×1 transparent PNG when Gemini credentials are absent.
*/
async executeImageWork(prompt: string): Promise<ImageWorkResult> {
const geminiAvailable =
!!process.env["AI_INTEGRATIONS_GEMINI_API_KEY"] &&
!!process.env["AI_INTEGRATIONS_GEMINI_BASE_URL"];
if (!geminiAvailable) {
logger.warn("Gemini credentials absent — returning stub image", { component: "agent" });
// 1×1 transparent PNG (base64)
return {
b64_json:
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
mimeType: "image/png",
};
}
try {
const mod = (await import("@workspace/integrations-gemini-ai")) as {
generateImage: (prompt: string) => Promise<{ b64_json: string; mimeType: string }>;
};
return await mod.generateImage(prompt);
} catch (err) {
logger.error("Gemini image generation failed", { error: String(err) });
throw err;
}
}
}
export const agentService = new AgentService();

View File

@@ -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
}

View File

@@ -3,7 +3,7 @@ import { EventEmitter } from "events";
export type JobEvent =
| { type: "job:state"; jobId: string; state: string }
| { type: "job:paid"; jobId: string; invoiceType: "eval" | "work" }
| { type: "job:completed"; jobId: string; result: string }
| { type: "job:completed"; jobId: string; result: string; mediaUrl?: string; mediaType?: string }
| { type: "job:failed"; jobId: string; reason: string };
export type SessionEvent =

View File

@@ -1,7 +0,0 @@
export const Status = {
OK: 200,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
NOT_FOUND: 404,
INTERNAL_SERVER_ERROR: 500,
};

View File

@@ -62,6 +62,11 @@ const DO_INFRA_PER_REQUEST_USD = DO_MONTHLY_COST_USD / DO_MONTHLY_REQUESTS;
const ORIGINATOR_MARGIN_PCT = envFloat("ORIGINATOR_MARGIN_PCT", 25);
// ── Image generation flat rate ────────────────────────────────────────────────
// Charged in addition to eval fee; covers Gemini imagen costs + margin.
const IMAGE_GENERATION_FLAT_RATE_USD = envFloat("IMAGE_GENERATION_FLAT_RATE_USD", 0.04);
// ── Fixed fees ────────────────────────────────────────────────────────────────
const EVAL_FEE_SATS = envInt("EVAL_FEE_SATS", 10);
@@ -95,6 +100,25 @@ export class PricingService {
return BOOTSTRAP_FEE_SATS;
}
/**
* Flat USD cost for a single image generation request (covers Gemini + margin).
*/
calculateImageFeeUsd(): number {
return IMAGE_GENERATION_FLAT_RATE_USD * (1 + this.marginPct / 100);
}
/**
* Convert image flat rate to sats. Includes infra amortisation and margin.
* Returns the same shape as calculateWorkFeeSats() for drop-in use.
*/
async calculateImageFeeSats(): Promise<WorkFeeBreakdown> {
const rawCostUsd = IMAGE_GENERATION_FLAT_RATE_USD + DO_INFRA_PER_REQUEST_USD;
const estimatedCostUsd = rawCostUsd * (1 + this.marginPct / 100);
const btcPriceUsd = await getBtcPriceUsd();
const amountSats = usdToSats(estimatedCostUsd, btcPriceUsd);
return { amountSats, estimatedCostUsd, marginPct: this.marginPct, btcPriceUsd };
}
// ── Token estimation ─────────────────────────────────────────────────────
/**

View File

@@ -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

View File

@@ -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();

View File

@@ -1,6 +1,6 @@
import { Router, type Request, type Response } from "express";
import { pricingService } from "../lib/pricing.js";
import { agentService } from "../lib/agent.js";
import { agentService, detectImageRequest } from "../lib/agent.js";
import { getBtcPriceUsd, usdToSats } from "../lib/btc-oracle.js";
import { freeTierService } from "../lib/free-tier.js";
import { trustService } from "../lib/trust.js";
@@ -25,10 +25,27 @@ router.get("/estimate", async (req: Request, res: Response) => {
}
try {
const { estimatedInputTokens: inputTokens, estimatedOutputTokens: outputTokens, estimatedCostUsd: costUsd } =
pricingService.estimateRequestCost(requestText, agentService.workModel);
const btcPriceUsd = await getBtcPriceUsd();
const estimatedSats = usdToSats(costUsd, btcPriceUsd);
const isImageRequest = detectImageRequest(requestText);
let inputTokens = 0;
let outputTokens = 0;
let costUsd: number;
let btcPriceUsd: number;
let estimatedSats: number;
if (isImageRequest) {
const imageBreakdown = await pricingService.calculateImageFeeSats();
costUsd = imageBreakdown.estimatedCostUsd;
btcPriceUsd = imageBreakdown.btcPriceUsd;
estimatedSats = imageBreakdown.amountSats;
} else {
const estimate = pricingService.estimateRequestCost(requestText, agentService.workModel);
inputTokens = estimate.estimatedInputTokens;
outputTokens = estimate.estimatedOutputTokens;
costUsd = estimate.estimatedCostUsd;
btcPriceUsd = await getBtcPriceUsd();
estimatedSats = usdToSats(costUsd, btcPriceUsd);
}
// Optionally resolve Nostr identity from query param or header for free-tier preview
const rawToken =
@@ -59,10 +76,11 @@ router.get("/estimate", async (req: Request, res: Response) => {
estimatedSats,
estimatedCostUsd: costUsd,
btcPriceUsd,
...(isImageRequest ? { mediaType: "image" } : {}),
tokenEstimate: {
inputTokens,
outputTokens,
model: agentService.workModel,
model: isImageRequest ? "gemini-2.5-flash-image" : agentService.workModel,
},
identity: {
trust_tier: trustTier,

View File

@@ -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;

View File

@@ -1,10 +1,10 @@
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 { db, jobs, invoices, jobDebates, jobMedia, type Job } from "@workspace/db";
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";
import { agentService, detectImageRequest } from "../lib/agent.js";
import { pricingService } from "../lib/pricing.js";
import { jobsLimiter } from "../lib/rate-limiter.js";
import { eventBus } from "../lib/event-bus.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");
@@ -111,12 +110,18 @@ async function runEvalInBackground(
}
if (evalResult.accepted) {
const { estimatedInputTokens, estimatedOutputTokens } = pricingService.estimateRequestCost(request, agentService.workModel);
const breakdown = await pricingService.calculateWorkFeeSats(
estimatedInputTokens,
estimatedOutputTokens,
agentService.workModel,
);
// Detect image-generation requests and flag job accordingly
const isImageJob = detectImageRequest(request);
if (isImageJob) {
await db.update(jobs).set({ mediaType: "image", updatedAt: new Date() }).where(eq(jobs.id, jobId));
}
const breakdown = isImageJob
? await pricingService.calculateImageFeeSats()
: await (async () => {
const { estimatedInputTokens, estimatedOutputTokens } = pricingService.estimateRequestCost(request, agentService.workModel);
return pricingService.calculateWorkFeeSats(estimatedInputTokens, estimatedOutputTokens, agentService.workModel);
})();
// ── Free-tier gate ──────────────────────────────────────────────────
const ftDecision = await freeTierService.decide(nostrPubkey, breakdown.amountSats);
@@ -255,18 +260,49 @@ async function runWorkInBackground(
try {
eventBus.publish({ type: "job:state", jobId, state: "executing" });
const workResult = await agentService.executeWorkStreaming(request, (delta) => {
streamRegistry.write(jobId, delta);
});
// Check if this is an image job
const jobRow = await getJobById(jobId);
const isImageJob = jobRow?.mediaType === "image";
let resultText = "";
let mediaUrl: string | undefined;
let inputTokensUsed = 0;
let outputTokensUsed = 0;
if (isImageJob) {
// Generate image via Gemini
const imageResult = await agentService.executeImageWork(request);
const mediaId = randomUUID();
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
await db.insert(jobMedia).values({
id: mediaId,
entityId: jobId,
entityType: "job",
mediaType: "image",
mimeType: imageResult.mimeType,
data: imageResult.b64_json,
expiresAt,
});
mediaUrl = `/api/jobs/${jobId}/media`;
resultText = `Image generated. View at: ${mediaUrl}`;
streamRegistry.write(jobId, resultText);
} else {
const workResult = await agentService.executeWorkStreaming(request, (delta) => {
streamRegistry.write(jobId, delta);
});
resultText = workResult.result;
inputTokensUsed = workResult.inputTokens;
outputTokensUsed = workResult.outputTokens;
}
streamRegistry.end(jobId);
latencyHistogram.record("work_phase", Date.now() - workStart);
const actualCostUsd = pricingService.calculateActualCostUsd(
workResult.inputTokens,
workResult.outputTokens,
agentService.workModel,
);
const actualCostUsd = isImageJob
? pricingService.calculateImageFeeUsd()
: pricingService.calculateActualCostUsd(inputTokensUsed, outputTokensUsed, agentService.workModel);
const lockedBtcPrice = btcPriceUsd ?? 100_000;
const actualTotalCostSats = pricingService.calculateActualChargeSats(actualCostUsd, lockedBtcPrice);
@@ -285,23 +321,17 @@ 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({
state: "complete",
result: workResult.result,
actualInputTokens: workResult.inputTokens,
actualOutputTokens: workResult.outputTokens,
result: resultText,
actualInputTokens: isImageJob ? null : inputTokensUsed,
actualOutputTokens: isImageJob ? null : outputTokensUsed,
actualCostUsd,
actualAmountSats,
refundAmountSats,
refundState,
category,
selfEvalRating,
updatedAt: new Date(),
})
.where(eq(jobs.id, jobId));
@@ -309,13 +339,14 @@ async function runWorkInBackground(
logger.info("work completed", {
jobId,
isFree,
inputTokens: workResult.inputTokens,
outputTokens: workResult.outputTokens,
isImageJob,
inputTokens: inputTokensUsed,
outputTokens: outputTokensUsed,
actualAmountSats,
refundAmountSats,
refundState,
});
eventBus.publish({ type: "job:completed", jobId, result: workResult.result });
eventBus.publish({ type: "job:completed", jobId, result: resultText, ...(mediaUrl ? { mediaUrl, mediaType: "image" } : {}) });
// Emit final actual cost for the UI cost ticker
if (!isFree && actualAmountSats > 0) {
eventBus.publish({ type: "cost:update", jobId, sats: actualAmountSats, phase: "work", isFinal: true });
@@ -674,6 +705,7 @@ router.get("/jobs/:id", async (req: Request, res: Response) => {
res.json({
...base,
result: job.result ?? undefined,
...(job.mediaType === "image" ? { mediaType: "image", mediaUrl: `/api/jobs/${job.id}/media` } : {}),
...(job.actualCostUsd != null ? {
costLedger: {
// Token usage
@@ -713,6 +745,44 @@ router.get("/jobs/:id", async (req: Request, res: Response) => {
}
});
// ── GET /jobs/:id/media ───────────────────────────────────────────────────────
router.get("/jobs/:id/media", async (req: Request, res: Response) => {
const paramResult = GetJobParams.safeParse(req.params);
if (!paramResult.success) { res.status(400).json({ error: "Invalid job id" }); return; }
const { id } = paramResult.data;
try {
const rows = await db
.select()
.from(jobMedia)
.where(eq(jobMedia.entityId, id))
.limit(1);
const media = rows[0];
if (!media) {
res.status(404).json({ error: "No media found for this job" });
return;
}
if (new Date() > media.expiresAt) {
res.status(410).json({ error: "Media has expired" });
return;
}
res.json({
jobId: id,
mediaType: media.mediaType,
mimeType: media.mimeType,
data: media.data,
expiresAt: media.expiresAt.toISOString(),
});
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to fetch media";
res.status(500).json({ error: message });
}
});
// ── POST /jobs/:id/refund ─────────────────────────────────────────────────────
router.post("/jobs/:id/refund", async (req: Request, res: Response) => {
@@ -907,148 +977,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;

View File

@@ -1,11 +1,11 @@
import { Router, type Request, type Response } from "express";
import { randomBytes, randomUUID, createHash } from "crypto";
import { db, sessions, sessionRequests, sessionMessages, getSessionHistory, type Session } from "@workspace/db";
import { db, sessions, sessionRequests, sessionMessages, jobMedia, getSessionHistory, type Session } from "@workspace/db";
import { eq, and } from "drizzle-orm";
import { lnbitsService } from "../lib/lnbits.js";
import { sessionsLimiter } from "../lib/rate-limiter.js";
import { eventBus } from "../lib/event-bus.js";
import { agentService } from "../lib/agent.js";
import { agentService, detectImageRequest } from "../lib/agent.js";
import { pricingService } from "../lib/pricing.js";
import { getBtcPriceUsd, usdToSats } from "../lib/btc-oracle.js";
import { trustService } from "../lib/trust.js";
@@ -336,6 +336,11 @@ router.post("/sessions/:id/request", async (req: Request, res: Response) => {
let finalState: "complete" | "rejected" | "failed" = "rejected";
let reason: string | null = null;
let errorMessage: string | null = null;
let mediaUrl: string | null = null;
let mediaType: string | null = null;
// Detect image generation intent before pricing estimate
const isImageRequest = detectImageRequest(requestText);
// ── Pre-gate: free-tier decision on ESTIMATED cost before executing work ──
// Estimate total request cost (work portion) pre-execution to determine subsidy.
@@ -345,26 +350,59 @@ router.post("/sessions/:id/request", async (req: Request, res: Response) => {
let ftDecision: import("../lib/free-tier.js").FreeTierDecision | null = null;
if (evalResult.accepted && session.nostrPubkey) {
// estimateRequestCost includes infra + margin. Convert to sats for decide().
const { estimatedCostUsd } = pricingService.estimateRequestCost(requestText, agentService.workModel);
const estimatedSats = usdToSats(estimatedCostUsd, btcPriceUsd);
let estimatedSats: number;
if (isImageRequest) {
const imageBreakdown = await pricingService.calculateImageFeeSats();
estimatedSats = imageBreakdown.amountSats;
} else {
const { estimatedCostUsd } = pricingService.estimateRequestCost(requestText, agentService.workModel);
estimatedSats = usdToSats(estimatedCostUsd, btcPriceUsd);
}
ftDecision = await freeTierService.decide(session.nostrPubkey, estimatedSats);
}
if (evalResult.accepted) {
try {
const workResult = await agentService.executeWork(requestText, history);
workInputTokens = workResult.inputTokens;
workOutputTokens = workResult.outputTokens;
workCostUsd = pricingService.calculateActualCostUsd(
workResult.inputTokens,
workResult.outputTokens,
agentService.workModel,
);
result = workResult.result;
finalState = "complete";
} catch (err) {
errorMessage = err instanceof Error ? err.message : "Execution error";
finalState = "failed";
if (isImageRequest) {
try {
const imageResult = await agentService.executeImageWork(requestText);
const mediaId = randomUUID();
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
await db.insert(jobMedia).values({
id: mediaId,
entityId: requestId,
entityType: "session_request",
mediaType: "image",
mimeType: imageResult.mimeType,
data: imageResult.b64_json,
expiresAt,
});
mediaUrl = `/api/sessions/${id}/requests/${requestId}/media`;
mediaType = "image";
workCostUsd = pricingService.calculateImageFeeUsd();
result = `Image generated. View at: ${mediaUrl}`;
finalState = "complete";
} catch (err) {
errorMessage = err instanceof Error ? err.message : "Image generation error";
finalState = "failed";
}
} else {
try {
const workResult = await agentService.executeWork(requestText, history);
workInputTokens = workResult.inputTokens;
workOutputTokens = workResult.outputTokens;
workCostUsd = pricingService.calculateActualCostUsd(
workResult.inputTokens,
workResult.outputTokens,
agentService.workModel,
);
result = workResult.result;
finalState = "complete";
} catch (err) {
errorMessage = err instanceof Error ? err.message : "Execution error";
finalState = "failed";
}
}
} else {
reason = evalResult.reason;
@@ -491,6 +529,7 @@ router.post("/sessions/:id/request", async (req: Request, res: Response) => {
...(result ? { result } : {}),
...(reason ? { reason } : {}),
...(errorMessage ? { errorMessage } : {}),
...(mediaUrl ? { mediaUrl, mediaType } : {}),
debitedSats,
balanceRemaining: newBalance,
...(freeTierServed ? { free_tier: true, absorbed_sats: absorbedSats } : {}),
@@ -608,4 +647,43 @@ router.delete("/sessions/:id/history", async (req: Request, res: Response) => {
}
});
// ── GET /sessions/:id/requests/:requestId/media ───────────────────────────────
router.get("/sessions/:id/requests/:requestId/media", async (req: Request, res: Response) => {
const sessionId = req.params.id as string;
const requestId = req.params.requestId as string;
try {
const session = await getSessionById(sessionId);
if (!session) { res.status(404).json({ error: "Session not found" }); return; }
const rows = await db
.select()
.from(jobMedia)
.where(eq(jobMedia.entityId, requestId))
.limit(1);
const media = rows[0];
if (!media) {
res.status(404).json({ error: "No media found for this request" });
return;
}
if (new Date() > media.expiresAt) {
res.status(410).json({ error: "Media has expired" });
return;
}
res.json({
requestId,
mediaType: media.mediaType,
mimeType: media.mimeType,
data: media.data,
expiresAt: media.expiresAt.toISOString(),
});
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : "Failed to fetch media" });
}
});
export default router;

View File

@@ -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
# ===========================================================================
# T41T45 — 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.
# ===========================================================================
# ---------------------------------------------------------------------------
# T41T45 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)
# ===========================================================================

View File

@@ -0,0 +1,26 @@
-- Migration: Image generation media storage (#19)
-- Adds job_media table for storing generated images (base64) with 7-day TTL.
-- Also adds media_type column to jobs table to flag image-type work.
-- ── job_media ─────────────────────────────────────────────────────────────────
-- Stores generated media for both standalone jobs and session requests.
-- entity_id is polymorphic: job ID or session request ID.
-- expires_at is set to NOW + 7 days at insert time.
CREATE TABLE IF NOT EXISTS job_media (
id TEXT PRIMARY KEY,
entity_id TEXT NOT NULL, -- job ID or session request ID
entity_type TEXT NOT NULL, -- 'job' | 'session_request'
media_type TEXT NOT NULL, -- 'image'
mime_type TEXT NOT NULL, -- e.g. 'image/png'
data TEXT NOT NULL, -- base64-encoded image data
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_job_media_entity_id ON job_media(entity_id);
-- ── jobs.media_type ───────────────────────────────────────────────────────────
-- Nullable flag set during eval phase for image-generation requests.
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS media_type TEXT;

View File

@@ -14,3 +14,4 @@ export * from "./relay-accounts";
export * from "./relay-event-queue";
export * from "./job-debates";
export * from "./session-messages";
export * from "./job-media";

View File

@@ -0,0 +1,19 @@
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
/**
* job_media — stores generated media (images) for jobs and session requests.
* entityId is polymorphic: it can be a job ID or a session request ID.
* expiresAt is set to NOW + 7 days; a cleanup job should purge expired rows.
*/
export const jobMedia = pgTable("job_media", {
id: text("id").primaryKey(),
entityId: text("entity_id").notNull(), // job ID or session request ID
entityType: text("entity_type").notNull(), // 'job' | 'session_request'
mediaType: text("media_type").notNull(), // 'image'
mimeType: text("mime_type").notNull(), // e.g. 'image/png'
data: text("data").notNull(), // base64-encoded image data
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
});
export type JobMedia = typeof jobMedia.$inferSelect;

View File

@@ -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"),
@@ -54,6 +52,9 @@ export const jobs = pgTable("jobs", {
refundState: text("refund_state").$type<"not_applicable" | "pending" | "paid">(),
refundPaymentHash: text("refund_payment_hash"),
// ── Image generation (set during eval if request is an image job) ───────────
mediaType: text("media_type"), // 'image' | null
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
});

View File

@@ -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;
@@ -725,7 +639,6 @@
<!-- ── 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>
<a id="relay-admin-btn" href="/admin/relay">⚙ RELAY ADMIN</a>
</div>
@@ -788,48 +701,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>
@@ -939,18 +810,6 @@
</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);
});
}
}
// Show Relay Admin button if admin token is stored in localStorage
(function() {
if (localStorage.getItem('relay_admin_token')) {

View File

@@ -157,15 +157,20 @@ export async function sessionSendHandler(text) {
_saveToStorage();
_applySessionUI();
const reply = data.result || data.reason || '…';
setSpeechBubble(reply);
appendSystemMessage('Timmy: ' + reply.slice(0, 80));
if (data.mediaType === 'image' && data.mediaUrl) {
// Fetch image data and render inline
_renderImageResponse(data.mediaUrl, text);
} else {
const reply = data.result || data.reason || '…';
setSpeechBubble(reply);
appendSystemMessage('Timmy: ' + reply.slice(0, 80));
// Sentiment-driven mood on inbound Timmy reply
sentiment(reply).then(s => {
setMood(s.label);
setTimeout(() => setMood(null), 10_000);
}).catch(() => {});
// Sentiment-driven mood on inbound Timmy reply
sentiment(reply).then(s => {
setMood(s.label);
setTimeout(() => setMood(null), 10_000);
}).catch(() => {});
}
// Update active-step balance if panel is open
_updateActiveStep();
@@ -178,6 +183,66 @@ export async function sessionSendHandler(text) {
}
}
// ── Image rendering ───────────────────────────────────────────────────────────
async function _renderImageResponse(mediaUrl, prompt) {
const $log = document.getElementById('event-log');
if (!$log) return;
setSpeechBubble('✨ Here is your image!');
appendSystemMessage('Timmy: ✨ Image generated!');
try {
const res = await fetch(mediaUrl);
if (!res.ok) {
appendSystemMessage('Timmy: Image ready — ' + mediaUrl);
return;
}
const data = await res.json();
const src = `data:${data.mimeType};base64,${data.data}`;
const container = document.createElement('div');
container.className = 'log-entry timmy-image-result';
container.style.cssText = [
'margin:6px 0;padding:6px;',
'border:1px solid #336655;border-radius:4px;',
'background:#0a1a14;',
].join('');
const img = document.createElement('img');
img.src = src;
img.alt = prompt.slice(0, 60);
img.style.cssText = [
'max-width:100%;max-height:240px;',
'display:block;border-radius:3px;',
'cursor:pointer;',
].join('');
img.title = 'Click to view full size';
const dlBtn = document.createElement('a');
dlBtn.href = src;
dlBtn.download = 'timmy-image.png';
dlBtn.textContent = '⬇ Download';
dlBtn.style.cssText = [
'display:inline-block;margin-top:4px;',
'font-size:10px;color:#44cc88;',
'text-decoration:none;letter-spacing:1px;',
].join('');
container.appendChild(img);
container.appendChild(dlBtn);
const entries = $log.querySelectorAll('.log-entry');
if (entries.length >= 6) {
$log.removeChild(entries[0]);
}
$log.appendChild(container);
$log.scrollTop = $log.scrollHeight;
} catch {
appendSystemMessage('Timmy: Image generated — ' + mediaUrl);
}
}
// ── Panel open/close ──────────────────────────────────────────────────────────
function _openPanel() {

View File

@@ -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 01 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);
}
}