Merge pull request #93 from jaiprasad04/feat/modernize-studio-upload

feat: integrate AI agent studio with minimal UI and upgrade Tailwind v4
This commit is contained in:
Anil Chandra Naidu Matcha 2026-04-22 17:36:08 +05:30 committed by GitHub
commit 5cbcd88733
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1813 additions and 153 deletions

3
.gitmodules vendored
View file

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

View file

@ -0,0 +1,83 @@
"use client";
import { AiAgent } from "ai-agent";
import "ai-agent/dist/tailwind.css";
import { useCallback, useEffect, useRef } from "react";
import axios from "axios";
const STORAGE_KEY = "muapi_key";
/**
* AgentChatClient mirrors muapiapp's AgentClient.js.
* Renders the AiAgent library component with server-fetched agent details
* and optional initial history.
*
* IMPORTANT: StandaloneShell is NOT in the tree on /agents/* pages, so we
* must set up our own axios interceptor here to inject the API key into
* all requests made by the AiAgent library.
*/
export default function AgentChatClient({ agentDetails, initialHistory, userData }) {
const interceptorRef = useRef(null);
console.log("[AgentChatClient] Rendering", {
hasAgentDetails: !!agentDetails,
hasHistory: !!initialHistory,
hasUserData: !!userData
});
useEffect(() => {
const getKey = () => {
if (typeof window === "undefined") return null;
const fromStorage = localStorage.getItem(STORAGE_KEY);
if (fromStorage) return fromStorage;
const match = document.cookie.match(/muapi_key=([^;]+)/);
return match ? match[1] : null;
};
const apiKey = getKey();
if (!apiKey) return;
interceptorRef.current = axios.interceptors.request.use((config) => {
const isRelative =
config.url.startsWith("/") || !config.url.startsWith("http");
// Include specific proxy paths to be sure
const isInternalProxy = config.url.includes('/api/app') || config.url.includes('/api/workflow') || config.url.includes('/api/agents') || config.url.includes('/api/api') || config.url.includes('/api/v1');
if (isRelative || isInternalProxy) {
config.headers["x-api-key"] = apiKey;
}
return config;
});
return () => {
if (interceptorRef.current !== null) {
axios.interceptors.request.eject(interceptorRef.current);
}
};
}, []);
const useUser = useCallback(
() => ({
user: {
username: userData?.email?.split("@")[0] || "Studio User",
name: userData?.email?.split("@")[0] || "Studio User",
email: userData?.email || null,
profile_photo: null,
balance: userData?.balance || 0,
},
isAuthorized: !!userData,
}),
[userData]
);
return (
<div className="h-screen w-full bg-black">
<AiAgent
initialAgentDetails={agentDetails}
initialHistory={initialHistory}
useUser={useUser}
usedIn="muapiapp"
/>
</div>
);
}

View file

