Commit Graph

271 Commits

Author SHA1 Message Date
caoqianming c6232d8325 feat(kb): 个人知识库——.kb/ 纯文件机制+/v1/kb* API+前端 modal+注入契约(bump 0.59.0)
- core/kb.py: 状态/视图层(INDEX 单行格式 parse/format、已入库判据=INDEX 有条目、
  删单篇连带原件与索引行、kb_block 注入——有库才注)
- core/kb_ingest.py: 入库管线 markitdown→扫描件 OCR 兜底(方舟)→flash 摘要(失败降级
  不阻塞)→追加 INDEX;编排 to_thread+per-(user,库) 锁,幂等可续跑
- 0022 迁移: usage_events.task_id 放宽可 NULL(kb 入库无 task 上下文);
  记账溯源 kind="kb_ingest"/"vision" + units {"kb","source"}
- web/app.py: /v1/kb* 8 端点(列/建/删库、详情带进度、上传即入库、手动 ingest、看/删单篇),
  不设 HTTP 检索端点(agent 走 fs 工具)
- 前端: kb.js 两栏 modal(上传 XHR/进度轮询/删除确认);rail 左下新增知识库入口,
  四按钮改图标+小字两行布局
- agent_builder 在 memory_block 后注 kb_block(docker/host 路径换算同 .memory);
  documents skill「何时不用」补自建资料路由行
- DESIGN §3.8 机制小节 / CHANGELOG 0.59.0 / PROGRESS

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 15:20:43 +08:00
caoqianming fc53d21620 fix(web): 长对话点目录圆点首次跳不到位——重建窗口瞬时定位+跳转期抑制 sentinel 补载
根因链:loadMessagesAround 后 renderMessages 尾部无条件滚底,把视口钉到
新窗口末尾;底部 sentinel 落在视口内立刻触发 loadNewerMessages,其整窗
重渲染(innerHTML 清空)删掉平滑滚动的目标元素、再把 scrollTop 恢复到
fetch 前位置——首次点击停在窗口底部附近,第二次点击才能到位。

- renderMessages 加 stickBottom 参数(默认 true 保持首屏/发送行为);
  loadEarlier/loadNewer/loadMessagesAround 三个调窗口路径传 false
- jumpToMessage 重建窗口后用瞬时定位(auto),不给补载/图片异步撑高留
  动画窗口期;已在 DOM 的近距离跳转保留平滑
- _msgScrollObserver 在 _outlineJumpLock 期间不补载;解锁时
  _repokeMsgSentinels 对 sentinel unobserve+observe 补投一次交叉状态,
  避免 sentinel 停留在视口内时解锁后无人触发加载

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 16:42:41 +08:00
caoqianming 2e3a4db4e0 fix(web): 手机端文件预览改悬浮卡片——四边留边距、不压输入区(bump 0.58.54)
移动端预览弹框原为全屏贴边(100vw×100dvh、直角),观感差且糊在消息
输入框上。≤640px 下改为:遮罩层四边 10px 呼吸边距,底部在
--preview-bottom-inset(chat-form 让位)之上再加 10px;卡片尺寸改
100%(相对遮罩内容区,弃 100dvh,旧 WebView 兼容更稳),恢复默认圆角。
输入区仍由 body.fp-open #chat-form z-index 抬升保持可见可用;桌面端不动。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 15:20:08 +08:00
caoqianming c1508d0df0 feat(web,docs): App 套壳 embed app 变体(relogin_url + fragment 注入)+ APP.md 对接文档(bump 0.58.53)
移动 App 方案:原生壳(WebView)+ 原生登录页,登录走 platform 自有接口
(/api/login/token → /api/login/external-login,实测打通,后者服务端换 zcbot JWT)。
顶层 WebView 无父窗口,iframe 的 postMessage 协议失效,泛化企微免登为 app 变体:
?embed=1&relogin_url=<绝对地址>#token=..&user_id=.. —— fragment 注入读完即清,
401/logout 时 location.replace(relogin_url),原生壳拦自定义 scheme 换新 token 重进。

- state.js: EMBED_RELOGIN_URL 解析+消毒(拦 javascript:/data: 等可执行 scheme)
- embed.js: 抽共用 readFragmentToken/gotoInitialTask,加 embedAppInit/embedAppRelogin
- auth.js: logout 分支序 wecom → app → iframe
- APP.md 新增(进入契约 + platform 登录链路 + 原生壳杂活清单 + H5 备选)
- EMBED.md 精简 243→约 120 行,指向 APP.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:59:16 +08:00
caoqianming 62f3ddb658 fix(ui,loop): salvage 成功提示降噪——黄色警告降为灰色 info 行(bump 0.58.50)
用户反馈流内黄字「工具调用参数损坏但已就地抢救」吓人、疑影响后续对话。
核实零影响:salvage 只就地改写 arguments、当轮照常执行,对话历史与正常轮
无异;warn 是纯前端 SSE 展示事件,不入 messages、不回灌 LLM,刷新即消失。

处置取分级降噪而非全静默(上游返脏数据的事实要可见、可与 tool_salvaged
DB 留痕对上):
- core/loop.py:该 emit 加 level:"info",措辞软化为「已自动修复工具调用
  参数…继续执行」;
- web/static/js/chat.js:warn 渲染按 level 分流,info 走灰色 muted 行、
  不带 ⚠;黄色 ⚠ 只留给真打断本轮的路径(丢弃重试/熔断等)。

频发原因研究背书(DB 近7天,bad/chat 轮次):deepseek_v4.pro 14.5%、
flash 8.3%、unifyllm 5.9~7%,glm/sonnet 0 —— DeepSeek wire + unifyllm
网关两链路的流式 delta 乱序病,前缀实为同一调用自身内容的错位碎片
(与 0.58.24 定层一致);社区 opencode/goose/Cline/Roo-Code 同病且多数
closed-not-planned 未修,我方 salvage(85% 救回)+ 非流式重试已是最完整
处置 —— 频繁黄字本质是「救回成功」记录高频刷屏,降灰合理。

