feat: implement workflow identity persistence, unify api auth, and upgrade tailwind to v4

This commit is contained in:
Jaya Prasad Kavuru 2026-04-21 19:38:34 +05:30
parent 6f9cdeeb47
commit efa772e772
18 changed files with 4654 additions and 250 deletions

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "packages/workflow-ui"]
path = packages/workflow-ui
url = https://github.com/Anil-matcha/workflow-ui.git

View file

@ -0,0 +1,145 @@
import { NextResponse } from 'next/server';
const MUAPI_BASE = 'https://api.muapi.ai';
function getApiKey(request) {
// Priority 1: Direct x-api-key header
const headerKey = request.headers.get('x-api-key');
if (headerKey) return headerKey;
// Priority 2: muapi_key cookie (used by the fixed builder library)
const cookieKey = request.cookies.get('muapi_key')?.value;
return cookieKey;
}
function cleanHeaders(request) {
const headers = new Headers(request.headers);
headers.delete('host');
headers.delete('connection');
headers.delete('cookie'); // CRITICAL: Stop forwarding browser cookies to MuAPI to avoid auth conflicts
return headers;
}
export async function GET(request, { params }) {
const slug = await params;
const pathSegments = slug.path || [];
const path = pathSegments.join('/');
// Handle alias: get_upload_file -> get_file_upload_url
const effectivePath = path === 'get_upload_file' ? 'get_file_upload_url' : path;
const { search } = new URL(request.url);
const targetUrl = `${MUAPI_BASE}/app/${effectivePath}${search}`;
const headers = cleanHeaders(request);
const apiKey = getApiKey(request);
if (apiKey) headers.set('x-api-key', apiKey);
try {
const response = await fetch(targetUrl, {
headers,
method: 'GET',
});
const data = await response.json();
// SPECIAL CASE: Intercept upload URL and redirect to local binary proxy
if (effectivePath === 'get_file_upload_url' && data.url) {
const originalS3Url = data.url;
// We pass the real S3 URL as a header to our proxy
data.url = `/api/upload-binary`;
// Store target in a temporary way?
// Better: Return the target URL as an extra field that our proxy will look for
data.fields = {
...data.fields,
'x-proxy-target-url': originalS3Url
};
}
return NextResponse.json(data, { status: response.status });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function POST(request, { params }) {
const slug = await params;
const pathSegments = slug.path || [];
const path = pathSegments.join('/');
const { search } = new URL(request.url);
const targetUrl = `${MUAPI_BASE}/app/${path}${search}`;
const headers = cleanHeaders(request);
const apiKey = getApiKey(request);
if (apiKey) headers.set('x-api-key', apiKey);
try {
const body = await request.arrayBuffer();
const response = await fetch(targetUrl, {
method: 'POST',
headers,
body
});
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function DELETE(request, { params }) {
const slug = await params;
const pathSegments = slug.path || [];
const path = pathSegments.join('/');
const { search } = new URL(request.url);
const targetUrl = `${MUAPI_BASE}/app/${path}${search}`;
const headers = cleanHeaders(request);
const apiKey = getApiKey(request);
if (apiKey) headers.set('x-api-key', apiKey);
try {
const response = await fetch(targetUrl, {
method: 'DELETE',
headers
});
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function PUT(request, { params }) {
const slug = await params;
const pathSegments = slug.path || [];
const path = pathSegments.join('/');
const { search } = new URL(request.url);
const targetUrl = `${MUAPI_BASE}/app/${path}${search}`;
const headers = cleanHeaders(request);
const apiKey = getApiKey(request);
if (apiKey) headers.set('x-api-key', apiKey);
try {
const body = await request.arrayBuffer();
const response = await fetch(targetUrl, {
method: 'PUT',
headers,
body
});
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View file

@ -0,0 +1,44 @@
import { NextResponse } from 'next/server';
export async function POST(request) {
try {
const formData = await request.formData();
// Extract the original S3 target URL we injected earlier
const targetUrl = formData.get('x-proxy-target-url');
if (!targetUrl) {
return NextResponse.json({ error: 'Missing proxy target URL' }, { status: 400 });
}
// Reconstruct the FormData for S3 (excluding our internal proxy marker)
const s3FormData = new FormData();
// S3 is very sensitive to field ordering. We must ensure 'file' is likely last
// or at least that all signature fields come before what S3 expects.
// The original library code appends 'file' last, so iterating should preserve that.
for (const [key, value] of formData.entries()) {
if (key !== 'x-proxy-target-url') {
s3FormData.append(key, value);
}
}
// Perform the server-to-server POST to S3
// This bypasses browser CORS/Preflight security entirely
const s3Response = await fetch(targetUrl, {
method: 'POST',
body: s3FormData,
});
if (s3Response.ok || s3Response.status === 204) {
return new Response(null, { status: 204 });
} else {
const errorText = await s3Response.text();
console.error('S3 Proxy Error:', errorText);
return new Response(errorText, { status: s3Response.status });
}
} catch (error) {
console.error('Upload Proxy Exception:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View file

@ -0,0 +1,137 @@
import { NextResponse } from 'next/server';
const MUAPI_BASE = 'https://api.muapi.ai';
function getApiKey(request) {
// Priority 1: Direct x-api-key header
const headerKey = request.headers.get('x-api-key');
if (headerKey) return headerKey;
// Priority 2: muapi_key cookie (used by the fixed builder library)
const cookieKey = request.cookies.get('muapi_key')?.value;
return cookieKey;
}
function cleanHeaders(request) {
const headers = new Headers(request.headers);
headers.delete('host');
headers.delete('connection');
headers.delete('cookie'); // CRITICAL: Stop forwarding browser cookies to MuAPI to avoid auth conflicts
return headers;
}
export async function GET(request, { params }) {
const slug = await params;
const pathSegments = slug.path || [];
const path = pathSegments.join('/');
const { search } = new URL(request.url);
const targetUrl = `${MUAPI_BASE}/workflow/${path}${search}`;
const headers = cleanHeaders(request);
const apiKey = getApiKey(request);
console.log(`[proxy GET] ${targetUrl} | apiKey: ${apiKey ? apiKey.slice(0,8)+'...' : 'MISSING'}`);
if (apiKey) headers.set('x-api-key', apiKey);
try {
const response = await fetch(targetUrl, {
headers,
method: 'GET',
});
const data = await response.json();
if (path.includes('get-workflow-def')) {
console.log(`[proxy GET] get-workflow-def response: is_owner=${data?.is_owner}, workflow_id=${data?.workflow_id}`);
}
return NextResponse.json(data, { status: response.status });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function POST(request, { params }) {
const slug = await params;
const pathSegments = slug.path || [];
const path = pathSegments.join('/');
const { search } = new URL(request.url);
const targetUrl = `${MUAPI_BASE}/workflow/${path}${search}`;
const headers = cleanHeaders(request);
const apiKey = getApiKey(request);
console.log(`[proxy POST] ${targetUrl} | apiKey: ${apiKey ? apiKey.slice(0,8)+'...' : 'MISSING'} | cookie: ${request.cookies.get('muapi_key')?.value?.slice(0,8) || 'NONE'} | header: ${request.headers.get('x-api-key')?.slice(0,8) || 'NONE'}`);
if (apiKey) headers.set('x-api-key', apiKey);
try {
const body = await request.arrayBuffer();
// Decode body to see what workflow_id is being sent
try {
const parsed = JSON.parse(Buffer.from(body).toString('utf-8'));
console.log(`[proxy POST] body: workflow_id=${parsed.workflow_id}, source_workflow_id=${parsed.source_workflow_id}, name=${parsed.name}`);
} catch(e) { /* ignore decode errors */ }
const response = await fetch(targetUrl, {
method: 'POST',
headers,
body
});
const data = await response.json();
console.log(`[proxy POST] response: status=${response.status}`, JSON.stringify(data).slice(0, 200));
return NextResponse.json(data, { status: response.status });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function DELETE(request, { params }) {
const slug = await params;
const pathSegments = slug.path || [];
const path = pathSegments.join('/');
const { search } = new URL(request.url);
const targetUrl = `${MUAPI_BASE}/workflow/${path}${search}`;
const headers = cleanHeaders(request);
const apiKey = getApiKey(request);
if (apiKey) headers.set('x-api-key', apiKey);
try {
const response = await fetch(targetUrl, {
method: 'DELETE',
headers
});
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function PUT(request, { params }) {
const slug = await params;
const pathSegments = slug.path || [];
const path = pathSegments.join('/');
const { search } = new URL(request.url);
const targetUrl = `${MUAPI_BASE}/workflow/${path}${search}`;
const headers = cleanHeaders(request);
const apiKey = getApiKey(request);
if (apiKey) headers.set('x-api-key', apiKey);
try {
const body = await request.arrayBuffer();
const response = await fetch(targetUrl, {
method: 'PUT',
headers,
body
});
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View file

@ -0,0 +1,9 @@
import StandaloneShell from '@/components/StandaloneShell';
export const metadata = {
title: 'Workflow — Open Generative AI',
};
export default function WorkflowTabPage() {
return <StandaloneShell />;
}

View file

@ -0,0 +1,9 @@
import StandaloneShell from '@/components/StandaloneShell';
export const metadata = {
title: 'Workflow — Open Generative AI',
};
export default function WorkflowPage() {
return <StandaloneShell />;
}

View file

@ -1,7 +1,9 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { ImageStudio, VideoStudio, LipSyncStudio, CinemaStudio, getUserBalance } from 'studio';
import { useParams, useRouter } from 'next/navigation';
import { ImageStudio, VideoStudio, LipSyncStudio, CinemaStudio, WorkflowStudio, getUserBalance } from 'studio';
import axios from 'axios';
import ApiKeyModal from './ApiKeyModal';
const TABS = [
@ -9,17 +11,86 @@ const TABS = [
{ id: 'video', label: 'Video Studio' },
{ id: 'lipsync', label: 'Lip Sync' },
{ id: 'cinema', label: 'Cinema Studio' },
{ id: 'workflows', label: 'Workflows' },
];
const STORAGE_KEY = 'muapi_key';
export default function StandaloneShell() {
const params = useParams();
const router = useRouter();
const slug = params?.slug || [];
const idFromParams = params?.id;
const tabFromParams = params?.tab;
// Helper to extract workflow details precisely from either route structure
const getWorkflowInfo = useCallback(() => {
if (idFromParams) {
return { id: idFromParams, tab: tabFromParams || null };
}
const wfIndex = slug.findIndex(s => s === 'workflows' || s === 'workflow');
if (wfIndex === -1) return { id: null, tab: null };
return {
id: slug[wfIndex + 1] || null,
tab: slug[wfIndex + 2] || null
};
}, [slug, idFromParams, tabFromParams]);
const { id: urlWorkflowId } = getWorkflowInfo();
// Initialize activeTab from URL slug/params or default to 'image'
const getInitialTab = () => {
if (idFromParams || slug.includes('workflow')) return 'workflows';
const firstSegment = slug[0];
if (firstSegment && TABS.find(t => t.id === firstSegment)) return firstSegment;
return 'image';
};
const [apiKey, setApiKey] = useState(null);
const [activeTab, setActiveTab] = useState('image');
const [activeTab, setActiveTab] = useState(getInitialTab());
const [balance, setBalance] = useState(null);
const [showSettings, setShowSettings] = useState(false);
const [isHeaderVisible, setIsHeaderVisible] = useState(true);
const [hasMounted, setHasMounted] = useState(false);
// Sync tab with URL if user navigates manually or via browser back/forward
useEffect(() => {
const info = getWorkflowInfo();
if (info.id) {
setActiveTab('workflows');
} else {
const firstSegment = slug[0];
if (firstSegment && TABS.find(t => t.id === firstSegment)) {
setActiveTab(firstSegment);
}
}
}, [slug, getWorkflowInfo]);
const handleTabChange = (tabId) => {
setActiveTab(tabId);
router.push(`/studio/${tabId}`);
};
// Auto-hide header when inside a specific workflow view
useEffect(() => {
const isEditingWorkflow = (activeTab === 'workflows' || !!idFromParams) && urlWorkflowId;
if (isEditingWorkflow) {
setIsHeaderVisible(false);
} else {
setIsHeaderVisible(true);
}
}, [activeTab, urlWorkflowId, idFromParams]);
// Global builder CSS cleanup when switching away from Workflows tab
useEffect(() => {
const fromBuilder = sessionStorage.getItem("fromWorkflowBuilder");
if (fromBuilder && activeTab !== 'workflows') {
sessionStorage.removeItem("fromWorkflowBuilder");
window.location.reload();
}
}, [activeTab]);
const fetchBalance = useCallback(async (key) => {
try {
const data = await getUserBalance(key);
@ -35,6 +106,8 @@ export default function StandaloneShell() {
if (stored) {
setApiKey(stored);
fetchBalance(stored);
// Sync cookie immediately on mount to establish identity for background requests
document.cookie = `muapi_key=${stored}; path=/; max-age=31536000; SameSite=Lax`;
}
}, [fetchBalance]);
@ -42,14 +115,41 @@ export default function StandaloneShell() {
localStorage.setItem(STORAGE_KEY, key);
setApiKey(key);
fetchBalance(key);
document.cookie = `muapi_key=${key}; path=/; max-age=31536000; SameSite=Lax`;
}, [fetchBalance]);
const handleKeyChange = useCallback(() => {
localStorage.removeItem(STORAGE_KEY);
setApiKey(null);
setBalance(null);
document.cookie = "muapi_key=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT";
}, []);
// Inject API key into all outgoing Axios requests (prop-based approach)
// We use an interceptor to be selective and NOT send the key to external domains like S3
useEffect(() => {
// Safety: Clear any global defaults that might have been set previously
delete axios.defaults.headers.common['x-api-key'];
if (!apiKey) return;
const interceptorId = axios.interceptors.request.use((config) => {
// Check if URL is local/proxied
const isRelative = config.url.startsWith('/') || !config.url.startsWith('http');
const isInternalProxy = config.url.includes('/api/app') || config.url.includes('/api/workflow') || config.url.includes('/api/v1');
if (isRelative || isInternalProxy) {
config.headers['x-api-key'] = apiKey;
}
return config;
});
return () => {
axios.interceptors.request.eject(interceptorId);
};
}, [apiKey]);
// Poll for balance every 30 seconds if key is present
useEffect(() => {
if (!apiKey) return;
@ -70,61 +170,64 @@ export default function StandaloneShell() {
return (
<div className="h-screen bg-[#030303] flex flex-col overflow-hidden text-white">
{/* Header */}
<header className="flex-shrink-0 h-14 border-b border-white/[0.03] flex items-center justify-between px-6 bg-black/20 backdrop-blur-md z-40">
{/* Left: Logo */}
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-white rounded-lg flex items-center justify-center">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="black" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
</div>
<span className="text-sm font-bold tracking-tight hidden sm:block">OpenGenerativeAI</span>
</div>
{/* Center: Navigation */}
<nav className="absolute left-1/2 -translate-x-1/2 flex items-center gap-6">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`relative py-4 text-[13px] font-medium transition-all whitespace-nowrap px-1 ${
activeTab === tab.id
? 'text-[#d9ff00]'
: 'text-white/50 hover:text-white'
}`}
>
{tab.label}
{activeTab === tab.id && (
<div className="absolute bottom-0 left-0 right-0 h-[2px] bg-[#d9ff00] rounded-full" />
)}
</button>
))}
</nav>
{/* Right: Actions */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-3 bg-white/5 px-3 py-1.5 rounded-full border border-white/5 transition-colors">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
<div className="flex flex-col">
<span className="text-xs font-bold text-white/90">
${balance !== null ? `${balance}` : '---'}
</span>
{isHeaderVisible && (
<header className="flex-shrink-0 h-14 border-b border-white/[0.03] flex items-center justify-between px-6 bg-black/20 backdrop-blur-md z-40">
{/* Left: Logo */}
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-white rounded-lg flex items-center justify-center">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="black" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
</div>
<span className="text-sm font-bold tracking-tight hidden sm:block">OpenGenerativeAI</span>
</div>
<div
onClick={() => setShowSettings(true)}
className="w-8 h-8 rounded-full bg-gradient-to-tr from-[#d9ff00] to-yellow-200 border border-white/20 cursor-pointer hover:scale-105 transition-transform"
/>
</div>
</header>
{/* Center: Navigation */}
<nav className="absolute left-1/2 -translate-x-1/2 flex items-center gap-6">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => handleTabChange(tab.id)}
className={`relative py-4 text-[13px] font-medium transition-all whitespace-nowrap px-1 ${
activeTab === tab.id
? 'text-[#d9ff00]'
: 'text-white/50 hover:text-white'
}`}
>
{tab.label}
{activeTab === tab.id && (
<div className="absolute bottom-0 left-0 right-0 h-[2px] bg-[#d9ff00] rounded-full" />
)}
</button>
))}
</nav>
{/* Right: Actions */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-3 bg-white/5 px-3 py-1.5 rounded-full border border-white/5 transition-colors">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
<div className="flex flex-col">
<span className="text-xs font-bold text-white/90">
${balance !== null ? `${balance}` : '---'}
</span>
</div>
</div>
<div
onClick={() => setShowSettings(true)}
className="w-8 h-8 rounded-full bg-gradient-to-tr from-[#d9ff00] to-yellow-200 border border-white/20 cursor-pointer hover:scale-105 transition-transform"
/>
</div>
</header>
)}
{/* Studio Content */}
<div className="flex-1">
<div className="flex-1 min-h-0 relative overflow-hidden">
{activeTab === 'image' && <ImageStudio apiKey={apiKey} />}
{activeTab === 'video' && <VideoStudio apiKey={apiKey} />}
{activeTab === 'lipsync' && <LipSyncStudio apiKey={apiKey} />}
{activeTab === 'cinema' && <CinemaStudio apiKey={apiKey} />}
{activeTab === 'workflows' && <WorkflowStudio apiKey={apiKey} isHeaderVisible={isHeaderVisible} onToggleHeader={setIsHeaderVisible} />}
</div>
{/* Settings Modal */}
@ -139,7 +242,7 @@ export default function StandaloneShell() {
<div className="space-y-4 mb-8">
<div className="bg-white/5 border border-white/[0.03] rounded-md p-4">
<label className="block text-xs font-bold text-white/30 mb-2">
Active API Key
Active API Key
</label>
<div className="text-[13px] font-mono text-white/80">
{apiKey.slice(0, 8)}

31
middleware.js Normal file
View file

@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
export function middleware(request) {
const url = request.nextUrl;
// Catch requests to /api/workflow, /api/app, and /api/v1
const isMuApi = url.pathname.startsWith('/api/workflow') ||
url.pathname.startsWith('/api/app') ||
url.pathname.startsWith('/api/v1');
if (isMuApi) {
// Remap /api/v1 ONLY if it's not handled by a specific route.
// Actually, we'll let existing remapping for /api/v1 stay if needed,
// but we'll remove app/workflow as they need special handling.
if (url.pathname.startsWith('/api/v1')) {
const targetUrl = new URL(url.pathname + url.search, 'https://api.muapi.ai');
return NextResponse.rewrite(targetUrl);
}
}
return NextResponse.next();
}
// Match the paths we want to proxy
export const config = {
matcher: [
'/api/workflow/:path*',
'/api/app/:path*',
'/api/v1/:path*'
],
};

2970
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -90,7 +90,8 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hot-toast": "^2.4.1",
"studio": "*"
"studio": "*",
"workflow-builder": "file:./packages/workflow-ui"
},
"devDependencies": {
"@eslint/eslintrc": "^3",

View file

@ -14,9 +14,17 @@
},
"license": "MIT",
"dependencies": {
"@xyflow/react": "^12.10.2",
"axios": "^1.7.0",
"lucide-react": "^1.8.0",
"react-hot-toast": "^2.4.1",
"react-icons": "^5.0.1",
"react-hot-toast": "^2.4.1"
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.1",
"react-toastify": "^11.1.0",
"reactflow": "^11.11.4",
"remark-gfm": "^4.0.1",
"workflow-builder": "file:../workflow-ui"
},
"peerDependencies": {
"react": ">=18.0.0",

View file

@ -1014,7 +1014,7 @@ export default function ImageStudio({
{history.map((entry, idx) => (
<div
key={entry.id || idx}
className="relative group rounded-2xl overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-primary/50 transition-all duration-300 flex flex-col"
className="relative group rounded-lg overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-primary/50 transition-all duration-300 flex flex-col"
>
<img
src={entry.url}
@ -1206,7 +1206,7 @@ export default function ImageStudio({
{dropdownOpen === "ar" && (
<div
onClick={(e) => e.stopPropagation()}
className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0a0a0a] rounded-md p-3 shadow-2xl border border-white/10 min-w-[160px]"
className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0a0a0a] rounded-md p-3 max-h-[40vh] overflow-y-auto custom-scrollbar shadow-2xl border border-white/10 min-w-[160px]"
>
<SimpleDropdown
title="Aspect Ratio"
@ -1228,7 +1228,7 @@ export default function ImageStudio({
e.stopPropagation();
setDropdownOpen((o) => (o === "quality" ? null : "quality"));
}}
className="flex items-center gap-2 px-3 py-2 bg-white/[0.03] hover:bg-white/[0.06] rounded-xl transition-all border border-white/[0.03] group whitespace-nowrap"
className="flex items-center gap-2 px-3 py-2 bg-white/[0.03] hover:bg-white/[0.06] rounded-md transition-all border border-white/[0.03] group whitespace-nowrap"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="opacity-40 text-white">
<path d="M6 2L3 6v15a2 2 0 002 2h14a2 2 0 002-2V6l-3-4H6z" />
@ -1241,7 +1241,7 @@ export default function ImageStudio({
{dropdownOpen === "quality" && (
<div
onClick={(e) => e.stopPropagation()}
className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0a0a0a] rounded-[1.5rem] p-3 shadow-2xl border border-white/[0.05] min-w-[160px]"
className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0a0a0a] rounded-md p-3 max-h-[40vh] overflow-y-auto custom-scrollbar shadow-2xl border border-white/[0.05] min-w-[160px]"
>
<SimpleDropdown
title="Resolution"

View file

@ -0,0 +1,980 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useParams, useRouter } from "next/navigation";
import {
getTemplateWorkflows,
getUserWorkflows,
getPublishedWorkflows,
createWorkflow,
updateWorkflowName,
deleteWorkflow,
getWorkflowInputs,
executeWorkflow,
getAllNodeSchemas,
getWorkflowData,
} from "../muapi.js";
import dynamic from "next/dynamic";
const WorkflowUI = dynamic(() => import("./WorkflowUI"), {
ssr: false,
loading: () => (
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 border-4 border-white/5 border-t-[#d9ff00] rounded-full animate-spin" />
<div className="text-[10px] font-black text-white/20 uppercase tracking-widest">
Loading Builder...
</div>
</div>
</div>
),
});
function WorkflowCard({ workflow, onClick, activeTab, onRename, onDelete }) {
const [showOptions, setShowOptions] = useState(false);
return (
<div
onClick={() => onClick(workflow)}
className="group relative aspect-[3/4] rounded-lg overflow-hidden cursor-pointer border border-white/5 bg-[#0a0a0a] transition-all hover:border-[#d9ff00]/30 hover:scale-[1.02] shadow-2xl"
>
{workflow.thumbnail ? (
<img
src={workflow.thumbnail}
alt={workflow.name}
className="absolute inset-0 w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/10 to-purple-500/10 flex items-center justify-center">
<svg
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
stroke="white"
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
className="opacity-20"
>
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent" />
{/* Options Dropdown for My Workflows */}
{activeTab === 'my-workflows' && (
<div
className="absolute top-2 right-2 z-30"
onClick={(e) => { e.stopPropagation(); }}
>
<button
onClick={() => setShowOptions(!showOptions)}
onBlur={() => setTimeout(() => setShowOptions(false), 200)}
className="w-8 h-8 rounded-full bg-black/40 backdrop-blur-md border border-white/10 flex items-center justify-center text-white/60 hover:text-white transition-colors"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<circle cx="12" cy="5" r="1" /><circle cx="12" cy="12" r="1" /><circle cx="12" cy="19" r="1" />
</svg>
</button>
{showOptions && (
<div className="absolute top-10 right-0 w-32 bg-[#111] border border-white/10 rounded-lg shadow-2xl py-1 animate-in fade-in zoom-in duration-200">
<button
onClick={() => onRename(workflow)}
className="w-full px-4 py-2 text-left text-[11px] font-bold text-white/70 hover:text-[#d9ff00] hover:bg-white/5 transition-colors flex items-center gap-2"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
Rename
</button>
<button
onClick={() => onDelete(workflow.id)}
className="w-full px-4 py-2 text-left text-[11px] font-bold text-red-500 hover:bg-red-500/10 transition-colors flex items-center gap-2"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
</svg>
Delete
</button>
</div>
)}
</div>
)}
{/* Community Profile Info */}
{activeTab === 'published' && workflow.user_name && (
<div className="absolute top-2 left-2 z-20 flex items-center gap-2 bg-black/40 backdrop-blur-md px-2 py-1 rounded-full border border-white/10">
<img src={workflow.user_profile || "/user_profile.png"} alt="profile" className="w-4 h-4 rounded-full" />
<span className="text-[9px] font-black text-white/80 uppercase tracking-widest">{workflow.user_name}</span>
</div>
)}
<div className="absolute inset-x-0 bottom-0 p-4">
<div className="text-[10px] font-bold text-[#d9ff00] uppercase tracking-wider mb-1 opacity-80">
{workflow.category || "General"}
</div>
<h3 className="text-sm font-bold text-white truncate group-hover:text-[#d9ff00] transition-colors">
{workflow.name || "Untitled Flow"}
</h3>
</div>
</div>
);
}
export default function WorkflowStudio({ apiKey, isHeaderVisible = true, onToggleHeader }) {
const params = useParams();
const router = useRouter();
const slug = params?.slug || [];
const idFromParams = params?.id; // exists on /workflow/[id]/[tab] route
const tabFromParams = params?.tab; // exists on /workflow/[id]/[tab] route
// Robustly extract ID and Tab from either route structure
const getWorkflowInfo = useCallback(() => {
// Priority 1: Dedicated /workflow/[id]/[tab] route
if (idFromParams) {
return { id: idFromParams, tab: tabFromParams || null };
}
// Priority 2: Catch-all /studio/[[...slug]] route
const wfIndex = slug.findIndex(s => s === 'workflows' || s === 'workflow');
if (wfIndex === -1) return { id: null, tab: null };
return {
id: slug[wfIndex + 1] || null,
tab: slug[wfIndex + 2] || null
};
}, [slug, idFromParams, tabFromParams]);
const { id: urlWorkflowId, tab: urlTab } = getWorkflowInfo();
const [workflows, setWorkflows] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedWorkflow, setSelectedWorkflow] = useState(null);
const [activeSubTab, setActiveSubTab] = useState("playground"); // 'playground' | 'builder'
const [activeMainTab, setActiveMainTab] = useState("templates"); // 'templates' | 'my-workflows' | 'published'
const [renamingWorkflow, setRenamingWorkflow] = useState(null);
const [newWorkflowName, setNewWorkflowName] = useState("");
const [isDeletingId, setIsDeletingId] = useState(null);
const [inputSchema, setInputSchema] = useState(null);
const [nodeSchemas, setNodeSchemas] = useState(null);
const [workflowDef, setWorkflowDef] = useState(null);
const [formData, setFormData] = useState({});
const [isExecuting, setIsExecuting] = useState(false);
const [result, setResult] = useState(null);
const [error, setError] = useState(null);
// Handlers defined early so they can be used in effects
const handleSelectWorkflow = useCallback(
async (wf, fromUrl = false) => {
setSelectedWorkflow(wf);
setResult(null);
setError(null);
const targetTab = urlTab || "playground";
setActiveSubTab(targetTab);
if (!fromUrl) {
// Always route to /workflow/[id] so the builder library's useParams().id resolves correctly
router.push(`/workflow/${wf.id}/${targetTab}`);
}
},
[router, urlTab],
);
// Dedicated data fetching effect for the active workflow
useEffect(() => {
if (!selectedWorkflow?.id || !apiKey) return;
async function loadWorkflowDetails() {
try {
setLoading(true);
const wfId = selectedWorkflow.id;
// Fetch everything in parallel with allSettled so one failure doesn't block the others
const results = await Promise.allSettled([
getWorkflowInputs(apiKey, wfId),
getAllNodeSchemas(apiKey, wfId),
getWorkflowData(apiKey, wfId)
]);
// Process Input Schema
if (results[0].status === 'fulfilled') {
const response = results[0].value;
const schema = response.input_data || response;
setInputSchema(schema);
const initial = {};
Object.entries(schema.properties || {}).forEach(([key, prop]) => {
initial[key] =
prop.default ||
(Array.isArray(prop.examples) ? prop.examples[0] : prop.examples) ||
"";
});
setFormData(initial);
} else {
console.warn("Input schema not available for this workflow:", results[0].reason);
setInputSchema(null);
setFormData({});
}
// Process Builder State
const nodes = results[1].status === 'fulfilled' ? results[1].value : [];
const def = results[2].status === 'fulfilled' ? results[2].value : { nodes: [], edges: [] };
setNodeSchemas(nodes);
setWorkflowDef(def);
if (results[1].status === 'rejected' || results[2].status === 'rejected') {
console.error("Builder components failed to load:", results[1].reason, results[2].reason);
if (!nodes.length && !def.nodes?.length) {
setError("Failed to load full builder data. Some features may be disabled.");
}
}
} catch (err) {
console.error("Critical error loading pulse details:", err);
setError("Critical error loading builder: " + err.message);
setNodeSchemas([]);
setWorkflowDef({ nodes: [], edges: [] });
} finally {
setLoading(false);
}
}
loadWorkflowDetails();
}, [selectedWorkflow?.id, apiKey]);
const handleCreateWorkflow = useCallback(
async (fromUrl = false) => {
try {
setLoading(true);
if (!fromUrl) {
const payload = {
workflow_id: null,
name: "Untitled Workflow",
edges: [],
data: { nodes: [] },
};
const response = await createWorkflow(apiKey, payload);
// Route to /workflow/[id] so useParams().id works in the builder library
router.push(`/workflow/${response.workflow_id}/builder`);
return;
}
// Initialize state for the new flow
setSelectedWorkflow({ id: null, name: "Untitled Workflow" });
setNodeSchemas([]);
setWorkflowDef({ nodes: [], edges: [] });
setActiveSubTab("builder");
} catch (err) {
setError("Failed to initialize workflow: " + err.message);
} finally {
setLoading(false);
}
},
[apiKey, router],
);
const handleDeleteWorkflow = async (wfId) => {
if (!confirm("Are you sure you want to delete this workflow?")) return;
setIsDeletingId(wfId);
try {
await deleteWorkflow(apiKey, wfId);
setWorkflows((prev) => prev.filter((w) => w.id !== wfId));
} catch (err) {
console.error("Delete failed:", err);
alert("Failed to delete workflow");
} finally {
setIsDeletingId(null);
}
};
const handleRenameWorkflow = async (e) => {
e?.preventDefault();
if (!renamingWorkflow || !newWorkflowName.trim()) return;
const wfId = renamingWorkflow.id;
try {
await updateWorkflowName(apiKey, wfId, newWorkflowName);
setWorkflows((prev) =>
prev.map((w) => (w.id === wfId ? { ...w, name: newWorkflowName } : w)),
);
if (selectedWorkflow?.id === wfId) {
setSelectedWorkflow({ ...selectedWorkflow, name: newWorkflowName });
}
setRenamingWorkflow(null);
} catch (err) {
console.error("Rename failed:", err);
alert("Failed to rename workflow");
}
};
// KEY FIX: If the user is on /studio/workflows/[id], redirect to /workflow/[id]
// so the builder library's useParams().id resolves correctly, preventing duplicate creation.
useEffect(() => {
if (typeof window !== 'undefined' && urlWorkflowId && urlWorkflowId !== 'new') {
const path = window.location.pathname;
if (path.startsWith('/studio/workflows/')) {
const tab = urlTab || 'builder';
router.replace(`/workflow/${urlWorkflowId}/${tab}`);
}
}
}, [urlWorkflowId, urlTab, router]);
// 1. Sync state with URL on mount or URL change
useEffect(() => {
if (loading) return;
if (urlWorkflowId) {
if (urlWorkflowId === "new") {
if (!selectedWorkflow || selectedWorkflow.id !== null) {
handleCreateWorkflow(true);
}
} else {
const found = workflows.find((wf) => wf.id === urlWorkflowId);
if (found) {
if (!selectedWorkflow || selectedWorkflow.id !== urlWorkflowId) {
handleSelectWorkflow(found, true);
}
} else if (
!selectedWorkflow ||
selectedWorkflow.id !== urlWorkflowId
) {
// Fallback for deep-linking: attempt to open even if not in the current tab's list
// handleSelectWorkflow fetches official name/data anyway
handleSelectWorkflow(
{ id: urlWorkflowId, name: "Loading..." },
true,
);
}
}
} else if (selectedWorkflow) {
setSelectedWorkflow(null);
}
}, [
urlWorkflowId,
workflows,
loading,
selectedWorkflow,
handleCreateWorkflow,
handleSelectWorkflow,
]);
// Handle reload on exit to clear builder CSS
useEffect(() => {
const fromBuilder = sessionStorage.getItem("fromWorkflowBuilder");
if (fromBuilder && (!urlWorkflowId || activeSubTab !== "builder")) {
sessionStorage.removeItem("fromWorkflowBuilder");
window.location.reload();
}
}, [urlWorkflowId, activeSubTab]);
useEffect(() => {
async function loadWorkflows() {
try {
setLoading(true);
let data = [];
if (activeMainTab === "templates") {
data = await getTemplateWorkflows(apiKey);
} else if (activeMainTab === "my-workflows") {
data = await getUserWorkflows(apiKey);
} else if (activeMainTab === "published") {
data = await getPublishedWorkflows(apiKey);
}
setWorkflows(data);
} catch (err) {
console.error("Failed to load workflows:", err);
setError("Failed to load workflows list.");
} finally {
setLoading(false);
}
}
loadWorkflows();
}, [apiKey, activeMainTab]);
const handleRun = async (e) => {
e.preventDefault();
if (isExecuting) return;
setIsExecuting(true);
setError(null);
setResult(null);
try {
const inputs = {};
Object.entries(formData).forEach(([key, value]) => {
if (!value) return;
if (key.startsWith("text")) inputs[key] = { prompt: value };
else if (key.startsWith("image")) inputs[key] = { image_url: value };
else if (key.startsWith("video")) inputs[key] = { video_url: value };
else inputs[key] = value;
});
const data = await executeWorkflow(apiKey, selectedWorkflow.id, inputs);
setResult(data);
} catch (err) {
console.error("Execution failed:", err);
setError(err.message || "Execution failed");
} finally {
setIsExecuting(false);
}
};
if (loading && !selectedWorkflow) {
return (
<div className="h-full flex items-center justify-center">
<div className="animate-spin text-[#d9ff00] text-3xl"></div>
</div>
);
}
if (selectedWorkflow) {
return (
<div className="h-full flex flex-col bg-[#030303] text-white">
{/* Immersive Sub-header / Floating Toggle */}
{isHeaderVisible ? (
<div className="flex-shrink-0 h-14 border-b border-white/5 flex items-center justify-between px-6 bg-black/40 z-30">
<div className="flex items-center gap-8 h-full">
<button
onClick={() => router.push("/studio/workflows")}
className="flex items-center gap-2 text-xs font-bold text-white/50 hover:text-white transition-colors"
type="button"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
All Workflows
</button>
<div className="h-4 w-[1px] bg-white/10" />
<div className="flex h-full">
<div className="flex bg-white/5 p-1 rounded-lg my-auto">
<button
onClick={() => {
setActiveSubTab("playground");
if (selectedWorkflow?.id) router.push(`/workflow/${selectedWorkflow.id}/playground`);
}}
type="button"
className={`px-4 py-1.5 text-[10px] font-black uppercase tracking-widest rounded-md transition-all ${
activeSubTab === "playground"
? "bg-[#d9ff00] text-black shadow-[0_0_15px_rgba(217,255,0,0.2)]"
: "text-white/40 hover:text-white"
}`}
>
Playground
</button>
<button
onClick={() => {
setActiveSubTab("builder");
if (selectedWorkflow?.id) router.push(`/workflow/${selectedWorkflow.id}/builder`);
}}
type="button"
className={`px-4 py-1.5 text-[10px] font-black uppercase tracking-widest rounded-md transition-all ${
activeSubTab === "builder"
? "bg-[#d9ff00] text-black shadow-[0_0_15px_rgba(217,255,0,0.2)]"
: "text-white/40 hover:text-white"
}`}
>
Full Workflow
</button>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-[11px] font-black text-[#d9ff00] uppercase tracking-widest">
{selectedWorkflow.name}
</span>
<button
onClick={() => onToggleHeader?.(false)}
className="p-1.5 bg-white/5 hover:bg-white/10 rounded-md transition-colors text-white/40 hover:text-white"
title="Enter Zen Mode"
type="button"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" />
</svg>
</button>
</div>
</div>
) : (
/* Floating Immersive Mode Controller */
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-[100] flex items-center gap-4 px-4 py-2 bg-black/60 backdrop-blur-xl border border-white/10 rounded-full shadow-2xl animate-fade-in-down">
<button
onClick={() => router.push("/studio/workflows")}
className="p-1.5 text-white/40 hover:text-white transition-colors"
title="Back to All Workflows"
type="button"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
</button>
<div className="h-4 w-[1px] bg-white/10" />
<div className="flex bg-white/5 p-1 rounded-lg">
<button
onClick={() => setActiveSubTab("playground")}
type="button"
className={`px-3 py-1 text-[9px] font-black uppercase tracking-widest rounded-md transition-all ${
activeSubTab === "playground" ? "bg-[#d9ff00] text-black" : "text-white/40"
}`}
>
Play
</button>
<button
onClick={() => setActiveSubTab("builder")}
type="button"
className={`px-3 py-1 text-[9px] font-black uppercase tracking-widest rounded-md transition-all ${
activeSubTab === "builder" ? "bg-[#d9ff00] text-black" : "text-white/40"
}`}
>
Builder
</button>
</div>
<div className="h-4 w-[1px] bg-white/10" />
<button
onClick={() => onToggleHeader?.(true)}
className="px-3 py-1 bg-white/10 hover:bg-white/20 text-[9px] font-black text-white uppercase tracking-widest rounded-lg transition-colors flex items-center gap-2"
type="button"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3"><path d="M4 14h6v6M20 10h-6V4M10 20l-7-7M14 4l7 7"/></svg>
Exit Zen
</button>
</div>
)}
<div className="flex-1 overflow-hidden flex flex-col lg:flex-row">
{activeSubTab === "playground" ? (
<>
{/* Controls Panel */}
<div className="w-full lg:w-[400px] border-r border-white/5 flex flex-col bg-black/20">
<div className="p-6 overflow-y-auto flex-1 custom-scrollbar">
<form onSubmit={handleRun} className="space-y-6">
<div>
<h3 className="text-xs font-black text-white/30 uppercase tracking-widest mb-4">
Configuration
</h3>
<div className="space-y-4">
{inputSchema &&
Object.entries(inputSchema.properties || {}).map(
([key, prop]) => (
<div key={key} className="space-y-2">
<label className="block text-[11px] font-bold text-white/80 uppercase tracking-wider">
{prop.title || key}
</label>
{prop.type === "string" && !prop.enum ? (
<textarea
value={formData[key] || ""}
onChange={(e) =>
setFormData({
...formData,
[key]: e.target.value,
})
}
className="w-full bg-white/5 border border-white/10 rounded-lg p-3 text-sm text-white focus:outline-none focus:border-[#d9ff00]/50 transition-colors min-h-[80px] resize-none"
placeholder={
prop.description || `Enter ${key}...`
}
/>
) : prop.enum ? (
<select
value={formData[key] || ""}
onChange={(e) =>
setFormData({
...formData,
[key]: e.target.value,
})
}
className="w-full bg-white/5 border border-white/10 rounded-lg p-3 text-sm text-white focus:outline-none focus:border-[#d9ff00]/50 transition-colors"
>
{prop.enum.map((opt) => (
<option
key={opt}
value={opt}
className="bg-black"
>
{opt}
</option>
))}
</select>
) : (
<input
type="text"
value={formData[key] || ""}
onChange={(e) =>
setFormData({
...formData,
[key]: e.target.value,
})
}
className="w-full bg-white/5 border border-white/10 rounded-lg p-3 text-sm text-white focus:outline-none focus:border-[#d9ff00]/50 transition-colors"
placeholder={
prop.description || `Enter ${key}...`
}
/>
)}
</div>
),
)}
</div>
</div>
<button
type="submit"
disabled={isExecuting || !selectedWorkflow.id}
className="w-full py-4 bg-[#d9ff00] text-black text-xs font-black uppercase tracking-[0.2em] rounded-xl hover:bg-white transition-all transform hover:scale-[1.02] active:scale-95 disabled:opacity-50 disabled:grayscale shadow-[0_0_30px_rgba(217,255,0,0.15)] flex items-center justify-center gap-3 mt-8"
>
{isExecuting ? (
<>
<div className="w-4 h-4 border-2 border-black/20 border-t-black rounded-full animate-spin" />
<span>Generating...</span>
</>
) : (
<>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
>
<path d="M5 3l14 9-14 9V3z" />
</svg>
<span>Run Workflow</span>
</>
)}
</button>
{!selectedWorkflow.id && (
<p className="text-[10px] text-white/30 text-center mt-4">
Save your workflow first to enable execution.
</p>
)}
</form>
</div>
</div>
{/* Preview Panel */}
<div className="flex-1 overflow-y-auto p-8 lg:p-12 bg-[#050505] flex items-center justify-center min-h-[500px]">
{error && (
<div className="w-full max-w-md p-6 bg-red-500/10 border border-red-500/20 rounded-2xl flex flex-col items-center gap-4 animate-shake">
<div className="w-12 h-12 bg-red-500/20 rounded-full flex items-center justify-center text-red-500">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</div>
<div className="text-center">
<span className="text-[10px] font-black text-red-500 uppercase tracking-widest block mb-1">
Execution Error
</span>
<p className="text-white/60 text-sm leading-relaxed">
{error}
</p>
</div>
</div>
)}
{!isExecuting && !result && !error && (
<div className="flex flex-col items-center gap-6 opacity-40">
<div className="w-20 h-20 bg-white/5 rounded-3xl flex items-center justify-center text-white/20">
<svg
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
</div>
<p className="text-xs text-white/40 max-w-[200px] mx-auto text-center font-medium">
Configure parameters and run the workflow to see results.
</p>
</div>
)}
{isExecuting && (
<div className="flex flex-col items-center gap-6 animate-fade-in">
<div className="relative">
<div className="w-24 h-24 border-[3px] border-white/5 border-t-[#d9ff00] rounded-full animate-spin shadow-[0_0_40px_rgba(217,255,0,0.1)]" />
<div className="absolute inset-0 flex items-center justify-center text-[#d9ff00]">
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
className="animate-pulse"
>
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
</div>
</div>
<div className="text-center space-y-2">
<div className="text-[10px] font-black text-[#d9ff00] uppercase tracking-[0.3em] animate-pulse">
Running Pipeline
</div>
<div className="text-[13px] text-white/40 font-medium">
Processing nodes and generating assets...
</div>
</div>
</div>
)}
{result && (
<div className="w-full max-w-4xl space-y-8 animate-fade-in-up">
<div className="flex items-center justify-between mb-2">
<h3 className="text-xs font-black text-white/30 uppercase tracking-widest">
Workflow Results
</h3>
<div className="flex items-center gap-2 px-3 py-1 bg-green-500/10 text-green-500 rounded-full text-[10px] font-bold border border-green-500/20">
<div className="w-1 h-1 bg-green-500 rounded-full animate-pulse" />{" "}
COMPLETED
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{result.outputs?.map((out, idx) => (
<div
key={idx}
className="group relative bg-white/5 border border-white/10 rounded-2xl overflow-hidden hover:border-[#d9ff00]/30 transition-all shadow-2xl"
>
{out.type === "image_url" ? (
<img
src={out.value}
className="w-full aspect-square object-cover"
alt="Output"
/>
) : out.type === "video_url" ? (
<video
src={out.value}
controls
className="w-full aspect-square object-cover"
/>
) : (
<div className="p-6 min-h-[200px] flex items-center justify-center italic text-white/60">
{out.value}
</div>
)}
<div className="absolute inset-x-0 bottom-0 p-4 bg-gradient-to-t from-black/80 to-transparent translate-y-full group-hover:translate-y-0 transition-transform">
<div className="flex items-center justify-between">
<span className="text-[10px] font-black text-[#d9ff00] uppercase tracking-widest">
{out.id}
</span>
<a
href={out.value}
target="_blank"
rel="noreferrer"
className="w-8 h-8 rounded-lg bg-white/10 flex items-center justify-center hover:bg-[#d9ff00] hover:text-black transition-colors"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
>
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3" />
</svg>
</a>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
</>
) : (
<div className="flex-1 relative bg-[#050505]">
{nodeSchemas && workflowDef ? (
<WorkflowUI
workflowId={selectedWorkflow?.id}
initialNodeSchemas={nodeSchemas}
initialWorkflowData={{
...workflowDef,
// Inject ID to prevent builder from assuming this is a new unsaved flow
workflow_id: selectedWorkflow?.id
}}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 border-4 border-white/5 border-t-[#d9ff00] rounded-full animate-spin" />
<div className="text-[10px] font-black text-white/20 uppercase tracking-widest">
Loading Builder...
</div>
</div>
</div>
)}
</div>
)}
</div>
</div>
);
}
// Render main workflow list
return (
<div className="h-full w-full flex flex-col p-8 overflow-y-auto custom-scrollbar">
<div className="max-w-7xl mx-auto w-full">
<div className="flex flex-col gap-6 mb-12">
<div className="flex items-end justify-between">
<div>
<h1 className="text-3xl font-bold text-white mb-2 tracking-tight">
Workflows
</h1>
<p className="text-white/40 text-sm font-medium">
Create and manage your asynchronous AI processing pipelines
</p>
</div>
<button
onClick={() => handleCreateWorkflow()}
className="px-6 py-3 bg-[#d9ff00] text-black text-xs font-black uppercase tracking-widest rounded-lg hover:bg-white transition-all transform hover:scale-105 active:scale-95 shadow-[0_0_20px_rgba(217,255,0,0.3)] flex items-center gap-2"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
Create Workflow
</button>
</div>
<div className="flex items-center gap-2 border-b border-white/5">
<button
onClick={() => setActiveMainTab("templates")}
className={`px-6 py-4 text-xs font-black uppercase tracking-[0.2em] transition-all border-b-2 ${
activeMainTab === "templates"
? "text-[#d9ff00] border-[#d9ff00]"
: "text-white/30 border-transparent hover:text-white"
}`}
>
Templates
</button>
<button
onClick={() => setActiveMainTab("my-workflows")}
className={`px-6 py-4 text-xs font-black uppercase tracking-[0.2em] transition-all border-b-2 ${
activeMainTab === "my-workflows"
? "text-[#d9ff00] border-[#d9ff00]"
: "text-white/30 border-transparent hover:text-white"
}`}
>
My Workflows
</button>
<button
onClick={() => setActiveMainTab("published")}
className={`px-6 py-4 text-xs font-black uppercase tracking-[0.2em] transition-all border-b-2 ${
activeMainTab === "published"
? "text-[#d9ff00] border-[#d9ff00]"
: "text-white/30 border-transparent hover:text-white"
}`}
>
Community
</button>
</div>
</div>
{loading ? (
<div className="py-20 flex items-center justify-center">
<div className="w-10 h-10 border-4 border-white/5 border-t-[#d9ff00] rounded-full animate-spin" />
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-6">
{workflows.map((wf) => (
<WorkflowCard
key={wf.id}
workflow={wf}
onClick={handleSelectWorkflow}
activeTab={activeMainTab}
onRename={(wf) => {
setRenamingWorkflow(wf);
setNewWorkflowName(wf.name);
}}
onDelete={handleDeleteWorkflow}
/>
))}
{!loading && workflows.length === 0 && (
<div className="col-span-full py-24 text-center border-2 border-dashed border-white/5 rounded-2xl bg-white/[0.02]">
<div className="text-white/20 text-sm font-medium italic">
No workflows found in this section.
</div>
</div>
)}
</div>
)}
</div>
{/* Rename Modal */}
{renamingWorkflow && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-6">
<div className="absolute inset-0 bg-black/80 backdrop-blur-md" onClick={() => setRenamingWorkflow(null)} />
<form
onSubmit={handleRenameWorkflow}
className="relative w-full max-w-sm bg-[#0a0a0a] border border-white/10 rounded-2xl p-8 shadow-2xl animate-in fade-in zoom-in duration-300"
>
<h3 className="text-xl font-bold text-white mb-2">Rename Workflow</h3>
<p className="text-white/40 text-sm mb-6">Enter a new descriptive name for your pipeline.</p>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-[10px] font-black text-[#d9ff00] uppercase tracking-widest">Workflow Name</label>
<input
autoFocus
type="text"
value={newWorkflowName}
onChange={(e) => setNewWorkflowName(e.target.value)}
placeholder="e.g. Cinematic Video Flow"
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm text-white focus:outline-none focus:border-[#d9ff00]/50 transition-colors"
/>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => setRenamingWorkflow(null)}
className="flex-1 px-4 py-3 text-xs font-black text-white/40 uppercase tracking-widest hover:text-white transition-colors"
>
Cancel
</button>
<button
type="submit"
className="flex-1 bg-[#d9ff00] text-black px-4 py-3 rounded-xl text-xs font-black uppercase tracking-widest hover:bg-white transition-all transform hover:scale-105 active:scale-95"
>
Save Name
</button>
</div>
</div>
</form>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,26 @@
"use client";
import React, { useEffect } from "react";
import { WorkflowBuilder } from "workflow-builder";
import "reactflow/dist/style.css";
import "react-toastify/dist/ReactToastify.css";
import "workflow-builder/dist/tailwind.css";
const WorkflowUI = ({ workflowId, initialNodeSchemas, initialWorkflowData }) => {
useEffect(() => {
sessionStorage.setItem("fromWorkflowBuilder", "true");
}, []);
return (
<div className="w-full h-full bg-black">
<WorkflowBuilder
workflowId={workflowId}
initialNodeSchemas={initialNodeSchemas}
initialWorkflowData={initialWorkflowData}
costType="dollars"
/>
</div>
);
};
export default WorkflowUI;

View file

@ -4,4 +4,5 @@ export { default as ImageStudio } from './components/ImageStudio';
export { default as VideoStudio } from './components/VideoStudio';
export { default as LipSyncStudio } from './components/LipSyncStudio';
export { default as CinemaStudio } from './components/CinemaStudio';
export { default as WorkflowStudio } from './components/WorkflowStudio';
export * from './muapi';

View file

@ -1,9 +1,11 @@
import { getModelById, getVideoModelById, getI2IModelById, getI2VModelById, getV2VModelById, getLipSyncModelById } from './models.js';
const BASE_URL = 'https://api.muapi.ai';
const BASE_URL = 'https://api.muapi.ai'; // Legacy direct URL
const PROXY_APP_BASE = '/api/app';
const PROXY_WF_BASE = '/api/workflow';
async function pollForResult(requestId, key, maxAttempts = 900, interval = 2000) {
const pollUrl = `${BASE_URL}/api/v1/predictions/${requestId}/result`;
const pollUrl = `${PROXY_APP_BASE}/v1/predictions/${requestId}/result`;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
await new Promise(resolve => setTimeout(resolve, interval));
try {
@ -183,3 +185,323 @@ export async function getUserBalance(apiKey) {
}
return await response.json();
}
export async function getTemplateWorkflows(apiKey) {
const response = await fetch(`${BASE_URL}/workflow/get-template-workflows`, {
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey
}
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`Failed to fetch template workflows: ${response.status} - ${errText.slice(0, 100)}`);
}
return await response.json();
};
export async function getUserWorkflows(apiKey) {
const response = await fetch(`${BASE_URL}/workflow/get-workflow-defs`, {
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey
}
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`Failed to fetch user workflows: ${response.status} - ${errText.slice(0, 100)}`);
}
return await response.json();
};
export async function getPublishedWorkflows(apiKey) {
const response = await fetch(`${BASE_URL}/workflow/get-published-workflows`, {
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey
}
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`Failed to fetch published workflows: ${response.status} - ${errText.slice(0, 100)}`);
}
return await response.json();
};
export async function createWorkflow(apiKey, payload) {
const response = await fetch(`${BASE_URL}/workflow/create`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey
},
body: JSON.stringify(payload)
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`Failed to create workflow: ${response.status} - ${errText.slice(0, 100)}`);
}
return await response.json();
};
export async function updateWorkflowName(apiKey, workflowId, name) {
const response = await fetch(`${BASE_URL}/workflow/update-name/${workflowId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey
},
body: JSON.stringify({ name })
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`Failed to rename workflow: ${response.status} - ${errText.slice(0, 100)}`);
}
return await response.json();
};
export async function deleteWorkflow(apiKey, workflowId) {
const response = await fetch(`${BASE_URL}/workflow/delete-workflow-def/${workflowId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey
}
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`Failed to delete workflow: ${response.status} - ${errText.slice(0, 100)}`);
}
return await response.json();
};
export async function getWorkflowInputs(apiKey, workflowId) {
const response = await fetch(`${BASE_URL}/workflow/${workflowId}/api-inputs`, {
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey
}
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`Failed to fetch workflow inputs: ${response.status} - ${errText.slice(0, 100)}`);
}
return await response.json();
};
export async function executeWorkflow(apiKey, workflowId, inputs) {
const response = await fetch(`${BASE_URL}/workflow/${workflowId}/api-execute`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey
},
body: JSON.stringify({ inputs })
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`Failed to execute workflow: ${response.status} - ${errText.slice(0, 100)}`);
}
const submitData = await response.json();
const runId = submitData.run_id || submitData.id;
if (!runId) return submitData;
// Poll for results
return await pollWorkflowResult(runId, apiKey);
};
async function pollWorkflowResult(runId, apiKey, maxAttempts = 900, interval = 2000) {
const pollUrl = `${BASE_URL}/workflow/run/${runId}/api-outputs`;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
await new Promise(resolve => setTimeout(resolve, interval));
try {
const response = await fetch(pollUrl, {
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey }
});
if (!response.ok) {
if (response.status >= 500) continue;
throw new Error(`Poll Failed: ${response.status}`);
}
const data = await response.json();
const status = data.status?.toLowerCase();
if (status === 'completed' || status === 'succeeded' || status === 'success') return data;
if (status === 'failed' || status === 'error') throw new Error(`Workflow failed: ${data.error || 'Unknown error'}`);
} catch (error) {
if (attempt === maxAttempts) throw error;
}
}
throw new Error('Workflow timed out after polling.');
};
export async function getAllNodeSchemas(apiKey, workflowId) {
const response = await fetch(`${BASE_URL}/workflow/${workflowId}/node-schemas`, {
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey
}
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`Failed to fetch node schemas: ${response.status} - ${errText.slice(0, 100)}`);
}
return await response.json();
};
export async function getWorkflowData(apiKey, workflowId) {
const response = await fetch(`${BASE_URL}/workflow/get-workflow-def/${workflowId}`, {
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey
}
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`Failed to fetch workflow data: ${response.status} - ${errText.slice(0, 100)}`);
}
return await response.json();
};
export async function getNodeSchemas(apiKey, workflowId) {
const response = await fetch(`${BASE_URL}/workflow/${workflowId}/api-node-schemas`, {
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey
}
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`Failed to fetch node schemas: ${response.status} - ${errText.slice(0, 100)}`);
}
return await response.json();
}
export async function runSingleNode(apiKey, workflowId, nodeId, payload) {
const response = await fetch(`${BASE_URL}/workflow/${workflowId}/node/${nodeId}/run`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey
},
body: JSON.stringify(payload)
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`Failed to run single node: ${response.status} - ${errText.slice(0, 100)}`);
}
return await response.json();
}
export async function deleteNodeRun(apiKey, nodeRunId) {
const response = await fetch(`${BASE_URL}/workflow/node-run/${nodeRunId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey
}
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`Failed to delete node run: ${response.status} - ${errText.slice(0, 100)}`);
}
return await response.json();
}
export async function getNodeStatus(apiKey, runId) {
const response = await fetch(`${BASE_URL}/workflow/run/${runId}/status`, {
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey
}
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`Failed to get node status: ${response.status} - ${errText.slice(0, 100)}`);
}
return await response.json();
}
/**
* Handle proxy requests centralizing communication logic with MuAPI.
* This is used by the server-side entry points.
*/
export async function handleProxyRequest(prefix, path, method, headers, body, apiKey) {
const url = `${BASE_URL}/${prefix}/${path}`;
const finalHeaders = new Headers(headers);
finalHeaders.delete('host');
finalHeaders.delete('connection');
finalHeaders.delete('content-length'); // Let fetch recalculate this for safety
if (apiKey) {
finalHeaders.set('x-api-key', apiKey);
}
try {
const response = await fetch(url, {
method,
headers: finalHeaders,
body: (method !== 'GET' && method !== 'HEAD') ? body : undefined,
redirect: 'follow',
});
const contentType = response.headers.get('Content-Type') || 'application/json';
const buffer = await response.arrayBuffer();
return {
status: response.status,
contentType,
data: buffer
};
} catch (error) {
console.error(`MuAPI Proxy error for ${url}:`, error);
throw error;
}
}
/**
* A centralized handler for Next.js API routes or middleware.
*/
export async function handleServerSideProxy(prefix, request, params, apiKey) {
try {
const slug = await params;
const pathSegments = slug.path || [];
const path = pathSegments.join('/');
const method = request.method;
let body = null;
if (method !== 'GET' && method !== 'HEAD') {
body = await request.arrayBuffer();
}
const { search } = new URL(request.url);
const pathWithSearch = search ? `${path}${search}` : path;
return await handleProxyRequest(
prefix,
pathWithSearch,
method,
request.headers,
body,
apiKey
);
} catch (error) {
console.error(`Server proxy failed:`, error);
throw error;
}
}
export async function calculateDynamicCost(apiKey, taskName, payload) {
const response = await fetch(`${BASE_URL}/api/v1/app/calculate_dynamic_cost`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey
},
body: JSON.stringify({ task_name: taskName, payload })
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`Failed to calculate dynamic cost: ${response.status} - ${errText.slice(0, 100)}`);
}
return await response.json();
}

1
packages/workflow-ui Submodule

@ -0,0 +1 @@
Subproject commit 58cc9b22f311f7e4c6c80a7f0eb289f322b7c199