diff --git a/README.md b/README.md index 7bf5e7a..54c26f2 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/app/api/projects/[projectId]/spec/markdown/route.ts b/app/api/projects/[projectId]/spec/markdown/route.ts new file mode 100644 index 0000000..e8c5a3f --- /dev/null +++ b/app/api/projects/[projectId]/spec/markdown/route.ts @@ -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", + }, + }); +} diff --git a/app/api/projects/[projectId]/spec/route.ts b/app/api/projects/[projectId]/spec/route.ts new file mode 100644 index 0000000..df6ed8f --- /dev/null +++ b/app/api/projects/[projectId]/spec/route.ts @@ -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 }); +} diff --git a/components/editor/ai-sidebar.tsx b/components/editor/ai-sidebar.tsx index b77f231..254bbbe 100644 --- a/components/editor/ai-sidebar.tsx +++ b/components/editor/ai-sidebar.tsx @@ -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, @@ -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(null); @@ -394,39 +398,7 @@ export function AiSidebar({ open, onClose, projectId }: AiSidebarProps) { -
- - -
-
- -
-

Realtime Chat Platform Spec

-

- Includes service boundaries, event flow, storage strategy, and deployment notes. -

-
-
- -
-
+
diff --git a/components/editor/canvas-node.tsx b/components/editor/canvas-node.tsx index a1b3931..07e87cf 100644 --- a/components/editor/canvas-node.tsx +++ b/components/editor/canvas-node.tsx @@ -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"; // --------------------------------------------------------------------------- @@ -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 = { - 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 diff --git a/components/editor/spec-panel.tsx b/components/editor/spec-panel.tsx new file mode 100644 index 0000000..2f67b53 --- /dev/null +++ b/components/editor/spec-panel.tsx @@ -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 ( +
+

+ Turn the current canvas into a Markdown technical spec: diagrams, key decisions, components, connections, and + risks. +