顺带:CLAUDE.md 文档节奏调整(bump/PROGRESS/CHANGELOG 改为一次 push
统一更新一次,不逐 commit;DESIGN 仍随触发它的 commit 走)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 08:47:44 +08:00
caoqianming ab55be4cde fix(ui): 文件预览背景撤回毛玻璃、改回半透明压暗遮罩(bump 0.58.46)
用户反馈 0.58.44 的毛玻璃虚化(rgba(20,22,28,.22) + blur(6px))"不行",
要求还是变暗、且背景仍看得到、注意度适中。改回半透明深色遮罩
rgba(0,0,0,0.45)(比 0.58.43 的 .5 略透、背景内容仍隐约可辨,不挡死),
去掉 backdrop-filter,chat-form 悬浮阴影恢复 0 -10px 28px/.22。
因 0.58.44 之上已叠 0.58.45(工具修复),不走 git revert(会冲突
版本/CHANGELOG),改为前向修复。只动 web/static/dev.html。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 13:42:58 +08:00
caoqianming d5854e3337 fix(ui): 文件预览背景改毛玻璃虚化——柔和聚焦替代硬深色遮罩、消除输入条突兀感(bump 0.58.44)
0.58.43 的整屏纯深色遮罩(rgba(0,0,0,.5))仍偏硬,亮着的悬浮输入条
浮在深黑上略突兀。改为毛玻璃:浅压暗 rgba(20,22,28,.22) + backdrop-filter
blur(6px)(带 -webkit- 前缀,老浏览器不支持则退化为纯浅压暗、不崩),
背景虚化把内容推远、聚焦到预览卡片又不刺眼;chat-form 悬浮阴影同步调柔
(0 -10px 28px/.22 → 0 -6px 20px/.12)。只动 web/static/dev.html。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 08:57:56 +08:00
caoqianming 9c39d0ba42 fix(ui): 文件预览遮罩改整屏、消除"上暗下亮"割裂——输入区上浮为悬浮活动条继续可用(bump 0.58.43)
旧实现用 #file-preview-modal { bottom: --preview-bottom-inset } 把遮罩顶到
chat-form 之上以便边看边打字,代价是底部整条(输入区+左栏入口+右栏+状态)
全不压暗,与上方压暗区形成硬边界的通亮带,视觉割裂。

改为遮罩铺满整屏(bottom → padding-bottom,仍让居中卡片停在输入区之上不遮挡),
底部随整屏统一压暗;仅 chat-form 由新 body.fp-open 类 position:relative;z-index:95
(压过遮罩 z90;#app/#pane-mid 均不建层叠上下文,已核)+ 上向阴影上浮为
"悬浮活动条",继续可打字。预览时点左栏/右栏/状态落到遮罩=关预览,符合
"背景不可用"预期。改 dev.html + preview.js(open/close 加/去 fp-open)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 21:05:15 +08:00
caoqianming 20c7512370 feat(ui): 页内弹框替代原生 alert/confirm/prompt——分 dialog/message 两族,复用改密码卡片样式(bump 0.58.42)
新增 web/static/js/dialog.js 两族原语(对标 Element dialog/message):
- dialogConfirm/dialogPrompt:遮罩+卡片 modal,Promise 化以一比一替换同步的
  confirm/prompt;Esc/点遮罩=取消,Enter=确认,删除类走 danger 红键。
- message:顶部居中轻 toast,success/error/info 三态,自动消失、点即消。

dev.html 加 #app-dialog 容器 + #app-messages toast + 对应 CSS。全量替换主应用
8 文件调用点(chat/files/crons/procs/wechat/skills/media/auth,共 30 处)。
admin.js 独立 admin-only 页(自带 <style>、无 CSS 变量)刻意保留原生 alert
避免缺样式。纯前端加法,不动后端/接口。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 09:38:31 +08:00
caoqianming 6efda0a157 feat(task): 任务改名 UI——⋯ 菜单加「重命名」,改完列表+顶栏全同步(bump 0.58.41)
后端 PATCH /v1/tasks/{id} 早支持 name(validate_task_name 校验)但前端无入口。
taskMenuItems 加「重命名」项(列表行 ⋯ 与顶栏 ⋯ 共用此菜单,一处插入两处生效),
renameTask() 走 prompt→PATCH→更 state.tasksById/taskMeta.name + renderChatMeta()
+ loadTaskList() 全同步。刻意只改 DB 显示名,不动 working_dir/磁盘宪法文件
(定位锚是 task_id/short_id,name 设计为自由可变标签)。仅改 web/static/js/chat.js。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 22:42:17 +08:00
caoqianming f7e5a80ed9 feat(auth): JWT 滑动续签——活跃用户临期无感换 token,不再因 7d TTL 掉线(bump 0.58.39)
原状:无状态 HS256 JWT(默 7d TTL)无任何续签路径,过期即 401——platform 靠
PLATFORM_KEY 自行重换,SPA 用户只能重输密码。判断:平台层握长期 PLATFORM_KEY、
login 本就幂等换发,缺的不是 refresh token 而是"无感重换"这层;放鉴权中间件一次
改动同时惠及 platform 层与 SPA,且纯加法契约。

- web/auth.py:AuthConfig 加 refresh_threshold_seconds(env
  ZCBOT_JWT_REFRESH_THRESHOLD_SECONDS,默 TTL 的 1/4,设 0 关闭);verify_token
  拆出 decode_token+_uid_from_payload;require_user/admin 共用 _authenticate,
  注入 Response,临期就地 mint 新 token 放响应头 X-Refreshed-Token /
  X-Token-Expires-At(仅续未过期的,已过期先抛 401)
- web/app.py:CORS 加 expose_headers 放行两头,否则浏览器 fetch 读不到
- web/static/js/api.js:中央 api() 封装 absorbRefreshedToken——任一响应带新
  token 就无感换 state.token+localStorage
- 验证:4 case 全绿(fresh 不续/临期续且 exp 正确延长/已过期仍 401/阈值 0 关闭)

顺带:EMBED.md 收敛为纯 platform iframe 对接文档——删企业微信免登 §9(实质併入
RUN.md 企微段「应用主页免登」的内部链路)、校准 §3 换 token 返回体过期字段、补
滑动续签说明;新 env 进 RUN.md、用户可感知条目进 CHANGELOG。

additive,不改字段语义/URL/DB schema;WS ASR 路径 verify_token 签名不变。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 10:21:35 +08:00
caoqianming 011aee1334 fix(channel-push): 定时简报产物文件链接点开 404——推送端收敛为相对 rel + 前端识别完整 URL 兜底历史(bump 0.58.38)
task 7f904e5f 复盘:定时任务/企业微信推送写进 DB 的 assistant 消息把产物
文件写成完整下载 API 链接 [名](/v1/files/download?path=<rel>),web 正文链接
拦截器(chat.js 的 .msg .body a[href] click)注释设定是"user_root 相对路径",
于是把整个 /v1/files/download?path=... URL 当相对 rel,去前导 / 后
rel=v1/files/download?path=scheduled-…txt,再塞进 openFilePreview 二次包进
download URL → 请求 path 不存在 → 404(node 复刻拦截器坐实)。

关键调研(纠了初判):一度以为链接得留完整 URL 给企业微信端点下载、改格式会
break wecom——错。push_wecom(service.py:266)企业微信端走 send_text(摘要)+
send_file(文件附件独立通道),压根不碰这个 markdown 链接;_build_push_message
的链接只进 DB 给 web 端渲染。焊死 /v1/files/download?path= 纯属把具体 API
端点这个渲染细节泄漏进数据层,还正好踩拦截器坑。

修(A+B 互补):
- B 源头(分层正解):core/wechat/service.py::_build_push_message 链接改
  user_root 相对路径 [名]({rel}),与 chip/正文相对链接统一契约、走拦截器
  已有相对分支;docstring 记明为何不用绝对 API URL(防后人再焊)。
- A 前端兜底:chat.js 拦截器在相对路径回退前先判 ^/?v1/files/,命中则
  new URL(raw,origin).searchParams.get("path") 抽真正 rel 交 openFilePreview
  (自动解码,txt/docx 复用同 modal)——救 DB 里已存 20+ 条历史简报(改不了)
  + 健壮兜底。

验证:node 复刻拦截器——B 新格式(相对)与 A 历史(完整 URL)都解析成同一
干净 rel;A 另测 URL 编码含空格#号 path / 普通相对 / ./ 前缀 / 外链全对、
外链仍开新标签;node --check chat.js + ast.parse service.py 通过。web 前端
需刷新页面生效。additive,不碰对外 API/DB/企业微信推送(send_file)契约,
仅 DB 内 assistant 文本链接写法由绝对 API URL 收敛为相对 rel。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 22:38:57 +08:00
caoqianming eb45e771fa fix(rendering+chip): 修 task d9c74a5a 三处独立缺陷——chip 粗体路径 404 / docx emoji 缺字 / 单文件被迫建 sections(bump 0.58.37)
一次"今日新闻 md→word"暴露三个不相干的坑,逐个定层修:

1) 前端文件 chip 点开弹框 404 —— media.js::extractArtifactRels 的 tail
   字符集 _TAIL_CLS 没排除 `*`,助手把路径写成 markdown 粗体
   `**<wd>/x.md**` 时前导 `**` 被 lead 边界吃掉、尾部 `**` 被贪婪吞进
   rel(星号既不在 tail 集也不在尾部 strip 集),生成 `x.md**` →
   /v1/files/download 404。修:`*` 加进 _TAIL_CLS 排除集,同反引号一样
   在 emphasis 处干净截断;文件名含 `*` 在 Windows 非法、误伤≈0。

