mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-27 20:57:13 +00:00
Compare commits
8 Commits
f0ee57c1e0
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| aeff8d7e30 | |||
| be3734ffda | |||
| 855ae2bdba | |||
| b66db86bff | |||
| 95b8736e93 | |||
| cabde423a1 | |||
| 08307790e5 | |||
| 777fe1f20b |
@@ -75,6 +75,8 @@ shape as the corresponding HTTP API request body. Discover resources with the
|
|||||||
`list_*` / `get_*` tools before mutating; identifiers are UUIDs. Reads require
|
`list_*` / `get_*` tools before mutating; identifiers are UUIDs. Reads require
|
||||||
`resource.view`; mutations require `resource.manage`. All service calls inherit
|
`resource.view`; mutations require `resource.manage`. All service calls inherit
|
||||||
the immutable Workspace context authenticated at the MCP transport boundary.
|
the immutable Workspace context authenticated at the MCP transport boundary.
|
||||||
|
Pass `is_default: true` to `create_pipeline` only when the Workspace does not
|
||||||
|
already have a default pipeline.
|
||||||
|
|
||||||
## How to use
|
## How to use
|
||||||
|
|
||||||
|
|||||||
@@ -295,6 +295,34 @@ class WecomCSClient:
|
|||||||
raise Exception('Failed to send message')
|
raise Exception('Failed to send message')
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
@_bounded_token_retry
|
||||||
|
async def send_image_msg(self, open_kfid: str, external_userid: str, msgid: str, media_id: str):
|
||||||
|
if not await self.check_access_token():
|
||||||
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
|
|
||||||
|
url = f'{self.base_url}/kf/send_msg?access_token={self.access_token}'
|
||||||
|
payload = {
|
||||||
|
'touser': external_userid,
|
||||||
|
'open_kfid': open_kfid,
|
||||||
|
'msgid': msgid,
|
||||||
|
'msgtype': 'image',
|
||||||
|
'image': {
|
||||||
|
'media_id': media_id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async with self._http_client_context() as client:
|
||||||
|
response = await client.post(url, json=payload)
|
||||||
|
|
||||||
|
data = await httpclient.parse_json_response(response)
|
||||||
|
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||||
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
|
return await self.send_image_msg(open_kfid, external_userid, msgid, media_id)
|
||||||
|
if data['errcode'] != 0:
|
||||||
|
await self.logger.error(f'发送图片失败:{data}')
|
||||||
|
raise Exception('Failed to send image message')
|
||||||
|
return data
|
||||||
|
|
||||||
async def handle_callback_request(self):
|
async def handle_callback_request(self):
|
||||||
"""处理回调请求(独立端口模式,使用全局 request)。"""
|
"""处理回调请求(独立端口模式,使用全局 request)。"""
|
||||||
return await self._handle_callback_internal(request)
|
return await self._handle_callback_internal(request)
|
||||||
|
|||||||
@@ -39,7 +39,13 @@ class PipelinesRouterGroup(group.RouterGroup):
|
|||||||
permission=Permission.RESOURCE_MANAGE,
|
permission=Permission.RESOURCE_MANAGE,
|
||||||
)
|
)
|
||||||
async def _(request_context: RequestContext) -> str:
|
async def _(request_context: RequestContext) -> str:
|
||||||
pipeline_uuid = await self.ap.pipeline_service.create_pipeline(request_context, await quart.request.json)
|
pipeline_data = await quart.request.json
|
||||||
|
create_as_default = pipeline_data.get('is_default') is True
|
||||||
|
pipeline_uuid = await self.ap.pipeline_service.create_pipeline(
|
||||||
|
request_context,
|
||||||
|
pipeline_data,
|
||||||
|
default=create_as_default,
|
||||||
|
)
|
||||||
return self.success(data={'uuid': pipeline_uuid})
|
return self.success(data={'uuid': pipeline_uuid})
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
|
|||||||
@@ -113,6 +113,24 @@ class BotsRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
return self.success(data={'sent': True})
|
return self.success(data={'sent': True})
|
||||||
|
|
||||||
|
@self.route(
|
||||||
|
'/<bot_uuid>/test-inbound',
|
||||||
|
methods=['POST'],
|
||||||
|
auth_type=group.AuthType.USER_TOKEN,
|
||||||
|
permission=Permission.RESOURCE_MANAGE,
|
||||||
|
)
|
||||||
|
async def _(bot_uuid: str, request_context: RequestContext) -> str:
|
||||||
|
json_data = await quart.request.get_json(silent=True) or {}
|
||||||
|
try:
|
||||||
|
result = await self.ap.bot_service.send_http_bot_test_message(
|
||||||
|
request_context,
|
||||||
|
bot_uuid,
|
||||||
|
str(json_data.get('message') or ''),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return self.http_status(400, -1, str(exc))
|
||||||
|
return self.success(data=result)
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
'/<bot_uuid>/admins',
|
'/<bot_uuid>/admins',
|
||||||
methods=['GET'],
|
methods=['GET'],
|
||||||
|
|||||||
@@ -206,6 +206,20 @@ class SystemRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
return self.success(data={})
|
return self.success(data={})
|
||||||
|
|
||||||
|
@self.route(
|
||||||
|
'/wizard/recommended-model',
|
||||||
|
methods=['GET'],
|
||||||
|
auth_type=group.AuthType.USER_TOKEN,
|
||||||
|
permission=Permission.RESOURCE_MANAGE,
|
||||||
|
)
|
||||||
|
async def _(request_context: RequestContext) -> str:
|
||||||
|
"""Resolve Space's best available chat model to this Workspace."""
|
||||||
|
try:
|
||||||
|
model = await self.ap.space_service.get_recommended_chat_model(request_context)
|
||||||
|
except ValueError as exc:
|
||||||
|
return self.http_status(503, -1, str(exc))
|
||||||
|
return self.success(data=model)
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
'/tasks',
|
'/tasks',
|
||||||
methods=['GET'],
|
methods=['GET'],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
import json
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
|
||||||
from ....core import app
|
from ....core import app
|
||||||
@@ -8,6 +9,8 @@ from ....entity.persistence import bot as persistence_bot
|
|||||||
from ....entity.persistence import pipeline as persistence_pipeline
|
from ....entity.persistence import pipeline as persistence_pipeline
|
||||||
from ....workspace.errors import WorkspaceNotFoundError
|
from ....workspace.errors import WorkspaceNotFoundError
|
||||||
from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
||||||
|
from ....utils import httpclient
|
||||||
|
from ....platform.sources import http_bot_signing
|
||||||
|
|
||||||
|
|
||||||
class BotService:
|
class BotService:
|
||||||
@@ -80,6 +83,7 @@ class BotService:
|
|||||||
'wecomcs',
|
'wecomcs',
|
||||||
'LINE',
|
'LINE',
|
||||||
'lark',
|
'lark',
|
||||||
|
'http_bot',
|
||||||
]:
|
]:
|
||||||
webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
|
webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
|
||||||
extra_webhook_prefix = self.ap.instance_config.data['api'].get('extra_webhook_prefix', '')
|
extra_webhook_prefix = self.ap.instance_config.data['api'].get('extra_webhook_prefix', '')
|
||||||
@@ -216,6 +220,53 @@ class BotService:
|
|||||||
|
|
||||||
return [log.to_json() for log in logs], total_count
|
return [log.to_json() for log in logs], total_count
|
||||||
|
|
||||||
|
async def send_http_bot_test_message(
|
||||||
|
self,
|
||||||
|
context: TenantContext,
|
||||||
|
bot_uuid: str,
|
||||||
|
message: str,
|
||||||
|
) -> dict:
|
||||||
|
"""Send a signed test message through the HTTP Bot public ingress."""
|
||||||
|
bot = await self.get_bot(context, bot_uuid, include_secret=True)
|
||||||
|
if bot is None:
|
||||||
|
raise WorkspaceNotFoundError('Bot not found')
|
||||||
|
if bot.get('adapter') != 'http_bot':
|
||||||
|
raise ValueError('Inbound test is only available for HTTP Bot')
|
||||||
|
if not bot.get('enable'):
|
||||||
|
raise ValueError('Bot must be enabled before sending a test message')
|
||||||
|
|
||||||
|
text = message.strip()
|
||||||
|
if not text or len(text) > 2000:
|
||||||
|
raise ValueError('Test message must contain 1 to 2000 characters')
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'session_id': f'wizard-{uuid.uuid4().hex}',
|
||||||
|
'sender': {'id': 'wizard-user', 'name': 'Wizard Test'},
|
||||||
|
'message': [{'type': 'Plain', 'text': text}],
|
||||||
|
}
|
||||||
|
body = json.dumps(payload, ensure_ascii=False, separators=(',', ':')).encode()
|
||||||
|
config = bot.get('adapter_config') or {}
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
if config.get('signature_required', True):
|
||||||
|
secret = str(config.get('inbound_secret') or '')
|
||||||
|
if not secret:
|
||||||
|
raise ValueError('HTTP Bot inbound signing secret is required')
|
||||||
|
timestamp, signature = http_bot_signing.sign(secret, body)
|
||||||
|
headers[http_bot_signing.HEADER_TIMESTAMP] = timestamp
|
||||||
|
headers[http_bot_signing.HEADER_SIGNATURE] = signature
|
||||||
|
|
||||||
|
port = int(self.ap.instance_config.data.get('api', {}).get('port', 5300))
|
||||||
|
session = httpclient.get_session()
|
||||||
|
async with session.post(
|
||||||
|
f'http://127.0.0.1:{port}/bots/{bot_uuid}',
|
||||||
|
data=body,
|
||||||
|
headers=headers,
|
||||||
|
) as response:
|
||||||
|
result = await httpclient.read_json_limited(response)
|
||||||
|
if response.status not in {200, 202}:
|
||||||
|
raise ValueError(result.get('msg') or f'HTTP Bot test failed with status {response.status}')
|
||||||
|
return result.get('data') or {}
|
||||||
|
|
||||||
async def send_message(
|
async def send_message(
|
||||||
self,
|
self,
|
||||||
context: TenantContext,
|
context: TenantContext,
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import sqlalchemy
|
|||||||
from ....core import app
|
from ....core import app
|
||||||
from ....entity.persistence import user
|
from ....entity.persistence import user
|
||||||
from ....entity.dto.space_model import SpaceModel
|
from ....entity.dto.space_model import SpaceModel
|
||||||
|
from ....entity.dto.space_model import SpaceModelSelection
|
||||||
|
from ....entity.persistence import model as persistence_model
|
||||||
|
from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER
|
||||||
|
|
||||||
|
|
||||||
_CREDITS_CACHE_TTL_SECONDS = 60
|
_CREDITS_CACHE_TTL_SECONDS = 60
|
||||||
@@ -238,3 +241,76 @@ class SpaceService:
|
|||||||
raise ValueError(f'Failed to get models: {data.get("msg")}')
|
raise ValueError(f'Failed to get models: {data.get("msg")}')
|
||||||
models_data = data.get('data', {}).get('models', [])
|
models_data = data.get('data', {}).get('models', [])
|
||||||
return [SpaceModel.model_validate(model_dict) for model_dict in models_data]
|
return [SpaceModel.model_validate(model_dict) for model_dict in models_data]
|
||||||
|
|
||||||
|
async def get_model_selection(self, category: str) -> typing.List[SpaceModelSelection]:
|
||||||
|
"""Return Space models in the availability-ranked selection order."""
|
||||||
|
space_url = self._get_space_config()['url']
|
||||||
|
session = httpclient.get_session()
|
||||||
|
async with session.get(
|
||||||
|
f'{space_url}/api/v1/models/selection',
|
||||||
|
params={'category': category},
|
||||||
|
) as response:
|
||||||
|
if response.status != 200:
|
||||||
|
error = await httpclient.read_text_limited(response)
|
||||||
|
raise ValueError(f'Failed to get model selection: {error}')
|
||||||
|
payload = await httpclient.read_json_limited(response)
|
||||||
|
if payload.get('code') != 0:
|
||||||
|
raise ValueError(f'Failed to get model selection: {payload.get("msg")}')
|
||||||
|
|
||||||
|
data = payload.get('data', [])
|
||||||
|
if isinstance(data, dict):
|
||||||
|
data = data.get('models', data.get('items', []))
|
||||||
|
if not isinstance(data, list):
|
||||||
|
raise ValueError('Failed to get model selection: invalid response')
|
||||||
|
|
||||||
|
models = []
|
||||||
|
for selection in data:
|
||||||
|
if isinstance(selection, dict) and isinstance(selection.get('model'), dict):
|
||||||
|
models.append(selection['model'])
|
||||||
|
else:
|
||||||
|
models.append(selection)
|
||||||
|
return [SpaceModelSelection.model_validate(model) for model in models]
|
||||||
|
|
||||||
|
async def get_recommended_chat_model(self, context: typing.Any) -> dict:
|
||||||
|
"""Resolve Space's first ranked chat model to a local Workspace model."""
|
||||||
|
selection = await self.get_model_selection('chat')
|
||||||
|
if not selection:
|
||||||
|
raise ValueError('No recommended chat model is available')
|
||||||
|
recommended = selection[0]
|
||||||
|
|
||||||
|
async def find_local_model():
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_model.LLMModel)
|
||||||
|
.join(
|
||||||
|
persistence_model.ModelProvider,
|
||||||
|
sqlalchemy.and_(
|
||||||
|
persistence_model.ModelProvider.workspace_uuid == persistence_model.LLMModel.workspace_uuid,
|
||||||
|
persistence_model.ModelProvider.uuid == persistence_model.LLMModel.provider_uuid,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
persistence_model.LLMModel.workspace_uuid == context.workspace_uuid,
|
||||||
|
persistence_model.ModelProvider.requester == LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||||
|
sqlalchemy.or_(
|
||||||
|
persistence_model.LLMModel.uuid == recommended.uuid,
|
||||||
|
persistence_model.LLMModel.name == recommended.model_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.first()
|
||||||
|
|
||||||
|
local_model = await find_local_model()
|
||||||
|
if local_model is None:
|
||||||
|
# OSS synchronizes the public catalog locally. Refresh once in case
|
||||||
|
# the recommendation was published after this process started.
|
||||||
|
from ..context import ExecutionContext
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.ap.model_mgr.sync_new_models_from_space(ExecutionContext.from_request(context))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
local_model = await find_local_model()
|
||||||
|
|
||||||
|
if local_model is None:
|
||||||
|
raise ValueError('Recommended chat model is not available in this Workspace')
|
||||||
|
return {'uuid': local_model.uuid, 'name': local_model.name}
|
||||||
|
|||||||
@@ -147,7 +147,16 @@ class LangBotMCPServer:
|
|||||||
)
|
)
|
||||||
async def create_pipeline(pipeline_data: dict) -> str:
|
async def create_pipeline(pipeline_data: dict) -> str:
|
||||||
context = _authorized(Permission.RESOURCE_MANAGE)
|
context = _authorized(Permission.RESOURCE_MANAGE)
|
||||||
return _dump({'uuid': await ap.pipeline_service.create_pipeline(context, pipeline_data)})
|
create_as_default = pipeline_data.get('is_default') is True
|
||||||
|
return _dump(
|
||||||
|
{
|
||||||
|
'uuid': await ap.pipeline_service.create_pipeline(
|
||||||
|
context,
|
||||||
|
pipeline_data,
|
||||||
|
default=create_as_default,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
@mcp.tool(description='Update a pipeline by UUID. `pipeline_data` matches the PUT body.')
|
@mcp.tool(description='Update a pipeline by UUID. `pipeline_data` matches the PUT body.')
|
||||||
async def update_pipeline(pipeline_uuid: str, pipeline_data: dict) -> str:
|
async def update_pipeline(pipeline_uuid: str, pipeline_data: dict) -> str:
|
||||||
|
|||||||
@@ -47,3 +47,10 @@ class SpaceModel(pydantic.BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
created_at: str | None = None
|
created_at: str | None = None
|
||||||
updated_at: str | None = None
|
updated_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SpaceModelSelection(pydantic.BaseModel):
|
||||||
|
"""Minimal model identity returned by the ranked selection endpoint."""
|
||||||
|
|
||||||
|
uuid: str
|
||||||
|
model_id: str
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ from .. import entities
|
|||||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||||
from ....utils.safe_regex import SafeRegexError, mask_patterns
|
from ....utils.safe_regex import SafeRegexError, mask_patterns
|
||||||
|
|
||||||
|
# Legacy sensitive-words.json files shipped ~70 rules, which exceeds the
|
||||||
|
# default safe_regex per-call cap of 64 and used to fail-close every message.
|
||||||
|
# Keep one 50ms CPU budget for the whole list; only raise the pattern cap.
|
||||||
|
_MAX_SENSITIVE_WORD_PATTERNS = 256
|
||||||
|
|
||||||
|
|
||||||
@filter_model.filter_class('ban-word-filter')
|
@filter_model.filter_class('ban-word-filter')
|
||||||
class BanWordFilter(filter_model.ContentFilter):
|
class BanWordFilter(filter_model.ContentFilter):
|
||||||
@@ -14,12 +19,17 @@ class BanWordFilter(filter_model.ContentFilter):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
async def process(self, query: pipeline_query.Query, message: str) -> entities.FilterResult:
|
async def process(self, query: pipeline_query.Query, message: str) -> entities.FilterResult:
|
||||||
|
words = self.ap.sensitive_meta.data.get('words') or []
|
||||||
|
mask = self.ap.sensitive_meta.data['mask']
|
||||||
|
mask_word = self.ap.sensitive_meta.data['mask_word']
|
||||||
|
|
||||||
try:
|
try:
|
||||||
found, message = await mask_patterns(
|
found, current = await mask_patterns(
|
||||||
self.ap.sensitive_meta.data['words'],
|
words,
|
||||||
message,
|
message,
|
||||||
mask=self.ap.sensitive_meta.data['mask'],
|
mask=mask,
|
||||||
mask_word=self.ap.sensitive_meta.data['mask_word'],
|
mask_word=mask_word,
|
||||||
|
max_pattern_count=_MAX_SENSITIVE_WORD_PATTERNS,
|
||||||
)
|
)
|
||||||
except SafeRegexError as exc:
|
except SafeRegexError as exc:
|
||||||
return entities.FilterResult(
|
return entities.FilterResult(
|
||||||
@@ -31,7 +41,7 @@ class BanWordFilter(filter_model.ContentFilter):
|
|||||||
|
|
||||||
return entities.FilterResult(
|
return entities.FilterResult(
|
||||||
level=entities.ResultLevel.MASKED if found else entities.ResultLevel.PASS,
|
level=entities.ResultLevel.MASKED if found else entities.ResultLevel.PASS,
|
||||||
replacement=message,
|
replacement=current,
|
||||||
user_notice='消息中存在不合适的内容, 请修改' if found else '',
|
user_notice='消息中存在不合适的内容, 请修改' if found else '',
|
||||||
console_notice='',
|
console_notice='',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from linebot.v3.webhooks import (
|
|||||||
ImageMessageContent,
|
ImageMessageContent,
|
||||||
VideoMessageContent,
|
VideoMessageContent,
|
||||||
AudioMessageContent,
|
AudioMessageContent,
|
||||||
|
UserMentionee,
|
||||||
)
|
)
|
||||||
|
|
||||||
# from linebot import WebhookParser
|
# from linebot import WebhookParser
|
||||||
@@ -58,15 +59,19 @@ class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
|||||||
|
|
||||||
return content_list
|
return content_list
|
||||||
|
|
||||||
@staticmethod
|
def __init__(self, bot_account_id: str = ''):
|
||||||
async def target2yiri(message, bot_client) -> platform_message.MessageChain:
|
self.bot_account_id = bot_account_id
|
||||||
|
|
||||||
|
async def target2yiri(self, message, bot_client) -> platform_message.MessageChain:
|
||||||
lb_msg_list = []
|
lb_msg_list = []
|
||||||
msg_create_time = datetime.datetime.fromtimestamp(int(message.timestamp) / 1000)
|
msg_create_time = datetime.datetime.fromtimestamp(int(message.timestamp) / 1000)
|
||||||
|
|
||||||
lb_msg_list.append(platform_message.Source(id=message.webhook_event_id, time=msg_create_time))
|
lb_msg_list.append(platform_message.Source(id=message.webhook_event_id, time=msg_create_time))
|
||||||
|
|
||||||
if isinstance(message.message, TextMessageContent):
|
if isinstance(message.message, TextMessageContent):
|
||||||
lb_msg_list.append(platform_message.Plain(text=message.message.text))
|
lb_msg_list.extend(
|
||||||
|
self._build_text_components(message.message.text, getattr(message.message, 'mention', None))
|
||||||
|
)
|
||||||
elif isinstance(message.message, AudioMessageContent):
|
elif isinstance(message.message, AudioMessageContent):
|
||||||
pass
|
pass
|
||||||
elif isinstance(message.message, VideoMessageContent):
|
elif isinstance(message.message, VideoMessageContent):
|
||||||
@@ -86,22 +91,60 @@ class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
|||||||
lb_msg_list.append(platform_message.Image(base64=data_uri))
|
lb_msg_list.append(platform_message.Image(base64=data_uri))
|
||||||
return platform_message.MessageChain(lb_msg_list)
|
return platform_message.MessageChain(lb_msg_list)
|
||||||
|
|
||||||
|
def _build_text_components(self, text: str, mention) -> list:
|
||||||
|
"""Build message components from text, inserting At components for mentions.
|
||||||
|
|
||||||
|
LINE provides mention positions (index/length) and is_self per mentionee in the
|
||||||
|
webhook payload. Mapping the bot mention to At(target=bot_account_id) makes the
|
||||||
|
'at-bot' group respond rule work for LINE, consistent with other adapters.
|
||||||
|
"""
|
||||||
|
components: list = []
|
||||||
|
if not mention or not mention.mentionees:
|
||||||
|
if text:
|
||||||
|
components.append(platform_message.Plain(text=text))
|
||||||
|
return components
|
||||||
|
segments: list[tuple[int, int, object]] = sorted((m.index, m.index + m.length, m) for m in mention.mentionees)
|
||||||
|
cursor = 0
|
||||||
|
for start, end, mentionee in segments:
|
||||||
|
if start < cursor:
|
||||||
|
start, end = cursor, min(end, len(text))
|
||||||
|
if start < cursor or end <= start or end > len(text):
|
||||||
|
continue
|
||||||
|
if start > cursor:
|
||||||
|
components.append(platform_message.Plain(text=text[cursor:start]))
|
||||||
|
if isinstance(mentionee, UserMentionee):
|
||||||
|
target = self.bot_account_id if mentionee.is_self else mentionee.user_id
|
||||||
|
if not target:
|
||||||
|
target = text[start:end]
|
||||||
|
else:
|
||||||
|
target = text[start:end]
|
||||||
|
# At.__str__ already prepends '@', so strip one from the LINE text token.
|
||||||
|
display = text[start:end].lstrip('@')
|
||||||
|
components.append(platform_message.At(target=str(target), display=display))
|
||||||
|
cursor = end
|
||||||
|
if cursor < len(text):
|
||||||
|
components.append(platform_message.Plain(text=text[cursor:]))
|
||||||
|
return components
|
||||||
|
|
||||||
|
|
||||||
class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||||
|
def __init__(self, bot_account_id: str = ''):
|
||||||
|
self.bot_account_id = bot_account_id
|
||||||
|
self.message_converter = LINEMessageConverter(bot_account_id)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def yiri2target(
|
async def yiri2target(
|
||||||
event: platform_events.MessageEvent,
|
event: platform_events.MessageEvent,
|
||||||
) -> MessageEvent:
|
) -> MessageEvent:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@staticmethod
|
async def target2yiri(self, event, bot_client) -> platform_events.Event:
|
||||||
async def target2yiri(event, bot_client) -> platform_events.Event:
|
message_chain = await self.message_converter.target2yiri(event, bot_client)
|
||||||
message_chain = await LINEMessageConverter.target2yiri(event, bot_client)
|
|
||||||
|
|
||||||
if event.source.type == 'user':
|
if event.source.type == 'user':
|
||||||
return platform_events.FriendMessage(
|
return platform_events.FriendMessage(
|
||||||
sender=platform_entities.Friend(
|
sender=platform_entities.Friend(
|
||||||
id=event.message.id,
|
id=event.source.user_id,
|
||||||
nickname=event.source.user_id,
|
nickname=event.source.user_id,
|
||||||
remark='',
|
remark='',
|
||||||
),
|
),
|
||||||
@@ -110,13 +153,19 @@ class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
source_platform_object=event,
|
source_platform_object=event,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
# 'group' and 'room' sources carry the stable chat id under different
|
||||||
|
# field names; user_id may be absent for some members, so fall back
|
||||||
|
# to the group/room id rather than the per-message id.
|
||||||
|
group_id = event.source.group_id if event.source.type == 'group' else event.source.room_id
|
||||||
|
member_id = event.source.user_id or group_id
|
||||||
|
|
||||||
return platform_events.GroupMessage(
|
return platform_events.GroupMessage(
|
||||||
sender=platform_entities.GroupMember(
|
sender=platform_entities.GroupMember(
|
||||||
id=event.event.sender.sender_id.open_id,
|
id=member_id,
|
||||||
member_name=event.event.sender.sender_id.union_id,
|
member_name=member_id,
|
||||||
permission=platform_entities.Permission.Member,
|
permission=platform_entities.Permission.Member,
|
||||||
group=platform_entities.Group(
|
group=platform_entities.Group(
|
||||||
id=event.message.id,
|
id=group_id,
|
||||||
name='',
|
name='',
|
||||||
permission=platform_entities.Permission.Member,
|
permission=platform_entities.Permission.Member,
|
||||||
),
|
),
|
||||||
@@ -163,8 +212,8 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
listeners={},
|
listeners={},
|
||||||
card_id_dict={},
|
card_id_dict={},
|
||||||
seq=1,
|
seq=1,
|
||||||
event_converter=LINEEventConverter(),
|
event_converter=LINEEventConverter(bot_account_id),
|
||||||
message_converter=LINEMessageConverter(),
|
message_converter=LINEMessageConverter(bot_account_id),
|
||||||
line_webhook=line_webhook,
|
line_webhook=line_webhook,
|
||||||
parser=parser,
|
parser=parser,
|
||||||
configuration=configuration,
|
configuration=configuration,
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
if event.type == 'text':
|
if event.type == 'text':
|
||||||
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
|
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
|
||||||
friend = platform_entities.Friend(
|
friend = platform_entities.Friend(
|
||||||
id=f'u{event.user_id}',
|
id=f'{event.receiver_id}|u{event.user_id}',
|
||||||
nickname=nickname,
|
nickname=nickname,
|
||||||
remark='',
|
remark='',
|
||||||
)
|
)
|
||||||
@@ -117,7 +117,7 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
)
|
)
|
||||||
elif event.type == 'image':
|
elif event.type == 'image':
|
||||||
friend = platform_entities.Friend(
|
friend = platform_entities.Friend(
|
||||||
id=f'u{event.user_id}',
|
id=f'{event.receiver_id}|u{event.user_id}',
|
||||||
nickname=nickname,
|
nickname=nickname,
|
||||||
remark='',
|
remark='',
|
||||||
)
|
)
|
||||||
@@ -197,7 +197,7 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
|
|
||||||
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
||||||
for content in content_list:
|
for content in content_list:
|
||||||
msgid = f'langbot_{uuid.uuid4().hex}'
|
msgid = f'{uuid.uuid4().hex}'
|
||||||
if content['type'] == 'text':
|
if content['type'] == 'text':
|
||||||
await self.bot.send_text_msg(
|
await self.bot.send_text_msg(
|
||||||
open_kfid=open_kfid,
|
open_kfid=open_kfid,
|
||||||
@@ -205,6 +205,13 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
msgid=msgid,
|
msgid=msgid,
|
||||||
content=content['content'],
|
content=content['content'],
|
||||||
)
|
)
|
||||||
|
elif content['type'] == 'image':
|
||||||
|
await self.bot.send_image_msg(
|
||||||
|
open_kfid=open_kfid,
|
||||||
|
external_userid=external_userid,
|
||||||
|
msgid=msgid,
|
||||||
|
media_id=content['media_id'],
|
||||||
|
)
|
||||||
|
|
||||||
def set_bot_uuid(self, bot_uuid: str):
|
def set_bot_uuid(self, bot_uuid: str):
|
||||||
"""设置 bot UUID(用于生成 webhook URL)"""
|
"""设置 bot UUID(用于生成 webhook URL)"""
|
||||||
|
|||||||
@@ -747,9 +747,24 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
converted_parts = []
|
converted_parts = []
|
||||||
for part in content:
|
for part in content:
|
||||||
if isinstance(part, dict) and part.get('type') == 'image_base64':
|
if isinstance(part, dict) and part.get('type') == 'image_base64':
|
||||||
part['image_url'] = {'url': part['image_base64']}
|
# History trimming (SessionManager) clears image_base64
|
||||||
|
# on past turns and exclude_none serialization drops
|
||||||
|
# the key entirely, so the replayed part may carry no
|
||||||
|
# payload. Prefer the base64 payload; fall back to an
|
||||||
|
# image_url that survived on the same element; drop
|
||||||
|
# hollow parts instead of raising KeyError (#2469).
|
||||||
|
image_b64 = part.get('image_base64')
|
||||||
|
fallback_url = None
|
||||||
|
if not image_b64:
|
||||||
|
raw_image_url = part.get('image_url')
|
||||||
|
if isinstance(raw_image_url, dict):
|
||||||
|
fallback_url = raw_image_url.get('url')
|
||||||
|
if image_b64 or fallback_url:
|
||||||
|
part['image_url'] = {'url': image_b64 or fallback_url}
|
||||||
part['type'] = 'image_url'
|
part['type'] = 'image_url'
|
||||||
del part['image_base64']
|
part.pop('image_base64', None)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
# OpenAI-compatible chat models reject non-image file parts
|
# OpenAI-compatible chat models reject non-image file parts
|
||||||
# (audio/document base64 or url). These originate from Voice /
|
# (audio/document base64 or url). These originate from Voice /
|
||||||
# File attachments — including ones replayed from conversation
|
# File attachments — including ones replayed from conversation
|
||||||
|
|||||||
@@ -619,7 +619,9 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
and len(func_ret) > 0
|
and len(func_ret) > 0
|
||||||
and isinstance(func_ret[0], provider_message.ContentElement)
|
and isinstance(func_ret[0], provider_message.ContentElement)
|
||||||
):
|
):
|
||||||
tool_content = func_ret
|
# OpenAI-compatible APIs require tool-message content to be a
|
||||||
|
# string; a raw list of ContentElement causes HTTP 500 (#2457).
|
||||||
|
tool_content = '\n'.join(str(ce) for ce in func_ret)
|
||||||
else:
|
else:
|
||||||
tool_content = json.dumps(func_ret, ensure_ascii=False)
|
tool_content = json.dumps(func_ret, ensure_ascii=False)
|
||||||
|
|
||||||
|
|||||||
@@ -27,10 +27,16 @@ class SafeRegexTimeoutError(SafeRegexError):
|
|||||||
"""Raised when the regex engine exhausts the operation CPU budget."""
|
"""Raised when the regex engine exhausts the operation CPU budget."""
|
||||||
|
|
||||||
|
|
||||||
def _validate_patterns(patterns: Sequence[str]) -> tuple[str, ...]:
|
def _validate_patterns(
|
||||||
|
patterns: Sequence[str],
|
||||||
|
*,
|
||||||
|
max_pattern_count: int = MAX_PATTERN_COUNT,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
if max_pattern_count < 1:
|
||||||
|
raise ValueError('max_pattern_count must be positive')
|
||||||
|
if len(patterns) > max_pattern_count:
|
||||||
|
raise SafeRegexLimitError(f'At most {max_pattern_count} regex patterns are allowed')
|
||||||
normalized = tuple(patterns)
|
normalized = tuple(patterns)
|
||||||
if len(normalized) > MAX_PATTERN_COUNT:
|
|
||||||
raise SafeRegexLimitError(f'At most {MAX_PATTERN_COUNT} regex patterns are allowed')
|
|
||||||
for pattern in normalized:
|
for pattern in normalized:
|
||||||
if not isinstance(pattern, str):
|
if not isinstance(pattern, str):
|
||||||
raise SafeRegexError('Regex patterns must be strings')
|
raise SafeRegexError('Regex patterns must be strings')
|
||||||
@@ -115,8 +121,9 @@ def _mask_patterns_sync(
|
|||||||
mask: str,
|
mask: str,
|
||||||
mask_word: str,
|
mask_word: str,
|
||||||
timeout_seconds: float,
|
timeout_seconds: float,
|
||||||
|
max_pattern_count: int,
|
||||||
) -> tuple[bool, str]:
|
) -> tuple[bool, str]:
|
||||||
normalized_patterns = _validate_patterns(patterns)
|
normalized_patterns = _validate_patterns(patterns, max_pattern_count=max_pattern_count)
|
||||||
_validate_input(value)
|
_validate_input(value)
|
||||||
if len(mask) > MAX_REPLACEMENT_CHARS or len(mask_word) > MAX_REPLACEMENT_CHARS:
|
if len(mask) > MAX_REPLACEMENT_CHARS or len(mask_word) > MAX_REPLACEMENT_CHARS:
|
||||||
raise SafeRegexLimitError(f'Regex replacements may contain at most {MAX_REPLACEMENT_CHARS} characters')
|
raise SafeRegexLimitError(f'Regex replacements may contain at most {MAX_REPLACEMENT_CHARS} characters')
|
||||||
@@ -162,6 +169,7 @@ async def mask_patterns(
|
|||||||
mask: str,
|
mask: str,
|
||||||
mask_word: str,
|
mask_word: str,
|
||||||
timeout_seconds: float = DEFAULT_OPERATION_TIMEOUT_SECONDS,
|
timeout_seconds: float = DEFAULT_OPERATION_TIMEOUT_SECONDS,
|
||||||
|
max_pattern_count: int = MAX_PATTERN_COUNT,
|
||||||
) -> tuple[bool, str]:
|
) -> tuple[bool, str]:
|
||||||
"""Apply untrusted masking patterns with bounded CPU and output growth."""
|
"""Apply untrusted masking patterns with bounded CPU and output growth."""
|
||||||
|
|
||||||
@@ -174,4 +182,5 @@ async def mask_patterns(
|
|||||||
mask=mask,
|
mask=mask,
|
||||||
mask_word=mask_word,
|
mask_word=mask_word,
|
||||||
timeout_seconds=timeout_seconds,
|
timeout_seconds=timeout_seconds,
|
||||||
|
max_pattern_count=max_pattern_count,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
// Read config from script tag data attributes
|
// Read config from script tag data attributes
|
||||||
var scriptEl = document.currentScript;
|
var scriptEl = document.currentScript;
|
||||||
var scriptTitle = scriptEl ? scriptEl.getAttribute("data-title") : null;
|
var scriptTitle = scriptEl ? scriptEl.getAttribute("data-title") : null;
|
||||||
|
var scriptTestNotice = scriptEl
|
||||||
|
? scriptEl.getAttribute("data-test-notice")
|
||||||
|
: null;
|
||||||
|
|
||||||
// ========== i18n ==========
|
// ========== i18n ==========
|
||||||
var I18N = {
|
var I18N = {
|
||||||
@@ -192,6 +195,7 @@
|
|||||||
.lb-header-btn { background: none; border: none; color: #fff; cursor: pointer; padding: 4px; border-radius: 6px; display: flex; align-items: center; justify-content: center; opacity: 0.8; transition: opacity 0.15s; }\
|
.lb-header-btn { background: none; border: none; color: #fff; cursor: pointer; padding: 4px; border-radius: 6px; display: flex; align-items: center; justify-content: center; opacity: 0.8; transition: opacity 0.15s; }\
|
||||||
.lb-header-btn:hover { opacity: 1; }\
|
.lb-header-btn:hover { opacity: 1; }\
|
||||||
.lb-header-btn svg { width: 18px; height: 18px; fill: currentColor; }\
|
.lb-header-btn svg { width: 18px; height: 18px; fill: currentColor; }\
|
||||||
|
.lb-test-notice { padding: 8px 16px; border-bottom: 1px solid #fde68a; background: #fffbeb; color: #92400e; font-size: 12px; line-height: 1.5; text-align: center; flex-shrink: 0; }\
|
||||||
.lb-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 16px; scroll-behavior: smooth; }\
|
.lb-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 16px; scroll-behavior: smooth; }\
|
||||||
.lb-messages::-webkit-scrollbar { width: 6px; }\
|
.lb-messages::-webkit-scrollbar { width: 6px; }\
|
||||||
.lb-messages::-webkit-scrollbar-track { background: transparent; }\
|
.lb-messages::-webkit-scrollbar-track { background: transparent; }\
|
||||||
@@ -1240,6 +1244,14 @@
|
|||||||
// Root container
|
// Root container
|
||||||
var root = document.createElement("div");
|
var root = document.createElement("div");
|
||||||
root.id = "langbot-widget-root";
|
root.id = "langbot-widget-root";
|
||||||
|
root.langbotDestroy = function () {
|
||||||
|
wsDisconnect();
|
||||||
|
if (state.historyReloadTimer) {
|
||||||
|
clearTimeout(state.historyReloadTimer);
|
||||||
|
state.historyReloadTimer = null;
|
||||||
|
}
|
||||||
|
root.remove();
|
||||||
|
};
|
||||||
document.body.appendChild(root);
|
document.body.appendChild(root);
|
||||||
|
|
||||||
var shadow = root.attachShadow({ mode: "open" });
|
var shadow = root.attachShadow({ mode: "open" });
|
||||||
@@ -1328,6 +1340,14 @@
|
|||||||
header.appendChild(headerActions);
|
header.appendChild(headerActions);
|
||||||
panel.appendChild(header);
|
panel.appendChild(header);
|
||||||
|
|
||||||
|
if (scriptTestNotice) {
|
||||||
|
var testNotice = document.createElement("div");
|
||||||
|
testNotice.className = "lb-test-notice";
|
||||||
|
testNotice.setAttribute("role", "note");
|
||||||
|
testNotice.textContent = scriptTestNotice;
|
||||||
|
panel.appendChild(testNotice);
|
||||||
|
}
|
||||||
|
|
||||||
// Messages area
|
// Messages area
|
||||||
var messages = document.createElement("div");
|
var messages = document.createElement("div");
|
||||||
messages.className = "lb-messages";
|
messages.className = "lb-messages";
|
||||||
|
|||||||
@@ -325,7 +325,7 @@ stages:
|
|||||||
zh_Hans: API 密钥
|
zh_Hans: API 密钥
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: 'your-api-key'
|
default: ''
|
||||||
- name: n8n-service-api
|
- name: n8n-service-api
|
||||||
label:
|
label:
|
||||||
en_US: n8n Workflow API
|
en_US: n8n Workflow API
|
||||||
|
|||||||
@@ -254,6 +254,22 @@ class TestPipelinesCRUDEndpoints:
|
|||||||
assert data['code'] == 0
|
assert data['code'] == 0
|
||||||
assert 'uuid' in data['data']
|
assert 'uuid' in data['data']
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_default_pipeline_forwards_default_flag(self, quart_test_client, fake_pipeline_app):
|
||||||
|
"""POST /api/v1/pipelines explicitly creates a default pipeline."""
|
||||||
|
fake_pipeline_app.pipeline_service.create_pipeline.reset_mock()
|
||||||
|
|
||||||
|
response = await quart_test_client.post(
|
||||||
|
'/api/v1/pipelines',
|
||||||
|
headers={'Authorization': 'Bearer test_token'},
|
||||||
|
json={'name': 'Default Pipeline', 'config': {}, 'is_default': True},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
call = fake_pipeline_app.pipeline_service.create_pipeline.await_args
|
||||||
|
assert call.kwargs == {'default': True}
|
||||||
|
assert call.args[1]['is_default'] is True
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_update_pipeline_success(self, quart_test_client):
|
async def test_update_pipeline_success(self, quart_test_client):
|
||||||
"""PUT /api/v1/pipelines/{uuid} updates pipeline."""
|
"""PUT /api/v1/pipelines/{uuid} updates pipeline."""
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ Source: src/langbot/pkg/api/http/service/bot.py
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import AsyncMock, Mock, patch
|
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from langbot.pkg.api.http.service.bot import BotService
|
from langbot.pkg.api.http.service.bot import BotService
|
||||||
@@ -241,6 +242,29 @@ class TestBotServiceGetRuntimeBotInfo:
|
|||||||
assert result['adapter_runtime_values']['webhook_url'] == '/bots/wecom-uuid'
|
assert result['adapter_runtime_values']['webhook_url'] == '/bots/wecom-uuid'
|
||||||
assert result['adapter_runtime_values']['webhook_full_url'] == 'http://127.0.0.1:5300/bots/wecom-uuid'
|
assert result['adapter_runtime_values']['webhook_full_url'] == 'http://127.0.0.1:5300/bots/wecom-uuid'
|
||||||
|
|
||||||
|
async def test_get_runtime_bot_info_returns_webhook_for_http_bot(self):
|
||||||
|
ap = SimpleNamespace(
|
||||||
|
instance_config=SimpleNamespace(
|
||||||
|
data={'api': {'webhook_prefix': 'https://bot.example.com'}}
|
||||||
|
),
|
||||||
|
platform_mgr=SimpleNamespace(get_bot_by_uuid=AsyncMock(return_value=None)),
|
||||||
|
)
|
||||||
|
service = BotService(ap)
|
||||||
|
service.get_bot = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
'uuid': 'http-bot-uuid',
|
||||||
|
'name': 'HTTP Bot',
|
||||||
|
'adapter': 'http_bot',
|
||||||
|
'adapter_config': {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await service.get_runtime_bot_info(WORKSPACE_UUID, 'http-bot-uuid')
|
||||||
|
|
||||||
|
assert result['adapter_runtime_values']['webhook_full_url'] == (
|
||||||
|
'https://bot.example.com/bots/http-bot-uuid'
|
||||||
|
)
|
||||||
|
|
||||||
async def test_get_runtime_bot_info_no_webhook_for_telegram(self):
|
async def test_get_runtime_bot_info_no_webhook_for_telegram(self):
|
||||||
"""Returns no webhook URL for non-webhook adapters like telegram."""
|
"""Returns no webhook URL for non-webhook adapters like telegram."""
|
||||||
# Setup
|
# Setup
|
||||||
@@ -605,6 +629,77 @@ class TestBotServiceListEventLogs:
|
|||||||
assert total == 5
|
assert total == 5
|
||||||
|
|
||||||
|
|
||||||
|
class TestBotServiceHttpBotInboundTest:
|
||||||
|
async def test_sends_signed_message_through_public_ingress(self):
|
||||||
|
ap = SimpleNamespace(
|
||||||
|
instance_config=SimpleNamespace(data={'api': {'port': 5300}}),
|
||||||
|
)
|
||||||
|
service = BotService(ap)
|
||||||
|
service.get_bot = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
'uuid': 'http-bot-uuid',
|
||||||
|
'adapter': 'http_bot',
|
||||||
|
'adapter_config': {
|
||||||
|
'signature_required': True,
|
||||||
|
'inbound_secret': 'test-secret',
|
||||||
|
},
|
||||||
|
'enable': True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response = MagicMock(status=202)
|
||||||
|
session = MagicMock()
|
||||||
|
session.post.return_value.__aenter__ = AsyncMock(return_value=response)
|
||||||
|
session.post.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch('langbot.pkg.api.http.service.bot.httpclient.get_session', return_value=session),
|
||||||
|
patch(
|
||||||
|
'langbot.pkg.api.http.service.bot.httpclient.read_json_limited',
|
||||||
|
new=AsyncMock(
|
||||||
|
return_value={
|
||||||
|
'code': 0,
|
||||||
|
'data': {
|
||||||
|
'session_id': 'wizard-session',
|
||||||
|
'accepted_message_id': 'in-message',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = await service.send_http_bot_test_message(
|
||||||
|
WORKSPACE_UUID,
|
||||||
|
'http-bot-uuid',
|
||||||
|
'hello',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result['accepted_message_id'] == 'in-message'
|
||||||
|
request = session.post.call_args
|
||||||
|
assert request.args[0] == 'http://127.0.0.1:5300/bots/http-bot-uuid'
|
||||||
|
payload = json.loads(request.kwargs['data'])
|
||||||
|
assert payload['message'] == [{'type': 'Plain', 'text': 'hello'}]
|
||||||
|
headers = request.kwargs['headers']
|
||||||
|
assert headers['X-LB-Timestamp']
|
||||||
|
assert headers['X-LB-Signature'].startswith('sha256=')
|
||||||
|
|
||||||
|
async def test_rejects_non_http_bot(self):
|
||||||
|
service = BotService(SimpleNamespace())
|
||||||
|
service.get_bot = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
'uuid': 'telegram-bot',
|
||||||
|
'adapter': 'telegram',
|
||||||
|
'adapter_config': {},
|
||||||
|
'enable': True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match='only available for HTTP Bot'):
|
||||||
|
await service.send_http_bot_test_message(
|
||||||
|
WORKSPACE_UUID,
|
||||||
|
'telegram-bot',
|
||||||
|
'hello',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestBotServiceSendMessage:
|
class TestBotServiceSendMessage:
|
||||||
"""Tests for send_message method."""
|
"""Tests for send_message method."""
|
||||||
|
|
||||||
|
|||||||
@@ -820,6 +820,100 @@ class TestSpaceServiceGetModels:
|
|||||||
await service.get_models()
|
await service.get_models()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSpaceServiceGetModelSelection:
|
||||||
|
"""Tests for availability-ranked model selection."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('response_shape', ['direct', 'models-envelope', 'availability-wrapper'])
|
||||||
|
async def test_preserves_selection_order_and_category_query(self, response_shape):
|
||||||
|
ap = SimpleNamespace(instance_config=SimpleNamespace(data={}))
|
||||||
|
service = SpaceService(ap)
|
||||||
|
models = [
|
||||||
|
{
|
||||||
|
'uuid': 'best-model',
|
||||||
|
'model_id': 'best-chat-model',
|
||||||
|
'provider': 'provider-1',
|
||||||
|
'category': 'chat',
|
||||||
|
'status': 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'uuid': 'fallback-model',
|
||||||
|
'model_id': 'fallback-chat-model',
|
||||||
|
'provider': 'provider-2',
|
||||||
|
'category': 'chat',
|
||||||
|
'status': 'active',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if response_shape == 'models-envelope':
|
||||||
|
data = {'models': models}
|
||||||
|
elif response_shape == 'availability-wrapper':
|
||||||
|
data = [
|
||||||
|
{'model': model, 'latency_ms': index + 10, 'http_code': 200}
|
||||||
|
for index, model in enumerate(models)
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
data = models
|
||||||
|
payload = {'code': 0, 'data': data}
|
||||||
|
mock_response = MagicMock(status=200)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch('langbot.pkg.api.http.service.space.httpclient.get_session') as get_session,
|
||||||
|
patch(
|
||||||
|
'langbot.pkg.api.http.service.space.httpclient.read_json_limited',
|
||||||
|
new=AsyncMock(return_value=payload),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
session = MagicMock()
|
||||||
|
session.get.return_value.__aenter__ = AsyncMock(return_value=mock_response)
|
||||||
|
session.get.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
get_session.return_value = session
|
||||||
|
|
||||||
|
result = await service.get_model_selection('chat')
|
||||||
|
|
||||||
|
assert [model.uuid for model in result] == ['best-model', 'fallback-model']
|
||||||
|
session.get.assert_called_once_with(
|
||||||
|
'https://space.langbot.app/api/v1/models/selection',
|
||||||
|
params={'category': 'chat'},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_recommended_model_uses_first_selection_and_refreshes_once(self):
|
||||||
|
local_model = SimpleNamespace(uuid='local-model-uuid', name='best-chat-model')
|
||||||
|
persistence = SimpleNamespace(
|
||||||
|
execute_async=AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
_create_mock_result(first_item=None),
|
||||||
|
_create_mock_result(first_item=local_model),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
model_mgr = SimpleNamespace(sync_new_models_from_space=AsyncMock())
|
||||||
|
ap = SimpleNamespace(
|
||||||
|
instance_config=SimpleNamespace(data={}),
|
||||||
|
persistence_mgr=persistence,
|
||||||
|
model_mgr=model_mgr,
|
||||||
|
)
|
||||||
|
service = SpaceService(ap)
|
||||||
|
service.get_model_selection = AsyncMock(
|
||||||
|
return_value=[
|
||||||
|
SimpleNamespace(uuid='best-upstream-uuid', model_id='best-chat-model'),
|
||||||
|
SimpleNamespace(uuid='fallback-upstream-uuid', model_id='fallback-chat-model'),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
context = SimpleNamespace(
|
||||||
|
instance_uuid='instance',
|
||||||
|
workspace_uuid='workspace',
|
||||||
|
placement_generation=1,
|
||||||
|
principal=SimpleNamespace(),
|
||||||
|
entitlement_revision=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await service.get_recommended_chat_model(context)
|
||||||
|
|
||||||
|
assert result == {'uuid': 'local-model-uuid', 'name': 'best-chat-model'}
|
||||||
|
service.get_model_selection.assert_awaited_once_with('chat')
|
||||||
|
model_mgr.sync_new_models_from_space.assert_awaited_once()
|
||||||
|
assert persistence.execute_async.await_count == 2
|
||||||
|
|
||||||
|
|
||||||
class TestSpaceServiceCreditsCache:
|
class TestSpaceServiceCreditsCache:
|
||||||
"""Tests for credits cache behavior."""
|
"""Tests for credits cache behavior."""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""BanWordFilter regression tests for legacy sensitive-word lists.
|
||||||
|
|
||||||
|
v4.10.7 introduced a 64-pattern cap in safe_regex. Older installs still carry
|
||||||
|
the previous default list (~70 patterns). The filter must keep applying those
|
||||||
|
rules instead of blocking every message.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib import import_module
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.factories import FakeApp
|
||||||
|
|
||||||
|
|
||||||
|
def _load_banwords():
|
||||||
|
import_module('langbot.pkg.pipeline.pipelinemgr')
|
||||||
|
banwords = import_module('langbot.pkg.pipeline.cntfilter.filters.banwords')
|
||||||
|
entities = import_module('langbot.pkg.pipeline.cntfilter.entities')
|
||||||
|
safe_regex = import_module('langbot.pkg.utils.safe_regex')
|
||||||
|
return banwords, entities, safe_regex
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_with_words(words: list[str], *, mask: str = '*', mask_word: str = ''):
|
||||||
|
banwords, entities, _ = _load_banwords()
|
||||||
|
app = FakeApp()
|
||||||
|
app.sensitive_meta = Mock()
|
||||||
|
app.sensitive_meta.data = {
|
||||||
|
'words': words,
|
||||||
|
'mask': mask,
|
||||||
|
'mask_word': mask_word,
|
||||||
|
}
|
||||||
|
return banwords.BanWordFilter(app), entities, app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_legacy_word_list_over_pattern_cap_does_not_block_clean_message():
|
||||||
|
"""A pre-v4.10.7 word list must not fail closed on every message."""
|
||||||
|
_, _, safe_regex = _load_banwords()
|
||||||
|
words = [f'word{i}' for i in range(safe_regex.MAX_PATTERN_COUNT + 6)]
|
||||||
|
filt, entities, _ = _filter_with_words(words)
|
||||||
|
|
||||||
|
result = await filt.process(Mock(), 'hello there, nothing banned')
|
||||||
|
|
||||||
|
assert result.level == entities.ResultLevel.PASS
|
||||||
|
assert result.replacement == 'hello there, nothing banned'
|
||||||
|
assert result.user_notice == ''
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_legacy_word_list_still_masks_match_beyond_first_batch():
|
||||||
|
"""Words past the first 64-pattern batch must still be applied."""
|
||||||
|
_, _, safe_regex = _load_banwords()
|
||||||
|
words = [f'word{i}' for i in range(safe_regex.MAX_PATTERN_COUNT)] + ['secret-token']
|
||||||
|
filt, entities, _ = _filter_with_words(words, mask_word='[hidden]')
|
||||||
|
|
||||||
|
result = await filt.process(Mock(), 'please hide secret-token now')
|
||||||
|
|
||||||
|
assert result.level == entities.ResultLevel.MASKED
|
||||||
|
assert 'secret-token' not in result.replacement
|
||||||
|
assert '[hidden]' in result.replacement
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_legacy_word_list_masks_match_in_first_batch():
|
||||||
|
_, _, safe_regex = _load_banwords()
|
||||||
|
words = ['alpha-secret'] + [f'word{i}' for i in range(safe_regex.MAX_PATTERN_COUNT)]
|
||||||
|
filt, entities, _ = _filter_with_words(words, mask_word='[hidden]')
|
||||||
|
|
||||||
|
result = await filt.process(Mock(), 'alpha-secret is here')
|
||||||
|
|
||||||
|
assert result.level == entities.ResultLevel.MASKED
|
||||||
|
assert result.replacement == '[hidden] is here'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_invalid_sensitive_word_regex_still_blocks():
|
||||||
|
filt, entities, _ = _filter_with_words(['(unclosed'])
|
||||||
|
|
||||||
|
result = await filt.process(Mock(), 'any message')
|
||||||
|
|
||||||
|
assert result.level == entities.ResultLevel.BLOCK
|
||||||
|
assert result.user_notice == '内容检查规则执行失败,请联系管理员'
|
||||||
|
assert 'rejected' in result.console_notice.lower() or 'invalid' in result.console_notice.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_oversized_word_list_is_blocked():
|
||||||
|
"""Configured rules must never be silently skipped when the list is oversized."""
|
||||||
|
banwords, _, _ = _load_banwords()
|
||||||
|
words = [f'word{i}' for i in range(banwords._MAX_SENSITIVE_WORD_PATTERNS + 10)]
|
||||||
|
filt, entities, _ = _filter_with_words(words)
|
||||||
|
|
||||||
|
result = await filt.process(Mock(), 'hello there, nothing banned')
|
||||||
|
|
||||||
|
assert result.level == entities.ResultLevel.BLOCK
|
||||||
|
assert result.replacement == ''
|
||||||
|
assert result.user_notice == '内容检查规则执行失败,请联系管理员'
|
||||||
|
assert 'at most 256 regex patterns are allowed' in result.console_notice.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_match_beyond_total_cap_cannot_bypass_filter():
|
||||||
|
banwords, _, _ = _load_banwords()
|
||||||
|
words = [f'word{i}' for i in range(banwords._MAX_SENSITIVE_WORD_PATTERNS)] + ['late-secret']
|
||||||
|
filt, entities, _ = _filter_with_words(words, mask_word='[hidden]')
|
||||||
|
|
||||||
|
result = await filt.process(Mock(), 'please hide late-secret now')
|
||||||
|
|
||||||
|
assert result.level == entities.ResultLevel.BLOCK
|
||||||
|
assert result.replacement == ''
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from linebot.v3.webhooks import TextMessageContent, UserMentionee, AllMentionee
|
||||||
|
|
||||||
|
from langbot.pkg.platform import botmgr as _botmgr # noqa: F401
|
||||||
|
from langbot.pkg.platform.sources import line
|
||||||
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
|
|
||||||
|
BOT_ACCOUNT_ID = 'line-bot-account'
|
||||||
|
|
||||||
|
|
||||||
|
def _make_event(
|
||||||
|
*, source_type: str, user_id, group_id=None, room_id=None, message_id: str, text: str = 'hi', mention=None
|
||||||
|
):
|
||||||
|
event = MagicMock()
|
||||||
|
event.timestamp = 1700000000000
|
||||||
|
message = MagicMock(spec=TextMessageContent)
|
||||||
|
message.id = message_id
|
||||||
|
message.text = text
|
||||||
|
message.mention = mention
|
||||||
|
event.message = message
|
||||||
|
event.message.webhook_event_id = f'webhook-{message_id}'
|
||||||
|
event.message.timestamp = event.timestamp
|
||||||
|
|
||||||
|
source = MagicMock()
|
||||||
|
source.type = source_type
|
||||||
|
source.user_id = user_id
|
||||||
|
if group_id is not None:
|
||||||
|
source.group_id = group_id
|
||||||
|
if room_id is not None:
|
||||||
|
source.room_id = room_id
|
||||||
|
event.source = source
|
||||||
|
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
def _make_converter(bot_account_id: str = BOT_ACCOUNT_ID) -> line.LINEEventConverter:
|
||||||
|
return line.LINEEventConverter(bot_account_id=bot_account_id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_user_message_launcher_id_stable_across_messages() -> None:
|
||||||
|
"""Two distinct messages from the same LINE user must resolve to the same
|
||||||
|
sender id, otherwise every message starts a brand new session (context loss).
|
||||||
|
"""
|
||||||
|
converter = _make_converter()
|
||||||
|
event1 = _make_event(source_type='user', user_id='U-stable-user', message_id='msg-1')
|
||||||
|
event2 = _make_event(source_type='user', user_id='U-stable-user', message_id='msg-2')
|
||||||
|
|
||||||
|
result1 = await converter.target2yiri(event1, bot_client=None)
|
||||||
|
result2 = await converter.target2yiri(event2, bot_client=None)
|
||||||
|
|
||||||
|
assert result1.sender.id == 'U-stable-user'
|
||||||
|
assert result1.sender.id == result2.sender.id
|
||||||
|
assert result1.sender.id != event1.message.id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_group_message_uses_group_id_not_message_id() -> None:
|
||||||
|
converter = _make_converter()
|
||||||
|
event1 = _make_event(source_type='group', user_id='U-member', group_id='G-stable-group', message_id='msg-1')
|
||||||
|
event2 = _make_event(source_type='group', user_id='U-member', group_id='G-stable-group', message_id='msg-2')
|
||||||
|
|
||||||
|
result1 = await converter.target2yiri(event1, bot_client=None)
|
||||||
|
result2 = await converter.target2yiri(event2, bot_client=None)
|
||||||
|
|
||||||
|
assert result1.sender.group.id == 'G-stable-group'
|
||||||
|
assert result1.sender.group.id == result2.sender.group.id
|
||||||
|
assert result1.sender.id == 'U-member'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_room_message_uses_room_id_and_falls_back_when_user_id_missing() -> None:
|
||||||
|
converter = _make_converter()
|
||||||
|
event = _make_event(source_type='room', user_id=None, room_id='R-stable-room', message_id='msg-1')
|
||||||
|
|
||||||
|
result = await converter.target2yiri(event, bot_client=None)
|
||||||
|
|
||||||
|
assert result.sender.group.id == 'R-stable-room'
|
||||||
|
assert result.sender.id == 'R-stable-room'
|
||||||
|
|
||||||
|
|
||||||
|
def _plain_texts(chain: platform_message.MessageChain) -> list[str]:
|
||||||
|
return [c.text for c in chain if isinstance(c, platform_message.Plain)]
|
||||||
|
|
||||||
|
|
||||||
|
def _ats(chain: platform_message.MessageChain) -> list[platform_message.At]:
|
||||||
|
return [c for c in chain if isinstance(c, platform_message.At)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_mention_keeps_plain_text() -> None:
|
||||||
|
converter = _make_converter()
|
||||||
|
event = _make_event(source_type='group', user_id='U-member', group_id='G1', message_id='m1', text='hello world')
|
||||||
|
|
||||||
|
chain = await converter.message_converter.target2yiri(event, bot_client=None)
|
||||||
|
|
||||||
|
assert _plain_texts(chain) == ['hello world']
|
||||||
|
assert _ats(chain) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bot_mention_maps_to_at_with_bot_account_id() -> None:
|
||||||
|
"""A @bot mention must become At(target=bot_account_id) so the 'at-bot'
|
||||||
|
group respond rule matches (previously the mention was lost and the message
|
||||||
|
was silently dropped in groups with at-only rules).
|
||||||
|
"""
|
||||||
|
mention = MagicMock()
|
||||||
|
mention.mentionees = [
|
||||||
|
UserMentionee(type='user', index=0, length=4, userId='U-bot-user-id', isSelf=True),
|
||||||
|
]
|
||||||
|
converter = _make_converter()
|
||||||
|
event = _make_event(
|
||||||
|
source_type='group',
|
||||||
|
user_id='U-member',
|
||||||
|
group_id='G1',
|
||||||
|
message_id='m1',
|
||||||
|
text='@BOT hey',
|
||||||
|
mention=mention,
|
||||||
|
)
|
||||||
|
|
||||||
|
chain = await converter.message_converter.target2yiri(event, bot_client=None)
|
||||||
|
|
||||||
|
ats = _ats(chain)
|
||||||
|
assert len(ats) == 1
|
||||||
|
assert ats[0].target == BOT_ACCOUNT_ID
|
||||||
|
assert _plain_texts(chain) == [' hey']
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_other_user_mention_keeps_display_text() -> None:
|
||||||
|
"""Mentions of other users keep their display text in the message string,
|
||||||
|
so prefix/regexp rules that match the raw '@Name ...' text still work.
|
||||||
|
"""
|
||||||
|
mention = MagicMock()
|
||||||
|
mention.mentionees = [
|
||||||
|
UserMentionee(type='user', index=0, length=6, userId='U-other', isSelf=False),
|
||||||
|
]
|
||||||
|
converter = _make_converter()
|
||||||
|
event = _make_event(
|
||||||
|
source_type='group',
|
||||||
|
user_id='U-member',
|
||||||
|
group_id='G1',
|
||||||
|
message_id='m1',
|
||||||
|
text='@Alice hello',
|
||||||
|
mention=mention,
|
||||||
|
)
|
||||||
|
|
||||||
|
chain = await converter.message_converter.target2yiri(event, bot_client=None)
|
||||||
|
|
||||||
|
ats = _ats(chain)
|
||||||
|
assert len(ats) == 1
|
||||||
|
assert ats[0].target == 'U-other'
|
||||||
|
# str() of the At component falls back to display when set
|
||||||
|
assert str(chain) == '@Alice hello'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bot_mention_triggers_atbot_rule() -> None:
|
||||||
|
"""End-to-end: a group message that @mentions the bot must be accepted by
|
||||||
|
the at-bot respond rule (this is the regression that silently dropped
|
||||||
|
'@bot' messages in LINE groups).
|
||||||
|
"""
|
||||||
|
from langbot.pkg.pipeline.resprule.rules.atbot import AtBotRule
|
||||||
|
|
||||||
|
mention = MagicMock()
|
||||||
|
mention.mentionees = [
|
||||||
|
UserMentionee(type='user', index=0, length=6, userId='U-bot-user-id', isSelf=True),
|
||||||
|
]
|
||||||
|
converter = _make_converter()
|
||||||
|
event = _make_event(
|
||||||
|
source_type='group',
|
||||||
|
user_id='U-member',
|
||||||
|
group_id='G1',
|
||||||
|
message_id='m1',
|
||||||
|
text='@RAIQt hi',
|
||||||
|
mention=mention,
|
||||||
|
)
|
||||||
|
|
||||||
|
chain = await converter.message_converter.target2yiri(event, bot_client=None)
|
||||||
|
|
||||||
|
query = MagicMock()
|
||||||
|
query.adapter = MagicMock()
|
||||||
|
query.adapter.bot_account_id = BOT_ACCOUNT_ID
|
||||||
|
|
||||||
|
rule = AtBotRule(ap=MagicMock())
|
||||||
|
result = await rule.match(str(chain), chain, {'at': True}, query)
|
||||||
|
|
||||||
|
assert result.matching is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_group_without_bot_mention_still_dropped_by_atbot_rule() -> None:
|
||||||
|
from langbot.pkg.pipeline.resprule.rules.atbot import AtBotRule
|
||||||
|
|
||||||
|
converter = _make_converter()
|
||||||
|
event = _make_event(source_type='group', user_id='U-member', group_id='G1', message_id='m1', text='hello')
|
||||||
|
|
||||||
|
chain = await converter.message_converter.target2yiri(event, bot_client=None)
|
||||||
|
|
||||||
|
query = MagicMock()
|
||||||
|
query.adapter = MagicMock()
|
||||||
|
query.adapter.bot_account_id = BOT_ACCOUNT_ID
|
||||||
|
|
||||||
|
rule = AtBotRule(ap=MagicMock())
|
||||||
|
result = await rule.match(str(chain), chain, {'at': True}, query)
|
||||||
|
|
||||||
|
assert result.matching is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_at_all_mention_preserved_as_at_component() -> None:
|
||||||
|
mention = MagicMock()
|
||||||
|
mention.mentionees = [
|
||||||
|
AllMentionee(type='all', index=0, length=4),
|
||||||
|
]
|
||||||
|
converter = _make_converter()
|
||||||
|
event = _make_event(
|
||||||
|
source_type='group',
|
||||||
|
user_id='U-member',
|
||||||
|
group_id='G1',
|
||||||
|
message_id='m1',
|
||||||
|
text='@All hello',
|
||||||
|
mention=mention,
|
||||||
|
)
|
||||||
|
|
||||||
|
chain = await converter.message_converter.target2yiri(event, bot_client=None)
|
||||||
|
|
||||||
|
ats = _ats(chain)
|
||||||
|
assert len(ats) == 1
|
||||||
|
assert str(chain) == '@All hello'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_multiple_mentions_sorted_by_position() -> None:
|
||||||
|
mention = MagicMock()
|
||||||
|
# Intentionally out of order to exercise sorting
|
||||||
|
mention.mentionees = [
|
||||||
|
UserMentionee(type='user', index=9, length=4, userId='U-b', isSelf=False),
|
||||||
|
UserMentionee(type='user', index=0, length=4, userId='U-a', isSelf=False),
|
||||||
|
]
|
||||||
|
converter = _make_converter()
|
||||||
|
event = _make_event(
|
||||||
|
source_type='group',
|
||||||
|
user_id='U-member',
|
||||||
|
group_id='G1',
|
||||||
|
message_id='m1',
|
||||||
|
text='@aaa mid @bbb tail',
|
||||||
|
mention=mention,
|
||||||
|
)
|
||||||
|
|
||||||
|
chain = await converter.message_converter.target2yiri(event, bot_client=None)
|
||||||
|
|
||||||
|
ats = _ats(chain)
|
||||||
|
assert [a.target for a in ats] == ['U-a', 'U-b']
|
||||||
|
assert str(chain) == '@aaa mid @bbb tail'
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import uuid
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
@@ -49,7 +50,29 @@ async def test_send_message_sends_text_to_customer_service_user():
|
|||||||
assert kwargs['open_kfid'] == 'kf-test'
|
assert kwargs['open_kfid'] == 'kf-test'
|
||||||
assert kwargs['external_userid'] == 'external-user'
|
assert kwargs['external_userid'] == 'external-user'
|
||||||
assert kwargs['content'] == 'hello'
|
assert kwargs['content'] == 'hello'
|
||||||
assert kwargs['msgid'].startswith('langbot_')
|
assert len(kwargs['msgid'].encode()) <= 32
|
||||||
|
assert uuid.UUID(hex=kwargs['msgid']).hex == kwargs['msgid']
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_message_sends_image_to_customer_service_user():
|
||||||
|
adapter = make_adapter()
|
||||||
|
adapter.bot_account_id = 'kf-test'
|
||||||
|
adapter.bot = SimpleNamespace(
|
||||||
|
get_media_id=AsyncMock(return_value='media-id'),
|
||||||
|
send_image_msg=AsyncMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
message = platform_message.MessageChain([platform_message.Image(base64='aW1hZ2U=')])
|
||||||
|
|
||||||
|
await adapter.send_message('person', 'uexternal-user', message)
|
||||||
|
|
||||||
|
adapter.bot.send_image_msg.assert_awaited_once()
|
||||||
|
kwargs = adapter.bot.send_image_msg.await_args.kwargs
|
||||||
|
assert kwargs['open_kfid'] == 'kf-test'
|
||||||
|
assert kwargs['external_userid'] == 'external-user'
|
||||||
|
assert kwargs['media_id'] == 'media-id'
|
||||||
|
assert len(kwargs['msgid'].encode()) <= 32
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from langbot.libs.wecom_customer_service_api.api import WecomCSClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_image_msg_posts_customer_service_image_payload() -> None:
|
||||||
|
captured_request: httpx.Request | None = None
|
||||||
|
|
||||||
|
def handle_request(request: httpx.Request) -> httpx.Response:
|
||||||
|
nonlocal captured_request
|
||||||
|
captured_request = request
|
||||||
|
return httpx.Response(200, json={'errcode': 0})
|
||||||
|
|
||||||
|
client = WecomCSClient(
|
||||||
|
corpid='corp-id',
|
||||||
|
secret='secret',
|
||||||
|
token='token',
|
||||||
|
EncodingAESKey='encoding-key',
|
||||||
|
logger=None,
|
||||||
|
unified_mode=True,
|
||||||
|
)
|
||||||
|
client.access_token = 'access-token'
|
||||||
|
client._http_client = httpx.AsyncClient(transport=httpx.MockTransport(handle_request))
|
||||||
|
|
||||||
|
try:
|
||||||
|
await client.send_image_msg(
|
||||||
|
open_kfid='kf-test',
|
||||||
|
external_userid='external-user',
|
||||||
|
msgid='a' * 32,
|
||||||
|
media_id='media-id',
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
assert captured_request is not None
|
||||||
|
assert captured_request.url.path == '/cgi-bin/kf/send_msg'
|
||||||
|
assert captured_request.url.params['access_token'] == 'access-token'
|
||||||
|
assert captured_request.method == 'POST'
|
||||||
|
assert captured_request.read().decode() == (
|
||||||
|
'{"touser":"external-user","open_kfid":"kf-test","msgid":"'
|
||||||
|
+ 'a' * 32
|
||||||
|
+ '","msgtype":"image","image":{"media_id":"media-id"}}'
|
||||||
|
)
|
||||||
@@ -91,3 +91,42 @@ def test_convert_messages_plain_string_content_untouched():
|
|||||||
msg = provider_message.Message(role='user', content='just text')
|
msg = provider_message.Message(role='user', content='just text')
|
||||||
out = req._convert_messages([msg])
|
out = req._convert_messages([msg])
|
||||||
assert out[0]['content'] == 'just text'
|
assert out[0]['content'] == 'just text'
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_messages_replayed_image_without_base64_does_not_crash():
|
||||||
|
"""Replayed image parts hollowed out by history trimming must not raise KeyError (#2469).
|
||||||
|
|
||||||
|
SessionManager clears image_base64 on past turns, and URL-less platform
|
||||||
|
images never had a URL, so the replayed part serializes as
|
||||||
|
{'type': 'image_base64'} with no payload keys. The hollow part should be
|
||||||
|
dropped while the sibling text part survives.
|
||||||
|
"""
|
||||||
|
req = _make_requester()
|
||||||
|
image = provider_message.ContentElement.from_image_base64('data:image/jpeg;base64,AAAA')
|
||||||
|
# Simulate SessionManager.trim_conversation_messages clearing binary payloads.
|
||||||
|
image.image_base64 = None
|
||||||
|
msg = provider_message.Message(
|
||||||
|
role='user',
|
||||||
|
content=[
|
||||||
|
provider_message.ContentElement.from_text('describe the photo'),
|
||||||
|
image,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
out = req._convert_messages([msg])
|
||||||
|
assert [p.get('type') for p in out[0]['content']] == ['text']
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_messages_replayed_image_with_url_falls_back_to_url():
|
||||||
|
"""When base64 was trimmed but image_url survived, rebuild the OpenAI image_url part from the URL."""
|
||||||
|
req = _make_requester()
|
||||||
|
image = provider_message.ContentElement(
|
||||||
|
type='image_base64',
|
||||||
|
image_base64=None,
|
||||||
|
image_url=provider_message.ImageURLContentObject(url='https://example.com/pic.jpg'),
|
||||||
|
)
|
||||||
|
msg = provider_message.Message(role='user', content=[image])
|
||||||
|
out = req._convert_messages([msg])
|
||||||
|
parts = out[0]['content']
|
||||||
|
assert [p.get('type') for p in parts] == ['image_url']
|
||||||
|
assert parts[0]['image_url'] == {'url': 'https://example.com/pic.jpg'}
|
||||||
|
assert 'image_base64' not in parts[0]
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
"""Regression tests for tool-message content serialization (#2457).
|
||||||
|
|
||||||
|
MCP tools return ``list[ContentElement]`` from ``execute_func_call``.
|
||||||
|
The runner must serialize that list to a string before placing it in a
|
||||||
|
``role='tool'`` message, because the OpenAI chat-completions spec
|
||||||
|
requires tool-message content to be a string. Sending the raw list
|
||||||
|
causes OpenAI-compatible endpoints to return HTTP 500.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
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.provider.session as provider_session
|
||||||
|
|
||||||
|
from langbot.pkg.api.http.context import ExecutionContext, PrincipalContext, PrincipalType
|
||||||
|
from langbot.pkg.provider.runners.localagent import LocalAgentRunner
|
||||||
|
|
||||||
|
|
||||||
|
class _ToolCallProvider:
|
||||||
|
"""Non-streaming provider: round 1 issues a tool call, round 2 returns text."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.requests: list[dict] = []
|
||||||
|
|
||||||
|
async def invoke_llm(self, query, model, messages, funcs, extra_args=None, remove_think=None):
|
||||||
|
self.requests.append({'messages': list(messages)})
|
||||||
|
|
||||||
|
if len(self.requests) == 1:
|
||||||
|
return provider_message.Message(
|
||||||
|
role='assistant',
|
||||||
|
content='Let me search that.',
|
||||||
|
tool_calls=[
|
||||||
|
provider_message.ToolCall(
|
||||||
|
id='call-mcp-1',
|
||||||
|
type='function',
|
||||||
|
function=provider_message.FunctionCall(
|
||||||
|
name='duckduckgo_search',
|
||||||
|
arguments=json.dumps({'query': 'swift'}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return provider_message.Message(role='assistant', content='Done.')
|
||||||
|
|
||||||
|
|
||||||
|
class _ToolCallStreamProvider:
|
||||||
|
"""Streaming variant of _ToolCallProvider."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.requests: list[dict] = []
|
||||||
|
|
||||||
|
def invoke_llm_stream(self, query, model, messages, funcs, extra_args=None, remove_think=None):
|
||||||
|
self.requests.append({'messages': list(messages)})
|
||||||
|
|
||||||
|
async def _stream():
|
||||||
|
if len(self.requests) == 1:
|
||||||
|
yield provider_message.MessageChunk(
|
||||||
|
role='assistant',
|
||||||
|
content='Let me search that.',
|
||||||
|
tool_calls=[
|
||||||
|
provider_message.ToolCall(
|
||||||
|
id='call-mcp-1',
|
||||||
|
type='function',
|
||||||
|
function=provider_message.FunctionCall(
|
||||||
|
name='duckduckgo_search',
|
||||||
|
arguments=json.dumps({'query': 'swift'}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
is_final=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
yield provider_message.MessageChunk(
|
||||||
|
role='assistant',
|
||||||
|
content='Done.',
|
||||||
|
is_final=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return _stream()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_query(stream: bool = False) -> pipeline_query.Query:
|
||||||
|
adapter = AsyncMock()
|
||||||
|
adapter.is_stream_output_supported = AsyncMock(return_value=stream)
|
||||||
|
|
||||||
|
query = pipeline_query.Query.model_construct(
|
||||||
|
query_id='mcp-tool-query',
|
||||||
|
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||||
|
launcher_id=12345,
|
||||||
|
sender_id=12345,
|
||||||
|
message_chain=[],
|
||||||
|
message_event=None,
|
||||||
|
adapter=adapter,
|
||||||
|
pipeline_uuid='pipeline-uuid',
|
||||||
|
bot_uuid='bot-uuid',
|
||||||
|
pipeline_config={
|
||||||
|
'ai': {
|
||||||
|
'runner': {'runner': 'local-agent'},
|
||||||
|
'local-agent': {'model': {'primary': 'test-model-uuid', 'fallbacks': []}, 'prompt': 'test-prompt'},
|
||||||
|
},
|
||||||
|
'output': {'misc': {'remove-think': False}},
|
||||||
|
},
|
||||||
|
prompt=SimpleNamespace(messages=[]),
|
||||||
|
messages=[],
|
||||||
|
user_message=provider_message.Message(role='user', content='search swift'),
|
||||||
|
use_funcs=[SimpleNamespace(name='duckduckgo_search')],
|
||||||
|
use_llm_model_uuid='test-model-uuid',
|
||||||
|
variables={},
|
||||||
|
)
|
||||||
|
object.__setattr__(
|
||||||
|
query,
|
||||||
|
'_execution_context',
|
||||||
|
ExecutionContext(
|
||||||
|
instance_uuid='instance-test',
|
||||||
|
workspace_uuid='workspace-test',
|
||||||
|
placement_generation=1,
|
||||||
|
trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return query
|
||||||
|
|
||||||
|
|
||||||
|
def _make_app(provider, func_ret) -> SimpleNamespace:
|
||||||
|
"""Build a minimal app whose tool_mgr returns *func_ret*."""
|
||||||
|
model = SimpleNamespace(
|
||||||
|
provider=provider,
|
||||||
|
model_entity=SimpleNamespace(
|
||||||
|
uuid='test-model-uuid',
|
||||||
|
name='test-model',
|
||||||
|
abilities=['func_call'],
|
||||||
|
extra_args={},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return SimpleNamespace(
|
||||||
|
logger=Mock(),
|
||||||
|
model_mgr=SimpleNamespace(get_model_by_uuid=AsyncMock(return_value=model)),
|
||||||
|
tool_mgr=SimpleNamespace(execute_func_call=AsyncMock(return_value=func_ret)),
|
||||||
|
rag_mgr=SimpleNamespace(),
|
||||||
|
box_service=SimpleNamespace(get_system_guidance=Mock(return_value='sandbox guidance')),
|
||||||
|
skill_mgr=SimpleNamespace(
|
||||||
|
get_skills_for_pipeline=AsyncMock(return_value=[]),
|
||||||
|
detect_skill_activation=AsyncMock(return_value=None),
|
||||||
|
build_activation_prompt=Mock(return_value=None),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# The actual shape returned by MCP tools: a list of ContentElement objects.
|
||||||
|
_MCP_FUNC_RET = [
|
||||||
|
provider_message.ContentElement.from_text('Title: Swift - Wikipedia\nURL: https://en.wikipedia.org/wiki/Swift'),
|
||||||
|
provider_message.ContentElement.from_text('Title: Swift Programming Language\nURL: https://swift.org'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tool_message_content_is_string_not_list():
|
||||||
|
"""Non-streaming: tool message content must be a string (#2457).
|
||||||
|
|
||||||
|
Before the fix, ``func_ret`` (a ``list[ContentElement]``) was assigned
|
||||||
|
to ``tool_content`` as-is, so the tool message carried a list instead
|
||||||
|
of a string, causing OpenAI-compatible APIs to return 500.
|
||||||
|
"""
|
||||||
|
provider = _ToolCallProvider()
|
||||||
|
app = _make_app(provider, _MCP_FUNC_RET)
|
||||||
|
runner = LocalAgentRunner(app, pipeline_config={})
|
||||||
|
query = _make_query(stream=False)
|
||||||
|
|
||||||
|
results = [msg async for msg in runner.run(query)]
|
||||||
|
|
||||||
|
tool_msgs = [m for m in results if m.role == 'tool']
|
||||||
|
assert len(tool_msgs) == 1
|
||||||
|
|
||||||
|
# The content must be a string, not a list.
|
||||||
|
assert isinstance(tool_msgs[0].content, str), (
|
||||||
|
f'tool message content should be str, got {type(tool_msgs[0].content).__name__}'
|
||||||
|
)
|
||||||
|
# And it should contain the text of both ContentElements.
|
||||||
|
assert 'Swift - Wikipedia' in tool_msgs[0].content
|
||||||
|
assert 'Swift Programming Language' in tool_msgs[0].content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tool_message_content_is_string_in_stream():
|
||||||
|
"""Streaming: same regression check for the streaming path (#2457)."""
|
||||||
|
provider = _ToolCallStreamProvider()
|
||||||
|
app = _make_app(provider, _MCP_FUNC_RET)
|
||||||
|
runner = LocalAgentRunner(app, pipeline_config={})
|
||||||
|
query = _make_query(stream=True)
|
||||||
|
|
||||||
|
results = [msg async for msg in runner.run(query)]
|
||||||
|
|
||||||
|
tool_msgs = [m for m in results if m.role == 'tool']
|
||||||
|
assert len(tool_msgs) == 1
|
||||||
|
|
||||||
|
assert isinstance(tool_msgs[0].content, str), (
|
||||||
|
f'tool message content should be str, got {type(tool_msgs[0].content).__name__}'
|
||||||
|
)
|
||||||
|
assert 'Swift - Wikipedia' in tool_msgs[0].content
|
||||||
|
assert 'Swift Programming Language' in tool_msgs[0].content
|
||||||
@@ -53,6 +53,45 @@ async def test_matches_any_rejects_pattern_and_input_amplification():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mask_patterns_honors_explicit_pattern_count_cap():
|
||||||
|
patterns = ['a'] * (safe_regex.MAX_PATTERN_COUNT + 6)
|
||||||
|
found, masked = await safe_regex.mask_patterns(
|
||||||
|
patterns,
|
||||||
|
'hello',
|
||||||
|
mask='*',
|
||||||
|
mask_word='',
|
||||||
|
max_pattern_count=len(patterns),
|
||||||
|
)
|
||||||
|
assert found is False
|
||||||
|
assert masked == 'hello'
|
||||||
|
|
||||||
|
with pytest.raises(safe_regex.SafeRegexLimitError):
|
||||||
|
await safe_regex.mask_patterns(
|
||||||
|
patterns,
|
||||||
|
'hello',
|
||||||
|
mask='*',
|
||||||
|
mask_word='',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mask_patterns_rejects_oversized_sequence_before_copying_it():
|
||||||
|
class OversizedPatterns(list):
|
||||||
|
def __iter__(self):
|
||||||
|
raise AssertionError('oversized patterns must not be materialized')
|
||||||
|
|
||||||
|
patterns = OversizedPatterns(['a'] * (safe_regex.MAX_PATTERN_COUNT + 1))
|
||||||
|
|
||||||
|
with pytest.raises(safe_regex.SafeRegexLimitError):
|
||||||
|
await safe_regex.mask_patterns(
|
||||||
|
patterns,
|
||||||
|
'hello',
|
||||||
|
mask='*',
|
||||||
|
mask_word='',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mask_patterns_bounds_replacement_growth_and_masks_matches():
|
async def test_mask_patterns_bounds_replacement_growth_and_masks_matches():
|
||||||
found, masked = await safe_regex.mask_patterns(
|
found, masked = await safe_regex.mask_patterns(
|
||||||
|
|||||||
+5
-1
@@ -1 +1,5 @@
|
|||||||
VITE_API_BASE_URL=http://localhost:5300
|
# Leave empty in development to use Vite's same-origin proxy. This keeps API,
|
||||||
|
# login, and WebSocket requests working when the UI is opened from another
|
||||||
|
# device on the local network.
|
||||||
|
VITE_API_BASE_URL=
|
||||||
|
VITE_API_PROXY_TARGET=http://127.0.0.1:5300
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export function BotLogListComponent({
|
|||||||
autoExpandImages = false,
|
autoExpandImages = false,
|
||||||
hideDetailedLogsLink = false,
|
hideDetailedLogsLink = false,
|
||||||
hideToolbar = false,
|
hideToolbar = false,
|
||||||
|
onMessageReceived,
|
||||||
}: {
|
}: {
|
||||||
botId: string;
|
botId: string;
|
||||||
/** When true, log entries with images are rendered expanded by default */
|
/** When true, log entries with images are rendered expanded by default */
|
||||||
@@ -28,6 +29,8 @@ export function BotLogListComponent({
|
|||||||
hideDetailedLogsLink?: boolean;
|
hideDetailedLogsLink?: boolean;
|
||||||
/** When true, hides the entire toolbar (auto-refresh, level filter, detailed logs link) */
|
/** When true, hides the entire toolbar (auto-refresh, level filter, detailed logs link) */
|
||||||
hideToolbar?: boolean;
|
hideToolbar?: boolean;
|
||||||
|
/** Called after an inbound person/group message appears in the bot log. */
|
||||||
|
onMessageReceived?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -41,6 +44,8 @@ export function BotLogListComponent({
|
|||||||
]);
|
]);
|
||||||
const listContainerRef = useRef<HTMLDivElement>(null);
|
const listContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const botLogListRef = useRef<BotLog[]>(botLogList);
|
const botLogListRef = useRef<BotLog[]>(botLogList);
|
||||||
|
const onMessageReceivedRef = useRef(onMessageReceived);
|
||||||
|
onMessageReceivedRef.current = onMessageReceived;
|
||||||
|
|
||||||
const logLevels = [
|
const logLevels = [
|
||||||
{ value: 'error', label: 'ERROR' },
|
{ value: 'error', label: 'ERROR' },
|
||||||
@@ -108,6 +113,9 @@ export function BotLogListComponent({
|
|||||||
manager.subscribeLogPush(handleBotLogPush);
|
manager.subscribeLogPush(handleBotLogPush);
|
||||||
manager.loadFirstPage().then((response) => {
|
manager.loadFirstPage().then((response) => {
|
||||||
setBotLogList(response.reverse());
|
setBotLogList(response.reverse());
|
||||||
|
if (response.some((log) => Boolean(log.message_session_id))) {
|
||||||
|
onMessageReceivedRef.current?.();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
listenScroll();
|
listenScroll();
|
||||||
}
|
}
|
||||||
@@ -138,6 +146,9 @@ export function BotLogListComponent({
|
|||||||
|
|
||||||
function handleBotLogPush(response: BotLog[]) {
|
function handleBotLogPush(response: BotLog[]) {
|
||||||
setBotLogList(response.reverse());
|
setBotLogList(response.reverse());
|
||||||
|
if (response.some((log) => Boolean(log.message_session_id))) {
|
||||||
|
onMessageReceivedRef.current?.();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleScroll = useCallback(
|
const handleScroll = useCallback(
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@/components/ui/form';
|
} from '@/components/ui/form';
|
||||||
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
|
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
|
||||||
import { normalizeDynamicFormValuesForSave } from '@/app/home/components/dynamic-form/DynamicFormSaveValues';
|
import {
|
||||||
|
normalizeDynamicFormFieldValue,
|
||||||
|
normalizeDynamicFormValuesForSave,
|
||||||
|
} from '@/app/home/components/dynamic-form/DynamicFormSaveValues';
|
||||||
import QrCodeLoginDialog, {
|
import QrCodeLoginDialog, {
|
||||||
QrLoginPlatform,
|
QrLoginPlatform,
|
||||||
} from '@/app/home/components/qrcode-login/QrCodeLoginDialog';
|
} from '@/app/home/components/qrcode-login/QrCodeLoginDialog';
|
||||||
@@ -464,61 +467,6 @@ export default function DynamicFormComponent({
|
|||||||
const previousInitialValues = useRef(initialValues);
|
const previousInitialValues = useRef(initialValues);
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
// Normalize a form value according to its field type.
|
|
||||||
// This ensures legacy/malformed data (e.g. a plain string for
|
|
||||||
// model-fallback-selector) is coerced to the expected shape
|
|
||||||
// so that downstream components never crash.
|
|
||||||
const normalizeFieldValue = (
|
|
||||||
item: DynamicFormValueSpec,
|
|
||||||
value: unknown,
|
|
||||||
): unknown => {
|
|
||||||
if (
|
|
||||||
item.name === 'mcp-resources' ||
|
|
||||||
item.type === DynamicFormItemType.RESOURCES_SELECTOR ||
|
|
||||||
item.type === DynamicFormItemType.RICH_TOOLS_SELECTOR
|
|
||||||
) {
|
|
||||||
return Array.isArray(value) ? value : [];
|
|
||||||
}
|
|
||||||
if (item.type === 'model-fallback-selector') {
|
|
||||||
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
|
|
||||||
const obj = value as Record<string, unknown>;
|
|
||||||
return {
|
|
||||||
primary: typeof obj.primary === 'string' ? obj.primary : '',
|
|
||||||
fallbacks: Array.isArray(obj.fallbacks)
|
|
||||||
? (obj.fallbacks as unknown[]).filter(
|
|
||||||
(v): v is string => typeof v === 'string',
|
|
||||||
)
|
|
||||||
: [],
|
|
||||||
reasoning:
|
|
||||||
obj.reasoning != null &&
|
|
||||||
typeof obj.reasoning === 'object' &&
|
|
||||||
!Array.isArray(obj.reasoning)
|
|
||||||
? Object.fromEntries(
|
|
||||||
Object.entries(obj.reasoning).filter(
|
|
||||||
(entry): entry is [string, string] =>
|
|
||||||
typeof entry[1] === 'string',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Legacy string format or any other unexpected type
|
|
||||||
return {
|
|
||||||
primary: typeof value === 'string' ? value : '',
|
|
||||||
fallbacks: [],
|
|
||||||
reasoning: {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (item.type === 'prompt-editor') {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
// Default to a single empty system prompt entry
|
|
||||||
return [{ role: 'system', content: '' }];
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Filter out display-only fields (webhook-url/embed-code/qr-code-login types
|
// Filter out display-only fields (webhook-url/embed-code/qr-code-login types
|
||||||
// and `__system.*`-named fields) that should not participate in form state,
|
// and `__system.*`-named fields) that should not participate in form state,
|
||||||
// validation, or value emission.
|
// validation, or value emission.
|
||||||
@@ -574,7 +522,7 @@ export default function DynamicFormComponent({
|
|||||||
const rawValue = initialValues?.[item.name] ?? item.default;
|
const rawValue = initialValues?.[item.name] ?? item.default;
|
||||||
return {
|
return {
|
||||||
...acc,
|
...acc,
|
||||||
[item.name]: normalizeFieldValue(item, rawValue),
|
[item.name]: normalizeDynamicFormFieldValue(item, rawValue),
|
||||||
};
|
};
|
||||||
}, {} as FormValues),
|
}, {} as FormValues),
|
||||||
});
|
});
|
||||||
@@ -611,7 +559,10 @@ export default function DynamicFormComponent({
|
|||||||
const mergedValues = editableValueSpecs.reduce(
|
const mergedValues = editableValueSpecs.reduce(
|
||||||
(acc, item) => {
|
(acc, item) => {
|
||||||
const rawValue = initialValues[item.name] ?? item.default;
|
const rawValue = initialValues[item.name] ?? item.default;
|
||||||
acc[item.name] = normalizeFieldValue(item, rawValue) as object;
|
acc[item.name] = normalizeDynamicFormFieldValue(
|
||||||
|
item,
|
||||||
|
rawValue,
|
||||||
|
) as object;
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, object>,
|
{} as Record<string, object>,
|
||||||
|
|||||||
@@ -5,6 +5,80 @@ export type DynamicFormSaveValueSpec = Pick<
|
|||||||
'default' | 'name' | 'type'
|
'default' | 'name' | 'type'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
const ARRAY_FIELD_TYPES = new Set([
|
||||||
|
'array[string]',
|
||||||
|
'array[file]',
|
||||||
|
'knowledge-base-multi-selector',
|
||||||
|
'resources-selector',
|
||||||
|
'rich-tools-selector',
|
||||||
|
'tools-selector',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const STRING_FIELD_TYPES = new Set([
|
||||||
|
'string',
|
||||||
|
'text',
|
||||||
|
'select',
|
||||||
|
'llm-model-selector',
|
||||||
|
'embedding-model-selector',
|
||||||
|
'rerank-model-selector',
|
||||||
|
'knowledge-base-selector',
|
||||||
|
'bot-selector',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coerce empty dynamic-form defaults into controlled React values.
|
||||||
|
* Metadata from older adapters and runners can omit `default`; inputs must
|
||||||
|
* still receive a stable value from their first render.
|
||||||
|
*/
|
||||||
|
export function normalizeDynamicFormFieldValue(
|
||||||
|
spec: DynamicFormSaveValueSpec,
|
||||||
|
value: unknown,
|
||||||
|
): unknown {
|
||||||
|
if (spec.name === 'mcp-resources' || ARRAY_FIELD_TYPES.has(spec.type)) {
|
||||||
|
return Array.isArray(value) ? value : [];
|
||||||
|
}
|
||||||
|
if (spec.type === 'boolean') {
|
||||||
|
return typeof value === 'boolean' ? value : false;
|
||||||
|
}
|
||||||
|
if (STRING_FIELD_TYPES.has(spec.type)) {
|
||||||
|
return typeof value === 'string' ? value : '';
|
||||||
|
}
|
||||||
|
if (spec.type === 'model-fallback-selector') {
|
||||||
|
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
const objectValue = value as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
primary:
|
||||||
|
typeof objectValue.primary === 'string' ? objectValue.primary : '',
|
||||||
|
fallbacks: Array.isArray(objectValue.fallbacks)
|
||||||
|
? objectValue.fallbacks.filter(
|
||||||
|
(fallback): fallback is string => typeof fallback === 'string',
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
reasoning:
|
||||||
|
objectValue.reasoning != null &&
|
||||||
|
typeof objectValue.reasoning === 'object' &&
|
||||||
|
!Array.isArray(objectValue.reasoning)
|
||||||
|
? Object.fromEntries(
|
||||||
|
Object.entries(objectValue.reasoning).filter(
|
||||||
|
(entry): entry is [string, string] =>
|
||||||
|
typeof entry[1] === 'string',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
primary: typeof value === 'string' ? value : '',
|
||||||
|
fallbacks: [],
|
||||||
|
reasoning: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (spec.type === 'prompt-editor') {
|
||||||
|
return Array.isArray(value) ? value : [{ role: 'system', content: '' }];
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
const reasoningLevels = new Set([
|
const reasoningLevels = new Set([
|
||||||
'disabled',
|
'disabled',
|
||||||
'enabled',
|
'enabled',
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const getFormSchema = (t: (key: string) => string) =>
|
|||||||
|
|
||||||
interface ProviderFormProps {
|
interface ProviderFormProps {
|
||||||
providerId?: string;
|
providerId?: string;
|
||||||
onFormSubmit: () => void;
|
onFormSubmit: (providerUuid: string) => void | Promise<void>;
|
||||||
onFormCancel: () => void;
|
onFormCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,14 +171,16 @@ export default function ProviderForm({
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let savedProviderUuid = providerId;
|
||||||
if (providerId) {
|
if (providerId) {
|
||||||
await httpClient.updateModelProvider(providerId, data);
|
await httpClient.updateModelProvider(providerId, data);
|
||||||
toast.success(t('models.providerSaved'));
|
toast.success(t('models.providerSaved'));
|
||||||
} else {
|
} else {
|
||||||
await httpClient.createModelProvider(data);
|
const response = await httpClient.createModelProvider(data);
|
||||||
|
savedProviderUuid = response.uuid;
|
||||||
toast.success(t('models.providerCreated'));
|
toast.success(t('models.providerCreated'));
|
||||||
}
|
}
|
||||||
onFormSubmit();
|
await onFormSubmit(savedProviderUuid as string);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(t('models.providerSaveError') + (err as CustomApiError).msg);
|
toast.error(t('models.providerSaveError') + (err as CustomApiError).msg);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function groupByCategory<T extends { categories?: string[] }>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let placed = false;
|
let placed = false;
|
||||||
for (const cat of cats) {
|
for (const cat of new Set(cats)) {
|
||||||
if (ordered.includes(cat as AdapterCategoryId)) {
|
if (ordered.includes(cat as AdapterCategoryId)) {
|
||||||
buckets.get(cat as AdapterCategoryId)!.push(item);
|
buckets.get(cat as AdapterCategoryId)!.push(item);
|
||||||
placed = true;
|
placed = true;
|
||||||
|
|||||||
@@ -363,7 +363,9 @@ export interface WizardProgress {
|
|||||||
step: number;
|
step: number;
|
||||||
selected_adapter: string | null;
|
selected_adapter: string | null;
|
||||||
created_bot_uuid: string | null;
|
created_bot_uuid: string | null;
|
||||||
|
created_pipeline_uuid?: string | null;
|
||||||
bot_saved: boolean;
|
bot_saved: boolean;
|
||||||
|
message_received?: boolean;
|
||||||
selected_runner: string | null;
|
selected_runner: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -150,7 +150,9 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
return this.get(`/api/v1/provider/models/llm/${uuid}`);
|
return this.get(`/api/v1/provider/models/llm/${uuid}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
public createProviderLLMModel(model: LLMModel): Promise<object> {
|
public createProviderLLMModel(
|
||||||
|
model: Omit<LLMModel, 'uuid'>,
|
||||||
|
): Promise<{ uuid: string }> {
|
||||||
return this.post('/api/v1/provider/models/llm', model);
|
return this.post('/api/v1/provider/models/llm', model);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,6 +463,15 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
return this.post(`/api/v1/platform/bots/${botId}/logs`, request);
|
return this.post(`/api/v1/platform/bots/${botId}/logs`, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public testHttpBotInbound(
|
||||||
|
botId: string,
|
||||||
|
message: string,
|
||||||
|
): Promise<{ session_id: string; accepted_message_id: string }> {
|
||||||
|
return this.post(`/api/v1/platform/bots/${botId}/test-inbound`, {
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public getBotSessions(
|
public getBotSessions(
|
||||||
botId: string,
|
botId: string,
|
||||||
limit: number = 100,
|
limit: number = 100,
|
||||||
@@ -1068,12 +1079,21 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
step: number;
|
step: number;
|
||||||
selected_adapter: string | null;
|
selected_adapter: string | null;
|
||||||
created_bot_uuid: string | null;
|
created_bot_uuid: string | null;
|
||||||
|
created_pipeline_uuid?: string | null;
|
||||||
bot_saved: boolean;
|
bot_saved: boolean;
|
||||||
|
message_received?: boolean;
|
||||||
selected_runner: string | null;
|
selected_runner: string | null;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
return this.put('/api/v1/system/wizard/progress', progress);
|
return this.put('/api/v1/system/wizard/progress', progress);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getWizardRecommendedModel(): Promise<{
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
}> {
|
||||||
|
return this.get('/api/v1/system/wizard/recommended-model');
|
||||||
|
}
|
||||||
|
|
||||||
public getAsyncTasks(params?: {
|
public getAsyncTasks(params?: {
|
||||||
type?: string;
|
type?: string;
|
||||||
kind?: string;
|
kind?: string;
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Check,
|
||||||
|
Eye,
|
||||||
|
Loader2,
|
||||||
|
Pencil,
|
||||||
|
RefreshCw,
|
||||||
|
Wrench,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import ProviderForm from '@/app/home/components/models-dialog/component/provider-form/ProviderForm';
|
||||||
|
import type { ScannedProviderModel } from '@/app/infra/entities/api';
|
||||||
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
type ModelSetupMode = 'scan' | 'manual';
|
||||||
|
type ScanFallbackReason = 'failed' | 'empty' | null;
|
||||||
|
|
||||||
|
export interface OwnModelSelection {
|
||||||
|
source: ModelSetupMode;
|
||||||
|
providerUuid: string;
|
||||||
|
model: ScannedProviderModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OwnModelSetupProps {
|
||||||
|
onBack: () => void;
|
||||||
|
onSelectionChange: (selection: OwnModelSelection | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OwnModelSetup({
|
||||||
|
onBack,
|
||||||
|
onSelectionChange,
|
||||||
|
}: OwnModelSetupProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [providerUuid, setProviderUuid] = useState<string | null>(null);
|
||||||
|
const [showProviderForm, setShowProviderForm] = useState(true);
|
||||||
|
const [mode, setMode] = useState<ModelSetupMode>('scan');
|
||||||
|
const [models, setModels] = useState<ScannedProviderModel[]>([]);
|
||||||
|
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
|
||||||
|
const [isScanning, setIsScanning] = useState(false);
|
||||||
|
const [scanFallbackReason, setScanFallbackReason] =
|
||||||
|
useState<ScanFallbackReason>(null);
|
||||||
|
const [manualModelName, setManualModelName] = useState('');
|
||||||
|
const [manualContextLength, setManualContextLength] = useState('');
|
||||||
|
const [manualVision, setManualVision] = useState(false);
|
||||||
|
const [manualFunctionCall, setManualFunctionCall] = useState(false);
|
||||||
|
|
||||||
|
const parsedManualContextLength = useMemo(() => {
|
||||||
|
if (!manualContextLength.trim()) return null;
|
||||||
|
const value = Number(manualContextLength);
|
||||||
|
return Number.isInteger(value) && value > 0 ? value : undefined;
|
||||||
|
}, [manualContextLength]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== 'manual' || !providerUuid) return;
|
||||||
|
if (!manualModelName.trim() || parsedManualContextLength === undefined) {
|
||||||
|
onSelectionChange(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const abilities = [
|
||||||
|
...(manualVision ? ['vision'] : []),
|
||||||
|
...(manualFunctionCall ? ['func_call'] : []),
|
||||||
|
];
|
||||||
|
const modelName = manualModelName.trim();
|
||||||
|
onSelectionChange({
|
||||||
|
source: 'manual',
|
||||||
|
providerUuid,
|
||||||
|
model: {
|
||||||
|
id: modelName,
|
||||||
|
name: modelName,
|
||||||
|
type: 'llm',
|
||||||
|
abilities,
|
||||||
|
context_length: parsedManualContextLength,
|
||||||
|
already_added: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
manualFunctionCall,
|
||||||
|
manualModelName,
|
||||||
|
manualVision,
|
||||||
|
mode,
|
||||||
|
onSelectionChange,
|
||||||
|
parsedManualContextLength,
|
||||||
|
providerUuid,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const scanModels = useCallback(
|
||||||
|
async (uuid: string) => {
|
||||||
|
setMode('scan');
|
||||||
|
setIsScanning(true);
|
||||||
|
setScanFallbackReason(null);
|
||||||
|
setModels([]);
|
||||||
|
setSelectedModelId(null);
|
||||||
|
onSelectionChange(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await httpClient.scanProviderModels(uuid, 'llm');
|
||||||
|
const availableModels = response.models.filter(
|
||||||
|
(model) => model.type === 'llm' && !model.already_added,
|
||||||
|
);
|
||||||
|
setModels(availableModels);
|
||||||
|
if (availableModels.length === 0) {
|
||||||
|
setScanFallbackReason('empty');
|
||||||
|
setMode('manual');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setScanFallbackReason('failed');
|
||||||
|
setMode('manual');
|
||||||
|
} finally {
|
||||||
|
setIsScanning(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onSelectionChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleProviderSaved = useCallback(
|
||||||
|
async (uuid: string) => {
|
||||||
|
setProviderUuid(uuid);
|
||||||
|
setShowProviderForm(false);
|
||||||
|
await scanModels(uuid);
|
||||||
|
},
|
||||||
|
[scanModels],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelectModel = useCallback(
|
||||||
|
(model: ScannedProviderModel) => {
|
||||||
|
if (!providerUuid) return;
|
||||||
|
setSelectedModelId(model.id);
|
||||||
|
onSelectionChange({ source: 'scan', providerUuid, model });
|
||||||
|
},
|
||||||
|
[onSelectionChange, providerUuid],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleModeChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setMode(value as ModelSetupMode);
|
||||||
|
setSelectedModelId(null);
|
||||||
|
onSelectionChange(null);
|
||||||
|
},
|
||||||
|
[onSelectionChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
onSelectionChange(null);
|
||||||
|
onBack();
|
||||||
|
}, [onBack, onSelectionChange]);
|
||||||
|
|
||||||
|
const handleEditProvider = useCallback(() => {
|
||||||
|
setSelectedModelId(null);
|
||||||
|
onSelectionChange(null);
|
||||||
|
setShowProviderForm(true);
|
||||||
|
}, [onSelectionChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-4xl space-y-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-xl font-semibold">
|
||||||
|
{t('wizard.aiEngine.ownModelSetupTitle')}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{t('wizard.aiEngine.ownModelSetupDescription')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleBack}>
|
||||||
|
<ArrowLeft className="mr-1.5 size-4" />
|
||||||
|
{t('wizard.aiEngine.backToChoices')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showProviderForm ? (
|
||||||
|
<Card className="mx-auto w-full max-w-3xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
{t('wizard.aiEngine.addProviderTitle')}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t('wizard.aiEngine.addProviderDescription')}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ProviderForm
|
||||||
|
providerId={providerUuid ?? undefined}
|
||||||
|
onFormSubmit={handleProviderSaved}
|
||||||
|
onFormCancel={() =>
|
||||||
|
providerUuid ? setShowProviderForm(false) : handleBack()
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="mx-auto w-full max-w-3xl space-y-4">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3 border-b pb-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold">
|
||||||
|
{t('wizard.aiEngine.selectModelTitle')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('wizard.aiEngine.selectScannedModelDescription')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
title={t('wizard.aiEngine.editProvider')}
|
||||||
|
onClick={handleEditProvider}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs value={mode} onValueChange={handleModeChange}>
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="scan">
|
||||||
|
{t('wizard.aiEngine.scanModelMode')}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="manual">
|
||||||
|
{t('wizard.aiEngine.manualModelMode')}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="scan" className="mt-4">
|
||||||
|
{isScanning ? (
|
||||||
|
<div className="flex min-h-48 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
{t('wizard.aiEngine.scanningModels')}
|
||||||
|
</div>
|
||||||
|
) : models.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{models.map((model) => {
|
||||||
|
const selected = selectedModelId === model.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={model.id}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-20 items-center gap-3 rounded-md border p-3 text-left transition-colors hover:border-primary/60 hover:bg-accent/40',
|
||||||
|
selected &&
|
||||||
|
'border-primary bg-accent ring-1 ring-primary',
|
||||||
|
)}
|
||||||
|
onClick={() => handleSelectModel(model)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex size-5 shrink-0 items-center justify-center rounded-full border',
|
||||||
|
selected &&
|
||||||
|
'border-primary bg-primary text-primary-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selected && <Check className="size-3" />}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-sm font-medium">
|
||||||
|
{model.display_name || model.name}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-xs text-muted-foreground">
|
||||||
|
{model.name}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={isScanning || !providerUuid}
|
||||||
|
onClick={() => providerUuid && scanModels(providerUuid)}
|
||||||
|
>
|
||||||
|
<RefreshCw className="mr-1.5 size-4" />
|
||||||
|
{t('wizard.aiEngine.rescanModels')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-h-48 flex-col items-center justify-center gap-3 border border-dashed p-6 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
scanFallbackReason === 'failed'
|
||||||
|
? 'wizard.aiEngine.scanModelsFailed'
|
||||||
|
: 'wizard.aiEngine.noScannedModels',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!providerUuid}
|
||||||
|
onClick={() => providerUuid && scanModels(providerUuid)}
|
||||||
|
>
|
||||||
|
<RefreshCw className="mr-1.5 size-4" />
|
||||||
|
{t('wizard.aiEngine.rescanModels')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="manual" className="mt-4 space-y-5">
|
||||||
|
{scanFallbackReason && (
|
||||||
|
<div className="border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-200">
|
||||||
|
{t(
|
||||||
|
scanFallbackReason === 'failed'
|
||||||
|
? 'wizard.aiEngine.manualFallbackFailed'
|
||||||
|
: 'wizard.aiEngine.manualFallbackEmpty',
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="wizard-manual-model-name">
|
||||||
|
{t('wizard.aiEngine.manualModelId')}
|
||||||
|
<span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="wizard-manual-model-name"
|
||||||
|
value={manualModelName}
|
||||||
|
onChange={(event) => setManualModelName(event.target.value)}
|
||||||
|
placeholder={t('wizard.aiEngine.manualModelIdPlaceholder')}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('wizard.aiEngine.manualModelIdDescription')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 border-t pt-4">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{t('wizard.aiEngine.manualModelOptions')}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="wizard-manual-context-length">
|
||||||
|
{t('models.contextLength')}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="wizard-manual-context-length"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
value={manualContextLength}
|
||||||
|
onChange={(event) =>
|
||||||
|
setManualContextLength(event.target.value)
|
||||||
|
}
|
||||||
|
placeholder={t('models.contextLengthPlaceholder')}
|
||||||
|
/>
|
||||||
|
{parsedManualContextLength === undefined && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{t('models.contextLengthInvalid')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="wizard-manual-vision"
|
||||||
|
checked={manualVision}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setManualVision(checked === true)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="wizard-manual-vision"
|
||||||
|
className="flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Eye className="size-4" />
|
||||||
|
{t('models.visionAbility')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="wizard-manual-function-call"
|
||||||
|
checked={manualFunctionCall}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setManualFunctionCall(checked === true)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="wizard-manual-function-call"
|
||||||
|
className="flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Wrench className="size-4" />
|
||||||
|
{t('models.functionCallAbility')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+648
-166
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,137 @@
|
|||||||
|
export function getErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) return error.message;
|
||||||
|
|
||||||
|
if (typeof error === 'object' && error !== null && 'msg' in error) {
|
||||||
|
const message = (error as { msg?: unknown }).msg;
|
||||||
|
if (typeof message === 'string') return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSigningSecret(): string {
|
||||||
|
const bytes = new Uint8Array(32);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureHttpBotSigningSecret(
|
||||||
|
adapterName: string,
|
||||||
|
config: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (
|
||||||
|
adapterName !== 'http_bot' ||
|
||||||
|
config.signature_required === false ||
|
||||||
|
(typeof config.inbound_secret === 'string' && config.inbound_secret)
|
||||||
|
) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
inbound_secret: createSigningSecret(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findDefaultPipeline<
|
||||||
|
T extends { uuid?: string; is_default?: boolean },
|
||||||
|
>(pipelines: T[]): T | undefined {
|
||||||
|
return pipelines.find(
|
||||||
|
(pipeline) =>
|
||||||
|
pipeline.is_default === true &&
|
||||||
|
typeof pipeline.uuid === 'string' &&
|
||||||
|
pipeline.uuid.length > 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebhookConfigItem {
|
||||||
|
name: string;
|
||||||
|
show_if?: {
|
||||||
|
field: string;
|
||||||
|
operator: 'eq' | 'neq' | 'in';
|
||||||
|
value: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWebhookModeEnabled(
|
||||||
|
configItems: WebhookConfigItem[],
|
||||||
|
configValues: Record<string, unknown>,
|
||||||
|
): boolean {
|
||||||
|
const webhookField = configItems.find((item) => item.name === 'webhook_url');
|
||||||
|
if (!webhookField) return false;
|
||||||
|
if (!webhookField.show_if) return true;
|
||||||
|
|
||||||
|
const condition = webhookField.show_if;
|
||||||
|
const actualValue = configValues[condition.field];
|
||||||
|
if (condition.operator === 'eq') return actualValue === condition.value;
|
||||||
|
if (condition.operator === 'neq') return actualValue !== condition.value;
|
||||||
|
return (
|
||||||
|
Array.isArray(condition.value) && condition.value.includes(actualValue)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequiredConfigItem {
|
||||||
|
name: string;
|
||||||
|
required: boolean;
|
||||||
|
default: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlaceholderDefault(value: string, defaultValue: unknown): boolean {
|
||||||
|
if (typeof defaultValue !== 'string' || value !== defaultValue.trim()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return /(^|:\/\/)your-/i.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRequiredRunnerConfigComplete(
|
||||||
|
configItems: RequiredConfigItem[],
|
||||||
|
configValues: Record<string, unknown>,
|
||||||
|
): boolean {
|
||||||
|
return configItems
|
||||||
|
.filter((item) => item.required)
|
||||||
|
.every((item) => {
|
||||||
|
const value = configValues[item.name];
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const normalizedValue = value.trim();
|
||||||
|
return (
|
||||||
|
normalizedValue.length > 0 &&
|
||||||
|
!isPlaceholderDefault(normalizedValue, item.default)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) return value.length > 0;
|
||||||
|
return value !== undefined && value !== null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configureLocalAgentPrimaryModel(
|
||||||
|
config: Record<string, unknown>,
|
||||||
|
modelUuid: string,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const aiConfig = (config.ai ?? {}) as Record<string, unknown>;
|
||||||
|
const runnerConfig = (aiConfig.runner ?? {}) as Record<string, unknown>;
|
||||||
|
const localAgentConfig = (aiConfig['local-agent'] ?? {}) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
const modelConfig = (localAgentConfig.model ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
ai: {
|
||||||
|
...aiConfig,
|
||||||
|
runner: { ...runnerConfig, runner: 'local-agent' },
|
||||||
|
'local-agent': {
|
||||||
|
...localAgentConfig,
|
||||||
|
model: {
|
||||||
|
...modelConfig,
|
||||||
|
primary: modelUuid,
|
||||||
|
fallbacks: Array.isArray(modelConfig.fallbacks)
|
||||||
|
? modelConfig.fallbacks
|
||||||
|
: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1827,14 +1827,80 @@ const enUS = {
|
|||||||
resaveBot: 'Re-save Configuration',
|
resaveBot: 'Re-save Configuration',
|
||||||
botSaved:
|
botSaved:
|
||||||
'Bot configuration saved and enabled. Check the logs to verify the connection.',
|
'Bot configuration saved and enabled. Check the logs to verify the connection.',
|
||||||
|
waitingForMessage:
|
||||||
|
'The bot is enabled. Send it a message from your IM platform to continue.',
|
||||||
|
messageReceived:
|
||||||
|
'The bot received an IM message. You can continue to the next step.',
|
||||||
|
messageReceivedLocalAccountWarning:
|
||||||
|
'The bot-side connection is configured correctly and received an IM message. Because you are not signed in with a LangBot Account, model calls may fail; continue to the next step to add your own model.',
|
||||||
|
pageBotTestPrompt:
|
||||||
|
'Page Bot is enabled. Click the chat bubble in the lower-right corner and send a message to verify the full conversation flow.',
|
||||||
|
pageBotTestNotice:
|
||||||
|
'For testing only. Embed the code on a real external webpage.',
|
||||||
|
webhookTestPrompt:
|
||||||
|
'The callback URL is ready. Configure it on the external platform, then send the bot a real message.',
|
||||||
|
httpTestPrompt:
|
||||||
|
'HTTP Bot is enabled. Send a real inbound message here to verify the connection.',
|
||||||
|
httpTestDefaultMessage: 'Hello, this is a connection test message.',
|
||||||
|
sendHttpTest: 'Send Test Message',
|
||||||
|
httpTestAccepted:
|
||||||
|
'The test message was accepted. It will appear in the log shortly.',
|
||||||
|
httpTestMissingSecret:
|
||||||
|
'Enter an inbound signing secret and save the configuration first.',
|
||||||
|
httpTestFailed: 'Failed to send the test message: {{error}}',
|
||||||
logsTitle: 'Bot Logs',
|
logsTitle: 'Bot Logs',
|
||||||
logsDescription:
|
logsDescription:
|
||||||
'Monitor bot activity to verify the platform connection is working.',
|
'Monitor bot activity to verify the platform connection is working.',
|
||||||
},
|
},
|
||||||
aiEngine: {
|
aiEngine: {
|
||||||
title: 'Select an AI Engine',
|
title: 'Configure AI Engine',
|
||||||
description:
|
description:
|
||||||
"Choose the AI engine that will power your bot's intelligence.",
|
"Choose the AI engine that will power your bot's intelligence.",
|
||||||
|
optionalDescription:
|
||||||
|
'This step is optional. Choose how you want to continue with the current agent.',
|
||||||
|
externalTitle: 'Connect an External Agent',
|
||||||
|
externalDescription:
|
||||||
|
'Connect Dify, n8n, Coze, or another platform and replace the bot pipeline.',
|
||||||
|
ownModelTitle: 'Use My Own Model',
|
||||||
|
ownModelDescription:
|
||||||
|
'Add a provider, then scan or manually enter a model to finish setup.',
|
||||||
|
ownModelSetupTitle: 'Add Your Own Model',
|
||||||
|
ownModelSetupDescription:
|
||||||
|
'Add a model provider. Chat models are scanned automatically, or you can enter a model ID manually.',
|
||||||
|
addProviderTitle: 'Add Provider',
|
||||||
|
addProviderDescription:
|
||||||
|
'Enter the provider details and API key used to connect and scan models.',
|
||||||
|
selectModelTitle: 'Choose a Model',
|
||||||
|
selectScannedModelTitle: 'Choose a Model',
|
||||||
|
selectScannedModelDescription:
|
||||||
|
'The selected model will be the primary model of a new pipeline, and the bot will switch to it.',
|
||||||
|
scanModelMode: 'Scan Models',
|
||||||
|
manualModelMode: 'Add Manually',
|
||||||
|
scanningModels: 'Scanning available models…',
|
||||||
|
noScannedModels:
|
||||||
|
'No available chat models were found. Check the provider configuration.',
|
||||||
|
scanModelsFailed:
|
||||||
|
'Model scanning failed. Check the URL and API key, then try again.',
|
||||||
|
manualFallbackFailed:
|
||||||
|
'Automatic scanning failed. Enter a model ID supported by the provider.',
|
||||||
|
manualFallbackEmpty:
|
||||||
|
'No models were found. Enter a model ID supported by the provider.',
|
||||||
|
manualModelId: 'Model ID',
|
||||||
|
manualModelIdPlaceholder: 'For example: gpt-4o',
|
||||||
|
manualModelIdDescription:
|
||||||
|
'Enter the model parameter used in model requests.',
|
||||||
|
manualModelOptions: 'Optional Model Capabilities',
|
||||||
|
editProvider: 'Edit provider',
|
||||||
|
rescanModels: 'Scan models again',
|
||||||
|
moreFeaturesTitle: 'Add More Agent Features',
|
||||||
|
moreFeaturesDescription:
|
||||||
|
'Open the workbench to add tools, knowledge bases, and other capabilities to the Agent that was just generated automatically.',
|
||||||
|
runnerDescription:
|
||||||
|
'Select a runner for the external agent and configure its connection.',
|
||||||
|
backToChoices: 'Back to options',
|
||||||
|
createExternal: 'Create and Bind',
|
||||||
|
finishWithModel: 'Use Selected Model & Finish',
|
||||||
|
openWorkbench: 'Open Workbench',
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
botInfo: 'Bot Information',
|
botInfo: 'Bot Information',
|
||||||
|
|||||||
@@ -1697,14 +1697,80 @@ const esES = {
|
|||||||
resaveBot: 'Volver a guardar configuración',
|
resaveBot: 'Volver a guardar configuración',
|
||||||
botSaved:
|
botSaved:
|
||||||
'Configuración del Bot guardada y activada. Consulta los registros para verificar la conexión.',
|
'Configuración del Bot guardada y activada. Consulta los registros para verificar la conexión.',
|
||||||
|
waitingForMessage:
|
||||||
|
'El Bot está activado. Envíale un mensaje desde tu plataforma de mensajería para continuar.',
|
||||||
|
messageReceived:
|
||||||
|
'El Bot recibió un mensaje. Puedes continuar al siguiente paso.',
|
||||||
|
messageReceivedLocalAccountWarning:
|
||||||
|
'La conexión del Bot está configurada correctamente y recibió un mensaje. Como no has iniciado sesión con una cuenta de LangBot, las llamadas al modelo pueden fallar; continúa al siguiente paso para añadir tu propio modelo.',
|
||||||
|
pageBotTestPrompt:
|
||||||
|
'El Bot de página está activado. Haz clic en la burbuja de chat de la esquina inferior derecha y envía un mensaje para verificar el flujo completo de la conversación.',
|
||||||
|
pageBotTestNotice:
|
||||||
|
'Solo para pruebas. Inserta el código en una página web externa real.',
|
||||||
|
webhookTestPrompt:
|
||||||
|
'La URL de devolución de llamada está lista. Configúrala en la plataforma externa y envía un mensaje real al Bot.',
|
||||||
|
httpTestPrompt:
|
||||||
|
'El Bot HTTP está activado. Envía aquí un mensaje entrante real para verificar la conexión.',
|
||||||
|
httpTestDefaultMessage: 'Hola, este es un mensaje de prueba de conexión.',
|
||||||
|
sendHttpTest: 'Enviar mensaje de prueba',
|
||||||
|
httpTestAccepted:
|
||||||
|
'El mensaje de prueba fue aceptado. Aparecerá en el registro en breve.',
|
||||||
|
httpTestMissingSecret:
|
||||||
|
'Introduce un secreto de firma entrante y guarda primero la configuración.',
|
||||||
|
httpTestFailed: 'No se pudo enviar el mensaje de prueba: {{error}}',
|
||||||
logsTitle: 'Registros del Bot',
|
logsTitle: 'Registros del Bot',
|
||||||
logsDescription:
|
logsDescription:
|
||||||
'Monitorea la actividad del Bot para verificar que la conexión con la plataforma funcione.',
|
'Monitorea la actividad del Bot para verificar que la conexión con la plataforma funcione.',
|
||||||
},
|
},
|
||||||
aiEngine: {
|
aiEngine: {
|
||||||
title: 'Selecciona un motor de IA',
|
title: 'Configura el motor de IA',
|
||||||
description:
|
description:
|
||||||
'Elige el motor de IA que impulsará la inteligencia de tu Bot.',
|
'Elige el motor de IA que impulsará la inteligencia de tu Bot.',
|
||||||
|
optionalDescription:
|
||||||
|
'Este paso es opcional. Elige cómo quieres continuar con el Agent actual.',
|
||||||
|
externalTitle: 'Conectar un Agent de una plataforma externa',
|
||||||
|
externalDescription:
|
||||||
|
'Conecta Dify, n8n, Coze u otra plataforma y sustituye el Pipeline del Bot.',
|
||||||
|
ownModelTitle: 'Usar mi propio modelo',
|
||||||
|
ownModelDescription:
|
||||||
|
'Añade un proveedor y luego escanea o introduce manualmente un modelo para completar la configuración.',
|
||||||
|
ownModelSetupTitle: 'Añade tu propio modelo',
|
||||||
|
ownModelSetupDescription:
|
||||||
|
'Añade un proveedor de modelos. Los modelos de chat se detectan automáticamente, o puedes introducir un ID de modelo manualmente.',
|
||||||
|
addProviderTitle: 'Añadir proveedor',
|
||||||
|
addProviderDescription:
|
||||||
|
'Introduce los datos del proveedor y la clave de API usados para conectar y detectar modelos.',
|
||||||
|
selectModelTitle: 'Elige un modelo',
|
||||||
|
selectScannedModelTitle: 'Elige un modelo',
|
||||||
|
selectScannedModelDescription:
|
||||||
|
'El modelo seleccionado será el modelo principal de un nuevo Pipeline y el Bot cambiará a él.',
|
||||||
|
scanModelMode: 'Detectar modelos',
|
||||||
|
manualModelMode: 'Añadir manualmente',
|
||||||
|
scanningModels: 'Detectando modelos disponibles…',
|
||||||
|
noScannedModels:
|
||||||
|
'No se encontraron modelos de chat disponibles. Revisa la configuración del proveedor.',
|
||||||
|
scanModelsFailed:
|
||||||
|
'No se pudieron detectar los modelos. Revisa la URL y la clave de API e inténtalo de nuevo.',
|
||||||
|
manualFallbackFailed:
|
||||||
|
'La detección automática falló. Introduce un ID de modelo compatible con el proveedor.',
|
||||||
|
manualFallbackEmpty:
|
||||||
|
'No se encontraron modelos. Introduce un ID de modelo compatible con el proveedor.',
|
||||||
|
manualModelId: 'ID del modelo',
|
||||||
|
manualModelIdPlaceholder: 'Por ejemplo: gpt-4o',
|
||||||
|
manualModelIdDescription:
|
||||||
|
'Introduce el parámetro de modelo utilizado en las solicitudes.',
|
||||||
|
manualModelOptions: 'Capacidades opcionales del modelo',
|
||||||
|
editProvider: 'Editar proveedor',
|
||||||
|
rescanModels: 'Volver a detectar modelos',
|
||||||
|
moreFeaturesTitle: 'Añadir más funciones al Agent',
|
||||||
|
moreFeaturesDescription:
|
||||||
|
'Abre el área de trabajo para añadir herramientas, bases de conocimiento y otras capacidades al Agent que se acaba de generar automáticamente.',
|
||||||
|
runnerDescription:
|
||||||
|
'Selecciona un Runner para el Agent externo y configura su conexión.',
|
||||||
|
backToChoices: 'Volver a las opciones',
|
||||||
|
createExternal: 'Crear y vincular',
|
||||||
|
finishWithModel: 'Usar el modelo seleccionado y finalizar',
|
||||||
|
openWorkbench: 'Abrir área de trabajo',
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
botInfo: 'Información del Bot',
|
botInfo: 'Información del Bot',
|
||||||
|
|||||||
@@ -1744,14 +1744,79 @@ const jaJP = {
|
|||||||
resaveBot: '設定を再保存',
|
resaveBot: '設定を再保存',
|
||||||
botSaved:
|
botSaved:
|
||||||
'ボット設定が保存され、有効になりました。ログを確認して接続を検証してください。',
|
'ボット設定が保存され、有効になりました。ログを確認して接続を検証してください。',
|
||||||
|
waitingForMessage:
|
||||||
|
'ボットが有効になりました。続行するには IM からメッセージを送信してください。',
|
||||||
|
messageReceived:
|
||||||
|
'ボットが IM メッセージを受信しました。次のステップに進めます。',
|
||||||
|
messageReceivedLocalAccountWarning:
|
||||||
|
'ボット側の接続設定は正常で、IM メッセージを受信できています。LangBot Account でログインしていないためモデル呼び出しが失敗する場合がありますが、次のステップで独自のモデルを追加できます。',
|
||||||
|
pageBotTestPrompt:
|
||||||
|
'ページボットが有効になりました。右下のチャットバブルをクリックしてメッセージを送信し、会話フロー全体を確認してください。',
|
||||||
|
pageBotTestNotice:
|
||||||
|
'テスト専用です。実際の外部 Web ページにコードを埋め込んでください。',
|
||||||
|
webhookTestPrompt:
|
||||||
|
'コールバック URL の準備ができました。外部プラットフォームに設定し、ボットへ実際のメッセージを送信してください。',
|
||||||
|
httpTestPrompt:
|
||||||
|
'HTTP Bot が有効になりました。実際の受信メッセージを送信して接続を確認できます。',
|
||||||
|
httpTestDefaultMessage: 'こんにちは。これは接続テストメッセージです。',
|
||||||
|
sendHttpTest: 'テストメッセージを送信',
|
||||||
|
httpTestAccepted:
|
||||||
|
'テストメッセージを受け付けました。まもなくログに表示されます。',
|
||||||
|
httpTestMissingSecret:
|
||||||
|
'受信署名シークレットを入力し、先に設定を保存してください。',
|
||||||
|
httpTestFailed: 'テストメッセージの送信に失敗しました:{{error}}',
|
||||||
logsTitle: 'ボットログ',
|
logsTitle: 'ボットログ',
|
||||||
logsDescription:
|
logsDescription:
|
||||||
'ボットの活動を監視して、プラットフォーム接続が正常に動作していることを確認します。',
|
'ボットの活動を監視して、プラットフォーム接続が正常に動作していることを確認します。',
|
||||||
},
|
},
|
||||||
aiEngine: {
|
aiEngine: {
|
||||||
title: 'AIエンジンを選択',
|
title: 'AIエンジンを設定',
|
||||||
description:
|
description:
|
||||||
'ボットのインテリジェンスを駆動するAIエンジンを選択してください。',
|
'ボットのインテリジェンスを駆動するAIエンジンを選択してください。',
|
||||||
|
optionalDescription:
|
||||||
|
'このステップは任意です。現在の Agent をどのように設定するか選択してください。',
|
||||||
|
externalTitle: '外部プラットフォームの Agent を接続',
|
||||||
|
externalDescription:
|
||||||
|
'Dify、n8n、Coze などを接続し、ボットのパイプラインを置き換えます。',
|
||||||
|
ownModelTitle: '自分のモデルを使用',
|
||||||
|
ownModelDescription:
|
||||||
|
'プロバイダーを追加し、モデルをスキャンまたは手動入力して設定を完了します。',
|
||||||
|
ownModelSetupTitle: '自分のモデルを追加',
|
||||||
|
ownModelSetupDescription:
|
||||||
|
'モデルプロバイダーを追加すると自動スキャンされます。モデル ID の手動入力も可能です。',
|
||||||
|
addProviderTitle: 'プロバイダーを追加',
|
||||||
|
addProviderDescription:
|
||||||
|
'接続とモデルスキャンに使用するプロバイダー情報と API キーを入力します。',
|
||||||
|
selectModelTitle: 'モデルを選択',
|
||||||
|
selectScannedModelTitle: 'モデルを選択',
|
||||||
|
selectScannedModelDescription:
|
||||||
|
'選択したモデルを新しいパイプラインのメインモデルに設定し、ボットをそのパイプラインへ切り替えます。',
|
||||||
|
scanModelMode: 'モデルをスキャン',
|
||||||
|
manualModelMode: '手動で追加',
|
||||||
|
scanningModels: '利用可能なモデルをスキャン中…',
|
||||||
|
noScannedModels:
|
||||||
|
'利用可能なチャットモデルが見つかりません。プロバイダー設定を確認してください。',
|
||||||
|
scanModelsFailed:
|
||||||
|
'モデルのスキャンに失敗しました。URL と API キーを確認して再試行してください。',
|
||||||
|
manualFallbackFailed:
|
||||||
|
'自動スキャンに失敗しました。プロバイダーが対応するモデル ID を直接入力できます。',
|
||||||
|
manualFallbackEmpty:
|
||||||
|
'モデルが見つかりませんでした。プロバイダーが対応するモデル ID を直接入力できます。',
|
||||||
|
manualModelId: 'モデル ID',
|
||||||
|
manualModelIdPlaceholder: '例:gpt-4o',
|
||||||
|
manualModelIdDescription:
|
||||||
|
'モデルリクエストで実際に使用する model パラメーターを入力します。',
|
||||||
|
manualModelOptions: '任意のモデル機能',
|
||||||
|
editProvider: 'プロバイダーを編集',
|
||||||
|
rescanModels: 'モデルを再スキャン',
|
||||||
|
moreFeaturesTitle: 'Agent に機能を追加',
|
||||||
|
moreFeaturesDescription:
|
||||||
|
'ワークベンチを開き、自動生成されたばかりの Agent にツール、ナレッジベースなどの機能を追加します。',
|
||||||
|
runnerDescription: '外部 Agent の Runner を選択し、接続を設定します。',
|
||||||
|
backToChoices: '選択肢に戻る',
|
||||||
|
createExternal: '作成して関連付ける',
|
||||||
|
finishWithModel: '選択したモデルを使用して完了',
|
||||||
|
openWorkbench: 'ワークベンチを開く',
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
botInfo: 'ボット情報',
|
botInfo: 'ボット情報',
|
||||||
|
|||||||
@@ -1666,14 +1666,81 @@ const ruRU = {
|
|||||||
resaveBot: 'Пересохранить конфигурацию',
|
resaveBot: 'Пересохранить конфигурацию',
|
||||||
botSaved:
|
botSaved:
|
||||||
'Конфигурация бота сохранена и включена. Проверьте журналы для подтверждения подключения.',
|
'Конфигурация бота сохранена и включена. Проверьте журналы для подтверждения подключения.',
|
||||||
|
waitingForMessage:
|
||||||
|
'Бот включён. Отправьте ему сообщение из мессенджера, чтобы продолжить.',
|
||||||
|
messageReceived:
|
||||||
|
'Бот получил сообщение. Можно перейти к следующему шагу.',
|
||||||
|
messageReceivedLocalAccountWarning:
|
||||||
|
'Подключение бота настроено правильно, и сообщение получено. Поскольку вход выполнен не через аккаунт LangBot, вызовы модели могут завершаться ошибкой; перейдите к следующему шагу, чтобы добавить собственную модель.',
|
||||||
|
pageBotTestPrompt:
|
||||||
|
'Бот для веб-страницы включён. Нажмите на значок чата в правом нижнем углу и отправьте сообщение, чтобы проверить полный сценарий диалога.',
|
||||||
|
pageBotTestNotice:
|
||||||
|
'Только для тестирования. Встройте код в настоящую внешнюю веб-страницу.',
|
||||||
|
webhookTestPrompt:
|
||||||
|
'URL обратного вызова готов. Настройте его на внешней платформе, затем отправьте боту настоящее сообщение.',
|
||||||
|
httpTestPrompt:
|
||||||
|
'HTTP-бот включён. Отправьте сюда настоящее входящее сообщение, чтобы проверить подключение.',
|
||||||
|
httpTestDefaultMessage:
|
||||||
|
'Здравствуйте, это тестовое сообщение подключения.',
|
||||||
|
sendHttpTest: 'Отправить тестовое сообщение',
|
||||||
|
httpTestAccepted:
|
||||||
|
'Тестовое сообщение принято. Оно скоро появится в журнале.',
|
||||||
|
httpTestMissingSecret:
|
||||||
|
'Введите секрет подписи входящих запросов и сначала сохраните конфигурацию.',
|
||||||
|
httpTestFailed: 'Не удалось отправить тестовое сообщение: {{error}}',
|
||||||
logsTitle: 'Журналы бота',
|
logsTitle: 'Журналы бота',
|
||||||
logsDescription:
|
logsDescription:
|
||||||
'Отслеживайте активность бота для проверки подключения к платформе.',
|
'Отслеживайте активность бота для проверки подключения к платформе.',
|
||||||
},
|
},
|
||||||
aiEngine: {
|
aiEngine: {
|
||||||
title: 'Выберите ИИ-движок',
|
title: 'Настройте ИИ-движок',
|
||||||
description:
|
description:
|
||||||
'Выберите ИИ-движок, который будет управлять интеллектом вашего бота.',
|
'Выберите ИИ-движок, который будет управлять интеллектом вашего бота.',
|
||||||
|
optionalDescription:
|
||||||
|
'Этот шаг необязателен. Выберите, как продолжить настройку текущего Agent.',
|
||||||
|
externalTitle: 'Подключить Agent внешней платформы',
|
||||||
|
externalDescription:
|
||||||
|
'Подключите Dify, n8n, Coze или другую платформу и замените Pipeline бота.',
|
||||||
|
ownModelTitle: 'Использовать собственную модель',
|
||||||
|
ownModelDescription:
|
||||||
|
'Добавьте провайдера, затем найдите модель автоматически или укажите её вручную, чтобы завершить настройку.',
|
||||||
|
ownModelSetupTitle: 'Добавьте собственную модель',
|
||||||
|
ownModelSetupDescription:
|
||||||
|
'Добавьте провайдера моделей. Модели чата будут найдены автоматически, либо можно вручную указать ID модели.',
|
||||||
|
addProviderTitle: 'Добавить провайдера',
|
||||||
|
addProviderDescription:
|
||||||
|
'Введите данные провайдера и API-ключ для подключения и поиска моделей.',
|
||||||
|
selectModelTitle: 'Выберите модель',
|
||||||
|
selectScannedModelTitle: 'Выберите модель',
|
||||||
|
selectScannedModelDescription:
|
||||||
|
'Выбранная модель станет основной моделью нового Pipeline, и бот переключится на неё.',
|
||||||
|
scanModelMode: 'Найти модели',
|
||||||
|
manualModelMode: 'Добавить вручную',
|
||||||
|
scanningModels: 'Поиск доступных моделей…',
|
||||||
|
noScannedModels:
|
||||||
|
'Доступные модели чата не найдены. Проверьте конфигурацию провайдера.',
|
||||||
|
scanModelsFailed:
|
||||||
|
'Не удалось найти модели. Проверьте URL и API-ключ, затем повторите попытку.',
|
||||||
|
manualFallbackFailed:
|
||||||
|
'Автоматический поиск не удался. Введите ID модели, поддерживаемой провайдером.',
|
||||||
|
manualFallbackEmpty:
|
||||||
|
'Модели не найдены. Введите ID модели, поддерживаемой провайдером.',
|
||||||
|
manualModelId: 'ID модели',
|
||||||
|
manualModelIdPlaceholder: 'Например: gpt-4o',
|
||||||
|
manualModelIdDescription:
|
||||||
|
'Введите параметр модели, используемый в запросах к модели.',
|
||||||
|
manualModelOptions: 'Дополнительные возможности модели',
|
||||||
|
editProvider: 'Изменить провайдера',
|
||||||
|
rescanModels: 'Повторить поиск моделей',
|
||||||
|
moreFeaturesTitle: 'Добавить возможности Agent',
|
||||||
|
moreFeaturesDescription:
|
||||||
|
'Откройте рабочую панель, чтобы добавить инструменты, базы знаний и другие возможности только что автоматически созданному Agent.',
|
||||||
|
runnerDescription:
|
||||||
|
'Выберите Runner для внешнего Agent и настройте подключение.',
|
||||||
|
backToChoices: 'Вернуться к вариантам',
|
||||||
|
createExternal: 'Создать и привязать',
|
||||||
|
finishWithModel: 'Использовать выбранную модель и завершить',
|
||||||
|
openWorkbench: 'Открыть рабочую панель',
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
botInfo: 'Информация о боте',
|
botInfo: 'Информация о боте',
|
||||||
|
|||||||
@@ -1634,13 +1634,75 @@ const thTH = {
|
|||||||
resaveBot: 'บันทึกการกำหนดค่าอีกครั้ง',
|
resaveBot: 'บันทึกการกำหนดค่าอีกครั้ง',
|
||||||
botSaved:
|
botSaved:
|
||||||
'บันทึกและเปิดใช้งาน Bot แล้ว ตรวจสอบบันทึกเพื่อยืนยันการเชื่อมต่อ',
|
'บันทึกและเปิดใช้งาน Bot แล้ว ตรวจสอบบันทึกเพื่อยืนยันการเชื่อมต่อ',
|
||||||
|
waitingForMessage:
|
||||||
|
'เปิดใช้งาน Bot แล้ว โปรดส่งข้อความจากแพลตฟอร์มแชตเพื่อดำเนินการต่อ',
|
||||||
|
messageReceived: 'Bot ได้รับข้อความแล้ว คุณสามารถไปยังขั้นตอนถัดไปได้',
|
||||||
|
messageReceivedLocalAccountWarning:
|
||||||
|
'การเชื่อมต่อฝั่ง Bot ได้รับการกำหนดค่าอย่างถูกต้องและได้รับข้อความแล้ว เนื่องจากคุณไม่ได้เข้าสู่ระบบด้วยบัญชี LangBot การเรียกใช้โมเดลอาจล้มเหลว โปรดไปยังขั้นตอนถัดไปเพื่อเพิ่มโมเดลของคุณเอง',
|
||||||
|
pageBotTestPrompt:
|
||||||
|
'เปิดใช้งาน Page Bot แล้ว คลิกฟองแชตที่มุมขวาล่างและส่งข้อความเพื่อตรวจสอบขั้นตอนการสนทนาทั้งหมด',
|
||||||
|
pageBotTestNotice:
|
||||||
|
'สำหรับการทดสอบเท่านั้น โปรดฝังโค้ดในหน้าเว็บภายนอกจริง',
|
||||||
|
webhookTestPrompt:
|
||||||
|
'URL Callback พร้อมแล้ว โปรดกำหนดค่าบนแพลตฟอร์มภายนอก แล้วส่งข้อความจริงถึง Bot',
|
||||||
|
httpTestPrompt:
|
||||||
|
'เปิดใช้งาน HTTP Bot แล้ว ส่งข้อความขาเข้าจริงที่นี่เพื่อตรวจสอบการเชื่อมต่อ',
|
||||||
|
httpTestDefaultMessage: 'สวัสดี นี่คือข้อความทดสอบการเชื่อมต่อ',
|
||||||
|
sendHttpTest: 'ส่งข้อความทดสอบ',
|
||||||
|
httpTestAccepted: 'ระบบรับข้อความทดสอบแล้ว และจะแสดงในบันทึกในอีกสักครู่',
|
||||||
|
httpTestMissingSecret:
|
||||||
|
'โปรดกรอก Secret สำหรับลงนามข้อความขาเข้าและบันทึกการกำหนดค่าก่อน',
|
||||||
|
httpTestFailed: 'ส่งข้อความทดสอบไม่สำเร็จ: {{error}}',
|
||||||
logsTitle: 'บันทึก Bot',
|
logsTitle: 'บันทึก Bot',
|
||||||
logsDescription:
|
logsDescription:
|
||||||
'ตรวจสอบกิจกรรม Bot เพื่อยืนยันว่าการเชื่อมต่อแพลตฟอร์มทำงานอยู่',
|
'ตรวจสอบกิจกรรม Bot เพื่อยืนยันว่าการเชื่อมต่อแพลตฟอร์มทำงานอยู่',
|
||||||
},
|
},
|
||||||
aiEngine: {
|
aiEngine: {
|
||||||
title: 'เลือกเครื่องมือ AI',
|
title: 'กำหนดค่าเครื่องมือ AI',
|
||||||
description: 'เลือกเครื่องมือ AI ที่จะขับเคลื่อนความฉลาดของ Bot',
|
description: 'เลือกเครื่องมือ AI ที่จะขับเคลื่อนความฉลาดของ Bot',
|
||||||
|
optionalDescription:
|
||||||
|
'ขั้นตอนนี้ไม่บังคับ เลือกวิธีที่คุณต้องการดำเนินการต่อกับ Agent ปัจจุบัน',
|
||||||
|
externalTitle: 'เชื่อมต่อ Agent จากแพลตฟอร์มภายนอก',
|
||||||
|
externalDescription:
|
||||||
|
'เชื่อมต่อ Dify, n8n, Coze หรือแพลตฟอร์มอื่น และแทนที่ Pipeline ของ Bot',
|
||||||
|
ownModelTitle: 'ใช้โมเดลของฉันเอง',
|
||||||
|
ownModelDescription:
|
||||||
|
'เพิ่มผู้ให้บริการ แล้วสแกนหรือกรอกโมเดลด้วยตนเองเพื่อเสร็จสิ้นการตั้งค่า',
|
||||||
|
ownModelSetupTitle: 'เพิ่มโมเดลของคุณเอง',
|
||||||
|
ownModelSetupDescription:
|
||||||
|
'เพิ่มผู้ให้บริการโมเดล ระบบจะสแกนโมเดลแชตโดยอัตโนมัติ หรือคุณสามารถกรอก ID โมเดลด้วยตนเอง',
|
||||||
|
addProviderTitle: 'เพิ่มผู้ให้บริการ',
|
||||||
|
addProviderDescription:
|
||||||
|
'กรอกรายละเอียดผู้ให้บริการและ API Key ที่ใช้เชื่อมต่อและสแกนโมเดล',
|
||||||
|
selectModelTitle: 'เลือกโมเดล',
|
||||||
|
selectScannedModelTitle: 'เลือกโมเดล',
|
||||||
|
selectScannedModelDescription:
|
||||||
|
'โมเดลที่เลือกจะเป็นโมเดลหลักของ Pipeline ใหม่ และ Bot จะเปลี่ยนไปใช้โมเดลนี้',
|
||||||
|
scanModelMode: 'สแกนโมเดล',
|
||||||
|
manualModelMode: 'เพิ่มด้วยตนเอง',
|
||||||
|
scanningModels: 'กำลังสแกนโมเดลที่พร้อมใช้งาน…',
|
||||||
|
noScannedModels:
|
||||||
|
'ไม่พบโมเดลแชตที่พร้อมใช้งาน โปรดตรวจสอบการกำหนดค่าผู้ให้บริการ',
|
||||||
|
scanModelsFailed:
|
||||||
|
'สแกนโมเดลไม่สำเร็จ โปรดตรวจสอบ URL และ API Key แล้วลองอีกครั้ง',
|
||||||
|
manualFallbackFailed:
|
||||||
|
'การสแกนอัตโนมัติไม่สำเร็จ โปรดกรอก ID โมเดลที่ผู้ให้บริการรองรับ',
|
||||||
|
manualFallbackEmpty: 'ไม่พบโมเดล โปรดกรอก ID โมเดลที่ผู้ให้บริการรองรับ',
|
||||||
|
manualModelId: 'ID โมเดล',
|
||||||
|
manualModelIdPlaceholder: 'ตัวอย่าง: gpt-4o',
|
||||||
|
manualModelIdDescription: 'กรอกพารามิเตอร์โมเดลที่ใช้ในคำขอโมเดล',
|
||||||
|
manualModelOptions: 'ความสามารถเพิ่มเติมของโมเดล',
|
||||||
|
editProvider: 'แก้ไขผู้ให้บริการ',
|
||||||
|
rescanModels: 'สแกนโมเดลอีกครั้ง',
|
||||||
|
moreFeaturesTitle: 'เพิ่มความสามารถให้ Agent',
|
||||||
|
moreFeaturesDescription:
|
||||||
|
'เปิดหน้าทำงานเพื่อเพิ่มเครื่องมือ ฐานความรู้ และความสามารถอื่น ๆ ให้ Agent ที่เพิ่งสร้างขึ้นโดยอัตโนมัติ',
|
||||||
|
runnerDescription:
|
||||||
|
'เลือก Runner สำหรับ Agent ภายนอกและกำหนดค่าการเชื่อมต่อ',
|
||||||
|
backToChoices: 'กลับไปยังตัวเลือก',
|
||||||
|
createExternal: 'สร้างและผูก',
|
||||||
|
finishWithModel: 'ใช้โมเดลที่เลือกและเสร็จสิ้น',
|
||||||
|
openWorkbench: 'เปิดหน้าทำงาน',
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
botInfo: 'ข้อมูล Bot',
|
botInfo: 'ข้อมูล Bot',
|
||||||
|
|||||||
@@ -1658,13 +1658,78 @@ const viVN = {
|
|||||||
resaveBot: 'Lưu lại cấu hình',
|
resaveBot: 'Lưu lại cấu hình',
|
||||||
botSaved:
|
botSaved:
|
||||||
'Cấu hình Bot đã lưu và bật. Kiểm tra nhật ký để xác minh kết nối.',
|
'Cấu hình Bot đã lưu và bật. Kiểm tra nhật ký để xác minh kết nối.',
|
||||||
|
waitingForMessage:
|
||||||
|
'Bot đã được bật. Hãy gửi cho Bot một tin nhắn từ nền tảng nhắn tin để tiếp tục.',
|
||||||
|
messageReceived:
|
||||||
|
'Bot đã nhận được tin nhắn. Bạn có thể tiếp tục sang bước tiếp theo.',
|
||||||
|
messageReceivedLocalAccountWarning:
|
||||||
|
'Kết nối phía Bot đã được cấu hình đúng và đã nhận được tin nhắn. Vì bạn không đăng nhập bằng tài khoản LangBot, lệnh gọi mô hình có thể thất bại; hãy tiếp tục sang bước tiếp theo để thêm mô hình của riêng bạn.',
|
||||||
|
pageBotTestPrompt:
|
||||||
|
'Page Bot đã được bật. Nhấp vào bong bóng trò chuyện ở góc dưới bên phải và gửi tin nhắn để xác minh toàn bộ luồng hội thoại.',
|
||||||
|
pageBotTestNotice:
|
||||||
|
'Chỉ dùng để kiểm thử. Hãy nhúng mã vào một trang web bên ngoài thực tế.',
|
||||||
|
webhookTestPrompt:
|
||||||
|
'URL callback đã sẵn sàng. Hãy cấu hình URL này trên nền tảng bên ngoài, sau đó gửi một tin nhắn thực cho Bot.',
|
||||||
|
httpTestPrompt:
|
||||||
|
'HTTP Bot đã được bật. Gửi một tin nhắn đến thực tế tại đây để xác minh kết nối.',
|
||||||
|
httpTestDefaultMessage: 'Xin chào, đây là tin nhắn kiểm tra kết nối.',
|
||||||
|
sendHttpTest: 'Gửi tin nhắn kiểm tra',
|
||||||
|
httpTestAccepted:
|
||||||
|
'Tin nhắn kiểm tra đã được chấp nhận và sẽ sớm xuất hiện trong nhật ký.',
|
||||||
|
httpTestMissingSecret:
|
||||||
|
'Hãy nhập khóa bí mật ký yêu cầu đến và lưu cấu hình trước.',
|
||||||
|
httpTestFailed: 'Không thể gửi tin nhắn kiểm tra: {{error}}',
|
||||||
logsTitle: 'Nhật ký Bot',
|
logsTitle: 'Nhật ký Bot',
|
||||||
logsDescription:
|
logsDescription:
|
||||||
'Giám sát hoạt động Bot để xác minh kết nối nền tảng đang hoạt động.',
|
'Giám sát hoạt động Bot để xác minh kết nối nền tảng đang hoạt động.',
|
||||||
},
|
},
|
||||||
aiEngine: {
|
aiEngine: {
|
||||||
title: 'Chọn công cụ AI',
|
title: 'Cấu hình công cụ AI',
|
||||||
description: 'Chọn công cụ AI sẽ cung cấp trí tuệ cho Bot của bạn.',
|
description: 'Chọn công cụ AI sẽ cung cấp trí tuệ cho Bot của bạn.',
|
||||||
|
optionalDescription:
|
||||||
|
'Bước này không bắt buộc. Hãy chọn cách bạn muốn tiếp tục với Agent hiện tại.',
|
||||||
|
externalTitle: 'Kết nối Agent từ nền tảng bên ngoài',
|
||||||
|
externalDescription:
|
||||||
|
'Kết nối Dify, n8n, Coze hoặc nền tảng khác và thay thế Pipeline của Bot.',
|
||||||
|
ownModelTitle: 'Sử dụng mô hình của riêng tôi',
|
||||||
|
ownModelDescription:
|
||||||
|
'Thêm nhà cung cấp, sau đó quét hoặc nhập mô hình thủ công để hoàn tất thiết lập.',
|
||||||
|
ownModelSetupTitle: 'Thêm mô hình của riêng bạn',
|
||||||
|
ownModelSetupDescription:
|
||||||
|
'Thêm nhà cung cấp mô hình. Các mô hình trò chuyện sẽ được quét tự động, hoặc bạn có thể nhập ID mô hình thủ công.',
|
||||||
|
addProviderTitle: 'Thêm nhà cung cấp',
|
||||||
|
addProviderDescription:
|
||||||
|
'Nhập thông tin nhà cung cấp và API Key dùng để kết nối và quét mô hình.',
|
||||||
|
selectModelTitle: 'Chọn mô hình',
|
||||||
|
selectScannedModelTitle: 'Chọn mô hình',
|
||||||
|
selectScannedModelDescription:
|
||||||
|
'Mô hình đã chọn sẽ là mô hình chính của Pipeline mới và Bot sẽ chuyển sang sử dụng mô hình đó.',
|
||||||
|
scanModelMode: 'Quét mô hình',
|
||||||
|
manualModelMode: 'Thêm thủ công',
|
||||||
|
scanningModels: 'Đang quét các mô hình khả dụng…',
|
||||||
|
noScannedModels:
|
||||||
|
'Không tìm thấy mô hình trò chuyện khả dụng. Hãy kiểm tra cấu hình nhà cung cấp.',
|
||||||
|
scanModelsFailed:
|
||||||
|
'Quét mô hình thất bại. Hãy kiểm tra URL và API Key rồi thử lại.',
|
||||||
|
manualFallbackFailed:
|
||||||
|
'Quét tự động thất bại. Hãy nhập ID mô hình được nhà cung cấp hỗ trợ.',
|
||||||
|
manualFallbackEmpty:
|
||||||
|
'Không tìm thấy mô hình. Hãy nhập ID mô hình được nhà cung cấp hỗ trợ.',
|
||||||
|
manualModelId: 'ID mô hình',
|
||||||
|
manualModelIdPlaceholder: 'Ví dụ: gpt-4o',
|
||||||
|
manualModelIdDescription:
|
||||||
|
'Nhập tham số mô hình được sử dụng trong các yêu cầu mô hình.',
|
||||||
|
manualModelOptions: 'Khả năng mô hình tùy chọn',
|
||||||
|
editProvider: 'Chỉnh sửa nhà cung cấp',
|
||||||
|
rescanModels: 'Quét lại mô hình',
|
||||||
|
moreFeaturesTitle: 'Thêm khả năng cho Agent',
|
||||||
|
moreFeaturesDescription:
|
||||||
|
'Mở bàn làm việc để thêm công cụ, cơ sở tri thức và các khả năng khác cho Agent vừa được tạo tự động.',
|
||||||
|
runnerDescription: 'Chọn Runner cho Agent bên ngoài và cấu hình kết nối.',
|
||||||
|
backToChoices: 'Quay lại các tùy chọn',
|
||||||
|
createExternal: 'Tạo và liên kết',
|
||||||
|
finishWithModel: 'Sử dụng mô hình đã chọn và hoàn tất',
|
||||||
|
openWorkbench: 'Mở bàn làm việc',
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
botInfo: 'Thông tin Bot',
|
botInfo: 'Thông tin Bot',
|
||||||
|
|||||||
@@ -1749,12 +1749,65 @@ const zhHans = {
|
|||||||
saveBot: '保存并启用',
|
saveBot: '保存并启用',
|
||||||
resaveBot: '重新保存配置',
|
resaveBot: '重新保存配置',
|
||||||
botSaved: '机器人配置已保存并启用,请查看日志确认连接正常。',
|
botSaved: '机器人配置已保存并启用,请查看日志确认连接正常。',
|
||||||
|
waitingForMessage: '机器人已启用。请在 IM 中向机器人发送一条消息以继续。',
|
||||||
|
messageReceived: '机器人已成功收到 IM 消息,可以进入下一步。',
|
||||||
|
messageReceivedLocalAccountWarning:
|
||||||
|
'机器人侧已配置正常并成功收到 IM 消息。当前未通过 LangBot Account 登录,模型调用可能报错;可以进入下一步添加自己的模型。',
|
||||||
|
pageBotTestPrompt:
|
||||||
|
'页面机器人已启用。点击右下角聊天气泡并发送一条消息,验证完整对话链路。',
|
||||||
|
pageBotTestNotice: '仅供测试使用,请嵌入代码到真实外部网页。',
|
||||||
|
webhookTestPrompt:
|
||||||
|
'回调地址已就绪。将它配置到外部平台,然后向机器人发送一条真实消息。',
|
||||||
|
httpTestPrompt: 'HTTP Bot 已启用。可直接发送一条真实入站消息验证连接。',
|
||||||
|
httpTestDefaultMessage: '你好,这是一条连接测试消息。',
|
||||||
|
sendHttpTest: '发送测试消息',
|
||||||
|
httpTestAccepted: '测试消息已被机器人接收,请稍候查看日志。',
|
||||||
|
httpTestMissingSecret: '请先填写入站签名密钥并重新保存。',
|
||||||
|
httpTestFailed: '测试消息发送失败:{{error}}',
|
||||||
logsTitle: '机器人日志',
|
logsTitle: '机器人日志',
|
||||||
logsDescription: '监控机器人活动,确认平台连接是否正常工作。',
|
logsDescription: '监控机器人活动,确认平台连接是否正常工作。',
|
||||||
},
|
},
|
||||||
aiEngine: {
|
aiEngine: {
|
||||||
title: '选择 AI 引擎',
|
title: '配置 AI 引擎',
|
||||||
description: '选择驱动机器人智能的 AI 引擎。',
|
description: '选择驱动机器人智能的 AI 引擎。',
|
||||||
|
optionalDescription: '这一步可选。选择接下来要如何完善当前 Agent。',
|
||||||
|
externalTitle: '接入外部平台 Agent',
|
||||||
|
externalDescription:
|
||||||
|
'接入 Dify、n8n、Coze 等平台,并替换当前机器人的流水线。',
|
||||||
|
ownModelTitle: '改成使用自己的模型',
|
||||||
|
ownModelDescription:
|
||||||
|
'添加模型供应商,自动扫描或手动填写模型以快速完成引导。',
|
||||||
|
ownModelSetupTitle: '添加你自己的模型',
|
||||||
|
ownModelSetupDescription:
|
||||||
|
'先添加模型供应商,保存后会自动扫描,也可以手动填写模型 ID。',
|
||||||
|
addProviderTitle: '添加供应商',
|
||||||
|
addProviderDescription: '填写供应商和 API Key,用于连接并扫描模型。',
|
||||||
|
selectModelTitle: '选择模型',
|
||||||
|
selectScannedModelTitle: '选择一个模型',
|
||||||
|
selectScannedModelDescription:
|
||||||
|
'选中的模型将作为新流水线的主模型,机器人会切换到这条流水线。',
|
||||||
|
scanModelMode: '扫描模型',
|
||||||
|
manualModelMode: '手动添加',
|
||||||
|
scanningModels: '正在扫描可用模型…',
|
||||||
|
noScannedModels: '没有扫描到可用的对话模型,请检查供应商配置。',
|
||||||
|
scanModelsFailed: '模型扫描失败,请检查地址和 API Key 后重试。',
|
||||||
|
manualFallbackFailed: '自动扫描失败,你可以直接填写中转站支持的模型 ID。',
|
||||||
|
manualFallbackEmpty:
|
||||||
|
'没有扫描到可用模型,你可以直接填写中转站支持的模型 ID。',
|
||||||
|
manualModelId: '模型 ID',
|
||||||
|
manualModelIdPlaceholder: '例如:gpt-4o',
|
||||||
|
manualModelIdDescription: '填写模型请求中实际使用的 model 参数。',
|
||||||
|
manualModelOptions: '可选模型能力',
|
||||||
|
editProvider: '修改供应商',
|
||||||
|
rescanModels: '重新扫描模型',
|
||||||
|
moreFeaturesTitle: '给现在的 Agent 配置更多功能',
|
||||||
|
moreFeaturesDescription:
|
||||||
|
'进入工作台,为刚刚自动生成的 Agent 添加工具、知识库等能力',
|
||||||
|
runnerDescription: '选择外部 Agent 的 Runner 并完成连接配置。',
|
||||||
|
backToChoices: '返回选项',
|
||||||
|
createExternal: '创建并绑定',
|
||||||
|
finishWithModel: '使用所选模型并完成',
|
||||||
|
openWorkbench: '进入工作台',
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
botInfo: '机器人信息',
|
botInfo: '机器人信息',
|
||||||
|
|||||||
@@ -1584,12 +1584,65 @@ const zhHant = {
|
|||||||
saveBot: '儲存並啟用',
|
saveBot: '儲存並啟用',
|
||||||
resaveBot: '重新儲存配置',
|
resaveBot: '重新儲存配置',
|
||||||
botSaved: '機器人配置已儲存並啟用,請查看日誌確認連接正常。',
|
botSaved: '機器人配置已儲存並啟用,請查看日誌確認連接正常。',
|
||||||
|
waitingForMessage: '機器人已啟用。請在 IM 中向機器人傳送一則訊息以繼續。',
|
||||||
|
messageReceived: '機器人已成功收到 IM 訊息,可以進入下一步。',
|
||||||
|
messageReceivedLocalAccountWarning:
|
||||||
|
'機器人側已配置正常並成功收到 IM 訊息。目前未透過 LangBot Account 登入,模型呼叫可能報錯;可以進入下一步新增自己的模型。',
|
||||||
|
pageBotTestPrompt:
|
||||||
|
'頁面機器人已啟用。點擊右下角聊天氣泡並傳送一則訊息,驗證完整對話流程。',
|
||||||
|
pageBotTestNotice: '僅供測試使用,請將程式碼嵌入真實的外部網頁。',
|
||||||
|
webhookTestPrompt:
|
||||||
|
'回呼 URL 已準備好。請在外部平台完成配置,然後向機器人傳送一則真實訊息。',
|
||||||
|
httpTestPrompt:
|
||||||
|
'HTTP 機器人已啟用。請在此傳送一則真實的入站訊息以驗證連接。',
|
||||||
|
httpTestDefaultMessage: '你好,這是一則連接測試訊息。',
|
||||||
|
sendHttpTest: '傳送測試訊息',
|
||||||
|
httpTestAccepted: '測試訊息已被接受,稍後將顯示在日誌中。',
|
||||||
|
httpTestMissingSecret: '請先填寫入站簽章密鑰並儲存配置。',
|
||||||
|
httpTestFailed: '傳送測試訊息失敗:{{error}}',
|
||||||
logsTitle: '機器人日誌',
|
logsTitle: '機器人日誌',
|
||||||
logsDescription: '監控機器人活動,確認平台連接是否正常運作。',
|
logsDescription: '監控機器人活動,確認平台連接是否正常運作。',
|
||||||
},
|
},
|
||||||
aiEngine: {
|
aiEngine: {
|
||||||
title: '選擇 AI 引擎',
|
title: '配置 AI 引擎',
|
||||||
description: '選擇驅動機器人智慧的 AI 引擎。',
|
description: '選擇驅動機器人智慧的 AI 引擎。',
|
||||||
|
optionalDescription: '這一步可選。選擇接下來要如何完善目前的 Agent。',
|
||||||
|
externalTitle: '接入外部平台 Agent',
|
||||||
|
externalDescription:
|
||||||
|
'接入 Dify、n8n、Coze 等平台,並替換目前機器人的流水線。',
|
||||||
|
ownModelTitle: '改成使用自己的模型',
|
||||||
|
ownModelDescription:
|
||||||
|
'新增模型供應商,自動掃描或手動填寫模型以快速完成引導。',
|
||||||
|
ownModelSetupTitle: '新增你自己的模型',
|
||||||
|
ownModelSetupDescription:
|
||||||
|
'先新增模型供應商,儲存後會自動掃描,也可以手動填寫模型 ID。',
|
||||||
|
addProviderTitle: '新增供應商',
|
||||||
|
addProviderDescription: '填寫供應商和 API Key,用於連接並掃描模型。',
|
||||||
|
selectModelTitle: '選擇模型',
|
||||||
|
selectScannedModelTitle: '選擇模型',
|
||||||
|
selectScannedModelDescription:
|
||||||
|
'所選模型將成為新流水線的主要模型,機器人也會切換到該流水線。',
|
||||||
|
scanModelMode: '自動掃描',
|
||||||
|
manualModelMode: '手動新增',
|
||||||
|
scanningModels: '正在掃描可用模型…',
|
||||||
|
noScannedModels: '未找到可用的對話模型,請檢查供應商配置。',
|
||||||
|
scanModelsFailed: '掃描模型失敗,請檢查請求 URL 和 API Key 後重試。',
|
||||||
|
manualFallbackFailed: '自動掃描失敗,請填寫該供應商支援的模型 ID。',
|
||||||
|
manualFallbackEmpty: '未掃描到模型,請填寫該供應商支援的模型 ID。',
|
||||||
|
manualModelId: '模型 ID',
|
||||||
|
manualModelIdPlaceholder: '例如:gpt-4o',
|
||||||
|
manualModelIdDescription: '填寫模型請求中使用的 model 參數。',
|
||||||
|
manualModelOptions: '模型能力(可選)',
|
||||||
|
editProvider: '編輯供應商',
|
||||||
|
rescanModels: '重新掃描模型',
|
||||||
|
moreFeaturesTitle: '給目前的 Agent 配置更多功能',
|
||||||
|
moreFeaturesDescription:
|
||||||
|
'進入工作台,為剛剛自動產生的 Agent 新增工具、知識庫等能力',
|
||||||
|
runnerDescription: '選擇外部 Agent 的 Runner 並完成連接配置。',
|
||||||
|
backToChoices: '返回選項',
|
||||||
|
createExternal: '建立並關聯',
|
||||||
|
finishWithModel: '使用所選模型並完成',
|
||||||
|
openWorkbench: '進入工作台',
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
botInfo: '機器人資訊',
|
botInfo: '機器人資訊',
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import ts from 'typescript';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const sourcePath = path.resolve(
|
||||||
|
currentDirectory,
|
||||||
|
'../../src/app/infra/entities/adapter-categories.ts',
|
||||||
|
);
|
||||||
|
|
||||||
|
function loadCategoryHelpers(language = 'zh-Hans') {
|
||||||
|
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||||
|
const compiled = ts.transpileModule(source, {
|
||||||
|
compilerOptions: {
|
||||||
|
esModuleInterop: true,
|
||||||
|
module: ts.ModuleKind.CommonJS,
|
||||||
|
target: ts.ScriptTarget.ES2020,
|
||||||
|
},
|
||||||
|
}).outputText;
|
||||||
|
const loadedModule = { exports: {} };
|
||||||
|
new Function('require', 'module', 'exports', compiled)(
|
||||||
|
(name) => {
|
||||||
|
if (name === 'i18next') return { language };
|
||||||
|
throw new Error(`Unexpected runtime import: ${name}`);
|
||||||
|
},
|
||||||
|
loadedModule,
|
||||||
|
loadedModule.exports,
|
||||||
|
);
|
||||||
|
return loadedModule.exports;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('places an adapter only once when metadata repeats a category', () => {
|
||||||
|
const { groupByCategory } = loadCategoryHelpers();
|
||||||
|
const adapter = { name: 'http_bot', categories: ['popular', 'popular'] };
|
||||||
|
|
||||||
|
assert.deepEqual(groupByCategory([adapter]), [
|
||||||
|
{ categoryId: 'popular', items: [adapter] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -24,11 +24,11 @@ function loadNormalizer() {
|
|||||||
loadedModule,
|
loadedModule,
|
||||||
loadedModule.exports,
|
loadedModule.exports,
|
||||||
);
|
);
|
||||||
return loadedModule.exports.normalizeDynamicFormValuesForSave;
|
return loadedModule.exports;
|
||||||
}
|
}
|
||||||
|
|
||||||
test('normalizes only single-line text fields in a dynamic form save snapshot', () => {
|
test('normalizes only single-line text fields in a dynamic form save snapshot', () => {
|
||||||
const normalizeDynamicFormValuesForSave = loadNormalizer();
|
const { normalizeDynamicFormValuesForSave } = loadNormalizer();
|
||||||
const specs = [
|
const specs = [
|
||||||
{ name: 'single-line', type: 'string', default: '' },
|
{ name: 'single-line', type: 'string', default: '' },
|
||||||
{ name: 'multiline', type: 'text', default: '' },
|
{ name: 'multiline', type: 'text', default: '' },
|
||||||
@@ -70,3 +70,29 @@ test('normalizes only single-line text fields in a dynamic form save snapshot',
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('normalizes missing dynamic form defaults into controlled values', () => {
|
||||||
|
const { normalizeDynamicFormFieldValue } = loadNormalizer();
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
normalizeDynamicFormFieldValue(
|
||||||
|
{ name: 'api-key', type: 'string', default: undefined },
|
||||||
|
undefined,
|
||||||
|
),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
normalizeDynamicFormFieldValue(
|
||||||
|
{ name: 'enabled', type: 'boolean', default: undefined },
|
||||||
|
undefined,
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
normalizeDynamicFormFieldValue(
|
||||||
|
{ name: 'items', type: 'array[string]', default: undefined },
|
||||||
|
undefined,
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import ts from 'typescript';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const sourcePath = path.resolve(
|
||||||
|
currentDirectory,
|
||||||
|
'../../src/app/wizard/utils.ts',
|
||||||
|
);
|
||||||
|
|
||||||
|
function loadWizardUtils() {
|
||||||
|
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||||
|
const compiled = ts.transpileModule(source, {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||||
|
}).outputText;
|
||||||
|
const loadedModule = { exports: {} };
|
||||||
|
new Function('require', 'module', 'exports', compiled)(
|
||||||
|
() => {
|
||||||
|
throw new Error('Wizard utils must not have runtime imports');
|
||||||
|
},
|
||||||
|
loadedModule,
|
||||||
|
loadedModule.exports,
|
||||||
|
);
|
||||||
|
return loadedModule.exports;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
configureLocalAgentPrimaryModel,
|
||||||
|
ensureHttpBotSigningSecret,
|
||||||
|
findDefaultPipeline,
|
||||||
|
getErrorMessage,
|
||||||
|
isRequiredRunnerConfigComplete,
|
||||||
|
isWebhookModeEnabled,
|
||||||
|
} = loadWizardUtils();
|
||||||
|
|
||||||
|
test('generates an HTTP Bot signing secret when signatures are enabled', () => {
|
||||||
|
const config = ensureHttpBotSigningSecret('http_bot', {
|
||||||
|
signature_required: true,
|
||||||
|
inbound_secret: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.match(config.inbound_secret, /^[a-f0-9]{64}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves existing or intentionally disabled HTTP Bot signing config', () => {
|
||||||
|
const existing = { signature_required: true, inbound_secret: 'keep-me' };
|
||||||
|
const disabled = { signature_required: false, inbound_secret: '' };
|
||||||
|
|
||||||
|
assert.equal(ensureHttpBotSigningSecret('http_bot', existing), existing);
|
||||||
|
assert.equal(ensureHttpBotSigningSecret('http_bot', disabled), disabled);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not add signing config to other adapters', () => {
|
||||||
|
const config = {};
|
||||||
|
|
||||||
|
assert.equal(ensureHttpBotSigningSecret('web_page_bot', config), config);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extracts the backend message from structured API errors', () => {
|
||||||
|
assert.equal(
|
||||||
|
getErrorMessage({ code: 400, msg: 'Signing secret is required' }),
|
||||||
|
'Signing secret is required',
|
||||||
|
);
|
||||||
|
assert.equal(getErrorMessage(new Error('Network failed')), 'Network failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selects only a usable Workspace default pipeline', () => {
|
||||||
|
const pipelines = [
|
||||||
|
{ uuid: 'recent-pipeline', is_default: false },
|
||||||
|
{ uuid: '', is_default: true },
|
||||||
|
{ uuid: 'default-pipeline', is_default: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.equal(findDefaultPipeline(pipelines)?.uuid, 'default-pipeline');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('configures the selected model as the Local Agent primary model', () => {
|
||||||
|
const config = {
|
||||||
|
trigger: { prefix: '!' },
|
||||||
|
ai: {
|
||||||
|
runner: { runner: 'plugin:external', timeout: 30 },
|
||||||
|
'local-agent': {
|
||||||
|
model: { primary: 'old-model', fallbacks: ['fallback-model'] },
|
||||||
|
tools: { enabled: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const updated = configureLocalAgentPrimaryModel(config, 'selected-model');
|
||||||
|
|
||||||
|
assert.equal(updated.ai.runner.runner, 'local-agent');
|
||||||
|
assert.equal(updated.ai.runner.timeout, 30);
|
||||||
|
assert.equal(updated.ai['local-agent'].model.primary, 'selected-model');
|
||||||
|
assert.deepEqual(updated.ai['local-agent'].model.fallbacks, [
|
||||||
|
'fallback-model',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(updated.ai['local-agent'].tools, { enabled: true });
|
||||||
|
assert.deepEqual(updated.trigger, { prefix: '!' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows webhook guidance only when the adapter webhook mode is active', () => {
|
||||||
|
const dualModeFields = [
|
||||||
|
{
|
||||||
|
name: 'webhook_url',
|
||||||
|
show_if: { field: 'enable-webhook', operator: 'eq', value: true },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
isWebhookModeEnabled(dualModeFields, { 'enable-webhook': false }),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
isWebhookModeEnabled(dualModeFields, { 'enable-webhook': true }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(isWebhookModeEnabled([{ name: 'webhook_url' }], {}), true);
|
||||||
|
assert.equal(isWebhookModeEnabled([], {}), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requires real values for required external runner configuration', () => {
|
||||||
|
const fields = [
|
||||||
|
{ name: 'base-url', required: true, default: 'https://api.dify.ai/v1' },
|
||||||
|
{ name: 'api-key', required: true, default: 'your-api-key' },
|
||||||
|
{ name: 'optional', required: false, default: '' },
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
isRequiredRunnerConfigComplete(fields, {
|
||||||
|
'base-url': 'https://api.dify.ai/v1',
|
||||||
|
'api-key': 'your-api-key',
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
isRequiredRunnerConfigComplete(fields, {
|
||||||
|
'base-url': 'https://api.dify.ai/v1',
|
||||||
|
'api-key': 'app-real-key',
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const wizardSource = fs.readFileSync(
|
||||||
|
path.resolve(currentDirectory, '../../src/app/wizard/page.tsx'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const ownModelSetupSource = fs.readFileSync(
|
||||||
|
path.resolve(
|
||||||
|
currentDirectory,
|
||||||
|
'../../src/app/wizard/components/OwnModelSetup.tsx',
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const widgetSource = fs.readFileSync(
|
||||||
|
path.resolve(
|
||||||
|
currentDirectory,
|
||||||
|
'../../../src/langbot/templates/embed/widget.js',
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
test('shows the test-only notice only when the wizard opts in', () => {
|
||||||
|
assert.match(
|
||||||
|
wizardSource,
|
||||||
|
/widget\.js\?preview=wizard&v=\$\{Date\.now\(\)\}/,
|
||||||
|
);
|
||||||
|
assert.match(wizardSource, /script\.dataset\.testNotice = testNotice/);
|
||||||
|
assert.match(
|
||||||
|
wizardSource,
|
||||||
|
/testNotice=\{t\('wizard\.botConfig\.pageBotTestNotice'\)\}/,
|
||||||
|
);
|
||||||
|
assert.match(widgetSource, /getAttribute\("data-test-notice"\)/);
|
||||||
|
assert.match(widgetSource, /if \(scriptTestNotice\)/);
|
||||||
|
assert.match(widgetSource, /testNotice\.textContent = scriptTestNotice/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('defaults the AI engine step to the workbench option and lists it first', () => {
|
||||||
|
assert.match(
|
||||||
|
wizardSource,
|
||||||
|
/const \[aiChoice, setAiChoice\] = useState<[\s\S]*?>\('more-features'\);/,
|
||||||
|
);
|
||||||
|
|
||||||
|
const choicesStart = wizardSource.indexOf('const choices = [');
|
||||||
|
const moreFeaturesChoice = wizardSource.indexOf(
|
||||||
|
"id: 'more-features' as const",
|
||||||
|
choicesStart,
|
||||||
|
);
|
||||||
|
const externalChoice = wizardSource.indexOf(
|
||||||
|
"id: 'external' as const",
|
||||||
|
choicesStart,
|
||||||
|
);
|
||||||
|
const ownModelChoice = wizardSource.indexOf(
|
||||||
|
"id: 'own-model' as const",
|
||||||
|
choicesStart,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(choicesStart >= 0);
|
||||||
|
assert.ok(moreFeaturesChoice > choicesStart);
|
||||||
|
assert.ok(moreFeaturesChoice < externalChoice);
|
||||||
|
assert.ok(moreFeaturesChoice < ownModelChoice);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses the external-runner layout only while that configuration is open', () => {
|
||||||
|
assert.match(
|
||||||
|
wizardSource,
|
||||||
|
/currentStep === 2 && aiChoice === 'external' && selectedRunner/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restores the default workbench choice when leaving a nested AI setup', () => {
|
||||||
|
assert.equal(
|
||||||
|
wizardSource.match(/onChoiceChange\('more-features'\)/g)?.length,
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('warns local-account users after the bot receives an IM message', () => {
|
||||||
|
assert.match(
|
||||||
|
wizardSource,
|
||||||
|
/messageReceived && userInfo\?\.account_type !== 'space'/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
wizardSource,
|
||||||
|
/wizard\.botConfig\.messageReceivedLocalAccountWarning/,
|
||||||
|
);
|
||||||
|
assert.match(wizardSource, /<AlertTriangle className="size-3 text-white"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('animates AI engine sub-pages and the return to choices', () => {
|
||||||
|
assert.match(
|
||||||
|
wizardSource,
|
||||||
|
/key="ai-engine-own-model"[\s\S]*?slide-in-from-right-4/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
wizardSource,
|
||||||
|
/key="ai-engine-external-picker"[\s\S]*?slide-in-from-right-4/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
wizardSource,
|
||||||
|
/key="ai-engine-choices"[\s\S]*?slide-in-from-left-4/,
|
||||||
|
);
|
||||||
|
assert.match(wizardSource, /motion-reduce:animate-none/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aligns the own-model title and back button with external Agent setup', () => {
|
||||||
|
const ownModelTitle = ownModelSetupSource.indexOf(
|
||||||
|
"t('wizard.aiEngine.ownModelSetupTitle')",
|
||||||
|
);
|
||||||
|
const ownModelBack = ownModelSetupSource.indexOf(
|
||||||
|
"t('wizard.aiEngine.backToChoices')",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(ownModelTitle >= 0);
|
||||||
|
assert.ok(ownModelBack > ownModelTitle);
|
||||||
|
assert.match(ownModelSetupSource, /mx-auto w-full max-w-4xl space-y-6/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('labels both external Agent setup states with their specific title', () => {
|
||||||
|
assert.equal(
|
||||||
|
wizardSource.match(/t\('wizard\.aiEngine\.externalTitle'\)/g)?.length,
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
});
|
||||||
+23
-2
@@ -1,8 +1,12 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), '');
|
||||||
|
const apiProxyTarget = env.VITE_API_PROXY_TARGET || 'http://127.0.0.1:5300';
|
||||||
|
|
||||||
|
return {
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
@@ -10,9 +14,26 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
port: 3000,
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: apiProxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
'/mcp': {
|
||||||
|
target: apiProxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/bots': {
|
||||||
|
target: apiProxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
},
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user