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 };
|
||||
}
|
||||
@@ -157,12 +157,16 @@ test.describe('bot session request recovery', () => {
|
||||
.click();
|
||||
}
|
||||
}
|
||||
await expect(page.getByRole('alert')).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('tabpanel', { name: /Sessions/ }).getByRole('alert'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('No sessions found', { exact: true }),
|
||||
).toHaveCount(0);
|
||||
if (failure === 'analysis') {
|
||||
await expect(page.getByRole('alert')).toContainText(/Tool/i);
|
||||
await expect(
|
||||
page.getByRole('tabpanel', { name: /Sessions/ }).getByRole('alert'),
|
||||
).toContainText(/Tool/i);
|
||||
await expect(
|
||||
page.getByText('Successful message', { exact: true }),
|
||||
).toBeVisible();
|
||||
@@ -177,10 +181,13 @@ test.describe('bot session request recovery', () => {
|
||||
).toHaveCount(0);
|
||||
failing = false;
|
||||
await page
|
||||
.getByRole('tabpanel', { name: /Sessions/ })
|
||||
.getByRole('alert')
|
||||
.getByRole('button', { name: 'Retry', exact: true })
|
||||
.click();
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole('tabpanel', { name: /Sessions/ }).getByRole('alert'),
|
||||
).toHaveCount(0);
|
||||
if (failure === 'initial list' || failure === 'list page') {
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
@@ -313,7 +320,9 @@ test.describe('bot session request races', () => {
|
||||
await expect.poll(() => released).toBe(true);
|
||||
// Allow the released HTTP response and React's queued update to settle.
|
||||
await page.waitForTimeout(200);
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole('tabpanel', { name: /Sessions/ }).getByRole('alert'),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByText('Stale list', { exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
@@ -98,6 +98,7 @@ export interface WorkspaceEntryMock {
|
||||
|
||||
interface LangBotApiMockState {
|
||||
authenticated: boolean;
|
||||
withAssistant: boolean;
|
||||
bots: BotMock[];
|
||||
counters: Record<string, number>;
|
||||
knowledgeBases: KnowledgeBaseMock[];
|
||||
@@ -732,7 +733,9 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
||||
|
||||
if (path === '/api/v1/user/info') {
|
||||
return fulfillJson(route, {
|
||||
account_uuid: 'account-playwright',
|
||||
// Most tests exercise an unlinked local user. Assistant tests opt into
|
||||
// an account identity and mock its additional provider/conversation APIs.
|
||||
account_uuid: state.withAssistant ? 'account-playwright' : null,
|
||||
user: '[email protected]',
|
||||
account_type: 'local',
|
||||
has_password: true,
|
||||
@@ -1459,6 +1462,7 @@ export async function installLangBotApiMocks(
|
||||
page: Page,
|
||||
options: {
|
||||
authenticated?: boolean;
|
||||
withAssistant?: boolean;
|
||||
language?: string;
|
||||
monitoringData?: unknown;
|
||||
monitoringSessions?: unknown[];
|
||||
@@ -1472,6 +1476,7 @@ export async function installLangBotApiMocks(
|
||||
) {
|
||||
const {
|
||||
authenticated = false,
|
||||
withAssistant = false,
|
||||
language = 'en-US',
|
||||
monitoringData,
|
||||
monitoringSessions,
|
||||
@@ -1484,6 +1489,7 @@ export async function installLangBotApiMocks(
|
||||
} = options;
|
||||
const state: LangBotApiMockState = {
|
||||
authenticated,
|
||||
withAssistant,
|
||||
bots: [],
|
||||
counters: {},
|
||||
knowledgeBases: [],
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import {
|
||||
installLangBotApiMocks,
|
||||
pipelineMetadata,
|
||||
} from './fixtures/langbot-api';
|
||||
|
||||
const runner = 'plugin:langbot-team/LocalAgent/default';
|
||||
|
||||
async function setup(page: Page, kind: 'agent' | 'pipeline' = 'agent') {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
// Exercise the self-hosted form, without the unrelated account assistant overlay.
|
||||
await page.route('**/api/v1/user/info', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: { account_uuid: null, user_id: 1, username: 'Form tester' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const metadata = pipelineMetadata();
|
||||
let pipelineSaved = false;
|
||||
page.on('request', (r) => {
|
||||
if (r.method() === 'PUT' && r.url().endsWith('/pipelines/pipeline-draft'))
|
||||
pipelineSaved = true;
|
||||
});
|
||||
await page.route('**/api/v1/pipelines/pipeline-draft', async (route) => {
|
||||
if (route.request().method() !== 'GET' || pipelineSaved)
|
||||
return route.fallback();
|
||||
await route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
pipeline: {
|
||||
uuid: 'pipeline-draft',
|
||||
name: 'Pipeline draft',
|
||||
emoji: '⚙️',
|
||||
description: '',
|
||||
config: {
|
||||
ai: {
|
||||
runner: { id: runner },
|
||||
runner_config: {
|
||||
[runner]: {
|
||||
model: { primary: 'llm-valid', fallbacks: [] },
|
||||
instruction: 'Original instruction',
|
||||
advanced: false,
|
||||
detail: 'Original detail',
|
||||
},
|
||||
},
|
||||
},
|
||||
trigger: {},
|
||||
safety: {},
|
||||
output: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
metadata.configs[0].stages[1].config.push(
|
||||
...([
|
||||
{
|
||||
name: 'instruction',
|
||||
label: { en_US: 'Instruction' },
|
||||
type: 'text',
|
||||
required: true,
|
||||
default: 'Original instruction',
|
||||
},
|
||||
{
|
||||
name: 'advanced',
|
||||
label: { en_US: 'Advanced' },
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
name: 'detail',
|
||||
label: { en_US: 'Detail' },
|
||||
type: 'text',
|
||||
default: 'Original detail',
|
||||
show_if: { field: 'advanced', operator: 'eq', value: true },
|
||||
},
|
||||
] as any),
|
||||
);
|
||||
await page.route(
|
||||
`**/api/v1/${kind === 'agent' ? 'agents' : 'pipelines'}/_/metadata`,
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data:
|
||||
kind === 'agent'
|
||||
? {
|
||||
runner_config: metadata.configs[0],
|
||||
kinds: [],
|
||||
event_processors: [],
|
||||
}
|
||||
: metadata,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.goto(
|
||||
`/home/${kind === 'agent' ? 'agents' : 'pipelines'}?id=${kind}-draft`,
|
||||
);
|
||||
if (kind === 'pipeline')
|
||||
await page.getByRole('tab', { name: /^AI$/ }).click();
|
||||
const input = page.locator('textarea[name="instruction"]');
|
||||
await expect(input).toHaveValue('Original instruction');
|
||||
return {
|
||||
input,
|
||||
save: page.getByRole('button', { name: 'Save', exact: true }),
|
||||
endpoint: `/api/v1/${kind === 'agent' ? 'agents' : 'pipelines'}/${kind}-draft`,
|
||||
};
|
||||
}
|
||||
|
||||
for (const kind of ['agent', 'pipeline'] as const) {
|
||||
test(`${kind}: revert, conditional fields, immediate save and reload retain the latest draft`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const { input, save, endpoint } = await setup(page, kind);
|
||||
await expect(save).toBeDisabled();
|
||||
await input.fill('Changed');
|
||||
await expect(save).toBeEnabled();
|
||||
await input.fill('Original instruction');
|
||||
await expect(save).toBeDisabled();
|
||||
await page
|
||||
.locator('label')
|
||||
.filter({ hasText: /^Advanced$/ })
|
||||
.locator('../..')
|
||||
.getByRole('switch')
|
||||
.click();
|
||||
const detail = page.locator('textarea[name="detail"]');
|
||||
await detail.fill('Retained while hidden');
|
||||
await page
|
||||
.locator('label')
|
||||
.filter({ hasText: /^Advanced$/ })
|
||||
.locator('../..')
|
||||
.getByRole('switch')
|
||||
.click();
|
||||
await expect(detail).toHaveCount(0);
|
||||
await input.pressSequentially(' final characters');
|
||||
const request = page.waitForRequest(
|
||||
(r) => new URL(r.url()).pathname === endpoint && r.method() === 'PUT',
|
||||
);
|
||||
await save.click();
|
||||
const payload = (await request).postDataJSON();
|
||||
const config = kind === 'agent' ? payload.config : payload.config.ai;
|
||||
expect(config.runner_config[runner]).toMatchObject({
|
||||
instruction: 'Original instruction final characters',
|
||||
detail: 'Retained while hidden',
|
||||
advanced: false,
|
||||
});
|
||||
await expect(save).toBeDisabled();
|
||||
await page.reload();
|
||||
if (kind === 'pipeline')
|
||||
await page.getByRole('tab', { name: /^AI$/ }).click();
|
||||
await expect(input).toHaveValue('Original instruction final characters');
|
||||
await expect(save).toBeDisabled();
|
||||
await page
|
||||
.locator('label')
|
||||
.filter({ hasText: /^Advanced$/ })
|
||||
.locator('../..')
|
||||
.getByRole('switch')
|
||||
.click();
|
||||
await expect(detail).toHaveValue('Retained while hidden');
|
||||
});
|
||||
}
|
||||
|
||||
test('agent: save completion does not absorb edits made while the request is pending', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { input, save, endpoint } = await setup(page);
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.route(`**${endpoint}`, async (route) => {
|
||||
if (route.request().method() === 'PUT') await gate;
|
||||
await route.fallback();
|
||||
});
|
||||
await input.fill('First draft');
|
||||
const request = page.waitForRequest(
|
||||
(r) => r.method() === 'PUT' && r.url().endsWith(endpoint),
|
||||
);
|
||||
await save.click();
|
||||
await request;
|
||||
await input.fill('Second draft typed during save');
|
||||
release();
|
||||
await expect(save).toBeEnabled();
|
||||
const next = page.waitForRequest(
|
||||
(r) => r.method() === 'PUT' && r.url().endsWith(endpoint),
|
||||
);
|
||||
await save.click();
|
||||
expect(
|
||||
(await next).postDataJSON().config.runner_config[runner].instruction,
|
||||
).toBe('Second draft typed during save');
|
||||
await expect(save).toBeDisabled();
|
||||
});
|
||||
|
||||
test('agent: failed save keeps the draft, and debugging saves the latest value first', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { input, save, endpoint } = await setup(page);
|
||||
let fail = true;
|
||||
const operations: string[] = [];
|
||||
await page.route(`**${endpoint}`, async (route) => {
|
||||
if (route.request().method() !== 'PUT') return route.fallback();
|
||||
if (fail)
|
||||
return route.fulfill({
|
||||
status: 500,
|
||||
json: { code: 500, msg: 'Deliberate save failure' },
|
||||
});
|
||||
operations.push('save');
|
||||
expect(
|
||||
route.request().postDataJSON().config.runner_config[runner].instruction,
|
||||
).toBe('Latest before debug');
|
||||
await route.fallback();
|
||||
});
|
||||
await input.fill('Unsaved draft');
|
||||
await save.click();
|
||||
await expect(page.getByText(/Deliberate save failure/)).toBeVisible();
|
||||
await expect(save).toBeEnabled();
|
||||
await expect(input).toHaveValue('Unsaved draft');
|
||||
fail = false;
|
||||
page.on('request', (request) => {
|
||||
if (request.url().includes('/debug/stream')) operations.push('debug');
|
||||
});
|
||||
await page.getByRole('textbox', { name: 'Message content' }).fill('Hello');
|
||||
await input.fill('Latest before debug');
|
||||
await page.getByRole('button', { name: 'Save and run', exact: true }).click();
|
||||
await expect(page.getByText('Mock Agent response')).toBeVisible();
|
||||
expect(operations).toEqual(['save', 'debug']);
|
||||
await expect(save).toBeDisabled();
|
||||
});
|
||||
|
||||
test('agent: required-field status still reacts after unrelated keystrokes are isolated', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { input } = await setup(page);
|
||||
await expect(page.getByText('Runner ready', { exact: true })).toBeVisible();
|
||||
await input.fill('');
|
||||
await expect(page.getByText('Runner ready', { exact: true })).toHaveCount(0);
|
||||
await input.fill('Valid again');
|
||||
await expect(page.getByText('Runner ready', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('knowledge: early edits are not absorbed into initialization, and save uses a submitted snapshot', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
const engine = {
|
||||
plugin_id: 'builtin/minimal-knowledge',
|
||||
name: { en_US: 'Test engine' },
|
||||
capabilities: ['text_retrieval'],
|
||||
creation_schema: [],
|
||||
retrieval_schema: [
|
||||
{
|
||||
name: 'instructions',
|
||||
type: 'text',
|
||||
label: { en_US: 'Retrieval instructions' },
|
||||
default: 'Original',
|
||||
},
|
||||
],
|
||||
};
|
||||
let base = {
|
||||
uuid: 'kb-draft',
|
||||
name: 'Knowledge draft',
|
||||
description: '',
|
||||
emoji: '📚',
|
||||
initialized: true,
|
||||
knowledge_engine_plugin_id: engine.plugin_id,
|
||||
knowledge_engine: engine,
|
||||
creation_settings: {},
|
||||
retrieval_settings: { instructions: 'Original' },
|
||||
};
|
||||
await page.route('**/api/v1/knowledge/engines', (route) =>
|
||||
route.fulfill({ json: { code: 0, data: { engines: [engine] } } }),
|
||||
);
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let saving = false;
|
||||
await page.route('**/api/v1/knowledge/bases/kb-draft', async (route) => {
|
||||
if (route.request().method() === 'PUT') {
|
||||
const payload = route.request().postDataJSON();
|
||||
saving = true;
|
||||
await gate;
|
||||
base = { ...base, ...payload };
|
||||
return route.fulfill({ json: { code: 0, data: { uuid: base.uuid } } });
|
||||
}
|
||||
return route.fulfill({ json: { code: 0, data: { base } } });
|
||||
});
|
||||
await page.goto('/home/knowledge?id=kb-draft');
|
||||
const input = page.locator('textarea[name="instructions"]');
|
||||
const save = page.getByRole('button', { name: 'Save', exact: true });
|
||||
await expect(input).toHaveValue('Original');
|
||||
await input.fill('Early edit');
|
||||
await expect(save).toBeEnabled();
|
||||
await input.fill('Original');
|
||||
await expect(save).toBeDisabled();
|
||||
await input.fill('Submitted value');
|
||||
await save.click();
|
||||
await expect.poll(() => saving).toBe(true);
|
||||
await input.fill('Typed while saving');
|
||||
release();
|
||||
await expect
|
||||
.poll(() => base.retrieval_settings.instructions)
|
||||
.toBe('Submitted value');
|
||||
await expect(save).toBeEnabled();
|
||||
await save.click();
|
||||
await expect
|
||||
.poll(() => base.retrieval_settings.instructions)
|
||||
.toBe('Typed while saving');
|
||||
await expect(save).toBeDisabled();
|
||||
await page.reload();
|
||||
await expect(input).toHaveValue('Typed while saving');
|
||||
await expect(save).toBeDisabled();
|
||||
});
|
||||
@@ -12,10 +12,92 @@ const chat = {
|
||||
model_name: null,
|
||||
};
|
||||
|
||||
test('sending survives model labels appearing and disappearing in stream snapshots', async ({
|
||||
page,
|
||||
}) => {
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
page.on('console', (message) => {
|
||||
if (/createPortal|removeChild|createRoot/.test(message.text()))
|
||||
errors.push(message.text());
|
||||
});
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
withAssistant: true,
|
||||
});
|
||||
await page.route('**/api/v1/provider/providers', (route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: { providers: [{ uuid: 'provider' }] } },
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/assistant/recommended-model', (route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: { uuid: 'llm-valid', name: 'Recommended model' } },
|
||||
}),
|
||||
);
|
||||
let current: Record<string, unknown> = chat;
|
||||
await page.route('**/api/v1/assistant/conversations', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data:
|
||||
route.request().method() === 'POST' ? chat : { conversations: [] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route(
|
||||
'**/api/v1/assistant/conversations/assistant-test',
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: current },
|
||||
}),
|
||||
);
|
||||
let turn = 0;
|
||||
await page.route(
|
||||
'**/api/v1/assistant/conversations/*/turn/stream',
|
||||
async (route) => {
|
||||
turn += 1;
|
||||
current = {
|
||||
...chat,
|
||||
revision: turn,
|
||||
model_uuid: 'llm-valid',
|
||||
model_name: turn === 2 ? 'Snapshot model' : null,
|
||||
messages: [{ role: 'assistant', content: `Reply ${turn}` }],
|
||||
};
|
||||
await route.fulfill({
|
||||
contentType: 'application/x-ndjson',
|
||||
body:
|
||||
JSON.stringify({ kind: 'snapshot', data: current }) +
|
||||
'\n' +
|
||||
JSON.stringify({ kind: 'completed', data: current }) +
|
||||
'\n',
|
||||
});
|
||||
},
|
||||
);
|
||||
await page.goto('/home/bots');
|
||||
const assistant = page.getByRole('dialog', { name: 'Workspace assistant' });
|
||||
const model = assistant.getByRole('combobox', { name: 'Select Model' });
|
||||
await expect(model).toContainText('Recommended model');
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await assistant.getByRole('textbox').fill(`Hello ${i}`);
|
||||
await assistant.getByRole('button', { name: 'Send', exact: true }).click();
|
||||
await expect(
|
||||
assistant.getByText(`Reply ${i}`, { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(model).toContainText(
|
||||
i === 2 ? 'Snapshot model' : 'Valid Mock Model',
|
||||
);
|
||||
}
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('selects the recommendation on entry and submits the visible model', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
withAssistant: true,
|
||||
});
|
||||
await page.route('**/api/v1/provider/providers', (route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: { providers: [{ uuid: 'provider' }] } },
|
||||
@@ -64,7 +146,10 @@ test('selects the recommendation on entry and submits the visible model', async
|
||||
test('late recommendation cannot overwrite manual selection, including request payload', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
withAssistant: true,
|
||||
});
|
||||
await page.route('**/api/v1/provider/providers', (route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: { providers: [{ uuid: 'provider' }] } },
|
||||
@@ -138,6 +223,7 @@ test('renders streaming text and tool progress before completion', async ({
|
||||
model_name: 'Recommended model',
|
||||
messages: [{ role: 'user', content: 'Inspect resources' }],
|
||||
};
|
||||
let currentConversation: Record<string, unknown> = started;
|
||||
const server = createServer(async (req, res) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '*');
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
@@ -160,7 +246,7 @@ test('renders streaming text and tool progress before completion', async ({
|
||||
tool: { name: 'list_resources', arguments: { kind: 'models' } },
|
||||
});
|
||||
await gate;
|
||||
emit('completed', {
|
||||
currentConversation = {
|
||||
...started,
|
||||
status: 'ready',
|
||||
messages: [
|
||||
@@ -176,13 +262,24 @@ test('renders streaming text and tool progress before completion', async ({
|
||||
},
|
||||
{ role: 'assistant', content: 'Found your model' },
|
||||
],
|
||||
});
|
||||
};
|
||||
emit('completed', currentConversation);
|
||||
res.end();
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const address = server.address() as { port: number };
|
||||
try {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
withAssistant: true,
|
||||
});
|
||||
// The panel refreshes the persisted conversation after streaming finishes.
|
||||
// Return the same snapshot as the stream instead of the generic API fallback.
|
||||
await page.route(
|
||||
'**/api/v1/assistant/conversations/assistant-test',
|
||||
(route) =>
|
||||
route.fulfill({ json: { code: 0, data: currentConversation } }),
|
||||
);
|
||||
await page.route('**/api/v1/provider/providers', (route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: { providers: [{ uuid: 'provider' }] } },
|
||||
@@ -243,7 +340,10 @@ test('renders streaming text and tool progress before completion', async ({
|
||||
test('without providers shows setup only and rechecks after model settings close', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
withAssistant: true,
|
||||
});
|
||||
await page.route('**/api/v1/provider/requesters', (route) =>
|
||||
route.fulfill({ json: { code: 0, data: { requesters: [] } } }),
|
||||
);
|
||||
@@ -299,7 +399,10 @@ test('without providers shows setup only and rechecks after model settings close
|
||||
test('setup login starts the existing LangBot Account authorization flow', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
withAssistant: true,
|
||||
});
|
||||
await page.route('**/api/v1/provider/providers', (route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: { providers: [] } },
|
||||
@@ -320,7 +423,10 @@ test('setup login starts the existing LangBot Account authorization flow', async
|
||||
test('switches conversations, restores background progress after reload and stops only the selected task', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
withAssistant: true,
|
||||
});
|
||||
await page.route('**/api/v1/provider/providers', (route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: { providers: [{ uuid: 'provider' }] } },
|
||||
@@ -452,7 +558,10 @@ test('switches conversations, restores background progress after reload and stop
|
||||
test('keeps the reading position through background updates and resumes following at the bottom', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
withAssistant: true,
|
||||
});
|
||||
await page.route('**/api/v1/provider/providers', (route) =>
|
||||
route.fulfill({
|
||||
json: { code: 0, data: { providers: [{ uuid: 'provider' }] } },
|
||||
|
||||
Reference in New Issue
Block a user