]> acesimba.cloud Git - StationBuilder.git/commitdiff
feat: 修改意见重新生成时展示「AI 生成工作流」实时进度
authorwang.yu <wang.yu@advantech.com>
Mon, 17 Aug 2026 09:14:50 +0000 (17:14 +0800)
committerwang.yu <wang.yu@advantech.com>
Mon, 17 Aug 2026 09:14:50 +0000 (17:14 +0800)
- db-sidecar: handleRefineCube 改为 NDJSON 流式返回,refineCubeJson 透传 onEvent,发射 log 进度与 task 状态
- 前端 refineCube 改为读取流并累积 refineLog;反馈弹窗「修改意见」下方新增黑底白字只读终端,自动滚动
- 设计文档同步更新(5.7 修改意见 / 8 反馈模态)

app/globals.css
app/page.tsx
db-sidecar.mjs
public/design-doc.html

index 58e3d0f2165dfef1e2b97d2cda202e6868d865fc..2ae63a3719b96de58ab6d0617acb5678556dd199 100644 (file)
 .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:""}
+.modal-console{margin-top:12px;border:1px solid #1c2a38;border-radius:4px;overflow:hidden}
+.modal-console-head{background:#0f1722;color:#9fd0e6;font-size:12px;padding:6px 10px;border-bottom:1px solid #1c2a38}
+.modal-console-body{background:#0f1722;color:#cfe3f0;font:12px/1.5 "SFMono-Regular",Consolas,"Liberation Mono",Menlo,monospace;margin:0;padding:10px 12px;max-height:240px;overflow:auto;white-space:pre-wrap;word-break:break-word}
 
index 41562ff39e2055124c04e8c6a900c05dcf29e940..1ed89431e1b0ea3120ce12d015e33cc7dbfa41d4 100644 (file)
@@ -171,6 +171,7 @@ export default function Home(){
   const [previewTab,setPreviewTab]=useState<"info"|"json"|"feedback">("info");
   const [feedbackCube,setFeedbackCube]=useState<Cube|null>(null);
   const [refining,setRefining]=useState(false);
+  const [refineLog,setRefineLog]=useState("");
 
   const notify=(m:string)=>{setToast(m);window.setTimeout(()=>setToast(""),2200);};
 
@@ -198,18 +199,34 @@ export default function Home(){
   const openFeedback=(cube:Cube)=>{
     if(!cube.id){ notify("请先生成该页面再提出修改意见"); return; }
     setFeedbackCube(cube);
+    setRefineLog("");
     setModal("feedback");
   };
   const refineCube=async(cube:Cube, feedback:string)=>{
     if(!site?.id || !cube.id) return;
     if(!feedback.trim()){ notify("请填写修改意见"); return; }
     setRefining(true);
+    setRefineLog("");
     try{
-      await fetchDb(`/sites/${site.id}/cubes/${cube.id}/refine`, {
+      const res = await fetch(`/api/db/sites/${site.id}/cubes/${cube.id}/refine`, {
         method:"POST",
         headers:{"Content-Type":"application/json"},
         body:JSON.stringify({feedback}),
       });
+      if(!res.ok||!res.body) throw new Error("HTTP "+res.status);
+      const reader = res.body.getReader(); const dec = new TextDecoder(); let buf="";
+      for(;;){
+        const {done,value}=await reader.read(); if(done) break;
+        buf += dec.decode(value,{stream:true});
+        const lines = buf.split("\n"); buf = lines.pop()||"";
+        for(const line of lines){
+          if(!line.trim()) continue;
+          try{
+            const ev = JSON.parse(line);
+            if(ev.log) setRefineLog(prev=>prev+ev.log);
+          }catch{}
+        }
+      }
       notify(`已根据修改意见重新生成「${cube.name}」`);
       const refreshed:SiteDetail = await fetchDb(`/sites/${site.id}`);
       setSite(refreshed);
@@ -405,7 +422,7 @@ export default function Home(){
       {page==="result"&&<Result site={site} go={go} setModal={setModal} viewPrompt={viewPrompt} notify={notify} openFeedback={openFeedback} viewSitePrompt={viewSitePrompt}/>}
       {page==="preview"&&<Preview site={site} tab={previewTab} setTab={setPreviewTab} setModal={setModal} notify={notify}/>}
     </div>{(["basic","assets","functions"] as PageKey[]).includes(page)&&<div className="sticky-actions"><Button secondary onClick={()=>go(page==="basic"?"projects":page==="assets"?"basic":"assets")}>上一步</Button><div><Button secondary onClick={()=>notify("草稿已保存")}>保存草稿</Button><Button onClick={()=>go(page==="basic"?"assets":page==="assets"?"functions":"generate")}>下一步</Button></div></div>}</main>
-    {toast&&<div className="toast"><b>✓</b>{toast}</div>}{modal&&<Modal type={modal} close={()=>setModal("")} notify={notify} promptData={promptData} feedbackCube={feedbackCube} refineCube={refineCube} refining={refining}/>}
+    {toast&&<div className="toast"><b>✓</b>{toast}</div>}{modal&&<Modal type={modal} close={()=>setModal("")} notify={notify} promptData={promptData} feedbackCube={feedbackCube} refineCube={refineCube} refining={refining} refineLog={refineLog}/>}
     {!aiOpen&&<button ref={fabRef} className="ai-fab" title="AI 助手 · 拖动可移动位置,双击复位" aria-label="AI 助手"
       style={fabPos?{left:fabPos.x,top:fabPos.y,right:"auto",bottom:"auto"}:undefined}
       onPointerDown={onFabPointerDown} onPointerMove={onFabPointerMove} onPointerUp={onFabPointerUp} onDoubleClick={resetFab}>
@@ -888,12 +905,14 @@ function StyleEditorModal({id,notify,afterChange,onClose}:{id?:number;notify:(m:
   </div></div>;
 }
 
-function Modal({type,close,notify,promptData,feedbackCube,refineCube,refining}:{type:Exclude<ModalKey,"">;close:()=>void;notify:(m:string)=>void;promptData?:{name:string;text:string}|null;feedbackCube?:Cube|null;refineCube?:(cube:Cube, feedback:string)=>void;refining?:boolean;}){
+function Modal({type,close,notify,promptData,feedbackCube,refineCube,refining,refineLog}:{type:Exclude<ModalKey,"">;close:()=>void;notify:(m:string)=>void;promptData?:{name:string;text:string}|null;feedbackCube?:Cube|null;refineCube?:(cube:Cube, feedback:string)=>void;refining?:boolean;refineLog?:string;}){
   const [feedbackText,setFeedbackText]=useState("");
+  const refineConsoleRef=useRef<HTMLPreElement>(null);
+  useEffect(()=>{ if(refineConsoleRef.current) refineConsoleRef.current.scrollTop = refineConsoleRef.current.scrollHeight; }, [refineLog]);
   const title={feedback:`提出修改意见 · ${feedbackCube?.name||""}`,download:"下载站点交付物",history:"版本历史",prompt:`生成提示词 · ${promptData?.name||""}`}[type];
   const submitFeedback=()=>{
     if(!feedbackCube){ notify("未选择目标 Cube 页面,请关闭后重试"); close(); return; }
     if(!refineCube){ notify("反馈功能未正确初始化"); return; }
     refineCube(feedbackCube, feedbackText);
   };
-  return <div className="modal-backdrop" onMouseDown={close}><div className="modal" onMouseDown={e=>e.stopPropagation()}><header><h2>{title}</h2><button onClick={close}>×</button></header><div className="modal-body">{type==="feedback"&&<><div className="impact-title">系统已识别本次修改范围(仅针对「{feedbackCube?.name}」页面)</div><ul className="impact-list"><li><b>当前页面 JSON</b><span>将依据修改意见重新生成</span></li><li><b>页面模拟数据</b><span>将同步更新</span></li><li><b>Asset Excel</b><span>不受影响</span></li><li><b>其他 Cube 页面</b><span>不受影响</span></li></ul><Field label="修改意见"><textarea value={feedbackText} onChange={e=>setFeedbackText(e.target.value)} placeholder="例如:顶部增加设备在线率,将告警列表移到页面底部,趋势图改为最近七天。"/></Field><p className="hint">提交后会在当前页面已生成的 JSON 基础上按意见修改,并保留完整提示词(含本次修改意见),可随时通过「提示词」按钮查看。</p></>}{type==="download"&&<div className="download-list">{["Cube 页面 JSON","站点菜单配置","生成及校验报告"].map(x=><label key={x}><input type="checkbox" defaultChecked/><span>{x}</span></label>)}<div><b>station-package.zip</b><span>包含全部生成产物</span></div></div>}{type==="history"&&<div className="history-list">{[["V1","首次生成",new Date().toLocaleString()]].map((x,i)=><div key={x[0]}><i>{x[0]}</i><span><b>{x[1]}</b><small>{x[2]} · 王宇</small></span><Status>当前版本</Status></div>)}</div>}{type==="prompt"&&<><p className="hint">这是发送给 Cube 生成模型的完整组合提示词。在点击「开始生成」前可在此核对站点上下文、页面要求与 Cube 规则是否准确,便于定位生成质量问题。</p><pre className="prompt-view">{promptData?.text||"(暂无提示词)"}</pre></>}</div><footer><Button secondary onClick={close}>关闭</Button>{type==="feedback"&&<Button disabled={refining} onClick={submitFeedback}>{refining?"重新生成中…":"提交并重新生成"}</Button>}{type==="prompt"&&<Button onClick={()=>{ if(promptData?.text){ navigator.clipboard?.writeText(promptData.text).then(()=>notify("提示词已复制到剪贴板")).catch(()=>notify("复制失败,请手动选择")); } }}>复制提示词</Button>}</footer></div></div>}
+  return <div className="modal-backdrop" onMouseDown={close}><div className="modal" onMouseDown={e=>e.stopPropagation()}><header><h2>{title}</h2><button onClick={close}>×</button></header><div className="modal-body">{type==="feedback"&&<><div className="impact-title">系统已识别本次修改范围(仅针对「{feedbackCube?.name}」页面)</div><ul className="impact-list"><li><b>当前页面 JSON</b><span>将依据修改意见重新生成</span></li><li><b>页面模拟数据</b><span>将同步更新</span></li><li><b>Asset Excel</b><span>不受影响</span></li><li><b>其他 Cube 页面</b><span>不受影响</span></li></ul><Field label="修改意见"><textarea value={feedbackText} onChange={e=>setFeedbackText(e.target.value)} placeholder="例如:顶部增加设备在线率,将告警列表移到页面底部,趋势图改为最近七天。"/></Field><p className="hint">提交后会在当前页面已生成的 JSON 基础上按意见修改,并保留完整提示词(含本次修改意见),可随时通过「提示词」按钮查看。</p>{(refining||refineLog)?<div className="modal-console"><div className="modal-console-head">AI 生成工作流(实时 · 只读)</div><pre className="modal-console-body" ref={refineConsoleRef}>{refineLog||"(等待 AI 反馈…)"}</pre></div>:null}</>}{type==="download"&&<div className="download-list">{["Cube 页面 JSON","站点菜单配置","生成及校验报告"].map(x=><label key={x}><input type="checkbox" defaultChecked/><span>{x}</span></label>)}<div><b>station-package.zip</b><span>包含全部生成产物</span></div></div>}{type==="history"&&<div className="history-list">{[["V1","首次生成",new Date().toLocaleString()]].map((x,i)=><div key={x[0]}><i>{x[0]}</i><span><b>{x[1]}</b><small>{x[2]} · 王宇</small></span><Status>当前版本</Status></div>)}</div>}{type==="prompt"&&<><p className="hint">这是发送给 Cube 生成模型的完整组合提示词。在点击「开始生成」前可在此核对站点上下文、页面要求与 Cube 规则是否准确,便于定位生成质量问题。</p><pre className="prompt-view">{promptData?.text||"(暂无提示词)"}</pre></>}</div><footer><Button secondary onClick={close}>关闭</Button>{type==="feedback"&&<Button disabled={refining} onClick={submitFeedback}>{refining?"重新生成中…":"提交并重新生成"}</Button>}{type==="prompt"&&<Button onClick={()=>{ if(promptData?.text){ navigator.clipboard?.writeText(promptData.text).then(()=>notify("提示词已复制到剪贴板")).catch(()=>notify("复制失败,请手动选择")); } }}>复制提示词</Button>}</footer></div></div>}
index 5f608afea5569fe50388728a60b5c5853e9a0389..76fee867b542a1deb6938eecbcd387bd2e21d510 100644 (file)
@@ -590,7 +590,7 @@ function checkDeletionFeedback(feedback, beforeJson, afterJson) {
 }
 
 // 基于“当前已生成的 JSON + 修改意见”进行修改,保持 Cube 结构不变。
-async function refineCubeJson(site, cube, currentJson, feedback, attempt = 1, previousErrors = "") {
+async function refineCubeJson(site, cube, currentJson, feedback, attempt = 1, previousErrors = "", onEvent) {
   const panels = Array.isArray(currentJson.panels) ? currentJson.panels : [];
   const titleList = panels
     .map((p, i) => `  ${i + 1}. [${p.type}] ${panelTitle(p) || "(无标题)"}`)
@@ -620,7 +620,7 @@ ${JSON.stringify(currentJson, null, 2)}`;
     ? `The previous output failed validation:\n${previousErrors}\n\n${refineInstruction}\n\nPlease fix the errors and output ONLY the corrected raw dashboard JSON.`
     : refineInstruction;
   const sessionId = `stationbuilder-refine-${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); }
@@ -629,7 +629,7 @@ ${JSON.stringify(currentJson, null, 2)}`;
   if (delErr) errors.push(delErr);
   if (errors.length > 0) {
     if (attempt >= 3) throw new Error("Refine failed after 3 attempts: " + errors.join("; "));
-    return refineCubeJson(site, cube, currentJson, feedback, attempt + 1, errors.join("\n"));
+    return refineCubeJson(site, cube, currentJson, feedback, attempt + 1, errors.join("\n"), onEvent);
   }
   return { json };
 }
@@ -698,22 +698,36 @@ async function handleGenerateCube(req, res, siteId, cubeId) {
 }
 
 // 根据修改意见,在已有生成结果上重新生成(修改 JSON 并保留/累积完整提示词)
+// 以 NDJSON 流式返回,便于前端「AI 生成工作流」面板实时展示进度。
 async function handleRefineCube(req, res, siteId, cubeId) {
-  if (genLocks.has(`cube-${cubeId}`)) return respondError(res, 429, "该页面正在生成中");
+  res.writeHead(200, { "Content-Type": "application/x-ndjson; charset=utf-8", "Cache-Control": "no-cache" });
+  const emit = (obj) => streamEvent(res, obj);
+  const fail = (message) => { emit({ task: "系统", status: "failed", message }); res.end(); };
+  if (genLocks.has(`cube-${cubeId}`)) return fail("该页面正在生成中");
   genLocks.add(`cube-${cubeId}`);
   try {
     const site = hydrateSite(siteId);
-    if (!site) return respondError(res, 404, "站点不存在");
+    if (!site) return fail("站点不存在");
     const cube = site.cubes.find(c => c.id === Number(cubeId));
-    if (!cube) return respondError(res, 404, "Cube 页面不存在");
+    if (!cube) return fail("Cube 页面不存在");
     const body = await parseBody(req).catch(() => ({}));
     const feedback = (body.feedback || "").toString().trim();
-    if (!feedback) return respondError(res, 400, "修改意见不能为空");
+    if (!feedback) return fail("修改意见不能为空");
     const row = db.prepare("SELECT json_text FROM gen_files WHERE cube_id=? ORDER BY id DESC LIMIT 1").get(cubeId);
-    if (!row) return respondError(res, 400, "该页面尚未生成,无法在此基础上修改,请先生成");
+    if (!row) return fail("该页面尚未生成,无法在此基础上修改,请先生成");
     let currentJson;
-    try { currentJson = JSON.parse(row.json_text); } catch { return respondError(res, 500, "当前 JSON 解析失败"); }
-    const { json } = await refineCubeJson(site, cube, currentJson, feedback);
+    try { currentJson = JSON.parse(row.json_text); } catch { return fail("当前 JSON 解析失败"); }
+
+    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) {
+        emit({ log: o.event.delta.text });
+      }
+    };
+    emit({ log: `\n▶ 开始按修改意见重新生成「${cube.name}」:调用 Wordbuddy AI(Hy3)与 dashboard-json-generator Skill…\n` });
+    emit({ task: `修改「${cube.name}」`, status: "running" });
+    emit({ log: `▶ 调用 Cube Skill(dashboard-json-generator)修改「${cube.name}」…\n` });
+
+    const { json } = await refineCubeJson(site, cube, currentJson, feedback, 1, "", onEvent);
     const filename = `${safeFilename(cube.name)}.json`;
     const jsonText = JSON.stringify(json, null, 2);
     if (cube.gen_file_id) {
@@ -725,10 +739,14 @@ async function handleRefineCube(req, res, siteId, cubeId) {
     const existingPrompt = cube.prompt || buildCubePrompt(site, cube);
     const storedPrompt = `${existingPrompt}\n\n--- 修改意见(${new Date().toLocaleString("zh-CN")})---\n${feedback}`;
     db.prepare("UPDATE cubes SET status='generated', prompt=? WHERE id=?").run(storedPrompt, cube.id);
-    respondJson(res, 200, { ok: true, cubeId: cube.id, filename });
+    emit({ log: `✓ 「${cube.name}」修改完成\n` });
+    emit({ task: `修改「${cube.name}」`, status: "done" });
+    res.end();
   } catch (e) {
     db.prepare("UPDATE cubes SET status='failed' WHERE id=?").run(cubeId);
-    respondError(res, 500, e.message);
+    emit({ log: `✗ 重新生成失败:${e.message}\n` });
+    emit({ task: "系统", status: "failed", message: e.message });
+    res.end();
   } finally {
     genLocks.delete(`cube-${cubeId}`);
   }
index 70e14383b6a34c06064849ec3202d055f1cb4e3d..88128d92ec0aa75b37fa7ccd472c052668bc48a3 100644 (file)
     <tr><td>结果头(状态 / 修改配置 / 下载全部 / 版本历史)</td><td>概览与操作入口</td><td>当前 site</td><td>「修改配置」回 assets;「下载全部」取 package;「版本历史」开模态</td></tr>
     <tr><td>KPI 条</td><td>资产/MU/Cube/交付文件统计</td><td>本地计算</td><td>纯展示</td></tr>
     <tr><td>页面结果卡片(缩略图 + 状态 + 预览/JSON/提示词/修改意见)</td><td>逐页查看与操作</td><td>GET /api/db/sites/:id/cubes/:cid/file</td><td>缩略图由 dashboard-renderer 渲染生成 JSON;各按钮分别预览/下载/看提示词/提修改意见</td></tr>
-    <tr><td>「✎ 修改意见」</td><td>对单页提反馈并重新生成</td><td>PUT 触发 refine</td><td>打开反馈模态,提交后在该页 JSON 基础上按意见重新生成</td></tr>
+    <tr><td>「✎ 修改意见」</td><td>对单页提反馈并重新生成</td><td>POST /api/db/sites/:id/cubes/:cid/refine 流式</td><td>打开反馈模态,提交后在当前页 JSON 基础上按意见重新生成;提交后模态内「修改意见」下方出现黑底白字的 <b>AI 生成工作流</b> 只读终端,实时滚动展示 CLI/AI/Skill 过程与进度,完成后自动刷新结果并关闭模态</td></tr>
   </table>
 
   <h3>5.8 页面预览(preview)</h3>
   <h2>8. 模态框(Modal)</h2>
   <table>
     <tr><th>类型</th><th>功能</th><th>交互效果</th></tr>
-    <tr><td>æ\8f\90å\87ºä¿®æ\94¹æ\84\8fè§\81ï¼\88feedbackï¼\89</td><td>对å\8d\95页æ\8f\90å\8f\8dé¦\88å¹¶é\87\8dæ\96°ç\94\9fæ\88\90</td><td>å±\95示影å\93\8dè\8c\83å\9b´ï¼\8cæ\8f\90交å\90\8eæ\8c\89æ\84\8fè§\81é\87\8dæ\96°ç\94\9fæ\88\90该页</td></tr>
+    <tr><td>æ\8f\90å\87ºä¿®æ\94¹æ\84\8fè§\81ï¼\88feedbackï¼\89</td><td>对å\8d\95页æ\8f\90å\8f\8dé¦\88å¹¶é\87\8dæ\96°ç\94\9fæ\88\90</td><td>å±\95示影å\93\8dè\8c\83å\9b´ï¼\88ä»\85å½\93å\89\8d页 / æ¨¡æ\8b\9fæ\95°æ\8d® / ä¸\8då\8f\97å½±å\93\8d项ï¼\89ï¼\9bæ\8f\90交å\90\8eã\80\8cä¿®æ\94¹æ\84\8fè§\81ã\80\8dä¸\8bæ\96¹å\87ºç\8e°é»\91åº\95ç\99½å­\97ç\9a\84 <b>AI ç\94\9fæ\88\90å·¥ä½\9cæµ\81</b> ç»\88端ï¼\8cå®\9eæ\97¶æ»\9aå\8a¨ CLI/AI/Skill è¿\9b度ï¼\8cå®\8cæ\88\90å\90\8eè\87ªå\8a¨å\88·æ\96°å¹¶å\85³é\97­</td></tr>
     <tr><td>下载站点交付物(download)</td><td>选择下载项</td><td>列表含 Cube JSON / 菜单配置 / 报告;Asset Excel 占位 <span class="todo">尚未实现</span></td></tr>
     <tr><td>版本历史(history)</td><td>查看版本</td><td>仅展示 V1 当前版本 <span class="todo">真实版本管理尚未实现</span></td></tr>
     <tr><td>生成提示词(prompt)</td><td>查看/复制组合提示词</td><td>展示发送给模型的完整提示词,可复制到剪贴板</td></tr>