import { Router } from "express"; import fs from "fs"; import path from "path"; import crypto from "crypto"; import { requireAuth } from "../../auth-connector/source/server/auth"; import { getAgentStream, updateAgentConversation, AgentConfig } from "../rojos/RojosAgent"; import { readProjectFile, writeProjectFile, createProjectDirectory } from "../storage"; import { checkAccess } from "../projectAccess"; import { RojosConfig } from "../rojos/RojosConfig"; import { ROOT } from "../rootDir"; const router = Router(); const STORAGE = path.join( ROOT, "build", "data", "storage" ); router.use( requireAuth ); // ── Helpers ────────────────────────────────────────────────────────────────── function normalizeUrl( url: string ): string { if ( !url.startsWith( "http://" ) && !url.startsWith( "https://" ) ) { return "http://" + url; } return url; } function pick( arr: T[] ): T { return arr[ Math.floor( Math.random() * arr.length ) ]; } function cap( s: string ): string { return s[ 0 ].toUpperCase() + s.slice( 1 ); } const ADJECTIVES = [ "funky", "cool", "groovy", "wild", "sharp", "slick", "swift", "bold", "bright", "calm", "daring", "eager", "fierce", "jolly", "lively", "quirky", "smart", "vivid", "zesty", "sleek", "crisp", "nifty", "rad", "sunny", "sassy", "snappy", "peppy", "zippy", "breezy", "mellow", ]; const ROLES = [ "boss", "doctor", "player", "teacher", "hunter", "builder", "maker", "dancer", "singer", "painter", "writer", "coder", "pilot", "chef", "sailor", "rider", "climber", "dreamer", "wanderer", "scout", "keeper", "helper", "guide", "mentor", "runner", "seeker", "ranger", "scholar", ]; // 50% European, 50% rest of world — modern names, male/female mixed const NAMES = [ "Sofia", "Elena", "Lucas", "Mia", "Noah", "Emma", "Leon", "Lena", "Felix", "Anna", "Max", "Clara", "Julian", "Sara", "Lars", "Nina", "Tom", "Lara", "Erik", "Ida", "Hugo", "Vera", "Otto", "Maja", "Kai", "Maya", "Arjun", "Zara", "Aisha", "Omar", "Nala", "Ravi", "Yuki", "Kenji", "Amara", "Diego", "Mateo", "Jae", "Sora", "Kira", "Rio", "Zion", "Nova", "Leila", "Cyrus", "Noa", "Bao", "Mila", ]; function generateRojoName(): { display: string; slug: string } { const adj = pick( ADJECTIVES ); const role = pick( ROLES ); const name = pick( NAMES ); return { display: `${ cap( adj ) } ${ cap( role ) } ${ name }`, slug: `${ adj }-${ role }-${ name.toLowerCase() }`, }; } // ── Recursive *.rojo scanner ───────────────────────────────────────────────── function scanRojoFiles( dir: string, projectRoot: string, out: string[] = [] ): string[] { if ( !fs.existsSync( dir ) ) return out; for ( const entry of fs.readdirSync( dir, { withFileTypes: true } ) ) { const full = path.join( dir, entry.name ); if ( entry.isDirectory() ) { scanRojoFiles( full, projectRoot, out ); } else if ( entry.name.endsWith( ".rojo" ) ) { out.push( path.relative( projectRoot, full ).replace( /\\/g, "/" ) ); } } return out; } // ── Tunnel browser (proxies to tunnel.rokojori.com) ───────────────────────── router.get( "/tunnels/browse", async ( req, res ) => { const purpose = typeof req.query.purpose === "string" ? req.query.purpose : ""; const token = req.cookies?.accessToken || ( typeof req.headers.authorization === "string" && req.headers.authorization.startsWith( "Bearer " ) ? req.headers.authorization.slice( 7 ) : "" ); if ( !token ) { res.status( 401 ).json( { error: "No auth token" } ); return; } const tunnelServer = process.env.TUNNEL_SERVER_URL || "https://tunnel.rokojori.com"; const url = purpose ? `${ tunnelServer }/api/tunnels/available?purpose=${ encodeURIComponent( purpose ) }` : `${ tunnelServer }/api/tunnels/available`; try { const upstream = await fetch( url, { headers: { Authorization: `Bearer ${ token }` } } ); const data = await upstream.json(); res.json( data ); } catch { res.status( 502 ).json( { error: "Could not reach tunnel server" } ); } } ); // ── List rojos ─────────────────────────────────────────────────────────────── router.get( "/:projectId/list", ( req, res ) => { const denied = checkAccess( req.params.projectId, req.auth!, "view" ); if ( denied ) { res.status( denied.status ).json( { error: denied.error } ); return; } const projectRoot = path.join( STORAGE, req.params.projectId, "root" ); const rojoDir = path.join( projectRoot, "workspace", "rojos" ); const files = scanRojoFiles( rojoDir, projectRoot ); const rojos = files.map( filePath => { let id = "", name = filePath, description = ""; const content = readProjectFile( req.params.projectId, filePath ); if ( content ) { try { const s = JSON.parse( content ); id = s.id || ""; name = s.name || name; description = s.description || ""; } catch {} } return { id, name, description, path: filePath }; } ); res.json( rojos ); } ); // ── Create rojo ────────────────────────────────────────────────────────────── router.post( "/:projectId/create", ( req, res ) => { const denied = checkAccess( req.params.projectId, req.auth!, "edit" ); if ( denied ) { res.status( denied.status ).json( { error: denied.error } ); return; } const { parentDir } = req.body as { parentDir?: string }; const dir = parentDir || "workspace/rojos"; const { display, slug } = generateRojoName(); const id = crypto.randomUUID(); const filePath = `${ dir }/${ slug }.rojo`; const defaults = JSON.stringify( { id, name: display, description: "", systemPrompt: "", endpoint: { type: "external", url: "", model: "", apiKey: "", tunnelId: "" }, appearance: { colors: [], layers: [] }, }, null, 2 ); const ok = writeProjectFile( req.params.projectId, filePath, defaults ); if ( !ok ) { res.status( 500 ).json( { error: "Could not create rojo file" } ); return; } res.json( { id, path: filePath, name: display } ); } ); // ── Chat ───────────────────────────────────────────────────────────────────── router.post( "/chat", async ( req, res ) => { const { id, message, projectId, rojoPath } = req.body as { id: string; message: string; projectId?: string; rojoPath?: string; }; if ( !id || !message ) { res.status( 400 ).json( { error: "id and message are required" } ); return; } const config: AgentConfig = { baseURL: RojosConfig.baseURL, model: RojosConfig.model, apiKey: RojosConfig.apiKey, systemPrompt: "", }; if ( projectId && rojoPath ) { const content = readProjectFile( projectId, rojoPath ); if ( content ) { try { const settings = JSON.parse( content ); const ep = settings.endpoint ?? {}; config.systemPrompt = settings.systemPrompt ?? ""; if ( ep.type === "tunnel" && ep.tunnelId ) { const tunnelServer = process.env.TUNNEL_SERVER_URL || "https://tunnel.rokojori.com"; config.baseURL = `${ tunnelServer }/t/${ ep.tunnelId }/v1`; config.model = ep.model || RojosConfig.model; config.apiKey = "not-needed"; const token = req.cookies?.accessToken || ( typeof req.headers.authorization === "string" && req.headers.authorization.startsWith( "Bearer " ) ? req.headers.authorization.slice( 7 ) : "" ); if ( token ) config.headers = { Authorization: `Bearer ${ token }` }; } else if ( ep.url ) { config.baseURL = normalizeUrl( ep.url ); config.model = ep.model || RojosConfig.model; config.apiKey = ep.apiKey || "not-needed"; } } catch {} } } try { const agentStream = await getAgentStream( id, message, config ); res.setHeader( "Content-Type", "text/event-stream" ); res.setHeader( "Cache-Control", "no-cache" ); res.setHeader( "Connection", "keep-alive" ); res.flushHeaders(); const collected: string[] = []; for await ( const chunk of agentStream ) { const content = chunk.content; let text = ""; if ( typeof content === "string" ) { text = content; } else if ( Array.isArray( content ) ) { for ( const c of content ) { if ( typeof c === "string" ) text += c; else if ( c && typeof c === "object" && "text" in c ) text += ( c as any ).text; } } if ( text.length > 0 ) { collected.push( text ); res.write( JSON.stringify( { type: "CHAT", text } ) + "\n" ); } else { res.write( JSON.stringify( { type: "THINKING" } ) + "\n" ); } } updateAgentConversation( id, collected.join( "" ) ); res.write( JSON.stringify( { type: "DONE" } ) + "\n" ); res.end(); } catch ( err ) { console.error( err ); if ( !res.headersSent ) res.status( 500 ).json( { error: "Failed to get agent response" } ); } } ); export default router;