mirror of
https://github.com/Anil-matcha/Open-Generative-AI.git
synced 2026-05-07 01:17:18 +00:00
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:
commit
5cbcd88733
25 changed files with 1813 additions and 153 deletions
3
.gitmodules
vendored
3
.gitmodules
vendored
|
|
@ -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
|
||||
|
|
|
|||
83
app/agents/[agent_id]/AgentChatClient.js
Normal file
83
app/agents/[agent_id]/AgentChatClient.js
Normal 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>
|
||||
);
|
||||
}
|
||||
111
app/agents/[agent_id]/[conversation_id]/page.js
Normal file
111
app/agents/[agent_id]/[conversation_id]/page.js
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
89
app/agents/[agent_id]/page.js
Normal file
89
app/agents/[agent_id]/page.js
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
62
app/agents/create/AgentCreateClient.js
Normal file
62
app/agents/create/AgentCreateClient.js
Normal 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
29
app/agents/create/page.js
Normal 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} />
|
||||
);
|
||||
}
|
||||
62
app/agents/edit/[id]/AgentEditClient.js
Normal file
62
app/agents/edit/[id]/AgentEditClient.js
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
30
app/agents/edit/[id]/page.js
Normal file
30
app/agents/edit/[id]/page.js
Normal 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
16
app/agents/layout.js
Normal 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>
|
||||
);
|
||||
}
|
||||
110
app/api/agents/[[...path]]/route.js
Normal file
110
app/api/agents/[[...path]]/route.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
65
app/api/api/v1/[[...path]]/route.js
Normal file
65
app/api/api/v1/[[...path]]/route.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
"@/*": ["./*"],
|
||||
"ai-agent": ["./packages/ai-agent/src/index.js"],
|
||||
"workflow-builder": ["./packages/workflow-ui/src/index.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
151
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
1
packages/ai-agent
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit bfc7087bf4ae0ed9a9c98daed9bf0c3c8d7333df
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
295
packages/studio/src/components/AgentStudio.jsx
Normal file
295
packages/studio/src/components/AgentStudio.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue