fix: present mode theme issue

This commit is contained in:
shiva raj badu 2026-03-29 17:27:31 +05:45
parent 9fc867e23b
commit 0c772a28e4
No known key found for this signature in database
12 changed files with 546 additions and 240 deletions

View file

@ -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<Theme[]>([]);
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [draftTitle, setDraftTitle] = useState("");
const titleInputRef = useRef<HTMLInputElement>(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<PptxPresentationModel> => {
const response = await fetch(`/api/presentation_to_pptx_model?id=${id}`);
const pptx_model = await response.json();
@ -240,13 +299,96 @@ const PresentationHeader = ({
</div>
);
const titleBlock = (
<div
className={cn(
"min-w-0 max-w-[min(640px,calc(100vw-12rem))] flex-1 transition-[box-shadow] duration-200",
isEditingTitle && "relative z-[60]"
)}
>
{isEditingTitle ? (
<div className="flex items-stretch gap-0.5 rounded-[14px] border border-[#E4E2EB] bg-white pl-3.5 pr-1 py-1 shadow-[0_2px_12px_rgba(17,3,31,0.06)] ring-2 ring-[#5141e5]/15">
<input
ref={titleInputRef}
value={draftTitle}
onChange={(e) => 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"
/>
<div className="flex shrink-0 items-center gap-0.5 border-l border-[#EDECEC] pl-1 ml-0.5">
<ToolTip content="Save · Enter">
<button
type="button"
onMouseDown={onTitleSaveMouseDown}
onClick={commitTitleEdit}
className="flex h-8 w-8 items-center justify-center rounded-lg text-[#5141e5] hover:bg-[#5141e5]/10 transition-colors"
aria-label="Save title"
>
<Check className="h-4 w-4" strokeWidth={2.25} />
</button>
</ToolTip>
<ToolTip content="Cancel · Esc">
<button
type="button"
onMouseDown={onTitleCancelMouseDown}
onClick={cancelTitleEdit}
className="flex h-8 w-8 items-center justify-center rounded-lg text-[#101323]/55 hover:bg-[#F6F6F9] hover:text-[#101323] transition-colors"
aria-label="Cancel editing title"
>
<X className="h-4 w-4" strokeWidth={2.25} />
</button>
</ToolTip>
</div>
</div>
) : (
<button
type="button"
onClick={beginTitleEdit}
disabled={isStreaming || !presentationData}
className={cn(
"group/title flex w-full min-w-0 items-center gap-2.5 rounded-[14px] px-3 py-2 text-left -mx-3 transition-colors",
"hover:bg-[#F6F6F9] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#5141e5] focus-visible:ring-offset-2",
"disabled:pointer-events-none disabled:opacity-100 disabled:hover:bg-transparent"
)}
>
<h2 className="min-w-0 flex-1 font-unbounded text-lg leading-snug text-[#101323]">
<MarkdownRenderer
content={presentationData?.title || "Presentation"}
className="mb-0 min-w-0 overflow-hidden text-ellipsis line-clamp-1 text-sm text-[#101323] prose-p:my-0 prose-headings:my-0"
/>
</h2>
{presentationData && !isStreaming && (
<Pencil
className="h-3.5 w-3.5 shrink-0 text-[#101323]/40 transition-all duration-200 group-hover/title:text-[#5141e5] opacity-80 sm:opacity-0 sm:group-hover/title:opacity-100 group-hover/title:opacity-100"
aria-hidden
/>
)}
</button>
)}
</div>
);
return (
<>
<div className="py-7 sticky top-0 bg-white z-50 mb-[17px] font-syne flex justify-between items-center">
<h2 className="text-lg text-[#101323] font-unbounded "><MarkdownRenderer content={presentationData?.title || "Presentation"} className="mb-0 max-w-[600px] overflow-ellipsis line-clamp-1 text-sm text-[#101323] " /></h2>
<div className="py-7 sticky top-0 bg-white z-50 mb-[17px] font-syne flex justify-between items-center gap-4">
{presentationData && !isStreaming && !isEditingTitle ? (
<ToolTip content="Rename presentation">{titleBlock}</ToolTip>
) : (
titleBlock
)}
<div className="flex items-center gap-2.5">
{isPresentationSaving && <div className="flex items-center gap-2">

View file

@ -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<PresentationModeProps> = ({
};
}, [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<PresentationModeProps> = ({
return (
<div
id="presentation-mode-wrapper"
ref={rootRef}
role="application"
aria-label="Presentation"

View file

@ -1,5 +1,5 @@
"use client";
import React, { useState } from "react";
import React, { useLayoutEffect, useState } from "react";
import { useSelector } from "react-redux";
import { RootState } from "@/store/store";
import { Skeleton } from "@/components/ui/skeleton";
@ -16,10 +16,9 @@ import {
usePresentationNavigation,
useAutoSave,
} from "../hooks";
import { useEffect } from "react";
import { PresentationPageProps } from "../types";
import LoadingState from "./LoadingState";
import { setupImageUrlConverter } from "@/utils/image-url-converter";
import { applyPresentationThemeToElement } from "../utils/applyPresentationThemeDom";
import { usePresentationUndoRedo } from "../hooks/PresentationUndoRedo";
import PresentationHeader from "./PresentationHeader";
@ -35,10 +34,10 @@ const PresentationPage: React.FC<PresentationPageProps> = ({
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<PresentationPageProps> = ({
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);
};

View file

@ -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<any>(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 (

View file

@ -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);

View file

@ -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<string, string> = {
"--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}"`);
}

View file

@ -49,6 +49,12 @@ const presentationGenerationSlice = createSlice({
setLoading: (state, action: PayloadAction<boolean>) => {
state.isLoading = action.payload;
},
// update title
updateTitle: (state, action: PayloadAction<string>) => {
if (state.presentationData) {
state.presentationData.title = action.payload;
}
},
setLayoutLoading: (state, action: PayloadAction<boolean>) => {
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<boolean>) => {
state.isSlidesRendered = action.payload;
},
@ -391,6 +397,7 @@ const presentationGenerationSlice = createSlice({
export const {
setStreaming,
setLoading,
updateTitle,
setLayoutLoading,
setPresentationId,
setSlidesRendered,

View file

@ -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<boolean>(false);
const downloadAbortRef = React.useRef<AbortController | null>(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 = () => {
</div>
</div>
)}
{!downloadingModel.done && (
<div className="mt-6 flex justify-center">
<Button
type="button"
variant="outline"
className="rounded-lg border-gray-300 text-gray-800 hover:bg-gray-50"
onClick={() => downloadAbortRef.current?.abort()}
>
Cancel download
</Button>
</div>
)}
</div>
</div>
</div>

View file

@ -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 {

View file

@ -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<typeof Sonner>

View file

@ -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<DownloadingModel> => {
return new Promise((resolve, reject) => {
const interval = setInterval(async () => {
let interval: ReturnType<typeof setInterval> | 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);

View file

@ -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;