2) 导出 word 图标(emoji)缺失 —— docx_manuscript 正文/标题字体是宋体/
   黑体/Times New Roman,无 emoji 字形,📰🔥🇨🇳📊 写进宋体 run →
   Word 渲染成豆腐块。修:common.py 加 EMOJI_RE + add_fonted_runs
   (按 emoji 边界拆 run,emoji 段走 Segoe UI Emoji、其余走原中西文字体),
   接进 _emit_plain_with_chem(不破坏化学式下标)与 add_inline
   (bold/italic/code);标题走 add_inline 自动覆盖。

3) 过程中冒出 sections/ 文件夹 —— render.py docstring 承诺 src 可为
   "目录或单个 .md",但 docx 三 profile 的 render_sections 全是 is_dir()+
   glob 只认目录(只有 pdf 支持单文件),模型传单 .md 撞
   `sections dir not found` → 被迫 mkdir sections && cp 兜底。修:
   common.py 抽 collect_md_files(文件→[src],目录→glob),docx_manuscript
   + docx_brief 共用,figures 基准取 src.parent;兑现 docstring 承诺。

验证(实跑非仅单测):emoji 满满的今日新闻 md 跑单文件 --profile report
退出 0(不再报 sections 缺失),解包 docx 校 run 字体——emoji 全
Segoe UI Emoji、中文仍宋体、SiO2/CaCO3 下标未被破坏;正则修复 4 组样本
(粗体/反引号+间隔号/裸路径+中文句号/斜体)全绿;目录多文件+brief+
不存在输入三路回归无破坏。三处互不相干可独立修,均 additive,不碰对外
API/DB 契约。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 22:15:34 +08:00
caoqianming d3bd40fad7 fix(web): 对话正文文件锚点两处回归——含·的 pptx 丢 chip + 链接点击整页404(bump 0.58.29)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 13:40:12 +08:00
caoqianming 54db4fc348 feat(web): 对话卡片悬停「复制」按钮,取正文 Markdown 原文(bump 0.58.27)
renderMessages 给 user 卡与有正文的 assistant 卡挂 .msg-actions 栏
(纯 tool_calls 轮/error 卡不挂),默认隐、hover/focus-within 现、
触屏常显淡态。复制经 #chat-stream 既有 click 委托据 data-idx 回
state.loadedMessages 取 payload.content(非渲染 HTML),Clipboard API
主路径 + execCommand 降级,按钮短暂反馈「✓ 已复制」。直播卡不带此栏
——流结束 loadMessages 整屏重渲成持久卡自然带上。

本期只做零后端耦合的复制;重新生成/编辑重发需后端截断语义,另立项。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 09:51:28 +08:00
caoqianming 2066947061 fix(web): 用户停止后补持久「已停止」提示,cancel 落一等终态(bump 0.58.26)
点停止中断回复后对话底部什么都不剩:live-append 的「已停止」badge 被收尾
loadMessages 整屏重建冲掉(同 2a1bc25d 案)。补齐 cancel 终态,与 error 卡对称:

- 后端: agent.run 取消路径 return "[cancelled]",_run_agent_bg 据此收尾写
  run_status="cancelled"(否则 idle)。run_status 是 Text 列,加新值零 migration。
