From: wang.yu Date: Mon, 17 Aug 2026 08:29:46 +0000 (+0800) Subject: feat: 生成页新增只读实时终端「AI 生成工作流」并修复代理缓冲导致的过程不可见 X-Git-Url: http://acesimba.cloud/gitweb/?a=commitdiff_plain;h=03cf0b985064e9cc0952a41d166817833c18a9ec;p=StationBuilder.git feat: 生成页新增只读实时终端「AI 生成工作流」并修复代理缓冲导致的过程不可见 - 新增 DB 代理流式透传(原 Buffer 整段缓存使生成过程在完成后才一次性返回) - db-sidecar 将 codebuddy CLI 实时输出(text_delta)与 Skill 调用标记以 {log} 事件转发 - Generate 组件新增只读终端面板,节流刷新 + 自动滚动,点开始生成时清空 - globals.css 新增 .gen-right/.console-panel/.gen-console 终端样式 - 面板标题定为「AI 生成工作流」 --- diff --git a/app/api/db/[[...slug]]/route.ts b/app/api/db/[[...slug]]/route.ts index 36bf546..f8fb501 100644 --- a/app/api/db/[[...slug]]/route.ts +++ b/app/api/db/[[...slug]]/route.ts @@ -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) }), { diff --git a/app/globals.css b/app/globals.css index 0892528..63cb841 100644 --- a/app/globals.css +++ b/app/globals.css @@ -165,4 +165,10 @@ .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:""} diff --git a/app/page.tsx b/app/page.tsx index 6b15187..37c4544 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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(null); + const consoleRef=useRef(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
加载中…
; 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=>)} -
{progress===100?"生成完成":running?"正在生成站点方案":"准备就绪"}{progress}%

{progress===100?"全部交付物已生成并通过校验。":running?"Wordbuddy Hy3 正在调用 Cube Skill 创建页面与模拟数据。":"Wordbuddy Hy3 已就绪,预计需要约 2 分钟。"}

{tasks.map((x,i)=>
{x.status==="done"?"✓":x.status==="running"?"↻":x.status==="failed"?"!":"·"}{x.name}{x.message&&{x.message}}{x.status==="done"?"成功":x.status==="running"?"生成中":x.status==="failed"?"失败":"等待"}
)}
{zipUrl&&}
; +
{progress===100?"生成完成":running?"正在生成站点方案":"准备就绪"}{progress}%

{progress===100?"全部交付物已生成并通过校验。":running?"Wordbuddy Hy3 正在调用 Cube Skill 创建页面与模拟数据。":"Wordbuddy Hy3 已就绪,预计需要约 2 分钟。"}

{tasks.map((x,i)=>
{x.status==="done"?"✓":x.status==="running"?"↻":x.status==="failed"?"!":"·"}{x.name}{x.message&&{x.message}}{x.status==="done"?"成功":x.status==="running"?"生成中":x.status==="failed"?"失败":"等待"}
)}
{zipUrl&&}
{log || "(等待生成开始…)"}
; } // 缩略图:拉取 cube 生成的 dashboard JSON,用公开渲染器按 1920×1080 渲染后缩放显示 diff --git a/db-sidecar.mjs b/db-sidecar.mjs index ee94037..5f608af 100644 --- a/db-sidecar.mjs +++ b/db-sidecar.mjs @@ -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);