feat(build): AI architecture recommendation with review + confirm flow
- New /api/projects/[projectId]/architecture (GET/POST/PATCH) — reads PRD + phases, calls AI to generate structured monorepo architecture JSON, persists to fs_projects.data.architecture; PATCH sets confirmed flag - Rebuilt Build tab to show the AI-generated recommendation: expandable app cards (tech stack, key screens), shared packages, infrastructure, integrations, and risk notes; confirm button + "adjustable later" note Made-with: Cursor
This commit is contained in:
@@ -4,39 +4,190 @@ import { useEffect, useState } from "react";
|
|||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
interface Project {
|
interface App {
|
||||||
id: string;
|
name: string;
|
||||||
status?: string;
|
type: string;
|
||||||
prd?: string;
|
description: string;
|
||||||
giteaRepoUrl?: string;
|
tech: string[];
|
||||||
|
screens: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const BUILD_FEATURES = [
|
interface Package {
|
||||||
"Authentication system",
|
name: string;
|
||||||
"Database schema",
|
description: string;
|
||||||
"API endpoints",
|
}
|
||||||
"Core UI",
|
|
||||||
"Business logic",
|
interface Infra {
|
||||||
"Tests",
|
name: string;
|
||||||
];
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Integration {
|
||||||
|
name: string;
|
||||||
|
required: boolean;
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Architecture {
|
||||||
|
productName: string;
|
||||||
|
productType: string;
|
||||||
|
summary: string;
|
||||||
|
apps: App[];
|
||||||
|
packages: Package[];
|
||||||
|
infrastructure: Infra[];
|
||||||
|
integrations: Integration[];
|
||||||
|
designSurfaces: string[];
|
||||||
|
riskNotes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
fontSize: "0.6rem", fontWeight: 700, color: "#a09a90",
|
||||||
|
letterSpacing: "0.12em", textTransform: "uppercase",
|
||||||
|
marginBottom: 10, marginTop: 28,
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppCard({ app }: { app: App }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
web: "🌐", api: "⚡", simulator: "🎮", admin: "🔧",
|
||||||
|
mobile: "📱", worker: "⚙️", engine: "🎯",
|
||||||
|
};
|
||||||
|
const icon = Object.entries(icons).find(([k]) => app.name.toLowerCase().includes(k))?.[1] ?? "📦";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
|
||||||
|
marginBottom: 8, overflow: "hidden",
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(o => !o)}
|
||||||
|
style={{
|
||||||
|
width: "100%", textAlign: "left", background: "none", border: "none",
|
||||||
|
cursor: "pointer", padding: "14px 18px",
|
||||||
|
display: "flex", alignItems: "center", gap: 12,
|
||||||
|
fontFamily: "Outfit, sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: "1.2rem" }}>{icon}</span>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<span style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a" }}>
|
||||||
|
apps/{app.name}
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
|
||||||
|
color: "#6b6560", background: "#f0ece4",
|
||||||
|
padding: "2px 7px", borderRadius: 4,
|
||||||
|
}}>
|
||||||
|
{app.type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "0.78rem", color: "#8a8478", marginTop: 2 }}>
|
||||||
|
{app.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: "0.7rem", color: "#c5c0b8" }}>{open ? "▲" : "▼"}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div style={{ padding: "0 18px 16px", borderTop: "1px solid #f0ece4" }}>
|
||||||
|
{app.tech.length > 0 && (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<div style={{ fontSize: "0.62rem", color: "#b5b0a6", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 6 }}>Stack</div>
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 5 }}>
|
||||||
|
{app.tech.map((t, i) => (
|
||||||
|
<span key={i} style={{
|
||||||
|
fontSize: "0.72rem", fontFamily: "IBM Plex Mono, monospace",
|
||||||
|
color: "#4a4640", background: "#f6f4f0",
|
||||||
|
border: "1px solid #e8e4dc", padding: "2px 8px", borderRadius: 4,
|
||||||
|
}}>{t}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{app.screens.length > 0 && (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<div style={{ fontSize: "0.62rem", color: "#b5b0a6", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 6 }}>Key screens</div>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
||||||
|
{app.screens.map((s, i) => (
|
||||||
|
<div key={i} style={{ fontSize: "0.78rem", color: "#4a4640", display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
|
<span style={{ color: "#c5c0b8" }}>→</span> {s}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function BuildPage() {
|
export default function BuildPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const projectId = params.projectId as string;
|
const projectId = params.projectId as string;
|
||||||
const workspace = params.workspace as string;
|
const workspace = params.workspace as string;
|
||||||
const [project, setProject] = useState<Project | null>(null);
|
|
||||||
|
const [prd, setPrd] = useState<string | null>(null);
|
||||||
|
const [architecture, setArchitecture] = useState<Architecture | null>(null);
|
||||||
|
const [architectureConfirmed, setArchitectureConfirmed] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [generating, setGenerating] = useState(false);
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`/api/projects/${projectId}`)
|
fetch(`/api/projects/${projectId}/architecture`)
|
||||||
.then((r) => r.json())
|
.then(r => r.json())
|
||||||
.then((d) => {
|
.then(d => {
|
||||||
setProject(d.project);
|
setPrd(d.prd);
|
||||||
|
setArchitecture(d.architecture ?? null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
})
|
})
|
||||||
.catch(() => setLoading(false));
|
.catch(() => setLoading(false));
|
||||||
|
|
||||||
|
// Also check confirmed flag
|
||||||
|
fetch(`/api/projects/${projectId}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => setArchitectureConfirmed(d.project?.architectureConfirmed === true))
|
||||||
|
.catch(() => {});
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
|
const handleGenerate = async (force = false) => {
|
||||||
|
setGenerating(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/projects/${projectId}/architecture`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ forceRegenerate: force }),
|
||||||
|
});
|
||||||
|
const d = await res.json();
|
||||||
|
if (!res.ok) throw new Error(d.error || "Generation failed");
|
||||||
|
setArchitecture(d.architecture);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Something went wrong");
|
||||||
|
} finally {
|
||||||
|
setGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
setConfirming(true);
|
||||||
|
try {
|
||||||
|
await fetch(`/api/projects/${projectId}/architecture`, { method: "PATCH" });
|
||||||
|
setArchitectureConfirmed(true);
|
||||||
|
} catch { /* swallow */ } finally {
|
||||||
|
setConfirming(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "Outfit, sans-serif", color: "#a09a90" }}>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "Outfit, sans-serif", color: "#a09a90" }}>
|
||||||
@@ -45,48 +196,24 @@ export default function BuildPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasRepo = Boolean(project?.giteaRepoUrl);
|
// No PRD yet
|
||||||
const hasPRD = Boolean(project?.prd);
|
if (!prd) {
|
||||||
|
|
||||||
if (!hasPRD) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", padding: 40, fontFamily: "Outfit, sans-serif" }}>
|
||||||
className="vibn-enter"
|
|
||||||
style={{
|
|
||||||
flex: 1, display: "flex", alignItems: "center", justifyContent: "center",
|
|
||||||
padding: 40, fontFamily: "Outfit, sans-serif",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ textAlign: "center", maxWidth: 360 }}>
|
<div style={{ textAlign: "center", maxWidth: 360 }}>
|
||||||
<div style={{
|
<div style={{ fontSize: "2.5rem", marginBottom: 16 }}>🔒</div>
|
||||||
width: 56, height: 56, borderRadius: 14,
|
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.3rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 8 }}>
|
||||||
background: "#fff", border: "1px solid #e8e4dc",
|
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
|
||||||
fontSize: "1.4rem", margin: "0 auto 18px",
|
|
||||||
boxShadow: "0 2px 8px #1a1a1a08",
|
|
||||||
}}>
|
|
||||||
🔒
|
|
||||||
</div>
|
|
||||||
<h3 style={{
|
|
||||||
fontFamily: "Newsreader, serif", fontSize: "1.3rem",
|
|
||||||
fontWeight: 400, color: "#1a1a1a", marginBottom: 8,
|
|
||||||
}}>
|
|
||||||
Complete your PRD first
|
Complete your PRD first
|
||||||
</h3>
|
</h3>
|
||||||
<p style={{ fontSize: "0.82rem", color: "#a09a90", lineHeight: 1.6, marginBottom: 20 }}>
|
<p style={{ fontSize: "0.82rem", color: "#a09a90", lineHeight: 1.6, marginBottom: 20 }}>
|
||||||
Finish your discovery with Atlas, then the builder unlocks automatically.
|
Finish your discovery conversation with Atlas, then the architect will unlock automatically.
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link href={`/${workspace}/project/${projectId}/overview`} style={{
|
||||||
href={`/${workspace}/project/${projectId}/overview`}
|
display: "inline-block", padding: "9px 20px", borderRadius: 7,
|
||||||
style={{
|
|
||||||
display: "inline-block",
|
|
||||||
padding: "9px 20px", borderRadius: 7,
|
|
||||||
background: "#1a1a1a", color: "#fff",
|
background: "#1a1a1a", color: "#fff",
|
||||||
fontSize: "0.78rem", fontWeight: 600,
|
fontSize: "0.78rem", fontWeight: 600, fontFamily: "Outfit, sans-serif",
|
||||||
fontFamily: "Outfit, sans-serif",
|
|
||||||
textDecoration: "none",
|
textDecoration: "none",
|
||||||
}}
|
}}>
|
||||||
>
|
|
||||||
Continue with Atlas →
|
Continue with Atlas →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,83 +221,255 @@ export default function BuildPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasRepo) {
|
// PRD exists but no architecture yet — prompt to generate
|
||||||
|
if (!architecture) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", padding: 40, fontFamily: "Outfit, sans-serif" }}>
|
||||||
className="vibn-enter"
|
<div style={{ textAlign: "center", maxWidth: 440 }}>
|
||||||
|
<div style={{ fontSize: "2.5rem", marginBottom: 16 }}>🏗️</div>
|
||||||
|
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.4rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 10 }}>
|
||||||
|
Ready to architect {architecture ? (architecture as Architecture).productName : "your product"}
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontSize: "0.84rem", color: "#6b6560", lineHeight: 1.65, marginBottom: 28, maxWidth: 380, margin: "0 auto 28px" }}>
|
||||||
|
The AI will read your PRD and recommend the technical structure — apps, services, database, and integrations. You'll review it before anything gets built.
|
||||||
|
</p>
|
||||||
|
{error && (
|
||||||
|
<div style={{ marginBottom: 16, padding: "10px 14px", background: "#fff0f0", border: "1px solid #ffcdd2", borderRadius: 8, fontSize: "0.78rem", color: "#c62828" }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handleGenerate(false)}
|
||||||
|
disabled={generating}
|
||||||
style={{
|
style={{
|
||||||
flex: 1, display: "flex", alignItems: "center", justifyContent: "center",
|
padding: "11px 28px", borderRadius: 8,
|
||||||
padding: 40, fontFamily: "Outfit, sans-serif",
|
background: generating ? "#8a8478" : "#1a1a1a",
|
||||||
|
color: "#fff", border: "none", cursor: generating ? "default" : "pointer",
|
||||||
|
fontSize: "0.84rem", fontWeight: 600, fontFamily: "Outfit, sans-serif",
|
||||||
|
transition: "background 0.15s",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ textAlign: "center", maxWidth: 360 }}>
|
{generating ? "Analysing PRD…" : "Generate architecture →"}
|
||||||
<div style={{
|
</button>
|
||||||
width: 56, height: 56, borderRadius: 14,
|
{generating && (
|
||||||
background: "#fff", border: "1px solid #e8e4dc",
|
<p style={{ fontSize: "0.72rem", color: "#a09a90", marginTop: 12 }}>
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
This takes about 15–30 seconds
|
||||||
fontSize: "1.4rem", margin: "0 auto 18px",
|
|
||||||
boxShadow: "0 2px 8px #1a1a1a08",
|
|
||||||
}}>
|
|
||||||
⚡
|
|
||||||
</div>
|
|
||||||
<h3 style={{
|
|
||||||
fontFamily: "Newsreader, serif", fontSize: "1.3rem",
|
|
||||||
fontWeight: 400, color: "#1a1a1a", marginBottom: 8,
|
|
||||||
}}>
|
|
||||||
PRD ready — build coming soon
|
|
||||||
</h3>
|
|
||||||
<p style={{ fontSize: "0.82rem", color: "#a09a90", lineHeight: 1.6 }}>
|
|
||||||
The Architect agent will generate your project structure and kick off the build pipeline.
|
|
||||||
This feature is in active development.
|
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Architecture loaded — show full review UI
|
||||||
return (
|
return (
|
||||||
<div
|
<div style={{ padding: "28px 32px", overflow: "auto", fontFamily: "Outfit, sans-serif", maxWidth: 780 }}>
|
||||||
className="vibn-enter"
|
|
||||||
style={{
|
{/* Header */}
|
||||||
flex: 1, display: "flex", alignItems: "center", justifyContent: "center",
|
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 4 }}>
|
||||||
padding: 40, fontFamily: "Outfit, sans-serif",
|
<div>
|
||||||
}}
|
<h2 style={{ fontFamily: "Newsreader, serif", fontSize: "1.35rem", fontWeight: 400, color: "#1a1a1a", margin: 0 }}>
|
||||||
>
|
Architecture
|
||||||
<div style={{ width: "100%", maxWidth: 500 }}>
|
</h2>
|
||||||
<h3 style={{
|
<p style={{ fontSize: "0.75rem", color: "#a09a90", marginTop: 4 }}>
|
||||||
fontFamily: "Newsreader, serif", fontSize: "1.2rem",
|
{architecture.productType}
|
||||||
fontWeight: 400, color: "#1a1a1a", marginBottom: 18,
|
</p>
|
||||||
}}>
|
|
||||||
Build progress
|
|
||||||
</h3>
|
|
||||||
{BUILD_FEATURES.map((f, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="vibn-enter"
|
|
||||||
style={{
|
|
||||||
display: "flex", alignItems: "center", gap: 12,
|
|
||||||
padding: "12px 16px", marginBottom: 4, borderRadius: 8,
|
|
||||||
background: "#fff", border: "1px solid #e8e4dc",
|
|
||||||
animationDelay: `${i * 0.05}s`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{
|
|
||||||
width: 7, height: 7, borderRadius: "50%",
|
|
||||||
background: "#d4a04a", display: "inline-block", flexShrink: 0,
|
|
||||||
}} />
|
|
||||||
<span style={{ flex: 1, fontSize: "0.84rem", color: "#1a1a1a" }}>{f}</span>
|
|
||||||
<div style={{ width: 80, height: 3, borderRadius: 2, background: "#eae6de" }}>
|
|
||||||
<div style={{ height: "100%", width: "0%", borderRadius: 2, background: "#3d5afe" }} />
|
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||||
|
{architectureConfirmed && (
|
||||||
<span style={{
|
<span style={{
|
||||||
fontFamily: "IBM Plex Mono, monospace",
|
fontSize: "0.72rem", fontFamily: "IBM Plex Mono, monospace",
|
||||||
fontSize: "0.7rem", color: "#a09a90", minWidth: 28, textAlign: "right",
|
color: "#2e7d32", background: "#2e7d3210",
|
||||||
|
border: "1px solid #a5d6a740", padding: "4px 10px", borderRadius: 5,
|
||||||
}}>
|
}}>
|
||||||
0%
|
✓ Confirmed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handleGenerate(true)}
|
||||||
|
disabled={generating}
|
||||||
|
style={{
|
||||||
|
padding: "6px 14px", borderRadius: 6,
|
||||||
|
background: "none", border: "1px solid #e0dcd4",
|
||||||
|
fontSize: "0.72rem", color: "#8a8478", cursor: "pointer",
|
||||||
|
fontFamily: "Outfit, sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{generating ? "Regenerating…" : "Regenerate"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<div style={{
|
||||||
|
marginTop: 18, padding: "16px 20px",
|
||||||
|
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
|
||||||
|
fontSize: "0.84rem", color: "#2a2824", lineHeight: 1.7,
|
||||||
|
}}>
|
||||||
|
{architecture.summary}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Apps */}
|
||||||
|
<SectionLabel>Apps — monorepo/apps/</SectionLabel>
|
||||||
|
{architecture.apps.map((app, i) => <AppCard key={i} app={app} />)}
|
||||||
|
|
||||||
|
{/* Packages */}
|
||||||
|
<SectionLabel>Shared packages — monorepo/packages/</SectionLabel>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||||
|
{architecture.packages.map((pkg, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
|
||||||
|
padding: "12px 16px",
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: "0.82rem", fontWeight: 600, color: "#1a1a1a", fontFamily: "IBM Plex Mono, monospace" }}>
|
||||||
|
packages/{pkg.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "0.76rem", color: "#8a8478", marginTop: 4, lineHeight: 1.5 }}>
|
||||||
|
{pkg.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Infrastructure */}
|
||||||
|
{architecture.infrastructure.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SectionLabel>Infrastructure</SectionLabel>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
|
{architecture.infrastructure.map((infra, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 8,
|
||||||
|
padding: "10px 16px", display: "flex", gap: 12, alignItems: "flex-start",
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
|
||||||
|
color: "#3d5afe", background: "#3d5afe0d",
|
||||||
|
border: "1px solid #3d5afe20", padding: "2px 7px", borderRadius: 4,
|
||||||
|
flexShrink: 0, marginTop: 1,
|
||||||
|
}}>
|
||||||
|
{infra.name}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.5 }}>
|
||||||
|
{infra.reason}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Integrations */}
|
||||||
|
{architecture.integrations.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SectionLabel>External integrations</SectionLabel>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
|
{architecture.integrations.map((intg, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 8,
|
||||||
|
padding: "10px 16px", display: "flex", gap: 12, alignItems: "flex-start",
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
|
||||||
|
color: intg.required ? "#9a7b3a" : "#8a8478",
|
||||||
|
background: intg.required ? "#d4a04a12" : "#f6f4f0",
|
||||||
|
border: `1px solid ${intg.required ? "#d4a04a30" : "#e8e4dc"}`,
|
||||||
|
padding: "2px 7px", borderRadius: 4, flexShrink: 0, marginTop: 1,
|
||||||
|
}}>
|
||||||
|
{intg.required ? "required" : "optional"}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: "0.82rem", fontWeight: 600, color: "#1a1a1a" }}>{intg.name}</div>
|
||||||
|
<div style={{ fontSize: "0.75rem", color: "#8a8478", marginTop: 2 }}>{intg.notes}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Risk notes */}
|
||||||
|
{architecture.riskNotes.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SectionLabel>Architecture risks</SectionLabel>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
|
{architecture.riskNotes.map((risk, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
background: "#fff8f0", border: "1px solid #ffe0b2",
|
||||||
|
borderRadius: 8, padding: "10px 16px",
|
||||||
|
fontSize: "0.78rem", color: "#6d4c00", lineHeight: 1.55,
|
||||||
|
display: "flex", gap: 8,
|
||||||
|
}}>
|
||||||
|
<span>⚠️</span><span>{risk}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Confirm section */}
|
||||||
|
<div style={{
|
||||||
|
marginTop: 32, padding: "20px 24px",
|
||||||
|
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 12,
|
||||||
|
borderLeft: "3px solid #1a1a1a",
|
||||||
|
}}>
|
||||||
|
{architectureConfirmed ? (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 6 }}>
|
||||||
|
✓ Architecture confirmed
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: "0.78rem", color: "#6b6560", margin: "0 0 14px" }}>
|
||||||
|
You can still regenerate or adjust the architecture before scaffolding begins. Nothing has been built yet.
|
||||||
|
</p>
|
||||||
|
<Link href={`/${workspace}/project/${projectId}/design`} style={{
|
||||||
|
display: "inline-block", padding: "9px 20px", borderRadius: 7,
|
||||||
|
background: "#1a1a1a", color: "#fff",
|
||||||
|
fontSize: "0.78rem", fontWeight: 600, fontFamily: "Outfit, sans-serif",
|
||||||
|
textDecoration: "none",
|
||||||
|
}}>
|
||||||
|
Choose your design →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 6 }}>
|
||||||
|
Does this look right?
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: "0.78rem", color: "#6b6560", margin: "0 0 16px", lineHeight: 1.6 }}>
|
||||||
|
Review the structure above. You can regenerate if something's off, or confirm to move to design.
|
||||||
|
You can always come back and adjust before the build starts — nothing gets scaffolded yet.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: "flex", gap: 10 }}>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={confirming}
|
||||||
|
style={{
|
||||||
|
padding: "9px 22px", borderRadius: 7,
|
||||||
|
background: confirming ? "#8a8478" : "#1a1a1a",
|
||||||
|
color: "#fff", border: "none",
|
||||||
|
fontSize: "0.78rem", fontWeight: 600,
|
||||||
|
fontFamily: "Outfit, sans-serif", cursor: confirming ? "default" : "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{confirming ? "Confirming…" : "Confirm architecture →"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleGenerate(true)}
|
||||||
|
disabled={generating}
|
||||||
|
style={{
|
||||||
|
padding: "9px 18px", borderRadius: 7,
|
||||||
|
background: "none", border: "1px solid #e0dcd4",
|
||||||
|
fontSize: "0.78rem", color: "#8a8478",
|
||||||
|
fontFamily: "Outfit, sans-serif", cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Regenerate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ height: 40 }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
212
app/api/projects/[projectId]/architecture/route.ts
Normal file
212
app/api/projects/[projectId]/architecture/route.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth/next";
|
||||||
|
import { authOptions } from "@/lib/auth/authOptions";
|
||||||
|
import { query } from "@/lib/db-postgres";
|
||||||
|
|
||||||
|
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET — return saved architecture (if it exists)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { projectId } = await params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = await query<{ data: any }>(
|
||||||
|
`SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`,
|
||||||
|
[projectId]
|
||||||
|
);
|
||||||
|
const data = rows[0]?.data ?? {};
|
||||||
|
return NextResponse.json({
|
||||||
|
architecture: data.architecture ?? null,
|
||||||
|
prd: data.prd ?? null,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ architecture: null, prd: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST — generate architecture recommendation from PRD using AI
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { projectId } = await params;
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
|
const forceRegenerate = body.forceRegenerate === true;
|
||||||
|
|
||||||
|
// Load project PRD + phases
|
||||||
|
let prd: string | null = null;
|
||||||
|
let phases: any[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = await query<{ data: any }>(
|
||||||
|
`SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`,
|
||||||
|
[projectId]
|
||||||
|
);
|
||||||
|
const data = rows[0]?.data ?? {};
|
||||||
|
prd = data.prd ?? null;
|
||||||
|
|
||||||
|
// Return cached architecture if it exists and not forcing regenerate
|
||||||
|
if (data.architecture && !forceRegenerate) {
|
||||||
|
return NextResponse.json({ architecture: data.architecture, cached: true });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!prd) {
|
||||||
|
return NextResponse.json({ error: "No PRD found — complete discovery first" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const phaseRows = await query<{ phase: string; title: string; summary: string; data: any }>(
|
||||||
|
`SELECT phase, title, summary, data FROM atlas_phases WHERE project_id = $1 ORDER BY saved_at ASC`,
|
||||||
|
[projectId]
|
||||||
|
);
|
||||||
|
phases = phaseRows;
|
||||||
|
} catch { /* phases optional */ }
|
||||||
|
|
||||||
|
// Build a concise context string from phases
|
||||||
|
const phaseContext = phases.map(p =>
|
||||||
|
`## ${p.title}\n${p.summary}\n${JSON.stringify(p.data, null, 2)}`
|
||||||
|
).join("\n\n");
|
||||||
|
|
||||||
|
const prompt = `You are a senior software architect. Analyse the following Product Requirements Document and recommend a technical architecture for a Turborepo monorepo.
|
||||||
|
|
||||||
|
Return ONLY a valid JSON object (no markdown, no explanation) with this exact structure:
|
||||||
|
{
|
||||||
|
"productName": "string",
|
||||||
|
"productType": "string (e.g. PWA Game, SaaS, Marketplace, Internal Tool)",
|
||||||
|
"summary": "2-3 sentence plain-English summary of the recommended architecture",
|
||||||
|
"apps": [
|
||||||
|
{
|
||||||
|
"name": "string (e.g. web, api, simulator)",
|
||||||
|
"type": "string (e.g. Next.js 15, Express API, Node.js service)",
|
||||||
|
"description": "string — what this app does",
|
||||||
|
"tech": ["string array of key technologies"],
|
||||||
|
"screens": ["string array — key screens/routes if applicable, else empty"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages": [
|
||||||
|
{
|
||||||
|
"name": "string (e.g. db, types, ui)",
|
||||||
|
"description": "string — what this shared package contains"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"infrastructure": [
|
||||||
|
{
|
||||||
|
"name": "string (e.g. PostgreSQL, Redis, Background Jobs)",
|
||||||
|
"reason": "string — why this is needed based on the PRD"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"integrations": [
|
||||||
|
{
|
||||||
|
"name": "string (e.g. Ad Network SDK)",
|
||||||
|
"required": true,
|
||||||
|
"notes": "string"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"designSurfaces": ["string array — e.g. Web App, Mobile PWA, Admin"],
|
||||||
|
"riskNotes": ["string array — 1-2 key architectural risks from the PRD"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Be specific to this product. Do not use generic boilerplate — base your decisions on the PRD content.
|
||||||
|
|
||||||
|
--- DISCOVERY PHASES ---
|
||||||
|
${phaseContext}
|
||||||
|
|
||||||
|
--- PRD ---
|
||||||
|
${prd}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${AGENT_RUNNER_URL}/atlas/chat`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: prompt,
|
||||||
|
session_id: `arch_${projectId}_${Date.now()}`,
|
||||||
|
history: [],
|
||||||
|
is_init: false,
|
||||||
|
tools: [], // no tools needed — just structured generation
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(120_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Agent runner responded ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const raw = data.reply ?? "";
|
||||||
|
|
||||||
|
// Extract JSON from response (strip any accidental markdown)
|
||||||
|
const jsonMatch = raw.match(/\{[\s\S]*\}/);
|
||||||
|
if (!jsonMatch) throw new Error("No JSON in response");
|
||||||
|
|
||||||
|
const architecture = JSON.parse(jsonMatch[0]);
|
||||||
|
|
||||||
|
// Persist to project data
|
||||||
|
await query(
|
||||||
|
`UPDATE fs_projects
|
||||||
|
SET data = jsonb_set(COALESCE(data, '{}'::jsonb), '{architecture}', $2::jsonb, true),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[projectId, JSON.stringify(architecture)]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ architecture, cached: false });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[architecture] Generation failed:", err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Architecture generation failed. Please try again." },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PATCH — confirm architecture (sets architectureConfirmed flag)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { projectId } = await params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await query(
|
||||||
|
`UPDATE fs_projects
|
||||||
|
SET data = jsonb_set(COALESCE(data, '{}'::jsonb), '{architectureConfirmed}', 'true'::jsonb, true),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[projectId]
|
||||||
|
);
|
||||||
|
return NextResponse.json({ confirmed: true });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Failed to confirm" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user