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) {
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 {
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) {
return {
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") {
const failureReason = currentPrediction.error || "Prediction failed";
console.error(`[API:${requestId}] Replicate prediction failed: ${failureReason}`);
return {
success: false,
error: currentPrediction.error || "Prediction failed",
error: `${input.model.name}: ${failureReason}`,
};
}
@ -496,9 +514,25 @@ async function generateWithFal(
if (!response.ok) {
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 {
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 [externalModels, setExternalModels] = useState<ProviderModel[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState(false);
const [modelsFetchError, setModelsFetchError] = useState<string | null>(null);
// Get the current selected provider (default to 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]);
// Fetch models from external providers when provider changes
useEffect(() => {
const fetchModels = async () => {
if (currentProvider === "gemini") {
setExternalModels([]);
return;
}
const fetchModels = useCallback(async () => {
if (currentProvider === "gemini") {
setExternalModels([]);
setModelsFetchError(null);
return;
}
setIsLoadingModels(true);
try {
const capabilities = IMAGE_CAPABILITIES.join(",");
const headers: HeadersInit = {};
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;
}
const response = await fetch(`/api/models?provider=${currentProvider}&capabilities=${capabilities}`, { headers });
if (response.ok) {
const data = await response.json();
setExternalModels(data.models || []);
} else {
setExternalModels([]);
}
} catch (error) {
console.error("Failed to fetch models:", error);
setIsLoadingModels(true);
setModelsFetchError(null);
try {
const capabilities = IMAGE_CAPABILITIES.join(",");
const headers: HeadersInit = {};
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;
}
const response = await fetch(`/api/models?provider=${currentProvider}&capabilities=${capabilities}`, { headers });
if (response.ok) {
const data = await response.json();
setExternalModels(data.models || []);
setModelsFetchError(null);
} else {
const errorData = await response.json().catch(() => ({}));
const errorMsg = errorData.error || `Failed to load models (${response.status})`;
setExternalModels([]);
} finally {
setIsLoadingModels(false);
setModelsFetchError(
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();
}, [currentProvider, providerSettings]);
}, [fetchModels]);
// Handle provider change
const handleProviderChange = useCallback(
@ -481,30 +493,43 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
</select>
) : (
<>
<select
value={nodeData.selectedModel?.modelId || ""}
onChange={handleExternalModelChange}
disabled={isLoadingModels}
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>
) : 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>
{/* Error state with retry button */}
{modelsFetchError ? (
<div className="flex flex-col gap-1 shrink-0">
<span className="text-[9px] text-red-400">{modelsFetchError}</span>
<button
onClick={fetchModels}
className="text-[9px] px-2 py-0.5 bg-neutral-800 hover:bg-neutral-700 rounded text-neutral-300 transition-colors"
>
Retry
</button>
</div>
) : (
<select
value={nodeData.selectedModel?.modelId || ""}
onChange={handleExternalModelChange}
disabled={isLoadingModels}
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>
) : 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 */}
{nodeData.selectedModel?.modelId && (
{nodeData.selectedModel?.modelId && !modelsFetchError && (
<ModelParameters
modelId={nodeData.selectedModel.modelId}
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 [externalModels, setExternalModels] = useState<ProviderModel[]>([]);
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)
const currentProvider: ProviderType = nodeData.selectedModel?.provider || "fal";
@ -36,35 +37,45 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
}, [providerSettings]);
// Fetch models from external providers when provider changes
useEffect(() => {
const fetchModels = async () => {
setIsLoadingModels(true);
try {
const capabilities = VIDEO_CAPABILITIES.join(",");
const headers: HeadersInit = {};
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;
}
const response = await fetch(`/api/models?provider=${currentProvider}&capabilities=${capabilities}`, { headers });
if (response.ok) {
const data = await response.json();
setExternalModels(data.models || []);
} else {
setExternalModels([]);
}
} catch (error) {
console.error("Failed to fetch video models:", error);
const fetchModels = useCallback(async () => {
setIsLoadingModels(true);
setModelsFetchError(null);
try {
const capabilities = VIDEO_CAPABILITIES.join(",");
const headers: HeadersInit = {};
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;
}
const response = await fetch(`/api/models?provider=${currentProvider}&capabilities=${capabilities}`, { headers });
if (response.ok) {
const data = await response.json();
setExternalModels(data.models || []);
setModelsFetchError(null);
} else {
const errorData = await response.json().catch(() => ({}));
const errorMsg = errorData.error || `Failed to load models (${response.status})`;
setExternalModels([]);
} finally {
setIsLoadingModels(false);
setModelsFetchError(
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();
}, [currentProvider, providerSettings]);
}, [fetchModels]);
// Handle provider change
const handleProviderChange = useCallback(
@ -250,30 +261,42 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
)}
{/* Model selector */}
<select
value={nodeData.selectedModel?.modelId || ""}
onChange={handleModelChange}
disabled={isLoadingModels}
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>
) : externalModels.length === 0 ? (
<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>
{modelsFetchError ? (
<div className="flex flex-col gap-1 shrink-0">
<span className="text-[9px] text-red-400">{modelsFetchError}</span>
<button
onClick={fetchModels}
className="text-[9px] px-2 py-0.5 bg-neutral-800 hover:bg-neutral-700 rounded text-neutral-300 transition-colors"
>
Retry
</button>
</div>
) : (
<select
value={nodeData.selectedModel?.modelId || ""}
onChange={handleModelChange}
disabled={isLoadingModels}
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>
) : externalModels.length === 0 ? (
<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 */}
{nodeData.selectedModel?.modelId && (
{nodeData.selectedModel?.modelId && !modelsFetchError && (
<ModelParameters
modelId={nodeData.selectedModel.modelId}
provider={currentProvider}

Loading…
Cancel
Save