- 前端: renderPersistedRunError → renderPersistedRunTerminal,同时认 error/cancelled;
  cancelled 渲居中 muted 虚线「已停止 · 你中断了上次运行」(良性态,不用错误卡样式),
  刷新/切任务仍在,下次起新 run 覆盖清掉。删掉会被冲掉的 live badge 及死 CSS。
- 兼容: run_status 加新值纯增量,蓝绿旧实例读到 cancelled 当良性态优雅退化不崩;
  全部 run_status 消费点(409 门/reaper/SSE is_active/cancel 端点/clear)已核对。
- 同步 DESIGN §7.4 / models.py 注释 / filter allowlist / CHANGELOG / PROGRESS。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 09:38:54 +08:00
caoqianming 859c02cf65 feat(ops+web): run 级错误持久提示+失败聚集+provider 致命错误即时告警(bump 0.58.21)
反 2a1bc25d 教训(Zai 余额不足连挂 3 次续跑,前端零提示、admin 零感知):

- chat.js: renderPersistedRunError 挂 renderMessages 末尾——run_status=error
  且无 live run 时消息流末尾补「上次运行出错」持久卡(live 错误卡被收尾
  loadMessages 整屏重建冲掉是"发了继续啥也没发生"的根因);下次 run 起跑
  后端清 run_error 自然消失
- usage.py: record_run_error(kind=run_error, cost 0)——tasks.run_error 只留
  最后一次,此行才是聚合的完整数据源;app.py _run_agent_bg except 落库
- toolfail.py: 第三段扫描聚成 kind=run / tool=(run),admin 面板+巡检邮件
  零改动带上;alert_provider_critical 余额/配额/认证关键词命中即时邮件
  ZCBOT_DEVELOPER_EMAIL(同签名 6h 进程内冷却,全路径静默失败)
- tests/test_toolfail_malformed.py +3 用例;PROGRESS/CHANGELOG/RUN 同步

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 14:06:29 +08:00
caoqianming da289b2ce2 feat(ops): 畸形 tool_call 进工具失败聚集 + 巡检邮件只发活跃聚集(bump 0.58.19)
畸形轮整轮丢弃、messages 无痕,admin「工具失败聚集」原本看不见这类失败
(toolfail 只扫 role=tool 报错);且巡检邮件去重集是内存态,每次部署清零,
高频部署期每次重启都把 7 天窗口内早已安静的存量聚集重发一遍。

- 新增 record_malformed_tool_call:丢弃畸形轮时写 usage_events 一行
  kind=tool_malformed(units={tool,len,err,head,tail,tokens_in/out}),
  cost_cny 恒 0 —— cost 全 kind 合计且随任务展示,provider 抖动不算用户
  花销;真实 token 快照进 units 供反推浪费。DB 写失败静默不阻塞重试
- core/toolfail.py 加第二段扫描(usage_events kind=tool_malformed),聚成
  kind=malformed 同构 cluster,admin 面板/巡检邮件零改动自动带上;聚合
  逻辑抽 _add 闭包双数据源复用
- 巡检邮件发信前加 count_24h>0 过滤:已安静的聚集重启后不再重发,还在
  烧的重启后再提醒一次(刻意保留)
- 新增 tests/test_toolfail_malformed.py 4 用例(mock session_scope);
  RUN.md env 巡检段 + 故障兜底同步;PROGRESS 更新

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 12:04:22 +08:00
caoqianming 4a591e1a90 fix(web): 历史消息丢图片预览/chip——taskMeta 竞态 + 中文路径抽取(bump 0.58.17)
两根因:
1. selectTask 并发化(0.12.16)后 renderMessages 在 Promise.all 内执行,
   state.taskMeta 必然还是旧值/null → wd 错 → chip+inline 图整体丢失。
   loadMessages 加 {render} 开关,首屏渲染延后到 taskMeta 赋值后(零额外 RTT)。
2. extractArtifactRels lead 边界不认汉字("已保存到<wd>/xxx.png"不命中),
   tail 会把紧贴的中文标点连后文吞进文件名。lead 改任意非 [A-Za-z0-9._-],
   tail 排除集抽 _TAIL_CLS 常量补中文标点(主规则+legacy 双处)。

node 离线回归 12 用例全绿;CHANGELOG 面向用户补记。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 10:29:23 +08:00
caoqianming 4d7075aeb8 feat(web): 有 gpt_image 权限的用户生图默认 GPT 生图(bump 0.58.16)
- /v1/image_models 的 is_default 改为 _default_image_variant:档位里有
  gpt_image(pro/admin)→ 默认它,否则仍过滤后第一个(seedream)
- _resolve_image_model 空串时按同一 helper 解析该用户默认,下拉默认选中
  与实际起 run 挂的图像工具保持一致;用户仍可下拉切回豆包 Seedream
- 无 user_id 内部路径(定时任务/messenger)行为不变

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 10:26:31 +08:00
caoqianming dadcc8e7e7 feat(ops): toolfail 聚集加时间分布,活跃/已安静一眼分(bump 0.58.14)
修掉的问题旧记录挂满 7 天窗口,和还在发生的没视觉区别。聚合加
daily(24h 分桶)/count_24h/first_at,活跃在前安静沉底;admin 表
加近24h+趋势列,安静行淡化;告警邮件带近24h。API 只加字段。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 09:27:40 +08:00
caoqianming 62bfb07312 feat(ops): 工具失败聚集巡检,同签名报错自动冒头(bump 0.58.11)
反 9dcae061 结构性缺口(mmdc 挂 90 天 0 成功无人知,靠人工扫 DB 才发现):
- core/toolfail.py:扫近 7 天 role=tool 错误([Error/timeout/exit!=0]),
  按 工具名+归一化签名(路径/数字/uuid 抹平) 聚合,>=5 次且跨 >=2 task 判聚集
- web/app.py _toolfail_scanner:每天跑(仿 _disk_scanner),聚集发
  ZCBOT_DEVELOPER_EMAIL(复用 SMTP_*,未配则日志);签名进程内去重
- GET /v1/admin/tool-failures + admin 页"工具失败"表(低阈值看全量)

