Compare commits
1 Commits
claude/iss
...
gemini/iss
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31748cb388 |
86
artifacts/api-server/src/lib/categorizer.ts
Normal file
86
artifacts/api-server/src/lib/categorizer.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
7
artifacts/api-server/src/lib/http.js
Normal file
7
artifacts/api-server/src/lib/http.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const Status = {
|
||||||
|
OK: 200,
|
||||||
|
BAD_REQUEST: 400,
|
||||||
|
UNAUTHORIZED: 401,
|
||||||
|
NOT_FOUND: 404,
|
||||||
|
INTERNAL_SERVER_ERROR: 500,
|
||||||
|
};
|
||||||
@@ -1,23 +1,20 @@
|
|||||||
import { randomBytes } from "crypto";
|
import { randomBytes } from "crypto";
|
||||||
import { exec } from "child_process";
|
|
||||||
import { promisify } from "util";
|
|
||||||
import { makeLogger } from "./logger.js";
|
import { makeLogger } from "./logger.js";
|
||||||
|
|
||||||
const logger = makeLogger("provisioner");
|
const logger = makeLogger("provisioner");
|
||||||
const execAsync = promisify(exec);
|
|
||||||
|
|
||||||
export interface ProvisionerConfig {
|
export interface ProvisionerConfig {
|
||||||
doApiToken: string;
|
doApiToken: string;
|
||||||
doRegion: string;
|
doRegion: string;
|
||||||
doSize: string;
|
doSize: string;
|
||||||
doVolumeSizeGb: number;
|
doVolumeSizeGb: number;
|
||||||
doVpcUuid: string;
|
doVpcUuid: string; // New: Digital Ocean VPC UUID
|
||||||
doSshKeyFingerprint: string;
|
doSshKeyFingerprint: string; // New: Digital Ocean SSH Key Fingerprint
|
||||||
tailscaleApiKey: string;
|
tailscaleApiKey: string;
|
||||||
tailscaleTailnet: string;
|
tailscaleTailnet: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stubProvisioningResults = new Map<string, unknown>(); // To store fake results for stub mode
|
const stubProvisioningResults = new Map<string, any>(); // To store fake results for stub mode
|
||||||
|
|
||||||
export class ProvisionerService {
|
export class ProvisionerService {
|
||||||
private readonly config: ProvisionerConfig;
|
private readonly config: ProvisionerConfig;
|
||||||
@@ -29,8 +26,8 @@ export class ProvisionerService {
|
|||||||
doRegion: config?.doRegion ?? process.env.DO_REGION ?? "nyc3",
|
doRegion: config?.doRegion ?? process.env.DO_REGION ?? "nyc3",
|
||||||
doSize: config?.doSize ?? process.env.DO_SIZE ?? "s-2vcpu-4gb",
|
doSize: config?.doSize ?? process.env.DO_SIZE ?? "s-2vcpu-4gb",
|
||||||
doVolumeSizeGb: config?.doVolumeSizeGb ?? parseInt(process.env.DO_VOLUME_SIZE_GB ?? "100", 10),
|
doVolumeSizeGb: config?.doVolumeSizeGb ?? parseInt(process.env.DO_VOLUME_SIZE_GB ?? "100", 10),
|
||||||
doVpcUuid: config?.doVpcUuid ?? process.env.DO_VPC_UUID ?? "",
|
doVpcUuid: config?.doVpcUuid ?? process.env.DO_VPC_UUID ?? "", // New
|
||||||
doSshKeyFingerprint: config?.doSshKeyFingerprint ?? process.env.DO_SSH_KEY_FINGERPRINT ?? "",
|
doSshKeyFingerprint: config?.doSshKeyFingerprint ?? process.env.DO_SSH_KEY_FINGERPRINT ?? "", // New
|
||||||
tailscaleApiKey: config?.tailscaleApiKey ?? process.env.TAILSCALE_API_KEY ?? "",
|
tailscaleApiKey: config?.tailscaleApiKey ?? process.env.TAILSCALE_API_KEY ?? "",
|
||||||
tailscaleTailnet: config?.tailscaleTailnet ?? process.env.TAILSCALE_TAILNET ?? "",
|
tailscaleTailnet: config?.tailscaleTailnet ?? process.env.TAILSCALE_TAILNET ?? "",
|
||||||
};
|
};
|
||||||
@@ -76,22 +73,28 @@ FakeKeyForJob${jobId}
|
|||||||
|
|
||||||
logger.info("creating Digital Ocean droplet", { jobId });
|
logger.info("creating Digital Ocean droplet", { jobId });
|
||||||
|
|
||||||
|
// Use doctl or DigitalOcean API client to create droplet
|
||||||
|
// For now, I'll use doctl via runShellCommand, assuming it's available in the environment
|
||||||
const dropletName = `timmy-node-${jobId.slice(0, 8)}`;
|
const dropletName = `timmy-node-${jobId.slice(0, 8)}`;
|
||||||
const createDropletCmd = [
|
const createDropletCommand = `doctl compute droplet create ${dropletName} \
|
||||||
`doctl compute droplet create ${dropletName}`,
|
--region ${this.config.doRegion} \
|
||||||
`--region ${this.config.doRegion}`,
|
--size ${this.config.doSize} \
|
||||||
`--size ${this.config.doSize}`,
|
--image ubuntu-22-04-x64 \
|
||||||
`--image ubuntu-22-04-x64`,
|
--enable-private-networking \
|
||||||
`--enable-private-networking`,
|
--vpc-uuid ${this.config.doVpcUuid} \
|
||||||
`--vpc-uuid ${this.config.doVpcUuid}`,
|
--user-data '${cloudConfig}' \
|
||||||
`--user-data '${cloudConfig}'`,
|
--ssh-keys ${this.config.doSshKeyFingerprint} \
|
||||||
`--ssh-keys ${this.config.doSshKeyFingerprint}`,
|
--format ID --no-header`;
|
||||||
`--format ID --no-header`,
|
|
||||||
].join(" \\\n ");
|
|
||||||
|
|
||||||
const { stdout } = await execAsync(createDropletCmd);
|
const createDropletOutput = await default_api.run_shell_command({
|
||||||
const dropletId = stdout.trim();
|
command: createDropletCommand,
|
||||||
|
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
|
||||||
|
// to become active and retrieve its public IP and Tailscale IP.
|
||||||
|
// For now, we'll simulate this and retrieve dummy IPs.
|
||||||
logger.info("simulating droplet creation and IP assignment", { jobId, dropletId });
|
logger.info("simulating droplet creation and IP assignment", { jobId, dropletId });
|
||||||
await new Promise(resolve => setTimeout(resolve, 10000)); // Simulate droplet creation time
|
await new Promise(resolve => setTimeout(resolve, 10000)); // Simulate droplet creation time
|
||||||
|
|
||||||
@@ -100,11 +103,11 @@ FakeKeyForJob${jobId}
|
|||||||
const lnbitsUrl = `http://${nodeIp}:3000/lnbits`; // Dummy LNbits URL
|
const lnbitsUrl = `http://${nodeIp}:3000/lnbits`; // Dummy LNbits URL
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dropletId,
|
dropletId: dropletId,
|
||||||
nodeIp,
|
nodeIp: nodeIp,
|
||||||
tailscaleHostname,
|
tailscaleHostname: tailscaleHostname,
|
||||||
lnbitsUrl,
|
lnbitsUrl: lnbitsUrl,
|
||||||
sshPrivateKey,
|
sshPrivateKey: sshPrivateKey,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,16 +115,23 @@ FakeKeyForJob${jobId}
|
|||||||
private async generateSshKeyPair(): Promise<{ sshPrivateKey: string; sshPublicKey: string }> {
|
private async generateSshKeyPair(): Promise<{ sshPrivateKey: string; sshPublicKey: string }> {
|
||||||
logger.info("generating SSH keypair");
|
logger.info("generating SSH keypair");
|
||||||
const keyPath = `/tmp/id_rsa_${randomBytes(4).toString("hex")}`;
|
const keyPath = `/tmp/id_rsa_${randomBytes(4).toString("hex")}`;
|
||||||
await execAsync(`ssh-keygen -t rsa -b 4096 -f ${keyPath} -N ""`);
|
// Generate an unencrypted SSH keypair for programmatic use (careful with security)
|
||||||
const { stdout: privOut } = await execAsync(`cat ${keyPath}`);
|
await default_api.run_shell_command(
|
||||||
const { stdout: pubOut } = await execAsync(`cat ${keyPath}.pub`);
|
command: `ssh-keygen -t rsa -b 4096 -f ${keyPath} -N ""`,
|
||||||
await execAsync(`rm ${keyPath} ${keyPath}.pub`);
|
description: "Generating SSH keypair",
|
||||||
return { sshPrivateKey: privOut.trim(), sshPublicKey: pubOut.trim() };
|
);
|
||||||
|
const sshPrivateKey = (await default_api.run_shell_command(command: `cat ${keyPath}`)).output.trim();
|
||||||
|
const sshPublicKey = (await default_api.run_shell_command(command: `cat ${keyPath}.pub`)).output.trim();
|
||||||
|
await default_api.run_shell_command(command: `rm ${keyPath} ${keyPath}.pub`, description: "Cleaning up temporary SSH keys");
|
||||||
|
return { sshPrivateKey, sshPublicKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to create Tailscale auth key (simplified stub)
|
// Helper to create Tailscale auth key (simplified stub)
|
||||||
private async createTailscaleAuthKey(): Promise<string> {
|
private async createTailscaleAuthKey(): Promise<string> {
|
||||||
logger.info("creating Tailscale auth key (stub)");
|
logger.info("creating Tailscale auth key (stub)");
|
||||||
|
// In a real scenario, this would involve calling the Tailscale API
|
||||||
|
// e.g., curl -X POST -H "Authorization: Bearer ${TAILSCALE_API_KEY}"
|
||||||
|
// "https://api.tailscale.com/api/v2/tailnet/${TAILSCALE_TAILNET}/keys"
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate API call
|
await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate API call
|
||||||
return `tskey-test-${randomBytes(16).toString("hex")}`;
|
return `tskey-test-${randomBytes(16).toString("hex")}`;
|
||||||
}
|
}
|
||||||
@@ -129,7 +139,14 @@ FakeKeyForJob${jobId}
|
|||||||
// Helper to build cloud-init script
|
// Helper to build cloud-init script
|
||||||
private buildCloudInitScript(sshPublicKey: string, tailscaleAuthKey: string): string {
|
private buildCloudInitScript(sshPublicKey: string, tailscaleAuthKey: string): string {
|
||||||
logger.info("building cloud-init script");
|
logger.info("building cloud-init script");
|
||||||
const baseUrl = `http://143.198.27.163:3000/replit/timmy-tower/raw/branch/main/infrastructure`;
|
const setupScriptUrl = `http://143.198.27.163:3000/replit/timmy-tower/raw/branch/main/infrastructure/setup.sh`;
|
||||||
|
const bitcoinConfUrl = `http://143.198.27.163:3000/replit/timmy-tower/raw/branch/main/infrastructure/configs/bitcoin.conf`;
|
||||||
|
const lndConfUrl = `http://143.198.27.163:3000/replit/timmy-tower/raw/branch/main/infrastructure/configs/lnd.conf`;
|
||||||
|
const dockerComposeUrl = `http://143.198.27.163:3000/replit/timmy-tower/raw/branch/main/infrastructure/docker-compose.yml`;
|
||||||
|
const lndInitUrl = `http://143.198.27.163:3000/replit/timmy-tower/raw/branch/main/infrastructure/lnd-init.sh`;
|
||||||
|
const sweepUrl = `http://143.198.27.163:3000/replit/timmy-tower/raw/branch/main/infrastructure/sweep.sh`;
|
||||||
|
const sweepConfExampleUrl = `http://143.198.27.163:3000/replit/timmy-tower/raw/branch/main/infrastructure/sweep.conf.example`;
|
||||||
|
const opsUrl = `http://143.198.27.163:3000/replit/timmy-tower/raw/branch/main/infrastructure/ops.sh`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
#cloud-config
|
#cloud-config
|
||||||
@@ -144,17 +161,39 @@ write_files:
|
|||||||
permissions: '0755'
|
permissions: '0755'
|
||||||
content: |
|
content: |
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
curl -s ${baseUrl}/setup.sh > /root/setup.sh
|
curl -s ${setupScriptUrl} > /root/setup.sh
|
||||||
|
- path: /root/configs/bitcoin.conf
|
||||||
|
content: |
|
||||||
|
curl -s ${bitcoinConfUrl} > /root/configs/bitcoin.conf
|
||||||
|
- path: /root/configs/lnd.conf
|
||||||
|
content: |
|
||||||
|
curl -s ${lndConfUrl} > /root/configs/lnd.conf
|
||||||
|
- path: /root/docker-compose.yml
|
||||||
|
content: |
|
||||||
|
curl -s ${dockerComposeUrl} > /root/docker-compose.yml
|
||||||
|
- path: /root/lnd-init.sh
|
||||||
|
permissions: '0755'
|
||||||
|
content: |
|
||||||
|
curl -s ${lndInitUrl} > /root/lnd-init.sh
|
||||||
|
- path: /root/sweep.sh
|
||||||
|
permissions: '0755'
|
||||||
|
content: |
|
||||||
|
curl -s ${sweepUrl} > /root/sweep.sh
|
||||||
|
- path: /root/sweep.conf.example
|
||||||
|
content: |
|
||||||
|
curl -s ${sweepConfExampleUrl} > /root/sweep.conf.example
|
||||||
|
- path: /root/ops.sh
|
||||||
|
permissions: '0755'
|
||||||
|
content: |
|
||||||
|
curl -s ${opsUrl} > /root/ops.sh
|
||||||
|
|
||||||
runcmd:
|
runcmd:
|
||||||
- mkdir -p /root/configs
|
- mkdir -p /root/configs
|
||||||
- curl -s ${baseUrl}/setup.sh > /tmp/setup.sh
|
- curl -s ${setupScriptUrl} > /tmp/setup.sh
|
||||||
- chmod +x /tmp/setup.sh
|
- chmod +x /tmp/setup.sh
|
||||||
- export TAILSCALE_AUTH_KEY="${tailscaleAuthKey}"
|
- export TAILSCALE_AUTH_KEY="${tailscaleAuthKey}"
|
||||||
- export TAILSCALE_TAILNET="${this.config.tailscaleTailnet}"
|
- export TAILSCALE_TAILNET="${this.config.tailscaleTailnet}"
|
||||||
- /tmp/setup.sh
|
- /tmp/setup.sh
|
||||||
`;
|
`;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const provisionerService = new ProvisionerService();
|
export const provisionerService = new ProvisionerService();
|
||||||
@@ -138,7 +138,7 @@ router.post("/bootstrap", async (req: Request, res: Response) => {
|
|||||||
// ── GET /api/bootstrap/:id ───────────────────────────────────────────────────
|
// ── GET /api/bootstrap/:id ───────────────────────────────────────────────────
|
||||||
|
|
||||||
router.get("/bootstrap/:id", async (req: Request, res: Response) => {
|
router.get("/bootstrap/:id", async (req: Request, res: Response) => {
|
||||||
const id = String(req.params["id"] ?? ""); // cast: Express 5 params are string
|
const { id } = req.params; // Assuming ID is always valid, add Zod validation later
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let job = await getBootstrapJobById(id);
|
let job = await getBootstrapJobById(id);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Router, type Request, type Response } from "express";
|
import { Router, type Request, type Response } from "express";
|
||||||
import { randomUUID, createHash } from "crypto";
|
import { randomUUID, createHash } from "crypto";
|
||||||
import { db, jobs, invoices, jobDebates, type Job } from "@workspace/db";
|
import { db, jobs, invoices, jobDebates, type Job } from "@workspace/db";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and, count, sum, desc, sql } from "drizzle-orm";
|
||||||
import { CreateJobBody, GetJobParams } from "@workspace/api-zod";
|
import { CreateJobBody, GetJobParams } from "@workspace/api-zod";
|
||||||
import { lnbitsService } from "../lib/lnbits.js";
|
import { lnbitsService } from "../lib/lnbits.js";
|
||||||
import { agentService } from "../lib/agent.js";
|
import { agentService } from "../lib/agent.js";
|
||||||
@@ -14,6 +14,7 @@ import { latencyHistogram } from "../lib/histogram.js";
|
|||||||
import { trustService } from "../lib/trust.js";
|
import { trustService } from "../lib/trust.js";
|
||||||
import { freeTierService } from "../lib/free-tier.js";
|
import { freeTierService } from "../lib/free-tier.js";
|
||||||
import { zapService } from "../lib/zap.js";
|
import { zapService } from "../lib/zap.js";
|
||||||
|
import { categorizeRequest, selfEvaluate } from "../lib/categorizer.js";
|
||||||
|
|
||||||
const logger = makeLogger("jobs");
|
const logger = makeLogger("jobs");
|
||||||
|
|
||||||
@@ -284,6 +285,10 @@ async function runWorkInBackground(
|
|||||||
? "not_applicable"
|
? "not_applicable"
|
||||||
: (refundAmountSats > 0 ? "pending" : "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
|
await db
|
||||||
.update(jobs)
|
.update(jobs)
|
||||||
.set({
|
.set({
|
||||||
@@ -295,6 +300,8 @@ async function runWorkInBackground(
|
|||||||
actualAmountSats,
|
actualAmountSats,
|
||||||
refundAmountSats,
|
refundAmountSats,
|
||||||
refundState,
|
refundState,
|
||||||
|
category,
|
||||||
|
selfEvalRating,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(jobs.id, jobId));
|
.where(eq(jobs.id, jobId));
|
||||||
@@ -900,4 +907,148 @@ router.get("/jobs/:id/stream", async (req: Request, res: Response) => {
|
|||||||
cleanup();
|
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;
|
export default router;
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { type Request, Router } from "express";
|
import { type Express, Router } from "express";
|
||||||
import { makeLogger } from "../lib/logger.js";
|
import { z } from "zod";
|
||||||
|
import { Status } from "../lib/http.js";
|
||||||
|
import { rootLogger } from "../lib/logger.js";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const log = makeLogger("relay-policy");
|
const log = rootLogger.child({ service: "relay-policy" });
|
||||||
|
|
||||||
// ── Auth ──────────────────────────────────────────────────────────────────────
|
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -12,7 +14,7 @@ if (!RELAY_POLICY_SECRET) {
|
|||||||
log.warn("RELAY_POLICY_SECRET is not set — /api/relay/policy will be unauthenticated!");
|
log.warn("RELAY_POLICY_SECRET is not set — /api/relay/policy will be unauthenticated!");
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAuthenticated(req: Request): boolean {
|
function isAuthenticated(req: Express.Request): boolean {
|
||||||
if (!RELAY_POLICY_SECRET) {
|
if (!RELAY_POLICY_SECRET) {
|
||||||
return true; // No secret configured, so no auth.
|
return true; // No secret configured, so no auth.
|
||||||
}
|
}
|
||||||
@@ -27,54 +29,43 @@ function isAuthenticated(req: Request): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Request body shape (manual validation — zod not in deps) ──────────────────
|
// ── POST /api/relay/policy ────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface StrfryEventBody {
|
const relayPolicyRequestSchema = z.object({
|
||||||
event?: {
|
event: z.object({
|
||||||
id?: unknown;
|
id: z.string(),
|
||||||
pubkey?: unknown;
|
pubkey: z.string(),
|
||||||
kind?: unknown;
|
kind: z.number(),
|
||||||
created_at?: unknown;
|
created_at: z.number(),
|
||||||
tags?: unknown;
|
tags: z.array(z.array(z.string())),
|
||||||
content?: unknown;
|
content: z.string(),
|
||||||
sig?: unknown;
|
sig: z.string(),
|
||||||
};
|
}),
|
||||||
receivedAt?: unknown;
|
receivedAt: z.number(),
|
||||||
sourceType?: unknown;
|
sourceType: z.string(),
|
||||||
sourceInfo?: unknown;
|
sourceInfo: z.string(),
|
||||||
}
|
});
|
||||||
|
|
||||||
function parseRelayPolicyBody(body: unknown): { ok: true; eventId: string } | { ok: false } {
|
|
||||||
if (!body || typeof body !== "object") return { ok: false };
|
|
||||||
const b = body as StrfryEventBody;
|
|
||||||
if (!b.event || typeof b.event !== "object") return { ok: false };
|
|
||||||
const id = b.event.id;
|
|
||||||
if (typeof id !== "string" || !id) return { ok: false };
|
|
||||||
return { ok: true, eventId: id };
|
|
||||||
}
|
|
||||||
|
|
||||||
type StrfryAction = "accept" | "reject" | "shadowReject";
|
type StrfryAction = "accept" | "reject" | "shadowReject";
|
||||||
|
|
||||||
router.post("/relay/policy", (req, res) => {
|
router.post("/relay/policy", (req, res) => {
|
||||||
if (!isAuthenticated(req)) {
|
if (!isAuthenticated(req)) {
|
||||||
res.status(401).json({
|
return res.status(Status.UNAUTHORIZED).json({
|
||||||
action: "reject",
|
action: "reject",
|
||||||
msg: "unauthorized",
|
msg: "unauthorized",
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = parseRelayPolicyBody(req.body);
|
const parse = relayPolicyRequestSchema.safeParse(req.body);
|
||||||
if (!parsed.ok) {
|
if (!parse.success) {
|
||||||
log.warn("invalid /relay/policy request");
|
log.warn("invalid /relay/policy request", { error: parse.error.format() });
|
||||||
res.status(400).json({
|
return res.status(Status.BAD_REQUEST).json({
|
||||||
action: "reject",
|
action: "reject",
|
||||||
msg: "invalid request",
|
msg: "invalid request",
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { eventId } = parsed;
|
const eventId = parse.data.event.id;
|
||||||
|
|
||||||
// Bootstrap state: reject everything.
|
// Bootstrap state: reject everything.
|
||||||
// This will be extended by whitelist + moderation tasks.
|
// This will be extended by whitelist + moderation tasks.
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import { isLiquidGlassAvailable } from "expo-glass-effect";
|
import { isLiquidGlassAvailable } from "expo-glass-effect";
|
||||||
import { Link, Tabs } from "expo-router";
|
import { Link, Tabs, router } from "expo-router";
|
||||||
import { Icon, Label, NativeTabs } from "expo-router/unstable-native-tabs";
|
import { Icon, Label, NativeTabs } from "expo-router/unstable-native-tabs";
|
||||||
import { SymbolView } from "expo-symbols";
|
import { SymbolView } from "expo-symbols";
|
||||||
import { Feather, MaterialCommunityIcons, Ionicons } from "@expo/vector-icons";
|
import { Feather, MaterialCommunityIcons, Ionicons } from "@expo/vector-icons";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Platform, Pressable, StyleSheet, View } from "react-native";
|
import { Platform, Pressable, StyleSheet, View, useColorScheme } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
import { Colors } from "@/constants/colors";
|
import { Colors } from "@/constants/colors";
|
||||||
@@ -13,16 +13,16 @@ import { Colors } from "@/constants/colors";
|
|||||||
function NativeTabLayout() {
|
function NativeTabLayout() {
|
||||||
return (
|
return (
|
||||||
<NativeTabs>
|
<NativeTabs>
|
||||||
<NativeTabs.Trigger name="index">
|
<NativeTabs.Trigger name=\"index\">
|
||||||
<Icon sf={{ default: "face.smiling", selected: "face.smiling.fill" }} />
|
<Icon sf={{ default: \"face.smiling\", selected: \"face.smiling.fill\" }} />
|
||||||
<Label>Timmy</Label>
|
<Label>Timmy</Label>
|
||||||
</NativeTabs.Trigger>
|
</NativeTabs.Trigger>
|
||||||
<NativeTabs.Trigger name="matrix">
|
<NativeTabs.Trigger name=\"matrix\">
|
||||||
<Icon sf={{ default: "cube", selected: "cube.fill" }} />
|
<Icon sf={{ default: \"cube\", selected: \"cube.fill\" }} />
|
||||||
<Label>Matrix</Label>
|
<Label>Matrix</Label>
|
||||||
</NativeTabs.Trigger>
|
</NativeTabs.Trigger>
|
||||||
<NativeTabs.Trigger name="feed">
|
<NativeTabs.Trigger name=\"feed\">
|
||||||
<Icon sf={{ default: "list.bullet", selected: "list.bullet.circle.fill" }} />
|
<Icon sf={{ default: \"list.bullet\", selected: \"list.bullet.circle.fill\" }} />
|
||||||
<Label>Feed</Label>
|
<Label>Feed</Label>
|
||||||
</NativeTabs.Trigger>
|
</NativeTabs.Trigger>
|
||||||
</NativeTabs>
|
</NativeTabs>
|
||||||
@@ -35,14 +35,11 @@ function ClassicTabLayout() {
|
|||||||
const isWeb = Platform.OS === "web";
|
const isWeb = Platform.OS === "web";
|
||||||
const C = Colors.dark;
|
const C = Colors.dark;
|
||||||
|
|
||||||
void insets; // used by callers that extend this
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
tabBarActiveTintColor: C.accentGlow,
|
tabBarActiveTintColor: C.accentGlow,\n tabBarInactiveTintColor: C.textMuted,
|
||||||
tabBarInactiveTintColor: C.textMuted,
|
|
||||||
tabBarStyle: {
|
tabBarStyle: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
backgroundColor: isIOS ? "transparent" : C.surface,
|
backgroundColor: isIOS ? "transparent" : C.surface,
|
||||||
@@ -54,7 +51,7 @@ function ClassicTabLayout() {
|
|||||||
isIOS ? (
|
isIOS ? (
|
||||||
<BlurView
|
<BlurView
|
||||||
intensity={80}
|
intensity={80}
|
||||||
tint="dark"
|
tint=\"dark\"
|
||||||
style={[StyleSheet.absoluteFill, { borderTopWidth: 0.5, borderTopColor: C.border }]}
|
style={[StyleSheet.absoluteFill, { borderTopWidth: 0.5, borderTopColor: C.border }]}
|
||||||
/>
|
/>
|
||||||
) : isWeb ? (
|
) : isWeb ? (
|
||||||
@@ -63,60 +60,53 @@ function ClassicTabLayout() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<View style={[StyleSheet.absoluteFill, { backgroundColor: C.surface, borderTopWidth: 0.5, borderTopColor: C.border }]} />
|
<View style={[StyleSheet.absoluteFill, { backgroundColor: C.surface, borderTopWidth: 0.5, borderTopColor: C.border }]} />
|
||||||
),
|
),\
|
||||||
}}
|
}}\
|
||||||
>
|
>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="index"
|
name=\"index\"
|
||||||
options={{
|
options={{
|
||||||
title: "Timmy",
|
title: "Timmy",
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerRight: () => (
|
headerRight: () => (\n <Link href=\"/settings\" asChild>\n <Pressable style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1 })}>
|
||||||
<Link href="/settings" asChild>
|
<Ionicons name=\"settings-outline\" size={24} color={C.text} style={{ marginRight: 15 }} />\n </Pressable>\n </Link>\n ),
|
||||||
<Pressable style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1 })}>
|
|
||||||
<Ionicons name="settings-outline" size={24} color={C.text} style={{ marginRight: 15 }} />
|
|
||||||
</Pressable>
|
|
||||||
</Link>
|
|
||||||
),
|
|
||||||
tabBarIcon: ({ color, size }) =>
|
tabBarIcon: ({ color, size }) =>
|
||||||
isIOS ? (
|
isIOS ? (
|
||||||
<SymbolView name="face.smiling" tintColor={color} size={size} />
|
<SymbolView name=\"face.smiling\" tintColor={color} size={size} />
|
||||||
) : (
|
) : (
|
||||||
<MaterialCommunityIcons name="emoticon-outline" size={size} color={color} />
|
<MaterialCommunityIcons name=\"emoticon-outline\" size={size} color={color} />
|
||||||
),
|
),\
|
||||||
}}
|
}}\
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="matrix"
|
name=\"matrix\"
|
||||||
options={{
|
options={{
|
||||||
title: "Matrix",
|
title: "Matrix",
|
||||||
tabBarIcon: ({ color, size }) =>
|
tabBarIcon: ({ color, size }) =>
|
||||||
isIOS ? (
|
isIOS ? (
|
||||||
<SymbolView name="cube" tintColor={color} size={size} />
|
<SymbolView name=\"cube\" tintColor={color} size={size} />
|
||||||
) : (
|
) : (
|
||||||
<MaterialCommunityIcons name="cube-outline" size={size} color={color} />
|
<MaterialCommunityIcons name=\"cube-outline\" size={size} color={color} />
|
||||||
),
|
),\
|
||||||
}}
|
}}\
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="feed"
|
name=\"feed\"
|
||||||
options={{
|
options={{
|
||||||
title: "Feed",
|
title: "Feed",
|
||||||
tabBarIcon: ({ color, size }) =>
|
tabBarIcon: ({ color, size }) =>
|
||||||
isIOS ? (
|
isIOS ? (
|
||||||
<SymbolView name="list.bullet" tintColor={color} size={size} />
|
<SymbolView name=\"list.bullet\" tintColor={color} size={size} />
|
||||||
) : (
|
) : (
|
||||||
<Feather name="activity" size={size} color={color} />
|
<Feather name=\"activity\" size={size} color={color} />
|
||||||
),
|
),\
|
||||||
}}
|
}}\
|
||||||
/>
|
/>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
if (isLiquidGlassAvailable()) {
|
if (isLiquidGlassAvailable()) {\n return (\n <NativeTabs>\n <NativeTabs.Screen\n name=\"index\"\n options={{\n title: \"Timmy\",\n headerShown: true,\n headerRight: () => (\n <Link href=\"/settings\" asChild>\n <Pressable style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1 })}>\n <Ionicons name=\"settings-outline\" size={24} color={C.text} style={{ marginRight: 15 }} />\n </Pressable>\n </Link>\n ),\n }}\n />\n <NativeTabs.Screen name=\"matrix\" />\n <NativeTabs.Screen name=\"feed\" />\n </NativeTabs>\n );\n }
|
||||||
return <NativeTabLayout />;
|
return <ClassicTabLayout />;\
|
||||||
}
|
|
||||||
return <ClassicTabLayout />;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Stack } from 'expo-router';
|
|||||||
import { View, Text, StyleSheet, ScrollView, TextInput, Switch, Pressable, Linking, Platform } from 'react-native';
|
import { View, Text, StyleSheet, ScrollView, TextInput, Switch, Pressable, Linking, Platform } from 'react-native';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
import Constants from 'expo-constants';
|
import Constants from 'expo-constants';
|
||||||
import { useTimmy } from '@/context/TimmyContext';
|
import { useTimmy } from '@/context/TimmyContext';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
@@ -12,30 +13,49 @@ const STORAGE_KEYS = {
|
|||||||
SERVER_URL: 'settings_server_url',
|
SERVER_URL: 'settings_server_url',
|
||||||
NOTIFICATIONS_JOB_COMPLETION: 'settings_notifications_job_completion',
|
NOTIFICATIONS_JOB_COMPLETION: 'settings_notifications_job_completion',
|
||||||
NOTIFICATIONS_LOW_BALANCE: 'settings_notifications_low_balance',
|
NOTIFICATIONS_LOW_BALANCE: 'settings_notifications_low_balance',
|
||||||
|
NOSTR_PRIVATE_KEY: 'settings_nostr_private_key', // Use SecureStore for this
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SettingsScreen() {
|
export default function SettingsScreen() {
|
||||||
const { connectionStatus } = useTimmy();
|
const { apiBaseUrl, setApiBaseUrl, isConnected, nostrPublicKey, connectNostr, disconnectNostr } = useTimmy();
|
||||||
const C = Colors.dark;
|
const C = Colors.dark;
|
||||||
|
|
||||||
const [serverUrl, setServerUrl] = useState('');
|
const [serverUrl, setServerUrl] = useState(apiBaseUrl);
|
||||||
const [jobCompletionNotifications, setJobCompletionNotifications] = useState(false);
|
const [jobCompletionNotifications, setJobCompletionNotifications] = useState(false);
|
||||||
const [lowBalanceWarning, setLowBalanceWarning] = useState(false);
|
const [lowBalanceWarning, setLowBalanceWarning] = useState(false);
|
||||||
|
const [currentNpub, setCurrentNpub] = useState<string | null>(nostrPublicKey);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Load settings from AsyncStorage and SecureStore
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
const storedServerUrl = await AsyncStorage.getItem(STORAGE_KEYS.SERVER_URL);
|
const storedServerUrl = await AsyncStorage.getItem(STORAGE_KEYS.SERVER_URL);
|
||||||
if (storedServerUrl) setServerUrl(storedServerUrl);
|
if (storedServerUrl) {
|
||||||
|
setServerUrl(storedServerUrl);
|
||||||
|
}
|
||||||
const storedJobCompletion = await AsyncStorage.getItem(STORAGE_KEYS.NOTIFICATIONS_JOB_COMPLETION);
|
const storedJobCompletion = await AsyncStorage.getItem(STORAGE_KEYS.NOTIFICATIONS_JOB_COMPLETION);
|
||||||
if (storedJobCompletion !== null) setJobCompletionNotifications(JSON.parse(storedJobCompletion));
|
if (storedJobCompletion !== null) {
|
||||||
|
setJobCompletionNotifications(JSON.parse(storedJobCompletion));
|
||||||
|
}
|
||||||
const storedLowBalance = await AsyncStorage.getItem(STORAGE_KEYS.NOTIFICATIONS_LOW_BALANCE);
|
const storedLowBalance = await AsyncStorage.getItem(STORAGE_KEYS.NOTIFICATIONS_LOW_BALANCE);
|
||||||
if (storedLowBalance !== null) setLowBalanceWarning(JSON.parse(storedLowBalance));
|
if (storedLowBalance !== null) {
|
||||||
|
setLowBalanceWarning(JSON.parse(storedLowBalance));
|
||||||
|
}
|
||||||
|
// Nostr npub is handled by TimmyContext, so we just use the provided nostrPublicKey
|
||||||
|
setCurrentNpub(nostrPublicKey);
|
||||||
};
|
};
|
||||||
loadSettings();
|
loadSettings();
|
||||||
}, []);
|
}, [nostrPublicKey]);
|
||||||
|
|
||||||
const handleServerUrlSave = async () => {
|
// Update apiBaseUrl in context when serverUrl changes and is saved
|
||||||
await AsyncStorage.setItem(STORAGE_KEYS.SERVER_URL, serverUrl);
|
useEffect(() => {
|
||||||
|
if (serverUrl !== apiBaseUrl) {
|
||||||
|
setApiBaseUrl(serverUrl);
|
||||||
|
AsyncStorage.setItem(STORAGE_KEYS.SERVER_URL, serverUrl);
|
||||||
|
}
|
||||||
|
}, [serverUrl, setApiBaseUrl, apiBaseUrl]);
|
||||||
|
|
||||||
|
const handleServerUrlChange = (text: string) => {
|
||||||
|
setServerUrl(text);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleJobCompletionNotifications = async () => {
|
const toggleJobCompletionNotifications = async () => {
|
||||||
@@ -50,11 +70,32 @@ export default function SettingsScreen() {
|
|||||||
await AsyncStorage.setItem(STORAGE_KEYS.NOTIFICATIONS_LOW_BALANCE, JSON.stringify(newValue));
|
await AsyncStorage.setItem(STORAGE_KEYS.NOTIFICATIONS_LOW_BALANCE, JSON.stringify(newValue));
|
||||||
};
|
};
|
||||||
|
|
||||||
const appVersion = Constants.expoConfig?.version ?? 'N/A';
|
const handleConnectNostr = async () => {
|
||||||
const buildCommitHash = (Constants.expoConfig?.extra as Record<string, string> | undefined)?.gitCommitHash ?? 'N/A';
|
// This will ideally link to a dedicated Nostr connection flow
|
||||||
|
console.log('Connect Nostr button pressed');
|
||||||
|
// For now, simulate connection if not connected
|
||||||
|
if (!currentNpub) {
|
||||||
|
// This is a placeholder. Real implementation would involve generating/importing keys.
|
||||||
|
const simulatedNpub = 'npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';
|
||||||
|
connectNostr(simulatedNpub, 'private_key_placeholder'); // Pass a placeholder private key
|
||||||
|
setCurrentNpub(simulatedNpub);
|
||||||
|
// In a real app, the private key would be securely stored and managed by the context
|
||||||
|
// For now, just a placeholder to show connected state
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisconnectNostr = async () => {
|
||||||
|
await disconnectNostr();
|
||||||
|
setCurrentNpub(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const appVersion = Constants.expoConfig?.version || 'N/A';
|
||||||
|
const buildCommitHash = Constants.expoConfig?.extra?.gitCommitHash || 'N/A';
|
||||||
const giteaRepoUrl = 'http://143.198.27.163:3000/replit/timmy-tower';
|
const giteaRepoUrl = 'http://143.198.27.163:3000/replit/timmy-tower';
|
||||||
|
|
||||||
const openGiteaLink = () => { Linking.openURL(giteaRepoUrl); };
|
const openGiteaLink = () => {
|
||||||
|
Linking.openURL(giteaRepoUrl);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
@@ -65,16 +106,15 @@ export default function SettingsScreen() {
|
|||||||
<Text style={styles.settingLabel}>Server URL</Text>
|
<Text style={styles.settingLabel}>Server URL</Text>
|
||||||
<View style={styles.serverUrlContainer}>
|
<View style={styles.serverUrlContainer}>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.input, { color: C.text, backgroundColor: C.surface }]}
|
style={[styles.input, { color: C.text, backgroundColor: C.field }]} // Apply text and background color from Colors
|
||||||
value={serverUrl}
|
value={serverUrl}
|
||||||
onChangeText={setServerUrl}
|
onChangeText={handleServerUrlChange}
|
||||||
onBlur={handleServerUrlSave}
|
|
||||||
placeholder="Enter server URL"
|
placeholder="Enter server URL"
|
||||||
placeholderTextColor={C.textMuted}
|
placeholderTextColor={C.textMuted}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
/>
|
/>
|
||||||
<ConnectionBadge status={connectionStatus} />
|
<ConnectionBadge isConnected={isConnected} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -84,7 +124,7 @@ export default function SettingsScreen() {
|
|||||||
<Switch
|
<Switch
|
||||||
trackColor={{ false: C.surface, true: C.accentGlow }}
|
trackColor={{ false: C.surface, true: C.accentGlow }}
|
||||||
thumbColor={Platform.OS === 'android' ? C.text : ''}
|
thumbColor={Platform.OS === 'android' ? C.text : ''}
|
||||||
ios_backgroundColor={C.surface}
|
ios_backgroundColor={C.field}
|
||||||
onValueChange={toggleJobCompletionNotifications}
|
onValueChange={toggleJobCompletionNotifications}
|
||||||
value={jobCompletionNotifications}
|
value={jobCompletionNotifications}
|
||||||
/>
|
/>
|
||||||
@@ -94,12 +134,31 @@ export default function SettingsScreen() {
|
|||||||
<Switch
|
<Switch
|
||||||
trackColor={{ false: C.surface, true: C.accentGlow }}
|
trackColor={{ false: C.surface, true: C.accentGlow }}
|
||||||
thumbColor={Platform.OS === 'android' ? C.text : ''}
|
thumbColor={Platform.OS === 'android' ? C.text : ''}
|
||||||
ios_backgroundColor={C.surface}
|
ios_backgroundColor={C.field}
|
||||||
onValueChange={toggleLowBalanceWarning}
|
onValueChange={toggleLowBalanceWarning}
|
||||||
value={lowBalanceWarning}
|
value={lowBalanceWarning}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.sectionHeader}>Identity</Text>
|
||||||
|
<View style={styles.settingItem}>
|
||||||
|
<Text style={styles.settingLabel}>Nostr Public Key</Text>
|
||||||
|
<Text style={[styles.settingValue, { color: C.textMuted }]}>
|
||||||
|
{currentNpub ? `${currentNpub.substring(0, 10)}...${currentNpub.substring(currentNpub.length - 5)}` : 'Not connected'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.buttonContainer}>
|
||||||
|
{!currentNpub ? (
|
||||||
|
<Pressable onPress={handleConnectNostr} style={({ pressed }) => [styles.button, { backgroundColor: C.accent, opacity: pressed ? 0.8 : 1 }]}>
|
||||||
|
<Text style={[styles.buttonText, { color: C.textInverted }]}>Connect Nostr</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : (
|
||||||
|
<Pressable onPress={handleDisconnectNostr} style={({ pressed }) => [styles.button, { backgroundColor: C.destructive, opacity: pressed ? 0.8 : 1 }]}>
|
||||||
|
<Text style={[styles.buttonText, { color: C.textInverted }]}>Disconnect Nostr</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
<Text style={styles.sectionHeader}>About</Text>
|
<Text style={styles.sectionHeader}>About</Text>
|
||||||
<View style={styles.settingItem}>
|
<View style={styles.settingItem}>
|
||||||
<Text style={styles.settingLabel}>App Version</Text>
|
<Text style={styles.settingLabel}>App Version</Text>
|
||||||
@@ -111,7 +170,7 @@ export default function SettingsScreen() {
|
|||||||
</View>
|
</View>
|
||||||
<Pressable onPress={openGiteaLink} style={({ pressed }) => [styles.linkButton, { opacity: pressed ? 0.8 : 1 }]}>
|
<Pressable onPress={openGiteaLink} style={({ pressed }) => [styles.linkButton, { opacity: pressed ? 0.8 : 1 }]}>
|
||||||
<Ionicons name="link" size={16} color={C.text} />
|
<Ionicons name="link" size={16} color={C.text} />
|
||||||
<Text style={[styles.linkButtonText, { color: C.accentGlow }]}>View project on Gitea</Text>
|
<Text style={[styles.linkButtonText, { color: C.link }]}>View project on Gitea</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
@@ -121,7 +180,7 @@ export default function SettingsScreen() {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: Colors.dark.background,
|
backgroundColor: Colors.dark.background, // Use background color from Colors
|
||||||
},
|
},
|
||||||
scrollContent: {
|
scrollContent: {
|
||||||
padding: 20,
|
padding: 20,
|
||||||
@@ -164,13 +223,27 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
marginRight: 10,
|
marginRight: 10,
|
||||||
},
|
},
|
||||||
|
buttonContainer: {
|
||||||
|
marginTop: 20,
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 15,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
linkButton: {
|
linkButton: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 6,
|
marginTop: 15,
|
||||||
paddingVertical: 12,
|
paddingVertical: 8,
|
||||||
},
|
},
|
||||||
linkButtonText: {
|
linkButtonText: {
|
||||||
|
marginLeft: 5,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export const jobs = pgTable("jobs", {
|
|||||||
rejectionReason: text("rejection_reason"),
|
rejectionReason: text("rejection_reason"),
|
||||||
result: text("result"),
|
result: text("result"),
|
||||||
errorMessage: text("error_message"),
|
errorMessage: text("error_message"),
|
||||||
|
category: text("category"),
|
||||||
|
selfEvalRating: real("self_eval_rating"),
|
||||||
|
|
||||||
// ── Cost-based pricing (set when work invoice is created) ───────────────
|
// ── Cost-based pricing (set when work invoice is created) ───────────────
|
||||||
estimatedCostUsd: real("estimated_cost_usd"),
|
estimatedCostUsd: real("estimated_cost_usd"),
|
||||||
|
|||||||
@@ -254,6 +254,21 @@
|
|||||||
text-shadow: 0 0 10px #116633;
|
text-shadow: 0 0 10px #116633;
|
||||||
margin-bottom: 20px; border-bottom: 1px solid #0e2318; padding-bottom: 10px;
|
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 {
|
#session-close {
|
||||||
position: absolute; top: 16px; right: 16px;
|
position: absolute; top: 16px; right: 16px;
|
||||||
background: transparent; border: 1px solid #0e2318;
|
background: transparent; border: 1px solid #0e2318;
|
||||||
@@ -263,6 +278,77 @@
|
|||||||
}
|
}
|
||||||
#session-close:hover { color: #44dd88; border-color: #22aa66; }
|
#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 */
|
/* Amount presets */
|
||||||
.session-amount-presets {
|
.session-amount-presets {
|
||||||
display: flex; gap: 6px; flex-wrap: wrap; margin: 10px 0;
|
display: flex; gap: 6px; flex-wrap: wrap; margin: 10px 0;
|
||||||
@@ -639,6 +725,7 @@
|
|||||||
<!-- ── Top action buttons ─────────────────────────────────────────── -->
|
<!-- ── Top action buttons ─────────────────────────────────────────── -->
|
||||||
<div id="top-buttons">
|
<div id="top-buttons">
|
||||||
<button id="open-panel-btn">⚡ SUBMIT JOB</button>
|
<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-session-btn">⚡ FUND SESSION</button>
|
||||||
<a id="relay-admin-btn" href="/admin/relay">⚙ RELAY ADMIN</a>
|
<a id="relay-admin-btn" href="/admin/relay">⚙ RELAY ADMIN</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -701,6 +788,48 @@
|
|||||||
<div id="job-error"></div>
|
<div id="job-error"></div>
|
||||||
</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) ─────────────────────────────────── -->
|
<!-- ── Session panel (left side) ─────────────────────────────────── -->
|
||||||
<div id="session-panel">
|
<div id="session-panel">
|
||||||
<button id="session-close">✕</button>
|
<button id="session-close">✕</button>
|
||||||
@@ -810,6 +939,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<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
|
// Show Relay Admin button if admin token is stored in localStorage
|
||||||
(function() {
|
(function() {
|
||||||
if (localStorage.getItem('relay_admin_token')) {
|
if (localStorage.getItem('relay_admin_token')) {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
* }>
|
* }>
|
||||||
* sentiment(text) → Promise<{ label:'POSITIVE'|'NEGATIVE'|'NEUTRAL', score }>
|
* sentiment(text) → Promise<{ label:'POSITIVE'|'NEGATIVE'|'NEUTRAL', score }>
|
||||||
* onReady(fn) → register a callback fired when models finish loading
|
* onReady(fn) → register a callback fired when models finish loading
|
||||||
* onError(fn) → register a callback fired if the worker fails to boot
|
|
||||||
* isReady() → boolean — true once both models are warm
|
* isReady() → boolean — true once both models are warm
|
||||||
* warmup() → start the worker early so first classify() is fast
|
* warmup() → start the worker early so first classify() is fast
|
||||||
*
|
*
|
||||||
@@ -24,9 +23,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
let _worker = null;
|
let _worker = null;
|
||||||
let _ready = false;
|
let _ready = false;
|
||||||
let _readyCb = null;
|
let _readyCb = null;
|
||||||
let _errorCb = null;
|
|
||||||
const _pending = new Map(); // id → { resolve, reject }
|
const _pending = new Map(); // id → { resolve, reject }
|
||||||
let _nextId = 1;
|
let _nextId = 1;
|
||||||
|
|
||||||
@@ -47,7 +45,6 @@ function _init() {
|
|||||||
}
|
}
|
||||||
if (data?.type === 'error') {
|
if (data?.type === 'error') {
|
||||||
console.warn('[edge-worker] worker boot error:', data.message);
|
console.warn('[edge-worker] worker boot error:', data.message);
|
||||||
if (_errorCb) { _errorCb(data.message); _errorCb = null; }
|
|
||||||
// Resolve all pending with fallback values
|
// Resolve all pending with fallback values
|
||||||
for (const [, { resolve }] of _pending) resolve(_fallback(null));
|
for (const [, { resolve }] of _pending) resolve(_fallback(null));
|
||||||
_pending.clear();
|
_pending.clear();
|
||||||
@@ -106,11 +103,6 @@ export function onReady(fn) {
|
|||||||
_readyCb = fn;
|
_readyCb = fn;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Register a callback fired if the worker fails to boot (model load error). */
|
|
||||||
export function onError(fn) {
|
|
||||||
_errorCb = fn;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isReady() { return _ready; }
|
export function isReady() { return _ready; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import { initWebSocket, getConnectionState, getJobCount } from './websocket.js';
|
|||||||
import { initPaymentPanel } from './payment.js';
|
import { initPaymentPanel } from './payment.js';
|
||||||
import { initSessionPanel } from './session.js';
|
import { initSessionPanel } from './session.js';
|
||||||
import { initNostrIdentity } from './nostr-identity.js';
|
import { initNostrIdentity } from './nostr-identity.js';
|
||||||
import { warmup as warmupEdgeWorker, onReady as onEdgeWorkerReady, onError as onEdgeWorkerError } from './edge-worker-client.js';
|
import { warmup as warmupEdgeWorker, onReady as onEdgeWorkerReady } from './edge-worker-client.js';
|
||||||
import { setEdgeWorkerReady, setEdgeWorkerLoading, setEdgeWorkerError } from './ui.js';
|
import { setEdgeWorkerReady } from './ui.js';
|
||||||
import { initTimmyId } from './timmy-id.js';
|
import { initTimmyId } from './timmy-id.js';
|
||||||
import { AGENT_DEFS } from './agent-defs.js';
|
import { AGENT_DEFS } from './agent-defs.js';
|
||||||
import { initNavigation, updateNavigation, disposeNavigation } from './navigation.js';
|
import { initNavigation, updateNavigation, disposeNavigation } from './navigation.js';
|
||||||
@@ -47,10 +47,8 @@ function buildWorld(firstInit, stateSnapshot) {
|
|||||||
initPaymentPanel();
|
initPaymentPanel();
|
||||||
initSessionPanel();
|
initSessionPanel();
|
||||||
void initNostrIdentity('/api');
|
void initNostrIdentity('/api');
|
||||||
setEdgeWorkerLoading();
|
|
||||||
warmupEdgeWorker();
|
warmupEdgeWorker();
|
||||||
onEdgeWorkerReady(() => setEdgeWorkerReady());
|
onEdgeWorkerReady(() => setEdgeWorkerReady());
|
||||||
onEdgeWorkerError(() => setEdgeWorkerError());
|
|
||||||
void initTimmyId();
|
void initTimmyId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,48 +32,32 @@ export function setInputBarSessionMode(active, placeholder) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Model-ready indicator ─────────────────────────────────────────────────────
|
// ── Model-ready indicator ─────────────────────────────────────────────────────
|
||||||
// A small badge on the input bar showing local AI status: loading / ready / error.
|
// A small badge on the input bar showing when local AI is warm and ready.
|
||||||
// Appears immediately when warmup() starts so users know the worker is initialising.
|
// Hidden until the first `ready` event from the edge worker.
|
||||||
|
|
||||||
let $readyBadge = null;
|
let $readyBadge = null;
|
||||||
|
|
||||||
const EDGE_STATES = {
|
export function setEdgeWorkerReady() {
|
||||||
loading: { text: '◌ AI loading', color: '#88aacc', border: '#335577', title: 'Local AI model loading…' },
|
if (!$readyBadge) {
|
||||||
ready: { text: '⚡ local AI', color: '#44cc88', border: '#226644', title: 'Local AI active — trivial queries answered without Lightning payment' },
|
$readyBadge = document.createElement('span');
|
||||||
error: { text: '✕ AI offline', color: '#cc6644', border: '#773322', title: 'Local AI failed to load — all requests will be routed to server' },
|
$readyBadge.id = 'edge-ready-badge';
|
||||||
};
|
$readyBadge.title = 'Local AI active — trivial queries answered without Lightning payment';
|
||||||
|
$readyBadge.style.cssText = [
|
||||||
function _ensureEdgeBadge() {
|
'font-size:10px;color:#44cc88;border:1px solid #226644',
|
||||||
if ($readyBadge) return $readyBadge;
|
'border-radius:3px;padding:1px 5px;margin-left:6px',
|
||||||
$readyBadge = document.createElement('span');
|
'vertical-align:middle;cursor:default',
|
||||||
$readyBadge.id = 'edge-ready-badge';
|
].join(';');
|
||||||
$readyBadge.style.cssText = [
|
$readyBadge.textContent = '⚡ local AI';
|
||||||
'font-size:10px;border-radius:3px;padding:1px 5px;margin-left:6px',
|
const $input = document.getElementById('visitor-input');
|
||||||
'vertical-align:middle;cursor:default;transition:color .3s,border-color .3s',
|
$input?.insertAdjacentElement('afterend', $readyBadge);
|
||||||
].join(';');
|
// Fallback: append to send button area
|
||||||
const $input = document.getElementById('visitor-input');
|
if (!$readyBadge.isConnected) {
|
||||||
$input?.insertAdjacentElement('afterend', $readyBadge);
|
document.getElementById('send-btn')?.insertAdjacentElement('afterend', $readyBadge);
|
||||||
if (!$readyBadge.isConnected) {
|
}
|
||||||
document.getElementById('send-btn')?.insertAdjacentElement('afterend', $readyBadge);
|
|
||||||
}
|
}
|
||||||
return $readyBadge;
|
$readyBadge.style.display = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setEdgeWorkerStatus(state) {
|
|
||||||
const cfg = EDGE_STATES[state] ?? EDGE_STATES.loading;
|
|
||||||
const el = _ensureEdgeBadge();
|
|
||||||
el.textContent = cfg.text;
|
|
||||||
el.title = cfg.title;
|
|
||||||
el.style.color = cfg.color;
|
|
||||||
el.style.border = `1px solid ${cfg.border}`;
|
|
||||||
el.style.display = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Convenience wrappers kept for backward-compat with main.js callers. */
|
|
||||||
export function setEdgeWorkerReady() { setEdgeWorkerStatus('ready'); }
|
|
||||||
export function setEdgeWorkerLoading() { setEdgeWorkerStatus('loading'); }
|
|
||||||
export function setEdgeWorkerError() { setEdgeWorkerStatus('error'); }
|
|
||||||
|
|
||||||
// ── Cost preview badge ────────────────────────────────────────────────────────
|
// ── Cost preview badge ────────────────────────────────────────────────────────
|
||||||
// Shown beneath the input bar: "~N sats" / "FREE" / "answered locally".
|
// Shown beneath the input bar: "~N sats" / "FREE" / "answered locally".
|
||||||
// Fetched from GET /api/estimate once the user stops typing (300 ms debounce).
|
// Fetched from GET /api/estimate once the user stops typing (300 ms debounce).
|
||||||
@@ -496,6 +480,489 @@ 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() {
|
export function initHeatmap() {
|
||||||
const $bar = document.getElementById('heatmap-bar');
|
const $bar = document.getElementById('heatmap-bar');
|
||||||
const $iconBtn = document.getElementById('heatmap-icon-btn');
|
const $iconBtn = document.getElementById('heatmap-icon-btn');
|
||||||
@@ -522,3 +989,80 @@ export function initHeatmap() {
|
|||||||
void _fetchAndRenderHeatmap();
|
void _fetchAndRenderHeatmap();
|
||||||
_heatmapTimer = setInterval(_fetchAndRenderHeatmap, HEATMAP_REFRESH_MS);
|
_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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user