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() {