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 };
}
@@ -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,
);
+7 -1
View File
@@ -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();
});
+118 -9
View File
@@ -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' }] } },