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>
This commit is contained in:
caoqianming 2026-06-29 10:05:07 +08:00
parent e49ff641f9
commit b27cc9cd5b
8 changed files with 148 additions and 7 deletions

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-06-26(admin 近7天用量表加合计行 + bump 0.31.1)
最后更新:2026-06-29(channel 长会话上下文软重置 + bump 0.32.0)
---
@ -21,6 +21,13 @@
## 已完成关键能力
### 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 顶掉,只剩一个。

1
RUN.md
View File

@ -81,6 +81,7 @@
- **扫码授权登录(要 HTTPS 域名)**:管理员在应用→**「企业微信授权登录」**里把 zcbot 域名配进可信域名(注意不是「网页授权可信域名」,是另一项)+ 设 `ZCBOT_PUBLIC_BASE_URL`;用户点「扫码绑定」→ 桌面浏览器出二维码 → 企业微信 App 扫码确认。回调 `/v1/wecom/oauth/callback` 公开(身份从 HMAC state 验)。链接走 `login.work.weixin.qq.com/wwlogin/sso/login`(不是网页授权 `oauth2/authorize`,后者只能在企微客户端内打开 → 桌面浏览器会报「请在企业微信客户端打开链接」)。
- 绑定后简报/结果**无条件主动推**(不挑活跃度、无 24h 窗口),适合必达。
- **入站对话(可选,要公网 HTTPS)**:企微后台「应用 → 接收消息 → 设置 API 接收」填回调 URL `<公网 base>/v1/wecom/callback` + 自动生成的 Token / EncodingAESKey → 写进 env `WECOM_CALLBACK_TOKEN` / `WECOM_CALLBACK_AESKEY` → 保存时企微 GET 验 URL(`/v1/wecom/callback` GET 自动回 echostr)。配好后用户在企业微信里直接给应用发消息即走 zcbot 对话(与个人微信各一张会话上下文)。agent 跑完走 message/send 主动推回(非被动同步,故无 5s 限制)。**支持文本 + 图片 + 文件**(图片/文件走 media/get 下载,落盘进会话目录 inbound/);语音/视频/位置等暂不处理;未绑定/空消息静默。
- **channel 长会话上下文(微信/企业微信通用,0019)**:常驻会话不再无限膨胀。① **自动分段**——入站时距上次消息超过 `config.json``channel.session_gap_hours`(默 **6** 小时,设 `<=0` 关闭)→ 软重置:只把「最后一条 user 消息起」喂模型(保留上一轮做续聊锚点),之前的历史仍全留 DB,网页端照旧翻完整记录;② **手动新话题**——用户在微信/企业微信里直接发「新话题 / 新会话 / `/new` / 清空上下文」→ 硬重置,彻底从零(回执提示已归档)。两者都**不删任何消息**,只移动「喂给模型的窗口起点」`tasks.context_base_idx`。网页端「清空对话」(`POST /v1/tasks/{id}/clear`)仍整清并把 base 归 0。需 `main.py db upgrade head` 带上 `0019`
- **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose / 远端 dev / 生产任选;未设置时启动清晰报错,不引导 docker(§7.4)。
- **Auth env**:`PLATFORM_KEY` + `JWT_SECRET` 任一缺失 web 启动 fail-fast。生成随机串:`python -c "import secrets; print(secrets.token_urlsafe(48))"`。
- **用户管理**(`users.email/password_hash/role`,0005 UNIQUE(email)、0009 role):dev SPA 登录后端。发用户两条路径任选:CLI `main.py user add`(下方),或在登录页右下角"+ 管理员添加用户"链接(需先设 `ZCBOT_ADMIN_TOKEN` env,弹窗输入 email/密码/管理员口令/角色)。撤用户 `DELETE FROM users WHERE email=...`(先 DELETE 该 user 的 tasks)。**用户自助改密**:登录后顶栏「改密码」按钮(走 `POST /v1/auth/change_password`,需知道旧密码);改邮箱 / 用户忘了旧密码无法自助 → 手动 SQL(见故障兜底)。

