fix: sonner and proper error message

This commit is contained in:
shiva raj badu 2026-03-29 12:31:57 +05:45
parent 929f5b4883
commit 3e469cb945
No known key found for this signature in database
9 changed files with 430 additions and 182 deletions

View file

@ -60,7 +60,7 @@ const ImageProvider = ({ llmConfig, setLlmConfig }: { llmConfig: LLMConfig, setL
DALL·E 3 Image Quality
</label>
<div className="">
<Select value={llmConfig.DALL_E_3_QUALITY} onValueChange={(value) => input_field_changed(value, "DALL_E_3_QUALITY")}>
<Select value={llmConfig.DALL_E_3_QUALITY || 'standard'} onValueChange={(value) => input_field_changed(value, "DALL_E_3_QUALITY")}>
<SelectTrigger className="w-full h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between">
<SelectValue placeholder="Select a quality" />
</SelectTrigger>
@ -84,7 +84,7 @@ const ImageProvider = ({ llmConfig, setLlmConfig }: { llmConfig: LLMConfig, setL
</label>
<div className="">
<Select
value={llmConfig.GPT_IMAGE_1_5_QUALITY}
value={llmConfig.GPT_IMAGE_1_5_QUALITY || 'low'}
onValueChange={(value) => input_field_changed(value, "GPT_IMAGE_1_5_QUALITY")}
>
<SelectTrigger

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]);
@ -336,6 +391,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

@ -9,7 +9,7 @@ import { getApiUrl } from '@/utils/api';
import { LLM_PROVIDERS } from '@/utils/providerConstants';
import { Check, Loader2, Eye, EyeOff, ChevronUp } from 'lucide-react';
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner';
import { notify } from '@/components/ui/sonner';
interface OpenAIConfigProps {
@ -190,11 +190,17 @@ const TextProvider = ({
console.error('Failed to fetch models');
setAvailableModels([]);
setModelsChecked(true);
toast.error(`Failed to fetch ${modelLabel} models`);
notify.error(
'Could not load models',
`The server could not list ${modelLabel} models. Check your API key or endpoint and try again.`
);
}
} 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,10 +1,14 @@
"use client";
import React, { useState, useEffect } from "react";
import { Loader2, Download, CheckCircle } 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,
@ -48,6 +52,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 (
@ -64,6 +69,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,
@ -80,11 +90,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,
@ -94,7 +117,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,
@ -104,13 +131,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;
}
};
@ -137,7 +189,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]);
@ -272,6 +327,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

@ -289,7 +289,7 @@ export default function Home() {
<img src="/right-confetti.png" alt="presenton" className='w-full h-full object-contain' />
</div>
)}
<OnBoardingHeader currentStep={step} />
<OnBoardingHeader currentStep={step} setStep={setStep} />
{step === 1 && <ModeSelectStep selectedMode={selectedMode} setStep={setStep} setSelectedMode={setSelectedMode} />}
{step === 2 && selectedMode === "presenton" && <PresentonMode currentStep={step} setStep={setStep} />}
{step === 2 && selectedMode === "image" && <GenerationWithImage />}

View file

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

View file

