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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ curl -X POST http://localhost:3000/api/trigger/hello \

Task source files live in `src/trigger/` and Trigger.dev config is in `trigger.config.ts`.

## AI design agent (local)

1. Set `GOOGLE_GENERATIVE_AI_API_KEY` in `.env.local`, and optionally `GOOGLE_GENERATIVE_AI_MODEL`
(default `gemini-3.5-flash`; Gemini 2.x and 3.x models both work).
2. Run `pnpm prisma migrate dev` so the `AiSession` and `AiMessage` tables exist.
3. Run `pnpm dev` and `pnpm trigger:dev` together. The worker reads `.env.local` only when it starts, so restart it
after changing the model or any key.
4. In production, set `CRON_SECRET` in the Vercel project so the daily AI session cleanup in `vercel.json` can
authenticate.

`docs.md` → **AI** explains sessions, turns, and how generation works.

## Getting Started

First, run the development server:
Expand Down
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
14 changes: 3 additions & 11 deletions components/editor/canvas-node.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
} from "@xyflow/react";

import { getUsedSides } from "@/lib/canvas-connections";
import { resolveNodeFill, TEXT_NODE_SHAPE } from "@/types/canvas";
import { resolveNodeFill, SHAPE_KICKERS, TEXT_NODE_SHAPE } from "@/types/canvas";
import type { CanvasNodeData, CanvasNodeShape } from "@/types/canvas";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -92,16 +92,8 @@ const STROKE_WIDTH_ACTIVE = 2.5;
const SHAPE_SHADOW_FILTER = "drop-shadow(var(--shadow-flat))";
export const DEFAULT_NODE_FONT_SIZE = 11;

/** Default mono kicker above the label, describing what the shape usually stands for. */
export const SHAPE_KICKERS: Record<CanvasNodeShape, string> = {
rectangle: "Service",
circle: "Event",
diamond: "Decision",
pill: "Queue",
cylinder: "Database",
hexagon: "External",
text: "Note",
};
// Defined in `types/canvas.ts`; re-exported for the control bar, which imports it from here.
export { SHAPE_KICKERS };

// ---------------------------------------------------------------------------
// Label block — mono kicker + Archivo label
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>
);
}
22 changes: 4 additions & 18 deletions components/editor/starter-templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ import { assignEdgeHandles } from "@/lib/canvas-connections";
import {
CANVAS_EDGE_TYPE,
CANVAS_NODE_TYPE,
NODE_FILLS,
getRoleFill,
SHAPE_DEFAULTS,
type CanvasArrowDirection,
type CanvasEdge,
type CanvasEdgeData,
type CanvasNode,
type CanvasShape,
type NodeFill,
type NodeRole,
} from "@/types/canvas";

export interface CanvasTemplate {
Expand Down Expand Up @@ -46,28 +46,14 @@ export interface CanvasTemplate {
* has more than four edges. Handles are assigned by `assignEdgeHandles`.
*/

type TemplateRole = "entry" | "compute" | "messaging" | "data" | "output";

const ROLE_FILLS: Record<TemplateRole, NodeFill["id"]> = {
entry: "blue",
compute: "paper",
messaging: "coral",
data: "amber",
output: "sage",
};

function getFillValue(fillId: NodeFill["id"]) {
return (NODE_FILLS.find((fill) => fill.id === fillId) ?? NODE_FILLS[0]).value;
}

/** `cx` / `cy` are the node's centre, so shapes of different sizes on one row or column line up. */
export function createTemplateNode(
id: string,
label: string,
cx: number,
cy: number,
shape: CanvasShape,
role: TemplateRole,
role: NodeRole,
kicker: string,
): CanvasNode {
const { width, height } = SHAPE_DEFAULTS[shape];
Expand All @@ -81,7 +67,7 @@ export function createTemplateNode(
label,
shape,
kicker,
color: getFillValue(ROLE_FILLS[role]),
color: getRoleFill(role).value,
},
};
}
Expand Down
Loading
Loading