mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-04 00:27:14 +00:00
feat(tenancy): add Workspace multi-tenant foundation (#2353)
* Document multi-tenant workspace architecture * Add OSS and commercial workspace boundaries * docs: redesign multi-tenant workspace architecture * feat(tenancy): implement workspace isolation * docs(tenancy): record verification evidence * docs(tenancy): revise single-instance SaaS topology * docs(tenancy): refine architecture options * docs: finalize cloud v2 multi-tenant decisions * feat(tenancy): establish cloud isolation foundations * feat(tenancy): harden shared cloud runtime boundaries * docs(tenancy): record final isolation verification * fix(tenancy): close isolation and permission gaps * docs(tenancy): record final isolation verification * feat(tenancy): connect cloud workspace control plane * fix(build): install git for pinned SDK * docs(cloud): update control plane verification * chore: update multi-tenant SDK pin * fix(cloud): skip legacy model sync during startup * test(cloud): preserve minimal model manager fixtures * fix(cloud): preserve authenticated account context * fix(cloud): reuse authenticated account for user info * feat(cloud): complete Workspace settings navigation * test(web): cover Workspace dropdown menu * feat(web): place workspace controls in sidebar * refactor(web): streamline workspace controls * style(web): format workspace layout test * fix(cloud): surface runtime and workspace plan status * fix(plugin): keep runtime identity stable across restarts * fix(ui): widen and center workspace switcher * fix(ui): hide roles from workspace switcher * fix(ui): align workspace switcher with sidebar entries * feat(workspace): add in-product collaboration and direct Cloud launch * style: format collaboration changes * fix(workspace): bind collaboration APIs to tenant UoW * fix(cloud): preserve Core-owned collaboration state * test(cloud): require Space identity for invite registration * feat(cloud): complete secure invitation experience * style(web): format invitation flows * fix(cloud): recover box runtime without unscoped skill reload * feat(oss): enforce invitation account and owner billing flows * style: format OSS account service * test(oss): cover invitation logout handoff * fix(oss): resolve workspace owner in scoped session * feat(cloud): harden multi-tenant runtime resources * fix(cloud): bound runtime restart storms * fix(cloud): eliminate periodic runtime CPU spikes * fix(cloud): enforce instance capacity ceilings * fix(cloud): scope public login capability discovery * fix(cloud): bound tenant maintenance and monitoring work * fix(runtime): bound tenant resource amplification * fix(deps): pin green multi-tenant plugin SDK * fix(cloud): handle unavailable skill capability * fix(security): require authentication for image file endpoint (H-2) - Changed /api/v1/files/image from AuthType.NONE to USER_TOKEN_OR_API_KEY - Added Permission.RESOURCE_VIEW requirement - Prevents unauthenticated cross-tenant file access via leaked keys - Fixes HIGH severity finding from multi-tenant security review docs: add comprehensive database migration guide - Complete migration steps for OSS → multi-tenant - Backup, execution, verification procedures - Rollback scenarios and recovery plans - Performance tuning recommendations * test: add comprehensive cross-tenant isolation tests Added 7 critical test scenarios for multi-tenant boundaries: - Cross-tenant bot access prevention - Viewer role read-only enforcement - Removed member immediate access revocation - Model provider credential isolation - WebSocket message isolation - Invitation token workspace scoping - Multi-workspace context validation These tests address P0-2 coverage gaps for: - workspaces.py (membership & invitation flows) - user.py (authentication & authorization) - websocket_chat.py (real-time isolation) - plugins.py (resource access control) docs: finalize database migration guide * fix(security): resolve M-1, M-2, M-3 security findings M-1: WebSocket authorization TOCTOU race (FIXED) - Changed _revalidate_websocket_authorization to return RequestContext - Ensures validated context is used immediately without race window - Prevents removed members from sending messages during revalidation gap M-2: Model Manager cache workspace isolation (VERIFIED) - Confirmed _CacheKey already uses 4-tuple: (instance, workspace, generation, resource) - Cache is properly scoped per workspace, no cross-tenant leakage possible - No code change needed, documented as working correctly M-3: Invitation lock workspace scoping (FIXED) - Changed lock key from token_digest to workspace_uuid:token_digest - Prevents DoS where attacker locks token in Workspace A to block Workspace B - Locks now isolated per workspace All MEDIUM severity findings from security review now resolved. * fix(cloud): unblock tenant CI and enforce knowledge quotas * fix(tenancy): scope rerank model sync --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
This commit is contained in:
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import json
|
||||
import base64
|
||||
|
||||
from langbot.pkg.provider import runner
|
||||
from langbot.pkg.core import app
|
||||
@@ -11,6 +10,16 @@ from langbot.pkg.utils import image
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
from langbot.libs.coze_server_api.client import AsyncCozeAPIClient
|
||||
|
||||
_MAX_COZE_GENERATED_CHARS = 1024 * 1024
|
||||
_MAX_COZE_MEDIA_BYTES = 10 * 1024 * 1024
|
||||
|
||||
|
||||
def _append_bounded(current: str, addition: typing.Any) -> str:
|
||||
addition = str(addition or '')
|
||||
if len(current) + len(addition) > _MAX_COZE_GENERATED_CHARS:
|
||||
raise ValueError('Coze response exceeds the runtime limit')
|
||||
return current + addition
|
||||
|
||||
|
||||
@runner.runner_class('coze-api')
|
||||
class CozeAPIRunner(runner.RequestRunner):
|
||||
@@ -77,7 +86,10 @@ class CozeAPIRunner(runner.RequestRunner):
|
||||
content_parts.append({'type': 'text', 'text': ce.text})
|
||||
elif ce.type == 'image_base64':
|
||||
image_b64, image_format = await image.extract_b64_and_format(ce.image_base64)
|
||||
file_bytes = base64.b64decode(image_b64)
|
||||
file_bytes = await image.decode_base64_limited(
|
||||
image_b64,
|
||||
max_bytes=_MAX_COZE_MEDIA_BYTES,
|
||||
)
|
||||
file_id = await self._get_file_id(file_bytes)
|
||||
content_parts.append({'type': 'image', 'file_id': file_id})
|
||||
elif ce.type == 'file':
|
||||
@@ -144,7 +156,7 @@ class CozeAPIRunner(runner.RequestRunner):
|
||||
auto_save_history=self.auto_save_history,
|
||||
stream=True,
|
||||
):
|
||||
self.ap.logger.debug(f'coze-chat-stream: {chunk}')
|
||||
self.ap.logger.debug(f'coze-chat-stream: {str(chunk)[:1000]}')
|
||||
|
||||
event_type = chunk.get('event')
|
||||
data = chunk.get('data', {})
|
||||
@@ -153,11 +165,17 @@ class CozeAPIRunner(runner.RequestRunner):
|
||||
if event_type == 'conversation.message.delta':
|
||||
# 收集内容
|
||||
if 'content' in data:
|
||||
full_content += data.get('content', '')
|
||||
full_content = _append_bounded(
|
||||
full_content,
|
||||
data.get('content', ''),
|
||||
)
|
||||
|
||||
# 收集推理内容(如果有)
|
||||
if 'reasoning_content' in data:
|
||||
full_reasoning += data.get('reasoning_content', '')
|
||||
full_reasoning = _append_bounded(
|
||||
full_reasoning,
|
||||
data.get('reasoning_content', ''),
|
||||
)
|
||||
|
||||
elif event_type.split('.')[-1] == 'done': # 本地部署coze时,结束event不为done
|
||||
# 保存会话ID
|
||||
@@ -179,6 +197,8 @@ class CozeAPIRunner(runner.RequestRunner):
|
||||
remove_think = self.pipeline_config.get('output', {}).get('misc', {}).get('remove-think', False)
|
||||
if not remove_think:
|
||||
content = f'<think>\n{full_reasoning}\n</think>\n{content}'.strip()
|
||||
if len(content) > _MAX_COZE_GENERATED_CHARS:
|
||||
raise ValueError('Coze response exceeds the runtime limit')
|
||||
|
||||
# 一次性返回完整内容
|
||||
yield provider_message.Message(
|
||||
@@ -227,7 +247,7 @@ class CozeAPIRunner(runner.RequestRunner):
|
||||
auto_save_history=self.auto_save_history,
|
||||
stream=True,
|
||||
):
|
||||
self.ap.logger.debug(f'coze-chat-stream-chunk: {chunk}')
|
||||
self.ap.logger.debug(f'coze-chat-stream-chunk: {str(chunk)[:1000]}')
|
||||
|
||||
event_type = chunk.get('event')
|
||||
data = chunk.get('data', {})
|
||||
@@ -263,7 +283,7 @@ class CozeAPIRunner(runner.RequestRunner):
|
||||
error_msg = f'Coze API错误: {data.get("message", "未知错误")}'
|
||||
yield provider_message.MessageChunk(role='assistant', content=error_msg, finish_reason='error')
|
||||
return
|
||||
full_content += content
|
||||
full_content = _append_bounded(full_content, content)
|
||||
if message_idx % 8 == 0 or is_final:
|
||||
if full_content:
|
||||
yield provider_message.MessageChunk(role='assistant', content=full_content, is_final=is_final)
|
||||
@@ -286,3 +306,6 @@ class CozeAPIRunner(runner.RequestRunner):
|
||||
else:
|
||||
async for msg in self._chat_messages(query):
|
||||
yield msg
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self.coze.close()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import typing
|
||||
import re
|
||||
|
||||
@@ -10,6 +11,9 @@ from ...core import app
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
_MAX_DASHSCOPE_RESPONSE_CHARS = 1024 * 1024
|
||||
_MAX_DASHSCOPE_REFERENCES = 1024
|
||||
|
||||
|
||||
class DashscopeAPIError(Exception):
|
||||
"""Dashscope API 请求失败"""
|
||||
@@ -19,6 +23,13 @@ class DashscopeAPIError(Exception):
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
def _append_bounded(current: str, addition: typing.Any) -> str:
|
||||
addition = str(addition or '')
|
||||
if len(current) + len(addition) > _MAX_DASHSCOPE_RESPONSE_CHARS:
|
||||
raise DashscopeAPIError('Dashscope response exceeds the runtime limit')
|
||||
return current + addition
|
||||
|
||||
|
||||
@runner.runner_class('dashscope-app-api')
|
||||
class DashScopeAPIRunner(runner.RequestRunner):
|
||||
"阿里云百炼DashsscopeAPI对话请求器"
|
||||
@@ -111,18 +122,16 @@ class DashScopeAPIRunner(runner.RequestRunner):
|
||||
if remove_think:
|
||||
has_thoughts = False
|
||||
# 发送对话请求
|
||||
response = dashscope.Application.call(
|
||||
api_key=self.api_key, # 智能体应用的API Key
|
||||
app_id=self.app_id, # 智能体应用的ID
|
||||
prompt=plain_text, # 用户输入的文本信息
|
||||
stream=True, # 流式输出
|
||||
incremental_output=True, # 增量输出,使用流式输出需要开启增量输出
|
||||
session_id=query.session.using_conversation.uuid, # 会话ID用于,多轮对话
|
||||
response = await asyncio.to_thread(
|
||||
dashscope.Application.call,
|
||||
api_key=self.api_key,
|
||||
app_id=self.app_id,
|
||||
prompt=plain_text,
|
||||
stream=True,
|
||||
incremental_output=True,
|
||||
session_id=query.session.using_conversation.uuid,
|
||||
enable_thinking=has_thoughts,
|
||||
has_thoughts=has_thoughts,
|
||||
# rag_options={ # 主要用于文件交互,暂不支持
|
||||
# "session_file_ids": ["FILE_ID1"], # FILE_ID1 替换为实际的临时文件ID,逗号隔开多个
|
||||
# }
|
||||
)
|
||||
idx_chunk = 0
|
||||
try:
|
||||
@@ -131,7 +140,7 @@ class DashScopeAPIRunner(runner.RequestRunner):
|
||||
except AttributeError:
|
||||
is_stream = False
|
||||
if is_stream:
|
||||
for chunk in response:
|
||||
async for chunk in runner.iterate_sync(response):
|
||||
if chunk.get('status_code') != 200:
|
||||
raise DashscopeAPIError(
|
||||
f'Dashscope API 请求失败: status_code={chunk.get("status_code")} message={chunk.get("message")} request_id={chunk.get("request_id")} '
|
||||
@@ -145,15 +154,27 @@ class DashScopeAPIRunner(runner.RequestRunner):
|
||||
if stream_think and stream_think[0].get('thought'):
|
||||
if not think_start:
|
||||
think_start = True
|
||||
pending_content += f'<think>\n{stream_think[0].get("thought")}'
|
||||
pending_content = _append_bounded(
|
||||
pending_content,
|
||||
f'<think>\n{stream_think[0].get("thought")}',
|
||||
)
|
||||
else:
|
||||
# 继续输出 reasoning_content
|
||||
pending_content += stream_think[0].get('thought')
|
||||
pending_content = _append_bounded(
|
||||
pending_content,
|
||||
stream_think[0].get('thought'),
|
||||
)
|
||||
elif think_start and (not stream_think or stream_think[0].get('thought') == '') and not think_end:
|
||||
think_end = True
|
||||
pending_content += '\n</think>\n'
|
||||
pending_content = _append_bounded(
|
||||
pending_content,
|
||||
'\n</think>\n',
|
||||
)
|
||||
if stream_output.get('text') is not None:
|
||||
pending_content += stream_output.get('text')
|
||||
pending_content = _append_bounded(
|
||||
pending_content,
|
||||
stream_output.get('text'),
|
||||
)
|
||||
# 是否是流式最后一个chunk
|
||||
is_final = False if stream_output.get('finish_reason', False) == 'null' else True
|
||||
|
||||
@@ -162,12 +183,14 @@ class DashScopeAPIRunner(runner.RequestRunner):
|
||||
|
||||
# 从模型传出的参考资料信息中提取用于替换的字典
|
||||
if references_dict_list is not None:
|
||||
for doc in references_dict_list:
|
||||
for doc in references_dict_list[:_MAX_DASHSCOPE_REFERENCES]:
|
||||
if doc.get('index_id') is not None:
|
||||
references_dict[doc.get('index_id')] = doc.get('doc_name')
|
||||
|
||||
# 将参考资料替换到文本中
|
||||
pending_content = self._replace_references(pending_content, references_dict)
|
||||
if len(pending_content) > _MAX_DASHSCOPE_RESPONSE_CHARS:
|
||||
raise DashscopeAPIError('Dashscope response exceeds the runtime limit')
|
||||
|
||||
if idx_chunk % 8 == 0 or is_final:
|
||||
yield provider_message.MessageChunk(
|
||||
@@ -178,7 +201,7 @@ class DashScopeAPIRunner(runner.RequestRunner):
|
||||
# 保存当前会话的session_id用于下次对话的语境
|
||||
query.session.using_conversation.uuid = stream_output.get('session_id')
|
||||
else:
|
||||
for chunk in response:
|
||||
async for chunk in runner.iterate_sync(response):
|
||||
if chunk.get('status_code') != 200:
|
||||
raise DashscopeAPIError(
|
||||
f'Dashscope API 请求失败: status_code={chunk.get("status_code")} message={chunk.get("message")} request_id={chunk.get("request_id")} '
|
||||
@@ -192,15 +215,27 @@ class DashScopeAPIRunner(runner.RequestRunner):
|
||||
if stream_think and stream_think[0].get('thought'):
|
||||
if not think_start:
|
||||
think_start = True
|
||||
pending_content += f'<think>\n{stream_think[0].get("thought")}'
|
||||
pending_content = _append_bounded(
|
||||
pending_content,
|
||||
f'<think>\n{stream_think[0].get("thought")}',
|
||||
)
|
||||
else:
|
||||
# 继续输出 reasoning_content
|
||||
pending_content += stream_think[0].get('thought')
|
||||
pending_content = _append_bounded(
|
||||
pending_content,
|
||||
stream_think[0].get('thought'),
|
||||
)
|
||||
elif think_start and (not stream_think or stream_think[0].get('thought') == '') and not think_end:
|
||||
think_end = True
|
||||
pending_content += '\n</think>\n'
|
||||
pending_content = _append_bounded(
|
||||
pending_content,
|
||||
'\n</think>\n',
|
||||
)
|
||||
if stream_output.get('text') is not None:
|
||||
pending_content += stream_output.get('text')
|
||||
pending_content = _append_bounded(
|
||||
pending_content,
|
||||
stream_output.get('text'),
|
||||
)
|
||||
|
||||
# 保存当前会话的session_id用于下次对话的语境
|
||||
query.session.using_conversation.uuid = stream_output.get('session_id')
|
||||
@@ -210,12 +245,14 @@ class DashScopeAPIRunner(runner.RequestRunner):
|
||||
|
||||
# 从模型传出的参考资料信息中提取用于替换的字典
|
||||
if references_dict_list is not None:
|
||||
for doc in references_dict_list:
|
||||
for doc in references_dict_list[:_MAX_DASHSCOPE_REFERENCES]:
|
||||
if doc.get('index_id') is not None:
|
||||
references_dict[doc.get('index_id')] = doc.get('doc_name')
|
||||
|
||||
# 将参考资料替换到文本中
|
||||
pending_content = self._replace_references(pending_content, references_dict)
|
||||
pending_content = self._replace_references(pending_content, references_dict)
|
||||
if len(pending_content) > _MAX_DASHSCOPE_RESPONSE_CHARS:
|
||||
raise DashscopeAPIError('Dashscope response exceeds the runtime limit')
|
||||
|
||||
yield provider_message.Message(
|
||||
role='assistant',
|
||||
@@ -240,18 +277,16 @@ class DashScopeAPIRunner(runner.RequestRunner):
|
||||
biz_params.update(query.variables)
|
||||
|
||||
# 发送对话请求
|
||||
response = dashscope.Application.call(
|
||||
api_key=self.api_key, # 智能体应用的API Key
|
||||
app_id=self.app_id, # 智能体应用的ID
|
||||
prompt=plain_text, # 用户输入的文本信息
|
||||
stream=True, # 流式输出
|
||||
incremental_output=True, # 增量输出,使用流式输出需要开启增量输出
|
||||
session_id=query.session.using_conversation.uuid, # 会话ID用于,多轮对话
|
||||
biz_params=biz_params, # 工作流应用的自定义输入参数传递
|
||||
flow_stream_mode='message_format', # 消息模式,输出/结束节点的流式结果
|
||||
# rag_options={ # 主要用于文件交互,暂不支持
|
||||
# "session_file_ids": ["FILE_ID1"], # FILE_ID1 替换为实际的临时文件ID,逗号隔开多个
|
||||
# }
|
||||
response = await asyncio.to_thread(
|
||||
dashscope.Application.call,
|
||||
api_key=self.api_key,
|
||||
app_id=self.app_id,
|
||||
prompt=plain_text,
|
||||
stream=True,
|
||||
incremental_output=True,
|
||||
session_id=query.session.using_conversation.uuid,
|
||||
biz_params=biz_params,
|
||||
flow_stream_mode='message_format',
|
||||
)
|
||||
|
||||
# 处理API返回的流式输出
|
||||
@@ -262,7 +297,7 @@ class DashScopeAPIRunner(runner.RequestRunner):
|
||||
is_stream = False
|
||||
idx_chunk = 0
|
||||
if is_stream:
|
||||
for chunk in response:
|
||||
async for chunk in runner.iterate_sync(response):
|
||||
if chunk.get('status_code') != 200:
|
||||
raise DashscopeAPIError(
|
||||
f'Dashscope API 请求失败: status_code={chunk.get("status_code")} message={chunk.get("message")} request_id={chunk.get("request_id")} '
|
||||
@@ -273,7 +308,10 @@ class DashScopeAPIRunner(runner.RequestRunner):
|
||||
# 获取流式传输的output
|
||||
stream_output = chunk.get('output', {})
|
||||
if stream_output.get('workflow_message') is not None:
|
||||
pending_content += stream_output.get('workflow_message').get('message').get('content')
|
||||
pending_content = _append_bounded(
|
||||
pending_content,
|
||||
stream_output.get('workflow_message').get('message').get('content'),
|
||||
)
|
||||
# if stream_output.get('text') is not None:
|
||||
# pending_content += stream_output.get('text')
|
||||
|
||||
@@ -284,12 +322,14 @@ class DashScopeAPIRunner(runner.RequestRunner):
|
||||
|
||||
# 从模型传出的参考资料信息中提取用于替换的字典
|
||||
if references_dict_list is not None:
|
||||
for doc in references_dict_list:
|
||||
for doc in references_dict_list[:_MAX_DASHSCOPE_REFERENCES]:
|
||||
if doc.get('index_id') is not None:
|
||||
references_dict[doc.get('index_id')] = doc.get('doc_name')
|
||||
|
||||
# 将参考资料替换到文本中
|
||||
pending_content = self._replace_references(pending_content, references_dict)
|
||||
if len(pending_content) > _MAX_DASHSCOPE_RESPONSE_CHARS:
|
||||
raise DashscopeAPIError('Dashscope response exceeds the runtime limit')
|
||||
if idx_chunk % 8 == 0 or is_final:
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
@@ -301,7 +341,7 @@ class DashScopeAPIRunner(runner.RequestRunner):
|
||||
query.session.using_conversation.uuid = stream_output.get('session_id')
|
||||
|
||||
else:
|
||||
for chunk in response:
|
||||
async for chunk in runner.iterate_sync(response):
|
||||
if chunk.get('status_code') != 200:
|
||||
raise DashscopeAPIError(
|
||||
f'Dashscope API 请求失败: status_code={chunk.get("status_code")} message={chunk.get("message")} request_id={chunk.get("request_id")} '
|
||||
@@ -312,7 +352,10 @@ class DashScopeAPIRunner(runner.RequestRunner):
|
||||
# 获取流式传输的output
|
||||
stream_output = chunk.get('output', {})
|
||||
if stream_output.get('text') is not None:
|
||||
pending_content += stream_output.get('text')
|
||||
pending_content = _append_bounded(
|
||||
pending_content,
|
||||
stream_output.get('text'),
|
||||
)
|
||||
|
||||
is_final = False if stream_output.get('finish_reason', False) == 'null' else True
|
||||
|
||||
@@ -324,12 +367,14 @@ class DashScopeAPIRunner(runner.RequestRunner):
|
||||
|
||||
# 从模型传出的参考资料信息中提取用于替换的字典
|
||||
if references_dict_list is not None:
|
||||
for doc in references_dict_list:
|
||||
for doc in references_dict_list[:_MAX_DASHSCOPE_REFERENCES]:
|
||||
if doc.get('index_id') is not None:
|
||||
references_dict[doc.get('index_id')] = doc.get('doc_name')
|
||||
|
||||
# 将参考资料替换到文本中
|
||||
pending_content = self._replace_references(pending_content, references_dict)
|
||||
pending_content = self._replace_references(pending_content, references_dict)
|
||||
if len(pending_content) > _MAX_DASHSCOPE_RESPONSE_CHARS:
|
||||
raise DashscopeAPIError('Dashscope response exceeds the runtime limit')
|
||||
|
||||
yield provider_message.Message(
|
||||
role='assistant',
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import heapq
|
||||
import typing
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
import base64
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
@@ -16,19 +17,40 @@ from langbot.pkg.provider import runner
|
||||
from langbot.pkg.core import app
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
from langbot.pkg.utils import image
|
||||
from langbot.pkg.utils import httpclient, image
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
from langbot.libs.dify_service_api.v1 import client, errors
|
||||
import httpx
|
||||
|
||||
|
||||
# Module-level store for paused-workflow form state. The key isolates the bot,
|
||||
# pipeline, adapter, and launcher; each value holds an insertion-ordered map of
|
||||
# form_token -> form_data so one conversation can pause multiple workflows.
|
||||
PendingFormKey = tuple[str, str, str, str, str]
|
||||
# Module-level store for paused-workflow form state. The key includes the full
|
||||
# execution scope before the bot, pipeline, adapter, and launcher dimensions;
|
||||
# each value holds an insertion-ordered map of form_token -> form_data so one
|
||||
# conversation can pause multiple workflows without crossing Workspaces or
|
||||
# placement generations.
|
||||
PendingFormKey = tuple[str, str, int, str, str, str, str, str]
|
||||
_PENDING_FORMS: dict[PendingFormKey, 'OrderedDict[str, dict[str, typing.Any]]'] = {}
|
||||
_PENDING_FORM_EXPIRY_HEAP: list[tuple[float, int, PendingFormKey, str]] = []
|
||||
_PENDING_FORM_ACTIVE_COUNT = 0
|
||||
_PENDING_FORM_REVISION = 0
|
||||
_PENDING_FORM_DEFAULT_TTL = 30 * 60 # 30 minutes safety cap
|
||||
_PENDING_FORM_MAX_SESSIONS = 4096
|
||||
_PENDING_FORM_MAX_PER_SESSION = 16
|
||||
_PENDING_FORM_HEAP_COMPACT_FLOOR = 64
|
||||
_PENDING_FORM_HEAP_MAX_MULTIPLIER = 4
|
||||
_PENDING_FORM_REVISION_KEY = '_langbot_cache_revision'
|
||||
_STREAM_FORM_PLACEHOLDER = '\u200b'
|
||||
_MAX_DIFY_UPLOAD_BYTES = 10 * 1024 * 1024
|
||||
|
||||
|
||||
def _read_local_file_limited(path: str) -> bytes:
|
||||
"""Read a local platform attachment without allowing an oversized allocation."""
|
||||
|
||||
if os.path.getsize(path) > _MAX_DIFY_UPLOAD_BYTES:
|
||||
raise ValueError('Dify upload file exceeds the size limit')
|
||||
with open(path, 'rb') as file:
|
||||
content = file.read(_MAX_DIFY_UPLOAD_BYTES + 1)
|
||||
if len(content) > _MAX_DIFY_UPLOAD_BYTES:
|
||||
raise ValueError('Dify upload file exceeds the size limit')
|
||||
return content
|
||||
|
||||
|
||||
def _merge_stream_text(accumulated: str, incoming: typing.Any) -> str:
|
||||
@@ -48,10 +70,13 @@ def _dify_user_from_query(query: pipeline_query.Query) -> str:
|
||||
|
||||
|
||||
def _session_key_from_query(query: pipeline_query.Query) -> PendingFormKey:
|
||||
"""Build a process-local pending-form key isolated by bot and pipeline."""
|
||||
"""Build a process-local pending-form key isolated by execution scope."""
|
||||
adapter = getattr(query, 'adapter', None)
|
||||
adapter_type = f'{type(adapter).__module__}.{type(adapter).__qualname__}'
|
||||
return (
|
||||
str(getattr(query, 'instance_uuid', '') or ''),
|
||||
str(getattr(query, 'workspace_uuid', '') or ''),
|
||||
int(getattr(query, 'placement_generation', 0) or 0),
|
||||
str(getattr(query, 'bot_uuid', '') or ''),
|
||||
str(getattr(query, 'pipeline_uuid', '') or ''),
|
||||
adapter_type,
|
||||
@@ -60,22 +85,103 @@ def _session_key_from_query(query: pipeline_query.Query) -> PendingFormKey:
|
||||
)
|
||||
|
||||
|
||||
def _synchronize_pending_form_cache_if_externally_cleared() -> None:
|
||||
"""Keep test/debug direct cache clears from retaining stale heap entries."""
|
||||
|
||||
global _PENDING_FORM_ACTIVE_COUNT
|
||||
if _PENDING_FORMS:
|
||||
return
|
||||
_PENDING_FORM_EXPIRY_HEAP.clear()
|
||||
_PENDING_FORM_ACTIVE_COUNT = 0
|
||||
|
||||
|
||||
def _pending_form_entry_is_current(
|
||||
expires_at: float,
|
||||
revision: int,
|
||||
session_key: PendingFormKey,
|
||||
form_token: str,
|
||||
) -> bool:
|
||||
forms = _PENDING_FORMS.get(session_key)
|
||||
if forms is None:
|
||||
return False
|
||||
stored = forms.get(form_token)
|
||||
if stored is None:
|
||||
return False
|
||||
return stored.get(_PENDING_FORM_REVISION_KEY) == revision and stored.get('_expires_at') == expires_at
|
||||
|
||||
|
||||
def _peek_valid_pending_form_expiry(
|
||||
*,
|
||||
pop: bool = False,
|
||||
) -> tuple[float, int, PendingFormKey, str] | None:
|
||||
while _PENDING_FORM_EXPIRY_HEAP:
|
||||
entry = _PENDING_FORM_EXPIRY_HEAP[0]
|
||||
if _pending_form_entry_is_current(*entry):
|
||||
if pop:
|
||||
heapq.heappop(_PENDING_FORM_EXPIRY_HEAP)
|
||||
return entry
|
||||
heapq.heappop(_PENDING_FORM_EXPIRY_HEAP)
|
||||
return None
|
||||
|
||||
|
||||
def _drop_pending_form(session_key: PendingFormKey, form_token: str) -> None:
|
||||
global _PENDING_FORM_ACTIVE_COUNT
|
||||
forms = _PENDING_FORMS.get(session_key)
|
||||
if forms is None or forms.pop(form_token, None) is None:
|
||||
return
|
||||
_PENDING_FORM_ACTIVE_COUNT = max(_PENDING_FORM_ACTIVE_COUNT - 1, 0)
|
||||
if not forms:
|
||||
_PENDING_FORMS.pop(session_key, None)
|
||||
|
||||
|
||||
def _drop_pending_form_session(session_key: PendingFormKey) -> None:
|
||||
global _PENDING_FORM_ACTIVE_COUNT
|
||||
forms = _PENDING_FORMS.pop(session_key, None)
|
||||
if forms is not None:
|
||||
_PENDING_FORM_ACTIVE_COUNT = max(
|
||||
_PENDING_FORM_ACTIVE_COUNT - len(forms),
|
||||
0,
|
||||
)
|
||||
|
||||
|
||||
def _compact_pending_form_expiry_heap_if_needed() -> None:
|
||||
max_heap_entries = max(
|
||||
_PENDING_FORM_HEAP_COMPACT_FLOOR,
|
||||
_PENDING_FORM_ACTIVE_COUNT * _PENDING_FORM_HEAP_MAX_MULTIPLIER,
|
||||
)
|
||||
if len(_PENDING_FORM_EXPIRY_HEAP) <= max_heap_entries:
|
||||
return
|
||||
_PENDING_FORM_EXPIRY_HEAP[:] = [
|
||||
(
|
||||
float(stored['_expires_at']),
|
||||
int(stored[_PENDING_FORM_REVISION_KEY]),
|
||||
session_key,
|
||||
form_token,
|
||||
)
|
||||
for session_key, forms in _PENDING_FORMS.items()
|
||||
for form_token, stored in forms.items()
|
||||
]
|
||||
heapq.heapify(_PENDING_FORM_EXPIRY_HEAP)
|
||||
|
||||
|
||||
def _prune_pending_forms(now: float | None = None) -> None:
|
||||
_synchronize_pending_form_cache_if_externally_cleared()
|
||||
if now is None:
|
||||
now = time.time()
|
||||
for session_key in list(_PENDING_FORMS.keys()):
|
||||
forms = _PENDING_FORMS[session_key]
|
||||
expired_tokens = [token for token, data in forms.items() if data.get('_expires_at', 0) <= now]
|
||||
for token in expired_tokens:
|
||||
forms.pop(token, None)
|
||||
if not forms:
|
||||
_PENDING_FORMS.pop(session_key, None)
|
||||
while True:
|
||||
entry = _peek_valid_pending_form_expiry()
|
||||
if entry is None or entry[0] > now:
|
||||
break
|
||||
_, _, session_key, form_token = _peek_valid_pending_form_expiry(pop=True)
|
||||
_drop_pending_form(session_key, form_token)
|
||||
_compact_pending_form_expiry_heap_if_needed()
|
||||
|
||||
|
||||
def _set_pending_form(session_key: PendingFormKey, form_data: dict[str, typing.Any]) -> None:
|
||||
global _PENDING_FORM_ACTIVE_COUNT, _PENDING_FORM_REVISION
|
||||
_prune_pending_forms()
|
||||
if isinstance(session_key, tuple) and len(session_key) > 1:
|
||||
form_data['pipeline_uuid'] = session_key[1]
|
||||
if isinstance(session_key, tuple) and len(session_key) == 8:
|
||||
form_data['pipeline_uuid'] = session_key[4]
|
||||
stored = dict(form_data)
|
||||
expiration_time = stored.get('expiration_time')
|
||||
try:
|
||||
@@ -83,11 +189,31 @@ def _set_pending_form(session_key: PendingFormKey, form_data: dict[str, typing.A
|
||||
except (TypeError, ValueError):
|
||||
expiration_ts = 0.0
|
||||
stored['_expires_at'] = expiration_ts or (time.time() + _PENDING_FORM_DEFAULT_TTL)
|
||||
_PENDING_FORM_REVISION += 1
|
||||
stored[_PENDING_FORM_REVISION_KEY] = _PENDING_FORM_REVISION
|
||||
form_token = str(stored.get('form_token') or '')
|
||||
forms = _PENDING_FORMS.setdefault(session_key, OrderedDict())
|
||||
# Re-insert at the end so this becomes the "latest" entry
|
||||
forms.pop(form_token, None)
|
||||
if forms.pop(form_token, None) is None:
|
||||
_PENDING_FORM_ACTIVE_COUNT += 1
|
||||
forms[form_token] = stored
|
||||
heapq.heappush(
|
||||
_PENDING_FORM_EXPIRY_HEAP,
|
||||
(
|
||||
stored['_expires_at'],
|
||||
_PENDING_FORM_REVISION,
|
||||
session_key,
|
||||
form_token,
|
||||
),
|
||||
)
|
||||
while len(forms) > _PENDING_FORM_MAX_PER_SESSION:
|
||||
oldest_token = next(iter(forms))
|
||||
_drop_pending_form(session_key, oldest_token)
|
||||
if len(_PENDING_FORMS) > _PENDING_FORM_MAX_SESSIONS:
|
||||
oldest_entry = _peek_valid_pending_form_expiry()
|
||||
if oldest_entry is not None:
|
||||
_drop_pending_form_session(oldest_entry[2])
|
||||
_compact_pending_form_expiry_heap_if_needed()
|
||||
|
||||
|
||||
def _get_pending_form_by_token(session_key: PendingFormKey, form_token: str) -> dict[str, typing.Any] | None:
|
||||
@@ -139,11 +265,11 @@ def _clear_pending_form(session_key: PendingFormKey, form_token: str | None = No
|
||||
if not forms:
|
||||
return
|
||||
if form_token is None:
|
||||
_PENDING_FORMS.pop(session_key, None)
|
||||
_drop_pending_form_session(session_key)
|
||||
_compact_pending_form_expiry_heap_if_needed()
|
||||
return
|
||||
forms.pop(form_token, None)
|
||||
if not forms:
|
||||
_PENDING_FORMS.pop(session_key, None)
|
||||
_drop_pending_form(session_key, form_token)
|
||||
_compact_pending_form_expiry_heap_if_needed()
|
||||
|
||||
|
||||
def _format_human_input_text(
|
||||
@@ -716,6 +842,9 @@ class DifyServiceAPIRunner(runner.RequestRunner):
|
||||
base_url=self.pipeline_config['ai']['dify-service-api']['base-url'],
|
||||
)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self.dify_client.aclose()
|
||||
|
||||
def _process_thinking_content(
|
||||
self,
|
||||
content: str,
|
||||
@@ -791,13 +920,16 @@ class DifyServiceAPIRunner(runner.RequestRunner):
|
||||
async def download_file(file_url: str) -> tuple[bytes, str]:
|
||||
"""Download file from url (supports data url)."""
|
||||
|
||||
async with httpx.AsyncClient() as client_session:
|
||||
resp = await client_session.get(file_url)
|
||||
client_session = httpclient.get_session()
|
||||
async with client_session.get(file_url, timeout=120) as resp:
|
||||
resp.raise_for_status()
|
||||
content_type = (
|
||||
resp.headers.get('content-type') or mimetypes.guess_type(file_url)[0] or 'application/octet-stream'
|
||||
)
|
||||
return resp.content, content_type
|
||||
return (
|
||||
await httpclient.read_limited(resp, max_bytes=_MAX_DIFY_UPLOAD_BYTES),
|
||||
content_type,
|
||||
)
|
||||
|
||||
def _detect_file_type(content_type: str) -> str:
|
||||
"""Map MIME to dify file type."""
|
||||
@@ -815,7 +947,10 @@ class DifyServiceAPIRunner(runner.RequestRunner):
|
||||
plain_text += ce.text
|
||||
elif ce.type == 'image_base64':
|
||||
image_b64, image_format = await image.extract_b64_and_format(ce.image_base64)
|
||||
file_bytes = base64.b64decode(image_b64)
|
||||
file_bytes = await image.decode_base64_limited(
|
||||
image_b64,
|
||||
max_bytes=_MAX_DIFY_UPLOAD_BYTES,
|
||||
)
|
||||
image_id = await upload_file_bytes(f'img.{image_format}', file_bytes, f'image/{image_format}')
|
||||
upload_files.append({'type': 'image', 'id': image_id})
|
||||
elif ce.type == 'file_url':
|
||||
@@ -835,7 +970,10 @@ class DifyServiceAPIRunner(runner.RequestRunner):
|
||||
content_type = 'application/octet-stream'
|
||||
if ';' in header:
|
||||
content_type = header.split(';')[0][5:] or content_type
|
||||
file_bytes = base64.b64decode(b64_data)
|
||||
file_bytes = await image.decode_base64_limited(
|
||||
b64_data,
|
||||
max_bytes=_MAX_DIFY_UPLOAD_BYTES,
|
||||
)
|
||||
file_id = await upload_file_bytes(file_name, file_bytes, content_type)
|
||||
file_type = _detect_file_type(content_type)
|
||||
upload_files.append({'type': file_type, 'id': file_id})
|
||||
@@ -860,15 +998,19 @@ class DifyServiceAPIRunner(runner.RequestRunner):
|
||||
}
|
||||
|
||||
async def _download_file_for_form(self, file_url: str) -> tuple[bytes, str, str]:
|
||||
async with httpx.AsyncClient() as client_session:
|
||||
resp = await client_session.get(file_url)
|
||||
client_session = httpclient.get_session()
|
||||
async with client_session.get(file_url, timeout=120) as resp:
|
||||
resp.raise_for_status()
|
||||
content_type = (
|
||||
resp.headers.get('content-type') or mimetypes.guess_type(file_url)[0] or 'application/octet-stream'
|
||||
)
|
||||
parsed = urlparse(file_url)
|
||||
file_name = os.path.basename(parsed.path) or 'file'
|
||||
return resp.content, content_type, file_name
|
||||
return (
|
||||
await httpclient.read_limited(resp, max_bytes=_MAX_DIFY_UPLOAD_BYTES),
|
||||
content_type,
|
||||
file_name,
|
||||
)
|
||||
|
||||
async def _platform_file_to_dify(self, item: typing.Any, user: str) -> dict | None:
|
||||
try:
|
||||
@@ -885,13 +1027,15 @@ class DifyServiceAPIRunner(runner.RequestRunner):
|
||||
content_type = header.split(';', 1)[0][5:] or content_type
|
||||
return await self._upload_file_bytes_for_user(
|
||||
file_name,
|
||||
base64.b64decode(b64_data),
|
||||
await image.decode_base64_limited(
|
||||
b64_data,
|
||||
max_bytes=_MAX_DIFY_UPLOAD_BYTES,
|
||||
),
|
||||
content_type,
|
||||
user,
|
||||
)
|
||||
if item.path:
|
||||
with open(item.path, 'rb') as f:
|
||||
file_bytes = f.read()
|
||||
file_bytes = await asyncio.to_thread(_read_local_file_limited, str(item.path))
|
||||
content_type = mimetypes.guess_type(str(item.path))[0] or 'application/octet-stream'
|
||||
file_name = item.name or os.path.basename(str(item.path)) or 'file'
|
||||
return await self._upload_file_bytes_for_user(file_name, file_bytes, content_type, user)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import codecs
|
||||
import typing
|
||||
import json
|
||||
import httpx
|
||||
@@ -11,6 +12,44 @@ from ...core import app
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
_MAX_LANGFLOW_LINE_CHARS = 1024 * 1024
|
||||
_MAX_LANGFLOW_TOTAL_BYTES = 16 * 1024 * 1024
|
||||
_MAX_LANGFLOW_RESPONSE_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
async def _iter_limited_lines(
|
||||
response: httpx.Response,
|
||||
) -> typing.AsyncGenerator[str, None]:
|
||||
decoder = codecs.getincrementaldecoder('utf-8')('replace')
|
||||
buffer = ''
|
||||
total_bytes = 0
|
||||
async for chunk in response.aiter_bytes(chunk_size=8192):
|
||||
total_bytes += len(chunk)
|
||||
if total_bytes > _MAX_LANGFLOW_TOTAL_BYTES:
|
||||
raise ValueError('Langflow stream exceeds the runtime limit')
|
||||
buffer += decoder.decode(chunk)
|
||||
while '\n' in buffer:
|
||||
line, buffer = buffer.split('\n', 1)
|
||||
if len(line) > _MAX_LANGFLOW_LINE_CHARS:
|
||||
raise ValueError('Langflow event exceeds the runtime limit')
|
||||
yield line.rstrip('\r')
|
||||
if len(buffer) > _MAX_LANGFLOW_LINE_CHARS:
|
||||
raise ValueError('Langflow event exceeds the runtime limit')
|
||||
buffer += decoder.decode(b'', final=True)
|
||||
if buffer:
|
||||
if len(buffer) > _MAX_LANGFLOW_LINE_CHARS:
|
||||
raise ValueError('Langflow event exceeds the runtime limit')
|
||||
yield buffer.rstrip('\r')
|
||||
|
||||
|
||||
async def _read_limited_response(response: httpx.Response) -> bytes:
|
||||
body = bytearray()
|
||||
async for chunk in response.aiter_bytes(chunk_size=8192):
|
||||
body.extend(chunk)
|
||||
if len(body) > _MAX_LANGFLOW_RESPONSE_BYTES:
|
||||
raise ValueError('Langflow response exceeds the runtime limit')
|
||||
return bytes(body)
|
||||
|
||||
|
||||
@runner.runner_class('langflow-api')
|
||||
class LangflowAPIRunner(runner.RequestRunner):
|
||||
@@ -99,7 +138,7 @@ class LangflowAPIRunner(runner.RequestRunner):
|
||||
accumulated_content = ''
|
||||
message_count = 0
|
||||
|
||||
async for line in response.aiter_lines():
|
||||
async for line in _iter_limited_lines(response):
|
||||
data_str = line
|
||||
|
||||
if data_str.startswith('data: '):
|
||||
@@ -144,11 +183,15 @@ class LangflowAPIRunner(runner.RequestRunner):
|
||||
yield provider_message.MessageChunk(role='assistant', content=accumulated_content, is_final=True)
|
||||
else:
|
||||
# 非流式请求
|
||||
response = await client.post(url, json=payload, headers=headers, timeout=120.0)
|
||||
response.raise_for_status()
|
||||
|
||||
# 解析响应
|
||||
response_data = response.json()
|
||||
async with client.stream(
|
||||
'POST',
|
||||
url,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=120.0,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
response_data = json.loads(await _read_limited_response(response))
|
||||
|
||||
# 提取消息内容
|
||||
# 根据Langflow API文档,响应结构可能在outputs[0].outputs[0].outputs.message.message中
|
||||
|
||||
@@ -11,6 +11,7 @@ import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
import langbot_plugin.api.entities.builtin.rag.context as rag_context
|
||||
|
||||
from ...pipeline.pool import get_query_execution_context
|
||||
|
||||
rag_combined_prompt_template = """
|
||||
The following are relevant context entries retrieved from the knowledge base.
|
||||
@@ -210,7 +211,7 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
req_messages.append(
|
||||
provider_message.Message(
|
||||
role='system',
|
||||
content=self.ap.box_service.get_system_guidance(query.query_id),
|
||||
content=self.ap.box_service.get_system_guidance(query),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -223,11 +224,15 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
) -> list[modelmgr_requester.RuntimeLLMModel]:
|
||||
"""Build ordered list of models to try: primary model + fallback models."""
|
||||
candidates = []
|
||||
execution_context = get_query_execution_context(query)
|
||||
|
||||
# Primary model
|
||||
if query.use_llm_model_uuid:
|
||||
try:
|
||||
primary = await self.ap.model_mgr.get_model_by_uuid(query.use_llm_model_uuid)
|
||||
primary = await self.ap.model_mgr.get_model_by_uuid(
|
||||
execution_context,
|
||||
query.use_llm_model_uuid,
|
||||
)
|
||||
candidates.append(primary)
|
||||
except ValueError:
|
||||
self.ap.logger.warning(f'Primary model {query.use_llm_model_uuid} not found')
|
||||
@@ -236,7 +241,10 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
fallback_uuids = (query.variables or {}).get('_fallback_model_uuids', [])
|
||||
for fb_uuid in fallback_uuids:
|
||||
try:
|
||||
fb_model = await self.ap.model_mgr.get_model_by_uuid(fb_uuid)
|
||||
fb_model = await self.ap.model_mgr.get_model_by_uuid(
|
||||
execution_context,
|
||||
fb_uuid,
|
||||
)
|
||||
candidates.append(fb_model)
|
||||
except ValueError:
|
||||
self.ap.logger.warning(f'Fallback model {fb_uuid} not found, skipping')
|
||||
@@ -346,12 +354,13 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
if kb_uuids and user_message_text:
|
||||
# only support text for now
|
||||
all_results: list[rag_context.RetrievalResultEntry] = []
|
||||
execution_context = get_query_execution_context(query)
|
||||
|
||||
kb_engine_plugins: set[str] = set()
|
||||
|
||||
# Retrieve from each knowledge base
|
||||
for kb_uuid in kb_uuids:
|
||||
kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)
|
||||
kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(execution_context, kb_uuid)
|
||||
|
||||
if not kb:
|
||||
self.ap.logger.warning(f'Knowledge base {kb_uuid} not found, skipping')
|
||||
@@ -364,6 +373,7 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
kb_engine_plugins.add(engine_plugin_id)
|
||||
|
||||
result = await kb.retrieve(
|
||||
execution_context,
|
||||
user_message_text,
|
||||
settings={
|
||||
'bot_uuid': query.bot_uuid or '',
|
||||
@@ -398,7 +408,10 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
)
|
||||
if all_results and rerank_model_uuid:
|
||||
try:
|
||||
rerank_model = await self.ap.model_mgr.get_rerank_model_by_uuid(rerank_model_uuid)
|
||||
rerank_model = await self.ap.model_mgr.get_rerank_model_by_uuid(
|
||||
execution_context,
|
||||
rerank_model_uuid,
|
||||
)
|
||||
rerank_top_k = int(local_agent_config.get('rerank-top-k', 5))
|
||||
|
||||
doc_texts = []
|
||||
@@ -411,6 +424,7 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
model=rerank_model,
|
||||
query=user_message_text,
|
||||
documents=doc_texts_capped,
|
||||
execution_context=execution_context,
|
||||
)
|
||||
|
||||
scored = sorted(scores, key=lambda x: x.get('relevance_score', 0), reverse=True)
|
||||
|
||||
@@ -12,6 +12,8 @@ from ...core import app
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
_MAX_N8N_RESPONSE_CHARS = 1024 * 1024
|
||||
|
||||
|
||||
class N8nAPIError(Exception):
|
||||
"""N8n API 请求失败"""
|
||||
@@ -94,6 +96,8 @@ class N8nServiceAPIRunner(runner.RequestRunner):
|
||||
else:
|
||||
chunk_str = str(raw_chunk)
|
||||
|
||||
if len(full_text) + len(chunk_str) > _MAX_N8N_RESPONSE_CHARS:
|
||||
raise N8nAPIError('n8n response exceeds the runtime limit')
|
||||
full_text += chunk_str
|
||||
buffer += chunk_str
|
||||
|
||||
@@ -112,7 +116,9 @@ class N8nServiceAPIRunner(runner.RequestRunner):
|
||||
|
||||
if obj.get('type') == 'item' and 'content' in obj:
|
||||
chunk_idx += 1
|
||||
content = obj['content']
|
||||
content = str(obj['content'])
|
||||
if len(full_content) + len(content) > _MAX_N8N_RESPONSE_CHARS:
|
||||
raise N8nAPIError('n8n response exceeds the runtime limit')
|
||||
full_content += content
|
||||
elif obj.get('type') == 'end':
|
||||
is_final = True
|
||||
@@ -128,6 +134,8 @@ class N8nServiceAPIRunner(runner.RequestRunner):
|
||||
except json.JSONDecodeError:
|
||||
# buffer 末尾可能是一个不完整的 JSON,等待更多数据
|
||||
break
|
||||
except N8nAPIError:
|
||||
raise
|
||||
except Exception as e:
|
||||
# 记录解析失败并继续接收后续 chunk
|
||||
try:
|
||||
@@ -255,7 +263,12 @@ class N8nServiceAPIRunner(runner.RequestRunner):
|
||||
self.webhook_url, json=payload, headers=headers, auth=auth, timeout=self.timeout
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
error_text = await response.text()
|
||||
error_text = (
|
||||
await httpclient.read_limited(
|
||||
response,
|
||||
max_bytes=_MAX_N8N_RESPONSE_CHARS,
|
||||
)
|
||||
).decode('utf-8', errors='replace')
|
||||
self.ap.logger.error(f'n8n webhook call failed: {response.status}, {error_text}')
|
||||
raise Exception(f'n8n webhook call failed: {response.status}, {error_text}')
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import typing
|
||||
import json
|
||||
import base64
|
||||
import logging
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
@@ -11,10 +12,13 @@ from tboxsdk.model.file import File, FileType
|
||||
|
||||
from .. import runner
|
||||
from ...core import app
|
||||
from ...utils import image
|
||||
from ...utils import bounded_executor, image
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
_MAX_TBOX_RESPONSE_CHARS = 1024 * 1024
|
||||
_MAX_TBOX_MEDIA_BYTES = 10 * 1024 * 1024
|
||||
|
||||
|
||||
class TboxAPIError(Exception):
|
||||
"""TBox API 请求失败"""
|
||||
@@ -24,6 +28,19 @@ class TboxAPIError(Exception):
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
def _append_bounded(current: str, addition: typing.Any) -> str:
|
||||
addition = str(addition or '')
|
||||
if len(current) + len(addition) > _MAX_TBOX_RESPONSE_CHARS:
|
||||
raise TboxAPIError('Tbox response exceeds the runtime limit')
|
||||
return current + addition
|
||||
|
||||
|
||||
def _write_temp_media(file_bytes: bytes, suffix: str) -> str:
|
||||
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp_file:
|
||||
tmp_file.write(file_bytes)
|
||||
return tmp_file.name
|
||||
|
||||
|
||||
@runner.runner_class('tbox-app-api')
|
||||
class TboxAPIRunner(runner.RequestRunner):
|
||||
"蚂蚁百宝箱API对话请求器"
|
||||
@@ -42,6 +59,7 @@ class TboxAPIRunner(runner.RequestRunner):
|
||||
self.api_key = self.pipeline_config['ai']['tbox-app-api']['api-key']
|
||||
|
||||
# 初始化Tbox client
|
||||
logging.getLogger('tbox.client').setLevel(logging.WARNING)
|
||||
self.tbox_client = TboxClient(authorization=self.api_key)
|
||||
|
||||
async def _preprocess_user_message(self, query: pipeline_query.Query) -> tuple[str, list[str]]:
|
||||
@@ -59,19 +77,29 @@ class TboxAPIRunner(runner.RequestRunner):
|
||||
plain_text += ce.text
|
||||
elif ce.type == 'image_base64':
|
||||
image_b64, image_format = await image.extract_b64_and_format(ce.image_base64)
|
||||
# 创建临时文件
|
||||
file_bytes = base64.b64decode(image_b64)
|
||||
file_bytes = await image.decode_base64_limited(
|
||||
image_b64,
|
||||
max_bytes=_MAX_TBOX_MEDIA_BYTES,
|
||||
)
|
||||
tmp_file_path: str | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix=f'.{image_format}', delete=False) as tmp_file:
|
||||
tmp_file.write(file_bytes)
|
||||
tmp_file_path = tmp_file.name
|
||||
file_upload_resp = self.tbox_client.upload_file(tmp_file_path)
|
||||
tmp_file_path = await asyncio.to_thread(
|
||||
_write_temp_media,
|
||||
file_bytes,
|
||||
f'.{image_format}',
|
||||
)
|
||||
file_upload_resp = await asyncio.to_thread(
|
||||
self.tbox_client.upload_file,
|
||||
tmp_file_path,
|
||||
)
|
||||
image_id = file_upload_resp.get('data', '')
|
||||
image_ids.append(image_id)
|
||||
finally:
|
||||
# 清理临时文件
|
||||
if os.path.exists(tmp_file_path):
|
||||
os.unlink(tmp_file_path)
|
||||
if tmp_file_path and os.path.exists(tmp_file_path):
|
||||
await bounded_executor.run_blocking_cleanup(
|
||||
os.unlink,
|
||||
tmp_file_path,
|
||||
)
|
||||
elif isinstance(query.user_message.content, str):
|
||||
plain_text = query.user_message.content
|
||||
|
||||
@@ -98,18 +126,23 @@ class TboxAPIRunner(runner.RequestRunner):
|
||||
files = [File(file_id=image_id, type=FileType.IMAGE) for image_id in image_ids]
|
||||
|
||||
# 发送对话请求
|
||||
response = self.tbox_client.chat(
|
||||
app_id=self.app_id, # Tbox中智能体应用的ID
|
||||
user_id=query.bot_uuid, # 用户ID
|
||||
query=plain_text, # 用户输入的文本信息
|
||||
stream=is_stream, # 是否流式输出
|
||||
conversation_id=conversation_id, # 会话ID,为None时Tbox会自动创建一个新会话
|
||||
files=files, # 图片内容
|
||||
response = await asyncio.to_thread(
|
||||
self.tbox_client.chat,
|
||||
app_id=self.app_id,
|
||||
user_id=query.bot_uuid,
|
||||
query=plain_text,
|
||||
stream=is_stream,
|
||||
conversation_id=conversation_id,
|
||||
files=files,
|
||||
)
|
||||
|
||||
if is_stream:
|
||||
# 解析Tbox流式输出内容,并发送给上游
|
||||
for chunk in self._process_stream_message(response, query, remove_think):
|
||||
async for chunk in self._process_stream_message(
|
||||
response,
|
||||
query,
|
||||
remove_think,
|
||||
):
|
||||
yield chunk
|
||||
else:
|
||||
message = self._process_non_stream_message(response, query, remove_think)
|
||||
@@ -127,13 +160,16 @@ class TboxAPIRunner(runner.RequestRunner):
|
||||
thinking_content = payload.get('reasoningContent', [])
|
||||
result = ''
|
||||
if thinking_content and not remove_think:
|
||||
result += f'<think>\n{thinking_content[0].get("text", "")}\n</think>\n'
|
||||
result = _append_bounded(
|
||||
result,
|
||||
f'<think>\n{thinking_content[0].get("text", "")}\n</think>\n',
|
||||
)
|
||||
content = payload.get('result', [])
|
||||
if content:
|
||||
result += content[0].get('chunk', '')
|
||||
result = _append_bounded(result, content[0].get('chunk', ''))
|
||||
return result
|
||||
|
||||
def _process_stream_message(
|
||||
async def _process_stream_message(
|
||||
self, response: typing.Generator[dict], query: pipeline_query.Query, remove_think: bool
|
||||
):
|
||||
idx_msg = 0
|
||||
@@ -141,7 +177,7 @@ class TboxAPIRunner(runner.RequestRunner):
|
||||
conversation_id = None
|
||||
think_start = False
|
||||
think_end = False
|
||||
for chunk in response:
|
||||
async for chunk in runner.iterate_sync(response):
|
||||
if chunk.get('type', '') == 'chunk':
|
||||
"""
|
||||
Tbox返回的消息内容chunk结构
|
||||
@@ -149,7 +185,10 @@ class TboxAPIRunner(runner.RequestRunner):
|
||||
"""
|
||||
# 如果包含思考过程,拼接</think>
|
||||
if think_start and not think_end:
|
||||
pending_content += '\n</think>\n'
|
||||
pending_content = _append_bounded(
|
||||
pending_content,
|
||||
'\n</think>\n',
|
||||
)
|
||||
think_end = True
|
||||
|
||||
payload = chunk.get('payload', {})
|
||||
@@ -158,7 +197,10 @@ class TboxAPIRunner(runner.RequestRunner):
|
||||
query.session.using_conversation.uuid = conversation_id
|
||||
if payload.get('text'):
|
||||
idx_msg += 1
|
||||
pending_content += payload.get('text')
|
||||
pending_content = _append_bounded(
|
||||
pending_content,
|
||||
payload.get('text'),
|
||||
)
|
||||
elif chunk.get('type', '') == 'thinking' and not remove_think:
|
||||
"""
|
||||
Tbox返回的思考过程chunk结构
|
||||
@@ -170,9 +212,15 @@ class TboxAPIRunner(runner.RequestRunner):
|
||||
content = payload.get('ext_data', {}).get('text')
|
||||
if not think_start:
|
||||
think_start = True
|
||||
pending_content += f'<think>\n{content}'
|
||||
pending_content = _append_bounded(
|
||||
pending_content,
|
||||
f'<think>\n{content}',
|
||||
)
|
||||
else:
|
||||
pending_content += content
|
||||
pending_content = _append_bounded(
|
||||
pending_content,
|
||||
content,
|
||||
)
|
||||
elif chunk.get('type', '') == 'error':
|
||||
raise TboxAPIError(
|
||||
f'Tbox API 请求失败: status_code={chunk.get("status_code")} message={chunk.get("message")} request_id={chunk.get("request_id")} '
|
||||
|
||||
@@ -10,6 +10,15 @@ import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
from langbot.libs.weknora_api import client, errors
|
||||
|
||||
_MAX_WEKNORA_GENERATED_CHARS = 1024 * 1024
|
||||
|
||||
|
||||
def _append_bounded(current: str, addition: typing.Any) -> str:
|
||||
addition = str(addition or '')
|
||||
if len(current) + len(addition) > _MAX_WEKNORA_GENERATED_CHARS:
|
||||
raise errors.WeKnoraAPIError('WeKnora response exceeds the runtime limit')
|
||||
return current + addition
|
||||
|
||||
|
||||
@runner.runner_class('weknora-api')
|
||||
class WeKnoraAPIRunner(runner.RequestRunner):
|
||||
@@ -94,7 +103,7 @@ class WeKnoraAPIRunner(runner.RequestRunner):
|
||||
web_search_enabled=web_search_enabled,
|
||||
timeout=timeout,
|
||||
):
|
||||
self.ap.logger.debug('weknora-agent-chunk: ' + str(chunk))
|
||||
self.ap.logger.debug('weknora-agent-chunk: ' + str(chunk)[:1000])
|
||||
|
||||
response_type = chunk.get('response_type', '')
|
||||
content = chunk.get('content', '')
|
||||
@@ -120,7 +129,7 @@ class WeKnoraAPIRunner(runner.RequestRunner):
|
||||
|
||||
elif response_type == 'answer':
|
||||
if content:
|
||||
full_answer += content
|
||||
full_answer = _append_bounded(full_answer, content)
|
||||
|
||||
elif response_type == 'error':
|
||||
raise errors.WeKnoraAPIError(f'WeKnora 服务错误: {content}')
|
||||
@@ -158,14 +167,14 @@ class WeKnoraAPIRunner(runner.RequestRunner):
|
||||
knowledge_base_ids=knowledge_base_ids,
|
||||
timeout=timeout,
|
||||
):
|
||||
self.ap.logger.debug('weknora-chat-chunk: ' + str(chunk))
|
||||
self.ap.logger.debug('weknora-chat-chunk: ' + str(chunk)[:1000])
|
||||
|
||||
response_type = chunk.get('response_type', '')
|
||||
content = chunk.get('content', '')
|
||||
|
||||
if response_type == 'answer':
|
||||
if content:
|
||||
full_answer += content
|
||||
full_answer = _append_bounded(full_answer, content)
|
||||
|
||||
elif response_type == 'error':
|
||||
raise errors.WeKnoraAPIError(f'WeKnora 服务错误: {content}')
|
||||
@@ -207,7 +216,7 @@ class WeKnoraAPIRunner(runner.RequestRunner):
|
||||
web_search_enabled=web_search_enabled,
|
||||
timeout=timeout,
|
||||
):
|
||||
self.ap.logger.debug('weknora-agent-chunk: ' + str(chunk))
|
||||
self.ap.logger.debug('weknora-agent-chunk: ' + str(chunk)[:1000])
|
||||
|
||||
response_type = chunk.get('response_type', '')
|
||||
content = chunk.get('content', '')
|
||||
@@ -235,7 +244,7 @@ class WeKnoraAPIRunner(runner.RequestRunner):
|
||||
elif response_type == 'answer':
|
||||
message_idx += 1
|
||||
if content:
|
||||
pending_answer += content
|
||||
pending_answer = _append_bounded(pending_answer, content)
|
||||
|
||||
if done:
|
||||
is_final = True
|
||||
@@ -288,7 +297,7 @@ class WeKnoraAPIRunner(runner.RequestRunner):
|
||||
knowledge_base_ids=knowledge_base_ids,
|
||||
timeout=timeout,
|
||||
):
|
||||
self.ap.logger.debug('weknora-chat-chunk: ' + str(chunk))
|
||||
self.ap.logger.debug('weknora-chat-chunk: ' + str(chunk)[:1000])
|
||||
|
||||
response_type = chunk.get('response_type', '')
|
||||
content = chunk.get('content', '')
|
||||
@@ -297,7 +306,7 @@ class WeKnoraAPIRunner(runner.RequestRunner):
|
||||
if response_type == 'answer':
|
||||
message_idx += 1
|
||||
if content:
|
||||
pending_answer += content
|
||||
pending_answer = _append_bounded(pending_answer, content)
|
||||
|
||||
if done:
|
||||
is_final = True
|
||||
|
||||
Reference in New Issue
Block a user