+ + + + {isRunning ? ( +

+ {statusText} +

+ ) : ( +

Downloads automatically when ready

+ )} + + {error ? ( +
+ {error} +
+ ) : null} + + {isLoading ? ( +

+

+ ) : spec ? ( +
+
+
+ + +
+ ) : ( +

+ No spec yet. Generate one from the current canvas. +

+ )} +
+ ); +} diff --git a/components/editor/starter-templates.ts b/components/editor/starter-templates.ts index bf7d994..1e86fab 100644 --- a/components/editor/starter-templates.ts +++ b/components/editor/starter-templates.ts @@ -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 { @@ -46,20 +46,6 @@ export interface CanvasTemplate { * has more than four edges. Handles are assigned by `assignEdgeHandles`. */ -type TemplateRole = "entry" | "compute" | "messaging" | "data" | "output"; - -const ROLE_FILLS: Record = { - 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, @@ -67,7 +53,7 @@ export function createTemplateNode( cx: number, cy: number, shape: CanvasShape, - role: TemplateRole, + role: NodeRole, kicker: string, ): CanvasNode { const { width, height } = SHAPE_DEFAULTS[shape]; @@ -81,7 +67,7 @@ export function createTemplateNode( label, shape, kicker, - color: getFillValue(ROLE_FILLS[role]), + color: getRoleFill(role).value, }, }; } diff --git a/context/architecture-context.md b/context/architecture-context.md index 44bb7b4..ab94ec9 100644 --- a/context/architecture-context.md +++ b/context/architecture-context.md @@ -28,6 +28,43 @@ removed by a daily Vercel Cron (`/api/cron/ai-sessions/cleanup`, `CRON_SECRET`). Reads ignore expired rows, so expiry holds even before cleanup runs. Deleting a project cascades to its sessions. +## AI Design Agent + +- **One turn = one Trigger.dev run** (`design-agent`). Routes validate input and access, trigger the run, and + return `202`; they never call the model. +- **A turn** reads the Liveblocks room read-only and summarises it (`lib/ai/canvas-summary.ts`), then analyzes the + message (updates the requirements brief and decides ask / plan / generate), drafts or revises a plan, or + generates the diagram for the approved plan, validates it (`lib/ai/graph-validation.ts`), makes at most one repair + call, and writes it with `mutateFlow`. +- **Code enforces the rules** the model is asked to follow (question rounds, skip, no generating without a plan) and + fills in everything render-related: node and edge types, sizes, fills from `NODE_ROLES`, dashed async edges, + connection points, and layout. Existing components are referenced by `ex-N` refs; generated duplicates fold into + existing nodes. +- **Turns settle on read.** The turns route stores a `PENDING` reply with the run id; reading the session retrieves + the run, validates its output with zod, and stores `QUESTIONS`, `PLAN`, or `RESULT`. The task has no database + access and never writes blobs. +- **Reliability:** per-call timeouts (analyze 90s, plan 150s, generate 120s), task `maxDuration` 300s, a single task + attempt, one retry per call on a schema mismatch, list limits trimmed in code instead of `maxItems`, and friendly + 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. @@ -40,4 +77,10 @@ 2. Metadata belongs in the database; large assets must be offloaded to blob/file storage. 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. 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. diff --git a/context/progress-tracker.md b/context/progress-tracker.md index ba686c9..91e5f6c 100644 --- a/context/progress-tracker.md +++ b/context/progress-tracker.md @@ -10,9 +10,14 @@ Update this file whenever the current phase, active feature, or implementation s - Persistent, multi-turn AI design sessions (plan: storage → wire sessions → turn engine → UI cards → generation quality → docs). Steps 1 (storage, PR #21), 2 (sessions in the sidebar, PR #22) and 3 (clarify → plan → generate - turn engine, PR #23) are done; step 4 (question/plan/result cards in the sidebar) is built on - `feat/ai-agent-cards` and awaiting a live run; next is step 5, generation quality (canvas context, kickers, - fills, edge styles, validate-and-repair). + turn engine, PR #23) and 4 (question/plan/result cards, PR #25) are done; step 5 (generation quality: canvas + context, roles and kickers, async edges, validate-and-repair, PR #26) and step 6 (docs, PR #27) are done. +- Generate Spec (unit G1) was reviewed in PR #28 and merged into its stacked base (`feat/ai-sessions-docs`). +- Repository note (2026-09-16): #25–#28 had been merged into stacked base branches instead of `main`, whose content + was exactly step 3 (squash commits of #21–#23). #29 (step 4, `feat/ai-design-turn-engine` → `main`) conflicted with + that squash history in 6 files; it was resolved with a merge commit that kept the branch content unchanged (no force + push) and merged. Steps 5–6, the spec feature, and this tracker then went to `main` through a sync PR built the same + way, so `main` holds all of the AI sessions and spec work. - After that: the Specs tab (Generate Spec + automatic Markdown download), which remains inert. ## Completed @@ -1373,4 +1378,166 @@ Update this file whenever the current phase, active feature, or implementation s - DB turn checks (15) passed after the shared-reader refactor - Live flow on `gemini-3.5-flash-lite` through the Trigger worker passed (message → questions → skip → plan → generate; see the step 3 entry), including QUESTIONS and PLAN payloads that now store the agent's reply. - - Not yet verified: clicking through the cards in a signed-in browser (chips, typed answers, send, skip, draw). + - Browser check by the user (2026-09-16), both working: a detailed prompt with every question answered, and a + low-detail prompt that skipped the questions and generated a good design. +- AI sessions, step 5: generation quality — canvas context, roles and kickers, async edges, validate and repair + (2026-09-16, branch `feat/ai-generation-quality`): + - Shared node roles: `types/canvas.ts` now has `NODE_ROLES` (entry, compute, messaging, data, output), + `NODE_ROLE_FILLS`, and `getRoleFill`, taken from the starter templates' private role map, which now uses them. + `SHAPE_KICKERS` moved here from `canvas-node.tsx` (re-exported there for the control bar) so server code can read + it without importing React Flow. + - `lib/ai/canvas-summary.ts`: `summarizeCanvas` turns the room into components with refs (`ex-1`…), kind (kicker or + the shape default), and used connection points; connections (label, async); and text notes. Capped at 60 + components, 80 connections, 10 notes. `formatCanvasSummary` renders it for prompts. + - `lib/design-generation.ts`: the generation schema replaces the legacy dark `colorId` with `role` and `kicker`, and + edges gain `delivery: sync | async`. Edge endpoints may be a new node id or an existing ref. `buildCanvasGraph` + takes the existing canvas: refs resolve to existing node ids; a generated node that repeats an existing label is + not drawn and its edges attach to the existing node; repeated labels among new nodes fold into the first; + unknown endpoints, self loops, and repeated pairs (either direction) are dropped; fills come from the role; async + edges are dashed; connection points are assigned with existing edges counted. `MAX_GRAPH_NODES` / + `MAX_GRAPH_EDGES` are exported. + - `lib/ai/graph-validation.ts`: `validateDesignGraph` reports, in messages written for the model: over-limit counts, + duplicate ids, new ids shaped like refs, components already on the canvas, duplicate labels, unknown references, + self loops, repeated connections, components over 4 connections (existing ones included), unconnected new + components, and plan components missing from the diagram. + - `lib/ai/prompts.ts`: the canvas summary goes into analyze (treat requests as changes to the existing design), + plan (list only components to add, refer to existing ones by name), and generate (connect by ref, roles, + kickers, async delivery, every new component connected). New `buildRepairPrompt`. + - `lib/ai/design-agent-engine.ts`: `analyzeTurn`, `draftPlan`, and `generateDesignGraph` take the canvas summary. + `generateDesignGraph` validates the diagram and, when anything is wrong, makes one repair call with the issues + and the previous diagram, keeping whichever attempt has fewer issues (a failed repair keeps the first). It + returns `{ graph, issues, repairedIssues }`. + - `src/trigger/design-agent.ts`: reads the room read-only at the start of every turn and passes the summary to each + step. When writing, it uses the live room for layout and connection points and drops refs to components deleted + since the read. Repaired and remaining issues are logged. Comment updated: a turn makes at most three model calls. + - Validation checks: + - `pnpm typecheck` and `pnpm lint` passed + - Offline checks (36, no model calls): canvas summary (refs only for labelled drawn components, kinds, used points, + connections, notes, empty canvas, 60-component cap); every validation rule, including a clean graph with no + issues; `buildCanvasGraph` (existing duplicates and repeated labels not drawn, role fills, kickers, ref and + duplicate resolution, dropped unknown refs, self loops and repeated pairs, dashed async edges, handles on every + edge, new edges avoiding used sides, only an already-full node sharing a side, refs dropped without canvas + context); all 13 starter templates still on paper fills, entry still draft blue. + - Live extend run on `gemini-3.5-flash-lite` through the Trigger worker, on a throwaway room seeded with the + Microservices template (8 nodes, 8 edges): "Add a Redis cache in front of the user database, and a notification + service that consumes order events." → PLAN in 12.4s listing only the 3 new components (User Cache, Event + Broker, Notification Service) with 2 decisions → generate in 12.6s added 3 nodes (kickers Queue / Worker / + Cache, paper fills) and 4 edges: User Service → User Cache → User DB, and Order Service → Event Broker → + Notification Service, both order-event links dashed. Existing nodes untouched, no component redrawn, no + node forced to share a connection point, counts match the room. Test project, room, sessions, and TaskRuns + removed. 0 failures, 0 warnings. + - Follow-up from that run: the plan's flows read "User Service (ex-7) → User Cache → User DB (ex-8)" — the model + copied internal refs into user-facing plan text despite the prompt. `removeCanvasRefs` in + `design-agent-engine.ts` now strips parenthesised refs ("(ex-7)", "(ex-4, ex-5)") from the plan summary, + responsibilities, flows, decisions, and assumptions after drafting; other parentheses are left alone. Covered + by 3 more offline checks. +- AI sessions, step 6: documentation refresh (2026-09-16, branch `feat/ai-sessions-docs`, docs only, no code): + - `docs.md` is listed in `.gitignore` (and has never been committed), so its refresh below stays in the local + working copy by decision; the branch commits `README.md` and the context files only. + - `docs.md`: + - AI category rewritten: **AI design sessions** (flow diagram, guide through the questions / plan / result + cards, stages, code-enforced rules, the Microservices extend example from the live run, error messages), + **How generation works** (turn steps with files, output contract with roles / kickers / delivery / refs, + validation checks, models, thinking, timeouts, retries), **Sessions & storage** (models, limits, settle on + read, cleanup cron), **Run tracking & tokens** (example now `useAiSession` + turns route), and **Spec + generation** (still planned; notes the stored plan decisions). The planned "AI sessions" page and "Diagram + from prompt" are gone; the sidebar nav matches. + - Getting started: goal 4, core user flow rows 5–6, and the worked example describe clarify → plan → draw. + Quickstart adds Vercel Cron / `CRON_SECRET`, the AI session migrations, and restarting the worker after a + model change. + - Reference: API routes (session and turns routes and the cleanup cron replace `/api/ai/design`), data models + (`AiSession`, `AiMessage`, `Project.aiSessions`), canvas types (`SHAPE_KICKERS`, fills, roles, edge colours, + text nodes, legacy palette), hooks (`useAiSession`), environment variables (`CRON_SECRET`, model notes). + - Contributing: spec index rows S1–S6 with their PRs; stack (`generateText` + `Output.object`, Vercel Cron), + `lib/ai/` boundary, storage table row for AI chats, invariant 6 widened and invariant 9 added; the stale note + about `project-overview.md` saying "filesystem" removed. + - Roadmap: AI design sessions and chat sessions are `BETA`; known issues replace the fixed ones (no run + timeout, conversation lost on reload, "filesystem" wording) with current ones (adds only, private sessions, + free-tier quota, preview-model timeouts); five new architecture decisions; a 2026-09-15 → 16 history row. + - `context/architecture-context.md`: new "AI Design Agent" section (turn = run, turn steps, code-enforced rules, + settle on read, reliability, model config) and invariants 5–9. + - `context/project-overview.md`: canvas snapshots are in Vercel Blob (was "filesystem"); AI section adds the + chat cards and canvas-aware generation, and notes it does not edit or remove existing components. + - `context/ui-context.md`: the AI sidebar session bar, saved-chat list, and chat cards follow the paper conventions. + - `README.md`: "AI design agent (local)" setup — API key and model, migrations, running and restarting the worker, + `CRON_SECRET` in production. + - Validation: + - A grep for stale references (`/api/ai/design` outside the token route, `useDesignAgent`, `generateObject`, + "Diagram from prompt", "lost on reload", "filesystem") across `docs.md`, `README.md`, and the context files + found one: the Collaboration → Thinking indicator paragraph, now describing AI turns and linking to + "AI design sessions". The only other match is the intended legacy-palette row in Canvas types. + - A check of every in-doc anchor link in `docs.md` against its headings: all resolve after that fix. + - Section order after the splice: Getting started, Platform, AI, Authentication, Design system, Reference, + Contributing, Roadmap. + - No code changed, so lint, typecheck, and build were not re-run. +- Generate Spec, unit G1: Markdown technical spec from the canvas, with automatic download (2026-09-16, branch + `feat/generate-spec`, based on `feat/ai-sessions-docs` because GitHub `main` is missing #25 and #26): + - Data (migration `20260915195650_add_project_spec`, additive, applied): `TaskRunKind` enum (`DESIGN`, `SPEC`) and + `TaskRun.kind` (default `DESIGN`, so existing rows are design runs) with a `(projectId, kind, createdAt)` index; + `Project.specMdPath`, `specRunId`, `specGeneratedAt`, `specStats` (JSON). + - Shared refactors: `lib/ai/model.ts` (model id, thinking level/budget, `withSchemaRetry`, `modelIdOf`) moved out of + `design-agent-engine.ts`; `lib/ai/run-failure.ts` (`describeRunFailure` with generic, timeout and passthrough + messages) used by `session-turns.ts` and the spec store; `lib/canvas-room.ts` (`readRoomSnapshot`) used by both + tasks. No behaviour change for design turns. + - `lib/spec/spec-graph.ts`: `buildSpecGraph` — drawn, labelled components (cap 200, total kept), connections + between them (unknown endpoints and self loops dropped), groups of connected components via union-find in + reading order (`g1`…) plus a `standalone` group, component refs (`c1`…) in group then position order, kinds from + kicker or shape, and text notes attached to the nearest group within 600px (otherwise general notes). + - `lib/spec/mermaid.ts`: `renderGroupMermaid` — `flowchart TD`, one node per component with shape mapping (pill + `([ ])`, rectangle `[ ]`, cylinder `[( )]`, circle `(( ))`, diamond `{ }`, hexagon `{{ }}`), quoted labels with + `#`, `"`, `<`, `>`, `|` escaped, arrows for forward / backward (reversed) / bidirectional / none, dashed for + async and dotted edges, quoted edge labels. + - `lib/spec/spec-schema.ts`: model output schema (overview, goals, group name/purpose/flow, component + responsibilities, decisions with alternatives, trade-offs, component refs and source + `recorded | stated | inferred`, risks with mitigations, open questions); `sanitizeSpecContent` trims to list + limits, drops unknown groups/components/refs, and removes internal refs such as "(c1)" from prose (a live run + showed flow steps like "Web Client (c1) sends…"); run result and status types; stage names; abort messages. + - `lib/spec/spec-prompt.ts` + `spec-engine.ts`: one `generateText` + `Output.object` call with medium thinking, a + 180s timeout, and one schema retry. The prompt lists groups, refs, connections (direction, label, sync/async), + notes, and recorded decisions, and asks for 3–6 decisions from the diagram in addition to recorded ones (the + first live run returned only the recorded decision). + - `lib/spec/render-markdown.ts`: title and generation line, contents, 1 Overview (goals, general notes), 2 Key + decisions as `> [!IMPORTANT]` callouts (why, alternatives, trade-offs, components, source), 3 System diagrams + (per group: purpose, Mermaid, component table, flow, notes; "Standalone components"), 4 Component reference + (kind, diagram, responsibility, receives from / sends to / connected to), 5 Connections table, 6 Risks table and + open-question checklist, footer with model and time. Table cells escape pipes; fallbacks when prose is missing. + - `src/trigger/spec-agent.ts`: reads the room, aborts with a user-facing message on an empty canvas or more than 200 + components, generates the prose, renders, returns `{ markdown, stats }`. Single attempt, `maxDuration` 300s, + stages `reading` → `writing` → `rendering` → `done`. + - `lib/spec/spec-store.ts` (Trigger and Blob injectable for tests): `getSpecStatus` stores the latest finished SPEC + run on read (upload to private Blob, update guarded on `specRunId`, delete the duplicate or previous blob) and + reports pending runs and friendly failures; `loadRecordedDecisions` (the user's unexpired completed RESULT + messages, newest first, one per title, max 12); `startSpecRun` (409 with the pending run, trigger, `TaskRun` kind + SPEC); `readSpecMarkdown`. + - Routes: `GET`/`POST /api/projects/[projectId]/spec` (status; start → 202 / 409 / 502) and + `GET /api/projects/[projectId]/spec/markdown` (attachment `{project-slug}-spec.md`, via + `lib/spec/spec-file-name.ts` because route files may only export handlers). Owner or collaborator access. + - `hooks/use-spec-generator.ts` (mounted in `AiSidebar` so a run is followed while another tab is open): loads the + status, starts runs (409 follows the existing run), Realtime stages via the existing token route plus 4s + polling, and downloads automatically when the run the user started is stored. + - `components/editor/spec-panel.tsx`: description, Generate / Regenerate button with spinner and stage text, error + alert, latest-spec card (generated time, counts, **Download .md** link), empty and loading states. Replaces the + placeholder Specs tab in `ai-sidebar.tsx`. + - Context: `project-overview.md` Spec Generation describes the built behaviour; `architecture-context.md` gains a + Spec Agent section and invariant 6 covers both tasks; `ui-context.md` lists the Specs tab. `docs.md` (local, + gitignored) Spec generation page, reference tables, roadmap, decisions, and history updated. + - Validation checks: + - `pnpm typecheck` and `pnpm lint` passed (after `next typegen` for the new routes) + - Offline spec checks (60, no model or DB): grouping, refs, kinds, dropped edges, note placement, 200 cap; Mermaid + shapes, arrows, escaping; sanitising and limits; Markdown sections, callouts, tables, directions, notes, + fallbacks, pipe escaping; prompt text; file names; all 13 starter templates rendered with a stub spec (one + diagram per group, every component referenced, Mermaid line counts match) + - Ref stripping checks (10): refs removed from every prose field, paragraph breaks kept, other parentheses kept + - Spec store checks against the database with fake Trigger/Blob (22): empty, design runs ignored, pending, quota / + abort / unknown / missing failures, invalid output rejected before upload, stored once with finish time and + stats, not re-retrieved, failed newer run keeps the old spec, concurrent reads store once and delete the + duplicate and previous blobs, recorded decisions deduplicated and scoped to the user's unexpired completed + results + - Live runs through the local Trigger worker on `gemini-3.5-flash-lite` with the Microservices template and a + recorded decision (throwaway project, room, session, and blob removed): 202 → 409 for a second start → stored in + about 11s with stats 8 / 8 / 1; Markdown in Blob with all sections, one Mermaid diagram, every component, the + recorded decision tagged "Recorded during AI design". After the prompt change the second run also inferred an + "API Gateway Entry Point" decision (2 decisions, 0 warnings). + - Signed-out requests to the three spec routes are stopped by Clerk (307). + - Not yet verified: the Specs tab in a signed-in browser (button, stages, auto-download, Download link), Mermaid + rendering on GitHub, and the ref-stripping fix in a live run. diff --git a/context/project-overview.md b/context/project-overview.md index dbf1d82..b1ce550 100644 --- a/context/project-overview.md +++ b/context/project-overview.md @@ -39,7 +39,7 @@ Ghost AI is a real-time collaborative system design workspace. Users describe a - Shared real-time canvas using Liveblocks and React Flow. - Live cursors, presence indicators, and node/edge editing. -- Canvas snapshots persisted to the filesystem. +- Canvas snapshots persisted to blob storage (Vercel Blob). ### Starter System Designs @@ -58,12 +58,26 @@ Ghost AI is a real-time collaborative system design workspace. Users describe a that the user approves or asks to revise before anything is drawn. - AI chat sessions are saved per user per project for 7 days after last use, so a conversation and an in-flight generation survive a reload. +- The AI sidebar shows clarifying questions (suggested answers, skip), plans (key decisions highlighted, draw + button), and results as interactive cards. +- Follow-up requests see the current canvas: the AI adds only new components, connected to the existing ones, + using the starter templates' conventions (role-based paper fills, kickers, dashed async links), and checks the + diagram with one repair attempt before drawing. It does not edit or remove existing components. ### Spec Generation - The current canvas graph is converted into a Markdown technical specification. -- Specs are persisted as files and linked to the project in the database. -- Users can view and download generated specs. +- Owners and collaborators generate a spec from the **Specs** tab in the AI sidebar. It runs in the background and + downloads automatically as `{project-name}-spec.md` when ready; **Download .md** fetches the latest spec again. +- The document contains an overview and goals, key decisions highlighted as callouts (with rationale, alternatives, + trade-offs, the components involved, and whether each was recorded during AI design, stated on the canvas, or + inferred), one section per group of connected components with a Mermaid diagram, component table, flow and notes, + a component reference, a connections table, and risks and open questions. +- Diagrams, tables, and connections are built from the canvas in code; the model writes only the explanations and + cannot add components or connections. Decisions recorded in the requesting user's AI design sessions are included. +- One spec per project: generating again replaces it. The Markdown is persisted in blob storage and linked to the + project in the database. Spec history is out of scope. +- An empty canvas, or one with more than 200 components, is rejected with a clear message. ## Scope diff --git a/context/ui-context.md b/context/ui-context.md index 0701d2c..c4fee1e 100644 --- a/context/ui-context.md +++ b/context/ui-context.md @@ -10,7 +10,12 @@ Two separate visual systems live in this app and must not be mixed: ink type and CTAs, JetBrains Mono chrome, `rounded-paper`, `shadow-flat`) with `scheme-light`. The canvas ground (cream with ink dots), AI sidebar, shape panel, control bar, minimap and the top-right canvas status panel use the brand tokens too, and so do the project, share and starter-templates dialogs - (all built on `PaperDialog` in `components/editor/paper-dialog.tsx`) and the Clerk user menu (`UserMenuButton`). Live + (all built on `PaperDialog` in `components/editor/paper-dialog.tsx`) and the Clerk user menu (`UserMenuButton`). + Inside the AI sidebar, the session bar, saved-chat list (`AiSessionHistory`) and chat cards (`QuestionsCard`, + `PlanCard`, `ResultCard` in `components/editor/ai-chat-cards.tsx`) and the Specs tab (`SpecPanel` in + `components/editor/spec-panel.tsx`) follow the same paper conventions: + `paper-bright` cards with ink hairlines, mono chrome kickers, chips that fill ink when selected, ink primary + buttons, and a plan's key decisions on marker-amber paper. Live cursors, the AI thinking indicator and Clerk's "Manage account" profile modal are still product-dark. 2. **Marketing (`app/(marketing)`) and auth (`app/(auth)`)** — the Lab-site brand & craft system v1 defined in `app/globals.css` (Sept 2026). Marketing surfaces use **only** the brand tokens diff --git a/hooks/use-spec-generator.ts b/hooks/use-spec-generator.ts new file mode 100644 index 0000000..217e635 --- /dev/null +++ b/hooks/use-spec-generator.ts @@ -0,0 +1,236 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useRealtimeRun } from "@trigger.dev/react-hooks"; + +import { + parseSpecAgentStage, + SPEC_AGENT_STAGE_KEY, + type SpecAgentStage, + type SpecStatus, + type SpecSummary, +} from "@/lib/spec/spec-schema"; +import type { specAgentTask } from "@/src/trigger/spec-agent"; + +export interface UseSpecGeneratorResult { + /** The project's latest stored spec. */ + spec: SpecSummary | null; + /** True until the first status load finishes. */ + isLoading: boolean; + /** True from clicking generate until the run has finished and been stored. */ + isRunning: boolean; + statusText: string | null; + error: string | null; + /** Same-origin download URL for the stored spec. */ + downloadHref: string; + generate: () => void; +} + +const STAGE_LABELS: Record = { + reading: "Reading the canvas…", + writing: "Writing the spec…", + rendering: "Formatting the Markdown…", + done: "Saving the spec…", +}; + +const STARTING_STATUS = "Starting…"; + +/** While a run is pending the status is re-read on this interval; reading it stores a finished spec. */ +const PENDING_POLL_MS = 4000; + +async function fetchSpecStatus(projectId: string): Promise { + try { + const response = await fetch(`/api/projects/${projectId}/spec`, { cache: "no-store" }); + if (!response.ok) { + return null; + } + return (await response.json()) as SpecStatus; + } catch { + return null; + } +} + +function describeStartFailure(status: number, serverError: string | undefined): string { + switch (status) { + case 401: + return "Your session expired. Sign in again to generate a spec."; + case 403: + return "You do not have access to this project, so no spec was generated."; + case 502: + return "The spec service is unreachable right now. Try again shortly."; + default: + return serverError ?? "The spec could not be started. Try again."; + } +} + +function startDownload(href: string) { + const link = document.createElement("a"); + link.href = href; + link.download = ""; + document.body.appendChild(link); + link.click(); + link.remove(); +} + +/** + * Drives the Specs tab: loads the project's spec status, starts generation, + * follows the run (Realtime for stages, polling as the guarantee), and + * downloads the Markdown automatically once the run the user started is stored. + */ +export function useSpecGenerator(projectId: string): UseSpecGeneratorResult { + const [status, setStatus] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const [requestError, setRequestError] = useState(null); + const [runAccess, setRunAccess] = useState<{ runId: string; token: string } | null>(null); + + const tokenRequestedFor = useRef(null); + /** The run whose stored spec should download automatically. */ + const downloadWhenStored = useRef(null); + + const downloadHref = `/api/projects/${projectId}/spec/markdown`; + const pendingRunId = status?.pendingRunId ?? null; + + useEffect(() => { + let cancelled = false; + void (async () => { + const next = await fetchSpecStatus(projectId); + if (cancelled) { + return; + } + if (next) { + setStatus(next); + } + setIsLoading(false); + })(); + return () => { + cancelled = true; + }; + }, [projectId]); + + // Exchange the pending run id for a run-scoped Realtime token. + useEffect(() => { + if (!pendingRunId || tokenRequestedFor.current === pendingRunId) { + return; + } + tokenRequestedFor.current = pendingRunId; + + void (async () => { + try { + const response = await fetch("/api/ai/design/token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ runId: pendingRunId }), + }); + if (!response.ok) { + return; // Only the user who started a run can watch it; polling still stores it. + } + const { token } = (await response.json()) as { token: string }; + setRunAccess({ runId: pendingRunId, token }); + } catch { + // Polling still stores the spec. + } + })(); + }, [pendingRunId]); + + const { run, error: realtimeError } = useRealtimeRun(runAccess?.runId, { + accessToken: runAccess?.token, + enabled: Boolean(runAccess && runAccess.runId === pendingRunId), + }); + + const activeRun = run && run.id === pendingRunId ? run : undefined; + const runFinished = + pendingRunId !== null && + (Boolean(activeRun && (activeRun.isSuccess || activeRun.isFailed || activeRun.isCancelled)) || + Boolean(realtimeError)); + + // Realtime reports the end of a run sooner than the next poll would. + useEffect(() => { + if (!runFinished) { + return; + } + void (async () => { + const next = await fetchSpecStatus(projectId); + if (next) { + setStatus(next); + } + })(); + }, [projectId, runFinished]); + + useEffect(() => { + if (!pendingRunId) { + return; + } + const timer = window.setInterval(() => { + void (async () => { + const next = await fetchSpecStatus(projectId); + if (next) { + setStatus(next); + } + })(); + }, PENDING_POLL_MS); + return () => window.clearInterval(timer); + }, [pendingRunId, projectId]); + + // Download once the run the user started has been stored; give up if it failed. + useEffect(() => { + const awaited = downloadWhenStored.current; + if (!awaited || !status) { + return; + } + if (status.spec?.runId === awaited) { + downloadWhenStored.current = null; + startDownload(downloadHref); + } else if (!status.pendingRunId && status.lastFailure) { + downloadWhenStored.current = null; + } + }, [downloadHref, status]); + + const generate = useCallback(() => { + if (isSubmitting || pendingRunId) { + return; + } + + setRequestError(null); + setIsSubmitting(true); + + void (async () => { + try { + const response = await fetch(`/api/projects/${projectId}/spec`, { method: "POST" }); + const body = (await response.json().catch(() => null)) as { runId?: string; error?: string } | null; + + // 409 means a spec is already being generated; follow that run instead. + if ((response.status === 202 || response.status === 409) && body?.runId) { + const runId = body.runId; + downloadWhenStored.current = runId; + setStatus((previous) => ({ spec: previous?.spec ?? null, pendingRunId: runId, lastFailure: null })); + return; + } + + setRequestError(describeStartFailure(response.status, body?.error)); + } catch { + setRequestError("The spec could not be started. Try again."); + } finally { + setIsSubmitting(false); + } + })(); + }, [isSubmitting, pendingRunId, projectId]); + + const isRunning = isSubmitting || pendingRunId !== null; + + let statusText: string | null = null; + if (isRunning) { + const stage = activeRun ? parseSpecAgentStage(activeRun.metadata?.[SPEC_AGENT_STAGE_KEY]) : null; + statusText = stage ? STAGE_LABELS[stage] : STARTING_STATUS; + } + + return { + spec: status?.spec ?? null, + isLoading, + isRunning, + statusText, + error: requestError ?? (isRunning ? null : (status?.lastFailure ?? null)), + downloadHref, + generate, + }; +} diff --git a/lib/ai/canvas-summary.ts b/lib/ai/canvas-summary.ts new file mode 100644 index 0000000..7af00a2 --- /dev/null +++ b/lib/ai/canvas-summary.ts @@ -0,0 +1,132 @@ +import { getUsedSides } from "@/lib/canvas-connections"; +import { SHAPE_KICKERS, TEXT_NODE_SHAPE, type CanvasEdge, type CanvasNode } from "@/types/canvas"; + +// --------------------------------------------------------------------------- +// Canvas summary +// +// A compact, model-readable view of what is already on the canvas, so the +// design agent extends an existing design instead of redrawing it. Each drawn +// component gets a short ref ("ex-3") the model can use to connect new +// components to it. +// --------------------------------------------------------------------------- + +export const CANVAS_SUMMARY_LIMITS = { + components: 60, + connections: 80, + notes: 10, +} as const; + +/** Connection points per component; each holds one edge. */ +export const CONNECTION_POINTS_PER_COMPONENT = 4; + +export interface ExistingComponent { + /** Short reference the model uses to connect to this component, e.g. "ex-3". */ + ref: string; + nodeId: string; + label: string; + /** The node's kicker, or its shape's default kicker. */ + kind: string; + /** Connection points already holding an edge (0 to 4). */ + usedConnections: number; +} + +export interface ExistingConnection { + from: string; + to: string; + label?: string; + async: boolean; +} + +export interface CanvasSummary { + components: ExistingComponent[]; + connections: ExistingConnection[]; + /** Text of free text notes on the canvas. */ + notes: string[]; + /** Drawn components on the canvas, including any beyond the listing limit. */ + totalComponents: number; +} + +export const EXISTING_REF_PATTERN = /^ex-\d+$/; + +function labelOf(node: CanvasNode): string { + return typeof node.data.label === "string" ? node.data.label.trim() : ""; +} + +export function summarizeCanvas(nodes: readonly CanvasNode[], edges: readonly CanvasEdge[]): CanvasSummary { + const drawn = nodes.filter((node) => node.data.shape !== TEXT_NODE_SHAPE && labelOf(node).length > 0); + + const components: ExistingComponent[] = drawn + .slice(0, CANVAS_SUMMARY_LIMITS.components) + .map((node, index) => ({ + ref: `ex-${index + 1}`, + nodeId: node.id, + label: labelOf(node), + kind: node.data.kicker?.trim() || SHAPE_KICKERS[node.data.shape ?? "rectangle"], + usedConnections: getUsedSides(edges, node.id).size, + })); + + const refByNodeId = new Map(components.map((component) => [component.nodeId, component.ref])); + + const connections: ExistingConnection[] = edges + .flatMap((edge): ExistingConnection[] => { + const from = refByNodeId.get(edge.source); + const to = refByNodeId.get(edge.target); + if (!from || !to) { + return []; + } + const label = edge.data?.label?.trim(); + return [{ from, to, ...(label ? { label } : {}), async: edge.data?.edgeStyle === "dashed" }]; + }) + .slice(0, CANVAS_SUMMARY_LIMITS.connections); + + const notes = nodes + .filter((node) => node.data.shape === TEXT_NODE_SHAPE) + .map(labelOf) + .filter((note) => note.length > 0) + .slice(0, CANVAS_SUMMARY_LIMITS.notes); + + return { components, connections, notes, totalComponents: drawn.length }; +} + +/** The summary as prompt text. */ +export function formatCanvasSummary(summary: CanvasSummary | null): string { + if (!summary || summary.components.length === 0) { + return summary && summary.notes.length > 0 + ? `The canvas has no components yet. Notes on it:\n${summary.notes.map((note) => `- ${note}`).join("\n")}` + : "The canvas is empty."; + } + + const byRef = new Map(summary.components.map((component) => [component.ref, component])); + const sections = [ + `Components already on the canvas (ref: name, kind, connection points used):\n${summary.components + .map( + (component) => + `- ${component.ref}: ${component.label} (${component.kind}), ` + + `${component.usedConnections}/${CONNECTION_POINTS_PER_COMPONENT} used`, + ) + .join("\n")}`, + ]; + + if (summary.totalComponents > summary.components.length) { + sections.push(`...and ${summary.totalComponents - summary.components.length} more components not listed.`); + } + + if (summary.connections.length > 0) { + sections.push( + `Existing connections:\n${summary.connections + .map((connection) => { + const from = byRef.get(connection.from)?.label ?? connection.from; + const to = byRef.get(connection.to)?.label ?? connection.to; + const details = [connection.label, connection.async ? "async" : ""].filter(Boolean).join(", "); + return `- ${from} (${connection.from}) → ${to} (${connection.to})${details ? `: ${details}` : ""}`; + }) + .join("\n")}`, + ); + } + + if (summary.notes.length > 0) { + sections.push(`Notes on the canvas:\n${summary.notes.map((note) => `- ${note}`).join("\n")}`); + } + + return sections.join("\n\n"); +} diff --git a/lib/ai/design-agent-engine.ts b/lib/ai/design-agent-engine.ts index bb7d7b3..9107f4a 100644 --- a/lib/ai/design-agent-engine.ts +++ b/lib/ai/design-agent-engine.ts @@ -1,5 +1,4 @@ -import { google, type GoogleLanguageModelOptions } from "@ai-sdk/google"; -import { generateText, NoObjectGeneratedError, Output, type LanguageModel, type ModelMessage } from "ai"; +import { generateText, Output, type LanguageModel, type ModelMessage } from "ai"; import { clampBrief, @@ -14,11 +13,15 @@ import { type DesignPlan, type TurnAnalysis, } from "@/lib/ai/agent-schema"; +import { formatCanvasSummary, type CanvasSummary } from "@/lib/ai/canvas-summary"; +import { validateDesignGraph } from "@/lib/ai/graph-validation"; +import { thinking, withSchemaRetry } from "@/lib/ai/model"; import { ANALYZE_SYSTEM_PROMPT, buildAnalyzeContext, buildGeneratePrompt, buildPlanPrompt, + buildRepairPrompt, GENERATE_SYSTEM_PROMPT, PLAN_SYSTEM_PROMPT, } from "@/lib/ai/prompts"; @@ -27,7 +30,7 @@ import { designGraphSchema, type DesignGraph } from "@/lib/design-generation"; // Model calls for one design turn. No canvas or database access, so the steps // can be exercised directly from a script. -const DEFAULT_MODEL_ID = "gemini-3.5-flash"; +export { resolveDesignModel } from "@/lib/ai/model"; /** * Upper bound for each structured call, retries included. Under provider load a @@ -40,55 +43,6 @@ const CALL_TIMEOUT_MS = { generate: 120_000, } as const; -type ThinkingLevel = "low" | "medium"; - -/** Gemini 2.x models take a token budget instead of a thinking level. */ -const THINKING_BUDGETS: Record = { - low: 1024, - medium: 4096, -}; - -export function resolveDesignModel(): LanguageModel { - const configured = process.env.GOOGLE_GENERATIVE_AI_MODEL?.trim(); - return google(configured && configured.length > 0 ? configured : DEFAULT_MODEL_ID); -} - -function modelIdOf(model: LanguageModel): string { - return typeof model === "string" ? model : model.modelId; -} - -/** - * Model reasoning depth per step. Default thinking made a single turn take up - * to ~80s; reading a turn and drawing an agreed plan need little reasoning, - * while drafting the plan (where the architectural decisions are made) keeps - * more. Gemini 3+ takes `thinkingLevel`; Gemini 2.x only understands - * `thinkingBudget`. - */ -function thinking(model: LanguageModel, level: ThinkingLevel) { - const thinkingConfig = /^gemini-2\./.test(modelIdOf(model)) - ? { thinkingBudget: THINKING_BUDGETS[level] } - : { thinkingLevel: level }; - return { google: { thinkingConfig } satisfies GoogleLanguageModelOptions }; -} - -/** - * Runs a structured-output call, retrying once when the response does not - * match the schema. Transport errors are already retried by the SDK; a schema - * mismatch is usually a one-off bad sample, so a single fresh attempt is - * cheaper than failing the whole turn. - */ -async function withSchemaRetry(call: () => Promise): Promise { - try { - return await call(); - } catch (error) { - if (!NoObjectGeneratedError.isInstance(error)) { - throw error; - } - console.warn("[design-agent] model output did not match the schema; retrying once", error.cause); - return call(); - } -} - /** * Builds the model conversation from stored history plus the new turn. Leading * assistant entries are dropped, since a trimmed history can start mid-reply @@ -133,11 +87,15 @@ export function enforceDecision(analysis: TurnAnalysis, payload: DesignAgentPayl }; } -export async function analyzeTurn(model: LanguageModel, payload: DesignAgentPayload): Promise { +export async function analyzeTurn( + model: LanguageModel, + payload: DesignAgentPayload, + canvas: CanvasSummary | null = null, +): Promise { const { output } = await withSchemaRetry(() => generateText({ model, - system: `${ANALYZE_SYSTEM_PROMPT}\n\n${buildAnalyzeContext(payload)}`, + system: `${ANALYZE_SYSTEM_PROMPT}\n\n${buildAnalyzeContext(payload, formatCanvasSummary(canvas))}`, messages: toMessages(payload), output: Output.object({ schema: turnAnalysisSchema, name: "turn_analysis" }), providerOptions: thinking(model, "low"), @@ -151,45 +109,110 @@ export async function analyzeTurn(model: LanguageModel, payload: DesignAgentPayl ); } +/** Parenthesised canvas refs such as "(ex-7)" or "(ex-2, ex-3)". */ +const CANVAS_REF_MENTION = /\s*\((?:\s*ex-\d+\s*,?)+\)/g; + +function withoutRefs(text: string): string { + return text.replace(CANVAS_REF_MENTION, ""); +} + +/** + * Removes canvas refs the model copies from the canvas summary into plan text. + * Refs are internal handles for connecting to existing components; the plan + * is shown to the user, who only knows components by name. + */ +export function removeCanvasRefs(plan: DesignPlan): DesignPlan { + return { + summary: withoutRefs(plan.summary), + components: plan.components.map((component) => ({ + ...component, + responsibility: withoutRefs(component.responsibility), + })), + flows: plan.flows.map(withoutRefs), + decisions: plan.decisions.map((decision) => ({ + ...decision, + choice: withoutRefs(decision.choice), + rationale: withoutRefs(decision.rationale), + alternatives: decision.alternatives.map(withoutRefs), + })), + assumptions: plan.assumptions.map(withoutRefs), + }; +} + export async function draftPlan( model: LanguageModel, brief: DesignBrief, previousPlan: DesignPlan | null, latestInput: string, + canvas: CanvasSummary | null = null, ): Promise { const { output } = await withSchemaRetry(() => generateText({ model, system: PLAN_SYSTEM_PROMPT, - prompt: buildPlanPrompt(brief, previousPlan, latestInput), + prompt: buildPlanPrompt(brief, previousPlan, latestInput, formatCanvasSummary(canvas)), output: Output.object({ schema: designPlanSchema, name: "design_plan" }), providerOptions: thinking(model, "medium"), timeout: { totalMs: CALL_TIMEOUT_MS.plan }, }), ); - return clampPlan(output); + return removeCanvasRefs(clampPlan(output)); } -export async function generateDesignGraph( - model: LanguageModel, - brief: DesignBrief, - plan: DesignPlan, -): Promise { +export interface GeneratedDesign { + graph: DesignGraph; + /** Problems still present in `graph`; buildCanvasGraph drops what it cannot draw. */ + issues: string[]; + /** Problems found in the first attempt and sent back for repair, if a repair ran. */ + repairedIssues: string[]; +} + +async function requestGraph(model: LanguageModel, prompt: string): Promise { const { output } = await withSchemaRetry(() => generateText({ model, system: GENERATE_SYSTEM_PROMPT, - prompt: buildGeneratePrompt(brief, plan), + prompt, output: Output.object({ schema: designGraphSchema, name: "design_graph" }), providerOptions: thinking(model, "low"), timeout: { totalMs: CALL_TIMEOUT_MS.generate }, }), ); - return output; } +/** + * Generates the diagram for an approved plan, validates it, and when anything + * is wrong asks the model once to fix exactly those problems. The attempt with + * fewer remaining problems wins; a failed repair call keeps the first attempt. + */ +export async function generateDesignGraph( + model: LanguageModel, + brief: DesignBrief, + plan: DesignPlan, + canvas: CanvasSummary | null = null, +): Promise { + const prompt = buildGeneratePrompt(brief, plan, formatCanvasSummary(canvas)); + const first = await requestGraph(model, prompt); + const firstIssues = validateDesignGraph(first, { canvas, plan }); + + if (firstIssues.length === 0) { + return { graph: first, issues: [], repairedIssues: [] }; + } + + try { + const repaired = await requestGraph(model, buildRepairPrompt(prompt, first, firstIssues)); + const remaining = validateDesignGraph(repaired, { canvas, plan }); + return remaining.length <= firstIssues.length + ? { graph: repaired, issues: remaining, repairedIssues: firstIssues } + : { graph: first, issues: firstIssues, repairedIssues: firstIssues }; + } catch (error) { + console.warn("[design-agent] repair attempt failed; keeping the first diagram", error); + return { graph: first, issues: firstIssues, repairedIssues: [] }; + } +} + /** An empty brief, for a generate turn that somehow has none stored. */ export function emptyBrief(): DesignBrief { return designBriefSchema.parse({ diff --git a/lib/ai/graph-validation.ts b/lib/ai/graph-validation.ts new file mode 100644 index 0000000..fc501e2 --- /dev/null +++ b/lib/ai/graph-validation.ts @@ -0,0 +1,124 @@ +import type { DesignPlan } from "@/lib/ai/agent-schema"; +import { CONNECTION_POINTS_PER_COMPONENT, EXISTING_REF_PATTERN, type CanvasSummary } from "@/lib/ai/canvas-summary"; +import { MAX_GRAPH_EDGES, MAX_GRAPH_NODES, type DesignGraph } from "@/lib/design-generation"; + +// --------------------------------------------------------------------------- +// Generated graph validation +// +// Checks a generated diagram against the rules the canvas and the plan impose. +// The messages are written for the model: they are sent back verbatim in a +// single repair attempt. Whatever survives repair is still sanitised by +// buildCanvasGraph, so these are quality checks, not safety checks. +// --------------------------------------------------------------------------- + +interface ValidationContext { + canvas?: CanvasSummary | null; + plan?: DesignPlan | null; +} + +function normalizeLabel(label: string): string { + return label.trim().toLowerCase(); +} + +export function validateDesignGraph(graph: DesignGraph, { canvas, plan }: ValidationContext = {}): string[] { + const issues: string[] = []; + const existing = canvas?.components ?? []; + const existingByRef = new Map(existing.map((component) => [component.ref, component])); + const existingByLabel = new Map(existing.map((component) => [normalizeLabel(component.label), component])); + + if (graph.nodes.length > MAX_GRAPH_NODES) { + issues.push(`There are ${graph.nodes.length} components; draw at most ${MAX_GRAPH_NODES}.`); + } + if (graph.edges.length > MAX_GRAPH_EDGES) { + issues.push(`There are ${graph.edges.length} connections; draw at most ${MAX_GRAPH_EDGES}.`); + } + + // --- components + const labelById = new Map(); + const seenLabels = new Set(); + + for (const node of graph.nodes) { + if (labelById.has(node.id)) { + issues.push(`Component id "${node.id}" is used more than once; give every component a unique id.`); + continue; + } + if (EXISTING_REF_PATTERN.test(node.id)) { + issues.push( + `Component id "${node.id}" looks like a ref for an existing canvas component; choose a different id for new components.`, + ); + } + labelById.set(node.id, node.label); + + const label = normalizeLabel(node.label); + const onCanvas = existingByLabel.get(label); + if (onCanvas) { + issues.push( + `"${node.label}" is already on the canvas as ${onCanvas.ref}; do not draw it again, connect to ${onCanvas.ref} instead.`, + ); + } else if (seenLabels.has(label)) { + issues.push(`"${node.label}" appears more than once; each component needs a unique name.`); + } + seenLabels.add(label); + } + + const nameOf = (id: string) => labelById.get(id) ?? existingByRef.get(id)?.label ?? id; + + // --- connections + const connectionCounts = new Map(existing.map((component) => [component.ref, component.usedConnections])); + const seenPairs = new Set(); + + for (const edge of graph.edges) { + const unknown = [edge.source, edge.target].filter((id) => !labelById.has(id) && !existingByRef.has(id)); + if (unknown.length > 0) { + issues.push( + `Connection "${edge.id}" references ${unknown.map((id) => `"${id}"`).join(" and ")}, which is neither a new component id nor an existing ref.`, + ); + continue; + } + if (edge.source === edge.target) { + issues.push(`Connection "${edge.id}" connects "${nameOf(edge.source)}" to itself.`); + continue; + } + + const pair = [edge.source, edge.target].sort().join("|"); + if (seenPairs.has(pair)) { + issues.push(`"${nameOf(edge.source)}" and "${nameOf(edge.target)}" are connected more than once; keep one connection.`); + continue; + } + seenPairs.add(pair); + + for (const id of [edge.source, edge.target]) { + connectionCounts.set(id, (connectionCounts.get(id) ?? 0) + 1); + } + } + + for (const [id, count] of connectionCounts) { + if (count > CONNECTION_POINTS_PER_COMPONENT) { + const note = existingByRef.has(id) ? " (including connections it already has on the canvas)" : ""; + issues.push( + `"${nameOf(id)}" has ${count} connections${note}; each component has ${CONNECTION_POINTS_PER_COMPONENT} connection points, so keep it to ${CONNECTION_POINTS_PER_COMPONENT}.`, + ); + } + } + + // --- unconnected components + if (graph.nodes.length > 1 || existing.length > 0) { + for (const [id, label] of labelById) { + if (!connectionCounts.has(id)) { + issues.push(`"${label}" is not connected to anything.`); + } + } + } + + // --- plan coverage + if (plan) { + for (const component of plan.components) { + const name = normalizeLabel(component.name); + if (!seenLabels.has(name) && !existingByLabel.has(name)) { + issues.push(`Plan component "${component.name}" is missing from the diagram.`); + } + } + } + + return issues; +} diff --git a/lib/ai/model.ts b/lib/ai/model.ts new file mode 100644 index 0000000..a15ab7b --- /dev/null +++ b/lib/ai/model.ts @@ -0,0 +1,54 @@ +import { google, type GoogleLanguageModelOptions } from "@ai-sdk/google"; +import { NoObjectGeneratedError, type LanguageModel } from "ai"; + +// Model configuration shared by the Trigger.dev agents (design and spec). + +const DEFAULT_MODEL_ID = "gemini-3.5-flash"; + +export type ThinkingLevel = "low" | "medium"; + +/** Gemini 2.x models take a token budget instead of a thinking level. */ +const THINKING_BUDGETS: Record = { + low: 1024, + medium: 4096, +}; + +export function resolveDesignModel(): LanguageModel { + const configured = process.env.GOOGLE_GENERATIVE_AI_MODEL?.trim(); + return google(configured && configured.length > 0 ? configured : DEFAULT_MODEL_ID); +} + +export function modelIdOf(model: LanguageModel): string { + return typeof model === "string" ? model : model.modelId; +} + +/** + * Model reasoning depth for a step. Default thinking made a single call take up + * to ~80s; most steps need little reasoning, while drafting plans and specs + * keeps more. Gemini 3+ takes `thinkingLevel`; Gemini 2.x only understands + * `thinkingBudget`. + */ +export function thinking(model: LanguageModel, level: ThinkingLevel) { + const thinkingConfig = /^gemini-2\./.test(modelIdOf(model)) + ? { thinkingBudget: THINKING_BUDGETS[level] } + : { thinkingLevel: level }; + return { google: { thinkingConfig } satisfies GoogleLanguageModelOptions }; +} + +/** + * Runs a structured-output call, retrying once when the response does not + * match the schema. Transport errors are already retried by the SDK; a schema + * mismatch is usually a one-off bad sample, so a single fresh attempt is + * cheaper than failing the whole run. + */ +export async function withSchemaRetry(call: () => Promise): Promise { + try { + return await call(); + } catch (error) { + if (!NoObjectGeneratedError.isInstance(error)) { + throw error; + } + console.warn("[ai] model output did not match the schema; retrying once", error.cause); + return call(); + } +} diff --git a/lib/ai/prompts.ts b/lib/ai/prompts.ts index b57b763..b653e32 100644 --- a/lib/ai/prompts.ts +++ b/lib/ai/prompts.ts @@ -27,14 +27,19 @@ export const ANALYZE_SYSTEM_PROMPT = [ " without asking for changes.", " A detailed first message can go straight to a plan; a vague one should get questions.", "3. Write a short reply: one or two plain sentences, no markdown, no restating the questions.", + "", + "The canvas may already hold a design; its contents are listed below. Treat the user's request as", + "a change to that design unless they ask for a separate system: do not ask about anything the", + "canvas already answers, and plan only what needs to be added.", ].join("\n"); function describeBrief(brief: DesignBrief | null): string { return brief ? JSON.stringify(brief, null, 2) : "No brief yet — this is the start of the conversation."; } -export function buildAnalyzeContext(payload: DesignAgentPayload): string { +export function buildAnalyzeContext(payload: DesignAgentPayload, canvasText: string): string { const notes = [ + `Current canvas:\n${canvasText}`, `Current brief:\n${describeBrief(payload.brief)}`, payload.plan ? `A plan has been proposed:\n${describePlan(payload.plan)}` @@ -70,15 +75,23 @@ export const PLAN_SYSTEM_PROMPT = [ " to the brief and name realistic alternatives.", "- Assumptions: carry over the brief's assumptions and add any the plan depends on.", "When a previous plan exists and the user asked for changes, revise that plan rather than starting over.", + "When the canvas already has components, list only the components to add. Refer to existing", + "components by their exact names in flows and decisions, and never list them again as components.", ].join("\n"); function describePlan(plan: DesignPlan): string { return JSON.stringify(plan, null, 2); } -export function buildPlanPrompt(brief: DesignBrief, previousPlan: DesignPlan | null, latestInput: string): string { +export function buildPlanPrompt( + brief: DesignBrief, + previousPlan: DesignPlan | null, + latestInput: string, + canvasText: string, +): string { return [ `Requirements brief:\n${JSON.stringify(brief, null, 2)}`, + `Current canvas:\n${canvasText}`, previousPlan ? `Previous plan:\n${describePlan(previousPlan)}` : "", `The user's latest message:\n${latestInput}`, ] @@ -92,17 +105,35 @@ export function buildPlanPrompt(brief: DesignBrief, previousPlan: DesignPlan | n export const GENERATE_SYSTEM_PROMPT = [ "You are a system design architect. Turn the approved plan into a component diagram.", - "Draw exactly the plan's components, using each component's name verbatim as its label.", - "Connect components in the direction traffic actually flows, following the plan's flows, and label", - "a connection only when the protocol or payload is not obvious from the two components it joins.", + "Draw exactly the plan's components as new nodes, using each component's name verbatim as its label.", + "Components already on the canvas are listed with refs such as 'ex-2': never draw them again;", + "connect new components to them by using the ref as the connection's source or target.", + "Give every node a role (entry, compute, messaging, data, output) and a short kicker such as", + "'Gateway', 'Worker', 'Queue', or 'Database'.", + "Connect components in the direction traffic actually flows, following the plan's flows. Mark a", + "connection 'async' for events, queues, streams, replication, and background work; otherwise 'sync'.", + "Label a connection only when the protocol or payload is not obvious from the two components it joins.", "Lay the diagram out top to bottom: entry points at the lowest y values, datastores at the", "highest. Components that sit at the same level of the request path share a y value.", - "Every component has four connection points (top, right, bottom, left) and each point takes", - "one connection, so give each component at most four connections in total.", + "Every component has four connection points and each takes one connection, so give each component", + "at most four connections in total, counting the ones an existing component already uses.", + "Every new component must be connected to at least one other component.", ].join(" "); -export function buildGeneratePrompt(brief: DesignBrief, plan: DesignPlan): string { - return [`Approved plan:\n${describePlan(plan)}`, `Requirements brief:\n${JSON.stringify(brief, null, 2)}`].join( - "\n\n", - ); +export function buildGeneratePrompt(brief: DesignBrief, plan: DesignPlan, canvasText: string): string { + return [ + `Approved plan:\n${describePlan(plan)}`, + `Current canvas:\n${canvasText}`, + `Requirements brief:\n${JSON.stringify(brief, null, 2)}`, + ].join("\n\n"); +} + +/** The generate prompt plus the previous attempt and what was wrong with it. */ +export function buildRepairPrompt(generatePrompt: string, previousGraph: unknown, issues: readonly string[]): string { + return [ + generatePrompt, + `Your previous diagram had these problems:\n${issues.map((issue) => `- ${issue}`).join("\n")}`, + "Return the complete corrected diagram — every component and connection — fixing each problem and keeping everything else the same.", + `Previous diagram:\n${JSON.stringify(previousGraph, null, 2)}`, + ].join("\n\n"); } diff --git a/lib/ai/run-failure.ts b/lib/ai/run-failure.ts new file mode 100644 index 0000000..be4a8a4 --- /dev/null +++ b/lib/ai/run-failure.ts @@ -0,0 +1,35 @@ +// Turns a failed Trigger.dev run's error into something a user can act on. +// Raw provider errors (quota details, retry counts, URLs) are logged by the +// caller and never shown. + +interface DescribeRunFailureOptions { + /** Shown when nothing more specific applies. */ + generic: string; + /** Shown when the run timed out. */ + timeout: string; + /** Messages written for users (e.g. an abort reason) that are shown as they are. */ + passthrough?: readonly string[]; +} + +export function describeRunFailure( + message: string | undefined, + { generic, timeout, passthrough = [] }: DescribeRunFailureOptions, +): string { + const trimmed = message?.trim(); + if (!trimmed) { + return generic; + } + if (passthrough.includes(trimmed)) { + return trimmed; + } + if (/quota|rate.?limit|too many requests|\b429\b/i.test(trimmed)) { + return "Draftly AI has hit its usage limit for the moment. Wait a minute and try again."; + } + if (/high demand|overloaded|unavailable|\b503\b/i.test(trimmed)) { + return "Draftly AI is busy right now. Try again in a moment."; + } + if (/timed? ?out|timeout|deadline/i.test(trimmed)) { + return timeout; + } + return generic; +} diff --git a/lib/ai/session-turns.ts b/lib/ai/session-turns.ts index e5cc966..9d1223f 100644 --- a/lib/ai/session-turns.ts +++ b/lib/ai/session-turns.ts @@ -29,6 +29,7 @@ import { MAX_MESSAGES_PER_SESSION, toSessionTitle, } from "@/lib/ai/session-store"; +import { describeRunFailure } from "@/lib/ai/run-failure"; import { prisma } from "@/lib/prisma"; import type { designAgentTask } from "@/src/trigger/design-agent"; import type { @@ -123,26 +124,13 @@ function failedOutcome(content: string): TurnOutcome { return { kind: "ERROR", status: "FAILED", content }; } -/** - * Turns a failed run's error into something a user can act on. Raw provider - * errors (quota details, retry counts, URLs) are logged, never stored. - */ -function describeRunFailure(runId: string, message: string | undefined): string { +/** Logs a failed run's raw error and returns the message stored for the user. */ +function describeFailedRun(runId: string, message: string | undefined): string { console.error("[ai-sessions] design-agent run failed", runId, message); - - if (!message) { - return GENERIC_RUN_ERROR; - } - if (/quota|rate.?limit|too many requests|\b429\b/i.test(message)) { - return "Draftly AI has hit its usage limit for the moment. Wait a minute and try again."; - } - if (/high demand|overloaded|unavailable|\b503\b/i.test(message)) { - return "Draftly AI is busy right now. Try again in a moment."; - } - if (/timed? ?out|timeout|deadline/i.test(message)) { - return "The design agent took too long to respond. Try again."; - } - return GENERIC_RUN_ERROR; + return describeRunFailure(message, { + generic: GENERIC_RUN_ERROR, + timeout: "The design agent took too long to respond. Try again.", + }); } function outcomeFromResult(result: DesignAgentResult): TurnOutcome { @@ -243,7 +231,7 @@ async function settleTurn(sessionId: string, messageId: string, runId: string): } else if (run.isCancelled) { outcome = failedOutcome("The design run was cancelled."); } else if (run.isFailed) { - outcome = failedOutcome(describeRunFailure(runId, run.error?.message)); + outcome = failedOutcome(describeFailedRun(runId, run.error?.message)); } else { return false; } diff --git a/lib/canvas-room.ts b/lib/canvas-room.ts new file mode 100644 index 0000000..78e4149 --- /dev/null +++ b/lib/canvas-room.ts @@ -0,0 +1,15 @@ +import { mutateFlow } from "@liveblocks/react-flow/node"; + +import { getLiveblocksClient } from "@/lib/liveblocks"; +import type { CanvasEdge, CanvasNode } from "@/types/canvas"; + +/** A copy of a room's nodes and edges, read without changing the room. */ +export async function readRoomSnapshot(roomId: string): Promise<{ nodes: CanvasNode[]; edges: CanvasEdge[] }> { + let snapshot: { nodes: CanvasNode[]; edges: CanvasEdge[] } = { nodes: [], edges: [] }; + + await mutateFlow({ client: getLiveblocksClient(), roomId }, (flow) => { + snapshot = { nodes: [...flow.nodes], edges: [...flow.edges] }; + }); + + return snapshot; +} diff --git a/lib/design-generation.ts b/lib/design-generation.ts index a80bac5..dbf2466 100644 --- a/lib/design-generation.ts +++ b/lib/design-generation.ts @@ -1,28 +1,28 @@ import { z } from "zod"; +import type { CanvasSummary } from "@/lib/ai/canvas-summary"; import { assignEdgeHandles } from "@/lib/canvas-connections"; import { CANVAS_EDGE_TYPE, CANVAS_NODE_TYPE, CANVAS_SHAPES, EDGE_ARROW_DIRECTIONS, - NODE_COLOR_IDS, - NODE_COLOR_PALETTE, + getRoleFill, + NODE_ROLES, SHAPE_DEFAULTS, type CanvasEdge, type CanvasNode, - type NodeColorId, } from "@/types/canvas"; // --------------------------------------------------------------------------- // Generation schema // // This is the contract handed to the model. It is deliberately narrower than -// CanvasNode / CanvasEdge: the model only chooses semantics (what exists, how -// it connects, roughly where it sits). Everything the canvas needs in order to -// render — node type, edge type, pixel sizes, resolved hex colors — is filled -// in by buildCanvasGraph() below, so the model can never emit a value the -// canvas is unable to draw. +// CanvasNode / CanvasEdge: the model only chooses semantics (what exists, what +// role it plays, how it connects, roughly where it sits). Everything the canvas +// needs in order to render — node type, edge type, pixel sizes, fills, edge +// styles, connection points — is filled in by buildCanvasGraph() below, so the +// model can never emit a value the canvas is unable to draw. // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- @@ -51,14 +51,17 @@ export function parseDesignAgentStage(value: unknown): DesignAgentStage | null { } /** Upper bound on generated components, to keep a single run legible. */ -const MAX_NODES = 24; +export const MAX_GRAPH_NODES = 24; /** Upper bound on generated connections. */ -const MAX_EDGES = 48; +export const MAX_GRAPH_EDGES = 48; /** A single generated component in the system design. */ export const designNodeSchema = z.object({ - id: z.string().min(1).describe("Short unique slug for this component, e.g. 'api-gateway'."), + id: z + .string() + .min(1) + .describe("Short unique slug for this new component, e.g. 'api-gateway'. Never of the form 'ex-N'."), label: z .string() .min(1) @@ -72,33 +75,49 @@ export const designNodeSchema = z.object({ shape: z .enum(CANVAS_SHAPES) .describe( - "Visual shape. Use 'cylinder' for datastores, 'pill' for gateways and entry points, " + - "'diamond' for routers and decisions, 'rectangle' for services, 'hexagon' for queues " + - "and brokers, 'circle' for clients and external actors.", + "Visual shape. 'pill' for clients and entry points (DNS, CDN, load balancers, gateways), " + + "'rectangle' for services, APIs and workers, 'hexagon' for queues, topics and streams, " + + "'cylinder' for databases, caches and storage, 'diamond' for routers and decisions, " + + "'circle' for external actors and events.", + ), + role: z + .enum(NODE_ROLES) + .describe( + "What the component does, which sets its colour: 'entry' (clients, DNS, CDN, load balancers, gateways), " + + "'compute' (services, APIs, workers), 'messaging' (queues, topics, streams, brokers), " + + "'data' (databases, caches, object storage), 'output' (external systems, consumers, delivery).", ), - colorId: z - .enum(NODE_COLOR_IDS) - .optional() - .describe("Palette entry used to tint the node. Give related components the same entry."), + kicker: z + .string() + .describe("One or two word kind shown above the name, e.g. 'Gateway', 'Worker', 'Queue', 'Database'."), }); /** A single generated connection between two components. */ export const designEdgeSchema = z.object({ id: z.string().min(1).describe("Short unique slug for this connection."), - source: z.string().min(1).describe("`id` of the node the connection starts at."), - target: z.string().min(1).describe("`id` of the node the connection ends at."), + source: z + .string() + .min(1) + .describe("Where the connection starts: a new component's `id`, or an existing component's ref such as 'ex-2'."), + target: z + .string() + .min(1) + .describe("Where the connection ends: a new component's `id`, or an existing component's ref such as 'ex-2'."), label: z.string().optional().describe("Short description of the traffic, e.g. 'writes'."), arrowDirection: z .enum(EDGE_ARROW_DIRECTIONS) .describe("Arrowheads to draw. Use 'forward' for one-way flow."), + delivery: z + .enum(["sync", "async"]) + .describe("'async' for events, queues, streams, replication and background work; otherwise 'sync'."), }); /** The full structured output requested from the model. */ // The limits are stated, not enforced as maxItems: models don't reliably honour // them, and one extra item would fail the run. buildCanvasGraph trims instead. export const designGraphSchema = z.object({ - nodes: z.array(designNodeSchema).min(1).describe(`At most ${MAX_NODES} components.`), - edges: z.array(designEdgeSchema).describe(`At most ${MAX_EDGES} connections.`), + nodes: z.array(designNodeSchema).min(1).describe(`New components to draw. At most ${MAX_GRAPH_NODES}.`), + edges: z.array(designEdgeSchema).describe(`Connections. At most ${MAX_GRAPH_EDGES}.`), }); export type DesignGraph = z.infer; @@ -124,6 +143,12 @@ interface LayoutOrigin { y: number; } +export interface ExistingCanvas { + summary: CanvasSummary; + nodes: readonly CanvasNode[]; + edges: readonly CanvasEdge[]; +} + export interface BuildCanvasGraphOptions { /** * Prefix applied to every generated id, so a run can never collide with @@ -132,10 +157,16 @@ export interface BuildCanvasGraphOptions { idPrefix: string; /** Top-left corner the generated graph is laid out from. */ origin?: LayoutOrigin; + /** + * What is already in the room. Lets edges reference existing components by + * ref, folds redrawn duplicates into the existing node, and keeps connection + * points that existing edges already use. + */ + existing?: ExistingCanvas; } -function resolveColor(colorId: NodeColorId | undefined) { - return NODE_COLOR_PALETTE.find((entry) => entry.id === colorId) ?? NODE_COLOR_PALETTE[0]; +function normalizeLabel(label: string): string { + return label.trim().toLowerCase(); } /** @@ -192,25 +223,55 @@ function assignGridCells(nodes: DesignGraph["nodes"]): Map [component.ref, component.nodeId])); + const existingNodeIdByLabel = new Map( + existingComponents.map((component) => [normalizeLabel(component.label), component.nodeId]), + ); + + /** Design ids that resolve to a node other than their own: existing duplicates and repeated labels. */ + const aliases = new Map(); + const firstIdByLabel = new Map(); const seenNodeIds = new Set(); + const uniqueNodes = design.nodes .filter((node) => { - if (seenNodeIds.has(node.id)) { + if (seenNodeIds.has(node.id) || existingNodeIdByRef.has(node.id)) { return false; } seenNodeIds.add(node.id); + + const label = normalizeLabel(node.label); + const onCanvas = existingNodeIdByLabel.get(label); + if (onCanvas) { + aliases.set(node.id, onCanvas); + return false; + } + + const first = firstIdByLabel.get(label); + if (first) { + aliases.set(node.id, `${idPrefix}-${first}`); + return false; + } + firstIdByLabel.set(label, node.id); return true; }) - .slice(0, MAX_NODES); + .slice(0, MAX_GRAPH_NODES); const cells = assignGridCells(uniqueNodes); const canvasNodeIds = new Map(); @@ -220,8 +281,8 @@ export function buildCanvasGraph( canvasNodeIds.set(node.id, canvasNodeId); const dimensions = SHAPE_DEFAULTS[node.shape]; - const color = resolveColor(node.colorId); const cell = cells.get(node.id) ?? { column: 0, row: 0 }; + const kicker = node.kicker.trim(); return { id: canvasNodeId, @@ -237,31 +298,40 @@ export function buildCanvasGraph( data: { label: node.label, shape: node.shape, - color: color.bg, - textColor: "var(--text-primary)", - strokeColor: "var(--text-primary)", + color: getRoleFill(node.role).value, + ...(kicker ? { kicker } : {}), }, }; }); + const drawnIds = new Set(canvasNodeIds.values()); + const resolveEndpoint = (id: string): string | undefined => { + const resolved = canvasNodeIds.get(id) ?? aliases.get(id) ?? existingNodeIdByRef.get(id); + // An alias to a repeated label points at the first node, which may have been trimmed away. + return resolved && (drawnIds.has(resolved) || !resolved.startsWith(`${idPrefix}-`)) ? resolved : undefined; + }; + const seenEdgeIds = new Set(); + const seenPairs = new Set(); const edges: CanvasEdge[] = []; for (const edge of design.edges) { - if (edges.length >= MAX_EDGES) { + if (edges.length >= MAX_GRAPH_EDGES) { break; } - const source = canvasNodeIds.get(edge.source); - const target = canvasNodeIds.get(edge.target); + const source = resolveEndpoint(edge.source); + const target = resolveEndpoint(edge.target); if (!source || !target || source === target) { continue; } + const pair = [source, target].sort().join("|"); const canvasEdgeId = `${idPrefix}-${edge.id}`; - if (seenEdgeIds.has(canvasEdgeId)) { + if (seenEdgeIds.has(canvasEdgeId) || seenPairs.has(pair)) { continue; } seenEdgeIds.add(canvasEdgeId); + seenPairs.add(pair); const label = edge.label?.trim(); @@ -272,11 +342,15 @@ export function buildCanvasGraph( target, data: { arrowDirection: edge.arrowDirection, + edgeStyle: edge.delivery === "async" ? "dashed" : "solid", ...(label ? { label } : {}), }, }); } - // One edge per node side, so connections don't pile up on a single point. - return { nodes, edges: assignEdgeHandles(nodes, edges) }; + // One edge per node side, counting the sides existing edges already hold. + const existingEdges = existing?.edges ?? []; + const assigned = assignEdgeHandles([...(existing?.nodes ?? []), ...nodes], [...existingEdges, ...edges]); + + return { nodes, edges: assigned.slice(existingEdges.length) }; } diff --git a/lib/spec/mermaid.ts b/lib/spec/mermaid.ts new file mode 100644 index 0000000..0c39280 --- /dev/null +++ b/lib/spec/mermaid.ts @@ -0,0 +1,69 @@ +import type { SpecGraph, SpecGroup } from "@/lib/spec/spec-graph"; +import type { CanvasArrowDirection, CanvasShape } from "@/types/canvas"; + +// Mermaid flowcharts for spec diagram sections. Every node label is quoted and +// entity-escaped, so component names cannot break the diagram syntax. + +const SHAPE_WRAPPERS: Record = { + rectangle: ['["', '"]'], + circle: ['(("', '"))'], + diamond: ['{"', '"}'], + pill: ['(["', '"])'], + cylinder: ['[("', '")]'], + hexagon: ['{{"', '"}}'], +}; + +/** Text safe inside a quoted Mermaid label. */ +export function escapeMermaidText(text: string): string { + return text + .replace(/\s+/g, " ") + .trim() + .replace(/#/g, "#35;") + .replace(/"/g, "#quot;") + .replace(//g, "#gt;") + .replace(/\|/g, "#124;"); +} + +function edgeOperator(direction: CanvasArrowDirection, async: boolean): { operator: string; reverse: boolean } { + switch (direction) { + case "forward": + return { operator: async ? "-.->" : "-->", reverse: false }; + case "backward": + return { operator: async ? "-.->" : "-->", reverse: true }; + case "bidirectional": + return { operator: async ? "<-.->" : "<-->", reverse: false }; + default: + return { operator: async ? "-.-" : "---", reverse: false }; + } +} + +/** The group as a top-down Mermaid flowchart, without code fences. */ +export function renderGroupMermaid(graph: SpecGraph, group: SpecGroup): string { + const componentByRef = new Map(graph.components.map((component) => [component.ref, component])); + const lines = ["flowchart TD"]; + + for (const ref of group.componentRefs) { + const component = componentByRef.get(ref); + if (!component) { + continue; + } + const [open, close] = SHAPE_WRAPPERS[component.shape]; + lines.push(` ${ref}${open}${escapeMermaidText(component.label)}${close}`); + } + + for (const index of group.connectionIndexes) { + const connection = graph.connections[index]; + if (!connection) { + continue; + } + const { operator, reverse } = edgeOperator(connection.direction, connection.async); + const [from, to] = reverse + ? [connection.targetRef, connection.sourceRef] + : [connection.sourceRef, connection.targetRef]; + const label = connection.label ? `|"${escapeMermaidText(connection.label)}"|` : ""; + lines.push(` ${from} ${operator}${label} ${to}`); + } + + return lines.join("\n"); +} diff --git a/lib/spec/render-markdown.ts b/lib/spec/render-markdown.ts new file mode 100644 index 0000000..353759f --- /dev/null +++ b/lib/spec/render-markdown.ts @@ -0,0 +1,229 @@ +import { renderGroupMermaid } from "@/lib/spec/mermaid"; +import type { SpecConnection, SpecGraph, SpecGroup } from "@/lib/spec/spec-graph"; +import type { DecisionSource, SpecContent, SpecStats } from "@/lib/spec/spec-schema"; + +// --------------------------------------------------------------------------- +// Markdown rendering +// +// The spec document is assembled in code: headings, callouts, Mermaid +// diagrams, and tables come from the canvas graph, and the model's prose fills +// in the explanations. GitHub renders the `> [!IMPORTANT]` callouts and +// Mermaid blocks natively. +// --------------------------------------------------------------------------- + +export interface RenderSpecOptions { + projectName: string; + generatedAt: Date; + modelId: string; +} + +const SOURCE_LABELS: Record = { + recorded: "Recorded during AI design", + stated: "Stated on the canvas", + inferred: "Inferred from the diagram", +}; + +function inline(text: string): string { + return text.replace(/\s+/g, " ").trim(); +} + +/** Text safe inside a Markdown table cell. */ +function cell(text: string | undefined): string { + const value = inline(text ?? "").replace(/\|/g, "\\|"); + return value.length > 0 ? value : "—"; +} + +function plural(count: number, noun: string): string { + return `${count} ${noun}${count === 1 ? "" : "s"}`; +} + +function formatUtc(date: Date): string { + return `${date.toISOString().slice(0, 16).replace("T", " ")} UTC`; +} + +export function renderSpecMarkdown( + graph: SpecGraph, + content: SpecContent, + { projectName, generatedAt, modelId }: RenderSpecOptions, +): { markdown: string; stats: SpecStats } { + const componentByRef = new Map(graph.components.map((component) => [component.ref, component])); + const groupContent = new Map(content.groups.map((group) => [group.groupId, group])); + const responsibilities = new Map(content.components.map((entry) => [entry.componentId, entry.responsibility])); + const nameOf = (ref: string) => componentByRef.get(ref)?.label ?? ref; + const groupName = (group: SpecGroup, index: number) => + group.standalone ? "Standalone components" : groupContent.get(group.id)?.name || `Diagram ${index + 1}`; + const groupNameById = new Map(graph.groups.map((group, index) => [group.id, groupName(group, index)])); + + const stats: SpecStats = { + components: graph.components.length, + connections: graph.connections.length, + diagrams: graph.groups.length, + decisions: content.decisions.length, + }; + + const title = inline(projectName) || "Untitled project"; + const out: string[] = [ + `# ${title} — Technical Specification`, + "", + `> Generated by Draftly on ${formatUtc(generatedAt)} from the project canvas: ${plural(stats.components, "component")}, ` + + `${plural(stats.connections, "connection")}, ${plural(stats.diagrams, "diagram")}.`, + "", + "## Contents", + "", + "1. [Overview](#1-overview)", + "2. [Key decisions](#2-key-decisions)", + "3. [System diagrams](#3-system-diagrams)", + "4. [Component reference](#4-component-reference)", + "5. [Connections](#5-connections)", + "6. [Risks and open questions](#6-risks-and-open-questions)", + "", + ]; + + // ---- 1. Overview + out.push("## 1. Overview", "", content.overview || "_No overview was produced._", ""); + if (content.goals.length > 0) { + out.push("**Goals**", "", ...content.goals.map((goal) => `- ${inline(goal)}`), ""); + } + if (graph.generalNotes.length > 0) { + out.push("**Notes on the canvas**", "", ...graph.generalNotes.map((note) => `- ${inline(note)}`), ""); + } + + // ---- 2. Key decisions + out.push("## 2. Key decisions", ""); + if (content.decisions.length === 0) { + out.push("_No major decisions were identified from the canvas._", ""); + } + for (const decision of content.decisions) { + const paragraphs = [ + `**${inline(decision.title)}:** ${inline(decision.decision)}`, + decision.rationale ? `**Why:** ${inline(decision.rationale)}` : "", + decision.alternatives.length > 0 ? `**Alternatives considered:** ${decision.alternatives.map(inline).join("; ")}` : "", + decision.tradeoffs ? `**Trade-offs:** ${inline(decision.tradeoffs)}` : "", + [ + decision.componentIds.length > 0 ? `**Components:** ${decision.componentIds.map(nameOf).join(", ")}` : "", + `**Source:** ${SOURCE_LABELS[decision.source]}`, + ] + .filter(Boolean) + .join(" · "), + ].filter(Boolean); + + out.push("> [!IMPORTANT]", ...paragraphs.flatMap((paragraph, index) => (index === 0 ? [`> ${paragraph}`] : [">", `> ${paragraph}`])), ""); + } + + // ---- 3. System diagrams + out.push("## 3. System diagrams", ""); + if (graph.groups.length === 0) { + out.push("_The canvas has no components._", ""); + } + graph.groups.forEach((group, index) => { + const described = groupContent.get(group.id); + out.push(`### 3.${index + 1} ${groupName(group, index)}`, ""); + if (described?.purpose) { + out.push(described.purpose, ""); + } + out.push("```mermaid", renderGroupMermaid(graph, group), "```", ""); + out.push( + "| Component | Kind | Responsibility |", + "| --- | --- | --- |", + ...group.componentRefs.map((ref) => { + const component = componentByRef.get(ref); + return `| ${cell(component?.label)} | ${cell(component?.kind)} | ${cell(responsibilities.get(ref))} |`; + }), + "", + ); + if (described && described.flow.length > 0) { + out.push("**Flow**", "", ...described.flow.map((step, stepIndex) => `${stepIndex + 1}. ${inline(step)}`), ""); + } + if (group.notes.length > 0) { + out.push("**Notes**", "", ...group.notes.map((note) => `- ${inline(note)}`), ""); + } + }); + + // ---- 4. Component reference + out.push("## 4. Component reference", ""); + if (graph.components.length === 0) { + out.push("_The canvas has no components._", ""); + } + for (const component of graph.components) { + const receivesFrom: string[] = []; + const sendsTo: string[] = []; + const connectedTo: string[] = []; + + for (const connection of graph.connections) { + if (connection.sourceRef !== component.ref && connection.targetRef !== component.ref) { + continue; + } + const isSource = connection.sourceRef === component.ref; + const other = nameOf(isSource ? connection.targetRef : connection.sourceRef); + const details = [connection.label, connection.async ? "async" : ""].filter(Boolean).join(", "); + const entry = details ? `${other} (${inline(details)})` : other; + + switch (connection.direction) { + case "forward": + (isSource ? sendsTo : receivesFrom).push(entry); + break; + case "backward": + (isSource ? receivesFrom : sendsTo).push(entry); + break; + case "bidirectional": + sendsTo.push(entry); + receivesFrom.push(entry); + break; + default: + connectedTo.push(entry); + } + } + + out.push( + `### ${component.label}`, + "", + `- **Kind:** ${inline(component.kind)}`, + `- **Diagram:** ${groupNameById.get(component.groupId) ?? "—"}`, + `- **Responsibility:** ${responsibilities.get(component.ref) ?? "_Not described._"}`, + ...(receivesFrom.length > 0 ? [`- **Receives from:** ${receivesFrom.join(", ")}`] : []), + ...(sendsTo.length > 0 ? [`- **Sends to:** ${sendsTo.join(", ")}`] : []), + ...(connectedTo.length > 0 ? [`- **Connected to:** ${connectedTo.join(", ")}`] : []), + "", + ); + } + + // ---- 5. Connections + out.push("## 5. Connections", ""); + if (graph.connections.length === 0) { + out.push("_The canvas has no connections._", ""); + } else { + const describe = (connection: SpecConnection) => { + const [from, to] = + connection.direction === "backward" + ? [connection.targetRef, connection.sourceRef] + : [connection.sourceRef, connection.targetRef]; + const direction = + connection.direction === "bidirectional" ? "Two-way" : connection.direction === "none" ? "Undirected" : "One-way"; + return `| ${cell(nameOf(from))} | ${cell(nameOf(to))} | ${cell(connection.label)} | ${connection.async ? "Async" : "Sync"} | ${direction} |`; + }; + out.push("| From | To | Label | Delivery | Direction |", "| --- | --- | --- | --- | --- |", ...graph.connections.map(describe), ""); + } + + // ---- 6. Risks and open questions + out.push("## 6. Risks and open questions", "", "**Risks**", ""); + if (content.risks.length === 0) { + out.push("_No risks were identified._", ""); + } else { + out.push("| Risk | Mitigation |", "| --- | --- |", ...content.risks.map((risk) => `| ${cell(risk.risk)} | ${cell(risk.mitigation)} |`), ""); + } + out.push("**Open questions**", ""); + if (content.openQuestions.length === 0) { + out.push("_None recorded._", ""); + } else { + out.push(...content.openQuestions.map((question) => `- [ ] ${inline(question)}`), ""); + } + + out.push( + "---", + "", + `_Generated with Draftly using ${modelId} on ${formatUtc(generatedAt)}. Diagrams reflect the canvas at generation time._`, + ); + + const markdown = `${out.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`; + return { markdown, stats }; +} diff --git a/lib/spec/spec-engine.ts b/lib/spec/spec-engine.ts new file mode 100644 index 0000000..9e35c40 --- /dev/null +++ b/lib/spec/spec-engine.ts @@ -0,0 +1,38 @@ +import { generateText, Output, type LanguageModel } from "ai"; + +import { thinking, withSchemaRetry } from "@/lib/ai/model"; +import type { SpecGraph } from "@/lib/spec/spec-graph"; +import { buildSpecPrompt, SPEC_SYSTEM_PROMPT } from "@/lib/spec/spec-prompt"; +import { + sanitizeSpecContent, + specContentSchema, + type RecordedDecision, + type SpecContent, +} from "@/lib/spec/spec-schema"; + +/** Upper bound for the spec call, retries included. */ +const SPEC_CALL_TIMEOUT_MS = 180_000; + +/** Asks the model for the spec's prose and drops anything that doesn't match the canvas. */ +export async function generateSpecContent( + model: LanguageModel, + graph: SpecGraph, + projectName: string, + recordedDecisions: readonly RecordedDecision[], +): Promise { + const { output } = await withSchemaRetry(() => + generateText({ + model, + system: SPEC_SYSTEM_PROMPT, + prompt: buildSpecPrompt(graph, projectName, recordedDecisions), + output: Output.object({ schema: specContentSchema, name: "technical_spec" }), + providerOptions: thinking(model, "medium"), + timeout: { totalMs: SPEC_CALL_TIMEOUT_MS }, + }), + ); + + return sanitizeSpecContent(output, { + groupIds: new Set(graph.groups.map((group) => group.id)), + componentRefs: new Set(graph.components.map((component) => component.ref)), + }); +} diff --git a/lib/spec/spec-file-name.ts b/lib/spec/spec-file-name.ts new file mode 100644 index 0000000..6076f40 --- /dev/null +++ b/lib/spec/spec-file-name.ts @@ -0,0 +1,11 @@ +/** Download file name for a project's spec: `payments-service-spec.md` from "Payments Service". */ +export function toSpecFileName(projectName: string): string { + const slug = projectName + .normalize("NFKD") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 60) + .replace(/-+$/g, ""); + return `${slug || "draftly"}-spec.md`; +} diff --git a/lib/spec/spec-graph.ts b/lib/spec/spec-graph.ts new file mode 100644 index 0000000..296ab60 --- /dev/null +++ b/lib/spec/spec-graph.ts @@ -0,0 +1,222 @@ +import { + CANVAS_SHAPES, + SHAPE_DEFAULTS, + SHAPE_KICKERS, + TEXT_NODE_SHAPE, + type CanvasArrowDirection, + type CanvasEdge, + type CanvasNode, + type CanvasShape, +} from "@/types/canvas"; + +// --------------------------------------------------------------------------- +// Spec graph +// +// The canvas as the spec describes it: drawn components split into groups of +// connected components (each group becomes one diagram section), the +// connections between them, and text notes placed with the group they sit +// next to. Components get short refs ("c1") and groups short ids ("g1") that +// the model uses; the renderer maps them back to names. +// --------------------------------------------------------------------------- + +/** Largest canvas a single spec run covers. */ +export const SPEC_MAX_COMPONENTS = 200; + +/** A note further than this from every group is listed as a general note. */ +const NOTE_ATTACH_DISTANCE = 600; + +export interface SpecComponent { + ref: string; + nodeId: string; + label: string; + kind: string; + shape: CanvasShape; + groupId: string; +} + +export interface SpecConnection { + sourceRef: string; + targetRef: string; + label?: string; + async: boolean; + direction: CanvasArrowDirection; +} + +export interface SpecGroup { + /** "g1", "g2", … in reading order, or "standalone". */ + id: string; + standalone: boolean; + componentRefs: string[]; + /** Indexes into `SpecGraph.connections`. */ + connectionIndexes: number[]; + notes: string[]; +} + +export interface SpecGraph { + components: SpecComponent[]; + connections: SpecConnection[]; + groups: SpecGroup[]; + /** Notes that are not near any group. */ + generalNotes: string[]; + /** Drawn components on the canvas, including any beyond `SPEC_MAX_COMPONENTS`. */ + totalComponents: number; +} + +interface Box { + x: number; + y: number; + width: number; + height: number; +} + +function labelOf(node: CanvasNode): string { + return typeof node.data?.label === "string" ? node.data.label.trim() : ""; +} + +function resolveShape(shape: unknown): CanvasShape { + return (CANVAS_SHAPES as readonly unknown[]).includes(shape) ? (shape as CanvasShape) : "rectangle"; +} + +function boxOf(node: CanvasNode): Box { + const fallback = node.data?.shape === TEXT_NODE_SHAPE ? SHAPE_DEFAULTS.text : SHAPE_DEFAULTS[resolveShape(node.data?.shape)]; + const width = typeof node.style?.width === "number" ? node.style.width : fallback.width; + const height = typeof node.style?.height === "number" ? node.style.height : fallback.height; + return { x: node.position.x, y: node.position.y, width, height }; +} + +/** Distance from a point to the nearest edge of a box; 0 inside it. */ +function distanceToBox(point: { x: number; y: number }, box: Box): number { + const dx = Math.max(box.x - point.x, 0, point.x - (box.x + box.width)); + const dy = Math.max(box.y - point.y, 0, point.y - (box.y + box.height)); + return Math.hypot(dx, dy); +} + +function boundingBox(boxes: readonly Box[]): Box { + const left = Math.min(...boxes.map((box) => box.x)); + const top = Math.min(...boxes.map((box) => box.y)); + const right = Math.max(...boxes.map((box) => box.x + box.width)); + const bottom = Math.max(...boxes.map((box) => box.y + box.height)); + return { x: left, y: top, width: right - left, height: bottom - top }; +} + +export function buildSpecGraph(nodes: readonly CanvasNode[], edges: readonly CanvasEdge[]): SpecGraph { + const drawnAll = nodes.filter((node) => node.data?.shape !== TEXT_NODE_SHAPE && labelOf(node).length > 0); + const drawn = drawnAll.slice(0, SPEC_MAX_COMPONENTS); + const nodeById = new Map(drawn.map((node) => [node.id, node])); + const boxes = new Map(drawn.map((node) => [node.id, boxOf(node)])); + + const keptEdges = edges.filter( + (edge) => nodeById.has(edge.source) && nodeById.has(edge.target) && edge.source !== edge.target, + ); + + // Union-find over connections: each connected set of components is one diagram. + const parent = new Map(drawn.map((node) => [node.id, node.id])); + const find = (id: string): string => { + let root = id; + while (parent.get(root) !== root) { + root = parent.get(root) as string; + } + let current = id; + while (parent.get(current) !== root) { + const next = parent.get(current) as string; + parent.set(current, root); + current = next; + } + return root; + }; + for (const edge of keptEdges) { + const a = find(edge.source); + const b = find(edge.target); + if (a !== b) { + parent.set(a, b); + } + } + + const clusters = new Map(); + for (const node of drawn) { + const root = find(node.id); + clusters.set(root, [...(clusters.get(root) ?? []), node.id]); + } + + const byPosition = (a: string, b: string) => { + const boxA = boxes.get(a) as Box; + const boxB = boxes.get(b) as Box; + return boxA.y - boxB.y || boxA.x - boxB.x; + }; + const clusterBox = (ids: readonly string[]) => boundingBox(ids.map((id) => boxes.get(id) as Box)); + + const connected = [...clusters.values()] + .filter((ids) => ids.length > 1) + .sort((a, b) => { + const boxA = clusterBox(a); + const boxB = clusterBox(b); + return boxA.y - boxB.y || boxA.x - boxB.x; + }); + const singles = [...clusters.values()].filter((ids) => ids.length === 1).flat(); + + const ordered = connected.map((ids, index) => ({ id: `g${index + 1}`, standalone: false, nodeIds: [...ids].sort(byPosition) })); + if (singles.length > 0) { + ordered.push({ id: "standalone", standalone: true, nodeIds: [...singles].sort(byPosition) }); + } + + const refByNodeId = new Map(); + const components: SpecComponent[] = []; + for (const group of ordered) { + for (const nodeId of group.nodeIds) { + const node = nodeById.get(nodeId) as CanvasNode; + const ref = `c${components.length + 1}`; + const shape = resolveShape(node.data?.shape); + refByNodeId.set(nodeId, ref); + components.push({ + ref, + nodeId, + label: labelOf(node), + kind: node.data?.kicker?.trim() || SHAPE_KICKERS[shape], + shape, + groupId: group.id, + }); + } + } + + const connections: SpecConnection[] = keptEdges.map((edge) => { + const label = edge.data?.label?.trim(); + return { + sourceRef: refByNodeId.get(edge.source) as string, + targetRef: refByNodeId.get(edge.target) as string, + ...(label ? { label } : {}), + async: edge.data?.edgeStyle === "dashed" || edge.data?.edgeStyle === "dotted", + direction: edge.data?.arrowDirection ?? "none", + }; + }); + + const groups: SpecGroup[] = ordered.map((group) => ({ + id: group.id, + standalone: group.standalone, + componentRefs: group.nodeIds.map((nodeId) => refByNodeId.get(nodeId) as string), + connectionIndexes: [], + notes: [], + })); + const groupById = new Map(groups.map((group) => [group.id, group])); + const groupIdByRef = new Map(components.map((component) => [component.ref, component.groupId])); + connections.forEach((connection, index) => { + groupById.get(groupIdByRef.get(connection.sourceRef) as string)?.connectionIndexes.push(index); + }); + + const generalNotes: string[] = []; + const groupBoxes = ordered.map((group) => ({ id: group.id, box: clusterBox(group.nodeIds) })); + for (const note of nodes.filter((node) => node.data?.shape === TEXT_NODE_SHAPE && labelOf(node).length > 0)) { + const box = boxOf(note); + const center = { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + const nearest = groupBoxes + .map((entry) => ({ id: entry.id, distance: distanceToBox(center, entry.box) })) + .sort((a, b) => a.distance - b.distance)[0]; + + if (nearest && nearest.distance <= NOTE_ATTACH_DISTANCE) { + groupById.get(nearest.id)?.notes.push(labelOf(note)); + } else { + generalNotes.push(labelOf(note)); + } + } + + return { components, connections, groups, generalNotes, totalComponents: drawnAll.length }; +} diff --git a/lib/spec/spec-prompt.ts b/lib/spec/spec-prompt.ts new file mode 100644 index 0000000..627f496 --- /dev/null +++ b/lib/spec/spec-prompt.ts @@ -0,0 +1,98 @@ +import type { SpecGraph } from "@/lib/spec/spec-graph"; +import type { RecordedDecision } from "@/lib/spec/spec-schema"; + +export const SPEC_SYSTEM_PROMPT = [ + "You are a staff engineer writing the technical specification for a system design drawn on a", + "collaborative canvas. The design is given as structured data: groups of connected components (each", + "group is one diagram), components with refs, names and kinds, the connections between them (with", + "labels, direction, and whether they are asynchronous), notes the designers wrote on the canvas, and", + "decisions recorded when the design was generated with the AI architect.", + "", + "Explain the design as drawn. Never invent components or connections that are not in the input.", + "- overview: two or three short paragraphs for an engineer new to the system.", + "- goals: what the design is built to achieve, as evident from the canvas.", + "- groups: for every group, a short name, its purpose, and its main flow as ordered steps that follow", + " its actual connections, naming components exactly.", + "- components: a one-sentence responsibility for every component.", + "- decisions: the major architectural decisions the design shows — datastores, sync vs async messaging,", + " caching, gateways and entry points, authentication, service boundaries, scaling, integration", + " boundaries. Identify them from the diagram itself (usually 3 to 6 for a design with several", + " components), and add every recorded decision that still matches the canvas on top of those; recorded", + " decisions are never the whole list. For each, what the design does, why it fits, realistic", + " alternatives, trade-offs, the refs of the components involved, and its source. Most consequential first.", + "- risks: real weaknesses of this design (single points of failure, consistency gaps, scaling limits,", + " security exposure), each with a mitigation.", + "- openQuestions: what a reviewer should resolve before building.", + "Use the group ids and component refs exactly as given. Write plain text in every field: no markdown", + "headings, tables, or code blocks.", +].join("\n"); + +const DIRECTION_ARROWS = { + forward: "→", + backward: "←", + bidirectional: "↔", + none: "—", +} as const; + +/** The canvas and recorded decisions as prompt text. */ +export function buildSpecPrompt( + graph: SpecGraph, + projectName: string, + recordedDecisions: readonly RecordedDecision[], +): string { + const componentByRef = new Map(graph.components.map((component) => [component.ref, component])); + const name = (ref: string) => componentByRef.get(ref)?.label ?? ref; + + const sections = [ + `Project: ${projectName.trim() || "Untitled project"}`, + `Totals: ${graph.components.length} components, ${graph.connections.length} connections, ${graph.groups.length} groups.`, + ]; + + for (const group of graph.groups) { + const lines = [ + group.standalone + ? `Group ${group.id} (components not connected to anything):` + : `Group ${group.id}:`, + " Components:", + ...group.componentRefs.map((ref) => { + const component = componentByRef.get(ref); + return ` - ${ref}: ${component?.label ?? ref} (${component?.kind ?? "Component"})`; + }), + ]; + + if (group.connectionIndexes.length > 0) { + lines.push(" Connections:"); + for (const index of group.connectionIndexes) { + const connection = graph.connections[index]; + const details = [connection.label, connection.async ? "async" : "sync"].filter(Boolean).join(", "); + lines.push( + ` - ${name(connection.sourceRef)} (${connection.sourceRef}) ${DIRECTION_ARROWS[connection.direction]} ` + + `${name(connection.targetRef)} (${connection.targetRef}): ${details}`, + ); + } + } + + if (group.notes.length > 0) { + lines.push(" Notes:", ...group.notes.map((note) => ` - ${note}`)); + } + + sections.push(lines.join("\n")); + } + + if (graph.generalNotes.length > 0) { + sections.push(`Other notes on the canvas:\n${graph.generalNotes.map((note) => `- ${note}`).join("\n")}`); + } + + sections.push( + recordedDecisions.length > 0 + ? `Decisions recorded during AI design sessions:\n${recordedDecisions + .map((decision) => { + const alternatives = decision.alternatives.length > 0 ? ` (alternatives: ${decision.alternatives.join(", ")})` : ""; + return `- ${decision.title}: ${decision.choice} — ${decision.rationale}${alternatives}`; + }) + .join("\n")}` + : "Decisions recorded during AI design sessions: none.", + ); + + return sections.join("\n\n"); +} diff --git a/lib/spec/spec-schema.ts b/lib/spec/spec-schema.ts new file mode 100644 index 0000000..347edac --- /dev/null +++ b/lib/spec/spec-schema.ts @@ -0,0 +1,247 @@ +import { z } from "zod"; + +import { SPEC_MAX_COMPONENTS } from "@/lib/spec/spec-graph"; + +// --------------------------------------------------------------------------- +// Spec generation contract +// +// The model writes only prose about the design (overview, group purposes and +// flows, component responsibilities, decisions, risks). Structure — diagrams, +// tables, connections — comes from the canvas in code. Shared by the task, +// the server (validating run output), and the sidebar (status and stages). +// --------------------------------------------------------------------------- + +/** Metadata key carrying the current {@link SpecAgentStage}. */ +export const SPEC_AGENT_STAGE_KEY = "stage"; + +export const SPEC_AGENT_STAGES = ["reading", "writing", "rendering", "done"] as const; + +export type SpecAgentStage = (typeof SPEC_AGENT_STAGES)[number]; + +export function parseSpecAgentStage(value: unknown): SpecAgentStage | null { + return SPEC_AGENT_STAGES.find((stage) => stage === value) ?? null; +} + +/** Abort reasons written for users; shown as they are. */ +export const SPEC_EMPTY_CANVAS_MESSAGE = "Add some components to the canvas before generating a spec."; +export const SPEC_TOO_LARGE_MESSAGE = `The canvas has more than ${SPEC_MAX_COMPONENTS} components, which is too many for one spec.`; + +/** Size limits for model-written lists; stated in descriptions, trimmed in code. */ +export const SPEC_LIST_LIMITS = { + goals: 6, + flowSteps: 8, + decisions: 8, + alternatives: 3, + risks: 8, + openQuestions: 8, +} as const; + +/** Decisions recorded in AI design sessions passed to one spec run. */ +export const RECORDED_DECISION_LIMIT = 12; + +export const DECISION_SOURCES = ["recorded", "stated", "inferred"] as const; + +export type DecisionSource = (typeof DECISION_SOURCES)[number]; + +function atMost(limit: number, description: string): string { + return `${description} At most ${limit} items.`; +} + +export const specContentSchema = z.object({ + overview: z + .string() + .min(1) + .describe("Two or three short paragraphs: what the system does, how it is structured, and what matters most."), + goals: z + .array(z.string()) + .describe(atMost(SPEC_LIST_LIMITS.goals, "What the design is built to achieve, as evident from the canvas.")), + groups: z + .array( + z.object({ + groupId: z.string().describe("A group id exactly as given in the input, e.g. 'g1' or 'standalone'."), + name: z.string().min(1).describe("A short name for this diagram, e.g. 'Checkout request path'."), + purpose: z.string().describe("One or two sentences on what this part of the system does."), + flow: z + .array(z.string()) + .describe( + atMost( + SPEC_LIST_LIMITS.flowSteps, + "The main flow through this group as ordered steps that follow its actual connections, naming components exactly.", + ), + ), + }), + ) + .describe("One entry per group in the input."), + components: z + .array( + z.object({ + componentId: z.string().describe("A component ref exactly as given in the input, e.g. 'c3'."), + responsibility: z.string().describe("What the component is responsible for, in one sentence."), + }), + ) + .describe("One entry per component in the input."), + decisions: z + .array( + z.object({ + title: z.string().min(1).describe("The question the decision answers, e.g. 'Primary datastore'."), + decision: z.string().min(1).describe("What the design does."), + rationale: z.string().describe("Why it fits this system."), + alternatives: z.array(z.string()).describe(atMost(SPEC_LIST_LIMITS.alternatives, "Realistic alternatives.")), + tradeoffs: z.string().describe("What the choice costs or risks."), + componentIds: z.array(z.string()).describe("Refs of the components involved."), + source: z + .enum(DECISION_SOURCES) + .describe( + "'recorded' when it comes from the recorded decisions, 'stated' when a canvas note or connection " + + "label states it, otherwise 'inferred'.", + ), + }), + ) + .describe(atMost(SPEC_LIST_LIMITS.decisions, "The major architectural decisions, most consequential first.")), + risks: z + .array(z.object({ risk: z.string(), mitigation: z.string() })) + .describe(atMost(SPEC_LIST_LIMITS.risks, "Risks in the design, each with a mitigation.")), + openQuestions: z + .array(z.string()) + .describe(atMost(SPEC_LIST_LIMITS.openQuestions, "Questions a reviewer should resolve.")), +}); + +export type SpecContent = z.infer; + +export interface KnownSpecIds { + groupIds: ReadonlySet; + componentRefs: ReadonlySet; +} + +/** Parenthesised refs such as "(c3)", "(c1, c4)", "(g2)" or "(standalone)" copied into prose. */ +const SPEC_REF_MENTION = /[ \t]*\((?:\s*(?:c\d+|g\d+|standalone)\s*,?)+\)/g; + +/** Removes internal refs from text shown to readers, who only know components by name. */ +export function withoutSpecRefs(text: string): string { + return text.replace(SPEC_REF_MENTION, ""); +} + +/** + * Trims the model's prose to the list limits, removes internal refs the model + * copied into the text, and drops anything that refers to a group or component + * the canvas does not have. + */ +export function sanitizeSpecContent(content: SpecContent, known: KnownSpecIds): SpecContent { + const clean = (value: string) => withoutSpecRefs(value).replace(/\s+/g, " ").trim(); + const cleanList = (values: readonly string[], limit: number) => values.map(clean).filter(Boolean).slice(0, limit); + + const seenGroups = new Set(); + const groups = content.groups.flatMap((group) => { + if (!known.groupIds.has(group.groupId) || seenGroups.has(group.groupId)) { + return []; + } + seenGroups.add(group.groupId); + return [ + { + groupId: group.groupId, + name: clean(group.name), + purpose: clean(group.purpose), + flow: cleanList(group.flow, SPEC_LIST_LIMITS.flowSteps), + }, + ]; + }); + + const seenComponents = new Set(); + const components = content.components.flatMap((component) => { + const responsibility = clean(component.responsibility); + if (!known.componentRefs.has(component.componentId) || seenComponents.has(component.componentId) || !responsibility) { + return []; + } + seenComponents.add(component.componentId); + return [{ componentId: component.componentId, responsibility }]; + }); + + const decisions = content.decisions + .flatMap((decision) => { + const title = clean(decision.title); + const choice = clean(decision.decision); + if (!title || !choice) { + return []; + } + return [ + { + title, + decision: choice, + rationale: clean(decision.rationale), + alternatives: cleanList(decision.alternatives, SPEC_LIST_LIMITS.alternatives), + tradeoffs: clean(decision.tradeoffs), + componentIds: [...new Set(decision.componentIds.filter((id) => known.componentRefs.has(id)))], + source: decision.source, + }, + ]; + }) + .slice(0, SPEC_LIST_LIMITS.decisions); + + const risks = content.risks + .flatMap((entry) => { + const risk = clean(entry.risk); + return risk ? [{ risk, mitigation: clean(entry.mitigation) }] : []; + }) + .slice(0, SPEC_LIST_LIMITS.risks); + + return { + overview: withoutSpecRefs(content.overview).trim(), + goals: cleanList(content.goals, SPEC_LIST_LIMITS.goals), + groups, + components, + decisions, + risks, + openQuestions: cleanList(content.openQuestions, SPEC_LIST_LIMITS.openQuestions), + }; +} + +// --------------------------------------------------------------------------- +// Run contract and status +// --------------------------------------------------------------------------- + +export interface RecordedDecision { + title: string; + choice: string; + rationale: string; + alternatives: string[]; +} + +export interface SpecAgentPayload { + roomId: string; + projectName: string; + /** Decisions stored in the requesting user's AI design sessions, newest first. */ + recordedDecisions: RecordedDecision[]; +} + +export const specStatsSchema = z.object({ + components: z.number().int().nonnegative(), + connections: z.number().int().nonnegative(), + diagrams: z.number().int().nonnegative(), + decisions: z.number().int().nonnegative(), +}); + +export type SpecStats = z.infer; + +/** Output of one spec-agent run. The server validates it before storing the Markdown. */ +export const specAgentResultSchema = z.object({ + markdown: z.string().min(1), + stats: specStatsSchema, +}); + +export type SpecAgentResult = z.infer; + +export interface SpecSummary { + runId: string; + generatedAt: string; + stats: SpecStats | null; +} + +/** What `GET /api/projects/[projectId]/spec` returns. */ +export interface SpecStatus { + /** The stored spec, if one has been generated. */ + spec: SpecSummary | null; + /** A spec run that is still in progress. */ + pendingRunId: string | null; + /** Why the latest run failed, when it is newer than the stored spec. */ + lastFailure: string | null; +} diff --git a/lib/spec/spec-store.ts b/lib/spec/spec-store.ts new file mode 100644 index 0000000..5629977 --- /dev/null +++ b/lib/spec/spec-store.ts @@ -0,0 +1,265 @@ +import { ApiError, runs, tasks } from "@trigger.dev/sdk/v3"; +import { del, get, put } from "@vercel/blob"; + +import { readResultPayload } from "@/lib/ai/agent-schema"; +import { describeRunFailure } from "@/lib/ai/run-failure"; +import { prisma } from "@/lib/prisma"; +import { + RECORDED_DECISION_LIMIT, + SPEC_EMPTY_CANVAS_MESSAGE, + SPEC_TOO_LARGE_MESSAGE, + specAgentResultSchema, + specStatsSchema, + type RecordedDecision, + type SpecAgentPayload, + type SpecStatus, +} from "@/lib/spec/spec-schema"; +import type { specAgentTask } from "@/src/trigger/spec-agent"; + +// --------------------------------------------------------------------------- +// Spec storage +// +// One spec per project: the latest Markdown lives in private Vercel Blob +// storage, and the project row holds its URL, the run that produced it, when, +// and its counts. Like AI turns, a finished spec run is stored when the status +// is read, so the task never touches the database or blob storage and a spec +// is saved even if the tab closed mid-run. +// --------------------------------------------------------------------------- + +export type RetrievedSpecRun = + | { state: "running" } + | { state: "missing" } + | { state: "failed"; message?: string } + | { state: "succeeded"; output: unknown; finishedAt: Date }; + +/** External calls, injectable so the storing logic can be tested without Trigger.dev or Blob. */ +export interface SpecStoreDeps { + retrieveRun: (runId: string) => Promise; + uploadMarkdown: (projectId: string, markdown: string) => Promise; + deleteBlob: (url: string) => Promise; +} + +const CANCELLED_MESSAGE = "The spec run was cancelled."; + +async function retrieveFromTrigger(runId: string): Promise { + try { + const run = await runs.retrieve(runId); + if (run.isSuccess) { + return { state: "succeeded", output: run.output, finishedAt: run.finishedAt ?? new Date() }; + } + if (run.isCancelled) { + return { state: "failed", message: CANCELLED_MESSAGE }; + } + if (run.isFailed) { + return { state: "failed", message: run.error?.message }; + } + return { state: "running" }; + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + return { state: "missing" }; + } + console.error("[spec] failed to retrieve run", runId, error); + return { state: "running" }; + } +} + +const defaultDeps: SpecStoreDeps = { + retrieveRun: retrieveFromTrigger, + uploadMarkdown: async (projectId, markdown) => { + const uploaded = await put(`spec-${projectId}.md`, markdown, { + access: "private", + addRandomSuffix: true, + contentType: "text/markdown; charset=utf-8", + }); + return uploaded.url; + }, + deleteBlob: async (url) => { + await del(url); + }, +}; + +const specSelect = { + specMdPath: true, + specRunId: true, + specGeneratedAt: true, + specStats: true, +} as const; + +type ProjectSpecRow = { + specMdPath: string | null; + specRunId: string | null; + specGeneratedAt: Date | null; + specStats: unknown; +}; + +function toStatus( + project: ProjectSpecRow | null, + { pendingRunId = null, lastFailure = null }: { pendingRunId?: string | null; lastFailure?: string | null } = {}, +): SpecStatus { + const stats = specStatsSchema.safeParse(project?.specStats); + const spec = + project?.specMdPath && project.specRunId && project.specGeneratedAt + ? { + runId: project.specRunId, + generatedAt: project.specGeneratedAt.toISOString(), + stats: stats.success ? stats.data : null, + } + : null; + return { spec, pendingRunId, lastFailure }; +} + +/** + * The project's spec status. When the latest spec run finished after the + * stored spec, its Markdown is uploaded and recorded first. The update is + * guarded on the run id, so concurrent reads store a run only once. + */ +export async function getSpecStatus(projectId: string, deps: SpecStoreDeps = defaultDeps): Promise { + const project = await prisma.project.findUnique({ where: { id: projectId }, select: specSelect }); + if (!project) { + return toStatus(null); + } + + const latestRun = await prisma.taskRun.findFirst({ + where: { projectId, kind: "SPEC" }, + orderBy: { createdAt: "desc" }, + select: { runId: true }, + }); + if (!latestRun || latestRun.runId === project.specRunId) { + return toStatus(project); + } + + const run = await deps.retrieveRun(latestRun.runId); + if (run.state === "running") { + return toStatus(project, { pendingRunId: latestRun.runId }); + } + if (run.state === "missing") { + return toStatus(project, { lastFailure: "The spec run could not be found. Try again." }); + } + if (run.state === "failed") { + console.error("[spec] spec-agent run failed", latestRun.runId, run.message); + return toStatus(project, { + lastFailure: describeRunFailure(run.message, { + generic: "Something went wrong generating the spec. Try again.", + timeout: "Spec generation took too long. Try again.", + passthrough: [SPEC_EMPTY_CANVAS_MESSAGE, SPEC_TOO_LARGE_MESSAGE, CANCELLED_MESSAGE], + }), + }); + } + + const parsed = specAgentResultSchema.safeParse(run.output); + if (!parsed.success) { + console.error("[spec] unexpected spec-agent output", latestRun.runId, parsed.error.issues); + return toStatus(project, { lastFailure: "Spec generation returned an unexpected result. Try again." }); + } + + const url = await deps.uploadMarkdown(projectId, parsed.data.markdown); + const { count } = await prisma.project.updateMany({ + where: { id: projectId, OR: [{ specRunId: null }, { specRunId: { not: latestRun.runId } }] }, + data: { + specMdPath: url, + specRunId: latestRun.runId, + specGeneratedAt: run.finishedAt, + specStats: parsed.data.stats, + }, + }); + + if (count === 0) { + // A concurrent read stored this run first; drop the duplicate upload. + await deps.deleteBlob(url).catch(() => undefined); + } else if (project.specMdPath && project.specMdPath !== url) { + await deps.deleteBlob(project.specMdPath).catch(() => undefined); + } + + return toStatus(await prisma.project.findUnique({ where: { id: projectId }, select: specSelect })); +} + +/** Decisions from the user's unexpired AI design sessions in this project, newest first, one per title. */ +export async function loadRecordedDecisions(projectId: string, userId: string): Promise { + const messages = await prisma.aiMessage.findMany({ + where: { + kind: "RESULT", + status: "COMPLETE", + session: { projectId, userId, expiresAt: { gt: new Date() } }, + }, + orderBy: { createdAt: "desc" }, + take: 20, + select: { payload: true }, + }); + + const seen = new Set(); + const decisions: RecordedDecision[] = []; + for (const message of messages) { + for (const decision of readResultPayload(message.payload)?.decisions ?? []) { + const key = decision.title.trim().toLowerCase(); + if (seen.has(key)) { + continue; + } + seen.add(key); + decisions.push({ + title: decision.title, + choice: decision.choice, + rationale: decision.rationale, + alternatives: decision.alternatives, + }); + } + } + return decisions.slice(0, RECORDED_DECISION_LIMIT); +} + +export type StartSpecResult = + | { ok: true; runId: string } + | { ok: false; status: 409; runId: string; error: string } + | { ok: false; status: 502; error: string }; + +/** Starts a spec run for the project, unless one is already in progress. */ +export async function startSpecRun({ + projectId, + userId, + projectName, +}: { + projectId: string; + userId: string; + projectName: string; +}): Promise { + const current = await getSpecStatus(projectId); + if (current.pendingRunId) { + return { ok: false, status: 409, runId: current.pendingRunId, error: "A spec is already being generated" }; + } + + const payload: SpecAgentPayload = { + roomId: projectId, + projectName, + recordedDecisions: await loadRecordedDecisions(projectId, userId), + }; + + let runId: string; + try { + const handle = await tasks.trigger("spec-agent", payload); + runId = handle.id; + } catch (error) { + console.error("[spec] failed to trigger spec-agent", error); + return { ok: false, status: 502, error: "Spec service unavailable" }; + } + + await prisma.taskRun.create({ data: { runId, projectId, userId, kind: "SPEC" } }); + return { ok: true, runId }; +} + +/** The stored spec's Markdown, or null when there is none or it cannot be read. */ +export async function readSpecMarkdown(projectId: string): Promise { + const project = await prisma.project.findUnique({ where: { id: projectId }, select: { specMdPath: true } }); + if (!project?.specMdPath) { + return null; + } + + try { + const result = await get(project.specMdPath, { access: "private", useCache: false }); + if (!result || result.statusCode !== 200) { + return null; + } + return await new Response(result.stream).text(); + } catch (error) { + console.error("[spec] failed to read spec blob", projectId, error); + return null; + } +} diff --git a/prisma/migrations/20260915195650_add_project_spec/migration.sql b/prisma/migrations/20260915195650_add_project_spec/migration.sql new file mode 100644 index 0000000..f172d40 --- /dev/null +++ b/prisma/migrations/20260915195650_add_project_spec/migration.sql @@ -0,0 +1,14 @@ +-- CreateEnum +CREATE TYPE "TaskRunKind" AS ENUM ('DESIGN', 'SPEC'); + +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "specGeneratedAt" TIMESTAMP(3), +ADD COLUMN "specMdPath" TEXT, +ADD COLUMN "specRunId" TEXT, +ADD COLUMN "specStats" JSONB; + +-- AlterTable +ALTER TABLE "TaskRun" ADD COLUMN "kind" "TaskRunKind" NOT NULL DEFAULT 'DESIGN'; + +-- CreateIndex +CREATE INDEX "TaskRun_projectId_kind_createdAt_idx" ON "TaskRun"("projectId", "kind", "createdAt"); diff --git a/prisma/models/project.prisma b/prisma/models/project.prisma index 0ceb925..c07e9f6 100644 --- a/prisma/models/project.prisma +++ b/prisma/models/project.prisma @@ -10,6 +10,11 @@ model Project { description String? status ProjectStatus @default(DRAFT) canvasJsonPath String? + // Latest generated Markdown spec: blob URL, the run that produced it, when, and its counts. + specMdPath String? + specRunId String? + specGeneratedAt DateTime? + specStats Json? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt collaborators ProjectCollaborator[] diff --git a/prisma/models/task-run.prisma b/prisma/models/task-run.prisma index d513e45..95eb52f 100644 --- a/prisma/models/task-run.prisma +++ b/prisma/models/task-run.prisma @@ -1,10 +1,19 @@ +// What a Trigger.dev run was started for. The token route authorises any kind; +// the spec routes look up the latest SPEC run for a project. +enum TaskRunKind { + DESIGN + SPEC +} + model TaskRun { - id String @id @default(cuid()) - runId String @unique + id String @id @default(cuid()) + runId String @unique projectId String userId String - createdAt DateTime @default(now()) + kind TaskRunKind @default(DESIGN) + createdAt DateTime @default(now()) @@index([runId]) @@index([userId, projectId]) + @@index([projectId, kind, createdAt]) } diff --git a/src/trigger/design-agent.ts b/src/trigger/design-agent.ts index e74541d..646feba 100644 --- a/src/trigger/design-agent.ts +++ b/src/trigger/design-agent.ts @@ -3,6 +3,7 @@ import { logger, metadata, task } from "@trigger.dev/sdk/v3"; import type { LanguageModel } from "ai"; import type { DesignAgentPayload, DesignAgentResult, DesignBrief, DesignPlan } from "@/lib/ai/agent-schema"; +import { summarizeCanvas, type CanvasSummary } from "@/lib/ai/canvas-summary"; import { analyzeTurn, draftPlan, @@ -11,6 +12,7 @@ import { resolveDesignModel, } from "@/lib/ai/design-agent-engine"; import { buildCanvasGraph, DESIGN_AGENT_STAGE_KEY, type DesignAgentStage } from "@/lib/design-generation"; +import { readRoomSnapshot } from "@/lib/canvas-room"; import { getLiveblocksClient } from "@/lib/liveblocks"; import { SHAPE_DEFAULTS, type CanvasEdge, type CanvasNode, type CanvasShape } from "@/types/canvas"; @@ -53,6 +55,12 @@ function resolveOrigin(existingNodes: readonly CanvasNode[]): { x: number; y: nu return { x: left, y: bottom + EXISTING_CONTENT_GAP }; } +/** Reads the room without changing it, so the agent knows what is already drawn. */ +async function readCanvas(roomId: string): Promise { + const { nodes, edges } = await readRoomSnapshot(roomId); + return summarizeCanvas(nodes, edges); +} + /** Generates the diagram for an approved plan and writes it into the room. */ async function drawPlan( model: LanguageModel, @@ -60,18 +68,32 @@ async function drawPlan( runId: string, brief: DesignBrief, plan: DesignPlan, + canvas: CanvasSummary, ): Promise<{ nodeCount: number; edgeCount: number }> { setStage("generating"); - const design = await generateDesignGraph(model, brief, plan); - logger.log("Design generated", { nodeCount: design.nodes.length, edgeCount: design.edges.length }); + const { graph, issues, repairedIssues } = await generateDesignGraph(model, brief, plan, canvas); + logger.log("Design generated", { + nodeCount: graph.nodes.length, + edgeCount: graph.edges.length, + repairedIssues, + remainingIssues: issues, + }); setStage("writing"); let counts = { nodeCount: 0, edgeCount: 0 }; await mutateFlow({ client: getLiveblocksClient(), roomId }, (flow) => { - const { nodes, edges } = buildCanvasGraph(design, { + // Collaborators may have deleted components since the canvas was read; refs to them are dropped. + const liveNodeIds = new Set(flow.nodes.map((node) => node.id)); + const summary: CanvasSummary = { + ...canvas, + components: canvas.components.filter((component) => liveNodeIds.has(component.nodeId)), + }; + + const { nodes, edges } = buildCanvasGraph(graph, { idPrefix: runId, origin: resolveOrigin(flow.nodes), + existing: { summary, nodes: flow.nodes, edges: flow.edges }, }); flow.addNodes(nodes); @@ -87,7 +109,9 @@ async function drawPlan( /** * One turn of a design session. Reads the user's turn, then either asks * clarifying questions, proposes (or revises) a plan, or draws the approved - * plan on the canvas. The server stores the returned result on the session. + * plan on the canvas. Every step sees what is already on the canvas, so + * follow-up requests extend the existing design. The server stores the + * returned result on the session. */ export const designAgentTask = task({ id: "design-agent", @@ -95,23 +119,30 @@ export const designAgentTask = task({ // whole turn on top multiplied quota use (up to 9 calls per failure) and could // write a generated diagram twice; a failed turn is shown to the user instead. retry: { maxAttempts: 1 }, - // A turn makes at most two model calls, each bounded by its own timeout; this - // is the backstop so a stuck turn fails and settles instead of staying pending. + // A turn makes at most three model calls (analyze and plan, or generate and a + // repair), each bounded by its own timeout; this is the backstop so a stuck + // turn fails and settles instead of staying pending. maxDuration: 300, run: async (payload: DesignAgentPayload, { ctx }): Promise => { logger.log("Design agent turn", { roomId: payload.roomId, intent: payload.intent }); const model = resolveDesignModel(); + const canvas = await readCanvas(payload.roomId); + logger.log("Canvas read", { + components: canvas.totalComponents, + connections: canvas.connections.length, + notes: canvas.notes.length, + }); // An explicit approval skips analysis: the plan is already agreed. if (payload.intent === "generate" && payload.plan) { const brief = payload.brief ?? emptyBrief(); - const counts = await drawPlan(model, payload.roomId, ctx.run.id, brief, payload.plan); + const counts = await drawPlan(model, payload.roomId, ctx.run.id, brief, payload.plan, canvas); setStage("done"); return { action: "generated", reply: "", ...counts, decisions: payload.plan.decisions, brief }; } setStage("analyzing"); - const analysis = await analyzeTurn(model, payload); + const analysis = await analyzeTurn(model, payload, canvas); logger.log("Turn analyzed", { decision: analysis.decision, questions: analysis.questions.length }); if (analysis.decision === "ask") { @@ -120,7 +151,7 @@ export const designAgentTask = task({ } if (analysis.decision === "generate" && payload.plan) { - const counts = await drawPlan(model, payload.roomId, ctx.run.id, analysis.brief, payload.plan); + const counts = await drawPlan(model, payload.roomId, ctx.run.id, analysis.brief, payload.plan, canvas); setStage("done"); return { action: "generated", @@ -132,7 +163,7 @@ export const designAgentTask = task({ } setStage("planning"); - const plan = await draftPlan(model, analysis.brief, payload.plan, payload.input); + const plan = await draftPlan(model, analysis.brief, payload.plan, payload.input, canvas); logger.log("Plan drafted", { components: plan.components.length, decisions: plan.decisions.length }); setStage("done"); diff --git a/src/trigger/spec-agent.ts b/src/trigger/spec-agent.ts new file mode 100644 index 0000000..bc192db --- /dev/null +++ b/src/trigger/spec-agent.ts @@ -0,0 +1,67 @@ +import { AbortTaskRunError, logger, metadata, task } from "@trigger.dev/sdk/v3"; + +import { modelIdOf, resolveDesignModel } from "@/lib/ai/model"; +import { readRoomSnapshot } from "@/lib/canvas-room"; +import { renderSpecMarkdown } from "@/lib/spec/render-markdown"; +import { generateSpecContent } from "@/lib/spec/spec-engine"; +import { buildSpecGraph, SPEC_MAX_COMPONENTS } from "@/lib/spec/spec-graph"; +import { + SPEC_AGENT_STAGE_KEY, + SPEC_EMPTY_CANVAS_MESSAGE, + SPEC_TOO_LARGE_MESSAGE, + type SpecAgentPayload, + type SpecAgentResult, + type SpecAgentStage, +} from "@/lib/spec/spec-schema"; + +export type { SpecAgentPayload, SpecAgentResult }; + +function setStage(stage: SpecAgentStage) { + metadata.set(SPEC_AGENT_STAGE_KEY, stage); +} + +/** + * Turns the project's canvas into a Markdown technical spec. Reads the room, + * asks the model once for the prose, and renders the document in code. The + * server stores the returned Markdown in blob storage when the status is read. + */ +export const specAgentTask = task({ + id: "spec-agent", + // The model call already retries transient errors; a failed spec is shown to the user instead. + retry: { maxAttempts: 1 }, + maxDuration: 300, + run: async (payload: SpecAgentPayload): Promise => { + setStage("reading"); + const { nodes, edges } = await readRoomSnapshot(payload.roomId); + const graph = buildSpecGraph(nodes, edges); + logger.log("Canvas read for spec", { + roomId: payload.roomId, + components: graph.totalComponents, + connections: graph.connections.length, + groups: graph.groups.length, + recordedDecisions: payload.recordedDecisions.length, + }); + + if (graph.components.length === 0) { + throw new AbortTaskRunError(SPEC_EMPTY_CANVAS_MESSAGE); + } + if (graph.totalComponents > SPEC_MAX_COMPONENTS) { + throw new AbortTaskRunError(SPEC_TOO_LARGE_MESSAGE); + } + + setStage("writing"); + const model = resolveDesignModel(); + const content = await generateSpecContent(model, graph, payload.projectName, payload.recordedDecisions); + + setStage("rendering"); + const { markdown, stats } = renderSpecMarkdown(graph, content, { + projectName: payload.projectName, + generatedAt: new Date(), + modelId: modelIdOf(model), + }); + logger.log("Spec rendered", { ...stats, characters: markdown.length }); + + setStage("done"); + return { markdown, stats }; + }, +}); diff --git a/types/canvas.ts b/types/canvas.ts index bbb6f65..99b76fb 100644 --- a/types/canvas.ts +++ b/types/canvas.ts @@ -38,6 +38,20 @@ export const SHAPE_DEFAULTS: Record = { + rectangle: "Service", + circle: "Event", + diamond: "Decision", + pill: "Queue", + cylinder: "Database", + hexagon: "External", + text: "Note", +}; + // --------------------------------------------------------------------------- // Node color palette // --------------------------------------------------------------------------- @@ -124,6 +138,32 @@ export function resolveNodeFill(color: string | undefined): NodeFill { return NODE_FILLS.find((fill) => fill.id === fillId) ?? NODE_FILLS[0]; } +/** + * What a component does in the system. Its fill follows from the role, so starter templates and + * AI-generated designs colour components the same way: + * entry -> draft blue clients, DNS, CDNs, load balancers, gateways + * compute -> paper services, APIs, workers + * messaging -> scrap coral queues, topics, streams, brokers + * data -> marker amber databases, caches, object storage + * output -> cut sage external systems, consumers, delivery + */ +export const NODE_ROLES = ["entry", "compute", "messaging", "data", "output"] as const; + +export type NodeRole = (typeof NODE_ROLES)[number]; + +export const NODE_ROLE_FILLS: Record = { + entry: "blue", + compute: "paper", + messaging: "coral", + data: "amber", + output: "sage", +}; + +export function getRoleFill(role: NodeRole): NodeFill { + const fillId = NODE_ROLE_FILLS[role]; + return NODE_FILLS.find((fill) => fill.id === fillId) ?? NODE_FILLS[0]; +} + // --------------------------------------------------------------------------- // Node / edge types // ---------------------------------------------------------------------------