Generation Log
实时日志
-显示分析和生成过程的完整输出
+显示生成过程的完整输出
diff --git a/frontend/scene-generator/generator-runner.js b/frontend/scene-generator/generator-runner.js index ec60597..91f8165 100644 --- a/frontend/scene-generator/generator-runner.js +++ b/frontend/scene-generator/generator-runner.js @@ -2,7 +2,7 @@ const { spawn } = require("child_process"); const path = require("path"); function runGenerator(params, sseWriter, projectRoot) { - const { sourceDir, sceneId, sceneName, sceneKind, outputRoot, lessons } = params; + const { sourceDir, sceneId, sceneName, sceneKind, targetUrl, outputRoot, lessons } = params; const normalize = (p) => p.replace(/\\/g, "/"); @@ -24,6 +24,11 @@ function runGenerator(params, sseWriter, projectRoot) { args.push("--scene-kind", sceneKind); } + // 如果提供了 targetUrl,添加参数 + if (targetUrl) { + args.push("--target-url", targetUrl); + } + args.push( "--output-root", normalize(outputRoot), diff --git a/frontend/scene-generator/server.js b/frontend/scene-generator/server.js index 2f57fab..887b096 100644 --- a/frontend/scene-generator/server.js +++ b/frontend/scene-generator/server.js @@ -4,6 +4,7 @@ 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"); @@ -126,7 +127,7 @@ async function handleGenerate(req, res) { return; } - const { sourceDir, sceneId, sceneName, sceneKind, outputRoot, lessons } = body; + const { sourceDir, sceneId, sceneName, sceneKind, targetUrl, outputRoot, lessons } = body; if (!sourceDir || !sceneId || !sceneName || !outputRoot || !lessons) { res.writeHead(400, { "Content-Type": "application/json" }); res.end( @@ -142,7 +143,7 @@ async function handleGenerate(req, res) { try { await runGenerator( - { sourceDir, sceneId, sceneName, sceneKind, outputRoot, lessons }, + { sourceDir, sceneId, sceneName, sceneKind, targetUrl, outputRoot, lessons }, sseWriter, config.projectRoot ); @@ -166,6 +167,116 @@ function handleHealth(req, res) { ); } +/** + * 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; @@ -187,6 +298,10 @@ const server = http.createServer(async (req, res) => { 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 { diff --git a/frontend/scene-generator/sg_scene_generator.html b/frontend/scene-generator/sg_scene_generator.html index bb1495e..37d4e1d 100644 --- a/frontend/scene-generator/sg_scene_generator.html +++ b/frontend/scene-generator/sg_scene_generator.html @@ -48,15 +48,15 @@ } .hero h1 { margin: 0; font-size: clamp(1.8rem, 4vw, 2.6rem); line-height: 1.05; letter-spacing: 0.02em; } .hero p { margin: 10px 0 0; max-width: 60ch; color: var(--muted); line-height: 1.6; } - .content { display: grid; grid-template-columns: minmax(280px, 320px) minmax(0, 1fr); gap: 0; } + .content { display: grid; grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); gap: 0; } .sidebar, .stream-panel { padding: 24px; } .sidebar { border-right: 1px solid var(--line); background: rgba(255, 255, 255, 0.38); } .section-label { margin: 0 0 14px; font-size: 0.83rem; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--muted); } - .field { margin-bottom: 18px; } - .field label { display: block; margin-bottom: 8px; font-size: 0.92rem; color: var(--muted); } + .field { margin-bottom: 16px; } + .field label { display: block; margin-bottom: 6px; font-size: 0.92rem; color: var(--muted); } .input-row { display: flex; gap: 8px; } .input-row input { flex: 1; } - .input-row button { width: auto; min-width: 80px; } + .input-row .browse-btn { width: auto; min-width: 60px; padding: 10px 14px; font-size: 0.85rem; } input, button { width: 100%; border: 1px solid var(--line); @@ -66,7 +66,7 @@ input { background: rgba(255, 255, 255, 0.92); color: var(--text); - padding: 14px 16px; + padding: 12px 14px; outline: none; transition: border-color 140ms ease, box-shadow 140ms ease; } @@ -77,26 +77,26 @@ border-radius: 16px; background: rgba(255, 255, 255, 0.92); color: var(--text); - padding: 14px 16px; + padding: 12px 14px; font: inherit; outline: none; cursor: pointer; transition: border-color 140ms ease, box-shadow 140ms ease; } select:focus { border-color: rgba(15, 118, 110, 0.5); box-shadow: 0 0 0 4px rgba(15, 118, 110, 0.12); } - button { border: 0; padding: 14px 16px; font-weight: 700; cursor: pointer; transition: transform 140ms ease, opacity 140ms ease; } + button { border: 0; padding: 12px 14px; font-weight: 700; cursor: pointer; transition: transform 140ms ease, opacity 140ms ease; } button:hover:not(:disabled) { transform: translateY(-1px); } button:disabled { cursor: not-allowed; opacity: 0.45; } .primary-btn { background: linear-gradient(135deg, var(--accent), var(--accent-strong)); color: #f6fffd; box-shadow: 0 14px 28px rgba(15, 118, 110, 0.18); } .ghost-btn { background: rgba(255, 255, 255, 0.9); color: var(--text); border: 1px solid var(--line); } - .status-card { display: grid; gap: 8px; padding: 16px; border-radius: 20px; background: var(--panel-strong); border: 1px solid var(--line); margin-bottom: 18px; } - .state-chip { display: inline-flex; align-items: center; width: fit-content; padding: 6px 10px; border-radius: 999px; font-size: 0.85rem; font-weight: 700; background: rgba(99, 107, 116, 0.12); color: var(--muted); } + .status-card { display: grid; gap: 8px; padding: 14px; border-radius: 20px; background: var(--panel-strong); border: 1px solid var(--line); margin-bottom: 16px; } + .state-chip { display: inline-flex; align-items: center; width: fit-content; padding: 5px 10px; border-radius: 999px; font-size: 0.85rem; font-weight: 700; background: rgba(99, 107, 116, 0.12); color: var(--muted); } .state-chip[data-state="ready"] { background: rgba(99, 107, 116, 0.12); color: var(--muted); } .state-chip[data-state="analyzing"] { background: rgba(180, 83, 9, 0.12); color: var(--warn); } .state-chip[data-state="generating"] { background: rgba(15, 118, 110, 0.12); color: var(--accent); } .state-chip[data-state="complete"] { background: rgba(22, 101, 52, 0.12); color: var(--success); } .state-chip[data-state="error"] { background: rgba(180, 35, 24, 0.12); color: var(--error); } - .validation { min-height: 1.4em; margin: 10px 0 14px; color: var(--error); font-size: 0.92rem; } + .validation { min-height: 1.4em; margin: 8px 0 12px; color: var(--error); font-size: 0.92rem; } .stream-panel { display: grid; grid-template-rows: auto minmax(320px, 1fr); gap: 18px; } .stream-head { display: flex; justify-content: space-between; align-items: end; gap: 16px; } .stream-head h2 { margin: 0; font-size: 1.35rem; } @@ -111,7 +111,8 @@ .row.error .row-badge { background: rgba(180, 35, 24, 0.14); color: var(--error); } .row-text { margin: 0; line-height: 1.6; white-space: pre-wrap; word-break: break-word; } @keyframes rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } } - .hint { font-size: 0.82rem; color: var(--muted); margin-top: 4px; } + .hint { font-size: 0.8rem; color: var(--muted); margin-top: 4px; } + .divider { height: 1px; background: var(--line); margin: 12px 0; } @media (max-width: 900px) { body { padding: 16px; } .content { grid-template-columns: 1fr; } .sidebar { border-right: 0; border-bottom: 1px solid var(--line); } .stream { max-height: none; } } @@ -119,23 +120,22 @@
输入场景目录路径,自动提取 scene-id 和 scene-name,一键生成 skill 包并实时查看进度。
+选择场景目录,配置参数,一键生成 skill 包。
Generation Log
显示分析和生成过程的完整输出
+显示生成过程的完整输出