feat: OnBoarding two pages design

This commit is contained in:
shiva raj badu 2026-03-04 00:32:14 +05:45
parent afa60af22c
commit ed76eff535
No known key found for this signature in database
17 changed files with 1170 additions and 143 deletions

View file

@ -32,7 +32,7 @@ export function ConfigurationInitializer({ children }: { children: React.ReactNo
const fetchUserConfigState = async () => {
setIsLoading(true);
let canChangeKeys = false;
if (typeof window !== 'undefined' && (window as any).electron) {
// Electron mode: use IPC
@ -74,13 +74,13 @@ export function ConfigurationInitializer({ children }: { children: React.ReactNo
}
dispatch(setLLMConfig(llmConfig));
const isValid = hasValidLLMConfig(llmConfig);
// Allow access to pdf-maker without LLM configuration (needed for PPTX export)
if (route.startsWith('/pdf-maker')) {
setIsLoading(false);
return;
}
if (isValid) {
// Check if the selected Ollama model is pulled
if (llmConfig.LLM === 'ollama' && llmConfig.OLLAMA_MODEL) {
@ -134,12 +134,12 @@ export function ConfigurationInitializer({ children }: { children: React.ReactNo
api_key: llmConfig.CUSTOM_LLM_API_KEY,
}),
});
if (!response.ok) {
console.error('Custom model check failed with status:', response.status);
return false;
}
const data = await response.json();
return data.includes(llmConfig.CUSTOM_MODEL);
} catch (error) {

View file

@ -122,9 +122,9 @@ const ImageProvider = ({ llmConfig, setLlmConfig }: { llmConfig: LLMConfig, setL
<ToolTip content="Enable/Disable Image Generation" className='flex justify-end items-center'>
<div className='flex justify-end items-center'>
<Switch
checked={isImageGenerationDisabled}
checked={!isImageGenerationDisabled}
className=''
onCheckedChange={(checked) => handleChangeImageGenerationDisabled(checked)}
onCheckedChange={(checked) => handleChangeImageGenerationDisabled(!checked)}
/>
</div>

View file

@ -468,7 +468,7 @@ const TextProvider = ({
</div>
<div className="flex items-center gap-4">
<div className="w-[275px]">
<div className="w-[205px]">
<div className="flex items-center mb-4 gap-2.5 ">
<Switch
checked={!!llmConfig.WEB_GROUNDING}
@ -481,9 +481,10 @@ const TextProvider = ({
</div>
<div className="w-[295px]"></div>
{/* <div className="w-[295px]"></div> */}
</div>
</div>

View file

@ -93,7 +93,7 @@ const OutlineContent: React.FC<OutlineContentProps> = ({
{/* Outlines content */}
{outlines && outlines.length > 0 && (
<div className="bg-[#F9F8F8] p-7 rounded-[20px] overflow-y-auto custom_scrollbar">
<div className="bg-[#F9F8F8] p-7 rounded-[20px] overflow-y-auto custom_scrollbar">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}

View file

@ -119,12 +119,12 @@ export function OutlineItem({
}, [isStreaming, isActiveStreaming, isStableStreaming, slideOutline.content])
return (
<div className="mb-4 bg-white rounded-[12px] shadow-sm p-10 relative">
<div className="mb-4 bg-white rounded-[12px] group shadow-sm p-10 relative">
<div
ref={setNodeRef}
style={style}
className={`flex items-start gap-3 md:gap-4 rounded-[8px] ${isDragging ? "opacity-50" : ""}`}
className={`flex items-start gap-3 md:gap-4 rounded-[8px] ${isDragging ? "opacity-50" : ""}`}
>
<div
@ -165,7 +165,7 @@ export function OutlineItem({
</div>
<div className="absolute -top-3 -right-3 flex gap-1 sm:gap-2 items-center">
<div className="hidden group-hover:flex absolute -top-3 -right-3 gap-1 sm:gap-2 items-center">
<ToolTip content="Delete Slide">
<button

View file

@ -57,7 +57,7 @@ const OutlinePage: React.FC = () => {
/>
<Wrapper className="h-full flex flex-col w-full relative">
<div className="flex-grow w-full overflow-y-hidden mx-auto ">
<div className="flex-grow w-full hidden-scrollbar mx-auto ">
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full flex flex-col">
<TabsList className="my-4 h-auto w-fit rounded-full border border-[#DFDFE1] bg-[#F8F8F9] p-1.5">
<TabsTrigger
@ -76,7 +76,7 @@ const OutlinePage: React.FC = () => {
</TabsList>
<div className="flex-grow w-full mx-auto">
<TabsContent value={TABS.OUTLINE} className="h-[calc(100vh-16rem)] overflow-y-auto custom_scrollbar"
<TabsContent value={TABS.OUTLINE} className="h-[calc(100vh-16rem)] overflow-y-auto hide-scrollbar"
>
<div>
<OutlineContent
@ -91,7 +91,7 @@ const OutlinePage: React.FC = () => {
</div>
</TabsContent>
<TabsContent value={TABS.LAYOUTS} className="h-[calc(100vh-16rem)] overflow-y-auto custom_scrollbar">
<TabsContent value={TABS.LAYOUTS} className="h-[calc(100vh-16rem)] overflow-y-auto hide-scrollbar">
<div>
<TemplateSelection
selectedTemplate={selectedTemplate}
@ -103,7 +103,7 @@ const OutlinePage: React.FC = () => {
</Tabs>
{/* Fixed Button */}
<div className="absolute bottom-36 -right-10 z-50">
<div className="absolute bottom-0 -right-10 z-50">
<GenerateButton
outlineCount={outlines.length}
loadingState={loadingState}

View file

@ -2,12 +2,13 @@
import React, { useEffect, useMemo, useCallback, memo } from "react";
import { TemplateLayoutsWithSettings } from "@/app/presentation-templates/utils";
import { templates} from "@/app/presentation-templates";
import { templates } from "@/app/presentation-templates";
import { Card } from "@/components/ui/card";
import { TemplateWithData } from "@/app/presentation-templates/utils";
import { CustomTemplates, useCustomTemplateSummaries } from "@/app/hooks/useCustomTemplates";
import { Loader2 } from "lucide-react";
import { CustomTemplateCard } from "./CustomTemplateCard";
import CreateCustomTemplate from "../../(dashboard)/templates/components/CreateCustomTemplate";
// Memoized layout preview for built-in templates
const BuiltInLayoutPreview = memo(({ layout, templateId, index }: {
@ -137,12 +138,10 @@ const TemplateSelection: React.FC<TemplateSelectionProps> = memo(({
}
if (customTemplates.length === 0) {
return (
<Card className="p-8 text-center">
<p className="text-gray-500">No custom templates yet.</p>
<p className="text-sm text-gray-400 mt-2">
Custom templates you create will appear here.
</p>
</Card>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<CreateCustomTemplate />
</div>
);
}
return (

View file

@ -76,7 +76,6 @@ const ThemeSelector = ({ presentation_id, current_theme, themes: allThemes }: {
dispatch(updateTheme(null))
}
console.log('presentation data', presentationData)
return (
<Popover>

View file

@ -41,6 +41,9 @@ export function ConfigurationInitializer({ children }: { children: React.ReactNo
if (!llmConfig.LLM) {
llmConfig.LLM = 'openai';
}
if (!llmConfig.IMAGE_PROVIDER) {
llmConfig.IMAGE_PROVIDER = 'gpt-image-1.5';
}
dispatch(setLLMConfig(llmConfig));
const isValid = hasValidLLMConfig(llmConfig);
if (isValid) {

View file

@ -14,6 +14,11 @@ import {
import { LLMConfig } from "@/types/llm_config";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
import { usePathname } from "next/navigation";
import OnBoardingSlidebar from "./OnBoarding/OnBoardingSlidebar";
import OnBoardingHeader from "./OnBoarding/OnBoardingHeader";
import ModeSelectStep from "./OnBoarding/ModeSelectStep";
import PresentonMode from "./OnBoarding/PresentonMode";
import GenerationWithImage from "./OnBoarding/GenerationWithImage";
// Button state interface
interface ButtonState {
@ -28,9 +33,11 @@ interface ButtonState {
export default function Home() {
const router = useRouter();
const pathname = usePathname();
const [step, setStep] = useState<number>(1)
const [selectedMode, setSelectedMode] = useState<string>("presenton")
const config = useSelector((state: RootState) => state.userConfig);
const [llmConfig, setLlmConfig] = useState<LLMConfig>(config.llm_config);
console.log('config', config);
const [downloadingModel, setDownloadingModel] = useState<{
name: string;
size: number | null;
@ -144,124 +151,133 @@ export default function Home() {
}
return (
<div className="h-screen bg-gradient-to-b font-instrument_sans from-gray-50 to-white flex flex-col overflow-hidden">
<main className="flex-1 container mx-auto px-4 max-w-3xl overflow-hidden flex flex-col">
{/* Branding Header */}
<div className="text-center mb-2 mt-4 flex-shrink-0">
<div className="flex items-center justify-center gap-3 mb-2">
<img src="/Logo.png" alt="Presenton Logo" className="h-12" />
</div>
<p className="text-gray-600 text-sm">
Open-source AI presentation generator
</p>
</div>
// <div className="h-screen bg-gradient-to-b font-instrument_sans from-gray-50 to-white flex flex-col overflow-hidden">
// <main className="flex-1 container mx-auto px-4 max-w-3xl overflow-hidden flex flex-col">
// {/* Branding Header */}
// <div className="text-center mb-2 mt-4 flex-shrink-0">
// <div className="flex items-center justify-center gap-3 mb-2">
// <img src="/Logo.png" alt="Presenton Logo" className="h-12" />
// </div>
// <p className="text-gray-600 text-sm">
// Open-source AI presentation generator
// </p>
// </div>
{/* Main Configuration Card */}
<div className="flex-1 overflow-hidden">
<LLMProviderSelection
initialLLMConfig={llmConfig}
onConfigChange={setLlmConfig}
buttonState={buttonState}
setButtonState={setButtonState}
/>
</div>
// {/* Main Configuration Card */}
// <div className="flex-1 overflow-hidden">
// <LLMProviderSelection
// initialLLMConfig={llmConfig}
// onConfigChange={setLlmConfig}
// buttonState={buttonState}
// setButtonState={setButtonState}
// />
// </div>
// </main>
// {/* 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>
// )}
// {/* Fixed Bottom Button */}
// <div className="flex-shrink-0 bg-white border-t border-gray-200 p-4">
// <div className="container mx-auto max-w-3xl">
// <button
// onClick={handleSaveConfig}
// disabled={buttonState.isDisabled}
// className={`w-full font-semibold py-3 px-4 rounded-lg transition-all duration-500 ${buttonState.isDisabled
// ? "bg-gray-400 cursor-not-allowed"
// : "bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 focus:ring-4 focus:ring-blue-200"
// } text-white`}
// >
// {buttonState.isLoading ? (
// <div className="flex items-center justify-center gap-2">
// <Loader2 className="w-4 h-4 animate-spin" />
// {buttonState.text}
// </div>
// ) : (
// buttonState.text
// )}
// </button>
// </div>
// </div>
// </div>
<div className="flex h-screen ">
<OnBoardingSlidebar />
<main className="w-full pl-20 pr-8 max-w-[1440px] mx-auto relative">
<OnBoardingHeader currentStep={step} />
{step === 1 && <ModeSelectStep setStep={setStep} setSelectedMode={setSelectedMode} />}
{step === 2 && selectedMode === "presenton" && <PresentonMode currentStep={step} setStep={setStep} />}
{step === 2 && selectedMode === "image" && <GenerationWithImage />}
</main>
{/* 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>
)}
{/* Fixed Bottom Button */}
<div className="flex-shrink-0 bg-white border-t border-gray-200 p-4">
<div className="container mx-auto max-w-3xl">
<button
onClick={handleSaveConfig}
disabled={buttonState.isDisabled}
className={`w-full font-semibold py-3 px-4 rounded-lg transition-all duration-500 ${buttonState.isDisabled
? "bg-gray-400 cursor-not-allowed"
: "bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 focus:ring-4 focus:ring-blue-200"
} text-white`}
>
{buttonState.isLoading ? (
<div className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
{buttonState.text}
</div>
) : (
buttonState.text
)}
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,11 @@
import React from 'react'
const GenerationWithImage = () => {
return (
<div>
</div>
)
}
export default GenerationWithImage

View file

@ -0,0 +1,56 @@
import { ChevronRight } from 'lucide-react'
import React from 'react'
const ModeSelectStep = ({ setStep, setSelectedMode }: { setStep: (step: number) => void, setSelectedMode: (mode: string) => void }) => {
return (
<div className='max-w-[650px]'>
<div className='mb-[70px]'>
<h2 className='mb-4 text-black text-[26px] font-normal '>Lets set up your AI workspace</h2>
<p className='text-[##000000CC] text-xl font-normal'>First, choose the intelligence behind your presentation generation.</p>
</div>
<div className='space-y-5'>
<div onClick={() => {
setSelectedMode("presenton")
setStep(2)
}} className='border border-[#EDEEEF] rounded-[11px] p-3 flex items-center justify-between gap-6 cursor-pointer'>
<div className='flex items-center gap-6'>
<div className='rounded-[4px] bg-[#F4F3FF] p-[12px] w-[58px] h-[58px] flex items-center justify-center'>
<img src='/logo-with-bg.png' alt='presenton' className='w-full h-full object-contain' />
</div>
<div className=''>
<div className='flex items-start gap-2 relative '>
<h3 className='text-black text-[18px] font-medium'>Presenton</h3>
<p className='bg-[#F4F3FF] px-3 py-1.5 rounded-[30px] text-[#7A5AF8] text-[9px] absolute left-[95px] top-[-10px]'>PPTX</p>
</div>
<p className='text-[#999999] text-[14px] font-normal'>Optimized for fast, structured slide generation.</p>
</div>
</div>
<ChevronRight className='w-6 h-6 text-[#B3B3B3]' />
</div>
<div onClick={() => {
setSelectedMode("image")
setStep(2)
}} className='border border-[#EDEEEF] rounded-[11px] p-3 flex items-center justify-between gap-6 cursor-pointer'>
<div className='flex items-center gap-6'>
<div className='rounded-[4px] bg-[#FFF6ED] p-[12px] w-[58px] h-[58px] flex items-center justify-center'>
<img src='/image_mode.png' alt='presenton' className='w-full h-full object-contain' />
</div>
<div className=''>
<div className='flex items-start gap-2 relative '>
<h3 className='text-black text-[18px] font-medium'>Generate with Image Model</h3>
</div>
<p className='text-[#999999] text-[14px] font-normal'>Generate presentations with visual layouts and elements.</p>
</div>
</div>
<ChevronRight className='w-6 h-6 text-[#B3B3B3]' />
</div>
</div>
</div>
)
}
export default ModeSelectStep

View file

@ -0,0 +1,36 @@
import React from 'react'
const OnBoardingHeader = ({ currentStep }: { currentStep: number }) => {
return (
<div className='flex items-center justify-end gap-1 mt-7 mb-[52px]'>
<div className='flex items-center gap-1'>
<div className={`${currentStep >= 1 ? 'bg-[#010100] text-white' : 'border border-[#ECECEF] text-[#494A4D]'} px-2.5 h-7 w-7 text-xs font-medium rounded-full flex items-center justify-center`}>
1
</div>
<p className='text-[#010000] text-xs '>Select Mode</p>
</div>
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="1" viewBox="0 0 22 1" fill="none">
<path d="M0 0.5H21.5" stroke="#ECECEF" />
</svg>
<div className='flex items-center gap-1'>
<div className={`${currentStep >= 2 ? 'bg-[#010100] text-white' : 'border border-[#ECECEF] text-[#494A4D]'} px-2.5 h-7 w-7 text-xs font-medium rounded-full flex items-center justify-center`}>
2
</div>
<p className='text-[#010000] text-xs '>Choose Providers</p>
</div>
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="1" viewBox="0 0 22 1" fill="none">
<path d="M0 0.5H21.5" stroke="#ECECEF" />
</svg>
<div className='flex items-center gap-1'>
<div className={`${currentStep >= 3 ? 'bg-[#010100] text-white' : 'border border-[#ECECEF] text-[#494A4D]'} px-2.5 h-7 w-7 text-xs font-medium rounded-full flex items-center justify-center`}>
3
</div>
<p className='text-[#010000] text-xs '>Finish Setup</p>
</div>
</div>
)
}
export default OnBoardingHeader

View file

@ -0,0 +1,19 @@
import React from 'react'
const OnBoardingSlidebar = () => {
return (
<div className='bg-[#F6F6F9] w-[300px] relative'>
<img src="/Logo.png" alt="Presenton logo" className="absolute top-0 left-0 w-[128px] m-6" />
<svg xmlns="http://www.w3.org/2000/svg" width="296" height="591" viewBox="0 0 296 591" fill="none">
<path d="M291.5 183.5C311.916 183.5 328.5 200.271 328.5 221C328.5 241.729 311.916 258.5 291.5 258.5C271.084 258.5 254.5 241.729 254.5 221C254.5 200.271 271.084 183.5 291.5 183.5Z" stroke="#EDEEEF" strokeWidth="3" />
<path d="M291.5 131.238C340.408 131.238 380.089 171.407 380.09 220.998C380.09 270.589 340.408 310.758 291.5 310.758C242.591 310.758 202.91 270.589 202.91 220.998C202.91 171.407 242.591 131.238 291.5 131.238Z" stroke="#EDEEEF" strokeWidth="3" />
<path d="M291.5 43.6289C388.173 43.6289 466.576 123.021 466.576 220.998C466.576 318.975 388.174 398.368 291.5 398.368C194.826 398.368 116.424 318.975 116.424 220.998C116.424 123.022 194.826 43.629 291.5 43.6289Z" stroke="#EDEEEF" strokeWidth="3" />
<path d="M287.5 -62.5C434.322 -62.5 553.5 64.115 553.5 220.5C553.5 376.885 434.322 503.5 287.5 503.5C140.678 503.5 21.5 376.885 21.5 220.5C21.5 64.115 140.678 -62.5 287.5 -62.5Z" stroke="#EDEEEF" strokeWidth="3" />
<path d="M291 -176.5C495.019 -176.5 660.5 -5.07604 660.5 206.5C660.5 418.076 495.019 589.5 291 589.5C86.9809 589.5 -78.5 418.076 -78.5 206.5C-78.5 -5.07604 86.9809 -176.5 291 -176.5Z" stroke="#EDEEEF" strokeWidth="3" />
</svg>
</div>
)
}
export default OnBoardingSlidebar

View file

@ -0,0 +1,883 @@
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 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");
setSavingConfig(false);
// Track navigation from -> to
trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/upload" });
router.push("/upload");
} catch (error) {
toast.info(error instanceof Error ? error.message : "Failed to save configuration");
}
};
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]);
return (
<div className='w-full max-w-[640px]'>
<p className='px-2.5 py-0.5 w-fit text-[#7A5AF8] rounded-[50px] border border-[#EDEEEF] text-[10px] font-medium mb-5'>PRESENTON</p>
<div className='mb-[54px]'>
<h2 className='mb-4 text-black text-[26px] font-normal '>Choose your content providers</h2>
<p className='text-[##000000CC] text-xl font-normal'>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 ">
<label className="block text-sm font-medium capitalize text-gray-700 mb-2">
{llmConfig.LLM === 'ollama' ? 'Ollama URL' : llmConfig.LLM === 'custom' ? 'Custom LLM API Key' : `${llmConfig.LLM} API Key`}
</label>
<div className="relative">
<input
type={llmConfig.LLM === 'ollama' ? 'text' : showApiKey ? 'text' : 'password'}
value={llmConfig.LLM === 'ollama' ? llmConfig.OLLAMA_URL : 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={llmConfig.LLM === 'ollama' ? 'http://localhost:11434' : `Enter your ${llmConfig.LLM} API key`}
/>
{llmConfig.LLM !== 'ollama' && (
<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=''
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 || downloadProgress > 0}
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

View file

@ -7,7 +7,11 @@ interface UserConfigState {
}
const initialState: UserConfigState = {
llm_config: {},
llm_config: {
LLM: "openai",
IMAGE_PROVIDER: "gpt-image-1.5",
},
can_change_keys: false,
}