feat(wizard): rework agent onboarding flow (#2471)

* feat(wizard): rework agent onboarding flow

* fix(web): support LAN development access

* fix(wizard): parse ranked model selection entries

* feat(wizard): add inbound bot verification

* feat(wizard): add floating page bot verification

* fix(wizard): repair HTTP bot inbound test setup

* feat(wizard): streamline custom model onboarding

* feat(wizard): label page bot test preview

* style(space): apply ruff formatting

* fix(wizard): polish AI engine onboarding

* fix(wizard): clarify local account message test

* feat(wizard): animate AI engine transitions

* fix(wizard): align AI engine setup headers

---------

Co-authored-by: langbot-dev <langbot@users.noreply.github.com>
Co-authored-by: RockChinQ <rockchinq@gmail.com>
This commit is contained in:
Dongchuan Fu
2026-08-28 01:30:30 +08:00
committed by GitHub
parent 855ae2bdba
commit be3734ffda
33 changed files with 2319 additions and 271 deletions
@@ -0,0 +1,409 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
ArrowLeft,
Check,
Eye,
Loader2,
Pencil,
RefreshCw,
Wrench,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import ProviderForm from '@/app/home/components/models-dialog/component/provider-form/ProviderForm';
import type { ScannedProviderModel } from '@/app/infra/entities/api';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { cn } from '@/lib/utils';
type ModelSetupMode = 'scan' | 'manual';
type ScanFallbackReason = 'failed' | 'empty' | null;
export interface OwnModelSelection {
source: ModelSetupMode;
providerUuid: string;
model: ScannedProviderModel;
}
interface OwnModelSetupProps {
onBack: () => void;
onSelectionChange: (selection: OwnModelSelection | null) => void;
}
export default function OwnModelSetup({
onBack,
onSelectionChange,
}: OwnModelSetupProps) {
const { t } = useTranslation();
const [providerUuid, setProviderUuid] = useState<string | null>(null);
const [showProviderForm, setShowProviderForm] = useState(true);
const [mode, setMode] = useState<ModelSetupMode>('scan');
const [models, setModels] = useState<ScannedProviderModel[]>([]);
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
const [isScanning, setIsScanning] = useState(false);
const [scanFallbackReason, setScanFallbackReason] =
useState<ScanFallbackReason>(null);
const [manualModelName, setManualModelName] = useState('');
const [manualContextLength, setManualContextLength] = useState('');
const [manualVision, setManualVision] = useState(false);
const [manualFunctionCall, setManualFunctionCall] = useState(false);
const parsedManualContextLength = useMemo(() => {
if (!manualContextLength.trim()) return null;
const value = Number(manualContextLength);
return Number.isInteger(value) && value > 0 ? value : undefined;
}, [manualContextLength]);
useEffect(() => {
if (mode !== 'manual' || !providerUuid) return;
if (!manualModelName.trim() || parsedManualContextLength === undefined) {
onSelectionChange(null);
return;
}
const abilities = [
...(manualVision ? ['vision'] : []),
...(manualFunctionCall ? ['func_call'] : []),
];
const modelName = manualModelName.trim();
onSelectionChange({
source: 'manual',
providerUuid,
model: {
id: modelName,
name: modelName,
type: 'llm',
abilities,
context_length: parsedManualContextLength,
already_added: false,
},
});
}, [
manualFunctionCall,
manualModelName,
manualVision,
mode,
onSelectionChange,
parsedManualContextLength,
providerUuid,
]);
const scanModels = useCallback(
async (uuid: string) => {
setMode('scan');
setIsScanning(true);
setScanFallbackReason(null);
setModels([]);
setSelectedModelId(null);
onSelectionChange(null);
try {
const response = await httpClient.scanProviderModels(uuid, 'llm');
const availableModels = response.models.filter(
(model) => model.type === 'llm' && !model.already_added,
);
setModels(availableModels);
if (availableModels.length === 0) {
setScanFallbackReason('empty');
setMode('manual');
}
} catch {
setScanFallbackReason('failed');
setMode('manual');
} finally {
setIsScanning(false);
}
},
[onSelectionChange],
);
const handleProviderSaved = useCallback(
async (uuid: string) => {
setProviderUuid(uuid);
setShowProviderForm(false);
await scanModels(uuid);
},
[scanModels],
);
const handleSelectModel = useCallback(
(model: ScannedProviderModel) => {
if (!providerUuid) return;
setSelectedModelId(model.id);
onSelectionChange({ source: 'scan', providerUuid, model });
},
[onSelectionChange, providerUuid],
);
const handleModeChange = useCallback(
(value: string) => {
setMode(value as ModelSetupMode);
setSelectedModelId(null);
onSelectionChange(null);
},
[onSelectionChange],
);
const handleBack = useCallback(() => {
onSelectionChange(null);
onBack();
}, [onBack, onSelectionChange]);
const handleEditProvider = useCallback(() => {
setSelectedModelId(null);
onSelectionChange(null);
setShowProviderForm(true);
}, [onSelectionChange]);
return (
<div className="mx-auto w-full max-w-4xl space-y-6">
<div className="text-center">
<h2 className="text-xl font-semibold">
{t('wizard.aiEngine.ownModelSetupTitle')}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
{t('wizard.aiEngine.ownModelSetupDescription')}
</p>
</div>
<div>
<Button variant="ghost" size="sm" onClick={handleBack}>
<ArrowLeft className="mr-1.5 size-4" />
{t('wizard.aiEngine.backToChoices')}
</Button>
</div>
{showProviderForm ? (
<Card className="mx-auto w-full max-w-3xl">
<CardHeader>
<CardTitle className="text-base">
{t('wizard.aiEngine.addProviderTitle')}
</CardTitle>
<CardDescription>
{t('wizard.aiEngine.addProviderDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<ProviderForm
providerId={providerUuid ?? undefined}
onFormSubmit={handleProviderSaved}
onFormCancel={() =>
providerUuid ? setShowProviderForm(false) : handleBack()
}
/>
</CardContent>
</Card>
) : (
<div className="mx-auto w-full max-w-3xl space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3 border-b pb-3">
<div>
<h3 className="text-base font-semibold">
{t('wizard.aiEngine.selectModelTitle')}
</h3>
<p className="text-sm text-muted-foreground">
{t('wizard.aiEngine.selectScannedModelDescription')}
</p>
</div>
<Button
variant="outline"
size="icon"
title={t('wizard.aiEngine.editProvider')}
onClick={handleEditProvider}
>
<Pencil className="size-4" />
</Button>
</div>
<Tabs value={mode} onValueChange={handleModeChange}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="scan">
{t('wizard.aiEngine.scanModelMode')}
</TabsTrigger>
<TabsTrigger value="manual">
{t('wizard.aiEngine.manualModelMode')}
</TabsTrigger>
</TabsList>
<TabsContent value="scan" className="mt-4">
{isScanning ? (
<div className="flex min-h-48 items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
{t('wizard.aiEngine.scanningModels')}
</div>
) : models.length > 0 ? (
<div className="space-y-3">
<div className="grid gap-2 sm:grid-cols-2">
{models.map((model) => {
const selected = selectedModelId === model.id;
return (
<button
key={model.id}
type="button"
className={cn(
'flex min-h-20 items-center gap-3 rounded-md border p-3 text-left transition-colors hover:border-primary/60 hover:bg-accent/40',
selected &&
'border-primary bg-accent ring-1 ring-primary',
)}
onClick={() => handleSelectModel(model)}
>
<span
className={cn(
'flex size-5 shrink-0 items-center justify-center rounded-full border',
selected &&
'border-primary bg-primary text-primary-foreground',
)}
>
{selected && <Check className="size-3" />}
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-medium">
{model.display_name || model.name}
</span>
<span className="block truncate text-xs text-muted-foreground">
{model.name}
</span>
</span>
</button>
);
})}
</div>
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
disabled={isScanning || !providerUuid}
onClick={() => providerUuid && scanModels(providerUuid)}
>
<RefreshCw className="mr-1.5 size-4" />
{t('wizard.aiEngine.rescanModels')}
</Button>
</div>
</div>
) : (
<div className="flex min-h-48 flex-col items-center justify-center gap-3 border border-dashed p-6 text-center">
<p className="text-sm text-muted-foreground">
{t(
scanFallbackReason === 'failed'
? 'wizard.aiEngine.scanModelsFailed'
: 'wizard.aiEngine.noScannedModels',
)}
</p>
<Button
variant="outline"
size="sm"
disabled={!providerUuid}
onClick={() => providerUuid && scanModels(providerUuid)}
>
<RefreshCw className="mr-1.5 size-4" />
{t('wizard.aiEngine.rescanModels')}
</Button>
</div>
)}
</TabsContent>
<TabsContent value="manual" className="mt-4 space-y-5">
{scanFallbackReason && (
<div className="border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-200">
{t(
scanFallbackReason === 'failed'
? 'wizard.aiEngine.manualFallbackFailed'
: 'wizard.aiEngine.manualFallbackEmpty',
)}
</div>
)}
<div className="space-y-2">
<Label htmlFor="wizard-manual-model-name">
{t('wizard.aiEngine.manualModelId')}
<span className="text-red-500">*</span>
</Label>
<Input
id="wizard-manual-model-name"
value={manualModelName}
onChange={(event) => setManualModelName(event.target.value)}
placeholder={t('wizard.aiEngine.manualModelIdPlaceholder')}
/>
<p className="text-xs text-muted-foreground">
{t('wizard.aiEngine.manualModelIdDescription')}
</p>
</div>
<div className="space-y-3 border-t pt-4">
<p className="text-sm font-medium">
{t('wizard.aiEngine.manualModelOptions')}
</p>
<div className="space-y-2">
<Label htmlFor="wizard-manual-context-length">
{t('models.contextLength')}
</Label>
<Input
id="wizard-manual-context-length"
type="number"
min={1}
step={1}
value={manualContextLength}
onChange={(event) =>
setManualContextLength(event.target.value)
}
placeholder={t('models.contextLengthPlaceholder')}
/>
{parsedManualContextLength === undefined && (
<p className="text-xs text-destructive">
{t('models.contextLengthInvalid')}
</p>
)}
</div>
<div className="flex flex-wrap gap-5">
<div className="flex items-center gap-2">
<Checkbox
id="wizard-manual-vision"
checked={manualVision}
onCheckedChange={(checked) =>
setManualVision(checked === true)
}
/>
<Label
htmlFor="wizard-manual-vision"
className="flex items-center gap-1.5"
>
<Eye className="size-4" />
{t('models.visionAbility')}
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="wizard-manual-function-call"
checked={manualFunctionCall}
onCheckedChange={(checked) =>
setManualFunctionCall(checked === true)
}
/>
<Label
htmlFor="wizard-manual-function-call"
className="flex items-center gap-1.5"
>
<Wrench className="size-4" />
{t('models.functionCallAbility')}
</Label>
</div>
</div>
</div>
</TabsContent>
</Tabs>
</div>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
+137
View File
@@ -0,0 +1,137 @@
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === 'object' && error !== null && 'msg' in error) {
const message = (error as { msg?: unknown }).msg;
if (typeof message === 'string') return message;
}
return String(error);
}
function createSigningSecret(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(
'',
);
}
export function ensureHttpBotSigningSecret(
adapterName: string,
config: Record<string, unknown>,
): Record<string, unknown> {
if (
adapterName !== 'http_bot' ||
config.signature_required === false ||
(typeof config.inbound_secret === 'string' && config.inbound_secret)
) {
return config;
}
return {
...config,
inbound_secret: createSigningSecret(),
};
}
export function findDefaultPipeline<
T extends { uuid?: string; is_default?: boolean },
>(pipelines: T[]): T | undefined {
return pipelines.find(
(pipeline) =>
pipeline.is_default === true &&
typeof pipeline.uuid === 'string' &&
pipeline.uuid.length > 0,
);
}
interface WebhookConfigItem {
name: string;
show_if?: {
field: string;
operator: 'eq' | 'neq' | 'in';
value: unknown;
};
}
export function isWebhookModeEnabled(
configItems: WebhookConfigItem[],
configValues: Record<string, unknown>,
): boolean {
const webhookField = configItems.find((item) => item.name === 'webhook_url');
if (!webhookField) return false;
if (!webhookField.show_if) return true;
const condition = webhookField.show_if;
const actualValue = configValues[condition.field];
if (condition.operator === 'eq') return actualValue === condition.value;
if (condition.operator === 'neq') return actualValue !== condition.value;
return (
Array.isArray(condition.value) && condition.value.includes(actualValue)
);
}
interface RequiredConfigItem {
name: string;
required: boolean;
default: unknown;
}
function isPlaceholderDefault(value: string, defaultValue: unknown): boolean {
if (typeof defaultValue !== 'string' || value !== defaultValue.trim()) {
return false;
}
return /(^|:\/\/)your-/i.test(value);
}
export function isRequiredRunnerConfigComplete(
configItems: RequiredConfigItem[],
configValues: Record<string, unknown>,
): boolean {
return configItems
.filter((item) => item.required)
.every((item) => {
const value = configValues[item.name];
if (typeof value === 'string') {
const normalizedValue = value.trim();
return (
normalizedValue.length > 0 &&
!isPlaceholderDefault(normalizedValue, item.default)
);
}
if (Array.isArray(value)) return value.length > 0;
return value !== undefined && value !== null;
});
}
export function configureLocalAgentPrimaryModel(
config: Record<string, unknown>,
modelUuid: string,
): Record<string, unknown> {
const aiConfig = (config.ai ?? {}) as Record<string, unknown>;
const runnerConfig = (aiConfig.runner ?? {}) as Record<string, unknown>;
const localAgentConfig = (aiConfig['local-agent'] ?? {}) as Record<
string,
unknown
>;
const modelConfig = (localAgentConfig.model ?? {}) as Record<string, unknown>;
return {
...config,
ai: {
...aiConfig,
runner: { ...runnerConfig, runner: 'local-agent' },
'local-agent': {
...localAgentConfig,
model: {
...modelConfig,
primary: modelUuid,
fallbacks: Array.isArray(modelConfig.fallbacks)
? modelConfig.fallbacks
: [],
},
},
},
};
}