From d960649c60007d7f4c5ad8c6c9bc1f8ff56716eb Mon Sep 17 00:00:00 2001 From: suharshit singh Date: Tue, 15 Sep 2026 22:16:16 +0530 Subject: [PATCH 1/9] feat(ai-sessions): add persistent storage for AI design sessions The AI sidebar kept its chat in React state only, so a reload lost the transcript and any in-flight run. This adds the storage layer the multi-turn design agent will build on (not wired to the UI yet). - AiSession / AiMessage Prisma models + additive migration; sessions cascade with their project, messages cascade with their session - lib/ai/session-store.ts: 7-day sliding expiry, 10 sessions per user per project, 60 messages per session; reads ignore expired rows - GET/POST /api/projects/[projectId]/ai-sessions and GET/DELETE /api/projects/[projectId]/ai-sessions/[sessionId], private to the session's creator - Daily Vercel Cron cleanup at /api/cron/ai-sessions/cleanup, gated by CRON_SECRET (public in proxy.ts) Co-Authored-By: Claude Opus 5 --- app/api/cron/ai-sessions/cleanup/route.ts | 28 +++ .../ai-sessions/[sessionId]/route.ts | 66 ++++++ .../projects/[projectId]/ai-sessions/route.ts | 72 +++++++ context/architecture-context.md | 5 + context/progress-tracker.md | 37 +++- lib/ai/session-store.ts | 188 ++++++++++++++++++ .../migration.sql | 60 ++++++ prisma/models/ai-session.prisma | 67 +++++++ prisma/models/project.prisma | 1 + proxy.ts | 2 + types/ai-session.ts | 42 ++++ vercel.json | 9 + 12 files changed, 576 insertions(+), 1 deletion(-) create mode 100644 app/api/cron/ai-sessions/cleanup/route.ts create mode 100644 app/api/projects/[projectId]/ai-sessions/[sessionId]/route.ts create mode 100644 app/api/projects/[projectId]/ai-sessions/route.ts create mode 100644 lib/ai/session-store.ts create mode 100644 prisma/migrations/20260915163917_add_ai_sessions/migration.sql create mode 100644 prisma/models/ai-session.prisma create mode 100644 types/ai-session.ts create mode 100644 vercel.json diff --git a/app/api/cron/ai-sessions/cleanup/route.ts b/app/api/cron/ai-sessions/cleanup/route.ts new file mode 100644 index 0000000..25fc335 --- /dev/null +++ b/app/api/cron/ai-sessions/cleanup/route.ts @@ -0,0 +1,28 @@ +import { timingSafeEqual } from "node:crypto"; + +import { deleteExpiredSessions } from "@/lib/ai/session-store"; + +/** + * Vercel Cron calls this daily (see `vercel.json`) with + * `Authorization: Bearer $CRON_SECRET`. The route is public in `proxy.ts`, so + * the secret is the only gate. + */ +function isAuthorized(request: Request): boolean { + const secret = process.env.CRON_SECRET; + if (!secret) { + return false; + } + + const provided = Buffer.from(request.headers.get("authorization") ?? ""); + const expected = Buffer.from(`Bearer ${secret}`); + return provided.length === expected.length && timingSafeEqual(provided, expected); +} + +export async function GET(request: Request) { + if (!isAuthorized(request)) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const deletedSessions = await deleteExpiredSessions(); + return Response.json({ deletedSessions }); +} diff --git a/app/api/projects/[projectId]/ai-sessions/[sessionId]/route.ts b/app/api/projects/[projectId]/ai-sessions/[sessionId]/route.ts new file mode 100644 index 0000000..5482e64 --- /dev/null +++ b/app/api/projects/[projectId]/ai-sessions/[sessionId]/route.ts @@ -0,0 +1,66 @@ +import { auth } from "@clerk/nextjs/server"; + +import { deleteSession, getSession } from "@/lib/ai/session-store"; +import { getAccessibleProject, getCurrentIdentity } from "@/lib/project-access"; + +type SessionRouteContext = { params: Promise<{ projectId: string; sessionId: string }> }; + +function unauthorizedResponse() { + return Response.json({ error: "Unauthorized" }, { status: 401 }); +} + +function forbiddenResponse() { + return Response.json({ error: "Forbidden" }, { status: 403 }); +} + +// Sessions are private to their creator, so another user's session id reads +// the same as one that never existed. +function notFoundResponse() { + return Response.json({ error: "Session not found" }, { status: 404 }); +} + +async function resolveScope(projectId: string) { + const { userId } = await auth(); + if (!userId) { + return { error: unauthorizedResponse() } as const; + } + + const { primaryEmail } = await getCurrentIdentity(); + const project = await getAccessibleProject(userId, primaryEmail, projectId); + if (!project) { + return { error: forbiddenResponse() } as const; + } + + return { scope: { projectId: project.id, userId } } as const; +} + +/** Returns one of the current user's sessions with its full transcript. */ +export async function GET(_request: Request, context: SessionRouteContext) { + const { projectId, sessionId } = await context.params; + const result = await resolveScope(projectId); + if ("error" in result) { + return result.error; + } + + const session = await getSession(result.scope, sessionId); + if (!session) { + return notFoundResponse(); + } + + return Response.json({ session }); +} + +export async function DELETE(_request: Request, context: SessionRouteContext) { + const { projectId, sessionId } = await context.params; + const result = await resolveScope(projectId); + if ("error" in result) { + return result.error; + } + + const deleted = await deleteSession(result.scope, sessionId); + if (!deleted) { + return notFoundResponse(); + } + + return new Response(null, { status: 204 }); +} diff --git a/app/api/projects/[projectId]/ai-sessions/route.ts b/app/api/projects/[projectId]/ai-sessions/route.ts new file mode 100644 index 0000000..2806dd5 --- /dev/null +++ b/app/api/projects/[projectId]/ai-sessions/route.ts @@ -0,0 +1,72 @@ +import { auth } from "@clerk/nextjs/server"; + +import { createSession, DEFAULT_SESSION_TITLE, listSessions } from "@/lib/ai/session-store"; +import { getAccessibleProject, getCurrentIdentity } from "@/lib/project-access"; + +function unauthorizedResponse() { + return Response.json({ error: "Unauthorized" }, { status: 401 }); +} + +function forbiddenResponse() { + return Response.json({ error: "Forbidden" }, { status: 403 }); +} + +async function resolveScope(projectId: string) { + const { userId } = await auth(); + if (!userId) { + return { error: unauthorizedResponse() } as const; + } + + const { primaryEmail } = await getCurrentIdentity(); + const project = await getAccessibleProject(userId, primaryEmail, projectId); + if (!project) { + return { error: forbiddenResponse() } as const; + } + + return { scope: { projectId: project.id, userId } } as const; +} + +/** Optional `title`; anything else in the body is ignored. */ +function parseTitle(value: unknown): string { + if (typeof value === "object" && value !== null && "title" in value && typeof value.title === "string") { + return value.title; + } + return DEFAULT_SESSION_TITLE; +} + +/** Lists the current user's unexpired AI sessions in this project, most recent first. */ +export async function GET( + _request: Request, + context: { params: Promise<{ projectId: string }> }, +) { + const { projectId } = await context.params; + const result = await resolveScope(projectId); + if ("error" in result) { + return result.error; + } + + const sessions = await listSessions(result.scope); + return Response.json({ sessions }); +} + +/** Starts a new AI session for the current user in this project. */ +export async function POST( + request: Request, + context: { params: Promise<{ projectId: string }> }, +) { + const { projectId } = await context.params; + const result = await resolveScope(projectId); + if ("error" in result) { + return result.error; + } + + let body: unknown = null; + try { + body = await request.json(); + } catch { + // An empty body is allowed; the session gets the default title. + } + + const session = await createSession(result.scope, parseTitle(body)); + return Response.json({ session }, { status: 201 }); +} diff --git a/context/architecture-context.md b/context/architecture-context.md index 50654ef..44bb7b4 100644 --- a/context/architecture-context.md +++ b/context/architecture-context.md @@ -22,6 +22,11 @@ - **Database**: Stores project metadata, user ownership, collaborator links, and references to generated artifacts. - **File System / Blob Storage**: Stores large generated artifacts including Markdown technical specs and canvas snapshots. +- **AI sessions (database, short-lived)**: Design agent chats live in `AiSession` / `AiMessage` (small text + JSON payloads). + Sessions are private to the user who created them within a project, expire 7 days after last activity + (`expiresAt` slides forward on activity), are capped at 10 per user per project and 60 messages each, and are + 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. ## Auth and Access Model diff --git a/context/progress-tracker.md b/context/progress-tracker.md index c46861c..5549924 100644 --- a/context/progress-tracker.md +++ b/context/progress-tracker.md @@ -8,7 +8,9 @@ Update this file whenever the current phase, active feature, or implementation s ## Current Goal -- Implement the Specs tab (Generate Spec + download), which remains inert. +- Persistent, multi-turn AI design sessions (plan: storage → wire sessions → turn engine → UI cards → generation + quality → docs). Step 1 (storage) is done; next is step 2, wiring the existing generation to stored sessions. +- After that: the Specs tab (Generate Spec + automatic Markdown download), which remains inert. ## Completed @@ -1160,3 +1162,36 @@ Update this file whenever the current phase, active feature, or implementation s - `project-dialogs.tsx`: primary buttons show a `Loader2` spinner with "Creating…", "Saving…", "Deleting…" and `aria-busy`; name inputs are disabled while loading. - Type check and lint pass. Not checked in a browser. +- AI sessions, step 1: storage for persistent design agent chats (2026-09-15): + - Why: the AI sidebar kept its chat in React state only (`useDesignAgent` `useState`), so a reload lost the + transcript and any in-flight run, and every prompt reached the model with no history. + - `prisma/models/ai-session.prisma`: `AiSession` (projectId → `Project` cascade, userId, title, phase + `CLARIFYING|PLANNED|GENERATING|COMPLETE`, `brief` JSON, `clarifyRounds`, `lastActivityAt`, `expiresAt`) and + `AiMessage` (sessionId cascade, role `USER|ASSISTANT`, kind `TEXT|QUESTIONS|ANSWERS|PLAN|RESULT|ERROR`, `content`, + `payload` JSON, unique `runId`, status `PENDING|COMPLETE|FAILED`). `Project.aiSessions` back-relation. + Migration `20260915163917_add_ai_sessions` (additive only) applied. + - `types/ai-session.ts`: Prisma-free wire types (`AiSessionSummary`, `AiSessionDetail`, `AiMessageDto`) and value + lists for the enums, for client use in step 2. + - `lib/ai/session-store.ts`: limits (7-day sliding TTL, 10 sessions per user per project, 60 messages per + session, 4,000-char messages, 80-char titles), `toSessionTitle`, `listSessions`, `createSession` (one + transaction that trims to the cap, dropping least recently active and expired), `getSession` (with transcript), + `deleteSession`, `deleteExpiredSessions`. Every read filters `expiresAt > now`, scoped to project + user. + - Routes (Clerk auth + `getAccessibleProject`; sessions private to their creator, others' ids return 404): + - `GET/POST /api/projects/[projectId]/ai-sessions` — list mine / create (optional `title`, 201). + - `GET/DELETE /api/projects/[projectId]/ai-sessions/[sessionId]` — session + messages / delete (204). + - `GET /api/cron/ai-sessions/cleanup` — deletes expired sessions; requires `Authorization: Bearer $CRON_SECRET` + (constant-time compare, 401 when unset or wrong). `/api/cron(.*)` added to the public routes in `proxy.ts`. + - `vercel.json` (new): daily cron at 03:00 UTC. `CRON_SECRET` added to `.env.example`; it must also be set in + Vercel project env for the cron to authenticate. + - `architecture-context.md` storage model documents session retention. + - Not wired to the UI yet; `useDesignAgent` and `/api/ai/design` are unchanged. + - Validation checks: + - `pnpm typecheck` and `pnpm lint` passed (2 pre-existing warnings in vendored skill templates) + - A tsx script against the database confirmed: title normalisation, 7-day expiry, the 10-session cap deletes + the oldest rows, other users can't read/delete or count toward the cap, transcripts load, expired sessions + are hidden from get/list, cleanup deletes them with messages cascading, and project delete cascades. Test + rows were removed. + - Against the running dev server: signed-out requests to the session routes are stopped by Clerk (404 / 307 + to sign-in) before the handler; the cron route is reachable and returns 401 JSON with no or a wrong bearer. + - Not yet exercised: the session routes over HTTP with a signed-in user, and the cron route with the correct + secret (the dev server had no `CRON_SECRET`). diff --git a/lib/ai/session-store.ts b/lib/ai/session-store.ts new file mode 100644 index 0000000..700575f --- /dev/null +++ b/lib/ai/session-store.ts @@ -0,0 +1,188 @@ +import { prisma } from "@/lib/prisma"; +import type { AiSessionDetail, AiSessionSummary } from "@/types/ai-session"; + +// --------------------------------------------------------------------------- +// Retention and size limits +// +// Sessions are working memory for the design agent, not an archive. They +// expire a fixed time after the last activity, and each user keeps only a +// handful per project, so storage stays small. +// --------------------------------------------------------------------------- + +/** A session expires this long after its last activity. */ +export const AI_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +/** Sessions one user keeps per project. Creating one more drops the oldest. */ +export const MAX_SESSIONS_PER_USER_PROJECT = 10; + +/** Messages allowed in one session before the user must start a new one. */ +export const MAX_MESSAGES_PER_SESSION = 60; + +/** Longest message text accepted from the client. */ +export const MAX_MESSAGE_LENGTH = 4000; + +export const MAX_SESSION_TITLE_LENGTH = 80; + +export const DEFAULT_SESSION_TITLE = "New chat"; + +export function computeExpiresAt(from: Date = new Date()): Date { + return new Date(from.getTime() + AI_SESSION_TTL_MS); +} + +/** Collapses whitespace and truncates text into a session title. */ +export function toSessionTitle(text: string): string { + const collapsed = text.replace(/\s+/g, " ").trim(); + if (collapsed.length === 0) { + return DEFAULT_SESSION_TITLE; + } + if (collapsed.length <= MAX_SESSION_TITLE_LENGTH) { + return collapsed; + } + return `${collapsed.slice(0, MAX_SESSION_TITLE_LENGTH - 1).trimEnd()}…`; +} + +// --------------------------------------------------------------------------- +// Queries +// +// Every query is scoped to project + user, and ignores expired sessions even +// before the cleanup cron has removed them. Callers must already have checked +// project access. +// --------------------------------------------------------------------------- + +const summarySelect = { + id: true, + title: true, + phase: true, + lastActivityAt: true, + expiresAt: true, + createdAt: true, +} as const; + +interface SessionScope { + projectId: string; + userId: string; +} + +function toSummary(session: { + id: string; + title: string; + phase: AiSessionSummary["phase"]; + lastActivityAt: Date; + expiresAt: Date; + createdAt: Date; +}): AiSessionSummary { + return { + id: session.id, + title: session.title, + phase: session.phase, + lastActivityAt: session.lastActivityAt.toISOString(), + expiresAt: session.expiresAt.toISOString(), + createdAt: session.createdAt.toISOString(), + }; +} + +export async function listSessions({ projectId, userId }: SessionScope): Promise { + const sessions = await prisma.aiSession.findMany({ + where: { projectId, userId, expiresAt: { gt: new Date() } }, + orderBy: { lastActivityAt: "desc" }, + take: MAX_SESSIONS_PER_USER_PROJECT, + select: summarySelect, + }); + + return sessions.map(toSummary); +} + +/** + * Creates a session and trims the user's sessions in this project down to + * {@link MAX_SESSIONS_PER_USER_PROJECT}, dropping the least recently active + * ones and any that have already expired. + */ +export async function createSession( + { projectId, userId }: SessionScope, + title: string = DEFAULT_SESSION_TITLE, +): Promise { + const now = new Date(); + + return prisma.$transaction(async (tx) => { + const kept = await tx.aiSession.findMany({ + where: { projectId, userId, expiresAt: { gt: now } }, + orderBy: { lastActivityAt: "desc" }, + take: MAX_SESSIONS_PER_USER_PROJECT - 1, + select: { id: true }, + }); + + await tx.aiSession.deleteMany({ + where: { projectId, userId, id: { notIn: kept.map((session) => session.id) } }, + }); + + const session = await tx.aiSession.create({ + data: { + projectId, + userId, + title: toSessionTitle(title), + lastActivityAt: now, + expiresAt: computeExpiresAt(now), + }, + select: summarySelect, + }); + + return toSummary(session); + }); +} + +export async function getSession( + { projectId, userId }: SessionScope, + sessionId: string, +): Promise { + const session = await prisma.aiSession.findFirst({ + where: { id: sessionId, projectId, userId, expiresAt: { gt: new Date() } }, + select: { + ...summarySelect, + brief: true, + clarifyRounds: true, + messages: { + orderBy: { createdAt: "asc" }, + select: { + id: true, + role: true, + kind: true, + content: true, + payload: true, + runId: true, + status: true, + createdAt: true, + }, + }, + }, + }); + + if (!session) { + return null; + } + + return { + ...toSummary(session), + brief: session.brief, + clarifyRounds: session.clarifyRounds, + messages: session.messages.map((message) => ({ + ...message, + createdAt: message.createdAt.toISOString(), + })), + }; +} + +/** Returns false when no matching session existed. */ +export async function deleteSession({ projectId, userId }: SessionScope, sessionId: string): Promise { + const { count } = await prisma.aiSession.deleteMany({ + where: { id: sessionId, projectId, userId }, + }); + return count > 0; +} + +/** Deletes every expired session across all projects. Messages cascade. */ +export async function deleteExpiredSessions(now: Date = new Date()): Promise { + const { count } = await prisma.aiSession.deleteMany({ + where: { expiresAt: { lte: now } }, + }); + return count; +} diff --git a/prisma/migrations/20260915163917_add_ai_sessions/migration.sql b/prisma/migrations/20260915163917_add_ai_sessions/migration.sql new file mode 100644 index 0000000..e5789ea --- /dev/null +++ b/prisma/migrations/20260915163917_add_ai_sessions/migration.sql @@ -0,0 +1,60 @@ +-- CreateEnum +CREATE TYPE "AiSessionPhase" AS ENUM ('CLARIFYING', 'PLANNED', 'GENERATING', 'COMPLETE'); + +-- CreateEnum +CREATE TYPE "AiMessageRole" AS ENUM ('USER', 'ASSISTANT'); + +-- CreateEnum +CREATE TYPE "AiMessageKind" AS ENUM ('TEXT', 'QUESTIONS', 'ANSWERS', 'PLAN', 'RESULT', 'ERROR'); + +-- CreateEnum +CREATE TYPE "AiMessageStatus" AS ENUM ('PENDING', 'COMPLETE', 'FAILED'); + +-- CreateTable +CREATE TABLE "AiSession" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "phase" "AiSessionPhase" NOT NULL DEFAULT 'CLARIFYING', + "brief" JSONB, + "clarifyRounds" INTEGER NOT NULL DEFAULT 0, + "lastActivityAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AiSession_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AiMessage" ( + "id" TEXT NOT NULL, + "sessionId" TEXT NOT NULL, + "role" "AiMessageRole" NOT NULL, + "kind" "AiMessageKind" NOT NULL, + "content" TEXT NOT NULL, + "payload" JSONB, + "runId" TEXT, + "status" "AiMessageStatus" NOT NULL DEFAULT 'COMPLETE', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AiMessage_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "AiSession_projectId_userId_lastActivityAt_idx" ON "AiSession"("projectId", "userId", "lastActivityAt"); + +-- CreateIndex +CREATE INDEX "AiSession_expiresAt_idx" ON "AiSession"("expiresAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "AiMessage_runId_key" ON "AiMessage"("runId"); + +-- CreateIndex +CREATE INDEX "AiMessage_sessionId_createdAt_idx" ON "AiMessage"("sessionId", "createdAt"); + +-- AddForeignKey +ALTER TABLE "AiSession" ADD CONSTRAINT "AiSession_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AiMessage" ADD CONSTRAINT "AiMessage_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "AiSession"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/models/ai-session.prisma b/prisma/models/ai-session.prisma new file mode 100644 index 0000000..fec2f9b --- /dev/null +++ b/prisma/models/ai-session.prisma @@ -0,0 +1,67 @@ +// AI chat sessions for the design agent. Rows are short-lived: every session +// carries an `expiresAt` that slides forward on activity, and a daily cron +// deletes expired sessions (messages cascade). + +enum AiSessionPhase { + CLARIFYING + PLANNED + GENERATING + COMPLETE +} + +enum AiMessageRole { + USER + ASSISTANT +} + +enum AiMessageKind { + TEXT + QUESTIONS + ANSWERS + PLAN + RESULT + ERROR +} + +enum AiMessageStatus { + PENDING + COMPLETE + FAILED +} + +model AiSession { + id String @id @default(cuid()) + projectId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + userId String + title String + phase AiSessionPhase @default(CLARIFYING) + // Running requirements summary the agent keeps between turns. + brief Json? + clarifyRounds Int @default(0) + lastActivityAt DateTime @default(now()) + expiresAt DateTime + createdAt DateTime @default(now()) + messages AiMessage[] + + @@index([projectId, userId, lastActivityAt]) + @@index([expiresAt]) +} + +model AiMessage { + id String @id @default(cuid()) + sessionId String + session AiSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + role AiMessageRole + kind AiMessageKind + // Display text, capped by the API. + content String + // Structured part of the message: questions, answers, plan, or result. + payload Json? + // Trigger.dev run producing this message, while it is PENDING. + runId String? @unique + status AiMessageStatus @default(COMPLETE) + createdAt DateTime @default(now()) + + @@index([sessionId, createdAt]) +} diff --git a/prisma/models/project.prisma b/prisma/models/project.prisma index 6fdde90..0ceb925 100644 --- a/prisma/models/project.prisma +++ b/prisma/models/project.prisma @@ -13,6 +13,7 @@ model Project { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt collaborators ProjectCollaborator[] + aiSessions AiSession[] @@index([ownerId]) @@index([createdAt]) diff --git a/proxy.ts b/proxy.ts index 844ca18..6888d28 100644 --- a/proxy.ts +++ b/proxy.ts @@ -23,6 +23,8 @@ const isPublicRoute = createRouteMatcher([ `${signUpPath}(.*)`, "/docs", "/docs(.*)", + // Invoked by Vercel Cron without a Clerk session; each handler checks CRON_SECRET. + "/api/cron(.*)", ]); export default clerkMiddleware( diff --git a/types/ai-session.ts b/types/ai-session.ts new file mode 100644 index 0000000..c9d1383 --- /dev/null +++ b/types/ai-session.ts @@ -0,0 +1,42 @@ +// Wire shapes for the AI session API. Kept free of Prisma imports so client +// code can use them. + +export const AI_SESSION_PHASES = ["CLARIFYING", "PLANNED", "GENERATING", "COMPLETE"] as const; +export type AiSessionPhase = (typeof AI_SESSION_PHASES)[number]; + +export const AI_MESSAGE_ROLES = ["USER", "ASSISTANT"] as const; +export type AiMessageRole = (typeof AI_MESSAGE_ROLES)[number]; + +export const AI_MESSAGE_KINDS = ["TEXT", "QUESTIONS", "ANSWERS", "PLAN", "RESULT", "ERROR"] as const; +export type AiMessageKind = (typeof AI_MESSAGE_KINDS)[number]; + +export const AI_MESSAGE_STATUSES = ["PENDING", "COMPLETE", "FAILED"] as const; +export type AiMessageStatus = (typeof AI_MESSAGE_STATUSES)[number]; + +/** Session as listed in the sidebar's session switcher. */ +export interface AiSessionSummary { + id: string; + title: string; + phase: AiSessionPhase; + lastActivityAt: string; + expiresAt: string; + createdAt: string; +} + +export interface AiMessageDto { + id: string; + role: AiMessageRole; + kind: AiMessageKind; + content: string; + payload: unknown; + runId: string | null; + status: AiMessageStatus; + createdAt: string; +} + +/** Full session with its transcript, oldest message first. */ +export interface AiSessionDetail extends AiSessionSummary { + brief: unknown; + clarifyRounds: number; + messages: AiMessageDto[]; +} diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..49e9965 --- /dev/null +++ b/vercel.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "crons": [ + { + "path": "/api/cron/ai-sessions/cleanup", + "schedule": "0 3 * * *" + } + ] +} From 6d2255e696f354370644d81cdb62bb9971834165 Mon Sep 17 00:00:00 2001 From: suharshit singh Date: Tue, 15 Sep 2026 22:33:24 +0530 Subject: [PATCH 2/9] feat(ai-sessions): run the AI Architect chat on stored sessions A reload no longer loses the chat or an in-flight run. Generation is still one-shot; this changes persistence and resume only. - POST /api/projects/[projectId]/ai-sessions/[sessionId]/turns stores the prompt and a PENDING reply carrying the run id (replaces POST /api/ai/design, which is removed) - Replies are settled from runs.retrieve whenever a session is read, so a turn is saved even if the tab closed mid-run; concurrent reads settle once - useAiSession replaces useDesignAgent: reopens the last chat, resumes pending runs via Realtime with a 4s polling fallback, stores new chats lazily on the first message - Sidebar session bar with saved-chat history (delete, expiry) and New chat - Limits moved to lib/ai/session-limits.ts for client use; formatRelativeTime extracted to lib/relative-time.ts Co-Authored-By: Claude Opus 5 --- app/api/ai/design/route.ts | 94 ---- .../ai-sessions/[sessionId]/route.ts | 11 +- .../ai-sessions/[sessionId]/turns/route.ts | 71 +++ components/editor/ai-session-history.tsx | 97 ++++ components/editor/ai-sidebar.tsx | 226 ++++++--- components/editor/project-sidebar.tsx | 14 +- context/progress-tracker.md | 51 +- hooks/use-ai-session.ts | 479 ++++++++++++++++++ hooks/use-design-agent.ts | 252 --------- lib/ai/session-limits.ts | 25 + lib/ai/session-store.ts | 30 +- lib/ai/session-turns.ts | 227 +++++++++ lib/relative-time.ts | 13 + 13 files changed, 1147 insertions(+), 443 deletions(-) delete mode 100644 app/api/ai/design/route.ts create mode 100644 app/api/projects/[projectId]/ai-sessions/[sessionId]/turns/route.ts create mode 100644 components/editor/ai-session-history.tsx create mode 100644 hooks/use-ai-session.ts delete mode 100644 hooks/use-design-agent.ts create mode 100644 lib/ai/session-limits.ts create mode 100644 lib/ai/session-turns.ts create mode 100644 lib/relative-time.ts diff --git a/app/api/ai/design/route.ts b/app/api/ai/design/route.ts deleted file mode 100644 index 0558aba..0000000 --- a/app/api/ai/design/route.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { auth } from "@clerk/nextjs/server"; -import { tasks } from "@trigger.dev/sdk/v3"; - -import { prisma } from "@/lib/prisma"; -import { getAccessibleProject, getCurrentIdentity } from "@/lib/project-access"; -import type { designAgentTask } from "@/src/trigger/design-agent"; - -interface DesignRequestBody { - prompt: string; - roomId: string; - projectId: string; -} - -function parseRequestBody(value: unknown): DesignRequestBody | null { - if (value === null || typeof value !== "object") { - return null; - } - - const candidate = value as Record; - - if ( - typeof candidate.prompt !== "string" || - typeof candidate.roomId !== "string" || - typeof candidate.projectId !== "string" - ) { - return null; - } - - const prompt = candidate.prompt.trim(); - const roomId = candidate.roomId.trim(); - const projectId = candidate.projectId.trim(); - - if (!prompt || !roomId || !projectId) { - return null; - } - - return { prompt, roomId, projectId }; -} - -export async function POST(request: Request) { - const { userId } = await auth(); - if (!userId) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid request body" }, { status: 400 }); - } - - const parsedBody = parseRequestBody(body); - if (!parsedBody) { - return Response.json( - { error: "prompt, roomId, and projectId are required" }, - { status: 400 }, - ); - } - - const { primaryEmail } = await getCurrentIdentity(); - const project = await getAccessibleProject(userId, primaryEmail, parsedBody.projectId); - if (!project) { - return Response.json({ error: "Forbidden" }, { status: 403 }); - } - - if (parsedBody.roomId !== parsedBody.projectId) { - return Response.json({ error: "roomId must match projectId" }, { status: 400 }); - } - - let handle: Awaited>>; - try { - handle = await tasks.trigger("design-agent", { - prompt: parsedBody.prompt, - roomId: parsedBody.roomId, - }); - } catch (error) { - // wrong-environment TRIGGER_SECRET_KEY, a branch env that does not exist, or - // no deployed version of the task. Surface it as 502 so the client does not - // mistake it for an expired session, and log the cause for the server side. - console.error("[api/ai/design] failed to trigger design-agent", error); - return Response.json({ error: "Design service unavailable" }, { status: 502 }); - } - - await prisma.taskRun.create({ - data: { - runId: handle.id, - projectId: parsedBody.projectId, - userId, - }, - }); - - return Response.json({ runId: handle.id }, { status: 202 }); -} diff --git a/app/api/projects/[projectId]/ai-sessions/[sessionId]/route.ts b/app/api/projects/[projectId]/ai-sessions/[sessionId]/route.ts index 5482e64..ce606b5 100644 --- a/app/api/projects/[projectId]/ai-sessions/[sessionId]/route.ts +++ b/app/api/projects/[projectId]/ai-sessions/[sessionId]/route.ts @@ -1,6 +1,7 @@ import { auth } from "@clerk/nextjs/server"; -import { deleteSession, getSession } from "@/lib/ai/session-store"; +import { deleteSession } from "@/lib/ai/session-store"; +import { getSettledSession } from "@/lib/ai/session-turns"; import { getAccessibleProject, getCurrentIdentity } from "@/lib/project-access"; type SessionRouteContext = { params: Promise<{ projectId: string; sessionId: string }> }; @@ -34,7 +35,11 @@ async function resolveScope(projectId: string) { return { scope: { projectId: project.id, userId } } as const; } -/** Returns one of the current user's sessions with its full transcript. */ +/** + * Returns one of the current user's sessions with its full transcript. Turns + * whose runs have finished are settled first, so the reply is there even if the + * tab that sent the message was closed. + */ export async function GET(_request: Request, context: SessionRouteContext) { const { projectId, sessionId } = await context.params; const result = await resolveScope(projectId); @@ -42,7 +47,7 @@ export async function GET(_request: Request, context: SessionRouteContext) { return result.error; } - const session = await getSession(result.scope, sessionId); + const session = await getSettledSession(result.scope, sessionId); if (!session) { return notFoundResponse(); } diff --git a/app/api/projects/[projectId]/ai-sessions/[sessionId]/turns/route.ts b/app/api/projects/[projectId]/ai-sessions/[sessionId]/turns/route.ts new file mode 100644 index 0000000..218a8b2 --- /dev/null +++ b/app/api/projects/[projectId]/ai-sessions/[sessionId]/turns/route.ts @@ -0,0 +1,71 @@ +import { auth } from "@clerk/nextjs/server"; + +import { MAX_MESSAGE_LENGTH } from "@/lib/ai/session-limits"; +import { startTurn } from "@/lib/ai/session-turns"; +import { getAccessibleProject, getCurrentIdentity } from "@/lib/project-access"; + +type TurnRouteContext = { params: Promise<{ projectId: string; sessionId: string }> }; + +/** Only `{ type: "message", text }` for now; answers/generate/skip arrive with the turn engine. */ +function parseMessageText(value: unknown): string | null { + if (typeof value !== "object" || value === null) { + return null; + } + + const candidate = value as Record; + if (candidate.type !== "message" || typeof candidate.text !== "string") { + return null; + } + + const text = candidate.text.trim(); + if (text.length === 0 || text.length > MAX_MESSAGE_LENGTH) { + return null; + } + + return text; +} + +/** + * Sends a message in one of the current user's sessions and starts the design + * run that answers it. Responds 202 with the updated session (the reply is a + * PENDING message carrying the run id), or 502 with the session when the run + * could not be started. + */ +export async function POST(request: Request, context: TurnRouteContext) { + const { userId } = await auth(); + if (!userId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return Response.json({ error: "Invalid request body" }, { status: 400 }); + } + + const text = parseMessageText(body); + if (!text) { + return Response.json( + { error: `Message text is required and must be at most ${MAX_MESSAGE_LENGTH} characters` }, + { status: 400 }, + ); + } + + const { projectId, sessionId } = await context.params; + const { primaryEmail } = await getCurrentIdentity(); + const project = await getAccessibleProject(userId, primaryEmail, projectId); + if (!project) { + return Response.json({ error: "Forbidden" }, { status: 403 }); + } + + const result = await startTurn({ projectId: project.id, userId }, sessionId, text); + if (result.ok) { + return Response.json({ session: result.session }, { status: 202 }); + } + + return Response.json( + { error: result.error, ...(result.session ? { session: result.session } : {}) }, + { status: result.status }, + ); +} diff --git a/components/editor/ai-session-history.tsx b/components/editor/ai-session-history.tsx new file mode 100644 index 0000000..615cbbe --- /dev/null +++ b/components/editor/ai-session-history.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useState } from "react"; +import { Trash2 } from "lucide-react"; + +import { AI_SESSION_TTL_DAYS, MAX_SESSIONS_PER_USER_PROJECT } from "@/lib/ai/session-limits"; +import { formatRelativeTime } from "@/lib/relative-time"; +import { cn } from "@/lib/utils"; +import type { AiSessionSummary } from "@/types/ai-session"; + +interface AiSessionHistoryProps { + sessions: AiSessionSummary[]; + activeSessionId: string | null; + /** False while a message is being sent. */ + canSwitch: boolean; + onSelect: (sessionId: string) => void; + onDelete: (sessionId: string) => void; +} + +const focusClass = "outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink/60"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +function describeExpiry(expiresAt: string, now: number): string { + const remaining = new Date(expiresAt).getTime() - now; + if (remaining < DAY_MS) { + return "expires today"; + } + return `expires in ${Math.floor(remaining / DAY_MS)}d`; +} + +/** The AI Architect's saved chats, shown in place of the transcript. */ +export function AiSessionHistory({ sessions, activeSessionId, canSwitch, onSelect, onDelete }: AiSessionHistoryProps) { + // Captured when the list opens; relative times don't need to tick while it is visible. + const [now] = useState(() => Date.now()); + + return ( +
+
+ Recent chats + + {sessions.length}/{MAX_SESSIONS_PER_USER_PROJECT} + +
+ + {sessions.length === 0 ? ( +

+ No saved chats yet. Send a message to start one. +

+ ) : ( +
    + {sessions.map((entry) => { + const isActive = entry.id === activeSessionId; + + return ( +
  • + + +
  • + ); + })} +
+ )} + +

