366 lines
10 KiB
JavaScript
366 lines
10 KiB
JavaScript
// 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}`);
|
|
});
|