View File

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

View File

@ -15,7 +15,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional
from uuid import UUID
from sqlalchemy import delete, select
from sqlalchemy import delete, func, select
from .storage import session_scope
from .storage.models import Message, Task
@ -116,17 +116,30 @@ class Session:
task_id DB 不存在,返回空 Session(messages 只含 system,_db_idx=0);
调用方判断该不该报错
只把 idx >= tasks.context_base_idx 的消息装进 LLM 上下文(channel 长会话软重置,
0019)base 之前的历史仍全量留 messages (web `/messages` gate,照旧翻得到)
**关键**:`_db_idx` 必须取 DB 真实总条数(下一条 append idx),不能用 len(rows)
否则下次 append 会复用已存在的 idx, uq_messages_task_idx / 覆盖历史
"""
sess = cls(task_id=task_id, system_prompt=system_prompt, meta=meta)
with session_scope() as s:
base = s.execute(
select(Task.context_base_idx).where(Task.task_id == task_id)
).scalar_one_or_none() or 0
rows = s.execute(
select(Message)
.where(Message.task_id == task_id)
.where(Message.task_id == task_id, Message.idx >= base)
.order_by(Message.idx)
).scalars().all()
for row in rows:
sess.messages.append(dict(row.payload))
sess._db_idx = len(rows)
# 真实总条数(含 base 之前的归档历史),保证 append 续号不撞 idx。
sess._db_idx = s.execute(
select(func.count())
.select_from(Message)
.where(Message.task_id == task_id)
).scalar_one()
return sess
@classmethod

View File

@ -85,6 +85,12 @@ class Task(Base):
# 只有 error 是持久终态(下次起新 run 时由 post_message 清掉)
run_status: Mapped[str] = mapped_column(Text, nullable=False, default="idle")
run_error: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# 喂给模型的上下文窗口起点(0019,channel 长会话软重置)。Session.load 只把 idx >=
# context_base_idx 的消息装进 LLM 上下文;之前的历史仍全量留 messages 表(web 翻得到)。
# web 普通任务恒 0 = 喂全量;channel 入站按 gap / 「新话题」推进。详 DESIGN §8.7。
context_base_idx: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0"
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)

View File

@ -17,10 +17,10 @@ from datetime import datetime, timedelta, timezone
from typing import Optional
from uuid import UUID
from sqlalchemy import select
from sqlalchemy import func, select, update
from core.storage import session_scope
from core.storage.models import ChannelBinding
from core.storage.models import ChannelBinding, Message, Task
from core.wechat import crypto
from core.wechat.ilink import DEFAULT_BASE, ILinkClient
@ -359,6 +359,62 @@ def ensure_channel_chat_task(uid: UUID, channel: str) -> Optional[UUID]:
return tid
# ─────────────────────── channel 长会话上下文软重置(0019) ───────────────────────
# gap 默认值:超过它未说话 → 入站时软重置(保留上一轮原文做续聊锚点)。可被
# config.json 的 channel.session_gap_hours 覆盖(见 reload 入口)。
SESSION_GAP_HOURS_DEFAULT = 6.0
# 用户在 channel 里发这些词 → 手动「新话题」硬重置(base 推到总数,彻底从零)。
NEW_TOPIC_COMMANDS = frozenset({"新话题", "新会话", "/new", "清空上下文"})
def reset_channel_context(task_id: UUID, *, hard: bool) -> int:
"""推进 task 的 context_base_idx(软重置),返回新 base。不删任何消息。
hard=True(手动新话题):base = 总消息数 下一条入站起彻底新会话
hard=False(自动 gap):base = 最后一条 user 消息 idx 新窗口仍带上上一轮原文,
续聊接得上; user 消息(理论上不会)退化为总数
"""
with session_scope() as s:
total = s.execute(
select(func.count()).select_from(Message).where(Message.task_id == task_id)
).scalar_one()
if hard:
new_base = int(total)
else:
last_user_idx = s.execute(
select(func.max(Message.idx)).where(
Message.task_id == task_id,
Message.payload["role"].astext == "user",
)
).scalar_one_or_none()
new_base = int(last_user_idx) if last_user_idx is not None else int(total)
s.execute(
update(Task).where(Task.task_id == task_id).values(context_base_idx=new_base)
)
return new_base
def maybe_gap_reset(task_id: UUID, gap_hours: float = SESSION_GAP_HOURS_DEFAULT) -> bool:
"""入站时检测:距上次消息超过 gap_hours → 软重置(保留上一轮)。返回是否重置。
仅入站对话调用(push 记录不触发)gap_hours <= 0 视为关闭自动分段
"""
if gap_hours <= 0:
return False
with session_scope() as s:
last_at = s.execute(
select(func.max(Message.created_at)).where(Message.task_id == task_id)
).scalar_one_or_none()
if last_at is None:
return False # 空 task,首条入站,无需重置
if (datetime.now(timezone.utc) - last_at) <= timedelta(hours=gap_hours):
return False
reset_channel_context(task_id, hard=False)
return True
def _file_rel_to_user_root(user_id: UUID, file_path: str) -> Optional[str]:
"""宿主绝对路径 → user_root 相对 POSIX(如 scheduled-<jobid>/x.md)。
文件不在 user_root (外部 --working-dir) None"""

View File

@ -0,0 +1,40 @@
"""tasks.context_base_idx 列(channel 长会话软重置,DESIGN §8.7).
Revision ID: 0019
Revises: 0018
Create Date: 2026-06-29
tasks context_base_idx(NOT NULL DEFAULT 0):喂给模型的上下文窗口起点
Session.load 只把 idx >= context_base_idx 的消息装进 LLM 上下文;idx < base 的历史
仍全量留在 messages (web `/messages` 直查不受影响,用户照旧翻完整历史)
channel 入站对话据此做软重置:超过 gap 阈值未说话 base 推到最后一条 user 消息
idx(保留上一轮原文做续聊锚点);手动新话题 base 推到总消息数(彻底从零)
存量行 / web 普通任务 base 0 = 喂全量,行为不变additive,无数据迁移
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0019"
down_revision: Union[str, None] = "0018"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"tasks",
sa.Column(
"context_base_idx",
sa.Integer(),
nullable=False,
server_default="0",
),
)
def downgrade() -> None:
op.drop_column("tasks", "context_base_idx")

