mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-16 10:46:03 +00:00
The model-selector in dynamic forms (pipeline / knowledge base settings) still opened the old standalone ModelsDialog. Point it at the unified SettingsDialog (section pinned to models) and delete the now-unused ModelsDialog wrapper so only the new dialog remains.
1630 lines
59 KiB
TypeScript
1630 lines
59 KiB
TypeScript
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';
|
|
|
|
export default function DynamicFormItemComponent({
|
|
config,
|
|
field,
|
|
onFileUploaded,
|
|
}: {
|
|
config: IDynamicFormItemSchema;
|
|
field: ControllerRenderProps<any, any>;
|
|
onFileUploaded?: (fileKey: string) => void;
|
|
}) {
|
|
const [llmModels, setLlmModels] = useState<LLMModel[]>([]);
|
|
const [embeddingModels, setEmbeddingModels] = useState<EmbeddingModel[]>([]);
|
|
const [rerankModels, setRerankModels] = useState<RerankModel[]>([]);
|
|
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
|
const [bots, setBots] = useState<Bot[]>([]);
|
|
const [tools, setTools] = useState<PluginTool[]>([]);
|
|
const [uploading, setUploading] = useState<boolean>(false);
|
|
const [kbDialogOpen, setKbDialogOpen] = useState(false);
|
|
const [tempSelectedKBIds, setTempSelectedKBIds] = useState<string[]>([]);
|
|
const [toolsDialogOpen, setToolsDialogOpen] = useState(false);
|
|
const [tempSelectedToolNames, setTempSelectedToolNames] = useState<string[]>(
|
|
[],
|
|
);
|
|
const { t } = useTranslation();
|
|
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
|
|
const [settingsSection, setSettingsSection] =
|
|
useState<SettingsSection>('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<IFileConfig | null> => {
|
|
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:
|
|
return (
|
|
<Input
|
|
type="number"
|
|
className="w-full max-w-xs"
|
|
{...field}
|
|
onChange={(e) => field.onChange(Number(e.target.value))}
|
|
/>
|
|
);
|
|
|
|
case DynamicFormItemType.STRING:
|
|
if (config.options && config.options.length > 0) {
|
|
return (
|
|
<div className="flex w-full max-w-md min-w-0 items-center gap-1.5">
|
|
<Input className="min-w-0 flex-1" {...field} />
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
type="button"
|
|
className="h-9 w-9 shrink-0 text-muted-foreground"
|
|
>
|
|
<ChevronDown className="size-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
{config.options.map((option) => (
|
|
<DropdownMenuItem
|
|
key={option.name}
|
|
onClick={() => field.onChange(option.name)}
|
|
>
|
|
<div className="flex flex-col gap-0.5">
|
|
<span>{extractI18nObject(option.label)}</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{option.name}
|
|
</span>
|
|
</div>
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
);
|
|
}
|
|
return <Input className="w-full max-w-md" {...field} />;
|
|
|
|
case DynamicFormItemType.TEXT:
|
|
return (
|
|
<Textarea
|
|
{...field}
|
|
className="min-h-[120px] w-full max-w-full resize-y overflow-x-hidden break-all"
|
|
/>
|
|
);
|
|
|
|
case DynamicFormItemType.BOOLEAN:
|
|
return <Switch checked={field.value} onCheckedChange={field.onChange} />;
|
|
|
|
case DynamicFormItemType.STRING_ARRAY:
|
|
return (
|
|
<div className="w-full max-w-md min-w-0 space-y-2">
|
|
{field.value.map((item: string, index: number) => (
|
|
<div key={index} className="flex min-w-0 items-center gap-1.5">
|
|
<Input
|
|
className="min-w-0 flex-1"
|
|
value={item}
|
|
onChange={(e) => {
|
|
const newValue = [...field.value];
|
|
newValue[index] = e.target.value;
|
|
field.onChange(newValue);
|
|
}}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="shrink-0 text-muted-foreground hover:text-destructive"
|
|
onClick={() => {
|
|
const newValue = field.value.filter(
|
|
(_: string, i: number) => i !== index,
|
|
);
|
|
field.onChange(newValue);
|
|
}}
|
|
>
|
|
<Trash2 className="size-4" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="w-full border-dashed text-muted-foreground hover:text-foreground"
|
|
onClick={() => {
|
|
field.onChange([...field.value, '']);
|
|
}}
|
|
>
|
|
<Plus className="size-4 mr-1.5" />
|
|
{t('common.add')}
|
|
</Button>
|
|
</div>
|
|
);
|
|
|
|
case DynamicFormItemType.SELECT:
|
|
return (
|
|
<Select value={field.value} onValueChange={field.onChange}>
|
|
<SelectTrigger className="w-full max-w-md bg-[#ffffff] dark:bg-[#2a2a2e]">
|
|
<SelectValue placeholder={t('common.select')} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
{config.options?.map((option) => (
|
|
<SelectItem
|
|
key={option.name}
|
|
value={option.name}
|
|
description={option.name}
|
|
>
|
|
{extractI18nObject(option.label)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
);
|
|
|
|
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<string, LLMModel[]>,
|
|
);
|
|
|
|
// 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<string, LLMModel[]>,
|
|
);
|
|
|
|
// 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 (
|
|
<div className="flex w-full max-w-md min-w-0 items-center gap-1.5">
|
|
<div className="min-w-0 flex-1">
|
|
<Select value={field.value} onValueChange={field.onChange}>
|
|
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
|
|
<SelectValue placeholder={t('models.selectModel')} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Object.entries(groupedModels).map(([providerName, models]) => (
|
|
<SelectGroup key={providerName}>
|
|
<SelectLabel>{providerName}</SelectLabel>
|
|
{models.map((model) => (
|
|
<SelectItem key={model.uuid} value={model.uuid}>
|
|
<span className="inline-flex items-center gap-1">
|
|
{model.name}
|
|
{model.abilities?.includes('vision') && (
|
|
<Eye className="h-3 w-3 text-muted-foreground" />
|
|
)}
|
|
{model.abilities?.includes('func_call') && (
|
|
<Wrench className="h-3 w-3 text-muted-foreground" />
|
|
)}
|
|
</span>
|
|
</SelectItem>
|
|
))}
|
|
</SelectGroup>
|
|
))}
|
|
{/* Space models section */}
|
|
{showSpaceLoginCTA ? (
|
|
<SelectGroup>
|
|
<SelectLabel>
|
|
<span className="inline-flex items-center gap-1.5">
|
|
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
|
|
{t('models.langbotModels')}
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
asChild
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
>
|
|
<Info className="h-3 w-3 text-muted-foreground cursor-help" />
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" className="max-w-[240px]">
|
|
{t('models.spaceTrialTooltip')}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</span>
|
|
</SelectLabel>
|
|
<div
|
|
className="relative"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
>
|
|
{/* Preview models (first 3 visible, rest blurred) */}
|
|
{(spaceModels.length > 0
|
|
? spaceModels.map((m) => m.name)
|
|
: previewModelNames
|
|
)
|
|
.slice(0, 3)
|
|
.map((name) => (
|
|
<div
|
|
key={name}
|
|
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm text-muted-foreground/60"
|
|
>
|
|
{name}
|
|
</div>
|
|
))}
|
|
{/* Blurred remaining models with login overlay */}
|
|
<div className="relative">
|
|
<div
|
|
className="select-none overflow-hidden"
|
|
style={{ maxHeight: '3rem' }}
|
|
>
|
|
{(spaceModels.length > 0
|
|
? spaceModels.map((m) => m.name)
|
|
: previewModelNames
|
|
)
|
|
.slice(3)
|
|
.map((name) => (
|
|
<div
|
|
key={name}
|
|
className="flex w-full items-center py-1.5 pl-8 pr-2 text-sm text-muted-foreground/40 blur-[2px]"
|
|
>
|
|
{name}
|
|
</div>
|
|
))}
|
|
</div>
|
|
{/* Login overlay */}
|
|
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-b from-transparent to-background/80">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 text-xs px-3 gap-1.5 shadow-sm"
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleSpaceLogin();
|
|
}}
|
|
>
|
|
<Sparkles className="h-3 w-3" />
|
|
{t('models.unlockModels')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</SelectGroup>
|
|
) : !systemInfo.disable_models_service ? (
|
|
// User is logged into Space — show space models normally
|
|
Object.entries(groupedSpaceModels).map(
|
|
([providerName, models]) => (
|
|
<SelectGroup key={providerName}>
|
|
<SelectLabel>
|
|
<span className="inline-flex items-center gap-1.5">
|
|
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
|
|
{providerName}
|
|
</span>
|
|
</SelectLabel>
|
|
{models.map((model) => (
|
|
<SelectItem key={model.uuid} value={model.uuid}>
|
|
<span className="inline-flex items-center gap-1">
|
|
{model.name}
|
|
{model.abilities?.includes('vision') && (
|
|
<Eye className="h-3 w-3 text-muted-foreground" />
|
|
)}
|
|
{model.abilities?.includes('func_call') && (
|
|
<Wrench className="h-3 w-3 text-muted-foreground" />
|
|
)}
|
|
</span>
|
|
</SelectItem>
|
|
))}
|
|
</SelectGroup>
|
|
),
|
|
)
|
|
) : null}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-9 w-9 shrink-0"
|
|
onClick={() => setModelsDialogOpen(true)}
|
|
>
|
|
<Settings className="h-4 w-4 text-muted-foreground" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="right">{t('models.title')}</TooltipContent>
|
|
</Tooltip>
|
|
<SettingsDialog
|
|
open={modelsDialogOpen}
|
|
onOpenChange={handleModelsDialogChange}
|
|
section={settingsSection}
|
|
onSectionChange={setSettingsSection}
|
|
/>
|
|
</div>
|
|
);
|
|
|
|
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<string, EmbeddingModel[]>,
|
|
);
|
|
|
|
return (
|
|
<div className="w-full max-w-md min-w-0">
|
|
<Select value={field.value} onValueChange={field.onChange}>
|
|
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
|
|
<SelectValue placeholder={t('knowledge.selectEmbeddingModel')} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Object.entries(groupedEmbeddingModels).map(
|
|
([providerName, models]) => (
|
|
<SelectGroup key={providerName}>
|
|
<SelectLabel>{providerName}</SelectLabel>
|
|
{models.map((model) => (
|
|
<SelectItem key={model.uuid} value={model.uuid}>
|
|
{model.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectGroup>
|
|
),
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
);
|
|
|
|
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<string, RerankModel[]>,
|
|
);
|
|
|
|
return (
|
|
<div className="w-full max-w-md min-w-0">
|
|
<Select
|
|
value={field.value || '__none__'}
|
|
onValueChange={(v) => field.onChange(v === '__none__' ? '' : v)}
|
|
>
|
|
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
|
|
<SelectValue placeholder={t('models.rerank')} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__">{t('common.none')}</SelectItem>
|
|
{Object.entries(groupedRerankModels).map(
|
|
([providerName, models]) => (
|
|
<SelectGroup key={providerName}>
|
|
<SelectLabel>{providerName}</SelectLabel>
|
|
{models.map((model) => (
|
|
<SelectItem key={model.uuid} value={model.uuid}>
|
|
{model.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectGroup>
|
|
),
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
);
|
|
|
|
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<string, LLMModel[]>,
|
|
);
|
|
|
|
// 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<string, LLMModel[]>,
|
|
);
|
|
|
|
// 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<string, unknown>).primary ===
|
|
'string'
|
|
? ((rawModelValue as Record<string, unknown>)
|
|
.primary as string)
|
|
: '',
|
|
fallbacks: Array.isArray(
|
|
(rawModelValue as Record<string, unknown>).fallbacks,
|
|
)
|
|
? (
|
|
(rawModelValue as Record<string, unknown>)
|
|
.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,
|
|
) => (
|
|
<Select value={value} onValueChange={onChange}>
|
|
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
|
|
<SelectValue placeholder={placeholder} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Object.entries(groupedModelsForFallback).map(
|
|
([providerName, models]) => (
|
|
<SelectGroup key={providerName}>
|
|
<SelectLabel>{providerName}</SelectLabel>
|
|
{models.map((model) => (
|
|
<SelectItem key={model.uuid} value={model.uuid}>
|
|
<span className="inline-flex items-center gap-1">
|
|
{model.name}
|
|
{model.abilities?.includes('vision') && (
|
|
<Eye className="h-3 w-3 text-muted-foreground" />
|
|
)}
|
|
{model.abilities?.includes('func_call') && (
|
|
<Wrench className="h-3 w-3 text-muted-foreground" />
|
|
)}
|
|
</span>
|
|
</SelectItem>
|
|
))}
|
|
</SelectGroup>
|
|
),
|
|
)}
|
|
{/* Space models section */}
|
|
{showSpaceLoginCTA ? (
|
|
<SelectGroup>
|
|
<SelectLabel>
|
|
<span className="inline-flex items-center gap-1.5">
|
|
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
|
|
{t('models.langbotModels')}
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
asChild
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
>
|
|
<Info className="h-3 w-3 text-muted-foreground cursor-help" />
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" className="max-w-[240px]">
|
|
{t('models.spaceTrialTooltip')}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</span>
|
|
</SelectLabel>
|
|
<div
|
|
className="relative"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
>
|
|
{/* Preview models (first 3 visible, rest blurred) */}
|
|
{(fbSpaceModels.length > 0
|
|
? fbSpaceModels.map((m) => m.name)
|
|
: fbPreviewModelNames
|
|
)
|
|
.slice(0, 3)
|
|
.map((name) => (
|
|
<div
|
|
key={name}
|
|
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm text-muted-foreground/60"
|
|
>
|
|
{name}
|
|
</div>
|
|
))}
|
|
{/* Blurred remaining models with login overlay */}
|
|
<div className="relative">
|
|
<div
|
|
className="select-none overflow-hidden"
|
|
style={{ maxHeight: '3rem' }}
|
|
>
|
|
{(fbSpaceModels.length > 0
|
|
? fbSpaceModels.map((m) => m.name)
|
|
: fbPreviewModelNames
|
|
)
|
|
.slice(3)
|
|
.map((name) => (
|
|
<div
|
|
key={name}
|
|
className="flex w-full items-center py-1.5 pl-8 pr-2 text-sm text-muted-foreground/40 blur-[2px]"
|
|
>
|
|
{name}
|
|
</div>
|
|
))}
|
|
</div>
|
|
{/* Login overlay */}
|
|
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-b from-transparent to-background/80">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 text-xs px-3 gap-1.5 shadow-sm"
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleSpaceLogin();
|
|
}}
|
|
>
|
|
<Sparkles className="h-3 w-3" />
|
|
{t('models.unlockModels')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</SelectGroup>
|
|
) : !systemInfo.disable_models_service ? (
|
|
// User is logged into Space — show space models normally
|
|
Object.entries(fbGroupedSpaceModels).map(
|
|
([providerName, models]) => (
|
|
<SelectGroup key={providerName}>
|
|
<SelectLabel>
|
|
<span className="inline-flex items-center gap-1.5">
|
|
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
|
|
{providerName}
|
|
</span>
|
|
</SelectLabel>
|
|
{models.map((model) => (
|
|
<SelectItem key={model.uuid} value={model.uuid}>
|
|
<span className="inline-flex items-center gap-1">
|
|
{model.name}
|
|
{model.abilities?.includes('vision') && (
|
|
<Eye className="h-3 w-3 text-muted-foreground" />
|
|
)}
|
|
{model.abilities?.includes('func_call') && (
|
|
<Wrench className="h-3 w-3 text-muted-foreground" />
|
|
)}
|
|
</span>
|
|
</SelectItem>
|
|
))}
|
|
</SelectGroup>
|
|
),
|
|
)
|
|
) : null}
|
|
</SelectContent>
|
|
</Select>
|
|
);
|
|
|
|
const updateValue = (patch: Partial<typeof modelValue>) => {
|
|
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 (
|
|
<div className="w-full min-w-0 space-y-3">
|
|
{/* Primary model selector */}
|
|
<div>
|
|
<p className="text-xs text-muted-foreground mb-1">
|
|
{t('models.fallback.primary')}
|
|
</p>
|
|
<div className="flex min-w-0 items-center gap-1.5">
|
|
<div className="min-w-0 flex-1">
|
|
{renderModelSelect(
|
|
modelValue.primary,
|
|
(val) => updateValue({ primary: val }),
|
|
t('models.selectModel'),
|
|
)}
|
|
</div>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-9 w-9 shrink-0"
|
|
onClick={() => setModelsDialogOpen(true)}
|
|
>
|
|
<Settings className="h-4 w-4 text-muted-foreground" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="right">
|
|
{t('models.title')}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
<SettingsDialog
|
|
open={modelsDialogOpen}
|
|
onOpenChange={handleModelsDialogChange}
|
|
section={settingsSection}
|
|
onSectionChange={setSettingsSection}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Fallback models */}
|
|
{modelValue.fallbacks.length > 0 && (
|
|
<div className="min-w-0 space-y-2">
|
|
<p className="text-xs text-muted-foreground">
|
|
{t('models.fallback.fallbackList')}
|
|
</p>
|
|
{modelValue.fallbacks.map((fbUuid: string, index: number) => (
|
|
<div key={index} className="flex min-w-0 items-center gap-2">
|
|
<span className="text-xs text-muted-foreground w-4 shrink-0">
|
|
{index + 1}.
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
{renderModelSelect(
|
|
fbUuid,
|
|
(val) => updateFallbackModel(index, val),
|
|
t('models.selectModel'),
|
|
)}
|
|
</div>
|
|
<div className="flex gap-1 shrink-0">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 w-8 p-0"
|
|
onClick={() => moveFallbackModel(index, 'up')}
|
|
disabled={index === 0}
|
|
>
|
|
↑
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 w-8 p-0"
|
|
onClick={() => moveFallbackModel(index, 'down')}
|
|
disabled={index === modelValue.fallbacks.length - 1}
|
|
>
|
|
↓
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
|
onClick={() => removeFallbackModel(index)}
|
|
>
|
|
<Trash2 className="size-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Add fallback button */}
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-full border-dashed text-muted-foreground hover:text-foreground"
|
|
onClick={addFallbackModel}
|
|
>
|
|
<Plus className="size-4 mr-1.5" />
|
|
{t('models.fallback.addFallback')}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<string, typeof knowledgeBases>,
|
|
);
|
|
|
|
return (
|
|
<Select value={field.value} onValueChange={field.onChange}>
|
|
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
|
|
{field.value && field.value !== '__none__' ? (
|
|
(() => {
|
|
const selectedKb = knowledgeBases.find(
|
|
(kb) => kb.uuid === field.value,
|
|
);
|
|
return (
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
{selectedKb?.emoji && (
|
|
<span className="text-sm shrink-0">
|
|
{selectedKb.emoji}
|
|
</span>
|
|
)}
|
|
<span className="truncate">
|
|
{selectedKb?.name ?? field.value}
|
|
</span>
|
|
</div>
|
|
);
|
|
})()
|
|
) : (
|
|
<SelectValue placeholder={t('knowledge.selectKnowledgeBase')} />
|
|
)}
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
<SelectItem value="__none__">{t('knowledge.empty')}</SelectItem>
|
|
</SelectGroup>
|
|
|
|
{Object.entries(kbsByEngine).map(([engineName, kbs]) => (
|
|
<SelectGroup key={engineName}>
|
|
<SelectLabel>{engineName}</SelectLabel>
|
|
{kbs.map((base) => (
|
|
<SelectItem key={base.uuid} value={base.uuid ?? ''}>
|
|
<div className="flex items-center gap-2">
|
|
{base.emoji && (
|
|
<span className="text-sm shrink-0">{base.emoji}</span>
|
|
)}
|
|
<span>{base.name}</span>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectGroup>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
);
|
|
|
|
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<string, typeof knowledgeBases>,
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<div className="min-w-0 space-y-2">
|
|
{field.value && field.value.length > 0 ? (
|
|
<div className="min-w-0 space-y-2">
|
|
{field.value.map((kbId: string) => {
|
|
const currentKb = knowledgeBases.find(
|
|
(base) => base.uuid === kbId,
|
|
);
|
|
if (!currentKb) return null;
|
|
|
|
return (
|
|
<div
|
|
key={kbId}
|
|
className="flex min-w-0 items-center justify-between rounded-lg border p-3 hover:bg-accent"
|
|
>
|
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex min-w-0 items-center gap-2 font-medium">
|
|
{currentKb.emoji && (
|
|
<span className="text-sm shrink-0">
|
|
{currentKb.emoji}
|
|
</span>
|
|
)}
|
|
<span className="truncate">{currentKb.name}</span>
|
|
{currentKb.knowledge_engine?.name && (
|
|
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300">
|
|
{extractI18nObject(
|
|
currentKb.knowledge_engine.name,
|
|
)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{currentKb.description && (
|
|
<div className="text-sm break-words text-muted-foreground">
|
|
{currentKb.description}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => {
|
|
const newValue = field.value.filter(
|
|
(id: string) => id !== kbId,
|
|
);
|
|
field.onChange(newValue);
|
|
}}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border">
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('knowledge.noKnowledgeBaseSelected')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Button
|
|
type="button"
|
|
onClick={() => {
|
|
setTempSelectedKBIds(field.value || []);
|
|
setKbDialogOpen(true);
|
|
}}
|
|
variant="outline"
|
|
className="w-full"
|
|
>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
{t('knowledge.addKnowledgeBase')}
|
|
</Button>
|
|
|
|
{/* Knowledge Base Selection Dialog */}
|
|
<Dialog open={kbDialogOpen} onOpenChange={setKbDialogOpen}>
|
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
|
<DialogHeader>
|
|
<DialogTitle>{t('knowledge.selectKnowledgeBases')}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="flex-1 overflow-y-auto space-y-4 pr-2">
|
|
{Object.entries(multiKbsByEngine).map(([engineName, kbs]) => (
|
|
<div key={engineName} className="space-y-2">
|
|
<div className="text-sm font-semibold text-muted-foreground px-2">
|
|
{engineName}
|
|
</div>
|
|
{kbs.map((base) => {
|
|
const isSelected = tempSelectedKBIds.includes(
|
|
base.uuid ?? '',
|
|
);
|
|
return (
|
|
<div
|
|
key={base.uuid}
|
|
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
|
|
onClick={() => {
|
|
const kbId = base.uuid ?? '';
|
|
setTempSelectedKBIds((prev) =>
|
|
prev.includes(kbId)
|
|
? prev.filter((id) => id !== kbId)
|
|
: [...prev, kbId],
|
|
);
|
|
}}
|
|
>
|
|
<Checkbox
|
|
checked={isSelected}
|
|
aria-label={`Select ${base.name}`}
|
|
/>
|
|
<div className="flex-1">
|
|
<div className="font-medium flex items-center gap-2">
|
|
{base.emoji && (
|
|
<span className="text-sm shrink-0">
|
|
{base.emoji}
|
|
</span>
|
|
)}
|
|
{base.name}
|
|
</div>
|
|
{base.description && (
|
|
<div className="text-sm text-muted-foreground">
|
|
{base.description}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
))}
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setKbDialogOpen(false)}
|
|
>
|
|
{t('common.cancel')}
|
|
</Button>
|
|
<Button
|
|
onClick={() => {
|
|
field.onChange(tempSelectedKBIds);
|
|
setKbDialogOpen(false);
|
|
}}
|
|
>
|
|
{t('common.confirm')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
|
|
case DynamicFormItemType.BOT_SELECTOR:
|
|
return (
|
|
<Select value={field.value} onValueChange={field.onChange}>
|
|
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
|
|
<SelectValue placeholder={t('bots.selectBot')} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
{bots.map((bot) => (
|
|
<SelectItem key={bot.uuid} value={bot.uuid ?? ''}>
|
|
{bot.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
);
|
|
|
|
case DynamicFormItemType.TOOLS_SELECTOR:
|
|
return (
|
|
<>
|
|
<div className="min-w-0 space-y-2">
|
|
{field.value && field.value.length > 0 ? (
|
|
<div className="min-w-0 space-y-2">
|
|
{field.value.map((toolName: string) => {
|
|
const currentTool = tools.find(
|
|
(tool) => tool.name === toolName,
|
|
);
|
|
|
|
return (
|
|
<div
|
|
key={toolName}
|
|
className="flex min-w-0 items-center justify-between rounded-lg border p-3 hover:bg-accent"
|
|
>
|
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
<Wrench className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
<div className="flex-1 min-w-0">
|
|
<div className="truncate font-medium">{toolName}</div>
|
|
{currentTool?.human_desc && (
|
|
<div className="text-sm text-muted-foreground truncate">
|
|
{currentTool.human_desc}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => {
|
|
const newValue = field.value.filter(
|
|
(name: string) => name !== toolName,
|
|
);
|
|
field.onChange(newValue);
|
|
}}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border">
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('tools.noToolSelected', 'No tools selected')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Button
|
|
type="button"
|
|
onClick={() => {
|
|
setTempSelectedToolNames(field.value || []);
|
|
setToolsDialogOpen(true);
|
|
}}
|
|
variant="outline"
|
|
className="w-full"
|
|
>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
{t('tools.addTool', 'Add Tool')}
|
|
</Button>
|
|
|
|
<Dialog open={toolsDialogOpen} onOpenChange={setToolsDialogOpen}>
|
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{t('tools.selectTools', 'Select Tools')}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="flex-1 overflow-y-auto space-y-2 pr-2">
|
|
{tools.map((tool) => {
|
|
const isSelected = tempSelectedToolNames.includes(tool.name);
|
|
return (
|
|
<div
|
|
key={tool.name}
|
|
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
|
|
onClick={() => {
|
|
setTempSelectedToolNames((prev) =>
|
|
prev.includes(tool.name)
|
|
? prev.filter((name) => name !== tool.name)
|
|
: [...prev, tool.name],
|
|
);
|
|
}}
|
|
>
|
|
<Checkbox
|
|
checked={isSelected}
|
|
aria-label={`Select ${tool.name}`}
|
|
/>
|
|
<Wrench className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
<div className="flex-1">
|
|
<div className="font-medium">{tool.name}</div>
|
|
{tool.human_desc && (
|
|
<div className="text-sm text-muted-foreground">
|
|
{tool.human_desc}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
{tools.length === 0 && (
|
|
<div className="flex h-32 items-center justify-center">
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('tools.noToolsAvailable', 'No tools available')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setToolsDialogOpen(false)}
|
|
>
|
|
{t('common.cancel')}
|
|
</Button>
|
|
<Button
|
|
onClick={() => {
|
|
field.onChange(tempSelectedToolNames);
|
|
setToolsDialogOpen(false);
|
|
}}
|
|
>
|
|
{t('common.confirm')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
|
|
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 (
|
|
<div className="min-w-0 space-y-2">
|
|
{promptItems.map(
|
|
(item: { role: string; content: string }, index: number) => (
|
|
<div
|
|
key={index}
|
|
className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center"
|
|
>
|
|
{/* 角色选择 */}
|
|
{index === 0 ? (
|
|
<div className="w-full shrink-0 rounded border bg-gray-50 px-3 py-2 text-gray-500 sm:w-[120px] dark:border-gray-600 dark:bg-[#2a292e] dark:text-white">
|
|
system
|
|
</div>
|
|
) : (
|
|
<Select
|
|
value={item.role}
|
|
onValueChange={(value) => {
|
|
const newValue = [...(field.value ?? promptItems)];
|
|
newValue[index] = { ...newValue[index], role: value };
|
|
field.onChange(newValue);
|
|
}}
|
|
>
|
|
<SelectTrigger className="w-[120px] bg-[#ffffff] dark:bg-[#2a2a2e]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
<SelectItem value="user">user</SelectItem>
|
|
<SelectItem value="assistant">assistant</SelectItem>
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
{/* 内容输入 */}
|
|
<Textarea
|
|
className="min-h-20 w-full min-w-0 flex-1 resize-y overflow-x-hidden break-all sm:w-[300px]"
|
|
value={item.content}
|
|
onChange={(e) => {
|
|
const newValue = [...(field.value ?? promptItems)];
|
|
newValue[index] = {
|
|
...newValue[index],
|
|
content: e.target.value,
|
|
};
|
|
field.onChange(newValue);
|
|
}}
|
|
/>
|
|
{/* 删除按钮,第一轮不显示 */}
|
|
{index !== 0 && (
|
|
<button
|
|
type="button"
|
|
className="p-2 hover:bg-gray-100 rounded"
|
|
onClick={() => {
|
|
const newValue = (field.value ?? promptItems).filter(
|
|
(_: any, i: number) => i !== index,
|
|
);
|
|
field.onChange(newValue);
|
|
}}
|
|
>
|
|
<Trash2 className="w-5 h-5 text-red-500" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
),
|
|
)}
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => {
|
|
field.onChange([
|
|
...(field.value ?? promptItems),
|
|
{ role: 'user', content: '' },
|
|
]);
|
|
}}
|
|
>
|
|
{t('common.addRound')}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
case DynamicFormItemType.FILE:
|
|
return (
|
|
<div className="space-y-2">
|
|
{field.value && (field.value as IFileConfig).file_key ? (
|
|
<Card className="py-3 max-w-full overflow-hidden bg-gray-900">
|
|
<CardContent className="flex items-center gap-3 p-0 px-4 min-w-0">
|
|
<div className="flex-1 min-w-0 overflow-hidden">
|
|
<div
|
|
className="text-sm font-medium truncate"
|
|
title={(field.value as IFileConfig).file_key}
|
|
>
|
|
{(field.value as IFileConfig).file_key}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground truncate">
|
|
{(field.value as IFileConfig).mimetype}
|
|
</div>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="flex-shrink-0 h-8 w-8 p-0"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
field.onChange(null);
|
|
}}
|
|
title={t('common.delete')}
|
|
>
|
|
<Trash2 className="w-4 h-4 text-destructive" />
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<div className="relative">
|
|
<input
|
|
type="file"
|
|
accept={config.accept}
|
|
disabled={uploading}
|
|
onChange={async (e) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) {
|
|
const fileConfig = await handleFileUpload(file);
|
|
if (fileConfig) {
|
|
field.onChange(fileConfig);
|
|
}
|
|
}
|
|
e.target.value = '';
|
|
}}
|
|
className="hidden"
|
|
id={`file-input-${config.name}`}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={uploading}
|
|
onClick={() =>
|
|
document.getElementById(`file-input-${config.name}`)?.click()
|
|
}
|
|
>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
{uploading
|
|
? t('plugins.fileUpload.uploading')
|
|
: t('plugins.fileUpload.chooseFile')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
case DynamicFormItemType.FILE_ARRAY:
|
|
return (
|
|
<div className="space-y-2">
|
|
{(field.value as IFileConfig[])?.map(
|
|
(fileConfig: IFileConfig, index: number) => (
|
|
<Card
|
|
key={index}
|
|
className="py-3 max-w-full overflow-hidden bg-gray-900"
|
|
>
|
|
<CardContent className="flex items-center gap-3 p-0 px-4 min-w-0">
|
|
<div className="flex-1 min-w-0 overflow-hidden">
|
|
<div
|
|
className="text-sm font-medium truncate"
|
|
title={fileConfig.file_key}
|
|
>
|
|
{fileConfig.file_key}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground truncate">
|
|
{fileConfig.mimetype}
|
|
</div>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="flex-shrink-0 h-8 w-8 p-0"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const newValue = (field.value as IFileConfig[]).filter(
|
|
(_: IFileConfig, i: number) => i !== index,
|
|
);
|
|
field.onChange(newValue);
|
|
}}
|
|
title={t('common.delete')}
|
|
>
|
|
<Trash2 className="w-4 h-4 text-destructive" />
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
),
|
|
)}
|
|
<div className="relative">
|
|
<input
|
|
type="file"
|
|
accept={config.accept}
|
|
disabled={uploading}
|
|
onChange={async (e) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) {
|
|
const fileConfig = await handleFileUpload(file);
|
|
if (fileConfig) {
|
|
field.onChange([...(field.value || []), fileConfig]);
|
|
}
|
|
}
|
|
e.target.value = '';
|
|
}}
|
|
className="hidden"
|
|
id={`file-array-input-${config.name}`}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={uploading}
|
|
onClick={() =>
|
|
document
|
|
.getElementById(`file-array-input-${config.name}`)
|
|
?.click()
|
|
}
|
|
>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
{uploading
|
|
? t('plugins.fileUpload.uploading')
|
|
: t('plugins.fileUpload.addFile')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
default:
|
|
return <Input {...field} />;
|
|
}
|
|
}
|