mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-27 20:57:13 +00:00
Compare commits
7 Commits
cd7acd5769
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| aeff8d7e30 | |||
| be3734ffda | |||
| 855ae2bdba | |||
| b66db86bff | |||
| 95b8736e93 | |||
| cabde423a1 | |||
| 08307790e5 |
@@ -295,6 +295,34 @@ class WecomCSClient:
|
||||
raise Exception('Failed to send message')
|
||||
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):
|
||||
"""处理回调请求(独立端口模式,使用全局 request)。"""
|
||||
return await self._handle_callback_internal(request)
|
||||
|
||||
@@ -5,6 +5,11 @@ from .. import entities
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
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')
|
||||
class BanWordFilter(filter_model.ContentFilter):
|
||||
@@ -14,12 +19,17 @@ class BanWordFilter(filter_model.ContentFilter):
|
||||
pass
|
||||
|
||||
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:
|
||||
found, message = await mask_patterns(
|
||||
self.ap.sensitive_meta.data['words'],
|
||||
found, current = await mask_patterns(
|
||||
words,
|
||||
message,
|
||||
mask=self.ap.sensitive_meta.data['mask'],
|
||||
mask_word=self.ap.sensitive_meta.data['mask_word'],
|
||||
mask=mask,
|
||||
mask_word=mask_word,
|
||||
max_pattern_count=_MAX_SENSITIVE_WORD_PATTERNS,
|
||||
)
|
||||
except SafeRegexError as exc:
|
||||
return entities.FilterResult(
|
||||
@@ -31,7 +41,7 @@ class BanWordFilter(filter_model.ContentFilter):
|
||||
|
||||
return entities.FilterResult(
|
||||
level=entities.ResultLevel.MASKED if found else entities.ResultLevel.PASS,
|
||||
replacement=message,
|
||||
replacement=current,
|
||||
user_notice='消息中存在不合适的内容, 请修改' if found else '',
|
||||
console_notice='',
|
||||
)
|
||||
|
||||
@@ -25,6 +25,7 @@ from linebot.v3.webhooks import (
|
||||
ImageMessageContent,
|
||||
VideoMessageContent,
|
||||
AudioMessageContent,
|
||||
UserMentionee,
|
||||
)
|
||||
|
||||
# from linebot import WebhookParser
|
||||
@@ -58,15 +59,19 @@ class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
|
||||
return content_list
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(message, bot_client) -> platform_message.MessageChain:
|
||||
def __init__(self, bot_account_id: str = ''):
|
||||
self.bot_account_id = bot_account_id
|
||||
|
||||
async def target2yiri(self, message, bot_client) -> platform_message.MessageChain:
|
||||
lb_msg_list = []
|
||||
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))
|
||||
|
||||
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):
|
||||
pass
|
||||
elif isinstance(message.message, VideoMessageContent):
|
||||
@@ -86,17 +91,55 @@ class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
lb_msg_list.append(platform_message.Image(base64=data_uri))
|
||||
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):
|
||||
def __init__(self, bot_account_id: str = ''):
|
||||
self.bot_account_id = bot_account_id
|
||||
self.message_converter = LINEMessageConverter(bot_account_id)
|
||||
|
||||
@staticmethod
|
||||
async def yiri2target(
|
||||
event: platform_events.MessageEvent,
|
||||
) -> MessageEvent:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(event, bot_client) -> platform_events.Event:
|
||||
message_chain = await LINEMessageConverter.target2yiri(event, bot_client)
|
||||
async def target2yiri(self, event, bot_client) -> platform_events.Event:
|
||||
message_chain = await self.message_converter.target2yiri(event, bot_client)
|
||||
|
||||
if event.source.type == 'user':
|
||||
return platform_events.FriendMessage(
|
||||
@@ -169,8 +212,8 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
listeners={},
|
||||
card_id_dict={},
|
||||
seq=1,
|
||||
event_converter=LINEEventConverter(),
|
||||
message_converter=LINEMessageConverter(),
|
||||
event_converter=LINEEventConverter(bot_account_id),
|
||||
message_converter=LINEMessageConverter(bot_account_id),
|
||||
line_webhook=line_webhook,
|
||||
parser=parser,
|
||||
configuration=configuration,
|
||||
|
||||
@@ -107,7 +107,7 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
if event.type == 'text':
|
||||
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
|
||||
friend = platform_entities.Friend(
|
||||
id=f'u{event.user_id}',
|
||||
id=f'{event.receiver_id}|u{event.user_id}',
|
||||
nickname=nickname,
|
||||
remark='',
|
||||
)
|
||||
@@ -117,7 +117,7 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
)
|
||||
elif event.type == 'image':
|
||||
friend = platform_entities.Friend(
|
||||
id=f'u{event.user_id}',
|
||||
id=f'{event.receiver_id}|u{event.user_id}',
|
||||
nickname=nickname,
|
||||
remark='',
|
||||
)
|
||||
@@ -197,7 +197,7 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
|
||||
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
||||
for content in content_list:
|
||||
msgid = f'langbot_{uuid.uuid4().hex}'
|
||||
msgid = f'{uuid.uuid4().hex}'
|
||||
if content['type'] == 'text':
|
||||
await self.bot.send_text_msg(
|
||||
open_kfid=open_kfid,
|
||||
@@ -205,6 +205,13 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
msgid=msgid,
|
||||
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):
|
||||
"""设置 bot UUID(用于生成 webhook URL)"""
|
||||
|
||||
@@ -747,9 +747,24 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
converted_parts = []
|
||||
for part in content:
|
||||
if isinstance(part, dict) and part.get('type') == 'image_base64':
|
||||
part['image_url'] = {'url': part['image_base64']}
|
||||
part['type'] = 'image_url'
|
||||
del 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.pop('image_base64', None)
|
||||
else:
|
||||
continue
|
||||
# OpenAI-compatible chat models reject non-image file parts
|
||||
# (audio/document base64 or url). These originate from Voice /
|
||||
# File attachments — including ones replayed from conversation
|
||||
|
||||
@@ -619,7 +619,9 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
and len(func_ret) > 0
|
||||
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:
|
||||
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."""
|
||||
|
||||
|
||||
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)
|
||||
if len(normalized) > MAX_PATTERN_COUNT:
|
||||
raise SafeRegexLimitError(f'At most {MAX_PATTERN_COUNT} regex patterns are allowed')
|
||||
for pattern in normalized:
|
||||
if not isinstance(pattern, str):
|
||||
raise SafeRegexError('Regex patterns must be strings')
|
||||
@@ -115,8 +121,9 @@ def _mask_patterns_sync(
|
||||
mask: str,
|
||||
mask_word: str,
|
||||
timeout_seconds: float,
|
||||
max_pattern_count: int,
|
||||
) -> tuple[bool, str]:
|
||||
normalized_patterns = _validate_patterns(patterns)
|
||||
normalized_patterns = _validate_patterns(patterns, max_pattern_count=max_pattern_count)
|
||||
_validate_input(value)
|
||||
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')
|
||||
@@ -162,6 +169,7 @@ async def mask_patterns(
|
||||
mask: str,
|
||||
mask_word: str,
|
||||
timeout_seconds: float = DEFAULT_OPERATION_TIMEOUT_SECONDS,
|
||||
max_pattern_count: int = MAX_PATTERN_COUNT,
|
||||
) -> tuple[bool, str]:
|
||||
"""Apply untrusted masking patterns with bounded CPU and output growth."""
|
||||
|
||||
@@ -174,4 +182,5 @@ async def mask_patterns(
|
||||
mask=mask,
|
||||
mask_word=mask_word,
|
||||
timeout_seconds=timeout_seconds,
|
||||
max_pattern_count=max_pattern_count,
|
||||
)
|
||||
|
||||
@@ -325,7 +325,7 @@ stages:
|
||||
zh_Hans: API 密钥
|
||||
type: string
|
||||
required: true
|
||||
default: 'your-api-key'
|
||||
default: ''
|
||||
- name: n8n-service-api
|
||||
label:
|
||||
en_US: n8n Workflow API
|
||||
|
||||
@@ -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 == ''
|
||||
@@ -3,18 +3,25 @@ from __future__ import annotations
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from linebot.v3.webhooks import TextMessageContent
|
||||
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'):
|
||||
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
|
||||
event.message = MagicMock(spec=TextMessageContent)
|
||||
event.message.id = message_id
|
||||
event.message.text = text
|
||||
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
|
||||
|
||||
@@ -30,16 +37,21 @@ def _make_event(*, source_type: str, user_id, group_id=None, room_id=None, messa
|
||||
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 line.LINEEventConverter.target2yiri(event1, bot_client=None)
|
||||
result2 = await line.LINEEventConverter.target2yiri(event2, bot_client=None)
|
||||
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
|
||||
@@ -48,11 +60,12 @@ async def test_user_message_launcher_id_stable_across_messages() -> None:
|
||||
|
||||
@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 line.LINEEventConverter.target2yiri(event1, bot_client=None)
|
||||
result2 = await line.LINEEventConverter.target2yiri(event2, bot_client=None)
|
||||
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
|
||||
@@ -61,9 +74,186 @@ async def test_group_message_uses_group_id_not_message_id() -> None:
|
||||
|
||||
@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 line.LINEEventConverter.target2yiri(event, bot_client=None)
|
||||
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 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['external_userid'] == 'external-user'
|
||||
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
|
||||
|
||||
@@ -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')
|
||||
out = req._convert_messages([msg])
|
||||
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
|
||||
async def test_mask_patterns_bounds_replacement_growth_and_masks_matches():
|
||||
found, masked = await safe_regex.mask_patterns(
|
||||
|
||||
@@ -15,7 +15,10 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
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, {
|
||||
QrLoginPlatform,
|
||||
} from '@/app/home/components/qrcode-login/QrCodeLoginDialog';
|
||||
@@ -464,61 +467,6 @@ export default function DynamicFormComponent({
|
||||
const previousInitialValues = useRef(initialValues);
|
||||
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
|
||||
// and `__system.*`-named fields) that should not participate in form state,
|
||||
// validation, or value emission.
|
||||
@@ -574,7 +522,7 @@ export default function DynamicFormComponent({
|
||||
const rawValue = initialValues?.[item.name] ?? item.default;
|
||||
return {
|
||||
...acc,
|
||||
[item.name]: normalizeFieldValue(item, rawValue),
|
||||
[item.name]: normalizeDynamicFormFieldValue(item, rawValue),
|
||||
};
|
||||
}, {} as FormValues),
|
||||
});
|
||||
@@ -611,7 +559,10 @@ export default function DynamicFormComponent({
|
||||
const mergedValues = editableValueSpecs.reduce(
|
||||
(acc, item) => {
|
||||
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;
|
||||
},
|
||||
{} as Record<string, object>,
|
||||
|
||||
@@ -5,6 +5,80 @@ export type DynamicFormSaveValueSpec = Pick<
|
||||
'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([
|
||||
'disabled',
|
||||
'enabled',
|
||||
|
||||
@@ -54,7 +54,7 @@ export function groupByCategory<T extends { categories?: string[] }>(
|
||||
}
|
||||
|
||||
let placed = false;
|
||||
for (const cat of cats) {
|
||||
for (const cat of new Set(cats)) {
|
||||
if (ordered.includes(cat as AdapterCategoryId)) {
|
||||
buckets.get(cat as AdapterCategoryId)!.push(item);
|
||||
placed = true;
|
||||
|
||||
@@ -167,14 +167,7 @@ export default function OwnModelSetup({
|
||||
}, [onSelectionChange]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-3xl space-y-5">
|
||||
<div>
|
||||
<Button variant="ghost" size="sm" onClick={handleBack}>
|
||||
<ArrowLeft className="mr-1.5 size-4" />
|
||||
{t('wizard.aiEngine.backToChoices')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<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')}
|
||||
@@ -184,8 +177,15 @@ export default function OwnModelSetup({
|
||||
</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>
|
||||
<Card className="mx-auto w-full max-w-3xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{t('wizard.aiEngine.addProviderTitle')}
|
||||
@@ -205,7 +205,7 @@ export default function OwnModelSetup({
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<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">
|
||||
|
||||
+76
-30
@@ -6,6 +6,7 @@ import { toast } from 'sonner';
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Sparkles,
|
||||
Loader2,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
systemInfo,
|
||||
bootstrapWorkspaceSession,
|
||||
initializeSystemInfo,
|
||||
userInfo,
|
||||
} from '@/app/infra/http';
|
||||
import { Adapter, Bot, WizardProgress } from '@/app/infra/entities/api';
|
||||
import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
|
||||
@@ -55,6 +57,7 @@ import {
|
||||
ensureHttpBotSigningSecret,
|
||||
findDefaultPipeline,
|
||||
getErrorMessage,
|
||||
isRequiredRunnerConfigComplete,
|
||||
isWebhookModeEnabled,
|
||||
} from '@/app/wizard/utils';
|
||||
|
||||
@@ -124,7 +127,7 @@ export default function WizardPage() {
|
||||
const [messageReceived, setMessageReceived] = useState(false);
|
||||
const [aiChoice, setAiChoice] = useState<
|
||||
'external' | 'own-model' | 'more-features' | null
|
||||
>(null);
|
||||
>('more-features');
|
||||
const [ownModelSelection, setOwnModelSelection] =
|
||||
useState<OwnModelSelection | null>(null);
|
||||
|
||||
@@ -322,13 +325,20 @@ export default function WizardPage() {
|
||||
);
|
||||
}, [selectedRunnerConfigStage]);
|
||||
|
||||
const isRunnerConfigComplete = useMemo(
|
||||
() =>
|
||||
isRequiredRunnerConfigComplete(selectedRunnerConfigItems, runnerConfig),
|
||||
[selectedRunnerConfigItems, runnerConfig],
|
||||
);
|
||||
|
||||
// ---- Runner selection with progress saving ----
|
||||
const handleSelectRunner = useCallback(
|
||||
(runner: string) => {
|
||||
if (runner !== selectedRunner) setRunnerConfig({});
|
||||
setSelectedRunner(runner);
|
||||
saveProgress({ step: 2, selected_runner: runner });
|
||||
},
|
||||
[saveProgress],
|
||||
[saveProgress, selectedRunner],
|
||||
);
|
||||
|
||||
// ---- Navigation helpers ----
|
||||
@@ -599,7 +609,8 @@ export default function WizardPage() {
|
||||
|
||||
const handleFinish = useCallback(async () => {
|
||||
if (!aiChoice || !createdBotUuid || !createdPipelineUuid) return;
|
||||
if (aiChoice === 'external' && !selectedRunner) return;
|
||||
if (aiChoice === 'external' && (!selectedRunner || !isRunnerConfigComplete))
|
||||
return;
|
||||
if (aiChoice === 'own-model' && !ownModelSelection) return;
|
||||
setIsSubmitting(true);
|
||||
let externalPipelineUuid: string | null = null;
|
||||
@@ -739,6 +750,7 @@ export default function WizardPage() {
|
||||
}
|
||||
}, [
|
||||
selectedRunner,
|
||||
isRunnerConfigComplete,
|
||||
createdBotUuid,
|
||||
createdPipelineUuid,
|
||||
aiChoice,
|
||||
@@ -874,7 +886,7 @@ export default function WizardPage() {
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 min-h-0 px-4 sm:px-6 pb-4 sm:pb-6',
|
||||
currentStep === 2 && selectedRunner
|
||||
currentStep === 2 && aiChoice === 'external' && selectedRunner
|
||||
? 'lg:flex lg:flex-col lg:overflow-hidden overflow-y-auto'
|
||||
: 'overflow-y-auto',
|
||||
)}
|
||||
@@ -952,7 +964,8 @@ export default function WizardPage() {
|
||||
disabled={
|
||||
!canProceed() ||
|
||||
isSubmitting ||
|
||||
(aiChoice === 'external' && !selectedRunner) ||
|
||||
(aiChoice === 'external' &&
|
||||
(!selectedRunner || !isRunnerConfigComplete)) ||
|
||||
(aiChoice === 'own-model' && !ownModelSelection)
|
||||
}
|
||||
>
|
||||
@@ -1015,7 +1028,10 @@ function StepPlatform({
|
||||
const { t } = useTranslation();
|
||||
|
||||
const groupedAdapters = useMemo(() => {
|
||||
const withCategories = adapters.map((a) => ({
|
||||
const uniqueAdapters = Array.from(
|
||||
new Map(adapters.map((adapter) => [adapter.name, adapter])).values(),
|
||||
);
|
||||
const withCategories = uniqueAdapters.map((a) => ({
|
||||
...a,
|
||||
categories: a.spec.categories,
|
||||
}));
|
||||
@@ -1182,6 +1198,10 @@ function StepBotConfig({
|
||||
Boolean(webhookUrl),
|
||||
[adapterConfigItems, adapterConfigValues, webhookUrl],
|
||||
);
|
||||
const receivedMessageWithoutLangBotAccount =
|
||||
messageReceived && userInfo?.account_type !== 'space';
|
||||
const receivedMessageSuccessfully =
|
||||
messageReceived && !receivedMessageWithoutLangBotAccount;
|
||||
|
||||
// Stable callback ref
|
||||
const onAdapterConfigRef = useRef(onAdapterConfigChange);
|
||||
@@ -1239,7 +1259,7 @@ function StepBotConfig({
|
||||
<div
|
||||
className={cn(
|
||||
'border px-4 py-3',
|
||||
messageReceived
|
||||
receivedMessageSuccessfully
|
||||
? 'border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/30'
|
||||
: 'border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30',
|
||||
)}
|
||||
@@ -1248,10 +1268,12 @@ function StepBotConfig({
|
||||
<div
|
||||
className={cn(
|
||||
'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full',
|
||||
messageReceived ? 'bg-green-500' : 'bg-amber-500',
|
||||
receivedMessageSuccessfully ? 'bg-green-500' : 'bg-amber-500',
|
||||
)}
|
||||
>
|
||||
{messageReceived ? (
|
||||
{receivedMessageWithoutLangBotAccount ? (
|
||||
<AlertTriangle className="size-3 text-white" />
|
||||
) : messageReceived ? (
|
||||
<Check className="size-3 text-white" />
|
||||
) : selectedAdapterName === 'web_page_bot' ? (
|
||||
<MessageSquare className="size-3 text-white" />
|
||||
@@ -1267,13 +1289,17 @@ function StepBotConfig({
|
||||
<p
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
messageReceived
|
||||
receivedMessageSuccessfully
|
||||
? 'text-green-800 dark:text-green-200'
|
||||
: 'text-amber-800 dark:text-amber-200',
|
||||
)}
|
||||
>
|
||||
{messageReceived
|
||||
? t('wizard.botConfig.messageReceived')
|
||||
? t(
|
||||
receivedMessageWithoutLangBotAccount
|
||||
? 'wizard.botConfig.messageReceivedLocalAccountWarning'
|
||||
: 'wizard.botConfig.messageReceived',
|
||||
)
|
||||
: selectedAdapterName === 'web_page_bot'
|
||||
? t('wizard.botConfig.pageBotTestPrompt')
|
||||
: selectedAdapterName === 'http_bot'
|
||||
@@ -1465,6 +1491,12 @@ function StepAIEngine({
|
||||
}, [runnerOptions, selected]);
|
||||
|
||||
const choices = [
|
||||
{
|
||||
id: 'more-features' as const,
|
||||
icon: Blocks,
|
||||
title: t('wizard.aiEngine.moreFeaturesTitle'),
|
||||
description: t('wizard.aiEngine.moreFeaturesDescription'),
|
||||
},
|
||||
{
|
||||
id: 'external' as const,
|
||||
icon: Cable,
|
||||
@@ -1477,26 +1509,28 @@ function StepAIEngine({
|
||||
title: t('wizard.aiEngine.ownModelTitle'),
|
||||
description: t('wizard.aiEngine.ownModelDescription'),
|
||||
},
|
||||
{
|
||||
id: 'more-features' as const,
|
||||
icon: Blocks,
|
||||
title: t('wizard.aiEngine.moreFeaturesTitle'),
|
||||
description: t('wizard.aiEngine.moreFeaturesDescription'),
|
||||
},
|
||||
];
|
||||
|
||||
if (choice === 'own-model') {
|
||||
return (
|
||||
<OwnModelSetup
|
||||
onBack={() => onChoiceChange(null)}
|
||||
onSelectionChange={onOwnModelSelectionChange}
|
||||
/>
|
||||
<div
|
||||
key="ai-engine-own-model"
|
||||
className="w-full animate-in fade-in-0 slide-in-from-right-4 duration-300 ease-out motion-reduce:animate-none"
|
||||
>
|
||||
<OwnModelSetup
|
||||
onBack={() => onChoiceChange('more-features')}
|
||||
onSelectionChange={onOwnModelSelectionChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (choice !== 'external') {
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl mx-auto">
|
||||
<div
|
||||
key="ai-engine-choices"
|
||||
className="mx-auto max-w-4xl space-y-6 animate-in fade-in-0 slide-in-from-left-4 duration-300 ease-out motion-reduce:animate-none"
|
||||
>
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold">
|
||||
{t('wizard.aiEngine.title')}
|
||||
@@ -1533,16 +1567,23 @@ function StepAIEngine({
|
||||
// Before any runner is selected: centered grid layout
|
||||
if (!selected) {
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl mx-auto">
|
||||
<div
|
||||
key="ai-engine-external-picker"
|
||||
className="mx-auto max-w-4xl space-y-6 animate-in fade-in-0 slide-in-from-right-4 duration-300 ease-out motion-reduce:animate-none"
|
||||
>
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold">
|
||||
{t('wizard.aiEngine.title')}
|
||||
{t('wizard.aiEngine.externalTitle')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t('wizard.aiEngine.runnerDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => onChoiceChange(null)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onChoiceChange('more-features')}
|
||||
>
|
||||
<ArrowLeft className="size-4 mr-1.5" />
|
||||
{t('wizard.aiEngine.backToChoices')}
|
||||
</Button>
|
||||
@@ -1574,9 +1615,14 @@ function StepAIEngine({
|
||||
// On mobile (< lg): single column, normal scroll from parent
|
||||
// On desktop (>= lg): side-by-side with independent scroll per column
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-1 lg:min-h-0 max-w-6xl mx-auto w-full">
|
||||
<div
|
||||
key={`ai-engine-external-config-${selected}`}
|
||||
className="mx-auto flex w-full max-w-6xl flex-col animate-in fade-in-0 slide-in-from-right-4 duration-300 ease-out motion-reduce:animate-none lg:min-h-0 lg:flex-1"
|
||||
>
|
||||
<div className="text-center shrink-0 mb-4">
|
||||
<h2 className="text-xl font-semibold">{t('wizard.aiEngine.title')}</h2>
|
||||
<h2 className="text-xl font-semibold">
|
||||
{t('wizard.aiEngine.externalTitle')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t('wizard.aiEngine.description')}
|
||||
</p>
|
||||
@@ -1586,13 +1632,13 @@ function StepAIEngine({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="self-start mb-3"
|
||||
onClick={() => onChoiceChange(null)}
|
||||
onClick={() => onChoiceChange('more-features')}
|
||||
>
|
||||
<ArrowLeft className="size-4 mr-1.5" />
|
||||
{t('wizard.aiEngine.backToChoices')}
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-col lg:flex-row lg:justify-center gap-6 lg:flex-1 lg:min-h-0 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
<div className="flex flex-col gap-6 lg:min-h-0 lg:flex-1 lg:flex-row lg:justify-center">
|
||||
{/* Left: runner list */}
|
||||
<div className="w-full lg:w-[280px] shrink-0 lg:overflow-y-auto lg:pr-3">
|
||||
{/* p-1 provides space for ring-2 (4px) to render without clipping */}
|
||||
@@ -1639,7 +1685,7 @@ function StepAIEngine({
|
||||
</div>
|
||||
|
||||
{/* Right: runner configuration — fixed width on desktop */}
|
||||
<div className="w-full lg:w-[560px] shrink-0 lg:overflow-y-auto lg:pr-3 animate-in fade-in slide-in-from-right-2 duration-300">
|
||||
<div className="w-full shrink-0 lg:w-[560px] lg:overflow-y-auto lg:pr-3">
|
||||
<div className="p-1">
|
||||
{runnerConfigItems.length > 0 && (
|
||||
<Card>
|
||||
|
||||
@@ -72,6 +72,39 @@ export function isWebhookModeEnabled(
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -1831,6 +1831,8 @@ const enUS = {
|
||||
'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:
|
||||
@@ -1851,7 +1853,7 @@ const enUS = {
|
||||
'Monitor bot activity to verify the platform connection is working.',
|
||||
},
|
||||
aiEngine: {
|
||||
title: 'Select an AI Engine',
|
||||
title: 'Configure AI Engine',
|
||||
description:
|
||||
"Choose the AI engine that will power your bot's intelligence.",
|
||||
optionalDescription:
|
||||
@@ -1892,7 +1894,7 @@ const enUS = {
|
||||
rescanModels: 'Scan models again',
|
||||
moreFeaturesTitle: 'Add More Agent Features',
|
||||
moreFeaturesDescription:
|
||||
'Open the workbench to add tools, knowledge, and other capabilities.',
|
||||
'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',
|
||||
|
||||
@@ -1697,14 +1697,80 @@ const esES = {
|
||||
resaveBot: 'Volver a guardar configuración',
|
||||
botSaved:
|
||||
'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',
|
||||
logsDescription:
|
||||
'Monitorea la actividad del Bot para verificar que la conexión con la plataforma funcione.',
|
||||
},
|
||||
aiEngine: {
|
||||
title: 'Selecciona un motor de IA',
|
||||
title: 'Configura el motor de IA',
|
||||
description:
|
||||
'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: {
|
||||
botInfo: 'Información del Bot',
|
||||
|
||||
@@ -1748,6 +1748,8 @@ const jaJP = {
|
||||
'ボットが有効になりました。続行するには IM からメッセージを送信してください。',
|
||||
messageReceived:
|
||||
'ボットが IM メッセージを受信しました。次のステップに進めます。',
|
||||
messageReceivedLocalAccountWarning:
|
||||
'ボット側の接続設定は正常で、IM メッセージを受信できています。LangBot Account でログインしていないためモデル呼び出しが失敗する場合がありますが、次のステップで独自のモデルを追加できます。',
|
||||
pageBotTestPrompt:
|
||||
'ページボットが有効になりました。右下のチャットバブルをクリックしてメッセージを送信し、会話フロー全体を確認してください。',
|
||||
pageBotTestNotice:
|
||||
@@ -1768,7 +1770,7 @@ const jaJP = {
|
||||
'ボットの活動を監視して、プラットフォーム接続が正常に動作していることを確認します。',
|
||||
},
|
||||
aiEngine: {
|
||||
title: 'AIエンジンを選択',
|
||||
title: 'AIエンジンを設定',
|
||||
description:
|
||||
'ボットのインテリジェンスを駆動するAIエンジンを選択してください。',
|
||||
optionalDescription:
|
||||
@@ -1809,7 +1811,7 @@ const jaJP = {
|
||||
rescanModels: 'モデルを再スキャン',
|
||||
moreFeaturesTitle: 'Agent に機能を追加',
|
||||
moreFeaturesDescription:
|
||||
'ワークベンチを開き、ツールやナレッジなどの機能を追加します。',
|
||||
'ワークベンチを開き、自動生成されたばかりの Agent にツール、ナレッジベースなどの機能を追加します。',
|
||||
runnerDescription: '外部 Agent の Runner を選択し、接続を設定します。',
|
||||
backToChoices: '選択肢に戻る',
|
||||
createExternal: '作成して関連付ける',
|
||||
|
||||
@@ -1666,14 +1666,81 @@ const ruRU = {
|
||||
resaveBot: 'Пересохранить конфигурацию',
|
||||
botSaved:
|
||||
'Конфигурация бота сохранена и включена. Проверьте журналы для подтверждения подключения.',
|
||||
waitingForMessage:
|
||||
'Бот включён. Отправьте ему сообщение из мессенджера, чтобы продолжить.',
|
||||
messageReceived:
|
||||
'Бот получил сообщение. Можно перейти к следующему шагу.',
|
||||
messageReceivedLocalAccountWarning:
|
||||
'Подключение бота настроено правильно, и сообщение получено. Поскольку вход выполнен не через аккаунт LangBot, вызовы модели могут завершаться ошибкой; перейдите к следующему шагу, чтобы добавить собственную модель.',
|
||||
pageBotTestPrompt:
|
||||
'Бот для веб-страницы включён. Нажмите на значок чата в правом нижнем углу и отправьте сообщение, чтобы проверить полный сценарий диалога.',
|
||||
pageBotTestNotice:
|
||||
'Только для тестирования. Встройте код в настоящую внешнюю веб-страницу.',
|
||||
webhookTestPrompt:
|
||||
'URL обратного вызова готов. Настройте его на внешней платформе, затем отправьте боту настоящее сообщение.',
|
||||
httpTestPrompt:
|
||||
'HTTP-бот включён. Отправьте сюда настоящее входящее сообщение, чтобы проверить подключение.',
|
||||
httpTestDefaultMessage:
|
||||
'Здравствуйте, это тестовое сообщение подключения.',
|
||||
sendHttpTest: 'Отправить тестовое сообщение',
|
||||
httpTestAccepted:
|
||||
'Тестовое сообщение принято. Оно скоро появится в журнале.',
|
||||
httpTestMissingSecret:
|
||||
'Введите секрет подписи входящих запросов и сначала сохраните конфигурацию.',
|
||||
httpTestFailed: 'Не удалось отправить тестовое сообщение: {{error}}',
|
||||
logsTitle: 'Журналы бота',
|
||||
logsDescription:
|
||||
'Отслеживайте активность бота для проверки подключения к платформе.',
|
||||
},
|
||||
aiEngine: {
|
||||
title: 'Выберите ИИ-движок',
|
||||
title: 'Настройте ИИ-движок',
|
||||
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: {
|
||||
botInfo: 'Информация о боте',
|
||||
|
||||
@@ -1634,13 +1634,75 @@ const thTH = {
|
||||
resaveBot: 'บันทึกการกำหนดค่าอีกครั้ง',
|
||||
botSaved:
|
||||
'บันทึกและเปิดใช้งาน 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',
|
||||
logsDescription:
|
||||
'ตรวจสอบกิจกรรม Bot เพื่อยืนยันว่าการเชื่อมต่อแพลตฟอร์มทำงานอยู่',
|
||||
},
|
||||
aiEngine: {
|
||||
title: 'เลือกเครื่องมือ AI',
|
||||
title: 'กำหนดค่าเครื่องมือ AI',
|
||||
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: {
|
||||
botInfo: 'ข้อมูล Bot',
|
||||
|
||||
@@ -1658,13 +1658,78 @@ const viVN = {
|
||||
resaveBot: 'Lưu lại cấu hình',
|
||||
botSaved:
|
||||
'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',
|
||||
logsDescription:
|
||||
'Giám sát hoạt động Bot để xác minh kết nối nền tảng đang hoạt động.',
|
||||
},
|
||||
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.',
|
||||
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: {
|
||||
botInfo: 'Thông tin Bot',
|
||||
|
||||
@@ -1751,6 +1751,8 @@ const zhHans = {
|
||||
botSaved: '机器人配置已保存并启用,请查看日志确认连接正常。',
|
||||
waitingForMessage: '机器人已启用。请在 IM 中向机器人发送一条消息以继续。',
|
||||
messageReceived: '机器人已成功收到 IM 消息,可以进入下一步。',
|
||||
messageReceivedLocalAccountWarning:
|
||||
'机器人侧已配置正常并成功收到 IM 消息。当前未通过 LangBot Account 登录,模型调用可能报错;可以进入下一步添加自己的模型。',
|
||||
pageBotTestPrompt:
|
||||
'页面机器人已启用。点击右下角聊天气泡并发送一条消息,验证完整对话链路。',
|
||||
pageBotTestNotice: '仅供测试使用,请嵌入代码到真实外部网页。',
|
||||
@@ -1766,7 +1768,7 @@ const zhHans = {
|
||||
logsDescription: '监控机器人活动,确认平台连接是否正常工作。',
|
||||
},
|
||||
aiEngine: {
|
||||
title: '选择 AI 引擎',
|
||||
title: '配置 AI 引擎',
|
||||
description: '选择驱动机器人智能的 AI 引擎。',
|
||||
optionalDescription: '这一步可选。选择接下来要如何完善当前 Agent。',
|
||||
externalTitle: '接入外部平台 Agent',
|
||||
@@ -1799,7 +1801,8 @@ const zhHans = {
|
||||
editProvider: '修改供应商',
|
||||
rescanModels: '重新扫描模型',
|
||||
moreFeaturesTitle: '给现在的 Agent 配置更多功能',
|
||||
moreFeaturesDescription: '进入工作台,为 Agent 添加工具、知识库等能力。',
|
||||
moreFeaturesDescription:
|
||||
'进入工作台,为刚刚自动生成的 Agent 添加工具、知识库等能力',
|
||||
runnerDescription: '选择外部 Agent 的 Runner 并完成连接配置。',
|
||||
backToChoices: '返回选项',
|
||||
createExternal: '创建并绑定',
|
||||
|
||||
@@ -1584,12 +1584,65 @@ const zhHant = {
|
||||
saveBot: '儲存並啟用',
|
||||
resaveBot: '重新儲存配置',
|
||||
botSaved: '機器人配置已儲存並啟用,請查看日誌確認連接正常。',
|
||||
waitingForMessage: '機器人已啟用。請在 IM 中向機器人傳送一則訊息以繼續。',
|
||||
messageReceived: '機器人已成功收到 IM 訊息,可以進入下一步。',
|
||||
messageReceivedLocalAccountWarning:
|
||||
'機器人側已配置正常並成功收到 IM 訊息。目前未透過 LangBot Account 登入,模型呼叫可能報錯;可以進入下一步新增自己的模型。',
|
||||
pageBotTestPrompt:
|
||||
'頁面機器人已啟用。點擊右下角聊天氣泡並傳送一則訊息,驗證完整對話流程。',
|
||||
pageBotTestNotice: '僅供測試使用,請將程式碼嵌入真實的外部網頁。',
|
||||
webhookTestPrompt:
|
||||
'回呼 URL 已準備好。請在外部平台完成配置,然後向機器人傳送一則真實訊息。',
|
||||
httpTestPrompt:
|
||||
'HTTP 機器人已啟用。請在此傳送一則真實的入站訊息以驗證連接。',
|
||||
httpTestDefaultMessage: '你好,這是一則連接測試訊息。',
|
||||
sendHttpTest: '傳送測試訊息',
|
||||
httpTestAccepted: '測試訊息已被接受,稍後將顯示在日誌中。',
|
||||
httpTestMissingSecret: '請先填寫入站簽章密鑰並儲存配置。',
|
||||
httpTestFailed: '傳送測試訊息失敗:{{error}}',
|
||||
logsTitle: '機器人日誌',
|
||||
logsDescription: '監控機器人活動,確認平台連接是否正常運作。',
|
||||
},
|
||||
aiEngine: {
|
||||
title: '選擇 AI 引擎',
|
||||
title: '配置 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: {
|
||||
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.exports,
|
||||
);
|
||||
return loadedModule.exports.normalizeDynamicFormValuesForSave;
|
||||
return loadedModule.exports;
|
||||
}
|
||||
|
||||
test('normalizes only single-line text fields in a dynamic form save snapshot', () => {
|
||||
const normalizeDynamicFormValuesForSave = loadNormalizer();
|
||||
const { normalizeDynamicFormValuesForSave } = loadNormalizer();
|
||||
const specs = [
|
||||
{ name: 'single-line', type: 'string', 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,
|
||||
),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ const {
|
||||
ensureHttpBotSigningSecret,
|
||||
findDefaultPipeline,
|
||||
getErrorMessage,
|
||||
isRequiredRunnerConfigComplete,
|
||||
isWebhookModeEnabled,
|
||||
} = loadWizardUtils();
|
||||
|
||||
@@ -119,3 +120,26 @@ test('shows webhook guidance only when the adapter webhook mode is active', () =
|
||||
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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -9,6 +9,13 @@ 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,
|
||||
@@ -31,3 +38,91 @@ test('shows the test-only notice only when the wizard opts in', () => {
|
||||
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,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user