View File

@ -489,6 +489,22 @@ async def _run_channel_conversation(app, uid, text, attachments, *, channel):
if tid is None:
return ""
# 手动「新话题」命令:硬重置上下文窗口(base=总数),不跑 agent,直接回执。之前的
# 对话全留 DB(网页端可翻),只是不再喂模型。纯文本命令,有附件则不当命令处理。
if not attachments and text.strip() in _wx.NEW_TOPIC_COMMANDS:
await asyncio.to_thread(_wx.reset_channel_context, tid, hard=True)
return "已开启新话题,之前的对话已归档(网页端仍可查看完整历史)。"
# 自动分段:距上次消息超过 gap 阈值 → 软重置(base=最后一条 user 消息 idx,保留上一轮
# 原文做续聊锚点)。在入站消息落库前判断,故 last_at 取的是上一轮的时间。push 不走这。
from core.agent_builder import load_config as _load_config
gap_hours = float(
(_load_config().get("channel") or {}).get(
"session_gap_hours", _wx.SESSION_GAP_HOURS_DEFAULT
)
)
await asyncio.to_thread(_wx.maybe_gap_reset, tid, gap_hours)
# 落盘入站附件到 <wd>/inbound/,拼 [用户上传的...] 行进 text(复用 web 端粘贴图约定)
if attachments:
from datetime import datetime
@ -2449,6 +2465,8 @@ def create_app() -> FastAPI:
cost_cny=0,
run_status="idle",
run_error=None,
# 全删后 idx 从 0 重起,base 必须归零否则 load 窗口起点悬空(0019)
context_base_idx=0,
)
)
task_row = s.execute(select(Task).where(Task.task_id == tid)).scalar_one()