Compare commits

..

7 Commits

Author SHA1 Message Date
RockChinQ aeff8d7e30 fix(i18n): complete wizard locale keys 2026-08-28 01:37:45 +08:00
Dongchuan Fu be3734ffda feat(wizard): rework agent onboarding flow (#2471)
* feat(wizard): rework agent onboarding flow

* fix(web): support LAN development access

* fix(wizard): parse ranked model selection entries

* feat(wizard): add inbound bot verification

* feat(wizard): add floating page bot verification

* fix(wizard): repair HTTP bot inbound test setup

* feat(wizard): streamline custom model onboarding

* feat(wizard): label page bot test preview

* style(space): apply ruff formatting

* fix(wizard): polish AI engine onboarding

* fix(wizard): clarify local account message test

* feat(wizard): animate AI engine transitions

* fix(wizard): align AI engine setup headers

---------

Co-authored-by: langbot-dev <langbot@users.noreply.github.com>
Co-authored-by: RockChinQ <rockchinq@gmail.com>
2026-08-28 01:30:30 +08:00
leonoxo 855ae2bdba fix(line): map LINE mentions to At elements so the at-bot rule works (#2478)
The LINE adapter passed text through as a single Plain component,
ignoring the mention payload (mentions[].index/length/isSelf) that the
Line Messaging API includes in the webhook. As a result:

- At(target=bot_account_id) never appeared in the message chain, so the
  'at-bot' group respond rule silently dropped every @bot mention.
- The bot only replied when the message happened to match the prefix
  rule (e.g. starting with 'ai').

Now LINEMessageConverter reads message.message.mention and builds the
chain per mention position:

- Bot mention (isSelf) -> At(target=bot_account_id) so AtBotRule matches
  the same way as other adapters (dingtalk/lark etc.).
- Other mentions -> At(target=<line user id>, display=<mention text>).
  At.__str__ already prepends '@', so the display text carries no
  double '@' and the rendered text (prefix/regexp rules, quotes,
  session context) is byte-identical to before.
- Missing/out-of-bounds mentions are skipped defensively.

target2yiri becomes an instance method (like wechatpad/aiocqhttp) so
the converters can hold bot_account_id; LINEAdapter passes it in from
its own config.
2026-08-27 18:38:27 +08:00
fishzjp b66db86bff fix(provider): stringify MCP tool results for OpenAI-compatible APIs (#2476)
execute_func_call returns list[ContentElement] for MCP tools, but the
runner assigned that list directly to the tool-message content. The
OpenAI chat-completions spec requires tool-message content to be a
string, so OpenAI-compatible endpoints return HTTP 500 when the raw
list is sent.

Serialize the list to a string before building the tool message, using
ContentElement.__str__ which returns the text payload for text elements
and a human-readable placeholder for images and files. Fixes #2457.
2026-08-27 18:19:04 +08:00
fishzjp 95b8736e93 fix(provider): tolerate trimmed image parts in litellm message conversion (#2475)
SessionManager clears image_base64 on past turns to save memory, and
exclude_none serialization drops the hollowed field entirely, so a
replayed history part can arrive as {'type': 'image_base64'} with no
payload. The converter accessed the missing key unconditionally and
raised KeyError on every turn after an image was sent.

Prefer the base64 payload when present, fall back to an image_url that
survived on the same element, and drop hollow parts otherwise (same
strategy as the existing file-part handling). Fixes #2469.
2026-08-27 18:07:43 +08:00
Yang cabde423a1 Fix wecomcs open_kfid msgid (#2449)
* Update wecomcs.py

fix bug wecomcs send_message open_kfid
event.receiver_id  is open_kfid

* Update wecomcs.py

fix bug msgid exceeds the 32-byte limit

* test(wecomcs): cover bounded message IDs and images

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-27 17:30:49 +08:00
ciri667 08307790e5 fix(cntfilter): allow legacy sensitive-word lists over 64 patterns (#2467)
* fix(cntfilter): allow legacy sensitive-word lists over 64 patterns

Legacy sensitive-words.json files shipped ~70 rules. After v4.10.7,
BanWordFilter treated the 64-pattern safe_regex cap as a hard failure
and blocked every message. Raise the cap only on the sensitive-word
path, keep the 50ms CPU budget, and truncate oversized lists with a
one-time warning.

Fixes #2443

* fix(cntfilter): reject oversized sensitive-word lists

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-25 23:53:41 +08:00
33 changed files with 1532 additions and 148 deletions
@@ -295,6 +295,34 @@ class WecomCSClient:
raise Exception('Failed to send message') raise Exception('Failed to send message')
return data return data
@_bounded_token_retry
async def send_image_msg(self, open_kfid: str, external_userid: str, msgid: str, media_id: str):
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = f'{self.base_url}/kf/send_msg?access_token={self.access_token}'
payload = {
'touser': external_userid,
'open_kfid': open_kfid,
'msgid': msgid,
'msgtype': 'image',
'image': {
'media_id': media_id,
},
}
async with self._http_client_context() as client:
response = await client.post(url, json=payload)
data = await httpclient.parse_json_response(response)
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
return await self.send_image_msg(open_kfid, external_userid, msgid, media_id)
if data['errcode'] != 0:
await self.logger.error(f'发送图片失败:{data}')
raise Exception('Failed to send image message')
return data
async def handle_callback_request(self): async def handle_callback_request(self):
"""处理回调请求(独立端口模式,使用全局 request)。""" """处理回调请求(独立端口模式,使用全局 request)。"""
return await self._handle_callback_internal(request) return await self._handle_callback_internal(request)
@@ -5,6 +5,11 @@ from .. import entities
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
from ....utils.safe_regex import SafeRegexError, mask_patterns from ....utils.safe_regex import SafeRegexError, mask_patterns
# Legacy sensitive-words.json files shipped ~70 rules, which exceeds the
# default safe_regex per-call cap of 64 and used to fail-close every message.
# Keep one 50ms CPU budget for the whole list; only raise the pattern cap.
_MAX_SENSITIVE_WORD_PATTERNS = 256
@filter_model.filter_class('ban-word-filter') @filter_model.filter_class('ban-word-filter')
class BanWordFilter(filter_model.ContentFilter): class BanWordFilter(filter_model.ContentFilter):
@@ -14,12 +19,17 @@ class BanWordFilter(filter_model.ContentFilter):
pass pass
async def process(self, query: pipeline_query.Query, message: str) -> entities.FilterResult: async def process(self, query: pipeline_query.Query, message: str) -> entities.FilterResult:
words = self.ap.sensitive_meta.data.get('words') or []
mask = self.ap.sensitive_meta.data['mask']
mask_word = self.ap.sensitive_meta.data['mask_word']
try: try:
found, message = await mask_patterns( found, current = await mask_patterns(
self.ap.sensitive_meta.data['words'], words,
message, message,
mask=self.ap.sensitive_meta.data['mask'], mask=mask,
mask_word=self.ap.sensitive_meta.data['mask_word'], mask_word=mask_word,
max_pattern_count=_MAX_SENSITIVE_WORD_PATTERNS,
) )
except SafeRegexError as exc: except SafeRegexError as exc:
return entities.FilterResult( return entities.FilterResult(
@@ -31,7 +41,7 @@ class BanWordFilter(filter_model.ContentFilter):
return entities.FilterResult( return entities.FilterResult(
level=entities.ResultLevel.MASKED if found else entities.ResultLevel.PASS, level=entities.ResultLevel.MASKED if found else entities.ResultLevel.PASS,
replacement=message, replacement=current,
user_notice='消息中存在不合适的内容, 请修改' if found else '', user_notice='消息中存在不合适的内容, 请修改' if found else '',
console_notice='', console_notice='',
) )
+51 -8
View File
@@ -25,6 +25,7 @@ from linebot.v3.webhooks import (
ImageMessageContent, ImageMessageContent,
VideoMessageContent, VideoMessageContent,
AudioMessageContent, AudioMessageContent,
UserMentionee,
) )
# from linebot import WebhookParser # from linebot import WebhookParser
@@ -58,15 +59,19 @@ class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
return content_list return content_list
@staticmethod def __init__(self, bot_account_id: str = ''):
async def target2yiri(message, bot_client) -> platform_message.MessageChain: self.bot_account_id = bot_account_id
async def target2yiri(self, message, bot_client) -> platform_message.MessageChain:
lb_msg_list = [] lb_msg_list = []
msg_create_time = datetime.datetime.fromtimestamp(int(message.timestamp) / 1000) msg_create_time = datetime.datetime.fromtimestamp(int(message.timestamp) / 1000)
lb_msg_list.append(platform_message.Source(id=message.webhook_event_id, time=msg_create_time)) lb_msg_list.append(platform_message.Source(id=message.webhook_event_id, time=msg_create_time))
if isinstance(message.message, TextMessageContent): if isinstance(message.message, TextMessageContent):
lb_msg_list.append(platform_message.Plain(text=message.message.text)) lb_msg_list.extend(
self._build_text_components(message.message.text, getattr(message.message, 'mention', None))
)
elif isinstance(message.message, AudioMessageContent): elif isinstance(message.message, AudioMessageContent):
pass pass
elif isinstance(message.message, VideoMessageContent): elif isinstance(message.message, VideoMessageContent):
@@ -86,17 +91,55 @@ class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
lb_msg_list.append(platform_message.Image(base64=data_uri)) lb_msg_list.append(platform_message.Image(base64=data_uri))
return platform_message.MessageChain(lb_msg_list) return platform_message.MessageChain(lb_msg_list)
def _build_text_components(self, text: str, mention) -> list:
"""Build message components from text, inserting At components for mentions.
LINE provides mention positions (index/length) and is_self per mentionee in the
webhook payload. Mapping the bot mention to At(target=bot_account_id) makes the
'at-bot' group respond rule work for LINE, consistent with other adapters.
"""
components: list = []
if not mention or not mention.mentionees:
if text:
components.append(platform_message.Plain(text=text))
return components
segments: list[tuple[int, int, object]] = sorted((m.index, m.index + m.length, m) for m in mention.mentionees)
cursor = 0
for start, end, mentionee in segments:
if start < cursor:
start, end = cursor, min(end, len(text))
if start < cursor or end <= start or end > len(text):
continue
if start > cursor:
components.append(platform_message.Plain(text=text[cursor:start]))
if isinstance(mentionee, UserMentionee):
target = self.bot_account_id if mentionee.is_self else mentionee.user_id
if not target:
target = text[start:end]
else:
target = text[start:end]
# At.__str__ already prepends '@', so strip one from the LINE text token.
display = text[start:end].lstrip('@')
components.append(platform_message.At(target=str(target), display=display))
cursor = end
if cursor < len(text):
components.append(platform_message.Plain(text=text[cursor:]))
return components
class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter): class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
def __init__(self, bot_account_id: str = ''):
self.bot_account_id = bot_account_id
self.message_converter = LINEMessageConverter(bot_account_id)
@staticmethod @staticmethod
async def yiri2target( async def yiri2target(
event: platform_events.MessageEvent, event: platform_events.MessageEvent,
) -> MessageEvent: ) -> MessageEvent:
pass pass
@staticmethod async def target2yiri(self, event, bot_client) -> platform_events.Event:
async def target2yiri(event, bot_client) -> platform_events.Event: message_chain = await self.message_converter.target2yiri(event, bot_client)
message_chain = await LINEMessageConverter.target2yiri(event, bot_client)
if event.source.type == 'user': if event.source.type == 'user':
return platform_events.FriendMessage( return platform_events.FriendMessage(
@@ -169,8 +212,8 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
listeners={}, listeners={},
card_id_dict={}, card_id_dict={},
seq=1, seq=1,
event_converter=LINEEventConverter(), event_converter=LINEEventConverter(bot_account_id),
message_converter=LINEMessageConverter(), message_converter=LINEMessageConverter(bot_account_id),
line_webhook=line_webhook, line_webhook=line_webhook,
parser=parser, parser=parser,
configuration=configuration, configuration=configuration,
+10 -3
View File
@@ -107,7 +107,7 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
if event.type == 'text': if event.type == 'text':
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id) yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
friend = platform_entities.Friend( friend = platform_entities.Friend(
id=f'u{event.user_id}', id=f'{event.receiver_id}|u{event.user_id}',
nickname=nickname, nickname=nickname,
remark='', remark='',
) )
@@ -117,7 +117,7 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
) )
elif event.type == 'image': elif event.type == 'image':
friend = platform_entities.Friend( friend = platform_entities.Friend(
id=f'u{event.user_id}', id=f'{event.receiver_id}|u{event.user_id}',
nickname=nickname, nickname=nickname,
remark='', remark='',
) )
@@ -197,7 +197,7 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
content_list = await WecomMessageConverter.yiri2target(message, self.bot) content_list = await WecomMessageConverter.yiri2target(message, self.bot)
for content in content_list: for content in content_list:
msgid = f'langbot_{uuid.uuid4().hex}' msgid = f'{uuid.uuid4().hex}'
if content['type'] == 'text': if content['type'] == 'text':
await self.bot.send_text_msg( await self.bot.send_text_msg(
open_kfid=open_kfid, open_kfid=open_kfid,
@@ -205,6 +205,13 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
msgid=msgid, msgid=msgid,
content=content['content'], content=content['content'],
) )
elif content['type'] == 'image':
await self.bot.send_image_msg(
open_kfid=open_kfid,
external_userid=external_userid,
msgid=msgid,
media_id=content['media_id'],
)
def set_bot_uuid(self, bot_uuid: str): def set_bot_uuid(self, bot_uuid: str):
"""设置 bot UUID(用于生成 webhook URL""" """设置 bot UUID(用于生成 webhook URL"""
@@ -747,9 +747,24 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
converted_parts = [] converted_parts = []
for part in content: for part in content:
if isinstance(part, dict) and part.get('type') == 'image_base64': if isinstance(part, dict) and part.get('type') == 'image_base64':
part['image_url'] = {'url': part['image_base64']} # History trimming (SessionManager) clears image_base64
part['type'] = 'image_url' # on past turns and exclude_none serialization drops
del part['image_base64'] # 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 # OpenAI-compatible chat models reject non-image file parts
# (audio/document base64 or url). These originate from Voice / # (audio/document base64 or url). These originate from Voice /
# File attachments — including ones replayed from conversation # File attachments — including ones replayed from conversation
@@ -619,7 +619,9 @@ class LocalAgentRunner(runner.RequestRunner):
and len(func_ret) > 0 and len(func_ret) > 0
and isinstance(func_ret[0], provider_message.ContentElement) and isinstance(func_ret[0], provider_message.ContentElement)
): ):
tool_content = func_ret # OpenAI-compatible APIs require tool-message content to be a
# string; a raw list of ContentElement causes HTTP 500 (#2457).
tool_content = '\n'.join(str(ce) for ce in func_ret)
else: else:
tool_content = json.dumps(func_ret, ensure_ascii=False) tool_content = json.dumps(func_ret, ensure_ascii=False)
+13 -4
View File
@@ -27,10 +27,16 @@ class SafeRegexTimeoutError(SafeRegexError):
"""Raised when the regex engine exhausts the operation CPU budget.""" """Raised when the regex engine exhausts the operation CPU budget."""
def _validate_patterns(patterns: Sequence[str]) -> tuple[str, ...]: def _validate_patterns(
patterns: Sequence[str],
*,
max_pattern_count: int = MAX_PATTERN_COUNT,
) -> tuple[str, ...]:
if max_pattern_count < 1:
raise ValueError('max_pattern_count must be positive')
if len(patterns) > max_pattern_count:
raise SafeRegexLimitError(f'At most {max_pattern_count} regex patterns are allowed')
normalized = tuple(patterns) normalized = tuple(patterns)
if len(normalized) > MAX_PATTERN_COUNT:
raise SafeRegexLimitError(f'At most {MAX_PATTERN_COUNT} regex patterns are allowed')
for pattern in normalized: for pattern in normalized:
if not isinstance(pattern, str): if not isinstance(pattern, str):
raise SafeRegexError('Regex patterns must be strings') raise SafeRegexError('Regex patterns must be strings')
@@ -115,8 +121,9 @@ def _mask_patterns_sync(
mask: str, mask: str,
mask_word: str, mask_word: str,
timeout_seconds: float, timeout_seconds: float,
max_pattern_count: int,
) -> tuple[bool, str]: ) -> tuple[bool, str]:
normalized_patterns = _validate_patterns(patterns) normalized_patterns = _validate_patterns(patterns, max_pattern_count=max_pattern_count)
_validate_input(value) _validate_input(value)
if len(mask) > MAX_REPLACEMENT_CHARS or len(mask_word) > MAX_REPLACEMENT_CHARS: if len(mask) > MAX_REPLACEMENT_CHARS or len(mask_word) > MAX_REPLACEMENT_CHARS:
raise SafeRegexLimitError(f'Regex replacements may contain at most {MAX_REPLACEMENT_CHARS} characters') raise SafeRegexLimitError(f'Regex replacements may contain at most {MAX_REPLACEMENT_CHARS} characters')
@@ -162,6 +169,7 @@ async def mask_patterns(
mask: str, mask: str,
mask_word: str, mask_word: str,
timeout_seconds: float = DEFAULT_OPERATION_TIMEOUT_SECONDS, timeout_seconds: float = DEFAULT_OPERATION_TIMEOUT_SECONDS,
max_pattern_count: int = MAX_PATTERN_COUNT,
) -> tuple[bool, str]: ) -> tuple[bool, str]:
"""Apply untrusted masking patterns with bounded CPU and output growth.""" """Apply untrusted masking patterns with bounded CPU and output growth."""
@@ -174,4 +182,5 @@ async def mask_patterns(
mask=mask, mask=mask,
mask_word=mask_word, mask_word=mask_word,
timeout_seconds=timeout_seconds, timeout_seconds=timeout_seconds,
max_pattern_count=max_pattern_count,
) )
@@ -325,7 +325,7 @@ stages:
zh_Hans: API 密钥 zh_Hans: API 密钥
type: string type: string
required: true required: true
default: 'your-api-key' default: ''
- name: n8n-service-api - name: n8n-service-api
label: label:
en_US: n8n Workflow API en_US: n8n Workflow API
+113
View File
@@ -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 import pytest
from unittest.mock import MagicMock 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 import botmgr as _botmgr # noqa: F401
from langbot.pkg.platform.sources import line 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 = MagicMock()
event.timestamp = 1700000000000 event.timestamp = 1700000000000
event.message = MagicMock(spec=TextMessageContent) message = MagicMock(spec=TextMessageContent)
event.message.id = message_id message.id = message_id
event.message.text = text message.text = text
message.mention = mention
event.message = message
event.message.webhook_event_id = f'webhook-{message_id}' event.message.webhook_event_id = f'webhook-{message_id}'
event.message.timestamp = event.timestamp 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 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 @pytest.mark.asyncio
async def test_user_message_launcher_id_stable_across_messages() -> None: async def test_user_message_launcher_id_stable_across_messages() -> None:
"""Two distinct messages from the same LINE user must resolve to the same """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). 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') 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') event2 = _make_event(source_type='user', user_id='U-stable-user', message_id='msg-2')
result1 = await line.LINEEventConverter.target2yiri(event1, bot_client=None) result1 = await converter.target2yiri(event1, bot_client=None)
result2 = await line.LINEEventConverter.target2yiri(event2, bot_client=None) result2 = await converter.target2yiri(event2, bot_client=None)
assert result1.sender.id == 'U-stable-user' assert result1.sender.id == 'U-stable-user'
assert result1.sender.id == result2.sender.id 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 @pytest.mark.asyncio
async def test_group_message_uses_group_id_not_message_id() -> None: 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') 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') 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) result1 = await converter.target2yiri(event1, bot_client=None)
result2 = await line.LINEEventConverter.target2yiri(event2, bot_client=None) result2 = await converter.target2yiri(event2, bot_client=None)
assert result1.sender.group.id == 'G-stable-group' assert result1.sender.group.id == 'G-stable-group'
assert result1.sender.group.id == result2.sender.group.id 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 @pytest.mark.asyncio
async def test_room_message_uses_room_id_and_falls_back_when_user_id_missing() -> None: 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') 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.group.id == 'R-stable-room'
assert result.sender.id == 'R-stable-room' assert result.sender.id == 'R-stable-room'
def _plain_texts(chain: platform_message.MessageChain) -> list[str]:
return [c.text for c in chain if isinstance(c, platform_message.Plain)]
def _ats(chain: platform_message.MessageChain) -> list[platform_message.At]:
return [c for c in chain if isinstance(c, platform_message.At)]
@pytest.mark.asyncio
async def test_no_mention_keeps_plain_text() -> None:
converter = _make_converter()
event = _make_event(source_type='group', user_id='U-member', group_id='G1', message_id='m1', text='hello world')
chain = await converter.message_converter.target2yiri(event, bot_client=None)
assert _plain_texts(chain) == ['hello world']
assert _ats(chain) == []
@pytest.mark.asyncio
async def test_bot_mention_maps_to_at_with_bot_account_id() -> None:
"""A @bot mention must become At(target=bot_account_id) so the 'at-bot'
group respond rule matches (previously the mention was lost and the message
was silently dropped in groups with at-only rules).
"""
mention = MagicMock()
mention.mentionees = [
UserMentionee(type='user', index=0, length=4, userId='U-bot-user-id', isSelf=True),
]
converter = _make_converter()
event = _make_event(
source_type='group',
user_id='U-member',
group_id='G1',
message_id='m1',
text='@BOT hey',
mention=mention,
)
chain = await converter.message_converter.target2yiri(event, bot_client=None)
ats = _ats(chain)
assert len(ats) == 1
assert ats[0].target == BOT_ACCOUNT_ID
assert _plain_texts(chain) == [' hey']
@pytest.mark.asyncio
async def test_other_user_mention_keeps_display_text() -> None:
"""Mentions of other users keep their display text in the message string,
so prefix/regexp rules that match the raw '@Name ...' text still work.
"""
mention = MagicMock()
mention.mentionees = [
UserMentionee(type='user', index=0, length=6, userId='U-other', isSelf=False),
]
converter = _make_converter()
event = _make_event(
source_type='group',
user_id='U-member',
group_id='G1',
message_id='m1',
text='@Alice hello',
mention=mention,
)
chain = await converter.message_converter.target2yiri(event, bot_client=None)
ats = _ats(chain)
assert len(ats) == 1
assert ats[0].target == 'U-other'
# str() of the At component falls back to display when set
assert str(chain) == '@Alice hello'
@pytest.mark.asyncio
async def test_bot_mention_triggers_atbot_rule() -> None:
"""End-to-end: a group message that @mentions the bot must be accepted by
the at-bot respond rule (this is the regression that silently dropped
'@bot' messages in LINE groups).
"""
from langbot.pkg.pipeline.resprule.rules.atbot import AtBotRule
mention = MagicMock()
mention.mentionees = [
UserMentionee(type='user', index=0, length=6, userId='U-bot-user-id', isSelf=True),
]
converter = _make_converter()
event = _make_event(
source_type='group',
user_id='U-member',
group_id='G1',
message_id='m1',
text='@RAIQt hi',
mention=mention,
)
chain = await converter.message_converter.target2yiri(event, bot_client=None)
query = MagicMock()
query.adapter = MagicMock()
query.adapter.bot_account_id = BOT_ACCOUNT_ID
rule = AtBotRule(ap=MagicMock())
result = await rule.match(str(chain), chain, {'at': True}, query)
assert result.matching is True
@pytest.mark.asyncio
async def test_group_without_bot_mention_still_dropped_by_atbot_rule() -> None:
from langbot.pkg.pipeline.resprule.rules.atbot import AtBotRule
converter = _make_converter()
event = _make_event(source_type='group', user_id='U-member', group_id='G1', message_id='m1', text='hello')
chain = await converter.message_converter.target2yiri(event, bot_client=None)
query = MagicMock()
query.adapter = MagicMock()
query.adapter.bot_account_id = BOT_ACCOUNT_ID
rule = AtBotRule(ap=MagicMock())
result = await rule.match(str(chain), chain, {'at': True}, query)
assert result.matching is False
@pytest.mark.asyncio
async def test_at_all_mention_preserved_as_at_component() -> None:
mention = MagicMock()
mention.mentionees = [
AllMentionee(type='all', index=0, length=4),
]
converter = _make_converter()
event = _make_event(
source_type='group',
user_id='U-member',
group_id='G1',
message_id='m1',
text='@All hello',
mention=mention,
)
chain = await converter.message_converter.target2yiri(event, bot_client=None)
ats = _ats(chain)
assert len(ats) == 1
assert str(chain) == '@All hello'
@pytest.mark.asyncio
async def test_multiple_mentions_sorted_by_position() -> None:
mention = MagicMock()
# Intentionally out of order to exercise sorting
mention.mentionees = [
UserMentionee(type='user', index=9, length=4, userId='U-b', isSelf=False),
UserMentionee(type='user', index=0, length=4, userId='U-a', isSelf=False),
]
converter = _make_converter()
event = _make_event(
source_type='group',
user_id='U-member',
group_id='G1',
message_id='m1',
text='@aaa mid @bbb tail',
mention=mention,
)
chain = await converter.message_converter.target2yiri(event, bot_client=None)
ats = _ats(chain)
assert [a.target for a in ats] == ['U-a', 'U-b']
assert str(chain) == '@aaa mid @bbb tail'
@@ -1,3 +1,4 @@
import uuid
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
@@ -49,7 +50,29 @@ async def test_send_message_sends_text_to_customer_service_user():
assert kwargs['open_kfid'] == 'kf-test' assert kwargs['open_kfid'] == 'kf-test'
assert kwargs['external_userid'] == 'external-user' assert kwargs['external_userid'] == 'external-user'
assert kwargs['content'] == 'hello' assert kwargs['content'] == 'hello'
assert kwargs['msgid'].startswith('langbot_') assert len(kwargs['msgid'].encode()) <= 32
assert uuid.UUID(hex=kwargs['msgid']).hex == kwargs['msgid']
@pytest.mark.asyncio
async def test_send_message_sends_image_to_customer_service_user():
adapter = make_adapter()
adapter.bot_account_id = 'kf-test'
adapter.bot = SimpleNamespace(
get_media_id=AsyncMock(return_value='media-id'),
send_image_msg=AsyncMock(),
)
message = platform_message.MessageChain([platform_message.Image(base64='aW1hZ2U=')])
await adapter.send_message('person', 'uexternal-user', message)
adapter.bot.send_image_msg.assert_awaited_once()
kwargs = adapter.bot.send_image_msg.await_args.kwargs
assert kwargs['open_kfid'] == 'kf-test'
assert kwargs['external_userid'] == 'external-user'
assert kwargs['media_id'] == 'media-id'
assert len(kwargs['msgid'].encode()) <= 32
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -0,0 +1,47 @@
from __future__ import annotations
import httpx
import pytest
from langbot.libs.wecom_customer_service_api.api import WecomCSClient
@pytest.mark.asyncio
async def test_send_image_msg_posts_customer_service_image_payload() -> None:
captured_request: httpx.Request | None = None
def handle_request(request: httpx.Request) -> httpx.Response:
nonlocal captured_request
captured_request = request
return httpx.Response(200, json={'errcode': 0})
client = WecomCSClient(
corpid='corp-id',
secret='secret',
token='token',
EncodingAESKey='encoding-key',
logger=None,
unified_mode=True,
)
client.access_token = 'access-token'
client._http_client = httpx.AsyncClient(transport=httpx.MockTransport(handle_request))
try:
await client.send_image_msg(
open_kfid='kf-test',
external_userid='external-user',
msgid='a' * 32,
media_id='media-id',
)
finally:
await client.close()
assert captured_request is not None
assert captured_request.url.path == '/cgi-bin/kf/send_msg'
assert captured_request.url.params['access_token'] == 'access-token'
assert captured_request.method == 'POST'
assert captured_request.read().decode() == (
'{"touser":"external-user","open_kfid":"kf-test","msgid":"'
+ 'a' * 32
+ '","msgtype":"image","image":{"media_id":"media-id"}}'
)
@@ -91,3 +91,42 @@ def test_convert_messages_plain_string_content_untouched():
msg = provider_message.Message(role='user', content='just text') msg = provider_message.Message(role='user', content='just text')
out = req._convert_messages([msg]) out = req._convert_messages([msg])
assert out[0]['content'] == 'just text' assert out[0]['content'] == 'just text'
def test_convert_messages_replayed_image_without_base64_does_not_crash():
"""Replayed image parts hollowed out by history trimming must not raise KeyError (#2469).
SessionManager clears image_base64 on past turns, and URL-less platform
images never had a URL, so the replayed part serializes as
{'type': 'image_base64'} with no payload keys. The hollow part should be
dropped while the sibling text part survives.
"""
req = _make_requester()
image = provider_message.ContentElement.from_image_base64('data:image/jpeg;base64,AAAA')
# Simulate SessionManager.trim_conversation_messages clearing binary payloads.
image.image_base64 = None
msg = provider_message.Message(
role='user',
content=[
provider_message.ContentElement.from_text('describe the photo'),
image,
],
)
out = req._convert_messages([msg])
assert [p.get('type') for p in out[0]['content']] == ['text']
def test_convert_messages_replayed_image_with_url_falls_back_to_url():
"""When base64 was trimmed but image_url survived, rebuild the OpenAI image_url part from the URL."""
req = _make_requester()
image = provider_message.ContentElement(
type='image_base64',
image_base64=None,
image_url=provider_message.ImageURLContentObject(url='https://example.com/pic.jpg'),
)
msg = provider_message.Message(role='user', content=[image])
out = req._convert_messages([msg])
parts = out[0]['content']
assert [p.get('type') for p in parts] == ['image_url']
assert parts[0]['image_url'] == {'url': 'https://example.com/pic.jpg'}
assert 'image_base64' not in parts[0]
@@ -0,0 +1,208 @@
"""Regression tests for tool-message content serialization (#2457).
MCP tools return ``list[ContentElement]`` from ``execute_func_call``.
The runner must serialize that list to a string before placing it in a
``role='tool'`` message, because the OpenAI chat-completions spec
requires tool-message content to be a string. Sending the raw list
causes OpenAI-compatible endpoints to return HTTP 500.
"""
from __future__ import annotations
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.provider.message as provider_message
import langbot_plugin.api.entities.builtin.provider.session as provider_session
from langbot.pkg.api.http.context import ExecutionContext, PrincipalContext, PrincipalType
from langbot.pkg.provider.runners.localagent import LocalAgentRunner
class _ToolCallProvider:
"""Non-streaming provider: round 1 issues a tool call, round 2 returns text."""
def __init__(self):
self.requests: list[dict] = []
async def invoke_llm(self, query, model, messages, funcs, extra_args=None, remove_think=None):
self.requests.append({'messages': list(messages)})
if len(self.requests) == 1:
return provider_message.Message(
role='assistant',
content='Let me search that.',
tool_calls=[
provider_message.ToolCall(
id='call-mcp-1',
type='function',
function=provider_message.FunctionCall(
name='duckduckgo_search',
arguments=json.dumps({'query': 'swift'}),
),
)
],
)
return provider_message.Message(role='assistant', content='Done.')
class _ToolCallStreamProvider:
"""Streaming variant of _ToolCallProvider."""
def __init__(self):
self.requests: list[dict] = []
def invoke_llm_stream(self, query, model, messages, funcs, extra_args=None, remove_think=None):
self.requests.append({'messages': list(messages)})
async def _stream():
if len(self.requests) == 1:
yield provider_message.MessageChunk(
role='assistant',
content='Let me search that.',
tool_calls=[
provider_message.ToolCall(
id='call-mcp-1',
type='function',
function=provider_message.FunctionCall(
name='duckduckgo_search',
arguments=json.dumps({'query': 'swift'}),
),
)
],
is_final=True,
)
return
yield provider_message.MessageChunk(
role='assistant',
content='Done.',
is_final=True,
)
return _stream()
def _make_query(stream: bool = False) -> pipeline_query.Query:
adapter = AsyncMock()
adapter.is_stream_output_supported = AsyncMock(return_value=stream)
query = pipeline_query.Query.model_construct(
query_id='mcp-tool-query',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
sender_id=12345,
message_chain=[],
message_event=None,
adapter=adapter,
pipeline_uuid='pipeline-uuid',
bot_uuid='bot-uuid',
pipeline_config={
'ai': {
'runner': {'runner': 'local-agent'},
'local-agent': {'model': {'primary': 'test-model-uuid', 'fallbacks': []}, 'prompt': 'test-prompt'},
},
'output': {'misc': {'remove-think': False}},
},
prompt=SimpleNamespace(messages=[]),
messages=[],
user_message=provider_message.Message(role='user', content='search swift'),
use_funcs=[SimpleNamespace(name='duckduckgo_search')],
use_llm_model_uuid='test-model-uuid',
variables={},
)
object.__setattr__(
query,
'_execution_context',
ExecutionContext(
instance_uuid='instance-test',
workspace_uuid='workspace-test',
placement_generation=1,
trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
),
)
return query
def _make_app(provider, func_ret) -> SimpleNamespace:
"""Build a minimal app whose tool_mgr returns *func_ret*."""
model = SimpleNamespace(
provider=provider,
model_entity=SimpleNamespace(
uuid='test-model-uuid',
name='test-model',
abilities=['func_call'],
extra_args={},
),
)
return SimpleNamespace(
logger=Mock(),
model_mgr=SimpleNamespace(get_model_by_uuid=AsyncMock(return_value=model)),
tool_mgr=SimpleNamespace(execute_func_call=AsyncMock(return_value=func_ret)),
rag_mgr=SimpleNamespace(),
box_service=SimpleNamespace(get_system_guidance=Mock(return_value='sandbox guidance')),
skill_mgr=SimpleNamespace(
get_skills_for_pipeline=AsyncMock(return_value=[]),
detect_skill_activation=AsyncMock(return_value=None),
build_activation_prompt=Mock(return_value=None),
),
)
# The actual shape returned by MCP tools: a list of ContentElement objects.
_MCP_FUNC_RET = [
provider_message.ContentElement.from_text('Title: Swift - Wikipedia\nURL: https://en.wikipedia.org/wiki/Swift'),
provider_message.ContentElement.from_text('Title: Swift Programming Language\nURL: https://swift.org'),
]
@pytest.mark.asyncio
async def test_tool_message_content_is_string_not_list():
"""Non-streaming: tool message content must be a string (#2457).
Before the fix, ``func_ret`` (a ``list[ContentElement]``) was assigned
to ``tool_content`` as-is, so the tool message carried a list instead
of a string, causing OpenAI-compatible APIs to return 500.
"""
provider = _ToolCallProvider()
app = _make_app(provider, _MCP_FUNC_RET)
runner = LocalAgentRunner(app, pipeline_config={})
query = _make_query(stream=False)
results = [msg async for msg in runner.run(query)]
tool_msgs = [m for m in results if m.role == 'tool']
assert len(tool_msgs) == 1
# The content must be a string, not a list.
assert isinstance(tool_msgs[0].content, str), (
f'tool message content should be str, got {type(tool_msgs[0].content).__name__}'
)
# And it should contain the text of both ContentElements.
assert 'Swift - Wikipedia' in tool_msgs[0].content
assert 'Swift Programming Language' in tool_msgs[0].content
@pytest.mark.asyncio
async def test_tool_message_content_is_string_in_stream():
"""Streaming: same regression check for the streaming path (#2457)."""
provider = _ToolCallStreamProvider()
app = _make_app(provider, _MCP_FUNC_RET)
runner = LocalAgentRunner(app, pipeline_config={})
query = _make_query(stream=True)
results = [msg async for msg in runner.run(query)]
tool_msgs = [m for m in results if m.role == 'tool']
assert len(tool_msgs) == 1
assert isinstance(tool_msgs[0].content, str), (
f'tool message content should be str, got {type(tool_msgs[0].content).__name__}'
)
assert 'Swift - Wikipedia' in tool_msgs[0].content
assert 'Swift Programming Language' in tool_msgs[0].content
+39
View File
@@ -53,6 +53,45 @@ async def test_matches_any_rejects_pattern_and_input_amplification():
) )
@pytest.mark.asyncio
async def test_mask_patterns_honors_explicit_pattern_count_cap():
patterns = ['a'] * (safe_regex.MAX_PATTERN_COUNT + 6)
found, masked = await safe_regex.mask_patterns(
patterns,
'hello',
mask='*',
mask_word='',
max_pattern_count=len(patterns),
)
assert found is False
assert masked == 'hello'
with pytest.raises(safe_regex.SafeRegexLimitError):
await safe_regex.mask_patterns(
patterns,
'hello',
mask='*',
mask_word='',
)
@pytest.mark.asyncio
async def test_mask_patterns_rejects_oversized_sequence_before_copying_it():
class OversizedPatterns(list):
def __iter__(self):
raise AssertionError('oversized patterns must not be materialized')
patterns = OversizedPatterns(['a'] * (safe_regex.MAX_PATTERN_COUNT + 1))
with pytest.raises(safe_regex.SafeRegexLimitError):
await safe_regex.mask_patterns(
patterns,
'hello',
mask='*',
mask_word='',
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mask_patterns_bounds_replacement_growth_and_masks_matches(): async def test_mask_patterns_bounds_replacement_growth_and_masks_matches():
found, masked = await safe_regex.mask_patterns( found, masked = await safe_regex.mask_patterns(
@@ -15,7 +15,10 @@ import {
FormMessage, FormMessage,
} from '@/components/ui/form'; } from '@/components/ui/form';
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent'; import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
import { normalizeDynamicFormValuesForSave } from '@/app/home/components/dynamic-form/DynamicFormSaveValues'; import {
normalizeDynamicFormFieldValue,
normalizeDynamicFormValuesForSave,
} from '@/app/home/components/dynamic-form/DynamicFormSaveValues';
import QrCodeLoginDialog, { import QrCodeLoginDialog, {
QrLoginPlatform, QrLoginPlatform,
} from '@/app/home/components/qrcode-login/QrCodeLoginDialog'; } from '@/app/home/components/qrcode-login/QrCodeLoginDialog';
@@ -464,61 +467,6 @@ export default function DynamicFormComponent({
const previousInitialValues = useRef(initialValues); const previousInitialValues = useRef(initialValues);
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
// Normalize a form value according to its field type.
// This ensures legacy/malformed data (e.g. a plain string for
// model-fallback-selector) is coerced to the expected shape
// so that downstream components never crash.
const normalizeFieldValue = (
item: DynamicFormValueSpec,
value: unknown,
): unknown => {
if (
item.name === 'mcp-resources' ||
item.type === DynamicFormItemType.RESOURCES_SELECTOR ||
item.type === DynamicFormItemType.RICH_TOOLS_SELECTOR
) {
return Array.isArray(value) ? value : [];
}
if (item.type === 'model-fallback-selector') {
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
const obj = value as Record<string, unknown>;
return {
primary: typeof obj.primary === 'string' ? obj.primary : '',
fallbacks: Array.isArray(obj.fallbacks)
? (obj.fallbacks as unknown[]).filter(
(v): v is string => typeof v === 'string',
)
: [],
reasoning:
obj.reasoning != null &&
typeof obj.reasoning === 'object' &&
!Array.isArray(obj.reasoning)
? Object.fromEntries(
Object.entries(obj.reasoning).filter(
(entry): entry is [string, string] =>
typeof entry[1] === 'string',
),
)
: {},
};
}
// Legacy string format or any other unexpected type
return {
primary: typeof value === 'string' ? value : '',
fallbacks: [],
reasoning: {},
};
}
if (item.type === 'prompt-editor') {
if (Array.isArray(value)) {
return value;
}
// Default to a single empty system prompt entry
return [{ role: 'system', content: '' }];
}
return value;
};
// Filter out display-only fields (webhook-url/embed-code/qr-code-login types // Filter out display-only fields (webhook-url/embed-code/qr-code-login types
// and `__system.*`-named fields) that should not participate in form state, // and `__system.*`-named fields) that should not participate in form state,
// validation, or value emission. // validation, or value emission.
@@ -574,7 +522,7 @@ export default function DynamicFormComponent({
const rawValue = initialValues?.[item.name] ?? item.default; const rawValue = initialValues?.[item.name] ?? item.default;
return { return {
...acc, ...acc,
[item.name]: normalizeFieldValue(item, rawValue), [item.name]: normalizeDynamicFormFieldValue(item, rawValue),
}; };
}, {} as FormValues), }, {} as FormValues),
}); });
@@ -611,7 +559,10 @@ export default function DynamicFormComponent({
const mergedValues = editableValueSpecs.reduce( const mergedValues = editableValueSpecs.reduce(
(acc, item) => { (acc, item) => {
const rawValue = initialValues[item.name] ?? item.default; const rawValue = initialValues[item.name] ?? item.default;
acc[item.name] = normalizeFieldValue(item, rawValue) as object; acc[item.name] = normalizeDynamicFormFieldValue(
item,
rawValue,
) as object;
return acc; return acc;
}, },
{} as Record<string, object>, {} as Record<string, object>,
@@ -5,6 +5,80 @@ export type DynamicFormSaveValueSpec = Pick<
'default' | 'name' | 'type' 'default' | 'name' | 'type'
>; >;
const ARRAY_FIELD_TYPES = new Set([
'array[string]',
'array[file]',
'knowledge-base-multi-selector',
'resources-selector',
'rich-tools-selector',
'tools-selector',
]);
const STRING_FIELD_TYPES = new Set([
'string',
'text',
'select',
'llm-model-selector',
'embedding-model-selector',
'rerank-model-selector',
'knowledge-base-selector',
'bot-selector',
]);
/**
* Coerce empty dynamic-form defaults into controlled React values.
* Metadata from older adapters and runners can omit `default`; inputs must
* still receive a stable value from their first render.
*/
export function normalizeDynamicFormFieldValue(
spec: DynamicFormSaveValueSpec,
value: unknown,
): unknown {
if (spec.name === 'mcp-resources' || ARRAY_FIELD_TYPES.has(spec.type)) {
return Array.isArray(value) ? value : [];
}
if (spec.type === 'boolean') {
return typeof value === 'boolean' ? value : false;
}
if (STRING_FIELD_TYPES.has(spec.type)) {
return typeof value === 'string' ? value : '';
}
if (spec.type === 'model-fallback-selector') {
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
const objectValue = value as Record<string, unknown>;
return {
primary:
typeof objectValue.primary === 'string' ? objectValue.primary : '',
fallbacks: Array.isArray(objectValue.fallbacks)
? objectValue.fallbacks.filter(
(fallback): fallback is string => typeof fallback === 'string',
)
: [],
reasoning:
objectValue.reasoning != null &&
typeof objectValue.reasoning === 'object' &&
!Array.isArray(objectValue.reasoning)
? Object.fromEntries(
Object.entries(objectValue.reasoning).filter(
(entry): entry is [string, string] =>
typeof entry[1] === 'string',
),
)
: {},
};
}
return {
primary: typeof value === 'string' ? value : '',
fallbacks: [],
reasoning: {},
};
}
if (spec.type === 'prompt-editor') {
return Array.isArray(value) ? value : [{ role: 'system', content: '' }];
}
return value;
}
const reasoningLevels = new Set([ const reasoningLevels = new Set([
'disabled', 'disabled',
'enabled', 'enabled',
@@ -54,7 +54,7 @@ export function groupByCategory<T extends { categories?: string[] }>(
} }
let placed = false; let placed = false;
for (const cat of cats) { for (const cat of new Set(cats)) {
if (ordered.includes(cat as AdapterCategoryId)) { if (ordered.includes(cat as AdapterCategoryId)) {
buckets.get(cat as AdapterCategoryId)!.push(item); buckets.get(cat as AdapterCategoryId)!.push(item);
placed = true; placed = true;
+10 -10
View File
@@ -167,14 +167,7 @@ export default function OwnModelSetup({
}, [onSelectionChange]); }, [onSelectionChange]);
return ( return (
<div className="mx-auto w-full max-w-3xl space-y-5"> <div className="mx-auto w-full max-w-4xl space-y-6">
<div>
<Button variant="ghost" size="sm" onClick={handleBack}>
<ArrowLeft className="mr-1.5 size-4" />
{t('wizard.aiEngine.backToChoices')}
</Button>
</div>
<div className="text-center"> <div className="text-center">
<h2 className="text-xl font-semibold"> <h2 className="text-xl font-semibold">
{t('wizard.aiEngine.ownModelSetupTitle')} {t('wizard.aiEngine.ownModelSetupTitle')}
@@ -184,8 +177,15 @@ export default function OwnModelSetup({
</p> </p>
</div> </div>
<div>
<Button variant="ghost" size="sm" onClick={handleBack}>
<ArrowLeft className="mr-1.5 size-4" />
{t('wizard.aiEngine.backToChoices')}
</Button>
</div>
{showProviderForm ? ( {showProviderForm ? (
<Card> <Card className="mx-auto w-full max-w-3xl">
<CardHeader> <CardHeader>
<CardTitle className="text-base"> <CardTitle className="text-base">
{t('wizard.aiEngine.addProviderTitle')} {t('wizard.aiEngine.addProviderTitle')}
@@ -205,7 +205,7 @@ export default function OwnModelSetup({
</CardContent> </CardContent>
</Card> </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 className="flex flex-wrap items-start justify-between gap-3 border-b pb-3">
<div> <div>
<h3 className="text-base font-semibold"> <h3 className="text-base font-semibold">
+76 -30
View File
@@ -6,6 +6,7 @@ import { toast } from 'sonner';
import { import {
ArrowLeft, ArrowLeft,
ArrowRight, ArrowRight,
AlertTriangle,
Check, Check,
Sparkles, Sparkles,
Loader2, Loader2,
@@ -25,6 +26,7 @@ import {
systemInfo, systemInfo,
bootstrapWorkspaceSession, bootstrapWorkspaceSession,
initializeSystemInfo, initializeSystemInfo,
userInfo,
} from '@/app/infra/http'; } from '@/app/infra/http';
import { Adapter, Bot, WizardProgress } from '@/app/infra/entities/api'; import { Adapter, Bot, WizardProgress } from '@/app/infra/entities/api';
import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic'; import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
@@ -55,6 +57,7 @@ import {
ensureHttpBotSigningSecret, ensureHttpBotSigningSecret,
findDefaultPipeline, findDefaultPipeline,
getErrorMessage, getErrorMessage,
isRequiredRunnerConfigComplete,
isWebhookModeEnabled, isWebhookModeEnabled,
} from '@/app/wizard/utils'; } from '@/app/wizard/utils';
@@ -124,7 +127,7 @@ export default function WizardPage() {
const [messageReceived, setMessageReceived] = useState(false); const [messageReceived, setMessageReceived] = useState(false);
const [aiChoice, setAiChoice] = useState< const [aiChoice, setAiChoice] = useState<
'external' | 'own-model' | 'more-features' | null 'external' | 'own-model' | 'more-features' | null
>(null); >('more-features');
const [ownModelSelection, setOwnModelSelection] = const [ownModelSelection, setOwnModelSelection] =
useState<OwnModelSelection | null>(null); useState<OwnModelSelection | null>(null);
@@ -322,13 +325,20 @@ export default function WizardPage() {
); );
}, [selectedRunnerConfigStage]); }, [selectedRunnerConfigStage]);
const isRunnerConfigComplete = useMemo(
() =>
isRequiredRunnerConfigComplete(selectedRunnerConfigItems, runnerConfig),
[selectedRunnerConfigItems, runnerConfig],
);
// ---- Runner selection with progress saving ---- // ---- Runner selection with progress saving ----
const handleSelectRunner = useCallback( const handleSelectRunner = useCallback(
(runner: string) => { (runner: string) => {
if (runner !== selectedRunner) setRunnerConfig({});
setSelectedRunner(runner); setSelectedRunner(runner);
saveProgress({ step: 2, selected_runner: runner }); saveProgress({ step: 2, selected_runner: runner });
}, },
[saveProgress], [saveProgress, selectedRunner],
); );
// ---- Navigation helpers ---- // ---- Navigation helpers ----
@@ -599,7 +609,8 @@ export default function WizardPage() {
const handleFinish = useCallback(async () => { const handleFinish = useCallback(async () => {
if (!aiChoice || !createdBotUuid || !createdPipelineUuid) return; if (!aiChoice || !createdBotUuid || !createdPipelineUuid) return;
if (aiChoice === 'external' && !selectedRunner) return; if (aiChoice === 'external' && (!selectedRunner || !isRunnerConfigComplete))
return;
if (aiChoice === 'own-model' && !ownModelSelection) return; if (aiChoice === 'own-model' && !ownModelSelection) return;
setIsSubmitting(true); setIsSubmitting(true);
let externalPipelineUuid: string | null = null; let externalPipelineUuid: string | null = null;
@@ -739,6 +750,7 @@ export default function WizardPage() {
} }
}, [ }, [
selectedRunner, selectedRunner,
isRunnerConfigComplete,
createdBotUuid, createdBotUuid,
createdPipelineUuid, createdPipelineUuid,
aiChoice, aiChoice,
@@ -874,7 +886,7 @@ export default function WizardPage() {
<div <div
className={cn( className={cn(
'flex-1 min-h-0 px-4 sm:px-6 pb-4 sm:pb-6', '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' ? 'lg:flex lg:flex-col lg:overflow-hidden overflow-y-auto'
: 'overflow-y-auto', : 'overflow-y-auto',
)} )}
@@ -952,7 +964,8 @@ export default function WizardPage() {
disabled={ disabled={
!canProceed() || !canProceed() ||
isSubmitting || isSubmitting ||
(aiChoice === 'external' && !selectedRunner) || (aiChoice === 'external' &&
(!selectedRunner || !isRunnerConfigComplete)) ||
(aiChoice === 'own-model' && !ownModelSelection) (aiChoice === 'own-model' && !ownModelSelection)
} }
> >
@@ -1015,7 +1028,10 @@ function StepPlatform({
const { t } = useTranslation(); const { t } = useTranslation();
const groupedAdapters = useMemo(() => { 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, ...a,
categories: a.spec.categories, categories: a.spec.categories,
})); }));
@@ -1182,6 +1198,10 @@ function StepBotConfig({
Boolean(webhookUrl), Boolean(webhookUrl),
[adapterConfigItems, adapterConfigValues, webhookUrl], [adapterConfigItems, adapterConfigValues, webhookUrl],
); );
const receivedMessageWithoutLangBotAccount =
messageReceived && userInfo?.account_type !== 'space';
const receivedMessageSuccessfully =
messageReceived && !receivedMessageWithoutLangBotAccount;
// Stable callback ref // Stable callback ref
const onAdapterConfigRef = useRef(onAdapterConfigChange); const onAdapterConfigRef = useRef(onAdapterConfigChange);
@@ -1239,7 +1259,7 @@ function StepBotConfig({
<div <div
className={cn( className={cn(
'border px-4 py-3', 'border px-4 py-3',
messageReceived receivedMessageSuccessfully
? 'border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/30' ? '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', : 'border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30',
)} )}
@@ -1248,10 +1268,12 @@ function StepBotConfig({
<div <div
className={cn( className={cn(
'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full', '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" /> <Check className="size-3 text-white" />
) : selectedAdapterName === 'web_page_bot' ? ( ) : selectedAdapterName === 'web_page_bot' ? (
<MessageSquare className="size-3 text-white" /> <MessageSquare className="size-3 text-white" />
@@ -1267,13 +1289,17 @@ function StepBotConfig({
<p <p
className={cn( className={cn(
'text-sm font-medium', 'text-sm font-medium',
messageReceived receivedMessageSuccessfully
? 'text-green-800 dark:text-green-200' ? 'text-green-800 dark:text-green-200'
: 'text-amber-800 dark:text-amber-200', : 'text-amber-800 dark:text-amber-200',
)} )}
> >
{messageReceived {messageReceived
? t('wizard.botConfig.messageReceived') ? t(
receivedMessageWithoutLangBotAccount
? 'wizard.botConfig.messageReceivedLocalAccountWarning'
: 'wizard.botConfig.messageReceived',
)
: selectedAdapterName === 'web_page_bot' : selectedAdapterName === 'web_page_bot'
? t('wizard.botConfig.pageBotTestPrompt') ? t('wizard.botConfig.pageBotTestPrompt')
: selectedAdapterName === 'http_bot' : selectedAdapterName === 'http_bot'
@@ -1465,6 +1491,12 @@ function StepAIEngine({
}, [runnerOptions, selected]); }, [runnerOptions, selected]);
const choices = [ const choices = [
{
id: 'more-features' as const,
icon: Blocks,
title: t('wizard.aiEngine.moreFeaturesTitle'),
description: t('wizard.aiEngine.moreFeaturesDescription'),
},
{ {
id: 'external' as const, id: 'external' as const,
icon: Cable, icon: Cable,
@@ -1477,26 +1509,28 @@ function StepAIEngine({
title: t('wizard.aiEngine.ownModelTitle'), title: t('wizard.aiEngine.ownModelTitle'),
description: t('wizard.aiEngine.ownModelDescription'), 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') { if (choice === 'own-model') {
return ( return (
<OwnModelSetup <div
onBack={() => onChoiceChange(null)} key="ai-engine-own-model"
onSelectionChange={onOwnModelSelectionChange} 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') { if (choice !== 'external') {
return ( 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"> <div className="text-center">
<h2 className="text-xl font-semibold"> <h2 className="text-xl font-semibold">
{t('wizard.aiEngine.title')} {t('wizard.aiEngine.title')}
@@ -1533,16 +1567,23 @@ function StepAIEngine({
// Before any runner is selected: centered grid layout // Before any runner is selected: centered grid layout
if (!selected) { if (!selected) {
return ( 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"> <div className="text-center">
<h2 className="text-xl font-semibold"> <h2 className="text-xl font-semibold">
{t('wizard.aiEngine.title')} {t('wizard.aiEngine.externalTitle')}
</h2> </h2>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
{t('wizard.aiEngine.runnerDescription')} {t('wizard.aiEngine.runnerDescription')}
</p> </p>
</div> </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" /> <ArrowLeft className="size-4 mr-1.5" />
{t('wizard.aiEngine.backToChoices')} {t('wizard.aiEngine.backToChoices')}
</Button> </Button>
@@ -1574,9 +1615,14 @@ function StepAIEngine({
// On mobile (< lg): single column, normal scroll from parent // On mobile (< lg): single column, normal scroll from parent
// On desktop (>= lg): side-by-side with independent scroll per column // On desktop (>= lg): side-by-side with independent scroll per column
return ( 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"> <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"> <p className="text-sm text-muted-foreground mt-1">
{t('wizard.aiEngine.description')} {t('wizard.aiEngine.description')}
</p> </p>
@@ -1586,13 +1632,13 @@ function StepAIEngine({
variant="ghost" variant="ghost"
size="sm" size="sm"
className="self-start mb-3" className="self-start mb-3"
onClick={() => onChoiceChange(null)} onClick={() => onChoiceChange('more-features')}
> >
<ArrowLeft className="size-4 mr-1.5" /> <ArrowLeft className="size-4 mr-1.5" />
{t('wizard.aiEngine.backToChoices')} {t('wizard.aiEngine.backToChoices')}
</Button> </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 */} {/* Left: runner list */}
<div className="w-full lg:w-[280px] shrink-0 lg:overflow-y-auto lg:pr-3"> <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 */} {/* p-1 provides space for ring-2 (4px) to render without clipping */}
@@ -1639,7 +1685,7 @@ function StepAIEngine({
</div> </div>
{/* Right: runner configuration — fixed width on desktop */} {/* 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"> <div className="p-1">
{runnerConfigItems.length > 0 && ( {runnerConfigItems.length > 0 && (
<Card> <Card>
+33
View File
@@ -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( export function configureLocalAgentPrimaryModel(
config: Record<string, unknown>, config: Record<string, unknown>,
modelUuid: string, modelUuid: string,
+4 -2
View File
@@ -1831,6 +1831,8 @@ const enUS = {
'The bot is enabled. Send it a message from your IM platform to continue.', 'The bot is enabled. Send it a message from your IM platform to continue.',
messageReceived: messageReceived:
'The bot received an IM message. You can continue to the next step.', '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: pageBotTestPrompt:
'Page Bot is enabled. Click the chat bubble in the lower-right corner and send a message to verify the full conversation flow.', 'Page Bot is enabled. Click the chat bubble in the lower-right corner and send a message to verify the full conversation flow.',
pageBotTestNotice: pageBotTestNotice:
@@ -1851,7 +1853,7 @@ const enUS = {
'Monitor bot activity to verify the platform connection is working.', 'Monitor bot activity to verify the platform connection is working.',
}, },
aiEngine: { aiEngine: {
title: 'Select an AI Engine', title: 'Configure AI Engine',
description: description:
"Choose the AI engine that will power your bot's intelligence.", "Choose the AI engine that will power your bot's intelligence.",
optionalDescription: optionalDescription:
@@ -1892,7 +1894,7 @@ const enUS = {
rescanModels: 'Scan models again', rescanModels: 'Scan models again',
moreFeaturesTitle: 'Add More Agent Features', moreFeaturesTitle: 'Add More Agent Features',
moreFeaturesDescription: 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: runnerDescription:
'Select a runner for the external agent and configure its connection.', 'Select a runner for the external agent and configure its connection.',
backToChoices: 'Back to options', backToChoices: 'Back to options',
+67 -1
View File
@@ -1697,14 +1697,80 @@ const esES = {
resaveBot: 'Volver a guardar configuración', resaveBot: 'Volver a guardar configuración',
botSaved: botSaved:
'Configuración del Bot guardada y activada. Consulta los registros para verificar la conexión.', 'Configuración del Bot guardada y activada. Consulta los registros para verificar la conexión.',
waitingForMessage:
'El Bot está activado. Envíale un mensaje desde tu plataforma de mensajería para continuar.',
messageReceived:
'El Bot recibió un mensaje. Puedes continuar al siguiente paso.',
messageReceivedLocalAccountWarning:
'La conexión del Bot está configurada correctamente y recibió un mensaje. Como no has iniciado sesión con una cuenta de LangBot, las llamadas al modelo pueden fallar; continúa al siguiente paso para añadir tu propio modelo.',
pageBotTestPrompt:
'El Bot de página está activado. Haz clic en la burbuja de chat de la esquina inferior derecha y envía un mensaje para verificar el flujo completo de la conversación.',
pageBotTestNotice:
'Solo para pruebas. Inserta el código en una página web externa real.',
webhookTestPrompt:
'La URL de devolución de llamada está lista. Configúrala en la plataforma externa y envía un mensaje real al Bot.',
httpTestPrompt:
'El Bot HTTP está activado. Envía aquí un mensaje entrante real para verificar la conexión.',
httpTestDefaultMessage: 'Hola, este es un mensaje de prueba de conexión.',
sendHttpTest: 'Enviar mensaje de prueba',
httpTestAccepted:
'El mensaje de prueba fue aceptado. Aparecerá en el registro en breve.',
httpTestMissingSecret:
'Introduce un secreto de firma entrante y guarda primero la configuración.',
httpTestFailed: 'No se pudo enviar el mensaje de prueba: {{error}}',
logsTitle: 'Registros del Bot', logsTitle: 'Registros del Bot',
logsDescription: logsDescription:
'Monitorea la actividad del Bot para verificar que la conexión con la plataforma funcione.', 'Monitorea la actividad del Bot para verificar que la conexión con la plataforma funcione.',
}, },
aiEngine: { aiEngine: {
title: 'Selecciona un motor de IA', title: 'Configura el motor de IA',
description: description:
'Elige el motor de IA que impulsará la inteligencia de tu Bot.', 'Elige el motor de IA que impulsará la inteligencia de tu Bot.',
optionalDescription:
'Este paso es opcional. Elige cómo quieres continuar con el Agent actual.',
externalTitle: 'Conectar un Agent de una plataforma externa',
externalDescription:
'Conecta Dify, n8n, Coze u otra plataforma y sustituye el Pipeline del Bot.',
ownModelTitle: 'Usar mi propio modelo',
ownModelDescription:
'Añade un proveedor y luego escanea o introduce manualmente un modelo para completar la configuración.',
ownModelSetupTitle: 'Añade tu propio modelo',
ownModelSetupDescription:
'Añade un proveedor de modelos. Los modelos de chat se detectan automáticamente, o puedes introducir un ID de modelo manualmente.',
addProviderTitle: 'Añadir proveedor',
addProviderDescription:
'Introduce los datos del proveedor y la clave de API usados para conectar y detectar modelos.',
selectModelTitle: 'Elige un modelo',
selectScannedModelTitle: 'Elige un modelo',
selectScannedModelDescription:
'El modelo seleccionado será el modelo principal de un nuevo Pipeline y el Bot cambiará a él.',
scanModelMode: 'Detectar modelos',
manualModelMode: 'Añadir manualmente',
scanningModels: 'Detectando modelos disponibles…',
noScannedModels:
'No se encontraron modelos de chat disponibles. Revisa la configuración del proveedor.',
scanModelsFailed:
'No se pudieron detectar los modelos. Revisa la URL y la clave de API e inténtalo de nuevo.',
manualFallbackFailed:
'La detección automática falló. Introduce un ID de modelo compatible con el proveedor.',
manualFallbackEmpty:
'No se encontraron modelos. Introduce un ID de modelo compatible con el proveedor.',
manualModelId: 'ID del modelo',
manualModelIdPlaceholder: 'Por ejemplo: gpt-4o',
manualModelIdDescription:
'Introduce el parámetro de modelo utilizado en las solicitudes.',
manualModelOptions: 'Capacidades opcionales del modelo',
editProvider: 'Editar proveedor',
rescanModels: 'Volver a detectar modelos',
moreFeaturesTitle: 'Añadir más funciones al Agent',
moreFeaturesDescription:
'Abre el área de trabajo para añadir herramientas, bases de conocimiento y otras capacidades al Agent que se acaba de generar automáticamente.',
runnerDescription:
'Selecciona un Runner para el Agent externo y configura su conexión.',
backToChoices: 'Volver a las opciones',
createExternal: 'Crear y vincular',
finishWithModel: 'Usar el modelo seleccionado y finalizar',
openWorkbench: 'Abrir área de trabajo',
}, },
config: { config: {
botInfo: 'Información del Bot', botInfo: 'Información del Bot',
+4 -2
View File
@@ -1748,6 +1748,8 @@ const jaJP = {
'ボットが有効になりました。続行するには IM からメッセージを送信してください。', 'ボットが有効になりました。続行するには IM からメッセージを送信してください。',
messageReceived: messageReceived:
'ボットが IM メッセージを受信しました。次のステップに進めます。', 'ボットが IM メッセージを受信しました。次のステップに進めます。',
messageReceivedLocalAccountWarning:
'ボット側の接続設定は正常で、IM メッセージを受信できています。LangBot Account でログインしていないためモデル呼び出しが失敗する場合がありますが、次のステップで独自のモデルを追加できます。',
pageBotTestPrompt: pageBotTestPrompt:
'ページボットが有効になりました。右下のチャットバブルをクリックしてメッセージを送信し、会話フロー全体を確認してください。', 'ページボットが有効になりました。右下のチャットバブルをクリックしてメッセージを送信し、会話フロー全体を確認してください。',
pageBotTestNotice: pageBotTestNotice:
@@ -1768,7 +1770,7 @@ const jaJP = {
'ボットの活動を監視して、プラットフォーム接続が正常に動作していることを確認します。', 'ボットの活動を監視して、プラットフォーム接続が正常に動作していることを確認します。',
}, },
aiEngine: { aiEngine: {
title: 'AIエンジンを選択', title: 'AIエンジンを設定',
description: description:
'ボットのインテリジェンスを駆動するAIエンジンを選択してください。', 'ボットのインテリジェンスを駆動するAIエンジンを選択してください。',
optionalDescription: optionalDescription:
@@ -1809,7 +1811,7 @@ const jaJP = {
rescanModels: 'モデルを再スキャン', rescanModels: 'モデルを再スキャン',
moreFeaturesTitle: 'Agent に機能を追加', moreFeaturesTitle: 'Agent に機能を追加',
moreFeaturesDescription: moreFeaturesDescription:
'ワークベンチを開き、ツールナレッジなどの機能を追加します。', 'ワークベンチを開き、自動生成されたばかりの Agent にツールナレッジベースなどの機能を追加します。',
runnerDescription: '外部 Agent の Runner を選択し、接続を設定します。', runnerDescription: '外部 Agent の Runner を選択し、接続を設定します。',
backToChoices: '選択肢に戻る', backToChoices: '選択肢に戻る',
createExternal: '作成して関連付ける', createExternal: '作成して関連付ける',
+68 -1
View File
@@ -1666,14 +1666,81 @@ const ruRU = {
resaveBot: 'Пересохранить конфигурацию', resaveBot: 'Пересохранить конфигурацию',
botSaved: botSaved:
'Конфигурация бота сохранена и включена. Проверьте журналы для подтверждения подключения.', 'Конфигурация бота сохранена и включена. Проверьте журналы для подтверждения подключения.',
waitingForMessage:
'Бот включён. Отправьте ему сообщение из мессенджера, чтобы продолжить.',
messageReceived:
'Бот получил сообщение. Можно перейти к следующему шагу.',
messageReceivedLocalAccountWarning:
'Подключение бота настроено правильно, и сообщение получено. Поскольку вход выполнен не через аккаунт LangBot, вызовы модели могут завершаться ошибкой; перейдите к следующему шагу, чтобы добавить собственную модель.',
pageBotTestPrompt:
'Бот для веб-страницы включён. Нажмите на значок чата в правом нижнем углу и отправьте сообщение, чтобы проверить полный сценарий диалога.',
pageBotTestNotice:
'Только для тестирования. Встройте код в настоящую внешнюю веб-страницу.',
webhookTestPrompt:
'URL обратного вызова готов. Настройте его на внешней платформе, затем отправьте боту настоящее сообщение.',
httpTestPrompt:
'HTTP-бот включён. Отправьте сюда настоящее входящее сообщение, чтобы проверить подключение.',
httpTestDefaultMessage:
'Здравствуйте, это тестовое сообщение подключения.',
sendHttpTest: 'Отправить тестовое сообщение',
httpTestAccepted:
'Тестовое сообщение принято. Оно скоро появится в журнале.',
httpTestMissingSecret:
'Введите секрет подписи входящих запросов и сначала сохраните конфигурацию.',
httpTestFailed: 'Не удалось отправить тестовое сообщение: {{error}}',
logsTitle: 'Журналы бота', logsTitle: 'Журналы бота',
logsDescription: logsDescription:
'Отслеживайте активность бота для проверки подключения к платформе.', 'Отслеживайте активность бота для проверки подключения к платформе.',
}, },
aiEngine: { aiEngine: {
title: 'Выберите ИИ-движок', title: 'Настройте ИИ-движок',
description: description:
'Выберите ИИ-движок, который будет управлять интеллектом вашего бота.', 'Выберите ИИ-движок, который будет управлять интеллектом вашего бота.',
optionalDescription:
'Этот шаг необязателен. Выберите, как продолжить настройку текущего Agent.',
externalTitle: 'Подключить Agent внешней платформы',
externalDescription:
'Подключите Dify, n8n, Coze или другую платформу и замените Pipeline бота.',
ownModelTitle: 'Использовать собственную модель',
ownModelDescription:
'Добавьте провайдера, затем найдите модель автоматически или укажите её вручную, чтобы завершить настройку.',
ownModelSetupTitle: 'Добавьте собственную модель',
ownModelSetupDescription:
'Добавьте провайдера моделей. Модели чата будут найдены автоматически, либо можно вручную указать ID модели.',
addProviderTitle: 'Добавить провайдера',
addProviderDescription:
'Введите данные провайдера и API-ключ для подключения и поиска моделей.',
selectModelTitle: 'Выберите модель',
selectScannedModelTitle: 'Выберите модель',
selectScannedModelDescription:
'Выбранная модель станет основной моделью нового Pipeline, и бот переключится на неё.',
scanModelMode: 'Найти модели',
manualModelMode: 'Добавить вручную',
scanningModels: 'Поиск доступных моделей…',
noScannedModels:
'Доступные модели чата не найдены. Проверьте конфигурацию провайдера.',
scanModelsFailed:
'Не удалось найти модели. Проверьте URL и API-ключ, затем повторите попытку.',
manualFallbackFailed:
'Автоматический поиск не удался. Введите ID модели, поддерживаемой провайдером.',
manualFallbackEmpty:
'Модели не найдены. Введите ID модели, поддерживаемой провайдером.',
manualModelId: 'ID модели',
manualModelIdPlaceholder: 'Например: gpt-4o',
manualModelIdDescription:
'Введите параметр модели, используемый в запросах к модели.',
manualModelOptions: 'Дополнительные возможности модели',
editProvider: 'Изменить провайдера',
rescanModels: 'Повторить поиск моделей',
moreFeaturesTitle: 'Добавить возможности Agent',
moreFeaturesDescription:
'Откройте рабочую панель, чтобы добавить инструменты, базы знаний и другие возможности только что автоматически созданному Agent.',
runnerDescription:
'Выберите Runner для внешнего Agent и настройте подключение.',
backToChoices: 'Вернуться к вариантам',
createExternal: 'Создать и привязать',
finishWithModel: 'Использовать выбранную модель и завершить',
openWorkbench: 'Открыть рабочую панель',
}, },
config: { config: {
botInfo: 'Информация о боте', botInfo: 'Информация о боте',
+63 -1
View File
@@ -1634,13 +1634,75 @@ const thTH = {
resaveBot: 'บันทึกการกำหนดค่าอีกครั้ง', resaveBot: 'บันทึกการกำหนดค่าอีกครั้ง',
botSaved: botSaved:
'บันทึกและเปิดใช้งาน Bot แล้ว ตรวจสอบบันทึกเพื่อยืนยันการเชื่อมต่อ', 'บันทึกและเปิดใช้งาน Bot แล้ว ตรวจสอบบันทึกเพื่อยืนยันการเชื่อมต่อ',
waitingForMessage:
'เปิดใช้งาน Bot แล้ว โปรดส่งข้อความจากแพลตฟอร์มแชตเพื่อดำเนินการต่อ',
messageReceived: 'Bot ได้รับข้อความแล้ว คุณสามารถไปยังขั้นตอนถัดไปได้',
messageReceivedLocalAccountWarning:
'การเชื่อมต่อฝั่ง Bot ได้รับการกำหนดค่าอย่างถูกต้องและได้รับข้อความแล้ว เนื่องจากคุณไม่ได้เข้าสู่ระบบด้วยบัญชี LangBot การเรียกใช้โมเดลอาจล้มเหลว โปรดไปยังขั้นตอนถัดไปเพื่อเพิ่มโมเดลของคุณเอง',
pageBotTestPrompt:
'เปิดใช้งาน Page Bot แล้ว คลิกฟองแชตที่มุมขวาล่างและส่งข้อความเพื่อตรวจสอบขั้นตอนการสนทนาทั้งหมด',
pageBotTestNotice:
'สำหรับการทดสอบเท่านั้น โปรดฝังโค้ดในหน้าเว็บภายนอกจริง',
webhookTestPrompt:
'URL Callback พร้อมแล้ว โปรดกำหนดค่าบนแพลตฟอร์มภายนอก แล้วส่งข้อความจริงถึง Bot',
httpTestPrompt:
'เปิดใช้งาน HTTP Bot แล้ว ส่งข้อความขาเข้าจริงที่นี่เพื่อตรวจสอบการเชื่อมต่อ',
httpTestDefaultMessage: 'สวัสดี นี่คือข้อความทดสอบการเชื่อมต่อ',
sendHttpTest: 'ส่งข้อความทดสอบ',
httpTestAccepted: 'ระบบรับข้อความทดสอบแล้ว และจะแสดงในบันทึกในอีกสักครู่',
httpTestMissingSecret:
'โปรดกรอก Secret สำหรับลงนามข้อความขาเข้าและบันทึกการกำหนดค่าก่อน',
httpTestFailed: 'ส่งข้อความทดสอบไม่สำเร็จ: {{error}}',
logsTitle: 'บันทึก Bot', logsTitle: 'บันทึก Bot',
logsDescription: logsDescription:
'ตรวจสอบกิจกรรม Bot เพื่อยืนยันว่าการเชื่อมต่อแพลตฟอร์มทำงานอยู่', 'ตรวจสอบกิจกรรม Bot เพื่อยืนยันว่าการเชื่อมต่อแพลตฟอร์มทำงานอยู่',
}, },
aiEngine: { aiEngine: {
title: 'เลือกเครื่องมือ AI', title: 'กำหนดค่าเครื่องมือ AI',
description: 'เลือกเครื่องมือ AI ที่จะขับเคลื่อนความฉลาดของ Bot', description: 'เลือกเครื่องมือ AI ที่จะขับเคลื่อนความฉลาดของ Bot',
optionalDescription:
'ขั้นตอนนี้ไม่บังคับ เลือกวิธีที่คุณต้องการดำเนินการต่อกับ Agent ปัจจุบัน',
externalTitle: 'เชื่อมต่อ Agent จากแพลตฟอร์มภายนอก',
externalDescription:
'เชื่อมต่อ Dify, n8n, Coze หรือแพลตฟอร์มอื่น และแทนที่ Pipeline ของ Bot',
ownModelTitle: 'ใช้โมเดลของฉันเอง',
ownModelDescription:
'เพิ่มผู้ให้บริการ แล้วสแกนหรือกรอกโมเดลด้วยตนเองเพื่อเสร็จสิ้นการตั้งค่า',
ownModelSetupTitle: 'เพิ่มโมเดลของคุณเอง',
ownModelSetupDescription:
'เพิ่มผู้ให้บริการโมเดล ระบบจะสแกนโมเดลแชตโดยอัตโนมัติ หรือคุณสามารถกรอก ID โมเดลด้วยตนเอง',
addProviderTitle: 'เพิ่มผู้ให้บริการ',
addProviderDescription:
'กรอกรายละเอียดผู้ให้บริการและ API Key ที่ใช้เชื่อมต่อและสแกนโมเดล',
selectModelTitle: 'เลือกโมเดล',
selectScannedModelTitle: 'เลือกโมเดล',
selectScannedModelDescription:
'โมเดลที่เลือกจะเป็นโมเดลหลักของ Pipeline ใหม่ และ Bot จะเปลี่ยนไปใช้โมเดลนี้',
scanModelMode: 'สแกนโมเดล',
manualModelMode: 'เพิ่มด้วยตนเอง',
scanningModels: 'กำลังสแกนโมเดลที่พร้อมใช้งาน…',
noScannedModels:
'ไม่พบโมเดลแชตที่พร้อมใช้งาน โปรดตรวจสอบการกำหนดค่าผู้ให้บริการ',
scanModelsFailed:
'สแกนโมเดลไม่สำเร็จ โปรดตรวจสอบ URL และ API Key แล้วลองอีกครั้ง',
manualFallbackFailed:
'การสแกนอัตโนมัติไม่สำเร็จ โปรดกรอก ID โมเดลที่ผู้ให้บริการรองรับ',
manualFallbackEmpty: 'ไม่พบโมเดล โปรดกรอก ID โมเดลที่ผู้ให้บริการรองรับ',
manualModelId: 'ID โมเดล',
manualModelIdPlaceholder: 'ตัวอย่าง: gpt-4o',
manualModelIdDescription: 'กรอกพารามิเตอร์โมเดลที่ใช้ในคำขอโมเดล',
manualModelOptions: 'ความสามารถเพิ่มเติมของโมเดล',
editProvider: 'แก้ไขผู้ให้บริการ',
rescanModels: 'สแกนโมเดลอีกครั้ง',
moreFeaturesTitle: 'เพิ่มความสามารถให้ Agent',
moreFeaturesDescription:
'เปิดหน้าทำงานเพื่อเพิ่มเครื่องมือ ฐานความรู้ และความสามารถอื่น ๆ ให้ Agent ที่เพิ่งสร้างขึ้นโดยอัตโนมัติ',
runnerDescription:
'เลือก Runner สำหรับ Agent ภายนอกและกำหนดค่าการเชื่อมต่อ',
backToChoices: 'กลับไปยังตัวเลือก',
createExternal: 'สร้างและผูก',
finishWithModel: 'ใช้โมเดลที่เลือกและเสร็จสิ้น',
openWorkbench: 'เปิดหน้าทำงาน',
}, },
config: { config: {
botInfo: 'ข้อมูล Bot', botInfo: 'ข้อมูล Bot',
+66 -1
View File
@@ -1658,13 +1658,78 @@ const viVN = {
resaveBot: 'Lưu lại cấu hình', resaveBot: 'Lưu lại cấu hình',
botSaved: botSaved:
'Cấu hình Bot đã lưu và bật. Kiểm tra nhật ký để xác minh kết nối.', 'Cấu hình Bot đã lưu và bật. Kiểm tra nhật ký để xác minh kết nối.',
waitingForMessage:
'Bot đã được bật. Hãy gửi cho Bot một tin nhắn từ nền tảng nhắn tin để tiếp tục.',
messageReceived:
'Bot đã nhận được tin nhắn. Bạn có thể tiếp tục sang bước tiếp theo.',
messageReceivedLocalAccountWarning:
'Kết nối phía Bot đã được cấu hình đúng và đã nhận được tin nhắn. Vì bạn không đăng nhập bằng tài khoản LangBot, lệnh gọi mô hình có thể thất bại; hãy tiếp tục sang bước tiếp theo để thêm mô hình của riêng bạn.',
pageBotTestPrompt:
'Page Bot đã được bật. Nhấp vào bong bóng trò chuyện ở góc dưới bên phải và gửi tin nhắn để xác minh toàn bộ luồng hội thoại.',
pageBotTestNotice:
'Chỉ dùng để kiểm thử. Hãy nhúng mã vào một trang web bên ngoài thực tế.',
webhookTestPrompt:
'URL callback đã sẵn sàng. Hãy cấu hình URL này trên nền tảng bên ngoài, sau đó gửi một tin nhắn thực cho Bot.',
httpTestPrompt:
'HTTP Bot đã được bật. Gửi một tin nhắn đến thực tế tại đây để xác minh kết nối.',
httpTestDefaultMessage: 'Xin chào, đây là tin nhắn kiểm tra kết nối.',
sendHttpTest: 'Gửi tin nhắn kiểm tra',
httpTestAccepted:
'Tin nhắn kiểm tra đã được chấp nhận và sẽ sớm xuất hiện trong nhật ký.',
httpTestMissingSecret:
'Hãy nhập khóa bí mật ký yêu cầu đến và lưu cấu hình trước.',
httpTestFailed: 'Không thể gửi tin nhắn kiểm tra: {{error}}',
logsTitle: 'Nhật ký Bot', logsTitle: 'Nhật ký Bot',
logsDescription: logsDescription:
'Giám sát hoạt động Bot để xác minh kết nối nền tảng đang hoạt động.', 'Giám sát hoạt động Bot để xác minh kết nối nền tảng đang hoạt động.',
}, },
aiEngine: { aiEngine: {
title: 'Chọn công cụ AI', title: 'Cấu hình công cụ AI',
description: 'Chọn công cụ AI sẽ cung cấp trí tuệ cho Bot của bạn.', description: 'Chọn công cụ AI sẽ cung cấp trí tuệ cho Bot của bạn.',
optionalDescription:
'Bước này không bắt buộc. Hãy chọn cách bạn muốn tiếp tục với Agent hiện tại.',
externalTitle: 'Kết nối Agent từ nền tảng bên ngoài',
externalDescription:
'Kết nối Dify, n8n, Coze hoặc nền tảng khác và thay thế Pipeline của Bot.',
ownModelTitle: 'Sử dụng mô hình của riêng tôi',
ownModelDescription:
'Thêm nhà cung cấp, sau đó quét hoặc nhập mô hình thủ công để hoàn tất thiết lập.',
ownModelSetupTitle: 'Thêm mô hình của riêng bạn',
ownModelSetupDescription:
'Thêm nhà cung cấp mô hình. Các mô hình trò chuyện sẽ được quét tự động, hoặc bạn có thể nhập ID mô hình thủ công.',
addProviderTitle: 'Thêm nhà cung cấp',
addProviderDescription:
'Nhập thông tin nhà cung cấp và API Key dùng để kết nối và quét mô hình.',
selectModelTitle: 'Chọn mô hình',
selectScannedModelTitle: 'Chọn mô hình',
selectScannedModelDescription:
'Mô hình đã chọn sẽ là mô hình chính của Pipeline mới và Bot sẽ chuyển sang sử dụng mô hình đó.',
scanModelMode: 'Quét mô hình',
manualModelMode: 'Thêm thủ công',
scanningModels: 'Đang quét các mô hình khả dụng…',
noScannedModels:
'Không tìm thấy mô hình trò chuyện khả dụng. Hãy kiểm tra cấu hình nhà cung cấp.',
scanModelsFailed:
'Quét mô hình thất bại. Hãy kiểm tra URL và API Key rồi thử lại.',
manualFallbackFailed:
'Quét tự động thất bại. Hãy nhập ID mô hình được nhà cung cấp hỗ trợ.',
manualFallbackEmpty:
'Không tìm thấy mô hình. Hãy nhập ID mô hình được nhà cung cấp hỗ trợ.',
manualModelId: 'ID mô hình',
manualModelIdPlaceholder: 'Ví dụ: gpt-4o',
manualModelIdDescription:
'Nhập tham số mô hình được sử dụng trong các yêu cầu mô hình.',
manualModelOptions: 'Khả năng mô hình tùy chọn',
editProvider: 'Chỉnh sửa nhà cung cấp',
rescanModels: 'Quét lại mô hình',
moreFeaturesTitle: 'Thêm khả năng cho Agent',
moreFeaturesDescription:
'Mở bàn làm việc để thêm công cụ, cơ sở tri thức và các khả năng khác cho Agent vừa được tạo tự động.',
runnerDescription: 'Chọn Runner cho Agent bên ngoài và cấu hình kết nối.',
backToChoices: 'Quay lại các tùy chọn',
createExternal: 'Tạo và liên kết',
finishWithModel: 'Sử dụng mô hình đã chọn và hoàn tất',
openWorkbench: 'Mở bàn làm việc',
}, },
config: { config: {
botInfo: 'Thông tin Bot', botInfo: 'Thông tin Bot',
+5 -2
View File
@@ -1751,6 +1751,8 @@ const zhHans = {
botSaved: '机器人配置已保存并启用,请查看日志确认连接正常。', botSaved: '机器人配置已保存并启用,请查看日志确认连接正常。',
waitingForMessage: '机器人已启用。请在 IM 中向机器人发送一条消息以继续。', waitingForMessage: '机器人已启用。请在 IM 中向机器人发送一条消息以继续。',
messageReceived: '机器人已成功收到 IM 消息,可以进入下一步。', messageReceived: '机器人已成功收到 IM 消息,可以进入下一步。',
messageReceivedLocalAccountWarning:
'机器人侧已配置正常并成功收到 IM 消息。当前未通过 LangBot Account 登录,模型调用可能报错;可以进入下一步添加自己的模型。',
pageBotTestPrompt: pageBotTestPrompt:
'页面机器人已启用。点击右下角聊天气泡并发送一条消息,验证完整对话链路。', '页面机器人已启用。点击右下角聊天气泡并发送一条消息,验证完整对话链路。',
pageBotTestNotice: '仅供测试使用,请嵌入代码到真实外部网页。', pageBotTestNotice: '仅供测试使用,请嵌入代码到真实外部网页。',
@@ -1766,7 +1768,7 @@ const zhHans = {
logsDescription: '监控机器人活动,确认平台连接是否正常工作。', logsDescription: '监控机器人活动,确认平台连接是否正常工作。',
}, },
aiEngine: { aiEngine: {
title: '选择 AI 引擎', title: '配置 AI 引擎',
description: '选择驱动机器人智能的 AI 引擎。', description: '选择驱动机器人智能的 AI 引擎。',
optionalDescription: '这一步可选。选择接下来要如何完善当前 Agent。', optionalDescription: '这一步可选。选择接下来要如何完善当前 Agent。',
externalTitle: '接入外部平台 Agent', externalTitle: '接入外部平台 Agent',
@@ -1799,7 +1801,8 @@ const zhHans = {
editProvider: '修改供应商', editProvider: '修改供应商',
rescanModels: '重新扫描模型', rescanModels: '重新扫描模型',
moreFeaturesTitle: '给现在的 Agent 配置更多功能', moreFeaturesTitle: '给现在的 Agent 配置更多功能',
moreFeaturesDescription: '进入工作台,为 Agent 添加工具、知识库等能力。', moreFeaturesDescription:
'进入工作台,为刚刚自动生成的 Agent 添加工具、知识库等能力',
runnerDescription: '选择外部 Agent 的 Runner 并完成连接配置。', runnerDescription: '选择外部 Agent 的 Runner 并完成连接配置。',
backToChoices: '返回选项', backToChoices: '返回选项',
createExternal: '创建并绑定', createExternal: '创建并绑定',
+54 -1
View File
@@ -1584,12 +1584,65 @@ const zhHant = {
saveBot: '儲存並啟用', saveBot: '儲存並啟用',
resaveBot: '重新儲存配置', resaveBot: '重新儲存配置',
botSaved: '機器人配置已儲存並啟用,請查看日誌確認連接正常。', botSaved: '機器人配置已儲存並啟用,請查看日誌確認連接正常。',
waitingForMessage: '機器人已啟用。請在 IM 中向機器人傳送一則訊息以繼續。',
messageReceived: '機器人已成功收到 IM 訊息,可以進入下一步。',
messageReceivedLocalAccountWarning:
'機器人側已配置正常並成功收到 IM 訊息。目前未透過 LangBot Account 登入,模型呼叫可能報錯;可以進入下一步新增自己的模型。',
pageBotTestPrompt:
'頁面機器人已啟用。點擊右下角聊天氣泡並傳送一則訊息,驗證完整對話流程。',
pageBotTestNotice: '僅供測試使用,請將程式碼嵌入真實的外部網頁。',
webhookTestPrompt:
'回呼 URL 已準備好。請在外部平台完成配置,然後向機器人傳送一則真實訊息。',
httpTestPrompt:
'HTTP 機器人已啟用。請在此傳送一則真實的入站訊息以驗證連接。',
httpTestDefaultMessage: '你好,這是一則連接測試訊息。',
sendHttpTest: '傳送測試訊息',
httpTestAccepted: '測試訊息已被接受,稍後將顯示在日誌中。',
httpTestMissingSecret: '請先填寫入站簽章密鑰並儲存配置。',
httpTestFailed: '傳送測試訊息失敗:{{error}}',
logsTitle: '機器人日誌', logsTitle: '機器人日誌',
logsDescription: '監控機器人活動,確認平台連接是否正常運作。', logsDescription: '監控機器人活動,確認平台連接是否正常運作。',
}, },
aiEngine: { aiEngine: {
title: '選擇 AI 引擎', title: '配置 AI 引擎',
description: '選擇驅動機器人智慧的 AI 引擎。', description: '選擇驅動機器人智慧的 AI 引擎。',
optionalDescription: '這一步可選。選擇接下來要如何完善目前的 Agent。',
externalTitle: '接入外部平台 Agent',
externalDescription:
'接入 Dify、n8n、Coze 等平台,並替換目前機器人的流水線。',
ownModelTitle: '改成使用自己的模型',
ownModelDescription:
'新增模型供應商,自動掃描或手動填寫模型以快速完成引導。',
ownModelSetupTitle: '新增你自己的模型',
ownModelSetupDescription:
'先新增模型供應商,儲存後會自動掃描,也可以手動填寫模型 ID。',
addProviderTitle: '新增供應商',
addProviderDescription: '填寫供應商和 API Key,用於連接並掃描模型。',
selectModelTitle: '選擇模型',
selectScannedModelTitle: '選擇模型',
selectScannedModelDescription:
'所選模型將成為新流水線的主要模型,機器人也會切換到該流水線。',
scanModelMode: '自動掃描',
manualModelMode: '手動新增',
scanningModels: '正在掃描可用模型…',
noScannedModels: '未找到可用的對話模型,請檢查供應商配置。',
scanModelsFailed: '掃描模型失敗,請檢查請求 URL 和 API Key 後重試。',
manualFallbackFailed: '自動掃描失敗,請填寫該供應商支援的模型 ID。',
manualFallbackEmpty: '未掃描到模型,請填寫該供應商支援的模型 ID。',
manualModelId: '模型 ID',
manualModelIdPlaceholder: '例如:gpt-4o',
manualModelIdDescription: '填寫模型請求中使用的 model 參數。',
manualModelOptions: '模型能力(可選)',
editProvider: '編輯供應商',
rescanModels: '重新掃描模型',
moreFeaturesTitle: '給目前的 Agent 配置更多功能',
moreFeaturesDescription:
'進入工作台,為剛剛自動產生的 Agent 新增工具、知識庫等能力',
runnerDescription: '選擇外部 Agent 的 Runner 並完成連接配置。',
backToChoices: '返回選項',
createExternal: '建立並關聯',
finishWithModel: '使用所選模型並完成',
openWorkbench: '進入工作台',
}, },
config: { config: {
botInfo: '機器人資訊', botInfo: '機器人資訊',
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import ts from 'typescript';
import { fileURLToPath } from 'node:url';
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
const sourcePath = path.resolve(
currentDirectory,
'../../src/app/infra/entities/adapter-categories.ts',
);
function loadCategoryHelpers(language = 'zh-Hans') {
const source = fs.readFileSync(sourcePath, 'utf8');
const compiled = ts.transpileModule(source, {
compilerOptions: {
esModuleInterop: true,
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
},
}).outputText;
const loadedModule = { exports: {} };
new Function('require', 'module', 'exports', compiled)(
(name) => {
if (name === 'i18next') return { language };
throw new Error(`Unexpected runtime import: ${name}`);
},
loadedModule,
loadedModule.exports,
);
return loadedModule.exports;
}
test('places an adapter only once when metadata repeats a category', () => {
const { groupByCategory } = loadCategoryHelpers();
const adapter = { name: 'http_bot', categories: ['popular', 'popular'] };
assert.deepEqual(groupByCategory([adapter]), [
{ categoryId: 'popular', items: [adapter] },
]);
});
@@ -24,11 +24,11 @@ function loadNormalizer() {
loadedModule, loadedModule,
loadedModule.exports, loadedModule.exports,
); );
return loadedModule.exports.normalizeDynamicFormValuesForSave; return loadedModule.exports;
} }
test('normalizes only single-line text fields in a dynamic form save snapshot', () => { test('normalizes only single-line text fields in a dynamic form save snapshot', () => {
const normalizeDynamicFormValuesForSave = loadNormalizer(); const { normalizeDynamicFormValuesForSave } = loadNormalizer();
const specs = [ const specs = [
{ name: 'single-line', type: 'string', default: '' }, { name: 'single-line', type: 'string', default: '' },
{ name: 'multiline', type: 'text', default: '' }, { name: 'multiline', type: 'text', default: '' },
@@ -70,3 +70,29 @@ test('normalizes only single-line text fields in a dynamic form save snapshot',
}, },
}); });
}); });
test('normalizes missing dynamic form defaults into controlled values', () => {
const { normalizeDynamicFormFieldValue } = loadNormalizer();
assert.equal(
normalizeDynamicFormFieldValue(
{ name: 'api-key', type: 'string', default: undefined },
undefined,
),
'',
);
assert.equal(
normalizeDynamicFormFieldValue(
{ name: 'enabled', type: 'boolean', default: undefined },
undefined,
),
false,
);
assert.deepEqual(
normalizeDynamicFormFieldValue(
{ name: 'items', type: 'array[string]', default: undefined },
undefined,
),
[],
);
});
+24
View File
@@ -32,6 +32,7 @@ const {
ensureHttpBotSigningSecret, ensureHttpBotSigningSecret,
findDefaultPipeline, findDefaultPipeline,
getErrorMessage, getErrorMessage,
isRequiredRunnerConfigComplete,
isWebhookModeEnabled, isWebhookModeEnabled,
} = loadWizardUtils(); } = 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([{ name: 'webhook_url' }], {}), true);
assert.equal(isWebhookModeEnabled([], {}), false); 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,
);
});
+95
View File
@@ -9,6 +9,13 @@ const wizardSource = fs.readFileSync(
path.resolve(currentDirectory, '../../src/app/wizard/page.tsx'), path.resolve(currentDirectory, '../../src/app/wizard/page.tsx'),
'utf8', 'utf8',
); );
const ownModelSetupSource = fs.readFileSync(
path.resolve(
currentDirectory,
'../../src/app/wizard/components/OwnModelSetup.tsx',
),
'utf8',
);
const widgetSource = fs.readFileSync( const widgetSource = fs.readFileSync(
path.resolve( path.resolve(
currentDirectory, 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, /if \(scriptTestNotice\)/);
assert.match(widgetSource, /testNotice\.textContent = 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,
);
});