diff --git a/app/[workspace]/project/[projectId]/build/page.tsx b/app/[workspace]/project/[projectId]/build/page.tsx index 6dcb870..8cc4194 100644 --- a/app/[workspace]/project/[projectId]/build/page.tsx +++ b/app/[workspace]/project/[projectId]/build/page.tsx @@ -4,39 +4,190 @@ import { useEffect, useState } from "react"; import { useParams } from "next/navigation"; import Link from "next/link"; -interface Project { - id: string; - status?: string; - prd?: string; - giteaRepoUrl?: string; +interface App { + name: string; + type: string; + description: string; + tech: string[]; + screens: string[]; } -const BUILD_FEATURES = [ - "Authentication system", - "Database schema", - "API endpoints", - "Core UI", - "Business logic", - "Tests", -]; +interface Package { + name: string; + description: string; +} + +interface Infra { + 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 ( +
+ {children} +
+ ); +} + +function AppCard({ app }: { app: App }) { + const [open, setOpen] = useState(false); + const icons: Record = { + web: "๐ŸŒ", api: "โšก", simulator: "๐ŸŽฎ", admin: "๐Ÿ”ง", + mobile: "๐Ÿ“ฑ", worker: "โš™๏ธ", engine: "๐ŸŽฏ", + }; + const icon = Object.entries(icons).find(([k]) => app.name.toLowerCase().includes(k))?.[1] ?? "๐Ÿ“ฆ"; + + return ( +
+ + + {open && ( +
+ {app.tech.length > 0 && ( +
+
Stack
+
+ {app.tech.map((t, i) => ( + {t} + ))} +
+
+ )} + {app.screens.length > 0 && ( +
+
Key screens
+
+ {app.screens.map((s, i) => ( +
+ โ†’ {s} +
+ ))} +
+
+ )} +
+ )} +
+ ); +} export default function BuildPage() { const params = useParams(); const projectId = params.projectId as string; const workspace = params.workspace as string; - const [project, setProject] = useState(null); + + const [prd, setPrd] = useState(null); + const [architecture, setArchitecture] = useState(null); + const [architectureConfirmed, setArchitectureConfirmed] = useState(false); const [loading, setLoading] = useState(true); + const [generating, setGenerating] = useState(false); + const [confirming, setConfirming] = useState(false); + const [error, setError] = useState(null); useEffect(() => { - fetch(`/api/projects/${projectId}`) - .then((r) => r.json()) - .then((d) => { - setProject(d.project); + fetch(`/api/projects/${projectId}/architecture`) + .then(r => r.json()) + .then(d => { + setPrd(d.prd); + setArchitecture(d.architecture ?? null); 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]); + 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) { return (
@@ -45,48 +196,24 @@ export default function BuildPage() { ); } - const hasRepo = Boolean(project?.giteaRepoUrl); - const hasPRD = Boolean(project?.prd); - - if (!hasPRD) { + // No PRD yet + if (!prd) { return ( -
+
-
- ๐Ÿ”’ -
-

+
๐Ÿ”’
+

Complete your PRD first

- Finish your discovery with Atlas, then the builder unlocks automatically. + Finish your discovery conversation with Atlas, then the architect will unlock automatically.

- + Continue with Atlas โ†’
@@ -94,83 +221,255 @@ export default function BuildPage() { ); } - if (!hasRepo) { + // PRD exists but no architecture yet โ€” prompt to generate + if (!architecture) { return ( -
-
-
- โšก -
-

- PRD ready โ€” build coming soon +
+
+
๐Ÿ—๏ธ
+

+ Ready to architect {architecture ? (architecture as Architecture).productName : "your product"}

-

- The Architect agent will generate your project structure and kick off the build pipeline. - This feature is in active development. +

+ The AI will read your PRD and recommend the technical structure โ€” apps, services, database, and integrations. You'll review it before anything gets built.

+ {error && ( +
+ {error} +
+ )} + + {generating && ( +

+ This takes about 15โ€“30 seconds +

+ )}
); } + // Architecture loaded โ€” show full review UI return ( -
-
-

- Build progress -

- {BUILD_FEATURES.map((f, i) => ( -
+ + {/* Header */} +
+
+

+ Architecture +

+

+ {architecture.productType} +

+
+
+ {architectureConfirmed && ( + + โœ“ Confirmed + + )} + +
+
+ + {/* Summary */} +
+ {architecture.summary} +
+ + {/* Apps */} + Apps โ€” monorepo/apps/ + {architecture.apps.map((app, i) => )} + + {/* Packages */} + Shared packages โ€” monorepo/packages/ +
+ {architecture.packages.map((pkg, i) => ( +
+
+ packages/{pkg.name} +
+
+ {pkg.description}
- - 0% -
))}
+ + {/* Infrastructure */} + {architecture.infrastructure.length > 0 && ( + <> + Infrastructure +
+ {architecture.infrastructure.map((infra, i) => ( +
+ + {infra.name} + + + {infra.reason} + +
+ ))} +
+ + )} + + {/* Integrations */} + {architecture.integrations.length > 0 && ( + <> + External integrations +
+ {architecture.integrations.map((intg, i) => ( +
+ + {intg.required ? "required" : "optional"} + +
+
{intg.name}
+
{intg.notes}
+
+
+ ))} +
+ + )} + + {/* Risk notes */} + {architecture.riskNotes.length > 0 && ( + <> + Architecture risks +
+ {architecture.riskNotes.map((risk, i) => ( +
+ โš ๏ธ{risk} +
+ ))} +
+ + )} + + {/* Confirm section */} +
+ {architectureConfirmed ? ( +
+
+ โœ“ Architecture confirmed +
+

+ You can still regenerate or adjust the architecture before scaffolding begins. Nothing has been built yet. +

+ + Choose your design โ†’ + +
+ ) : ( +
+
+ Does this look right? +
+

+ 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. +

+
+ + +
+
+ )} +
+ +
); } diff --git a/app/api/projects/[projectId]/architecture/route.ts b/app/api/projects/[projectId]/architecture/route.ts new file mode 100644 index 0000000..56c84b2 --- /dev/null +++ b/app/api/projects/[projectId]/architecture/route.ts @@ -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 }); + } +}