docs: 精简 PROGRESS(865→188 行)与 DESIGN(797→218 行)(bump 0.39.1)

- PROGRESS 按自身契约压缩:每条收成一行(做了啥+关键判断),细节查 git log
- DESIGN 保留全部决策/取舍/硬协议清单/升级信号,砍实现复述;协议细节指向代码与 probe 脚本;§2 架构树补齐实际模块

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-07-06 09:52:12 +08:00
parent 43775d531a
commit 7533119039
3 changed files with 293 additions and 1428 deletions

817
DESIGN.md
View File

@ -1,20 +1,16 @@
# 设计文档 # 设计文档
> 本地运行的个人任务 agent,覆盖三类工作:汇报 PPT、科研申报书、代码。 > 本地运行的个人任务 agent,覆盖三类工作:汇报 PPT、科研申报书、代码。
> 模型自由(LiteLLM 接 OpenAI-compatible),代码可控(目标 1500-2000 行 Python) > 模型自由(LiteLLM 接 OpenAI-compatible),代码可控。本文只记**架构与取舍(为什么)**;进度/历史见 PROGRESS,怎么跑见 RUN
--- ---
## 1. 边界 ## 1. 边界
**做**:PPT(`python-pptx`)/ 申报书(`python-docx`)/ 编码(读写文件 + shell + 迭代验证)。 **做**:PPT / 申报书 / 编码(读写文件 + shell + 迭代验证)。
**不做**:子 agent / IM 渠道 / 自定义 RAG / 锁定 Anthropic / Eval Suite(个人工具 dogfooding 替代)。多用户 / Web UI 归 §7。 **不做**:子 agent / 自定义 RAG / 锁定 Anthropic / Eval Suite(dogfooding 替代)。多用户 / Web 归 §7。
**关键约束**: **关键约束**:模型自由(LiteLLM,默认 DeepSeek V4);任务持久化(任意时刻关机可恢复);演化性(模型升级不大改架构);**形态兼容**——本地与 SaaS 共享同一份 core / PG / web `/v1` API,无 CLI REPL 分叉(§7.9)。
- 模型自由:LiteLLM + OpenAI-compatible(默认 DeepSeek V4)
- 任务持久化:任意时刻关机,下次能恢复
- 演化性:模型升级不需要大改架构
- **形态兼容**:本地与 SaaS 共享同一份 core / storage(PG)/ web `/v1` API,无 CLI REPL / 本地 in-process 分叉(2026-05-18 撤,详 §7.9)
--- ---
@ -25,150 +21,85 @@ zcbot/
├── core/ ├── core/
│ ├── capabilities.py # ModelCapabilities,从 yaml 加载 │ ├── capabilities.py # ModelCapabilities,从 yaml 加载
│ ├── llm.py # LiteLLM 封装,按 capabilities 自动启 features │ ├── llm.py # LiteLLM 封装,按 capabilities 自动启 features
│ ├── loop.py # ReAct 主循环 + cancel_check 协作式 cancel │ ├── loop.py # ReAct 主循环 + 协作式 cancel
│ ├── probe.py # 真实探测对账 yaml 声称的能力 │ ├── probe.py # 真实探测对账 yaml 声称的能力
│ ├── session.py # 消息列表 + meta + 落 PG │ ├── session.py # 消息列表 + meta,落 PG
│ ├── skills.py # SkillRegistry(Anthropic 渐进披露) │ ├── skills.py # SkillRegistry(渐进披露,多来源)
│ ├── task.py # TaskState │ ├── task.py # TaskState
│ ├── memory.py # per-user .memory/ 双层记忆 │ ├── memory.py # per-user .memory/ 双层记忆
│ ├── shortcuts.py # 快捷指令(触发词→完整指令,入口层确定性展开;.memory/shortcuts.md) │ ├── shortcuts.py # 快捷指令(入口层确定性展开)
│ ├── paths.py # task_dir db form 归一(to_db_path / from_db_path) │ ├── paths.py # task_dir db form 归一
│ ├── storage/{engine,models,utils}.py # SQLAlchemy 2.x ORM │ ├── storage/ # SQLAlchemy 2.x ORM
│ └── agent_builder.py # 装配 lib:build_agent / system prompt / validate_task_name │ ├── scheduler.py # 定时任务(§8.5)
├── tools/ │ ├── wechat/ # 渠道:ilink / wecom / service / inbound(§8.7)
│ ├── base.py # Tool 基类 + _resolve │ ├── sandbox/ + executor*.py # Executor ABC + Docker per-user 容器池(§7.5)
│ ├── fs.py # read / write / edit (唯一匹配) / glob / grep │ └── agent_builder.py # 装配 lib:build_agent / system prompt
│ ├── shell.py # subprocess + 黑名单 ├── tools/ # fs / shell / run_python / skill / 媒体 / 检索 / host-side 域工具
│ ├── run_python.py # tmp .py + subprocess + 敏感 env 过滤 ├── skills/<name>/ # SKILL.md + references / scripts / assets
│ ├── skill_tool.py # load_skill ├── rendering/ # 平台渲染层 md→docx/pdf(§8.6)
│ └── skill_authoring.py # save_skill / fork_skill(host-side 写用户 .skills)
├── skills/{coding,ppt,proposal}/ # SKILL.md + references / scripts / assets
├── prompts/system/general_v1.md ├── prompts/system/general_v1.md
├── config/{agent.yaml, models/*.yaml} ├── config/{agent.yaml, models/*.yaml, media/*.yaml}
├── workspace/users/<user_id>/ ├── workspace/users/<user_id>/{.memory/, .skills/, <working_dir>/}
│ ├── .memory/{core.md, extended/*.md} # 跨 task 共享记忆,dotfile 隔离 ├── web/{app.py, auth.py, admin.py, broker.py, sinks.py, static/}
│ └── <working_dir>/ # 工作目录,用户起名(同 working_dir 多 task 共享) ├── db/migrations/ # alembic
├── web/{app.py, auth.py, broker.py, sinks.py, static/dev.html} └── main.py # 入口:web / db / probe / user
├── db/migrations/ # alembic
└── main.py # 入口:web / db / probe / user 四子命令
``` ```
**工作目录(working_dir) = `workspace/users/<user_id>/<working_dir>/`,所有 skill 产物写到这里**,绝对路径在 system prompt 显式给 agent。写错位置(cwd / `skills/` / repo 根)git status 立刻报红。`user_id` 走 JWT `sub`:邮箱密码登录由 users 表 email lookup 直读,platform 登录由 platform 直传;**无 SENTINEL fallback,所有路径必须显式有 user_id**。**`name`(任务显示名)必填**,**`working_dir` 可选**(留空 → 用 name 作目录名);两者都是简单名(不含 `/\..`、不以 `.` 起头,挡 `.memory`);同 `working_dir` 多 task 自动共享同目录(§7.1)。SaaS 化只是把 `workspace/``<storage_root>/`,布局不变。 **工作目录** = `workspace/users/<user_id>/<working_dir>/`,所有 skill 产物写这里,绝对路径注入 system prompt。`user_id` 走 JWT `sub`,**无 SENTINEL fallback**。`name`(显示名)必填、`working_dir` 可选(留空用 name);都是简单名(拒 `/\..`、`.` 起头);同 working_dir 多 task 共享同目录(§7.1)。SaaS 化只换外层根目录,布局不变。
**启动**:`python main.py web` → uvicorn 起 FastAPI → lifespan 跑 stale-run reaper → 客户端走 `/v1/auth/login_password`(邮箱密码)或 `/v1/auth/login`(platform_key)换 JWT → `POST /v1/tasks/{id}/messages` 起 BG 线程,内部 `build_agent``agent.yaml` → 加载 `ModelCapabilities``LLM(caps)` → 拼 system prompt(general_v1.md + skill discovery + cwd + working_dir 绝对路径)→ 装配工具 → `AgentLoop.run`。`ZCBOT_DB_URL` 指 PG **启动**:`main.py web` → FastAPI + lifespan(reaper / scheduler / 渠道入站)→ 登录换 JWT → `POST /v1/tasks/{id}/messages` 起 BG 线程 → `build_agent`(capabilities → LLM → system prompt → 工具)→ `AgentLoop.run`
--- ---
## 3. 核心组件 ## 3. 核心组件
### 3.1 主循环(`core/loop.py`) ### 3.1 主循环(`core/loop.py`)
ReAct:LLM → 若有 tool_calls 就执行 → 结果塞回消息 → 再调 LLM。无 tool_call 即返回。 ReAct:LLM → tool_calls 执行 → 结果塞回 → 再调;无 tool_call 即返回。工具结果对模型截 16K、用户预览 400 字符;事件走 `sink.emit`(SSE 桥),content delta 即时 emit。
- 工具结果对模型截 16K 字符,用户预览 400 字符 - **LLM 走 streaming**(`chat_stream` + `stream_chunk_builder` 拼回);`cancel_check` 在每轮 LLM 前 + **chunk 间** + tool 间 poll → cancel 延迟 ~100ms;中途 cancel 已收 chunk 丢弃不入库,未执行 tool_call 补 `[cancelled by user]` 保协议。
- 事件通过 `sink.emit` 流式发布(§7 A,SSE 桥);content delta 在 stream chunk 到达即时 emit `text` 事件,前端打字机渲染 - **停机判据 = 解耦"跑了几步"与"是否在推进"**(2026-06-10):`max_iterations` 降为纯安全 backstop(step-count 是"不收敛"的粗糙代理,正经 80 步任务和死循环 5 步不该一刀切);主防护是进展信号——① `_RepeatGuard` 逐指纹累计"同名同参+无产出"(SOFT2 提示 / HARD4 拦截);② run 级 `_stall` 连续 8 步全 tool 无净产出主动停。停下都 emit"回复『继续』可续跑",不静默。
- **LLM 调用走 `LLM.chat_stream`(litellm `stream=True`)**:chunks 攒齐后用 `litellm.stream_chunk_builder` 拼回完整 response 给 tool_calls 解析 + usage 记账;`stream_options.include_usage=True` 让最后一个 chunk 带 usage
- `cancel_check: Optional[Callable[[], bool]]` 协作式 cancel,每轮 LLM 前 + **stream chunk 之间** + tool_calls 之间 poll;chunk 间 poll 让 cancel 延迟从「整轮 generation 时长」(几十秒)降到「单 chunk 间隔」(~100ms);中途 cancel 时已收 chunk 丢弃,assistant 半截内容不入库(resume 上下文干净);命中给未执行 tool_call 补 `[cancelled by user]` 保 LiteLLM 协议
- **停机判据 = 解耦「跑了几步」与「是否在推进」**(2026-06-10):用户感知的"轮"是来回对话次数,一个 run 内模型自主连调 N 次 tool **概念上仍是 1 轮**,该放它跑完;真正要掐的是"空转"。故 `max_iterations`(从 capabilities/yaml 读,flash 120 / pro 150)降级为**纯安全 backstop**,不再当"轮预算"砍正经长任务;主防护是两道**进展信号**:① `_RepeatGuard` 逐指纹"同名同参+无产出(`[Error]`/结果一字不差重复)"累计,SOFT=2 注提示、HARD=4 拦截;② run 级全局 `_stall`——整步所有 tool 都无净产出则 +1、任一净产出清零,连续 `_STALL_LIMIT=8` 步主动停(`[stopped: no progress]`),比撞 backstop 早得多掐死循环。撞 backstop / 空转停都 emit 明确"回复『继续』可续跑"提示,不静默停。**取舍**:step-count 是"不收敛"的粗糙代理,正经任务 80 步和死循环 5 步被一刀切同等对待是错的;进展信号才对症。新增成本=run loop 一个计数器,死循环兜底反而更早(8 步 vs 120 步)。
### 3.2 Model Profile(`core/capabilities.py` + `config/models/*.yaml`) ### 3.2 Model Profile(`core/capabilities.py` + `config/models/*.yaml`)
每模型一份 yaml,agent 行为按档案动态调整。新模型 5 分钟接入,不改代码。 每模型一份 yaml(context/输出/parallel_tools/thinking/计费/max_iterations 等),新模型 5 分钟接入不改代码;`LLM.chat` 按档案自动启 features。
字段:max/reliable_context、max_output、parallel_tools、tool_calling_quality、thinking_mode、reasoning_effort_levels、code_quality、enable_run_python、max_iterations、optimal_temperature、prompt_caching、extended_thinking、api_base、api_key_env。
`LLM.chat` 按 capabilities 自动启 `parallel_tool_calls` / `reasoning_effort` / Anthropic prompt-caching header。
### 3.3 Capability Probing(`core/probe.py` + `main.py probe`) ### 3.3 Capability Probing(`core/probe.py`)
yaml 是手填的,probe 用真实调用对账:`basic_chat` / `parallel_tools` / `thinking_mode` / `long_context`(opt-in)。不改 yaml,只出 rich Table 报告。**显式触发,不进启动路径**(避免烧 API) yaml 是手填的,probe 用真实调用对账(basic_chat/parallel_tools/thinking/long_context)。显式触发,不进启动路径
### 3.4 工具系统(Hybrid 范式) ### 3.4 工具系统(Hybrid 范式)
**JSON tool call**(`tools/`):read / write / edit / glob / grep / shell / run_python / load_skill / save_skill / fork_skill — 离散操作。 **JSON tool call** 管离散操作;**run_python**(tmp .py + subprocess + 敏感 env 过滤)管批处理/生成文档。`edit` **唯一匹配**(old_str 重复即报错);工具按**原子操作**切分,不做 `make_pptx()` 式高级封装。持 key 的能力一律 host-side tool、仅对应 env 存在才注册(§7.5 #7)。
**Code execution**(`run_python`):tmp `.py` + subprocess + 工作目录限制 + 敏感 env 过滤(`*API_KEY *TOKEN *SECRET *PASSWORD *PRIVATE_KEY`)— 批处理 / 算数据 / 生成文档。
关键设计:`edit` **唯一匹配**(CoreCoder 风格,old_str 重复即报错);工具按**原子操作**切分,不做 `make_pptx()` 这种高级封装。
### 3.5 Skill 系统(Anthropic 渐进披露) ### 3.5 Skill 系统(Anthropic 渐进披露)
对齐 Anthropic 2025-12 开放标准。三层加载:Discovery(`name + description`,几百 token)→ Activation(`load_skill(name)` 加载完整 SKILL.md,1-5K)→ Execution(SKILL.md 指 `references/xxx` 按需拉)。 三层加载:Discovery(name+description,几百 token)→ Activation(`load_skill` 完整 SKILL.md)→ Execution(references 按需拉)。写 WHY+WHAT 不写 Step 1/2/3;description 决定触发。
原则:写 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 提示用户修,不静默丢、不崩整次扫描。 **用户私有 skill**(2026-06-11):registry 收有序来源列表——内置 `ROOT/skills`(只读)+ 用户 `user_root/.skills`(可写)。取舍:① **user wins 同名覆盖**(核心用例是"copy 内置再改",覆盖只作用于本人会话,blast radius 锁死),覆盖显式标注不静默;② **创作走 host-side `save_skill`/`fork_skill`**——fs 工具的 base_dir 跨 backend 够不到 `user_root/.skills`,host 工具一个落点两模式通吃;③ 用户 skill 加载失败收进 `load_errors` 注入 prompt 提示修,不崩整次扫描。
**Skill 定向模型(frontmatter `model:`,2026-07-06)**:内置 skill 可在 frontmatter 声明 `model: <family.variant>`(如 ppt → `glm.pro52`),表示"该工作流用这个模型效果最好"。架构 = 声明 → 执行 → 兜底 → 呈现四层,**单一执行点**:① 执行 —— 对话中 `load_skill` 命中即 **run 内热切**(agent_builder 注入 switcher 闭包,loop 替换 `self.llm/self.caps`,下一轮 LLM 调用即生效;记账/压缩阈值/reasoning 都读 `self.caps` 自动跟上),并持久化 `tasks.model/model_profile`(resume / 下条 send 沿用);切失败(yaml 配错/缺 key)降级 warn、原模型继续。② 兜底 —— send 侧"档外模型静默降级"对定向模型豁免(判定:落到档外只有"管理员降档"与"skill 定向"两条路,`profile ∈ 内置 skill 定向集合`即放行)。③ 呈现 —— loop emit `model_switch` 事件,前端插模型分隔线 + 顶栏下拉同步。取舍:**定向模型是产品决策,跳过档位门控**(任何档位用户触发该 skill 都可用);**只信内置 skill**(用户 skill 的 `model` 字段忽略,否则自写 frontmatter 即绕门控);**切换不自动切回**("skill 结束"不可判定,且同 task 后续多半仍在该 skill 语境);**不设独立开关、不做建 task 时预切** —— registry 每 run 现扫 SKILL.md,frontmatter 那一行本身就是热配置(删行下个 run 停用,per-skill 粒度),全局 DB 开关是同一行为的第二事实源、建 task 预切是第二执行点,都砍(评估过 `app_settings` 开关表方案,放弃) **Skill 定向模型**(frontmatter `model:`,2026-07-06):内置 skill 声明"该工作流用这个模型最好"(如 ppt→glm.pro52)。**单一执行点**:对话中 `load_skill` 命中 → run 内热切(loop 换 `self.llm/self.caps`,下一轮生效)+ 持久化 task 模型;切失败降级原模型。取舍:**跳档位门控**(产品决策,任何档位可用);**只信内置 skill**(用户 skill 的 model 忽略,防自写 frontmatter 绕门控);**不自动切回**("skill 结束"不可判定);**不设 DB 开关、不做建 task 预切**——frontmatter 那一行本身就是热配置(删行即停,per-skill 粒度),全局开关是第二事实源、预切是第二执行点,都砍
### 3.6 Session 与 Task ### 3.6 Session 与 Task
Session = 消息列表,ORM 直写 PG `messages`(append-only,jsonb 存 LiteLLM 原样 payload);Task = 上层元数据,写 `tasks`。working_dir FS 只存 skill 产物,无 state.json。本地 + SaaS 同一份 schema/ORM,差别只在 `ZCBOT_DB_URL`。字段:`name`=显示名(独立于目录)、`working_dir`=相对 ROOT posix 串(多 task 共享)、`skill`=类型标签。working_dir 在创建入口 eager mkdir;DELETE 走软删(§7.9),FS 一律不动。原子性:PG INSERT 天然;产物走 `atomic_write_text`
**Session**(`core/session.py`)= 消息列表 + meta,**直接 ORM 写 PG `messages` 表**(append-only,`jsonb` 存 LiteLLM 原样 payload)。
**Task**(`core/task.py`)= Session 上层,含 name / working_dir / skill / description / status / model / reasoning_effort / 时间戳 / tokens。**直接 ORM 写 PG `tasks` 表**。working_dir FS 目录只存 skill 产物,无 `state.json` / `messages.json`。本地 + SaaS **同一份 schema 和 ORM**,差别只在 `ZCBOT_DB_URL`
**字段三件套语义**:
- `name`(NOT NULL) = 任务显示名,UI 列表 / 标题 / docx 导出文件名用;独立于工作目录
- `working_dir` = 工作目录(相对 ROOT posix 串),同 working_dir 多 task 共享同物理目录
- `skill` = 智能体类型标签(coding / ppt / proposal / ...自由形式,后续可对齐 `skills/` 注册表强校验)
**创建语义** — working_dir 目录在 task 创建入口立即 `mkdir(parents=True, exist_ok=True)`(`name` 必填代表"显式声明项目";`working_dir` 留空 → fallback 用 name 作目录名)。`Task` 行在 web `POST /v1/tasks` 时即写。**Task 切换 / 软删 / 硬删** 走 dev SPA + `/v1/tasks*`(`DELETE /v1/tasks/{id}` 删 DB 行 + messages CASCADE;**FS 一律不动**,同 name 多 task 共享,绝不 rmtree)。原 CLI REPL(`chat / tasks / export`)2026-05-18 撤,详 §7.9。
**原子性** — PG INSERT 天然原子;skill 产物走 `core.session.atomic_write_text`(tmp + fsync + replace)。
### 3.7 双层记忆(`core/memory.py`) ### 3.7 双层记忆(`core/memory.py`)
跨 task 事实放 `user_root/.memory/`:Core(`core.md`,每次 build_agent 进 prompt)+ Extended(`extended/*.md`,索引进 prompt、内容按需 `read`;索引优先 frontmatter description,legacy 退首行)。system prompt 每次 build_agent 重建,memory 演化即时生效。
跨 task 共享的事实(用户偏好 / 项目约定 / 模型 quirk)放 `workspace/users/<user_id>/.memory/`(per-user,dotfile 隔离): - **写入 = agent 自管**(prompt 契约,非后台蒸馏):`memory_block` 注可写路径锚点 + 维护契约(常驻,即使记忆为空——解冷启动);agent 用已有 fs 工具维护、写前查重。不引专用 `remember` 工具、不做后台蒸馏(不烧额外 token,人可审核手编)。
- **memory 永远在 FS 不入 DB**:用户笔记语义,编辑器手编是产品一部分;跨 task 共享靠同一目录自动达成。dotfile 命名防项目名撞车,`validate_task_name` 拒 `.` 起头双向防呆。
| 层 | 文件 | 加载 | 适合 | - **前端记忆面板只读,"改"全走对话**:看全貌是读、直读 FS 才是地面真相;改走 agent 自管 = 单一写入口、不写坏 frontmatter。故意零写/删 API;将来若"删一条"摩擦大再单加 delete(唯一廉价确定性 mutation)。路径穿越校验收口在 `core/memory.py`
|---|---|---|---| - **快捷指令 ≠ memory**(`core/shortcuts.py`):触发词→完整指令,存 `.memory/shortcuts.md` 但**内容永不注上下文**——入口层(渠道核心 + web post_message 共用)整条精确匹配确定性替换,0 额外 token、渠道无关;maintenance 蹭 memory 心智(对话让模型写)。若反过来塞 core.md 靠模型概率召回:既不确定又每轮烧 token,正是要绕开的坑。
| Core | `core.md` | 每次 build_agent 进 system prompt | 跨任务高频精炼事实(几百 token) |
| Extended | `extended/*.md` | 索引(frontmatter `description`,缺则退回首行标题 — legacy 兼容)+ 可写绝对路径进 prompt,内容靠 `read` 工具按需拉 | 大量低频专题 |
**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. 模型路由 ## 4. 模型路由
默认 `default_model: deepseek_v4.flash`。分模式路由思路: 默认 `deepseek_v4.flash`;复杂 bug / 终稿升 pro + reasoning_effort=max;fallback 手动切 Claude。成本量级:修 bug flash ~$0.01 / 完整申报书 flash ~$0.30(pro-max ~$1.5,Opus ~$10+)。99% 任务 flash 够用。
| 模式 | 模型 | 理由 |
|---|---|---|
| 通用 / 编码 / PPT / 提案初稿 | flash | SWE-Bench 80.6,够用 |
| 复杂 bug / 提案终稿 | pro + reasoning_effort=max | 关键产出 |
| fallback | claude_4_7.opus | V4 不行时手动切 |
成本量级:
| 任务 | flash | pro-max | Opus 4.7 |
|---|---|---|---|
| 修 bug(~10 轮) | $0.01 | $0.05 | $0.30 |
| 5 页 PPT | $0.05 | $0.20 | $1.50 |
| 完整申报书 | $0.30 | $1.50 | $10-15 |
99% 任务 flash 够用,关键终稿升 Pro。
--- ---
## 5. 设计哲学 ## 5. 设计哲学
### 核心原则:Less Scaffolding, More Trust **Less Scaffolding, More Trust**:把 LLM 当会持续变强的同事,告诉它目标不告诉步骤;脚手架在模型升级后会变枷锁。
老 agent 框架失败的核心:给 LLM 太多脚手架,模型升级后这些脚手架成枷锁。**正确做法**:把 LLM 当一个**会持续变强的同事**,告诉它目标,不告诉它步骤 七条:① prompt 用 WHY+WHAT 不用 HOW;② skill 渐进披露;③ 工具原子切分留组合空间;④ Model Profile 化不硬编码;⑤ probing 对账;⑥ 版本化 prompt(真要切再做);⑦ ~~eval~~(dogfooding 更有效)。
### 七条具体原则 借鉴:CoreCoder(主循环 + edit 唯一匹配)/ Anthropic Skills(渐进披露)/ nanobot(workspace 隔离)/ smolagents(LiteLLM + CodeAct)。
1. Prompt 用 WHY+WHAT 不用 HOW — 教"怎么思考"会降智强模型
2. Skill 渐进披露,不写完整流程
3. 工具按原子操作切分,不做高级封装 — 留组合空间
4. Model Profile 化,不硬编码
5. Capability Probing 对账实际行为
6. 版本化 Prompt(等真要切版本时再做)
7. ~~eval 评估~~ — 已删,dogfooding 更有效
### 借鉴
| 来源 | 借鉴 |
|---|---|
| CoreCoder | 主循环简洁实现 + Edit 唯一匹配 |
| Anthropic Skills | SKILL.md 渐进披露 |
| nanobot | Workspace + 任务隔离 |
| smolagents | LiteLLM + CodeAct 启发 run_python |
--- ---
@ -176,621 +107,203 @@ yaml 是手填的,probe 用真实调用对账:`basic_chat` / `parallel_tools` /
| 风险 | 缓解 | | 风险 | 缓解 |
|---|---| |---|---|
| run_python sandbox 不够强(本地非真隔离) | 工作目录限制 + 敏感 env 过滤;SaaS 走 docker exec(§7.5);本地依赖用户最终审阅 | | 本地 run_python 非真隔离 | 工作目录限制 + env 过滤;SaaS 走 docker(§7.5);本地靠用户审阅 |
| V4 某些复杂任务不如 Claude | dogfooding 判断,fallback 手动切 | | V4 复杂任务不如 Claude | dogfooding 判断,fallback 手动切 |
| Skill description 不准 → 触发不到 | Pro 优化描述,实战观察 | | skill description 触发不准 | 实战观察迭代 |
| Long context 退化 | `probe --long-context` 探测可靠 ceiling | | long context 退化 | probe 探测可靠 ceiling |
| 本地 PG 离线 | `docker compose up -d` 起本地 PG 兜底;也可连远端 dev / staging PG | | 本地 PG 离线 | docker compose 起本地 PG / 连远端 |
**Hybrid 范式而非纯 CodeAgent**:V4 JSON tool call 已稳定;sandbox 成本只在需要时付;兼容 thinking。 **Hybrid 而非纯 CodeAgent**:V4 JSON tool call 已稳,sandbox 成本按需付。**不做 subagent**:状态管理爆炸,单 agent + skill 覆盖 95%。**不做 Eval Suite**:单用户 dogfooding 信号更强。
**Anthropic Skill 标准**:行业标准已成,跨 SDK 兼容。
**不做 subagent**:状态管理爆炸;单 agent + skill 已覆盖 95% 场景。
**不做 Eval Suite**:个人单用户场景,dogfooding 信号比造作 case 强,probe 覆盖健康检查。
--- ---
## 7. SaaS 化(草案,status=design,2026-05-12) ## 7. SaaS 化
> §1-§6 是**本地 dogfood 形态**;本节是**SaaS 形态**,把 core 包成多用户在线服务。 > §1-§6 是本地 dogfood 形态;本节把同一份 core 包成多用户在线服务。不引 platform/core 切分——core 就是后端,直接对用户 auth。
> 不引入 platform/core 切分 — core 就是后端,直接对用户做 auth。两条形态共享同一份 core,差别只在反代部署。本节落地前 §1-§6 路线照走,不阻塞 dogfood。
### 7.0 与本地形态的兼容性 ### 7.0 与本地形态的兼容性
SaaS 化不是"重写",而是把同一份 web `/v1` 服务部署到云端。 同一份 web `/v1` 服务换部署位置:Storage 都是 PG(换 `ZCBOT_DB_URL`);working_dir/memory 换外层根目录;Sandbox 本地 subprocess → SaaS per-user 容器;Auth 邮箱密码 + platform_key→JWT → 未来 OIDC(邮箱密码长期并存)。`workspace/` 仅存产物,state/messages 全在 PG。
| 维度 | 本地 | SaaS |
|---|---|---|
| 入口 | `python main.py web` 起 FastAPI + dev SPA | uvicorn 部署形态,反代到 platform UI |
| Storage | **PG**(`ZCBOT_DB_URL` 指 docker compose / 远端 dev PG) | **PG**(指生产 PG) |
| working_dir | `workspace/users/<user_id>/<name>/` | `<storage_root>/users/<user_id>/<name>/` |
| Memory | `workspace/users/<user_id>/.memory/` (FS, dotfile) | `<storage_root>/users/<user_id>/.memory/` |
| Sandbox | subprocess + env 过滤 | per-user sandbox container + per-tool exec |
| Auth | 邮箱密码(`users.email/password_hash`,bcrypt)→ JWT;platform_key → JWT(机器对机器) | OIDC → JWT(D' 替换 platform_key 路径);**邮箱密码长期保留**,与 OIDC 并存 |
`workspace/` 仅存 skill 产物,state / messages 全在 PG。本地 vs SaaS 共用 `users/<user_id>/` 子树布局,差别只在外层根目录,不在 storage 形态。
### 7.1 心智模型:Task 一等公民 + Dir 文件副视图 ### 7.1 心智模型:Task 一等公民 + Dir 文件副视图
两个并列入口,正交不嵌套: 两个并列入口,正交不嵌套:**Task list**(主,"我的对话历史")+ **Dir tree**(辅,"我的文件资产")。类比 Finder + 最近使用。dir **不是 task 的父容器**、无 DB 实体、path 即标识。同 working_dir 多 task 共享 = "同一项目多对话",无需"项目"实体;前缀嵌套拒(no-subtask)。skill 产物全落 working_dir,不引 artifacts 表。空 dir 正常展示(上传本身是有效行为)。多 task 并发写由软警告兜底(§7.9)。
| 视图 | 入口语义 | 适用场景 | API |
|---|---|---|---|
| **Task list**(主) | "我的对话历史" | 任务驱动:"继续昨天那个 bug fix" | `GET /v1/tasks?status=&working_dir=` |
| **Dir tree**(辅) | "我的文件资产" | 项目驱动:"看汇报项目里所有素材 + 关联对话" | `GET /v1/folders` + `GET /v1/files` |
类比:macOS Finder + 最近使用 / Apple Notes 文件夹视图 + 全部备忘录。两个视图查同一份数据的不同切面,**dir 不是 task 的父容器**。
- **Task** = DB 一行,一等公民,自带 `working_dir` 字段:
- **新建必给 `name`**(简单名),`working_dir = workspace/users/<user_id>/<name>/`(留空 fallback 用 name)。同 working_dir 多 task 共享 → "同一项目多对话"语义
- **指定 → 项目化 task**,同 working_dir 多 task 自动共享 `source/` / `sections/` / 终稿(无需建"项目"实体)
- **Dir** = FS 路径,**无 DB 实体,path 即标识**;无父子结构,改名走顶层目录 DB-aware 同事务 cascade(§7.4)
- **No-subtask**:同 working_dir 允许(同项目多对话),前缀嵌套拒
- **Messages** = DB 表,append-only,`jsonb` 存 LiteLLM 原样 payload
- **Skill 产物**全落 working_dir,不引入 artifacts 表;SKILL.md 指示 agent 清中间件
- **Skill 定义**是项目代码,跟部署走,所有用户共享
**空 dir**(用户上传素材但还没开 task)在 dir tree 视图正常展示 — 上传本身是有效产品行为;UI 上跟"有 task 的 dir"做轻量区分(如 task 数 badge)。
state / messages 两形态都在 PG,FS 只承担 skill 产物。多 task 共享同 working_dir 时由 §7.8 文件级悲观锁兜底。
### 7.2 资源模型(/v1) ### 7.2 资源模型(/v1)
Task 一等公民;files 与 task 正交(§7.1),走 user-rooted `/v1/files*`,以 `workspace/users/<uid>/` 为边界(不强制选 task)。所有路由统一 `/v1` 前缀,**返 JSON**;前端由 platform 端实现(§7.9 取舍),本地开发用 FastAPI `/docs` Swagger UI 自查。 统一 `/v1` 前缀返 JSON;UI 由 platform 实现(§7.9),本地 dev SPA dogfood。要点(细节见 `web/app.py`):
``` ```
Tasks Tasks POST/GET/PATCH/DELETE /v1/tasks*(分页+筛选+ordering allowlist;DELETE=软删,FS 不动)
POST /v1/tasks {name(必填), working_dir?, description?, skill?};不合法 → 400 GET /v1/folders(working_dir + task 计数)
GET /v1/tasks?page=&page_size=&status=&skill=&working_dir=&q=&ordering= GET/POST /v1/tasks/{id}/messages(POST 起 run;单活 run:running/cancelling→409,
分页 1-based;page_size 1100 clamp;ordering DRF 风格逗号分隔, SELECT FOR UPDATE 锁 task 行防 idx race)
`-field` 倒序;allowlist created_at/updated_at/name/status; GET /v1/tasks/{id}/events(SSE) POST /v1/tasks/{id}/cancel(协作式,202)
**默认 `-created_at`**;返 `{page, page_size, count, results}` Auth POST /v1/auth/login(platform_key)/ login_password / change_password;GET /v1/me
GET /v1/tasks/{id} 单 task meta Files GET /v1/files?path= / upload / download / delete / rename
PATCH /v1/tasks/{id} {status?,description?,name?,skill?};active 不让从 web 切回 (user-rooted;dotfile 隐藏;越界 400;顶层目录 DB-aware,见 §7.4)
DELETE /v1/tasks/{id} 硬删:DB 行 + messages CASCADE;**FS working_dir 保留** Admin GET /v1/admin/*(require_admin;overview + usage/models|users + storage/users)
GET /v1/folders 列当前 user 的 working_dir + 关联 task 计数 + 最后使用时间 Export GET /v1/tasks/{id}/export(docx)
GET /v1/tasks/{id}/messages 历史(后续 ?search= 走 jsonb GIN / tsvector)
POST /v1/tasks/{id}/messages {content} 发消息 + 起 run,返 {events_url}
**单活 run**(0004 简化):tasks.run_status in
('running','cancelling') → 409;`SELECT … FOR UPDATE`
锁 task 行序列化并发 POST 防 messages.idx race
GET /v1/tasks/{id}/events SSE 流(见下) — 订阅 task 当前活动事件,
单活 run 形态下无歧义,客户端只需 task_id
POST /v1/tasks/{id}/cancel 协作式 cancel(202):标 cancelling + 信号 broker;
BG loop 在 stream chunk 间 + 工具调用之间 poll 看见即退;
run_status != running → 409;cancel 延迟 ~ 单 chunk 间隔(100ms 级)
Auth
POST /v1/auth/login {user_id, platform_key} → JWT(platform 机器对机器)
POST /v1/auth/login_password {email, password} → JWT(dev SPA / 同事试用)
bcrypt 校验 users.password_hash(0005 加 UNIQUE(email));
错邮箱 / 错密码 / 未设密码统一 403 防探测
POST /v1/auth/change_password {old_password, new_password} → {ok}(dev SPA 顶栏自助改密)
需 Bearer(user_id 取自 JWT);验旧密码 + 新密码 ≥6 bcrypt 重哈希;
旧密码错 / platform_key 建的无密码行 → 403,弱密码 → 400
Files(user-rooted,workspace/users/<uid>/ 为根)
GET /v1/files?path= 列子目录 {entries, crumbs, exists, root, current};
留空 → user_root;dotfile(`.memory/` 等)一律隐藏
POST /v1/files/upload multipart;path 通过 form;严格拒含 / \\ .. 的 filename
GET /v1/files/download?path= 下载单文件;`..` / 绝对 / symlink 越界 400
POST /v1/files/delete {path} 文件或空目录;非空目录 400;user_root 拒;
**path 是顶层目录(user_root 直接子项)且被 task 引用 → 409**
POST /v1/files/rename {path, new_name};sibling 已存在 → 409;
**path 是顶层目录** → 同事务 SELECT FOR UPDATE 锁关联 task +
任一 running/cancelling → 409 + check_no_subtask 防嵌套;
DB UPDATE 在 FS rename 之前,FS 失败回滚 DB
Export
GET /v1/tasks/{id}/export docx 临时文件下载,BackgroundTask 删 tmp
Misc
GET /healthz {"status":"ok"}
GET / 302 → /static/dev.html(本地 dev SPA)
``` ```
**SSE 事件**(`Content-Type: text/event-stream`,响应头带 `X-Accel-Buffering: no` 给 nginx 反代友好;每事件 `event: <type>` + `data: <JSON>`): **SSE 事件**:`run_start / llm_start / text{delta} / tool_call / tool_result(预览,完整走 DB)/ llm_end / model_switch / cancelled / error / done`。fan-out:每订阅独立 queue;迟到订阅立收 done。事件不持久化(messages 走 PG)。
**版本化**:`/v1` minor 半年兼容,major 6 个月 deprecation。**CORS**:本地 `*`,部署收紧。
```
run_start {}
llm_start {}
text {"delta":"<delta 文本>"}
tool_call {"name":"...","args":{...},"args_preview":"..."}
tool_result {"name":"...","preview":"...","truncated":bool} # 完整 result 走 DB,SSE 只送预览
llm_end {"prompt_tokens":N,"completion_tokens":N}
cancelled {} # cancel 命中,后随 done 收流
error {"msg":"<type>: <detail>"}
done {}
```
订阅 fan-out:同 run 多订阅者(刷新 / 多 tab / 多设备)每订阅 1 独立 queue。订阅迟到(run 已 done)立刻收 done 不挂。事件不持久化 — messages 走 PG,未来要"刷新继续看流式"再加 event log。
**版本化**:`/v1` minor 半年向后兼容,major 6 个月 deprecation。
**CORS**:本地 dev `allow_origins=["*"]`;部署 platform 时收紧。
**Auth**:Bearer JWT 走所有 `/v1/tasks*`;`/healthz`、`/docs`、`/openapi.json`、`/`、`/v1/auth/login*`、`/static/*` 豁免。
### 7.3 认证 ### 7.3 认证
**当前形态(D' 过渡)**:两条 login 路径签**同款 JWT**(HS256,`JWT_SECRET` env 签,默 7d TTL): 两条 login 签同款 JWT(HS256,7d):`login`(platform 机器对机器,持 PLATFORM_KEY 可为任意 user_id 签;body 可带 name/user_name,upsert COALESCE 落 users——与未来 OIDC claim 注入同构)+ `login_password`(邮箱 bcrypt;错误统一 403 防探测)。`require_user` 提取 user_id,所有查询带 `Task.user_id==` 隔离;`require_admin` 再查 `users.role=='admin'`(**role 走 DB 不进 JWT**,改完即时生效)。信任模型:platform 是单点可信中间层,泄漏风险与 platform 自身同级,可接受。**未来 OIDC** 只换 login 内部校验,路由层不动;**无 tenant 层**(企业版再加 org_id)。
- `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` 条件做隔离。`/v1/admin/*` 在 `require_user` 基础上再叠一层 `users.role=='admin'` 检查(`make_require_admin`)。`PLATFORM_KEY` / `JWT_SECRET` 任一缺失 → app 启动 fail-fast。
**信任模型**:platform 是单点可信中间层(持 PLATFORM_KEY = 可为任意 user_id 签 token),风险与"platform 服务端泄漏 = 用户身份泄漏"同级,可接受。
**未来形态(真 OIDC)**:Provider 签 ID token,zcbot `/v1/auth/login` 内部从"校验 PLATFORM_KEY"换成"校验 ID token 签名 + 提取 sub" — **路由层 Depends 不动**,Bearer JWT 契约不变。**邮箱密码路径长期保留**,与 OIDC 并存(自有账号体系 + 同事试用不依赖外部 IdP);OIDC 只接管 platform 机器对机器那条路径。所有 storage/executor scoped by `user_id`,**无 tenant 层** — 个人 SaaS 用不上,做企业版再加 `org_id` 等价隔离。
### 7.4 存储:Postgres + 本地文件系统 ### 7.4 存储:Postgres + 本地文件系统
```sql ```sql
users(user_id uuid pk, email text null unique, password_hash text null, oidc_subject null, plan null, users(user_id pk, email unique null, password_hash, oidc_subject, plan, -- plan=模型档位(0.31 启用)
-- plan:模型档位名(0001 起就有列,0.31 起启用;之前休眠)。值是 config/agent.yaml name, user_name, -- 0016 平台注入档案
-- model_tiers 的 key(如 'pro');NULL/未知 → 落 'default' 档。控制该用户能用哪些模型, role default 'user', -- 0009 admin 门控
-- 详见 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) created_at)
-- email UNIQUE (0005);NULL 不冲突,允许 platform_key 入口 user 共存 tasks(task_id pk, user_id fk, name NOT NULL, working_dir NOT NULL, skill, description, status,
-- 入口三条:① main.py user add(bcrypt → password_hash;dev SPA 邮箱密码登录用) model_profile, tokens_*, cost_usd,
-- ② /v1/auth/login platform_key 路径 ensure_user_row(只填 user_id) channel default 'web', -- 0013 渠道来源,仅 INSERT 写定
-- ③ 未来 OIDC(替换 login 内部;email/oidc_subject 由 ID token 注入) run_status default 'idle', run_error, -- 0004 合并 runs 表
-- role:make_require_admin 每请求查(不进 JWT,改完即时生效、老 token 不重签); scheduled_job_id, -- 0017 定时任务归属,普通列表过滤
-- 提管理员 main.py user role --email X --role admin。与 ZCBOT_ADMIN_TOKEN context_base_idx, -- 0019 §8.8 软重置窗口起点
-- (发用户共享口令)正交,互不相干 deleted_at, -- 0010 软删
created_at, updated_at)
tasks(task_id uuid pk, user_id fk, name text not null, working_dir text not null, skill, description, messages(pk, task_id fk, idx, payload jsonb, tokens_in/out, model_profile, kind, -- kind=push 等
status, model_profile, tokens_prompt, tokens_completion, cost_usd, unique(task_id, idx); gin(payload))
channel text not null default 'web', -- web/wechat 渠道来源(0013);仅 INSERT 写定, usage_events(pk, user_id, task_id, message_id, kind, -- chat/image/video/vision/... 自由文本
-- upsert/save 不传不覆盖。前端据此打徽章 + 列表强制置顶 model_profile, units jsonb, cost numeric, created_at) -- 多态用量,加媒体不动 schema
run_status text not null default 'idle', -- idle/running/cancelling/error(0004 合 runs 表) scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
run_error text null,
created_at, updated_at);
create index on tasks (user_id, working_dir);
-- working_dir 存储:相对 ROOT 的 posix 串(workspace/users/<uid>/<name>);写入入口
-- 只接 simple name,越出 ROOT → to_db_path raise(不留 ROOT 外路径)
-- 读写边界统一过 core/paths.py::{to_db_path, from_db_path}
-- 入口校验 validate_task_name():拒空 / 含 /\NUL / `.` 起头 / >255
messages(message_id uuid pk, task_id fk, idx int not null,
payload jsonb not null, tokens_in, tokens_out,
model_profile text null, -- 0006:只在 assistant 行有值,标产生该 msg 的模型
created_at,
unique (task_id, idx));
create index on messages using gin (payload jsonb_path_ops);
usage_events(event_id uuid pk, user_id fk, task_id fk on delete cascade,
message_id fk on delete set null,
kind text not null, -- chat / image / video / audio / ...(0006 起只 chat,媒体扩展位)
model_profile text not null,
units jsonb not null, -- chat: {tokens_in, tokens_out};image: {count, size};...
cost_usd numeric(12,6) not null default 0,
created_at);
create index on usage_events (user_id, created_at); -- 用户级聚合走这条,JOIN-free
create index on usage_events (task_id);
create index on usage_events (model_profile, created_at);
``` ```
**0004 简化**:`runs` 表角色等价"task 当前 in-flight 状态",合并到 `tasks.run_status` + `run_error`;`run_id` 单活 run 形态下对客户端 / broker / cancel 全冗余 → 客户端只需 task_id。 - working_dir 存相对 ROOT posix 串,读写统一过 `core/paths.py`;入口 `validate_task_name` 拒空/`/\NUL`/`.` 起头。
**0006 模型切换 + 用量统计**:`tasks.model_profile` 从 0001 起就有,本次开始真用 —— task 创建时 UI 选 / PATCH 切;`build_agent` resume 读它而非 `cfg["default_model"]`(A 粒度:下条 send 才生效,当前 run 不受影响)。`messages.model_profile` 新增,assistant 行落实际用的模型,前端按 model 切换点画小标。`usage_events` 表 0004 删掉的简陋版形态(id/user_id/task_id/run_id/kind/value/ts)字段不够多态,本次重建 v2 形态:per-event 一行,`units` JSONB 装多态用量(token / 张数 / 秒数),`cost_usd` 用 litellm cost map 算;chat 已接入(`core/loop.py` 在 assistant message 入库后调 `record_chat_usage`),媒体工具未来加 image/video kind 不动 schema。**`tasks.tokens_prompt/completion/cost_usd` 三列保留作粗 task 级概览**,继续由 `sync_task_tokens` 维护;`messages.tokens_in/out` 同时双写,查 message 详情不需 JOIN。统计真实 source-of-truth 走 `usage_events`,跨用户 / 跨模型 / 跨时间维度都按 `(user_id, created_at)` 索引直查。 - **0004 简化**:runs 表只写不读、run_id 单活 run 下全冗余 → 合并 `run_status/run_error` 入 tasks。**0006**:`tasks.model_profile` 为 source-of-truth(PATCH 切、下条 send 生效);usage_events 重建 v2 多态形态,统计 source-of-truth;tasks 三列保留作粗概览。run_status 终态:ok/cancelled 收回 idle,只有 error 持久。
**run_status 终态语义**:`ok` / `cancelled` 收尾回 `idle`(用户视角等价),只有 `error` 持久(让用户能看到),起新 run 时由 `post_message` 清。 - **No-subtask**:同 user 下前缀互含即拒(归一 posix 后 Python 端比对);同 working_dir 允许。
- **files API 是目录树唯一 mutation 入口,DB-FS 一致性服务端内化**:顶层目录 rename/delete 走 DB-aware 分支(事务锁关联 task、running→409、DB UPDATE 先于 FS、delete 被引用→409)。
**No-subtask 校验**(`create_task`):同 user 下查 `new LIKE existing/%``existing LIKE new/%`,中一则拒;同 working_dir 允许。两侧先用 `from_db_path` 归一到 absolute posix 再比前缀(混合存储形态不漏判),数量小直接 Python 端比对,不在 SQL 里拼分隔符。 - **单一 PG ORM**(本地 + SaaS 共用):一份 schema 一份查询,无 adapter,alembic 管 migration。
**Folder rename / delete**(`/v1/files/rename` + `/v1/files/delete`):**files API 是目录树唯一 mutation 入口,DB-FS 一致性作服务端不变量内化**(§7.9 架构教训)。顶层目录(user_root 直接子项)走 DB-aware 分支:事务内 `SELECT ... FOR UPDATE` 锁关联 task + 任一 running/cancelling → 409 + `check_no_subtask(exclude=被改名 tids)` 防嵌套;rename UPDATE DB 在 FS rename 之前(FS 失败可回滚);delete 顶层目录有任意 task 引用 → 409 要求先 DELETE 关联 task。
**文件系统**(本地 `<storage_root>` = `workspace/`,SaaS 替换为部署根):
```
<storage_root>/users/<user_id>/
.memory/{core.md, extended/} # per-user 记忆,dotfile 隔离,不入 DB
<name>/ # 项目目录,name 用户起(必填),working_dir 直接落这
# 同 name 多 task 共享同目录(§7.1)
```
**Storage 实现:单一 PG ORM**(本地 + SaaS 共用):一份 schema、一份 SQLAlchemy、一份查询,无 adapter,无 SQL 方言适配,无契约测试。alembic 管 migration。
### 7.5 沙盒:Per-user 容器 + Per-tool exec ### 7.5 沙盒:Per-user 容器 + Per-tool exec
| 选择 | 理由 | 选型:**每 user 长驻容器**(文件模型本就以 user root 为安全边界,per-task 会切碎共享工作区)+ **每 tool 一次 docker exec**(exec 级 timeout/cwd/统计)+ 空闲 5 分钟回收 + bind mount user root→`/workspace`。
|---|---|
| 每 user 长驻 sandbox container | 文件模型本来以 `<storage_root>/users/<user_id>/` 为安全边界;同 user 多 task / working_dir 共享素材与中间产物,per-user 容器比 per-task 容器更贴合心智模型 |
| 每 tool 调用一次 `docker exec` | exec 级 timeout / cwd / 资源统计;一轮对话内多次 tool call 复用同一容器 |
| 空闲 5 分钟回收 | 对话结束后进入 idle;5 分钟内新对话 / 新 tool call 复用,否则销毁;不浪费也避免频繁 cold start |
| bind mount = user root | `<storage_root>/users/<user_id>/``/workspace`;每次 exec 显式 `cwd=/workspace/<working_dir>`;同 user 内不做运行时隔离,跨 user 由独立容器 + 独立 mount 隔离 |
**边界划分**:Control plane 留在宿主后端,Execution plane 进容器。宿主后端负责 auth / JWT / DB 事务 / task-message 状态机 / `/v1/files` 路径校验与上传下载 / SSE broker / LLM 调用 / 受控 `web_search``web_fetch` / 配额审计。容器只跑不可信执行: `shell`、`run_python`、用户或模型生成脚本、编译器 / 解释器 / 包管理器 / 渲染命令。目标不是"所有操作都进容器",而是"所有不可信代码执行都不能在宿主执行";否则 DB 凭据、JWT secret、对象存储凭据反而更容易被带进执行面。 **边界划分**:Control plane 留宿主(auth/DB/files 校验/SSE/LLM/受控 web 工具/配额审计),Execution plane 进容器(shell/run_python/任意生成代码)。目标不是"所有操作进容器",是"所有不可信执行不能在宿主"——否则凭据反被带进执行面。
**硬限制**:cgroup CPU/mem、`pids-limit`、单次 exec timeout、同 user 并发 exec 数、上传大小、root fs read-only、`tmpfs /tmp`、no-new-privileges、drop ALL caps、非 root 用户运行。`run_python` 临时文件落 `/tmp/zcbot/<task_id>/` 或 working_dir 受控临时目录;exec 结束后按进程组清理,避免后台进程常驻 **硬限制**:cgroup CPU/mem、pids-limit、exec timeout、并发数、read-only rootfs、tmpfs /tmp、no-new-privileges、drop ALL caps、非 root、`--shm-size`。**软配额**:按 user 计 DB(磁盘/LLM cost/wall time/流量/并发),超额 429。**网络**:默认 deny outbound,搜索抓取走宿主受控工具。
**软配额**:按 user 计入 DB,超额返回 429 / 402 / 明确错误。配额项包括 workspace 磁盘总量、月度 LLM cost / tokens、tool wall time、文件上传下载流量、网络下载量、running task / exec 并发数。磁盘配额起步用应用层统计(上传 / write / tool 执行前后检查 + 周期扫 user root),后续需要更硬边界再上 filesystem project quota / volume driver。 **落地清单(Stage C 硬协议,实施按此对账)**:
1. **网络 blocklist 硬编码段**(任一缺失=未完成):`169.254/16`(metadata)、loopback、内网三段、CGNAT `100.64/10`;**PG 实际 IP 单独再 block**(belt-and-suspenders)。
2. **egress 模型**:容器 `HTTP(S)_PROXY` 走宿主 proxy + iptables DROP 其余 outbound(防 SDK 绕 env);proxy 做域名 allowlist(pypi/github/npm + 镜像)+ IP block + per-user 计量 + 审计。
3. **进程组清理**:exec 套 `setsid`,timeout/cancel/正常三路径都 `kill -- -PGID`——防 `nohup` 派生 daemon 跨 exec 持久化成"跨对话后门"。
4. **磁盘配额硬化时点**:首版应用层统计;**外部用户开放前必须升 xfs/ext4 project quota**(扫描间隙打满共享盘会拖死同节点)。
5. **Executor 接口 + runtime 注入**:不 hard-code docker exec,走 `Executor.call_tool` 抽象 + `ZCBOT_SANDBOX_RUNTIME` config——未来切 gVisor/Firecracker 应用层零改动。
6. **工具按信任域二分,Executor 内部 dispatch**:container backend = shell/run_python/fs 全套(fs 以前 host 跑无 user_root 校验能读任意文件,进容器 `/workspace` 是物理边界;`tool_runner.py` stdin 喂 JSON);host backend = load_skill/skill_authoring/web_*/媒体/documents 等持 key 工具。AgentLoop 零感知。代价每 fs call ~200ms,LLM 推理下是噪声。
7. **Secret-bearing 域工具不进 sandbox、不做 key 下发**:容器内任意代码可 `print(os.environ)`,短期 token 只缩窗口不改根因;正确形态 = host-side JSON tool(LLM 传业务参数 → host 持 key 调远端 → 裁剪计量审计 → 返结果/落盘路径)。仅 env 存在才注册。
**网络**:容器默认 deny outbound 更安全;搜索和网页抓取走宿主后端受控工具。确需安装依赖时走受控 PyPI 镜像或 HTTP proxy,并计量下载量;不要让容器自由 `curl` 外网 / 内网 / cloud metadata。 **升级触发信号(无信号不升级)**:
**选型**:起步 Docker;流量起来后视情况换 gVisor / Firecracker / e2b。
**落地清单(Stage C 实施硬协议,与 PROGRESS Stage C DoD 锚定;实施时按此对账)**: | 方向 | 触发信号 | 不升级理由 |
1. **网络 blocklist 硬编码段**(容器 iptables 启动必含,**任一缺失=Stage C 未完成**):`169.254.0.0/16`(cloud metadata SSRF)、`127.0.0.0/8`+`::1`(loopback)、内网三段 `10/8`+`172.16/12`+`192.168/16`、`100.64.0.0/10`(CGNAT);**PG 实际 IP 单独再 block 一遍**(belt-and-suspenders,自定义网络/VPC peering 会让段级 block 看似覆盖实际能直连)。
2. **网络 egress 模型**:容器内 `HTTP(S)_PROXY` 走宿主侧 proxy + iptables `DROP outbound except <proxy port>`(防 SDK 不读 env 绕过)。宿主 proxy 负责:① 域名 allowlist;② 红线段 IP block(再做一次);③ per-user 出网字节计量入软配额(超额 429);④ 审计日志 `network_audit`。**Allowlist 初始集**:`*.pypi.org`/`*.pythonhosted.org`/`github.com`/`raw|codeload|objects.githubusercontent.com`/`*.npmjs.org` + 部署的 PyPI 镜像域名。
3. **进程组清理协议**:`docker exec` 通过 `setsid` 包一层,timeout/cancel/正常结束三路径都 `kill -- -PGID` 杀整组。**目的**:防 `nohup &`/`disown`/派生 daemon 跨 exec 持久化——同 user 不做内隔离,stale 进程能看到后续 exec in-memory 状态,守不住这条残留风险就放大成"跨对话持久后门"。
4. **磁盘配额硬化时点**:首版应用层统计 + 周期扫描(=软配额);**外部用户开放前必须升级到 xfs/ext4 project quota 或 zfs dataset quota**。否则扫描间隙打满共享 fs 会拖死同节点其他 user(写满远快于扫描周期),且不算配额超额、排查痛苦。
5. **Executor 接口 + runtime config 注入**:不在工具层 hard-code `docker exec`,走 backend driver 抽象(`Executor.call_tool(tool, args, ctx)`);container runtime 走 config `ZCBOT_SANDBOX_RUNTIME=runc|runsc`(`docker run --runtime=`)。**理由**:未来切 gVisor/Firecracker/Kata/e2b 应用层零改动,避免接口泄漏 Docker 假设(`docker exec/cp/stats`)致后期重写。
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`/`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 文档提示降级。
**升级触发信号(反向兜底:无信号不升级)**:
| 升级方向 | 触发信号 | 不升级的理由 |
|---|---|---| |---|---|---|
| Docker → **gVisor**(`runsc`) | 开放陌生注册 / 逃逸 CVE 未及时打补丁窗口 / 可疑 syscall 告警 | Docker + 完整 hardening 已挡主流逃逸,kernel 0day 在 dogfood 阶段非 #1 风险;gVisor syscall -30~50% 真代价 | | Docker → gVisor | 陌生注册开放 / 逃逸 CVE 窗口 / 可疑 syscall | 完整 hardening 已挡主流逃逸;gVisor syscall -30~50% 真代价 |
| gVisor → **Firecracker / e2b** | 合规客户(PCI/HIPAA) / 单机 100+ user / gVisor 兼容墙撞死 | Firecracker 每 VM 100MB+ 起步不划算;e2b 数据出去执行与 storage_root 自持模型冲突 | | gVisor → Firecracker/e2b | 合规客户 / 单机 100+ user / 兼容墙 | 每 VM 100MB+ 不划算;e2b 与 storage_root 自持冲突 |
| `docker exec`**容器内 tool-runner**(socket RPC) | `docker_exec_overhead/total > 30%` 持续两周 / 模型起长驻 web 服务 / 单轮工具调用 >20 次 | 自管进程组清理 + cgroup + 状态污染面 + 失去 Docker 工具链观测,代价 >> 200ms×N;**美学统一性 ≠ 升级理由** | | docker exec → 容器内 tool-runner RPC | exec 开销 >30% 持续两周 / 长驻服务 / 单轮 >20 次调用 | 自管清理+观测损失 >> 200ms×N;美学统一 ≠ 理由 |
**Image 体积 / 多 user 资源 / 加包策略**(2026-05-28):sandbox image ~1.5G(python+chromium+node+mermaid),后续 domain 包还会推大。三点认知分开: **Image 体积 / 多 user 资源 / 加包**(2026-05-28):① image 大 ≠ 运行时吃资源(layer 共享、不 exec 只是磁盘字节);② 瓶颈在并发 exec 不在 idle 容器,杠杆全在运行时限制;③ 新增依赖 = base 收敛 + **per-user venv**(`<user_root>/.venv/`,bind mount 回收不丢;不放共享 volume——install 脚本是任意代码,破坏隔离)+ 使用频次沉淀进 base。
1. **Image 大 ≠ 运行时吃更多资源**:空载 `sleep infinity` RSS 个位数 MB,image 里的库不 exec 只是磁盘字节;layer 共享让 N 个 user 容器磁盘乘数=1。真吃 RAM 的是 active exec(chromium 渲 mermaid 瞬时 200-500MB),跟 image 大小解耦。 ### 7.6 / 7.7 改造项与阶段
2. **多 user 瓶颈在并发 exec 不在 idle 容器**:100 idle 容器几百 MB 可接受;10 user 同渲 mermaid 瞬时 2-5GB 才是瓶颈。**杠杆全在运行时**(单容器 `--memory/--cpus/--pids-limit` + 同 user exec semaphore + 整机 active cap + idle 5min 回收),减 image 体积对这条曲线无影响。
3. **新增依赖:base 收敛 + per-user 持久化 venv + 使用频次沉淀**:重包(torch/texlive)或长尾 domain 包不进 base,中高频+轻量的留 base;**采用 per-user venv** 落 `<user_root>/.venv/`(bind mount 进容器 idle 回收不丢,`pip install --target` + `PYTHONPATH` 注入)。不放共享 named volume(破坏跨 user 隔离,install 脚本是任意代码);不依赖 pip cache(只省网络、回收照丢)。**沉淀机制**:audit 统计 >30% user 装过 ≥3 次的包 → 下次 build 合并进 `requirements.txt`,base 跟真实使用收敛。
落地对应 Stage C `DockerExecutor`(cgroup limits / 并发 semaphore / idle 回收 / per-user venv);audit 沉淀可延后。 进度见 PROGRESS `## 状态`。依赖顺序:事件流化 → PG(一次性切换无双轨)→ working_dir 语义 → Files API → no-subtask → Executor+sandbox → /v1 → ~~CLI 双模式~~(撤)→ ~~Web UI~~(撤,API-only)。阶段:A/B/D/D' 完;CORS 收紧应尽快;真 OIDC 选做;**C 是外部用户开放 hard prereq**;F(限流/监控/HA)持续。
### 7.6 Core 代码改造(按依赖顺序)
> 哪步做完见 PROGRESS `## 状态`;此处只记改造项与依赖顺序。
| # | 项 |
|---|---|
| 1 | 事件流化 `loop.py` |
| 2 | Storage 落 PG(Session/TaskState 改 SQLAlchemy + alembic + docker-compose) |
| 3 | working_dir 字段语义(name 必填,派生 `users/<uid>/<name>/`,同 name 共享) |
| 4 | Files API(list/upload/download/delete/rename,user-rooted) |
| 5 | No-subtask 校验 |
| 6 | Executor + sandbox(`run_python`/`shell` → `Executor.run`;docker exec) |
| 7 | HTTP /v1 surface |
| 8 | ~~CLI 双模式~~ — 撤(§7.9) |
| 9 | ~~Web UI~~ → API-only,UI 由 platform 实现 — 撤(§7.9) |
代码量增量:**+1000~1500 行**(单一 PG 比双 adapter 省 500-800 行;UI 不计入)。
### 7.7 分阶段落地
> 阶段做没做完见 PROGRESS `## 状态`;此处记阶段范围 + 设计意图(估时 / 撤销 / 前置依赖)。
| 阶段 | 范围 | 设计意图 |
|---|---|---|
| A | 事件流化 | — |
| B | Storage 落 PG + working_dir 语义 + no-subtask | 一次性切换,无双轨(见下) |
| D | HTTP /v1 surface | — |
| D' 过渡 | 邮箱密码 + PLATFORM_KEY → JWT + user_id 隔离 + dev SPA | — |
| CORS 收紧 | `allow_origins``*` 改 platform 域名 allowlist | 已接入真实用户,**应尽快做**(与 OIDC 解耦) |
| D' 真 OIDC | 替换 /v1/auth/login 内部为 ID token 校验(邮箱密码并存保留) | 选做,platform_key 信任模型可接受则可延后;真要弃 PLATFORM_KEY 共享密钥时再做 |
| C | Executor + sandbox(`run_python`/`shell` → `Executor.run`;docker exec) | 3 天,**外部用户开放的 hard prereq**(详 §7.8 / §7.9 2026-05-21) |
| ~~E~~ | ~~CLI transport 双模式~~ | 撤(§7.9) |
| ~~G~~ | ~~Web UI 简洁版~~ | 撤(§7.9) |
| F | 上线打磨(限流 / 监控 / 告警 / HA) | 持续 |
**B 阶段一次性切换**:切到 PG 后本地与 SaaS 走相同代码路径,无回退、无双轨,dogfood 即生效。
**D 落在 G 前面**:原排期 D 在 G 后(以为 dogfood 用 UI 跑),转向"platform 端联调"后 API surface 反而成阻塞;G 的 Jinja2+HTMX 投入沉淀的 sink 协议 / broker / no-subtask / files 路径安全归一 / task_dir 相对存储仍被 D 复用。
### 7.8 已知风险 ### 7.8 已知风险
| 风险 | 缓解 | | 风险 | 缓解 |
|---|---| |---|---|
| 过早抽象违背 §5 | B 阶段单一 PG 无 adapter;各阶段独立 dogfood 价值;CLI REPL 整套撤无双 transport 维护税 | | `/v1` 冻死演化慢 | minor 半年兼容 + deprecation 窗口 |
| `/v1` 冻死后演化慢 | minor 半年兼容,major 6 个月 deprecation | | running task 被 rename/delete | 后端校验 + UI 禁按钮 |
| Rename 误中前缀 / 漏改子 task | cascade SQL 用 `old/%` + 单测覆盖 | | DB-then-FS 中断孤儿 | rename DB 先行可回滚;delete 后台 GC 扫"FS 有 DB 无" |
| Running task 被 rename / delete | 后端校验 + UI 禁按钮(详 §7.4) | | 同 wd 多 task 并发写同名 | known limitation,频率近 0;软警告 banner;宪法文件已按 short_id 命名隔离 |
| 误删 folder | 二确认 + 输入 folder 名;真要再加 trash bin | | 并发 POST 撞 messages.idx | 单活 run gate(FOR UPDATE + 409)+ lifespan reaper;multi-worker 再换 lease |
| DB-then-FS 中断留孤儿目录 | rename 顺序 DB UPDATE → FS rename(FS 失败回滚 DB);delete 后台 GC 周期扫"FS 有但 DB 无引用" | | shell/run_python 无沙箱开放外部 = 主机沦陷 | **Stage C 是 hard prereq**;`BLOCKED_PATTERNS` 是 trivial-bypass 装饰品,不再加规则(黑名单 fundamentally broken),防线在 OS 层 |
| 同 folder 多 task 并发写同名 | known limitation,实践频率近 0(同 wd 多 task 是"项目对话历史轨迹",非并发);dev SPA chat 区顶 banner 软警告(`GET /v1/tasks?working_dir=&run_status=running,cancelling` 拉同 wd 活跃邻居),不挡发送;宪法文件已由 `<date>-<short_id>` 命名隔离(§7.9 2026-05-20);真高频出现再加 gate | | sandbox 出站越权 / 资源滥用 | default-deny + 受控 proxy;硬限制 + 软配额 + idle 回收 |
| 同 task 并发 POST messages 撞 `messages.idx` | `POST /messages` 单活 run gate:`SELECT … FOR UPDATE` 锁 task + `run_status in ('running','cancelling')` → 409;启动 lifespan reaper 把孤儿 `running`/`cancelling` 全标 error。未来 multi-worker 换 heartbeat / lease |
| Run 跑太久 / 用户想中断 | `POST /v1/tasks/{id}/cancel` 协作式;LLM 走 streaming,chunk 间 poll cancel → 延迟 ~ 单 chunk 间隔(100ms 级)|
| `shell` / `run_python` 在 SaaS 无沙箱即开放外部用户 = 主机沦陷 / 跨 user 读 working_dir / cloud metadata 凭据泄漏 / 内网扫描 | **Stage C(§7.5 per-user sandbox container + per-tool exec + drop caps + read-only rootfs + bind mount = own user root + default-deny network)是开放外部用户的 hard prereq**;现状 `tools/shell.py::BLOCKED_PATTERNS` 是 trivial-bypass 的装饰品(双空格 / `bash -c` / `python -c` / `curl \| sh` / `cd /` 全能过),**不在它上面加规则**,黑名单 fundamentally broken;外部开放前仅 dogfood + 信任同事白名单手动加 |
| Sandbox 出站越权 | 容器默认 deny outbound;搜索 / 抓网页走宿主受控工具;依赖安装走受控 PyPI 镜像或 HTTP proxy,并显式阻断 cloud metadata / 内网地址 |
| 资源滥用 | 容器硬限制(CPU/mem/pids/timeout/并发 exec/上传大小) + user 软配额(磁盘、LLM cost、tool wall time、文件流量、网络下载量);idle 5 分钟回收 |
### 7.9 取舍说明 ### 7.9 取舍说明
**path-as-identity 而非 folder_id**:folder 真实存在于 FS,folder_id 等于造两份 source of truth。rename 是 UI 主动动作,顶层目录走 DB-aware 同事务 cascade(§7.4)。 - **path-as-identity 而非 folder_id**:folder 真实存在于 FS,folder_id 是第二份 source of truth;rename 走 DB-aware 同事务 cascade。
- **files API 单一 mutation 入口**(2026-05-18):"顶层目录分支"从数据状态派生而非客户端意图,放服务端才有强制力;双命名空间(/folders vs /files)把分支搬给 client,失强制力且端点翻倍。
**`/v1/files/*` 作目录树唯一 mutation 入口,DB-FS 一致性服务端内化**(2026-05-18):此前提过双命名空间 `/v1/folders/rename` vs `/v1/files/rename`,内部 `if path is top-level` 分支被视为"代码异味"。实际反了 — 这个分支**从数据状态派生**(path 恰好是 working_dir),不是从客户端意图派生,放服务端是更安全的位置(client 没法绕过去导致悬空引用);双命名空间反而把同一个分支搬到 client 去做,失去强制力且端点表面翻倍。 - **task 软删除(2026-06-17 推翻 hard cascade)**:公测后对话轨迹是训练/研究语料,硬删=永久丢失。`deleted_at` 置位 + restore;心智=**平台对数据 append-only,"删除"是可见性状态**。物理清理留管理员工具。
- **文件留存(设计已定,实现待办)**:用户文件在 FS,删除/覆盖即字节丢。方案=① restic/borg 定时增量备份做地基(与应用解耦,新端点自动覆盖,捕获删除+覆盖+成品)+ ② 应用层 `data_events` 事件日志(补用户意图语义)。**不选**每个删除端点内联 copytree:横切关注点手写 N 处必漏。起步同盘(不防整盘损坏,已知边界)。
**user auth 而非 tenant 层**:个人 SaaS 用不上。企业版加 `org_id` claim 等价。 - **0004 删 runs/usage_events 旧表**:只写不读的死代码;代价是失历史 run 元数据,真要细粒度审计再补(届时是新需求非技术债)。
- **本地也用 PG 不用 SQLite**:dogfood ≡ 真实路径;Docker 已是必然依赖;双 adapter 维护税 > 一次性配置。
**skill 产物全落 working_dir 不引入 artifacts 表**:中间件是用户花 token 生成的资产,可下载可替换;artifacts 表是为不确定 UX 收益预付架构成本。真嫌乱 UI 加折叠视图。 - **API-only,UI 由 platform 实现**(2026-05-15):本仓库再维护一套 UI 是双套浪费;SSE payload 从 HTML 切 JSON;沉淀的 sink/broker/路径安全全保留。**dev SPA 留一份**作 dogfood 主路径(SSE 调试 curl/Swagger 都覆盖不了)。
- **CLI REPL 撤**(2026-05-18):dev SPA 落地后 REPL 与 web 完全等价,双套 task 语义只是"对称美",每个 bug 修两次;看内部状态临时写 ad-hoc script 即可。
**~~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(否则"任务都删了文件夹却删不掉"死结)。 - **Memory 不入 DB**:见 §3.7。**Tasks/messages 在 PG、产物在 FS**:查询/统计是 DB 强项;产物终用户要文件管理器看到、Office 打开——**FS 是产物天然存储,DB 是元数据天然存储**;bind mount = user root 让容器视角 ≡ 用户视角,无翻译层。
- **同 wd 并发只做软警告**(2026-05-21):dogfood 中同 wd 多 task 是"项目对话历史",并发近 0;硬 gate 破坏切换流畅、short_id 全隔离破坏共享语义、clone 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 改图)若 ① 颗粒度不够,将来在该具体工具内定点补"覆盖前快照",不铺全局钩子。 - **shell 黑名单不加强**(2026-05-21):命令注入图灵完备,枚举不完、越复杂越虚假安全;正确防线 §7.5 OS 层。本地 dogfood blast radius 限自身可接受,外部开放信任模型不同必须 Stage C。
- **task 级「宪法」文件靠文件名隔离**(2026-05-20):`<date>-<short_id>-<name>.<base>.md`,short_id 主锚 + glob 字典序最大=current;不 cascade rename(in-flight 丢文件)、不 DB 化(工作量 5-10× 且失直接编辑)、不开物理子目录(破坏扁平共享)。升级 DB 化信号:结构化编辑视图 / 跨 task 查 spec 字段。
**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`(届时是新需求,不是技术债)。
**本地也用 PG,不用 SQLite / JSON**:① dogfood ≡ 真实用户路径,bug 在 dogfood 就能复现;② Docker 已是必然依赖(§7.5),`docker compose up postgres` 零增量门槛;③ 双 adapter 维护税远高于 PG 一次性配置成本;④ 本地 dev 也能连远端测试服。
**API-only,UI 由 platform 实现**(2026-05-15):用户决定与已有 platform 联调,前端用 platform 框架,本仓库再维护 HTML/CSS/HTMX 就是双套 UI 浪费。删 `web/templates/*` + Jinja2/markdown-it-py/pygments 依赖,SSE event payload 从 HTML 片段切 JSON,路由统一 `/v1` 前缀。沉淀:sink 协议 / RunBroker fan-out / no-subtask / files 路径安全归一 / task_dir 相对存储全部保留,不被 UI 层牵连。
**dev SPA 留一份 + 升级为本地 dogfood 主路径**(2026-05-15,05-18 强化):`web/static/dev.html` 单文件 vanilla JS,3 栏布局(task list + chat + files),无构建链。与"UI 由 platform 实现"不冲突 — platform UI 是给真用户的生产形态;dev.html 是给开发者 dogfood + 自验 /v1 API + SSE 流的开发期工具(SSE 调试在 curl 里看不到 UI 反应,Swagger 不发 SSE 流也没流式视图,删了再补不如留着)。登录页两 tab(邮箱密码 默认 / UUID+PLATFORM_KEY 备用,last-used 持久化)→ JWT → localStorage → fetch+Bearer。
**CLI REPL 撤,入口统一 `main.py {web,db,probe,user}`**(2026-05-18):原计划 `cli.py chat` REPL 本地直跑 + `--remote https://...` 走 HTTP,两套覆盖"本地调内部状态"+ "dogfood ≡ 真用户路径"。dev SPA 落地后浏览器一直开着,REPL 命令与 web `/v1` 接口完全等价;维护双套 task 切换语义只是"对称美",每个 REPL 命令的 bug fix 要在 web 端再 fix 一次。`--remote` 从未实现也再不需要(platform 联调 + dev SPA + curl Swagger 已覆盖)。**失**:CLI "无 auth 直跑调 core 内部状态"通道 — 但 dev SPA 邮箱密码登录走同一条 web 路径,看内部状态可临时写几行 ad-hoc script,不需要常驻 CLI 命令。
**Memory 不入 DB**:跨 task 共享靠"同一 user 同一 FS 目录"自动达成。md 用户直接编辑器改,DB 化反而要造 UI、违反 §3.7"事实由用户判断"。
**Tasks/Messages 在 PG 但 skill 产物在 FS**:tasks / messages 需要查询、过滤、全文搜、跨 task 统计 — DB 强项;skill 产物(`*.pptx` / `*.docx` / `sections/*.md`)终用户拿走,期望文件管理器看到、Office 打开、邮件发出 — 进 DB 要做"导出"多余操作。**FS 是产物天然存储,DB 是元数据 / 状态 / 索引天然存储**。同理 §7.5 bind mount = user root,容器里 ≡ 用户在 Web UI 看到的目录,无中间层翻译;per-user 容器天然匹配这个边界,per-task 容器会把同 user 共享工作区人为切碎。
**同 wd 多 task 并发不做 gate / clone / 物理隔离,只做软警告**(2026-05-21):dogfood 经验同 wd 多 task 主要是"项目对话历史轨迹",并发频率近 0。走 Claude Code 同款"信任 + 软警告 + 承认 limitation",dev SPA 在 selectTask + SSE 收尾拉同 wd 活跃邻居挂 banner,不挡发送,真高频再升级。**不选 γ(同 wd 单活 gate)**:硬挡破坏扁平共享中间产物的切换流畅性;**不选 short_id 全产物隔离**:破坏 §7.1 共享语义 + SKILL 改造成本;**不选 clone task**:对零频"真要并行"场景工程量过重。
**`shell` / `run_python` 不在工具层加强黑名单,§7.5 sandbox 是 SaaS hard prereq**(2026-05-21,05-25):`BLOCKED_PATTERNS` 只挡几个明显失误,稍有意识就绕过(双空格 / `bash -c` / `python -c` / `curl|sh` / `cd /` 全过),`cwd` 非 chroot。**不继续加规则**:命令注入图灵完备(`shell=True` + 任何脚本语言),黑名单枚举不完、越复杂越虚假安全感、误伤合法用法。**正确防线在 OS 层**:§7.5 per-user 容器 + drop ALL caps + read-only rootfs + bind mount own root + default-deny network + cgroup,Stage C 前仅 dogfood + 信任白名单。**per-user 非 per-task**:文件模型 user-rooted,安全目标是跨 user 隔离非同 user task 互隔。**非所有操作进容器**:auth/DB/files/SSE/LLM/受控 web 工具属 control plane 留宿主做权限审计,只有不可信代码进 execution plane。**本地 dogfood 接受风险**:自己机器 + 自己 prompt,blast radius 限自身(§5);外部场景 blast radius 是主机 + 他人数据 + cloud IAM,信任模型不同必须 §7.5。
**task 级「宪法」文件靠文件名隔离,不 cascade / 不入 DB / 不开物理子目录**(2026-05-20):同 wd 多 task 共享中间产物(source/sections/figures)是价值,但 spec 这种 1:1 宪法文件必须隔离。文件名 `<YYYY-MM-DD>-<task_short_id>-<task_name>.<base>.md`:`short_id`(`task_id.hex[:8]` 永不变)主锚,glob 字典序最大=current;日期让"重定调"写新文件成历史快照;`task_name` 仅可读说明,改 name 不 cascade(short_id 兜底)。**不选**:① cascade rename(in-flight 丢文件 + 复杂);② DB 化(最干净但工作量 5-10× 且失"直接编辑 markdown"、spec 字段还在演化);③ 物理 task 子目录(破坏扁平共享)。**升级 DB 化信号**:想做结构化编辑视图 / 跨 task 查 spec 字段 / 版本文件堆积乱。约定由 `_build_system_prompt` 单点注入,所有 skill SKILL.md 引用同一份。
--- ---
## 8. 未来步骤 / 已落地设计 ## 8. 未来步骤 / 已落地设计
> 实施细节(步骤清单 / 验收项)进 PROGRESS + git;此处只留缺口、选型与取舍。 > 实施细节进 PROGRESS + git;此处只留缺口、选型与取舍。
### 8.1 图像理解 + Seedream i2i(2026-05-29 设计;✅ 2026-06-16 i2i + look_at_image 双双落地) ### 8.1 图像理解 + Seedream i2i(✅ 2026-06-16)
**缺口**:DeepSeek V4 主模型纯文本无视觉;`seedream` 只 t2i;"基于已生成图二次修改" / "上传外部参考图让 agent 据此干活"两条路径未覆盖 缺口:主模型纯文本;t2i 无法"改已生成图"或"读上传图"。**选 E+C 组合**:seedream 加 `reference_images` 走 i2i + 新增 `look_at_image`(Doubao Seed 2.0 Lite,一次读图 <¥0.01)让 DeepSeek 自决何时"借眼睛"。**不选 A**(主模型换多模态:V4 code/tool-calling 是核心,换=降能力+改 loop 引 multimodal,工程 5×);**不选 B**(每条消息隐式 vision 路由:烧 token+失 agentic 控制权)。关键实测:ARK 接受 base64 data URL → 内网无需对象存储。**升级到 A 的信号**:用户要"贴图直接对话读图"成高频——当前假设"图是工具调用对象"而非"对话内容"。
**选 E + C 组合**:`seedream` 加 `reference_images` 走 i2i(改已生成图,像素级)+ 新增 `look_at_image` 走豆包视觉单图理解(读外部图,DeepSeek 自决何时调)。改动面=2 tool + 1 prompt 段 + 1 yaml 段,不动 loop / llm / capabilities / DB / 前端。 ### 8.2 Token 优化与上下文治理(✅ 2026-06-04 起)
> **模型选型更新(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 张 根因:全量历史每轮重发。**质量边界(设计约束,后续改动都守)**:不改模型输入的优化(caching/计费)零风险;改可见上下文的必须**保留可追溯原文**(长结果落文件留路径,确认过的需求/规格/结论不删);**禁止"只保留最近 N 条"当主策略**。落地:`core/context.py` 发送前压缩旧 tool/load_skill(保协议字段,不改持久化历史)+ **压力门槛**(未逼近上限完全跳过,护 DeepSeek 前缀缓存,实测命中 92-94%)。教训(2026-06-12):**不压 assistant tool_call 参数**——压缩 marker 会被模型仿写成真实参数(投毒),范本必须永远真实可执行。task summary 并入 §8.8 Phase 2。
**风险 / 边界**:v1 只支持单张参考(multi-ref 角色定义靠 prompt,留 v2);base64 ARK 未承诺长期稳定(收紧则降级走 TOS 上传换 URL)。 ### 8.3 PPTX 前端在线预览(✅ Stage 1)
**落地实况(2026-06-16,详 PROGRESS)**: 关键洞察:前端已有 PDF iframe 路径 → 后端 soffice 转 PDF 即可,前端几乎不动。选 LibreOffice(像素级保真、任意 pptx)不选轻量 HTML(复杂失真)/PDF→PNG(失矢量)。转换在 web host 不进沙盒;宏安全 high + 禁网 + 仅本人 user_root。与 `pptx_preview.py`(agent 生成期自检)分工。Stage 2 未做:常驻 listener / eager 预转。
- **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) ### 8.4 运维监控 / 无感更新(监控 ✅ / 换版 design)
**根因**:`Session.load()` 把全量历史装回每轮 LLM 调用,旧 tool 结果 / `load_skill` 正文 / 检索结果 / 长 stdout 反复携带;LiteLLM cost map 未覆盖 V4 致 `cost_cny=0` 不可用 **优雅 drain 已是单实例上限**(SIGTERM 拒新 run + 等收尾);先撞的瓶颈是线程池(每活跃 run 占 1 线程)。落地排序:① 轻量监控(显式 executor + 60s `[stats]` 周期日志——要历史峰值不是快照);② 按数据决策扩容;③ `--reload` 缩 503 窗。**不做监控界面**:运维健康是少数标量,日志够;业务分析走 DB SQL。界面阶梯:日志 → /v1/stats → Grafana → 只读 dashboard,现停第一级。gunicorn 无感换版 / broker 外置 Redis 搁置到真实投诉。
**质量边界(设计约束,后续改动都守)**: ### 8.5 定时任务(✅ 2026-06-18)
- 不改模型输入的优化(prompt caching、固定前缀、计费修复、cache hit/miss 记录)不影响输出质量。
- 改模型可见上下文的优化(裁剪 / 摘要 / 按需读取)必须**保留可追溯原文**:长结果写文件留路径,summary 只替代陈旧噪声,**用户确认过的需求 / 规格 / 大纲 / 关键结论不删**。
- **禁止把"只保留最近 N 条"当主策略** —— 省 token 但最易丢已确认约束。
**选型**:Context Editing + Memory/File State + Cache Observability 混合。稳定 system/tools 前缀利于 provider cache;旧 tool result 移除或压缩;关键发现写 task summary / FS,需要时 `read` 重新拉。长上下文保留作少数全局推理的临时能力,非默认每轮成本 **核心洞察**:job 本体 = cron+tz + 一句 prompt + 会话模式;守护循环只负责到点把带标记的 prompt 喂进**现成 agent 主管线**,不造第二套执行路径。**"发邮件"不是字段是 agent 动作**——加任何投递能力不改 schema。业界四源(OpenClaw/Autobot/Claude Code/geta)模式收敛佐证。
**落地形态**:`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 软重置分段治理(本节压缩挡不住跨时段累积)。 - **三层投递**:baseline(结果必进 task 线程)→ opt-in 推送(prompt 里说,agent 调工具)→ 可靠兜底(job.notify 结构化,run 完确定性补发)。
- **会话模式**:isolated 默认(每次新 task,省 token)/ persistent(绑定 task 续上下文,token 逐日涨——仅用户明确要连续性)。mode 只管对话延续;**文件夹两模式都按 job 复用**。定时 task 标 `scheduled_job_id` 不混普通列表。
- **可靠性**:退避重试(transient/permanent 区分,v1 简化为下个 cron 点)、per-job 超时(默 1800s,复用协作 cancel;超时按 error 记不吞)、**无补跑**、定时 run 内禁 schedule_create(防自我繁殖)、连续失败 N 次自停。
- **选型**:croniter 只当 next_run 计算器(vixie dom/dow OR 语义 + 时区,手搓必踩坑);**不引 APScheduler/Celery**(单机低并发,过度工程);**不用 JSON 文件持久化**(已有 PG)。persistent 绑定 task 正忙 → 跳过本次不排队。
- **前端取舍**:对话端完整 CRUD(schedule_* 工具),前端只读看板 + 停用/删除——cron 构建器 UX 难题直接消失(用户对 bot 说"每天早九点");工具与 REST 共用 `core.scheduler` 服务层不漂移。v1 纯工具不配 skill(schema 够;skill 值钱处是教写好 job.prompt,v2 按需)。
### 8.3 PPTX 前端在线预览(2026-06-09,✅ 已落地 Stage 1) ### 8.6 平台渲染层 rendering/(✅ 2026-06-23)
**动机**:文件区点 `.pptx` 原只能下载;要在浏览器直接翻看,且覆盖任意 pptx(含上传)。 **心智:文档渲染是平台能力不是 skill 内容**。起因:化学式白名单在三份 render_docx 逐字重复 + brief 缺 PDF 路径致线上手搓 weasyprint。**不放 `skills/_shared/`**:skill 走自包含/可 fork 标准,跨 skill import 破坏 fork。抽顶层 `rendering/` bind-mount `/sandbox/rendering:ro`:common(叶子原语单一事实源)+ docx_manuscript(paper/proposal 双 profile)+ docx_brief + pdf(chromium 不用 weasyprint——镜像已有,保真更高)+ render.py 统一 CLI。重构前后 docx 字节一致零回归;brief 不强并 manuscript(差异大,只共叶子)。与 §8.3 分工:pptx 预览在 host 面向用户,本层在沙盒面向 agent 交付物。
**关键洞察(定方案极简)**:前端已有 `<iframe src=blob:application/pdf>` PDF 原生渲染路径,所以**后端把 pptx 转 PDF 即可,前端几乎不动**(不需 pdf.js / PNG 栅格化)。
**选型 LibreOffice→PDF**:像素级保真 + 通用(任意 pptx)+ 前端复用现成 PDF iframe;代价=服务器装 LibreOffice + CJK 字体。劣选:轻量 HTML(复杂 pptx 失真,不满足"任意");LibreOffice→PDF→PNG(多栅格化层、失矢量缩放、无收益)。 ### 8.7 微信接入(双渠道)(✅ 均落地)
**与 `scripts/pptx_preview.py` 分工**:后者是 agent 生成阶段自检(pptx→Chrome→PNG,近似但零服务器依赖);本方案是面向用户的高保真预览。
**落地形态**:`web/pptx_render.py`(soffice 转 PDF,独立临时 `UserInstallation` 绕单 profile 锁 + 缓存 `.preview/<hash>.pdf` + 超时 kill)+ `GET /v1/files/preview_pdf`(复用 `_safe_path` 防穿越 + per-path `asyncio.Lock` + `run_in_executor`)。**转换在 web host 不进沙盒**(沙盒不该有 LibreOffice;预览与 deck 生成解耦)。 **诉求**:简报/结果推进个人微信 + 能在微信里对话。**三条路选官方 ClawBot**(腾讯 2026-03 官方个人号 Bot API,零封号,后端可接任意):wechaty/hook 违规高封号排除;企业微信官方但只触达成员、要管理员——**作渠道 B 并列**(其无条件主动推正补 ClawBot"24h 活跃才可推"短板,定时简报必达首选)。
**安全边界**:对上传任意 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) **协议真机实测结论**(细节见 `core/wechat/ilink.py` + `scripts/probe_clawbot*.py`):双向对话 + 主动推送成立。关键:`client_id` 每条必唯一(漏则静默丢)、`context_token` ~24h 可复用且每条入站刷新(→ 主动推的前提是用户开口过且 24h 内活跃,冷推不可能,超期退邮件兜底)、文件走 AES-128-ECB+CDN 可原生直推 docx/pdf、多条分块 state=1/2。取码零门槛(无预置凭据),bot_token 是 per-user 长期凭据。**现实卡点**:个微 8.0.70+ 灰度;腾讯保留限频/终止权力(政策风险)。
**背景**:已上生产、真实用户在用。换版可用性从"nice to have"变真账;且当前并发到多少、线程池有没有排队**没有观测手段**。 **架构决策**:
- **入站出站一体**:主动推送依赖 context_token 而 token 只能从入站拿,"只出站"不成立;getupdates 长轮询既收对话又刷新 token(每 binding 一条,公测 N 小可接受)。
- **入站 → 每用户每渠道一条 persistent 会话 task**(连续性;token 增长靠 §8.8 治理);两渠道共用 `_run_channel_conversation(channel)` 核心,各一张 task 互不串扰。
- **web 端只读镜像**:web→微信不同步(回微信需 context_token,24h 窗口会把"双向"拖成不可预测的"有时同步"+ 双入口并发歧义)→ 交互权威单一锚定微信,web 只读;主动推走 `wechat_push`/定时(出站语义非对话)。
- **渠道抽象**:`send_to_user(user_id, text, file?, channel=None)` 统一发送,None=广播、点名单投不回退;scheduler/tool 不感知具体渠道;`active_channels()` 是渠道清单唯一真相源。**推送即对话记录**:投递成功写一条 assistant 消息(摘要+链接,`messages.kind="push"`,取回复时跳过)进渠道 task——agent 记得自己推过什么,可基于推送追问;不塞正文防膨胀。
- **数据**:统一表 `channel_bindings(user_id, channel, status, config JSONB)`——渠道绑定="用户在某渠道的一份配置",判别列+JSONB 与 usage_events 同范式,加渠道零 migration;分表不扛增长、宽表最差。敏感凭据(bot_token/context_token)加密入 JSONB(`ZCBOT_WECHAT_SECRET_KEY`),**绝不进沙箱/日志/API**;企微 secret 走 env 不入库。
- **渠道 B 企业微信**:出站推送先行("和邮箱似的"),公测需求明确后补入站——**与 ClawBot 本质不同**:回调 webhook 而非长轮询(无后台 task,只加 HTTP 端点 + `wecom_crypto.py` 验签解密);agent >5s 超被动窗口 → 回复走 message/send 主动推回。绑定两路:手填 userid(推送是出站直连**不需要域名**)+ OAuth 扫码(需 HTTPS 可信域名,用 wwlogin 扫码端点非网页授权端点)。
- **不选**:wechaty(违规);富排版卡片(个微能力存疑,统一纯文本+文件直推);bot_token 不落库(它是长期凭据必须持久化,安全靠加密列)。
**心智** ### 8.8 channel 长会话上下文治理(Phase 1 ✅ / 2-3 design)
- **优雅 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 / 加信号量背压,**不引外部依赖**。
**落地排序(便宜→贵,到触发线才进下一级)** **根因**:IM 常驻 task 只增不减(web 任务"做完即止"有天然边界,IM 没有),全量历史越用越贵终撞 context window;§8.2 压缩只摘 tool 正文挡不住跨时段累积。业界(OpenClaw/Hermes/Claude Code)都是"阈值摘要+头尾保护",但都是单次 session,不解"IM 用三个月"——IM 独有的**会话分段**最高杠杆且零信息损失,自补。
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。现在停在第一级 **心智:边界而非删除**——一条消息都不删,只移动喂给模型的窗口起点;全历史留 DB,web 照旧翻完整记录。
**搁置(成本不抵当前收益)**:gunicorn 无感换版 / broker 外置 Redis / nginx 蓝绿双实例 —— 留到"单机线程池调到头仍不够"或"换版断流成真实投诉"再议(无感换版需先把 broker 外置共享,分析见 RUN.md §B)。 - **Phase 1(✅):`context_base_idx` 软重置**。`Session.load` 只装 `idx>=base`;自动 gap(默 6h,base=最后一条 user 消息——**不是失忆墙**,留上一轮做续聊锚点)+ 手动「新话题」硬重置(base=总数)。**关键不变量**:append 续号取 DB 真实总条数而非加载条数,否则撞 unique 约束。**不选**"每次 gap 开新 task"(堆文件夹+task 卡片)、"boundary 标记消息"(混进消息流要处理 tool 配对);列是纯元数据零侵入。
- **Phase 2(design):阈值结构化摘要**(补 Hermes 阶段③):中段轮压成固定模板(目标/约束/进展/待办),增量更新、保留 path/ID/数值原文(mem0 实测摘要会静默丢精确值);双层门槛 50%+85%。分段砍跨话题累积、摘要兜单段超长,正交。
### 8.5 定时任务 / 计划运行(Scheduled Jobs)(2026-06-18 设计,status=design) - **Phase 3(design):持久检索**(sqlite-vec/FTS5)解"问很久以前的精确内容";工程最重,待确认真实需求(数据没删随时能补)。
**缺口**:无任何定时触发机制。但有价值的活很多是**时间驱动**而非事件驱动 —— 每日简报、每周综述、定时拉数据存盘、早安提醒。当前必须有人在对话里手动发消息才跑得起来。诉求:**用对话方式创建**"每天 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) ## 附录:DeepSeek V4 关键事实(2026-04-24)
- **V4-Pro**:1.6T / 49B 激活,1M context,SWE-Bench 80.6 / Terminal-Bench 67.9 / MCPAtlas 73.6 - V4-Pro:1.6T/49B 激活,1M context,SWE-Bench 80.6;V4-Flash:284B/13B 激活,1M context
- **V4-Flash**:284B / 13B 激活,1M context - 推理:non-thinking / thinking / thinking-max;价格 in ~$0.145/M、out ~$1.74/M(约 Opus 1/6)
- 推理模式:non-thinking / thinking / thinking-max - `deepseek-chat/reasoner` 2026-07-24 下线 → 必须迁 `deepseek-v4-flash/pro`
- 价格:in ~$0.145/M,out ~$1.74/M(约 Claude Opus 1/6 ~ 1/7)
- `deepseek-chat` / `deepseek-reasoner` 2026-07-24 下线 → 必须迁 `deepseek-v4-flash` / `deepseek-v4-pro`

View File

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

View File

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