feat(wizard): rework agent onboarding flow

This commit is contained in:
langbot-dev
2026-08-14 01:10:00 +08:00
parent 90f3d880e5
commit b0566f4c9d
11 changed files with 523 additions and 163 deletions
@@ -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'],
+72
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,72 @@ 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')
return [SpaceModelSelection.model_validate(model) for model in data]
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
@@ -820,6 +820,91 @@ class TestSpaceServiceGetModels:
await service.get_models() await service.get_models()
class TestSpaceServiceGetModelSelection:
"""Tests for availability-ranked model selection."""
@pytest.mark.parametrize('use_envelope', [False, True])
async def test_preserves_selection_order_and_category_query(self, use_envelope):
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',
},
]
payload = {'code': 0, 'data': {'models': models} if use_envelope else models}
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."""
@@ -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;
} }
+9
View File
@@ -1046,12 +1046,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;
+267 -163
View File
@@ -8,10 +8,12 @@ import {
ArrowRight, ArrowRight,
Check, Check,
Sparkles, Sparkles,
PartyPopper,
Loader2, Loader2,
X, X,
ExternalLink, ExternalLink,
Cable,
Settings2,
Blocks,
} from 'lucide-react'; } from 'lucide-react';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
@@ -21,12 +23,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,
@@ -71,7 +68,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 +90,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 +106,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 +118,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 +174,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 +200,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 +230,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 +306,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 +388,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 +404,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 +475,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,60 +501,80 @@ 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;
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 const botData = await httpClient.getBot(createdBotUuid);
// (includes trigger, safety, ai, output sections). const existingBot = botData.bot;
// Then merge only the AI section with the wizard's runner config. await httpClient.updateBot(createdBotUuid, {
const createdPipeline = await httpClient.getPipeline(pipelineResp.uuid); name: existingBot.name,
const fullConfig = createdPipeline.pipeline.config; description: existingBot.description,
adapter: existingBot.adapter,
adapter_config: existingBot.adapter_config,
enable: existingBot.enable,
use_pipeline_uuid: pipelineResp.uuid,
});
externalPipelineBound = true;
}
const mergedConfig = { await completeWizard();
...fullConfig, if (aiChoice === 'own-model') {
ai: { navigate(`/home/pipelines?id=${createdPipelineUuid}`, {
...fullConfig.ai, replace: true,
runner: { runner: selectedRunner }, });
[selectedRunner]: runnerConfig, } else {
}, navigate('/home', { replace: true });
}; }
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 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,
});
setCurrentStep(3);
} 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 +585,13 @@ export default function WizardPage() {
}, [ }, [
selectedRunner, selectedRunner,
createdBotUuid, createdBotUuid,
createdPipelineUuid,
aiChoice,
botName, botName,
botDescription, botDescription,
runnerConfig, runnerConfig,
completeWizard,
navigate,
t, t,
]); ]);
@@ -524,7 +629,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 +659,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 +673,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 +758,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 +768,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 +779,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 +812,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>
@@ -858,6 +975,8 @@ function StepBotConfig({
createdBotUuid, createdBotUuid,
isSavingBot, isSavingBot,
botSaved, botSaved,
messageReceived,
onMessageReceived,
onSaveBot, onSaveBot,
webhookUrl, webhookUrl,
extraWebhookUrl, extraWebhookUrl,
@@ -870,6 +989,8 @@ 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;
@@ -962,14 +1083,16 @@ function StepBotConfig({
</Card> </Card>
)} )}
{/* Bot saved indicator */} {/* Bot and inbound-message verification status */}
{botSaved && ( {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="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"> <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" /> <Check className="w-3 h-3 text-white" />
</div> </div>
<span className="text-sm text-green-700 dark:text-green-300"> <span className="text-sm text-green-700 dark:text-green-300">
{t('wizard.botConfig.botSaved')} {messageReceived
? t('wizard.botConfig.messageReceived')
: t('wizard.botConfig.waitingForMessage')}
</span> </span>
</div> </div>
)} )}
@@ -989,6 +1112,7 @@ function StepBotConfig({
botId={createdBotUuid} botId={createdBotUuid}
autoExpandImages autoExpandImages
hideToolbar hideToolbar
onMessageReceived={onMessageReceived}
/> />
</CardContent> </CardContent>
</Card> </Card>
@@ -1004,6 +1128,8 @@ function StepBotConfig({
function StepAIEngine({ function StepAIEngine({
runnerOptions, runnerOptions,
choice,
onChoiceChange,
selected, selected,
onSelect, onSelect,
isLocalAccount, isLocalAccount,
@@ -1013,6 +1139,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 +1166,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 +1232,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 +1275,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 +1380,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>
);
}
+21
View File
@@ -1827,6 +1827,10 @@ 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.',
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 +1839,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:
+20
View File
@@ -1744,6 +1744,10 @@ const jaJP = {
resaveBot: '設定を再保存', resaveBot: '設定を再保存',
botSaved: botSaved:
'ボット設定が保存され、有効になりました。ログを確認して接続を検証してください。', 'ボット設定が保存され、有効になりました。ログを確認して接続を検証してください。',
waitingForMessage:
'ボットが有効になりました。続行するには IM からメッセージを送信してください。',
messageReceived:
'ボットが IM メッセージを受信しました。次のステップに進めます。',
logsTitle: 'ボットログ', logsTitle: 'ボットログ',
logsDescription: logsDescription:
'ボットの活動を監視して、プラットフォーム接続が正常に動作していることを確認します。', 'ボットの活動を監視して、プラットフォーム接続が正常に動作していることを確認します。',
@@ -1752,6 +1756,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:
+15
View File
@@ -1749,12 +1749,27 @@ const zhHans = {
saveBot: '保存并启用', saveBot: '保存并启用',
resaveBot: '重新保存配置', resaveBot: '重新保存配置',
botSaved: '机器人配置已保存并启用,请查看日志确认连接正常。', botSaved: '机器人配置已保存并启用,请查看日志确认连接正常。',
waitingForMessage: '机器人已启用。请在 IM 中向机器人发送一条消息以继续。',
messageReceived: '机器人已成功收到 IM 消息,可以进入下一步。',
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,获取免费试用模型额度,零配置极速开箱!',