生产 DB 冒烟:一次扫出 8 类真聚集(沙箱缺 file 命令 x13 跨 13 task、
web_fetch 断网 x7、mmdc 超时批次 x6 等)。无新表无 migration。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 08:36:31 +08:00
caoqianming 0d7725b010 feat(admin): 用量表默认时间范围 all→近7天(bump 0.58.3)
admin.js「按模型」「各用户用量」两表初始 range 从"全部"改"近7天",
下拉仍可切全部/30天;API 端 range 默认值不动。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:09:49 +08:00
caoqianming 5a8f5bd930 feat(media): 第二图像后端 gpt_image(unifyllm 网关,pro 档)(bump 0.58.0)
- config/media/unifyllm.yaml + tools/gpt_image.py:仅 prompt 参数(网关忽略
  size/quality,尺寸上游自动定,不支持 i2i),b64 直落 figures/,banner 协议
  同 seedream,价目未知期 cost 记 0、usage tokens 进 units 留对账
- 媒体装配泛化:ArkConfig 通用键名(api_key_env/base_url,ark_* 兜底);
  _choose_image_variant 跨 provider 选 image variant(默认仍 seedream_5,
  本 run 只挂一个图像工具);_MEDIA_TOOLS_BLOCK 拆分段按实挂工具拼装
- web:_list_media_variants 扫 config/media/*.yaml(image/video 清单 +
  admin 门控 catalog);media.js 三处白名单加 gpt_image;prompt 润色 hint
  无 default_size 不再谎报 2048
- gpt_image 进 pro 档,与 seedream 共享 images_per_day 配额
- 手工验证 scripts/test_gpt_image_manual.py 全过(含真调网关落图),
  unittest 201 过

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 20:47:06 +08:00
caoqianming daf0db854a feat(procs): 后台进程 bg proc + 对话锁/完成通知 + seedance 续查(bump 0.56.0)
- shell/run_python 加 background=true(模型判断,>~1min 走后台):进程 detach 独立运行,
  不受工具超时/服务重启/蓝绿切换影响;新 check_process 工具查状态/日志尾部/终止
- 状态协议纯文件(<user_root>/.zcbot_procs/<task>/<proc>/,exit_code 出现即终态,
  无 DB 无队列组件):host=stdlib wrapper detach(限时+杀树+日志截尾 10MB);
  docker=专用容器 zcbot-proc-*(同款硬化+iptables,product=proc 与 sandbox
  reaper/shutdown_all 生命周期解耦);web lifespan 每小时 sweep(7d TTL+孤儿容器)
- web:GET /v1/procs(用户级)+ POST /v1/tasks/{id}/procs/{pid}/kill;前端
  [Background] 工具卡活化(spinner/跳秒/停止,历史重渲恢复)+ 完成 toast(跨 task
  可跳转)+ 对话锁(bgproc 运行期 composer 发送→停止/Enter 拦截,先锁后放消
  刷新时序窗口;润色/语音与 streaming 期一致可用)
- seedance:resume_task_id 续查(轮询超时/中断后取回结果,跳过提交不重复计费),
  fast/pro poll_timeout 统一 1200s;取消/超时文案引导续查
- core/llm.py:litellm timeout 显式化(默认 600s 不变,ZCBOT_LLM_TIMEOUT_S 可调)
- DESIGN 新增 §8.12(含边界:不做 job 链/自动续跑/不引 Celery);RUN/PROGRESS/
  CHANGELOG 同步;scripts/test_bgproc_manual.py 手工验证;unittest 全量 201 过

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:47:11 +08:00
caoqianming 3ef472c112 feat(files): 文件面板根目录隐藏定时/渠道系统工作目录(bump 0.55.2)
- /v1/files 根层与 /v1/folders 候选过滤 scheduled-*/wechat-*/wecom-* 工作目录
- 判定走 DB 回查(_system_wd_names:scheduled_job_id / channel),孤儿目录不隐藏
- 仅列表降噪非权限拦截:带 path 访问照常,点任务跳入目录的前端行为不变

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 12:12:22 +08:00
caoqianming 5b6adb3746 feat(changelog): 用户版更新日志 CHANGELOG.md + /v1/changelog + 点版本号弹层(bump 0.55.0)
- 新增仓库根 CHANGELOG.md:面向用户口径(只记可感知变化),回填至 0.30.0 共 25 条
- GET /v1/changelog:公开无鉴权,`## <版本> — <日期>` 正则切段,mtime 缓存热改即生效,无 DB 零 migration
- 前端 changelog.js:右栏底部版本号可点弹更新日志;localStorage 记 last-seen 版本,
  与 /healthz 版本不一致亮红点提示,打开即清;接入 Esc/遮罩关闭栈
- 单测 tests/test_changelog.py 3 项(含真文件格式校验);全量 201 过
- CLAUDE.md 加维护约定:bump 版本若用户可感知顺手补 CHANGELOG 条目

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 16:49:26 +08:00
caoqianming 66a515aa3d feat(context): §8.8 Phase 2 长会话中段折叠摘要 + 头部压缩指示环(bump 0.54.1)
- 折叠(0.54.0):run 起点窗口体量达 reliable_context×85% → core/context_fold.py
  把中段折叠为固定模板结构化摘要(路径/ID/数值原文保留),写 tasks.context_summary
  (migration 0021)+ 推进 context_base_idx;Session.load 注入仅内存「前情摘要」
  (_n_head/_base_idx 维护 idx 映射);摘要调用复用会话 prepare 后前缀 + 末尾追加
  指令,与上一轮 chat 缓存字节一致近全程 hit;失败零阻塞(warn+跳过);「新话题」
  硬重置 / web 清空对话顺带清摘要,gap 软重置保留
- 前端(0.54.0):新 SSE context_fold 事件(start 状态行提示 / done 流内分隔条);
  补 warn 事件渲染 —— 此前 loop 熔断/重复拦截提示在 web 端被静默丢弃
- 压缩指示环(0.54.1):头部金额撤下明面(挪 hover tooltip),换 SVG donut
  (绿<50% 未压缩 / 琥珀 50-85% 压缩区 / 红≥85% 达折叠阈值)+ 已整理×N;
  GET /v1/tasks/{id} 详情新增 context_window_chars/limit_chars/pressure/folds
  (additive,列表端点不加);折叠计费 kind="context_fold"(record_chat_usage
  加 kind 参数,折叠次数 = count 该 kind,零 migration);llm_start 事件补
  context_limit_chars,run 中实时刷环
- DESIGN:§8.11 新增「最小子循环 delegate」预留(上下文隔离而非多 agent 编排,
  按 diag 数据触发,无信号不实施);§1/§6 "不做 subagent" 收窄为编排型;
  §8.8 Phase 2 标  沉淀六条取舍;§7.2 SSE 清单补 warn/context_fold