+ Chats are kept for {AI_SESSION_TTL_DAYS} days after last use +

+
+ ); +} diff --git a/components/editor/ai-sidebar.tsx b/components/editor/ai-sidebar.tsx index 9a5db6c..97963a5 100644 --- a/components/editor/ai-sidebar.tsx +++ b/components/editor/ai-sidebar.tsx @@ -1,10 +1,11 @@ "use client"; -import { FormEvent, KeyboardEvent, useState } from "react"; +import { FormEvent, KeyboardEvent, useEffect, useRef, useState } from "react"; import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"; -import { ArrowRight, Download, FileText, Loader2, Sparkle, X } from "lucide-react"; +import { ArrowRight, ChevronDown, Download, FileText, History, Loader2, Plus, Sparkle, X } from "lucide-react"; -import { useDesignAgent } from "@/hooks/use-design-agent"; +import { AiSessionHistory } from "@/components/editor/ai-session-history"; +import { useAiSession } from "@/hooks/use-ai-session"; import { cn } from "@/lib/utils"; interface AiSidebarProps { @@ -42,9 +43,34 @@ function SparkleMark({ className }: { className?: string }) { export function AiSidebar({ open, onClose, projectId }: AiSidebarProps) { const [inputValue, setInputValue] = useState(""); - const { messages, statusText, isRunning, sendPrompt } = useDesignAgent(projectId); + const [showHistory, setShowHistory] = useState(false); + const { + sessions, + activeSessionId, + activeSessionTitle, + messages, + statusText, + isRunning, + canSwitchSession, + isLoadingSession, + sessionLoadFailed, + sendPrompt, + startNewChat, + selectSession, + removeSession, + retryLoadSession, + } = useAiSession(projectId); - const showEmptyState = messages.length === 0; + const transcriptEndRef = useRef(null); + + const showEmptyState = messages.length === 0 && !isLoadingSession && !sessionLoadFailed; + + // Keep the latest message in view, including when a saved chat opens. + useEffect(() => { + if (!showHistory) { + transcriptEndRef.current?.scrollIntoView({ block: "end" }); + } + }, [messages.length, showHistory, statusText]); const sendMessage = (text: string) => { if (!text.trim() || isRunning) { @@ -53,6 +79,7 @@ export function AiSidebar({ open, onClose, projectId }: AiSidebarProps) { sendPrompt(text); setInputValue(""); + setShowHistory(false); }; const handleSubmit = (event: FormEvent) => { @@ -67,6 +94,16 @@ export function AiSidebar({ open, onClose, projectId }: AiSidebarProps) { } }; + const handleNewChat = () => { + startNewChat(); + setShowHistory(false); + }; + + const handleSelectSession = (sessionId: string) => { + selectSession(sessionId); + setShowHistory(false); + }; + return (