feat: implement workflow identity persistence, unify api auth, and upgrade tailwind to v4
This commit is contained in:
parent
6f9cdeeb47
commit
efa772e772
18 changed files with 4654 additions and 250 deletions
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[submodule "packages/workflow-ui"]
|
||||
path = packages/workflow-ui
|
||||
url = https://github.com/Anil-matcha/workflow-ui.git
|
||||
145
app/api/app/[[...path]]/route.js
Normal file
145
app/api/app/[[...path]]/route.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
44
app/api/upload-binary/route.js
Normal file
44
app/api/upload-binary/route.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
137
app/api/workflow/[[...path]]/route.js
Normal file
137
app/api/workflow/[[...path]]/route.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
9
app/workflow/[id]/[tab]/page.js
Normal file
9
app/workflow/[id]/[tab]/page.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import StandaloneShell from '@/components/StandaloneShell';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Workflow — Open Generative AI',
|
||||
};
|
||||
|
||||
export default function WorkflowTabPage() {
|
||||
return <StandaloneShell />;
|
||||
}
|
||||
9
app/workflow/[id]/page.js
Normal file
9
app/workflow/[id]/page.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import StandaloneShell from '@/components/StandaloneShell';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Workflow — Open Generative AI',
|
||||
};
|
||||
|
||||
export default function WorkflowPage() {
|
||||
return <StandaloneShell />;
|
||||
}
|
||||
|
|
@ -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
31
middleware.js
Normal 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
2970
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
980
packages/studio/src/components/WorkflowStudio.jsx
Normal file
980
packages/studio/src/components/WorkflowStudio.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
packages/studio/src/components/WorkflowUI.jsx
Normal file
26
packages/studio/src/components/WorkflowUI.jsx
Normal 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;
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
1
packages/workflow-ui
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 58cc9b22f311f7e4c6c80a7f0eb289f322b7c199
|
||||
Loading…
Reference in a new issue