rojects/source/server/routes/rojos.ts

324 lines
11 KiB
TypeScript

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, AgentConfig } from "../rojos/RojosAgent";
import { RojoToolRegistry } from "../rojos/tools/RojoToolRegistry";
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<T>( 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.rawToken
|| 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, localRoot } = req.body as
{
id: string;
message: string;
projectId?: string;
rojoPath?: string;
localRoot?: 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: "",
tools: projectId ? RojoToolRegistry.definitions() : [],
};
if ( rojoPath )
{
let content: string | null = null;
if ( localRoot )
{
const resolvedRoot = path.resolve( localRoot );
const full = path.resolve( path.join( resolvedRoot, rojoPath ) );
if ( full.startsWith( resolvedRoot + path.sep ) && fs.existsSync( full ) )
{
content = fs.readFileSync( full, "utf8" );
}
}
else if ( projectId )
{
content = readProjectFile( projectId, rojoPath );
}
if ( content )
{
try
{
const settings = JSON.parse( content );
const ep = settings.endpoint ?? {};
config.systemPrompt = settings.systemPrompt ?? "";
if ( ep.type === "claude-code" )
{
config.provider = "claude-code";
}
else if ( ep.type === "claude" )
{
config.provider = "claude";
config.model = ep.model || "claude-opus-4-8";
config.apiKey = ep.apiKey || "";
}
else 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.rawToken
|| 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 {}
}
}
const toolContext = projectId ? { projectId } : undefined;
try
{
const agentStream = getAgentStream(
id,
message,
config,
toolContext,
toolContext ? RojoToolRegistry.execute.bind( RojoToolRegistry ) : undefined,
);
res.setHeader( "Content-Type", "text/event-stream" );
res.setHeader( "Cache-Control", "no-cache" );
res.setHeader( "Connection", "keep-alive" );
res.flushHeaders();
for await ( const event of agentStream )
{
if ( "text" === event.type )
{
res.write( JSON.stringify( { type: "CHAT", text: event.text } ) + "\n" );
}
else if ( "tool_call" === event.type )
{
res.write( JSON.stringify( { type: "TOOL_CALL", name: event.name, args: event.args } ) + "\n" );
}
else if ( "tool_result" === event.type )
{
res.write( JSON.stringify( { type: "TOOL_RESULT", name: event.name, result: event.result } ) + "\n" );
}
}
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;