@ -0,0 +1,111 @@
import { cookies } from "next/headers";
import AgentChatClient from "../AgentChatClient";
/**
* Server component fetches both agentDetails and initialHistory
* from the /api/agents proxy using the muapi_key cookie, then renders
* the client chat component with existing conversation messages pre-loaded.
*
* URL: /agents/[agent_id]/[conversation_id]
*/
export async function generateMetadata({ params }) {
return {
title: `Agent Chat — Open Generative AI`,
};
}
const BASE_URL = 'https://api.muapi.ai';
async function fetchAgentDetails(agentId, apiKey) {
if (!apiKey) return null;
try {
const res = await fetch(
`${BASE_URL}/agents/by-slug/${agentId}`,
{
cache: "no-store",
headers: { "x-api-key": apiKey },
}
);
if (res.ok) return await res.json();
if (agentId.length > 20) {
const resId = await fetch(
`${BASE_URL}/agents/${agentId}`,
{
cache: "no-store",
headers: { "x-api-key": apiKey },
}
);
if (resId.ok) return await resId.json();
}
return null;
} catch {
return null;
}
}
async function fetchHistory(agentId, conversationId, apiKey) {
if (!apiKey) return null;
try {
// Try by slug first
const res = await fetch(
`${BASE_URL}/agents/by-slug/${agentId}/${conversationId}`,
{
cache: "no-store",
headers: { "x-api-key": apiKey },
}
);
if (res.ok) return await res.json();
// Fallback to direct agent ID if needed
if (agentId.length > 20) {
const resId = await fetch(
`${BASE_URL}/agents/${agentId}/${conversationId}`,
{
cache: "no-store",
headers: { "x-api-key": apiKey },
}
);
if (resId.ok) return await resId.json();
}
return null;
} catch {
return null;
}
}
async function fetchUserData(apiKey) {
if (!apiKey) return null;
try {
const res = await fetch(`${BASE_URL}/api/v1/account/balance`, {
cache: "no-store",
headers: { "x-api-key": apiKey },
});
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
export default async function AgentConversationPage({ params }) {
const { agent_id, conversation_id } = await params;
const cookieStore = await cookies();
const apiKey = cookieStore.get("muapi_key")?.value;
console.log(`[ConvPage] Loading for agent: ${agent_id}, conv: ${conversation_id}, hasKey: ${!!apiKey}`);
const [agentDetails, initialHistory, userData] = await Promise.all([
fetchAgentDetails(agent_id, apiKey),
fetchHistory(agent_id, conversation_id, apiKey),
fetchUserData(apiKey)
]);
return (
<AgentChatClient
agentDetails={agentDetails}
initialHistory={initialHistory}
userData={userData}
/>
);
}

View file

@ -0,0 +1,89 @@
import { cookies } from "next/headers";
import AgentChatClient from "./AgentChatClient";
/**
* Server component fetches agentDetails from the /api/agents proxy
* (which forwards to https://api.muapi.ai/agents/by-slug/{id})
* using the muapi_key cookie for auth, then renders the client chat component.
*
* URL: /agents/[agent_id] (new chat no conversation ID yet)
*/
export async function generateMetadata({ params }) {
const { agent_id } = await params;
return {
title: `Agent Chat — Open Generative AI`,
};
}
const BASE_URL = 'https://api.muapi.ai';
async function fetchAgentDetails(agentId, apiKey) {
if (!apiKey) return null;
// Try fetching by slug first
try {
console.log(`[AgentPage] Fetching agent by slug: ${agentId}`);
const res = await fetch(
`${BASE_URL}/agents/by-slug/${agentId}`,
{
cache: "no-store",
headers: { "x-api-key": apiKey },
}
);
if (res.ok) return await res.json();
// If by-slug fails, try fetching by direct ID (if it looks like a UUID)
if (agentId.length > 20) {
console.log(`[AgentPage] Fetch by slug failed, trying by ID: ${agentId}`);
const resId = await fetch(
`${BASE_URL}/agents/${agentId}`,
{
cache: "no-store",
headers: { "x-api-key": apiKey },
}
);
if (resId.ok) return await resId.json();
}
console.warn(`[AgentPage] Failed to fetch agent details for: ${agentId}`);
return null;
} catch (error) {
console.error("[AgentPage] Fetch error:", error);
return null;
}
}
async function fetchUserData(apiKey) {
if (!apiKey) return null;
try {
const res = await fetch(`${BASE_URL}/api/v1/account/balance`, {
cache: "no-store",
headers: { "x-api-key": apiKey },
});
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
export default async function AgentPage({ params }) {
const { agent_id } = await params;
const cookieStore = await cookies();
const apiKey = cookieStore.get("muapi_key")?.value;
console.log(`[AgentPage] Loading page for agent: ${agent_id}, hasKey: ${!!apiKey}`);
const [agentDetails, userData] = await Promise.all([
fetchAgentDetails(agent_id, apiKey),
fetchUserData(apiKey)
]);
return (
<AgentChatClient
agentDetails={agentDetails}
initialHistory={null}
userData={userData}
/>
);
}

View file

@ -0,0 +1,62 @@
"use client";
import { CreateAgentPage } from "ai-agent";
import "ai-agent/dist/tailwind.css";
import { useCallback, useEffect, useRef } from "react";
import axios from "axios";
const STORAGE_KEY = "muapi_key";
export default function AgentCreateClient({ userData }) {
const interceptorRef = useRef(null);
useEffect(() => {
const getKey = () => {
if (typeof window === "undefined") return null;
const fromStorage = localStorage.getItem(STORAGE_KEY);
if (fromStorage) return fromStorage;
const match = document.cookie.match(/muapi_key=([^;]+)/);
return match ? match[1] : null;
};
const apiKey = getKey();
if (!apiKey) return;
interceptorRef.current = axios.interceptors.request.use((config) => {
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/agents') || config.url.includes('/api/api') || config.url.includes('/api/v1');
if (isRelative || isInternalProxy) {
config.headers["x-api-key"] = apiKey;
}
return config;
});
return () => {
if (interceptorRef.current !== null) {
axios.interceptors.request.eject(interceptorRef.current);
}
};
}, []);
const useUser = useCallback(
() => ({
user: {
username: userData?.email?.split("@")[0] || "Studio User",
name: userData?.email?.split("@")[0] || "Studio User",
email: userData?.email || null,
profile_photo: null,
balance: userData?.balance || 0,
},
isAuthorized: !!userData,
}),
[userData]
);
return (
<CreateAgentPage
useUser={useUser}
usedIn="studio"
/>
);
}

29
app/agents/create/page.js Normal file
View file

@ -0,0 +1,29 @@
import { cookies } from "next/headers";
import AgentCreateClient from "./AgentCreateClient";
const BASE_URL = 'https://api.muapi.ai';
async function fetchUserData(apiKey) {
if (!apiKey) return null;
try {
const res = await fetch(`${BASE_URL}/api/v1/account/balance`, {
cache: "no-store",
headers: { "x-api-key": apiKey },
});
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
export default async function CreateAgentPage() {
const cookieStore = await cookies();
const apiKey = cookieStore.get("muapi_key")?.value;
const userData = await fetchUserData(apiKey);
return (
<AgentCreateClient userData={userData} />
);
}

View file

@ -0,0 +1,62 @@
"use client";
import { EditAgentPage } from "ai-agent";
import "ai-agent/dist/tailwind.css";
import { useCallback, useEffect, useRef } from "react";
import axios from "axios";
const STORAGE_KEY = "muapi_key";
export default function AgentEditClient({ userData }) {
const interceptorRef = useRef(null);
useEffect(() => {
const getKey = () => {
if (typeof window === "undefined") return null;
const fromStorage = localStorage.getItem(STORAGE_KEY);
if (fromStorage) return fromStorage;
const match = document.cookie.match(/muapi_key=([^;]+)/);
return match ? match[1] : null;
};
const apiKey = getKey();
if (!apiKey) return;
interceptorRef.current = axios.interceptors.request.use((config) => {
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/agents') || config.url.includes('/api/api') || config.url.includes('/api/v1');
if (isRelative || isInternalProxy) {
config.headers["x-api-key"] = apiKey;
}
return config;
});
return () => {
if (interceptorRef.current !== null) {
axios.interceptors.request.eject(interceptorRef.current);
}
};
}, []);
const useUser = useCallback(
() => ({
user: {
username: userData?.email?.split("@")[0] || "Studio User",
name: userData?.email?.split("@")[0] || "Studio User",
email: userData?.email || null,
profile_photo: null,
balance: userData?.balance || 0,
},
isAuthorized: !!userData,
}),
[userData]
);
return (
<EditAgentPage
useUser={useUser}
usedIn="studio"
/>
);
}

View file

@ -0,0 +1,30 @@
import { cookies } from "next/headers";
import AgentEditClient from "./AgentEditClient";
const BASE_URL = 'https://api.muapi.ai';
async function fetchUserData(apiKey) {
if (!apiKey) return null;
try {
const res = await fetch(`${BASE_URL}/api/v1/account/balance`, {
cache: "no-store",
headers: { "x-api-key": apiKey },
});
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
export default async function EditAgentPage({ params }) {
const { id } = await params; // although we don't use id on server here, it's used by useParams in client
const cookieStore = await cookies();
const apiKey = cookieStore.get("muapi_key")?.value;
const userData = await fetchUserData(apiKey);
return (
<AgentEditClient userData={userData} />
);
}

16
app/agents/layout.js Normal file
View file

@ -0,0 +1,16 @@
/**
* Layout for /agents/* pages.
* These pages host the AiAgent component full-screen no studio chrome needed.
* The api key is available via the muapi_key cookie which StandaloneShell sets.
*/
export const metadata = {
title: "Agent Chat — Open Generative AI",
};
export default function AgentsLayout({ children }) {
return (
<div className="h-screen w-full overflow-hidden bg-black">
{children}
</div>
);
}

View file

@ -0,0 +1,110 @@
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
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
return headers;
}
// Build the target URL without a trailing slash when path is empty.
// e.g. GET /api/agents?is_template=true → https://api.muapi.ai/agents?is_template=true
// e.g. GET /api/agents/by-slug/foo → https://api.muapi.ai/agents/by-slug/foo
function buildTargetUrl(pathSegments, search) {
const path = pathSegments.join('/');
const base = `${MUAPI_BASE}/agents`;
return path ? `${base}/${path}${search}` : `${base}${search}`;
}
export async function GET(request, { params }) {
const slug = await params;
const pathSegments = slug.path || [];
const { search } = new URL(request.url);
const targetUrl = buildTargetUrl(pathSegments, search);
const headers = cleanHeaders(request);
const apiKey = getApiKey(request);
console.log(`[agents 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();
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 { search } = new URL(request.url);
const targetUrl = buildTargetUrl(pathSegments, search);
const headers = cleanHeaders(request);
const apiKey = getApiKey(request);
console.log(`[agents proxy POST] ${targetUrl} | apiKey: ${apiKey ? apiKey.slice(0,8)+'...' : 'MISSING'}`);
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 { search } = new URL(request.url);
const targetUrl = buildTargetUrl(pathSegments, 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 { search } = new URL(request.url);
const targetUrl = buildTargetUrl(pathSegments, 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,65 @@
import { NextResponse } from 'next/server';
const MUAPI_BASE = 'https://api.muapi.ai';
function getApiKey(request) {
const headerKey = request.headers.get('x-api-key');
if (headerKey) return headerKey;
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');
return headers;
}
// Proxies /api/api/v1/* -> https://api.muapi.ai/api/v1/*
// This is required because the AiAgent library hardcodes a double /api/api
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}/api/v1/${path}${search}`;
const headers = cleanHeaders(request);
const apiKey = getApiKey(request);
console.log(`[double-api 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();
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}/api/v1/${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 });
}
}

View file

@ -2,7 +2,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { ImageStudio, VideoStudio, LipSyncStudio, CinemaStudio, WorkflowStudio, getUserBalance } from 'studio';
import { ImageStudio, VideoStudio, LipSyncStudio, CinemaStudio, WorkflowStudio, AgentStudio, getUserBalance } from 'studio';
import axios from 'axios';
import ApiKeyModal from './ApiKeyModal';
@ -12,6 +12,7 @@ const TABS = [
{ id: 'lipsync', label: 'Lip Sync' },
{ id: 'cinema', label: 'Cinema Studio' },
{ id: 'workflows', label: 'Workflows' },
{ id: 'agents', label: 'Agents' },
];
const STORAGE_KEY = 'muapi_key';
@ -41,6 +42,7 @@ export default function StandaloneShell() {
// Initialize activeTab from URL slug/params or default to 'image'
const getInitialTab = () => {
if (idFromParams || slug.includes('workflow')) return 'workflows';
if (slug.includes('agents')) return 'agents';
const firstSegment = slug[0];
if (firstSegment && TABS.find(t => t.id === firstSegment)) return firstSegment;
return 'image';
@ -54,11 +56,17 @@ export default function StandaloneShell() {
const [isHeaderVisible, setIsHeaderVisible] = useState(true);
const [hasMounted, setHasMounted] = useState(false);
// Drag and Drop State
const [isDragging, setIsDragging] = useState(false);
const [droppedFiles, setDroppedFiles] = useState(null);
// Sync tab with URL if user navigates manually or via browser back/forward
useEffect(() => {
const info = getWorkflowInfo();
if (info.id) {
setActiveTab('workflows');
} else if (slug.includes('agents')) {
setActiveTab('agents');
} else {
const firstSegment = slug[0];
if (firstSegment && TABS.find(t => t.id === firstSegment)) {
@ -136,7 +144,7 @@ export default function StandaloneShell() {
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');
const isInternalProxy = config.url.includes('/api/app') || config.url.includes('/api/workflow') || config.url.includes('/api/agents') || config.url.includes('/api/api') || config.url.includes('/api/v1');
if (isRelative || isInternalProxy) {
config.headers['x-api-key'] = apiKey;
@ -157,6 +165,43 @@ export default function StandaloneShell() {
return () => clearInterval(interval);
}, [apiKey, fetchBalance]);
// Drag and Drop Handlers
const handleDragOver = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDragEnter = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
setIsDragging(true);
}
}, []);
const handleDragLeave = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
// Only set to false if we're leaving the container itself, not moving between children
if (e.currentTarget.contains(e.relatedTarget)) return;
setIsDragging(false);
}, []);
const handleDrop = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
setDroppedFiles(files);
}
}, []);
const handleFilesHandled = useCallback(() => {
setDroppedFiles(null);
}, []);
if (!hasMounted) return (
<div className="min-h-screen bg-[#050505] flex items-center justify-center">
<div className="animate-spin text-[#d9ff00] text-3xl"></div>
@ -168,7 +213,30 @@ export default function StandaloneShell() {
}
return (
<div className="h-screen bg-[#030303] flex flex-col overflow-hidden text-white">
<div
className="h-screen bg-[#030303] flex flex-col overflow-hidden text-white relative"
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Drag Overlay */}
{isDragging && (
<div className="fixed inset-0 z-[100] bg-[#d9ff00]/10 backdrop-blur-md border-4 border-dashed border-[#d9ff00]/50 flex items-center justify-center pointer-events-none transition-all duration-300">
<div className="bg-[#0a0a0a] p-8 rounded-3xl border border-white/10 shadow-2xl flex flex-col items-center gap-4 scale-110 animate-pulse">
<div className="w-20 h-20 bg-[#d9ff00] rounded-2xl flex items-center justify-center">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="black" strokeWidth="2.5">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12"/>
</svg>
</div>
<div className="flex flex-col items-center">
<span className="text-xl font-bold text-white">Drop your media here</span>
<span className="text-sm text-white/40">Images, videos, or audio files</span>
</div>
</div>
</div>
)}
{/* Header */}
{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">
@ -223,11 +291,12 @@ export default function StandaloneShell() {
{/* Studio Content */}
<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 === 'image' && <ImageStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />}
{activeTab === 'video' && <VideoStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />}
{activeTab === 'lipsync' && <LipSyncStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />}
{activeTab === 'cinema' && <CinemaStudio apiKey={apiKey} />}
{activeTab === 'workflows' && <WorkflowStudio apiKey={apiKey} isHeaderVisible={isHeaderVisible} onToggleHeader={setIsHeaderVisible} />}
{activeTab === 'agents' && <AgentStudio apiKey={apiKey} isHeaderVisible={isHeaderVisible} onToggleHeader={setIsHeaderVisible} />}
</div>
{/* Settings Modal */}

View file

@ -2,7 +2,9 @@
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
"@/*": ["./*"],
"ai-agent": ["./packages/ai-agent/src/index.js"],
"workflow-builder": ["./packages/workflow-ui/src/index.js"]
}
}
}

View file

@ -1,6 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ['studio'],
transpilePackages: ['studio', 'ai-agent', 'workflow-builder'],
};
export default nextConfig;

151
package-lock.json generated
View file

@ -8,9 +8,12 @@
"name": "open-generative-ai",
"version": "1.0.1",
"workspaces": [
"packages/studio"
"packages/studio",
"packages/workflow-ui",
"packages/ai-agent"
],
"dependencies": {
"ai-agent": "file:./packages/ai-agent",
"axios": "^1.7.0",
"next": "^15.0.0",
"react": "^19.0.0",
@ -5732,6 +5735,10 @@
"node": ">=8"
}
},
"node_modules/ai-agent": {
"resolved": "packages/ai-agent",
"link": true
},
"node_modules/ajv": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
@ -12633,6 +12640,16 @@
}
}
},
"node_modules/next-themes": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@ -16127,11 +16144,143 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"packages/ai-agent": {
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"axios": "^1.7.0",
"next-themes": "^0.4.6",
"react-hot-toast": "^2.5.2",
"react-icons": "^5.0.1",
"react-markdown": "^9.0.0",
"react-toastify": "^11.0.5",
"reactflow": "^11.11.4",
"remark-gfm": "^4.0.0"
},
"devDependencies": {
"@babel/cli": "^7.28.3",
"@babel/preset-env": "^7.28.5",
"@babel/preset-react": "^7.28.5",
"autoprefixer": "^10.4.14",
"postcss": "^8.4.24",
"tailwindcss": "^3.3.3"
},
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
}
},
"packages/ai-agent/node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
"glob-parent": "^5.1.2",
"merge2": "^1.3.0",
"micromatch": "^4.0.8"
},
"engines": {
"node": ">=8.6.0"
}
},
"packages/ai-agent/node_modules/fast-glob/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"packages/ai-agent/node_modules/jiti": {
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "bin/jiti.js"
}
},
"packages/ai-agent/node_modules/react-markdown": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz",
"integrity": "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"hast-util-to-jsx-runtime": "^2.0.0",
"html-url-attributes": "^3.0.0",
"mdast-util-to-hast": "^13.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.0.0",
"unified": "^11.0.0",
"unist-util-visit": "^5.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
},
"peerDependencies": {
"@types/react": ">=18",
"react": ">=18"
}
},
"packages/ai-agent/node_modules/tailwindcss": {
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
"chokidar": "^3.6.0",
"didyoumean": "^1.2.2",
"dlv": "^1.1.3",
"fast-glob": "^3.3.2",
"glob-parent": "^6.0.2",
"is-glob": "^4.0.3",
"jiti": "^1.21.7",
"lilconfig": "^3.1.3",
"micromatch": "^4.0.8",
"normalize-path": "^3.0.0",
"object-hash": "^3.0.0",
"picocolors": "^1.1.1",
"postcss": "^8.4.47",
"postcss-import": "^15.1.0",
"postcss-js": "^4.0.1",
"postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
"postcss-nested": "^6.2.0",
"postcss-selector-parser": "^6.1.2",
"resolve": "^1.22.8",
"sucrase": "^3.35.0"
},
"bin": {
"tailwind": "lib/cli.js",
"tailwindcss": "lib/cli.js"
},
"engines": {
"node": ">=14.0.0"
}
},
"packages/studio": {
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"@xyflow/react": "^12.10.2",
"ai-agent": "file:../ai-agent",
"axios": "^1.7.0",
"lucide-react": "^1.8.0",
"react-hot-toast": "^2.4.1",

View file

@ -4,7 +4,9 @@
"private": true,
"version": "1.0.2",
"workspaces": [
"packages/studio"
"packages/studio",
"packages/workflow-ui",
"packages/ai-agent"
],
"scripts": {
"dev": "next dev",
@ -90,7 +92,8 @@
"react-dom": "^19.0.0",
"react-hot-toast": "^2.4.1",
"studio": "*",
"workflow-builder": "file:./packages/workflow-ui"
"workflow-builder": "file:./packages/workflow-ui",
"ai-agent": "file:./packages/ai-agent"
},
"devDependencies": {
"@eslint/eslintrc": "^3",

1
packages/ai-agent Submodule

@ -0,0 +1 @@
Subproject commit bfc7087bf4ae0ed9a9c98daed9bf0c3c8d7333df

View file

@ -24,7 +24,8 @@
"react-toastify": "^11.1.0",
"reactflow": "^11.11.4",
"remark-gfm": "^4.0.1",
"workflow-builder": "file:../workflow-ui"
"workflow-builder": "file:../workflow-ui",
"ai-agent": "file:../ai-agent"
},
"peerDependencies": {
"react": ">=18.0.0",

View file

@ -0,0 +1,295 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import {
getTemplateAgents,
getUserAgents,
getUserConversations,
} from "../muapi.js";
// Helpers
function timeAgo(dateStr) {
if (!dateStr) return "";
const utcStr =
dateStr.endsWith("Z") || dateStr.includes("+") ? dateStr : dateStr + "Z";
const diff = Math.floor((Date.now() - new Date(utcStr)) / 1000);
if (diff < 60) return "just now";
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
return new Date(utcStr).toLocaleDateString();
}
// Agent Card (grid)
function AgentCard({ agent, onClick, onEdit }) {
return (
<div className="group relative aspect-[4/5] rounded-xl cursor-pointer">
<div
onClick={() => onClick(agent)}
className="absolute inset-0 rounded-xl overflow-hidden border border-white/5 bg-[#0a0a0a] transition-all group-hover:border-[#d9ff00]/30 group-hover:scale-[1.02] shadow-2xl"
>
{agent.icon_url ? (
<img
src={agent.icon_url}
alt={agent.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-violet-500/10 to-fuchsia-500/10 flex items-center justify-center">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="1" 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" />
<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">
{agent.category || "AI Assistant"}
</div>
<h3 className="text-sm font-bold text-white truncate group-hover:text-[#d9ff00] transition-colors">
{agent.name || "Unnamed Agent"}
</h3>
{agent.owner_username && (
<p className="text-[9px] text-white/40 mt-1 uppercase tracking-tighter font-black">
By {agent.owner_username}
</p>
)}
</div>
</div>
{onEdit && (
<button
onClick={(e) => {
e.stopPropagation();
onEdit(agent);
}}
className="absolute top-3 right-3 w-8 h-8 rounded-full bg-black/60 border border-white/10 flex items-center justify-center text-white opacity-0 group-hover:opacity-100 transition-all hover:bg-[#d9ff00] hover:text-black hover:scale-110 z-10"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
)}
</div>
);
}
// Conversation Card (My Chats)
function ConversationCard({ conv, onClick }) {
const displayTitle = conv.title || "New Chat";
const agentSlug = conv.agent_slug || conv.agent_id;
return (
<div
onClick={() => onClick(agentSlug, conv.id)}
className="group flex flex-col gap-3 bg-white/[0.03] border border-white/5 rounded-xl p-4 hover:border-[#d9ff00]/20 hover:bg-white/5 transition-all cursor-pointer"
>
<div className="flex items-center gap-3">
<div className="relative w-10 h-10 rounded-xl overflow-hidden bg-white/5 border border-white/5 shrink-0">
{conv.agent_icon_url ? (
<img src={conv.agent_icon_url} alt={conv.agent_name || "Agent"} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-white/20">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
</div>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-[10px] font-black text-[#d9ff00] uppercase tracking-wider truncate">
{conv.agent_name || "Unknown Agent"}
</p>
<p className="text-sm font-bold text-white truncate" title={displayTitle}>
{displayTitle}
</p>
</div>
</div>
<div className="flex items-center justify-between pt-2 border-t border-white/5 mt-auto text-[10px] text-white/30 font-medium">
<span>{timeAgo(conv.updated_at)}</span>
{conv.message_count != null && <span>{conv.message_count} msgs</span>}
</div>
</div>
);
}
// Main Component
const TABS = ["templates", "my-agents", "my-chats"];
export default function AgentStudio({ apiKey }) {
const router = useRouter();
const [activeMainTab, setActiveMainTab] = useState("templates");
const [agents, setAgents] = useState([]);
const [conversations, setConversations] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Navigate to the standalone /agents page AiAgent handles its own routing there
const handleSelectAgent = useCallback(
(agent) => {
const id = agent.agent_id || agent.id;
router.push(`/agents/${id}`);
},
[router]
);
const handleEditAgent = useCallback(
(agent) => {
const id = agent.agent_id || agent.id;
router.push(`/agents/edit/${id}`);
},
[router]
);
const handleCreateAgent = useCallback(() => {
router.push("/agents/create");
}, [router]);
const handleOpenConversation = useCallback(
(agentSlug, convId) => {
router.push(`/agents/${agentSlug}/${convId}`);
},
[router]
);
useEffect(() => {
if (!apiKey) return;
let cancelled = false;
async function load() {
setLoading(true);
setError(null);
setAgents([]);
setConversations([]);
try {
if (activeMainTab === "templates") {
const data = await getTemplateAgents(apiKey);
if (!cancelled) setAgents(data);
} else if (activeMainTab === "my-agents") {
const data = await getUserAgents(apiKey);
if (!cancelled) setAgents(data);
} else if (activeMainTab === "my-chats") {
const data = await getUserConversations(apiKey);
if (!cancelled) setConversations(data);
}
} catch (err) {
console.error("AgentStudio load error:", err);
if (!cancelled) setError(err.message || "Failed to load.");
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => { cancelled = true; };
}, [apiKey, activeMainTab]);
// Render
return (
<div className="h-full flex flex-col bg-[#030303] text-white">
{/* Header */}
<div className="flex-shrink-0 h-16 border-b border-white/5 flex items-center justify-between px-8 bg-black/40">
<div className="flex items-center gap-8 h-full">
<h2 className="text-sm font-black uppercase tracking-[0.2em] text-[#d9ff00]">
Agents
</h2>
<div className="flex gap-1 bg-white/5 p-1 rounded-xl">
{TABS.map((tab) => (
<button
key={tab}
onClick={() => setActiveMainTab(tab)}
className={`px-4 py-1.5 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all ${
activeMainTab === tab
? "bg-white text-black shadow-xl"
: "text-white/40 hover:text-white hover:bg-white/5"
}`}
>
{tab.replace(/-/g, " ")}
</button>
))}
</div>
</div>
<button
onClick={handleCreateAgent}
className="px-6 py-2 bg-[#d9ff00] text-black text-[10px] font-black uppercase tracking-widest rounded-lg hover:bg-[#ebff66] transition-all active:scale-95 flex items-center gap-2"
>
<span className="text-sm">+</span>
Create
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-8">
{loading ? (
<div className="h-full flex items-center justify-center">
<div className="w-10 h-10 border-2 border-white/5 border-t-[#d9ff00] rounded-full animate-spin" />
</div>
) : error ? (
<div className="h-full flex flex-col items-center justify-center text-white/20 gap-4">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1">
<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>
<p className="text-xs font-bold uppercase tracking-widest">{error}</p>
<button
onClick={() => setActiveMainTab(activeMainTab)} // retrigger effect
className="text-[10px] text-white/40 hover:text-white border border-white/10 px-4 py-2 rounded-lg transition-colors"
>
Retry
</button>
</div>
) : activeMainTab === "my-chats" ? (
// My Chats view
conversations.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-white/10 gap-4">
<svg width="60" height="60" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="0.5">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
<p className="text-[10px] font-black uppercase tracking-[0.3em]">No chats yet</p>
<button
onClick={() => setActiveMainTab("templates")}
className="text-[10px] text-[#d9ff00] hover:text-white border border-[#d9ff00]/20 hover:border-white/20 px-4 py-2 rounded-lg transition-colors"
>
Browse Templates
</button>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 max-w-[1600px] mx-auto">
{conversations.map((conv) => (
<ConversationCard
key={conv.id}
conv={conv}
onClick={handleOpenConversation}
/>
))}
</div>
)
) : (
// Agents grid (templates / my-agents)
agents.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-white/10 gap-4">
<svg width="60" height="60" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="0.5">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
<p className="text-[10px] font-black uppercase tracking-[0.3em]">No agents found</p>
</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 max-w-[1600px] mx-auto">
{agents.map((agent) => (
<AgentCard
key={agent.agent_id || agent.id}
agent={agent}
onClick={handleSelectAgent}
/>
))}
</div>
)
)}
</div>
</div>
);
}

View file

@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { generateImage } from "../muapi.js";
import { generateImage, uploadFile } from "../muapi.js";
// Constants (inlined from promptUtils)
@ -111,14 +111,6 @@ function Dropdown({ items, selected, onSelect, triggerRef, onClose }) {
const [position, setPosition] = useState({ bottom: 0, left: 0 });
useEffect(() => {
if (triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
setPosition({
bottom: window.innerHeight - rect.top + 8,
left: rect.left,
});
}
const handler = (e) => {
if (
menuRef.current &&
@ -129,21 +121,14 @@ function Dropdown({ items, selected, onSelect, triggerRef, onClose }) {
onClose();
}
};
const timer = setTimeout(
() => document.addEventListener("click", handler),
0,
);
return () => {
clearTimeout(timer);
document.removeEventListener("click", handler);
};
}, [triggerRef, onClose]);
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [onClose, triggerRef]);
return (
<div
ref={menuRef}
className="custom-dropdown fixed bg-[#1a1a1a] border border-white/10 rounded-xl py-1 shadow-2xl z-50 flex flex-col min-w-[100px] animate-fade-in"
style={{ bottom: position.bottom, left: position.left }}
className="custom-dropdown absolute bottom-[calc(100%+8px)] left-0 bg-[#1a1a1a] border border-white/10 rounded py-1 shadow-2xl z-50 flex flex-col min-w-[120px] animate-fade-in"
>
{items.map((item) => (
<button
@ -204,35 +189,24 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
children.forEach((child) => {
const imgBox = child.querySelector("[data-imgbox]");
const label = child.querySelector("[data-label]");
const focalSpan = imgBox?.querySelector("[data-focal-text]");
const isClosest = child === closest;
if (isClosest) {
child.classList.remove("opacity-30", "scale-75", "blur-[1px]");
child.classList.add("opacity-100", "scale-100", "blur-0", "z-30");
child.classList.remove("opacity-20", "scale-90");
child.classList.add("opacity-100", "scale-100", "z-30");
if (imgBox) {
imgBox.classList.add(
"border-primary/50",
"shadow-glow-sm",
"scale-110",
);
imgBox.classList.remove("border-white/10", "bg-white/5");
imgBox.classList.add("border-primary/40", "bg-primary/5", "scale-110");
imgBox.classList.remove("border-transparent", "bg-transparent");
}
if (focalSpan) focalSpan.classList.add("text-primary");
if (label) label.classList.add("text-primary", "text-shadow-sm");
if (label) label.classList.add("text-primary");
} else {
child.classList.add("opacity-30", "scale-75", "blur-[1px]");
child.classList.remove("opacity-100", "scale-100", "blur-0", "z-30");
child.classList.add("opacity-20", "scale-90");
child.classList.remove("opacity-100", "scale-100", "z-30");
if (imgBox) {
imgBox.classList.remove(
"border-primary/50",
"shadow-glow-sm",
"scale-110",
);
imgBox.classList.add("border-white/10", "bg-white/5");
imgBox.classList.remove("border-primary/40", "bg-primary/5", "scale-110");
imgBox.classList.add("border-transparent", "bg-transparent");
}
if (focalSpan) focalSpan.classList.remove("text-primary");
if (label) label.classList.remove("text-primary", "text-shadow-sm");
if (label) label.classList.remove("text-primary");
}
});
@ -299,18 +273,26 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
if (target) target.scrollIntoView({ behavior: "smooth", block: "center" });
};
const getSelectedDescription = () => {
if (columnKey === 'camera') return CAMERA_MAP[value] || '';
if (columnKey === 'lens') return LENS_MAP[value] || '';
if (columnKey === 'focal') return FOCAL_PERSPECTIVE[value] || '';
if (columnKey === 'aperture') return APERTURE_EFFECT[value] || '';
return '';
};
return (
<div className="flex flex-col items-center relative w-[140px] md:w-[160px] shrink-0 snap-center group">
<div className="mb-3 text-[9px] font-black text-white/40 uppercase tracking-[0.2em] text-center">
<div className="flex flex-col items-center relative w-[130px] md:w-[150px] shrink-0 snap-center">
<div className="mb-4 text-[10px] font-black text-white/20 uppercase tracking-[0.25em] text-center">
{title}
</div>
<div className="relative overflow-hidden w-full h-[40vh] md:h-[320px] bg-[#050505]/60 rounded-2xl border border-white/[0.05] shadow-2xl backdrop-blur-2xl transition-transform duration-500 hover:scale-[1.01] hover:border-white/[0.1]">
{/* Top mask */}
<div className="absolute top-0 left-0 right-0 h-32 bg-gradient-to-b from-[#0a0a0a] via-[#0a0a0a]/40 to-transparent z-20 pointer-events-none" />
{/* Bottom mask */}
<div className="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-t from-[#0a0a0a] via-[#0a0a0a]/40 to-transparent z-20 pointer-events-none" />
{/* Center selection indicator */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[85%] h-[80px] bg-primary/[0.03] border border-primary/[0.1] rounded-2xl pointer-events-none z-0" />
<div className="relative overflow-hidden w-full h-[280px] md:h-[300px] bg-gradient-to-b from-white/[0.02] to-transparent rounded-2xl border border-white/[0.03] shadow-2xl backdrop-blur-3xl group">
{/* Masks */}
<div className="absolute top-0 left-0 right-0 h-24 bg-gradient-to-b from-[#0a0a0a] to-transparent z-20 pointer-events-none" />
<div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-t from-[#0a0a0a] to-transparent z-20 pointer-events-none" />
{/* Active Selection Ring */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[85%] h-[70px] bg-white/[0.02] border border-white/[0.05] rounded-xl pointer-events-none z-0" />
<div
ref={listRef}
@ -320,8 +302,7 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
onMouseUp={onMouseUp}
onMouseMove={onMouseMove}
>
{/* Top spacer */}
<div style={{ height: "calc(50% - 50px)" }} />
<div style={{ height: "calc(50% - 35px)" }} />
{items.map((item) => {
const imageUrl = ASSET_URLS[item];
@ -329,33 +310,28 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
<div
key={item}
data-value={item}
className="h-[100px] flex flex-col items-center justify-center gap-3 snap-center cursor-pointer transition-all duration-500 ease-out text-white p-2 select-none opacity-30 scale-75 blur-[1px]"
className="h-[70px] flex flex-col items-center justify-center gap-2 snap-center cursor-pointer transition-all duration-300 ease-out text-white p-2 select-none opacity-20 scale-90"
onClick={() => onItemClick(item)}
>
<div
data-imgbox="true"
className="w-14 h-14 rounded-xl border border-white/10 bg-white/5 flex items-center justify-center transition-all duration-500 shadow-inner overflow-hidden relative"
className="w-10 h-10 rounded-lg border border-transparent flex items-center justify-center transition-all duration-300 overflow-hidden relative"
>
{imageUrl ? (
<img
src={imageUrl}
alt={String(item)}
className="w-full h-full object-cover opacity-80"
className="w-full h-full object-cover opacity-70"
/>
) : columnKey === "focal" ? (
<span
data-focal-text="true"
className="text-lg font-bold text-white/50"
>
) : (
<span className="text-sm font-bold text-white/40">
{item}
</span>
) : (
<div className="w-3 h-3 bg-white/20 rounded-full" />
)}
</div>
<span
data-label="true"
className="text-[9px] md:text-[10px] font-bold uppercase text-center leading-tight max-w-full truncate px-1 tracking-wider"
className="text-[8px] md:text-[9px] font-black uppercase text-center leading-tight max-w-full truncate px-1 tracking-widest text-white/60"
>
{item}
</span>
@ -363,10 +339,16 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
);
})}
{/* Bottom spacer */}
<div style={{ height: "calc(50% - 50px)" }} />
<div style={{ height: "calc(50% - 35px)" }} />
</div>
</div>
{/* Selection Helper Text */}
<div className="mt-4 h-8 px-2 text-center">
<span className="text-[9px] font-medium text-primary/60 uppercase tracking-widest animate-fade-in inline-block leading-tight">
{getSelectedDescription()}
</span>
</div>
</div>
);
}
@ -394,21 +376,19 @@ function CameraControlsOverlay({
onClick={handleBackdropClick}
>
<div
className={`w-full max-w-5xl bg-[#0a0a0a]/60 border border-white/10 rounded-2xl p-6 md:p-10 shadow-3xl transform transition-all duration-500 flex flex-col max-h-[90vh] ${isOpen ? "scale-100 translate-y-0" : "scale-95 translate-y-10"}`}
className={`w-full max-w-5xl bg-[#0a0a0a] border border-white/5 rounded-3xl p-6 md:p-10 shadow-[0_0_100px_rgba(0,0,0,0.8)] transform transition-all duration-500 flex flex-col max-h-[90vh] ${isOpen ? "scale-100 translate-y-0" : "scale-95 translate-y-10"}`}
>
{/* Header */}
<div className="flex items-center justify-between mb-10">
<div className="flex items-center justify-between mb-8">
<div className="flex flex-col gap-1">
<h2 className="text-xl font-bold text-white tracking-tight">
Camera Configuration
<h2 className="text-2xl font-black text-white tracking-tighter uppercase italic">
Camera Config
</h2>
<p className="text-[11px] font-medium text-white/20 uppercase tracking-[0.2em]">
Select hardware & optics
</p>
<div className="h-[1px] w-12 bg-primary/40" />
</div>
<button
onClick={onClose}
className="w-10 h-10 rounded-full bg-white/[0.03] border border-white/[0.05] flex items-center justify-center text-white/40 hover:text-white hover:bg-white/[0.06] transition-all"
className="w-10 h-10 rounded-full hover:bg-white/5 flex items-center justify-center text-white/20 hover:text-white transition-all"
>
<svg
width="20"
@ -416,7 +396,7 @@ function CameraControlsOverlay({
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeWidth="2"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
@ -484,6 +464,10 @@ export default function CinemaStudio({
const [isGenerating, setIsGenerating] = useState(false);
const [canvasUrl, setCanvasUrl] = useState(null); // null = prompt view
const [fullscreenUrl, setFullscreenUrl] = useState(null);
const [uploadedImage, setUploadedImage] = useState(null);
const [isUploadingImage, setIsUploadingImage] = useState(false);
const [imageUploadProgress, setImageUploadProgress] = useState(0);
const imageInputRef = useRef(null);
const [activeHistoryIndex, setactiveHistoryIndex] = useState(null);
// Internal history state (used when historyItems prop is not provided)
@ -498,6 +482,31 @@ export default function CinemaStudio({
const textareaRef = useRef(null);
const resultImgRef = useRef(null);
const handleImageUpload = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setIsUploadingImage(true);
setImageUploadProgress(0);
try {
const url = await uploadFile(apiKey, file, (progress) => {
setImageUploadProgress(progress);
});
if (url) setUploadedImage(url);
} catch (err) {
console.error("Image upload failed:", err);
} finally {
setIsUploadingImage(false);
setImageUploadProgress(0);
if (imageInputRef.current) imageInputRef.current.value = "";
}
};
const removeImage = () => {
setUploadedImage(null);
};
// Persistence: Load
useEffect(() => {
try {
@ -507,12 +516,25 @@ export default function CinemaStudio({
if (data.settings) setSettings(data.settings);
if (data.resolution) setResolution(data.resolution);
if (data.internalHistory) setInternalHistory(data.internalHistory);
if (data.uploadedImage) setUploadedImage(data.uploadedImage);
}
} catch (err) {
console.warn("Failed to load CinemaStudio persistence:", err);
}
}, []);
// Adjust height on load
useEffect(() => {
const timer = setTimeout(() => {
if (textareaRef.current) {
const el = textareaRef.current;
el.style.height = "auto";
el.style.height = el.scrollHeight + "px";
}
}, 150);
return () => clearTimeout(timer);
}, []);
// Persistence: Save
useEffect(() => {
const timer = setTimeout(() => {
@ -521,6 +543,7 @@ export default function CinemaStudio({
settings,
resolution,
internalHistory,
uploadedImage,
};
localStorage.setItem(PERSIST_KEY, JSON.stringify(state));
} catch (err) {
@ -528,7 +551,7 @@ export default function CinemaStudio({
}
}, 500); // 500ms debounce
return () => clearTimeout(timer);
}, [settings, resolution, internalHistory]);
}, [settings, resolution, internalHistory, uploadedImage]);
// Derive effective history (prop wins over internal)
const history = historyItems != null ? historyItems : internalHistory;
@ -566,11 +589,12 @@ export default function CinemaStudio({
try {
const res = await generateImage(apiKey, {
model: "nano-banana-pro",
model: uploadedImage ? "nano-banana-pro-edit" : "nano-banana-pro",
prompt: finalPrompt,
aspect_ratio: settings.aspect_ratio,
resolution: resolution.toLowerCase(),
negative_prompt: "blurry, low quality, distortion, bad composition",
images_list: uploadedImage ? [uploadedImage] : [],
});
if (res && res.url) {
@ -693,7 +717,7 @@ export default function CinemaStudio({
{history.map((entry, idx) => (
<div
key={entry.timestamp ?? idx}
className="relative group rounded-2xl overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-[#d9ff00]/50 transition-all duration-300 flex flex-col cursor-pointer"
className="relative group rounded-lg overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-[#d9ff00]/50 transition-all duration-300 flex flex-col cursor-pointer"
onClick={() => loadHistoryItem(entry, idx)}
>
<img
@ -799,7 +823,77 @@ export default function CinemaStudio({
{/* Left Column */}
<div className="flex-1 flex flex-col gap-3 min-h-[80px] justify-between py-1">
{/* Input Row */}
<div className="flex items-start gap-4 w-full">
<div className="flex items-start gap-4 w-full px-1">
{/* Image Upload Button */}
<div className="relative pt-0.5">
<input
type="file"
ref={imageInputRef}
className="hidden"
accept="image/*"
onChange={handleImageUpload}
/>
<button
onClick={() =>
uploadedImage
? removeImage()
: imageInputRef.current?.click()
}
disabled={isUploadingImage}
className={`w-10 h-10 shrink-0 rounded-full border transition-all flex items-center justify-center relative overflow-hidden ${uploadedImage ? "border-primary/60 bg-white/5" : "bg-white/[0.03] border-white/[0.03] hover:bg-white/10 hover:border-primary/40"} group`}
>
{isUploadingImage ? (
<div className="flex flex-col items-center justify-center w-full h-full absolute inset-0 bg-black/80 z-20 backdrop-blur-[2px]">
<svg className="w-8 h-8 -rotate-90">
<circle
cx="16"
cy="16"
r="14"
stroke="currentColor"
strokeWidth="2"
fill="transparent"
className="text-white/10"
/>
<circle
cx="16"
cy="16"
r="14"
stroke="currentColor"
strokeWidth="2"
fill="transparent"
strokeDasharray={88}
strokeDashoffset={88 - (88 * imageUploadProgress) / 100}
className="text-primary transition-all duration-300"
/>
</svg>
<span className="absolute text-[8px] font-bold text-white">
{imageUploadProgress}%
</span>
</div>
) : uploadedImage ? (
<div className="relative w-full h-full group">
<img
src={uploadedImage}
alt="Reference"
className="w-full h-full object-cover opacity-80 group-hover:opacity-40 transition-opacity"
/>
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" className="text-white">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</div>
</div>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="text-white/40 group-hover:text-white transition-colors">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
)}
</button>
</div>
<textarea
ref={textareaRef}
placeholder="Describe your cinema scene..."
@ -898,7 +992,32 @@ export default function CinemaStudio({
</div>
</div>
</div>
{fullscreenUrl && (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/95 backdrop-blur-sm animate-fade-in"
onClick={() => setFullscreenUrl(null)}
>
<button
type="button"
className="absolute top-6 right-6 p-3 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors border border-white/10"
onClick={(e) => {
e.stopPropagation();
setFullscreenUrl(null);
}}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
<img
src={fullscreenUrl}
alt="Fullscreen Preview"
className="max-w-[95vw] max-h-[95vh] rounded-2xl shadow-2xl object-contain animate-scale-up"
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
{/* ── Camera Controls Overlay ── */}
<CameraControlsOverlay
isOpen={isOverlayOpen}

View file

@ -242,9 +242,30 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear, initialUrls = [] }
let badge;
if (uploading && !hasSelection) {
badge = (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/60 z-10">
<div className="w-4 h-4 rounded-full border border-primary/30 border-t-primary animate-spin mb-0.5" />
<span className="text-[8px] font-black text-primary">
<div className="flex flex-col items-center justify-center w-full h-full absolute inset-0 bg-black/80 z-20 backdrop-blur-[2px]">
<svg className="w-8 h-8 -rotate-90">
<circle
cx="16"
cy="16"
r="14"
stroke="currentColor"
strokeWidth="2"
fill="transparent"
className="text-white/10"
/>
<circle
cx="16"
cy="16"
r="14"
stroke="currentColor"
strokeWidth="2"
fill="transparent"
strokeDasharray={88}
strokeDashoffset={88 - (88 * lastUploadProgress) / 100}
className="text-primary transition-all duration-300"
/>
</svg>
<span className="absolute text-[9px] font-black text-primary leading-none">
{lastUploadProgress}%
</span>
</div>
@ -718,6 +739,8 @@ export default function ImageStudio({
apiKey,
onGenerationComplete,
historyItems,
droppedFiles,
onFilesHandled,
}) {
const PERSIST_KEY = "hg_image_studio_persistent";
@ -747,6 +770,7 @@ export default function ImageStudio({
// Canvas / history state
const [currentImageUrl, setCurrentImageUrl] = useState(null);
const [activeHistoryIdx, setActiveHistoryIdx] = useState(0);
const [batchSize, setBatchSize] = useState(1);
const [localHistory, setLocalHistory] = useState([]); // [{id,url,prompt,model,aspect_ratio,timestamp}]
// Use prop history if provided, otherwise local
@ -783,6 +807,7 @@ export default function ImageStudio({
if (data.maxImages) setMaxImages(data.maxImages);
if (data.prompt) setPrompt(data.prompt);
if (data.uploadedImageUrls) setUploadedImageUrls(data.uploadedImageUrls);
if (data.batchSize) setBatchSize(data.batchSize);
if (data.localHistory) setLocalHistory(data.localHistory);
}
} catch (err) {
@ -790,6 +815,14 @@ export default function ImageStudio({
}
}, []);
// Adjust height on load
useEffect(() => {
const timer = setTimeout(() => {
handleTextareaInput();
}, 150);
return () => clearTimeout(timer);
}, []);
// Persistence: Save
useEffect(() => {
const timer = setTimeout(() => {
@ -803,6 +836,7 @@ export default function ImageStudio({
maxImages,
prompt,
uploadedImageUrls,
batchSize,
localHistory,
};
localStorage.setItem(PERSIST_KEY, JSON.stringify(state));
@ -820,9 +854,58 @@ export default function ImageStudio({
maxImages,
prompt,
uploadedImageUrls,
batchSize,
localHistory,
]);
const processDroppedImages = async (files) => {
const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB
const tooLarge = files.filter((f) => f.size > MAX_IMAGE_SIZE);
if (tooLarge.length > 0) {
alert(
`The following images are too large (max 10MB): ${tooLarge.map((f) => f.name).join(", ")}`
);
return;
}
setGenerating(true); // Show as generating/busy
try {
const toUpload =
maxImages === 1 ? files.slice(0, 1) : files.slice(0, maxImages);
const urls = await Promise.all(
toUpload.map(async (file) => {
try {
return await uploadFile(apiKey, file);
} catch (err) {
console.error(
"[ImageStudio] Drop upload failed for",
file.name,
err
);
throw err;
}
})
);
handleUploadSelect({ urls });
} catch (err) {
alert(`Image upload failed: ${err.message}`);
} finally {
setGenerating(false);
}
};
// Handle Dropped Files
useEffect(() => {
if (droppedFiles && droppedFiles.length > 0) {
const imageFiles = droppedFiles.filter(f => f.type.startsWith('image/'));
if (imageFiles.length > 0) {
processDroppedImages(imageFiles);
}
onFilesHandled?.();
}
}, [droppedFiles, onFilesHandled, processDroppedImages]);
// Derived: current model lists & helpers
const currentModels = imageMode ? i2iModels : t2iModels;
const currentAspectRatios = imageMode
@ -943,50 +1026,53 @@ export default function ImageStudio({
setGenerateError(null);
try {
let res;
if (imageMode) {
const genParams = {
model: selectedModelId,
images_list: uploadedImageUrls,
image_url: uploadedImageUrls[0],
aspect_ratio: selectedAr,
};
if (prompt.trim()) genParams.prompt = prompt.trim();
if (currentQualityField && selectedQuality) {
genParams[currentQualityField] = selectedQuality;
}
res = await generateI2I(apiKey, genParams);
} else {
const genParams = {
model: selectedModelId,
prompt: prompt.trim(),
aspect_ratio: selectedAr,
};
if (currentQualityField && selectedQuality) {
genParams[currentQualityField] = selectedQuality;
}
res = await generateImage(apiKey, genParams);
}
const results = await Promise.all(
Array.from({ length: batchSize }).map(async () => {
if (imageMode) {
const genParams = {
model: selectedModelId,
images_list: uploadedImageUrls,
image_url: uploadedImageUrls[0],
aspect_ratio: selectedAr,
};
if (prompt.trim()) genParams.prompt = prompt.trim();
if (currentQualityField && selectedQuality) {
genParams[currentQualityField] = selectedQuality;
}
return await generateI2I(apiKey, genParams);
} else {
const genParams = {
model: selectedModelId,
prompt: prompt.trim(),
aspect_ratio: selectedAr,
};
if (currentQualityField && selectedQuality) {
genParams[currentQualityField] = selectedQuality;
}
return await generateImage(apiKey, genParams);
}
})
);
if (res && res.url) {
const entry = {
id: res.id || Date.now().toString(),
url: res.url,
prompt: prompt.trim(),
model: selectedModelId,
aspect_ratio: selectedAr,
timestamp: new Date().toISOString(),
};
addToHistory(entry);
onGenerationComplete?.({
url: res.url,
model: selectedModelId,
prompt: prompt.trim(),
type: "image",
});
} else {
throw new Error("No image URL returned by API");
}
results.forEach((res) => {
if (res && res.url) {
const entry = {
id: res.id || Math.random().toString(36).substring(7),
url: res.url,
prompt: prompt.trim(),
model: selectedModelId,
aspect_ratio: selectedAr,
timestamp: new Date().toISOString(),
};
addToHistory(entry);
onGenerationComplete?.({
url: res.url,
model: selectedModelId,
prompt: prompt.trim(),
type: "image",
});
}
});
} catch (e) {
console.error("[ImageStudio] Generation failed:", e);
setGenerateError(e.message.slice(0, 80));
@ -1254,6 +1340,24 @@ export default function ImageStudio({
)}
</div>
)}
{/* Batch size selector */}
<div className="flex items-center gap-1 bg-white/[0.03] rounded-md p-1 border border-white/[0.03]">
{[1, 2, 3, 4].map((num) => (
<button
key={num}
type="button"
onClick={() => setBatchSize(num)}
className={`w-7 h-7 flex items-center justify-center rounded-md text-[10px] font-black transition-all ${
batchSize === num
? "bg-[#d9ff00] text-black shadow-lg shadow-[#d9ff00]/20"
: "text-white/40 hover:text-white/80 hover:bg-white/5"
}`}
>
{num}
</button>
))}
</div>
</div>
{/* Generate button */}

View file

@ -83,9 +83,30 @@ function MediaPickerButton({
{/* Uploading indicator */}
{uploadState === UPLOAD_STATE.UPLOADING && (
<div className="flex flex-col items-center justify-center w-full h-full absolute inset-0 bg-black/60 z-10 animate-pulse">
<div className="w-4 h-4 rounded-full border border-primary/30 border-t-primary animate-spin mb-0.5" />
<span className="text-xs font-black text-primary">
<div className="flex flex-col items-center justify-center w-full h-full absolute inset-0 bg-black/80 z-20 backdrop-blur-[2px]">
<svg className="w-8 h-8 -rotate-90">
<circle
cx="16"
cy="16"
r="14"
stroke="currentColor"
strokeWidth="2"
fill="transparent"
className="text-white/10"
/>
<circle
cx="16"
cy="16"
r="14"
stroke="currentColor"
strokeWidth="2"
fill="transparent"
strokeDasharray={88}
strokeDashoffset={88 - (88 * progress) / 100}
className="text-primary transition-all duration-300"
/>
</svg>
<span className="absolute text-[9px] font-black text-primary leading-none">
{progress}%
</span>
</div>
@ -93,7 +114,7 @@ function MediaPickerButton({
{/* Ready state */}
{uploadState === UPLOAD_STATE.READY && (
<div className="flex flex-col items-center justify-center gap-1 w-full h-full absolute inset-0 bg-primary/10 rounded-full">
<div className="flex flex-col items-center justify-center gap-1 w-full h-full absolute inset-0 bg-primary/10 rounded-full group-hover:bg-primary/20 transition-all">
{previewUrl ? (
isVideo ? (
<video
@ -109,9 +130,16 @@ function MediaPickerButton({
/>
)
) : (
<>
{icon}
</>
<div className="flex flex-col items-center justify-center w-full px-1">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="text-primary mb-0.5">
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
<span className="text-[7px] font-black text-primary uppercase truncate w-full text-center">
{fileName?.split('.').pop() || "AUD"}
</span>
</div>
)}
</div>
)}
@ -291,6 +319,8 @@ export default function LipSyncStudio({
apiKey,
onGenerationComplete,
historyItems,
droppedFiles,
onFilesHandled,
}) {
const PERSIST_KEY = "hg_lipsync_studio_persistent";
@ -513,6 +543,26 @@ export default function LipSyncStudio({
[apiKey],
);
// Handle Dropped Files
useEffect(() => {
if (droppedFiles && droppedFiles.length > 0) {
const imageFiles = droppedFiles.filter(f => f.type.startsWith('image/'));
const videoFiles = droppedFiles.filter(f => f.type.startsWith('video/'));
const audioFiles = droppedFiles.filter(f => f.type.startsWith('audio/'));
if (audioFiles.length > 0) {
handleAudioPick(audioFiles[0]);
} else if (videoFiles.length > 0) {
switchToVideo();
handleVideoPick(videoFiles[0]);
} else if (imageFiles.length > 0) {
switchToImage();
handleImageUpload(imageFiles[0]);
}
onFilesHandled?.();
}
}, [droppedFiles, onFilesHandled, handleAudioPick, handleVideoPick, handleImageUpload]);
// Mode toggle
const switchToImage = () => {
if (inputMode === "image") return;

View file

@ -235,6 +235,8 @@ export default function VideoStudio({
apiKey,
onGenerationComplete,
historyItems,
droppedFiles,
onFilesHandled,
}) {
const PERSIST_KEY = "hg_video_studio_persistent";
@ -444,6 +446,19 @@ export default function VideoStudio({
}
}, [applyControlsForModel, defaultModel.id]);
// Adjust height on load
useEffect(() => {
const timer = setTimeout(() => {
if (textareaRef.current) {
const el = textareaRef.current;
el.style.height = "auto";
const maxH = window.innerWidth < 768 ? 150 : 250;
el.style.height = Math.min(el.scrollHeight, maxH) + "px";
}
}, 150);
return () => clearTimeout(timer);
}, []);
// Persistence: Save
useEffect(() => {
const timer = setTimeout(() => {
@ -487,6 +502,86 @@ export default function VideoStudio({
localHistory,
]);
// Derived UI values
const processDroppedImage = async (file) => {
if (file.size > 10 * 1024 * 1024) {
alert("Image exceeds 10MB limit.");
return;
}
setImageUploading(true);
setImageProgress(0);
try {
const url = await uploadFile(apiKey, file, (pct) => {
setImageProgress(pct);
});
setUploadedImageUrl(url);
setUploadedVideoUrl(null);
setUploadedVideoName(null);
setV2vMode(false);
if (!imageMode) {
const firstI2V = i2vModels[0];
setImageMode(true);
setSelectedModel(firstI2V.id);
setSelectedModelName(firstI2V.name);
applyControlsForModel(firstI2V.id, true, false);
}
setPromptDisabled(false);
} catch (err) {
alert(`Image upload failed: ${err.message}`);
} finally {
setImageUploading(false);
setImageProgress(0);
}
};
const processDroppedVideo = async (file) => {
if (file.size > 50 * 1024 * 1024) {
alert("Video exceeds 50MB limit.");
return;
}
setVideoUploading(true);
setVideoProgress(0);
try {
const url = await uploadFile(apiKey, file, (pct) => {
setVideoProgress(pct);
});
setUploadedVideoUrl(url);
setUploadedVideoName(file.name);
if (imageMode) {
setUploadedImageUrl(null);
setImageMode(false);
}
setV2vMode(true);
const firstV2V = v2vModels[0];
setSelectedModel(firstV2V.id);
setSelectedModelName(firstV2V.name);
applyControlsForModel(firstV2V.id, false, true);
setPrompt("");
setPromptDisabled(true);
} catch (err) {
alert(`Video upload failed: ${err.message}`);
} finally {
setVideoUploading(false);
setVideoProgress(0);
}
};
// Handle Dropped Files
useEffect(() => {
if (droppedFiles && droppedFiles.length > 0) {
const imageFiles = droppedFiles.filter(f => f.type.startsWith('image/'));
const videoFiles = droppedFiles.filter(f => f.type.startsWith('video/'));
if (videoFiles.length > 0) {
processDroppedVideo(videoFiles[0]);
} else if (imageFiles.length > 0) {
processDroppedImage(imageFiles[0]);
}
onFilesHandled?.();
}
}, [droppedFiles, onFilesHandled, processDroppedImage, processDroppedVideo]);
// Initialise controls for default model on mount
useEffect(() => {
if (hasRestored.current) return;
@ -910,7 +1005,7 @@ export default function VideoStudio({
return (
<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"
>
<video
src={entry.url}
@ -1052,9 +1147,30 @@ export default function VideoStudio({
className={`w-10 h-10 shrink-0 rounded-full border transition-all flex items-center justify-center relative overflow-hidden ${uploadedImageUrl ? "border-primary/60 bg-primary/5" : "bg-white/5 border-white/[0.03] hover:bg-white/10 hover:border-primary/40"} group`}
>
{imageUploading ? (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/60 z-10">
<div className="w-4 h-4 rounded-full border border-primary/30 border-t-primary animate-spin mb-0.5" />
<span className="text-[8px] font-black text-primary">
<div className="flex flex-col items-center justify-center w-full h-full absolute inset-0 bg-black/80 z-20 backdrop-blur-[2px]">
<svg className="w-8 h-8 -rotate-90">
<circle
cx="16"
cy="16"
r="14"
stroke="currentColor"
strokeWidth="2"
fill="transparent"
className="text-white/10"
/>
<circle
cx="16"
cy="16"
r="14"
stroke="currentColor"
strokeWidth="2"
fill="transparent"
strokeDasharray={88}
strokeDashoffset={88 - (88 * imageProgress) / 100}
className="text-primary transition-all duration-300"
/>
</svg>
<span className="absolute text-[9px] font-black text-primary leading-none">
{imageProgress}%
</span>
</div>
@ -1117,9 +1233,30 @@ export default function VideoStudio({
className={`w-10 h-10 shrink-0 rounded-full border transition-all flex items-center justify-center relative overflow-hidden ${uploadedVideoUrl ? "border-primary/60 bg-white/5" : "bg-white/[0.03] border-white/[0.03] hover:bg-white/10 hover:border-primary/40"} group`}
>
{videoUploading ? (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/60 z-10">
<div className="w-4 h-4 rounded-full border border-primary/30 border-t-primary animate-spin mb-0.5" />
<span className="text-[8px] font-black text-primary">
<div className="flex flex-col items-center justify-center w-full h-full absolute inset-0 bg-black/80 z-20 backdrop-blur-[2px]">
<svg className="w-8 h-8 -rotate-90">
<circle
cx="16"
cy="16"
r="14"
stroke="currentColor"
strokeWidth="2"
fill="transparent"
className="text-white/10"
/>
<circle
cx="16"
cy="16"
r="14"
stroke="currentColor"
strokeWidth="2"
fill="transparent"
strokeDasharray={88}
strokeDashoffset={88 - (88 * videoProgress) / 100}
className="text-primary transition-all duration-300"
/>
</svg>
<span className="absolute text-[9px] font-black text-primary leading-none">
{videoProgress}%
</span>
</div>

View file

@ -5,4 +5,5 @@ 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 { default as AgentStudio } from './components/AgentStudio';
export * from './muapi';

View file

@ -54,8 +54,14 @@ export async function generateImage(apiKey, params) {
if (params.aspect_ratio) payload.aspect_ratio = params.aspect_ratio;
if (params.resolution) payload.resolution = params.resolution;
if (params.quality) payload.quality = params.quality;
if (params.image_url) { payload.image_url = params.image_url; payload.strength = params.strength || 0.6; }
else payload.image_url = null;
if (params.image_url) {
payload.image_url = params.image_url;
payload.strength = params.strength || 0.6;
} else if (params.images_list) {
payload.images_list = params.images_list;
} else {
payload.image_url = null;
}
if (params.seed && params.seed !== -1) payload.seed = params.seed;
return submitAndPoll(endpoint, payload, apiKey, params.onRequestId, 60);
}
@ -227,6 +233,69 @@ export async function getPublishedWorkflows(apiKey) {
return await response.json();
};
// Agents — uses direct URL → https://api.muapi.ai/agents/...
export async function getTemplateAgents(apiKey) {
const response = await fetch(`${BASE_URL}/agents/templates/agents`, {
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey
}
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`Failed to fetch template agents: ${response.status} - ${errText.slice(0, 100)}`);
}
const data = await response.json();
return Array.isArray(data) ? data : (data.agents || data.items || []);
};
export async function getUserAgents(apiKey) {
const response = await fetch(`${BASE_URL}/agents/user/agents`, {
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey
}
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`Failed to fetch user agents: ${response.status} - ${errText.slice(0, 100)}`);
}
const data = await response.json();
return Array.isArray(data) ? data : (data.agents || data.items || []);
};
export async function getPublishedAgents(apiKey) {
// MuAPI: GET /agents/featured/agents
const response = await fetch(`${BASE_URL}/agents/featured/agents`, {
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey
}
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`Failed to fetch featured agents: ${response.status} - ${errText.slice(0, 100)}`);
}
const data = await response.json();
return Array.isArray(data) ? data : (data.agents || data.items || []);
};
// GET /agents/user/conversations — returns the user's chat history across all agents
export async function getUserConversations(apiKey) {
const response = await fetch(`${BASE_URL}/agents/user/conversations`, {
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey
}
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`Failed to fetch conversations: ${response.status} - ${errText.slice(0, 100)}`);
}
const data = await response.json();
return Array.isArray(data) ? data : [];
};
export async function createWorkflow(apiKey, payload) {
const response = await fetch(`${BASE_URL}/workflow/create`, {
method: 'POST',