fix(web): 历史消息丢图片预览/chip——taskMeta 竞态 + 中文路径抽取(bump 0.58.17)
两根因:
1. selectTask 并发化(0.12.16)后 renderMessages 在 Promise.all 内执行,
state.taskMeta 必然还是旧值/null → wd 错 → chip+inline 图整体丢失。
loadMessages 加 {render} 开关,首屏渲染延后到 taskMeta 赋值后(零额外 RTT)。
2. extractArtifactRels lead 边界不认汉字("已保存到<wd>/xxx.png"不命中),
tail 会把紧贴的中文标点连后文吞进文件名。lead 改任意非 [A-Za-z0-9._-],
tail 排除集抽 _TAIL_CLS 常量补中文标点(主规则+legacy 双处)。
node 离线回归 12 用例全绿;CHANGELOG 面向用户补记。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
4d7075aeb8
commit
4a591e1a90
|
|
@ -5,6 +5,10 @@
|
||||||
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
|
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
|
||||||
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
||||||
|
|
||||||
|
## 0.58.17 — 2026-07-13
|
||||||
|
|
||||||
|
- 修复打开历史对话时图片预览 / 文件按钮不显示的问题:此前生成的图片只在生成当下可见,切换对话或刷新后就"消失"(文件其实还在,右侧文件面板能找到),现在重新打开对话也能直接看到;另外中文回复里紧贴文字的文件路径(如"已保存到xxx.png")此前点不了,现在也会正常挂出可点的文件按钮。
|
||||||
|
|
||||||
## 0.58.16 — 2026-07-13
|
## 0.58.16 — 2026-07-13
|
||||||
|
|
||||||
- Pro 档用户的图像生成默认改为「GPT 生图」(顶栏下拉自动选中并实际生效);想用豆包 Seedream(支持改图、指定尺寸)随时在下拉切回即可。
|
- Pro 档用户的图像生成默认改为「GPT 生图」(顶栏下拉自动选中并实际生效);想用豆包 Seedream(支持改图、指定尺寸)随时在下拉切回即可。
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
|
|
||||||
### 2026-07
|
### 2026-07
|
||||||
|
|
||||||
|
- **07-13 / 0.58.17**:**历史消息渲染丢图片预览/chip 两根因修复**(用户报"对话里看不到预览图,有时也不挂 chip"):① 确定性回归(0.12.16 `82feece` 并发化引入):`selectTask` 的 `Promise.all` 里 `loadMessages()` 内部就调 `renderMessages()`,而 `state.taskMeta = meta` 要等 Promise.all 整体完成——首屏渲染时 taskMeta 必然是上个 task 的/null → `_workingDirName` 算出的 wd 错/空 → `extractArtifactRels` 返 `[]`,chip+inline 图整体丢失(直播 SSE 期间可见、切走再切回/刷新就"消失"的体感即此)。修法:`loadMessages` 加 `{render}` 开关,selectTask 传 `render:false` 保持并发取数,`renderMessages` 延后到 taskMeta 赋值后(零额外 RTT,只等 meta/messages 较慢者)。② 中文语境抽取正则漏挂:media.js lead 边界原要求空格/英文标点,"已保存到gpt生图/xxx.png"路径前紧贴汉字不命中;且路径后紧贴",请查看"时 tail 把中文标点连后文吞进文件名产生 404 坏 chip。修法:lead 改"行首或任意非 `[A-Za-z0-9._-]`"(汉字算边界,仍防 mygpt生图 前缀误挂),tail 排除集抽成 `_TAIL_CLS` 常量并补常见中文标点(主规则+legacy figures/videos 双处)。node 离线回归 12 用例全绿(CJK 前邻/后邻标点/前缀防护/中文文件名/legacy 不重复等)。
|
||||||
- **07-13 / 0.58.16**:**有 gpt_image 权限的用户生图默认 GPT 生图**:此前 `/v1/image_models` 的 `is_default` 恒标"档位过滤后第一个"(yaml 文件名排序豆包在前 → 人人默认 seedream)。改法:`web/app.py` 新增 `_default_image_variant(allowed)`——档位集合里有 `gpt_image`(pro 档 / admin)→ 默认它,否则仍第一个(seedream);`/v1/image_models` 的 `is_default` 与 `_resolve_image_model` 的空串解析(POST messages 时客户端没显式选 image_model)共用该 helper,保证下拉默认选中项和实际起 run 挂的图像工具永远一致。用户可在顶栏下拉自行切回豆包 Seedream(选中值随消息显式发送,不受默认影响)。前端零改动(chat.js 本就选 `is_default` 项);无 user_id 的内部路径(定时任务/messenger)行为不变(空串 → agent_builder yaml 第一个兜底)。
|
- **07-13 / 0.58.16**:**有 gpt_image 权限的用户生图默认 GPT 生图**:此前 `/v1/image_models` 的 `is_default` 恒标"档位过滤后第一个"(yaml 文件名排序豆包在前 → 人人默认 seedream)。改法:`web/app.py` 新增 `_default_image_variant(allowed)`——档位集合里有 `gpt_image`(pro 档 / admin)→ 默认它,否则仍第一个(seedream);`/v1/image_models` 的 `is_default` 与 `_resolve_image_model` 的空串解析(POST messages 时客户端没显式选 image_model)共用该 helper,保证下拉默认选中项和实际起 run 挂的图像工具永远一致。用户可在顶栏下拉自行切回豆包 Seedream(选中值随消息显式发送,不受默认影响)。前端零改动(chat.js 本就选 `is_default` 项);无 user_id 的内部路径(定时任务/messenger)行为不变(空串 → agent_builder yaml 第一个兜底)。
|
||||||
- **07-13 / 0.58.15**:**结构图 mermaid vs 生图:改为 ask_user 让用户明确点选**:用户反馈"画流程图/架构图时模型直奔 mermaid,想用生图(gpt image)没入口"。根因两处:①岔路引导只写在 imagegen SKILL.md 里,而 mermaid 路径根本不触发 load_skill,模型全程看不到岔路口;②SKILL.md 岔路段把"画个流程图/做个架构图"硬性划给 mermaid。改法(口径:结构图选型交给用户点选,不做信号猜测):`core/agent_builder.py` 新增 `_MEDIA_DIAGRAM_FORK_SEG`(挂 seedream 或 gpt_image 任一 variant 时追加进媒体段)——用户要流程图/架构图/技术路线/时序图时先 `ask_user` 二选一(mermaid 矢量:文字准可编辑零成本 vs 生图视觉版:有质感但中文标签易乱码/位图不可改字),免问例外=用户已点名工具/本对话已选过路线/paper·proposal skill 管线内部要求 mermaid;SKILL.md 岔路段同口径重写,并补"生图画结构图取舍"段(乱码/不可改字提前说清,prompt 按风格化示意少文字装配,想两全建议 mermaid 出结构+生图出底图),反模式条同步;SKILL_LIST.md imagegen 岔路段同步。纯 prompt 调整。
|
- **07-13 / 0.58.15**:**结构图 mermaid vs 生图:改为 ask_user 让用户明确点选**:用户反馈"画流程图/架构图时模型直奔 mermaid,想用生图(gpt image)没入口"。根因两处:①岔路引导只写在 imagegen SKILL.md 里,而 mermaid 路径根本不触发 load_skill,模型全程看不到岔路口;②SKILL.md 岔路段把"画个流程图/做个架构图"硬性划给 mermaid。改法(口径:结构图选型交给用户点选,不做信号猜测):`core/agent_builder.py` 新增 `_MEDIA_DIAGRAM_FORK_SEG`(挂 seedream 或 gpt_image 任一 variant 时追加进媒体段)——用户要流程图/架构图/技术路线/时序图时先 `ask_user` 二选一(mermaid 矢量:文字准可编辑零成本 vs 生图视觉版:有质感但中文标签易乱码/位图不可改字),免问例外=用户已点名工具/本对话已选过路线/paper·proposal skill 管线内部要求 mermaid;SKILL.md 岔路段同口径重写,并补"生图画结构图取舍"段(乱码/不可改字提前说清,prompt 按风格化示意少文字装配,想两全建议 mermaid 出结构+生图出底图),反模式条同步;SKILL_LIST.md imagegen 岔路段同步。纯 prompt 调整。
|
||||||
- **07-13 / 0.58.14**:**toolfail 聚集加时间分布,活跃/已安静一眼分**(0.58.11 的可观测性补强):此前聚集只有全窗口累计 count+last_at——修掉的问题旧记录仍挂满 7 天窗口,和"还在发生"的没视觉区别,只能人肉盯"最近时间"是否前进。`core/toolfail.py` 聚合时加 `daily`(从 now 往回按 24h 分桶,旧→新,非日历日)/ `count_24h`(=尾桶)/ `first_at`,排序改"近 24h 活跃在前(count_24h 降序),安静的按 count 降序沉底";兼补 naive timestamp 的 tzinfo 兜底。admin 页表格加"近24h"(>0 红)和"趋势"列(unicode block mini 条形图,悬浮看逐桶数值),近24h=0 的行整体淡化;告警邮件每条带"近24h N 次/已安静"。API 只加字段不删,向后兼容。冒烟:伪造行 patch session_scope 全绿(分桶/排序/naive ts/邮件格式)。修复部署后次日看板:对应行变灰+趋势尾格归零 = 修好;仍红 = 没修好。
|
- **07-13 / 0.58.14**:**toolfail 聚集加时间分布,活跃/已安静一眼分**(0.58.11 的可观测性补强):此前聚集只有全窗口累计 count+last_at——修掉的问题旧记录仍挂满 7 天窗口,和"还在发生"的没视觉区别,只能人肉盯"最近时间"是否前进。`core/toolfail.py` 聚合时加 `daily`(从 now 往回按 24h 分桶,旧→新,非日历日)/ `count_24h`(=尾桶)/ `first_at`,排序改"近 24h 活跃在前(count_24h 降序),安静的按 count 降序沉底";兼补 naive timestamp 的 tzinfo 兜底。admin 页表格加"近24h"(>0 红)和"趋势"列(unicode block mini 条形图,悬浮看逐桶数值),近24h=0 的行整体淡化;告警邮件每条带"近24h N 次/已安静"。API 只加字段不删,向后兼容。冒烟:伪造行 patch session_scope 全绿(分桶/排序/naive ts/邮件格式)。修复部署后次日看板:对应行变灰+趋势尾格归零 = 修好;仍红 = 没修好。
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||||
# 改版本只动这一行。
|
# 改版本只动这一行。
|
||||||
__version__ = "0.58.16"
|
__version__ = "0.58.17"
|
||||||
|
|
|
||||||
|
|
@ -411,17 +411,19 @@ export async function selectTask(tid) {
|
||||||
state.outline = []; renderOutlineRail(); // 切 task 先清旧目录,refreshOutline 拉到再渲
|
state.outline = []; renderOutlineRail(); // 切 task 先清旧目录,refreshOutline 拉到再渲
|
||||||
try {
|
try {
|
||||||
// meta / messages / outline 三者无依赖,并发拉省 RTT(切 task 体感更跟手)。
|
// meta / messages / outline 三者无依赖,并发拉省 RTT(切 task 体感更跟手)。
|
||||||
// loadMessages、refreshOutline 内部读 state.taskId(上方已置),不依赖 meta;
|
// loadMessages、refreshOutline 内部读 state.taskId(上方已置),不依赖 meta。
|
||||||
// 落在不同 DOM 区(chat-meta / chat-stream / outline-rail),谁先返回先渲染。
|
// 但消息**渲染**依赖 meta(artifact chip 抽取要 working_dir)→ loadMessages 只取数,
|
||||||
|
// 渲染延后到 taskMeta 赋值之后;代价是首屏渲染等 meta/messages 中较慢者(meta 很轻)。
|
||||||
const [meta] = await Promise.all([
|
const [meta] = await Promise.all([
|
||||||
api("GET", "/v1/tasks/" + tid),
|
api("GET", "/v1/tasks/" + tid),
|
||||||
loadMessages(),
|
loadMessages({ render: false }),
|
||||||
refreshOutline(),
|
refreshOutline(),
|
||||||
// proc 状态并入预取:后面 renderLiveRunIfVisible 决定按钮态时数据已就位,
|
// proc 状态并入预取:后面 renderLiveRunIfVisible 决定按钮态时数据已就位,
|
||||||
// 消掉"刷新后按钮先短暂可用、锁随轮询迟到"的亚秒窗口
|
// 消掉"刷新后按钮先短暂可用、锁随轮询迟到"的亚秒窗口
|
||||||
refreshProcs(),
|
refreshProcs(),
|
||||||
]);
|
]);
|
||||||
state.taskMeta = meta;
|
state.taskMeta = meta;
|
||||||
|
renderMessages(state.loadedMessages);
|
||||||
renderChatMeta();
|
renderChatMeta();
|
||||||
applyChannelComposerLock(meta);
|
applyChannelComposerLock(meta);
|
||||||
if (meta.run_status === "running" || meta.run_status === "cancelling") {
|
if (meta.run_status === "running" || meta.run_status === "cancelling") {
|
||||||
|
|
@ -651,14 +653,17 @@ function alignedEarlierLimit(firstIdx) {
|
||||||
return Math.min(firstIdx - bounds[j], MSG_FETCH_MAX);
|
return Math.min(firstIdx - bounds[j], MSG_FETCH_MAX);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMessages() {
|
async function loadMessages({ render = true } = {}) {
|
||||||
const data = await api("GET", `/v1/tasks/${state.taskId}/messages?limit=${MSG_PAGE}`);
|
const data = await api("GET", `/v1/tasks/${state.taskId}/messages?limit=${MSG_PAGE}`);
|
||||||
state.loadedMessages = data.messages || [];
|
state.loadedMessages = data.messages || [];
|
||||||
state.msgHasMore = !!data.has_more;
|
state.msgHasMore = !!data.has_more;
|
||||||
state.msgHasMoreNewer = !!data.has_more_after; // 尾部窗口通常为 false
|
state.msgHasMoreNewer = !!data.has_more_after; // 尾部窗口通常为 false
|
||||||
state.msgLoadingEarlier = false;
|
state.msgLoadingEarlier = false;
|
||||||
state.msgLoadingNewer = false;
|
state.msgLoadingNewer = false;
|
||||||
renderMessages(state.loadedMessages);
|
// render=false:selectTask 首屏走延后渲染 —— renderMessages 里 artifact chip 抽取
|
||||||
|
// 依赖 state.taskMeta.working_dir,必须等 meta 赋值后再渲,否则 wd 是旧 task 的/null,
|
||||||
|
// 历史消息里的图片预览和 chip 整体丢失(0.12.16 并发化引入的回归)。
|
||||||
|
if (render) renderMessages(state.loadedMessages);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 向上加载更早一批:取当前已加载窗口最早 idx 之前、按整轮对齐的一批,prepend 进数组后
|
// 向上加载更早一批:取当前已加载窗口最早 idx 之前、按整轮对齐的一批,prepend 进数组后
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,11 @@ export function extractMediaBanner(toolName, resultText) {
|
||||||
return parts.length ? `<span class="tool-banner">${parts.join("")}</span>` : "";
|
return parts.length ? `<span class="tool-banner">${parts.join("")}</span>` : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 路径 tail 排除集:空白/引号/括号类 + 常见中文标点 —— 中文正文里路径后常紧贴
|
||||||
|
// ",请查看"这类标点(无空格),不排除会把标点连后文一起吞进文件名,生成点开 404
|
||||||
|
// 的坏 chip。文件名几乎不含这些中文标点,排除的误伤面≈0。
|
||||||
|
const _TAIL_CLS = "[^\\s\"'`<>(){}\\[\\]|,。、;:!?…·“”‘’《》〈〉【】()「」『』]+";
|
||||||
|
|
||||||
export function extractArtifactRels(text, workingDir) {
|
export function extractArtifactRels(text, workingDir) {
|
||||||
if (!text || !workingDir) return [];
|
if (!text || !workingDir) return [];
|
||||||
const wd = String(workingDir).replace(/\\+/g, "/").replace(/^\/+|\/+$/g, "");
|
const wd = String(workingDir).replace(/\\+/g, "/").replace(/^\/+|\/+$/g, "");
|
||||||
|
|
@ -98,10 +103,12 @@ export function extractArtifactRels(text, workingDir) {
|
||||||
const out = [];
|
const out = [];
|
||||||
|
|
||||||
// 规范形式:<wdName>/<...>/<file>.<ext> —— 当前协议(system prompt 强约束助手照抄 tool `saved:` 行)
|
// 规范形式:<wdName>/<...>/<file>.<ext> —— 当前协议(system prompt 强约束助手照抄 tool `saved:` 行)
|
||||||
// lead 边界:行首或非 path-字符;tail 截到空白/引号/括号等
|
// lead 边界:行首或任意非 [A-Za-z0-9._-] 字符 —— 汉字也算边界(中文正文"已保存
|
||||||
|
// 到gpt生图/xxx.png"路径前紧贴汉字是常态),同时仍防 "mygpt生图/..." 前缀误挂;
|
||||||
|
// tail 截到空白/引号/括号/中文标点(见 _TAIL_CLS)
|
||||||
{
|
{
|
||||||
const re = new RegExp(
|
const re = new RegExp(
|
||||||
"(?:^|[\\s\"'`/=:,()<>\\[\\]{}|])(" + wdEsc + "/[^\\s\"'`<>(){}\\[\\]|]+)",
|
"(?:^|[^A-Za-z0-9._-])(" + wdEsc + "/" + _TAIL_CLS + ")",
|
||||||
"g"
|
"g"
|
||||||
);
|
);
|
||||||
let m;
|
let m;
|
||||||
|
|
@ -127,10 +134,10 @@ export function extractArtifactRels(text, workingDir) {
|
||||||
const LEGACY_PRODUCT_DIRS = ["videos", "figures"];
|
const LEGACY_PRODUCT_DIRS = ["videos", "figures"];
|
||||||
for (const dir of LEGACY_PRODUCT_DIRS) {
|
for (const dir of LEGACY_PRODUCT_DIRS) {
|
||||||
const dirEsc = dir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
const dirEsc = dir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
// lead 边界跟主规则一致但去掉 `/` —— 否则 <wd>/videos/xxx 里的 videos/xxx 会被重复
|
// lead 边界跟主规则一致但额外排 `/` —— 否则 <wd>/videos/xxx 里的 videos/xxx 会被重复
|
||||||
// 匹配(虽然 seen 去重,但浪费 cycles)
|
// 匹配(虽然 seen 去重,但浪费 cycles)
|
||||||
const re = new RegExp(
|
const re = new RegExp(
|
||||||
"(?:^|[\\s\"'`=:,()<>\\[\\]{}|])(" + dirEsc + "/[^\\s\"'`<>(){}\\[\\]|]+)",
|
"(?:^|[^A-Za-z0-9._/-])(" + dirEsc + "/" + _TAIL_CLS + ")",
|
||||||
"g"
|
"g"
|
||||||
);
|
);
|
||||||
let m;
|
let m;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue