From ad2a5e23fa95ed8345a28acb33857844aa70e0e5 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Mon, 23 Mar 2026 22:26:20 -0400 Subject: [PATCH] WIP: Claude Code progress on #65 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automated salvage commit — agent session ended (exit 124). Work in progress, may need continuation. --- artifacts/api-server/src/lib/provisioner.ts | 121 ++++++------------ artifacts/api-server/src/routes/bootstrap.ts | 2 +- .../api-server/src/routes/relay-policy.ts | 63 +++++---- artifacts/mobile/app/(tabs)/_layout.tsx | 72 ++++++----- artifacts/mobile/app/settings.tsx | 115 +++-------------- the-matrix/js/edge-worker-client.js | 10 +- the-matrix/js/main.js | 6 +- the-matrix/js/ui.js | 56 +++++--- 8 files changed, 185 insertions(+), 260 deletions(-) diff --git a/artifacts/api-server/src/lib/provisioner.ts b/artifacts/api-server/src/lib/provisioner.ts index c721a2c..dbce75a 100644 --- a/artifacts/api-server/src/lib/provisioner.ts +++ b/artifacts/api-server/src/lib/provisioner.ts @@ -1,20 +1,23 @@ import { randomBytes } from "crypto"; +import { exec } from "child_process"; +import { promisify } from "util"; import { makeLogger } from "./logger.js"; const logger = makeLogger("provisioner"); +const execAsync = promisify(exec); export interface ProvisionerConfig { doApiToken: string; doRegion: string; doSize: string; doVolumeSizeGb: number; - doVpcUuid: string; // New: Digital Ocean VPC UUID - doSshKeyFingerprint: string; // New: Digital Ocean SSH Key Fingerprint + doVpcUuid: string; + doSshKeyFingerprint: string; tailscaleApiKey: string; tailscaleTailnet: string; } -const stubProvisioningResults = new Map(); // To store fake results for stub mode +const stubProvisioningResults = new Map(); // To store fake results for stub mode export class ProvisionerService { private readonly config: ProvisionerConfig; @@ -26,8 +29,8 @@ export class ProvisionerService { doRegion: config?.doRegion ?? process.env.DO_REGION ?? "nyc3", doSize: config?.doSize ?? process.env.DO_SIZE ?? "s-2vcpu-4gb", doVolumeSizeGb: config?.doVolumeSizeGb ?? parseInt(process.env.DO_VOLUME_SIZE_GB ?? "100", 10), - doVpcUuid: config?.doVpcUuid ?? process.env.DO_VPC_UUID ?? "", // New - doSshKeyFingerprint: config?.doSshKeyFingerprint ?? process.env.DO_SSH_KEY_FINGERPRINT ?? "", // New + doVpcUuid: config?.doVpcUuid ?? process.env.DO_VPC_UUID ?? "", + doSshKeyFingerprint: config?.doSshKeyFingerprint ?? process.env.DO_SSH_KEY_FINGERPRINT ?? "", tailscaleApiKey: config?.tailscaleApiKey ?? process.env.TAILSCALE_API_KEY ?? "", tailscaleTailnet: config?.tailscaleTailnet ?? process.env.TAILSCALE_TAILNET ?? "", }; @@ -73,36 +76,22 @@ FakeKeyForJob${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 createDropletCommand = `doctl compute droplet create ${dropletName} \ - --region ${this.config.doRegion} \ - --size ${this.config.doSize} \ - --image ubuntu-22-04-x64 \ - --enable-private-networking \ - --vpc-uuid \ - --user-data '${cloudConfig}' \ - --ssh-keys \ - --format ID --no-header`; // Simplistic command, needs refinement for real use + const createDropletCmd = [ + `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`, + ].join(" \\\n "); - 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(); + const { stdout } = await execAsync(createDropletCmd); + const dropletId = stdout.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 }); await new Promise(resolve => setTimeout(resolve, 10000)); // Simulate droplet creation time @@ -111,11 +100,11 @@ FakeKeyForJob${jobId} const lnbitsUrl = `http://${nodeIp}:3000/lnbits`; // Dummy LNbits URL return { - dropletId: dropletId, - nodeIp: nodeIp, - tailscaleHostname: tailscaleHostname, - lnbitsUrl: lnbitsUrl, - sshPrivateKey: sshPrivateKey, + dropletId, + nodeIp, + tailscaleHostname, + lnbitsUrl, + sshPrivateKey, }; } @@ -123,23 +112,16 @@ FakeKeyForJob${jobId} private async generateSshKeyPair(): Promise<{ sshPrivateKey: string; sshPublicKey: string }> { logger.info("generating SSH keypair"); const keyPath = `/tmp/id_rsa_${randomBytes(4).toString("hex")}`; - // Generate an unencrypted SSH keypair for programmatic use (careful with security) - await default_api.run_shell_command( - command: `ssh-keygen -t rsa -b 4096 -f ${keyPath} -N ""`, - description: "Generating SSH keypair", - ); - 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 }; + await execAsync(`ssh-keygen -t rsa -b 4096 -f ${keyPath} -N ""`); + const { stdout: privOut } = await execAsync(`cat ${keyPath}`); + const { stdout: pubOut } = await execAsync(`cat ${keyPath}.pub`); + await execAsync(`rm ${keyPath} ${keyPath}.pub`); + return { sshPrivateKey: privOut.trim(), sshPublicKey: pubOut.trim() }; } // Helper to create Tailscale auth key (simplified stub) private async createTailscaleAuthKey(): Promise { 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 return `tskey-test-${randomBytes(16).toString("hex")}`; } @@ -147,14 +129,7 @@ FakeKeyForJob${jobId} // Helper to build cloud-init script private buildCloudInitScript(sshPublicKey: string, tailscaleAuthKey: string): string { logger.info("building cloud-init script"); - 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`; + const baseUrl = `http://143.198.27.163:3000/replit/timmy-tower/raw/branch/main/infrastructure`; return ` #cloud-config @@ -169,39 +144,17 @@ write_files: permissions: '0755' content: | #!/usr/bin/env bash - 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 + curl -s ${baseUrl}/setup.sh > /root/setup.sh runcmd: - mkdir -p /root/configs - - curl -s ${setupScriptUrl} > /tmp/setup.sh + - curl -s ${baseUrl}/setup.sh > /tmp/setup.sh - chmod +x /tmp/setup.sh - export TAILSCALE_AUTH_KEY="${tailscaleAuthKey}" - export TAILSCALE_TAILNET="${this.config.tailscaleTailnet}" - /tmp/setup.sh `; + } +} -export const provisionerService = new ProvisionerService(); \ No newline at end of file +export const provisionerService = new ProvisionerService(); diff --git a/artifacts/api-server/src/routes/bootstrap.ts b/artifacts/api-server/src/routes/bootstrap.ts index fbcfaeb..ca607ed 100644 --- a/artifacts/api-server/src/routes/bootstrap.ts +++ b/artifacts/api-server/src/routes/bootstrap.ts @@ -138,7 +138,7 @@ router.post("/bootstrap", async (req: Request, res: Response) => { // ── GET /api/bootstrap/:id ─────────────────────────────────────────────────── router.get("/bootstrap/:id", async (req: Request, res: Response) => { - const { id } = req.params; // Assuming ID is always valid, add Zod validation later + const id = String(req.params["id"] ?? ""); // cast: Express 5 params are string try { let job = await getBootstrapJobById(id); diff --git a/artifacts/api-server/src/routes/relay-policy.ts b/artifacts/api-server/src/routes/relay-policy.ts index f775a0f..3f86f7b 100644 --- a/artifacts/api-server/src/routes/relay-policy.ts +++ b/artifacts/api-server/src/routes/relay-policy.ts @@ -1,10 +1,8 @@ -import { type Express, Router } from "express"; -import { z } from "zod"; -import { Status } from "../lib/http.js"; -import { rootLogger } from "../lib/logger.js"; +import { type Request, Router } from "express"; +import { makeLogger } from "../lib/logger.js"; const router = Router(); -const log = rootLogger.child({ service: "relay-policy" }); +const log = makeLogger("relay-policy"); // ── Auth ────────────────────────────────────────────────────────────────────── @@ -14,7 +12,7 @@ if (!RELAY_POLICY_SECRET) { log.warn("RELAY_POLICY_SECRET is not set — /api/relay/policy will be unauthenticated!"); } -function isAuthenticated(req: Express.Request): boolean { +function isAuthenticated(req: Request): boolean { if (!RELAY_POLICY_SECRET) { return true; // No secret configured, so no auth. } @@ -29,43 +27,54 @@ function isAuthenticated(req: Express.Request): boolean { return true; } -// ── POST /api/relay/policy ──────────────────────────────────────────────────── +// ── Request body shape (manual validation — zod not in deps) ────────────────── -const relayPolicyRequestSchema = z.object({ - event: z.object({ - id: z.string(), - pubkey: z.string(), - kind: z.number(), - created_at: z.number(), - tags: z.array(z.array(z.string())), - content: z.string(), - sig: z.string(), - }), - receivedAt: z.number(), - sourceType: z.string(), - sourceInfo: z.string(), -}); +interface StrfryEventBody { + event?: { + id?: unknown; + pubkey?: unknown; + kind?: unknown; + created_at?: unknown; + tags?: unknown; + content?: unknown; + sig?: unknown; + }; + receivedAt?: unknown; + sourceType?: unknown; + sourceInfo?: unknown; +} + +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"; router.post("/relay/policy", (req, res) => { if (!isAuthenticated(req)) { - return res.status(Status.UNAUTHORIZED).json({ + res.status(401).json({ action: "reject", msg: "unauthorized", }); + return; } - const parse = relayPolicyRequestSchema.safeParse(req.body); - if (!parse.success) { - log.warn("invalid /relay/policy request", { error: parse.error.format() }); - return res.status(Status.BAD_REQUEST).json({ + const parsed = parseRelayPolicyBody(req.body); + if (!parsed.ok) { + log.warn("invalid /relay/policy request"); + res.status(400).json({ action: "reject", msg: "invalid request", }); + return; } - const eventId = parse.data.event.id; + const { eventId } = parsed; // Bootstrap state: reject everything. // This will be extended by whitelist + moderation tasks. diff --git a/artifacts/mobile/app/(tabs)/_layout.tsx b/artifacts/mobile/app/(tabs)/_layout.tsx index c744df3..83cadf9 100644 --- a/artifacts/mobile/app/(tabs)/_layout.tsx +++ b/artifacts/mobile/app/(tabs)/_layout.tsx @@ -1,11 +1,11 @@ import { BlurView } from "expo-blur"; import { isLiquidGlassAvailable } from "expo-glass-effect"; -import { Link, Tabs, router } from "expo-router"; +import { Link, Tabs } from "expo-router"; import { Icon, Label, NativeTabs } from "expo-router/unstable-native-tabs"; import { SymbolView } from "expo-symbols"; import { Feather, MaterialCommunityIcons, Ionicons } from "@expo/vector-icons"; import React from "react"; -import { Platform, Pressable, StyleSheet, View, useColorScheme } from "react-native"; +import { Platform, Pressable, StyleSheet, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Colors } from "@/constants/colors"; @@ -13,16 +13,16 @@ import { Colors } from "@/constants/colors"; function NativeTabLayout() { return ( - - + + - - + + - - + + @@ -35,11 +35,14 @@ function ClassicTabLayout() { const isWeb = Platform.OS === "web"; const C = Colors.dark; + void insets; // used by callers that extend this + return ( ) : isWeb ? ( @@ -60,53 +63,60 @@ function ClassicTabLayout() { /> ) : ( - ),\ - }}\ + ), + }} > (\n \n ({ opacity: pressed ? 0.5 : 1 })}> - \n \n \n ), + headerRight: () => ( + + ({ opacity: pressed ? 0.5 : 1 })}> + + + + ), tabBarIcon: ({ color, size }) => isIOS ? ( - + ) : ( - - ),\ - }}\ + + ), + }} /> isIOS ? ( - + ) : ( - - ),\ - }}\ + + ), + }} /> isIOS ? ( - + ) : ( - - ),\ - }}\ + + ), + }} /> ); } export default function TabLayout() { - if (isLiquidGlassAvailable()) {\n return (\n \n (\n \n ({ opacity: pressed ? 0.5 : 1 })}>\n \n \n \n ),\n }}\n />\n \n \n \n );\n } - return ;\ + if (isLiquidGlassAvailable()) { + return ; + } + return ; } diff --git a/artifacts/mobile/app/settings.tsx b/artifacts/mobile/app/settings.tsx index b0f38f5..4e04513 100644 --- a/artifacts/mobile/app/settings.tsx +++ b/artifacts/mobile/app/settings.tsx @@ -2,7 +2,6 @@ import { Stack } from 'expo-router'; import { View, Text, StyleSheet, ScrollView, TextInput, Switch, Pressable, Linking, Platform } from 'react-native'; import { useState, useEffect } from 'react'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import * as SecureStore from 'expo-secure-store'; import Constants from 'expo-constants'; import { useTimmy } from '@/context/TimmyContext'; import { Ionicons } from '@expo/vector-icons'; @@ -13,49 +12,30 @@ const STORAGE_KEYS = { SERVER_URL: 'settings_server_url', NOTIFICATIONS_JOB_COMPLETION: 'settings_notifications_job_completion', NOTIFICATIONS_LOW_BALANCE: 'settings_notifications_low_balance', - NOSTR_PRIVATE_KEY: 'settings_nostr_private_key', // Use SecureStore for this }; export default function SettingsScreen() { - const { apiBaseUrl, setApiBaseUrl, isConnected, nostrPublicKey, connectNostr, disconnectNostr } = useTimmy(); + const { connectionStatus } = useTimmy(); const C = Colors.dark; - const [serverUrl, setServerUrl] = useState(apiBaseUrl); + const [serverUrl, setServerUrl] = useState(''); const [jobCompletionNotifications, setJobCompletionNotifications] = useState(false); const [lowBalanceWarning, setLowBalanceWarning] = useState(false); - const [currentNpub, setCurrentNpub] = useState(nostrPublicKey); useEffect(() => { - // Load settings from AsyncStorage and SecureStore const loadSettings = async () => { 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); - if (storedJobCompletion !== null) { - setJobCompletionNotifications(JSON.parse(storedJobCompletion)); - } + if (storedJobCompletion !== null) setJobCompletionNotifications(JSON.parse(storedJobCompletion)); const storedLowBalance = await AsyncStorage.getItem(STORAGE_KEYS.NOTIFICATIONS_LOW_BALANCE); - if (storedLowBalance !== null) { - setLowBalanceWarning(JSON.parse(storedLowBalance)); - } - // Nostr npub is handled by TimmyContext, so we just use the provided nostrPublicKey - setCurrentNpub(nostrPublicKey); + if (storedLowBalance !== null) setLowBalanceWarning(JSON.parse(storedLowBalance)); }; loadSettings(); - }, [nostrPublicKey]); + }, []); - // Update apiBaseUrl in context when serverUrl changes and is saved - useEffect(() => { - if (serverUrl !== apiBaseUrl) { - setApiBaseUrl(serverUrl); - AsyncStorage.setItem(STORAGE_KEYS.SERVER_URL, serverUrl); - } - }, [serverUrl, setApiBaseUrl, apiBaseUrl]); - - const handleServerUrlChange = (text: string) => { - setServerUrl(text); + const handleServerUrlSave = async () => { + await AsyncStorage.setItem(STORAGE_KEYS.SERVER_URL, serverUrl); }; const toggleJobCompletionNotifications = async () => { @@ -70,32 +50,11 @@ export default function SettingsScreen() { await AsyncStorage.setItem(STORAGE_KEYS.NOTIFICATIONS_LOW_BALANCE, JSON.stringify(newValue)); }; - const handleConnectNostr = async () => { - // 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 appVersion = Constants.expoConfig?.version ?? 'N/A'; + const buildCommitHash = (Constants.expoConfig?.extra as Record | undefined)?.gitCommitHash ?? 'N/A'; const giteaRepoUrl = 'http://143.198.27.163:3000/replit/timmy-tower'; - const openGiteaLink = () => { - Linking.openURL(giteaRepoUrl); - }; + const openGiteaLink = () => { Linking.openURL(giteaRepoUrl); }; return ( @@ -106,15 +65,16 @@ export default function SettingsScreen() { Server URL - + @@ -124,7 +84,7 @@ export default function SettingsScreen() { @@ -134,31 +94,12 @@ export default function SettingsScreen() { - Identity - - Nostr Public Key - - {currentNpub ? `${currentNpub.substring(0, 10)}...${currentNpub.substring(currentNpub.length - 5)}` : 'Not connected'} - - - - {!currentNpub ? ( - [styles.button, { backgroundColor: C.accent, opacity: pressed ? 0.8 : 1 }]}> - Connect Nostr - - ) : ( - [styles.button, { backgroundColor: C.destructive, opacity: pressed ? 0.8 : 1 }]}> - Disconnect Nostr - - )} - - About App Version @@ -170,7 +111,7 @@ export default function SettingsScreen() { [styles.linkButton, { opacity: pressed ? 0.8 : 1 }]}> - View project on Gitea + View project on Gitea @@ -180,7 +121,7 @@ export default function SettingsScreen() { const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: Colors.dark.background, // Use background color from Colors + backgroundColor: Colors.dark.background, }, scrollContent: { padding: 20, @@ -223,27 +164,13 @@ const styles = StyleSheet.create({ fontSize: 14, marginRight: 10, }, - buttonContainer: { - marginTop: 20, - alignItems: 'flex-start', - }, - button: { - paddingVertical: 10, - paddingHorizontal: 15, - borderRadius: 8, - }, - buttonText: { - fontSize: 16, - fontWeight: 'bold', - }, linkButton: { flexDirection: 'row', alignItems: 'center', - marginTop: 15, - paddingVertical: 8, + gap: 6, + paddingVertical: 12, }, linkButtonText: { - marginLeft: 5, fontSize: 16, }, }); diff --git a/the-matrix/js/edge-worker-client.js b/the-matrix/js/edge-worker-client.js index eaa680f..adddbef 100644 --- a/the-matrix/js/edge-worker-client.js +++ b/the-matrix/js/edge-worker-client.js @@ -10,6 +10,7 @@ * }> * sentiment(text) → Promise<{ label:'POSITIVE'|'NEGATIVE'|'NEUTRAL', score }> * 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 * warmup() → start the worker early so first classify() is fast * @@ -23,8 +24,9 @@ */ let _worker = null; -let _ready = false; +let _ready = false; let _readyCb = null; +let _errorCb = null; const _pending = new Map(); // id → { resolve, reject } let _nextId = 1; @@ -45,6 +47,7 @@ function _init() { } if (data?.type === 'error') { console.warn('[edge-worker] worker boot error:', data.message); + if (_errorCb) { _errorCb(data.message); _errorCb = null; } // Resolve all pending with fallback values for (const [, { resolve }] of _pending) resolve(_fallback(null)); _pending.clear(); @@ -103,6 +106,11 @@ export function onReady(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; } /** diff --git a/the-matrix/js/main.js b/the-matrix/js/main.js index 4c89cc6..5ca64eb 100644 --- a/the-matrix/js/main.js +++ b/the-matrix/js/main.js @@ -12,8 +12,8 @@ import { initWebSocket, getConnectionState, getJobCount } from './websocket.js'; import { initPaymentPanel } from './payment.js'; import { initSessionPanel } from './session.js'; import { initNostrIdentity } from './nostr-identity.js'; -import { warmup as warmupEdgeWorker, onReady as onEdgeWorkerReady } from './edge-worker-client.js'; -import { setEdgeWorkerReady } from './ui.js'; +import { warmup as warmupEdgeWorker, onReady as onEdgeWorkerReady, onError as onEdgeWorkerError } from './edge-worker-client.js'; +import { setEdgeWorkerReady, setEdgeWorkerLoading, setEdgeWorkerError } from './ui.js'; import { initTimmyId } from './timmy-id.js'; import { AGENT_DEFS } from './agent-defs.js'; import { initNavigation, updateNavigation, disposeNavigation } from './navigation.js'; @@ -47,8 +47,10 @@ function buildWorld(firstInit, stateSnapshot) { initPaymentPanel(); initSessionPanel(); void initNostrIdentity('/api'); + setEdgeWorkerLoading(); warmupEdgeWorker(); onEdgeWorkerReady(() => setEdgeWorkerReady()); + onEdgeWorkerError(() => setEdgeWorkerError()); void initTimmyId(); } diff --git a/the-matrix/js/ui.js b/the-matrix/js/ui.js index 801482a..506a9fc 100644 --- a/the-matrix/js/ui.js +++ b/the-matrix/js/ui.js @@ -32,32 +32,48 @@ export function setInputBarSessionMode(active, placeholder) { } // ── 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. +// A small badge on the input bar showing local AI status: loading / ready / error. +// Appears immediately when warmup() starts so users know the worker is initialising. 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); - } +const EDGE_STATES = { + loading: { text: '◌ AI loading', color: '#88aacc', border: '#335577', title: 'Local AI model loading…' }, + ready: { text: '⚡ local AI', color: '#44cc88', border: '#226644', title: 'Local AI active — trivial queries answered without Lightning payment' }, + error: { text: '✕ AI offline', color: '#cc6644', border: '#773322', title: 'Local AI failed to load — all requests will be routed to server' }, +}; + +function _ensureEdgeBadge() { + if ($readyBadge) return $readyBadge; + $readyBadge = document.createElement('span'); + $readyBadge.id = 'edge-ready-badge'; + $readyBadge.style.cssText = [ + 'font-size:10px;border-radius:3px;padding:1px 5px;margin-left:6px', + 'vertical-align:middle;cursor:default;transition:color .3s,border-color .3s', + ].join(';'); + const $input = document.getElementById('visitor-input'); + $input?.insertAdjacentElement('afterend', $readyBadge); + if (!$readyBadge.isConnected) { + document.getElementById('send-btn')?.insertAdjacentElement('afterend', $readyBadge); } - $readyBadge.style.display = ''; + return $readyBadge; } +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 ──────────────────────────────────────────────────────── // Shown beneath the input bar: "~N sats" / "FREE" / "answered locally". // Fetched from GET /api/estimate once the user stops typing (300 ms debounce).