Compare commits

..

No commits in common. "346930449afd7aea1183e2c6cc399017e78b9ea1" and "0e02cff6c6cae647cfad25756a6197d1de2c325e" have entirely different histories.

11 changed files with 52 additions and 345 deletions

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-07-03(seedream size 面积钳制:修 1920x1080 被 ARK 400 打回,bump 0.37.1) 最后更新:2026-07-03(ppt 门体系二轮硬化:逃生口收紧 + 导出自动质检 + svg_final 嵌图修复,bump 0.36.1)
--- ---
@ -21,15 +21,6 @@
## 已完成关键能力 ## 已完成关键能力
### 2026-07-03 / ppt 反纯文字页+图表落地硬门(7aa49195 二代陶瓷 deck 复盘,bump 0.38.0)
0.37 网格锁上线后同题重做(task 7aa49195),对齐/标题/节奏大幅好转,但用户复评两点成立:①**两栏裸文字页 ×4**(S8/S9/S16/S21 同为"图标小标题+下划线+文字堆 ×2 栏"零图形)——该形态无卡片、仅 2 图标,0.37 的 icon-grid/card-grid 指纹完全看不见,单调门盲区;②**全本零数据图表**(素材全是数字:100万→500万条/能耗降10-20%/碳排26%),"历程"类内容也退化成文字列表。另有两硬缺陷:S18 第 5 条描述被页脚裁掉(内容超出内容区)、S19 红色大字直接叠压灰色说明文字。修:**A 指纹加 text-columns 原型**(0 卡片+≤3 图标+≤2 图形基元+左对齐文本聚 ≥2 列)堵盲区,4 页同指纹→error;**B spec 指派图表落空检测**——spec_lock page_charts 指派了图表但该页 <3 图形基元且 <4 卡片error("图表被退化成文字"), executor 硬规则"不许把指派图表降级为文字/大字 KPI";**C CJK 叠压升级 error** run 70% CJK(表意字宽 1.0em 估宽近精确)且互叠 50%error(其余情形保持 warning+渲图过目);**D layout_grid 加可选 content_bottom**非页脚文本 baseline 越过它error(S18 ),executor "写页前垂直空间预算"纪律;**E 策略层数据图表下限**素材含 3 组可比数值全本至少 1-2 页真数据图表,零图表需在 spec 写理由;两栏裸文字列表计入"原型 2 "上限测试 +9(30 )全过,全量 162 ;71 charts 模板 + 中汽研 deck 模板回归零新增噪音已知边界:S19 类叠压若文字带 rotate/scale transform 仍不可测(子树跳过);数据图表下限是策略纪律,机器只能验"指派了没画",验不了"该指派没指派"
### 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 例全落合法区间。
### 2026-07-03 / ppt 对齐网格锁 + 错位/单调质检(d1285247 陶瓷 deck 复盘,bump 0.37.0) ### 2026-07-03 / ppt 对齐网格锁 + 错位/单调质检(d1285247 陶瓷 deck 复盘,bump 0.37.0)
对 d1285247 产物(25 页陶瓷方案 PPTX)逐页几何量测 + PowerPoint COM 渲图目视复盘,三类缺陷:①跨页左基线漂移(0.6560.75in 七个值)+ 并排块顶差 212px 的"想对齐没对齐"(S8/S19/S23);②5 页同为"图标+标题+三行字"卡网格,零流程箭头/零分层图形,单调;③标题语义不兑现("五层架构"画成五条等宽横条、"矩阵"画成卡片格)。根因:executor 手写绝对坐标但 spec_lock 无网格常量可依;质检只查重叠/越界不查对齐;"节奏不雷同"只约束相邻页。修四层:**A spec_lock 新增 `layout_grid` 锁段**(margin_x/content_top/footer_y/gutter,strategist 派生、executor 每页吸附、checker 强制;design_spec_reference §V 同步);**B executor-base §3 网格对齐纪律**(并排卡片同 top 同高等 gutter、打破网格 ≥16px 干净打破、同行文字 ≥0.3em 禁贴字);**C svg_quality_checker 新增 check 14**——兄弟卡片近失对齐(精确几何,212px error;底对齐/中心对齐/绘图区内数据柱三类豁免,71 charts 模板回归误报清零)、layout_grid 偏离 215px error、行内 gap 不等 warning、无锁存量项目跨页左缘聚类漂移 warning、版式指纹单调门(≥3 页同指纹 warn、≥4 或过半 error;仅对 NN_ 编号 deck 页聚合,模板库静默);**D 策略纪律升级**——同一版式原型整本 ≤2 次 + 标题语义必须被图形兑现(SKILL.md 大纲纪律 + strategist visual-floor GATE)。顺手修 comparison_columns 模板胶囊 5px 错位。新增 tests/test_svg_alignment_check.py 21 项,全量 153 过。已知边界:页面平衡类(底部大空白/重心偏移,S18/S22)误报风险高未进 checker,只进阶段五验收 checklist 眼看;错位 error 会被导出边界自动质检门连带拦截,存量项目重导出若报新 error 属预期(真缺陷)。 对 d1285247 产物(25 页陶瓷方案 PPTX)逐页几何量测 + PowerPoint COM 渲图目视复盘,三类缺陷:①跨页左基线漂移(0.6560.75in 七个值)+ 并排块顶差 212px 的"想对齐没对齐"(S8/S19/S23);②5 页同为"图标+标题+三行字"卡网格,零流程箭头/零分层图形,单调;③标题语义不兑现("五层架构"画成五条等宽横条、"矩阵"画成卡片格)。根因:executor 手写绝对坐标但 spec_lock 无网格常量可依;质检只查重叠/越界不查对齐;"节奏不雷同"只约束相邻页。修四层:**A spec_lock 新增 `layout_grid` 锁段**(margin_x/content_top/footer_y/gutter,strategist 派生、executor 每页吸附、checker 强制;design_spec_reference §V 同步);**B executor-base §3 网格对齐纪律**(并排卡片同 top 同高等 gutter、打破网格 ≥16px 干净打破、同行文字 ≥0.3em 禁贴字);**C svg_quality_checker 新增 check 14**——兄弟卡片近失对齐(精确几何,212px error;底对齐/中心对齐/绘图区内数据柱三类豁免,71 charts 模板回归误报清零)、layout_grid 偏离 215px error、行内 gap 不等 warning、无锁存量项目跨页左缘聚类漂移 warning、版式指纹单调门(≥3 页同指纹 warn、≥4 或过半 error;仅对 NN_ 编号 deck 页聚合,模板库静默);**D 策略纪律升级**——同一版式原型整本 ≤2 次 + 标题语义必须被图形兑现(SKILL.md 大纲纪律 + strategist visual-floor GATE)。顺手修 comparison_columns 模板胶囊 5px 错位。新增 tests/test_svg_alignment_check.py 21 项,全量 153 过。已知边界:页面平衡类(底部大空白/重心偏移,S18/S22)误报风险高未进 checker,只进阶段五验收 checklist 眼看;错位 error 会被导出边界自动质检门连带拦截,存量项目重导出若报新 error 属预期(真缺陷)。

View File

@ -21,11 +21,6 @@ image:
endpoint: /images/generations endpoint: /images/generations
price_cny_per_image: 0.22 # 计费单位:成功输出张数;调价改这里 + 重启 price_cny_per_image: 0.22 # 计费单位:成功输出张数;调价改这里 + 重启
default_size: 2048x2048 # 原生最高 3072x3072;2K 兼顾质量/体积 default_size: 2048x2048 # 原生最高 3072x3072;2K 兼顾质量/体积
# 输出尺寸面积约束(ARK 硬门):面积 < min_pixels → 400 InvalidParameter。
# 模型自选 16:9 之类小尺寸(如 1920x1080=2.07M)会栽,故 tool 侧等比钳到合法区间:
# min = 1920² = 3,686,400(16:9 最小合规即 2560x1440);max = 3072² = 9,437,184。
min_pixels: 3686400
max_pixels: 9437184
default_watermark: false # 默认无水印(申报/PPT 场景反需求) default_watermark: false # 默认无水印(申报/PPT 场景反需求)
default_search: false # web search 额外加价 ~¥0.05/张;默认关 default_search: false # web search 额外加价 ~¥0.05/张;默认关
request_timeout_s: 60 # 出图慢于此判超时 request_timeout_s: 60 # 出图慢于此判超时

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。 # 改版本只动这一行。
__version__ = "0.38.0" __version__ = "0.37.0"

View File

@ -100,7 +100,7 @@ description: 生成 PowerPoint 演示文稿 (.pptx) 文件。✅ 触发:用户
**逐页大纲**(写进 design_spec.md §IX,也是 spec_lock 的 page_rhythm/page_layouts 依据):**论断式标题 + 每页标节奏**(`anchor`/`dense`/`breathing`)。三条硬纪律(大纲阶段定死): **逐页大纲**(写进 design_spec.md §IX,也是 spec_lock 的 page_rhythm/page_layouts 依据):**论断式标题 + 每页标节奏**(`anchor`/`dense`/`breathing`)。三条硬纪律(大纲阶段定死):
- **论断标题**:写结论不写主题("渗透率破 60%" 不是 "行业背景"); - **论断标题**:写结论不写主题("渗透率破 60%" 不是 "行业背景");
- **节奏不雷同(整本 ≤2 次)**:相邻内容页不同版式,且**同一版式原型全 deck 最多 2 页**(图标卡网格 / 全宽横条列表 / **两栏裸文字列表**(图标小标题+下划线+文字堆 ×2、零图形 —— 一次真实交付里出现了 4 页)尤其;5 页"2×3 图标卡"哪怕文案不同也读作同一张片重复,真实翻车过);第 3 页起换形态(时间轴/分层/象限/流程/hub-spoke/图表)。narrative 真正停顿处插 `breathing`(单概念/金句/大图,**禁多卡网格**);不要为凑节奏造填充页;素材含 ≥3 组可比数值(规模/占比/趋势/阶段目标)→ **全本至少 1-2 页真数据图表**(bar/line/donut/进度条),大字 KPI 是强调不算图表,零数据图表要在 spec 写明理由; - **节奏不雷同(整本 ≤2 次)**:相邻内容页不同版式,且**同一版式原型全 deck 最多 2 页**(图标卡网格 / 全宽横条列表尤其 —— 5 页"2×3 图标卡"哪怕文案不同也读作同一张片重复,真实翻车过);第 3 页起换形态(时间轴/分层/象限/流程/hub-spoke/图表)。narrative 真正停顿处插 `breathing`(单概念/金句/大图,**禁多卡网格**);不要为凑节奏造填充页;
- **内容→版式映射(必须落到 spec,不能整本留空)**:历程→时间轴、循环→闭环、2-4 数字→KPI、并列→网格、单震撼数字→breathing 大字、≥3 数据点→图表(charts/ 模板或自绘);对比→象限/分栏、流程→process_flow、占比→donut、架构→分层、关系→hub_spoke。**标题语义必须被图形兑现**:标题写"架构"就画层块堆叠(不是等宽横条列表)、写"矩阵"就画真象限(不是卡片网格)、写"流程/层级"就有方向/层次 —— "五层架构"画成五条一样的横条是典型名不副实。每个能结构化的内容页都要在 spec_lock 的 `page_charts`/`page_layouts` 落一个视觉处理 —— **内容 deck 不许 page_charts + page_layouts 同时空着**(=啥图都没分配,执行层必堆文字方块)。视觉下限见 strategist.md「GATE — visual floor」;质检会硬卡"全是文字方块"的扁平 deck(见阶段四)。 - **内容→版式映射(必须落到 spec,不能整本留空)**:历程→时间轴、循环→闭环、2-4 数字→KPI、并列→网格、单震撼数字→breathing 大字、≥3 数据点→图表(charts/ 模板或自绘);对比→象限/分栏、流程→process_flow、占比→donut、架构→分层、关系→hub_spoke。**标题语义必须被图形兑现**:标题写"架构"就画层块堆叠(不是等宽横条列表)、写"矩阵"就画真象限(不是卡片网格)、写"流程/层级"就有方向/层次 —— "五层架构"画成五条一样的横条是典型名不副实。每个能结构化的内容页都要在 spec_lock 的 `page_charts`/`page_layouts` 落一个视觉处理 —— **内容 deck 不许 page_charts + page_layouts 同时空着**(=啥图都没分配,执行层必堆文字方块)。视觉下限见 strategist.md「GATE — visual floor」;质检会硬卡"全是文字方块"的扁平 deck(见阶段四)。
大纲连同 ah **一起给用户预览,⛔ BLOCKING 等确认整份结构**后再进阶段二(改文字比改 slide 便宜)。 大纲连同 ah **一起给用户预览,⛔ BLOCKING 等确认整份结构**后再进阶段二(改文字比改 slide 便宜)。
@ -142,7 +142,7 @@ references/visual-styles/<locked-style>.md # 锁定的视觉风格
``` ```
.venv/Scripts/python.exe <skill_dir>/scripts/svg_quality_checker.py <project_dir> .venv/Scripts/python.exe <skill_dir>/scripts/svg_quality_checker.py <project_dir>
``` ```
- **任何 `error`(禁用特性 / viewBox 不符 / spec_lock 漂移 / **图标压在文字上、文字基线超出画布、CJK 文字互相叠压**(Geometry 检测,几何精确)/ **兄弟卡片错位 212px、偏离 layout_grid 网格、正文越过 content_bottom 侵入页脚区、spec 指派了 page_charts 该页却零图形(图表被退化成文字)**(Alignment 检测,几何精确)/ **锁了图标 inventory 却全 deck 0 图标** / **内容 deck 全是文字方块(≥6 页且零 `<path>`/`<polygon>`/`<polyline>`/`<image>`)** / **≥4 页同版式指纹(单调门,含两栏裸文字列表)** 等)必须改:回阶段三重写该页再跑**,不放过。 - **任何 `error`(禁用特性 / viewBox 不符 / spec_lock 漂移 / **图标压在文字上、文字基线超出画布**(Geometry 检测,几何精确)/ **兄弟卡片错位 212px、偏离 layout_grid 网格**(Alignment 检测,几何精确)/ **锁了图标 inventory 却全 deck 0 图标** / **内容 deck 全是文字方块(≥6 页且零 `<path>`/`<polygon>`/`<polyline>`/`<image>`)** / **≥4 页同版式指纹(单调门)** 等)必须改:回阶段三重写该页再跑**,不放过。
- `warning`(低分辨率图 / 非 PPT 安全字体等):能顺手改就改,否则知会后放行。**例外:`Geometry:` 开头的文字重叠 warning 不许无视** —— 它给了精确坐标,是"大字压说明 / 同行文字互侵"的高嫌疑点(估宽无法区分擦边与压字,所以只报 warn),阶段五渲图时**必须对着该页该坐标专门看**,压了就返工。 - `warning`(低分辨率图 / 非 PPT 安全字体等):能顺手改就改,否则知会后放行。**例外:`Geometry:` 开头的文字重叠 warning 不许无视** —— 它给了精确坐标,是"大字压说明 / 同行文字互侵"的高嫌疑点(估宽无法区分擦边与压字,所以只报 warn),阶段五渲图时**必须对着该页该坐标专门看**,压了就返工。
- 跑 `svg_output/`(不要在 finalize 后跑 —— finalize 改写 SVG 会掩盖源级违规)。 - 跑 `svg_output/`(不要在 finalize 后跑 —— finalize 改写 SVG 会掩盖源级违规)。
- ⚠️ **别用 `| head` / `| tail` 截断质检输出**:管道会把脚本的非零退出码换成 `head` 的 0(门形同虚设),`head` 还会截掉打在**最后**的 deck 级门结论(如零图标 `[ERROR]`)。原样跑,读完整输出、认它的退出码。 - ⚠️ **别用 `| head` / `| tail` 截断质检输出**:管道会把脚本的非零退出码换成 `head` 的 0(门形同虚设),`head` 还会截掉打在**最后**的 deck 级门结论(如零图标 `[ERROR]`)。原样跑,读完整输出、认它的退出码。

View File

@ -182,8 +182,6 @@ Before drawing each page, look up its entry in `page_charts` to decide which cha
- **Sibling cards align exactly**: cards in one row share the same `y` and `height`; cards in one column share the same `x`; gaps in a row all equal `layout_grid.gutter`. Compute one set of constants per grid (`x = margin_x + i * (card_w + gutter)`) instead of placing each card by feel. Deliberate stagger (masonry) offsets by ≥16px. - **Sibling cards align exactly**: cards in one row share the same `y` and `height`; cards in one column share the same `x`; gaps in a row all equal `layout_grid.gutter`. Compute one set of constants per grid (`x = margin_x + i * (card_w + gutter)`) instead of placing each card by feel. Deliberate stagger (masonry) offsets by ≥16px.
- **Two-column layouts resolve the bottom edge**: either both columns end at the same y, or the shorter column is deliberately closed (vertical centering, a filler visual, a closing rule) — never one column dangling far above the other with dead whitespace. - **Two-column layouts resolve the bottom edge**: either both columns end at the same y, or the shorter column is deliberately closed (vertical centering, a filler visual, a closing rule) — never one column dangling far above the other with dead whitespace.
- **No glued glyphs on one line**: adjacent inline elements (arrow + number, numeral + unit, badge + label) keep ≥0.3em horizontal gap. An arrow touching a digit ("→02") reads as a typo. - **No glued glyphs on one line**: adjacent inline elements (arrow + number, numeral + unit, badge + label) keep ≥0.3em horizontal gap. An arrow touching a digit ("→02") reads as a typo.
- **Budget vertical space BEFORE writing the page**: items × row height must fit within `content_top`..`content_bottom`. If N items don't fit, cut items, tighten copy, or change layout — never compress row gaps until the last item's description lands in the footer zone (shipped failure: a 5-item list whose 5th description was clipped by the footer). The checker errors on any text baseline past `content_bottom`.
- **Never degrade an assigned chart to text**: when `spec_lock.page_charts` assigns this page a chart, the page MUST draw a real figure (adapt the named `templates/charts/` template). Rendering the numbers as a text list or big KPI type instead of the assigned chart is a checker-level error — quantitative evidence belongs in a figure, and the Strategist already decided this page gets one.
- **Template structure**: if templates exist, inherit the visual framework - **Template structure**: if templates exist, inherit the visual framework
- **Main-agent ownership**: SVG generation must run in the main agent (not sub-agents) — pages share upstream context for cross-page visual continuity - **Main-agent ownership**: SVG generation must run in the main agent (not sub-agents) — pages share upstream context for cross-page visual continuity
- **Generation rhythm**: lock global design context first, then generate pages sequentially in one continuous context. No batched groups (e.g., 5 at a time). - **Generation rhythm**: lock global design context first, then generate pages sequentially in one continuous context. No batched groups (e.g., 5 at a time).

View File

@ -650,8 +650,7 @@ The most common Strategist failure mode is missing the structural half — treat
> - Every **content page whose shape matches a catalog Pick clause** MUST get a visual treatment — a `page_charts` entry (chart / infographic template), a `page_layouts` structural template, or an explicit §VII custom-diagram plan (`no-template-match` with the figure described). Text-in-boxes is the fallback only for pages that genuinely carry no structurable shape, and you must be able to name why. > - Every **content page whose shape matches a catalog Pick clause** MUST get a visual treatment — a `page_charts` entry (chart / infographic template), a `page_layouts` structural template, or an explicit §VII custom-diagram plan (`no-template-match` with the figure described). Text-in-boxes is the fallback only for pages that genuinely carry no structurable shape, and you must be able to name why.
> - **`spec_lock.md` MUST NOT ship a content deck with `page_charts` empty AND `page_layouts` empty/free-design AND no §VII custom-diagram rows.** That combination means no visual was assigned anywhere — re-scan §IX and map each content page's shape to a figure (comparison→columns/quadrant, process/历程→timeline/process_flow, cycle/循环→concentric/segmented_wheel, share→donut/pie, trend→line/area, ranking→bar, architecture→layered_architecture, relations→hub_spoke/mind_map). > - **`spec_lock.md` MUST NOT ship a content deck with `page_charts` empty AND `page_layouts` empty/free-design AND no §VII custom-diagram rows.** That combination means no visual was assigned anywhere — re-scan §IX and map each content page's shape to a figure (comparison→columns/quadrant, process/历程→timeline/process_flow, cycle/循环→concentric/segmented_wheel, share→donut/pie, trend→line/area, ranking→bar, architecture→layered_architecture, relations→hub_spoke/mind_map).
> - Downstream enforcement: `svg_quality_checker.py` **hard-fails** any deck (≥6 text-heavy pages) whose Executor output has zero `<path>`/`<polygon>`/`<polyline>`/`<image>` deck-wide. Leaving the visual plan empty here guarantees that failure later — assign the figures now, at the spec stage, where it is cheapest to change. > - Downstream enforcement: `svg_quality_checker.py` **hard-fails** any deck (≥6 text-heavy pages) whose Executor output has zero `<path>`/`<polygon>`/`<polyline>`/`<image>` deck-wide. Leaving the visual plan empty here guarantees that failure later — assign the figures now, at the spec stage, where it is cheapest to change.
> - **Archetype cap (anti-monotony)**: the same layout archetype — especially the icon+title+text card grid, the full-width stacked row list, and the **two-column bare text list** (icon header + underline + text stack ×2, zero figures — a shipped deck carried FOUR of these) — may carry at most **2 pages per deck**, adjacent or not. Five "2×3 icon grid" pages read as the same slide repeated even when the copy differs (real shipped failure). When §IX assigns a third page the same shape, rework it into a different visual form via the mapping above. Downstream: the checker warns at 3 same-fingerprint pages and errors at 4. > - **Archetype cap (anti-monotony)**: the same layout archetype — especially the icon+title+text card grid and the full-width stacked row list — may carry at most **2 pages per deck**, adjacent or not. Five "2×3 icon grid" pages read as the same slide repeated even when the copy differs (real shipped failure). When §IX assigns a third page the same shape, rework it into a different visual form via the mapping above. Downstream: the checker warns at 3 same-fingerprint pages and errors at 4.
> - **Data-chart floor**: when the source material carries ≥3 comparable quantities (scale numbers, shares, trends, staged targets — e.g. 100万条→500万条, 能耗降10-20%, 碳排占26%), the deck MUST include at least **1-2 real data charts** (bar / line / donut / progress — from `templates/charts/` or custom §VII). A quantitative deck that ships zero data charts wastes its strongest evidence as prose (real shipped failure); if you deliberately assign none, write the reason into design_spec §VII. Numbers rendered as big KPI type count as emphasis, not as a chart.
> - **Title semantics must be honored by the layout**: a page titled 架构/分层 gets stacked layers (NOT equal-width row bars); 矩阵 gets a real 2×2 quadrant (NOT a card grid); 流程 gets a directional flow; 层级 gets a pyramid/tree. Shipping a "五层架构" page drawn as five identical list rows is the canonical semantic mismatch — the title promises a figure the page never draws. > - **Title semantics must be honored by the layout**: a page titled 架构/分层 gets stacked layers (NOT equal-width row bars); 矩阵 gets a real 2×2 quadrant (NOT a card grid); 流程 gets a directional flow; 层级 gets a pyramid/tree. Shipping a "五层架构" page drawn as five identical list rows is the canonical semantic mismatch — the title promises a figure the page never draws.
> **Reading is mandatory; the catalog is a starting point, not a copy target.** > **Reading is mandatory; the catalog is a starting point, not a copy target.**

View File

