Compare commits

...

16 Commits

Author SHA1 Message Date
fdc310 cd7acd5769 style(space): apply ruff formatting 2026-08-26 00:13:04 +08:00
fdc310 51b9e1cf54 Merge remote-tracking branch 'origin/master' into feat/rework-agent-onboarding 2026-08-25 22:36:44 +08:00
fdc310 1f07471f81 feat(wizard): label page bot test preview 2026-08-25 22:34:45 +08:00
fdc310 7f29d51c69 feat(wizard): streamline custom model onboarding 2026-08-25 19:33:36 +08:00
leonoxo 777fe1f20b fix(line): use stable source id for session identity (#2398)
LINEEventConverter.target2yiri() built Friend.id/Group.id from
event.message.id, which is unique per message. Every incoming message
therefore mapped to a new session key, so LINE users and groups lost
conversation context on every turn.

Use event.source.user_id/group_id/room_id instead, matching the stable
identifiers other adapters (e.g. Telegram) use for session identity.
Falls back to the group/room id when user_id is absent, per LINE's
documented behavior for some group/room members.
2026-08-25 12:30:29 +08:00
Hyu f0ee57c1e0 style(email): use generic LangBot invitation branding (#2466)
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2026-08-24 14:53:59 +08:00
Hyu a45e27e76e style(cloud): redesign workspace invitation email (#2464)
* style(cloud): redesign workspace invitation email

* fix(email): harden Outlook spacing and text contrast

---------

Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2026-08-24 14:14:56 +08:00
Hyu 536fcdf29f fix(web): restore plugin page SDK loading (#2435)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-24 12:40:50 +08:00
QuasarRyan c87548c0b9 feat(qqofficial): add markdown reply rendering (#2459) 2026-08-24 11:59:23 +08:00
QuasarRyan 79634772da fix(qqofficial): send complete stream snapshots (#2458) 2026-08-24 11:49:22 +08:00
fdc310 6b60ad1678 fix(wizard): repair HTTP bot inbound test setup 2026-08-23 20:41:46 +08:00
langbot-dev 7a4fadc375 feat(wizard): add floating page bot verification 2026-08-14 23:58:39 +08:00
langbot-dev f2ba540ffb feat(wizard): add inbound bot verification 2026-08-14 23:14:22 +08:00
langbot-dev 97b176aef2 fix(wizard): parse ranked model selection entries 2026-08-14 17:43:36 +08:00
langbot-dev 7c387f75e1 fix(web): support LAN development access 2026-08-14 17:29:21 +08:00
langbot-dev b0566f4c9d feat(wizard): rework agent onboarding flow 2026-08-14 01:10:00 +08:00
35 changed files with 2465 additions and 263 deletions
+2
View File
@@ -75,6 +75,8 @@ shape as the corresponding HTTP API request body. Discover resources with the
`list_*` / `get_*` tools before mutating; identifiers are UUIDs. Reads require
`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
+63
View File
@@ -422,6 +422,69 @@ class QQOfficialClient:
await self.logger.error(f'Failed to send private message: {response_data}')
raise ValueError(response)
async def _send_markdown_msg(
self,
target_type: str,
target_id: str,
content: str,
msg_id: Optional[str] = None,
event_id: Optional[str] = None,
msg_seq: int = 1,
) -> None:
"""Send a Markdown message to a C2C user or QQ group."""
if not await self.check_access_token():
await self.get_access_token()
if target_type == 'c2c':
url = f'{self.base_url}/v2/users/{target_id}/messages'
elif target_type == 'group':
url = f'{self.base_url}/v2/groups/{target_id}/messages'
else:
raise ValueError(f'Unsupported Markdown target type: {target_type}')
data: dict[str, Any] = {
'msg_type': 2,
'markdown': {'content': content},
'msg_seq': msg_seq,
}
if msg_id:
data['msg_id'] = msg_id
if event_id:
data['event_id'] = event_id
async with self._http_client_context() as client:
headers = {
'Authorization': f'QQBot {self.access_token}',
'Content-Type': 'application/json',
}
response = await client.post(url, headers=headers, json=data)
if response.status_code != 200:
response_data = await httpclient.parse_json_response(response)
await self.logger.error(f'Failed to send Markdown message: {response_data}')
raise ValueError(response)
async def send_private_markdown_msg(
self,
user_openid: str,
content: str,
msg_id: Optional[str] = None,
event_id: Optional[str] = None,
msg_seq: int = 1,
) -> None:
"""Send a Markdown C2C message."""
await self._send_markdown_msg('c2c', user_openid, content, msg_id, event_id, msg_seq)
async def send_group_markdown_msg(
self,
group_openid: str,
content: str,
msg_id: Optional[str] = None,
event_id: Optional[str] = None,
msg_seq: int = 1,
) -> None:
"""Send a Markdown QQ group message."""
await self._send_markdown_msg('group', group_openid, content, msg_id, event_id, msg_seq)
async def send_group_text_msg(
self,
group_openid: str,
@@ -39,7 +39,13 @@ class PipelinesRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_MANAGE,
)
async def _(request_context: RequestContext) -> str:
pipeline_uuid = await self.ap.pipeline_service.create_pipeline(request_context, await quart.request.json)
pipeline_data = await quart.request.json
create_as_default = pipeline_data.get('is_default') is True
pipeline_uuid = await self.ap.pipeline_service.create_pipeline(
request_context,
pipeline_data,
default=create_as_default,
)
return self.success(data={'uuid': pipeline_uuid})
@self.route(
@@ -113,6 +113,24 @@ 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,6 +206,20 @@ 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'],
+51
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import uuid
import json
import sqlalchemy
from ....core import app
@@ -8,6 +9,8 @@ from ....entity.persistence import bot as persistence_bot
from ....entity.persistence import pipeline as persistence_pipeline
from ....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:
@@ -80,6 +83,7 @@ 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', '')
@@ -216,6 +220,53 @@ 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,
+76
View File
@@ -11,6 +11,9 @@ 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
@@ -238,3 +241,76 @@ 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}
+10 -1
View File
@@ -147,7 +147,16 @@ class LangBotMCPServer:
)
async def create_pipeline(pipeline_data: dict) -> str:
context = _authorized(Permission.RESOURCE_MANAGE)
return _dump({'uuid': await ap.pipeline_service.create_pipeline(context, pipeline_data)})
create_as_default = pipeline_data.get('is_default') is True
return _dump(
{
'uuid': await ap.pipeline_service.create_pipeline(
context,
pipeline_data,
default=create_as_default,
)
}
)
@mcp.tool(description='Update a pipeline by UUID. `pipeline_data` matches the PUT body.')
async def update_pipeline(pipeline_uuid: str, pipeline_data: dict) -> str:
@@ -47,3 +47,10 @@ 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
+10 -4
View File
@@ -101,7 +101,7 @@ class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
if event.source.type == 'user':
return platform_events.FriendMessage(
sender=platform_entities.Friend(
id=event.message.id,
id=event.source.user_id,
nickname=event.source.user_id,
remark='',
),
@@ -110,13 +110,19 @@ class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
source_platform_object=event,
)
else:
# 'group' and 'room' sources carry the stable chat id under different
# field names; user_id may be absent for some members, so fall back
# to the group/room id rather than the per-message id.
group_id = event.source.group_id if event.source.type == 'group' else event.source.room_id
member_id = event.source.user_id or group_id
return platform_events.GroupMessage(
sender=platform_entities.GroupMember(
id=event.event.sender.sender_id.open_id,
member_name=event.event.sender.sender_id.union_id,
id=member_id,
member_name=member_id,
permission=platform_entities.Permission.Member,
group=platform_entities.Group(
id=event.message.id,
id=group_id,
name='',
permission=platform_entities.Permission.Member,
),
+50 -29
View File
@@ -329,17 +329,12 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
content_type = content.get('type', 'text')
if content_type == 'text':
if target_type == 'c2c':
await self.bot.send_private_text_msg(
if target_type in {'c2c', 'group'}:
await self._send_c2c_or_group_text_reply(
target_type,
target_id,
content['content'],
qq_official_event.d_id,
)
elif target_type == 'group':
await self.bot.send_group_text_msg(
target_id,
content['content'],
qq_official_event.d_id,
msg_id=qq_official_event.d_id,
)
elif content_type == 'image':
@@ -383,6 +378,39 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
pass
async def _send_c2c_or_group_text_reply(
self,
target_type: str,
target_id: str,
content: str,
*,
msg_id: typing.Optional[str] = None,
event_id: typing.Optional[str] = None,
msg_seq: int = 1,
) -> None:
"""Send a text reply using the configured C2C/group render mode."""
use_markdown = self.config.get('enable-markdown-rendering', False)
if target_type == 'c2c':
send = self.bot.send_private_markdown_msg if use_markdown else self.bot.send_private_text_msg
await send(
user_openid=target_id,
content=content,
msg_id=msg_id,
event_id=event_id,
msg_seq=msg_seq,
)
elif target_type == 'group':
send = self.bot.send_group_markdown_msg if use_markdown else self.bot.send_group_text_msg
await send(
group_openid=target_id,
content=content,
msg_id=msg_id,
event_id=event_id,
msg_seq=msg_seq,
)
else:
raise ValueError(f'Unsupported QQ Official text reply target: {target_type}')
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
@@ -650,13 +678,13 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
# 用第一个 chunk 的文本建立会话(不发 "..." 避免污染前缀)
ctx['session_started'] = True
# 发送内容 = 全量累积文本
# QQ API 的 replace 模式不允许修改已下发前缀,所以:
# - 首次:发送全部文本,建立会话
# - 后续:只能发送新增部分(append 行为)
content_to_send = ctx['accumulated_text'][ctx['sent_length'] :]
if not content_to_send and not is_final:
# `replace` mode requires every update to contain the previously
# delivered content as its prefix. `sent_length` only tells us whether
# a non-final snapshot has new content; it must not truncate the
# content sent to QQ.
if len(ctx['accumulated_text']) <= ctx['sent_length'] and not is_final:
return
content_to_send = ctx['accumulated_text']
input_state = 10 if is_final else 1
@@ -778,20 +806,13 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
return
try:
if target_type == 'c2c':
await self.bot.send_private_text_msg(
user_openid=target_id,
content=text,
event_id=event_id,
msg_seq=msg_seq,
)
elif target_type == 'group':
await self.bot.send_group_text_msg(
group_openid=target_id,
content=text,
event_id=event_id,
msg_seq=msg_seq,
)
await self._send_c2c_or_group_text_reply(
target_type,
target_id,
text,
event_id=event_id,
msg_seq=msg_seq,
)
except Exception:
await self.logger.error(f'QQ Official: synthetic reply delivery failed: {traceback.format_exc()}')
@@ -95,6 +95,18 @@ spec:
type: boolean
required: true
default: false
- name: enable-markdown-rendering
label:
en_US: Enable Markdown Rendering
zh_Hans: 启用 Markdown 渲染
zh_Hant: 啟用 Markdown 渲染
description:
en_US: Render non-stream C2C and QQ group text replies as Markdown. Channel messages always use plain text and are not affected by this setting.
zh_Hans: 将非流式 C2C 私聊和 QQ 群聊文本回复渲染为 Markdown。频道消息始终以纯文本发送,不受此设置影响。
zh_Hant: 將非串流 C2C 私聊與 QQ 群聊文字回覆渲染為 Markdown。頻道訊息一律以純文字傳送,不受此設定影響。
type: boolean
required: true
default: false
- name: webhook_url
label:
en_US: Webhook Callback URL
@@ -240,7 +240,7 @@ class InvitationDeliveryService:
@staticmethod
def _plain_text(workspace_name: str, invitation_link: str) -> str:
return (
'You have been invited to LangBot Cloud\n\n'
'You have been invited to join a Workspace in LangBot\n\n'
f'Join the Workspace “{workspace_name}” to collaborate with your team.\n\n'
f'Accept invitation: {invitation_link}\n\n'
'This secure invitation expires in 7 days and can only be accepted by the email address '
@@ -258,30 +258,77 @@ class InvitationDeliveryService:
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Join {escaped_workspace} on LangBot Cloud</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Join {escaped_workspace} in LangBot</title>
</head>
<body style="margin:0;background:#f4f7fb;color:#152033;font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">You have been invited to join {escaped_workspace} on LangBot Cloud.</div>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f4f7fb;padding:40px 16px;">
<tr><td align="center">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:600px;background:#ffffff;border:1px solid #e5eaf2;border-radius:16px;overflow:hidden;box-shadow:0 12px 32px rgba(20,49,93,.08);">
<tr><td style="padding:28px 36px;background:linear-gradient(135deg,#0f172a,#1d4ed8);color:#ffffff;">
<div style="font-size:14px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;opacity:.78;">LangBot Cloud</div>
<div style="font-size:26px;font-weight:700;margin-top:8px;line-height:1.25;">Youre invited</div>
</td></tr>
<tr><td style="padding:36px;">
<p style="margin:0 0 18px;font-size:16px;line-height:1.65;color:#475569;">You have been invited to collaborate in this Workspace:</p>
<div style="margin:0 0 26px;padding:18px 20px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;font-size:18px;font-weight:700;color:#0f172a;">{escaped_workspace}</div>
<table role="presentation" cellspacing="0" cellpadding="0"><tr><td style="border-radius:9px;background:#2563eb;">
<a href="{escaped_link}" style="display:inline-block;padding:13px 22px;color:#ffffff;text-decoration:none;font-size:15px;font-weight:700;">Accept invitation</a>
</td></tr></table>
<p style="margin:26px 0 8px;font-size:14px;line-height:1.6;color:#64748b;">This invitation expires in 7 days and is bound to the email address that received it.</p>
<p style="margin:0 0 8px;font-size:13px;line-height:1.6;color:#94a3b8;">If the button does not work, copy and paste this URL into your browser:</p>
<p style="margin:0;padding:12px;background:#f8fafc;border-radius:8px;word-break:break-all;font-size:12px;line-height:1.55;color:#475569;">{escaped_link}</p>
</td></tr>
<tr><td style="padding:20px 36px;border-top:1px solid #eef2f7;font-size:12px;line-height:1.6;color:#94a3b8;">If you were not expecting this invitation, you can safely ignore this email.</td></tr>
</table>
</td></tr>
<body style="margin:0;padding:0;background:#f4f7fb;color:#111827;font-family:Arial,'Helvetica Neue',sans-serif;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">You have been invited to join {escaped_workspace} in LangBot.</div>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;background:#f4f7fb;">
<tr>
<td align="center" style="padding:48px 16px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;max-width:600px;">
<tr>
<td style="padding:0 4px 20px;">
<img src="https://docs.langbot.app/langbot-logo.png" alt="LangBot" width="34" height="34" style="display:inline-block;width:34px;height:34px;border:0;vertical-align:middle;">
<span style="display:inline-block;margin-left:10px;vertical-align:middle;font-size:18px;font-weight:700;letter-spacing:-.01em;">LangBot</span>
</td>
</tr>
<tr>
<td style="background:#ffffff;border-radius:10px;overflow:hidden;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td style="padding:42px 42px 38px;">
<div style="margin:0 0 12px;font-size:13px;line-height:1.4;font-weight:600;color:#5f6f84;">Workspace invitation</div>
<h1 style="margin:0 0 16px;font-size:28px;line-height:1.25;font-weight:700;letter-spacing:-.025em;color:#111827;">Youre invited to collaborate</h1>
<p style="margin:0 0 28px;font-size:15px;line-height:1.7;color:#526173;">Join your team in LangBot and start building together in this Workspace.</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background:#f6f8fb;border-radius:8px;">
<tr>
<td style="padding:16px 18px;">
<div style="margin:0 0 4px;font-size:11px;line-height:1.4;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#5f6f84;">Workspace</div>
<div style="font-size:18px;line-height:1.4;font-weight:700;color:#111827;">{escaped_workspace}</div>
</td>
</tr>
</table>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr><td height="28" style="height:28px;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
<tr>
<td style="background:#2563eb;border-radius:8px;">
<a href="{escaped_link}" target="_blank" style="display:inline-block;padding:13px 22px;font-size:15px;line-height:1.2;font-weight:700;color:#ffffff;text-decoration:none;border-radius:8px;">Accept invitation</a>
</td>
</tr>
</table>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr><td height="32" style="height:32px;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="border-top:1px solid #e8edf4;">
<tr>
<td style="padding-top:22px;">
<p style="margin:0 0 10px;font-size:13px;line-height:1.6;color:#5f6f84;">For your security, this invitation expires in 7 days and only works for the email address that received it.</p>
<a href="{escaped_link}" target="_blank" style="font-size:13px;line-height:1.6;font-weight:600;color:#2563eb;text-decoration:none;">Open invitation link&nbsp;&rarr;</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="center" style="padding:20px 24px 0;font-size:12px;line-height:1.6;color:#5f6f84;">
Sent by LangBot<br>
If you were not expecting this invitation, you can safely ignore this email.
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>'''
+20
View File
@@ -7,6 +7,9 @@
// 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 = {
@@ -192,6 +195,7 @@
.lb-header-btn { background: none; border: none; color: #fff; cursor: pointer; padding: 4px; border-radius: 6px; display: flex; align-items: center; justify-content: center; opacity: 0.8; transition: opacity 0.15s; }\
.lb-header-btn: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; }\
@@ -1240,6 +1244,14 @@
// 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" });
@@ -1328,6 +1340,14 @@
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";
+16
View File
@@ -254,6 +254,22 @@ 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,8 +9,9 @@ Source: src/langbot/pkg/api/http/service/bot.py
from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, Mock, patch
from unittest.mock import AsyncMock, MagicMock, Mock, patch
from types import SimpleNamespace
import json
import uuid
from langbot.pkg.api.http.service.bot import BotService
@@ -241,6 +242,29 @@ class TestBotServiceGetRuntimeBotInfo:
assert result['adapter_runtime_values']['webhook_url'] == '/bots/wecom-uuid'
assert result['adapter_runtime_values']['webhook_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
@@ -605,6 +629,77 @@ 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,6 +820,100 @@ 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,69 @@
from __future__ import annotations
import pytest
from unittest.mock import MagicMock
from linebot.v3.webhooks import TextMessageContent
from langbot.pkg.platform import botmgr as _botmgr # noqa: F401
from langbot.pkg.platform.sources import line
def _make_event(*, source_type: str, user_id, group_id=None, room_id=None, message_id: str, text: str = 'hi'):
event = MagicMock()
event.timestamp = 1700000000000
event.message = MagicMock(spec=TextMessageContent)
event.message.id = message_id
event.message.text = text
event.message.webhook_event_id = f'webhook-{message_id}'
event.message.timestamp = event.timestamp
source = MagicMock()
source.type = source_type
source.user_id = user_id
if group_id is not None:
source.group_id = group_id
if room_id is not None:
source.room_id = room_id
event.source = source
return event
@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).
"""
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)
assert result1.sender.id == 'U-stable-user'
assert result1.sender.id == result2.sender.id
assert result1.sender.id != event1.message.id
@pytest.mark.asyncio
async def test_group_message_uses_group_id_not_message_id() -> None:
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)
assert result1.sender.group.id == 'G-stable-group'
assert result1.sender.group.id == result2.sender.group.id
assert result1.sender.id == 'U-member'
@pytest.mark.asyncio
async def test_room_message_uses_room_id_and_falls_back_when_user_id_missing() -> None:
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)
assert result.sender.group.id == 'R-stable-room'
assert result.sender.id == 'R-stable-room'
@@ -1,9 +1,11 @@
"""Tests for QQ Official keyboard payload helpers."""
"""Tests for QQ Official message and keyboard payload helpers."""
import asyncio
import json
import time
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
import langbot_plugin.api.entities.builtin.platform.message as platform_message
@@ -99,6 +101,12 @@ def _stream_test_adapter():
adapter.bot = MagicMock()
adapter.bot.send_stream_msg = AsyncMock(return_value={'id': 'stream-1'})
adapter.bot.send_markdown_keyboard = AsyncMock(return_value={'id': 'message-1'})
adapter.bot.send_private_text_msg = AsyncMock()
adapter.bot.send_group_text_msg = AsyncMock()
adapter.bot.send_private_markdown_msg = AsyncMock()
adapter.bot.send_group_markdown_msg = AsyncMock()
adapter.bot.send_channle_group_text_msg = AsyncMock()
adapter.bot.send_channle_private_text_msg = AsyncMock()
adapter.ap = None
adapter._stream_ctx = {}
adapter._stream_ctx_ts = {}
@@ -108,7 +116,7 @@ def _stream_test_adapter():
@pytest.mark.asyncio
async def test_qq_stream_uses_cumulative_chunks_as_snapshots():
async def test_qq_stream_replace_mode_sends_complete_snapshots():
adapter = _stream_test_adapter()
adapter._stream_ctx['message-1'] = {
'user_openid': 'user-1',
@@ -138,10 +146,109 @@ async def test_qq_stream_uses_cumulative_chunks_as_snapshots():
assert [call.kwargs['content'] for call in adapter.bot.send_stream_msg.await_args_list] == [
'<think>one',
' two',
'<think>one two',
]
@pytest.mark.asyncio
async def test_qq_markdown_messages_use_markdown_payloads():
requests = []
def capture_request(request: httpx.Request) -> httpx.Response:
requests.append((str(request.url), json.loads(request.content)))
return httpx.Response(200, json={})
client = QQOfficialClient('secret', 'token', 'app-id', AsyncMock())
client.access_token = 'access-token'
client.access_token_expiry_time = time.time() + 3600
client._http_clients[None] = httpx.AsyncClient(transport=httpx.MockTransport(capture_request))
try:
await client.send_private_markdown_msg('user-1', '# Hello', msg_id='message-1', msg_seq=2)
await client.send_group_markdown_msg('group-1', '* Hello', event_id='event-1', msg_seq=3)
finally:
await client.close()
assert requests == [
(
'https://api.sgroup.qq.com/v2/users/user-1/messages',
{'msg_type': 2, 'markdown': {'content': '# Hello'}, 'msg_seq': 2, 'msg_id': 'message-1'},
),
(
'https://api.sgroup.qq.com/v2/groups/group-1/messages',
{'msg_type': 2, 'markdown': {'content': '* Hello'}, 'msg_seq': 3, 'event_id': 'event-1'},
),
]
@pytest.mark.asyncio
async def test_qq_markdown_rendering_switches_c2c_and_group_text_replies():
adapter = _stream_test_adapter()
adapter.config = {'enable-markdown-rendering': True}
await adapter._send_c2c_or_group_text_reply('c2c', 'user-1', '# Hello', msg_id='message-1')
await adapter._send_c2c_or_group_text_reply('group', 'group-1', '* Hello', event_id='event-1')
adapter.bot.send_private_markdown_msg.assert_awaited_once_with(
user_openid='user-1',
content='# Hello',
msg_id='message-1',
event_id=None,
msg_seq=1,
)
adapter.bot.send_group_markdown_msg.assert_awaited_once_with(
group_openid='group-1',
content='* Hello',
msg_id=None,
event_id='event-1',
msg_seq=1,
)
adapter.bot.send_private_text_msg.assert_not_awaited()
adapter.bot.send_group_text_msg.assert_not_awaited()
@pytest.mark.asyncio
async def test_qq_markdown_rendering_defaults_to_plain_text_replies():
adapter = _stream_test_adapter()
adapter.config = {}
await adapter._send_c2c_or_group_text_reply('c2c', 'user-1', 'Hello')
await adapter._send_c2c_or_group_text_reply('group', 'group-1', 'Hello')
adapter.bot.send_private_text_msg.assert_awaited_once()
adapter.bot.send_group_text_msg.assert_awaited_once()
adapter.bot.send_private_markdown_msg.assert_not_awaited()
adapter.bot.send_group_markdown_msg.assert_not_awaited()
@pytest.mark.asyncio
async def test_qq_markdown_rendering_does_not_affect_channel_messages():
adapter = _stream_test_adapter()
adapter.config = {'enable-markdown-rendering': True}
message = platform_message.MessageChain([platform_message.Plain(text='# Hello')])
channel_source = MagicMock()
channel_source.t = 'AT_MESSAGE_CREATE'
channel_source.channel_id = 'channel-1'
channel_source.d_id = 'message-1'
channel_event = MagicMock()
channel_event.source_platform_object = channel_source
await adapter.reply_message(channel_event, message)
dm_source = MagicMock()
dm_source.t = 'DIRECT_MESSAGE_CREATE'
dm_source.guild_id = 'guild-1'
dm_source.d_id = 'message-2'
dm_event = MagicMock()
dm_event.source_platform_object = dm_source
await adapter.reply_message(dm_event, message)
adapter.bot.send_channle_group_text_msg.assert_awaited_once_with('channel-1', '# Hello', 'message-1')
adapter.bot.send_channle_private_text_msg.assert_awaited_once_with('guild-1', '# Hello', 'message-2')
adapter.bot.send_private_markdown_msg.assert_not_awaited()
adapter.bot.send_group_markdown_msg.assert_not_awaited()
@pytest.mark.asyncio
async def test_qq_non_streaming_fallback_keeps_latest_snapshot_only():
from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter
@@ -88,14 +88,15 @@ async def test_environment_mapping_enables_provider_without_leaking_secret(monke
assert service.capability() == {'enabled': True, 'provider': 'smtp'}
async def test_cloud_invitation_email_has_branded_html_plain_fallback_and_expiry_copy():
async def test_invitation_email_has_generic_langbot_brand_plain_fallback_and_expiry_copy():
service = InvitationDeliveryService(_app({}))
link = 'https://cloud.langbot.app/invitations/accept#token=lbi_secret&next=<unsafe>'
text = service._plain_text('Research & Development', link)
html = service._html('Research & Development', link)
assert 'LangBot Cloud' in text
assert 'LangBot' in text
assert 'LangBot Cloud' not in text
assert 'Research & Development' in text
assert '7 days' in text
assert link in text
@@ -103,3 +104,55 @@ async def test_cloud_invitation_email_has_branded_html_plain_fallback_and_expiry
assert 'Research &amp; Development' in html
assert 'expires in 7 days' in html
assert 'lbi_secret&amp;next=&lt;unsafe&gt;' in html
assert 'LangBot Cloud' not in html
async def test_invitation_email_uses_quiet_brand_lockup_and_compact_fallback_link():
service = InvitationDeliveryService(_app({}))
link = 'https://cloud.langbot.app/invitations/accept#token=lbi_secret'
html = service._html("RockChinQ's Workspace", link)
assert 'https://docs.langbot.app/langbot-logo.png' in html
assert '>LangBot<' in html
assert 'Workspace invitation' in html
assert 'Open invitation link' in html
assert 'linear-gradient' not in html
assert 'box-shadow' not in html
assert 'border-top:4px solid' not in html
assert 'border:1px solid #dfe6f0' not in html
assert 'height="28"' in html
assert 'height="32"' in html
assert 'margin-top:32px' not in html
assert f'>{link}<' not in html
async def test_oss_smtp_configuration_delivers_the_generic_invitation_email():
service = InvitationDeliveryService(
_app(
{
'workspace': {
'invitations': {
'email': {
'provider': 'smtp',
'from': 'LangBot <noreply@example.com>',
'smtp': {'host': 'smtp.example.com'},
}
}
}
}
)
)
service._send_smtp = AsyncMock(return_value=True)
link = 'https://self-hosted.example/invitations/accept#token=lbi_secret'
result = await service.deliver_invitation(
recipient_email='member@example.com',
workspace_name='Self-hosted Workspace',
invitation_link=link,
)
assert result == InvitationDeliveryResult(status='sent', provider='smtp')
service._send_smtp.assert_awaited_once()
assert 'LangBot Cloud' not in service._plain_text('Self-hosted Workspace', link)
assert 'LangBot Cloud' not in service._html('Self-hosted Workspace', link)
+5 -1
View File
@@ -1 +1,5 @@
VITE_API_BASE_URL=http://localhost:5300
# Leave empty in development to use Vite's same-origin proxy. This keeps API,
# login, and WebSocket requests working when the UI is opened from another
# device on the local network.
VITE_API_BASE_URL=
VITE_API_PROXY_TARGET=http://127.0.0.1:5300
@@ -20,6 +20,7 @@ export function BotLogListComponent({
autoExpandImages = false,
hideDetailedLogsLink = false,
hideToolbar = false,
onMessageReceived,
}: {
botId: string;
/** When true, log entries with images are rendered expanded by default */
@@ -28,6 +29,8 @@ 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();
@@ -41,6 +44,8 @@ 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' },
@@ -108,6 +113,9 @@ 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();
}
@@ -138,6 +146,9 @@ 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: () => void;
onFormSubmit: (providerUuid: string) => void | Promise<void>;
onFormCancel: () => void;
}
@@ -171,14 +171,16 @@ export default function ProviderForm({
};
try {
let savedProviderUuid = providerId;
if (providerId) {
await httpClient.updateModelProvider(providerId, data);
toast.success(t('models.providerSaved'));
} else {
await httpClient.createModelProvider(data);
const response = await httpClient.createModelProvider(data);
savedProviderUuid = response.uuid;
toast.success(t('models.providerCreated'));
}
onFormSubmit();
await onFormSubmit(savedProviderUuid as string);
} catch (err) {
toast.error(t('models.providerSaveError') + (err as CustomApiError).msg);
}
+2
View File
@@ -363,7 +363,9 @@ 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;
}
+45 -3
View File
@@ -150,7 +150,9 @@ export class BackendClient extends BaseHttpClient {
return this.get(`/api/v1/provider/models/llm/${uuid}`);
}
public createProviderLLMModel(model: LLMModel): Promise<object> {
public createProviderLLMModel(
model: Omit<LLMModel, 'uuid'>,
): Promise<{ uuid: string }> {
return this.post('/api/v1/provider/models/llm', model);
}
@@ -461,6 +463,15 @@ 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,
@@ -710,11 +721,32 @@ export class BackendClient extends BaseHttpClient {
);
}
private async getAuthenticatedObjectURL(path: string): Promise<string> {
private async getAuthenticatedObjectURL(
path: string,
rewritePluginPageSdk = false,
): Promise<string> {
const response = await this.instance.get<Blob>(path, {
responseType: 'blob',
});
return URL.createObjectURL(response.data);
let blob = response.data;
if (rewritePluginPageSdk && blob.type.startsWith('text/html')) {
const apiBase =
this.instance.defaults.baseURL === '/'
? window.location.origin
: this.instance.defaults.baseURL?.replace(/\/$/, '');
const pageSdkUrl = `${apiBase}/api/v1/plugins/_sdk/page-sdk.js`;
const html = await blob.text();
blob = new Blob(
[
html.replace(
/(<script\b[^>]*\bsrc\s*=\s*)(["'])\/api\/v1\/plugins\/_sdk\/page-sdk\.js\2/gi,
`$1$2${pageSdkUrl}$2`,
),
],
{ type: blob.type },
);
}
return URL.createObjectURL(blob);
}
public getAuthenticatedPluginAssetURL(
@@ -724,6 +756,7 @@ export class BackendClient extends BaseHttpClient {
): Promise<string> {
return this.getAuthenticatedObjectURL(
`/api/v1/plugins/${author}/${name}/authenticated-assets/${filepath}`,
true,
);
}
@@ -1046,12 +1079,21 @@ 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;
@@ -0,0 +1,409 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
ArrowLeft,
Check,
Eye,
Loader2,
Pencil,
RefreshCw,
Wrench,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import ProviderForm from '@/app/home/components/models-dialog/component/provider-form/ProviderForm';
import type { ScannedProviderModel } from '@/app/infra/entities/api';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { cn } from '@/lib/utils';
type ModelSetupMode = 'scan' | 'manual';
type ScanFallbackReason = 'failed' | 'empty' | null;
export interface OwnModelSelection {
source: ModelSetupMode;
providerUuid: string;
model: ScannedProviderModel;
}
interface OwnModelSetupProps {
onBack: () => void;
onSelectionChange: (selection: OwnModelSelection | null) => void;
}
export default function OwnModelSetup({
onBack,
onSelectionChange,
}: OwnModelSetupProps) {
const { t } = useTranslation();
const [providerUuid, setProviderUuid] = useState<string | null>(null);
const [showProviderForm, setShowProviderForm] = useState(true);
const [mode, setMode] = useState<ModelSetupMode>('scan');
const [models, setModels] = useState<ScannedProviderModel[]>([]);
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
const [isScanning, setIsScanning] = useState(false);
const [scanFallbackReason, setScanFallbackReason] =
useState<ScanFallbackReason>(null);
const [manualModelName, setManualModelName] = useState('');
const [manualContextLength, setManualContextLength] = useState('');
const [manualVision, setManualVision] = useState(false);
const [manualFunctionCall, setManualFunctionCall] = useState(false);
const parsedManualContextLength = useMemo(() => {
if (!manualContextLength.trim()) return null;
const value = Number(manualContextLength);
return Number.isInteger(value) && value > 0 ? value : undefined;
}, [manualContextLength]);
useEffect(() => {
if (mode !== 'manual' || !providerUuid) return;
if (!manualModelName.trim() || parsedManualContextLength === undefined) {
onSelectionChange(null);
return;
}
const abilities = [
...(manualVision ? ['vision'] : []),
...(manualFunctionCall ? ['func_call'] : []),
];
const modelName = manualModelName.trim();
onSelectionChange({
source: 'manual',
providerUuid,
model: {
id: modelName,
name: modelName,
type: 'llm',
abilities,
context_length: parsedManualContextLength,
already_added: false,
},
});
}, [
manualFunctionCall,
manualModelName,
manualVision,
mode,
onSelectionChange,
parsedManualContextLength,
providerUuid,
]);
const scanModels = useCallback(
async (uuid: string) => {
setMode('scan');
setIsScanning(true);
setScanFallbackReason(null);
setModels([]);
setSelectedModelId(null);
onSelectionChange(null);
try {
const response = await httpClient.scanProviderModels(uuid, 'llm');
const availableModels = response.models.filter(
(model) => model.type === 'llm' && !model.already_added,
);
setModels(availableModels);
if (availableModels.length === 0) {
setScanFallbackReason('empty');
setMode('manual');
}
} catch {
setScanFallbackReason('failed');
setMode('manual');
} finally {
setIsScanning(false);
}
},
[onSelectionChange],
);
const handleProviderSaved = useCallback(
async (uuid: string) => {
setProviderUuid(uuid);
setShowProviderForm(false);
await scanModels(uuid);
},
[scanModels],
);
const handleSelectModel = useCallback(
(model: ScannedProviderModel) => {
if (!providerUuid) return;
setSelectedModelId(model.id);
onSelectionChange({ source: 'scan', providerUuid, model });
},
[onSelectionChange, providerUuid],
);
const handleModeChange = useCallback(
(value: string) => {
setMode(value as ModelSetupMode);
setSelectedModelId(null);
onSelectionChange(null);
},
[onSelectionChange],
);
const handleBack = useCallback(() => {
onSelectionChange(null);
onBack();
}, [onBack, onSelectionChange]);
const handleEditProvider = useCallback(() => {
setSelectedModelId(null);
onSelectionChange(null);
setShowProviderForm(true);
}, [onSelectionChange]);
return (
<div className="mx-auto w-full max-w-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>
);
}
File diff suppressed because it is too large Load Diff
+104
View File
@@ -0,0 +1,104 @@
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
: [],
},
},
},
};
}
+64
View File
@@ -1827,6 +1827,25 @@ 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.',
@@ -1835,6 +1854,51 @@ 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',
+63
View File
@@ -1744,6 +1744,25 @@ const jaJP = {
resaveBot: '設定を再保存',
botSaved:
'ボット設定が保存され、有効になりました。ログを確認して接続を検証してください。',
waitingForMessage:
'ボットが有効になりました。続行するには IM からメッセージを送信してください。',
messageReceived:
'ボットが IM メッセージを受信しました。次のステップに進めます。',
pageBotTestPrompt:
'ページボットが有効になりました。右下のチャットバブルをクリックしてメッセージを送信し、会話フロー全体を確認してください。',
pageBotTestNotice:
'テスト専用です。実際の外部 Web ページにコードを埋め込んでください。',
webhookTestPrompt:
'コールバック URL の準備ができました。外部プラットフォームに設定し、ボットへ実際のメッセージを送信してください。',
httpTestPrompt:
'HTTP Bot が有効になりました。実際の受信メッセージを送信して接続を確認できます。',
httpTestDefaultMessage: 'こんにちは。これは接続テストメッセージです。',
sendHttpTest: 'テストメッセージを送信',
httpTestAccepted:
'テストメッセージを受け付けました。まもなくログに表示されます。',
httpTestMissingSecret:
'受信署名シークレットを入力し、先に設定を保存してください。',
httpTestFailed: 'テストメッセージの送信に失敗しました:{{error}}',
logsTitle: 'ボットログ',
logsDescription:
'ボットの活動を監視して、プラットフォーム接続が正常に動作していることを確認します。',
@@ -1752,6 +1771,50 @@ 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: 'ボット情報',
+50
View File
@@ -1749,12 +1749,62 @@ 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: '机器人信息',
+58 -4
View File
@@ -69,7 +69,55 @@ test('loads a Cloud plugin page through the authenticated asset route', async ({
await route.fulfill({
status: 200,
contentType: 'text/html',
body: '<!doctype html><html><body><h1>LangRAG Observability</h1></body></html>',
body: `<!doctype html>
<html>
<body>
<h1>LangRAG Observability</h1>
<button id="save">Save</button>
<script src="/api/v1/plugins/_sdk/page-sdk.js"></script>
<script>
document.querySelector('#save').addEventListener('click', async () => {
await window.langbot.api('/settings', { enabled: true }, 'POST');
document.body.dataset.saved = 'true';
});
</script>
</body>
</html>`,
});
},
);
let pageSdkRequests = 0;
await page.route('**/api/v1/plugins/_sdk/page-sdk.js', async (route) => {
pageSdkRequests += 1;
await route.fulfill({
status: 200,
contentType: 'application/javascript',
body: `window.langbot = {
api(endpoint, body, method) {
return new Promise((resolve) => {
const requestId = 'request-' + Date.now();
const handler = (event) => {
if (event.data?.type === 'langbot:api:response' && event.data.requestId === requestId) {
window.removeEventListener('message', handler);
resolve(event.data.data);
}
};
window.addEventListener('message', handler);
window.parent.postMessage({ type: 'langbot:api', requestId, endpoint, body, method }, '*');
});
},
};`,
});
});
let pageApiRequests = 0;
await page.route(
'**/api/v1/plugins/langbot-team/LangRAG/page-api',
async (route) => {
pageApiRequests += 1;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: wrapped({ saved: true }),
});
},
);
@@ -78,11 +126,17 @@ test('loads a Cloud plugin page through the authenticated asset route', async ({
'/home/plugin-pages?id=langbot-team%2FLangRAG%2Fobservability',
);
const pluginFrame = page.frameLocator('iframe');
await expect(
page
.frameLocator('iframe')
.getByRole('heading', { name: 'LangRAG Observability' }),
pluginFrame.getByRole('heading', { name: 'LangRAG Observability' }),
).toBeVisible();
await pluginFrame.getByRole('button', { name: 'Save' }).click();
await expect(pluginFrame.locator('body')).toHaveAttribute(
'data-saved',
'true',
);
expect(authenticatedAssetRequests).toBeGreaterThan(0);
expect(pageSdkRequests).toBe(1);
expect(pageApiRequests).toBe(1);
await expect(page.getByText('Loading...')).toHaveCount(0);
});
+121
View File
@@ -0,0 +1,121 @@
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);
});
+33
View File
@@ -0,0 +1,33 @@
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/);
});
+34 -13
View File
@@ -1,18 +1,39 @@
import { defineConfig } from 'vite';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
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'),
},
},
},
server: {
port: 3000,
},
build: {
outDir: 'dist',
},
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',
},
};
});