rojects/source/server/routes/rojos.ts

303 lines
10 KiB
TypeScript
Raw Normal View History

import { Router } from "express";
import fs from "fs";
import path from "path";
import crypto from "crypto";
2026-07-17 12:00:16 +00:00
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<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.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 ) =>
{
2026-07-17 11:37:48 +00:00
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 ) =>
{
2026-07-17 11:37:48 +00:00
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;