fix: present mode theme issue
This commit is contained in:
parent
9fc867e23b
commit
0c772a28e4
12 changed files with 546 additions and 240 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}"`);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue