Compare commits

...

116 Commits

Author SHA1 Message Date
caoqianming d24165a2fe style(web): 列表状态灯挪到文件夹行左侧,数据行 space-between 均匀分布(bump 0.38.8)
终态徽章 + 运行圆点放进文件夹行行首(无文件夹行回落数据行,patch 逻辑同规则
找 host);底部数据行剩纯数据均匀铺开,时间自然落行尾。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:58:56 +08:00
caoqianming 259dde502d fix(web): 列表 meta 行数字组靠左跟排——修 active 静默后的左侧缺口(bump 0.38.7)
active 徽章静默后,无 skill 行的 meta 左槽空置,条/tok 整组右挤留出一块
"缺了东西"的空白。数字组改靠左填槽,仅时间锚行尾;删无意义的 right-group。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:55:32 +08:00
caoqianming 2937b75143 style(web): status 徽章默认态静默——active 不挂徽章,终态行淡化(bump 0.38.6)
「进行中」徽章与运行圆点语义撞车,且列表主体都是 active,重复徽章是零信息
噪音。改为:active 不渲染徽章(列表 + chat-meta 同规则),completed/abandoned
保留徽章且整行淡化(st-* class,hover 恢复),脉冲圆点成为唯一动效信号。
删不再渲染的 .badge.active CSS。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:49:44 +08:00
caoqianming 7e6159af48 style(web): 运行态标识精简为纯脉冲圆点——文案收进 hover title(bump 0.38.5)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:43:58 +08:00
caoqianming 640bd0a1a3 feat(web): 后台 running task 自动挂 SSE——运行态标识刷新后也实时(bump 0.38.4)
loadTaskList 收尾 subscribeRunningRows:列表带出的 running/cancelling 行本地
未订阅的自动挂事件流(上限 4 条防同源连接占满),done/error 走现有收尾清标识 +
重拉列表,零轮询。ensureRunningTaskSubscribed 的 cancelling/workingDir 改由
调用方传 seed(后台 task 媒体 rel 解析要用各自 working_dir);后台订阅不再调
renderLiveRunIfVisible(避免重挂卡强制滚底误伤当前对话)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:41:26 +08:00
caoqianming 0ad7d08242 feat(web): 任务列表加运行态标识——多 task 并发时可见哪些在跑(bump 0.38.3)
列表行 run_status(后端本就返回,前端一直没用)渲成状态徽章旁的标识:
running 绿脉冲点/cancelling 橙/error 红点(hover 出 run_error)。取值叠加本地
liveRuns;run 开始与点停止时就地 patch 行 DOM(不重拉列表保分页),run 结束
沿用收尾 loadTaskList() 重拉。⋯ 菜单"清空对话"的 running 判断同源修正。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:25:59 +08:00
caoqianming 941554f9d7 feat(ppt): zongyuan_red 逆向重建为真实中国建材总院模板 + 主动提示(bump 0.38.2)
按官方 总院模板.pptx(中国建筑材料科学研究总院)把手搓的 zongyuan_red
重建为真实品牌模板:PowerPoint COM 渲真页 + 解 pptx 抽实测色/字/资产。

- 打包 logo.png(八边形字标,EMF→PNG)/ cover_bg.jpg(总部大楼灰度)/
  ending_bg.jpg(材料马赛克);TIFF→压缩 JPG、EMF→透明 PNG
- 重写 5 页 SVG 忠实还原:封面(实景铺底+红块)/目录(红斜三角)/
  章节(八边形水印,原件缺按 DNA 合成)/内容(灰底红顶条卡片+底部红条)/
  尾页(材料创造美好世界+Thanks)
- 实测身份:主红 #D7000E、目录红 #D52C24、近黑 #181717、辅灰 #6F6F6F/#BCBDBD;
  微软雅黑+Arial+方正兰亭黑
- 改写 design_spec.md;补登记 layouts_index.json(此前 dir 在但未注册)
- 质检 --template-mode 5 页零 error;finalize 内嵌 8 图 + 全量渲图逐页确认

主动提示:strategist.md §e + SKILL.md 默认主题段各补一条 —— 指向
中国建材总院·CNBM 系汇报(含职称评审)时策略阶段主动把 zongyuan_red
整套模板作为候选点名给用户,点头再按明确路径套入;唯一鼓励主动提模板的
场景,其余仍等明确路径。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 15:18:06 +08:00
caoqianming dc721ba8a3 fix(web): 进度 dock 展开遮挡最新内容——task_progress 后补触底(bump 0.38.1)
#task-progress-dock 是 #chat-stream 上方的 flex 兄弟(flex-shrink:0),dock 一涨高就
从顶部挤掉 chat-stream 的可视高度,scrollTop 据置不变 → 原本贴底的最新内容被推到视口
折线以下看不见。直播态 task_progress 事件重渲 dock(=涨高)后早 return,跳过了末尾的
贴底兜底,故底部不自动回滚。修:在 task_progress 分支重渲 dock 后补一句
if (nearBottom) stream.scrollTop = stream.scrollHeight(与其余事件分支同款)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:59:34 +08:00
caoqianming 346930449a feat(ppt): 反纯文字页+图表落地硬门(7aa49195 二代陶瓷 deck 复盘,bump 0.38.0)
0.37 网格锁生效后复评仍存两盲区:两栏裸文字页 x4(指纹看不见)、
全本零数据图表;另有内容被页脚裁掉、CJK 文字叠压两硬缺陷。修五处:
- 指纹加 text-columns 原型(0 卡片+<=3 图标+<=2 图形基元+左对齐文本
  聚 >=2 列),裸文字页进单调门,4 页同指纹 error
- spec 指派图表落空检测:page_charts 指派了图表但该页 <3 图形基元
  且 <4 卡片 -> error;executor 硬规则"不许把指派图表降级为文字"
- CJK 叠压升级:两 run 均 >=70% CJK 且互叠 >=50% -> error
  (表意字宽 1.0em 估宽近精确,其余情形保持 warning)
- layout_grid 加可选 content_bottom,正文 baseline 越过 -> error;
  executor 加"写页前垂直空间预算"纪律
- 策略层数据图表下限:素材含 >=3 组可比数值 -> 全本至少 1-2 页
  真数据图表,零图表需在 spec 写理由

测试 +9(30 项)全过,全量 162 过;charts/decks 模板回归零新增噪音。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:34:51 +08:00
caoqianming d30f6089bb 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>
2026-07-03 13:22:15 +08:00
caoqianming 6f27b7cc5a fix(seedream): size 面积钳制——修 1920x1080 被 ARK 400 打回(bump 0.37.1)
模型自选 16:9 出图(1920x1080=2.07M px)触发 ARK 硬门
`image size must be at least 3686400 pixels`(=1920²,卡总面积非单边),
整次文生图 400 失败。

- tools/seedream.py: 新增 _normalize_size(),出图前把 size 钳进
  [min_pixels, max_pixels]:面积不足按 sqrt(min/area) 等比放大、
  取整到 8 的倍数并复核达标(1920x1080→2560x1440);超上限等比缩小;
  已合规原样透传(向后兼容)。归一化时返回串附 [note]、meta 记
  requested_size,记账按真实出图尺寸。
- config/media/doubao.yaml: seedream_5 加 min_pixels/max_pixels
  (旧 yaml 缺键=不设该侧,行为不变)。
- bump 0.37.0→0.37.1;PROGRESS 加一条。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 12:41:46 +08:00
caoqianming 0e02cff6c6 feat(ppt): 对齐网格锁+错位/单调质检(d1285247 陶瓷 deck 复盘,bump 0.37.0)
复盘 25 页陶瓷 deck 三类缺陷:跨页左基线漂移+并排块顶差 2-12px 的
"想对齐没对齐"、5 页同为图标卡网格的单调、标题语义不兑现(架构画成
横条列表)。修四层:
- spec_lock 新增 layout_grid 锁段(margin_x/content_top/footer_y/gutter),
  strategist 派生、executor 每页吸附、checker 强制
- executor-base §3 网格对齐纪律(同 top 同高等 gutter、打破网格 >=16px、
  同行文字 >=0.3em 禁贴字)
- svg_quality_checker 新增 check 14:兄弟卡片近失对齐 2-12px error
  (底对齐/中心对齐/chart-plot-area 内数据柱三类豁免,71 charts 回归
  误报清零)、layout_grid 偏离 2-15px error、gap 不等 warning、无锁
  项目跨页左缘聚类漂移 warning、版式指纹单调门(>=3 同指纹 warn、
  >=4 或过半 error;仅对 NN_ 编号 deck 页聚合)
- 策略纪律:同一版式原型整本 <=2 次 + 标题语义必须被图形兑现

顺手修 comparison_columns 模板胶囊 5px 错位。
新增 tests/test_svg_alignment_check.py 21 项;全量 153 过。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:16:42 +08:00
caoqianming a89c7386fd fix(web): 进度条自愈——回放层强制单调完成(d1285247 复盘,bump 0.36.2)
task_progress 回放非渲染 bug:模型跳步推进时漏给上一步补 completed,
导致"下面绿勾、上面红圈"。progress.js 加 enforceMonotonicProgress:
某步 completed 则其之前所有步自动 completed,set_plan/update_step 出口
各过一遍,漏发自愈。前端单测 +3(含复刻 d1285247 跳步序列→6/6)。
诊断脚本 scripts/diag_progress_d1285247.py。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 11:16:34 +08:00
caoqianming fcc158dff6 fix(ppt): 门体系二轮硬化——逃生口收紧+导出自动质检+svg_final 嵌图修复(bump 0.36.1)
0.36.0 重跑复盘:门都触发了,但弱模型 8 秒内连按 --allow-iconless +
--allow-unreviewed 绕过,质检/渲图验收仍 0 调用,4/25 页错位漏出。修五处:

- A 验收门分层:"从没渲过/渲后又改/finalize 前渲的"= 硬问题,任何 CLI
  flag 不豁免;--allow-unreviewed 只豁免"渲过但没标 pass";运维兜底走
  ZCBOT_PPT_FORCE_EXPORT=1 环境变量(不进 --help/SKILL)
- B 拔 -s final 雷:图标门永远对 svg_output 源检测(消除 svg_final 展开
  后误报"零图标"),wrapper docstring 老示例删除
- C 导出自动质检门:svg_to_pptx 导出前内嵌复跑 quality checker 逐页硬
  错误,error 拒绝导出、无豁免参数
- E 几何质检加"文字骑卡片边缘"检测(warn 带坐标,P12/P14/P18 类命中)
- F 修 svg_final 嵌图失效:copytree 后 ../images/ 解析必落空,所有 deck
  的 svg_final 一直嵌不进外链图(验收 PNG 图片为空);resolve 加 rebase
  回 svg_output 兜底

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 08:58:49 +08:00
caoqianming 3c712031d5 feat(ppt): 渲图验收闭环+导出验收硬门+几何质检(139a59c5 错位复盘,bump 0.36.0)
复盘 25 页 deck 错位交付:阶段六全量渲图验收被整个跳过(svg_preview 0 调用,
进度步骤只跑了 echo),图标 regex 盲插压字、大字压说明、目录溢出页底全部漏出。
文档要求过但无机制强制,三层补齐:

- A 机制:svg_preview 渲图登记 .build/acceptance.json(源 sha1+verdict);
  新增 accept_pages.py 标 pass/fail(校验渲过+源未改);svg_to_pptx 导出
  边界加验收硬门(每页 pass 且 sha1 未变,--allow-unreviewed 逃生)
- B 提前拦截:svg_quality_checker 新增几何检测(估宽包围盒):图标压字/
  基线出画布=ERROR,文字重叠=WARN 带坐标(密排设计误伤权衡,判断交渲图
  验收);tspan 按视觉行归组续排,71 charts 模板 0 error 误报
- C 文档:SKILL.md 管线改"后处理→渲图验收→导出",反模式加"没看 PNG 就
  --pass-all""为消警告批量盲插元素";SKILL_LIST 同步

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 13:37:59 +08:00
caoqianming d79c28de06 fix(ppt): 禁自搓 SVG→PPTX 导出器硬约束(966041e5 复盘,bump 0.35.1)
复盘 陶瓷资源节点建设方案 (3).pptx:25 页全是整页 PNG 贴图、零原生
文本/形状。根因是模型整条绕开官方管线(svg_quality_checker/finalize_svg/
svg_to_pptx/svg_preview/total_md_split 调用次数全 0),自搓 cairosvg
export_pptx.py 逐页光栅化贴图,连带图标空方框、外链配图丢失、文字溢出。

上一条(0.34.7)硬化的是官方工具内部的门,只在模型用官方工具时生效;
本次证明模型可完全另起平行管线,内部门无从触发。改动仅在文档层:
- SKILL.md 阶段五:加「导出唯一入口=官方 svg_to_pptx.py,默认原生可编辑、
  纯 Python 无需外部渲染器,渲染器没装不是自搓借口」
- SKILL.md 反模式:加「绕开官方管线自搓导出器 → 不可编辑贴图、价值作废」

不改线上跑法/官方脚本行为。残留风险(平台层自动检测整页贴图)按用户
选择暂缓,已记入 PROGRESS。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 09:25:14 +08:00
caoqianming e46eb01766 feat(shortcuts): 加快捷指令(触发词→完整指令,入口层确定性展开)(bump 0.35.0)
预定义"简报 → 给我输出一份昨日的 AI 新闻简报",任意入口整条打"简报"就展开执行。

关键设计:快捷指令 ≠ memory。memory 是注上下文给模型概率召回的软上下文;快捷词是
入口层、模型跑之前的确定性替换(命中即换、零歧义)。性能上 shortcuts.md 内容永不注
上下文,存再多条平时也是 0 token;触发时进上下文的就是那条完整指令本身。

- core/shortcuts.py(新):shortcuts.md(| 触发词 | 指令 | 两列表)解析 + expand()
  整条 strip()+casefold() 精确匹配展开(与「新话题」魔法命令同风格,不部分匹配)
- web/app.py 两处共用同一 expand:渠道核心 _run_channel_conversation(微信/企业微信)
  + 网页 post_message,起 run 前展开,任意入口行为一致
- core/memory.py memory_block:加一行契约让模型可维护 shortcuts.md;内容不注上下文
- tests/test_shortcuts.py(新):解析 + 展开全覆盖
- DESIGN §3.7 加"快捷指令 ≠ memory"取舍段 + 文件树;PROGRESS 加条目

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:58:55 +08:00
caoqianming c2d24b20b4 fix(ppt): 导出图标门升硬 + 修 svg_to_pptx CLI 退出码不传播 + 验收改全量(bump 0.34.7)
诊断 ppt生成2(966041e5)真实产出的两个缺陷——23 页零图标、多处错位——
根因不是缺 gate 而是 gate 被打穿:

- svg_to_pptx.py 只 main() 不 sys.exit(main()),main() 里所有 return 1
  (图标门/无 SVG/坏路径)全被吞成退出 0(最致命)
- 导出侧图标检查按设计只软 WARN、照常产出
- 模型质检用 `| head` 截断,吞非零退出码 + 截掉打在最后的零图标 [ERROR]
- SKILL.md 验收本就只要求抽查 3 页,错位藏在没看的页里;差评也未阻断

改动:
- svg_to_pptx.py: sys.exit(main()) 传播退出码
- pptx_cli.py: 导出图标门从软 WARN 升为硬门(锁图标却全 deck 零
  <use data-icon> → [ERROR] 退非零、不产出 pptx),加逃生口 --allow-iconless
- SKILL.md: 阶段六验收改「默认渲整本 + 逐页过目 + 差评即阻断返工」,
  阶段四/五/反模式补「别用 | head 截断」「别只看几页」「差评必返工」

合成测试三例(默认拒 / --allow-iconless 放行 / 有图标正常)全过。
仅改 skill 侧,不改动线上跑法;导出门只兜「锁了图标却零引用」,正常 deck 不受影响。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:16:49 +08:00
caoqianming 641c7d58aa fix(tools): look_at_image/seedream 接受容器 /workspace 绝对路径(bump 0.34.6)
docker backend 下系统提示告知主模型一切在 /workspace 下,模型自然产出
/workspace/<wd>/x 绝对路径,但 image_ref.resolve_in_root 不翻译该前缀,
报「图片找不到或越界」。加容器根前缀翻译(与 send_email 的 _resolve_user_file
一致),按字符串前缀判断而非 is_absolute()(Windows 上 /workspace 缺盘符不算
绝对);越界仍靠 relative_to(root) 兜住。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:07:44 +08:00
caoqianming eb9ffd654f feat(admin): 各用户用量表加「最近使用」列(bump 0.34.3)
后端 _user_usage_page 加全量(不随 range 筛选)相关子查询
max(created_at) → last_used_at;前端 renderUserUsage 加列,
fmtTimeAgo 显示 + 全时间戳 title,无用量显示「—」。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:28:34 +08:00
caoqianming d8f71aa7b2 feat(ppt): 页数改为用户必须显式拍板的 gate(bump 0.34.2)
页数原先只给「常 8–15 页」区间又被打包进 a–h 批量确认,用户一句
笼统「OK」就整批过、模型自取区间中位数(~12)。改(纯文档):
- SKILL.md b 项 → 推一个具体数字 + 标为「独立拍板项」
- SKILL.md 新增「🔒 页数 gate」:没给/没显式认可具体张数必须单独
  追问「就定 N 页?」拿到明确整数才写逐页大纲;唯一例外是用户明说
  「页数你随意」时按推荐数走、仍在预览写出供否掉
- strategist.md §b 同步补 Non-defaultable gate 硬约束 + 例外

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:01:51 +08:00
caoqianming 4b1dce6df9 fix(web): 清空对话时同步清空右侧导航条(bump 0.34.1)
clearMessages 成功分支只 renderMessages([]),漏了重置 outline;
切 task 路径有 state.outline=[]; renderOutlineRail(),清空路径补齐。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:13:51 +08:00
caoqianming 5bde2445a0 refactor(ppt): 工作目录收进隐藏 .build/ + 反卡片映射 + svg_preview 兜底/gate(bump 0.34.0)
累积一批(承接 ppt生成2 验证 + 用户"缺图形/卡片阵太多/文件夹过多"反馈):

- 工作目录重构:<project_dir> 根原本把"持久源 / 交付物 / 可再生构建产物"混摊。
  新增 project_utils.build_dir/svg_final_dir/preview_dir/backup_dir 单一事实源,
  把 svg_final→.build/svg_final、preview→.build/preview、backup→.build/backup/latest
  (只留最新,不再堆时间戳)。.build 是 dotfile → /v1/files 自动隐藏 → 用户可见面
  收敛到 源(sources/images/svg_output/notes/两个 spec)+ 交付物(exports)。改动:
  finalize_svg / svg_preview(_collect)/ pptx_discovery('final'→.build/svg_final)/
  pptx_cli(backup 路径 + rmtree 清旧)+ SKILL 工作目录约定/命令。端到端实测:根目录
  只剩 exports/+svg_output/,.build/ 三子目录就位,导出/预览/backup 全正常。

- 反卡片映射(治"大段大段卡片阵"):executor-base §page_rhythm 的 dense 行去掉
  "card grid 是 baseline"的背书;加一段硬映射「先看内容关系再选图形」(系统→
  hub_spoke/分层、流程→flow、层级→树/金字塔、循环→环、互依→mind_map、对比→象限、
  ≥3数据→图表),卡片阵封顶 ~1/3 页、连画两页网格下一关系页必须上示意图,指回 page_charts。

- svg_preview 加 cairosvg 兜底:find_browser 改返回 None 不抛错;无 chromium 时回退
  cairosvg,渲前用 embed_icons 预展开 <use data-icon> 成真 path(避 INVALID_MATRIX);
  修 --screenshot 相对路径静默失败(改绝对路径 + 暴露 chromium stderr)。

- 扁平 gate 计入 circle/polyline:svg_quality_checker 图形图元加 <circle>(node/venn/
  timeline 是真图,修 21-circle roadmap 误判);文字密集 deck ≥60% 页无图形 → ERROR。

架构结论(svg 目录):svg_output(可编辑源)与 svg_final(自包含编译产物)是两态、不能
合并成一个文件,但只暴露一个——现 svg_output 可见、svg_final 进 .build。终态(下一议题)
干掉持久化 svg_final、finalize 内存化 + web 按需预览,牵涉 web 层,本次未做。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:12:57 +08:00
caoqianming 13835a315a feat(ppt): 加商务红品牌预设 + 配图默认主动提议(bump 0.33.5)
用户两个需求:(1) 加一款红色主题;(2) 用户没给图时在需要处主动配图。

- 商务红品牌预设:新增 templates/brands/business-red/design_spec.md(同 anthropic
  格式:#C00000 全色表 + primary-deep/gold/info/positive/alert/surface/border/muted
  派生色 + 宋体标题/黑体正文字体栈(栈尾收预装字体)+ 实心图标偏好 + 政企口吻;无
  logo,注明用文字 wordmark / 可后补)+ brands_index.json 加条目。红色承载在 brand
  而非 visual-style(后者不带色)。同时把商务红设为 strategist §e 默认配色候选:中文
  政企/集团/科研商务汇报默认列入 ≥3 候选(红金 #BF9B5F / 红蓝 #2B4C7E 二选一点缀,
  纯红只压标题/关键数据)。SKILL §默认主题 + 八条对齐 h 行同步指向。

- 配图默认主动提议:strategist §h + SKILL h 行改——用户没给图时不再默认整本 A
  (no images);封面/分节/概念/breathing/氛围页主动把 ai 配图作为候选提给用户(数据/
  列表/流程页仍走图表→§VII,不配装饰图)。仍全程 gated:用户在 h 确认 + imagegen
  自带成本门(提议免费,确认才花钱)。

附:scripts/config.py 的 INDUSTRY_COLORS 未移植(ppt-master 残留引用),strategist
文档表是实际依据,已直接在表里加商务红行。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:57:52 +08:00
caoqianming 4a6182a76a fix(ppt): 修生成 PPT 缺图形(扁平 deck 质检 gate + 策略层视觉下限)(bump 0.33.4)
延续缺图标排查,统计最近 ppt生成 任务 24 页 SVG 的元素构成:<path>=0、
<image>=0,整本是 <text> 摞 <rect>(文字方块),零示意图/图表/配图。根因同
图标——71 个 charts/ 模板没用、content→版式映射形同虚设,且策略层把"Not every
page needs a chart"当跳过口子(spec_lock 实际 page_layouts: free design、无
page_charts 段),输出层又无 gate 拦扁平 deck。两层修(用户选定):

- A' 输出 gate(svg_quality_checker):统计每页图形图元 <path>/<polyline>/
  <polygon>/<image>(rect/line 是版面脚手架不算);≥6 页且文字密集(avg <text>
  ≥10/页)却全 deck 0 图元 → deck 级 error 退非零(逼回执行重写);多数页无图元
  → INFO;<6 页豁免(不误伤极简/teaser)。实测:8 页文字方块→exit 1;任一页带
  path→放行;4 页→豁免。

- B' 策略层视觉下限(strategist.md GATE):把 §633「Template Match」从纯建议升为
  硬下限——内容 deck(≥6 页)每个能结构化的内容页必须分配视觉处理(page_charts
  模板 / page_layouts 结构模板 / §VII 自绘示意图),spec_lock 不许 page_charts +
  page_layouts 同时空着;给出 content→图形映射速查;明示下游 A' 会硬卡。同步改
  SKILL §大纲映射纪律 + §阶段四质检清单 + spec_lock_reference page_charts 段。

诚实边界:prompt+gate 抬下限(逼别交全文字 deck),执行模型设计功力是上限;gate
守"零图形"底线而非"每页必图表",避免误伤极简风。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:37:28 +08:00
caoqianming 5d23ee682b fix(ppt): 修生成 PPT 缺图标(图标管线四层断点)+ 沙箱 SVG 预览渲染(bump 0.33.3)
查真实用户两个「ppt生成」任务的 DB 执行轨迹:24 页 SVG 共 0 个 <use data-icon>。
根因是图标管线四环节无一强制图标落地——策略层(有时)锁图标,执行层不放、
质检层不拦、工具层还断着。四层一起修:

- B 工具断点:references/SKILL 23 处路径仍指向已不存在的 skills/ppt-master/
  (zcbot 是 skills/ppt/)→ 模型 `ls .../icons/<lib>/|grep` 验名得空集 → 放弃图标;
  且 strategist 强制用的 icon_sync.py 在 zcbot 根本没有(GATE 空转,正是某任务连
  图标都没锁的原因)。修:全量改路径(保留上游署名)+ 新建 icon_sync.py(复用
  embed_icons 解析,验名+拷进 project/icons,缺名非零退出)。
- A 质检兜底(硬门):svg_quality_checker 加图标校验——锁了 icons.library + 非空
  inventory 但全 deck 0 图标 → deck 级 error 退非零(逼回执行重写);单页 0 图标 →
  warning(封面/分节/breathing/尾页豁免)。
- C 执行强制:executor-base §4 + SKILL 执行纪律改为"内容页必须放 1–3 个 inventory
  图标"(自由设计无模板可继承图标,只能逐页手写)。
- D 导出兜底(纵深):svg_to_pptx 导出前预扫,锁了 inventory 却 0 图标 → stderr 大声
  [WARN](非致命,防跳过质检直接导出)。核实 native 转换器本就自己从图标库展开
  <use data-icon>,故原设想的"finalize 硬前置"前提不成立,D 改成与 A 同源的导出层警告。

同版附带修 svg_preview.py 在沙箱里渲不出 SVG(报"未找到 Chrome / Edge"):移植自
ppt-master 的 find_browser() 只认 Windows chrome/msedge,不认镜像自带 /usr/bin/chromium
(给 mermaid 装的)→ 视觉验收这关在容器里全程失效。对齐 rendering/pdf.py 发现逻辑
(认 chromium/chromium-browser/google-chrome + $CHROMIUM 覆盖);render() 补容器必需的
--disable-dev-shm-usage + 临时 --user-data-dir;并修一个静默已久的 bug——--screenshot
传相对路径 chromium 写不出文件(原代码吞 stderr,看着和"没浏览器"一样),改传绝对路径
并暴露 chromium stderr。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 13:59:00 +08:00
caoqianming 001f9af96f fix(vision): look_at_image 超时透明重试 + 超时 60→120s(bump 0.33.2)
Seed 2.0 Lite 非流式,长 OCR 首字节可能逼近 60s read timeout → 偶发超时;
且返 [Error] 会触发主模型重发整个 tool call(图 base64 重传、输入 token 再付一次)。

- core/ark_client: 新增 ArkTimeoutError(ArkError) 子类,仅超时/网络抖动抛它;
  HTTP 4xx/5xx 业务错误仍抛普通 ArkError 不重试。子类仍是 ArkError,seedream 等
  现有 except ArkError 不受影响。
- tools/look_at_image: 对 ArkTimeoutError 退避重试(timeout_retries 默认 1 次,
  2^n s),tool 内消化掉不抛给主模型,避免重传图烧 token。
- config/media/doubao.yaml: vision request_timeout_s 60→120,新增 timeout_retries。

smoke_look_at_image 通过(OCR 命中 + 记账正确)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:02:40 +08:00
caoqianming ff276eb9b3 fix(web): SVG 预览强制 image/svg+xml(前端 blob mime + 后端 download)(bump 0.33.1)
SVG 在 <img> 里必须 Content-Type=image/svg+xml 才渲染。前端 preview.js 的
_showImage / mini 图片分支据扩展名强制 blob mime;后端 download 接口对 .svg
显式回 image/svg+xml(部分部署环境 mimetypes 未注册 svg → FileResponse 会猜成
octet-stream → 不显示)。双保险。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 08:28:11 +08:00
caoqianming e3a432dcdd feat(ppt): skill 重构为 SVG-first(移植 ppt-master,弃 python-pptx 版式件)(bump 0.33.0)
旧 python-pptx 固定组合版式件是版面单调/AI 味的架构天花板。改为 SVG-first:
AI 逐页手写 SVG 设计稿 → 纯 Python 转换器逐元素译成原生可编辑 DrawingML。

- 搬引擎:svg_to_pptx/ 转换器 + finalize_svg/svg_finalize + svg_quality_checker + total_md_split + update_spec(依赖闭包干净,只需 python-pptx)
- 搬知识:references(shared-standards/executor-base/strategist/image-layout-*/canvas-formats)+ 5 叙事骨架 + 19 视觉风格
- 搬模板:templates(layouts/decks/brands/charts + 图标库 1.1w+ + spec 骨架)
- 换 GUI:浏览器 Confirm UI → 聊天 BLOCKING 八条确认;live preview → svg_preview.py(无头 Chrome 渲 SVG→PNG);配图走 zcbot imagegen skill
- 默认主题改自由设计(商务红降为候选之一)
- 修 Windows GBK 控制台 UnicodeEncodeError:6 个入口脚本加 sys.stdout.reconfigure(utf-8) shim
- 端到端验证通过:4 页材料领域 deck,质检 0 error → finalize 嵌图标 → 导出原生 pptx → 渲图肉眼验收(swiss-minimal 设计级,非 AI 味)

移植自 github.com/hugohe3/ppt-master (MIT),适配 zcbot task_dir/聊天确认/imagegen 工作流。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 16:38:58 +08:00
caoqianming d4aa5ccbec docs(prompt): system prompt 加通用 context 纪律,堵大块输出滚雪球(bump 0.32.5)
反复 dump 全文 abstract 烧 2.5M token 不是 brief 专属——任何 skill 让弱模型
处理一批长文本都可能踩。在 system prompt 单一事实源 general_v1.md「工作原则」
段、紧挨「少来回」加一条全局铁律:大段 run_python/shell 输出会进对话历史每轮
重发,中间数据落文件、只 read 用得上的片段、别整批重复打印,否则烧 token 还
可能撑爆窗口/拖到超时被掐断。

与既有规则互补:行7(源码落 .py)管代码、行42(少来回)管轮数、本条管"大块
数据输出"。brief skill 0.32.3 的场景化版本保留做细化。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:56:44 +08:00
caoqianming b5d75d2a7b feat(scheduler): 定时任务默认单次超时 0→1800s(bump 0.32.4)
超时此前默认 0(不限),配合"超时被吞成 ok"的旧 bug,跑飞的 job 能无限拖。
改默认有限值 1800s(30min):新建 job 不指定 timeout_seconds 时给 1800,
显式 0 仍保留为"不限"逃生口。

- 单一事实源 core/scheduler.DEFAULT_TIMEOUT_SECONDS=1800;create_job 与
  tools/schedule.py(agent 建 job 的工具)默认都引它,JSON schema 描述同步。
- create_job 里 int(timeout_seconds or 0) 保留显式 0=不限语义。
- 存量:线上 job e621c8a6「每日水泥科研简报」timeout 600→1800(直接 SQL,
  未动其它 job)。
- RUN 故障兜底行同步默认值。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:52:09 +08:00
caoqianming 700176a0c6 docs(brief): 加 context 纪律,堵反复 dump 全文 abstract 烧 token(bump 0.32.3)
承接定时任务超时复盘:同一 job 的 agent 把 38 篇全文英文 abstract 用
run_python/print 反复灌进上下文(≥3 次),工具输出每轮重发 → 48 次 LLM
调用累计输入 2.5M tokens(输出仅 28K),既慢又贵还顶满 600s 超时。根因
brief skill 虽要求证据落 evidence.md 文件,却没明令"别反复 print 进上下文"。

skills/brief/SKILL.md 三处加指示文:
- 阶段二「context 纪律」:落文件、按需 read、别整批重打
- 阶段三:一次成稿别重复 dump + 论文多时按期刊分批 write
- 反模式加一条:反复 print 全文 abstract 让 context 滚雪球

纯指示文,frontmatter/description 不变 → SKILL_LIST 无需更新。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:38:32 +08:00
caoqianming 1646205364 fix(scheduler): 定时任务超时被掐断时记 error 而非误吞成 ok(bump 0.32.2)
实测 bug:isolated 定时 job 跑满 timeout_seconds 被协作式 cancel 后,
_run_agent_bg 对 ok/cancelled 都把 run_status 收回 idle(DB 不可区分),
而 _execute_scheduled_job 收尾只判 run_status=="error",于是超时中断被落成
last_status="ok" —— 掩盖"跑到一半没写 sections/没推送",且不计连续失败、
不触发兜底。复盘 job e621c8a6「每日水泥科研简报」:timeout=600s,task
创建→last_run_at 正好 600.0s,agent 停在"按期刊打印 38 篇摘要"(还在取数)。

修:超时分支置 timed_out 标志,run 收尾后若 timed_out → record_result(
status="error", 半成品不投递 notify)并直接返回。复用既有 error 语义(计入
consecutive_failures、到阈值自动停用、前端 crons 显示「上次失败」)。不动
_run_agent_bg 的 idle-on-cancel 共享语义(HTTP cancel/drain 也依赖)。

配套:PROGRESS/RUN 故障兜底各加一条;诊断脚本 scripts/diag_sched_e621.py
(dump 输出 scripts/_*.txt 入 gitignore)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:35:40 +08:00
caoqianming 89062d99b3 docs(design): 新增 §8.8 channel 长会话上下文治理(Phase 1 / Phase 2-3 design)(bump 0.32.1)
记 channel 常驻会话上下文软重置的设计:根因、业界对照(OpenClaw/Hermes/
Claude Code)、「边界而非删除」心智、Phase 1 已落地(context_base_idx 软重置
+ gap 自动分段 + 新话题命令 + 否决的替代方案)、Phase 2(阈值结构化摘要,
对齐 Hermes 阶段③)/ Phase 3(sqlite-vec/FTS5 持久检索)design。
回链修 §8.7、§8.2 两处引用。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 10:47:02 +08:00
caoqianming b27cc9cd5b feat: channel 长会话上下文软重置(gap 自动分段 + 新话题命令)(bump 0.32.0)
微信/企业微信常驻会话不再无限膨胀。tasks 加 context_base_idx,
Session.load 只把 idx>=base 的消息喂模型,base 之前历史全留 DB
(网页端照旧翻完整记录,一条不删)。

- 自动 gap 分段:入站距上次消息超 channel.session_gap_hours(默 6h)
  → 软重置,base=最后一条 user 消息 idx(保留上一轮做续聊锚点)
- 手动新话题:发「新话题/新会话//new/清空上下文」→ 硬重置 base=总数
- clear_messages 全删后归零 base;_db_idx 取真实总数避免 append 撞 idx

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 10:05:07 +08:00
caoqianming e49ff641f9 feat(web): 消息框支持拖拽文件 + 修多次粘贴互相顶掉(bump 0.31.3)
附件 chip 拆出独立托盘 #chat-attach,与状态文字解耦:append+按 rel 去重,
上传进度只写 #chat-hint,不再互相覆盖。整个 #chat-form 加 dragenter/over/
leave/drop(计数防闪烁,只认文件拖拽,微信镜像只读不接收),复用 uploadFiles。
takePastedRels/删除/预览改查托盘;切 task 清残留未发送 chip。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 14:38:58 +08:00
caoqianming d235cb7564 fix(web): 消息目录圆点错位再修(点击竞态 + 触底兜底)(bump 0.31.2)
- 点圆点不变红/点#1跳到#2:scrollIntoView 平滑滚动途中的 scroll 事件
  抢走显式点选 → 加 _outlineJumpLock,跳转期间不重算,700ms 兜底解锁
- 点最后一个/滚到底倒数第二个变红:末项永远顶不到顶线(容器先到底)
  → updateActiveOutlineDot 加触底分支,判最后一个已加载轮为当前

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 14:27:50 +08:00
caoqianming 1352f092a3 feat(web): admin 近7天用量表加合计行(bump 0.31.1)
renderByDay 在 by_day_7d 表底加 tfoot 合计行,汇总 7 天
cost_cny/tokens_in/tokens_out;无数据时不渲染。后端无改动。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 14:21:40 +08:00
caoqianming b4808b0370 feat: per-account 模型访问控制(档位制,复用 plan 列)(bump 0.31.0)
- core/model_access.py(新):档位制访问控制。users.plan 存档位名,
  「档位→模型集合」配在 config/agent.yaml model_tiers;plan 空/未知→default 档,
  role=admin 全开。无需 migration(plan 列 0001 起就有,之前休眠)。
- 两档:default(deepseek+local+seedream+seedance)、pro(+doubao+glm)。
- web/app.py:三个 list 端点按档过滤(用户只看到本档模型);三个 resolve 加
  user_id 门控 —— 显式选档外模型 403;老 task 下次发消息模型已不在档位内→
  持久落回 deepseek_v4.flash;定时任务执行 grandfather 不门控。
- web/admin.py:GET /v1/admin/tiers + PATCH /v1/admin/users/{uid}/plan;
  用户行补 plan 字段。
- web/static/js/admin.js:各用户用量表加「档位」列(内联下拉)+ 档位图例 + apiSend。
- DESIGN.md plan 列语义 / RUN.md model_tiers 配置说明。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 14:11:22 +08:00
caoqianming 8263382fd1 feat: 新增豆包 Seed 2.1(turbo/pro/evolving)+ GLM 5.2 文本模型档案(bump 0.30.0)
- config/models/doubao.yaml(新建):Seed 2.1 turbo/pro + 自进化 evolving,
  走 Ark OpenAI 兼容端点(openai/ 前缀 + ARK_API_KEY,同 local.yaml 范式)
- config/models/glm.yaml:加 pro52(GLM 5.2,zai/glm-5.2,1M 上下文),与 glm.pro(5.1)并存
- thinking_mode 均 false(深度思考走 body 协议,非 reasoning_effort 等级,留 TODO)
- 单价按火山/智谱 2026-06 发布价;evolving 单价未公布暂按 pro 估值兜底
- RUN.md 更新 ARK_API_KEY 说明(文本+图像+视频三处共用)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:05:07 +08:00
caoqianming d633949a66 feat(web): 定时任务执行历史列表(右栏 Tab + 分页)(bump 0.29.0)
isolated 模式每次触发新建 task,旧的带 scheduled_job_id 被普通列表过滤、
UI 够不到,原来只有「打开它跑的任务」单按钮指向 last_task_id(最近一次)。

- 后端新增 GET /v1/schedules/{job_id}/tasks?page=&page_size=:按 scheduled_job_id
  归属 + user_id 隔离,created_at desc 分页,复用 _task_dict,标准分页壳返回。
- 前端定时弹框右栏改 Tab(详情 / 执行记录),动作按钮提到顶部 head;
  执行记录是分页列表,点某条打开那次对话。await 后重查 #cr-hist 防切换串显。
- 决策(与用户对齐):历史全部保留不剪枝;布局选 Tab 而非三栏。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 12:53:56 +08:00
caoqianming e7a86fb00c fix(web): 修复卡片⚙点击无反应(export openWechatModal)+ 卡片圆角调小
- openWechatModal 原是 wechat.js 私有函数,chat.js 访问不到 → 点击⚙无反应。
  export 出来并在 chat.js import,事件绑定直接调用(去掉 typeof 兜底)。
- 卡片圆角 8px → 4px(用户嫌太圆);cc-icon/cc-action 圆角同步调小。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 12:26:44 +08:00
caoqianming 12a2289de2 style(web): 卡片布局改左大右小,⚙ 固定宽度
卡片左侧占主空间(点开对话),右侧「⚙」固定宽度 28px(点开弹框),
点击更易触发。未绑定/已绑定无对话卡片也加右侧 ⚙ 管理。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 12:17:32 +08:00
caoqianming 013cbc28b5 fix(web): 卡片 ⚙ 按钮打开弹框管理
已绑定且有对话的卡片:⚙ 按钮打开弹框管理,拦截点击不触发 selectTask。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:28:14 +08:00
caoqianming a6d00b24ff feat(web): 渠道卡片收拢绑定管理 + 删 rail 按钮 + bump 0.28.1
把渠道绑定/对话/管理全部收进「新建任务」下方的卡片,删掉左下角
rail「微信」按钮(精简页面)。后端 /v1/channel_tasks 返回
{ wechat: { bound, task }, wecom: { bound, task } },前端渲染三种卡片:
未绑定(点绑定)/已绑定无对话(占位)/已绑定有对话(点进+⚙管理)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:19:45 +08:00
caoqianming e66fdd0ffc feat: 定时任务对话归属 + push 统一记录到渠道对话(bump 0.28.0)
问题1:定时任务产生的 task(isolated 每次新建)混进普通对话列表。
- tasks 加 scheduled_job_id(nullable FK→scheduled_jobs,migration 0017 + backfill
  persistent/isolated);列表 WHERE scheduled_job_id IS NULL 排除(+working_dir LIKE 兜底)
- ensure_local_task_row 加参数,_execute_scheduled_job 建任务时填
- mode 语义澄清:只管对话是否延续,文件夹两种模式都按 job 复用

问题2:任何 push(定时 deliver_notify / agent wechat_push 工具)推到微信渠道,
web 端渠道对话看不到、没法基于推送追问。
- 记录下沉到 send_to_user(两调用方统一入口):投递成功后对每个成功渠道
  ensure_channel_chat_task(不存在自动建,与入站对话共用)+ 写 assistant 消息
  (摘要+文件下载链接+../rel read 路径)
- Unified 进 agent 上下文(基于推送追问);source_task_id 去重(chat task 内调
  wechat_push 时不重复插摘要);不塞正文,agent 按需 read 产物文件
- _run_channel_conversation 复用 ensure_channel_chat_task,消除建 task 重复逻辑

messages.kind 列(migration 0018):push 记录标 kind="push"(独立列不进 payload),
extract_last_assistant_text 加 WHERE kind IS NULL 跳过,避免 wecom 入站取回复
误取 push 摘要当回复。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-26 10:51:06 +08:00
caoqianming 133e350428 style(web): 渠道卡片改并排省纵向空间 + bump 0.27.4
接 0.27.3:两张渠道镜像对话卡片(微信/企业微信)从竖排改并排
(#channel-cards flex row,各 flex:1);窄栏内图标左、名称 + 条数·时间
堆两行(新增 .cc-body 列容器)。绑定弹框(左下角「微信」rail 按钮)保留
不动 —— 它是绑定/解绑/测试推送唯一入口,与卡片职责互补不重复。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:39:25 +08:00
caoqianming 7dfdf4c73b feat(web): 微信/企业微信对话改成左栏固定卡片 + 企业微信也只读 + bump 0.27.3
把渠道镜像对话(每用户每渠道唯一的常驻只读对话)从「任务列表置顶行 +
绿徽章 + 绿边」改成「新建任务下方两张固定卡片」,与可滚动任务列表分离、
常驻可见;顺带补企业微信对话的 web 端只读锁。

- 后端 /v1/tasks 用 coalesce(channel,'web').notin_(CHANNEL_MIRROR_KINDS)
  排除渠道任务并删掉 case() 强制置顶;新增 GET /v1/channel_tasks 返回
  {wechat, wecom} 摘要(复用 _task_dict,无则 null)
- 前端加 #channel-cards 卡片块(:empty 自动隐藏)+ loadChannelCards/
  syncChannelCardActive;移除列表行已失效的绿徽章逻辑
- applyChannelComposerLock / sendMessage 守卫从硬编码 channel==='wechat'
  改读 CHANNEL_BADGE,微信 + 企业微信都 readonly,提示文案按渠道动态

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:32:12 +08:00
caoqianming 474597cfc6 feat(wecom): 企业微信入站对话支持图片/文件附件(media/get 下载 + 复用渠道无关核心)+ bump 0.27.2
接续 0.27.0 企业微信入站(此前只收文本)。

- wecom.download_media(media_id):走 media/get,成功回二进制流 + Content-Disposition
  文件名,出错回 JSON errcode(40014/42001 重取 token);_filename_from_disposition 解
  filename / filename* 两种形式。
- 回调按 MsgType 分支:image/file 下载后构造 InboundAttachment(kind/file_name/data,与
  个人微信同结构)→ 喂同一 _run_channel_conversation,复用其落盘 + 拼 [用户上传的...] 行
  (图片 agent 自调 look_at_image,文件走 Read)。纯图片/文件消息无文本时据附件行生成 text。
- 语音/视频/位置/链接/事件暂回 success 不处理;附件下载失败静默跳过(打日志)。
- dev.html「企业微信(仅推送)」文案纠正为「推送 + 对话」。

文件:core/wechat/wecom.py、web/app.py、web/static/dev.html。_filename_from_disposition
+ import 自测过。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:17:44 +08:00
caoqianming d1aa2b12e2 fix(wecom): wechat_push 支持按渠道定向投递,修「点名企微仍推到个微」+ bump 0.27.1
用户说"推送给我的企业微信",消息却同时进了个人微信。根因:send_to_user
是无差别广播(for ch in active_channels() 逐个推),且 wechat_push 工具
没有指定渠道的参数 —— 部署同开 clawbot+wecom 时一条推送两边都到。

- send_to_user 加 channel=None:None 保持广播(定时任务/不点名沿用,向后
  兼容);指定 wecom/clawbot 时只投那一条,该渠道未开返回单条 no_binding,
  不静默回退到别的渠道。
- WechatPushTool 加可选 channel(enum wecom/clawbot)+ 描述教 agent
  「用户点名某微信就传对应 channel」,execute 做渠道白名单校验。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:15:36 +08:00
caoqianming d16297e556 feat(wecom): 企业微信支持入站对话(回调 webhook + AES 解密 + 复用渠道无关对话核心)+ bump 0.27.0
入站方式与 ClawBot 本质不同:ClawBot 走长轮询(getupdates + 常驻 run_inbound_manager),
企业微信走回调 webhook(企微服务器主动 POST 加密 XML)→ 无需后台轮询 task,只加 HTTP 端点。
agent 跑 >5s 超被动同步窗口 → 回复走 message/send 主动推回(复用 push_wecom),被动回 success 防重试。

- 抽 _run_wechat_message 为模块级 _run_channel_conversation(app, uid, text, atts, channel):
  个人微信(wechat)与企业微信(wecom)同核心、各一张会话 task(企微 binding 也存 chat_task_id)。
- 新增 core/wechat/wecom_crypto.py:WXBizMsgCrypt 等价(SHA1 验签 + AES-256-CBC 解密 + corpid 校验);
  与 crypto.py 的 Fernet 列加密、wecom.py 出站 API 全无关。
- service.py:get_user_by_wecom_userid 回调反查身份 + get/set_wecom_chat_task;
  upsert_wecom_binding 改成合并 config(不再覆盖 chat_task_id)。
- web/app.py:GET/POST /v1/wecom/callback(无 JWT,身份从加密 XML FromUserName 反查)。
- env:WECOM_CALLBACK_TOKEN / WECOM_CALLBACK_AESKEY;暂只收文本,未绑定/空消息静默。
- 文档:PROGRESS/RUN/DESIGN/wecom 同步(DESIGN 把「只做推送不做对话」旧决策标为演进)。

crypto round-trip 自测过;create_app + 路由注册 + 全量 import 通过。端到端待企微后台配回调 URL(需公网 HTTPS)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:07:47 +08:00
caoqianming 5d3cd88e2c fix(wecom): 扫码绑定改用扫码授权登录端点,修复「请在企业微信客户端打开链接」+ bump 0.26.10
oauth_authorize_url 原用 open.weixin.qq.com/connect/oauth2/authorize(网页授权,
只能在企业微信客户端内打开),桌面浏览器 window.open 它 → 企业微信报「请在企业微信
客户端打开链接」,扫不了码。

改用扫码授权登录端点 login.work.weixin.qq.com/wwlogin/sso/login(login_type=CorpApp),
桌面浏览器渲染二维码,企业微信 App 扫码确认后回跳带 code,verify_state / get_user_id
逻辑不变。前置:redirect_uri 域名须配在应用「企业微信授权登录」可信域名(另一项设置)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:21:30 +08:00
caoqianming 8ab1805df4 fix(wechat): wechat_push 工具漏挂企业微信 + 提取 active_channels 单一真相源 + bump 0.26.9
根因:wechat_push_available() 只看 clawbot_enabled(),没算企业微信。线上若只开
企微渠道(ClawBot 开关没开)→ 工具压根不注册到 agent → zcbot 照实回"没有直接
发企业微信的工具",用户已绑企微仍推不出。底层 send_to_user 早支持 push_wecom,
纯属注册门槛漏判。

修:提取 service.active_channels() 作渠道清单唯一真相源,门槛(wechat_push_available)
与投递(send_to_user)都引它,加渠道只改一处,根除"两处各列各的"这类偏差。
工具描述把 ~24h 窗口注明为 ClawBot-only(企业微信无窗口约束)。

纯内部重构,对外契约不变;test_secret_host_tools 8/8 过。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:09:21 +08:00
caoqianming 23c5ab20e0 feat(web): main.py web 支持 --ssl-certfile/--ssl-keyfile(uvicorn 原生 TLS,免 nginx)+ bump 0.26.8
两者同时给即在本端口跑 HTTPS,只给其一报错;都不给=明文(向后兼容)。
适配「只有 8765 对外」场景:zcbot 直接在 8765 上 HTTPS,不用 nginx/不挪端口。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:43:18 +08:00
caoqianming 0cf6e3e61e chore(wecom): 加企业微信可信域名校验文件 WW_verify_THssshZfneJwIG5Y.txt(放 repo 根)+ bump 0.26.7
bot.ctc-zc.com 的可信域名归属校验;配合 /WW_verify_{token}.txt 路由在域名根 serve。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:25:06 +08:00
caoqianming 36964d9920 feat(wecom): 域名根 serve 企业微信可信域名校验文件 WW_verify_*.txt + bump 0.26.6
GET /WW_verify_{token}.txt 从 ZCBOT_WECOM_VERIFY_DIR(默 repo 根)读同名文件返回,
公开端点 + token isalnum 防穿越。解企业微信「网页授权可信域名」归属校验
(zcbot 根路径原是 302 跳 SPA,验证文件 404)。配好可信域名才能配可信IP(修推送 60020)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:22:34 +08:00
caoqianming ed2ff52bf4 fix(wecom): diag_wecom 加 sys.path 仓库根 + 手动 .env 兜底(直跑不再 ModuleNotFoundError)+ bump 0.26.5
诊断已定位线上 60020:应用「企业可信IP」白名单未含服务器出口 IP。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:16:17 +08:00
caoqianming 6f7c904cca fix(wecom): 推送失败透出真实 errcode/errmsg + 加 diag_wecom 诊断脚本 + bump 0.26.4
之前推送失败只回 error:RuntimeError,吞了企业微信的 errcode。改成 reason=str(e)
(含 gettoken/message_send 失败的 errcode+errmsg);scripts/diag_wecom.py 分步查
gettoken vs send 的确切 errcode,服务器上直跑即可定位(可见范围/userid/凭据)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:10:56 +08:00
caoqianming c79fc8ef0c feat(wecom): 企业微信加「手填 userid」绑定(无 HTTPS 域名也能推)+ bump 0.26.3
企业微信推送是出站调用(gettoken/message_send 直连 qyapi),不需要域名;只有
OAuth 扫码拿 userid 那步要 HTTPS 可信域名。用户暂无域名 → 加第二条绑定路:
手填成员 userid(管理后台→通讯录→成员→「账号」)即可推送。

- web/app.py:`PUT /v1/wecom/bind/userid`(写绑定,wecom_configured 才允许)
- 前端 rail「微信」modal 企业微信段加输入框 + 保存(与扫码并列,已绑回填);
  refreshWecom 提示两路并存
- service/推送/send_to_user 不动(userid 来源换了,绑定数据结构一样)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:42:53 +08:00
caoqianming 9381655210 fix(admin): 近 7 天用量按日期倒序(最新一天在最上)+ bump 0.26.2
_usage_section 的 by_day_7d 排序 order_by(day) → order_by(day.desc())。
overview 趋势表 + PDF 报告共用此数据,两处都生效;前端纯按行渲染、不依赖升序,无需改 JS。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:33:54 +08:00
caoqianming f17da6a6e1 feat(auth): 平台登录注入 name/user_name + 监控页/dev 顶栏用户名展示 + bump 0.26.1
平台登录档案注入(0.26.0):
- users 加 name/user_name 两列(migration 0016,纯加 nullable 列,平滑兼容存量行)
- /v1/auth/login body 可选收 name/user_name,ensure_user_row 升级为 upsert
  (COALESCE(EXCLUDED, 旧值):平台传非空就刷新、传 null 不覆盖清空)
- login / login_password / /v1/me 响应回带 name/user_name/role

用户名展示(0.26.1):
- 统一兜底链 name → user_name → email → uid8,监控页与 dev 页共用
- 监控页 admin.js:各用户用量 / 存储 / overview 迷你表用户列走 userCellHTML,
  name+user_name 都有时主显 name + 浅灰 user_name;title 悬浮完整身份。
  admin.py 两表 SELECT 补 User.name/user_name
- dev 顶栏 main.js renderWho:默认显 name,hover 显账号/邮箱/ID;
  state.js 加 userUserName/userEmail + setIdentity/userDisplayName/userDisplayTitle helper,
  登录 / embed / /v1/me 校准共用

注:migration 0016 需在目标环境 `main.py db upgrade head` 应用后生效。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:31:32 +08:00
caoqianming 2b2b4531b3 fix(web): 登录失败提示统一为「账号或密码错误」,不再回显原始状态码 + bump 0.25.2
输错密码时前端弹「404」:后端 login_password 实际返 403,前置网关/旧构建
把状态改写成 404 后,doLogin 直接回显 r.status 导致语义错误。

- auth.js doLogin 失败分支:表单已校验非空,非 2xx 绝大多数是凭据不对,
  统一给「账号或密码错误」(pw)/「user_id 或 PLATFORM_KEY 错误」(key);
  仅 5xx 暴露状态码提示服务端问题。
- app.py:1399 detail 同步改中文,保持契约自洽。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:08:00 +08:00
caoqianming b5cfce72b5 feat(wechat): web 端微信 task 只读镜像,锚定微信为单一交互入口 + bump 0.25.1
web 端打开 channel=wechat 的常驻 task 原能正常发消息,但 web→微信单向
不同步(web 发的走通用端点 → _run_agent_bg,不经过 inbound loop 里
send_text 回微信那段,微信侧零感知);微信→web 则同步(同一条 task)。

不做双向打通:回微信需 context_token、只能从入站拿且 24h 过期,双向同步
会被该窗口拖成"有时同步"(不可预测)+ 两入口并发写歧义。改为 web 端只读
镜像,交互权威单一锚定微信;主动推走 wechat_push / 定时简报。

- chat.js: applyChannelComposerLock(selectTask 后调)对 wechat task 置
  chat-input readOnly + 改 placeholder 引导去微信 + 禁润色;sendMessage
  入口加 channel 守卫(Enter 兜底)
- dev.html: .readonly-locked 置灰样式

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:30:22 +08:00
caoqianming 529d7f1046 feat(wechat): 入站收图片/文件,CDN 下载+AES 解密落盘 + bump 0.25.0
get_updates 原只抽 text_item,图片/文件 item 被丢成空 text,inbound
又因空文本 continue → 用户发的图/文件静默丢弃、零落库(DB 实证)。

- ilink: InboundAttachment + 解析 image_item/file_item + download_media
  (CDN /c2c/download GET 密文 → AES-128-ECB 解,发送侧加密的逆);key 双
  编码兜底(base64(raw16)/base64(hex32)),图片按 magic bytes 补扩展名
- inbound: handle_message 契约加附件参,文本/附件都空才跳过,下载失败
  只丢该附件不拖垮整条
- app.py: 附件落盘 <wd>/inbound/,图片拼 [用户上传的参考图](走
  look_at_image)、文件拼 [用户上传的文件](走 Read/Shell),复用 web 端
  粘贴图约定,不碰模型链路

crypto roundtrip + 双编码 key decode 已单测;端到端(GET/POST、真实
image_item 结构)待用户重发一张图实测。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:15:52 +08:00
caoqianming 6f7e32bb33 docs: 操作说明书精简版表格微调 + 新增「科研AI双智能体·汇报PPT大纲」+ bump 0.24.4
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:57:57 +08:00
caoqianming 7b9f0c12ed refactor(wechat): 绑定表合一 channel_bindings(判别列+JSONB),取代 ClawBot/企微两表 + bump 0.24.3
架构复盘:渠道绑定 = "用户在某渠道的一份配置",各渠道字段形态不同 → 判别列 + JSONB 多态
(同本库 usage_events kind+units)最契合,加渠道(飞书/TG…)零 migration。原分表
(0012/0014)对 2 渠道够用但不扛增长、与库内多态范式不一致;单宽表(NULL 列并列)最差。

- models:`ChannelBinding(user_id, channel, status, config JSONB)` PK=(user_id,channel)
  取代 WeChatBotBinding/WeComBinding;clawbot 敏感字段 crypto 加密入 config,wecom 明文 userid。
- migration 0015:建表 + 旧两表数据搬进 config(token 密文串原样搬)+ drop 旧表;
  DDL+DML 同事务失败回滚不丢;含 down 拆回。
- service 存取改读写 config —— **公共 API + BindingSnapshot 形状不变** → inbound/web/tool/
  scheduler 零改动(纯内部数据层重构,对外行为不变)。趁绑定数据极少时合表最省。

import/编译 + _snap 反序列化单测过;DB 往返 + migration 待部署联调。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:55:39 +08:00
caoqianming 2dd1b49725 fix(web): 微信绑定弹框标题样式对齐其他弹框 + bump 0.24.2
#wechat-modal h3 只设了 flex,漏了 margin/padding/font-size/border-bottom,
吃浏览器默认 h3 样式导致标题又大又飘、无分隔线。补齐标题样式 +
h3 svg opacity + .sk-x 关闭按钮样式,与 crons/memory 弹框一致。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:42:57 +08:00
caoqianming 6008e1b8a0 fix(wechat,email): host-side 文件工具翻译容器路径,修复附件发不出 + bump 0.24.1
docker 模式下 fs 工具在容器跑,文件落宿主 users/<uid>/<wd>/,但 send_email /
wechat_push 是宿主进程工具:base_dir=cwd 且不识别容器↔宿主路径映射,agent 给的
相对路径拼到 cwd、容器绝对路径 /workspace/... 宿主上瞎解析,relative_to(user_root)
必越界 → 附件永远发不出(probe 直调 send_file 绕过解析,故"测试可发")。

- tools/base.py: 共享 _resolve_user_file(/workspace 前缀翻回 user_root + 相对拼
  base_dir + 越界校验)+ FileOutOfBounds
- agent_builder: 两个 host 工具 base_dir=working_dir_path(宿主 task 目录)而非 cwd
- send_email / wechat_bot: 改用 helper
- tests: 加 3 例回归(翻译+越界、send_email 容器路径、wechat_push 相对路径)
- scripts/diag_wechat_push.py: 诊断脚本

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:02:48 +08:00
caoqianming 193b545b75 feat(wecom): 企业微信渠道 B 纯推送 + OAuth 扫码绑 userid + bump 0.24.0
企业微信只做推送、不做对话(省回调 + AES + 5s ACK):无条件主动推(不挑活跃度、
无 24h 窗口),补 ClawBot 短板,定时简报必达首选。touser 经 OAuth 网页授权扫码拿成员 userid。

- core/wechat/wecom.py:access_token 2h 缓存(线程安全 + 失效重取)、OAuth getuserinfo、
  message/send text/file、media/upload、state HMAC 签名
- WeComBinding 模型 + migration 0014(0013 被 task_channel 占);service 加 wecom CRUD
  + push_wecom + send_to_user 接 wecom 一路(scheduler deliver_notify 经它自动带上)
- web/app.py 5 端点(/v1/wecom/oauth/url、callback 公开-身份从 state 验、bind GET/DELETE、test)
- 前端 rail「微信」modal 加企业微信段(wechat.js + dev.html)

激活(管理员):建自建应用 → WECOM_CORPID/AGENTID/SECRET + 配「网页授权可信域名」;
db upgrade head(带 0014)。redirect 主机取 ZCBOT_PUBLIC_BASE_URL 或请求 base。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:44:23 +08:00
caoqianming 320f428dd3 feat(email): 配置 foxmail SMTP 发信 + 发件人显示名品牌化 + bump 0.23.2
- .env 填入 smtp.qq.com:25/STARTTLS/授权码,send_email tool 与定时任务
  notify 兜底投递生效(.env 不入库)
- send_email.py 发件人显示名由硬编码 zcbot 改读 SMTP_FROM_NAME,默认
  「总院科研辅助智能体」,对外不暴露内部代号
- RUN.md 补 SMTP_FROM_NAME 说明;PROGRESS 记一条

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 11:31:17 +08:00
caoqianming 340786a42f feat(web): 微信任务徽章改品牌绿 + 微信 logo + 整行绿边 + bump 0.23.1
上版徽章复用 .badge.active(蓝灰)与旁边「进行中」状态徽章撞色、不显眼。
新增 .badge.wx(微信绿 #07C160 + 白字 + 内嵌微信 logo SVG)与 .task-row.wx
(绿色左边框 + 极淡绿底 + hover 加深),让置顶的微信任务从普通任务里跳出来。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 11:23:34 +08:00
caoqianming 85336ccb7e feat(wechat): 微信对话 task 渠道标记 + 列表置顶(channel 字段)+ bump 0.23.0
tasks 加 channel 列(web/wechat,migration 0013,server_default='web' 回填存量,
并把 description='(微信 ClawBot 对话)' 的存量 task backfill 成 wechat)。微信常驻
task 后端强制置顶(列表查询前置 case pin 表达式,跨分页稳定),前端任务名前打绿色
「微信」徽章一眼可辨。channel 仅 INSERT 写定,后续 upsert/save 不传不覆盖。

- core/storage/models.py: Task.channel 列
- db/migrations/.../0013_task_channel.py: 加列 + backfill
- core/storage/utils.py: ensure_local_task_row 加 channel 参数
- web/app.py: 微信建 task 传 channel=wechat;_task_dict 透出;列表 pin 置顶
- web/static/js/chat.js: channel===wechat 打徽章

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 11:12:16 +08:00
caoqianming 95857ba687 feat(wechat): 绑定 UI 并入主 SPA(左栏 rail「微信」按钮 + 扫码 modal)+ bump 0.22.2
上版绑定页是独立 /static/wechat_bind.html、主界面没入口、用户找不到。集成:
rail 加「微信」按钮(hd-wechat)→ 扫码绑定 modal(wechat-modal),复用 api()
调已有 5 端点(起码/轮询/查/解绑/自检),仿 crons.js 范式;二维码过期自动换码。
独立页 wechat_bind.html 保留作嵌入/兜底入口。

文件:web/static/js/wechat.js(新)、dev.html(rail 按钮 + modal + CSS)、
main.js(import 触发顶层绑定 + Esc 关闭);RUN/PROGRESS 同步去掉"未并入 SPA"。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:47:34 +08:00
caoqianming c569438d5f fix(web): 顶栏 token 计量栏回复后不刷新 + bump 0.22.1
提问→助手答完后,对话顶栏「总 token·缓存命中·花费」停在发问前旧值,
要切走再切回才更新。根因:计量栏读 state.taskMeta,而它只在 selectTask
里重拉;SSE 收尾的 fetchSse finally 只刷列表+消息,从未重拉 meta。
修:finally 里当收尾的是当前可见 task 时补一次 GET /v1/tasks/{id} →
重置 state.taskMeta → renderChatMeta(),失败 try/catch 吞掉不打断收尾。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:08:19 +08:00
caoqianming 528b974d9f feat(wechat): ClawBot 个人微信接入第一期(后端 + 绑定页)+ 双渠道设计 §8.7 + bump 0.22.0
把 zcbot 送进用户个人微信:对话 + 主动推送(简报/结果)。选官方微信 ClawBot
(iLink Bot API,零封号)先行;企业微信作渠道 B 留接口。协议全程真机实测
(scripts/probe_clawbot*.py,本人微信号在灰度内)。

核心(后端 import/编译自测过):
- core/wechat/{ilink 协议客户端, crypto 凭据加密, service 绑定CRUD+24h窗口推送
  +send_to_user 渠道抽象, inbound 长轮询管理器+回复提取}
- WeChatBotBinding 模型 + migration 0012;tools/wechat_bot.py WechatPushTool
  + agent_builder 注册(有开关才挂)
- scheduler.deliver_notify 加 wechat 通道(未送达退邮件);web/app.py lifespan
  起入站管理器 + _run_wechat_message 回调 + 5 端点;web/static/wechat_bind.html 绑定页

实测要点:每条 sendmessage 必带唯一 client_id(漏则同 token 后续被丢);context_token
24h 可复用→主动推(需用户先开口);文件 getuploadurl→AES-128-ECB(PKCS7)→CDN
(URL 带 filekey)→file_item,docx/pdf 原生直推。

激活:db upgrade head(带 0012)+ env ZCBOT_WECHAT_BOT_ENABLED=1
+ ZCBOT_WECHAT_SECRET_KEY=<串>。待办:部署端到端联调、SPA 集成绑定 UI、企业微信渠道 B。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 08:59:56 +08:00
caoqianming 336db63a01 feat(rendering): 平台渲染层 rendering/ 统一三 skill docx + chromium md→pdf + bump 0.21.0
渲染从「各 skill 自带 render_docx.py」抽成平台能力:新建顶层 rendering/ 包,
bind-mount 进 /sandbox/rendering,各 skill 调 render.py 不再 bundle 渲染脚本
(符合 Skills 自包含/可 fork 标准,跨 skill import 会破坏 fork 故不走 skills/_shared)。

- common.py 叶子原语单一事实源(化学式白名单 CHEM_RE 原先三份逐字重复→收敛一处)
- docx_manuscript.py paper/proposal 配置化双 profile;docx_brief.py brief 富渲染复用 common
- pdf.py md→HTML→沙盒 chromium --print-to-pdf(不用 weasyprint:要 pango/cairo 原生库且不在镜像)
- render.py 统一 CLI --profile {brief,paper,proposal} --format {docx,pdf}

零回归:三 profile 重构前后 docx 解包 diff word/document.xml 字节完全一致。
守护测试 tests/test_rendering.py 5 项全过。chromium 冒烟 deploy/sandbox/probe_chromium_pdf.sh。

删 3 份 render_docx.py + 短命 skills/_shared/render_pdf.py;改 5 个 SKILL.md 调用到
render.py + 补反模式"渲染一律调 render.py、禁止手搓 weasyprint/pip 装包";brief 另删
research 索引滞后描述。requirements 加 markdown,pool.py 加 rendering 挂载。

部署须一次原子激活:/sandbox/rendering 挂载靠 pool.py(restart 重建容器生效)+
markdown 进镜像靠 requirements 触发整体重建——update.sh build→restart 顺序覆盖,
旧 render_docx 路径已删,勿只推代码不重建。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 13:07:19 +08:00
caoqianming d412aa6b24 fix(web): 消息目录点第一个圆点误高亮第二个 + bump 0.20.4
跳转锚点(block:center)与活跃判定锚点(顶线 80px)不一致:第一轮
上方无内容无法居中、被钉到顶端,短轮时下一轮卡片顶也落进 80px 带内
→ 越界高亮第二个圆点。改跳转为 block:start(同锚点)+ .msg
scroll-margin-top:16px,活跃容差 80→24 对齐。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 08:56:02 +08:00
caoqianming 247a887cd6 fix(web): 定时弹窗 z-index 遮挡 + 登录 focus 引用错 id + bump 0.20.3
- #crons-modal 漏了 z-index,退回基础 .modal(无 z-index)被 z-index:5
  的侧栏/面板盖住("弹了但被遮挡");补 z-index:112 与 #skills-modal/
  #memory-modal 对齐。排查用 node+DOM mock 跑通整条前端模块图确认 hd-crons
  绑定确实执行,定位到纯 CSS 层叠问题。
- main.js:106 $("li-token").focus() 引用了不存在的输入框(实际 li-email),
  未登录 boot 末尾会抛 TypeError;改为 li-email。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 10:13:13 +08:00
caoqianming c55d0d11f0 fix(context): 发送期补齐悬空 tool_calls,断中断 run 留下的协议崩 + bump 0.20.2
run 在写入 assistant.tool_calls 之后、tool 结果写库之前被中断(上游流式断连 /
用户取消 / 崩溃),历史里留下一条 tool_calls 后面没有对应 tool 结果的消息;用户
随后继续发言,下一轮原样发给 DeepSeek/OpenAI 即被拒(must be followed by tool
messages),任务卡死在 run_status=error(监控页排查 task 5c5d6d25 实测)。

prepare_messages_with_stats 入口(早返回分支之前)新增 _repair_dangling_tool_calls:
对每条 assistant.tool_calls 扫描紧随其后的 tool 结果,为缺失的 tool_call_id 补占位
tool 消息。纯发送期不改库 → 覆盖所有中断路径 + 存量坏数据自愈,stats 计 repaired_tool_calls。
区别于 06-06/06-12 的 arguments 损坏修复(那治参数投毒,此为结构性悬空)。

新增 4 个单测,context 套件 14 项全过。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 15:08:20 +08:00
caoqianming f8d11a2491 test(scheduler): 定时任务端到端 smoke + tick 默认 30s→10s + bump 0.20.1
- scripts/smoke_scheduler.py:插一条 next_run=now 的 isolated job,轮询 last_status
  翻 ok/error/skipped,验证守护循环全链路(认领→建 task→_run_agent_bg→LLM→记账)。
  实跑通过:约 15s 内触发,agent 回「早安,今天也加油!」,last_status=ok。
- 守护循环扫描间隔默认 30s→10s(ZCBOT_SCHEDULER_TICK_SECONDS);间隔只决定最坏
  延迟≤1tick,不决定会否漏(claim 取 next_run<=now 的全部)。DESIGN/RUN 同步。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:48:44 +08:00
caoqianming 2b9a7febde feat(skill): brief 重定位为重要文献速览(论文列表+总结,只描述不给建议)+ 精简三文件 + bump 0.20.0
- 重定位:重要论文列表(各大期刊,Elsevier 数据库优先,每篇带简介/摘要概述)+ 内容总结;去掉建议/启示/热点聚类/判断
- 三路取数:research + documents 取文献为主力,web search 取政策·标准·产业动向单列(不混进论文总结)
- 精简 8→3 文件:SKILL.md 自包含(spec 字段/骨架/检索法/核验铁律/渲染说明)+ references/journals.md(各建材子领域主流期刊清单,Elsevier 标注 + 精确 publication_name + 0 命中降级)+ scripts/render_docx.py;删 templates/spec.md、templates/brief_outline.md、references/search_strategy.md、references/citation_verify.md、scripts/quality_check.py
- render_docx.py:论文列表段(标题含"论文列表/文献列表/参考文献")H3 期刊子标题下的 [n] 条目仍作锚点(只在 H1/H2 重判段类型);条目内 DOI 子串(末尾 "DOI: 10.xxx")也做 https://doi.org 超链接;smoke test 验证锚点/回链/外链/化学式下标全在

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:24:31 +08:00
caoqianming 108351864e feat(scheduler): 定时任务 v1 — 对话建/管 + 守护循环执行 + 只读前端 (DESIGN §8.5)
到点把一句自然语言 prompt 喂进 agent 主管线,可跑 skill 出简报 / 发邮件 / 打招呼等。
job 本体 = cron+时区 + prompt + 会话模式;"发邮件"不是字段,是 agent 据 prompt 调
send_email 的动作 → 加任何能力不改 schema。

后端:
- scheduled_jobs 表 + migration 0011(独立加表,公测兼容)
- core/scheduler.py:croniter 算 next_run(时区+vixie OR 语义)、claim+advance 防重复触发、
  失败阈值自停、notify 兜底投递、CRUD 服务层(工具与 REST 共用不漂移)
- 守护循环 _scheduler_loop(lifespan,仿 _disk_scanner 的 plain-asyncio,不引 APScheduler/Celery;
  复用 _run_agent_bg,抢 run 锁、超时协作 cancel、并发上限)
- tools/send_email.py(host-side,SMTP_* 齐才挂)
- /v1/schedules GET/PATCH/DELETE 三端点

对话端 = 完整 CRUD:schedule_create/list/update/cancel 四工具(定时 run 内不挂防自我繁殖)。

前端 = 只读 + 停用/删除:左栏 rail「定时」入口 + crons.js 只读 master-detail modal
(复用 skills modal 范式);建/改故意只走对话,规避 cron 构建器 UX。

会话模式:isolated(默认,每次新建临时 task 省 token)/ persistent(绑 bound_task_id 续上下文)。
env:SMTP_* / ZCBOT_DISABLE_SCHEDULER / ZCBOT_SCHEDULER_TICK_SECONDS / ZCBOT_SCHEDULER_CONCURRENCY。

已验:migration 上库、CRUD 端到端、3 REST + 4 工具注册、crons.js 语法。
待验:起 web 进程跑一轮真实触发 + 邮件 smoke。bump 0.18.0 → 0.19.0。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 13:42:31 +08:00
caoqianming 4f61b5fc56 feat(skill): brief 科研方向简报(三路检索 documents/research/web)+ 全局化学式下标修复 + bump 0.18.0
新增 brief skill:给定研究方向 + 时间窗,用三路真实数据(documents 内部库取全文 /
research 取近期 DOI 元数据 / web 取政策·会议·标准动向)产出文献计量趋势型简报。
六阶段:定题对齐 spec → 三路检索取数(中→英术语 + 跨源去重)→ 趋势分析(3-7 热点簇)
→ 逐段起草 → 引文核验(复用 paper 三层协议)→ 渲染验收。深度三档 flash/standard/deep。

自带 render_docx.py(简报专属版式):商务红主题 + 正文 [n]/[Wn] 引文上标并锚到文末
+ DOI/URL 可点击超链接 + TL;DR 卡片 + 标题信息带 + 页脚页码。

顺带修 zcbot 全局「角标」问题:水泥化学式在 docx 里平排数字(CO2/C3S/SO3...)是
paper/proposal 渲染器的老毛病。抽一份化学式下标白名单(长在前 + \b 防误伤
LC3/C595/Ca2+/2026,实测命中精确零误伤)统一补进 paper、proposal、brief 三个
render_docx.py 的 add_inline plain 分支(按"自包含 skill 脚本不跨 skill 引"的既有约定
各自复制同一份)。core/export_docx.py 是对话原文转录、非排版文档,不动。

文件:skills/brief/{SKILL.md, templates/{spec,brief_outline}.md,
references/{search_strategy,citation_verify}.md, scripts/{quality_check,render_docx}.py};
SKILL_LIST.md(16→17)+ PROGRESS.md 同步。bump 0.17.0 → 0.18.0。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 11:28:03 +08:00
caoqianming e87daa7c89 feat(tasks): 任务软删除(留对话轨迹做语料 + 可恢复)+ bump 0.17.0
DELETE /v1/tasks/{id} 从硬删(DELETE + CASCADE + rmdir)改为软删(置 deleted_at),
messages/usage_events 及工作目录文件全部保留,留作训练语料且可恢复;新增
POST /v1/tasks/{id}/restore;list_tasks/list_folders 计数过滤 deleted_at IS NULL;
delete_file 顶层目录 409 引用检查排除软删 task(避免"任务删了文件夹却删不掉")。
0010 migration 加 tasks.deleted_at(additive 可空,存量行自动视为未删)。
推翻 DESIGN 原 hard-cascade 决策;文件归档方案(restic 备份 + DB 事件日志)写入 DESIGN 待办。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:37:47 +08:00
caoqianming 6d6e9f79b5 docs: 用户操作说明书(详+精简)+ 文献库口径 21W→100W + bump 0.16.2
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 10:34:08 +08:00
caoqianming 660bec0f2f feat(skill): paper 学术论文写作(中英×三类型 + 引文三角核验)+ bump 0.16.1
新增自包含 skills/paper/:把实验数据/前期报告整理成可投稿论文 .docx。
流程骨架取 paper-writer-skill 的"先定图表"纪律 + ARS 的三角引文核验/反谄媚审稿,
底座全换成 zcbot 自有(documents/research 查文献与核验、plot_pub 出图、复用 proposal 渲染心智)。

- 中英双语 × 三类型(original/review/letter)子 md 分流,一篇只挂一套
  cite_gbt7714/cite_elsevier + redlines_zh/redlines_en
- 六阶段:摄取 → 八条对齐 spec → 文献矩阵 → 先定图表 →
  逐章一段一卡(Methods→Results→Intro→Discussion→Abstract→Title) →
  引文三角核验(存在性/三角/支撑度,台账 CITATIONS.md) → 验收渲染 + 投稿件
- 自带脚本:render_docx(--lang 图题切换 + --toc 默认关)/ quality_check
  (引文交叉核对 orphan/uncited/编号连续 + 结构/占位符/过度宣称)/ word_count / render_diagrams
- smoke 验证:happy path 全 OK,orphan/uncited/缺号负例正确触发,render 出 docx
- SKILL_LIST 15→16,PROGRESS 加一条

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 09:55:10 +08:00
caoqianming 0d69ae86e2 feat(media): look_at_image 图像理解(豆包 Seed 2.0 Lite vision)+ bump 0.16.0
DESIGN §8.1 C 路落地 —— 主模型 DeepSeek V4 纯文本无视觉,挂 look_at_image
工具按需读图(OCR / 描述 / 读图表),模型自决何时调。

- 选型:设计时的 Seed 1.6 vision 已过时,改用 Doubao Seed 2.0 Lite
  (doubao-seed-2-0-lite-260428,全模态 SOTA 细粒度感知)。token 计费
  输入 ¥0.6 / 输出 ¥3.6 /Mtok,一次读图 < ¥0.01
- 后端:tools/look_at_image.py(/chat/completions base64 单图+问题→文本解读);
  doubao.yaml 加 vision 段;usage.py 加 record_vision_usage(kind=vision,
  按 token,无需 migration——kind 自由文本);agent_builder 注册 + media prompt 段
- 图片路径解析与 i2i 共用 tools/image_ref.py
- 验证:scripts/smoke_look_at_image.py 真机 OCR 通过(实测 ¥0.0011)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:20:05 +08:00
caoqianming be813629b2 feat(media): seedream i2i 改图 + 前端 paste 路径注入 + bump 0.15.0
- seedream 加 reference_images(v1 单图):传已有图路径做像素级改图,
  不传=文生图行为 100% 不变(向后兼容)。路径解析抽到 tools/image_ref.py
  (三形态路径 + 强制落 user_root 内防越界 + 扩展名/大小校验)
- 前端 chat.js:sendMessage 把粘贴图路径作 [用户上传的参考图] 行注入正文,
  修了粘贴图路径到不了模型的既有缺口("上传外部图→改图"才能定位文件)
- 引导:imagegen SKILL 删旧"不接图像输入"+ 加改图(i2i)专段,纠正
  "该 i2i 却重新文生图丢原构图";agent_builder 媒体 block + SKILL_LIST 同步

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:18:18 +08:00
caoqianming 1cfeb000a6 feat(web): ask_user 工具 — 回复里渲染可点击「方案确认」选项卡 + bump 0.14.0
agent 在真正的分叉点(2-4 个互斥方向且选择会实质改变后续动作)调用 ask_user,
前端渲染可点击选项卡:点一个即作为回复继续,或不点直接用文字讨论。

收窄定位防 agent 变爱问(高轮数烧 token 已知痛点),系统提示严格约束使用条件。
与轮次模型同构、无阻塞:ask_user 是虚拟工具(同 task_progress 范式),loop 检测到
本步调用它就提前结束本轮、不回灌 LLM;点选项=发该选项 label 作新用户消息,零额外
LLM 往返。选项落在 tool_calls.arguments 里,刷新页面按钮还在;已答的卡自动置灰。

- tools/ask_user.py 新增 AskUserTool;core/agent_builder.py 注册
- core/loop.py 加 ask_user 提前终止分支
- prompts/system/general_v1.md 加「方案确认约定」段
- web/static/js/chat.js buildAskUserCard + SSE/历史重渲特判 + sendMessage(overrideText) + 点击委托
- web/static/dev.html 加 .ask-user/.ask-option 样式

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:23:59 +08:00
caoqianming 91e200ef4f feat(web): 消息目录-右侧悬浮圆点轨道导航(ChatGPT 式)+ 双向分页 + bump 0.13.0
右缘悬浮圆点轨道:每点=一轮"我"的提问,hover 展开标题,点击滚动定位+高亮,
滚动自动高亮当前轮;覆盖全部轮次(非仅当前窗口)。

后端:新增 GET /v1/tasks/{id}/outline(只取 role=user 的 idx+首行片段,不回传整
payload);list_messages 加 after_idx 参数 + has_more_after 响应,支持向下翻页
(从目录跳旧消息后补回下方未加载的新消息)。纯增量,旧前端不受影响。

前端:消息卡补 data-idx 锚点;jumpToMessage 已加载则 scrollIntoView、未加载用
before_idx 拉居中窗口再定位;refreshOutline 并入 selectTask 并发拉 + run 收尾刷新;
dev.html 加 #msg-outline-rail(容器 pointer-events:none 不挡滚动条、仅圆点可点),
手机端隐藏,embed 页 null-safe。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:52:39 +08:00
caoqianming 82feecef06 perf(web): 切 task 并发拉 meta+messages + 默认窗口 60→30 + bump 0.12.16
selectTask 里 meta 与 messages 原本串行 await,改 Promise.all 并发省一个 RTT;
MSG_PAGE 60→30 降首屏传输与 markdown/highlight 同步渲染量。切 task 慢非索引问题
((task_id, idx) 唯一索引已覆盖主查询),故只优化前端串行与窗口大小。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:21:26 +08:00
caoqianming ec27fcae3e feat(skill): plot_pub 吸收 nature-figure 投稿级复合图设计纪律 + bump 0.12.15
调研 nature-figure skill(MIT)后只迁移可复用设计 IP,不整包移植
(避免与 plot_pub 重叠、R/生物内容不适配、多文件结构破坏单 SKILL.md 约定)。

- style.py: 补 svg.fonttype='none'(原只设 PDF Type 42,漏了 SVG 可编辑)
  + SEMANTIC_COLORS 语义色表 + clean_spines() + ablation_alphas()
- SKILL.md: 新增「投稿级多 panel 复合图」段(五点 figure contract /
  语义配色 / 信息架构 + spine 纪律 / 导出纪律),示例改建材领域
- SKILL_LIST.md / PROGRESS.md 同步;纯 Python 零新依赖,保留中文字体

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:54:32 +08:00
caoqianming 5caa3db62e perf(web): 消息尾部窗口分页 + 向上滚动加载更早 + 切 task loading 占位 + bump 0.12.14
切 task 卡顿:/v1/tasks/{id}/messages 无分页全量拉 + 前端全量渲 DOM,消息多时两段成本线性涨。

- 后端 list_messages 加可选 limit/before_idx:不传=旧行为(升序全量,仅多返 has_more,向后兼容);传 limit 取尾部最近 N 条,before_idx 取更早一批,响应恒含 has_more
- 前端 selectTask 进来立即换「加载中…」占位(治感知);loadMessages 默认 limit=60
- 新增 loadEarlierMessages + _msgScrollObserver(复用 task list sentinel 范式):顶部 sentinel 进视口自动 prepend 更早一批后整窗重渲,锚回滚动位不跳视口
- state 加 loadedMessages/msgHasMore/msgLoadingEarlier;dev.html 加 .msg-top-sentinel 样式

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:30:42 +08:00
caoqianming 888824ba85 feat(web): 图片预览放大后左键拖动平移 + 光标语义改正 + bump 0.12.13
- 光标:100% 改普通箭头(原 zoom-in 放大镜误导,左键不缩放);放大后 grab、
  拖动中 grabbing。
- 左键拖动平移:放大态 mousedown 记起点+滚动位,mousemove 改 body
  scrollLeft/Top 平移;img.draggable=false 关原生拖拽。document move/up
  监听存 z._onMove/_onUp,_clearZoom 时移除避免泄漏。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:00:15 +08:00
caoqianming eb1027b040 fix(web): 图片缩放 load 即量基准尺寸 + 双击复位提示 + bump 0.12.12
- _captureBase:图片加载完即量贴合尺寸做基准,避免首次缩放时还没渲染量到
  0px 把图片塌成 0;量不到则本次跳过不破坏。
- 双击复位徽标显式提示「已复位 · 100%」(停留 1.4s)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:53:00 +08:00
caoqianming 12171a4bdf fix(web): 图片预览缩放改显式 px,修 CSS zoom 被 flex max 夹回放不大 + bump 0.12.11
CSS zoom 对带 max-width/height:100% 的 flex item 无效(放大后被百分比 max
重新约束回去,视觉不变)。改为:以 scale=1 的贴合显示尺寸为基准缓存,缩放时
max:none + 显式 width/height = base × scale 像素,真正撑大布局让 body 出
滚动条;复位时清空还原自适应。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:42:37 +08:00
caoqianming 31f46baaf6 fix(web): 文件预览修滚动穿透 + 图片 Ctrl+滚轮缩放 + bump 0.12.10
- 滚动不穿透:主/小预览 .body 加 overscroll-behavior: contain,再挂一次性
  非 passive wheel 监听,容器不可滚或到边界时 preventDefault 断冒泡,背景
  对话列表不再被带滚。
- 图片缩放(仅图片):Ctrl+滚轮 ×1.1 步进(夹 0.1-8x),用 CSS zoom 而非
  transform(zoom 改布局盒,放大后 body 才出滚动条能看溢出);右下角 xx%
  比例徽标(挂 .card,滚动不跟走,1s 淡出);双击复位 100%;.body.center
  改 safe center 防 flex 居中裁掉溢出顶/左。
- wheel 监听只 init 挂一次到复用 body,缩放目标走 _zoomState WeakMap,免
  每次预览重复 addEventListener 泄漏。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:31:12 +08:00
caoqianming 314a05e111 fix(sandbox): 容器装 fonts-noto-color-emoji 修 mermaid 图满图豆腐块 + bump 0.12.9
模型生成的 mermaid 节点标签常前缀 emoji 图标(🌐🔥🛡 等),容器只装了
CJK 字体缺 emoji 字体,chromium 渲染时每个 emoji 都成空心方框 □。加
fonts-noto-color-emoji(+~10MB)并 fc-cache 刷索引即可正常出图标。
纯增量容器改动,需重建镜像 + 重启 per-user 容器生效。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:21:39 +08:00
caoqianming 977923b6cf feat(web): 左栏任务筛选区默认折叠(偏好仍持久化)+ bump 0.12.8
进页面只见「筛选 ▸」一行,点开才展开;用户显式展开过(localStorage 存 "0")才默认展开,否则一律折叠。已选筛选条件折叠后仍生效。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 08:56:46 +08:00
caoqianming 211b008821 fix(sandbox): 容器渲 mermaid 开箱即用(mmdc wrapper) + system 按 backend 注入运行环境段 + bump 0.12.7
接 --shm-size(0.12.5)。修两层让容器渲 mermaid 不再反复栽:

执行层 ── mmdc wrapper:
- Dockerfile 给 /usr/local/bin/mmdc 套 wrapper,没显式 -p 时自动注入
  -p /sandbox/puppeteer-config.json(含 --no-sandbox/--disable-dev-shm-usage),
  裸调 `mmdc -i x -o y` 一次成;render_diagrams.py 等走 which mmdc 的脚本透明受益。
- 删掉没人读的 MERMAID_PUPPETEER_CONFIG env(mmdc 只认 -p)。

引导层 ── system prompt 按 backend 注入「运行环境」段:
- general_v1.md 删写死的 "Windows+cmd" 平台段(线上是 docker=Ubuntu 容器+bash,
  误报导致模型在 Linux 里打 cmd 构文)。
- agent_builder 注入 _CONTAINER_ENV_BLOCK(docker)/_HOST_ENV_BLOCK(host):写明
  Linux/bash、渲图走本地 mmdc 别调境外在线服务(mermaid.ink 被墙,容器虽有外网但
  渲图不该依赖出站)、mmdc/chromium/中文字体已装。
- 撤回上一轮加到 imagegen 的渲图引导(环境事实收归 system)。

顺带:RUN.md 修正把 sandbox 网络写成 --internal 无 outbound 的过时注释(实际 bridge+NAT
有外网,见 network.py)。

部署:Dockerfile 改动需 rebuild 镜像;prompt 改动重启 web 生效。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 08:30:43 +08:00
caoqianming 32bf6ae917 fix(sandbox): docker run 加 --shm-size 修 mmdc 渲 mermaid 挂超时 + bump 0.12.5
容器 /dev/shm 默 docker 64MB,chromium(mmdc/puppeteer)起不来一直挂到 timeout。
实测一个"生图测试"对话:模型裸调 mmdc,自造 puppeteer config 漏 --disable-dev-shm-usage,
连试 6 次全超时烧约 120k token。从根上给 docker run 加 --shm-size(默 512m,
env ZCBOT_SANDBOX_SHM_SIZE / yaml sandbox.shm_size 可配),任何 chromium 路径都不再挂。

- core/sandbox/pool.py: --shm-size 旋钮(优先级同 memory/cpus)
- config/agent.yaml / RUN.md: 新增 shm_size 配置 + env + 故障兜底一行
- deploy/sandbox/probe_mermaid.sh: 实测脚本(区分 chromium 缺包 vs 纯 shm 超时)
- scripts/diag_dump_task.py: 按 email+任务名 dump 对话的诊断脚本
- 已 running 旧容器需重启 web + idle 回收后新起才生效;镜像无需 rebuild

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:40:45 +08:00
caoqianming d30435198c feat(web): 模型选择瘦身 — 对话模型常驻 + 生图/生视频收进 ⚙ 弹层 + bump 0.12.4
- meta 行原三个带标签下拉(模型/生图/生视频)占满整行 → 高频对话模型常驻可见可切,
  低频生图/生视频收进一个「⚙ 媒体」fixed 弹层(点开才渲染 select)
- 行为不变:媒体模型选中值仍只进 state.*,随下条消息 image_model/video_model 发;
  send 读 state 不读 DOM,迁移安全;两个 select 都没配时连 ⚙ 都不画

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:07:51 +08:00
caoqianming 18f702886f feat(web): 左栏筛选区可折叠(默认展开,偏好持久化)+ bump 0.12.3
- 搜索/状态/目录/排序四控件归到两行 .task-filter-row,标题行加「筛选 ▾」toggle
- 默认展开,折叠只藏 UI(已选条件仍生效),偏好存 localStorage(同 pane 折叠范式)
- 折叠后左栏顶部 4 行→2 行,任务列表可视区更高
- 状态下拉并入筛选区(flex),搜索框 flex:2 更宽;目录/排序合一行用 title 提示

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 12:58:33 +08:00
caoqianming ae9790601a refactor(web): 中栏操作收进 ⋯ 菜单 + 消息阅读限宽 + 色彩收敛 + bump 0.12.2
- 中栏顶栏 5 个平铺按钮(导出/清空/完成/废弃/删除)→「完成」+「⋯」菜单,
  菜单复用 taskMenuItems(过滤 complete),与任务行同一范式;破坏性操作不再平铺易误点。
  顺带让菜单「清空」按 run_status 也禁用(修运行中 409-after-confirm 小坑)
- 消息限宽:.msg max-width 92% → min(92%,48rem),user 气泡 min(92%,36rem),宽屏可读性↑
- 色彩收敛:颜色=后果(完成/下载绿、废弃橙、删除红),导出/清空中性不着色;移除紫/蓝冗余

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 12:52:56 +08:00
caoqianming 1f57bbd201 fix(web): 导出按钮简写 + 任务菜单加清空 + 修移动端 task 滚动 + admin 自适应 + bump 0.12.1
- 顶栏「导出对话记录」→「导出对话」,与「清空对话」对齐(dev.html + chat.js 菜单 export 项)
- 任务菜单(每行 ⋯)新增「清空对话」,复用 clearMessages;dropdown 加 .act-clear 紫色
- 修移动端 task 列表无法滚动:手机断点 #pane-left 误用 display:block 致 #task-scroll
  flex:1 失效被 overflow:hidden 截断;改 display:flex 恢复滚动
- admin 移动端自适应:header 紧凑化 + .card-head/.ctrl 允许换行,避免窄屏横向溢出

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 12:41:53 +08:00
caoqianming c870b10368 feat(memory): 双层记忆升级为 agent 自管 + 前端只读记忆面板 + bump 0.12.0
写入路径从纯手工改为 agent 自管(prompt 契约,非后台蒸馏):memory_block
注入可写路径锚点 + 「记忆维护契约」,契约/锚点常驻(记忆为空也注,解新用户
冷启动)。extended 索引从首行标题升为优先 frontmatter description(缺则退回
首行,平滑兼容存量)。修旧 bug:extended 路径在 docker 下注的是宿主路径指不到,
改按 backend 给 host 绝对路径 / /workspace/.memory。

前端记忆面板取舍 = GUI 当眼睛、模型当手:左栏「记忆」按钮开只读 modal 看全貌
(GET /v1/memory + GET /v1/memory/extended/{filename},零写/删 API,路径穿越
校验收口在 core/memory.py)。"看全貌"是读不是 operation,走 LLM 又贵又只拿
转述;"改"全走对话(agent 自管),单一写入口 + 自然语言 + 不会写坏 frontmatter。
对照业界:Claude(同文件式)给全套 view+edit,ChatGPT/Gemini 黑箱只给看/删。

单测覆盖:frontmatter 解析 / legacy 兜底 / 空记忆常驻契约 / host·docker 路径 /
只读视图 / 单篇读 / 文件名安全 / 越界拦截。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 12:20:08 +08:00
caoqianming 0259f0ce92 docs(compat): 进入公测期,开发心智翻新为保证对外兼容 + bump 0.11.1
- CLAUDE.md「开发阶段心智」从"开发期可随意 break、不写兼容层"改为:
  对外契约(用户数据/DB schema/对外 API/CLI·env·文件布局)必须向后兼容,
  仅纯内部实现仍以最优为准放手重构;拿不准 → 当对外契约处理
- 版本号段:公测保持 0.x,1.0 留给对外冻结行为 / 正式 GA
- PROGRESS 加一条;bump 0.11.0 → 0.11.1

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 11:23:03 +08:00
caoqianming f12df1bd82 feat(admin): 后台目录导航 + 按模型/各用户用量时间筛选排序 + 存储分页 + 导出 PDF + bump 0.11.0
- 左侧目录(sticky,点击平滑滚动 + scrollspy 高亮,窄屏转横向 chip);各区 scroll-margin-top 避开顶栏
- 按模型 / 各用户用量拆为独立端点,带 range(all/7d/30d)+ sort(cost/tokens);
  各用户用量含零用量用户(时间条件放 JOIN ON,避免被 cutoff 挤掉)
- 存储分页(/v1/admin/storage/users);各用户用量分页;overview 瘦身为固定指标(runtime/tasks/users/总用量+近7d),独立表自管 range/sort/page
- 导出 PDF:客户端 window.print()(零依赖),填充隐藏报告 DOM + @media print 版式;列表取前 10
- 文档同步 DESIGN §7.3 / PROGRESS / RUN

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:44:47 +08:00
caoqianming 81da2f6f55 fix(context): 不压 assistant tool_call 参数,断 run_python 投毒空转
旧 assistant tool_call.arguments(>800 字符)被压成 {"_compacted":...} marker 发给
LLM,模型在长 doc/ppt 任务里反复看到后仿写它当真实参数 → run_python 拿不到
code/script_path 报错空转(DB 实测最近 60 个 task 命中 83 次,其中 61 次是模型仿写
marker)。把原本只给 task_progress 的豁免升级成通用规则:删 _compact_assistant_tool_calls
/ _compact_tool_call_arguments,只压 tool 结果 + skill,assistant 参数一律原样保留。

附诊断脚本 scripts/diag_run_python_empty.py / diag_run_python_trace.py;全量 120 tests OK。
bump 0.10.0 -> 0.10.1

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:41:54 +08:00
caoqianming ef611b0666 feat(admin): 角色化管理后台 + 分页各用户用量 + bump 0.9.0
- users 加 role 列(user/admin,migration 0009);make_require_admin 按 DB role gate(不进 JWT,改完即时生效)
- /v1/admin/overview 监控总览:runtime(并发/线程池/SSE/RSS)+ tasks + users + usage 总用量 + storage
- /v1/admin/usage/users 分页各用户 token 用量(全表 LEFT JOIN 含零用量,cost desc,稳定排序)
- /v1/me 返 role;登录/建用户响应带 role;main.py user role / user add --role;建用户弹框加角色下拉
- 独立页 web/static/admin.html + js/admin.js(阈值/热力色差、响应式、10s 轮询、独立翻页);dev SPA admin 才显"管理"入口
- 文档同步:DESIGN §7.3/§7.4、PROGRESS、RUN

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:02:20 +08:00
caoqianming 44be5753f7 fix(version): 版本号挪到右侧存储条最左,垂直居中 + bump 0.8.1
- dev.html: #app-version 从左栏 #rail-resources 移入 #storage-foot 最左,
  带细分隔线,垂直居中(align-items:center);左栏底部腾给后续按钮
- core.__version__ 0.8.0 → 0.8.1(纯 UI 位置微调,patch)
- PROGRESS 同步新位置描述

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:55:35 +08:00
caoqianming dd797a91e2 feat(version): 版本号单一事实源(core.__version__)+ web 左栏底部展示
- core/__init__.py 新增 __version__ = "0.8.0",作唯一来源
- web/app.py: FastAPI version 与 /healthz 返回都引它(不再写死两份)
- dev.html: 左栏「我的资源」技能按钮旁加 #app-version 小灰字(纯展示)
- main.js: boot 时无条件 fetch /healthz 填版本号(auth 豁免,embed/未登录皆可)
- 放左栏底部而非顶栏:embed 模式桌面端 header 被 CSS 隐藏,顶栏点不到
- CLAUDE.md「文档维护」加规矩:每次 commit/push bump __version__(patch/minor/major 分类)
- RUN.md / PROGRESS.md 同步

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:00:30 +08:00
caoqianming 15d69b3372 feat(ops): 并发/线程池轻量监控 + 接管默认 executor
已上生产后线程池排队此前无观测手段:
- lifespan 显式建 ThreadPoolExecutor(尺寸复刻 Python 默认 min(32,cpu+4),
  env ZCBOT_RUN_MAX_WORKERS 可调) + set_default_executor 接管 —— 行为不变
  (匿名池换显式同尺寸池),但 max_workers 可读、成调并发的旋钮
- _stats_logger 每 60s 采样:active_runs(含排队)逼近 max_workers 即排队,
  刷新峰值/有负载打 [stats],空闲静默不刷屏
- broker.total_subscribers() 全局 SSE 订阅数;RSS 用 stdlib resource
  (Unix 峰值;Windows dev 降级),零新依赖

不做监控界面:运维健康是少数标量日志够,业务分析走 SQL。DESIGN 8.4 记
取舍 + 界面阶梯;无感换版(gunicorn/Redis/蓝绿)成本不抵当前收益,搁置。

查看: journalctl -u zcbot | grep [stats]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:09:44 +08:00
caoqianming f614046438 feat(web): 技能弹框平台/我的拆成独立两栏(三栏 master-detail)
平台 skill 多、我的是用户核心区,混一栏易被淹。改三栏:平台列表 |
我的列表 | 正文,我的获得与平台对等版面。modal 加宽到 1000px;窄屏
(<=760px)三栏自动上下堆叠。load_errors 归到「我的」栏底。纯前端。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:22:06 +08:00
caoqianming d89ebad272 feat(web): 技能按钮换扁平 SVG 图标 + 弹框改两栏 master-detail
- 左侧 rail「技能」按钮 emoji 🧩 换成扁平 inline SVG(2x2 grid,
  描边 currentColor 跟随主题);modal 标题同步。
- modal 从"列表→点开换正文+返回"改两栏:左栏平台/我的两组列表(可选中
  高亮),右栏展示选中 skill 完整 SKILL.md;删除按钮挪到右栏正文头部
  (只对我的),左列表保持干净只显名+描述。窄屏(<=640px)两栏自动改上下堆叠。
- 纯前端,后端/测试无影响。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:08:26 +08:00
caoqianming 958678aa12 feat(skills): 用户私有 skill(.skills)+ 创作工具 + skill-creator + Web 查看页
每用户可在私有 .skills/ 下造/改 skill,只对自己生效。

- SkillRegistry 改多来源(SkillSource 列表:内置 + 用户 .skills),后扫同名
  覆盖先扫 → user wins;user_overrides 记覆盖关系、discovery 显式标注;
  Skill 加 source;from_dir 区分"非 skill 目录(静默)"与"格式错(SkillLoadError)",
  坏的用户 skill 收进 load_errors 注入 prompt,不崩整次扫描。容器路径改写下沉
  到 registry.container_dir(按 source 给 /sandbox/skills 或 /workspace/.skills),
  LoadSkillTool 去掉 container_skills_dir 参数。
- 新增 host-side 工具 save_skill / fork_skill(tools/skill_authoring.py):
  fs 的 base_dir 锚 cwd/容器 wd 够不到 user_root/.skills,故用 host-side typed
  tool(与 seedream/document_* 同范式)。save_skill 写时校验 frontmatter;
  fork_skill copytree 整目录(带脚本)+ 自动对齐 frontmatter name。
- 新增 skill-creator 引导 skill(重点教写好 description + fork 语义)。
- Web:左侧 rail 底部「技能」按钮 → modal 分平台/我的两组,点开看完整
  SKILL.md,我的可删;后端加 GET /v1/skills/{name}(正文)+ DELETE
  /v1/skills/{name}(只删 user 源 + 防穿越);/v1/skills 带 source/overrides/
  load_errors;新 web/static/js/skills.js。创建/改/fork 仍走对话。
- .skills 是 dotfile(文件面板隐藏,与 .memory 一致;validate_task_name 已禁
  . 起头 working_dir,天然不撞)。
- 测试:test_user_skills.py(20 例)+ 改写 test_load_skill.py;全 121 过。
- 文档:DESIGN §3.5 / PROGRESS / RUN(布局+端点)/ SKILL_LIST 同步。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:46:39 +08:00
12089 changed files with 193342 additions and 4202 deletions

11
.gitignore vendored
View File

@ -49,3 +49,14 @@ untitled*.pptx
规划.docx
cl.ps1
col.ps1
# brief skill 临时样例输出 (可由 skill 重新生成, 不入库)
.brief_out/
# ClawBot 接入探测临时产物 (二维码图 / 测试文件, 探测时重新生成, 不入库;
# 探测脚本 scripts/probe_clawbot*.py 保留作参考与复测)
scripts/clawbot_qr*.png
scripts/zcbot_filetest.txt
# 诊断脚本的使用即弃 dump 输出(diag_*.py 写本地,不入库)
scripts/_*.txt

View File

@ -24,16 +24,24 @@ PowerShell here-string `@'...'@` **只在 PowerShell 工具里有效**;用 Bash
理由:开发期需求漂移快,写到一半被推翻代价高 —— 口头对齐方案是最低成本的纠偏机会。
## 开发阶段心智
## 开发阶段心智(公测期:保证对外兼容)
当前处于**开发测试期**(开发自用 + 内部测试,DB 已有真实测试数据)。改需求 / 重构时,**以最优实现为准,不为旧数据 / 旧字段 / 旧 API 留兼容层**,但**不删现有数据**:
- DB schema 变 → 直接改 model + 写一条干净的 migration:加列 / 改列结构 OK;**不要 truncate / DELETE FROM 现有表 —— 测试数据要保留**
- 删字段(DROP COLUMN)前:若该列是当前唯一持有该信息(如累计型 tokens 列),先 backfill 到新位置再删;若纯冗余(从其他列能推出)直接删 OK
- 字段语义变 → 全量替换 + migration 把旧值映射到新值(不留 `legacy_xxx` / `*_v2` 并存)
- CLI / REPL 选项变 → 直接改,不留 deprecated 别名
- 只有当用户明确说"这条要保留兼容"时才写兼容代码
**已进入公测期**(对外真实用户在用,DB 里是真实用户数据 + 线上正在跑的会话)。心智从"开发期可随意 break"切换到**对外面必须向后兼容、对内部实现仍以最优为准**。判断一处改动能不能随意改,先问:**它是不是外部用户能感知 / 依赖的契约?**
理由:兼容层是技术债;但测试数据是观察新代码行为的依据 —— 一次 truncate 后再回去查"上周那 task 烧了多少 token / 哪条消息触发的 bug",就只能瞎猜。
**对外契约 —— 必须保证兼容,break 前先有迁移路径**:
- **用户数据**:绝不 truncate / DELETE FROM / 重置现有表 —— 这是用户的东西,丢了无法恢复
- **DB schema**:加列 / 改列 OK,但要写干净 migration 且**平滑兼容线上存量数据**;删字段(DROP COLUMN)前先 backfill 到新位置,确认无引用再删
- **字段语义变**:全量替换 + migration 把旧值映射到新值,且要考虑**线上正在跑的旧请求**读到该字段时不崩
- **对外 API(HTTP 接口 / 请求·响应 schema)**:不改既有字段语义、不删字段、不改 URL;要变先加新字段 / 新端点,旧的留一个废弃窗口
- **CLI / REPL 选项、env 变量、文件布局**:改名 / 删除前保留 deprecated 别名一个版本,并在 RUN.md 标注废弃;直接 break 会打断正在用的人
**对内部实现 —— 仍以最优为准,放手重构**:
- 纯内部模块 / 函数 / 私有数据流(外部不可见、无人依赖)→ 该重写重写,不留 `legacy_xxx` / `*_v2` 并存
- 内部重构只要**对外行为不变**(同样的输入 → 同样的输出 / 同样的 schema),不算破坏兼容
**拿不准是"对外契约"还是"内部实现"时 → 当成对外契约处理(先对方案,见上一节)。** 只有用户明确说"这条可以 break / 不用兼容"才走破坏式改法。
理由:公测后"随意 break"的前提(只有自己的测试数据、坏了重来)已不成立 —— 现在每次破坏式改动都可能弄丢真实用户数据或打断线上请求。兼容层确实是技术债,但比起搞坏用户数据,这点债值得背;等正式打 1.0、对外冻结行为后再统一清理废弃面。
## 文档维护
@ -42,6 +50,12 @@ PowerShell here-string `@'...'@` **只在 PowerShell 工具里有效**;用 Bash
- 状态表(§7 B Step 几 / Phase 几)若变化跟着改
- 文件清单若新增 / 删除模块跟着改
**每次 commit / push 必须 bump 版本号** —— 单一事实源是 `core/__init__.py``__version__`(web/app.py 的 FastAPI version、`/healthz` 返回、前端左栏底部展示都引这里,改版本只动这一行):
- patch(`0.8.x`):bug 修复 / 重构 / 调参 / 新加 skill / 样式
- minor(`0.x.0`):成批新功能 / 明显的对外行为变化
- major(`x.0.0`):1.0 正式发版 / 不兼容大重构
- 当前 `0.x` **公测期**,1.0 留给"对外冻结行为 / 正式 GA"那一刻;公测中保持 `0.x` 迭代,minor 走新功能、patch 走修复
**只有以下情况才动 `DESIGN.md`**(避免把工程笔记沉淀成设计):
- 架构 / 心智模型变化(如 §7.1 task-primary 重写)
- 取舍决策推翻或新增(§5 / §7.9 类内容)

257
DESIGN.md
View File

@ -31,6 +31,7 @@ zcbot/
│ ├── skills.py # SkillRegistry(Anthropic 渐进披露)
│ ├── task.py # TaskState
│ ├── memory.py # per-user .memory/ 双层记忆
│ ├── shortcuts.py # 快捷指令(触发词→完整指令,入口层确定性展开;.memory/shortcuts.md)
│ ├── paths.py # task_dir db form 归一(to_db_path / from_db_path)
│ ├── storage/{engine,models,utils}.py # SQLAlchemy 2.x ORM
│ └── agent_builder.py # 装配 lib:build_agent / system prompt / validate_task_name
@ -39,7 +40,8 @@ zcbot/
│ ├── fs.py # read / write / edit (唯一匹配) / glob / grep
│ ├── shell.py # subprocess + 黑名单
│ ├── run_python.py # tmp .py + subprocess + 敏感 env 过滤
│ └── skill_tool.py # load_skill
│ ├── skill_tool.py # load_skill
│ └── skill_authoring.py # save_skill / fork_skill(host-side 写用户 .skills)
├── skills/{coding,ppt,proposal}/ # SKILL.md + references / scripts / assets
├── prompts/system/general_v1.md
├── config/{agent.yaml, models/*.yaml}
@ -76,7 +78,7 @@ ReAct:LLM → 若有 tool_calls 就执行 → 结果塞回消息 → 再调 LLM
yaml 是手填的,probe 用真实调用对账:`basic_chat` / `parallel_tools` / `thinking_mode` / `long_context`(opt-in)。不改 yaml,只出 rich Table 报告。**显式触发,不进启动路径**(避免烧 API)。
### 3.4 工具系统(Hybrid 范式)
**JSON tool call**(`tools/`):read / write / edit / glob / grep / shell / run_python / load_skill — 离散操作。
**JSON tool call**(`tools/`):read / write / edit / glob / grep / shell / run_python / load_skill / save_skill / fork_skill — 离散操作。
**Code execution**(`run_python`):tmp `.py` + subprocess + 工作目录限制 + 敏感 env 过滤(`*API_KEY *TOKEN *SECRET *PASSWORD *PRIVATE_KEY`)— 批处理 / 算数据 / 生成文档。
关键设计:`edit` **唯一匹配**(CoreCoder 风格,old_str 重复即报错);工具按**原子操作**切分,不做 `make_pptx()` 这种高级封装。
@ -84,6 +86,8 @@ yaml 是手填的,probe 用真实调用对账:`basic_chat` / `parallel_tools` /
对齐 Anthropic 2025-12 开放标准。三层加载:Discovery(`name + description`,几百 token)→ Activation(`load_skill(name)` 加载完整 SKILL.md,1-5K)→ Execution(SKILL.md 指 `references/xxx` 按需拉)。
原则:写 WHY+WHAT,不写 Step 1/2/3。description 决定模型能否触发。
**用户私有 skill(多来源 registry,2026-06-11)**:`SkillRegistry` 收**有序来源列表**——内置 `ROOT/skills`(只读)+ 用户 `user_root/.skills`(可写,per-user)。用户来源排后,**同名覆盖内置(user wins)**;覆盖在 discovery 显式标注,不静默。取舍:① **user wins** 而非 namespace 隔离——核心用例是"copy 内置 skill 再改",同名覆盖才符合"我的覆盖全局"直觉,且 skill 是纯指引、覆盖只作用于该用户自己会话,blast radius 锁死;② **创作走 host-side typed tool**(`save_skill`/`fork_skill`)而非 fs/shell——fs 的 base_dir 锚 cwd(host)/ 容器 wd(docker),够不到 `user_root/.skills`,跨 backend 不可靠;host-side 工具知 user_root,一个落点两模式通吃(与 seedream/document_* 持 key host-side 同范式),且 `fork_skill` copytree 整目录解决"带脚本 skill 的 fork";③ 用户来源加载失败(YAML 坏 / 缺 description)收进 `load_errors` 注入 prompt 提示用户修,不静默丢、不崩整次扫描。
### 3.6 Session 与 Task
**Session**(`core/session.py`)= 消息列表 + meta,**直接 ORM 写 PG `messages` 表**(append-only,`jsonb` 存 LiteLLM 原样 payload)。
@ -105,12 +109,18 @@ yaml 是手填的,probe 用真实调用对账:`basic_chat` / `parallel_tools` /
| 层 | 文件 | 加载 | 适合 |
|---|---|---|---|
| Core | `core.md` | 每次 build_agent 进 system prompt | 跨任务高频精炼事实(几百 token) |
| Extended | `extended/*.md` | 索引(标题+绝对路径)进 prompt,内容靠 `read` 工具按需拉 | 大量低频专题 |
| Extended | `extended/*.md` | 索引(frontmatter `description`,缺则退回首行标题 — legacy 兼容)+ 可写绝对路径进 prompt,内容靠 `read` 工具按需拉 | 大量低频专题 |
**system prompt 每次 build_agent 重建**(resume 也是),memory 演化即时生效。memory 由人填(也允许 agent 用 `write` 写),系统不自动维护 — **事实由用户判断,不由 LLM 自动总结**
**system prompt 每次 build_agent 重建**(resume 也是),memory 演化即时生效。
**写入路径 = agent 自管(prompt 契约,非后台蒸馏)**:`memory_block` 把 `.memory/` 的**可写绝对路径锚点** + 一段「记忆维护契约」一起注进 prompt(契约 + 锚点常驻,即使记忆为空,否则新用户冷启动不知道自己能记)。契约规定:学到跨 task 复用的稳定事实就当场用已有 `write`/`edit` 存,写前 `grep`/`read` 查重(更新而非堆重复),extended 一事一文件 + frontmatter `description`(这行进索引决定召回)。**不引专用 `remember` 工具**(复用 fs 工具,改动最小);**不做后台自动蒸馏**(不烧额外 token,人仍可审核/手编)。路径锚点按 backend 给 host 绝对路径 / docker `/workspace/.memory`(同 working_dir 的容器路径转译)。
**memory 永远在 FS,不入 DB**:本地 `workspace/users/<user_id>/.memory/`,SaaS `<storage_root>/users/<user_id>/.memory/`(bind mount 进容器)。**dotfile `.memory/` 命名**避免项目名取 `memory` 时撞;`validate_task_name` 拒 `.` 起头双向防呆。理由:用户笔记语义,FS 读写 + 编辑器手编是产品的一部分;跨 task 共享靠"同一 user 同一目录"自动达成,无需 schema。
**前端记忆面板 = 只读窗口,"改"全走对话(取舍)**:web 左栏「记忆」按钮开只读 modal,直接读 FS 渲染全貌(`GET /v1/memory` 全貌 + `GET /v1/memory/extended/{filename}` 单篇),**故意不提供写/删 API**。理由:① "看全貌"是读、不是 operation —— 走 LLM 反而又贵又只能拿到转述,看地面真相必须直读 FS;② "改"走对话(agent 自管,上文契约)= 单一写入口、自然语言、能合并改写,且用户不会写坏 frontmatter。对照业界:Claude(同为文件式记忆)给全套 view+edit;ChatGPT/Gemini 黑箱式只给看/删、长期不支持内联编辑。我们取"GUI 当眼睛、模型当手":既守住文件式记忆的透明卖点,又不引第二套写代码。后续若"删一条 / prune 臃肿 core.md"这类确定性精确操作摩擦明显,再单加直接的 delete(delete 是唯一廉价且确定性强、值得直连的 mutation,同 ChatGPT 做法)。路径穿越校验收口在 `core/memory.py`(只许 `.memory/extended/` 下扁平 `.md` + resolve 子树兜底)。
**快捷指令 ≠ memory(两种机制,别混)**(`core/shortcuts.py`):触发词 → 完整指令的映射,存 `.memory/shortcuts.md`(`| 触发词 | 指令 |` 两列 md 表)。**关键区别**:memory 是注上下文、给模型**概率召回**的软上下文;快捷指令是入口层、模型跑之前的**确定性替换** —— 每条入站消息先经 `shortcuts.expand(ws, uid, text)` 整条 `strip()+casefold()` 精确匹配,命中即把文本换成完整指令再跑 agent(与「新话题」魔法命令同风格,"帮我出个简报"不误伤)。取舍:① **性能** —— shortcuts.md **内容永不注上下文**(触发靠入口层查表,不靠模型),存再多条平时上下文也是 0,触发时进上下文的就是那条完整指令本身(= 用户本来要打的字),无额外 token;若反过来把它塞进 core.md 让模型概率召回,则既不确定、又每轮烧 token,正是本设计要绕开的坑。② **渠道无关** —— `expand` 在渠道核心 `_run_channel_conversation`(微信/企业微信)与网页 `post_message` 两处共用,任意入口打同一触发词行为一致。③ **维护复用 memory 心智** —— 存储蹭 `.memory/` per-user 壳(agent 已有写权限),`memory_block` 加一行契约让模型在用户说"记个快捷词 X→Y"时写 shortcuts.md;但这行契约只讲"能维护 + 格式",不注文件内容。故:**存储借 memory 的壳,触发逻辑独立且确定**。
---
## 4. 模型路由
@ -299,11 +309,13 @@ done {}
### 7.3 认证
**当前形态(D' 过渡)**:两条 login 路径签**同款 JWT**(HS256,`JWT_SECRET` env 签,默 7d TTL):
- `POST /v1/auth/login {user_id, platform_key}` — platform 服务端机器对机器入口,持 `PLATFORM_KEY` 共享密钥可为任意 user_id 签 token(等同 user 身份由 platform 注入)
- `POST /v1/auth/login {user_id, platform_key, name?, user_name?}` — platform 服务端机器对机器入口,持 `PLATFORM_KEY` 共享密钥可为任意 user_id 签 token(等同 user 身份由 platform 注入)。body 可选带 `name`(显示名)/ `user_name`(平台账号名),`ensure_user_row` upsert 落 `users.name/user_name`(`COALESCE(EXCLUDED, 旧值)`:平台传非空就刷新、同步平台侧改名,传 null 不覆盖);响应回带 `{name, user_name, role}`。缺省即旧行为(只填 user_id),向后兼容老调用方。与未来 OIDC 的 `name/preferred_username` claim 注入同构
- `POST /v1/auth/login_password {email, password}` — dev SPA / 同事试用,`users.email` UNIQUE + bcrypt 校验 `password_hash`;`main.py user add` CLI 发用户
- `POST /v1/auth/change_password {old_password, new_password}` — dev SPA 顶栏自助改密,需 Bearer(user_id 从 JWT 取,不信前端);验旧密码 + bcrypt 重哈希;platform_key 入口建的无密码行不可改(403)
- `GET /v1/me` — 返 `{user_id, role, name, user_name, email}`(走 DB 查),dev SPA 据 role 决定显不显"管理"入口,据 name/user_name/email 渲顶栏用户名(默认 name,hover 显账号 / 邮箱)。两条 login 响应同样回带 name/user_name(平滑展示,登录即有名,/v1/me 再校准)
- `GET /v1/admin/*` — 管理后台,`Depends(require_admin)`(验 JWT + `users.role=='admin'`,否则 403)。`/v1/admin/overview` 返固定指标(runtime/tasks/users/usage 总用量+近7d趋势,供轮询);`/v1/admin/usage/models?range=&sort=`、`/v1/admin/usage/users?range=&sort=&page=&page_size=`、`/v1/admin/storage/users?page=&page_size=` 是带时间筛选(all/7d/30d)/ 排序(cost/tokens)/ 分页的独立表端点。独立页 `/static/admin.html`(目录导航 + 客户端打印导出 PDF)。后续续挂建用户/改角色/配置等管理动作
后续 `Authorization: Bearer <jwt>` 走所有 /v1/tasks*,FastAPI `Depends(require_user)` 验签 → 提取 user_id → SELECT/UPDATE 全带 `Task.user_id == user_id` 条件做隔离。`PLATFORM_KEY` / `JWT_SECRET` 任一缺失 → app 启动 fail-fast。
后续 `Authorization: Bearer <jwt>` 走所有 /v1/tasks*,FastAPI `Depends(require_user)` 验签 → 提取 user_id → SELECT/UPDATE 全带 `Task.user_id == user_id` 条件做隔离。`/v1/admin/*` 在 `require_user` 基础上再叠一层 `users.role=='admin'` 检查(`make_require_admin`)。`PLATFORM_KEY` / `JWT_SECRET` 任一缺失 → app 启动 fail-fast。
**信任模型**:platform 是单点可信中间层(持 PLATFORM_KEY = 可为任意 user_id 签 token),风险与"platform 服务端泄漏 = 用户身份泄漏"同级,可接受。
@ -312,14 +324,27 @@ done {}
### 7.4 存储:Postgres + 本地文件系统
```sql
users(user_id uuid pk, email text null unique, password_hash text null, oidc_subject null, plan null, created_at)
users(user_id uuid pk, email text null unique, password_hash text null, oidc_subject null, plan null,
-- plan:模型档位名(0001 起就有列,0.31 起启用;之前休眠)。值是 config/agent.yaml
-- model_tiers 的 key(如 'pro');NULL/未知 → 落 'default' 档。控制该用户能用哪些模型,
-- 详见 core/model_access.py。role=admin 始终全开,不受档位限制。无需 migration。
name text null, user_name text null, -- 0016:平台登录注入的档案(显示名 / 平台账号名);
-- platform_key 入口 ensure_user_row upsert 写,
-- 邮箱密码 / 历史行留空。未来 OIDC claim 注入同构
role text not null default 'user', -- 0009:user/admin;admin 才能访问 /v1/admin/* 管理后台
created_at)
-- email UNIQUE (0005);NULL 不冲突,允许 platform_key 入口 user 共存
-- 入口三条:① main.py user add(bcrypt → password_hash;dev SPA 邮箱密码登录用)
-- ② /v1/auth/login platform_key 路径 ensure_user_row(只填 user_id)
-- ③ 未来 OIDC(替换 login 内部;email/oidc_subject 由 ID token 注入)
-- role:make_require_admin 每请求查(不进 JWT,改完即时生效、老 token 不重签);
-- 提管理员 main.py user role --email X --role admin。与 ZCBOT_ADMIN_TOKEN
-- (发用户共享口令)正交,互不相干
tasks(task_id uuid pk, user_id fk, name text not null, working_dir text not null, skill, description,
status, model_profile, tokens_prompt, tokens_completion, cost_usd,
channel text not null default 'web', -- web/wechat 渠道来源(0013);仅 INSERT 写定,
-- upsert/save 不传不覆盖。前端据此打徽章 + 列表强制置顶
run_status text not null default 'idle', -- idle/running/cancelling/error(0004 合 runs 表)
run_error text null,
created_at, updated_at);
@ -398,7 +423,7 @@ create index on usage_events (model_profile, created_at);
6. **工具按信任域二分,Executor 内部 dispatch**(2026-05-26 修正:原"host 工具走 `resolve_user_path` 校验"是假命题无此函数;dogfood 发现 glob 仍列 host repo,改物理边界替代代码护栏):
- **Container exec backend**:`shell`/`run_python`/`read`/`write`/`edit`/`glob`/`grep` 全走 docker exec。shell/run_python 是任意代码;fs 工具以前 host 跑 `base_dir=Path.cwd()` 无 user_root 校验能读 `/etc/passwd`/源码/`~/.ssh`,进容器后 `user_root=/workspace` 是物理边界。调用形态:`docker exec --user zcbot --workdir /workspace/<wd> -i <c> python /sandbox/tool_runner.py <name>` + stdin 喂 JSON args(CJK/引号透明传);`tool_runner.py` 复用 `tools/fs.py`,skill references 走 `skills:/sandbox/skills:ro` mount。
- **Host in-process backend**:`load_skill`/`web_*`/`seedream`/`seedance`/`document_*`/`mp_*` — 持 key 不能进容器 env;`load_skill` 是内存查找无越界。
- **Host in-process backend**:`load_skill`/`save_skill`/`fork_skill`/`web_*`/`seedream`/`seedance`/`document_*`/`mp_*` — 持 key 不能进容器 env;`load_skill` 是内存查找无越界;`save_skill`/`fork_skill` host-side 写 `user_root/.skills`(沙箱 fs 的 base_dir 够不到)
- Dispatcher(`DockerExecutor`)内部分流,`AgentLoop` 零感知;接口形状按"未来全进容器 + tool-runner unix socket RPC"留好(升级信号见下表)。**代价**:每 fs tool call 多 ~200ms,对话级 N≤15 → 1-3s,LLM 推理 5-30s 下噪声。
7. **Secret-bearing domain tools 不进 sandbox,不做 key 下发**(2026-06-01):凡需 `*_API_KEY`/OAuth/DB credential 的能力**不能**让容器读 env,也不做"credential broker 发短期 key"(sandbox 内任意代码可 `print(os.environ)`/monkeypatch SDK,短期 token 只缩有效期不改根因)。正确形态=**host-side JSON tool**:LLM 传非敏感业务参数 → host tool 取 key 调远端 API → 裁剪/限大小/计量/审计 → 只返业务结果或落盘文件路径,容器最多读到落盘产物。已落地:`documents`/Materials Project 改 host tool(详 PROGRESS 06-01)。注册规则:仅对应 env 存在时注册,否则 schema 不暴露 + skill 文档提示降级。
@ -484,7 +509,9 @@ create index on usage_events (model_profile, created_at);
**skill 产物全落 working_dir 不引入 artifacts 表**:中间件是用户花 token 生成的资产,可下载可替换;artifacts 表是为不确定 UX 收益预付架构成本。真嫌乱 UI 加折叠视图。
**hard cascade 而非 soft orphan**:`orphaned` 让 list / resume / UI 都多一种特殊 case,"删 folder = 删项目"比"留对话残骸"自然。
**~~hard cascade 而非 soft orphan~~ → task 改软删除(2026-06-17 推翻)**:原决策为避免 `orphaned` 特殊 case 选硬删(`DELETE tasks` CASCADE 连带 messages/usage_events)。公测后目标变为**沉淀用户对话轨迹做训练/研究语料**,硬删 = 语料永久丢失,故推翻:`DELETE /v1/tasks/{id}` 改为置 `tasks.deleted_at`(0010 migration),从 `list_tasks` / `list_folders` 计数中过滤,messages/usage_events(CASCADE 不再触发)与工作目录文件全部保留;新增 `POST /v1/tasks/{id}/restore` 恢复。原"特殊 case"成本被一处 `WHERE deleted_at IS NULL` 收口(列表是唯一用户可见入口,按 id 取单 task 的端点不过滤,恢复/直链仍可达)。心智改为:**平台对数据 append-only,用户"删除" = 可见性状态,永不销毁字节**。物理清理留给将来的管理员工具。`delete_file` 顶层目录 409 引用检查同步排除软删 task(否则"任务都删了文件夹却删不掉"死结)。
**文件留存(归档)—— 设计已定,实现待办**(2026-06-17):任务对话靠软删除即留在 DB;但**用户文件在 FS 上,删除/覆盖即字节丢失**,需单独留存以供训练/研究。已对齐的方案(尚未实现,优先级靠后):**① 基础设施层定时增量备份做持久化地基**(restic/borg → 只进不删、内容寻址去重,定时跑;与应用代码完全解耦 → 新端点/新工具自动覆盖不会漏,且捕获删除+覆盖+最终成品,这是"删除前归档"钩子拿不到的)+ **② 应用层轻量事件日志**(删除/覆盖时只追加 user/task/path/time/reason 一条,补 ① 缺的用户意图/出处语义;放 DB 表 `data_events` 而非 jsonl,避并发追加竞争)。**起步同盘**(防误删+留语料够;不防整盘损坏 —— 已知边界,将来换备份 target 到第二块盘/异地即可,纯配置改动)。**不选**"每个删除端点内联 copytree-再删":横切关注点手写 N 处 → 易漏(删文件/夹/skill、rename/upload/i2i 覆盖入口持续增加)、只看得见删除一瞬、跨卷拷脆。覆盖(如 seedream i2i 改图)若 ① 颗粒度不够,将来在该具体工具内定点补"覆盖前快照",不铺全局钩子。
**0004 删 `runs` + `usage_events` 表**(2026-05-18):`runs` 表 tokens_p/c 写但从未读(真 tokens 走 tasks 累计),`started_at/finished_at/error` 也只写不读;`run_id` 单活 run 形态下对客户端 / broker / cancel 全冗余。合并 `run_status` + `run_error` 两列入 `tasks`。`usage_events` 从未真写,纯死代码,真要计费再加。**代价**:失"历史 run 元数据"(每次 LLM 调用的独立时间戳 / token 切片) — messages 表已记下产物,token 累计在 tasks,真要细粒度审计再补回 `usage_events`(届时是新需求,不是技术债)。
@ -512,17 +539,23 @@ create index on usage_events (model_profile, created_at);
> 实施细节(步骤清单 / 验收项)进 PROGRESS + git;此处只留缺口、选型与取舍。
### 8.1 图像理解 + Seedream i2i(2026-05-29,status=design 待启动)
### 8.1 图像理解 + Seedream i2i(2026-05-29 设计;✅ 2026-06-16 i2i + look_at_image 双双落地)
**缺口**:DeepSeek V4 主模型纯文本无视觉;`seedream` 只 t2i;"基于已生成图二次修改" / "上传外部参考图让 agent 据此干活"两条路径未覆盖。
**选 E + C 组合**:`seedream` 加 `reference_images` 走 i2i(改已生成图,像素级)+ 新增 `look_at_image` 走豆包 Seed 1.6 vision 单图理解(读外部图,DeepSeek 自决何时调)。改动面=2 tool + 1 prompt 段 + 1 yaml 段,不动 loop / llm / capabilities / DB / 前端。
**选 E + C 组合**:`seedream` 加 `reference_images` 走 i2i(改已生成图,像素级)+ 新增 `look_at_image` 走豆包视觉单图理解(读外部图,DeepSeek 自决何时调)。改动面=2 tool + 1 prompt 段 + 1 yaml 段,不动 loop / llm / capabilities / DB / 前端。
> **模型选型更新(2026-06-16)**:设计时写的 Seed 1.6 vision 已过时,落地用 **Doubao Seed 2.0 Lite**(`doubao-seed-2-0-lite-260428`,2026-02 发布、05 升级为全模态理解 SOTA 细粒度感知)。Seed 2.0 全系文本模型已原生支持图片输入 → A 路(主模型换多模态)门槛降低,但主模型 DeepSeek V4 的 code/tool-calling 仍是核心,**维持 C 路(vision 当工具)不变**。token 计费(输入 ¥0.6 / 输出 ¥3.6 / Mtok),一次读图 < ¥0.01。
- **不选 A(主模型换多模态)**:V4 的 code / tool calling 是主路径核心,换豆包当主 chat 降能力 + 要改 loop/memory 引 multimodal,工程 5× 且破坏架构。
- **不选 B(后台 vision 路由)**:每条消息隐式 vision 描述 = 多烧 token + 1 跳延迟 + 失去 agentic 控制权 + debug 难。
**关键实测**:Seedream 5.0 `/images/generations` 接受 `image_urls` base64 data URL,200 返新图 → **内网无需对象存储中介**(排除最大工程不确定性)。约束:输出 ≥~1920²、单张参考 ≤10MB、最多 14 张。
**风险 / 边界**:v1 只支持单张参考(multi-ref 角色定义靠 prompt,留 v2);base64 ARK 未承诺长期稳定(收紧则降级走 TOS 上传换 URL)。
**落地实况(2026-06-16,详 PROGRESS)**:
- **E 路(改图)**:`seedream` 加 `reference_images`(v1 单图,传 >1 报错);路径解析强制落 user_root 内防越界;前端 `chat.js` 补 paste 路径注入(把粘贴图路径作 `[用户上传的参考图]` 行进正文,修了"粘贴路径到不了模型"的既有缺口)。
- **C 路(看图)**:`look_at_image` tool(`tools/look_at_image.py`)走 Seed 2.0 Lite `/chat/completions`,base64 单图 + 问题 → 文本解读;`doubao.yaml` 加 `vision:` 段;`usage.py` 加 `record_vision_usage`(kind="vision",token 计费);agent_builder 注册 + media prompt 段。图片解析与 i2i 共用 `tools/image_ref.py`。真机 smoke(`scripts/smoke_look_at_image.py`)OCR 验证通过。
- 两路均不动 loop / llm / DB schema(`usage_events.kind` 自由文本,vision 无需 migration)。
**升级到 A 的信号**:用户要"贴图同时说话模型直接读图回话",或多轮带图成高频 —— 当前假设"图是工具调用对象"而非"对话内容"。
### 8.2 Token 优化与上下文治理(2026-06-04,✅ 已落地,详 PROGRESS)
@ -536,7 +569,7 @@ create index on usage_events (model_profile, created_at);
**选型**:Context Editing + Memory/File State + Cache Observability 混合。稳定 system/tools 前缀利于 provider cache;旧 tool result 移除或压缩;关键发现写 task summary / FS,需要时 `read` 重新拉。长上下文保留作少数全局推理的临时能力,非默认每轮成本。
**落地形态**:`core/context.py` 发送前压缩旧 tool / `load_skill` / assistant tool_call arguments(保 `role/tool_call_id/name` 协议完整),不改持久化历史;**上下文压力门槛**(2026-06-10):总 chars 未逼近上限则完全跳过压缩、原样发,护 DeepSeek 前缀缓存(短任务字节逐轮一致、命中 92-94%)。task summary(旧消息压成一条、区分硬约束/计划/文件路径/关键事实)为第二步,未做。
**落地形态**:`core/context.py` 发送前压缩旧 tool / `load_skill` / assistant tool_call arguments(保 `role/tool_call_id/name` 协议完整),不改持久化历史;**上下文压力门槛**(2026-06-10):总 chars 未逼近上限则完全跳过压缩、原样发,护 DeepSeek 前缀缓存(短任务字节逐轮一致、命中 92-94%)。task summary(旧消息压成一条、区分硬约束/计划/文件路径/关键事实)为第二步,未做 —— 已并入 §8.8 Phase 2(对齐 Hermes 结构化摘要)统一推进。channel 常驻会话的无限累积另由 §8.8 软重置分段治理(本节压缩挡不住跨时段累积)
### 8.3 PPTX 前端在线预览(2026-06-09,✅ 已落地 Stage 1)
@ -550,6 +583,206 @@ create index on usage_events (model_profile, created_at);
**安全边界**:对上传任意 pptx 跑 LibreOffice(历史有宏/EPS CVE)→ `--convert-to` 默认不执行宏 + 宏安全 high + 禁网 + 仅处理鉴权用户自己 user_root 内文件。
**保真边界**:deck 用微软雅黑,Linux 上替换成 Noto Sans CJK 度量略差(可接受)。**Stage 2(未做)**:常驻 soffice listener 消冷启、deck 生成后 eager 预转、缩略图导航。
### 8.4 运维监控 / 无感更新(2026-06-11,监控 ✅ 已落地 / 无感换版 status=design)
**背景**:已上生产、真实用户在用。换版可用性从"nice to have"变真账;且当前并发到多少、线程池有没有排队**没有观测手段**。
**心智**
- **优雅 drain(已实现,2026-06-10)** —— SIGTERM 后拒新 run(503)、等在跑的 run 收尾再换版,不再标 `error`。这是**单实例能做到的上限**。剩余代价:几十秒 503 窗(dev SPA 退避重试已吸收)+ 换版时 SSE 重连丢正在吐的 delta。
- **真正先撞的瓶颈是线程池,不是别处**:run 走 `asyncio.to_thread`(`web/app.py:1382`)用默认 `ThreadPoolExecutor`(`min(32, cpu+4)`),每个活跃对话整个 run 期占 1 线程。4 核 ≈ 8 并发活跃对话就排队,第 9 个 SSE 卡着不吐 token。解这个只需调大 executor / 加信号量背压,**不引外部依赖**。
**落地排序(便宜→贵,到触发线才进下一级)**
1. **轻量监控(✅ 已落地 2026-06-11,详 PROGRESS)**:核心数据现成 —— `len(app.state.inflight)`=当前活跃 run 数(含排队)、`broker._subs`=SSE 订阅者、`resource.getrusage`=RSS(Unix,Windows 跳过)。**周期日志优先**(lifespan 起 task 每 60s 打 `[stats] active_runs=N max=M rss=X`),因为要的是历史峰值不是此刻快照;`/v1/stats` 端点(复用 `ZCBOT_ADMIN_TOKEN` 鉴权)为辅。前提:启动时显式建 executor + `set_default_executor` 接管,才能读 `max_workers` 且日后可调大。
2. **按数据决策**:`active_runs` 峰值不逼近线程池 → 并发非瓶颈,扩容彻底搁置;逼近 → 先调大 executor(改个数字),再观察。
3. **503 窗优化(零依赖)**:`--reload`(RUN.md §A)把窗从几十秒缩到 <1s
**不做监控界面(现在)**:运维健康(线程池/内存/SSE/容器)是少数标量,日志 + 偶尔 curl 够诊断,可视化是过度工程;业务分析(token/任务/成本)已落 DB(`usage_events`/`tasks` 三列),SQL 查即可。界面阶梯:日志 → `/v1/stats` JSON → (要趋势图)Prometheus+Grafana(不自写前端)→ (要给非技术人看报表)只读 dashboard。现在停在第一级。
**搁置(成本不抵当前收益)**:gunicorn 无感换版 / broker 外置 Redis / nginx 蓝绿双实例 —— 留到"单机线程池调到头仍不够"或"换版断流成真实投诉"再议(无感换版需先把 broker 外置共享,分析见 RUN.md §B)。
### 8.5 定时任务 / 计划运行(Scheduled Jobs)(2026-06-18 设计,status=design)
**缺口**:无任何定时触发机制。但有价值的活很多是**时间驱动**而非事件驱动 —— 每日简报、每周综述、定时拉数据存盘、早安提醒。当前必须有人在对话里手动发消息才跑得起来。诉求:**用对话方式创建**"每天 X 点干 Y"的任务,到点自动跑、结果送达。
**业界印证(四源高度收敛)**:OpenClaw `cron-jobs` / Autobot(agent-loop×cron)/ Claude Code routines / geta.team 自建调度器,关键模式一致 —— ① cron 到点**往同一条 agent 主管线注入一条带标记的消息**,不另起执行路径;② 三种会话隔离模式(isolated 默认 / persistent 续上下文 / main 系统事件);③ isolated 运行到期自动 prune;④ 退避重试(transient vs permanent);⑤ per-job 超时;⑥ 投递显式 + runner 兜底(OpenClaw `--announce`);⑦ 5 段 cron + 时区,警惕 dom/dow 同列的 vixie OR 语义坑;⑧ 持久化用 DB,管理三件套(`cron_create/list/delete`);⑨ 对话式自然语言创建即标准做法。
**核心洞察(把方案收口到极简)**:定时任务本体 = `什么时候(cron+时区)` + `做什么(一句自然语言 prompt)` + `跑在哪(会话模式)`。**复用现成 agent 主管线**(`web/app.py:_run_agent_bg`,§3.6 / §7.2 同一条 POST /messages 路径),守护循环只负责"到点把一条带 `[定时任务]` 标记的 prompt 喂进去",**不造第二套跑 agent 的逻辑**。
> **关键解耦:"发邮件"不是一等公民,是 agent 据 prompt 调工具的一个动作。** job 模型只存 prompt,"做什么 / 结果发哪"全在那句话里(发邮件→调 `send_email`;出简报→`load_skill` 落盘;打招呼→回一句话)。好处:未来加任何能力(telegram / webhook / 落盘 / 调 API)**不改 schema**,只要 agent 有对应工具、prompt 说清楚。
**三层投递(没人盯着看 → 结果不能丢)**:
1. **baseline(永远有,零配置)**:定时 run 就是正常 run,结果**必进对应 task 线程**;守护循环跑完给该 task 打**未读/通知标记**,用户下次登录可见。
2. **opt-in 推送(prompt 驱动)**:要发邮件/(将来)telegram → prompt 里说,agent 调工具发。灵活、能写动态正文。
3. **可靠兜底(可选结构化 `notify`)**:某 job 要"必达某邮箱、不靠 AI 记性" → job 带 `notify={channel,to}`,守护循环 run 完**确定性补发**最新产物。不填走第 1 层。
**会话模式(隔离轴,业界核心设计点)**:
- **isolated(默认)**:每次触发新建临时 task,只带 job 的 prompt + skill,**不继承对话历史**。上下文最小 → 省 token(契合 high-turn 烧 token 治理,§8.2 / [[project_high_turn_token_burn_root_causes]]);临时 task 打标签 + 到期自动归档,防 task 列表被每日任务刷屏。
- **persistent(可选)**:job 绑定一个常驻 task(`bound_task_id`),每次往同一线程追加消息,有跨天连续性("和昨天比")。代价:线程越长重发历史越多、token 逐日涨 —— 仅在用户明确要连续性时用。
**数据模型(新表 `scheduled_jobs`,独立加表不碰现有 schema → 公测兼容)**:
`id, user_id, name, prompt, cron, tz(默 Asia/Shanghai), mode(isolated|persistent), bound_task_id(可空), notify(JSONB 可空), enabled, timeout_seconds, next_run_at, last_run_at, last_status, last_error, last_task_id, consecutive_failures, expires_at(可空), created_at, deleted_at`。Alembic 加表 migration;`usage_events` 复用现成记账(可加 `kind="scheduled"` 自由文本区分,无需 migration)。
**mode 语义(澄清)**:mode 只决定"对话是否延续"——isolated 每次新建 task(隔离对话历史、省 token),persistent 复用 `bound_task_id` 常驻 task(跨天连续性)。**文件夹两种模式都按 job 复用**(`scheduled-<jobid>`,产物累积 + notify 取最新产物依赖它),不是 mode 的区分维度。
**定时执行 task 的归属与可见性(0017)**:定时任务产生的 task 在 `tasks` 上标 `scheduled_job_id`(nullable FK → `scheduled_jobs.job_id`)。普通对话列表 `WHERE scheduled_job_id IS NULL` 排除(不混进"用户项目"列表);crons 页可按 job 反查执行历史。push 投递记录见 §8.7。
**守护循环(仿 §8.4 `_disk_scanner`,plain-asyncio)**:lifespan 起一个后台 task,每 ~10s(`ZCBOT_SCHEDULER_TICK_SECONDS`,只决定最坏延迟≤1tick、不决定会否漏 —— claim 取 `next_run<=now` 的全部)扫 `enabled AND next_run_at<=now()`;命中即 `asyncio.create_task(asyncio.to_thread(_run_agent_bg, ...))` 复用现成路径,登记到 `app.state.inflight`(随关停 drain 一起收尾)。与**单活 run 锁**(§7.x `run_status` + `SELECT FOR UPDATE`)交互:isolated 每次新 task 天然无冲突;persistent 若绑定 task 正忙 → 跳过本次 + 记 warn,下一个点再来(不排队堆积)。run 完回写 `last_*` + croniter 算 `next_run_at`
**croniter 选型**:存标准 5 段 cron 串 + 时区,`croniter` 算 `next_run_at`。理由:正确处理 dom/dow 同列的 vixie OR 语义和时区折算(手搓极易踩坑,四源都点名这个坑);纯 Python 小依赖。劣选:只支持"每天/每周 HH:MM"自己用 datetime 算 —— 零依赖但遇复杂周期要返工。
**可靠性(业界补的,纳入设计)**:
- **退避重试**:transient(限流/网络)指数退避重试(60s→120s→300s),成功重置;permanent(prompt 报错/鉴权)直接失败记 `last_error`
- **per-job 超时** `timeout_seconds`:超时复用现成协作式 cancel 信号(§7.x)。
- **无补跑(no catch-up)**:守护进程宕机期间错过的点**跳到下一个**,不补 N 次(同 Claude Code 语义)。
- **防自我繁殖**:定时 run 内**禁用 `schedule_create`**(防任务造任务);并发调度数设上限。
- **expiry 安全界**:`expires_at` + `consecutive_failures` 阈值 → 连续失败 N 次或长期没人管自动停,防僵尸定时任务(同 Claude Code 7 天过期思路)。
**对话端(用户要的"对话方式创建")**:核心是 host-side 工具三件套 `schedule_create / schedule_list / schedule_cancel`(写 `scheduled_jobs`,按 `user_id` 隔离,密钥不进沙箱,沿用 §3.4 typed-tool 范式)。自然语言进、自然语言管("我有哪些定时任务""取消那个简报")—— 即 Claude Code 模式(其定时任务纯工具实现,无配套 skill,证明工具单干可跑通)。
- **工具必须、skill 可选后置**:skill 是 markdown 不能落库,执行器只能是工具;收集字段/`ask_user` 确认这套流程,能力强的模型靠工具自描述 schema 即可走通。故 **v1 纯工具**(schema + 参数描述写好就够),契合 §5 "Less Scaffolding, More Trust" —— 先信模型,跑不好再加脚手架。
- **skill 真正值钱处不是教填参数(schema 够),而是教写好 `job.prompt`**:job 的 prompt 决定未来**每天**那次 run 的质量,用户随口一句直接存会跑得差;好 prompt 要自包含/可重复/产物位置明确/把发哪存哪写死 —— 模型默认不会,值得一份模板+确认纪律(cron 口径翻译、回读人话确认、默认 isolated 并提示 persistent 代价)去教。**v2 按需补**:实测发现 agent 写的 `job.prompt` 质量差 / 确认流程乱再加;且因调度低频,用按需 `load_skill`(§3.5)而非 always-on prompt 块,避免每轮白烧 token(§8.2)。
- 三件套用**三个独立工具**(schema 清晰、对齐 Claude Code `CronCreate/List/Delete`),非单工具带 `action` 参数。
**取舍(不选)**:
- **不引 APScheduler / Celery**:项目刻意用 plain-asyncio 后台循环(§8.4),调度需求是单机低并发,引调度框架/Redis broker 是过度工程。
- **不学 geta 用 JSON 文件持久化**:已有 PG + SQLAlchemy + alembic,加表是自然选择(JSON 文件丢状态、无事务、无按 user 查询)。
- **email 不做成 job 一等字段**:降通用性(见核心解耦);仅留可选 `notify` 兜底。
**风险 / 边界 / 待定**:
- **`send_email` 工具仍要建**(`tools/send_email.py`,host-side,仅当 `SMTP_*` env 存在才挂,沿用 §3.4 "有 key 才注册"),让第 2/3 层能用。**待定:SMTP 发信账号**(企业邮箱/QQ/163/Gmail 应用密码)—— 给真实账号走 env,或先占位走沙箱验证链路。
- **计费归属**:定时 run 计入 job 所属 `user_id` 的 token/配额,`usage_events` 标 `kind="scheduled"` 可审计。
- **错峰抖动**:多用户同设 8 点 → 按 job-id 加确定性偏移防同一秒打爆 LLM provider(单机低并发,列 nice-to-have 不阻塞 v1)。
- **待定小项**:可选 `notify` 字段是否 v1 就上(倾向上,零成本兜底);`expires_at` 默认值。
**改动面(v1)**:1 张新表 + migration、1 守护循环(lifespan)、4 个 schedule 工具(create/list/**update**/cancel)、1 个 send_email 工具、agent_builder 注册 + 定时 run 内工具裁剪。**v2 按需**:薄 skill(教写 `job.prompt`)。**不动** loop / llm / capabilities / 现有 DB schema。
**前端取舍(2026-06-18 定 + 落地):对话端做完整 CRUD,前端只读展示 + 停用/删除。** 前端 SPA 调 `/v1/*` REST、不经 agent → "界面建/改定时任务"必须另开 REST + 表单 + cron 构建器(整套最重的是让科研用户填 cron 的 UX)。既然产品本就是对话式 agent,把建/改/删/查全收到对话(`schedule_*` 工具),**前端退化成只读看板**:`GET /v1/schedules` 列表 + 列表项「停用/删除」两个高频便捷动作(`PATCH`/`DELETE /v1/schedules/{id}`)。好处:cron 构建器 UX 难题直接消失(用户从不在前端填 cron,对 bot 说"每天早九点"由模型翻译);无"前端改了和对话不同步"的状态问题。代价:界面不能新建/编辑(需求低频,且对话更自然)。落地:`web/static/js/crons.js` 只读 master-detail modal(复用 skills modal 范式)+ 左栏 rail「定时」入口;工具与 REST 共用 `core.scheduler` CRUD 服务层不漂移。
### 8.6 平台渲染层 rendering/(2026-06-23,✅ 已落地)
**心智:文档渲染(md→docx/pdf)是平台能力,不是 skill 内容。** 像 `chromium` / `document_search` / `python` 一样,skill **调用**它而非各自 bundle 一份。
**起因**:`_CHEM_RE` 化学式下标白名单在 brief/paper/proposal **三份 render_docx.py 逐字重复**(改一处易漏改),patent/standard 还复用 proposal 那份;且 brief 缺 PDF 路径,模型临场手搓 weasyprint + 运行时 pip(线上事故)。
**为什么不放 `skills/_shared/` 让各 skill `import`**:Skills 走 Anthropic 自包含/渐进披露/可 fork bundle 标准(§3.5),`fork_skill` 把内置 skill 整份拷到用户 `.skills`。跨 skill `import skills._shared` 会破坏 fork(用户拷贝里 import 不到内置树)且 sys.path 脆。故抽到**顶层 `rendering/` 平台包**,bind-mount 进 `/sandbox/rendering`(pool.py,与 skills 同款 `:ro`),与 skill bundle 正交。
**结构**:`common.py`(叶子原语单一事实源:字体 OOXML/`CHEM_RE`/块级正则/表格行切分/图片路径)+ `docx_manuscript.py`(paper 投稿稿 + proposal 申报书,配置化双 profile:页边距/TOC/图题前缀/列表模式/分页策略)+ `docx_brief.py`(brief 简报富渲染:商务红 + 引文上标超链 + callout,复用 common 叶子)+ `pdf.py`(md→HTML→沙盒 chromium `--print-to-pdf`,复用 `common.CHEM_RE`)+ `render.py`(统一 CLI `--profile {brief,paper,proposal} --format {docx,pdf}`)。各 skill SKILL.md 调 `python /sandbox/rendering/render.py`,不再自带 render_docx.py。
**PDF 用 chromium 不用 weasyprint**:chromium 镜像已装(给 mermaid),fonts-noto-cjk 已装,完整浏览器内核 CSS 保真度高;weasyprint 要 pango/cairo 原生库、不在仓库 Dockerfile。**与 §8.3 pptx 预览分工**:pptx 预览在 web host 调 LibreOffice(面向用户的高保真预览,不进沙盒);本层在沙盒内 chromium 渲染(agent 生成阶段产出 docx/pdf 交付物)。
**取舍**:重构对三 profile 各渲前后 diff `word/document.xml` **字节一致**(零回归);brief 不强并进 manuscript 路径(引文/配色差异大,只共用叶子原语,降回归面)。
### 8.7 微信接入(双渠道:ClawBot 个人微信 + 企业微信自建应用)(2026-06-23 设计,status=design)
**诉求**:把 zcbot 送进用户**个人微信**——简报/任务结果主动推过来,且能在微信里直接跟它对话。用户体感 = 微信通讯录里多一个叫「微信 ClawBot」的**联系人**,像加了个好友一样聊。
> **⚠️ 实测结论(2026-06-23,`scripts/probe_clawbot*.py`,真机端到端;关键是 `client_id`):ClawBot 可双向对话 + 可主动推送(有前提)。**
> ① 灰度可用(扫码 `confirmed``bot_token` + `baseurl`);② **入站通**(`getupdates` 长轮询收用户消息,带 `from_user_id` + `context_token`);③ **多条/流式回复成立**——同一 `context_token` 连发多条,**每条 `msg` 必须带唯一 `client_id`**(漏它则只有第一条送达——前几轮误判"单条/纯被动"的真因),中间块 `message_state=1`(GENERATING)、末块 `=2`(FINISH),按 ~1000 字分块、各块间隔 ~300ms;④ **主动推送成立**——发完 FINISH 后隔 30s 复用同一 `context_token`(+ 新 `client_id`)仍送达,**`context_token` 有效期约 24h、可复用**。
> **故「定时简报主动推送」(本节最初核心诉求)在 ClawBot 上可行**,前提:用户**先开口过一次**(冷启动无 token 不能凭空推),且距上次互动在 token 有效期(~24h)内——**每条入站消息刷新该用户的 `context_token`**;超期未互动则需用户再开口(或退邮件兜底)。冷推(从未开口)仍不可能。
**选型:三条路,选官方 ClawBot(详见对话调研 2026-06-23)**:
- **wechaty / hook(非官方个微)** —— 逆向/注入,违反腾讯 ToS,**封号率高**(hook >80%、web 协议被大量封),要养号/同省 IP/限速。**排除**。
- **企业微信自建应用** —— 官方、稳定;①只触达**企业微信成员**(非个人微信);②要企业**管理员**建应用 + 配可信域名;③双向对话要回调 + AES + 5s ACK,重。但**主动推送无条件**(不挑用户活跃度、不依赖灰度)→ 定时简报"必达"首选。**与 ClawBot 并列为第二渠道(本节一并设计,见下「渠道 B」),共用渠道抽象。**
- **微信 ClawBot(iLink Bot API)** —— 腾讯 2026-03-22 官方上线,跑在官方 iLink 协议 + 官方服务器 `ilinkai.weixin.qq.com`,**零封号**;腾讯定位"管道",**后端接谁都行**(可接 zcbot)。**采用**。
**为什么先实现 ClawBot(企业微信紧随)**:零管理员(用户自扫,不建应用/不配域名)→ 能立即跑通验证(协议已真机实测全通);企业微信要等管理员建应用 + 配可信域名的资源到位。企业微信随后补上,用其**无条件推送**补 ClawBot 的"24h 活跃才可推"短板。
**渠道抽象(两渠道共用,加渠道不改 scheduler / 工具主体)**:
- **绑定**:per-user 记"绑了哪些渠道 + 各自凭据/标识"(ClawBot:`bot_token`+`latest_context_token`;企业微信:`wecom_userid`,应用凭据走全局 env)。
- **统一发送**:`send_to_user(user_id, text, file?)` → 解析该用户已绑渠道 → 各渠道实现各自发;`scheduler.deliver_notify`、`WechatPushTool` 都调这层,不感知具体渠道。
- **推送即对话记录(Unified)**:`send_to_user` 投递成功后,对每个成功渠道把推送(摘要 + 文件下载链接 + agent `read` 路径 `../<rel>`)作为一条 assistant 消息写进该渠道 chat task(`ensure_channel_chat_task` 不存在自动建,与入站对话共用)。web 端渠道对话卡片可见 + agent 可基于推送追问(`read` 产物文件)。进 agent 上下文(推送是 bot 发给用户的话,记得自己发过 = 连贯,非污染);`source_task_id` 去重——调用方即目标 chat task 自己(如用户在微信里让 agent 推)时 tool 记录已在,跳过。不塞正文(避免上下文膨胀)。push 记录在 `messages.kind` 标 "push"(独立列,不进 payload),`extract_last_assistant_text`(wecom 入站取回复)加 `WHERE kind IS NULL` 跳过,避免误取 push 摘要当回复。
- **推送择优**:简报这类"必达" → 优先企业微信(无条件);ClawBot 作个人微信触达 + 聊天;两者都绑可多投或按用户偏好。
**第一期两处已定决策(评审通过)**:
- **入站对话 → 每用户一条 persistent「微信」task**(聊天要连续性;token 增长靠 §8.8 channel 长会话治理 = 软重置分段 + §8.2 context 压缩;打标签与网页 task 区分)。**两渠道入站都落到这条 task**。
- **敏感凭据入库一律加密列**(`bot_token`/`latest_context_token`;企业微信 secret 走 env 不入库)——env `ZCBOT_WECHAT_SECRET_KEY` 派生密钥;绝不进沙箱/日志/API 响应(§3.4)。
**唯一现实卡点 = 微信灰度可用性**:仅**国内个人微信**、需 **8.0.70+** 且功能灰度推送中(设置→插件),**不支持企业微信**(`bot_type=3`)。目标用户没有插件入口就用不了——落地前要先核实目标用户在灰度内。腾讯另保留**限频 / 决定可连哪些 AI / 随时终止**的权力(政策风险)。
**注册门槛 ≈ 零**:`get_bot_qrcode` **无需任何预置 app_id/凭据/审核/费用**,任何后端直接调即可生成二维码;`bot_token` 纯靠用户扫码下发。**能完全脱离 OpenClaw 自实现**协议客户端(社区 `weixin-ClawBot-API` 已证)。
**绑定模型(沿用前版已对的 per-user 扫码骨架)**:
- 每个 zcbot 用户**扫一次码** → 后端拿到**该用户专属 `bot_token`**(Bot ID `xxx@im.bot` / User ID `xxx@im.wechat`)→ 存库 → 之后按用户收发。**1 个 bot_token 对应 1 个微信账号**(扫码者)。
- 这与"每个用户连自己的微信"天然吻合,且**零管理员**(对比企业微信省掉建应用 + 可信域名)。
- ⚠️ **待核实**:`bot_token` 是 1:1(每用户一条、各自一条长轮询)还是 1:N(单 token 多用户、靠消息内 `@im.wechat` 区分,Telegram 式)。设计**按更确定的 1:1** 落,若实测为 1:N 则简化为单循环。
**扫码绑定流程(iLink)**:
1. zcbot 网页"绑定微信" → 后端 `GET get_bot_qrcode?bot_type=3``{qrcode, qrcode_img_content}`,前端展示二维码。
2. 后端 `GET get_qrcode_status?qrcode=<id>`(长轮询,单连 hold ≤35s,循环续)→ 用户用**个人微信**扫码确认 → 返回 `{status:'confirmed', bot_token, baseurl}`
3. 把当前登录 zcbot user 与返回的 `bot_token/baseurl/user_im_id` upsert 进 `channel_bindings`(channel='clawbot')。前端轮询自己的绑定状态翻转。
**数据模型(统一表 `channel_bindings`,判别列 + JSONB 多态;0015 由旧 `wechat_bot_bindings`/`wecom_bindings` 合并而来)**:
`user_id, channel, status, config(JSONB), created_at, updated_at`,PK=(user_id, channel)。沿用本库 `usage_events`(kind+units)范式 —— 各渠道字段装 `config`,加渠道不动 schema。
- channel='clawbot' 的 config:`{bot_token*, bot_im_id, user_im_id, base_url, latest_context_token*, context_token_at(iso), chat_task_id}`(`*`=经 crypto 加密入 JSONB;`latest_context_token`+`context_token_at` 判 24h 推送窗口)。
- channel='wecom' 的 config:`{wecom_userid}`(企业成员 id,非密钥、明文)。
- 敏感字段加密 + **绝不进沙箱 / 不落日志 / API**(§3.4);`chat_task_id` FK 与 per-字段 NOT NULL 退应用层校验(与 usage_events JSONB 同向取舍)。
> **为何统一表(2026-06-24 重构,§设计取舍)**:渠道绑定 = "用户在某渠道的一份配置",各渠道字段形态不同 → 用判别列 + JSONB(同 usage_events)最契合本库,且渠道增长(飞书/TG…)零 migration。分表(每渠道一表)对 2 渠道够用但不扛增长、与库内多态范式不一致;单宽表(NULL 列并列)2 列 vs 8 列硬并、稀疏 + 破坏 NOT NULL,最差。趁绑定数据极少时合表(migration 0015 搬数据,DDL 同事务失败回滚不丢)。
**协议要点(自实现客户端,2026-06-23 实测验证)**:base = 绑定返回的 `base_url`(实测 `https://ilinkai.weixin.qq.com`)。所有请求 header:`Content-Type: application/json` + `AuthorizationType: ilink_bot_token` + **`X-WECHAT-UIN` 每请求变**(`base64(随机uint32)`,反重放);除取码/查状态外加 `Authorization: Bearer <bot_token>`
- **取码/绑定**:`GET /ilink/bot/get_bot_qrcode?bot_type=3`(无需任何预置凭据)→ `{qrcode, qrcode_img_content}`,`qrcode_img_content` 是**微信深链**(`liteapp.weixin.qq.com/q/...`),需**自渲成二维码**(非图片直链);`GET /ilink/bot/get_qrcode_status?qrcode=`(长轮询)→ `{status: wait|confirmed|expired, bot_token, baseurl}`。二维码 TTL 短(~1min),实现要**过期自动换码**。
- **收**:`POST /ilink/bot/getupdates`,body `{get_updates_buf:<游标,首次空>, base_info:{channel_version:"1.0.2"}}`(长轮询 hold ≤35s)→ `{msgs:[{from_user_id, context_token, item_list:[{type:1,text_item:{text}}]}], get_updates_buf}`
- **收图片/文件(2026-06-24)**:`item_list` 项除 `text_item` 外还有 `image_item`(type=2,带 `media{encrypt_query_param, aes_key, encrypt_type}` + 优先 `aeskey` 32-hex)、`file_item`(type=4,带 `media` + `file_name` + `len`);**下载是文件发送(下条)的逆操作**——`GET {cdn_base}/download?encrypted_query_param=<media.encrypt_query_param>` 取密文 → **AES-128-ECB+PKCS7 解密**(key 优先图片 `aeskey`,否则 `media.aes_key` 两种编码兜底:base64(raw16) / base64(hex32))。落盘 `<wd>/inbound/`,图片拼 `[用户上传的参考图]`(走 `look_at_image`)、文件拼 `[用户上传的文件]`(走 Read/Shell)注入 user 消息,**复用 web 端粘贴图约定,不碰模型链路**。⚠️ 下载 GET/POST 与 aes_key 取支待真机端到端校(crypto 单测已过)。
- **发**:`POST /ilink/bot/sendmessage`,body `{msg:{to_user_id, client_id:<每条唯一>, message_type:2, message_state:1|2, context_token, item_list:[...]}, base_info:{channel_version:"1.0.2"}}`。**`client_id` 必带且每条唯一**(否则同 token 后续消息被丢);多条/长文 → 中间块 `message_state=1`、末块 `=2`,~1000 字/块、间隔 ~300ms。成功返回 HTTP 200 + 空 body `{}`(无 ret,不能据 body 判成败,以实投为准)。
- **token 生命周期**:`context_token` 有效期 ~24h、可复用(发完 FINISH 仍可再发)→ 主动推送靠它;**每条入站消息刷新**该用户 token(存最新值 + 时间戳)。`bot_token` 长期 per-user 凭据(扫码下发)。
- **文件发送(2026-06-23 实测通,`scripts/probe_clawbot_file.py`)**:①`POST /ilink/bot/getuploadurl`(body `{filekey:随机16B的hex, media_type:3(FILE)/1(IMAGE), to_user_id, rawsize, rawfilemd5, filesize:PKCS7填充后大小, aeskey:随机16B的hex, no_need_thumb:true, base_info}`)→ 返回 `{upload_param}`;② 本地用该 aeskey 做 **AES-128-ECB + PKCS7** 加密文件;③ `POST {cdn_base}/upload?encrypted_query_param=<urlenc(upload_param)>&filekey=<urlenc(filekey)>`(`cdn_base=https://novac2c.cdn.weixin.qq.com/c2c`,body=密文、`application/octet-stream`)→ **响应头 `x-encrypted-param`** = 下载引用(漏 `&filekey=` 会 400 `filekey mismatch`);④ `sendmessage``item_list:[{type:4, file_item:{media:{encrypt_query_param:<上一步 x-encrypted-param>, aes_key:base64(aeskey.hex()的ascii字节), encrypt_type:1}, file_name, len:str(rawsize)}}]`。**docx/pdf 简报可原生直推为可打开附件**,无须退下载链接。
- ⚠️ **仍待核实**:富文本(markdown)渲染支持度(源码有 `markdown-filter.ts`,暂按纯文本正文 + 文件直推设计);限频数值(腾讯保留限速);媒体大小上限(暂沿用 20MB)。
**架构:入站与出站一体(第一期一起做)** —— **主动推送依赖 `context_token`,而 token 只能从入站消息拿**,故"只出站不入站"不成立;getupdates 长轮询既收对话、又负责刷新 token。
- **入站长轮询管理器**(lifespan 起,仿 §8.4 `_disk_scanner` plain-asyncio):每个 active binding 一条 `getupdates`(hold ≤35s 循环续)。收到消息 → 按 `bot_token`→binding→zcbot `user_id` 定位是谁 → **刷新该 binding 的 `latest_context_token` + 时间戳** → 映射到该用户的微信对话 task(默认一条 persistent「微信」task 保连续性,§8.5 会话模式)→ 复用 `_run_agent_bg` 跑 → 结果按 ~1000 字分块 `sendmessage`(每块新 `client_id`、中间 `state=1``state=2`)带 `context_token` 回。**无 5s ACK 约束**,长 run 天然 OK——相对企业微信回调的根本简化。
- **出站主动推送**(scheduler 简报 / 任务结果 / `WechatPushTool`):用库里该用户 `latest_context_token`,**距上次入站 <~24h** 则直接 `sendmessage`(文本 + docx/pdf 文件直推);**超期 / 从未开口** → 推不出,退邮件兜底(§8.5)或挂起待用户下次开口刷新 token。即"用户开口过、且近 24h 活跃 → 可主动推"。
- **scale**:N 个 active binding = N 条长轮询;公测期 N 小可接受;放大时视 1:1/1:N 实测结果改为单循环轮询多 token。
- **web↔微信同步不对称 → web 端只读镜像(2026-06-24 取舍)**:这条 persistent「微信」task 是 web 与微信共享的同一条 DB 消息流,但写入方向不对称——**微信→web 同步**(入站经 `_poll_binding` 落库,web 打开即见),**web→微信不同步**(web 端发消息走通用 `/v1/tasks/{id}/messages`→`_run_agent_bg`,不经过 inbound loop 里 `send_text` 回微信那段,微信侧零感知)。**不做双向打通**:回微信需 `context_token`、只能从入站拿且 24h 过期,双向同步会被该窗口拖成"有时同步"(不可预测)+ 两入口并发写同一上下文歧义。改为 web 端对 channel=wechat 的 task **只读镜像**(`applyChannelComposerLock` 置 readOnly + 引导去微信),交互权威单一锚定微信;主控台想主动往微信推 → 走 `WechatPushTool`/定时简报(出站语义,非对话)。
**接入面(复用现有范式)**:
1. `tools/wechat_bot.py`:ClawBot 客户端(`get_bot_qrcode/get_qrcode_status/getupdates/sendmessage` + AES 媒体)+ `wechat_bot_enabled()`(开关在才挂工具,沿用 §3.4)+ `resolve_wechat_target(user_id)`→`bot_token` + `WechatPushTool`(agent 可调,按当前 run 的 user_id 解析)。HTTP 走已有 httpx。
2. `core/scheduler.py` `deliver_notify``channel=="wechat"` 分支,与 email 并列 → 定时简报**把最新产物文件直推**本人微信(取 `_newest_artifact`,≤上限 `sendmessage` 文件、超限退"点此下载"链接;**不改 job schema**——通道是 notify 字段的值)。
3. `web/app.py`:`POST /v1/wechat/bind/qrcode`(起二维码)、`GET /v1/wechat/bind/status`(轮询绑定结果)、`DELETE /v1/wechat/bind`(解绑)、`POST /v1/wechat/test`(自检发一条);**lifespan 起入站长轮询管理器**(见上"架构");前端设置加"绑定微信"扫码 UI。
**渠道 B:企业微信自建应用(✅ 2026-06-24 推送;✅ 2026-06-25 入站对话,共用渠道抽象)**
- **决策演进:出站推送先行,入站对话后补(2026-06-25)**。最初(2026-06-24)刻意只做推送以简化("和邮件一个量级"),其无条件主动推正补 ClawBot 24h 窗口短板;公测中需求明确企业微信也要能直接对话 → 补入站。**入站方式与 ClawBot 本质不同**:ClawBot 走长轮询(`getupdates` + 常驻 `run_inbound_manager`),企业微信走**回调 webhook**(企微服务器主动 POST 加密 XML)→ **无需后台轮询 task**,只加 HTTP 端点。agent 跑 >5s 超被动同步(5s 返回密文 XML)窗口 → 回复走 `message/send` 主动推回(复用 `push_wecom`),被动回复回 `success` 防重试。**对话核心与个人微信共用** `_run_channel_conversation(channel)`(建/复用会话 task → run 锁 → `_run_agent_bg` → 取回复),两渠道**各一张会话 task**(企微 binding 也存 `chat_task_id`)。
- 入站组件:`core/wechat/wecom_crypto.py`(WXBizMsgCrypt 等价:SHA1 验签 + AES-256-CBC 解密 + receiveid/corpid 校验;与 `crypto.py` Fernet 列加密、`wecom.py` 出站 API 全无关);`service.get_user_by_wecom_userid`(回调反查身份)+ `get/set_wecom_chat_task`;`GET/POST /v1/wecom/callback`(无 JWT,身份从加密 XML `FromUserName` 反查)。env:`WECOM_CALLBACK_TOKEN` / `WECOM_CALLBACK_AESKEY`。**暂只收文本**(图片/语音/文件回 success,后续走 `media/get` 补);未绑定/空消息静默。
- **应用凭据(全局 env,需管理员建应用)**:`WECOM_CORPID / WECOM_AGENTID / WECOM_SECRET`;secret 仅 host 进程读、不进沙箱(同 ClawBot / `send_email`)。host 直连 `qyapi.weixin.qq.com`(`core/wechat/wecom.py`)。
- **绑定两路(touser=wecom_userid)**:
- **手填 userid(无 HTTPS 域名时,默认)**:`PUT /v1/wecom/bind/userid` 直接写绑定;userid 见管理后台→通讯录→成员→「账号」。**推送是出站调用、不需域名**,故没域名也能用企业微信推送 —— 仅 OAuth 那路要域名。
- **扫码绑定(OAuth,需 HTTPS 可信域名)**:rail modal「扫码绑定」→ `oauth2/authorize?...scope=snsapi_base&state=<HMAC签+短TTL>` → 扫码/静默 → 回调 `GET /v1/wecom/oauth/callback`(公开端点,身份从 state 验,非 JWT)→ `cgi-bin/auth/getuserinfo?code=``wecom_userid`。**需管理员配「网页授权可信域名」** + `ZCBOT_PUBLIC_BASE_URL`
- **推送**:`gettoken` → `access_token`(2h 缓存 + 提前刷新 + 线程安全锁 + 40014/42001 失效重取)→ `message/send` text/file(file 先 `media/upload?type=file``media_id`,≤20MB)。
- **数据**:统一进 `channel_bindings`(channel='wecom',config=`{wecom_userid}`,明文非密钥);最初 0014 单建 `wecom_bindings`,0015 合进统一表(见上数据模型)。多企业留 `corpid/permanent_code` 进同一 config(additive,YAGNI)。
- **接入**:`service.push_wecom` + `send_to_user` 加 wecom 一路(已绑则推);scheduler `deliver_notify``wechat` 通道经 `send_to_user` 自动带上企业微信。端点 `/v1/wecom/oauth/url|callback`、`/v1/wecom/bind` GET/DELETE、`/v1/wecom/bind/userid` PUT(手填)、`/v1/wecom/test`;前端 rail modal 企业微信段(扫码 + 手填两路)。
- **触达**:仅企业成员;**品牌可自定义**(应用名/头像,区别于 ClawBot 统一名)。
**取舍(不选)**:
- **不用 wechaty/hook**:违规 + 高封号 + 养号运维,机构产品不可接受。
- **第一期不锁企业微信**:企业微信触达面窄(仅成员)、要管理员、双向重;ClawBot 触达个人微信 + 零管理员 + 双向轻。企业微信留作"机构身份 / 不依赖灰度"的后续备选,与本通道正交、绑定表/推送抽象可平行扩。
- **bot_token 落库但隔离**:它是长期 per-user 凭据,必须持久化(不同于企业微信 2h `access_token` 可纯内存);安全靠加密列 + 不进沙箱,不靠不落库。
- **富排版不强求卡片**:个微富文本能力存疑,统一走"正文纯文本 + 产物文件直推",规避平台差异。
**改动面(第一期,含入站+出站)**:1 张新表 + migration `0012_wechat_bot_bindings`;`tools/wechat_bot.py`(iLink 客户端 + `WechatPushTool` + 绑定/token 服务);**1 个 lifespan 入站长轮询管理器 + 消息→user/task 映射**(复用 `_run_agent_bg`);`core/scheduler.py` `deliver_notify``wechat` 分支;`web/app.py` 4 端点 + 前端扫码 UI;agent_builder 注册(开关在才挂)。env:`ZCBOT_WECHAT_BOT_ENABLED`(+ 可选 `ZCBOT_WECHAT_BASE_URL` 覆盖)+ `ZCBOT_WECHAT_SECRET_KEY`(凭据加密)——**无全局 app secret**(凭据是 per-user `bot_token`,扫码下发)。**不动** loop/llm/capabilities/现有 schema。
**渠道 B(企业微信,紧随)改动面**:env `WECOM_CORPID/AGENTID/SECRET`;`tools/wecom_push.py`(access_token 缓存 + `message/send` + `media/upload` + 渠道实现);`send_to_user` / `deliver_notify` 接 wecom 渠道;绑定抽象加 wecom 侧 + migration `0013`;OAuth 起始/回调 2 端点 + 前端"绑定企业微信"。**两渠道共用 `send_to_user` 抽象与绑定层**,故渠道 B 主要是"多一个渠道实现 + 一种绑定方式",不重写主体。
### 8.8 channel 长会话上下文治理(2026-06-29,Phase 1 ✅ 落地 / Phase 2-3 design)
**根因**:微信/企业微信入站对话复用**同一条常驻 chat task**(§8.7,per-user-per-channel 一条,要连续性),`Session.load()` 全量装回每轮 LLM 调用。web 任务"做完即止"故有天然边界,IM 是"用户当常驻助手永远在聊"→ 这条 task 只增不减,越用越贵/慢,终撞 context window。§8.2 的压缩只摘旧 tool 正文、门槛高(可靠上下文 50%)、从不删消息,挡不住 IM 这种无限累积。
**业界对照(2026-06-29 调研:OpenClaw / Hermes(NousResearch)/ Claude Code)**:三家都是"阈值触发摘要 + 头尾保护 + 旧 tool 输出先剪枝"。Hermes 最清晰:双阈值(agent 内 50% + gateway 85% 兜底)+ 四阶段(剪枝→边界检测 protect 头3+尾N→结构化摘要中段→重组保 tool 配对),摘要**增量更新**且保留 file path/ID/数值原文(mem0 实测:摘要会静默丢精确值/硬约束/决策理由)。OpenClaw/Hermes 另配持久记忆层(sqlite-vec / FTS5 + 跨会话)。**但三家都是单次 coding session,不解"IM 用三个月"的跨时段累积** —— 那是 IM 独有、最高杠杆且零信息损失的「会话分段」,本库自补(Phase 1)。
**心智:边界而非删除**。沿用 §8.2「禁止把『只保留最近 N 条』当主策略」「保留可追溯原文」——本设计**一条消息都不删**,只移动"喂给模型的窗口起点",全历史留 DB、web `/messages` 不 gate 照旧翻完整记录。
**Phase 1(✅ 2026-06-29):context_base_idx 软重置**
- `tasks.context_base_idx`(migration 0019,NOT NULL DEFAULT 0,additive)= 喂给模型的窗口起点。`Session.load()` 只装 `idx >= base` 的消息进 LLM 上下文。
- **关键不变量**:`_db_idx`(append 续号锚点)取 messages **真实总条数**而非加载条数 —— 否则下次 append 复用已存在 idx,撞 `uq_messages_task_idx`/覆盖历史。
- 两个触发口(`core/wechat/service.py`,仅入站走、push 不触发):
- **自动 gap 分段**(`maybe_gap_reset`):入站时距上次消息超 `config.json` `channel.session_gap_hours`(默 6h,`<=0` 关闭)→ 软重置,`base = 最后一条 user 消息 idx`。**不是失忆墙**:新窗口仍带"上一轮"原文做续聊锚点(用户"接着刚才说"接得上),零额外 LLM 调用、零延迟。
- **手动新话题**(`reset_channel_context(hard=True)`):用户发「新话题/新会话/`/new`/清空上下文」→ `base = 总数`,彻底从零(回执提示已归档)。
- 二者本质同一操作(推进 base)的被动/主动两口:被动断开要续上(软)、主动换题要干净(硬)。
- `clear_messages`(web 端清空)全删消息后 `base` 归 0(idx 从 0 重起,否则窗口起点悬空)。存量 task / web 普通任务 base 恒 0 = 喂全量,行为不变(对外契约友好)。
- **不选「每次 gap 开新 chat_task_id」**:会堆 `wechat-xxx-2/-3…` 文件夹(`working_dir_from_name` slug 写死)+ web 一堆 task 卡片;软重置零新文件夹/零新 task。**不选「kind='boundary' 标记消息」**:要混进消息流处理 tool 配对 + "别喂模型",列是纯元数据零侵入。
**Phase 2(design):阈值结构化摘要(补全 Hermes 阶段③)**。现 `core/context.py` 只做剪枝(旧 tool 截 2000 字)+ 尾部保护,缺"中段轮做 LLM 结构化摘要"。补:到门槛时把「base 之后、头 N 条之后、最近 keep_recent 之前」压成固定模板(目标/约束偏好/进展/待办),增量更新而非重写,保留 path/ID/数值原文。门槛接 Hermes 双层(50% + 85% 兜底,`_COMPACT_CONTEXT_RATIO`)。工程坑(mem0 列):辅助模型返非 JSON 降级回原文、tool 配对别被切断(复用 `_repair_dangling_tool_calls`)。**A(分段)砍跨话题累积,B(摘要)兜单段超长,两者正交**。
**Phase 3(design):持久检索(解"问很久以前的精确内容")**。软边界拿"跨边界精确回忆"换成本——梗概不够时(问上个月让查的具体数据),上 OpenClaw sqlite-vec / Hermes FTS5:新消息进来先语义/全文检索本 task 历史,命中原文注入当前窗口。工程最重,待 Phase 1/2 跑稳、确认确有此类需求再做(数据没删,随时能补)。
**落地次序**:Phase 1 上线观察 token 曲线 → 再定 Phase 2 门槛/是否做 → Phase 3 视真实"长期精确回忆"需求。
---
## 附录:DeepSeek V4 关键事实(2026-04-24)

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-06-10(上下文压缩加压力门槛 + 停机判据从步数解耦为是否在推进)
最后更新:2026-07-03(web 进度 dock 展开遮挡最新内容:贴底时补触底,bump 0.38.1)
---
@ -21,6 +21,602 @@
## 已完成关键能力
### 2026-07-03 / web 列表状态灯挪到文件夹行左侧,数据行均匀分布(bump 0.38.8)
用户建议:状态放文件夹名左侧、时间那行正常分布。落地:终态徽章 + 运行圆点挪进文件夹行行首(`● 📁 ppt4`,行首左上区最先被扫到;无文件夹行的 task 回落到数据行行首,`syncTaskRowRunIndicator` 按同规则找 host:`.wd-line` 优先、`.meta.stats` 兜底);底部数据行只剩纯数据(skill/条/tok/时间),改 `justify-content:space-between` 均匀铺开,时间自然落行尾。改 `web/static/js/chat.js` + `web/static/dev.html`
### 2026-07-03 / web 列表 meta 行数字组改靠左跟排——修 active 静默后的左侧"缺口"(bump 0.38.7)
用户发截图:0.38.6 active 徽章静默后,无 skill 的行(列表主体)meta 行左槽空了,数字组(条/tok)又被 `.num.right-group{margin-left:auto}` 整组挤右,中间留出一块像缺了东西。修:数字组改靠左跟排填上左槽,只有 time-ago 锚行尾(`margin-left:auto` 移到 time-ago);模板删掉已无意义的 `right-group` class。"条/tok"跨行对齐由原有 min-width+右对齐槽位保持。改 `web/static/dev.html` + `web/static/js/chat.js`
### 2026-07-03 / web status 徽章改"默认态静默"——active 不挂徽章,终态行淡化(bump 0.38.6)
运行圆点落地后暴露 status 徽章两问题:「进行中」(生命周期 active)与「运行中」(run_status)语义撞车;列表主体都是 active,每行重复挂蓝徽章是零信息噪音、还占 meta 行首槽。设计原则定为**默认态静默、例外态着色、瞬时态用动效**:active 不再渲染徽章(列表行 + 中栏 chat-meta 同规则,chat-meta 终态徽章保留兼解释"输入框为什么消失");completed/abandoned 徽章保留且整行淡化(`st-*` class,opacity .68,hover 恢复——st- 前缀防撞选中态 .task-row.active);绿脉冲点成为唯一动效信号,与生命周期解耦。筛选下拉「进行中」文案不动(筛选语境无歧义)。顺手删掉不再被渲染的 `.badge.active` CSS。改 `web/static/js/chat.js` + `web/static/dev.html`
### 2026-07-03 / web 运行态标识精简为纯脉冲圆点(bump 0.38.5)
用户反馈「运行中」等文字让列表 meta 行太拥挤。标识收成一个 7px 带色脉冲圆点(绿=运行中/橙=停止中/红=出错),文案全部移进 hover title(error 仍带 run_error 详情);圆点在 baseline 对齐的 meta 行里补 `align-self:center`。改 `web/static/js/chat.js` + `web/static/dev.html`
### 2026-07-03 / web 后台 running task 自动挂 SSE——运行态标识刷新页面后也实时(bump 0.38.4)
0.38.3 留的边界:刷新页面(liveRuns 清空)或 run 由别的标签页/渠道启动时,列表标识只是服务端快照,run 跑完没人通知前端,会一直挂「运行中」。用户点出方向:别轮询,直接复用 SSE。改法:`loadTaskList` 收尾新增 `subscribeRunningRows`——列表带出的 running/cancelling 行,本地未订阅的自动 `ensureRunningTaskSubscribed` 挂上事件流(上限 4 条后台流,防 HTTP/1.1 同源连接数被占满;超限行标识仍显示只是不自动清),done/error 走 fetchSse 现有收尾(清 liveRuns + 就地清标识 + 重拉列表),全程实时零轮询。配套两处:`ensureRunningTaskSubscribed` 的 cancelling/workingDir 从"读全局 state.taskMeta"改为调用方传 seed(taskMeta 或列表行)——后台 task 的媒体产物 rel 解析必须用各自 working_dir;`renderLiveRunIfVisible` 只在订阅的是选中 task 时才调(后台订阅不碰对话区,否则重挂卡 + 强制滚底误伤正看着的对话)。附带收益:刷新后切进 running task,直播卡带着后台累计的文字直接可见(renderMessages 收尾 renderLiveRunIfVisible 挂卡)。只改 `web/static/js/chat.js`
### 2026-07-03 / web 任务列表加运行态标识(bump 0.38.3)
用户报:多个 task 并发执行(调用工具/回复中)时,左栏任务列表看不出哪些在跑。后端 `/v1/tasks` 每行其实早已带 `run_status`(`_task_dict` 统一出),只是前端 `renderTaskList` 没用——`chat.js` 里"列表行摘要无此字段"的注释已过时。修:列表行状态徽章旁新增运行态标识,`running` 绿脉冲点「运行中」、`cancelling` 橙「停止中」、`error` 红点「出错」(hover 出 run_error),`idle` 不显示;取值 = 服务端 run_status 快照 + 本地 `state.liveRuns` 叠加(本会话刚发出的 run 比列表快照新,cancelling 本地标志优先)。实时性三时机:run 开始(sendMessage / ensureRunningTaskSubscribed)与点停止时 `syncTaskRowRunIndicator` 就地 patch 对应行 DOM(不重拉列表,保住滚动加载的分页);run 结束沿用 fetchSse 收尾已有的 `loadTaskList()` 重拉。别处启动的 run(其他标签页/渠道)靠列表任意一次重拉带出,首版不加轮询。顺手把 ⋯ 菜单「清空对话」的 running 判断改走同一 `taskRunState`(列表行此前恒 false)。改 `web/static/js/chat.js` + `web/static/dev.html`(CSS)。
### 2026-07-03 / ppt 模板 zongyuan_red 逆向重建为真实 中国建材总院 身份(bump 0.38.2)
用户给官方 `总院模板.pptx`(中国建筑材料科学研究总院有限公司)要求"统一按这个来,zongyuan_red"。原 `layouts/zongyuan_red/` 是手搓的红条结构版(深蓝 #1F2A44 + 顶部红条 + 55/45 封面 + PART 章节),与真实文件 DNA 完全不符。PowerPoint COM 渲出 3 档真页(封面/内容/尾页)+ 解 pptx 抽实测:主红 `#D7000E`、目录红 `#D52C24`、近黑 `#181717`、辅灰 `#6F6F6F`/`#BCBDBD`;字体 微软雅黑 + Arial + 方正兰亭黑;八边形品牌 logo(EMF→PNG 透明底)+ 总部大楼灰度实景 + 材料马赛克实景(TIFF→压缩 JPG)。重写 5 页 SVG 忠实还原:封面(实景铺底+顶左 logo&机构全称+居中主红块+白标题)/目录(左上实景+右下大红斜三角+目录标题+白字方块序号,承集团规范斜向分割)/章节(八边形品牌水印+红 PART 胶囊+大标题,原件缺、按八边形 DNA 合成)/内容(左缘红方块+标题+灰分隔线+右上 logo+4 列灰底红顶条卡片+底部红条+页码)/尾页(材料马赛克+"材料创造美好世界"红+Thanks)。打包 logo.png/cover_bg.jpg/ending_bg.jpg 三资产,改写 design_spec.md 反映真实身份,补登记进 layouts_index.json(此前 dir 在但未注册)。质检 --template-mode 5 页零 error;finalize 内嵌 8 图 + svg_preview 全量渲图逐页过目确认与原件一致。**并加主动提示**:strategist.md §e + SKILL.md 默认主题段各补一条 —— 受众/素材/用户机构指向 中国建材总院·CNBM 系(汇报/立项/评审/职称评审/品牌宣讲)时,策略阶段**主动**把 `zongyuan_red` 整套模板作为候选点名给用户(区别于 business-red 仅配色预设),用户点头再按明确路径套入;这是唯一鼓励主动提模板的场景,其余仍等明确路径,不模糊匹配。
### 2026-07-03 / web 进度 dock 展开遮挡最新内容(贴底时补触底,bump 0.38.1)
用户报:对话「拉到底部但仍有内容被遮挡看不到」。根因:`#task-progress-dock` 是 `#chat-stream` 上方的 flex 兄弟(`flex-shrink:0`),dock 一展开/长高,`chat-stream` 可视高度就被从顶部挤掉那么多——`scrollTop` 据置不变,原本贴底的内容被推到视口折线以下看不见。而 `chat.js` 直播态 `task_progress` 事件在重渲 dock(=长高)后**早 return,跳过了末尾第 1684 行的贴底兜底**,所以底部不会自动回滚。修:在 `task_progress` 分支 `setTaskProgress` 后补一句 `if (nearBottom) stream.scrollTop = stream.scrollHeight`(与其余事件分支同款贴底逻辑),dock 涨高时把最新内容重新钉到底。只动 `web/static/js/chat.js` 直播路径一处,历史渲染/其他事件不受影响。
### 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)
对 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 属预期(真缺陷)。
### 2026-07-03 / 进度条自愈:回放层强制单调完成(d1285247 复盘,bump 0.36.2)
用户报 task d1285247(ppt生成3)进度条反常:后面步(质检/导出)打绿勾、前面步(摄取素材/配图)却卡红圈"…",顶部"4/6"。诊断脚本 `scripts/diag_progress_d1285247.py` 拉出 `task_progress` 调用序列定位**非渲染 bug**——`progress.js` 忠实回放了模型发的调用:模型每次推进是"标下一步 completed + 再下一步 in_progress"的跳步,**每次都漏给上一次留在 in_progress 的那步补 completed**(s1、s3 被漏),回放到最后就是 `s1=in_progress,s2=completed,s3=in_progress,s4/s5/s6=completed`。根因是模型用工具收尾不稳,纯提示拦不住(与门体系教训同构)。修在**回放层加确定性单调不变量**:`enforceMonotonicProgress`——checklist 线性推进,只要某步 completed,其之前所有步自动视为 completed;`applyProgressAction` 的 set_plan / update_step 两条出口都过一遍,漏发自愈。前端单测加 3 条(含复刻 d1285247 跳步序列 → 6/6)。已知边界:假设步骤线性顺序(现有所有 skill 成立);若将来出现真·并行/乱序 checklist 会被抹平。
### 2026-07-03 / ppt 门体系二轮硬化:逃生口收紧 + 导出自动质检 + svg_final 嵌图修复(139a59c5 重跑复盘,bump 0.36.1)
0.36.0 上线后同 task 重跑(仍 deepseek-v4-flash):产物整体大幅好转,但仍有 4/25 页错位(P12 色带裁两行标题+正文跑出卡外 / P14·P18 文字骑卡片边框 / P21 手画饼图弧线劈叉)。轨迹显示**两道新门都触发了、都被模型 8 秒内用逃生口按过去**:质检+渲图验收 0 调用,`--allow-iconless` + `--allow-unreviewed` 连按直接导出——门有了,逃生口对弱模型等于"报错时该加的参数"。且 `--allow-iconless` 的"正当理由"是我们自己给的:wrapper docstring 老示例教它 `-s final`,而图标门检查的是 svg_final(data-icon 已展开)→ 误报零图标;`-s final` 还连锁出图片路径连环坑(见 F)。二轮修五处:**A 验收门分层**——"从没渲过/渲后又改/finalize 前渲的"为硬问题,**任何 CLI flag 不豁免**(渲图便宜且机器可验,没理由交付没人能看过的页);`--allow-unreviewed` 只豁免"渲过但没标 pass";运维兜底走 `ZCBOT_PPT_FORCE_EXPORT=1` 环境变量(不进 --help/SKILL)。**B 拔 `-s final` 雷**——图标门永远对 svg_output 源检测(误报根除);wrapper docstring 示例去掉 `-s final` 并注明勿用。**C 导出自动质检门**——svg_to_pptx 导出前内嵌复跑 quality checker 逐页硬错误(坏 XML/禁用特性/图片缺失/几何 error),error 拒绝导出、无豁免参数(fail-open 于 import 失败)——"忘跑/不跑质检"从此无效。**D** 验收门报错计数措辞修正。**E 几何质检加"文字骑卡片边缘"检测**(warning 带坐标:文字与可见矩形交叠面积占比 0.20.85 即骑边,P12/P14/P18 三类当场可命中;P21 饼图弧线错误静态无解,只能渲图过目)。**F 修 svg_final 嵌图失效 bug**——finalize 先 copytree 到 `.build/svg_final` 再就地嵌图,`../images/` 从 svg_final 解析必落空 → **所有 deck 的 svg_final 一直嵌不进外链图**(渲图验收 PNG 里图片也是空的);`_resolve_image_path` 加"rebase 回 svg_output 同相对路径"兜底,实测 data:URI 落位。本机全链路回归:未渲→硬拒(带 flag 也拒)/ pending→拒、flag 放 / pass→放行 / 质检 error→拒 / env 强制→放;71 charts 模板几何 0 error。已知边界:P21 类"图形画错但不重叠不越界"仍只有渲图过目能拦——"看没看"无法机器验证,治本要平台层 vision 验收(待做,同 0.35.1 备注)。
### 2026-07-02 / ppt 渲图验收闭环 + 导出验收硬门 + 几何质检(139a59c5 复盘,bump 0.36.0)
复盘 task 139a59c5(deepseek-v4-flash,25 页陶瓷节点方案):用户实报"很多地方错位"。本机 PowerPoint COM 渲全部 25 页定位三类错位:①图标压字/游离(P4/P5/P8/P10/P16/P24——质检报"缺图标"后模型写 `add_icons.py` **regex 批量盲插坐标**,插完没看);②大字号数字压说明文字(P5 万亿/26%);③目录溢出页底(P2)。**根因:SKILL 阶段六"全量渲图验收"被整个跳过**——进度步骤标 completed 但唯一动作是 `echo 交付清单`,`svg_preview` 全程 0 调用;文档要求了但无机制强制(与 0.35.1 教训同构:纯文档约束拦不住弱模型)。改动三层:**A 验收闭环+导出硬门(机制)**——`svg_preview.py` 渲 project 时登记 `.build/acceptance.json`(每页 svg_output 源 sha1 + rendered_from + verdict;svg_output 比 svg_final 新的页拒登记);新增 `accept_pages.py`(`--pass/--pass-all/--fail --reason/--status`,标 pass 前校验"渲过 + PNG 在 + 渲后源没改");`svg_to_pptx` 导出边界加验收门(spec_lock 存在时每页须 verdict=pass 且源 sha1 未变,finalize 前渲的也拒;`--allow-unreviewed` 逃生口)——"从没渲过就交付"和"改页不复看"在导出边界被确定性挡下,单页返工回路(`--pages N` 重渲 merge 记录)已本机全链路验证。**B 几何质检(提前拦截)**——`svg_quality_checker` 新增 check 13:按字符估宽(CJK≈1em/Latin≈0.5-0.7em)+ translate 累加构包围盒;**图标压字、基线出画布=ERROR**(几何精确),**文字-文字重叠一律 WARN 带精确坐标**(估宽分不清擦边与压字,词云/象限图等密排设计会误伤,判断权交渲图验收;SKILL 阶段四明确 Geometry warn 渲图时必须对着坐标看);tspan 按"视觉行"归组续排(`$4.2B <tspan>(35%)</tspan>` 是一行不是两段),71 个 charts 模板 0 error 误报、复刻事故的 fixture 全命中。**C 管线顺序+反模式(文档)**——SKILL.md 管线改"后处理→渲图验收→导出"(验收在导出前),阶段五=finalize+全量渲图+逐页过目+标记,阶段六=拆备注+导出(验收门+图标门双硬门);反模式加"没看 PNG 就 --pass-all"和"为消警告脚本批量盲插元素不复看"。SKILL_LIST 同步。已知边界:gate 只能强制"渲过、源没改",看没看 PNG 无法机器验证(--pass-all 仍可被糊弄,但本次事故"从不渲图"的直接通路已封死)。
### 2026-07-02 / ppt skill 补「禁自搓导出器」硬约束(966041e5 复盘,bump 0.35.1)
复盘同一 task 后续产物 `陶瓷资源节点建设方案 (3).pptx`(deepseek-v4-flash 跑):python-pptx 拆开验证 **25 页每页只有 1 张 1280×720 整页 PNG 贴图、零原生文本/形状**——skill「原生可编辑 DrawingML」的核心卖点全废。根因:模型**整条绕开官方管线**——DB 轨迹里 `svg_quality_checker / finalize_svg / svg_to_pptx / svg_preview / total_md_split` 官方脚本**调用次数全是 0**,取而代之自己 `pip install cairosvg` + 手搓 `export_pptx.py` 调 16 次,把每页 SVG 渲成 PNG 整页贴进幻灯片。连锁三个用户实报缺陷:①「很多方格子」= 跳过 finalize_svg,图标占位空心 rect 没内嵌;②「生成的图没放进去」= cairosvg 加载不了 `href="../images/*"` 外链(实测 file://+xlink 都渲空白),AI 配图全丢、事后靠 base64 补;③文字溢出出血被裁(P04/P05/P09)+ 标题 font-weight 因属性写坏(`serif" font-weight="bold"` 引号错位)丢加粗。**关键教训**:上一条(0.34.7)硬化的是官方工具**内部**的门(退出码/图标门/验收全量),但只在模型**用了**官方工具时才生效;本次证明模型可完全另起平行管线,内部门无从触发。改动(经用户拍板**只走文档层**、平台层自动检测暂缓):SKILL.md 阶段五加「🛑 导出唯一入口=官方 `svg_to_pptx.py`,默认原生可编辑、纯 Python 无需任何外部渲染器,'渲染器没装'永不是自搓借口」;反模式加「绕开官方管线自搓 SVG→PPTX 导出器 → 一叠不可编辑贴图、价值作废」。**注:仅改 skill 文档,不改线上跑法/官方脚本行为。** 已知残留风险:纯文档约束对'完全无视 skill'的弱模型拦截力有限,真正治本需平台层在 pptx 交付/预览路径自动检测整页贴图(本次未做)。
### 2026-07-01 / 加快捷指令(触发词 → 完整指令,渠道无关)(bump 0.35.0)
用户需求:预先定义"简报 → 给我输出一份昨日的 AI 新闻简报",之后任意入口整条打"简报"就展开执行。关键设计判断:**快捷指令不是 memory**——memory 是注上下文给模型概率召回的软上下文,快捷词必须是入口层、模型跑之前的**确定性替换**(命中即换、零歧义、0 额外 token;存再多条平时上下文也是 0)。落地(方案 A:蹭 memory 的 per-user 存储壳、但触发逻辑独立):①新模块 `core/shortcuts.py`——`shortcuts.md`(`| 触发词 | 指令 |` 两列 md 表)解析 + `expand(ws, uid, text)` 整条 `strip()+casefold()` 精确匹配展开(与「新话题」魔法命令同风格,"帮我出个简报"不误伤);②入口接线两处共用同一 `expand`:渠道核心 `_run_channel_conversation`(微信/企业微信自动都覆盖)+ 网页 `post_message`,起 run 前展开;③`core/memory.py memory_block` 加一行契约告诉模型可维护 `shortcuts.md`(用户说"记个快捷词 X→Y"时写),但**内容不注上下文**、触发不问模型。维护沿用 memory 心智(对话里让模型写,无新增管理 UI)。`tests/test_shortcuts.py` 覆盖解析(跳表头/分隔行、首行赢、大小写归一)+ 展开(精确命中、不部分匹配、缺文件、空文本)全过。
### 2026-07-01 / ppt skill 修复 ppt生成2(966041e5):图标门升硬 + CLI 退出码传播 + 验收改全量(bump 0.34.7)
诊断真实产出 `陶瓷资源节点建设方案.pptx`(deepseek-v4-flash 跑)两个缺陷:①23 页零图标(spec_lock 锁了 chunk-filled+inventory 却全 deck 0 个 `<use data-icon>`);②不少错位。根因不是缺 gate 而是 gate 被打穿:(a) `svg_to_pptx.py:22``main()``sys.exit(main())`——**main() 里所有 `return 1`(图标门/无 SVG/坏路径)全被吞成退出 0**,这是最致命的一处;(b) 导出侧图标检查 `_warn_if_icons_unused` 按设计只软 WARN、照常产出;(c) 模型质检时 `svg_quality_checker.py ... | head -30`,管道吞非零退出码 + `head` 截掉打在最后的零图标 `[ERROR]` 结论;(d) 验收阶段 SKILL.md 本就只要求抽查 3 页,23 页里只肉眼看了 2 页,且封面 vision 已报"半成品/错位"仍未返工直接交付。改动:①`svg_to_pptx.py` → `sys.exit(main())`;②`pptx_cli.py` 把导出侧检查从软 WARN 升为**硬门**(锁图标却全 deck 零 `<use data-icon>``[ERROR]` 退非零、不产出 pptx),加显式逃生口 `--allow-iconless`(应对 lock 过期/有意无图标);③SKILL.md 阶段六验收改「默认渲整本、逐页过目、差评即阻断返工」(废掉抽查 3 页),阶段四/五/反模式补「别用 `| head` 截断质检/导出输出」「别只看几页」「看到差评必返工」。合成测试三例(默认拒/`--allow-iconless` 放行/有图标正常)全过。**注:此修仅改 skill 侧,不改动线上跑法**;导出门只兜"锁了图标却零引用",正常有图标 deck 不受影响。
### 2026-07-01 / 修 look_at_image/seedream 拒收容器绝对路径(bump 0.34.6)
现象:docker backend 下主模型被系统提示告知一切都在 `/workspace` 下,自然产出容器绝对路径(如 `/workspace/ppt生成2/ceramic-node/images/cover_bg.png`)喂给 `look_at_image`,却报「图片找不到或越界」,只有改成 working_dir 相对路径才成功。根因:`tools/image_ref.py resolve_in_root`(look_at_image + seedream 共用)只吃「working_dir 相对 / user_root 相对 / 宿主绝对」三形态,唯独不把 `/workspace/<rest>` 翻回宿主 `user_root/<rest>`——而 host-side 的 send_email 早在 `Tool._resolve_user_file` 做了这翻译。改动:`resolve_in_root` 加容器根(`/workspace`)前缀翻译,**按字符串前缀判断而非 `is_absolute()`**(Windows 上 `/workspace/...` 缺盘符不算绝对);越界仍靠原 `relative_to(root)` 兜住(`/workspace/../secret`、`/workspace/../../etc/passwd` 实测仍拒)。这样 look_at_image/seedream 接受的路径形态与 send_email/wechat_push 及系统提示告诉 agent 的口径一致。
### 2026-07-01 / admin 各用户用量加「最近使用」列(bump 0.34.3)
用户需求:admin 页面「各用户用量」表加一列展示每个用户的最近使用时间。改动:`web/admin.py _user_usage_page` 加一个**全量**(不随 range 筛选)的相关子查询 `max(usage_events.created_at)`,新字段 `last_used_at`(ISO 或 null);语义上刻意用全量而非跟着 range 走的 join——否则选 7d/30d 会把更早的真实 last-used 藏掉,列就失去意义。前端 `admin.js renderUserUsage` 加「最近使用」表头 + 单元格,用 `fmtTimeAgo`(相对时间)展示、`fmtTime` 全时间戳作 title 悬浮,无用量用户显示「—」;colspan 7→8。
### 2026-07-01 / ppt 页数必须用户显式拍板(bump 0.34.2)
用户反馈:ppt skill 生成时页数总默认到 ~12 张,页数从没被真正确认过。根因是行为层:ah 八条对齐里 b 项(页数)只给「常 815 页」区间,又被打包进整批 BLOCKING 确认,用户一句笼统「OK」就整批过、模型自取区间中位数(~12)。修(纯文档):`SKILL.md` b 项改为推**一个具体数字**+ 标为「独立拍板项」;ah 表后新增「🔒 页数 gate(不可默认放行)」——用户没给/没显式认可具体张数时必须单独追问「就定 N 页?」拿到明确整数才写逐页大纲,禁止用区间中位数当默认(唯一例外:用户明说「页数你随意」时按推荐数走、仍在预览写出数字供否掉);`strategist.md §b` 同步补 Non-defaultable gate 硬约束。
### 2026-07-01 / web 清空对话同步清空右侧导航条(bump 0.34.1)
用户反馈:web 端「清空对话」后右侧的导航条(msg-outline-rail 目录圆点)没跟着清空,还留着旧轮次锚点。根因:`chat.js` `clearMessages()` 清空后只 `renderMessages([])`,没重置 outline 状态(切 task 路径 line 344 有 `state.outline=[]; renderOutlineRail()`,清空路径漏了)。修:clearMessages 成功分支补一行 `state.outline = []; renderOutlineRail();`,与切 task 同款。
### 2026-07-01 / ppt skill 工作目录重构:中间物收进隐藏 .build/(bump 0.34.0)
用户反馈"中间产物/文件夹过多"。架构判断:`<project_dir>` 根把三类混摊了——持久源(sources/images/svg_output/notes/两个 spec)、交付物(exports)、**可再生构建产物(svg_final/preview/backup)**;第三类是 build artifact,不该和源平级。修:新增 `project_utils.build_dir/svg_final_dir/preview_dir/backup_dir` 单一事实源,把 svg_final→`.build/svg_final`、preview→`.build/preview`、backup→`.build/backup/latest`(**只留最新**,不再堆时间戳)。`.build` 是 dotfile → `/v1/files` 自动隐藏 → 用户可见面从 ~11 降到"源+交付物"。改动:finalize_svg / svg_preview(_collect)/ pptx_discovery(`final`→`.build/svg_final`)/ pptx_cli(backup 路径 + rmtree 清旧)+ SKILL 工作目录约定/命令。端到端实测:根目录只剩 exports/+svg_output/,`.build/` 三子目录就位,导出/预览/backup 全正常。
> 关于"svg现在能 web 预览、要不要收敛成一个 svg 目录":架构上 svg_output(可编辑源:占位符+相对引用)与 svg_final(自包含编译产物:图标展开+图片 base64)是**两态**、不能合并成一个文件(可编辑 vs 浏览器忠实渲染冲突);但只该暴露一个——svg_output 可见、svg_final 进 .build。终态(下一议题):干掉持久化 svg_final,finalize 纯内存化 + web 忠实预览走"按需 finalize 再 serve",磁盘就一个 svg 目录。本次先做隐藏,未做内存化(牵涉 web 层)。
### 2026-07-01 / ppt skill 验证 ppt生成2 后修复:svg_preview cairosvg 兜底 + gate 计入 circle + 反卡片映射(bump 0.33.x→并入 0.34.0)
DB 取证验证「ppt生成2」(用户重跑,商务红+图标):图标 31 个(前 0)、商务红 #C00000、封面 imagegen 配图、扁平 gate 在跑 —— **代码类修复随 bind-mount 全部生效**。但视觉验收卡住:轨迹显示沙箱 `which chromium/cairosvg/rsvg` 全空、`svg_preview.py` 没被调用、模型自己 `pip install cairosvg` 渲 raw svg_output → **6/13 图标页 INVALID_MATRIX 失败**(cairosvg 不认 href-less `<use data-icon>`)。根因:**服务器沙箱镜像旧、没带 chromium 层**(镜像非 bind-mount,`deploy/update.sh` 第 4 步 rebuild 才更新;需服务器执行)。据此两处代码修复(用户选定):
- **svg_preview.py 加 cairosvg 兜底**:`find_browser()` 改返回 None 不抛错;无 chromium 时回退 cairosvg,且渲前**用 finalize 的 embed_icons 把 `<use data-icon>` 预展开成真 `<path>`**(避开 INVALID_MATRIX);顺带修上一版遗留的 `--screenshot` 绝对路径 + 保留 chromium 优先(保真更高)。browser happy-path 实测完好。
- **扁平 gate 计入 circle/polyline**:`svg_quality_checker` 图形图元加 `<circle>`(node/venn/bubble/timeline 是真图,之前把 21-circle roadmap 误判"无图形");并收紧——文字密集 deck **≥60% 页无图形 → ERROR**(不止"全 deck 0 图形"),4060% → INFO。实测:ceramic 式(46%)→INFO exit0、多数扁平(75%)→ERROR、极端→ERROR、全 circle→clean。
> 部署:视觉验收/PDF/mermaid 的根仍是镜像 —— 服务器跑 `sudo deploy/update.sh`(不加 --skip-build)rebuild `zcbot-sandbox`(Dockerfile 已含 chromium),存量 per-user 容器待 ensure() 用新镜像重建(必要时手动 docker rm 该用户旧容器)。
同批加 **执行层反卡片映射**(治"大段大段卡片阵"):验证 ppt生成2 发现 SVG 注释自写 "3x2 Card Grid"/"3x3 Grid"——执行模型对"N 个并列项"默认摊成卡片网格。executor-base §page_rhythm:`dense` 行去掉"card grid 是 baseline"的背书;加一段硬映射「先看内容**关系**再选图形」(系统→hub_spoke/分层、流程→flow、层级→树/金字塔、循环→环、互依→mind_map、对比→象限、≥3数据→图表),**卡片阵封顶 ~1/3 页**、连画两页网格下一关系页必须上示意图,并指回 page_charts(strategist 分配了模板就画那个别塌回卡片)。诚实边界:这是执行模型设计本能天花板,prompt 抬下限但不保证每张示意图都漂亮。
### 2026-06-30 / ppt skill 加商务红品牌预设 + 配图默认主动提议(bump 0.33.5)
用户两个需求:(1) 加一款红色主题;(2) 用户没给图时在需要处主动配图。
- **商务红品牌预设**:新增 `templates/brands/business-red/design_spec.md`(同 anthropic 格式:#C00000 全色表 + primary-deep/gold/info/positive/alert/surface/border/muted 派生色 + 宋体标题/黑体正文字体栈 + 实心图标偏好 + 政企口吻;无 logo,注明用文字 wordmark / 可后补)+ `brands_index.json` 加条目。**红色承载在 brand 而非 visual-style**(visual-style 不带色)。同时把**商务红设为 strategist §e 默认配色候选**:中文政企/集团/科研商务汇报默认列入 ≥3 候选(红金 #BF9B5F / 红蓝 #2B4C7E 二选一点缀,纯红只压标题/关键数据)。SKILL §默认主题 + 八条对齐 h 行同步指向。
- **配图默认主动提议**:strategist §h + SKILL h 行改——用户没给图时**不再默认整本 A(no images)**;封面/分节/概念/breathing/氛围页主动把 ai 配图作为候选提给用户(数据/列表/流程页仍走图表→§VII,不配装饰图)。仍全程 gated:用户在 h 确认 + imagegen 自带成本门(提议免费,确认才花钱)。
> 附:`scripts/config.py` 的 INDUSTRY_COLORS 未移植(又一处 ppt-master 残留引用),strategist 文档表是实际依据,已直接在表里加商务红行。
### 2026-06-30 / ppt skill 修「生成的 PPT 缺图形」:扁平 deck 质检 gate + 策略层视觉下限(bump 0.33.4)
延续缺图标排查,统计最近 ppt生成 任务 24 页 SVG 的元素构成:**`<path>`=0、`<image>`=0**,整本是 `<text>``<rect>`(文字方块),零示意图/图表/配图。根因同图标——71 个 `charts/` 模板没用、content→版式映射形同虚设,且策略层把"Not every page needs a chart"当跳过口子(spec_lock 实际 `page_layouts: free design`、无 page_charts 段),输出层无 gate 拦扁平 deck。两层修(用户选定):
- **A' 输出 gate(svg_quality_checker)**:统计每页图形图元 `<path>/<polyline>/<polygon>/<image>`(`rect`/`line` 是版面脚手架不算);**≥6 页且文字密集(avg `<text>`≥10/页)却全 deck 0 图元 → deck 级 error 退非零**;多数页无图元 → INFO;<6 页豁免(不误伤极简/teaser)。实测:8 页文字方块exit 1;任一页带 path放行;4 豁免
- **B' 策略层视觉下限(strategist.md GATE)**:把 §633「Template Match」从纯建议升为硬下限——内容 deck(≥6 页)每个能结构化的内容页必须分配视觉处理(page_charts 模板 / page_layouts 结构模板 / §VII 自绘示意图),**spec_lock 不许 page_charts + page_layouts 同时空着**;给出 content→图形映射速查;明示下游 A' 会硬卡。同步改 SKILL §大纲映射纪律 + §阶段四质检清单 + spec_lock_reference page_charts 段。
> 诚实边界:prompt+gate 抬下限(逼别交全文字 deck),执行模型设计功力是上限;gate 守"零图形"底线而非"每页必图表",避免误伤极简风。
### 2026-06-30 / ppt skill 修「生成的 PPT 缺图标」四层断点(bump 0.33.3)
查真实用户(caoqianming@foxmail.com)两个「ppt生成」任务的 DB 执行轨迹:24 页 SVG 共 0 个 `<use data-icon>`。根因是图标管线四个环节没有一个强制图标落地——**策略层(有时)锁图标,执行层不放、质检层不拦、工具层还断着**。四层一起修:
- **B 工具断点**:references/SKILL 里 23 处路径仍指向已不存在的 `skills/ppt-master/`(zcbot 是 `skills/ppt/`)→ 模型按文档 `ls .../icons/<lib>/|grep` 验名得空集 → 放弃图标;且 strategist 强制用的 `icon_sync.py` 在 zcbot 根本没有(GATE 空转,正是某任务连图标都没锁的原因)。修:全量改路径 + 新建 `skills/ppt/scripts/icon_sync.py`(复用 embed_icons 解析,验名+拷进 project/icons,缺名非零退出)。
- **A 质检兜底(硬门)**:`svg_quality_checker.py` 加图标校验——spec_lock 锁了 `icons.library`+非空 `inventory` 但全 deck 0 图标 → **deck 级 error 退非零**(逼回执行重写);单页 0 图标 → warning(封面/分节/breathing/尾页豁免)。
- **C 执行强制**:executor-base §4 + SKILL 执行纪律第 4 条从"怎么写图标"改为"**内容页必须放 13 个 inventory 图标**"(自由设计无模板可继承图标,只能逐页手写)。
- **D 导出兜底(纵深)**:`svg_to_pptx` 导出前预扫,锁了 inventory 却 0 图标 → stderr 大声 [WARN](非致命,防跳过质检直接导出)。
> 附:核实 native 转换器(`drawingml_converter` 调 `use_expander`)本就自己从图标库展开 `<use data-icon>`,故 svg_output 保留原始占位符是正确的——原设想的"finalize 硬前置防丢图标"前提不成立,D 改成 A 同源的导出层警告。
同版附带修 **svg_preview.py 在沙箱里渲不出 SVG**(报"未找到 Chrome / Edge"):移植自 ppt-master 的 `find_browser()` 只认 Windows `chrome/msedge`,不认沙箱镜像自带的 `/usr/bin/chromium`(给 mermaid 装的)→ 视觉验收这关在容器里全程失效。对齐 `rendering/pdf.py` 的发现逻辑(认 `chromium`/`chromium-browser`/`google-chrome` + `$CHROMIUM` 覆盖);`render()` 补容器必需的 `--disable-dev-shm-usage` + 临时 `--user-data-dir`(cap-dropped 容器 /dev/shm 仅 64MB,否则 chromium 渲染中途崩);顺带挖出并修一个静默已久的 bug——`--screenshot` 传相对路径 chromium 写不出文件(原代码吞 stderr 看着和"没浏览器"一样),改传**绝对路径**并把 chromium stderr 暴露出来。skills 是 `/sandbox/skills:ro` bind 挂载,改动下次 exec 即生效,无需重建镜像。
### 2026-06-30 / look_at_image 偶发超时:tool 内透明重试 + 超时上限提到 120s(bump 0.33.2)
Seed 2.0 Lite 非流式,长 OCR 首字节可能逼近 60s read timeout → 偶发超时,且返 `[Error]` 会触发主模型重发整个 tool call(图 base64 重传、输入 token 再付一次,正中"报错重试烧 token"根因)。修法:`ark_client` 新增 `ArkTimeoutError(ArkError)` 子类(仅超时/网络抖动抛它,HTTP 4xx/5xx 业务错误仍抛普通 `ArkError` 不重试);`look_at_image` 对该子类退避重试(`timeout_retries` 默认 1 次,退避 2^n s),在 tool 内消化掉不抛给主模型;`doubao.yaml` vision `request_timeout_s` 60→120。子类仍是 `ArkError`,seedream 等现有 `except ArkError` 不受影响。
### 2026-06-30 / 修复 web 端 SVG 无法预览(bump 0.33.1)
SVG 在 `<img>` 里必须 Content-Type=`image/svg+xml` 才渲染。前端 `preview.js``_showImage` / mini 图片分支据扩展名强制 blob mime(与服务端响应头无关);后端 `download` 接口对 `.svg` 显式回 `image/svg+xml`(部分部署环境 mimetypes 未注册 svg → 会被 FileResponse 猜成 octet-stream)。双保险。
### 2026-06-29 / ppt skill 清空重构为 SVG-first(移植 ppt-master,bump 0.33.0)
- 背景:旧 ppt skill 用 python-pptx + 固定组合版式件(`add_card_grid` 等),版面被 helper 框死 → 单调、AI 味重,是架构天花板,调参救不了。用户要求"清空重做,参考 github ppt-master"。
- 路线(范围 B:搬引擎+知识、弃 GUI、适配 zcbot):核心改为 **SVG-first** —— AI 逐页手写 SVG 设计稿,再由纯 Python 转换器(`svg_to_pptx/`,只依赖 python-pptx)逐元素译成原生可编辑 DrawingML。依赖闭包干净:转换器/质检/finalize 三套自包含,不碰 ppt-master 的 config/project_manager 重型层。
- 搬入:引擎(`svg_to_pptx.py`+包 / `finalize_svg.py`+`svg_finalize/` / `svg_quality_checker.py` / `total_md_split.py` / `update_spec.py` / 辅助 `project_utils`+`error_helper`);设计知识 references(`shared-standards`/`executor-base`/`strategist`/`image-layout-*`/`canvas-formats` + `modes/`5 + `visual-styles/`19);templates 全量(layouts/decks/brands/charts + **icons 30MB/1.1w+ 图标,用户要求一并入仓**)。
- 弃用/替换:浏览器 Confirm UI → 聊天 BLOCKING 八条确认;live preview server → 新写 `svg_preview.py`(无头 Chrome 渲 SVG→PNG,优先渲 svg_final 显图标);TTS/复杂动画(动画留 opt-in);ppt-master 配图子系统 → 走 zcbot 现有 imagegen skill。默认主题改"自由设计"(商务红降为候选)。
- 踩坑修复:vendored 脚本 print 含 ©/NBSP/emoji,在 zcbot Windows GBK stdout 上 `UnicodeEncodeError` 崩([[feedback_windows_console_emoji]])→ 给 6 个入口脚本顶部加 `sys.stdout.reconfigure(utf-8)` shim。
- 端到端验证通过:造材料领域 4 页 deck(低碳水泥),质检 0 error → 拆备注 → finalize 嵌图标 → 导出 4 页原生 pptx(13.33×7.5in、每页带备注)→ svg_preview 渲 PNG 肉眼确认设计级观感(swiss-minimal,非 AI 味)。
- 文件:`skills/ppt/`(SKILL.md 重写 + scripts/ + references/ + templates/);依赖加 Pillow(svglib/reportlab 注释为可选老 Office 兜底)。
### 2026-06-29 / system prompt 加通用 context 纪律铁律(bump 0.32.5)
- 承上:反复 dump 全文 abstract 烧 2.5M token 不是 brief 专属,任何 skill 让弱模型处理一批长文本都可能踩。故在 system prompt 单一事实源 `prompts/system/general_v1.md` 的「工作原则」段、紧挨「少来回」加一条全局铁律:大段 `run_python`/`shell` 输出会进对话历史每轮重发,中间数据落文件、只 read 用得上的片段、别整批重复打印。
- 与既有规则互补:行 7(源码落 .py 文件)管代码、行 42(少来回)管轮数、这条管「大块数据输出」。brief skill 里的场景化版本(0.32.3)保留做细化。
### 2026-06-29 / 定时任务默认单次超时 0→1800s(bump 0.32.4)
- 承上:超时此前默认 0(不限),配合"超时被吞成 ok"的旧 bug,一个跑飞的 job 能无限拖。改默认有限值 1800s(30min):新建 job 不指定 `timeout_seconds` 时给 1800,`0` 仍保留为"不限"逃生口。
- 单一事实源 `core/scheduler.DEFAULT_TIMEOUT_SECONDS=1800`,`create_job` 与 `tools/schedule.py`(agent 建 job 的工具)默认都引它;tool JSON schema 描述同步注明"default 1800 / 0=no limit / 重活可调大"。`create_job` 里 `int(timeout_seconds or 0)` 保留显式 0=不限语义。
- 存量:把线上 job `e621c8a6`「每日水泥科研简报」的 `timeout_seconds` 由 600 手动改为 1800(直接 SQL UPDATE,未动其它 job)。
### 2026-06-29 / brief skill 加 context 纪律,堵反复 dump abstract 烧 token(bump 0.32.3)
- 承上条同一 job 复盘:agent 把同一批 38 篇全文英文 abstract 用 `run_python`/`print` **反复灌进上下文**(实测 dump ≥3 次),工具输出每轮重发 → 48 次 LLM 调用累计输入 **2.5M tokens**(输出仅 28K),既慢又贵,还顶满 600s 超时。根因:brief skill 虽已要求把证据落 `evidence.md` 文件,但没明令"别反复 print 进上下文",弱模型(deepseek-v4-flash)规律不足就放飞。
- 修:`skills/brief/SKILL.md` 三处加指示文——阶段二「context 纪律」(落文件、按需 read、别整批重打)、阶段三「一次成稿别重复 dump + 按期刊分批写」、反模式加一条。纯指示文,frontmatter/description 不变 → SKILL_LIST 无需更新。
- 仍存的更大杠杆(未做):框架层对超大 `run_python` stdout 在上下文里做截断/省略,根治"工具输出滚雪球",但改动面大、有风险,留待单议。
### 2026-06-29 / 修定时任务超时被误记成 ok(bump 0.32.2)
- 实测 bug:定时 job(isolated)跑满 `timeout_seconds` 被调度器协作式 cancel 后,`_run_agent_bg` 对 ok/cancelled 都把 `run_status` 收回 `idle`(二者 DB 不可区分),而 `_execute_scheduled_job` 收尾只判 `run_status=="error"`,于是超时中断被落成 `last_status="ok"` —— 掩盖"跑到一半没写 sections / 没推送",且不计连续失败、不触发兜底。复盘 job `e621c8a6`「每日水泥科研简报」:`timeout_seconds=600`,task 创建→`last_run_at` 正好 600.0s,最后一条 agent 消息停在"按期刊分组打印 38 篇摘要"(还在取数阶段),`last_status` 却是 ok。
- 修:`web/app.py` `_execute_scheduled_job` 在超时分支置 `timed_out` 标志,run 收尾后若 `timed_out``record_result(status="error", ...)` 并直接返回(不投递半成品 notify)。复用既有 error 语义:计入 `consecutive_failures`、到阈值自动停用、前端 crons.js 显示「上次失败」。不动 `_run_agent_bg` 的 idle-on-cancel 共享语义(HTTP cancel/drain 也用)。
- 配套:该 job 真正的诱因是 600s 超时对"7 刊 38 篇带中文摘要重写 + 渲 docx"太短,需用户把 `timeout_seconds` 调大(或 0=不限)。诊断脚本 `scripts/diag_sched_e621.py`
### 2026-06-29 / channel 长会话上下文软重置(Phase 1,bump 0.32.0)
- 问题:微信/企业微信复用同一常驻 chat_task,`Session.load` 全量喂模型 → 越用越贵/慢,终撞 context window。业界(OpenClaw/Hermes)做法:阈值摘要 + 会话分段 + 持久记忆;IM 场景独有的「会话分段」最高杠杆且零信息损失。
- 方案(对外契约友好,无删用户数据):`tasks` 加 `context_base_idx`(0019,additive),`Session.load` 只把 `idx >= base` 的消息装进 LLM 上下文,base 之前的历史仍全量留 messages 表(web `/messages` 不 gate,照旧翻完整历史)。**关键雷点**:`_db_idx` 取 DB 真实总数而非 `len(rows)`,否则 append 续号撞 `uq_messages_task_idx`
- 两个触发口(`core/wechat/service.py`):① 自动 gap——入站时距上次消息超 `channel.session_gap_hours`(默 6h)→ 软重置,base=最后一条 user 消息 idx(保留上一轮原文做续聊锚点,不是失忆墙);② 手动「新话题/新会话/`/new`/清空上下文」→ 硬重置 base=总数,彻底从零。`_run_channel_conversation`(`web/app.py`)接入两口;`clear_messages` 全删后顺手 base 归 0。
- Phase 2(阈值结构化摘要,对齐 Hermes 四阶段③)、Phase 3(sqlite-vec/FTS5 持久检索,解「问很久前的精确内容」)延后,待观察 token 曲线再定。
### 2026-06-26 / 消息框支持拖拽文件 + 修多次粘贴互相顶掉(bump 0.31.3)
- 现象:① 消息框只能粘贴文件不能拖拽;② 连粘多个文件,后一个把前一个的 chip 顶掉,只剩一个。
- 根因:粘贴附件 chip 和状态文字共用 `#chat-hint`,每次粘贴用 `innerHTML =` 整体重建只塞最新一批,且上传进度回调写 `hint.textContent` 也会清掉已有 chip——附件与状态文字抢同一个容器。
- 修复(`web/static/dev.html` + `web/static/js/chat.js`):① 新增独立 chip 托盘 `#chat-attach`(textarea 与按钮行之间),chip 累积靠 append + 按 `rel` 去重,状态进度只写 `#chat-hint`,从根上解耦;② 给整个 `#chat-form``dragenter/over/leave/drop`(enter/leave 计数防闪烁,`_dragHasFiles` 只认文件拖拽,微信镜像只读时不接收),复用 `uploadFiles` + 同一托盘;`takePastedRels` / 删除 / 预览三处改查托盘。
### 2026-06-26 / 消息目录圆点错位再修(点击竞态 + 触底兜底)(bump 0.31.2)
- 现象(0.20.4 后仍残留):① 点圆点,被点的圆点不变红、活跃态跑到途经轮次(尤其点 #1 跳到 #2);② 点最后一个 / 滚到底,倒数第二个变红。
- 根因:① `jumpToMessage``scrollIntoView({behavior:"smooth"})` 在动画途中连发 scroll 事件,`updateActiveOutlineDot` 按动画途中位置反复改写,抢走刚 `setActiveOutlineIdx` 的显式点选;② 「顶线以上最后一卡」判活跃,最后几轮永远顶不到顶线(容器先到底)→ 永远停在倒数第二个,这是 scroll-spy 经典「不可达末项」bug,普通滚动也复现。
- 修复(`web/static/js/chat.js`):① 加 `_outlineJumpLock`,点选后锁定活跃态,平滑滚动期间 `updateActiveOutlineDot` 直接返回,700ms 兜底解锁并按落点重算一次;② `updateActiveOutlineDot` 加触底分支——滚到容器底且无更新内容可加载(`!msgHasMoreNewer`)时,直接判最后一个已加载轮为当前。
### 2026-06-26 / admin 近7天用量表加合计行(bump 0.31.1)
- 纯前端展示:`renderByDay`(`web/static/js/admin.js`)在 `by_day_7d` 表底加 `<tfoot>` 合计行,对 7 天 cost_cny/tokens_in/tokens_out 求和;`tfoot .total-row` 样式(粗体 + 上分隔线)在 `admin.html`。无数据时不渲染合计行。后端数据已有(`_usage_section`),无改动。
### 2026-06-26 / per-account 模型访问控制(档位制,复用 plan 列)(bump 0.31.0)
- 需求:管理后台按账户控制可调用哪些模型。deepseek flash/pro + seedream/seedance + 内网 local 对所有人开放,doubao/glm 按账户分配。
- 架构决策(与用户对齐):**档位制**而非逐账户逐模型授予 —— 复用 `users.plan`(0001 起休眠列,无需 migration),「档位→模型集合」配在 `config/agent.yaml` `model_tiers`,用户只挂一个 plan。管理成本 O(档位) 而非 O(用户×模型)。`plan` 空/未知 → `default` 档;`role=admin` 始终全开。`"*"` 通配支持全开档(当前未用)。
- 起始两档:`default`(deepseek flash/pro + local r1/qwen3 + seedream + seedance)、`pro`(+ doubao turbo/pro/evolving + glm pro/pro52)。
- 后端 `core/model_access.py`:`allowed_set(plan,role)`(None=全开)/ `is_allowed`。三个 list 端点(`/v1/models` `/v1/image_models` `/v1/video_models`)按档过滤 → 用户只看到本档模型(chat 前端无改动,下拉自动收窄)。三个 resolve(文本/图/视频)加 `user_id` 门控:**显式选模型**(建 task / 切模型 / 发媒体)档外 → 403;**老 task 下次发消息**若存量模型已不在档位内 → 持久落回 `deepseek_v4.flash`(send 路径锁行内 UPDATE;optimize_prompt 同降级但不持久);定时任务执行(user_id=None)grandfather 不门控。
- 管理端 `web/admin.py`:`GET /v1/admin/tiers`(档位定义 + 全模型目录,给 UI 图例)、`PATCH /v1/admin/users/{uid}/plan`(校验档位名存在,写 `users.plan`);`/v1/admin/usage/users` 行补 `plan` 字段。
- 管理 UI `admin.js`:各用户用量表加「档位」列(内联下拉选档 → PATCH → 刷新)+ 档位图例(每档含哪些模型,id→显示名);加 `apiSend`(PATCH/POST)助手。
- 已知边界:媒体 **tool 注册**不按档(seedream/seedance tool 仍随 ARK key 注册,只门控 variant 选择),当前各档都含媒体基线故无实际影响;待有付费媒体 variant 再收口 tool 层。
- 文件:`core/model_access.py`(新)、`config/agent.yaml`(model_tiers)、`web/app.py`(门控+过滤+降级)、`web/admin.py`(tiers/set-plan 端点)、`web/static/js/admin.js`(档位列+图例)、`DESIGN.md`(plan 列语义)。
### 2026-06-26 / 新增豆包 Seed 2.1 + GLM 5.2 文本模型档案(bump 0.30.0)
- 背景:用户要接入火山方舟豆包 Seed 2.1(turbo/pro)、自进化版 doubao-seed-evolving,以及智谱 GLM 5.2。`/v1/models` 自动扫 `config/models/*.yaml`,加档案即在 UI 下拉出现,无需改代码。
- 新增 `config/models/doubao.yaml`(family=doubao):`turbo`/`pro`/`evolving` 三 variant。走 Ark OpenAI 兼容端点(`openai/` 前缀 + `api_base=ark.cn-beijing.volces.com/api/v3`,复用媒体侧 `ARK_API_KEY`),同 local.yaml 范式。单价按火山 2026-06 发布价:turbo 3/15(缓存 0.6)、pro 6/30(缓存 1.2);evolving 官方未公布单价,暂按 pro 估值兜底(宁高勿低)。context 均 256K。
- `config/models/glm.yaml` 新增 `pro52`(GLM 5.2,model_id `zai/glm-5.2`,1M 上下文,单价 8/28 缓存 2),**与 `glm.pro`(5.1)并存**,线上引 `glm.pro` 的 task 不受影响(公测期兼容)。
- thinking_mode 均设 false:Seed 2.1 / GLM 的深度思考开关走 body 协议(非 OpenAI `reasoning_effort` 等级),透传等级需 core/llm.py 加 family 分支,留 TODO;设 false 不发 reasoning_effort,模型默认仍深度思考,不影响调用。
- 文件:`config/models/doubao.yaml`(新增)、`config/models/glm.yaml`(加 pro52 variant)。
### 2026-06-26 / 定时任务执行历史列表(分页)(bump 0.29.0)
- 背景:isolated 模式每次触发新建一个 task,旧的带 `scheduled_job_id` 被普通列表过滤掉、UI 够不到,只有详情里单个「打开它跑的任务」按钮指向 `last_task_id`(最近一次)。历史 task 一直在库里(不删除),但访问不到。
- 改:把单按钮换成右栏 **Tab 布局(详情 / 执行记录)**,动作按钮(停用/删除)提到右栏顶部 head;执行记录 tab 是**带分页的列表**。决策(与用户对齐):**保留全部历史不剪枝**(以后再清),列表做好分页;布局选 Tab 而非三栏(固定宽 modal 三栏每栏太窄、长文本难读)。
- 后端:新增 `GET /v1/schedules/{job_id}/tasks?page=&page_size=` —— 查 `scheduled_job_id == job AND user_id == 自己 AND deleted_at IS NULL`,`created_at desc` 分页,复用 `_task_dict`(带消息数/用量),返回标准分页壳 `{page, page_size, count, results}`。user_id 过滤天然隔离他人 job;非法/非本人 job_id 返回空。
- 前端 `crons.js`:`selectJob` 渲染 head(名+状态+按钮)+ tab 条 + `#cr-tab-body`;`renderTab` 切详情/历史;`loadHistory(jobId, page)` 拉一页渲染进 `#cr-hist`(时间·名称·状态/消息数,点某条 → 关弹框 + `selectTask` 打开那次对话),底部「上一页/下一页」+ 页码;await 后**重查** `#cr-hist` 校验 `data-job`,防切 job/切 tab 的迟到响应串显。persistent 模式天然只显一条。
- 文件:`web/app.py`(新端点)、`web/static/js/crons.js`(tab+历史+分页)、`web/static/dev.html`(`.cr-tabs/.cr-tab/.cr-hist-*` 样式)。
### 2026-06-26 / 渠道卡片收拢绑定管理 + 删 rail 按钮(bump 0.28.1)
- 把渠道绑定/对话/管理全部收进「新建任务」下方的卡片,删掉左下角 rail「微信」按钮(精简页面)。
- 后端 `/v1/channel_tasks` 改为返回 `{ wechat: { bound, task }, wecom: { bound, task } }`:
* bound: 绑定状态(`wechat` 用 `get_binding` 判定,`wecom` 用 `get_wecom_userid`)
* task: 对话摘要(无对话为 null,复用 `_task_dict`)。
- 前端 `loadChannelCards` 渲染三种卡片:
* 未绑定: 虚线占位「绑定微信」(点打开弹框绑定)
* 已绑定无对话: 虚线占位「微信对话(发消息后可打开)」(点打开弹框管理)
* 已绑定有对话: 正常卡片(名称 + N条·时间 + ⚙,点打开对话,⚙ 打开弹框管理)
- 文件:`web/app.py`(/v1/channel_tasks 返回 bound+task)、`web/static/dev.html`(删 rail 按钮+占位样式)、`web/static/js/chat.js`(三态卡片渲染)、`web/static/js/wechat.js`(删 hd-wechat 绑定)。
### 2026-06-26 / 定时任务对话归属 + push 统一记录到渠道对话(bump 0.28.0)
- 问题1:定时任务产生的 task(isolated 每次新建)混进普通对话列表。解:`tasks` 加 `scheduled_job_id`(nullable FK→scheduled_jobs,0017 migration + backfill persistent/isolated);列表 `WHERE scheduled_job_id IS NULL`(+ `working_dir LIKE '%/scheduled-%'` 兜底漏网孤行);`ensure_local_task_row` 加参数,`_execute_scheduled_job` 建任务时填。mode 语义澄清:只管对话是否延续,文件夹两种模式都按 job 复用。
- 问题2:任何 push(定时 `deliver_notify` / agent `wechat_push` 工具)推到微信渠道,web 端渠道对话看不到、没法基于推送追问。解:**记录下沉到 `send_to_user`**(两调用方统一入口)——投递成功后对每个成功渠道 `ensure_channel_chat_task`(不存在自动建,与入站对话共用)+ 写一条 assistant 消息(摘要 + 文件下载链接 + `../rel` read 路径),Unified 进 agent 上下文;`source_task_id` 去重(chat task 内调 wechat_push 时不重复插摘要)。不塞正文(避免膨胀),agent 按需 `read` 产物文件(fs `_resolve` 无越界拦,`../rel` 相对 cwd 上一级;mount=user_root docker 也可读)。前端零改动(markdown 链接 + 文本 read 路径)。push 记录标 `messages.kind="push"`(0018,独立列不进 payload),`extract_last_assistant_text` 加 `WHERE kind IS NULL` 跳过,避免 wecom 入站取回复误取 push 摘要当回复。
- 文件:`core/storage/models.py`(Task.`scheduled_job_id`+Message.`kind`)、`db/migrations/versions/20260626_1000_0017_*.py`+`20260626_1100_0018_*.py`、`core/storage/utils.py`(`ensure_local_task_row`+`append_channel_message`)、`core/wechat/service.py`(`send_to_user` 记录+`ensure_channel_chat_task`)、`core/wechat/inbound.py`(`extract_last_assistant_text` 过滤 kind)、`tools/wechat_bot.py`、`core/agent_builder.py`、`web/app.py`(`_run_channel_conversation` 复用)、`DESIGN.md`(§8.5/§8.7)。
### 2026-06-25 / 渠道卡片改并排(bump 0.27.4)
- 接 0.27.3:两张渠道卡片从竖排改并排(`#channel-cards` flex row,各 `flex:1`),省左栏纵向空间;窄栏内图标左、名称 + 条数·时间堆两行(新增 `.cc-body` 列容器)。
- 确认渠道绑定弹框(左下角「微信」rail 按钮)**保留不动** —— 它是绑定/解绑/测试推送的唯一入口,与卡片(只读对话入口)职责互补不重复(方案②)。
- 文件:`web/static/dev.html`(CSS row + cc-body)、`web/static/js/chat.js`(卡片 markup 加 cc-body)。
### 2026-06-25 / 渠道镜像对话改成左栏固定卡片 + 企业微信也只读(bump 0.27.3)
- 把微信 / 企业微信常驻对话从「任务列表里置顶 + 绿徽章 + 绿边的行」改成「『新建任务』下方两张固定卡片」(`#channel-cards`):它们是每用户每渠道唯一的常驻只读镜像,从可滚动任务列表抽出更清爽、常驻可见。
- 后端:`/v1/tasks` 列表用 `func.coalesce(Task.channel,'web').notin_(CHANNEL_MIRROR_KINDS)` 排除渠道任务,并删掉原 `case(...)` 强制置顶;新增 `GET /v1/channel_tasks` 返回 `{wechat, wecom}` 两条摘要(复用 `_task_dict`,无则 null)。`CHANNEL_MIRROR_KINDS=("wechat","wecom")` 单一真相源。
- 前端:`dev.html` 加 `#channel-cards` 块 + `.channel-card` 绿调样式(`:empty` 自动隐藏);`chat.js` 加 `loadChannelCards()`(enterApp/刷新按钮调)+ `syncChannelCardActive`(selectTask 同步高亮);移除列表行已失效的绿徽章逻辑。
- 企业微信对话补只读锁:`applyChannelComposerLock` / `sendMessage` 守卫从硬编码 `channel==='wechat'` 改读 `CHANNEL_BADGE`(`channelCfg`),微信 + 企业微信都 readonly,提示文案按渠道动态。
- 文件:`web/app.py`(列表排除 + 新端点 + 常量,移除 `case` import)、`web/static/dev.html`(卡片容器 + CSS)、`web/static/js/chat.js`(卡片渲染 + 只读锁统一)、`web/static/js/main.js`(enterApp 调 loadChannelCards)。
### 2026-06-25 / 企业微信入站对话支持图片/文件附件(bump 0.27.2)
- 接续 0.27.0 企业微信入站(此前只收文本)。补图片/文件:`wecom.download_media(media_id)` 走 `media/get`(成功回二进制流 + Content-Disposition 文件名,出错回 JSON errcode、40014/42001 重取 token);回调按 `MsgType` 分支,image/file 下载后构造 `InboundAttachment(kind/file_name/data)`(与个人微信同结构,仅这三字段被用到)→ 喂同一 `_run_channel_conversation`,复用其落盘 + 拼 `[用户上传的...]` 行(图片 agent 自调 look_at_image,文件走 Read)。
- 语音/视频/位置/链接/事件暂回 success 不处理;附件下载失败则静默跳过(打日志)。纯图片/文件消息无文本 → 核心据附件行生成 text,不再被「空消息」挡掉。
- 文件:`core/wechat/wecom.py`(`download_media` + `_filename_from_disposition`)、`web/app.py`(回调 image/file 分支)、`web/static/dev.html`(「企业微信(仅推送)」→「推送 + 对话」文案纠正)。`_filename_from_disposition` + import 自测过。
### 2026-06-25 / wechat_push 按渠道定向投递(修「点名企微仍推到个微」,bump 0.27.1)
- bug:用户说"推送给我的企业微信",消息却同时进了个人微信。根因 —— `send_to_user` 是无差别广播(`for ch in active_channels()` 逐个推),且 `wechat_push` 工具压根没有"指定渠道"的参数,agent 想只发企微也做不到;部署同时开了 clawbot+wecom 两渠道 → 一条推送两边都到。早期只有 clawbot 一渠道时此语义无碍,加企微后暴露。
- 修:`send_to_user` 加 `channel=None` 入参 —— `None` 保持广播(定时任务/不点名沿用,向后兼容),指定 `wecom`/`clawbot` 时只投那一条(该渠道未开则返回单条 `no_binding`,**不静默回退到别的渠道**避免又推错);`WechatPushTool` 加可选 `channel`(enum wecom/clawbot)+ 描述教 agent「用户点名某微信就传对应 channel」。
- 文件:`core/wechat/service.py`、`tools/wechat_bot.py`。
- 需求:企业微信此前只做出站推送(渠道 B 定位"和邮箱似的");现补**入站对话**,企微也能像个人微信那样直接聊。
- 关键认知 —— 入站方式与 ClawBot 不同:ClawBot 走**长轮询**(`getupdates` + `run_inbound_manager` 常驻),企业微信走**回调 webhook**(企微服务器主动 POST 加密 XML),故**不需要后台轮询 task**,只加一个 HTTP 端点。回复因 agent 跑 >5s 超被动同步窗口 → 走 `message/send` 主动推回(复用 `push_wecom`),被动回复直接回 `success` 防重试。
- 抽象:把 `_run_wechat_message` 的"建/复用会话 task → 落盘附件 → 抢 run 锁 → `_run_agent_bg` → 取回复"抽成**模块级 `_run_channel_conversation(app, uid, text, atts, channel)`**,个人微信(`channel='wechat'`)与企业微信(`channel='wecom'`)同核心、**各一张会话 task**(企微 binding 也存 `chat_task_id`),互不串扰。run 锁挡企微回调的并发/重复投递。
- 新增:`core/wechat/wecom_crypto.py`(WXBizMsgCrypt 等价:SHA1 验签 + AES-256-CBC 解密 + receiveid/corpid 校验;**注意**与 `crypto.py` 的 Fernet 列加密、`wecom.py` 的出站 API 全无关);`service.get_user_by_wecom_userid` 回调反查身份 + `get/set_wecom_chat_task`;`upsert_wecom_binding` 改成合并 config(不再覆盖 chat_task_id);`web/app.py` `GET/POST /v1/wecom/callback`(无 JWT,身份从加密 XML `FromUserName` 反查)。
- env:`WECOM_CALLBACK_TOKEN` / `WECOM_CALLBACK_AESKEY`(企微后台「接收消息」页生成);回调 URL = `<公网 base>/v1/wecom/callback`。**暂只收文本**(图片/语音/文件回 success,后续走 `media/get` 补);未绑定/空消息静默。crypto round-trip 自测过(verify_url / decrypt_message / 坏签名 / 坏 corpid 均符合预期)。
### 2026-06-25 / 修复企业微信扫码绑定报「请在企业微信客户端打开链接」(bump 0.26.10)
- bug:`oauth_authorize_url()` 用的是 `open.weixin.qq.com/connect/oauth2/authorize`(网页授权),这条只能在企业微信客户端内置浏览器里打开;前端 `wecomBind()``window.open` 在**桌面浏览器**新标签打开它 → 企业微信返回「请在企业微信客户端打开链接」,扫不了码。注释里「桌面浏览器=出二维码扫」是误解(那是公众号行为,企微 oauth2/authorize 不出扫码页)。
- 修:换成**扫码授权登录**端点 `login.work.weixin.qq.com/wwlogin/sso/login?login_type=CorpApp&appid=CORPID&agentid=...&redirect_uri=...&state=...` —— 桌面浏览器会渲染二维码,用户用企业微信 App 扫码确认后回跳带 `code`,后续 `verify_state` / `get_user_id(code)` 换 userid 的逻辑完全不动。前置:redirect_uri 域名须在企业微信后台「应用 → 企业微信授权登录 → 可信域名」登记(与「网页授权可信域名」是两项不同设置)。
- 文件:`core/wechat/wecom.py`(`OAUTH_AUTHORIZE`→`WWLOGIN_SSO`、`oauth_authorize_url`)。
### 2026-06-25 / 修复 wechat_push 工具漏挂企业微信(只配企微也能推,bump 0.26.9)
- bug:`wechat_push_available()` 只返回 `service.clawbot_enabled()`,完全没算企业微信。线上若只开了企业微信渠道(ClawBot 开关没开)→ 工具压根没注册到 agent → zcbot 照实回"我没有直接发企业微信的工具"(用户已绑企微仍推不出)。底层 `send_to_user` 其实早支持 `push_wecom`,门槛漏判而已。
- 修:提取 `service.active_channels()` 作渠道清单**唯一真相源** —— `wechat_push_available()` 改成 `bool(active_channels())`、`send_to_user()` 改成 `for ch in active_channels(): _DISPATCH[ch](...)`,门槛与投递同源,加渠道只改一处,根除"两处各列各的"这类漏判。工具描述把「~24h 窗口」注明为 ClawBot-only(企业微信无窗口约束),避免 agent 在企微场景误判窗口限制。纯内部重构,对外契约不变;`test_secret_host_tools` 8/8 过。
- 文件:`tools/wechat_bot.py`、`core/wechat/service.py`。
### 2026-06-25 / 企业微信加「手填 userid」绑定(无域名也能推,bump 0.26.3)
- 痛点:企业微信只有 OAuth 扫码绑定那一路,而 OAuth 回调要落在 HTTPS 可信域名;用户暂无域名 → 卡住。关键认知:**企业微信推送是出站调用(gettoken/message_send 直连 qyapi),根本不需要域名**——只有"扫码拿 userid"那步要域名。
- 加第二条绑定路:`PUT /v1/wecom/bind/userid` 手填成员 userid(管理后台→通讯录→成员→「账号」)→ `upsert_wecom_binding`;前端 rail「微信」modal 企业微信段加输入框 + 保存(与「扫码绑定」并列,已绑回填 userid)。`service`/推送/`send_to_user` 全不动(userid 来源换了,绑定数据结构一样)。
- 文件:`web/app.py`(+1 端点)、`web/static/dev.html`(输入框)、`web/static/js/wechat.js`(保存处理 + 回填)。py 编译 + node --check 过。
### 2026-06-25 / 监控页近 7 天用量按日期倒序(bump 0.26.2)
- `admin.py` `_usage_section``by_day_7d` 排序由 `order_by(day)``order_by(day.desc())`,最新一天在最上(overview 趋势表 + PDF 报告共用此数据,两处都生效)。前端纯按行渲染、不依赖升序,无需改 JS。
### 2026-06-25 / 用户名展示:监控页 + dev 顶栏(bump 0.26.1)
- 统一一条兜底链 `name → user_name → email → uid8`,监控页与 dev 页共用。
- 监控页(`admin.js`):各用户用量 / 存储两表 + overview 迷你表的用户列改走 `userCellHTML`/`userLabelText`,name 与 user_name 都有时主显 name + 浅灰 user_name;`title` 悬浮给完整姓名/账号/邮箱/ID。后端 `admin.py` 两张表 SELECT 补 `User.name/user_name` 回带。
- dev 顶栏(`main.js` `renderWho`):默认显 name,hover(title)显账号/邮箱/ID。`state.js` 加 `userUserName/userEmail` + LS 持久化,抽 `setIdentity`/`userDisplayName`/`userDisplayTitle` 三个 helper,登录(`auth.js`)、embed 签发(`embed.js`)、`/v1/me` 校准(`loadRole`)共用;`login_password` 响应也回带 name/user_name 避免展示闪烁。
### 2026-06-25 / 平台登录注入用户档案 name/user_name(bump 0.26.0)
- 需求:平台作为可信中间层登录时,把用户 `name`(显示名)/ `user_name`(平台账号名)一并注入 zcbot 持久化,供前端展示。
- 实现:`users` 加两列(migration `0016`,纯加 nullable 列,平滑兼容存量行);`LoginRequest` 加可选 `name/user_name`,缺省即旧行为(向后兼容老调用方);`ensure_user_row` 升级为 upsert,`ON CONFLICT DO UPDATE SET x = COALESCE(EXCLUDED.x, users.x)` —— 平台传非空就刷新(同步平台侧改名),传 null/空不覆盖清空,空串归一到 None。
- 暴露:`/v1/auth/login` 响应 + `/v1/me` 回带 `{name, user_name, role}`(新增 `get_user_profile` 单次 SELECT)。机制选 platform 在 login body 推送(零额外往返,与未来 OIDC 的 name/preferred_username claim 注入同构),未选 zcbot 反向拉平台 API。
- 待办:migration `0016` 需在配好 `ZCBOT_DB_URL` 的环境跑 `.venv/Scripts/python.exe main.py db upgrade head` 应用;前端可消费 `/v1/me` 的 name 显示用户名。
### 2026-06-25 / 登录失败提示修正(bump 0.25.2)
- 问题:邮箱密码输错时前端弹「404」(后端 `login_password` 实际返 403「invalid email or password」,前置网关/旧构建把状态改写成 404 后,前端 `doLogin` 直接回显 `r.status + " login failed"` → 用户看到「404 login failed」,语义错误)。
- 修:`web/static/js/auth.js` `doLogin` 失败分支不再回显原始状态码 —— 表单已校验非空,非 2xx 绝大多数是凭据不对,统一给「账号或密码错误」(pw tab)/「user_id 或 PLATFORM_KEY 错误」(key tab);仅 5xx 暴露状态码提示服务端问题。后端 `web/app.py:1399` detail 同步改中文「账号或密码错误」保持契约自洽。
### 2026-06-24 / 微信 task 在 web 端只读镜像(bump 0.25.1)
- 问题:web 端打开 channel=wechat 的常驻 task 能正常发消息,但 web→微信**单向不同步**(web 发消息走 `/v1/tasks/{id}/messages`→`_run_agent_bg`,不经过 inbound loop 里 `send_text` 回微信那段,微信侧零感知);微信→web 则同步(同一条 task)。
- 取舍:不做"双向打通"(受微信 24h `context_token` 窗口约束 → 只能"有时同步",不可预测 + 两入口并发写歧义),改为 web 端**只读镜像**(单一交互权威锚定微信;想主动推走 `wechat_push`/定时简报)。
- `web/static/js/chat.js`:`applyChannelComposerLock(meta)`(selectTask 后调)对 wechat task 置 `chat-input` readOnly + 改 placeholder「请在微信里对话」+ 禁润色;`sendMessage` 入口加 channel 守卫(Enter 兜底)。`dev.html` 加 `.readonly-locked` 置灰样式。
### 2026-06-24 / 微信入站收图片/文件(bump 0.25.0)
- 缺口:`ILinkClient.get_updates` 只抽 `text_item`,图片/文件 item 被丢成空 text → `inbound._poll_binding` 又因空文本 `continue`,用户发的图/文件**静默丢弃、零落库**(DB 实证:caoqianming@foxmail.com 的微信 task 里发的图无任何记录)。
- `core/wechat/ilink.py`:新 `InboundAttachment`(kind/media/file_name/aeskey_hex/data);`get_updates` 解析 `image_item`(type=2)/`file_item`(type=4);新 `download_media()` = CDN `/c2c/download?encrypted_query_param=...` GET 密文 → `_aes_ecb_unpkcs7`(AES-128-ECB 解,发送侧 `_aes_ecb_pkcs7` 的逆);key 两种编码兜底 `_decode_media_aes_key`(base64(raw16) / base64(hex32),后者同发送侧);图片无名按 magic bytes 补扩展名 `_guess_image_ext` + `attachment_basename`(剥路径防穿越)。
- `core/wechat/inbound.py`:`HandleMessage` 契约加第三参 attachments;`_poll_binding` 先下载解密回填 `att.data`,文本/附件**都空才跳过**(单附件下载失败不拖垮整条)。
- `web/app.py:_run_wechat_message`:附件落盘 `<wd>/inbound/<ts>-<i>-<name>`,图片拼 `[用户上传的参考图] <rel>`(agent 自调 `look_at_image` 看图)、文件拼 `[用户上传的文件] <rel>`(agent 用 Read/Shell),**复用 web 端粘贴图同一约定**,不碰模型链路。
- 协议下载分支(GET vs POST、aes_key 取哪支)有真机实测风险:crypto roundtrip + 双编码 key decode 已单测通过;端到端待用户重发一张图验证(原图 cursor 已过)。
### 2026-06-24 / 微信绑定表重构:两表合一 channel_bindings(判别列+JSONB,bump 0.24.3)
- 起因:ClawBot(0012 `wechat_bot_bindings`,8 列)+ 企微(0014 `wecom_bindings`,1 列)各一表。从架构角度复盘:渠道绑定本质="用户在某渠道的一份配置",各渠道字段形态不同 → 最优是**判别列 + JSONB 多态**(与本库 `usage_events` kind+units / `scheduled_jobs.notify` 同范式),加渠道(飞书/TG…)零 migration。分表不扛增长、与库内范式不一致;单宽表(NULL 列并列)最差。
- 重构:`ChannelBinding(user_id, channel, status, config JSONB)` PK=(user_id,channel);clawbot config 装 `{bot_token*, user_im_id, base_url, latest_context_token*, context_token_at, chat_task_id}`(`*` crypto 加密入 JSONB),wecom 装 `{wecom_userid}`。migration `0015` 建表 + 把旧两表数据搬进 config(token 本就是密文串、原样搬)+ drop 旧表;DDL+DML 同事务,失败回滚不丢。
- **关键:只动 models + service 内部 + migration**,`service` 公共 API 与 `BindingSnapshot` 形状不变 → inbound/web/tool/scheduler **零改动**(纯内部数据层重构,对外行为不变)。趁绑定数据极少时合表最省。
- 文件:`core/storage/models.py`(`ChannelBinding` 替 `WeChatBotBinding`/`WeComBinding`)、`core/wechat/service.py`(存取改读写 config)、migration `0015_channel_bindings`(含 down 拆回)。import/编译 + `_snap` 反序列化单测过;DB 往返 + migration 待部署联调。
### 2026-06-24 / 修复微信绑定弹框标题样式错乱(bump 0.24.2)
- 根因:`#wechat-modal h3` 只设了 flex 布局,漏了其他弹框(crons/memory)都有的 `margin:0; padding:12px 16px; font-size:16px; border-bottom` → 标题吃浏览器默认 h3 样式(大字号 + ~21px 上下默认 margin + 无分隔线),看着比别的弹框又大又飘。
- 修复:`web/static/dev.html` 给 `#wechat-modal h3` 补齐标题样式,并加 `h3 svg{opacity:.85}``.sk-x` 关闭按钮样式,与 crons/memory 弹框对齐。
### 2026-06-24 / 修复 host-side 文件工具发不出附件(docker 容器路径未翻译,bump 0.24.1)
- 根因:生产 docker 模式下,fs 工具在容器里跑(文件落容器卷=宿主 `users/<uid>/<wd>/`),但 `send_email` / `wechat_push` 是**宿主进程**工具;它们 `base_dir=Path.cwd()`(部署根)且不识别容器↔宿主路径映射 → agent 给的相对路径拼到 cwd、容器绝对路径 `/workspace/...` 宿主上瞎解析,`relative_to(user_root)` 必越界 → 附件永远发不出(微信 DB 实锤 `#7` 相对 + `#15` 容器绝对两条都「文件路径越界」)。probe 脚本能发是因直接调 `send_file` 绕过解析。
- 修复:`tools/base.py` 加共享 `_resolve_user_file`(`/workspace` 前缀翻回 `user_root` + 相对拼 `base_dir` + 越界校验,抽 `FileOutOfBounds`);`agent_builder` 给两个 host 工具传 `base_dir=working_dir_path`(宿主 task 目录)而非 cwd;`send_email`/`wechat_bot` 改用 helper。host 模式同样受益(相对路径之前也错)。
- 测试:`tests/test_secret_host_tools.py` 加 3 例(helper 翻译+越界、send_email 容器路径附件、wechat_push 相对路径);诊断脚本 `scripts/diag_wechat_push.py`
### 2026-06-24 / 企业微信渠道 B:纯推送 + OAuth 扫码绑定(bump 0.24.0)
- 决策:**企业微信只做推送、不做对话**(用户拍板"和邮箱似的")——省掉入站回调 + AES + 5s ACK + agent 回推一整套;要对话走 ClawBot。企业微信的**无条件主动推**(不挑活跃度、无 24h 窗口)正补 ClawBot 短板,定时简报必达首选。
- 定位 touser:**OAuth 网页授权扫码**拿企业成员 `userid`(用户拍板,优于手填 opaque id)。前提:管理员建自建应用给 `WECOM_CORPID/AGENTID/SECRET` + 配「网页授权可信域名」。
- 文件(后端 import/编译 + 前端 node --check 自测过):`core/wechat/wecom.py`(access_token 2h 缓存+线程安全+失效重取、OAuth getuserinfo、message/send text/file、media/upload、state HMAC 签名);`WeComBinding` 模型 + migration `0014_wecom_bindings`(0013 被 task_channel 占);`service.py` 加 wecom CRUD + `push_wecom` + `send_to_user` 接 wecom 一路;`web/app.py` 5 端点(`/v1/wecom/oauth/url`、`/v1/wecom/oauth/callback` 公开-身份从 state 验、`/v1/wecom/bind` GET/DELETE、`/v1/wecom/test`);前端 rail「微信」modal 加企业微信段(`wechat.js` + dev.html)。
- env:`WECOM_CORPID/AGENTID/SECRET` + 可选 `ZCBOT_PUBLIC_BASE_URL`(OAuth redirect 主机,须在可信域名内)。**待办**:管理员就绪后端到端验(扫码绑 → test → 简报推);**回调端点须公开**(已不挂 require_user)且 redirect 主机匹配可信域名。
### 2026-06-24 / 配置 QQ/foxmail SMTP 发信 + 发件人显示名品牌化(bump 0.23.2)
- `.env` 填入 foxmail SMTP(smtp.qq.com:25 / STARTTLS / 授权码),`send_email` tool 与定时任务 notify 兜底投递就此生效;自检发信链路通过。
- `tools/send_email.py` 发件人显示名从硬编码 `zcbot` 改为读 `SMTP_FROM_NAME`,默认「总院科研辅助智能体」—— 对外不暴露内部代号。RUN.md env 段补 `SMTP_FROM_NAME`
### 2026-06-24 / 微信任务徽章改品牌绿 + 微信 logo + 整行绿边(bump 0.23.1)
- 上一版徽章复用 `.badge.active`(蓝灰),与旁边「进行中」状态徽章撞色、不显眼。
- 新增 `.badge.wx`(微信品牌绿 `#07C160` + 白字 + 内嵌微信 logo SVG)与 `.task-row.wx`(绿色左边框 + 极淡绿底 + hover 加深),让置顶的微信任务从普通任务里跳出来。文件:`web/static/dev.html`(CSS)、`web/static/js/chat.js`(`WECHAT_ICON` 常量 + badge/row class)。
### 2026-06-24 / 微信对话 task 渠道标记 + 置顶(bump 0.23.0)
- 痛点:微信常驻 task 与网页常规 task 结构相同,只能靠 description 魔法值反推;且 `created_at` 固定后随用户开新 task 越沉越深,这个「渠道收件箱」反而最难找。
- `tasks``channel` 列(`web`/`wechat`,migration 0013,`server_default='web'` 回填存量、并把 description=`(微信 ClawBot 对话)` 的存量 task backfill 成 `wechat`)。`ensure_local_task_row` 加 `channel` 参数,微信建 task 处传 `wechat`;`channel` 仅 INSERT 写定,后续 upsert/save 不传 → 不覆盖。
- `_task_dict` 透出 `channel`;列表查询排序前置 `case((channel=='wechat',0),else_=1)` pin 表达式 → 微信 task 后端强制置顶(跨分页稳定),用户选的排序对其余 task 照常生效。
- 前端 `chat.js` 任务名前打绿色「微信」徽章(`channel==='wechat'`)。文件:`core/storage/models.py`、`core/storage/utils.py`、`web/app.py`、`web/static/js/chat.js`、`db/migrations/versions/...0013_task_channel.py`。
### 2026-06-24 / 微信绑定 UI 并入主 SPA(bump 0.22.2)
- 上一版绑定页是独立 `/static/wechat_bind.html`,主界面没入口、用户找不到。
- 集成:左栏 rail 加「微信」按钮(`hd-wechat`)→ 扫码绑定 modal(`wechat-modal`),复用 `api()` 调已有 5 端点(起码/轮询/查/解绑/自检),仿 `crons.js` modal 范式;过期自动换码、绑定成功提示去微信开口。文件:`web/static/js/wechat.js`(新)、`web/static/dev.html`(rail 按钮 + modal + CSS)、`web/static/js/main.js`(import 触发绑定 + Esc 关闭)。
- 独立页 `web/static/wechat_bind.html` 保留作嵌入/兜底入口(同套端点)。
### 2026-06-24 / 修复顶栏 token 计量栏回复后不刷新(bump 0.22.1)
- 现象:提问→助手答完后,对话顶栏的「总 token · 缓存命中 · 花费」计量栏停在发问前旧值,要切到别的 task 再切回才更新。
- 根因:计量栏由 `renderChatMeta()``state.taskMeta` 渲染,而 `state.taskMeta` 只在 `selectTask``GET /v1/tasks/{id}` 时刷新。SSE 流结束后 `fetchSse` 的 finally 只 `loadTaskList()`(左栏列表)+ `loadMessages()`,从未重拉 meta 也没调 `renderChatMeta`——SSE 期间用量只累计进 hint,没落 taskMeta。
- 修:`fetchSse` finally 块里,当收尾的是当前可见 task 时补一次 `GET /v1/tasks/{id}` → 重置 `state.taskMeta``renderChatMeta()`;失败 try/catch 吞掉不打断收尾。`web/static/js/chat.js`。
### 2026-06-24 / 微信接入第一期:ClawBot 个人微信(后端完成,bump 0.22.0)
- 需求:把 zcbot 送进用户**个人微信**——能对话、能推简报/结果。调研三条路:wechaty/hook(违规高封号,排除)、企业微信自建应用(官方但要管理员+仅企业成员)、**微信 ClawBot**(腾讯 2026-03 官方个人号 Bot API,iLink 协议,零封号,后端接谁都行)。选 ClawBot 先行。详 DESIGN §8.7。
- **协议全程真机实测**(`scripts/probe_clawbot*.py`,本人微信号在灰度内):① 扫码绑定拿 `bot_token`;② `getupdates` 长轮询收消息;③ `sendmessage` **每条 `client_id` 必唯一**(漏则同 token 后续被丢——前几轮误判"纯被动"的真因),多条/长文中间块 `state=1` 末块 `state=2`;④ `context_token` 24h 可复用 → **主动推送成立**(需用户先开口一次);⑤ 文件:`getuploadurl`→AES-128-ECB(PKCS7)→CDN(URL 带 `filekey`,漏则 400 mismatch)→`file_item`,docx/pdf 原生直推。
- **关键设计决策**:入站对话→每用户一条 persistent「微信」task(连续性,token 靠 §8.2 压缩);凭据(bot_token/context_token)加密列(env `ZCBOT_WECHAT_SECRET_KEY`),绝不进沙箱/日志;**入站出站一体**——主动推送依赖入站给的 context_token,故 getupdates 长轮询常驻(既收对话又刷新 24h 窗口)。
- **文件**(后端全部 import/编译自测过):`core/wechat/{ilink.py 协议客户端, crypto.py 凭据加密, service.py 绑定CRUD+推送+send_to_user 渠道抽象, inbound.py 长轮询管理器+回复提取}`;`core/storage/models.py` 加 `WeChatBotBinding` + migration `0012_wechat_bot_bindings`;`tools/wechat_bot.py` `WechatPushTool` + `core/agent_builder.py` 注册(有开关才挂);`core/scheduler.py` `deliver_notify``wechat` 通道(未送达退邮件兜底);`web/app.py` lifespan 起入站管理器 + `_run_wechat_message` 回调 + 5 端点(`/v1/wechat/bind/qrcode|status`、`/v1/wechat/bind` GET/DELETE、`/v1/wechat/test`);`web/static/wechat_bind.html` 自包含绑定页;`requirements.txt` 加 segno+cryptography。
- **env**:`ZCBOT_WECHAT_BOT_ENABLED=1`(渠道开关)+ `ZCBOT_WECHAT_SECRET_KEY=<串>`(凭据加密,缺则退明文标记)+ 可选 `ZCBOT_WECHAT_BASE_URL`
- **待办(部署后联调)**:migration `0012` 上库;起 web 进程端到端验(扫码绑定→对话→主动推→定时简报推);**渠道 B 企业微信**(无条件推送,补 ClawBot 24h 窗口短板)按 §8.7「渠道 B」实现。SPA 集成已落(见下条)。
### 2026-06-23 / 平台渲染层 rendering/:三 skill docx 统一 + chromium md→pdf(bump 0.21.0)
- 背景:线上 `简报` task 用户要"输出为pdf",模型因 brief 无 PDF 路径而临场即兴——试 `apt install libreoffice`(只读 fs 失败)→ `pip install weasyprint markdown` 手搓 md→HTML→weasyprint;容器空闲回收后包不持久,二次导出又重装一遍。深挖发现两个问题:① skill 缺 PDF 路径、weasyprint 不在镜像;② `_CHEM_RE` 化学式白名单在 brief/paper/proposal **三份 render_docx.py 逐字重复**(改一处易漏改),patent/standard 还复用 proposal 那份。
- 架构判断:**渲染不是 skill 内容,是平台能力**(像 chromium/document_search)。Skills 走 Anthropic 自包含/可 fork bundle 标准,把共享渲染库塞 `skills/_shared` 让各 skill `import` 会破坏 fork。故新建**顶层 `rendering/` 平台包**,bind-mount 进 `/sandbox/rendering`(pool.py,与 skills 同款 ro),各 skill 调 `render.py` 不再自带 render 脚本。
- `rendering/`:`common.py`(叶子原语单一事实源:字体/`CHEM_RE`/块级正则/表格行/图片路径)+ `docx_manuscript.py`(paper/proposal 配置化双 profile)+ `docx_brief.py`(brief 富渲染,复用 common)+ `pdf.py`(md→HTML→chromium `--print-to-pdf`,复用 `common.CHEM_RE`)+ `render.py`(统一 CLI `--profile {brief,paper,proposal} --format {docx,pdf}`,sys.path bootstrap 让 `python /sandbox/rendering/render.py` 直调可解析)。
- **零回归证明**:重构前后对三 profile 各渲 docx、解包 diff `word/document.xml`,brief/paper/proposal **全部字节一致**(12962/10755/11401 bytes)。纯搬移+共享原语,输出不变。
- chromium md→pdf:不用 weasyprint(要 pango/cairo、不在仓库 Dockerfile);chromium 镜像已装(给 mermaid)+ fonts-noto-cjk 已装,完整内核 CSS 保真度更高。固定 `--no-sandbox --disable-dev-shm-usage --user-data-dir=/tmp/* --no-pdf-header-footer`。冒烟 `deploy/sandbox/probe_chromium_pdf.sh`(照 probe_mermaid.sh):最小 chromium 镜像在 `--read-only --cap-drop=ALL` + 64MB `/dev/shm` 下实测出图,中文/下标/DOI 超链/表格/callout 全绿、页眉已关。
- 删:`skills/{brief,paper,proposal}/scripts/render_docx.py`(3 份)+ 短命的 `skills/_shared/render_pdf.py`。改 5 个 SKILL.md(brief/paper/proposal 直接调,patent/standard 复用 proposal profile)调用到 render.py + 补反模式"渲染一律调 render.py、禁止手搓"。`requirements.txt` 加 `markdown`
- **部署要点**:`/sandbox/rendering` 挂载靠 pool.py(restart 重建容器才生效)+ `markdown` 进镜像靠 requirements 变更触发的整体重建 —— **需一次 deploy(update.sh)原子激活**,旧 render_docx 路径已删,deploy 前别只推 SKILL 改动。引文 `[n]` 上标回链 pdf 仍按字面渲(docx 有,pdf 后补)。
- 文件:`rendering/{__init__,common,docx_manuscript,docx_brief,pdf,render}.py`(新)、`core/sandbox/pool.py`(+rendering 挂载)、`deploy/sandbox/probe_chromium_pdf.sh`(新)、`requirements.txt`、5×`SKILL.md`、`skills/brief/SKILL.md`(另删 research 索引滞后描述)、`core/__init__.py` 0.20.4→0.21.0。
### 2026-06-23 / 消息目录定位错位修复(bump 0.20.4)
- 现象:点右侧圆点轨道**第一个**圆点,活跃高亮常落到**第二个**。根因是两套锚点不一致——`jumpToMessage` 用 `block:"center"` 居中,但第一轮上方无内容无法居中、被钉到顶端;而 `updateActiveOutlineDot` 按「顶线 80px 容差」判活跃轮,第一轮短时下一轮卡片顶也落进 80px 带内 → 越界高亮第二个圆点(滚动监听又覆盖了 jumpToMessage 的显式 setActiveOutlineIdx)。
- 修复:跳转改 `block:"start"`(顶部对齐,与活跃判定同锚点)+ `.msg``scroll-margin-top:16px` 留呼吸;活跃容差 80→24 与之对齐,贴顶短轮判到自己不越界。
- 文件:`web/static/js/chat.js`(`jumpToMessage` / `updateActiveOutlineDot`)、`web/static/dev.html`(`.msg` CSS);`core/__init__.py` 0.20.3→0.20.4。
### 2026-06-22 / 前端两处 bug 修复(bump 0.20.3)
- 定时弹窗"被遮挡":`#crons-modal` 漏了 z-index,退回基础 `.modal`(无 z-index)被 z-index:5 的侧栏/面板盖住;补 `z-index: 112` 与兄弟只读 modal(`#skills-modal`/`#memory-modal`)对齐。排查用 node 加 DOM mock 跑通整条前端模块图,确认 `hd-crons` 绑定确实执行(排除了"按钮没绑事件"),定位到纯 CSS 层叠问题。
- 登录页 focus 引用错 id:`web/static/js/main.js:106` `$("li-token").focus()``li-token` 不存在(登录输入框实际是 `li-email`),未登录 boot 末尾会抛 TypeError;改为 `li-email`
- 文件:`web/static/dev.html`、`web/static/js/main.js`;`core/__init__.py` 0.20.2→0.20.3。
### 2026-06-21 / 发送期修复悬空 tool_calls(bump 0.20.2)
- 根因(监控页 error 任务排查,task 5c5d6d25 DB 实测):run 在写入 `assistant.tool_calls` 之后、tool 结果写库之前被中断(上游流式断连 / 用户取消 / 崩溃),历史里留下一条 `assistant.tool_calls` 后面**没有对应 tool 结果**的消息;用户随后继续发言,下一轮把历史原样发给 DeepSeek/OpenAI 即被拒 `An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'` → 任务进 `run_status=error` 卡死。区别于 06-06/06-12 的 arguments 损坏/投毒修复(那治"参数被压成 marker"),这是**结构性悬空**,旧修复不覆盖。
- 修复(方案 A,发送期兜底):`core/context.py` 新增 `_repair_dangling_tool_calls`,在 `prepare_messages_with_stats` 入口(早返回分支之前)对每条 `assistant.tool_calls` 扫描紧随其后的连续 tool 结果,为**缺失**的 `tool_call_id` 补一条占位 tool 消息(`[interrupted: ...]`,带原 function name)。纯发送期、不改库 → 覆盖所有中断路径 + 已存在的坏数据自愈(下次发消息即修复),`stats.repaired_tool_calls` 计数。选 A 而非写入期防御(方案 B):B 要覆盖所有中断路径易漏且救不了存量。
- 验证:真实坏 task 5c5d6d25 修复前 idx 19 悬空 1 条 → 修复后 0 悬空、协议合法(压缩开/跳过两分支均覆盖);新增 4 个单测,context 套件 14 项全过。
- 文件:`core/context.py`、`tests/test_context_compaction.py`;`core/__init__.py` 0.20.1→0.20.2。
### 2026-06-18 / brief 简报重定位「重要文献速览」+ 精简三文件(bump 0.20.0)
- 需求漂移收敛:brief 从"热点聚类趋势判断型简报"重定位为**「重要论文列表 + 内容总结」速览型** —— ①只描述不给建议(去掉启示/判断/空白争议);②开头一份重要期刊论文列表(各大相关刊、**Elsevier 数据库优先**),每篇带一段简介/摘要概述;③对这批论文做客观总结即可。
- 数据源:**research + documents 都是取文献主力**(research 逐刊精确取最新 Elsevier 论文 + DOI;documents 取内部材料库全文),web search 取动向**单列**不混进论文总结。
- **精简到三文件**(原 8 文件):`SKILL.md`(自包含:spec 字段/骨架/检索法/核验铁律/渲染说明)+ `references/journals.md`(各建材子领域主流期刊清单,Elsevier 标注 + 精确 publication_name + 0 命中降级)+ `scripts/render_docx.py`。删 `templates/spec.md`、`templates/brief_outline.md`、`references/search_strategy.md`、`references/citation_verify.md`、`scripts/quality_check.py`。
- `render_docx.py` 两处小改并已 smoke test 验证:①「重要论文列表」段(标题含"论文列表/文献列表/参考文献")H3 期刊子标题下的 `[n]` 条目仍作锚点(只在 H1/H2 重判段类型);②条目内 DOI 子串(末尾 "DOI: 10.xxx")也做 https://doi.org 超链接。验证:ref 锚点/内部回链/外部 DOI 链/化学式下标全在。
- 文件:`skills/brief/{SKILL.md,references/journals.md,scripts/render_docx.py}`;`core/__init__.py` 0.19.0→0.20.0;`SKILL_LIST.md`(brief 条目重写,总数仍 17)同步。
### 2026-06-18 / 定时任务 v1(scheduled_jobs,DESIGN §8.5)
- 需求:对话方式建"每天 X 点干 Y"的定时任务(跑 skill 出简报 / 发邮件 / 打招呼皆可)。调研 OpenClaw/Autobot/Claude Code/geta 四源收敛,定方案见 DESIGN §8.5。
- **核心解耦**:job 本体 = `cron+tz + 一句 prompt + 会话模式`;"发邮件"不是字段,是 agent 据 prompt 调 `send_email` 的动作 → 加任何能力不改 schema。
- **不引调度框架**:croniter(唯一新依赖)只当 next_run 计算器(正确处理 dom/dow OR 语义 + 时区);"每 30s 醒来扫到点 job"是 plain-asyncio 守护循环,仿 §8.4 `_disk_scanner`,复用 `_run_agent_bg`,不上 APScheduler/Celery。
- **文件**:`core/storage/models.py` 加 `ScheduledJob` + migration `0011_scheduled_jobs`(独立加表,公测兼容)/ `core/scheduler.py`(cron 数学 + claim+advance 防重复触发 + record_result 失败阈值自停 + notify 兜底投递 + CRUD 服务层 `list/create/update/set_enabled/cancel_job`,工具与 REST 共用)/ `tools/schedule.py`(create/list/**update**/cancel 四件套,薄包装服务层,user_id ctor 注入,定时 run 内不挂防自我繁殖)/ `tools/send_email.py`(host-side,SMTP_* 齐才挂)/ `web/app.py` lifespan `_scheduler_loop` + `_execute_scheduled_job`(认领→抢 run 锁→to_thread 跑→超时协作 cancel→notify→记账)+ `/v1/schedules` GET/PATCH/DELETE 三端点。
- **对话端 = 完整 CRUD**(建/改/删/查都说着办);**前端 = 只读展示 + 停用/删除两个便捷按钮**(左栏 rail「定时」按钮 → `crons.js` 只读 master-detail modal,复用 skills modal 范式;建/改无 REST、故意只走对话,§8.5)。两条路径共用 `core.scheduler` 服务层不漂移。
- **会话模式**:isolated(默认,每次新建临时 task `scheduled-<id8>` 目录,省 token)/ persistent(绑定 bound_task_id 续上下文)。env:`SMTP_*` / `ZCBOT_DISABLE_SCHEDULER` / `ZCBOT_SCHEDULER_TICK_SECONDS` / `ZCBOT_SCHEDULER_CONCURRENCY`(见 RUN)。已验:migration 上库 0011、CRUD 服务层端到端、3 REST 路由 + 4 工具注册、crons.js 语法。bump 0.18.0 → 0.19.0。
- **v2 待做**:对话工具教写好 job.prompt 的薄 skill;退避重试(transient/permanent 区分)目前简化为"到下一 cron 点 + 连失败 5 次自停";真机邮件 smoke + 守护循环定时触发的端到端验证(需起 web 进程跑一轮)。
### 2026-06-18 / brief skill:科研方向简报
- 需求:用户要"水泥/建材方向的科研简报"。联网调研简报类做法——Anthropic 官方 digest skill(办公活动聚合)+ Paper Digest(论文影响力周报)+ 文献计量趋势报告(热点聚类/新兴方法/地理格局)。结论:现有 skill 缺"某方向近期文献 → 有判断的趋势简报"这一环(research/documents 只取文献不组织、paper-review 出可投稿综述、analyze 拆问题不查文献)。
- **方案**:新建自包含 `skills/brief/`,定位"文献计量趋势型简报",数据底座**三路并用**:documents(内部胶凝材料库取全文)/ research(补 DOI + year_gte 卡时间窗)/ web(政策·标准·产业动向,单列不混学术引文计数)。六阶段:定题对齐 spec(方向+边界/时间窗/受众/深度/源开关/语言/关注点)→ 三路检索取数(中→英术语转译 + 跨源去重,证据表 evidence.md)→ 趋势分析(3-7 热点簇,BLOCKING-lite 对齐)→ 逐段起草 → 引文核验(复用 paper 三层协议,CITATIONS.md)→ 渲染验收。
- 深度三档 flash/standard/deep 配字数/簇数/引文数预算;骨架:TL;DR→概览→热点聚类→新兴方法→标志性进展→研究空白→产业政策动向(web)→参考文献。渲染早期复用 proposal,后改为自带 render_docx。
- 文件:`SKILL.md` + `templates/{spec,brief_outline}.md` + `references/{search_strategy,citation_verify}.md` + `scripts/quality_check.py`(结构/簇数预算/过度宣称/**无源句式**/引文交叉核对)+ `scripts/render_docx.py`(简报专属:商务红主题 + 引文 [n]/[Wn] 上标并锚到文末 + DOI/URL 可点击超链接 + TL;DR/判断 callout 底纹)。
- **顺带修 zcbot 全局「角标」问题**:水泥化学式在 docx 里平排数字(CO2/C3S/SO3...)是 paper/proposal 渲染器的老毛病。抽一份**化学式下标白名单**(长在前 + `\b` 防误伤 LC3/C595/Ca2+/2026,实测命中精确零误伤)统一补进 `paper`、`proposal`、`brief` 三个 `render_docx.py``add_inline` plain 分支(按"自包含 skill 脚本不跨 skill 引"的既有约定**各自复制同一份**,不建共享模块)。`core/export_docx.py` 是对话原文转录、非排版文档,不动。bump 0.17.0 → 0.18.0。
### 2026-06-17 / 任务软删除(留对话轨迹做语料 + 可恢复)
- 背景:公测后目标转为沉淀用户对话/文件做训练研究语料;原"hard cascade"硬删任务会连带 messages/usage_events 永久丢失,推翻该决策(DESIGN §取舍同步标注)。
- 改动:`tasks` 加 `deleted_at` 列(0010 migration,additive 可空);`DELETE /v1/tasks/{id}` 从 `DELETE` 改为置 `deleted_at=now()`,不再触发 CASCADE、不动工作目录文件(原 rmdir 清理一并去掉);`list_tasks` / `list_folders` 计数加 `WHERE deleted_at IS NULL` 过滤;新增 `POST /v1/tasks/{id}/restore` 恢复;`delete_file` 顶层目录 409 引用检查排除软删 task。
- 文件留存(归档)方案已在 DESIGN 记录(restic 备份地基 + DB 事件日志 + 起步同盘),**实现待办**,优先级靠后。bump 0.16.2 → 0.17.0。
### 2026-06-17 / 用户操作说明书(详 + 精简两版)+ 文献库库容 21W→100W 全量更新
- 新增 `docs/操作说明书.md`(详版)+ `docs/操作说明书-精简版.md`:面向科研用户、不出现产品代号、从登录后正式操作讲起。覆盖三栏布局、**个人文件夹 → 工作目录 → 任务**三层概念(任务≠文件夹、多任务可共享一个工作目录)、新建任务、对话、技能矩阵(含 paper)、文件管理、进阶(方案确认卡/消息目录/记忆)、任务管理、图像视频、账户存储、FAQ;截图留占位标注。突出对外优势(内部文献库、科研计算、可直接产出文件)。
- 文献库库容口径 **21W+ → 100W+** 全量改一遍:`SKILL_LIST.md`、`skills/documents/SKILL.md`(含 description,模型运行时据此向用户描述库容)、`skills/patent/SKILL.md`、`PROGRESS.md`、`scripts/optimize_arch_ppt.py`、两份说明书,共 10 处。bump 0.16.1 → 0.16.2。
### 2026-06-17 / paper skill:学术期刊论文写作
- 需求:现有 skill(proposal 写本子 / review 改稿 / research 查文献 / plot_pub 出图)缺"从零起草期刊投稿稿"这一环。联网调研开源论文 skill(ARS 32.1k★ / paper-writer-skill / claude-scientific-writer 1.9k★)——结论:不直接装(ARS 是 CC-BY-NC 非商用、全偏英文/医学/CS、引文默认 APA、依赖外部 API),但流程值得移植。
- **方案**:新建自包含 `skills/paper/`,流程骨架取 paper-writer 的"先定图表 + stage-gate"与 ARS 的"三角引文核验 + 反谄媚审稿",**底座全换成 zcbot 自有**(documents/research 查文献与核验、plot_pub 出图、复用 proposal 的渲染心智)。**中英双语 × 三类型(original/review/letter)用子 md 分流**(cite_gbt7714/cite_elsevier + redlines_zh/redlines_en,一篇只挂一套)。
- 六阶段:摄取 → 八条对齐 spec → 文献矩阵 → 先定图表 → 逐章一段一卡(Methods→Results→Intro→Discussion→Abstract→Title 顺序) → 引文三角核验(存在性/三角/支撑度,台账 CITATIONS.md) → 验收渲染 + 投稿件。终审复用 review skill。
- 脚本(自带,不跨 skill 引):`render_diagrams.py`(照搬)/ `render_docx.py`(去 fund-type,加 `--lang {zh,en}` 图题切换 + `--toc` 默认关)/ `word_count.py`(类型×语言双口径预算)/ `quality_check.py`(论文版核心=**引文交叉核对** orphan/uncited/编号连续 + 结构/占位符/过度宣称/插图)。
- 验证:微型 fixture 端到端 smoke——word_count 正确标欠预算、quality_check happy path 全 OK 且 orphan/uncited/缺号负例正确触发、render_docx 出 37KB docx。文件:1 SKILL.md + 6 references + 3 templates + 4 scripts。SKILL_LIST 同步(15→16)。bump 0.16.0 → 0.16.1。
### 2026-06-16 / look_at_image 图像理解(DESIGN §8.1 C 路落地)
- 需求:DeepSeek V4 主模型纯文本无视觉,挂 `look_at_image` 工具按需"借眼睛"读图(OCR / 描述 / 读图表),模型自决何时调。
- **模型选型**:设计时的 Seed 1.6 vision 已过时(联网核实),改用 **Doubao Seed 2.0 Lite**(`doubao-seed-2-0-lite-260428`,全模态 SOTA 细粒度感知)。token 计费输入 ¥0.6 / 输出 ¥3.6 / Mtok,一次读图 < ¥0.01。否决「换主模型走 A 路」——DeepSeek 的 code/tool-calling 仍是核心,vision 当工具更稳。
- 后端:新建 `tools/look_at_image.py`(`/chat/completions` OpenAI 兼容,base64 单图 + question → 文本解读,默认 question 覆盖描述+OCR+图表读数);`config/media/doubao.yaml` 加 `vision:` 段;`core/storage/usage.py` 加 `record_vision_usage`(kind="vision",按 token,单价 snapshot 进 units);`agent_builder.py` 注册(yaml 有 vision 段才挂)+ media prompt 段教「何时调 / 何时别调」。`usage_events.kind` 自由文本,vision **无需 migration**
- 重构:图片路径解析 + base64 抽到 `tools/image_ref.py`,seedream(i2i)与 look_at_image 共用(三形态路径 + user_root 边界 + 扩展名/大小校验)。
- 验证:真机 smoke `scripts/smoke_look_at_image.py` 合成含已知文字图 → OCR 准确读出 + usage_events 落 kind=vision(实测 ¥0.0011)。bump 0.15.0 → 0.16.0。
### 2026-06-16 / seedream i2i 改图(DESIGN §8.1 E 路落地)+ 前端 paste 路径注入
- 需求:覆盖「基于已生成 / 上传的图做修改」(像素级),核心循环=文生图 → 用户"改成 X" → i2i 改那张(不重画)。base64 通路 probe 2026-05-29 已验,本次落 tool。
- 后端 `tools/seedream.py`:加 `reference_images` 数组参数(**v1 单图**,传 >1 直接报错不静默截断)。路径解析走共享 `tools/image_ref.py`(与 look_at_image 同一套)——依次试 `working_dir/rel``user_root/rel` → 绝对,**强制结果落在 user_root 子树内**(防越界读任意文件),吃三种路径形态(`figures/x.png` / saved 形态 `<taskname>/figures/x.png` / 绝对);校验存在 + 图片扩展名(png/jpg/jpeg/webp/gif)+ ≤10MB;读 base64 → data URL → ARK body `image_urls`。**不传 reference_images = 文生图,行为 100% 不变(向后兼容)**。banner 加 `· mode=i2i` + `reference=` 行(前端正则兼容),meta.json 记 `mode` / `reference_images`(派生链可追溯)。
- 前端 `web/static/js/chat.js`:`sendMessage` 发送时 `takePastedRels()` 收集 `chat-hint` 的 paste-chip 路径,作 `[用户上传的参考图] <rel>` 行注入正文 + 清 chip ——**修了既有缺口**(之前粘贴的图路径根本到不了模型)。这样"上传外部图 → 改图 / 看图"才能定位到文件。
- 引导:`skills/imagegen/SKILL.md` 删旧「不接图像输入」结论 + 加「改图(i2i)」专段(最易踩错=该 i2i 却重新 t2i 丢构图);`agent_builder.py` 媒体 block 提 i2i + paste 注入约定;`SKILL_LIST.md` 同步。bump 0.14.0 → 0.15.0(看图 look_at_image 同日落地见上一条)。
### 2026-06-16 / ask_user:回复里渲染可点击「方案确认」选项卡(Claude 式)
- 需求:agent 在分叉点能像 Claude 那样抛出可点选项,用户点一个继续、或不点直接用文字讨论。设计取舍见下。
- **收窄定位**:不是通用提问器,只做「方案/分支确认」——存在 2-4 个互斥方向且选择会实质改变后续动作时才用。防 agent「变爱问」(高轮数烧 token 已知痛点)是成败关键,故系统提示严格约束使用条件。
- **与轮次模型同构、无阻塞**:复用「LLM 出无 tool_call 消息即结束本轮」语义——`ask_user` 是虚拟工具(同 `task_progress` 范式),`core/loop.py` 检测到本步调用它就 emit done 提前结束本轮、不回灌 LLM;点选项 = 把该选项 label 当新用户消息发出(复用 `POST /messages`),零额外 LLM 往返。
- 后端:新增 `tools/ask_user.py`(`AskUserTool`,question + 2-4 个 `{label, description}` 选项,结果仅占位);`core/agent_builder.py` 注册;`core/loop.py` 加提前终止分支;`prompts/system/general_v1.md` 加「方案确认约定」段 + 工具清单一行。
- 前端 `web/static/js/chat.js`:`buildAskUserCard` 渲染选项卡;`handleSseEvent` 的 `tool_call`/`tool_result` 特判 ask_user(选项卡 / 抑制占位结果);`renderMessages` 历史重渲特判(改 index 遍历,向后看有无 user 回复判「已答」,命中项标「✓ 已选」);`sendMessage(overrideText)` 支持点击直发不清输入框;`chat-stream` 点击委托接 `.ask-option`。`dev.html` 加 `.ask-user/.ask-option` 等样式。持久化天然免费(选项在 `tool_calls.arguments` 里,刷新页面按钮还在)。bump 0.13.0 → 0.14.0。
### 2026-06-16 / 消息目录:右侧悬浮圆点轨道导航(ChatGPT 式)+ 双向分页
- 需求:长对话里快速定位历史某轮提问。参考 ChatGPT 扩展(Scrollbar / Outline)的交互——每点=一轮"我"的提问,hover 出标题气泡,点击滚动定位。
- 后端 `web/app.py`:① `list_messages``after_idx` 参数 + 响应加 `has_more_after`,支持**向下**翻页(从目录跳到旧消息后下方还有未加载的新消息);② 新增 `GET /v1/tasks/{id}/outline`,只取全部 role=user 的 `idx + 首行片段`(`payload->>'content'`,不回传整 payload,轻量),`_outline_snippet` 取首个非空行截 48 字。走 `(task_id,idx)` 索引按 task 收窄。
- 前端:`state.js` 加 `outline / msgHasMoreNewer / msgLoadingNewer`;`chat.js` 加 `refreshOutline / renderOutlineRail / jumpToMessage / loadMessagesAround / loadNewerMessages`、消息卡补 `data-idx` 锚点、底部 sentinel(下滑加载更新)、滚动高亮当前轮;`selectTask` 把 outline 并入 meta/messages 并发拉,run 收尾后刷新。跳未加载轮次用 `before_idx=idx+11` 拉居中窗口再 `scrollIntoView`
- `dev.html`:`#pane-mid` 加 `position:relative`,新增 `#msg-outline-rail` 悬浮轨道(容器 `pointer-events:none` 不挡滚动条、仅圆点可点,hover 整列展开标题),手机端隐藏。embed 页无该元素,绑定与渲染均 null-safe。bump 0.12.16 → 0.13.0。
### 2026-06-16 / 切 task 提速:meta+messages 并发拉 + 默认窗口降到 30
- 体感诊断:切 task 慢**不是索引问题**——`messages` 的 `UniqueConstraint(task_id, idx)` 在 PG 自带 `(task_id, idx)` 复合索引,主查询 `WHERE task_id=? ORDER BY idx`(app.py:1442)既走索引过滤又免排序;也不是"全量加载",前端早已尾部窗口分页。真正的低垂果实是 `selectTask` 里 meta 与 messages **串行 await**,以及首屏窗口偏大。
- `web/static/js/chat.js`:`selectTask` 把 `GET /v1/tasks/{id}`(meta)与 `loadMessages`(messages)改 `Promise.all` 并发(两者无依赖、落不同 DOM 区),省一个 RTT;`MSG_PAGE` 60→30,降首屏传输 + markdown/highlight 同步渲染量。bump 0.12.15 → 0.12.16。
### 2026-06-15 / plot_pub 吸收 nature-figure 投稿级复合图设计纪律
- 联网调研 `nature-figure` skill(MIT,github.com/Yuan1z0825/nature-skills):双层 manifest 路由 + Python/R 双后端 + 生物医学 gallery。判断不整包移植 —— 与已有 plot_pub 高度重叠、R/单细胞/在体内容跟建材院领域不沾边、多文件结构破坏 zcbot 单 SKILL.md 约定。
- 只迁移可复用的设计 IP,折进 `skills/plot_pub`:`style.py` 补 `svg.fonttype='none'`(可编辑矢量,原本只设了 PDF Type 42 漏了 SVG)+ `SEMANTIC_COLORS` 语义色表 + `clean_spines()` spine 纪律 + `ablation_alphas()` 同色变 alpha;`SKILL.md` 新增「投稿级多 panel 复合图」段(五点 figure contract / 语义配色 / 信息架构 / 导出纪律),示例全改建材领域。纯 Python、零新依赖、保留中文字体。bump 0.12.14 → 0.12.15。
### 2026-06-15 / 消息分页:尾部窗口 + 向上滚动加载更早(切 task 提速)
- 痛点:切 task 卡顿 —— `/v1/tasks/{id}/messages` 无分页一次拉全量,前端 `renderMessages` 又对每条跑 markdown+highlight+media 全量渲 DOM,消息多时两段成本都线性涨。
- 后端 `web/app.py` `list_messages`:加可选 query `limit`、`before_idx`。不传 → 旧行为(升序全量,仅多返 `has_more:false`,向后兼容);传 `limit` → 取尾部最近 N 条(`idx desc + limit` 再 reverse);传 `before_idx` → 取该 idx 之前更早一批。响应恒含 `has_more`
- 前端 `chat.js`:① `selectTask` 进来立即把 chat-stream 换「加载中…」(治感知,切换瞬时跟手);② `loadMessages` 默认 `limit=60`,结果存 `state.loadedMessages/msgHasMore`;③ 新增 `loadEarlierMessages` + `_msgScrollObserver`(复用 task list 的 sentinel 范式),顶部 sentinel 进视口自动 prepend 更早一批后整窗重渲(renderMessages 仍是对 loadedMessages 的纯函数,时序累积逻辑不动),重渲后锚回滚动位不跳视口。
- `state.js``loadedMessages/msgHasMore/msgLoadingEarlier`;`dev.html` 加 `.msg-top-sentinel` 样式。取舍:只载尾部时进度 dock 仅反映窗口内 task_progress,补满更早后一致。bump 0.12.13 → 0.12.14。
### 2026-06-15 / 图片预览:左键拖动平移 + 光标语义改正
- 光标:100% 时改回普通箭头(原 `zoom-in` 放大镜误导 —— 左键不缩放,缩放是 Ctrl+滚轮);放大后改 `grab`、拖动中 `grabbing`,贴合"可拖"语义。
- 左键拖动平移:放大态下 mousedown 记起点 + body 滚动位,mousemove 改 `bodyEl.scrollLeft/Top` 平移看局部(替代拖滚动条);`img.draggable=false` 关原生 ghost 拖拽。document 上的 move/up 监听存 `z._onMove/_onUp`,`_clearZoom` 时移除避免泄漏。bump 0.12.12 → 0.12.13。
### 2026-06-15 / 文件预览缩放加固 + 双击复位提示
- 图片 load 完即量基准尺寸(`_captureBase`,免首次缩放时还没渲染量到 0px 导致塌成 0);基准未量到时本次缩放跳过不破坏;双击复位时徽标显式提示「已复位 · 100%」(停留 1.4s)。bump 0.12.11 → 0.12.12。
- 排查提示:左栏底部版本号 = `core/__init__.py __version__`,用户报"缩放完全没动静"且本地 8765 无服务 → 多半是**远端实例未 pull/重启**,版本号对不上即旧代码。
### 2026-06-15 / 文件预览缩放改显式 px:修 CSS zoom 放不大
- 接上一条:CSS `zoom` 对带 `max-width/height:100%` 的 flex item 不生效 —— zoom 放大后被百分比 max 约束重新夹回,视觉无变化(用户实测"还是不能放大")。
- 改法(`web/static/js/preview.js` `_applyZoom`):以 scale=1 的贴合显示尺寸(`clientWidth/Height`)为基准缓存到 `z.baseW/baseH`,缩放时 `max-width/height:none` + 显式 `width/height = base × scale` px;复位时清空还原 CSS 自适应。显式 px 真正撑大布局,body 才出滚动条。bump 0.12.10 → 0.12.11。
### 2026-06-15 / 文件预览:修滚动穿透 + 图片 Ctrl+滚轮缩放
- 现象:web 端文件预览弹框内滚滚轮,事件冒泡到背景把对话列表也滚了(scroll chaining);且图片预览无缩放手段。
- 修法(纯前端,`web/static/js/preview.js` + `web/static/dev.html`):
- **滚动不穿透**:主/小预览 `.body``overscroll-behavior: contain`,再挂一次性非 passive `wheel` 监听 ── 容器不可滚(如图片正好铺满)或已到顶/底时 `preventDefault()` 断掉冒泡。
- **图片缩放**:仅图片(文本/md/docx/pdf 各有原生流/阅读器)。Ctrl+滚轮按 ×1.1 步进缩放(夹 0.18×),用 **CSS `zoom`** 而非 transform(zoom 改布局盒尺寸,放大后 body 才出滚动条能看溢出);右下角浮 `xx%` 比例徽标(挂 `.card` 上,滚动不跟走,1s 后淡出);双击复位 100%。`.body.center` 改 `safe center` 防 flex 居中把溢出顶/左裁掉够不到。
- wheel 监听只在 init 挂一次到复用的 body 元素,缩放目标走 `_zoomState` WeakMap,避免每次预览重复 addEventListener 泄漏。
- bump 0.12.9 → 0.12.10。
### 2026-06-15 / sandbox 装 emoji 字体:修 mermaid 图满图豆腐块
- 现象:模型生成的 mermaid 架构图里几乎每个节点标签前缀的 emoji 图标(🌐🔥🛡 等)全渲染成空心方框 □。根因不在 mermaid 语法 / 布局 ── `deploy/sandbox/Dockerfile` 只装了 `fonts-noto-cjk` + `fonts-wqy-microhei`(中文不豆腐),**缺 emoji 字体**,chromium 渲染时找不到 emoji glyph 就用 tofu 占位。
- 修法:Dockerfile 字体安装行加 `fonts-noto-color-emoji`(+~10MB),与 CJK / WQY 同 `fc-cache -f` 刷索引。chromium 支持 COLR/CBDT 彩色 emoji,fontconfig fallback 即正常出图标。纯增量容器改动,不碰对外契约。**需重建 sandbox 镜像 + 重启 per-user 容器生效**。bump 0.12.8 → 0.12.9。
### 2026-06-15 / 左栏任务筛选区默认折叠
- 接 2026-06-13「筛选区可折叠」一条:把默认态从展开改为**折叠**(进页面只见「筛选 ▸」一行,点开才展开)。偏好仍持久化 —— 用户显式展开过(`zcbot.task-filters-collapsed` 存 `"0"`)才默认展开,否则一律折叠。改动:`web/static/js/chat.js`(默认判定 `!== "0"`,onclick 改存 `"1"/"0"`)、`web/static/js/state.js` 注释。bump 0.12.7 → 0.12.8。
### 2026-06-15 / system prompt 按 backend 注入「运行环境」段:纠正平台误报 + 写明禁外网
- 接上两条(--shm-size + mmdc wrapper 修执行层)。再查发现**引导层的根问题在 system prompt**:`general_v1.md` 的「平台」段写死 "Windows + cmd.exe",但线上是 **docker = Ubuntu 容器 + bash** ── 模型被误导在 Linux 里打 cmd 构文(`where mmdc 2>nul`),且没引导"渲图走本地",模型以为 mermaid.ink 等在线服务能用、反复去试(其实**境外被墙**,容器有外网但渲图不该依赖出站)白烧 token。
- 修法(引导层,环境事实归 system 而非 skill):
- `general_v1.md`:删写死的 Windows 平台段,改为中立一句"平台以系统消息「运行环境」段为准"。
- `agent_builder.py`:`_build_system_prompt` 按 backend 注入环境段 ── **docker** = `_CONTAINER_ENV_BLOCK`(Linux/Ubuntu·bash·**渲图走本地 mmdc 别调境外在线服务**·mmdc/chromium/中文字体已装·`mmdc -i x -o y` 直接渲图·/tmp 可写);**host** = `_HOST_ENV_BLOCK`(一行 Windows/cmd 提示,免 general_v1 指向落空)。
- 撤回上一条加到 imagegen skill 的渲图引导(环境事实收归 system,不重复)。
- 原则沉淀:**全局不变的环境事实(在哪/能否联网/装了啥)→ system(高杠杆,一句省一类试错);具体可选方法/流程 → skill**。这是"换"不是"加" ── 删掉的是每轮都发且 docker 下错误的 Windows 段,token 量级相当、信息变对。改动文件:`prompts/system/general_v1.md`、`core/agent_builder.py`、`skills/imagegen/SKILL.md`、`RUN.md`。bump 0.12.6 → 0.12.7。
### 2026-06-14 / mmdc wrapper:容器内裸调 mmdc 自动带 puppeteer config,渲图开箱即用
- 接上条 `--shm-size` 修复。`--shm-size` 只填了"模型自己摸对 config 后那一下能成";模型**初始裸调 `mmdc`** 仍因 chromium 缺 `--no-sandbox`(容器 `--cap-drop=ALL`)直接跪,然后反复试 `mermaid.ink` 等在线服务 ── 但那是**境外、被墙/不稳**(容器虽有外网,渲图也不该依赖出站),实测又一条对话这么烧掉上百 k token。
- 修法(执行层 + 引导层,均不破坏对外契约):
- **执行层 wrapper**:Dockerfile 给 `/usr/local/bin/mmdc` 套 wrapper,没显式 `-p` 时自动注入 `-p /sandbox/puppeteer-config.json`(含 `--no-sandbox --disable-setuid-sandbox --disable-dev-shm-usage`)。裸调 `mmdc -i x.md -o x.png` 一次成;`render_diagrams.py` 等走 `which mmdc` 的脚本透明受益。删掉没人读的 `MERMAID_PUPPETEER_CONFIG` env(mmdc 本就不认它,只认 `-p`)。
- **引导层**:imagegen skill「mermaid vs seedream」段加硬引导 ── 渲图直接 `mmdc -i x -o y`、⛔ 容器禁外网别试 mermaid.ink 等在线 API。
- 取舍:没开 first-class `render_mermaid` tool ── mermaid 是纯本地计算,zcbot 专用 tool 只留给带 key/计费的能力(seedream/seedance);wrapper(执行兜底)+ skill 一句(affordance 引导)已覆盖,不扩工具面。**需 rebuild 镜像**才带 wrapper(旧容器没有)。改动文件:`deploy/sandbox/Dockerfile`、`skills/imagegen/SKILL.md`、`RUN.md`。bump 0.12.5 → 0.12.6。
### 2026-06-14 / sandbox 容器加 `--shm-size`:修 mmdc 渲 mermaid 挂超时
- 实测一个"生图测试"任务(`caoqianming@foxmail.com`)对话:模型裸调 `mmdc` 渲 mermaid,自造的 puppeteer config 漏了 `--disable-dev-shm-usage`,chromium 用 64MB 的 `/dev/shm` 起不来 → 连试 6 次全超时,烧约 120k token 才绕道 mermaid.ink 出了个 SVG。根因:`pool.py` 的 `docker run` 没传 `--shm-size`,容器 `/dev/shm` = docker 默认 64MB(镜像备的 `/sandbox/puppeteer-config.json` 虽有 `--disable-dev-shm-usage`,但模型不一定用那份;且 `mmdc` 不读 `MERMAID_PUPPETEER_CONFIG` env)。
- 修法(只做最小 infra,不动模型侧):`docker run` 加 `--shm-size`(`DEFAULT_SHM_SIZE=512m`,env `ZCBOT_SANDBOX_SHM_SIZE` / yaml `sandbox.shm_size` 可配,优先级同 memory/cpus)。从根上让任何 chromium 路径都不再挂,连模型自造的漏 flag config 也能跑。已 running 旧容器需重启 web + idle 回收后新起才带。
- 实测脚本 `deploy/sandbox/probe_mermaid.sh`(区分 chromium 缺包 vs 纯 shm 超时);诊断脚本 `scripts/diag_dump_task.py`(按 email+任务名 dump 对话)。改动文件:`core/sandbox/pool.py`、`config/agent.yaml`、`RUN.md`。bump 0.12.4 → 0.12.5。
### 2026-06-13 / 模型选择瘦身:对话模型常驻 + 生图/生视频收进 ⚙ 弹层
- `#chat-meta` 右侧原三个带标签下拉(模型/生图/生视频)占满整行。改为**高频的对话模型下拉常驻**(一眼可见当前模型、直接切),**低频的生图/生视频收进一个「⚙ 媒体」弹层**(fixed 定位逃出 pane overflow,点开才渲染 select)。meta 行从"3 下拉"降到"1 下拉 + 1 齿轮"。
- 行为不变:生图/生视频选中值仍只进 `state.imageModel/videoModel`、随下条消息 POST 的 `image_model/video_model` 发(send 逻辑读 state 不读 DOM,迁移安全);`onChangeImageModel/onChangeVideoModel` 复用。imageModels/videoModels 皆空时连 ⚙ 都不画。
- 改动文件:`dev.html`(弹层元素 + CSS)、`chat.js`(renderMediaModelTrigger / openMediaModelPop + 点外/resize/scroll 关闭)。bump 0.12.3 → 0.12.4。
### 2026-06-13 / 左栏筛选区可折叠(默认展开)
- 左栏顶部原 4 行固定头把任务列表压矮。把搜索/状态/目录/排序四个筛选控件归到两行 `.task-filter-row`,标题行加「筛选 ▾」toggle:**默认展开**,点击折叠只藏 UI(已选条件仍生效),偏好存 `localStorage`(`zcbot.task-filters-collapsed`),与 pane 折叠同套范式。折叠后左栏顶部从 4 行降到 2 行(标题 + 新建),列表可视区更高。
- 顺手把状态下拉从标题行并入筛选区(原 `width:auto` → flex),搜索框给 `flex:2` 更宽;目录/排序合一行,去掉独立"排序"文字标签改 `title` 提示。
- 改动文件:`dev.html`(markup + CSS)、`chat.js`(toggle 接线 + 复用 LS 范式)、`state.js`(新增 LS key)。bump 0.12.2 → 0.12.3。
### 2026-06-13 / 前端 UI 优化:中栏操作收菜单 + 阅读限宽 + 色彩收敛
- **中栏顶栏 5 按钮 → 「完成」+「⋯」菜单**:原导出/清空/完成/废弃/删除 平铺,与任务行的 `⋯` 浮层菜单两套范式打架,且破坏性操作(废弃/删除)平铺易误点、移动端挤。改为只留高频「完成」+ 一个 `⋯`,菜单复用 `taskMenuItems`(过滤掉 complete);单一事实源,两处共用。顺带把「清空」在菜单里按 `run_status` 也禁用(taskMeta 带该字段,修了之前菜单清空运行中会 409-after-confirm 的小坑)。
- **消息阅读限宽**:`.msg` 由 `max-width:92%` 收到 `min(92%,48rem)`(assistant ~60-80 字/行),user 气泡 `min(92%,36rem)`;宽屏长文不再满屏铺开难回扫,窄屏 92% 仍生效。
- **色彩负载收敛**:语义色由"每个操作一色"改为"颜色=后果"——正向(完成/下载)绿、破坏性(废弃橙/删除红),中性(导出/清空)不着色;移除紫色"清空"与蓝色"导出"。删掉已不存在的顶栏按钮 hover 规则(保留 file-picker 的 sp-copy/sp-move)。
- 改动文件:`dev.html`(中栏 markup + 三处 CSS)、`chat.js`(菜单接线 + renderChatMeta/deleteTask 收口)。**未动**左栏 4 行筛选头折叠(点 2,行为变化较大,留作下一步)。
- bump 0.12.1 → 0.12.2(patch:UI 重构 + 样式)。
### 2026-06-13 / 前端小修:导出按钮简写 + 任务菜单加清空 + 移动端 task 可滚 + admin 自适应
- **顶栏「导出对话记录」→「导出对话」**:与「清空对话」对齐(`dev.html` 按钮 + `chat.js` 任务菜单 export 项同步)。
- **任务菜单加「清空对话」项**:`chat.js::taskMenuItems` 新增一条,复用已有 `clearMessages`;disabled 条件 `!hasMsg` 与 export 项一致;dropdown 新增 `.dd-item.act-clear` 紫色(与顶栏清空按钮 hover 同色)。
- **修移动端 task 列表无法滚动**:手机断点把 `#pane-left` 设成 `display:block`,但 `#task-scroll``flex:1` 撑高才能滚 —— 父级非 flex 时 flex:1 失效,列表被 `overflow:hidden` 截断不能滚。改 `body.mv-left #pane-left { display:flex }`(`flex-direction:column` 由默认规则给),恢复滚动。
- **admin 移动端自适应增强**:`admin.html` 的 `@media(max-width:640px)` 补 header 紧凑化(缩 padding/字号、gen-at 时间戳截断)+ `.card-head`/`.ctrl` 允许换行(标题长 + 下拉不再撑出横向溢出)。
- bump 0.12.0 → 0.12.1(patch:bugfix + 样式)。
### 2026-06-12 / 双层记忆升级为 agent 自管(写入路径)
- **背景**:`.memory/`(core.md + extended/)存储原语已在,但纯手工维护 —— 系统不往里写,用户也不会主动整理 → 记忆形同虚设。**这轮补「写入」与「召回」两条路,不碰存储/DB,不破坏存量 `.memory/` 数据。**
- **写入 = agent 自管(选型:不引专用工具、不做后台蒸馏)**:`memory_block` 把 `.memory/` 可写绝对路径锚点 + 一段「记忆维护契约」注进 prompt,**契约+锚点常驻(即使记忆为空,解新用户冷启动不知道能记)**。agent 学到跨 task 稳定事实就用已有 `write`/`edit`/`grep` 维护,写前查重、extended 一事一文件 + frontmatter `description`。复用 fs 工具改动最小,人仍可审核手编。
- **召回升级**:extended 索引从「读首行当标题」升成**优先解析 frontmatter `description`**(召回依据更准),无 frontmatter 的存量文件退回首行标题(**公测期平滑兼容**)。
- **docker 路径转译**:发现旧 extended 索引注的是宿主绝对路径,docker 下 agent 看到的是 `/workspace/...` → 指不到。`mem_dir_display` 按 backend 给 host 绝对路径 / `/workspace/.memory`,与 working_dir 同套转译。
- 改动文件:`core/memory.py`(frontmatter 解析 + 契约 + 路径锚点)、`core/agent_builder.py`(算 `mem_dir_display` 传入)、`DESIGN.md` §3.7 同步心智+语义。单测覆盖 frontmatter 解析 / legacy 兜底 / 空记忆常驻契约 / host·docker 路径。明确不做:向量/RAG、全文搜索端点(正交,要做单开)。
- **前端只读记忆面板(GUI 当眼睛、模型当手)**:左栏「记忆」按钮(技能旁)开只读 modal 看全貌。**取舍**:查完业界(Claude 文件式给全套 view+edit;ChatGPT/Gemini 黑箱只给看/删)后定为 **GUI 只读 + "改"全走对话**(agent 自管已建好)—— "看全貌"是读不是 operation,走 LLM 又贵又只拿转述;"改"走对话 = 单一写入口 + 自然语言 + 不会写坏 frontmatter。后端只加 2 个只读端点 `GET /v1/memory`、`GET /v1/memory/extended/{filename}`(路径穿越校验收口在 `core/memory.py::read_extended_file`),**零写/删 API**。前端新增 `web/static/js/memory.js` + modal/CSS,复用 skills-modal 同构。契约里补明「用户说记住/改/忘掉是直接指令」。单测覆盖只读视图 / 单篇读 / 文件名安全 / 越界拦截。bump 0.11.1 → 0.12.0(本批含 agent 自管 + 记忆面板,同一 minor)。
### 2026-06-12 / 进入公测期:对外兼容策略
- 项目进入公测(对外真实用户在用)。`CLAUDE.md`「开发阶段心智」从"开发期可随意 break、不写兼容层"翻新为**对外契约(用户数据 / DB schema / 对外 API / CLI·env·文件布局)必须向后兼容,仅纯内部实现仍以最优为准放手重构**;拿不准 → 当对外契约处理。版本号段同步:公测保持 `0.x`,1.0 留给"对外冻结行为 / 正式 GA"。同条记忆 `feedback_dev_phase_no_compat` 一并翻新。bump 0.11.0 → 0.11.1。
### 2026-06-12(傍晚)修上下文压缩投毒 → run_python 空转报错
- **根因(DB 实测,60 个 task 命中 83 次 `[Error] bad arguments to run_python: code or script_path must be provided`)**:`core/context.py` 把旧 assistant `tool_call.arguments`(>800 字符)压成 `{"_compacted":true,"original_chars":N,"note":...}` marker 发给 LLM。模型在长 doc/ppt 任务里看到几十次"过去的 run_python 长这样",就**照葫芦画瓢把 marker 当真实参数原样吐出来** → executor 拿不到 code/script_path → 报错空转。83 次里 **61 次是模型仿写 marker**(铁证:抓到 `{"_compacted":true,"original_chars":85}`——85<800 压缩器根本不会出手且缺 `note` 字段,压缩器必带 只能是模型伪造),22 次是真· `{}`这正是代码里早已为 `task_progress` 单独豁免注释明写"会毒化模型"的同一个坑,只是 run_python 没豁免
- **修复(方案 A,把 task_progress 特例升级成通用规则)**:删掉 `_compact_assistant_tool_calls` / `_compact_tool_call_arguments`,`prepare_messages_with_stats` 不再压任何 assistant tool_call 参数(去掉 `old_tool_arg_chars` 形参与 `compacted_tool_call_arguments` 统计)。**只压 tool 结果 + skill(省 token 的大头)**,参数原样留 = 模型看到的范本永远是真实可执行调用,投毒向量连根拔。代价仅个别一次性大参数(如 12KB pptx 脚本)留在历史 1 条消息,不随轮数翻倍。
- 诊断脚本落盘可复用:`scripts/diag_run_python_empty.py`(扫最近 task 的报错形态分桶)、`scripts/diag_run_python_trace.py`(回溯每条报错配对的 assistant 参数)。
- 验证:`tests/test_context_compaction.py` 改 2 条旧"压参数"断言为"原样保留"+ 去除已删统计键;全量 120 tests OK。bump 0.10.0 → 0.10.1。
### 2026-06-12(下午)admin 后台增强:目录 + 筛选排序 + 分页 + 导出 PDF
- **目录(TOC)+ 平滑滚动**:admin.html 左侧加 sticky 目录(运行态/任务/用户与用量/按模型/各用户用量/存储),点击 `scrollIntoView` 平滑滚到对应区(`.anchor { scroll-margin-top }` 避开 sticky 顶栏);IntersectionObserver 高亮当前区;窄屏目录变顶部横向 chip 条。
- **按模型 / 各用户用量:时间筛选 + 排序**:两表从 overview bundle 拆成独立端点 `GET /v1/admin/usage/models?range=&sort=`、`GET /v1/admin/usage/users?range=&sort=&page=&page_size=`。range = all/7d/30d(`_range_cutoff`);sort = cost(按成本)/ tokens(按用量=输入+输出)。**各用户用量含零用量用户**故时间条件放 JOIN ON(非 WHERE),否则带 cutoff 会把零用量用户挤掉。前端每表一组 range/sort 下拉,改筛选即重拉(用户表回第 0 页);热力色按当前排序维度上色。
- **存储分页**:`GET /v1/admin/storage/users?page=&page_size=`(bytes desc + user_id 兜底),前端独立翻页;overview 不再含 storage/by_model(只留 runtime/tasks/users/usage 总用量+近7d趋势,固定形态供轮询)。三个独立表各自 fetch、自管 range/sort/page,overview tick 顺手刷新但不丢状态。
- **导出 PDF(客户端打印)**:顶栏「导出 PDF」→ 现取 overview + models(all/cost)+ users(all/cost top10)+ storage(top10)+ /healthz 版本,填充隐藏的 `#print-report``window.print()`;`@media print` 只显报告、`@page` 边距、表格描边版式。**零依赖**(不引 jsPDF / 不走服务端 soffice)、中文走浏览器字体、版式完全可控;**列表只取前 10**(符合需求)。报告版式:抬头(标题/生成时间/版本)→ 运行态 → 任务 → 用户 → 用量总览 → 近7天 → 按模型 Top10 → 各用户用量 Top10 → 存储 Top10。
- 验证:TestClient 跑通 models(range all=6/7d=4/30d=6、sort cost/tokens)、users(range+sort+分页)、storage(分页 42 行);overview 已不含 by_model/storage;admin.js `node --check` 通过。bump 0.10.1 → 0.11.0。
### 2026-06-12(上午)
- **admin 管理后台(角色鉴权 + 独立监控页,可扩展为管理动作总入口)**:此前只有共享口令 `ZCBOT_ADMIN_TOKEN`(仅用于发用户),无"管理员角色"概念,运维指标只打 stdout(`[stats]`)无界面。本次落地按角色的 admin 区:① **schema**:`users` 加 `role` 列(`user`/`admin`,`server_default='user'`,migration 0009 只加列不动现有数据);② **鉴权**:`make_require_admin(cfg)` 先验 JWT(同 `require_user`)再查 `users.role=='admin'`,否则 403——**role 走 DB 查不进 JWT**,改完下次请求即时生效、老 token 不重签;③ **端点**:`web/admin.py` 的 `register_admin_routes``GET /v1/admin/overview`(整组 `Depends(require_admin)`),一次返回 runtime(active_runs/max_workers/sse_subs/rss_peak,读 app.state,与 `_stats_logger` 同源)/ tasks(按 status+run_status 计数)/ users(总数+近7d活跃)/ usage(全局总用量+近7d按天+按模型)/ storage(各用户 bytes/file_count+配额)五段,全 GROUP BY 无 N+1;另挂 `GET /v1/admin/usage/users?page=&page_size=` 分页返**各用户 token 用量**(全表 LEFT JOIN usage_events 含零用量用户,cost desc,稳定排序兜底 user_id;cost 全 kind、token/缓存命中仅 chat,与总用量同源)——前端独立翻页、不随 overview 轮询丢页码;④ **前端**:独立单页 `web/static/admin.html`+`js/admin.js`(复用 localStorage `zcbot.token` 与 format 工具,不挂主应用模块图),纯数字卡片+表格不画图、**阈值/热力色差**(active_runs 逼近 max_workers 变橙/红、磁盘按配额占比变色、cost 列相对热力底色)、**响应式**(窄屏竖排)、默 10s 轮询(切后台暂停);401/403 给明确提示+回控制台链接;⑤ **入口**:`/v1/me` 返 `{user_id, role}`,dev SPA `enterApp` 拉一次,admin 才显顶栏"管理"链接(`/static/admin.html`);⑥ **建用户带 role**:`POST /v1/auth/admin/create_user` + 登录页弹框加角色下拉,`main.py user add --role` / 新增 `main.py user role --email X --role admin` 改角色。**命名取舍**:先按 inspect/dashboard 摇摆,最终定 **admin**——这页会长出建用户/改角色/配置(磁盘配额等)管理动作,admin 既盖"看"又盖"管"、且与 `require_admin`/`role='admin'`/`/v1/auth/admin/*` 一脉相承;监控总览只是其第一个 tab,后续在 `web/admin.py` 续挂 `/v1/admin/users`、`/v1/admin/config`。已用 TestClient 验:admin→200、非 admin→403、无 token→401;五段聚合对真实数据跑通。
### 2026-06-11
- **版本号机制(单一事实源 + 前端展示)**:此前只有 `web/app.py` 写死 `version="0.8"`(仅进 OpenAPI 文档,前端拿不到)。改为 `core/__init__.py``__version__`(当前 `0.8.0`)作唯一来源 → FastAPI `version`、`/healthz` 返回 `{"status":"ok","version":..}`、前端左栏底部展示全引它,**改版本只动这一行**。前端 `main.js` boot 时无条件 fetch `/healthz`(auth 豁免,embed/未登录都拿得到)填进 `#app-version`,**钉在右侧文件面板底部存储条(`.storage-foot`)最左、带细分隔线、垂直居中**(纯展示不可点;随存储条一起显隐)。**不放顶栏**:embed 模式桌面端整层 header 被 CSS 隐藏,顶栏点不到;**也不放左栏**:左栏底部留给后续按钮。CLAUDE.md「文档维护」段已加规矩:每次 commit/push bump `__version__`(patch=修复/重构/调参/skill、minor=成批新功能/对外行为变化、major=1.0 发版)。
- **并发/线程池轻量监控 + 接管默认 executor(§8.4 落地第 1 步)**:已上生产后线程池排队此前无观测手段。lifespan 显式建 `ThreadPoolExecutor`(尺寸复刻 Python 默认 `min(32, cpu+4)`,env `ZCBOT_RUN_MAX_WORKERS` 可调大)+ `set_default_executor` 接管——run 走 `asyncio.to_thread` 即用它,这样既能读 `max_workers` 判断排队、也成了日后调并发的旋钮(**行为不变**,只从匿名默认池换成显式同尺寸池;run 与 disk scan/pptx/reaper 仍共享此池,同原默认)。加 `_stats_logger` 后台 task 每 60s 采样:`active_runs`(=`len(inflight)`,含排队中)逼近 `max_workers` 即排队、新 run 的 SSE 会卡着不吐 token;**刷新峰值**时打 `[stats] new peak active_runs=N max_workers=M`(≥max_workers 带 `[WARN 已在排队]`),**有负载**时打 `[stats] active_runs=.. max_workers=.. sse_subs=.. rss_peak=..MB`,**空闲静默不刷屏**。RSS 用 stdlib `resource`(Unix 峰值/high-water;Windows dev 降级跳过),零新依赖;新 `broker.total_subscribers()` 给全局 SSE 订阅数。查看:`journalctl -u zcbot | grep '\[stats\]'`。**不做监控界面**(运维健康是少数标量、日志够诊断;业务分析数据已落 DB 走 SQL)——界面阶梯见 DESIGN §8.4。
- **dev SPA「技能」查看 modal(左侧 rail 底部入口)**:因 `.skills` 在文件面板隐藏,加左侧 rail 底部「我的资源」分组(`#rail-resources`,留位给后续「记忆」)+「技能」按钮 → 弹 modal 分「平台 skill / 我的 skill」两组列表,点任一项展开**完整 SKILL.md**(`GET /v1/skills/{name}` + 现有 markdown 渲染),「我的」每项带删除(二次确认 → `DELETE /v1/skills/{name}`,只删 user 源 + 防穿越);覆盖标 `已覆盖平台同名`,`load_errors` 提示未加载的。创建/改/fork 仍走对话。新 `web/static/js/skills.js`(零构建 ES module,main.js import + Esc 栈接入);`/v1/skills` 已带 source/overrides/load_errors。**纯查看 + 删除,不在 UI 做创建/编辑**(编辑天然对话式)。
- **用户私有 skill(每用户 `.skills/`,可从零写或 fork 内置再改)**:`SkillRegistry` 从单目录改**多来源**(`SkillSource` 列表:内置 `ROOT/skills` + 用户 `user_root/.skills`),后扫同名覆盖先扫 → **user wins**;覆盖关系记进 `user_overrides`,discovery 显式标 `[你的·已覆盖内置]`(不静默)。`Skill` 加 `source` 字段;`from_dir` 区分"无 SKILL.md(静默跳过)"与"有但格式错(抛 `SkillLoadError`)",`_scan` 捕获用户来源的错收进 `load_errors`、注入 system prompt 提示用户修(一个坏 skill 不再崩整次扫描)。容器路径改写从 LoadSkillTool 下沉到 registry(`container_dir` 按 `source``/sandbox/skills``/workspace/.skills`),LoadSkillTool 去掉 `container_skills_dir` 参数。**关键判断**:写 skill 用 host-side typed tool(`save_skill`/`fork_skill`,`tools/skill_authoring.py`)而非 fs/shell —— 因 fs 的 base_dir 锚 cwd(host)/ 容器 wd(docker),都够不到 `user_root/.skills`,跨 backend 不可靠;host-side 工具知道 user_root 一个落点两模式通吃(与 seedream/DocumentDownload 一致范式)。`save_skill` 写时校验 frontmatter(名合法 / YAML 合法 / 有 description / name 一致),`fork_skill` copytree 整目录(带脚本)+ 自动把 frontmatter name 对齐新名(否则 fork ppt 仍叫 ppt 会反覆盖内置)。`.skills` 是 dotfile(文件面板隐藏,与 `.memory` 一致;`validate_task_name` 已禁 `.` 起头 working_dir,天然不撞)。`/v1/skills` 带上用户 skill + `source`/`overrides_builtin`/`load_errors`。新增 `skill-creator` 引导 skill。+`test_user_skills.py`(20 例)+ 改写 `test_load_skill.py`。性能:多扫一目录,没 `.skills` 的用户一次 `exists()` 跳过;有 skill 仅每 run +1-3ms,不在热路径。
### 2026-06-10
- **system prompt 精简(瘦身 ~40 行 + 媒体段按需注入)**:`general_v1.md` + `_build_system_prompt` 去冗余:① 「宪法」文件命名约定从 ~25 行压到 ~6 行(只留格式定义 + 注入值 + 一行 current/重定调,操作细节本就由 proposal/ppt skill 各自讲,引用仍成立);② run_python「先 write script 再 script_path」指引去重(原模板 + agent_builder 两处 → 合并进模板 1 处,顺带把 `scripts/` 子目录约定收进去);③ 媒体工具段(seedream/seedance 红线)从常驻模板抽成 `_MEDIA_TOOLS_BLOCK`,仅 `ArkConfig.load() is not None`(有 ARK_API_KEY)时由 agent_builder 追加——无 key 用户不再背 7 行永远报错工具的说明,且 ark_cfg 提前 load 一次复用给下方 tool 注册;④ 「路径 echo 全形式」段 8 行压到 4 行。通用任务每轮 system prompt 净瘦 ~40-50 行,领域 task 加载 skill 后信息不丢。`test_system_prompt_paths` 仍过。
@ -129,7 +725,7 @@
- **imagegen skill 加 ⛔ 调 tool 前必须贴 prompt + BLOCKING 等确认**:把模型脑内装配摊到对话层让用户最后过一眼防白烧 ¥0.22;诊断五维→六维加比例/尺寸。
- **新增 imagegen skill**:单文件五步法(诊断模糊度→给推断+待确认→拍板→装配 prompt→调 seedream);mermaid vs seedream 选型三段式。
- **登录页加「管理员添加用户」入口 + 删 chat meta 条/tok 显示**:`create_user`(CLI/web 共用)+ `POST /v1/auth/admin/create_user` 校验 `ZCBOT_ADMIN_TOKEN`。否决 User 表加 is_admin 列。
- **新增 documents skill(内部材料学科知识库 document_search API)**:四函数 Bearer 认证,search 返整篇 Markdown,反模式约束只 print 前 300 字防爆上下文;库=7 材料学科英文论文 21W+ 文件 + 跨语言语义检索;与 research(OpenAlex)互补。
- **新增 documents skill(内部材料学科知识库 document_search API)**:四函数 Bearer 认证,search 返整篇 Markdown,反模式约束只 print 前 300 字防爆上下文;库=7 材料学科英文论文 100W+ 文件 + 跨语言语义检索;与 research(OpenAlex)互补。
- **dev SPA SSE 客户端重连**:`fetchSse` 拆 consume + 重连壳(1/2/4s 退避 ×3);后端 `stream_events` 入口检 run_status 非 running 立即吐 done 关流。
- **research skill fetch_pdf 改走静态直链 + list 端点加直链 + pg_trgm GIN 索引**:绕开 paper_pdf_view 路径 bug;`?search` 30s→几十 ms;SKILL 加「XML 优先 PDF」。
- **顶栏 token 累计修(`sync_task_tokens` 改走 messages SUM)**:切 streaming 后内存计数器永不更新,改现算 + backfill。
@ -234,20 +830,20 @@ core/paths.py 50 ← task_dir db form 归一
core/probe.py 243
core/session.py 153 ← ORM
core/task.py 82 ← PG-backed TaskState
core/skills.py 81
core/skills.py 180 ← 多来源 registry(SkillSource)+ source 标记 + 覆盖感知(user wins)+ load_errors + container_dir
core/memory.py 81 ← per-user `.memory/` dotfile
core/export_docx.py 383
core/storage/{__init__,engine,models,usage,utils}.py ← 4 表(0004-0007 演进);record_chat/image_usage
core/ark_client.py 105 ← 火山方舟 HTTP 客户端
core/agent_builder.py 325 ← 装配 lib(有 ARK_API_KEY 才挂 SeedreamTool)
core/agent_builder.py 340 ← 装配 lib(有 ARK_API_KEY 才挂 SeedreamTool);build_skill_registry 装两来源
core/executor.py / sandbox/{network,pool}.py / executor_docker.py ← Executor ABC + Docker per-user 容器池
tools/{base,fs,shell,run_python,skill_tool,seedream,seedance,web_search,web_fetch,documents,materials_project}.py
tools/{base,fs,shell,run_python,skill_tool,skill_authoring,seedream,seedance,web_search,web_fetch,documents,materials_project}.py ← skill_authoring=save_skill/fork_skill(host-side 写 user .skills)
main.py ~210 ← 入口:web / db / probe / user / sandbox check
db/migrations/versions/ 0001-0008
web/app.py ~1320 ← /v1 JSON API + user_id 隔离 + run lock + cancel + files + pptx 预览
web/app.py ~1360 ← /v1 JSON API + user_id 隔离 + run lock + cancel + files + pptx 预览 + skills(列表/正文/删)
web/auth.py ~190 ← 邮箱密码 + platform_key → JWT
web/broker.py / sinks.py / pptx_render.py
web/static/dev.html + js/*.js ← dev SPA 拆 14 个零构建 ES module(main.js 75 行入口)
web/static/dev.html + js/*.js ← dev SPA 拆 15 个零构建 ES module(main.js 入口;skills.js=技能查看 modal)
web/static/vendor/ ~1 MB ← jszip / docx-preview / xlsx
─────────────────────────────────
Python 合计 ~3400 行(+ dev SPA + vendor 1MB);加 skills 脚本 + 配置,总仓库约 3800 行

97
RUN.md
View File

@ -2,7 +2,7 @@
> 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`
最后更新:2026-06-03(默认镜像源改清华 pip+apt / 腾讯 npm —— 腾讯 PyPI 给过损坏 litellm wheel,npmmirror 访问不稳;workspace 落数据盘改 bind mount,撤 ZCBOT_WORKSPACE_DIR env)
最后更新:2026-06-12(admin 角色 + /static/admin.html 管理后台:user role CLI / 建用户带 --role / 顶栏"管理"入口)
---
@ -14,8 +14,11 @@
DEEPSEEK_API_KEY=sk-...
# 用 GLM 的话再加一条;国际站 z.ai 用 ZAI_API_KEY,国内站 bigmodel.cn 用 ZHIPUAI_API_KEY(对应 config/models/glm.yaml 的 api_key_env 字段)
ZHIPUAI_API_KEY=...
# 豆包(火山方舟)图像/视频生成:可选。设了同时挂 seedream tool(0.22 元/张)与 seedance tool
# (Seedance 2.0 Fast,文生视频,480p 4s ¥1.86 ~ 720p 15s ¥12+,异步等 30-90s);未设两个 tool 都不出现
# 豆包(火山方舟)统一 key,三处共用:可选。
# 1) 文本/Agent 模型 config/models/doubao.yaml(Seed 2.1 turbo/pro、自进化 evolving)—— 走 Ark OpenAI 兼容端点
# 2) 图像生成 seedream tool(0.22 元/张)
# 3) 视频生成 seedance tool(Seedance 2.0 Fast,文生视频,480p 4s ¥1.86 ~ 720p 15s ¥12+,异步等 30-90s)
# 未设:豆包文本模型选不了,seedream/seedance 两个 tool 都不出现
ARK_API_KEY=...
# documents skill(内部知识库 document_search API):可选。设了后注册
# document_list_kb / document_search / document_download 三个 host-side tool;
@ -40,12 +43,49 @@
# ZCBOT_JWT_TTL_SECONDS=604800
# 可选:设了之后登录页右下角"+ 管理员添加用户"入口才工作(未设 → 接口返 503)
# ZCBOT_ADMIN_TOKEN=<≥32 字符随机串,管理员发用户共享口令>
# 可选:web run 线程池大小(asyncio.to_thread 默认池),默 min(32, cpu+4)。每个活跃
# 对话整个 run 期占 1 线程,active_runs 逼近此值即排队(看 journalctl 的 [stats] 行);
# 并发不够再调大(先确认是真并发高、而非单条 run 慢)。
# ZCBOT_RUN_MAX_WORKERS=16
# 定时任务发邮件(send_email tool + 定时任务 notify 兜底投递,DESIGN §8.5):可选。
# 三者齐了才挂 send_email tool(没配的部署 agent 看不到这个工具);密钥只留宿主、不进 sandbox。
# SMTP_HOST=smtp.qq.com
# SMTP_PORT=465 # 默 465;465→SSL,其余→STARTTLS(可用 SMTP_TLS=ssl|starttls|none 覆盖)
# SMTP_USER=you@qq.com
# SMTP_PASSWORD=<授权码/应用专用密码,非登录密码>
# SMTP_FROM=you@qq.com # 可选,默认取 SMTP_USER
# SMTP_FROM_NAME=总院科研辅助智能体 # 可选,发件人显示名,默"总院科研辅助智能体"(不暴露内部代号)
# 定时任务守护循环(DESIGN §8.5,随 web 进程起,plain-asyncio 仿 _disk_scanner):
# ZCBOT_DISABLE_SCHEDULER=1 # 可选,整体关掉调度(对照 Claude Code CLAUDE_CODE_DISABLE_CRON)
# ZCBOT_SCHEDULER_TICK_SECONDS=10 # 可选,扫描间隔,默 10s(只决定最坏延迟≤1tick,不影响会否漏)
# ZCBOT_SCHEDULER_CONCURRENCY=4 # 可选,并发跑的定时 run 上限,默 4
# 微信接入(ClawBot 个人微信,DESIGN §8.7):可选。开关在才挂 wechat_push tool + 起入站长轮询。
# ZCBOT_WECHAT_BOT_ENABLED=1 # 渠道总开关;开启后 lifespan 起入站管理器,用户可扫码绑定
# ZCBOT_WECHAT_SECRET_KEY=<随机串> # 凭据(bot_token/context_token)列加密密钥;缺则退明文标记(公测兜底)
# ZCBOT_WECHAT_BASE_URL=... # 可选,覆盖 iLink base(默 https://ilinkai.weixin.qq.com)
# 企业微信(渠道 B,出站推送 + 入站对话,§8.7):三件套齐才挂推送。无条件主动推,补 ClawBot 24h 窗口短板。
# WECOM_CORPID=ww... # 企业 ID(管理员:我的企业→企业信息)
# WECOM_AGENTID=1000002 # 自建应用 AgentId
# WECOM_SECRET=... # 自建应用 Secret
# ZCBOT_PUBLIC_BASE_URL=https://zcbot.example.com # 可选,OAuth 回调主机(须在应用「企业微信授权登录」可信域名内;缺则取请求 base)
# 入站对话(可选,要公网 HTTPS):企微后台「应用→接收消息→设置 API 接收」填回调 URL + 下面两项,
# 用户即可在企业微信里直接和 zcbot 对话(回调 URL = <公网 base>/v1/wecom/callback)。
# WECOM_CALLBACK_TOKEN=... # 接收消息 Token(企微后台生成)
# WECOM_CALLBACK_AESKEY=... # EncodingAESKey(43 字符,企微后台生成)
```
> litellm 在 import 时副作用加载 .env;入口走 `main.py`,`.env` 自动生效。直跑 `python -c "from core.storage import ..."` 不经 litellm 链路时记得自己 `import litellm` 触发,或手动 `export ZCBOT_DB_URL=...`
- **依赖**:`pip install -r requirements.txt`(已在 `.venv` 里;含 `bcrypt`)。
- **依赖**:`pip install -r requirements.txt`(已在 `.venv` 里;含 `bcrypt`、`segno`、`cryptography`)。
- **微信接入(ClawBot,§8.7)**:① `main.py db upgrade head` 带上 migration `0012`;② `.env``ZCBOT_WECHAT_BOT_ENABLED=1` + `ZCBOT_WECHAT_SECRET_KEY=<串>`;③ 用户登录后点**左栏 rail「微信」按钮**(`/static/wechat_bind.html` 仍保留作独立/嵌入入口)扫码绑定(需个人微信 8.0.70+ 且灰度到 ClawBot 插件)。绑定后在微信「微信 ClawBot」对话即走 zcbot;**主动推送需用户近 24h 在微信开口过一次**(冷启动/超期推不出,退邮件兜底)。
- **企业微信(渠道 B,纯推送,§8.7)**:① 管理员建自建应用 → 填 `WECOM_CORPID/AGENTID/SECRET`(+ 可见范围含目标用户);② `main.py db upgrade head`。**绑定两条路,任选**:
- **手填 userid(无域名时,最省)**:rail「微信」modal 企业微信段填成员 userid(管理后台→通讯录→点成员→「账号」)→ 保存。**推送是出站调用,不需要域名/HTTPS**,这条最省事。
- **扫码授权登录(要 HTTPS 域名)**:管理员在应用→**「企业微信授权登录」**里把 zcbot 域名配进可信域名(注意不是「网页授权可信域名」,是另一项)+ 设 `ZCBOT_PUBLIC_BASE_URL`;用户点「扫码绑定」→ 桌面浏览器出二维码 → 企业微信 App 扫码确认。回调 `/v1/wecom/oauth/callback` 公开(身份从 HMAC state 验)。链接走 `login.work.weixin.qq.com/wwlogin/sso/login`(不是网页授权 `oauth2/authorize`,后者只能在企微客户端内打开 → 桌面浏览器会报「请在企业微信客户端打开链接」)。
- 绑定后简报/结果**无条件主动推**(不挑活跃度、无 24h 窗口),适合必达。
- **入站对话(可选,要公网 HTTPS)**:企微后台「应用 → 接收消息 → 设置 API 接收」填回调 URL `<公网 base>/v1/wecom/callback` + 自动生成的 Token / EncodingAESKey → 写进 env `WECOM_CALLBACK_TOKEN` / `WECOM_CALLBACK_AESKEY` → 保存时企微 GET 验 URL(`/v1/wecom/callback` GET 自动回 echostr)。配好后用户在企业微信里直接给应用发消息即走 zcbot 对话(与个人微信各一张会话上下文)。agent 跑完走 message/send 主动推回(非被动同步,故无 5s 限制)。**支持文本 + 图片 + 文件**(图片/文件走 media/get 下载,落盘进会话目录 inbound/);语音/视频/位置等暂不处理;未绑定/空消息静默。
- **channel 长会话上下文(微信/企业微信通用,0019)**:常驻会话不再无限膨胀。① **自动分段**——入站时距上次消息超过 `config.json``channel.session_gap_hours`(默 **6** 小时,设 `<=0` 关闭)→ 软重置:只把「最后一条 user 消息起」喂模型(保留上一轮做续聊锚点),之前的历史仍全留 DB,网页端照旧翻完整记录;② **手动新话题**——用户在微信/企业微信里直接发「新话题 / 新会话 / `/new` / 清空上下文」→ 硬重置,彻底从零(回执提示已归档)。两者都**不删任何消息**,只移动「喂给模型的窗口起点」`tasks.context_base_idx`。网页端「清空对话」(`POST /v1/tasks/{id}/clear`)仍整清并把 base 归 0。需 `main.py db upgrade head` 带上 `0019`
- **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose / 远端 dev / 生产任选;未设置时启动清晰报错,不引导 docker(§7.4)。
- **Auth env**:`PLATFORM_KEY` + `JWT_SECRET` 任一缺失 web 启动 fail-fast。生成随机串:`python -c "import secrets; print(secrets.token_urlsafe(48))"`。
- **用户管理**(`users.email/password_hash`,0005 UNIQUE(email)):dev SPA 登录后端。发用户两条路径任选:CLI `main.py user add`(下方),或在登录页右下角"+ 管理员添加用户"链接(需先设 `ZCBOT_ADMIN_TOKEN` env,弹窗输入 email/密码/管理员口令)。撤用户 `DELETE FROM users WHERE email=...`(先 DELETE 该 user 的 tasks)。**用户自助改密**:登录后顶栏「改密码」按钮(走 `POST /v1/auth/change_password`,需知道旧密码);改邮箱 / 用户忘了旧密码无法自助 → 手动 SQL(见故障兜底)。
- **用户管理**(`users.email/password_hash/role`,0005 UNIQUE(email)、0009 role):dev SPA 登录后端。发用户两条路径任选:CLI `main.py user add`(下方),或在登录页右下角"+ 管理员添加用户"链接(需先设 `ZCBOT_ADMIN_TOKEN` env,弹窗输入 email/密码/管理员口令/角色)。撤用户 `DELETE FROM users WHERE email=...`(先 DELETE 该 user 的 tasks)。**用户自助改密**:登录后顶栏「改密码」按钮(走 `POST /v1/auth/change_password`,需知道旧密码);改邮箱 / 用户忘了旧密码无法自助 → 手动 SQL(见故障兜底)。
- **角色与管理后台**(`users.role` ∈ `user`/`admin`):admin 才显顶栏"管理"入口 → `/static/admin.html`(非 admin 403)。页面:左侧目录(点击滚到对应区)+ 运行态/任务/用户用量/按模型/各用户用量/存储;「按模型」「各用户用量」支持时间筛选(全部/近7天/近30天)+ 排序(按成本/按用量),「各用户用量」「存储」分页;顶栏「导出 PDF」走浏览器打印(在打印对话框选"另存为 PDF",列表取前 10)。提管理员 `main.py user role --email X --role admin`(改完即时生效,role 走 DB 查不进 JWT)。`ZCBOT_ADMIN_TOKEN` 是另一回事(发用户共享口令),与 role 互不相干。
---
@ -60,7 +100,7 @@ python -m venv .venv
# 3) DB schema 上车
.venv/Scripts/python.exe main.py db upgrade head
.venv/Scripts/python.exe main.py db current # 应输出 0007 (head)
.venv/Scripts/python.exe main.py db current # 应输出 0010 (head)
```
---
@ -95,14 +135,20 @@ python -m venv .venv
# 发用户(两条路径,任选其一)
# a) CLI:
.venv/Scripts/python.exe main.py user add --email alice@example.com --password "atLeast6"
# → [ok] user added email=alice@example.com user_id=<uuid>
# → [ok] user added email=alice@example.com role=user user_id=<uuid>
# b) 登录页右下角"+ 管理员添加用户":需先在 .env 里设 `ZCBOT_ADMIN_TOKEN`,
# 弹窗输入 email/密码/管理员口令,POST /v1/auth/admin/create_user 落库。
# 弹窗输入 email/密码/管理员口令/角色,POST /v1/auth/admin/create_user 落库。
# 没设 env → 接口直接返 503,UI 入口会报"admin create_user disabled"。
# 可选:把已有 user_id(platform_key 入口创的)接到邮箱密码路径
.venv/Scripts/python.exe main.py user add --email bob@x.com --password "s3cret" --user-id <UUID>
# 角色:user(默认)/ admin。admin 才能开顶栏"管理"入口 → /static/admin.html 管理后台
# (监控总览:运行态/用量/任务/用户/存储)。建用户时带 --role,或事后改:
.venv/Scripts/python.exe main.py user add --email ops@x.com --password "s3cret" --role admin
.venv/Scripts/python.exe main.py user role --email alice@example.com --role admin
# → [ok] role set email=alice@example.com role=admin user_id=<uuid>
# 撤用户:先清 tasks(messages CASCADE)再 DELETE user
# psql> DELETE FROM tasks WHERE user_id=(SELECT user_id FROM users WHERE email='alice@example.com');
# psql> DELETE FROM users WHERE email='alice@example.com';
@ -138,7 +184,7 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta
| 方法 + 路径 | 用途 | Auth |
|---|---|---|
| `GET /healthz` | `{"status":"ok"}` | 豁免 |
| `GET /healthz` | `{"status":"ok","version":"<zcbot 版本>"}` | 豁免 |
| `GET /` | 302 → `/static/dev.html` dev SPA | 豁免 |
| `GET /docs` `/openapi.json` | Swagger UI / OpenAPI schema | 豁免 |
| `GET /static/*` | dev.html 等静态文件 | 豁免 |
@ -149,8 +195,12 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta
| `GET /v1/tasks?page=&page_size=&status=&skill=&working_dir=&q=&ordering=` | 列任务,默认 `-created_at`;响应 `{page, page_size, count, results}`;`ordering` DRF 风格逗号分隔 `-field` 倒序,allowlist created_at/updated_at/name/status | 必填 |
| `GET /v1/tasks/{id}` | 单 task meta + `n_messages`;跨 user → 404 | 必填 |
| `PATCH /v1/tasks/{id}` | `{status?,description?,name?,skill?}`;active 不让从 web 切回 | 必填 |
| `DELETE /v1/tasks/{id}` | 硬删 DB 行(messages CASCADE);若 working_dir 已无其他 task 引用且 FS 目录为空 → 顺手 rmdir 清孤儿(非空 / 外部 --working-dir 静默跳过) | 必填 |
| `DELETE /v1/tasks/{id}` | **软删**(204):置 `deleted_at`,从列表隐藏;messages/usage_events 及工作目录文件全部保留(留作语料 + 可恢复),不动任何磁盘文件;已软删幂等 204 | 必填 |
| `POST /v1/tasks/{id}/restore` | 恢复软删的 task(置 `deleted_at=NULL`),重新出现在列表;返回 task meta;未软删幂等成功;跨 user / 不存在 → 404 | 必填 |
| `GET /v1/folders` | 列当前 user 工作目录 + n_tasks + last_used | 必填 |
| `GET /v1/skills` | 列当前 user 可用 skill(内置 + 自己的);每项带 `source`(builtin/user)/`overrides_builtin`;另返 `load_errors`(用户 skill 因 frontmatter 坏未加载的) | 必填 |
| `GET /v1/skills/{name}` | 返某 skill 完整 SKILL.md 正文(前端「技能」modal 点开查看);同名按 user wins | 必填 |
| `DELETE /v1/skills/{name}` | 删当前 user 私有 skill(`.skills/<name>/` 整目录);只删 user 源,内置不可删 → 404;`.skills` 文件面板隐藏,这是 UI 上删自己 skill 的唯一入口 | 必填 |
| `GET /v1/tasks/{id}/messages` | LiteLLM payload 透传 | 必填 |
| `POST /v1/tasks/{id}/messages` | `{content, image_model?=""}` 发消息;返 `{events_url}`;**`run_status` 是 running/cancelling → 409**(单活 run;error 起新 run 时清);`image_model` 是 `config/media/doubao.yaml` image 段的 variant key(空 → 沿用 yaml 第一个),仅本 run 装配 SeedreamTool 时使用,不入 DB;UI 应 disable send 直到 SSE `done` | 必填 |
| `GET /v1/tasks/{id}/events` | SSE 流(`event: <type>` + `data: <json>`);订阅 task 当前活动 | 必填 |
@ -229,6 +279,7 @@ sudo systemctl daemon-reload
sudo systemctl enable --now zcbot
sudo systemctl status zcbot | head
sudo journalctl -u zcbot -f # 实时日志
sudo journalctl -u zcbot | grep '\[stats\]' # 并发/线程池采样:active_runs 逼近 max_workers 即排队 → 调 ZCBOT_RUN_MAX_WORKERS
sudo systemctl restart zcbot # 重启:先 drain 在跑的 run 再换新版,新发消息期间 503(客户端自动重试)
sudo systemctl stop zcbot
```
@ -274,6 +325,7 @@ sudo bash /opt/zcbot/deploy/update.sh
脚本顺序写死:`git pull --ff-only` → `pip install -r``db upgrade head`**`docker build` sandbox 镜像** → **`systemctl restart zcbot`** → `curl /healthz` 验活。要点:
- **build 必须在 restart 之前**:sandbox 容器 per-user 长驻 + 复用,`tools/` 是 build 进镜像的(非 mount)。restart 时 `shutdown_all` 清旧容器,下次 `ensure()` 才用新 `zcbot-sandbox:latest` 重建 —— 顺序反了新 tools/ 要等下次重启才生效。
- **平台渲染层 `rendering/`(2026-06-23 起)**:各 skill 出 docx/pdf 调 `python /sandbox/rendering/render.py --profile {brief,paper,proposal} --format {docx,pdf}`(不再各自带 render_docx.py)。`rendering/` 随 `pool.py` **bind-mount 进 `/sandbox/rendering`**(restart 重建容器才挂上),pdf 依赖 `markdown`(已进 requirements,镜像重建才内置)+ 镜像自带 chromium。**这次升级要整体重建镜像 + restart 一并 deploy**——旧 render_docx 路径已删,只推代码不重建会让 brief/paper/proposal/patent/standard 渲染失败。沙盒 chromium 渲 pdf 的冒烟探针:`deploy/sandbox/probe_chromium_pdf.sh`(服务器上跑,用法见脚本头)。
- **sandbox build 每次都跑没关系**:layer cache 让重活(pip ~1G / chromium / 字体 / mermaid,都在 `COPY tools/` 之上)在改代码部署时秒过;只有 `requirements.txt` 变了才整体重建(~5-10min,正好也是该重建的时候)。host backend 机器 / 临时不想动 docker:`sudo bash deploy/update.sh --skip-build`。
- **镜像源默认:pip+apt 清华、npm 腾讯**(`PIP_INDEX_URL=pypi.tuna.tsinghua.edu.cn/simple/` / `APT_MIRROR=mirrors.tuna.tsinghua.edu.cn` / `NPM_REGISTRY=mirrors.cloud.tencent.com/npm/`)。pip 选清华是因为**腾讯 PyPI 曾返回损坏的 litellm wheel**(index hash 对、文件字节不对 → pip `DO NOT MATCH THE HASHES`),且**阿里 PyPI 又一度滞后**(litellm 只到 1.82.6,卡死 `>=1.83.0`);清华境内稳 + 同步及时。npm 用腾讯是因为**清华不提供 npm registry**、npmmirror 访问不稳,腾讯 npm 历来 OK(坏 wheel 只是腾讯 PyPI 的事,npm 不受影响;备选华为 / USTC npm 源)。要命中 docker cache 就别多组源来回换(换源从 pip 层炸开全重跑)。想用官方源:`PIP_INDEX_URL= sudo -E bash deploy/update.sh`(置空即回落 Dockerfile 官方默认)。host venv 的 step 2 pip 也吃这个源(脚本显式 `--index-url`,不靠 host pip.conf)。
- **进度可见**:step 2 pip 不带 `-q`,部署时能看到装包进度;step 4 docker build 走默认 TTY 进度 UI(分层折叠刷新,直观)。
@ -351,13 +403,16 @@ sudo -u zcbot docker build \
# npm 源同款(@mermaid-js/mermaid-cli + 依赖,境内访问 registry.npmjs.org 也慢):
# --build-arg NPM_REGISTRY=https://mirrors.cloud.tencent.com/npm/ # 腾讯云
# --build-arg NPM_REGISTRY=https://registry.npmmirror.com/ # 阿里(npmmirror)
# 镜像内自带 Chromium + mermaid-cli + puppeteer-config.json,proposal/patent skill
# 的 render_diagrams.py 看到 MERMAID_PUPPETEER_CONFIG env 自动 -p 注入,
# host 上跑时该 env 没设,行为不变
# 镜像内自带 Chromium + mermaid-cli + puppeteer-config.json;mmdc 被 wrapper 包了一层
# (/usr/local/bin/mmdc → 自动注入 -p /sandbox/puppeteer-config.json,除非显式传 -p),
# 所以容器内**裸调 `mmdc -i x.md -o x.png` 就能出图**,无需 --no-sandbox / 自写配置。
# render_diagrams.py 等走 `which mmdc` 的脚本透明受益(原靠 MERMAID_PUPPETEER_CONFIG
# env,已删 ── mmdc 本就不读它,改 wrapper 兜底)。host 上跑无 wrapper,行为不变
# 3) 创建 sandbox 网络(--internal,默认无 outbound)
sudo -u zcbot docker network create --internal zcbot-sandbox-net
# 或 SandboxPool.setup_pool() 自动 ensure
# 3) 创建 sandbox 网络(bridge,dogfood 阶段保留 outbound NAT —— 让模型能 pip/curl 公网;
# iptables 仍挡内网/cloud metadata/PG。--internal 完全禁出站是外部用户开放时才改,见 §7.5 #2)
sudo -u zcbot docker network create zcbot-sandbox-net
# 或 SandboxPool.setup_pool() 自动 ensure(ensure_network 即建非 internal bridge)
```
### Sandbox 相关 env(.env 加)
@ -381,6 +436,7 @@ sudo -u zcbot docker network create --internal zcbot-sandbox-net
# ZCBOT_SANDBOX_MEMORY=2g
# ZCBOT_SANDBOX_CPUS=1.0
# ZCBOT_SANDBOX_PIDS_LIMIT=256
# ZCBOT_SANDBOX_SHM_SIZE=512m # chromium/mmdc 渲 mermaid 的 /dev/shm(docker 默 64MB 不够会挂超时)
# PG 实际 IP,逗号分隔。defense-in-depth ── 即便落内网三段(§7.5 #1),
# init.sh 再加一遍 DROP 规则。生产部署必填。
ZCBOT_PG_IPS=10.1.2.3,10.1.2.4
@ -664,7 +720,7 @@ sudo xfs_quota -x -c "limit -p bhard=10g zcbot_<user_uuid>" /opt
| Windows 控制台 emoji 崩 | Python stdout 是 GBK。用 `[OK]` / `[ng]` 等 ASCII 标签(见 memory) |
| `db upgrade``column already exists` | DB 已被改过,`db current` 确认 revision,必要时手 ALTER 或 `db downgrade base` 重来 |
| Resume 找不到 task | dev SPA 左侧 task 列表看 task_id 是否在;或 `curl /v1/tasks` 拉 |
| `--working-dir` 指定后 task 删了目录还在 | 两种情况:① 目录非空(有用户文件) — 设计如此,绝不 rmtree,手动 `rm -rf <dir>` 清;② 外部 `--working-dir`(DB 存绝对路径)— 不自动清,避免误删用户外部项目。ROOT 内 + 同 working_dir 无其他 task 引用 + FS 空 → DELETE task 时已自动 rmdir |
| task 删了文件还在 | 现在 `DELETE /v1/tasks/{id}` 是**软删**,本就不动任何磁盘文件(留作语料 + 可恢复);要清磁盘走 `POST /v1/files/delete`。彻底物理删 task(及 messages)留给将来的管理员清理工具;当前如需手动:`psql> DELETE FROM tasks WHERE task_id=...`(messages/usage_events CASCADE) |
| Sandbox 容器内 `touch /workspace/x``Permission denied` | 容器 uid 1000 与 host `zcbot` 用户 uid 不一致(bind mount 保 host owner)。`docker build --build-arg HOST_UID=$(id -u zcbot)` 重建镜像 |
| Sandbox 容器 build 完起不来,`docker logs` 显示 iptables 报错 | 缺 NET_ADMIN cap(`--cap-add=NET_ADMIN` 漏了)或 kernel 不支持(WSL2 / OpenVZ 环境不能跑)。Ubuntu 物理 / KVM 正常。验:`docker exec ... iptables -V` |
| 启动报 `ZCBOT_SANDBOX_BACKEND=docker but sandbox init failed: ...` | docker daemon 没起 / 用户不在 docker group / network create 失败。先跑 `main.py sandbox check` 看哪一项 err |
@ -678,7 +734,9 @@ sudo xfs_quota -x -c "limit -p bhard=10g zcbot_<user_uuid>" /opt
| 镜像 build npm 装 mermaid-cli 慢 / fail | npm 源境内慢。默认已用腾讯 `https://mirrors.cloud.tencent.com/npm/`(清华无 npm 源;npmmirror 访问不稳被弃)。备选:华为 `https://repo.huaweicloud.com/repository/npm/` / USTC `https://npmreg.mirrors.ustc.edu.cn/`,手动 build 加 `--build-arg NPM_REGISTRY=...` |
| 镜像 build apt 报 `OpenSSL error: ... unexpected eof while reading` | 某些 mirror HTTPS 端偶发 close_notify 缺失,OpenSSL 3 严格 fail(腾讯 / 阿里见过;清华一般不犯)。改用 http 形式:`--build-arg APT_MIRROR=http://mirrors.tuna.tsinghua.edu.cn`(apt 包 GPG 签名校验,无 HTTPS 安全收益)。Dockerfile 已配 apt retry=5 + 关 pipeline,重 build 一般直接过 |
| 容器内 shell 写工作目录报 `Permission denied`(but `sandbox check` ⑤ HOST_UID aligned ok) | DockerExecutor 写死了 `--user 1000:1000` 不会自动跟 build 的 HOST_UID 同步(改 `--user zcbot` 后已修)。仍报错检查镜像内 `docker run --rm --entrypoint id zcbot-sandbox:latest zcbot` 输出 uid 是否 = `id -u $(whoami)` |
| 模型用 run_python 跑 `render_diagrams.py``mmdc returncode=1: Failed to launch chromium` | 容器内 chromium 缺 puppeteer no-sandbox 配置。镜像已落 `/sandbox/puppeteer-config.json` + ENV `MERMAID_PUPPETEER_CONFIG`,render_diagrams.py 已读 env 自动 -p 注入;仍跪查 `docker exec ... env \| grep MERMAID` 看 env 是否在 |
| 容器内 mmdc 渲 mermaid 报 `Failed to launch chromium` / `No usable sandbox` | chromium 在 `--cap-drop=ALL` 下自家 setuid sandbox 起不来,要 `--no-sandbox`。镜像已落 `/sandbox/puppeteer-config.json` + 给 mmdc 套了 wrapper 自动 `-p` 注入 ── **裸调 `mmdc -i x -o y` 就该成**。仍跪:`docker exec ... cat /usr/local/bin/mmdc` 看 wrapper 在不在(老镜像没 rebuild 则没有);或显式 `mmdc -p /sandbox/puppeteer-config.json -i x -o y` |
| 容器内 mmdc 渲图卡到 **timeout** 而非报错 | chromium 默认用 `/dev/shm`,docker 不传 `--shm-size` 时只 64MB → 起不来一直挂。已在 `pool.py``docker run``--shm-size`(默 512m,env `ZCBOT_SANDBOX_SHM_SIZE` / yaml `sandbox.shm_size`)。**已 running 的旧容器不会自动生效**,重启 web + 等 idle 回收(或 `docker rm -f zcbot-sandbox-<uid>`)后新起的才带。实测脚本 `deploy/sandbox/probe_mermaid.sh` |
| 模型不渲本地 mmdc、反复试 `mermaid.ink` 等在线渲图服务还失败 | 容器**有外网**(bridge+NAT),但 `mermaid.ink` 等**境外服务易被墙/不稳**,渲图不该依赖出站。docker backend 的 system prompt「运行环境」段(`agent_builder.py` 注入)已写明"渲图走本地 mmdc、别调在线服务";撞到多半是 prompt 没更新 / 跑在 host backend。渲 mermaid 一律 `mmdc -i x.md -o x.png` |
| Export 报 "无可导出内容" | task 没 messages(只 system 不算);先发条消息再 export |
| `NoSubtaskError: working_dir ... 前缀嵌套` | §7.4 no-subtask:同 user 不允许 working_dir 嵌套(child / parent)。**同项目多对话**用**完全相同**的 working_dir;否则改成 sibling(平级) |
| `main.py web` 启动后 curl 连不上 | 检查 proxy(`HTTP_PROXY` / `HTTPS_PROXY`):本地服务 127.0.0.1,系统 proxy 拦截会 502。临时 `unset HTTP_PROXY HTTPS_PROXY``curl --noproxy '*'`。验通:`curl --noproxy '*' http://127.0.0.1:8765/healthz` |
@ -698,6 +756,7 @@ sudo xfs_quota -x -c "limit -p bhard=10g zcbot_<user_uuid>" /opt
| `kill -HUP <pid>``/openapi.json` 没新接口 | uvicorn **不响应 SIGHUP**(没装 handler,落 Python 默认终止;Windows 上信号本身无效)。Ubuntu 上用 `systemctl restart zcbot`,或 unit 加 `--reload` 让 uvicorn 监听文件自动重起(见"部署"段)。验证:`curl -s http://127.0.0.1:8765/openapi.json \| python3 -c 'import sys,json;print([p for p in json.load(sys.stdin)["paths"] if "auth" in p])'` |
| `systemctl restart zcbot` 要等几十秒才退 | 正常 —— 优雅 drain 在等在跑的 run 收尾(`shutdown.drain_timeout` 默 30s),没在跑 run 时秒退。journal 出现 `[shutdown] draining N in-flight run(s)` 即正常。真急(不在乎杀掉在跑 run):`systemctl kill -s KILL zcbot` |
| 部署后在跑的对话被标 `error: server restarted before run finished` | 该 run 在 drain 期内没收尾、cancel 也没在 `cancel_grace` 内退,被 SIGKILL 后下次启动 reaper 标的。多半是 run 卡在不 poll cancel 的长动作(如单次超长 docker exec)或 `TimeoutStopSec` 配得比 drain 预算还小被提前 SIGKILL。先核对 unit `TimeoutStopSec > drain_timeout + cancel_grace`;真有超长 run 把 `drain_timeout` 调大 |
| 定时任务「跑到一半没推送」/ crons 页显示「上次失败: 运行超过超时上限 Ns 未完成」 | job 跑满 `timeout_seconds` 被协作式中断(还没写完 / 没推送)。**0.32.2 起超时记 error**(此前误记 ok 看不出来),计入连续失败、到阈值自动停用。**0.32.4 起新建 job 默认超时 1800s**(此前默认 0=不限;`DEFAULT_TIMEOUT_SECONDS`),`0` 仍可显式设"不限"。处置:报告类重活(多刊检索+渲 docx)若仍不够,把该 job `timeout_seconds` 再调大或设 0;被自动停用的重新 enable。诊断单个 job 用 `scripts/diag_sched_e621.py <job_id 前缀>` |
| `POST /v1/files/rename` 返 409 `folder has active run(s)` | 顶层目录被某 running/cancelling 的 task 占用;先 cancel 等流式 done 再 rename |
| `POST /v1/files/rename` 返 409 `... 前缀嵌套` | 改名后会与其他 task 的 working_dir 形成嵌套;换不冲突的 new_name |
| `POST /v1/files/upload` 返 413 `已达磁盘配额上限` | per-user 5GB(yaml `quotas.disk_bytes_per_user`)。让用户在 dev SPA 右侧文件栏删旧产物 / 大文件,或改 yaml 升配重启 web |
@ -732,9 +791,11 @@ sudo xfs_quota -x -c "limit -p bhard=10g zcbot_<user_uuid>" /opt
- **工具**:`tools/{fs, shell, run_python, skill_tool}.py`
- **Web**:`web/{app.py, auth.py, broker.py, sinks.py}` + `web/static/dev.html`(dev SPA)+ `web/static/vendor/`(office 预览 jszip/docx-preview/xlsx)
- **配置**:`config/agent.yaml` + `config/models/*.yaml`(§3.2 Model Profile)
- **模型档位(per-account 模型访问)**:`config/agent.yaml` `model_tiers` 段定义「档位→可用模型 id 集合」;`users.plan` 存档位名,空/未知 → `default` 档,`role=admin` 全开。管理后台「各用户用量」表的「档位」下拉改 plan(`PATCH /v1/admin/users/{uid}/plan`);档位定义见 `GET /v1/admin/tiers`。改 `model_tiers` 后**重启 web** 生效;无需 migration(`plan` 列 0001 起就有)。模型 id:文本=`family.variant`,图/视频=variant key。行为:用户只看到本档模型;显式选档外模型 403;老 task 下次发消息若模型已不在档位内 → 自动落回 `deepseek_v4.flash`
- **Skill**:`skills/{coding,ppt,proposal}/SKILL.md`(渐进披露,§3.5)
- **Workspace**(per-user 子树,user_id 来自 JWT `sub`):
- `workspace/users/<user_id>/.memory/{core.md, extended/}` — 跨 task 记忆,FS 永久,dotfile 隔离
- `workspace/users/<user_id>/.skills/<name>/SKILL.md` — 用户私有 skill,dotfile 隐藏;只对该用户生效,与内置同名则覆盖内置(user wins)。由 agent 工具 `save_skill` / `fork_skill` 写(host-side,不走沙箱 fs);docker 下随 user_root bind 到 `/workspace/.skills`
- `workspace/users/<user_id>/<working_dir>/` — 工作目录,用户起名,同 working_dir 多 task 共享
---

View File

@ -1,36 +1,69 @@
# zcbot Skill 清单
服务对象:中国建筑材料科学研究总院 —— 无机非金属材料 R&D(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材)
最后更新:2026-06-08
Skill 总数:14
最后更新:2026-07-02(ppt skill 加渲图验收闭环 + 导出验收硬门 + 几何质检)
Skill 总数:17
zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` + 配套 templates / scripts / Python helper),模型在识别用户意图后挂载对应 skill,按其内置的阶段化流程产出可交付物。本文档面向**使用方 / 协作方**,按"做什么、什么时候用、什么时候别用、典型产物"组织。
> **用户私有 skill**:除内置 skill 外,每个用户可在自己私有的 `.skills/` 下创建 / 改造 skill(只对自己生效,不影响他人)。用 `skill-creator` 引导即可——从零写或 fork 某个内置 skill 再改。用户 skill 与内置**同名则覆盖内置**(列表里标 `[你的·已覆盖内置]`),改名则并存。
---
## 速览
| 分类 | Skill | 一句话 |
|---|---|---|
| 科研写作 | [paper](#paper) | 写期刊投稿论文:中文核心 / 英文 SCI(原创 / 综述 / 快报,IMRaD + 引文三角核验) |
| 科研写作 | [proposal](#proposal) | 写本子 / 申报书 / 任务书(6 类基金) |
| 科研写作 | [standard](#standard) | 起草标准:国标 / 行标 / 团标(含 T/CSTM)+ 编制说明 |
| 科研写作 | [patent](#patent) | 写发明专利技术交底书(供代理师转写) |
| 科研写作 | [review](#review) | 审稿 / 润色 / 校对(中英文,长文档分段深审) |
| 演示出图 | [ppt](#ppt) | 生成 PowerPoint 演示稿(商务红主题,大纲对齐后一脚本整建) |
| 演示出图 | [plot_pub](#plot_pub) | 出版级 matplotlib 学术图(中文 + viridis + 矢量) |
| 演示出图 | [ppt](#ppt) | 生成可编辑 PowerPoint(SVG-first:逐页手写 SVG → 转原生 DrawingML;19 种视觉风格 + 模板库) |
| 演示出图 | [plot_pub](#plot_pub) | 出版级 matplotlib 学术图(中文 + viridis + 矢量 + 投稿级复合图设计纪律) |
| 文献检索 | [research](#research) | 查 paper_server(OpenAlex 元数据 + Sci-Hub 下载) |
| 文献检索 | [documents](#documents) | 查内部 7 学科材料知识库(21W+ 论文,跨语言检索;host-side tool 持 key) |
| 文献检索 | [documents](#documents) | 查内部 7 学科材料知识库(100W+ 论文,跨语言检索;host-side tool 持 key) |
| 文献检索 | [brief](#brief) | 科研方向简报:三路检索(research + 内部库取文献 / web 取动向)→ 重要论文列表(带摘要概述)+ 内容总结,只描述不给建议 |
| 科研计算 | [pymatgen](#pymatgen) | 晶体结构 / XRD 模拟 / 相图 / Materials Project(host-side tool 持 key) |
| 科研计算 | [stats_ml](#stats_ml) | 配方-性能建模与机器学习(三库分工) |
| 内容生成 | [imagegen](#imagegen) | 豆包 Seedream 5.0 文生图(¥0.22 / 张) |
| 内容生成 | [imagegen](#imagegen) | 豆包 Seedream 5.0 文生图 + 改图 i2i(¥0.22 / 张) |
| 内容生成 | [videogen](#videogen) | 豆包 Seedance 2.0 文生视频(¥1.86 起 / 段) |
| 通用 | [analyze](#analyze) | 科学问题拆解 / 引导(模糊命题 → 子问题 + 路线图) |
| 通用 | [coding](#coding) | 修代码 / 调试 / 重构 |
| 元能力 | [skill-creator](#skill-creator) | 引导用户创建 / 改造自己的私有 skill(从零写或 fork 内置) |
---
## 科研写作
### paper
**撰写学术期刊投稿论文(中文核心 / 英文 SCI)。**
把实验数据 / 前期报告整理成可投稿的论文 .docx。流程:**先定类型与语言 → 八条对齐 spec → 文献矩阵 → 先定图表 → 逐章一段一卡 → 引文三角核验 → 验收渲染 + 投稿件**,不一口气出全文,关键章(Intro/Methods/Results/Discussion)"一段一卡"。
**覆盖类型 × 语言**(子 md 分流,一篇只挂一套):
- 类型:原创研究(`original`,IMRaD)/ 综述(`review`,主题式)/ 快报(`letter`,凝练版)
- 语言:中文核心(GB/T 7714 + 中文硬规则)/ 英文 SCI(Elsevier/IEEE 数字制 + 英文硬规则)
**何时用**:写论文、投稿稿、manuscript、写 Introduction/Methods/Results/Discussion、写综述、把实验数据整理成可投稿论文。
**何时不用**:
- ⛔ 只改 / 润色已有稿 → 走 review
- ⛔ 写本子 / 申报书 → 走 proposal;写交底书 → patent;写标准 → standard
- ⛔ 只查文献 → research / documents;只出图 → plot_pub
**核心能力**:
- 三类论文 × 中英双语的 IMRaD / 主题式骨架 + 篇幅预算(`paper_types.md`)
- **引文三角核验**(`citation_verify.md`,移植 ARS 思路、后端换成自有 documents/research 库):存在性 → 三角印证 → 支撑度(抓原文比对 ≤25 词锚点,partial 就改论断迁就证据),编造引文零容忍
- "先定图表再写正文"纪律(接 plot_pub 出 figure)+ 文献矩阵立证据底座
- 写作顺序 Methods→Results→Intro→Discussion→Abstract→Title;关键章一段一卡 + 预告下一段
- `quality_check.py`:结构 / 占位符 / 过度宣称 + **引文交叉核对**(orphan / uncited / 编号连续);docx/pdf 调平台渲染层 `rendering/render.py --profile paper`(中英字体切换 + 图题自增);`word_count.py` 按类型 × 语言核篇幅
- 终审复用 review skill 的反谄媚审稿协议;可选出 cover letter / AI 声明 / CRediT
**典型产物**:`<topic>.docx`(投稿稿)+ sections/ 分章草稿 + `lit_matrix.md`(文献矩阵)+ `CITATIONS.md`(引文核验台账)。
---
### proposal
**撰写中国科研项目申报书 / 课题任务书。**
@ -135,41 +168,31 @@ zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` +
## 演示出图
### ppt
**生成 PowerPoint 演示文稿 (.pptx)。**
**生成可编辑 PowerPoint 演示文稿 (.pptx)。SVG-first 路线。**
把材料(汇报草稿 / 项目方案 / 调研报告)变成可演示的 .pptx。流程:**先定调(8 项 + 逐页大纲)→ 一个脚本建整 deck → quality_check 验收**。方向在大纲阶段对齐,执行阶段一把出稿(不逐页来回)。视觉走**卡片式系统**(圆角卡片 + 柔和投影 + 渐变 + 从主色派生的明暗色阶),原生可编辑,告别扁平办公模板观感
把材料(汇报草稿 / 项目方案 / 调研报告)变成可演示、**可编辑**的 .pptx。流程:**素材摄取 → 八条对齐 + 逐页大纲(spec)→ [配图] → 逐页手写 SVG → SVG 质检 → 后处理 → 全量渲图验收 → 导出 PPTX**(导出边界硬门:每页都要渲图过目、标记 pass 且此后源未改动,否则拒绝产出 pptx)。核心是 AI 把每页当**矢量设计稿手写成 SVG**(设计自由度=浏览器级),再由纯 Python 转换器逐元素译成**原生 DrawingML**(形状/文本/渐变都能在 PowerPoint 里选中改)——告别 python-pptx 固定版式件的单调与 AI 味
**触发**:
- ✅ 用户明确点名 PPT / 幻灯片 / 演示文稿 / .pptx / slide / deck
- ⛔ 用户明确说"报告 / 文档 / 纪要"等纯文档产物 → 不走本 skill
- ⚠️ 用户说"汇报 / 方案 / 材料"等产物形态不明 → **先反问** PPT 还是 Word/Markdown,确认后再 load
**默认主题 —— 商务红**(硬约束):
- 主色 `#C00000` / 辅色 `#E15554` / 强调色 `#FFC107`
- ⛔ 不允许擅自换色,除非用户明确点其它配色或提供 brand guideline
**默认主题 —— 自由设计**(content-driven):按内容+受众+选定 visual_style 派生配色版式,spec 阶段给 ≥3 套候选挑;商务红/品牌色作为候选之一,用户点名或素材有 brand guideline 才锁定。
**八条对齐**(spec 阶段定稿):
| # | 项 | 默认值 |
|---|---|---|
| 1 | 画布 | 16:9 (13.33×7.5 in) |
| 2 | 页数 | 封面 + 5-8 页正文 + 尾页(Q&A) = 7-10 页 |
| 3 | 受众 | 看材料推断:领导汇报 / 同行评审 / 客户 pitch |
| 4 | 风格 | 现代简约(白底 + 细线 + 留白) |
| 5 | 配色 | 商务红 |
| 6 | 字体 | 微软雅黑 + Arial |
| 7 | 图标 | Iconify `tabler` 集(主色染色,本地缓存;概念页配图标底块) |
| 8 | 图表 / 配图 | 数据图 matplotlib / 少量数字上 KPI 卡;真实配图 opt-in 走 imagegen(每张 ¥0.22) |
**八条对齐**(spec 阶段定稿,ah):画布 / 页数 / 受众+核心信息+投递目的 / mode+visual_style / 配色 / 图标库 / 字体+字号 / 配图。确认后产出两份引擎契约:`design_spec.md`(人读叙事)+ `spec_lock.md`(机读执行锁,executor 每页重读、抗长 deck 漂移)。
**核心能力**:
- **信息设计纪律(咨询级的真功)**:论断式标题(写结论不写主题)、Takeaway 结论框、数据语境化(数字带对比基准+趋势)、page_rhythm 节奏(anchor/dense/breathing,breathing 页强制打破卡片网格)
- **组合版式件**(一函数一整块):`add_card_grid`(均衡网格)/ `add_timeline`(时间轴)/ `add_cycle`(闭环)/ `add_toc`(目录)/ `add_kpi`(数字卡带对比+升降)/ `add_takeaway` / `add_source`
- **质感工具箱**:`add_card`(圆角卡,投影克制——平铺卡默认平)/ `add_gradient_rect` / `add_icon_tile` / `add_pill` / 派生明暗色阶 + 语义色 `GOOD/BAD`
- **混合背景** `render_bg.py`:无头 Chrome 渲杂志级背景图 + 其上原生可编辑文字(封面/章节)
- **观感验收** `pptx_preview.py`:把 .pptx 渲成 PNG 肉眼验版面(quality_check 查结构,预览查好看)
- 演讲者备注 `add_notes` + 业务图标双层兜底(Iconify → 本地缓存 → unicode)
- `quality_check.py` 结构验收(越界 / 溢出 / 按列 bullet / 按色系三色制 / 重叠)+ markitdown 素材摄取
- **SVG→原生 PPTX 转换器**:逐元素译 DrawingML(圆角矩形/渐变/阴影/箭头/裁切图都映射原生),非截图嵌图,完全可编辑;默认嵌演讲者备注 + Office 兼容兜底
- **19 种视觉风格 + 5 种叙事骨架**:editorial / swiss-minimal / glassmorphism / dark-tech / data-journalism… × pyramid / narrative / instructional / showcase / briefing —— 去 AI 味的关键
- **模板库**:layouts(版式)/ decks(整套:中汽研/招商银行/重庆大学等)/ brands(品牌)/ charts(71 个图表信息图)/ icons(5 套共 1.1w+ 图标,finalize 自动内嵌)
- **逐页节奏纪律**:论断式标题、page_rhythm(anchor/dense/breathing,breathing 页禁卡片墙)、内容→版式映射、图文版式 72 式
- **SVG 质检** `svg_quality_checker.py`:禁用特性 / viewBox / spec_lock 漂移 / 配色越界 / **几何检测**(文本·图标包围盒估算,拦大字压说明、图标压字、行溢出画布、文字骑卡片边缘)(error 必改,回写 SVG;**导出边界自动复跑同套硬错误,error 拒绝导出、无豁免参数**)
- **渲图验收闭环** `svg_preview.py` + `accept_pages.py`:无头 Chrome 全量渲 PNG 肉眼/vision 验版面,逐页标 pass/fail 落 `.build/acceptance.json`;**导出 gate 只认"渲过 + 看过标 pass + 渲后源未改(sha1)"**,跳验收/盲改混不过去;`update_spec.py` 一键改色/字体传播到所有 SVG
- AI 配图走 imagegen skill;markitdown 素材摄取
**典型产物**:`<task>.pptx` + `build_deck.py`(整 deck 构建脚本,改稿/修验收项都改它重跑)。
**典型产物**:`exports/<topic>_<ts>.pptx`(原生可编辑)+ `svg_output/*.svg`(逐页设计源,改稿对象)+ `design_spec.md`/`spec_lock.md`。
> 引擎/知识/模板移植自开源 **ppt-master**(github.com/hugohe3/ppt-master,MIT),适配 zcbot 的 task_dir / 聊天确认 / imagegen 工作流。
---
@ -194,6 +217,8 @@ zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` +
**默认配色**:viridis(jet 是现代审稿雷区)。
**投稿级复合图**:要投高影响期刊的 figure 1 时,内置一套设计纪律(移植自 nature-figure skill,MIT)—— 五点 figure contract(核心结论 / 证据链 / 图原型 / 后端 / 期刊契约)、语义配色(蓝=本方法 / 绿=提升 / 红=baseline / 灰=参照,消融用同色变 alpha)、spine 纪律(`clean_spines` 只留左+下)、可编辑 SVG(`svg.fonttype='none'`)。
---
## 文献检索
@ -228,7 +253,7 @@ paper_server 是内部 Django 文献库:元数据来自 OpenAlex,PDF / XML 由 S
### documents
**查内部材料学科知识库(document_search API)。**
部署在 `https://ai.ctc-zc.com:8100/api`。后端按 `kb_name` 分库存 7 个材料学科,共 **21W+ 英文学术论文**(Elsevier 期刊为主,DOI 前缀文件名)。每个文档带 `md_content`(整篇 Markdown,LLM 友好)+ 可选原 PDF 下载。
部署在 `https://ai.ctc-zc.com:8100/api`。后端按 `kb_name` 分库存 7 个材料学科,共 **100W+ 英文学术论文**(Elsevier 期刊为主,DOI 前缀文件名)。每个文档带 `md_content`(整篇 Markdown,LLM 友好)+ 可选原 PDF 下载。
**7 大学科库**(`classification_id` 1-7):
| 学科 | 内容 |
@ -255,6 +280,36 @@ paper_server 是内部 Django 文献库:元数据来自 OpenAlex,PDF / XML 由 S
---
### brief
**生成科研方向简报(重要文献速览)。**
给定一个研究方向 + 时间窗,从各大相关期刊(**Elsevier 数据库优先**)挑选近期**重要论文**,产出两段式简报:**先一份重要论文列表(每篇带标题/作者/期刊/年月/DOI + 一段简介或摘要概述),再对这批论文做内容总结**。三路取数:research(逐刊精确取最新 Elsevier 论文 + DOI)+ documents(内部材料库取全文)取文献,web search 取政策·标准·产业动向(单列)。**只描述不给建议**——呈现"发了什么、讲了什么",判断留给读者。简报 ≠ 综述论文,要**快、准、客观**,520 分钟掌握一个方向近期发了哪些重要论文。
**五阶段**:定题对齐 spec(方向+边界 / 时间窗 / 期刊范围 / 深度 / 数据源 / 语言 / 关注点)→ 三路取数(research+documents 取文献、web 取动向;中→英术语转译 + 跨源去重)→ 列清单(带摘要概述)+ 内容总结 → 引文核验 → 渲染验收。
**深度三档(按篇数)**:`flash` 1020 篇 / `standard` 2040 篇 / `deep` 4080 篇。
**何时用**:
- ✅ 用户要"简报 / 方向简报 / 最新文献 / 重要论文列表 / 研究动态 / 某方向近期重要论文 / 跟踪某领域最新研究"
- ✅ 立项前想快速摸清一个方向近期发了哪些重要论文(产出可喂 proposal / analyze)
**何时不用**:
- ⛔ 只要文献清单 / DOI / PDF → research / documents
- ⛔ 要写可投稿的综述论文(几十页、定论)→ paper(review 类型)
- ⛔ 要把模糊科学问题拆成子问题 + 路线图 → analyze
- ⛔ 要"对本院的建议" / 写本子 → proposal
**核心能力**:
- **逐刊取重要论文**(`references/journals.md`):各建材子领域主流期刊清单(Elsevier 优先),精确 `publication_name` + `year_gte` 取最新,0 命中降级到 keyword 搜;按重要性(期刊层级 + 主题相关性 + 发现分量)筛、按 publication_date 留最新
- **三路分工 + 去重**:research+documents 取文献(同 DOI 一条、documents 全文优先)、web 单列产业政策动向不混论文总结;中文方向→英文术语转译(SCM/LC3 等缩写展开)
- **每篇带摘要概述**:列表不只标题,每篇 24 句讲研究对象/方法/主要发现,基于 abstract 或全文、不夸张不评判
- **引文核验**:存在性 / DOI 真伪(以库返回字段为准)/ 支撑度(摘要概述与原文一致,partial 改概述迁就证据),编造零容忍
- **平台渲染层 `rendering/render.py --profile brief`**(docx/pdf):商务红主题 + 论文列表 `[n]` 作锚点、正文 `[n]`/`[Wn]` 引文上标回链 + DOI/URL 可点击超链接(条目内 DOI 子串也链)+ 化学式下标(CO₂/C₃S...,白名单不误伤 LC3/Ca2+);pdf 走沙盒 chromium;做 deck 转 ppt
**典型产物**:`<方向>-简报.md`(默认,含 `01_papers` 重要论文列表 + `02_summary` 内容总结)+ `evidence.md`(证据表);可选转 docx / deck。
---
## 科研计算
### pymatgen
@ -323,9 +378,9 @@ paper_server 是内部 Django 文献库:元数据来自 OpenAlex,PDF / XML 由 S
## 内容生成
### imagegen
**用豆包 Seedream 5.0 生图(`seedream` tool)。**
**用豆包 Seedream 5.0 生图 / 改图(`seedream` tool,文生图 + image-to-image)。**
把"我想要张图"变成一张能用的图。流程:**诊断模糊度(六维)→ 给推断 + 待确认项 → 用户拍板 → 装配最终 prompt → 把 prompt 完整贴给用户确认 → 调 tool**。
把"我想要张图"变成一张能用的图,或在已有图上做像素级修改。流程:**诊断模糊度(六维)→ 给推断 + 待确认项 → 用户拍板 → 装配最终 prompt → 把 prompt 完整贴给用户确认 → 调 tool**。改图(i2i)额外传 `reference_images` 指向要改的那张图。
**成本**:每次 ¥0.22(`search=true` 加 ¥0.05),3-5 秒出图。
@ -341,8 +396,8 @@ paper_server 是内部 Django 文献库:元数据来自 OpenAlex,PDF / XML 由 S
**何时不走本 skill**:
- ⛔ 用户没主动要图 —— 别为"丰富回复"装饰性生图
- ⛔ 用户给参考图说"按这个改" —— Seedream 5.0 是文生图,不接图像输入
- ⛔ 已有合适素材 —— 直接 read / 引用,别重新生成
- ✅ 用户给参考图 / 对刚生成的图说"按这个改 / 改成 X" —— 走**改图(i2i)**:`reference_images` 指那张图,**别重新文生图**(重画会丢原构图);v1 单图
- ⛔ 已有合适素材且不改 —— 直接 read / 引用,别重新生成
**关键岔路**:
- 节点-箭头-结构关系明确(技术路线 / 流程图)→ **走 mermaid**(矢量、零成本、可编辑)
@ -430,15 +485,43 @@ paper_server 是内部 Django 文献库:元数据来自 OpenAlex,PDF / XML 由 S
---
### skill-creator
**引导用户创建 / 改造自己的私有 skill。**
把"每次都要重复交代的一套做法"沉淀成用户私有 skill,存在自己的 `.skills/` 下,只对自己生效。两种来源:**从零写**(`save_skill` 写一份 SKILL.md)或 **fork 内置再改**(`fork_skill` 整目录拷过来、连脚本一起带,再编辑)。
**何时用**:
- ✅ 用户说"我想要个自己的 skill / 自定义 skill / 把这套流程固定下来"
- ✅ 用户说"zcbot 的 X skill 挺好但我想改成 Y"(→ fork 再改)
- ✅ 用户每次任务都重复交代同一套约束(术语表 / 模板 / 禁忌),值得固化
**何时不用**:
- ⛔ 用户只是要完成一个具体任务 → 走对应内置 skill,别绕到造 skill
- ⛔ 要改的是所有任务都该遵守的全局行为 → 那是偏好 / system prompt,不是 skill
- ⛔ 一次性的事 → 直接做
**关键机制**:
- 用户 skill 存私有 `.skills/<name>/`(文件面板隐藏),用 `save_skill` / `fork_skill` 落盘(**不走 fs/shell**——沙箱 fs 根够不到那里)
- 造好 / 改好后**下一条消息**才生效(registry 每轮重建)
- 同名内置 → 覆盖(user wins,列表显式标注);改名 → 并存
- `save_skill` 写时校验 frontmatter(缺 description / YAML 坏直接拒),挡住"加载失败"黑洞
**典型产物**:`.skills/<name>/SKILL.md`(+ fork 带来的 scripts / references / templates)。
---
## 跨 skill 协作
实际任务往往跨多个 skill,典型组合:
- **写论文全流程**:analyze(拆问题) → stats_ml / pymatgen(算数据出结果) → research / documents(建文献矩阵) → plot_pub(先定图表) → paper(逐章起草 + 引文三角核验) → review(投稿前反谄媚终审)
- **写本子全流程**:analyze(拆问题) → research / documents(查文献) → stats_ml(算配方-性能模型出预实验数据) → plot_pub(出图) → proposal(写本子) → review(审稿)
- **写专利全流程**:patent(挖点 + 检索 + 起草) → research(查现有技术) → plot_pub(出附图) → review(终审)
- **写标准全流程**:analyze(定标准化对象) → stats_ml(配方-性能 / 精密度试验数据定指标) → research / documents(查国内外现有标准与现状) → standard(起草标准 + 编制说明) → plot_pub(出图) → review(送审前终审)
- **方向简报 → 立项**:brief(三路取数,出重要论文列表 + 内容总结,只描述) → analyze(把方向拆成子问题 + 路线图) → proposal(写本子、给建议) / paper(写综述);简报要做成汇报 → ppt
- **PPT 汇报**:analyze(提炼论点) → research / documents(找数据 + 引文) → plot_pub(出图) → ppt(组装 deck) → imagegen(可选,做封面 / 引子页)
- **晶体计算**:pymatgen(算 XRD / 相图) → plot_pub(出图) → proposal / patent(写到本子 / 交底书里)
- **定制能力**:skill-creator(fork 某内置 skill,如 ppt / proposal) → 改造成本组 / 本人专属版本(术语 / 模板 / 默认值),之后日常任务直接用改造版
---

View File

@ -0,0 +1 @@
THssshZfneJwIG5Y

View File

@ -2,6 +2,35 @@
default_model: deepseek_v4.flash
models_dir: config/models
# 模型档位(per-account 模型访问控制,见 core/model_access.py)。users.plan 存档位名;
# plan 为空 / 未知 → 落 `default` 档;role=admin 始终全开,不受此限制。
# 每档列出可用的模型 id:文本 = `family.variant`(config/models/);图/视频 = variant key
# (config/media/doubao.yaml)。成员含 `"*"` = 全开(含未来新增模型)。
# 三个 list 端点(/v1/models、/v1/image_models、/v1/video_models)按档过滤,用户只看到本档模型;
# 新建/切换/发媒体时再硬校验(老 task 续跑读 task.model_profile 不打断)。改后重启 web 生效。
model_tiers:
default: # 基线:所有未分配档位的用户(= 公测期默认可用)
- deepseek_v4.flash
- deepseek_v4.pro
- local.r1 # 内网模型(涉密任务)
- local.qwen3
- seedream_5 # 图(config/media/doubao.yaml image 段)
- seedance_2_fast # 视频
- seedance_2_pro
pro: # 基线 + 豆包 Seed 2.1 + GLM
- deepseek_v4.flash
- deepseek_v4.pro
- local.r1 # 内网模型(涉密任务)
- local.qwen3
- doubao.turbo
- doubao.pro
- doubao.evolving
- glm.pro
- glm.pro52
- seedream_5
- seedance_2_fast
- seedance_2_pro
skills_dir: skills
workspace_dir: workspace
system_prompt: prompts/system/general_v1.md
@ -35,6 +64,7 @@ sandbox:
memory: 2g # --memory (env: ZCBOT_SANDBOX_MEMORY)
cpus: 1.0 # --cpus (env: ZCBOT_SANDBOX_CPUS)
pids_limit: 256 # --pids-limit (env: ZCBOT_SANDBOX_PIDS_LIMIT)
shm_size: 512m # --shm-size (env: ZCBOT_SANDBOX_SHM_SIZE);chromium/mmdc 渲 mermaid 的 /dev/shm,默 64MB 不够会挂
# 容器 DNS server 显式配置(docker run --dns,容器 /etc/resolv.conf 直接写,
# 绕过 docker daemon 上游 DNS 探测路径;腾讯云轻量 / 部分云上 daemon 探测
# systemd-resolved 上游会失败,导致 embedded DNS 127.0.0.11 forward 出去也跪)。

View File

@ -21,10 +21,33 @@ image:
endpoint: /images/generations
price_cny_per_image: 0.22 # 计费单位:成功输出张数;调价改这里 + 重启
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_search: false # web search 额外加价 ~¥0.05/张;默认关
request_timeout_s: 60 # 出图慢于此判超时
vision:
# 图像理解(看图 / OCR / 读图表)。DeepSeek V4 主模型纯文本无视觉,挂 `look_at_image`
# 工具走豆包 Seed 2.0 Lite(全模态理解)按需读图。OpenAI 兼容 /chat/completions,
# 单图走 base64 data URL(同 seedream i2i,内网无需对象存储)。
# 价格 last_updated: 2026-06-16,源 https://www.volcengine.com/docs/82379/1544106
seed_2_lite:
model_id: doubao-seed-2-0-lite-260428
display_name: 豆包 Seed 2.0 Lite(视觉理解)
endpoint: /chat/completions
# token 计费(元/百万 tokens):输入 0.6 / 输出 3.6 / 缓存命中 0.12。
# 一次读图 ≈ 图 1-2K + 输出几百 token → 成本 < ¥0.01,故不设每日配额(够便宜)。
price_cny_per_mtoken_input: 0.6
price_cny_per_mtoken_output: 3.6
price_cny_per_mtoken_cache_hit: 0.12
max_image_mb: 10 # 单图上限(超出 tool 侧直接报错,不发请求)
request_timeout_s: 120 # 读图慢于此判超时(非流式,长 OCR 首字节可能逼近上限)
timeout_retries: 1 # 超时/网络抖动 tool 内透明重试次数(退避 2^n s);不含业务错误
video:
# fast 放第一个 → 默认 variant(成本敏感场景优先);开通了 Pro 的用户从顶栏下拉切。
seedance_2_fast:

84
config/models/doubao.yaml Normal file
View File

@ -0,0 +1,84 @@
# 豆包 Seed 2.1 文本/Agent 模型档案(火山方舟 Ark)
# 走 Ark 的 OpenAI 兼容 /chat/completions:litellm 用 `openai/` 前缀 + api_base 覆盖,
# 与 config/models/local.yaml 同范式(避免 litellm volcengine provider 的版本/字段差异)。
# api_key 复用媒体侧的 ARK_API_KEY(同一火山账号),env 见 RUN.md。
#
# thinking_mode 暂设 false:Seed 2.1 是深度思考模型,但开关走 Ark body `thinking:{type:enabled}`,
# 与 OpenAI/DeepSeek 的 `reasoning_effort` 等级协议不同 —— 同 glm.yaml 的处理,要 core/llm.py
# 加 family 分支才能透传等级,留 TODO。设 false 只是不发 reasoning_effort 字段;模型默认仍会
# 深度思考并返回 reasoning_content,不影响调用。
# 单价见各 variant(元/百万 tokens,来源:火山方舟 2026-06 发布价)。
family: doubao
variants:
turbo:
display_name: 豆包 Seed 2.1 Turbo
model_id: openai/doubao-seed-2-1-turbo-260628
api_base: https://ark.cn-beijing.volces.com/api/v3
api_key_env: ARK_API_KEY
max_context: 262144 # 256K
reliable_context: 131072
max_output: 16384 # 模型上限 128K(含思考),这里保守取值,需要长输出可调高
parallel_tools: true # Ark 兼容 parallel_tool_calls,默认 true
tool_calling_quality: good
thinking_mode: false
reasoning_effort_levels: []
default_reasoning_effort: ""
code_quality: good
enable_run_python: true
max_iterations: 120 # backstop 兜底,非"轮"预算;真正的空转防护是 loop 的无进展熔断 + _RepeatGuard
optimal_temperature: 0.3
prompt_caching: false
extended_thinking: false
input_cny_per_mtoken: 3.0
output_cny_per_mtoken: 15.0
cache_hit_cny_per_mtoken: 0.6
pro:
display_name: 豆包 Seed 2.1 Pro
model_id: openai/doubao-seed-2-1-pro-260628
api_base: https://ark.cn-beijing.volces.com/api/v3
api_key_env: ARK_API_KEY
max_context: 262144 # 256K
reliable_context: 131072
max_output: 16384 # 模型上限 128K(含思考),这里保守取值,需要长输出可调高
parallel_tools: true
tool_calling_quality: excellent
thinking_mode: false
reasoning_effort_levels: []
default_reasoning_effort: ""
code_quality: excellent
enable_run_python: true
max_iterations: 150 # backstop 兜底,非"轮"预算;真正的空转防护是 loop 的无进展熔断 + _RepeatGuard
optimal_temperature: 0.3
prompt_caching: false
extended_thinking: false
input_cny_per_mtoken: 6.0
output_cny_per_mtoken: 30.0
cache_hit_cny_per_mtoken: 1.2
evolving:
# 自进化版:统一 model_id `doubao-seed-evolving`,每周至少迭代一次,始终指向最新版。
# 面向 Coding/Agent 持续优化,覆盖全场景(与 pro 旗舰、turbo 低成本并列)。
display_name: 豆包 Seed Evolving(自进化)
model_id: openai/doubao-seed-evolving
api_base: https://ark.cn-beijing.volces.com/api/v3
api_key_env: ARK_API_KEY
max_context: 262144 # 256K(随版本可能变,按 Seed 2.1 家族取值)
reliable_context: 131072
max_output: 16384
parallel_tools: true
tool_calling_quality: excellent
thinking_mode: false
reasoning_effort_levels: []
default_reasoning_effort: ""
code_quality: excellent
enable_run_python: true
max_iterations: 150 # backstop 兜底,非"轮"预算;真正的空转防护是 loop 的无进展熔断 + _RepeatGuard
optimal_temperature: 0.3
prompt_caching: false
extended_thinking: false
# evolving 官方未单独公布单价,暂按 pro 估值兜底(宁高勿低,不少记成本);公布后校正。
input_cny_per_mtoken: 6.0
output_cny_per_mtoken: 30.0
cache_hit_cny_per_mtoken: 1.2

View File

@ -25,3 +25,28 @@ variants:
optimal_temperature: 0.3
prompt_caching: false
extended_thinking: false
# GLM 5.2:与 5.1 并存(新增 variant,不动 glm.pro,线上 task 仍引 5.1 不受影响)。
# 旗舰基座,真正可用的 1M 上下文,适合大仓库/长链路工程任务。thinking 同 pro 留 false(协议同 5.1)。
pro52:
display_name: GLM 5.2
model_id: zai/glm-5.2
api_base: https://open.bigmodel.cn/api/paas/v4
api_key_env: ZHIPUAI_API_KEY
max_context: 1000000 # 真 1M
reliable_context: 262144
max_output: 8192
parallel_tools: false
tool_calling_quality: good
thinking_mode: false
reasoning_effort_levels: []
default_reasoning_effort: ""
code_quality: excellent
enable_run_python: true
max_iterations: 50
optimal_temperature: 0.3
prompt_caching: false
extended_thinking: false
input_cny_per_mtoken: 8.0
output_cny_per_mtoken: 28.0
cache_hit_cny_per_mtoken: 2.0

View File

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

View File

@ -45,14 +45,22 @@ from tools.materials_project import (
MaterialsProjectGetStructureTool,
MaterialsProjectSearchSummaryTool,
)
from tools.look_at_image import LookAtImageTool
from tools.run_python import RunPythonTool
from tools.seedance import SeedanceTool
from tools.seedream import SeedreamTool
from tools.shell import ShellTool
from tools.skill_authoring import ForkSkillTool, SaveSkillTool
from tools.skill_tool import LoadSkillTool
from tools.task_progress import TaskProgressTool
from tools.ask_user import AskUserTool
from tools.web_fetch import WebFetchTool
from tools.web_search import WebSearchTool
from tools.schedule import (
ScheduleCancelTool, ScheduleCreateTool, ScheduleListTool, ScheduleUpdateTool,
)
from tools.send_email import SendEmailTool, smtp_configured
from tools.wechat_bot import WechatPushTool, wechat_push_available
from core.ark_client import ArkConfig
from core.bocha_client import BochaConfig
@ -63,15 +71,36 @@ from core.bocha_client import BochaConfig
# 也不该背这段红线。文案与 base 模板里其余工具表平级,放在 _build_system_prompt 里按需拼。
_MEDIA_TOOLS_BLOCK = """\
## 媒体生成工具(seedream 图 / seedance 视频)
- `seedream` 豆包图像生成产物自动落 `<task_dir>/figures/`每次 **¥0.22**(联网 `search=true` ¥0.05)
- **调用前必须先 `load_skill('imagegen')`** skill 里有何时该用 / 该不该用 mermaid 替代 / 用户描述模糊度诊断 / 一次性追问范式 / prompt 装配 / 失败解药全套引导**不要拿用户原话直接当 prompt tool** 容易烧 ¥0.22 在错的方向上
- 兜底硬约束(即使没 load skill 也守):用户没主动要图就别装饰性生成;同一目的不满意**不要连发**,先口头校准 prompt 再调
## 媒体工具(seedream 图 / seedance 视频 / look_at_image 看图)
- `look_at_image` 看图 / 读图(豆包 Seed 2.0 Lite 视觉)**(主模型)是纯文本看不见图,""图就调它**:OCR 文字描述画面读图表/表格/示意图识别物体每次很便宜( token,通常 < ¥0.01)
- **何时调**:用户消息里出现 `[用户上传的参考图] <路径>` 且需要据图内容回答("这图里写了啥 / 是什么 / 表格数据多少");或要基于 task 内某张图(`figures/xxx.png`)**实际内容**做事(不是改图,改图走 seedream) `image=<路径>` + 可选 `question`
- **何时不调**:用户只是要改图( seedream i2i)/ 只要文件名不关心内容 / 图是你自己刚生成的且 prompt 已知(无需再读)别对同一张图无意义反复看(每次都烧 token)
- `seedream` 豆包图像生成 / 改图产物自动落 `<task_dir>/figures/`每次 **¥0.22**(联网 `search=true` ¥0.05)
- **文生图**(不传 `reference_images`):从零按 prompt **改图 i2i**( `reference_images=["figures/xxx.png"]`):在已有图上做像素级修改**用户对刚生成 / 上传的图说"改成 X / 换个颜色 / 去掉某处" 必须走改图(reference_images 指那张图),绝不重新文生图**(重画 = 完全不同的图,丢原构图)v1 改图仅支持单张参考
- **调用前必须先 `load_skill('imagegen')`** skill 里有何时该用 / 该不该用 mermaid 替代 / 用户描述模糊度诊断 / 一次性追问范式 / prompt 装配 / 改图(i2i)范式 / 失败解药全套引导**不要拿用户原话直接当 prompt tool** 容易烧 ¥0.22 在错的方向上
- 兜底硬约束(即使没 load skill 也守):用户没主动要图就别装饰性生成;同一目的不满意**不要连发**,先口头校准 prompt 再调用户消息里出现 `[用户上传的参考图] <路径>` = 用户贴了图,要看图 / 改图时用那个路径
- `seedance` 豆包视频生成(Seedance 2.0 Fast)异步任务,** 30-90s 出片**;产物自动落 `<task_dir>/videos/`每次 **¥1.86 **(480p 4s)~ **¥12+**(720p 15s),比图贵 10 倍以上触发词:视频 / 动画 / 动起来 / 做个 video / 镜头 / 短片 / 演示视频 / 动效
- **调用前必须先 `load_skill('videogen')`** skill 里有6 维诊断(含运动维必填)/ seedream/mermaid 反向选型 / prompt 装配 / 参数取舍(时长/分辨率/比例直接决定钱)/ 失败解药全套引导视频比图贵 10 倍且 90s 等待,绝对不要拿用户原话当 prompt 直接调
- 兜底硬约束:用户没主动要视频就别装饰性生成(比生图更严重的红线);同一目的不满意**绝不连发**(1 次错 = ¥4+60s,连发 2 = ¥8+2min);phase 1 仅文生视频,**不支持** image-to-video / video-to-video"""
# 运行环境段(按 backend 注入,general_v1.md 的「平台」段指向这里)。环境事实(在哪 /
# 能否联网 / 装了啥)是全局不变量,放 system 比塞进某个 skill 高杠杆 —— 一句话省掉一整类
# 试错(外网试错 / 平台命令试错)。docker = 线上真实形态(Ubuntu 容器,无外网);host =
# 本地 dogfood(Windows),给一行最小提示免 general_v1 里那句指向落空。
_CONTAINER_ENV_BLOCK = """\
## 运行环境(容器)
你的 `shell` / `run_python` / 文件工具都在一个 **Linux(Ubuntu)容器**里执行 **bash 不是 cmd**:unix 命令 / 管道 / 重定向正常用,`mkdir -p``&&``2>&1` 都行
- ** mermaid 图一律走本地 `mmdc`**:`mmdc -i .md -o .png`(要矢量就 `-o .svg`;chromium 已配好,**不用加 `--no-sandbox`不用自己写 puppeteer 配置**) **别去调 `mermaid.ink` 等在线渲图服务** 境外易被墙 / 不稳,实测有对话在上面反复试编码改压缩,白烧上百 k token;本地 mmdc 一条命令就出图
- 中文字体已装(matplotlib / mermaid 出图不乱码);常用 Python 库已预装;`/tmp` 可写其余 rootfs 只读"""
_HOST_ENV_BLOCK = """\
## 运行环境(本地 host)
`shell` 走的是 **Windows cmd.exe**( bash):避免 unix-only flag,`mkdir -p` 不识别 `run_python` `os.makedirs(..., exist_ok=True)` 建目录;复杂管道/重定向用 run_python 更稳"""
def load_config() -> dict:
return yaml.safe_load((ROOT / "config" / "agent.yaml").read_text(encoding="utf-8")) or {}
@ -124,12 +153,36 @@ def resolve_workspace(workspace: Optional[str], cfg: Optional[dict] = None) -> P
def user_root(workspace_dir: Path, user_id: UUID) -> Path:
"""per-user 子树根:`<workspace>/users/<user_id>/`。working_dir / `.memory/` 都在下面。"""
"""per-user 子树根:`<workspace>/users/<user_id>/`。working_dir / `.memory/` / `.skills/` 都在下面。"""
d = workspace_dir / "users" / str(user_id)
d.mkdir(parents=True, exist_ok=True)
return d
def build_skill_registry(
cfg: dict, workspace_dir: Path, user_id: UUID, *, docker: bool
) -> "SkillRegistry":
"""装两来源 registry:内置 skill(`ROOT/skills`,只读)+ 用户 skill(`user_root/.skills`)。
用户来源排在内置之后 同名时 user wins( core/skills.py)container_root docker
:内置 bind `/sandbox/skills`,用户 `.skills` user_root user_root bind
`/workspace`,故为 `/workspace/.skills`host backend None
"""
from core.skills import SkillSource
builtin = SkillSource(
ROOT / cfg.get("skills_dir", "skills"),
"builtin",
"/sandbox/skills" if docker else None,
)
user = SkillSource(
user_root(workspace_dir, user_id) / ".skills",
"user",
"/workspace/.skills" if docker else None,
)
return SkillRegistry([builtin, user])
class InvalidTaskName(ValueError):
"""task name / working_dir 不合法(空 / 含分隔符 / dotfile 起头 / 超长)。"""
@ -225,18 +278,24 @@ def _build_system_prompt(
today 当场算, prompt LLM 直接拼路径(避免 LLM 不知道当前日期)
"""
prompt = (ROOT / cfg["system_prompt"]).read_text(encoding="utf-8")
if skills.skills:
prompt += f"\n\n## 可用 skill (用 load_skill 加载完整指引)\n{skills.discovery_block()}"
prompt += memory_block(workspace_dir, user_id)
if media_enabled:
prompt += "\n\n" + _MEDIA_TOOLS_BLOCK
# docker backend 下 shell/run_python/fs 工具全在容器里跑,容器把
# `<workspace>/users/<uid>` bind 到 `/workspace`、`--workdir /workspace/<wd>`
# (executor_docker.py:99-100)。此时 prompt 必须给**容器路径**,否则 LLM
# 拿着宿主绝对路径在沙盒里 find 不到任何东西(host 路径容器内根本不存在)。
# host backend 不变,直接用宿主绝对路径。
wd_abs = working_dir.resolve()
# (executor_docker.py:99-100)。此时 prompt 给 agent 的所有可写/可读绝对路径
# (含 .memory/ 写入锚点)都必须是**容器路径**,否则 LLM 拿着宿主绝对路径在沙盒里
# find 不到任何东西。host backend 不变,直接用宿主绝对路径。
is_docker = os.getenv("ZCBOT_SANDBOX_BACKEND", "host").lower() == "docker"
# 运行环境段紧跟模板(平台/网络是基础事实,放前面);general_v1 的「平台」段指向这里。
prompt += _CONTAINER_ENV_BLOCK if is_docker else _HOST_ENV_BLOCK
if skills.skills:
prompt += f"\n\n## 可用 skill (用 load_skill 加载完整指引)\n{skills.discovery_block()}"
# .memory/ 在 agent 视角下的可写路径:docker 给容器路径,host 给宿主绝对路径。
mem_dir_display = "/workspace/.memory" if is_docker else str(
user_root(workspace_dir, user_id) / ".memory"
)
prompt += memory_block(workspace_dir, user_id, mem_dir_display)
if media_enabled:
prompt += "\n\n" + _MEDIA_TOOLS_BLOCK
wd_abs = working_dir.resolve()
if is_docker:
try:
wd_rel = wd_abs.relative_to(user_root(workspace_dir, user_id))
@ -307,6 +366,7 @@ def build_agent(
image_variant: str = "",
video_variant: str = "",
cancel_check: Optional[Callable[[], bool]] = None,
scheduled_run: bool = False,
) -> Tuple[AgentLoop, Session, str, TaskState, Path]:
"""返回 (agent, session, task_id_str, task_state, working_dir_path)。
@ -368,7 +428,8 @@ def build_agent(
tool_base = Path(tool_base) if tool_base else Path.cwd()
skills = SkillRegistry(ROOT / cfg.get("skills_dir", "skills"))
is_docker = os.getenv("ZCBOT_SANDBOX_BACKEND", "host").lower() == "docker"
skills = build_skill_registry(cfg, workspace_dir, uid, docker=is_docker)
# 媒体配置提前 load 一次:既决定 system prompt 要不要追加媒体段(media_enabled),
# 也复用给下方 seedream/seedance 注册(避免重复读 doubao.yaml)。无 ARK_API_KEY → None。
@ -434,6 +495,8 @@ def build_agent(
tools = {}
tp = TaskProgressTool(base_dir=tool_base, user_root=ur_path)
tools[tp.name] = tp
au = AskUserTool(base_dir=tool_base, user_root=ur_path)
tools[au.name] = au
for cls in (ReadTool, WriteTool, EditTool, GlobTool, GrepTool, ShellTool):
t = cls(base_dir=tool_base, user_root=ur_path)
@ -443,8 +506,6 @@ def build_agent(
wf = WebFetchTool(base_dir=tool_base, user_root=ur_path)
tools[wf.name] = wf
import os
# Secret-bearing domain tools stay host-side. Never expose DOCUMENT_SEARCH_API_KEY
# / MP_API_KEY to run_python or the sandbox; only register typed tools when the
# corresponding host env exists.
@ -477,22 +538,48 @@ def build_agent(
tools[t.name] = t
if skills.skills:
# docker backend 下 fs/shell/run_python 在容器内跑,skills/ bind mount 到
# /sandbox/skills:ro。把 LoadSkillTool 返回头里的 dir 改写成容器路径,LLM
# 拿来 read references 才能命中。host backend = None,保持原 host 绝对路径。
container_skills_dir = (
"/sandbox/skills"
if os.getenv("ZCBOT_SANDBOX_BACKEND", "host").lower() == "docker"
else None
)
ls = LoadSkillTool(
registry=skills,
base_dir=tool_base,
user_root=ur_path,
container_skills_dir=container_skills_dir,
)
# LoadSkillTool 返回头里的 dir 由 registry 按 skill.source 给容器内路径
# (内置 → /sandbox/skills,用户 → /workspace/.skills);host backend → host 绝对路径。
ls = LoadSkillTool(registry=skills, base_dir=tool_base, user_root=ur_path)
tools[ls.name] = ls
# 用户 skill 创作工具:恒挂(每个用户都能造自己的 skill)。host-side 直接写
# user_root/.skills —— 不走沙箱 fs(其 base_dir 锚 cwd / 容器 wd,够不到 .skills)。
user_skills_dir = ur_path / ".skills"
for t in (
SaveSkillTool(user_skills_dir, skills, base_dir=tool_base, user_root=ur_path),
ForkSkillTool(user_skills_dir, skills, base_dir=tool_base, user_root=ur_path),
):
tools[t.name] = t
# 定时任务管理(DESIGN §8.5):增删查三件套。**定时 run 内不挂**(防任务造任务,
# 自我繁殖);仅交互对话里能建/管 job。user_id 由 ctor 注入,不信模型传的 id。
if not scheduled_run:
for t in (
ScheduleCreateTool(uid, base_dir=tool_base, user_root=ur_path),
ScheduleListTool(uid, base_dir=tool_base, user_root=ur_path),
ScheduleUpdateTool(uid, base_dir=tool_base, user_root=ur_path),
ScheduleCancelTool(uid, base_dir=tool_base, user_root=ur_path),
):
tools[t.name] = t
# 发邮件(§8.5 投递):仅当 SMTP_* env 齐了才挂(沿用"有 key 才注册",没配的
# 部署里 agent 看不到一个永远报错的工具)。定时与交互 run 都可用。
# base_dir 用 working_dir_path(该 task 的**宿主**工作目录绝对路径),不是 tool_base(cwd)。
# send_email 在宿主进程读附件文件,docker 下 agent 给的相对路径相对容器 workdir=task_dir,
# 翻回宿主即 working_dir_path;tool 内 _resolve_user_file 再处理 /workspace 容器绝对路径。
if smtp_configured():
se = SendEmailTool(base_dir=working_dir_path, user_root=ur_path)
tools[se.name] = se
# 微信主动推送(§8.7 渠道抽象):仅当微信渠道开关在才挂(沿用"有开关才注册")。
# 交互与定时 run 都可用(定时简报可主动推回用户微信,24h 窗口内)。user_id ctor 注入。
# base_dir 同 send_email:用 working_dir_path(宿主 task 目录),wechat_push 在宿主进程
# 读待发文件,需把 agent 给的相对/容器路径翻回宿主(详 _resolve_user_file)。
if wechat_push_available():
wp = WechatPushTool(uid, base_dir=working_dir_path, user_root=ur_path, task_id=task_id)
tools[wp.name] = wp
if caps.enable_run_python:
rp = RunPythonTool(base_dir=tool_base, user_root=ur_path)
tools[rp.name] = rp
@ -566,6 +653,27 @@ def build_agent(
)
tools[seedance_tool.name] = seedance_tool
# 图像理解 tool(look_at_image / 豆包 Seed 2.0 Lite vision):仅当 yaml 有 vision 段才挂。
# 无 variant 选择维度(读图不分档,固定第一个 variant),与 image/video 的"用户可切档"不同。
vision_cfg = (ark_cfg.raw.get("vision") or {})
vis_key, vis_variant = "", None
for variant_key, variant_cfg in vision_cfg.items():
if isinstance(variant_cfg, dict):
vis_key, vis_variant = variant_key, variant_cfg
break
if vis_variant is not None:
look_tool = LookAtImageTool(
ark_cfg=ark_cfg,
vision_variant_cfg=vis_variant,
variant_key=vis_key,
working_dir=working_dir_path,
task_id=task_id,
user_id=uid,
base_dir=tool_base,
user_root=ur_path,
)
tools[look_tool.name] = look_tool
# 博查联网搜索:仅当 BOCHA_API_KEY 设了才挂
bocha_cfg = BochaConfig.load()
if bocha_cfg is not None:

View File

@ -23,6 +23,14 @@ class ArkError(RuntimeError):
"""ark API 调用失败的统一异常。"""
class ArkTimeoutError(ArkError):
"""可重试的瞬时失败:请求超时 / 网络抖动(非业务错误)。
HTTP 4xx/5xx 业务错误仍抛普通 ArkError(不该重试,重试也是同样的错)
caller 可单独 catch 本子类做退避重试;catch ArkError 仍能兜住(isinstance)
"""
@dataclass
class ArkConfig:
api_key: str
@ -73,18 +81,18 @@ class ArkClient:
try:
resp = self._client.post(path, json=body, timeout=timeout_s or self.timeout_s)
except httpx.TimeoutException as e:
raise ArkError(f"timeout calling POST {path}: {e}") from e
raise ArkTimeoutError(f"timeout calling POST {path}: {e}") from e
except httpx.HTTPError as e:
raise ArkError(f"network error calling POST {path}: {e}") from e
raise ArkTimeoutError(f"network error calling POST {path}: {e}") from e
return self._parse(resp, f"POST {path}")
def get_json(self, path: str, *, timeout_s: Optional[float] = None) -> dict:
try:
resp = self._client.get(path, timeout=timeout_s or self.timeout_s)
except httpx.TimeoutException as e:
raise ArkError(f"timeout calling GET {path}: {e}") from e
raise ArkTimeoutError(f"timeout calling GET {path}: {e}") from e
except httpx.HTTPError as e:
raise ArkError(f"network error calling GET {path}: {e}") from e
raise ArkTimeoutError(f"network error calling GET {path}: {e}") from e
return self._parse(resp, f"GET {path}")
@staticmethod

View File

@ -1,7 +1,11 @@
"""LLM 上下文准备。
不改 Session 持久化历史,只在发给模型前做低风险压缩第一阶段只压旧 tool
消息内容,保留 tool_call 协议字段,避免历史命令输出 / 检索结果反复占满 prompt
不改 Session 持久化历史,只在发给模型前做低风险压缩只压旧 tool 消息**内容**,
绝不动 assistant `tool_call.arguments` arguments 是模型"该怎么调工具"的范本,
把它改写成 `{"_compacted":...}` 这种"看着像合法调用"的标记会毒化模型:它在长任务里
看到几十次"过去的 run_python/write 长这样",就照葫芦画瓢把 marker 当参数原样吐出来,
executor 拿不到 code/path 报错空转(2026-06-12 DB 实测 60 task 命中 83 ,
其中 61 次是模型仿写 marker; PROGRESS) arguments 一律原样保留
"""
from __future__ import annotations
@ -45,63 +49,66 @@ def _message_chars(msg: dict[str, Any]) -> int:
return len(str(msg))
def _compact_tool_call_arguments(raw: Any, max_chars: int, tool_name: str = "") -> tuple[Any, bool]:
# task_progress 参数本就很小(3-7 个短步骤),压缩省的 token 微乎其微,但把它换成
# `{"_compacted":true,"step_id":...}` 这种"看起来像合法调用"的标记会:① 毒化模型,
# 让它照葫芦画瓢生成残废的 update_step(丢了 step.status)入库;② 残废格式前端
# applyProgressAction 读不到 args.step → 进度还原错乱。故 task_progress 一律不压缩参数。
if tool_name == "task_progress":
return raw, False
if not isinstance(raw, str) or len(raw) <= max_chars:
return raw, False
marker: dict[str, Any] = {
"_compacted": True,
"original_chars": len(raw),
"note": "old assistant tool_call arguments omitted from context",
_INTERRUPTED_TOOL_RESULT = (
"[interrupted: tool result missing — run was cut off "
"(disconnect/cancel) before this tool finished]"
)
def _repair_dangling_tool_calls(
messages: List[dict[str, Any]],
) -> tuple[List[dict[str, Any]], int]:
"""补齐被中断 run 留下的悬空 tool_calls,返回 (修复后的消息, 补的占位条数)。
run 在写入 `assistant.tool_calls` 之后tool 结果写入之前被中断(上游断连 /
用户取消 / 崩溃),会在历史里留下一条 `assistant.tool_calls` 后面没有对应 tool
结果的消息;用户随后继续发言,下一轮把历史原样发给 OpenAI/DeepSeek 就会被拒:
"An assistant message with 'tool_calls' must be followed by tool messages
responding to each 'tool_call_id'"(2026-06-18 DB 实测 task 5c5d6d25 命中)。
这里在发送前为每个**缺失** tool_call_id 紧跟其 assistant 消息补一条占位 tool
消息,满足协议且不丢上下文纯发送期处理,不改库 对所有中断路径和已存在的坏
数据都生效
"""
repaired: List[dict[str, Any]] = []
repaired_count = 0
n = len(messages)
i = 0
while i < n:
msg = messages[i]
repaired.append(msg)
tool_calls = msg.get("tool_calls") if isinstance(msg, dict) else None
if isinstance(msg, dict) and msg.get("role") == "assistant" and tool_calls:
id_to_name = {
tc.get("id"): (tc.get("function") or {}).get("name")
for tc in tool_calls
if isinstance(tc, dict) and tc.get("id")
}
try:
parsed = json.loads(raw)
except Exception:
parsed = None
if isinstance(parsed, dict):
for key in ("path", "script_path", "file_path", "name"):
value = parsed.get(key)
if isinstance(value, str) and value:
marker[key] = value
content = parsed.get("content")
if isinstance(content, str):
marker["content_chars"] = len(content)
return json.dumps(marker, ensure_ascii=False), True
def _compact_assistant_tool_calls(
msg: dict[str, Any],
*,
max_arg_chars: int,
) -> tuple[int, int]:
tool_calls = msg.get("tool_calls")
if not isinstance(tool_calls, list):
return 0, 0
compacted = 0
saved = 0
for tc in tool_calls:
if not isinstance(tc, dict):
# 收集紧随其后的连续 tool 消息已回应的 id(协议要求 tool 结果紧跟 assistant)。
answered: set[Any] = set()
j = i + 1
while j < n and isinstance(messages[j], dict) and messages[j].get("role") == "tool":
cid = messages[j].get("tool_call_id")
if cid:
answered.add(cid)
repaired.append(messages[j])
j += 1
# 为缺失的 id 补占位 tool 消息(保持在该 assistant 的 tool 结果块内)。
for cid, name in id_to_name.items():
if cid not in answered:
synthetic: dict[str, Any] = {
"role": "tool",
"tool_call_id": cid,
"content": _INTERRUPTED_TOOL_RESULT,
}
if name:
synthetic["name"] = name
repaired.append(synthetic)
repaired_count += 1
i = j
continue
fn = tc.get("function")
if not isinstance(fn, dict):
continue
before = fn.get("arguments")
tool_name = fn.get("name") if isinstance(fn.get("name"), str) else ""
after, did_compact = _compact_tool_call_arguments(
before,
max_chars=max(0, max_arg_chars),
tool_name=tool_name,
)
if did_compact:
fn["arguments"] = after
compacted += 1
saved += len(before) - len(after)
return compacted, max(0, saved)
i += 1
return repaired, repaired_count
def prepare_messages_for_llm(
@ -109,20 +116,19 @@ def prepare_messages_for_llm(
*,
keep_recent: int = 12,
old_tool_chars: int = 2_000,
old_tool_arg_chars: int = 800,
compact_threshold_chars: int = 0,
) -> List[dict[str, Any]]:
"""返回发给 LLM 的 messages 副本。
- system 和最近 keep_recent 条消息原样保留
- 较旧且过长的 tool content 压缩为头尾摘要
- assistant tool_call.arguments 一律原样保留(改写会毒化模型,见模块注释)
- role/tool_call_id/name 等协议字段不变
"""
prepared, _ = prepare_messages_with_stats(
messages,
keep_recent=keep_recent,
old_tool_chars=old_tool_chars,
old_tool_arg_chars=old_tool_arg_chars,
compact_threshold_chars=compact_threshold_chars,
)
return prepared
@ -133,7 +139,6 @@ def prepare_messages_with_stats(
*,
keep_recent: int = 12,
old_tool_chars: int = 2_000,
old_tool_arg_chars: int = 800,
compact_threshold_chars: int = 0,
) -> tuple[List[dict[str, Any]], dict[str, int]]:
"""返回发给 LLM 的 messages 副本和压缩统计。
@ -144,6 +149,8 @@ def prepare_messages_with_stats(
"""
if keep_recent < 0:
keep_recent = 0
# 先补齐被中断 run 留下的悬空 tool_calls(否则原样发给模型会被拒,见函数注释)。
messages, repaired_tool_calls = _repair_dangling_tool_calls(messages)
original_chars = sum(_message_chars(m) for m in messages)
# 未到上下文压力门槛 → 原样发,零压缩(缓存全暖 + 不丢信息)。压缩是"放不下"才做的事。
@ -155,8 +162,8 @@ def prepare_messages_with_stats(
"saved_chars": 0,
"compacted_tool_messages": 0,
"compacted_skill_messages": 0,
"compacted_tool_call_arguments": 0,
"compaction_skipped": 1,
"repaired_tool_calls": repaired_tool_calls,
}
return prepared, stats
@ -164,16 +171,10 @@ def prepare_messages_with_stats(
prepared: List[dict[str, Any]] = []
compacted_tool_messages = 0
compacted_skill_messages = 0
compacted_tool_call_arguments = 0
for idx, msg in enumerate(messages):
new_msg = deepcopy(msg)
is_recent = idx >= recent_start
if not is_recent and new_msg.get("role") == "assistant":
n_args, _ = _compact_assistant_tool_calls(
new_msg,
max_arg_chars=old_tool_arg_chars,
)
compacted_tool_call_arguments += n_args
# assistant 的 tool_call.arguments 一律原样保留 —— 压成 marker 会毒化模型(见模块注释)。
if (
not is_recent
and new_msg.get("role") == "tool"
@ -199,7 +200,7 @@ def prepare_messages_with_stats(
"saved_chars": max(0, original_chars - sent_chars),
"compacted_tool_messages": compacted_tool_messages,
"compacted_skill_messages": compacted_skill_messages,
"compacted_tool_call_arguments": compacted_tool_call_arguments,
"compaction_skipped": 0,
"repaired_tool_calls": repaired_tool_calls,
}
return prepared, stats

View File

@ -323,6 +323,13 @@ class AgentLoop:
}
)
# ask_user:本步调用了人工选择工具 → 提前结束本轮,等用户点选项 / 文字讨论,
# 不回灌 LLM。选项已随该 tool_call 的 arguments 流给前端渲染成选项卡;tool 结果
# 只是占位,下轮用户回复(点选项 = 发选项 label 文本)后模型自然接上。
if any(getattr(tc.function, "name", "") == "ask_user" for tc in tool_calls):
self._emit({"type": "done"})
return getattr(msg, "content", None) or ""
# 全局「无进展」熔断:整步所有 tool 都无净产出(全是 [Error]/重复/被拦)→ 累计;
# 连续 _STALL_LIMIT 步空转就主动停,别烧到 max_iterations。一旦某步有净产出立即清零。
if step_productive:

View File

@ -2,8 +2,9 @@
core.md system prompt,每次都看到装稳定事实
(用户偏好 / 常用命令 / 项目约定 / 模型 quirk 备忘等)
extended/<x>.md 索引(标题+路径) prompt,内容 agent `read` 按需拉
装少数任务才用的专题资料( API 速查 / 某历史事件等)
extended/<x>.md 索引(frontmatter `description` + 路径) prompt,内容 agent
`read` 按需拉装少数任务才用的专题资料( API 速查 / 某历史
事件等)description 是召回依据:写得准,模型才知道何时该拉
为什么这样切:
core 一直挂在上下文里,token 成本固定 只放跨任务高频用的精炼内容
@ -14,11 +15,17 @@ memory 是 per-user(同一 workspace 内按 user_id 隔离),同 user 的所有 t
项目名取 `memory` 时撞名;`.` 起头也被 `validate_task_name` ,双向防呆
user_id web auth 入口(JWT `sub`)透传到 build_agentSaaS 化时 `<storage_root>`
替换 `workspace`,布局不变(§7.0)
写入路径(agent 自管):memory_block `.memory/` **可写绝对路径**(host 绝对路径 /
docker `/workspace/.memory`)连同记忆维护契约一起注进 prompt,agent 用已有
`write`/`edit`/`grep` 直接维护 不引专用工具契约 + 锚点即使记忆为空也常驻,
否则新用户冷启动永远不知道自己能记
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import List, Tuple
from typing import Any, Dict, List, Optional, Tuple
from uuid import UUID
@ -26,18 +33,42 @@ def _memory_dir(workspace_dir: Path, user_id: UUID) -> Path:
return workspace_dir / "users" / str(user_id) / ".memory"
def _read_first_title(p: Path) -> str:
"""取文件第一个非空 h1/h2 行作为标题;没有就用文件名 stem。"""
try:
for raw in p.read_text(encoding="utf-8").splitlines():
def _parse_frontmatter_description(text: str) -> Optional[str]:
"""取 YAML frontmatter 里的 `description:` 一行;没有 frontmatter 返回 None。
只认文件最开头的 `---` ... `---` ,块内首个 `description:` 行的值
刻意不引 yaml 依赖 记忆文件 frontmatter 就这一个字段够用,手解析最省
"""
lines = text.splitlines()
if not lines or lines[0].strip() != "---":
return None
for raw in lines[1:]:
stripped = raw.strip()
if stripped == "---":
break
if stripped.startswith("description:"):
val = stripped[len("description:"):].strip()
# 去掉可能的引号
if len(val) >= 2 and val[0] in "'\"" and val[-1] == val[0]:
val = val[1:-1]
return val.strip() or None
return None
def _read_first_title(text: str, stem: str) -> str:
"""取文件第一个非空 h1/h2 行作为标题;没有就用文件名 stem。
legacy 兜底:存量 extended 文件没 frontmatter,退回首行当标题(平滑兼容)
"""
for raw in text.splitlines():
line = raw.strip()
if line == "---":
continue
if line.startswith("#"):
return line.lstrip("#").strip()
if line:
return line[:60]
except (OSError, UnicodeDecodeError):
pass
return p.stem
return stem
def _load_core(workspace_dir: Path, user_id: UUID) -> str:
@ -50,31 +81,137 @@ def _load_core(workspace_dir: Path, user_id: UUID) -> str:
return ""
def _extended_index(workspace_dir: Path, user_id: UUID) -> List[Tuple[str, Path]]:
"""返回 [(title, abs_path), ...],按文件名排序。"""
def _extended_index(workspace_dir: Path, user_id: UUID) -> List[Tuple[str, str]]:
"""返回 [(description_or_title, filename), ...],按文件名排序。
优先 frontmatter `description`;没有则退回首行标题(legacy 兼容)
返回 filename(非绝对路径) 路径由 memory_block backend display 前缀,
docker 下要给容器路径而非宿主路径
"""
ext_dir = _memory_dir(workspace_dir, user_id) / "extended"
if not ext_dir.is_dir():
return []
items: List[Tuple[str, Path]] = []
items: List[Tuple[str, str]] = []
for p in sorted(ext_dir.glob("*.md")):
if p.is_file():
items.append((_read_first_title(p), p.resolve()))
if not p.is_file():
continue
try:
text = p.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
text = ""
label = _parse_frontmatter_description(text) or _read_first_title(text, p.stem)
items.append((label, p.name))
return items
def memory_block(workspace_dir: Path, user_id: UUID) -> str:
"""构造注入 system prompt 的记忆段;两块都空就返回空串。"""
_CONTRACT = """\
你可以**主动维护**这份记忆(用已有 `write`/`edit`/`grep`),把跨 task 复用的事实存下来,
下次别的 task 一开场就能用规矩:
- **什么值得记**:用户稳定偏好项目长期约定反复用到的事实踩过的坑(及为什么)
**不要记**:只跟当前 task 有关的一次性信息能从产物/代码里直接看到的东西
- **core.md(常驻,)**:只放跨 task 高频精炼的稳定事实 它每轮都占 token
- **extended/<slug>.md(按需,便宜)**:低频专题资料,一事一文件文件开头写
frontmatter `description:` 一行(这行进上面索引,决定何时被召回),正文是事实本身:
```
---
description: <一句话说清这份资料是什么何时该拉>
---
<内容>
```
- **写前先查重**:`grep`/`read` 看现有记忆有没有,有就 `edit` 更新别堆重复;发现记错了就删
- **用户让你"记住 / 改 / 忘掉"某事时,这是直接指令**:照办 "记住"就写"改成" `edit`
"忘掉 / 删掉"就把对应条目从 core.md 删掉或删掉那个 extended 文件改完回一句确认即可
- 记忆即时生效(每个新 task 重读),不用通知用户"""
def memory_block(
workspace_dir: Path,
user_id: UUID,
mem_dir_display: Optional[str] = None,
) -> str:
"""构造注入 system prompt 的记忆段。
mem_dir_display: `.memory/` agent 视角下的可写绝对路径前缀host backend
None 用宿主绝对路径;docker backend `/workspace/.memory`(容器内路径)
与旧版不同:契约 + 写入锚点常驻(即使记忆空), agent 知道自己能记;core /
extended 两段仍按有无内容才出现
"""
core = _load_core(workspace_dir, user_id)
ext = _extended_index(workspace_dir, user_id)
if not core and not ext:
return ""
parts = ["\n\n## 记忆 (user 级,跨 task 共享)"]
real_dir = _memory_dir(workspace_dir, user_id)
base = mem_dir_display if mem_dir_display is not None else str(real_dir)
base = base.rstrip("/")
parts = ["\n\n## 记忆 (user 级,跨 task 共享)\n"]
parts.append(_CONTRACT)
parts.append(
f"\n\n**写到这里**:core → `{base}/core.md`;"
f"专题 → `{base}/extended/<slug>.md`\n"
)
# 快捷指令(与记忆是两套机制):触发词 → 完整指令的映射,存 shortcuts.md。**内容不注上下文**
# (入口层查表展开,不靠你召回),这里只给"能维护 + 格式",让你在用户要建/改快捷词时会写。
parts.append(
f"\n**快捷指令**:用户说\"记个快捷词 X → Y\"/\"把快捷词 X 改成/删掉\"时,维护 "
f"`{base}/shortcuts.md`(先 `read` 再 `edit`)。格式是两列 markdown 表 "
f"`| 触发词 | 完整指令 |`(表头 + `|---|---|` 分隔行 + 每条一行;触发词别含 `|`)。"
f"之后用户在任意入口(网页/微信/企业微信)整条打这个触发词,系统自动展开成完整指令 —— "
f"你无需在对话里替他执行触发,只负责把这行写对。\n"
)
if core:
parts.append("\n### Core (常驻 prompt)\n")
parts.append(core)
if ext:
parts.append("\n\n### Extended (按需用 `read` 加载)\n")
for title, path in ext:
parts.append(f"- `{path}` — {title}\n")
for label, name in ext:
parts.append(f"- `{base}/extended/{name}` — {label}\n")
return "".join(parts)
# ── 只读视图(web GUI 用) ─────────────────────────────────────────────
# 前端「记忆」弹框只读展示用。**故意不提供写/删 API** —— 改记忆全走对话(agent
# 自管,见 _CONTRACT),GUI 当"眼睛"不当"手":看全貌靠直接读 FS(便宜、是地面真相),
# 改靠模型(统一写入口、自然语言、能合并改写)。详见 DESIGN §3.7。
_EXTENDED_NAME_RE = re.compile(r"^[\w\-.]+\.md$")
def _is_safe_extended_name(name: str) -> bool:
"""防穿越:只许 `.memory/extended/` 下的扁平 `.md` 文件名。
拒斜杠 / `..` / dotfile / .md配合调用处 resolve 再兜一层子树校验
"""
if not name or "/" in name or "\\" in name or name.startswith("."):
return False
return bool(_EXTENDED_NAME_RE.match(name))
def memory_view(workspace_dir: Path, user_id: UUID) -> Dict[str, Any]:
"""记忆全貌(只读):core 原文 + extended 列表(filename + description)。
一次填满前端弹框core 给原文( strip 前的注入版)让用户看到真实落盘内容
"""
return {
"core": _load_core(workspace_dir, user_id),
"extended": [
{"filename": name, "description": label}
for label, name in _extended_index(workspace_dir, user_id)
],
}
def read_extended_file(
workspace_dir: Path, user_id: UUID, filename: str
) -> Optional[str]:
"""读单篇 extended 原文;文件名非法 / 越界 / 不存在 → None(调用方转 404)。"""
if not _is_safe_extended_name(filename):
return None
ext_dir = (_memory_dir(workspace_dir, user_id) / "extended").resolve()
target = (ext_dir / filename).resolve()
if ext_dir not in target.parents or not target.is_file():
return None
try:
return target.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return None

58
core/model_access.py Normal file
View File

@ -0,0 +1,58 @@
"""Per-account 模型访问控制(档位制)。
`users.plan` 存档位名;档位 可用模型集合定义在 `config/agent.yaml` `model_tiers`
- plan 为空 / 未知档位 `default` (= 基线,所有未分配用户)
- `role == 'admin'` 始终全开,不受档位限制(管理员要能测所有模型)
- 某档成员里出现 `"*"` 该档全开(含未来新增模型),给内部档用
模型 id 约定( list 端点 / resolve 校验一致):
- 文本模型 = `family.variant`(config/models/<family>.yaml), `doubao.pro``glm.pro52`
- / 视频模型 = variant key(config/media/doubao.yaml), `seedream_5``seedance_2_fast`
两者命名不冲突(文本带点媒体 variant 不带点),同一档集合里混放即可
纯函数 + yaml 配置,不碰 DB / HTTP 调用方(web )负责取 user plan/role
并把"拒绝"翻译成 HTTP 403这样 core 不耦合 fastapi
"""
from __future__ import annotations
from typing import Optional
DEFAULT_TIER = "default"
WILDCARD = "*"
def _tiers() -> dict[str, list[str]]:
"""从 agent.yaml 读 model_tiers;缺失 → 空 dict(→ 所有人落 default,而 default 也空 → 全禁)。
开发期不缓存,每次现读(load_config 自身轻量); yaml 重启 web 生效
"""
from core.agent_builder import load_config
return load_config().get("model_tiers") or {}
def tier_name(plan: Optional[str], tiers: Optional[dict] = None) -> str:
"""plan → 实际生效的档位名;plan 为空 / 不在 tiers 里 → DEFAULT_TIER。"""
tiers = _tiers() if tiers is None else tiers
p = (plan or "").strip()
return p if p in tiers else DEFAULT_TIER
def allowed_set(plan: Optional[str], role: Optional[str]) -> Optional[set[str]]:
"""该用户可用模型 id 集合;返回 None = 全开(admin 或档位含 '*')。
None set 语义不同:None=不设限(放行一切), set=一个都不许
"""
if (role or "") == "admin":
return None
tiers = _tiers()
members = tiers.get(tier_name(plan, tiers)) or []
if WILDCARD in members:
return None
return set(members)
def is_allowed(model_id: str, plan: Optional[str], role: Optional[str]) -> bool:
"""该用户能否使用某模型 id(文本 profile 或媒体 variant)。"""
allowed = allowed_set(plan, role)
return allowed is None or model_id in allowed

View File

@ -49,6 +49,11 @@ DEFAULT_IDLE_TTL_SECONDS = 300
DEFAULT_MEMORY = "2g"
DEFAULT_CPUS = "1.0"
DEFAULT_PIDS_LIMIT = 256
# chromium(mmdc 渲 mermaid / puppeteer)默认走 /dev/shm,docker 不传 --shm-size 时
# 只给 64MB,起不来就一直挂到 timeout。镜像备的 puppeteer-config 有 --disable-dev-shm-usage,
# 但模型不一定用那份;这里从根上把 /dev/shm 撑到够用,任何 chromium 路径都不再挂。
# 从 --memory(默 2g)里切,512m 是上限非占用(tmpfs 按需用)。
DEFAULT_SHM_SIZE = "512m"
def container_name(user_id: UUID) -> str:
@ -89,6 +94,7 @@ class SandboxPool:
memory: Optional[str] = None,
cpus: Optional[str] = None,
pids_limit: Optional[int] = None,
shm_size: Optional[str] = None,
dns: Optional[List[str]] = None,
) -> None:
"""
@ -107,10 +113,11 @@ class SandboxPool:
(env `ZCBOT_SANDBOX_IDLE_TTL`, 300)
pg_ips: 逗号分隔的 PG IP ,塞容器 `ZCBOT_PG_IPS` env,init.sh DROP 规则
(env `ZCBOT_PG_IPS`)defense-in-depth 即便落内网三段
memory/cpus/pids_limit:
容器资源限制, 2g/1.0/256;env(`ZCBOT_SANDBOX_MEMORY` )
memory/cpus/pids_limit/shm_size:
容器资源限制, 2g/1.0/256/512m;env(`ZCBOT_SANDBOX_MEMORY` )
override caller 参数 override 默认改后重启 web 生效,新起的
容器用新值; running 不变(idle 5min 回收后下次起按新值)
shm_size chromium /dev/shm( 64MB 不够,mmdc 渲图会挂)
"""
self.user_root_base = user_root_base
self.repo_root = repo_root
@ -123,6 +130,7 @@ class SandboxPool:
# 资源限制:env > caller > 默
self.memory = os.getenv("ZCBOT_SANDBOX_MEMORY") or memory or DEFAULT_MEMORY
self.cpus = os.getenv("ZCBOT_SANDBOX_CPUS") or cpus or DEFAULT_CPUS
self.shm_size = os.getenv("ZCBOT_SANDBOX_SHM_SIZE") or shm_size or DEFAULT_SHM_SIZE
self.pids_limit = int(
os.getenv("ZCBOT_SANDBOX_PIDS_LIMIT")
or (pids_limit if pids_limit is not None else DEFAULT_PIDS_LIMIT)
@ -197,6 +205,7 @@ class SandboxPool:
# §7.5 硬限制(任一缺失视为 hardening 未完成)
"--read-only", # rootfs read-only
"--tmpfs", "/tmp:exec,size=512m,mode=1777", # 可写临时区,exec 允许 (run_python 写脚本)
f"--shm-size={self.shm_size}", # chromium/mmdc 的 /dev/shm,默 64MB 不够会挂(DEFAULT_SHM_SIZE)
"--cap-drop=ALL", # 默全丢
"--cap-add=NET_ADMIN", # init.sh 配 iptables 需要;exec 进来的 uid 1000 拿不到
"--security-opt=no-new-privileges",
@ -227,6 +236,11 @@ class SandboxPool:
skills_path = (self.repo_root / "skills").resolve()
if skills_path.is_dir():
cmd += ["-v", f"{skills_path}:/sandbox/skills:ro"]
# 平台渲染层(rendering/)只读 mount ── 各 skill 出 docx/pdf 调
# `python /sandbox/rendering/render.py`,不再自带 render 脚本。与 skills 同款 ro。
rendering_path = (self.repo_root / "rendering").resolve()
if rendering_path.is_dir():
cmd += ["-v", f"{rendering_path}:/sandbox/rendering:ro"]
if self.runtime:
cmd += ["--runtime", self.runtime]
cmd.append(self.image)
@ -308,5 +322,6 @@ def setup_pool(
memory=cfg.get("memory") if isinstance(cfg.get("memory"), str) else None,
cpus=str(cfg["cpus"]) if cfg.get("cpus") is not None else None,
pids_limit=int(cfg["pids_limit"]) if cfg.get("pids_limit") is not None else None,
shm_size=cfg.get("shm_size") if isinstance(cfg.get("shm_size"), str) else None,
dns=[str(x) for x in dns_cfg],
)

434
core/scheduler.py Normal file
View File

@ -0,0 +1,434 @@
"""定时任务调度核心(DESIGN §8.5)。
纯逻辑层:cronnext_run 计算due 任务认领跑完记账确定性兜底投递
**不碰 asyncio / broker / _run_agent_bg**(那些 web 专属编排留在 web/app.py
lifespan `_scheduler_loop`,仿 _disk_scanner 调本模块)
为什么 claim 时就推进 next_run_at:守护循环每 ~30s 扫一次,若不在认领时把 job
next_run_at 推到下一个 cron ,run 还没跑完时下一 tick 会把同一 job 重复触发
claim+advance 一把事务做掉 天然防重复触发(at-most-once per slot)
"""
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from uuid import UUID, uuid4
from croniter import croniter
from sqlalchemy import select, update
from .storage import session_scope
from .storage.models import ScheduledJob
_MODES = ("isolated", "persistent")
try:
from zoneinfo import ZoneInfo
except ImportError: # pragma: no cover (py<3.9 不支持,本项目 3.11+)
ZoneInfo = None # type: ignore
# 连续失败到这个数自动停(防僵尸定时任务,DESIGN §8.5 expiry 安全界)
FAILURE_DISABLE_THRESHOLD = 5
# 单次 tick 最多认领多少 job(防一批同点任务一次性涌入)
CLAIM_LIMIT = 20
# 新建 job 不指定时的默认单次超时(秒)。0=不限;给个有限默认防"跑到一半被
# 无限拖着 / 静默吞成 ok"。报告类重活(多刊检索+渲 docx)按经验 30min 够用。
DEFAULT_TIMEOUT_SECONDS = 1800
def validate_cron(expr: str) -> None:
"""非法 cron 抛 ValueError(给 schedule_create 工具做入参校验)。"""
expr = (expr or "").strip()
if not expr or not croniter.is_valid(expr):
raise ValueError(f"非法 cron 表达式: {expr!r}(需标准 5 段,如 '0 8 * * *')")
def _tzinfo(tz: str):
if ZoneInfo is None:
return timezone.utc
try:
return ZoneInfo(tz or "Asia/Shanghai")
except Exception:
return ZoneInfo("Asia/Shanghai")
def compute_next_run(cron: str, tz: str, after: Optional[datetime] = None) -> datetime:
"""按墙钟时区算下一个触发点,返回 UTC-aware datetime。
croniter 保留 base tzinfo base 折算到 job 的本地时区再算,
'0 8 * * *' 就是该时区的早上 8 ,而非 UTC 8 (§8.5 时区坑)
"""
tzinfo = _tzinfo(tz)
base = (after or datetime.now(timezone.utc)).astimezone(tzinfo)
nxt = croniter(cron, base).get_next(datetime)
return nxt.astimezone(timezone.utc)
def _snapshot(job: ScheduledJob) -> dict[str, Any]:
"""把 ORM 行拍成普通 dict,脱离 session 给编排层用(避免跨线程 lazy-load)。"""
return {
"job_id": job.job_id,
"user_id": job.user_id,
"name": job.name,
"prompt": job.prompt,
"cron": job.cron,
"tz": job.tz,
"mode": job.mode,
"bound_task_id": job.bound_task_id,
"skill": job.skill or "",
"model_profile": job.model_profile or "",
"notify": job.notify,
"timeout_seconds": job.timeout_seconds or 0,
}
def claim_due_jobs(now: Optional[datetime] = None, limit: int = CLAIM_LIMIT) -> list[dict[str, Any]]:
"""认领到点 job:一把事务 SELECT due + 推进 next_run_at,返回快照列表。
- 到点判据:enabled AND deleted_at IS NULL AND next_run_at <= now
- 过期(expires_at <= now): enabled=False 跳过,不返回(安全界)
- 其余:next_run_at 推到下一个 cron (防重复触发),返回快照交编排层去跑
"""
now = now or datetime.now(timezone.utc)
claimed: list[dict[str, Any]] = []
with session_scope() as s:
rows = s.execute(
select(ScheduledJob)
.where(
ScheduledJob.enabled.is_(True),
ScheduledJob.deleted_at.is_(None),
ScheduledJob.next_run_at <= now,
)
.order_by(ScheduledJob.next_run_at)
.limit(limit)
.with_for_update(skip_locked=True)
).scalars().all()
for job in rows:
if job.expires_at is not None and job.expires_at <= now:
job.enabled = False
job.last_status = "expired"
continue
claimed.append(_snapshot(job))
try:
job.next_run_at = compute_next_run(job.cron, job.tz, after=now)
except Exception:
# cron 莫名失效(理论上 create 时已校验)→ 停掉别让它卡死循环
job.enabled = False
job.last_status = "error"
job.last_error = "cron 计算失败,已自动停用"
return claimed
def record_result(
job_id: UUID,
*,
status: str,
task_id: Optional[UUID],
error: Optional[str] = None,
) -> None:
"""run 跑完(或 skip)后回写 last_*。ok 重置连续失败计数;error 累加,
到阈值自动停用"""
now = datetime.now(timezone.utc)
with session_scope() as s:
job = s.get(ScheduledJob, job_id)
if job is None:
return
job.last_run_at = now
job.last_status = status
job.last_error = error
if task_id is not None:
job.last_task_id = task_id
if status == "ok":
job.run_count = (job.run_count or 0) + 1
job.consecutive_failures = 0
elif status == "error":
job.run_count = (job.run_count or 0) + 1
job.consecutive_failures = (job.consecutive_failures or 0) + 1
if job.consecutive_failures >= FAILURE_DISABLE_THRESHOLD:
job.enabled = False
job.last_error = (
f"连续失败 {job.consecutive_failures} 次,已自动停用。最后错误: {error}"
)
# status == "skipped"(persistent task 正忙)不动计数,下一 cron 点再来
def build_run_message(snapshot: dict[str, Any]) -> str:
"""把 job.prompt 包成一条带标记的用户消息喂进 agent。"""
when = datetime.now(_tzinfo(snapshot.get("tz") or "Asia/Shanghai")).strftime("%Y-%m-%d %H:%M")
name = snapshot.get("name") or "定时任务"
return (
f"[定时任务「{name}」自动触发 · {when}]\n\n"
f"{snapshot['prompt']}"
)
# ───────────── 第 3 层确定性兜底投递(notify) ─────────────
def _newest_artifact(working_dir: Path) -> Optional[Path]:
"""工作目录里最近修改的普通文件(跳过隐藏 / .preview 缓存)。"""
if not working_dir.is_dir():
return None
best: Optional[Path] = None
best_mtime = -1.0
for p in working_dir.rglob("*"):
if not p.is_file():
continue
if any(part.startswith(".") for part in p.relative_to(working_dir).parts):
continue
try:
m = p.stat().st_mtime
except OSError:
continue
if m > best_mtime:
best, best_mtime = p, m
return best
def _notify_email(to, job_name: str, when: str, artifact: Optional[Path]) -> None:
from tools.send_email import send_email_smtp # 延迟导入,避免 core→tools 顶层环依赖
if artifact is not None:
subject = f"[定时任务] {job_name} · {when}"
body = f"定时任务「{job_name}」已于 {when} 执行,产物见附件:{artifact.name}"
send_email_smtp(to, subject, body, [artifact])
else:
subject = f"[定时任务] {job_name} · {when}(无产物文件)"
body = f"定时任务「{job_name}」已于 {when} 执行,本次未产生文件产物。"
send_email_smtp(to, subject, body)
def deliver_notify(
notify: Optional[dict[str, Any]],
*,
job_name: str,
working_dir: Path,
tz: str,
user_id: Optional[Any] = None,
) -> None:
"""job 配了 notify 就确定性补发(不靠 agent 记性)。通道:
- `email`:把工作目录最新产物当附件发到 notify.to
- `wechat`:把最新产物 + 一句话主动推到该用户已绑微信(§8.7);未送达( 24h 窗口 /
未绑 / 未开口) notify 配了 `to`(邮箱)+ SMTP 退邮件兜底,否则抛错
阻塞 IO(smtplib / httpx),由编排层放进 run_in_executor 失败抛异常,编排层吞掉记日志
"""
if not notify:
return
channel = notify.get("channel")
when = datetime.now(_tzinfo(tz)).strftime("%Y-%m-%d %H:%M")
artifact = _newest_artifact(working_dir)
if channel == "email":
to = notify.get("to")
if to:
_notify_email(to, job_name, when, artifact)
return
if channel == "wechat":
if user_id is None:
return
from core.wechat.service import send_to_user # 延迟导入,避免顶层环依赖
from tools.send_email import smtp_configured
text = (f"定时任务「{job_name}」已于 {when} 执行"
+ (f",产物:{artifact.name}" if artifact else ",本次未产生文件产物。"))
report = send_to_user(user_id, text, str(artifact) if artifact else None)
if report.delivered:
return
fb = notify.get("to") # 可选 fallback 邮箱
if fb and smtp_configured():
_notify_email(fb, job_name, when, artifact)
return
raise RuntimeError("微信推送未送达: " + ", ".join(r.reason for r in report.results))
# ───────────── CRUD 服务层(对话工具 + REST 端点共用,DESIGN §8.5)─────────────
#
# tools/schedule.py(对话)与 web/app.py 的 /v1/schedules(前端只读+停用/删除)都调
# 这一层,避免两条创建路径逻辑漂移。函数内自管 session、返回可序列化 dict(脱离
# session,跨 web/工具线程安全);校验失败抛 JobError → 工具转 [Error] 行 / REST 转 4xx。
WEEKDAYS_CN = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
class JobError(ValueError):
"""job 校验 / 找不到 —— 工具转 [Error],REST 转 400/404。"""
def describe_cron(cron: str, tz: str) -> str:
"""常见 cron → 人话(给前端/工具回显);不认的样式退回原 cron 串。"""
parts = (cron or "").split()
if len(parts) != 5:
return cron
mi, ho, dom, mon, dow = parts
hhmm = None
if mi.isdigit() and ho.isdigit():
hhmm = f"{int(ho):02d}:{int(mi):02d}"
if hhmm and dom == "*" and mon == "*" and dow == "*":
return f"每天 {hhmm}"
if hhmm and dom == "*" and mon == "*" and dow.isdigit():
wd = int(dow) % 7 # cron 0/7=周日;映射到 WEEKDAYS_CN(0=周一)
idx = 6 if wd == 0 else wd - 1
return f"{WEEKDAYS_CN[idx]} {hhmm}"
if hhmm and dom.isdigit() and mon == "*" and dow == "*":
return f"每月 {int(dom)}{hhmm}"
if mi.startswith("*/") and ho == "*" and dom == "*":
return f"{mi[2:]} 分钟"
if mi.isdigit() and ho.startswith("*/") and dom == "*":
return f"{ho[2:]} 小时(第 {int(mi)} 分)"
return cron
def job_to_dict(job: ScheduledJob) -> dict[str, Any]:
"""ORM 行 → API/工具用的可序列化快照。"""
def _iso(dt: Optional[datetime]) -> Optional[str]:
if dt is None:
return None
return (dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)).isoformat()
return {
"job_id": str(job.job_id),
"short_id": str(job.job_id)[:8],
"name": job.name,
"prompt": job.prompt,
"cron": job.cron,
"schedule_desc": describe_cron(job.cron, job.tz),
"tz": job.tz,
"mode": job.mode,
"skill": job.skill or "",
"model_profile": job.model_profile or "",
"notify": job.notify,
"enabled": job.enabled,
"timeout_seconds": job.timeout_seconds or 0,
"next_run_at": _iso(job.next_run_at),
"last_run_at": _iso(job.last_run_at),
"last_status": job.last_status,
"last_error": job.last_error,
"last_task_id": str(job.last_task_id) if job.last_task_id else None,
"consecutive_failures": job.consecutive_failures or 0,
"run_count": job.run_count or 0,
"expires_at": _iso(job.expires_at),
"created_at": _iso(job.created_at),
}
def _validate_tz(tz: str) -> str:
tz = (tz or "Asia/Shanghai").strip() or "Asia/Shanghai"
if ZoneInfo is not None:
try:
ZoneInfo(tz)
except Exception:
raise JobError(f"未知时区: {tz!r}(用 IANA 名,如 'Asia/Shanghai')")
return tz
def list_jobs(user_id: UUID) -> list[dict[str, Any]]:
with session_scope() as s:
rows = s.execute(
select(ScheduledJob)
.where(ScheduledJob.user_id == user_id, ScheduledJob.deleted_at.is_(None))
.order_by(ScheduledJob.created_at.desc())
).scalars().all()
return [job_to_dict(j) for j in rows]
def create_job(
user_id: UUID,
*,
name: str,
prompt: str,
cron: str,
tz: str = "Asia/Shanghai",
mode: str = "isolated",
skill: str = "",
notify: Optional[dict[str, Any]] = None,
model_profile: str = "",
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
) -> dict[str, Any]:
name = (name or "").strip()
prompt = (prompt or "").strip()
cron = (cron or "").strip()
mode = (mode or "isolated").strip().lower()
if not name:
raise JobError("name 不能为空")
if not prompt:
raise JobError("prompt 不能为空")
if mode not in _MODES:
raise JobError(f"mode 必须是 {_MODES} 之一")
validate_cron(cron)
tz = _validate_tz(tz)
next_run = compute_next_run(cron, tz)
job = ScheduledJob(
job_id=uuid4(), user_id=user_id, name=name, prompt=prompt, cron=cron, tz=tz,
mode=mode, skill=(skill or "").strip(), notify=notify,
model_profile=(model_profile or "").strip(),
timeout_seconds=int(timeout_seconds or 0), next_run_at=next_run,
)
with session_scope() as s:
s.add(job)
s.flush()
return job_to_dict(job)
def _resolve(s, user_id: UUID, id_str: str) -> ScheduledJob:
"""按完整 UUID 或短 id 前缀定位当前用户的 job;0 或多匹配抛 JobError。"""
jid = (id_str or "").strip()
if not jid:
raise JobError("job_id 不能为空")
rows = s.execute(
select(ScheduledJob).where(
ScheduledJob.user_id == user_id, ScheduledJob.deleted_at.is_(None)
)
).scalars().all()
matches = [j for j in rows if str(j.job_id) == jid or str(j.job_id).startswith(jid)]
if not matches:
raise JobError(f"没找到 id 以 {jid!r} 开头的定时任务")
if len(matches) > 1:
ids = ", ".join(str(j.job_id)[:8] for j in matches)
raise JobError(f"id 前缀 {jid!r} 匹配到多个({ids}),请用更长的 id")
return matches[0]
_EDITABLE = {"name", "prompt", "cron", "tz", "mode", "skill", "notify", "model_profile",
"timeout_seconds", "enabled"}
def update_job(user_id: UUID, id_str: str, **fields: Any) -> dict[str, Any]:
"""改 job 的任意可编辑字段;改了 cron/tz 则重算 next_run_at。"""
fields = {k: v for k, v in fields.items() if k in _EDITABLE and v is not None}
if not fields:
raise JobError("没有可更新的字段")
if "mode" in fields:
fields["mode"] = str(fields["mode"]).strip().lower()
if fields["mode"] not in _MODES:
raise JobError(f"mode 必须是 {_MODES} 之一")
if "cron" in fields:
validate_cron(str(fields["cron"]).strip())
fields["cron"] = str(fields["cron"]).strip()
if "tz" in fields:
fields["tz"] = _validate_tz(str(fields["tz"]))
with session_scope() as s:
job = _resolve(s, user_id, id_str)
for k, v in fields.items():
setattr(job, k, v)
if "cron" in fields or "tz" in fields:
job.next_run_at = compute_next_run(job.cron, job.tz)
# 重新启用 / 改了排程 → 清零连续失败计数,给它干净的重新开始
if fields.get("enabled") is True or "cron" in fields:
job.consecutive_failures = 0
s.flush()
return job_to_dict(job)
def set_enabled(user_id: UUID, id_str: str, enabled: bool) -> dict[str, Any]:
return update_job(user_id, id_str, enabled=bool(enabled))
def cancel_job(user_id: UUID, id_str: str) -> dict[str, Any]:
"""软删(deleted_at + enabled=False)。"""
with session_scope() as s:
job = _resolve(s, user_id, id_str)
job.deleted_at = datetime.now(timezone.utc)
job.enabled = False
s.flush()
return job_to_dict(job)

View File

@ -15,7 +15,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional
from uuid import UUID
from sqlalchemy import delete, select
from sqlalchemy import delete, func, select
from .storage import session_scope
from .storage.models import Message, Task
@ -116,17 +116,30 @@ class Session:
task_id DB 不存在,返回空 Session(messages 只含 system,_db_idx=0);
调用方判断该不该报错
只把 idx >= tasks.context_base_idx 的消息装进 LLM 上下文(channel 长会话软重置,
0019)base 之前的历史仍全量留 messages (web `/messages` gate,照旧翻得到)
**关键**:`_db_idx` 必须取 DB 真实总条数(下一条 append idx),不能用 len(rows)
否则下次 append 会复用已存在的 idx, uq_messages_task_idx / 覆盖历史
"""
sess = cls(task_id=task_id, system_prompt=system_prompt, meta=meta)
with session_scope() as s:
base = s.execute(
select(Task.context_base_idx).where(Task.task_id == task_id)
).scalar_one_or_none() or 0
rows = s.execute(
select(Message)
.where(Message.task_id == task_id)
.where(Message.task_id == task_id, Message.idx >= base)
.order_by(Message.idx)
).scalars().all()
for row in rows:
sess.messages.append(dict(row.payload))
sess._db_idx = len(rows)
# 真实总条数(含 base 之前的归档历史),保证 append 续号不撞 idx。
sess._db_idx = s.execute(
select(func.count())
.select_from(Message)
.where(Message.task_id == task_id)
).scalar_one()
return sess
@classmethod

103
core/shortcuts.py Normal file
View File

@ -0,0 +1,103 @@
"""用户快捷指令(触发词 → 完整指令)。渠道无关,入口层确定性展开。
存储:`workspace/users/<user_id>/.memory/shortcuts.md` memory per-user 存储壳
(同一 workspace 内按 user_id 隔离,agent 已有该目录写权限),** memory 是两种机制**:
- memory 是注进 system prompt给模型**参考**的软上下文(概率召回)
- 快捷指令**不进上下文**:展开发生在入口层模型跑之前 每条入站消息先经 `expand()`
查表,整条精确命中触发词就把文本替换成完整指令再跑 agent所以存再多条,平时上下文也是 0;
触发时进上下文的就是那条完整指令本身(= 用户本来要打的字),无额外 token
维护(agent 自管, memory):用户在对话里说"记个快捷词:X → Y",模型往 shortcuts.md 写一行
(memory 契约里加了一句告诉它格式);触发不靠模型,靠本模块解析,确定零歧义
格式(markdown 两列表,容错解析;表头/分隔行自动跳过):
| 触发词 | 指令 |
|---|---|
| 简报 | 给我输出一份昨日的 AI 新闻简报 |
匹配语义:整条消息 `strip()` + `casefold()` 后与某触发词**精确相等**才展开;
"帮我出个简报" 不命中(当普通消息走)新话题魔法命令同风格,零误伤
触发词含 `|` 会破坏表格解析 约定触发词不含竖线;指令正文含竖线也会被截断,同样避免
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Dict, Optional, Tuple
from uuid import UUID
# 表头行的触发词(解析时跳过,避免把表头当成一条快捷词)
_HEADER_TRIGGERS = {"触发词", "触发", "快捷词", "快捷指令", "命令", "trigger", "shortcut"}
# markdown 表格分隔行的单元格:`---` / `:--` / `:-:` 之类
_SEP_RE = re.compile(r"^:?-+:?$")
def _shortcuts_file(workspace_dir: Path, user_id: UUID) -> Path:
return workspace_dir / "users" / str(user_id) / ".memory" / "shortcuts.md"
def _normalize(s: str) -> str:
return s.strip().casefold()
def _is_separator(cell: str) -> bool:
return bool(_SEP_RE.match(cell.replace(" ", "")))
def parse_shortcuts(text: str) -> Dict[str, str]:
"""解析 shortcuts.md 文本 → {归一化触发词: 完整指令}。纯函数,可测。
容错:只认以 `|` 起头的表格行;跳过分隔行表头行空单元格行;
触发词重复时**先出现者赢**(首行优先,和人读顺序一致)
"""
mapping: Dict[str, str] = {}
for raw in text.splitlines():
line = raw.strip()
if not line.startswith("|"):
continue
cells = [c.strip() for c in line.strip("|").split("|")]
if len(cells) < 2:
continue
trigger, prompt = cells[0], cells[1]
if not trigger or not prompt:
continue
if _is_separator(trigger) and _is_separator(prompt):
continue # 分隔行 |---|---|
key = _normalize(trigger)
if not key or key in _HEADER_TRIGGERS:
continue # 空或表头
mapping.setdefault(key, prompt) # 首行优先
return mapping
def load_shortcuts(workspace_dir: Path, user_id: UUID) -> Dict[str, str]:
"""读该用户 shortcuts.md 并解析;文件不存在 / 读失败 → 空表(不抛,不挡入站)。"""
p = _shortcuts_file(workspace_dir, user_id)
if not p.is_file():
return {}
try:
return parse_shortcuts(p.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError):
return {}
def expand(
workspace_dir: Path, user_id: UUID, text: str
) -> Tuple[str, Optional[str]]:
"""入口层展开:整条 `text` 精确命中某触发词 → 返回 (完整指令, 命中的触发词原文);
未命中 返回 (text 原样, None)空文本直接原样返回
调用点:渠道核心 `_run_channel_conversation` + 网页 `post_message`,共用此函数,
保证任何入口打同一个触发词行为一致
"""
if not text or not text.strip():
return text, None
mapping = load_shortcuts(workspace_dir, user_id)
if not mapping:
return text, None
prompt = mapping.get(_normalize(text))
if prompt is None:
return text, None
return prompt, text.strip()

View File

@ -1,15 +1,19 @@
"""Skill 注册表 (Anthropic 标准格式)。
每个 skill skills/<name>/ 目录,内含 SKILL.md( frontmatter)+ 可选的
每个 skill <root>/<name>/ 目录,内含 SKILL.md( frontmatter)+ 可选的
references/scripts/assets/启动时只读 frontmatter discovery,完整 SKILL.md
references agent 按需加载(渐进披露)
多来源:内置 skill(`ROOT/skills`,只读)+ 用户 skill(`user_root/.skills`,可写)
来源按顺序扫,**后扫的同名覆盖先扫的** 用户 skill 排在内置之后,"用户覆盖
内置"(user wins);覆盖关系记进 `user_overrides` 供 discovery 显式标注,不静默。
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Optional, Tuple
from typing import Dict, List, Optional, Tuple, Union
import yaml
@ -18,7 +22,11 @@ _FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n?", re.DOTALL)
def parse_frontmatter(text: str) -> Tuple[dict, str]:
"""解析 markdown 顶部的 YAML frontmatter。返回 (meta, body)。"""
"""解析 markdown 顶部的 YAML frontmatter。返回 (meta, body)。
frontmatter YAML 非法时抛 `yaml.YAMLError`( `SkillRegistry._scan` 捕获记进
load_errors 用户手写 skill 易踩,不能让一个坏 skill 崩掉整次扫描)
"""
m = _FRONTMATTER_RE.match(text)
if not m:
return {}, text
@ -28,11 +36,20 @@ def parse_frontmatter(text: str) -> Tuple[dict, str]:
return meta, text[m.end():]
class SkillLoadError(Exception):
"""skill 目录有 SKILL.md 但加载失败(YAML 坏 / 缺 description 等)。
"没有 SKILL.md(根本不是 skill 目录,静默跳过)"区分:前者要面向用户报,
后者是正常的非 skill 子目录
"""
@dataclass
class Skill:
name: str
description: str
skill_dir: Path
source: str = "builtin" # 'builtin' | 'user'
@property
def skill_md(self) -> Path:
@ -42,40 +59,110 @@ class Skill:
return self.skill_md.read_text(encoding="utf-8")
@classmethod
def from_dir(cls, skill_dir: Path) -> Optional["Skill"]:
def from_dir(cls, skill_dir: Path, source: str = "builtin") -> Optional["Skill"]:
"""加载一个 skill 目录。
SKILL.md 返回 None(静默跳过,不是 skill 目录);
SKILL.md 但格式错(YAML / description) SkillLoadError
"""
md = skill_dir / "SKILL.md"
if not md.exists():
return None
meta, _ = parse_frontmatter(md.read_text(encoding="utf-8"))
return None # 不是 skill 目录,静默跳过
try:
text = md.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as e:
raise SkillLoadError(f"读不出 SKILL.md: {e}")
try:
meta, _ = parse_frontmatter(text)
except yaml.YAMLError as e:
raise SkillLoadError(f"frontmatter YAML 非法: {e}")
name = meta.get("name") or skill_dir.name
desc = meta.get("description") or ""
if not desc:
return None # description 是 discovery 的关键,缺了不收
return cls(name=name, description=desc, skill_dir=skill_dir)
raise SkillLoadError("缺 description(frontmatter 必须有 name + description)")
return cls(name=name, description=desc, skill_dir=skill_dir, source=source)
@dataclass
class SkillSource:
"""一个 skill 搜索来源。
container_root: docker backend 下该来源在容器内的挂载前缀
(内置 `/sandbox/skills`,用户 `/workspace/.skills`);None = host backend,
LoadSkillTool 退回 host 绝对路径
"""
root: Path
source: str = "builtin"
container_root: Optional[str] = None
SourcesArg = Union[Path, str, SkillSource, List[SkillSource]]
class SkillRegistry:
def __init__(self, skills_dir: Path) -> None:
self.skills_dir = Path(skills_dir)
def __init__(self, sources: SourcesArg) -> None:
# 单个 Path/str → 包成单一 builtin 来源(向后兼容直接传目录的调用 / 测试)
if isinstance(sources, (str, Path)):
sources = [SkillSource(Path(sources), "builtin")]
elif isinstance(sources, SkillSource):
sources = [sources]
self.sources: List[SkillSource] = list(sources)
self.skills: Dict[str, Skill] = {}
# 用户 skill 覆盖了内置 skill 的 name 集合 —— discovery 显式标注,覆盖不静默
self.user_overrides: set[str] = set()
# 加载失败的用户 skill:(目录名, 原因)。内置 skill 失败是 dev bug,不进此列
# (不面向终端用户报),由测试 / 启动日志兜底
self.load_errors: List[Tuple[str, str]] = []
self._container_roots: Dict[str, Optional[str]] = {}
self._scan()
def _scan(self) -> None:
if not self.skills_dir.exists():
return
for child in sorted(self.skills_dir.iterdir()):
for src in self.sources:
self._container_roots[src.source] = src.container_root
if not src.root.exists():
continue # 用户没有 .skills 目录 → 一次 exists() 跳过,零成本
for child in sorted(src.root.iterdir()):
if not child.is_dir():
continue
skill = Skill.from_dir(child)
if skill is not None:
self.skills[skill.name] = skill
try:
skill = Skill.from_dir(child, source=src.source)
except SkillLoadError as e:
if src.source == "user":
self.load_errors.append((child.name, str(e)))
continue
if skill is None:
continue
prev = self.skills.get(skill.name)
if prev is not None and prev.source != skill.source and skill.source == "user":
self.user_overrides.add(skill.name) # 用户覆盖了内置
self.skills[skill.name] = skill # 后扫覆盖先扫 → user wins
def discovery_block(self) -> str:
"""启动时注入 system prompt 的 skill 列表(name + description)。"""
if not self.skills:
"""注入 system prompt 的 skill 列表(name + description + 来源标注)。"""
if not self.skills and not self.load_errors:
return ""
lines = [f"- **{s.name}**: {s.description}" for s in self.skills.values()]
return "\n".join(lines)
lines = []
for s in self.skills.values():
if s.source == "user":
tag = " [你的·已覆盖内置]" if s.name in self.user_overrides else " [你的]"
else:
tag = ""
lines.append(f"- **{s.name}**{tag}: {s.description}")
block = "\n".join(lines)
if self.load_errors:
errs = "; ".join(f"`{n}`({why})" for n, why in self.load_errors)
block += (
"\n\n> ⚠️ 你有用户 skill 因格式问题未加载,需要时提醒用户修好 frontmatter"
f"(修好后下条消息生效):{errs}"
)
return block
def container_dir(self, skill: Skill) -> Optional[str]:
"""docker 下该 skill 在容器内的目录;host backend → None(调用方退回 host 绝对路径)。"""
root = self._container_roots.get(skill.source)
if not root:
return None
return f"{root.rstrip('/')}/{skill.name}"
def get(self, name: str) -> Optional[Skill]:
return self.skills.get(name)

View File

@ -21,6 +21,7 @@ from uuid import UUID, uuid4
from sqlalchemy import (
BigInteger,
Boolean,
DateTime,
ForeignKey,
Integer,
@ -45,6 +46,14 @@ class User(Base):
oidc_subject: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
password_hash: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
plan: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# 0016:平台登录注入的用户档案。name=显示名/姓名,user_name=平台账号名;均 nullable
# (platform_key 入口 ensure_user_row upsert 写;邮箱密码 / 历史行留空)。未来 OIDC
# 接管时由 ID token 的 name / preferred_username claim 注入,数据流不变。
name: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
user_name: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# 0009:访问角色。'user'(默认)/ 'admin';仅 admin 可访问 /v1/admin/* 管理端点。
# 提管理员:main.py user role --email X --role admin。
role: Mapped[str] = mapped_column(Text, nullable=False, server_default="user")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
@ -61,6 +70,9 @@ class Task(Base):
working_dir: Mapped[str] = mapped_column(Text, nullable=False)
skill: Mapped[str] = mapped_column(Text, nullable=False, default="")
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
# 渠道来源(0011):web=网页端常规任务 / wechat=微信 ClawBot 常驻对话。
# 仅 INSERT 时由建 task 方写定,后续 upsert/save 不传 → 不覆盖。前端据此打徽章 + 置顶。
channel: Mapped[str] = mapped_column(Text, nullable=False, default="web", server_default="web")
status: Mapped[str] = mapped_column(Text, nullable=False, default="active")
model: Mapped[str] = mapped_column(Text, nullable=False, default="")
model_profile: Mapped[str] = mapped_column(Text, nullable=False, default="")
@ -73,12 +85,31 @@ class Task(Base):
# 只有 error 是持久终态(下次起新 run 时由 post_message 清掉)
run_status: Mapped[str] = mapped_column(Text, nullable=False, default="idle")
run_error: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# 喂给模型的上下文窗口起点(0019,channel 长会话软重置)。Session.load 只把 idx >=
# context_base_idx 的消息装进 LLM 上下文;之前的历史仍全量留 messages 表(web 翻得到)。
# web 普通任务恒 0 = 喂全量;channel 入站按 gap / 「新话题」推进。详 DESIGN §8.7。
context_base_idx: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0"
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)
# 软删除标记(0010):置时间即"逻辑删除",从列表隐藏但 DB 行 / messages / usage_events /
# 工作目录文件全部保留(留作语料 + 可恢复)。NULL = 未删。物理删只在管理员清理时走。
deleted_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True
)
# 定时任务执行归属(0017):非 NULL = 该 task 是某 scheduled_job 的一次执行(isolated
# 每次新建 / persistent 首次新建都填)。普通对话列表据此排除,不混进"用户项目"列表;
# crons 页可按 job 反查执行历史。job 走软删不硬删 → ondelete SET NULL 安全。
scheduled_job_id: Mapped[Optional[UUID]] = mapped_column(
PG_UUID(as_uuid=True),
ForeignKey("scheduled_jobs.job_id", ondelete="SET NULL"),
nullable=True,
)
class Message(Base):
@ -98,6 +129,10 @@ class Message(Base):
# 0006:产生该 message 的模型(只在 assistant 行有值;user/tool/system 为 NULL)。
# 跟 usage_events.model_profile 写入一致,JOIN-free 时按 message 直查也能拿到。
model_profile: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# 消息来源(0018):NULL=agent run 产生;"push"=push 记录(_record_push_to_chat 写)。
# extract_last_assistant_text 据此跳过 push 记录,避免误取当入站回复。独立列不进 payload,
# 不影响 agent 上下文 / LLM API。
kind: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
@ -163,3 +198,89 @@ class UserDiskUsage(Base):
)
class ScheduledJob(Base):
"""定时任务(0011,DESIGN §8.5)。
一行 = 一个"到点把 prompt 喂进 agent 主管线"的计划本体 = cron+tz(何时)
+ prompt(做什么)+ mode(跑在哪);"发邮件"不是字段, agent prompt
send_email 的动作 notify(可空 JSONB)"必达某邮箱"留确定性兜底
守护循环(web/app.py lifespan `_scheduler_loop`,仿 _disk_scanner) ~30s
`enabled AND deleted_at IS NULL AND next_run_at<=now()`,命中即复用 _run_agent_bg
run,跑完回写 last_* + croniter next_run_atmode:
- isolated(默认):每次新建临时 task,只带本 job prompt,不继承历史 token
- persistent:绑定 bound_task_id 常驻 task,追加消息有跨天连续性
"""
__tablename__ = "scheduled_jobs"
job_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid4)
user_id: Mapped[UUID] = mapped_column(
PG_UUID(as_uuid=True), ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(Text, nullable=False)
prompt: Mapped[str] = mapped_column(Text, nullable=False)
cron: Mapped[str] = mapped_column(Text, nullable=False) # 标准 5 段 cron
tz: Mapped[str] = mapped_column(Text, nullable=False, server_default="Asia/Shanghai")
mode: Mapped[str] = mapped_column(Text, nullable=False, server_default="isolated") # isolated|persistent
# persistent 模式绑定的常驻 task;task 软删/物理删后 SET NULL(下次触发当 isolated 兜底)
bound_task_id: Mapped[Optional[UUID]] = mapped_column(
PG_UUID(as_uuid=True), ForeignKey("tasks.task_id", ondelete="SET NULL"), nullable=True
)
skill: Mapped[str] = mapped_column(Text, nullable=False, server_default="") # 可选预载 skill
model_profile: Mapped[str] = mapped_column(Text, nullable=False, server_default="") # 可选模型覆盖
# 第 3 层可靠投递:{"channel":"email","to":"a@b.com"};NULL=不兜底(走 prompt 驱动/线程未读)
notify: Mapped[Optional[dict[str, Any]]] = mapped_column(JSONB, nullable=True)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true")
timeout_seconds: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0") # 0=不限
next_run_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
last_run_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
last_status: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # ok|error|skipped
last_error: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
last_task_id: Mapped[Optional[UUID]] = mapped_column(PG_UUID(as_uuid=True), nullable=True)
consecutive_failures: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0")
run_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0")
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
deleted_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True
)
class ChannelBinding(Base):
"""微信渠道绑定(0015,DESIGN §8.7 渠道抽象)。
一行 = 一个用户在某渠道(`channel`)的一份绑定配置;PK=(user_id, channel) 1 用户每渠道 1
沿用本库判别列 + JSONB 多态范式( usage_events.kind+units / scheduled_jobs.notify):
各渠道配置字段不同,全装进 `config` JSONB,加渠道不动 schema不再各建一表
config 形态(敏感字段经 core/wechat/crypto.py 加密入 JSONB,绝不进沙箱/日志/API):
- channel='clawbot':{bot_token*, bot_im_id, user_im_id, base_url, latest_context_token*,
context_token_at(iso), chat_task_id(str)} *=密文;context_token 24h 窗口主动推靠它
- channel='wecom':{wecom_userid, chat_task_id(str)} wecom_userid 企业成员 id,
非密钥明文,无条件推 + 回调反查身份;chat_task_id 企业微信入站对话常驻 task
chat_task_id/FKper-字段 NOT NULL 退到应用层校验, usage_events JSONB 同向取舍
"""
__tablename__ = "channel_bindings"
user_id: Mapped[UUID] = mapped_column(
PG_UUID(as_uuid=True),
ForeignKey("users.user_id", ondelete="CASCADE"),
primary_key=True,
)
channel: Mapped[str] = mapped_column(Text, primary_key=True) # clawbot | wecom | ...
status: Mapped[str] = mapped_column(Text, nullable=False, server_default="active") # active|revoked
config: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)

View File

@ -249,6 +249,54 @@ def record_video_usage(
return cost_cny
def record_vision_usage(
*,
task_id: UUID,
user_id: UUID,
model_profile: str,
prompt_tokens: int,
completion_tokens: int,
input_cny_per_mtoken: float,
output_cny_per_mtoken: float,
extra_units: Optional[Mapping[str, Any]] = None,
) -> Decimal:
"""记一次图像理解(看图 / OCR):写 usage_events(kind=vision)。
vision token 计费( chat,不是 per-call),`cost_cny = in*in_price/1e6 + out*out_price/1e6`
tokens caller ARK /chat/completions 响应的 usage 字段取(没有则传 0,cost=0 不阻塞)
单价(CNY/Mtok)同步 snapshot units jsonb,日后调价不影响历史对账
`model_profile` 形如 `"doubao.seed_2_lite"`(family.variant 风格, chat/image 对齐)
**失败任务不要走这里** ARK 失败不计费,失败的 tool 调用直接返 [Error] 不写 usage
"""
inp = Decimal(str(input_cny_per_mtoken or 0))
out = Decimal(str(output_cny_per_mtoken or 0))
cost_cny = (
Decimal(str(int(prompt_tokens))) * inp / Decimal("1000000")
+ Decimal(str(int(completion_tokens))) * out / Decimal("1000000")
).quantize(Decimal("0.000001"))
units: dict[str, Any] = {
"tokens_in": int(prompt_tokens),
"tokens_out": int(completion_tokens),
"input_cny_per_mtoken": float(input_cny_per_mtoken or 0),
"output_cny_per_mtoken": float(output_cny_per_mtoken or 0),
}
if extra_units:
units.update(extra_units)
with session_scope() as s:
s.add(UsageEvent(
user_id=user_id,
task_id=task_id,
message_id=None, # vision tool 在 tool execute 时调用,message 还未落库
kind="vision",
model_profile=model_profile,
units=units,
cost_cny=cost_cny,
))
return cost_cny
def check_daily_quota(*, user_id: UUID, kind: str, limit: int) -> tuple[int, bool]:
"""每账号每日 kind=image/video 调用配额检查。返回 (今日已用次数, 是否超额)。

View File

@ -6,9 +6,10 @@ from uuid import UUID
from sqlalchemy import func, select, update
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.exc import IntegrityError
from .engine import session_scope
from .models import Task
from .models import Message, Task
class NoSubtaskError(ValueError):
@ -25,6 +26,8 @@ def ensure_local_task_row(
model: str = "",
model_profile: str = "",
reasoning_effort: str = "",
channel: str = "web",
scheduled_job_id: Optional[UUID] = None,
) -> None:
"""占位 INSERT(ON CONFLICT DO NOTHING)—— 不覆盖已有字段。
@ -45,6 +48,8 @@ def ensure_local_task_row(
model=model,
model_profile=model_profile,
reasoning_effort=reasoning_effort,
channel=channel,
scheduled_job_id=scheduled_job_id,
)
.on_conflict_do_nothing(index_elements=["task_id"])
)
@ -52,6 +57,31 @@ def ensure_local_task_row(
s.execute(stmt)
def append_channel_message(
task_id: UUID, content: str, *, role: str = "assistant", kind: Optional[str] = None
) -> None:
"""往 task 追加一条非 agent-run 产生的消息(push 出站记录等)。原子算 idx
(SELECT max(idx)+1)+INSERT; uq_messages_task_idx(与入站 agent run 并发
append) 重试payload 形态同 Session.append {role, content};不设
model_profile / tokens_*(非模型产出,usage 不计)kind messages.kind
(独立列,不进 payload):"push" 标记 push 记录,extract_last_assistant_text 据此跳过"""
payload = {"role": role, "content": content}
last_err: Optional[Exception] = None
for _ in range(3):
try:
with session_scope() as s:
max_idx = s.execute(
select(func.max(Message.idx)).where(Message.task_id == task_id)
).scalar()
next_idx = (max_idx if max_idx is not None else -1) + 1
s.add(Message(task_id=task_id, idx=next_idx, payload=payload, kind=kind))
return
except IntegrityError as e:
last_err = e
continue
raise RuntimeError(f"append_channel_message: idx 冲突重试耗尽: {last_err}")
def upsert_task(
task_id: UUID,
*,

6
core/wechat/__init__.py Normal file
View File

@ -0,0 +1,6 @@
"""微信接入(DESIGN §8.7)。
渠道 A = ClawBot 个人微信 iLink Bot API(`ilink.py`,协议已真机实测,
`scripts/probe_clawbot*.py`);渠道 B = 企业微信自建应用(后续 `wecom.py`)
本包只放协议客户端等纯逻辑, DB / agent 编排解耦
"""

59
core/wechat/crypto.py Normal file
View File

@ -0,0 +1,59 @@
"""敏感凭据的列加密(DESIGN §8.7:bot_token / latest_context_token 加密入库)。
- env `ZCBOT_WECHAT_SECRET_KEY` 用其派生的 Fernet 密钥加密,密文带 `v1:` 前缀
- env 不在 退明文标记`plain:`(公测兜底,日志/沙箱/API 仍绝不带这两列;
正式部署应配 key)`enc()`/`dec()` 对两种前缀都可逆, key 不影响存量明文行
只在 host 进程(绑定服务 / 入站管理器 / push);绝不进沙箱 / run_python
"""
from __future__ import annotations
import base64
import hashlib
import os
from typing import Optional
from cryptography.fernet import Fernet, InvalidToken
_PREFIX_ENC = "v1:"
_PREFIX_PLAIN = "plain:"
def _fernet() -> Optional[Fernet]:
key = os.getenv("ZCBOT_WECHAT_SECRET_KEY", "").strip()
if not key:
return None
# 任意口令 → 32B → urlsafe-base64 Fernet 密钥(确定性,免单独管 Fernet key)
digest = hashlib.sha256(key.encode("utf-8")).digest()
return Fernet(base64.urlsafe_b64encode(digest))
def enc(plaintext: Optional[str]) -> Optional[str]:
"""明文 → 入库串。配了 key 走密文(v1:),否则明文标记(plain:)。None 透传。"""
if plaintext is None:
return None
f = _fernet()
if f is None:
return _PREFIX_PLAIN + plaintext
token = f.encrypt(plaintext.encode("utf-8")).decode("ascii")
return _PREFIX_ENC + token
def dec(stored: Optional[str]) -> Optional[str]:
"""入库串 → 明文。识别 v1:/plain: 前缀;v1: 需 key 且匹配。None 透传。"""
if stored is None:
return None
if stored.startswith(_PREFIX_PLAIN):
return stored[len(_PREFIX_PLAIN):]
if stored.startswith(_PREFIX_ENC):
f = _fernet()
if f is None:
raise RuntimeError(
"密文需要 ZCBOT_WECHAT_SECRET_KEY 才能解密,但 env 未配置"
)
try:
return f.decrypt(stored[len(_PREFIX_ENC):].encode("ascii")).decode("utf-8")
except InvalidToken as e:
raise RuntimeError("ZCBOT_WECHAT_SECRET_KEY 与密文不匹配(key 变了?)") from e
# 无前缀:历史/手填的裸明文,容错原样返回
return stored

411
core/wechat/ilink.py Normal file
View File

@ -0,0 +1,411 @@
"""ClawBot 个人微信 iLink Bot API 客户端(DESIGN §8.7 渠道 A)。
协议全部经真机实测(`scripts/probe_clawbot*.py`,2026-06-23):
- 绑定:`get_bot_qrcode`(无凭据,出深链 自渲二维码) 轮询 `get_qrcode_status`
(TTL ~1min,过期换码) `confirmed` `bot_token` + `baseurl`
- :`getupdates` 长轮询(hold 35s),消息带 `from_user_id` + `context_token`
- :`sendmessage`,**每条 `client_id` 必唯一**(漏则同 token 后续被丢);多条/长文
~1000 字分块,中间 `message_state=GENERATING(1)`末块 `FINISH(2)`,间隔 ~300ms
- `context_token` 有效期 ~24h可复用 主动推送靠它(用户须先开口拿到 token)
- 文件:`getuploadurl` AES-128-ECB(PKCS7)加密 POST 密文到 CDN `x-encrypted-param`
`sendmessage` `file_item`
纯协议客户端,不碰 DB / agent 编排阻塞 IO(httpx 同步),调用方放 to_thread / executor
"""
from __future__ import annotations
import base64
import hashlib
import os
import time
import uuid
from dataclasses import dataclass, field
from typing import Any, Optional
from urllib.parse import quote
import httpx
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
DEFAULT_BASE = "https://ilinkai.weixin.qq.com"
CDN_BASE = "https://novac2c.cdn.weixin.qq.com/c2c"
CHANNEL_VERSION = "1.0.2"
BOT_TYPE_PERSONAL = 3
# 协议枚举(源码 @tencent-weixin/openclaw-weixin src/api/types.ts,已实测)
MSG_TYPE_BOT = 2
STATE_GENERATING = 1
STATE_FINISH = 2
ITEM_TEXT = 1
ITEM_IMAGE = 2
ITEM_FILE = 4
UPLOAD_MEDIA_FILE = 3
UPLOAD_MEDIA_IMAGE = 1
# 分块:长文按 ~1000 字切,块间隔防丢
CHUNK_CHARS = 1000
CHUNK_DELAY_S = 0.3
MAX_FILE_BYTES = 20 * 1024 * 1024
def _uin_header() -> str:
"""X-WECHAT-UIN:base64(随机 uint32 的十进制字符串),反重放,每请求变。"""
n = int.from_bytes(os.urandom(4), "big")
return base64.b64encode(str(n).encode()).decode()
def _headers(bot_token: Optional[str] = None) -> dict[str, str]:
h = {
"Content-Type": "application/json",
"AuthorizationType": "ilink_bot_token",
"X-WECHAT-UIN": _uin_header(),
}
if bot_token:
h["Authorization"] = f"Bearer {bot_token}"
return h
def _base_info() -> dict[str, str]:
return {"channel_version": CHANNEL_VERSION}
def _new_client_id() -> str:
return f"openclaw-weixin-{uuid.uuid4().hex}"
def _aes_ecb_pkcs7(plaintext: bytes, key: bytes) -> bytes:
padder = padding.PKCS7(128).padder()
padded = padder.update(plaintext) + padder.finalize()
enc = Cipher(algorithms.AES(key), modes.ECB()).encryptor()
return enc.update(padded) + enc.finalize()
def _aes_ecb_unpkcs7(ciphertext: bytes, key: bytes) -> bytes:
"""收图/收文件的解密:AES-128-ECB 解 + 去 PKCS7(发送侧 `_aes_ecb_pkcs7` 的逆)。"""
dec = Cipher(algorithms.AES(key), modes.ECB()).decryptor()
padded = dec.update(ciphertext) + dec.finalize()
unpadder = padding.PKCS7(128).unpadder()
return unpadder.update(padded) + unpadder.finalize()
def _decode_media_aes_key(raw: str) -> bytes:
"""媒体 `media.aes_key` → 16 字节 AES key。两种实测编码兜住:
- `base64(raw 16 bytes)`(图片常见) 解码得 16 字节直用;
- `base64(hex 字符串)`(文件/语音/视频,发送侧 `_upload_file` 也用这种) 解码得
32 ASCII hex 字符, `fromhex` 16 字节
"""
dec = base64.b64decode(raw)
if len(dec) == 16:
return dec
if len(dec) == 32:
try:
return bytes.fromhex(dec.decode("ascii"))
except (ValueError, UnicodeDecodeError):
return dec[:16]
return dec[:16]
def _guess_image_ext(data: bytes) -> str:
"""按 magic bytes 猜图片扩展名(微信入站图片无原文件名)。认不出回退 .jpg。"""
if data[:3] == b"\xff\xd8\xff":
return ".jpg"
if data[:8] == b"\x89PNG\r\n\x1a\n":
return ".png"
if data[:6] in (b"GIF87a", b"GIF89a"):
return ".gif"
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return ".webp"
if data[:2] == b"BM":
return ".bmp"
return ".jpg"
# ─────────────────────────── 绑定(无 token)───────────────────────────
@dataclass
class QrCode:
qrcode_id: str
deeplink: str # liteapp.weixin.qq.com/q/...,调用方自渲成二维码图片
def get_bot_qrcode(base_url: str = DEFAULT_BASE, *, timeout: float = 20.0) -> QrCode:
"""取一张绑定二维码。无需任何预置凭据。`deeplink` 需自渲成二维码让用户扫。"""
with httpx.Client(timeout=timeout) as c:
r = c.get(
f"{base_url}/ilink/bot/get_bot_qrcode",
params={"bot_type": BOT_TYPE_PERSONAL},
headers=_headers(),
)
r.raise_for_status()
d = r.json()
return QrCode(qrcode_id=d.get("qrcode", ""), deeplink=d.get("qrcode_img_content", ""))
@dataclass
class BindResult:
status: str # wait | confirmed | expired
bot_token: Optional[str] = None
base_url: Optional[str] = None
def poll_qrcode_status(
qrcode_id: str, base_url: str = DEFAULT_BASE, *, timeout: float = 40.0
) -> BindResult:
"""单次轮询扫码状态(服务端长轮询,hold 数十秒)。调用方循环调用,
`expired` 重新 `get_bot_qrcode` 换码`confirmed` 时返回 bot_token + base_url"""
with httpx.Client(timeout=timeout) as c:
r = c.get(
f"{base_url}/ilink/bot/get_qrcode_status",
params={"qrcode": qrcode_id},
headers=_headers(),
)
r.raise_for_status()
d = r.json()
return BindResult(
status=d.get("status", ""),
bot_token=d.get("bot_token"),
base_url=d.get("baseurl") or d.get("base_url"),
)
# ─────────────────────────── 收发(带 token)───────────────────────────
@dataclass
class InboundAttachment:
"""入站附件(图片 / 文件)的 CDN 引用 + 下载后填充的明文字节。
协议结构(getupdates 返回的 item_list ,实测 + 逆向 photon-hq/wechat-ilink-client):
- 图片 `image_item`(type=2):`media{encrypt_query_param, aes_key, encrypt_type}`,
另带优先 `aeskey`(32 hex);文件名缺失,下载后按 magic bytes 补扩展名
- 文件 `file_item`(type=4):`media{...}` + `file_name` + `len`(明文大小)
"""
kind: str # "image" | "file"
media: dict[str, Any] # {encrypt_query_param, aes_key, encrypt_type}
file_name: str = "" # 文件原名(图片无名,落盘时按 magic bytes 生成)
aeskey_hex: str = "" # 图片优先 key:image_item.aeskey(32 hex chars)
size: int = 0 # 明文大小(file_item.len / image mid_size),仅参考
data: Optional[bytes] = None # 下载 + 解密后的明文,由调用方(inbound)回填
@dataclass
class InboundMessage:
from_user_id: str # xxx@im.wechat
context_token: str # 回复 / 24h 内主动推须带回
text: str
raw: dict[str, Any]
attachments: list[InboundAttachment] = field(default_factory=list)
class ILinkClient:
"""绑定后按用户持有 `bot_token` + `base_url`,收发该用户消息。"""
def __init__(self, bot_token: str, base_url: str = DEFAULT_BASE) -> None:
self.bot_token = bot_token
self.base_url = base_url or DEFAULT_BASE
# —— 收 ——
def get_updates(
self, cursor: str = "", *, timeout: float = 45.0
) -> tuple[list[InboundMessage], str]:
"""长轮询拉新消息。返回 (消息列表, 新游标);游标传回下次调用。"""
with httpx.Client(timeout=timeout) as c:
r = c.post(
f"{self.base_url}/ilink/bot/getupdates",
json={"get_updates_buf": cursor, "base_info": _base_info()},
headers=_headers(self.bot_token),
)
r.raise_for_status()
d = r.json()
msgs: list[InboundMessage] = []
for m in d.get("msgs", []) or []:
text_parts: list[str] = []
attachments: list[InboundAttachment] = []
for it in m.get("item_list", []) or []:
if it.get("text_item"):
text_parts.append((it["text_item"] or {}).get("text", ""))
img = it.get("image_item")
if img:
attachments.append(InboundAttachment(
kind="image",
media=img.get("media") or {},
aeskey_hex=(img.get("aeskey") or ""),
size=int(img.get("mid_size") or 0),
))
fil = it.get("file_item")
if fil:
attachments.append(InboundAttachment(
kind="file",
media=fil.get("media") or {},
file_name=(fil.get("file_name") or "file"),
size=int(fil.get("len") or 0),
))
msgs.append(InboundMessage(
from_user_id=m.get("from_user_id", ""),
context_token=m.get("context_token", ""),
text="".join(text_parts),
raw=m,
attachments=attachments,
))
return msgs, d.get("get_updates_buf", cursor)
# —— 收附件(CDN 下载 → AES-128-ECB 解密 → 明文 bytes)——
def download_media(self, att: InboundAttachment, *, timeout: float = 60.0) -> bytes:
"""下载并解密一个入站附件,返回明文 bytes(发送侧上传链路的逆操作)。
URL:`{CDN_BASE}/download?encrypted_query_param=<media.encrypt_query_param>`
Key 优先级:图片 `image_item.aeskey`(32 hex)> `media.aes_key`(两种编码,
`_decode_media_aes_key`)
"""
media = att.media or {}
qp = media.get("encrypt_query_param") or media.get("encrypted_query_param") or ""
if not qp:
raise RuntimeError(f"附件无 encrypt_query_param: kind={att.kind} media={media}")
url = f"{CDN_BASE}/download?encrypted_query_param={quote(qp)}"
with httpx.Client(timeout=timeout) as c:
# 下载语义按逆向文档是 GET;CDN 若只认 POST 则回退一次(下载幂等,无副作用)
r = c.get(url)
if r.status_code == 405 or (400 <= r.status_code < 500 and not r.content):
r = c.post(url, content=b"")
r.raise_for_status()
ciphertext = r.content
if att.aeskey_hex and len(att.aeskey_hex) == 32:
key = bytes.fromhex(att.aeskey_hex)
else:
key = _decode_media_aes_key(media.get("aes_key") or "")
return _aes_ecb_unpkcs7(ciphertext, key)
# —— 发(底层单条)——
def _send(
self, to_user_id: str, context_token: str, item: dict, *, state: int
) -> None:
body = {
"msg": {
"from_user_id": "",
"to_user_id": to_user_id,
"client_id": _new_client_id(),
"message_type": MSG_TYPE_BOT,
"message_state": state,
"context_token": context_token,
"item_list": [item],
},
"base_info": _base_info(),
}
with httpx.Client(timeout=30.0) as c:
r = c.post(
f"{self.base_url}/ilink/bot/sendmessage",
json=body,
headers=_headers(self.bot_token),
)
# 成功为 HTTP 200 + 空 body {};非 200 抛错(空 body 不代表失败)
r.raise_for_status()
# —— 发文本(自动分块,长文不丢)——
def send_text(self, to_user_id: str, context_token: str, text: str) -> None:
text = text or ""
chunks = [text[i:i + CHUNK_CHARS] for i in range(0, len(text), CHUNK_CHARS)] or [""]
last = len(chunks) - 1
for i, chunk in enumerate(chunks):
self._send(
to_user_id, context_token,
{"type": ITEM_TEXT, "text_item": {"text": chunk}},
state=STATE_FINISH if i == last else STATE_GENERATING,
)
if i != last:
time.sleep(CHUNK_DELAY_S)
# —— 发文件(getuploadurl → AES-128-ECB → CDN → file_item)——
def _upload_file(self, to_user_id: str, data: bytes) -> dict[str, Any]:
rawsize = len(data)
rawmd5 = hashlib.md5(data).hexdigest()
aeskey = os.urandom(16)
filekey = os.urandom(16).hex()
ciphertext = _aes_ecb_pkcs7(data, aeskey)
filesize = len(ciphertext)
with httpx.Client(timeout=30.0) as c:
ru = c.post(
f"{self.base_url}/ilink/bot/getuploadurl",
json={
"filekey": filekey,
"media_type": UPLOAD_MEDIA_FILE,
"to_user_id": to_user_id,
"rawsize": rawsize,
"rawfilemd5": rawmd5,
"filesize": filesize,
"no_need_thumb": True,
"aeskey": aeskey.hex(),
"base_info": _base_info(),
},
headers=_headers(self.bot_token),
)
ru.raise_for_status()
uj = ru.json()
full = (uj.get("upload_full_url") or uj.get("uploadFullUrl")
or uj.get("full_url") or uj.get("url"))
param = (uj.get("upload_param") or uj.get("uploadParam") or uj.get("param"))
if full:
cdn_url = full
elif param:
cdn_url = (f"{CDN_BASE}/upload?encrypted_query_param={quote(param)}"
f"&filekey={quote(filekey)}")
else:
raise RuntimeError(f"getuploadurl 无 upload url/param: {uj}")
rc = c.post(cdn_url, content=ciphertext,
headers={"Content-Type": "application/octet-stream"})
download_param = rc.headers.get("x-encrypted-param")
if rc.status_code != 200 or not download_param:
raise RuntimeError(
f"CDN 上传失败 http={rc.status_code} "
f"err={rc.headers.get('x-error-message')}"
)
return {
"encrypt_query_param": download_param,
"aes_key": base64.b64encode(aeskey.hex().encode()).decode(),
"rawsize": rawsize,
}
def send_file(
self,
to_user_id: str,
context_token: str,
file_path: str | os.PathLike,
*,
file_name: Optional[str] = None,
) -> None:
data = _read_file_capped(file_path)
name = file_name or os.path.basename(str(file_path))
up = self._upload_file(to_user_id, data)
item = {
"type": ITEM_FILE,
"file_item": {
"media": {
"encrypt_query_param": up["encrypt_query_param"],
"aes_key": up["aes_key"],
"encrypt_type": 1,
},
"file_name": name,
"len": str(up["rawsize"]),
},
}
self._send(to_user_id, context_token, item, state=STATE_FINISH)
def attachment_basename(att: InboundAttachment) -> str:
"""入站附件的安全落盘文件名(不含目录):剥掉路径分隔防穿越;图片按 magic bytes 补扩展名。
返回的是 basename,调用方负责加前缀(时间戳 / 随机)防重名并拼到 inbound 目录下
"""
if att.kind == "image":
ext = _guess_image_ext(att.data or b"")
return f"image{ext}"
name = os.path.basename((att.file_name or "file").replace("\\", "/")).strip()
return name or "file"
def _read_file_capped(file_path: str | os.PathLike) -> bytes:
size = os.path.getsize(file_path)
if size > MAX_FILE_BYTES:
raise ValueError(f"文件超过 {MAX_FILE_BYTES // (1024*1024)}MB 上限")
with open(file_path, "rb") as f:
return f.read()

155
core/wechat/inbound.py Normal file
View File

@ -0,0 +1,155 @@
"""入站长轮询管理器(DESIGN §8.7):收用户消息 → 跑 agent → 回复发回。
- 每个 active 绑定一条 `getupdates` 长轮询(ilink 同步, to_thread);收到消息:
`service.refresh_context_token` 刷新 24h 推送窗口; 调注入的 `handle_message`
(app.py 提供:解析/建该用户常驻微信task run `_run_agent_bg` 取回复);
用本轮新鲜 `context_token` 分块发回
- 每绑定 loop **串行**处理(再收):天然避免同用户并发 run 锁冲突;不同用户并发
- 管理器周期性对账 active 绑定:新增起 loop撤销/revoke loop
`handle_message` 注入解耦 app.py 内部(broker / run / _run_agent_bg);本模块只管协议循环
与回复提取(`extract_last_assistant_text` 纯函数可测)
"""
from __future__ import annotations
import asyncio
from typing import Any, Awaitable, Callable, Optional
from uuid import UUID
from sqlalchemy import select
from core.storage import session_scope
from core.storage.models import Message
from core.wechat import service
from core.wechat.ilink import ILinkClient, InboundAttachment
from core.wechat.service import BindingSnapshot
# app.py 注入:跑该用户的微信对话 task,返回 assistant 回复文本(可空)。
# 第三参 attachments:已下载解密(att.data 已回填)的入站附件,app.py 负责落盘 + 拼提示行。
HandleMessage = Callable[[UUID, str, list[InboundAttachment]], Awaitable[str]]
def _content_to_text(content: Any) -> str:
"""OpenAI 风格 content → 纯文本(str 直返;content blocks 拼 text 段)。"""
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for b in content:
if isinstance(b, dict) and b.get("type") in (None, "text"):
parts.append(b.get("text", ""))
return "".join(parts)
return ""
def extract_last_assistant_text(task_id: UUID, *, scan: int = 20) -> str:
"""取该 task 最后一条**有正文**的 assistant 消息文本(跳过纯 tool_calls 行)。"""
with session_scope() as s:
rows = s.execute(
select(Message.payload)
.where(Message.task_id == task_id, Message.kind.is_(None))
.order_by(Message.idx.desc())
.limit(scan)
).all()
for (payload,) in rows:
if not isinstance(payload, dict) or payload.get("role") != "assistant":
continue
text = _content_to_text(payload.get("content"))
if text.strip():
return text
return ""
async def _poll_binding(
snap: BindingSnapshot, handle_message: HandleMessage, stop: asyncio.Event
) -> None:
"""单个绑定的长轮询循环。异常退避重试,直到 stop。"""
client = ILinkClient(snap.bot_token, snap.base_url)
cursor = ""
backoff = 2
while not stop.is_set():
try:
msgs, cursor = await asyncio.to_thread(client.get_updates, cursor)
backoff = 2
except Exception as e: # noqa: BLE001
print(f"[wechat-inbound] {str(snap.user_id)[:8]} getupdates err: "
f"{type(e).__name__}: {e}; retry in {backoff}s")
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 60)
continue
for m in msgs:
if stop.is_set():
break
# 下载入站附件(图片/文件):CDN 取密文 → AES 解密 → 回填 att.data
atts: list[InboundAttachment] = []
for att in m.attachments:
try:
att.data = await asyncio.to_thread(client.download_media, att)
atts.append(att)
except Exception as e: # noqa: BLE001
print(f"[wechat-inbound] {str(snap.user_id)[:8]} download "
f"{att.kind} err: {type(e).__name__}: {e}")
# 文本和附件都没有(纯文本为空 / 附件全下载失败)→ 跳过整条
if not m.text.strip() and not atts:
continue
# ① 刷新该用户推送窗口(主动推靠它续命)
await asyncio.to_thread(
service.refresh_context_token, snap.user_id, m.from_user_id, m.context_token
)
# ② 跑 agent 取回复(附件由 handle_message 落盘 + 拼 [用户上传的...] 行)
try:
reply = await handle_message(snap.user_id, m.text, atts)
except Exception as e: # noqa: BLE001
reply = f"[出错] {type(e).__name__}: {e}"
# ③ 用本轮新鲜 token 分块回
if reply and reply.strip():
try:
await asyncio.to_thread(
client.send_text, m.from_user_id, m.context_token, reply
)
except Exception as e: # noqa: BLE001
print(f"[wechat-inbound] {str(snap.user_id)[:8]} reply send err: "
f"{type(e).__name__}: {e}")
async def run_inbound_manager(
handle_message: HandleMessage,
stop: asyncio.Event,
*,
reconcile_seconds: int = 60,
) -> None:
"""常驻管理器:周期对账 active 绑定,起/停 per-binding 长轮询循环。"""
loops: dict[UUID, asyncio.Task] = {}
try:
while not stop.is_set():
try:
active = await asyncio.to_thread(service.list_active_bindings)
except Exception as e: # noqa: BLE001
print(f"[wechat-inbound] list bindings err: {type(e).__name__}: {e}")
active = []
active_ids = {s.user_id for s in active}
# 起新增
for snap in active:
t = loops.get(snap.user_id)
if t is None or t.done():
loops[snap.user_id] = asyncio.create_task(
_poll_binding(snap, handle_message, stop),
name=f"wechat-poll-{str(snap.user_id)[:8]}",
)
# 清撤销 / 已结束
for uid in list(loops):
if uid not in active_ids:
loops.pop(uid).cancel()
elif loops[uid].done():
loops.pop(uid)
await _wait_stop(stop, reconcile_seconds) # 等 stop 或到下次对账
finally:
for t in loops.values():
t.cancel()
async def _wait_stop(stop: asyncio.Event, timeout: float) -> None:
try:
await asyncio.wait_for(stop.wait(), timeout=timeout)
except asyncio.TimeoutError:
pass

498
core/wechat/service.py Normal file
View File

@ -0,0 +1,498 @@
"""微信渠道服务层(DESIGN §8.7):绑定 CRUD + 主动推送 + `send_to_user` 渠道抽象。
- 绑定行的 `bot_token` / `latest_context_token` `crypto` 加解密;快照(BindingSnapshot)
脱离 session含明文 token,** host 进程内用,绝不外泄/进沙箱**
- 主动推送 24h 窗口:`context_token` 仅在末次入站 ~24h 内可用;超期/未开口 推不出,
返回 reason 给调用方退邮件兜底(§8.5)
- `send_to_user` 是渠道抽象:scheduler / WechatPushTool 调它,不感知 ClawBot/企业微信;
企业微信(渠道 B)后续在此追加一路
阻塞 IO(DB + httpx),调用方放 to_thread / executor
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Optional
from uuid import UUID
from sqlalchemy import func, select, update
from core.storage import session_scope
from core.storage.models import ChannelBinding, Message, Task
from core.wechat import crypto
from core.wechat.ilink import DEFAULT_BASE, ILinkClient
CONTEXT_TOKEN_TTL = timedelta(hours=24)
_CLAWBOT = "clawbot"
_WECOM = "wecom"
def _get_or_new(s, user_id: UUID, channel: str) -> ChannelBinding:
row = s.get(ChannelBinding, (user_id, channel))
if row is None:
row = ChannelBinding(user_id=user_id, channel=channel, config={})
s.add(row)
return row
def clawbot_enabled() -> bool:
"""ClawBot 渠道总开关(沿用「有开关才挂」范式,§3.4)。"""
return os.getenv("ZCBOT_WECHAT_BOT_ENABLED", "").strip().lower() in (
"1", "true", "yes", "on",
)
# ─────────────────────────── 绑定快照 / CRUD ───────────────────────────
@dataclass
class BindingSnapshot:
user_id: UUID
bot_token: str # 明文(已解密)
base_url: str
user_im_id: Optional[str]
context_token: Optional[str] # 明文(已解密)
context_token_at: Optional[datetime]
chat_task_id: Optional[UUID]
status: str
def _snap(row: ChannelBinding) -> BindingSnapshot:
"""channel='clawbot' 行 → 快照(解密 token,反序列化 config)。"""
cfg = row.config or {}
cta = cfg.get("context_token_at")
cti = cfg.get("chat_task_id")
return BindingSnapshot(
user_id=row.user_id,
bot_token=crypto.dec(cfg.get("bot_token")) or "",
base_url=cfg.get("base_url") or DEFAULT_BASE,
user_im_id=cfg.get("user_im_id"),
context_token=crypto.dec(cfg.get("latest_context_token")),
context_token_at=datetime.fromisoformat(cta) if cta else None,
chat_task_id=UUID(cti) if cti else None,
status=row.status,
)
def get_binding(user_id: UUID) -> Optional[BindingSnapshot]:
with session_scope() as s:
row = s.get(ChannelBinding, (user_id, _CLAWBOT))
return _snap(row) if row else None
def list_active_bindings() -> list[BindingSnapshot]:
"""入站长轮询管理器用:所有 active 的 ClawBot 绑定(含明文 bot_token)。"""
with session_scope() as s:
rows = (
s.execute(
select(ChannelBinding).where(
ChannelBinding.channel == _CLAWBOT,
ChannelBinding.status == "active",
)
)
.scalars()
.all()
)
return [_snap(r) for r in rows]
def upsert_clawbot_binding(
user_id: UUID, bot_token: str, base_url: str, *, bot_im_id: Optional[str] = None
) -> None:
"""扫码 confirmed 后写/更新绑定。bot_token 加密存进 config(保留已有 user_im_id 等)。"""
now = datetime.now(timezone.utc)
with session_scope() as s:
row = _get_or_new(s, user_id, _CLAWBOT)
cfg = dict(row.config or {})
cfg["bot_token"] = crypto.enc(bot_token)
cfg["base_url"] = base_url or DEFAULT_BASE
if bot_im_id:
cfg["bot_im_id"] = bot_im_id
row.config = cfg # 重新赋值 → ORM 追踪 JSONB 变更
row.status = "active"
row.updated_at = now
def refresh_context_token(user_id: UUID, user_im_id: str, context_token: str) -> None:
"""每条入站消息刷新该用户的 context_token(+时间戳)——主动推送窗口靠它续命。"""
now = datetime.now(timezone.utc)
with session_scope() as s:
row = s.get(ChannelBinding, (user_id, _CLAWBOT))
if row is None:
return
cfg = dict(row.config or {})
if user_im_id:
cfg["user_im_id"] = user_im_id
cfg["latest_context_token"] = crypto.enc(context_token)
cfg["context_token_at"] = now.isoformat()
row.config = cfg
row.updated_at = now
def set_chat_task(user_id: UUID, task_id: UUID) -> None:
now = datetime.now(timezone.utc)
with session_scope() as s:
row = s.get(ChannelBinding, (user_id, _CLAWBOT))
if row is not None:
cfg = dict(row.config or {})
cfg["chat_task_id"] = str(task_id)
row.config = cfg
row.updated_at = now
def unbind(user_id: UUID) -> bool:
"""解绑 ClawBot(标 revoked,不物理删 → 保留轨迹)。返回是否有绑定被改。"""
now = datetime.now(timezone.utc)
with session_scope() as s:
row = s.get(ChannelBinding, (user_id, _CLAWBOT))
if row is None:
return False
row.status = "revoked"
row.updated_at = now
return True
# ─────────────────────────── 推送 ───────────────────────────
@dataclass
class PushResult:
ok: bool
channel: str = "clawbot"
# sent | no_binding | never_opened | token_stale | error:<...>
reason: str = ""
def _token_fresh(snap: BindingSnapshot) -> bool:
if not snap.context_token or snap.context_token_at is None:
return False
at = snap.context_token_at
if at.tzinfo is None:
at = at.replace(tzinfo=timezone.utc)
return (datetime.now(timezone.utc) - at) < CONTEXT_TOKEN_TTL
def push_clawbot(
user_id: UUID, text: str = "", file_path: Optional[str] = None
) -> PushResult:
"""主动推一条到用户个人微信。仅在 24h 窗口内可用,否则返回 reason 供兜底。"""
snap = get_binding(user_id)
if snap is None or snap.status != "active":
return PushResult(False, reason="no_binding")
if not snap.user_im_id or not snap.context_token:
return PushResult(False, reason="never_opened") # 冷启动:用户从未开口
if not _token_fresh(snap):
return PushResult(False, reason="token_stale") # 超 24h 未互动
client = ILinkClient(snap.bot_token, snap.base_url)
try:
if text:
client.send_text(snap.user_im_id, snap.context_token, text)
if file_path:
client.send_file(snap.user_im_id, snap.context_token, file_path)
except Exception as e: # noqa: BLE001 —— 调用方据 reason 决定兜底
return PushResult(False, reason=f"error: {str(e)[:200]}")
return PushResult(True, reason="sent")
# ─────────────── 企业微信(渠道 B,纯推送;无 24h 窗口约束)───────────────
def get_wecom_userid(user_id: UUID) -> Optional[str]:
with session_scope() as s:
row = s.get(ChannelBinding, (user_id, _WECOM))
if row is None or row.status != "active":
return None
return (row.config or {}).get("wecom_userid")
def get_user_by_wecom_userid(wecom_userid: str) -> Optional[UUID]:
"""企业微信回调只带 wecom_userid → 反查内部 user_id(仅 active 绑定)。入站对话用。"""
if not wecom_userid:
return None
with session_scope() as s:
row = s.execute(
select(ChannelBinding.user_id).where(
ChannelBinding.channel == _WECOM,
ChannelBinding.status == "active",
ChannelBinding.config["wecom_userid"].astext == wecom_userid,
)
).first()
return row[0] if row else None
def upsert_wecom_binding(user_id: UUID, wecom_userid: str) -> None:
"""OAuth 拿到 userid 后写/更新绑定。合并进 config(保留 chat_task_id 等已有字段)。"""
now = datetime.now(timezone.utc)
with session_scope() as s:
row = _get_or_new(s, user_id, _WECOM)
cfg = dict(row.config or {})
cfg["wecom_userid"] = wecom_userid
row.config = cfg
row.status = "active"
row.updated_at = now
def get_wecom_chat_task(user_id: UUID) -> Optional[UUID]:
"""企业微信入站对话常驻 task id(无 → None)。"""
with session_scope() as s:
row = s.get(ChannelBinding, (user_id, _WECOM))
if row is None:
return None
cti = (row.config or {}).get("chat_task_id")
return UUID(cti) if cti else None
def set_wecom_chat_task(user_id: UUID, task_id: UUID) -> None:
now = datetime.now(timezone.utc)
with session_scope() as s:
row = s.get(ChannelBinding, (user_id, _WECOM))
if row is not None:
cfg = dict(row.config or {})
cfg["chat_task_id"] = str(task_id)
row.config = cfg
row.updated_at = now
def unbind_wecom(user_id: UUID) -> bool:
now = datetime.now(timezone.utc)
with session_scope() as s:
row = s.get(ChannelBinding, (user_id, _WECOM))
if row is None:
return False
row.status = "revoked"
row.updated_at = now
return True
def push_wecom(user_id: UUID, text: str = "", file_path: Optional[str] = None) -> PushResult:
"""企业微信主动推一条(无条件,不挑活跃度)。"""
from core.wechat import wecom
wuid = get_wecom_userid(user_id)
if not wuid:
return PushResult(False, channel="wecom", reason="no_binding")
try:
if text:
wecom.send_text(wuid, text)
if file_path:
wecom.send_file(wuid, file_path)
except Exception as e: # noqa: BLE001 —— 透出 errcode/errmsg 便于排错
return PushResult(False, channel="wecom", reason=f"error: {str(e)[:200]}")
return PushResult(True, channel="wecom", reason="sent")
@dataclass
class DeliveryReport:
results: list[PushResult] = field(default_factory=list)
@property
def delivered(self) -> bool:
return any(r.ok for r in self.results)
def active_channels() -> list[str]:
"""部署级「哪些渠道开了」的**唯一真相源**:门槛判断(`wechat_push_available`)
与投递(`send_to_user`)都引它,避免两处各列各的(曾漏判企业微信致工具不挂)
加渠道只改这一处,门槛与投递自动一致顺序即投递优先序"""
from core.wechat.wecom import wecom_configured
chans: list[str] = []
if clawbot_enabled():
chans.append(_CLAWBOT)
if wecom_configured():
chans.append(_WECOM)
return chans
_DISPATCH = {_CLAWBOT: push_clawbot, _WECOM: push_wecom}
def ensure_channel_chat_task(uid: UUID, channel: str) -> Optional[UUID]:
"""确保 uid 的 channel 常驻 chat task 存在(未软删),返回 task_id;不存在则新建并回填绑定。
channel {'wechat','wecom'}wechat binding 返回 None(没法建/)
入站对话(`_run_channel_conversation`) push 记录(`send_to_user`)共用此入口,
避免两条"解析/建 chat task"路径逻辑漂移 task 逻辑搬自原 _run_channel_conversation
"""
from uuid import uuid4
from core.agent_builder import ( # 延迟 import:service 被 tools.wechat_bot 引用,
load_config, resolve_workspace, working_dir_from_name, # agent_builder 又 import tools.wechat_bot
) # → 顶层 import 循环;函数内 import 打破(同 scheduler.py:227 范式)
from core.capabilities import ModelCapabilities
from core.paths import ROOT, to_db_path
from core.storage.models import Task
from core.storage.utils import ensure_local_task_row
if channel == "wecom":
existing_tid = get_wecom_chat_task(uid)
task_name, slug, desc = "企业微信对话", f"wecom-{str(uid)[:8]}", "(企业微信对话)"
set_task = set_wecom_chat_task
else: # wechat
snap = get_binding(uid)
if snap is None:
return None
existing_tid = snap.chat_task_id
task_name, slug, desc = "微信对话", f"wechat-{str(uid)[:8]}", "(微信 ClawBot 对话)"
set_task = set_chat_task
tid = existing_tid
need_create = tid is None
if not need_create:
with session_scope() as s:
exists = s.execute(
select(Task.task_id).where(Task.task_id == tid, Task.deleted_at.is_(None))
).first()
if exists is None:
need_create = True
if need_create:
cfg = load_config()
profile = cfg["default_model"]
caps = ModelCapabilities.load(profile, ROOT / cfg["models_dir"])
ws = resolve_workspace(None, cfg)
tid = uuid4()
fs_dir = working_dir_from_name(ws, uid, slug)
fs_dir.mkdir(parents=True, exist_ok=True)
ensure_local_task_row(
task_id=tid, name=task_name, working_dir=to_db_path(fs_dir),
skill="", user_id=uid, model=caps.model_id, model_profile=profile,
description=desc, channel=channel,
)
set_task(uid, tid)
return tid
# ─────────────────────── channel 长会话上下文软重置(0019) ───────────────────────
# gap 默认值:超过它未说话 → 入站时软重置(保留上一轮原文做续聊锚点)。可被
# config.json 的 channel.session_gap_hours 覆盖(见 reload 入口)。
SESSION_GAP_HOURS_DEFAULT = 6.0
# 用户在 channel 里发这些词 → 手动「新话题」硬重置(base 推到总数,彻底从零)。
NEW_TOPIC_COMMANDS = frozenset({"新话题", "新会话", "/new", "清空上下文"})
def reset_channel_context(task_id: UUID, *, hard: bool) -> int:
"""推进 task 的 context_base_idx(软重置),返回新 base。不删任何消息。
hard=True(手动新话题):base = 总消息数 下一条入站起彻底新会话
hard=False(自动 gap):base = 最后一条 user 消息 idx 新窗口仍带上上一轮原文,
续聊接得上; user 消息(理论上不会)退化为总数
"""
with session_scope() as s:
total = s.execute(
select(func.count()).select_from(Message).where(Message.task_id == task_id)
).scalar_one()
if hard:
new_base = int(total)
else:
last_user_idx = s.execute(
select(func.max(Message.idx)).where(
Message.task_id == task_id,
Message.payload["role"].astext == "user",
)
).scalar_one_or_none()
new_base = int(last_user_idx) if last_user_idx is not None else int(total)
s.execute(
update(Task).where(Task.task_id == task_id).values(context_base_idx=new_base)
)
return new_base
def maybe_gap_reset(task_id: UUID, gap_hours: float = SESSION_GAP_HOURS_DEFAULT) -> bool:
"""入站时检测:距上次消息超过 gap_hours → 软重置(保留上一轮)。返回是否重置。
仅入站对话调用(push 记录不触发)gap_hours <= 0 视为关闭自动分段
"""
if gap_hours <= 0:
return False
with session_scope() as s:
last_at = s.execute(
select(func.max(Message.created_at)).where(Message.task_id == task_id)
).scalar_one_or_none()
if last_at is None:
return False # 空 task,首条入站,无需重置
if (datetime.now(timezone.utc) - last_at) <= timedelta(hours=gap_hours):
return False
reset_channel_context(task_id, hard=False)
return True
def _file_rel_to_user_root(user_id: UUID, file_path: str) -> Optional[str]:
"""宿主绝对路径 → user_root 相对 POSIX(如 scheduled-<jobid>/x.md)。
文件不在 user_root (外部 --working-dir) None"""
from pathlib import Path
from core.agent_builder import load_config, resolve_workspace, user_root
try:
ws = resolve_workspace(None, load_config())
root = user_root(ws, user_id)
return Path(file_path).resolve().relative_to(root.resolve()).as_posix()
except Exception:
return None
def _build_push_message(text: str, rel: Optional[str]) -> str:
"""构造写进 chat task 的 assistant 消息:推送摘要 + 可点文件链接 + agent read 路径。"""
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"(如需基于此文件提问,可读取 ../{rel})")
return "\n\n".join(lines)
def _record_push_to_chat(
report: DeliveryReport, user_id: UUID, text: str,
file_path: Optional[str], source_task_id: Optional[UUID],
) -> None:
"""把投递成功的推送记为对应渠道 chat task 的 assistant 消息(web 端可见 +
agent 可基于追问)Unified 模式: agent 上下文(推送是 bot 发给用户的话,
记得自己发过什么 = 连贯,非污染)记录失败不影响投递(吞掉打日志)"""
if not report.delivered:
return
from core.storage.utils import append_channel_message
rel = _file_rel_to_user_root(user_id, file_path) if file_path else None
for r in report.results:
if not r.ok:
continue
ch = "wechat" if r.channel == _CLAWBOT else r.channel # clawbot→wechat(建 task channel)
try:
tid = ensure_channel_chat_task(user_id, ch)
if tid is None:
continue
if source_task_id is not None and tid == source_task_id:
continue # 调用方即该 chat task 自己的 run,tool 记录已在,不重复插摘要
append_channel_message(tid, _build_push_message(text, rel), kind="push")
except Exception as e: # noqa: BLE001 —— 记录失败不放大,投递已成功
print(f"[push] record to {ch} chat task failed: {type(e).__name__}: {e}")
def send_to_user(
user_id: UUID,
text: str = "",
file_path: Optional[str] = None,
channel: Optional[str] = None,
*,
source_task_id: Optional[UUID] = None,
) -> DeliveryReport:
"""渠道抽象:按 `active_channels()` 列出的已开渠道投递 + 把推送记进渠道 chat task。
- `channel=None`(默认):广播到所有已开渠道(定时任务/不点名推送沿用此口径)
- `channel="wecom"|"clawbot"`:用户点名某个微信时只投这一条;若该渠道未开/无效,
返回单条 `no_binding` 结果(不静默回退到别的渠道,避免又推到没点名的渠道)
- 投递成功后,对每个成功渠道把推送(摘要 + 文件链接 + read 路径)作为 assistant
消息写进该渠道 chat task(不存在自动建)`source_task_id` = 调用方所在 task:
若恰为目标 chat task 自己(如用户在微信里让 agent ),tool 记录已在,跳过去重
"""
report = DeliveryReport()
if channel is not None:
if channel in active_channels():
report.results.append(_DISPATCH[channel](user_id, text, file_path))
else:
report.results.append(PushResult(False, channel=channel, reason="no_binding"))
else:
for ch in active_channels():
report.results.append(_DISPATCH[ch](user_id, text, file_path))
_record_push_to_chat(report, user_id, text, file_path, source_task_id)
return report

252
core/wechat/wecom.py Normal file
View File

@ -0,0 +1,252 @@
"""企业微信自建应用客户端(DESIGN §8.7 渠道 B,出站推送 + 入站对话)。
本模块只管**出站**(access_token / OAuth 绑定 / 发送);**入站对话**走回调:加解密在
`wecom_crypto.py`(WXBizMsgCrypt 等价),回调端点 + 反查身份在 web/app.py `/v1/wecom/callback`,
对话核心复用 `_run_channel_conversation`(与个人微信同核心,各一张会话 task)
出站能力:
- `access_token`:`gettoken(corpid,secret)`,进程内缓存 ~2h线程安全errcode 失效即重取
- OAuth 扫码登录:`oauth_authorize_url()` 造扫码授权登录链接(桌面浏览器出二维码);
`get_user_id(code)` 拿成员 userid(绑定用,一次性)需管理员在应用配企业微信授权登录可信域名
- 发送:`send_text / send_markdown / send_file`(file `media/upload` media_id,20MB)
- `state` HMAC 签名( user_id + TTL, CSRF):回调无 JWT,用户身份从 state
凭据(secret)只在 host 进程读,绝不进沙箱 / run_python( ClawBot / send_email,§3.4)
阻塞 IO(httpx 同步),调用方放 to_thread / executor
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import os
import threading
import time
from pathlib import Path
from typing import Optional
import httpx
QYAPI = "https://qyapi.weixin.qq.com/cgi-bin"
# 扫码授权登录(桌面浏览器渲染二维码,用企业微信 App 扫码)。
# 不能用 open.weixin.qq.com/connect/oauth2/authorize —— 那条是「网页授权」,只能在
# 企业微信客户端内打开,桌面浏览器会报「请在企业微信客户端打开链接」。
WWLOGIN_SSO = "https://login.work.weixin.qq.com/wwlogin/sso/login"
MAX_FILE_BYTES = 20 * 1024 * 1024
# access_token 进程内缓存
_tok_lock = threading.Lock()
_tok_val: Optional[str] = None
_tok_exp: float = 0.0
def wecom_configured() -> bool:
"""三件套齐才算配好(沿用「有 key 才挂」§3.4)。"""
return bool(
os.getenv("WECOM_CORPID", "").strip()
and os.getenv("WECOM_AGENTID", "").strip()
and os.getenv("WECOM_SECRET", "").strip()
)
def _corpid() -> str:
return os.getenv("WECOM_CORPID", "").strip()
def _agentid() -> str:
return os.getenv("WECOM_AGENTID", "").strip()
def _secret() -> str:
return os.getenv("WECOM_SECRET", "").strip()
def _state_secret() -> bytes:
# OAuth state 签名密钥:复用凭据加密 key,退 JWT_SECRET
key = (os.getenv("ZCBOT_WECHAT_SECRET_KEY", "").strip()
or os.getenv("JWT_SECRET", "").strip() or "zcbot-wecom")
return key.encode("utf-8")
# ─────────────────────────── access_token ───────────────────────────
def get_access_token(*, force: bool = False) -> str:
"""缓存的 app access_token;过期/force 时重取。线程安全。"""
global _tok_val, _tok_exp
with _tok_lock:
if not force and _tok_val and time.time() < _tok_exp:
return _tok_val
with httpx.Client(timeout=15) as c:
r = c.get(f"{QYAPI}/gettoken",
params={"corpid": _corpid(), "corpsecret": _secret()})
r.raise_for_status()
d = r.json()
if d.get("errcode", 0) != 0 or not d.get("access_token"):
raise RuntimeError(f"gettoken 失败: {d.get('errcode')} {d.get('errmsg')}")
_tok_val = d["access_token"]
_tok_exp = time.time() + int(d.get("expires_in", 7200)) - 300 # 提前 5min 续
return _tok_val
def _api_get(path: str, params: dict) -> dict:
"""带 access_token 的 GET;40014/42001(token 失效)自动重取一次。"""
for attempt in (1, 2):
tok = get_access_token(force=(attempt == 2))
with httpx.Client(timeout=15) as c:
r = c.get(f"{QYAPI}/{path}", params={"access_token": tok, **params})
r.raise_for_status()
d = r.json()
if d.get("errcode") in (40014, 42001) and attempt == 1:
continue
return d
return d
def _api_post(path: str, json_body: dict) -> dict:
for attempt in (1, 2):
tok = get_access_token(force=(attempt == 2))
with httpx.Client(timeout=20) as c:
r = c.post(f"{QYAPI}/{path}", params={"access_token": tok}, json=json_body)
r.raise_for_status()
d = r.json()
if d.get("errcode") in (40014, 42001) and attempt == 1:
continue
return d
return d
# ─────────────────────────── OAuth 绑定 ───────────────────────────
def sign_state(user_id: str, *, ttl: int = 600) -> str:
"""state = base64(user_id.exp).hmac —— 绑 user_id + 短 TTL,防 CSRF。"""
exp = int(time.time()) + ttl
payload = f"{user_id}.{exp}"
sig = hmac.new(_state_secret(), payload.encode(), hashlib.sha256).hexdigest()[:32]
raw = f"{payload}.{sig}"
return base64.urlsafe_b64encode(raw.encode()).decode().rstrip("=")
def verify_state(state: str) -> Optional[str]:
"""校验 state,返回 user_id;失败/过期返回 None。"""
try:
pad = "=" * (-len(state) % 4)
raw = base64.urlsafe_b64decode(state + pad).decode()
user_id, exp_s, sig = raw.rsplit(".", 2)
payload = f"{user_id}.{exp_s}"
good = hmac.new(_state_secret(), payload.encode(), hashlib.sha256).hexdigest()[:32]
if not hmac.compare_digest(sig, good):
return None
if int(exp_s) < int(time.time()):
return None
return user_id
except Exception:
return None
def oauth_authorize_url(redirect_uri: str, state: str) -> str:
"""造**扫码授权登录**链接:桌面浏览器打开会渲染二维码,用户用企业微信 App 扫码确认后
回跳到 redirect_uri code(后续 auth/getuserinfo userid 不变)
注意:redirect_uri 域名须在企业微信后台应用 企业微信授权登录 可信域名里登记,
网页授权可信域名是两项不同设置"""
from urllib.parse import quote
return (
f"{WWLOGIN_SSO}?login_type=CorpApp&appid={_corpid()}"
f"&agentid={_agentid()}"
f"&redirect_uri={quote(redirect_uri, safe='')}"
f"&state={quote(state, safe='')}"
)
def get_user_id(code: str) -> Optional[str]:
"""OAuth 回调用 code 换企业成员 userid(非成员返回 None)。"""
d = _api_get("auth/getuserinfo", {"code": code})
if d.get("errcode", 0) != 0:
raise RuntimeError(f"getuserinfo 失败: {d.get('errcode')} {d.get('errmsg')}")
return d.get("userid") # 外部联系人/非成员只有 openid → None
# ─────────────────────────── 发送 ───────────────────────────
def _send(touser: str, msgtype: str, body_field: dict) -> None:
payload = {"touser": touser, "msgtype": msgtype, "agentid": _agentid(), **body_field}
d = _api_post("message/send", payload)
if d.get("errcode", 0) != 0:
raise RuntimeError(f"message/send 失败: {d.get('errcode')} {d.get('errmsg')}")
def send_text(touser: str, content: str) -> None:
_send(touser, "text", {"text": {"content": content or ""}})
def send_markdown(touser: str, content: str) -> None:
_send(touser, "markdown", {"markdown": {"content": content or ""}})
def upload_media(file_path: str | os.PathLike, *, media_type: str = "file") -> str:
"""上传临时素材(3 天有效)→ media_id。"""
p = Path(file_path)
if p.stat().st_size > MAX_FILE_BYTES:
raise ValueError(f"文件超过 {MAX_FILE_BYTES // (1024*1024)}MB 上限")
for attempt in (1, 2):
tok = get_access_token(force=(attempt == 2))
with httpx.Client(timeout=30) as c, open(p, "rb") as f:
r = c.post(f"{QYAPI}/media/upload",
params={"access_token": tok, "type": media_type},
files={"media": (p.name, f)})
r.raise_for_status()
d = r.json()
if d.get("errcode") in (40014, 42001) and attempt == 1:
continue
break
if d.get("errcode", 0) != 0 or not d.get("media_id"):
raise RuntimeError(f"media/upload 失败: {d.get('errcode')} {d.get('errmsg')}")
return d["media_id"]
def send_file(touser: str, file_path: str | os.PathLike) -> None:
media_id = upload_media(file_path, media_type="file")
_send(touser, "file", {"file": {"media_id": media_id}})
# ─────────────────────────── 入站素材下载 ───────────────────────────
def _filename_from_disposition(disposition: str) -> str:
"""从 Content-Disposition 取文件名(filename="..." / filename*=UTF-8''...);取不到回空。"""
if not disposition:
return ""
import re
from urllib.parse import unquote
m = re.search(r"filename\*=(?:UTF-8'')?([^;]+)", disposition, re.IGNORECASE)
if m:
return unquote(m.group(1).strip().strip('"'))
m = re.search(r'filename="?([^";]+)"?', disposition, re.IGNORECASE)
return m.group(1).strip() if m else ""
def download_media(media_id: str) -> tuple[bytes, str]:
"""下载临时素材(`media/get`)→ (明文字节, 文件名)。入站图片/文件消息用。
成功回二进制流(文件名在 Content-Disposition);出错回 JSON(errcode/errmsg)
40014/42001(token 失效)自动重取一次供回调线程 to_thread
"""
last = None
for attempt in (1, 2):
tok = get_access_token(force=(attempt == 2))
with httpx.Client(timeout=60) as c:
r = c.get(f"{QYAPI}/media/get",
params={"access_token": tok, "media_id": media_id})
r.raise_for_status()
ctype = r.headers.get("content-type", "").lower()
if "application/json" in ctype or "text/plain" in ctype:
try:
d = r.json()
except Exception: # noqa: BLE001 —— 非 JSON 当二进制处理
d = None
if d is not None:
if d.get("errcode") in (40014, 42001) and attempt == 1:
continue
raise RuntimeError(f"media/get 失败: {d.get('errcode')} {d.get('errmsg')}")
fname = _filename_from_disposition(r.headers.get("content-disposition", ""))
return r.content, fname
raise RuntimeError(f"media/get 失败: token 重取后仍未拿到素材 {last}")

View File

@ -0,0 +1,93 @@
"""企业微信「接收消息」回调加解密(WXBizMsgCrypt 等价实现,DESIGN §8.7 渠道 B 入站)。
企业微信自建应用配接收消息回调后,服务器**主动 POST 加密 XML** 到回调 URL,
URL 时还会先 GET 一次 echostr 验有效性这套加密** wecom.py access_token /
出站 API 无关,也与 crypto.py Fernet 列加密无关** 是企业微信专用方案:
- key = base64decode(EncodingAESKey + "="),32B;IV = key[:16](AES-256-CBC)
- 明文密文体 = random(16) || msg_len(4B 大端) || msg || receiveid(自建应用为 corpid)
- 签名 = sha1(sorted([Token, timestamp, nonce, encrypt]) 拼接) hexdigest
只做**解密 + 验签**(入站);回复走 wecom.send_text 主动推(agent >5s 无法被动同步回),
故不实现加密凭据 Token / EncodingAESKey secret 只在 host 进程读,绝不进沙箱
"""
from __future__ import annotations
import base64
import hashlib
import os
import struct
import xml.etree.ElementTree as ET
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
def callback_token() -> str:
return os.getenv("WECOM_CALLBACK_TOKEN", "").strip()
def callback_aeskey() -> str:
return os.getenv("WECOM_CALLBACK_AESKEY", "").strip()
def callback_configured() -> bool:
"""Token + EncodingAESKey 都在才算配好回调(沿用「有 key 才挂」§3.4)。"""
return bool(callback_token() and callback_aeskey())
def _aes_key() -> bytes:
"""EncodingAESKey(43 字符)→ +'=' → base64 解码 → 32B AES 密钥。"""
return base64.b64decode(callback_aeskey() + "=")
def _signature(timestamp: str, nonce: str, encrypt: str) -> str:
arr = sorted([callback_token(), timestamp, nonce, encrypt])
return hashlib.sha1("".join(arr).encode("utf-8")).hexdigest()
def _aes_decrypt(encrypt_b64: str) -> bytes:
key = _aes_key()
cipher = Cipher(algorithms.AES(key), modes.CBC(key[:16]))
dec = cipher.decryptor()
raw = dec.update(base64.b64decode(encrypt_b64)) + dec.finalize()
pad = raw[-1] # PKCS7(企业微信 block=32,按末字节剥即可)
if not 1 <= pad <= 32:
raise ValueError("PKCS7 padding 非法")
return raw[:-pad]
def _extract_plain(encrypt_b64: str, *, expect_receiveid: str = "") -> str:
"""解密 → 剥 16B 随机前缀 + 4B 长度,取 msg;尾部 receiveid 校验 corpid。"""
raw = _aes_decrypt(encrypt_b64)
body = raw[16:]
msg_len = struct.unpack(">I", body[:4])[0]
msg = body[4:4 + msg_len]
receiveid = body[4 + msg_len:].decode("utf-8", "ignore")
if expect_receiveid and receiveid != expect_receiveid:
raise ValueError("receiveid 不匹配(corpid 校验失败)")
return msg.decode("utf-8")
def verify_url(
msg_signature: str, timestamp: str, nonce: str, echostr: str, *, corpid: str = ""
) -> str:
"""配回调 URL 时企业微信 GET 验有效性:验签 + 解密 echostr,原样回明文。"""
if _signature(timestamp, nonce, echostr) != msg_signature:
raise ValueError("签名校验失败")
return _extract_plain(echostr, expect_receiveid=corpid)
def parse_message(plain_xml: str) -> dict:
"""解密后的明文 XML → dict(FromUserName / MsgType / Content / MsgId / ...)。"""
root = ET.fromstring(plain_xml)
return {child.tag: (child.text or "") for child in root}
def decrypt_message(
msg_signature: str, timestamp: str, nonce: str, body: str, *, corpid: str = ""
) -> dict:
"""收消息 POST:从信封 XML 取 Encrypt → 验签 → 解密 → parse_message。"""
encrypt = ET.fromstring(body).findtext("Encrypt") or ""
if _signature(timestamp, nonce, encrypt) != msg_signature:
raise ValueError("签名校验失败")
return parse_message(_extract_plain(encrypt, expect_receiveid=corpid))

View File

@ -0,0 +1,33 @@
"""users.role 列(admin 管理后台访问控制).
Revision ID: 0009
Revises: 0008
Create Date: 2026-06-12
users role (user / admin),给现有所有行默认 'user';/v1/admin/* 监控端点
make_require_admin gate,只放 role='admin' 的用户提管理员:
`.venv/Scripts/python.exe main.py user role --email X --role admin`
只加列不动现有数据(开发期测试数据保留);server_default='user' 让历史行自动回填
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0009"
down_revision: Union[str, None] = "0008"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column("role", sa.Text(), nullable=False, server_default="user"),
)
def downgrade() -> None:
op.drop_column("users", "role")

View File

@ -0,0 +1,34 @@
"""tasks.deleted_at 列(任务软删除).
Revision ID: 0010
Revises: 0009
Create Date: 2026-06-17
tasks deleted_at (可空,默认 NULL=未删)DELETE /v1/tasks/{id} 从硬删
改为软删( deleted_at=now()),列表查询过滤 deleted_at IS NULL;新增
POST /v1/tasks/{id}/restore 恢复软删后 messages / usage_events( CASCADE 不再触发)
及工作目录文件全部保留,留作训练语料并支持恢复
只加列不动现有数据;历史行 deleted_at 默认 NULL,自动视为"未删"
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0010"
down_revision: Union[str, None] = "0009"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"tasks",
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_column("tasks", "deleted_at")

View File

@ -0,0 +1,70 @@
"""scheduled_jobs 表(定时任务,DESIGN §8.5).
Revision ID: 0011
Revises: 0010
Create Date: 2026-06-18
新增独立表 scheduled_jobs 不碰现有 schema(公测兼容)一行 = 一个"到点把
prompt 喂进 agent 主管线"的计划。守护循环(web/app.py lifespan)按 (enabled,
next_run_at) 索引扫到点 job 触发 DESIGN §8.5 / core/storage/models.py
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import JSONB, UUID as PG_UUID
revision: str = "0011"
down_revision: Union[str, None] = "0010"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"scheduled_jobs",
sa.Column("job_id", PG_UUID(as_uuid=True), primary_key=True),
sa.Column(
"user_id", PG_UUID(as_uuid=True),
sa.ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False,
),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("prompt", sa.Text(), nullable=False),
sa.Column("cron", sa.Text(), nullable=False),
sa.Column("tz", sa.Text(), nullable=False, server_default="Asia/Shanghai"),
sa.Column("mode", sa.Text(), nullable=False, server_default="isolated"),
sa.Column(
"bound_task_id", PG_UUID(as_uuid=True),
sa.ForeignKey("tasks.task_id", ondelete="SET NULL"), nullable=True,
),
sa.Column("skill", sa.Text(), nullable=False, server_default=""),
sa.Column("model_profile", sa.Text(), nullable=False, server_default=""),
sa.Column("notify", JSONB(), nullable=True),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("timeout_seconds", sa.Integer(), nullable=False, server_default="0"),
sa.Column("next_run_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_run_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_status", sa.Text(), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("last_task_id", PG_UUID(as_uuid=True), nullable=True),
sa.Column("consecutive_failures", sa.Integer(), nullable=False, server_default="0"),
sa.Column("run_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
)
# 守护循环 due 扫描热路径:WHERE enabled AND deleted_at IS NULL AND next_run_at<=now()
op.create_index(
"ix_scheduled_jobs_due", "scheduled_jobs", ["enabled", "next_run_at"],
)
# 用户列出自己的 job
op.create_index(
"ix_scheduled_jobs_user", "scheduled_jobs", ["user_id"],
)
def downgrade() -> None:
op.drop_index("ix_scheduled_jobs_user", table_name="scheduled_jobs")
op.drop_index("ix_scheduled_jobs_due", table_name="scheduled_jobs")
op.drop_table("scheduled_jobs")

View File

@ -0,0 +1,63 @@
"""wechat_bot_bindings 表(ClawBot 个人微信绑定,DESIGN §8.7 渠道 A).
Revision ID: 0012
Revises: 0011
Create Date: 2026-06-24
新增独立表 wechat_bot_bindings 不碰现有 schema(公测兼容)一行 = 一个用户绑定其
个人微信 ClawBotbot_token / latest_context_token 存密文(core/wechat/crypto.py)
入站长轮询管理器按 status='active' 拉绑定起 getupdates 循环;主动推送用 latest_context_token
(24h 内有效) DESIGN §8.7 / core/storage/models.py
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
revision: str = "0012"
down_revision: Union[str, None] = "0011"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"wechat_bot_bindings",
sa.Column(
"user_id", PG_UUID(as_uuid=True),
sa.ForeignKey("users.user_id", ondelete="CASCADE"), primary_key=True,
),
sa.Column("bot_token", sa.Text(), nullable=False),
sa.Column("bot_im_id", sa.Text(), nullable=True),
sa.Column("user_im_id", sa.Text(), nullable=True),
sa.Column(
"base_url", sa.Text(), nullable=False,
server_default="https://ilinkai.weixin.qq.com",
),
sa.Column("latest_context_token", sa.Text(), nullable=True),
sa.Column("context_token_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"chat_task_id", PG_UUID(as_uuid=True),
sa.ForeignKey("tasks.task_id", ondelete="SET NULL"), nullable=True,
),
sa.Column("status", sa.Text(), nullable=False, server_default="active"),
sa.Column(
"created_at", sa.DateTime(timezone=True),
server_default=sa.func.now(), nullable=False,
),
sa.Column(
"updated_at", sa.DateTime(timezone=True),
server_default=sa.func.now(), nullable=False,
),
)
# 入站管理器扫 active 绑定起长轮询
op.create_index(
"ix_wechat_bot_bindings_active", "wechat_bot_bindings", ["status"],
)
def downgrade() -> None:
op.drop_index("ix_wechat_bot_bindings_active", table_name="wechat_bot_bindings")
op.drop_table("wechat_bot_bindings")

View File

@ -0,0 +1,42 @@
"""tasks.channel 列(渠道来源:web / wechat).
Revision ID: 0013
Revises: 0012
Create Date: 2026-06-24
tasks channel ,标记任务来源渠道:
- web = 网页端常规任务(默认)
- wechat = 微信 ClawBot 常驻对话(每用户一条)
只加列不动现有数据;server_default='web' 让历史行自动回填为 web建表后把
现网已存在的微信常驻 task(description = '(微信 ClawBot 对话)')backfill
'wechat',让置顶 / 徽章逻辑对存量数据立即生效
前端据 channel 给微信任务打徽章并后端强制置顶(列表查询排序前置 pin 表达式)
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0013"
down_revision: Union[str, None] = "0012"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"tasks",
sa.Column("channel", sa.Text(), nullable=False, server_default="web"),
)
# backfill 存量微信常驻 task —— 用建 task 时写死的 description 作标记。
op.execute(
"UPDATE tasks SET channel = 'wechat' "
"WHERE description = '(微信 ClawBot 对话)'"
)
def downgrade() -> None:
op.drop_column("tasks", "channel")

View File

@ -0,0 +1,44 @@
"""wecom_bindings 表(企业微信绑定,DESIGN §8.7 渠道 B,纯推送).
Revision ID: 0014
Revises: 0013
Create Date: 2026-06-24
新增独立表 wecom_bindings 不碰现有 schema(公测兼容)一行 = 一个用户的企业微信成员
userid(OAuth 扫码拿)应用凭据走全局 env不入库;userid 非密钥明文存 DESIGN §8.7
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
revision: str = "0014"
down_revision: Union[str, None] = "0013"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"wecom_bindings",
sa.Column(
"user_id", PG_UUID(as_uuid=True),
sa.ForeignKey("users.user_id", ondelete="CASCADE"), primary_key=True,
),
sa.Column("wecom_userid", sa.Text(), nullable=False),
sa.Column("status", sa.Text(), nullable=False, server_default="active"),
sa.Column(
"created_at", sa.DateTime(timezone=True),
server_default=sa.func.now(), nullable=False,
),
sa.Column(
"updated_at", sa.DateTime(timezone=True),
server_default=sa.func.now(), nullable=False,
),
)
def downgrade() -> None:
op.drop_table("wecom_bindings")

View File

@ -0,0 +1,144 @@
"""channel_bindings 统一表(微信渠道抽象,DESIGN §8.7).
Revision ID: 0015
Revises: 0014
Create Date: 2026-06-24
0012 wechat_bot_bindings(ClawBot)+ 0014 wecom_bindings(企业微信)合成一张
判别列 + JSONB channel_bindings(user_id, channel, status, config),沿用本库
usage_events(kind+units)的多态范式 加渠道不再各建表
数据迁移:旧两表的行搬进 config JSONB(敏感 token 列本就是密文串,原样搬不重新加密),
drop 旧表DDL + DML 同一事务,失败整体回滚不丢数据 DESIGN §8.7
"""
import json
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import JSONB, UUID as PG_UUID
revision: str = "0015"
down_revision: Union[str, None] = "0014"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"channel_bindings",
sa.Column(
"user_id", PG_UUID(as_uuid=True),
sa.ForeignKey("users.user_id", ondelete="CASCADE"), primary_key=True,
),
sa.Column("channel", sa.Text(), primary_key=True), # clawbot | wecom | ...
sa.Column("status", sa.Text(), nullable=False, server_default="active"),
sa.Column("config", JSONB(), nullable=False, server_default="{}"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
# 入站管理器/推送:按 (channel, status) 扫某渠道活跃绑定
op.create_index("ix_channel_bindings_channel", "channel_bindings", ["channel", "status"])
conn = op.get_bind()
insert = sa.text(
"INSERT INTO channel_bindings (user_id, channel, status, config, created_at, updated_at) "
"VALUES (:uid, :ch, :st, CAST(:cfg AS JSONB), :ca, :ua)"
)
# 0012 wechat_bot_bindings → channel='clawbot'(token 列已是密文串,原样搬)
insp = sa.inspect(conn)
if insp.has_table("wechat_bot_bindings"):
rows = conn.execute(sa.text(
"SELECT user_id, bot_token, bot_im_id, user_im_id, base_url, "
"latest_context_token, context_token_at, chat_task_id, status, created_at, updated_at "
"FROM wechat_bot_bindings"
)).mappings().all()
for r in rows:
cfg = {
"bot_token": r["bot_token"],
"bot_im_id": r["bot_im_id"],
"user_im_id": r["user_im_id"],
"base_url": r["base_url"],
"latest_context_token": r["latest_context_token"],
"context_token_at": r["context_token_at"].isoformat() if r["context_token_at"] else None,
"chat_task_id": str(r["chat_task_id"]) if r["chat_task_id"] else None,
}
conn.execute(insert, {
"uid": r["user_id"], "ch": "clawbot", "st": r["status"],
"cfg": json.dumps(cfg), "ca": r["created_at"], "ua": r["updated_at"],
})
op.drop_table("wechat_bot_bindings")
# 0014 wecom_bindings → channel='wecom'
if insp.has_table("wecom_bindings"):
rows = conn.execute(sa.text(
"SELECT user_id, wecom_userid, status, created_at, updated_at FROM wecom_bindings"
)).mappings().all()
for r in rows:
cfg = {"wecom_userid": r["wecom_userid"]}
conn.execute(insert, {
"uid": r["user_id"], "ch": "wecom", "st": r["status"],
"cfg": json.dumps(cfg), "ca": r["created_at"], "ua": r["updated_at"],
})
op.drop_table("wecom_bindings")
def downgrade() -> None:
# 回滚:重建旧两表 + 把 config 拆回列,再 drop channel_bindings。
op.create_table(
"wechat_bot_bindings",
sa.Column("user_id", PG_UUID(as_uuid=True),
sa.ForeignKey("users.user_id", ondelete="CASCADE"), primary_key=True),
sa.Column("bot_token", sa.Text(), nullable=False),
sa.Column("bot_im_id", sa.Text(), nullable=True),
sa.Column("user_im_id", sa.Text(), nullable=True),
sa.Column("base_url", sa.Text(), nullable=False,
server_default="https://ilinkai.weixin.qq.com"),
sa.Column("latest_context_token", sa.Text(), nullable=True),
sa.Column("context_token_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("chat_task_id", PG_UUID(as_uuid=True),
sa.ForeignKey("tasks.task_id", ondelete="SET NULL"), nullable=True),
sa.Column("status", sa.Text(), nullable=False, server_default="active"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_table(
"wecom_bindings",
sa.Column("user_id", PG_UUID(as_uuid=True),
sa.ForeignKey("users.user_id", ondelete="CASCADE"), primary_key=True),
sa.Column("wecom_userid", sa.Text(), nullable=False),
sa.Column("status", sa.Text(), nullable=False, server_default="active"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
conn = op.get_bind()
rows = conn.execute(sa.text(
"SELECT user_id, channel, status, config, created_at, updated_at FROM channel_bindings"
)).mappings().all()
for r in rows:
cfg = r["config"] or {}
if r["channel"] == "clawbot":
conn.execute(sa.text(
"INSERT INTO wechat_bot_bindings (user_id, bot_token, bot_im_id, user_im_id, base_url, "
"latest_context_token, context_token_at, chat_task_id, status, created_at, updated_at) "
"VALUES (:uid, :bt, :bim, :uim, :bu, :lct, CAST(:cta AS timestamptz), "
"CAST(:cti AS uuid), :st, :ca, :ua)"
), {
"uid": r["user_id"], "bt": cfg.get("bot_token") or "", "bim": cfg.get("bot_im_id"),
"uim": cfg.get("user_im_id"), "bu": cfg.get("base_url") or "https://ilinkai.weixin.qq.com",
"lct": cfg.get("latest_context_token"), "cta": cfg.get("context_token_at"),
"cti": cfg.get("chat_task_id"), "st": r["status"],
"ca": r["created_at"], "ua": r["updated_at"],
})
elif r["channel"] == "wecom":
conn.execute(sa.text(
"INSERT INTO wecom_bindings (user_id, wecom_userid, status, created_at, updated_at) "
"VALUES (:uid, :wu, :st, :ca, :ua)"
), {
"uid": r["user_id"], "wu": cfg.get("wecom_userid") or "",
"st": r["status"], "ca": r["created_at"], "ua": r["updated_at"],
})
op.drop_index("ix_channel_bindings_channel", table_name="channel_bindings")
op.drop_table("channel_bindings")

View File

@ -0,0 +1,33 @@
"""users.name / users.user_name 列(平台登录注入的用户档案).
Revision ID: 0016
Revises: 0015
Create Date: 2026-06-25
users 加两列:name(显示名/姓名)+ user_name(平台账号名), nullable
平台经 /v1/auth/login(platform_key 形态) body 里注入,ensure_user_row upsert
落库;邮箱密码 / 历史行留空将来 OIDC 接管时由 ID token name / preferred_username
claim 注入,数据流不变 DESIGN §7.3 / §7.4
纯加列不动现有数据(平滑兼容线上存量行, NULL)
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0016"
down_revision: Union[str, None] = "0015"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("users", sa.Column("name", sa.Text(), nullable=True))
op.add_column("users", sa.Column("user_name", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("users", "user_name")
op.drop_column("users", "name")

View File

@ -0,0 +1,58 @@
"""tasks.scheduled_job_id 列(定时任务执行归属,DESIGN §8.5).
Revision ID: 0017
Revises: 0016
Create Date: 2026-06-26
tasks scheduled_job_id(nullable FK scheduled_jobs.job_id, ondelete SET NULL)
NULL = task 是某定时任务的一次执行(isolated 每次新建 / persistent 首次新建都填),
普通对话列表据此排除,不混进"用户项目"列表;job 软删不硬删,SET NULL 安全
backfill 存量定时执行 task:
- persistent:bound_task_id 直接指向其常驻 task 精确回填
- isolated:working_dir 末段 'scheduled-<job_id 前 8 位>' 8 位前缀匹配 job_id
匹配不上的孤行(job 已物理删等) NULL,由列表查询的 working_dir LIKE 兜底排除
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
revision: str = "0017"
down_revision: Union[str, None] = "0016"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"tasks",
sa.Column("scheduled_job_id", PG_UUID(as_uuid=True), nullable=True),
)
op.create_foreign_key(
"fk_tasks_scheduled_job_id",
"tasks", "scheduled_jobs",
["scheduled_job_id"], ["job_id"],
ondelete="SET NULL",
)
# persistent:bound_task_id 精确指向其常驻 task
op.execute(
"UPDATE tasks SET scheduled_job_id = j.job_id "
"FROM scheduled_jobs j "
"WHERE j.bound_task_id = tasks.task_id"
)
# isolated:working_dir 末段 scheduled-<8hex> 按 job_id 前 8 位匹配
op.execute(
"UPDATE tasks t SET scheduled_job_id = j.job_id "
"FROM scheduled_jobs j "
"WHERE t.scheduled_job_id IS NULL "
"AND t.working_dir ~ 'scheduled-[0-9a-f]{8}' "
"AND left(j.job_id::text, 8) = substring(t.working_dir from 'scheduled-([0-9a-f]{8})')"
)
def downgrade() -> None:
op.drop_constraint("fk_tasks_scheduled_job_id", "tasks", type_="foreignkey")
op.drop_column("tasks", "scheduled_job_id")

View File

@ -0,0 +1,29 @@
"""messages.kind 列(消息来源标记,避免 push 记录被 extract_last_assistant_text 误取).
Revision ID: 0018
Revises: 0017
Create Date: 2026-06-26
messages kind (nullable Text,默认 NULL)NULL=agent run 产生的消息;
"push"=push 记录(_record_push_to_chat )extract_last_assistant_text
WHERE kind IS NULL 跳过 push 记录,避免 wecom 入站取回复时误取 push 摘要
独立列不进 payload,不影响 agent 上下文 / LLM API纯加列,不动现有数据
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0018"
down_revision: Union[str, None] = "0017"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("messages", sa.Column("kind", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("messages", "kind")

View File

@ -0,0 +1,40 @@
"""tasks.context_base_idx 列(channel 长会话软重置,DESIGN §8.7).
Revision ID: 0019
Revises: 0018
Create Date: 2026-06-29
tasks context_base_idx(NOT NULL DEFAULT 0):喂给模型的上下文窗口起点
Session.load 只把 idx >= context_base_idx 的消息装进 LLM 上下文;idx < base 的历史
仍全量留在 messages (web `/messages` 直查不受影响,用户照旧翻完整历史)
channel 入站对话据此做软重置:超过 gap 阈值未说话 base 推到最后一条 user 消息
idx(保留上一轮原文做续聊锚点);手动新话题 base 推到总消息数(彻底从零)
存量行 / web 普通任务 base 0 = 喂全量,行为不变additive,无数据迁移
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0019"
down_revision: Union[str, None] = "0018"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"tasks",
sa.Column(
"context_base_idx",
sa.Integer(),
nullable=False,
server_default="0",
),
)
def downgrade() -> None:
op.drop_column("tasks", "context_base_idx")

View File

@ -75,15 +75,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
chromium nodejs npm \
&& rm -rf /var/lib/apt/lists/*
# 中文字体 ── 不装则 matplotlib / mermaid(chromium) / render_icon 出的 PNG 里
# 中文全是方块(豆腐块 □)。装两套:
# 中文字体 + emoji 字体 ── 不装则 matplotlib / mermaid(chromium) / render_icon 出的
# PNG 里中文 / emoji 全是方块(豆腐块 □)。装三套:
# - fonts-noto-cjk: 出版级字形,matplotlib 出版图 / mermaid 节点首选(+~330MB)
# - fonts-wqy-microhei: 兜底,匹配 style.py 候选 'WenQuanYi Micro Hei'
# + render_icon.py 引用的 wqy-microhei.ttc 路径(+~5MB)
# - fonts-noto-color-emoji: 模型常在 mermaid 节点标签前缀 emoji 图标(🌐🔥🛡 等),
# 缺此字体则 chromium 渲染时每个 emoji 都成 □,满图豆腐块(+~10MB)。chromium
# 支持 COLR/CBDT 彩色 emoji,fontconfig fallback 到它即可正常出图标。
# fc-cache 刷 fontconfig 索引 ── chromium 经 fontconfig 选字必需;matplotlib 走自家
# font_manager 扫 /usr/share/fonts,运行时首次用图自动建缓存,无需在此处理。
RUN apt-get update && apt-get install -y --no-install-recommends \
fonts-noto-cjk fonts-wqy-microhei fontconfig \
fonts-noto-cjk fonts-wqy-microhei fonts-noto-color-emoji fontconfig \
&& fc-cache -f \
&& rm -rf /var/lib/apt/lists/*
@ -95,7 +98,6 @@ ARG NPM_REGISTRY=https://registry.npmjs.org/
# Puppeteer 用容器内已装的 chromium 而非自带下载(省 ~300MB + 避免下载失败)
ENV PUPPETEER_SKIP_DOWNLOAD=true
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
ENV MERMAID_PUPPETEER_CONFIG=/sandbox/puppeteer-config.json
RUN npm config set registry ${NPM_REGISTRY} \
&& npm install -g @mermaid-js/mermaid-cli@latest \
@ -115,6 +117,27 @@ RUN mkdir -p /sandbox && cat > /sandbox/puppeteer-config.json <<'EOF'
}
EOF
# mmdc wrapper ── 容器 --cap-drop=ALL 下 chromium 自家 setuid sandbox 起不来,必须
# --no-sandbox(+ --disable-dev-shm-usage),这些在上面的 /sandbox/puppeteer-config.json 里。
# 但 mmdc **不读任何 env 自动加载**该 config(只认 -p/--puppeteerConfigFile);模型裸调
# `mmdc -i x.md -o x.png` 会因缺 --no-sandbox 直接跪 → 然后反复试 mermaid.ink 等在线 API
# (容器 internal network 禁外网,死路),实测一条对话这么烧掉 ~120k token。wrapper 在调用方
# 没显式传 -p 时自动注入这份 config,让裸调一次成;已显式 -p 则尊重不覆盖。proposal 的
# render_diagrams.py 等走 `which mmdc` 的脚本同样透明受益(原靠 MERMAID_PUPPETEER_CONFIG
# env,已删 ── wrapper 兜底,不再依赖那个谁都不读的 env)。
RUN mv /usr/local/bin/mmdc /usr/local/bin/mmdc.real \
&& cat > /usr/local/bin/mmdc <<'EOF'
#!/bin/sh
for a in "$@"; do
case "$a" in
-p|--puppeteerConfigFile|--puppeteerConfigFile=*)
exec /usr/local/bin/mmdc.real "$@" ;;
esac
done
exec /usr/local/bin/mmdc.real -p /sandbox/puppeteer-config.json "$@"
EOF
RUN chmod +x /usr/local/bin/mmdc
# fs 工具进容器(§7.5 #6,2026-05-26 修正)── tool_runner.py 在容器内通过
# `python /sandbox/tool_runner.py <name>` 调用 tools/fs.py 的 Tool 子类,read/write/
# edit/glob/grep 全在容器内执行,物理边界替代代码护栏。tools/ 目录与 host 同步

View File

@ -0,0 +1,66 @@
#!/usr/bin/env bash
# 在 sandbox 容器里实测 `chromium --headless --print-to-pdf`(md→HTML→PDF 的 PDF 那段)。
# 区分「chromium 缺包」「纯启动超时(/dev/shm 64MB)」「只读 rootfs 下 user-data-dir 写不了」。
# 用法(服务器上,任选其一):
# A) 进一个活着的 per-user 容器(最贴真,复用线上 64MB /dev/shm 默认):
# C=$(docker ps --filter "label=zcbot.product=sandbox" --format '{{.Names}}' | head -1)
# docker cp deploy/sandbox/probe_chromium_pdf.sh "$C":/tmp/probe.sh
# docker exec "$C" bash /tmp/probe.sh
# B) 没有活容器时,起一个临时的(显式 NOT 传 --shm-size,复现线上 64MB):
# docker run --rm --read-only --tmpfs /tmp:exec,size=512m,mode=1777 \
# --cap-drop=ALL --security-opt=no-new-privileges \
# --entrypoint bash zcbot-sandbox:latest /dev/stdin < deploy/sandbox/probe_chromium_pdf.sh
set -u
CR=""
for c in chromium chromium-browser /usr/bin/chromium; do
command -v "$c" >/dev/null 2>&1 && { CR="$c"; break; }
done
echo "===== /dev/shm size (期望线上 64M) ====="; df -h /dev/shm
echo "===== chromium 是否在 (缺包则这里就失败) ====="
[ -n "$CR" ] && "$CR" --version 2>&1 | head -1 || { echo "[FAIL] chromium 缺包/不可执行"; exit 1; }
# 测试输入:中文 + 表格背景色(print-color-adjust) + 化学式下标 + 超链接,覆盖简报常见元素
cd /tmp
cat > in.html <<'HTML'
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"><style>
@page { size: A4; margin: 2cm; }
body { font-family: 'Noto Sans CJK SC','Noto Serif CJK SC',serif; font-size:12pt; }
h1 { color:#C00000; border-bottom:2px solid #C00000; }
th { background:#C00000; color:#fff; -webkit-print-color-adjust:exact; print-color-adjust:exact; }
td,th { border:1px solid #999; padding:4pt 8pt; }
a { color:#1155CC; }
sub { font-size:0.75em; }
</style></head><body>
<h1>水泥科研方向 — 冒烟测试</h1>
<p>中文渲染、化学式 CO<sub>2</sub> / C<sub>3</sub>S、<a href="https://doi.org/10.1016/x">DOI 超链接</a>。</p>
<table><tr><th>期刊</th><th>篇数</th></tr><tr><td>Cement and Concrete Research</td><td>11</td></tr></table>
</body></html>
HTML
run() { # $1=label $2..=extra flags
local label="$1"; shift
local ts=$SECONDS
timeout 60 "$CR" --headless --disable-gpu --no-sandbox \
--user-data-dir=/tmp/cr-$label "$@" \
--print-to-pdf=/tmp/out-$label.pdf /tmp/in.html >"$label.log" 2>&1
local rc=$?
echo "rc=$rc 用时=$((SECONDS-ts))s"; tail -3 "$label.log"
if [ -s "/tmp/out-$label.pdf" ]; then
echo "[$label 出图] $(wc -c < /tmp/out-$label.pdf) bytes -> /tmp/out-$label.pdf"
else
echo "[$label 无图]"
fi
}
echo; echo "===== A: 漏 --disable-dev-shm-usage(线上 64MB /dev/shm)→ 可能挂起/超时 ====="
run A
echo; echo "===== B: 加 --disable-dev-shm-usage(走 /tmp)→ 预期成功出 PDF ====="
run B --disable-dev-shm-usage
echo; echo "===== 结论 ====="
echo "B 出图 => chromium print-to-pdf 可用,render_pdf.py 固定带 --disable-dev-shm-usage + --user-data-dir=/tmp/* 即可"
echo "B 无图/超时 => 看 B.log;若是 /dev/shm 仍报错,给 docker run 加 --shm-size"
echo "chromium 缺/全失败 => 更深环境问题,镜像没装好 chromium/字体"

View File

@ -0,0 +1,29 @@
#!/usr/bin/env bash
# 在 sandbox 容器里实测 mmdc/chromium区分「chromium 缺包」vs「纯启动超时(/dev/shm 64MB)」。
# 用法(服务器上,任选其一):
# A) 进一个活着的 per-user 容器(最贴真,复用线上 64MB /dev/shm 默认):
# C=$(docker ps --filter "label=zcbot.product=sandbox" --format '{{.Names}}' | head -1)
# docker cp deploy/sandbox/probe_mermaid.sh "$C":/tmp/probe.sh
# docker exec "$C" bash /tmp/probe.sh
# B) 没有活容器时,起一个临时的(显式 NOT 传 --shm-size,复现线上 64MB):
# docker run --rm --read-only --tmpfs /tmp:exec,size=512m,mode=1777 \
# --cap-drop=ALL --security-opt=no-new-privileges \
# --entrypoint bash zcbot-sandbox:latest /dev/stdin < deploy/sandbox/probe_mermaid.sh
set -u
echo "===== /dev/shm size (期望线上 64M) ====="; df -h /dev/shm
echo "===== chromium 是否在 (缺包则这里就失败) ====="
command -v chromium && chromium --version 2>&1 | head -1 || echo "[FAIL] chromium 缺包/不可执行"
cd /tmp; printf 'flowchart TB\n A[甲]-->B[乙]\n' > d.mmd
echo; echo "===== A: 模型自造 config(漏 --disable-dev-shm-usage)→ 预期挂起/超时 ====="
printf '{"args":["--no-sandbox","--disable-setuid-sandbox"]}' > bad.json
ts=$SECONDS; timeout 60 mmdc -i d.mmd -o a.png -p bad.json >a.log 2>&1; rc=$?
echo "rc=$rc 用时=$((SECONDS-ts))s"; tail -3 a.log; ls -l a.png 2>/dev/null && echo "[A 出图]" || echo "[A 无图]"
echo; echo "===== B: 镜像备好的 /sandbox/puppeteer-config.json(含 --disable-dev-shm-usage)→ 预期成功 ====="
ts=$SECONDS; timeout 60 mmdc -i d.mmd -o b.png -p /sandbox/puppeteer-config.json >b.log 2>&1; rc=$?
echo "rc=$rc 用时=$((SECONDS-ts))s"; tail -3 b.log; ls -l b.png 2>/dev/null && echo "[B 出图]" || echo "[B 无图]"
echo; echo "===== 结论 ====="
echo "chromium 在 + A挂超时 + B出图 => 纯 /dev/shm 64MB 问题,fix=给 docker run 加 --shm-size 或强制用 B 的 config"
echo "chromium 缺/B 也失败 => 更深的环境问题,看上面 b.log"

Binary file not shown.

After

Width:  |  Height:  |  Size: 646 KiB

View File

@ -0,0 +1,106 @@
# 科研智能助手 · 操作说明书(精简版)
> 适用:无机非金属材料(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材)科研人员。
---
## 1. 它能帮你做什么
一句话:**把模糊的科研需求,变成可交付的成果文件。** 它不只是聊天,能查库、跑算、出图、写文档,并产出可下载的 `.docx` / `.pptx` / 图 / 数据。
相较通用聊天工具的**关键优势**
- **接入内部材料文献库**——100 万+ 篇材料论文7 大学科),**中文提问命中英文文献**,全文可读,免你 OCR 翻库;另接 OpenAlex 元数据库补 DOI / 拉 PDF。
- **科研写作全覆盖**——论文投稿稿、6 类基金申报书、国/行/团标、专利交底书、审稿润色,规范成稿、文献真实。
- **科研计算**——XRD 模拟 / 相图、配方-性能建模、出版级学术图(中文 + 矢量)。
- **真实执行**——在安全沙箱里实跑 Python、出图、转档结果是算出来的并有**跨任务长期记忆**。
---
## 2. 界面:三栏
| 左:任务列表 | 中:对话区 | 右:文件区 |
|---|---|---|
| + 新建任务 | 与助手一问一答 | 当前工作目录的文件 |
| 搜索 / 筛选 | 实时显示进度 | 上传 / 预览 / 下载 |
| 技能 / 记忆 | 底部输入框 | |
![](assets/image-1781662287932-1.png)
> 手机端顶部有「任务 / 对话 / 文件」标签切换;分隔线可拖宽,`` `` 可折叠两侧栏。
**三个概念(务必理清):个人文件夹 → 工作目录 → 任务**
| 概念 | 是什么 |
|---|---|
| **个人文件夹(「我的」)** | 你的私有根空间,别人看不到;右栏文件区顶层即「我的」。 |
| **工作目录** | 一个项目 / 课题的文件夹,存该项目的素材与成果;右栏展示的就是它。**可被多个任务共享**。 |
| **任务** | 一次对话会话,新建时绑定到某个工作目录。 |
**任务 ≠ 文件夹**:默认一个任务跟随任务名建一个同名目录(一对一);也可让多个任务挂**同一个工作目录**共享文件(如写一份本子,「立项依据 / 技术路线 / 经费」几个任务共用「XX 基金」目录,文件互通、对话各自独立)。
> 类比:个人文件夹是家,工作目录是项目抽屉,任务是围绕抽屉的一次次对话。
---
## 3. 上手三步
1. **新建任务**:点左栏 `+ 新建任务` → 填任务名(其余默认即可)→ `创建`
- 可选「智能体类型」预设专长,不选则助手按你的话自动判断。
2. **说需求**:在中栏底部输入框打字,**Enter 发送 / Shift+Enter 换行**。需求越具体越好(说清对象、目标、要什么产物)。
- 例:「按国自然面上,写低碳水泥熟料的立项依据」「这组掺量-28天强度数据建回归模型并出图」。
3. **取文件**:助手产出的文件出现在右栏,点开预览,右上角 `下载`
> 助手会分步执行(查资料 → 起草 → 出图),中栏实时显示进度;若提示「回复『继续』可续跑」,回个「继续」即可。
---
## 4. 能力一览(技能)
新建任务时可指定「智能体类型」,或直接在对话里说需求由助手自动挂载。点左栏底部 **`技能`** 可看完整说明。
| 类别 | 能力 | 产物 |
|---|---|---|
| 科研写作 | 学术论文(中文核心/英文 SCI含**引文三角核验**、基金申报书6 类)、标准起草(国/行/团标 + 编制说明)、专利交底书、审稿润色 | `.docx` |
| 演示出图 | PPT 演示稿、出版级学术图XRD / TG-DSC / 应力应变,矢量) | `.pptx` / 高清图 |
| 文献检索 | 内部材料库(中文命中英文,**找材料文献优先**、OpenAlex 元数据库(要 DOI 走它) | 文献清单 / PDF |
| 科研计算 | 晶体·物相XRD/相图,含中英相名映射)、配方-性能建模与机器学习 | 数据 / 图 / 结论 |
| 内容生成 | 文生图 / 改图、文生视频 | 图 / 视频 |
| 通用 | 问题拆解(模糊命题 → 子问题 + 路线图)、代码调试 | — |
> **写论文 vs 写本子 vs 审稿**:把数据写成投稿稿用「学术论文」;写基金本子用「申报书」;只改已有稿用「审稿」。
>
> 任务常串多个技能(如**写论文全流程**:查文献 → 建模出数据 → 出图 → 起草 → 引文核验 → 终审),把目标说清即可,助手自动调度。每次都要重复交代的一套做法,可让助手**固化成你的私有技能**。
---
## 5. 文件操作
- **上传**:点右栏 `⬆`、或直接拖文件进右栏、或在输入框 **Ctrl+V 粘贴**图片(随消息发给助手)。
- **选入**:点 `⊕` 从其他目录勾选文件,复制 / 移动到当前目录复用。
- **预览**:点文件即在线看——图片可 Ctrl+滚轮缩放Word/PDF/PPT/文本/表格直接渲染,无需下载。
- **下载**:预览弹窗右上角 `下载`
---
## 6. 常用小功能
- **切模型**:中栏顶部下拉切对话模型;旁边 `⚙` 选生图 / 生视频模型。
- **润色**`✨ 润色` 把随手草稿改成更清晰的指令再发Ctrl+Z 可撤销)。
- **方案确认卡**:助手在分叉点会给可点选项,点一个继续、或直接打字讨论。
- **消息目录**:长对话右缘有圆点轨道,悬停看标题、点击定位到某轮提问。
- **记忆**:左栏底部 `记忆` 只读查看长期记忆;**想改直接在对话里说**「记住… / 改成… / 忘掉…」。
- **任务管理**:左栏 `筛选 ▾` 按名称 / 状态 / 目录筛选排序;中栏 `完成` 归档,`⋯` 里可导出 / 清空 / 废弃 / 删除(**删除不可逆,暂时不用建议「废弃」**)。
- **账户**:右上角 `改密码` / `退出登录`;右栏底部显示已用存储与版本号。
---
## 7. 用好它的几条建议
- **需求越具体越好**说清对象、目标、约束、想要的产物形式Word 还是 PPT要图还是数据。不会表达就先点 `✨ 润色`
- **一件事一个任务**:文件与对话都更清爽,便于归档筛选。
- **文献多为英文别担心**:用中文提问即可,助手自动转专业英文术语检索。
- **图 / 文件没被看到**:确认已出现在右栏;粘贴的图记得连同消息一起发送。
---
> 把它当成一位**懂材料、会查库、能动手出活**的科研助理:你说清要什么,它把活干出来、把文件交到你手上。

359
docs/操作说明书.md Normal file
View File

@ -0,0 +1,359 @@
# 科研智能助手 · 操作说明书
> 适用对象:无机非金属材料(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材)科研人员
> 本说明书从**登录后正式操作**讲起。
---
## 目录
1. [平台能做什么(核心优势)](#1-平台能做什么核心优势)
2. [界面总览:三栏布局](#2-界面总览三栏布局)
- [2.1 三个概念:个人文件夹 → 工作目录 → 任务](#21-三个概念个人文件夹--工作目录--任务)
3. [第一步:新建任务](#3-第一步新建任务)
4. [第二步:与助手对话](#4-第二步与助手对话)
5. [选对「智能体类型」(技能矩阵)](#5-选对智能体类型技能矩阵)
6. [文件管理:上传 / 选入 / 预览 / 下载](#6-文件管理上传--选入--预览--下载)
7. [进阶操作](#7-进阶操作)
8. [任务管理](#8-任务管理)
9. [图像与视频能力](#9-图像与视频能力)
10. [账户与存储](#10-账户与存储)
11. [常见问题与使用建议](#11-常见问题与使用建议)
---
## 1. 平台能做什么(核心优势)
本平台是一个**面向材料科研的 AI 智能助手**。它不只是聊天工具——它能真正打开你的资料、查内部文献、跑计算、出图、写文档并把成果以可直接交付的文件Word / PPT / 图片 / 数据)产出。
相较通用聊天工具,本平台对科研工作有几项**关键优势**
| 优势 | 说明 |
|---|---|
| **接入内部材料文献库** | 内置 **100 万+ 篇材料学科论文**(胶凝 / 陶瓷 / 玻璃 / 晶体 / 复合 / 耐火 / 检验检测 7 大学科库,以 Elsevier 期刊为主),支持**中文提问命中英文论文**的跨语言语义检索,并已整篇 Markdown 化,助手可直接通读全文,免去你自己 OCR / 翻库。 |
| **接入文献元数据库** | 另接一套基于 OpenAlex 元数据的文献库,可按关键词 / DOI / 作者查文献、取摘要、拉 PDF适合补 DOI、找全网文献。 |
| **科研写作全覆盖** | 申报书 / 任务书6 类基金)、国标·行标·团标(含编制说明)、发明专利技术交底书、审稿润色——均按规范章节骨架成稿,文献真实、指标可考核。 |
| **科研计算能力** | 晶体结构 / XRD 模拟 / 相图pymatgen内置水泥·陶瓷·耐火相名中英文映射、配方—性能建模与机器学习、出版级学术图中文字体 + 矢量 + 投稿级排版纪律)。 |
| **可直接产出文件** | 不是给你贴一段文字,而是生成 `.docx` / `.pptx` / 高清图 / 数据文件,下载即用。 |
| **跨任务长期记忆** | 你的领域、习惯、常用约定会被记住,下次不用重复交代。 |
| **真实执行环境** | 助手在隔离的安全沙箱里实际运行 Python、渲染图表、转换文档结果是真算出来的不是「编」的。 |
> 一句话:**把模糊的科研需求,变成可交付的成果文件。**
---
## 2. 界面总览:三栏布局
登录后进入主界面,整体分为**左 / 中 / 右三栏**
![](assets/image-1781662287932-1.png)
- **左栏 = 任务**:你的所有工作会话,每条叫一个「任务」。顶部有「+ 新建任务」和搜索 / 筛选;底部有「技能」「记忆」两个入口。
- **中栏 = 对话**:选中某个任务后,在这里与助手对话。中部会实时显示助手正在做什么(查文献、跑脚本、出图…),底部是消息输入框。
- **右栏 = 文件**:当前任务工作目录下的所有文件。助手生成的成果、你上传的素材都在这里,可预览、可下载。
> **手机端**:顶部会出现「任务 / 对话 / 文件」三个标签,点击切换三栏。
三栏宽度可拖拽中间的分隔线调整;点栏顶的 `` / `` 可折叠左 / 右栏,给对话区让出空间。
### 2.1 三个概念:个人文件夹 → 工作目录 → 任务
理解这三者的层级关系,是用好平台的关键:
```
我的(个人文件夹 · 你的私有空间,别人看不到)
├── 工作目录 A一个项目 / 一个课题的文件夹)
│ ├── 任务①(对话会话:写大纲) ┐
│ ├── 任务②(对话会话:起初稿) ├─ 共用 A 里的同一批文件
│ └── 文件:素材 / 草稿 / 成果 ┘
├── 工作目录 B另一个项目
│ └── 任务③
└── ……
```
| 概念 | 是什么 | 关系 |
|---|---|---|
| **个人文件夹(「我的」)** | 你的**私有根空间**,所有工作目录都在它下面。每个用户彼此隔离,看不到别人的内容。右栏文件区面包屑最顶层就标「我的」。 | 最外层容器 |
| **工作目录** | 一个项目 / 课题的文件夹,存放该项目的素材、草稿、成果。右栏「文件」区展示的,就是当前任务所属的工作目录。 | 属于个人文件夹;**可被多个任务共享** |
| **任务** | 一次**对话会话**(上下文 + 进度 + 消息记录)。新建任务时绑定到某个工作目录。 | 属于某个工作目录 |
**关键点:任务 ≠ 文件夹。**
- 新建任务默认「跟随任务名」**自动新建一个同名工作目录**——此时任务与目录一对一,最省心。
- 你也可以让**多个任务共用一个工作目录**:新建任务时把「工作目录」选成已有的那个,它们就**共享同一批文件**。
- 典型场景写一份本子建「立项依据」「技术路线」「经费测算」几个任务都挂在同一个「XX 基金」工作目录下——彼此能看到对方产生的文件,但**对话各自独立、互不干扰**。
- 在右栏文件区,面包屑点到顶层「我的」,可浏览你**所有**工作目录;用 `⊕ 选入`(见 [6.2](#62-选入从其他目录带文件进来))还能把别的目录里的文件复制 / 移动到当前目录复用。
> **一句话****个人文件夹是你的家,工作目录是一个个项目抽屉,任务是围绕某个抽屉展开的一次次对话。** 文件按抽屉(工作目录)存,对话按任务分。
---
## 3. 第一步:新建任务
所有工作都从「任务」开始。一个任务 = 一个独立的工作会话 + 一个独立的文件目录,互不干扰。建议**一件事一个任务**如「XX 基金申报书」「玻璃配方建模」各建一个),方便管理与归档。
**操作步骤:**
1. 点左栏顶部蓝色按钮 **`+ 新建任务`**。
2. 在弹窗中填写:
[截图:新建任务弹窗]
| 字段 | 是否必填 | 说明 |
|---|---|---|
| **任务名** | 必填 | 一眼能认出的名字,如「初稿大纲」「早强配方对比」。 |
| **工作目录** | 默认即可 | 任务产生的文件存放处。默认「跟随任务名」自动新建;也可**选已有目录,让多个任务共享同一批文件**(概念见 [2.1](#21-三个概念个人文件夹--工作目录--任务))。 |
| **描述** | 可选 | 对任务的补充说明。 |
| **智能体类型** | 可选 | 预先指定助手用哪种专长(写本子 / 出 PPT / 查文献…)。不选则由助手按你的话自动判断。详见 [第 5 节](#5-选对智能体类型技能矩阵)。 |
| **模型** | 默认即可 | 驱动助手的大模型,一般用默认。 |
3. 点 **`创建`**。任务出现在左栏列表顶部并自动选中,即可开始对话。
> **小贴士**:「智能体类型」不是必须先选——你完全可以建一个空白任务,直接用大白话说需求,助手会自己挂载合适的能力。
---
## 4. 第二步:与助手对话
选中任务后,中栏底部出现输入框。这是你与助手交互的主要方式。
[截图:对话区 + 底部输入框]
### 4.1 发送消息
- 在输入框打字,按 **Enter 发送****Shift + Enter 换行**。
- 发送后,助手开始工作。中栏会**实时显示它在做什么**——「正在检索文献」「正在运行脚本」「正在生成图表」等,过程透明可见。
- 助手可能一次完成,也可能分多步(查资料 → 起草 → 出图)。耐心等它跑完一轮即可。
**提问示例(越具体越好):**
> - 「帮我查一下碱激发胶凝材料早期强度发展的近五年文献,要英文期刊的。」
> - 「按国家自然科学基金面上项目,写一份关于低碳水泥熟料的申报书立项依据。」
> - 「这组掺量—28天强度数据帮我建个回归模型并出一张图。」
### 4.2 「✨ 润色」按钮
如果你的需求只打了草稿、表达比较随意,可先点输入框右侧的 **`✨ 润色`**。它会用模型把你的草稿改写成更清晰、更可执行的指令再发送(误改了可 Ctrl+Z 撤销)。适合不确定怎么把需求说清楚时。
### 4.3 切换模型
中栏顶部的对话信息行有一个**模型下拉框**(常驻),可随时切换当前任务使用的对话模型。旁边的 **`⚙`(媒体)** 齿轮里可单独选「生图模型」「生视频模型」,详见 [第 9 节](#9-图像与视频能力)。
### 4.4 中途暂停 / 继续
- 助手运行时,发送按钮会变为可**停止**当前运行。
- 若助手因步数上限等原因停下,通常会提示「回复『继续』可续跑」——直接回个「继续」即可。
---
## 5. 选对「智能体类型」(技能矩阵)
平台内置一套**专业技能**,覆盖材料科研的高频场景。你可以在新建任务时指定,也可以在对话里直接说需求由助手自动挂载。下面是能力清单与**何时用**
### 科研写作
| 技能 | 做什么 | 典型产物 |
|---|---|---|
| **学术论文写作** | 把实验数据 / 前期报告写成**可投稿的期刊论文**:中文核心 / 英文 SCI原创研究 / 综述 / 快报。按 IMRaD 骨架先建文献矩阵、先定图表、逐章「一段一卡」起草,并做**引文三角核验**(每条引文回库查真、定位原文锚点,杜绝编造与引而不实)。 | 投稿稿 `.docx`+ 可选 cover letter / Highlights / 声明等投稿件) |
| **申报书 / 任务书** | 写本子、立项依据、研究方案、技术路线。覆盖国家重点研发、重大专项、国自然面上 / 青年 / 联合基金、省地方、横向共 6 类基金;自动生成经费表、技术路线图,并**严守文献真实性**(不编引文)。 | 带目录与图表编号的 `.docx` |
| **标准起草** | 起草国标 / 行标 / 团标(重点对接 CSTM 团标)及**编制说明**,符合 GB/T 1.1—2020自动套用「应 / 宜 / 可」能愿动词、指标量化闭环。 | 标准正文 `.docx` + 编制说明 `.docx` |
| **专利交底书** | 把项目材料 / 论文 / 代码挖成发明专利点,做现有技术检索,按七章骨架成稿,供代理师转写。 | 技术交底书 `.docx` |
| **审稿 / 润色** | 审中英文论文 / 报告 / 申报书,先抓全局结构再改语言;长文档先出骨架扫描再分段深审。 | 问题清单 + 修改稿 |
> **写论文 vs 写本子 vs 审稿**:要把数据写成**期刊投稿稿**用「学术论文写作」;写**基金申报书 / 任务书**用「申报书」;只**改已有稿**用「审稿 / 润色」。
### 演示与出图
| 技能 | 做什么 | 典型产物 |
|---|---|---|
| **PPT 演示稿** | 把汇报材料做成商务风可演示的 `.pptx`卡片式版式、论断式标题、KPI 数据卡,原生可编辑。 | `.pptx` |
| **出版级学术图** | 出 SCI 投稿级 matplotlib 图XRD 多谱叠图、TG-DSC 双轴、应力—应变…),中文字体 + viridis 配色 + 矢量输出。 | 高 dpi PNG / SVG / PDF |
### 文献检索(**平台核心优势**
| 技能 | 做什么 | 关系 |
|---|---|---|
| **内部材料知识库** | 查 7 大学科 100 万+ 篇论文,**中文提问也能命中英文文献**,整篇 Markdown 直接可读。找材料类文献**优先用它**。 | 与下者互补 |
| **文献元数据库** | 基于 OpenAlex 查全网文献,要 DOI、要 PDF、要摘要走它。 | 找其他学科或要 DOI 走它 |
### 科研计算
| 技能 | 做什么 |
|---|---|
| **晶体 / 物相计算** | 晶体结构 I/O、XRD 正向模拟与比对、相图、空间群、对称性;内置水泥熟料 / 陶瓷 / 耐火 / 玻璃相名中英文映射C3S、钙矾石、莫来石…。 |
| **统计建模与机器学习** | 配方—性能(强度 / 流动度 / 凝结时间回归、DoE 响应面、假设检验与置信区间、小样本贝叶斯估计、聚类找异常配方。 |
### 内容生成与通用
| 技能 | 做什么 |
|---|---|
| **文生图 / 改图** | 按描述生成图片,或在已有图上做像素级修改(详见第 9 节)。 |
| **文生视频** | 按描述生成短视频片段(详见第 9 节)。 |
| **问题拆解** | 面对模糊的高层科研问题,帮你拆成可操作的子问题 + 实施路线图,再接力给上面的专业技能。 |
| **代码 / 调试** | 修代码、调试、实现脚本。 |
> **查看完整技能说明**:点左栏底部 **`技能`** 按钮,弹窗里分「平台技能 / 我的技能」两列,点任一项可展开完整说明文档。
>
> [截图:技能查看弹窗]
### 跨技能协作(典型组合)
实际任务常常串联多个技能,例如:
- **写论文全流程**:查文献建证据底座 → 配方建模 / 计算出数据 → 出版级出图 → 逐章起草 → 引文核验 → 审稿终审 →(可选)出投稿件。
- **写本子全流程**:问题拆解 → 查文献 → 配方建模出预实验数据 → 出图 → 写申报书 → 审稿。
- **PPT 汇报**:提炼论点 → 找数据与引文 → 出图 → 组装 PPT →(可选)做封面图。
你不需要手动串——把目标说清楚,助手会自动调度。
### 自定义你自己的技能
如果你每次都要重复交代同一套做法(术语表、模板、默认值),可以让助手把它**固化成你的私有技能**(只对你生效):在对话里说「我想要个自己的技能 / 把这套流程固定下来」,或「平台的 XX 技能挺好,但我想改成 YY」即可。
---
## 6. 文件管理:上传 / 选入 / 预览 / 下载
右栏「文件」区是当前任务的工作目录。助手生成的成果、你提供的素材都在这里。
[截图:右栏文件区 + 顶部三个按钮]
### 6.1 上传素材
把已有资料(汇报草稿、数据表、参考论文、图片)交给助手处理,有三种方式:
1. **点 `⬆` 按钮**,选文件上传到当前目录。
2. **直接把文件拖进右栏**(出现「松开以上传」提示时放手)。
3. **在对话输入框 Ctrl+V 粘贴**文件(如截图、图片),会生成一个可预览的小卡片,随消息一起发给助手。
### 6.2 选入(从其他目录带文件进来)
`⊕` 按钮可从你**其他任务 / 目录**里勾选文件或文件夹,**复制**或**移动**到当前任务目录。适合复用之前任务的素材。
[截图:选入文件弹窗]
### 6.3 预览
点文件列表里的任意文件即可在线预览,**无需下载**
- **图片**:支持 Ctrl + 滚轮缩放、放大后左键拖动平移、双击复位。
- **Word.docx/ PDF**:直接在线阅读。
- **PPT.pptx**:自动转换后在线预览版面。
- **文本 / Markdown / 表格**:直接渲染。
[截图:文件预览弹窗(以一张图或一份 docx 为例)]
### 6.4 下载
预览弹窗右上角有 **`下载`** 按钮,可保存原文件到本地。
---
## 7. 进阶操作
### 7.1 方案确认卡
当助手遇到需要你拍板的分叉点(如「用方案 A 还是方案 B」它会在回复里给出**可点击的选项卡**。你可以:
- **直接点某个选项**——相当于把该选项作为回复发出,助手继续;
- **或不点,直接用文字讨论**——把你的想法打出来也行。
历史里已选过的选项会标「✓ 已选」。
[截图:方案确认卡(带可点选项)]
### 7.2 消息目录导航(长对话快速定位)
长对话里,对话区**右缘会悬浮一列圆点**,每个点对应你的一轮提问。鼠标悬停出现该轮标题气泡,点击即可滚动定位到那一轮。方便在几十轮的长任务里快速回到某个问题。
### 7.3 跨任务长期记忆
点左栏底部 **`记忆`** 按钮,可**只读查看**助手记住的、跨任务共享的长期信息(你的领域、常用约定等)。
- **想改记忆?** 不在这里操作——**直接在对话里说**「记住……」「改成……」「忘掉……」,助手会帮你维护。
[截图:记忆查看弹窗]
---
## 8. 任务管理
### 8.1 查找与筛选
左栏顶部点 **`筛选 ▾`** 展开筛选区,可:
- **搜索**任务名 / 描述;
- 按**状态**(进行中 / 已完成 / 已废弃)筛选;
- 按**工作目录**筛选;
- 按创建时间 / 更新时间 / 名称 / 状态分组**排序**。
### 8.2 任务操作菜单
选中任务后,中栏顶部有:
- **`完成`**:把任务标记为已完成(归档,不删数据)。
- **`⋯`(更多)**:导出对话、清空对话、废弃、删除。
| 操作 | 含义 | 可逆性 |
|---|---|---|
| **导出对话** | 把整个对话记录导出留存。 | — |
| **清空对话** | 清掉对话消息(保留任务与文件)。 | 谨慎 |
| **废弃** | 标记为已废弃(仍可在筛选里找到)。 | 可恢复 |
| **删除** | 彻底删除任务。 | **不可逆** |
> **提示**:破坏性操作(废弃 / 删除)按颜色区分(橙 / 红),并有二次确认,避免误点。
---
## 9. 图像与视频能力
平台可按文字描述**生成图片**、在已有图上**改图**、**生成短视频**,也能**「看懂」一张图**OCR、读图表、描述内容
### 9.1 选择媒体模型
中栏对话信息行的 **`⚙`(媒体)** 齿轮里,可单独选「生图模型」「生视频模型」。选定后随你下一条消息生效。
### 9.2 生图与改图
- **生图**:直接说「画一张……」「来个封面」。助手会先把要画的内容、尺寸等**整理成最终描述明文展示给你确认**,你拍板后才真正出图。
- **改图**:对刚生成或你上传的图说「按这个改成 X」助手会在**那张图上修改**(保留原构图),而不是重画。
> **关于费用**:生图、生视频属于**计费能力**(按次 / 按时长),助手在调用前**一定会先把方案明文给你确认**,不会擅自消费。静态图够用时不建议上视频(视频单价显著更高)。
### 9.3 看图(图像理解)
助手在需要时会自动「借眼睛」读图——识别图中文字、读取图表数据、描述图片内容。你上传图后直接问「这张图里写了什么 / 这条曲线峰值多少」即可。
---
## 10. 存储
### 10.1 存储用量
右栏底部有一条**存储进度条**,显示你当前已用的存储空间。最左侧还显示当前平台版本号。
---
## 11. 常见问题与使用建议
**Q怎么让结果更好**
A需求越具体越好——说清**对象、目标、约束、想要的产物形式**(要 Word 还是 PPT要图还是数据。不确定怎么表达时先点 `✨ 润色`
**Q助手好像停住了 / 没出全?**
A若提示「回复『继续』可续跑」直接回「继续」。长任务分多步是正常的。
**Q文献查出来大多是英文**
A内部材料库主语料是英文期刊但**支持中文提问命中英文文献**。你用中文描述需求即可,助手会自动转专业英文术语检索。
**Q我上传的图 / 文件助手看不到?**
A确认文件已出现在右栏「文件」区对话里用 Ctrl+V 粘贴的图,记得**连同那条消息一起发送**。
**Q一个任务能干很多不相干的事吗**
A不建议。**一件事一个任务**,文件与对话都更清爽,也便于后续归档、筛选。
**Q误删了任务能找回吗**
A**删除不可逆**。只是暂时不用,建议用「废弃」而非「删除」——废弃后仍可在筛选里找回。
---
> **使用心法**:把它当成一位**懂材料、会查库、能动手出活**的科研助理。你负责说清要什么,它负责把活干出来、把文件交到你手上。

View File

@ -0,0 +1,428 @@
# 科研 AI 双智能体 · 汇报 PPT 大纲
> 单位:中国建筑材料科学研究总院 · 中存大数据
> 用途:生成汇报 PPT 的内容底稿。本文件只定**结构 + 每页要点 + 呈现形式**,不写大段叙述文字。
> 编写日期:2026-06-24
---
## 0. 总体设计说明(给设计 / 制作人员看)
**叙事主线 —— 通用 + 垂直,双轮驱动:**
| | 第一部分 | 第二部分 |
|---|---|---|
| 名称 | 通用科研辅助智能体 | 无机非金属材料自主研发智能体 |
| 定位 | **横向**:服务全院科研人员日常全流程 | **纵深**:材料配方自主研发的自动化 |
| 入口 | 自然语言,任意科研任务 | 材料研发需求 → 实验方案/配方 |
| 形态 | 17 项 skill 能力矩阵 + 可交付物 | 五大引擎 + 配方大模型(垂直微调) |
| 一句话 | 把"想法"变成可交付的科研产物 | 把"性能要求"变成可执行的实验配方 |
**呈现纪律(全程硬约束):**
- 每页**论断式标题**(写结论,不写"XX 介绍")。
- 正文只用:**短卡片(≤12 字)/ KPI 数字卡 / 流程图 / 时间轴 / 对比表 / 矩阵网格**。禁止整段话。
- 每页带一行【呈现形式】,指明该页用什么版式画。
- 颜色:商务红主题(主色 #C00000),关键数字 / 核心步骤高亮。
- 凡是带"流程"的页,一律画成**节点+箭头流程图**,不写成文字列表。
**全篇页序(约 26 页):** 封面 → 双智能体总览 → [PART1:1.01.10] → [PART2:2.02.10] → 总结 → 展望/交流。
---
## 封面
- 主标题:**科研 AI 双智能体**
- 副标题:通用科研辅助智能体 · 无机非金属材料自主研发智能体
- 落款:中国建筑材料科学研究总院 · 中存大数据 / 2026
【呈现形式】杂志级背景图 + 居中大标题;底部一行四个关键词:自然语言驱动 / 全流程可交付 / 垂直配方大模型 / 统一安全底座。
---
## 总览页 · 一张图看懂两个智能体
**论断:一个横向赋能全院,一个纵向攻坚配方 —— 通用 + 垂直,双轮驱动。**
左右两张大卡:
- 左卡「通用科研辅助智能体」:自然语言入口 · 17 skill · 内部 100 万+ 文献库 · 直出 Word/PPT/图表
- 右卡「材料自主研发智能体」:五大引擎 · 智能实验设计 · 配方大模型(LoRA 微调) · 预测→配方闭环
- 中间用箭头/底座连接:**共享统一底座**(多模型调度 · 向量知识库 · 安全沙盒 · 训练流水线)
【呈现形式】左右双卡 + 下方一条横贯"统一底座"长条。这页是全场的"地图",后面两部分都回指这张图。
---
# 第一部分 · 通用科研辅助智能体
## 1.0 章节分隔页
- PART 01
- **通用科研辅助智能体**
- 副题:以自然语言为入口,把科研任务串成可交付的工作流
【呈现形式】章节封面页,大序号 + 标题 + 一句定位。
---
## 1.1 它是什么 —— 现有功能总览
**论断:不止"问答",而是能自己动手、直接交付成果的科研智能体。**
四张能力卡 + 一行数字条:
- **自然语言驱动**:描述需求 → 自动识别意图、动态挂载专业能力
- **产出可交付物**:直接生成 Word / PPT / 图表 / 数据,贴合科研与申报格式
- **全流程覆盖**:调研 — 计算 — 写作 — 评审,一个智能体串起,无需多工具切换
- **统一底座**:多模型调度 · 安全沙盒 · 长期记忆 · 长任务断点恢复
数字条(KPI):**17** 项专业 skill · **6** 大能力类别 · 内部 **100 万+** 篇材料文献库 · **多渠道**接入(网页/微信/定时)
【呈现形式】2×2 能力卡网格 + 底部一条 KPI 数字条(4 个数字)。
---
## 1.2 它怎么工作 —— 五步工作流
**论断:意图识别 → 动态挂载能力 → 沙盒内执行 → 关键节点人工确认 → 规范化成果。**
横向五段流程:
1. **自然语言需求**(用户提出)
2. **意图识别**(自动挂载对应 Skill)
3. **工具调用循环**(安全沙盒内自主迭代:思考→调用工具→观察)
4. **人工确认**(关键决策由用户拍板,过程可追溯)
5. **规范化成果**(Word · PPT · 图表 · 数据)
底部一条"统一底座支撑":多模型调度 / 安全沙盒隔离 / 个人文件库 / 长期记忆·断点恢复
【呈现形式】横向 5 节点流程图(箭头串联)+ 底部一条底座长条,做成主图、放大。
---
## 1.3 能力矩阵 —— 科研全流程 Skill 体系
**论断:17 项专业能力,按科研全流程六大类组织,可持续扩展。**
六张分类卡(每卡:类名 + 含的 skill + 一句话):
- **科研写作**:proposal 申报书 / paper 论文 / standard 标准 / patent 专利 / review 审稿 —— 立项到评审全链路
- **文献检索**:documents 内部库 / research 全网 / brief 方向简报 —— 可溯源文献支撑
- **科研计算**:pymatgen 晶体计算 / stats_ml 配方建模 —— "配比→性能"预测寻优
- **演示出图**:ppt 商务级幻灯 / plot_pub 出版级学术图 —— 能看、能讲、能投稿
- **通用元能力**:analyze 问题拆解 / coding 代码实现
- **可定制**:skill-creator 用户私有 skill(从零写或 fork 内置再改)
【呈现形式】2×3 卡片网格,每卡一个图标。下面五页对其中"标志性"能力各展开一页。
> 说明:内容生成(文生图/文生视频)本次汇报不展开,不单列页。
---
## 1.4 标志性能力 ① 文献检索 —— 内部百万级材料文献库
**论断:中文提问,命中英文文献 —— 100 万+ 篇材料学科论文,可溯源。**
主体两块:
- **七大学科库**(卡片/六边形网格,各一行):胶凝材料 · 陶瓷基 · 玻璃基 · 晶体 · 复合 · 耐火 · 检验检测
- **三路检索分工**(小流程):
- `documents` 内部库:100 万+ 英文论文,已 Markdown 化(LLM 直读),**跨语言语义检索**
- `research` 全网:OpenAlex 元数据 + DOI + PDF 下载
- `brief` 方向简报:重要论文列表 + 内容总结,520 分钟掌握一个方向
差异化标签(高亮):**跨语言检索** · **可溯源引用** · **立项依据有真实文献支撑**
【呈现形式】上方七学科库网格,下方三路检索分工小图;右侧竖排三个差异化标签 pill。
---
## 1.5 标志性能力 ② 项目申报 —— proposal
**论断:把课题信息变成可提交的申报书,评审雷区与文献真实性内置兜底。**
能力卡(短):
- **6 类基金骨架**:重点研发 / 重大专项 / 国自然面上·青年 / 联合基金 / 省地方 / 横向
- **评审雷区清单** + "不可考核词"过滤
- **文献真实性铁律**:不允许编造引文(GB/T 7714 顺序编码)
- **自动化产出**:间接费用台阶 + 经费表自动生成 · 技术路线图自动渲染插图
- **一段一卡**:关键章节逐段确认,不一口气出全文
产物:带目录 + 自动图题 + 图表编号的 `.docx`
【呈现形式】左侧"6 类基金"卡片网格,右侧"需求 → 一段一卡起草 → 渲染 docx"竖向流程;底部一条产物预览缩略。
---
## 1.6 标志性能力 ③ 科研写作全家桶 —— 论文 / 标准 / 专利 / 审稿
**论断:从论文到标准、专利、审稿 —— 写作全链路,反 AI 幻觉是底线。**
四象限卡(每卡:skill + 输入→产物):
- **paper 论文**:实验数据 → 中文核心 / 英文 SCI 投稿稿(IMRaD + 引文三角核验)
- **standard 标准**:材料/方法 → 国标 / 行标 / 团标 + 编制说明(GB/T 1.1—2020)
- **patent 专利**:项目素材 → 发明专利技术交底书(供代理师转写)
- **review 审稿**:已有稿 → 问题表 + 修改稿(长文分段深审)
横贯亮点条(高亮):**引文三角核验** —— 存在性 → 三角印证 → 支撑度,编造引文**零容忍**。
【呈现形式】2×2 象限卡 + 底部一条横贯"引文三角核验"亮点带。
---
## 1.7 标志性能力 ④ 材料计算 —— pymatgen + stats_ml
**论断:从晶体结构到配方建模 —— 服务"配比 → 性能"的预测与寻优。**
左右两栏:
- **pymatgen 无机材料计算**:晶体结构 I/O · XRD 模拟 · 相图 · 对称性 · Materials Project;**中文相名映射**(C₃S / 钙矾石 / 莫来石 / 方镁石 → 化学式)
- **stats_ml 配方-性能建模**:三库分工(sklearn 预测 / statsmodels 假设检验·p值 / PyMC 小样本贝叶斯);DoE 响应面 · 强度预测 · 异常配方聚类
典型场景标签:XRD 谱图模拟 · TG-DSC 双轴 · 强度预测 · 响应面寻优
【呈现形式】左右双栏卡,每栏配 23 个典型场景小图标;高亮"中文相名映射"和"三库分工"。
---
## 1.8 标志性能力 ⑤ 演示出图 —— ppt + plot_pub
**论断:成果"能看、能讲、能投稿" —— 商务级幻灯 + 出版级学术图。**
左右两块:
- **ppt 商务级演示**:卡片式视觉系统 · 论断式标题 · 信息设计纪律 · 一键整建 deck(原生可编辑)
- **plot_pub 出版级学术图**:中文 + viridis + 矢量(SVG/PDF)· 投稿级复合图设计纪律(XRD 叠图 / TG-DSC 双轴 / 多 panel)
价值标签:贴合期刊投稿(Cement and Concrete Research 等)· 降低整理排版成本
【呈现形式】左右两个产物缩略(一张 PPT 卡片样张 + 一张学术图样张)做观感对比。
---
## 1.9 平台技术架构(架构师视角)
**论断:Less Scaffolding, More Trust —— 把 LLM 当会持续变强的同事,给目标不给步骤。**
四象限架构卡:
- **① 智能体内核**:ReAct 工具调用循环(思考→调用→观察自主迭代)+ 进展守卫(重复调用/空转自动收敛)+ 阶段化编排嵌人工确认
- **② Skill 动态加载**:意图识别按需挂载,不相关能力不进上下文(渐进披露,省算力)+ 可扩展插件(流程+模板+脚本)
- **③ 安全沙盒**:每用户 Docker 容器隔离 · 资源限额 + 网络管控 + 最小权限 + 丰富工具集 / MCP
- **④ 模型·知识·记忆底座**:多模型自由调度(DeepSeek/Qwen + OpenAI 接口,涉密切内网)· RAG 抑制幻觉 · 双层长期记忆 + 长任务断点恢复
底部技术栈条:FastAPI(异步后端 + 原生 SSE)· LiteLLM(多模型统一接入,OpenAI 兼容)· 自研 ReAct 内核 · PostgreSQL(任务/消息 append-only)· Docker(每用户沙盒)· Skill 渐进披露体系
【呈现形式】2×2 架构象限卡 + 底部技术栈 pill 条,每条压成一句。
---
## 1.10 多渠道接入与产品化
**论断:不只是网页 —— 微信对话、定时任务,把智能体送到用户身边。**
三张卡:
- **网页工作台**:三栏 SPA(任务 / 对话 / 文件),消息目录导航、方案确认卡、文件预览
- **微信接入**:个人微信对话即可用,可主动推送简报/结果
- **定时任务**:"每天 X 点干 Y" —— 跑 skill 出简报 / 发邮件,自然语言建任务
【呈现形式】三卡横排,各配渠道图标。
---
# 第二部分 · 无机非金属材料自主研发智能体
## 2.0 章节分隔页
- PART 02
- **无机非金属材料自主研发智能体**
- 副题:水泥基配方大模型 —— 从"性能要求"到"实验配方"的自动化
【呈现形式】章节封面页。承上启下一句:从通用辅助,进入材料研发深水区。
---
## 2.1 五大引擎 —— 一图看全
**论断:五大引擎协同,构成材料研发的智能中枢。**
五个引擎卡(每卡:名称 + 一句≤10 字功能 + 图标):
1. **智能问答中枢**:统一入口,多轮+工具+文件问答
2. **知识库构建**:非结构化文档 → 可检索知识资产
3. **知识库问答**:RAG 结合企业知识,引用溯源
4. **AI 文档分类**:自动归档 + 触发向量重建
5. **智能实验设计**:需求 → 可执行配方(旗舰)
【呈现形式】五卡环形/总线布局,中心写"配方大模型";第 5 个引擎高亮(2.7 展开)。后面 2.32.7 逐个引擎各一页。
---
## 2.2 总体架构图(分层框图)
**论断:应用层 → 五大引擎 → 模型与向量层 → 训练模块,标准接口协同。**
四层框图:
- **User**:业务系统 / 请求
- **Backend 五大引擎**:Chat / KBBuild / KBQA / DocAI / Lab(**LangGraph 编排**复杂逻辑与实验设计流)
- **模型与数据层**:LLM(DeepSeek/Qwen) · Qwen2.5-VL 视觉 · BGE-M3 向量 · Milvus 向量库 · MinerU 解析
- **Train 训练模块**:LLaMA Factory → LoRA → 行业配方模型
【呈现形式】自上而下四层分层框图,层间箭头标接口(RAG / Embedding / LoRA)。只画框和箭头,不写段落。
---
## 2.3 引擎 ① 智能问答中枢
**论断:大模型统一入口 —— 从"回答问题"升级为"执行任务"。**
工作流程(流程图):
用户问题 → 会话与权限处理 → 任务识别 → **是否需要外部能力?**
- 否 → 普通问答 / 文件上下文 → LLM 生成
- 是 → 工具能力 → 读取文档 / MCP 工具调用
→ SSE 流式返回回答
技术卡(短):LangGraph 编排 · DeepSeek V3.1 / Qwen3-30B-A3B · 文件问答 + 多轮 + 思考模式 · MCP 接入外部系统 · SSE 流式输出
价值标签:统一标准化问答 · 高扩展集成业务工具 · 可升级为执行任务
【呈现形式】左侧带分支判定的流程图(菱形判定)+ 右侧技术卡 + 底部价值 pill。
---
## 2.4 引擎 ② 知识库构建
**论断:把分散的非结构化文档,沉淀为可检索、可引用、可追溯的企业知识资产。**
工作流程(流程图):
上传原始文档 → MinerU 解析 → **是否含图片/图表/扫描件?**
- 是 → Qwen2.5-VL 视觉解析 ↘
→ 文本结构化 & 生成 Markdown → 文本切分 → BGE-M3 向量化写入 Milvus → 保存文档元数据
支持内容卡(三类):
- **文档类**:PDF / Word / PPT / Excel
- **图像类**:图片 / 扫描件 / 图表
- **文本类**:Markdown / TXT / CSV / JSON
价值标签:分散资料 → 结构化知识库 · 为问答/实验/训练提供高质量数据基础
【呈现形式】上方带分支的处理流程图 + 下方三类支持内容卡。
---
## 2.5 引擎 ③ 知识库问答
**论断:基于 RAG 结合企业内部知识作答,引用可溯源,显著抑制幻觉。**
工作流程(流程图):
用户问题 → 问题理解 → 生成检索问题 → BGE-M3 向量化 → Milvus 检索 → 组装引用上下文 → 生成答案与溯源
技术卡(短):RAG 检索增强 · BGE-M3 向量化 + Milvus 检索 · DeepSeek/Qwen 结合上下文生成 · 引用来源溯源 · 多维度检索过滤
价值标签:提升专业性/准确性/可追溯 · 赋能私有文档深度问答 · 降低大模型幻觉风险
【呈现形式】横向 7 节点检索流程图(主色高亮"Milvus 检索"与"溯源")+ 右侧技术卡。
---
## 2.6 引擎 ④ AI 文档分类
**论断:自动识别领域与材料分类并归档,触发向量重建 —— 知识治理自动化。**
工作流程(流程图,含闭环):
待分类文档 → 读取解析内容 → 领域预判 → 构建分类体系 → 大模型分类 → 分类结果校验 → 保存 → **是否需调整归属?**
- 是 → 迁移文档并重建向量 → 完成归档
智能输出卡:摘要 · 领域 · 分类路径 · 判定依据 · 置信度
价值标签:降低人工整理归档成本 · 归入正确体系提升检索效率 · 为行业模型筛选标准化数据集
【呈现形式】带回环箭头的闭环流程图 + 一张"智能输出 5 字段"卡。
---
## 2.7 引擎 ⑤ 智能实验设计 —— 核心工作流(旗舰)
**论断:多阶段工作流,把研发需求转成可执行实验配方;核心一步是调用行业微调模型。**
横向时间轴,11 步压成 6 个阶段(核心步高亮):
1. **问题提炼**(科学问题 + 检索分类匹配 + 方向确认)
2. **文献检索分析**(向量库召回 + 逐篇提取实验参数)
3. **初步方案**(融合目标与文献,生成思路框架)
4. **学术评估优化**(多维量化评估,迭代优化路径)
5. ⭐ **配方生成**(调用 Qwen2.5-1.5B LoRA 行业模型 → 原料/配比/条件)
6. **校验 + 用户确认 + 实验工单**(人机协同闭环 → 对接实验室)
【呈现形式】横向 6 段时间轴/泳道,第 5 段(配方生成)用主色高亮放大;标注"人工确认节点"。
---
## 2.8 配方大模型训练 —— 配置与成效
**论断:LLaMA Factory + Qwen2.5-1.5B + LoRA,16 条实测数据完成首版训练。**
左:训练配置卡(短):
- 框架 / 基座:**LLaMA Factory + Qwen2.5-1.5B-Instruct**
- 微调:**PEFT + LoRA**(冻结主干,仅训低秩矩阵)
- 任务:**SFT** 建立"性能要求 → 配方组成"映射
- 数据:**16 组**实验室实测(输入 3d/7d 抗压抗折 → 输出 矿粉/电石渣/脱硫石膏/粉煤灰/水/减水剂 配比)
右:KPI 数字卡网格 + loss 曲线示意:
- 可训练参数占比 **4.57%**(7386 万 / 16.18 亿)
- Loss **0.6897 → 0.0073**(降 **98.9%**)
- 训练轮数 **50** Epochs
- 优化策略:禁用 KV Cache · 梯度检查点 · Torch SDPA 加速
成效三标签:收敛稳定 · 捕捉"低强度→低掺量"行业规律 · 标准化配方输出
【呈现形式】左配置卡 + 右 KPI 网格(4 个大数字)+ 一条 loss 下降曲线示意。
---
## 2.9 现状与下一步 —— 局限与优化路线
**论断:首版受 16 条数据所限偏"记忆";分三阶段补数据、简空间、建闭环。**
左右对比:
- **左 · 当前局限**:
- 数据仅 16 条 → 模型偏"记忆样本",未真正"理解规律"
- 泛化受限 → 未见性能区间配方精度有波动
- **右 · 优化路线**(P0/P1/P2 路线条):
- **P0** 扩充数据集至 **200+**(从记忆升级为理解)
- **P1** 简化配方空间(精简冗余材料,降学习维度)
- **P2** 搭建"预测–实验–反馈"闭环,目标达标率 **≥85%**
【呈现形式】左侧两张"痛点"卡(冷色),右侧 P0→P1→P2 路线时间轴(暖色/主色)。
---
## 2.10 模型矩阵 —— 通用 + 垂直双轮
**论断:通用基座 + 视觉/向量 + 垂直 LoRA 配方模型,打通"解析→沉淀→决策"。**
六行场景表(场景 | 模型 | 用途):
| 场景 | 模型 | 用途 |
|---|---|---|
| 智能问答中枢 | DeepSeek V3.1 / Qwen3-30B-A3B | 通用问答、文件问答、工具调用 |
| 知识库构建 | Qwen2.5-VL + BGE-M3 + Milvus | 文档解析、图表提取、向量入库 |
| 知识库问答 | DeepSeek V3.1 + BGE-M3 + Milvus | RAG 精准问答 + 原文溯源 |
| AI 文档分类 | Qwen3-30B-A3B + BGE-M3 | 自动识别主题、分类归档 |
| 智能实验设计 | 通用大模型 + Qwen2.5-1.5B(LoRA) | 分析文献、生成配方方案 |
| 配方模型训练 | Qwen2.5-1.5B 基座 + BGE-M3 | 学习"性能-配方"映射 |
【呈现形式】六行卡片表(非密集文字表);右侧一句"通用 + 垂直双轮驱动"呼应总览页。
---
# 结尾
## 总结 —— 双智能体落地成效
**论断:一横一纵双智能体已落地,共享统一底座。**
四张成果卡:
- **通用智能体**:17 项 skill · 内部 100 万+ 文献库 · 全流程可交付(Word/PPT/图表)
- **垂直智能体**:五大引擎 · 智能实验设计 · 配方大模型首版(Loss 收敛 0.0073)
- **统一底座**:多模型调度 · 向量知识库 + RAG · 每用户安全沙盒 · 训练流水线 + LoRA 微调
- **业务价值**:打通"数据 → 知识 → 决策"闭环,知识沉淀为可复用资产,支撑研发提效
【呈现形式】2×2 成果卡,关键数字高亮。
---
## 展望 / 交流
- 下一阶段:配方数据集 16 → 200+ · 简化配方空间 · 建"预测–实验–反馈"闭环(达标率 ≥85%)· 持续扩展 skill 与渠道
- **感谢聆听 · 欢迎交流**
【呈现形式】左侧 34 条展望短句(带图标),右侧大字"感谢聆听 / 交流环节"。

54
main.py
View File

@ -150,8 +150,11 @@ def user() -> None:
@click.option("--password", required=True, help="明文密码,后台 bcrypt 哈希落盘")
@click.option("--user-id", default=None,
help="可选指定 UUID(默认随机);用于把已有 user_id 接到邮箱密码登录路径")
def user_add(email: str, password: str, user_id: str) -> None:
"""新建用户:bcrypt(password) → INSERT users(email,password_hash[,user_id])。
@click.option("--role", "role", default="user",
type=click.Choice(["user", "admin"]), show_default=True,
help="admin 可访问 /static/admin.html 管理后台;之后也可 user role 改")
def user_add(email: str, password: str, user_id: str, role: str) -> None:
"""新建用户:bcrypt(password) → INSERT users(email,password_hash,role[,user_id])。
email UNIQUE 报错退出 2;user_id PK 也是撤销直接
`DELETE FROM users WHERE email='...'`(先清该 user tasks,否则 FK )
@ -169,11 +172,32 @@ def user_add(email: str, password: str, user_id: str) -> None:
sys.exit(2)
try:
uid, e = create_user(email=email, password=password, user_id=uid_arg)
uid, e = create_user(email=email, password=password, user_id=uid_arg, role=role)
except UserCreateError as ex:
click.echo(f"[err] {ex.message}", err=True)
sys.exit(2)
click.echo(f"[ok] user added email={e} user_id={uid}")
click.echo(f"[ok] user added email={e} role={role} user_id={uid}")
@user.command("role")
@click.option("--email", required=True, help="目标用户登录邮箱")
@click.option("--role", "role", required=True,
type=click.Choice(["user", "admin"]),
help="user(普通)/ admin(可访问 /static/admin.html 管理后台)")
def user_role(email: str, role: str) -> None:
"""改用户角色:UPDATE users SET role=... WHERE email=...。
admin 才能访问 /v1/admin/* /static/admin.html改完下次请求立即生效
(role DB ,不进 JWT, token 无需重签)email 查无此人 退出 2
"""
from web.auth import UserCreateError, set_user_role
try:
uid, e = set_user_role(email=email, role=role)
except UserCreateError as ex:
click.echo(f"[err] {ex.message}", err=True)
sys.exit(2)
click.echo(f"[ok] role set email={e} role={role} user_id={uid}")
# ─────────────── Web 服务 ───────────────
@ -185,10 +209,24 @@ def user_add(email: str, password: str, user_id: str) -> None:
help="监听端口")
@click.option("--reload/--no-reload", default=False,
help="dev:文件改动自动重启(uvicorn 工厂模式)")
def web(host: str, port: int, reload: bool) -> None:
"""启动 Web 服务(JSON API + dev SPA)。Auth 需 PLATFORM_KEY / JWT_SECRET env。"""
@click.option("--ssl-certfile", default=None,
help="TLS 证书链(fullchain.pem);与 --ssl-keyfile 同时给即在本端口跑 HTTPS")
@click.option("--ssl-keyfile", default=None,
help="TLS 私钥(privkey.pem)")
def web(host: str, port: int, reload: bool,
ssl_certfile: str | None, ssl_keyfile: str | None) -> None:
"""启动 Web 服务(JSON API + dev SPA)。Auth 需 PLATFORM_KEY / JWT_SECRET env。
HTTPS:`--ssl-certfile <fullchain.pem> --ssl-keyfile <privkey.pem>`(uvicorn 原生 TLS,
无需 nginx)两者都不给 = 明文 HTTP(默认,向后兼容)
"""
import uvicorn
# 两者都给才算启用 TLS;只给其一报错提醒(避免半配置悄悄退回 http)
if bool(ssl_certfile) ^ bool(ssl_keyfile):
raise click.UsageError("--ssl-certfile 与 --ssl-keyfile 必须同时提供")
tls = {"ssl_certfile": ssl_certfile, "ssl_keyfile": ssl_keyfile} if ssl_certfile else {}
# timeout_graceful_shutdown=5:SIGTERM 后 uvicorn 至多等 5s 关掉在连的 HTTP 请求
# (主要是长连 SSE GET,断开后客户端会重连,run 不受影响),再进 lifespan shutdown
# 跑真正的 run drain(见 web/app.py finally + config/agent.yaml `shutdown` 段)。
@ -197,11 +235,11 @@ def web(host: str, port: int, reload: bool) -> None:
# reload 模式需要 import string + factory,uvicorn 才能监听文件
uvicorn.run("web.app:create_app", host=host, port=port,
reload=True, factory=True, log_level="info",
timeout_graceful_shutdown=5)
timeout_graceful_shutdown=5, **tls)
else:
from web.app import create_app
uvicorn.run(create_app(), host=host, port=port, log_level="info",
timeout_graceful_shutdown=5)
timeout_graceful_shutdown=5, **tls)
# ─────────────── Sandbox(Stage C 部署前置对账) ───────────────

View File

@ -7,6 +7,7 @@
- `run_python` —— 在子进程里跑 Python (数据处理、生成 .pptx/.docx、画图等)。非短小一次性代码时,先用 `write``.py` 落到 `<task_dir>/scripts/`(如 `scripts/analyze.py`),再 `run_python(script_path="scripts/analyze.py")` 执行 —— 源码留文件里可重读可改可重跑,不挤占对话历史;`scripts/` 只放过程脚本,交付产物仍落 task_dir 根或 SKILL 指定路径。真·一次性短代码(算个数/探查一行)才用 `run_python(code=...)` 内联。
- `load_skill` —— 加载某个 skill 的完整指引
- `task_progress` —— 给 Web 前端发布/更新用户可见的进度步骤列表。只在多步骤任务使用;开始时设 3-7 个关键步骤,每完成或进入一个关键步骤时更新一次。
- `ask_user` —— 在真正的分叉点让用户在 2-4 个互斥方向间点选拍板(见下「方案确认约定」)。
## 进度展示约定
- 多步骤任务开始后,用 `task_progress(action="set_plan", steps=[...])` 发布一份简短计划。
@ -15,6 +16,12 @@
- 任务全部做完时,把最后一步标成 `completed`(让用户在顶部进度面板看到"全绿"收尾),**不要用 `clear`**;`clear` 只在计划被推翻、不再相关时才用。
- 简单问答、单次文件读取、很小的改动不需要调用 `task_progress`
## 方案确认约定(ask_user)
- **只在真正的分叉点用**:存在 2-4 个互斥方向、且用户选哪个会**实质改变你接下来的动作**时,用 `ask_user(question, options=[{label, description}])` 让用户点选拍板。典型:确认实施方案、在多条候选路线里选一条、在明确取舍间二选一。
- `label` 写成「用户可直接当作回复的一句话」—— 用户点它就等于发出这句话;`description` 可选,补一句该选项的取舍/后果。
- **不要滥用**:信息缺失的开放性提问直接用文字问;你能自己合理默认就推进的决定别问(跟「能自己定的别停下来问」一致);单纯是/否确认、进度播报都不用 `ask_user`
- 每轮最多调用一次;**调用后你的发言即结束、等待用户**,不要在同一轮里继续往下做。用户可能点选项、也可能不点直接用文字与你讨论,两种都要能自然接住。
## Skill 机制
你启动时只看到下方 skill 的"名字 + 描述"。Skill 是**可选辅助** —— 任务明确落在
某个 skill 领域(用户要做 PPT、写申报书等)时,先 `load_skill(name)` 拿完整指引
@ -33,6 +40,7 @@
- 工具结果带 `[Error ...]` 时,先想清楚原因再重试,不要盲目重复同一调用
- 不臆造 API、文献、数据 —— 不知道就 read 源码 / 让用户提供 / 明说不知道
- 少来回:多个**互相独立、不依赖中间结果**的操作(建多页产物、批量改文件、生成整份 deck/文档)合到一个脚本或一轮(并发多 tool call)里做,别一步一个 tool call —— 每轮来回都重发整段上下文,轮数是 token 体量的线性乘数;但**下一步输入要看上一步结果**时(探索性检索、按报错改、需用户确认方向)就老实分步,别硬批
- 大块输出别反复灌进上下文:`run_python`/`shell` 打印的大段结果(整批文献摘要、长文件全文、大 JSON)会进对话历史并**每轮重发**,同一批数据 print 两三次上下文就滚雪球。中间数据**落文件**(如 `<task_dir>/scripts/data.json`、`evidence.md`),之后**只 `read` 用得上的片段**,别为"再看一眼"把整批重新打印 —— 既烧 token 又可能撑爆窗口 / 拖到超时被掐断
## 路径
默认工作目录见系统消息末尾,相对路径都基于它。
@ -40,7 +48,5 @@
**对外 echo 产物路径(回复 / 汇报用)一律用全形式 `<wd_name>/<rel>`** —— `<wd_name>` = 上方 task_dir 末段(如末段是 `生图测试``生图测试/figures/cover.png`、`基金申报/sections/01-绪论.md`)。**别简写**成 `figures/cover.png` 这种 task 内裸形式:Web UI 靠 `<wd_name>/` 前缀挂可点 chip(预览 / 下载),简写会失效。媒体 tool 的 `saved:` 行已是规范全形式,原样照抄即可。
## 平台
当前是 Windows + cmd.exe。**避免用 unix-only flag**:
- 建目录用 `run_python``os.makedirs(path, exist_ok=True)`,**不要** `shell mkdir -p`(cmd 不识别 -p,会创建名为 '-p' 的字面目录;shell 工具已对此做兜底但仍以 run_python 为优先)
- 路径分隔符用 `/``\\`,Python 内部都识别;字符串 raw 路径用 `r"..."`
- shell 工具走的是 cmd,不是 bash,管道/重定向语义可能不同 —— 复杂逻辑用 run_python 更稳
运行平台(Linux 容器 / Windows host)由系统消息里的「运行环境」段说明,以那段为准。
通用习惯:建目录优先 `run_python``os.makedirs(path, exist_ok=True)`;路径分隔符用 `/` 最稳;复杂 shell 逻辑(管道/重定向)拿不准就用 run_python。

11
rendering/__init__.py Normal file
View File

@ -0,0 +1,11 @@
"""平台渲染层:把 sections/*.md(或单 .md)渲染成 docx / pdf。
不是 skill 内容,**平台能力** skill 通过 `render.py` CLI 调用,自身不再 bundle
渲染脚本( fork skill 不受影响)随镜像 bind-mount `/sandbox/rendering`
- common.py 叶子原语(字体/化学式白名单/块级正则/表格行切分/图片路径), profile 单一事实源
- docx_manuscript.py paper 投稿稿 + proposal 申报书(配置化双 profile)
- docx_brief.py brief 简报(商务红 + 引文上标超链 + callout)
- pdf.py mdHTML沙盒 chromium --print-to-pdf
- render.py 统一入口:--profile {brief,paper,proposal} --format {docx,pdf}
"""

143
rendering/common.py Normal file
View File

@ -0,0 +1,143 @@
"""平台渲染层 · 共享叶子原语(docx 三 profile + 部分 pdf 复用)。
**真正同源 profile 无关**的底层件:字体 OOXML 助手化学式下标白名单
内联/块级 markdown 正则表格行切分图片路径解析三套 docx profile
(manuscript=paper/proposalbrief) import 这里,**单一事实源**
改化学式白名单 / 字体规范只动这一处,不再三处各拷一份
历史:原先 skills/{brief,paper,proposal}/scripts/render_docx.py 各自带一份
拷贝(_CHEM_RE 三份逐字相同易漏改)2026-06 抽到平台层 rendering/
"""
from __future__ import annotations
import re
from pathlib import Path
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Cm, Pt
# ───────────────────────── 字体 OOXML 助手 ─────────────────────────
def set_run_fonts(run, *, cn_font: str = "宋体", en_font: str = "Times New Roman") -> None:
"""同时设置 run 的中文 (eastAsia) 和西文 (ascii/hAnsi) 字体。"""
rPr = run._element.get_or_add_rPr()
rFonts = rPr.find(qn("w:rFonts"))
if rFonts is None:
rFonts = OxmlElement("w:rFonts")
rPr.append(rFonts)
rFonts.set(qn("w:eastAsia"), cn_font)
rFonts.set(qn("w:ascii"), en_font)
rFonts.set(qn("w:hAnsi"), en_font)
def set_style_fonts(style, *, cn_font: str = "宋体", en_font: str = "Times New Roman") -> None:
"""直接给 style 写 rFonts, 基于该 style 的所有段落都继承字体。"""
el = style.element
rPr = el.find(qn("w:rPr"))
if rPr is None:
rPr = OxmlElement("w:rPr")
el.insert(0, rPr)
rFonts = rPr.find(qn("w:rFonts"))
if rFonts is None:
rFonts = OxmlElement("w:rFonts")
rPr.append(rFonts)
rFonts.set(qn("w:eastAsia"), cn_font)
rFonts.set(qn("w:ascii"), en_font)
rFonts.set(qn("w:hAnsi"), en_font)
def set_subscript(run) -> None:
rPr = run._element.get_or_add_rPr()
va = OxmlElement("w:vertAlign")
va.set(qn("w:val"), "subscript")
rPr.append(va)
# ───────────────────────── 内联 markdown 切分 ─────────────────────────
# 顺序敏感:**bold** 必须先于 *italic* 匹配, 否则会被 italic 抢
INLINE_RE = re.compile(
r"(?P<bold>\*\*(?P<bold_t>[^*\n]+?)\*\*)"
r"|(?P<italic>(?<![\*\w])\*(?P<italic_t>[^*\n]+?)\*(?!\*))"
r"|(?P<code>`(?P<code_t>[^`\n]+?)`)"
)
def parse_inline(text: str) -> list[tuple[str, str]]:
"""切成 (style, segment) 列表; style ∈ plain/bold/italic/code。"""
out: list[tuple[str, str]] = []
pos = 0
for m in INLINE_RE.finditer(text):
if m.start() > pos:
out.append(("plain", text[pos:m.start()]))
if m.group("bold"):
out.append(("bold", m.group("bold_t")))
elif m.group("italic"):
out.append(("italic", m.group("italic_t")))
elif m.group("code"):
out.append(("code", m.group("code_t")))
pos = m.end()
if pos < len(text):
out.append(("plain", text[pos:]))
return out or [("plain", text)]
# ── 化学式下标白名单(三 profile 共用同一份;单一事实源)──
# 长的在前,\b 防误伤 LC3 / C595 / 2026;不收 Ca2+ 这类带电荷的(那是上标,白名单不收即天然避开)
CHEM_RE = re.compile(
r"Ca\(OH\)2|Mg\(OH\)2"
r"|\b(?:Al2O3|Fe2O3|Fe3O4|Mn2O3|Cr2O3|P2O5|Na2SO4|K2SO4|CaSO4|CaCO3|MgCO3|"
r"CaCl2|MgCl2|Na2O|K2O|SiO2|TiO2|ZrO2|SO4|SO3|SO2|CO3|CO2|NO3|NO2|PO4|"
r"H2O|NH3|CH4|C4AF|C3S2|C2AS|C3S|C2S|C3A|O2|N2|H2)\b"
)
# ───────────────────────── 块级行类型正则 ─────────────────────────
HEADING_RE = re.compile(r"^(#{1,6})\s+(.+)$")
TABLE_LINE_RE = re.compile(r"^\s*\|.*\|\s*$")
BLOCKQUOTE_RE = re.compile(r"^\s*>\s?")
HR_RE = re.compile(r"^\s*-{3,}\s*$|^\s*={3,}\s*$|^\s*_{3,}\s*$")
FENCE_RE = re.compile(r"^\s*(`{3,}|~{3,})\s*(\S*)\s*$")
IMAGE_LINE_RE = re.compile(r"^\s*!\[(?P<cap>[^\]]*)\]\((?P<src>[^)\s]+)\)\s*$")
def is_table_line(line: str) -> bool:
return bool(TABLE_LINE_RE.match(line))
def is_heading(line: str) -> bool:
return bool(HEADING_RE.match(line))
def is_blockquote(line: str) -> bool:
return bool(BLOCKQUOTE_RE.match(line))
def is_hr(line: str) -> bool:
return bool(HR_RE.match(line))
# ───────────────────────── 表格行切分 ─────────────────────────
def split_md_row(line: str) -> list[str]:
return [c.strip() for c in line.strip().strip("|").split("|")]
def is_separator_row(cells: list[str]) -> bool:
return all(re.match(r"^[-:\s]+$", c) for c in cells if c != "")
# ───────────────────────── 图片 ─────────────────────────
MAX_IMG_WIDTH = Cm(15)
def resolve_image_path(src: str, base_dir: Path) -> Path | None:
"""图片相对路径以 base_dir (单个 .md 所在目录) 为锚。"""
p = Path(src)
if not p.is_absolute():
p = (base_dir / p).resolve()
return p if p.is_file() else None

656
rendering/docx_brief.py Normal file
View File

@ -0,0 +1,656 @@
"""brief 简报体例 docx 渲染器(商务红主题 + 引文上标超链 + callout/底纹边框)。
brief 是三 profile 里最富的一支:书签锚点内部/外部超链接引文 [n]/[Wn] 上标回链
参考条目 DOI 超链概览信息带 / TL;DR 卡片 / 判断 callout页脚页码域这些 paper/proposal
都没有, brief 保留自己的渲染层,只从 rendering.common 复用叶子原语(字体/化学式/块级正则/
表格行切分/图片路径)函数体逐字移植自旧 skills/brief/scripts/render_docx.py
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
from docx import Document
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.opc.constants import RELATIONSHIP_TYPE as RT
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Cm, Pt, RGBColor
from .common import (
set_run_fonts as _set_run_fonts,
set_style_fonts as _set_style_fonts,
set_subscript as _set_subscript,
CHEM_RE as _CHEM_RE,
INLINE_RE as _INLINE_RE,
HEADING_RE as _HEADING_RE,
TABLE_LINE_RE as _TABLE_LINE_RE,
BLOCKQUOTE_RE as _BLOCKQUOTE_RE,
HR_RE as _HR_RE,
FENCE_RE as _FENCE_RE,
IMAGE_LINE_RE as _IMAGE_LINE_RE,
split_md_row as _split_md_row,
is_separator_row as _is_sep_row,
resolve_image_path as _resolve_image_path,
MAX_IMG_WIDTH as _MAX_IMG_WIDTH,
)
# ───────────────────────── 主题色 ─────────────────────────
PRIMARY = "C00000" # 商务红主色
PRIMARY_RGB = RGBColor(0xC0, 0x00, 0x00)
TLDR_FILL = "FBE9E9" # TL;DR 浅红底纹
CALLOUT_FILL = "F7DDDD" # 「判断」callout 底纹
LINK_BLUE = "1155CC" # 超链接蓝
TABLE_HEAD_FILL = "C00000"
# ───────────────────────── 低层 OOXML 辅助 ─────────────────────────
def _para_shading(paragraph, fill: str) -> None:
pPr = paragraph._p.get_or_add_pPr()
shd = OxmlElement("w:shd")
shd.set(qn("w:val"), "clear")
shd.set(qn("w:color"), "auto")
shd.set(qn("w:fill"), fill)
pPr.append(shd)
def _para_border(paragraph, *, sides=("bottom",), color=PRIMARY, size=8, space=3) -> None:
pPr = paragraph._p.get_or_add_pPr()
pBdr = pPr.find(qn("w:pBdr"))
if pBdr is None:
pBdr = OxmlElement("w:pBdr")
pPr.append(pBdr)
for side in sides:
el = OxmlElement(f"w:{side}")
el.set(qn("w:val"), "single")
el.set(qn("w:sz"), str(size))
el.set(qn("w:space"), str(space))
el.set(qn("w:color"), color)
pBdr.append(el)
def _add_bookmark(paragraph, name: str, bm_id: int) -> None:
start = OxmlElement("w:bookmarkStart")
start.set(qn("w:id"), str(bm_id))
start.set(qn("w:name"), name)
end = OxmlElement("w:bookmarkEnd")
end.set(qn("w:id"), str(bm_id))
paragraph._p.insert(0, start)
paragraph._p.append(end)
def _mk_run_xml(text: str, *, size_pt: float, color=None, superscript=False,
underline=False, bold=False, cn_font="宋体", en_font="Times New Roman"):
r = OxmlElement("w:r")
rPr = OxmlElement("w:rPr")
rFonts = OxmlElement("w:rFonts")
rFonts.set(qn("w:eastAsia"), cn_font)
rFonts.set(qn("w:ascii"), en_font)
rFonts.set(qn("w:hAnsi"), en_font)
rPr.append(rFonts)
if bold:
rPr.append(OxmlElement("w:b"))
if color:
c = OxmlElement("w:color")
c.set(qn("w:val"), color)
rPr.append(c)
if underline:
u = OxmlElement("w:u")
u.set(qn("w:val"), "single")
rPr.append(u)
if superscript:
va = OxmlElement("w:vertAlign")
va.set(qn("w:val"), "superscript")
rPr.append(va)
sz = OxmlElement("w:sz")
sz.set(qn("w:val"), str(int(size_pt * 2)))
rPr.append(sz)
r.append(rPr)
t = OxmlElement("w:t")
t.set(qn("xml:space"), "preserve")
t.text = text
r.append(t)
return r
def add_internal_link(paragraph, anchor: str, text: str, *, size_pt: float,
color=PRIMARY, superscript=False) -> None:
h = OxmlElement("w:hyperlink")
h.set(qn("w:anchor"), anchor)
h.append(_mk_run_xml(text, size_pt=size_pt, color=color, superscript=superscript))
paragraph._p.append(h)
def add_external_link(paragraph, url: str, text: str, *, size_pt: float) -> None:
part = paragraph.part
r_id = part.relate_to(url, RT.HYPERLINK, is_external=True)
h = OxmlElement("w:hyperlink")
h.set(qn("r:id"), r_id)
h.append(_mk_run_xml(text, size_pt=size_pt, color=LINK_BLUE, underline=True))
paragraph._p.append(h)
# ───────────────────────── 文档初始化 ─────────────────────────
def init_doc(color: bool) -> Document:
doc = Document()
section = doc.sections[0]
section.page_height = Cm(29.7)
section.page_width = Cm(21)
for m in ("top_margin", "bottom_margin", "left_margin", "right_margin"):
setattr(section, m, Cm(2.5))
normal = doc.styles["Normal"]
normal.font.name = "Times New Roman"
normal.font.size = Pt(12)
_set_style_fonts(normal, cn_font="宋体")
pf = normal.paragraph_format
pf.line_spacing = 1.5
pf.space_before = Pt(0)
pf.space_after = Pt(0)
head_color = PRIMARY_RGB if color else RGBColor(0, 0, 0)
for lvl, sz, cn in [(1, Pt(18), "黑体"), (2, Pt(14), "黑体"), (3, Pt(12), "黑体")]:
h = doc.styles[f"Heading {lvl}"]
h.font.name = "Times New Roman"
h.font.size = sz
h.font.bold = True
h.font.color.rgb = head_color
_set_style_fonts(h, cn_font=cn)
h.paragraph_format.line_spacing = 1.3
h.paragraph_format.space_before = Pt(10 if lvl <= 2 else 6)
h.paragraph_format.space_after = Pt(4)
h.paragraph_format.first_line_indent = None
return doc
# ───────────────────────── 内联:bold/italic/code + 引文 + 化学式 ─────────────────────────
# 引文标记 [12] / [W3]
_CITE_RE = re.compile(r"\[(W?\d+)\]")
def _emit_chem(paragraph, text: str, *, size_pt: float, cn_font: str) -> None:
"""把白名单化学式里的数字渲成下标,其余正常。"""
pos = 0
for m in _CHEM_RE.finditer(text):
if m.start() > pos:
_emit_plain_run(paragraph, text[pos:m.start()], size_pt=size_pt, cn_font=cn_font)
formula = m.group(0)
buf = ""
for ch in formula:
if ch.isdigit():
if buf:
_emit_plain_run(paragraph, buf, size_pt=size_pt, cn_font=cn_font)
buf = ""
sub = paragraph.add_run(ch)
sub.font.size = Pt(size_pt)
_set_run_fonts(sub, cn_font=cn_font, en_font="Times New Roman")
_set_subscript(sub)
else:
buf += ch
if buf:
_emit_plain_run(paragraph, buf, size_pt=size_pt, cn_font=cn_font)
pos = m.end()
if pos < len(text):
_emit_plain_run(paragraph, text[pos:], size_pt=size_pt, cn_font=cn_font)
def _emit_plain_run(paragraph, text: str, *, size_pt: float, cn_font: str) -> None:
if not text:
return
run = paragraph.add_run(text)
run.font.size = Pt(size_pt)
_set_run_fonts(run, cn_font=cn_font, en_font="Times New Roman")
def _emit_plain_with_cites(paragraph, text: str, *, size_pt: float, cn_font: str,
make_citations: bool) -> None:
"""plain 段:处理引文上标超链接 + 化学式下标。"""
if not make_citations:
_emit_chem(paragraph, text, size_pt=size_pt, cn_font=cn_font)
return
pos = 0
prev_end = None
for m in _CITE_RE.finditer(text):
if m.start() > pos:
_emit_chem(paragraph, text[pos:m.start()], size_pt=size_pt, cn_font=cn_font)
# 连续 [1][3] 之间补一个上标逗号
if prev_end is not None and m.start() == prev_end:
comma = paragraph.add_run(",")
comma.font.size = Pt(size_pt * 0.85)
comma.font.color.rgb = PRIMARY_RGB
_set_subscript_super(comma)
cid = m.group(1)
add_internal_link(paragraph, f"ref_{cid}", cid, size_pt=size_pt * 0.85,
color=PRIMARY, superscript=True)
prev_end = m.end()
pos = m.end()
if pos < len(text):
_emit_chem(paragraph, text[pos:], size_pt=size_pt, cn_font=cn_font)
def _set_subscript_super(run) -> None:
rPr = run._element.get_or_add_rPr()
va = OxmlElement("w:vertAlign")
va.set(qn("w:val"), "superscript")
rPr.append(va)
def add_inline_rich(paragraph, text: str, *, size_pt=12.0, cn_font="宋体",
make_citations=True) -> None:
pos = 0
for m in _INLINE_RE.finditer(text):
if m.start() > pos:
_emit_plain_with_cites(paragraph, text[pos:m.start()], size_pt=size_pt,
cn_font=cn_font, make_citations=make_citations)
if m.group("bold"):
run = paragraph.add_run(m.group("bold_t"))
run.bold = True
run.font.size = Pt(size_pt)
_set_run_fonts(run, cn_font=cn_font)
elif m.group("italic"):
run = paragraph.add_run(m.group("italic_t"))
run.italic = True
run.font.size = Pt(size_pt)
_set_run_fonts(run, cn_font=cn_font)
elif m.group("code"):
run = paragraph.add_run(m.group("code_t"))
run.font.size = Pt(size_pt)
_set_run_fonts(run, cn_font=cn_font, en_font="Consolas")
pos = m.end()
if pos < len(text):
_emit_plain_with_cites(paragraph, text[pos:], size_pt=size_pt,
cn_font=cn_font, make_citations=make_citations)
# ───────────────────────── 标题 / 段落 ─────────────────────────
def add_heading(doc: Document, text: str, level: int, color: bool) -> None:
p = doc.add_paragraph(style=f"Heading {level}")
p.paragraph_format.first_line_indent = None
sizes = {1: 18.0, 2: 14.0, 3: 12.0}
if level == 1:
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
add_inline_rich(p, text, size_pt=sizes[level], cn_font="黑体", make_citations=False)
for run in p.runs:
run.bold = True
if color and level <= 2:
_para_border(p, sides=("bottom",), color=PRIMARY, size=(12 if level == 1 else 6))
elif color and level == 3:
p.paragraph_format.left_indent = Pt(8)
_para_border(p, sides=("left",), color=PRIMARY, size=20, space=6)
def add_body_paragraph(doc: Document, text: str, *, indent=True) -> None:
p = doc.add_paragraph()
pf = p.paragraph_format
pf.line_spacing = 1.5
pf.first_line_indent = Pt(24) if indent else None
p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
add_inline_rich(p, text)
def add_callout(doc: Document, text: str, fill: str, color: bool) -> None:
"""判断 / 引用块类强调框:底纹 + 左红条。"""
p = doc.add_paragraph()
pf = p.paragraph_format
pf.line_spacing = 1.4
pf.first_line_indent = None
pf.left_indent = Pt(8)
pf.space_before = Pt(3)
pf.space_after = Pt(3)
if color:
_para_shading(p, fill)
_para_border(p, sides=("left",), color=PRIMARY, size=22, space=5)
add_inline_rich(p, text)
def add_meta_band(doc: Document, text: str, color: bool) -> None:
"""标题下方的信息带(方向/时间窗/深度/数据源/受众):居中浅红底纹 + 上下细线。"""
p = doc.add_paragraph()
pf = p.paragraph_format
pf.first_line_indent = None
pf.space_before = Pt(2)
pf.space_after = Pt(12)
pf.line_spacing = 1.35
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
if color:
_para_shading(p, "F3DADA")
_para_border(p, sides=("top", "bottom"), color=PRIMARY, size=6, space=3)
add_inline_rich(p, text, size_pt=10.5, make_citations=False)
def add_tldr_card(doc: Document, text: str, color: bool) -> None:
"""TL;DR 要点:每条做成浅红左条卡片,堆叠成卡片列。"""
p = doc.add_paragraph()
pf = p.paragraph_format
pf.first_line_indent = None
pf.left_indent = Pt(10)
pf.space_before = Pt(1)
pf.space_after = Pt(3)
pf.line_spacing = 1.3
if color:
_para_shading(p, TLDR_FILL)
_para_border(p, sides=("left",), color=PRIMARY, size=26, space=6)
add_inline_rich(p, text, size_pt=11.0)
def _add_field(paragraph, instr: str) -> None:
run = paragraph.add_run()
for typ, payload in (("begin", None), ("instr", instr), ("separate", None), ("end", None)):
if typ == "instr":
el = OxmlElement("w:instrText")
el.set(qn("xml:space"), "preserve")
el.text = payload
else:
el = OxmlElement("w:fldChar")
el.set(qn("w:fldCharType"), typ)
run._r.append(el)
def add_page_footer(doc: Document, color: bool) -> None:
p = doc.sections[0].footer.paragraphs[0]
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
pre = p.add_run("")
_add_field(p, " PAGE ")
post = p.add_run("")
for r in p.runs:
r.font.size = Pt(9)
if color:
r.font.color.rgb = PRIMARY_RGB
_set_run_fonts(r, cn_font="宋体")
# ───────────────────────── 参考文献条目(可点击)─────────────────────────
_REF_RE = re.compile(r"^\[(W?\d+)\]\s+(.+)$")
_DOI_RE = re.compile(r"^10\.\d{4,9}/\S+$")
_DOI_INLINE_RE = re.compile(r"10\.\d{4,9}/\S+") # 条目内 DOI 子串(论文列表条目末尾常带 "DOI: 10.xxx")
_URL_TOKEN_RE = re.compile(r"([a-z0-9][\w.\-]*\.[a-z]{2,}(?:/[^\s]+)?)", re.IGNORECASE)
def add_reference_item(doc: Document, cid: str, value: str, bm_id: int, color: bool) -> None:
p = doc.add_paragraph()
pf = p.paragraph_format
pf.first_line_indent = None
pf.left_indent = Pt(18)
pf.line_spacing = 1.3
_add_bookmark(p, f"ref_{cid}", bm_id)
# 编号标签 [n]
lab = p.add_run(f"[{cid}] ")
lab.bold = True
lab.font.size = Pt(10.5)
if color:
lab.font.color.rgb = PRIMARY_RGB
_set_run_fonts(lab, cn_font="宋体")
value = value.strip()
if _DOI_RE.match(value):
add_external_link(p, f"https://doi.org/{value}", value, size_pt=10.5)
return
# 论文列表条目:行内含 DOI(如 "<标题>. <作者>, <刊>, 2026-03. DOI: 10.1016/...")
# → 把 DOI 子串做成超链接,前后文正常
m_doi = _DOI_INLINE_RE.search(value)
if m_doi:
doi = m_doi.group(0).rstrip(".,;)")
pre, post = value[:m_doi.start()], value[m_doi.start() + len(doi):]
if pre:
_emit_plain_run(p, pre, size_pt=10.5, cn_font="宋体")
add_external_link(p, f"https://doi.org/{doi}", doi, size_pt=10.5)
if post:
_emit_plain_run(p, post, size_pt=10.5, cn_font="宋体")
return
# web 条目:把第一个像 URL 的 token 变成超链接
m = _URL_TOKEN_RE.search(value)
if m and ("/" in m.group(1) or m.group(1).count(".") >= 1) and " " not in m.group(1):
pre, mid, post = value[:m.start()], m.group(1), value[m.end():]
_emit_plain_run(p, pre, size_pt=10.5, cn_font="宋体")
url = mid if mid.startswith("http") else f"https://{mid}"
add_external_link(p, url, mid, size_pt=10.5)
if post:
_emit_plain_run(p, post, size_pt=10.5, cn_font="宋体")
else:
_emit_plain_run(p, value, size_pt=10.5, cn_font="宋体")
# ───────────────────────── 行类型识别(brief 专属列表模式)─────────────────────────
_LIST_PATTERNS = [
re.compile(r"^[-*+]\s"),
re.compile(r"^\d+[\.、.]\s*"),
re.compile(r"^\(\d+\)\s*"),
re.compile(r"^\d+\s*"),
re.compile(r"^[①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮]"),
]
def is_list_item(line: str) -> bool:
return any(p.match(line) for p in _LIST_PATTERNS)
# ───────────────────────── 表格 ─────────────────────────
def render_table(doc: Document, table_lines: list[str], color: bool) -> None:
rows = []
for ln in table_lines:
cells = _split_md_row(ln)
if not cells or _is_sep_row(cells):
continue
rows.append(cells)
if not rows:
return
n_cols = max(len(r) for r in rows)
for r in rows:
while len(r) < n_cols:
r.append("")
table = doc.add_table(rows=len(rows), cols=n_cols)
try:
table.style = "Table Grid"
except KeyError:
pass
for ri, row in enumerate(rows):
for ci, val in enumerate(row):
cell = table.rows[ri].cells[ci]
cell.text = ""
p = cell.paragraphs[0]
p.paragraph_format.first_line_indent = None
p.paragraph_format.line_spacing = 1.2
add_inline_rich(p, val, size_pt=10.5, cn_font="宋体", make_citations=False)
if ri == 0:
if color:
_para_shading(p, TABLE_HEAD_FILL)
for run in p.runs:
run.bold = True
if color:
run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
# ───────────────────────── 图片 ─────────────────────────
def add_image(doc: Document, png_path: Path, caption: str | None, ctx: dict) -> None:
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
p.paragraph_format.first_line_indent = None
p.paragraph_format.space_before = Pt(6)
p.paragraph_format.space_after = Pt(3)
run = p.add_run()
try:
run.add_picture(str(png_path), width=_MAX_IMG_WIDTH)
except Exception as e:
run.add_text(f"[image failed: {png_path.name}: {e}]")
return
ctx["fig_no"] = ctx.get("fig_no", 0) + 1
cap_p = doc.add_paragraph()
cap_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
cap_p.paragraph_format.first_line_indent = None
cap_p.paragraph_format.space_after = Pt(6)
cap_text = f"{ctx['fig_no']} {caption}" if caption else f"{ctx['fig_no']}"
cap_run = cap_p.add_run(cap_text)
cap_run.font.size = Pt(10.5)
cap_run.bold = True
_set_run_fonts(cap_run, cn_font="宋体")
# ───────────────────────── 主渲染 ─────────────────────────
def render_md_block(doc: Document, md_text: str, ctx: dict) -> None:
color = ctx["color"]
lines = md_text.splitlines()
i, n = 0, len(lines)
in_refs = False # 进入「参考文献」段后,[n] 行按引文条目渲染
expect_meta = False # 紧跟 H1 标题的信息带(方向/时间窗...)
in_tldr = False # 「一句话要点」段:列表项做卡片
while i < n:
line = lines[i].rstrip()
if not line.strip():
i += 1
continue
if _HR_RE.match(line):
i += 1
continue
m_img = _IMAGE_LINE_RE.match(line)
if m_img:
png = _resolve_image_path(m_img.group("src"), ctx["sections_dir"])
if png is not None:
add_image(doc, png, m_img.group("cap").strip() or None, ctx)
else:
add_body_paragraph(doc, f"[image missing: {m_img.group('src')}]", indent=False)
i += 1
continue
m_fence = _FENCE_RE.match(line)
if m_fence:
fence = m_fence.group(1)
code = []
i += 1
while i < n:
mc = _FENCE_RE.match(lines[i])
if mc and mc.group(1)[0] == fence[0] and len(mc.group(1)) >= len(fence):
i += 1
break
code.append(lines[i])
i += 1
for ln in code:
p = doc.add_paragraph()
p.paragraph_format.first_line_indent = None
p.paragraph_format.line_spacing = 1.0
run = p.add_run(ln if ln else " ")
run.font.size = Pt(10.5)
_set_run_fonts(run, cn_font="新宋体", en_font="Consolas")
continue
if _TABLE_LINE_RE.match(line):
block = []
while i < n and _TABLE_LINE_RE.match(lines[i]):
block.append(lines[i])
i += 1
render_table(doc, block, color)
continue
m = _HEADING_RE.match(line)
if m:
title = m.group(2).strip()
level = min(len(m.group(1)), 3)
# 只在 H1/H2 重判段类型 —— 让「重要论文列表」段下的 ### 期刊子标题不重置 in_refs,
# 子标题下的 [n] 条目才能继续按参考锚点渲染(带 DOI 超链接)
if level <= 2:
in_refs = ("参考文献" in title) or ("论文列表" in title) or ("文献列表" in title)
expect_meta = (level == 1)
if level <= 2:
in_tldr = ("要点" in title) or ("TL;DR" in title.upper())
add_heading(doc, title, level, color)
i += 1
continue
if _BLOCKQUOTE_RE.match(line):
# 引用块:并合连续 > 行,做浅红 callout(说明 / 取舍纪律等)
buf = [_BLOCKQUOTE_RE.sub("", line).strip()]
i += 1
while i < n and _BLOCKQUOTE_RE.match(lines[i]):
buf.append(_BLOCKQUOTE_RE.sub("", lines[i]).strip())
i += 1
add_callout(doc, " ".join(buf), TLDR_FILL, color)
continue
# 参考文献条目
if in_refs:
m_ref = _REF_RE.match(line.strip())
if m_ref:
ctx["bm_id"] += 1
add_reference_item(doc, m_ref.group(1), m_ref.group(2), ctx["bm_id"], color)
i += 1
continue
# 「判断」强调行 → callout
if line.strip().startswith("**判断**"):
add_callout(doc, line.strip(), CALLOUT_FILL, color)
i += 1
continue
if is_list_item(line):
if in_tldr:
add_tldr_card(doc, line.strip(), color)
else:
add_body_paragraph(doc, line.strip(), indent=False)
i += 1
continue
# 紧跟标题的信息带
if expect_meta and ("时间窗" in line):
add_meta_band(doc, line.strip(), color)
expect_meta = False
i += 1
continue
# 普通段落:并合软换行
buf = [line.strip()]
j = i + 1
while j < n:
nxt = lines[j].rstrip()
if not nxt.strip() or _HEADING_RE.match(nxt) or _BLOCKQUOTE_RE.match(nxt) \
or _TABLE_LINE_RE.match(nxt) or is_list_item(nxt) or _HR_RE.match(nxt):
break
buf.append(nxt.strip())
j += 1
add_body_paragraph(doc, " ".join(buf), indent=True)
i = j
# ───────────────────────── 入口 ─────────────────────────
def render_sections(sections_dir: Path, out: Path, color: bool) -> None:
if not sections_dir.is_dir():
print(f"[ERR] sections dir not found: {sections_dir}", file=sys.stderr)
sys.exit(2)
md_files = sorted(sections_dir.glob("*.md"))
if not md_files:
print(f"[ERR] no .md found in {sections_dir}", file=sys.stderr)
sys.exit(2)
ctx = {
"sections_dir": sections_dir,
"figures_dir": sections_dir.parent / "figures",
"fig_no": 0,
"bm_id": 0,
"color": color,
}
doc = init_doc(color)
add_page_footer(doc, color)
for idx, f in enumerate(md_files):
render_md_block(doc, f.read_text(encoding="utf-8"), ctx)
if idx != len(md_files) - 1:
doc.add_page_break()
out.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(out))
paras = sum(1 for _ in doc.paragraphs)
chars = sum(len(p.text) for p in doc.paragraphs)
print(f"[OK] rendered {len(md_files)} sections -> {out}")
print(f" profile: brief | paragraphs: {paras} | tables: {len(doc.tables)} | "
f"figures: {ctx['fig_no']} | chars: {chars} | theme: {'商务红' if color else '黑白'}")

View File

@ -0,0 +1,441 @@
"""manuscript 体例 docx 渲染器(paper 投稿稿 + proposal 申报书,配置化双 profile)。
两者原是近亲(~80% 逐字相同),差异收进 PROFILES:页边距 / TOC 标题 / 图题前缀 /
列表多一条"第X条" / sections 循环(toc 是否默认 + 末段是否补分页)函数体移植自
paper/proposal render_docx.py,叶子原语走 rendering.common
profile=paper: --lang {zh,en}(图题前缀 /Fig.),--toc 可选(默认无)
profile=proposal: --fund-type ...(仅打印),始终带 TOC,每段后分页
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
from docx import Document
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Cm, Pt, RGBColor
from . import common
from .common import set_run_fonts, set_style_fonts, set_subscript, CHEM_RE, parse_inline
# ───────────────────────── profile 配置 ─────────────────────────
_BASE_LIST_PATTERNS = [
re.compile(r"^\[\d+\]\s"), # [1]
re.compile(r"^[-*+]\s"), # - / * / +
re.compile(r"^\d+[\.、.]\s*"), # 1. / 1、 / 1
re.compile(r"^\(\d+\)\s*"), # (1)
re.compile(r"^\d+\s*"), # 1
re.compile(r"^[一二三四五六七八九十百千]+[、.\.]"), # 一、
re.compile(r"^[(][一二三四五六七八九十百千]+[)]"), # (一)
re.compile(r"^[①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮]"), # ①
]
PROFILES = {
"paper": {
"left_margin": Cm(2.5),
"right_margin": Cm(2.5),
"list_patterns": _BASE_LIST_PATTERNS,
"toc_title": "Contents",
"toc_placeholder": "[Press F9 in Word to generate the table of contents]",
"always_toc": False,
"trailing_page_break": False,
},
"proposal": {
"left_margin": Cm(3.0),
"right_margin": Cm(2.0),
"list_patterns": _BASE_LIST_PATTERNS + [
re.compile(r"^第[一二三四五六七八九十百]+[条章节]"), # 第一条
],
"toc_title": "目 录",
"toc_placeholder": "[在 Word 中按 F9 或右键此处选择 “更新域” 即可生成完整目录]",
"always_toc": True,
"trailing_page_break": True,
},
}
# ───────────────────────── 文档初始化 ─────────────────────────
def init_doc(prof: dict) -> Document:
doc = Document()
section = doc.sections[0]
section.page_height = Cm(29.7)
section.page_width = Cm(21)
section.top_margin = Cm(2.5)
section.bottom_margin = Cm(2.5)
section.left_margin = prof["left_margin"]
section.right_margin = prof["right_margin"]
normal = doc.styles["Normal"]
normal.font.name = "Times New Roman"
normal.font.size = Pt(12)
set_style_fonts(normal, cn_font="宋体")
pf = normal.paragraph_format
pf.line_spacing = 1.5
pf.space_before = Pt(0)
pf.space_after = Pt(0)
for lvl, sz, cn in [(1, Pt(14), "黑体"), (2, Pt(12), "黑体"), (3, Pt(12), "宋体")]:
h = doc.styles[f"Heading {lvl}"]
h.font.name = "Times New Roman"
h.font.size = sz
h.font.bold = True
h.font.color.rgb = RGBColor(0, 0, 0)
set_style_fonts(h, cn_font=cn)
h.paragraph_format.line_spacing = 1.5
h.paragraph_format.space_before = Pt(6)
h.paragraph_format.space_after = Pt(3)
h.paragraph_format.first_line_indent = None
return doc
def add_toc(doc: Document, prof: dict, depth: int = 3) -> None:
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
p.paragraph_format.first_line_indent = None
p.paragraph_format.space_before = Pt(12)
p.paragraph_format.space_after = Pt(6)
run = p.add_run(prof["toc_title"])
run.font.size = Pt(16)
run.font.bold = True
set_run_fonts(run, cn_font="黑体")
p = doc.add_paragraph()
p.paragraph_format.first_line_indent = None
run = p.add_run()
fldChar1 = OxmlElement("w:fldChar")
fldChar1.set(qn("w:fldCharType"), "begin")
instrText = OxmlElement("w:instrText")
instrText.set(qn("xml:space"), "preserve")
instrText.text = f' TOC \\o "1-{depth}" \\h \\z \\u '
fldChar2 = OxmlElement("w:fldChar")
fldChar2.set(qn("w:fldCharType"), "separate")
fldChar3 = OxmlElement("w:fldChar")
fldChar3.set(qn("w:fldCharType"), "end")
placeholder_t = OxmlElement("w:t")
placeholder_t.set(qn("xml:space"), "preserve")
placeholder_t.text = prof["toc_placeholder"]
run._element.append(fldChar1)
run._element.append(instrText)
run._element.append(fldChar2)
run._element.append(placeholder_t)
run._element.append(fldChar3)
doc.add_page_break()
# ───────────────────────── 内联(化学式下标)─────────────────────────
def _emit_plain_with_chem(paragraph, text: str, *, size, cn_font: str) -> None:
"""plain 段:白名单化学式里的数字渲成下标,其余正常。无命中即一条普通 run。"""
def _run(seg: str, sub: bool = False):
if not seg:
return
r = paragraph.add_run(seg)
r.font.size = size
set_run_fonts(r, cn_font=cn_font, en_font="Times New Roman")
if sub:
set_subscript(r)
pos = 0
for m in CHEM_RE.finditer(text):
_run(text[pos:m.start()])
buf = ""
for ch in m.group(0):
if ch.isdigit():
_run(buf); buf = ""
_run(ch, sub=True)
else:
buf += ch
_run(buf)
pos = m.end()
_run(text[pos:])
def add_inline(paragraph, text: str, *, size: Pt = Pt(12), cn_font: str = "宋体") -> None:
for style, seg in parse_inline(text):
if style == "plain":
_emit_plain_with_chem(paragraph, seg, size=size, cn_font=cn_font)
continue
run = paragraph.add_run(seg)
run.font.size = size
if style == "bold":
run.bold = True
set_run_fonts(run, cn_font=cn_font, en_font="Times New Roman")
elif style == "italic":
run.italic = True
set_run_fonts(run, cn_font=cn_font, en_font="Times New Roman")
elif style == "code":
set_run_fonts(run, cn_font=cn_font, en_font="Consolas")
# ───────────────────────── 段落 / 标题 / 列表 ─────────────────────────
def add_heading(doc: Document, text: str, level: int) -> None:
p = doc.add_paragraph(style=f"Heading {level}")
p.paragraph_format.first_line_indent = None
sizes = {1: Pt(14), 2: Pt(12), 3: Pt(12)}
cn = {1: "黑体", 2: "黑体", 3: "宋体"}
add_inline(p, text, size=sizes[level], cn_font=cn[level])
for run in p.runs:
run.bold = True
def add_body_paragraph(doc: Document, text: str, *, indent: bool = True) -> None:
p = doc.add_paragraph()
pf = p.paragraph_format
pf.line_spacing = 1.5
if indent:
pf.first_line_indent = Pt(24)
else:
pf.first_line_indent = None
p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
add_inline(p, text)
def is_list_item(line: str, prof: dict) -> bool:
return any(p.match(line) for p in prof["list_patterns"])
def add_code_block(doc: Document, lines: list[str], lang: str = "") -> None:
for ln in lines:
p = doc.add_paragraph()
pf = p.paragraph_format
pf.first_line_indent = None
pf.line_spacing = 1.0
pf.space_before = Pt(0)
pf.space_after = Pt(0)
run = p.add_run(ln if ln else " ")
run.font.size = Pt(10.5)
set_run_fonts(run, cn_font="新宋体", en_font="Consolas")
for t in run._element.iter(qn("w:t")):
t.set(qn("xml:space"), "preserve")
# ───────────────────────── 表格 ─────────────────────────
def render_table(doc: Document, table_lines: list[str]) -> None:
rows: list[list[str]] = []
for ln in table_lines:
cells = common.split_md_row(ln)
if not cells or common.is_separator_row(cells):
continue
rows.append(cells)
if not rows:
return
n_cols = max(len(r) for r in rows)
for r in rows:
while len(r) < n_cols:
r.append("")
table = doc.add_table(rows=len(rows), cols=n_cols)
try:
table.style = "Light Grid Accent 1"
except KeyError:
pass
for ri, row in enumerate(rows):
for ci, val in enumerate(row):
cell = table.rows[ri].cells[ci]
cell.text = ""
p = cell.paragraphs[0]
p.paragraph_format.first_line_indent = None
p.paragraph_format.line_spacing = 1.2
add_inline(p, val, size=Pt(10.5), cn_font="宋体")
if ri == 0:
for run in p.runs:
run.bold = True
# ───────────────────────── 图片 + 图题 ─────────────────────────
_MERMAID_CAPTION_RE = re.compile(r"^\s*%%\s*caption\s*:\s*(.+?)\s*$", re.IGNORECASE)
_FILENAME_INVALID_RE = re.compile(r"[^一-鿿A-Za-z0-9]+")
def caption_to_stem(caption: str) -> str:
cleaned = _FILENAME_INVALID_RE.sub("_", caption).strip("_")[:40]
if not cleaned:
return ""
return f"fig_{cleaned}"
def extract_mermaid_caption(source: str) -> str | None:
for ln in source.splitlines():
m = _MERMAID_CAPTION_RE.match(ln)
if m:
return m.group(1).strip()
return None
def add_image(doc: Document, png_path: Path, caption: str | None, ctx: dict) -> None:
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
p.paragraph_format.first_line_indent = None
p.paragraph_format.space_before = Pt(6)
p.paragraph_format.space_after = Pt(3)
run = p.add_run()
try:
run.add_picture(str(png_path), width=common.MAX_IMG_WIDTH)
except Exception as e:
run.add_text(f"[image failed: {png_path.name}: {e}]")
return
ctx["fig_no"] = ctx.get("fig_no", 0) + 1
cap_p = doc.add_paragraph()
cap_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
cap_p.paragraph_format.first_line_indent = None
cap_p.paragraph_format.space_before = Pt(0)
cap_p.paragraph_format.space_after = Pt(6)
label = ctx.get("fig_label", "Fig.")
cap_text = f"{label} {ctx['fig_no']} {caption}" if caption else f"{label} {ctx['fig_no']}"
cap_run = cap_p.add_run(cap_text)
cap_run.font.size = Pt(10.5)
cap_run.bold = True
set_run_fonts(cap_run, cn_font="宋体", en_font="Times New Roman")
# ───────────────────────── 主渲染 ─────────────────────────
def render_md_block(doc: Document, md_text: str, ctx: dict) -> None:
prof = ctx["prof"]
lines = md_text.splitlines()
i = 0
n = len(lines)
while i < n:
line = lines[i].rstrip()
if not line.strip():
i += 1
continue
if common.is_hr(line):
i += 1
continue
m_img = common.IMAGE_LINE_RE.match(line)
if m_img:
src = m_img.group("src")
cap = m_img.group("cap").strip() or None
png = common.resolve_image_path(src, ctx["sections_dir"])
if png is not None:
add_image(doc, png, cap, ctx)
else:
add_body_paragraph(doc, f"[image missing: {src}]", indent=False)
i += 1
continue
m_fence = common.FENCE_RE.match(line)
if m_fence:
fence = m_fence.group(1)
lang = m_fence.group(2) or ""
code: list[str] = []
i += 1
while i < n:
m_close = common.FENCE_RE.match(lines[i])
if m_close and m_close.group(1)[0] == fence[0] and len(m_close.group(1)) >= len(fence):
i += 1
break
code.append(lines[i])
i += 1
if lang.lower() == "mermaid":
source = "\n".join(code)
cap = extract_mermaid_caption(source)
if cap:
stem = caption_to_stem(cap)
if stem:
png = ctx["figures_dir"] / f"{stem}.png"
if png.is_file():
add_image(doc, png, cap, ctx)
continue
add_code_block(doc, code, lang)
continue
if common.is_table_line(line):
block: list[str] = []
while i < n and common.is_table_line(lines[i]):
block.append(lines[i])
i += 1
render_table(doc, block)
continue
m = common.HEADING_RE.match(line)
if m:
level = min(len(m.group(1)), 3)
add_heading(doc, m.group(2).strip(), level)
i += 1
continue
if common.is_blockquote(line):
i += 1
continue
if is_list_item(line, prof):
add_body_paragraph(doc, line.strip(), indent=False)
i += 1
continue
buf = [line.strip()]
j = i + 1
while j < n:
nxt = lines[j].rstrip()
if not nxt.strip():
break
if (common.is_heading(nxt) or common.is_blockquote(nxt) or common.is_table_line(nxt)
or is_list_item(nxt, prof) or common.is_hr(nxt)):
break
buf.append(nxt.strip())
j += 1
add_body_paragraph(doc, " ".join(buf), indent=True)
i = j
# ───────────────────────── 入口 ─────────────────────────
def render_sections(profile: str, sections_dir: Path, out: Path, *,
lang: str = "en", toc: bool = False, fund_type: str = "") -> None:
prof = PROFILES[profile]
if not sections_dir.is_dir():
print(f"[ERR] sections dir not found: {sections_dir}", file=sys.stderr)
sys.exit(2)
md_files = sorted(sections_dir.glob("*.md"))
if not md_files:
print(f"[ERR] no .md found in {sections_dir}", file=sys.stderr)
sys.exit(2)
figures_dir = sections_dir.parent / "figures"
ctx: dict = {
"prof": prof,
"sections_dir": sections_dir,
"figures_dir": figures_dir,
"fig_no": 0,
"fig_label": ("" if lang == "zh" else "Fig.") if profile == "paper" else "",
}
doc = init_doc(prof)
if prof["always_toc"] or toc:
add_toc(doc, prof)
for idx, f in enumerate(md_files):
text = f.read_text(encoding="utf-8")
render_md_block(doc, text, ctx)
if prof["trailing_page_break"] or idx != len(md_files) - 1:
doc.add_page_break()
out.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(out))
paras = sum(1 for _ in doc.paragraphs)
chars = sum(len(p.text) for p in doc.paragraphs)
tbls = len(doc.tables)
print(f"[OK] rendered {len(md_files)} sections -> {out}")
print(f" profile: {profile} | paragraphs: {paras} | tables: {tbls} | "
f"figures: {ctx['fig_no']} | chars: {chars}")

177
rendering/pdf.py Normal file
View File

@ -0,0 +1,177 @@
"""md(sections 目录或单 .md)→ PDF,沙盒自带 chromium 渲染。
渲染链(全程沙盒内,不进 weasyprint不装额外包):
md --(python `markdown` )--> HTML --(chromium --headless --print-to-pdf)--> PDF
chromium 是镜像里已装的( mermaid ),fonts-noto-cjk 也已装;chromium 是完整浏览器
内核,CSS 保真度比 weasyprint 冒烟见 deploy/sandbox/probe_chromium_pdf.sh
视觉与 docx 一致:复用 common.CHEM_RE(化学式下标白名单,单一事实源)+ 商务红配色 +
DOI/URL 超链引文 [n] 上标回链这版按字面渲染(后续与 docx 一起 DRY 再补)
ASCII-only stdout(Windows GBK 控制台安全)
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import tempfile
from pathlib import Path
from .common import CHEM_RE
# ───────────────────────── 主题色(与 docx 商务红一致)─────────────────────────
PRIMARY = "#C00000"
TLDR_FILL = "#FBE9E9"
LINK_BLUE = "#1155CC"
TABLE_HEAD_FILL = "#C00000"
TABLE_ZEBRA = "#F8F0F0"
# 行内 DOI 子串(HTML-safe 边界)
_DOI_INLINE_RE = re.compile(r"10\.\d{4,9}/[^\s<>\"]+")
# 裸 URL / 域名 token
_URL_TOKEN_RE = re.compile(
r"(?<![\w/@.])((?:https?://)?[a-z0-9][\w.\-]*\.[a-z]{2,}(?:/[^\s<>\"]*)?)",
re.IGNORECASE,
)
# 切分 HTML 成 [文本, 标签, ...];只对文本 token 做下标/超链替换
_TAG_SPLIT = re.compile(r"(<[^>]+>)")
_SKIP_TAGS = {"a", "code", "pre", "script", "style", "head"}
_TAG_NAME_RE = re.compile(r"<\s*(/?)\s*([a-zA-Z0-9]+)")
def _log(msg: str) -> None:
print(f"[render_pdf] {msg}")
def _emit_chem(text: str) -> str:
def repl(m: re.Match) -> str:
return re.sub(r"(\d+)", r"<sub>\1</sub>", m.group(0))
return CHEM_RE.sub(repl, text)
def _emit_links(text: str) -> str:
def doi_repl(m: re.Match) -> str:
doi = m.group(0)
return f'<a href="https://doi.org/{doi}">{doi}</a>'
text = _DOI_INLINE_RE.sub(doi_repl, text)
out_parts = []
for piece in _TAG_SPLIT.split(text):
if piece.startswith("<"):
out_parts.append(piece)
continue
def url_repl(m: re.Match) -> str:
raw = m.group(1)
href = raw if raw.lower().startswith("http") else f"https://{raw}"
return f'<a href="{href}">{raw}</a>'
out_parts.append(_URL_TOKEN_RE.sub(url_repl, piece))
return "".join(out_parts)
def _enrich_html(html: str) -> str:
"""对 HTML 纯文本片段做化学式下标 + DOI/URL 超链;<a>/<code>/<pre> 内不动。"""
out = []
skip_depth = 0
for token in _TAG_SPLIT.split(html):
if not token:
continue
if token.startswith("<"):
m = _TAG_NAME_RE.match(token)
if m:
closing, name = m.group(1), m.group(2).lower()
if name in _SKIP_TAGS and not token.rstrip().endswith("/>"):
skip_depth += -1 if closing else 1
skip_depth = max(0, skip_depth)
out.append(token)
else:
out.append(token if skip_depth else _emit_links(_emit_chem(token)))
return "".join(out)
def _read_sections(src: Path) -> str:
if src.is_dir():
parts = [md.read_text(encoding="utf-8") for md in sorted(src.glob("*.md"))]
if not parts:
raise SystemExit(f"[render_pdf] no *.md under {src}")
return "\n\n".join(parts)
return src.read_text(encoding="utf-8")
def _css(color: bool) -> str:
primary = PRIMARY if color else "#000000"
head_fill = TABLE_HEAD_FILL if color else "#000000"
zebra = TABLE_ZEBRA if color else "#FFFFFF"
tldr = TLDR_FILL if color else "#FFFFFF"
link = LINK_BLUE if color else "#000000"
return f"""
@page {{ size: A4; margin: 2.2cm 2cm; }}
* {{ -webkit-print-color-adjust: exact; print-color-adjust: exact; }}
body {{ font-family: 'Times New Roman','Noto Serif CJK SC','Noto Sans CJK SC',serif;
font-size: 12pt; line-height: 1.6; color: #000; }}
h1 {{ font-family: 'Noto Sans CJK SC',sans-serif; font-size: 19pt; color: {primary};
border-bottom: 2px solid {primary}; padding-bottom: 4pt; margin: 22pt 0 12pt; }}
h2 {{ font-family: 'Noto Sans CJK SC',sans-serif; font-size: 15pt; color: {primary}; margin: 20pt 0 8pt; }}
h3 {{ font-family: 'Noto Sans CJK SC',sans-serif; font-size: 13pt; color: {primary}; margin: 16pt 0 6pt; }}
p {{ text-align: justify; margin: 6pt 0; }}
a {{ color: {link}; text-decoration: underline; word-break: break-all; }}
sub {{ font-size: 0.72em; }}
table {{ border-collapse: collapse; width: 100%; margin: 12pt 0; font-size: 10.5pt; }}
th {{ background: {head_fill}; color: #fff; padding: 6pt 8pt; border: 1px solid #999; text-align: center; }}
td {{ padding: 5pt 8pt; border: 1px solid #999; }}
tr:nth-child(even) td {{ background: {zebra}; }}
blockquote {{ border-left: 4px solid {primary}; background: {tldr}; margin: 12pt 0;
padding: 8pt 12pt; font-size: 11pt; }}
blockquote p {{ margin: 3pt 0; }}
code {{ font-family: Consolas,monospace; font-size: 10pt; background: #f5f5f5; padding: 1pt 3pt; }}
ul,ol {{ margin: 6pt 0; padding-left: 22pt; }}
li {{ margin: 3pt 0; }}
"""
def _find_chromium() -> str:
env = os.environ.get("CHROMIUM") or os.environ.get("CHROME")
cands = [env] if env else []
cands += ["chromium", "chromium-browser", "google-chrome",
"/usr/bin/chromium", "/usr/bin/chromium-browser"]
for c in cands:
if c and (shutil.which(c) or Path(c).exists()):
return shutil.which(c) or c
raise SystemExit("[render_pdf] chromium 不在沙盒里(镜像应已装,给 mermaid 用)。"
"确认 `which chromium` 或设 CHROMIUM 环境变量。")
def md_to_pdf(src: Path, out: Path, *, color: bool = True, profile: str = "") -> Path:
try:
import markdown
except ImportError:
raise SystemExit("[render_pdf] 缺 `markdown` 包。基础镜像应已装(requirements.txt);"
"本地补:.venv/Scripts/python.exe -m pip install markdown")
md_text = _read_sections(src)
body = markdown.markdown(
md_text, extensions=["tables", "fenced_code", "sane_lists", "attr_list"]
)
body = _enrich_html(body)
html = (f'<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">'
f"<style>{_css(color)}</style></head><body>{body}</body></html>")
chromium = _find_chromium()
out.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="render-pdf-") as tmp:
html_path = Path(tmp) / "doc.html"
html_path.write_text(html, encoding="utf-8")
cmd = [
chromium, "--headless", "--disable-gpu", "--no-sandbox",
"--disable-dev-shm-usage", f"--user-data-dir={tmp}/cr",
"--no-pdf-header-footer",
f"--print-to-pdf={out}", html_path.as_uri(),
]
proc = subprocess.run(cmd, capture_output=True, timeout=120, check=False)
if proc.returncode != 0 or not out.exists() or out.stat().st_size == 0:
tail = (proc.stderr or proc.stdout or b"").decode("utf-8", "replace")[-600:]
raise SystemExit(f"[render_pdf] chromium 转 PDF 失败(rc={proc.returncode}):\n{tail}")
return out

63
rendering/render.py Normal file
View File

@ -0,0 +1,63 @@
"""平台渲染统一入口。各 skill 出 docx/pdf 都调这一个,不再自带 render 脚本。
用法(沙盒内 / host ):
python /sandbox/rendering/render.py --profile brief --format docx <sections> -o out.docx
python /sandbox/rendering/render.py --profile brief --format pdf <sections> -o out.pdf
python /sandbox/rendering/render.py --profile paper --format docx <sections> --lang zh -o out.docx
python /sandbox/rendering/render.py --profile proposal --format docx <sections> --fund-type key_rd -o out.docx
--no-color 出黑白(brief docx / 任意 pdf 生效)<sections> 可为目录(拼接其 *.md)或单个 .md
"""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
# bootstrap:让 `import rendering.*` 在 `python /sandbox/rendering/render.py` 直接调时也能解析。
# render.py 恒在 <root>/rendering/render.py,故 dirname(dirname(__file__)) 恒为含 rendering/ 的根
# (沙盒=/sandbox,host=repo 根),与挂载点 / 深度无关。
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from rendering import docx_brief, docx_manuscript, pdf # noqa: E402
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description="md(sections 目录或单 .md)→ docx / pdf")
ap.add_argument("src", type=Path, help="sections 目录(拼接其 *.md)或单个 .md")
ap.add_argument("--profile", required=True, choices=["brief", "paper", "proposal"])
ap.add_argument("--format", default="docx", choices=["docx", "pdf"])
ap.add_argument("-o", "--output", type=Path, required=True, help="输出路径")
ap.add_argument("--no-color", dest="color", action="store_false",
help="关配色出黑白(brief docx / pdf 生效)")
ap.add_argument("--lang", choices=["zh", "en"], default="en",
help="paper 图题前缀 图/Fig.;默认 en")
ap.add_argument("--toc", action="store_true", help="paper 生成目录页(proposal 始终带)")
ap.add_argument("--fund-type", default="key_rd",
help="proposal 基金类型(仅打印标注)")
args = ap.parse_args(argv)
if not args.src.exists():
print(f"[render] 输入不存在:{args.src}", file=sys.stderr)
return 1
if args.format == "pdf":
out = pdf.md_to_pdf(args.src, args.output, color=args.color, profile=args.profile)
print(f"[render] OK pdf -> {out} ({out.stat().st_size} bytes)")
return 0
# docx
if args.profile == "brief":
docx_brief.render_sections(args.src, args.output, args.color)
elif args.profile == "paper":
docx_manuscript.render_sections("paper", args.src, args.output,
lang=args.lang, toc=args.toc)
else: # proposal
docx_manuscript.render_sections("proposal", args.src, args.output,
fund_type=args.fund_type)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -7,6 +7,11 @@ rich>=13.7.0
python-pptx>=0.6.21
python-docx>=1.1.0
matplotlib>=3.8.0
Pillow>=9.0.0 # ppt skill(SVG-first)svg_finalize:配图裁切/内嵌
# ppt skill 可选 —— 老版 Office(<2019)的 SVG→PNG 兜底;现代 PowerPoint 直接渲 SVG 无需,核心不依赖:
# svglib>=1.5.0
# reportlab>=4.0.0
markdown>=3.5 # skills/_shared/render_pdf.py: md→HTML→chromium 出 PDF(纯 Python,host/sandbox 通吃)
# 素材摄取: PDF/DOCX/PPTX/XLSX/HTML/URL → Markdown (ppt 阶段零 + proposal 阶段零)
markitdown[pdf,docx,pptx,xlsx]>=0.0.1
@ -15,6 +20,13 @@ markitdown[pdf,docx,pptx,xlsx]>=0.0.1
httpx>=0.27.0
html2text>=2024.0
# 定时任务(§8.5 scheduled_jobs):cron 串 → next_run_at 计算,正确处理 dom/dow OR 语义 + 时区
croniter>=2.0
# 微信接入(§8.7 ClawBot):segno 渲绑定二维码;cryptography 做凭据列加密 + 文件 AES-128-ECB
segno>=1.6
cryptography>=42.0
# §7 B 阶段: Storage 落 PG
sqlalchemy>=2.0.0
psycopg[binary]>=3.1.0

77
scripts/diag_dump_task.py Normal file
View File

@ -0,0 +1,77 @@
"""按 email + 任务名 dump 一个 task 的完整对话记录(ASCII 标签,Windows GBK 安全)。"""
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_dump.txt", "w", encoding="utf-8")
def print(*a, **k): # noqa: A001 - redirect to utf-8 file
builtins.print(*a, **k, file=_out)
engine = create_engine(os.environ["ZCBOT_DB_URL"])
email = sys.argv[1] if len(sys.argv) > 1 else "caoqianming@foxmail.com"
name_like = sys.argv[2] if len(sys.argv) > 2 else "生图测试"
def s(x, n=4000):
t = str(x or "")
return t if len(t) <= n else t[:n] + f"...[+{len(t)-n} chars]"
with engine.connect() as conn:
uid = conn.execute(text("select user_id from users where email=:e"), {"e": email}).fetchone()
if not uid:
print("[NO USER]", email)
sys.exit(1)
uid = uid[0]
rows = conn.execute(
text("select task_id,name,skill,model,model_profile,status,run_status,run_error,"
"tokens_prompt,tokens_completion,created_at,updated_at from tasks "
"where user_id=:u and name like :n order by created_at"),
{"u": uid, "n": "%" + name_like + "%"},
).fetchall()
print(f"[USER] {email} matched tasks: {len(rows)}")
for r in rows:
print(f" task={r[0]} name={r[1]!r} skill={r[2]!r} model={r[3]}/{r[4]} "
f"status={r[5]} run={r[6]} tok={r[8]}/{r[9]} created={r[10]}")
if r[7]:
print(f" run_error: {s(r[7], 500)}")
if not rows:
sys.exit(0)
tid = rows[-1][0]
print(f"\n========== DUMP task {tid} ==========")
msgs = conn.execute(
text("select idx,payload,model_profile,tokens_in,tokens_out from messages "
"where task_id=:t order by idx"),
{"t": tid},
).fetchall()
print(f"messages: {len(msgs)}\n")
for idx, p, mp, ti, to in msgs:
role = p.get("role")
head = f"[{idx}] {role}"
if mp:
head += f" ({mp})"
if ti or to:
head += f" tok={ti}/{to}"
print(head)
content = p.get("content")
if content:
print(" content:", s(content, 3000))
for tc in p.get("tool_calls") or []:
fn = tc.get("function") or {}
print(f" CALL {fn.get('name')}({s(fn.get('arguments'), 1500)})")
if role == "tool":
print(f" TOOL[{p.get('name')}]:", s(content, 2000))
print()

View File

@ -0,0 +1,56 @@
"""Dump the task_progress tool-call sequence for a task (by id prefix). ASCII-only."""
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
engine = create_engine(os.environ["ZCBOT_DB_URL"])
prefix = sys.argv[1] if len(sys.argv) > 1 else "d1285247"
with engine.connect() as conn:
row = conn.execute(
text("select task_id,name,status,run_status from tasks where task_id::text like :p"),
{"p": prefix + "%"},
).fetchone()
if not row:
print("[NO TASK]", prefix)
sys.exit(1)
tid = row[0]
print(f"[TASK] {tid} name={row[1]!r} status={row[2]} run={row[3]}")
msgs = conn.execute(
text("select idx,payload from messages where task_id=:t order by idx"),
{"t": tid},
).fetchall()
print(f"[MESSAGES] {len(msgs)}")
n = 0
for idx, p in msgs:
for tc in p.get("tool_calls") or []:
fn = tc.get("function") or {}
if fn.get("name") != "task_progress":
continue
n += 1
try:
args = json.loads(fn.get("arguments") or "{}")
except Exception as e:
print(f" [{idx}] PARSE-ERR: {e} raw={fn.get('arguments')!r}")
continue
act = args.get("action")
if act == "set_plan":
steps = args.get("steps") or []
print(f" [{idx}] set_plan ({len(steps)} steps):")
for st in steps:
print(f" {st.get('id')!r:8} {st.get('status'):11} {st.get('title')!r}")
elif act == "update_step":
st = args.get("step") or {}
print(f" [{idx}] update_step id={st.get('id')!r} status={st.get('status')!r} title={st.get('title')!r}")
else:
print(f" [{idx}] {act} {json.dumps(args, ensure_ascii=False)}")
print(f"[task_progress calls] {n}")

View File

@ -0,0 +1,79 @@
"""扫最近的 task,定位「bad arguments to run_python: code or script_path must be
provided到底什么时候真正触发
两条线:
A. 直接在 tool-result 消息里搜这句错误 这是运行时真的报了的铁证
B. 看产生它的那条 assistant run_python 调用,arguments 到底长啥样
排除 `_compacted`(那是入库后上下文压缩留下的历史占位,运行时是有 code ,不算)
"""
import json
import os
from collections import Counter
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
engine = create_engine(os.environ["ZCBOT_DB_URL"])
ERR = "bad arguments to run_python: code or script_path must be provided"
with engine.connect() as conn:
tasks = conn.execute(
text("select task_id, created_at from tasks order by created_at desc limit 60")
).fetchall()
per_task = Counter()
shapes = Counter()
samples = []
for tid, created in tasks:
msgs = conn.execute(
text("select idx, payload from messages where task_id=:t order by idx"),
{"t": tid},
).fetchall()
# 建 tool_call_id -> arguments 映射(看错误对应的调用 args)
call_args = {}
for idx, payload in msgs:
if payload.get("role") == "assistant":
for tc in payload.get("tool_calls") or []:
call_args[tc.get("id")] = (tc.get("function") or {}).get("arguments")
for idx, payload in msgs:
if payload.get("role") != "tool":
continue
content = payload.get("content") or ""
if isinstance(content, list):
content = json.dumps(content, ensure_ascii=False)
if ERR not in content:
continue
per_task[(str(tid)[:8], str(created)[:16])] += 1
raw = call_args.get(payload.get("tool_call_id"))
# 归类 args 形态
try:
args = json.loads(raw) if raw else {}
except Exception:
shape = "MANGLED(非法JSON)"
else:
if args == {}:
shape = "{}"
elif "_compacted" in args:
shape = "_compacted(历史占位)"
else:
shape = "其他: " + repr(raw)[:80]
shapes[shape] += 1
if len(samples) < 25:
samples.append((str(tid)[:8], idx, shape, repr(raw)[:140]))
print(f"扫了最近 {len(tasks)} 个 task")
print(f"真正触发该错误的 tool-result 条数: {sum(per_task.values())}\n")
print("=== 按 task 分布(task / 创建时间 / 次数)===")
for (t, c), n in per_task.most_common():
print(f" {t} {c} -> {n}")
print("\n=== 触发时 run_python 的 arguments 形态 ===")
for s, n in shapes.most_common():
print(f" {n:>3}x {s}")
print("\n=== 样本 ===")
for t, idx, shape, raw in samples:
print(f" [{t} #{idx}] {shape}: {raw}")

View File

@ -0,0 +1,56 @@
"""对某 task:列出每条 run_python 报错的 tool-result,并回溯它配对的 assistant
tool_call arguments( tool_call_id),判断报错那一刻 DB 里存的 args
真实 code / {} / 还是 _compacted 占位"""
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
engine = create_engine(os.environ["ZCBOT_DB_URL"])
prefix = sys.argv[1] if len(sys.argv) > 1 else "9956b139"
ERR = "code or script_path must be provided"
with engine.connect() as conn:
tid = conn.execute(
text("select task_id from tasks where task_id::text like :p"),
{"p": prefix + "%"},
).fetchone()[0]
msgs = conn.execute(
text("select idx, payload from messages where task_id=:t order by idx"),
{"t": tid},
).fetchall()
# id -> (assist_idx, name, raw_args)
by_id = {}
for idx, payload in msgs:
if payload.get("role") == "assistant":
for tc in payload.get("tool_calls") or []:
fn = tc.get("function") or {}
by_id[tc.get("id")] = (idx, fn.get("name"), fn.get("arguments"))
print(f"task {tid}\n")
n = 0
for idx, payload in msgs:
if payload.get("role") != "tool":
continue
content = payload.get("content") or ""
if isinstance(content, list):
content = json.dumps(content, ensure_ascii=False)
if ERR not in content:
continue
n += 1
tcid = payload.get("tool_call_id")
src = by_id.get(tcid)
if src is None:
print(f"[err #{idx}] tcid={tcid} -> 找不到配对的 assistant 调用!")
continue
a_idx, name, raw = src
print(f"[err #{idx}] <- assist #{a_idx} {name} : {repr(raw)[:110]}")
print(f"\n{n} 条报错")

View File

@ -0,0 +1,93 @@
"""diag: 查 scheduled-e621c8a6 这个 job 为何执行到一半没推送(ASCII only, GBK safe)."""
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 / "_sched_e621.txt", "w", encoding="utf-8")
def print(*a, **k): # noqa: A001
builtins.print(*a, **k, file=_out)
PREFIX = sys.argv[1] if len(sys.argv) > 1 else "e621c8a6"
engine = create_engine(os.environ["ZCBOT_DB_URL"])
def s(x, n=2000):
t = str(x if x is not None else "")
return t if len(t) <= n else t[:n] + f"...[+{len(t)-n}]"
with engine.connect() as conn:
job = conn.execute(text(
"select job_id,user_id,name,mode,cron,tz,enabled,notify,timeout_seconds,"
"next_run_at,last_run_at,last_status,last_error,last_task_id,"
"consecutive_failures,run_count,bound_task_id,created_at,deleted_at "
"from scheduled_jobs where cast(job_id as text) like :p"),
{"p": PREFIX + "%"}).fetchall()
print(f"[JOBS matched '{PREFIX}'] {len(job)}")
for j in job:
print("-" * 60)
print(f"job_id={j[0]} name={j[2]!r}")
print(f" mode={j[3]} cron={j[4]!r} tz={j[5]} enabled={j[6]} timeout={j[8]}")
print(f" notify={j[7]}")
print(f" next_run_at={j[9]} last_run_at={j[10]}")
print(f" last_status={j[11]} consecutive_failures={j[14]} run_count={j[15]}")
print(f" last_task_id={j[13]} bound_task_id={j[16]}")
print(f" deleted_at={j[18]} created_at={j[17]}")
if j[12]:
print(f" last_error: {s(j[12], 1500)}")
if not job:
sys.exit(0)
j = job[0]
uid = j[1]
last_tid = j[13]
# 找该 job 关联的所有 task(scheduled_job_id 回填 + last_task_id)
tasks = conn.execute(text(
"select task_id,name,status,run_status,run_error,tokens_prompt,tokens_completion,"
"created_at,updated_at,scheduled_job_id from tasks "
"where scheduled_job_id = :jid order by created_at"),
{"jid": str(j[0])}).fetchall()
print("\n" + "=" * 60)
print(f"[TASKS with scheduled_job_id={str(j[0])[:8]}] {len(tasks)}")
for t in tasks:
print(f" task={t[0]} name={t[1]!r} status={t[2]} run={t[3]} "
f"tok={t[5]}/{t[6]} created={t[7]} updated={t[8]}")
if t[4]:
print(f" run_error: {s(t[4], 1500)}")
# dump last_task_id 的消息(执行到哪一步)
tid = last_tid or (tasks[-1][0] if tasks else None)
if tid is None:
print("\n[no task to dump]")
sys.exit(0)
print("\n" + "=" * 60)
print(f"[DUMP messages of task {tid}]")
msgs = conn.execute(text(
"select idx,payload,tokens_in,tokens_out,created_at from messages "
"where task_id=:t order by idx"), {"t": str(tid)}).fetchall()
print(f"messages: {len(msgs)}\n")
for idx, p, ti, to, cat in msgs:
role = p.get("role")
head = f"[{idx}] {role} tok={ti}/{to} at={cat}"
print(head)
content = p.get("content")
if content:
print(" content:", s(content, 1500))
for tc in p.get("tool_calls") or []:
fn = tc.get("function") or {}
print(f" CALL {fn.get('name')}({s(fn.get('arguments'), 800)})")
if role == "tool":
print(f" TOOL[{p.get('name')}]:", s(content, 1200))
print()

View File

@ -0,0 +1,87 @@
"""诊断微信对话里 wechat_push 发文件失败:dump 绑定状态 + 微信 task 里 wechat_push 工具调用与返回。
ASCII 标签(Windows GBK 安全)用法:.venv/Scripts/python.exe scripts/diag_wechat_push.py [email]
"""
import json
import os
import sys
from datetime import datetime, timezone
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 / "_wechat_push_dump.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"])
email = sys.argv[1] if len(sys.argv) > 1 else "caoqianming@foxmail.com"
def s(x, n=2000):
t = str(x or "")
return t if len(t) <= n else t[:n] + f"...[+{len(t)-n}]"
with engine.connect() as conn:
row = conn.execute(text("select user_id from users where email=:e"), {"e": email}).fetchone()
if not row:
print("[NO USER]", email); sys.exit(1)
uid = row[0]
print("[USER]", uid)
b = conn.execute(text(
"select user_im_id, base_url, status, context_token_at, "
"(latest_context_token is not null) as has_ctx, chat_task_id "
"from wechat_bot_bindings where user_id=:u"), {"u": uid}).fetchone()
if not b:
print("[NO BINDING]"); sys.exit(1)
print("[BINDING] status=%s user_im_id=%s has_ctx=%s ctx_at=%s base=%s" % (
b.status, b.user_im_id, b.has_ctx, b.context_token_at, b.base_url))
print("[BINDING] chat_task_id=%s" % b.chat_task_id)
if b.context_token_at:
at = b.context_token_at
if at.tzinfo is None:
at = at.replace(tzinfo=timezone.utc)
age = datetime.now(timezone.utc) - at
print("[BINDING] ctx age = %s (fresh if <24h)" % age)
tid = b.chat_task_id
if not tid:
print("[NO CHAT TASK]"); sys.exit(0)
# dump messages, focus on wechat_push tool calls/results
rows = conn.execute(text(
"select idx, payload from messages where task_id=:t order by idx desc limit 60"),
{"t": tid}).fetchall()
print("\n[MESSAGES] last %d (newest first):" % len(rows))
for idx, payload in rows:
if isinstance(payload, str):
try:
payload = json.loads(payload)
except Exception:
pass
if not isinstance(payload, dict):
continue
role = payload.get("role")
# assistant tool_calls
tcs = payload.get("tool_calls") or []
for tc in tcs:
fn = (tc.get("function") or {})
if fn.get("name") == "wechat_push":
print(" #%s [CALL wechat_push] args=%s" % (idx, s(fn.get("arguments"), 800)))
# tool result
if role == "tool":
name = payload.get("name", "")
content = payload.get("content")
if name == "wechat_push" or "微信" in s(content, 200) or "wechat" in s(name):
print(" #%s [TOOL RESULT %s] %s" % (idx, name, s(content, 800)))

81
scripts/diag_wecom.py Normal file
View File

@ -0,0 +1,81 @@
"""企业微信推送诊断:分步查 gettoken / message_send 的确切 errcode/errmsg。
用法(服务器上,.env 同目录):
.venv/Scripts/python.exe scripts/diag_wecom.py <userid>
.env WECOM_CORPID/AGENTID/SECRETASCII 输出,secret 不打印
常见 errcode:
gettoken: 40013=corpid / 40001|42001=secret / 41002= corpid
send: 60011=无权限(应用可见范围没包含该成员)/ 81013=UserID 不存在
40056=agentid / 60020=IP 不在可信IP / 81014=该成员未关注/未激活
"""
import os
import sys
# 仓库根加入 sys.path(脚本在 scripts/ 下,直跑时 core 在上一级)
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
def _load_env(path: str) -> None:
"""加载 .env:优先 python-dotenv,没装则手动解析(只填未设置的 key)。"""
try:
from dotenv import load_dotenv
load_dotenv(path)
return
except Exception:
pass
try:
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
except FileNotFoundError:
pass
_load_env(os.path.join(_ROOT, ".env"))
from core.wechat import wecom
def main() -> int:
uid = sys.argv[1] if len(sys.argv) > 1 else None
print("[cfg] configured:", wecom.wecom_configured())
print("[cfg] corpid:", (os.getenv("WECOM_CORPID", "") or "")[:8] + "...",
"| agentid:", os.getenv("WECOM_AGENTID", ""))
if not wecom.wecom_configured():
print("[FAIL] WECOM_CORPID/AGENTID/SECRET 没读到(确认 .env 在当前目录、值已填)")
return 1
print("[step1] gettoken ...")
try:
tok = wecom.get_access_token(force=True)
print(f"[step1] OK (token len {len(tok)})")
except Exception as e:
print(f"[step1] FAIL: {e}")
print(" → corpid 或 secret 不对(secret 必须是这个自建应用的,不是通讯录密钥)")
return 2
if not uid:
print("[step2] 跳过(没给 userid 参数);用法: diag_wecom.py <userid>")
return 0
print(f"[step2] message/send 到 userid={uid} ...")
try:
wecom.send_text(uid, "zcbot 企业微信诊断测试消息")
print(f"[step2] OK → 去企业微信查收。链路通了!")
except Exception as e:
print(f"[step2] FAIL: {e}")
print(" → 看 errcode:60011=应用可见范围没含该成员 / 81013=userid 写错"
"(大小写要和通讯录「账号」完全一致)/ 40056=agentid 错")
return 3
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,418 @@
# -*- coding: utf-8 -*-
"""一次性脚本:重组并瘦身《后端AI技术架构脉络说明-2》。
- 双产品重组为 4 部分;删除占位/虚构内容并替换为真实信息;长段落瘦身
- 输出 -3,保留 -2 原件不动视觉模板不动,只改文字 + 增删/重排页
"""
import copy
from pptx import Presentation
from pptx.oxml.ns import qn
from pptx.util import Inches, Pt
from pptx.enum.text import PP_ALIGN
SRC = "后端AI技术架构脉络说明20260608-2.pptx"
DST = "后端AI技术架构脉络说明20260608-3.pptx"
A_P = qn('a:p'); A_R = qn('a:r'); A_T = qn('a:t')
def first_run_size(p):
for r in p.runs:
if r.font.size is not None:
return r.font.size.pt
return None
def size_templates(tf):
d = {}
for p in tf.paragraphs:
if not p.runs:
continue
sz = first_run_size(p)
key = sz if sz is not None else '_none'
if key not in d:
d[key] = copy.deepcopy(p._p)
return d
def clone_para_with_text(template_p, text):
new_p = copy.deepcopy(template_p)
rs = new_p.findall(A_R)
if not rs:
return new_p
first = rs[0]
t = first.find(A_T)
if t is None:
t = first.makeelement(A_T, {})
first.append(t)
t.text = text
for r in rs[1:]:
new_p.remove(r)
return new_p
def rebuild(tf, specs):
"""specs: [(text, size_pt)] size 用于挑选要克隆格式的模板段落。"""
templ = size_templates(tf)
if not templ:
return
body = tf._txBody
for p in body.findall(A_P):
body.remove(p)
for text, sz in specs:
t = templ.get(sz)
if t is None:
t = next(iter(templ.values()))
body.append(clone_para_with_text(t, text))
def shp(slide, name):
for s in slide.shapes:
if s.name == name:
return s
return None
def edit(slide, name, specs):
s = shp(slide, name)
if s is None or not s.has_text_frame:
print(f" !! 未找到形状 {name}")
return
rebuild(s.text_frame, specs)
def del_shape(slide, name):
s = shp(slide, name)
if s is not None:
s._element.getparent().remove(s._element)
def retext(shape, text, size_pt=None, align=None):
"""改单段文字 + 可选字号/对齐,保留原 run 的颜色/字体(克隆来的)。"""
tf = shape.text_frame
body = tf._txBody
for p in body.findall(A_P)[1:]:
body.remove(p)
p0 = tf.paragraphs[0]
rs = p0._p.findall(A_R)
if rs:
first = rs[0]
t = first.find(A_T)
if t is None:
t = first.makeelement(A_T, {})
first.append(t)
t.text = text
for r in rs[1:]:
p0._p.remove(r)
if size_pt is not None:
for r in p0.runs:
r.font.size = Pt(size_pt)
if align is not None:
p0.alignment = align
def clone_shape(slide, src_name, new_name):
"""深拷贝一个形状(连同主题色/字体),分配唯一 id + 新名,返回 Shape。"""
src = shp(slide, src_name)
spTree = src._element.getparent()
new_sp = copy.deepcopy(src._element)
spTree.append(new_sp)
ids = [int(e.get('id')) for e in spTree.iter(qn('p:cNvPr'))
if e.get('id') and e.get('id').isdigit()]
nid = (max(ids) + 1) if ids else 100
cNvPr = new_sp.find(qn('p:nvSpPr')).find(qn('p:cNvPr'))
cNvPr.set('id', str(nid))
cNvPr.set('name', new_name)
for s in slide.shapes:
if s._element is new_sp:
return s
return None
def place(shape, left, top, width, height):
shape.left = Inches(left)
shape.top = Inches(top)
shape.width = Inches(width)
shape.height = Inches(height)
prs = Presentation(SRC)
S = prs.slides # 原始 0-based 索引 = 幻灯片号 - 1
# ============ S1 (idx0) 封面:删除 4 段概述(标题+正文+连接线+卡片底框),只留标题/副标题 ============
# 每段含:卡片底框(9/13/17/21)、标题(10/14/18/22)、连接线(11/15/19/23)、正文(12/16/20/24)
for nm in ["AutoShape 6",
"AutoShape 9", "AutoShape 13", "AutoShape 17", "AutoShape 21", # 卡片底框
"AutoShape 10", "AutoShape 14", "AutoShape 18", "AutoShape 22", # 标题
"AutoShape 12", "AutoShape 16", "AutoShape 20", "AutoShape 24", # 正文
"Connector 11", "Connector 15", "Connector 19", "Connector 23"]:
del_shape(S[0], nm)
# 重新设计封面:左侧竖条/logo/顶部细线/页脚 保留作装饰边框;
# 主标题下移居中放大,补副标题(双产品)、议程标语(四部分)、落款单位。
cover = S[0]
# 主标题:24 -> 40,移到垂直居中偏上,左对齐与竖条呼应
retext(shp(cover, "AutoShape 7"), "后端 AI 技术架构脉络说明", size_pt=40, align=PP_ALIGN.LEFT)
place(shp(cover, "AutoShape 7"), 1.5, 2.55, 10.3, 1.0)
# 副标题:两大产品线
sub = clone_shape(cover, "AutoShape 7", "CoverSubtitle")
retext(sub, "水泥基配方大模型 · 科研智能体应用平台", size_pt=20, align=PP_ALIGN.LEFT)
place(sub, 1.55, 3.7, 10.0, 0.6)
# 议程标语:四部分一行
agenda = clone_shape(cover, "AutoShape 7", "CoverAgenda")
retext(agenda, "总体架构与核心定位 / 五大引擎与训练 / 智能体应用平台 / 总结与展望",
size_pt=14, align=PP_ALIGN.LEFT)
place(agenda, 1.57, 4.45, 10.5, 0.5)
# 落款单位:左下
unit = clone_shape(cover, "AutoShape 7", "CoverUnit")
retext(unit, "中国建筑材料科学研究总院 · AI 技术部", size_pt=13, align=PP_ALIGN.LEFT)
place(unit, 1.57, 6.1, 8.0, 0.4)
# ============ S2 (idx1) 目录:改为真实 4 部分 ============
edit(S[1], "AutoShape 7", [("后端 AI 技术架构 · 汇报目录", 24)])
edit(S[1], "AutoShape 12", [("总体架构与核心定位", 18)])
edit(S[1], "AutoShape 13", [("明确平台核心定位与建设目标,展示总体技术架构图与核心技术栈,奠定整体框架。", 14)])
edit(S[1], "AutoShape 17", [("配方大模型:五大引擎与训练", 18)])
edit(S[1], "AutoShape 18", [("智能问答、知识库构建、知识库问答、文档分类、实验设计五大引擎,及配方大模型训练体系与成效。", 14)])
edit(S[1], "AutoShape 22", [("科研智能体应用平台", 18)])
edit(S[1], "AutoShape 23", [("自然语言驱动的科研智能体:工作流、定位价值、14 项 Skill 能力矩阵与平台技术架构。", 14)])
edit(S[1], "AutoShape 27", [("总结与展望", 18)])
edit(S[1], "AutoShape 28", [("汇总核心成果与模型矩阵,总结建设价值,展望后续优化方向。", 14)])
# ============ S3 (idx2) PART 01:替换占位/假技术栈为真实内容 ============
edit(S[2], "AutoShape 7", [("PART 01 总体架构与核心定位", 24)])
edit(S[2], "AutoShape 10", [
("01 / 核心定位与目标", 18),
("面向行业场景构建一体化 AI 能力平台,聚焦水泥基材料,打通“通用能力接入 → 专属模型训练 → 智能推理 → 业务落地”全链路闭环。", 14)])
edit(S[2], "AutoShape 12", [
("02 / 总体架构分层", 18),
("分层解耦:应用层 → 后端服务层(五大引擎)→ 模型与数据层 → 行业模型训练模块,各层经标准接口协同,兼顾高可用与弹性扩展。", 14)])
edit(S[2], "AutoShape 14", [
("03 / 关键技术栈", 18),
("FastAPI 高并发异步后端;LangGraph + LangChain 编排;DeepSeek V3.1 / Qwen3 多模型并兼容 OpenAI 接口;Milvus 向量库;LLaMA Factory 训练。", 14)])
# ============ S6 (idx5) 核心技术栈:8 框瘦身 ============
edit(S[5], "AutoShape 12", [("高性能后端框架:FastAPI 高并发异步,保障接口高效、稳定、低延迟,支撑大规模请求与模型调用。", 14)])
edit(S[5], "AutoShape 14", [("智能体流程编排:LangGraph 可视化编排复杂逻辑,LangChain 构建调用链,兼容 OpenAI 接口,多模型高效协同。", 14)])
edit(S[5], "AutoShape 18", [("通用与微调基座:DeepSeek V3.1、Qwen3-30B-A3B 为通用基座,Qwen2.5-1.5B 行业微调,兼顾通用与适配。", 14)])
edit(S[5], "AutoShape 20", [("多模态与向量增强:Qwen2.5-VL 解析视觉内容,BGE-M3 向量化,构建全维度语义理解与知识表示。", 14)])
edit(S[5], "AutoShape 24", [("数据解析与存储:MinerU 解析 PDF/DOC 等非结构化文档,Milvus 向量库实现海量向量高维索引与快速检索。", 14)])
edit(S[5], "AutoShape 26", [("RAG 检索增强:外部知识库与大模型深度融合,有效抑制幻觉,提升回答准确性、专业性与一致性。", 14)])
edit(S[5], "AutoShape 30", [("一站式训练:LLaMA Factory 标准化训练流水线,统一接入多类开源大模型,降低开发与迭代门槛。", 14)])
edit(S[5], "AutoShape 32", [("低成本微调:PEFT + LoRA 仅训练少量关键参数即显著提效,大幅节省算力,加速行业模型落地。", 14)])
# ============ S7 (idx6) PART 02:通用假五引擎 → 真实五引擎总览 ============
edit(S[6], "AutoShape 7", [("PART 02 水泥基配方大模型:五大引擎", 24)])
edit(S[6], "AutoShape 10", [("01 智能问答中枢", 16)])
edit(S[6], "AutoShape 12", [("大模型统一入口,支持通用对话、文件问答、工具调用与多轮会话,可升级为执行任务。", 13)])
edit(S[6], "AutoShape 14", [("02 知识库构建引擎", 16)])
edit(S[6], "AutoShape 16", [("将非结构化文档解析、向量化为可检索、可追溯的企业知识资产,支撑上层应用。", 13)])
edit(S[6], "AutoShape 18", [("03 知识库问答引擎", 16)])
edit(S[6], "AutoShape 20", [("基于 RAG 结合企业知识作答,支持引用溯源,显著抑制大模型幻觉。", 13)])
edit(S[6], "AutoShape 22", [("04 AI 文档分类引擎", 16)])
edit(S[6], "AutoShape 24", [("自动识别文档领域与材料分类并归档,触发向量重建,实现知识治理自动化。", 13)])
edit(S[6], "AutoShape 26", [("05 智能实验设计引擎", 16)])
edit(S[6], "AutoShape 28", [("多阶段工作流将需求转为可执行实验方案,调用行业微调模型生成配方。", 13)])
# ============ S8 (idx7) 智能问答中枢:295 字一坨 → 瘦身 ============
edit(S[7], "AutoShape 2", [
("定位", 16),
("大模型统一入口,负责通用对话、文件问答、工具调用与多轮会话管理。", 13),
("核心技术", 16),
("• LangGraph 编排复杂对话流程", 13),
("• 核心模型 DeepSeek V3.1 / Qwen3-30B-A3B", 13),
("• 支持文件问答、多轮上下文与思考模式", 13),
("• MCP 工具接入外部业务系统与接口", 13),
("• SSE 流式输出,实时生成展示", 13),
("主要价值", 16),
("• 统一、标准化的大模型问答能力", 13),
("• 高扩展性,无缝集成更多业务工具", 13),
("• 从“回答问题”升级为“执行任务”", 13)])
# ============ S9 (idx8) 知识库构建 ============
edit(S[8], "AutoShape 2", [
("核心定位", 16),
("将非结构化文档转化为可检索、可引用、可追溯的企业知识资产,是上层应用的基础。", 13)])
edit(S[8], "AutoShape 3", [
("支持内容类型", 16),
("• 文档类:PDF / Word / PPT / Excel", 13),
("• 图像类:图片、扫描件、图表", 13),
("• 文本类:Markdown / TXT / CSV / JSON", 13)])
edit(S[8], "AutoShape 4", [
("主要价值", 16),
("• 分散资料沉淀为结构化企业知识库", 13),
("• 为问答、实验、训练提供高质量数据基础", 13)])
# ============ S10 (idx9) 知识库问答 ============
edit(S[9], "AutoShape 2", [
("定位", 16),
("基于 RAG 架构,让大模型结合企业内部知识作答,保证专业性与准确性。", 13)])
edit(S[9], "AutoShape 3", [
("核心技术", 16),
("• RAG 检索增强生成", 12),
("• BGE-M3 向量化 + Milvus 检索", 12),
("• DeepSeek/Qwen 结合上下文生成", 12),
("• 支持引用来源溯源", 12),
("• 多维度检索过滤", 12)])
edit(S[9], "AutoShape 4", [
("主要价值", 16),
("• 提升专业性、准确性与可追溯性", 13),
("• 赋能私有文档深度问答", 13),
("• 降低大模型幻觉风险", 13)])
# ============ S11 (idx10) 文档分类:169 字 → 瘦身 ============
edit(S[10], "AutoShape 2", [
("定位", 16),
("自动识别文档领域与材料分类并归档至对应知识库,实现知识管理自动化。", 13)])
edit(S[10], "AutoShape 3", [
("核心技术", 16),
("• 内容理解:基于 MinerU + Qwen2.5-VL 解析", 13),
("• 分类推理:DeepSeek V3.1 / Qwen3 判定", 13),
("• 智能输出:摘要、领域、分类路径、依据、置信度", 13),
("• 闭环管理:自动触发文档迁移与 Milvus 重建", 13)])
edit(S[10], "AutoShape 4", [
("主要价值", 16),
("• 大幅降低人工整理归档成本", 13),
("• 归入正确体系,提升检索效率", 13),
("• 为行业模型筛选标准化数据集", 13)])
# ============ S12 (idx11) 实验设计:238 字 → 瘦身 ============
edit(S[11], "AutoShape 2", [
("▍定位", 16),
("平台最核心的行业智能体能力,经多阶段工作流将需求转为可执行实验方案。", 13),
("▍核心技术", 16),
("• LangGraph 编排含人工确认的实验设计流", 13),
("• 混合调用:通用模型析文献,行业微调模型生成配方", 13),
("• 知识驱动:Milvus 检索文献作决策依据", 13),
("▍主要价值", 16),
("• 海量文献转为实验依据,降低人工成本", 13),
("• 方案生成全链路可追溯", 13),
("• 专项微调模型提升配方专业性与适配性", 13)])
# ============ S14 (idx13) 科研平台:作为 PART 03 开篇 ============
edit(S[13], "AutoShape 7", [("PART 03 科研智能体应用平台", 24)])
edit(S[13], "TextBox 9", [
("以自然语言为入口,平台自动识别意图、动态挂载专业能力,把科研任务串成可执行、可交付的工作流;", 13.5),
("关键节点由用户确认,全程运行在统一的模型、知识与安全底座之上。", 13.5)])
# ============ S15 (idx14) 定位与价值 ============
edit(S[14], "AutoShape 9", [
("面向科研全流程的智能体:以自然语言为入口,自动拆解任务、调度工具与专业能力,把“想法”直接转化为可交付科研产物,压缩从需求到成果的链路。", 16)])
edit(S[14], "AutoShape 14", [
("从问题拆解、文献检索、计算建模、出版级出图,到申报书/标准/专利起草与审稿,覆盖“调研—计算—写作—评审”全链条,无需多工具切换。", 14)])
edit(S[14], "AutoShape 18", [
("用自然语言描述需求,平台自动识别意图、挂载对应专业能力,按阶段化流程推进,关键节点与用户确认,过程可控可追溯。", 14)])
edit(S[14], "AutoShape 22", [
("不止对话回答,直接产出 Word / PPT / 图表 / 数据等规范化交付物,贴合科研与项目申报格式,降低整理排版成本。", 14)])
# ============ S16 (idx15) 能力矩阵 ============
edit(S[15], "AutoShape 12", [("申报书/任务书、国标·行标·团标、专利交底书、审稿润色,覆盖立项到评审的写作全链路。", 14)])
edit(S[15], "AutoShape 16", [("检索内部 100 万+ 篇材料学科论文库与全网文献,支持中文检索命中英文文献,提供可溯文献支撑。", 14)])
edit(S[15], "AutoShape 20", [("晶体结构 / XRD 模拟 / 相图计算,配方-性能统计建模与机器学习,服务“配比→性能”预测寻优。", 14)])
edit(S[15], "AutoShape 24", [("一键生成商务级 PPT,出版级 matplotlib 学术图(中文+矢量),让成果能看、能讲、能投稿。", 14)])
edit(S[15], "AutoShape 28", [("文生图、文生视频按需调用,为封面、概念示意与宣传材料快速产出配图与动效。", 14)])
edit(S[15], "AutoShape 32", [("科学问题拆解与路线图引导、代码实现与调试,把专业能力按任务智能编排串联。", 14)])
edit(S[15], "AutoShape 34", [("当前已沉淀 14 项专业能力(skill),按“科研写作·文献检索·科研计算·演示出图·内容生成·通用元能力”六类组织,并可持续扩展。", 13)])
# ============ S17 (idx16) 平台技术架构:8 框瘦身 ============
edit(S[16], "AutoShape 12", [("ReAct 智能体循环:“思考→调用工具→观察”自主迭代,内置重复调用守卫与异常自愈,自动收敛到可交付结果。", 14)])
edit(S[16], "AutoShape 14", [("阶段化编排:复杂任务以图式工作流编排,嵌入人工确认节点,关键决策由用户拍板,过程可追溯。", 14)])
edit(S[16], "AutoShape 18", [("意图识别 + 按需挂载:识别需求后动态加载对应 skill,不相关能力不进上下文,精准又省算力。", 14)])
edit(S[16], "AutoShape 20", [("可扩展插件:每个 skill 是独立可维护的工作流(流程+模板+脚本),新增能力即插即用。", 14)])
edit(S[16], "AutoShape 24", [("每用户 Docker 沙盒隔离:代码执行与文件读写独立容器运行,资源限额 + 网络管控 + 最小权限。", 14)])
edit(S[16], "AutoShape 26", [("丰富工具集 + MCP:内置文件、命令、Python、联网检索等工具,兼容 MCP 接入外部系统。", 14)])
edit(S[16], "AutoShape 30", [("多模型自由调度:兼容 DeepSeek、Qwen 等及 OpenAI 接口,涉密任务可切内网私有模型。", 14)])
edit(S[16], "AutoShape 32", [("RAG + 长期记忆:向量检索抑制幻觉,双层记忆与长任务断点恢复,跨会话沉淀偏好与上下文。", 14)])
# ============ S18 (idx17) 训练体系导语:215 字 → 瘦身 + 侧标改子节 ============
edit(S[17], "AutoShape 8", [("训练体系", 20)])
edit(S[17], "AutoShape 10", [
("聚焦水泥基材料智能配方大模型的核心训练体系——材料配方智能化设计的技术基石。", 16),
("阐述从数据采集、特征工程到算法选型、迭代优化的全流程逻辑,融合材料科学机理与深度学习,破解传统研发“试错成本高、周期长”的痛点。", 16),
("并展示模型在性能预测、多目标配方寻优等关键环节的技术突破。", 16)])
# ============ S19 (idx18) 训练基础信息 ============
edit(S[18], "AutoShape 12", [("采用 LLaMA Factory 训练框架,Qwen2.5-1.5B-Instruct 为基座,兼顾训练效率与推理性能。", 13)])
edit(S[18], "AutoShape 16", [("PEFT + LoRA:不更新主干参数,仅微调少量低秩矩阵,大幅降低显存与训练成本,精准学习配方知识。", 13)])
edit(S[18], "AutoShape 20", [("以 SFT 为核心,建立“材料性能要求 → 配方组成”的精准映射,实现需求到方案直接转化。", 13)])
edit(S[18], "AutoShape 24", [("基于 16 组实验室实测数据:输入 3 天/7 天抗压、抗折强度;输出矿粉、电石渣、脱硫石膏、粉煤灰、水、减水剂配比。", 13)])
# ============ S21 (idx20) 训练成效 ============
edit(S[20], "AutoShape 12", [
("损失值收敛表现", 14),
("初始 0.6897 → 第 50 轮 0.0073,降幅 98.9%,拟合充分且未过拟合。", 12)])
edit(S[20], "AutoShape 14", [
("学习率动态调整", 14),
("从 4.92e-04 衰减至 4.93e-07,配合损失动态适配,避免后期震荡。", 12)])
edit(S[20], "AutoShape 27", [("损失曲线全程无剧烈波动,稳定在极低水平,参数更新策略有效,鲁棒性佳。", 12)])
edit(S[20], "AutoShape 31", [("模型掌握“低强度→低掺量、高强度→高掺量”行业逻辑,配方贴合工程实际。", 12)])
edit(S[20], "AutoShape 35", [("稳定输出高精度预测值,并按工程格式生成完整配方,直接对接下游系统。", 12)])
# ============ S23 (idx22) 总结 PART 04:删除虚构指标,替换真实成果 ============
edit(S[22], "AutoShape 7", [("总结与展望", 24)])
# 标题与正文是分开的形状:标题改 AutoShape 11/16/21/26,正文改 13/18/23/28
edit(S[22], "AutoShape 11", [("01. 核心能力落地成效", 18)])
edit(S[22], "AutoShape 13", [
("已落地五大引擎(智能问答、知识库构建/问答、文档分类、实验设计)与科研智能体平台,沉淀 14 项专业 skill;水泥基配方大模型完成首版训练,损失收敛至 0.0073。", 14)])
edit(S[22], "AutoShape 16", [("02. 平台技术底座", 18)])
edit(S[22], "AutoShape 18", [
("FastAPI + LangGraph 智能体内核,DeepSeek/Qwen 多模型调度,Milvus + RAG 抑制幻觉,每用户 Docker 沙盒隔离,LLaMA Factory + LoRA 行业微调。", 14)])
edit(S[22], "AutoShape 21", [("03. 业务价值与知识资产", 18)])
edit(S[22], "AutoShape 23", [
("打通“数据→知识→决策”全链路闭环;内部 100 万+ 篇材料论文知识库与配方数据沉淀为可复用知识资产,支撑研发提效。", 14)])
edit(S[22], "AutoShape 26", [("04. 下一阶段规划", 18)])
edit(S[22], "AutoShape 28", [
("配方数据集由 16 条扩充至 200+,简化配方空间,搭建“预测—实验—反馈”闭环,目标配方达标率 ≥85%;持续扩展 skill 与场景。", 14)])
# ============ S24 (idx23) 模型矩阵汇总:6 框各 120+ 字 → 瘦身 ============
edit(S[23], "AutoShape 9", [
("模型矩阵架构", 18),
("基于 DeepSeek、Qwen 大模型底座,融合视觉模型与向量检索,构建“通用+垂直”双轮驱动体系。", 13),
("核心价值:打通“解析→沉淀→决策”全链路闭环,提升研发与应用效率。", 14)])
DIV = "——————————"
edit(S[23], "AutoShape 11", [
("01. 智能问答中枢:通用基座", 15), (DIV, 11),
("模型:DeepSeek V3.1 / Qwen3-30B-A3B", 12),
("场景:通用问答、文件问答与工具调用,平台统一入口。", 12)])
edit(S[23], "AutoShape 13", [
("02. 知识库构建:多模态沉淀", 15), (DIV, 11),
("模型:Qwen2.5-VL + BGE-M3 + Milvus", 12),
("场景:文档解析、图表提取,非结构化数据向量化入库。", 12)])
edit(S[23], "AutoShape 15", [
("03. 知识库问答:精准溯源", 15), (DIV, 11),
("模型:DeepSeek V3.1 + BGE-M3 + Milvus", 12),
("场景:RAG 精准问答,提供原文引用与溯源。", 12)])
edit(S[23], "AutoShape 17", [
("04. AI 文档分类:知识治理", 15), (DIV, 11),
("模型:Qwen3-30B-A3B + BGE-M3", 12),
("场景:自动识别主题、分类归档,解决海量文档管理难题。", 12)])
edit(S[23], "AutoShape 19", [
("05. 智能实验设计:研发提效", 15), (DIV, 11),
("模型:通用大模型 + Qwen2.5-1.5B(LoRA 配方模型)", 12),
("场景:分析文献与实验数据,生成配方初步方案。", 12)])
edit(S[23], "AutoShape 21", [
("06. 配方模型训练:垂直深耕", 15), (DIV, 11),
("模型:Qwen2.5-1.5B 基座 + BGE-M3 预处理", 12),
("场景:学习“性能-配方”映射,建立专属垂直模型。", 12)])
# ============ 结构:重排 + 删除(S4=idx3 与 S25=idx24 删除) ============
# 目标顺序(原始 0-based 索引):
# 开场: 0,1 | PART1: 2,4,5 | PART2: 6,7,8,9,10,11,12,17,18,19,20,21
# PART3: 13,14,15,16 | PART4: 22,23,25
order = [0, 1, 2, 4, 5,
6, 7, 8, 9, 10, 11, 12, 17, 18, 19, 20, 21,
13, 14, 15, 16,
22, 23, 25]
sldIdLst = prs.slides._sldIdLst
ids = list(sldIdLst)
dropped_rids = [ids[i].get(qn('r:id')) for i in range(len(ids)) if i not in order]
for e in ids:
sldIdLst.remove(e)
for i in order:
sldIdLst.append(ids[i])
# 彻底移除被删幻灯片的部件(否则孤立 part 残留,PowerPoint 可能弹"修复")
for rid in dropped_rids:
if rid in prs.part.rels:
prs.part.drop_rel(rid)
prs.save(DST)
print(f"OK -> {DST}{len(order)}")

150
scripts/probe_clawbot.py Normal file
View File

@ -0,0 +1,150 @@
"""一次性探测:微信 ClawBot 灰度是否覆盖某个微信号。
只做两件事(不碰 zcbot 主体不落库):
1. GET get_bot_qrcode 拿二维码 -> qr.png 并自动打开
2. 轮询 get_qrcode_status 等扫码确认 -> 报告 status
判读:
- 接口连不通 / 200 -> 本机到 ilinkai 网络不通,换网或在有网机器跑
- 出码成功手机扫得动确认 -> 该微信号在灰度内,ClawBot 可用
- 出码成功扫了报"不支持" -> 版本不够或未灰度到该号
ASCII-only 输出(Windows GBK 控制台)
"""
from __future__ import annotations
import base64
import os
import random
import sys
import time
import webbrowser
import httpx
BASE = "https://ilinkai.weixin.qq.com"
QR_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "clawbot_qr.png")
def _uin_header() -> str:
# X-WECHAT-UIN: base64(String(randomUint32()))
return base64.b64encode(str(random.randint(0, 2**32 - 1)).encode()).decode()
def _headers() -> dict:
return {
"Content-Type": "application/json",
"AuthorizationType": "ilink_bot_token",
"X-WECHAT-UIN": _uin_header(),
}
def _save_qr(img_content: str, qrcode_id: str) -> bool:
"""实测:qrcode_img_content 是微信深链(https://liteapp.weixin.qq.com/q/...),
需把该 URL **编码成二维码** 让微信扫,而非当图片下载
兜底:若哪天返回的是真图片字节(data-uri / base64 PNG)则直接存
"""
try:
if not img_content:
print(f"[hint] no img content; encode this id manually: {qrcode_id}")
return False
# 情况 A:真图片字节
if img_content.startswith("data:image"):
data = base64.b64decode(img_content.split(",", 1)[1])
with open(QR_PATH, "wb") as f:
f.write(data)
print(f"[ok] QR (image) saved -> {QR_PATH}")
return True
# 情况 B(实测):深链 / 任意字符串 -> 自己渲染成二维码
import segno
print(f"[info] encoding deep-link into QR: {img_content}")
segno.make(img_content, error="m").save(QR_PATH, scale=8, border=3)
print(f"[ok] QR (rendered from deep-link) saved -> {QR_PATH}")
return True
except Exception as e:
print(f"[warn] could not build QR: {type(e).__name__}: {e}")
print(f"[hint] deep-link to scan manually: {img_content}")
return False
def main() -> int:
print("[step1] GET get_bot_qrcode ...")
try:
with httpx.Client(timeout=20) as c:
r = c.get(
f"{BASE}/ilink/bot/get_bot_qrcode",
params={"bot_type": "3"},
headers=_headers(),
)
except Exception as e:
print(f"[FAIL] network error to {BASE}: {type(e).__name__}: {e}")
print("[judge] host cannot reach ilinkai.weixin.qq.com -> try another network.")
return 2
print(f"[http] status={r.status_code}")
body_preview = r.text[:600]
print(f"[body] {body_preview}")
if r.status_code != 200:
print("[judge] non-200 from get_bot_qrcode -> endpoint/params may be wrong or blocked.")
return 3
try:
data = r.json()
except Exception:
print("[FAIL] response not JSON; see body above.")
return 3
qrcode_id = data.get("qrcode") or data.get("qrcode_id") or ""
img = data.get("qrcode_img_content") or data.get("qrcode_img") or ""
if not qrcode_id:
print("[FAIL] no 'qrcode' field in response; field names differ -> inspect body above.")
return 3
if _save_qr(img, qrcode_id):
try:
webbrowser.open("file://" + QR_PATH.replace("\\", "/"))
except Exception:
pass
print("[action] QR opened. Scan it with your phone WeChat NOW.")
else:
print("[action] QR image unavailable; cannot open. See hint above.")
poll_secs = int(sys.argv[1]) if len(sys.argv) > 1 else 100
print(f"[step2] polling get_qrcode_status (up to ~{poll_secs}s; Ctrl-C to stop)...")
deadline = time.time() + poll_secs
last = ""
with httpx.Client(timeout=40) as c:
while time.time() < deadline:
try:
r = c.get(
f"{BASE}/ilink/bot/get_qrcode_status",
params={"qrcode": qrcode_id},
headers=_headers(),
)
st = ""
try:
st = (r.json() or {}).get("status", "")
except Exception:
st = f"(non-json http {r.status_code})"
if st != last:
print(f"[poll] status={st!r}")
last = st
if st == "confirmed":
j = r.json()
tok = j.get("bot_token", "")
base_url = j.get("baseurl") or j.get("base_url") or ""
masked = (tok[:6] + "..." + tok[-4:]) if len(tok) > 12 else "(short)"
print("[SUCCESS] scan confirmed -> this WeChat account IS in the ClawBot rollout.")
print(f"[SUCCESS] bot_token={masked} baseurl={base_url}")
print("[note] token masked on purpose; it is a per-user credential.")
return 0
except Exception as e:
print(f"[poll] error: {type(e).__name__}: {e}")
time.sleep(2)
print("[timeout] no confirmation within window. Either not scanned in time, or")
print("[timeout] your WeChat lacks the ClawBot entry (version <8.0.70 or not gray-rolled).")
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,181 @@
"""探测二:微信 ClawBot 的【对话】与【主动推送】能力(命门验证)。
流程(都在一次运行里,不落库):
1. 扫码绑定拿 bot_token(同探测一)
2. getupdates 长轮询,等你给微信 ClawBot联系人发一条消息
3. 收到后,依次测三种发送,逐一报 ret:
A. context_token 回复 -> 被动回复是否通
B. 25s ,同一个context_token 再发 -> 开口一次后能否延迟主动推
C. context_token 置空再发 -> 冷推( token)是否被拒
判读:
A = 双向对话成立
B = 用户开口一次后可后续推送(简报可走"先开口、后定时推"的弱化版)
C = 可冷推(几乎不可能,但要验)
B/C 都不通 = ClawBot 纯被动回复,定时主动推送这条路不成立
ASCII-only 输出bot_token 不打印
"""
from __future__ import annotations
import base64
import os
import random
import sys
import time
import httpx
import segno
BASE = "https://ilinkai.weixin.qq.com"
QR_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "clawbot_qr.png")
CHANNEL_VER = "1.0.2"
def _uin() -> str:
return base64.b64encode(str(random.randint(0, 2**32 - 1)).encode()).decode()
def _headers(token: str | None = None) -> dict:
h = {
"Content-Type": "application/json",
"AuthorizationType": "ilink_bot_token",
"X-WECHAT-UIN": _uin(),
}
if token:
h["Authorization"] = f"Bearer {token}"
return h
def bind() -> tuple[str, str] | None:
print("[bind] GET get_bot_qrcode ...")
with httpx.Client(timeout=20) as c:
r = c.get(f"{BASE}/ilink/bot/get_bot_qrcode",
params={"bot_type": "3"}, headers=_headers())
if r.status_code != 200:
print(f"[FAIL] get_bot_qrcode http {r.status_code}: {r.text[:300]}")
return None
d = r.json()
qid = d.get("qrcode", "")
link = d.get("qrcode_img_content", "")
segno.make(link, error="m").save(QR_PATH, scale=8, border=3)
try:
import webbrowser
webbrowser.open("file://" + QR_PATH.replace("\\", "/"))
except Exception:
pass
print(f"[bind] QR opened -> {QR_PATH} SCAN IT NOW with phone WeChat.")
deadline = time.time() + 180
with httpx.Client(timeout=40) as c:
last = ""
while time.time() < deadline:
try:
r = c.get(f"{BASE}/ilink/bot/get_qrcode_status",
params={"qrcode": qid}, headers=_headers())
j = r.json()
st = j.get("status", "")
if st != last:
print(f"[bind] status={st!r}")
last = st
if st == "confirmed":
print("[bind] confirmed.")
return j.get("bot_token", ""), (j.get("baseurl") or BASE)
if st == "expired":
print("[bind] QR expired before scan.")
return None
except Exception as e:
print(f"[bind] poll err: {type(e).__name__}: {e}")
time.sleep(2)
print("[bind] timeout waiting for scan.")
return None
def _send(client: httpx.Client, token: str, to_user: str, text: str,
context_token: str) -> dict:
body = {
"msg": {
"to_user_id": to_user,
"message_type": 2,
"message_state": 2,
"context_token": context_token,
"item_list": [{"type": 1, "text_item": {"text": text}}],
}
}
r = client.post(f"{BASE}/ilink/bot/sendmessage",
json=body, headers=_headers(token))
try:
return {"http": r.status_code, "json": r.json()}
except Exception:
return {"http": r.status_code, "text": r.text[:300]}
def main() -> int:
b = bind()
if not b:
return 2
token, base_url = b
global BASE
BASE = base_url or BASE
print("[chat] now SEND a message (e.g. 'hi') to the WeChat ClawBot contact on your phone.")
print("[chat] waiting via getupdates (up to ~150s)...")
buf = ""
deadline = time.time() + 150
got = None
with httpx.Client(timeout=40) as c:
while time.time() < deadline and got is None:
try:
r = c.post(f"{BASE}/ilink/bot/getupdates",
json={"get_updates_buf": buf,
"base_info": {"channel_version": CHANNEL_VER}},
headers=_headers(token))
j = r.json()
buf = j.get("get_updates_buf", buf)
for m in j.get("msgs", []) or []:
txt = ""
for it in m.get("item_list", []) or []:
txt += (it.get("text_item", {}) or {}).get("text", "")
print(f"[chat] <- from={m.get('from_user_id')} text={txt!r}")
got = m
break
except Exception as e:
print(f"[chat] getupdates err: {type(e).__name__}: {e}")
time.sleep(2)
if got is None:
print("[chat] no message received in window. Re-run and send promptly after scan.")
return 1
to_user = got.get("from_user_id", "")
ctx = got.get("context_token", "")
print(f"[chat] captured to_user={to_user} context_token_len={len(ctx)}")
with httpx.Client(timeout=30) as c:
print("\n[testA] reply WITH context_token ...")
ra = _send(c, token, to_user, "[zcbot 测试A] 收到你的消息,这是带 token 的回复。", ctx)
print(f"[testA] result={ra}")
print("\n[testB] wait 25s, then push again with the SAME context_token (delayed proactive)...")
time.sleep(25)
rb = _send(c, token, to_user, "[zcbot 测试B] 这是25秒后用同一token的延迟主动推送。", ctx)
print(f"[testB] result={rb}")
print("\n[testC] push with EMPTY context_token (cold push) ...")
rc = _send(c, token, to_user, "[zcbot 测试C] 这是空token的冷推送。", "")
print(f"[testC] result={rc}")
def ok(r):
j = r.get("json") or {}
return r.get("http") == 200 and j.get("ret", -1) == 0
print("\n========== VERDICT ==========")
print(f"A reply(with token) : {'OK' if ok(ra) else 'FAIL'}")
print(f"B delayed push(same token) : {'OK' if ok(rb) else 'FAIL'}")
print(f"C cold push(empty token) : {'OK' if ok(rc) else 'FAIL'}")
print("Interpretation:")
print(" - A only -> reply-only; scheduled PROACTIVE push NOT possible.")
print(" - A+B -> after user opens chat once, delayed push works (weak push OK).")
print(" - C -> true cold push works (unlikely).")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,158 @@
"""探测五(决定性):补上 client_id(每条唯一)+ base_info,重验两件事。
A. 流式多条:同一 context_token 连发 3 (client_id 各异,state 1/1/2,间隔300ms)
-> 三块都到 = 多条/长简报可行
B. finish 后复用:发完 FINISH,等30s,同一 context_token+ client_id 再发一条(state=2)
-> = context_token 24h 内可复用 -> "用户开口一次后可主动推" 成立(简报推送复活)
之前失败的最大嫌疑: client_id(后续块无法路由被丢)需要你发一条消息触发
ASCII-only,bot_token 不打印
"""
from __future__ import annotations
import base64
import os
import random
import sys
import time
import uuid
import httpx
import segno
BASE = "https://ilinkai.weixin.qq.com"
QR_DIR = os.path.dirname(os.path.abspath(__file__))
CHANNEL_VER = "1.0.2"
def _uin() -> str:
return base64.b64encode(str(random.randint(0, 2**32 - 1)).encode()).decode()
def _headers(token=None) -> dict:
h = {"Content-Type": "application/json",
"AuthorizationType": "ilink_bot_token", "X-WECHAT-UIN": _uin()}
if token:
h["Authorization"] = f"Bearer {token}"
return h
def _new_qr():
with httpx.Client(timeout=20) as c:
r = c.get(f"{BASE}/ilink/bot/get_bot_qrcode",
params={"bot_type": "3"}, headers=_headers())
if r.status_code != 200:
print(f"[FAIL] http {r.status_code}"); return None
d = r.json()
uniq = os.path.join(QR_DIR, f"clawbot_qr_{int(time.time())}.png")
segno.make(d.get("qrcode_img_content", ""), error="m").save(uniq, scale=8, border=3)
try:
os.startfile(uniq)
except Exception:
pass
print(f"[bind] FRESH QR -> {uniq}")
return d.get("qrcode", "")
def bind():
print("[bind] auto-refresh on expiry; scan whenever ready.")
qid = _new_qr()
if not qid:
return None
deadline = time.time() + 300
with httpx.Client(timeout=40) as c:
last = ""
while time.time() < deadline:
try:
j = c.get(f"{BASE}/ilink/bot/get_qrcode_status",
params={"qrcode": qid}, headers=_headers()).json()
st = j.get("status", "")
if st != last:
print(f"[bind] status={st!r}"); last = st
if st == "confirmed":
return j.get("bot_token", ""), (j.get("baseurl") or BASE)
if st == "expired":
nq = _new_qr()
if not nq:
return None
qid, last = nq, ""
except Exception as e:
print(f"[bind] err {e}")
time.sleep(2)
return None
def send(c, token, to_user, text, ctx, state, tag):
cid = uuid.uuid4().hex
body = {
"msg": {
"to_user_id": to_user,
"client_id": cid,
"message_type": 2,
"message_state": state,
"context_token": ctx,
"item_list": [{"type": 1, "text_item": {"text": text}}],
},
"base_info": {"channel_version": CHANNEL_VER},
}
r = c.post(f"{BASE}/ilink/bot/sendmessage", json=body, headers=_headers(token))
try:
j = r.json()
except Exception:
j = r.text[:160]
print(f"[send {tag}] state={state} client_id={cid[:8]} -> http={r.status_code} body={j}")
def wait_msg(c, token):
deadline = time.time() + 150
buf = ""
while time.time() < deadline:
try:
j = c.post(f"{BASE}/ilink/bot/getupdates",
json={"get_updates_buf": buf,
"base_info": {"channel_version": CHANNEL_VER}},
headers=_headers(token)).json()
buf = j.get("get_updates_buf", buf)
for m in j.get("msgs", []) or []:
txt = "".join((it.get("text_item", {}) or {}).get("text", "")
for it in m.get("item_list", []) or [])
print(f"[recv] <- {txt!r}")
return m
except Exception as e:
print(f"[recv] err {e}"); time.sleep(2)
return None
def main() -> int:
b = bind()
if not b:
return 2
token, base_url = b
global BASE
BASE = base_url or BASE
print("[bind] confirmed.\n[A] SEND one message now (e.g. 'go') ...")
with httpx.Client(timeout=30) as c:
m = wait_msg(c, token)
if not m:
print("no msg; abort."); return 1
to_user, ctx = m.get("from_user_id", ""), m.get("context_token", "")
print("[A] streaming 3 chunks WITH client_id (state 1,1,2, 300ms apart)...")
send(c, token, to_user, "[A1] client_id+流式第一段(state=1)", ctx, 1, "A1")
time.sleep(0.3)
send(c, token, to_user, "[A2] client_id+流式第二段(state=1)", ctx, 1, "A2")
time.sleep(0.3)
send(c, token, to_user, "[A3] client_id+末段(state=2 FINISH)", ctx, 2, "A3")
print("\n[B] wait 30s, then reuse SAME context_token + new client_id (state=2)...")
time.sleep(30)
send(c, token, to_user, "[B] finish后30秒,复用同token主动推(若到=24h可复用)", ctx, 2, "B")
print("\n========== CHECK YOUR PHONE ==========")
print("Report which arrived:")
print(" [A1]/[A2]/[A3] -> all three = multi-message/streaming OK (need client_id)")
print(" [B] -> arrived = token reusable after finish => PROACTIVE PUSH revives")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,223 @@
"""探测六:验证 ClawBot 能否发【文件附件】(照官方 @tencent-weixin/openclaw-weixin 协议复刻)。
流程(全诊断,每步打印):
绑定 -> 等你发一条消息( to_user + context_token) -> 造个小 txt ->
md5/随机aeskey(16B)/随机filekey(16B hex) -> AES-128-ECB+PKCS7 加密 ->
POST /ilink/bot/getuploadurl(打印完整返回,字段名不对可据此改) ->
POST 密文到 CDN header x-encrypted-param ->
sendmessage file_item(type=4) 引用 -> 看手机是否收到文件
字段依据(源码):MessageItemType.FILE=4 / UploadMediaType.FILE=3 / MessageState.FINISH=2,
aes_key = base64(aeskey.hex() ascii 字节)ASCII-only,bot_token 不打印
"""
from __future__ import annotations
import base64
import hashlib
import os
import random
import sys
import time
import uuid
from urllib.parse import quote
import httpx
import segno
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
BASE = "https://ilinkai.weixin.qq.com"
CDN_BASE_DEFAULT = "https://novac2c.cdn.weixin.qq.com/c2c"
QR_DIR = os.path.dirname(os.path.abspath(__file__))
CHANNEL_VER = "1.0.2"
def _uin() -> str:
return base64.b64encode(str(random.randint(0, 2**32 - 1)).encode()).decode()
def _headers(token=None) -> dict:
h = {"Content-Type": "application/json",
"AuthorizationType": "ilink_bot_token", "X-WECHAT-UIN": _uin()}
if token:
h["Authorization"] = f"Bearer {token}"
return h
def _new_qr():
with httpx.Client(timeout=20) as c:
r = c.get(f"{BASE}/ilink/bot/get_bot_qrcode",
params={"bot_type": "3"}, headers=_headers())
if r.status_code != 200:
print(f"[FAIL] http {r.status_code}"); return None
d = r.json()
uniq = os.path.join(QR_DIR, f"clawbot_qr_{int(time.time())}.png")
segno.make(d.get("qrcode_img_content", ""), error="m").save(uniq, scale=8, border=3)
try:
os.startfile(uniq)
except Exception:
pass
print(f"[bind] FRESH QR -> {uniq}")
return d.get("qrcode", "")
def bind():
print("[bind] auto-refresh on expiry; scan whenever ready.")
qid = _new_qr()
if not qid:
return None
deadline = time.time() + 300
with httpx.Client(timeout=40) as c:
last = ""
while time.time() < deadline:
try:
j = c.get(f"{BASE}/ilink/bot/get_qrcode_status",
params={"qrcode": qid}, headers=_headers()).json()
st = j.get("status", "")
if st != last:
print(f"[bind] status={st!r}"); last = st
if st == "confirmed":
return j.get("bot_token", ""), (j.get("baseurl") or BASE)
if st == "expired":
nq = _new_qr()
if not nq:
return None
qid, last = nq, ""
except Exception as e:
print(f"[bind] err {e}")
time.sleep(2)
return None
def wait_msg(c, token):
deadline = time.time() + 150
buf = ""
while time.time() < deadline:
try:
j = c.post(f"{BASE}/ilink/bot/getupdates",
json={"get_updates_buf": buf,
"base_info": {"channel_version": CHANNEL_VER}},
headers=_headers(token)).json()
buf = j.get("get_updates_buf", buf)
for m in j.get("msgs", []) or []:
txt = "".join((it.get("text_item", {}) or {}).get("text", "")
for it in m.get("item_list", []) or [])
print(f"[recv] <- {txt!r}")
return m
except Exception as e:
print(f"[recv] err {e}"); time.sleep(2)
return None
def aes_ecb_pkcs7(plain: bytes, key: bytes) -> bytes:
padder = padding.PKCS7(128).padder()
padded = padder.update(plain) + padder.finalize()
enc = Cipher(algorithms.AES(key), modes.ECB()).encryptor()
return enc.update(padded) + enc.finalize()
def main() -> int:
b = bind()
if not b:
return 2
token, base_url = b
global BASE
BASE = base_url or BASE
print("[bind] confirmed.\n[file] SEND one message now (e.g. 'file') ...")
with httpx.Client(timeout=30) as c:
m = wait_msg(c, token)
if not m:
print("no msg; abort."); return 1
to_user, ctx = m.get("from_user_id", ""), m.get("context_token", "")
# 1) 造测试文件
fpath = os.path.join(QR_DIR, "zcbot_filetest.txt")
with open(fpath, "w", encoding="utf-8") as f:
f.write("zcbot 文件发送测试\nClawBot file attachment probe\n" + "x" * 200)
data = open(fpath, "rb").read()
fname = "zcbot_filetest.txt"
rawsize = len(data)
rawmd5 = hashlib.md5(data).hexdigest()
aeskey = random.randbytes(16)
filekey = random.randbytes(16).hex()
cipher = aes_ecb_pkcs7(data, aeskey)
filesize = len(cipher)
print(f"[file] {fname} rawsize={rawsize} md5={rawmd5} filesize(enc)={filesize}")
# 2) getuploadurl
up_body = {
"filekey": filekey, "media_type": 3, "to_user_id": to_user,
"rawsize": rawsize, "rawfilemd5": rawmd5, "filesize": filesize,
"no_need_thumb": True, "aeskey": aeskey.hex(),
"base_info": {"channel_version": CHANNEL_VER},
}
ru = c.post(f"{BASE}/ilink/bot/getuploadurl", json=up_body, headers=_headers(token))
print(f"[getuploadurl] http={ru.status_code}")
try:
uj = ru.json()
except Exception:
print(f"[getuploadurl] non-json: {ru.text[:300]}"); return 3
print(f"[getuploadurl] resp={uj}")
# 3) 解析上传 URL(字段名不确定,多名兜底)
full = (uj.get("upload_full_url") or uj.get("uploadFullUrl")
or uj.get("full_url") or uj.get("url"))
param = (uj.get("upload_param") or uj.get("uploadParam") or uj.get("param"))
cdn_base = uj.get("cdn_base_url") or uj.get("cdnBaseUrl") or CDN_BASE_DEFAULT
if full:
cdn_url = full
elif param:
# 源码模板:?encrypted_query_param=<urlencode(uploadParam)>&filekey=<urlencode(filekey)>
cdn_url = (f"{cdn_base}/upload?encrypted_query_param={quote(param)}"
f"&filekey={quote(filekey)}")
else:
print("[FAIL] no upload url/param in resp; inspect resp above to fix field names.")
return 4
print(f"[upload] POST ciphertext -> {cdn_url[:120]}...")
# 4) 上传密文到 CDN
rc = c.post(cdn_url, content=cipher,
headers={"Content-Type": "application/octet-stream"})
download_param = rc.headers.get("x-encrypted-param")
print(f"[upload] http={rc.status_code} x-encrypted-param={download_param!r}")
if not download_param:
print(f"[upload] resp headers={dict(rc.headers)} body={rc.text[:200]}")
print("[FAIL] no x-encrypted-param returned; upload likely rejected.")
return 5
# 5) sendmessage 带 file_item
msg_body = {
"msg": {
"from_user_id": "", "to_user_id": to_user,
"client_id": f"openclaw-weixin-{uuid.uuid4().hex}",
"message_type": 2, "message_state": 2, "context_token": ctx,
"item_list": [{
"type": 4,
"file_item": {
"media": {
"encrypt_query_param": download_param,
"aes_key": base64.b64encode(aeskey.hex().encode()).decode(),
"encrypt_type": 1,
},
"file_name": fname,
"len": str(rawsize),
},
}],
},
"base_info": {"channel_version": CHANNEL_VER},
}
rs = c.post(f"{BASE}/ilink/bot/sendmessage", json=msg_body, headers=_headers(token))
try:
sj = rs.json()
except Exception:
sj = rs.text[:200]
print(f"[sendmessage file] http={rs.status_code} body={sj}")
print("\n========== CHECK YOUR PHONE ==========")
print(f"Did a file '{fname}' arrive in the WeChat ClawBot chat (openable)?")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,147 @@
"""探测四:验证 ClawBot 流式/多条回复(message_state 非 FINISH 是关键)。
上轮发现:message_state=2 = FINISH,"封口"本轮,故第二条被丢
本轮:同一 context_token 连发三段前两段 state=1(未结束),末段 state=2(FINISH),
看手机收到的形态:
- 三条独立气泡 AAA / BBB / CCC -> 支持多条独立消息
- 一条气泡里 AAABBBCCC(增长) -> 流式增量(delta),拼成一条
- 只剩 CCC -> 流式覆盖(cumulative,末值胜)
据此定长简报的发法需要你发一条消息触发bot_token 不打印ASCII-only
"""
from __future__ import annotations
import base64
import os
import random
import sys
import time
import httpx
import segno
BASE = "https://ilinkai.weixin.qq.com"
QR_DIR = os.path.dirname(os.path.abspath(__file__))
CHANNEL_VER = "1.0.2"
def _uin() -> str:
return base64.b64encode(str(random.randint(0, 2**32 - 1)).encode()).decode()
def _headers(token: str | None = None) -> dict:
h = {"Content-Type": "application/json",
"AuthorizationType": "ilink_bot_token", "X-WECHAT-UIN": _uin()}
if token:
h["Authorization"] = f"Bearer {token}"
return h
def _new_qr() -> str | None:
with httpx.Client(timeout=20) as c:
r = c.get(f"{BASE}/ilink/bot/get_bot_qrcode",
params={"bot_type": "3"}, headers=_headers())
if r.status_code != 200:
print(f"[FAIL] http {r.status_code}: {r.text[:200]}"); return None
d = r.json()
uniq = os.path.join(QR_DIR, f"clawbot_qr_{int(time.time())}.png")
segno.make(d.get("qrcode_img_content", ""), error="m").save(uniq, scale=8, border=3)
try:
os.startfile(uniq)
except Exception:
pass
print(f"[bind] FRESH QR -> {uniq}")
return d.get("qrcode", "")
def bind() -> tuple[str, str] | None:
print("[bind] auto-refresh on expiry; scan whenever ready.")
qid = _new_qr()
if not qid:
return None
deadline = time.time() + 300
with httpx.Client(timeout=40) as c:
last = ""
while time.time() < deadline:
try:
j = c.get(f"{BASE}/ilink/bot/get_qrcode_status",
params={"qrcode": qid}, headers=_headers()).json()
st = j.get("status", "")
if st != last:
print(f"[bind] status={st!r}"); last = st
if st == "confirmed":
return j.get("bot_token", ""), (j.get("baseurl") or BASE)
if st == "expired":
print("[bind] expired -> new QR");
nq = _new_qr()
if not nq:
return None
qid, last = nq, ""
continue
except Exception as e:
print(f"[bind] err {type(e).__name__}: {e}")
time.sleep(2)
return None
def send(c, token, to_user, text, ctx, state):
body = {"msg": {"to_user_id": to_user, "message_type": 2, "message_state": state,
"context_token": ctx,
"item_list": [{"type": 1, "text_item": {"text": text}}]}}
r = c.post(f"{BASE}/ilink/bot/sendmessage", json=body, headers=_headers(token))
try:
j = r.json()
except Exception:
j = r.text[:200]
print(f"[send] state={state} text={text!r} -> http={r.status_code} body={j}")
def wait_msg(c, token):
deadline = time.time() + 150
buf = ""
while time.time() < deadline:
try:
j = c.post(f"{BASE}/ilink/bot/getupdates",
json={"get_updates_buf": buf,
"base_info": {"channel_version": CHANNEL_VER}},
headers=_headers(token)).json()
buf = j.get("get_updates_buf", buf)
for m in j.get("msgs", []) or []:
txt = "".join((it.get("text_item", {}) or {}).get("text", "")
for it in m.get("item_list", []) or [])
print(f"[recv] <- {txt!r}")
return m
except Exception as e:
print(f"[recv] err {type(e).__name__}: {e}"); time.sleep(2)
return None
def main() -> int:
b = bind()
if not b:
return 2
token, base_url = b
global BASE
BASE = base_url or BASE
print("[bind] confirmed.\n[stream] SEND one message now (e.g. 'go') ...")
with httpx.Client(timeout=30) as c:
m = wait_msg(c, token)
if not m:
print("[stream] no msg; abort."); return 1
to_user, ctx = m.get("from_user_id", ""), m.get("context_token", "")
print("[stream] sending 3 parts with same token (state 1,1,2)...")
send(c, token, to_user, "AAA-第一段(state=1)", ctx, 1)
time.sleep(1)
send(c, token, to_user, "BBB-第二段(state=1)", ctx, 1)
time.sleep(1)
send(c, token, to_user, "CCC-第三段(state=2,FINISH)", ctx, 2)
print("\n========== CHECK YOUR PHONE ==========")
print("Which form did you get?")
print(" (a) three separate bubbles: AAA / BBB / CCC -> multi-message OK")
print(" (b) one bubble growing: AAABBBCCC -> streaming delta-append")
print(" (c) one bubble only: CCC -> streaming cumulative(last wins)")
print(" (d) only AAA / nothing else -> still single")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,168 @@
"""探测三:钉死 ClawBot 的 context_token 语义(决定拉取式简报 + 长回复可行性)。
要回答两个问题:
T1 多发:一条用户消息收到后,同一个新鲜 token连发两条回复
-> 第二条到不到 = 能否分段/多条回复(长简报关键)
T2 延迟:第二条用户消息收到后,先不回, 25s,再用那条没用过的token 回一次
-> 到不到 = token 是否限时(能否把回复推迟一会儿)
需要你先后发两条消息微信 ClawBot(比如先发 1,再发 2)
结果以手机实收为准(接口返空 body 不可信)bot_token 不打印ASCII-only
"""
from __future__ import annotations
import base64
import os
import random
import sys
import time
import httpx
import segno
BASE = "https://ilinkai.weixin.qq.com"
QR_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "clawbot_qr.png")
CHANNEL_VER = "1.0.2"
def _uin() -> str:
return base64.b64encode(str(random.randint(0, 2**32 - 1)).encode()).decode()
def _headers(token: str | None = None) -> dict:
h = {"Content-Type": "application/json",
"AuthorizationType": "ilink_bot_token",
"X-WECHAT-UIN": _uin()}
if token:
h["Authorization"] = f"Bearer {token}"
return h
def _new_qr() -> str | None:
"""拉一张新二维码、弹窗,返回 qrcode id;失败返回 None。"""
with httpx.Client(timeout=20) as c:
r = c.get(f"{BASE}/ilink/bot/get_bot_qrcode",
params={"bot_type": "3"}, headers=_headers())
if r.status_code != 200:
print(f"[FAIL] get_bot_qrcode http {r.status_code}: {r.text[:200]}")
return None
d = r.json()
qid = d.get("qrcode", "")
uniq = os.path.join(os.path.dirname(QR_PATH), f"clawbot_qr_{int(time.time())}.png")
segno.make(d.get("qrcode_img_content", ""), error="m").save(uniq, scale=8, border=3)
try:
os.startfile(uniq)
except Exception:
try:
import webbrowser
webbrowser.open("file://" + uniq.replace("\\", "/"))
except Exception:
pass
print(f"[bind] FRESH QR -> {uniq} (older windows are stale, ignore them)")
return qid
def bind() -> tuple[str, str] | None:
"""过期自动换新码,直到扫成功或总超时(5min)。消除扫码时间竞争。"""
print("[bind] GET get_bot_qrcode ... (auto-refresh on expiry; scan whenever ready)")
qid = _new_qr()
if not qid:
return None
deadline = time.time() + 300
with httpx.Client(timeout=40) as c:
last = ""
while time.time() < deadline:
try:
j = c.get(f"{BASE}/ilink/bot/get_qrcode_status",
params={"qrcode": qid}, headers=_headers()).json()
st = j.get("status", "")
if st != last:
print(f"[bind] status={st!r}"); last = st
if st == "confirmed":
return j.get("bot_token", ""), (j.get("baseurl") or BASE)
if st == "expired":
print("[bind] QR expired -> generating a new one ...")
nq = _new_qr()
if not nq:
return None
qid, last = nq, ""
continue
except Exception as e:
print(f"[bind] err {type(e).__name__}: {e}")
time.sleep(2)
print("[bind] overall timeout (5min)."); return None
def send(c, token, to_user, text, ctx):
body = {"msg": {"to_user_id": to_user, "message_type": 2, "message_state": 2,
"context_token": ctx,
"item_list": [{"type": 1, "text_item": {"text": text}}]}}
r = c.post(f"{BASE}/ilink/bot/sendmessage", json=body, headers=_headers(token))
try:
return {"http": r.status_code, "json": r.json()}
except Exception:
return {"http": r.status_code, "text": r.text[:200]}
def wait_msg(c, token, buf):
"""阻塞等下一条用户消息,返回 (msg, new_buf)。"""
deadline = time.time() + 150
while time.time() < deadline:
try:
j = c.post(f"{BASE}/ilink/bot/getupdates",
json={"get_updates_buf": buf,
"base_info": {"channel_version": CHANNEL_VER}},
headers=_headers(token)).json()
buf = j.get("get_updates_buf", buf)
for m in j.get("msgs", []) or []:
txt = "".join((it.get("text_item", {}) or {}).get("text", "")
for it in m.get("item_list", []) or [])
print(f"[recv] <- {txt!r}")
return m, buf
except Exception as e:
print(f"[recv] err {type(e).__name__}: {e}"); time.sleep(2)
return None, buf
def main() -> int:
b = bind()
if not b:
return 2
token, base_url = b
global BASE
BASE = base_url or BASE
print("[bind] confirmed.\n")
with httpx.Client(timeout=40) as c:
# ---- T1: 同一 token 连发两条 ----
print("[T1] SEND your 1st message now (e.g. '1') ...")
m, buf = wait_msg(c, token, "")
if not m:
print("[T1] no msg; abort."); return 1
to_user, ctx = m.get("from_user_id", ""), m.get("context_token", "")
r1a = send(c, token, to_user, "[T1-a] 同token第一条(立即)", ctx)
r1b = send(c, token, to_user, "[T1-b] 同token第二条(紧接)", ctx)
print(f"[T1] sent two with same token. http: a={r1a.get('http')} b={r1b.get('http')}")
# ---- T2: 收到后不回,延迟 25s 再用未用过的 token 回一次 ----
print("\n[T2] SEND your 2nd message now (e.g. '2') ...")
m2, buf = wait_msg(c, token, buf)
if not m2:
print("[T2] no msg; skip.");
else:
to_user2, ctx2 = m2.get("from_user_id", ""), m2.get("context_token", "")
print("[T2] received; NOT replying; waiting 25s...")
time.sleep(25)
r2 = send(c, token, to_user2, "[T2] 延迟25秒,未用过的token回复", ctx2)
print(f"[T2] sent after delay. http={r2.get('http')}")
print("\n========== CHECK YOUR PHONE ==========")
print("Report which of these arrived in the WeChat ClawBot chat:")
print(" [T1-a] 同token第一条(立即)")
print(" [T1-b] 同token第二条(紧接) <- if arrives: multi-message per turn OK")
print(" [T2] 延迟25秒,未用过的token回复 <- if arrives: token is time-windowed, deferred reply OK")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,130 @@
"""Smoke: look_at_image(豆包 Seed 2.0 Lite 视觉)端到端走通 + OCR 验证。
跑法: .venv/Scripts/python.exe scripts/smoke_look_at_image.py
依赖 .env ARK_API_KEY / ZCBOT_DB_URL**会真的调豆包 vision API,产生 < ¥0.01 费用**
校验:
1. ArkConfig.load() + yaml vision 段存在
2. 合成一张含已知文字 "ZCBOT-VISION-8848" PNG LookAtImageTool.execute OCR 出该串
3. 返回串首行 banner model/tokens/cost
4. usage_events 多出一行 kind="vision",units tokens_in/out + 单价 snapshot
"""
from __future__ import annotations
import os
import sys
import uuid
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
# Windows 控制台默认 GBK,打印 ¥ / 中文结果会崩 → 强制 stdout UTF-8(只影响本脚本打印)
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
except Exception:
pass
# 读 .env
env_file = ROOT / ".env"
if env_file.exists():
for line in env_file.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, _, v = line.partition("=")
os.environ.setdefault(k.strip(), v.strip())
from PIL import Image, ImageDraw
from sqlalchemy import text
from core.ark_client import ArkConfig
from core.storage import session_scope
from core.storage.models import Task, User
from tools.look_at_image import LookAtImageTool
MAGIC = "ZCBOT-VISION-8848"
def make_text_png(dest: Path) -> None:
"""白底大号黑字 PNG(放大 4x 让默认位图字体也清晰可 OCR)。"""
small = Image.new("RGB", (260, 60), (255, 255, 255))
d = ImageDraw.Draw(small)
d.text((10, 22), MAGIC, fill=(0, 0, 0))
big = small.resize((260 * 4, 60 * 4), Image.NEAREST)
dest.parent.mkdir(parents=True, exist_ok=True)
big.save(dest)
def main() -> int:
cfg = ArkConfig.load()
if cfg is None:
print("[SKIP] ARK_API_KEY 未设(或 doubao.yaml 缺失),无法测真接口")
return 0
vision_cfg = (cfg.raw.get("vision") or {})
if not vision_cfg:
print("[SKIP] doubao.yaml 无 vision 段")
return 0
variant_key, variant_cfg = next(iter(vision_cfg.items()))
print(f"[setup] variant={variant_key} model={variant_cfg.get('model_id')} "
f"price_in={variant_cfg.get('price_cny_per_mtoken_input')} "
f"price_out={variant_cfg.get('price_cny_per_mtoken_output')}")
uid = uuid.uuid4()
tid = uuid.uuid4()
ws_user = ROOT / "workspace" / "users" / str(uid)
wd = ws_user / "smoke_vision"
img = wd / "figures" / "magic.png"
make_text_png(img)
print(f"[setup] 合成测试图 {img.name}(含文字 {MAGIC!r})")
with session_scope() as s: # User 先单独落库,再建 Task(FK 顺序保险)
s.add(User(user_id=uid))
with session_scope() as s:
s.add(Task(task_id=tid, user_id=uid, name="smoke_vision", working_dir=str(wd)))
tool = LookAtImageTool(
ark_cfg=cfg,
vision_variant_cfg=variant_cfg,
variant_key=variant_key,
working_dir=wd,
task_id=tid,
user_id=uid,
base_dir=wd,
user_root=ws_user,
)
print("[call] question='把图中的文字逐字 OCR 出来'")
result = tool.execute(image="figures/magic.png", question="把图中的文字逐字 OCR 出来。")
print(f"[tool result]\n{result}\n")
if result.startswith("[Error]"):
print("[FAIL] tool 返回错误")
return 2
# OCR 命中(容忍模型加空格/大小写差异,去掉分隔比对)
norm = result.replace(" ", "").replace("\n", "").upper()
if MAGIC.replace("-", "") in norm.replace("-", ""):
print(f"[OK] OCR 命中魔术串 {MAGIC}")
else:
print(f"[WARN] 未在结果里精确匹配 {MAGIC} —— 人工核对上面 result(模型可能换了排版)")
with session_scope() as s:
rows = s.execute(text(
"SELECT kind, model_profile, units, cost_cny FROM usage_events "
"WHERE task_id = :tid"
), {"tid": str(tid)}).all()
assert len(rows) == 1, f"usage_events 行数应 1,实际 {len(rows)}"
row = rows[0]
assert row.kind == "vision", f"kind 应 vision,实际 {row.kind}"
assert row.model_profile == f"doubao.{variant_key}", f"model_profile={row.model_profile}"
for k in ("tokens_in", "tokens_out", "input_cny_per_mtoken", "output_cny_per_mtoken"):
assert k in row.units, f"units 缺 {k}"
print(f"[OK] usage_events: kind={row.kind} model={row.model_profile} "
f"cost_cny={row.cost_cny} units={row.units}")
print("\n[PASS] smoke_look_at_image 全部通过")
return 0
if __name__ == "__main__":
sys.exit(main())

156
scripts/smoke_scheduler.py Normal file
View File

@ -0,0 +1,156 @@
"""Smoke: 定时任务守护循环端到端(DESIGN §8.5)。
跑法(**需先在另一个终端起 web 服务** `.venv/Scripts/python.exe main.py web`):
.venv/Scripts/python.exe scripts/smoke_scheduler.py [--email a@b.com]
干什么:
1. 给某用户(默认 DB 第一个 / --email 指定)插一条 isolated 定时任务,
prompt "回一句早安、不调工具",并把 next_run_at 改成现在 让守护循环下一 tick 就认领
2. 轮询 scheduled_jobs.last_status 直到翻成 ok/error/skipped(超时 180s)
3. ok 则打印它新建的 task_id + agent 实际回复片段,证明全链路(认领 task
_run_agent_bgLLM回写 run_statusrecord_result)走通
4. 收尾软删该 job(留下 task 供查看)
**会真的发起一次 LLM 调用**(一句短回复,费用可忽略)不测邮件 notify 投递另行验
"""
from __future__ import annotations
import os
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from uuid import UUID
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
# Windows 控制台默认 GBK,打印中文会乱码 → 强制 stdout UTF-8(只影响本脚本打印)
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
except Exception:
pass
# 读 .env
env_file = ROOT / ".env"
if env_file.exists():
for line in env_file.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, _, v = line.partition("=")
os.environ.setdefault(k.strip(), v.strip())
from sqlalchemy import select, update
from core import scheduler
from core.storage import session_scope
from core.storage.models import Message, ScheduledJob, Task, User
POLL_TIMEOUT = 180 # 秒
POLL_INTERVAL = 3
PROMPT = "请直接回复一句『早安,今天也加油!』。不要调用任何工具,不要创建文件,不要做其它事。"
def _pick_user(email: str | None) -> UUID | None:
with session_scope() as s:
if email:
return s.execute(select(User.user_id).where(User.email == email)).scalar_one_or_none()
return s.execute(select(User.user_id).order_by(User.created_at).limit(1)).scalar_one_or_none()
def _last_assistant_text(task_id: UUID) -> str:
"""取该 task 最后一条 assistant 文本(payload JSONB)。"""
with session_scope() as s:
rows = s.execute(
select(Message.payload).where(Message.task_id == task_id)
.order_by(Message.idx.desc()).limit(20)
).scalars().all()
for payload in rows:
if not isinstance(payload, dict):
continue
if payload.get("role") != "assistant":
continue
c = payload.get("content")
if isinstance(c, str) and c.strip():
return c.strip()
if isinstance(c, list): # 富内容块
for blk in c:
if isinstance(blk, dict) and isinstance(blk.get("text"), str) and blk["text"].strip():
return blk["text"].strip()
return "(未找到 assistant 文本)"
def main() -> int:
email = None
if "--email" in sys.argv:
i = sys.argv.index("--email")
email = sys.argv[i + 1] if i + 1 < len(sys.argv) else None
uid = _pick_user(email)
if uid is None:
print("[FAIL] DB 里没有用户(或 --email 未匹配)。先 main.py user add。")
return 1
print(f"[..] 用户 {str(uid)[:8]} prompt={PROMPT[:24]}...")
# 1) 建 job(cron 随便给个合法值,马上覆盖 next_run_at 为现在)
job = scheduler.create_job(
uid, name="[smoke] 早安测试", prompt=PROMPT, cron="*/5 * * * *", mode="isolated",
)
jid = UUID(job["job_id"])
with session_scope() as s:
s.execute(update(ScheduledJob).where(ScheduledJob.job_id == jid)
.values(next_run_at=datetime.now(timezone.utc)))
print(f"[ok] 已插入 job {job['short_id']},next_run 置为现在,等守护循环认领...")
print(" (若卡住不动 → 确认 web 服务在跑、ZCBOT_DISABLE_SCHEDULER 未设、tick 已过)")
# 2) 轮询 last_status
deadline = time.time() + POLL_TIMEOUT
status = None
last_task_id = None
last_error = None
while time.time() < deadline:
time.sleep(POLL_INTERVAL)
with session_scope() as s:
row = s.execute(
select(ScheduledJob.last_status, ScheduledJob.last_task_id,
ScheduledJob.last_error, ScheduledJob.next_run_at)
.where(ScheduledJob.job_id == jid)
).first()
if row is None:
print("[FAIL] job 不见了(被并发删?)")
return 1
status, last_task_id, last_error = row.last_status, row.last_task_id, row.last_error
waited = int(time.time() - (deadline - POLL_TIMEOUT))
print(f" [{waited:>3}s] last_status={status or '(待触发)'}")
if status in ("ok", "error", "skipped"):
break
# 3) 结果
print("-" * 50)
if status == "ok":
print(f"[PASS] 守护循环已触发并成功。task={str(last_task_id)[:8] if last_task_id else '?'}")
if last_task_id:
print(f" agent 回复: {_last_assistant_text(last_task_id)[:120]}")
rc = 0
elif status == "error":
print(f"[FAIL] 触发了但 run 报错: {last_error}")
rc = 1
elif status == "skipped":
print(f"[WARN] 被跳过(目标 task 正忙?): {last_error}")
rc = 1
else:
print(f"[FAIL] {POLL_TIMEOUT}s 内未触发(last_status 仍为空)。web 服务/调度是否在跑?")
rc = 1
# 4) 收尾:软删 job(留 task)
try:
scheduler.cancel_job(uid, str(jid))
print(f"[..] 已清理 smoke job {job['short_id']}(task 保留可查看)")
except Exception as e:
print(f"[..] 清理 job 失败(可手动删): {e}")
return rc
if __name__ == "__main__":
raise SystemExit(main())

114
skills/brief/SKILL.md Normal file
View File

@ -0,0 +1,114 @@
---
name: brief
description: 生成科研方向简报(research direction briefing / 重要文献速览)。给定一个研究方向 + 时间窗,从各大相关期刊(Elsevier 数据库优先)挑选近期重要论文,产出一份「重要论文列表 + 内容总结」的可读简报:先列清单(每篇带标题/作者/期刊/年月/DOI + 一段简介或摘要概述),再对这批论文做客观归纳。可溯源、不编造引文,**只描述不给建议**。当用户要"简报 / 方向简报 / 最新文献 / 重要论文列表 / 研究动态 / 某方向近期重要论文 / 跟踪某领域最新研究"时使用。
---
# 科研方向简报(重要文献速览)
把"某方向近期发了哪些重要论文、都在讲什么"做成一份**可读、可溯源、客观**的简报。两段式:**先一份重要期刊论文列表(各大相关期刊、Elsevier 数据库优先;每篇带一段简介/摘要概述),再对这批论文做内容总结**。
> **只描述、不给建议。** 简报呈现"发了什么、讲了什么",不给"本院应当……/可切入……/建议……"。判断留给读者。
>
> **"重要"怎么挑**:来自主流期刊(Elsevier 旗舰刊优先)、方向上居中而非边缘、有实质发现。近期论文引用尚少,故主要看**期刊层级 + 主题相关性 + 发现的分量**,不是单纯按引用数。控量靠"重要性 + 时新",不靠主观褒贬。
简报 ≠ 综述论文(paper review):综述要全面、深、给定论;简报要**快、准、客观**——520 分钟掌握一个方向近期发了哪些重要论文、各讲了什么。
## 边界(免得和别的 skill 撞)
- vs `research`/`documents`:它们**只取文献**;brief 把取回的论文**组织成可读列表 + 客观总结**。
- vs `paper`(review):paper 写**可投稿综述**(几十页、定论);brief 出**轻量速览**(几页、客观、不给判断)。
- vs `analyze`:analyze 拆**科学问题**;brief 围绕**已定方向**列近期重要论文。
- vs `proposal`:proposal 写**本子、给建议**;brief 只列论文 + 客观总结。要"对本院的建议" → 转 proposal。
## 资源(路径相对 `load_skill` 头里的 `dir=<绝对路径>`)
- `references/journals.md` —— 各建材子领域主流期刊清单(Elsevier 数据库优先)+ 精确 `publication_name` + 0 命中降级法。**阶段二必读**。
- **平台渲染层 `/sandbox/rendering/render.py`**(各 skill 通用,不再自带 render 脚本)—— `--profile brief --format docx|pdf`。docx:商务红主题 + 列表 `[n]` 锚点 + 正文 `[n]`/`[Wn]` 引文上标回链 + DOI/URL 超链 + 化学式下标白名单(CO2/C3S/Na2O...,不误伤 LC3/C595/Ca2+);pdf:沙盒自带 chromium 渲染(`md→HTML→chromium`),同套主题 + DOI/URL 超链 + 化学式下标。**渲染一律调它,禁止自己手搓 HTML / pip 装 weasyprint。**
产物默认 `.md`;要 docx/pdf 调 `render.py --profile brief`;要 deck 转 `ppt` skill。
## 阶段一:定题对齐(BLOCKING)
写一份 task 级 spec(命名见 system prompt《task 级「宪法」文件命名约定》),填下面字段,**有歧义先反问、不替用户拍板**,写完复述确认再往下:
1. **方向 + 边界**:具体到子方向(不是"水泥"而是"低碳水泥 SCM");明确纳入/排除
2. **时间窗**:默认**近 1 年**(简报是"最新文献",窗口宜短);换算成 `year_gte`(今年见 system prompt)
3. **期刊范围**:默认按方向所属子领域取 `journals.md` 主流期刊(Elsevier 优先);用户可增删指定刊
4. **深度 / 篇数**:`flash` 1020 篇 / `standard`(默认)2040 篇 / `deep` 4080 篇
5. **数据源(默认三路并用)**:research + documents **都是获取文献的主力**(research 按期刊精确取最新 Elsevier 论文 + DOI;documents 取内部材料库全文),web search 取政策·标准·产业动向(**单列、不混进论文总结**)。某一路不可用时降级用其余两路,不整体放弃
6. **语言**:中文(默认)/ 英文
7. **特殊关注点**(可选):想重点呈现的材料体系 / 方法(仍只描述,不给建议)
## 阶段二:三路取数(research + documents 取文献 / web 取动向)
**先读 `references/journals.md`**。**中文方向先转专业英文术语**(库主语料英文):低碳水泥→low-carbon cement / clinker substitution;SCM→supplementary cementitious materials / fly ash / GGBFS / calcined clay;LC3→limestone calcined clay cement;碳化养护→CO2 curing / carbonation。缩写与全称都试。
**research(逐刊取最新 Elsevier 论文 + DOI)** —— `run_python`:
```python
from skills.research.paper import search
# 逐刊拉最新:publication_name 精确匹配 + 时间窗;list 自带 abstract,看前 200-400 字判切题与分量
for jname in ["Cement and Concrete Research", "Cement and Concrete Composites",
"Construction and Building Materials", "Journal of Cleaner Production"]:
papers = search(publication_name=jname, year_gte=2025, limit=50)
# 按 publication_date 倒序取最新若干;留重要的(主题居中 + 有实质发现),弃边缘
```
某刊精确名 0 命中 → 换 `keyword=<方向英文术语>` 再搜,从返回里挑 `publication_name` 命中目标刊的;仍空记"该刊本窗口库内无收录"。
**documents(内部材料库取全文,材料类首选)** —— host-side tool `document_search`,中英 query 都行(后端跨语言语义检索);胶凝材料库 `classification_id=1`。取 `md_content` 既做候选也供引文核验抓锚点最顺。
**web search(取动向)** —— 政策(双碳/碳配额)、标准(新国标/团标)、行业会议、企业产线中试。**单列"其他动向",不混进论文列表与总结**。
- 汇成证据表 `<task_dir>/evidence.md`:期刊 | 标题 | 第一作者(机构)| 年-月 | 摘要概述 | DOI | 来源(research/documents/web)。
- 跨源去重:同 DOI 一条(documents 全文优先,DOI 记自 research);web 不与论文去重、单列。
> **context 纪律(省时省钱,务必遵守)**:检索结果(尤其全文 abstract)**落进 `evidence.md` / `selected_papers.json` 文件**,**不要在对话里反复 `run_python`/`print` 把整批 abstract 灌进上下文**。工具输出会永久留在 context 并每轮重发——同一批摘要 dump 三次,context 就滚成雪球(实测一次简报因此累计烧 2.5M 输入 token、跑满超时被掐断)。需要看某几篇时按需 `read` 文件片段,看完即弃,别整批重打。
> **窗口内 0 篇**:如实告知库内该窗口暂无收录(可能该刊本窗口尚未发文),可用 web 补更近的非论文动向,**不脑补文献**。
## 阶段三:列清单 + 内容总结(写 `<task_dir>/sections/*.md`)
骨架四段(`flash` 可省 `00`/`03`):
- **`00_overview.md` 概览**:方向 + 纳入/排除边界 + 时间窗 + 覆盖了哪些期刊 + 收录多少篇。无引文。
- **`01_papers.md` 重要论文列表(主体)**:按期刊 `###` 分组,每篇一条,行首 `[n]`(渲染时此段作参考锚点、`[n]` 带 DOI 超链接):
```
### Cement and Concrete Research(Elsevier)
[1] <标题>. <第一作者> et al., Cement and Concrete Research, 2026-03. DOI: 10.1016/j.cemconres.2026.xxxxxx
<简介/摘要概述:24 ,讲研究对象方法/表征主要发现与关键数据 基于 abstract 或全文,不夸张不评判>
```
`publication_date` 倒序,最新在前。每篇都要有摘要概述,不能只留标题。
> **一次成稿,别重复 dump**:中文概述基于 `evidence.md` / `selected_papers.json` **一遍生成写入**,生成后**不要再把英文 abstract 重新 `print` 进上下文**(它已在文件里)。论文多时按期刊**分批写**(每个 `###` 期刊段一次 `write`/`edit`),避免单次超长输出拖慢——而不是先把全批 abstract 全打印出来再憋一个巨型 write。
- **`02_summary.md` 内容总结**:对这批论文**客观归纳**——主题分布、常涉材料体系、常用方法/表征、共同关注点;引具体论文挂 `[n]` 上标(回链到 01)。**只描述"这批论文在讲什么",不给"应当/建议/可切入"**。
- **`03_web.md` 其他动向(仅 spec 开 web 时)**:政策/标准/会议/产业,`[W1]` 标来源 + 日期,单列。
数字/定量结论必须挂 `[n]`;"据报道""有研究表明"这类无源句式禁止。
## 阶段四:引文核验(渲染前必跑)
论文直接来自 research/documents,DOI 以**库返回字段为准**(不沿用记忆、不编造)。逐条核验:
1. **存在性**:`search()`/`get_paper(doi)` 或 documents 命中确认真实存在;查不到 → 标 `[未核实]`,告诉用户"找不到来源,请提供 DOI 或删去",**不编造**。
2. **支撑度**:摘要概述 / `[n]` 论断要和 abstract(或全文)一致;不一致 → **改概述迁就证据**,不是改证据。
3. **web**:记原始 URL + 访问日期 + 发布机构,标"截至 <日期>";不当学术结论引。
台账可写 `<task_dir>/CITATIONS.md`。**铁律**:不为凑数编造文献;支撑不足改论断不改证据;查不到如实说。
## 阶段五:渲染验收
- 用户要 docx → `python /sandbox/rendering/render.py --profile brief --format docx <sections_dir> -o <方向>-简报.docx`(`--no-color` 出黑白);要 deck → 转 ppt。
- 用户要 pdf → `python /sandbox/rendering/render.py --profile brief --format pdf <sections_dir> -o <方向>-简报.pdf`(沙盒内 chromium 渲染,同样 `--no-color` 出黑白)。**别现搓 weasyprint / 现 pip 装包** —— 直接调 render.py。
- 渲染前自查:`[CITE-]`/`<TODO>` 占位是否清干净、正文 `[n]` 与列表 `[n]` 是否对得上(无 orphan)、有没有混进"建议/启示/本院应当"措辞。
- 交付一句话说清:覆盖了哪些期刊、收了多少篇、时间窗、哪些刊本窗口库内无收录。
## 反模式
- ❌ **给建议/启示/"本院应当"** —— 只描述论文讲了什么,判断留给读者
- ❌ 列表只留标题、没摘要概述 —— 每篇都要 24 句简介
- ❌ 跳过定题直接检索 / 用中文 keyword 搜英文库 / 期刊名不精确 —— 先定题、转英文术语、用精确 `publication_name`
- ❌ web 资讯混进论文列表/总结 —— 单列"其他动向"
- ❌ 编造 DOI / "据报道"无源句 —— 查不到就如实说
- ❌ 反复 `run_python`/`print` 把整批全文 abstract 灌进上下文 —— 落文件、按需读;同批摘要 dump 多次会让 context 滚雪球(实测一次简报累计烧 2.5M token、跑满超时被掐断没推送出去)

View File

@ -0,0 +1,58 @@
# 各建材子领域主流期刊清单(Elsevier 数据库优先)
逐刊取最新论文时用。**绝大多数是 Elsevier**(下表 `E` 标),少数主流非 Elsevier 刊也列上(标出版商),取数时 Elsevier 优先。
> **用法**:`search(publication_name="<下表精确名>", year_gte=<>, limit=50)`,按 `publication_date` 倒序取最新。
> **名字要精确**:OpenAlex 的期刊显示名就是下表这串,带副标题的(如 `Composites Part B: Engineering`)要带全。
> **0 命中降级**:精确名搜不到 → 换 `keyword=<期刊核心词>``keyword=<方向英文术语>` 搜,从返回里挑 `publication_name` 命中该刊的;仍空 → 记"该刊本窗口库内无收录",不脑补。
## 水泥 / 混凝土 / 胶凝材料(本院核心)
| 期刊 | 出版商 | 备注 |
|---|---|---|
| Cement and Concrete Research | E | 领域顶刊,机理与材料 |
| Cement and Concrete Composites | E | 复合胶凝、SCM、耐久 |
| Construction and Building Materials | E | 体量最大,工程材料广谱 |
| Cement | E | 较新 OA 刊,水泥专门 |
| Journal of Building Engineering | E | 建筑工程材料与结构 |
| Materials and Structures | Springer(RILEM) | 非 E 主流,RILEM 旗舰 |
| Cement, Concrete and Aggregates | ASTM | 非 E |
## 绿色 / 低碳 / 固废资源化
| 期刊 | 出版商 | 备注 |
|---|---|---|
| Journal of Cleaner Production | E | 低碳、生命周期、固废 |
| Resources, Conservation and Recycling | E | 工业固废资源化 |
| Journal of Environmental Management | E | 环境与固废处置 |
| Waste Management | E | 固废(矿渣/粉煤灰/赤泥)|
| Journal of CO2 Utilization | E | 碳化养护 / CCUS |
## 陶瓷 / 玻璃 / 耐火
| 期刊 | 出版商 | 备注 |
|---|---|---|
| Ceramics International | E | 陶瓷综合顶刊 |
| Journal of the European Ceramic Society | E | 陶瓷,欧洲旗舰 |
| Journal of Non-Crystalline Solids | E | 玻璃 / 非晶 |
| Journal of the American Ceramic Society | Wiley | 非 E,陶瓷顶刊(JACerS)|
| International Journal of Applied Glass Science | Wiley | 非 E,玻璃 |
## 复合材料 / 新型建材 / 通用材料
| 期刊 | 出版商 | 备注 |
|---|---|---|
| Composites Part B: Engineering | E | 复合材料(纤维增强等)|
| Composites Part A: Applied Science and Manufacturing | E | 复合材料 |
| Materials & Design | E | 材料设计广谱 |
| Journal of Materials Research and Technology | E | 材料制备表征 |
| Materials Today Communications | E | 材料快报 |
| Powder Technology | E | 粉体 / 颗粒 |
| Fuel | E | 燃煤灰渣相关 |
## 取数策略
- 按 spec 方向所属子领域,从上面对应表里取 **38 本主流刊**(Elsevier 优先),逐刊拉最新。
- 跨子领域的方向(如"低碳水泥固废路线")→ 水泥表 + 绿色表合并取。
- 每本刊取最新若干篇后,**按重要性筛**:主题居中、有实质发现的留,边缘/纯验证性的弃(控量见 SKILL.md 篇数预算)。
- 主流刊都覆盖到了就够,不必穷举所有刊。哪些刊本窗口库内 0 收录,交付时如实点出。

View File

@ -1,11 +1,11 @@
---
name: documents
description: 查内部材料学科知识库(document_search API,7 个学科:胶凝 / 陶瓷 / 玻璃 / 晶体 / 复合 / 耐火 / 检验检测,21W+ 英文学术论文 Markdown 化,跨语言语义检索)。用户找材料领域文献、特定学科论文、材料性能数据时使用;与 research(OpenAlex 外部库)互补,可并用 / 同时试。
description: 查内部材料学科知识库(document_search API,7 个学科:胶凝 / 陶瓷 / 玻璃 / 晶体 / 复合 / 耐火 / 检验检测,100W+ 英文学术论文 Markdown 化,跨语言语义检索)。用户找材料领域文献、特定学科论文、材料性能数据时使用;与 research(OpenAlex 外部库)互补,可并用 / 同时试。
---
# Documents
部署在 `https://ai.ctc-zc.com:8100/api` 的文档检索 API。后端按 `kb_name` 分库存储 7 个材料学科库(中文命名:胶凝 / 陶瓷基 / 玻璃基 / 晶体材料 / 复合材料 / 耐火材料 / 检验检测,共 21W+ 文件),**文档主体是英文学术论文**(Elsevier 期刊为主,DOI 前缀文件名),每个文档带 `md_content`(整篇 Markdown,LLM 友好)+ 可选的原 PDF 下载。**API 后端有跨语言语义检索**,中英文 query 都能命中英文文档。本 skill 使用三个 host-side tool:`document_list_kb` / `document_search` / `document_download`,**不要**自己 `httpx` 裸调,也不要在 `run_python` 里读 `DOCUMENT_SEARCH_API_KEY`
部署在 `https://ai.ctc-zc.com:8100/api` 的文档检索 API。后端按 `kb_name` 分库存储 7 个材料学科库(中文命名:胶凝 / 陶瓷基 / 玻璃基 / 晶体材料 / 复合材料 / 耐火材料 / 检验检测,共 100W+ 文件),**文档主体是英文学术论文**(Elsevier 期刊为主,DOI 前缀文件名),每个文档带 `md_content`(整篇 Markdown,LLM 友好)+ 可选的原 PDF 下载。**API 后端有跨语言语义检索**,中英文 query 都能命中英文文档。本 skill 使用三个 host-side tool:`document_list_kb` / `document_search` / `document_download`,**不要**自己 `httpx` 裸调,也不要在 `run_python` 里读 `DOCUMENT_SEARCH_API_KEY`
> ⚠️ **配置条件**:只有宿主后端配置了 `DOCUMENT_SEARCH_API_KEY` 时,上述 tool 才会出现在可用工具列表里。若没有 `document_*` tool,降级走 `research` skill(OpenAlex + Sci-Hub,不受影响,中文 query 先转专业英文术语)/ 用户自己导出文档落 task 目录后用 `read` 工具读。**别让 LLM 误推**:research 跟本 skill 不同范式,research 不持 secret,任何模式都能用。

View File

@ -1,6 +1,6 @@
---
name: imagegen
description: 用豆包 Seedream 5.0 生图(`seedream` tool)。**任何生图任务调 tool 前必须 load 本 skill**。触发词:画 / 绘制 / 出图 / 来张 / 生成图 / 做张 + 图 / 图片 / 图像 / 配图 / 封面 / 概念图 / 效果图 / 示意图 / 场景图 / 艺术图 / 写实图 / 海报 / 插画 / 插图 / 封皮 / 头图。核心是把用户模糊一句话**问清楚再画**,不要上来就烧 ¥0.22。
description: 用豆包 Seedream 5.0 生图 / 改图(`seedream` tool,文生图 + image-to-image 改图)。**任何生图 / 改图任务调 tool 前必须 load 本 skill**。触发词:画 / 绘制 / 出图 / 来张 / 生成图 / 做张 + 图 / 图片 / 图像 / 配图 / 封面 / 概念图 / 效果图 / 示意图 / 场景图 / 艺术图 / 写实图 / 海报 / 插画 / 插图 / 封皮 / 头图;**改图触发词**:改这张图 / 把图里的 X 改成 Y / 基于刚那张图 / 按这张参考图改 / 换个颜色·背景·风格(针对已有图)。核心是把用户模糊一句话**问清楚再画**,不要上来就烧 ¥0.22。
---
# Imagegen
@ -31,8 +31,9 @@ description: 用豆包 Seedream 5.0 生图(`seedream` tool)。**任何生图任
## 何时不走本 skill(直接走通用工具)
- 用户**没主动要图**(别为"丰富回复"装饰性生图 —— 这是 system prompt 红线)
- 用户给了具体参考图说"按这个改" —— Seedream 5.0 是文生图不接图像输入,告诉用户走描述
- 已有合适素材(用户上传 / 之前生成过)—— 直接 `read` / 引用,别重新生成
- 已有合适素材且用户**没要改**(用户上传 / 之前生成过)—— 直接 `read` / 引用,别重新生成
> 用户给了参考图说"按这个改" / 对刚生成的图说"改成 X" —— **这是改图(i2i),不是不能做**,走下面「改图」段用 `reference_images`,**别再走文生图从零画**。
## 关键岔路:mermaid vs seedream
@ -174,6 +175,34 @@ seedream(
产物自动落 `<task_dir>/figures/<时间戳>-<rand>.png` + 同名 `.meta.json`(prompt / 参数 / 成本 / response_id)。
## 改图(i2i):基于已有图做修改
**核心场景**:用户对**刚生成的图**(或自己上传的参考图)说"把天空改成黄昏" / "颜色换成蓝色" / "去掉左下角那个人" —— 这是**像素级改图**,要在原图基础上改,**不是重新文生图**。
> ⚠️ 最容易踩的错:用户说"改一下刚那张图",模型却拿新 prompt **重新文生图** —— 结果是一张**完全不同构图**的新图,原图的布局/主体全丢了,用户要的"只动某处"变成"全推翻"。**只要是基于某张已有图改,一律走 `reference_images`。**
**怎么调**:把要改的那张图路径传 `reference_images`(数组,**v1 只放 1 张**),prompt 只写**改成什么**(不用重述整张图):
```
seedream(
prompt="保持构图和主体不变,把背景天空从正午改成金色黄昏,光线偏暖",
reference_images=["figures/20260616-153022-a1b2c3.png"], # 上次 seedream 返回的 saved 路径,原样照抄
size="2048x2048", # 改图建议 ≥1920²(ARK i2i 最小输出约束),默认方图即可
)
```
参考图路径从哪来:
- **改刚生成的图** → 上一次 `seedream` 返回的 `saved:` 那行路径,**原样照抄**进 `reference_images`
- **改用户上传的图** → 用户消息里会带 `[用户上传的参考图] <路径>` 行(前端粘贴注入),把那个路径放进去
**和文生图一样守 ⛔ 铁律**:改图也烧 **¥0.22**,调 tool 前同样把「参考哪张图 + 改成什么 prompt」贴给用户确认再发。
**约束 / 边界**:
- **v1 单图**:`reference_images` 只放 1 张,传 2 张及以上会 `[Error]`。多图合成 / 角色定义留 v2,现在靠 prompt 描述
- 参考图 ≤10MB,扩展名 png/jpg/jpeg/webp/gif;路径必须在 task_dir 内
- 改图 size 别低于 ~1920²(如 1024² 会被 ARK 拒),保持默认 2048² 最稳
- 改不满意**不要原样重发** —— 同文生图,先口头对齐"还要再改哪一处",再发新调用
## 失败 / 不满意后怎么办
**不要原 prompt 重发**!那是浪费 ¥0.22。失败模式 / 解药:

221
skills/paper/SKILL.md Normal file
View File

@ -0,0 +1,221 @@
---
name: paper
description: 撰写学术期刊投稿论文(中文核心 / 英文 SCI;原创研究 original / 综述 review / 快报 letter)。把实验数据、前期报告整理成可投稿的论文 .docx,含 IMRaD 骨架、引文三角核验、投稿件。当用户要写论文、投稿稿、manuscript、写 Introduction/Methods/Results/Discussion、写综述、改投稿稿时使用。
---
# 学术论文写作
把实验数据 / 前期素材变成可投稿的论文 .docx。**先定类型与语言 → 八条对齐 → 建文献矩阵 → 先定图表 → 逐章一段一卡 → 引文三角核验 → 验收渲染 + 投稿件** —— 不要一口气出全文。
进度展示建议:用 `task_progress` 标记「摄取素材 / 类型与八条对齐 / 文献矩阵 / 图表定稿 / 逐章起草 / 引文核验 / 验收渲染」等关键阶段;章节内每段确认不必单独更新。
## 边界(先划清,免得和别的 skill 撞)
| 与谁区分 | 边界 |
|---|---|
| vs `proposal` | proposal 写**本子/任务书**(立项依据骨架);paper 写**期刊投稿稿**(IMRaD 骨架)。两者各自独立 |
| vs `review` | review 改**已有稿**;paper **从零起草**。paper 阶段六终审**调用** review 的协议,不重复造 |
| vs `research`/`documents` | 它们查文献;paper 是消费方,引文核验(阶段五)接到它们头上 |
| vs `patent`/`standard` | 写交底书→patent;写标准→standard |
**何时不用**:只改不写→review;写本子→proposal;只查文献→research/documents;只出图→plot_pub。
## 资源
下面所有路径都相对 **`<skill_dir>`** —— `load_skill` 返回头里的 `[skill=paper, dir=<绝对路径>]`,用这个绝对路径拼脚本/资源,不要假设 cwd。
**先读(always)**:
- `<skill_dir>/references/paper_types.md` —— 原创/综述/快报 的 IMRaD 骨架 + 篇幅预算 + 章节命名
**按 spec 条件加载(一篇论文只挂一套)**:
- 语言=zh → `references/cite_gbt7714.md` + `references/redlines_zh.md`
- 语言=en → `references/cite_elsevier.md` + `references/redlines_en.md`
**阶段五必读**:
- `references/citation_verify.md` —— 引文三角核验协议(存在性 / 三角印证 / 支撑度,接 documents/research)
**模板**:
- `templates/spec.md` —— 八条对齐固定字段(复制到 task 级 spec 文件)
- `templates/original_article.md` —— IMRaD 章节骨架(type=original)
- `templates/review_article.md` —— 主题式章节骨架(type=review)
**脚本**(`.venv/Scripts/python.exe <skill_dir>/scripts/...`):
- `scripts/render_diagrams.py` —— sections/*.md 的 ```mermaid``` 块 → `figures/fig_<caption>.png`(caption 必填+唯一)
- **平台渲染层 `/sandbox/rendering/render.py --profile paper`**(不再自带 render_docx)—— md→docx,`--lang {zh,en}`(图题 图/Fig.),`--toc`(默认不出目录),自动 `**bold**`/列表/表格/`![](png)` 居中插图 + 图题自增;要 pdf 加 `--format pdf`。**渲染一律调它,别自己手搓。**
- `scripts/word_count.py` —— `--type --lang`,章节篇幅 vs 预算
- `scripts/quality_check.py` —— `--type`,结构/占位符/过度宣称/插图 + **引文交叉核对**(orphan/uncited/编号连续)
## 阶段零:摄取素材(有实验数据 / 报告 / PDF 时才走)
用户给实验数据 XLSX / 前期报告 DOCX / 相关论文 PDF / 目标期刊 Guide URL → 先转 `<task_dir>/source/<name>.md`,后续才能读:
```bash
markitdown <path>/data.xlsx -o <task_dir>/source/data.md
markitdown <path>/report.docx -o <task_dir>/source/report.md
markitdown <path>/ref_paper.pdf -o <task_dir>/source/ref.md
markitdown https://.../guide -o <task_dir>/source/guide.md
```
转完后阶段一直接 `read <task_dir>/source/*.md` 拿事实,**实验数据一律以用户素材为准,不得自造**。
## 阶段一:八条对齐(写 spec)
产物:**task 级 spec 文件**(论文"宪法",后续每章前都要重读)。命名按 system prompt 的《task 级「宪法」文件命名约定》:
<task_dir>/<today>-<task_short_id>-<task_name>.spec.md
**0. 先检测已有 spec**(同 working_dir 可能已有别的 task 的 spec):
```
glob <task_dir>/*-<task_short_id>-*.spec.md → 按文件名字典序排,取最大者作 current
```
- 已有当前 task 的 spec → 读出展示,问「**沿用进阶段二** / **重定调**(以 today 为前缀写新版,旧版留存)」,⛔ BLOCKING
- 只有别的 task 的 spec → 仅作参考;继续走 1-4
- 完全没有 → 直接走 1-4
1. **先读 `references/paper_types.md`** 定论文类型(original/review/letter)
2. **复制模板** `read templates/spec.md``write <task_dir>/<today>-<task_short_id>-<task_name>.spec.md`
3. 按字段填(**§1 类型+语言、§2 目标期刊、§3 一句话贡献** 是后续所有阶段的锚,务必和用户敲定)
4. ⛔ **BLOCKING:用户确认 spec 后才进阶段二**
spec 定下「类型 + 语言」后,**按 §资源 条件加载**对应的 cite_*.md + redlines_*.md,后续都遵这一套。
## 阶段二:文献矩阵(立证据底座)
> 移植自 ARS,后端用 zcbot 自己的库。Introduction 与 Discussion 靠这份矩阵,不靠记忆。
1. 据 spec §3/§4 的贡献与 gap,列要查的主题(英文 keyword 优先,见 research/documents 规则)
2. 用 `documents`(材料类优先,中英 query 都行)/ `research`(要 DOI 走这个)检索,建矩阵到 `<task_dir>/lit_matrix.md`:
| 文献(真实条目) | DOI | 一句话贡献 | 在本文用在哪(Intro/Methods/Disc) |
|---|---|---|---|
| `<author year>` | `<doi>` | `<gap/方法/对比>` | Intro 第2段 |
3. ⛔ **BLOCKING:矩阵给用户过目**(查得够不够、方向对不对),确认后进阶段三
4. 矩阵里的文献是阶段五核验的输入;起草引用先用 `[CITE-<keyword>]` 占位
## 阶段三:先定图表(写正文前)
> paper-writer 的关键纪律 —— 先把证据骨架(图/表)定下来,再写正文,避免正文写完发现图对不上。
1. 据 spec §6 图表清单,确认每张图/表**要表达的结论**与数据来源
2. 出图:数据图走 `plot_pub` skill(材料论文配色/字号/矢量规范),流程/机理/装置图走 ```mermaid``` 块(caption 必填),实拍/SEM 直接 `![]()`
3. 落到 `<task_dir>/figures/`;mermaid 块先留在将写的章节里,阶段六统一 `render_diagrams.py`
4. ⛔ **BLOCKING:图表清单与初版图给用户确认**后进阶段四(图错了正文白写)
## 阶段四:逐章起草(一段一卡)
**写作顺序**(不是文件顺序):**Methods → Results → Introduction → Discussion → Abstract → Title**。先写定事实,再写需要全局视野的部分,最后凝练摘要题名。
复制 `templates/<original_article|review_article>.md` 对应小节到 `<task_dir>/sections/NN_xxx.md`(命名见 paper_types.md)。
每章两段式:**先列要点 → 用户确认 → 再起草 → 用户确认**。
**A. 起草前列要点**(改要点比改正文便宜):
1. 读 **current spec** + 加载的 redlines + 本章在 paper_types.md 的篇幅预算与要素
2. 列 3-6 条要点骨架:本章论点 / 用哪些图表 / 引哪些矩阵里的文献,每条贴预估篇幅
3. ⛔ **BLOCKING:用户确认要点后才动正文**
**B. 正文起草**:
4. 按要点填;引用处放 `[CITE-<keyword>]` 占位(阶段五再核验编号)
5. **关键章节一段一卡** —— Introduction / Methods / Results / Discussion:写一段 → 报篇幅 + **预告下一段** → 等确认 → 写下一段。短章节(Abstract/Conclusion)一节一卡
6. 报告格式(每次卡点):
- **本段(节)**:章节名 / 实际篇幅 / 预算 / 与 redlines 对齐情况(可复现?只陈述?不过度宣称?)
- **下一段(节)预告**:标题 + 3-5 条要点(论点 / 图表 / 引文)
- 提问:"本段可以了吗?下一段要点改/加/删什么?"
7. ⛔ **BLOCKING:等用户明确反馈**("OK"/"下一段"/"继续")才动笔。沉默/"看着不错"不算确认;**篇幅或 redlines 异常**时必须主动追问
8. 用户确认**实质改动**(改机理解释 / 换核心数据图 / 调结论 / 增删引文 / 改创新点表述)后,追加一行到 `<task_dir>/REVISIONS.md`
两段式 + 段段卡是为了拦早 —— 论文连续生成容易把错方向(尤其机理论述、过度解读)推到底。
**例外**:用户**主动且明确**说"别问,直接全做"才一次跑完,跑完必须 quality_check + citation_verify。"太慢/太碎"的抱怨**不算**例外。
## 阶段五:引文三角核验(渲染前必跑)
> 论文最致命的失分是编造引文 / 引而不实。**逐条**走 `references/citation_verify.md` 三层:
1. **存在性**:每条引文在 documents/research 查到真实条目,字段以库返回为准;查不到标 `[未核实]`,**不编造**
2. **三角印证**:关键论断的支撑引文至少两个独立来源一致
3. **支撑度**:抓回 md_content/PDF,定位 ≤25 词锚点原文,判 support/partial/not-support;partial→**改论断迁就证据**,not-support→删或换
4. 台账写 `<task_dir>/CITATIONS.md`;只有 verified 的进编号
5. 按文中首次出现顺序编 `[1][2]...`,把占位替换掉,写 `sections/<NN>_references.md`
⛔ status 非 verified 的引文不得带进最终稿(核实 / 删论断 / 用户拍板,三选一)。
## 阶段六:验收 + 渲染 + 投稿件
```bash
python <skill_dir>/scripts/word_count.py <task_dir>/sections/ --type original --lang en
python <skill_dir>/scripts/quality_check.py <task_dir>/sections/ --type original
python <skill_dir>/scripts/render_diagrams.py <task_dir>/sections/ # 有 ```mermaid 块就跑
python /sandbox/rendering/render.py --profile paper --format docx <task_dir>/sections/ --lang en -o <task_dir>/<topic>.docx
```
- `quality_check` 的 orphan/uncited/占位符不通过 → 回头改章节或补阶段五核验,再跑
- **终审走 `review` skill** 的反谄媚审稿协议(EIC + 审稿人视角,pre-commit 评分防一味说好),别自己说"挺好"就交
- **投稿件(可选,用户要才出)**:cover letter(说清贡献与契合度)/ Highlights / AI 使用声明 / 作者贡献(CRediT)/ 利益冲突声明 —— 按目标期刊要求
## 工作目录
`<task_dir>` = system prompt 给的**绝对路径**。所有产物写到 task_dir 下,不要写 cwd / skills/ / repo 根。
```
<task_dir>/
├── source/ # 摄取的素材(实验数据/报告/参考论文)
├── <today>-<task_short_id>-<task_name>.spec.md # 阶段一定调,论文宪法
├── lit_matrix.md # 阶段二文献矩阵
├── figures/ # 阶段三图表(plot_pub 出的 png / mermaid 渲染的 png)
├── sections/ # 阶段四逐章产物(NN_xxx.md)
├── CITATIONS.md # 阶段五引文核验台账
├── REVISIONS.md # 修订日志:每次卡点用户确认的实质改动
└── <topic>.docx # 最终投稿稿(按论文主题命名,不要 output.docx)
```
## 修订日志 (REVISIONS.md)
`<task_dir>/REVISIONS.md` 是产物迭代的紧凑 changelog。**spec 是宪法(定调一次),REVISIONS 是实施日志(每次卡点累加)**。
| 情形 | 记? |
|---|---|
| 用户确认改 **机理解释 / 核心数据图 / 结论 / 创新点表述** | ✅ 必记 |
| 用户确认 增/删/换 **引文 / 图表 / 章节** | ✅ 必记 |
| 阶段五因支撑度不足**改写论断** | ✅ 必记(注明触发的引文) |
| 章节首次起草(从 0 写出) | ❌ 不记 |
| 错别字 / 标点 / 排版 | ❌ 不记 |
格式(倒序,最新在上;文件首次创建写一次头注释):
```
- `<YYYY-MM-DD HH:MM>` | <文件:章节/> | <一句话改了什么><为什么>
```
操作:`edit` 在头注释后插入新行;文件不存在就 `write` 带头注释创建。
## 硬规则速查(违反审稿扣分)
- **可复现**:Methods 给材料来源/纯度/配比/工艺/仪器型号/标准,不写"按常规方法"
- **结果只陈述**:机理解释留 Discussion;数据带单位 + 误差
- **不过度宣称**:"国际领先/首次/world-first/unprecedented" 等无证据夸张词禁用(quality_check 拦)
- **引文真实**:经 citation_verify 核验;不编造、不凭印象;起草用 `[CITE-xx]` 占位,渲染前必清空
- **摘要自含**:不出现 [n] 引文与图表号
- **术语统一**:一个概念一个词;缩写首次给全称
- **图**:用 mermaid/matplotlib 出 png,**不用 ASCII 字符画**(Word 必错位,quality_check 拦);图题自增不手写"图 2-2"
- 详细规则见加载的 redlines_zh.md / redlines_en.md
## 反模式
- 未 spec 就硬写正文 / 一次性出全文 / 跳过"列要点"直接写
- 跳过文献矩阵与图表定稿,边写正文边凑图凑引文
- 关键章节(Intro/Methods/Results/Discussion)整章一次出 —— 必须段段卡
- **自造实验数据 / 指标**(不知道就 `<TODO 待用户提供>`)
- **编造引文** / 引文凭印象 / 带 `[CITE-xx]` 占位就渲染 / 跳过阶段五核验
- 结果章大段解读机理 / 讨论重复结果数字 / 摘要里写 [n]
- 不跑 quality_check 就交付 / 文件名 output.docx / 论文.docx(按主题命名)
## 输出
完成后给用户:
- 文件路径
- 各章节篇幅 vs 预算
- 引文核验结论(verified 条数 / 待用户提供条数 / 因支撑度改写的论断)
- `<TODO>` 待补项清单
- 是否需要出投稿件(cover letter / 声明)

View File

@ -0,0 +1,71 @@
# 引文三角核验协议 (language 无关)
论文最致命的失分是**编造引文**(hallucinated citation)与**引而不实**(cite 的文献不支撑该论断)。
本协议把每条引文从"看起来对"逼到"经得起查"。移植自 ARS 的 triangulation + claim-faithfulness 思路,
**后端换成 zcbot 自己的 `documents` / `research` 库**(它们本就带 DOI + md_content,做 anchor 比对反而更顺)。
> 这是**协议**不是脚本 —— 你(模型)拿 host-side tool 逐条执行。quality_check.py 只做机械的
> orphan/uncited/编号核对,真伪与支撑度靠本协议。
## 何时跑
- 阶段四逐章起草后、阶段六渲染前,对所有引文跑一遍
- 用户自带的引文清单**也要跑**(用户也可能记错卷期/页码)
## 三层核验(逐条引文执行)
### 第 1 层 — 存在性 (exists)
每条引文先确认"这篇文献真实存在":
1. `documents` 库语义检索(材料类优先,中英 query 都行)/ `research``search()` / `get_paper(doi)`
2. 命中 → 记下真实 DOI / 作者 / 年份 / 期刊 / 卷期页;**以库里返回为准**,不沿用记忆里的字段
3. 两个库都查不到 → 标 `[未核实]`,**不得编造条目**;告诉用户"这条找不到来源,请提供 PDF/DOI 或删去该论断"
### 第 2 层 — 三角印证 (triangulate)
关键论断(创新点对比、机理依据、定量结论)的支撑引文,**至少两个独立信息源一致**才算稳:
- documents 命中 + research/DOI 一致 → 通过
- 仅单一来源 → 标"单源,谨慎",提示用户复核
- 不同来源字段冲突(年份/卷期不一致)→ 以可验证的 DOI 元数据为准,修正条目
### 第 3 层 — 支撑度 (claim-faithfulness)
最容易翻车的一层:文献存在,但**并不支撑你写的那句话**。逐条做:
1. 抓回该文献的 `md_content`(documents 直接给整篇 Markdown)/ `fetch_xml` / `fetch_pdf`(research)
2. 在原文里定位与论断相关的**锚点证据**:一句 ≤25 词的原文引语 + 出现的段落/小节位置
3. 判定支撑度三档:
- **support**:原文明确支撑该论断 → 通过
- **partial / 需限定**:原文只支撑部分,或有前提条件 → **改写论断**使之与证据相符(别让引文背锅)
- **not-support / 反向**:原文不支撑甚至相反 → **删除该引用或换文献**;绝不硬挂
4. 抓不到全文(无 PDF/XML)→ 至少用 abstract 做弱核验,标"仅摘要核验",提示用户终审时复查
## 产出:核验台账 `CITATIONS.md`
`<task_dir>/CITATIONS.md` 记一份可复盘的台账(append,一条引文一行):
```markdown
# 引文核验台账
> 每条引文的存在性/三角/支撑度核验结果。渲染前所有条目应为 verified 或经用户确认。
- [1] Provis & Bernal 2014, Annu. Rev. Mater. Res. 44:299 | exists:✓(documents+DOI) | triangulate:✓ | claim:support "geopolymers form via dissolution-polymerisation"(§2.1) | status: verified
- [2] <author> <year> ... | exists:✓ | claim:partial → 已把"显著提高"改为"在 28d 提高约 12%" | status: verified-revised
- [3] <author> ... | exists:✗ 两库未命中 | status: 待用户提供来源
```
## 与编号流程的衔接
1. 起草时占位 `[CITE-<keyword>]`(见 cite_*.md)
2. 本协议逐条把占位映射到**已核验的真实文献**
3. 仅 status=verified / verified-revised / 用户确认 的才进入编号
4. 按文中首次出现顺序编 `[1][2]...`,写 `06_references.md`
5. quality_check.py 兜底查 orphan/uncited/编号连续
## 铁律
- ❌ 任何 status 非 verified 的引文不得带进最终稿(要么核实、要么删论断、要么用户拍板)
- ❌ 不得为凑引文数编造"看起来合理"的文献
- ✅ 支撑度不足时**改论断迁就证据**,不是改证据迁就论断
- ✅ 两库都查不到时如实告诉用户,给出"提供来源 / 删除论断"两个选项

View File

@ -0,0 +1,62 @@
# 英文论文引文规范 (Elsevier / IEEE 数字制)
英文 SCI 材料期刊多用**数字顺序制**(numbered, Vancouver-like):文中 `[1]`,文末按出现顺序排。
常见对口期刊:*Cement and Concrete Research*、*Cement and Concrete Composites*、
*Construction and Building Materials*、*Journal of the American Ceramic Society*、
*Ceramics International*、*Journal of the European Ceramic Society*。
**语言=en 时加载本文件;语言=zh 时改用 cite_gbt7714.md。**
> 不同期刊细节有别(JACerS 用 numbered;部分期刊要 author-year)。**以目标期刊 Guide for Authors 为准**,本文件给最常见的 Elsevier numbered 默认。
## 真实性铁律
- ❌ 不可编造 authors / year / journal / volume / pages / DOI
- ✅ 引文必须经 `citation_verify.md` 核验(优先 documents / research 库)
- ✅ 用户给 BibTeX / RIS 你只排版
## 文中标注(numbered)
```
The early-age strength of alkali-activated binders is governed by calcium content [1].
Provis et al. [2] proposed a nanostructural evolution model for geopolymers.
Several studies [3-5] reported similar trends.
```
多篇:`[3-5]` 连续 / `[1,3,7]` 非连续。
❌ 不要 author-year `(Provis et al., 2014)`,除非目标期刊明确要求。
## 文末 References 格式 (Elsevier numbered)
字段顺序:**Authors, Title, Journal Abbrev. Volume (Year) Pages.**(可带 DOI)
作者全列或按期刊要求截断;**期刊名用标准缩写**(ISO 4 / CASSI),与中文刊全称相反。
| 类型 | 格式示例 |
|---|---|
| Journal | `[1] J.L. Provis, S.A. Bernal, Geopolymers and related alkali-activated materials, Annu. Rev. Mater. Res. 44 (2014) 299-327. https://doi.org/10.1146/annurev-matsci-070813-113515.` |
| Journal | `[2] K. Scrivener, A. Ouzia, P. Juilland, et al., Advances in understanding cement hydration mechanisms, Cem. Concr. Res. 124 (2019) 105823.` |
| Book | `[3] H.F.W. Taylor, Cement Chemistry, 2nd ed., Thomas Telford, London, 1997, pp. 113-156.` |
| Book chapter | `[4] B. Lothenbach, et al., Thermodynamic modelling, in: Cementitious Materials, De Gruyter, 2018, pp. 53-105.` |
| Conference | `[5] K. Scrivener, Hydration of cementitious materials, in: Proc. 13th ICCC, Madrid, 2011, pp. 1-12.` |
| Standard | `[6] ASTM C150/C150M-22, Standard Specification for Portland Cement, ASTM International, West Conshohocken, 2022.` |
| Thesis | `[7] X. Li, Hydration of high-belite cement (Ph.D. thesis), CBMA, Beijing, 2019.` |
| Dataset/Web | `[8] USGS, Mineral Commodity Summaries 2024, 2024. https://... (accessed 1 June 2024).` |
## IEEE 变体(投 IEEE / 部分材料-器件交叉刊时)
字段顺序与 Elsevier 接近但标点/缩写规则不同:
`[1] J. L. Provis and S. A. Bernal, "Geopolymers and related alkali-activated materials," Annu. Rev. Mater. Res., vol. 44, pp. 299-327, 2014.`
题名加引号、卷期用 `vol.`/`pp.`、作者名缩写在前。投 IEEE 系才用,默认走 Elsevier。
## 写作流程(与 citation_verify 配合)
1. 起草时引用处放占位 `[CITE-<keyword>]`
2. 草稿成形 → 走 `citation_verify.md` 逐条查真实文献
3. 按文中**首次出现顺序**编号,替换占位为 `[1][2]...`
4. 按目标期刊格式重排 `06_references.md`;期刊名缩写查 CASSI
## 引文密度 / 常见错误
- Introduction 15-40、Methods 3-10、Discussion 10-30、Results/Conclusion 0-5
- 期刊名**该缩写没缩写**(`Cement and Concrete Research` → `Cem. Concr. Res.`)
- 漏 DOI / volume / pages;author-year 与 numbered 混用
- orphan cite / uncited ref(quality_check 必拦)

View File

@ -0,0 +1,65 @@
# 中文论文引文规范 (GB/T 7714-2015 顺序编码制)
中文核心期刊(《硅酸盐学报》《建筑材料学报》《硅酸盐通报》等)统一用顺序编码制:
文中按出现顺序 `[1][2][3]...`,文末参考文献按编号顺序排列。
**语言=zh 时加载本文件;语言=en 时改用 cite_elsevier.md。**
## 真实性铁律
- ❌ **不可编造**作者 / 年份 / 期刊 / 卷期 / 页码 / DOI
- ❌ **不可凭印象**写"某某 2020 提到过…" —— 大概率错
- ✅ 引文必须经 `citation_verify.md` 的核验流程拿到真实条目(优先查 documents / research 库)
- ✅ 用户给 BibTeX / EndNote / 纯文本均可,你只**排版**,不补全凭空内容
## 文中标注
```
碱激发胶凝材料的早期强度发展受钙含量调控 [1]。
Provis 等 [2] 提出了地聚物的纳米结构演化模型。
```
多篇:`[1-3]` 连续 / `[1, 3, 5]` 非连续。
❌ 不要 APA `(Provis, 2020)`;❌ 不要"张三在文献[1]中提出",写"张三 [1] 提出"。
## 文末参考文献格式
字段顺序:**作者. 题名[类型代码]. 出版信息.** 中文用全角符号,英文用半角;
3 位以上作者列前 3 位 + ", 等."(中文)/ ", et al."(英文);期刊名用**全称**。
| 类型 | 格式示例 |
|---|---|
| 期刊 J | `[1] 沈晓东, 王培铭. 水泥水化动力学研究进展[J]. 硅酸盐学报, 2006, 34(8): 1009-1015.` |
| 期刊 J(英文混排) | `[2] PROVIS J L, BERNAL S A. Geopolymers and related alkali-activated materials[J]. Annual Review of Materials Research, 2014, 44: 299-327.` |
| 会议 C | `[3] SCRIVENER K. Hydration of cementitious materials[C]//Proc. of the 13th ICCC. Madrid, 2011: 1-12.` |
| 学位 D | `[4] 李响. 高贝利特水泥的水化硬化特性研究[D]. 北京: 中国建筑材料科学研究总院, 2019.` |
| 专著 M | `[5] 沈威, 黄文熙. 水泥工艺学[M]. 武汉: 武汉理工大学出版社, 2017: 88-95.` |
| 标准 S | `[6] 中国建筑材料联合会. 通用硅酸盐水泥: GB 175—2023[S]. 北京: 中国标准出版社, 2023.` |
| 专利 P | `[7] 张三, 李四. 一种低碳胶凝材料及其制备方法: CN20231012345.6[P]. 2023-08-15.` |
| 网络 EB/OL | `[8] USGS. Mineral commodity summaries 2024[EB/OL]. (2024-01-31)[2024-06-01]. https://...` |
类型代码:J 期刊 / M 专著 / C 会议 / D 学位 / R 报告 / P 专利 / S 标准 / EB 电子公告 / DS 数据集 / OL 联机网络。
## 写作流程(与 citation_verify 配合)
1. 起草正文时,引用处先放占位符 `[CITE-<关键词>]`(如 `[CITE-geopolymer-strength]`)
2. 草稿成形后,走 `citation_verify.md`:对每个占位逐条查 documents / research 拿真实文献
3. 核验通过的文献按**文中首次出现顺序**编号,占位符替换成 `[1][2][3]...`
4. 按 GB/T 7714 重排 `06_references.md`
> quality_check.py 会拦未替换的 `[CITE-xx]` 占位与 orphan/uncited —— 别带着占位符渲染。
## 引文密度(材料论文参考)
| 章节 | 推荐数 |
|---|---|
| Introduction(背景 + gap) | 15-40 |
| Methods(方法/标准出处) | 3-10 |
| Discussion(机理对比) | 10-30 |
| Results / Conclusion | 0-5 |
## 常见错误
- APA `(Smith, 2020)` → 顺序编码 `[1]`
- 期刊名缩写 `J. Am. Ceram. Soc.` → 中文刊全称;英文刊按目标期刊要求(中文核心多用全称)
- 缺页码 / 缺出版地;中英作者格式混用(中文全角逗号,英文半角)
- 文中引了 `[5]` 但参考文献清单没有第 5 条(orphan cite,quality_check 必拦)

View File

@ -0,0 +1,70 @@
# 论文类型 cheat sheet
写之前先确认**论文类型**(决定章节骨架与篇幅预算)和**语言**(决定引文格式与硬规则)。
章节文件按下表命名(NN_ 前缀决定 sections/ 里的排序,也是 quality_check / word_count 识别依据)。
适用学科:无机非金属材料(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材)。
篇幅预算 en 口径=词数,zh 口径=字数(摘要除外,见各类说明)。
---
## 1. 原创研究论文 (`original`) —— IMRaD
绝大多数实验性论文走这个。**英文 SCI 4000-7000 词 / 中文核心 6000-10000 字**(不含图表与参考文献)。
| 文件 | 章节 | 内容要点 | en 词 | zh 字 |
|---|---|---|---|---|
| `00_title_abstract.md` | Title / Abstract / Keywords | 结论式题名;结构化或叙述式摘要(背景-方法-结果-结论);3-6 关键词 | 150-320 | 200-400 |
| `01_introduction.md` | Introduction | 漏斗式:领域背景 → 已有工作与 gap → 本文目的与贡献(末段点明) | 600-1000 | 1000-1800 |
| `02_methods.md` | Materials and Methods | 原材料(来源/纯度/配比)→ 制备工艺(温度/时间/升温制度)→ 表征方法(仪器型号/参数/标准)。**可复现是铁律** | 800-1600 | 1200-2400 |
| `03_results.md` | Results | 按逻辑(不按时间)摆数据;每个图表对应 1-2 段;**只陈述不解读** | 1000-1900 | 1500-2900 |
| `04_discussion.md` | Discussion | 解读机理 / 与文献对比 / 局限。回答 Introduction 提的问题。**不重复 Results 的数字** | 800-1600 | 1200-2400 |
| `05_conclusion.md` | Conclusion | 3-5 条结论 + 定量关键数据 + 展望(可选) | 180-400 | 300-600 |
| `06_references.md` | References | 顺序编码制 `[1][2]...`,格式见 cite_*.md | — | — |
可选附加(按需,放对应位置):Graphical Abstract / Highlights(Elsevier 系常要)/ Acknowledgments / CRediT 作者贡献 / Declaration of competing interest / Data availability。
**章节顺序写作建议(不是文件顺序)**:Methods → Results → Introduction → Discussion → Abstract → Title。
先写定事实(怎么做、得到什么),再写需要全局视野的部分(为什么重要、怎么解读),最后用全文凝练摘要与题名 —— 这是论文写作公认更稳的顺序,避免开头写 Introduction 时把方向带偏。
---
## 2. 综述论文 (`review`) —— 主题式
按主题(thematic)组织,不是 IMRaD。**英文 6000-12000 词 / 中文 8000-15000 字**。
| 文件 | 章节 | en 词 | zh 字 |
|---|---|---|---|
| `00_title_abstract.md` | Title / Abstract / Keywords | 180-350 | 250-450 |
| `01_introduction.md` | Introduction(领域意义 + 综述范围 + 本文组织) | 700-1300 | 1200-2200 |
| `02_<theme>.md` ~ `NN_<theme>.md` | 主题章节(按材料体系 / 性能维度 / 方法路线切,每章一个主题,**篇数可变**,word_count 标 no budget) | 自定 | 自定 |
| `98_outlook.md` | Challenges & Outlook(未解难题 + 趋势) | 500-1100 | 800-1800 |
| `99_conclusion.md` | Conclusion | 180-450 | 300-700 |
| `<NN>_references.md` | References(综述引文常 80-200 条) | — | — |
> 主题章节文件名用 `02_` 起的两位前缀保证排序,主题名跟在后面(如 `02_hydration_mechanism.md`)。`98_/99_` 前缀保证 outlook/conclusion 永远在主题章之后。
---
## 3. 快报 / 通讯 (`letter`) —— 凝练版
Communication / Letter,篇幅小、强调新颖性与时效。**英文 1500-3000 词 / 中文 2000-4000 字**。
| 文件 | 章节 | en 词 | zh 字 |
|---|---|---|---|
| `00_title_abstract.md` | Title / Abstract | 120-250 | 180-350 |
| `01_main.md` | 正文(引言+方法+结果+讨论压缩成连续叙述,不分大节) | 1500-3000 | 2000-4000 |
| `02_references.md` | References | — | — |
---
## 选择决策树
```
要系统报告一组实验(材料-工艺-性能-机理)? — 是 → original
否 → 要综述某方向已有研究、不含自己新实验? — 是 → review
否 → 有一个新颖、时效强、单点突破的结果想快发? — 是 → letter
否 → 跟用户确认论文类型, 不要猜
```
不确定时默认 `original`(覆盖面最广)。投稿目标期刊有特定体裁要求(如 Highlights 必填、字数硬上限)时,以**目标期刊投稿指南**为准,本表只给通用骨架。

View File

@ -0,0 +1,40 @@
# English manuscript writing rules (loaded when language=en)
For materials-science SCI submissions. Reviewers penalize violations even when quality_check doesn't catch them all.
## Title / Abstract / Keywords
- **Title**: state what was done + system + key finding; avoid "Study on…/Investigation of…" filler; minimize abbreviations.
- **Abstract**: 150-320 words, **self-contained**, covering background/aim, methods, **quantitative** results, conclusion. **No citation markers [n]** and **no figure/table numbers** inside the abstract. Structured abstract only if the journal requires it.
- **Keywords**: 3-6, complementary to the title (don't just repeat it); cover material system + method + property.
## Per-section rules
- **Introduction**: funnel structure; the final paragraph must state the aim and contribution of this work. Each paragraph makes a point — not a citation list.
- **Materials and Methods**: **reproducibility is mandatory** — raw material source/purity/composition, mix proportions (with values + units), processing (temperature/time/heating-cooling schedule/atmosphere), characterization instrument models + parameters + standards (ASTM/EN/ISO). Never "by conventional method".
- **Results**: **report, don't interpret** (interpretation belongs to Discussion). Every figure/table is cited and described in text; data carry **units + uncertainty/SD**; highlight key numbers, don't restate every value in a table.
- **Discussion**: mechanism, comparison with literature (cited), limitations; **do not restate Results numbers**; answer the question posed in the Introduction.
- **Conclusion**: itemized + quantitative; introduce no new data; outlook optional, not vague.
## Language & style
- **Tense**: Methods & Results in past tense (what you did/found); established facts and conclusions in present tense.
- **Voice**: active voice is increasingly accepted ("We measured…"); keep it consistent; avoid dangling modifiers.
- Terminology consistent throughout (one concept = one term). Define abbreviations at first use: C-S-H, C₃S, AFt (ettringite), etc.
- SI units and standard symbols (MPa, °C, wt%, mol/L); correct spacing between number and unit.
- **No overclaiming**: "world-first / unprecedented / groundbreaking / state-of-the-art" without evidence (quality_check flags these). Let data speak.
- Figures: cite before showing; caption below figure, title above table; axes labelled with units; **no ASCII art** (use mermaid or matplotlib PNG).
- Watch common L2 issues: article use (a/the), subject-verb agreement, "respectively" placement, comma splices.
## Research integrity (hard rules)
- **No fabrication / no result beautification / no selective reporting**; report negative results honestly.
- **Citations must be real** and verified via `citation_verify.md`; no plagiarism, no duplicate submission.
- Disclose AI-assisted writing if the target journal requires it.
- Funding / ethics / competing interests as required; mark `<TODO>` when user input is needed.
## Anti-patterns
- "Study on…" title with no finding / [n] markers inside abstract / interpreting mechanism in Results
- Methods saying "conventional/appropriate/certain temperature" (not reproducible) / Discussion restating Results numbers
- Fabricated data or citations / rendering while `[CITE-xx]` placeholders remain

View File

@ -0,0 +1,38 @@
# 中文论文写作硬规则 (language=zh 时加载)
材料类中文核心期刊投稿。违反这些 quality_check 不一定全拦,但审稿人会扣分。
## 题名 / 摘要 / 关键词
- **题名**:写清"做了什么 + 体系 + 关键发现",≤25 字,不用"研究""探讨"凑字(如"……的研究"信息量低)。少用缩写,首字母缩略词题名里慎用。
- **摘要**:200-400 字,**自含**(不依赖正文也能读懂),含 背景目的 / 方法 / **定量**结果 / 结论 四要素。摘要里**不出现引文标注 [n]**,不出现图表编号。
- **关键词**:3-6 个,覆盖材料体系 + 方法 + 性能维度,避免与题名完全重复。
## 各章红线
- **引言**:漏斗式收口,末段必须点明本文目的与创新点;不堆砌文献流水账,每段要有论点。
- **实验/方法**:**可复现是铁律** —— 原材料来源/纯度/化学组成、配比(给具体数值与单位)、制备工艺(温度/时间/升温降温制度/气氛)、表征仪器型号+测试参数+依据标准(GB/T、ASTM 等)。别写"按常规方法"。
- **结果**:**只陈述,不解读**(解读留给讨论)。每个图/表都要在正文被引用并描述趋势;数据带**单位 + 误差/标准差**;不重复图表里已有的全部数字,挑关键的说。
- **讨论**:解释机理、与文献对比(用引文)、说明局限;**不重复结果的数字**;回答引言提出的科学问题。
- **结论**:分条 + 定量;不引入新数据;展望可选但别空泛。
## 语言与表达
- 学术语体,**不用口语 / 不用感叹**;术语全文统一(一个概念一个词,别中途换说法)。
- 量与单位用法定计量单位 + 国标符号(MPa、°C、wt%、mol/L);数字与单位间规范空格。
- 化学式 / 相名规范:C-S-H、C₃S、钙矾石(AFt)等首次出现给全称 + 缩写,之后用缩写。
- **过度宣称禁用**:"国际领先""首次""填补空白""重大突破"等无证据夸张词(quality_check 会拦);用数据说话。
- 图表:图随文走、先文后图;图题在图下、表题在表上;坐标轴带量纲;**不用 ASCII 字符画**(用 mermaid 或 matplotlib 出 png)。
## 学术诚信(铁律)
- **不编造数据 / 不美化结果 / 不选择性报告**;阴性结果如实写。
- **引文必须真实**且经 `citation_verify.md` 核验;不抄袭、不一稿多投。
- 使用 AI 辅助写作如目标期刊要求披露,则在 Acknowledgments / 声明中如实说明。
- 基金号 / 伦理审批 / 利益冲突 按期刊要求如实填,缺则标 `<TODO>` 待用户提供。
## 反模式速查
- 题名带"研究""探讨"且无具体发现 / 摘要里出现 [n] 引文 / 结果章大段解读机理
- 方法写"按常规""适量""一定温度"(不可复现)/ 讨论重复结果数字
- 自造数据或指标 / 引文凭印象写 / 带 `[CITE-xx]` 占位就渲染

View File

@ -0,0 +1,305 @@
"""论文投稿稿质量检查 — 渲染 docx 前跑一遍。
检查项:
- 结构完整性: 论文类型必备章节是否齐全
- 占位符泄漏: <TODO> / [REF-xx] / [CITE-xx] / (Author, year) 占位是否还在
- 过度宣称: "国际领先 / 首次 / world-first / unprecedented" 等无证据夸张词
- 插图: figures/ png sections ![]() 引用; 代码块 ASCII 字符画; mermaid caption / 撞名
- **引文交叉核对** (论文版核心): 文中 [n] 与文末参考文献清单互查
· orphan cite: 文中引了 [7] 但参考文献列表没有第 7
· uncited ref: 参考文献列了第 9 条但正文从没引用
· 编号不连续 / 不从 1 (顺序编码制要求按首次出现顺序连续编号)
用法:
python quality_check.py <sections_dir> --type original
python quality_check.py <sections_dir> --type original --strict
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
REQUIRED_SECTIONS: dict[str, list[str]] = {
"original": [
"00_title_abstract", "01_introduction", "02_methods",
"03_results", "04_discussion", "05_conclusion", "06_references",
],
# 综述: title/abstract + intro + (≥1 个 thematic 主体, 不强制命名) + outlook/conclusion + references
"review": ["00_title_abstract", "01_introduction", "99_conclusion", "references"],
"letter": ["00_title_abstract", "01_main", "references"],
}
# 过度宣称 / 无证据夸张 (中英)
OVERCLAIM_PHRASES = [
"国际领先", "国际一流", "世界领先", "世界一流", "填补空白", "首次提出",
"重大突破", "划时代", "前所未有",
"world-first", "world-leading", "unprecedented", "groundbreaking",
"revolutionary", "first-ever", "state of the art", "best-in-class",
]
PLACEHOLDER_PATTERNS = [
r"<TODO[^>]*>",
r"\[REF-[A-Za-z0-9]+\]",
r"\[CITE-[A-Za-z0-9]+\]",
r"\[Smith et al",
r"\(Author,?\s*\d{4}\)", # APA 占位 (Author, 2024)
r"\bXX+\b", # XX / XXX 占位
]
# 插图相关 (同 proposal)
_BOX_DRAWING_RE = re.compile(r"[┌┐└┘├┤┬┴┼─│╔╗╚╝╠╣╦╩╬═║▲▼◀▶]")
_IMAGE_REF_RE = re.compile(r"!\[[^\]]*\]\([^)\s]+\)")
_FENCE_RE = re.compile(r"^\s*(`{3,}|~{3,})\s*(\S*)\s*$")
_MERMAID_CAPTION_RE = re.compile(r"^\s*%%\s*caption\s*:\s*(.+?)\s*$", re.IGNORECASE)
# 文中引文标记: [7] / [7-9] / [7, 9] / [7,9-11]
_INTEXT_CITE_RE = re.compile(r"\[(\d[\d,\s\-]*)\]")
# 参考文献条目行: 以 [n] 开头
_REF_ENTRY_RE = re.compile(r"^\s*\[(\d+)\]")
def _is_references_file(stem: str) -> bool:
s = stem.lower()
return "reference" in s or s.endswith("_refs") or "参考文献" in stem
def _extract_mermaid_caption(block_lines: list[str]) -> str | None:
for ln in block_lines:
m = _MERMAID_CAPTION_RE.match(ln)
if m:
return m.group(1).strip()
return None
def check_structure(sections_dir: Path, ptype: str) -> list[str]:
required = REQUIRED_SECTIONS.get(ptype, [])
existing = {f.stem for f in sections_dir.glob("*.md")}
issues = []
for req in required:
if req == "references":
if not any(_is_references_file(s) for s in existing):
issues.append("缺章节: references (参考文献)")
continue
if not any(s.startswith(req) for s in existing):
issues.append(f"缺章节: {req}")
return issues
def check_phrases(text: str, label: str) -> list[str]:
issues = []
low = text.lower()
for phrase in OVERCLAIM_PHRASES:
hit = phrase in text or phrase.lower() in low
if hit:
issues.append(f"[{label}] 过度宣称: '{phrase}' — 换成可被数据支撑的具体表述")
return issues
def check_placeholders(text: str, label: str) -> list[str]:
issues = []
for pat in PLACEHOLDER_PATTERNS:
for m in re.findall(pat, text):
issues.append(f"[{label}] 占位符未替换: '{m}'")
return issues
def _expand_cite_group(grp: str) -> set[int]:
"""'7, 9-11' -> {7,9,10,11}。非法片段忽略。"""
out: set[int] = set()
for part in grp.split(","):
part = part.strip()
if not part:
continue
if "-" in part:
a, _, b = part.partition("-")
try:
lo, hi = int(a), int(b)
except ValueError:
continue
if 0 < lo <= hi <= 999:
out.update(range(lo, hi + 1))
else:
try:
out.add(int(part))
except ValueError:
continue
return out
def check_citations(sections_dir: Path) -> list[str]:
"""文中 [n] 与参考文献清单 [n] 互查。"""
issues: list[str] = []
cited: set[int] = set()
ref_nums: list[int] = []
for md in sorted(sections_dir.glob("*.md")):
text = md.read_text(encoding="utf-8")
if _is_references_file(md.stem):
for ln in text.splitlines():
m = _REF_ENTRY_RE.match(ln)
if m:
ref_nums.append(int(m.group(1)))
else:
for grp in _INTEXT_CITE_RE.findall(text):
cited.update(_expand_cite_group(grp))
if not ref_nums and not cited:
return ["未发现任何引文 (文中 [n] 和参考文献清单都为空) — 论文一般需要引用支撑"]
ref_set = set(ref_nums)
# orphan cite: 引了但参考文献没有
orphan = sorted(cited - ref_set)
if orphan:
issues.append(f"orphan cite — 文中引了 {orphan} 但参考文献清单缺对应条目 (编造/漏排)")
# uncited ref: 列了但正文从没引
uncited = sorted(ref_set - cited)
if uncited:
issues.append(f"uncited ref — 参考文献第 {uncited} 条正文从未引用 (删除或在正文补引)")
# 编号重复
dups = sorted({n for n in ref_nums if ref_nums.count(n) > 1})
if dups:
issues.append(f"参考文献编号重复: {dups}")
# 连续性: 应从 1 起连续
if ref_set:
expected = set(range(1, max(ref_set) + 1))
gaps = sorted(expected - ref_set)
if gaps:
issues.append(f"参考文献编号不连续, 缺号: {gaps} (顺序编码制需 1..N 连续)")
if 1 not in ref_set:
issues.append("参考文献编号未从 [1] 起")
return issues
def check_figures(sections_dir: Path) -> list[str]:
issues: list[str] = []
figures_dir = sections_dir.parent / "figures"
pngs = list(figures_dir.glob("*.png")) if figures_dir.is_dir() else []
total_img_refs = 0
ascii_art_blocks: list[tuple[str, int]] = []
mermaid_no_caption: list[tuple[str, int]] = []
mermaid_captions: dict[str, list[str]] = {}
for md in sorted(sections_dir.glob("*.md")):
text = md.read_text(encoding="utf-8")
total_img_refs += len(_IMAGE_REF_RE.findall(text))
lines = text.splitlines()
i = 0
while i < len(lines):
m = _FENCE_RE.match(lines[i])
if not m:
i += 1
continue
fence = m.group(1)
lang = (m.group(2) or "").lower()
block_line = i + 1
i += 1
buf: list[str] = []
while i < len(lines):
mc = _FENCE_RE.match(lines[i])
if mc and mc.group(1)[0] == fence[0] and len(mc.group(1)) >= len(fence):
i += 1
break
buf.append(lines[i])
i += 1
if lang == "mermaid":
cap = _extract_mermaid_caption(buf)
if not cap:
mermaid_no_caption.append((md.name, block_line))
else:
mermaid_captions.setdefault(cap, []).append(f"{md.name}:{block_line}")
continue
if any(_BOX_DRAWING_RE.search(ln) for ln in buf):
ascii_art_blocks.append((md.name, block_line))
if pngs and total_img_refs == 0:
names = ", ".join(p.name for p in pngs[:4])
more = f" ... +{len(pngs) - 4}" if len(pngs) > 4 else ""
issues.append(f"figures/ 有 {len(pngs)} 张 png ({names}{more}) 但 sections 里 0 个 ![](...) 引用")
for fname, lineno in ascii_art_blocks:
issues.append(f"[{fname}:~{lineno}] 代码块里有 ASCII 字符画 — Word 必错位, 改 ```mermaid 块或 ![](figures/x.png)")
for fname, lineno in mermaid_no_caption:
issues.append(f"[{fname}:~{lineno}] mermaid 块缺首行 '%% caption: <图题>'")
for cap, locs in mermaid_captions.items():
if len(locs) > 1:
issues.append(f"mermaid caption 撞名: {cap!r} 出现在 {', '.join(locs)}")
return issues
def main() -> None:
ap = argparse.ArgumentParser(description="论文质量检查")
ap.add_argument("sections_dir", type=Path)
ap.add_argument("--type", required=True, choices=list(REQUIRED_SECTIONS.keys()))
ap.add_argument("--strict", action="store_true", help="严格模式: 任何问题退出 1")
args = ap.parse_args()
if not args.sections_dir.is_dir():
print(f"[ERR] {args.sections_dir} not a directory", file=sys.stderr)
sys.exit(2)
print(f"\n[质量检查] type={args.type}\n")
all_issues: list[str] = []
struct = check_structure(args.sections_dir, args.type)
if struct:
print("[ERR] 结构问题:")
for s in struct:
print(f" - {s}")
all_issues.extend(struct)
else:
print("[OK] 结构完整")
files = sorted(args.sections_dir.glob("*.md"))
print(f"\n{len(files)} 个章节, 逐章扫描 (过度宣称 / 占位符)...\n")
for f in files:
text = f.read_text(encoding="utf-8")
sub = check_phrases(text, f.stem) + check_placeholders(text, f.stem)
if sub:
print(f"[WARN] {f.stem}:")
for s in sub:
print(f" - {s.split('] ', 1)[1] if '] ' in s else s}")
all_issues.extend(sub)
cite_issues = check_citations(args.sections_dir)
if cite_issues:
print("\n[ERR] 引文交叉核对:")
for s in cite_issues:
print(f" - {s}")
all_issues.extend(cite_issues)
else:
print("\n[OK] 引文 [n] 与参考文献清单一致 (无 orphan / uncited, 编号连续)")
fig_issues = check_figures(args.sections_dir)
if fig_issues:
print("\n[ERR] 插图问题:")
for s in fig_issues:
print(f" - {s}")
all_issues.extend(fig_issues)
else:
print("\n[OK] 插图引用 / 无 ASCII 字符画")
print("\n" + "=" * 60)
if all_issues:
print(f"[WARN] 共发现 {len(all_issues)} 个问题。")
print("\n建议:")
print(" - 过度宣称 -> 换成数据支撑的具体表述")
print(" - 占位符未替换 -> 补真实数据 / 真实引文")
print(" - orphan cite -> 核对参考文献清单 (大概率编造引文, 走 citation_verify 三角核验)")
print(" - uncited ref -> 删条目或在正文补引")
print(" - 插图未挂 / ASCII 字符画 -> ```mermaid 块或 ![](figures/x.png)")
if args.strict:
sys.exit(1)
else:
print("[OK] 全部检查通过, 可以渲染 docx 了。")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,214 @@
"""预处理 sections/*.md 里的 mermaid 块 → 渲染为 figures/fig_<caption>.png。
proposal skill render_diagrams.py 同源 论文里的技术流程图 / 实验装置
示意 / 机理图同样用 mermaid , 本脚本统一渲成 PNG, render_docx.py caption 查表插图
caption 命名规则:
- 每个 mermaid **必须**有首行注释 `%% caption: <图题>`, 否则直接报错退出
- caption 在全 task 内必须唯一, 撞了就报错 (强制起更具体的题)
- 文件名 = caption 清洗后 (保留 CJK / 字母 / 数字, 其它字符 '_', 40 ), 前缀 'fig_'
渲染后端 (按优先级): 本地 mmdc mermaid.ink 公网 API两种都没留警告退出 0
用法:
python render_diagrams.py <task_dir>/sections/
"""
from __future__ import annotations
import argparse
import base64
import re
import shutil
import subprocess
import sys
import tempfile
import urllib.error
import urllib.request
from collections import defaultdict
from pathlib import Path
_FENCE_OPEN_RE = re.compile(r"^\s*```\s*mermaid\s*$")
_FENCE_CLOSE_RE = re.compile(r"^\s*```\s*$")
_CAPTION_RE = re.compile(r"^\s*%%\s*caption\s*:\s*(.+?)\s*$", re.IGNORECASE)
_FILENAME_INVALID_RE = re.compile(r"[^一-鿿A-Za-z0-9]+")
MERMAID_INK_URL = "https://mermaid.ink/img/{payload}?type=png&bgColor=FFFFFF"
def caption_to_stem(caption: str) -> str:
cleaned = _FILENAME_INVALID_RE.sub("_", caption).strip("_")[:40]
if not cleaned:
raise ValueError(f"caption sanitizes to empty: {caption!r}")
return f"fig_{cleaned}"
def extract_caption(source: str) -> str | None:
for ln in source.splitlines():
m = _CAPTION_RE.match(ln)
if m:
return m.group(1).strip()
return None
def find_mermaid_blocks(md_text: str) -> list[str]:
blocks: list[str] = []
lines = md_text.splitlines()
i = 0
n = len(lines)
while i < n:
if _FENCE_OPEN_RE.match(lines[i]):
buf: list[str] = []
i += 1
while i < n and not _FENCE_CLOSE_RE.match(lines[i]):
buf.append(lines[i])
i += 1
blocks.append("\n".join(buf))
i += 1
else:
i += 1
return blocks
def render_via_mmdc(source: str, out_png: Path) -> bool:
import os
mmdc = shutil.which("mmdc")
if not mmdc:
return False
with tempfile.NamedTemporaryFile("w", suffix=".mmd", delete=False, encoding="utf-8") as tf:
tf.write(source)
tmp_path = Path(tf.name)
try:
argv = [mmdc, "-i", str(tmp_path), "-o", str(out_png), "-b", "white", "--quiet"]
puppeteer_cfg = os.environ.get("MERMAID_PUPPETEER_CONFIG", "").strip()
if puppeteer_cfg and Path(puppeteer_cfg).is_file():
argv += ["-p", puppeteer_cfg]
proc = subprocess.run(argv, capture_output=True, text=True, timeout=60)
if proc.returncode != 0:
print(f" [mmdc] returncode={proc.returncode}: {proc.stderr.strip()[:200]}", file=sys.stderr)
return False
return out_png.exists()
except (subprocess.TimeoutExpired, OSError) as e:
print(f" [mmdc] error: {e}", file=sys.stderr)
return False
finally:
try:
tmp_path.unlink()
except OSError:
pass
def render_via_mermaid_ink(source: str, out_png: Path) -> bool:
payload = base64.urlsafe_b64encode(source.strip().encode("utf-8")).decode("ascii").rstrip("=")
url = MERMAID_INK_URL.format(payload=payload)
try:
req = urllib.request.Request(url, headers={"User-Agent": "zcbot-paper/1.0"})
with urllib.request.urlopen(req, timeout=30) as resp:
if resp.status != 200:
print(f" [mermaid.ink] HTTP {resp.status}", file=sys.stderr)
return False
data = resp.read()
if not data or len(data) < 100:
print(f" [mermaid.ink] payload too small ({len(data)} bytes)", file=sys.stderr)
return False
out_png.write_bytes(data)
return True
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError) as e:
print(f" [mermaid.ink] error: {e}", file=sys.stderr)
return False
def render_one(source: str, out_png: Path) -> str:
if render_via_mmdc(source, out_png):
return "mmdc"
if render_via_mermaid_ink(source, out_png):
return "mermaid.ink"
return "fail"
def render_sections(sections_dir: Path) -> int:
if not sections_dir.is_dir():
print(f"[ERR] sections dir not found: {sections_dir}", file=sys.stderr)
return 2
figures_dir = sections_dir.parent / "figures"
figures_dir.mkdir(parents=True, exist_ok=True)
md_files = sorted(sections_dir.glob("*.md"))
if not md_files:
print(f"[ERR] no .md found in {sections_dir}", file=sys.stderr)
return 2
blocks_meta: list[tuple[Path, str, str]] = []
missing_cap: list[Path] = []
for md in md_files:
text = md.read_text(encoding="utf-8")
for src in find_mermaid_blocks(text):
cap = extract_caption(src)
if not cap:
missing_cap.append(md)
continue
blocks_meta.append((md, cap, src))
fatal = False
if missing_cap:
print("[ERR] 以下 md 里有 mermaid 块缺首行 '%% caption: <图题>':", file=sys.stderr)
for md in missing_cap:
print(f" - {md.name}", file=sys.stderr)
fatal = True
by_cap: dict[str, list[str]] = defaultdict(list)
for md, cap, _ in blocks_meta:
by_cap[cap].append(md.name)
dups = [(c, mds) for c, mds in by_cap.items() if len(mds) > 1]
if dups:
print("[ERR] caption 在全 task 内必须唯一, 以下撞名:", file=sys.stderr)
for c, mds in dups:
print(f" - {c!r} 出现在: {', '.join(mds)}", file=sys.stderr)
fatal = True
if fatal:
return 2
if not blocks_meta:
print(f"[OK] no mermaid block found in {sections_dir} (nothing to render)")
return 0
by_backend: dict[str, int] = {}
fail_blocks: list[tuple[Path, str]] = []
for md, cap, src in blocks_meta:
try:
stem = caption_to_stem(cap)
except ValueError as e:
print(f"[ERR] {md.name}: {e}", file=sys.stderr)
return 2
png = figures_dir / f"{stem}.png"
backend = render_one(src, png)
by_backend[backend] = by_backend.get(backend, 0) + 1
mark = {"mmdc": "+", "mermaid.ink": "+", "fail": "x"}[backend]
print(f" {mark} [{backend:11s}] {md.name} :: {png.name} :: {cap}")
if backend == "fail":
fail_blocks.append((md, cap))
print()
print(f"[OK] processed {len(blocks_meta)} mermaid block(s) -> {figures_dir}")
for b, c in sorted(by_backend.items()):
print(f" {b}: {c}")
if fail_blocks:
print()
print(f"[WARN] {len(fail_blocks)} block(s) failed to render. render_docx.py 会走 ASCII fallback.")
for md, cap in fail_blocks:
print(f" - {md.name} :: {cap}")
return 0
def main() -> None:
ap = argparse.ArgumentParser(description="预处理 sections/*.md 的 mermaid 块 → figures/*.png")
ap.add_argument("sections_dir", type=Path, help="sections/*.md 目录")
args = ap.parse_args()
sys.exit(render_sections(args.sections_dir))
if __name__ == "__main__":
main()

View File

@ -0,0 +1,122 @@
"""核算各章节篇幅, 对照论文类型 + 语言的预算, 输出表格。
计量: CJK 字符按 1 ; 连续 ASCII (英文单词 / 数字) 1 对中文稿近似"字数",
对英文稿近似"词数"预算按 (paper_type, lang) , 两套不同口径
用法:
python word_count.py <sections_dir> --type original --lang en
python word_count.py <sections_dir> --type original --lang zh
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
# BUDGETS[type][section] = {"en": (lo, hi), "zh": (lo, hi), "desc": str}
# en 口径=词数, zh 口径=字数。综述的 thematic 主体章节不设固定预算 (篇数可变)。
BUDGETS: dict[str, dict[str, dict]] = {
"original": {
"00_title_abstract": {"en": (150, 320), "zh": (200, 400), "desc": "Title + Abstract + Keywords"},
"01_introduction": {"en": (600, 1000), "zh": (1000, 1800), "desc": "Introduction"},
"02_methods": {"en": (800, 1600), "zh": (1200, 2400), "desc": "Materials & Methods"},
"03_results": {"en": (1000, 1900), "zh": (1500, 2900), "desc": "Results"},
"04_discussion": {"en": (800, 1600), "zh": (1200, 2400), "desc": "Discussion"},
"05_conclusion": {"en": (180, 400), "zh": (300, 600), "desc": "Conclusion"},
},
"review": {
"00_title_abstract": {"en": (180, 350), "zh": (250, 450), "desc": "Title + Abstract + Keywords"},
"01_introduction": {"en": (700, 1300), "zh": (1200, 2200), "desc": "Introduction"},
# 02_..NN_ thematic 主体不设预算 (篇数可变, word_count 标 no budget)
"98_outlook": {"en": (500, 1100), "zh": (800, 1800), "desc": "Challenges & Outlook"},
"99_conclusion": {"en": (180, 450), "zh": (300, 700), "desc": "Conclusion"},
},
"letter": {
"00_title_abstract": {"en": (120, 250), "zh": (180, 350), "desc": "Title + Abstract"},
"01_main": {"en": (1500, 3000), "zh": (2000, 4000), "desc": "Main text (condensed)"},
},
}
_HEADING_RE = re.compile(r"^#+\s+")
_BLOCKQUOTE_RE = re.compile(r"^>")
_TABLE_LINE_RE = re.compile(r"^\s*\|")
def count_chars(text: str) -> int:
n = 0
for line in text.splitlines():
stripped = line.strip()
if not stripped:
continue
if _HEADING_RE.match(stripped) or _BLOCKQUOTE_RE.match(stripped) or _TABLE_LINE_RE.match(stripped):
continue
if stripped.startswith("<TODO") and stripped.endswith(">"):
continue
for c in stripped:
if "" <= c <= "鿿":
n += 1
n += len(re.findall(r"[A-Za-z0-9]+", stripped))
return n
def main() -> None:
ap = argparse.ArgumentParser(description="论文章节篇幅核算")
ap.add_argument("sections_dir", type=Path)
ap.add_argument("--type", required=True, choices=list(BUDGETS.keys()))
ap.add_argument("--lang", required=True, choices=["zh", "en"])
args = ap.parse_args()
if not args.sections_dir.is_dir():
print(f"[ERR] {args.sections_dir} not a directory", file=sys.stderr)
sys.exit(2)
budget = BUDGETS[args.type]
files = sorted(args.sections_dir.glob("*.md"))
if not files:
print(f"[ERR] no .md found in {args.sections_dir}", file=sys.stderr)
sys.exit(2)
unit = "" if args.lang == "en" else ""
print(f"\n[篇幅核算] type={args.type} lang={args.lang} (口径: {unit})\n")
header = f"{'章节':<26} {'篇幅':>8} {'下限':>6} {'上限':>6} 状态"
print(header)
print("-" * len(header))
total = 0
overflow = 0
underflow = 0
for f in files:
text = f.read_text(encoding="utf-8")
n = count_chars(text)
total += n
stem = f.stem
bud = None
for key, val in budget.items():
if stem.startswith(key):
bud = val
break
if bud is None:
print(f"{stem:<26} {n:>8} - - (no budget; thematic/aux section)")
continue
lo, hi = bud[args.lang]
status = "OK"
if n > hi:
status = f"WARN over {n - hi}"
overflow += 1
elif n < lo:
status = f"WARN under {lo - n}"
underflow += 1
print(f"{stem:<26} {n:>8} {lo:>6} {hi:>6} {status}")
print("-" * len(header))
print(f"{'合计':<26} {total:>8}")
if overflow or underflow:
print(f"\n[WARN] {overflow} 项超出 / {underflow} 项不足 (含摘要/正文)。回头调整。")
sys.exit(1)
print("\n[OK] 全部章节篇幅合规。")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,112 @@
# 原创研究论文章节骨架 (type=original)
> 阶段四起草时,把每一节复制到 `<task_dir>/sections/NN_xxx.md`(文件名见 references/paper_types.md)。
> `> 引言块` 是写作提示,**不进正稿**(render_docx 会跳过)。正文语言随 spec(zh/en)。
> 标题用对应语言(下面给中英双标,据 spec 取一套)。
---
## 00_title_abstract
# 题名 / Title
> 结论式,含 体系 + 做法 + 关键发现;≤25 字(中)/ 简洁(英)。不用"研究/Study on"凑字。
`<TODO 题名>`
**作者与单位 / Authors & Affiliations**:`<TODO>`(通讯作者标 *)
# 摘要 / Abstract
> 自含,150-320 词 / 200-400 字。四要素:背景目的 → 方法 → **定量**结果 → 结论。**不出现 [n] 引文与图表号**
`<TODO 摘要正文>`
# 关键词 / Keywords
`<TODO 关键词1>; <TODO 关键词2>; <TODO 关键词3>`(3-6 个,与题名互补)
---
## 01_introduction
# 引言 / Introduction
> 漏斗式三段:① 领域背景与重要性(为什么这类材料/性能值得做);② 已有工作与 **gap**(别人做到哪、缺什么,带引文);③ 本文目的与贡献(末段点明,呼应 spec §3/§4)。每段一个论点,不堆文献流水账。
`<TODO 第 1 段:领域背景>` [CITE-xx]
`<TODO 第 2 段:已有研究与不足>` [CITE-xx]
`<TODO 末段:本文针对该 gap,做了……,贡献是……>`
---
## 02_methods
# 材料与方法 / Materials and Methods
> **可复现是铁律**。别写"按常规方法"。
## 原材料 / Materials
> 来源 / 纯度 / 化学组成(给 XRF 或厂家数据表)/ 粒度等。
`<TODO>`
## 配合比与制备 / Mix design and preparation
> 配比(具体数值 + 单位)、成型、养护(温度/湿度/龄期)、升温降温制度/气氛(如涉及烧成)。
`<TODO>`
## 表征方法 / Characterization
> 每种测试:仪器型号、关键参数、依据标准(GB/T、ASTM、EN、ISO)。
`<TODO XRD / SEM / TG-DSC / 抗压强度 / ……>`
---
## 03_results
# 结果 / Results
> **只陈述不解读**。每个图表在正文被引用 + 描述趋势;数据带单位 + 误差。机理解释留给 Discussion。
## <结果主题 1, 力学性能>
`<TODO 描述 Fig.1 趋势>`
![强度发展曲线](figures/<name>.png)
## <结果主题 2, 物相与微观结构>
`<TODO 描述 Fig.2 / Table 1>`
---
## 04_discussion
# 讨论 / Discussion
> 解读机理 / 与文献对比(带引文)/ 局限。回答 Introduction 提的问题。**不重复 Results 的数字**。
`<TODO 机理解释>` [CITE-xx]
`<TODO 与已有研究对比>` [CITE-xx]
`<TODO 局限与适用边界>`
---
## 05_conclusion
# 结论 / Conclusion
> 3-5 条,定量,不引入新数据;展望可选。
1. `<TODO 结论 1 + 关键数据>`
2. `<TODO 结论 2>`
3. `<TODO 结论 3>`
---
## 06_references
# 参考文献 / References
> 走 citation_verify.md 核验后,按文中首次出现顺序编号。格式见 cite_gbt7714.md(zh)或 cite_elsevier.md(en)。
```
[1] <TODO 核验后的真实条目>
[2] <TODO>
```
> 可选附加:Graphical Abstract / Highlights / Acknowledgments / CRediT / Declaration of competing interest / Data availability —— 按目标期刊要求增删。

View File

@ -0,0 +1,77 @@
# 综述论文章节骨架 (type=review)
> 主题式组织(thematic),不是 IMRaD。每节复制到 `<task_dir>/sections/NN_xxx.md`
> 主题章用 `02_`~`NN_` 两位前缀排序;outlook/conclusion 用 `98_/99_` 保证排在主题之后。
> `> 块` 是写作提示,不进正稿。正文语言随 spec。
---
## 00_title_abstract
# 题名 / Title
> 点明综述范围与视角(别只写"……研究进展",加上独特切入点)。
`<TODO 题名>`
**作者与单位**:`<TODO>`
# 摘要 / Abstract
> 180-350 词 / 250-450 字。说清:综述什么、为什么现在综述、本文的组织视角、主要结论性判断。不出现 [n]。
`<TODO>`
# 关键词 / Keywords
`<TODO>`(3-6 个)
---
## 01_introduction
# 引言 / Introduction
> ① 领域意义与为何需要这篇综述;② 已有综述的不足 / 本文独特视角;③ 综述范围界定 + 本文组织结构(按什么维度切主题)。
`<TODO>` [CITE-xx]
---
## 02_<theme> (主题章,按需复制多份:02_/03_/04_…
# <主题名, 水化机理 / Hydration mechanisms>
> 一章一个主题维度(可按 材料体系 / 性能维度 / 方法路线 切)。综述≠罗列文献,要有**横向归纳与批判**:谁做了什么、结论是否一致、矛盾在哪、本文的判断。
`<TODO 归纳性论述,带引文>` [CITE-xx]
> 需要对比多项研究时用表格:
| 体系 / 研究 | 方法 | 关键结论 | 局限 |
|---|---|---|---|
| `<TODO>` [CITE-xx] | `<TODO>` | `<TODO>` | `<TODO>` |
---
## 98_outlook
# 挑战与展望 / Challenges and Outlook
> 未解难题、方法瓶颈、产业化障碍、未来趋势。要具体到方向,不空泛喊口号。
`<TODO>`
---
## 99_conclusion
# 结论 / Conclusion
> 凝练全文的主要判断(不是逐章复述),给读者带走的核心结论。
`<TODO>`
---
## <NN>_references
# 参考文献 / References
> 综述引文常 80-200 条。**全部走 citation_verify.md 核验** —— 综述引文量大,编造/引错风险更高,逐条核。格式见 cite_*.md。
```
[1] <TODO 核验后的真实条目>
```

View File

@ -0,0 +1,67 @@
# 论文 spec(投稿稿"宪法")
> 阶段一产物。**写定后不再改**,阶段二/四每章前都要 read。`<TODO>` 是占位符,需用户明确填值,不要硬编。
> 本 spec 的「论文类型 + 语言」决定后续加载哪套 references(paper_types / cite_* / redlines_*)。
## 1. 论文类型 与 语言
- 类型:`<TODO original / review / letter>`(决定章节骨架,见 references/paper_types.md)
- 语言:`<TODO zh / en>`(zh→cite_gbt7714 + redlines_zh;en→cite_elsevier + redlines_en)
## 2. 目标期刊
- 期刊:`<TODO 期刊名, Cement and Concrete Research / 硅酸盐学报>`
- 体裁要求:`<TODO 字数上限 / Highlights 是否必填 / 结构化摘要 / 引文格式特殊要求 查该刊 Guide for Authors>`
- 若未定期刊:按语言走默认引文格式,体裁走 paper_types 通用骨架
## 3. 一句话贡献 (one-sentence contribution)
> 全文的"宪法第一条"。审稿人记不住别的,要记住这一句。
`<TODO 用一句话说清:本文做了什么、发现了什么、为什么重要。如"通过 X 调控 Y 体系的 Z,首次定量揭示了 A 与 B 的关系,使 C 性能提升 D">`
## 4. 创新点 / 贡献清单
(2-4 条,每条一句,后面 Introduction 末段与 Conclusion 要呼应)
- 贡献 1:`<TODO>`
- 贡献 2:`<TODO>`
- 贡献 3:`<TODO>`
## 5. 材料体系与实验设计(original / letter 必填)
- 研究对象 / 材料体系:`<TODO 碱激发矿渣-粉煤灰胶凝材料>`
- 关键自变量(调控因素):`<TODO NaO 当量 8/10/12 wt%;养护温度 20/40/60 °C>`
- 关键因变量(性能/表征指标):`<TODO 28d 抗压强度;XRD 物相;SEM 微观结构;TG 结合水>`
- 核心数据来源:`<TODO 用户实验数据 / 引用的预实验 不得自造>`
## 6. 图表清单(阶段三"先定图表"用)
> 论文的证据骨架。写正文前先把这些图表定下来,避免正文与图对不上。
| 编号 | 类型 | 内容 / 要表达的结论 | 数据来源 | 出图工具 |
|---|---|---|---|---|
| Fig.1 | `<TODO>` | `<TODO 这张图证明什么>` | `<TODO>` | plot_pub / mermaid / 实拍 |
| Fig.2 | `<TODO>` | `<TODO>` | `<TODO>` | `<TODO>` |
| Table 1 | `<TODO>` | `<TODO>` | `<TODO>` | — |
## 7. 篇幅预算
- 类型对应预算见 references/paper_types.md(en 词 / zh 字)
- 目标期刊有硬上限的,以期刊为准:`<TODO /词上限>`
## 8. TODO / 待用户提供
> 阶段一冒出来的"等用户提供"事项。阶段四每章开头扫一眼;阶段六 quality_check 扫余留 `<TODO>`
- [ ] `<TODO 如:提供 28d 强度实测数据表>`
- [ ] `<TODO 如:确认目标期刊与引文格式>`
- [ ] `<TODO 如:提供基金号 / 利益冲突声明>`
## 9. 引文清单 / 来源(经核验后填)
> 编造文献是学术不端。引文走 citation_verify.md 三层核验后才进此清单;起草时正文用 `[CITE-xx]` 占位。
```
[CITE-xx] -> <TODO 核验后的真实条目>
```

View File

@ -17,7 +17,7 @@ description: 撰写中国发明专利技术交底书 (供专利代理师转写
- `<skill_dir>/references/self_check.md` —— 渲染前自查清单(参数/公式一致、逻辑闭环、脱敏、附图)
- `<skill_dir>/templates/spec.md` —— task 级"宪法"模板(案件名 / 技术领域 / 创新点清单 / 检索结论 / 脱敏边界 / 附图清单)
- `<skill_dir>/templates/disclosure.md` —— 交底书 7 章 Markdown 模板,阶段四照抄
- **渲染脚本复用 proposal skill**:`skills/proposal/scripts/render_diagrams.py` + `render_docx.py` —— 跟交底书 md 兼容(同样的 markdown + ```mermaid``` + `%% caption:` 约定),不另写
- **渲染复用平台层 + proposal 图脚本**:docx 调 `rendering/render.py --profile proposal`(见下);mermaid 图仍用 `skills/proposal/scripts/render_diagrams.py` 预渲染 `figures/fig_<caption>.png` —— 同样的 markdown + ```mermaid``` + `%% caption:` 约定,不另写
## 阶段零: 摄取素材 (有 PDF/DOCX/PPTX/XLSX/URL 时才走)
@ -57,7 +57,7 @@ markitdown https://example.com/ -o <task_dir>/source/外部.md
3. **执行检索**(优先级从高到低):
- **`web_search`** —— 优先搜中国专利文库 / Google Patents / 期刊综述。query 里带 `site:patents.google.com` / `专利` / `CN10` 等限定符可显著提升信号
- **`web_fetch`** —— 命中的关键专利/论文页拉全文摘要
- **`documents` skill** —— 本地材料学科库(7 个学科共 21W+ 论文)如果命中
- **`documents` skill** —— 本地材料学科库(7 个学科共 100W+ 论文)如果命中
- **`research` skill** —— OpenAlex 学术文献库,找综述与对比方案
4. **每条命中归档** (写到 spec §4):
- 公开号 / 标题 / 申请人 / 公开日 (专利) 或 DOI / 作者 / 期刊 / 年 (论文)
@ -130,8 +130,8 @@ read <skill_dir>/references/self_check.md
# 2. mermaid 附图预渲染 (章节有 ```mermaid``` 块就跑)
python <skill_dir>/../proposal/scripts/render_diagrams.py <task_dir>/sections/
# 3. 渲染 .docx (复用 proposal skill 的脚本,patent 不另写)
python <skill_dir>/../proposal/scripts/render_docx.py <task_dir>/sections/ --fund-type key_rd -o <task_dir>/<案件名>_技术交底书.docx
# 3. 渲染 .docx (调平台渲染层,复用 proposal profile)
python /sandbox/rendering/render.py --profile proposal --format docx <task_dir>/sections/ --fund-type key_rd -o <task_dir>/<案件名>_技术交底书.docx
```
> `render_docx.py``--fund-type` 只影响目录页表头文案与封面,不影响章节解析 —— 交底书复用 `key_rd` 排版规范(国标黑体/宋体/1.5 倍行距)。封面页用户拿到后手动改成"技术交底书"标题,或在 sections/00_封面.md 自定义。

View File

@ -1,6 +1,6 @@
---
name: plot_pub
description: 出版级 matplotlib 绘图(论文 / 报告 / 申报书用,中文字体 + viridis 配色 + dpi 设定一键到位)。✅ 触发:用户要画 XRD 谱、TG-DSC 曲线、应力-应变曲线、SEM 标注、多 panel 学术图、要 SVG/PDF 矢量图给论文。⛔ 不触发:PPT 内嵌图(走 `ppt` skill 的 matplotlib 配图流程);只要快速看一眼数据(直接 `df.plot()` 即可,不用本 skill)。
description: 出版级 matplotlib 绘图(论文 / 报告 / 申报书用,中文字体 + viridis 配色 + dpi 设定一键到位)。✅ 触发:用户要画 XRD 谱、TG-DSC 曲线、应力-应变曲线、SEM 标注、多 panel 学术图、投稿级 / Nature 级复合图、要 SVG/PDF 矢量图给论文。⛔ 不触发:PPT 内嵌图(走 `ppt` skill 的 matplotlib 配图流程);只要快速看一眼数据(直接 `df.plot()` 即可,不用本 skill)。
---
# plot_pub
@ -139,6 +139,73 @@ fig.savefig("fig1_characterization.pdf", bbox_inches="tight")
plt.close(fig)
```
## 投稿级多 panel 复合图(Nature-grade composite)
要投高影响期刊、要一张"封面级"的多 panel figure 1 时,光排版正确不够 —— 还要**让图讲一个故事**。下面这套设计纪律移植自 `nature-figure` skill(MIT,[github.com/Yuan1z0825/nature-skills](https://github.com/Yuan1z0825/nature-skills)),砍掉其 R / 单细胞 / 在体生物那一套,只留可迁移的部分,适配建材领域。
### 动手前:五点 figure contract
画复合图前先在心里(或跟用户)把这五条对齐,**先想清楚再下笔**,避免画完六张图才发现讲不成一个故事:
1. **核心结论**:这张图要捍卫的**一句话**论断是什么?(例:"复合掺合料把 28d 强度提升 18% 且不牺牲流动度")
2. **证据链**:每个 panel 对应**唯一**一条证据,删掉冗余 panel —— 同一份数据别用两种图形再画一遍,同一组指标别排两次序
3. **图原型**:归类为 ① 定量网格(全是量化对比图)② schematic 主导(机理图 + 验证小图)③ 图像板 + 定量(SEM/光学 plate + 旁证曲线)④ 非对称混合;先定原型再排版
4. **后端**:本 skill 纯 matplotlib(Python),所有 panel / 预览 / 导出统一一套后端出
5. **期刊契约**:开画前定好尺寸(单栏 ~89mm / 双栏 ~183mm)、可编辑矢量(已由 `apply_pub_style``svg.fonttype='none'`)、source data、统计标注、导出格式
> 一句话原则:**图为科学逻辑服务**,美化 / 套模板 / 复杂排版都是次要的。
### 语义配色(比 viridis 更进一步)
单组数据用 viridis 没问题;但**多组对比 / 方法 vs 基线**时,颜色要承载语义,而不是随机区分。`style.py` 已内置一张语义色表:
```python
from skills.plot_pub.style import apply_pub_style, SEMANTIC_COLORS, clean_spines, ablation_alphas
apply_pub_style()
# 蓝=本方法/主角 绿=提升 红=baseline/对照 灰=参照 橙=少量强调
ax.bar(x0, y_base, color=SEMANTIC_COLORS["baseline"], label="基准配方")
ax.bar(x1, y_method, color=SEMANTIC_COLORS["method"], label="复合掺合料")
```
原则:**family consistency beats maximal hue separation** —— 相关的几条基线归一个色系,本方法的几个变体归另一个色系,别把颜色撒得到处都是。消融 / 梯度对比用**同色变 alpha**(0.25→1.0)而不是换色相:
```python
colors = ablation_alphas(len(掺量梯度)) # 同蓝色由浅到深
for (label, y), c in zip(掺量梯度.items(), colors):
ax.plot(x, y, color=c, label=label)
```
### 信息架构 + spine 纪律
- 多 panel 要**作为一个故事读**,不是六张互不相干的图拼一起
- 有 schematic / hero panel 时给它视觉主导,旁边的验证小图别喧宾夺主
- 每个 panel 调 `clean_spines(ax)` —— 只留左 + 下边框,去掉上 + 右,信噪比立刻上来
- legend 无框(`apply_pub_style` 已设)、网格抑制(只在需要时留稀疏 y 网格)
```python
fig, axes = plt.subplots(1, 3, figsize=(7.2, 2.6), constrained_layout=True) # 双栏宽 ~183mm
axes[0].bar(...) # (a) 概览:堆叠/分组柱
axes[1].imshow(...) # (b) 偏差:z-score 热图
axes[2].scatter(...) # (c) 关系:散点/气泡 —— 三级递进,各答一个不同的科学问题
for ax in axes:
clean_spines(ax)
for ax, letter in zip(axes, "abc"):
ax.text(-0.12, 1.02, f"({letter})", transform=ax.transAxes, fontweight="bold", va="bottom")
fig.savefig("fig1_composite.svg", bbox_inches="tight") # SVG 主格式,文字可编辑
fig.savefig("fig1_composite.png", dpi=300, bbox_inches="tight") # PNG 仅作 raster 预览
plt.close(fig)
```
### 导出纪律
- **SVG 为主格式**(文字可编辑,编辑部/作者能改),PDF 矢量并行,PNG 300dpi 仅作预览
- 别拿 PNG 当投稿正图(见反模式 #6)
## 中文字体配置(Windows 注意)
`apply_pub_style(chinese=True)` 默认按以下顺序找字体:

View File

@ -109,7 +109,64 @@ def apply_pub_style(
rc["pdf.fonttype"] = 42
rc["ps.fonttype"] = 42
# ---- SVG 文字可编辑(投稿级要求:导出后编辑部/作者能在 AI 里改文字) ----
# 'none' = 文字以 <text> 保留,不转 path;配 PDF Type 42 一起,矢量两路都可编辑
rc["svg.fonttype"] = "none"
def reset_style() -> None:
"""还原 matplotlib 默认 rcParams(测试 / 切换主题时用)。"""
matplotlib.rcdefaults()
# ============================================================
# Nature 级复合图辅助:语义配色 + spine 纪律
# 思路源自 nature-figure skill(MIT, github.com/Yuan1z0825/nature-skills),
# 砍掉 R / 生物 gallery,只留可迁移的设计纪律,改为纯 Python + 建材领域。
# ============================================================
# 语义配色:颜色承载"科学语义"而非随机区分。同族基线归一个冷色系,
# 本方法/主角归一个暖/蓝主色系 —— family consistency beats maximal hue separation。
SEMANTIC_COLORS = {
"method": "#1f5fa8", # 蓝 = 本工作 / 提出的方法(主角)
"gain": "#2a8f5e", # 绿 = 提升 / 增益 / 正向
"baseline": "#c0392b", # 红 = 对照 / baseline / 退化
"neutral": "#8a8f99", # 灰 = 参照 / 背景 / 次要
"accent": "#e08a1e", # 橙 = 强调 / 高亮少量点
}
def clean_spines(ax, keep=("left", "bottom")) -> None:
"""
出版图 spine 纪律:只留指定边框(默认左 + ),去掉上 +
复合图每个子 panel 都调一次,视觉更干净信噪比更高
Args:
ax: matplotlib Axes
keep: 保留哪几条 spine,默认 ("left", "bottom")
"""
for side in ("top", "right", "left", "bottom"):
ax.spines[side].set_visible(side in keep)
# 刻度只画在保留的边上
ax.tick_params(
top="top" in keep, right="right" in keep,
bottom="bottom" in keep, left="left" in keep,
)
def ablation_alphas(n: int, base_color: str = None):
"""
消融 / 梯度对比:同一颜色变 alpha(0.25 1.0),而不是换色相
返回 n (color, alpha) 不便用,这里直接返回 n RGBA
Args:
n: 系列数
base_color: 基色,默认用 SEMANTIC_COLORS["method"]
"""
import matplotlib.colors as mcolors
import numpy as np
base = base_color or SEMANTIC_COLORS["method"]
rgb = mcolors.to_rgb(base)
alphas = np.linspace(0.25, 1.0, n)
return [(rgb[0], rgb[1], rgb[2], a) for a in alphas]

28
skills/ppt/ATTRIBUTION.md Normal file
View File

@ -0,0 +1,28 @@
# 第三方来源与许可 (Attribution)
本 skill 的 SVG→PPTX 引擎、设计知识 references、模板与图标库**移植自开源项目 ppt-master**,并适配 zcbot 的 task_dir / 聊天确认 / imagegen 工作流。
## ppt-master
- 仓库:https://github.com/hugohe3/ppt-master
- 许可:MIT License
- 作者:Hugo He
- 移植范围(范围 B):
- **引擎**:`scripts/svg_to_pptx/`、`scripts/svg_finalize/`、`svg_quality_checker.py`、`finalize_svg.py`、`svg_to_pptx.py`、`total_md_split.py`、`update_spec.py`、`project_utils.py`、`error_helper.py`
- **设计知识**:`references/`(shared-standards / executor-base / strategist / image-layout-* / canvas-formats / modes / visual-styles / animations)
- **模板库**:`templates/`(layouts / decks / brands / charts / icons + spec 骨架)
- **未移植**:浏览器 Confirm UI、live preview server、TTS 配音子系统、AI 配图/网图子系统(zcbot 走自己的 imagegen skill)。
- zcbot 侧改动:`SKILL.md` 重写为两阶段聊天确认流;新增 `svg_preview.py`(无头 Chrome 渲 SVG→PNG 验收);入口脚本加 Windows GBK 控制台兼容 shim。
## 图标库 (templates/icons/)
各图标集沿用其上游许可,商用前以上游为准:
| 库 | 上游 | 许可 |
|---|---|---|
| tabler-outline / tabler-filled | Tabler Icons | MIT |
| phosphor-duotone | Phosphor Icons | MIT |
| simple-icons | Simple Icons | CC0 1.0(品牌标识版权归各品牌方,仅按其品牌规范使用) |
| chunk-filled | 见 templates/icons/README.md | 见上游 |
详见 `templates/icons/README.md`

View File

@ -3,228 +3,227 @@ name: ppt
description: 生成 PowerPoint 演示文稿 (.pptx) 文件。✅ 触发:用户明确点名 PPT / 幻灯片 / 演示文稿 / .pptx / slide / deck 之一。⛔ 不触发:用户明确说要"报告 / 文档 / 纪要"等指向纯文档形式的产物。⚠️ 歧义先反问:用户说"汇报 / 方案 / 材料"等产物形态不明的词、且没说成品形式时,不要直接 load 本 skill 也不要假定走文档,先反问一句"这份要做成 PPT 演示稿,还是 Word/Markdown 文档?" 用户确认 PPT 后再 load。
---
# PPT
# PPT(SVG-first)
把材料变成可演示的 .pptx。**先定调(spec + 逐页大纲),再出稿(一个脚本建整 deck),再验收(quality_check)** —— 方向在大纲阶段对齐,不在逐页阶段反复来回。
把材料变成**可演示、可编辑**的 .pptx。
进度展示建议:多页 deck 任务用 `task_progress` 标记「摄取素材 / 八条对齐 + 逐页大纲 / 图标预取 / 脚本建 deck / 质量检查 / 交付」等关键阶段;不要把每一页的内部写入都作为进度步骤。
**核心管线**:`素材 → 策略(spec)→ [配图] → 执行(逐页手写 SVG)→ SVG 质检 → 后处理 → 渲图验收 → 导出 PPTX`(验收在导出**之前**;导出边界有硬门,没验收过的 deck 拒绝产出 pptx)
> **为什么是 SVG**:不再用 python-pptx 拼固定版式件(那是版面单调/AI 味的天花板)。AI 把每页当**矢量设计稿手写成 SVG**(设计自由度 = 浏览器级),再由纯 Python 转换器逐元素译成**原生可编辑的 DrawingML**(形状/文本/渐变都能在 PowerPoint 里选中改)。SVG 与 DrawingML 是同一套"绝对坐标 2D 矢量"世界观的两种方言,转换是翻译而非格式硬凑。详见 `references/shared-standards.md`
> 进度展示:多页 deck 用 `task_progress` 标记「摄取素材 / 八条对齐 + 逐页大纲 / [配图] / 逐页 SVG / 质检 / 渲图验收 / 导出」等关键阶段;不要把每页内部写入都当进度步骤。
## 资源
- `scripts/pptx_helpers.py` —— **卡片式视觉工具箱模块**:配色/字体常量 + 派生明暗色阶(`PRIMARY_WASH/SOFT/DARK`)+ 语义色 `GOOD/BAD` + `new_presentation`/`set_palette` + **组合版式件**(一个函数摆一整块):`add_card_grid`(均衡网格)/`add_timeline`(时间轴)/`add_cycle`(流程闭环)/`add_toc`(目录)/`add_kpi`(数字卡,带 baseline+delta)/`add_takeaway`(结论框)/`add_source`(数据来源)+ 质感件 `add_card`(圆角卡,**默认平卡**)/`add_gradient_rect`/`add_icon_tile`/`add_pill`/`add_eyebrow`/`add_picture_bg`(混合背景)+ `add_notes`(演讲者备注)+ 基础件 `add_textbox`/`page_title`/`apply_brand`。`import pptx_helpers as P` 调用,**不默写源码**。⚠️ helper 的 `name=` 会写进形状名,quality_check 靠它判标签/bullet
- `references/design_principles.md` —— **§信息设计纪律(论断标题/Takeaway/数据语境化/page_rhythm)** + 画布/字号/配色/投影克制/字数预算等硬规则。**先读这节**
- `references/layouts.md` —— 13+ 版式与组合件调用示例 + helper API 速查 + 安全区保护
- `references/icons.md` —— 业务图标两层:Iconify (在线/本地缓存) / unicode 字形兜底
- `assets/icons/` —— **只读**种子图标库 (商务红 tabler 集,见 `INDEX.md`;新拉的图标写 `<task_dir>/assets/icons/`)
- 素材摄取: 用 `markitdown` CLI 把 PDF/DOCX/PPTX/XLSX/HTML/URL 转干净 Markdown,落到 `<task_dir>/source/<name>.md`
- `scripts/fetch_icon.py` —— 从 Iconify CDN 拉 SVG/PNG (染主题色;**PNG 转换需 cairosvg/svglib,没装会只出 SVG** —— 优先用种子库现成 PNG)
- `scripts/render_icon.py` —— unicode 字形 → 透明 PNG (Iconify 没有时兜底)
- `scripts/render_bg.py` —— 无头 Chrome 把主题化 HTML 渲成**杂志级背景 PNG**(混合方案:封面/章节背景图 + 其上原生可编辑文字)
- `scripts/pptx_preview.py` —— **把 .pptx 渲成 PNG 预览**(无头 Chrome),交付前**肉眼验收版面**(quality_check 查结构,预览查观感;能抓到多行不上色这类渲染 bug)
- `scripts/quality_check.py` —— 产物 .pptx 结构验收 (越界 / 文本溢出 / 按列 bullet / 按色系三色制 / 重叠)
## 默认主题 — 商务红 (硬约束)
**脚本**(host 上用 `.venv/Scripts/python.exe <skill_dir>/scripts/xxx.py ...` 跑;`<skill_dir>` = 本 skill 绝对路径):
- `svg_quality_checker.py` —— **SVG 结构质检**(禁用特性 / viewBox / spec_lock 漂移 / 配色越界等)。引擎,自包含
- `finalize_svg.py` —— **SVG 后处理**(图标内嵌 / 配图裁切内嵌 / tspan 展平 / 圆角矩形转 path)→ 产出 `.build/svg_final/`(隐藏、可再生)
- `svg_to_pptx.py` —— **SVG → 原生 PPTX**(逐元素译 DrawingML;默认嵌演讲者备注 + Office 兼容 PNG 兜底)
- `total_md_split.py` —— 把 `notes/total.md` 拆成逐页备注(导出前跑)
- `update_spec.py` —— 改 `spec_lock.md` 的颜色/字体后,**一键传播到所有已生成 SVG**(改稿用)
- `svg_preview.py` —— **无头 Chrome 把 SVG 渲成 PNG** 供肉眼/vision 验收(SVG 是视觉真相;**替代**了浏览器 live preview);渲 project 目录时同步登记 `.build/acceptance.json` 验收记录(每页源 sha1 + verdict)
- `accept_pages.py` —— 看完 PNG 后**标记每页验收结论**(`--pass`/`--pass-all`/`--fail --reason`);标 pass 要求"渲过图 + 渲后源没改",导出 gate 只认 pass 页
- `project_utils.py` / `error_helper.py` —— 引擎辅助(canvas 校验 / 友好报错),被上面脚本 import,不直接调
**主色 `#C00000` / 辅色 `#E15554` / 强调色 `#FFC107`。**
**设计知识(references/,先读相关的,不默写)**:
- `shared-standards.md` —— **SVG→PPT 硬约束(禁用特性清单 / XML 良构陷阱 / 字体栈纪律)**,执行前**必读**
- `executor-base.md` —— 执行通则(模板继承 / 逐页 spec_lock 重读 / 字号纪律 / 内容→版式)
- `strategist.md` —— 策略通则(八条对齐内容 / 配色派生 / 字号阶 §g / 配图意图 §h / spec 产出);**注:其中"Confirm UI 浏览器确认页"机制在 zcbot 里用聊天确认替代,只取其设计判断**
- `image-layout-patterns.md` / `image-layout-spec.md` / `svg-image-embedding.md` —— 图文版式 72 式 + 并排尺寸算法 + 配图嵌入规范
- `canvas-formats.md` —— 画布格式(viewBox / 安全区)
- `modes/`(5 种叙事骨架:pyramid/narrative/instructional/showcase/briefing)+ `visual-styles/`(**19 种视觉风格**:editorial/swiss-minimal/glassmorphism/dark-tech/data-journalism/…)—— **去 AI 味的关键**,执行时按 spec 锁定的那一个读
- `animations.md` —— 导出动画(可选,默认只翻页淡入、无逐元素动画)
**不允许擅自换色**。除非满足以下任一条件,否则 spec 必须填这套红色:
- 用户在请求里**明确**点名其它配色 (例:"做成蓝色"、"用我们公司的紫色")
- 用户提供素材里有明确的 brand guideline / 配色卡
**模板库(templates/,opt-in,默认自由设计不读)**:
- `layouts/`(版式模板)/ `decks/`(整套替换:中汽研/招商银行/重庆大学等)/ `brands/`(品牌身份)/ `charts/`(71 个图表/信息图 SVG)—— 索引见各自 `*_index.json`
- `icons/` —— **5 套图标库**(tabler-outline/tabler-filled/chunk-filled/phosphor-duotone/simple-icons,共 1.1w+)。executor 写 `<use data-icon="<lib>/<name>">`,finalize 自动从这里内嵌(默认目录,无需预取);锁 inventory 前用 `ls templates/icons/<lib>/ | grep <关键词>` 验名
- `design_spec_reference.md` / `spec_lock_reference.md` —— **spec 产出骨架**,策略阶段写 spec 前必读
**禁止的自我合理化**(都属违规):
- "这个场景蓝色更专业" / "学术汇报红色不合适" / "财务用蓝更稳重"
- "我觉得 XX 主题更适合"
要换色,**先问用户**,不要在 spec 里塞自己的偏好。其它备选见 `design_principles.md §2`
## 两阶段工作流
### 阶段一: 策略 (Strategist) — 八条对齐
产物:**task 级 spec 文件** —— 整个 deck 的"宪法",阶段二每页前都要重读。文件路径按 system prompt 的《task 级「宪法」文件命名约定》:
<task_dir>/<today>-<task_short_id>-<task_name>.spec.md
`<today>` / `<task_short_id>` / `<task_name>` 用 system prompt 注入的实际值替换。
**0. 先检测已有 spec**:
```
glob <task_dir>/*-<task_short_id>-*.spec.md → 按文件名字典序排,取最大者作 current
```
(按 short_id 主锚,name 部分不参与匹配 — 用户改过 task name 时旧文件仍能定位)
- 有 current(当前 task 已有 spec) → 展示给用户,问「**沿用进阶段二** / **重定调**(以 today 写新版,旧版保留)」,⛔ BLOCKING 等用户决定
- 仅有其它 task 的(`*-<别的 short_id>-*.spec.md`)→ 不当 current 用,继续走下面流程
- 完全没有 → 直接走下面流程
按下表**一次性给出推荐方案**,然后 ⛔ **BLOCKING:等用户确认/修改后才能进阶段二**。不要一条一条问。
| # | 项 | 默认值 |
|---|----|-------|
| 1 | 画布 | **16:9** (13.33×7.5 in) |
| 2 | 页数 | **封面 + 5-8 页正文 + 尾页(Q&A)** = 共 7-10 页。**封面 / 尾页强制必有**,不在 5-8 页预算里 |
| 3 | 受众 | 看材料推断:领导汇报 / 同行评审 / 客户 pitch |
| 4 | 风格 | **现代简约** (白底 + 细线 + 留白) |
| 5 | 配色 | **商务红** `#C00000` `#E15554` `#FFC107` (见上"默认主题") |
| 6 | 字体 | **微软雅黑 + Arial** |
| 7 | 图标 | **Iconify `tabler` 集** (描边商务图标,主色染色;`fetch_icon.py` 拉到 `<task_dir>/assets/icons/`;业务概念页用 `add_icon_tile` 配图标底块) |
| 8 | 图表 / 配图 | 数据 ≥ 3 个点 → matplotlib 图(或 ≤4 个数字直接上 KPI 卡 L10);**真实配图 opt-in**:封面/章节/图片页可走 imagegen 生图(**每张 ¥0.22**,默认不开,要用在大纲里标 `[img]` 并经用户确认) |
把这 8 项写进上面那个 task 级 spec 文件,以表格形式给用户预览,问一句"按这个开干?"。**spec 写定后不再改**(要改就走 §0 的「重定调」分支,以 today 为前缀写新版,旧版保留)。
**8 项之外,spec 还要含一张「逐页大纲」表** —— 阶段二一个脚本建整 deck 的输入,也是替代"逐页确认"的前置 checkpoint。**标题写论断、每页标节奏**(见 design_principles §信息设计纪律):
| 页 | 节奏 | 版式 | **论断式标题** | 核心信息 / Takeaway | 图标 / 图表 / 配图 |
|---|---|---|---|---|---|
| 1 | anchor | L1 封面 | <主标题> | <副标题 / 定位> | 可选 `[img]` 主图 |
| 2 | anchor | 目录 | 目录 | <5 + 各一句副标> | — |
| 3 | dense | 卡片网格 | "大模型靠规模涌现出通用智能" | <3-5 概念 + 一句 takeaway> | `brain`/`cpu`/… |
| 4 | dense | 时间轴 | "六年能力指数跃迁" | <里程碑 + takeaway + 来源> | — |
| 5 | **breathing** | 大字页 | "2 个月,月活破亿" | <单个大数字 + 一句语境对比> | — |
| … | … | … | … | … | … |
| N | anchor | 尾页 | 致谢 / Q&A | <联系方式> | — |
> **三条硬纪律(大纲阶段就定死)**:
> - **论断标题**:标题列写"结论"不写"主题"("渗透率破 60%" 不是 "行业背景");
> - **节奏不雷同**:相邻内容页不同版式;**每隔 2-3 页插一个 `breathing` 页**(大字/金句/整图,禁卡片网格)打破"全卡 = AI 味";**卡片网格全 deck ≤2 次**;
> - **内容→版式映射**:历程→时间轴、循环→闭环、2-4 数字→KPI 卡(带对比基准)、并列概念→均衡网格、单震撼数字→breathing 大字。
>
> 内容页正文优先压成一句 **Takeaway 结论**;含数据的页要有**对比基准 + 来源**。版式见 layouts.md §选版式速查。配图页标 `[img]` + 一句画面。
大纲连同 8 项一起给用户预览,**BLOCKING 等用户确认整份结构**(页数、每页讲什么、节奏、版式)后再进阶段二。用户在这一步推翻方向 = 改表格文字,零 slide 返工。
### 阶段二: 执行 (Executor) — 一个脚本建整 deck
方向已在阶段一的「逐页大纲」里跟用户对齐过,执行阶段就是把大纲机械落成 slide。**不逐页 run_python**(每页一轮来回烧轮数/token);整 deck 在一个脚本、一个进程内构建,坐标天然一致(`pptx_helpers` 已把画布常量统一,漂移问题已解决)。
流程:
1. **读 current spec**(按 §0 的 glob 规则拿字典序最大那份),含 8 项 + 逐页大纲;只用里面定的颜色/字体/图标/页结构,**不凭记忆发挥**。
2. **图标批量预取(全 deck 一次,不逐页)**: 把大纲里所有页需要的图标概念汇总,`glob` 两处看现成 —— 种子库 `<skill_dir>/assets/icons/`(只读)+ 本 task `<task_dir>/assets/icons/`;缺的在**一个 `run_python` 里批量** `fetch_icon.py <name> --set tabler --color C00000 --size 128 -o <task_dir>/assets/icons/...` 拉齐。**几何形状(圆点/徽章/装饰线)不算图标,走 layouts.md helper**。
3. **真实配图(opt-in,仅当大纲标了 `[img]`)**: 把标 `[img]` 的页(封面/章节/图片页)汇总,**load `imagegen` skill 走它自己的确认流程**逐张生成(每张 ¥0.22,有强制确认门,不要绕过),产物落 `<task_dir>/figures/`;build_deck 里 `add_picture(<figures 路径>)` 引用。**没标 `[img]` 的 deck 跳过这步**,图标/卡片/渐变已足够撑视觉。
4. **混合背景(opt-in)**:封面/章节想要杂志级背景时,`run_python` 调 `render_bg.py --out <task_dir>/figures/cover_bg.png --kind cover --primary <主色>`(+ section),build_deck 里 `P.add_picture_bg(slide, bg)` 铺底再叠**白色**文字。**背景图不可编辑、文字可编辑**——这是 editable 前提下的最高观感。
5. **写 `build_deck.py` 到 `<task_dir>`,一次建整 deck**: 顶部 `import pptx_helpers as P``P.new_presentation``P.set_palette(spec_path=...)`**按大纲循环每页**(每页一个小函数)→ 末尾 `prs.save`。落实**信息内功**(见 design_principles §信息设计纪律):
- **论断式标题**(写结论)+ 内容页 `P.add_takeaway(slide, "<一句话结论>")`;
- 含数据用 `P.add_kpi(..., baseline=, delta=)` + `P.add_source`;**数字别孤立**;
- **节奏**:按大纲的 anchor/dense/breathing 落版式,breathing 页走大字/金句/整图(**禁卡片网格**);
- **投影克制**:平铺网格卡用 `add_card`(默认平卡),投影只给悬浮/被挑出的卡,每页 ≤2-3 个;
- 每页 `P.add_notes` 写 2-4 句**结论先行的口语**演讲稿。
helper 一律 `P.xxx` 不默写源码;版式见 layouts.md。先 `write` 脚本再 `run_python(script_path=...)`
6. **quality_check + 预览双验收**(见阶段三)→ 按报告**改 `build_deck.py` 重跑**(不逐页 edit 成品)。
7. 报整份 deck:页数、各页版式/节奏、用到的图标/配图;问用户要不要改。
8. 用户确认了**实质改动**后,追加一行到 `<task_dir>/REVISIONS.md` —— 见 §修订日志。
**风格探针(可选,降视觉返工险)**: 用户对观感没底、或这是全新风格时,可先只建**封面 + 1 内页**给用户看一眼,确认后把 `build_deck.py` 的页范围放开重跑补齐其余页 —— 仍是改一个脚本,不退回逐页。用户要快("直接全做")就跳过探针,整 deck 一把出。
**为什么不再逐页?** 逐页的两个理由都已消解:① 防坐标漂移 → `pptx_helpers` 模块化已解决;② 早发现方向问题 → 前移到阶段一「逐页大纲」确认(改文字比改 slide 便宜),视觉观感由可选探针 + 整 deck 后批改兜底。代价是放弃"逐页即时纠错",换来 N 页从 ~2N 轮降到 ~3-4 轮。
### 阶段三: 验收 (结构 + 观感 双验)
**① 结构验收** `quality_check.py`(越界/溢出/三色/重叠):
```bash
python <skill_dir>/scripts/quality_check.py <task_dir>/<output.pptx> --spec <task_dir>/<today>-<task_short_id>-<task_name>.spec.md
```
**② 观感验收** `pptx_preview.py`(渲成 PNG **肉眼看版面**)—— quality_check 查不出"好不好看 / 文字层级 / 留白 / 多行文本掉色"这类问题,**交付前必须渲几页关键页用 `read` 亲眼过**:
```bash
python <skill_dir>/scripts/pptx_preview.py <task_dir>/<output.pptx> -o <task_dir>/preview --pages 1,3,5
```
看封面、一个内容页、breathing 页是否如预期(标题层级、卡片是否过挤/过空、文字是否都正常上色、节奏是否单调)。
两项不通过的,**改 `build_deck.py` 重跑**(改源脚本可复现;不要直接 edit 成品 .pptx)。
## 设计原则 (硬规则速查)
- **每页一个核心信息**: 一页讲一件事,塞两件就拆页
- **内容装进卡片**: 内容页主力容器是 `add_card`(圆角+柔和投影),白底之上靠卡片浮起分层,别让元素裸贴白纸
- **概念配图标底块**: 业务概念(能力/模块/策略)用 L11 卡片网格 + `add_icon_tile`,**别只摆圆点 bullet**(视觉太单薄)
- **数字上 KPI 卡**: 2-4 个关键数字用 L10 `add_kpi`,优先于硬画柱状图;单个震撼数字用 L13
- **bullet ≤ 5 条/列**: 单列超过就拆页或改卡片网格;双栏对比左右各 ≤5
- **正文不写完整段落**: 列要点;长句留给演讲者口述(写进 `add_notes`)
- **数据 ≥ 3 个点应有图表**: matplotlib 生成 .png 嵌入(或转 KPI 卡)
- **中文标题 ≤ 30 字**
- **配色三色封顶 + 派生阶**: 主 + 辅 + 强调三色系,浅底/卡片底走 `set_palette` 自动派生的 `PRIMARY_WASH/SOFT`,不算新色
- **渐变只用在大色块**: 封面/章节用 `apply_brand` 内置渐变;渐变深底上文字一律用白/`ACCENT_SOFT`
- **每页演讲者备注**: `add_notes` 写 2-4 句口述要点(正式产物标配)
- **Shape 不能越界**: helper 内置 `assert_inside` 生成时即报错
- **字数按预算来**: 写 bullet 前查 `design_principles.md §4.1` 字数预算表;卡片内按"卡宽 - 0.8"算框宽
- 详细规则见 `references/design_principles.md`
**素材摄取**:用 `markitdown` CLI 把 PDF/DOCX/PPTX/XLSX/HTML/URL 转 Markdown,落 `<project_dir>/sources/<name>.md`
## 工作目录约定
下文 `<task_dir>` = system prompt 里「task_dir」给的**绝对路径**(host 下形如 `…/workspace/users/<uid>/<wd>/`,docker 沙盒里是 `/workspace/<wd>/`)。**所有产物都写到 task_dir 下**,不要写到 cwd / `skills/` / repo 根;图标分两处:skill 自带的**只读种子库**走 `<skill_dir>/assets/icons/`(docker 沙盒里 skills 只读,只读不写),`fetch_icon.py` 新拉的图标写 `<task_dir>/assets/icons/`(详见 references/icons.md §A)。
`<task_dir>` = system prompt 注入的绝对路径。**每份 deck 用一个独立 project 目录** `<project_dir> = <task_dir>/<deck_slug>/`(`deck_slug` 按主题取,多 deck 不撞)。引擎契约文件(`design_spec.md`/`spec_lock.md`)和各产物子目录都在 `<project_dir>` 下:
```
<task_dir>/
├── source/ # markitdown 转出的素材(同 working_dir 多 task 共享;用 markitdown -o <task_dir>/source/<name>.md)
├── <today>-<task_short_id>-<task_name>.spec.md # 八条对齐落定,task 级宪法;命名见 system prompt 约定;按 short_id 主锚,重定调时写新日期,旧版保留
├── slides/ # 各页 matplotlib 图表 (chart_p3.png 等),多 task 时文件名前缀区分
├── figures/ # imagegen 生成的真实配图 (opt-in;封面/章节主图),由 imagegen skill 落盘
├── assets/icons/ # fetch_icon.py 新拉的主题色图标(种子库在 skill 只读侧)
├── build_deck.py # 整 deck 构建脚本(一次建完所有页);改稿/修 quality_check 项都改它重跑
├── REVISIONS.md # 修订日志:每次卡点用户确认的实质改动,见 §修订日志
└── <topic>.pptx # 最终产物 (按主题命名,多 task 时主题必须不同)
<project_dir>/
├── sources/ # markitdown 转出的素材
├── design_spec.md # 人读:设计叙事(受众/风格/配色理由/逐页大纲)——引擎契约之一
├── spec_lock.md # 机读:执行锁(HEX/字体栈/图标/图片清单/page_rhythm/page_layouts)——executor 每页重读
├── images/ # 配图(imagegen 生成 / 用户提供 / 公式 PNG);SVG 里用 ../images/ 引用
├── templates/ # 仅当用户给了模板路径才有(模板 SVG + 其 design_spec)
├── icons/ # 可选:项目本地图标(没有则 finalize 回退到 skill 的 templates/icons/)
├── svg_output/*.svg # ★ executor 逐页手写的 SVG(视觉真相、改稿对象)—— 唯一可见的 svg 目录
├── notes/total.md # 演讲者备注(逐页),total_md_split 拆分后导出嵌入
├── exports/<slug>_<ts>.pptx # ★ 最终产物(原生 DrawingML,可编辑)
├── REVISIONS.md # 修订日志(见 §修订日志)
└── .build/ # 可再生构建产物(dotfile 隐藏、随时可删;用户文件列表看不到)
├── svg_final/ # finalize 产出(图标/配图已内嵌,自包含;供 legacy 导出 + 忠实预览)
├── preview/ # svg_preview 渲的验收 PNG
├── acceptance.json # 渲图验收记录(每页源 sha1 + verdict;导出 gate 依据)
└── backup/latest/svg_output/ # SVG 源快照(只留最新一份,可不跑模型重新导出)
```
## 修订日志 (REVISIONS.md)
**所有产物写 `<project_dir>` 下**,不写 cwd / `skills/` / repo 根。**可见面 = 源 + 交付物**(sources/images/svg_output/notes/exports + 两个 spec + REVISIONS);派生的中间物(svg_final/preview/backup)一律进 `.build/`,由脚本自动落位,**不要手动在根目录建 svg_final/preview/backup**。
`<task_dir>/REVISIONS.md` 是产物迭代过程的紧凑可读 changelog。**spec 是宪法(定调一次),REVISIONS 是实施日志(每次卡点累加)** —— 两份独立但互参,后期 review / 复盘 / 跨周回看"上周这页为啥改成这样"靠这份。
## 默认主题 — 自由设计(content-driven)
### 何时记 / 何时不记
**默认不锁死配色**:策略阶段根据**内容 + 受众 + 选定的 visual_style** 派生一套协调配色与版式(在 spec 阶段给用户 ≥3 个配色/风格候选挑)。模板是地板也是天花板 —— 默认自由设计让版面跟着内容走,而非被固定语汇框死。
- 商务红 `#C00000` / 中建材等品牌色,作为**候选之一**;**中文政企/集团/科研商务汇报默认就把商务红列进 ≥3 配色候选**(见 strategist.md §e)。用户点名("做成蓝色 / 用我们公司紫色")或素材里有 brand guideline → 按其锁定。
- 现成一款 **`business-red` 商务红品牌预设**(`templates/brands/business-red/`:#C00000 全色表 + 宋体标题 + 实心图标);用户说"红色 / 商务红 / 中建材风"→ 指给他按路径 opt-in,或直接锁其配色。其它品牌/模板同理:**用户给 `templates/` 下明确路径才触发**(见 strategist.md 模板分发),不主动猜、不模糊匹配。
- **例外(主动提示):中国建材总院系汇报** —— 受众/素材/用户机构指向 **中国建筑材料科学研究总院 · 中国建材(CNBM)系**(工作汇报/立项/项目评审/**职称评审**/品牌宣讲)时,策略阶段**主动**把整套品牌模板 `templates/layouts/zongyuan_red/`(八边形 logo + 品牌红 `#D7000E` + 总部大楼实景铺底,5 页齐)作为候选点名给用户,用户点头再按明确路径套入(见 strategist.md §e "中国建材总院" 提示)。这是唯一鼓励主动提模板的场景;其余仍等明确路径。
---
## 阶段一:策略(Strategist)—— 八条对齐 + 逐页大纲,产出 spec
**先读** `references/strategist.md`(取其设计判断)+ `templates/design_spec_reference.md` + `templates/spec_lock_reference.md`(产出骨架)。
**0. 先检测已有 spec**:`glob <task_dir>/*/spec_lock.md`。
- 当前 task 已有 project → 展示给用户,问「**沿用进阶段二** / **重定调**(新建 project 目录,旧的保留)」,⛔ BLOCKING 等决定。
- 没有 → 走下面。
**八条对齐(ah)**——按下表**一次性给推荐方案**(默认自由设计),然后 ⛔ **BLOCKING:等用户确认/修改**。不要一条条问。zcbot 走**聊天确认**(不开浏览器 Confirm UI),内容与 strategist.md 的 ah 一致:
| # | 项 | 默认 |
|---|----|------|
| a | 画布 | **16:9**(viewBox `0 0 1280 720`)。其它见 canvas-formats.md |
| b | 页数 | **独立拍板项(见下方「页数 gate」)**:按内容量 × 投递目的推**一个具体数字**(如「建议 10 页」),不甩「常 815」这种区间就想过;**封面 + 正文 + 尾页** |
| c | 受众 + 核心信息 + 投递目的 | 看材料推断受众;投递目的 `text`(读)/`balanced`(商务,默认)/`presentation`(演讲)定正文字号与密度 |
| d | mode + visual_style | mode 选 5 骨架之一;**visual_style 给 ≥3 个候选**(safe/shifted/bold)让用户挑 —— 这是观感主轴 |
| e | 配色 | 按 visual_style + 内容**派生 ≥3 套候选**(每套含 bg/primary/accent/text…);自由设计默认 |
| f | 图标 | 选 1 个库(tabler-outline 等),stroke 库要定 stroke_width;**锁 inventory 前 `ls templates/icons/<lib>/|grep` 验名** |
| g | 字体 + 字号 | CJK+Latin 字体栈(栈尾必须是预装字体,见 shared-standards §字体);正文字号按投递目的一个定值;公式策略 mixed/render-all/text-only |
| h | 配图 | `none`/`ai`(走 imagegen skill)/`provided`/`placeholder`;ai 要定 image_rendering + image_palette(deck 级锁)。**用户没给图时别默认整本 none**:封面/分节/概念/氛围页主动把 `ai` 配图作为候选提给用户(数据/列表/流程页仍走图表→§VII,不配装饰图);提议免费,只有用户确认后 imagegen 才花钱(成本门见阶段二)。见 strategist.md §h |
> 🔒 **页数 gate(不可默认放行)**:页数是**唯一必须拿到用户明确数字**才能往下走的项。给完 ah 推荐后,若用户只回笼统的「可以 / OK / 你定」而**没给出、也没逐字认可一个具体张数**,⛔ **必须单独再追问一句「这份就定 N 页,可以吗?」** —— 拿到明确整数(用户报的数,或对你推荐数的显式点头)后,才用这个数去写逐页大纲。**禁止**把区间中位数(如 ~12)当默认值自行敲定、绕过用户。**唯一例外**:用户明确说「页数你随意 / 不重要 / 你定就行」时,按你的推荐数走、不再追问(但仍要在预览里写出这个数,让用户有机会否掉)。逐页大纲的页数 = 已确认的这个数,一页不多一页不少(封面 + 正文 + 尾页含在内)。
**逐页大纲**(写进 design_spec.md §IX,也是 spec_lock 的 page_rhythm/page_layouts 依据):**论断式标题 + 每页标节奏**(`anchor`/`dense`/`breathing`)。三条硬纪律(大纲阶段定死):
- **论断标题**:写结论不写主题("渗透率破 60%" 不是 "行业背景");
- **节奏不雷同(整本 ≤2 次)**:相邻内容页不同版式,且**同一版式原型全 deck 最多 2 页**(图标卡网格 / 全宽横条列表 / **两栏裸文字列表**(图标小标题+下划线+文字堆 ×2、零图形 —— 一次真实交付里出现了 4 页)尤其;5 页"2×3 图标卡"哪怕文案不同也读作同一张片重复,真实翻车过);第 3 页起换形态(时间轴/分层/象限/流程/hub-spoke/图表)。narrative 真正停顿处插 `breathing`(单概念/金句/大图,**禁多卡网格**);不要为凑节奏造填充页;素材含 ≥3 组可比数值(规模/占比/趋势/阶段目标)→ **全本至少 1-2 页真数据图表**(bar/line/donut/进度条),大字 KPI 是强调不算图表,零数据图表要在 spec 写明理由;
- **内容→版式映射(必须落到 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 便宜)。
**确认后产出两份引擎契约**(按骨架填,**只填实际用到的行**):
- `<project_dir>/design_spec.md` —— 人读叙事(IXI 节,见 design_spec_reference.md)
- `<project_dir>/spec_lock.md` —— 机读执行锁(canvas/**layout_grid**/mode/visual_style/colors/typography/icons/images/page_rhythm/page_layouts/page_charts/forbidden,见 spec_lock_reference.md)。**executor 每页重读它**,是长 deck 抗漂移的命门。`layout_grid`(margin_x/content_top/footer_y/gutter)是跨页对齐的锚 —— 手写绝对坐标没有锁定基线必漂,质检会硬卡偏离网格 215px 的"想对齐没对齐"。
> 公式策略 mixed/render-all 且有公式 → 写 `images/formula_manifest.json` 后渲染(ppt-master 的 latex_render 未搬;zcbot 可用现有公式渲染或转图后按 `images` 行登记)。
## 阶段二:配图(条件触发)
**仅当 spec §VIII 有 `ai` 行**:把要 AI 生成的配图汇总,**load `imagegen` skill 走它自己的成本确认流**逐张生成(有强制确认门,不要绕过),产物落 `<project_dir>/images/`。`web`/`provided`/`placeholder`/`none` → 跳过本阶段。
> ppt-master 自带的 image_gen.py / image_search.py 配图子系统**未搬**;zcbot 统一走 imagegen skill。spec 的 §VIII 图片清单格式照用,只是获取机制不同。
## 阶段三:执行(Executor)—— 逐页手写 SVG
**先读**(按本 deck spec_lock 锁定值):
```
references/executor-base.md # 执行通则
references/shared-standards.md # SVG/PPT 硬约束
references/modes/<locked-mode>.md # 锁定的叙事骨架
references/visual-styles/<locked-style>.md # 锁定的视觉风格
```
只读锁定的那一个 mode + 一个 visual-style,别 glob 整个目录。
**纪律(来自 SKILL 全局 + executor-base,务必遵守)**:
1. **逐页串行手写,不批量、不脚本生成**:每页由当前主 agent 在同一上下文里手写 SVG;**禁止写循环脚本批量产 SVG**(跨页视觉一致性靠逐页带上游上下文,生成器做不到),也不要 5 页一组。
2. **每页前重读 `spec_lock.md`**:颜色/字体/图标/图片只能来自它;查本页 `page_rhythm`/`page_layouts`/`page_charts`;坐标吸附 `layout_grid`(左缘=margin_x、正文顶=content_top、并排卡片同 top 同高等 gutter,打破网格要 ≥16px 干净地打破,不许差几 px 的"差不多" —— 对齐纪律详见 executor-base §3)。抗上下文压缩漂移。
3. **模板供结构不供皮**(非 mirror):继承几何/标签位置/编码逻辑,**重新上 visual_style + spec_lock.colors 的皮**;字号按 spec_lock 角色锁定值,不继承模板占位字号。
4. **图标(锁了就必须用,非可选装饰)**:spec_lock 有 `icons.library` + 非空 `inventory` 时,**每个内容页必须放 13 个 inventory 内的图标**(KPI/列表/流程/对比/特性网格版式尤其要,常一卡一图标)——自由设计没有模板可继承图标,只能逐页手写 `<use data-icon>` 才有图标。封面/纯排版分节页/单数字·金句 breathing 页/尾页可不放。写法:`<use data-icon="<lib>/<name>" x= y= width= height= fill= [stroke-width=]>`,name 必须在 inventory 内、文件在 `templates/icons/<lib>/`。**质检会硬卡**:锁了 inventory 但全 deck 0 图标 → error 退非零(见阶段四)。
5. **配图**:`<image href="../images/<file>">`,croppable 用 `preserveAspectRatio="xMidYMid slice"`,`| no-crop` 行用 `meet`;意图与版式见 image-layout-*。
逐页写到 `<project_dir>/svg_output/<NN>_<page>.svg`。**演讲者备注**写 `<project_dir>/notes/total.md`(每页 24 句结论先行口语)。
## 阶段四:SVG 质检(强制门)
```
.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 页同版式指纹(单调门,含两栏裸文字列表)** 等)必须改:回阶段三重写该页再跑**,不放过。
- `warning`(低分辨率图 / 非 PPT 安全字体等):能顺手改就改,否则知会后放行。**例外:`Geometry:` 开头的文字重叠 warning 不许无视** —— 它给了精确坐标,是"大字压说明 / 同行文字互侵"的高嫌疑点(估宽无法区分擦边与压字,所以只报 warn),阶段五渲图时**必须对着该页该坐标专门看**,压了就返工。
- 跑 `svg_output/`(不要在 finalize 后跑 —— finalize 改写 SVG 会掩盖源级违规)。
- ⚠️ **别用 `| head` / `| tail` 截断质检输出**:管道会把脚本的非零退出码换成 `head` 的 0(门形同虚设),`head` 还会截掉打在**最后**的 deck 级门结论(如零图标 `[ERROR]`)。原样跑,读完整输出、认它的退出码。
- 跳过本阶段没有意义:导出边界会**自动复跑同一套逐页硬错误检查**(见阶段六质检门),error 到那里一样拒绝导出 —— 在这里主动跑并连警告一起读,能更早返工。
## 阶段五:后处理 + 渲图验收(强制门)—— 全量,不抽查
⚠️ 三步**一步步来**,别合并成一条命令:
```
# 5.1 SVG 后处理(图标/配图内嵌 / 文本展平 / 圆角转 path)
.venv/Scripts/python.exe <skill_dir>/scripts/finalize_svg.py <project_dir>
# 5.2 全量渲图(渲 .build/svg_final,同步登记 .build/acceptance.json 验收记录)
.venv/Scripts/python.exe <skill_dir>/scripts/svg_preview.py <project_dir>
# 5.3 read/look_at_image 逐页过目后,标记验收结论
.venv/Scripts/python.exe <skill_dir>/scripts/accept_pages.py <project_dir> --pass-all
# (有问题的页:--fail <页名> --reason "…";只标部分页:--pass <页名>;看状态:--status
```
- **默认渲整本,不带 `--pages`**。抽查 3 页只能覆盖 3 页,错位/文字溢出/元素重叠恰恰藏在没看的那些页里 —— 逐页手写绝对坐标,每页都可能翻车,所以**每页都要过目**。(页数多时可分批渲,但目标是 100% 覆盖,不是采样。)
- `read` / `look_at_image` **逐页**亲眼过:标题层级、卡片过挤/过空、**文字是否溢出卡片/被裁**、**元素是否重叠错位**、**并排元素顶/底是否对齐、与上一页对比左缘/内容顶线是否一致**(跨页一致性只有连续翻看才看得出)、图标在不在(位置对不对)、节奏是否单调(连续几页同为卡片墙就该返工换形态)、配图位置。**看完才许标 pass** —— `--pass-all` 是"每页都看过且都合格"的宣告,不是跳过看的快捷键。
- 🚧 **差评即阻断 + 返工回路**:任一页有排版/溢出/重叠/半成品问题(哪怕只是封面)→ **改那一页 svg_output 的 SVG → 重跑 finalize → `svg_preview.py <project_dir> --pages <N>` 重渲该页 → 复看 → 再标 pass**。机制会强制这个回路:标 pass 和导出 gate 都校验"渲图之后源文件没再改过"(sha1),改了不重渲重看,gate 过不去。不许"看了一页差评、跳去看下一页好评就收尾"——那正是错位交付的来路。
- ❌ **禁止盲改**:修错位/补图标不许写脚本批量 regex 插元素、改完不看渲染结果(真实事故来源:质检提示缺图标后 regex 批量盲插,图标全压在文字上交付)。每处修改都要走上面的返工回路落到"复看"。
> svg_preview 渲的是 SVG(视觉真相,与导出的 pptx 1:1),比渲最终 pptx 更早更准暴露观感问题。需要校验"SVG→DrawingML 转换是否保真",再开导出的 pptx 在 PowerPoint 里看。
## 阶段六:导出
```
# 6.1 拆备注
.venv/Scripts/python.exe <skill_dir>/scripts/total_md_split.py <project_dir>
# 6.2 导出原生 PPTX(默认嵌备注 + Office 兼容 PNG 兜底)
.venv/Scripts/python.exe <skill_dir>/scripts/svg_to_pptx.py <project_dir>
# 产物:exports/<slug>_<ts>.pptx(原生,读 svg_output/)+ .build/backup/latest/svg_output/(源快照,只留最新)
```
- 🚧 **导出边界质检门(硬,无豁免参数)**:导出前自动复跑阶段四质检的逐页硬错误(禁用特性 / 坏 XML / 图片文件缺失 / 图标压字·出画布几何错误等),**有 error 直接拒绝导出**。没有任何 `--allow-*` 能绕过 —— 这些是真缺陷,回 svg_output 修完再来。
- 🚧 **导出边界验收门(硬)**:spec_lock 存在时,**每页都必须渲过图(svg_preview)、且渲图后源未再改动、且 verdict=pass**。分两层:**"从没渲过 / 渲后又改 / finalize 前渲的"没有任何 CLI 逃生口**(渲图很便宜,没有理由交付一页没人看过的东西);`--allow-unreviewed` 只豁免"渲过但还没标 pass"这一层,**不是跳过验收的捷径**。被拒就回阶段五补验收/走返工回路。
- 🚧 **导出边界图标门(硬)**:spec_lock 锁了 `icons.library` + 非空 `inventory``svg_output/` 全 deck 零 `<use data-icon>` → 同样 `[ERROR]` 退非零(检测永远对 svg_output 源,与 `-s` 无关)。正确做法是回阶段三给内容页补图标重跑;只有 lock 确实过期 / 有意做无图标 deck 才加 `--allow-iconless` 放行。
- ❌ **别加 `-s final`**:native 导出默认读 `svg_output/`(转换器自己处理图标占位与 `../images/` 相对路径),`-s final` 只会引出图片路径错位这类连锁问题;真实事故里模型为绕它把 svg_output 源里的 href 改坏了。
- 🛑 **导出唯一入口 = 官方 `svg_to_pptx.py`,严禁自写导出器**:它**默认产出原生可编辑 DrawingML**(形状/文本/渐变都能在 PowerPoint 里选中改),是**纯 Python、不依赖任何外部渲染器**(cairosvg / inkscape / rsvg-convert 一个都不需要)。所以**"某某渲染器没装"永远不是理由**——别 `pip install cairosvg` 也别手搓"SVG→PNG→整页贴图"的 `export_pptx.py`。自搓光栅导出器 = 整份变成一叠不可编辑的贴图(每页一张整页 PNG、零原生文本),**skill 核心价值直接归零、判废**。官方脚本跑不动就读它的报错按流程修 / 反馈,不要另起平行管线。
- ❌ 别用 `cp` 代替 finalize_svg(它做了多步关键处理);❌ 别加 `--only` / 强制 `-s output`
- 动画可选:`-t fade`(翻页,默认)/ `-a auto`(逐元素入场,**默认 none**,用户要才开)。全表见 animations.md。
- 改稿:只改 `spec_lock.md` 的颜色/字体 → `update_spec.py <project_dir>` 传播到所有 SVG(所有页源都变了 → **重跑阶段五全量重渲重标**,顺手把全本再过一遍眼);改版式/内容 → 重写对应页 SVG 再走阶段五返工回路 + 6.2,**不要直接 edit 成品 .pptx**。
完成后:用 `update_spec` / 重写页迭代;用户确认**实质改动**后追加一行到 `REVISIONS.md`
## 修订日志(REVISIONS.md)
`<project_dir>/REVISIONS.md` 是迭代 changelog。**spec 是宪法(定调一次),REVISIONS 是实施日志(每次卡点累加)**。
| 情形 | 记? |
|---|---|
| 用户确认改**版式 / 主色 / 字体方向** | ✅ 必记 |
| 用户确认换 / 增 / 删**页 / 关键图标 / 数据图表** | ✅ 必记 |
| 用户确认改**文案要点 / 核心信息 / 受众定位** | ✅ 必记 |
| 自查阶段发现版式越界 / 颜色不一致后的修正 | ✅ 必记(说明触发 quality_check 项) |
| 页首次起草(从 0 加出来) | ❌ 不记(初稿不是改动) |
| 字号 / 间距 / 对齐微调 | ❌ 不记 |
| 模型自己改改撤撤、用户没明确确认 | ❌ 不记 |
> 拿不准 → 倾向不记。`REVISIONS.md` 是"用户与 LLM 共同沉淀的实质决策",不是流水账(那是对话历史的事)。
### 格式
文件首次创建时写头(只写一次):
```markdown
# 修订日志
> 产物迭代过程中每次用户确认的实质改动,按时间倒序追加(最新在上)。spec 是宪法定调,本文件是实施日志。
```
每次记一笔追加在头注释之后、最新一笔的顶部(一行 = 一次改动):
| 用户确认改**版式/主色/字体/mode/visual_style 方向** | ✅ |
| 用户确认换/增/删**页/关键图标/数据图表** | ✅ |
| 用户确认改**文案要点/核心信息/受众定位** | ✅ |
| 自查发现越界/不一致后的修正 | ✅(注明触发的 quality_check 项) |
| 页首次起草 / 字号间距微调 / 模型自己改撤未经确认 | ❌ |
格式(倒序,最新在上,插在头注释之后):
```
- `<YYYY-MM-DD HH:MM>` | < N / spec §X> | <一句话改了什么><为什么>
```
### 实例
```
- `2026-03-12 16:20` | 第 5 页 | 版式从 layouts.md "两栏文+图"改为"单栏图占主体" — 用户反馈原版式右侧文字太挤,核心数据需放大
- `2026-03-12 14:05` | 第 3 页 | 删 chart 图,换成 3 个 KPI 数字块 — 数据点只有 3 个,bar chart 浪费版面
- `2026-03-11 10:30` | spec §5 配色 | 主色 `#C00000``#1F4E79` — 用户给的品牌指南要求蓝色,商务红默认被覆盖
```
### 操作
每次卡点用户确认后,用 `edit` 在头注释之后插入新一行(不要 append 到文件末尾 —— 倒序读才能秒看最新)。文件不存在就 `write` 创建带头注释的新文件。
## 反模式
- 用户没给材料就开始硬编内容
- 八条没对齐就跑 python-pptx
- **基于"场景判断"自行换配色**(见上"默认主题"违规清单)
- **缺封面 / 缺尾页(Q&A)** —— 两端都是强制项,不算在正文页数预算内
- **裸白纸版式** —— 所有版式起手都必须 `apply_brand(slide, kind)`,见 layouts.md
- **业务概念页只用几何形状 / 裸圆点 bullet** —— "战略目标 / 三大能力"这类页摆光圆点没图标没卡片,视觉太单薄;用 L11 卡片网格 + `add_icon_tile`,图标按 §阶段二第 2 步先拉
- **数字页硬画柱图** —— 只有 2-4 个数字却画 bar chart 浪费版面,用 L10 KPI 卡
- **元素裸贴白纸不进卡片** —— 内容页一坨文字/图标直接铺白底,显扁平;装进 `add_card`(自带投影)分层
- **演讲者备注全空** —— 正式产物每页应有口述要点,`add_notes` 顺手写,别交白板
- **逐页 run_python 建 deck**(每页一轮来回烧轮数;改用一个 `build_deck.py` 整建,方向风险靠阶段一大纲 + 可选探针兜)
- **没经阶段一大纲对齐就直接整建** —— 大纲是替代逐页确认的 checkpoint,跳过它整建才会"改方向全推翻"
- 跑完不做 `quality_check.py` 就交付
- 起名 `output.pptx` / `untitled.pptx` —— 务必按主题给文件名
- 用户没给材料就硬编内容(没材料只给主题 → 先补素材/反问,别凭空发挥)
- 八条没对齐、没产出 spec_lock 就开始写 SVG
- **写脚本批量生成 SVG**(破坏跨页一致性,禁;逐页手写)
- **绕开官方管线、自搓 SVG→PPTX 导出器**(`pip install cairosvg`/`inkscape` + 手写 `export_pptx.py` 把每页渲成 PNG 整页贴进幻灯片)—— 产物变一叠**不可编辑的整页贴图**(零原生文本/形状、还发虚、外链配图丢失),skill 全部价值作废。官方 `svg_to_pptx.py` 默认就是原生可编辑、纯 Python 无需外部渲染器,**"渲染器没装"不是造轮子的借口**;导出/后处理/质检/验收**只走 §16 资源里那几个官方脚本**,缺一步就补一步,别另起平行流程
- **执行时不每页重读 spec_lock**(长 deck 必漂色/漂字号)
- **同 deck 混用多个图标库** / 用 inventory 外的图标名
- 用了 `<style>`/`class`/`<mask>`/`<symbol>+<use>`/`@font-face`/`rgba()`/HTML 命名实体 等 **shared-standards 禁用特性**(导出会丢元素或报错)
- 字体栈尾不是预装字体(PPTX 无运行时回退,会变默认字体)
- **breathing 页堆多卡网格**(违节奏,显 AI 味)
- 模板照搬不重上皮(直接用模板默认渐变/阴影/字号)
- 质检没过就交付 / 直接 edit 成品 .pptx 改稿
- **只渲/只看几页就收尾**(错位藏在没看的页里);**看到差评却不返工**(封面 vision 说"半成品/挤左侧"还继续导出交付);**没看 PNG 就 `accept_pages --pass-all`**(把验收门当橡皮图章 —— gate 只能强制"渲过、源没改",看没看只有你自己知道,糊弄的结果就是错位 deck 交到用户手上)
- **质检/渲图后为消警告写脚本批量盲插元素**(regex 批量加图标、改坐标,改完不复看渲染 —— 真实事故:25 页 deck 图标全压在文字上交付)
- **用 `| head` 截断质检或导出输出**(吞非零退出码 + 截掉最后的门结论,门形同虚设)
- 起名 `output.pptx` —— 按主题命名
## 输出
完成后告诉用户:文件路径、页数、用到的版式列表、是否有未满足的 spec 项。问一句要不要再改。
完成后告诉用户:文件路径、页数、用到的 mode + visual_style + 版式列表、是否有未满足的 spec 项。问一句要不要再改。
---
> 本 skill 的 SVG→PPTX 引擎、references 设计知识、templates 模板/图标库移植自开源项目 **ppt-master**(github.com/hugohe3/ppt-master,MIT License),适配 zcbot 的 task_dir / 聊天确认 / imagegen 工作流;浏览器 Confirm UI、live preview server、TTS 配音等桌面交互件未移植。

View File

@ -1,66 +0,0 @@
# 本地图标库
> 这里是 skill 自带的**只读种子图标库**,**已入库一组商务红 tabler 种子集**(target / brain / chart-bar / users / trophy / alert-triangle / cpu / building-factory / cloud-network / database 等),覆盖大部分商务汇报场景 —— 直接 `glob` 读用即可。docker 沙盒里 `skills/` 是只读挂载,**不能往这儿写**。新场景按需 `fetch_icon.py` 拉,落点是 `<task_dir>/assets/icons/`(可写),本 task 内再用直接读不发请求。
## 缓存命名规约
```
<set>_<name>_<colorhex>_<sizepx>.png
<set>_<name>_<colorhex>.svg
```
例: `tabler_rocket_C00000_128.png` / `lucide_target_FFC107_96.svg`
## 推荐图标清单 (按业务主题)
种子集已含下列大部分;若某个本 task 缺,按下面命令拉到 `<task_dir>/assets/icons/`(种子库只读,新图标进 task 目录):
```bash
ICONS_DIR=<task_dir>/assets/icons # 可写落点;<skill_dir>/scripts 来自 load_skill 头(只读可执行)
# 战略 / 目标 / 启动
for n in target rocket flag bulb; do
python <skill_dir>/scripts/fetch_icon.py $n --set tabler --color C00000 --size 128 \
-o "$ICONS_DIR/tabler_${n}_C00000_128.png"
done
# 数据 / 趋势 / 报表
for n in chart-bar chart-line trending-up calculator; do
python <skill_dir>/scripts/fetch_icon.py $n --set tabler --color C00000 --size 128 \
-o "$ICONS_DIR/tabler_${n}_C00000_128.png"
done
# 团队 / 流程 / 时间
for n in users settings calendar clock check shield-check arrow-right alert-triangle currency-yuan circle-check; do
python <skill_dir>/scripts/fetch_icon.py $n --set tabler --color C00000 --size 128 \
-o "$ICONS_DIR/tabler_${n}_C00000_128.png"
done
```
## 图标集对照
| 集名 | 风格 | 数量 | License |
|-----|-----|-----|---------|
| **tabler** ⭐ 推荐 | 描边、商务、克制 | 4500+ | MIT |
| lucide | 描边、克制 | 1500+ | ISC |
| heroicons | Tailwind 风、双重粗细 | 300+ | MIT |
| material-symbols | Google Material 描边/填充 | 3000+ | Apache 2.0 |
| carbon | IBM、克制专业 | 2000+ | Apache 2.0 |
| fluent | Microsoft、温和现代 | 4000+ | MIT |
| mdi | Material Design Icons 社区 | 7000+ | Apache 2.0 |
## 浏览找名字
打开 https://icon-sets.iconify.design/ 搜中英文关键词,复制图标名 (如 `tabler:rocket`),回来用 `--set tabler rocket` 拉。
## 主题色变体
同一图标按主色/辅色/强调色/灰各拉一份,文件名只在 `<colorhex>` 段不同:
- `tabler_target_C00000_128.png` (主红)
- `tabler_target_E15554_128.png` (辅红)
- `tabler_target_FFC107_128.png` (强调金)
- `tabler_target_595959_128.png` (灰)
## 用图标的硬规则
`references/icons.md §C` —— 风格统一、颜色限定、大小克制、不替表意、避 emoji。

Some files were not shown because too many files have changed in this diff Show More