zcbot/web/runs.py

212 lines
10 KiB
Python

"""BG run worker + 渠道入站对话核心(从 app.py 析出,2026-07-23 拆分)。
`run_agent_bg` 是「一次 run」的工作线程本体(build_agent → agent.run → 写终态),
web 路由与渠道回调都经它起 run;`run_channel_conversation` 是微信/企微共用的
入站对话编排(§8.7)。依赖 web 层 broker/sink 桥事件,故住 web/ 不下沉 core。
"""
from __future__ import annotations
import asyncio
from uuid import UUID
from sqlalchemy import select, update
from core.paths import from_db_path
from core.storage import session_scope
from core.storage.models import Task
from core.storage.telemetry import record_run_error
from core.toolfail import alert_provider_critical
from .broker import broker
from .common import INSTANCE
from .sinks import WebEventSink
def run_agent_bg(
task_id: UUID, user_id: UUID, user_message: str,
image_variant: str = "", video_variant: str = "",
scheduled: bool = False,
) -> None:
"""工作线程:`build_agent(resume=True)` → 装 WebEventSink + cancel_check → `agent.run` → 写 tasks.run_status。
sink 通过 broker.emit 桥事件回 asyncio loop;agent.run 是 sync,所以在 to_thread 跑。
user_id 必须从 JWT 那侧透传过来 —— 决定 memory_block 读哪个 per-user 子树。
cancel_check 桥 broker.is_cancelled,loop 在 stream chunk 间 + 工具调用之间 poll;
cancel 延迟 ~ 单 chunk 间隔(100ms 级);seedance 轮询间也读这个 cancel_check 用于
用户停止按钮(必须在 build_agent 阶段就传进去,因为 SeedanceTool ctor 持有它,
不能像以前那样 build_agent 返回后再赋 agent.cancel_check)。
`ok` 收尾回 `idle`;`cancelled`(用户停止)与 `error` 一样落持久终态 —— 前端据此
补「已停止」/ 错误卡(扛过收尾重渲),下次起新 run(post_message 写 running)覆盖清掉。
image_variant / video_variant:本 run 用哪个 image/video variant 装 tool(空 → yaml 第一个)。
随消息 POST 传进来,不入 DB —— UI 下拉的选择就跟在这一条消息上生效。
"""
from core.agent_builder import build_agent, sync_task_tokens
cancel_check = lambda tid=task_id: broker.is_cancelled(tid)
try:
broker.emit(task_id, {"type": "run_start"})
agent, session, sid, task_state, task_dir = build_agent(
session_id=str(task_id), resume=True, user_id=user_id,
image_variant=image_variant,
video_variant=video_variant,
cancel_check=cancel_check,
scheduled_run=scheduled,
)
agent.sink = WebEventSink(broker, task_id)
result = agent.run(user_message)
sync_task_tokens(task_state)
# 收尾终态:agent.run 在任一取消路径都 return "[cancelled]"(loop.py)——
# 用户停止 → 落持久 cancelled(前端 renderPersistedRunTerminal 据此补「已停止」卡,
# 扛过收尾 loadMessages 整屏重建);正常完成 → 回 idle。两者都清 run_error。
# cancelled 与 error 同为持久终态,下次起新 run(post_message 写 running)自然覆盖。
final_status = "cancelled" if result == "[cancelled]" else "idle"
with session_scope() as s:
s.execute(
update(Task).where(Task.task_id == task_id).values(
run_status=final_status, run_error=None,
)
)
except Exception as e:
err = f"{type(e).__name__}: {e}"
broker.emit(task_id, {"type": "error", "msg": err})
mp = ""
try:
with session_scope() as s:
mp = s.execute(
select(Task.model_profile).where(Task.task_id == task_id)
).scalar_one_or_none() or ""
s.execute(
update(Task).where(Task.task_id == task_id).values(
run_status="error", run_error=err,
)
)
except Exception:
pass # 已 emit error 给前端,DB 写失败不放大噪声
# 留痕 + 告警(0.58.21,反 2a1bc25d 教训:Zai 余额不足连挂 3 次续跑无人知):
# run_error 列只留最后一次,usage_events(kind=run_error)才是聚合面板/巡检
# 邮件的完整数据源;余额/认证类 provider 级错误另走即时邮件(6h 签名冷却)。
# 两路都静默失败 —— 留痕/告警绝不能在错误路径上再抛。
try:
record_run_error(
task_id=task_id, user_id=user_id, model_profile=mp, error=err,
)
except Exception:
pass
alert_provider_critical(err, task_id=task_id, model_profile=mp)
finally:
broker.clear_cancel(task_id)
broker.close(task_id)
async def run_channel_conversation(app, uid, text, attachments, *, channel):
"""渠道无关的入站对话核心(§8.7):解析/建该用户该渠道常驻 task → 落盘附件 → 抢 run 锁
→ run_agent_bg → 取 assistant 回复文本。两渠道各一张会话 task,互不串扰。
channel:'wechat'(个人微信 ClawBot,绑定快照取 chat_task_id)| 'wecom'(企业微信,
wecom 绑定行取 chat_task_id)。attachments:已下载解密的入站附件(可空,wecom 暂只收文本)。
返回回复文本(供 ClawBot 回流 / wecom 主动推回)。
"""
from core.wechat import service as _wx
from core.wechat.ilink import attachment_basename
from core.wechat.inbound import extract_last_assistant_text
# 解析/建该渠道常驻 chat task(不存在自动建)—— 与 push 记录(send_to_user)共用
# ensure_channel_chat_task,避免两条建 task 路径漂移。wechat 无 binding → 返回 None。
tid = await asyncio.to_thread(_wx.ensure_channel_chat_task, uid, 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 "已开启新话题,之前的对话已归档(网页端仍可查看完整历史)。"
# 快捷指令展开(渠道无关,见 core/shortcuts.py):整条精确命中触发词 → 文本换成完整指令
# 再照常跑;不进上下文、不问模型。放「新话题」命令之后、附件/gap 之前:展开后的文本仍会
# 被下面的附件行追加,故打「简报」+ 附图也成立。
from core.agent_builder import resolve_workspace as _resolve_ws
from core import shortcuts as _shortcuts
_ws = await asyncio.to_thread(_resolve_ws, None)
text, _hit = await asyncio.to_thread(_shortcuts.expand, _ws, uid, text)
if _hit:
print(f"[shortcut] {str(uid)[:8]} '{_hit}' expanded")
# 自动分段:距上次消息超过 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
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 锁:正忙 → 提示稍候(同用户串行;ClawBot loop 本就串行,wecom 回调靠此挡并发)
with session_scope() as s:
row = s.execute(
select(Task.run_status).where(Task.task_id == tid).with_for_update()
).first()
if row is None:
return "[出错] 对话 task 不存在"
if row.run_status in ("running", "cancelling"):
return "上一条还在处理中,请稍候再发。"
s.execute(update(Task).where(Task.task_id == tid).values(
run_status="running", run_error=None, run_owner=INSTANCE or None))
broker.start(tid)
runner = asyncio.create_task(asyncio.to_thread(
run_agent_bg, tid, uid, text, "", "", False,
))
app.state.inflight[runner] = tid
runner.add_done_callback(lambda t: app.state.inflight.pop(t, None))
await runner
with session_scope() as s:
st = s.execute(
select(Task.run_status, Task.run_error).where(Task.task_id == tid)
).first()
if st is not None and st.run_status == "error":
return f"[出错] {st.run_error}"
reply = await asyncio.to_thread(extract_last_assistant_text, tid)
return reply or "(本轮无文本回复)"
async def transcribe_wecom_voice(media_id: str) -> str:
"""企业微信入站语音 → 文本:media/get 下 AMR → `core.audio.transcribe_voice`
(ffmpeg 解 16k PCM → 讯飞 IAT 整段转写,60s 截断在里面)。
返回转写文本(空串=没听出内容)。失败抛异常,message 可直接给用户看
(XfyunASRNotConfigured / AudioNotConfigured 自带配置指引)。
"""
from core import audio
from core.wechat import wecom
data, _fname = await asyncio.to_thread(wecom.download_media, media_id)
return await audio.transcribe_voice(data)