fix(web): reduce form rerenders and stabilize assistant model labels

This commit is contained in:
Hyu
2026-10-08 15:21:59 +08:00
parent 4b370f0a92
commit b718c7b5dd
11 changed files with 1180 additions and 510 deletions
@@ -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, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
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, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
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 };
}