@ -2,7 +2,17 @@
import type React from "react"
import { BadgeCheck, Loader2, ShieldAlert } from "lucide-react"
import { Toaster as Sonner } from "sonner"
import { Toaster as Sonner, toast as sonnerToast } from "sonner"
/** Toasts with both title and description (matches styled [data-title] / [data-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>
@ -19,29 +29,42 @@ const Toaster = ({ icons, ...props }: ToasterProps) => {
return (
<>
<style jsx global>{`
/* Constrain toast width similar to the design mock */
/* Neutral "card" toast container */
[data-sonner-toast][data-styled="true"] {
border-radius: 10px !important;
border: 1px solid #E1E1E5 !important;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.06) !important;
padding: 8px !important;
gap: 10px !important;
backdrop-filter: blur(6px) !important;
background: white !important;
/* Near Sonner default width on desktop; nearly full width on narrow screens */
[data-sonner-toaster] {
--width: min(100dvw - 1.5rem, 22.5rem) !important;
box-sizing: border-box !important;
}
/* Typography */
@media (min-width: 640px) {
[data-sonner-toaster] {
--width: min(100dvw - 2rem, 24rem) !important;
}
}
/* Neutral "card" toast container — design tokens */
[data-sonner-toast][data-styled="true"] {
border-radius: 10px !important;
border: 1px solid var(--Base-Gray-700, #e1e1e5) !important;
background: rgba(255, 255, 255, 0.6) !important;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.06) !important;
padding: clamp(9px, 0.5rem + 0.35vw, 12px) clamp(11px, 0.65rem + 0.5vw, 14px) !important;
gap: clamp(8px, 0.5rem + 0.35vw, 11px) !important;
backdrop-filter: blur(6px) !important;
-webkit-backdrop-filter: blur(6px) !important;
width: 100% !important;
max-width: 100% !important;
}
/* Typography — slight scale-up from original 12px, capped modestly */
[data-sonner-toast][data-styled="true"] [data-title] {
font-family: var(--font-syne), ui-sans-serif, system-ui, -apple-system,
BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial,
"Noto Sans", sans-serif !important;
font-size: 12px !important;
font-size: clamp(0.8125rem, 0.8rem + 0.12vw, 0.9375rem) !important;
font-weight: 500 !important;
line-height: 14px !important;
letter-spacing: 0.04em !important;
line-height: 1.35 !important;
letter-spacing: 0.03em !important;
color: rgb(15 23 42) !important; /* slate-900 */
text-transform: none !important;
}
@ -50,23 +73,24 @@ const Toaster = ({ icons, ...props }: ToasterProps) => {
font-family: var(--font-syne), ui-sans-serif, system-ui, -apple-system,
BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial,
"Noto Sans", sans-serif !important;
font-size: 10px !important;
font-size: clamp(0.6875rem, 0.67rem + 0.1vw, 0.8125rem) !important;
font-weight: 400 !important;
line-height: 10px !important; /* 100% */
line-height: 1.4 !important;
letter-spacing: 0.03em !important;
color: rgb(100 116 139) !important; /* slate-500 */
}
[data-sonner-toast][data-styled="true"] [data-content] {
gap: 2px !important;
gap: clamp(2px, 0.15vw, 5px) !important;
flex: 1 1 auto !important;
min-width: 0 !important;
}
/* Left icon badge */
[data-sonner-toast][data-styled="true"] [data-icon] {
width: 20px !important;
height: 20px !important;
width: clamp(20px, 1.15rem + 0.35vw, 22px) !important;
height: clamp(20px, 1.15rem + 0.35vw, 22px) !important;
flex-shrink: 0 !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
@ -75,8 +99,8 @@ const Toaster = ({ icons, ...props }: ToasterProps) => {
}
[data-sonner-toast][data-styled="true"] [data-icon] svg {
width: 20px !important;
height: 20px !important;
width: clamp(20px, 1.15rem + 0.35vw, 22px) !important;
height: clamp(20px, 1.15rem + 0.35vw, 22px) !important;
}
/* Per-type icon colors */
@ -103,12 +127,13 @@ const Toaster = ({ icons, ...props }: ToasterProps) => {
/* Outline buttons like the mock ("Got it!") */
[data-sonner-toast][data-styled="true"] [data-button] {
height: auto !important;
padding: 4px 8px !important;
padding: clamp(4px, 0.3rem + 0.2vw, 7px)
clamp(7px, 0.5rem + 0.25vw, 10px) !important;
border-radius: 6px !important;
font-family: var(--font-syne), ui-sans-serif, system-ui, -apple-system,
BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial,
"Noto Sans", sans-serif !important;
font-size: 10px !important;
font-size: clamp(0.625rem, 0.62rem + 0.08vw, 0.75rem) !important;
font-weight: 400 !important;
background: rgb(255 255 255) !important;
color: #3F3F3F !important;
@ -127,21 +152,22 @@ const Toaster = ({ icons, ...props }: ToasterProps) => {
white-space: nowrap !important;
width: auto !important;
height: auto !important;
padding: 4px 8px !important;
padding: clamp(4px, 0.3rem + 0.2vw, 7px)
clamp(7px, 0.5rem + 0.25vw, 10px) !important;
border-radius: 6px !important;
margin-left: auto !important;
margin-right: 0 !important;
align-self: center !important;
background: rgb(255 255 255) !important;
color: #3F3F3F !important;
border: 1px solid #EDEEEF !important;
color: #3f3f3f !important;
border: 1px solid #edeeef !important;
box-shadow: none !important;
font-family: var(--font-syne), ui-sans-serif, system-ui, -apple-system,
BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial,
"Noto Sans", sans-serif !important;
font-size: 10px !important;
font-size: clamp(0.625rem, 0.62rem + 0.08vw, 0.75rem) !important;
font-weight: 400 !important;
line-height: 14px !important;
line-height: 1.3 !important;
letter-spacing: 0.02em !important;
}
@ -153,11 +179,14 @@ const Toaster = ({ icons, ...props }: ToasterProps) => {
background: rgb(248 250 252) !important; /* slate-50 */
}
/* Dark mode */
/* Dark mode — same radius, border weight, shadow; frosted dark surface */
.dark [data-sonner-toast][data-styled="true"] {
background: rgb(2 6 23) !important; /* slate-950 */
border: 1px solid rgba(148, 163, 184, 0.22) !important; /* slate-400 @ 22% */
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.45) !important;
border-radius: 10px !important;
border: 1px solid rgba(148, 163, 184, 0.22) !important;
background: rgba(2, 6, 23, 0.6) !important;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.06) !important;
backdrop-filter: blur(6px) !important;
-webkit-backdrop-filter: blur(6px) !important;
}
.dark [data-sonner-toast][data-styled="true"] [data-title] {
@ -189,11 +218,8 @@ const Toaster = ({ icons, ...props }: ToasterProps) => {
}
`}</style>
<Sonner
style={{
zIndex: 999999999,
background: 'red',
}}
className="toaster group z-50 bg-white"
style={{ zIndex: 999999999 }}
className="toaster group z-50 bg-transparent"
icons={{ ...defaultIcons, ...(icons ?? {}) }}
toastOptions={{
closeButtonAriaLabel: "Dismiss notification",

View file

@ -110,47 +110,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(
getApiUrl(`/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);
}
// Check if running in Electron environment
@ -22,96 +131,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;