Browse Source

feat(06-04): improve error handling and loading states

- Add modelsFetchError state with retry button in GenerateImage/VideoNode
- Show clear errors when model list fails to load (API key issues, connection)
- Improve API route error messages to include model name
- Add rate limit handling (429) with user-friendly messages
- Parse provider-specific error responses for better detail
- Improve timeout message for video generation
handoff-20260429-1057
shrimbly 6 months ago
parent
commit
2810572651
  1. 42
      src/app/api/generate/route.ts
  2. 127
      src/components/nodes/GenerateImageNode.tsx
  3. 117
      src/components/nodes/GenerateVideoNode.tsx

42
src/app/api/generate/route.ts

@ -307,9 +307,25 @@ async function generateWithReplicate(
if (!createResponse.ok) { if (!createResponse.ok) {
const errorText = await createResponse.text(); const errorText = await createResponse.text();
let errorDetail = errorText;
try {
const errorJson = JSON.parse(errorText);
errorDetail = errorJson.detail || errorJson.message || errorJson.error || errorText;
} catch {
// Keep original text if not JSON
}
// Handle rate limits
if (createResponse.status === 429) {
return {
success: false,
error: `${input.model.name}: Rate limit exceeded. Try again in a moment.`,
};
}
return { return {
success: false, success: false,
error: `Failed to create prediction: ${createResponse.status} - ${errorText}`, error: `${input.model.name}: ${errorDetail}`,
}; };
} }
@ -330,7 +346,7 @@ async function generateWithReplicate(
if (Date.now() - startTime > maxWaitTime) { if (Date.now() - startTime > maxWaitTime) {
return { return {
success: false, success: false,
error: "Prediction timed out after 5 minutes", error: `${input.model.name}: Generation timed out after 5 minutes. Video models may take longer - try again.`,
}; };
} }
@ -357,9 +373,11 @@ async function generateWithReplicate(
} }
if (currentPrediction.status === "failed") { if (currentPrediction.status === "failed") {
const failureReason = currentPrediction.error || "Prediction failed";
console.error(`[API:${requestId}] Replicate prediction failed: ${failureReason}`);
return { return {
success: false, success: false,
error: currentPrediction.error || "Prediction failed", error: `${input.model.name}: ${failureReason}`,
}; };
} }
@ -496,9 +514,25 @@ async function generateWithFal(
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
let errorDetail = errorText;
try {
const errorJson = JSON.parse(errorText);
errorDetail = errorJson.detail || errorJson.message || errorJson.error || errorText;
} catch {
// Keep original text if not JSON
}
// Handle rate limits
if (response.status === 429) {
return {
success: false,
error: `${input.model.name}: Rate limit exceeded. ${apiKey ? "Try again in a moment." : "Add an API key in settings for higher limits."}`,
};
}
return { return {
success: false, success: false,
error: `fal.ai API error: ${response.status} - ${errorText}`, error: `${input.model.name}: ${errorDetail}`,
}; };
} }

127
src/components/nodes/GenerateImageNode.tsx

