418 lines
12 KiB
JavaScript
418 lines
12 KiB
JavaScript
#!/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, analyzeSceneDeep } = 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 handleAnalyzeDeep(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 indexHtmlContent = dirContents.indexHtml || null;
|
|
const result = await analyzeSceneDeep(sourceDir, dirContents, indexHtmlContent, config);
|
|
|
|
// Log extraction results for debugging
|
|
console.log(`[analyze-deep] Extracted scene: ${result.sceneId} / ${result.sceneName}`);
|
|
console.log(`[analyze-deep] API endpoints: ${result.apiEndpoints?.length || 0}`);
|
|
console.log(`[analyze-deep] Column defs: ${result.columnDefs?.length || 0}`);
|
|
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify(result));
|
|
} catch (err) {
|
|
console.error(`[analyze-deep] Error: ${err.message}`);
|
|
res.writeHead(502, { "Content-Type": "application/json" });
|
|
res.end(
|
|
JSON.stringify({
|
|
error: `Deep analysis failed: ${err.message}`,
|
|
hint: "You can still use basic analysis or enter data 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) {
|
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
res.end(
|
|
JSON.stringify({
|
|
error:
|
|
"All fields required: sourceDir, sceneId, sceneName, outputRoot",
|
|
})
|
|
);
|
|
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 = `
|
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
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,
|
|
], {
|
|
windowsHide: true,
|
|
});
|
|
|
|
let output = "";
|
|
let error = "";
|
|
|
|
ps.stdout.on("data", (data) => {
|
|
output += data.toString("utf8");
|
|
});
|
|
|
|
ps.stderr.on("data", (data) => {
|
|
error += data.toString("utf8");
|
|
});
|
|
|
|
ps.on("close", (code) => {
|
|
if (code === 0 && output.trim()) {
|
|
// 移除可能的 BOM 标记
|
|
let path = output.trim();
|
|
if (path.charCodeAt(0) === 0xFEFF) {
|
|
path = path.slice(1);
|
|
}
|
|
resolve(path);
|
|
} 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 = `
|
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
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,
|
|
], {
|
|
windowsHide: true,
|
|
});
|
|
|
|
let output = "";
|
|
|
|
ps.stdout.on("data", (data) => {
|
|
output += data.toString("utf8");
|
|
});
|
|
|
|
ps.on("close", (code) => {
|
|
let path = output.trim();
|
|
if (path.charCodeAt(0) === 0xFEFF) {
|
|
path = path.slice(1);
|
|
}
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ path: code === 0 && path ? path : 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 === "/analyze-deep" && req.method === "POST") {
|
|
await handleAnalyzeDeep(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", () => {
|
|
if (server.closing) return;
|
|
server.closing = true;
|
|
console.log("\n[info] Shutting down...");
|
|
server.close(() => process.exit(0));
|
|
// 强制退出超时
|
|
setTimeout(() => process.exit(0), 2000);
|
|
});
|