Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions app/api/projects/[projectId]/spec/markdown/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { auth } from "@clerk/nextjs/server";

import { getAccessibleProject, getCurrentIdentity } from "@/lib/project-access";
import { toSpecFileName } from "@/lib/spec/spec-file-name";
import { readSpecMarkdown } from "@/lib/spec/spec-store";

type SpecMarkdownRouteContext = { params: Promise<{ projectId: string }> };

/** Downloads the project's latest spec as a Markdown file. */
export async function GET(_request: Request, context: SpecMarkdownRouteContext) {
const { userId } = await auth();
if (!userId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const { projectId } = await context.params;
const { primaryEmail } = await getCurrentIdentity();
const project = await getAccessibleProject(userId, primaryEmail, projectId);
if (!project) {
return Response.json({ error: "Forbidden" }, { status: 403 });
}

const markdown = await readSpecMarkdown(project.id);
if (markdown === null) {
return Response.json({ error: "No spec has been generated yet" }, { status: 404 });
}

return new Response(markdown, {
status: 200,
headers: {
"Content-Type": "text/markdown; charset=utf-8",
"Content-Disposition": `attachment; filename="${toSpecFileName(project.name)}"`,
"Cache-Control": "no-store",
},
});
}
58 changes: 58 additions & 0 deletions app/api/projects/[projectId]/spec/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { auth } from "@clerk/nextjs/server";

import { getAccessibleProject, getCurrentIdentity } from "@/lib/project-access";
import { getSpecStatus, startSpecRun } from "@/lib/spec/spec-store";

type SpecRouteContext = { params: Promise<{ projectId: string }> };

async function resolveProject(projectId: string) {
const { userId } = await auth();
if (!userId) {
return { error: Response.json({ error: "Unauthorized" }, { status: 401 }) } as const;
}

const { primaryEmail } = await getCurrentIdentity();
const project = await getAccessibleProject(userId, primaryEmail, projectId);
if (!project) {
return { error: Response.json({ error: "Forbidden" }, { status: 403 }) } as const;
}

return { project, userId } as const;
}

/**
* The project's spec status: the stored spec (if any), a pending run, or why
* the latest run failed. A finished run is stored before responding.
*/
export async function GET(_request: Request, context: SpecRouteContext) {
const { projectId } = await context.params;
const result = await resolveProject(projectId);
if ("error" in result) {
return result.error;
}

return Response.json(await getSpecStatus(result.project.id));
}

/** Starts generating a spec from the current canvas. Owners and collaborators can generate. */
export async function POST(_request: Request, context: SpecRouteContext) {
const { projectId } = await context.params;
const result = await resolveProject(projectId);
if ("error" in result) {
return result.error;
}

const started = await startSpecRun({
projectId: result.project.id,
userId: result.userId,
projectName: result.project.name,
});

if (started.ok) {
return Response.json({ runId: started.runId }, { status: 202 });
}
if (started.status === 409) {
return Response.json({ error: started.error, runId: started.runId }, { status: 409 });
}
return Response.json({ error: started.error }, { status: started.status });
}
40 changes: 6 additions & 34 deletions components/editor/ai-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

import { FormEvent, KeyboardEvent, useEffect, useRef, useState } from "react";
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
import { ArrowRight, ChevronDown, Download, FileText, History, Loader2, Plus, Sparkle, X } from "lucide-react";
import { ArrowRight, ChevronDown, History, Loader2, Plus, Sparkle, X } from "lucide-react";

import { PlanCard, QuestionsCard, ResultCard } from "@/components/editor/ai-chat-cards";
import { AiSessionHistory } from "@/components/editor/ai-session-history";
import { SpecPanel } from "@/components/editor/spec-panel";
import { useAiSession, type AiChatMessage } from "@/hooks/use-ai-session";
import { useSpecGenerator } from "@/hooks/use-spec-generator";
import {
readAnswersPayload,
readPlanPayload,
Expand Down Expand Up @@ -77,6 +79,8 @@ export function AiSidebar({ open, onClose, projectId }: AiSidebarProps) {
removeSession,
retryLoadSession,
} = useAiSession(projectId);
// Lives here rather than in the Specs panel so a run keeps being followed (and downloads) while another tab is open.
const specGenerator = useSpecGenerator(projectId);

const transcriptEndRef = useRef<HTMLDivElement>(null);

Expand Down Expand Up @@ -394,39 +398,7 @@ export function AiSidebar({ open, onClose, projectId }: AiSidebarProps) {
</TabsPrimitive.Panel>

<TabsPrimitive.Panel value="specs" className="min-h-0 flex-1 overflow-y-auto px-5 py-5 outline-none">
<div className="flex flex-col gap-4">
<button
type="button"
className={cn(
"flex h-11 w-full cursor-pointer items-center justify-center rounded-paper border border-ink bg-ink",
"font-brand text-sm font-semibold text-paper-cream shadow-flat",
"active:translate-y-px active:shadow-none",
focusClass,
)}
>
Generate Spec
</button>

<div className="space-y-4 rounded-paper border border-ink/20 bg-paper-bright p-4">
<div className="flex items-start gap-3">
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-ink-soft" />
<div>
<p className="font-brand text-sm font-semibold text-ink">Realtime Chat Platform Spec</p>
<p className="mt-1 font-brand text-sm text-ink-soft">
Includes service boundaries, event flow, storage strategy, and deployment notes.
</p>
</div>
</div>
<button
type="button"
disabled
className="flex h-10 w-full cursor-not-allowed items-center justify-center gap-2 rounded-paper border border-ink bg-transparent font-brand text-sm font-medium text-ink opacity-40"
>
<Download className="h-4 w-4" />
Download
</button>
</div>
</div>
<SpecPanel {...specGenerator} />
</TabsPrimitive.Panel>
</TabsPrimitive.Root>
</aside>
Expand Down
100 changes: 100 additions & 0 deletions components/editor/spec-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"use client";

import { Download, FileText, Loader2 } from "lucide-react";

import type { UseSpecGeneratorResult } from "@/hooks/use-spec-generator";
import { cn } from "@/lib/utils";

const focusClass = "outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink/60";

const kickerClass = "font-mono text-chrome tracking-chrome text-ink-soft uppercase";

const dateFormatter = new Intl.DateTimeFormat("en", { dateStyle: "medium", timeStyle: "short" });

function plural(count: number, noun: string): string {
return `${count} ${noun}${count === 1 ? "" : "s"}`;
}

/** The AI sidebar's Specs tab: generate a Markdown spec from the canvas and download it. */
export function SpecPanel({ spec, isLoading, isRunning, statusText, error, downloadHref, generate }: UseSpecGeneratorResult) {
return (
<div className="flex flex-col gap-4">
<p className="font-brand text-sm text-ink-soft">
Turn the current canvas into a Markdown technical spec: diagrams, key decisions, components, connections, and
risks.
</p>

<button
type="button"
onClick={generate}
disabled={isRunning}
aria-busy={isRunning || undefined}
className={cn(
"flex h-11 w-full cursor-pointer items-center justify-center gap-2 rounded-paper border border-ink bg-ink",
"font-brand text-sm font-semibold text-paper-cream shadow-flat",
"active:translate-y-px active:shadow-none disabled:cursor-not-allowed disabled:opacity-70 disabled:active:translate-y-0",
focusClass,
)}
>
{isRunning ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" /> : null}
{isRunning ? "Generating spec…" : spec ? "Regenerate spec" : "Generate spec"}
</button>

{isRunning ? (
<p className={cn(kickerClass, "flex items-center gap-2")} aria-live="polite">
{statusText}
</p>
) : (
<p className={kickerClass}>Downloads automatically when ready</p>
)}

{error ? (
<div
role="alert"
className="rounded-paper border border-ink/20 border-l-2 border-l-paper-pin-red bg-paper-bright px-3.5 py-3 font-brand text-sm text-ink"
>
{error}
</div>
) : null}

{isLoading ? (
<p className={cn(kickerClass, "flex items-center gap-2")} aria-live="polite">
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
Loading spec…
</p>
) : spec ? (
<div className="space-y-3 rounded-paper border border-ink/20 bg-paper-bright p-4">
<div className="flex items-start gap-3">
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-ink-soft" aria-hidden="true" />
<div className="min-w-0">
<p className="font-brand text-sm font-semibold text-ink">Technical spec</p>
<p className={cn(kickerClass, "mt-1")}>Generated {dateFormatter.format(new Date(spec.generatedAt))}</p>
{spec.stats ? (
<p className="mt-1 font-brand text-sm text-ink-soft">
{plural(spec.stats.components, "component")} · {plural(spec.stats.connections, "connection")} ·{" "}
{plural(spec.stats.diagrams, "diagram")} · {plural(spec.stats.decisions, "key decision")}
</p>
) : null}
</div>
</div>
<a
href={downloadHref}
download
className={cn(
"flex h-10 w-full items-center justify-center gap-2 rounded-paper border border-ink bg-transparent",
"font-brand text-sm font-medium text-ink transition-colors hover:bg-ink hover:text-paper-cream",
focusClass,
)}
>
<Download className="h-4 w-4" aria-hidden="true" />
Download .md
</a>
</div>
) : (
<p className="rounded-paper border border-dashed border-ink/25 px-3.5 py-4 font-brand text-sm text-ink-soft">
No spec yet. Generate one from the current canvas.
</p>
)}
</div>
);
}
20 changes: 19 additions & 1 deletion context/architecture-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,23 @@
failure messages with raw provider errors kept in server logs.
- **Model:** `GOOGLE_GENERATIVE_AI_MODEL`, with a thinking level (Gemini 3+) or thinking budget (Gemini 2.x) per step.

## Spec Agent

- **One spec = one Trigger.dev run** (`spec-agent`). `POST /api/projects/[projectId]/spec` starts it (409 with the
pending run id if one is already running) and records a `TaskRun` with `kind: SPEC`.
- **The run** reads the room read-only, builds the spec graph in code (`lib/spec/spec-graph.ts`: groups of connected
components, refs, notes), makes one structured model call for the prose (`lib/spec/spec-engine.ts`, validated and
sanitised against the graph), and renders the Markdown in code (`lib/spec/render-markdown.ts`, Mermaid from
`lib/spec/mermaid.ts`). It aborts with a user-facing message on an empty canvas or more than 200 components.
- **Stored on read.** `GET /api/projects/[projectId]/spec` retrieves the latest spec run; when it has finished and is
not yet stored, the Markdown is uploaded to private Vercel Blob storage and `Project.specMdPath`, `specRunId`,
`specGeneratedAt`, and `specStats` are updated in a write guarded on the run id. The previous blob is deleted.
`GET /api/projects/[projectId]/spec/markdown` serves the file as a download.
- **Recorded decisions** from the requesting user's unexpired AI sessions (their `RESULT` payloads) are passed to the
run so the spec can highlight them.
- Model configuration, thinking settings, schema retry, and friendly failure messages are shared with the design agent
(`lib/ai/model.ts`, `lib/ai/run-failure.ts`).

## Auth and Access Model

- Every user signs in via Clerk to establish identity.
Expand All @@ -61,7 +78,8 @@
3. API routes must validate authentication and resource ownership before any mutation.
4. Foundation components (`components/ui/*`) must remain generic and default.
5. Generated canvas content reaches clients only through Liveblocks, never through an HTTP response.
6. The design task never writes to the database or blob storage; the session routes store AI turns.
6. Background AI tasks (design and spec agents) never write to the database or blob storage; the session and spec
routes store their results.
7. A project's id is its Liveblocks room id.
8. Run tokens are minted only by `POST /api/ai/design/token`, after a `TaskRun` ownership check.
9. Model output is validated (zod) before it is stored or drawn.
Expand Down
Loading
Loading