- tests/test_context_fold.py 12 项(切点/映射/apply_fold/maybe_fold 打桩),
  全量 198 过;本地 DB 已升 0021

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 12:39:31 +08:00
caoqianming 5b09ec5c64 fix(wecom): 最终回复推送失败不再静默(bump 0.52.5)
_bg 最终 push_wecom 的返回值一直被忽略,message/send 失败(如企微
「企业可信 IP」拦截 60020)完全无痕。统一走 _push_checked,失败落
`[wecom] push failed: <reason>` 日志(带企微 errcode/errmsg)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 10:49:50 +08:00
caoqianming 52db2ef939 feat(wecom): 回调加入站留痕日志,断定语音回调是否被投递(bump 0.52.4)
企微发语音无反应且 [wecom-voice] 日志零出现(该分支被调用必打),疑企微
侧未投递语音回调;text 路径原先零日志无法对照。解密成功后每条打
`[wecom] inbound msgtype=... from=...`:text 行出现而 voice 行不出现
即可断定平台侧未投递,与处理代码无关。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 10:38:16 +08:00
caoqianming abe2f6b9f2 feat(wechat): ClawBot 语音接入 + 企微语音路径补日志(bump 0.52.0)
- ilink: 解析 voice_item(type=3,真机实测)-> InboundAttachment(kind=voice),
  CDN 下载/AES 解密复用现有 download_media
