// relay v0.3.0 // Core relay server for Lyra project // Handles incoming chat requests and forwards them to Cortex services import express from "express"; import dotenv from "dotenv"; import cors from "cors"; import fs from "fs/promises"; import path from "path"; import { fileURLToPath } from "url"; dotenv.config(); // ES module __dirname workaround const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const SESSIONS_DIR = path.join(__dirname, "sessions"); const app = express(); app.use(cors()); app.use(express.json()); const PORT = Number(process.env.PORT || 7078); // Cortex endpoints const CORTEX_REASON = process.env.CORTEX_REASON_URL || "http://cortex:7081/reason"; const CORTEX_SIMPLE = process.env.CORTEX_SIMPLE_URL || "http://cortex:7081/simple"; // ----------------------------------------------------- // Helper request wrapper // ----------------------------------------------------- async function postJSON(url, data) { const resp = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }); const raw = await resp.text(); let json; try { json = raw ? JSON.parse(raw) : null; } catch (e) { throw new Error(`Non-JSON from ${url}: ${raw}`); } if (!resp.ok) { throw new Error(json?.detail || json?.error || raw); } return json; } // ----------------------------------------------------- // The unified chat handler // ----------------------------------------------------- async function handleChatRequest(session_id, user_msg, mode = "cortex", backend = null) { let reason; // Determine which endpoint to use based on mode const endpoint = mode === "standard" ? CORTEX_SIMPLE : CORTEX_REASON; const modeName = mode === "standard" ? "simple" : "reason"; console.log(`Relay → routing to Cortex.${modeName} (mode: ${mode}${backend ? `, backend: ${backend}` : ''})`); // Build request payload const payload = { session_id, user_prompt: user_msg }; // Add backend parameter if provided (only for standard mode) if (backend && mode === "standard") { payload.backend = backend; } // Call appropriate Cortex endpoint try { reason = await postJSON(endpoint, payload); } catch (e) { console.error(`Relay → Cortex.${modeName} error:`, e.message); throw new Error(`cortex_${modeName}_failed: ${e.message}`); } // Correct persona field const persona = reason.persona || reason.final_output || "(no persona text)"; // Return final answer return { session_id, reply: persona }; } // ----------------------------------------------------- // HEALTHCHECK // ----------------------------------------------------- app.get("/_health", (_, res) => { res.json({ ok: true }); }); // ----------------------------------------------------- // OPENAI-COMPATIBLE ENDPOINT // ----------------------------------------------------- app.post("/v1/chat/completions", async (req, res) => { try { const session_id = req.body.session_id || req.body.sessionId || req.body.user || "default"; const messages = req.body.messages || []; const lastMessage = messages[messages.length - 1]; const user_msg = lastMessage?.content || ""; const mode = req.body.mode || "cortex"; // Get mode from request, default to cortex const backend = req.body.backend || null; // Get backend preference if (!user_msg) { return res.status(400).json({ error: "No message content provided" }); } console.log(`Relay (v1) → received: "${user_msg}" [mode: ${mode}${backend ? `, backend: ${backend}` : ''}]`); const result = await handleChatRequest(session_id, user_msg, mode, backend); res.json({ id: `chatcmpl-${Date.now()}`, object: "chat.completion", created: Math.floor(Date.now() / 1000), model: "lyra", choices: [{ index: 0, message: { role: "assistant", content: result.reply }, finish_reason: "stop" }], usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 } }); } catch (err) { console.error("Relay v1 fatal:", err); res.status(500).json({ error: { message: err.message || String(err), type: "server_error", code: "relay_failed" } }); } }); // ----------------------------------------------------- // MAIN ENDPOINT (Lyra-native UI) // ----------------------------------------------------- app.post("/chat", async (req, res) => { try { const session_id = req.body.session_id || "default"; const user_msg = req.body.message || ""; const mode = req.body.mode || "cortex"; // Get mode from request, default to cortex const backend = req.body.backend || null; // Get backend preference console.log(`Relay → received: "${user_msg}" [mode: ${mode}${backend ? `, backend: ${backend}` : ''}]`); const result = await handleChatRequest(session_id, user_msg, mode, backend); res.json(result); } catch (err) { console.error("Relay fatal:", err); res.status(500).json({ error: "relay_failed", detail: err.message || String(err) }); } }); // ----------------------------------------------------- // SESSION ENDPOINTS (for UI) // ----------------------------------------------------- // Helper functions for session persistence async function ensureSessionsDir() { try { await fs.mkdir(SESSIONS_DIR, { recursive: true }); } catch (err) { console.error("Failed to create sessions directory:", err); } } async function loadSession(sessionId) { try { const sessionPath = path.join(SESSIONS_DIR, `${sessionId}.json`); const data = await fs.readFile(sessionPath, "utf-8"); return JSON.parse(data); } catch (err) { // File doesn't exist or is invalid - return empty array return []; } } async function saveSession(sessionId, history, metadata = {}) { try { await ensureSessionsDir(); const sessionPath = path.join(SESSIONS_DIR, `${sessionId}.json`); const metadataPath = path.join(SESSIONS_DIR, `${sessionId}.meta.json`); // Save history await fs.writeFile(sessionPath, JSON.stringify(history, null, 2), "utf-8"); // Save metadata (name, etc.) await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), "utf-8"); return true; } catch (err) { console.error(`Failed to save session ${sessionId}:`, err); return false; } } async function loadSessionMetadata(sessionId) { try { const metadataPath = path.join(SESSIONS_DIR, `${sessionId}.meta.json`); const data = await fs.readFile(metadataPath, "utf-8"); return JSON.parse(data); } catch (err) { // No metadata file, return default return { name: sessionId }; } } async function saveSessionMetadata(sessionId, metadata) { try { await ensureSessionsDir(); const metadataPath = path.join(SESSIONS_DIR, `${sessionId}.meta.json`); await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), "utf-8"); return true; } catch (err) { console.error(`Failed to save metadata for ${sessionId}:`, err); return false; } } async function listSessions() { try { await ensureSessionsDir(); const files = await fs.readdir(SESSIONS_DIR); const sessions = []; for (const file of files) { if (file.endsWith(".json") && !file.endsWith(".meta.json")) { const sessionId = file.replace(".json", ""); const sessionPath = path.join(SESSIONS_DIR, file); const stats = await fs.stat(sessionPath); // Try to read the session to get message count let messageCount = 0; try { const data = await fs.readFile(sessionPath, "utf-8"); const history = JSON.parse(data); messageCount = history.length; } catch (e) { // Invalid JSON, skip } // Load metadata (name) const metadata = await loadSessionMetadata(sessionId); sessions.push({ id: sessionId, name: metadata.name || sessionId, lastModified: stats.mtime, messageCount }); } } // Sort by last modified (newest first) sessions.sort((a, b) => b.lastModified - a.lastModified); return sessions; } catch (err) { console.error("Failed to list sessions:", err); return []; } } async function deleteSession(sessionId) { try { const sessionPath = path.join(SESSIONS_DIR, `${sessionId}.json`); const metadataPath = path.join(SESSIONS_DIR, `${sessionId}.meta.json`); // Delete session file await fs.unlink(sessionPath); // Delete metadata file (if exists) try { await fs.unlink(metadataPath); } catch (e) { // Metadata file doesn't exist, that's ok } return true; } catch (err) { console.error(`Failed to delete session ${sessionId}:`, err); return false; } } // GET /sessions - List all sessions app.get("/sessions", async (req, res) => { const sessions = await listSessions(); res.json(sessions); }); // GET /sessions/:id - Get specific session history app.get("/sessions/:id", async (req, res) => { const sessionId = req.params.id; const history = await loadSession(sessionId); res.json(history); }); // POST /sessions/:id - Save session history app.post("/sessions/:id", async (req, res) => { const sessionId = req.params.id; const history = req.body; const success = await saveSession(sessionId, history); if (success) { res.json({ ok: true, saved: history.length }); } else { res.status(500).json({ error: "Failed to save session" }); } }); // PATCH /sessions/:id/metadata - Update session metadata (name, etc.) app.patch("/sessions/:id/metadata", async (req, res) => { const sessionId = req.params.id; const metadata = req.body; const success = await saveSessionMetadata(sessionId, metadata); if (success) { res.json({ ok: true, metadata }); } else { res.status(500).json({ error: "Failed to update metadata" }); } }); // DELETE /sessions/:id - Delete a session app.delete("/sessions/:id", async (req, res) => { const sessionId = req.params.id; const success = await deleteSession(sessionId); if (success) { res.json({ ok: true, deleted: sessionId }); } else { res.status(500).json({ error: "Failed to delete session" }); } }); // ----------------------------------------------------- app.listen(PORT, () => { console.log(`Relay is online on port ${PORT}`); });