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).
This commit is contained in:
Junyan Chin
2026-03-28 17:24:39 +08:00
committed by GitHub
parent 4c904c2375
commit 71e44f0e54
11 changed files with 623 additions and 86 deletions

View File

@@ -1,3 +1,5 @@
import json
import quart import quart
import sqlalchemy import sqlalchemy
@@ -11,16 +13,21 @@ class SystemRouterGroup(group.RouterGroup):
async def initialize(self) -> None: async def initialize(self) -> None:
@self.route('/info', methods=['GET'], auth_type=group.AuthType.NONE) @self.route('/info', methods=['GET'], auth_type=group.AuthType.NONE)
async def _() -> str: async def _() -> str:
# Read wizard_status from metadata table # Read wizard_status and wizard_progress from metadata table
# Possible values: 'skipped', 'completed'; absent key means 'none'
wizard_status = 'none' wizard_status = 'none'
wizard_progress = None
try: try:
result = await self.ap.persistence_mgr.execute_async( 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() for row in result:
if row: if row.key == 'wizard_status':
wizard_status = row.value 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: except Exception:
pass pass
@@ -43,12 +50,13 @@ class SystemRouterGroup(group.RouterGroup):
), ),
'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}), 'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}),
'wizard_status': wizard_status, 'wizard_status': wizard_status,
'wizard_progress': wizard_progress,
} }
) )
@self.route('/wizard/completed', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) @self.route('/wizard/completed', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str: async def _() -> str:
"""Mark wizard status in metadata table. """Mark wizard status in metadata table and clear progress.
Accepts JSON body: { "status": "skipped" | "completed" } Accepts JSON body: { "status": "skipped" | "completed" }
""" """
@@ -69,11 +77,44 @@ class SystemRouterGroup(group.RouterGroup):
await self.ap.persistence_mgr.execute_async( await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(Metadata).values(key='wizard_status', value=status) 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: except Exception as e:
return self.http_status(500, 500, f'Failed to update wizard status: {e}') return self.http_status(500, 500, f'Failed to update wizard status: {e}')
return self.success(data={}) 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) @self.route('/tasks', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str: async def _() -> str:
task_type = quart.request.args.get('type') task_type = quart.request.args.get('type')

View File

@@ -17,7 +17,8 @@ import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; 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. * Resolve the value referenced by a `show_if.field` string.
@@ -61,6 +62,7 @@ function WebhookUrlField({
}) { }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [extraCopied, setExtraCopied] = useState(false); const [extraCopied, setExtraCopied] = useState(false);
const { t } = useTranslation();
const handleCopy = (text: string, setter: (v: boolean) => void) => { const handleCopy = (text: string, setter: (v: boolean) => void) => {
if (navigator.clipboard && navigator.clipboard.writeText) { if (navigator.clipboard && navigator.clipboard.writeText) {
@@ -122,6 +124,22 @@ function WebhookUrlField({
{description && ( {description && (
<p className="text-sm text-muted-foreground">{description}</p> <p className="text-sm text-muted-foreground">{description}</p>
)} )}
{systemInfo.edition === 'community' && (
<div className="flex items-start gap-2.5 rounded-md border border-border/60 bg-muted/40 px-3 py-2.5 mt-1 max-w-2xl">
<Globe className="h-4 w-4 text-muted-foreground shrink-0 mt-0.5" />
<p className="text-sm text-muted-foreground leading-relaxed">
{t('bots.webhookSaasHint')}{' '}
<a
href="https://space.langbot.app/cloud?utm_source=local_webui&utm_medium=webhook_alert&utm_campaign=saas_conversion"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline-offset-4 hover:underline font-medium"
>
{t('bots.webhookSaasLink')}
</a>
</p>
</div>
)}
</FormItem> </FormItem>
); );
} }

View File

@@ -37,7 +37,22 @@ import {
DialogFooter, DialogFooter,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Checkbox } from '@/components/ui/checkbox'; 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({ export default function DynamicFormItemComponent({
config, config,
@@ -57,6 +72,25 @@ export default function DynamicFormItemComponent({
const [kbDialogOpen, setKbDialogOpen] = useState(false); const [kbDialogOpen, setKbDialogOpen] = useState(false);
const [tempSelectedKBIds, setTempSelectedKBIds] = useState<string[]>([]); const [tempSelectedKBIds, setTempSelectedKBIds] = useState<string[]>([]);
const { t } = useTranslation(); 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<IFileConfig | null> => { const handleFileUpload = async (file: File): Promise<IFileConfig | null> => {
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB 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(() => { useEffect(() => {
if (config.type === DynamicFormItemType.LLM_MODEL_SELECTOR) { if (config.type === DynamicFormItemType.LLM_MODEL_SELECTOR) {
httpClient fetchLlmModels();
.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);
});
} }
}, [config.type]); }, [config.type]);
@@ -126,23 +169,7 @@ export default function DynamicFormItemComponent({
useEffect(() => { useEffect(() => {
if (config.type === DynamicFormItemType.MODEL_FALLBACK_SELECTOR) { if (config.type === DynamicFormItemType.MODEL_FALLBACK_SELECTOR) {
httpClient fetchLlmModels();
.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);
});
} }
}, [config.type]); }, [config.type]);
@@ -259,8 +286,16 @@ export default function DynamicFormItemComponent({
); );
case DynamicFormItemType.LLM_MODEL_SELECTOR: case DynamicFormItemType.LLM_MODEL_SELECTOR:
// Group models by provider // Separate space models from regular models
const groupedModels = llmModels.reduce( 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) => { (acc, model) => {
const providerName = const providerName =
model.provider?.name || model.provider?.requester || 'Unknown'; model.provider?.name || model.provider?.requester || 'Unknown';
@@ -271,8 +306,30 @@ export default function DynamicFormItemComponent({
{} as Record<string, LLMModel[]>, {} as Record<string, LLMModel[]>,
); );
// 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<string, LLMModel[]>,
);
// 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 ( return (
<div className="max-w-md"> <div className="max-w-md flex items-center gap-1.5">
<div className="flex-1">
<Select value={field.value} onValueChange={field.onChange}> <Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]"> <SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('models.selectModel')} /> <SelectValue placeholder={t('models.selectModel')} />
@@ -296,9 +353,134 @@ export default function DynamicFormItemComponent({
))} ))}
</SelectGroup> </SelectGroup>
))} ))}
{/* Space models section */}
{showSpaceLoginCTA ? (
<SelectGroup>
<SelectLabel>
<span className="inline-flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
{t('models.langbotModels')}
<Tooltip>
<TooltipTrigger
asChild
onMouseDown={(e) => e.preventDefault()}
>
<Info className="h-3 w-3 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px]">
{t('models.spaceTrialTooltip')}
</TooltipContent>
</Tooltip>
</span>
</SelectLabel>
<div
className="relative"
onMouseDown={(e) => e.preventDefault()}
>
{/* Preview models (first 3 visible, rest blurred) */}
{(spaceModels.length > 0
? spaceModels.map((m) => m.name)
: previewModelNames
)
.slice(0, 3)
.map((name) => (
<div
key={name}
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm text-muted-foreground/60"
>
{name}
</div>
))}
{/* Blurred remaining models with login overlay */}
<div className="relative">
<div
className="select-none overflow-hidden"
style={{ maxHeight: '3rem' }}
>
{(spaceModels.length > 0
? spaceModels.map((m) => m.name)
: previewModelNames
)
.slice(3)
.map((name) => (
<div
key={name}
className="flex w-full items-center py-1.5 pl-8 pr-2 text-sm text-muted-foreground/40 blur-[2px]"
>
{name}
</div>
))}
</div>
{/* Login overlay */}
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-b from-transparent to-background/80">
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs px-3 gap-1.5 shadow-sm"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
handleSpaceLogin();
}}
>
<Sparkles className="h-3 w-3" />
{t('models.unlockModels')}
</Button>
</div>
</div>
</div>
</SelectGroup>
) : !systemInfo.disable_models_service ? (
// User is logged into Space — show space models normally
Object.entries(groupedSpaceModels).map(
([providerName, models]) => (
<SelectGroup key={providerName}>
<SelectLabel>
<span className="inline-flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
{providerName}
</span>
</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
<span className="inline-flex items-center gap-1">
{model.name}
{model.abilities?.includes('vision') && (
<Eye className="h-3 w-3 text-muted-foreground" />
)}
{model.abilities?.includes('func_call') && (
<Wrench className="h-3 w-3 text-muted-foreground" />
)}
</span>
</SelectItem>
))}
</SelectGroup>
),
)
) : null}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
onClick={() => setModelsDialogOpen(true)}
>
<Settings className="h-4 w-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">{t('models.title')}</TooltipContent>
</Tooltip>
<ModelsDialog
open={modelsDialogOpen}
onOpenChange={handleModelsDialogChange}
/>
</div>
); );
case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR: case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR:
@@ -338,8 +520,16 @@ export default function DynamicFormItemComponent({
); );
case DynamicFormItemType.MODEL_FALLBACK_SELECTOR: { case DynamicFormItemType.MODEL_FALLBACK_SELECTOR: {
// Group models by provider // Separate space models from regular models
const groupedModelsForFallback = llmModels.reduce( 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) => { (acc, model) => {
const providerName = const providerName =
model.provider?.name || model.provider?.requester || 'Unknown'; model.provider?.name || model.provider?.requester || 'Unknown';
@@ -350,6 +540,27 @@ export default function DynamicFormItemComponent({
{} as Record<string, LLMModel[]>, {} as Record<string, LLMModel[]>,
); );
// 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<string, LLMModel[]>,
);
// 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 rawModelValue = field.value;
const modelValue: { primary: string; fallbacks: string[] } = const modelValue: { primary: string; fallbacks: string[] } =
rawModelValue != null && rawModelValue != null &&
@@ -406,6 +617,112 @@ export default function DynamicFormItemComponent({
</SelectGroup> </SelectGroup>
), ),
)} )}
{/* Space models section */}
{showSpaceLoginCTA ? (
<SelectGroup>
<SelectLabel>
<span className="inline-flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
{t('models.langbotModels')}
<Tooltip>
<TooltipTrigger
asChild
onMouseDown={(e) => e.preventDefault()}
>
<Info className="h-3 w-3 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px]">
{t('models.spaceTrialTooltip')}
</TooltipContent>
</Tooltip>
</span>
</SelectLabel>
<div
className="relative"
onMouseDown={(e) => e.preventDefault()}
>
{/* Preview models (first 3 visible, rest blurred) */}
{(fbSpaceModels.length > 0
? fbSpaceModels.map((m) => m.name)
: fbPreviewModelNames
)
.slice(0, 3)
.map((name) => (
<div
key={name}
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm text-muted-foreground/60"
>
{name}
</div>
))}
{/* Blurred remaining models with login overlay */}
<div className="relative">
<div
className="select-none overflow-hidden"
style={{ maxHeight: '3rem' }}
>
{(fbSpaceModels.length > 0
? fbSpaceModels.map((m) => m.name)
: fbPreviewModelNames
)
.slice(3)
.map((name) => (
<div
key={name}
className="flex w-full items-center py-1.5 pl-8 pr-2 text-sm text-muted-foreground/40 blur-[2px]"
>
{name}
</div>
))}
</div>
{/* Login overlay */}
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-b from-transparent to-background/80">
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs px-3 gap-1.5 shadow-sm"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
handleSpaceLogin();
}}
>
<Sparkles className="h-3 w-3" />
{t('models.unlockModels')}
</Button>
</div>
</div>
</div>
</SelectGroup>
) : !systemInfo.disable_models_service ? (
// User is logged into Space — show space models normally
Object.entries(fbGroupedSpaceModels).map(
([providerName, models]) => (
<SelectGroup key={providerName}>
<SelectLabel>
<span className="inline-flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
{providerName}
</span>
</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
<span className="inline-flex items-center gap-1">
{model.name}
{model.abilities?.includes('vision') && (
<Eye className="h-3 w-3 text-muted-foreground" />
)}
{model.abilities?.includes('func_call') && (
<Wrench className="h-3 w-3 text-muted-foreground" />
)}
</span>
</SelectItem>
))}
</SelectGroup>
),
)
) : null}
</SelectContent> </SelectContent>
</Select> </Select>
); );
@@ -448,12 +765,36 @@ export default function DynamicFormItemComponent({
<p className="text-xs text-muted-foreground mb-1"> <p className="text-xs text-muted-foreground mb-1">
{t('models.fallback.primary')} {t('models.fallback.primary')}
</p> </p>
<div className="flex items-center gap-1.5">
<div className="flex-1">
{renderModelSelect( {renderModelSelect(
modelValue.primary, modelValue.primary,
(val) => updateValue({ primary: val }), (val) => updateValue({ primary: val }),
t('models.selectModel'), t('models.selectModel'),
)} )}
</div> </div>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
onClick={() => setModelsDialogOpen(true)}
>
<Settings className="h-4 w-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
{t('models.title')}
</TooltipContent>
</Tooltip>
<ModelsDialog
open={modelsDialogOpen}
onOpenChange={handleModelsDialogChange}
/>
</div>
</div>
{/* Fallback models */} {/* Fallback models */}
{modelValue.fallbacks.length > 0 && ( {modelValue.fallbacks.length > 0 && (

View File

@@ -251,6 +251,14 @@ export interface SystemLimitation {
max_extensions: number; 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 { export interface ApiRespSystemInfo {
debug: boolean; debug: boolean;
version: string; version: string;
@@ -261,6 +269,7 @@ export interface ApiRespSystemInfo {
disable_models_service: boolean; disable_models_service: boolean;
limitation: SystemLimitation; limitation: SystemLimitation;
wizard_status: string; // 'none' | 'skipped' | 'completed' wizard_status: string; // 'none' | 'skipped' | 'completed'
wizard_progress: WizardProgress | null;
} }
export interface RagMigrationStatusResp { export interface RagMigrationStatusResp {

View File

@@ -705,6 +705,16 @@ export class BackendClient extends BaseHttpClient {
return this.post('/api/v1/system/wizard/completed', { status }); 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<void> {
return this.put('/api/v1/system/wizard/progress', progress);
}
public getAsyncTasks(): Promise<ApiRespAsyncTasks> { public getAsyncTasks(): Promise<ApiRespAsyncTasks> {
return this.get('/api/v1/system/tasks'); return this.get('/api/v1/system/tasks');
} }

View File

@@ -17,6 +17,7 @@ export const systemInfo: ApiRespSystemInfo = {
max_extensions: -1, max_extensions: -1,
}, },
wizard_status: 'none', wizard_status: 'none',
wizard_progress: null,
}; };
// 用户信息 // 用户信息

View File

@@ -22,7 +22,12 @@ import {
initializeUserInfo, initializeUserInfo,
initializeSystemInfo, initializeSystemInfo,
} from '@/app/infra/http'; } 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 { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
import { import {
PipelineConfigTab, PipelineConfigTab,
@@ -97,7 +102,24 @@ export default function WizardPage() {
const [isSavingBot, setIsSavingBot] = useState(false); const [isSavingBot, setIsSavingBot] = useState(false);
const [botSaved, setBotSaved] = useState(false); const [botSaved, setBotSaved] = useState(false);
// ---- Fetch remote data ---- // ---- Helper: persist wizard progress to backend (fire-and-forget) ----
const saveProgress = useCallback(
(overrides: Partial<WizardProgress> = {}) => {
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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
@@ -113,6 +135,47 @@ export default function WizardPage() {
setAdapters(adaptersResp.adapters); setAdapters(adaptersResp.adapters);
const aiTab = metadataResp.configs.find((c) => c.name === 'ai'); const aiTab = metadataResp.configs.find((c) => c.name === 'ai');
if (aiTab) setAiConfigTab(aiTab); 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<string, unknown>
| 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) { } catch (err) {
console.error('Failed to load wizard data', err); console.error('Failed to load wizard data', err);
toast.error(t('wizard.loadError')); toast.error(t('wizard.loadError'));
@@ -183,6 +246,15 @@ export default function WizardPage() {
); );
}, [selectedRunnerConfigStage]); }, [selectedRunnerConfigStage]);
// ---- Runner selection with progress saving ----
const handleSelectRunner = useCallback(
(runner: string) => {
setSelectedRunner(runner);
saveProgress({ step: 2, selected_runner: runner });
},
[saveProgress],
);
// ---- Navigation helpers ---- // ---- Navigation helpers ----
const canProceed = useCallback((): boolean => { const canProceed = useCallback((): boolean => {
@@ -200,15 +272,19 @@ export default function WizardPage() {
const goNext = useCallback(() => { const goNext = useCallback(() => {
if (currentStep < TOTAL_STEPS - 1 && canProceed()) { 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(() => { const goPrev = useCallback(() => {
if (currentStep > 0) { if (currentStep > 0) {
setCurrentStep((s) => s - 1); const prevStep = currentStep - 1;
setCurrentStep(prevStep);
saveProgress({ step: prevStep });
} }
}, [currentStep]); }, [currentStep, saveProgress]);
// ---- Create Bot (Step 0) ---- // ---- Create Bot (Step 0) ----
// Creates a disabled bot using the adapter label as name. // Creates a disabled bot using the adapter label as name.
@@ -255,6 +331,15 @@ export default function WizardPage() {
// Advance to Step 1 // Advance to Step 1
setCurrentStep(1); setCurrentStep(1);
// Persist progress
saveProgress({
step: 1,
selected_adapter: selectedAdapter,
created_bot_uuid: resp.uuid,
bot_saved: false,
selected_runner: null,
});
} catch (err) { } catch (err) {
const apiErr = err as { msg?: string }; const apiErr = err as { msg?: string };
toast.error( toast.error(
@@ -263,7 +348,7 @@ export default function WizardPage() {
} finally { } finally {
setIsCreatingBot(false); setIsCreatingBot(false);
} }
}, [selectedAdapter, adapters, t]); }, [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. // Updates the bot's adapter config and enables it.
@@ -295,6 +380,9 @@ export default function WizardPage() {
} catch { } catch {
// Non-critical // Non-critical
} }
// Persist progress
saveProgress({ step: 1, bot_saved: true });
} catch (err) { } catch (err) {
const apiErr = err as { msg?: string }; const apiErr = err as { msg?: string };
toast.error( toast.error(
@@ -310,6 +398,7 @@ export default function WizardPage() {
botDescription, botDescription,
adapterConfig, adapterConfig,
t, t,
saveProgress,
]); ]);
// ---- Create Pipeline & Link (Step 2 finish) ---- // ---- Create Pipeline & Link (Step 2 finish) ----
@@ -540,7 +629,7 @@ export default function WizardPage() {
<StepAIEngine <StepAIEngine
runnerOptions={runnerOptions} runnerOptions={runnerOptions}
selected={selectedRunner} selected={selectedRunner}
onSelect={setSelectedRunner} onSelect={handleSelectRunner}
isLocalAccount={isLocalAccount} isLocalAccount={isLocalAccount}
onSpaceAuth={handleSpaceAuth} onSpaceAuth={handleSpaceAuth}
runnerConfigItems={selectedRunnerConfigItems} runnerConfigItems={selectedRunnerConfigItems}

View File

@@ -231,6 +231,10 @@ const enUS = {
loginWithSpace: 'Login with Space', loginWithSpace: 'Login with Space',
loginToUseModels: 'Login with Space to use cloud models', loginToUseModels: 'Login with Space to use cloud models',
noModels: 'No models configured', noModels: 'No models configured',
langbotModels: 'LangBot Models',
spaceTrialTooltip:
'Free trial credits available! Login with Space to access cloud models with zero configuration.',
unlockModels: 'Login to use',
editProvider: 'Edit Provider', editProvider: 'Edit Provider',
addProvider: 'Add Provider', addProvider: 'Add Provider',
addProviderHint: 'Add providers to use models from other sources', addProviderHint: 'Add providers to use models from other sources',
@@ -314,6 +318,9 @@ const enUS = {
'Click the input to select all, then press Ctrl+C (Mac: Cmd+C) to copy, or click the button', 'Click the input to select all, then press Ctrl+C (Mac: Cmd+C) to copy, or click the button',
webhookUrlHintEither: webhookUrlHintEither:
'Use either of the two URLs above in your platform configuration', 'Use either of the two URLs above in your platform configuration',
webhookSaasHint:
'Webhook requires a publicly accessible domain. LangBot Cloud provides a ready-to-use public endpoint for your bot.',
webhookSaasLink: 'Learn more about LangBot Cloud',
logLevel: 'Log Level', logLevel: 'Log Level',
allLevels: 'All Levels', allLevels: 'All Levels',
selectLevel: 'Select Level', selectLevel: 'Select Level',

View File

@@ -236,6 +236,10 @@
loginWithSpace: 'Space でログイン', loginWithSpace: 'Space でログイン',
loginToUseModels: 'Space でログインしてクラウドモデルを使用', loginToUseModels: 'Space でログインしてクラウドモデルを使用',
noModels: 'モデルがありません', noModels: 'モデルがありません',
langbotModels: 'LangBot モデル',
spaceTrialTooltip:
'無料トライアルクレジットが利用可能Space でログインして、設定不要でクラウドモデルを使用できます。',
unlockModels: 'ログインして使用',
editProvider: 'プロバイダーを編集', editProvider: 'プロバイダーを編集',
addProvider: 'プロバイダーを追加', addProvider: 'プロバイダーを追加',
addProviderHint: addProviderHint:
@@ -319,6 +323,9 @@
'入力ボックスをクリックして全選択し、Ctrl+C (Mac: Cmd+C) でコピーするか、右側のボタンをクリックしてください', '入力ボックスをクリックして全選択し、Ctrl+C (Mac: Cmd+C) でコピーするか、右側のボタンをクリックしてください',
webhookUrlHintEither: webhookUrlHintEither:
'上記の2つのURLのいずれかをプラットフォーム設定に使用してください', '上記の2つのURLのいずれかをプラットフォーム設定に使用してください',
webhookSaasHint:
'Webhook には公開アクセス可能なドメインが必要です。LangBot Cloud では、ボット用のパブリックエンドポイントをすぐにご利用いただけます。',
webhookSaasLink: 'LangBot Cloud の詳細はこちら',
logLevel: 'ログレベル', logLevel: 'ログレベル',
allLevels: 'すべてのレベル', allLevels: 'すべてのレベル',
selectLevel: 'レベルを選択', selectLevel: 'レベルを選択',

View File

@@ -222,6 +222,10 @@ const zhHans = {
loginWithSpace: '通过 Space 登录', loginWithSpace: '通过 Space 登录',
loginToUseModels: '通过 Space 登录以使用云端模型', loginToUseModels: '通过 Space 登录以使用云端模型',
noModels: '暂无模型', noModels: '暂无模型',
langbotModels: 'LangBot 模型',
spaceTrialTooltip:
'免费试用积分已就绪!通过 Space 登录即可零配置使用云端模型。',
unlockModels: '登录以使用',
editProvider: '编辑供应商', editProvider: '编辑供应商',
addProvider: '添加供应商', addProvider: '添加供应商',
addProviderHint: '添加自定义供应商以使用其他来源的模型', addProviderHint: '添加自定义供应商以使用其他来源的模型',
@@ -299,6 +303,9 @@ const zhHans = {
webhookUrlHint: webhookUrlHint:
'点击输入框自动全选,然后按 Ctrl+C (Mac: Cmd+C) 复制,或点击右侧按钮', '点击输入框自动全选,然后按 Ctrl+C (Mac: Cmd+C) 复制,或点击右侧按钮',
webhookUrlHintEither: '以上两个地址任选其一填入平台配置即可', webhookUrlHintEither: '以上两个地址任选其一填入平台配置即可',
webhookSaasHint:
'Webhook 需要公网可访问的域名。LangBot Cloud 为你的机器人提供开箱即用的公网地址。',
webhookSaasLink: '了解 LangBot Cloud',
logLevel: '日志级别', logLevel: '日志级别',
allLevels: '全部级别', allLevels: '全部级别',
selectLevel: '选择级别', selectLevel: '选择级别',

View File

@@ -221,6 +221,10 @@ const zhHant = {
loginWithSpace: '使用 Space 登入', loginWithSpace: '使用 Space 登入',
loginToUseModels: '使用 Space 登入以使用雲端模型', loginToUseModels: '使用 Space 登入以使用雲端模型',
noModels: '暫無模型', noModels: '暫無模型',
langbotModels: 'LangBot 模型',
spaceTrialTooltip:
'免費試用積分已就緒!使用 Space 登入即可零設定使用雲端模型。',
unlockModels: '登入以使用',
editProvider: '編輯供應商', editProvider: '編輯供應商',
addProvider: '新增供應商', addProvider: '新增供應商',
addProviderHint: '新增供應商以使用其他來源的模型', addProviderHint: '新增供應商以使用其他來源的模型',
@@ -298,6 +302,9 @@ const zhHant = {
webhookUrlHint: webhookUrlHint:
'點擊輸入框自動全選,然後按 Ctrl+C (Mac: Cmd+C) 複製,或點擊右側按鈕', '點擊輸入框自動全選,然後按 Ctrl+C (Mac: Cmd+C) 複製,或點擊右側按鈕',
webhookUrlHintEither: '以上兩個地址任選其一填入平台配置即可', webhookUrlHintEither: '以上兩個地址任選其一填入平台配置即可',
webhookSaasHint:
'Webhook 需要公網可存取的網域。LangBot Cloud 為你的機器人提供即開即用的公網位址。',
webhookSaasLink: '了解 LangBot Cloud',
logLevel: '日誌級別', logLevel: '日誌級別',
allLevels: '全部級別', allLevels: '全部級別',
selectLevel: '選擇級別', selectLevel: '選擇級別',