mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-22 00:15:20 +08:00
fix: stabilize native streaming and improve bot controls
This commit is contained in:
@@ -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 +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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user