Compare commits

...

5 Commits

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