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' && (
+
+ )}
);
}
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 (
-
-
);
@@ -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() {