From 011aee1334629f3a367243b9cb85a102100e71b3 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Fri, 17 Jul 2026 22:38:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(channel-push):=20=E5=AE=9A=E6=97=B6?= =?UTF-8?q?=E7=AE=80=E6=8A=A5=E4=BA=A7=E7=89=A9=E6=96=87=E4=BB=B6=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=E7=82=B9=E5=BC=80=20404=E2=80=94=E2=80=94=E6=8E=A8?= =?UTF-8?q?=E9=80=81=E7=AB=AF=E6=94=B6=E6=95=9B=E4=B8=BA=E7=9B=B8=E5=AF=B9?= =?UTF-8?q?=20rel=20+=20=E5=89=8D=E7=AB=AF=E8=AF=86=E5=88=AB=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=20URL=20=E5=85=9C=E5=BA=95=E5=8E=86=E5=8F=B2(bump=200?= =?UTF-8?q?.58.38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit task 7f904e5f 复盘:定时任务/企业微信推送写进 DB 的 assistant 消息把产物 文件写成完整下载 API 链接 [名](/v1/files/download?path=),web 正文链接 拦截器(chat.js 的 .msg .body a[href] click)注释设定是"user_root 相对路径", 于是把整个 /v1/files/download?path=... URL 当相对 rel,去前导 / 后 rel=v1/files/download?path=scheduled-…txt,再塞进 openFilePreview 二次包进 download URL → 请求 path 不存在 → 404(node 复刻拦截器坐实)。 关键调研(纠了初判):一度以为链接得留完整 URL 给企业微信端点下载、改格式会 break wecom——错。push_wecom(service.py:266)企业微信端走 send_text(摘要)+ send_file(文件附件独立通道),压根不碰这个 markdown 链接;_build_push_message 的链接只进 DB 给 web 端渲染。焊死 /v1/files/download?path= 纯属把具体 API 端点这个渲染细节泄漏进数据层,还正好踩拦截器坑。 修(A+B 互补): - B 源头(分层正解):core/wechat/service.py::_build_push_message 链接改 user_root 相对路径 [名]({rel}),与 chip/正文相对链接统一契约、走拦截器 已有相对分支;docstring 记明为何不用绝对 API URL(防后人再焊)。 - A 前端兜底:chat.js 拦截器在相对路径回退前先判 ^/?v1/files/,命中则 new URL(raw,origin).searchParams.get("path") 抽真正 rel 交 openFilePreview (自动解码,txt/docx 复用同 modal)——救 DB 里已存 20+ 条历史简报(改不了) + 健壮兜底。 验证:node 复刻拦截器——B 新格式(相对)与 A 历史(完整 URL)都解析成同一 干净 rel;A 另测 URL 编码含空格#号 path / 普通相对 / ./ 前缀 / 外链全对、 外链仍开新标签;node --check chat.js + ast.parse service.py 通过。web 前端 需刷新页面生效。additive,不碰对外 API/DB/企业微信推送(send_file)契约, 仅 DB 内 assistant 文本链接写法由绝对 API URL 收敛为相对 rel。 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 ++ PROGRESS.md | 1 + core/__init__.py | 2 +- core/wechat/service.py | 12 +++++- scripts/diag_task_7f904e5f.py | 72 +++++++++++++++++++++++++++++++++++ web/static/js/chat.js | 11 ++++++ 6 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 scripts/diag_task_7f904e5f.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c38923..e6381f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ > 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。 > 工程口径的完整记录见 `PROGRESS.md` / git log。 +## 0.58.38 — 2026-07-17 + +- 修定时任务/企业微信推送的简报里「产物文件」链接点开报 404 的问题:现在无论是新推送的消息,还是历史已推送的简报,点文件链接都能正常弹出预览(txt/docx 等直接在线看)。 + ## 0.58.37 — 2026-07-17 - 修三处文件/文档相关的小毛病:①助手把文件路径写成**加粗**形式时,对应的文件小标签点开不再报 404,能正常弹预览了;②把带 emoji 图标(📰🔥📊 等)的 Markdown 转成 Word 后,图标不再变成空白方块 / 缺字,能正常显示;③单个 Markdown 文件转 Word 时更顺,不会再在你的目录里多出一个 `sections` 文件夹。 diff --git a/PROGRESS.md b/PROGRESS.md index 56abd8e..1699d40 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -23,6 +23,7 @@ ### 2026-07 +- **07-17 / 0.58.38**:**修 task 7f904e5f 定时简报的产物文件链接点开 404**(与 0.58.37 的 chip 404 不同根因,勿混)。定时任务/企业微信推送写进 DB 的 assistant 消息里,产物文件写成**完整下载 API 链接** `产物文件:[名](/v1/files/download?path=)`(`core/wechat/service.py:_build_push_message`),web SPA 的正文链接拦截器(`chat.js` 的 `.msg .body a[href]` click 拦截)注释设定是"模型把 user_root 相对路径写成 `[rel](rel)`",于是把整个 `/v1/files/download?path=...` URL 当相对 rel,`replace(/^\.?\/+/,"")` 去前导 `/` 后 `rel="v1/files/download?path=scheduled-…txt"`,再塞进 `openFilePreview` 二次包进 download URL → 请求 `path=v1/files/download?path=…` 不存在 → 404(node 复刻拦截器坐实)。**关键调研(纠了初判)**:一度以为链接得留完整 URL 给企业微信端点下载、改推送格式会 break wecom——**错**:查 `push_wecom`(service.py:266) 企业微信端走 `send_text`(摘要)+`send_file`(**文件附件独立通道**),压根不碰这个 markdown 链接;`_build_push_message` 的链接**只进 DB 给 web 端渲染**。故焊死 `/v1/files/download?path=` 纯属把"具体 API 端点"这个渲染细节泄漏进数据层,还正好踩拦截器坑。**修(A+B 互补,用户提的分层方向)**:**A** 前端拦截器在相对路径回退前先判 `^\/?v1\/files\//`,命中则 `new URL(raw,location.origin).searchParams.get("path")` 抽真正 rel 交 `openFilePreview`(自动解码,txt/docx 复用同 modal)——救 DB 里**已存 20+ 条历史简报**(改不了)+ 健壮兜底;**B** 推送端源头改写 **user_root 相对路径** `[名]({rel})`,与 chip/正文相对链接统一契约、走拦截器已有的相对路径分支,新消息不再泄漏 API 细节。**验证**:node 复刻拦截器——B 新格式(相对 `scheduled-…txt`)与 A 历史(完整 URL)都解析成同一干净 rel;A 另测 5 组(URL 编码含空格#号 path/普通相对/`./` 前缀/外链)全对、外链仍开新标签;`node --check chat.js`+`ast.parse service.py` 通过。web 前端改动需刷新页面生效。additive,不碰对外 API/DB/企业微信推送(send_file)契约,仅 DB 内 assistant 文本的链接写法由绝对 API URL 收敛为相对 rel。相关:0.58.37(chip 粗体 404,另一路径)。 - **07-17 / 0.58.37**:**修 task d9c74a5a 复盘暴露的三处独立缺陷(chip 404 / docx emoji 缺失 / sections 文件夹被迫创建)**。一次"今日新闻 md→word"跑出三个不相干的坑,逐个定层修:**① 前端文件 chip 点开弹框 404**——`web/static/js/media.js::extractArtifactRels` 的路径 tail 字符集 `_TAIL_CLS` 没排除 `*`,助手把路径写成 markdown 粗体 `**/x.md**` 时,前导 `**` 被 lead 边界吃掉、但**尾部 `**` 被贪婪吞进 rel**(星号既不在 tail 集也不在尾部 strip 集),生成 `x.md**` 这种点开 `/v1/files/download?path=…md**` → 404 的坏 chip(用同款正则复现坐实结尾就是 `**`;反引号包裹的路径因反引号在排除集里天然无恙,偏这次用了粗体)。**修**:`*` 加进 `_TAIL_CLS` 排除集,同当初排除反引号一样在 emphasis 处干净截断;文件名含 `*` 在 Windows 非法、误伤≈0。**② 导出 word 里图标(emoji)缺失**——`rendering/docx_manuscript.py` 正文/标题字体是宋体/黑体/Times New Roman,**这些字体无 emoji 字形**,📰🔥🇨🇳📊 等直接写进宋体 run → Word 渲染成豆腐块/缺字。**修**:`rendering/common.py` 加 `EMOJI_RE`(圈主 emoji 区/区域指示符国旗/杂符+Dingbats/VS16/ZWJ,不含文本箭头等易误伤块)+ `add_fonted_runs`(按 emoji 边界拆 run,emoji 段走 `Segoe UI Emoji`、其余走原中西文字体),接进 `_emit_plain_with_chem`(正文,且不破坏化学式下标)与 `add_inline`(bold/italic/code);标题走 `add_inline` 自动覆盖。**③ 过程中冒出 sections/ 文件夹**——`render.py` docstring/argparse 都承诺 src 可为"目录**或单个 .md**",但 docx 三 profile(brief/paper/report/proposal)的 `render_sections` 全是 `is_dir()`+`glob("*.md")` **只认目录**(只有 pdf 真支持单文件),模型传单 .md → 撞 `sections dir not found` → 被迫 `mkdir sections && cp` 兜底留下垃圾目录。**修**:`common.py` 抽 `collect_md_files(src)`(文件→`[src]`,目录→glob),docx_manuscript + docx_brief 共用,figures 基准取 `src.parent`;兑现 docstring 承诺、模型不再需建 sections/。**验证**(/verify 精神,实跑非仅单测):还原 emoji 满满的今日新闻 md 跑单文件 `--profile report`,退出 0(不再报 sections 缺失),解包 docx 校 run 字体——📰🔥🇨🇳🏙️📊 全 `Segoe UI Emoji`、中文仍宋体、SiO₂/CaCO₃ 下标 `[SUB]` 未被破坏;正则修复用 4 组样本(粗体/反引号+间隔号/裸路径+中文句号/斜体)全绿。三处分属前端 chip 提取 / docx 字体 / render 契约,互不相干可独立修;均 additive,不碰对外 API/DB 契约(render.py 契约本就承诺单文件,现名副其实)。 - **07-17 / 0.58.36**:**run_python 超时返回超时前的部分输出 + 无缓冲(-u),让模型据此续跑不整批重来**(失败面板 #8:`command timed out` 19 次 / 7 task / 6 用户,累计最多)。**定性**(查 19 条超时的原始 tool_call 代码):不是死循环 / chromium 老坑,全是**真慢的批量 I/O**——① 论文检索 `skills.research.paper.search()` 循环搜多个期刊 / 关键词(最多,~11 条,每次打 OpenAlex 外部 API 累计爆超时)② pdfplumber 多 PDF `extract_text` ③ 大文件下载(ffmpeg tar.xz / MDPI PDF)④ pandas 大 Excel。超时机制本身正常,真正浪费在 **`_run_subprocess` 超时分支把已捕获的部分输出全丢了**(kill 后 `communicate()` 已续读超时前 stdout,却只回一句 `[Error] command timed out` → 模型看不到「搜到第 6 个」、整批重搜,同 task 反复重跑)。**修**:① `tools/base.py::format_timeout_result` 把超时前的 stdout/stderr 一并返回 + 续跑提示(据已完成部分续跑 / 每项落盘 / 大操作用 `background=true` / 单次别塞太多),docker(`_run_subprocess`)+ host(`run_python.py` 的 `TimeoutExpired.stdout/stderr`)两执行器共用;② **`-u` 无缓冲**——Python 管道下 stdout 块缓冲,不 flush 的 `print` 超时被 kill 时缓冲区就丢了,故 run_python 的 python 调用全加 `-u`(docker 前台 inline / script_path + host 前台 + 两处后台 `procs.launch_host`),让部分输出真正落管道被捕获(不碰 shell)。这里提示指路(background / 续跑)与语法预检不同、**恰当**:超时就是「你这段太慢」的确定信号,建议对任何超时都可靠,不像语法→渲染器那样分不清场景。**不做**:不抬默认 timeout(120s 合理,抬高只延后同问题 + 多占 loop);不单独改 prompt(提示随超时结果反应式投放更精准)。测试 `tests/test_timeout_partial.py`(format 单测 + host 端到端:print 两项后 sleep,timeout=1 → 结果带两项 + 续跑提示)+ `test_executor_docker` argv 断言更新为 `python -u`,全 34 相邻测试绿。additive(超时结果加信息,不破坏 schema),不碰对外 API/DB 契约。至此失败面板 top 全部处置(② edit streak / ③ 畸形 / ① SyntaxError 预检+渲染器 / #8 超时);char-0 畸形由既有 salvage 覆盖。相关 memory:高轮数烧 token 三根因 / 沙箱 chromium 案(本次确认非 chromium)。 - **07-17 / 0.58.35**:**通用报告走平台渲染器(`report` profile)+ 基座引导——从根上少让模型手撸 python-docx**(接 0.58.34:语法预检是止血,这条是根治那一头)。**关键发现**:平台**早有**成熟 md→docx(+pdf)渲染器 `rendering/`(bind-mount `/sandbox/rendering:ro`,任何 run_python/shell 可达,与 skill 无关),标题/正文/列表/表格/图片图题/mermaid/内联/化学式下标/目录/house 字体(宋体小四·黑体·Times·1.5 行距)全有,paper/proposal/brief 三 profile 在用。烧 token 的验收报告没走它、在裸手撸 python-docx,**唯一原因是没 skill/引导把"随手写 Word 报告"指向它**(只有 proposal/paper 知道)。故**不造轮子**,只两件小事:① `rendering/docx_manuscript.py` 加 `report` profile(复用全套渲染,差异=报告不是申报书:目录默认无[`--toc` 才带]、章节不强制分页、通用页边距 2.5/2.5、列表含"第X章/节"不含"条"),`render.py --profile` 加 `report` 选项 + 路由;② `core/agent_builder.py` 基座 prompt 加软引导「出 Word 报告→正文写成 `/sections/*.md`(纯文本零转义)调 `render.py --profile report`,别手撸 python-docx 内联中文」。**取舍红线(与用户对齐)**:渲染器是**首选加法不是替代**——绝不硬拦 run_python 生成文档,确需精细定制版式/改写已有 docx/处理 xlsx 等渲染器覆盖不了的照样自由写代码;软引导让位于更具体的 skill render 指引(proposal/paper)。**刻意不做**:(c) 把渲染器指路塞进语法预检 hint——会把只该管语法的检查overload成它做不可靠的工具选择判断(分不清"报告正文该走渲染器"vs"定制 python-docx 就该手撸"),误导合法手撸,故预检保持只诊断语法。三层叠加:渲染器(根治)> 预检(0.58.34 安全网)> RepeatGuard err-streak(0.58.33 兜底),run_python 能力不削。测试 `tests/test_rendering.py`(四 profile 端到端 + report 目录可选)+ CLI e2e(`render.py --profile report` 出 37KB docx)全绿;py_compile 校 agent_builder/render/manuscript 无语法错。DESIGN §8.6 记该 profile + 并存红线。additive,不碰对外 API/DB 契约。相关 memory:skill 禁令不带配方 / 高轮数烧 token 三根因。 diff --git a/core/__init__.py b/core/__init__.py index 154d3b2..b869ede 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.58.37" +__version__ = "0.58.38" diff --git a/core/wechat/service.py b/core/wechat/service.py index 9441d6e..5ebb89e 100644 --- a/core/wechat/service.py +++ b/core/wechat/service.py @@ -433,13 +433,21 @@ def _file_rel_to_user_root(user_id: UUID, file_path: str) -> Optional[str]: def _build_push_message(text: str, rel: Optional[str]) -> str: - """构造写进 chat task 的 assistant 消息:推送摘要 + 可点文件链接 + agent read 路径。""" + """构造写进 chat task 的 assistant 消息:推送摘要 + 可点文件链接 + agent read 路径。 + + 此消息**只进 DB 供 web 端渲染**(企业微信端另走 push_wecom 的 send_text 摘要 + + send_file 附件,不经此链接)。故文件链接用 **user_root 相对路径**(如 + `scheduled-/x.txt`)——与 chip / 正文相对链接同一契约,web 的正文链接拦截器 + 默认就按 user_root 相对解析、复用文件预览 modal。**不要**再写成完整下载 API + `[名](/v1/files/download?path=)`:那把具体 API 端点这个渲染细节焊进数据层,且 + 拦截器会把整串 URL 当相对 rel 二次包裹 → 404(踩过 task 7f904e5f;历史消息由前端 + 识别该形式兜底,新消息走这里的相对路径)。""" lines: list[str] = [] if text and text.strip(): lines.append(text.strip()) if rel: fname = rel.rsplit("/", 1)[-1] - lines.append(f"产物文件:[{fname}](/v1/files/download?path={rel})") + lines.append(f"产物文件:[{fname}]({rel})") lines.append(f"(如需基于此文件提问,可读取 ../{rel})") return "\n\n".join(lines) diff --git a/scripts/diag_task_7f904e5f.py b/scripts/diag_task_7f904e5f.py new file mode 100644 index 0000000..c5f04f0 --- /dev/null +++ b/scripts/diag_task_7f904e5f.py @@ -0,0 +1,72 @@ +"""Dump task 7f904e5f full row + messages (ASCII safe, prefix match on task_id).""" +import json +import os +import sys +from pathlib import Path + +env = Path(__file__).resolve().parent.parent / ".env" +for line in env.read_text(encoding="utf-8").splitlines(): + if line.strip().startswith("ZCBOT_DB_URL="): + os.environ["ZCBOT_DB_URL"] = line.split("=", 1)[1].strip() +from sqlalchemy import create_engine, text # noqa: E402 + +import builtins # noqa: E402 + +_out = open(Path(__file__).resolve().parent / "_task_7f904e5f.txt", "w", encoding="utf-8") + + +def print(*a, **k): # noqa: A001 + builtins.print(*a, **k, file=_out) + + +engine = create_engine(os.environ["ZCBOT_DB_URL"]) +PREFIX = "7f904e5f" + + +def s(x, n=6000): + t = str(x or "") + return t if len(t) <= n else t[:n] + f"...[+{len(t)-n} chars]" + + +with engine.connect() as conn: + row = conn.execute( + text("select * from tasks where task_id::text like :p"), {"p": PREFIX + "%"} + ).mappings().fetchone() + if not row: + print("[NO TASK]", PREFIX) + sys.exit(1) + TID = row["task_id"] + print("[TASK ROW]") + for k, v in dict(row).items(): + print(f" {k} = {s(v, 2000)}") + + msgs = conn.execute( + text("select idx,payload,model_profile,tokens_in,tokens_out,created_at from messages " + "where task_id=:t order by idx"), + {"t": TID}, + ).fetchall() + print(f"\n[MESSAGES] count={len(msgs)}\n") + for idx, p, mp, ti, to, ca in msgs: + if isinstance(p, str): + try: + p = json.loads(p) + except Exception as e: + print(f"[{idx}] !! payload JSON parse fail: {e}; raw head: {s(p, 500)}") + continue + role = p.get("role") + head = f"[{idx}] {role}" + if mp: + head += f" ({mp})" + if ti or to: + head += f" tok={ti}/{to}" + head += f" at={ca}" + print(head) + content = p.get("content") + if content is not None: + print(" content:", s(content, 2500)) + for tc in p.get("tool_calls") or []: + fn = tc.get("function") or {} + print(f" CALL {fn.get('name')}({s(fn.get('arguments'), 1200)})") + if role == "tool": + print(f" TOOL[{p.get('name')}] id={p.get('tool_call_id')}:", s(content, 1500)) + print() diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 7d7d93d..ba80669 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -1949,6 +1949,17 @@ $("chat-stream").addEventListener("click", (e) => { return; } e.preventDefault(); + // 渠道推送(企业微信/定时任务)把产物写成完整下载 API 链接 [名](/v1/files/download?path=) + // —— 对企业微信客户端是"点即下载"的正解,但 web 这里若当普通相对路径处理,会把整个 + // `/v1/files/download?path=...` 串当 rel 二次塞进 download URL → 请求 path 不存在 → 404 + // (踩过 task 7f904e5f 的定时简报)。故先识别"已是 /v1/files 接口"的 href,抽出 path= + // 查询参数作真正 rel 交预览(txt/docx 等一并复用同一 modal),抽取失败再回退旧逻辑。 + if (/^\/?v1\/files\//i.test(raw)) { + try { + const p = new URL(raw, location.origin).searchParams.get("path"); + if (p) { openFilePreview(p); return; } + } catch (_) {} + } let rel = raw.replace(/^sandbox:/i, "").replace(/^\.?\/+/, ""); try { rel = decodeURIComponent(rel); } catch (_) {} rel = rel.replace(/^\/+/, "");