fix(web): 直播流式文字按轮次分段——修工具刷屏时文字被推出视口(bump 0.37.2)
一次 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) <noreply@anthropic.com>
This commit is contained in:
parent
6f27b7cc5a
commit
d30f6089bb
|
|
@ -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]`——面积 `<min` 按 `sqrt(min/area)` 等比放大、两边向上取整到 8 的倍数并复核达标(1920x1080→2560x1440);`>max`(3072²=9,437,184)等比缩小;已合规原样透传(向后兼容)。约束值加到 `config/media/doubao.yaml` seedream_5 档(`min_pixels`/`max_pixels`,旧 yaml 缺键则视为不设该侧、行为不变)。归一化时返回串附 `[note]` 提示 + meta 记 `requested_size`,usage 记账按**真实出图尺寸**。选自动钳而非返错让模型重试:省一轮往返、避免二次错。新增 tests 手验 9 例全落合法区间。
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.37.1"
|
||||
__version__ = "0.37.2"
|
||||
|
|
|
|||
|
|
@ -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 = `<div class="role">助手</div><div class="body streaming">${run.acc ? renderMd(run.acc) : ""}</div>`;
|
||||
card.innerHTML = `<div class="role">助手</div>`;
|
||||
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";
|
||||
|
|
|
|||
Loading…
Reference in New Issue