@ -1602,12 +1602,10 @@ class SVGQualityChecker:
x0 = x x0 = x
joined = ''.join(t for t, _ in runs) joined = ''.join(t for t, _ in runs)
label = joined if len(joined) <= 12 else joined[:12] + '' label = joined if len(joined) <= 12 else joined[:12] + ''
cjk = (sum(1 for ch in joined if self._est_char_w(ch) == 1.0)
/ len(joined) if joined else 0.0)
return { return {
'x0': x0 + tx, 'y0': y - 0.76 * fs + ty, 'x0': x0 + tx, 'y0': y - 0.76 * fs + ty,
'x1': x0 + w + tx, 'y1': y + 0.22 * fs + ty, 'x1': x0 + w + tx, 'y1': y + 0.22 * fs + ty,
'fs': fs, 'label': label, 'cjk': cjk, 'fs': fs, 'label': label,
'baseline': y + ty, 'anchor_x': x + tx, 'baseline': y + ty, 'anchor_x': x + tx,
'exact_left': anchor not in ('middle', 'end'), 'exact_left': anchor not in ('middle', 'end'),
} }
@ -1787,24 +1785,14 @@ class SVGQualityChecker:
min_area = min((a['x1'] - a['x0']) * (a['y1'] - a['y0']), min_area = min((a['x1'] - a['x0']) * (a['y1'] - a['y0']),
(b['x1'] - b['x0']) * (b['y1'] - b['y0'])) (b['x1'] - b['x0']) * (b['y1'] - b['y0']))
ratio = (iw * ih) / min_area if min_area > 0 else 0 ratio = (iw * ih) / min_area if min_area > 0 else 0
# Text-text overlaps normally cap at WARNING: the width # Text-text overlaps cap at WARNING: the width estimate can't
# estimate can't tell a crash from a deliberate graze # tell a crash from a deliberate graze (quadrant captions,
# (quadrant captions, word clouds, tightly-kerned # word clouds, tightly-kerned numeral+suffix pairs all overlap
# numeral+suffix pairs all overlap estimated boxes # estimated boxes legitimately). The warning carries exact
# legitimately). The warning carries exact coordinates so the # coordinates so the render-acceptance pass knows which spot
# render-acceptance pass knows which spot to eyeball. # to eyeball; icon-on-text and off-canvas below stay errors
# EXCEPTION — CJK-on-CJK deep overlap is an ERROR: ideograph # because their geometry is exact.
# advance is a fixed 1.0em, so for runs that are mostly CJK if ratio >= 0.15 or same_line:
# the estimate is near-exact and a >=50% mutual overlap is a
# real crash, not noise (a shipped deck had a red display
# line sitting straight on top of its gray caption).
if ratio >= 0.5 and a['cjk'] >= 0.7 and b['cjk'] >= 0.7:
errors.append(
f"CJK text \"{a['label']}\" and \"{b['label']}\" are stacked "
f"on top of each other (~{ratio * 100:.0f}% overlap at "
f"({max(a['x0'], b['x0']):.0f},{max(a['y0'], b['y0']):.0f})) "
f"— CJK width estimation is near-exact; separate them")
elif ratio >= 0.15 or same_line:
warnings.append( warnings.append(
f"text \"{a['label']}\" and \"{b['label']}\" overlap " f"text \"{a['label']}\" and \"{b['label']}\" overlap "
f"(~{ratio * 100:.0f}% of the smaller run, around " f"(~{ratio * 100:.0f}% of the smaller run, around "
@ -1919,34 +1907,25 @@ class SVGQualityChecker:
return abs(a - b) <= max(floor, rel * max(a, b)) return abs(a - b) <= max(floor, rel * max(a, b))
@staticmethod @staticmethod
def _clusters(values, tol): def _cluster_values(values, tol):
"""Cluster sorted scalars; returns list of member lists."""
groups = []
for v in sorted(values):
if groups and v - groups[-1][-1] <= tol:
groups[-1].append(v)
else:
groups.append([v])
return groups
@classmethod
def _cluster_values(cls, values, tol):
"""Cluster sorted scalars; returns list of cluster-center floats.""" """Cluster sorted scalars; returns list of cluster-center floats."""
return [sum(c) / len(c) for c in cls._clusters(values, tol)] centers = []
for v in sorted(values):
if centers and v - centers[-1][-1] <= tol:
centers[-1].append(v)
else:
centers.append([v])
return [sum(c) / len(c) for c in centers]
def _layout_fingerprint(self, cards, icons, texts, graphic_count): def _layout_fingerprint(self, cards, icons):
"""Classify the page's dominant layout archetype, or None. """Classify the page's dominant grid archetype, or None.
icon-grid: >=4 icons arranged in a >=2x2 grid (the icon+title+text icon-grid: >=4 icons arranged in a >=2x2 grid (the icon+title+text
card pattern usually no visible card rect, so icons carry the card pattern usually no visible card rect, so icons carry the
structure). card-grid: >=4 similar-sized card rects in a grid, or a structure). card-grid: >=4 similar-sized card rects in a grid, or a
>=4-row single-column stack (full-width list rows). text-columns: >=4-row single-column stack (full-width list rows). Pages without a
bare text lists in >=2 columns with no cards and no figure the dominant grid (covers, chapters, diagrams, timelines) get None and
"icon header + underline + text list" two-column page that reads as never count toward monotony.
wall-of-text (invisible to the two grid archetypes above; a shipped
deck carried FOUR of these). Pages without a dominant archetype
(covers, chapters, diagrams, timelines) get None and never count
toward monotony.
""" """
if len(icons) >= 4: if len(icons) >= 4:
rows = self._cluster_values([i['y0'] for i in icons], self._CLUSTER_TOL) rows = self._cluster_values([i['y0'] for i in icons], self._CLUSTER_TOL)
@ -1970,14 +1949,6 @@ class SVGQualityChecker:
and (min(len(rows), len(cols)) >= 2 and (min(len(rows), len(cols)) >= 2
or (len(cols) == 1 and len(rows) >= 4)): or (len(cols) == 1 and len(rows) >= 4)):
return ('card-grid', len(rows), len(cols)) return ('card-grid', len(rows), len(cols))
# Text-columns: no cards, no figure, few icons — the structure is
# bare left-anchored text stacked in columns.
if len(cards) <= 1 and len(icons) <= 3 and graphic_count <= 2:
xs = [t['x0'] for t in texts if t.get('exact_left')]
col_sizes = [len(g) for g in self._clusters(xs, self._CLUSTER_TOL)
if len(g) >= 3]
if len(col_sizes) >= 2 and sum(col_sizes) >= 8:
return ('text-columns', len(col_sizes), 0)
return None return None
def _check_alignment(self, content: str, svg_path: Path, result: Dict) -> None: def _check_alignment(self, content: str, svg_path: Path, result: Dict) -> None:
@ -2155,45 +2126,6 @@ class SVGQualityChecker:
f"{dev:.0f}px off the locked footer_y={footer_y:.0f}") f"{dev:.0f}px off the locked footer_y={footer_y:.0f}")
break break
# 3b. Content spilling into the footer zone. With content_bottom
# locked, any non-footer text whose baseline lands between
# content_bottom and the canvas edge is content that didn't fit
# (a shipped deck had a list item's description clipped by the
# footer). Footer runs themselves (baseline ~ footer_y) and page
# numbers are exempt.
content_bottom = self._f(grid.get('content_bottom'))
if content_bottom is not None and not structural:
for t in texts:
bl = t['baseline']
if bl <= content_bottom + self._ALIGN_TOL or bl > canvas_h + 1:
continue # in-bounds, or already an off-canvas error
if footer_y is not None and abs(bl - footer_y) <= 8:
continue # the footer line itself
errors.append(
f"text \"{t['label']}\" baseline y={bl:.0f} spills past the locked "
f"content_bottom={content_bottom:.0f} into the footer zone — the "
f"content doesn't fit; cut items or rework the layout")
break
# 3c. Spec-assigned figure enforcement: spec_lock's page_charts says
# this page carries a chart, but the SVG draws no figure — the
# executor degraded an assigned visualization into text (shipped
# failure: a whole deck of quantitative content with zero data
# charts). Figure evidence: >=3 drawing primitives, or >=4
# card-size rects (bar charts are mostly <rect>).
graphic_count = len(re.findall(
r'<(?:path|polygon|polyline|circle|image)\b', content))
m_num = self._PAGE_NUM_RE.match(svg_path.name)
if m_num and lock:
charts_map = lock.get('page_charts') or {}
assigned = (charts_map.get(f'P{int(m_num.group(1)):02d}') or '').strip()
if assigned and graphic_count < 3 and len(cards) < 4:
errors.append(
f"spec_lock page_charts assigns '{assigned}' to this page but the "
f"SVG draws no figure ({graphic_count} drawing primitives, "
f"{len(cards)} cards) — the assigned chart was degraded to text; "
f"draw it (adapt templates/charts/{assigned}.svg)")
# 4. Deck aggregation: primary left edge (margin-drift fallback when no # 4. Deck aggregation: primary left edge (margin-drift fallback when no
# layout_grid is locked) + layout-archetype fingerprint (monotony). # layout_grid is locked) + layout-archetype fingerprint (monotony).
# Only numbered deck pages (NN_*.svg) participate — a directory of # Only numbered deck pages (NN_*.svg) participate — a directory of
@ -2205,7 +2137,7 @@ class SVGQualityChecker:
edge_candidates = [x for x in edge_candidates if 0 < x < 0.25 * canvas_w] edge_candidates = [x for x in edge_candidates if 0 < x < 0.25 * canvas_w]
if len(texts) >= 4 and edge_candidates: if len(texts) >= 4 and edge_candidates:
self._page_left_edges[svg_path.name] = min(edge_candidates) self._page_left_edges[svg_path.name] = min(edge_candidates)
fp = self._layout_fingerprint(cards, icons, texts, graphic_count) fp = self._layout_fingerprint(cards, icons)
if fp: if fp:
self._page_fingerprints[svg_path.name] = fp self._page_fingerprints[svg_path.name] = fp
@ -2259,8 +2191,7 @@ class SVGQualityChecker:
by_family[fp[0]].append(name) by_family[fp[0]].append(name)
fp, members = max(by_fp.items(), key=lambda kv: len(kv[1])) fp, members = max(by_fp.items(), key=lambda kv: len(kv[1]))
n = len(members) n = len(members)
label = (f"{fp[1]}-column bare-text-list" if fp[0] == 'text-columns' label = f"{fp[1]}x{fp[2]} {fp[0]}"
else f"{fp[1]}x{fp[2]} {fp[0]}")
_fix = ("Rework all but 1-2 of them into a different visual form — " _fix = ("Rework all but 1-2 of them into a different visual form — "
"timeline / layered architecture / quadrant / process flow / " "timeline / layered architecture / quadrant / process flow / "
"hub-spoke / chart (templates/charts/) — per the content->layout " "hub-spoke / chart (templates/charts/) — per the content->layout "

View File

@ -15,7 +15,6 @@
## layout_grid ## layout_grid
- margin_x: 60 - margin_x: 60
- content_top: 150 - content_top: 150
- content_bottom: 650
- footer_y: 688 - footer_y: 688
- gutter: 24 - gutter: 24
@ -23,7 +22,6 @@
> >
> - `margin_x` — left/right content margin. Header title, content blocks, and footer on content pages all share this left edge. > - `margin_x` — left/right content margin. Header title, content blocks, and footer on content pages all share this left edge.
> - `content_top` — top edge of the body content zone on content pages (below the header band). Structural pages (cover / chapter / TOC / ending) are exempt. > - `content_top` — top edge of the body content zone on content pages (below the header band). Structural pages (cover / chapter / TOC / ending) are exempt.
> - `content_bottom` — bottom edge of the body content zone. Any non-footer text baseline below it is content that didn't fit (checker errors — a shipped deck had a list item's description clipped by the footer). The Executor budgets vertical space against this before writing a page: items × row height must fit content_top..content_bottom, else cut items or change layout.
> - `footer_y` — footer baseline. Omit if the deck has no footer. > - `footer_y` — footer baseline. Omit if the deck has no footer.
> - `gutter` — gap between side-by-side sibling cards in a row/grid. One value deck-wide. > - `gutter` — gap between side-by-side sibling cards in a row/grid. One value deck-wide.
> >

View File

@ -235,100 +235,6 @@ class DeckAggregationTests(unittest.TestCase):
_, out = self._run_deck(pages) _, out = self._run_deck(pages)
self.assertNotIn("Layout monotony", out) self.assertNotIn("Layout monotony", out)
@staticmethod
def _text_columns_page() -> str:
# "图标小标题+文字列表 ×2 栏"零图形页(d1285247 二代产物 S8/S9/S16/S21 形态)
body = ""
for col_x in (66, 690):
for i in range(5):
body += _text(col_x, 300 + 40 * i, f"要点第{i}行文字")
return body
def test_four_bare_text_column_pages_is_error(self):
pages = {f"{i:02d}_t.svg": self._text_columns_page() for i in range(1, 5)}
pages["05_d.svg"] = self._diagram_page(1)
pages["06_e.svg"] = self._diagram_page(2)
_, out = self._run_deck(pages)
self.assertIn("[ERROR] Layout monotony", out)
self.assertIn("bare-text-list", out)
def test_text_columns_with_diagram_not_fingerprinted(self):
# 同样的文字栏但页上有真图形(≥3 基元,如时间轴线+节点)→ 不算裸文字页
timeline = ('<path d="M 100 250 L 1100 250" stroke="#C00000" fill="none"/>'
'<circle cx="300" cy="250" r="6" fill="#C00000"/>'
'<circle cx="700" cy="250" r="6" fill="#C00000"/>')
pages = {f"{i:02d}_t.svg": self._text_columns_page() + timeline
for i in range(1, 5)}
pages["05_d.svg"] = self._diagram_page(5)
pages["06_e.svg"] = self._diagram_page(6)
_, out = self._run_deck(pages)
self.assertNotIn("bare-text-list", out)
class SpecContractTests(unittest.TestCase):
"""spec 指派图表落空 + content_bottom 越界 + CJK 叠压升级。"""
def _check(self, body: str, lock: str, name="02_content.svg"):
with TemporaryDirectory() as tmp:
project = Path(tmp)
(project / "spec_lock.md").write_text(lock, encoding="utf-8")
page = _write_page(project, name, body)
return SVGQualityChecker().check_file(str(page))
def test_assigned_chart_degraded_to_text_is_error(self):
lock = "## page_charts\n- P02: bar_chart\n"
body = "".join(_text(66, 200 + 40 * i, f"目标{i}") for i in range(6))
r = self._check(body, lock)
errs = _alignment_errors(r)
self.assertTrue(any("page_charts" in e and "bar_chart" in e for e in errs),
errs)
def test_assigned_chart_with_figure_passes(self):
lock = "## page_charts\n- P02: line_chart\n"
body = ('<path d="M 100 400 L 400 300 L 700 350" stroke="#C00000" fill="none"/>'
'<circle cx="400" cy="300" r="5" fill="#C00000"/>'
'<circle cx="700" cy="350" r="5" fill="#C00000"/>')
r = self._check(body, lock)
self.assertFalse(any("page_charts" in e for e in _alignment_errors(r)))
def test_unassigned_text_page_no_chart_error(self):
lock = "## page_charts\n- P05: bar_chart\n"
body = "".join(_text(66, 200 + 40 * i, f"要点{i}") for i in range(6))
r = self._check(body, lock)
self.assertFalse(any("page_charts" in e for e in _alignment_errors(r)))
def test_content_spills_past_content_bottom_is_error(self):
lock = "## layout_grid\n- content_bottom: 650\n- footer_y: 686\n"
body = _text(66, 676, "被裁掉的描述文字") + _text(66, 686, "页脚")
r = self._check(body, lock)
errs = _alignment_errors(r)
self.assertTrue(any("content_bottom" in e for e in errs), errs)
def test_footer_text_near_footer_y_exempt(self):
lock = "## layout_grid\n- content_bottom: 650\n- footer_y: 686\n"
body = _text(66, 686, "页脚文字") + _text(1150, 690, "12 / 25")
r = self._check(body, lock)
self.assertFalse(any("content_bottom" in e for e in _alignment_errors(r)))
def test_cjk_deep_overlap_is_error(self):
with TemporaryDirectory() as tmp:
page = _write_page(
Path(tmp), "03_content.svg",
'<text x="400" y="500" font-size="22" fill="#C00000">中国陶瓷碳指数之都</text>'
'<text x="405" y="503" font-size="14" fill="#666666">不仅是千年瓷都更是权威发布地</text>')
r = SVGQualityChecker().check_file(str(page))
self.assertTrue(any("Geometry:" in e and "CJK" in e for e in r["errors"]),
r["errors"])
def test_latin_overlap_stays_warning(self):
with TemporaryDirectory() as tmp:
page = _write_page(
Path(tmp), "03_content.svg",
'<text x="400" y="500" font-size="22" fill="#C00000">Total Revenue 2027</text>'
'<text x="405" y="503" font-size="14" fill="#666666">annual growth rate</text>')
r = SVGQualityChecker().check_file(str(page))
self.assertFalse(any("CJK" in e for e in r["errors"]))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -138,15 +138,7 @@ class SeedreamTool(Tool):
cfg = self.cfg cfg = self.cfg
model_id = cfg["model_id"] model_id = cfg["model_id"]
requested_size = size or cfg.get("default_size", "2048x2048") chosen_size = size or cfg.get("default_size", "2048x2048")
# ARK 硬门:输出面积必须落在 [min_pixels, max_pixels],否则 400 InvalidParameter。
# 模型自选 16:9 之类小尺寸(1920x1080=2.07M < 3.69M)会被打回,这里等比钳到合法区间,
# 静默纠错省一轮往返;已合规的尺寸原样透传。归一化时给用户一行提示。
chosen_size, size_note = self._normalize_size(
requested_size,
min_pixels=int(cfg.get("min_pixels", 0)),
max_pixels=int(cfg.get("max_pixels", 0)),
)
chosen_watermark = bool(cfg.get("default_watermark", False)) if watermark is None else bool(watermark) chosen_watermark = bool(cfg.get("default_watermark", False)) if watermark is None else bool(watermark)
chosen_search = bool(cfg.get("default_search", False)) if search is None else bool(search) chosen_search = bool(cfg.get("default_search", False)) if search is None else bool(search)
timeout_s = float(cfg.get("request_timeout_s", 60)) timeout_s = float(cfg.get("request_timeout_s", 60))
@ -193,7 +185,6 @@ class SeedreamTool(Tool):
"prompt": prompt, "prompt": prompt,
"model_id": model_id, "model_id": model_id,
"size": chosen_size, "size": chosen_size,
"requested_size": requested_size, # 归一化前模型/用户请求的原始尺寸(=chosen_size 表示未钳)
"watermark": chosen_watermark, "watermark": chosen_watermark,
"search": chosen_search, "search": chosen_search,
"mode": "i2i" if is_i2i else "t2i", "mode": "i2i" if is_i2i else "t2i",
@ -228,75 +219,14 @@ class SeedreamTool(Tool):
# 格式严格 key=value · 分隔,parse 用正则 `key=([^·\n]+)` 抓。 # 格式严格 key=value · 分隔,parse 用正则 `key=([^·\n]+)` 抓。
mode_seg = " · mode=i2i" if is_i2i else "" mode_seg = " · mode=i2i" if is_i2i else ""
ref_line = f"\nreference={ref_disp[0]}" if is_i2i else "" ref_line = f"\nreference={ref_disp[0]}" if is_i2i else ""
note_line = f"\n{size_note}" if size_note else ""
return ( return (
f"[seedream] model={model_id} · size={chosen_size} · " f"[seedream] model={model_id} · size={chosen_size} · "
f"cost=¥{cost_cny:.2f} · elapsed={elapsed:.1f}s{mode_seg}\n" f"cost=¥{cost_cny:.2f} · elapsed={elapsed:.1f}s{mode_seg}\n"
f"saved: {disp}{ref_line}\n" f"saved: {disp}{ref_line}\n"
f"prompt={prompt!r}\n" f"prompt={prompt!r}\n"
f"watermark={chosen_watermark} search={chosen_search}{note_line}" f"watermark={chosen_watermark} search={chosen_search}"
) )
@staticmethod
def _normalize_size(
requested: str, *, min_pixels: int = 0, max_pixels: int = 0
) -> tuple[str, str]:
"""把请求尺寸钳进 ARK 面积约束 [min_pixels, max_pixels],保持宽高比。
返回 (chosen_size, note):note 非空表示发生了钳制(用于提示用户 + 记账用真实尺寸)
- 无法解析成 "WxH" / 任一边 <= 0 原样返回,不阻塞(交给 API 自己报错,行为不回退)
- min/max 0 视为不设该侧约束(向后兼容: yaml 无这两个键时不改变行为)
- 面积 < min: s=sqrt(min/area) 等比放大,两边向上取整到 8 的倍数,复核达标(不够再 +8)
- 面积 > max: s=sqrt(max/area) 等比缩小,两边向下取整到 8 的倍数,复核达标(超了再 -8)
- 已在区间内 原样透传,note 为空
"""
raw = (requested or "").strip().lower().replace(" ", "")
parts = raw.split("x")
if len(parts) != 2:
return requested, ""
try:
w, h = int(parts[0]), int(parts[1])
except ValueError:
return requested, ""
if w <= 0 or h <= 0:
return requested, ""
import math
def _round8(v: float, *, up: bool) -> int:
n = math.ceil(v / 8) if up else math.floor(v / 8)
return max(8, n * 8)
area = w * h
if min_pixels > 0 and area < min_pixels:
s = math.sqrt(min_pixels / area)
nw, nh = _round8(w * s, up=True), _round8(h * s, up=True)
# 取整可能把面积压回下限之下,补到达标为止(沿较长边加 8,尽量不破坏比例)
while nw * nh < min_pixels:
if nw >= nh:
nh += 8
else:
nw += 8
chosen = f"{nw}x{nh}"
return chosen, (
f"[note] 请求尺寸 {w}x{h}({area:,}px)低于模型最小面积 {min_pixels:,}px,"
f"已等比放大到 {chosen} 出图。"
)
if max_pixels > 0 and area > max_pixels:
s = math.sqrt(max_pixels / area)
nw, nh = _round8(w * s, up=False), _round8(h * s, up=False)
while nw * nh > max_pixels:
if nw >= nh:
nw -= 8
else:
nh -= 8
chosen = f"{nw}x{nh}"
return chosen, (
f"[note] 请求尺寸 {w}x{h}({area:,}px)超过模型最大面积 {max_pixels:,}px,"
f"已等比缩小到 {chosen} 出图。"
)
return requested, ""
@staticmethod @staticmethod
def _extract_url(resp: dict) -> tuple[str, str]: def _extract_url(resp: dict) -> tuple[str, str]:
"""ark images/generations 响应解析,容忍几种已知 shape: """ark images/generations 响应解析,容忍几种已知 shape:

View File

@ -770,48 +770,12 @@ function isCurrentTaskStreaming() {
return !!getLiveRun(state.taskId); 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) { function createLiveAssistantCard(run) {
const card = document.createElement("div"); const card = document.createElement("div");
card.className = "msg assistant live-run"; card.className = "msg assistant live-run";
card.innerHTML = `<div class="role">助手</div>`; card.innerHTML = `<div class="role">助手</div><div class="body streaming">${run.acc ? renderMd(run.acc) : ""}</div>`;
run.card = card; run.card = card;
run.curSeg = null; run.body = card.querySelector(".body");
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; return card;
} }
@ -822,9 +786,8 @@ function renderLiveRunIfVisible() {
return; return;
} }
const wrap = $("chat-stream"); const wrap = $("chat-stream");
// card 已持有全部文字段/工具卡 DOM(切走再切回只需重新挂载,不重渲);
// 新建的重连 card 由 createLiveAssistantCard 自行渲染已累积文字。
const card = run.card || createLiveAssistantCard(run); const card = run.card || createLiveAssistantCard(run);
if (run.body && run.acc) run.body.innerHTML = renderMd(run.acc);
renderTaskProgressDock(run.progressSteps || []); renderTaskProgressDock(run.progressSteps || []);
if (card.parentElement !== wrap) wrap.appendChild(card); if (card.parentElement !== wrap) wrap.appendChild(card);
wrap.scrollTop = wrap.scrollHeight; wrap.scrollTop = wrap.scrollHeight;
@ -838,10 +801,11 @@ function ensureRunningTaskSubscribed(taskId, url) {
taskId, taskId,
url, url,
acc: "", acc: "",
pending: false,
seenRels: new Set(), seenRels: new Set(),
terminal: false, terminal: false,
card: null, card: null,
curSeg: null, body: null,
cancelling: state.taskMeta && state.taskMeta.run_status === "cancelling", cancelling: state.taskMeta && state.taskMeta.run_status === "cancelling",
workingDir: state.taskMeta && state.taskMeta.working_dir, workingDir: state.taskMeta && state.taskMeta.working_dir,
progressSteps: cloneProgressSteps(state.taskProgressByTask.get(taskId)), progressSteps: cloneProgressSteps(state.taskProgressByTask.get(taskId)),
@ -1385,16 +1349,15 @@ async function sendMessage(overrideText) {
taskId, taskId,
url: r.events_url, url: r.events_url,
acc: "", acc: "",
pending: false,
seenRels: new Set(), seenRels: new Set(),
terminal: false, terminal: false,
card: asstCard, card: asstCard,
curSeg: null, body: asstCard.querySelector(".body"),
cancelling: false, cancelling: false,
workingDir: state.taskMeta && state.taskMeta.working_dir, workingDir: state.taskMeta && state.taskMeta.working_dir,
progressSteps: cloneProgressSteps(state.taskProgressByTask.get(taskId)), progressSteps: cloneProgressSteps(state.taskProgressByTask.get(taskId)),
}; };
// 预建的空占位 .body 即首个文字段(首字到达前显示「思考中」)
run.curSeg = { el: asstCard.querySelector(".body"), acc: "", pending: false };
state.liveRuns.set(taskId, run); state.liveRuns.set(taskId, run);
state.streaming = true; state.streaming = true;
setActionMode("streaming"); setActionMode("streaming");
@ -1452,6 +1415,8 @@ function appendRunError(run, msg) {
} }
async function fetchSse(url, run) { async function fetchSse(url, run) {
const body = run.body || (run.card && run.card.querySelector(".body"));
run.body = body;
const ctx = run; const ctx = run;
const hint = $("chat-hint"); const hint = $("chat-hint");
// 重连:reader 异常 / 自然 EOF 但未收到 done/error 时,GET events 重订阅。 // 重连:reader 异常 / 自然 EOF 但未收到 done/error 时,GET events 重订阅。
@ -1485,15 +1450,13 @@ async function fetchSse(url, run) {
await new Promise(r => setTimeout(r, backoffs[attempt])); await new Promise(r => setTimeout(r, backoffs[attempt]));
attempt++; attempt++;
} }
// 最终定稿 + 代码高亮(流式中不 highlight,省 CPU):定稿当前打开的文字段, // 最终定稿 + 代码高亮(流式中不 highlight,省 CPU)
// 已被工具关闭的历史段在 closeTextSeg 时已定稿 + 高亮。 if (ctx.body) {
if (ctx.curSeg && ctx.curSeg.el) { ctx.body.innerHTML = renderMd(ctx.acc);
if (ctx.curSeg.acc) ctx.curSeg.el.innerHTML = renderMd(ctx.curSeg.acc); if (ctx.card) highlightIn(ctx.card);
else ctx.curSeg.el.remove(); // 收尾时仍是空占位段 → 移除
} }
if (ctx.card) highlightIn(ctx.card);
} finally { } finally {
if (ctx.card) ctx.card.querySelectorAll(".body.streaming").forEach((b) => b.classList.remove("streaming")); if (ctx.body) ctx.body.classList.remove("streaming");
state.liveRuns.delete(ctx.taskId); state.liveRuns.delete(ctx.taskId);
state.streaming = state.liveRuns.size > 0; state.streaming = state.liveRuns.size > 0;
if (state.taskId === ctx.taskId) { if (state.taskId === ctx.taskId) {
@ -1593,6 +1556,7 @@ function handleSseEvent(ev, asstCard, ctx) {
const t = ev.event; const t = ev.event;
asstCard = asstCard || ctx.card || createLiveAssistantCard(ctx); asstCard = asstCard || ctx.card || createLiveAssistantCard(ctx);
ctx.card = asstCard; ctx.card = asstCard;
ctx.body = ctx.body || asstCard.querySelector(".body");
const stream = $("chat-stream"); const stream = $("chat-stream");
const visible = state.taskId === ctx.taskId; const visible = state.taskId === ctx.taskId;
// 用户拖到上面看历史时不抢滚动,只在贴底时跟流 // 用户拖到上面看历史时不抢滚动,只在贴底时跟流
@ -1605,15 +1569,12 @@ function handleSseEvent(ev, asstCard, ctx) {
setRunHint(ctx, ctx.lastUsageHint); setRunHint(ctx, ctx.lastUsageHint);
} else if (t === "text" && ev.data && ev.data.delta) { } else if (t === "text" && ev.data && ev.data.delta) {
ctx.acc += ev.data.delta; ctx.acc += ev.data.delta;
const seg = ensureTextSeg(ctx); // 无打开文字段则在卡片底部另起一段 // rAF 节流:每帧最多 1 次重渲染,流式 token 高频时不抖
seg.acc += ev.data.delta; if (!ctx.pending) {
// rAF 节流:每帧最多 1 次重渲染,流式 token 高频时不抖。闭包捕获 seg —— 若 rAF ctx.pending = true;
// 触发前来了工具调用把当前段关掉,仍渲染这一段自己的累积文本,不会错渲到别的段。
if (!seg.pending) {
seg.pending = true;
requestAnimationFrame(() => { requestAnimationFrame(() => {
seg.el.innerHTML = renderMd(seg.acc); ctx.body.innerHTML = renderMd(ctx.acc);
seg.pending = false; ctx.pending = false;
if (nearBottom) stream.scrollTop = stream.scrollHeight; if (nearBottom) stream.scrollTop = stream.scrollHeight;
}); });
} }
@ -1624,9 +1585,8 @@ function handleSseEvent(ev, asstCard, ctx) {
if (fn === "task_progress") { if (fn === "task_progress") {
ctx.progressSteps = applyProgressAction(ctx.progressSteps || [], args); ctx.progressSteps = applyProgressAction(ctx.progressSteps || [], args);
setTaskProgress(ctx.taskId, ctx.progressSteps); setTaskProgress(ctx.taskId, ctx.progressSteps);
return; // 进度是隐形动作,不落可见卡 → 不打断当前文字段 return;
} }
closeTextSeg(ctx); // 关闭当前文字段:工具/选项卡追加到其下方,之后新文字另起底部段
if (fn === "ask_user") { if (fn === "ask_user") {
asstCard.appendChild(buildAskUserCard(args, { interactive: true })); asstCard.appendChild(buildAskUserCard(args, { interactive: true }));
if (nearBottom) stream.scrollTop = stream.scrollHeight; if (nearBottom) stream.scrollTop = stream.scrollHeight;
@ -1654,7 +1614,6 @@ function handleSseEvent(ev, asstCard, ctx) {
const toolName = (ev.data && ev.data.name) || ""; const toolName = (ev.data && ev.data.name) || "";
if (toolName === "task_progress") return; if (toolName === "task_progress") return;
if (toolName === "ask_user") return; // 选项卡已在 tool_call 阶段渲染,结果是占位不展示 if (toolName === "ask_user") return; // 选项卡已在 tool_call 阶段渲染,结果是占位不展示
closeTextSeg(ctx); // 结果卡追加到当前文字段之下,之后新文字另起底部段
const banner = extractMediaBanner(toolName, txtStr); const banner = extractMediaBanner(toolName, txtStr);
const det = document.createElement("details"); const det = document.createElement("details");
det.className = "tool-call"; det.className = "tool-call";