diff --git a/README.md b/README.md index 730ae9f..3a87572 100644 --- a/README.md +++ b/README.md @@ -107,12 +107,21 @@ packages/ Set these variables in `apps/api/.env`: ```bash +STACKFORGE_PROVIDER=openrouter OPENROUTER_API_KEY=your_key_here OPENROUTER_ENDPOINT=https://openrouter.ai/api/v1/chat/completions OPENROUTER_APP_NAME=stackforge-api OPENROUTER_APP_URL=http://localhost:3001 ``` +`STACKFORGE_PROVIDER` supports: +- `openrouter`: forces real LLM execution (requires `OPENROUTER_API_KEY`) +- `mock`: forces deterministic offline agent responses +- omitted/`auto`: uses `openrouter` when API key is present, otherwise `mock` + +Runtime/provider health is exposed at: +- `GET /api/runtime` + --- ## ๐Ÿ“‰ Token Tuning Guide diff --git a/apps/api/.env.example b/apps/api/.env.example index 36fdf67..16b112f 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -1,5 +1,6 @@ PORT=3001 NODE_ENV=development +STACKFORGE_PROVIDER=auto OPENROUTER_API_KEY= OPENROUTER_ENDPOINT=https://openrouter.ai/api/v1/chat/completions OPENROUTER_APP_NAME=stackforge-api diff --git a/apps/api/src/controllers/generate.controller.ts b/apps/api/src/controllers/generate.controller.ts index b217f1e..7c450a9 100644 --- a/apps/api/src/controllers/generate.controller.ts +++ b/apps/api/src/controllers/generate.controller.ts @@ -1,6 +1,6 @@ import type { Request, Response, NextFunction } from "express"; import { GenerateRequestSchema } from "@stackforge/shared"; -import { generateProject } from "../services/generate.service.js"; +import { generateProject, getRuntimeStatus } from "../services/generate.service.js"; export function generateController(req: Request, res: Response, next: NextFunction): void { const parsed = GenerateRequestSchema.safeParse(req.body); @@ -12,6 +12,15 @@ export function generateController(req: Request, res: Response, next: NextFuncti const { prompt, projectName } = parsed.data; const resolvedName = projectName ?? prompt.slice(0, 40).replace(/\s+/g, "-").toLowerCase(); + const runtime = getRuntimeStatus(); + + if (!runtime.ready) { + res.status(503).json({ + error: runtime.reason ?? "LLM provider is not configured", + provider: runtime.provider, + }); + return; + } try { const job = generateProject(prompt, resolvedName); diff --git a/apps/api/src/controllers/jobs.controller.ts b/apps/api/src/controllers/jobs.controller.ts index dd326ea..85bd867 100644 --- a/apps/api/src/controllers/jobs.controller.ts +++ b/apps/api/src/controllers/jobs.controller.ts @@ -1,8 +1,14 @@ import type { Request, Response, NextFunction } from "express"; import { JobIdParamSchema, JOB_STATUS } from "@stackforge/shared"; import { getJob, listJobs, summarizeJobTokenUsage } from "../store/job.store.js"; +import { getRuntimeStatus } from "../services/generate.service.js"; import { subscribe, unsubscribe } from "../services/sse.service.js"; +export function runtimeController(_req: Request, res: Response): void { + const runtime = getRuntimeStatus(); + res.status(runtime.ready ? 200 : 503).json(runtime); +} + export function listJobsController(_req: Request, res: Response): void { const jobs = listJobs().map((job) => ({ id: job.id, diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index a8445a6..5bc1997 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -8,6 +8,18 @@ const PORT = process.env["PORT"] ?? "3001"; app.use(express.json()); +// CORS for Vite dev server +app.use((_req, res, next) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); + if (_req.method === "OPTIONS") { + res.sendStatus(204); + return; + } + next(); +}); + app.get("/healthz", (_req, res) => { res.json({ status: "ok", service: "stackforge-api", ts: new Date().toISOString() }); }); diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index 04661ac..88f5185 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -1,10 +1,16 @@ import { Router, type IRouter } from "express"; import { generateController } from "../controllers/generate.controller.js"; -import { listJobsController, getJobController, streamController } from "../controllers/jobs.controller.js"; +import { + runtimeController, + listJobsController, + getJobController, + streamController, +} from "../controllers/jobs.controller.js"; const router: IRouter = Router(); router.post("/generate", generateController); +router.get("/runtime", runtimeController); router.get("/jobs", listJobsController); router.get("/jobs/:jobId", getJobController); router.get("/stream/:jobId", streamController); diff --git a/apps/api/src/services/generate.service.ts b/apps/api/src/services/generate.service.ts index e95c08a..3c12bf2 100644 --- a/apps/api/src/services/generate.service.ts +++ b/apps/api/src/services/generate.service.ts @@ -1,7 +1,13 @@ import type { SSEEvent, AgentName } from "@stackforge/shared"; import { JOB_STATUS } from "@stackforge/shared"; -import { OpenRouterProvider, AgentCache, runOrchestrator } from "@stackforge/agents"; +import { + OpenRouterProvider, + MockProvider, + AgentCache, + runOrchestrator, + type LLMProvider, +} from "@stackforge/agents"; import { createJob, getJob, @@ -11,6 +17,29 @@ import { } from "../store/job.store.js"; import { broadcast, closeJobClients } from "./sse.service.js"; +type ProviderMode = "openrouter" | "mock"; + +export type RuntimeStatus = { + provider: ProviderMode; + ready: boolean; + reason?: string; +}; + +function resolveProviderMode(): ProviderMode { + const configured = (process.env["STACKFORGE_PROVIDER"] ?? "auto").trim().toLowerCase(); + + if (configured === "openrouter") { + return "openrouter"; + } + + if (configured === "mock") { + return "mock"; + } + + const hasOpenRouterKey = (process.env["OPENROUTER_API_KEY"] ?? "").trim().length > 0; + return hasOpenRouterKey ? "openrouter" : "mock"; +} + function readEnv(name: string): string { const value = process.env[name]; if (value === undefined || value.trim().length === 0) { @@ -19,7 +48,7 @@ function readEnv(name: string): string { return value; } -function buildProvider(): OpenRouterProvider { +function buildOpenRouterProvider(): OpenRouterProvider { const endpoint = process.env["OPENROUTER_ENDPOINT"]; const options = { apiKey: readEnv("OPENROUTER_API_KEY"), @@ -31,16 +60,38 @@ function buildProvider(): OpenRouterProvider { return new OpenRouterProvider(options); } -let provider: OpenRouterProvider | undefined; +function buildProvider(mode: ProviderMode): LLMProvider { + if (mode === "mock") { + return new MockProvider(); + } + + return buildOpenRouterProvider(); +} -function getProvider(): OpenRouterProvider { +let provider: LLMProvider | undefined; +let providerMode: ProviderMode | undefined; + +function getProvider(): LLMProvider { if (provider === undefined) { - provider = buildProvider(); + providerMode = resolveProviderMode(); + provider = buildProvider(providerMode); } return provider; } +export function getRuntimeStatus(): RuntimeStatus { + const mode = providerMode ?? resolveProviderMode(); + + try { + void getProvider(); + return { provider: mode, ready: true }; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + return { provider: mode, ready: false, reason }; + } +} + const cache = new AgentCache(); function buildEmitter(jobId: string): (event: SSEEvent) => void { diff --git a/apps/api/test/integration.test.ts b/apps/api/test/integration.test.ts index d96ff26..032bd8d 100644 --- a/apps/api/test/integration.test.ts +++ b/apps/api/test/integration.test.ts @@ -28,6 +28,15 @@ describe("StackForge API Integration", () => { expect(json.error).toBe("Validation failed"); }); + it("should return runtime provider status", async () => { + const res = await fetch(`${baseUrl}/api/runtime`); + expect([200, 503]).toContain(res.status); + + const data = await res.json(); + expect(["openrouter", "mock"]).toContain(data.provider); + expect(typeof data.ready).toBe("boolean"); + }); + it("should create a job and return 202 accepted", async () => { const res = await fetch(`${baseUrl}/api/generate`, { method: "POST", diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..21d6096 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,16 @@ + + + + + + + StackForge โ€” AI Project Scaffolding + + + + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json index 663acc5..eb81a9d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,11 +4,24 @@ "private": true, "type": "module", "scripts": { - "dev": "echo 'web: not implemented yet'", - "build": "echo 'web: not implemented yet'", - "lint": "echo 'web: not implemented yet'" + "dev": "vite", + "build": "tsc -b && vite build", + "typecheck": "tsc --noEmit", + "preview": "vite preview" }, "dependencies": { - "@stackforge/shared": "workspace:*" + "@stackforge/ui": "workspace:*", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.0", + "tailwindcss": "^4.1.0", + "typescript": "^5.7.3", + "vite": "^6.2.0" } } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..6f8b056 --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import { BrowserRouter, Routes, Route } from "react-router-dom"; +import { ToastProvider } from "@stackforge/ui"; +import { Layout } from "./components/Layout"; +import { Home } from "./pages/Home"; +import { JobPage } from "./pages/JobPage"; + +export function App() { + return ( + + + + + } /> + } /> + + + + + ); +} diff --git a/apps/web/src/components/AgentCard.tsx b/apps/web/src/components/AgentCard.tsx new file mode 100644 index 0000000..2eabb90 --- /dev/null +++ b/apps/web/src/components/AgentCard.tsx @@ -0,0 +1,88 @@ +import React from "react"; +import { Badge, Card, Spinner } from "@stackforge/ui"; +import type { AgentState } from "../hooks/useJobStream"; + +const AGENT_META: Record = { + planner: { icon: "๐Ÿ“‹", label: "Planner", description: "Defines project structure and tech stack" }, + schema: { icon: "๐Ÿ—„๏ธ", label: "Schema", description: "Designs database entities and relationships" }, + api: { icon: "โšก", label: "API", description: "Plans API routes and endpoints" }, + frontend: { icon: "๐ŸŽจ", label: "Frontend", description: "Creates frontend pages and components" }, + devops: { icon: "๐Ÿš€", label: "DevOps", description: "Sets up CI/CD, Docker, and deployment" }, + reviewer: { icon: "๐Ÿ”", label: "Reviewer", description: "Reviews the entire blueprint for quality" }, +}; + +export function AgentCard({ agent }: { agent: AgentState }) { + const meta = AGENT_META[agent.name] ?? { icon: "๐Ÿค–", label: agent.name, description: "" }; + + const borderColor = + agent.status === "running" + ? "rgba(56, 189, 248, 0.3)" + : agent.status === "completed" + ? "rgba(52, 211, 153, 0.2)" + : agent.status === "failed" + ? "rgba(244, 63, 94, 0.2)" + : "#23232f"; + + return ( + +
+ {/* Icon */} +
+ {agent.status === "running" ? : meta.icon} +
+ + {/* Info */} +
+
+ {meta.label} + +
+

