939 lines
52 KiB
TypeScript
939 lines
52 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react'
|
|
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
|
import { Button } from '../ui/button';
|
|
import { Check, CheckCircle, ChevronLeft, ChevronRight, ChevronUp, Download, Eye, EyeOff, Loader2 } from 'lucide-react';
|
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '../ui/command';
|
|
import { DALLE_3_QUALITY_OPTIONS, GPT_IMAGE_1_5_QUALITY_OPTIONS, IMAGE_PROVIDERS, LLM_PROVIDERS } from '@/utils/providerConstants';
|
|
import { cn } from '@/lib/utils';
|
|
import { LLMConfig } from '@/types/llm_config';
|
|
import { RootState } from '@/store/store';
|
|
import { useSelector } from 'react-redux';
|
|
import { toast } from 'sonner';
|
|
import ToolTip from '../ToolTip';
|
|
import { Switch } from '../ui/switch';
|
|
import { Select, SelectItem, SelectContent, SelectValue, SelectTrigger } from '../ui/select';
|
|
import { MixpanelEvent, trackEvent } from '@/utils/mixpanel';
|
|
import { usePathname, useRouter } from 'next/navigation';
|
|
import { handleSaveLLMConfig } from '@/utils/storeHelpers';
|
|
import { checkIfSelectedOllamaModelIsPulled, pullOllamaModel } from '@/utils/providerUtils';
|
|
|
|
const PresentonMode = ({ currentStep, setStep }: { currentStep: number, setStep: (step: number) => void }) => {
|
|
const pathname = usePathname();
|
|
const router = useRouter();
|
|
const [openProviderSelect, setOpenProviderSelect] = useState(false);
|
|
const [openImageProviderSelect, setOpenImageProviderSelect] = useState(false);
|
|
const userConfigState = useSelector((state: RootState) => state.userConfig);
|
|
|
|
const [showApiKey, setShowApiKey] = useState(false);
|
|
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
|
const [openModelSelect, setOpenModelSelect] = useState(false);
|
|
const [modelsLoading, setModelsLoading] = useState(false);
|
|
const [modelsChecked, setModelsChecked] = useState(false);
|
|
const [showDownloadModal, setShowDownloadModal] = useState(false);
|
|
const [savingConfig, setSavingConfig] = useState(false);
|
|
const [llmConfig, setLlmConfig] = useState<LLMConfig>(
|
|
userConfigState.llm_config
|
|
);
|
|
const [downloadingModel, setDownloadingModel] = useState<{
|
|
name: string;
|
|
size: number | null;
|
|
downloaded: number | null;
|
|
status: string;
|
|
done: boolean;
|
|
} | null>(null);
|
|
|
|
const handleProviderChange = (provider: string) => {
|
|
|
|
setLlmConfig(prev => ({
|
|
...prev,
|
|
LLM: provider
|
|
}));
|
|
setOpenProviderSelect(false);
|
|
setAvailableModels([]);
|
|
setModelsChecked(false);
|
|
if (currentModelField) {
|
|
setLlmConfig(prev => ({
|
|
...prev,
|
|
[currentModelField]: ''
|
|
}));
|
|
}
|
|
};
|
|
|
|
const currentModelField = useMemo(() => {
|
|
switch (llmConfig.LLM) {
|
|
case 'openai':
|
|
return 'OPENAI_MODEL';
|
|
case 'google':
|
|
return 'GOOGLE_MODEL';
|
|
case 'anthropic':
|
|
return 'ANTHROPIC_MODEL';
|
|
case 'ollama':
|
|
return 'OLLAMA_MODEL';
|
|
case 'custom':
|
|
return 'CUSTOM_MODEL';
|
|
default:
|
|
return '';
|
|
}
|
|
}, [llmConfig.LLM]);
|
|
const currentApiKeyField = useMemo(() => {
|
|
switch (llmConfig.LLM) {
|
|
case 'openai':
|
|
return 'OPENAI_API_KEY';
|
|
case 'google':
|
|
return 'GOOGLE_API_KEY';
|
|
case 'anthropic':
|
|
return 'ANTHROPIC_API_KEY';
|
|
case 'custom':
|
|
return 'CUSTOM_LLM_API_KEY';
|
|
default:
|
|
return '';
|
|
}
|
|
}, [llmConfig.LLM]);
|
|
|
|
|
|
|
|
const getFieldValue = (field?: string) => {
|
|
if (!field) return "";
|
|
return (llmConfig as Record<string, string | undefined>)[field] || "";
|
|
};
|
|
|
|
const currentApiKey = currentApiKeyField ? ((llmConfig as Record<string, unknown>)[currentApiKeyField] as string || '') : '';
|
|
const currentModel = currentModelField ? ((llmConfig as Record<string, unknown>)[currentModelField] as string || '') : '';
|
|
const currentOllamaUrl = llmConfig.OLLAMA_URL || '';
|
|
const useCustomOllamaUrl = !!llmConfig.USE_CUSTOM_URL;
|
|
|
|
const fetchAvailableModels = async () => {
|
|
if (llmConfig.LLM === 'openai' && !currentApiKey) return;
|
|
if (llmConfig.LLM === 'google' && !currentApiKey) return;
|
|
if (llmConfig.LLM === 'anthropic' && !currentApiKey) return;
|
|
if (llmConfig.LLM === 'custom' && !llmConfig.CUSTOM_LLM_URL) return;
|
|
|
|
setModelsLoading(true);
|
|
try {
|
|
let response: Response;
|
|
if (llmConfig.LLM === 'google') {
|
|
response = await fetch('/api/v1/ppt/google/models/available', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
api_key: currentApiKey
|
|
}),
|
|
});
|
|
} else if (llmConfig.LLM === 'anthropic') {
|
|
response = await fetch('/api/v1/ppt/anthropic/models/available', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
api_key: currentApiKey
|
|
}),
|
|
});
|
|
} else if (llmConfig.LLM === 'ollama') {
|
|
response = await fetch('/api/v1/ppt/ollama/models/supported');
|
|
} else {
|
|
response = await fetch('/api/v1/ppt/openai/models/available', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
url: llmConfig.LLM === 'custom' ? llmConfig.CUSTOM_LLM_URL : LLM_PROVIDERS[llmConfig.LLM!]?.url || '',
|
|
api_key: currentApiKey
|
|
}),
|
|
});
|
|
}
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
const normalizedModels: string[] = llmConfig.LLM === 'ollama'
|
|
? Array.isArray(data)
|
|
? data.map((model: { value?: string; label?: string }) => model.value || model.label || '').filter(Boolean)
|
|
: []
|
|
: Array.isArray(data)
|
|
? data
|
|
: [];
|
|
|
|
setAvailableModels(normalizedModels);
|
|
setModelsChecked(true);
|
|
|
|
if (normalizedModels.length > 0 && currentModelField) {
|
|
if (llmConfig[currentModelField] && normalizedModels.includes(llmConfig[currentModelField])) {
|
|
setLlmConfig(prev => ({
|
|
...prev,
|
|
[currentModelField]: llmConfig[currentModelField]
|
|
}));
|
|
return;
|
|
}
|
|
|
|
const preferredDefault =
|
|
llmConfig.LLM === 'openai'
|
|
? 'gpt-4.1'
|
|
: llmConfig.LLM === 'google'
|
|
? 'models/gemini-2.5-flash'
|
|
: llmConfig.LLM === 'anthropic'
|
|
? 'claude-sonnet-4-20250514'
|
|
: normalizedModels[0];
|
|
|
|
const nextModel = normalizedModels.includes(preferredDefault) ? preferredDefault : normalizedModels[0];
|
|
setLlmConfig(prev => ({
|
|
...prev,
|
|
[currentModelField]: nextModel
|
|
}));
|
|
}
|
|
} else {
|
|
console.error('Failed to fetch models');
|
|
setAvailableModels([]);
|
|
setModelsChecked(true);
|
|
toast.error(`Failed to fetch ${LLM_PROVIDERS[llmConfig.LLM!]?.label} models`);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching models:', error);
|
|
toast.error('Error fetching models');
|
|
setAvailableModels([]);
|
|
setModelsChecked(true);
|
|
} finally {
|
|
setModelsLoading(false);
|
|
}
|
|
};
|
|
|
|
const renderQualitySelector = (llmConfig: LLMConfig) => {
|
|
if (llmConfig.IMAGE_PROVIDER === "dall-e-3") {
|
|
return (
|
|
<div className="w-full ">
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
DALL·E 3 Image Quality
|
|
</label>
|
|
<div className="">
|
|
<Select value={llmConfig.DALL_E_3_QUALITY} onValueChange={(value) => setLlmConfig((prev) => ({
|
|
...prev,
|
|
DALL_E_3_QUALITY: value
|
|
}))}>
|
|
<SelectTrigger className="w-full h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between">
|
|
<SelectValue placeholder="Select a quality" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{DALLE_3_QUALITY_OPTIONS.map((option) => (
|
|
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (llmConfig.IMAGE_PROVIDER === "gpt-image-1.5") {
|
|
return (
|
|
<div className="w-full">
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
GPT Image 1.5 Quality
|
|
</label>
|
|
<div className="">
|
|
<Select
|
|
value={llmConfig.GPT_IMAGE_1_5_QUALITY}
|
|
onValueChange={(value) => setLlmConfig((prev) => ({
|
|
...prev,
|
|
GPT_IMAGE_1_5_QUALITY: value
|
|
}))}
|
|
>
|
|
<SelectTrigger
|
|
|
|
className="w-full h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between">
|
|
<SelectValue placeholder="Select a quality" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{GPT_IMAGE_1_5_QUALITY_OPTIONS.map((option) => (
|
|
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
};
|
|
const handleModelDownload = async () => {
|
|
try {
|
|
await pullOllamaModel(llmConfig.OLLAMA_MODEL!, setDownloadingModel);
|
|
}
|
|
finally {
|
|
setDownloadingModel(null);
|
|
setShowDownloadModal(false);
|
|
}
|
|
};
|
|
|
|
|
|
const handleSaveConfig = async () => {
|
|
trackEvent(MixpanelEvent.Home_SaveConfiguration_Button_Clicked, { pathname });
|
|
try {
|
|
setSavingConfig(true);
|
|
// API: save config
|
|
trackEvent(MixpanelEvent.Home_SaveConfiguration_API_Call);
|
|
// API CALL: save config
|
|
await handleSaveLLMConfig(llmConfig);
|
|
|
|
if (llmConfig.LLM === "ollama" && llmConfig.OLLAMA_MODEL) {
|
|
// API: check model pulled
|
|
trackEvent(MixpanelEvent.Home_CheckOllamaModelPulled_API_Call);
|
|
const isPulled = await checkIfSelectedOllamaModelIsPulled(llmConfig.OLLAMA_MODEL);
|
|
if (!isPulled) {
|
|
setShowDownloadModal(true);
|
|
// API: download model
|
|
trackEvent(MixpanelEvent.Home_DownloadOllamaModel_API_Call);
|
|
await handleModelDownload();
|
|
}
|
|
}
|
|
toast.info("Configuration saved successfully");
|
|
// Track navigation from -> to
|
|
trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/final onboarding step" });
|
|
setStep(3)
|
|
// router.push("/upload");
|
|
} catch (error) {
|
|
toast.info(error instanceof Error ? error.message : "Failed to save configuration");
|
|
|
|
}
|
|
finally {
|
|
setSavingConfig(false);
|
|
}
|
|
};
|
|
|
|
const downloadProgress = useMemo(() => {
|
|
if (downloadingModel && downloadingModel.downloaded !== null && downloadingModel.size !== null) {
|
|
return Math.round((downloadingModel.downloaded / downloadingModel.size) * 100);
|
|
}
|
|
return 0;
|
|
}, [downloadingModel?.downloaded, downloadingModel?.size]);
|
|
|
|
useEffect(() => {
|
|
if (llmConfig.LLM === 'ollama' && !modelsChecked && !modelsLoading) {
|
|
fetchAvailableModels();
|
|
}
|
|
}, [llmConfig.LLM, modelsChecked, modelsLoading]);
|
|
|
|
return (
|
|
<div className='w-full max-w-[640px] font-syne'>
|
|
<p className='px-2.5 py-0.5 w-fit text-[#7A5AF8] rounded-[50px] border border-[#EDEEEF] text-[10px] font-medium mb-5 font-syne'>PRESENTON</p>
|
|
<div className='mb-[54px]'>
|
|
|
|
<h2 className='mb-4 text-black text-[26px] font-normal font-unbounded '>Choose your content providers</h2>
|
|
<p className='text-[#000000CC] text-xl font-normal font-syne'>Select the AI engines that will generate your slide text and visuals.</p>
|
|
</div>
|
|
{/* Text Provider */}
|
|
<div className='p-3 border border-[#EDEEEF] rounded-[11px] '>
|
|
<div className="flex items-center gap-6 mb-7">
|
|
<div className='w-[60px] h-[60px] rounded-[4px] flex items-center justify-center'
|
|
style={{ backgroundColor: '#4C55541A' }}
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40" fill="none">
|
|
<path d="M20 6.6665V33.3332" stroke="#4C5554" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
<path d="M6.66666 11.6665V8.33317C6.66666 7.89114 6.84225 7.46722 7.15481 7.15466C7.46737 6.8421 7.8913 6.6665 8.33332 6.6665H31.6667C32.1087 6.6665 32.5326 6.8421 32.8452 7.15466C33.1577 7.46722 33.3333 7.89114 33.3333 8.33317V11.6665" stroke="#4C5554" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
<path d="M15 33.3335H25" stroke="#4C5554" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
</div>
|
|
<div className='w-full'>
|
|
|
|
<h3 className="text-xl font-normal text-[#191919] pb-1.5">Text Generation Settings</h3>
|
|
<p className=" text-sm text-gray-500">
|
|
Choosing where text contets come from
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className='flex items-start gap-4 '>
|
|
<div className="flex flex-col justify-start w-full ">
|
|
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Select Text Provider
|
|
</label>
|
|
<Popover
|
|
open={openProviderSelect}
|
|
onOpenChange={setOpenProviderSelect}
|
|
>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={openProviderSelect}
|
|
className=" h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between"
|
|
>
|
|
<div className="flex gap-3 items-center">
|
|
<span className="text-sm font-medium text-gray-900">
|
|
{llmConfig.LLM
|
|
? LLM_PROVIDERS[llmConfig.LLM]
|
|
?.label || llmConfig.LLM
|
|
: "Select text provider"}
|
|
</span>
|
|
</div>
|
|
<ChevronUp className="w-4 h-4 text-gray-500" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="p-0 w-[215px] "
|
|
align="start"
|
|
|
|
>
|
|
<Command>
|
|
<CommandInput placeholder="Search provider..." />
|
|
<CommandList className='hide-scrollbar'>
|
|
<CommandEmpty>No provider found.</CommandEmpty>
|
|
<CommandGroup >
|
|
{Object.values(LLM_PROVIDERS).map(
|
|
(provider, index) => (
|
|
<CommandItem
|
|
key={index}
|
|
value={provider.value}
|
|
onSelect={() => handleProviderChange(provider.value)}
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-4 w-4",
|
|
llmConfig.LLM === provider.value
|
|
? "opacity-100"
|
|
: "opacity-0"
|
|
)}
|
|
/>
|
|
<div className="flex gap-3 items-center">
|
|
<div className="flex flex-col space-y-1 flex-1">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="text-sm font-medium text-gray-900 capitalize">
|
|
{provider.label}
|
|
</span>
|
|
</div>
|
|
<span className="text-xs text-gray-600 leading-relaxed">
|
|
{provider.description}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</CommandItem>
|
|
)
|
|
)}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
<div className="relative flex flex-col justify-end items-end w-full ">
|
|
<div className="flex flex-col justify-start w-full ">
|
|
{llmConfig.LLM === 'ollama' ? (
|
|
<>
|
|
{!useCustomOllamaUrl ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => setLlmConfig(prev => ({
|
|
...prev,
|
|
USE_CUSTOM_URL: true,
|
|
OLLAMA_URL: prev.OLLAMA_URL || 'http://localhost:11434'
|
|
}))}
|
|
className="mt-8 py-2.5 bg-[#EDEEEF] px-3.5 w-fit rounded-[48px] text-xs font-semibold text-[#101323] transition-all duration-200 border border-[#EDEEEF] hover:bg-[#E8F0FF]/90 focus:ring-2 focus:ring-blue-500/20"
|
|
>
|
|
Use Ollama URL
|
|
</button>
|
|
) : (
|
|
<>
|
|
<label className="block text-sm font-medium capitalize text-gray-700 mb-2">
|
|
Ollama URL
|
|
</label>
|
|
<div className="relative">
|
|
<input
|
|
type="text"
|
|
value={currentOllamaUrl}
|
|
onChange={(e) => setLlmConfig(prev => ({
|
|
...prev,
|
|
OLLAMA_URL: e.target.value
|
|
}))}
|
|
className="w-full px-2 py-3 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
|
|
placeholder="http://localhost:11434"
|
|
/>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setLlmConfig(prev => ({
|
|
...prev,
|
|
USE_CUSTOM_URL: false,
|
|
OLLAMA_URL: 'http://localhost:11434'
|
|
}))}
|
|
className="mt-2 text-xs font-medium text-[#4B5563] underline underline-offset-2"
|
|
>
|
|
Use default Ollama URL
|
|
</button>
|
|
</>
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
<label className="block text-sm font-medium capitalize text-gray-700 mb-2">
|
|
{llmConfig.LLM === 'custom' ? 'Custom LLM API Key' : `${llmConfig.LLM} API Key`}
|
|
</label>
|
|
<div className="relative">
|
|
<input
|
|
type={showApiKey ? 'text' : 'password'}
|
|
value={currentApiKey}
|
|
onChange={(e) => setLlmConfig(prev => ({
|
|
...prev,
|
|
[currentApiKeyField]: e.target.value
|
|
}))}
|
|
className="w-full px-2 py-3 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
|
|
placeholder={`Enter your ${llmConfig.LLM} API key`}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowApiKey((prev) => !prev)}
|
|
className='absolute right-2 top-1/2 -translate-y-1/2 bg-white px-2 py-1 cursor-pointer'
|
|
>
|
|
{showApiKey ? <Eye className='w-4 h-4 text-gray-500' /> : <EyeOff className='w-4 h-4 text-gray-500' />}
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
{llmConfig.LLM === 'custom' && (
|
|
<input
|
|
type="text"
|
|
value={llmConfig.CUSTOM_LLM_URL}
|
|
onChange={(e) => setLlmConfig(prev => ({
|
|
...prev,
|
|
CUSTOM_LLM_URL: e.target.value
|
|
}))}
|
|
className="w-full mt-2 px-2 py-3 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
|
|
placeholder="OpenAI-compatible URL"
|
|
/>
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{llmConfig.LLM !== 'ollama' && (!modelsChecked || (modelsChecked && availableModels.length === 0)) && (
|
|
|
|
<button
|
|
onClick={fetchAvailableModels}
|
|
disabled={
|
|
modelsLoading ||
|
|
(llmConfig.LLM === 'openai' && !currentApiKey) ||
|
|
(llmConfig.LLM === 'google' && !currentApiKey) ||
|
|
(llmConfig.LLM === 'anthropic' && !currentApiKey) ||
|
|
(llmConfig.LLM === 'custom' && !llmConfig.CUSTOM_LLM_URL)
|
|
}
|
|
className={`mt-4 py-2.5 bg-[#EDEEEF] px-3.5 w-fit rounded-[48px] text-xs font-semibold text-[#101323] transition-all duration-200 border ${modelsLoading
|
|
? " border-gray-300 cursor-not-allowed text-gray-500"
|
|
: " border-[#EDEEEF] text-[#101323] hover:bg-[#E8F0FF]/90 focus:ring-2 focus:ring-blue-500/20"
|
|
}`}
|
|
>
|
|
{modelsLoading ? (
|
|
<span className="flex items-center justify-center gap-2">
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
Checking for models...
|
|
</span>
|
|
) : (
|
|
"Check models"
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
</div>
|
|
<div className='flex items-start gap-4 mt-4'>
|
|
<p className='text-sm font-medium text-gray-700 mb-2 w-full'></p>
|
|
|
|
{/* Model Selection - only show if models are available */}
|
|
{modelsChecked && availableModels.length > 0 && (
|
|
<div className="w-full">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
{llmConfig.LLM === 'ollama' ? 'Choose a supported model' : `Select ${LLM_PROVIDERS[llmConfig.LLM!]?.label} Model`}
|
|
</label>
|
|
<div className="w-full">
|
|
<Popover
|
|
open={openModelSelect}
|
|
onOpenChange={setOpenModelSelect}
|
|
>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={openModelSelect}
|
|
className="w-full h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between"
|
|
>
|
|
<span className="text-sm truncate font-medium text-gray-900">
|
|
{
|
|
currentModel
|
|
? availableModels.find(model => model === currentModel) || currentModel
|
|
:
|
|
"Select a model"
|
|
}
|
|
</span>
|
|
|
|
<ChevronUp className="w-4 h-4 text-gray-500" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="p-0"
|
|
align="start"
|
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
|
>
|
|
<Command>
|
|
<CommandInput placeholder="Search models..." />
|
|
<CommandList>
|
|
<CommandEmpty>No model found.</CommandEmpty>
|
|
<CommandGroup>
|
|
{availableModels.map((model, index) => (
|
|
<CommandItem
|
|
key={index}
|
|
value={model}
|
|
onSelect={(value) => {
|
|
if (currentModelField) {
|
|
setLlmConfig(prev => ({
|
|
...prev,
|
|
[currentModelField]: value
|
|
}));
|
|
}
|
|
setOpenModelSelect(false);
|
|
}}
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-4 w-4",
|
|
currentModel === model
|
|
? "opacity-100"
|
|
: "opacity-0"
|
|
)}
|
|
/>
|
|
<div className="flex gap-3 items-center">
|
|
<div className="flex flex-col space-y-1 flex-1">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="text-sm font-medium text-gray-900">
|
|
{model}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{/* Image Provider */}
|
|
<div className='p-3 border border-[#EDEEEF] rounded-[11px] mt-5'>
|
|
<ToolTip content="Enable/Disable Image Generation" className='flex justify-end items-center'>
|
|
<div className='flex justify-end items-center'>
|
|
<Switch
|
|
checked={!llmConfig.DISABLE_IMAGE_GENERATION}
|
|
className='data-[state=checked]:bg-[#4791FF] data-[state=unchecked]:bg-gray-400'
|
|
onCheckedChange={(checked) => setLlmConfig(prev => ({
|
|
...prev,
|
|
DISABLE_IMAGE_GENERATION: !checked
|
|
}))}
|
|
/>
|
|
</div>
|
|
|
|
</ToolTip>
|
|
<div className=" mb-7 flex items-center gap-6">
|
|
<div className='w-[60px] h-[60px] px-[13.5px] py-[14.2px] rounded-[4px] flex items-center justify-center'
|
|
style={{ backgroundColor: '#F4F3FF' }}
|
|
>
|
|
<img src="/image-markup.svg" className='w-full h-full object-cover' alt='image-markup' />
|
|
</div>
|
|
<div>
|
|
|
|
<h3 className="text-xl font-normal text-[#191919] ">Image Generation Settings</h3>
|
|
<p className=" text-sm text-gray-500">
|
|
Choosing where images come from
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{!llmConfig.DISABLE_IMAGE_GENERATION && (
|
|
<div className='flex gap-4'>
|
|
{/* Image Provider Selection */}
|
|
<div className="w-full">
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Select Image Provider
|
|
</label>
|
|
<div className="w-full">
|
|
<Popover
|
|
open={openImageProviderSelect}
|
|
onOpenChange={setOpenImageProviderSelect}
|
|
|
|
>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={openImageProviderSelect}
|
|
className=" w-full h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between"
|
|
>
|
|
<div className="flex gap-3 items-center">
|
|
<span className="text-sm font-medium capitalize text-gray-900">
|
|
{llmConfig.IMAGE_PROVIDER
|
|
? IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER]
|
|
?.label || llmConfig.IMAGE_PROVIDER
|
|
: 'Select Image Provider'}
|
|
</span>
|
|
</div>
|
|
<ChevronUp className="w-4 h-4 text-gray-500" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="p-0 w-full"
|
|
align="start"
|
|
|
|
>
|
|
<Command>
|
|
<CommandInput placeholder="Search provider..." />
|
|
<CommandList>
|
|
<CommandEmpty>No provider found.</CommandEmpty>
|
|
<CommandGroup>
|
|
{Object.values(IMAGE_PROVIDERS).map(
|
|
(provider, index) => (
|
|
<CommandItem
|
|
key={index}
|
|
value={provider.value}
|
|
onSelect={(value) => {
|
|
setLlmConfig(prev => ({
|
|
...prev,
|
|
IMAGE_PROVIDER: value
|
|
}));
|
|
setOpenImageProviderSelect(false);
|
|
}}
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-4 w-4",
|
|
llmConfig.IMAGE_PROVIDER === provider.value
|
|
? "opacity-100"
|
|
: "opacity-0"
|
|
)}
|
|
/>
|
|
<div className="flex gap-3 items-center">
|
|
<div className="flex flex-col space-y-1 flex-1">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="text-sm font-medium text-gray-900 capitalize">
|
|
{provider.label}
|
|
</span>
|
|
</div>
|
|
<span className="text-xs text-gray-600 leading-relaxed">
|
|
{provider.description}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</CommandItem>
|
|
)
|
|
)}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
{/* Dynamic API Key Input for Image Provider */}
|
|
{llmConfig.IMAGE_PROVIDER &&
|
|
IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER] &&
|
|
(() => {
|
|
const provider = IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER];
|
|
|
|
|
|
|
|
// Show ComfyUI configuration
|
|
if (provider.value === "comfyui") {
|
|
return (
|
|
<div className=" space-y-4 w-full">
|
|
<div className=''>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
ComfyUI Server URL
|
|
</label>
|
|
<div className="relative">
|
|
<input
|
|
type="text"
|
|
placeholder="http://192.168.1.7:8188"
|
|
className="w-full px-4 py-2.5 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
|
|
value={llmConfig.COMFYUI_URL || ""}
|
|
onChange={(e) => {
|
|
setLlmConfig(prev => ({
|
|
...prev,
|
|
COMFYUI_URL: e.target.value
|
|
}));
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Show API key input for other providers
|
|
return (
|
|
<div className="w-full ">
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
{provider.apiKeyFieldLabel}
|
|
</label>
|
|
<div className="relative">
|
|
<input
|
|
type={showApiKey ? 'text' : 'password'}
|
|
placeholder={`Enter your ${provider.apiKeyFieldLabel}`}
|
|
className="w-full px-4 py-2.5 h-12 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
|
|
value={getFieldValue(provider.apiKeyField)}
|
|
onChange={(e) => {
|
|
setLlmConfig((prev) => ({
|
|
...prev,
|
|
[provider.apiKeyField as keyof LLMConfig]: e.target.value
|
|
}))
|
|
}
|
|
|
|
}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowApiKey((prev) => !prev)}
|
|
className='absolute right-2 top-1/2 -translate-y-1/2 bg-white px-2 py-1 cursor-pointer'
|
|
>
|
|
{showApiKey ? <Eye className='w-4 h-4 text-gray-500' /> : <EyeOff className='w-4 h-4 text-gray-500' />}
|
|
</button>
|
|
</div>
|
|
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
</div>
|
|
)}
|
|
{!llmConfig.DISABLE_IMAGE_GENERATION && <div className='flex justify-end items-center mt-[18px]'>
|
|
<div className='w-full flex items-center gap-4'>
|
|
<p className='w-full'></p>
|
|
{renderQualitySelector(llmConfig)}
|
|
</div>
|
|
{llmConfig.IMAGE_PROVIDER === "comfyui" && <div className='w-full'>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Workflow JSON
|
|
</label>
|
|
<div className="relative">
|
|
<textarea
|
|
placeholder='Paste your ComfyUI workflow JSON here (export via "Export (API)" in ComfyUI)'
|
|
className="w-full px-4 py-2.5 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors font-mono text-xs"
|
|
rows={3}
|
|
value={llmConfig.COMFYUI_WORKFLOW || ""}
|
|
onChange={(e) => {
|
|
setLlmConfig((prev) => ({
|
|
...prev,
|
|
COMFYUI_WORKFLOW: e.target.value
|
|
}))
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
</div>}
|
|
</div>}
|
|
</div>
|
|
|
|
<div className='absolute bottom-16 mr-8 max-w-[1440px] right-0 flex justify-end items-center gap-2.5 '>
|
|
<button
|
|
disabled={currentStep === 1}
|
|
onClick={() => {
|
|
setStep(currentStep - 1);
|
|
}}
|
|
className='border border-[#EDEEEF] rounded-[53px] px-4 py-1 h-[36px]'>
|
|
<ChevronLeft className='w-4 h-4 text-gray-500' />
|
|
</button>
|
|
<button
|
|
|
|
disabled={savingConfig}
|
|
onClick={handleSaveConfig}
|
|
className='border border-[#EDEEEF] bg-[#7C51F8] rounded-[58px] px-5 py-2.5 text-white text-xs font-semibold'>
|
|
Continue to Finish
|
|
</button>
|
|
</div>
|
|
{/* Download Progress Modal */}
|
|
{showDownloadModal && downloadingModel && (
|
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
|
<div className="bg-white/95 backdrop-blur-md rounded-xl shadow-2xl max-w-md w-full p-6 relative">
|
|
{/* Modal Content */}
|
|
<div className="text-center">
|
|
{/* Icon */}
|
|
<div className="mb-4">
|
|
{downloadingModel.done ? (
|
|
<CheckCircle className="w-12 h-12 text-green-600 mx-auto" />
|
|
) : (
|
|
<Download className="w-12 h-12 text-blue-600 mx-auto animate-pulse" />
|
|
)}
|
|
</div>
|
|
|
|
{/* Title */}
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
{downloadingModel.done ? "Download Complete!" : "Downloading Model"}
|
|
</h3>
|
|
|
|
{/* Model Name */}
|
|
<p className="text-sm text-gray-600 mb-6">
|
|
{llmConfig.OLLAMA_MODEL}
|
|
</p>
|
|
|
|
{/* Progress Bar */}
|
|
{downloadProgress > 0 && (
|
|
<div className="mb-4">
|
|
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
|
|
<div
|
|
className="bg-blue-600 h-3 rounded-full transition-all duration-300 ease-out"
|
|
style={{ width: `${downloadProgress}%` }}
|
|
/>
|
|
</div>
|
|
<p className="text-sm text-gray-600 mt-2">
|
|
{downloadProgress}% Complete
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Status */}
|
|
{downloadingModel.status && (
|
|
<div className="flex items-center justify-center gap-2 mb-4">
|
|
<CheckCircle className="w-4 h-4 text-green-600" />
|
|
<span className="text-sm font-medium text-green-700 capitalize">
|
|
{downloadingModel.status}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Status Message */}
|
|
{downloadingModel.status && downloadingModel.status !== "pulled" && (
|
|
<div className="text-xs text-gray-500">
|
|
{downloadingModel.status === "downloading" && "Downloading model files..."}
|
|
{downloadingModel.status === "verifying" && "Verifying model integrity..."}
|
|
{downloadingModel.status === "pulling" && "Pulling model from registry..."}
|
|
</div>
|
|
)}
|
|
|
|
{/* Download Info */}
|
|
{downloadingModel.downloaded && downloadingModel.size && (
|
|
<div className="mt-4 p-3 bg-gray-50 rounded-lg">
|
|
<div className="flex justify-between text-xs text-gray-600">
|
|
<span>Downloaded: {(downloadingModel.downloaded / 1024 / 1024).toFixed(1)} MB</span>
|
|
<span>Total: {(downloadingModel.size / 1024 / 1024).toFixed(1)} MB</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default PresentonMode
|