import { DynamicFormItemType, IDynamicFormItemSchema, SYSTEM_FIELD_PREFIX, } 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 { normalizeDynamicFormValuesForSave } from '@/app/home/components/dynamic-form/DynamicFormSaveValues'; 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, Info, QrCode, Download, ExternalLink, } from 'lucide-react'; import { copyToClipboard } from '@/app/utils/clipboard'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip'; import { systemInfo } from '@/app/infra/http'; import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs'; /** * 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_FIELD_PREFIX)) { const key = field.slice(SYSTEM_FIELD_PREFIX.length); return systemContext?.[key]; } if (watchedValues[field] !== undefined) { return watchedValues[field]; } return externalDependentValues?.[field]; } type DynamicFormValueSpec = Pick< IDynamicFormItemSchema, 'default' | 'name' | 'required' | 'type' >; function getValueSpecs(item: IDynamicFormItemSchema): DynamicFormValueSpec[] { if (item.type === DynamicFormItemType.RICH_TOOLS_SELECTOR) { return [ item, { name: 'enable-all-tools', type: DynamicFormItemType.BOOLEAN, required: false, default: true, }, ]; } if (item.type === DynamicFormItemType.RESOURCES_SELECTOR) { return [ item, { name: 'mcp-resources', type: DynamicFormItemType.UNKNOWN, required: false, default: [], }, { name: 'mcp-resource-agent-read-enabled', type: DynamicFormItemType.BOOLEAN, required: false, default: true, }, ]; } return [item]; } function getValueSchema(spec: DynamicFormValueSpec) { if (spec.name === 'mcp-resources') { return z.array(z.any()); } switch (spec.type) { case DynamicFormItemType.INT: return z.number(); case DynamicFormItemType.FLOAT: return z.number(); case DynamicFormItemType.BOOLEAN: return z.boolean(); case DynamicFormItemType.STRING: return z.string(); case DynamicFormItemType.STRING_ARRAY: return z.array(z.string()); case DynamicFormItemType.SELECT: return z.string(); case DynamicFormItemType.LLM_MODEL_SELECTOR: return z.string(); case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR: return z.string(); case DynamicFormItemType.RERANK_MODEL_SELECTOR: return z.string(); case DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR: return z.string(); case DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR: case DynamicFormItemType.RESOURCES_SELECTOR: case DynamicFormItemType.RICH_TOOLS_SELECTOR: case DynamicFormItemType.TOOLS_SELECTOR: return z.array(z.string()); case DynamicFormItemType.BOT_SELECTOR: return z.string(); case DynamicFormItemType.MODEL_FALLBACK_SELECTOR: return z.object({ primary: z.string(), fallbacks: z.array(z.string()), }); case DynamicFormItemType.PROMPT_EDITOR: return z.array( z.object({ content: z.string(), role: z.string(), }), ); default: return z.string(); } } /** * 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')}

)}
); } function DownloadLinkField({ label, description, url, filename, helpUrl, helpLabel, }: { label: string; description?: string; url: string; filename?: string; helpUrl?: string | null; helpLabel: string; }) { const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin; const downloadUrl = url.startsWith('http') ? url : `${baseUrl}${url}`; return ( {label}
{helpUrl && ( )}
{description && (

{description}

)}
); } /** * Display-only component for `__system.*` fields (e.g. the deployment's * outbound IPs that the operator must add to a platform's trusted-IP list). * Renders one read-only row per value, each with a copy button. Rendered * outside of react-hook-form binding since the values come from * systemContext, not user input. */ function SystemInfoField({ label, description, values, }: { label: string; description?: string; values: string[]; }) { const [copiedIndex, setCopiedIndex] = useState(null); const handleCopy = (text: string, index: number) => { copyToClipboard(text).catch(() => {}); setCopiedIndex(index); setTimeout(() => setCopiedIndex(null), 2000); }; return ( {label}
{values.map((value, index) => (
(e.target as HTMLInputElement).select()} />
))}
{description && (

{description}

)}
); } // Hover-only Radix tooltips never open on touch devices (no pointer hover), // so the ``disabled_tooltip`` explaining why a field is locked was invisible on // mobile. This wrapper makes the info icon also toggle the tooltip on tap while // keeping hover behavior on desktop. function DisabledTooltipIcon({ text }: { text: string }) { const [open, setOpen] = useState(false); return ( {text} ); } 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, 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; 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 fields (webhook-url/embed-code/qr-code-login types // and `__system.*`-named fields) 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' && item.type !== 'download-link' && !item.name.startsWith(SYSTEM_FIELD_PREFIX), ), [itemConfigList], ); const editableValueSpecs = useMemo( () => editableItems.flatMap(getValueSpecs), [editableItems], ); // 根据 itemConfigList 动态生成 zod schema const formSchema = z.object( editableValueSpecs.reduce( (acc, item) => { let fieldSchema = getValueSchema(item); 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: editableValueSpecs.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 = editableValueSpecs.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, editableValueSpecs]); // Get reactive form values for conditional rendering const watchedValues = form.watch(); const setFormValue = (name: string, value: unknown) => { form.setValue(name as keyof FormValues, value as never, { shouldDirty: true, shouldValidate: true, }); }; // 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 = normalizeDynamicFormValuesForSave( editableValueSpecs, formValues 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 = normalizeDynamicFormValuesForSave( editableValueSpecs, formValues as Record, ); onSubmitRef.current?.(finalValues); previousInitialValues.current = finalValues as Record; }); return () => subscription.unsubscribe(); }, [form, editableValueSpecs]); // 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; } } // ``disable_if`` mirrors ``show_if``'s evaluator but instead of // hiding the field, leaves it visible and inert. Use it when the // operator needs to see that the field exists yet cannot edit it // under the current runtime state (e.g. sandbox-bound fields when // Box is disabled). let isDisabledByCondition = false; if (config.disable_if) { const dependValue = resolveShowIfValue( config.disable_if.field, watchedValues as Record, externalDependentValues, systemContext, ); const cond = config.disable_if; if (cond.operator === 'eq' && dependValue === cond.value) { isDisabledByCondition = true; } else if (cond.operator === 'neq' && dependValue !== cond.value) { isDisabledByCondition = true; } else if ( cond.operator === 'in' && Array.isArray(cond.value) && cond.value.includes(dependValue) ) { isDisabledByCondition = true; } } // All fields are disabled when editing (creation_settings are // immutable) or when ``disable_if`` matches. const isFieldDisabled = !!isEditing || isDisabledByCondition; const disabledTooltip = isDisabledByCondition && config.disabled_tooltip ? extractI18nObject(config.disabled_tooltip) : ''; const renderDisabledTooltipIcon = () => disabledTooltip ? ( ) : null; // `__system.*` fields are display-only; their value is resolved // from systemContext (same namespace as show_if), not user input. // Hidden entirely when the deployment doesn't provide the value. if (config.name.startsWith(SYSTEM_FIELD_PREFIX)) { const rawValue = systemContext?.[config.name.slice(SYSTEM_FIELD_PREFIX.length)]; const values = (Array.isArray(rawValue) ? rawValue : [rawValue]) .filter((v) => v !== undefined && v !== null && v !== '') .map(String); if (values.length === 0) return null; return ( ); } // 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 = `