mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-22 02:07:13 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a4fadc375 | |||
| f2ba540ffb | |||
| 97b176aef2 | |||
| 7c387f75e1 | |||
| b0566f4c9d |
@@ -113,6 +113,24 @@ class BotsRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
return self.success(data={'sent': True})
|
return self.success(data={'sent': True})
|
||||||
|
|
||||||
|
@self.route(
|
||||||
|
'/<bot_uuid>/test-inbound',
|
||||||
|
methods=['POST'],
|
||||||
|
auth_type=group.AuthType.USER_TOKEN,
|
||||||
|
permission=Permission.RESOURCE_MANAGE,
|
||||||
|
)
|
||||||
|
async def _(bot_uuid: str, request_context: RequestContext) -> str:
|
||||||
|
json_data = await quart.request.get_json(silent=True) or {}
|
||||||
|
try:
|
||||||
|
result = await self.ap.bot_service.send_http_bot_test_message(
|
||||||
|
request_context,
|
||||||
|
bot_uuid,
|
||||||
|
str(json_data.get('message') or ''),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return self.http_status(400, -1, str(exc))
|
||||||
|
return self.success(data=result)
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
'/<bot_uuid>/admins',
|
'/<bot_uuid>/admins',
|
||||||
methods=['GET'],
|
methods=['GET'],
|
||||||
|
|||||||
@@ -206,6 +206,20 @@ class SystemRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
return self.success(data={})
|
return self.success(data={})
|
||||||
|
|
||||||
|
@self.route(
|
||||||
|
'/wizard/recommended-model',
|
||||||
|
methods=['GET'],
|
||||||
|
auth_type=group.AuthType.USER_TOKEN,
|
||||||
|
permission=Permission.RESOURCE_MANAGE,
|
||||||
|
)
|
||||||
|
async def _(request_context: RequestContext) -> str:
|
||||||
|
"""Resolve Space's best available chat model to this Workspace."""
|
||||||
|
try:
|
||||||
|
model = await self.ap.space_service.get_recommended_chat_model(request_context)
|
||||||
|
except ValueError as exc:
|
||||||
|
return self.http_status(503, -1, str(exc))
|
||||||
|
return self.success(data=model)
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
'/tasks',
|
'/tasks',
|
||||||
methods=['GET'],
|
methods=['GET'],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
import json
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
|
||||||
from ....core import app
|
from ....core import app
|
||||||
@@ -8,6 +9,8 @@ from ....entity.persistence import bot as persistence_bot
|
|||||||
from ....entity.persistence import pipeline as persistence_pipeline
|
from ....entity.persistence import pipeline as persistence_pipeline
|
||||||
from ....workspace.errors import WorkspaceNotFoundError
|
from ....workspace.errors import WorkspaceNotFoundError
|
||||||
from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
||||||
|
from ....utils import httpclient
|
||||||
|
from ....platform.sources import http_bot_signing
|
||||||
|
|
||||||
|
|
||||||
class BotService:
|
class BotService:
|
||||||
@@ -80,6 +83,7 @@ class BotService:
|
|||||||
'wecomcs',
|
'wecomcs',
|
||||||
'LINE',
|
'LINE',
|
||||||
'lark',
|
'lark',
|
||||||
|
'http_bot',
|
||||||
]:
|
]:
|
||||||
webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
|
webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
|
||||||
extra_webhook_prefix = self.ap.instance_config.data['api'].get('extra_webhook_prefix', '')
|
extra_webhook_prefix = self.ap.instance_config.data['api'].get('extra_webhook_prefix', '')
|
||||||
@@ -216,6 +220,53 @@ class BotService:
|
|||||||
|
|
||||||
return [log.to_json() for log in logs], total_count
|
return [log.to_json() for log in logs], total_count
|
||||||
|
|
||||||
|
async def send_http_bot_test_message(
|
||||||
|
self,
|
||||||
|
context: TenantContext,
|
||||||
|
bot_uuid: str,
|
||||||
|
message: str,
|
||||||
|
) -> dict:
|
||||||
|
"""Send a signed test message through the HTTP Bot public ingress."""
|
||||||
|
bot = await self.get_bot(context, bot_uuid, include_secret=True)
|
||||||
|
if bot is None:
|
||||||
|
raise WorkspaceNotFoundError('Bot not found')
|
||||||
|
if bot.get('adapter') != 'http_bot':
|
||||||
|
raise ValueError('Inbound test is only available for HTTP Bot')
|
||||||
|
if not bot.get('enable'):
|
||||||
|
raise ValueError('Bot must be enabled before sending a test message')
|
||||||
|
|
||||||
|
text = message.strip()
|
||||||
|
if not text or len(text) > 2000:
|
||||||
|
raise ValueError('Test message must contain 1 to 2000 characters')
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'session_id': f'wizard-{uuid.uuid4().hex}',
|
||||||
|
'sender': {'id': 'wizard-user', 'name': 'Wizard Test'},
|
||||||
|
'message': [{'type': 'Plain', 'text': text}],
|
||||||
|
}
|
||||||
|
body = json.dumps(payload, ensure_ascii=False, separators=(',', ':')).encode()
|
||||||
|
config = bot.get('adapter_config') or {}
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
if config.get('signature_required', True):
|
||||||
|
secret = str(config.get('inbound_secret') or '')
|
||||||
|
if not secret:
|
||||||
|
raise ValueError('HTTP Bot inbound signing secret is required')
|
||||||
|
timestamp, signature = http_bot_signing.sign(secret, body)
|
||||||
|
headers[http_bot_signing.HEADER_TIMESTAMP] = timestamp
|
||||||
|
headers[http_bot_signing.HEADER_SIGNATURE] = signature
|
||||||
|
|
||||||
|
port = int(self.ap.instance_config.data.get('api', {}).get('port', 5300))
|
||||||
|
session = httpclient.get_session()
|
||||||
|
async with session.post(
|
||||||
|
f'http://127.0.0.1:{port}/bots/{bot_uuid}',
|
||||||
|
data=body,
|
||||||
|
headers=headers,
|
||||||
|
) as response:
|
||||||
|
result = await httpclient.read_json_limited(response)
|
||||||
|
if response.status not in {200, 202}:
|
||||||
|
raise ValueError(result.get('msg') or f'HTTP Bot test failed with status {response.status}')
|
||||||
|
return result.get('data') or {}
|
||||||
|
|
||||||
async def send_message(
|
async def send_message(
|
||||||
self,
|
self,
|
||||||
context: TenantContext,
|
context: TenantContext,
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import sqlalchemy
|
|||||||
from ....core import app
|
from ....core import app
|
||||||
from ....entity.persistence import user
|
from ....entity.persistence import user
|
||||||
from ....entity.dto.space_model import SpaceModel
|
from ....entity.dto.space_model import SpaceModel
|
||||||
|
from ....entity.dto.space_model import SpaceModelSelection
|
||||||
|
from ....entity.persistence import model as persistence_model
|
||||||
|
from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER
|
||||||
|
|
||||||
|
|
||||||
_CREDITS_CACHE_TTL_SECONDS = 60
|
_CREDITS_CACHE_TTL_SECONDS = 60
|
||||||
@@ -238,3 +241,79 @@ class SpaceService:
|
|||||||
raise ValueError(f'Failed to get models: {data.get("msg")}')
|
raise ValueError(f'Failed to get models: {data.get("msg")}')
|
||||||
models_data = data.get('data', {}).get('models', [])
|
models_data = data.get('data', {}).get('models', [])
|
||||||
return [SpaceModel.model_validate(model_dict) for model_dict in models_data]
|
return [SpaceModel.model_validate(model_dict) for model_dict in models_data]
|
||||||
|
|
||||||
|
async def get_model_selection(self, category: str) -> typing.List[SpaceModelSelection]:
|
||||||
|
"""Return Space models in the availability-ranked selection order."""
|
||||||
|
space_url = self._get_space_config()['url']
|
||||||
|
session = httpclient.get_session()
|
||||||
|
async with session.get(
|
||||||
|
f'{space_url}/api/v1/models/selection',
|
||||||
|
params={'category': category},
|
||||||
|
) as response:
|
||||||
|
if response.status != 200:
|
||||||
|
error = await httpclient.read_text_limited(response)
|
||||||
|
raise ValueError(f'Failed to get model selection: {error}')
|
||||||
|
payload = await httpclient.read_json_limited(response)
|
||||||
|
if payload.get('code') != 0:
|
||||||
|
raise ValueError(f'Failed to get model selection: {payload.get("msg")}')
|
||||||
|
|
||||||
|
data = payload.get('data', [])
|
||||||
|
if isinstance(data, dict):
|
||||||
|
data = data.get('models', data.get('items', []))
|
||||||
|
if not isinstance(data, list):
|
||||||
|
raise ValueError('Failed to get model selection: invalid response')
|
||||||
|
|
||||||
|
models = []
|
||||||
|
for selection in data:
|
||||||
|
if isinstance(selection, dict) and isinstance(selection.get('model'), dict):
|
||||||
|
models.append(selection['model'])
|
||||||
|
else:
|
||||||
|
models.append(selection)
|
||||||
|
return [SpaceModelSelection.model_validate(model) for model in models]
|
||||||
|
|
||||||
|
async def get_recommended_chat_model(self, context: typing.Any) -> dict:
|
||||||
|
"""Resolve Space's first ranked chat model to a local Workspace model."""
|
||||||
|
selection = await self.get_model_selection('chat')
|
||||||
|
if not selection:
|
||||||
|
raise ValueError('No recommended chat model is available')
|
||||||
|
recommended = selection[0]
|
||||||
|
|
||||||
|
async def find_local_model():
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_model.LLMModel)
|
||||||
|
.join(
|
||||||
|
persistence_model.ModelProvider,
|
||||||
|
sqlalchemy.and_(
|
||||||
|
persistence_model.ModelProvider.workspace_uuid
|
||||||
|
== persistence_model.LLMModel.workspace_uuid,
|
||||||
|
persistence_model.ModelProvider.uuid == persistence_model.LLMModel.provider_uuid,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
persistence_model.LLMModel.workspace_uuid == context.workspace_uuid,
|
||||||
|
persistence_model.ModelProvider.requester == LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||||
|
sqlalchemy.or_(
|
||||||
|
persistence_model.LLMModel.uuid == recommended.uuid,
|
||||||
|
persistence_model.LLMModel.name == recommended.model_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.first()
|
||||||
|
|
||||||
|
local_model = await find_local_model()
|
||||||
|
if local_model is None:
|
||||||
|
# OSS synchronizes the public catalog locally. Refresh once in case
|
||||||
|
# the recommendation was published after this process started.
|
||||||
|
from ..context import ExecutionContext
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.ap.model_mgr.sync_new_models_from_space(
|
||||||
|
ExecutionContext.from_request(context)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
local_model = await find_local_model()
|
||||||
|
|
||||||
|
if local_model is None:
|
||||||
|
raise ValueError('Recommended chat model is not available in this Workspace')
|
||||||
|
return {'uuid': local_model.uuid, 'name': local_model.name}
|
||||||
|
|||||||
@@ -47,3 +47,10 @@ class SpaceModel(pydantic.BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
created_at: str | None = None
|
created_at: str | None = None
|
||||||
updated_at: str | None = None
|
updated_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SpaceModelSelection(pydantic.BaseModel):
|
||||||
|
"""Minimal model identity returned by the ranked selection endpoint."""
|
||||||
|
|
||||||
|
uuid: str
|
||||||
|
model_id: str
|
||||||
|
|||||||
@@ -1240,6 +1240,14 @@
|
|||||||
// Root container
|
// Root container
|
||||||
var root = document.createElement("div");
|
var root = document.createElement("div");
|
||||||
root.id = "langbot-widget-root";
|
root.id = "langbot-widget-root";
|
||||||
|
root.langbotDestroy = function () {
|
||||||
|
wsDisconnect();
|
||||||
|
if (state.historyReloadTimer) {
|
||||||
|
clearTimeout(state.historyReloadTimer);
|
||||||
|
state.historyReloadTimer = null;
|
||||||
|
}
|
||||||
|
root.remove();
|
||||||
|
};
|
||||||
document.body.appendChild(root);
|
document.body.appendChild(root);
|
||||||
|
|
||||||
var shadow = root.attachShadow({ mode: "open" });
|
var shadow = root.attachShadow({ mode: "open" });
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ Source: src/langbot/pkg/api/http/service/bot.py
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import AsyncMock, Mock, patch
|
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from langbot.pkg.api.http.service.bot import BotService
|
from langbot.pkg.api.http.service.bot import BotService
|
||||||
@@ -241,6 +242,29 @@ class TestBotServiceGetRuntimeBotInfo:
|
|||||||
assert result['adapter_runtime_values']['webhook_url'] == '/bots/wecom-uuid'
|
assert result['adapter_runtime_values']['webhook_url'] == '/bots/wecom-uuid'
|
||||||
assert result['adapter_runtime_values']['webhook_full_url'] == 'http://127.0.0.1:5300/bots/wecom-uuid'
|
assert result['adapter_runtime_values']['webhook_full_url'] == 'http://127.0.0.1:5300/bots/wecom-uuid'
|
||||||
|
|
||||||
|
async def test_get_runtime_bot_info_returns_webhook_for_http_bot(self):
|
||||||
|
ap = SimpleNamespace(
|
||||||
|
instance_config=SimpleNamespace(
|
||||||
|
data={'api': {'webhook_prefix': 'https://bot.example.com'}}
|
||||||
|
),
|
||||||
|
platform_mgr=SimpleNamespace(get_bot_by_uuid=AsyncMock(return_value=None)),
|
||||||
|
)
|
||||||
|
service = BotService(ap)
|
||||||
|
service.get_bot = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
'uuid': 'http-bot-uuid',
|
||||||
|
'name': 'HTTP Bot',
|
||||||
|
'adapter': 'http_bot',
|
||||||
|
'adapter_config': {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await service.get_runtime_bot_info(WORKSPACE_UUID, 'http-bot-uuid')
|
||||||
|
|
||||||
|
assert result['adapter_runtime_values']['webhook_full_url'] == (
|
||||||
|
'https://bot.example.com/bots/http-bot-uuid'
|
||||||
|
)
|
||||||
|
|
||||||
async def test_get_runtime_bot_info_no_webhook_for_telegram(self):
|
async def test_get_runtime_bot_info_no_webhook_for_telegram(self):
|
||||||
"""Returns no webhook URL for non-webhook adapters like telegram."""
|
"""Returns no webhook URL for non-webhook adapters like telegram."""
|
||||||
# Setup
|
# Setup
|
||||||
@@ -605,6 +629,77 @@ class TestBotServiceListEventLogs:
|
|||||||
assert total == 5
|
assert total == 5
|
||||||
|
|
||||||
|
|
||||||
|
class TestBotServiceHttpBotInboundTest:
|
||||||
|
async def test_sends_signed_message_through_public_ingress(self):
|
||||||
|
ap = SimpleNamespace(
|
||||||
|
instance_config=SimpleNamespace(data={'api': {'port': 5300}}),
|
||||||
|
)
|
||||||
|
service = BotService(ap)
|
||||||
|
service.get_bot = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
'uuid': 'http-bot-uuid',
|
||||||
|
'adapter': 'http_bot',
|
||||||
|
'adapter_config': {
|
||||||
|
'signature_required': True,
|
||||||
|
'inbound_secret': 'test-secret',
|
||||||
|
},
|
||||||
|
'enable': True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response = MagicMock(status=202)
|
||||||
|
session = MagicMock()
|
||||||
|
session.post.return_value.__aenter__ = AsyncMock(return_value=response)
|
||||||
|
session.post.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch('langbot.pkg.api.http.service.bot.httpclient.get_session', return_value=session),
|
||||||
|
patch(
|
||||||
|
'langbot.pkg.api.http.service.bot.httpclient.read_json_limited',
|
||||||
|
new=AsyncMock(
|
||||||
|
return_value={
|
||||||
|
'code': 0,
|
||||||
|
'data': {
|
||||||
|
'session_id': 'wizard-session',
|
||||||
|
'accepted_message_id': 'in-message',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = await service.send_http_bot_test_message(
|
||||||
|
WORKSPACE_UUID,
|
||||||
|
'http-bot-uuid',
|
||||||
|
'hello',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result['accepted_message_id'] == 'in-message'
|
||||||
|
request = session.post.call_args
|
||||||
|
assert request.args[0] == 'http://127.0.0.1:5300/bots/http-bot-uuid'
|
||||||
|
payload = json.loads(request.kwargs['data'])
|
||||||
|
assert payload['message'] == [{'type': 'Plain', 'text': 'hello'}]
|
||||||
|
headers = request.kwargs['headers']
|
||||||
|
assert headers['X-LB-Timestamp']
|
||||||
|
assert headers['X-LB-Signature'].startswith('sha256=')
|
||||||
|
|
||||||
|
async def test_rejects_non_http_bot(self):
|
||||||
|
service = BotService(SimpleNamespace())
|
||||||
|
service.get_bot = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
'uuid': 'telegram-bot',
|
||||||
|
'adapter': 'telegram',
|
||||||
|
'adapter_config': {},
|
||||||
|
'enable': True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match='only available for HTTP Bot'):
|
||||||
|
await service.send_http_bot_test_message(
|
||||||
|
WORKSPACE_UUID,
|
||||||
|
'telegram-bot',
|
||||||
|
'hello',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestBotServiceSendMessage:
|
class TestBotServiceSendMessage:
|
||||||
"""Tests for send_message method."""
|
"""Tests for send_message method."""
|
||||||
|
|
||||||
|
|||||||
@@ -820,6 +820,100 @@ class TestSpaceServiceGetModels:
|
|||||||
await service.get_models()
|
await service.get_models()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSpaceServiceGetModelSelection:
|
||||||
|
"""Tests for availability-ranked model selection."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('response_shape', ['direct', 'models-envelope', 'availability-wrapper'])
|
||||||
|
async def test_preserves_selection_order_and_category_query(self, response_shape):
|
||||||
|
ap = SimpleNamespace(instance_config=SimpleNamespace(data={}))
|
||||||
|
service = SpaceService(ap)
|
||||||
|
models = [
|
||||||
|
{
|
||||||
|
'uuid': 'best-model',
|
||||||
|
'model_id': 'best-chat-model',
|
||||||
|
'provider': 'provider-1',
|
||||||
|
'category': 'chat',
|
||||||
|
'status': 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'uuid': 'fallback-model',
|
||||||
|
'model_id': 'fallback-chat-model',
|
||||||
|
'provider': 'provider-2',
|
||||||
|
'category': 'chat',
|
||||||
|
'status': 'active',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if response_shape == 'models-envelope':
|
||||||
|
data = {'models': models}
|
||||||
|
elif response_shape == 'availability-wrapper':
|
||||||
|
data = [
|
||||||
|
{'model': model, 'latency_ms': index + 10, 'http_code': 200}
|
||||||
|
for index, model in enumerate(models)
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
data = models
|
||||||
|
payload = {'code': 0, 'data': data}
|
||||||
|
mock_response = MagicMock(status=200)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch('langbot.pkg.api.http.service.space.httpclient.get_session') as get_session,
|
||||||
|
patch(
|
||||||
|
'langbot.pkg.api.http.service.space.httpclient.read_json_limited',
|
||||||
|
new=AsyncMock(return_value=payload),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
session = MagicMock()
|
||||||
|
session.get.return_value.__aenter__ = AsyncMock(return_value=mock_response)
|
||||||
|
session.get.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
get_session.return_value = session
|
||||||
|
|
||||||
|
result = await service.get_model_selection('chat')
|
||||||
|
|
||||||
|
assert [model.uuid for model in result] == ['best-model', 'fallback-model']
|
||||||
|
session.get.assert_called_once_with(
|
||||||
|
'https://space.langbot.app/api/v1/models/selection',
|
||||||
|
params={'category': 'chat'},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_recommended_model_uses_first_selection_and_refreshes_once(self):
|
||||||
|
local_model = SimpleNamespace(uuid='local-model-uuid', name='best-chat-model')
|
||||||
|
persistence = SimpleNamespace(
|
||||||
|
execute_async=AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
_create_mock_result(first_item=None),
|
||||||
|
_create_mock_result(first_item=local_model),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
model_mgr = SimpleNamespace(sync_new_models_from_space=AsyncMock())
|
||||||
|
ap = SimpleNamespace(
|
||||||
|
instance_config=SimpleNamespace(data={}),
|
||||||
|
persistence_mgr=persistence,
|
||||||
|
model_mgr=model_mgr,
|
||||||
|
)
|
||||||
|
service = SpaceService(ap)
|
||||||
|
service.get_model_selection = AsyncMock(
|
||||||
|
return_value=[
|
||||||
|
SimpleNamespace(uuid='best-upstream-uuid', model_id='best-chat-model'),
|
||||||
|
SimpleNamespace(uuid='fallback-upstream-uuid', model_id='fallback-chat-model'),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
context = SimpleNamespace(
|
||||||
|
instance_uuid='instance',
|
||||||
|
workspace_uuid='workspace',
|
||||||
|
placement_generation=1,
|
||||||
|
principal=SimpleNamespace(),
|
||||||
|
entitlement_revision=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await service.get_recommended_chat_model(context)
|
||||||
|
|
||||||
|
assert result == {'uuid': 'local-model-uuid', 'name': 'best-chat-model'}
|
||||||
|
service.get_model_selection.assert_awaited_once_with('chat')
|
||||||
|
model_mgr.sync_new_models_from_space.assert_awaited_once()
|
||||||
|
assert persistence.execute_async.await_count == 2
|
||||||
|
|
||||||
|
|
||||||
class TestSpaceServiceCreditsCache:
|
class TestSpaceServiceCreditsCache:
|
||||||
"""Tests for credits cache behavior."""
|
"""Tests for credits cache behavior."""
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -1 +1,5 @@
|
|||||||
VITE_API_BASE_URL=http://localhost:5300
|
# Leave empty in development to use Vite's same-origin proxy. This keeps API,
|
||||||
|
# login, and WebSocket requests working when the UI is opened from another
|
||||||
|
# device on the local network.
|
||||||
|
VITE_API_BASE_URL=
|
||||||
|
VITE_API_PROXY_TARGET=http://127.0.0.1:5300
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export function BotLogListComponent({
|
|||||||
autoExpandImages = false,
|
autoExpandImages = false,
|
||||||
hideDetailedLogsLink = false,
|
hideDetailedLogsLink = false,
|
||||||
hideToolbar = false,
|
hideToolbar = false,
|
||||||
|
onMessageReceived,
|
||||||
}: {
|
}: {
|
||||||
botId: string;
|
botId: string;
|
||||||
/** When true, log entries with images are rendered expanded by default */
|
/** When true, log entries with images are rendered expanded by default */
|
||||||
@@ -28,6 +29,8 @@ export function BotLogListComponent({
|
|||||||
hideDetailedLogsLink?: boolean;
|
hideDetailedLogsLink?: boolean;
|
||||||
/** When true, hides the entire toolbar (auto-refresh, level filter, detailed logs link) */
|
/** When true, hides the entire toolbar (auto-refresh, level filter, detailed logs link) */
|
||||||
hideToolbar?: boolean;
|
hideToolbar?: boolean;
|
||||||
|
/** Called after an inbound person/group message appears in the bot log. */
|
||||||
|
onMessageReceived?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -41,6 +44,8 @@ export function BotLogListComponent({
|
|||||||
]);
|
]);
|
||||||
const listContainerRef = useRef<HTMLDivElement>(null);
|
const listContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const botLogListRef = useRef<BotLog[]>(botLogList);
|
const botLogListRef = useRef<BotLog[]>(botLogList);
|
||||||
|
const onMessageReceivedRef = useRef(onMessageReceived);
|
||||||
|
onMessageReceivedRef.current = onMessageReceived;
|
||||||
|
|
||||||
const logLevels = [
|
const logLevels = [
|
||||||
{ value: 'error', label: 'ERROR' },
|
{ value: 'error', label: 'ERROR' },
|
||||||
@@ -108,6 +113,9 @@ export function BotLogListComponent({
|
|||||||
manager.subscribeLogPush(handleBotLogPush);
|
manager.subscribeLogPush(handleBotLogPush);
|
||||||
manager.loadFirstPage().then((response) => {
|
manager.loadFirstPage().then((response) => {
|
||||||
setBotLogList(response.reverse());
|
setBotLogList(response.reverse());
|
||||||
|
if (response.some((log) => Boolean(log.message_session_id))) {
|
||||||
|
onMessageReceivedRef.current?.();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
listenScroll();
|
listenScroll();
|
||||||
}
|
}
|
||||||
@@ -138,6 +146,9 @@ export function BotLogListComponent({
|
|||||||
|
|
||||||
function handleBotLogPush(response: BotLog[]) {
|
function handleBotLogPush(response: BotLog[]) {
|
||||||
setBotLogList(response.reverse());
|
setBotLogList(response.reverse());
|
||||||
|
if (response.some((log) => Boolean(log.message_session_id))) {
|
||||||
|
onMessageReceivedRef.current?.();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleScroll = useCallback(
|
const handleScroll = useCallback(
|
||||||
|
|||||||
@@ -363,7 +363,9 @@ export interface WizardProgress {
|
|||||||
step: number;
|
step: number;
|
||||||
selected_adapter: string | null;
|
selected_adapter: string | null;
|
||||||
created_bot_uuid: string | null;
|
created_bot_uuid: string | null;
|
||||||
|
created_pipeline_uuid?: string | null;
|
||||||
bot_saved: boolean;
|
bot_saved: boolean;
|
||||||
|
message_received?: boolean;
|
||||||
selected_runner: string | null;
|
selected_runner: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -461,6 +461,15 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
return this.post(`/api/v1/platform/bots/${botId}/logs`, request);
|
return this.post(`/api/v1/platform/bots/${botId}/logs`, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public testHttpBotInbound(
|
||||||
|
botId: string,
|
||||||
|
message: string,
|
||||||
|
): Promise<{ session_id: string; accepted_message_id: string }> {
|
||||||
|
return this.post(`/api/v1/platform/bots/${botId}/test-inbound`, {
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public getBotSessions(
|
public getBotSessions(
|
||||||
botId: string,
|
botId: string,
|
||||||
limit: number = 100,
|
limit: number = 100,
|
||||||
@@ -1046,12 +1055,21 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
step: number;
|
step: number;
|
||||||
selected_adapter: string | null;
|
selected_adapter: string | null;
|
||||||
created_bot_uuid: string | null;
|
created_bot_uuid: string | null;
|
||||||
|
created_pipeline_uuid?: string | null;
|
||||||
bot_saved: boolean;
|
bot_saved: boolean;
|
||||||
|
message_received?: boolean;
|
||||||
selected_runner: string | null;
|
selected_runner: string | null;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
return this.put('/api/v1/system/wizard/progress', progress);
|
return this.put('/api/v1/system/wizard/progress', progress);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getWizardRecommendedModel(): Promise<{
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
}> {
|
||||||
|
return this.get('/api/v1/system/wizard/recommended-model');
|
||||||
|
}
|
||||||
|
|
||||||
public getAsyncTasks(params?: {
|
public getAsyncTasks(params?: {
|
||||||
type?: string;
|
type?: string;
|
||||||
kind?: string;
|
kind?: string;
|
||||||
|
|||||||
+412
-152
@@ -8,10 +8,16 @@ import {
|
|||||||
ArrowRight,
|
ArrowRight,
|
||||||
Check,
|
Check,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
PartyPopper,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
X,
|
X,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
Cable,
|
||||||
|
Settings2,
|
||||||
|
Blocks,
|
||||||
|
Copy,
|
||||||
|
Send,
|
||||||
|
Webhook,
|
||||||
|
MessageSquare,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
@@ -21,12 +27,7 @@ import {
|
|||||||
bootstrapWorkspaceSession,
|
bootstrapWorkspaceSession,
|
||||||
initializeSystemInfo,
|
initializeSystemInfo,
|
||||||
} from '@/app/infra/http';
|
} from '@/app/infra/http';
|
||||||
import {
|
import { Adapter, Bot, WizardProgress } from '@/app/infra/entities/api';
|
||||||
Adapter,
|
|
||||||
Bot,
|
|
||||||
Pipeline,
|
|
||||||
WizardProgress,
|
|
||||||
} from '@/app/infra/entities/api';
|
|
||||||
import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
|
import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
|
||||||
import {
|
import {
|
||||||
PipelineConfigTab,
|
PipelineConfigTab,
|
||||||
@@ -48,6 +49,7 @@ import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
|
|||||||
import i18n from 'i18next';
|
import i18n from 'i18next';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -71,7 +73,7 @@ import {
|
|||||||
// Types
|
// Types
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const TOTAL_STEPS = 4;
|
const TOTAL_STEPS = 3;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Main Wizard Page (full-screen, no sidebar)
|
// Main Wizard Page (full-screen, no sidebar)
|
||||||
@@ -93,6 +95,9 @@ export default function WizardPage() {
|
|||||||
);
|
);
|
||||||
const [runnerConfig, setRunnerConfig] = useState<Record<string, unknown>>({});
|
const [runnerConfig, setRunnerConfig] = useState<Record<string, unknown>>({});
|
||||||
const [createdBotUuid, setCreatedBotUuid] = useState<string | null>(null);
|
const [createdBotUuid, setCreatedBotUuid] = useState<string | null>(null);
|
||||||
|
const [createdPipelineUuid, setCreatedPipelineUuid] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [webhookUrl, setWebhookUrl] = useState<string>('');
|
const [webhookUrl, setWebhookUrl] = useState<string>('');
|
||||||
const [extraWebhookUrl, setExtraWebhookUrl] = useState<string>('');
|
const [extraWebhookUrl, setExtraWebhookUrl] = useState<string>('');
|
||||||
|
|
||||||
@@ -106,6 +111,10 @@ export default function WizardPage() {
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [isSavingBot, setIsSavingBot] = useState(false);
|
const [isSavingBot, setIsSavingBot] = useState(false);
|
||||||
const [botSaved, setBotSaved] = useState(false);
|
const [botSaved, setBotSaved] = useState(false);
|
||||||
|
const [messageReceived, setMessageReceived] = useState(false);
|
||||||
|
const [aiChoice, setAiChoice] = useState<
|
||||||
|
'external' | 'own-model' | 'more-features' | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
// ---- Helper: persist wizard progress to backend (fire-and-forget) ----
|
// ---- Helper: persist wizard progress to backend (fire-and-forget) ----
|
||||||
const saveProgress = useCallback(
|
const saveProgress = useCallback(
|
||||||
@@ -114,14 +123,25 @@ export default function WizardPage() {
|
|||||||
step: overrides.step ?? currentStep,
|
step: overrides.step ?? currentStep,
|
||||||
selected_adapter: overrides.selected_adapter ?? selectedAdapter,
|
selected_adapter: overrides.selected_adapter ?? selectedAdapter,
|
||||||
created_bot_uuid: overrides.created_bot_uuid ?? createdBotUuid,
|
created_bot_uuid: overrides.created_bot_uuid ?? createdBotUuid,
|
||||||
|
created_pipeline_uuid:
|
||||||
|
overrides.created_pipeline_uuid ?? createdPipelineUuid,
|
||||||
bot_saved: overrides.bot_saved ?? botSaved,
|
bot_saved: overrides.bot_saved ?? botSaved,
|
||||||
|
message_received: overrides.message_received ?? messageReceived,
|
||||||
selected_runner: overrides.selected_runner ?? selectedRunner,
|
selected_runner: overrides.selected_runner ?? selectedRunner,
|
||||||
};
|
};
|
||||||
httpClient.saveWizardProgress(progress).catch((err) => {
|
httpClient.saveWizardProgress(progress).catch((err) => {
|
||||||
console.error('Failed to save wizard progress', err);
|
console.error('Failed to save wizard progress', err);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[currentStep, selectedAdapter, createdBotUuid, botSaved, selectedRunner],
|
[
|
||||||
|
currentStep,
|
||||||
|
selectedAdapter,
|
||||||
|
createdBotUuid,
|
||||||
|
createdPipelineUuid,
|
||||||
|
botSaved,
|
||||||
|
messageReceived,
|
||||||
|
selectedRunner,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---- Fetch remote data & restore progress ----
|
// ---- Fetch remote data & restore progress ----
|
||||||
@@ -159,7 +179,9 @@ export default function WizardPage() {
|
|||||||
|
|
||||||
setSelectedAdapter(progress.selected_adapter);
|
setSelectedAdapter(progress.selected_adapter);
|
||||||
setCreatedBotUuid(progress.created_bot_uuid);
|
setCreatedBotUuid(progress.created_bot_uuid);
|
||||||
|
setCreatedPipelineUuid(progress.created_pipeline_uuid ?? null);
|
||||||
setBotSaved(progress.bot_saved ?? false);
|
setBotSaved(progress.bot_saved ?? false);
|
||||||
|
setMessageReceived(progress.message_received ?? false);
|
||||||
setSelectedRunner(progress.selected_runner);
|
setSelectedRunner(progress.selected_runner);
|
||||||
|
|
||||||
// Restore bot name from fetched bot data
|
// Restore bot name from fetched bot data
|
||||||
@@ -183,7 +205,9 @@ export default function WizardPage() {
|
|||||||
step: 0,
|
step: 0,
|
||||||
selected_adapter: null,
|
selected_adapter: null,
|
||||||
created_bot_uuid: null,
|
created_bot_uuid: null,
|
||||||
|
created_pipeline_uuid: null,
|
||||||
bot_saved: false,
|
bot_saved: false,
|
||||||
|
message_received: false,
|
||||||
selected_runner: null,
|
selected_runner: null,
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
@@ -211,7 +235,9 @@ export default function WizardPage() {
|
|||||||
const runnerOptions = useMemo(() => {
|
const runnerOptions = useMemo(() => {
|
||||||
if (!runnerStage) return [];
|
if (!runnerStage) return [];
|
||||||
const runnerField = runnerStage.config.find((c) => c.name === 'runner');
|
const runnerField = runnerStage.config.find((c) => c.name === 'runner');
|
||||||
return runnerField?.options ?? [];
|
return (runnerField?.options ?? []).filter(
|
||||||
|
(option) => option.name !== 'local-agent',
|
||||||
|
);
|
||||||
}, [runnerStage]);
|
}, [runnerStage]);
|
||||||
|
|
||||||
const selectedRunnerConfigStage: PipelineConfigStage | undefined =
|
const selectedRunnerConfigStage: PipelineConfigStage | undefined =
|
||||||
@@ -285,13 +311,20 @@ export default function WizardPage() {
|
|||||||
case 0:
|
case 0:
|
||||||
return selectedAdapter !== null;
|
return selectedAdapter !== null;
|
||||||
case 1:
|
case 1:
|
||||||
return createdBotUuid !== null && botSaved;
|
return createdBotUuid !== null && botSaved && messageReceived;
|
||||||
case 2:
|
case 2:
|
||||||
return selectedRunner !== null;
|
return aiChoice !== null;
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [currentStep, selectedAdapter, createdBotUuid, botSaved, selectedRunner]);
|
}, [
|
||||||
|
currentStep,
|
||||||
|
selectedAdapter,
|
||||||
|
createdBotUuid,
|
||||||
|
botSaved,
|
||||||
|
messageReceived,
|
||||||
|
aiChoice,
|
||||||
|
]);
|
||||||
|
|
||||||
const goNext = useCallback(() => {
|
const goNext = useCallback(() => {
|
||||||
if (currentStep < TOTAL_STEPS - 1 && canProceed()) {
|
if (currentStep < TOTAL_STEPS - 1 && canProceed()) {
|
||||||
@@ -360,7 +393,9 @@ export default function WizardPage() {
|
|||||||
step: 1,
|
step: 1,
|
||||||
selected_adapter: selectedAdapter,
|
selected_adapter: selectedAdapter,
|
||||||
created_bot_uuid: resp.uuid,
|
created_bot_uuid: resp.uuid,
|
||||||
|
created_pipeline_uuid: null,
|
||||||
bot_saved: false,
|
bot_saved: false,
|
||||||
|
message_received: false,
|
||||||
selected_runner: null,
|
selected_runner: null,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -374,21 +409,61 @@ export default function WizardPage() {
|
|||||||
}, [selectedAdapter, adapters, t, saveProgress]);
|
}, [selectedAdapter, adapters, t, saveProgress]);
|
||||||
|
|
||||||
// ---- Save Bot Config & Enable (Step 1) ----
|
// ---- Save Bot Config & Enable (Step 1) ----
|
||||||
// Updates the bot's adapter config and enables it.
|
// Creates a recommended Local Agent pipeline, binds it, and enables the bot.
|
||||||
|
|
||||||
const handleSaveBot = useCallback(async () => {
|
const handleSaveBot = useCallback(async () => {
|
||||||
if (!createdBotUuid || !selectedAdapter) return;
|
if (!createdBotUuid || !selectedAdapter) return;
|
||||||
setIsSavingBot(true);
|
setIsSavingBot(true);
|
||||||
|
|
||||||
|
let createdPipelineThisAttempt: string | null = null;
|
||||||
try {
|
try {
|
||||||
|
let pipelineUuid = createdPipelineUuid;
|
||||||
|
if (!pipelineUuid) {
|
||||||
|
const recommendedModel = await httpClient.getWizardRecommendedModel();
|
||||||
|
const pipelineResp = await httpClient.createPipeline({
|
||||||
|
name: `${botName} Agent`,
|
||||||
|
description: botDescription || '',
|
||||||
|
config: {},
|
||||||
|
});
|
||||||
|
pipelineUuid = pipelineResp.uuid;
|
||||||
|
createdPipelineThisAttempt = pipelineUuid;
|
||||||
|
const createdPipeline = await httpClient.getPipeline(pipelineUuid);
|
||||||
|
const aiConfig = createdPipeline.pipeline.config.ai as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
const localAgentConfig = (aiConfig['local-agent'] ?? {}) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
await httpClient.updatePipeline(pipelineUuid, {
|
||||||
|
name: `${botName} Agent`,
|
||||||
|
description: botDescription || '',
|
||||||
|
config: {
|
||||||
|
...createdPipeline.pipeline.config,
|
||||||
|
ai: {
|
||||||
|
...aiConfig,
|
||||||
|
runner: { runner: 'local-agent' },
|
||||||
|
'local-agent': {
|
||||||
|
...localAgentConfig,
|
||||||
|
model: { primary: recommendedModel.uuid, fallbacks: [] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setCreatedPipelineUuid(pipelineUuid);
|
||||||
|
}
|
||||||
|
|
||||||
await httpClient.updateBot(createdBotUuid, {
|
await httpClient.updateBot(createdBotUuid, {
|
||||||
name: botName,
|
name: botName,
|
||||||
description: botDescription || '',
|
description: botDescription || '',
|
||||||
adapter: selectedAdapter,
|
adapter: selectedAdapter,
|
||||||
adapter_config: adapterConfig,
|
adapter_config: adapterConfig,
|
||||||
enable: true,
|
enable: true,
|
||||||
|
use_pipeline_uuid: pipelineUuid,
|
||||||
});
|
});
|
||||||
setBotSaved(true);
|
setBotSaved(true);
|
||||||
|
setMessageReceived(false);
|
||||||
|
|
||||||
// Re-fetch runtime info to get updated webhook URL(s)
|
// Re-fetch runtime info to get updated webhook URL(s)
|
||||||
try {
|
try {
|
||||||
@@ -405,8 +480,19 @@ export default function WizardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Persist progress
|
// Persist progress
|
||||||
saveProgress({ step: 1, bot_saved: true });
|
saveProgress({
|
||||||
|
step: 1,
|
||||||
|
bot_saved: true,
|
||||||
|
message_received: false,
|
||||||
|
created_pipeline_uuid: pipelineUuid,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (createdPipelineThisAttempt) {
|
||||||
|
await httpClient
|
||||||
|
.deletePipeline(createdPipelineThisAttempt)
|
||||||
|
.catch(() => {});
|
||||||
|
setCreatedPipelineUuid(null);
|
||||||
|
}
|
||||||
const apiErr = err as { msg?: string };
|
const apiErr = err as { msg?: string };
|
||||||
toast.error(
|
toast.error(
|
||||||
t('wizard.createError') + (apiErr?.msg ? `: ${apiErr.msg}` : ''),
|
t('wizard.createError') + (apiErr?.msg ? `: ${apiErr.msg}` : ''),
|
||||||
@@ -420,47 +506,55 @@ export default function WizardPage() {
|
|||||||
botName,
|
botName,
|
||||||
botDescription,
|
botDescription,
|
||||||
adapterConfig,
|
adapterConfig,
|
||||||
|
createdPipelineUuid,
|
||||||
t,
|
t,
|
||||||
saveProgress,
|
saveProgress,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ---- Create Pipeline & Link (Step 2 finish) ----
|
const handleMessageReceived = useCallback(() => {
|
||||||
|
if (messageReceived) return;
|
||||||
|
setMessageReceived(true);
|
||||||
|
saveProgress({ step: 1, message_received: true });
|
||||||
|
}, [messageReceived, saveProgress]);
|
||||||
|
|
||||||
|
const completeWizard = useCallback(async () => {
|
||||||
|
await httpClient.updateWizardStatus('completed');
|
||||||
|
systemInfo.wizard_status = 'completed';
|
||||||
|
systemInfo.wizard_progress = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ---- Complete the optional AI Engine step ----
|
||||||
|
|
||||||
const handleFinish = useCallback(async () => {
|
const handleFinish = useCallback(async () => {
|
||||||
if (!selectedRunner || !createdBotUuid) return;
|
if (!aiChoice || !createdBotUuid || !createdPipelineUuid) return;
|
||||||
|
if (aiChoice === 'external' && !selectedRunner) return;
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
let externalPipelineUuid: string | null = null;
|
||||||
|
let externalPipelineBound = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Create pipeline (backend fills config from default template)
|
if (aiChoice === 'external' && selectedRunner) {
|
||||||
const pipeline: Pipeline = {
|
const pipelineResp = await httpClient.createPipeline({
|
||||||
name: `${botName} Pipeline`,
|
name: `${botName} External Agent`,
|
||||||
description: botDescription || '',
|
description: botDescription || '',
|
||||||
config: {},
|
config: {},
|
||||||
};
|
});
|
||||||
const pipelineResp = await httpClient.createPipeline(pipeline);
|
externalPipelineUuid = pipelineResp.uuid;
|
||||||
|
|
||||||
// 2. Fetch the created pipeline to get the full default config
|
|
||||||
// (includes trigger, safety, ai, output sections).
|
|
||||||
// Then merge only the AI section with the wizard's runner config.
|
|
||||||
const createdPipeline = await httpClient.getPipeline(pipelineResp.uuid);
|
const createdPipeline = await httpClient.getPipeline(pipelineResp.uuid);
|
||||||
const fullConfig = createdPipeline.pipeline.config;
|
const fullConfig = createdPipeline.pipeline.config;
|
||||||
|
await httpClient.updatePipeline(pipelineResp.uuid, {
|
||||||
const mergedConfig = {
|
name: `${botName} External Agent`,
|
||||||
|
description: botDescription || '',
|
||||||
|
config: {
|
||||||
...fullConfig,
|
...fullConfig,
|
||||||
ai: {
|
ai: {
|
||||||
...fullConfig.ai,
|
...fullConfig.ai,
|
||||||
runner: { runner: selectedRunner },
|
runner: { runner: selectedRunner },
|
||||||
[selectedRunner]: runnerConfig,
|
[selectedRunner]: runnerConfig,
|
||||||
},
|
},
|
||||||
};
|
},
|
||||||
|
|
||||||
await httpClient.updatePipeline(pipelineResp.uuid, {
|
|
||||||
name: `${botName} Pipeline`,
|
|
||||||
description: botDescription || '',
|
|
||||||
config: mergedConfig,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Link pipeline to the bot created in Step 1
|
|
||||||
const botData = await httpClient.getBot(createdBotUuid);
|
const botData = await httpClient.getBot(createdBotUuid);
|
||||||
const existingBot = botData.bot;
|
const existingBot = botData.bot;
|
||||||
await httpClient.updateBot(createdBotUuid, {
|
await httpClient.updateBot(createdBotUuid, {
|
||||||
@@ -471,9 +565,21 @@ export default function WizardPage() {
|
|||||||
enable: existingBot.enable,
|
enable: existingBot.enable,
|
||||||
use_pipeline_uuid: pipelineResp.uuid,
|
use_pipeline_uuid: pipelineResp.uuid,
|
||||||
});
|
});
|
||||||
|
externalPipelineBound = true;
|
||||||
|
}
|
||||||
|
|
||||||
setCurrentStep(3);
|
await completeWizard();
|
||||||
|
if (aiChoice === 'own-model') {
|
||||||
|
navigate(`/home/pipelines?id=${createdPipelineUuid}`, {
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
navigate('/home', { replace: true });
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (externalPipelineUuid && !externalPipelineBound) {
|
||||||
|
await httpClient.deletePipeline(externalPipelineUuid).catch(() => {});
|
||||||
|
}
|
||||||
const apiErr = err as { msg?: string };
|
const apiErr = err as { msg?: string };
|
||||||
toast.error(
|
toast.error(
|
||||||
t('wizard.createError') + (apiErr?.msg ? `: ${apiErr.msg}` : ''),
|
t('wizard.createError') + (apiErr?.msg ? `: ${apiErr.msg}` : ''),
|
||||||
@@ -484,9 +590,13 @@ export default function WizardPage() {
|
|||||||
}, [
|
}, [
|
||||||
selectedRunner,
|
selectedRunner,
|
||||||
createdBotUuid,
|
createdBotUuid,
|
||||||
|
createdPipelineUuid,
|
||||||
|
aiChoice,
|
||||||
botName,
|
botName,
|
||||||
botDescription,
|
botDescription,
|
||||||
runnerConfig,
|
runnerConfig,
|
||||||
|
completeWizard,
|
||||||
|
navigate,
|
||||||
t,
|
t,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -524,7 +634,9 @@ export default function WizardPage() {
|
|||||||
step: 0,
|
step: 0,
|
||||||
selected_adapter: null,
|
selected_adapter: null,
|
||||||
created_bot_uuid: null,
|
created_bot_uuid: null,
|
||||||
|
created_pipeline_uuid: null,
|
||||||
bot_saved: false,
|
bot_saved: false,
|
||||||
|
message_received: false,
|
||||||
selected_runner: null,
|
selected_runner: null,
|
||||||
});
|
});
|
||||||
systemInfo.wizard_progress = null;
|
systemInfo.wizard_progress = null;
|
||||||
@@ -552,7 +664,6 @@ export default function WizardPage() {
|
|||||||
t('wizard.step.platform'),
|
t('wizard.step.platform'),
|
||||||
t('wizard.step.botConfig'),
|
t('wizard.step.botConfig'),
|
||||||
t('wizard.step.aiEngine'),
|
t('wizard.step.aiEngine'),
|
||||||
t('wizard.step.done'),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -567,7 +678,7 @@ export default function WizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<LanguageSelector />
|
<LanguageSelector />
|
||||||
{currentStep < 3 && (
|
{currentStep < TOTAL_STEPS && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -652,6 +763,8 @@ export default function WizardPage() {
|
|||||||
createdBotUuid={createdBotUuid}
|
createdBotUuid={createdBotUuid}
|
||||||
isSavingBot={isSavingBot}
|
isSavingBot={isSavingBot}
|
||||||
botSaved={botSaved}
|
botSaved={botSaved}
|
||||||
|
messageReceived={messageReceived}
|
||||||
|
onMessageReceived={handleMessageReceived}
|
||||||
onSaveBot={handleSaveBot}
|
onSaveBot={handleSaveBot}
|
||||||
webhookUrl={webhookUrl}
|
webhookUrl={webhookUrl}
|
||||||
extraWebhookUrl={extraWebhookUrl}
|
extraWebhookUrl={extraWebhookUrl}
|
||||||
@@ -660,6 +773,8 @@ export default function WizardPage() {
|
|||||||
{currentStep === 2 && (
|
{currentStep === 2 && (
|
||||||
<StepAIEngine
|
<StepAIEngine
|
||||||
runnerOptions={runnerOptions}
|
runnerOptions={runnerOptions}
|
||||||
|
choice={aiChoice}
|
||||||
|
onChoiceChange={setAiChoice}
|
||||||
selected={selectedRunner}
|
selected={selectedRunner}
|
||||||
onSelect={handleSelectRunner}
|
onSelect={handleSelectRunner}
|
||||||
isLocalAccount={isLocalAccount}
|
isLocalAccount={isLocalAccount}
|
||||||
@@ -669,11 +784,10 @@ export default function WizardPage() {
|
|||||||
onRunnerConfigChange={setRunnerConfig}
|
onRunnerConfigChange={setRunnerConfig}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{currentStep === 3 && <StepDone />}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer navigation */}
|
{/* Footer navigation */}
|
||||||
{currentStep < 3 && (
|
{currentStep < TOTAL_STEPS && (
|
||||||
<div className="shrink-0 flex justify-between items-center px-4 sm:px-6 py-3 sm:py-4 border-t">
|
<div className="shrink-0 flex justify-between items-center px-4 sm:px-6 py-3 sm:py-4 border-t">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -703,12 +817,20 @@ export default function WizardPage() {
|
|||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleFinish}
|
onClick={handleFinish}
|
||||||
disabled={!canProceed() || isSubmitting}
|
disabled={
|
||||||
|
!canProceed() ||
|
||||||
|
isSubmitting ||
|
||||||
|
(aiChoice === 'external' && !selectedRunner)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isSubmitting && (
|
{isSubmitting && (
|
||||||
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
|
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
|
||||||
)}
|
)}
|
||||||
{t('wizard.finish')}
|
{aiChoice === 'external'
|
||||||
|
? t('wizard.aiEngine.createExternal')
|
||||||
|
: aiChoice === 'own-model'
|
||||||
|
? t('wizard.aiEngine.configurePipeline')
|
||||||
|
: t('wizard.aiEngine.openWorkbench')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -849,6 +971,35 @@ function StepPlatform({
|
|||||||
// Step 1: Bot Configuration + Logs
|
// Step 1: Bot Configuration + Logs
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function PageBotFloatingWidget({
|
||||||
|
botUuid,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
botUuid: string;
|
||||||
|
title?: string;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = `${window.location.origin}/api/v1/embed/${botUuid}/widget.js`;
|
||||||
|
script.dataset.title = title || 'LangBot';
|
||||||
|
document.body.appendChild(script);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
script.remove();
|
||||||
|
const root = document.getElementById('langbot-widget-root') as
|
||||||
|
| (HTMLElement & { langbotDestroy?: () => void })
|
||||||
|
| null;
|
||||||
|
if (root?.langbotDestroy) {
|
||||||
|
root.langbotDestroy();
|
||||||
|
} else {
|
||||||
|
root?.remove();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [botUuid, title]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function StepBotConfig({
|
function StepBotConfig({
|
||||||
adapterConfigItems,
|
adapterConfigItems,
|
||||||
adapterConfigValues,
|
adapterConfigValues,
|
||||||
@@ -858,6 +1009,8 @@ function StepBotConfig({
|
|||||||
createdBotUuid,
|
createdBotUuid,
|
||||||
isSavingBot,
|
isSavingBot,
|
||||||
botSaved,
|
botSaved,
|
||||||
|
messageReceived,
|
||||||
|
onMessageReceived,
|
||||||
onSaveBot,
|
onSaveBot,
|
||||||
webhookUrl,
|
webhookUrl,
|
||||||
extraWebhookUrl,
|
extraWebhookUrl,
|
||||||
@@ -870,11 +1023,17 @@ function StepBotConfig({
|
|||||||
createdBotUuid: string | null;
|
createdBotUuid: string | null;
|
||||||
isSavingBot: boolean;
|
isSavingBot: boolean;
|
||||||
botSaved: boolean;
|
botSaved: boolean;
|
||||||
|
messageReceived: boolean;
|
||||||
|
onMessageReceived: () => void;
|
||||||
onSaveBot: () => void;
|
onSaveBot: () => void;
|
||||||
webhookUrl: string;
|
webhookUrl: string;
|
||||||
extraWebhookUrl: string;
|
extraWebhookUrl: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [testMessage, setTestMessage] = useState(
|
||||||
|
t('wizard.botConfig.httpTestDefaultMessage'),
|
||||||
|
);
|
||||||
|
const [isSendingTest, setIsSendingTest] = useState(false);
|
||||||
|
|
||||||
const adapterLabel = useMemo(() => {
|
const adapterLabel = useMemo(() => {
|
||||||
const a = adapters.find((ad) => ad.name === selectedAdapterName);
|
const a = adapters.find((ad) => ad.name === selectedAdapterName);
|
||||||
@@ -889,8 +1048,42 @@ function StepBotConfig({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const copyWebhookUrl = useCallback(async () => {
|
||||||
|
if (!webhookUrl) return;
|
||||||
|
await navigator.clipboard.writeText(webhookUrl);
|
||||||
|
toast.success(t('common.copySuccess'));
|
||||||
|
}, [t, webhookUrl]);
|
||||||
|
|
||||||
|
const sendHttpBotTest = useCallback(async () => {
|
||||||
|
if (!createdBotUuid || !testMessage.trim()) return;
|
||||||
|
setIsSendingTest(true);
|
||||||
|
try {
|
||||||
|
await httpClient.testHttpBotInbound(createdBotUuid, testMessage.trim());
|
||||||
|
toast.success(t('wizard.botConfig.httpTestAccepted'));
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
t('wizard.botConfig.httpTestFailed', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsSendingTest(false);
|
||||||
|
}
|
||||||
|
}, [createdBotUuid, testMessage, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-5xl mx-auto space-y-6">
|
<div className="max-w-5xl mx-auto space-y-6">
|
||||||
|
{selectedAdapterName === 'web_page_bot' && botSaved && createdBotUuid && (
|
||||||
|
<PageBotFloatingWidget
|
||||||
|
botUuid={createdBotUuid}
|
||||||
|
title={
|
||||||
|
typeof adapterConfigValues.title === 'string'
|
||||||
|
? adapterConfigValues.title
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h2 className="text-xl font-semibold">{t('wizard.botConfig.title')}</h2>
|
<h2 className="text-xl font-semibold">{t('wizard.botConfig.title')}</h2>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
@@ -898,6 +1091,104 @@ function StepBotConfig({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{botSaved && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'border px-4 py-3',
|
||||||
|
messageReceived
|
||||||
|
? 'border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/30'
|
||||||
|
: 'border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full',
|
||||||
|
messageReceived ? 'bg-green-500' : 'bg-amber-500',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{messageReceived ? (
|
||||||
|
<Check className="size-3 text-white" />
|
||||||
|
) : selectedAdapterName === 'web_page_bot' ? (
|
||||||
|
<MessageSquare className="size-3 text-white" />
|
||||||
|
) : selectedAdapterName === 'http_bot' ? (
|
||||||
|
<Send className="size-3 text-white" />
|
||||||
|
) : webhookUrl ? (
|
||||||
|
<Webhook className="size-3 text-white" />
|
||||||
|
) : (
|
||||||
|
<Loader2 className="size-3 animate-spin text-white" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-medium',
|
||||||
|
messageReceived
|
||||||
|
? 'text-green-800 dark:text-green-200'
|
||||||
|
: 'text-amber-800 dark:text-amber-200',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{messageReceived
|
||||||
|
? t('wizard.botConfig.messageReceived')
|
||||||
|
: selectedAdapterName === 'web_page_bot'
|
||||||
|
? t('wizard.botConfig.pageBotTestPrompt')
|
||||||
|
: selectedAdapterName === 'http_bot'
|
||||||
|
? t('wizard.botConfig.httpTestPrompt')
|
||||||
|
: webhookUrl
|
||||||
|
? t('wizard.botConfig.webhookTestPrompt')
|
||||||
|
: t('wizard.botConfig.waitingForMessage')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!messageReceived && webhookUrl && (
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap border bg-background px-2.5 py-2 text-xs">
|
||||||
|
{webhookUrl}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="size-9 shrink-0"
|
||||||
|
onClick={copyWebhookUrl}
|
||||||
|
title={t('common.copy')}
|
||||||
|
>
|
||||||
|
<Copy className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedAdapterName === 'http_bot' && (
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<Input
|
||||||
|
value={testMessage}
|
||||||
|
onChange={(event) => setTestMessage(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') void sendHttpBotTest();
|
||||||
|
}}
|
||||||
|
className="bg-background"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void sendHttpBotTest()}
|
||||||
|
disabled={isSendingTest || !testMessage.trim()}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
{isSendingTest ? (
|
||||||
|
<Loader2 className="mr-1.5 size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="mr-1.5 size-4" />
|
||||||
|
)}
|
||||||
|
{t('wizard.botConfig.sendHttpTest')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid gap-6 grid-cols-1 lg:grid-cols-2">
|
<div className="grid gap-6 grid-cols-1 lg:grid-cols-2">
|
||||||
{/* Left column: Adapter config form */}
|
{/* Left column: Adapter config form */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -961,18 +1252,6 @@ function StepBotConfig({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bot saved indicator */}
|
|
||||||
{botSaved && (
|
|
||||||
<div className="flex items-center gap-2 px-4 py-3 rounded-lg border border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/30">
|
|
||||||
<div className="w-5 h-5 rounded-full bg-green-500 flex items-center justify-center shrink-0">
|
|
||||||
<Check className="w-3 h-3 text-white" />
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-green-700 dark:text-green-300">
|
|
||||||
{t('wizard.botConfig.botSaved')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right column: Bot logs */}
|
{/* Right column: Bot logs */}
|
||||||
@@ -989,6 +1268,7 @@ function StepBotConfig({
|
|||||||
botId={createdBotUuid}
|
botId={createdBotUuid}
|
||||||
autoExpandImages
|
autoExpandImages
|
||||||
hideToolbar
|
hideToolbar
|
||||||
|
onMessageReceived={onMessageReceived}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -1004,6 +1284,8 @@ function StepBotConfig({
|
|||||||
|
|
||||||
function StepAIEngine({
|
function StepAIEngine({
|
||||||
runnerOptions,
|
runnerOptions,
|
||||||
|
choice,
|
||||||
|
onChoiceChange,
|
||||||
selected,
|
selected,
|
||||||
onSelect,
|
onSelect,
|
||||||
isLocalAccount,
|
isLocalAccount,
|
||||||
@@ -1013,6 +1295,10 @@ function StepAIEngine({
|
|||||||
onRunnerConfigChange,
|
onRunnerConfigChange,
|
||||||
}: {
|
}: {
|
||||||
runnerOptions: { name: string; label: { en_US: string; zh_Hans: string } }[];
|
runnerOptions: { name: string; label: { en_US: string; zh_Hans: string } }[];
|
||||||
|
choice: 'external' | 'own-model' | 'more-features' | null;
|
||||||
|
onChoiceChange: (
|
||||||
|
choice: 'external' | 'own-model' | 'more-features' | null,
|
||||||
|
) => void;
|
||||||
selected: string | null;
|
selected: string | null;
|
||||||
onSelect: (name: string) => void;
|
onSelect: (name: string) => void;
|
||||||
isLocalAccount: boolean;
|
isLocalAccount: boolean;
|
||||||
@@ -1036,6 +1322,63 @@ function StepAIEngine({
|
|||||||
return r ? extractI18nObject(r.label) : (selected ?? '');
|
return r ? extractI18nObject(r.label) : (selected ?? '');
|
||||||
}, [runnerOptions, selected]);
|
}, [runnerOptions, selected]);
|
||||||
|
|
||||||
|
const choices = [
|
||||||
|
{
|
||||||
|
id: 'external' as const,
|
||||||
|
icon: Cable,
|
||||||
|
title: t('wizard.aiEngine.externalTitle'),
|
||||||
|
description: t('wizard.aiEngine.externalDescription'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'own-model' as const,
|
||||||
|
icon: Settings2,
|
||||||
|
title: t('wizard.aiEngine.ownModelTitle'),
|
||||||
|
description: t('wizard.aiEngine.ownModelDescription'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'more-features' as const,
|
||||||
|
icon: Blocks,
|
||||||
|
title: t('wizard.aiEngine.moreFeaturesTitle'),
|
||||||
|
description: t('wizard.aiEngine.moreFeaturesDescription'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (choice !== 'external') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-4xl mx-auto">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-xl font-semibold">
|
||||||
|
{t('wizard.aiEngine.title')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{t('wizard.aiEngine.optionalDescription')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{choices.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={item.id}
|
||||||
|
className={cn(
|
||||||
|
'cursor-pointer transition-all hover:border-primary/50',
|
||||||
|
choice === item.id && 'ring-2 ring-primary',
|
||||||
|
)}
|
||||||
|
onClick={() => onChoiceChange(item.id)}
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<Icon className="size-6 text-primary" />
|
||||||
|
<CardTitle className="text-base">{item.title}</CardTitle>
|
||||||
|
<CardDescription>{item.description}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Before any runner is selected: centered grid layout
|
// Before any runner is selected: centered grid layout
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
return (
|
return (
|
||||||
@@ -1045,9 +1388,13 @@ function StepAIEngine({
|
|||||||
{t('wizard.aiEngine.title')}
|
{t('wizard.aiEngine.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
{t('wizard.aiEngine.description')}
|
{t('wizard.aiEngine.runnerDescription')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => onChoiceChange(null)}>
|
||||||
|
<ArrowLeft className="size-4 mr-1.5" />
|
||||||
|
{t('wizard.aiEngine.backToChoices')}
|
||||||
|
</Button>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{runnerOptions.map((opt) => (
|
{runnerOptions.map((opt) => (
|
||||||
<Card
|
<Card
|
||||||
@@ -1084,6 +1431,16 @@ function StepAIEngine({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="self-start mb-3"
|
||||||
|
onClick={() => onChoiceChange(null)}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-4 mr-1.5" />
|
||||||
|
{t('wizard.aiEngine.backToChoices')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row lg:justify-center gap-6 lg:flex-1 lg:min-h-0 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
<div className="flex flex-col lg:flex-row lg:justify-center gap-6 lg:flex-1 lg:min-h-0 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||||
{/* Left: runner list */}
|
{/* Left: runner list */}
|
||||||
<div className="w-full lg:w-[280px] shrink-0 lg:overflow-y-auto lg:pr-3">
|
<div className="w-full lg:w-[280px] shrink-0 lg:overflow-y-auto lg:pr-3">
|
||||||
@@ -1179,100 +1536,3 @@ function StepAIEngine({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Step 3: Done
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function StepDone() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [particles] = useState(() =>
|
|
||||||
Array.from({ length: 30 }, (_, i) => ({
|
|
||||||
id: i,
|
|
||||||
left: Math.random() * 100,
|
|
||||||
delay: Math.random() * 2,
|
|
||||||
duration: 2 + Math.random() * 2,
|
|
||||||
size: 4 + Math.random() * 6,
|
|
||||||
color: [
|
|
||||||
'bg-purple-400',
|
|
||||||
'bg-pink-400',
|
|
||||||
'bg-orange-400',
|
|
||||||
'bg-blue-400',
|
|
||||||
'bg-green-400',
|
|
||||||
'bg-yellow-400',
|
|
||||||
][Math.floor(Math.random() * 6)],
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
const [isCompleting, setIsCompleting] = useState(false);
|
|
||||||
|
|
||||||
const handleBack = useCallback(async () => {
|
|
||||||
setIsCompleting(true);
|
|
||||||
try {
|
|
||||||
if (systemInfo.wizard_status === 'none') {
|
|
||||||
await httpClient.updateWizardStatus('completed');
|
|
||||||
systemInfo.wizard_status = 'completed';
|
|
||||||
}
|
|
||||||
// Always clear persisted progress so re-entering starts fresh
|
|
||||||
await httpClient.saveWizardProgress({
|
|
||||||
step: 0,
|
|
||||||
selected_adapter: null,
|
|
||||||
created_bot_uuid: null,
|
|
||||||
bot_saved: false,
|
|
||||||
selected_runner: null,
|
|
||||||
});
|
|
||||||
systemInfo.wizard_progress = null;
|
|
||||||
} catch {
|
|
||||||
toast.error(t('wizard.completeSaveError'));
|
|
||||||
setIsCompleting(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsCompleting(false);
|
|
||||||
navigate('/home/bots');
|
|
||||||
}, [navigate, t]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative flex flex-col items-center justify-center h-full min-h-[400px]">
|
|
||||||
{/* Confetti particles */}
|
|
||||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
|
||||||
{particles.map((p) => (
|
|
||||||
<div
|
|
||||||
key={p.id}
|
|
||||||
className={cn('absolute rounded-full opacity-0', p.color)}
|
|
||||||
style={{
|
|
||||||
left: `${p.left}%`,
|
|
||||||
width: p.size,
|
|
||||||
height: p.size,
|
|
||||||
animation: `wizardConfetti ${p.duration}s ease-out ${p.delay}s forwards`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PartyPopper className="w-16 h-16 text-primary mb-4" />
|
|
||||||
<h2 className="text-2xl font-bold">{t('wizard.done.title')}</h2>
|
|
||||||
<p className="text-muted-foreground mt-2 text-center max-w-md">
|
|
||||||
{t('wizard.done.description')}
|
|
||||||
</p>
|
|
||||||
<Button className="mt-6" onClick={handleBack} disabled={isCompleting}>
|
|
||||||
{isCompleting && <Loader2 className="w-4 h-4 mr-1.5 animate-spin" />}
|
|
||||||
{t('wizard.done.backToWorkbench')}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<style>{`
|
|
||||||
@keyframes wizardConfetti {
|
|
||||||
0% {
|
|
||||||
transform: translateY(100vh) rotate(0deg);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: translateY(-20vh) rotate(720deg);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1827,6 +1827,23 @@ const enUS = {
|
|||||||
resaveBot: 'Re-save Configuration',
|
resaveBot: 'Re-save Configuration',
|
||||||
botSaved:
|
botSaved:
|
||||||
'Bot configuration saved and enabled. Check the logs to verify the connection.',
|
'Bot configuration saved and enabled. Check the logs to verify the connection.',
|
||||||
|
waitingForMessage:
|
||||||
|
'The bot is enabled. Send it a message from your IM platform to continue.',
|
||||||
|
messageReceived:
|
||||||
|
'The bot received an IM message. You can continue to the next step.',
|
||||||
|
pageBotTestPrompt:
|
||||||
|
'Page Bot is enabled. Click the chat bubble in the lower-right corner and send a message to verify the full conversation flow.',
|
||||||
|
webhookTestPrompt:
|
||||||
|
'The callback URL is ready. Configure it on the external platform, then send the bot a real message.',
|
||||||
|
httpTestPrompt:
|
||||||
|
'HTTP Bot is enabled. Send a real inbound message here to verify the connection.',
|
||||||
|
httpTestDefaultMessage: 'Hello, this is a connection test message.',
|
||||||
|
sendHttpTest: 'Send Test Message',
|
||||||
|
httpTestAccepted:
|
||||||
|
'The test message was accepted. It will appear in the log shortly.',
|
||||||
|
httpTestMissingSecret:
|
||||||
|
'Enter an inbound signing secret and save the configuration first.',
|
||||||
|
httpTestFailed: 'Failed to send the test message: {{error}}',
|
||||||
logsTitle: 'Bot Logs',
|
logsTitle: 'Bot Logs',
|
||||||
logsDescription:
|
logsDescription:
|
||||||
'Monitor bot activity to verify the platform connection is working.',
|
'Monitor bot activity to verify the platform connection is working.',
|
||||||
@@ -1835,6 +1852,23 @@ const enUS = {
|
|||||||
title: 'Select an AI Engine',
|
title: 'Select an AI Engine',
|
||||||
description:
|
description:
|
||||||
"Choose the AI engine that will power your bot's intelligence.",
|
"Choose the AI engine that will power your bot's intelligence.",
|
||||||
|
optionalDescription:
|
||||||
|
'This step is optional. Choose how you want to continue with the current agent.',
|
||||||
|
externalTitle: 'Connect an External Agent',
|
||||||
|
externalDescription:
|
||||||
|
'Connect Dify, n8n, Coze, or another platform and replace the bot pipeline.',
|
||||||
|
ownModelTitle: 'Use My Own Model',
|
||||||
|
ownModelDescription:
|
||||||
|
'Open the current Local Agent pipeline and configure your own model.',
|
||||||
|
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',
|
||||||
|
configurePipeline: 'Configure Pipeline',
|
||||||
|
openWorkbench: 'Open Workbench',
|
||||||
},
|
},
|
||||||
spaceBanner: {
|
spaceBanner: {
|
||||||
message:
|
message:
|
||||||
|
|||||||
@@ -1744,6 +1744,23 @@ const jaJP = {
|
|||||||
resaveBot: '設定を再保存',
|
resaveBot: '設定を再保存',
|
||||||
botSaved:
|
botSaved:
|
||||||
'ボット設定が保存され、有効になりました。ログを確認して接続を検証してください。',
|
'ボット設定が保存され、有効になりました。ログを確認して接続を検証してください。',
|
||||||
|
waitingForMessage:
|
||||||
|
'ボットが有効になりました。続行するには IM からメッセージを送信してください。',
|
||||||
|
messageReceived:
|
||||||
|
'ボットが IM メッセージを受信しました。次のステップに進めます。',
|
||||||
|
pageBotTestPrompt:
|
||||||
|
'ページボットが有効になりました。右下のチャットバブルをクリックしてメッセージを送信し、会話フロー全体を確認してください。',
|
||||||
|
webhookTestPrompt:
|
||||||
|
'コールバック URL の準備ができました。外部プラットフォームに設定し、ボットへ実際のメッセージを送信してください。',
|
||||||
|
httpTestPrompt:
|
||||||
|
'HTTP Bot が有効になりました。実際の受信メッセージを送信して接続を確認できます。',
|
||||||
|
httpTestDefaultMessage: 'こんにちは。これは接続テストメッセージです。',
|
||||||
|
sendHttpTest: 'テストメッセージを送信',
|
||||||
|
httpTestAccepted:
|
||||||
|
'テストメッセージを受け付けました。まもなくログに表示されます。',
|
||||||
|
httpTestMissingSecret:
|
||||||
|
'受信署名シークレットを入力し、先に設定を保存してください。',
|
||||||
|
httpTestFailed: 'テストメッセージの送信に失敗しました:{{error}}',
|
||||||
logsTitle: 'ボットログ',
|
logsTitle: 'ボットログ',
|
||||||
logsDescription:
|
logsDescription:
|
||||||
'ボットの活動を監視して、プラットフォーム接続が正常に動作していることを確認します。',
|
'ボットの活動を監視して、プラットフォーム接続が正常に動作していることを確認します。',
|
||||||
@@ -1752,6 +1769,22 @@ const jaJP = {
|
|||||||
title: 'AIエンジンを選択',
|
title: 'AIエンジンを選択',
|
||||||
description:
|
description:
|
||||||
'ボットのインテリジェンスを駆動するAIエンジンを選択してください。',
|
'ボットのインテリジェンスを駆動するAIエンジンを選択してください。',
|
||||||
|
optionalDescription:
|
||||||
|
'このステップは任意です。現在の Agent をどのように設定するか選択してください。',
|
||||||
|
externalTitle: '外部プラットフォームの Agent を接続',
|
||||||
|
externalDescription:
|
||||||
|
'Dify、n8n、Coze などを接続し、ボットのパイプラインを置き換えます。',
|
||||||
|
ownModelTitle: '自分のモデルを使用',
|
||||||
|
ownModelDescription:
|
||||||
|
'現在の Local Agent パイプラインを開き、自分のモデルを設定します。',
|
||||||
|
moreFeaturesTitle: 'Agent に機能を追加',
|
||||||
|
moreFeaturesDescription:
|
||||||
|
'ワークベンチを開き、ツールやナレッジなどの機能を追加します。',
|
||||||
|
runnerDescription: '外部 Agent の Runner を選択し、接続を設定します。',
|
||||||
|
backToChoices: '選択肢に戻る',
|
||||||
|
createExternal: '作成して関連付ける',
|
||||||
|
configurePipeline: 'パイプラインを設定',
|
||||||
|
openWorkbench: 'ワークベンチを開く',
|
||||||
},
|
},
|
||||||
spaceBanner: {
|
spaceBanner: {
|
||||||
message:
|
message:
|
||||||
|
|||||||
@@ -1749,12 +1749,37 @@ const zhHans = {
|
|||||||
saveBot: '保存并启用',
|
saveBot: '保存并启用',
|
||||||
resaveBot: '重新保存配置',
|
resaveBot: '重新保存配置',
|
||||||
botSaved: '机器人配置已保存并启用,请查看日志确认连接正常。',
|
botSaved: '机器人配置已保存并启用,请查看日志确认连接正常。',
|
||||||
|
waitingForMessage: '机器人已启用。请在 IM 中向机器人发送一条消息以继续。',
|
||||||
|
messageReceived: '机器人已成功收到 IM 消息,可以进入下一步。',
|
||||||
|
pageBotTestPrompt:
|
||||||
|
'页面机器人已启用。点击右下角聊天气泡并发送一条消息,验证完整对话链路。',
|
||||||
|
webhookTestPrompt:
|
||||||
|
'回调地址已就绪。将它配置到外部平台,然后向机器人发送一条真实消息。',
|
||||||
|
httpTestPrompt: 'HTTP Bot 已启用。可直接发送一条真实入站消息验证连接。',
|
||||||
|
httpTestDefaultMessage: '你好,这是一条连接测试消息。',
|
||||||
|
sendHttpTest: '发送测试消息',
|
||||||
|
httpTestAccepted: '测试消息已被机器人接收,请稍候查看日志。',
|
||||||
|
httpTestMissingSecret: '请先填写入站签名密钥并重新保存。',
|
||||||
|
httpTestFailed: '测试消息发送失败:{{error}}',
|
||||||
logsTitle: '机器人日志',
|
logsTitle: '机器人日志',
|
||||||
logsDescription: '监控机器人活动,确认平台连接是否正常工作。',
|
logsDescription: '监控机器人活动,确认平台连接是否正常工作。',
|
||||||
},
|
},
|
||||||
aiEngine: {
|
aiEngine: {
|
||||||
title: '选择 AI 引擎',
|
title: '选择 AI 引擎',
|
||||||
description: '选择驱动机器人智能的 AI 引擎。',
|
description: '选择驱动机器人智能的 AI 引擎。',
|
||||||
|
optionalDescription: '这一步可选。选择接下来要如何完善当前 Agent。',
|
||||||
|
externalTitle: '接入外部平台 Agent',
|
||||||
|
externalDescription:
|
||||||
|
'接入 Dify、n8n、Coze 等平台,并替换当前机器人的流水线。',
|
||||||
|
ownModelTitle: '改成使用自己的模型',
|
||||||
|
ownModelDescription: '进入当前 Local Agent 流水线,配置你自己的模型。',
|
||||||
|
moreFeaturesTitle: '给现在的 Agent 配置更多功能',
|
||||||
|
moreFeaturesDescription: '进入工作台,为 Agent 添加工具、知识库等能力。',
|
||||||
|
runnerDescription: '选择外部 Agent 的 Runner 并完成连接配置。',
|
||||||
|
backToChoices: '返回选项',
|
||||||
|
createExternal: '创建并绑定',
|
||||||
|
configurePipeline: '配置流水线',
|
||||||
|
openWorkbench: '进入工作台',
|
||||||
},
|
},
|
||||||
spaceBanner: {
|
spaceBanner: {
|
||||||
message: '接入 LangBot Space,获取免费试用模型额度,零配置极速开箱!',
|
message: '接入 LangBot Space,获取免费试用模型额度,零配置极速开箱!',
|
||||||
|
|||||||
+23
-2
@@ -1,8 +1,12 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), '');
|
||||||
|
const apiProxyTarget = env.VITE_API_PROXY_TARGET || 'http://127.0.0.1:5300';
|
||||||
|
|
||||||
|
return {
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
@@ -10,9 +14,26 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
port: 3000,
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: apiProxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
'/mcp': {
|
||||||
|
target: apiProxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/bots': {
|
||||||
|
target: apiProxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
},
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user