mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-21 07:55:18 +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:
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.box.service import BoxService
|
||||
from langbot_plugin.box.client import BoxRuntimeClient
|
||||
from tests.unit_tests.box.test_box_service import make_app, make_query
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_host_without_openat_uses_sandbox_attachment_channel(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr('os.supports_dir_fd', set())
|
||||
service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient))
|
||||
service._available = True
|
||||
service._tenant_workspace = Mock(return_value=str(tmp_path))
|
||||
service._read_outbox_host = Mock(side_effect=AssertionError('POSIX host reader must not run'))
|
||||
service._read_outbox_via_exec = AsyncMock(return_value=[])
|
||||
service._clear_outbox = AsyncMock()
|
||||
query = make_query()
|
||||
assert service._host_query_dir(service.OUTBOX_SUBDIR, query) is None
|
||||
assert await service.collect_outbound_attachments(query) == []
|
||||
service._read_outbox_via_exec.assert_awaited_once_with(query)
|
||||
service._clear_outbox.assert_awaited_once_with(query, None)
|
||||
@@ -263,6 +263,57 @@ class TestChatMessageHandlerReal:
|
||||
class TestChatHandlerStreaming:
|
||||
"""Tests for streaming behavior."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('with_tool_call', [False, True])
|
||||
async def test_terminal_delta_and_snapshot_close_card_once(
|
||||
self, fake_app, mock_event_ctx, set_runner, with_tool_call
|
||||
):
|
||||
from tests.factories import text_query
|
||||
from langbot_plugin.api.entities.builtin.provider.message import Message, MessageChunk
|
||||
from langbot_plugin.api.entities.builtin.platform.message import File, MessageChain
|
||||
|
||||
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
query = text_query('stream test')
|
||||
query.adapter = Mock()
|
||||
query.adapter.is_stream_output_supported = AsyncMock(return_value=True)
|
||||
query.adapter.create_message_card = AsyncMock()
|
||||
attachments = MessageChain([File(url='https://example.com/file.txt', name='file.txt')])
|
||||
|
||||
class StreamRunner:
|
||||
def __init__(self, app, config):
|
||||
pass
|
||||
|
||||
async def run(self, query):
|
||||
if with_tool_call:
|
||||
yield MessageChunk(
|
||||
role='assistant',
|
||||
is_final=True,
|
||||
tool_calls=[
|
||||
{'id': 'call-1', 'type': 'function', 'function': {'name': 'search', 'arguments': '{}'}}
|
||||
],
|
||||
)
|
||||
yield MessageChunk(role='assistant', content='hello')
|
||||
yield MessageChunk(role='assistant', content='hello world', is_final=True)
|
||||
completed = Message(role='assistant', content='hello world')
|
||||
completed.attachments = attachments
|
||||
yield completed
|
||||
|
||||
set_runner(StreamRunner)
|
||||
handler = get_chat_handler().ChatMessageHandler(fake_app)
|
||||
handler._ensure_conversation_for_history = AsyncMock()
|
||||
delivered = []
|
||||
async for stage_result in handler.handle(query):
|
||||
assert stage_result.result_type == get_entities().ResultType.CONTINUE
|
||||
delivered.append(query.resp_messages[-1])
|
||||
assert [result.content for result in delivered if result.content] == ['hello', 'hello world']
|
||||
if with_tool_call:
|
||||
assert delivered[0].tool_calls
|
||||
assert not delivered[0].is_final
|
||||
assert sum(result.is_final for result in delivered) == 1
|
||||
assert delivered[-1].attachments is attachments
|
||||
assert len({result.resp_message_id for result in delivered}) == 1
|
||||
query.adapter.create_message_card.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streaming_chunks_collected(self, fake_app, mock_event_ctx, set_runner):
|
||||
"""Streaming produces multiple results."""
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import pytest
|
||||
|
||||
from langbot_plugin.api.entities.builtin.provider.message import Message, MessageChunk
|
||||
from langbot_plugin.api.entities.builtin.platform.message import File, MessageChain
|
||||
from langbot.pkg.pipeline.process.stream_results import coalesce_stream_results
|
||||
|
||||
|
||||
async def source(*items):
|
||||
for item in items:
|
||||
yield item
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_terminal_snapshot_is_delivered_once_with_attachments():
|
||||
text = '<think>reasoning</think>answer'
|
||||
completed = Message(role='assistant', content=text)
|
||||
completed.attachments = MessageChain([File(url='https://example.com/report.txt', name='report.txt')])
|
||||
results = [
|
||||
item
|
||||
async for item in coalesce_stream_results(
|
||||
source(
|
||||
MessageChunk(role='assistant', content='answer', all_content=text, is_final=True),
|
||||
completed,
|
||||
)
|
||||
)
|
||||
]
|
||||
assert results == [completed]
|
||||
assert results[0].attachments is completed.attachments
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('with_snapshot', [False, True])
|
||||
async def test_progressive_chunks_are_not_buffered(with_snapshot):
|
||||
first = MessageChunk(role='assistant', content='hel', all_content='hel')
|
||||
final = MessageChunk(role='assistant', content='lo', all_content='hello', is_final=True)
|
||||
completed = Message(role='assistant', content='hello')
|
||||
stream = coalesce_stream_results(source(first, final, *([completed] if with_snapshot else [])))
|
||||
assert await anext(stream) == first
|
||||
assert [item async for item in stream] == [completed if with_snapshot else final]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_completed_only_and_distinct_messages_are_preserved():
|
||||
final = MessageChunk(role='assistant', content='first', is_final=True)
|
||||
other = Message(role='assistant', content='second')
|
||||
results = [item async for item in coalesce_stream_results(source(final, other))]
|
||||
assert [item.content for item in results] == ['first', 'second']
|
||||
assert [item async for item in coalesce_stream_results(source(other))] == [other]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blank_terminal_delta_uses_completed_snapshot():
|
||||
completed = Message(role='assistant', content='answer')
|
||||
results = [
|
||||
item
|
||||
async for item in coalesce_stream_results(
|
||||
source(
|
||||
MessageChunk(role='assistant', is_final=True),
|
||||
completed,
|
||||
)
|
||||
)
|
||||
]
|
||||
assert results == [completed]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failures_propagate_without_successful_final():
|
||||
async def failing():
|
||||
yield MessageChunk(role='assistant', content='partial')
|
||||
raise RuntimeError('provider failed')
|
||||
|
||||
stream = coalesce_stream_results(failing())
|
||||
assert (await anext(stream)).content == 'partial'
|
||||
with pytest.raises(RuntimeError, match='provider failed'):
|
||||
await anext(stream)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cumulative_stream_ends_with_last_snapshot_even_when_final_is_blank():
|
||||
results = [
|
||||
item
|
||||
async for item in coalesce_stream_results(
|
||||
source(
|
||||
MessageChunk(role='assistant', content='hel'),
|
||||
MessageChunk(role='assistant', content='hello'),
|
||||
MessageChunk(role='assistant', is_final=True),
|
||||
)
|
||||
)
|
||||
]
|
||||
assert [item.all_content for item in results] == ['hel', 'hello', 'hello']
|
||||
assert results[-1].is_final
|
||||
@@ -301,6 +301,7 @@ async def test_lark_event_converter_maps_group_and_private_message():
|
||||
@pytest.mark.asyncio
|
||||
async def test_lark_adapter_dispatches_and_caches_message_event():
|
||||
adapter = make_adapter()
|
||||
adapter._get_bot_open_id = AsyncMock(return_value='bot-open-id')
|
||||
calls: list[platform_events.Event] = []
|
||||
|
||||
async def listener(event, adapter):
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Exercise terminal runner snapshots against native adapter delivery methods."""
|
||||
|
||||
import datetime
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
import telegram
|
||||
|
||||
from langbot_plugin.api.entities.builtin.provider.message import Message, MessageChunk
|
||||
from langbot_plugin.api.entities.builtin.platform.message import MessageChain, Plain
|
||||
from langbot.pkg.pipeline.process.stream_results import coalesce_stream_results
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('platform', ['lark', 'dingtalk', 'telegram', 'discord', 'qqofficial', 'wecombot'])
|
||||
async def test_native_stream_receives_one_terminal_snapshot(platform):
|
||||
module = __import__(f'tests.unit_tests.platform.test_{platform}_eba_adapter', fromlist=['make_adapter'])
|
||||
adapter = module.make_adapter()
|
||||
object.__setattr__(adapter, 'reply_message', AsyncMock())
|
||||
source = SimpleNamespace()
|
||||
if platform == 'lark':
|
||||
adapter.card_id_dict['response'] = 'card'
|
||||
adapter.card_sequence_dict['card'] = 0
|
||||
adapter._replace_streaming_card = AsyncMock()
|
||||
source.source_platform_object = None
|
||||
elif platform == 'dingtalk':
|
||||
adapter.card_instance_id_dict['response'] = ('card', 'id')
|
||||
elif platform == 'telegram':
|
||||
source.source_platform_object = telegram.Update(
|
||||
1,
|
||||
message=telegram.Message(
|
||||
1,
|
||||
datetime.datetime.now(datetime.timezone.utc),
|
||||
telegram.Chat(1, 'private'),
|
||||
text='hi',
|
||||
),
|
||||
)
|
||||
adapter.bot = SimpleNamespace(send_message_draft=AsyncMock(), send_message=AsyncMock())
|
||||
adapter.msg_stream_id['response'] = ('private', 1, False)
|
||||
elif platform == 'discord':
|
||||
adapter._stream_buffer['response'] = {
|
||||
'channel': SimpleNamespace(send=AsyncMock(return_value=SimpleNamespace(content='hello', edit=AsyncMock()))),
|
||||
'sent_message': None,
|
||||
'last_content': '',
|
||||
'chunk_count': 0,
|
||||
'updated_at': time.time(),
|
||||
}
|
||||
channel = adapter._stream_buffer['response']['channel']
|
||||
elif platform == 'qqofficial':
|
||||
source = await module.QQOfficialEventConverter().target2yiri(module.qq_event())
|
||||
await adapter.create_message_card('response', source)
|
||||
else:
|
||||
source = await module.WecomBotEventConverter().target2yiri(module.wecombot_event())
|
||||
|
||||
async def runner():
|
||||
yield MessageChunk(role='assistant', content='hello', all_content='hello', is_final=True)
|
||||
yield Message(role='assistant', content='hello')
|
||||
|
||||
count = 0
|
||||
async for result in coalesce_stream_results(runner()):
|
||||
chunk = MessageChunk(role='assistant', content=result.content, resp_message_id='response', is_final=True)
|
||||
await adapter.reply_message_chunk(source, chunk, MessageChain([Plain(text=result.content)]), is_final=True)
|
||||
count += 1
|
||||
assert count == 1
|
||||
adapter.reply_message.assert_not_awaited()
|
||||
if platform == 'lark':
|
||||
adapter._replace_streaming_card.assert_awaited_once_with('card', 'hello')
|
||||
assert not adapter.card_id_dict
|
||||
elif platform == 'dingtalk':
|
||||
adapter.bot.send_card_message.assert_awaited_once_with('card', 'id', 'hello', True)
|
||||
assert not adapter.card_instance_id_dict
|
||||
elif platform == 'telegram':
|
||||
adapter.bot.send_message.assert_awaited_once()
|
||||
assert not adapter.msg_stream_id
|
||||
elif platform == 'discord':
|
||||
channel.send.assert_awaited_once_with('hello')
|
||||
assert not adapter._stream_buffer
|
||||
elif platform == 'qqofficial':
|
||||
assert len([call for call in adapter.bot.sent if call[0] == 'stream']) == 1
|
||||
assert not adapter._stream_ctx
|
||||
else:
|
||||
adapter.bot.push_stream_chunk.assert_awaited_once_with('msg-1', 'hello', is_final=True)
|
||||
adapter.bot.reply_text.assert_not_awaited()
|
||||
@@ -241,7 +241,7 @@ async def test_wecombot_event_converter_maps_private_and_group_messages_to_eba()
|
||||
assert private_event.adapter_name == 'wecombot-omni'
|
||||
assert private_event.chat_type == platform_entities.ChatType.PRIVATE
|
||||
assert private_event.chat_id == 'user-1'
|
||||
assert str(private_event.message_chain) == 'hello'
|
||||
assert str(private_event.message_chain) == '@EBA Bot hello'
|
||||
|
||||
assert isinstance(group_event, platform_events.MessageReceivedEvent)
|
||||
assert group_event.chat_type == platform_entities.ChatType.GROUP
|
||||
@@ -302,7 +302,9 @@ async def test_wecombot_send_reply_feedback_and_platform_api_use_underlying_clie
|
||||
|
||||
source_event = await WecomBotEventConverter().target2yiri(wecombot_event())
|
||||
await adapter.reply_message(source_event, message)
|
||||
adapter.bot.reply_text.assert_awaited_once_with('req-1', 'hello')
|
||||
adapter.bot.push_stream_chunk.assert_awaited_once_with('msg-1', 'hello', is_final=True)
|
||||
adapter.bot.reply_text.assert_not_awaited()
|
||||
adapter.bot.push_stream_chunk.reset_mock()
|
||||
|
||||
await adapter.reply_message_chunk(source_event, None, message, is_final=True)
|
||||
adapter.bot.push_stream_chunk.assert_awaited_once_with('msg-1', 'hello', is_final=True)
|
||||
|
||||
@@ -117,3 +117,61 @@ async def test_real_final_chunk_is_sent_and_closes_stream():
|
||||
assert frames[-1]['stream']['content'] == 'the answer'
|
||||
assert frames[-1]['stream']['finish'] is True
|
||||
assert not client.stream_still_open()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_final_is_acknowledged_without_sending_again():
|
||||
client = RecordingClient()
|
||||
client.seed_stream()
|
||||
assert await client.push_stream_chunk('msg-1', 'answer', is_final=True)
|
||||
assert await client.push_stream_chunk('msg-1', 'answer', is_final=True)
|
||||
assert len(client.stream_frames()) == 1
|
||||
assert not await client.push_stream_chunk('different-message', 'answer', is_final=True)
|
||||
assert not await client.push_stream_chunk('msg-1', 'different answer', is_final=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_cumulative_snapshots_reach_wire_once_without_concatenation():
|
||||
from langbot.pkg.pipeline.process.stream_results import coalesce_stream_results
|
||||
from langbot_plugin.api.entities.builtin.provider.message import Message, MessageChunk
|
||||
from tests.unit_tests.platform.test_wecombot_eba_adapter import (
|
||||
make_adapter,
|
||||
wecombot_event,
|
||||
WecomBotEventConverter,
|
||||
)
|
||||
|
||||
client = RecordingClient()
|
||||
client.seed_stream()
|
||||
adapter = make_adapter()
|
||||
adapter.bot = client
|
||||
event = await WecomBotEventConverter().target2yiri(wecombot_event())
|
||||
snapshots = ['<think>test', '<think>test</think>\nOK', '<think>test</think>\nOK!']
|
||||
|
||||
async def runner():
|
||||
# LocalAgent's StreamingModelCaller sets content to the accumulated
|
||||
# text, leaves all_content unset, then emits message.completed.
|
||||
for index, text in enumerate(snapshots):
|
||||
yield MessageChunk(role='assistant', content=text, msg_sequence=index + 1, is_final=index == 2)
|
||||
yield Message(role='assistant', content=snapshots[-1])
|
||||
|
||||
async for result in coalesce_stream_results(runner()):
|
||||
final = isinstance(result, Message) or result.is_final
|
||||
if isinstance(result, MessageChunk):
|
||||
result = result.model_copy(update={'content': result.all_content})
|
||||
await adapter.reply_message_chunk(event, result, result.get_content_platform_message_chain(), is_final=final)
|
||||
|
||||
frames = client.stream_frames()
|
||||
from langbot.pkg.platform.adapters.wecombot.stream_text import format_stream_text
|
||||
|
||||
assert [frame['stream']['content'] for frame in frames] == [format_stream_text(text) for text in snapshots]
|
||||
assert [frame['stream']['finish'] for frame in frames] == [False, False, True]
|
||||
assert len(client.replies) == 3 # No extra plain-text reply after completion.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revised_snapshot_replaces_previous_text():
|
||||
client = RecordingClient()
|
||||
client.seed_stream()
|
||||
await client.push_stream_chunk('msg-1', '<think>test</think>\nanswer')
|
||||
await client.push_stream_chunk('msg-1', 'answer', is_final=True)
|
||||
assert client.stream_frames()[-1]['stream']['content'] == 'answer'
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.platform.adapters.wecombot.stream_text import format_stream_text
|
||||
|
||||
|
||||
def test_multiple_rounds_have_one_thinking_section_and_separated_answers():
|
||||
raw = '<think>one</think>Checking.<think>two</think>More checks.<think>three</think>Done.'
|
||||
result = format_stream_text(raw)
|
||||
assert result == '<think>\none\n\ntwo\n\nthree\n</think>\n\nChecking.\n\nMore checks.\n\nDone.'
|
||||
assert format_stream_text(result) == result
|
||||
|
||||
|
||||
@pytest.mark.parametrize('suffix', ['<', '<t', '<thi', '<think', '</', '</thi'])
|
||||
def test_partial_tags_are_not_shown_in_answer(suffix):
|
||||
result = format_stream_text('<think>one</think>Checking.' + suffix)
|
||||
assert result.endswith('Checking.')
|
||||
|
||||
|
||||
def test_later_incomplete_thinking_remains_inside_single_section():
|
||||
result = format_stream_text('<think>one</think>Checking.<think>two')
|
||||
assert result == '<think>\none\n\ntwo\n</think>\n\nChecking.'
|
||||
|
||||
|
||||
def test_plain_markdown_is_unchanged():
|
||||
text = ' hello\n\n```python\na < b\n```\n'
|
||||
assert format_stream_text(text) == text
|
||||
|
||||
|
||||
def test_initial_partial_thinking_tag_waits_for_next_snapshot():
|
||||
assert format_stream_text('<thi') == ''
|
||||
@@ -369,11 +369,13 @@ async def test_ws_stream_sends_cumulative_snapshots_to_wecom():
|
||||
|
||||
assert await client.push_stream_chunk('msg-1', '你', is_final=False)
|
||||
assert await client.push_stream_chunk('msg-1', '你好', is_final=False)
|
||||
assert await client.push_stream_chunk('msg-1', '<think>test</think>你好', is_final=False)
|
||||
assert await client.push_stream_chunk('msg-1', '你好', is_final=True)
|
||||
|
||||
assert sent == [
|
||||
('req-1', 'stream-1', '你', False),
|
||||
('req-1', 'stream-1', '你好', False),
|
||||
('req-1', 'stream-1', '<think>test</think>你好', False),
|
||||
('req-1', 'stream-1', '你好', True),
|
||||
]
|
||||
|
||||
@@ -385,6 +387,7 @@ async def test_webhook_stream_queues_cumulative_snapshots_for_followups():
|
||||
|
||||
assert await client.push_stream_chunk('msg-1', '你', is_final=False)
|
||||
assert await client.push_stream_chunk('msg-1', '你好', is_final=False)
|
||||
assert await client.push_stream_chunk('msg-1', '<think>test</think>你好', is_final=False)
|
||||
assert await client.push_stream_chunk('msg-1', '你好', is_final=True)
|
||||
|
||||
assert session.queue.qsize() == 1
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -22,7 +22,7 @@ import type { BotSessionMonitorHandle } from '@/app/home/bots/components/bot-ses
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Settings, FileText, Users, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { Settings, FileText, Users, RefreshCw, Trash2, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { showBotError } from './bot-error';
|
||||
@@ -72,6 +72,7 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
|
||||
// Enable state managed here so the header switch works
|
||||
const [botEnabled, setBotEnabled] = useState(true);
|
||||
const [isTogglingEnable, setIsTogglingEnable] = useState(false);
|
||||
const [enableLoaded, setEnableLoaded] = useState(false);
|
||||
|
||||
// Fetch bot enable state
|
||||
@@ -91,20 +92,25 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
|
||||
const handleEnableToggle = useCallback(
|
||||
async (checked: boolean) => {
|
||||
if (isTogglingEnable) return;
|
||||
const prev = botEnabled;
|
||||
setIsTogglingEnable(true);
|
||||
setBotEnabled(checked);
|
||||
try {
|
||||
await httpClient.updateBot(id, { enable: checked });
|
||||
setBot((current) =>
|
||||
current ? { ...current, enable: checked } : current,
|
||||
);
|
||||
toast.success(t(checked ? 'bots.enableConfirmed' : 'bots.disableConfirmed'));
|
||||
refreshBots();
|
||||
} catch (error) {
|
||||
setBotEnabled(prev);
|
||||
showBotError(error, t('bots.setBotEnableError'), t);
|
||||
} finally {
|
||||
setIsTogglingEnable(false);
|
||||
}
|
||||
},
|
||||
[id, botEnabled, refreshBots, t],
|
||||
[id, botEnabled, isTogglingEnable, refreshBots, t],
|
||||
);
|
||||
|
||||
function handleFormSubmit() {
|
||||
@@ -210,18 +216,27 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
</div>
|
||||
{enableLoaded && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
status={isTogglingEnable ? 'pending' : botEnabled ? 'enabled' : 'disabled'}
|
||||
className="gap-1.5 rounded-full"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{isTogglingEnable
|
||||
? <Loader2 className="animate-spin" aria-hidden="true" />
|
||||
: <span className="size-1.5 rounded-full bg-current" aria-hidden="true" />}
|
||||
{t(isTogglingEnable
|
||||
? botEnabled ? 'bots.enabling' : 'bots.disabling'
|
||||
: botEnabled ? 'bots.enableConfirmed' : 'bots.disableConfirmed')}
|
||||
</Badge>
|
||||
<Switch
|
||||
id="bot-enable-switch"
|
||||
checked={botEnabled}
|
||||
onCheckedChange={handleEnableToggle}
|
||||
disabled={!canManage}
|
||||
disabled={!canManage || isTogglingEnable}
|
||||
aria-label={t('common.enable')}
|
||||
aria-busy={isTogglingEnable}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="bot-enable-switch"
|
||||
className="text-sm text-muted-foreground cursor-pointer"
|
||||
>
|
||||
{t('common.enable')}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -237,11 +252,11 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
<Button
|
||||
type="submit"
|
||||
form="bot-form"
|
||||
disabled={!enableLoaded || (!formDirty && botEnabled)}
|
||||
disabled={!enableLoaded || !formDirty || isTogglingEnable}
|
||||
className={activeTab !== 'config' ? 'invisible' : ''}
|
||||
data-guide="bot-config-save"
|
||||
>
|
||||
{t(botEnabled ? 'common.save' : 'bots.saveAndEnable')}
|
||||
{t(formDirty && !botEnabled ? 'bots.saveAndEnable' : 'common.save')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -800,7 +800,7 @@ function NavItems({
|
||||
{(isBot || isMCP) && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -bottom-0.5 -right-0.5 size-2 rounded-full border-2 border-popover',
|
||||
'absolute -bottom-1 -right-1 size-3 rounded-full border-2 border-popover',
|
||||
isMCP
|
||||
? mcpStatusColor(item)
|
||||
: item.enabled === false
|
||||
@@ -868,7 +868,7 @@ function NavItems({
|
||||
{(isBot || isMCP) && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -bottom-0.5 -right-0.5 size-2 rounded-full border-2 border-sidebar',
|
||||
'absolute -bottom-1 -right-1 size-3 rounded-full border-2 border-sidebar',
|
||||
isMCP
|
||||
? mcpStatusColor(item)
|
||||
: item.enabled === false
|
||||
|
||||
@@ -43,7 +43,8 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
requestId === requestIdRef.current &&
|
||||
getCurrentWorkspaceSnapshot()?.workspace.uuid === workspaceUuid;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
// Keep the failure view during retries; restore the dashboard only after
|
||||
// a successful response, without briefly restarting its polling timer.
|
||||
|
||||
try {
|
||||
const { startTime, endTime } = resolveMonitoringWindow(
|
||||
@@ -385,6 +386,7 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
].sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
||||
|
||||
setData(transformedData);
|
||||
setError(null);
|
||||
setRequestScope(scope);
|
||||
} catch (err) {
|
||||
if (!isCurrent()) return;
|
||||
|
||||
@@ -167,7 +167,7 @@ function MonitoringPageContent() {
|
||||
intervalRef.current = refreshInterval;
|
||||
countdown.current = refreshInterval;
|
||||
setRemainingSeconds(refreshInterval);
|
||||
if (!refreshInterval) return;
|
||||
if (!refreshInterval || error) return;
|
||||
const timer = window.setInterval(() => {
|
||||
if (document.visibilityState === 'visible' && !refreshState.current.busy) {
|
||||
countdown.current = Math.max(0, countdown.current - 1);
|
||||
@@ -179,7 +179,7 @@ function MonitoringPageContent() {
|
||||
}
|
||||
}, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [refreshInterval]);
|
||||
}, [refreshInterval, error]);
|
||||
|
||||
// State for expanded errors
|
||||
const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null);
|
||||
@@ -204,7 +204,7 @@ function MonitoringPageContent() {
|
||||
return (
|
||||
<div className="w-full h-full overflow-y-auto overflow-x-hidden">
|
||||
{/* Filters and Refresh Button - Sticky */}
|
||||
<div className="sticky top-0 z-10 -mt-1 pb-5 pt-1 bg-background">
|
||||
{!error && <div className="sticky top-0 z-10 -mt-1 pb-5 pt-1 bg-background">
|
||||
<div>
|
||||
<Card className="flex flex-col gap-4 p-4 xl:flex-row xl:items-end">
|
||||
<MonitoringFilters
|
||||
@@ -264,6 +264,8 @@ function MonitoringPageContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
}
|
||||
|
||||
{/* Content Area */}
|
||||
{error ? (
|
||||
<LoadErrorState title={t('monitoring.loadError')} onRetry={handleRefresh} busy={refreshing} />
|
||||
|
||||
@@ -822,6 +822,10 @@ const enUS = {
|
||||
earlier: 'Earlier',
|
||||
dateFormat: '{{month}}/{{day}}',
|
||||
setBotEnableError: 'Failed to set bot enable status',
|
||||
enabling: 'Enabling…',
|
||||
disabling: 'Disabling…',
|
||||
enableConfirmed: 'Enabled',
|
||||
disableConfirmed: 'Disabled',
|
||||
log: 'Log',
|
||||
configuration: 'Configuration',
|
||||
logs: 'Logs',
|
||||
|
||||
@@ -838,6 +838,10 @@ const esES = {
|
||||
earlier: 'Anterior',
|
||||
dateFormat: '{{day}}/{{month}}',
|
||||
setBotEnableError: 'Error al establecer el estado de activación del Bot',
|
||||
enabling: 'Activando…',
|
||||
disabling: 'Desactivando…',
|
||||
enableConfirmed: 'Activado',
|
||||
disableConfirmed: 'Desactivado',
|
||||
log: 'Registro',
|
||||
configuration: 'Configuración',
|
||||
logs: 'Registros',
|
||||
|
||||
@@ -825,6 +825,10 @@ const jaJP = {
|
||||
earlier: 'それ以前',
|
||||
dateFormat: '{{month}}月{{day}}日',
|
||||
setBotEnableError: 'ボットの有効状態の設定に失敗しました',
|
||||
enabling: '有効にしています…',
|
||||
disabling: '無効にしています…',
|
||||
enableConfirmed: '有効',
|
||||
disableConfirmed: '無効',
|
||||
log: 'ログ',
|
||||
configuration: '設定',
|
||||
logs: 'ログ',
|
||||
|
||||
@@ -833,6 +833,10 @@ const ruRU = {
|
||||
earlier: 'Ранее',
|
||||
dateFormat: '{{day}}.{{month}}',
|
||||
setBotEnableError: 'Не удалось изменить статус бота',
|
||||
enabling: 'Включение…',
|
||||
disabling: 'Отключение…',
|
||||
enableConfirmed: 'Включён',
|
||||
disableConfirmed: 'Отключён',
|
||||
log: 'Журнал',
|
||||
configuration: 'Настройки',
|
||||
logs: 'Журналы',
|
||||
|
||||
@@ -809,6 +809,10 @@ const thTH = {
|
||||
earlier: 'ก่อนหน้า',
|
||||
dateFormat: '{{day}}/{{month}}',
|
||||
setBotEnableError: 'ไม่สามารถตั้งค่าสถานะเปิดใช้งาน Bot ได้',
|
||||
enabling: 'กำลังเปิดใช้งาน…',
|
||||
disabling: 'กำลังปิดใช้งาน…',
|
||||
enableConfirmed: 'เปิดใช้งานแล้ว',
|
||||
disableConfirmed: 'ปิดใช้งานแล้ว',
|
||||
log: 'บันทึก',
|
||||
configuration: 'การกำหนดค่า',
|
||||
logs: 'บันทึก',
|
||||
|
||||
@@ -823,6 +823,10 @@ const viVN = {
|
||||
earlier: 'Trước đó',
|
||||
dateFormat: '{{day}}/{{month}}',
|
||||
setBotEnableError: 'Thiết lập trạng thái bật Bot thất bại',
|
||||
enabling: 'Đang bật…',
|
||||
disabling: 'Đang tắt…',
|
||||
enableConfirmed: 'Đã bật',
|
||||
disableConfirmed: 'Đã tắt',
|
||||
log: 'Nhật ký',
|
||||
configuration: 'Cấu hình',
|
||||
logs: 'Nhật ký',
|
||||
|
||||
@@ -775,6 +775,10 @@ const zhHans = {
|
||||
earlier: '更久之前',
|
||||
dateFormat: '{{month}}月{{day}}日',
|
||||
setBotEnableError: '设置机器人启用状态失败',
|
||||
enabling: '正在启用…',
|
||||
disabling: '正在禁用…',
|
||||
enableConfirmed: '已启用',
|
||||
disableConfirmed: '已禁用',
|
||||
log: '日志',
|
||||
configuration: '配置',
|
||||
logs: '日志',
|
||||
|
||||
@@ -773,6 +773,10 @@ const zhHant = {
|
||||
earlier: '更久之前',
|
||||
dateFormat: '{{month}}月{{day}}日',
|
||||
setBotEnableError: '設定機器人啟用狀態失敗',
|
||||
enabling: '正在啟用…',
|
||||
disabling: '正在停用…',
|
||||
enableConfirmed: '已啟用',
|
||||
disableConfirmed: '已停用',
|
||||
log: '日誌',
|
||||
configuration: '設定',
|
||||
logs: '日誌',
|
||||
|
||||
Reference in New Issue
Block a user