import {
DynamicFormItemType,
IDynamicFormItemSchema,
IFileConfig,
} from '@/app/infra/entities/form/dynamic';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { ControllerRenderProps } from 'react-hook-form';
import { Button } from '@/components/ui/button';
import { useEffect, useState } from 'react';
import { httpClient, systemInfo, userInfo } from '@/app/infra/http';
import {
LLMModel,
Bot,
KnowledgeBase,
EmbeddingModel,
RerankModel,
PluginTool,
} from '@/app/infra/entities/api';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Checkbox } from '@/components/ui/checkbox';
import {
Plus,
X,
Eye,
Wrench,
Trash2,
Sparkles,
Info,
Settings,
ChevronDown,
} from 'lucide-react';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import SettingsDialog, {
SettingsSection,
} from '@/app/home/components/settings-dialog/SettingsDialog';
function getPluginComponentIconURL(value?: string): string | null {
if (!value?.startsWith('plugin:')) {
return null;
}
const match = value.match(/^plugin:([^/]+)\/([^/]+)(?:\/|$)/);
if (!match) {
return null;
}
return httpClient.getPluginIconURL(match[1], match[2]);
}
function SelectOptionContent({
label,
value,
showDescription = false,
}: {
label: string;
value: string;
showDescription?: boolean;
}) {
const iconURL = getPluginComponentIconURL(value);
return (
{iconURL && (
)}
{label}
{showDescription && (
{value}
)}
);
}
export default function DynamicFormItemComponent({
config,
field,
onFileUploaded,
}: {
config: IDynamicFormItemSchema;
field: ControllerRenderProps;
onFileUploaded?: (fileKey: string) => void;
}) {
const [llmModels, setLlmModels] = useState([]);
const [embeddingModels, setEmbeddingModels] = useState([]);
const [rerankModels, setRerankModels] = useState([]);
const [knowledgeBases, setKnowledgeBases] = useState([]);
const [bots, setBots] = useState([]);
const [tools, setTools] = useState([]);
const [uploading, setUploading] = useState(false);
const [kbDialogOpen, setKbDialogOpen] = useState(false);
const [tempSelectedKBIds, setTempSelectedKBIds] = useState([]);
const [toolsDialogOpen, setToolsDialogOpen] = useState(false);
const [tempSelectedToolNames, setTempSelectedToolNames] = useState(
[],
);
const { t } = useTranslation();
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
const [settingsSection, setSettingsSection] =
useState('models');
const fetchLlmModels = () => {
httpClient
.getProviderLLMModels()
.then((resp) => {
setLlmModels(resp.models);
})
.catch((err) => {
toast.error(t('models.getModelListError') + err.msg);
});
};
const handleModelsDialogChange = (open: boolean) => {
setModelsDialogOpen(open);
if (!open) {
fetchLlmModels();
}
};
const handleFileUpload = async (file: File): Promise => {
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
if (file.size > MAX_FILE_SIZE) {
toast.error(t('plugins.fileUpload.tooLarge'));
return null;
}
try {
setUploading(true);
const response = await httpClient.uploadPluginConfigFile(file);
toast.success(t('plugins.fileUpload.success'));
// 通知父组件文件已上传
onFileUploaded?.(response.file_key);
return {
file_key: response.file_key,
mimetype: file.type,
};
} catch (error) {
toast.error(
t('plugins.fileUpload.failed') + ': ' + (error as Error).message,
);
return null;
} finally {
setUploading(false);
}
};
// Whether to show Space login CTA in model selectors
const showSpaceLoginCTA =
!systemInfo.disable_models_service && userInfo?.account_type !== 'space';
const handleSpaceLogin = () => {
try {
const token = localStorage.getItem('token');
if (!token) {
toast.error(t('common.error'));
return;
}
const currentOrigin = window.location.origin;
const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;
httpClient
.getSpaceAuthorizeUrl(redirectUri, token)
.then((response) => {
window.location.href = response.authorize_url;
})
.catch(() => {
toast.error(t('common.spaceLoginFailed'));
});
} catch {
toast.error(t('common.spaceLoginFailed'));
}
};
useEffect(() => {
if (config.type === DynamicFormItemType.LLM_MODEL_SELECTOR) {
fetchLlmModels();
}
}, [config.type]);
useEffect(() => {
if (config.type === DynamicFormItemType.EMBEDDING_MODEL_SELECTOR) {
httpClient
.getProviderEmbeddingModels()
.then((resp) => {
setEmbeddingModels(resp.models);
})
.catch((err) => {
toast.error(t('embedding.getModelListError') + err.msg);
});
}
}, [config.type]);
useEffect(() => {
if (config.type === DynamicFormItemType.RERANK_MODEL_SELECTOR) {
httpClient
.getProviderRerankModels()
.then((resp) => {
setRerankModels(resp.models);
})
.catch((err) => {
toast.error('Failed to load rerank models: ' + err.msg);
});
}
}, [config.type]);
useEffect(() => {
if (config.type === DynamicFormItemType.MODEL_FALLBACK_SELECTOR) {
fetchLlmModels();
}
}, [config.type]);
useEffect(() => {
if (
config.type === DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR ||
config.type === DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR
) {
httpClient
.getKnowledgeBases()
.then((resp) => {
setKnowledgeBases(resp.bases);
})
.catch((err) => {
toast.error(t('knowledge.getKnowledgeBaseListError') + err.msg);
});
}
}, [config.type]);
useEffect(() => {
if (config.type === DynamicFormItemType.BOT_SELECTOR) {
httpClient
.getBots()
.then((resp) => {
setBots(resp.bots);
})
.catch((err) => {
toast.error(t('bots.getBotListError') + err.msg);
});
}
}, [config.type]);
useEffect(() => {
if (config.type === DynamicFormItemType.TOOLS_SELECTOR) {
httpClient
.getTools()
.then((resp) => {
setTools(resp.tools);
})
.catch((err) => {
toast.error(
t('tools.getToolListError', 'Failed to get tools: ') + err.msg,
);
});
}
}, [config.type]);
switch (config.type) {
case DynamicFormItemType.INT:
case DynamicFormItemType.FLOAT:
case DynamicFormItemType.NUMBER:
return (
field.onChange(Number(e.target.value))}
/>
);
case DynamicFormItemType.STRING:
if (config.options && config.options.length > 0) {
return (
);
}
return (
);
case DynamicFormItemType.TEXT:
return (
);
case DynamicFormItemType.JSON:
return (
);
case DynamicFormItemType.BOOLEAN:
return (
);
case DynamicFormItemType.STRING_ARRAY:
return (
{field.value.map((item: string, index: number) => (
{
const newValue = [...field.value];
newValue[index] = e.target.value;
field.onChange(newValue);
}}
/>
{
const newValue = field.value.filter(
(_: string, i: number) => i !== index,
);
field.onChange(newValue);
}}
>
))}
{
field.onChange([...field.value, '']);
}}
>
{t('common.add')}
);
case DynamicFormItemType.SELECT:
const selectedOption = config.options?.find(
(option) => option.name === field.value,
);
return (
{selectedOption ? (
) : (
)}
{config.options?.map((option) => (
))}
);
case DynamicFormItemType.LLM_MODEL_SELECTOR:
// Separate space models from regular models
const spaceModels = llmModels.filter(
(m) => m.provider?.requester === 'space-chat-completions',
);
const regularModels = llmModels.filter(
(m) => m.provider?.requester !== 'space-chat-completions',
);
// Group regular models by provider
const groupedModels = regularModels.reduce(
(acc, model) => {
const providerName =
model.provider?.name || model.provider?.requester || 'Unknown';
if (!acc[providerName]) acc[providerName] = [];
acc[providerName].push(model);
return acc;
},
{} as Record,
);
// Group space models by provider (for logged-in users)
const groupedSpaceModels = spaceModels.reduce(
(acc, model) => {
const providerName =
model.provider?.name || model.provider?.requester || 'Unknown';
if (!acc[providerName]) acc[providerName] = [];
acc[providerName].push(model);
return acc;
},
{} as Record,
);
// Hardcoded preview model names for CTA when no space models are synced
const previewModelNames = [
'gpt-4o',
'claude-sonnet-4-20250514',
'deepseek-chat',
'gemini-2.5-flash',
'qwen-plus',
];
return (
{Object.entries(groupedModels).map(([providerName, models]) => (
{providerName}
{models.map((model) => (
{model.name}
{model.abilities?.includes('vision') && (
)}
{model.abilities?.includes('func_call') && (
)}
))}
))}
{/* Space models section */}
{showSpaceLoginCTA ? (
{t('models.langbotModels')}
e.preventDefault()}
>
{t('models.spaceTrialTooltip')}
e.preventDefault()}
>
{/* Preview models (first 3 visible, rest blurred) */}
{(spaceModels.length > 0
? spaceModels.map((m) => m.name)
: previewModelNames
)
.slice(0, 3)
.map((name) => (
{name}
))}
{/* Blurred remaining models with login overlay */}
{(spaceModels.length > 0
? spaceModels.map((m) => m.name)
: previewModelNames
)
.slice(3)
.map((name) => (
{name}
))}
{/* Login overlay */}
{
e.preventDefault();
e.stopPropagation();
handleSpaceLogin();
}}
>
{t('models.unlockModels')}
) : !systemInfo.disable_models_service ? (
// User is logged into Space — show space models normally
Object.entries(groupedSpaceModels).map(
([providerName, models]) => (
{providerName}
{models.map((model) => (
{model.name}
{model.abilities?.includes('vision') && (
)}
{model.abilities?.includes('func_call') && (
)}
))}
),
)
) : null}
setModelsDialogOpen(true)}
>
{t('models.title')}
);
case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR:
// Group embedding models by provider
const groupedEmbeddingModels = embeddingModels.reduce(
(acc, model) => {
const providerName = model.provider?.name || 'Unknown';
if (!acc[providerName]) acc[providerName] = [];
acc[providerName].push(model);
return acc;
},
{} as Record,
);
return (
{Object.entries(groupedEmbeddingModels).map(
([providerName, models]) => (
{providerName}
{models.map((model) => (
{model.name}
))}
),
)}
);
case DynamicFormItemType.RERANK_MODEL_SELECTOR:
const groupedRerankModels = rerankModels.reduce(
(acc, model) => {
const providerName = model.provider?.name || 'Unknown';
if (!acc[providerName]) acc[providerName] = [];
acc[providerName].push(model);
return acc;
},
{} as Record,
);
return (
field.onChange(v === '__none__' ? '' : v)}
>
{t('common.none')}
{Object.entries(groupedRerankModels).map(
([providerName, models]) => (
{providerName}
{models.map((model) => (
{model.name}
))}
),
)}
);
case DynamicFormItemType.MODEL_FALLBACK_SELECTOR: {
// Separate space models from regular models
const fbSpaceModels = llmModels.filter(
(m) => m.provider?.requester === 'space-chat-completions',
);
const fbRegularModels = llmModels.filter(
(m) => m.provider?.requester !== 'space-chat-completions',
);
// Group regular models by provider
const groupedModelsForFallback = fbRegularModels.reduce(
(acc, model) => {
const providerName =
model.provider?.name || model.provider?.requester || 'Unknown';
if (!acc[providerName]) acc[providerName] = [];
acc[providerName].push(model);
return acc;
},
{} as Record,
);
// Group space models by provider (for logged-in users)
const fbGroupedSpaceModels = fbSpaceModels.reduce(
(acc, model) => {
const providerName =
model.provider?.name || model.provider?.requester || 'Unknown';
if (!acc[providerName]) acc[providerName] = [];
acc[providerName].push(model);
return acc;
},
{} as Record,
);
// Hardcoded preview model names for CTA
const fbPreviewModelNames = [
'gpt-4o',
'claude-sonnet-4-20250514',
'deepseek-chat',
'gemini-2.5-flash',
'qwen-plus',
];
const rawModelValue = field.value;
const modelValue: { primary: string; fallbacks: string[] } =
rawModelValue != null &&
typeof rawModelValue === 'object' &&
!Array.isArray(rawModelValue)
? {
primary:
typeof (rawModelValue as Record).primary ===
'string'
? ((rawModelValue as Record)
.primary as string)
: '',
fallbacks: Array.isArray(
(rawModelValue as Record).fallbacks,
)
? (
(rawModelValue as Record)
.fallbacks as unknown[]
).filter((v): v is string => typeof v === 'string')
: [],
}
: {
primary: typeof rawModelValue === 'string' ? rawModelValue : '',
fallbacks: [],
};
const renderModelSelect = (
value: string,
onChange: (val: string) => void,
placeholder: string,
) => (
{Object.entries(groupedModelsForFallback).map(
([providerName, models]) => (
{providerName}
{models.map((model) => (
{model.name}
{model.abilities?.includes('vision') && (
)}
{model.abilities?.includes('func_call') && (
)}
))}
),
)}
{/* Space models section */}
{showSpaceLoginCTA ? (
{t('models.langbotModels')}
e.preventDefault()}
>
{t('models.spaceTrialTooltip')}
e.preventDefault()}
>
{/* Preview models (first 3 visible, rest blurred) */}
{(fbSpaceModels.length > 0
? fbSpaceModels.map((m) => m.name)
: fbPreviewModelNames
)
.slice(0, 3)
.map((name) => (
{name}
))}
{/* Blurred remaining models with login overlay */}
{(fbSpaceModels.length > 0
? fbSpaceModels.map((m) => m.name)
: fbPreviewModelNames
)
.slice(3)
.map((name) => (
{name}
))}
{/* Login overlay */}
{
e.preventDefault();
e.stopPropagation();
handleSpaceLogin();
}}
>
{t('models.unlockModels')}
) : !systemInfo.disable_models_service ? (
// User is logged into Space — show space models normally
Object.entries(fbGroupedSpaceModels).map(
([providerName, models]) => (
{providerName}
{models.map((model) => (
{model.name}
{model.abilities?.includes('vision') && (
)}
{model.abilities?.includes('func_call') && (
)}
))}
),
)
) : null}
);
const updateValue = (patch: Partial) => {
field.onChange({ ...modelValue, ...patch });
};
const addFallbackModel = () => {
updateValue({ fallbacks: [...modelValue.fallbacks, ''] });
};
const updateFallbackModel = (index: number, value: string) => {
const updated = [...modelValue.fallbacks];
updated[index] = value;
updateValue({ fallbacks: updated });
};
const removeFallbackModel = (index: number) => {
const updated = [...modelValue.fallbacks];
updated.splice(index, 1);
updateValue({ fallbacks: updated });
};
const moveFallbackModel = (index: number, direction: 'up' | 'down') => {
const updated = [...modelValue.fallbacks];
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= updated.length) return;
[updated[index], updated[newIndex]] = [
updated[newIndex],
updated[index],
];
updateValue({ fallbacks: updated });
};
return (
{/* Primary model selector */}
{t('models.fallback.primary')}
{renderModelSelect(
modelValue.primary,
(val) => updateValue({ primary: val }),
t('models.selectModel'),
)}
setModelsDialogOpen(true)}
>
{t('models.title')}
{/* Fallback models */}
{modelValue.fallbacks.length > 0 && (
{t('models.fallback.fallbackList')}
{modelValue.fallbacks.map((fbUuid: string, index: number) => (
{index + 1}.
{renderModelSelect(
fbUuid,
(val) => updateFallbackModel(index, val),
t('models.selectModel'),
)}
moveFallbackModel(index, 'up')}
disabled={index === 0}
>
↑
moveFallbackModel(index, 'down')}
disabled={index === modelValue.fallbacks.length - 1}
>
↓
removeFallbackModel(index)}
>
))}
)}
{/* Add fallback button */}
{t('models.fallback.addFallback')}
);
}
case DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR:
// Group KBs by Knowledge Engine name
const kbsByEngine = knowledgeBases.reduce(
(acc, kb) => {
const engineName = kb.knowledge_engine?.name
? extractI18nObject(kb.knowledge_engine.name)
: t('knowledge.unknownEngine');
if (!acc[engineName]) {
acc[engineName] = [];
}
acc[engineName].push(kb);
return acc;
},
{} as Record,
);
return (
{field.value && field.value !== '__none__' ? (
(() => {
const selectedKb = knowledgeBases.find(
(kb) => kb.uuid === field.value,
);
return (
{selectedKb?.emoji && (
{selectedKb.emoji}
)}
{selectedKb?.name ?? field.value}
);
})()
) : (
)}
{t('knowledge.empty')}
{Object.entries(kbsByEngine).map(([engineName, kbs]) => (
{engineName}
{kbs.map((base) => (
{base.emoji && (
{base.emoji}
)}
{base.name}
))}
))}
);
case DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR:
// Group KBs by Knowledge Engine name for multi-selector
const multiKbsByEngine = knowledgeBases.reduce(
(acc, kb) => {
const engineName = kb.knowledge_engine?.name
? extractI18nObject(kb.knowledge_engine.name)
: t('knowledge.unknownEngine');
if (!acc[engineName]) {
acc[engineName] = [];
}
acc[engineName].push(kb);
return acc;
},
{} as Record,
);
return (
<>
{field.value && field.value.length > 0 ? (
{field.value.map((kbId: string) => {
const currentKb = knowledgeBases.find(
(base) => base.uuid === kbId,
);
if (!currentKb) return null;
return (
{currentKb.emoji && (
{currentKb.emoji}
)}
{currentKb.name}
{currentKb.knowledge_engine?.name && (
{extractI18nObject(
currentKb.knowledge_engine.name,
)}
)}
{currentKb.description && (
{currentKb.description}
)}
{
const newValue = field.value.filter(
(id: string) => id !== kbId,
);
field.onChange(newValue);
}}
>
);
})}
) : (
{t('knowledge.noKnowledgeBaseSelected')}
)}
{
setTempSelectedKBIds(field.value || []);
setKbDialogOpen(true);
}}
variant="outline"
className="w-full"
>
{t('knowledge.addKnowledgeBase')}
{/* Knowledge Base Selection Dialog */}
{t('knowledge.selectKnowledgeBases')}
{Object.entries(multiKbsByEngine).map(([engineName, kbs]) => (
{engineName}
{kbs.map((base) => {
const isSelected = tempSelectedKBIds.includes(
base.uuid ?? '',
);
return (
{
const kbId = base.uuid ?? '';
setTempSelectedKBIds((prev) =>
prev.includes(kbId)
? prev.filter((id) => id !== kbId)
: [...prev, kbId],
);
}}
>
{base.emoji && (
{base.emoji}
)}
{base.name}
{base.description && (
{base.description}
)}
);
})}
))}
setKbDialogOpen(false)}
>
{t('common.cancel')}
{
field.onChange(tempSelectedKBIds);
setKbDialogOpen(false);
}}
>
{t('common.confirm')}
>
);
case DynamicFormItemType.BOT_SELECTOR:
return (
{bots.map((bot) => (
{bot.name}
))}
);
case DynamicFormItemType.TOOLS_SELECTOR:
return (
<>
{field.value && field.value.length > 0 ? (
{field.value.map((toolName: string) => {
const currentTool = tools.find(
(tool) => tool.name === toolName,
);
return (
{toolName}
{currentTool?.human_desc && (
{currentTool.human_desc}
)}
{
const newValue = field.value.filter(
(name: string) => name !== toolName,
);
field.onChange(newValue);
}}
>
);
})}
) : (
{t('tools.noToolSelected', 'No tools selected')}
)}
{
setTempSelectedToolNames(field.value || []);
setToolsDialogOpen(true);
}}
variant="outline"
className="w-full"
>
{t('tools.addTool', 'Add Tool')}
{t('tools.selectTools', 'Select Tools')}
{tools.map((tool) => {
const isSelected = tempSelectedToolNames.includes(tool.name);
return (
{
setTempSelectedToolNames((prev) =>
prev.includes(tool.name)
? prev.filter((name) => name !== tool.name)
: [...prev, tool.name],
);
}}
>
{tool.name}
{tool.human_desc && (
{tool.human_desc}
)}
);
})}
{tools.length === 0 && (
{t('tools.noToolsAvailable', 'No tools available')}
)}
setToolsDialogOpen(false)}
>
{t('common.cancel')}
{
field.onChange(tempSelectedToolNames);
setToolsDialogOpen(false);
}}
>
{t('common.confirm')}
>
);
case DynamicFormItemType.PROMPT_EDITOR: {
// Guard: field.value may be undefined when the form resets or
// initialValues haven't propagated yet. Fall back to a default
// single system-prompt entry to prevent the .map() crash.
const promptItems: { role: string; content: string }[] = Array.isArray(
field.value,
)
? field.value
: [{ role: 'system', content: '' }];
return (
{promptItems.map(
(item: { role: string; content: string }, index: number) => (
{/* 角色选择 */}
{index === 0 ? (
system
) : (
{
const newValue = [...(field.value ?? promptItems)];
newValue[index] = { ...newValue[index], role: value };
field.onChange(newValue);
}}
>
user
assistant
)}
{/* 内容输入 */}
),
)}
{
field.onChange([
...(field.value ?? promptItems),
{ role: 'user', content: '' },
]);
}}
>
{t('common.addRound')}
);
}
case DynamicFormItemType.FILE:
return (
{field.value && (field.value as IFileConfig).file_key ? (
{(field.value as IFileConfig).file_key}
{(field.value as IFileConfig).mimetype}
{
e.preventDefault();
e.stopPropagation();
field.onChange(null);
}}
title={t('common.delete')}
>
) : (
)}
);
case DynamicFormItemType.FILE_ARRAY:
return (
{(field.value as IFileConfig[])?.map(
(fileConfig: IFileConfig, index: number) => (
{fileConfig.file_key}
{fileConfig.mimetype}
{
e.preventDefault();
e.stopPropagation();
const newValue = (field.value as IFileConfig[]).filter(
(_: IFileConfig, i: number) => i !== index,
);
field.onChange(newValue);
}}
title={t('common.delete')}
>
),
)}
);
default:
return ;
}
}