Files
2026-07-13 13:08:41 +08:00

1758 lines
98 KiB
TypeScript

import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible';
import { Button } from '../ui/button';
import { ArrowUpRight, Blocks, Check, ChevronDown, ChevronLeft, ChevronUp, Eye, EyeOff, Info, Laptop, Loader2, Search } 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, WEB_SEARCH_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 { notify } from '@/components/ui/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 } from 'next/navigation';
import { getLLMConfigValidationError, handleSaveLLMConfig } from '@/utils/storeHelpers';
import { getDefaultOllamaUrl, isOllamaModelAvailable } from '@/utils/providerUtils';
import { getApiErrorMessage, getApiUrl } from '@/utils/api';
import CodexConfig from '../CodexConfig';
import { CODEX_MODELS } from '@/utils/codexModels';
import VertexAzureManualFields from '@/components/VertexAzureManualFields';
import BedrockManualFields from '@/components/BedrockManualFields';
import OpenAICompatibleImageFields from '@/components/OpenAICompatibleImageFields';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
import Image from 'next/image';
import OllamaConfig from '../OllamaConfig';
const MANUAL_MODEL_PROVIDERS = new Set(["vertex", "azure", "bedrock"]);
const LOCAL_PROVIDERS = ["ollama", "lmstudio"];
const OTHER_PROVIDERS = Object.values(LLM_PROVIDERS).filter(
(provider) => provider.value !== "codex" && !LOCAL_PROVIDERS.includes(provider.value)
);
const OTHER_PROVIDER_VALUES = new Set(OTHER_PROVIDERS.map((provider) => provider.value));
type TextProviderTab = "chatgpt" | "local" | "other";
const getTextProviderTab = (provider?: string): TextProviderTab => {
if (provider === "codex" || provider === "chatgpt") return "chatgpt";
if (LOCAL_PROVIDERS.includes(provider || "")) return "local";
return "other";
};
const WEB_SEARCH_PROVIDER_OPTIONS = [
WEB_SEARCH_PROVIDERS.auto,
WEB_SEARCH_PROVIDERS.searxng,
WEB_SEARCH_PROVIDERS.tavily,
WEB_SEARCH_PROVIDERS.exa,
WEB_SEARCH_PROVIDERS.brave,
];
const PresentonMode = ({
providerStep,
setStep,
setProviderStep,
}: {
providerStep: number,
setStep: (step: number) => void,
setProviderStep: (step: number) => void,
}) => {
const pathname = usePathname();
const userConfigState = useSelector((state: RootState) => state.userConfig);
const [openProviderSelect, setOpenProviderSelect] = useState(false);
const [textProviderTab, setTextProviderTab] = useState<TextProviderTab>("chatgpt");
const [chatGptAuthenticated, setChatGptAuthenticated] = useState(false);
const [showApiKey, setShowApiKey] = useState(false);
const [deepseekAdvancedOpen, setDeepseekAdvancedOpen] = useState(() =>
!!(userConfigState.llm_config.DEEPSEEK_BASE_URL || '').trim()
);
const [availableModels, setAvailableModels] = useState<string[]>([]);
const [openModelSelect, setOpenModelSelect] = useState(false);
const [modelsLoading, setModelsLoading] = useState(false);
const [modelsChecked, setModelsChecked] = useState(false);
const [savingConfig, setSavingConfig] = useState(false);
const [llmConfig, setLlmConfig] = useState<LLMConfig>(
userConfigState.llm_config
);
const llmConfigRef = useRef(llmConfig);
const isManualModelProvider = MANUAL_MODEL_PROVIDERS.has(llmConfig.LLM || "");
const isActiveNonChatProvider =
(textProviderTab === "local" && LOCAL_PROVIDERS.includes(llmConfig.LLM || "")) ||
(textProviderTab === "other" && OTHER_PROVIDER_VALUES.has(llmConfig.LLM || ""));
const handleProviderChange = (provider: string) => {
trackEvent(MixpanelEvent.Onboarding_Text_Provider_Selected, {
provider,
provider_label: LLM_PROVIDERS[provider]?.label || provider,
provider_group: LOCAL_PROVIDERS.includes(provider) ? "local" : "other",
text_provider_tab: textProviderTab,
selection_source: "provider_control",
});
setLlmConfig(prev => ({
...prev,
LLM: provider,
...(provider === "ollama" && !prev.OLLAMA_URL?.trim()
? { OLLAMA_URL: getDefaultOllamaUrl() }
: {}),
}));
setOpenProviderSelect(false);
setAvailableModels([]);
setModelsChecked(false);
if (currentModelField) {
setLlmConfig(prev => ({
...prev,
[currentModelField]: ''
}));
}
};
const currentModelField = useMemo(() => {
switch (llmConfig.LLM) {
case 'openai':
return 'OPENAI_MODEL';
case 'deepseek':
return 'DEEPSEEK_MODEL';
case 'google':
return 'GOOGLE_MODEL';
case 'vertex':
return 'VERTEX_MODEL';
case 'azure':
return 'AZURE_OPENAI_MODEL';
case 'bedrock':
return 'BEDROCK_MODEL';
case 'openrouter':
return 'OPENROUTER_MODEL';
case 'fireworks':
return 'FIREWORKS_MODEL';
case 'together':
return 'TOGETHER_MODEL';
case 'cerebras':
return 'CEREBRAS_MODEL';
case 'anthropic':
return 'ANTHROPIC_MODEL';
case 'ollama':
return 'OLLAMA_MODEL';
case 'custom':
return 'CUSTOM_MODEL';
case 'litellm':
return 'LITELLM_MODEL';
case 'lmstudio':
return 'LMSTUDIO_MODEL';
default:
return '';
}
}, [llmConfig.LLM]);
const currentApiKeyField = useMemo(() => {
switch (llmConfig.LLM) {
case 'openai':
return 'OPENAI_API_KEY';
case 'deepseek':
return 'DEEPSEEK_API_KEY';
case 'google':
return 'GOOGLE_API_KEY';
case 'vertex':
return 'VERTEX_API_KEY';
case 'azure':
return 'AZURE_OPENAI_API_KEY';
case 'bedrock':
return 'BEDROCK_API_KEY';
case 'openrouter':
return 'OPENROUTER_API_KEY';
case 'fireworks':
return 'FIREWORKS_API_KEY';
case 'together':
return 'TOGETHER_API_KEY';
case 'cerebras':
return 'CEREBRAS_API_KEY';
case 'anthropic':
return 'ANTHROPIC_API_KEY';
case 'custom':
return 'CUSTOM_LLM_API_KEY';
case 'litellm':
return 'LITELLM_API_KEY';
case 'lmstudio':
return 'LMSTUDIO_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 currentDeepseekBaseUrl = (llmConfig.DEEPSEEK_BASE_URL || '').trim();
const currentLitellmUrl = (llmConfig.LITELLM_BASE_URL || '').trim();
const currentLmStudioUrl = (llmConfig.LMSTUDIO_BASE_URL || '').trim();
const currentFireworksUrl = (llmConfig.FIREWORKS_BASE_URL || '').trim();
const currentTogetherUrl = (llmConfig.TOGETHER_BASE_URL || '').trim();
const currentOllamaUrl = llmConfig.OLLAMA_URL || '';
const providerApiKeyLabel =
llmConfig.LLM === 'custom'
? 'Custom LLM API Key'
: llmConfig.LLM === 'deepseek'
? 'DeepSeek API Key'
: llmConfig.LLM === 'vertex'
? 'Vertex API Key'
: llmConfig.LLM === 'azure'
? 'Azure OpenAI API Key'
: llmConfig.LLM === 'bedrock'
? 'Bedrock API Key (optional)'
: llmConfig.LLM === 'openrouter'
? 'OpenRouter API Key'
: llmConfig.LLM === 'fireworks'
? 'Fireworks API Key'
: llmConfig.LLM === 'together'
? 'Together API Key'
: llmConfig.LLM === 'cerebras'
? 'Cerebras API Key'
: llmConfig.LLM === 'litellm'
? 'LiteLLM API key (optional)'
: llmConfig.LLM === 'lmstudio'
? 'LM Studio API key (optional)'
: `${llmConfig.LLM} API Key`;
useEffect(() => {
if (currentDeepseekBaseUrl) setDeepseekAdvancedOpen(true);
}, [currentDeepseekBaseUrl]);
const getSelectedTextModel = (config: LLMConfig): string => {
switch (config.LLM) {
case 'openai':
return config.OPENAI_MODEL || '';
case 'deepseek':
return config.DEEPSEEK_MODEL || '';
case 'google':
return config.GOOGLE_MODEL || '';
case 'vertex':
return config.VERTEX_MODEL || '';
case 'azure':
return config.AZURE_OPENAI_MODEL || '';
case 'bedrock':
return config.BEDROCK_MODEL || '';
case 'openrouter':
return config.OPENROUTER_MODEL || '';
case 'fireworks':
return config.FIREWORKS_MODEL || '';
case 'together':
return config.TOGETHER_MODEL || '';
case 'cerebras':
return config.CEREBRAS_MODEL || '';
case 'anthropic':
return config.ANTHROPIC_MODEL || '';
case 'ollama':
return config.OLLAMA_MODEL || '';
case 'custom':
return config.CUSTOM_MODEL || '';
case 'litellm':
return config.LITELLM_MODEL || '';
case 'lmstudio':
return config.LMSTUDIO_MODEL || '';
case 'chatgpt':
case 'codex':
return config.CODEX_MODEL || '';
default:
return '';
}
};
const getSelectedImageQuality = (config: LLMConfig): string => {
if (config.IMAGE_PROVIDER === 'dall-e-3') return config.DALL_E_3_QUALITY || '';
if (config.IMAGE_PROVIDER === 'gpt-image-1.5') return config.GPT_IMAGE_1_5_QUALITY || '';
return '';
};
const handleTextProviderTabChange = (tab: string) => {
const nextTab = tab as TextProviderTab;
const nextProvider =
nextTab === "chatgpt"
? "codex"
: nextTab === "local"
? "ollama"
: OTHER_PROVIDERS[0].value;
const providerMatchesTab =
(nextTab === "chatgpt" && (llmConfig.LLM === "codex" || llmConfig.LLM === "chatgpt")) ||
(nextTab === "local" && LOCAL_PROVIDERS.includes(llmConfig.LLM || "")) ||
(nextTab === "other" && OTHER_PROVIDER_VALUES.has(llmConfig.LLM || ""));
trackEvent(MixpanelEvent.Onboarding_Text_Provider_Tab_Selected, {
tab: nextTab,
previous_tab: textProviderTab,
});
if (!providerMatchesTab) {
trackEvent(MixpanelEvent.Onboarding_Text_Provider_Selected, {
provider: nextProvider,
provider_label: LLM_PROVIDERS[nextProvider]?.label || nextProvider,
provider_group: nextTab,
text_provider_tab: nextTab,
selection_source: "tab_default",
});
}
setTextProviderTab(nextTab);
};
const fetchAvailableModels = async () => {
if (isManualModelProvider) return;
if (llmConfig.LLM === 'openai' && !currentApiKey) return;
if (llmConfig.LLM === 'deepseek' && !currentApiKey) return;
if (llmConfig.LLM === 'google' && !currentApiKey) return;
if (llmConfig.LLM === 'anthropic' && !currentApiKey) return;
if (llmConfig.LLM === 'openrouter' && !currentApiKey) return;
if (llmConfig.LLM === 'fireworks' && !currentApiKey) return;
if (llmConfig.LLM === 'together' && !currentApiKey) return;
if (llmConfig.LLM === 'cerebras' && !currentApiKey) return;
if (llmConfig.LLM === 'custom' && !llmConfig.CUSTOM_LLM_URL) return;
if (llmConfig.LLM === 'litellm' && !currentLitellmUrl) return;
setModelsLoading(true);
try {
let response: Response;
if (llmConfig.LLM === 'google') {
response = await fetch(getApiUrl('/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(getApiUrl('/api/v1/ppt/anthropic/models/available'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
api_key: currentApiKey
}),
});
} else {
const openAiCompatibleUrl =
llmConfig.LLM === 'custom'
? llmConfig.CUSTOM_LLM_URL
: llmConfig.LLM === 'deepseek'
? currentDeepseekBaseUrl || LLM_PROVIDERS[llmConfig.LLM!]?.url || ''
: llmConfig.LLM === 'litellm'
? currentLitellmUrl
: llmConfig.LLM === 'lmstudio'
? currentLmStudioUrl || LLM_PROVIDERS[llmConfig.LLM!]?.url || ''
: llmConfig.LLM === 'fireworks'
? currentFireworksUrl || LLM_PROVIDERS[llmConfig.LLM!]?.url || ''
: llmConfig.LLM === 'together'
? currentTogetherUrl || LLM_PROVIDERS[llmConfig.LLM!]?.url || ''
: LLM_PROVIDERS[llmConfig.LLM!]?.url || '';
response = await fetch(getApiUrl('/api/v1/ppt/openai/models/available'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: openAiCompatibleUrl,
api_key: currentApiKey
}),
});
}
if (response.ok) {
const data = await response.json();
const normalizedModels: string[] = 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 === 'deepseek'
? 'deepseek-chat'
: llmConfig.LLM === 'google'
? 'models/gemini-2.5-flash'
: llmConfig.LLM === 'anthropic'
? 'claude-sonnet-4-20250514'
: llmConfig.LLM === 'openrouter'
? 'openai/gpt-4o'
: llmConfig.LLM === 'fireworks'
? 'accounts/fireworks/models/llama-v3p1-8b-instruct'
: llmConfig.LLM === 'together'
? 'openai/gpt-oss-20b'
: llmConfig.LLM === 'cerebras'
? 'llama-3.3-70b'
: llmConfig.LLM === 'litellm'
? 'gpt-4.1'
: llmConfig.LLM === 'lmstudio'
? 'openai/gpt-oss-20b'
: normalizedModels[0];
const nextModel = normalizedModels.includes(preferredDefault) ? preferredDefault : normalizedModels[0];
setLlmConfig(prev => ({
...prev,
[currentModelField]: nextModel
}));
}
} else {
const message = await getApiErrorMessage(
response,
`The server could not list ${LLM_PROVIDERS[llmConfig.LLM!]?.label} models. Check your API key or endpoint and try again.`
);
console.error('Failed to fetch models');
setAvailableModels([]);
setModelsChecked(true);
notify.error("Could not load models", message);
}
} catch (error) {
console.error('Error fetching models:', error);
notify.error(
llmConfig.LLM === "ollama" ? "Could not connect to Ollama" : "Could not load models",
error instanceof Error
? error.message
: "The server could not list models. Check your API key or endpoint and try again."
);
setAvailableModels([]);
setModelsChecked(true);
if (llmConfig.LLM === "ollama") {
setLlmConfig(prev => ({ ...prev, OLLAMA_MODEL: "" }));
}
} 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 || 'standard'} onValueChange={(value) => {
trackEvent(MixpanelEvent.Onboarding_Image_Quality_Selected, {
image_provider: "dall-e-3",
quality: 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 || 'low'}
onValueChange={(value) => {
trackEvent(MixpanelEvent.Onboarding_Image_Quality_Selected, {
image_provider: "gpt-image-1.5",
quality: 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 renderSelectedImageProviderConfig = () => {
if (!llmConfig.IMAGE_PROVIDER || !IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER]) return null;
const provider = IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER];
return (
<div className="col-span-full rounded-[10px] border border-[#EDEEEF] bg-[#FBFBFD] p-4 shadow-[0_12px_28px_rgba(16,19,35,0.04)]">
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-[#191919]">{provider.label} setup</p>
<p className="mt-1 text-xs leading-5 text-gray-500">
Configure the selected image provider before continuing.
</p>
</div>
{provider.getApiKeyUrl && (
<a
href={provider.getApiKeyUrl}
target="_blank"
className="flex shrink-0 items-center gap-1 rounded-full border border-[#EDEEEF] bg-white px-3 py-1.5 text-xs font-medium text-[#666666] transition-colors hover:border-[#D9D6FE] hover:text-[#7A5AF8]"
>
Get API Key <ArrowUpRight className="h-3.5 w-3.5" />
</a>
)}
</div>
<div className="space-y-4">
{provider.value === "openai_compatible" ? (
<OpenAICompatibleImageFields
layout="stacked"
baseUrl={llmConfig.OPENAI_COMPAT_IMAGE_BASE_URL || ""}
apiKey={llmConfig.OPENAI_COMPAT_IMAGE_API_KEY || ""}
model={llmConfig.OPENAI_COMPAT_IMAGE_MODEL || ""}
onBaseUrlChange={(v) =>
setLlmConfig((prev) => ({
...prev,
OPENAI_COMPAT_IMAGE_BASE_URL: v,
}))
}
onApiKeyChange={(v) =>
setLlmConfig((prev) => ({
...prev,
OPENAI_COMPAT_IMAGE_API_KEY: v,
}))
}
onModelChange={(v) =>
setLlmConfig((prev) => ({
...prev,
OPENAI_COMPAT_IMAGE_MODEL: v,
}))
}
/>
) : provider.value === "comfyui" ? (
<>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
ComfyUI Server URL
</label>
<input
type="text"
placeholder="http://192.168.1.7:8188"
className="h-12 w-full rounded-lg border border-gray-300 px-4 py-2.5 outline-none transition-colors focus:border-[#7A5AF8] focus:ring-2 focus:ring-[#7A5AF8]/20"
value={llmConfig.COMFYUI_URL || ""}
onChange={(e) => {
setLlmConfig(prev => ({
...prev,
COMFYUI_URL: e.target.value
}));
}}
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Workflow JSON
</label>
<textarea
placeholder='Paste your ComfyUI workflow JSON here (export via "Export (API)" in ComfyUI)'
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 font-mono text-xs outline-none transition-colors focus:border-[#7A5AF8] focus:ring-2 focus:ring-[#7A5AF8]/20"
rows={3}
value={llmConfig.COMFYUI_WORKFLOW || ""}
onChange={(e) => {
setLlmConfig((prev) => ({
...prev,
COMFYUI_WORKFLOW: e.target.value
}));
}}
/>
</div>
</>
) : provider.value === "open_webui" ? (
<>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Open WebUI URL
</label>
<input
type="text"
placeholder="http://localhost:3000/api/v1"
className="h-12 w-full rounded-lg border border-gray-300 px-4 py-2.5 outline-none transition-colors focus:border-[#7A5AF8] focus:ring-2 focus:ring-[#7A5AF8]/20"
value={llmConfig.OPEN_WEBUI_IMAGE_URL || ""}
onChange={(e) => {
setLlmConfig(prev => ({
...prev,
OPEN_WEBUI_IMAGE_URL: e.target.value
}));
}}
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
API Key (optional)
</label>
<div className="relative">
<input
type={showApiKey ? "text" : "password"}
placeholder="API key"
className="h-12 w-full rounded-lg border border-gray-300 px-4 py-2.5 pr-12 outline-none transition-colors focus:border-[#7A5AF8] focus:ring-2 focus:ring-[#7A5AF8]/20"
value={llmConfig.OPEN_WEBUI_IMAGE_API_KEY || ""}
onChange={(e) => {
setLlmConfig(prev => ({
...prev,
OPEN_WEBUI_IMAGE_API_KEY: e.target.value
}));
}}
/>
<button
type="button"
onClick={() => setShowApiKey((prev) => !prev)}
className="absolute right-2 top-1/2 -translate-y-1/2 cursor-pointer bg-white px-2 py-1"
>
{showApiKey ? <Eye className="h-4 w-4 text-gray-500" /> : <EyeOff className="h-4 w-4 text-gray-500" />}
</button>
</div>
</div>
</>
) : (
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
{provider.apiKeyFieldLabel}
</label>
<div className="relative">
<input
type={showApiKey ? "text" : "password"}
placeholder={`Enter your ${provider.apiKeyFieldLabel}`}
className="h-12 w-full rounded-lg border border-gray-300 px-4 py-2.5 pr-12 outline-none transition-colors focus:border-[#7A5AF8] focus:ring-2 focus:ring-[#7A5AF8]/20"
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 cursor-pointer bg-white px-2 py-1"
>
{showApiKey ? <Eye className="h-4 w-4 text-gray-500" /> : <EyeOff className="h-4 w-4 text-gray-500" />}
</button>
</div>
</div>
)}
{renderQualitySelector(llmConfig)}
</div>
</div>
);
};
const checkCurrentAuthStatus = async () => {
try {
const res = await fetch(getApiUrl("/api/v1/ppt/codex/auth/status"));
if (!res.ok) {
return false;
}
const data = await res.json();
if (data.status === "authenticated") {
return true;
} else {
return false;
}
} catch {
return false;
}
};
const handleSaveConfig = async () => {
try {
if (llmConfig.LLM === 'codex') {
const isAuthenticated = await checkCurrentAuthStatus();
if (!isAuthenticated) {
trackEvent(MixpanelEvent.Onboarding_Validation_Failed, {
step_name: "text_provider",
provider: "codex",
validation_error: "Please sign in to ChatGPT to continue.",
});
notify.error("Sign in required", "Please sign in to ChatGPT to continue.");
return;
}
}
const validationError = getLLMConfigValidationError(llmConfig);
if (validationError) {
trackEvent(MixpanelEvent.Onboarding_Validation_Failed, {
step_name: "web_search",
web_search_enabled: !!llmConfig.WEB_GROUNDING,
web_search_provider: llmConfig.WEB_SEARCH_PROVIDER || "auto",
validation_error: validationError,
});
notify.warning("Cannot save yet", validationError);
return;
}
setSavingConfig(true);
if (
llmConfig.LLM === "ollama" &&
llmConfig.OLLAMA_MODEL &&
!(await isOllamaModelAvailable(llmConfig.OLLAMA_MODEL, currentOllamaUrl))
) {
throw new Error(
`The selected model "${llmConfig.OLLAMA_MODEL}" is not available at ${currentOllamaUrl || "the default Ollama URL"}. Check models and select an available model.`
);
}
await handleSaveLLMConfig(llmConfig);
trackEvent(MixpanelEvent.Onboarding_Configuration_Saved, {
text_provider: llmConfig.LLM || "",
text_provider_tab: getTextProviderTab(llmConfig.LLM),
image_generation_enabled: !llmConfig.DISABLE_IMAGE_GENERATION,
image_step_skipped: !!llmConfig.DISABLE_IMAGE_GENERATION,
image_provider: llmConfig.DISABLE_IMAGE_GENERATION ? "disabled" : llmConfig.IMAGE_PROVIDER || "",
web_search_enabled: !!llmConfig.WEB_GROUNDING,
web_search_step_skipped: !llmConfig.WEB_GROUNDING,
web_search_provider: llmConfig.WEB_GROUNDING ? llmConfig.WEB_SEARCH_PROVIDER || "auto" : "disabled",
});
const textProvider = llmConfig.LLM || '';
const textModel = getSelectedTextModel(llmConfig);
const imageGenerationEnabled = !llmConfig.DISABLE_IMAGE_GENERATION;
const imageProvider = imageGenerationEnabled ? (llmConfig.IMAGE_PROVIDER || '') : 'disabled';
trackEvent(MixpanelEvent.Onboarding_Providers_Models_Selected, {
pathname,
text_provider: textProvider,
text_provider_label: LLM_PROVIDERS[textProvider]?.label || textProvider || '',
text_provider_tab: getTextProviderTab(textProvider),
text_model: textModel,
uses_chatgpt_login: textProvider === 'chatgpt' || textProvider === 'codex',
image_generation_enabled: imageGenerationEnabled,
image_step_skipped: !imageGenerationEnabled,
image_provider: imageProvider,
image_provider_label: imageGenerationEnabled
? (IMAGE_PROVIDERS[imageProvider]?.label || imageProvider || '')
: 'Image generation disabled',
image_quality: imageGenerationEnabled ? getSelectedImageQuality(llmConfig) : '',
web_search_enabled: !!llmConfig.WEB_GROUNDING,
web_search_step_skipped: !llmConfig.WEB_GROUNDING,
web_search_provider: llmConfig.WEB_GROUNDING ? (llmConfig.WEB_SEARCH_PROVIDER || "auto") : "disabled",
});
notify.success("Configuration saved", "Your configuration was saved successfully.");
trackEvent(MixpanelEvent.Onboarding_Step_Continued, {
from_step: "web_search",
to_step: "finish",
web_search_enabled: !!llmConfig.WEB_GROUNDING,
web_search_step_skipped: !llmConfig.WEB_GROUNDING,
web_search_provider: llmConfig.WEB_GROUNDING ? llmConfig.WEB_SEARCH_PROVIDER || "auto" : "disabled",
});
setStep(3)
// router.push("/upload");
} catch (error) {
notify.error("Could not save configuration", error instanceof Error ? error.message : "Failed to save configuration");
}
finally {
setSavingConfig(false);
}
};
const validateTextProvider = async () => {
if (llmConfig.LLM === 'codex') {
const isAuthenticated = await checkCurrentAuthStatus();
if (!isAuthenticated) {
notify.error("Sign in required", "Please sign in to ChatGPT to continue.");
return false;
}
}
const validationError = getLLMConfigValidationError({
...llmConfig,
DISABLE_IMAGE_GENERATION: true,
WEB_GROUNDING: false,
});
if (validationError) {
trackEvent(MixpanelEvent.Onboarding_Validation_Failed, {
step_name: "text_provider",
provider: llmConfig.LLM || "",
validation_error: validationError,
});
notify.warning("Cannot continue yet", validationError);
return false;
}
return true;
};
const handleContinue = async () => {
if (providerStep === 1) {
if (await validateTextProvider()) {
trackEvent(MixpanelEvent.Onboarding_Step_Continued, {
from_step: "text_provider",
to_step: "image_provider",
provider: llmConfig.LLM || "",
text_provider_tab: getTextProviderTab(llmConfig.LLM),
});
setProviderStep(2);
}
return;
}
if (providerStep === 2) {
const validationError = getLLMConfigValidationError({ ...llmConfig, WEB_GROUNDING: false });
if (validationError) {
trackEvent(MixpanelEvent.Onboarding_Validation_Failed, {
step_name: "image_provider",
image_generation_enabled: !llmConfig.DISABLE_IMAGE_GENERATION,
image_step_skipped: !!llmConfig.DISABLE_IMAGE_GENERATION,
image_provider: llmConfig.IMAGE_PROVIDER || "",
validation_error: validationError,
});
notify.warning("Cannot continue yet", validationError);
return;
}
trackEvent(MixpanelEvent.Onboarding_Step_Continued, {
from_step: "image_provider",
to_step: "web_search",
image_generation_enabled: !llmConfig.DISABLE_IMAGE_GENERATION,
image_step_skipped: !!llmConfig.DISABLE_IMAGE_GENERATION,
image_provider: llmConfig.DISABLE_IMAGE_GENERATION ? "disabled" : llmConfig.IMAGE_PROVIDER || "",
});
setProviderStep(3);
return;
}
await handleSaveConfig();
};
const handleBack = () => {
trackEvent(MixpanelEvent.Onboarding_Back_Clicked, {
from_step: providerStep === 1 ? "text_provider" : providerStep === 2 ? "image_provider" : "web_search",
to_step: providerStep === 1 ? "text_provider" : providerStep === 2 ? "text_provider" : "image_provider",
source: "footer_button",
});
if (providerStep > 1) {
setProviderStep(providerStep - 1);
}
};
const selectedWebProvider = WEB_SEARCH_PROVIDER_OPTIONS.find(
(provider) => provider.value === llmConfig.WEB_SEARCH_PROVIDER
);
const renderSelectedWebSearchProviderConfig = () => {
if (!selectedWebProvider) return null;
return (
<div className="col-span-full rounded-[10px] border border-[#EDEEEF] bg-[#FBFBFD] p-4 shadow-[0_12px_28px_rgba(16,19,35,0.04)]">
<div className="mb-4">
<p className="text-sm font-semibold text-[#191919]">{selectedWebProvider.label} setup</p>
<p className="mt-1 text-xs leading-5 text-gray-500">
{selectedWebProvider.description}
</p>
</div>
<div className="space-y-4">
{selectedWebProvider.value === "auto" && (
<div className="rounded-lg border border-[#D9D6FE] bg-[#F4F3FF] p-3 text-xs leading-5 text-[#5146E5]">
Presenton will use model-native web grounding when available. If the selected text model does not support it, web search stays off until you choose an external provider.
</div>
)}
{selectedWebProvider.urlField && (
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
{selectedWebProvider.urlLabel}
</label>
<input
type="url"
value={getFieldValue(selectedWebProvider.urlField)}
onChange={(event) => setLlmConfig(prev => ({ ...prev, [selectedWebProvider.urlField!]: event.target.value }))}
className="h-12 w-full rounded-lg border border-gray-300 px-4 outline-none transition-colors focus:border-[#7A5AF8] focus:ring-2 focus:ring-[#7A5AF8]/20"
placeholder="https://search.example.com"
/>
</div>
)}
{selectedWebProvider.apiKeyField && (
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
{selectedWebProvider.apiKeyLabel}
</label>
<div className="relative">
<input
type={showApiKey ? "text" : "password"}
value={getFieldValue(selectedWebProvider.apiKeyField)}
onChange={(event) => setLlmConfig(prev => ({ ...prev, [selectedWebProvider.apiKeyField!]: event.target.value }))}
className="h-12 w-full rounded-lg border border-gray-300 px-4 pr-12 outline-none transition-colors focus:border-[#7A5AF8] focus:ring-2 focus:ring-[#7A5AF8]/20"
placeholder={`Enter your ${selectedWebProvider.apiKeyLabel}`}
/>
<button
type="button"
onClick={() => setShowApiKey(prev => !prev)}
className="absolute right-2 top-1/2 -translate-y-1/2 cursor-pointer bg-white px-2 py-1"
>
{showApiKey ? <Eye className="h-4 w-4 text-gray-500" /> : <EyeOff className="h-4 w-4 text-gray-500" />}
</button>
</div>
</div>
)}
{selectedWebProvider.value !== "auto" && (
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Maximum results
</label>
<input
type="number"
min={1}
max={10}
value={llmConfig.WEB_SEARCH_MAX_RESULTS || "5"}
onChange={(event) => setLlmConfig(prev => ({ ...prev, WEB_SEARCH_MAX_RESULTS: event.target.value }))}
className="h-12 w-full rounded-lg border border-gray-300 px-4 outline-none transition-colors focus:border-[#7A5AF8] focus:ring-2 focus:ring-[#7A5AF8]/20"
/>
</div>
)}
</div>
</div>
);
};
useEffect(() => {
llmConfigRef.current = llmConfig;
}, [llmConfig]);
useEffect(() => {
const config = llmConfigRef.current;
const stepName =
providerStep === 1
? "text_provider"
: providerStep === 2
? "image_provider"
: "web_search";
const stepProps =
providerStep === 1
? {
text_provider_tab: getTextProviderTab(config.LLM),
provider: config.LLM || "",
}
: providerStep === 2
? {
image_generation_enabled: !config.DISABLE_IMAGE_GENERATION,
image_step_skipped: !!config.DISABLE_IMAGE_GENERATION,
image_provider: config.DISABLE_IMAGE_GENERATION ? "disabled" : config.IMAGE_PROVIDER || "",
}
: {
web_search_enabled: !!config.WEB_GROUNDING,
web_search_step_skipped: !config.WEB_GROUNDING,
web_search_provider: config.WEB_GROUNDING ? config.WEB_SEARCH_PROVIDER || "auto" : "disabled",
};
trackEvent(MixpanelEvent.Onboarding_Step_Viewed, {
step_name: stepName,
step_number: providerStep,
...stepProps,
});
}, [providerStep]);
useEffect(() => {
const nextProvider =
textProviderTab === "chatgpt"
? "codex"
: textProviderTab === "local"
? "ollama"
: OTHER_PROVIDERS[0].value;
const providerMatchesTab =
(textProviderTab === "chatgpt" && llmConfig.LLM === "codex") ||
(textProviderTab === "local" && LOCAL_PROVIDERS.includes(llmConfig.LLM || "")) ||
(textProviderTab === "other" && OTHER_PROVIDER_VALUES.has(llmConfig.LLM || ""));
if (!providerMatchesTab) {
setLlmConfig(prev => ({
...prev,
LLM: nextProvider,
}));
setAvailableModels([]);
setModelsChecked(false);
}
}, [textProviderTab, llmConfig.LLM]);
const imageProviderRows = Object.values(IMAGE_PROVIDERS).reduce(
(rows, provider, index) => {
if (index % 3 === 0) rows.push([]);
rows[rows.length - 1].push(provider);
return rows;
},
[] as Array<Array<(typeof IMAGE_PROVIDERS)[keyof typeof IMAGE_PROVIDERS]>>
);
const webSearchProviderRows = WEB_SEARCH_PROVIDER_OPTIONS.reduce(
(rows, provider, index) => {
if (index % 3 === 0) rows.push([]);
rows[rows.length - 1].push(provider);
return rows;
},
[] as Array<Array<(typeof WEB_SEARCH_PROVIDER_OPTIONS)[number]>>
);
return (
<div className='w-full max-w-[660px] font-syne pb-10'>
<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=''>
<h2 className='mb-4 text-black text-[26px] font-normal font-unbounded '>
{providerStep === 1 ? "Choose your text provider" : providerStep === 2 ? "Choose your image provider" : "Configure web search"}
</h2>
<p className='text-[#000000CC] text-xl font-normal font-syne'>
{providerStep === 1
? "Start with ChatGPT, run a local model, or connect another AI provider."
: providerStep === 2
? "Choose how Presenton creates visuals, or continue without image generation."
: "Add current web context to presentations, or continue with web search disabled."}
</p>
</div>
<div className='flex items-center gap-2 bg-[#F0F3F9B2] rounded-[8px] px-6 py-2.5 my-[54px]'>
<Info className='w-4 h-4 fill-[#003399] stroke-white' />
<p className='text-sm text-[#5F6062] font-medium'>Runs locally on your device. Your API keys and generation setup stay on your machine.</p>
</div>
{providerStep === 1 && <>
{/* Text Provider */}
<div className='p-3 border border-[#EDEEEF] rounded-[11px] bg-white '>
<div className="flex items-center gap-[24.3px] mb-[42px]">
<div className='w-[74px] h-[74px] rounded-[4px] pt-[16.8px] pr-[17.15px] pb-[17.2px] pl-[16.85px] 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 content comes from
</p>
</div>
</div>
<Tabs
value={textProviderTab}
onValueChange={handleTextProviderTabChange}
className="w-full"
>
<TabsList className="grid h-14 w-full grid-cols-3 rounded-[10px] border border-[#EDEEEF] bg-[#F6F6F9] p-1 shadow-inner shadow-black/[0.02]">
<TabsTrigger value="chatgpt" className="h-12 gap-2 rounded-[8px] border border-transparent px-4 text-sm font-semibold text-[#5F6062] transition-all hover:text-[#191919] data-[state=active]:border-[#D9D6FE] data-[state=active]:bg-white data-[state=active]:text-[#191919] data-[state=active]:shadow-[0_8px_24px_rgba(16,19,35,0.08)]">
<Image src="/providers/openai.png" alt="" width={16} height={16} className="object-contain" />
ChatGPT
</TabsTrigger>
<TabsTrigger value="local" className="h-12 gap-2 rounded-[8px] border border-transparent px-4 text-sm font-semibold text-[#5F6062] transition-all hover:text-[#191919] data-[state=active]:border-[#D9D6FE] data-[state=active]:bg-white data-[state=active]:text-[#191919] data-[state=active]:shadow-[0_8px_24px_rgba(16,19,35,0.08)]">
<Laptop className="h-4 w-4" />
Local
</TabsTrigger>
<TabsTrigger value="other" className="h-12 gap-2 rounded-[8px] border border-transparent px-4 text-sm font-semibold text-[#5F6062] transition-all hover:text-[#191919] data-[state=active]:border-[#D9D6FE] data-[state=active]:bg-white data-[state=active]:text-[#191919] data-[state=active]:shadow-[0_8px_24px_rgba(16,19,35,0.08)]">
<Blocks className="h-4 w-4" />
AI Providers
</TabsTrigger>
</TabsList>
<p className="mt-3 text-xs leading-relaxed text-gray-500">
{textProviderTab === "chatgpt"
? "Connect your ChatGPT account and choose a supported model."
: textProviderTab === "local"
? "Run models on your machine with Ollama or LM Studio."
: "Connect hosted AI providers using an API key or custom endpoint."}
</p>
<TabsContent value="chatgpt" className="mt-6">
<CodexConfig
codexModel={llmConfig.CODEX_MODEL || ''}
onInputChange={(value, field) => {
const normalizedField = field === 'codex_model' ? 'CODEX_MODEL' : field;
setLlmConfig(prev => ({
...prev,
[normalizedField]: value
}));
}}
onAuthStatusChange={setChatGptAuthenticated}
/>
{chatGptAuthenticated && (llmConfig.LLM === "codex" || llmConfig.LLM === "chatgpt") && (
<div className="mt-5">
<label className="mb-2 block text-sm font-medium text-gray-700">ChatGPT model</label>
<Select
value={llmConfig.CODEX_MODEL || ""}
onValueChange={(value) => {
trackEvent(MixpanelEvent.Onboarding_Text_Model_Selected, {
provider: "codex",
model: value,
text_provider_tab: textProviderTab,
});
setLlmConfig(prev => ({ ...prev, CODEX_MODEL: value }));
}}
>
<SelectTrigger className="h-12 w-full rounded-lg border-gray-300">
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
{CODEX_MODELS.map((model) => (
<SelectItem key={model.id} value={model.id}>{model.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</TabsContent>
<TabsContent value="local" className="mt-6">
<div className="grid grid-cols-2 gap-3">
{LOCAL_PROVIDERS.map((value) => {
const provider = LLM_PROVIDERS[value];
return (
<button
type="button"
key={value}
onClick={() => handleProviderChange(value)}
className={cn(
"flex items-center gap-3 rounded-xl border p-4 text-left transition-colors hover:bg-[#F7F6F9]",
llmConfig.LLM === value ? "border-[#7A5AF8] bg-[#F4F3FF]" : "border-[#EDEEEF]"
)}
>
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-white border border-[#EDEEEF]">
{provider.icon ? <img src={provider.icon} alt="" className="h-7 w-7 object-contain" /> : <span className="font-semibold">LM</span>}
</div>
<div>
<p className="text-sm font-medium text-[#191919]">{provider.label}</p>
<p className="mt-1 text-xs text-[#777]">{provider.description}</p>
</div>
</button>
);
})}
</div>
</TabsContent>
<TabsContent value="other" className="mt-6">
<div className="flex w-full max-w-[300px] flex-col items-start gap-4">
<div className="flex w-full flex-col justify-start">
<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="flex h-12 w-full px-4 py-4 outline-none border border-[#E8E8E9] 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 && OTHER_PROVIDER_VALUES.has(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-full "
align="end"
>
<Command>
<CommandInput placeholder="Search provider..." />
<CommandList className='hide-scrollbar'>
<CommandEmpty>No provider found.</CommandEmpty>
<CommandGroup >
{OTHER_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>
</TabsContent>
</Tabs>
{isActiveNonChatProvider && (
<div className="mt-6 flex w-full max-w-[300px] flex-col items-start gap-4">
<div className="relative flex w-full flex-col justify-end items-start">
<div className="flex flex-col justify-start w-full ">
{llmConfig.LLM === 'ollama' ? (
<OllamaConfig
ollamaModel={llmConfig.OLLAMA_MODEL || ""}
ollamaUrl={currentOllamaUrl}
onInputChange={(value, field) => {
const normalizedField =
field === "ollama_url"
? "OLLAMA_URL"
: field === "ollama_model"
? "OLLAMA_MODEL"
: field;
if (typeof value !== "string") return;
setLlmConfig((prev) => ({
...prev,
[normalizedField]: value,
}));
}}
/>
) : llmConfig.LLM === 'bedrock' ? (
<BedrockManualFields
llmConfig={llmConfig}
onPatch={(patch) => {
setLlmConfig((prev) => ({ ...prev, ...patch }));
}}
/>
) : (
<>
<div className='flex items-center justify-between mb-2'>
<label className="block text-sm font-medium capitalize text-gray-700 ">
{providerApiKeyLabel}
</label>
{llmConfig.LLM && LLM_PROVIDERS[llmConfig.LLM!]?.getApiKeyUrl && <a href={LLM_PROVIDERS[llmConfig.LLM!]?.getApiKeyUrl || ""} target='_blank' className='text-[#666666] text-xs font-normal flex items-center gap-1'>Get API Key <ArrowUpRight className='w-3.5 h-3.5' /></a>}
</div>
<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 ${providerApiKeyLabel}`}
/>
<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"
/>
)}
{llmConfig.LLM === 'deepseek' && (
<Collapsible
open={deepseekAdvancedOpen}
onOpenChange={setDeepseekAdvancedOpen}
className="mt-3"
>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full min-w-0 items-center justify-between gap-2 rounded-lg border border-gray-200 bg-[#F9F9FA] px-3 py-2.5 text-left text-sm font-medium text-gray-800 transition-colors hover:bg-gray-100"
>
<span>Advanced settings</span>
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 text-gray-600 transition-transform duration-200",
deepseekAdvancedOpen && "rotate-180"
)}
aria-hidden
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 overflow-hidden">
<div className="space-y-1.5 border-t border-gray-100 pt-3">
<label className="block text-sm font-medium text-gray-700">
DeepSeek base URL (optional)
</label>
<input
type="text"
value={llmConfig.DEEPSEEK_BASE_URL || ''}
onChange={(e) => setLlmConfig(prev => ({
...prev,
DEEPSEEK_BASE_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="https://api.deepseek.com/v1"
/>
</div>
</CollapsibleContent>
</Collapsible>
)}
{llmConfig.LLM === 'litellm' && (
<>
<label className="mt-3 block text-sm font-medium text-gray-700 mb-2">
LiteLLM base URL
</label>
<input
type="text"
value={llmConfig.LITELLM_BASE_URL || ''}
onChange={(e) => setLlmConfig(prev => ({
...prev,
LITELLM_BASE_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="e.g. http://host.docker.internal:4000/v1"
/>
<p className="mt-1.5 text-xs text-gray-500">
OpenAI-compatible root (usually ends with /v1); /v1 is added if omitted. API key above is optional for local proxies with no auth.
</p>
</>
)}
{llmConfig.LLM === 'lmstudio' && (
<>
<label className="mt-3 block text-sm font-medium text-gray-700 mb-2">
LM Studio base URL
</label>
<input
type="text"
value={llmConfig.LMSTUDIO_BASE_URL || ''}
onChange={(e) => setLlmConfig(prev => ({
...prev,
LMSTUDIO_BASE_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:1234/v1"
/>
<p className="mt-1.5 text-xs text-gray-500">
Defaults to localhost:1234/v1, and /v1 is added automatically when omitted.
</p>
</>
)}
{llmConfig.LLM === 'fireworks' && (
<>
<label className="mt-3 block text-sm font-medium text-gray-700 mb-2">
Fireworks base URL (optional)
</label>
<input
type="text"
value={llmConfig.FIREWORKS_BASE_URL || ''}
onChange={(e) => setLlmConfig(prev => ({
...prev,
FIREWORKS_BASE_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="https://api.fireworks.ai/inference/v1"
/>
</>
)}
{llmConfig.LLM === 'together' && (
<>
<label className="mt-3 block text-sm font-medium text-gray-700 mb-2">
Together base URL (optional)
</label>
<input
type="text"
value={llmConfig.TOGETHER_BASE_URL || ''}
onChange={(e) => setLlmConfig(prev => ({
...prev,
TOGETHER_BASE_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="https://api.together.ai/v1"
/>
</>
)}
{(llmConfig.LLM === 'vertex' || llmConfig.LLM === 'azure') && (
<VertexAzureManualFields
key={llmConfig.LLM}
provider={llmConfig.LLM === 'vertex' ? 'vertex' : 'azure'}
llmConfig={llmConfig}
onPatch={(patch) => {
setLlmConfig((prev) => ({ ...prev, ...patch }));
}}
/>
)}
</div>
{!isManualModelProvider && llmConfig.LLM !== 'chatgpt' && llmConfig.LLM !== 'codex' && llmConfig.LLM !== 'ollama' && (!modelsChecked || availableModels.length === 0) && (
<button
onClick={fetchAvailableModels}
disabled={
modelsLoading ||
(llmConfig.LLM === 'openai' && !currentApiKey) ||
(llmConfig.LLM === 'deepseek' && !currentApiKey) ||
(llmConfig.LLM === 'google' && !currentApiKey) ||
(llmConfig.LLM === 'anthropic' && !currentApiKey) ||
(llmConfig.LLM === 'openrouter' && !currentApiKey) ||
(llmConfig.LLM === 'fireworks' && !currentApiKey) ||
(llmConfig.LLM === 'together' && !currentApiKey) ||
(llmConfig.LLM === 'cerebras' && !currentApiKey) ||
(llmConfig.LLM === 'custom' && !llmConfig.CUSTOM_LLM_URL) ||
(llmConfig.LLM === 'litellm' && !currentLitellmUrl)
}
className={`mt-4 py-2.5 bg-[#EDEEEF] disabled:opacity-50 disabled:cursor-not-allowed px-3.5 w-full 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-[#EDEEEF]/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>
) : (
"Validate & Load Models"
)}
</button>
)}
</div>
</div>
)}
<div className="mt-4 flex w-full max-w-[222px] items-start gap-4">
{/* Model Selection - only show if models are available */}
{isActiveNonChatProvider && !isManualModelProvider && llmConfig.LLM !== 'ollama' && modelsChecked && availableModels.length > 0 && (
<div className="w-full">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{`Select ${LLM_PROVIDERS[llmConfig.LLM!]?.label} Model`}
</label>
<div className="w-full">
<Popover
open={openModelSelect}
onOpenChange={(open) => {
setOpenModelSelect(open);
if (open && llmConfig.LLM === "ollama") {
void fetchAvailableModels();
}
}}
>
<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) {
trackEvent(MixpanelEvent.Onboarding_Text_Model_Selected, {
provider: llmConfig.LLM || "",
model: value,
text_provider_tab: textProviderTab,
});
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>
</>}
{providerStep === 2 && <>
{/* Image Provider */}
<div className={`p-3 border border-[#EDEEEF] rounded-[11px] relative mt-5 bg-white ${llmConfig.DISABLE_IMAGE_GENERATION ? "bg-[#F9FAFB]" : ""}`}>
<ToolTip content="Enable/Disable Image Generation" className='flex justify-end items-center absolute top-3 right-3'>
<div className='flex justify-end items-center'>
<Switch
checked={!llmConfig.DISABLE_IMAGE_GENERATION}
className='data-[state=checked]:bg-[#4791FF] h-[22px] w-[36px] data-[state=unchecked]:bg-[#E2E0E1]'
onCheckedChange={(checked) => {
trackEvent(MixpanelEvent.Onboarding_Image_Generation_Toggled, {
enabled: checked,
image_step_skipped: !checked,
});
setLlmConfig(prev => ({
...prev,
DISABLE_IMAGE_GENERATION: !checked
}));
}}
/>
</div>
</ToolTip>
<div className={` flex items-center gap-6 ${llmConfig.DISABLE_IMAGE_GENERATION ? "" : "mb-[42px]"}`}>
<div className='w-[74px] h-[74px] 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 flex-col 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="grid w-full grid-cols-2 gap-3 sm:grid-cols-3">
{imageProviderRows.map((row, rowIndex) => (
<React.Fragment key={`image-provider-row-${rowIndex}`}>
{row.map((provider) => (
<button
type="button"
key={provider.value}
onClick={() => {
trackEvent(MixpanelEvent.Onboarding_Image_Provider_Selected, {
image_provider: provider.value,
image_provider_label: provider.label,
});
setLlmConfig(prev => ({ ...prev, IMAGE_PROVIDER: provider.value }));
}}
className={cn(
"group flex min-h-24 flex-col items-center justify-center gap-2 rounded-[10px] border p-3 text-center transition-all hover:border-[#D9D6FE] hover:bg-[#F7F6F9]",
llmConfig.IMAGE_PROVIDER === provider.value
? "border-[#7A5AF8] bg-[#F4F3FF] shadow-[0_10px_24px_rgba(122,90,248,0.12)]"
: "border-[#EDEEEF] bg-white"
)}
>
<span
className={cn(
"flex h-10 w-10 items-center justify-center rounded-lg border bg-white transition-colors",
llmConfig.IMAGE_PROVIDER === provider.value
? "border-[#D9D6FE]"
: "border-[#EDEEEF] group-hover:border-[#D9D6FE]"
)}
>
{provider.icon
? <img src={provider.icon} alt="" className="h-7 w-7 object-contain" />
: <span className="text-sm font-semibold">{provider.label.slice(0, 1)}</span>}
</span>
<span className="text-xs font-semibold text-[#191919]">{provider.label}</span>
</button>
))}
{row.some((provider) => provider.value === llmConfig.IMAGE_PROVIDER) && renderSelectedImageProviderConfig()}
</React.Fragment>
))}
</div>
</div>
</div>
)}
</div>
</>}
{providerStep === 3 && (
<div className={`relative rounded-[11px] border border-[#EDEEEF] p-3 ${llmConfig.WEB_GROUNDING ? "bg-white" : "bg-[#F9FAFB]"}`}>
<ToolTip content="Enable/Disable Web Search" className='absolute right-3 top-3 flex items-center justify-end'>
<div className='flex items-center justify-end'>
<Switch
checked={!!llmConfig.WEB_GROUNDING}
className='data-[state=checked]:bg-[#4791FF] h-[22px] w-[36px] data-[state=unchecked]:bg-[#E2E0E1]'
onCheckedChange={(checked) => {
trackEvent(MixpanelEvent.Onboarding_Web_Search_Toggled, {
enabled: checked,
web_search_step_skipped: !checked,
});
setLlmConfig(prev => ({
...prev,
WEB_GROUNDING: checked,
}));
}}
/>
</div>
</ToolTip>
<div className="mb-[42px] flex items-center gap-6">
<div className='flex h-[74px] w-[74px] items-center justify-center rounded-[4px] bg-[#F4F3FF]'>
<Search className="h-9 w-9 text-[#5146E5]" />
</div>
<div>
<h3 className="text-xl font-normal text-[#191919]">Web Search Settings</h3>
<p className="text-sm text-gray-500">Bring current information into generated presentations</p>
</div>
</div>
{llmConfig.WEB_GROUNDING && <div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Select Web Search Provider</label>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{webSearchProviderRows.map((row, rowIndex) => (
<React.Fragment key={`web-search-provider-row-${rowIndex}`}>
{row.map((provider) => (
<button
type="button"
key={provider.value}
onClick={() => {
trackEvent(MixpanelEvent.Onboarding_Web_Search_Provider_Selected, {
web_search_provider: provider.value,
web_search_provider_label: provider.label,
});
setLlmConfig(prev => ({
...prev,
WEB_GROUNDING: true,
WEB_SEARCH_PROVIDER: provider.value,
}));
}}
className={cn(
"group flex min-h-32 flex-col items-center justify-center gap-2 rounded-[10px] border p-3 text-center transition-all hover:border-[#D9D6FE] hover:bg-[#F7F6F9]",
selectedWebProvider?.value === provider.value
? "border-[#7A5AF8] bg-[#F4F3FF] shadow-[0_10px_24px_rgba(122,90,248,0.12)]"
: "border-[#EDEEEF] bg-white"
)}
>
<span
className={cn(
"flex h-10 w-10 items-center justify-center rounded-lg border bg-white transition-colors",
selectedWebProvider?.value === provider.value
? "border-[#D9D6FE]"
: "border-[#EDEEEF] group-hover:border-[#D9D6FE]"
)}
>
{provider.icon && <img src={provider.icon} alt="" className="h-7 w-7 object-contain" />}
</span>
<span className="text-xs font-semibold text-[#191919]">{provider.label}</span>
<span className="line-clamp-2 text-[10px] leading-4 text-gray-500">{provider.description}</span>
</button>
))}
{row.some((provider) => provider.value === selectedWebProvider?.value) && renderSelectedWebSearchProviderConfig()}
</React.Fragment>
))}
</div>
</div>
</div>}
</div>
)}
<div className='fixed bottom-16 mr-8 max-w-[1440px] right-16 flex justify-end items-center gap-2.5 '>
{providerStep > 1 && (
<button
onClick={handleBack}
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={handleContinue}
className='border font-syne border-[#EDEEEF] bg-[#7C51F8] rounded-[58px] px-5 py-2.5 text-white text-xs font-semibold'>
{providerStep === 1
? "Continue to image provider"
: providerStep === 2
? llmConfig.DISABLE_IMAGE_GENERATION ? "Disable image generation & Continue" : "Continue to web search"
: llmConfig.WEB_GROUNDING ? "Save & Finish" : "Disable web search & Finish"}
</button>
</div>
</div>
)
}
export default PresentonMode