mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-21 16:05:19 +08:00
fix(web): reduce form rerenders and stabilize assistant model labels
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { useSnapshotDirty } from '@/app/infra/hooks/useSnapshotDirty';
|
||||
import EntityLoadState from '@/components/EntityLoadState';
|
||||
import {
|
||||
forwardRef,
|
||||
@@ -9,7 +10,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -157,7 +158,6 @@ function AgentFormComponent(
|
||||
const [activeSection, setActiveSection] =
|
||||
useState<AgentConfigSection>('runner');
|
||||
const isSavingRef = useRef(false);
|
||||
const hasUnsavedChangesRef = useRef(false);
|
||||
const loadedHostToolPolicyRef = useRef<string[] | undefined>(undefined);
|
||||
|
||||
const formSchema = z.object({
|
||||
@@ -197,18 +197,20 @@ function AgentFormComponent(
|
||||
|
||||
const savedSnapshotRef = useRef('');
|
||||
const initializedStagesRef = useRef<Set<string>>(new Set());
|
||||
const watchedValues = form.watch();
|
||||
const hasUnsavedChanges = (() => {
|
||||
if (!savedSnapshotRef.current) return false;
|
||||
return JSON.stringify(watchedValues) !== savedSnapshotRef.current;
|
||||
})();
|
||||
hasUnsavedChangesRef.current = hasUnsavedChanges;
|
||||
const {
|
||||
isDirty: hasUnsavedChanges,
|
||||
dirtyRef: hasUnsavedChangesRef,
|
||||
refreshDirty,
|
||||
} = useSnapshotDirty(form, savedSnapshotRef);
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(hasUnsavedChanges);
|
||||
}, [hasUnsavedChanges, onDirtyChange]);
|
||||
|
||||
const supportedEventPatterns = form.watch('supported_event_patterns');
|
||||
const supportedEventPatterns = useWatch({
|
||||
control: form.control,
|
||||
name: 'supported_event_patterns',
|
||||
});
|
||||
useEffect(() => {
|
||||
onSupportedEventPatternsChange?.(supportedEventPatterns);
|
||||
}, [onSupportedEventPatternsChange, supportedEventPatterns]);
|
||||
@@ -279,6 +281,7 @@ function AgentFormComponent(
|
||||
};
|
||||
form.reset(loadedValues);
|
||||
savedSnapshotRef.current = JSON.stringify(loadedValues);
|
||||
refreshDirty();
|
||||
initializedStagesRef.current.clear();
|
||||
setInitialDataLoaded(true);
|
||||
})
|
||||
@@ -290,7 +293,7 @@ function AgentFormComponent(
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agentId, form, t, loadAttempt]);
|
||||
}, [agentId, form, t, loadAttempt, refreshDirty]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialDataLoaded || !readPendingRunnerInstall(runnerInstallScope)) {
|
||||
@@ -340,7 +343,11 @@ function AgentFormComponent(
|
||||
void loadPluginSystemStatus();
|
||||
}, [loadPluginSystemStatus]);
|
||||
|
||||
const currentRunner = (form.watch('runner') as Record<string, any>)?.id;
|
||||
const currentRunner = useWatch({
|
||||
control: form.control,
|
||||
name: 'runner',
|
||||
compute: (runner) => runner?.id,
|
||||
});
|
||||
const runnerOptions = useMemo(() => {
|
||||
const runnerStage = runnerConfigSchema?.stages.find(
|
||||
(stage) => stage.name === 'runner',
|
||||
@@ -358,24 +365,38 @@ function AgentFormComponent(
|
||||
const activeRunnerStage = runnerConfigSchema?.stages.find(
|
||||
(stage) => stage.name === currentRunner,
|
||||
);
|
||||
const runnerConfigValues = form.watch('runner_config') as Record<
|
||||
const runnerConfigValues = form.getValues('runner_config') as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
const activeRunnerValues = useMemo(
|
||||
() => runnerConfigValues?.[currentRunner] ?? {},
|
||||
[currentRunner, runnerConfigValues],
|
||||
);
|
||||
const missingRunnerFields = useMemo(
|
||||
() =>
|
||||
(activeRunnerStage?.config ?? []).filter(
|
||||
(field) =>
|
||||
field.required &&
|
||||
isRunnerFieldVisible(field, activeRunnerValues) &&
|
||||
isRequiredRunnerValueMissing(activeRunnerValues[field.name]),
|
||||
),
|
||||
[activeRunnerStage, activeRunnerValues],
|
||||
useWatch({
|
||||
control: form.control,
|
||||
compute: (values) =>
|
||||
(activeRunnerStage?.config ?? [])
|
||||
.filter(
|
||||
(field) =>
|
||||
field.required &&
|
||||
isRunnerFieldVisible(
|
||||
field,
|
||||
values.runner_config?.[currentRunner] ?? {},
|
||||
) &&
|
||||
isRequiredRunnerValueMissing(
|
||||
values.runner_config?.[currentRunner]?.[field.name],
|
||||
),
|
||||
)
|
||||
.map((field) => field.name)
|
||||
.join('\n'),
|
||||
});
|
||||
const activeRunnerValues = runnerConfigValues?.[currentRunner] ?? {};
|
||||
const missingRunnerFields = (activeRunnerStage?.config ?? []).filter(
|
||||
(field) =>
|
||||
field.required &&
|
||||
isRunnerFieldVisible(field, activeRunnerValues) &&
|
||||
isRequiredRunnerValueMissing(activeRunnerValues[field.name]),
|
||||
);
|
||||
const missingRunnerFieldNames = missingRunnerFields
|
||||
.map((field) => extractI18nObject(field.label))
|
||||
.join(', ');
|
||||
const primarySections: Array<{
|
||||
name: AgentConfigSection;
|
||||
label: string;
|
||||
@@ -448,9 +469,7 @@ function AgentFormComponent(
|
||||
return {
|
||||
label: t('agents.runnerConfigIncomplete'),
|
||||
description: t('agents.runnerConfigIncompleteDescription', {
|
||||
fields: missingRunnerFields
|
||||
.map((field) => extractI18nObject(field.label))
|
||||
.join(', '),
|
||||
fields: missingRunnerFieldNames,
|
||||
}),
|
||||
tone: 'warning',
|
||||
};
|
||||
@@ -471,7 +490,8 @@ function AgentFormComponent(
|
||||
pluginStatusLoading,
|
||||
pluginSystemStatus,
|
||||
runnerOptions.length,
|
||||
missingRunnerFields,
|
||||
missingRunnerFieldNames,
|
||||
missingRunnerFields.length,
|
||||
selectedRunnerOption,
|
||||
t,
|
||||
]);
|
||||
@@ -520,11 +540,12 @@ function AgentFormComponent(
|
||||
onRunnerStatusChange?.(runnerStatus);
|
||||
}, [onRunnerStatusChange, runnerStatus]);
|
||||
|
||||
function updateSnapshotIfInitial(stageKey: string) {
|
||||
function updateSnapshotIfInitial(stageKey: string, wasDirty: boolean) {
|
||||
if (!initializedStagesRef.current.has(stageKey)) {
|
||||
initializedStagesRef.current.add(stageKey);
|
||||
if (!hasUnsavedChanges) {
|
||||
if (!wasDirty) {
|
||||
savedSnapshotRef.current = JSON.stringify(form.getValues());
|
||||
refreshDirty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -534,9 +555,10 @@ function AgentFormComponent(
|
||||
stageName: string,
|
||||
values: object,
|
||||
) {
|
||||
const wasDirty = hasUnsavedChangesRef.current;
|
||||
if (formName === 'runner') {
|
||||
form.setValue('runner', values, { shouldDirty: true });
|
||||
updateSnapshotIfInitial(`runner.${stageName}`);
|
||||
updateSnapshotIfInitial(`runner.${stageName}`, wasDirty);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -550,7 +572,7 @@ function AgentFormComponent(
|
||||
},
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
updateSnapshotIfInitial(`runner_config.${stageName}`);
|
||||
updateSnapshotIfInitial(`runner_config.${stageName}`, wasDirty);
|
||||
}
|
||||
|
||||
function renderDynamicStage(stage: PipelineConfigStage) {
|
||||
@@ -558,8 +580,8 @@ function AgentFormComponent(
|
||||
if (!isRunnerSelector && stage.name !== currentRunner) return null;
|
||||
|
||||
const initialValues = isRunnerSelector
|
||||
? (form.watch('runner') as Record<string, unknown>) || {}
|
||||
: ((form.watch('runner_config') as Record<string, any>) || {})[
|
||||
? (form.getValues('runner') as Record<string, unknown>) || {}
|
||||
: ((form.getValues('runner_config') as Record<string, any>) || {})[
|
||||
stage.name
|
||||
] || {};
|
||||
|
||||
@@ -640,6 +662,7 @@ function AgentFormComponent(
|
||||
loadedHostToolPolicyRef.current = [...values.allowed_tools];
|
||||
}
|
||||
savedSnapshotRef.current = submittedSnapshot;
|
||||
refreshDirty();
|
||||
onFinish(agent);
|
||||
toast.success(t('agents.saveSuccess'));
|
||||
return true;
|
||||
@@ -655,7 +678,14 @@ function AgentFormComponent(
|
||||
onSavingChange?.(false);
|
||||
}
|
||||
},
|
||||
[agentId, hostToolCatalogAvailable, onFinish, onSavingChange, t],
|
||||
[
|
||||
agentId,
|
||||
hostToolCatalogAvailable,
|
||||
onFinish,
|
||||
onSavingChange,
|
||||
refreshDirty,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
function handleSubmit(values: FormValues) {
|
||||
@@ -682,6 +712,7 @@ function AgentFormComponent(
|
||||
emoji: values.emoji || '🤖',
|
||||
};
|
||||
savedSnapshotRef.current = JSON.stringify(snapshot);
|
||||
refreshDirty();
|
||||
}
|
||||
},
|
||||
async save() {
|
||||
@@ -693,7 +724,14 @@ function AgentFormComponent(
|
||||
return (await saveValues(form.getValues())) ?? false;
|
||||
},
|
||||
}),
|
||||
[form, initialDataLoaded, loadFailed, saveValues],
|
||||
[
|
||||
form,
|
||||
initialDataLoaded,
|
||||
loadFailed,
|
||||
saveValues,
|
||||
hasUnsavedChangesRef,
|
||||
refreshDirty,
|
||||
],
|
||||
);
|
||||
|
||||
if (loadFailed)
|
||||
|
||||
@@ -38,7 +38,7 @@ import EventBindingsEditor from './EventBindingsEditor';
|
||||
import PluginProcessorBindings from './PluginProcessorBindings';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -273,7 +273,22 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
|
||||
useEffect(() => {
|
||||
onAdapterLabelChange?.(adapterLabel);
|
||||
}, [adapterLabel, onAdapterLabelChange]);
|
||||
const currentAdapterConfig = form.watch('adapter_config');
|
||||
const currentAdapterConfig = form.getValues('adapter_config');
|
||||
const embedTitle = useWatch({
|
||||
control: form.control,
|
||||
name: 'adapter_config',
|
||||
compute: (config) => config?.title,
|
||||
});
|
||||
const dynamicFormSystemContext = useMemo(
|
||||
() => ({
|
||||
bot_uuid: initBotId,
|
||||
webhook_url: webhookUrl,
|
||||
extra_webhook_url: extraWebhookUrl,
|
||||
adapter_config: { title: embedTitle },
|
||||
outbound_ips: systemInfo.outbound_ips,
|
||||
}),
|
||||
[initBotId, webhookUrl, extraWebhookUrl, embedTitle],
|
||||
);
|
||||
|
||||
// Group adapters by category for the Select dropdown. Legacy adapters are
|
||||
// split out and shown in a collapsed group at the bottom so they're
|
||||
@@ -1146,13 +1161,7 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
|
||||
shouldDirty: !isInitializing.current,
|
||||
});
|
||||
}}
|
||||
systemContext={{
|
||||
webhook_url: webhookUrl,
|
||||
extra_webhook_url: extraWebhookUrl,
|
||||
bot_uuid: initBotId || '',
|
||||
adapter_config: form.getValues('adapter_config') || {},
|
||||
outbound_ips: systemInfo.outbound_ips,
|
||||
}}
|
||||
systemContext={dynamicFormSystemContext}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
SYSTEM_FIELD_PREFIX,
|
||||
DynamicFormItemType,
|
||||
} from '@/app/infra/entities/form/dynamic';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useForm, useWatch, type UseFormReturn } from 'react-hook-form';
|
||||
import type { ControllerRenderProps } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { isJsonValue, isPromptValue } from './StructuredFieldValue';
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
import QrCodeLoginDialog, {
|
||||
QrLoginPlatform,
|
||||
} from '@/app/home/components/qrcode-login/QrCodeLoginDialog';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -466,7 +466,7 @@ export default function DynamicFormComponent({
|
||||
}) {
|
||||
const isInitialMount = useRef(true);
|
||||
const previousInitialValues = useRef(initialValues);
|
||||
const { t, i18n } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Filter out display-only fields (webhook-url/embed-code/qr-code-login types
|
||||
// and `__system.*`-named fields) that should not participate in form state,
|
||||
@@ -490,28 +490,32 @@ export default function DynamicFormComponent({
|
||||
);
|
||||
|
||||
// 根据 itemConfigList 动态生成 zod schema
|
||||
const formSchema = z.object(
|
||||
editableValueSpecs.reduce(
|
||||
(acc, item) => {
|
||||
let fieldSchema = getValueSchema(item);
|
||||
const formSchema = useMemo(
|
||||
() =>
|
||||
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'),
|
||||
});
|
||||
}
|
||||
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<string, z.ZodTypeAny>,
|
||||
),
|
||||
return {
|
||||
...acc,
|
||||
[item.name]: fieldSchema,
|
||||
};
|
||||
},
|
||||
{} as Record<string, z.ZodTypeAny>,
|
||||
),
|
||||
),
|
||||
[editableValueSpecs, t],
|
||||
);
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
@@ -577,21 +581,12 @@ export default function DynamicFormComponent({
|
||||
}
|
||||
}, [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;
|
||||
|
||||
// 监听表单值变化
|
||||
// Keep parent drafts synchronous with field changes.
|
||||
useEffect(() => {
|
||||
// Emit initial form values immediately so the parent always has a valid snapshot,
|
||||
// even if the user saves without modifying any field.
|
||||
@@ -618,323 +613,459 @@ export default function DynamicFormComponent({
|
||||
editableValueSpecs,
|
||||
formValues as Record<string, unknown>,
|
||||
);
|
||||
onSubmitRef.current?.(finalValues);
|
||||
previousInitialValues.current = finalValues as Record<string, object>;
|
||||
onSubmitRef.current?.(finalValues);
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, editableValueSpecs]);
|
||||
|
||||
return (
|
||||
<DynamicFormFields
|
||||
form={form}
|
||||
itemConfigList={itemConfigList}
|
||||
onFileUploaded={onFileUploaded}
|
||||
isEditing={isEditing}
|
||||
externalDependentValues={externalDependentValues}
|
||||
systemContext={systemContext}
|
||||
renderItem={renderItem}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type DynamicFormProps = Parameters<typeof DynamicFormComponent>[0];
|
||||
|
||||
type DynamicFormFieldsProps = Omit<
|
||||
DynamicFormProps,
|
||||
'initialValues' | 'onSubmit' | 'onValidate'
|
||||
> & {
|
||||
form: UseFormReturn<Record<string, any>>;
|
||||
};
|
||||
|
||||
// The stable provider also isolates useWatch/useController from an outer form's
|
||||
// context updates. The field list itself must subscribe inside this provider.
|
||||
const DynamicFormFields = memo(function DynamicFormFields(
|
||||
props: DynamicFormFieldsProps,
|
||||
) {
|
||||
return (
|
||||
<Form {...props.form}>
|
||||
<DynamicFormFieldList {...props} />
|
||||
</Form>
|
||||
);
|
||||
});
|
||||
|
||||
function DynamicFormFieldList({
|
||||
form,
|
||||
itemConfigList,
|
||||
onFileUploaded,
|
||||
isEditing,
|
||||
externalDependentValues,
|
||||
systemContext,
|
||||
renderItem,
|
||||
}: DynamicFormFieldsProps) {
|
||||
type FormValues = Record<string, any>;
|
||||
const { t, i18n } = useTranslation();
|
||||
const dependencies = useMemo(
|
||||
() => [
|
||||
...new Set(
|
||||
itemConfigList
|
||||
.flatMap((item) => [
|
||||
item.show_if?.field,
|
||||
item.disable_if?.field,
|
||||
...(item.disabled_tooltip_overrides ?? []).map(
|
||||
(rule) => rule.when.field,
|
||||
),
|
||||
...(item.type === DynamicFormItemType.RICH_TOOLS_SELECTOR ||
|
||||
item.type === DynamicFormItemType.RESOURCES_SELECTOR
|
||||
? getValueSpecs(item).map((spec) => spec.name)
|
||||
: []),
|
||||
])
|
||||
.filter(
|
||||
(name): name is string =>
|
||||
!!name && !name.startsWith(SYSTEM_FIELD_PREFIX),
|
||||
),
|
||||
),
|
||||
],
|
||||
[itemConfigList],
|
||||
);
|
||||
const watchedValues = useWatch({
|
||||
control: form.control,
|
||||
// Custom controls can read arbitrary fields; regular controls only need
|
||||
// their declared conditions and composite-field dependencies here.
|
||||
compute: (values: Record<string, any>) =>
|
||||
renderItem
|
||||
? values
|
||||
: Object.fromEntries(dependencies.map((name) => [name, values[name]])),
|
||||
});
|
||||
const setFormValue = useCallback(
|
||||
(name: string, value: unknown) => {
|
||||
form.setValue(name, value, { shouldDirty: true, shouldValidate: true });
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
// State for QR code login dialog
|
||||
const [qrDialogOpen, setQrDialogOpen] = useState(false);
|
||||
const [qrDialogPlatform, setQrDialogPlatform] =
|
||||
useState<QrLoginPlatform>('feishu');
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<div className="min-w-0 max-w-full space-y-4 overflow-x-hidden">
|
||||
{/* QR code login dialog */}
|
||||
<QrCodeLoginDialog
|
||||
open={qrDialogOpen}
|
||||
onOpenChange={setQrDialogOpen}
|
||||
platform={qrDialogPlatform}
|
||||
onSuccess={(credentials) => {
|
||||
for (const [key, value] of Object.entries(credentials)) {
|
||||
if (value) {
|
||||
form.setValue(key as keyof FormValues, value as never);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{itemConfigList.map((config, index) => {
|
||||
// Create a normalized config with type converted to frontend format
|
||||
const normalizedConfig = {
|
||||
...config,
|
||||
type: normalizeItemType(config.type),
|
||||
};
|
||||
const fieldKey = config.id || config.name || `field-${index}`;
|
||||
|
||||
let isHiddenByCondition = false;
|
||||
if (config.show_if) {
|
||||
const dependValue = resolveShowIfValue(
|
||||
config.show_if.field,
|
||||
watchedValues as Record<string, unknown>,
|
||||
externalDependentValues,
|
||||
systemContext,
|
||||
);
|
||||
|
||||
if (
|
||||
config.show_if.operator === 'eq' &&
|
||||
dependValue !== config.show_if.value
|
||||
) {
|
||||
isHiddenByCondition = true;
|
||||
}
|
||||
if (
|
||||
config.show_if.operator === 'neq' &&
|
||||
dependValue === config.show_if.value
|
||||
) {
|
||||
isHiddenByCondition = true;
|
||||
}
|
||||
if (
|
||||
config.show_if.operator === 'in' &&
|
||||
Array.isArray(config.show_if.value) &&
|
||||
!config.show_if.value.includes(dependValue)
|
||||
) {
|
||||
isHiddenByCondition = true;
|
||||
<div className="min-w-0 max-w-full space-y-4 overflow-x-hidden">
|
||||
{/* QR code login dialog */}
|
||||
<QrCodeLoginDialog
|
||||
open={qrDialogOpen}
|
||||
onOpenChange={setQrDialogOpen}
|
||||
platform={qrDialogPlatform}
|
||||
onSuccess={(credentials) => {
|
||||
for (const [key, value] of Object.entries(credentials)) {
|
||||
if (value) {
|
||||
form.setValue(key as keyof FormValues, value as never);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{itemConfigList.map((config, index) => {
|
||||
// Create a normalized config with type converted to frontend format
|
||||
const normalizedConfig = {
|
||||
...config,
|
||||
type: normalizeItemType(config.type),
|
||||
};
|
||||
const fieldKey = config.id || config.name || `field-${index}`;
|
||||
|
||||
let isHiddenByCondition = false;
|
||||
if (config.show_if) {
|
||||
const dependValue = resolveShowIfValue(
|
||||
config.show_if.field,
|
||||
watchedValues as Record<string, unknown>,
|
||||
externalDependentValues,
|
||||
systemContext,
|
||||
);
|
||||
|
||||
// Keep structured drafts mounted across conditional hiding so invalid
|
||||
// JSON cannot disappear from validation when Advanced Settings closes.
|
||||
if (
|
||||
isHiddenByCondition &&
|
||||
normalizedConfig.type !== DynamicFormItemType.JSON &&
|
||||
normalizedConfig.type !== DynamicFormItemType.PROMPT_EDITOR
|
||||
)
|
||||
return null;
|
||||
|
||||
// Keep locked fields visible and resolve only the applicable reason.
|
||||
const { isDisabledByCondition, disabledTooltip: tooltip } =
|
||||
resolveDisabledState(
|
||||
config,
|
||||
watchedValues as Record<string, unknown>,
|
||||
externalDependentValues,
|
||||
systemContext,
|
||||
);
|
||||
|
||||
// All fields are disabled when editing (creation_settings are
|
||||
// immutable) or when ``disable_if`` matches.
|
||||
const isFieldDisabled = !!isEditing || isDisabledByCondition;
|
||||
const disabledTooltip = tooltip ? extractI18nObject(tooltip) : '';
|
||||
const renderDisabledTooltipIcon = () =>
|
||||
disabledTooltip ? (
|
||||
<DisabledTooltipIcon text={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 (
|
||||
<SystemInfoField
|
||||
key={config.id}
|
||||
label={extractI18nObject(config.label)}
|
||||
description={
|
||||
config.description
|
||||
? extractI18nObject(config.description)
|
||||
: undefined
|
||||
}
|
||||
values={values}
|
||||
/>
|
||||
);
|
||||
config.show_if.operator === 'eq' &&
|
||||
dependValue !== config.show_if.value
|
||||
) {
|
||||
isHiddenByCondition = true;
|
||||
}
|
||||
|
||||
// Webhook URL fields are display-only; render outside of form binding
|
||||
if (normalizedConfig.type === 'webhook-url') {
|
||||
const webhookUrl = (systemContext?.webhook_url as string) || '';
|
||||
const extraWebhookUrl =
|
||||
(systemContext?.extra_webhook_url as string) || '';
|
||||
|
||||
if (!webhookUrl) return null;
|
||||
|
||||
return (
|
||||
<WebhookUrlField
|
||||
key={fieldKey}
|
||||
label={extractI18nObject(config.label)}
|
||||
description={
|
||||
config.description
|
||||
? extractI18nObject(config.description)
|
||||
: undefined
|
||||
}
|
||||
url={webhookUrl}
|
||||
extraUrl={extraWebhookUrl || undefined}
|
||||
/>
|
||||
);
|
||||
if (
|
||||
config.show_if.operator === 'neq' &&
|
||||
dependValue === config.show_if.value
|
||||
) {
|
||||
isHiddenByCondition = true;
|
||||
}
|
||||
|
||||
if (normalizedConfig.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<string, unknown>)
|
||||
?.title as string) || 'LangBot';
|
||||
const safeTitle = widgetTitle
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
const embedSnippet = `<script data-title="${safeTitle}" src="${baseUrl}/api/v1/embed/${botUuid}/widget.js"><\/script>`;
|
||||
|
||||
return (
|
||||
<EmbedCodeField
|
||||
key={fieldKey}
|
||||
label={extractI18nObject(config.label)}
|
||||
description={
|
||||
config.description
|
||||
? extractI18nObject(config.description)
|
||||
: undefined
|
||||
}
|
||||
snippet={embedSnippet}
|
||||
/>
|
||||
);
|
||||
if (
|
||||
config.show_if.operator === 'in' &&
|
||||
Array.isArray(config.show_if.value) &&
|
||||
!config.show_if.value.includes(dependValue)
|
||||
) {
|
||||
isHiddenByCondition = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (config.type === 'download-link') {
|
||||
if (!config.url) return null;
|
||||
// Keep structured drafts mounted across conditional hiding so invalid
|
||||
// JSON cannot disappear from validation when Advanced Settings closes.
|
||||
if (
|
||||
isHiddenByCondition &&
|
||||
normalizedConfig.type !== DynamicFormItemType.JSON &&
|
||||
normalizedConfig.type !== DynamicFormItemType.PROMPT_EDITOR
|
||||
)
|
||||
return null;
|
||||
|
||||
return (
|
||||
<DownloadLinkField
|
||||
key={config.id}
|
||||
label={extractI18nObject(config.label)}
|
||||
description={
|
||||
config.description
|
||||
? extractI18nObject(config.description)
|
||||
: undefined
|
||||
}
|
||||
url={config.url}
|
||||
filename={config.download_filename}
|
||||
helpUrl={getAdapterDocUrl(config.help_links, i18n.language)}
|
||||
helpLabel={
|
||||
config.help_label
|
||||
? extractI18nObject(config.help_label)
|
||||
: t('bots.viewAdapterDocs')
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Keep locked fields visible and resolve only the applicable reason.
|
||||
const { isDisabledByCondition, disabledTooltip: tooltip } =
|
||||
resolveDisabledState(
|
||||
config,
|
||||
watchedValues as Record<string, unknown>,
|
||||
externalDependentValues,
|
||||
systemContext,
|
||||
);
|
||||
|
||||
// QR code login button (e.g. Feishu one-click create, WeChat scan login)
|
||||
if (config.type === 'qr-code-login') {
|
||||
return (
|
||||
<FormItem key={fieldKey}>
|
||||
<div
|
||||
className="relative flex items-center gap-4 p-4 rounded-xl border-2 border-dashed cursor-pointer transition-all hover:border-solid hover:shadow-md group"
|
||||
style={{
|
||||
borderColor:
|
||||
'color-mix(in srgb, var(--primary) 25%, transparent)',
|
||||
background:
|
||||
'color-mix(in srgb, var(--primary) 3%, transparent)',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!isEditing) {
|
||||
setQrDialogPlatform(
|
||||
(config.login_platform as QrLoginPlatform) || 'feishu',
|
||||
);
|
||||
setQrDialogOpen(true);
|
||||
}
|
||||
// All fields are disabled when editing (creation_settings are
|
||||
// immutable) or when ``disable_if`` matches.
|
||||
const isFieldDisabled = !!isEditing || isDisabledByCondition;
|
||||
const disabledTooltip = tooltip ? extractI18nObject(tooltip) : '';
|
||||
const renderDisabledTooltipIcon = () =>
|
||||
disabledTooltip ? (
|
||||
<DisabledTooltipIcon text={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 (
|
||||
<SystemInfoField
|
||||
key={config.id}
|
||||
label={extractI18nObject(config.label)}
|
||||
description={
|
||||
config.description
|
||||
? extractI18nObject(config.description)
|
||||
: undefined
|
||||
}
|
||||
values={values}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Webhook URL fields are display-only; render outside of form binding
|
||||
if (normalizedConfig.type === 'webhook-url') {
|
||||
const webhookUrl = (systemContext?.webhook_url as string) || '';
|
||||
const extraWebhookUrl =
|
||||
(systemContext?.extra_webhook_url as string) || '';
|
||||
|
||||
if (!webhookUrl) return null;
|
||||
|
||||
return (
|
||||
<WebhookUrlField
|
||||
key={fieldKey}
|
||||
label={extractI18nObject(config.label)}
|
||||
description={
|
||||
config.description
|
||||
? extractI18nObject(config.description)
|
||||
: undefined
|
||||
}
|
||||
url={webhookUrl}
|
||||
extraUrl={extraWebhookUrl || undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (normalizedConfig.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<string, unknown>)
|
||||
?.title as string) || 'LangBot';
|
||||
const safeTitle = widgetTitle
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
const embedSnippet = `<script data-title="${safeTitle}" src="${baseUrl}/api/v1/embed/${botUuid}/widget.js"><\/script>`;
|
||||
|
||||
return (
|
||||
<EmbedCodeField
|
||||
key={fieldKey}
|
||||
label={extractI18nObject(config.label)}
|
||||
description={
|
||||
config.description
|
||||
? extractI18nObject(config.description)
|
||||
: undefined
|
||||
}
|
||||
snippet={embedSnippet}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (config.type === 'download-link') {
|
||||
if (!config.url) return null;
|
||||
|
||||
return (
|
||||
<DownloadLinkField
|
||||
key={config.id}
|
||||
label={extractI18nObject(config.label)}
|
||||
description={
|
||||
config.description
|
||||
? extractI18nObject(config.description)
|
||||
: undefined
|
||||
}
|
||||
url={config.url}
|
||||
filename={config.download_filename}
|
||||
helpUrl={getAdapterDocUrl(config.help_links, i18n.language)}
|
||||
helpLabel={
|
||||
config.help_label
|
||||
? extractI18nObject(config.help_label)
|
||||
: t('bots.viewAdapterDocs')
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// QR code login button (e.g. Feishu one-click create, WeChat scan login)
|
||||
if (config.type === 'qr-code-login') {
|
||||
return (
|
||||
<FormItem key={fieldKey}>
|
||||
<div
|
||||
className="relative flex items-center gap-4 p-4 rounded-xl border-2 border-dashed cursor-pointer transition-all hover:border-solid hover:shadow-md group"
|
||||
style={{
|
||||
borderColor:
|
||||
'color-mix(in srgb, var(--primary) 25%, transparent)',
|
||||
background:
|
||||
'color-mix(in srgb, var(--primary) 3%, transparent)',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!isEditing) {
|
||||
setQrDialogPlatform(
|
||||
(config.login_platform as QrLoginPlatform) || 'feishu',
|
||||
);
|
||||
setQrDialogOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-center h-12 w-12 rounded-lg bg-primary/10 shrink-0">
|
||||
<QrCode className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{extractI18nObject(config.label)}
|
||||
</span>
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded bg-primary text-primary-foreground">
|
||||
{t('common.recommend')}
|
||||
</span>
|
||||
</div>
|
||||
{config.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
{extractI18nObject(config.description)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!!isEditing}
|
||||
className="shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setQrDialogPlatform(
|
||||
(config.login_platform as QrLoginPlatform) || 'feishu',
|
||||
);
|
||||
setQrDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-center h-12 w-12 rounded-lg bg-primary/10 shrink-0">
|
||||
<QrCode className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{extractI18nObject(config.label)}
|
||||
</span>
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded bg-primary text-primary-foreground">
|
||||
{t('common.recommend')}
|
||||
</span>
|
||||
</div>
|
||||
{config.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
{extractI18nObject(config.description)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!!isEditing}
|
||||
className="shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setQrDialogPlatform(
|
||||
(config.login_platform as QrLoginPlatform) || 'feishu',
|
||||
);
|
||||
setQrDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<QrCode className="h-3.5 w-3.5 mr-1" />
|
||||
{t('common.start')}
|
||||
</Button>
|
||||
</div>
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
<QrCode className="h-3.5 w-3.5 mr-1" />
|
||||
{t('common.start')}
|
||||
</Button>
|
||||
</div>
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
config.type === DynamicFormItemType.RICH_TOOLS_SELECTOR ||
|
||||
config.type === DynamicFormItemType.RESOURCES_SELECTOR
|
||||
) {
|
||||
return (
|
||||
<FormField
|
||||
key={config.id}
|
||||
control={form.control}
|
||||
name={config.name as keyof FormValues}
|
||||
render={({ field }) => (
|
||||
<FormItem className="min-w-0">
|
||||
<FormControl>
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 max-w-full overflow-x-hidden',
|
||||
isFieldDisabled && 'pointer-events-none opacity-60',
|
||||
)}
|
||||
>
|
||||
<DynamicFormItemComponent
|
||||
config={config}
|
||||
field={field}
|
||||
formValues={watchedValues as Record<string, unknown>}
|
||||
onFileUploaded={onFileUploaded}
|
||||
setFormValue={setFormValue}
|
||||
systemContext={systemContext}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Boolean fields use a special inline layout
|
||||
if (normalizedConfig.type === 'boolean') {
|
||||
return (
|
||||
<FormField
|
||||
key={fieldKey}
|
||||
control={form.control}
|
||||
name={config.name as keyof FormValues}
|
||||
render={({ field }) => (
|
||||
<FormItem className="min-w-0">
|
||||
if (
|
||||
config.type === DynamicFormItemType.RICH_TOOLS_SELECTOR ||
|
||||
config.type === DynamicFormItemType.RESOURCES_SELECTOR
|
||||
) {
|
||||
return (
|
||||
<FormField
|
||||
key={config.id}
|
||||
control={form.control}
|
||||
name={config.name as keyof FormValues}
|
||||
render={({ field }) => (
|
||||
<FormItem className="min-w-0">
|
||||
<FormControl>
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full min-w-0 max-w-full flex-row items-center justify-between rounded-lg border p-4',
|
||||
'min-w-0 max-w-full overflow-x-hidden',
|
||||
isFieldDisabled && 'pointer-events-none opacity-60',
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<FormLabel className="flex min-w-0 items-center gap-1.5 text-base">
|
||||
{extractI18nObject(config.label)}
|
||||
{renderDisabledTooltipIcon()}
|
||||
</FormLabel>
|
||||
{config.description && (
|
||||
<p className="text-sm break-words text-muted-foreground">
|
||||
{extractI18nObject(config.description)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<FormControl>
|
||||
<DynamicFormItemComponent
|
||||
config={config}
|
||||
field={field}
|
||||
formValues={watchedValues as Record<string, unknown>}
|
||||
onFileUploaded={onFileUploaded}
|
||||
setFormValue={setFormValue}
|
||||
systemContext={systemContext}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Boolean fields use a special inline layout
|
||||
if (normalizedConfig.type === 'boolean') {
|
||||
return (
|
||||
<FormField
|
||||
key={fieldKey}
|
||||
control={form.control}
|
||||
name={config.name as keyof FormValues}
|
||||
render={({ field }) => (
|
||||
<FormItem className="min-w-0">
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full min-w-0 max-w-full flex-row items-center justify-between rounded-lg border p-4',
|
||||
isFieldDisabled && 'pointer-events-none opacity-60',
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<FormLabel className="flex min-w-0 items-center gap-1.5 text-base">
|
||||
{extractI18nObject(config.label)}
|
||||
{renderDisabledTooltipIcon()}
|
||||
</FormLabel>
|
||||
{config.description && (
|
||||
<p className="text-sm break-words text-muted-foreground">
|
||||
{extractI18nObject(config.description)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<FormControl>
|
||||
<DynamicFormItemComponent
|
||||
config={normalizedConfig}
|
||||
field={field}
|
||||
formValues={watchedValues as Record<string, unknown>}
|
||||
onFileUploaded={onFileUploaded}
|
||||
setFormValue={setFormValue}
|
||||
systemContext={systemContext}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormField
|
||||
key={fieldKey}
|
||||
control={form.control}
|
||||
name={config.name as keyof FormValues}
|
||||
render={({ field }) => {
|
||||
const customItem = renderItem?.({
|
||||
config: normalizedConfig,
|
||||
field,
|
||||
formValues: watchedValues as Record<string, unknown>,
|
||||
setFormValue,
|
||||
});
|
||||
return (
|
||||
<FormItem
|
||||
className={cn('min-w-0', isHiddenByCondition && 'hidden')}
|
||||
hidden={isHiddenByCondition}
|
||||
>
|
||||
<FormLabel className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="min-w-0 break-words">
|
||||
{extractI18nObject(config.label)}{' '}
|
||||
{config.required && (
|
||||
<span className="text-red-500">*</span>
|
||||
)}
|
||||
</span>
|
||||
{renderDisabledTooltipIcon()}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 max-w-full overflow-x-hidden',
|
||||
isFieldDisabled && 'pointer-events-none opacity-60',
|
||||
)}
|
||||
>
|
||||
{customItem !== undefined ? (
|
||||
customItem
|
||||
) : (
|
||||
<DynamicFormItemComponent
|
||||
config={normalizedConfig}
|
||||
field={field}
|
||||
@@ -943,77 +1074,21 @@ export default function DynamicFormComponent({
|
||||
setFormValue={setFormValue}
|
||||
systemContext={systemContext}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormField
|
||||
key={fieldKey}
|
||||
control={form.control}
|
||||
name={config.name as keyof FormValues}
|
||||
render={({ field }) => {
|
||||
const customItem = renderItem?.({
|
||||
config: normalizedConfig,
|
||||
field,
|
||||
formValues: watchedValues as Record<string, unknown>,
|
||||
setFormValue,
|
||||
});
|
||||
return (
|
||||
<FormItem
|
||||
className={cn('min-w-0', isHiddenByCondition && 'hidden')}
|
||||
hidden={isHiddenByCondition}
|
||||
>
|
||||
<FormLabel className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="min-w-0 break-words">
|
||||
{extractI18nObject(config.label)}{' '}
|
||||
{config.required && (
|
||||
<span className="text-red-500">*</span>
|
||||
)}
|
||||
</span>
|
||||
{renderDisabledTooltipIcon()}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 max-w-full overflow-x-hidden',
|
||||
isFieldDisabled && 'pointer-events-none opacity-60',
|
||||
)}
|
||||
>
|
||||
{customItem !== undefined ? (
|
||||
customItem
|
||||
) : (
|
||||
<DynamicFormItemComponent
|
||||
config={normalizedConfig}
|
||||
field={field}
|
||||
formValues={
|
||||
watchedValues as Record<string, unknown>
|
||||
}
|
||||
onFileUploaded={onFileUploaded}
|
||||
setFormValue={setFormValue}
|
||||
systemContext={systemContext}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
{config.description && (
|
||||
<p className="text-sm break-words text-muted-foreground">
|
||||
{extractI18nObject(config.description)}
|
||||
</p>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Form>
|
||||
</FormControl>
|
||||
{config.description && (
|
||||
<p className="text-sm break-words text-muted-foreground">
|
||||
{extractI18nObject(config.description)}
|
||||
</p>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -646,9 +646,19 @@ export default function DynamicFormItemComponent({
|
||||
}
|
||||
>
|
||||
<SelectValue placeholder={t('models.selectModel')}>
|
||||
{compactModelSelector && selectedModelLabel
|
||||
? selectedModelLabel
|
||||
: undefined}
|
||||
{/* Keep compact labels explicitly rendered even when a stream
|
||||
omits the model name. Switching to Radix's ItemText portal
|
||||
would give two renderers ownership of the same DOM node. */}
|
||||
{compactModelSelector ? (
|
||||
<span className="truncate">
|
||||
{selectedModelLabel ||
|
||||
selectableModels.find(
|
||||
(model) => model.uuid === field.value,
|
||||
)?.name ||
|
||||
field.value ||
|
||||
t('models.selectModel')}
|
||||
</span>
|
||||
) : undefined}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -112,6 +112,8 @@ export default function KBForm({
|
||||
// Dirty tracking: snapshot of saved state for comparison
|
||||
const savedSnapshotRef = useRef<string>('');
|
||||
const isInitializing = useRef(true);
|
||||
const loadVersion = useRef(0);
|
||||
const initialSettings = useRef(new Set<'config' | 'retrieval'>());
|
||||
const suppressNextAutoSelectRef = useRef(false);
|
||||
|
||||
// Refs to store validation functions from dynamic forms
|
||||
@@ -135,36 +137,65 @@ export default function KBForm({
|
||||
(e) => e.plugin_id === selectedEngineId,
|
||||
);
|
||||
|
||||
// Dirty tracking: compare current form + dynamic settings against saved snapshot
|
||||
const watchedFormValues = form.watch();
|
||||
useEffect(() => {
|
||||
const draftRef = useRef({
|
||||
config: configSettings,
|
||||
retrieval: retrievalSettings,
|
||||
});
|
||||
draftRef.current = { config: configSettings, retrieval: retrievalSettings };
|
||||
const lastDirtyRef = useRef(false);
|
||||
const refreshDirty = useCallback(() => {
|
||||
if (!savedSnapshotRef.current || isInitializing.current) return;
|
||||
const currentSnapshot = JSON.stringify({
|
||||
form: watchedFormValues,
|
||||
config: configSettings,
|
||||
retrieval: retrievalSettings,
|
||||
});
|
||||
const dirty = currentSnapshot !== savedSnapshotRef.current;
|
||||
onDirtyChange?.(dirty);
|
||||
}, [watchedFormValues, configSettings, retrievalSettings, onDirtyChange]);
|
||||
const next =
|
||||
!engineInitialized ||
|
||||
JSON.stringify({
|
||||
form: form.getValues(),
|
||||
...draftRef.current,
|
||||
}) !== savedSnapshotRef.current;
|
||||
if (next !== lastDirtyRef.current) {
|
||||
lastDirtyRef.current = next;
|
||||
onDirtyChange?.(next);
|
||||
}
|
||||
}, [engineInitialized, form, onDirtyChange]);
|
||||
useEffect(() => {
|
||||
refreshDirty();
|
||||
const subscription = form.watch(refreshDirty);
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, refreshDirty, configSettings, retrievalSettings]);
|
||||
|
||||
const captureSnapshot = () => {
|
||||
savedSnapshotRef.current = JSON.stringify({
|
||||
form: form.getValues(),
|
||||
config: configSettings,
|
||||
retrieval: retrievalSettings,
|
||||
});
|
||||
};
|
||||
function updateSettings(
|
||||
slot: 'config' | 'retrieval',
|
||||
values: Record<string, unknown>,
|
||||
) {
|
||||
// Normalize only this form's initial emission. Never absorb another field's
|
||||
// edits or depend on a timer to decide when the user may start typing.
|
||||
if (initialSettings.current.delete(slot) && savedSnapshotRef.current) {
|
||||
const baseline = JSON.parse(savedSnapshotRef.current);
|
||||
baseline[slot] = values;
|
||||
savedSnapshotRef.current = JSON.stringify(baseline);
|
||||
}
|
||||
draftRef.current = { ...draftRef.current, [slot]: values };
|
||||
if (slot === 'config') setConfigSettings(values);
|
||||
else setRetrievalSettings(values);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const version = ++loadVersion.current;
|
||||
setInitialDataLoaded(false);
|
||||
setLoadFailed(false);
|
||||
loadRagEngines()
|
||||
loadRagEngines(version)
|
||||
.then(() => {
|
||||
if (initKbId) return loadKbConfig(initKbId);
|
||||
if (version === loadVersion.current && initKbId)
|
||||
return loadKbConfig(initKbId, version);
|
||||
})
|
||||
.catch(() => setLoadFailed(true))
|
||||
.finally(() => setInitialDataLoaded(true));
|
||||
.catch(() => {
|
||||
if (version === loadVersion.current) setLoadFailed(true);
|
||||
})
|
||||
.finally(() => {
|
||||
if (version === loadVersion.current) setInitialDataLoaded(true);
|
||||
});
|
||||
return () => {
|
||||
loadVersion.current += 1;
|
||||
};
|
||||
}, [initKbId, loadAttempt]);
|
||||
|
||||
// Auto-select first engine when engines are loaded and no selection
|
||||
@@ -188,54 +219,63 @@ export default function KBForm({
|
||||
}
|
||||
}, [ragEngines, selectedEngineId, isEditing]);
|
||||
|
||||
const loadRagEngines = async () => {
|
||||
const loadRagEngines = async (version: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const resp = await httpClient.getKnowledgeEngines();
|
||||
setRagEngines(resp.engines);
|
||||
if (version === loadVersion.current) setRagEngines(resp.engines);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (version === loadVersion.current) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadKbConfig = async (kbId: string) => {
|
||||
const loadKbConfig = async (kbId: string, version: number) => {
|
||||
try {
|
||||
isInitializing.current = true;
|
||||
setIsEditing(true);
|
||||
|
||||
const res = await httpClient.getKnowledgeBase(kbId);
|
||||
if (version !== loadVersion.current) return;
|
||||
const kb = res.base;
|
||||
|
||||
const engineId = kb.knowledge_engine_plugin_id || '';
|
||||
setSelectedEngineId(engineId);
|
||||
|
||||
form.reset({
|
||||
const loadedForm = {
|
||||
name: kb.name,
|
||||
description: kb.description,
|
||||
emoji: kb.emoji || '📚',
|
||||
ragEngineId: engineId,
|
||||
});
|
||||
};
|
||||
form.reset(loadedForm);
|
||||
|
||||
setConfigSettings(kb.creation_settings || {});
|
||||
setRetrievalSettings(kb.retrieval_settings || {});
|
||||
setEngineInitialized(kb.initialized !== false);
|
||||
|
||||
// Capture snapshot after a tick so dynamic forms have emitted initial values
|
||||
setTimeout(() => {
|
||||
captureSnapshot();
|
||||
isInitializing.current = false;
|
||||
onDirtyChange?.(kb.initialized === false);
|
||||
}, 500);
|
||||
} catch (err) {
|
||||
draftRef.current = {
|
||||
config: kb.creation_settings || {},
|
||||
retrieval: kb.retrieval_settings || {},
|
||||
};
|
||||
savedSnapshotRef.current = JSON.stringify({
|
||||
form: loadedForm,
|
||||
...draftRef.current,
|
||||
});
|
||||
initialSettings.current = new Set(['config', 'retrieval']);
|
||||
isInitializing.current = false;
|
||||
lastDirtyRef.current = kb.initialized === false;
|
||||
onDirtyChange?.(lastDirtyRef.current);
|
||||
} catch (err) {
|
||||
if (version === loadVersion.current) isInitializing.current = false;
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEngineChange = useCallback(
|
||||
(engineId: string, installedEngine?: KnowledgeEngine) => {
|
||||
initialSettings.current.clear();
|
||||
setSelectedEngineId(engineId);
|
||||
form.setValue('ragEngineId', engineId, {
|
||||
shouldDirty: true,
|
||||
@@ -300,13 +340,22 @@ export default function KBForm({
|
||||
: { defer_initialization: true }),
|
||||
};
|
||||
|
||||
const submittedSnapshot = JSON.stringify({
|
||||
form: data,
|
||||
config: configSettings,
|
||||
retrieval: retrievalSettings,
|
||||
});
|
||||
if (initKbId) {
|
||||
httpClient
|
||||
.updateKnowledgeBase(initKbId, kbData)
|
||||
.then((res) => {
|
||||
setEngineInitialized(true);
|
||||
captureSnapshot();
|
||||
onDirtyChange?.(false);
|
||||
savedSnapshotRef.current = submittedSnapshot;
|
||||
const dirty =
|
||||
JSON.stringify({ form: form.getValues(), ...draftRef.current }) !==
|
||||
submittedSnapshot;
|
||||
lastDirtyRef.current = dirty;
|
||||
onDirtyChange?.(dirty);
|
||||
onKbUpdated(res.uuid);
|
||||
toast.success(t('knowledge.updateKnowledgeBaseSuccess'));
|
||||
})
|
||||
@@ -515,7 +564,7 @@ export default function KBForm({
|
||||
itemConfigList={configFormItems}
|
||||
initialValues={configSettings as Record<string, object>}
|
||||
onSubmit={(val) =>
|
||||
setConfigSettings(val as Record<string, unknown>)
|
||||
updateSettings('config', val as Record<string, unknown>)
|
||||
}
|
||||
isEditing={engineInitialized}
|
||||
externalDependentValues={retrievalSettings}
|
||||
@@ -542,7 +591,7 @@ export default function KBForm({
|
||||
itemConfigList={retrievalFormItems}
|
||||
initialValues={retrievalSettings as Record<string, object>}
|
||||
onSubmit={(val) =>
|
||||
setRetrievalSettings(val as Record<string, unknown>)
|
||||
updateSettings('retrieval', val as Record<string, unknown>)
|
||||
}
|
||||
externalDependentValues={configSettings}
|
||||
onValidate={(validateFn) =>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useSnapshotDirty } from '@/app/infra/hooks/useSnapshotDirty';
|
||||
import GuidedTour, {
|
||||
type GuidedTourStep,
|
||||
} from '@/app/home/components/guided-tour/GuidedTour';
|
||||
@@ -25,7 +26,7 @@ import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicForm
|
||||
import { getDefaultValues } from '@/app/home/components/dynamic-form/DynamicFormItemConfig';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -324,6 +325,13 @@ const PipelineFormComponent = forwardRef<
|
||||
output: {},
|
||||
},
|
||||
});
|
||||
useWatch({
|
||||
control: form.control,
|
||||
compute: (values) => [
|
||||
values.ai?.runner?.id,
|
||||
values.output?.misc?.['remove-think'],
|
||||
],
|
||||
});
|
||||
const runnerInstallScope = `pipeline:${pipelineId || 'new'}`;
|
||||
const applyInstalledRunner = useCallback((installed: InstalledRunner) => {
|
||||
setAIConfigTabSchema(installed.configTab);
|
||||
@@ -337,15 +345,11 @@ const PipelineFormComponent = forwardRef<
|
||||
const savedSnapshotRef = useRef<string>('');
|
||||
// Track which dynamic form stages have completed their initial mount emission.
|
||||
const initializedStagesRef = useRef<Set<string>>(new Set());
|
||||
const watchedValues = form.watch();
|
||||
const hasUnsavedChanges = (() => {
|
||||
if (!isEditMode || !savedSnapshotRef.current) return false;
|
||||
return JSON.stringify(watchedValues) !== savedSnapshotRef.current;
|
||||
})();
|
||||
// Keep a ref so that non-reactive callbacks (handleDynamicFormEmit) can
|
||||
// read the latest dirty state without stale closures.
|
||||
const hasUnsavedChangesRef = useRef(hasUnsavedChanges);
|
||||
hasUnsavedChangesRef.current = hasUnsavedChanges;
|
||||
const {
|
||||
isDirty: hasUnsavedChanges,
|
||||
dirtyRef: hasUnsavedChangesRef,
|
||||
refreshDirty,
|
||||
} = useSnapshotDirty(form, savedSnapshotRef, isEditMode);
|
||||
|
||||
// Notify parent when dirty state changes
|
||||
useEffect(() => {
|
||||
@@ -405,6 +409,7 @@ const PipelineFormComponent = forwardRef<
|
||||
};
|
||||
form.reset(loadedValues);
|
||||
savedSnapshotRef.current = JSON.stringify(loadedValues);
|
||||
refreshDirty();
|
||||
initializedStagesRef.current.clear();
|
||||
setPipelineLoaded(true);
|
||||
})
|
||||
@@ -415,7 +420,14 @@ const PipelineFormComponent = forwardRef<
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [form, isEditMode, pipelineId, loadAttempt, onLegacyPipeline]);
|
||||
}, [
|
||||
form,
|
||||
isEditMode,
|
||||
pipelineId,
|
||||
loadAttempt,
|
||||
onLegacyPipeline,
|
||||
refreshDirty,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -538,6 +550,7 @@ const PipelineFormComponent = forwardRef<
|
||||
try {
|
||||
await httpClient.updatePipeline(pipelineId || '', pipeline);
|
||||
savedSnapshotRef.current = submittedSnapshot;
|
||||
refreshDirty();
|
||||
onFinish();
|
||||
toast.success(t('pipelines.saveSuccess'));
|
||||
return true;
|
||||
@@ -572,6 +585,7 @@ const PipelineFormComponent = forwardRef<
|
||||
emoji: values.emoji || '⚙️',
|
||||
};
|
||||
savedSnapshotRef.current = JSON.stringify(snapshot);
|
||||
refreshDirty();
|
||||
}
|
||||
},
|
||||
async save() {
|
||||
@@ -595,6 +609,7 @@ const PipelineFormComponent = forwardRef<
|
||||
stageName: string,
|
||||
values: object,
|
||||
) {
|
||||
const wasDirty = hasUnsavedChangesRef.current;
|
||||
const stageKey = `${String(formName)}.${stageName}`;
|
||||
const isFirstEmission =
|
||||
!initializedStagesRef.current.has(stageKey) &&
|
||||
@@ -665,8 +680,9 @@ const PipelineFormComponent = forwardRef<
|
||||
// runner), the snapshot must remain at the last-saved state so that
|
||||
// hasUnsavedChanges stays true.
|
||||
const currentSnapshot = JSON.stringify(form.getValues());
|
||||
if (savedSnapshotRef.current === '' || !hasUnsavedChangesRef.current) {
|
||||
if (savedSnapshotRef.current === '' || !wasDirty) {
|
||||
savedSnapshotRef.current = currentSnapshot;
|
||||
refreshDirty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -698,7 +714,7 @@ const PipelineFormComponent = forwardRef<
|
||||
) {
|
||||
// Special handling for AI config section
|
||||
if (formName === 'ai') {
|
||||
const runnerConfig = (form.watch('ai.runner') as any) || {};
|
||||
const runnerConfig = (form.getValues('ai.runner') as any) || {};
|
||||
const currentRunner = runnerConfig.id;
|
||||
|
||||
// If this is the runner selector stage, render it directly
|
||||
@@ -717,7 +733,7 @@ const PipelineFormComponent = forwardRef<
|
||||
<DynamicFormComponent
|
||||
itemConfigList={stage.config}
|
||||
initialValues={
|
||||
(form.watch(formName) as Record<string, unknown>)?.[
|
||||
(form.getValues(formName) as Record<string, unknown>)?.[
|
||||
stage.name
|
||||
] || {}
|
||||
}
|
||||
@@ -752,7 +768,7 @@ const PipelineFormComponent = forwardRef<
|
||||
const isPluginRunner =
|
||||
currentRunner && currentRunner.startsWith('plugin:');
|
||||
if (isPluginRunner) {
|
||||
const runnerConfigs = (form.watch('ai.runner_config') as any) || {};
|
||||
const runnerConfigs = (form.getValues('ai.runner_config') as any) || {};
|
||||
const stageInitialValues = runnerConfigs[stage.name] || {};
|
||||
return (
|
||||
<Card key={stage.name}>
|
||||
@@ -784,7 +800,7 @@ const PipelineFormComponent = forwardRef<
|
||||
? PersistedRunnerForm
|
||||
: DynamicFormComponent;
|
||||
const stageInitialValues: Record<string, any> =
|
||||
(form.watch(formName) as Record<string, any>)?.[stage.name] || {};
|
||||
(form.getValues(formName) as Record<string, any>)?.[stage.name] || {};
|
||||
|
||||
return (
|
||||
<Card key={stage.name}>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { RefObject } from 'react';
|
||||
import type { FieldValues, UseFormReturn } from 'react-hook-form';
|
||||
|
||||
/** Subscribe without rendering the form for every keystroke. Values remain synchronous. */
|
||||
export function useSnapshotDirty<T extends FieldValues>(
|
||||
form: UseFormReturn<T>,
|
||||
savedSnapshot: RefObject<string>,
|
||||
enabled = true,
|
||||
) {
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const dirtyRef = useRef(false);
|
||||
const refreshDirty = useCallback(() => {
|
||||
const next =
|
||||
enabled &&
|
||||
!!savedSnapshot.current &&
|
||||
JSON.stringify(form.getValues()) !== savedSnapshot.current;
|
||||
if (next !== dirtyRef.current) {
|
||||
dirtyRef.current = next;
|
||||
setIsDirty(next);
|
||||
}
|
||||
return next;
|
||||
}, [enabled, form, savedSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshDirty();
|
||||
const subscription = form.watch(refreshDirty);
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, refreshDirty]);
|
||||
|
||||
return { isDirty, dirtyRef, refreshDirty };
|
||||
}
|
||||
Reference in New Issue
Block a user