feat: add server.js with /health, /analyze, /generate routes

This commit is contained in:
木炎
2026-04-16 22:18:32 +08:00
parent 15d4b0dcc1
commit e7a4179513

View File

@@ -0,0 +1,226 @@
#!/usr/bin/env node
"use strict";
const http = require("http");
const fs = require("fs");
const path = require("path");
const { loadConfig, getDefaults } = require("./config-loader");
const { analyzeScene } = require("./llm-client");
const { runGenerator, readDirectory } = require("./generator-runner");
let config;
let defaults;
try {
config = loadConfig();
defaults = getDefaults();
console.log(`[config] Loaded from: ${config.configPath}`);
console.log(`[config] Project root: ${config.projectRoot}`);
} catch (err) {
console.error(`[error] Failed to load config: ${err.message}`);
process.exit(1);
}
const PORT = parseInt(process.env.SG_SCENE_GENERATOR_PORT, 10) || 3210;
const HOST = "127.0.0.1";
const MIME_TYPES = {
".html": "text/html; charset=utf-8",
".js": "application/javascript",
".css": "text/css",
".json": "application/json",
};
function serveStatic(res, filePath) {
const ext = path.extname(filePath);
const contentType = MIME_TYPES[ext] || "application/octet-stream";
fs.readFile(filePath, (err, data) => {
if (err) {
res.writeHead(404);
res.end("Not found");
return;
}
res.writeHead(200, { "Content-Type": contentType });
res.end(data);
});
}
function initSSE(res) {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"Access-Control-Allow-Origin": "*",
});
res.write(":\n");
return res;
}
function writeSSE(res, event, data) {
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
}
function parseBody(req) {
return new Promise((resolve, reject) => {
let body = "";
req.on("data", (chunk) => (body += chunk));
req.on("end", () => {
try {
resolve(JSON.parse(body));
} catch (err) {
reject(new Error("Invalid JSON"));
}
});
req.on("error", reject);
});
}
async function handleAnalyze(req, res) {
let body;
try {
body = await parseBody(req);
} catch {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Invalid JSON body" }));
return;
}
const sourceDir = (body.sourceDir || "").replace(/\\/g, "/");
if (!sourceDir) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "sourceDir is required" }));
return;
}
let dirContents;
try {
dirContents = readDirectory(sourceDir);
} catch (err) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: err.message }));
return;
}
try {
const result = await analyzeScene(sourceDir, dirContents, config);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(result));
} catch (err) {
res.writeHead(502, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
error: `LLM analysis failed: ${err.message}`,
hint: "You can still enter scene-id and scene-name manually",
})
);
}
}
async function handleGenerate(req, res) {
let body;
try {
body = await parseBody(req);
} catch {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Invalid JSON body" }));
return;
}
const { sourceDir, sceneId, sceneName, outputRoot, lessons } = body;
if (!sourceDir || !sceneId || !sceneName || !outputRoot || !lessons) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
error:
"All fields required: sourceDir, sceneId, sceneName, outputRoot, lessons",
})
);
return;
}
const sseWriter = initSSE(res);
try {
await runGenerator(
{ sourceDir, sceneId, sceneName, outputRoot, lessons },
sseWriter,
config.projectRoot
);
} catch (err) {
writeSSE(sseWriter, "error", { message: `Server error: ${err.message}` });
}
sseWriter.end();
}
function handleHealth(req, res) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
status: "ok",
pid: process.pid,
configLoaded: true,
configPath: config.configPath,
projectRoot: config.projectRoot,
})
);
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const pathname = url.pathname;
if (req.method === "OPTIONS") {
res.writeHead(204, {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
});
res.end();
return;
}
try {
if (pathname === "/health" && req.method === "GET") {
handleHealth(req, res);
} else if (pathname === "/analyze" && req.method === "POST") {
await handleAnalyze(req, res);
} else if (pathname === "/generate" && req.method === "POST") {
await handleGenerate(req, res);
} else if (pathname === "/" || pathname === "/index.html") {
serveStatic(res, path.join(__dirname, "sg_scene_generator.html"));
} else {
const filePath = path.join(__dirname, pathname);
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
serveStatic(res, filePath);
} else {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Not found" }));
}
}
} catch (err) {
console.error(`[error] ${req.method} ${pathname}: ${err.message}`);
if (!res.headersSent) {
res.writeHead(500, { "Content-Type": "application/json" });
}
res.end(JSON.stringify({ error: err.message }));
}
});
server.listen(PORT, HOST, () => {
console.log("");
console.log(" ╔══════════════════════════════════════════════════╗");
console.log(" ║ sgClaw · Scene Skill Generator ║");
console.log(" ╠══════════════════════════════════════════════════╣");
console.log(" ║ ║");
console.log(` ║ 访问地址: http://${HOST}:${PORT}/ ║`);
console.log(" ║ ║");
console.log(" ║ 按 Ctrl+C 停止服务 ║");
console.log(" ╚══════════════════════════════════════════════════╝");
console.log("");
});
process.on("SIGINT", () => {
console.log("\n[info] Shutting down...");
server.close(() => process.exit(0));
});