@ -33,6 +33,7 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
const [isLoadingCarouselImage, setIsLoadingCarouselImage] = useState(false); const [isLoadingCarouselImage, setIsLoadingCarouselImage] = useState(false);
const [externalModels, setExternalModels] = useState<ProviderModel[]>([]); const [externalModels, setExternalModels] = useState<ProviderModel[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState(false); const [isLoadingModels, setIsLoadingModels] = useState(false);
const [modelsFetchError, setModelsFetchError] = useState<string | null>(null);
// Get the current selected provider (default to gemini) // Get the current selected provider (default to gemini)
const currentProvider: ProviderType = nodeData.selectedModel?.provider || "gemini"; const currentProvider: ProviderType = nodeData.selectedModel?.provider || "gemini";
@ -66,40 +67,51 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
}, [id, nodeData.model, nodeData.selectedModel, updateNodeData]); }, [id, nodeData.model, nodeData.selectedModel, updateNodeData]);
// Fetch models from external providers when provider changes // Fetch models from external providers when provider changes
useEffect(() => { const fetchModels = useCallback(async () => {
const fetchModels = async () => { if (currentProvider === "gemini") {
if (currentProvider === "gemini") { setExternalModels([]);
setExternalModels([]); setModelsFetchError(null);
return; return;
} }
setIsLoadingModels(true); setIsLoadingModels(true);
try { setModelsFetchError(null);
const capabilities = IMAGE_CAPABILITIES.join(","); try {
const headers: HeadersInit = {}; const capabilities = IMAGE_CAPABILITIES.join(",");
if (providerSettings.providers.replicate?.apiKey) { const headers: HeadersInit = {};
headers["X-Replicate-Key"] = providerSettings.providers.replicate.apiKey; if (providerSettings.providers.replicate?.apiKey) {
} headers["X-Replicate-Key"] = providerSettings.providers.replicate.apiKey;
if (providerSettings.providers.fal?.apiKey) { }
headers["X-Fal-Key"] = providerSettings.providers.fal.apiKey; if (providerSettings.providers.fal?.apiKey) {
} headers["X-Fal-Key"] = providerSettings.providers.fal.apiKey;
const response = await fetch(`/api/models?provider=${currentProvider}&capabilities=${capabilities}`, { headers }); }
if (response.ok) { const response = await fetch(`/api/models?provider=${currentProvider}&capabilities=${capabilities}`, { headers });
const data = await response.json(); if (response.ok) {
setExternalModels(data.models || []); const data = await response.json();
} else { setExternalModels(data.models || []);
setExternalModels([]); setModelsFetchError(null);
} } else {
} catch (error) { const errorData = await response.json().catch(() => ({}));
console.error("Failed to fetch models:", error); const errorMsg = errorData.error || `Failed to load models (${response.status})`;
setExternalModels([]); setExternalModels([]);
} finally { setModelsFetchError(
setIsLoadingModels(false); currentProvider === "replicate" && response.status === 401
? "Invalid Replicate API key. Check your settings."
: errorMsg
);
} }
}; } catch (error) {
console.error("Failed to fetch models:", error);
setExternalModels([]);
setModelsFetchError("Failed to load models. Check your connection.");
} finally {
setIsLoadingModels(false);
}
}, [currentProvider, providerSettings]);
useEffect(() => {
fetchModels(); fetchModels();
}, [currentProvider, providerSettings]); }, [fetchModels]);
// Handle provider change // Handle provider change
const handleProviderChange = useCallback( const handleProviderChange = useCallback(
@ -481,30 +493,43 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
</select> </select>
) : ( ) : (
<> <>
<select {/* Error state with retry button */}
value={nodeData.selectedModel?.modelId || ""} {modelsFetchError ? (
onChange={handleExternalModelChange} <div className="flex flex-col gap-1 shrink-0">
disabled={isLoadingModels} <span className="text-[9px] text-red-400">{modelsFetchError}</span>
className="w-full text-[10px] py-1 px-1.5 border border-neutral-700 rounded bg-neutral-900/50 focus:outline-none focus:ring-1 focus:ring-neutral-600 text-neutral-300 shrink-0 disabled:opacity-50" <button
> onClick={fetchModels}
{isLoadingModels ? ( className="text-[9px] px-2 py-0.5 bg-neutral-800 hover:bg-neutral-700 rounded text-neutral-300 transition-colors"
<option value="">Loading models...</option> >
) : externalModels.length === 0 ? ( Retry
<option value="">No models available</option> </button>
) : ( </div>
<> ) : (
<option value="">Select model...</option> <select
{externalModels.map((m) => ( value={nodeData.selectedModel?.modelId || ""}
<option key={m.id} value={m.id}> onChange={handleExternalModelChange}
{m.name} disabled={isLoadingModels}
</option> className="w-full text-[10px] py-1 px-1.5 border border-neutral-700 rounded bg-neutral-900/50 focus:outline-none focus:ring-1 focus:ring-neutral-600 text-neutral-300 shrink-0 disabled:opacity-50"
))} >
</> {isLoadingModels ? (
)} <option value="">Loading models...</option>
</select> ) : externalModels.length === 0 ? (
<option value="">No models available</option>
) : (
<>
<option value="">Select model...</option>
{externalModels.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</>
)}
</select>
)}
{/* Model-specific parameters for external providers */} {/* Model-specific parameters for external providers */}
{nodeData.selectedModel?.modelId && ( {nodeData.selectedModel?.modelId && !modelsFetchError && (
<ModelParameters <ModelParameters
modelId={nodeData.selectedModel.modelId} modelId={nodeData.selectedModel.modelId}
provider={currentProvider} provider={currentProvider}

117
src/components/nodes/GenerateVideoNode.tsx

@ -19,6 +19,7 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
const providerSettings = useWorkflowStore((state) => state.providerSettings); const providerSettings = useWorkflowStore((state) => state.providerSettings);
const [externalModels, setExternalModels] = useState<ProviderModel[]>([]); const [externalModels, setExternalModels] = useState<ProviderModel[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState(false); const [isLoadingModels, setIsLoadingModels] = useState(false);
const [modelsFetchError, setModelsFetchError] = useState<string | null>(null);
// Get the current selected provider (default to fal since Gemini doesn't do video) // Get the current selected provider (default to fal since Gemini doesn't do video)
const currentProvider: ProviderType = nodeData.selectedModel?.provider || "fal"; const currentProvider: ProviderType = nodeData.selectedModel?.provider || "fal";
@ -36,35 +37,45 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
}, [providerSettings]); }, [providerSettings]);
// Fetch models from external providers when provider changes // Fetch models from external providers when provider changes
useEffect(() => { const fetchModels = useCallback(async () => {
const fetchModels = async () => { setIsLoadingModels(true);
setIsLoadingModels(true); setModelsFetchError(null);
try { try {
const capabilities = VIDEO_CAPABILITIES.join(","); const capabilities = VIDEO_CAPABILITIES.join(",");
const headers: HeadersInit = {}; const headers: HeadersInit = {};
if (providerSettings.providers.replicate?.apiKey) { if (providerSettings.providers.replicate?.apiKey) {
headers["X-Replicate-Key"] = providerSettings.providers.replicate.apiKey; headers["X-Replicate-Key"] = providerSettings.providers.replicate.apiKey;
} }
if (providerSettings.providers.fal?.apiKey) { if (providerSettings.providers.fal?.apiKey) {
headers["X-Fal-Key"] = providerSettings.providers.fal.apiKey; headers["X-Fal-Key"] = providerSettings.providers.fal.apiKey;
} }
const response = await fetch(`/api/models?provider=${currentProvider}&capabilities=${capabilities}`, { headers }); const response = await fetch(`/api/models?provider=${currentProvider}&capabilities=${capabilities}`, { headers });
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setExternalModels(data.models || []); setExternalModels(data.models || []);
} else { setModelsFetchError(null);
setExternalModels([]); } else {
} const errorData = await response.json().catch(() => ({}));
} catch (error) { const errorMsg = errorData.error || `Failed to load models (${response.status})`;
console.error("Failed to fetch video models:", error);
setExternalModels([]); setExternalModels([]);
} finally { setModelsFetchError(
setIsLoadingModels(false); currentProvider === "replicate" && response.status === 401
? "Invalid Replicate API key. Check your settings."
: errorMsg
);
} }
}; } catch (error) {
console.error("Failed to fetch video models:", error);
setExternalModels([]);
setModelsFetchError("Failed to load models. Check your connection.");
} finally {
setIsLoadingModels(false);
}
}, [currentProvider, providerSettings]);
useEffect(() => {
fetchModels(); fetchModels();
}, [currentProvider, providerSettings]); }, [fetchModels]);
// Handle provider change // Handle provider change
const handleProviderChange = useCallback( const handleProviderChange = useCallback(
@ -250,30 +261,42 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
)} )}
{/* Model selector */} {/* Model selector */}
<select {modelsFetchError ? (
value={nodeData.selectedModel?.modelId || ""} <div className="flex flex-col gap-1 shrink-0">
onChange={handleModelChange} <span className="text-[9px] text-red-400">{modelsFetchError}</span>
disabled={isLoadingModels} <button
className="w-full text-[10px] py-1 px-1.5 border border-neutral-700 rounded bg-neutral-900/50 focus:outline-none focus:ring-1 focus:ring-neutral-600 text-neutral-300 shrink-0 disabled:opacity-50" onClick={fetchModels}
> className="text-[9px] px-2 py-0.5 bg-neutral-800 hover:bg-neutral-700 rounded text-neutral-300 transition-colors"
{isLoadingModels ? ( >
<option value="">Loading models...</option> Retry
) : externalModels.length === 0 ? ( </button>
<option value="">No video models available</option> </div>
) : ( ) : (
<> <select
<option value="">Select model...</option> value={nodeData.selectedModel?.modelId || ""}
{externalModels.map((m) => ( onChange={handleModelChange}
<option key={m.id} value={m.id}> disabled={isLoadingModels}
{m.name} className="w-full text-[10px] py-1 px-1.5 border border-neutral-700 rounded bg-neutral-900/50 focus:outline-none focus:ring-1 focus:ring-neutral-600 text-neutral-300 shrink-0 disabled:opacity-50"
</option> >
))} {isLoadingModels ? (
</> <option value="">Loading models...</option>
)} ) : externalModels.length === 0 ? (
</select> <option value="">No video models available</option>
) : (
<>
<option value="">Select model...</option>
{externalModels.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</>
)}
</select>
)}
{/* Model-specific parameters */} {/* Model-specific parameters */}
{nodeData.selectedModel?.modelId && ( {nodeData.selectedModel?.modelId && !modelsFetchError && (
<ModelParameters <ModelParameters
modelId={nodeData.selectedModel.modelId} modelId={nodeData.selectedModel.modelId}
provider={currentProvider} provider={currentProvider}

Loading…
Cancel
Save