- db-sidecar: handleRefineCube 改为 NDJSON 流式返回,refineCubeJson 透传 onEvent,发射 log 进度与 task 状态
- 前端 refineCube 改为读取流并累积 refineLog;反馈弹窗「修改意见」下方新增黑底白字只读终端,自动滚动
- 设计文档同步更新(5.7 修改意见 / 8 反馈模态)
.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}
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);};
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);
{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}>
</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>}
}
// 基于“当前已生成的 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) || "(无标题)"}`)
? `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); }
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 };
}
}
// 根据修改意见,在已有生成结果上重新生成(修改 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) {
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}`);
}
<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>