]> acesimba.cloud Git - StationBuilder.git/commitdiff
feat: 支持针对 Cube 页面提出修改意见并重新生成(保留累积提示词)
authorwang.yu <wang.yu@advantech.com>
Mon, 17 Aug 2026 01:52:53 +0000 (09:52 +0800)
committerwang.yu <wang.yu@advantech.com>
Mon, 17 Aug 2026 01:52:53 +0000 (09:52 +0800)
- 后端新增 POST /api/sites/:id/cubes/:cubeId/refine:基于当前已生成 JSON + 修改意见
  重新生成,修改意见以「--- 修改意见(时间)---」形式累积写入 cubes.prompt;
  /prompt 端点改为返回已存储(含历史修改意见)的提示词
- 前端 Result 页每个已生成 Cube 增加「✎ 修改意见」按钮,弹窗提交后刷新站点数据,
  缩略图(依赖 generated_at)与新标签页预览自动更新
- 强化 refine 对删除类意见的可靠性:提示词列出当前面板清单并明确删/移/增/改规则,
  生成后校验删除是否真正生效,未生效则重试最多 3 次并指名待删面板
- 修复 Modal 漏传 feedbackCube/refineCube/refining 导致「提交并重新生成」无效的缺陷

app/page.tsx
db-sidecar.mjs

index ad2878e334c145ccfe33c86b795c8544fe68af92..63cbcdf52df86b84b0e223cdaf15e8df3733f622 100644 (file)
@@ -166,6 +166,8 @@ export default function Home(){
   const [view,setView]=useState<"table"|"card">("table");
   const [functionTab,setFunctionTab]=useState<"mu"|"cube"|"menu">("mu");
   const [previewTab,setPreviewTab]=useState<"info"|"json"|"feedback">("info");
+  const [feedbackCube,setFeedbackCube]=useState<Cube|null>(null);
+  const [refining,setRefining]=useState(false);
 
   const notify=(m:string)=>{setToast(m);window.setTimeout(()=>setToast(""),2200);};
 
@@ -190,6 +192,31 @@ export default function Home(){
     }catch(e:any){ notify("读取提示词失败: "+e.message); }
   };
 
+  const openFeedback=(cube:Cube)=>{
+    if(!cube.id){ notify("请先生成该页面再提出修改意见"); return; }
+    setFeedbackCube(cube);
+    setModal("feedback");
+  };
+  const refineCube=async(cube:Cube, feedback:string)=>{
+    if(!site?.id || !cube.id) return;
+    if(!feedback.trim()){ notify("请填写修改意见"); return; }
+    setRefining(true);
+    try{
+      await fetchDb(`/sites/${site.id}/cubes/${cube.id}/refine`, {
+        method:"POST",
+        headers:{"Content-Type":"application/json"},
+        body:JSON.stringify({feedback}),
+      });
+      notify(`已根据修改意见重新生成「${cube.name}」`);
+      const refreshed:SiteDetail = await fetchDb(`/sites/${site.id}`);
+      setSite(refreshed);
+      setSiteId(site.id);
+      setModal("");
+      setFeedbackCube(null);
+    }catch(e:any){ notify("重新生成失败:"+(e?.message||e)); }
+    finally{ setRefining(false); }
+  };
+
   useEffect(()=>{ if(aiEndRef.current) aiEndRef.current.scrollIntoView({block:"end"}); }, [aiMessages, aiOpen]);
 
   // load sites and domains
@@ -359,10 +386,10 @@ export default function Home(){
       {page==="assets"&&<Assets site={site} onChange={saveAssets} saving={saving}/>}
       {page==="functions"&&<Functions site={site} muTree={muTree} onMuChange={saveMu} onSiteChange={setSite} notify={notify}/>}
       {page==="generate"&&<Generate site={site} onSiteChange={setSite} notify={notify} viewPrompt={viewPrompt} viewSitePrompt={viewSitePrompt}/>}
-      {page==="result"&&<Result site={site} go={go} setModal={setModal} viewPrompt={viewPrompt} notify={notify}/>}
+      {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}/>}
+    {toast&&<div className="toast"><b>✓</b>{toast}</div>}{modal&&<Modal type={modal} close={()=>setModal("")} notify={notify} promptData={promptData} feedbackCube={feedbackCube} refineCube={refineCube} refining={refining}/>}
     {!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}>
