diff --git a/skills/skills/langbot-mcp-ops/SKILL.md b/skills/skills/langbot-mcp-ops/SKILL.md index fb1152fa4..7480f2b1a 100644 --- a/skills/skills/langbot-mcp-ops/SKILL.md +++ b/skills/skills/langbot-mcp-ops/SKILL.md @@ -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 diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py b/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py index 69189d2ee..136eac39e 100644 --- a/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py +++ b/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py @@ -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( diff --git a/src/langbot/pkg/api/http/controller/groups/platform/bots.py b/src/langbot/pkg/api/http/controller/groups/platform/bots.py index 55867f189..fbbc5d879 100644 --- a/src/langbot/pkg/api/http/controller/groups/platform/bots.py +++ b/src/langbot/pkg/api/http/controller/groups/platform/bots.py @@ -113,6 +113,24 @@ class BotsRouterGroup(group.RouterGroup): ) return self.success(data={'sent': True}) + @self.route( + '//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( '//admins', methods=['GET'], diff --git a/src/langbot/pkg/api/http/controller/groups/system.py b/src/langbot/pkg/api/http/controller/groups/system.py index 330799335..9ae0e0bf2 100644 --- a/src/langbot/pkg/api/http/controller/groups/system.py +++ b/src/langbot/pkg/api/http/controller/groups/system.py @@ -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'], diff --git a/src/langbot/pkg/api/http/service/bot.py b/src/langbot/pkg/api/http/service/bot.py index 9d9211be3..42bb35338 100644 --- a/src/langbot/pkg/api/http/service/bot.py +++ b/src/langbot/pkg/api/http/service/bot.py @@ -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, diff --git a/src/langbot/pkg/api/http/service/space.py b/src/langbot/pkg/api/http/service/space.py index a0fc7dfb8..5be09a860 100644 --- a/src/langbot/pkg/api/http/service/space.py +++ b/src/langbot/pkg/api/http/service/space.py @@ -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} diff --git a/src/langbot/pkg/api/mcp/server.py b/src/langbot/pkg/api/mcp/server.py index 4cf4e33ad..9091178e1 100644 --- a/src/langbot/pkg/api/mcp/server.py +++ b/src/langbot/pkg/api/mcp/server.py @@ -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: diff --git a/src/langbot/pkg/entity/dto/space_model.py b/src/langbot/pkg/entity/dto/space_model.py index 62b9f2b09..4ecd598d7 100644 --- a/src/langbot/pkg/entity/dto/space_model.py +++ b/src/langbot/pkg/entity/dto/space_model.py @@ -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 diff --git a/src/langbot/templates/embed/widget.js b/src/langbot/templates/embed/widget.js index 2a710711b..a62c54a4c 100644 --- a/src/langbot/templates/embed/widget.js +++ b/src/langbot/templates/embed/widget.js @@ -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"; diff --git a/src/langbot/templates/metadata/pipeline/ai.yaml b/src/langbot/templates/metadata/pipeline/ai.yaml index 063f91d73..ccf009941 100644 --- a/src/langbot/templates/metadata/pipeline/ai.yaml +++ b/src/langbot/templates/metadata/pipeline/ai.yaml @@ -325,7 +325,7 @@ stages: zh_Hans: API 密钥 type: string required: true - default: 'your-api-key' + default: '' - name: n8n-service-api label: en_US: n8n Workflow API diff --git a/tests/integration/api/test_pipelines.py b/tests/integration/api/test_pipelines.py index 80fce9747..9f84aafb9 100644 --- a/tests/integration/api/test_pipelines.py +++ b/tests/integration/api/test_pipelines.py @@ -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.""" diff --git a/tests/unit_tests/api/service/test_bot_service.py b/tests/unit_tests/api/service/test_bot_service.py index dea5763f2..55869fc0a 100644 --- a/tests/unit_tests/api/service/test_bot_service.py +++ b/tests/unit_tests/api/service/test_bot_service.py @@ -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.""" diff --git a/tests/unit_tests/api/service/test_space_service.py b/tests/unit_tests/api/service/test_space_service.py index f16b609a9..a77dd14a1 100644 --- a/tests/unit_tests/api/service/test_space_service.py +++ b/tests/unit_tests/api/service/test_space_service.py @@ -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.""" diff --git a/web/.env.example b/web/.env.example index 2c5cdb153..f954e347f 100644 --- a/web/.env.example +++ b/web/.env.example @@ -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 diff --git a/web/src/app/home/bots/components/bot-log/view/BotLogListComponent.tsx b/web/src/app/home/bots/components/bot-log/view/BotLogListComponent.tsx index aacc9982b..00de91865 100644 --- a/web/src/app/home/bots/components/bot-log/view/BotLogListComponent.tsx +++ b/web/src/app/home/bots/components/bot-log/view/BotLogListComponent.tsx @@ -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(null); const botLogListRef = useRef(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( diff --git a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx index 6126a21e2..039f1df0e 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx @@ -15,7 +15,10 @@ import { FormMessage, } from '@/components/ui/form'; import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent'; -import { normalizeDynamicFormValuesForSave } from '@/app/home/components/dynamic-form/DynamicFormSaveValues'; +import { + normalizeDynamicFormFieldValue, + normalizeDynamicFormValuesForSave, +} from '@/app/home/components/dynamic-form/DynamicFormSaveValues'; import QrCodeLoginDialog, { QrLoginPlatform, } from '@/app/home/components/qrcode-login/QrCodeLoginDialog'; @@ -464,61 +467,6 @@ export default function DynamicFormComponent({ const previousInitialValues = useRef(initialValues); const { t, i18n } = useTranslation(); - // Normalize a form value according to its field type. - // This ensures legacy/malformed data (e.g. a plain string for - // model-fallback-selector) is coerced to the expected shape - // so that downstream components never crash. - const normalizeFieldValue = ( - item: DynamicFormValueSpec, - value: unknown, - ): unknown => { - if ( - item.name === 'mcp-resources' || - item.type === DynamicFormItemType.RESOURCES_SELECTOR || - item.type === DynamicFormItemType.RICH_TOOLS_SELECTOR - ) { - return Array.isArray(value) ? value : []; - } - if (item.type === 'model-fallback-selector') { - if (value != null && typeof value === 'object' && !Array.isArray(value)) { - const obj = value as Record; - return { - primary: typeof obj.primary === 'string' ? obj.primary : '', - fallbacks: Array.isArray(obj.fallbacks) - ? (obj.fallbacks as unknown[]).filter( - (v): v is string => typeof v === 'string', - ) - : [], - reasoning: - obj.reasoning != null && - typeof obj.reasoning === 'object' && - !Array.isArray(obj.reasoning) - ? Object.fromEntries( - Object.entries(obj.reasoning).filter( - (entry): entry is [string, string] => - typeof entry[1] === 'string', - ), - ) - : {}, - }; - } - // Legacy string format or any other unexpected type - return { - primary: typeof value === 'string' ? value : '', - fallbacks: [], - reasoning: {}, - }; - } - if (item.type === 'prompt-editor') { - if (Array.isArray(value)) { - return value; - } - // Default to a single empty system prompt entry - return [{ role: 'system', content: '' }]; - } - return value; - }; - // Filter out display-only fields (webhook-url/embed-code/qr-code-login types // and `__system.*`-named fields) that should not participate in form state, // validation, or value emission. @@ -574,7 +522,7 @@ export default function DynamicFormComponent({ const rawValue = initialValues?.[item.name] ?? item.default; return { ...acc, - [item.name]: normalizeFieldValue(item, rawValue), + [item.name]: normalizeDynamicFormFieldValue(item, rawValue), }; }, {} as FormValues), }); @@ -611,7 +559,10 @@ export default function DynamicFormComponent({ const mergedValues = editableValueSpecs.reduce( (acc, item) => { const rawValue = initialValues[item.name] ?? item.default; - acc[item.name] = normalizeFieldValue(item, rawValue) as object; + acc[item.name] = normalizeDynamicFormFieldValue( + item, + rawValue, + ) as object; return acc; }, {} as Record, diff --git a/web/src/app/home/components/dynamic-form/DynamicFormSaveValues.ts b/web/src/app/home/components/dynamic-form/DynamicFormSaveValues.ts index 2c1afe955..97a37031f 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormSaveValues.ts +++ b/web/src/app/home/components/dynamic-form/DynamicFormSaveValues.ts @@ -5,6 +5,80 @@ export type DynamicFormSaveValueSpec = Pick< 'default' | 'name' | 'type' >; +const ARRAY_FIELD_TYPES = new Set([ + 'array[string]', + 'array[file]', + 'knowledge-base-multi-selector', + 'resources-selector', + 'rich-tools-selector', + 'tools-selector', +]); + +const STRING_FIELD_TYPES = new Set([ + 'string', + 'text', + 'select', + 'llm-model-selector', + 'embedding-model-selector', + 'rerank-model-selector', + 'knowledge-base-selector', + 'bot-selector', +]); + +/** + * Coerce empty dynamic-form defaults into controlled React values. + * Metadata from older adapters and runners can omit `default`; inputs must + * still receive a stable value from their first render. + */ +export function normalizeDynamicFormFieldValue( + spec: DynamicFormSaveValueSpec, + value: unknown, +): unknown { + if (spec.name === 'mcp-resources' || ARRAY_FIELD_TYPES.has(spec.type)) { + return Array.isArray(value) ? value : []; + } + if (spec.type === 'boolean') { + return typeof value === 'boolean' ? value : false; + } + if (STRING_FIELD_TYPES.has(spec.type)) { + return typeof value === 'string' ? value : ''; + } + if (spec.type === 'model-fallback-selector') { + if (value != null && typeof value === 'object' && !Array.isArray(value)) { + const objectValue = value as Record; + return { + primary: + typeof objectValue.primary === 'string' ? objectValue.primary : '', + fallbacks: Array.isArray(objectValue.fallbacks) + ? objectValue.fallbacks.filter( + (fallback): fallback is string => typeof fallback === 'string', + ) + : [], + reasoning: + objectValue.reasoning != null && + typeof objectValue.reasoning === 'object' && + !Array.isArray(objectValue.reasoning) + ? Object.fromEntries( + Object.entries(objectValue.reasoning).filter( + (entry): entry is [string, string] => + typeof entry[1] === 'string', + ), + ) + : {}, + }; + } + return { + primary: typeof value === 'string' ? value : '', + fallbacks: [], + reasoning: {}, + }; + } + if (spec.type === 'prompt-editor') { + return Array.isArray(value) ? value : [{ role: 'system', content: '' }]; + } + return value; +} + const reasoningLevels = new Set([ 'disabled', 'enabled', diff --git a/web/src/app/home/components/models-dialog/component/provider-form/ProviderForm.tsx b/web/src/app/home/components/models-dialog/component/provider-form/ProviderForm.tsx index ae20bb254..96816db83 100644 --- a/web/src/app/home/components/models-dialog/component/provider-form/ProviderForm.tsx +++ b/web/src/app/home/components/models-dialog/component/provider-form/ProviderForm.tsx @@ -33,7 +33,7 @@ const getFormSchema = (t: (key: string) => string) => interface ProviderFormProps { providerId?: string; - onFormSubmit: () => void; + onFormSubmit: (providerUuid: string) => void | Promise; 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); } diff --git a/web/src/app/infra/entities/adapter-categories.ts b/web/src/app/infra/entities/adapter-categories.ts index b30f590be..6c01efb0a 100644 --- a/web/src/app/infra/entities/adapter-categories.ts +++ b/web/src/app/infra/entities/adapter-categories.ts @@ -54,7 +54,7 @@ export function groupByCategory( } let placed = false; - for (const cat of cats) { + for (const cat of new Set(cats)) { if (ordered.includes(cat as AdapterCategoryId)) { buckets.get(cat as AdapterCategoryId)!.push(item); placed = true; diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index 0eef6543c..7c66f6be1 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -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; } diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index a7aaf6f49..4a57956ea 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -150,7 +150,9 @@ export class BackendClient extends BaseHttpClient { return this.get(`/api/v1/provider/models/llm/${uuid}`); } - public createProviderLLMModel(model: LLMModel): Promise { + public createProviderLLMModel( + model: Omit, + ): 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, @@ -1068,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 { 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; diff --git a/web/src/app/wizard/components/OwnModelSetup.tsx b/web/src/app/wizard/components/OwnModelSetup.tsx new file mode 100644 index 000000000..982230c84 --- /dev/null +++ b/web/src/app/wizard/components/OwnModelSetup.tsx @@ -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(null); + const [showProviderForm, setShowProviderForm] = useState(true); + const [mode, setMode] = useState('scan'); + const [models, setModels] = useState([]); + const [selectedModelId, setSelectedModelId] = useState(null); + const [isScanning, setIsScanning] = useState(false); + const [scanFallbackReason, setScanFallbackReason] = + useState(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 ( +
+
+

+ {t('wizard.aiEngine.ownModelSetupTitle')} +

+

+ {t('wizard.aiEngine.ownModelSetupDescription')} +

+
+ +
+ +
+ + {showProviderForm ? ( + + + + {t('wizard.aiEngine.addProviderTitle')} + + + {t('wizard.aiEngine.addProviderDescription')} + + + + + providerUuid ? setShowProviderForm(false) : handleBack() + } + /> + + + ) : ( +
+
+
+

+ {t('wizard.aiEngine.selectModelTitle')} +

+

+ {t('wizard.aiEngine.selectScannedModelDescription')} +

+
+ +
+ + + + + {t('wizard.aiEngine.scanModelMode')} + + + {t('wizard.aiEngine.manualModelMode')} + + + + + {isScanning ? ( +
+ + {t('wizard.aiEngine.scanningModels')} +
+ ) : models.length > 0 ? ( +
+
+ {models.map((model) => { + const selected = selectedModelId === model.id; + return ( + + ); + })} +
+
+ +
+
+ ) : ( +
+

+ {t( + scanFallbackReason === 'failed' + ? 'wizard.aiEngine.scanModelsFailed' + : 'wizard.aiEngine.noScannedModels', + )} +

+ +
+ )} +
+ + + {scanFallbackReason && ( +
+ {t( + scanFallbackReason === 'failed' + ? 'wizard.aiEngine.manualFallbackFailed' + : 'wizard.aiEngine.manualFallbackEmpty', + )} +
+ )} + +
+ + setManualModelName(event.target.value)} + placeholder={t('wizard.aiEngine.manualModelIdPlaceholder')} + /> +

+ {t('wizard.aiEngine.manualModelIdDescription')} +

+
+ +
+

+ {t('wizard.aiEngine.manualModelOptions')} +

+
+ + + setManualContextLength(event.target.value) + } + placeholder={t('models.contextLengthPlaceholder')} + /> + {parsedManualContextLength === undefined && ( +

+ {t('models.contextLengthInvalid')} +

+ )} +
+ +
+
+ + setManualVision(checked === true) + } + /> + +
+
+ + setManualFunctionCall(checked === true) + } + /> + +
+
+
+
+
+
+ )} +
+ ); +} diff --git a/web/src/app/wizard/page.tsx b/web/src/app/wizard/page.tsx index 262f91dd6..dbb824735 100644 --- a/web/src/app/wizard/page.tsx +++ b/web/src/app/wizard/page.tsx @@ -6,12 +6,19 @@ import { toast } from 'sonner'; import { ArrowLeft, ArrowRight, + AlertTriangle, Check, Sparkles, - PartyPopper, Loader2, X, ExternalLink, + Cable, + Settings2, + Blocks, + Copy, + Send, + Webhook, + MessageSquare, } from 'lucide-react'; import { httpClient } from '@/app/infra/http/HttpClient'; @@ -19,13 +26,9 @@ import { systemInfo, bootstrapWorkspaceSession, initializeSystemInfo, + userInfo, } from '@/app/infra/http'; -import { - Adapter, - Bot, - Pipeline, - WizardProgress, -} from '@/app/infra/entities/api'; +import { Adapter, Bot, WizardProgress } from '@/app/infra/entities/api'; import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic'; import { PipelineConfigTab, @@ -38,6 +41,9 @@ import { } from '@/app/home/components/dynamic-form/DynamicFormItemConfig'; import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent'; import { BotLogListComponent } from '@/app/home/bots/components/bot-log/view/BotLogListComponent'; +import OwnModelSetup, { + OwnModelSelection, +} from '@/app/wizard/components/OwnModelSetup'; import { extractI18nObject } from '@/i18n/I18nProvider'; import { groupByCategory, @@ -46,7 +52,17 @@ import { import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs'; import i18n from 'i18next'; +import { + configureLocalAgentPrimaryModel, + ensureHttpBotSigningSecret, + findDefaultPipeline, + getErrorMessage, + isRequiredRunnerConfigComplete, + isWebhookModeEnabled, +} from '@/app/wizard/utils'; + import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; import { Card, CardContent, @@ -70,7 +86,7 @@ import { // Types // --------------------------------------------------------------------------- -const TOTAL_STEPS = 4; +const TOTAL_STEPS = 3; // --------------------------------------------------------------------------- // Main Wizard Page (full-screen, no sidebar) @@ -92,6 +108,9 @@ export default function WizardPage() { ); const [runnerConfig, setRunnerConfig] = useState>({}); const [createdBotUuid, setCreatedBotUuid] = useState(null); + const [createdPipelineUuid, setCreatedPipelineUuid] = useState( + null, + ); const [webhookUrl, setWebhookUrl] = useState(''); const [extraWebhookUrl, setExtraWebhookUrl] = useState(''); @@ -105,6 +124,12 @@ export default function WizardPage() { const [isSubmitting, setIsSubmitting] = useState(false); const [isSavingBot, setIsSavingBot] = useState(false); const [botSaved, setBotSaved] = useState(false); + const [messageReceived, setMessageReceived] = useState(false); + const [aiChoice, setAiChoice] = useState< + 'external' | 'own-model' | 'more-features' | null + >('more-features'); + const [ownModelSelection, setOwnModelSelection] = + useState(null); // ---- Helper: persist wizard progress to backend (fire-and-forget) ---- const saveProgress = useCallback( @@ -113,14 +138,25 @@ export default function WizardPage() { step: overrides.step ?? currentStep, selected_adapter: overrides.selected_adapter ?? selectedAdapter, created_bot_uuid: overrides.created_bot_uuid ?? createdBotUuid, + created_pipeline_uuid: + overrides.created_pipeline_uuid ?? createdPipelineUuid, bot_saved: overrides.bot_saved ?? botSaved, + message_received: overrides.message_received ?? messageReceived, selected_runner: overrides.selected_runner ?? selectedRunner, }; httpClient.saveWizardProgress(progress).catch((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 ---- @@ -156,13 +192,30 @@ export default function WizardPage() { const botData = await httpClient.getBot(progress.created_bot_uuid); if (cancelled) return; - setSelectedAdapter(progress.selected_adapter); + const restoredAdapter = + progress.selected_adapter ?? botData.bot.adapter; + const restoredConfig = (botData.bot.adapter_config ?? {}) as Record< + string, + unknown + >; + const configToRestore = ensureHttpBotSigningSecret( + restoredAdapter, + restoredConfig, + ); + const configNeedsSave = configToRestore !== restoredConfig; + + setSelectedAdapter(restoredAdapter); setCreatedBotUuid(progress.created_bot_uuid); - setBotSaved(progress.bot_saved ?? false); + setCreatedPipelineUuid(progress.created_pipeline_uuid ?? null); + setBotSaved( + configNeedsSave ? false : (progress.bot_saved ?? false), + ); + setMessageReceived(progress.message_received ?? false); setSelectedRunner(progress.selected_runner); // Restore bot name from fetched bot data setBotName(botData.bot.name); + setAdapterConfig(configToRestore); // Restore webhook URLs const runtimeValues = botData.bot.adapter_runtime_values as @@ -182,7 +235,9 @@ export default function WizardPage() { step: 0, selected_adapter: null, created_bot_uuid: null, + created_pipeline_uuid: null, bot_saved: false, + message_received: false, selected_runner: null, }) .catch(() => {}); @@ -210,7 +265,9 @@ export default function WizardPage() { const runnerOptions = useMemo(() => { if (!runnerStage) return []; const runnerField = runnerStage.config.find((c) => c.name === 'runner'); - return runnerField?.options ?? []; + return (runnerField?.options ?? []).filter( + (option) => option.name !== 'local-agent', + ); }, [runnerStage]); const selectedRunnerConfigStage: PipelineConfigStage | undefined = @@ -268,13 +325,20 @@ export default function WizardPage() { ); }, [selectedRunnerConfigStage]); + const isRunnerConfigComplete = useMemo( + () => + isRequiredRunnerConfigComplete(selectedRunnerConfigItems, runnerConfig), + [selectedRunnerConfigItems, runnerConfig], + ); + // ---- Runner selection with progress saving ---- const handleSelectRunner = useCallback( (runner: string) => { + if (runner !== selectedRunner) setRunnerConfig({}); setSelectedRunner(runner); saveProgress({ step: 2, selected_runner: runner }); }, - [saveProgress], + [saveProgress, selectedRunner], ); // ---- Navigation helpers ---- @@ -284,13 +348,20 @@ export default function WizardPage() { case 0: return selectedAdapter !== null; case 1: - return createdBotUuid !== null && botSaved; + return createdBotUuid !== null && botSaved && messageReceived; case 2: - return selectedRunner !== null; + return aiChoice !== null; default: return false; } - }, [currentStep, selectedAdapter, createdBotUuid, botSaved, selectedRunner]); + }, [ + currentStep, + selectedAdapter, + createdBotUuid, + botSaved, + messageReceived, + aiChoice, + ]); const goNext = useCallback(() => { if (currentStep < TOTAL_STEPS - 1 && canProceed()) { @@ -303,6 +374,9 @@ export default function WizardPage() { const goPrev = useCallback(() => { if (currentStep > 0) { const prevStep = currentStep - 1; + if (currentStep === 2) { + setOwnModelSelection(null); + } setCurrentStep(prevStep); saveProgress({ step: prevStep }); } @@ -326,12 +400,17 @@ export default function WizardPage() { const defaultConfig = adapter ? getDefaultValues(adapter.spec.config) : {}; + const initialConfig = ensureHttpBotSigningSecret( + selectedAdapter, + defaultConfig, + ); + setAdapterConfig(initialConfig); const bot: Bot = { name: defaultName, description: '', adapter: selectedAdapter, - adapter_config: defaultConfig, + adapter_config: initialConfig, enable: false, }; const resp = await httpClient.createBot(bot); @@ -359,7 +438,9 @@ export default function WizardPage() { step: 1, selected_adapter: selectedAdapter, created_bot_uuid: resp.uuid, + created_pipeline_uuid: null, bot_saved: false, + message_received: false, selected_runner: null, }); } catch (err) { @@ -373,21 +454,99 @@ export default function WizardPage() { }, [selectedAdapter, adapters, t, saveProgress]); // ---- Save Bot Config & Enable (Step 1) ---- - // Updates the bot's adapter config and enables it. + // Binds the bot to the Workspace default pipeline and enables it. const handleSaveBot = useCallback(async () => { if (!createdBotUuid || !selectedAdapter) return; setIsSavingBot(true); + let createdPipelineThisAttempt: string | null = null; try { + const pipelinesResponse = await httpClient.getPipelines( + 'updated_at', + 'DESC', + ); + const defaultPipeline = findDefaultPipeline(pipelinesResponse.pipelines); + let pipelineUuid = defaultPipeline?.uuid ?? null; + let createdDefaultPipeline = false; + + if (!pipelineUuid) { + const pipelineResp = await httpClient.createPipeline({ + name: `${botName} Agent`, + description: botDescription || '', + config: {}, + is_default: true, + }); + pipelineUuid = pipelineResp.uuid; + createdPipelineThisAttempt = pipelineUuid; + createdDefaultPipeline = true; + } + + const pipelineData = await httpClient.getPipeline(pipelineUuid); + const fullConfig = pipelineData.pipeline.config as unknown as Record< + string, + unknown + >; + const aiConfig = (fullConfig.ai ?? {}) as Record; + const runnerConfig = (aiConfig.runner ?? {}) as Record; + const localAgentConfig = (aiConfig['local-agent'] ?? {}) as Record< + string, + unknown + >; + const modelConfig = (localAgentConfig.model ?? {}) as Record< + string, + unknown + >; + const usesLocalAgent = + createdDefaultPipeline || runnerConfig.runner === 'local-agent'; + const needsPrimaryModel = + usesLocalAgent && + (typeof modelConfig.primary !== 'string' || !modelConfig.primary); + + if (createdDefaultPipeline || needsPrimaryModel) { + const recommendedModel = await httpClient.getWizardRecommendedModel(); + await httpClient.updatePipeline(pipelineUuid, { + name: pipelineData.pipeline.name, + description: pipelineData.pipeline.description || '', + config: { + ...fullConfig, + ai: { + ...aiConfig, + runner: createdDefaultPipeline + ? { ...runnerConfig, runner: 'local-agent' } + : runnerConfig, + 'local-agent': { + ...localAgentConfig, + model: { + ...modelConfig, + primary: recommendedModel.uuid, + fallbacks: Array.isArray(modelConfig.fallbacks) + ? modelConfig.fallbacks + : [], + }, + }, + }, + }, + }); + } + setCreatedPipelineUuid(pipelineUuid); + + const configToSave = ensureHttpBotSigningSecret( + selectedAdapter, + adapterConfig, + ); + setAdapterConfig(configToSave); + await httpClient.updateBot(createdBotUuid, { name: botName, description: botDescription || '', adapter: selectedAdapter, - adapter_config: adapterConfig, + adapter_config: configToSave, enable: true, + use_pipeline_uuid: pipelineUuid, }); setBotSaved(true); + setMessageReceived(false); // Re-fetch runtime info to get updated webhook URL(s) try { @@ -404,8 +563,19 @@ export default function WizardPage() { } // Persist progress - saveProgress({ step: 1, bot_saved: true }); + saveProgress({ + step: 1, + bot_saved: true, + message_received: false, + created_pipeline_uuid: pipelineUuid, + }); } catch (err) { + if (createdPipelineThisAttempt) { + await httpClient + .deletePipeline(createdPipelineThisAttempt) + .catch(() => {}); + setCreatedPipelineUuid(null); + } const apiErr = err as { msg?: string }; toast.error( t('wizard.createError') + (apiErr?.msg ? `: ${apiErr.msg}` : ''), @@ -423,56 +593,154 @@ export default function WizardPage() { 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 () => { - if (!selectedRunner || !createdBotUuid) return; + if (!aiChoice || !createdBotUuid || !createdPipelineUuid) return; + if (aiChoice === 'external' && (!selectedRunner || !isRunnerConfigComplete)) + return; + if (aiChoice === 'own-model' && !ownModelSelection) return; setIsSubmitting(true); + let externalPipelineUuid: string | null = null; + let externalPipelineBound = false; + let createdOwnModelUuid: string | null = null; + let ownModelPipelineUuid: string | null = null; + let ownModelPipelineBound = false; + let originalOwnModelBot: Bot | null = null; try { - // 1. Create pipeline (backend fills config from default template) - const pipeline: Pipeline = { - name: `${botName} Pipeline`, - description: botDescription || '', - config: {}, - }; - const pipelineResp = await httpClient.createPipeline(pipeline); + if (aiChoice === 'external' && selectedRunner) { + const pipelineResp = await httpClient.createPipeline({ + name: `${botName} External Agent`, + description: botDescription || '', + config: {}, + }); + externalPipelineUuid = pipelineResp.uuid; + const createdPipeline = await httpClient.getPipeline(pipelineResp.uuid); + const fullConfig = createdPipeline.pipeline.config; + await httpClient.updatePipeline(pipelineResp.uuid, { + name: `${botName} External Agent`, + description: botDescription || '', + config: { + ...fullConfig, + ai: { + ...fullConfig.ai, + runner: { runner: selectedRunner }, + [selectedRunner]: runnerConfig, + }, + }, + }); - // 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 fullConfig = createdPipeline.pipeline.config; + const botData = await httpClient.getBot(createdBotUuid); + const existingBot = botData.bot; + await httpClient.updateBot(createdBotUuid, { + name: existingBot.name, + description: existingBot.description, + adapter: existingBot.adapter, + adapter_config: existingBot.adapter_config, + enable: existingBot.enable, + use_pipeline_uuid: pipelineResp.uuid, + }); + externalPipelineBound = true; + } - const mergedConfig = { - ...fullConfig, - ai: { - ...fullConfig.ai, - runner: { runner: selectedRunner }, - [selectedRunner]: runnerConfig, - }, - }; + if (aiChoice === 'own-model' && ownModelSelection) { + const modelResponse = await httpClient.createProviderLLMModel({ + name: ownModelSelection.model.name, + provider_uuid: ownModelSelection.providerUuid, + abilities: ownModelSelection.model.abilities ?? [], + reasoning_config: { level: 'provider_default' }, + context_length: ownModelSelection.model.context_length ?? null, + extra_args: {}, + }); + createdOwnModelUuid = modelResponse.uuid; - await httpClient.updatePipeline(pipelineResp.uuid, { - name: `${botName} Pipeline`, - description: botDescription || '', - config: mergedConfig, - }); + const pipelineResponse = await httpClient.createPipeline({ + name: `${botName} Custom Agent`, + description: botDescription || '', + config: {}, + }); + ownModelPipelineUuid = pipelineResponse.uuid; + const createdPipeline = + await httpClient.getPipeline(ownModelPipelineUuid); + const fullConfig = createdPipeline.pipeline.config as unknown as Record< + string, + unknown + >; + await httpClient.updatePipeline(ownModelPipelineUuid, { + name: `${botName} Custom Agent`, + description: botDescription || '', + config: configureLocalAgentPrimaryModel( + fullConfig, + createdOwnModelUuid, + ), + }); - // 3. Link pipeline to the bot created in Step 1 - const botData = await httpClient.getBot(createdBotUuid); - const existingBot = botData.bot; - await httpClient.updateBot(createdBotUuid, { - name: existingBot.name, - description: existingBot.description, - adapter: existingBot.adapter, - adapter_config: existingBot.adapter_config, - enable: existingBot.enable, - use_pipeline_uuid: pipelineResp.uuid, - }); + originalOwnModelBot = (await httpClient.getBot(createdBotUuid)).bot; + await httpClient.updateBot(createdBotUuid, { + name: originalOwnModelBot.name, + description: originalOwnModelBot.description, + adapter: originalOwnModelBot.adapter, + adapter_config: originalOwnModelBot.adapter_config, + enable: originalOwnModelBot.enable, + use_pipeline_uuid: ownModelPipelineUuid, + }); + ownModelPipelineBound = true; + } - setCurrentStep(3); + await completeWizard(); + navigate('/home', { replace: true }); } catch (err) { + if (externalPipelineUuid && !externalPipelineBound) { + await httpClient.deletePipeline(externalPipelineUuid).catch(() => {}); + } + if (createdOwnModelUuid) { + let canCleanUpOwnModelResources = !ownModelPipelineBound; + if (ownModelPipelineBound && originalOwnModelBot) { + try { + await httpClient.updateBot(createdBotUuid, { + name: originalOwnModelBot.name, + description: originalOwnModelBot.description, + adapter: originalOwnModelBot.adapter, + adapter_config: originalOwnModelBot.adapter_config, + enable: originalOwnModelBot.enable, + use_pipeline_uuid: originalOwnModelBot.use_pipeline_uuid, + }); + canCleanUpOwnModelResources = true; + } catch { + canCleanUpOwnModelResources = false; + } + } + + if (canCleanUpOwnModelResources) { + let pipelineDeleted = ownModelPipelineUuid === null; + if (ownModelPipelineUuid) { + try { + await httpClient.deletePipeline(ownModelPipelineUuid); + pipelineDeleted = true; + } catch { + pipelineDeleted = false; + } + } + if (pipelineDeleted) { + await httpClient + .deleteProviderLLMModel(createdOwnModelUuid) + .catch(() => {}); + } + } + } const apiErr = err as { msg?: string }; toast.error( t('wizard.createError') + (apiErr?.msg ? `: ${apiErr.msg}` : ''), @@ -482,10 +750,16 @@ export default function WizardPage() { } }, [ selectedRunner, + isRunnerConfigComplete, createdBotUuid, + createdPipelineUuid, + aiChoice, botName, botDescription, runnerConfig, + ownModelSelection, + completeWizard, + navigate, t, ]); @@ -505,7 +779,9 @@ export default function WizardPage() { step: 0, selected_adapter: null, created_bot_uuid: null, + created_pipeline_uuid: null, bot_saved: false, + message_received: false, selected_runner: null, }); systemInfo.wizard_progress = null; @@ -533,7 +809,6 @@ export default function WizardPage() { t('wizard.step.platform'), t('wizard.step.botConfig'), t('wizard.step.aiEngine'), - t('wizard.step.done'), ]; return ( @@ -548,7 +823,7 @@ export default function WizardPage() {
- {currentStep < 3 && ( + {currentStep < TOTAL_STEPS && (
{/* Footer navigation */} - {currentStep < 3 && ( + {currentStep < TOTAL_STEPS && (
)}
@@ -739,7 +1028,10 @@ function StepPlatform({ const { t } = useTranslation(); const groupedAdapters = useMemo(() => { - const withCategories = adapters.map((a) => ({ + const uniqueAdapters = Array.from( + new Map(adapters.map((adapter) => [adapter.name, adapter])).values(), + ); + const withCategories = uniqueAdapters.map((a) => ({ ...a, categories: a.spec.categories, })); @@ -828,6 +1120,38 @@ function StepPlatform({ // Step 1: Bot Configuration + Logs // --------------------------------------------------------------------------- +function PageBotFloatingWidget({ + botUuid, + title, + testNotice, +}: { + botUuid: string; + title?: string; + testNotice: string; +}) { + useEffect(() => { + const script = document.createElement('script'); + script.src = `${window.location.origin}/api/v1/embed/${botUuid}/widget.js?preview=wizard&v=${Date.now()}`; + script.dataset.title = title || 'LangBot'; + script.dataset.testNotice = testNotice; + 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, testNotice, title]); + + return null; +} + function StepBotConfig({ adapterConfigItems, adapterConfigValues, @@ -837,6 +1161,8 @@ function StepBotConfig({ createdBotUuid, isSavingBot, botSaved, + messageReceived, + onMessageReceived, onSaveBot, webhookUrl, extraWebhookUrl, @@ -849,17 +1175,34 @@ function StepBotConfig({ createdBotUuid: string | null; isSavingBot: boolean; botSaved: boolean; + messageReceived: boolean; + onMessageReceived: () => void; onSaveBot: () => void; webhookUrl: string; extraWebhookUrl: string; }) { const { t } = useTranslation(); + const [testMessage, setTestMessage] = useState( + t('wizard.botConfig.httpTestDefaultMessage'), + ); + const [isSendingTest, setIsSendingTest] = useState(false); const adapterLabel = useMemo(() => { const a = adapters.find((ad) => ad.name === selectedAdapterName); return a ? extractI18nObject(a.label) : (selectedAdapterName ?? ''); }, [adapters, selectedAdapterName]); + const webhookModeEnabled = useMemo( + () => + isWebhookModeEnabled(adapterConfigItems, adapterConfigValues) && + Boolean(webhookUrl), + [adapterConfigItems, adapterConfigValues, webhookUrl], + ); + const receivedMessageWithoutLangBotAccount = + messageReceived && userInfo?.account_type !== 'space'; + const receivedMessageSuccessfully = + messageReceived && !receivedMessageWithoutLangBotAccount; + // Stable callback ref const onAdapterConfigRef = useRef(onAdapterConfigChange); onAdapterConfigRef.current = onAdapterConfigChange; @@ -868,8 +1211,43 @@ 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: getErrorMessage(error), + }), + ); + } finally { + setIsSendingTest(false); + } + }, [createdBotUuid, testMessage, t]); + return (
+ {selectedAdapterName === 'web_page_bot' && botSaved && createdBotUuid && ( + + )} +

{t('wizard.botConfig.title')}

@@ -877,6 +1255,110 @@ function StepBotConfig({

+ {botSaved && ( +
+
+
+ {receivedMessageWithoutLangBotAccount ? ( + + ) : messageReceived ? ( + + ) : selectedAdapterName === 'web_page_bot' ? ( + + ) : selectedAdapterName === 'http_bot' ? ( + + ) : webhookModeEnabled ? ( + + ) : ( + + )} +
+
+

+ {messageReceived + ? t( + receivedMessageWithoutLangBotAccount + ? 'wizard.botConfig.messageReceivedLocalAccountWarning' + : 'wizard.botConfig.messageReceived', + ) + : selectedAdapterName === 'web_page_bot' + ? t('wizard.botConfig.pageBotTestPrompt') + : selectedAdapterName === 'http_bot' + ? t('wizard.botConfig.httpTestPrompt') + : webhookModeEnabled + ? t('wizard.botConfig.webhookTestPrompt') + : t('wizard.botConfig.waitingForMessage')} +

+ + {!messageReceived && webhookModeEnabled && ( +
+
+ + {webhookUrl} + + +
+ + {selectedAdapterName === 'http_bot' && ( +
+ setTestMessage(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') void sendHttpBotTest(); + }} + className="bg-background" + /> + +
+ )} +
+ )} +
+
+
+ )} +
{/* Left column: Adapter config form */}
@@ -940,18 +1422,6 @@ function StepBotConfig({ )} - - {/* Bot saved indicator */} - {botSaved && ( -
-
- -
- - {t('wizard.botConfig.botSaved')} - -
- )}
{/* Right column: Bot logs */} @@ -968,6 +1438,7 @@ function StepBotConfig({ botId={createdBotUuid} autoExpandImages hideToolbar + onMessageReceived={onMessageReceived} /> @@ -983,18 +1454,26 @@ function StepBotConfig({ function StepAIEngine({ runnerOptions, + choice, + onChoiceChange, selected, onSelect, runnerConfigItems, runnerConfigValues, onRunnerConfigChange, + onOwnModelSelectionChange, }: { 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; onSelect: (name: string) => void; runnerConfigItems: IDynamicFormItemSchema[]; runnerConfigValues: Record; onRunnerConfigChange: (v: Record) => void; + onOwnModelSelectionChange: (selection: OwnModelSelection | null) => void; }) { const { t } = useTranslation(); @@ -1011,18 +1490,103 @@ function StepAIEngine({ return r ? extractI18nObject(r.label) : (selected ?? ''); }, [runnerOptions, selected]); - // Before any runner is selected: centered grid layout - if (!selected) { + const choices = [ + { + id: 'more-features' as const, + icon: Blocks, + title: t('wizard.aiEngine.moreFeaturesTitle'), + description: t('wizard.aiEngine.moreFeaturesDescription'), + }, + { + id: 'external' as const, + icon: Cable, + 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'), + }, + ]; + + if (choice === 'own-model') { return ( -
+
+ onChoiceChange('more-features')} + onSelectionChange={onOwnModelSelectionChange} + /> +
+ ); + } + + if (choice !== 'external') { + return ( +

{t('wizard.aiEngine.title')}

- {t('wizard.aiEngine.description')} + {t('wizard.aiEngine.optionalDescription')}

+
+ {choices.map((item) => { + const Icon = item.icon; + return ( + onChoiceChange(item.id)} + > + + + {item.title} + {item.description} + + + ); + })} +
+
+ ); + } + + // Before any runner is selected: centered grid layout + if (!selected) { + return ( +
+
+

+ {t('wizard.aiEngine.externalTitle')} +

+

+ {t('wizard.aiEngine.runnerDescription')} +

+
+
{runnerOptions.map((opt) => ( = lg): side-by-side with independent scroll per column return ( -
+
-

{t('wizard.aiEngine.title')}

+

+ {t('wizard.aiEngine.externalTitle')} +

{t('wizard.aiEngine.description')}

-
+ + +
{/* Left: runner list */}
{/* p-1 provides space for ring-2 (4px) to render without clipping */} @@ -1106,7 +1685,7 @@ function StepAIEngine({
{/* Right: runner configuration — fixed width on desktop */} -
+
{runnerConfigItems.length > 0 && ( @@ -1132,100 +1711,3 @@ function StepAIEngine({
); } - -// --------------------------------------------------------------------------- -// 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 ( -
- {/* Confetti particles */} -
- {particles.map((p) => ( -
- ))} -
- - -

{t('wizard.done.title')}

-

- {t('wizard.done.description')} -

- - - -
- ); -} diff --git a/web/src/app/wizard/utils.ts b/web/src/app/wizard/utils.ts new file mode 100644 index 000000000..064851d06 --- /dev/null +++ b/web/src/app/wizard/utils.ts @@ -0,0 +1,137 @@ +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, +): Record { + 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, +): 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) + ); +} + +interface RequiredConfigItem { + name: string; + required: boolean; + default: unknown; +} + +function isPlaceholderDefault(value: string, defaultValue: unknown): boolean { + if (typeof defaultValue !== 'string' || value !== defaultValue.trim()) { + return false; + } + return /(^|:\/\/)your-/i.test(value); +} + +export function isRequiredRunnerConfigComplete( + configItems: RequiredConfigItem[], + configValues: Record, +): boolean { + return configItems + .filter((item) => item.required) + .every((item) => { + const value = configValues[item.name]; + if (typeof value === 'string') { + const normalizedValue = value.trim(); + return ( + normalizedValue.length > 0 && + !isPlaceholderDefault(normalizedValue, item.default) + ); + } + if (Array.isArray(value)) return value.length > 0; + return value !== undefined && value !== null; + }); +} + +export function configureLocalAgentPrimaryModel( + config: Record, + modelUuid: string, +): Record { + const aiConfig = (config.ai ?? {}) as Record; + const runnerConfig = (aiConfig.runner ?? {}) as Record; + const localAgentConfig = (aiConfig['local-agent'] ?? {}) as Record< + string, + unknown + >; + const modelConfig = (localAgentConfig.model ?? {}) as Record; + + return { + ...config, + ai: { + ...aiConfig, + runner: { ...runnerConfig, runner: 'local-agent' }, + 'local-agent': { + ...localAgentConfig, + model: { + ...modelConfig, + primary: modelUuid, + fallbacks: Array.isArray(modelConfig.fallbacks) + ? modelConfig.fallbacks + : [], + }, + }, + }, + }; +} diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 258e7f21e..5faaa2293 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -1827,14 +1827,80 @@ 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.', + messageReceivedLocalAccountWarning: + 'The bot-side connection is configured correctly and received an IM message. Because you are not signed in with a LangBot Account, model calls may fail; continue to the next step to add your own model.', + pageBotTestPrompt: + 'Page Bot is enabled. Click the chat bubble in the lower-right corner and send a message to verify the full conversation flow.', + pageBotTestNotice: + '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.', }, aiEngine: { - title: 'Select an AI Engine', + title: 'Configure 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 bases, and other capabilities to the Agent that was just generated automatically.', + runnerDescription: + 'Select a runner for the external agent and configure its connection.', + backToChoices: 'Back to options', + createExternal: 'Create and Bind', + finishWithModel: 'Use Selected Model & Finish', + openWorkbench: 'Open Workbench', }, config: { botInfo: 'Bot Information', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index e23b6e101..81ca9a344 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -1744,14 +1744,79 @@ const jaJP = { resaveBot: '設定を再保存', botSaved: 'ボット設定が保存され、有効になりました。ログを確認して接続を検証してください。', + waitingForMessage: + 'ボットが有効になりました。続行するには IM からメッセージを送信してください。', + messageReceived: + 'ボットが IM メッセージを受信しました。次のステップに進めます。', + messageReceivedLocalAccountWarning: + 'ボット側の接続設定は正常で、IM メッセージを受信できています。LangBot Account でログインしていないためモデル呼び出しが失敗する場合がありますが、次のステップで独自のモデルを追加できます。', + pageBotTestPrompt: + 'ページボットが有効になりました。右下のチャットバブルをクリックしてメッセージを送信し、会話フロー全体を確認してください。', + pageBotTestNotice: + 'テスト専用です。実際の外部 Web ページにコードを埋め込んでください。', + webhookTestPrompt: + 'コールバック URL の準備ができました。外部プラットフォームに設定し、ボットへ実際のメッセージを送信してください。', + httpTestPrompt: + 'HTTP Bot が有効になりました。実際の受信メッセージを送信して接続を確認できます。', + httpTestDefaultMessage: 'こんにちは。これは接続テストメッセージです。', + sendHttpTest: 'テストメッセージを送信', + httpTestAccepted: + 'テストメッセージを受け付けました。まもなくログに表示されます。', + httpTestMissingSecret: + '受信署名シークレットを入力し、先に設定を保存してください。', + httpTestFailed: 'テストメッセージの送信に失敗しました:{{error}}', logsTitle: 'ボットログ', logsDescription: 'ボットの活動を監視して、プラットフォーム接続が正常に動作していることを確認します。', }, aiEngine: { - title: 'AIエンジンを選択', + title: 'AIエンジンを設定', description: 'ボットのインテリジェンスを駆動するAIエンジンを選択してください。', + optionalDescription: + 'このステップは任意です。現在の Agent をどのように設定するか選択してください。', + externalTitle: '外部プラットフォームの Agent を接続', + externalDescription: + 'Dify、n8n、Coze などを接続し、ボットのパイプラインを置き換えます。', + ownModelTitle: '自分のモデルを使用', + ownModelDescription: + 'プロバイダーを追加し、モデルをスキャンまたは手動入力して設定を完了します。', + ownModelSetupTitle: '自分のモデルを追加', + ownModelSetupDescription: + 'モデルプロバイダーを追加すると自動スキャンされます。モデル ID の手動入力も可能です。', + addProviderTitle: 'プロバイダーを追加', + addProviderDescription: + '接続とモデルスキャンに使用するプロバイダー情報と API キーを入力します。', + 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: + 'ワークベンチを開き、自動生成されたばかりの Agent にツール、ナレッジベースなどの機能を追加します。', + runnerDescription: '外部 Agent の Runner を選択し、接続を設定します。', + backToChoices: '選択肢に戻る', + createExternal: '作成して関連付ける', + finishWithModel: '選択したモデルを使用して完了', + openWorkbench: 'ワークベンチを開く', }, config: { botInfo: 'ボット情報', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 45011e943..58c1b5133 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -1749,12 +1749,65 @@ const zhHans = { saveBot: '保存并启用', resaveBot: '重新保存配置', botSaved: '机器人配置已保存并启用,请查看日志确认连接正常。', + waitingForMessage: '机器人已启用。请在 IM 中向机器人发送一条消息以继续。', + messageReceived: '机器人已成功收到 IM 消息,可以进入下一步。', + messageReceivedLocalAccountWarning: + '机器人侧已配置正常并成功收到 IM 消息。当前未通过 LangBot Account 登录,模型调用可能报错;可以进入下一步添加自己的模型。', + pageBotTestPrompt: + '页面机器人已启用。点击右下角聊天气泡并发送一条消息,验证完整对话链路。', + pageBotTestNotice: '仅供测试使用,请嵌入代码到真实外部网页。', + webhookTestPrompt: + '回调地址已就绪。将它配置到外部平台,然后向机器人发送一条真实消息。', + httpTestPrompt: 'HTTP Bot 已启用。可直接发送一条真实入站消息验证连接。', + httpTestDefaultMessage: '你好,这是一条连接测试消息。', + sendHttpTest: '发送测试消息', + httpTestAccepted: '测试消息已被机器人接收,请稍候查看日志。', + httpTestMissingSecret: '请先填写入站签名密钥并重新保存。', + httpTestFailed: '测试消息发送失败:{{error}}', logsTitle: '机器人日志', logsDescription: '监控机器人活动,确认平台连接是否正常工作。', }, aiEngine: { - title: '选择 AI 引擎', + title: '配置 AI 引擎', description: '选择驱动机器人智能的 AI 引擎。', + optionalDescription: '这一步可选。选择接下来要如何完善当前 Agent。', + externalTitle: '接入外部平台 Agent', + externalDescription: + '接入 Dify、n8n、Coze 等平台,并替换当前机器人的流水线。', + ownModelTitle: '改成使用自己的模型', + ownModelDescription: + '添加模型供应商,自动扫描或手动填写模型以快速完成引导。', + ownModelSetupTitle: '添加你自己的模型', + ownModelSetupDescription: + '先添加模型供应商,保存后会自动扫描,也可以手动填写模型 ID。', + addProviderTitle: '添加供应商', + addProviderDescription: '填写供应商和 API Key,用于连接并扫描模型。', + selectModelTitle: '选择模型', + selectScannedModelTitle: '选择一个模型', + selectScannedModelDescription: + '选中的模型将作为新流水线的主模型,机器人会切换到这条流水线。', + scanModelMode: '扫描模型', + manualModelMode: '手动添加', + scanningModels: '正在扫描可用模型…', + noScannedModels: '没有扫描到可用的对话模型,请检查供应商配置。', + scanModelsFailed: '模型扫描失败,请检查地址和 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: '机器人信息', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index d1ab15273..8fb8129ef 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -1584,11 +1584,13 @@ const zhHant = { saveBot: '儲存並啟用', resaveBot: '重新儲存配置', botSaved: '機器人配置已儲存並啟用,請查看日誌確認連接正常。', + messageReceivedLocalAccountWarning: + '機器人側已配置正常並成功收到 IM 訊息。目前未透過 LangBot Account 登入,模型呼叫可能報錯;可以進入下一步新增自己的模型。', logsTitle: '機器人日誌', logsDescription: '監控機器人活動,確認平台連接是否正常運作。', }, aiEngine: { - title: '選擇 AI 引擎', + title: '配置 AI 引擎', description: '選擇驅動機器人智慧的 AI 引擎。', }, config: { diff --git a/web/tests/unit/adapter-categories.test.mjs b/web/tests/unit/adapter-categories.test.mjs new file mode 100644 index 000000000..2c2126831 --- /dev/null +++ b/web/tests/unit/adapter-categories.test.mjs @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import ts from 'typescript'; +import { fileURLToPath } from 'node:url'; + +const currentDirectory = path.dirname(fileURLToPath(import.meta.url)); +const sourcePath = path.resolve( + currentDirectory, + '../../src/app/infra/entities/adapter-categories.ts', +); + +function loadCategoryHelpers(language = 'zh-Hans') { + const source = fs.readFileSync(sourcePath, 'utf8'); + const compiled = ts.transpileModule(source, { + compilerOptions: { + esModuleInterop: true, + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + }, + }).outputText; + const loadedModule = { exports: {} }; + new Function('require', 'module', 'exports', compiled)( + (name) => { + if (name === 'i18next') return { language }; + throw new Error(`Unexpected runtime import: ${name}`); + }, + loadedModule, + loadedModule.exports, + ); + return loadedModule.exports; +} + +test('places an adapter only once when metadata repeats a category', () => { + const { groupByCategory } = loadCategoryHelpers(); + const adapter = { name: 'http_bot', categories: ['popular', 'popular'] }; + + assert.deepEqual(groupByCategory([adapter]), [ + { categoryId: 'popular', items: [adapter] }, + ]); +}); diff --git a/web/tests/unit/dynamic-form-save-values.test.mjs b/web/tests/unit/dynamic-form-save-values.test.mjs index 8d9e2121c..f5626726b 100644 --- a/web/tests/unit/dynamic-form-save-values.test.mjs +++ b/web/tests/unit/dynamic-form-save-values.test.mjs @@ -24,11 +24,11 @@ function loadNormalizer() { loadedModule, loadedModule.exports, ); - return loadedModule.exports.normalizeDynamicFormValuesForSave; + return loadedModule.exports; } test('normalizes only single-line text fields in a dynamic form save snapshot', () => { - const normalizeDynamicFormValuesForSave = loadNormalizer(); + const { normalizeDynamicFormValuesForSave } = loadNormalizer(); const specs = [ { name: 'single-line', type: 'string', default: '' }, { name: 'multiline', type: 'text', default: '' }, @@ -70,3 +70,29 @@ test('normalizes only single-line text fields in a dynamic form save snapshot', }, }); }); + +test('normalizes missing dynamic form defaults into controlled values', () => { + const { normalizeDynamicFormFieldValue } = loadNormalizer(); + + assert.equal( + normalizeDynamicFormFieldValue( + { name: 'api-key', type: 'string', default: undefined }, + undefined, + ), + '', + ); + assert.equal( + normalizeDynamicFormFieldValue( + { name: 'enabled', type: 'boolean', default: undefined }, + undefined, + ), + false, + ); + assert.deepEqual( + normalizeDynamicFormFieldValue( + { name: 'items', type: 'array[string]', default: undefined }, + undefined, + ), + [], + ); +}); diff --git a/web/tests/unit/wizard-http-bot.test.mjs b/web/tests/unit/wizard-http-bot.test.mjs new file mode 100644 index 000000000..656e2f8a8 --- /dev/null +++ b/web/tests/unit/wizard-http-bot.test.mjs @@ -0,0 +1,145 @@ +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, + isRequiredRunnerConfigComplete, + 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); +}); + +test('requires real values for required external runner configuration', () => { + const fields = [ + { name: 'base-url', required: true, default: 'https://api.dify.ai/v1' }, + { name: 'api-key', required: true, default: 'your-api-key' }, + { name: 'optional', required: false, default: '' }, + ]; + + assert.equal( + isRequiredRunnerConfigComplete(fields, { + 'base-url': 'https://api.dify.ai/v1', + 'api-key': 'your-api-key', + }), + false, + ); + assert.equal( + isRequiredRunnerConfigComplete(fields, { + 'base-url': 'https://api.dify.ai/v1', + 'api-key': 'app-real-key', + }), + true, + ); +}); diff --git a/web/tests/unit/wizard-page-bot.test.mjs b/web/tests/unit/wizard-page-bot.test.mjs new file mode 100644 index 000000000..bf14ffae6 --- /dev/null +++ b/web/tests/unit/wizard-page-bot.test.mjs @@ -0,0 +1,128 @@ +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 ownModelSetupSource = fs.readFileSync( + path.resolve( + currentDirectory, + '../../src/app/wizard/components/OwnModelSetup.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/); +}); + +test('defaults the AI engine step to the workbench option and lists it first', () => { + assert.match( + wizardSource, + /const \[aiChoice, setAiChoice\] = useState<[\s\S]*?>\('more-features'\);/, + ); + + const choicesStart = wizardSource.indexOf('const choices = ['); + const moreFeaturesChoice = wizardSource.indexOf( + "id: 'more-features' as const", + choicesStart, + ); + const externalChoice = wizardSource.indexOf( + "id: 'external' as const", + choicesStart, + ); + const ownModelChoice = wizardSource.indexOf( + "id: 'own-model' as const", + choicesStart, + ); + + assert.ok(choicesStart >= 0); + assert.ok(moreFeaturesChoice > choicesStart); + assert.ok(moreFeaturesChoice < externalChoice); + assert.ok(moreFeaturesChoice < ownModelChoice); +}); + +test('uses the external-runner layout only while that configuration is open', () => { + assert.match( + wizardSource, + /currentStep === 2 && aiChoice === 'external' && selectedRunner/, + ); +}); + +test('restores the default workbench choice when leaving a nested AI setup', () => { + assert.equal( + wizardSource.match(/onChoiceChange\('more-features'\)/g)?.length, + 3, + ); +}); + +test('warns local-account users after the bot receives an IM message', () => { + assert.match( + wizardSource, + /messageReceived && userInfo\?\.account_type !== 'space'/, + ); + assert.match( + wizardSource, + /wizard\.botConfig\.messageReceivedLocalAccountWarning/, + ); + assert.match(wizardSource, / { + assert.match( + wizardSource, + /key="ai-engine-own-model"[\s\S]*?slide-in-from-right-4/, + ); + assert.match( + wizardSource, + /key="ai-engine-external-picker"[\s\S]*?slide-in-from-right-4/, + ); + assert.match( + wizardSource, + /key="ai-engine-choices"[\s\S]*?slide-in-from-left-4/, + ); + assert.match(wizardSource, /motion-reduce:animate-none/); +}); + +test('aligns the own-model title and back button with external Agent setup', () => { + const ownModelTitle = ownModelSetupSource.indexOf( + "t('wizard.aiEngine.ownModelSetupTitle')", + ); + const ownModelBack = ownModelSetupSource.indexOf( + "t('wizard.aiEngine.backToChoices')", + ); + + assert.ok(ownModelTitle >= 0); + assert.ok(ownModelBack > ownModelTitle); + assert.match(ownModelSetupSource, /mx-auto w-full max-w-4xl space-y-6/); +}); + +test('labels both external Agent setup states with their specific title', () => { + assert.equal( + wizardSource.match(/t\('wizard\.aiEngine\.externalTitle'\)/g)?.length, + 3, + ); +}); diff --git a/web/vite.config.ts b/web/vite.config.ts index d1023070a..b0f6ece18 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -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', + }, + }; });