Compare commits
116 Commits
refactor/s
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
d24165a2fe | |
|
|
259dde502d | |
|
|
2937b75143 | |
|
|
7e6159af48 | |
|
|
640bd0a1a3 | |
|
|
0ad7d08242 | |
|
|
941554f9d7 | |
|
|
dc721ba8a3 | |
|
|
346930449a | |
|
|
d30f6089bb | |
|
|
6f27b7cc5a | |
|
|
0e02cff6c6 | |
|
|
a89c7386fd | |
|
|
fcc158dff6 | |
|
|
3c712031d5 | |
|
|
d79c28de06 | |
|
|
e46eb01766 | |
|
|
c2d24b20b4 | |
|
|
641c7d58aa | |
|
|
eb9ffd654f | |
|
|
d8f71aa7b2 | |
|
|
4b1dce6df9 | |
|
|
5bde2445a0 | |
|
|
13835a315a | |
|
|
4a6182a76a | |
|
|
5d23ee682b | |
|
|
001f9af96f | |
|
|
ff276eb9b3 | |
|
|
e3a432dcdd | |
|
|
d4aa5ccbec | |
|
|
b5d75d2a7b | |
|
|
700176a0c6 | |
|
|
1646205364 | |
|
|
89062d99b3 | |
|
|
b27cc9cd5b | |
|
|
e49ff641f9 | |
|
|
d235cb7564 | |
|
|
1352f092a3 | |
|
|
b4808b0370 | |
|
|
8263382fd1 | |
|
|
d633949a66 | |
|
|
e7a86fb00c | |
|
|
12a2289de2 | |
|
|
013cbc28b5 | |
|
|
a6d00b24ff | |
|
|
e66fdd0ffc | |
|
|
133e350428 | |
|
|
7dfdf4c73b | |
|
|
474597cfc6 | |
|
|
d1aa2b12e2 | |
|
|
d16297e556 | |
|
|
5d3cd88e2c | |
|
|
8ab1805df4 | |
|
|
23c5ab20e0 | |
|
|
0cf6e3e61e | |
|
|
36964d9920 | |
|
|
ed2ff52bf4 | |
|
|
6f7c904cca | |
|
|
c79fc8ef0c | |
|
|
9381655210 | |
|
|
f17da6a6e1 | |
|
|
2b2b4531b3 | |
|
|
b5cfce72b5 | |
|
|
529d7f1046 | |
|
|
6f7e32bb33 | |
|
|
7b9f0c12ed | |
|
|
2dd1b49725 | |
|
|
6008e1b8a0 | |
|
|
193b545b75 | |
|
|
320f428dd3 | |
|
|
340786a42f | |
|
|
85336ccb7e | |
|
|
95857ba687 | |
|
|
c569438d5f | |
|
|
528b974d9f | |
|
|
336db63a01 | |
|
|
d412aa6b24 | |
|
|
247a887cd6 | |
|
|
c55d0d11f0 | |
|
|
f8d11a2491 | |
|
|
2b9a7febde | |
|
|
108351864e | |
|
|
4f61b5fc56 | |
|
|
e87daa7c89 | |
|
|
6d6e9f79b5 | |
|
|
660bec0f2f | |
|
|
0d69ae86e2 | |
|
|
be813629b2 | |
|
|
1cfeb000a6 | |
|
|
91e200ef4f | |
|
|
82feecef06 | |
|
|
ec27fcae3e | |
|
|
5caa3db62e | |
|
|
888824ba85 | |
|
|
eb1027b040 | |
|
|
12171a4bdf | |
|
|
31f46baaf6 | |
|
|
314a05e111 | |
|
|
977923b6cf | |
|
|
211b008821 | |
|
|
32bf6ae917 | |
|
|
d30435198c | |
|
|
18f702886f | |
|
|
ae9790601a | |
|
|
1f57bbd201 | |
|
|
c870b10368 | |
|
|
0259f0ce92 | |
|
|
f12df1bd82 | |
|
|
81da2f6f55 | |
|
|
ef611b0666 | |
|
|
44be5753f7 | |
|
|
dd797a91e2 | |
|
|
15d69b3372 | |
|
|
f614046438 | |
|
|
d89ebad272 | |
|
|
958678aa12 |
|
|
@ -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
|
||||
|
|
|
|||
30
CLAUDE.md
30
CLAUDE.md
|
|
@ -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
257
DESIGN.md
|
|
@ -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)
|
||||
|
|
|
|||
610
PROGRESS.md
610
PROGRESS.md
|
|
@ -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.656–0.75in 七个值)+ 并排块顶差 2–12px 的"想对齐没对齐"(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**——兄弟卡片近失对齐(精确几何,2–12px error;底对齐/中心对齐/绘图区内数据柱三类豁免,71 charts 模板回归误报清零)、layout_grid 偏离 2–15px 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.2–0.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 张,页数从没被真正确认过。根因是行为层:a–h 八条对齐里 b 项(页数)只给「常 8–15 页」区间,又被打包进整批 BLOCKING 确认,用户一句笼统「OK」就整批过、模型自取区间中位数(~12)。修(纯文档):`SKILL.md` b 项改为推**一个具体数字**+ 标为「独立拍板项」;a–h 表后新增「🔒 页数 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 图形"),40–60% → 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 条从"怎么写图标"改为"**内容页必须放 1–3 个 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.1–8×),用 **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
97
RUN.md
|
|
@ -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 共享
|
||||
|
||||
---
|
||||
|
|
|
|||
153
SKILL_LIST.md
153
SKILL_LIST.md
|
|
@ -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 阶段定稿,a–h):画布 / 页数 / 受众+核心信息+投递目的 / 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 取政策·标准·产业动向(单列)。**只描述不给建议**——呈现"发了什么、讲了什么",判断留给读者。简报 ≠ 综述论文,要**快、准、客观**,5–20 分钟掌握一个方向近期发了哪些重要论文。
|
||||
|
||||
**五阶段**:定题对齐 spec(方向+边界 / 时间窗 / 期刊范围 / 深度 / 数据源 / 语言 / 关注点)→ 三路取数(research+documents 取文献、web 取动向;中→英术语转译 + 跨源去重)→ 列清单(带摘要概述)+ 内容总结 → 引文核验 → 渲染验收。
|
||||
|
||||
**深度三档(按篇数)**:`flash` 10–20 篇 / `standard` 20–40 篇 / `deep` 40–80 篇。
|
||||
|
||||
**何时用**:
|
||||
- ✅ 用户要"简报 / 方向简报 / 最新文献 / 重要论文列表 / 研究动态 / 某方向近期重要论文 / 跟踪某领域最新研究"
|
||||
- ✅ 立项前想快速摸清一个方向近期发了哪些重要论文(产出可喂 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 等缩写展开)
|
||||
- **每篇带摘要概述**:列表不只标题,每篇 2–4 句讲研究对象/方法/主要发现,基于 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) → 改造成本组 / 本人专属版本(术语 / 模板 / 默认值),之后日常任务直接用改造版
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
THssshZfneJwIG5Y
|
||||
|
|
@ -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 出去也跪)。
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.38.8"
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
139
core/context.py
139
core/context.py
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
181
core/memory.py
181
core/memory.py
|
|
@ -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_agent。SaaS 化时 `<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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,434 @@
|
|||
"""定时任务调度核心(DESIGN §8.5)。
|
||||
|
||||
纯逻辑层:cron→next_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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
127
core/skills.py
127
core/skills.py
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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_at。mode:
|
||||
- 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/FK、per-字段 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
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 调用配额检查。返回 (今日已用次数, 是否超额)。
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
*,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
"""微信接入(DESIGN §8.7)。
|
||||
|
||||
渠道 A = ClawBot 个人微信 iLink Bot API(`ilink.py`,协议已真机实测,见
|
||||
`scripts/probe_clawbot*.py`);渠道 B = 企业微信自建应用(后续 `wecom.py`)。
|
||||
本包只放协议客户端等纯逻辑,与 DB / agent 编排解耦。
|
||||
"""
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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}")
|
||||
|
|
@ -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))
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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(公测兼容)。一行 = 一个用户绑定其
|
||||
个人微信 ClawBot。bot_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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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 同步
|
||||
|
|
|
|||
|
|
@ -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/字体"
|
||||
|
|
@ -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 |
|
|
@ -0,0 +1,106 @@
|
|||
# 科研智能助手 · 操作说明书(精简版)
|
||||
|
||||
> 适用:无机非金属材料(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材)科研人员。
|
||||
|
||||
---
|
||||
|
||||
## 1. 它能帮你做什么
|
||||
|
||||
一句话:**把模糊的科研需求,变成可交付的成果文件。** 它不只是聊天,能查库、跑算、出图、写文档,并产出可下载的 `.docx` / `.pptx` / 图 / 数据。
|
||||
|
||||
相较通用聊天工具的**关键优势**:
|
||||
|
||||
- **接入内部材料文献库**——100 万+ 篇材料论文(7 大学科),**中文提问命中英文文献**,全文可读,免你 OCR 翻库;另接 OpenAlex 元数据库补 DOI / 拉 PDF。
|
||||
- **科研写作全覆盖**——论文投稿稿、6 类基金申报书、国/行/团标、专利交底书、审稿润色,规范成稿、文献真实。
|
||||
- **科研计算**——XRD 模拟 / 相图、配方-性能建模、出版级学术图(中文 + 矢量)。
|
||||
- **真实执行**——在安全沙箱里实跑 Python、出图、转档,结果是算出来的;并有**跨任务长期记忆**。
|
||||
|
||||
---
|
||||
|
||||
## 2. 界面:三栏
|
||||
|
||||
| 左:任务列表 | 中:对话区 | 右:文件区 |
|
||||
|---|---|---|
|
||||
| + 新建任务 | 与助手一问一答 | 当前工作目录的文件 |
|
||||
| 搜索 / 筛选 | 实时显示进度 | 上传 / 预览 / 下载 |
|
||||
| 技能 / 记忆 | 底部输入框 | |
|
||||
|
||||

|
||||
|
||||
> 手机端顶部有「任务 / 对话 / 文件」标签切换;分隔线可拖宽,`‹` `›` 可折叠两侧栏。
|
||||
|
||||
**三个概念(务必理清):个人文件夹 → 工作目录 → 任务**
|
||||
|
||||
| 概念 | 是什么 |
|
||||
|---|---|
|
||||
| **个人文件夹(「我的」)** | 你的私有根空间,别人看不到;右栏文件区顶层即「我的」。 |
|
||||
| **工作目录** | 一个项目 / 课题的文件夹,存该项目的素材与成果;右栏展示的就是它。**可被多个任务共享**。 |
|
||||
| **任务** | 一次对话会话,新建时绑定到某个工作目录。 |
|
||||
|
||||
**任务 ≠ 文件夹**:默认一个任务跟随任务名建一个同名目录(一对一);也可让多个任务挂**同一个工作目录**共享文件(如写一份本子,「立项依据 / 技术路线 / 经费」几个任务共用「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?要图还是数据?)。不会表达就先点 `✨ 润色`。
|
||||
- **一件事一个任务**:文件与对话都更清爽,便于归档筛选。
|
||||
- **文献多为英文别担心**:用中文提问即可,助手自动转专业英文术语检索。
|
||||
- **图 / 文件没被看到**:确认已出现在右栏;粘贴的图记得连同消息一起发送。
|
||||
|
||||
---
|
||||
|
||||
> 把它当成一位**懂材料、会查库、能动手出活**的科研助理:你说清要什么,它把活干出来、把文件交到你手上。
|
||||
|
|
@ -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. 界面总览:三栏布局
|
||||
|
||||
登录后进入主界面,整体分为**左 / 中 / 右三栏**:
|
||||

|
||||
|
||||
- **左栏 = 任务**:你的所有工作会话,每条叫一个「任务」。顶部有「+ 新建任务」和搜索 / 筛选;底部有「技能」「记忆」两个入口。
|
||||
- **中栏 = 对话**:选中某个任务后,在这里与助手对话。中部会实时显示助手正在做什么(查文献、跑脚本、出图…),底部是消息输入框。
|
||||
- **右栏 = 文件**:当前任务工作目录下的所有文件。助手生成的成果、你上传的素材都在这里,可预览、可下载。
|
||||
|
||||
> **手机端**:顶部会出现「任务 / 对话 / 文件」三个标签,点击切换三栏。
|
||||
|
||||
三栏宽度可拖拽中间的分隔线调整;点栏顶的 `‹` / `›` 可折叠左 / 右栏,给对话区让出空间。
|
||||
|
||||
### 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:**删除不可逆**。只是暂时不用,建议用「废弃」而非「删除」——废弃后仍可在筛选里找回。
|
||||
|
||||
---
|
||||
|
||||
> **使用心法**:把它当成一位**懂材料、会查库、能动手出活**的科研助理。你负责说清要什么,它负责把活干出来、把文件交到你手上。
|
||||
|
|
@ -0,0 +1,428 @@
|
|||
# 科研 AI 双智能体 · 汇报 PPT 大纲
|
||||
|
||||
> 单位:中国建筑材料科学研究总院 · 中存大数据
|
||||
> 用途:生成汇报 PPT 的内容底稿。本文件只定**结构 + 每页要点 + 呈现形式**,不写大段叙述文字。
|
||||
> 编写日期:2026-06-24
|
||||
|
||||
---
|
||||
|
||||
## 0. 总体设计说明(给设计 / 制作人员看)
|
||||
|
||||
**叙事主线 —— 通用 + 垂直,双轮驱动:**
|
||||
|
||||
| | 第一部分 | 第二部分 |
|
||||
|---|---|---|
|
||||
| 名称 | 通用科研辅助智能体 | 无机非金属材料自主研发智能体 |
|
||||
| 定位 | **横向**:服务全院科研人员日常全流程 | **纵深**:材料配方自主研发的自动化 |
|
||||
| 入口 | 自然语言,任意科研任务 | 材料研发需求 → 实验方案/配方 |
|
||||
| 形态 | 17 项 skill 能力矩阵 + 可交付物 | 五大引擎 + 配方大模型(垂直微调) |
|
||||
| 一句话 | 把"想法"变成可交付的科研产物 | 把"性能要求"变成可执行的实验配方 |
|
||||
|
||||
**呈现纪律(全程硬约束):**
|
||||
- 每页**论断式标题**(写结论,不写"XX 介绍")。
|
||||
- 正文只用:**短卡片(≤12 字)/ KPI 数字卡 / 流程图 / 时间轴 / 对比表 / 矩阵网格**。禁止整段话。
|
||||
- 每页带一行【呈现形式】,指明该页用什么版式画。
|
||||
- 颜色:商务红主题(主色 #C00000),关键数字 / 核心步骤高亮。
|
||||
- 凡是带"流程"的页,一律画成**节点+箭头流程图**,不写成文字列表。
|
||||
|
||||
**全篇页序(约 26 页):** 封面 → 双智能体总览 → [PART1:1.0–1.10] → [PART2:2.0–2.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` 方向简报:重要论文列表 + 内容总结,5–20 分钟掌握一个方向
|
||||
|
||||
差异化标签(高亮):**跨语言检索** · **可溯源引用** · **立项依据有真实文献支撑**
|
||||
|
||||
【呈现形式】上方七学科库网格,下方三路检索分工小图;右侧竖排三个差异化标签 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 双轴 · 强度预测 · 响应面寻优
|
||||
|
||||
【呈现形式】左右双栏卡,每栏配 2–3 个典型场景小图标;高亮"中文相名映射"和"三库分工"。
|
||||
|
||||
---
|
||||
|
||||
## 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.3–2.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 与渠道
|
||||
- **感谢聆听 · 欢迎交流**
|
||||
|
||||
【呈现形式】左侧 3–4 条展望短句(带图标),右侧大字"感谢聆听 / 交流环节"。
|
||||
54
main.py
54
main.py
|
|
@ -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 部署前置对账) ───────────────
|
||||
|
|
|
|||
|
|
@ -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。
|
||||
|
|
|
|||
|
|
@ -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 md→HTML→沙盒 chromium --print-to-pdf
|
||||
- render.py 统一入口:--profile {brief,paper,proposal} --format {docx,pdf}
|
||||
"""
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
"""平台渲染层 · 共享叶子原语(docx 三 profile + 部分 pdf 复用)。
|
||||
|
||||
放**真正同源、与 profile 无关**的底层件:字体 OOXML 助手、化学式下标白名单、
|
||||
内联/块级 markdown 正则、表格行切分、图片路径解析。三套 docx profile
|
||||
(manuscript=paper/proposal、brief)都 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
|
||||
|
|
@ -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 '黑白'}")
|
||||
|
|
@ -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}")
|
||||
|
|
@ -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
|
||||
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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}")
|
||||
|
|
@ -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}")
|
||||
|
|
@ -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} 条报错")
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)))
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
"""企业微信推送诊断:分步查 gettoken / message_send 的确切 errcode/errmsg。
|
||||
|
||||
用法(服务器上,.env 同目录):
|
||||
.venv/Scripts/python.exe scripts/diag_wecom.py <userid>
|
||||
读 .env 的 WECOM_CORPID/AGENTID/SECRET。ASCII 输出,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())
|
||||
|
|
@ -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)} 页")
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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_bg→LLM→回写 run_status→record_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())
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
---
|
||||
name: brief
|
||||
description: 生成科研方向简报(research direction briefing / 重要文献速览)。给定一个研究方向 + 时间窗,从各大相关期刊(Elsevier 数据库优先)挑选近期重要论文,产出一份「重要论文列表 + 内容总结」的可读简报:先列清单(每篇带标题/作者/期刊/年月/DOI + 一段简介或摘要概述),再对这批论文做客观归纳。可溯源、不编造引文,**只描述不给建议**。当用户要"简报 / 方向简报 / 最新文献 / 重要论文列表 / 研究动态 / 某方向近期重要论文 / 跟踪某领域最新研究"时使用。
|
||||
---
|
||||
|
||||
# 科研方向简报(重要文献速览)
|
||||
|
||||
把"某方向近期发了哪些重要论文、都在讲什么"做成一份**可读、可溯源、客观**的简报。两段式:**先一份重要期刊论文列表(各大相关期刊、Elsevier 数据库优先;每篇带一段简介/摘要概述),再对这批论文做内容总结**。
|
||||
|
||||
> **只描述、不给建议。** 简报呈现"发了什么、讲了什么",不给"本院应当……/可切入……/建议……"。判断留给读者。
|
||||
>
|
||||
> **"重要"怎么挑**:来自主流期刊(Elsevier 旗舰刊优先)、方向上居中而非边缘、有实质发现。近期论文引用尚少,故主要看**期刊层级 + 主题相关性 + 发现的分量**,不是单纯按引用数。控量靠"重要性 + 时新",不靠主观褒贬。
|
||||
|
||||
简报 ≠ 综述论文(paper review):综述要全面、深、给定论;简报要**快、准、客观**——5–20 分钟掌握一个方向近期发了哪些重要论文、各讲了什么。
|
||||
|
||||
## 边界(免得和别的 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` 10–20 篇 / `standard`(默认)20–40 篇 / `deep` 40–80 篇
|
||||
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
|
||||
|
||||
<简介/摘要概述:2–4 句,讲研究对象、方法/表征、主要发现与关键数据 —— 基于 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)、有没有混进"建议/启示/本院应当"措辞。
|
||||
- 交付一句话说清:覆盖了哪些期刊、收了多少篇、时间窗、哪些刊本窗口库内无收录。
|
||||
|
||||
## 反模式
|
||||
|
||||
- ❌ **给建议/启示/"本院应当"** —— 只描述论文讲了什么,判断留给读者
|
||||
- ❌ 列表只留标题、没摘要概述 —— 每篇都要 2–4 句简介
|
||||
- ❌ 跳过定题直接检索 / 用中文 keyword 搜英文库 / 期刊名不精确 —— 先定题、转英文术语、用精确 `publication_name`
|
||||
- ❌ web 资讯混进论文列表/总结 —— 单列"其他动向"
|
||||
- ❌ 编造 DOI / "据报道"无源句 —— 查不到就如实说
|
||||
- ❌ 反复 `run_python`/`print` 把整批全文 abstract 灌进上下文 —— 落文件、按需读;同批摘要 dump 多次会让 context 滚雪球(实测一次简报累计烧 2.5M token、跑满超时被掐断没推送出去)
|
||||
|
|
@ -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 方向所属子领域,从上面对应表里取 **3–8 本主流刊**(Elsevier 优先),逐刊拉最新。
|
||||
- 跨子领域的方向(如"低碳水泥固废路线")→ 水泥表 + 绿色表合并取。
|
||||
- 每本刊取最新若干篇后,**按重要性筛**:主题居中、有实质发现的留,边缘/纯验证性的弃(控量见 SKILL.md 篇数预算)。
|
||||
- 主流刊都覆盖到了就够,不必穷举所有刊。哪些刊本窗口库内 0 收录,交付时如实点出。
|
||||
|
|
@ -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,任何模式都能用。
|
||||
|
||||
|
|
|
|||
|
|
@ -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。失败模式 / 解药:
|
||||
|
|
|
|||
|
|
@ -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**`/列表/表格/`` 居中插图 + 图题自增;要 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 / 声明)
|
||||
|
|
@ -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 的引文不得带进最终稿(要么核实、要么删论断、要么用户拍板)
|
||||
- ❌ 不得为凑引文数编造"看起来合理"的文献
|
||||
- ✅ 支撑度不足时**改论断迁就证据**,不是改证据迁就论断
|
||||
- ✅ 两库都查不到时如实告诉用户,给出"提供来源 / 删除论断"两个选项
|
||||
|
|
@ -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 必拦)
|
||||
|
|
@ -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 必拦)
|
||||
|
|
@ -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 必填、字数硬上限)时,以**目标期刊投稿指南**为准,本表只给通用骨架。
|
||||
|
|
@ -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
|
||||
|
|
@ -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]` 占位就渲染
|
||||
|
|
@ -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 块或 ")
|
||||
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 块或 ")
|
||||
if args.strict:
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("[OK] 全部检查通过, 可以渲染 docx 了。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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 趋势>`
|
||||
|
||||

|
||||
|
||||
## <结果主题 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 —— 按目标期刊要求增删。
|
||||
|
|
@ -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 核验后的真实条目>
|
||||
```
|
||||
|
|
@ -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 如 Na₂O 当量 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 核验后的真实条目>
|
||||
```
|
||||
|
|
@ -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 自定义。
|
||||
|
|
|
|||
|
|
@ -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)` 默认按以下顺序找字体:
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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`。
|
||||
|
|
@ -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 等决定。
|
||||
- 没有 → 走下面。
|
||||
|
||||
**八条对齐(a–h)**——按下表**一次性给推荐方案**(默认自由设计),然后 ⛔ **BLOCKING:等用户确认/修改**。不要一条条问。zcbot 走**聊天确认**(不开浏览器 Confirm UI),内容与 strategist.md 的 a–h 一致:
|
||||
|
||||
| # | 项 | 默认 |
|
||||
|---|----|------|
|
||||
| a | 画布 | **16:9**(viewBox `0 0 1280 720`)。其它见 canvas-formats.md |
|
||||
| b | 页数 | **独立拍板项(见下方「页数 gate」)**:按内容量 × 投递目的推**一个具体数字**(如「建议 10 页」),不甩「常 8–15」这种区间就想过;**封面 + 正文 + 尾页** |
|
||||
| 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(不可默认放行)**:页数是**唯一必须拿到用户明确数字**才能往下走的项。给完 a–h 推荐后,若用户只回笼统的「可以 / 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(见阶段四)。
|
||||
|
||||
大纲连同 a–h **一起给用户预览,⛔ BLOCKING 等确认整份结构**后再进阶段二(改文字比改 slide 便宜)。
|
||||
|
||||
**确认后产出两份引擎契约**(按骨架填,**只填实际用到的行**):
|
||||
- `<project_dir>/design_spec.md` —— 人读叙事(I–XI 节,见 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)是跨页对齐的锚 —— 手写绝对坐标没有锁定基线必漂,质检会硬卡偏离网格 2–15px 的"想对齐没对齐"。
|
||||
|
||||
> 公式策略 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` 时,**每个内容页必须放 1–3 个 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`(每页 2–4 句结论先行口语)。
|
||||
|
||||
## 阶段四:SVG 质检(强制门)
|
||||
|
||||
```
|
||||
.venv/Scripts/python.exe <skill_dir>/scripts/svg_quality_checker.py <project_dir>
|
||||
```
|
||||
- **任何 `error`(禁用特性 / viewBox 不符 / spec_lock 漂移 / **图标压在文字上、文字基线超出画布、CJK 文字互相叠压**(Geometry 检测,几何精确)/ **兄弟卡片错位 2–12px、偏离 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 配音等桌面交互件未移植。
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue