Unify generation notifications across Studio

This commit is contained in:
WhiteRaven11
2026-07-28 17:55:56 +03:00
committed by jaiprasad04
parent 5517c1f8d7
commit 53464e8e3f
14 changed files with 370 additions and 71 deletions
+147 -41
View File
@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useParams, useRouter } from 'next/navigation';
import dynamic from 'next/dynamic';
import { ImageStudio, VideoStudio, ClippingStudio, VibeMotionStudio, LipSyncStudio, RecastStudio, CinemaStudio, AudioStudio, MarketingStudio, WorkflowStudio, AgentStudio, AppsStudio, AiInfluencerStudio, getUserBalance } from 'studio';
@@ -238,6 +238,35 @@ const getNavigationCategory = (tabId) => (
);
const STORAGE_KEY = 'muapi_key';
const NOTIFICATIONS_STORAGE_KEY = 'open_gen_notifications_v1';
const MAX_VISIBLE_NOTIFICATIONS = 3;
const loadStoredNotifications = () => {
if (typeof window === 'undefined') return [];
try {
const stored = JSON.parse(window.sessionStorage.getItem(NOTIFICATIONS_STORAGE_KEY) || '[]');
const now = Date.now();
return Array.isArray(stored)
? stored.filter((notification) => notification.expiresAt > now).slice(0, MAX_VISIBLE_NOTIFICATIONS)
: [];
} catch {
return [];
}
};
const persistNotifications = (notifications) => {
if (typeof window === 'undefined') return;
try {
window.sessionStorage.setItem(
NOTIFICATIONS_STORAGE_KEY,
JSON.stringify(notifications),
);
} catch {
// Notification persistence is optional; rendering still works without storage.
}
};
export default function StandaloneShell() {
const params = useParams();
@@ -327,26 +356,64 @@ export default function StandaloneShell() {
const [isDragging, setIsDragging] = useState(false);
const [droppedFiles, setDroppedFiles] = useState(null);
// ── Global Generation Notifications ────────────────────────────────────────
// Global generation notifications remain mounted while users switch studios.
const [notifications, setNotifications] = useState([]);
const activeTabRef = useRef(null);
useEffect(() => { activeTabRef.current = activeTab; }, [activeTab]);
const [notificationsHydrated, setNotificationsHydrated] = useState(false);
useEffect(() => {
setNotifications(loadStoredNotifications());
setNotificationsHydrated(true);
}, []);
const pushNotification = useCallback((notif) => {
const now = Date.now();
const id = `notif-${Date.now()}-${Math.random()}`;
const entry = { ...notif, id };
setNotifications(prev => [entry, ...prev].slice(0, 5));
const ttl = notif.type === 'success' ? 8000 : 6000;
setTimeout(() => setNotifications(prev => prev.filter(n => n.id !== id)), ttl);
const ttl = 12000;
const entry = { ...notif, id, expiresAt: now + ttl };
setNotifications((previous) => {
const next = [
...previous.filter((notification) => notification.expiresAt > now),
entry,
].slice(-MAX_VISIBLE_NOTIFICATIONS);
persistNotifications(next);
return next;
});
}, []);
const dismissNotification = useCallback((id) => {
setNotifications(prev => prev.filter(n => n.id !== id));
setNotifications((previous) => {
const next = previous.filter((notification) => notification.id !== id);
persistNotifications(next);
return next;
});
}, []);
useEffect(() => {
if (!notificationsHydrated) return;
persistNotifications(notifications);
}, [notifications, notificationsHydrated]);
useEffect(() => {
if (notifications.length === 0) return undefined;
const nextExpiry = Math.min(...notifications.map((notification) => notification.expiresAt));
const timer = window.setTimeout(() => {
const now = Date.now();
setNotifications((previous) => previous.filter((notification) => notification.expiresAt > now));
}, Math.max(0, nextExpiry - Date.now()));
return () => window.clearTimeout(timer);
}, [notifications]);
const makeSuccessCallback = useCallback((tabId) => (data) => {
const tab = TABS.find(t => t.id === tabId);
pushNotification({ type: 'success', tabId, label: tab?.label || tabId, data });
pushNotification({
type: 'success',
tabId,
label: tab?.label || tabId,
resultUrl: data?.url || null,
});
}, [pushNotification]);
const makeErrorCallback = useCallback((tabId) => (message) => {
@@ -368,10 +435,15 @@ export default function StandaloneShell() {
return () => window.removeEventListener('popstate', handlePopState);
}, []);
const handleTabChange = (tabId) => {
const handleTabChange = useCallback((tabId) => {
window.history.pushState(null, '', `/studio/${tabId}`);
setActiveTab(tabId);
};
}, []);
const handleOpenNotification = useCallback((notification) => {
handleTabChange(notification.tabId);
dismissNotification(notification.id);
}, [dismissNotification, handleTabChange]);
const handleTabClick = (e, tabId) => {
if (e.button === 0 && !e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey) {
@@ -848,85 +920,119 @@ export default function StandaloneShell() {
<MarketingStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} onGenerationComplete={makeSuccessCallback('marketing')} onGenerationError={makeErrorCallback('marketing')} />
</div>
<div className={activeTab === 'workflows' ? "h-full w-full" : "hidden"}>
<WorkflowStudio apiKey={apiKey} isHeaderVisible={isHeaderVisible} onToggleHeader={setIsHeaderVisible} />
<WorkflowStudio
apiKey={apiKey}
isHeaderVisible={isHeaderVisible}
onToggleHeader={setIsHeaderVisible}
onGenerationComplete={makeSuccessCallback('workflows')}
onGenerationError={makeErrorCallback('workflows')}
/>
</div>
<div className={activeTab === 'agents' ? "h-full w-full" : "hidden"}>
<AgentStudio apiKey={apiKey} isHeaderVisible={isHeaderVisible} onToggleHeader={setIsHeaderVisible} />
</div>
<div className={activeTab === 'design-agent' ? "h-full w-full" : "hidden"}>
{activeTab === 'design-agent' && (
<DesignAgentStudio apiKey={apiKey} isHeaderVisible={isHeaderVisible} onToggleHeader={setIsHeaderVisible} />
<DesignAgentStudio
apiKey={apiKey}
isHeaderVisible={isHeaderVisible}
onToggleHeader={setIsHeaderVisible}
onGenerationComplete={makeSuccessCallback('design-agent')}
onGenerationError={makeErrorCallback('design-agent')}
/>
)}
</div>
<div className={activeTab === 'apps' ? "h-full w-full" : "hidden"}>
<AppsStudio apiKey={apiKey} />
</div>
<div className={activeTab === 'ai-influencer' ? "h-full w-full" : "hidden"}>
<AiInfluencerStudio apiKey={apiKey} />
<AiInfluencerStudio
apiKey={apiKey}
onGenerationComplete={makeSuccessCallback('ai-influencer')}
onGenerationError={makeErrorCallback('ai-influencer')}
/>
</div>
</div>
</div>
{/* ── Global Generation Notification Stack ── */}
{/* Global generation notification stack */}
{notifications.length > 0 && (
<div
aria-live="polite"
className="fixed bottom-6 right-6 z-[200] flex flex-col gap-3 pointer-events-none"
style={{ maxWidth: '360px' }}
aria-label="Generation notifications"
className="fixed bottom-5 right-5 z-[200] flex w-[340px] max-w-[calc(100vw-32px)] flex-col gap-2 pointer-events-none"
data-testid="global-notification-stack"
>
{notifications.map((notif) => (
<div
key={notif.id}
className="pointer-events-auto flex items-start gap-3 bg-[#0e0e10] border rounded-xl px-4 py-3 shadow-2xl shadow-black/60"
role={notif.type === 'error' ? 'alert' : 'status'}
data-notification-type={notif.type}
data-notification-tab={notif.tabId}
className="pointer-events-auto flex items-start gap-3 rounded-xl border bg-[#0d0d0f] px-3.5 py-3 text-[13px] text-zinc-100 shadow-[0_16px_48px_rgba(0,0,0,0.65)]"
style={{
borderColor: notif.type === 'success' ? 'rgba(34,211,238,0.35)' : 'rgba(239,68,68,0.35)',
borderLeftWidth: '3px',
borderLeftColor: notif.type === 'success' ? '#22d3ee' : '#ef4444',
animation: 'slideInRight 280ms cubic-bezier(0.16,1,0.3,1) forwards',
}}
>
{/* Icon */}
<div
className="flex-shrink-0 w-7 h-7 rounded-full flex items-center justify-center mt-0.5"
style={{ background: notif.type === 'success' ? 'rgba(34,211,238,0.12)' : 'rgba(239,68,68,0.12)' }}
<span
className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border ${
notif.type === 'success'
? 'border-cyan-400/35 bg-cyan-400/10 text-cyan-300'
: 'border-red-500/35 bg-red-500/10 text-red-400'
}`}
>
{notif.type === 'success' ? (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#22d3ee" strokeWidth="3"><polyline points="20 6 9 17 4 12" /></svg>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m5 12 4 4L19 6" />
</svg>
) : (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="3"><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" /></svg>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<path d="M12 7v6" />
<path d="M12 17h.01" />
</svg>
)}
</div>
</span>
{/* Body */}
<div className="flex-1 min-w-0">
<p className="text-[12px] font-bold text-white/90 leading-tight">
<div className="min-w-0 flex-1">
<p className="font-semibold leading-5 text-zinc-100">
{notif.label}
<span className="font-normal text-white/50">
{notif.type === 'success' ? ' · Generation complete' : ' · Generation failed'}
<span className="font-normal text-zinc-400">
{notif.type === 'success' ? ' - Generation complete' : ' - Generation failed'}
</span>
</p>
{notif.type === 'error' && notif.message && (
<p className="text-[11px] text-red-400/80 mt-0.5 leading-snug truncate" title={notif.message}>
<p className="mt-0.5 line-clamp-2 text-[12px] font-medium leading-4 text-red-300/85" title={notif.message}>
{notif.message}
</p>
)}
{notif.type === 'success' && (
<p className="mt-0.5 text-[12px] leading-4 text-zinc-400">
Your result is ready.
</p>
)}
{notif.type === 'success' && (
<button
onClick={() => { handleTabChange(notif.tabId); dismissNotification(notif.id); }}
className="mt-1.5 text-[11px] font-bold text-[#22d3ee] hover:underline"
type="button"
onClick={() => handleOpenNotification(notif)}
className="mt-1.5 text-[11px] font-bold text-cyan-300 transition-colors hover:text-cyan-100"
aria-label={`Open ${notif.label} result`}
>
Open
Open
</button>
)}
</div>
{/* Dismiss */}
<button
type="button"
onClick={() => dismissNotification(notif.id)}
className="flex-shrink-0 text-white/30 hover:text-white/70 transition-colors text-lg leading-none mt-0.5"
aria-label="Dismiss"
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-zinc-500 transition-colors hover:bg-white/5 hover:text-zinc-200 focus:outline-none focus:ring-1 focus:ring-white/20"
aria-label="Dismiss notification"
>
×
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
))}
@@ -332,7 +332,13 @@ function HoverPill({ label, img, onClick }) {
}
// ─── Main Component ─────────────────────────────────────────────────────────
export default function AiInfluencerStudio({ apiKey, onGenerate, isGenerating: externalIsGenerating }) {
export default function AiInfluencerStudio({
apiKey,
onGenerate,
onGenerationComplete,
onGenerationError,
isGenerating: externalIsGenerating,
}) {
const [activeTab, setActiveTab] = useState("face");
const [selectedOptions, setSelectedOptions] = useState(() => {
@@ -408,9 +414,17 @@ export default function AiInfluencerStudio({ apiKey, onGenerate, isGenerating: e
setCurrentResult(res.url);
setHistory((prev) => [{ url: res.url, ts: Date.now() }, ...prev]);
setSelectedHistoryIdx(0);
onGenerationComplete?.({
url: res.url,
model: INFLUENCER_MODEL,
prompt,
type: "image",
});
}
} catch (err) {
toast.error(formatErrorMessage(err, "Generation failed. Please try again."));
const message = formatErrorMessage(err, "Generation failed. Please try again.");
if (onGenerationError) onGenerationError(message);
else toast.error(message);
} finally {
setIsGeneratingInternal(false);
}
@@ -683,8 +683,8 @@ export default function AudioStudio({
} catch (e) {
console.error("[AudioStudio]", e);
const errMsg = formatErrorMessage(e, "Audio generation failed");
toast.error(errMsg);
onGenerationError?.(errMsg);
if (onGenerationError) onGenerationError(errMsg);
else toast.error(errMsg);
} finally {
setIsGenerating(false);
}
@@ -23,6 +23,25 @@ import {
promptMediaButtonClassName,
} from "./prompt/PromptComposer.jsx";
const MAX_VIDEO_SIZE_MB = 100;
const MAX_VIDEO_SIZE_BYTES = MAX_VIDEO_SIZE_MB * 1024 * 1024;
const CLIPPING_TOASTER_ID = "clipping-studio";
const VIDEO_TOO_LARGE_FOR_MODE_MESSAGE =
"The file is too large for this mode. Compress or trim the video, then upload a smaller file.";
const MAX_VISIBLE_ERROR_TOASTS = 3;
const ERROR_TOAST_DURATION_MS = 7000;
const activeErrorToastIds = [];
const forgetErrorToast = (toastId) => {
const index = activeErrorToastIds.indexOf(toastId);
if (index !== -1) activeErrorToastIds.splice(index, 1);
};
const dismissErrorToast = (toastId) => {
forgetErrorToast(toastId);
toast.dismiss(toastId, CLIPPING_TOASTER_ID);
};
// ---------------------------------------------------------------------------
// Inline SVG Icons
// ---------------------------------------------------------------------------
@@ -63,6 +82,104 @@ const CopyIcon = () => (
</svg>
);
const ErrorToast = ({ toastInstance, message }) => (
<div
className={`pointer-events-auto flex w-[340px] max-w-[calc(100vw-32px)] items-start gap-3 rounded-xl border border-red-500/35 bg-[#0d0d0f] px-3.5 py-3 text-[13px] text-zinc-100 shadow-[0_16px_48px_rgba(0,0,0,0.65)] transition-all duration-200 ${
toastInstance.visible ? "translate-y-0 opacity-100" : "translate-y-2 opacity-0"
}`}
role="alert"
>
<span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-red-500/35 bg-red-500/10 text-red-400">
<svg
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v6" />
<path d="M12 17h.01" />
</svg>
</span>
<span className="min-w-0 flex-1 py-1 font-medium leading-5">{message}</span>
<button
type="button"
onClick={() => dismissErrorToast(toastInstance.id)}
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-zinc-500 transition-colors hover:bg-white/5 hover:text-zinc-200 focus:outline-none focus:ring-1 focus:ring-red-400/60"
aria-label="Dismiss notification"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden="true"
>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
);
const showErrorToast = (message) => {
const options = {
duration: ERROR_TOAST_DURATION_MS,
position: "bottom-right",
toasterId: CLIPPING_TOASTER_ID,
};
while (activeErrorToastIds.length >= MAX_VISIBLE_ERROR_TOASTS) {
const oldestToastId = activeErrorToastIds.shift();
toast.remove(oldestToastId, CLIPPING_TOASTER_ID);
}
const toastId = toast.custom(
(toastInstance) => (
<ErrorToast
toastInstance={toastInstance}
message={message}
/>
),
options,
);
activeErrorToastIds.push(toastId);
setTimeout(
() => forgetErrorToast(toastId),
ERROR_TOAST_DURATION_MS + 1000,
);
};
const showVideoSizeLimitToast = () => {
showErrorToast(`Video exceeds ${MAX_VIDEO_SIZE_MB}MB limit.`);
};
const isFileSizeError = (error) => {
const message = String(error?.message || error || "");
return /(?:\b413\b|payload too large|request entity too large|file(?: size)? (?:is )?too large|file is too heavy|exceeds?.*(?:size|limit)|слишком (?:больш|тяж)|превышает.*(?:размер|лимит))/i.test(message);
};
const showVideoUploadError = (error) => {
if (isFileSizeError(error)) {
showErrorToast(VIDEO_TOO_LARGE_FOR_MODE_MESSAGE);
return;
}
const message = formatErrorMessage(
error,
"Video upload failed. Please try again.",
);
showErrorToast(message);
};
const getAspectClass = (ar) => {
switch (ar) {
case "16:9": return "aspect-video";
@@ -209,8 +326,8 @@ export default function ClippingStudio({
const videoFiles = droppedFiles.filter(f => f.type.startsWith('video/'));
if (videoFiles.length > 0) {
const file = videoFiles[0];
if (file.size > 100 * 1024 * 1024) {
alert("Video exceeds 100MB limit.");
if (file.size > MAX_VIDEO_SIZE_BYTES) {
showVideoSizeLimitToast();
onFilesHandled?.();
return;
}
@@ -225,7 +342,7 @@ export default function ClippingStudio({
})
.catch(err => {
setVideoUploading(false);
alert(`Failed to upload dropped file: ${err.message}`);
showVideoUploadError(err);
});
}
onFilesHandled?.();
@@ -286,8 +403,9 @@ export default function ClippingStudio({
const handleVideoFileChange = async (e) => {
const file = e.target.files[0];
if (!file) return;
if (file.size > 100 * 1024 * 1024) {
alert("Video exceeds 100MB limit.");
if (file.size > MAX_VIDEO_SIZE_BYTES) {
showVideoSizeLimitToast();
if (videoFileInputRef.current) videoFileInputRef.current.value = "";
return;
}
setVideoUploading(true);
@@ -299,7 +417,7 @@ export default function ClippingStudio({
setVideoUrl(url);
} catch (err) {
console.error("[ClippingStudio] Video upload failed:", err);
alert(`Video upload failed: ${err.message}`);
showVideoUploadError(err);
} finally {
setVideoUploading(false);
setVideoProgress(0);
@@ -374,8 +492,11 @@ export default function ClippingStudio({
} catch (err) {
console.error("[ClippingStudio] Error generating clips:", err);
const errMsg = formatErrorMessage(err, "Failed to process AI clipping.");
toast.error(errMsg);
onGenerationError?.(errMsg);
const notificationMessage = isFileSizeError(err)
? VIDEO_TOO_LARGE_FOR_MODE_MESSAGE
: errMsg;
if (onGenerationError) onGenerationError(notificationMessage);
else showErrorToast(notificationMessage);
} finally {
setIsGenerating(false);
}
@@ -988,7 +1109,34 @@ export default function ClippingStudio({
scrollbar-color: rgba(255, 255, 255, 0.08) transparent;
}
`}</style>
<Toaster position="top-right" containerStyle={{ zIndex: 99999 }} toastOptions={{ duration: 5000, style: { background: '#18181b', color: '#ffffff', border: '1px solid rgba(255,255,255,0.15)', fontSize: '13px', borderRadius: '12px', boxShadow: '0 10px 30px rgba(0,0,0,0.6)', maxWidth: '440px', wordBreak: 'break-word', whiteSpace: 'pre-wrap', padding: '12px 16px' } }} />
<Toaster
toasterId={CLIPPING_TOASTER_ID}
position="bottom-right"
reverseOrder={false}
gutter={8}
containerStyle={{ zIndex: 99999, right: 20, bottom: 20 }}
toastOptions={{
duration: 6000,
style: {
background: "#0d0d0f",
color: "#f4f4f5",
border: "1px solid rgba(239,68,68,0.35)",
fontSize: "13px",
borderRadius: "12px",
boxShadow: "0 16px 48px rgba(0,0,0,0.65)",
maxWidth: "380px",
wordBreak: "break-word",
whiteSpace: "pre-wrap",
padding: "12px 14px",
},
error: {
iconTheme: {
primary: "#f87171",
secondary: "#0d0d0f",
},
},
}}
/>
</div>
);
}
@@ -5,7 +5,13 @@ import { CreativeCanvas } from 'design-agent';
import { getUserBalance } from '../muapi';
export default function DesignAgentStudio({ apiKey, isHeaderVisible, onToggleHeader }) {
export default function DesignAgentStudio({
apiKey,
isHeaderVisible,
onToggleHeader,
onGenerationComplete,
onGenerationError,
}) {
const [userData, setUserData] = useState(null);
useEffect(() => {
@@ -38,6 +44,8 @@ export default function DesignAgentStudio({ apiKey, isHeaderVisible, onToggleHea
theme="dark"
onToggleHeader={onToggleHeader}
isHeaderVisible={isHeaderVisible}
onGenerationComplete={onGenerationComplete}
onGenerationError={onGenerationError}
/>
</div>
);
@@ -1272,8 +1272,8 @@ export default function ImageStudio({
} catch (e) {
console.error("[ImageStudio] Generation failed:", e);
const errMsg = formatErrorMessage(e, "Image generation failed");
toast.error(errMsg);
onGenerationError?.(errMsg);
if (onGenerationError) onGenerationError(errMsg);
else toast.error(errMsg);
} finally {
setGenerating(false);
}
@@ -691,8 +691,8 @@ export default function LipSyncStudio({
} catch (e) {
console.error("[LipSyncStudio]", e);
const errMsg = formatErrorMessage(e, "Lip sync generation failed");
toast.error(errMsg);
onGenerationError?.(errMsg);
if (onGenerationError) onGenerationError(errMsg);
else toast.error(errMsg);
} finally {
setIsGenerating(false);
}
@@ -765,8 +765,8 @@ export default function RecastStudio({
} catch (e) {
console.error("[RecastStudio]", e);
const errMsg = formatErrorMessage(e, "Body swap generation failed");
toast.error(errMsg);
onGenerationError?.(errMsg);
if (onGenerationError) onGenerationError(errMsg);
else toast.error(errMsg);
} finally {
setIsGenerating(false);
}
@@ -193,14 +193,15 @@ export default function VibeMotionStudio({ apiKey, onGenerationComplete, onGener
if (isStaleEdit) {
console.warn("[VibeMotionStudio] Remix unavailable:", raw.slice(0, 120));
const msg = "This generation can't be remixed — the animation code wasn't saved server-side. Generate a new motion graphic first, then remix that result.";
toast.error(msg);
if (onGenerationError) onGenerationError(msg);
else toast.error(msg);
setEditMode(false);
setEditSourceId(null);
} else {
console.error("[VibeMotionStudio]", err);
const errMsg = formatErrorMessage(raw || err, "Vibe Motion generation failed");
toast.error(errMsg);
onGenerationError?.(errMsg);
if (onGenerationError) onGenerationError(errMsg);
else toast.error(errMsg);
}
} finally {
setGenerating(false);
@@ -1256,8 +1256,8 @@ export default function VideoStudio({
hadError = true;
console.error("[VideoStudio]", e);
const errMsg = formatErrorMessage(e, "Video generation failed");
toast.error(errMsg);
onGenerationError?.(errMsg);
if (onGenerationError) onGenerationError(errMsg);
else toast.error(errMsg);
} finally {
setGenerating(false);
}
@@ -125,7 +125,13 @@ function WorkflowCard({ workflow, onClick, activeTab, onRename, onDelete }) {
);
}
export default function WorkflowStudio({ apiKey, isHeaderVisible = true, onToggleHeader }) {
export default function WorkflowStudio({
apiKey,
isHeaderVisible = true,
onToggleHeader,
onGenerationComplete,
onGenerationError,
}) {
const params = useParams();
const router = useRouter();
const slug = params?.slug || [];
@@ -414,9 +420,15 @@ export default function WorkflowStudio({ apiKey, isHeaderVisible = true, onToggl
const data = await executeWorkflow(apiKey, selectedWorkflow.id, inputs);
setResult(data);
onGenerationComplete?.({
url: data?.url || data?.output?.url || data?.outputs?.[0]?.url || null,
type: "workflow",
});
} catch (err) {
console.error("Execution failed:", err);
setError(err.message || "Execution failed");
const message = err.message || "Execution failed";
setError(message);
onGenerationError?.(message);
} finally {
setIsExecuting(false);
}
@@ -815,6 +827,8 @@ export default function WorkflowStudio({ apiKey, isHeaderVisible = true, onToggl
// Inject ID to prevent builder from assuming this is a new unsaved flow
workflow_id: selectedWorkflow?.id
}}
onGenerationComplete={onGenerationComplete}
onGenerationError={onGenerationError}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
@@ -6,7 +6,13 @@ import "reactflow/dist/style.css";
import "react-toastify/dist/ReactToastify.css";
const WorkflowUI = ({ workflowId, initialNodeSchemas, initialWorkflowData }) => {
const WorkflowUI = ({
workflowId,
initialNodeSchemas,
initialWorkflowData,
onGenerationComplete,
onGenerationError,
}) => {
useEffect(() => {
sessionStorage.setItem("fromWorkflowBuilder", "true");
}, []);
@@ -18,6 +24,8 @@ const WorkflowUI = ({ workflowId, initialNodeSchemas, initialWorkflowData }) =>
initialNodeSchemas={initialNodeSchemas}
initialWorkflowData={initialWorkflowData}
costType="dollars"
onGenerationComplete={onGenerationComplete}
onGenerationError={onGenerationError}
/>
</div>
);