import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from '@/components/ui/form'; import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent'; import QrCodeLoginDialog, { QrLoginPlatform, } from '@/app/home/components/qrcode-login/QrCodeLoginDialog'; import { useEffect, useMemo, useRef, useState } from 'react'; import { extractI18nObject } from '@/i18n/I18nProvider'; 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, Globe, QrCode } from 'lucide-react'; import { copyToClipboard } from '@/app/utils/clipboard'; import { systemInfo } from '@/app/infra/http'; /** * Resolve the value referenced by a `show_if.field` string. * * Fields prefixed with `__system.` are looked up in the caller-supplied * `systemContext` dictionary (e.g. `__system.is_wizard` → `systemContext.is_wizard`). * All other field names are resolved from the live form values first, then * fall back to `externalDependentValues`. */ function resolveShowIfValue( field: string, watchedValues: Record, externalDependentValues?: Record, systemContext?: Record, ): unknown { if (field.startsWith('__system.')) { const key = field.slice('__system.'.length); return systemContext?.[key]; } if (watchedValues[field] !== undefined) { return watchedValues[field]; } return externalDependentValues?.[field]; } /** * Display-only component for embed code fields with copy animation. */ function EmbedCodeField({ label, description, snippet, }: { label: string; description?: string; snippet: string; }) { const [copied, setCopied] = useState(false); const handleCopy = () => { copyToClipboard(snippet).catch(() => {}); setCopied(true); setTimeout(() => setCopied(false), 2000); }; return (
{description && (

{description}

)}
          {snippet}
        
); } /** * Display-only component for webhook URL fields. * Rendered outside of react-hook-form binding since the value is * read-only and comes from systemContext, not user input. */ function WebhookUrlField({ label, description, url, extraUrl, }: { label: string; description?: string; url: string; extraUrl?: string; }) { const [copied, setCopied] = useState(false); const [extraCopied, setExtraCopied] = useState(false); const { t } = useTranslation(); const handleCopy = (text: string, setter: (v: boolean) => void) => { copyToClipboard(text).catch(() => {}); setter(true); setTimeout(() => setter(false), 2000); }; return ( {label}
(e.target as HTMLInputElement).select()} />
{extraUrl && (
(e.target as HTMLInputElement).select()} />
)} {description && (

{description}

)} {systemInfo.edition === 'community' && (

{t('bots.webhookSaasHint')}{' '} {t('bots.webhookSaasLink')}

)}
); } export default function DynamicFormComponent({ itemConfigList, onSubmit, initialValues, onFileUploaded, isEditing, externalDependentValues, systemContext, onValidate, }: { itemConfigList: IDynamicFormItemSchema[]; onSubmit?: (val: object) => unknown; initialValues?: Record; onFileUploaded?: (fileKey: string) => void; isEditing?: boolean; externalDependentValues?: Record; /** Extra variables accessible via the `__system.*` namespace in show_if conditions. * e.g. `{ is_wizard: true }` makes `show_if: { field: "__system.is_wizard", ... }` work. */ systemContext?: Record; /** Callback to expose validation function to parent component. * Parent can call this function to trigger validation and get validity state. */ onValidate?: (validateFn: () => Promise) => void; }) { const isInitialMount = useRef(true); const previousInitialValues = useRef(initialValues); const { t } = 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: IDynamicFormItemSchema, value: unknown, ): unknown => { if (item.type === 'model-fallback-selector') { if (value != null && typeof value === 'object' && !Array.isArray(value)) { const obj = value as Record; 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', ) : [], }; } // Legacy string format or any other unexpected type return { primary: typeof value === 'string' ? value : '', fallbacks: [], }; } 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 field types (e.g. webhook-url, embed-code) that should not // participate in form state, validation, or value emission. const editableItems = useMemo( () => itemConfigList.filter( (item) => item.type !== 'webhook-url' && item.type !== 'embed-code' && item.type !== 'qr-code-login', ), [itemConfigList], ); // 根据 itemConfigList 动态生成 zod schema const formSchema = z.object( editableItems.reduce( (acc, item) => { let fieldSchema; switch (item.type) { case 'integer': fieldSchema = z.number(); break; case 'float': fieldSchema = z.number(); break; case 'boolean': fieldSchema = z.boolean(); break; case 'string': fieldSchema = z.string(); break; case 'array[string]': fieldSchema = z.array(z.string()); break; case 'select': fieldSchema = z.string(); break; case 'llm-model-selector': fieldSchema = z.string(); break; case 'embedding-model-selector': fieldSchema = z.string(); break; case 'rerank-model-selector': fieldSchema = z.string(); break; case 'knowledge-base-selector': fieldSchema = z.string(); break; case 'knowledge-base-multi-selector': fieldSchema = z.array(z.string()); break; case 'bot-selector': fieldSchema = z.string(); break; case 'tools-selector': fieldSchema = z.array(z.string()); break; case 'model-fallback-selector': fieldSchema = z.object({ primary: z.string(), fallbacks: z.array(z.string()), }); break; case 'prompt-editor': fieldSchema = z.array( z.object({ content: z.string(), role: z.string(), }), ); break; default: fieldSchema = z.string(); } if ( item.required && (fieldSchema instanceof z.ZodString || fieldSchema instanceof z.ZodArray) ) { fieldSchema = fieldSchema.min(1, { message: t('common.fieldRequired'), }); } return { ...acc, [item.name]: fieldSchema, }; }, {} as Record, ), ); type FormValues = z.infer; const form = useForm({ resolver: zodResolver(formSchema), defaultValues: editableItems.reduce((acc, item) => { // 优先使用 initialValues,如果没有则使用默认值 const rawValue = initialValues?.[item.name] ?? item.default; return { ...acc, [item.name]: normalizeFieldValue(item, rawValue), }; }, {} as FormValues), }); // Expose validation function to parent component const validate = async (): Promise => { // Trigger validation for all fields const result = await form.trigger(); return result; }; useEffect(() => { onValidate?.(validate); }, [onValidate]); // 当 initialValues 变化时更新表单值 // 但要避免因为内部表单更新触发的 onSubmit 导致的 initialValues 变化而重新设置表单 useEffect(() => { // 首次挂载时,使用 initialValues 初始化表单 if (isInitialMount.current) { isInitialMount.current = false; previousInitialValues.current = initialValues; return; } // 检查 initialValues 是否真的发生了实质性变化 // 使用 JSON.stringify 进行深度比较 const hasRealChange = JSON.stringify(previousInitialValues.current) !== JSON.stringify(initialValues); if (initialValues && hasRealChange) { // 合并默认值和初始值 const mergedValues = editableItems.reduce( (acc, item) => { const rawValue = initialValues[item.name] ?? item.default; acc[item.name] = normalizeFieldValue(item, rawValue) as object; return acc; }, {} as Record, ); Object.entries(mergedValues).forEach(([key, value]) => { form.setValue(key as keyof FormValues, value); }); previousInitialValues.current = initialValues; } }, [initialValues, form, editableItems]); // Get reactive form values for conditional rendering const watchedValues = form.watch(); // Stable ref for onSubmit to avoid re-triggering the effect when the // parent passes a new closure on every render. const onSubmitRef = useRef(onSubmit); onSubmitRef.current = onSubmit; // 监听表单值变化 useEffect(() => { // Emit initial form values immediately so the parent always has a valid snapshot, // even if the user saves without modifying any field. // form.watch(callback) only fires on subsequent changes, not on mount. const formValues = form.getValues(); const initialFinalValues = editableItems.reduce( (acc, item) => { acc[item.name] = formValues[item.name] ?? item.default; return acc; }, {} as Record, ); onSubmitRef.current?.(initialFinalValues); // Update previousInitialValues to the emitted snapshot so that if the // parent writes these values back as new initialValues, the deep // comparison in the initialValues-sync useEffect won't detect a change // and won't trigger an infinite update loop. previousInitialValues.current = initialFinalValues as Record< string, object >; const subscription = form.watch(() => { const formValues = form.getValues(); const finalValues = editableItems.reduce( (acc, item) => { acc[item.name] = formValues[item.name] ?? item.default; return acc; }, {} as Record, ); onSubmitRef.current?.(finalValues); previousInitialValues.current = finalValues as Record; }); return () => subscription.unsubscribe(); }, [form, editableItems]); // State for QR code login dialog const [qrDialogOpen, setQrDialogOpen] = useState(false); const [qrDialogPlatform, setQrDialogPlatform] = useState('feishu'); return (
{/* QR code login dialog */} { for (const [key, value] of Object.entries(credentials)) { if (value) { form.setValue(key as keyof FormValues, value as never); } } }} /> {itemConfigList.map((config) => { if (config.show_if) { const dependValue = resolveShowIfValue( config.show_if.field, watchedValues as Record, externalDependentValues, systemContext, ); if ( config.show_if.operator === 'eq' && dependValue !== config.show_if.value ) { return null; } if ( config.show_if.operator === 'neq' && dependValue === config.show_if.value ) { return null; } if ( config.show_if.operator === 'in' && Array.isArray(config.show_if.value) && !config.show_if.value.includes(dependValue) ) { return null; } } // All fields are disabled when editing (creation_settings are immutable) const isFieldDisabled = !!isEditing; // Webhook URL fields are display-only; render outside of form binding if (config.type === 'webhook-url') { const webhookUrl = (systemContext?.webhook_url as string) || ''; const extraWebhookUrl = (systemContext?.extra_webhook_url as string) || ''; if (!webhookUrl) return null; return ( ); } if (config.type === 'embed-code') { const botUuid = (systemContext?.bot_uuid as string) || ''; if (!botUuid) return null; const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin; const widgetTitle = ((systemContext?.adapter_config as Record) ?.title as string) || 'LangBot'; const safeTitle = widgetTitle .replace(/&/g, '&') .replace(/"/g, '"') .replace(//g, '>'); const embedSnippet = `