From 0c772a28e4076689574d25b818c83b6fd46fdb37 Mon Sep 17 00:00:00 2001 From: shiva raj badu Date: Sun, 29 Mar 2026 17:27:31 +0545 Subject: [PATCH] fix: present mode theme issue --- .../components/PresentationHeader.tsx | 162 +++++++++++++- .../components/PresentationMode.tsx | 9 +- .../components/PresentationPage.tsx | 22 +- .../presentation/components/ThemeSelector.tsx | 76 ++----- .../presentation/hooks/usePresentationData.ts | 42 +--- .../utils/applyPresentationThemeDom.ts | 70 ++++++ .../store/slices/presentationGeneration.ts | 9 +- .../(dashboard)/settings/SettingPage.tsx | 88 +++++++- .../(dashboard)/settings/TextProvider.tsx | 7 +- servers/nextjs/components/ui/sonner.tsx | 13 +- servers/nextjs/utils/providerUtils.ts | 80 +++++-- servers/nextjs/utils/storeHelpers.ts | 208 ++++++++++-------- 12 files changed, 546 insertions(+), 240 deletions(-) create mode 100644 electron/servers/nextjs/app/(presentation-generator)/presentation/utils/applyPresentationThemeDom.ts diff --git a/electron/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationHeader.tsx b/electron/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationHeader.tsx index a02e1b1e..fdda8bf2 100644 --- a/electron/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationHeader.tsx +++ b/electron/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationHeader.tsx @@ -7,11 +7,12 @@ import { Undo2, RotateCcw, ArrowRightFromLine, - ArrowUpRight, - + Pencil, + Check, + X, } from "lucide-react"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { useRouter, usePathname } from "next/navigation"; import { Popover, @@ -24,13 +25,14 @@ import { useDispatch, useSelector } from "react-redux"; import { RootState } from "@/store/store"; import { toast } from "sonner"; - - import { PptxPresentationModel } from "@/types/pptx_models"; import { trackEvent, MixpanelEvent } from "@/utils/mixpanel"; import { usePresentationUndoRedo } from "../hooks/PresentationUndoRedo"; import ToolTip from "@/components/ToolTip"; -import { clearPresentationData } from "@/store/slices/presentationGeneration"; +import { + clearPresentationData, + updateTitle, +} from "@/store/slices/presentationGeneration"; import { clearHistory } from "@/store/slices/undoRedoSlice"; import { Separator } from "@/components/ui/separator"; import ThemeSelector from "./ThemeSelector"; @@ -38,6 +40,7 @@ import { DEFAULT_THEMES } from "../../(dashboard)/theme/components/ThemePanel/co import ThemeApi from "../../services/api/theme"; import { Theme } from "../../services/api/types"; import MarkdownRenderer from "@/components/MarkDownRender"; +import { cn } from "@/lib/utils"; const PresentationHeader = ({ presentation_id, @@ -52,6 +55,11 @@ const PresentationHeader = ({ const router = useRouter(); const [isExporting, setIsExporting] = useState(false); const [themes, setThemes] = useState([]); + const [isEditingTitle, setIsEditingTitle] = useState(false); + const [draftTitle, setDraftTitle] = useState(""); + const titleInputRef = useRef(null); + /** Avoid committing on blur when Save/Cancel was used (focus/click ordering) */ + const titleBlurIntentRef = useRef<"none" | "save" | "cancel">("none"); const pathname = usePathname(); const dispatch = useDispatch(); @@ -79,6 +87,57 @@ const PresentationHeader = ({ const { onUndo, onRedo, canUndo, canRedo } = usePresentationUndoRedo(); + useEffect(() => { + if (isEditingTitle) { + titleInputRef.current?.focus(); + titleInputRef.current?.select(); + } + }, [isEditingTitle]); + + const beginTitleEdit = () => { + if (isStreaming || !presentationData) return; + setDraftTitle(presentationData.title || ""); + setIsEditingTitle(true); + }; + + const commitTitleEdit = () => { + if (!presentationData) { + setIsEditingTitle(false); + return; + } + const trimmed = draftTitle.trim(); + const next = + trimmed || presentationData.title || "Presentation"; + if (next !== presentationData.title) { + dispatch(updateTitle(next)); + } + setIsEditingTitle(false); + }; + + const cancelTitleEdit = () => { + setDraftTitle(presentationData?.title || ""); + setIsEditingTitle(false); + }; + + const handleTitleBlur = () => { + queueMicrotask(() => { + const intent = titleBlurIntentRef.current; + titleBlurIntentRef.current = "none"; + if (intent === "cancel" || intent === "save") return; + commitTitleEdit(); + }); + }; + + const onTitleSaveMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + titleBlurIntentRef.current = "save"; + }; + + const onTitleCancelMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + titleBlurIntentRef.current = "cancel"; + }; + const get_presentation_pptx_model = async (id: string): Promise => { const response = await fetch(`/api/presentation_to_pptx_model?id=${id}`); const pptx_model = await response.json(); @@ -240,13 +299,96 @@ const PresentationHeader = ({ ); - - + const titleBlock = ( +
+ {isEditingTitle ? ( +
+ setDraftTitle(e.target.value)} + onBlur={handleTitleBlur} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + titleBlurIntentRef.current = "save"; + commitTitleEdit(); + } else if (e.key === "Escape") { + e.preventDefault(); + titleBlurIntentRef.current = "cancel"; + cancelTitleEdit(); + } + }} + placeholder="Presentation title" + className="min-w-0 flex-1 bg-transparent py-2 pr-2 font-unbounded text-base leading-tight text-[#101323] placeholder:text-[#101323]/35 outline-none border-0 focus:ring-0" + aria-label="Presentation title" + /> +
+ + + + + + +
+
+ ) : ( + + )} +
+ ); return ( <> -
-

+
+ {presentationData && !isStreaming && !isEditingTitle ? ( + {titleBlock} + ) : ( + titleBlock + )}
{isPresentationSaving &&
diff --git a/electron/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationMode.tsx b/electron/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationMode.tsx index 4d610cd2..44aab9e5 100644 --- a/electron/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationMode.tsx +++ b/electron/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationMode.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { ChevronLeft, ChevronRight, @@ -15,6 +15,7 @@ import { Button } from "@/components/ui/button"; import { Slide } from "../../types/slide"; import SlideScale from "../../components/PresentationRender"; import type { Theme } from "../../services/api/types"; +import { applyPresentationThemeToElement } from "../utils/applyPresentationThemeDom"; interface PresentationModeProps { slides: Slide[]; @@ -76,6 +77,11 @@ const PresentationMode: React.FC = ({ }; }, [isFullscreen, bumpChromeVisibility]); + useLayoutEffect(() => { + if (!theme || !rootRef.current) return; + applyPresentationThemeToElement(rootRef.current, theme); + }, [theme]); + const handlePointerActivity = useCallback(() => { bumpChromeVisibility(); }, [bumpChromeVisibility]); @@ -182,6 +188,7 @@ const PresentationMode: React.FC = ({ return (
= ({ const [error, setError] = useState(false); // Ensure /app_data and /static image paths resolve through FastAPI in Electron. - useEffect(() => { - const observer = setupImageUrlConverter(); - return () => observer?.disconnect(); - }, []); + // useEffect(() => { + // const observer = setupImageUrlConverter(); + // return () => observer?.disconnect(); + // }, []); const { presentationData, isStreaming } = useSelector( @@ -84,6 +83,15 @@ const PresentationPage: React.FC = ({ usePresentationUndoRedo(); + /** Editor tree unmounts in present mode; remount loses inline theme CSS — re-apply from Redux. */ + useLayoutEffect(() => { + if (isPresentMode) return; + const theme = presentationData?.theme; + if (!theme) return; + const el = document.getElementById("presentation-slides-wrapper"); + applyPresentationThemeToElement(el, theme); + }, [isPresentMode, presentationData?.theme]); + const onSlideChange = (newSlide: number) => { handleSlideChange(newSlide, presentationData); }; diff --git a/electron/servers/nextjs/app/(presentation-generator)/presentation/components/ThemeSelector.tsx b/electron/servers/nextjs/app/(presentation-generator)/presentation/components/ThemeSelector.tsx index 5c58df1d..4de2c2e6 100644 --- a/electron/servers/nextjs/app/(presentation-generator)/presentation/components/ThemeSelector.tsx +++ b/electron/servers/nextjs/app/(presentation-generator)/presentation/components/ThemeSelector.tsx @@ -6,74 +6,32 @@ import { Palette } from 'lucide-react'; import { useDispatch } from 'react-redux'; import { updateTheme } from '@/store/slices/presentationGeneration'; import { useRouter } from 'next/navigation'; -import { useFontLoader } from '../../hooks/useFontLoad'; +import { + applyPresentationThemeToElement, + clearPresentationThemeFromElement, +} from "../utils/applyPresentationThemeDom"; + const ThemeSelector = ({ current_theme, themes: allThemes }: { current_theme: any, themes: any[] }) => { const [currentTheme, setCurrentTheme] = useState(current_theme) const dispatch = useDispatch() const [isOpen, setIsOpen] = useState(false) const router = useRouter() const applyTheme = async (theme: any) => { - const element = document.getElementById('presentation-slides-wrapper') + const element = document.getElementById("presentation-slides-wrapper"); if (!element) return; if (allThemes.length === 0) return; - setCurrentTheme(theme) - clearTheme() - if (!theme.data.colors['graph_0']) { return; } - const cssVariables = { - '--primary-color': theme.data.colors['primary'], - '--background-color': theme.data.colors['background'], - '--card-color': theme.data.colors['card'], - '--stroke': theme.data.colors['stroke'], - '--primary-text': theme.data.colors['primary_text'], - '--background-text': theme.data.colors['background_text'], - '--graph-0': theme.data.colors['graph_0'], - '--graph-1': theme.data.colors['graph_1'], - '--graph-2': theme.data.colors['graph_2'], - '--graph-3': theme.data.colors['graph_3'], - '--graph-4': theme.data.colors['graph_4'], - '--graph-5': theme.data.colors['graph_5'], - '--graph-6': theme.data.colors['graph_6'], - '--graph-7': theme.data.colors['graph_7'], - '--graph-8': theme.data.colors['graph_8'], - '--graph-9': theme.data.colors['graph_9'], - } - Object.entries(cssVariables).forEach(([key, value]) => { - element.style.setProperty(key, value) - }) - useFontLoader({ [theme.data.fonts.textFont.name]: theme.data.fonts.textFont.url }) - - // Apply fonts to preview container - element.style.setProperty('font-family', `"${theme.data.fonts.textFont.name}"`) - element.style.setProperty('--heading-font-family', `"${theme.data.fonts.textFont.name}"`) - element.style.setProperty('--body-font-family', `"${theme.data.fonts.textFont.name}"`) - - dispatch(updateTheme(theme)) - } - const clearTheme = () => { - const element = document.getElementById('presentation-slides-wrapper') - if (!element) return; - element.style.removeProperty('--primary-color'); - element.style.removeProperty('--background-color'); - element.style.removeProperty('--card-color'); - element.style.removeProperty('--stroke'); - element.style.removeProperty('--primary-text'); - element.style.removeProperty('--background-text'); - element.style.removeProperty('--graph-0'); - element.style.removeProperty('--graph-1'); - element.style.removeProperty('--graph-2'); - element.style.removeProperty('--graph-3'); - element.style.removeProperty('--graph-4'); - element.style.removeProperty('--graph-5'); - element.style.removeProperty('--graph-6'); - element.style.removeProperty('--graph-7'); - element.style.removeProperty('--graph-8'); - element.style.removeProperty('--graph-9'); - } + setCurrentTheme(theme); + clearPresentationThemeFromElement(element); + if (!theme.data?.colors?.["graph_0"]) return; + applyPresentationThemeToElement(element, theme); + dispatch(updateTheme(theme)); + }; const resetTheme = async () => { - clearTheme(); - - dispatch(updateTheme(null)) - } + clearPresentationThemeFromElement( + document.getElementById("presentation-slides-wrapper") + ); + dispatch(updateTheme(null)); + }; return ( diff --git a/electron/servers/nextjs/app/(presentation-generator)/presentation/hooks/usePresentationData.ts b/electron/servers/nextjs/app/(presentation-generator)/presentation/hooks/usePresentationData.ts index b066b071..227ced2e 100644 --- a/electron/servers/nextjs/app/(presentation-generator)/presentation/hooks/usePresentationData.ts +++ b/electron/servers/nextjs/app/(presentation-generator)/presentation/hooks/usePresentationData.ts @@ -4,9 +4,7 @@ import { toast } from "sonner"; import { setPresentationData } from "@/store/slices/presentationGeneration"; import { DashboardApi } from '../../services/api/dashboard'; import { clearHistory } from "@/store/slices/undoRedoSlice"; -import { useFontLoader } from "../../hooks/useFontLoad"; -import { Theme } from "../../services/api/types"; - +import { applyPresentationThemeToElement } from "../utils/applyPresentationThemeDom"; export const usePresentationData = ( presentationId: string, @@ -15,41 +13,6 @@ export const usePresentationData = ( ) => { const dispatch = useDispatch(); - const applyTheme = async (theme: Theme) => { - const element = document.getElementById('presentation-slides-wrapper') - if (!element) return; - if (!theme || !theme.data) { return; } - if (!theme.data.colors['graph_0']) { return; } - const cssVariables = { - '--primary-color': theme.data.colors['primary'], - '--background-color': theme.data.colors['background'], - '--card-color': theme.data.colors['card'], - '--stroke': theme.data.colors['stroke'], - '--primary-text': theme.data.colors['primary_text'], - '--background-text': theme.data.colors['background_text'], - '--graph-0': theme.data.colors['graph_0'], - '--graph-1': theme.data.colors['graph_1'], - '--graph-2': theme.data.colors['graph_2'], - '--graph-3': theme.data.colors['graph_3'], - '--graph-4': theme.data.colors['graph_4'], - '--graph-5': theme.data.colors['graph_5'], - '--graph-6': theme.data.colors['graph_6'], - '--graph-7': theme.data.colors['graph_7'], - '--graph-8': theme.data.colors['graph_8'], - '--graph-9': theme.data.colors['graph_9'], - } - Object.entries(cssVariables).forEach(([key, value]) => { - element.style.setProperty(key, value) - }) - useFontLoader({ [theme.data.fonts.textFont.name]: theme.data.fonts.textFont.url }) - - // Apply fonts to preview container - element.style.setProperty('font-family', `"${theme.data.fonts.textFont.name}"`) - element.style.setProperty('--heading-font-family', `"${theme.data.fonts.textFont.name}"`) - element.style.setProperty('--body-font-family', `"${theme.data.fonts.textFont.name}"`) - // Update the Presentation content with theme - } - const fetchUserSlides = useCallback(async () => { try { const data = await DashboardApi.getPresentation(presentationId); @@ -59,7 +22,8 @@ export const usePresentationData = ( setLoading(false); } if (data?.theme) { - applyTheme(data.theme); + const el = document.getElementById("presentation-slides-wrapper"); + applyPresentationThemeToElement(el, data.theme); } } catch (error) { setError(true); diff --git a/electron/servers/nextjs/app/(presentation-generator)/presentation/utils/applyPresentationThemeDom.ts b/electron/servers/nextjs/app/(presentation-generator)/presentation/utils/applyPresentationThemeDom.ts new file mode 100644 index 00000000..fbb295ec --- /dev/null +++ b/electron/servers/nextjs/app/(presentation-generator)/presentation/utils/applyPresentationThemeDom.ts @@ -0,0 +1,70 @@ +import { useFontLoader } from "../../hooks/useFontLoad"; +import type { Theme } from "../../services/api/types"; + +const THEME_CSS_KEYS = [ + "--primary-color", + "--background-color", + "--card-color", + "--stroke", + "--primary-text", + "--background-text", + "--graph-0", + "--graph-1", + "--graph-2", + "--graph-3", + "--graph-4", + "--graph-5", + "--graph-6", + "--graph-7", + "--graph-8", + "--graph-9", +] as const; + +/** Remove theme inline variables from a container (e.g. before switching themes). */ +export function clearPresentationThemeFromElement(element: HTMLElement | null): void { + if (!element) return; + for (const key of THEME_CSS_KEYS) { + element.style.removeProperty(key); + } + element.style.removeProperty("font-family"); + element.style.removeProperty("--heading-font-family"); + element.style.removeProperty("--body-font-family"); +} + +/** + * Apply presentation theme CSS variables + font loading to a DOM subtree + * (editor: #presentation-slides-wrapper, present: #presentation-mode-wrapper). + */ +export function applyPresentationThemeToElement( + element: HTMLElement | null, + theme: Theme | null | undefined +): void { + if (!element || !theme?.data) return; + if (!theme.data.colors?.["graph_0"]) return; + const colors = theme.data.colors; + const cssVariables: Record = { + "--primary-color": colors["primary"], + "--background-color": colors["background"], + "--card-color": colors["card"], + "--stroke": colors["stroke"], + "--primary-text": colors["primary_text"], + "--background-text": colors["background_text"], + "--graph-0": colors["graph_0"], + "--graph-1": colors["graph_1"], + "--graph-2": colors["graph_2"], + "--graph-3": colors["graph_3"], + "--graph-4": colors["graph_4"], + "--graph-5": colors["graph_5"], + "--graph-6": colors["graph_6"], + "--graph-7": colors["graph_7"], + "--graph-8": colors["graph_8"], + "--graph-9": colors["graph_9"], + }; + Object.entries(cssVariables).forEach(([key, value]) => { + element.style.setProperty(key, value); + }); + useFontLoader({ [theme.data.fonts.textFont.name]: theme.data.fonts.textFont.url }); + element.style.setProperty("font-family", `"${theme.data.fonts.textFont.name}"`); + element.style.setProperty("--heading-font-family", `"${theme.data.fonts.textFont.name}"`); + element.style.setProperty("--body-font-family", `"${theme.data.fonts.textFont.name}"`); +} diff --git a/electron/servers/nextjs/store/slices/presentationGeneration.ts b/electron/servers/nextjs/store/slices/presentationGeneration.ts index 788828d5..0145367d 100644 --- a/electron/servers/nextjs/store/slices/presentationGeneration.ts +++ b/electron/servers/nextjs/store/slices/presentationGeneration.ts @@ -49,6 +49,12 @@ const presentationGenerationSlice = createSlice({ setLoading: (state, action: PayloadAction) => { state.isLoading = action.payload; }, + // update title + updateTitle: (state, action: PayloadAction) => { + if (state.presentationData) { + state.presentationData.title = action.payload; + } + }, setLayoutLoading: (state, action: PayloadAction) => { state.isLayoutLoading = action.payload; }, @@ -57,7 +63,7 @@ const presentationGenerationSlice = createSlice({ state.presentation_id = action.payload; state.error = null; }, - // Slides rendereimport { useEffect } from "react"d + // Slides rendered setSlidesRendered: (state, action: PayloadAction) => { state.isSlidesRendered = action.payload; }, @@ -391,6 +397,7 @@ const presentationGenerationSlice = createSlice({ export const { setStreaming, setLoading, + updateTitle, setLayoutLoading, setPresentationId, setSlidesRendered, diff --git a/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/SettingPage.tsx b/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/SettingPage.tsx index eea08133..c1cb67b8 100644 --- a/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/SettingPage.tsx +++ b/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/SettingPage.tsx @@ -1,10 +1,14 @@ "use client"; import React, { useState, useEffect } from "react"; import { Loader2, Download, CheckCircle, ChevronRight } from "lucide-react"; -import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { notify } from "@/components/ui/sonner"; import { RootState } from "@/store/store"; import { useSelector } from "react-redux"; -import { handleSaveLLMConfig } from "@/utils/storeHelpers"; +import { + getLLMConfigValidationError, + handleSaveLLMConfig, +} from "@/utils/storeHelpers"; import { checkIfSelectedOllamaModelIsPulled, pullOllamaModel, @@ -52,6 +56,7 @@ const SettingsPage = () => { done: boolean; } | null>(null); const [showDownloadModal, setShowDownloadModal] = useState(false); + const downloadAbortRef = React.useRef(null); const downloadProgress = React.useMemo(() => { if ( @@ -68,6 +73,11 @@ const SettingsPage = () => { const handleSaveConfig = async () => { trackEvent(MixpanelEvent.Settings_SaveConfiguration_Button_Clicked, { pathname }); + const validationError = getLLMConfigValidationError(llmConfig); + if (validationError) { + notify.error("Cannot save settings", validationError); + return; + } try { setButtonState(prev => ({ ...prev, @@ -84,11 +94,24 @@ const SettingsPage = () => { ); if (!isPulled) { setShowDownloadModal(true); + setDownloadingModel({ + name: llmConfig.OLLAMA_MODEL || "", + size: null, + downloaded: null, + status: "pulling", + done: false, + }); trackEvent(MixpanelEvent.Settings_DownloadOllamaModel_API_Call); - await handleModelDownload(); + const downloadOutcome = await handleModelDownload(); + if (downloadOutcome === "cancelled") { + return; + } } } - toast.info("Configuration saved successfully"); + notify.info( + "Settings saved", + "Your configuration was saved successfully." + ); setButtonState(prev => ({ ...prev, isLoading: false, @@ -98,7 +121,11 @@ const SettingsPage = () => { trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/upload" }); router.push("/upload"); } catch (error) { - toast.info(error instanceof Error ? error.message : "Failed to save configuration"); + const message = + error instanceof Error + ? error.message + : "Something went wrong while saving."; + notify.error("Could not save settings", message); setButtonState(prev => ({ ...prev, isLoading: false, @@ -108,13 +135,38 @@ const SettingsPage = () => { } }; - const handleModelDownload = async () => { + const handleModelDownload = async (): Promise<"completed" | "cancelled"> => { + const ac = new AbortController(); + downloadAbortRef.current = ac; try { - await pullOllamaModel(llmConfig.OLLAMA_MODEL!, setDownloadingModel); - } - finally { + await pullOllamaModel( + llmConfig.OLLAMA_MODEL!, + setDownloadingModel, + ac.signal + ); + return "completed"; + } catch (e) { + const aborted = e instanceof Error && e.name === "AbortError"; + if (aborted) { + setDownloadingModel(null); + setShowDownloadModal(false); + setButtonState({ + isLoading: false, + isDisabled: false, + text: "Save Configuration", + showProgress: false, + }); + notify.info( + "Download cancelled", + "The Ollama model download was stopped. Your settings are already saved—you can save again to retry the download." + ); + return "cancelled"; + } setDownloadingModel(null); setShowDownloadModal(false); + throw e; + } finally { + downloadAbortRef.current = null; } }; @@ -141,7 +193,10 @@ const SettingsPage = () => { setTimeout(() => { setShowDownloadModal(false); setDownloadingModel(null); - toast.info("Model downloaded successfully!"); + notify.success( + "Model ready", + "The Ollama model finished downloading successfully." + ); }, 2000); } }, [downloadingModel]); @@ -334,6 +389,19 @@ const SettingsPage = () => {
)} + + {!downloadingModel.done && ( +
+ +
+ )}
diff --git a/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/TextProvider.tsx b/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/TextProvider.tsx index a42c0cb7..99206600 100644 --- a/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/TextProvider.tsx +++ b/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/TextProvider.tsx @@ -8,7 +8,7 @@ import { LLMConfig } from '@/types/llm_config'; import { LLM_PROVIDERS } from '@/utils/providerConstants'; import { Check, Loader2, Eye, EyeOff, ChevronUp, User, RefreshCw, LogOut } from 'lucide-react'; import React, { useEffect, useMemo, useRef, useState } from 'react' -import { toast } from 'sonner'; +import { notify } from '@/components/ui/sonner'; interface OpenAIConfigProps { @@ -191,7 +191,10 @@ const TextProvider = ({ } } catch (error) { console.error('Error fetching models:', error); - toast.error('Error fetching models'); + notify.error( + 'Could not load models', + 'Something went wrong while contacting the provider. Check your network and try again.' + ); setAvailableModels([]); setModelsChecked(true); } finally { diff --git a/servers/nextjs/components/ui/sonner.tsx b/servers/nextjs/components/ui/sonner.tsx index 357596f2..46c1e7f1 100644 --- a/servers/nextjs/components/ui/sonner.tsx +++ b/servers/nextjs/components/ui/sonner.tsx @@ -1,7 +1,18 @@ "use client" +import type React from "react" import { useTheme } from "next-themes" -import { Toaster as Sonner } from "sonner" +import { Toaster as Sonner, toast as sonnerToast } from "sonner" + +/** Toasts with both title and description. */ +export const notify = { + error: (title: string, description: string) => + sonnerToast.error(title, { description }), + success: (title: string, description: string) => + sonnerToast.success(title, { description }), + info: (title: string, description: string) => + sonnerToast.info(title, { description }), +} as const type ToasterProps = React.ComponentProps diff --git a/servers/nextjs/utils/providerUtils.ts b/servers/nextjs/utils/providerUtils.ts index f4010af0..4ddbfc15 100644 --- a/servers/nextjs/utils/providerUtils.ts +++ b/servers/nextjs/utils/providerUtils.ts @@ -109,47 +109,97 @@ export const resetDownloadingModel = (): DownloadingModel => ({ done: false, }); +function abortPullError(): Error { + const err = new Error("Download cancelled"); + err.name = "AbortError"; + return err; +} + +function isAbortError(e: unknown): boolean { + return e instanceof Error && e.name === "AbortError"; +} + /** - * Pulls Ollama model with progress tracking - * Returns a promise that resolves with the final downloading model state + * Pulls Ollama model with progress tracking. + * Pass an AbortSignal to stop polling (e.g. user cancels download). */ export const pullOllamaModel = async ( model: string, - onProgress?: (model: DownloadingModel) => void + onProgress?: (model: DownloadingModel) => void, + signal?: AbortSignal ): Promise => { return new Promise((resolve, reject) => { - const interval = setInterval(async () => { + let interval: ReturnType | null = null; + let settled = false; + + const cleanup = () => { + if (interval !== null) { + clearInterval(interval); + interval = null; + } + signal?.removeEventListener("abort", onAbort); + }; + + const onAbort = () => { + if (settled) return; + settled = true; + cleanup(); + onProgress?.(resetDownloadingModel()); + reject(abortPullError()); + }; + + if (signal?.aborted) { + onAbort(); + return; + } + signal?.addEventListener("abort", onAbort); + + interval = setInterval(async () => { + if (signal?.aborted) { + onAbort(); + return; + } try { const response = await fetch( `/api/v1/ppt/ollama/model/pull?model=${model}` ); + if (settled) return; if (response.status === 200) { const data = await response.json(); if (data.done && data.status !== "error") { - clearInterval(interval); + if (settled) return; + settled = true; + cleanup(); onProgress?.(data); resolve(data); } else if (data.status === "error") { - clearInterval(interval); - const resetData = resetDownloadingModel(); - onProgress?.(resetData); + if (settled) return; + settled = true; + cleanup(); + onProgress?.(resetDownloadingModel()); reject(new Error("Error occurred while pulling model")); } else { onProgress?.(data); } } else { - clearInterval(interval); - const resetData = resetDownloadingModel(); - onProgress?.(resetData); + if (settled) return; + settled = true; + cleanup(); + onProgress?.(resetDownloadingModel()); if (response.status === 403) { reject(new Error("Request to Ollama Not Authorized")); + } else { + reject(new Error("Error occurred while pulling model")); } - reject(new Error("Error occurred while pulling model")); } } catch (error) { - clearInterval(interval); - const resetData = resetDownloadingModel(); - onProgress?.(resetData); + if (settled) return; + if (isAbortError(error)) { + return; + } + settled = true; + cleanup(); + onProgress?.(resetDownloadingModel()); reject(error); } }, 1000); diff --git a/servers/nextjs/utils/storeHelpers.ts b/servers/nextjs/utils/storeHelpers.ts index 630a6e5f..4a953856 100644 --- a/servers/nextjs/utils/storeHelpers.ts +++ b/servers/nextjs/utils/storeHelpers.ts @@ -2,9 +2,118 @@ import { setLLMConfig } from "@/store/slices/userConfig"; import { store } from "@/store/store"; import { LLMConfig } from "@/types/llm_config"; +function isProvided(value: unknown): boolean { + return value !== "" && value !== null && value !== undefined; +} + +/** + * Returns a user-facing validation message, or null when the config is valid. + */ +export const getLLMConfigValidationError = ( + llmConfig: LLMConfig +): string | null => { + if (!llmConfig.LLM) { + return "Select a text provider."; + } + + if (!llmConfig.DISABLE_IMAGE_GENERATION && !llmConfig.IMAGE_PROVIDER) { + return "Select an image provider, or turn off image generation."; + } + + const llm = llmConfig.LLM; + + if (llm === "openai") { + if (!isProvided(llmConfig.OPENAI_API_KEY)) { + return "OpenAI API key is required."; + } + if (!isProvided(llmConfig.OPENAI_MODEL)) { + return 'No OpenAI model selected. Use "Check models" after entering your API key, then choose a model.'; + } + } else if (llm === "google") { + if (!isProvided(llmConfig.GOOGLE_API_KEY)) { + return "Google API key is required."; + } + if (!isProvided(llmConfig.GOOGLE_MODEL)) { + return 'No Google model selected. Use "Check models" after entering your API key, then choose a model.'; + } + } else if (llm === "anthropic") { + if (!isProvided(llmConfig.ANTHROPIC_API_KEY)) { + return "Anthropic API key is required."; + } + if (!isProvided(llmConfig.ANTHROPIC_MODEL)) { + return 'No Anthropic model selected. Use "Check models" after entering your API key, then choose a model.'; + } + } else if (llm === "ollama") { + if (!isProvided(llmConfig.OLLAMA_URL)) { + return "Ollama server URL is required."; + } + if (!isProvided(llmConfig.OLLAMA_MODEL)) { + return "Select an Ollama model. If none appear, confirm Ollama is running and reachable."; + } + } else if (llm === "custom") { + if (!isProvided(llmConfig.CUSTOM_LLM_URL)) { + return "Enter your custom LLM endpoint URL (OpenAI-compatible)."; + } + if (!isProvided(llmConfig.CUSTOM_MODEL)) { + return 'No model selected for your custom endpoint. Use "Check models" after entering the URL, then choose a model.'; + } + } else if (llm === "codex") { + if (!isProvided(llmConfig.CODEX_MODEL)) { + return "Select a Codex model."; + } + } else { + return "Unsupported or unknown text provider."; + } + + if (!llmConfig.DISABLE_IMAGE_GENERATION) { + switch (llmConfig.IMAGE_PROVIDER) { + case "pexels": + if (!isProvided(llmConfig.PEXELS_API_KEY)) { + return "Pexels API key is required."; + } + break; + case "pixabay": + if (!isProvided(llmConfig.PIXABAY_API_KEY)) { + return "Pixabay API key is required."; + } + break; + case "dall-e-3": + if (!isProvided(llmConfig.OPENAI_API_KEY)) { + return "OpenAI API key is required for DALL·E 3."; + } + break; + case "gpt-image-1.5": + if (!isProvided(llmConfig.OPENAI_API_KEY)) { + return "OpenAI API key is required for GPT Image 1.5."; + } + break; + case "gemini_flash": + if (!isProvided(llmConfig.GOOGLE_API_KEY)) { + return "Google API key is required for Gemini Flash image generation."; + } + break; + case "nanobanana_pro": + if (!isProvided(llmConfig.GOOGLE_API_KEY)) { + return "Google API key is required for NanoBanana Pro."; + } + break; + case "comfyui": + if (!isProvided(llmConfig.COMFYUI_URL)) { + return "ComfyUI server URL is required."; + } + break; + default: + return "Select a valid image provider."; + } + } + + return null; +}; + export const handleSaveLLMConfig = async (llmConfig: LLMConfig) => { - if (!hasValidLLMConfig(llmConfig)) { - throw new Error("Provided configuration is not valid"); + const validationError = getLLMConfigValidationError(llmConfig); + if (validationError) { + throw new Error(validationError); } await fetch("/api/user-config", { method: "POST", @@ -14,96 +123,5 @@ export const handleSaveLLMConfig = async (llmConfig: LLMConfig) => { store.dispatch(setLLMConfig(llmConfig)); }; -export const hasValidLLMConfig = (llmConfig: LLMConfig) => { - if (!llmConfig.LLM) return false; - if (!llmConfig.DISABLE_IMAGE_GENERATION && !llmConfig.IMAGE_PROVIDER) - return false; - - const isOpenAIConfigValid = - llmConfig.OPENAI_MODEL !== "" && - llmConfig.OPENAI_MODEL !== null && - llmConfig.OPENAI_MODEL !== undefined && - llmConfig.OPENAI_API_KEY !== "" && - llmConfig.OPENAI_API_KEY !== null && - llmConfig.OPENAI_API_KEY !== undefined; - - const isGoogleConfigValid = - llmConfig.GOOGLE_MODEL !== "" && - llmConfig.GOOGLE_MODEL !== null && - llmConfig.GOOGLE_MODEL !== undefined && - llmConfig.GOOGLE_API_KEY !== "" && - llmConfig.GOOGLE_API_KEY !== null && - llmConfig.GOOGLE_API_KEY !== undefined; - - const isAnthropicConfigValid = - llmConfig.ANTHROPIC_MODEL !== "" && - llmConfig.ANTHROPIC_MODEL !== null && - llmConfig.ANTHROPIC_MODEL !== undefined && - llmConfig.ANTHROPIC_API_KEY !== "" && - llmConfig.ANTHROPIC_API_KEY !== null && - llmConfig.ANTHROPIC_API_KEY !== undefined; - - const isOllamaConfigValid = - llmConfig.OLLAMA_MODEL !== "" && - llmConfig.OLLAMA_MODEL !== null && - llmConfig.OLLAMA_MODEL !== undefined && - llmConfig.OLLAMA_URL !== "" && - llmConfig.OLLAMA_URL !== null && - llmConfig.OLLAMA_URL !== undefined; - - const isCustomConfigValid = - llmConfig.CUSTOM_LLM_URL !== "" && - llmConfig.CUSTOM_LLM_URL !== null && - llmConfig.CUSTOM_LLM_URL !== undefined && - llmConfig.CUSTOM_MODEL !== "" && - llmConfig.CUSTOM_MODEL !== null && - llmConfig.CUSTOM_MODEL !== undefined; - - const isCodexConfigValid = - llmConfig.CODEX_MODEL !== "" && - llmConfig.CODEX_MODEL !== null && - llmConfig.CODEX_MODEL !== undefined; - - const shouldValidateImages = !llmConfig.DISABLE_IMAGE_GENERATION; - - const isImageConfigValid = () => { - if (!shouldValidateImages) { - return true; - } - switch (llmConfig.IMAGE_PROVIDER) { - case "pexels": - return llmConfig.PEXELS_API_KEY && llmConfig.PEXELS_API_KEY !== ""; - case "pixabay": - return llmConfig.PIXABAY_API_KEY && llmConfig.PIXABAY_API_KEY !== ""; - case "dall-e-3": - return llmConfig.OPENAI_API_KEY && llmConfig.OPENAI_API_KEY !== ""; - case "gpt-image-1.5": - return llmConfig.OPENAI_API_KEY && llmConfig.OPENAI_API_KEY !== ""; - case "gemini_flash": - return llmConfig.GOOGLE_API_KEY && llmConfig.GOOGLE_API_KEY !== ""; - case "nanobanana_pro": - return llmConfig.GOOGLE_API_KEY && llmConfig.GOOGLE_API_KEY !== ""; - case "comfyui": - return llmConfig.COMFYUI_URL && llmConfig.COMFYUI_URL !== ""; - default: - return false; - } - }; - - const isLLMConfigValid = - llmConfig.LLM === "openai" - ? isOpenAIConfigValid - : llmConfig.LLM === "google" - ? isGoogleConfigValid - : llmConfig.LLM === "anthropic" - ? isAnthropicConfigValid - : llmConfig.LLM === "ollama" - ? isOllamaConfigValid - : llmConfig.LLM === "custom" - ? isCustomConfigValid - : llmConfig.LLM === "codex" - ? isCodexConfigValid - : false; - - return isLLMConfigValid && isImageConfigValid(); -}; +export const hasValidLLMConfig = (llmConfig: LLMConfig) => + getLLMConfigValidationError(llmConfig) === null;