fix: stabilize native streaming and improve bot controls

This commit is contained in:
Hyu
2026-10-10 18:02:42 +08:00
parent e8da99be63
commit a2b7f6b7bb
32 changed files with 583 additions and 55 deletions
+3 -7
View File
@@ -2123,7 +2123,7 @@ class WecomBotClient:
Args:
msg_id: 原始企业微信消息 ID。
content: 模型产生的片段内容。
content: 流水线传入的累计正文快照。
is_final: 是否为最终片段。
Returns:
@@ -2138,12 +2138,8 @@ class WecomBotClient:
return False
previous_content = self._stream_last_content.get(msg_id, '')
if previous_content and content.startswith(previous_content):
next_content = content
elif previous_content and not content:
next_content = previous_content
else:
next_content = previous_content + content if previous_content else content
# Pipeline delivery provides a snapshot, not a provider delta.
next_content = content or previous_content
if len(next_content) > _MAX_STREAM_CONTENT_CHARS:
next_content = next_content[-_MAX_STREAM_CONTENT_CHARS:]
+11 -6
View File
@@ -11,6 +11,7 @@ Official Node.js SDK: https://github.com/WecomTeam/aibot-node-sdk
from __future__ import annotations
import asyncio
import hashlib
import json
import secrets
import time
@@ -134,6 +135,9 @@ class WecomBotWsClient:
self._stream_ids: dict[str, str] = {} # msg_id -> req_id|stream_id
# Dedup: skip sending when content hasn't changed
self._stream_last_content: dict[str, str] = {} # msg_id -> last content sent
# Preserve completion identity after removing active stream mappings.
# A runner may emit both a final delta and a completed message snapshot.
self._completed_stream_content: dict[str, str] = {}
# Stream session info for feedback tracking
self._stream_sessions: dict[str, dict] = {} # msg_id -> session info
# Feedback tracking: feedback_id -> session info
@@ -661,16 +665,15 @@ class WecomBotWsClient:
"""
key = self._stream_ids.get(msg_id)
if not key:
if is_final and self._completed_stream_content.get(msg_id) == hashlib.sha256(content.encode()).hexdigest():
return True
return False
req_id, stream_id = key.split('|', 1)
try:
previous_content = self._stream_last_content.get(msg_id, '')
if previous_content and content.startswith(previous_content):
next_content = content
elif previous_content and not content:
next_content = previous_content
else:
next_content = previous_content + content if previous_content else content
# Pipeline delivery provides complete snapshots, including edits
# that need not start with the previous text (e.g. reasoning tags).
next_content = content or previous_content
if len(next_content) > _MAX_STREAM_CONTENT_CHARS:
next_content = next_content[-_MAX_STREAM_CONTENT_CHARS:]
@@ -720,6 +723,8 @@ class WecomBotWsClient:
await self.reply_stream(req_id, stream_id, next_content, finish=is_final, feedback_id=feedback_id)
self._stream_last_content[msg_id] = next_content
if is_final:
self._completed_stream_content[msg_id] = hashlib.sha256(next_content.encode()).hexdigest()
self._cap_mapping(self._completed_stream_content, _STREAM_CACHE_MAX)
self._stream_ids.pop(msg_id, None)
self._stream_last_content.pop(msg_id, None)
self._stream_sessions.pop(msg_id, None)
+4
View File
@@ -811,6 +811,10 @@ class BoxService:
to the sandbox (and vice-versa). It is ``None`` / not a local dir for
E2B and remote runtimes, where we must fall back to the exec channel.
"""
# secure_fs relies on POSIX openat/O_NOFOLLOW. On Windows use the
# sandbox execution channel rather than misclassifying EACCES as a link.
if os.open not in os.supports_dir_fd or not hasattr(os, 'O_NOFOLLOW'):
return None
root = self._tenant_workspace(self._query_execution_context(query))
if not root or not os.path.isdir(root) or os.path.islink(root):
return None
@@ -6,6 +6,7 @@ import traceback
from .. import handler
from ..stream_results import coalesce_stream_results
from ... import entities
from ... import plugin_diagnostics
@@ -132,7 +133,10 @@ class ChatMessageHandler(handler.MessageHandler):
# Use AgentRunOrchestrator to run the agent
# This replaces direct runner lookup and PluginRunnerWrapper
async for result in self.ap.agent_run_orchestrator.run_from_query(query):
results = self.ap.agent_run_orchestrator.run_from_query(query)
if is_stream:
results = coalesce_stream_results(results)
async for result in results:
has_result = True
self._check_response_size(result)
@@ -149,6 +153,11 @@ class ChatMessageHandler(handler.MessageHandler):
)
result.attachments = attachments
if is_stream and result.tool_calls:
# A model round ending in tool calls does not end the
# reply. Keep the native stream open for the answer.
result = result.model_copy(update={'is_final': False})
result.resp_message_id = str(resp_message_id)
# For streaming mode, pop previous response before adding new chunk
@@ -192,6 +201,11 @@ class ChatMessageHandler(handler.MessageHandler):
if is_stream:
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
if result.is_final and not result.tool_calls:
# Native adapters release their card on completion.
# A distinct later message needs a fresh card identity.
is_create_card = False
resp_message_id = uuid.uuid4()
# Log final summary after streaming completes
if is_stream:
@@ -0,0 +1,44 @@
"""Coalesce a runner's terminal delta and completed snapshot before delivery."""
from collections.abc import AsyncIterable, AsyncIterator
from langbot_plugin.api.entities.builtin.provider.message import Message, MessageChunk
async def coalesce_stream_results(
results: AsyncIterable[Message | MessageChunk],
) -> AsyncIterator[Message | MessageChunk]:
pending: MessageChunk | None = None
snapshot = ''
async for result in results:
if pending is not None:
# Completion snapshots can add attachments and usage to the final
# delta. Deliver that richer result once, before closing the card.
content = pending.all_content if pending.all_content is not None else pending.content
same_message = (
isinstance(result, Message)
and result.role == pending.role
and result.tool_calls == pending.tool_calls
and (not content or result.content == content)
)
if not same_message:
yield pending
pending = None
snapshot = ''
if isinstance(result, MessageChunk) and isinstance(result.content, (str, type(None))):
# Runner output is already cumulative (unlike raw provider deltas).
# Explicit all_content also supports runners forwarding raw deltas.
# Never guess delta semantics from a missing all_content field.
snapshot = result.all_content if result.all_content is not None else (result.content or snapshot)
result = result.model_copy(update={'all_content': snapshot})
elif isinstance(result, Message):
snapshot = ''
if isinstance(result, MessageChunk) and result.is_final:
pending = result
else:
yield result
if pending is not None:
yield pending
@@ -8,9 +8,9 @@ metadata:
zh_Hans: 企业微信
zh_Hant: 企業微信
description:
en_US: WeCom application message adapter with event-driven orchestration support
zh_Hans: 企业微信内部应用消息适配器,支持事件驱动编排
zh_Hant: 企業微信內部應用訊息適配器,支援事件驅動編排
en_US: Serve employees through a custom WeCom app
zh_Hans: 通过企业自建应用,为内部员工提供服务
zh_Hant: 透過企業自建應用,為內部員工提供服務
icon: wecom.png
spec:
@@ -148,6 +148,16 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
raw = await self.bot.send_message(str(target_id), content)
return platform_events.MessageResult(raw={'result': raw})
async def reply_to_event(
self, *, context: dict, target_type: str, target_id: str, message: platform_message.MessageChain
) -> platform_events.MessageResult:
"""Bind Agent replies to the originating callback and its loading stream."""
message_id = ((context.get('delivery') or {}).get('reply_target') or {}).get('message_id')
if context.get('event_type') == 'message.received' and message_id:
source = await self.get_message(target_type, target_id, message_id)
return await self.reply_message(source, message)
return await self.send_message(target_type, target_id, message)
async def reply_message(
self,
message_source: platform_events.MessageEvent,
@@ -160,9 +170,14 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
items = await WecomBotMessageConverter.yiri2target(message)
content = self._join_text_components(items)
raw = None
from .stream_text import format_stream_text
content = format_stream_text(content)
if not self.config.get('enable-webhook', False) and event.get('req_id'):
if content:
raw = await self.bot.reply_text(event.get('req_id'), content)
finished = await self.bot.push_stream_chunk(event.message_id, content, is_final=True)
if not finished:
raw = await self.bot.reply_text(event.get('req_id'), content)
for item in self._iter_media_components(items):
await self._send_media(self.bot, event.get('req_id'), item)
else:
@@ -181,7 +196,9 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
if not isinstance(event, WecomBotEvent):
raise ValueError('WeComBot reply_message_chunk requires a WecomBotEvent source object')
items = await WecomBotMessageConverter.yiri2target(message)
content = self._join_text_components(items)
from .stream_text import format_stream_text
content = format_stream_text(self._join_text_components(items))
success = await self.bot.push_stream_chunk(event.message_id, content, is_final=is_final)
if not success and is_final and not self.config.get('enable-webhook', False) and event.get('req_id'):
await self.bot.reply_text(event.get('req_id'), content)
@@ -8,9 +8,9 @@ metadata:
zh_Hans: 企业微信智能机器人
zh_Hant: 企業微信智慧機器人
description:
en_US: WeCom AI Bot adapter with event-driven orchestration support
zh_Hans: 企业微信智能机器人适配器,支持事件驱动编排、长连接和 Webhook 两种接入方式
zh_Hant: 企業微信智慧機器人適配器,支援事件驅動編排、長連線和 Webhook 兩種接入方式
en_US: Answer questions and assist teams in WeCom chats and groups
zh_Hans: 在企业微信单聊和群聊中提供智能问答与协作
zh_Hant: 在企業微信單聊和群聊中提供智慧問答與協作
icon: wecombot.png
spec:
@@ -27,10 +27,9 @@ spec:
zh_Hans: 一键创建机器人
zh_Hant: 一鍵建立機器人
description:
en_US: 'Scan QR code with WeCom to automatically create a bot and fill in BotId and Secret. Note: Robot Name needs to
be filled in manually.'
zh_Hans: 使用企业微信扫码自动创建机器人并填写 BotId 和 Secret。注意:机器人名称需手动填写。
zh_Hant: 使用企業微信掃碼自動建立機器人並填寫 BotId 和 Secret。注意:機器人名稱需手動填寫。
en_US: Scan with WeCom to create a bot and fill in BotId and Secret automatically.
zh_Hans: 使用企业微信扫码自动创建机器人并填写 BotId 和 Secret。
zh_Hant: 使用企業微信掃碼自動建立機器人並填寫 BotId 和 Secret。
type: qr-code-login
login_platform: wecombot
required: false
@@ -60,8 +59,12 @@ spec:
en_US: Robot Name
zh_Hans: 机器人名称
zh_Hant: 機器人名稱
description:
en_US: Optional. Only removes matching @name text in group messages; bot mentions are identified by BotId.
zh_Hans: 可选。仅用于清理群消息中的 @名称 文本,机器人提及通过 BotId 识别。
zh_Hant: 選填。僅用於清理群訊息中的 @名稱 文字,機器人提及透過 BotId 識別。
type: string
required: true
required: false
default: ""
- name: enable-webhook
label:
@@ -103,6 +106,10 @@ spec:
type: string
required: false
default: ""
show_if:
field: enable-webhook
operator: neq
value: true
- name: Corpid
label:
en_US: Corpid
@@ -115,6 +122,10 @@ spec:
type: string
required: false
default: ""
show_if:
field: enable-webhook
operator: eq
value: true
- name: Token
label:
en_US: Token
@@ -127,18 +138,26 @@ spec:
type: string
required: false
default: ""
show_if:
field: enable-webhook
operator: eq
value: true
- name: EncodingAESKey
label:
en_US: EncodingAESKey
zh_Hans: 消息加解密密钥 (EncodingAESKey)
zh_Hant: 訊息加解密密鑰 (EncodingAESKey)
description:
en_US: Required for webhook mode. Optional for WebSocket mode when encrypted files need to be decrypted.
zh_Hans: Webhook 模式必填。WebSocket 模式下如需解密文件则填写。
zh_Hant: Webhook 模式必填。WebSocket 模式下如需解密檔案則填寫。
en_US: Required for webhook mode. Long connections use the decryption key included in each message.
zh_Hans: Webhook 模式必填。长连接模式使用消息自带的解密密钥。
zh_Hant: Webhook 模式必填。長連線模式使用訊息自帶的解密金鑰。
type: string
required: false
default: ""
show_if:
field: enable-webhook
operator: eq
value: true
- name: enable-stream-reply
label:
en_US: Enable Stream Reply
@@ -1,6 +1,7 @@
from __future__ import annotations
import datetime
import re
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
@@ -46,12 +47,14 @@ class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverte
platform_message.Source(id=event.message_id, time=datetime.datetime.now()),
]
if event.type == 'group' and event.ai_bot_id:
# Group callbacks identify the mentioned bot by ID, independently
# of its display name or the optional text-cleanup configuration.
components.append(platform_message.At(target=event.ai_bot_id))
if event.content:
content = event.content
if bot_name:
content = content.replace(f'@{bot_name}', '').strip()
if event.type == 'group' and bot_name:
content = re.sub(rf'(?<!\S)@{re.escape(bot_name)}(?=\s|$)', '', content).strip()
if content:
components.append(platform_message.Plain(text=content))
@@ -0,0 +1,31 @@
"""Render multi-round reasoning as one WeCom thinking section."""
import re
def format_stream_text(content: str) -> str:
if content.strip().lower() in {'<', '<t', '<th', '<thi', '<thin', '<think'}:
return ''
if '<think' not in content.lower() and '</think' not in content.lower():
return content
# Every invocation receives the full snapshot, so split tags may simply
# wait for the next snapshot instead of leaking into the visible answer.
content = re.sub(r'<(?:/?t(?:h(?:i(?:n(?:k)?)?)?)?|/?)$', '', content, flags=re.IGNORECASE)
tokens = re.split(r'(</?think\s*>)', content, flags=re.IGNORECASE)
thoughts: list[str] = []
answers: list[str] = []
thinking = False
for token in tokens:
if re.fullmatch(r'<think\s*>', token, flags=re.IGNORECASE):
thinking = True
elif re.fullmatch(r'</think\s*>', token, flags=re.IGNORECASE):
thinking = False
elif token.strip():
(thoughts if thinking else answers).append(token.strip())
if not thoughts:
return '\n\n'.join(answers)
reasoning = '\n\n'.join(thoughts)
answer = '\n\n'.join(answers)
if thinking and not answer:
return f'<think>\n{reasoning}'
return f'<think>\n{reasoning}\n</think>' + (f'\n\n{answer}' if answer else '')
@@ -8,9 +8,9 @@ metadata:
zh_Hans: 企业微信客服
zh_Hant: 企業微信客服
description:
en_US: WeCom Customer Service adapter with event-driven orchestration support
zh_Hans: 企业微信客服适配器,支持事件驱动编排,通过统一 Webhook 接收客服会话消息
zh_Hant: 企業微信客服適配器,支援事件驅動編排,透過統一 Webhook 接收客服會話訊息
en_US: Support customers on WeChat through WeChat Customer Service
zh_Hans: 通过微信客服,为微信用户提供咨询与售后服务
zh_Hant: 透過微信客服,為微信使用者提供諮詢與售後服務
icon: wecom.png
spec: