]> acesimba.cloud Git - StationBuilder.git/commitdiff
feat: 生成页新增只读实时终端「AI 生成工作流」并修复代理缓冲导致的过程不可见
authorwang.yu <wang.yu@advantech.com>
Mon, 17 Aug 2026 08:29:46 +0000 (16:29 +0800)
committerwang.yu <wang.yu@advantech.com>
Mon, 17 Aug 2026 08:29:46 +0000 (16:29 +0800)
- 新增 DB 代理流式透传(原 Buffer 整段缓存使生成过程在完成后才一次性返回)
- db-sidecar 将 codebuddy CLI 实时输出(text_delta)与 Skill 调用标记以 {log} 事件转发
- Generate 组件新增只读终端面板,节流刷新 + 自动滚动,点开始生成时清空
- globals.css 新增 .gen-right/.console-panel/.gen-console 终端样式
- 面板标题定为「AI 生成工作流」

app/api/db/[[...slug]]/route.ts
app/globals.css
app/page.tsx
db-sidecar.mjs

index 36bf5469132f1e6bb96e54c65dfd956bb45bb7c5..f8fb50176dcaf282faaa352d107577af45276046 100644 (file)
@@ -27,18 +27,31 @@ async function proxy(req: NextRequest, { params }: { params: Promise<{ slug?: st
     if (contentType) responseHeaders.set("Content-Type", contentType);
     const cd = upstream.headers.get("content-disposition");
     if (cd) responseHeaders.set("Content-Disposition", cd);
+    // 关闭代理层缓冲,让生成过程的实时进度 / 日志能逐行流式回传浏览器
+    responseHeaders.set("Cache-Control", "no-cache, no-transform");
+    responseHeaders.set("X-Accel-Buffering", "no");
 
-    // 完整读取上游响应体后再返回:避免「向已关闭的响应流式回传」阶段出错。
-    const buf = Buffer.from(await upstream.arrayBuffer());
-
-    // 客户端已断开则直接返回空响应,避免向已关闭的连接回写。
-    if (req.signal.aborted) return new Response(null, { status: 499, statusText: "Client Closed Request" });
-
-    return new Response(buf, {
-      status: upstream.status,
-      statusText: upstream.statusText,
-      headers: responseHeaders,
+    const src = upstream.body;
+    if (!src) {
+      return new Response(null, { status: upstream.status, statusText: upstream.statusText, headers: responseHeaders });
+    }
+    // 透传上游流;客户端断开(如 StrictMode 双挂载取消请求)导致上游中断时静默收尾,
+    // 避免未处理的 terminated 异常污染 Vite 错误遮罩。
+    const stream = new ReadableStream({
+      start(controller) {
+        const reader = src.getReader();
+        const pump = () => reader.read().then(
+          ({ done, value }) => {
+            if (done) { try { controller.close(); } catch {} return; }
+            try { controller.enqueue(value); pump(); } catch { /* 客户端已断开,停止回写 */ }
+          },
+          () => { try { controller.close(); } catch {} }, // 上游中断:静默关闭
+        );
+        pump();
+      },
+      cancel() { /* 浏览器主动取消,忽略 */ },
     });
+    return new Response(stream, { status: upstream.status, statusText: upstream.statusText, headers: responseHeaders });
   } catch (e) {
     console.error("[proxy error]", e);
     return new Response(JSON.stringify({ error: String((e as { message?: string })?.message || e) }), {
index 0892528ab6f34ebe33f0dd5e52bb68b19efb6d6d..63cb841091668857f30617b125535a457154773a 100644 (file)
 .style-pick-thumb img{width:100%;height:100%;object-fit:contain}
 .style-pick-thumb.none{color:#aaa;font-size:13px}
 .style-pick span{font-size:11px;line-height:1.3}
+/* 生成过程只读终端 */
+.gen-right{display:flex;flex-direction:column;gap:14px}
+.console-panel .panel-body{padding:0}
+.gen-console{height:300px;max-height:42vh;overflow-y:auto;background:#0f1722;color:#cfe3f0;font:12px/1.5 "SFMono-Regular",Consolas,"Liberation Mono",Menlo,monospace;padding:10px 12px}
+.gen-console pre{margin:0;white-space:pre-wrap;word-break:break-word}
+.gen-console pre:empty:before{content:""}
 
index 6b15187c3c8a3765d0413b1ff1833d90366fae3d..37c45442dad0de198a01de9505542b2c91d1d334 100644 (file)
@@ -625,7 +625,14 @@ function Generate({site,onSiteChange,notify,viewPrompt,viewSitePrompt}:{site:Sit
   const [tasks,setTasks]=useState<{name:string;status:"pending"|"running"|"done"|"failed";message?:string}[]>([]);
   const [progress,setProgress]=useState(0);
   const [zipUrl,setZipUrl]=useState("");
+  const [log,setLog]=useState("");
+  const logBuf=useRef("");
+  const logTimer=useRef<number|null>(null);
+  const consoleRef=useRef<HTMLDivElement>(null);
   const totalRef = useRef(0);
+  const appendLog=(s:string)=>{ logBuf.current+=s; if(logTimer.current==null){ logTimer.current=window.setTimeout(()=>{ logTimer.current=null; setLog(logBuf.current); },120); } };
+  const resetLog=()=>{ if(logTimer.current!=null){ clearTimeout(logTimer.current); logTimer.current=null; } logBuf.current=""; setLog(""); };
+  useEffect(()=>{ if(consoleRef.current) consoleRef.current.scrollTop=consoleRef.current.scrollHeight; },[log]);
   if(!site) return <Panel><div className="empty-state">加载中…</div></Panel>;
   const scope=[
     ["站点",site.name||"未命名站点",site.domain],
@@ -634,7 +641,7 @@ function Generate({site,onSiteChange,notify,viewPrompt,viewSitePrompt}:{site:Sit
     ["Cube 页面",`${site.cubes.length} 个扩展页面`,`生成 ${site.cubes.length} 个独立 JSON`],
   ];
   const start=async()=>{
-    setRunning(true); setZipUrl("");
+    setRunning(true); setZipUrl(""); resetLog();
     const initial = [{name:"模型准备",status:"pending" as const}, ...site.cubes.map(c=>({name:c.name,status:"pending" as const})), {name:"打包与检查校验",status:"pending" as const}];
     totalRef.current = initial.length;
     setTasks(initial); setProgress(0);
@@ -650,6 +657,7 @@ function Generate({site,onSiteChange,notify,viewPrompt,viewSitePrompt}:{site:Sit
           if(!line.trim()) continue;
           try{
             const ev = JSON.parse(line);
+            if(ev.log){ appendLog(ev.log); }
             if(ev.task){
               setTasks(prev=>{
                 const idx = prev.findIndex(t=>t.name===ev.task);
@@ -678,7 +686,7 @@ function Generate({site,onSiteChange,notify,viewPrompt,viewSitePrompt}:{site:Sit
       {label:"生成页面预览", disabled:false},
       {label:"执行 Cube JSON 校验", disabled:false},
     ].map(x=><label key={x.label} className={x.disabled?"disabled":""}><input type="checkbox" defaultChecked={!x.disabled} disabled={x.disabled}/><span>{x.label}{x.note&&<small>{x.note}</small>}</span></label>)}</div></Panel></div>
-    <Panel title="生成任务" className="task-panel"><div className="overall"><div><b>{progress===100?"生成完成":running?"正在生成站点方案":"准备就绪"}</b><span>{progress}%</span></div><div className="meter"><i style={{width:progress+"%"}}/></div><p>{progress===100?"全部交付物已生成并通过校验。":running?"Wordbuddy Hy3 正在调用 Cube Skill 创建页面与模拟数据。":"Wordbuddy Hy3 已就绪,预计需要约 2 分钟。"}</p></div><div className="task-list">{tasks.map((x,i)=><div key={i}><i className={x.status==="done"?"done":x.status==="running"?"spin":x.status==="failed"?"bad":""}>{x.status==="done"?"✓":x.status==="running"?"↻":x.status==="failed"?"!":"·"}</i><span><b>{x.name}</b>{x.message&&<small>{x.message}</small>}</span><Status>{x.status==="done"?"成功":x.status==="running"?"生成中":x.status==="failed"?"失败":"等待"}</Status></div>)}</div><Button disabled={running} onClick={start}>{progress===100?"重新生成":running?"正在生成…":"开始生成"}</Button>{zipUrl&&<Button secondary onClick={()=>window.open(zipUrl,"_blank")}>⇩ 下载 ZIP</Button>}</Panel></div>;
+    <div className="gen-right"><Panel title="生成任务" className="task-panel"><div className="overall"><div><b>{progress===100?"生成完成":running?"正在生成站点方案":"准备就绪"}</b><span>{progress}%</span></div><div className="meter"><i style={{width:progress+"%"}}/></div><p>{progress===100?"全部交付物已生成并通过校验。":running?"Wordbuddy Hy3 正在调用 Cube Skill 创建页面与模拟数据。":"Wordbuddy Hy3 已就绪,预计需要约 2 分钟。"}</p></div><div className="task-list">{tasks.map((x,i)=><div key={i}><i className={x.status==="done"?"done":x.status==="running"?"spin":x.status==="failed"?"bad":""}>{x.status==="done"?"✓":x.status==="running"?"↻":x.status==="failed"?"!":"·"}</i><span><b>{x.name}</b>{x.message&&<small>{x.message}</small>}</span><Status>{x.status==="done"?"成功":x.status==="running"?"生成中":x.status==="failed"?"失败":"等待"}</Status></div>)}</div><Button disabled={running} onClick={start}>{progress===100?"重新生成":running?"正在生成…":"开始生成"}</Button>{zipUrl&&<Button secondary onClick={()=>window.open(zipUrl,"_blank")}>⇩ 下载 ZIP</Button>}</Panel><Panel title="AI 生成工作流" className="console-panel"><div className="gen-console" ref={consoleRef}><pre>{log || "(等待生成开始…)"}</pre></div></Panel></div></div>;
 }
 
 // 缩略图:拉取 cube 生成的 dashboard JSON,用公开渲染器按 1920×1080 渲染后缩放显示
index ee94037767bedbb90d25c6654e550472ad29666e..5f608afea5569fe50388728a60b5c5853e9a0389 100644 (file)
@@ -344,7 +344,7 @@ function validateDashboard(json) {
   return errors;
 }
 
-function runCodebuddy(prompt, sessionId) {
+function runCodebuddy(prompt, sessionId, onEvent) {
   return new Promise((resolve, reject) => {
     const args = [
       "--model", "hy3",
@@ -369,6 +369,7 @@ function runCodebuddy(prompt, sessionId) {
         const o = JSON.parse(line);
         if (o.type === "stream_event" && o.event?.type === "content_block_delta" && o.event.delta?.type === "text_delta" && o.event.delta.text) {
           fullText += o.event.delta.text;
+          if (typeof onEvent === "function") onEvent(o);
         }
       } catch {}
     };
@@ -536,20 +537,20 @@ function extractColorPalette(style) {
   scan(style);
   return [...colors.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12).map(e => e[0]);
 }
-async function generateCubeJson(site, cube, attempt = 1, previousErrors = "") {
+async function generateCubeJson(site, cube, attempt = 1, previousErrors = "", onEvent) {
   const promptBase = buildCubePrompt(site, cube);
   const prompt = previousErrors
     ? `The previous output failed validation:\n${previousErrors}\n\n${promptBase}\n\nPlease fix the errors and output ONLY the corrected raw dashboard JSON.`
     : promptBase;
   const sessionId = `stationbuilder-gen-${site.id}-${cube.id || 0}-${attempt}`;
-  const text = await runCodebuddy(prompt, sessionId);
+  const text = await runCodebuddy(prompt, sessionId, onEvent);
   let json;
   try { json = await extractJson(text); }
   catch (e) { throw new Error("JSON parse failed: " + e.message); }
   const errors = validateDashboard(json);
   if (errors.length > 0) {
     if (attempt >= 3) throw new Error("Validation failed after 3 attempts: " + errors.join("; "));
-    return generateCubeJson(site, cube, attempt + 1, errors.join("\n"));
+    return generateCubeJson(site, cube, attempt + 1, errors.join("\n"), onEvent);
   }
   return { json, prompt: promptBase };
 }
@@ -757,11 +758,19 @@ async function handleGenerateAll(req, res, siteId) {
       }
     }
     const cubes = db.prepare("SELECT * FROM cubes WHERE site_id = ? ORDER BY sort_order,id").all(siteId);
+    // 实时转发模型输出(text_delta)作为只读过程日志
+    const onEvent = (o) => {
+      if (o && o.type === "stream_event" && o.event?.type === "content_block_delta" && o.event.delta?.type === "text_delta" && o.event.delta.text) {
+        streamEvent(res, { log: o.event.delta.text });
+      }
+    };
+    streamEvent(res, { log: `\n▶ 开始站点生成:调用 Wordbuddy AI(Hy3)与 dashboard-json-generator Skill 生成 ${cubes.length} 个 Cube 页面…\n` });
     streamEvent(res, { task: "模型准备", status: "done" });
     for (const cube of cubes) {
       streamEvent(res, { task: cube.name, status: "running" });
+      streamEvent(res, { log: `\n▶ 调用 Cube Skill(dashboard-json-generator)生成「${cube.name}」…\n` });
       try {
-        const { json, prompt } = await generateCubeJson(site, cube);
+        const { json, prompt } = await generateCubeJson(site, cube, 1, "", onEvent);
         const filename = `${safeFilename(cube.name)}.json`;
         const jsonText = JSON.stringify(json, null, 2);
         if (cube.gen_file_id) {
@@ -771,12 +780,15 @@ async function handleGenerateAll(req, res, siteId) {
           db.prepare("UPDATE cubes SET gen_file_id=?, status='generated' WHERE id=?").run(info.lastInsertRowid, cube.id);
         }
         db.prepare("UPDATE cubes SET status='generated', prompt=? WHERE id=?").run(prompt, cube.id);
+        streamEvent(res, { log: `✓ 「${cube.name}」生成完成\n` });
         streamEvent(res, { task: cube.name, status: "done" });
       } catch (e) {
         db.prepare("UPDATE cubes SET status='failed' WHERE id=?").run(cube.id);
+        streamEvent(res, { log: `✗ 「${cube.name}」生成失败:${e.message}\n` });
         streamEvent(res, { task: cube.name, status: "failed", message: e.message });
       }
     }
+    streamEvent(res, { log: "\n▶ 正在打包与校验交付物…\n" });
     streamEvent(res, { task: "打包与检查校验", status: "running" });
     const zipPath = await createSiteZip(siteId);
     db.prepare("UPDATE sites SET status='generated', updated_at=CURRENT_TIMESTAMP WHERE id=?").run(siteId);