@@ -671,12 +698,12 @@ function DashboardThumb({siteId, cube}:{siteId:number; cube:Cube}){
       }
     })();
     return ()=>{cancelled=true;};
-  },[siteId, cube.id]);
+  },[siteId, cube.id, cube.generated_at]);
   if(!cube.id) return <div className="dashboard-thumb-live"><div className="thumb-empty">未生成</div></div>;
   return <div ref={outer} className="dashboard-thumb-live" style={{overflow:"hidden"}}><div ref={inner} style={{width:1920, height:1080, position:"relative"}}/></div>;
 }
 
-function Result({site,go,setModal,viewPrompt,notify}:{site:SiteDetail|null;go:(p:PageKey)=>void;setModal:(m:ModalKey)=>void;viewPrompt:(cubeId:number|undefined,name:string,content?:string)=>void;notify:(m:string)=>void}){
+function Result({site,go,setModal,viewPrompt,notify,openFeedback,viewSitePrompt}:{site:SiteDetail|null;go:(p:PageKey)=>void;setModal:(m:ModalKey)=>void;viewPrompt:(cubeId:number|undefined,name:string,content?:string)=>void;notify:(m:string)=>void;openFeedback:(cube:Cube)=>void;viewSitePrompt:()=>void}){
   if(!site) return <Panel><div className="empty-state">加载中…</div></Panel>;
   const generated = site.cubes.filter(c=>c.status==="generated");
   const openPreview=async(cube:Cube)=>{
@@ -694,7 +721,7 @@ function Result({site,go,setModal,viewPrompt,notify}:{site:SiteDetail|null;go:(p
   };
   return <><div className="result-header"><div><span className="site-icon big">⬡</span><section><h2>{site.name || "未命名站点"}</h2><p>{site.status==="generated"?"生成完成":"草稿"} · {site.updated_at}</p></section><Status>{site.status==="generated"?"生成完成":"草稿"}</Status></div><div><Button secondary onClick={()=>setModal("history")}>版本历史</Button><Button secondary onClick={()=>go("assets")}>修改配置</Button><Button onClick={()=>window.open(`/api/db/sites/${site.id}/package`,`_blank`)}>⇩ 下载全部</Button></div></div>
   <div className="kpi-row">{[[site.assets.filter(a=>a.type).length,"Asset 资产","类设备"],[site.mu.length,"MU 页面","已配置"],[generated.length,"Cube 页面",generated.length===site.cubes.length?"校验全部通过":"部分未生成"],[generated.length+1,"交付文件","JSON+ZIP"]].map(x=><div key={x[1]}><span>{x[1]}</span><b>{x[0]}</b><small>{x[2]}</small></div>)}</div>
-  <div className="result-layout"><div><Panel title="页面结果" tools={<Button secondary onClick={()=>setModal("feedback")}>✎ 提出修改意见</Button>}><div className="result-pages">{site.cubes.map((cube,i)=><article key={i}><DashboardThumb siteId={site.id} cube={cube} /><div><header><Status>{cube.status==="generated"?"校验通过":"未生成"}</Status><small>Cube</small></header><h3>{cube.name}</h3><p>{cube.content || "无描述"}</p><footer><Button secondary onClick={()=>openPreview(cube)}>预览</Button>{cube.id&&<Button secondary onClick={()=>fetch(`/api/db/sites/${site.id}/cubes/${cube.id}/file`).then(r=>r.blob()).then(b=>downloadBlob(b, cube.gen_filename||`${cube.name}.json`))}>⇩ JSON</Button>}{cube.id&&<Button secondary onClick={()=>viewPrompt(cube.id, cube.name, cube.content)}>提示词</Button>}</footer></div></article>)}</div></Panel></div></div></>;
+  <div className="result-layout"><div><Panel title="页面结果" tools={<Button secondary onClick={()=>viewSitePrompt()}>查看站点提示词</Button>}><div className="result-pages">{site.cubes.map((cube,i)=><article key={i}><DashboardThumb siteId={site.id} cube={cube} /><div><header><Status>{cube.status==="generated"?"校验通过":"未生成"}</Status><small>Cube</small></header><h3>{cube.name}</h3><p>{cube.content || "无描述"}</p><footer><Button secondary onClick={()=>openPreview(cube)}>预览</Button>{cube.id&&<Button secondary onClick={()=>fetch(`/api/db/sites/${site.id}/cubes/${cube.id}/file`).then(r=>r.blob()).then(b=>downloadBlob(b, cube.gen_filename||`${cube.name}.json`))}>⇩ JSON</Button>}{cube.id&&<Button secondary onClick={()=>viewPrompt(cube.id, cube.name, cube.content)}>提示词</Button>}{cube.status==="generated"&&<Button secondary onClick={()=>openFeedback(cube)}>✎ 修改意见</Button>}</footer></div></article>)}</div></Panel></div></div></>;
 }
 
 function Preview({site,tab,setTab,setModal,notify}:{site:SiteDetail|null;tab:"info"|"json"|"feedback";setTab:(t:"info"|"json"|"feedback")=>void;setModal:(m:ModalKey)=>void;notify:(m:string)=>void}){
@@ -727,4 +754,12 @@ function DomainDetail({go,notify}:{go:(p:PageKey)=>void;notify:(m:string)=>void}
       <Panel title="领域知识"><div className="knowledge-list">{[["▤","自来水厂工艺说明.pdf","PDF · 3.2 MB"],["△","水质报警规则集","18 条规则"],["⚒","水泵维修规则","12 条规则"],["↻","设备保养规则","16 条规则"]].map(x=><div key={x[1]}><i>{x[0]}</i><span><b>{x[1]}</b><small>{x[2]}</small></span><button onClick={()=>notify("已打开 "+x[1])}>查看</button></div>)}</div></Panel></div></div></>
 }
 
-function Modal({type,close,notify,promptData}:{type:Exclude<ModalKey,"">;close:()=>void;notify:(m:string)=>void;promptData?:{name:string;text:string}|null}){const title={feedback:"确认修改影响",download:"下载站点交付物",history:"版本历史",prompt:`生成提示词 · ${promptData?.name||""}`}[type];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">系统已识别本次修改范围</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 defaultValue="顶部增加设备在线率,将告警列表移到页面底部,趋势图改为最近七天。"/></Field></>}{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==="prompt"&&<Button onClick={()=>{ if(promptData?.text){ navigator.clipboard?.writeText(promptData.text).then(()=>notify("提示词已复制到剪贴板")).catch(()=>notify("复制失败,请手动选择")); } }}>复制提示词</Button>}</footer></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;}){
+  const [feedbackText,setFeedbackText]=useState("");
+  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>}
index 72e53da891cd3fcd7bac0b198350e4e4d5bb07b2..cdb223da3929c1aef1a4daf8af425cb7f6a215dd 100644 (file)
@@ -429,6 +429,85 @@ async function generateCubeJson(site, cube, attempt = 1, previousErrors = "") {
   return { json, prompt: promptBase };
 }
 
+// 取面板展示标题(适配不同位置的标题字段)
+function panelTitle(p) {
+  return (
+    p.custom?.title?.label ||
+    (typeof p.custom?.title === "string" ? p.custom.title : "") ||
+    p.style?.title?.label ||
+    p.title ||
+    p.custom?.name ||
+    p.name ||
+    ""
+  );
+}
+
+// 若反馈要求删除某面板,校验该面板在生成结果中确实已被移除;否则返回错误信息用于重试。
+function checkDeletionFeedback(feedback, beforeJson, afterJson) {
+  const m = feedback.match(/(?:删除|移除|去掉|删掉|去除)\s*[::]?\s*["']?([^\s,。、;\n"']+)["']?/);
+  if (!m) return null;
+  const name = m[1].trim();
+  if (name.length < 2) return null;
+  const before = (beforeJson.panels || []).filter(p => {
+    const t = panelTitle(p);
+    return t && t.includes(name);
+  });
+  if (before.length === 0) return null; // 没找到可删除的面板,不强制
+  const stillThere = (afterJson.panels || []).some(p => {
+    const t = panelTitle(p);
+    return t && t.includes(name);
+  });
+  if (stillThere) {
+    return `反馈要求删除包含“${name}”的面板,但输出中该面板仍然存在。请把它整个对象(含 data/custom/gridPos 等全部内容)从 panels 数组中彻底移除,再输出完整 JSON。当前应被删除的面板标题类似:${before.map(p => panelTitle(p)).join("、")}`;
+  }
+  return null;
+}
+
+// 基于“当前已生成的 JSON + 修改意见”进行修改,保持 Cube 结构不变。
+async function refineCubeJson(site, cube, currentJson, feedback, attempt = 1, previousErrors = "") {
+  const panels = Array.isArray(currentJson.panels) ? currentJson.panels : [];
+  const titleList = panels
+    .map((p, i) => `  ${i + 1}. [${p.type}] ${panelTitle(p) || "(无标题)"}`)
+    .join("\n");
+  const refineInstruction = `你正在修改一个已生成的 Cube 仪表盘 JSON(可导入名为 Cube 的可视化工具)。下面给出当前页面的【完整 JSON】、页面内现有面板清单,以及用户提出的修改意见。
+
+现有面板清单(编号 / 类型 / 标题):
+${titleList}
+
+要求:
+1) 严格按照修改意见调整这份 JSON;
+2) 保持 dashboard-json-generator Skill 中 Cube 工具的结构与字段规则完全不变(顶层必须包含 category_id/name/id/grid/panels;grid 必须包含 theme/bgColor/panelBgColor;首面板必须是 type="Title" 且 gridPos {w:24,h:2,x:0,y:0};其余面板 y>=2;24 列网格,x+w 不超过 24,面板不可重叠;panel type 必须属于 ${[...VALID_PANEL_TYPES].join(", ")});
+3) 关于修改意见中的各类操作,请严格执行:
+   - 删除 / 移除 / 去掉某面板:先在“现有面板清单”中找到标题包含该名称的面板,然后把该面板【整个对象】(含 data、custom、gridPos 等全部内容)从 panels 数组中移除,不要再保留它;
+   - 移动 / 调整位置:只修改该面板的 gridPos;
+   - 新增面板:按规则新增一个完整面板对象,并安排不重叠的 gridPos;
+   - 修改文字 / 数据 / 配色:只改对应字段,其余保持不变;
+4) 不要删除或改动用户未要求的面板,不要改变主题(light/darkBlue 不可混用),模拟数据保持合理;
+5) 只输出修改后的【完整】JSON 对象,不要省略任何面板,也不要包含 markdown 代码块或解释文字。
+
+修改意见:
+${feedback}
+
+当前 JSON:
+${JSON.stringify(currentJson, null, 2)}`;
+  const prompt = previousErrors
+    ? `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);
+  let json;
+  try { json = await extractJson(text); }
+  catch (e) { throw new Error("JSON parse failed: " + e.message); }
+  const errors = validateDashboard(json);
+  const delErr = checkDeletionFeedback(feedback, currentJson, json);
+  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 { json };
+}
+
 async function createSiteZip(siteId) {
   const siteDir = join(GEN_DIR, String(siteId));
   ensureDir(siteDir);
@@ -492,6 +571,43 @@ async function handleGenerateCube(req, res, siteId, cubeId) {
   }
 }
 
+// 根据修改意见,在已有生成结果上重新生成(修改 JSON 并保留/累积完整提示词)
+async function handleRefineCube(req, res, siteId, cubeId) {
+  if (genLocks.has(`cube-${cubeId}`)) return respondError(res, 429, "该页面正在生成中");
+  genLocks.add(`cube-${cubeId}`);
+  try {
+    const site = hydrateSite(siteId);
+    if (!site) return respondError(res, 404, "站点不存在");
+    const cube = site.cubes.find(c => c.id === Number(cubeId));
+    if (!cube) return respondError(res, 404, "Cube 页面不存在");
+    const body = await parseBody(req).catch(() => ({}));
+    const feedback = (body.feedback || "").toString().trim();
+    if (!feedback) return respondError(res, 400, "修改意见不能为空");
+    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, "该页面尚未生成,无法在此基础上修改,请先生成");
+    let currentJson;
+    try { currentJson = JSON.parse(row.json_text); } catch { return respondError(res, 500, "当前 JSON 解析失败"); }
+    const { json } = await refineCubeJson(site, cube, currentJson, feedback);
+    const filename = `${safeFilename(cube.name)}.json`;
+    const jsonText = JSON.stringify(json, null, 2);
+    if (cube.gen_file_id) {
+      db.prepare("UPDATE gen_files SET filename=?, json_text=?, created_at=CURRENT_TIMESTAMP WHERE id=?").run(filename, jsonText, cube.gen_file_id);
+    } else {
+      const info = db.prepare("INSERT INTO gen_files (site_id, cube_id, filename, json_text, created_at) VALUES (?,?,?,?,CURRENT_TIMESTAMP)").run(siteId, cube.id, filename, jsonText);
+      db.prepare("UPDATE cubes SET gen_file_id=? WHERE id=?").run(info.lastInsertRowid, cube.id);
+    }
+    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 });
+  } catch (e) {
+    db.prepare("UPDATE cubes SET status='failed' WHERE id=?").run(cubeId);
+    respondError(res, 500, e.message);
+  } finally {
+    genLocks.delete(`cube-${cubeId}`);
+  }
+}
+
 async function handleGenerateAll(req, res, siteId) {
   res.writeHead(200, { "Content-Type": "application/x-ndjson; charset=utf-8", "Cache-Control": "no-cache" });
   if (genLocks.has(`site-${siteId}`)) {
@@ -605,6 +721,7 @@ async function route(req, res) {
   const mSitePrompt = path.match(/^\/api\/sites\/(\d+)\/prompt$/);
   const mCubeGenerate = path.match(/^\/api\/sites\/(\d+)\/cubes\/(\d+)\/generate$/);
   const mCubeFile = path.match(/^\/api\/sites\/(\d+)\/cubes\/(\d+)\/file$/);
+  const mCubeRefine = path.match(/^\/api\/sites\/(\d+)\/cubes\/(\d+)\/refine$/);
   const mCubePrompt = path.match(/^\/api\/sites\/(\d+)\/cubes\/(\d+)\/prompt$/);
   const mCubePromptNew = path.match(/^\/api\/sites\/(\d+)\/cubes\/prompt$/);
   const mSiteMenu = path.match(/^\/api\/sites\/(\d+)\/menu$/);
@@ -758,7 +875,9 @@ async function route(req, res) {
       if (!site) return respondError(res, 404, "站点不存在");
       const cube = site.cubes.find(c => c.id === cubeId);
       if (!cube) return respondError(res, 404, "Cube 页面不存在");
-      return respondJson(res, 200, { prompt: buildCubePrompt(site, cube) });
+      // 返回已存储的完整提示词(含历史修改意见累积),若尚未生成则回退到实时构建
+      const stored = cube.prompt || buildCubePrompt(site, cube);
+      return respondJson(res, 200, { prompt: stored });
     }
     if (mCubePromptNew) {
       const siteId = Number(mCubePromptNew[1]);
@@ -770,6 +889,7 @@ async function route(req, res) {
       return respondJson(res, 200, { prompt: buildCubePrompt(site, { name, content, sort_order: 0 }) });
     }
     if (mCubeFile) return handleCubeFileDownload(res, Number(mCubeFile[2]));
+    if (mCubeRefine) return handleRefineCube(req, res, Number(mCubeRefine[1]), Number(mCubeRefine[2]));
     if (mSiteGenerate) return handleGenerateAll(req, res, Number(mSiteGenerate[1]));
     if (mSitePackage) return handlePackageDownload(res, Number(mSitePackage[1]));