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:
Anurag Pappula 2026-04-23 07:39:23 +05:30 committed by GitHub
commit 404edc7f1e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 822 additions and 118 deletions

6
.gitmodules vendored
View file

@ -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

View file

@ -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 ./

View 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
View 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 });
}

View 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
View 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
View 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 />;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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.'
/>
);
}

View 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.'
/>
);
}

View 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
View 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
View 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
View 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
View file

@ -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

View 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
}

View file

@ -0,0 +1 @@
/* Stub for ai-agent/dist/tailwind.css — upstream package is unavailable. */

View 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

View 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
}

View file

@ -0,0 +1 @@
/* Stub for workflow-builder/dist/tailwind.css — upstream package is unavailable. */

View 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.'
)
);