From d30f6089bbd37c2c72b8685a05e5c1fa3e2f6dfe Mon Sep 17 00:00:00 2001 From: caoqianming Date: Fri, 3 Jul 2026 13:22:15 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20=E7=9B=B4=E6=92=AD=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=E6=96=87=E5=AD=97=E6=8C=89=E8=BD=AE=E6=AC=A1=E5=88=86?= =?UTF-8?q?=E6=AE=B5=E2=80=94=E2=80=94=E4=BF=AE=E5=B7=A5=E5=85=B7=E5=88=B7?= =?UTF-8?q?=E5=B1=8F=E6=97=B6=E6=96=87=E5=AD=97=E8=A2=AB=E6=8E=A8=E5=87=BA?= =?UTF-8?q?=E8=A7=86=E5=8F=A3(bump=200.37.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一次 run 把整段(含几十轮 LLM)塞进一张 assistant 卡:文字全累顶部单块、 工具卡全追加其下,工具多时文字被越推越高滚出视口看不到。根因是直播态(单卡合并) 与历史态(每轮 LLM 一条独立消息、天然穿插)结构不一致。 方案 A(只动 chat.js live-run 路径,历史渲染不动):文字按轮次分段—— ensureTextSeg/closeTextSeg 维护当前打开的文字段,每个可见工具/选项卡(非隐形 task_progress)先关掉当前段(空占位段移除、有内容段定稿去光标+高亮),之后新文字 在卡片底部另起新段。流式文字始终在底部可见,且与历史结构一致,run 结束 reload 无跳变。 rAF 节流改闭包捕获 seg 防错渲;ctx.body/ctx.pending 单块模型换成 ctx.curSeg。 Co-Authored-By: Claude Opus 4.8 (1M context) --- PROGRESS.md | 3 ++ core/__init__.py | 2 +- web/static/js/chat.js | 83 ++++++++++++++++++++++++++++++++----------- 3 files changed, 66 insertions(+), 22 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index b4d92b0..2aa70e6 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -21,6 +21,9 @@ ## 已完成关键能力 +### 2026-07-03 / web 直播流式文字按轮次分段(修工具刷屏时文字被推出视口,bump 0.37.2) +用户报:web 端一次 run 里工具调用多时,助手文字流式输出「一直在上方」被工具卡越推越高滚出视口,看不到。根因:直播态把整次 run(含几十轮 LLM)全塞进**一张 assistant 卡**——文字全累进顶部单块 `.body`(`ctx.acc` 反复重渲),工具 `tool_call`/`tool_result` 全 `appendChild` 到其下方;而历史态(DB reload)是**每轮 LLM 一条独立 assistant 消息**、天然按轮次穿插。两态结构不一致就是病根。修(方案 A,只动 `chat.js` live-run 路径,历史渲染不动):文字按轮次分段——`ensureTextSeg`/`closeTextSeg` 维护「当前打开的文字段」,每个可见工具/选项卡(非隐形 `task_progress`)先 `closeTextSeg` 关掉当前段(空占位段直接移除避免留「思考中」孤块、有内容段定稿去光标+高亮),之后的新文字在卡片底部另起新段。效果=`文字(轮1)→工具→结果→文字(轮2)→…`,流式文字始终在底部可见,且与历史结构一致(run 结束 reload 无跳变)。rAF 节流改为闭包捕获 seg,防工具关段后错渲。删掉 `ctx.body`/`ctx.pending` 单块模型,改 `ctx.curSeg={el,acc,pending}`;`createLiveAssistantCard`/`renderLiveRunIfVisible`/`sendMessage`/`fetchSse` 收尾同步改。 + ### 2026-07-03 / seedream size 面积钳制(修 1920x1080 被 ARK 400 打回,bump 0.37.1) 模型自选 16:9 出图(如 `1920x1080`=2,073,600px)触发 ARK 硬门 `image size must be at least 3686400 pixels`(=1920²),整次文生图直接 400 失败。根因:`tools/seedream.py` 把 `size` 原样透传,不校验 ARK 的**面积**约束(卡的是总像素不是单边,故 16:9 最小合规是 2560x1440)。修:tool 内新增 `_normalize_size()`,拿到 `chosen_size` 前先钳进 `[min_pixels, max_pixels]`——面积 `max`(3072²=9,437,184)等比缩小;已合规原样透传(向后兼容)。约束值加到 `config/media/doubao.yaml` seedream_5 档(`min_pixels`/`max_pixels`,旧 yaml 缺键则视为不设该侧、行为不变)。归一化时返回串附 `[note]` 提示 + meta 记 `requested_size`,usage 记账按**真实出图尺寸**。选自动钳而非返错让模型重试:省一轮往返、避免二次错。新增 tests 手验 9 例全落合法区间。 diff --git a/core/__init__.py b/core/__init__.py index 7e18a3f..f6b1171 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.37.1" +__version__ = "0.37.2" diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 9bd13f8..3a05825 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -770,12 +770,48 @@ function isCurrentTaskStreaming() { return !!getLiveRun(state.taskId); } +// 直播卡片内文字按「轮次」分段:每段一个 .body,工具调用会关闭当前段,之后的新文字 +// 在卡片底部另起一段 —— 使流式文字与工具卡按时序穿插、最新文字始终贴在底部可见。 +// 历史渲染天然按消息分段,直播这样分段后两态结构一致,run 结束 reload 无跳变。 +function ensureTextSeg(run) { + if (run.curSeg) return run.curSeg; + const el = document.createElement("div"); + el.className = "body streaming"; + run.card.appendChild(el); + run.curSeg = { el, acc: "", pending: false }; + return run.curSeg; +} + +// 关闭当前文字段:空占位段(还没吐字就来了工具)直接移除,避免留下「思考中」孤块; +// 有内容的段落定稿(去光标 + 代码高亮),之后的新文字会另起新段。 +function closeTextSeg(run) { + const seg = run.curSeg; + if (!seg) return; + if (!seg.acc) { + seg.el.remove(); + } else { + seg.el.classList.remove("streaming"); + highlightIn(seg.el); + } + run.curSeg = null; +} + function createLiveAssistantCard(run) { const card = document.createElement("div"); card.className = "msg assistant live-run"; - card.innerHTML = `
助手
${run.acc ? renderMd(run.acc) : ""}
`; + card.innerHTML = `
助手
`; run.card = card; - run.body = card.querySelector(".body"); + run.curSeg = null; + if (run.acc) { + // 重连:已累积文字作为初始(仍打开的)文字段渲染,后续事件在其后穿插 + const el = document.createElement("div"); + el.className = "body streaming"; + el.innerHTML = renderMd(run.acc); + card.appendChild(el); + run.curSeg = { el, acc: run.acc, pending: false }; + } else { + ensureTextSeg(run); // 空占位段:首字到达前显示「思考中」 + } return card; } @@ -786,8 +822,9 @@ function renderLiveRunIfVisible() { return; } const wrap = $("chat-stream"); + // card 已持有全部文字段/工具卡 DOM(切走再切回只需重新挂载,不重渲); + // 新建的重连 card 由 createLiveAssistantCard 自行渲染已累积文字。 const card = run.card || createLiveAssistantCard(run); - if (run.body && run.acc) run.body.innerHTML = renderMd(run.acc); renderTaskProgressDock(run.progressSteps || []); if (card.parentElement !== wrap) wrap.appendChild(card); wrap.scrollTop = wrap.scrollHeight; @@ -801,11 +838,10 @@ function ensureRunningTaskSubscribed(taskId, url) { taskId, url, acc: "", - pending: false, seenRels: new Set(), terminal: false, card: null, - body: null, + curSeg: null, cancelling: state.taskMeta && state.taskMeta.run_status === "cancelling", workingDir: state.taskMeta && state.taskMeta.working_dir, progressSteps: cloneProgressSteps(state.taskProgressByTask.get(taskId)), @@ -1349,15 +1385,16 @@ async function sendMessage(overrideText) { taskId, url: r.events_url, acc: "", - pending: false, seenRels: new Set(), terminal: false, card: asstCard, - body: asstCard.querySelector(".body"), + curSeg: null, cancelling: false, workingDir: state.taskMeta && state.taskMeta.working_dir, progressSteps: cloneProgressSteps(state.taskProgressByTask.get(taskId)), }; + // 预建的空占位 .body 即首个文字段(首字到达前显示「思考中」) + run.curSeg = { el: asstCard.querySelector(".body"), acc: "", pending: false }; state.liveRuns.set(taskId, run); state.streaming = true; setActionMode("streaming"); @@ -1415,8 +1452,6 @@ function appendRunError(run, msg) { } async function fetchSse(url, run) { - const body = run.body || (run.card && run.card.querySelector(".body")); - run.body = body; const ctx = run; const hint = $("chat-hint"); // 重连:reader 异常 / 自然 EOF 但未收到 done/error 时,GET events 重订阅。 @@ -1450,13 +1485,15 @@ async function fetchSse(url, run) { await new Promise(r => setTimeout(r, backoffs[attempt])); attempt++; } - // 最终定稿 + 代码高亮(流式中不 highlight,省 CPU) - if (ctx.body) { - ctx.body.innerHTML = renderMd(ctx.acc); - if (ctx.card) highlightIn(ctx.card); + // 最终定稿 + 代码高亮(流式中不 highlight,省 CPU):定稿当前打开的文字段, + // 已被工具关闭的历史段在 closeTextSeg 时已定稿 + 高亮。 + if (ctx.curSeg && ctx.curSeg.el) { + if (ctx.curSeg.acc) ctx.curSeg.el.innerHTML = renderMd(ctx.curSeg.acc); + else ctx.curSeg.el.remove(); // 收尾时仍是空占位段 → 移除 } + if (ctx.card) highlightIn(ctx.card); } finally { - if (ctx.body) ctx.body.classList.remove("streaming"); + if (ctx.card) ctx.card.querySelectorAll(".body.streaming").forEach((b) => b.classList.remove("streaming")); state.liveRuns.delete(ctx.taskId); state.streaming = state.liveRuns.size > 0; if (state.taskId === ctx.taskId) { @@ -1556,7 +1593,6 @@ function handleSseEvent(ev, asstCard, ctx) { const t = ev.event; asstCard = asstCard || ctx.card || createLiveAssistantCard(ctx); ctx.card = asstCard; - ctx.body = ctx.body || asstCard.querySelector(".body"); const stream = $("chat-stream"); const visible = state.taskId === ctx.taskId; // 用户拖到上面看历史时不抢滚动,只在贴底时跟流 @@ -1569,12 +1605,15 @@ function handleSseEvent(ev, asstCard, ctx) { setRunHint(ctx, ctx.lastUsageHint); } else if (t === "text" && ev.data && ev.data.delta) { ctx.acc += ev.data.delta; - // rAF 节流:每帧最多 1 次重渲染,流式 token 高频时不抖 - if (!ctx.pending) { - ctx.pending = true; + const seg = ensureTextSeg(ctx); // 无打开文字段则在卡片底部另起一段 + seg.acc += ev.data.delta; + // rAF 节流:每帧最多 1 次重渲染,流式 token 高频时不抖。闭包捕获 seg —— 若 rAF + // 触发前来了工具调用把当前段关掉,仍渲染这一段自己的累积文本,不会错渲到别的段。 + if (!seg.pending) { + seg.pending = true; requestAnimationFrame(() => { - ctx.body.innerHTML = renderMd(ctx.acc); - ctx.pending = false; + seg.el.innerHTML = renderMd(seg.acc); + seg.pending = false; if (nearBottom) stream.scrollTop = stream.scrollHeight; }); } @@ -1585,8 +1624,9 @@ function handleSseEvent(ev, asstCard, ctx) { if (fn === "task_progress") { ctx.progressSteps = applyProgressAction(ctx.progressSteps || [], args); setTaskProgress(ctx.taskId, ctx.progressSteps); - return; + return; // 进度是隐形动作,不落可见卡 → 不打断当前文字段 } + closeTextSeg(ctx); // 关闭当前文字段:工具/选项卡追加到其下方,之后新文字另起底部段 if (fn === "ask_user") { asstCard.appendChild(buildAskUserCard(args, { interactive: true })); if (nearBottom) stream.scrollTop = stream.scrollHeight; @@ -1614,6 +1654,7 @@ function handleSseEvent(ev, asstCard, ctx) { const toolName = (ev.data && ev.data.name) || ""; if (toolName === "task_progress") return; if (toolName === "ask_user") return; // 选项卡已在 tool_call 阶段渲染,结果是占位不展示 + closeTextSeg(ctx); // 结果卡追加到当前文字段之下,之后新文字另起底部段 const banner = extractMediaBanner(toolName, txtStr); const det = document.createElement("details"); det.className = "tool-call";