From 71e44f0e545e2f53da0383f699f5d6503042bfd7 Mon Sep 17 00:00:00 2001 From: Junyan Chin Date: Sat, 28 Mar 2026 17:24:39 +0800 Subject: [PATCH] Feat/space cta optimization (#2089) * feat(wizard): persist wizard progress to backend for session resumption Store wizard step, selected adapter, created bot UUID, and runner selection in the metadata table. On revisit, the wizard restores progress and verifies the bot still exists. Progress is cleared automatically when the wizard is completed or skipped. * feat(dynamic-form): optimize LLM model selection with space login CTA and improve localization strings * feat(web): add LangBot Cloud CTA for webhook URL fields in community edition Show a subtle hint below webhook URL fields prompting users about LangBot Cloud's public endpoint, only visible in community edition. Covers all 8 webhook-based adapters with i18n support (4 locales). --- .../pkg/api/http/controller/groups/system.py | 55 +- .../dynamic-form/DynamicFormComponent.tsx | 20 +- .../dynamic-form/DynamicFormItemComponent.tsx | 481 +++++++++++++++--- web/src/app/infra/entities/api/index.ts | 9 + web/src/app/infra/http/BackendClient.ts | 10 + web/src/app/infra/http/index.ts | 1 + web/src/app/wizard/page.tsx | 105 +++- web/src/i18n/locales/en-US.ts | 7 + web/src/i18n/locales/ja-JP.ts | 7 + web/src/i18n/locales/zh-Hans.ts | 7 + web/src/i18n/locales/zh-Hant.ts | 7 + 11 files changed, 623 insertions(+), 86 deletions(-) diff --git a/src/langbot/pkg/api/http/controller/groups/system.py b/src/langbot/pkg/api/http/controller/groups/system.py index 280985aa..c68a3837 100644 --- a/src/langbot/pkg/api/http/controller/groups/system.py +++ b/src/langbot/pkg/api/http/controller/groups/system.py @@ -1,3 +1,5 @@ +import json + import quart import sqlalchemy @@ -11,16 +13,21 @@ class SystemRouterGroup(group.RouterGroup): async def initialize(self) -> None: @self.route('/info', methods=['GET'], auth_type=group.AuthType.NONE) async def _() -> str: - # Read wizard_status from metadata table - # Possible values: 'skipped', 'completed'; absent key means 'none' + # Read wizard_status and wizard_progress from metadata table wizard_status = 'none' + wizard_progress = None try: result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.select(Metadata).where(Metadata.key == 'wizard_status') + sqlalchemy.select(Metadata).where(Metadata.key.in_(['wizard_status', 'wizard_progress'])) ) - row = result.first() - if row: - wizard_status = row.value + for row in result: + if row.key == 'wizard_status': + wizard_status = row.value + elif row.key == 'wizard_progress': + try: + wizard_progress = json.loads(row.value) + except (json.JSONDecodeError, TypeError): + wizard_progress = None except Exception: pass @@ -43,12 +50,13 @@ class SystemRouterGroup(group.RouterGroup): ), 'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}), 'wizard_status': wizard_status, + 'wizard_progress': wizard_progress, } ) @self.route('/wizard/completed', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) async def _() -> str: - """Mark wizard status in metadata table. + """Mark wizard status in metadata table and clear progress. Accepts JSON body: { "status": "skipped" | "completed" } """ @@ -69,11 +77,44 @@ class SystemRouterGroup(group.RouterGroup): await self.ap.persistence_mgr.execute_async( sqlalchemy.insert(Metadata).values(key='wizard_status', value=status) ) + + # Clear wizard progress when wizard is completed/skipped + await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(Metadata).where(Metadata.key == 'wizard_progress') + ) except Exception as e: return self.http_status(500, 500, f'Failed to update wizard status: {e}') return self.success(data={}) + @self.route('/wizard/progress', methods=['PUT'], auth_type=group.AuthType.USER_TOKEN) + async def _() -> str: + """Save wizard progress to metadata table. + + Accepts JSON body with wizard state fields: + { "step": int, "selected_adapter": str|null, "created_bot_uuid": str|null, + "bot_saved": bool, "selected_runner": str|null } + """ + data = await quart.request.get_json(silent=True) or {} + progress_json = json.dumps(data, ensure_ascii=False) + + try: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(Metadata).where(Metadata.key == 'wizard_progress') + ) + if result.first(): + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(Metadata).where(Metadata.key == 'wizard_progress').values(value=progress_json) + ) + else: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.insert(Metadata).values(key='wizard_progress', value=progress_json) + ) + except Exception as e: + return self.http_status(500, 500, f'Failed to save wizard progress: {e}') + + return self.success(data={}) + @self.route('/tasks', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) async def _() -> str: task_type = quart.request.args.get('type') diff --git a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx index 672b57e6..bf53cbbb 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx @@ -17,7 +17,8 @@ import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; -import { Copy, Check } from 'lucide-react'; +import { Copy, Check, Globe } from 'lucide-react'; +import { systemInfo } from '@/app/infra/http'; /** * Resolve the value referenced by a `show_if.field` string. @@ -61,6 +62,7 @@ function WebhookUrlField({ }) { const [copied, setCopied] = useState(false); const [extraCopied, setExtraCopied] = useState(false); + const { t } = useTranslation(); const handleCopy = (text: string, setter: (v: boolean) => void) => { if (navigator.clipboard && navigator.clipboard.writeText) { @@ -122,6 +124,22 @@ function WebhookUrlField({ {description && (

{description}

)} + {systemInfo.edition === 'community' && ( +
+ +

+ {t('bots.webhookSaasHint')}{' '} + + {t('bots.webhookSaasLink')} + +

+
+ )} ); } diff --git a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx index 78abf506..a33309bb 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx @@ -37,7 +37,22 @@ import { DialogFooter, } from '@/components/ui/dialog'; import { Checkbox } from '@/components/ui/checkbox'; -import { Plus, X, Eye, Wrench, Trash2 } from 'lucide-react'; +import { + Plus, + X, + Eye, + Wrench, + Trash2, + Sparkles, + Info, + Settings, +} from 'lucide-react'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import ModelsDialog from '@/app/home/components/models-dialog/ModelsDialog'; export default function DynamicFormItemComponent({ config, @@ -57,6 +72,25 @@ export default function DynamicFormItemComponent({ const [kbDialogOpen, setKbDialogOpen] = useState(false); const [tempSelectedKBIds, setTempSelectedKBIds] = useState([]); const { t } = useTranslation(); + const [modelsDialogOpen, setModelsDialogOpen] = useState(false); + + const fetchLlmModels = () => { + httpClient + .getProviderLLMModels() + .then((resp) => { + setLlmModels(resp.models); + }) + .catch((err) => { + toast.error(t('models.getModelListError') + err.msg); + }); + }; + + const handleModelsDialogChange = (open: boolean) => { + setModelsDialogOpen(open); + if (!open) { + fetchLlmModels(); + } + }; const handleFileUpload = async (file: File): Promise => { const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB @@ -88,26 +122,35 @@ export default function DynamicFormItemComponent({ } }; + // Whether to show Space login CTA in model selectors + const showSpaceLoginCTA = + !systemInfo.disable_models_service && userInfo?.account_type !== 'space'; + + const handleSpaceLogin = () => { + try { + const token = localStorage.getItem('token'); + if (!token) { + toast.error(t('common.error')); + return; + } + const currentOrigin = window.location.origin; + const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`; + httpClient + .getSpaceAuthorizeUrl(redirectUri, token) + .then((response) => { + window.location.href = response.authorize_url; + }) + .catch(() => { + toast.error(t('common.spaceLoginFailed')); + }); + } catch { + toast.error(t('common.spaceLoginFailed')); + } + }; + useEffect(() => { if (config.type === DynamicFormItemType.LLM_MODEL_SELECTOR) { - httpClient - .getProviderLLMModels() - .then((resp) => { - let models = resp.models; - // Filter out space-chat-completions models when not logged in with space account or when models service is disabled - if ( - systemInfo.disable_models_service || - userInfo?.account_type !== 'space' - ) { - models = models.filter( - (m) => m.provider?.requester !== 'space-chat-completions', - ); - } - setLlmModels(models); - }) - .catch((err) => { - toast.error(t('models.getModelListError') + err.msg); - }); + fetchLlmModels(); } }, [config.type]); @@ -126,23 +169,7 @@ export default function DynamicFormItemComponent({ useEffect(() => { if (config.type === DynamicFormItemType.MODEL_FALLBACK_SELECTOR) { - httpClient - .getProviderLLMModels() - .then((resp) => { - let models = resp.models; - if ( - systemInfo.disable_models_service || - userInfo?.account_type !== 'space' - ) { - models = models.filter( - (m) => m.provider?.requester !== 'space-chat-completions', - ); - } - setLlmModels(models); - }) - .catch((err) => { - toast.error('Failed to get LLM model list: ' + err.msg); - }); + fetchLlmModels(); } }, [config.type]); @@ -259,8 +286,16 @@ export default function DynamicFormItemComponent({ ); case DynamicFormItemType.LLM_MODEL_SELECTOR: - // Group models by provider - const groupedModels = llmModels.reduce( + // Separate space models from regular models + const spaceModels = llmModels.filter( + (m) => m.provider?.requester === 'space-chat-completions', + ); + const regularModels = llmModels.filter( + (m) => m.provider?.requester !== 'space-chat-completions', + ); + + // Group regular models by provider + const groupedModels = regularModels.reduce( (acc, model) => { const providerName = model.provider?.name || model.provider?.requester || 'Unknown'; @@ -271,33 +306,180 @@ export default function DynamicFormItemComponent({ {} as Record, ); + // Group space models by provider (for logged-in users) + const groupedSpaceModels = spaceModels.reduce( + (acc, model) => { + const providerName = + model.provider?.name || model.provider?.requester || 'Unknown'; + if (!acc[providerName]) acc[providerName] = []; + acc[providerName].push(model); + return acc; + }, + {} as Record, + ); + + // Hardcoded preview model names for CTA when no space models are synced + const previewModelNames = [ + 'gpt-4o', + 'claude-sonnet-4-20250514', + 'deepseek-chat', + 'gemini-2.5-flash', + 'qwen-plus', + ]; + return ( -
- + + + + + {Object.entries(groupedModels).map(([providerName, models]) => ( + + {providerName} + {models.map((model) => ( + + + {model.name} + {model.abilities?.includes('vision') && ( + + )} + {model.abilities?.includes('func_call') && ( + + )} + + + ))} + + ))} + {/* Space models section */} + {showSpaceLoginCTA ? ( + + + + + {t('models.langbotModels')} + + e.preventDefault()} + > + + + + {t('models.spaceTrialTooltip')} + + - - ))} - - ))} - - + +
e.preventDefault()} + > + {/* Preview models (first 3 visible, rest blurred) */} + {(spaceModels.length > 0 + ? spaceModels.map((m) => m.name) + : previewModelNames + ) + .slice(0, 3) + .map((name) => ( +
+ {name} +
+ ))} + {/* Blurred remaining models with login overlay */} +
+
+ {(spaceModels.length > 0 + ? spaceModels.map((m) => m.name) + : previewModelNames + ) + .slice(3) + .map((name) => ( +
+ {name} +
+ ))} +
+ {/* Login overlay */} +
+ +
+
+
+ + ) : !systemInfo.disable_models_service ? ( + // User is logged into Space — show space models normally + Object.entries(groupedSpaceModels).map( + ([providerName, models]) => ( + + + + + {providerName} + + + {models.map((model) => ( + + + {model.name} + {model.abilities?.includes('vision') && ( + + )} + {model.abilities?.includes('func_call') && ( + + )} + + + ))} + + ), + ) + ) : null} + + +
+ + + + + {t('models.title')} + + ); @@ -338,8 +520,16 @@ export default function DynamicFormItemComponent({ ); case DynamicFormItemType.MODEL_FALLBACK_SELECTOR: { - // Group models by provider - const groupedModelsForFallback = llmModels.reduce( + // Separate space models from regular models + const fbSpaceModels = llmModels.filter( + (m) => m.provider?.requester === 'space-chat-completions', + ); + const fbRegularModels = llmModels.filter( + (m) => m.provider?.requester !== 'space-chat-completions', + ); + + // Group regular models by provider + const groupedModelsForFallback = fbRegularModels.reduce( (acc, model) => { const providerName = model.provider?.name || model.provider?.requester || 'Unknown'; @@ -350,6 +540,27 @@ export default function DynamicFormItemComponent({ {} as Record, ); + // Group space models by provider (for logged-in users) + const fbGroupedSpaceModels = fbSpaceModels.reduce( + (acc, model) => { + const providerName = + model.provider?.name || model.provider?.requester || 'Unknown'; + if (!acc[providerName]) acc[providerName] = []; + acc[providerName].push(model); + return acc; + }, + {} as Record, + ); + + // Hardcoded preview model names for CTA + const fbPreviewModelNames = [ + 'gpt-4o', + 'claude-sonnet-4-20250514', + 'deepseek-chat', + 'gemini-2.5-flash', + 'qwen-plus', + ]; + const rawModelValue = field.value; const modelValue: { primary: string; fallbacks: string[] } = rawModelValue != null && @@ -406,6 +617,112 @@ export default function DynamicFormItemComponent({ ), )} + {/* Space models section */} + {showSpaceLoginCTA ? ( + + + + + {t('models.langbotModels')} + + e.preventDefault()} + > + + + + {t('models.spaceTrialTooltip')} + + + + +
e.preventDefault()} + > + {/* Preview models (first 3 visible, rest blurred) */} + {(fbSpaceModels.length > 0 + ? fbSpaceModels.map((m) => m.name) + : fbPreviewModelNames + ) + .slice(0, 3) + .map((name) => ( +
+ {name} +
+ ))} + {/* Blurred remaining models with login overlay */} +
+
+ {(fbSpaceModels.length > 0 + ? fbSpaceModels.map((m) => m.name) + : fbPreviewModelNames + ) + .slice(3) + .map((name) => ( +
+ {name} +
+ ))} +
+ {/* Login overlay */} +
+ +
+
+
+
+ ) : !systemInfo.disable_models_service ? ( + // User is logged into Space — show space models normally + Object.entries(fbGroupedSpaceModels).map( + ([providerName, models]) => ( + + + + + {providerName} + + + {models.map((model) => ( + + + {model.name} + {model.abilities?.includes('vision') && ( + + )} + {model.abilities?.includes('func_call') && ( + + )} + + + ))} + + ), + ) + ) : null} ); @@ -448,11 +765,35 @@ export default function DynamicFormItemComponent({

{t('models.fallback.primary')}

- {renderModelSelect( - modelValue.primary, - (val) => updateValue({ primary: val }), - t('models.selectModel'), - )} +
+
+ {renderModelSelect( + modelValue.primary, + (val) => updateValue({ primary: val }), + t('models.selectModel'), + )} +
+ + + + + + {t('models.title')} + + + +
{/* Fallback models */} diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index 26881cda..1da6ae92 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -251,6 +251,14 @@ export interface SystemLimitation { max_extensions: number; } +export interface WizardProgress { + step: number; + selected_adapter: string | null; + created_bot_uuid: string | null; + bot_saved: boolean; + selected_runner: string | null; +} + export interface ApiRespSystemInfo { debug: boolean; version: string; @@ -261,6 +269,7 @@ export interface ApiRespSystemInfo { disable_models_service: boolean; limitation: SystemLimitation; wizard_status: string; // 'none' | 'skipped' | 'completed' + wizard_progress: WizardProgress | null; } export interface RagMigrationStatusResp { diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index c2e2b247..95193180 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -705,6 +705,16 @@ export class BackendClient extends BaseHttpClient { return this.post('/api/v1/system/wizard/completed', { status }); } + public saveWizardProgress(progress: { + step: number; + selected_adapter: string | null; + created_bot_uuid: string | null; + bot_saved: boolean; + selected_runner: string | null; + }): Promise { + return this.put('/api/v1/system/wizard/progress', progress); + } + public getAsyncTasks(): Promise { return this.get('/api/v1/system/tasks'); } diff --git a/web/src/app/infra/http/index.ts b/web/src/app/infra/http/index.ts index 1da49ba9..4a9002a5 100644 --- a/web/src/app/infra/http/index.ts +++ b/web/src/app/infra/http/index.ts @@ -17,6 +17,7 @@ export const systemInfo: ApiRespSystemInfo = { max_extensions: -1, }, wizard_status: 'none', + wizard_progress: null, }; // 用户信息 diff --git a/web/src/app/wizard/page.tsx b/web/src/app/wizard/page.tsx index e5a2f907..4ca21b34 100644 --- a/web/src/app/wizard/page.tsx +++ b/web/src/app/wizard/page.tsx @@ -22,7 +22,12 @@ import { initializeUserInfo, initializeSystemInfo, } from '@/app/infra/http'; -import { Adapter, Bot, Pipeline } from '@/app/infra/entities/api'; +import { + Adapter, + Bot, + Pipeline, + WizardProgress, +} from '@/app/infra/entities/api'; import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic'; import { PipelineConfigTab, @@ -97,7 +102,24 @@ export default function WizardPage() { const [isSavingBot, setIsSavingBot] = useState(false); const [botSaved, setBotSaved] = useState(false); - // ---- Fetch remote data ---- + // ---- Helper: persist wizard progress to backend (fire-and-forget) ---- + const saveProgress = useCallback( + (overrides: Partial = {}) => { + const progress: WizardProgress = { + step: overrides.step ?? currentStep, + selected_adapter: overrides.selected_adapter ?? selectedAdapter, + created_bot_uuid: overrides.created_bot_uuid ?? createdBotUuid, + bot_saved: overrides.bot_saved ?? botSaved, + selected_runner: overrides.selected_runner ?? selectedRunner, + }; + httpClient.saveWizardProgress(progress).catch((err) => { + console.error('Failed to save wizard progress', err); + }); + }, + [currentStep, selectedAdapter, createdBotUuid, botSaved, selectedRunner], + ); + + // ---- Fetch remote data & restore progress ---- useEffect(() => { let cancelled = false; (async () => { @@ -113,6 +135,47 @@ export default function WizardPage() { setAdapters(adaptersResp.adapters); const aiTab = metadataResp.configs.find((c) => c.name === 'ai'); if (aiTab) setAiConfigTab(aiTab); + + // Restore wizard progress if available + const progress = systemInfo.wizard_progress; + if (progress && progress.created_bot_uuid) { + // Verify the bot still exists before restoring + try { + const botData = await httpClient.getBot(progress.created_bot_uuid); + if (cancelled) return; + + setSelectedAdapter(progress.selected_adapter); + setCreatedBotUuid(progress.created_bot_uuid); + setBotSaved(progress.bot_saved ?? false); + setSelectedRunner(progress.selected_runner); + + // Restore bot name from fetched bot data + setBotName(botData.bot.name); + + // Restore webhook URLs + const runtimeValues = botData.bot.adapter_runtime_values as + | Record + | undefined; + setWebhookUrl((runtimeValues?.webhook_full_url as string) || ''); + setExtraWebhookUrl( + (runtimeValues?.extra_webhook_full_url as string) || '', + ); + + // Restore step (cap at step 2 — step 3 means done) + setCurrentStep(Math.min(progress.step, 2)); + } catch { + // Bot no longer exists — clear stale progress and start fresh + httpClient + .saveWizardProgress({ + step: 0, + selected_adapter: null, + created_bot_uuid: null, + bot_saved: false, + selected_runner: null, + }) + .catch(() => {}); + } + } } catch (err) { console.error('Failed to load wizard data', err); toast.error(t('wizard.loadError')); @@ -183,6 +246,15 @@ export default function WizardPage() { ); }, [selectedRunnerConfigStage]); + // ---- Runner selection with progress saving ---- + const handleSelectRunner = useCallback( + (runner: string) => { + setSelectedRunner(runner); + saveProgress({ step: 2, selected_runner: runner }); + }, + [saveProgress], + ); + // ---- Navigation helpers ---- const canProceed = useCallback((): boolean => { @@ -200,15 +272,19 @@ export default function WizardPage() { const goNext = useCallback(() => { if (currentStep < TOTAL_STEPS - 1 && canProceed()) { - setCurrentStep((s) => s + 1); + const nextStep = currentStep + 1; + setCurrentStep(nextStep); + saveProgress({ step: nextStep }); } - }, [currentStep, canProceed]); + }, [currentStep, canProceed, saveProgress]); const goPrev = useCallback(() => { if (currentStep > 0) { - setCurrentStep((s) => s - 1); + const prevStep = currentStep - 1; + setCurrentStep(prevStep); + saveProgress({ step: prevStep }); } - }, [currentStep]); + }, [currentStep, saveProgress]); // ---- Create Bot (Step 0) ---- // Creates a disabled bot using the adapter label as name. @@ -255,6 +331,15 @@ export default function WizardPage() { // Advance to Step 1 setCurrentStep(1); + + // Persist progress + saveProgress({ + step: 1, + selected_adapter: selectedAdapter, + created_bot_uuid: resp.uuid, + bot_saved: false, + selected_runner: null, + }); } catch (err) { const apiErr = err as { msg?: string }; toast.error( @@ -263,7 +348,7 @@ export default function WizardPage() { } finally { setIsCreatingBot(false); } - }, [selectedAdapter, adapters, t]); + }, [selectedAdapter, adapters, t, saveProgress]); // ---- Save Bot Config & Enable (Step 1) ---- // Updates the bot's adapter config and enables it. @@ -295,6 +380,9 @@ export default function WizardPage() { } catch { // Non-critical } + + // Persist progress + saveProgress({ step: 1, bot_saved: true }); } catch (err) { const apiErr = err as { msg?: string }; toast.error( @@ -310,6 +398,7 @@ export default function WizardPage() { botDescription, adapterConfig, t, + saveProgress, ]); // ---- Create Pipeline & Link (Step 2 finish) ---- @@ -540,7 +629,7 @@ export default function WizardPage() {