mirror of
https://github.com/Anil-matcha/Open-Generative-AI.git
synced 2026-05-07 01:17:18 +00:00
Merge pull request #4 from Ahoum-Dev/feat/trainers-studios-crud
feat(batch): trainers + studios CRUD with /batch shell UI [slice 2/5]
This commit is contained in:
commit
404edc7f1e
26 changed files with 822 additions and 118 deletions
6
.gitmodules
vendored
6
.gitmodules
vendored
|
|
@ -1,6 +0,0 @@
|
|||
[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
|
||||
|
|
@ -13,6 +13,7 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
|||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/packages ./packages
|
||||
COPY . .
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
|
|
@ -29,6 +30,8 @@ COPY --from=builder /app/public ./public
|
|||
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/packages ./packages
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/lib ./lib
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/next.config.mjs ./
|
||||
|
||||
|
|
|
|||
29
app/api/studios/[id]/route.js
Normal file
29
app/api/studios/[id]/route.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/prisma';
|
||||
|
||||
export async function DELETE(_request, { params }) {
|
||||
const { id } = await params;
|
||||
|
||||
const inUse = await prisma.job.count({
|
||||
where: {
|
||||
studioId: id,
|
||||
status: { in: ['queued', 'submitting', 'polling'] },
|
||||
},
|
||||
});
|
||||
if (inUse > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `Studio is referenced by ${inUse} active job(s). Pause or cancel the batch first.` },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.studio.delete({ where: { id } });
|
||||
} catch (err) {
|
||||
if (err.code === 'P2025') {
|
||||
return NextResponse.json({ error: 'Studio not found' }, { status: 404 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
70
app/api/studios/route.js
Normal file
70
app/api/studios/route.js
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { getApiKey } from '@/lib/batchAuth';
|
||||
import { uploadFileToMuapi } from '@/lib/muapiUpload';
|
||||
import { saveLocalBackup } from '@/lib/localUploadStore';
|
||||
|
||||
export async function GET() {
|
||||
const studios = await prisma.studio.findMany({
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
return NextResponse.json({ studios });
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
const apiKey = getApiKey(request);
|
||||
if (!apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'MuAPI API key is required. Set it in /studio or pass x-api-key.' },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
let form;
|
||||
try {
|
||||
form = await request.formData();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid multipart body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const name = (form.get('name') || '').toString().trim();
|
||||
const csvLabel = (form.get('csvLabel') || '').toString().trim() || null;
|
||||
const file = form.get('image');
|
||||
|
||||
if (!name) return NextResponse.json({ error: 'name is required' }, { status: 400 });
|
||||
if (!file || typeof file === 'string') {
|
||||
return NextResponse.json({ error: 'image file is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (csvLabel) {
|
||||
const existing = await prisma.studio.findUnique({ where: { csvLabel } });
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: `csvLabel "${csvLabel}" is already used by studio "${existing.name}"` },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let muapiUrl;
|
||||
try {
|
||||
muapiUrl = await uploadFileToMuapi(apiKey, file);
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: err.message }, { status: 502 });
|
||||
}
|
||||
|
||||
const studio = await prisma.studio.create({
|
||||
data: { name, csvLabel, imageUrl: muapiUrl },
|
||||
});
|
||||
|
||||
const localPath = await saveLocalBackup('studios', studio.id, file);
|
||||
if (localPath) {
|
||||
await prisma.studio.update({
|
||||
where: { id: studio.id },
|
||||
data: { localPath },
|
||||
});
|
||||
studio.localPath = localPath;
|
||||
}
|
||||
|
||||
return NextResponse.json({ studio }, { status: 201 });
|
||||
}
|
||||
29
app/api/trainers/[id]/route.js
Normal file
29
app/api/trainers/[id]/route.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/prisma';
|
||||
|
||||
export async function DELETE(_request, { params }) {
|
||||
const { id } = await params;
|
||||
|
||||
const inUse = await prisma.job.count({
|
||||
where: {
|
||||
trainerId: id,
|
||||
status: { in: ['queued', 'submitting', 'polling'] },
|
||||
},
|
||||
});
|
||||
if (inUse > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `Trainer is referenced by ${inUse} active job(s). Pause or cancel the batch first.` },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.trainer.delete({ where: { id } });
|
||||
} catch (err) {
|
||||
if (err.code === 'P2025') {
|
||||
return NextResponse.json({ error: 'Trainer not found' }, { status: 404 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
70
app/api/trainers/route.js
Normal file
70
app/api/trainers/route.js
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { getApiKey } from '@/lib/batchAuth';
|
||||
import { uploadFileToMuapi } from '@/lib/muapiUpload';
|
||||
import { saveLocalBackup } from '@/lib/localUploadStore';
|
||||
|
||||
export async function GET() {
|
||||
const trainers = await prisma.trainer.findMany({
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
return NextResponse.json({ trainers });
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
const apiKey = getApiKey(request);
|
||||
if (!apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'MuAPI API key is required. Set it in /studio or pass x-api-key.' },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
let form;
|
||||
try {
|
||||
form = await request.formData();
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Invalid multipart body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const name = (form.get('name') || '').toString().trim();
|
||||
const csvLabel = (form.get('csvLabel') || '').toString().trim() || null;
|
||||
const file = form.get('image');
|
||||
|
||||
if (!name) return NextResponse.json({ error: 'name is required' }, { status: 400 });
|
||||
if (!file || typeof file === 'string') {
|
||||
return NextResponse.json({ error: 'image file is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (csvLabel) {
|
||||
const existing = await prisma.trainer.findUnique({ where: { csvLabel } });
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: `csvLabel "${csvLabel}" is already used by trainer "${existing.name}"` },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let muapiUrl;
|
||||
try {
|
||||
muapiUrl = await uploadFileToMuapi(apiKey, file);
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: err.message }, { status: 502 });
|
||||
}
|
||||
|
||||
const trainer = await prisma.trainer.create({
|
||||
data: { name, csvLabel, imageUrl: muapiUrl },
|
||||
});
|
||||
|
||||
const localPath = await saveLocalBackup('trainers', trainer.id, file);
|
||||
if (localPath) {
|
||||
await prisma.trainer.update({
|
||||
where: { id: trainer.id },
|
||||
data: { localPath },
|
||||
});
|
||||
trainer.localPath = localPath;
|
||||
}
|
||||
|
||||
return NextResponse.json({ trainer }, { status: 201 });
|
||||
}
|
||||
9
app/batch/page.js
Normal file
9
app/batch/page.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import BatchShell from '@/components/batch/BatchShell';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Batch — Open Generative AI',
|
||||
};
|
||||
|
||||
export default function BatchPage() {
|
||||
return <BatchShell />;
|
||||
}
|
||||
131
components/batch/AddAssetModal.jsx
Normal file
131
components/batch/AddAssetModal.jsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function AddAssetModal({ apiKey, endpoint, label, onClose, onCreated }) {
|
||||
const [name, setName] = useState('');
|
||||
const [csvLabel, setCsvLabel] = useState('');
|
||||
const [file, setFile] = useState(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const previewUrl = file ? URL.createObjectURL(file) : null;
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || !file) {
|
||||
setError('Name and image are required.');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('name', name.trim());
|
||||
if (csvLabel.trim()) form.append('csvLabel', csvLabel.trim());
|
||||
form.append('image', file);
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'x-api-key': apiKey },
|
||||
body: form,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || `Upload failed: ${res.status}`);
|
||||
}
|
||||
await onCreated();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 animate-fade-in-up">
|
||||
<div className="bg-[#0a0a0a] border border-white/10 rounded-xl p-8 w-full max-w-md shadow-2xl">
|
||||
<h2 className="text-white font-bold text-lg mb-1">Add {label.toLowerCase()}</h2>
|
||||
<p className="text-white/40 text-[13px] mb-6">
|
||||
Uploads to MuAPI once and saves a local backup. Reused across every batch.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-[11px] font-bold text-white/40 uppercase tracking-wide mb-1.5">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={label === 'Trainer' ? 'e.g. Raj' : 'e.g. Ahoum studio'}
|
||||
className="w-full bg-white/5 border border-white/[0.03] rounded-md px-4 py-2 text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-[#d9ff00]/30"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-bold text-white/40 uppercase tracking-wide mb-1.5">
|
||||
CSV label (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={csvLabel}
|
||||
onChange={(e) => setCsvLabel(e.target.value)}
|
||||
placeholder={label === 'Trainer' ? 'e.g. Trainer 1' : 'e.g. Studio 1'}
|
||||
className="w-full bg-white/5 border border-white/[0.03] rounded-md px-4 py-2 text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-[#d9ff00]/30"
|
||||
/>
|
||||
<p className="text-[11px] text-white/30 mt-1">
|
||||
Used to auto-match CSV rows. Set once; CSV column value "{label} 1" maps to this {label.toLowerCase()}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-bold text-white/40 uppercase tracking-wide mb-1.5">
|
||||
Image
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => setFile(e.target.files?.[0] || null)}
|
||||
className="w-full text-[13px] text-white/70 file:mr-3 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:bg-white/10 file:text-white/80 file:text-[12px] file:cursor-pointer hover:file:bg-white/20"
|
||||
required
|
||||
/>
|
||||
{previewUrl && (
|
||||
<div className="mt-3 rounded-md overflow-hidden bg-black/40 aspect-square max-w-[160px]">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={previewUrl} alt="preview" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/30 text-red-300 text-[13px] rounded-md px-3 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
className="flex-1 h-10 rounded-md bg-white/5 text-white/80 hover:bg-white/10 text-xs font-semibold border border-white/5 transition-all disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="flex-1 h-10 rounded-md bg-[#d9ff00] text-black hover:bg-[#e5ff33] text-xs font-semibold transition-all disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Uploading…' : 'Upload'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
133
components/batch/AssetLibrary.jsx
Normal file
133
components/batch/AssetLibrary.jsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import AddAssetModal from './AddAssetModal';
|
||||
|
||||
export default function AssetLibrary({ apiKey, kind, endpoint, emptyHint }) {
|
||||
const [items, setItems] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
|
||||
const collectionKey = kind === 'trainer' ? 'trainers' : 'studios';
|
||||
const label = kind === 'trainer' ? 'Trainer' : 'Studio';
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(endpoint, { headers: { 'x-api-key': apiKey } });
|
||||
if (!res.ok) throw new Error(`GET ${endpoint} failed: ${res.status}`);
|
||||
const data = await res.json();
|
||||
setItems(data[collectionKey] || []);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [endpoint, apiKey, collectionKey]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!window.confirm(`Delete this ${label.toLowerCase()}? This cannot be undone.`)) return;
|
||||
try {
|
||||
const res = await fetch(`${endpoint}/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'x-api-key': apiKey },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || `Delete failed: ${res.status}`);
|
||||
}
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
window.alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">{label} library</h1>
|
||||
<p className="text-white/40 text-[13px] mt-1">
|
||||
Upload once, reuse across every batch. CSV auto-mapping looks up by label.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAdd(true)}
|
||||
className="bg-[#d9ff00] text-black font-medium text-sm rounded-md px-4 py-2 hover:bg-[#e5ff33] transition-all"
|
||||
>
|
||||
+ Add {label.toLowerCase()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/30 text-red-300 text-[13px] rounded-md px-4 py-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-white/40 text-sm py-12 text-center">Loading…</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="bg-[#0a0a0a] border border-white/[0.03] rounded-md px-6 py-12 text-center">
|
||||
<p className="text-white/50 text-sm">No {label.toLowerCase()}s yet.</p>
|
||||
{emptyHint && <p className="text-white/30 text-[12px] mt-2">{emptyHint}</p>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{items.map((item) => (
|
||||
<AssetCard key={item.id} item={item} label={label} onDelete={() => handleDelete(item.id)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdd && (
|
||||
<AddAssetModal
|
||||
apiKey={apiKey}
|
||||
endpoint={endpoint}
|
||||
label={label}
|
||||
onClose={() => setShowAdd(false)}
|
||||
onCreated={async () => {
|
||||
setShowAdd(false);
|
||||
await refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetCard({ item, label, onDelete }) {
|
||||
return (
|
||||
<div className="bg-[#0a0a0a] border border-white/[0.03] rounded-md overflow-hidden group">
|
||||
<div className="aspect-square bg-black/40 overflow-hidden">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={item.imageUrl}
|
||||
alt={item.name}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="text-sm font-semibold text-white truncate">{item.name}</div>
|
||||
{item.csvLabel && (
|
||||
<div className="mt-1 inline-block bg-white/5 border border-white/5 rounded-full px-2 py-0.5 text-[10px] text-white/60 uppercase tracking-wide">
|
||||
{item.csvLabel}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="mt-3 w-full text-[11px] text-white/40 hover:text-red-400 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
components/batch/BatchShell.jsx
Normal file
110
components/batch/BatchShell.jsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import ApiKeyModal from '@/components/ApiKeyModal';
|
||||
import TrainersTab from './TrainersTab';
|
||||
import StudiosTab from './StudiosTab';
|
||||
import BatchesTab from './BatchesTab';
|
||||
|
||||
const STORAGE_KEY = 'muapi_key';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'batches', label: 'Batches' },
|
||||
{ id: 'trainers', label: 'Trainers' },
|
||||
{ id: 'studios', label: 'Studios' },
|
||||
];
|
||||
|
||||
export default function BatchShell() {
|
||||
const [apiKey, setApiKey] = useState(null);
|
||||
const [hasMounted, setHasMounted] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('batches');
|
||||
|
||||
useEffect(() => {
|
||||
setHasMounted(true);
|
||||
const stored = typeof window !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null;
|
||||
if (stored) {
|
||||
setApiKey(stored);
|
||||
document.cookie = `muapi_key=${stored}; path=/; max-age=31536000; SameSite=Lax`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleKeySave = (key) => {
|
||||
localStorage.setItem(STORAGE_KEY, key);
|
||||
document.cookie = `muapi_key=${key}; path=/; max-age=31536000; SameSite=Lax`;
|
||||
setApiKey(key);
|
||||
};
|
||||
|
||||
const handleKeyChange = () => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
document.cookie = 'muapi_key=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
|
||||
setApiKey(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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
return <ApiKeyModal onSave={handleKeySave} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#030303] text-white flex flex-col">
|
||||
<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">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-white rounded-lg flex items-center justify-center">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="black" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-bold tracking-tight">Batch</span>
|
||||
<span className="text-white/30 text-sm">/ Open Generative AI</span>
|
||||
</div>
|
||||
|
||||
<nav className="flex items-center gap-6">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`relative py-4 text-[13px] font-medium transition-all whitespace-nowrap px-1 ${
|
||||
activeTab === tab.id ? 'text-[#d9ff00]' : 'text-white/50 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
{activeTab === tab.id && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-[2px] bg-[#d9ff00] rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="/studio"
|
||||
className="text-[12px] text-white/50 hover:text-white/80 transition-colors"
|
||||
>
|
||||
← Studio
|
||||
</a>
|
||||
<button
|
||||
onClick={handleKeyChange}
|
||||
className="text-[11px] text-white/40 hover:text-red-400 transition-colors"
|
||||
>
|
||||
Change key
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-y-auto px-6 py-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{activeTab === 'batches' && <BatchesTab />}
|
||||
{activeTab === 'trainers' && <TrainersTab apiKey={apiKey} />}
|
||||
{activeTab === 'studios' && <StudiosTab apiKey={apiKey} />}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
components/batch/BatchesTab.jsx
Normal file
29
components/batch/BatchesTab.jsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
'use client';
|
||||
|
||||
export default function BatchesTab() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Batches</h1>
|
||||
<p className="text-white/40 text-[13px] mt-1">
|
||||
Upload a CSV, map trainers, run MuAPI video generations end-to-end.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
disabled
|
||||
className="bg-white/5 text-white/40 font-medium text-sm rounded-md px-4 py-2 border border-white/5 cursor-not-allowed"
|
||||
>
|
||||
+ New batch
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#0a0a0a] border border-white/[0.03] rounded-md px-6 py-12 text-center">
|
||||
<p className="text-white/50 text-sm">Batch creation lands in the next slice.</p>
|
||||
<p className="text-white/30 text-[12px] mt-2">
|
||||
For now, populate the Trainers and Studios libraries so the CSV auto-mapping has something to match against.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
components/batch/StudiosTab.jsx
Normal file
14
components/batch/StudiosTab.jsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
'use client';
|
||||
|
||||
import AssetLibrary from './AssetLibrary';
|
||||
|
||||
export default function StudiosTab({ apiKey }) {
|
||||
return (
|
||||
<AssetLibrary
|
||||
apiKey={apiKey}
|
||||
kind="studio"
|
||||
endpoint="/api/studios"
|
||||
emptyHint='Upload one image per studio and label it "Studio 1", "Studio 4", etc.'
|
||||
/>
|
||||
);
|
||||
}
|
||||
14
components/batch/TrainersTab.jsx
Normal file
14
components/batch/TrainersTab.jsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
'use client';
|
||||
|
||||
import AssetLibrary from './AssetLibrary';
|
||||
|
||||
export default function TrainersTab({ apiKey }) {
|
||||
return (
|
||||
<AssetLibrary
|
||||
apiKey={apiKey}
|
||||
kind="trainer"
|
||||
endpoint="/api/trainers"
|
||||
emptyHint='Upload one image per trainer and label it "Trainer 1"…"Trainer 6" so CSVs auto-map.'
|
||||
/>
|
||||
);
|
||||
}
|
||||
29
docker-compose.override.yml
Normal file
29
docker-compose.override.yml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Dev-mode override. Compose auto-merges this with docker-compose.yml.
|
||||
# Provides hot reload (next dev) and live source mount so code changes
|
||||
# on the host appear in the container without rebuilding the image.
|
||||
#
|
||||
# Delete or rename this file to run a pure production stack via
|
||||
# `docker compose -f docker-compose.yml up`.
|
||||
|
||||
services:
|
||||
web:
|
||||
user: root
|
||||
command: ["sh", "-c", "npx prisma generate && npx prisma migrate deploy && npx next dev -H 0.0.0.0 -p 3000"]
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
volumes:
|
||||
- ./:/app
|
||||
- web_node_modules:/app/node_modules
|
||||
- web_next_cache:/app/.next
|
||||
|
||||
worker:
|
||||
user: root
|
||||
command: ["sh", "-c", "echo 'worker placeholder — replaced in slice 4'; tail -f /dev/null"]
|
||||
volumes:
|
||||
- ./:/app
|
||||
- worker_node_modules:/app/node_modules
|
||||
|
||||
volumes:
|
||||
web_node_modules:
|
||||
web_next_cache:
|
||||
worker_node_modules:
|
||||
7
lib/batchAuth.js
Normal file
7
lib/batchAuth.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export function getApiKey(request) {
|
||||
const headerKey = request.headers.get('x-api-key');
|
||||
if (headerKey) return headerKey;
|
||||
const cookieKey = request.cookies.get('muapi_key')?.value;
|
||||
if (cookieKey) return cookieKey;
|
||||
return process.env.MUAPI_API_KEY || null;
|
||||
}
|
||||
30
lib/localUploadStore.js
Normal file
30
lib/localUploadStore.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const ROOT = process.env.UPLOAD_DIR || '/data/uploads';
|
||||
|
||||
export async function saveLocalBackup(kind, id, file) {
|
||||
try {
|
||||
const dir = path.join(ROOT, kind);
|
||||
await mkdir(dir, { recursive: true });
|
||||
const ext = inferExtension(file);
|
||||
const target = path.join(dir, `${id}${ext}`);
|
||||
const buf = Buffer.from(await file.arrayBuffer());
|
||||
await writeFile(target, buf);
|
||||
return target;
|
||||
} catch (err) {
|
||||
console.warn(`[localUploadStore] backup write skipped (${kind}/${id}):`, err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function inferExtension(file) {
|
||||
const name = file?.name || '';
|
||||
const dot = name.lastIndexOf('.');
|
||||
if (dot >= 0) return name.slice(dot).toLowerCase();
|
||||
const type = file?.type || '';
|
||||
if (type.includes('png')) return '.png';
|
||||
if (type.includes('webp')) return '.webp';
|
||||
if (type.includes('jpeg') || type.includes('jpg')) return '.jpg';
|
||||
return '.bin';
|
||||
}
|
||||
31
lib/muapiUpload.js
Normal file
31
lib/muapiUpload.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
const MUAPI_BASE = 'https://api.muapi.ai';
|
||||
|
||||
export async function uploadFileToMuapi(apiKey, file) {
|
||||
if (!apiKey) {
|
||||
throw new Error('Missing MuAPI API key');
|
||||
}
|
||||
if (!file) {
|
||||
throw new Error('Missing file');
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
|
||||
const res = await fetch(`${MUAPI_BASE}/api/v1/upload_file`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-api-key': apiKey },
|
||||
body: form,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`MuAPI upload failed: ${res.status} ${text.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const url = data.url || data.file_url || data?.data?.url;
|
||||
if (!url) {
|
||||
throw new Error(`MuAPI upload returned no URL: ${JSON.stringify(data).slice(0, 200)}`);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
112
package-lock.json
generated
112
package-lock.json
generated
|
|
@ -12762,16 +12762,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
|
|
@ -16467,57 +16457,7 @@
|
|||
}
|
||||
},
|
||||
"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/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"
|
||||
}
|
||||
"version": "0.0.0-stub"
|
||||
},
|
||||
"packages/studio": {
|
||||
"version": "1.0.0",
|
||||
|
|
@ -16572,55 +16512,7 @@
|
|||
},
|
||||
"packages/workflow-ui": {
|
||||
"name": "workflow-builder",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.7.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-icons": "^5.0.1",
|
||||
"react-markdown": "^9.0.0",
|
||||
"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/workflow-ui/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"
|
||||
}
|
||||
"version": "0.0.0-stub"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
Subproject commit bfc7087bf4ae0ed9a9c98daed9bf0c3c8d7333df
|
||||
12
packages/ai-agent/package.json
Normal file
12
packages/ai-agent/package.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "ai-agent",
|
||||
"version": "0.0.0-stub",
|
||||
"description": "Local stub. Upstream submodule (jaiprasad04/ai-agent) is unavailable. Provides no-op exports so the build succeeds; the /agents routes will render a 'feature unavailable' notice at runtime.",
|
||||
"main": "src/index.js",
|
||||
"module": "src/index.js",
|
||||
"exports": {
|
||||
".": "./src/index.js",
|
||||
"./dist/tailwind.css": "./src/empty.css"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
1
packages/ai-agent/src/empty.css
Normal file
1
packages/ai-agent/src/empty.css
Normal file
|
|
@ -0,0 +1 @@
|
|||
/* Stub for ai-agent/dist/tailwind.css — upstream package is unavailable. */
|
||||
30
packages/ai-agent/src/index.js
Normal file
30
packages/ai-agent/src/index.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import React from 'react';
|
||||
|
||||
const Unavailable = ({ feature }) =>
|
||||
React.createElement(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
padding: '2rem',
|
||||
color: '#fff',
|
||||
background: '#0a0a0a',
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
fontFamily: 'sans-serif',
|
||||
textAlign: 'center',
|
||||
},
|
||||
},
|
||||
React.createElement('h1', { style: { fontSize: 24, marginBottom: 8 } }, 'Agents unavailable'),
|
||||
React.createElement(
|
||||
'p',
|
||||
{ style: { color: '#ffffff80', maxWidth: 480 } },
|
||||
`The ai-agent package is not bundled with this Ahoum-Dev fork. The /agents/${feature} route is a no-op. See packages/ai-agent/package.json for context.`
|
||||
)
|
||||
);
|
||||
|
||||
export const CreateAgentPage = () => Unavailable({ feature: 'create' });
|
||||
export const EditAgentPage = () => Unavailable({ feature: 'edit' });
|
||||
export const AiAgent = () => Unavailable({ feature: 'chat' });
|
||||
|
|
@ -1 +0,0 @@
|
|||
Subproject commit 58cc9b22f311f7e4c6c80a7f0eb289f322b7c199
|
||||
12
packages/workflow-ui/package.json
Normal file
12
packages/workflow-ui/package.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "workflow-builder",
|
||||
"version": "0.0.0-stub",
|
||||
"description": "Local stub. Upstream submodule (Anil-matcha/workflow-ui) is unavailable. Provides no-op exports so the build succeeds; the Workflows tab will render a 'feature unavailable' notice at runtime.",
|
||||
"main": "src/index.js",
|
||||
"module": "src/index.js",
|
||||
"exports": {
|
||||
".": "./src/index.js",
|
||||
"./dist/tailwind.css": "./src/empty.css"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
1
packages/workflow-ui/src/empty.css
Normal file
1
packages/workflow-ui/src/empty.css
Normal file
|
|
@ -0,0 +1 @@
|
|||
/* Stub for workflow-builder/dist/tailwind.css — upstream package is unavailable. */
|
||||
26
packages/workflow-ui/src/index.js
Normal file
26
packages/workflow-ui/src/index.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import React from 'react';
|
||||
|
||||
export const WorkflowBuilder = () =>
|
||||
React.createElement(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
padding: '2rem',
|
||||
color: '#fff',
|
||||
background: '#0a0a0a',
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
fontFamily: 'sans-serif',
|
||||
textAlign: 'center',
|
||||
},
|
||||
},
|
||||
React.createElement('h1', { style: { fontSize: 24, marginBottom: 8 } }, 'Workflows unavailable'),
|
||||
React.createElement(
|
||||
'p',
|
||||
{ style: { color: '#ffffff80', maxWidth: 480 } },
|
||||
'The workflow-builder package is not bundled with this Ahoum-Dev fork. The Workflows tab is a no-op. See packages/workflow-ui/package.json for context.'
|
||||
)
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue