Merge remote-tracking branch 'origin/master' into dev/4.11.x

# Conflicts:
#	src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py
#	src/langbot/pkg/api/http/service/bot.py
#	src/langbot/pkg/provider/runners/localagent.py
#	src/langbot/templates/metadata/pipeline/ai.yaml
#	tests/unit_tests/api/service/test_bot_service.py
#	tests/unit_tests/provider/runners/test_difysvapi_runner.py
#	tests/unit_tests/utils/test_safe_regex.py
#	web/src/app/infra/entities/adapter-categories.ts
#	web/src/app/wizard/page.tsx
#	web/src/i18n/locales/en-US.ts
#	web/src/i18n/locales/ja-JP.ts
#	web/src/i18n/locales/zh-Hans.ts
#	web/tests/e2e/plugin-page-auth.spec.ts
This commit is contained in:
Hyu
2026-08-31 17:17:47 +08:00
67 changed files with 3604 additions and 255 deletions
@@ -15,7 +15,10 @@ import {
FormMessage,
} from '@/components/ui/form';
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
import { normalizeDynamicFormValuesForSave } from '@/app/home/components/dynamic-form/DynamicFormSaveValues';
import {
normalizeDynamicFormFieldValue,
normalizeDynamicFormValuesForSave,
} from '@/app/home/components/dynamic-form/DynamicFormSaveValues';
import QrCodeLoginDialog, {
QrLoginPlatform,
} from '@/app/home/components/qrcode-login/QrCodeLoginDialog';
@@ -475,61 +478,6 @@ export default function DynamicFormComponent({
const previousInitialValues = useRef(initialValues);
const { t, i18n } = useTranslation();
// Normalize a form value according to its field type.
// This ensures legacy/malformed data (e.g. a plain string for
// model-fallback-selector) is coerced to the expected shape
// so that downstream components never crash.
const normalizeFieldValue = (
item: DynamicFormValueSpec,
value: unknown,
): unknown => {
if (
item.name === 'mcp-resources' ||
item.type === DynamicFormItemType.RESOURCES_SELECTOR ||
item.type === DynamicFormItemType.RICH_TOOLS_SELECTOR
) {
return Array.isArray(value) ? value : [];
}
if (item.type === 'model-fallback-selector') {
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
const obj = value as Record<string, unknown>;
return {
primary: typeof obj.primary === 'string' ? obj.primary : '',
fallbacks: Array.isArray(obj.fallbacks)
? (obj.fallbacks as unknown[]).filter(
(v): v is string => typeof v === 'string',
)
: [],
reasoning:
obj.reasoning != null &&
typeof obj.reasoning === 'object' &&
!Array.isArray(obj.reasoning)
? Object.fromEntries(
Object.entries(obj.reasoning).filter(
(entry): entry is [string, string] =>
typeof entry[1] === 'string',
),
)
: {},
};
}
// Legacy string format or any other unexpected type
return {
primary: typeof value === 'string' ? value : '',
fallbacks: [],
reasoning: {},
};
}
if (item.type === 'prompt-editor') {
if (Array.isArray(value)) {
return value;
}
// Default to a single empty system prompt entry
return [{ role: 'system', content: '' }];
}
return value;
};
// Filter out display-only fields (webhook-url/embed-code/qr-code-login types
// and `__system.*`-named fields) that should not participate in form state,
// validation, or value emission.
@@ -585,7 +533,7 @@ export default function DynamicFormComponent({
const rawValue = initialValues?.[item.name] ?? item.default;
return {
...acc,
[item.name]: normalizeFieldValue(item, rawValue),
[item.name]: normalizeDynamicFormFieldValue(item, rawValue),
};
}, {} as FormValues),
});
@@ -622,7 +570,10 @@ export default function DynamicFormComponent({
const mergedValues = editableValueSpecs.reduce(
(acc, item) => {
const rawValue = initialValues[item.name] ?? item.default;
acc[item.name] = normalizeFieldValue(item, rawValue) as object;
acc[item.name] = normalizeDynamicFormFieldValue(
item,
rawValue,
) as object;
return acc;
},
{} as Record<string, object>,
@@ -5,6 +5,80 @@ export type DynamicFormSaveValueSpec = Pick<
'default' | 'name' | 'type'
>;
const ARRAY_FIELD_TYPES = new Set([
'array[string]',
'array[file]',
'knowledge-base-multi-selector',
'resources-selector',
'rich-tools-selector',
'tools-selector',
]);
const STRING_FIELD_TYPES = new Set([
'string',
'text',
'select',
'llm-model-selector',
'embedding-model-selector',
'rerank-model-selector',
'knowledge-base-selector',
'bot-selector',
]);
/**
* Coerce empty dynamic-form defaults into controlled React values.
* Metadata from older adapters and runners can omit `default`; inputs must
* still receive a stable value from their first render.
*/
export function normalizeDynamicFormFieldValue(
spec: DynamicFormSaveValueSpec,
value: unknown,
): unknown {
if (spec.name === 'mcp-resources' || ARRAY_FIELD_TYPES.has(spec.type)) {
return Array.isArray(value) ? value : [];
}
if (spec.type === 'boolean') {
return typeof value === 'boolean' ? value : false;
}
if (STRING_FIELD_TYPES.has(spec.type)) {
return typeof value === 'string' ? value : '';
}
if (spec.type === 'model-fallback-selector') {
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
const objectValue = value as Record<string, unknown>;
return {
primary:
typeof objectValue.primary === 'string' ? objectValue.primary : '',
fallbacks: Array.isArray(objectValue.fallbacks)
? objectValue.fallbacks.filter(
(fallback): fallback is string => typeof fallback === 'string',
)
: [],
reasoning:
objectValue.reasoning != null &&
typeof objectValue.reasoning === 'object' &&
!Array.isArray(objectValue.reasoning)
? Object.fromEntries(
Object.entries(objectValue.reasoning).filter(
(entry): entry is [string, string] =>
typeof entry[1] === 'string',
),
)
: {},
};
}
return {
primary: typeof value === 'string' ? value : '',
fallbacks: [],
reasoning: {},
};
}
if (spec.type === 'prompt-editor') {
return Array.isArray(value) ? value : [{ role: 'system', content: '' }];
}
return value;
}
const reasoningLevels = new Set([
'disabled',
'enabled',
@@ -33,7 +33,7 @@ const getFormSchema = (t: (key: string) => string) =>
interface ProviderFormProps {
providerId?: string;
onFormSubmit: () => void;
onFormSubmit: (providerUuid: string) => void | Promise<void>;
onFormCancel: () => void;
}
@@ -171,14 +171,16 @@ export default function ProviderForm({
};
try {
let savedProviderUuid = providerId;
if (providerId) {
await httpClient.updateModelProvider(providerId, data);
toast.success(t('models.providerSaved'));
} else {
await httpClient.createModelProvider(data);
const response = await httpClient.createModelProvider(data);
savedProviderUuid = response.uuid;
toast.success(t('models.providerCreated'));
}
onFormSubmit();
await onFormSubmit(savedProviderUuid as string);
} catch (err) {
toast.error(t('models.providerSaveError') + (err as CustomApiError).msg);
}