{meta.description}

+
+ + {/* Duration / details */} +
+ {agent.durationMs != null && ( + + {(agent.durationMs / 1000).toFixed(1)}s + + )} + {agent.totalTokens != null && ( +
+ {agent.totalTokens.toLocaleString()} tokens +
+ )} + {agent.error && ( + {agent.error} + )} +
+
+
+ ); +} diff --git a/apps/web/src/components/AgentTimeline.tsx b/apps/web/src/components/AgentTimeline.tsx new file mode 100644 index 0000000..5d31522 --- /dev/null +++ b/apps/web/src/components/AgentTimeline.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import { AgentCard } from "./AgentCard"; +import type { AgentState } from "../hooks/useJobStream"; + +export function AgentTimeline({ agents }: { agents: AgentState[] }) { + return ( +
+ {/* Vertical connector line */} +
+ +
+ {agents.map((agent, i) => ( +
+ +
+ ))} +
+
+ ); +} diff --git a/apps/web/src/components/BlueprintView.tsx b/apps/web/src/components/BlueprintView.tsx new file mode 100644 index 0000000..5c9a426 --- /dev/null +++ b/apps/web/src/components/BlueprintView.tsx @@ -0,0 +1,252 @@ +import React from "react"; +import { Card, Button } from "@stackforge/ui"; +import { CollapsibleSection } from "./CollapsibleSection"; +import { FileTree } from "./FileTree"; +import type { Blueprint } from "../lib/api"; + +export function BlueprintView({ blueprint }: { blueprint: Blueprint }) { + function handleDownload() { + const blob = new Blob([JSON.stringify(blueprint, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${blueprint.projectName}-blueprint.json`; + a.click(); + URL.revokeObjectURL(url); + } + + return ( +
+ {/* Stack summary card */} + +
+
+

+ {blueprint.projectName} +

+
+ {Object.entries(blueprint.stack) + .filter(([k]) => k !== "monorepo") + .map(([key, value]) => ( + + {key}: {String(value)} + + ))} +
+
+ +
+
+ + {/* Entities */} + +
+ {blueprint.entities.map((entity) => ( +
+
+ {entity.name} + ({entity.tableName}) +
+
+ {entity.fields.map((f) => ( + + {f.name}: {f.type} + {f.nullable ? "?" : ""} + {f.foreignKey ? ` โ†’ ${f.foreignKey}` : ""} + + ))} +
+
+ ))} +
+
+ + {/* API Routes */} + +
+ + + + {["Method", "Path", "Description", "Auth"].map((h) => ( + + ))} + + + + {blueprint.routePlan.map((r, i) => ( + + + + + + + ))} + +
+ {h} +
+ + {r.method} + + {r.path}{r.description}{r.auth ? "๐Ÿ”’" : "โ€”"}
+
+
+ + {/* Frontend Pages */} + +
+ {blueprint.frontendPages.map((page) => ( +
+
+ {page.name} + {page.route} + {page.auth && ๐Ÿ”’ Auth} +
+

{page.description}

+
+ {page.components.map((c) => ( + + {c} + + ))} +
+
+ ))} +
+
+ + {/* DevOps */} + +
+ + + + +
+
+ + {/* Reviewer Notes */} + +
+ {blueprint.reviewerNotes.map((note, i) => ( +
+ + [{note.agent}] + {" "} + {note.note} +
+ ))} +
+
+ + {/* File Tree */} + + + +
+ ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function methodColor(method: string): string { + switch (method) { + case "GET": return "rgba(52, 211, 153, 0.3)"; + case "POST": return "rgba(56, 189, 248, 0.3)"; + case "PUT": return "rgba(251, 191, 36, 0.3)"; + case "PATCH": return "rgba(168, 85, 247, 0.3)"; + case "DELETE": return "rgba(244, 63, 94, 0.3)"; + default: return "rgba(92, 92, 111, 0.3)"; + } +} diff --git a/apps/web/src/components/CollapsibleSection.tsx b/apps/web/src/components/CollapsibleSection.tsx new file mode 100644 index 0000000..33c52a4 --- /dev/null +++ b/apps/web/src/components/CollapsibleSection.tsx @@ -0,0 +1,88 @@ +import React, { useState } from "react"; + +interface CollapsibleSectionProps { + title: string; + icon?: string; + defaultOpen?: boolean; + count?: number; + children: React.ReactNode; +} + +export function CollapsibleSection({ + title, + icon, + defaultOpen = false, + count, + children, +}: CollapsibleSectionProps) { + const [open, setOpen] = useState(defaultOpen); + + return ( +
+ + + {open && ( +
+ {children} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/FileTree.tsx b/apps/web/src/components/FileTree.tsx new file mode 100644 index 0000000..5154216 --- /dev/null +++ b/apps/web/src/components/FileTree.tsx @@ -0,0 +1,54 @@ +import React from "react"; + +interface FolderNode { + path: string; + type: "file" | "dir"; + description?: string; +} + +export function FileTree({ nodes }: { nodes: FolderNode[] }) { + if (nodes.length === 0) { + return

No folder structure available.

; + } + + return ( +
+ {nodes.map((node) => { + const depth = node.path.split("/").length - 1; + const name = node.path.split("/").pop() ?? node.path; + const isDir = node.type === "dir"; + + return ( +
+ + {isDir ? "๐Ÿ“" : "๐Ÿ“„"} + + + {name} + + {node.description && ( + + โ€” {node.description} + + )} +
+ ); + })} +
+ ); +} diff --git a/apps/web/src/components/Layout.tsx b/apps/web/src/components/Layout.tsx new file mode 100644 index 0000000..a8f5d0a --- /dev/null +++ b/apps/web/src/components/Layout.tsx @@ -0,0 +1,125 @@ +import React from "react"; +import { Link, useLocation } from "react-router-dom"; + +export function Layout({ children }: { children: React.ReactNode }) { + const location = useLocation(); + + return ( +
+ {/* โ”€โ”€โ”€ Navbar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */} + + + {/* โ”€โ”€โ”€ Main โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */} +
{children}
+ + {/* โ”€โ”€โ”€ Footer โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */} +
+ Built with AI agents ยท StackForge ยฉ {new Date().getFullYear()} +
+
+ ); +} + +function NavLink({ + to, + active, + children, +}: { + to: string; + active: boolean; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/apps/web/src/hooks/useJobStream.ts b/apps/web/src/hooks/useJobStream.ts new file mode 100644 index 0000000..1f4a3d1 --- /dev/null +++ b/apps/web/src/hooks/useJobStream.ts @@ -0,0 +1,161 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { runDemoSimulation } from "../lib/mock-data"; + +export type AgentStatus = "waiting" | "running" | "completed" | "failed"; + +export interface AgentState { + name: string; + status: AgentStatus; + startedAt?: string; + completedAt?: string; + durationMs?: number; + error?: string; + model?: string; + totalTokens?: number; +} + +export interface JobStreamState { + agents: AgentState[]; + jobStatus: "queued" | "running" | "completed" | "failed"; + jobError?: string; + connected: boolean; +} + +const AGENT_ORDER = ["planner", "schema", "api", "frontend", "devops", "reviewer"] as const; + +function createInitialAgents(): AgentState[] { + return AGENT_ORDER.map((name) => ({ name, status: "waiting" as AgentStatus })); +} + +export function useJobStream(jobId: string | undefined, isDemo = false): JobStreamState { + const [agents, setAgents] = useState(createInitialAgents); + const [jobStatus, setJobStatus] = useState("queued"); + const [jobError, setJobError] = useState(); + const [connected, setConnected] = useState(false); + const sourceRef = useRef(null); + + const handleEvent = useCallback((data: Record) => { + const type = data["type"] as string; + + switch (type) { + case "job_created": + setJobStatus("running"); + break; + + case "agent_started": + setAgents((prev) => + prev.map((a) => + a.name === data["agent"] + ? { ...a, status: "running" as AgentStatus, startedAt: data["timestamp"] as string } + : a, + ), + ); + break; + + case "agent_completed": { + const payload = data["payload"] as Record; + setAgents((prev) => + prev.map((a) => + a.name === data["agent"] + ? { + ...a, + status: "completed" as AgentStatus, + completedAt: data["timestamp"] as string, + durationMs: payload["durationMs"] as number, + model: payload["model"] as string | undefined, + totalTokens: payload["totalTokens"] as number | undefined, + } + : a, + ), + ); + break; + } + + case "agent_failed": { + const payload = data["payload"] as Record; + setAgents((prev) => + prev.map((a) => + a.name === data["agent"] + ? { ...a, status: "failed" as AgentStatus, error: payload["error"] as string } + : a, + ), + ); + break; + } + + case "job_completed": + setJobStatus("completed"); + break; + + case "job_failed": { + const payload = data["payload"] as Record; + setJobStatus("failed"); + setJobError(payload["error"] as string); + break; + } + } + }, []); + + // Demo mode โ€” simulate locally + useEffect(() => { + if (!jobId || !isDemo) return; + + setAgents(createInitialAgents()); + setJobStatus("queued"); + setJobError(undefined); + setConnected(true); + + const cancel = runDemoSimulation( + jobId, + (event) => handleEvent(event as unknown as Record), + () => setConnected(false), + ); + + return cancel; + }, [jobId, isDemo, handleEvent]); + + // Real SSE mode + useEffect(() => { + if (!jobId || isDemo) return; + + setAgents(createInitialAgents()); + setJobStatus("queued"); + setJobError(undefined); + + const source = new EventSource(`/api/stream/${jobId}`); + sourceRef.current = source; + + source.onopen = () => setConnected(true); + + source.addEventListener("job_created", (e) => { + handleEvent(JSON.parse(e.data) as Record); + }); + source.addEventListener("agent_started", (e) => { + handleEvent(JSON.parse(e.data) as Record); + }); + source.addEventListener("agent_completed", (e) => { + handleEvent(JSON.parse(e.data) as Record); + }); + source.addEventListener("agent_failed", (e) => { + handleEvent(JSON.parse(e.data) as Record); + }); + source.addEventListener("job_completed", (e) => { + handleEvent(JSON.parse(e.data) as Record); + }); + source.addEventListener("job_failed", (e) => { + handleEvent(JSON.parse(e.data) as Record); + }); + + source.onerror = () => { + setConnected(false); + source.close(); + }; + + return () => { + source.close(); + sourceRef.current = null; + }; + }, [jobId, isDemo, handleEvent]); + + return { agents, jobStatus, jobError, connected }; +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css new file mode 100644 index 0000000..df5024e --- /dev/null +++ b/apps/web/src/index.css @@ -0,0 +1,143 @@ +@import "tailwindcss"; + +/* โ”€โ”€โ”€ Design Tokens โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +:root { + --font-sans: "Inter", system-ui, -apple-system, sans-serif; + + /* Dark theme palette */ + --bg-primary: #0a0a0f; + --bg-secondary: #111118; + --bg-card: #16161f; + --bg-card-hover: #1c1c28; + --bg-input: #1a1a26; + + --border-subtle: #23232f; + --border-focus: #6366f1; + + --text-primary: #f0f0f5; + --text-secondary: #9898a8; + --text-muted: #5c5c6f; + + /* Accent colors */ + --accent-indigo: #6366f1; + --accent-indigo-hover: #818cf8; + --accent-violet: #8b5cf6; + --accent-emerald: #34d399; + --accent-sky: #38bdf8; + --accent-amber: #fbbf24; + --accent-rose: #f43f5e; + + /* Gradients */ + --gradient-hero: linear-gradient(135deg, #6366f1 0%, #8b5cf6 40%, #a855f7 100%); + --gradient-card: linear-gradient(135deg, rgba(99, 102, 241, 0.08) 0%, rgba(139, 92, 246, 0.04) 100%); + + /* Shadows */ + --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.3), 0 4px 12px rgba(0, 0, 0, 0.2); + --shadow-card-hover: 0 4px 16px rgba(99, 102, 241, 0.15), 0 8px 32px rgba(0, 0, 0, 0.3); + --shadow-glow: 0 0 20px rgba(99, 102, 241, 0.25); + + /* Spacing */ + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 16px; + --radius-xl: 24px; + + /* Transitions */ + --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1); +} + +/* โ”€โ”€โ”€ Reset & Base โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +*, +*::before, +*::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + font-family: var(--font-sans); + background: var(--bg-primary); + color: var(--text-primary); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + min-height: 100vh; + line-height: 1.6; +} + +#root { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* โ”€โ”€โ”€ Scrollbar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +::-webkit-scrollbar { + width: 6px; +} +::-webkit-scrollbar-track { + background: var(--bg-primary); +} +::-webkit-scrollbar-thumb { + background: var(--border-subtle); + border-radius: 3px; +} +::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} + +/* โ”€โ”€โ”€ Keyframes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +@keyframes spin { + to { transform: rotate(360deg); } +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes slideUp { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes pulse-glow { + 0%, 100% { box-shadow: 0 0 8px rgba(99, 102, 241, 0.3); } + 50% { box-shadow: 0 0 20px rgba(99, 102, 241, 0.5); } +} + +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +@keyframes toast-in { + from { opacity: 0; transform: translateX(100%); } + to { opacity: 1; transform: translateX(0); } +} + +@keyframes toast-out { + from { opacity: 1; transform: translateX(0); } + to { opacity: 0; transform: translateX(100%); } +} + +/* โ”€โ”€โ”€ Utility classes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +.animate-fade-in { + animation: fadeIn var(--transition-base) ease-out; +} + +.animate-slide-up { + animation: slideUp var(--transition-slow) ease-out; +} + +.skeleton { + background: linear-gradient(90deg, var(--bg-card) 25%, var(--bg-card-hover) 50%, var(--bg-card) 75%); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; + border-radius: var(--radius-md); +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts new file mode 100644 index 0000000..6d5ac8a --- /dev/null +++ b/apps/web/src/lib/api.ts @@ -0,0 +1,111 @@ +const API_BASE = "/api"; + +export interface GenerateResponse { + jobId: string; + status: string; + projectName: string; + createdAt: string; + streamUrl: string; + jobUrl: string; +} + +export interface RuntimeResponse { + provider: "openrouter" | "mock"; + ready: boolean; + reason?: string; +} + +export interface JobResponse { + id: string; + status: string; + projectName: string; + createdAt: string; + updatedAt: string; + completedAt?: string; + agentsCompleted: string[]; + error?: string; + blueprint?: Blueprint; + tokenUsage?: { inputTokens: number; outputTokens: number; totalTokens: number }; +} + +export interface Blueprint { + projectName: string; + generatedAt: string; + stack: { + frontend: string; + backend: string; + database: string; + auth: string; + hosting: string; + packageManager: string; + monorepo: boolean; + }; + folderStructure: { path: string; type: "file" | "dir"; description?: string }[]; + entities: { + name: string; + tableName: string; + fields: { name: string; type: string; nullable: boolean; unique?: boolean; foreignKey?: string }[]; + indexes?: string[]; + }[]; + relationships: { from: string; to: string; type: string; description: string }[]; + routePlan: { + method: string; + path: string; + description: string; + auth: boolean; + requestBody?: string; + responseType: string; + }[]; + frontendPages: { + route: string; + name: string; + components: string[]; + auth: boolean; + description: string; + }[]; + infraPlan: { + ci: string[]; + docker: boolean; + deployment: string[]; + envVars: string[]; + }; + generatedFilesPlan: { path: string; generator: string; description: string }[]; + reviewerNotes: { severity: "info" | "warning" | "error"; agent: string; note: string }[]; +} + +export async function generateProject(prompt: string, projectName?: string): Promise { + const res = await fetch(`${API_BASE}/generate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prompt, ...(projectName ? { projectName } : {}) }), + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error((body as { error?: string }).error ?? `Request failed (${res.status})`); + } + + return res.json() as Promise; +} + +export async function getJob(jobId: string): Promise { + const res = await fetch(`${API_BASE}/jobs/${jobId}`); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error((body as { error?: string }).error ?? `Request failed (${res.status})`); + } + + return res.json() as Promise; +} + +export async function getRuntime(): Promise { + const res = await fetch(`${API_BASE}/runtime`); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error((body as { error?: string }).error ?? `Request failed (${res.status})`); + } + + return res.json() as Promise; +} diff --git a/apps/web/src/lib/mock-data.ts b/apps/web/src/lib/mock-data.ts new file mode 100644 index 0000000..3c0f098 --- /dev/null +++ b/apps/web/src/lib/mock-data.ts @@ -0,0 +1,217 @@ +import type { Blueprint } from "./api"; + +export const MOCK_BLUEPRINT: Blueprint = { + projectName: "invoice-saas", + generatedAt: new Date().toISOString(), + stack: { + frontend: "react", + backend: "express", + database: "postgres", + auth: "jwt", + hosting: "docker + railway", + packageManager: "bun", + monorepo: true, + }, + folderStructure: [ + { path: "apps", type: "dir", description: "Application packages" }, + { path: "apps/web", type: "dir", description: "React frontend" }, + { path: "apps/web/src", type: "dir" }, + { path: "apps/web/src/pages", type: "dir" }, + { path: "apps/web/src/pages/Dashboard.tsx", type: "file", description: "Main dashboard view" }, + { path: "apps/web/src/pages/Invoices.tsx", type: "file", description: "Invoice list & creation" }, + { path: "apps/web/src/pages/Login.tsx", type: "file", description: "Auth login page" }, + { path: "apps/api", type: "dir", description: "Express backend" }, + { path: "apps/api/src", type: "dir" }, + { path: "apps/api/src/routes", type: "dir" }, + { path: "apps/api/src/routes/invoices.ts", type: "file", description: "Invoice CRUD routes" }, + { path: "apps/api/src/routes/auth.ts", type: "file", description: "Auth endpoints" }, + { path: "apps/api/src/models", type: "dir" }, + { path: "apps/api/src/models/User.ts", type: "file" }, + { path: "apps/api/src/models/Invoice.ts", type: "file" }, + { path: "packages", type: "dir" }, + { path: "packages/shared", type: "dir", description: "Shared types & utils" }, + { path: "docker-compose.yml", type: "file", description: "Docker orchestration" }, + { path: "Dockerfile", type: "file", description: "Production container" }, + { path: ".github/workflows/ci.yml", type: "file", description: "CI pipeline" }, + ], + entities: [ + { + name: "User", + tableName: "users", + fields: [ + { name: "id", type: "uuid", nullable: false, unique: true }, + { name: "email", type: "varchar(255)", nullable: false, unique: true }, + { name: "passwordHash", type: "text", nullable: false }, + { name: "fullName", type: "varchar(100)", nullable: false }, + { name: "role", type: "enum('admin','user')", nullable: false }, + { name: "createdAt", type: "timestamp", nullable: false }, + ], + indexes: ["idx_users_email"], + }, + { + name: "Invoice", + tableName: "invoices", + fields: [ + { name: "id", type: "uuid", nullable: false, unique: true }, + { name: "userId", type: "uuid", nullable: false, foreignKey: "users.id" }, + { name: "clientName", type: "varchar(200)", nullable: false }, + { name: "amount", type: "decimal(10,2)", nullable: false }, + { name: "currency", type: "varchar(3)", nullable: false }, + { name: "status", type: "enum('draft','sent','paid','overdue')", nullable: false }, + { name: "dueDate", type: "date", nullable: false }, + { name: "createdAt", type: "timestamp", nullable: false }, + ], + indexes: ["idx_invoices_user_id", "idx_invoices_status"], + }, + { + name: "Payment", + tableName: "payments", + fields: [ + { name: "id", type: "uuid", nullable: false, unique: true }, + { name: "invoiceId", type: "uuid", nullable: false, foreignKey: "invoices.id" }, + { name: "stripePaymentId", type: "varchar(255)", nullable: false }, + { name: "amount", type: "decimal(10,2)", nullable: false }, + { name: "paidAt", type: "timestamp", nullable: false }, + ], + }, + ], + relationships: [ + { from: "User", to: "Invoice", type: "one-to-many", description: "A user can have many invoices" }, + { from: "Invoice", to: "Payment", type: "one-to-many", description: "An invoice can have multiple payments" }, + ], + routePlan: [ + { method: "POST", path: "/api/auth/register", description: "Register a new user", auth: false, requestBody: "{ email, password, fullName }", responseType: "{ user, token }" }, + { method: "POST", path: "/api/auth/login", description: "Login and get JWT", auth: false, requestBody: "{ email, password }", responseType: "{ token }" }, + { method: "GET", path: "/api/invoices", description: "List user invoices", auth: true, responseType: "Invoice[]" }, + { method: "POST", path: "/api/invoices", description: "Create a new invoice", auth: true, requestBody: "{ clientName, amount, currency, dueDate }", responseType: "Invoice" }, + { method: "GET", path: "/api/invoices/:id", description: "Get invoice detail", auth: true, responseType: "Invoice" }, + { method: "PATCH", path: "/api/invoices/:id", description: "Update invoice status", auth: true, requestBody: "{ status }", responseType: "Invoice" }, + { method: "DELETE", path: "/api/invoices/:id", description: "Delete a draft invoice", auth: true, responseType: "{ success: boolean }" }, + { method: "POST", path: "/api/invoices/:id/pay", description: "Process Stripe payment", auth: true, requestBody: "{ paymentMethodId }", responseType: "Payment" }, + { method: "GET", path: "/api/dashboard/stats", description: "Dashboard summary stats", auth: true, responseType: "{ totalRevenue, pendingInvoices, paidCount }" }, + ], + frontendPages: [ + { route: "/login", name: "Login", components: ["LoginForm", "AuthLayout"], auth: false, description: "User authentication page" }, + { route: "/dashboard", name: "Dashboard", components: ["StatsCards", "RevenueChart", "RecentInvoices"], auth: true, description: "Overview with revenue stats and recent activity" }, + { route: "/invoices", name: "Invoices", components: ["InvoiceTable", "FilterBar", "CreateInvoiceModal"], auth: true, description: "Invoice list with filters and creation" }, + { route: "/invoices/:id", name: "Invoice Detail", components: ["InvoiceHeader", "LineItems", "PaymentHistory", "PayButton"], auth: true, description: "Single invoice view with payment" }, + { route: "/settings", name: "Settings", components: ["ProfileForm", "BillingInfo"], auth: true, description: "User profile and billing settings" }, + ], + infraPlan: { + ci: ["lint", "typecheck", "test", "build"], + docker: true, + deployment: ["Railway (API)", "Vercel (Web)", "Neon (Postgres)"], + envVars: ["DATABASE_URL", "JWT_SECRET", "STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET"], + }, + generatedFilesPlan: [ + { path: "docker-compose.yml", generator: "devops", description: "Local dev with Postgres + API" }, + { path: "Dockerfile", generator: "devops", description: "Multi-stage production build" }, + { path: ".github/workflows/ci.yml", generator: "devops", description: "GitHub Actions CI pipeline" }, + { path: "apps/api/prisma/schema.prisma", generator: "schema", description: "Prisma schema with all entities" }, + ], + reviewerNotes: [ + { severity: "info", agent: "reviewer", note: "Consider adding rate limiting to auth endpoints to prevent brute force attacks." }, + { severity: "warning", agent: "reviewer", note: "Stripe webhook endpoint should verify webhook signatures to prevent spoofed events." }, + { severity: "info", agent: "reviewer", note: "Add pagination to GET /api/invoices for users with large invoice volumes." }, + { severity: "error", agent: "reviewer", note: "Missing CORS configuration โ€” frontend and API are on different origins in production." }, + ], +}; + +// โ”€โ”€โ”€ Demo simulation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +interface DemoSSEEvent { + type: string; + jobId: string; + timestamp: string; + agent?: string; + payload: Record; +} + +const AGENT_SEQUENCE = ["planner", "schema", "api", "frontend", "devops", "reviewer"] as const; + +export function runDemoSimulation( + jobId: string, + onEvent: (event: DemoSSEEvent) => void, + onComplete: () => void, +): () => void { + const timers: ReturnType[] = []; + let cancelled = false; + + function schedule(fn: () => void, delayMs: number) { + const t = setTimeout(() => { + if (!cancelled) fn(); + }, delayMs); + timers.push(t); + } + + // job_created at t=0 + schedule(() => { + onEvent({ + type: "job_created", + jobId, + timestamp: new Date().toISOString(), + payload: { prompt: "Demo project", projectName: "invoice-saas" }, + }); + }, 200); + + let cumulativeDelay = 500; + + AGENT_SEQUENCE.forEach((agent, i) => { + const startDelay = cumulativeDelay; + const runDuration = 1200 + Math.random() * 1800; // 1.2s โ€“ 3s per agent + const endDelay = startDelay + runDuration; + + // agent_started + schedule(() => { + onEvent({ + type: "agent_started", + jobId, + timestamp: new Date().toISOString(), + agent, + payload: {}, + }); + }, startDelay); + + // agent_completed + schedule(() => { + onEvent({ + type: "agent_completed", + jobId, + timestamp: new Date().toISOString(), + agent, + payload: { + durationMs: Math.round(runDuration), + cached: false, + inputTokens: 800 + Math.floor(Math.random() * 400), + outputTokens: 400 + Math.floor(Math.random() * 300), + totalTokens: 1200 + Math.floor(Math.random() * 700), + tokensUsed: 1200 + Math.floor(Math.random() * 700), + estimatedInputTokens: 900, + compressionPasses: 0, + providerInputTokens: 800, + providerOutputTokens: 400, + model: "meta-llama/llama-3.1-8b-instruct", + }, + }); + }, endDelay); + + cumulativeDelay = endDelay + 300; // small gap between agents + }); + + // job_completed + schedule(() => { + onEvent({ + type: "job_completed", + jobId, + timestamp: new Date().toISOString(), + payload: { durationMs: Math.round(cumulativeDelay) }, + }); + onComplete(); + }, cumulativeDelay + 200); + + // Return a cancel function + return () => { + cancelled = true; + timers.forEach(clearTimeout); + }; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..7cc597b --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { App } from "./App"; +import "./index.css"; + +const rootEl = document.getElementById("root"); +if (!rootEl) throw new Error("Missing #root element"); + +ReactDOM.createRoot(rootEl).render( + + + , +); diff --git a/apps/web/src/pages/Home.tsx b/apps/web/src/pages/Home.tsx new file mode 100644 index 0000000..6a67e52 --- /dev/null +++ b/apps/web/src/pages/Home.tsx @@ -0,0 +1,304 @@ +import React, { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Button, Card, useToast } from "@stackforge/ui"; +import { generateProject, getRuntime, type RuntimeResponse } from "../lib/api"; + +const FRONTEND_OPTIONS = ["react", "vue", "svelte"] as const; +const BACKEND_OPTIONS = ["express", "fastify", "hono"] as const; +const DATABASE_OPTIONS = ["postgres", "mysql", "mongodb"] as const; + +export function Home() { + const navigate = useNavigate(); + const { addToast } = useToast(); + + const [prompt, setPrompt] = useState(""); + const [frontend, setFrontend] = useState("react"); + const [backend, setBackend] = useState("express"); + const [database, setDatabase] = useState("postgres"); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [runtime, setRuntime] = useState(null); + const [runtimeError, setRuntimeError] = useState(null); + + const isValid = prompt.trim().length >= 10; + const canGenerate = isValid && runtime?.ready !== false; + + useEffect(() => { + getRuntime() + .then((res) => { + setRuntime(res); + setRuntimeError(null); + }) + .catch((err) => { + setRuntime(null); + setRuntimeError(err instanceof Error ? err.message : "Failed to load runtime status"); + }); + }, []); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!canGenerate || loading) return; + + setError(null); + setLoading(true); + + const fullPrompt = `${prompt.trim()}\n\nStack preferences: Frontend: ${frontend}, Backend: ${backend}, Database: ${database}`; + + try { + const res = await generateProject(fullPrompt); + addToast("success", `Project "${res.projectName}" queued!`); + navigate(`/jobs/${res.jobId}`); + } catch (err) { + const msg = err instanceof Error ? err.message : "Something went wrong"; + setError(msg); + addToast("error", msg); + } finally { + setLoading(false); + } + } + + return ( +
+ {/* Hero */} +
+

+ Describe your idea. +
+ + We'll build the blueprint. + +

+

+ StackForge uses AI agents to plan your full-stack project โ€” schema, APIs, + frontend pages, DevOps โ€” all generated in seconds. +

+
+ + {/* Form */} + +
+ {/* Prompt textarea */} + +