2026-07-16 13:06:33 +00:00
|
|
|
import { Router } from "express";
|
|
|
|
|
import fs from "fs";
|
|
|
|
|
import path from "path";
|
|
|
|
|
import crypto from "crypto";
|
|
|
|
|
import { requireAuth } from "../middleware/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" );
|
2026-07-10 18:18:15 +00:00
|
|
|
|
|
|
|
|
router.use( requireAuth );
|
|
|
|
|
|
2026-07-16 13:06:33 +00:00
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function normalizeUrl( url: string ): string
|
|
|
|
|
{
|
|
|
|
|
if ( !url.startsWith( "http://" ) && !url.startsWith( "https://" ) )
|
|
|
|
|
{
|
|
|
|
|
return "http://" + url;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return url;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pick<T>( arr: T[] ): T
|
2026-07-10 18:18:15 +00:00
|
|
|
{
|
2026-07-16 13:06:33 +00:00
|
|
|
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.user!, "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 = "";
|
2026-07-10 18:18:15 +00:00
|
|
|
|
2026-07-16 13:06:33 +00:00
|
|
|
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.user!, "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;
|
|
|
|
|
};
|
2026-07-10 18:18:15 +00:00
|
|
|
|
|
|
|
|
if ( !id || !message )
|
|
|
|
|
{
|
|
|
|
|
res.status( 400 ).json( { error: "id and message are required" } );
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-16 13:06:33 +00:00
|
|
|
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 {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-10 18:18:15 +00:00
|
|
|
try
|
|
|
|
|
{
|
2026-07-16 13:06:33 +00:00
|
|
|
const agentStream = await getAgentStream( id, message, config );
|
2026-07-10 18:18:15 +00:00
|
|
|
|
2026-07-16 13:06:33 +00:00
|
|
|
res.setHeader( "Content-Type", "text/event-stream" );
|
2026-07-10 18:18:15 +00:00
|
|
|
res.setHeader( "Cache-Control", "no-cache" );
|
2026-07-16 13:06:33 +00:00
|
|
|
res.setHeader( "Connection", "keep-alive" );
|
2026-07-10 18:18:15 +00:00
|
|
|
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 )
|
|
|
|
|
{
|
2026-07-16 13:06:33 +00:00
|
|
|
if ( typeof c === "string" ) text += c;
|
|
|
|
|
else if ( c && typeof c === "object" && "text" in c ) text += ( c as any ).text;
|
2026-07-10 18:18:15 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 );
|
2026-07-16 13:06:33 +00:00
|
|
|
if ( !res.headersSent ) res.status( 500 ).json( { error: "Failed to get agent response" } );
|
2026-07-10 18:18:15 +00:00
|
|
|
}
|
|
|
|
|
} );
|
|
|
|
|
|
|
|
|
|
export default router;
|