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>
This commit is contained in:
parent
529d7f1046
commit
b5cfce72b5
|
|
@ -716,6 +716,7 @@ create index on usage_events (model_profile, created_at);
|
|||
- **入站长轮询管理器**(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,7 +2,7 @@
|
|||
|
||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
||||
|
||||
最后更新:2026-06-24(微信入站收图片/文件:CDN 下载+AES 解密落盘,复用 [用户上传的参考图] 约定喂 look_at_image + bump 0.25.0)
|
||||
最后更新:2026-06-24(微信 task 在 web 端只读镜像:web→微信单向不同步,锚定微信为单一交互权威 + bump 0.25.1)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -21,6 +21,11 @@
|
|||
|
||||
## 已完成关键能力
|
||||
|
||||
### 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`(剥路径防穿越)。
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.25.0"
|
||||
__version__ = "0.25.1"
|
||||
|
|
|
|||
|
|
@ -824,6 +824,8 @@
|
|||
}
|
||||
#chat-form .row { display: flex; gap: 8px; }
|
||||
#chat-form textarea { flex: 1; }
|
||||
/* 微信渠道 task 只读镜像:输入框置灰禁手输,引导去微信对话 */
|
||||
#chat-input.readonly-locked { background: #f0f0f0; color: var(--muted); cursor: not-allowed; }
|
||||
#chat-form .hint { font-size: 11px; color: var(--muted); }
|
||||
|
||||
/* ───── files ───── */
|
||||
|
|
|
|||
|
|
@ -261,6 +261,7 @@ export async function selectTask(tid) {
|
|||
]);
|
||||
state.taskMeta = meta;
|
||||
renderChatMeta();
|
||||
applyChannelComposerLock(meta);
|
||||
if (meta.run_status === "running" || meta.run_status === "cancelling") {
|
||||
ensureRunningTaskSubscribed(tid, `/v1/tasks/${tid}/events`);
|
||||
} else {
|
||||
|
|
@ -972,6 +973,26 @@ async function deletePastedFile(rel, wrap) {
|
|||
}
|
||||
}
|
||||
|
||||
// 微信渠道 task 在 web 端只读:这条对话的唯一交互入口锚定在微信本身 —— agent 回复必须
|
||||
// 带 context_token 才能发回微信,而 token 只能从用户微信入站消息拿(24h 过期),zcbot 在
|
||||
// 协议层就没有对微信无条件说话的能力。故 web→微信单向不同步(web 发的微信侧看不到);
|
||||
// 与其做受 24h 窗口拖累的"有时同步"双向打通,不如让 web 端做干净的只读镜像(单一交互
|
||||
// 权威 + 可预测),想主动往微信推走 wechat_push / 定时简报。微信→web 仍同步(同一条 task)。
|
||||
function applyChannelComposerLock(meta) {
|
||||
const input = $("chat-input");
|
||||
if (!input) return;
|
||||
const isWechat = !!meta && meta.channel === "wechat";
|
||||
input.readOnly = isWechat;
|
||||
input.classList.toggle("readonly-locked", isWechat);
|
||||
input.placeholder = isWechat
|
||||
? "微信对话请在微信里进行 — web 端为只读镜像,可查看历史"
|
||||
: "输入消息…(Enter 发送,Shift+Enter 换行,Ctrl+V 可粘贴文件)";
|
||||
if (isWechat) {
|
||||
const opt = $("chat-optimize");
|
||||
if (opt) opt.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 润色:同步调后端,把 textarea 内容替成优化后文本。用 execCommand('insertText')
|
||||
// 接 textarea 原生 undo 栈 — Ctrl+Z 一次回到原文。streaming 期间允许并行(后端
|
||||
// 不与主对话 run 互斥,各跑各的 LLM)。
|
||||
|
|
@ -1093,6 +1114,11 @@ function takePastedRels() {
|
|||
async function sendMessage(overrideText) {
|
||||
if (!state.taskId) return;
|
||||
if (isCurrentTaskStreaming()) return;
|
||||
// 微信渠道 task 只读:web 发的消息推不到微信(单向),挡在入口避免不一致(见 applyChannelComposerLock)
|
||||
if (state.taskMeta && state.taskMeta.channel === "wechat") {
|
||||
$("chat-hint").textContent = "微信对话请在微信里进行 — web 端为只读镜像";
|
||||
return;
|
||||
}
|
||||
const fromInput = typeof overrideText !== "string";
|
||||
let content = (fromInput ? $("chat-input").value : overrideText).trim();
|
||||
// 粘贴附件路径注入正文:用户贴图后发的消息往往是「按这张改 / 看看这张图」,
|
||||
|
|
|
|||
Loading…
Reference in New Issue