#!/usr/bin/env node "use strict"; const http = require("http"); const fs = require("fs"); const path = require("path"); const { spawn } = require("child_process"); 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, sceneKind, targetUrl, 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, sceneKind, targetUrl, 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, }) ); } /** * Open a native Windows folder selection dialog using PowerShell. * Returns the selected folder path or null if cancelled. */ function openFolderDialog(defaultPath) { return new Promise((resolve) => { const psScript = ` Add-Type -AssemblyName System.Windows.Forms $dialog = New-Object System.Windows.Forms.FolderBrowserDialog $dialog.Description = "选择文件夹" $dialog.ShowNewFolderButton = true ${defaultPath ? `$dialog.SelectedPath = '${defaultPath.replace(/'/g, "''")}'` : ""} if ($dialog.ShowDialog() -eq 'OK') { Write-Output $dialog.SelectedPath } `.trim(); const ps = spawn("powershell.exe", [ "-NoProfile", "-NonInteractive", "-Command", psScript, ]); let output = ""; let error = ""; ps.stdout.on("data", (data) => { output += data.toString(); }); ps.stderr.on("data", (data) => { error += data.toString(); }); ps.on("close", (code) => { if (code === 0 && output.trim()) { resolve(output.trim()); } else { resolve(null); } }); ps.on("error", () => { resolve(null); }); }); } async function handleSelectFolder(req, res) { let body = {}; try { body = await parseBody(req); } catch { // ignore parse error, use empty body } const selectedPath = await openFolderDialog(body.defaultPath || ""); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ path: selectedPath })); } async function handleSelectFile(req, res) { let body = {}; try { body = await parseBody(req); } catch { // ignore parse error } const filter = body.filter || "所有文件 (*.*)|*.*"; const psScript = ` Add-Type -AssemblyName System.Windows.Forms $dialog = New-Object System.Windows.Forms.OpenFileDialog $dialog.Filter = '${filter}' $dialog.Title = "选择文件" ${body.defaultPath ? `$dialog.InitialDirectory = '${body.defaultPath.replace(/'/g, "''")}'` : ""} if ($dialog.ShowDialog() -eq 'OK') { Write-Output $dialog.FileName } `.trim(); return new Promise((resolve) => { const ps = spawn("powershell.exe", [ "-NoProfile", "-NonInteractive", "-Command", psScript, ]); let output = ""; ps.stdout.on("data", (data) => { output += data.toString(); }); ps.on("close", (code) => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ path: code === 0 && output.trim() ? output.trim() : null })); resolve(); }); ps.on("error", () => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ path: null })); resolve(); }); }); } 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 === "/select-folder" && req.method === "POST") { await handleSelectFolder(req, res); } else if (pathname === "/select-file" && req.method === "POST") { await handleSelectFile(req, res); } else if (pathname === "/" || pathname === "/index.html") { serveStatic(res, path.join(__dirname, "sg_scene_generator.html")); } else { const filePath = path.resolve(__dirname, pathname); const resolvedDir = path.resolve(__dirname); if (!filePath.startsWith(resolvedDir + path.sep) && filePath !== resolvedDir) { res.writeHead(403, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Forbidden" })); return; } 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)); });