- core/audio.py: 统一入口 transcribe_voice(bytes) -- 按字节头分流:
  SILK v3(微信,\x02#!SILK_V3)-> pilk 解 24k PCM + ffmpeg 重采样 16k;
  其余(企微 AMR 等)-> ffmpeg 直解;两渠道共用
- inbound: 语音附件就地转写,回执「已识别:...」,文本并入对话;失败回可读提示
- wecom 回调补日志: voice 入站行 / transcribe 异常行 / push_wecom 失败 reason
  (原返回值被忽略纯静默)/ 未处理 msgtype 行 -- 排查"发语音无反应"
- 新依赖 pilk(requirements);RUN/PROGRESS 同步
- 已实测: pilk 造微信同款 silk 全链转写逐字正确;AMR 路径回归通过

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 09:47:22 +08:00
caoqianming 3579e38df5 feat(wecom): 入站语音消息转写进对话(bump 0.51.0)
- 回调新增 msgtype=voice 分支:media/get 下 AMR -> core/audio.py(ffmpeg
  子进程解成 16k PCM)-> 讯飞 IAT 整段转写 -> 当用户消息走同一条对话链路
- 下载/解码/转写全放后台任务,回调窗口内只登记 MediaId(防 5s 超时重推)
- 识别结果先推「已识别:...」回显;缺 ffmpeg/讯飞未配/识别为空各回可读提示
- ClawBot getupdates 对未识别 item 打结构日志(个人微信语音探协议准备)
- 服务器新增系统依赖 ffmpeg(RUN.md env 段;可选 FFMPEG_PATH 指定路径)
- 已实测:TTS 中文 4.5s -> AMR-NB 8k -> 解码 -> 转写逐字正确

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 09:01:37 +08:00
caoqianming e9e549b606 feat(web): 语音输入流式化:边说边出字,松手秒出最终文本(bump 0.49.0)
根因:整段模式按 40ms/帧回放音频给讯飞 = 按录音时长等比例白等(16.8s 实测等 4.6s)。

- 新增 WS /v1/asr/stream 流式代理:首消息 JWT 鉴权(不走 query 防进 access log),
  二进制帧实时转发讯飞(dwa=wpgs),动态修正(sn/pgs/rg)在服务端 XfyunStream 合并,
  回推 {"text","final"} 增量全文;引擎提前收尾(静音/60s)推 final 前端自动结束
- 前端:录音即连 WS,增量降采样(carry 跨片连续)实时上行,录音面板实时字幕;
  WS 失败静默退回整段 POST 兜底。实测 16.8s 音频 end→final 179ms(原 ~2.5s+)
- 整段兜底路径帧间隔 40ms→8ms(实测 4.6s→1.75s,识别文本一致)
- nginx 示例加 /v1/asr/stream WS Upgrade 段;RUN 故障兜底补讯飞 IP 白名单 403 与
  反代挡 WS 两条

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:42:20 +08:00
caoqianming 7b20cae85b feat(web): 语音手势两端统一:按下时长判别,PC 鼠标也可按住说话(bump 0.48.1)
- 不再按 pointerType 分流:按住 ≥300ms = 按住说话(松开转写,上滑取消);
  快速点一下 = 连续录音免持(说完 Enter / 再点结束,Esc 取消)—— PC 手机同一心智模型
- press 发起的录音,松手后合成 click 全压掉;"点按钮停止连续录音"的 press 不压(_pressActive)
- getUserMedia 完成前松手按同一时长规则收尾;键盘激活按钮兜底走 click 起录

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:13:26 +08:00
caoqianming e911eb2c6b feat(web): 语音输入手势升级:触屏按住说话 + 桌面 Enter/Esc 收尾 + 波浪反馈(bump 0.48.0)
- 触屏(微信/DeepSeek 式):按住录音,松开转写,上滑 60px 松开取消;pointer capture + 合成 click 压制
- 桌面:点击开始,说完 Enter 结束转写 / Esc 取消(capture 拦截防触发发送);不做 VAD 静音自动停
  (口述需求句间停顿 3~5s 常态,自动截断更伤;业界听写类全是手动收尾)
- 录音面板替换 textarea:实时波浪(采集 chunk 顺手算 RMS)+ 计时 + 手势提示;iOS 手势内 resume
- 纯前端(chat.js + dev.html),后端零改动

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:03:47 +08:00
caoqianming a36a8f1da4 feat(web): 语音输入(讯飞语音听写 IAT)(bump 0.47.0)
- core/asr_xfyun.py:IAT wss 客户端(HMAC-SHA256 签名 / 9600B 帧 / vad_eos=10000),整段 PCM→文本
- web/app.py:POST /v1/asr/transcribe(裸 PCM 16k/16bit/mono body,>60s→413,未配 env→501,讯飞错→502)
- 前端「🎙 语音」按钮:Web Audio 采 PCM 降采样 16k → 转写 → insertText 填输入框(接 undo 栈,不自动发送);60s 自动停;渠道只读镜像锁定
- 凭据走 env XFYUN_APPID/API_KEY/API_SECRET(.env 当前为测试 key,待换正式)
- scripts/diag_asr.py 链路诊断;requirements 显式加 websockets
- 已实测:TTS 合成 5.2s 中文经端点转写逐字正确;企微语音(AMR 需 ffmpeg)留二期

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:16:58 +08:00
caoqianming 9ae1040faf fix(web): 消息分页窗口对齐 user 轮次边界(bump 0.46.2)
原按固定 30 条翻页/跳转,窗口边界常切在 assistant 工具调用序列中间
(顶部孤儿 tool 消息);圆点跳转 before_idx=idx+11 启发式长轮拉不全。
业界调研(Discord/OpenAI):分页单位保持消息条数,轮次归组属前端职责。

- 向上翻页 alignedEarlierLimit:按整轮累加攒够 30 条,窗口顶恒为 user 消息
- 圆点跳转:after_idx=起点-1 + 相邻 outline 边界差精确拉整轮
  (短轮补到 30,超大轮按 MSG_FETCH_MAX=100 截断,后续加载自愈对齐)
- outline 未加载/idx 缺失 → 回退旧按条行为;后端 API 零改动

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 13:07:40 +08:00
caoqianming a47a7799e7 refactor(wecom): 撤除欢迎推送机制,聊天优先布局下不需要(bump 0.46.1)
实际部署改为聊天优先布局(不配应用主页 + 自定义菜单「工作台」按钮指向
/v1/wecom/entry),点应用直进会话,冷启动问题不复存在,0.46.0 的欢迎
推送成了死重:

- 删 wecom_welcomes 表(migration 0021 撤下;DB 已 downgrade 回 0020,
  表建后无一行写入)、maybe_send_wecom_welcome、免登回调欢迎钩子、
  scripts/wecom_welcome_broadcast.py、wecom.list_visible_userids
- 保留:未绑定入站回绑定指引(聊天优先下必需)、core/branding.py 品牌单一源
- RUN.md 企微段只留推荐布局 + 未绑定指引;PROGRESS 同步

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:26:13 +08:00
caoqianming f3be3d5016 feat(wecom): 会话冷启动欢迎消息 + 未绑定入站回绑定指引(bump 0.46.0)
背景:应用配「应用主页」后点应用只进主页,应用会话要等应用发过第一条消息
才出现在成员消息列表,首次登录的员工没入口和 bot 打字。最终采用聊天优先
布局(不配主页、自定义菜单放「工作台」按钮指向 /v1/wecom/entry)。

- 回调:未绑定成员的 text/图片/文件消息不再静默,每条回绑定指引
  (聊天优先下新员工上来就打字,静默会被当成 bot 坏了;事件不回)
- 新表 wecom_welcomes(0021)+ service.maybe_send_wecom_welcome:
  免登入口回调给首次进入的成员推欢迎语"种"会话入口;两层去重
  (已推过 / 已绑定且有 chat task),claim-first 并发安全
- scripts/wecom_welcome_broadcast.py:配主页布局下的存量批量种入口
  (可选工具,--dry-run 先看名单);wecom.list_visible_userids 枚举可见范围
- 品牌名单一事实源挪 core/branding.py(app.py BRAND 与欢迎语共用)
- RUN.md 企微段补推荐布局;PROGRESS 同步

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:11:10 +08:00
caoqianming 7f77e5aca9 feat(web): 移动端显示修复 + 对外品牌名 ZCBOT_BRAND_NAME(bump 0.45.0)
移动端:
- 浮层菜单(⋯ 任务菜单 / ⚙ 媒体模型弹层)原只右对齐触发按钮,手机上触发钮
  靠左时菜单冲出屏幕左缘盖住正文;改为放不下时切左对齐 + 双向钳视口内
- 手机断点:菜单项加大触控目标、模型下拉限宽 48vw、header 品牌名单行省略
- 「管理」入口挪到 任务/对话/文件 tabs 行右侧小按钮(CSS order 实现,
  embed 与普通模式同款,embed 专属覆盖规则随之删除)

品牌名:
- 新 env ZCBOT_BRAND_NAME(默认「总院科研辅助助手」),zcbot 只是内部代号
- /healthz 返 brand 字段,前端 boot 拉取覆盖页面标题/顶栏/登录卡
- 微信/企微测试推送、OAuth 提示页、绑定页等用户可见文案改品牌名或中性「助手」

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:44:26 +08:00
caoqianming b0a8802d35 feat(wecom): 企业微信应用主页免登进 embed 控制台(bump 0.44.0)
- 新端点 GET /v1/wecom/entry:企微客户端内走网页授权 snsapi_base 静默
  无感,外部浏览器退 wwlogin 扫码,同一 URL 两端可用;state 签 login
  哨兵防 CSRF(绑定回调显式拒绝该哨兵,两条流程不串)
- 新端点 GET /v1/wecom/entry/callback:code→userid→反查绑定→签 JWT→
  302 dev.html?embed=1&wecom=1,token 走 URL fragment 不进日志;
  未绑定成员提示先在控制台绑定,不自动建号(避免重复账号)
- 前端 wecom 变体:fragment 吃 token 即清 hash,不需要 parent_origin;
  401/logout 回 entry 静默重签;iframe embed 与 postMessage 协议零改动
- wecom.py 新增 oauth_inclient_url();提示页 helper 提出共用
- EMBED.md §9 + RUN.md 企微段(应用主页 + 网页授权可信域名两项设置)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:09:52 +08:00
caoqianming 50e1d57ed5 fix(web): 跳秒指示覆盖刷新重连 + 发送后空窗(bump 0.43.1)
- 刷新/重连接管:phaseSince 随页面内存丢失,原先要等下一个 llm_start/
  tool_call 才恢复跳秒——恰是用户因疑似卡死而刷新的场景;重建直播卡时
  立即按"思考中"起跳,后续阶段事件校正(秒数从重连时刻起,非真实累计)
- 发消息 POST 返回即起跳:覆盖 build_agent + 首轮 TTFT,原先等 llm_start
  才开始计时

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 09:16:00 +08:00
caoqianming 463e282afd feat(web): run 长耗时可视化——reasoning 事件流 + 阶段跳秒指示(bump 0.43.0)
修"前端一直静止显示思考中被当成卡死":

- core/loop.py:新增 SSE reasoning{delta} 事件——thinking 模型分钟级推理
  原先 delta.reasoning_content 整段丢弃,前端零反馈;流式/非流式兜底两路都发
- chat.js:reasoning 渲染成灰色可折叠"思考过程"卡(直播态随流展开、正文
  开始折叠;历史消息里持久化的 reasoning_content 同款渲染)
- chat.js:活跃状态指示——TTFT/推理期占位段每秒跳"思考中/深度思考中 · Ns"
  (data-status + CSS attr),工具卡运行中 spinner + 跳秒、tool_result 定格
  为执行耗时;每轮 llm_start 重建占位段,工具轮之后不再空窗
- DESIGN §7.2 SSE 事件清单补 reasoning

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 09:02:31 +08:00
caoqianming 8b482e7a30 feat(broker): 外置 Redis pub/sub——蓝绿跨实例 SSE/cancel 可达(bump 0.42.0)
用户量上升重评落地(DESIGN §7.0):web/broker.py 拆 LocalRunBroker(原进程内
实现,默认)/ RedisRunBroker 同接口双实现,ZCBOT_REDIS_URL env 开关。

- event:PUBLISH zcbot:ev:<tid> + 单条 async pubsub reader 路由本地订阅 queue
  (Redis 只管跨进程一跳,fan-out 最后一公里仍在进程内)
- done:SETEX 60s key + publish 双通道;订阅先挂 channel 再查 key,晚到不漏
- cancel:SETEX/GET/DEL key,loop chunk 间 poll,跨实例可达——蓝绿切换窗口
  「刷新丢直播 / 停止失灵」两个边缘消失;稳态双实例分流只差 nginx 配置
- 容错:启动 ping 不通 fail-fast(同 sandbox init 纪律);运行中失败 10s 节流
  log + 降级丢帧,run 不受影响;reader 1s 退避重连并重挂活跃 channel
- lifespan finally 加 broker.shutdown();sinks.py 解开对旧类名的 import
- tests/test_broker_redis.py 9 例(fakeredis:跨实例 emit/cancel、晚订阅 done、
  降级不抛、fail-fast);requirements 加 redis/fakeredis
- RUN.md:env 段 + 蓝绿节 + bootstrap(apt install redis-server)+ 故障兜底 3 行

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 14:51:39 +08:00
caoqianming 1d62670366 feat(deploy): 蓝绿双实例无感部署落地——零 503 窗口 + SSE 不断流(bump 0.41.0)
- deploy/update_bluegreen.sh:复用 update.sh --prepare-only 做准备 → 起空闲色
  → healthz 验 instance → nginx upstream 切流 → 轮询 DB 等旧色 run 自然清空
  (DRAIN_WAIT_SECONDS 默 600s,期间旧 SSE 继续直播)→ stop 旧色 → 残留
  running 行标 error → enable 新色/disable 旧色;--switch 半途恢复调流
- deploy/zcbot@.service 模板 unit + deploy/instances/{blue,green}.env +
  deploy/nginx/zcbot.conf.example(upstream 单行文件由脚本改写)
- tasks.run_owner(migration 0020):起 run 写 ZCBOT_INSTANCE,启动 reaper
  只收自己色的孤儿,不误杀另一实例在跑的 run
- sandbox 容器名/label 带实例色,shutdown_all 只清自己色,并存期互不清扫
- 微信 ClawBot 长轮询 PG advisory lock 选主(detach 连接持锁 + 30s keepalive,
  断连即让位),蓝绿并存不抢消息;定时任务 claim 本就 SKIP LOCKED 免改
- /healthz 加 instance 字段;update.sh 加 --prepare-only
- 单实例部署(ZCBOT_INSTANCE 未设)行为全部不变;RUN/DESIGN/PROGRESS 同步

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 14:03:10 +08:00
caoqianming 43775d531a feat(skill): skill 定向模型——ppt 触发自动热切 GLM-5.2(bump 0.39.0)
内置 skill 可在 frontmatter 声明 model: <family.variant>(ppt → glm.pro52)。
单一执行点:load_skill 命中 → run 内热切 self.llm/self.caps(下一轮生效,
记账/压缩阈值自动跟上)+ 持久化 tasks 双列 + emit model_switch 事件;切失败
降级 warn 原模型继续。send 侧档外静默降级对定向模型豁免(免费档第二条消息
不被降回 flash)。跳档位门控(产品决策);只信内置 skill(用户 skill 的 model
字段忽略,防绕门控);不自动切回。"开关"= frontmatter 那一行本身(registry
每 run 现扫,删行即停,per-skill 粒度)—— 曾实现 app_settings 表 + admin
总开关 + 建 task 预切,架构评审后砍掉(第二事实源/第二执行点),详 DESIGN §3.5。
前端:直播流 model_switch 插模型分隔线 + 顶栏下拉同步(档外定向模型临时补
option 如实显示)。新增 tests/test_skill_model_pinning.py。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 08:57:32 +08:00
caoqianming d24165a2fe style(web): 列表状态灯挪到文件夹行左侧,数据行 space-between 均匀分布(bump 0.38.8)
终态徽章 + 运行圆点放进文件夹行行首(无文件夹行回落数据行,patch 逻辑同规则
找 host);底部数据行剩纯数据均匀铺开,时间自然落行尾。

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:59:34 +08:00
caoqianming d30f6089bb fix(web): 直播流式文字按轮次分段——修工具刷屏时文字被推出视口(bump 0.37.2)
一次 run 把整段(含几十轮 LLM)塞进一张 assistant 卡:文字全累顶部单块、
工具卡全追加其下,工具多时文字被越推越高滚出视口看不到。根因是直播态(单卡合并)
与历史态(每轮 LLM 一条独立消息、天然穿插)结构不一致。

方案 A(只动 chat.js live-run 路径,历史渲染不动):文字按轮次分段——
ensureTextSeg/closeTextSeg 维护当前打开的文字段,每个可见工具/选项卡(非隐形
task_progress)先关掉当前段(空占位段移除、有内容段定稿去光标+高亮),之后新文字
在卡片底部另起新段。流式文字始终在底部可见,且与历史结构一致,run 结束 reload 无跳变。
rAF 节流改闭包捕获 seg 防错渲;ctx.body/ctx.pending 单块模型换成 ctx.curSeg。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:00:15 +08:00