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
@@ -20,6 +20,7 @@ export function BotLogListComponent({
autoExpandImages = false,
hideDetailedLogsLink = false,
hideToolbar = false,
onMessageReceived,
}: {
botId: string;
/** When true, log entries with images are rendered expanded by default */
@@ -28,6 +29,8 @@ export function BotLogListComponent({
hideDetailedLogsLink?: boolean;
/** When true, hides the entire toolbar (auto-refresh, level filter, detailed logs link) */
hideToolbar?: boolean;
/** Called after an inbound person/group message appears in the bot log. */
onMessageReceived?: () => void;
}) {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -41,6 +44,8 @@ export function BotLogListComponent({
]);
const listContainerRef = useRef<HTMLDivElement>(null);
const botLogListRef = useRef<BotLog[]>(botLogList);
const onMessageReceivedRef = useRef(onMessageReceived);
onMessageReceivedRef.current = onMessageReceived;
const logLevels = [
{ value: 'error', label: 'ERROR' },
@@ -108,6 +113,9 @@ export function BotLogListComponent({
manager.subscribeLogPush(handleBotLogPush);
manager.loadFirstPage().then((response) => {
setBotLogList(response.reverse());
if (response.some((log) => Boolean(log.message_session_id))) {
onMessageReceivedRef.current?.();
}
});
listenScroll();
}
@@ -138,6 +146,9 @@ export function BotLogListComponent({
function handleBotLogPush(response: BotLog[]) {
setBotLogList(response.reverse());
if (response.some((log) => Boolean(log.message_session_id))) {
onMessageReceivedRef.current?.();
}
}
const handleScroll = useCallback(
@@ -15,7 +15,10 @@ import {
FormMessage,
} from '@/components/ui/form';
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
import { normalizeDynamicFormValuesForSave } from '@/app/home/components/dynamic-form/DynamicFormSaveValues';
import {
normalizeDynamicFormFieldValue,
normalizeDynamicFormValuesForSave,
} from '@/app/home/components/dynamic-form/DynamicFormSaveValues';
import QrCodeLoginDialog, {
QrLoginPlatform,
} from '@/app/home/components/qrcode-login/QrCodeLoginDialog';
@@ -464,61 +467,6 @@ export default function DynamicFormComponent({
const previousInitialValues = useRef(initialValues);
const { t, i18n } = useTranslation();
// Normalize a form value according to its field type.
// This ensures legacy/malformed data (e.g. a plain string for
// model-fallback-selector) is coerced to the expected shape
// so that downstream components never crash.
const normalizeFieldValue = (
item: DynamicFormValueSpec,
value: unknown,
): unknown => {
if (
item.name === 'mcp-resources' ||
item.type === DynamicFormItemType.RESOURCES_SELECTOR ||
item.type === DynamicFormItemType.RICH_TOOLS_SELECTOR
) {
return Array.isArray(value) ? value : [];
}
if (item.type === 'model-fallback-selector') {
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
const obj = value as Record<string, unknown>;
return {
primary: typeof obj.primary === 'string' ? obj.primary : '',
fallbacks: Array.isArray(obj.fallbacks)
? (obj.fallbacks as unknown[]).filter(
(v): v is string => typeof v === 'string',
)
: [],
reasoning:
obj.reasoning != null &&
typeof obj.reasoning === 'object' &&
!Array.isArray(obj.reasoning)
? Object.fromEntries(
Object.entries(obj.reasoning).filter(
(entry): entry is [string, string] =>
typeof entry[1] === 'string',
),
)
: {},
};
}
// Legacy string format or any other unexpected type
return {
primary: typeof value === 'string' ? value : '',
fallbacks: [],
reasoning: {},
};
}
if (item.type === 'prompt-editor') {
if (Array.isArray(value)) {
return value;
}
// Default to a single empty system prompt entry
return [{ role: 'system', content: '' }];
}
return value;
};
// Filter out display-only fields (webhook-url/embed-code/qr-code-login types
// and `__system.*`-named fields) that should not participate in form state,
// validation, or value emission.
@@ -574,7 +522,7 @@ export default function DynamicFormComponent({
const rawValue = initialValues?.[item.name] ?? item.default;
return {
...acc,
[item.name]: normalizeFieldValue(item, rawValue),
[item.name]: normalizeDynamicFormFieldValue(item, rawValue),
};
}, {} as FormValues),
});
@@ -611,7 +559,10 @@ export default function DynamicFormComponent({
const mergedValues = editableValueSpecs.reduce(
(acc, item) => {
const rawValue = initialValues[item.name] ?? item.default;
acc[item.name] = normalizeFieldValue(item, rawValue) as object;
acc[item.name] = normalizeDynamicFormFieldValue(
item,
rawValue,
) as object;
return acc;
},
{} as Record<string, object>,
@@ -5,6 +5,80 @@ export type DynamicFormSaveValueSpec = Pick<
'default' | 'name' | 'type'
>;
const ARRAY_FIELD_TYPES = new Set([
'array[string]',
'array[file]',
'knowledge-base-multi-selector',
'resources-selector',
'rich-tools-selector',
'tools-selector',
]);
const STRING_FIELD_TYPES = new Set([
'string',
'text',
'select',
'llm-model-selector',
'embedding-model-selector',
'rerank-model-selector',
'knowledge-base-selector',
'bot-selector',
]);
/**
* Coerce empty dynamic-form defaults into controlled React values.
* Metadata from older adapters and runners can omit `default`; inputs must
* still receive a stable value from their first render.
*/
export function normalizeDynamicFormFieldValue(
spec: DynamicFormSaveValueSpec,
value: unknown,
): unknown {
if (spec.name === 'mcp-resources' || ARRAY_FIELD_TYPES.has(spec.type)) {
return Array.isArray(value) ? value : [];
}
if (spec.type === 'boolean') {
return typeof value === 'boolean' ? value : false;
}
if (STRING_FIELD_TYPES.has(spec.type)) {
return typeof value === 'string' ? value : '';
}
if (spec.type === 'model-fallback-selector') {
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
const objectValue = value as Record<string, unknown>;
return {
primary:
typeof objectValue.primary === 'string' ? objectValue.primary : '',
fallbacks: Array.isArray(objectValue.fallbacks)
? objectValue.fallbacks.filter(
(fallback): fallback is string => typeof fallback === 'string',
)
: [],
reasoning:
objectValue.reasoning != null &&
typeof objectValue.reasoning === 'object' &&
!Array.isArray(objectValue.reasoning)
? Object.fromEntries(
Object.entries(objectValue.reasoning).filter(
(entry): entry is [string, string] =>
typeof entry[1] === 'string',
),
)
: {},
};
}
return {
primary: typeof value === 'string' ? value : '',
fallbacks: [],
reasoning: {},
};
}
if (spec.type === 'prompt-editor') {
return Array.isArray(value) ? value : [{ role: 'system', content: '' }];
}
return value;
}
const reasoningLevels = new Set([
'disabled',
'enabled',
@@ -33,7 +33,7 @@ const getFormSchema = (t: (key: string) => string) =>
interface ProviderFormProps {
providerId?: string;
onFormSubmit: () => void;
onFormSubmit: (providerUuid: string) => void | Promise<void>;
onFormCancel: () => void;
}
@@ -171,14 +171,16 @@ export default function ProviderForm({
};
try {
let savedProviderUuid = providerId;
if (providerId) {
await httpClient.updateModelProvider(providerId, data);
toast.success(t('models.providerSaved'));
} else {
await httpClient.createModelProvider(data);
const response = await httpClient.createModelProvider(data);
savedProviderUuid = response.uuid;
toast.success(t('models.providerCreated'));
}
onFormSubmit();
await onFormSubmit(savedProviderUuid as string);
} catch (err) {
toast.error(t('models.providerSaveError') + (err as CustomApiError).msg);
}