mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-27 12:47:13 +00:00
Compare commits
5 Commits
cd7acd5769
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 855ae2bdba | |||
| b66db86bff | |||
| 95b8736e93 | |||
| cabde423a1 | |||
| 08307790e5 |
@@ -75,8 +75,6 @@ shape as the corresponding HTTP API request body. Discover resources with the
|
||||
`list_*` / `get_*` tools before mutating; identifiers are UUIDs. Reads require
|
||||
`resource.view`; mutations require `resource.manage`. All service calls inherit
|
||||
the immutable Workspace context authenticated at the MCP transport boundary.
|
||||
Pass `is_default: true` to `create_pipeline` only when the Workspace does not
|
||||
already have a default pipeline.
|
||||
|
||||
## How to use
|
||||
|
||||
|
||||
@@ -295,6 +295,34 @@ class WecomCSClient:
|
||||
raise Exception('Failed to send message')
|
||||
return data
|
||||
|
||||
@_bounded_token_retry
|
||||
async def send_image_msg(self, open_kfid: str, external_userid: str, msgid: str, media_id: str):
|
||||
if not await self.check_access_token():
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
|
||||
url = f'{self.base_url}/kf/send_msg?access_token={self.access_token}'
|
||||
payload = {
|
||||
'touser': external_userid,
|
||||
'open_kfid': open_kfid,
|
||||
'msgid': msgid,
|
||||
'msgtype': 'image',
|
||||
'image': {
|
||||
'media_id': media_id,
|
||||
},
|
||||
}
|
||||
|
||||
async with self._http_client_context() as client:
|
||||
response = await client.post(url, json=payload)
|
||||
|
||||
data = await httpclient.parse_json_response(response)
|
||||
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
return await self.send_image_msg(open_kfid, external_userid, msgid, media_id)
|
||||
if data['errcode'] != 0:
|
||||
await self.logger.error(f'发送图片失败:{data}')
|
||||
raise Exception('Failed to send image message')
|
||||
return data
|
||||
|
||||
async def handle_callback_request(self):
|
||||
"""处理回调请求(独立端口模式,使用全局 request)。"""
|
||||
return await self._handle_callback_internal(request)
|
||||
|
||||
@@ -39,13 +39,7 @@ class PipelinesRouterGroup(group.RouterGroup):
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
pipeline_data = await quart.request.json
|
||||
create_as_default = pipeline_data.get('is_default') is True
|
||||
pipeline_uuid = await self.ap.pipeline_service.create_pipeline(
|
||||
request_context,
|
||||
pipeline_data,
|
||||
default=create_as_default,
|
||||
)
|
||||
pipeline_uuid = await self.ap.pipeline_service.create_pipeline(request_context, await quart.request.json)
|
||||
return self.success(data={'uuid': pipeline_uuid})
|
||||
|
||||
@self.route(
|
||||
|
||||
@@ -113,24 +113,6 @@ class BotsRouterGroup(group.RouterGroup):
|
||||
)
|
||||
return self.success(data={'sent': True})
|
||||
|
||||
@self.route(
|
||||
'/<bot_uuid>/test-inbound',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(bot_uuid: str, request_context: RequestContext) -> str:
|
||||
json_data = await quart.request.get_json(silent=True) or {}
|
||||
try:
|
||||
result = await self.ap.bot_service.send_http_bot_test_message(
|
||||
request_context,
|
||||
bot_uuid,
|
||||
str(json_data.get('message') or ''),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
return self.success(data=result)
|
||||
|
||||
@self.route(
|
||||
'/<bot_uuid>/admins',
|
||||
methods=['GET'],
|
||||
|
||||
@@ -206,20 +206,6 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success(data={})
|
||||
|
||||
@self.route(
|
||||
'/wizard/recommended-model',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
"""Resolve Space's best available chat model to this Workspace."""
|
||||
try:
|
||||
model = await self.ap.space_service.get_recommended_chat_model(request_context)
|
||||
except ValueError as exc:
|
||||
return self.http_status(503, -1, str(exc))
|
||||
return self.success(data=model)
|
||||
|
||||
@self.route(
|
||||
'/tasks',
|
||||
methods=['GET'],
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
import json
|
||||
import sqlalchemy
|
||||
|
||||
from ....core import app
|
||||
@@ -9,8 +8,6 @@ from ....entity.persistence import bot as persistence_bot
|
||||
from ....entity.persistence import pipeline as persistence_pipeline
|
||||
from ....workspace.errors import WorkspaceNotFoundError
|
||||
from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
||||
from ....utils import httpclient
|
||||
from ....platform.sources import http_bot_signing
|
||||
|
||||
|
||||
class BotService:
|
||||
@@ -83,7 +80,6 @@ class BotService:
|
||||
'wecomcs',
|
||||
'LINE',
|
||||
'lark',
|
||||
'http_bot',
|
||||
]:
|
||||
webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
|
||||
extra_webhook_prefix = self.ap.instance_config.data['api'].get('extra_webhook_prefix', '')
|
||||
@@ -220,53 +216,6 @@ class BotService:
|
||||
|
||||
return [log.to_json() for log in logs], total_count
|
||||
|
||||
async def send_http_bot_test_message(
|
||||
self,
|
||||
context: TenantContext,
|
||||
bot_uuid: str,
|
||||
message: str,
|
||||
) -> dict:
|
||||
"""Send a signed test message through the HTTP Bot public ingress."""
|
||||
bot = await self.get_bot(context, bot_uuid, include_secret=True)
|
||||
if bot is None:
|
||||
raise WorkspaceNotFoundError('Bot not found')
|
||||
if bot.get('adapter') != 'http_bot':
|
||||
raise ValueError('Inbound test is only available for HTTP Bot')
|
||||
if not bot.get('enable'):
|
||||
raise ValueError('Bot must be enabled before sending a test message')
|
||||
|
||||
text = message.strip()
|
||||
if not text or len(text) > 2000:
|
||||
raise ValueError('Test message must contain 1 to 2000 characters')
|
||||
|
||||
payload = {
|
||||
'session_id': f'wizard-{uuid.uuid4().hex}',
|
||||
'sender': {'id': 'wizard-user', 'name': 'Wizard Test'},
|
||||
'message': [{'type': 'Plain', 'text': text}],
|
||||
}
|
||||
body = json.dumps(payload, ensure_ascii=False, separators=(',', ':')).encode()
|
||||
config = bot.get('adapter_config') or {}
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
if config.get('signature_required', True):
|
||||
secret = str(config.get('inbound_secret') or '')
|
||||
if not secret:
|
||||
raise ValueError('HTTP Bot inbound signing secret is required')
|
||||
timestamp, signature = http_bot_signing.sign(secret, body)
|
||||
headers[http_bot_signing.HEADER_TIMESTAMP] = timestamp
|
||||
headers[http_bot_signing.HEADER_SIGNATURE] = signature
|
||||
|
||||
port = int(self.ap.instance_config.data.get('api', {}).get('port', 5300))
|
||||
session = httpclient.get_session()
|
||||
async with session.post(
|
||||
f'http://127.0.0.1:{port}/bots/{bot_uuid}',
|
||||
data=body,
|
||||
headers=headers,
|
||||
) as response:
|
||||
result = await httpclient.read_json_limited(response)
|
||||
if response.status not in {200, 202}:
|
||||
raise ValueError(result.get('msg') or f'HTTP Bot test failed with status {response.status}')
|
||||
return result.get('data') or {}
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
context: TenantContext,
|
||||
|
||||
@@ -11,9 +11,6 @@ import sqlalchemy
|
||||
from ....core import app
|
||||
from ....entity.persistence import user
|
||||
from ....entity.dto.space_model import SpaceModel
|
||||
from ....entity.dto.space_model import SpaceModelSelection
|
||||
from ....entity.persistence import model as persistence_model
|
||||
from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER
|
||||
|
||||
|
||||
_CREDITS_CACHE_TTL_SECONDS = 60
|
||||
@@ -241,76 +238,3 @@ class SpaceService:
|
||||
raise ValueError(f'Failed to get models: {data.get("msg")}')
|
||||
models_data = data.get('data', {}).get('models', [])
|
||||
return [SpaceModel.model_validate(model_dict) for model_dict in models_data]
|
||||
|
||||
async def get_model_selection(self, category: str) -> typing.List[SpaceModelSelection]:
|
||||
"""Return Space models in the availability-ranked selection order."""
|
||||
space_url = self._get_space_config()['url']
|
||||
session = httpclient.get_session()
|
||||
async with session.get(
|
||||
f'{space_url}/api/v1/models/selection',
|
||||
params={'category': category},
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
error = await httpclient.read_text_limited(response)
|
||||
raise ValueError(f'Failed to get model selection: {error}')
|
||||
payload = await httpclient.read_json_limited(response)
|
||||
if payload.get('code') != 0:
|
||||
raise ValueError(f'Failed to get model selection: {payload.get("msg")}')
|
||||
|
||||
data = payload.get('data', [])
|
||||
if isinstance(data, dict):
|
||||
data = data.get('models', data.get('items', []))
|
||||
if not isinstance(data, list):
|
||||
raise ValueError('Failed to get model selection: invalid response')
|
||||
|
||||
models = []
|
||||
for selection in data:
|
||||
if isinstance(selection, dict) and isinstance(selection.get('model'), dict):
|
||||
models.append(selection['model'])
|
||||
else:
|
||||
models.append(selection)
|
||||
return [SpaceModelSelection.model_validate(model) for model in models]
|
||||
|
||||
async def get_recommended_chat_model(self, context: typing.Any) -> dict:
|
||||
"""Resolve Space's first ranked chat model to a local Workspace model."""
|
||||
selection = await self.get_model_selection('chat')
|
||||
if not selection:
|
||||
raise ValueError('No recommended chat model is available')
|
||||
recommended = selection[0]
|
||||
|
||||
async def find_local_model():
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.LLMModel)
|
||||
.join(
|
||||
persistence_model.ModelProvider,
|
||||
sqlalchemy.and_(
|
||||
persistence_model.ModelProvider.workspace_uuid == persistence_model.LLMModel.workspace_uuid,
|
||||
persistence_model.ModelProvider.uuid == persistence_model.LLMModel.provider_uuid,
|
||||
),
|
||||
)
|
||||
.where(
|
||||
persistence_model.LLMModel.workspace_uuid == context.workspace_uuid,
|
||||
persistence_model.ModelProvider.requester == LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||
sqlalchemy.or_(
|
||||
persistence_model.LLMModel.uuid == recommended.uuid,
|
||||
persistence_model.LLMModel.name == recommended.model_id,
|
||||
),
|
||||
)
|
||||
)
|
||||
return result.first()
|
||||
|
||||
local_model = await find_local_model()
|
||||
if local_model is None:
|
||||
# OSS synchronizes the public catalog locally. Refresh once in case
|
||||
# the recommendation was published after this process started.
|
||||
from ..context import ExecutionContext
|
||||
|
||||
try:
|
||||
await self.ap.model_mgr.sync_new_models_from_space(ExecutionContext.from_request(context))
|
||||
except Exception:
|
||||
pass
|
||||
local_model = await find_local_model()
|
||||
|
||||
if local_model is None:
|
||||
raise ValueError('Recommended chat model is not available in this Workspace')
|
||||
return {'uuid': local_model.uuid, 'name': local_model.name}
|
||||
|
||||
@@ -147,16 +147,7 @@ class LangBotMCPServer:
|
||||
)
|
||||
async def create_pipeline(pipeline_data: dict) -> str:
|
||||
context = _authorized(Permission.RESOURCE_MANAGE)
|
||||
create_as_default = pipeline_data.get('is_default') is True
|
||||
return _dump(
|
||||
{
|
||||
'uuid': await ap.pipeline_service.create_pipeline(
|
||||
context,
|
||||
pipeline_data,
|
||||
default=create_as_default,
|
||||
)
|
||||
}
|
||||
)
|
||||
return _dump({'uuid': await ap.pipeline_service.create_pipeline(context, pipeline_data)})
|
||||
|
||||
@mcp.tool(description='Update a pipeline by UUID. `pipeline_data` matches the PUT body.')
|
||||
async def update_pipeline(pipeline_uuid: str, pipeline_data: dict) -> str:
|
||||
|
||||
@@ -47,10 +47,3 @@ class SpaceModel(pydantic.BaseModel):
|
||||
status: str
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class SpaceModelSelection(pydantic.BaseModel):
|
||||
"""Minimal model identity returned by the ranked selection endpoint."""
|
||||
|
||||
uuid: str
|
||||
model_id: str
|
||||
|
||||
@@ -5,6 +5,11 @@ from .. import entities
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
from ....utils.safe_regex import SafeRegexError, mask_patterns
|
||||
|
||||
# Legacy sensitive-words.json files shipped ~70 rules, which exceeds the
|
||||
# default safe_regex per-call cap of 64 and used to fail-close every message.
|
||||
# Keep one 50ms CPU budget for the whole list; only raise the pattern cap.
|
||||
_MAX_SENSITIVE_WORD_PATTERNS = 256
|
||||
|
||||
|
||||
@filter_model.filter_class('ban-word-filter')
|
||||
class BanWordFilter(filter_model.ContentFilter):
|
||||
@@ -14,12 +19,17 @@ class BanWordFilter(filter_model.ContentFilter):
|
||||
pass
|
||||
|
||||
async def process(self, query: pipeline_query.Query, message: str) -> entities.FilterResult:
|
||||
words = self.ap.sensitive_meta.data.get('words') or []
|
||||
mask = self.ap.sensitive_meta.data['mask']
|
||||
mask_word = self.ap.sensitive_meta.data['mask_word']
|
||||
|
||||
try:
|
||||
found, message = await mask_patterns(
|
||||
self.ap.sensitive_meta.data['words'],
|
||||
found, current = await mask_patterns(
|
||||
words,
|
||||
message,
|
||||
mask=self.ap.sensitive_meta.data['mask'],
|
||||
mask_word=self.ap.sensitive_meta.data['mask_word'],
|
||||
mask=mask,
|
||||
mask_word=mask_word,
|
||||
max_pattern_count=_MAX_SENSITIVE_WORD_PATTERNS,
|
||||
)
|
||||
except SafeRegexError as exc:
|
||||
return entities.FilterResult(
|
||||
@@ -31,7 +41,7 @@ class BanWordFilter(filter_model.ContentFilter):
|
||||
|
||||
return entities.FilterResult(
|
||||
level=entities.ResultLevel.MASKED if found else entities.ResultLevel.PASS,
|
||||
replacement=message,
|
||||
replacement=current,
|
||||
user_notice='消息中存在不合适的内容, 请修改' if found else '',
|
||||
console_notice='',
|
||||
)
|
||||
|
||||
@@ -25,6 +25,7 @@ from linebot.v3.webhooks import (
|
||||
ImageMessageContent,
|
||||
VideoMessageContent,
|
||||
AudioMessageContent,
|
||||
UserMentionee,
|
||||
)
|
||||
|
||||
# from linebot import WebhookParser
|
||||
@@ -58,15 +59,19 @@ class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
|
||||
return content_list
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(message, bot_client) -> platform_message.MessageChain:
|
||||
def __init__(self, bot_account_id: str = ''):
|
||||
self.bot_account_id = bot_account_id
|
||||
|
||||
async def target2yiri(self, message, bot_client) -> platform_message.MessageChain:
|
||||
lb_msg_list = []
|
||||
msg_create_time = datetime.datetime.fromtimestamp(int(message.timestamp) / 1000)
|
||||
|
||||
lb_msg_list.append(platform_message.Source(id=message.webhook_event_id, time=msg_create_time))
|
||||
|
||||
if isinstance(message.message, TextMessageContent):
|
||||
lb_msg_list.append(platform_message.Plain(text=message.message.text))
|
||||
lb_msg_list.extend(
|
||||
self._build_text_components(message.message.text, getattr(message.message, 'mention', None))
|
||||
)
|
||||
elif isinstance(message.message, AudioMessageContent):
|
||||
pass
|
||||
elif isinstance(message.message, VideoMessageContent):
|
||||
@@ -86,17 +91,55 @@ class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
lb_msg_list.append(platform_message.Image(base64=data_uri))
|
||||
return platform_message.MessageChain(lb_msg_list)
|
||||
|
||||
def _build_text_components(self, text: str, mention) -> list:
|
||||
"""Build message components from text, inserting At components for mentions.
|
||||
|
||||
LINE provides mention positions (index/length) and is_self per mentionee in the
|
||||
webhook payload. Mapping the bot mention to At(target=bot_account_id) makes the
|
||||
'at-bot' group respond rule work for LINE, consistent with other adapters.
|
||||
"""
|
||||
components: list = []
|
||||
if not mention or not mention.mentionees:
|
||||
if text:
|
||||
components.append(platform_message.Plain(text=text))
|
||||
return components
|
||||
segments: list[tuple[int, int, object]] = sorted((m.index, m.index + m.length, m) for m in mention.mentionees)
|
||||
cursor = 0
|
||||
for start, end, mentionee in segments:
|
||||
if start < cursor:
|
||||
start, end = cursor, min(end, len(text))
|
||||
if start < cursor or end <= start or end > len(text):
|
||||
continue
|
||||
if start > cursor:
|
||||
components.append(platform_message.Plain(text=text[cursor:start]))
|
||||
if isinstance(mentionee, UserMentionee):
|
||||
target = self.bot_account_id if mentionee.is_self else mentionee.user_id
|
||||
if not target:
|
||||
target = text[start:end]
|
||||
else:
|
||||
target = text[start:end]
|
||||
# At.__str__ already prepends '@', so strip one from the LINE text token.
|
||||
display = text[start:end].lstrip('@')
|
||||
components.append(platform_message.At(target=str(target), display=display))
|
||||
cursor = end
|
||||
if cursor < len(text):
|
||||
components.append(platform_message.Plain(text=text[cursor:]))
|
||||
return components
|
||||
|
||||
|
||||
class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
def __init__(self, bot_account_id: str = ''):
|
||||
self.bot_account_id = bot_account_id
|
||||
self.message_converter = LINEMessageConverter(bot_account_id)
|
||||
|
||||
@staticmethod
|
||||
async def yiri2target(
|
||||
event: platform_events.MessageEvent,
|
||||
) -> MessageEvent:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(event, bot_client) -> platform_events.Event:
|
||||
message_chain = await LINEMessageConverter.target2yiri(event, bot_client)
|
||||
async def target2yiri(self, event, bot_client) -> platform_events.Event:
|
||||
message_chain = await self.message_converter.target2yiri(event, bot_client)
|
||||
|
||||
if event.source.type == 'user':
|
||||
return platform_events.FriendMessage(
|
||||
@@ -169,8 +212,8 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
listeners={},
|
||||
card_id_dict={},
|
||||
seq=1,
|
||||
event_converter=LINEEventConverter(),
|
||||
message_converter=LINEMessageConverter(),
|
||||
event_converter=LINEEventConverter(bot_account_id),
|
||||
message_converter=LINEMessageConverter(bot_account_id),
|
||||
line_webhook=line_webhook,
|
||||
parser=parser,
|
||||
configuration=configuration,
|
||||
|
||||
@@ -107,7 +107,7 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
if event.type == 'text':
|
||||
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
|
||||
friend = platform_entities.Friend(
|
||||
id=f'u{event.user_id}',
|
||||
id=f'{event.receiver_id}|u{event.user_id}',
|
||||
nickname=nickname,
|
||||
remark='',
|
||||
)
|
||||
@@ -117,7 +117,7 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
)
|
||||
elif event.type == 'image':
|
||||
friend = platform_entities.Friend(
|
||||
id=f'u{event.user_id}',
|
||||
id=f'{event.receiver_id}|u{event.user_id}',
|
||||
nickname=nickname,
|
||||
remark='',
|
||||
)
|
||||
@@ -197,7 +197,7 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
|
||||
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
||||
for content in content_list:
|
||||
msgid = f'langbot_{uuid.uuid4().hex}'
|
||||
msgid = f'{uuid.uuid4().hex}'
|
||||
if content['type'] == 'text':
|
||||
await self.bot.send_text_msg(
|
||||
open_kfid=open_kfid,
|
||||
@@ -205,6 +205,13 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
msgid=msgid,
|
||||
content=content['content'],
|
||||
)
|
||||
elif content['type'] == 'image':
|
||||
await self.bot.send_image_msg(
|
||||
open_kfid=open_kfid,
|
||||
external_userid=external_userid,
|
||||
msgid=msgid,
|
||||
media_id=content['media_id'],
|
||||
)
|
||||
|
||||
def set_bot_uuid(self, bot_uuid: str):
|
||||
"""设置 bot UUID(用于生成 webhook URL)"""
|
||||
|
||||
@@ -747,9 +747,24 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
converted_parts = []
|
||||
for part in content:
|
||||
if isinstance(part, dict) and part.get('type') == 'image_base64':
|
||||
part['image_url'] = {'url': part['image_base64']}
|
||||
part['type'] = 'image_url'
|
||||
del part['image_base64']
|
||||
# History trimming (SessionManager) clears image_base64
|
||||
# on past turns and exclude_none serialization drops
|
||||
# the key entirely, so the replayed part may carry no
|
||||
# payload. Prefer the base64 payload; fall back to an
|
||||
# image_url that survived on the same element; drop
|
||||
# hollow parts instead of raising KeyError (#2469).
|
||||
image_b64 = part.get('image_base64')
|
||||
fallback_url = None
|
||||
if not image_b64:
|
||||
raw_image_url = part.get('image_url')
|
||||
if isinstance(raw_image_url, dict):
|
||||
fallback_url = raw_image_url.get('url')
|
||||
if image_b64 or fallback_url:
|
||||
part['image_url'] = {'url': image_b64 or fallback_url}
|
||||
part['type'] = 'image_url'
|
||||
part.pop('image_base64', None)
|
||||
else:
|
||||
continue
|
||||
# OpenAI-compatible chat models reject non-image file parts
|
||||
# (audio/document base64 or url). These originate from Voice /
|
||||
# File attachments — including ones replayed from conversation
|
||||
|
||||
@@ -619,7 +619,9 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
and len(func_ret) > 0
|
||||
and isinstance(func_ret[0], provider_message.ContentElement)
|
||||
):
|
||||
tool_content = func_ret
|
||||
# OpenAI-compatible APIs require tool-message content to be a
|
||||
# string; a raw list of ContentElement causes HTTP 500 (#2457).
|
||||
tool_content = '\n'.join(str(ce) for ce in func_ret)
|
||||
else:
|
||||
tool_content = json.dumps(func_ret, ensure_ascii=False)
|
||||
|
||||
|
||||
@@ -27,10 +27,16 @@ class SafeRegexTimeoutError(SafeRegexError):
|
||||
"""Raised when the regex engine exhausts the operation CPU budget."""
|
||||
|
||||
|
||||
def _validate_patterns(patterns: Sequence[str]) -> tuple[str, ...]:
|
||||
def _validate_patterns(
|
||||
patterns: Sequence[str],
|
||||
*,
|
||||
max_pattern_count: int = MAX_PATTERN_COUNT,
|
||||
) -> tuple[str, ...]:
|
||||
if max_pattern_count < 1:
|
||||
raise ValueError('max_pattern_count must be positive')
|
||||
if len(patterns) > max_pattern_count:
|
||||
raise SafeRegexLimitError(f'At most {max_pattern_count} regex patterns are allowed')
|
||||
normalized = tuple(patterns)
|
||||
if len(normalized) > MAX_PATTERN_COUNT:
|
||||
raise SafeRegexLimitError(f'At most {MAX_PATTERN_COUNT} regex patterns are allowed')
|
||||
for pattern in normalized:
|
||||
if not isinstance(pattern, str):
|
||||
raise SafeRegexError('Regex patterns must be strings')
|
||||
@@ -115,8 +121,9 @@ def _mask_patterns_sync(
|
||||
mask: str,
|
||||
mask_word: str,
|
||||
timeout_seconds: float,
|
||||
max_pattern_count: int,
|
||||
) -> tuple[bool, str]:
|
||||
normalized_patterns = _validate_patterns(patterns)
|
||||
normalized_patterns = _validate_patterns(patterns, max_pattern_count=max_pattern_count)
|
||||
_validate_input(value)
|
||||
if len(mask) > MAX_REPLACEMENT_CHARS or len(mask_word) > MAX_REPLACEMENT_CHARS:
|
||||
raise SafeRegexLimitError(f'Regex replacements may contain at most {MAX_REPLACEMENT_CHARS} characters')
|
||||
@@ -162,6 +169,7 @@ async def mask_patterns(
|
||||
mask: str,
|
||||
mask_word: str,
|
||||
timeout_seconds: float = DEFAULT_OPERATION_TIMEOUT_SECONDS,
|
||||
max_pattern_count: int = MAX_PATTERN_COUNT,
|
||||
) -> tuple[bool, str]:
|
||||
"""Apply untrusted masking patterns with bounded CPU and output growth."""
|
||||
|
||||
@@ -174,4 +182,5 @@ async def mask_patterns(
|
||||
mask=mask,
|
||||
mask_word=mask_word,
|
||||
timeout_seconds=timeout_seconds,
|
||||
max_pattern_count=max_pattern_count,
|
||||
)
|
||||
|
||||
@@ -7,9 +7,6 @@
|
||||
// Read config from script tag data attributes
|
||||
var scriptEl = document.currentScript;
|
||||
var scriptTitle = scriptEl ? scriptEl.getAttribute("data-title") : null;
|
||||
var scriptTestNotice = scriptEl
|
||||
? scriptEl.getAttribute("data-test-notice")
|
||||
: null;
|
||||
|
||||
// ========== i18n ==========
|
||||
var I18N = {
|
||||
@@ -195,7 +192,6 @@
|
||||
.lb-header-btn { background: none; border: none; color: #fff; cursor: pointer; padding: 4px; border-radius: 6px; display: flex; align-items: center; justify-content: center; opacity: 0.8; transition: opacity 0.15s; }\
|
||||
.lb-header-btn:hover { opacity: 1; }\
|
||||
.lb-header-btn svg { width: 18px; height: 18px; fill: currentColor; }\
|
||||
.lb-test-notice { padding: 8px 16px; border-bottom: 1px solid #fde68a; background: #fffbeb; color: #92400e; font-size: 12px; line-height: 1.5; text-align: center; flex-shrink: 0; }\
|
||||
.lb-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 16px; scroll-behavior: smooth; }\
|
||||
.lb-messages::-webkit-scrollbar { width: 6px; }\
|
||||
.lb-messages::-webkit-scrollbar-track { background: transparent; }\
|
||||
@@ -1244,14 +1240,6 @@
|
||||
// Root container
|
||||
var root = document.createElement("div");
|
||||
root.id = "langbot-widget-root";
|
||||
root.langbotDestroy = function () {
|
||||
wsDisconnect();
|
||||
if (state.historyReloadTimer) {
|
||||
clearTimeout(state.historyReloadTimer);
|
||||
state.historyReloadTimer = null;
|
||||
}
|
||||
root.remove();
|
||||
};
|
||||
document.body.appendChild(root);
|
||||
|
||||
var shadow = root.attachShadow({ mode: "open" });
|
||||
@@ -1340,14 +1328,6 @@
|
||||
header.appendChild(headerActions);
|
||||
panel.appendChild(header);
|
||||
|
||||
if (scriptTestNotice) {
|
||||
var testNotice = document.createElement("div");
|
||||
testNotice.className = "lb-test-notice";
|
||||
testNotice.setAttribute("role", "note");
|
||||
testNotice.textContent = scriptTestNotice;
|
||||
panel.appendChild(testNotice);
|
||||
}
|
||||
|
||||
// Messages area
|
||||
var messages = document.createElement("div");
|
||||
messages.className = "lb-messages";
|
||||
|
||||
@@ -254,22 +254,6 @@ class TestPipelinesCRUDEndpoints:
|
||||
assert data['code'] == 0
|
||||
assert 'uuid' in data['data']
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_default_pipeline_forwards_default_flag(self, quart_test_client, fake_pipeline_app):
|
||||
"""POST /api/v1/pipelines explicitly creates a default pipeline."""
|
||||
fake_pipeline_app.pipeline_service.create_pipeline.reset_mock()
|
||||
|
||||
response = await quart_test_client.post(
|
||||
'/api/v1/pipelines',
|
||||
headers={'Authorization': 'Bearer test_token'},
|
||||
json={'name': 'Default Pipeline', 'config': {}, 'is_default': True},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
call = fake_pipeline_app.pipeline_service.create_pipeline.await_args
|
||||
assert call.kwargs == {'default': True}
|
||||
assert call.args[1]['is_default'] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_pipeline_success(self, quart_test_client):
|
||||
"""PUT /api/v1/pipelines/{uuid} updates pipeline."""
|
||||
|
||||
@@ -9,9 +9,8 @@ Source: src/langbot/pkg/api/http/service/bot.py
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
from types import SimpleNamespace
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from langbot.pkg.api.http.service.bot import BotService
|
||||
@@ -242,29 +241,6 @@ class TestBotServiceGetRuntimeBotInfo:
|
||||
assert result['adapter_runtime_values']['webhook_url'] == '/bots/wecom-uuid'
|
||||
assert result['adapter_runtime_values']['webhook_full_url'] == 'http://127.0.0.1:5300/bots/wecom-uuid'
|
||||
|
||||
async def test_get_runtime_bot_info_returns_webhook_for_http_bot(self):
|
||||
ap = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(
|
||||
data={'api': {'webhook_prefix': 'https://bot.example.com'}}
|
||||
),
|
||||
platform_mgr=SimpleNamespace(get_bot_by_uuid=AsyncMock(return_value=None)),
|
||||
)
|
||||
service = BotService(ap)
|
||||
service.get_bot = AsyncMock(
|
||||
return_value={
|
||||
'uuid': 'http-bot-uuid',
|
||||
'name': 'HTTP Bot',
|
||||
'adapter': 'http_bot',
|
||||
'adapter_config': {},
|
||||
}
|
||||
)
|
||||
|
||||
result = await service.get_runtime_bot_info(WORKSPACE_UUID, 'http-bot-uuid')
|
||||
|
||||
assert result['adapter_runtime_values']['webhook_full_url'] == (
|
||||
'https://bot.example.com/bots/http-bot-uuid'
|
||||
)
|
||||
|
||||
async def test_get_runtime_bot_info_no_webhook_for_telegram(self):
|
||||
"""Returns no webhook URL for non-webhook adapters like telegram."""
|
||||
# Setup
|
||||
@@ -629,77 +605,6 @@ class TestBotServiceListEventLogs:
|
||||
assert total == 5
|
||||
|
||||
|
||||
class TestBotServiceHttpBotInboundTest:
|
||||
async def test_sends_signed_message_through_public_ingress(self):
|
||||
ap = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(data={'api': {'port': 5300}}),
|
||||
)
|
||||
service = BotService(ap)
|
||||
service.get_bot = AsyncMock(
|
||||
return_value={
|
||||
'uuid': 'http-bot-uuid',
|
||||
'adapter': 'http_bot',
|
||||
'adapter_config': {
|
||||
'signature_required': True,
|
||||
'inbound_secret': 'test-secret',
|
||||
},
|
||||
'enable': True,
|
||||
}
|
||||
)
|
||||
response = MagicMock(status=202)
|
||||
session = MagicMock()
|
||||
session.post.return_value.__aenter__ = AsyncMock(return_value=response)
|
||||
session.post.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch('langbot.pkg.api.http.service.bot.httpclient.get_session', return_value=session),
|
||||
patch(
|
||||
'langbot.pkg.api.http.service.bot.httpclient.read_json_limited',
|
||||
new=AsyncMock(
|
||||
return_value={
|
||||
'code': 0,
|
||||
'data': {
|
||||
'session_id': 'wizard-session',
|
||||
'accepted_message_id': 'in-message',
|
||||
},
|
||||
}
|
||||
),
|
||||
),
|
||||
):
|
||||
result = await service.send_http_bot_test_message(
|
||||
WORKSPACE_UUID,
|
||||
'http-bot-uuid',
|
||||
'hello',
|
||||
)
|
||||
|
||||
assert result['accepted_message_id'] == 'in-message'
|
||||
request = session.post.call_args
|
||||
assert request.args[0] == 'http://127.0.0.1:5300/bots/http-bot-uuid'
|
||||
payload = json.loads(request.kwargs['data'])
|
||||
assert payload['message'] == [{'type': 'Plain', 'text': 'hello'}]
|
||||
headers = request.kwargs['headers']
|
||||
assert headers['X-LB-Timestamp']
|
||||
assert headers['X-LB-Signature'].startswith('sha256=')
|
||||
|
||||
async def test_rejects_non_http_bot(self):
|
||||
service = BotService(SimpleNamespace())
|
||||
service.get_bot = AsyncMock(
|
||||
return_value={
|
||||
'uuid': 'telegram-bot',
|
||||
'adapter': 'telegram',
|
||||
'adapter_config': {},
|
||||
'enable': True,
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match='only available for HTTP Bot'):
|
||||
await service.send_http_bot_test_message(
|
||||
WORKSPACE_UUID,
|
||||
'telegram-bot',
|
||||
'hello',
|
||||
)
|
||||
|
||||
|
||||
class TestBotServiceSendMessage:
|
||||
"""Tests for send_message method."""
|
||||
|
||||
|
||||
@@ -820,100 +820,6 @@ class TestSpaceServiceGetModels:
|
||||
await service.get_models()
|
||||
|
||||
|
||||
class TestSpaceServiceGetModelSelection:
|
||||
"""Tests for availability-ranked model selection."""
|
||||
|
||||
@pytest.mark.parametrize('response_shape', ['direct', 'models-envelope', 'availability-wrapper'])
|
||||
async def test_preserves_selection_order_and_category_query(self, response_shape):
|
||||
ap = SimpleNamespace(instance_config=SimpleNamespace(data={}))
|
||||
service = SpaceService(ap)
|
||||
models = [
|
||||
{
|
||||
'uuid': 'best-model',
|
||||
'model_id': 'best-chat-model',
|
||||
'provider': 'provider-1',
|
||||
'category': 'chat',
|
||||
'status': 'active',
|
||||
},
|
||||
{
|
||||
'uuid': 'fallback-model',
|
||||
'model_id': 'fallback-chat-model',
|
||||
'provider': 'provider-2',
|
||||
'category': 'chat',
|
||||
'status': 'active',
|
||||
},
|
||||
]
|
||||
if response_shape == 'models-envelope':
|
||||
data = {'models': models}
|
||||
elif response_shape == 'availability-wrapper':
|
||||
data = [
|
||||
{'model': model, 'latency_ms': index + 10, 'http_code': 200}
|
||||
for index, model in enumerate(models)
|
||||
]
|
||||
else:
|
||||
data = models
|
||||
payload = {'code': 0, 'data': data}
|
||||
mock_response = MagicMock(status=200)
|
||||
|
||||
with (
|
||||
patch('langbot.pkg.api.http.service.space.httpclient.get_session') as get_session,
|
||||
patch(
|
||||
'langbot.pkg.api.http.service.space.httpclient.read_json_limited',
|
||||
new=AsyncMock(return_value=payload),
|
||||
),
|
||||
):
|
||||
session = MagicMock()
|
||||
session.get.return_value.__aenter__ = AsyncMock(return_value=mock_response)
|
||||
session.get.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
get_session.return_value = session
|
||||
|
||||
result = await service.get_model_selection('chat')
|
||||
|
||||
assert [model.uuid for model in result] == ['best-model', 'fallback-model']
|
||||
session.get.assert_called_once_with(
|
||||
'https://space.langbot.app/api/v1/models/selection',
|
||||
params={'category': 'chat'},
|
||||
)
|
||||
|
||||
async def test_recommended_model_uses_first_selection_and_refreshes_once(self):
|
||||
local_model = SimpleNamespace(uuid='local-model-uuid', name='best-chat-model')
|
||||
persistence = SimpleNamespace(
|
||||
execute_async=AsyncMock(
|
||||
side_effect=[
|
||||
_create_mock_result(first_item=None),
|
||||
_create_mock_result(first_item=local_model),
|
||||
]
|
||||
)
|
||||
)
|
||||
model_mgr = SimpleNamespace(sync_new_models_from_space=AsyncMock())
|
||||
ap = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(data={}),
|
||||
persistence_mgr=persistence,
|
||||
model_mgr=model_mgr,
|
||||
)
|
||||
service = SpaceService(ap)
|
||||
service.get_model_selection = AsyncMock(
|
||||
return_value=[
|
||||
SimpleNamespace(uuid='best-upstream-uuid', model_id='best-chat-model'),
|
||||
SimpleNamespace(uuid='fallback-upstream-uuid', model_id='fallback-chat-model'),
|
||||
]
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
instance_uuid='instance',
|
||||
workspace_uuid='workspace',
|
||||
placement_generation=1,
|
||||
principal=SimpleNamespace(),
|
||||
entitlement_revision=0,
|
||||
)
|
||||
|
||||
result = await service.get_recommended_chat_model(context)
|
||||
|
||||
assert result == {'uuid': 'local-model-uuid', 'name': 'best-chat-model'}
|
||||
service.get_model_selection.assert_awaited_once_with('chat')
|
||||
model_mgr.sync_new_models_from_space.assert_awaited_once()
|
||||
assert persistence.execute_async.await_count == 2
|
||||
|
||||
|
||||
class TestSpaceServiceCreditsCache:
|
||||
"""Tests for credits cache behavior."""
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""BanWordFilter regression tests for legacy sensitive-word lists.
|
||||
|
||||
v4.10.7 introduced a 64-pattern cap in safe_regex. Older installs still carry
|
||||
the previous default list (~70 patterns). The filter must keep applying those
|
||||
rules instead of blocking every message.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.factories import FakeApp
|
||||
|
||||
|
||||
def _load_banwords():
|
||||
import_module('langbot.pkg.pipeline.pipelinemgr')
|
||||
banwords = import_module('langbot.pkg.pipeline.cntfilter.filters.banwords')
|
||||
entities = import_module('langbot.pkg.pipeline.cntfilter.entities')
|
||||
safe_regex = import_module('langbot.pkg.utils.safe_regex')
|
||||
return banwords, entities, safe_regex
|
||||
|
||||
|
||||
def _filter_with_words(words: list[str], *, mask: str = '*', mask_word: str = ''):
|
||||
banwords, entities, _ = _load_banwords()
|
||||
app = FakeApp()
|
||||
app.sensitive_meta = Mock()
|
||||
app.sensitive_meta.data = {
|
||||
'words': words,
|
||||
'mask': mask,
|
||||
'mask_word': mask_word,
|
||||
}
|
||||
return banwords.BanWordFilter(app), entities, app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_word_list_over_pattern_cap_does_not_block_clean_message():
|
||||
"""A pre-v4.10.7 word list must not fail closed on every message."""
|
||||
_, _, safe_regex = _load_banwords()
|
||||
words = [f'word{i}' for i in range(safe_regex.MAX_PATTERN_COUNT + 6)]
|
||||
filt, entities, _ = _filter_with_words(words)
|
||||
|
||||
result = await filt.process(Mock(), 'hello there, nothing banned')
|
||||
|
||||
assert result.level == entities.ResultLevel.PASS
|
||||
assert result.replacement == 'hello there, nothing banned'
|
||||
assert result.user_notice == ''
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_word_list_still_masks_match_beyond_first_batch():
|
||||
"""Words past the first 64-pattern batch must still be applied."""
|
||||
_, _, safe_regex = _load_banwords()
|
||||
words = [f'word{i}' for i in range(safe_regex.MAX_PATTERN_COUNT)] + ['secret-token']
|
||||
filt, entities, _ = _filter_with_words(words, mask_word='[hidden]')
|
||||
|
||||
result = await filt.process(Mock(), 'please hide secret-token now')
|
||||
|
||||
assert result.level == entities.ResultLevel.MASKED
|
||||
assert 'secret-token' not in result.replacement
|
||||
assert '[hidden]' in result.replacement
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_word_list_masks_match_in_first_batch():
|
||||
_, _, safe_regex = _load_banwords()
|
||||
words = ['alpha-secret'] + [f'word{i}' for i in range(safe_regex.MAX_PATTERN_COUNT)]
|
||||
filt, entities, _ = _filter_with_words(words, mask_word='[hidden]')
|
||||
|
||||
result = await filt.process(Mock(), 'alpha-secret is here')
|
||||
|
||||
assert result.level == entities.ResultLevel.MASKED
|
||||
assert result.replacement == '[hidden] is here'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_sensitive_word_regex_still_blocks():
|
||||
filt, entities, _ = _filter_with_words(['(unclosed'])
|
||||
|
||||
result = await filt.process(Mock(), 'any message')
|
||||
|
||||
assert result.level == entities.ResultLevel.BLOCK
|
||||
assert result.user_notice == '内容检查规则执行失败,请联系管理员'
|
||||
assert 'rejected' in result.console_notice.lower() or 'invalid' in result.console_notice.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oversized_word_list_is_blocked():
|
||||
"""Configured rules must never be silently skipped when the list is oversized."""
|
||||
banwords, _, _ = _load_banwords()
|
||||
words = [f'word{i}' for i in range(banwords._MAX_SENSITIVE_WORD_PATTERNS + 10)]
|
||||
filt, entities, _ = _filter_with_words(words)
|
||||
|
||||
result = await filt.process(Mock(), 'hello there, nothing banned')
|
||||
|
||||
assert result.level == entities.ResultLevel.BLOCK
|
||||
assert result.replacement == ''
|
||||
assert result.user_notice == '内容检查规则执行失败,请联系管理员'
|
||||
assert 'at most 256 regex patterns are allowed' in result.console_notice.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_match_beyond_total_cap_cannot_bypass_filter():
|
||||
banwords, _, _ = _load_banwords()
|
||||
words = [f'word{i}' for i in range(banwords._MAX_SENSITIVE_WORD_PATTERNS)] + ['late-secret']
|
||||
filt, entities, _ = _filter_with_words(words, mask_word='[hidden]')
|
||||
|
||||
result = await filt.process(Mock(), 'please hide late-secret now')
|
||||
|
||||
assert result.level == entities.ResultLevel.BLOCK
|
||||
assert result.replacement == ''
|
||||
@@ -3,18 +3,25 @@ from __future__ import annotations
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from linebot.v3.webhooks import TextMessageContent
|
||||
from linebot.v3.webhooks import TextMessageContent, UserMentionee, AllMentionee
|
||||
|
||||
from langbot.pkg.platform import botmgr as _botmgr # noqa: F401
|
||||
from langbot.pkg.platform.sources import line
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
|
||||
BOT_ACCOUNT_ID = 'line-bot-account'
|
||||
|
||||
|
||||
def _make_event(*, source_type: str, user_id, group_id=None, room_id=None, message_id: str, text: str = 'hi'):
|
||||
def _make_event(
|
||||
*, source_type: str, user_id, group_id=None, room_id=None, message_id: str, text: str = 'hi', mention=None
|
||||
):
|
||||
event = MagicMock()
|
||||
event.timestamp = 1700000000000
|
||||
event.message = MagicMock(spec=TextMessageContent)
|
||||
event.message.id = message_id
|
||||
event.message.text = text
|
||||
message = MagicMock(spec=TextMessageContent)
|
||||
message.id = message_id
|
||||
message.text = text
|
||||
message.mention = mention
|
||||
event.message = message
|
||||
event.message.webhook_event_id = f'webhook-{message_id}'
|
||||
event.message.timestamp = event.timestamp
|
||||
|
||||
@@ -30,16 +37,21 @@ def _make_event(*, source_type: str, user_id, group_id=None, room_id=None, messa
|
||||
return event
|
||||
|
||||
|
||||
def _make_converter(bot_account_id: str = BOT_ACCOUNT_ID) -> line.LINEEventConverter:
|
||||
return line.LINEEventConverter(bot_account_id=bot_account_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_message_launcher_id_stable_across_messages() -> None:
|
||||
"""Two distinct messages from the same LINE user must resolve to the same
|
||||
sender id, otherwise every message starts a brand new session (context loss).
|
||||
"""
|
||||
converter = _make_converter()
|
||||
event1 = _make_event(source_type='user', user_id='U-stable-user', message_id='msg-1')
|
||||
event2 = _make_event(source_type='user', user_id='U-stable-user', message_id='msg-2')
|
||||
|
||||
result1 = await line.LINEEventConverter.target2yiri(event1, bot_client=None)
|
||||
result2 = await line.LINEEventConverter.target2yiri(event2, bot_client=None)
|
||||
result1 = await converter.target2yiri(event1, bot_client=None)
|
||||
result2 = await converter.target2yiri(event2, bot_client=None)
|
||||
|
||||
assert result1.sender.id == 'U-stable-user'
|
||||
assert result1.sender.id == result2.sender.id
|
||||
@@ -48,11 +60,12 @@ async def test_user_message_launcher_id_stable_across_messages() -> None:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_message_uses_group_id_not_message_id() -> None:
|
||||
converter = _make_converter()
|
||||
event1 = _make_event(source_type='group', user_id='U-member', group_id='G-stable-group', message_id='msg-1')
|
||||
event2 = _make_event(source_type='group', user_id='U-member', group_id='G-stable-group', message_id='msg-2')
|
||||
|
||||
result1 = await line.LINEEventConverter.target2yiri(event1, bot_client=None)
|
||||
result2 = await line.LINEEventConverter.target2yiri(event2, bot_client=None)
|
||||
result1 = await converter.target2yiri(event1, bot_client=None)
|
||||
result2 = await converter.target2yiri(event2, bot_client=None)
|
||||
|
||||
assert result1.sender.group.id == 'G-stable-group'
|
||||
assert result1.sender.group.id == result2.sender.group.id
|
||||
@@ -61,9 +74,186 @@ async def test_group_message_uses_group_id_not_message_id() -> None:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_room_message_uses_room_id_and_falls_back_when_user_id_missing() -> None:
|
||||
converter = _make_converter()
|
||||
event = _make_event(source_type='room', user_id=None, room_id='R-stable-room', message_id='msg-1')
|
||||
|
||||
result = await line.LINEEventConverter.target2yiri(event, bot_client=None)
|
||||
result = await converter.target2yiri(event, bot_client=None)
|
||||
|
||||
assert result.sender.group.id == 'R-stable-room'
|
||||
assert result.sender.id == 'R-stable-room'
|
||||
|
||||
|
||||
def _plain_texts(chain: platform_message.MessageChain) -> list[str]:
|
||||
return [c.text for c in chain if isinstance(c, platform_message.Plain)]
|
||||
|
||||
|
||||
def _ats(chain: platform_message.MessageChain) -> list[platform_message.At]:
|
||||
return [c for c in chain if isinstance(c, platform_message.At)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_mention_keeps_plain_text() -> None:
|
||||
converter = _make_converter()
|
||||
event = _make_event(source_type='group', user_id='U-member', group_id='G1', message_id='m1', text='hello world')
|
||||
|
||||
chain = await converter.message_converter.target2yiri(event, bot_client=None)
|
||||
|
||||
assert _plain_texts(chain) == ['hello world']
|
||||
assert _ats(chain) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_mention_maps_to_at_with_bot_account_id() -> None:
|
||||
"""A @bot mention must become At(target=bot_account_id) so the 'at-bot'
|
||||
group respond rule matches (previously the mention was lost and the message
|
||||
was silently dropped in groups with at-only rules).
|
||||
"""
|
||||
mention = MagicMock()
|
||||
mention.mentionees = [
|
||||
UserMentionee(type='user', index=0, length=4, userId='U-bot-user-id', isSelf=True),
|
||||
]
|
||||
converter = _make_converter()
|
||||
event = _make_event(
|
||||
source_type='group',
|
||||
user_id='U-member',
|
||||
group_id='G1',
|
||||
message_id='m1',
|
||||
text='@BOT hey',
|
||||
mention=mention,
|
||||
)
|
||||
|
||||
chain = await converter.message_converter.target2yiri(event, bot_client=None)
|
||||
|
||||
ats = _ats(chain)
|
||||
assert len(ats) == 1
|
||||
assert ats[0].target == BOT_ACCOUNT_ID
|
||||
assert _plain_texts(chain) == [' hey']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_other_user_mention_keeps_display_text() -> None:
|
||||
"""Mentions of other users keep their display text in the message string,
|
||||
so prefix/regexp rules that match the raw '@Name ...' text still work.
|
||||
"""
|
||||
mention = MagicMock()
|
||||
mention.mentionees = [
|
||||
UserMentionee(type='user', index=0, length=6, userId='U-other', isSelf=False),
|
||||
]
|
||||
converter = _make_converter()
|
||||
event = _make_event(
|
||||
source_type='group',
|
||||
user_id='U-member',
|
||||
group_id='G1',
|
||||
message_id='m1',
|
||||
text='@Alice hello',
|
||||
mention=mention,
|
||||
)
|
||||
|
||||
chain = await converter.message_converter.target2yiri(event, bot_client=None)
|
||||
|
||||
ats = _ats(chain)
|
||||
assert len(ats) == 1
|
||||
assert ats[0].target == 'U-other'
|
||||
# str() of the At component falls back to display when set
|
||||
assert str(chain) == '@Alice hello'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_mention_triggers_atbot_rule() -> None:
|
||||
"""End-to-end: a group message that @mentions the bot must be accepted by
|
||||
the at-bot respond rule (this is the regression that silently dropped
|
||||
'@bot' messages in LINE groups).
|
||||
"""
|
||||
from langbot.pkg.pipeline.resprule.rules.atbot import AtBotRule
|
||||
|
||||
mention = MagicMock()
|
||||
mention.mentionees = [
|
||||
UserMentionee(type='user', index=0, length=6, userId='U-bot-user-id', isSelf=True),
|
||||
]
|
||||
converter = _make_converter()
|
||||
event = _make_event(
|
||||
source_type='group',
|
||||
user_id='U-member',
|
||||
group_id='G1',
|
||||
message_id='m1',
|
||||
text='@RAIQt hi',
|
||||
mention=mention,
|
||||
)
|
||||
|
||||
chain = await converter.message_converter.target2yiri(event, bot_client=None)
|
||||
|
||||
query = MagicMock()
|
||||
query.adapter = MagicMock()
|
||||
query.adapter.bot_account_id = BOT_ACCOUNT_ID
|
||||
|
||||
rule = AtBotRule(ap=MagicMock())
|
||||
result = await rule.match(str(chain), chain, {'at': True}, query)
|
||||
|
||||
assert result.matching is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_without_bot_mention_still_dropped_by_atbot_rule() -> None:
|
||||
from langbot.pkg.pipeline.resprule.rules.atbot import AtBotRule
|
||||
|
||||
converter = _make_converter()
|
||||
event = _make_event(source_type='group', user_id='U-member', group_id='G1', message_id='m1', text='hello')
|
||||
|
||||
chain = await converter.message_converter.target2yiri(event, bot_client=None)
|
||||
|
||||
query = MagicMock()
|
||||
query.adapter = MagicMock()
|
||||
query.adapter.bot_account_id = BOT_ACCOUNT_ID
|
||||
|
||||
rule = AtBotRule(ap=MagicMock())
|
||||
result = await rule.match(str(chain), chain, {'at': True}, query)
|
||||
|
||||
assert result.matching is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_at_all_mention_preserved_as_at_component() -> None:
|
||||
mention = MagicMock()
|
||||
mention.mentionees = [
|
||||
AllMentionee(type='all', index=0, length=4),
|
||||
]
|
||||
converter = _make_converter()
|
||||
event = _make_event(
|
||||
source_type='group',
|
||||
user_id='U-member',
|
||||
group_id='G1',
|
||||
message_id='m1',
|
||||
text='@All hello',
|
||||
mention=mention,
|
||||
)
|
||||
|
||||
chain = await converter.message_converter.target2yiri(event, bot_client=None)
|
||||
|
||||
ats = _ats(chain)
|
||||
assert len(ats) == 1
|
||||
assert str(chain) == '@All hello'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_mentions_sorted_by_position() -> None:
|
||||
mention = MagicMock()
|
||||
# Intentionally out of order to exercise sorting
|
||||
mention.mentionees = [
|
||||
UserMentionee(type='user', index=9, length=4, userId='U-b', isSelf=False),
|
||||
UserMentionee(type='user', index=0, length=4, userId='U-a', isSelf=False),
|
||||
]
|
||||
converter = _make_converter()
|
||||
event = _make_event(
|
||||
source_type='group',
|
||||
user_id='U-member',
|
||||
group_id='G1',
|
||||
message_id='m1',
|
||||
text='@aaa mid @bbb tail',
|
||||
mention=mention,
|
||||
)
|
||||
|
||||
chain = await converter.message_converter.target2yiri(event, bot_client=None)
|
||||
|
||||
ats = _ats(chain)
|
||||
assert [a.target for a in ats] == ['U-a', 'U-b']
|
||||
assert str(chain) == '@aaa mid @bbb tail'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
@@ -49,7 +50,29 @@ async def test_send_message_sends_text_to_customer_service_user():
|
||||
assert kwargs['open_kfid'] == 'kf-test'
|
||||
assert kwargs['external_userid'] == 'external-user'
|
||||
assert kwargs['content'] == 'hello'
|
||||
assert kwargs['msgid'].startswith('langbot_')
|
||||
assert len(kwargs['msgid'].encode()) <= 32
|
||||
assert uuid.UUID(hex=kwargs['msgid']).hex == kwargs['msgid']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_sends_image_to_customer_service_user():
|
||||
adapter = make_adapter()
|
||||
adapter.bot_account_id = 'kf-test'
|
||||
adapter.bot = SimpleNamespace(
|
||||
get_media_id=AsyncMock(return_value='media-id'),
|
||||
send_image_msg=AsyncMock(),
|
||||
)
|
||||
|
||||
message = platform_message.MessageChain([platform_message.Image(base64='aW1hZ2U=')])
|
||||
|
||||
await adapter.send_message('person', 'uexternal-user', message)
|
||||
|
||||
adapter.bot.send_image_msg.assert_awaited_once()
|
||||
kwargs = adapter.bot.send_image_msg.await_args.kwargs
|
||||
assert kwargs['open_kfid'] == 'kf-test'
|
||||
assert kwargs['external_userid'] == 'external-user'
|
||||
assert kwargs['media_id'] == 'media-id'
|
||||
assert len(kwargs['msgid'].encode()) <= 32
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from langbot.libs.wecom_customer_service_api.api import WecomCSClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_image_msg_posts_customer_service_image_payload() -> None:
|
||||
captured_request: httpx.Request | None = None
|
||||
|
||||
def handle_request(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal captured_request
|
||||
captured_request = request
|
||||
return httpx.Response(200, json={'errcode': 0})
|
||||
|
||||
client = WecomCSClient(
|
||||
corpid='corp-id',
|
||||
secret='secret',
|
||||
token='token',
|
||||
EncodingAESKey='encoding-key',
|
||||
logger=None,
|
||||
unified_mode=True,
|
||||
)
|
||||
client.access_token = 'access-token'
|
||||
client._http_client = httpx.AsyncClient(transport=httpx.MockTransport(handle_request))
|
||||
|
||||
try:
|
||||
await client.send_image_msg(
|
||||
open_kfid='kf-test',
|
||||
external_userid='external-user',
|
||||
msgid='a' * 32,
|
||||
media_id='media-id',
|
||||
)
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
assert captured_request is not None
|
||||
assert captured_request.url.path == '/cgi-bin/kf/send_msg'
|
||||
assert captured_request.url.params['access_token'] == 'access-token'
|
||||
assert captured_request.method == 'POST'
|
||||
assert captured_request.read().decode() == (
|
||||
'{"touser":"external-user","open_kfid":"kf-test","msgid":"'
|
||||
+ 'a' * 32
|
||||
+ '","msgtype":"image","image":{"media_id":"media-id"}}'
|
||||
)
|
||||
@@ -91,3 +91,42 @@ def test_convert_messages_plain_string_content_untouched():
|
||||
msg = provider_message.Message(role='user', content='just text')
|
||||
out = req._convert_messages([msg])
|
||||
assert out[0]['content'] == 'just text'
|
||||
|
||||
|
||||
def test_convert_messages_replayed_image_without_base64_does_not_crash():
|
||||
"""Replayed image parts hollowed out by history trimming must not raise KeyError (#2469).
|
||||
|
||||
SessionManager clears image_base64 on past turns, and URL-less platform
|
||||
images never had a URL, so the replayed part serializes as
|
||||
{'type': 'image_base64'} with no payload keys. The hollow part should be
|
||||
dropped while the sibling text part survives.
|
||||
"""
|
||||
req = _make_requester()
|
||||
image = provider_message.ContentElement.from_image_base64('data:image/jpeg;base64,AAAA')
|
||||
# Simulate SessionManager.trim_conversation_messages clearing binary payloads.
|
||||
image.image_base64 = None
|
||||
msg = provider_message.Message(
|
||||
role='user',
|
||||
content=[
|
||||
provider_message.ContentElement.from_text('describe the photo'),
|
||||
image,
|
||||
],
|
||||
)
|
||||
out = req._convert_messages([msg])
|
||||
assert [p.get('type') for p in out[0]['content']] == ['text']
|
||||
|
||||
|
||||
def test_convert_messages_replayed_image_with_url_falls_back_to_url():
|
||||
"""When base64 was trimmed but image_url survived, rebuild the OpenAI image_url part from the URL."""
|
||||
req = _make_requester()
|
||||
image = provider_message.ContentElement(
|
||||
type='image_base64',
|
||||
image_base64=None,
|
||||
image_url=provider_message.ImageURLContentObject(url='https://example.com/pic.jpg'),
|
||||
)
|
||||
msg = provider_message.Message(role='user', content=[image])
|
||||
out = req._convert_messages([msg])
|
||||
parts = out[0]['content']
|
||||
assert [p.get('type') for p in parts] == ['image_url']
|
||||
assert parts[0]['image_url'] == {'url': 'https://example.com/pic.jpg'}
|
||||
assert 'image_base64' not in parts[0]
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Regression tests for tool-message content serialization (#2457).
|
||||
|
||||
MCP tools return ``list[ContentElement]`` from ``execute_func_call``.
|
||||
The runner must serialize that list to a string before placing it in a
|
||||
``role='tool'`` message, because the OpenAI chat-completions spec
|
||||
requires tool-message content to be a string. Sending the raw list
|
||||
causes OpenAI-compatible endpoints to return HTTP 500.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext, PrincipalContext, PrincipalType
|
||||
from langbot.pkg.provider.runners.localagent import LocalAgentRunner
|
||||
|
||||
|
||||
class _ToolCallProvider:
|
||||
"""Non-streaming provider: round 1 issues a tool call, round 2 returns text."""
|
||||
|
||||
def __init__(self):
|
||||
self.requests: list[dict] = []
|
||||
|
||||
async def invoke_llm(self, query, model, messages, funcs, extra_args=None, remove_think=None):
|
||||
self.requests.append({'messages': list(messages)})
|
||||
|
||||
if len(self.requests) == 1:
|
||||
return provider_message.Message(
|
||||
role='assistant',
|
||||
content='Let me search that.',
|
||||
tool_calls=[
|
||||
provider_message.ToolCall(
|
||||
id='call-mcp-1',
|
||||
type='function',
|
||||
function=provider_message.FunctionCall(
|
||||
name='duckduckgo_search',
|
||||
arguments=json.dumps({'query': 'swift'}),
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
return provider_message.Message(role='assistant', content='Done.')
|
||||
|
||||
|
||||
class _ToolCallStreamProvider:
|
||||
"""Streaming variant of _ToolCallProvider."""
|
||||
|
||||
def __init__(self):
|
||||
self.requests: list[dict] = []
|
||||
|
||||
def invoke_llm_stream(self, query, model, messages, funcs, extra_args=None, remove_think=None):
|
||||
self.requests.append({'messages': list(messages)})
|
||||
|
||||
async def _stream():
|
||||
if len(self.requests) == 1:
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content='Let me search that.',
|
||||
tool_calls=[
|
||||
provider_message.ToolCall(
|
||||
id='call-mcp-1',
|
||||
type='function',
|
||||
function=provider_message.FunctionCall(
|
||||
name='duckduckgo_search',
|
||||
arguments=json.dumps({'query': 'swift'}),
|
||||
),
|
||||
)
|
||||
],
|
||||
is_final=True,
|
||||
)
|
||||
return
|
||||
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content='Done.',
|
||||
is_final=True,
|
||||
)
|
||||
|
||||
return _stream()
|
||||
|
||||
|
||||
def _make_query(stream: bool = False) -> pipeline_query.Query:
|
||||
adapter = AsyncMock()
|
||||
adapter.is_stream_output_supported = AsyncMock(return_value=stream)
|
||||
|
||||
query = pipeline_query.Query.model_construct(
|
||||
query_id='mcp-tool-query',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
sender_id=12345,
|
||||
message_chain=[],
|
||||
message_event=None,
|
||||
adapter=adapter,
|
||||
pipeline_uuid='pipeline-uuid',
|
||||
bot_uuid='bot-uuid',
|
||||
pipeline_config={
|
||||
'ai': {
|
||||
'runner': {'runner': 'local-agent'},
|
||||
'local-agent': {'model': {'primary': 'test-model-uuid', 'fallbacks': []}, 'prompt': 'test-prompt'},
|
||||
},
|
||||
'output': {'misc': {'remove-think': False}},
|
||||
},
|
||||
prompt=SimpleNamespace(messages=[]),
|
||||
messages=[],
|
||||
user_message=provider_message.Message(role='user', content='search swift'),
|
||||
use_funcs=[SimpleNamespace(name='duckduckgo_search')],
|
||||
use_llm_model_uuid='test-model-uuid',
|
||||
variables={},
|
||||
)
|
||||
object.__setattr__(
|
||||
query,
|
||||
'_execution_context',
|
||||
ExecutionContext(
|
||||
instance_uuid='instance-test',
|
||||
workspace_uuid='workspace-test',
|
||||
placement_generation=1,
|
||||
trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
|
||||
),
|
||||
)
|
||||
return query
|
||||
|
||||
|
||||
def _make_app(provider, func_ret) -> SimpleNamespace:
|
||||
"""Build a minimal app whose tool_mgr returns *func_ret*."""
|
||||
model = SimpleNamespace(
|
||||
provider=provider,
|
||||
model_entity=SimpleNamespace(
|
||||
uuid='test-model-uuid',
|
||||
name='test-model',
|
||||
abilities=['func_call'],
|
||||
extra_args={},
|
||||
),
|
||||
)
|
||||
return SimpleNamespace(
|
||||
logger=Mock(),
|
||||
model_mgr=SimpleNamespace(get_model_by_uuid=AsyncMock(return_value=model)),
|
||||
tool_mgr=SimpleNamespace(execute_func_call=AsyncMock(return_value=func_ret)),
|
||||
rag_mgr=SimpleNamespace(),
|
||||
box_service=SimpleNamespace(get_system_guidance=Mock(return_value='sandbox guidance')),
|
||||
skill_mgr=SimpleNamespace(
|
||||
get_skills_for_pipeline=AsyncMock(return_value=[]),
|
||||
detect_skill_activation=AsyncMock(return_value=None),
|
||||
build_activation_prompt=Mock(return_value=None),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# The actual shape returned by MCP tools: a list of ContentElement objects.
|
||||
_MCP_FUNC_RET = [
|
||||
provider_message.ContentElement.from_text('Title: Swift - Wikipedia\nURL: https://en.wikipedia.org/wiki/Swift'),
|
||||
provider_message.ContentElement.from_text('Title: Swift Programming Language\nURL: https://swift.org'),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_message_content_is_string_not_list():
|
||||
"""Non-streaming: tool message content must be a string (#2457).
|
||||
|
||||
Before the fix, ``func_ret`` (a ``list[ContentElement]``) was assigned
|
||||
to ``tool_content`` as-is, so the tool message carried a list instead
|
||||
of a string, causing OpenAI-compatible APIs to return 500.
|
||||
"""
|
||||
provider = _ToolCallProvider()
|
||||
app = _make_app(provider, _MCP_FUNC_RET)
|
||||
runner = LocalAgentRunner(app, pipeline_config={})
|
||||
query = _make_query(stream=False)
|
||||
|
||||
results = [msg async for msg in runner.run(query)]
|
||||
|
||||
tool_msgs = [m for m in results if m.role == 'tool']
|
||||
assert len(tool_msgs) == 1
|
||||
|
||||
# The content must be a string, not a list.
|
||||
assert isinstance(tool_msgs[0].content, str), (
|
||||
f'tool message content should be str, got {type(tool_msgs[0].content).__name__}'
|
||||
)
|
||||
# And it should contain the text of both ContentElements.
|
||||
assert 'Swift - Wikipedia' in tool_msgs[0].content
|
||||
assert 'Swift Programming Language' in tool_msgs[0].content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_message_content_is_string_in_stream():
|
||||
"""Streaming: same regression check for the streaming path (#2457)."""
|
||||
provider = _ToolCallStreamProvider()
|
||||
app = _make_app(provider, _MCP_FUNC_RET)
|
||||
runner = LocalAgentRunner(app, pipeline_config={})
|
||||
query = _make_query(stream=True)
|
||||
|
||||
results = [msg async for msg in runner.run(query)]
|
||||
|
||||
tool_msgs = [m for m in results if m.role == 'tool']
|
||||
assert len(tool_msgs) == 1
|
||||
|
||||
assert isinstance(tool_msgs[0].content, str), (
|
||||
f'tool message content should be str, got {type(tool_msgs[0].content).__name__}'
|
||||
)
|
||||
assert 'Swift - Wikipedia' in tool_msgs[0].content
|
||||
assert 'Swift Programming Language' in tool_msgs[0].content
|
||||
@@ -53,6 +53,45 @@ async def test_matches_any_rejects_pattern_and_input_amplification():
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mask_patterns_honors_explicit_pattern_count_cap():
|
||||
patterns = ['a'] * (safe_regex.MAX_PATTERN_COUNT + 6)
|
||||
found, masked = await safe_regex.mask_patterns(
|
||||
patterns,
|
||||
'hello',
|
||||
mask='*',
|
||||
mask_word='',
|
||||
max_pattern_count=len(patterns),
|
||||
)
|
||||
assert found is False
|
||||
assert masked == 'hello'
|
||||
|
||||
with pytest.raises(safe_regex.SafeRegexLimitError):
|
||||
await safe_regex.mask_patterns(
|
||||
patterns,
|
||||
'hello',
|
||||
mask='*',
|
||||
mask_word='',
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mask_patterns_rejects_oversized_sequence_before_copying_it():
|
||||
class OversizedPatterns(list):
|
||||
def __iter__(self):
|
||||
raise AssertionError('oversized patterns must not be materialized')
|
||||
|
||||
patterns = OversizedPatterns(['a'] * (safe_regex.MAX_PATTERN_COUNT + 1))
|
||||
|
||||
with pytest.raises(safe_regex.SafeRegexLimitError):
|
||||
await safe_regex.mask_patterns(
|
||||
patterns,
|
||||
'hello',
|
||||
mask='*',
|
||||
mask_word='',
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mask_patterns_bounds_replacement_growth_and_masks_matches():
|
||||
found, masked = await safe_regex.mask_patterns(
|
||||
|
||||
+1
-5
@@ -1,5 +1 @@
|
||||
# Leave empty in development to use Vite's same-origin proxy. This keeps API,
|
||||
# login, and WebSocket requests working when the UI is opened from another
|
||||
# device on the local network.
|
||||
VITE_API_BASE_URL=
|
||||
VITE_API_PROXY_TARGET=http://127.0.0.1:5300
|
||||
VITE_API_BASE_URL=http://localhost:5300
|
||||
|
||||
@@ -20,7 +20,6 @@ export function BotLogListComponent({
|
||||
autoExpandImages = false,
|
||||
hideDetailedLogsLink = false,
|
||||
hideToolbar = false,
|
||||
onMessageReceived,
|
||||
}: {
|
||||
botId: string;
|
||||
/** When true, log entries with images are rendered expanded by default */
|
||||
@@ -29,8 +28,6 @@ export function BotLogListComponent({
|
||||
hideDetailedLogsLink?: boolean;
|
||||
/** When true, hides the entire toolbar (auto-refresh, level filter, detailed logs link) */
|
||||
hideToolbar?: boolean;
|
||||
/** Called after an inbound person/group message appears in the bot log. */
|
||||
onMessageReceived?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -44,8 +41,6 @@ export function BotLogListComponent({
|
||||
]);
|
||||
const listContainerRef = useRef<HTMLDivElement>(null);
|
||||
const botLogListRef = useRef<BotLog[]>(botLogList);
|
||||
const onMessageReceivedRef = useRef(onMessageReceived);
|
||||
onMessageReceivedRef.current = onMessageReceived;
|
||||
|
||||
const logLevels = [
|
||||
{ value: 'error', label: 'ERROR' },
|
||||
@@ -113,9 +108,6 @@ export function BotLogListComponent({
|
||||
manager.subscribeLogPush(handleBotLogPush);
|
||||
manager.loadFirstPage().then((response) => {
|
||||
setBotLogList(response.reverse());
|
||||
if (response.some((log) => Boolean(log.message_session_id))) {
|
||||
onMessageReceivedRef.current?.();
|
||||
}
|
||||
});
|
||||
listenScroll();
|
||||
}
|
||||
@@ -146,9 +138,6 @@ export function BotLogListComponent({
|
||||
|
||||
function handleBotLogPush(response: BotLog[]) {
|
||||
setBotLogList(response.reverse());
|
||||
if (response.some((log) => Boolean(log.message_session_id))) {
|
||||
onMessageReceivedRef.current?.();
|
||||
}
|
||||
}
|
||||
|
||||
const handleScroll = useCallback(
|
||||
|
||||
@@ -33,7 +33,7 @@ const getFormSchema = (t: (key: string) => string) =>
|
||||
|
||||
interface ProviderFormProps {
|
||||
providerId?: string;
|
||||
onFormSubmit: (providerUuid: string) => void | Promise<void>;
|
||||
onFormSubmit: () => void;
|
||||
onFormCancel: () => void;
|
||||
}
|
||||
|
||||
@@ -171,16 +171,14 @@ export default function ProviderForm({
|
||||
};
|
||||
|
||||
try {
|
||||
let savedProviderUuid = providerId;
|
||||
if (providerId) {
|
||||
await httpClient.updateModelProvider(providerId, data);
|
||||
toast.success(t('models.providerSaved'));
|
||||
} else {
|
||||
const response = await httpClient.createModelProvider(data);
|
||||
savedProviderUuid = response.uuid;
|
||||
await httpClient.createModelProvider(data);
|
||||
toast.success(t('models.providerCreated'));
|
||||
}
|
||||
await onFormSubmit(savedProviderUuid as string);
|
||||
onFormSubmit();
|
||||
} catch (err) {
|
||||
toast.error(t('models.providerSaveError') + (err as CustomApiError).msg);
|
||||
}
|
||||
|
||||
@@ -363,9 +363,7 @@ export interface WizardProgress {
|
||||
step: number;
|
||||
selected_adapter: string | null;
|
||||
created_bot_uuid: string | null;
|
||||
created_pipeline_uuid?: string | null;
|
||||
bot_saved: boolean;
|
||||
message_received?: boolean;
|
||||
selected_runner: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -150,9 +150,7 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.get(`/api/v1/provider/models/llm/${uuid}`);
|
||||
}
|
||||
|
||||
public createProviderLLMModel(
|
||||
model: Omit<LLMModel, 'uuid'>,
|
||||
): Promise<{ uuid: string }> {
|
||||
public createProviderLLMModel(model: LLMModel): Promise<object> {
|
||||
return this.post('/api/v1/provider/models/llm', model);
|
||||
}
|
||||
|
||||
@@ -463,15 +461,6 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.post(`/api/v1/platform/bots/${botId}/logs`, request);
|
||||
}
|
||||
|
||||
public testHttpBotInbound(
|
||||
botId: string,
|
||||
message: string,
|
||||
): Promise<{ session_id: string; accepted_message_id: string }> {
|
||||
return this.post(`/api/v1/platform/bots/${botId}/test-inbound`, {
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
public getBotSessions(
|
||||
botId: string,
|
||||
limit: number = 100,
|
||||
@@ -1079,21 +1068,12 @@ export class BackendClient extends BaseHttpClient {
|
||||
step: number;
|
||||
selected_adapter: string | null;
|
||||
created_bot_uuid: string | null;
|
||||
created_pipeline_uuid?: string | null;
|
||||
bot_saved: boolean;
|
||||
message_received?: boolean;
|
||||
selected_runner: string | null;
|
||||
}): Promise<void> {
|
||||
return this.put('/api/v1/system/wizard/progress', progress);
|
||||
}
|
||||
|
||||
public getWizardRecommendedModel(): Promise<{
|
||||
uuid: string;
|
||||
name: string;
|
||||
}> {
|
||||
return this.get('/api/v1/system/wizard/recommended-model');
|
||||
}
|
||||
|
||||
public getAsyncTasks(params?: {
|
||||
type?: string;
|
||||
kind?: string;
|
||||
|
||||
@@ -1,409 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
Eye,
|
||||
Loader2,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ProviderForm from '@/app/home/components/models-dialog/component/provider-form/ProviderForm';
|
||||
import type { ScannedProviderModel } from '@/app/infra/entities/api';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type ModelSetupMode = 'scan' | 'manual';
|
||||
type ScanFallbackReason = 'failed' | 'empty' | null;
|
||||
|
||||
export interface OwnModelSelection {
|
||||
source: ModelSetupMode;
|
||||
providerUuid: string;
|
||||
model: ScannedProviderModel;
|
||||
}
|
||||
|
||||
interface OwnModelSetupProps {
|
||||
onBack: () => void;
|
||||
onSelectionChange: (selection: OwnModelSelection | null) => void;
|
||||
}
|
||||
|
||||
export default function OwnModelSetup({
|
||||
onBack,
|
||||
onSelectionChange,
|
||||
}: OwnModelSetupProps) {
|
||||
const { t } = useTranslation();
|
||||
const [providerUuid, setProviderUuid] = useState<string | null>(null);
|
||||
const [showProviderForm, setShowProviderForm] = useState(true);
|
||||
const [mode, setMode] = useState<ModelSetupMode>('scan');
|
||||
const [models, setModels] = useState<ScannedProviderModel[]>([]);
|
||||
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [scanFallbackReason, setScanFallbackReason] =
|
||||
useState<ScanFallbackReason>(null);
|
||||
const [manualModelName, setManualModelName] = useState('');
|
||||
const [manualContextLength, setManualContextLength] = useState('');
|
||||
const [manualVision, setManualVision] = useState(false);
|
||||
const [manualFunctionCall, setManualFunctionCall] = useState(false);
|
||||
|
||||
const parsedManualContextLength = useMemo(() => {
|
||||
if (!manualContextLength.trim()) return null;
|
||||
const value = Number(manualContextLength);
|
||||
return Number.isInteger(value) && value > 0 ? value : undefined;
|
||||
}, [manualContextLength]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'manual' || !providerUuid) return;
|
||||
if (!manualModelName.trim() || parsedManualContextLength === undefined) {
|
||||
onSelectionChange(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const abilities = [
|
||||
...(manualVision ? ['vision'] : []),
|
||||
...(manualFunctionCall ? ['func_call'] : []),
|
||||
];
|
||||
const modelName = manualModelName.trim();
|
||||
onSelectionChange({
|
||||
source: 'manual',
|
||||
providerUuid,
|
||||
model: {
|
||||
id: modelName,
|
||||
name: modelName,
|
||||
type: 'llm',
|
||||
abilities,
|
||||
context_length: parsedManualContextLength,
|
||||
already_added: false,
|
||||
},
|
||||
});
|
||||
}, [
|
||||
manualFunctionCall,
|
||||
manualModelName,
|
||||
manualVision,
|
||||
mode,
|
||||
onSelectionChange,
|
||||
parsedManualContextLength,
|
||||
providerUuid,
|
||||
]);
|
||||
|
||||
const scanModels = useCallback(
|
||||
async (uuid: string) => {
|
||||
setMode('scan');
|
||||
setIsScanning(true);
|
||||
setScanFallbackReason(null);
|
||||
setModels([]);
|
||||
setSelectedModelId(null);
|
||||
onSelectionChange(null);
|
||||
|
||||
try {
|
||||
const response = await httpClient.scanProviderModels(uuid, 'llm');
|
||||
const availableModels = response.models.filter(
|
||||
(model) => model.type === 'llm' && !model.already_added,
|
||||
);
|
||||
setModels(availableModels);
|
||||
if (availableModels.length === 0) {
|
||||
setScanFallbackReason('empty');
|
||||
setMode('manual');
|
||||
}
|
||||
} catch {
|
||||
setScanFallbackReason('failed');
|
||||
setMode('manual');
|
||||
} finally {
|
||||
setIsScanning(false);
|
||||
}
|
||||
},
|
||||
[onSelectionChange],
|
||||
);
|
||||
|
||||
const handleProviderSaved = useCallback(
|
||||
async (uuid: string) => {
|
||||
setProviderUuid(uuid);
|
||||
setShowProviderForm(false);
|
||||
await scanModels(uuid);
|
||||
},
|
||||
[scanModels],
|
||||
);
|
||||
|
||||
const handleSelectModel = useCallback(
|
||||
(model: ScannedProviderModel) => {
|
||||
if (!providerUuid) return;
|
||||
setSelectedModelId(model.id);
|
||||
onSelectionChange({ source: 'scan', providerUuid, model });
|
||||
},
|
||||
[onSelectionChange, providerUuid],
|
||||
);
|
||||
|
||||
const handleModeChange = useCallback(
|
||||
(value: string) => {
|
||||
setMode(value as ModelSetupMode);
|
||||
setSelectedModelId(null);
|
||||
onSelectionChange(null);
|
||||
},
|
||||
[onSelectionChange],
|
||||
);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
onSelectionChange(null);
|
||||
onBack();
|
||||
}, [onBack, onSelectionChange]);
|
||||
|
||||
const handleEditProvider = useCallback(() => {
|
||||
setSelectedModelId(null);
|
||||
onSelectionChange(null);
|
||||
setShowProviderForm(true);
|
||||
}, [onSelectionChange]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-3xl space-y-5">
|
||||
<div>
|
||||
<Button variant="ghost" size="sm" onClick={handleBack}>
|
||||
<ArrowLeft className="mr-1.5 size-4" />
|
||||
{t('wizard.aiEngine.backToChoices')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold">
|
||||
{t('wizard.aiEngine.ownModelSetupTitle')}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t('wizard.aiEngine.ownModelSetupDescription')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showProviderForm ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{t('wizard.aiEngine.addProviderTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('wizard.aiEngine.addProviderDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ProviderForm
|
||||
providerId={providerUuid ?? undefined}
|
||||
onFormSubmit={handleProviderSaved}
|
||||
onFormCancel={() =>
|
||||
providerUuid ? setShowProviderForm(false) : handleBack()
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 border-b pb-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">
|
||||
{t('wizard.aiEngine.selectModelTitle')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('wizard.aiEngine.selectScannedModelDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
title={t('wizard.aiEngine.editProvider')}
|
||||
onClick={handleEditProvider}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs value={mode} onValueChange={handleModeChange}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="scan">
|
||||
{t('wizard.aiEngine.scanModelMode')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="manual">
|
||||
{t('wizard.aiEngine.manualModelMode')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="scan" className="mt-4">
|
||||
{isScanning ? (
|
||||
<div className="flex min-h-48 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t('wizard.aiEngine.scanningModels')}
|
||||
</div>
|
||||
) : models.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{models.map((model) => {
|
||||
const selected = selectedModelId === model.id;
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex min-h-20 items-center gap-3 rounded-md border p-3 text-left transition-colors hover:border-primary/60 hover:bg-accent/40',
|
||||
selected &&
|
||||
'border-primary bg-accent ring-1 ring-primary',
|
||||
)}
|
||||
onClick={() => handleSelectModel(model)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-5 shrink-0 items-center justify-center rounded-full border',
|
||||
selected &&
|
||||
'border-primary bg-primary text-primary-foreground',
|
||||
)}
|
||||
>
|
||||
{selected && <Check className="size-3" />}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{model.display_name || model.name}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{model.name}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isScanning || !providerUuid}
|
||||
onClick={() => providerUuid && scanModels(providerUuid)}
|
||||
>
|
||||
<RefreshCw className="mr-1.5 size-4" />
|
||||
{t('wizard.aiEngine.rescanModels')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-48 flex-col items-center justify-center gap-3 border border-dashed p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
scanFallbackReason === 'failed'
|
||||
? 'wizard.aiEngine.scanModelsFailed'
|
||||
: 'wizard.aiEngine.noScannedModels',
|
||||
)}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!providerUuid}
|
||||
onClick={() => providerUuid && scanModels(providerUuid)}
|
||||
>
|
||||
<RefreshCw className="mr-1.5 size-4" />
|
||||
{t('wizard.aiEngine.rescanModels')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="manual" className="mt-4 space-y-5">
|
||||
{scanFallbackReason && (
|
||||
<div className="border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
{t(
|
||||
scanFallbackReason === 'failed'
|
||||
? 'wizard.aiEngine.manualFallbackFailed'
|
||||
: 'wizard.aiEngine.manualFallbackEmpty',
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="wizard-manual-model-name">
|
||||
{t('wizard.aiEngine.manualModelId')}
|
||||
<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="wizard-manual-model-name"
|
||||
value={manualModelName}
|
||||
onChange={(event) => setManualModelName(event.target.value)}
|
||||
placeholder={t('wizard.aiEngine.manualModelIdPlaceholder')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('wizard.aiEngine.manualModelIdDescription')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 border-t pt-4">
|
||||
<p className="text-sm font-medium">
|
||||
{t('wizard.aiEngine.manualModelOptions')}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="wizard-manual-context-length">
|
||||
{t('models.contextLength')}
|
||||
</Label>
|
||||
<Input
|
||||
id="wizard-manual-context-length"
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={manualContextLength}
|
||||
onChange={(event) =>
|
||||
setManualContextLength(event.target.value)
|
||||
}
|
||||
placeholder={t('models.contextLengthPlaceholder')}
|
||||
/>
|
||||
{parsedManualContextLength === undefined && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t('models.contextLengthInvalid')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="wizard-manual-vision"
|
||||
checked={manualVision}
|
||||
onCheckedChange={(checked) =>
|
||||
setManualVision(checked === true)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="wizard-manual-vision"
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
{t('models.visionAbility')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="wizard-manual-function-call"
|
||||
checked={manualFunctionCall}
|
||||
onCheckedChange={(checked) =>
|
||||
setManualFunctionCall(checked === true)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="wizard-manual-function-call"
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<Wrench className="size-4" />
|
||||
{t('models.functionCallAbility')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+174
-610
File diff suppressed because it is too large
Load Diff
@@ -1,104 +0,0 @@
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
|
||||
if (typeof error === 'object' && error !== null && 'msg' in error) {
|
||||
const message = (error as { msg?: unknown }).msg;
|
||||
if (typeof message === 'string') return message;
|
||||
}
|
||||
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function createSigningSecret(): string {
|
||||
const bytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
export function ensureHttpBotSigningSecret(
|
||||
adapterName: string,
|
||||
config: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
if (
|
||||
adapterName !== 'http_bot' ||
|
||||
config.signature_required === false ||
|
||||
(typeof config.inbound_secret === 'string' && config.inbound_secret)
|
||||
) {
|
||||
return config;
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
inbound_secret: createSigningSecret(),
|
||||
};
|
||||
}
|
||||
|
||||
export function findDefaultPipeline<
|
||||
T extends { uuid?: string; is_default?: boolean },
|
||||
>(pipelines: T[]): T | undefined {
|
||||
return pipelines.find(
|
||||
(pipeline) =>
|
||||
pipeline.is_default === true &&
|
||||
typeof pipeline.uuid === 'string' &&
|
||||
pipeline.uuid.length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
interface WebhookConfigItem {
|
||||
name: string;
|
||||
show_if?: {
|
||||
field: string;
|
||||
operator: 'eq' | 'neq' | 'in';
|
||||
value: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export function isWebhookModeEnabled(
|
||||
configItems: WebhookConfigItem[],
|
||||
configValues: Record<string, unknown>,
|
||||
): boolean {
|
||||
const webhookField = configItems.find((item) => item.name === 'webhook_url');
|
||||
if (!webhookField) return false;
|
||||
if (!webhookField.show_if) return true;
|
||||
|
||||
const condition = webhookField.show_if;
|
||||
const actualValue = configValues[condition.field];
|
||||
if (condition.operator === 'eq') return actualValue === condition.value;
|
||||
if (condition.operator === 'neq') return actualValue !== condition.value;
|
||||
return (
|
||||
Array.isArray(condition.value) && condition.value.includes(actualValue)
|
||||
);
|
||||
}
|
||||
|
||||
export function configureLocalAgentPrimaryModel(
|
||||
config: Record<string, unknown>,
|
||||
modelUuid: string,
|
||||
): Record<string, unknown> {
|
||||
const aiConfig = (config.ai ?? {}) as Record<string, unknown>;
|
||||
const runnerConfig = (aiConfig.runner ?? {}) as Record<string, unknown>;
|
||||
const localAgentConfig = (aiConfig['local-agent'] ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const modelConfig = (localAgentConfig.model ?? {}) as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
...config,
|
||||
ai: {
|
||||
...aiConfig,
|
||||
runner: { ...runnerConfig, runner: 'local-agent' },
|
||||
'local-agent': {
|
||||
...localAgentConfig,
|
||||
model: {
|
||||
...modelConfig,
|
||||
primary: modelUuid,
|
||||
fallbacks: Array.isArray(modelConfig.fallbacks)
|
||||
? modelConfig.fallbacks
|
||||
: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1827,25 +1827,6 @@ const enUS = {
|
||||
resaveBot: 'Re-save Configuration',
|
||||
botSaved:
|
||||
'Bot configuration saved and enabled. Check the logs to verify the connection.',
|
||||
waitingForMessage:
|
||||
'The bot is enabled. Send it a message from your IM platform to continue.',
|
||||
messageReceived:
|
||||
'The bot received an IM message. You can continue to the next step.',
|
||||
pageBotTestPrompt:
|
||||
'Page Bot is enabled. Click the chat bubble in the lower-right corner and send a message to verify the full conversation flow.',
|
||||
pageBotTestNotice:
|
||||
'For testing only. Embed the code on a real external webpage.',
|
||||
webhookTestPrompt:
|
||||
'The callback URL is ready. Configure it on the external platform, then send the bot a real message.',
|
||||
httpTestPrompt:
|
||||
'HTTP Bot is enabled. Send a real inbound message here to verify the connection.',
|
||||
httpTestDefaultMessage: 'Hello, this is a connection test message.',
|
||||
sendHttpTest: 'Send Test Message',
|
||||
httpTestAccepted:
|
||||
'The test message was accepted. It will appear in the log shortly.',
|
||||
httpTestMissingSecret:
|
||||
'Enter an inbound signing secret and save the configuration first.',
|
||||
httpTestFailed: 'Failed to send the test message: {{error}}',
|
||||
logsTitle: 'Bot Logs',
|
||||
logsDescription:
|
||||
'Monitor bot activity to verify the platform connection is working.',
|
||||
@@ -1854,51 +1835,6 @@ const enUS = {
|
||||
title: 'Select an AI Engine',
|
||||
description:
|
||||
"Choose the AI engine that will power your bot's intelligence.",
|
||||
optionalDescription:
|
||||
'This step is optional. Choose how you want to continue with the current agent.',
|
||||
externalTitle: 'Connect an External Agent',
|
||||
externalDescription:
|
||||
'Connect Dify, n8n, Coze, or another platform and replace the bot pipeline.',
|
||||
ownModelTitle: 'Use My Own Model',
|
||||
ownModelDescription:
|
||||
'Add a provider, then scan or manually enter a model to finish setup.',
|
||||
ownModelSetupTitle: 'Add Your Own Model',
|
||||
ownModelSetupDescription:
|
||||
'Add a model provider. Chat models are scanned automatically, or you can enter a model ID manually.',
|
||||
addProviderTitle: 'Add Provider',
|
||||
addProviderDescription:
|
||||
'Enter the provider details and API key used to connect and scan models.',
|
||||
selectModelTitle: 'Choose a Model',
|
||||
selectScannedModelTitle: 'Choose a Model',
|
||||
selectScannedModelDescription:
|
||||
'The selected model will be the primary model of a new pipeline, and the bot will switch to it.',
|
||||
scanModelMode: 'Scan Models',
|
||||
manualModelMode: 'Add Manually',
|
||||
scanningModels: 'Scanning available models…',
|
||||
noScannedModels:
|
||||
'No available chat models were found. Check the provider configuration.',
|
||||
scanModelsFailed:
|
||||
'Model scanning failed. Check the URL and API key, then try again.',
|
||||
manualFallbackFailed:
|
||||
'Automatic scanning failed. Enter a model ID supported by the provider.',
|
||||
manualFallbackEmpty:
|
||||
'No models were found. Enter a model ID supported by the provider.',
|
||||
manualModelId: 'Model ID',
|
||||
manualModelIdPlaceholder: 'For example: gpt-4o',
|
||||
manualModelIdDescription:
|
||||
'Enter the model parameter used in model requests.',
|
||||
manualModelOptions: 'Optional Model Capabilities',
|
||||
editProvider: 'Edit provider',
|
||||
rescanModels: 'Scan models again',
|
||||
moreFeaturesTitle: 'Add More Agent Features',
|
||||
moreFeaturesDescription:
|
||||
'Open the workbench to add tools, knowledge, and other capabilities.',
|
||||
runnerDescription:
|
||||
'Select a runner for the external agent and configure its connection.',
|
||||
backToChoices: 'Back to options',
|
||||
createExternal: 'Create and Bind',
|
||||
finishWithModel: 'Use Selected Model & Finish',
|
||||
openWorkbench: 'Open Workbench',
|
||||
},
|
||||
config: {
|
||||
botInfo: 'Bot Information',
|
||||
|
||||
@@ -1744,25 +1744,6 @@ const jaJP = {
|
||||
resaveBot: '設定を再保存',
|
||||
botSaved:
|
||||
'ボット設定が保存され、有効になりました。ログを確認して接続を検証してください。',
|
||||
waitingForMessage:
|
||||
'ボットが有効になりました。続行するには IM からメッセージを送信してください。',
|
||||
messageReceived:
|
||||
'ボットが IM メッセージを受信しました。次のステップに進めます。',
|
||||
pageBotTestPrompt:
|
||||
'ページボットが有効になりました。右下のチャットバブルをクリックしてメッセージを送信し、会話フロー全体を確認してください。',
|
||||
pageBotTestNotice:
|
||||
'テスト専用です。実際の外部 Web ページにコードを埋め込んでください。',
|
||||
webhookTestPrompt:
|
||||
'コールバック URL の準備ができました。外部プラットフォームに設定し、ボットへ実際のメッセージを送信してください。',
|
||||
httpTestPrompt:
|
||||
'HTTP Bot が有効になりました。実際の受信メッセージを送信して接続を確認できます。',
|
||||
httpTestDefaultMessage: 'こんにちは。これは接続テストメッセージです。',
|
||||
sendHttpTest: 'テストメッセージを送信',
|
||||
httpTestAccepted:
|
||||
'テストメッセージを受け付けました。まもなくログに表示されます。',
|
||||
httpTestMissingSecret:
|
||||
'受信署名シークレットを入力し、先に設定を保存してください。',
|
||||
httpTestFailed: 'テストメッセージの送信に失敗しました:{{error}}',
|
||||
logsTitle: 'ボットログ',
|
||||
logsDescription:
|
||||
'ボットの活動を監視して、プラットフォーム接続が正常に動作していることを確認します。',
|
||||
@@ -1771,50 +1752,6 @@ const jaJP = {
|
||||
title: 'AIエンジンを選択',
|
||||
description:
|
||||
'ボットのインテリジェンスを駆動するAIエンジンを選択してください。',
|
||||
optionalDescription:
|
||||
'このステップは任意です。現在の Agent をどのように設定するか選択してください。',
|
||||
externalTitle: '外部プラットフォームの Agent を接続',
|
||||
externalDescription:
|
||||
'Dify、n8n、Coze などを接続し、ボットのパイプラインを置き換えます。',
|
||||
ownModelTitle: '自分のモデルを使用',
|
||||
ownModelDescription:
|
||||
'プロバイダーを追加し、モデルをスキャンまたは手動入力して設定を完了します。',
|
||||
ownModelSetupTitle: '自分のモデルを追加',
|
||||
ownModelSetupDescription:
|
||||
'モデルプロバイダーを追加すると自動スキャンされます。モデル ID の手動入力も可能です。',
|
||||
addProviderTitle: 'プロバイダーを追加',
|
||||
addProviderDescription:
|
||||
'接続とモデルスキャンに使用するプロバイダー情報と API キーを入力します。',
|
||||
selectModelTitle: 'モデルを選択',
|
||||
selectScannedModelTitle: 'モデルを選択',
|
||||
selectScannedModelDescription:
|
||||
'選択したモデルを新しいパイプラインのメインモデルに設定し、ボットをそのパイプラインへ切り替えます。',
|
||||
scanModelMode: 'モデルをスキャン',
|
||||
manualModelMode: '手動で追加',
|
||||
scanningModels: '利用可能なモデルをスキャン中…',
|
||||
noScannedModels:
|
||||
'利用可能なチャットモデルが見つかりません。プロバイダー設定を確認してください。',
|
||||
scanModelsFailed:
|
||||
'モデルのスキャンに失敗しました。URL と API キーを確認して再試行してください。',
|
||||
manualFallbackFailed:
|
||||
'自動スキャンに失敗しました。プロバイダーが対応するモデル ID を直接入力できます。',
|
||||
manualFallbackEmpty:
|
||||
'モデルが見つかりませんでした。プロバイダーが対応するモデル ID を直接入力できます。',
|
||||
manualModelId: 'モデル ID',
|
||||
manualModelIdPlaceholder: '例:gpt-4o',
|
||||
manualModelIdDescription:
|
||||
'モデルリクエストで実際に使用する model パラメーターを入力します。',
|
||||
manualModelOptions: '任意のモデル機能',
|
||||
editProvider: 'プロバイダーを編集',
|
||||
rescanModels: 'モデルを再スキャン',
|
||||
moreFeaturesTitle: 'Agent に機能を追加',
|
||||
moreFeaturesDescription:
|
||||
'ワークベンチを開き、ツールやナレッジなどの機能を追加します。',
|
||||
runnerDescription: '外部 Agent の Runner を選択し、接続を設定します。',
|
||||
backToChoices: '選択肢に戻る',
|
||||
createExternal: '作成して関連付ける',
|
||||
finishWithModel: '選択したモデルを使用して完了',
|
||||
openWorkbench: 'ワークベンチを開く',
|
||||
},
|
||||
config: {
|
||||
botInfo: 'ボット情報',
|
||||
|
||||
@@ -1749,62 +1749,12 @@ const zhHans = {
|
||||
saveBot: '保存并启用',
|
||||
resaveBot: '重新保存配置',
|
||||
botSaved: '机器人配置已保存并启用,请查看日志确认连接正常。',
|
||||
waitingForMessage: '机器人已启用。请在 IM 中向机器人发送一条消息以继续。',
|
||||
messageReceived: '机器人已成功收到 IM 消息,可以进入下一步。',
|
||||
pageBotTestPrompt:
|
||||
'页面机器人已启用。点击右下角聊天气泡并发送一条消息,验证完整对话链路。',
|
||||
pageBotTestNotice: '仅供测试使用,请嵌入代码到真实外部网页。',
|
||||
webhookTestPrompt:
|
||||
'回调地址已就绪。将它配置到外部平台,然后向机器人发送一条真实消息。',
|
||||
httpTestPrompt: 'HTTP Bot 已启用。可直接发送一条真实入站消息验证连接。',
|
||||
httpTestDefaultMessage: '你好,这是一条连接测试消息。',
|
||||
sendHttpTest: '发送测试消息',
|
||||
httpTestAccepted: '测试消息已被机器人接收,请稍候查看日志。',
|
||||
httpTestMissingSecret: '请先填写入站签名密钥并重新保存。',
|
||||
httpTestFailed: '测试消息发送失败:{{error}}',
|
||||
logsTitle: '机器人日志',
|
||||
logsDescription: '监控机器人活动,确认平台连接是否正常工作。',
|
||||
},
|
||||
aiEngine: {
|
||||
title: '选择 AI 引擎',
|
||||
description: '选择驱动机器人智能的 AI 引擎。',
|
||||
optionalDescription: '这一步可选。选择接下来要如何完善当前 Agent。',
|
||||
externalTitle: '接入外部平台 Agent',
|
||||
externalDescription:
|
||||
'接入 Dify、n8n、Coze 等平台,并替换当前机器人的流水线。',
|
||||
ownModelTitle: '改成使用自己的模型',
|
||||
ownModelDescription:
|
||||
'添加模型供应商,自动扫描或手动填写模型以快速完成引导。',
|
||||
ownModelSetupTitle: '添加你自己的模型',
|
||||
ownModelSetupDescription:
|
||||
'先添加模型供应商,保存后会自动扫描,也可以手动填写模型 ID。',
|
||||
addProviderTitle: '添加供应商',
|
||||
addProviderDescription: '填写供应商和 API Key,用于连接并扫描模型。',
|
||||
selectModelTitle: '选择模型',
|
||||
selectScannedModelTitle: '选择一个模型',
|
||||
selectScannedModelDescription:
|
||||
'选中的模型将作为新流水线的主模型,机器人会切换到这条流水线。',
|
||||
scanModelMode: '扫描模型',
|
||||
manualModelMode: '手动添加',
|
||||
scanningModels: '正在扫描可用模型…',
|
||||
noScannedModels: '没有扫描到可用的对话模型,请检查供应商配置。',
|
||||
scanModelsFailed: '模型扫描失败,请检查地址和 API Key 后重试。',
|
||||
manualFallbackFailed: '自动扫描失败,你可以直接填写中转站支持的模型 ID。',
|
||||
manualFallbackEmpty:
|
||||
'没有扫描到可用模型,你可以直接填写中转站支持的模型 ID。',
|
||||
manualModelId: '模型 ID',
|
||||
manualModelIdPlaceholder: '例如:gpt-4o',
|
||||
manualModelIdDescription: '填写模型请求中实际使用的 model 参数。',
|
||||
manualModelOptions: '可选模型能力',
|
||||
editProvider: '修改供应商',
|
||||
rescanModels: '重新扫描模型',
|
||||
moreFeaturesTitle: '给现在的 Agent 配置更多功能',
|
||||
moreFeaturesDescription: '进入工作台,为 Agent 添加工具、知识库等能力。',
|
||||
runnerDescription: '选择外部 Agent 的 Runner 并完成连接配置。',
|
||||
backToChoices: '返回选项',
|
||||
createExternal: '创建并绑定',
|
||||
finishWithModel: '使用所选模型并完成',
|
||||
openWorkbench: '进入工作台',
|
||||
},
|
||||
config: {
|
||||
botInfo: '机器人信息',
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import ts from 'typescript';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const sourcePath = path.resolve(
|
||||
currentDirectory,
|
||||
'../../src/app/wizard/utils.ts',
|
||||
);
|
||||
|
||||
function loadWizardUtils() {
|
||||
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
}).outputText;
|
||||
const loadedModule = { exports: {} };
|
||||
new Function('require', 'module', 'exports', compiled)(
|
||||
() => {
|
||||
throw new Error('Wizard utils must not have runtime imports');
|
||||
},
|
||||
loadedModule,
|
||||
loadedModule.exports,
|
||||
);
|
||||
return loadedModule.exports;
|
||||
}
|
||||
|
||||
const {
|
||||
configureLocalAgentPrimaryModel,
|
||||
ensureHttpBotSigningSecret,
|
||||
findDefaultPipeline,
|
||||
getErrorMessage,
|
||||
isWebhookModeEnabled,
|
||||
} = loadWizardUtils();
|
||||
|
||||
test('generates an HTTP Bot signing secret when signatures are enabled', () => {
|
||||
const config = ensureHttpBotSigningSecret('http_bot', {
|
||||
signature_required: true,
|
||||
inbound_secret: '',
|
||||
});
|
||||
|
||||
assert.match(config.inbound_secret, /^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
test('preserves existing or intentionally disabled HTTP Bot signing config', () => {
|
||||
const existing = { signature_required: true, inbound_secret: 'keep-me' };
|
||||
const disabled = { signature_required: false, inbound_secret: '' };
|
||||
|
||||
assert.equal(ensureHttpBotSigningSecret('http_bot', existing), existing);
|
||||
assert.equal(ensureHttpBotSigningSecret('http_bot', disabled), disabled);
|
||||
});
|
||||
|
||||
test('does not add signing config to other adapters', () => {
|
||||
const config = {};
|
||||
|
||||
assert.equal(ensureHttpBotSigningSecret('web_page_bot', config), config);
|
||||
});
|
||||
|
||||
test('extracts the backend message from structured API errors', () => {
|
||||
assert.equal(
|
||||
getErrorMessage({ code: 400, msg: 'Signing secret is required' }),
|
||||
'Signing secret is required',
|
||||
);
|
||||
assert.equal(getErrorMessage(new Error('Network failed')), 'Network failed');
|
||||
});
|
||||
|
||||
test('selects only a usable Workspace default pipeline', () => {
|
||||
const pipelines = [
|
||||
{ uuid: 'recent-pipeline', is_default: false },
|
||||
{ uuid: '', is_default: true },
|
||||
{ uuid: 'default-pipeline', is_default: true },
|
||||
];
|
||||
|
||||
assert.equal(findDefaultPipeline(pipelines)?.uuid, 'default-pipeline');
|
||||
});
|
||||
|
||||
test('configures the selected model as the Local Agent primary model', () => {
|
||||
const config = {
|
||||
trigger: { prefix: '!' },
|
||||
ai: {
|
||||
runner: { runner: 'plugin:external', timeout: 30 },
|
||||
'local-agent': {
|
||||
model: { primary: 'old-model', fallbacks: ['fallback-model'] },
|
||||
tools: { enabled: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const updated = configureLocalAgentPrimaryModel(config, 'selected-model');
|
||||
|
||||
assert.equal(updated.ai.runner.runner, 'local-agent');
|
||||
assert.equal(updated.ai.runner.timeout, 30);
|
||||
assert.equal(updated.ai['local-agent'].model.primary, 'selected-model');
|
||||
assert.deepEqual(updated.ai['local-agent'].model.fallbacks, [
|
||||
'fallback-model',
|
||||
]);
|
||||
assert.deepEqual(updated.ai['local-agent'].tools, { enabled: true });
|
||||
assert.deepEqual(updated.trigger, { prefix: '!' });
|
||||
});
|
||||
|
||||
test('shows webhook guidance only when the adapter webhook mode is active', () => {
|
||||
const dualModeFields = [
|
||||
{
|
||||
name: 'webhook_url',
|
||||
show_if: { field: 'enable-webhook', operator: 'eq', value: true },
|
||||
},
|
||||
];
|
||||
|
||||
assert.equal(
|
||||
isWebhookModeEnabled(dualModeFields, { 'enable-webhook': false }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isWebhookModeEnabled(dualModeFields, { 'enable-webhook': true }),
|
||||
true,
|
||||
);
|
||||
assert.equal(isWebhookModeEnabled([{ name: 'webhook_url' }], {}), true);
|
||||
assert.equal(isWebhookModeEnabled([], {}), false);
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const wizardSource = fs.readFileSync(
|
||||
path.resolve(currentDirectory, '../../src/app/wizard/page.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
const widgetSource = fs.readFileSync(
|
||||
path.resolve(
|
||||
currentDirectory,
|
||||
'../../../src/langbot/templates/embed/widget.js',
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
test('shows the test-only notice only when the wizard opts in', () => {
|
||||
assert.match(
|
||||
wizardSource,
|
||||
/widget\.js\?preview=wizard&v=\$\{Date\.now\(\)\}/,
|
||||
);
|
||||
assert.match(wizardSource, /script\.dataset\.testNotice = testNotice/);
|
||||
assert.match(
|
||||
wizardSource,
|
||||
/testNotice=\{t\('wizard\.botConfig\.pageBotTestNotice'\)\}/,
|
||||
);
|
||||
assert.match(widgetSource, /getAttribute\("data-test-notice"\)/);
|
||||
assert.match(widgetSource, /if \(scriptTestNotice\)/);
|
||||
assert.match(widgetSource, /testNotice\.textContent = scriptTestNotice/);
|
||||
});
|
||||
+13
-34
@@ -1,39 +1,18 @@
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '');
|
||||
const apiProxyTarget = env.VITE_API_PROXY_TARGET || 'http://127.0.0.1:5300';
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: apiProxyTarget,
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
'/mcp': {
|
||||
target: apiProxyTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/bots': {
|
||||
target: apiProxyTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
};
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user