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) }), {
.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:""}
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],
["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);
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);
{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 渲染后缩放显示
return errors;
}
-function runCodebuddy(prompt, sessionId) {
+function runCodebuddy(prompt, sessionId, onEvent) {
return new Promise((resolve, reject) => {
const args = [
"--model", "hy3",
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 {}
};
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 };
}
}
}
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) {
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);