Compare commits

..

2 Commits

Author SHA1 Message Date
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
8 changed files with 215 additions and 16 deletions

View File

@ -706,6 +706,7 @@ create index on usage_events (model_profile, created_at);
**协议要点(自实现客户端,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 简报可原生直推为可打开附件**,无须退下载链接。
@ -715,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。

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-06-24(微信绑定表重构为统一 channel_bindings 判别列+JSONB,合并 ClawBot/企微两表 + bump 0.24.3)
最后更新:2026-06-24(微信 task 在 web 端只读镜像:web→微信单向不同步,锚定微信为单一交互权威 + bump 0.25.1)
---
@ -21,6 +21,18 @@
## 已完成关键能力
### 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 列并列)最差。

View File

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

View File

@ -19,7 +19,7 @@ import hashlib
import os
import time
import uuid
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any, Optional
from urllib.parse import quote
@ -80,6 +80,46 @@ def _aes_ecb_pkcs7(plaintext: bytes, key: bytes) -> bytes:
return enc.update(padded) + enc.finalize()
def _aes_ecb_unpkcs7(ciphertext: bytes, key: bytes) -> bytes:
"""收图/收文件的解密:AES-128-ECB 解 + 去 PKCS7(发送侧 `_aes_ecb_pkcs7` 的逆)。"""
dec = Cipher(algorithms.AES(key), modes.ECB()).decryptor()
padded = dec.update(ciphertext) + dec.finalize()
unpadder = padding.PKCS7(128).unpadder()
return unpadder.update(padded) + unpadder.finalize()
def _decode_media_aes_key(raw: str) -> bytes:
"""媒体 `media.aes_key` → 16 字节 AES key。两种实测编码兜住:
- `base64(raw 16 bytes)`(图片常见) 解码得 16 字节直用;
- `base64(hex 字符串)`(文件/语音/视频,发送侧 `_upload_file` 也用这种) 解码得
32 ASCII hex 字符, `fromhex` 16 字节
"""
dec = base64.b64decode(raw)
if len(dec) == 16:
return dec
if len(dec) == 32:
try:
return bytes.fromhex(dec.decode("ascii"))
except (ValueError, UnicodeDecodeError):
return dec[:16]
return dec[:16]
def _guess_image_ext(data: bytes) -> str:
"""按 magic bytes 猜图片扩展名(微信入站图片无原文件名)。认不出回退 .jpg。"""
if data[:3] == b"\xff\xd8\xff":
return ".jpg"
if data[:8] == b"\x89PNG\r\n\x1a\n":
return ".png"
if data[:6] in (b"GIF87a", b"GIF89a"):
return ".gif"
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return ".webp"
if data[:2] == b"BM":
return ".bmp"
return ".jpg"
# ─────────────────────────── 绑定(无 token)───────────────────────────
@dataclass
@ -130,12 +170,30 @@ def poll_qrcode_status(
# ─────────────────────────── 收发(带 token)───────────────────────────
@dataclass
class InboundAttachment:
"""入站附件(图片 / 文件)的 CDN 引用 + 下载后填充的明文字节。
协议结构(getupdates 返回的 item_list ,实测 + 逆向 photon-hq/wechat-ilink-client):
- 图片 `image_item`(type=2):`media{encrypt_query_param, aes_key, encrypt_type}`,
另带优先 `aeskey`(32 hex);文件名缺失,下载后按 magic bytes 补扩展名
- 文件 `file_item`(type=4):`media{...}` + `file_name` + `len`(明文大小)
"""
kind: str # "image" | "file"
media: dict[str, Any] # {encrypt_query_param, aes_key, encrypt_type}
file_name: str = "" # 文件原名(图片无名,落盘时按 magic bytes 生成)
aeskey_hex: str = "" # 图片优先 key:image_item.aeskey(32 hex chars)
size: int = 0 # 明文大小(file_item.len / image mid_size),仅参考
data: Optional[bytes] = None # 下载 + 解密后的明文,由调用方(inbound)回填
@dataclass
class InboundMessage:
from_user_id: str # xxx@im.wechat
context_token: str # 回复 / 24h 内主动推须带回
text: str
raw: dict[str, Any]
attachments: list[InboundAttachment] = field(default_factory=list)
class ILinkClient:
@ -160,18 +218,62 @@ class ILinkClient:
d = r.json()
msgs: list[InboundMessage] = []
for m in d.get("msgs", []) or []:
text = "".join(
(it.get("text_item", {}) or {}).get("text", "")
for it in m.get("item_list", []) or []
)
text_parts: list[str] = []
attachments: list[InboundAttachment] = []
for it in m.get("item_list", []) or []:
if it.get("text_item"):
text_parts.append((it["text_item"] or {}).get("text", ""))
img = it.get("image_item")
if img:
attachments.append(InboundAttachment(
kind="image",
media=img.get("media") or {},
aeskey_hex=(img.get("aeskey") or ""),
size=int(img.get("mid_size") or 0),
))
fil = it.get("file_item")
if fil:
attachments.append(InboundAttachment(
kind="file",
media=fil.get("media") or {},
file_name=(fil.get("file_name") or "file"),
size=int(fil.get("len") or 0),
))
msgs.append(InboundMessage(
from_user_id=m.get("from_user_id", ""),
context_token=m.get("context_token", ""),
text=text,
text="".join(text_parts),
raw=m,
attachments=attachments,
))
return msgs, d.get("get_updates_buf", cursor)
# —— 收附件(CDN 下载 → AES-128-ECB 解密 → 明文 bytes)——
def download_media(self, att: InboundAttachment, *, timeout: float = 60.0) -> bytes:
"""下载并解密一个入站附件,返回明文 bytes(发送侧上传链路的逆操作)。
URL:`{CDN_BASE}/download?encrypted_query_param=<media.encrypt_query_param>`
Key 优先级:图片 `image_item.aeskey`(32 hex)> `media.aes_key`(两种编码,
`_decode_media_aes_key`)
"""
media = att.media or {}
qp = media.get("encrypt_query_param") or media.get("encrypted_query_param") or ""
if not qp:
raise RuntimeError(f"附件无 encrypt_query_param: kind={att.kind} media={media}")
url = f"{CDN_BASE}/download?encrypted_query_param={quote(qp)}"
with httpx.Client(timeout=timeout) as c:
# 下载语义按逆向文档是 GET;CDN 若只认 POST 则回退一次(下载幂等,无副作用)
r = c.get(url)
if r.status_code == 405 or (400 <= r.status_code < 500 and not r.content):
r = c.post(url, content=b"")
r.raise_for_status()
ciphertext = r.content
if att.aeskey_hex and len(att.aeskey_hex) == 32:
key = bytes.fromhex(att.aeskey_hex)
else:
key = _decode_media_aes_key(media.get("aes_key") or "")
return _aes_ecb_unpkcs7(ciphertext, key)
# —— 发(底层单条)——
def _send(
self, to_user_id: str, context_token: str, item: dict, *, state: int
@ -289,6 +391,18 @@ class ILinkClient:
self._send(to_user_id, context_token, item, state=STATE_FINISH)
def attachment_basename(att: InboundAttachment) -> str:
"""入站附件的安全落盘文件名(不含目录):剥掉路径分隔防穿越;图片按 magic bytes 补扩展名。
返回的是 basename,调用方负责加前缀(时间戳 / 随机)防重名并拼到 inbound 目录下
"""
if att.kind == "image":
ext = _guess_image_ext(att.data or b"")
return f"image{ext}"
name = os.path.basename((att.file_name or "file").replace("\\", "/")).strip()
return name or "file"
def _read_file_capped(file_path: str | os.PathLike) -> bytes:
size = os.path.getsize(file_path)
if size > MAX_FILE_BYTES:

View File

@ -21,11 +21,12 @@ from sqlalchemy import select
from core.storage import session_scope
from core.storage.models import Message
from core.wechat import service
from core.wechat.ilink import ILinkClient
from core.wechat.ilink import ILinkClient, InboundAttachment
from core.wechat.service import BindingSnapshot
# app.py 注入:跑该用户的微信对话 task,返回 assistant 回复文本(可空)
HandleMessage = Callable[[UUID, str], Awaitable[str]]
# app.py 注入:跑该用户的微信对话 task,返回 assistant 回复文本(可空)。
# 第三参 attachments:已下载解密(att.data 已回填)的入站附件,app.py 负责落盘 + 拼提示行。
HandleMessage = Callable[[UUID, str, list[InboundAttachment]], Awaitable[str]]
def _content_to_text(content: Any) -> str:
@ -79,15 +80,25 @@ async def _poll_binding(
for m in msgs:
if stop.is_set():
break
if not m.text.strip():
# 下载入站附件(图片/文件):CDN 取密文 → AES 解密 → 回填 att.data
atts: list[InboundAttachment] = []
for att in m.attachments:
try:
att.data = await asyncio.to_thread(client.download_media, att)
atts.append(att)
except Exception as e: # noqa: BLE001
print(f"[wechat-inbound] {str(snap.user_id)[:8]} download "
f"{att.kind} err: {type(e).__name__}: {e}")
# 文本和附件都没有(纯文本为空 / 附件全下载失败)→ 跳过整条
if not m.text.strip() and not atts:
continue
# ① 刷新该用户推送窗口(主动推靠它续命)
await asyncio.to_thread(
service.refresh_context_token, snap.user_id, m.from_user_id, m.context_token
)
# ② 跑 agent 取回复
# ② 跑 agent 取回复(附件由 handle_message 落盘 + 拼 [用户上传的...] 行)
try:
reply = await handle_message(snap.user_id, m.text)
reply = await handle_message(snap.user_id, m.text, atts)
except Exception as e: # noqa: BLE001
reply = f"[出错] {type(e).__name__}: {e}"
# ③ 用本轮新鲜 token 分块回

View File

@ -856,11 +856,17 @@ def create_app() -> FastAPI:
wechat_stop = asyncio.Event()
wechat_task = None
async def _run_wechat_message(uid: UUID, text: str) -> str:
"""微信入站一条消息:解析/建用户常驻「微信」task → 抢 run 锁 → _run_agent_bg → 取回复。"""
async def _run_wechat_message(uid: UUID, text: str, attachments=None) -> str:
"""微信入站一条消息:解析/建用户常驻「微信」task → 落盘附件 → 抢 run 锁 → _run_agent_bg → 取回复。
attachments:已下载解密的入站附件(core.wechat.ilink.InboundAttachment,att.data 已回填)
图片落盘后拼 `[用户上传的参考图] <rel>` (agent 见之自调 look_at_image),文件拼
`[用户上传的文件] <rel>` (agent Read/Shell 处理)
"""
from core.agent_builder import resolve_workspace, working_dir_from_name
from core.storage.utils import ensure_local_task_row
from core.wechat import service as _wx
from core.wechat.ilink import attachment_basename
from core.wechat.inbound import extract_last_assistant_text
snap = await asyncio.to_thread(_wx.get_binding, uid)
@ -888,6 +894,32 @@ def create_app() -> FastAPI:
)
await asyncio.to_thread(_wx.set_chat_task, uid, tid)
# 落盘入站附件到 <wd>/inbound/,拼 [用户上传的...] 行进 text(复用 web 端粘贴图约定)
if attachments:
from datetime import datetime
from pathlib import Path
with session_scope() as s:
wd_db = s.execute(
select(Task.working_dir).where(Task.task_id == tid)
).scalar_one()
inbound_dir = from_db_path(wd_db) / "inbound"
inbound_dir.mkdir(parents=True, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
lines: list[str] = []
for i, att in enumerate(attachments):
if not att.data:
continue
base = attachment_basename(att)
name = f"{ts}-{i}-{base}"
(inbound_dir / name).write_bytes(att.data)
rel = f"inbound/{name}"
tag = "[用户上传的参考图]" if att.kind == "image" else "[用户上传的文件]"
lines.append(f"{tag} {rel}")
if lines:
extra = "\n".join(lines)
text = f"{text}\n\n{extra}" if text.strip() else extra
# 抢 run 锁:正忙 → 提示稍候(同用户串行,inbound loop 本就串行)
with session_scope() as s:
row = s.execute(

View File

@ -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 ───── */

View File

@ -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();
// 粘贴附件路径注入正文:用户贴图后发的消息往往是「按这张改 / 看看这张图」,