diff --git a/next.config.ts b/next.config.ts index 185d0d24..e5ef1f12 100644 --- a/next.config.ts +++ b/next.config.ts @@ -82,11 +82,10 @@ export default withSentryConfig(withMDX(withNextIntl(nextConfig)), { // Upload a larger set of source maps for prettier stack traces (increases build time) widenClientFileUpload: true, - // Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers. - // This can increase your server load as well as your hosting bill. - // Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client- - // side errors will fail. - tunnelRoute: "/monitoring", + // tunnelRoute removed — was proxying all browser Sentry events through Vercel edge, + // generating unnecessary edge requests ($2.45/M) and function invocations ($0.60/M). + // Estimated savings: $100-400/month. Trade-off: some ad-blockers may block direct Sentry calls. + // See: https://github.com/f/prompts.chat/issues/1085 webpack: { // Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7303ba36..b58dfe55 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -139,6 +139,8 @@ model Prompt { @@index([isPrivate]) @@index([isFeatured]) @@index([isUnlisted]) + @@index([slug]) + @@index([isPrivate, isUnlisted, deletedAt, createdAt(sort: Desc)]) @@map("prompts") } diff --git a/src/app/prompts.json/route.ts b/src/app/prompts.json/route.ts index de1f263c..040cabf1 100644 --- a/src/app/prompts.json/route.ts +++ b/src/app/prompts.json/route.ts @@ -1,5 +1,6 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { db } from "@/lib/db"; +import crypto from "crypto"; function getUserIdentifier(user: { username: string; @@ -8,141 +9,225 @@ function getUserIdentifier(user: { return user.githubUsername || user.username; } +const CONTENT_PREVIEW_LENGTH = 500; +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 100; + export const revalidate = 3600; -export async function GET() { +function generateETag(count: number, latestUpdatedAt: Date | null): string { + const raw = `${count}-${latestUpdatedAt?.toISOString() ?? "none"}`; + const hash = crypto.createHash("md5").update(raw).digest("hex"); + return `"${hash}"`; +} + +export async function GET(request: NextRequest) { try { - const prompts = await db.prompt.findMany({ - where: { - isPrivate: false, - isUnlisted: false, - deletedAt: null, + const searchParams = request.nextUrl.searchParams; + const fullContent = searchParams.get("full_content") === "true"; + const pageParam = searchParams.get("page"); + const limitParam = searchParams.get("limit"); + + const isPaginated = pageParam !== null || limitParam !== null; + + const page = Math.max(1, parseInt(pageParam ?? "1", 10) || 1); + const limit = Math.min( + MAX_LIMIT, + Math.max(1, parseInt(limitParam ?? String(DEFAULT_LIMIT), 10) || DEFAULT_LIMIT) + ); + + const whereClause = { + isPrivate: false, + isUnlisted: false, + deletedAt: null, + }; + + // Fetch total count and latest updatedAt for ETag generation + const [totalCount, latestPrompt] = await Promise.all([ + db.prompt.count({ where: whereClause }), + db.prompt.findFirst({ + where: whereClause, + orderBy: { updatedAt: "desc" }, + select: { updatedAt: true }, + }), + ]); + + // Generate and check ETag for conditional requests + const etag = generateETag(totalCount, latestPrompt?.updatedAt ?? null); + const ifNoneMatch = request.headers.get("If-None-Match"); + + if (ifNoneMatch && ifNoneMatch === etag) { + return new NextResponse(null, { + status: 304, + headers: { + ETag: etag, + "Cache-Control": + "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400", + }, + }); + } + + const selectClause = { + id: true, + title: true, + slug: true, + description: true, + content: true, + type: true, + mediaUrl: true, + viewCount: true, + createdAt: true, + updatedAt: true, + structuredFormat: true, + isFeatured: true, + featuredAt: true, + requiresMediaUpload: true, + requiredMediaType: true, + requiredMediaCount: true, + category: { + select: { + id: true, + name: true, + slug: true, + icon: true, + }, }, - select: { - id: true, - title: true, - slug: true, - description: true, - content: true, - type: true, - mediaUrl: true, - viewCount: true, - createdAt: true, - updatedAt: true, - structuredFormat: true, - isFeatured: true, - featuredAt: true, - requiresMediaUpload: true, - requiredMediaType: true, - requiredMediaCount: true, - category: { - select: { - id: true, - name: true, - slug: true, - icon: true, - }, + author: { + select: { + username: true, + name: true, + avatar: true, + githubUsername: true, + verified: true, }, - author: { - select: { - username: true, - name: true, - avatar: true, - githubUsername: true, - verified: true, - }, + }, + contributors: { + select: { + username: true, + name: true, + avatar: true, + githubUsername: true, + verified: true, }, - contributors: { - select: { - username: true, - name: true, - avatar: true, - githubUsername: true, - verified: true, - }, - }, - tags: { - select: { - tag: { - select: { - id: true, - name: true, - slug: true, - color: true, - }, + }, + tags: { + select: { + tag: { + select: { + id: true, + name: true, + slug: true, + color: true, }, }, }, - _count: { - select: { - votes: true, - comments: true, - }, + }, + _count: { + select: { + votes: true, + comments: true, }, }, - orderBy: { createdAt: "desc" }, + } as const; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const queryOptions: any = { + where: whereClause, + select: selectClause, + orderBy: { createdAt: "desc" as const }, + }; + + if (isPaginated) { + queryOptions.skip = (page - 1) * limit; + queryOptions.take = limit; + } + + const prompts = await db.prompt.findMany(queryOptions); + + const includeFullContent = fullContent; + + const formattedPrompts = prompts.map((prompt) => { + const content = prompt.content ?? ""; + const contentPreview = + content.length > CONTENT_PREVIEW_LENGTH + ? content.slice(0, CONTENT_PREVIEW_LENGTH) + "..." + : content; + + return { + id: prompt.id, + title: prompt.title, + slug: prompt.slug, + description: prompt.description, + ...(includeFullContent + ? { content, contentPreview } + : { contentPreview }), + type: prompt.type, + structuredFormat: prompt.structuredFormat, + mediaUrl: prompt.mediaUrl, + viewCount: prompt.viewCount, + voteCount: prompt._count.votes, + commentCount: prompt._count.comments, + isFeatured: prompt.isFeatured, + featuredAt: prompt.featuredAt, + requiresMediaUpload: prompt.requiresMediaUpload, + requiredMediaType: prompt.requiredMediaType, + requiredMediaCount: prompt.requiredMediaCount, + createdAt: prompt.createdAt, + updatedAt: prompt.updatedAt, + category: prompt.category + ? { + id: prompt.category.id, + name: prompt.category.name, + slug: prompt.category.slug, + icon: prompt.category.icon, + } + : null, + author: { + username: prompt.author.username, + name: prompt.author.name, + avatar: prompt.author.avatar, + identifier: getUserIdentifier(prompt.author), + verified: prompt.author.verified, + }, + contributors: prompt.contributors.map((c) => ({ + username: c.username, + name: c.name, + avatar: c.avatar, + identifier: getUserIdentifier(c), + verified: c.verified, + })), + tags: prompt.tags.map((pt) => ({ + id: pt.tag.id, + name: pt.tag.name, + slug: pt.tag.slug, + color: pt.tag.color, + })), + }; }); - const formattedPrompts = prompts.map((prompt) => ({ - id: prompt.id, - title: prompt.title, - slug: prompt.slug, - description: prompt.description, - content: prompt.content, - type: prompt.type, - structuredFormat: prompt.structuredFormat, - mediaUrl: prompt.mediaUrl, - viewCount: prompt.viewCount, - voteCount: prompt._count.votes, - commentCount: prompt._count.comments, - isFeatured: prompt.isFeatured, - featuredAt: prompt.featuredAt, - requiresMediaUpload: prompt.requiresMediaUpload, - requiredMediaType: prompt.requiredMediaType, - requiredMediaCount: prompt.requiredMediaCount, - createdAt: prompt.createdAt, - updatedAt: prompt.updatedAt, - category: prompt.category - ? { - id: prompt.category.id, - name: prompt.category.name, - slug: prompt.category.slug, - icon: prompt.category.icon, - } - : null, - author: { - username: prompt.author.username, - name: prompt.author.name, - avatar: prompt.author.avatar, - identifier: getUserIdentifier(prompt.author), - verified: prompt.author.verified, - }, - contributors: prompt.contributors.map((c) => ({ - username: c.username, - name: c.name, - avatar: c.avatar, - identifier: getUserIdentifier(c), - verified: c.verified, - })), - tags: prompt.tags.map((pt) => ({ - id: pt.tag.id, - name: pt.tag.name, - slug: pt.tag.slug, - color: pt.tag.color, - })), - })); + const totalPages = isPaginated ? Math.ceil(totalCount / limit) : 1; - return NextResponse.json( - { - count: formattedPrompts.length, - prompts: formattedPrompts, + const responseBody = isPaginated + ? { + count: totalCount, + page, + limit, + totalPages, + hasMore: page < totalPages, + prompts: formattedPrompts, + } + : { + count: formattedPrompts.length, + prompts: formattedPrompts, + }; + + return NextResponse.json(responseBody, { + headers: { + "Cache-Control": + "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400", + ETag: etag, }, - { - headers: { - "Cache-Control": "public, max-age=3600", - }, - } - ); + }); } catch (error) { console.error("prompts.json error:", error); return NextResponse.json( diff --git a/src/pages/api/mcp.ts b/src/pages/api/mcp.ts index 6e8847c9..b19075a7 100644 --- a/src/pages/api/mcp.ts +++ b/src/pages/api/mcp.ts @@ -10,7 +10,6 @@ import { import { z } from "zod"; import { db } from "@/lib/db"; import { isValidApiKeyFormat } from "@/lib/api-key"; -import { improvePrompt } from "@/lib/ai/improve-prompt"; import { parseSkillFiles, serializeSkillFiles, DEFAULT_SKILL_FILE } from "@/lib/skill-files"; import appConfig from "@/../prompts.config"; @@ -103,7 +102,7 @@ function createServer(options: ServerOptions = {}) { { capabilities: { prompts: { listChanged: false }, - tools: {}, + tools: { listChanged: false }, }, } ); @@ -206,24 +205,18 @@ function createServer(options: ServerOptions = {}) { const promptSlug = request.params.name; const args = request.params.arguments || {}; - // Fetch all matching prompts and find by slug - const prompts = await db.prompt.findMany({ - where: promptFilter, - select: { - id: true, - slug: true, - title: true, - description: true, - content: true, - }, + // Try direct lookup by slug first + let prompt = await db.prompt.findFirst({ + where: { ...promptFilter, slug: promptSlug }, + select: { id: true, slug: true, title: true, description: true, content: true }, }); - - // Find by slug field first, then by slugified title, then by id - const prompt = prompts.find((p) => - p.slug === promptSlug || - slugify(p.title) === promptSlug || - p.id === promptSlug - ); + // Fallback: lookup by id + if (!prompt) { + prompt = await db.prompt.findFirst({ + where: { ...promptFilter, id: promptSlug }, + select: { id: true, slug: true, title: true, description: true, content: true }, + }); + } if (!prompt) { throw new Error(`Prompt not found: ${promptSlug}`); @@ -318,7 +311,6 @@ function createServer(options: ServerOptions = {}) { description: true, content: true, type: true, - structuredFormat: true, createdAt: true, author: { select: { username: true, name: true } }, category: { select: { name: true, slug: true } }, @@ -332,9 +324,8 @@ function createServer(options: ServerOptions = {}) { slug: getPromptName(p), title: p.title, description: p.description, - content: p.content, + contentPreview: p.content.substring(0, 1000) + (p.content.length > 1000 ? '...' : ''), type: p.type, - structuredFormat: p.structuredFormat, author: p.author.name || p.author.username, category: p.category?.name || null, tags: p.tags.map((t) => t.tag.name), @@ -346,7 +337,7 @@ function createServer(options: ServerOptions = {}) { content: [ { type: "text" as const, - text: JSON.stringify({ query, count: results.length, prompts: results }, null, 2), + text: JSON.stringify({ query, count: results.length, prompts: results }), }, ], }; @@ -368,9 +359,12 @@ function createServer(options: ServerOptions = {}) { "Get a prompt by ID and optionally fill in its variables. If the prompt contains template variables (like {{variable}}), you will be asked to provide values for them.", inputSchema: { id: z.string().describe("The ID of the prompt to retrieve"), + fill_variables: z.boolean().default(false).describe( + "If true and the prompt has template variables, triggers interactive variable filling. Default false — returns raw prompt with variable metadata." + ), }, }, - async ({ id }, extra) => { + async ({ id, fill_variables }, extra) => { try { const prompt = await db.prompt.findFirst({ where: { @@ -402,7 +396,7 @@ function createServer(options: ServerOptions = {}) { const variables = extractVariables(prompt.content); - if (variables.length > 0) { + if (fill_variables && variables.length > 0) { const properties: Record = {}; const requiredFields: string[] = []; for (const variable of variables) { @@ -436,73 +430,71 @@ function createServer(options: ServerOptions = {}) { }, ElicitResultSchema ); - - const timeoutPromise = new Promise((_, reject) => - setTimeout(() => reject(new Error("Elicitation timeout")), timeoutMs) - ); - - const result = await Promise.race([elicitationPromise, timeoutPromise]); - if (result.action === "accept" && result.content) { - let filledContent = prompt.content; - for (const [key, value] of Object.entries(result.content)) { - // Replace ${key} or ${key:default} patterns - filledContent = filledContent.replace( - new RegExp(`\\$\\{${key}(?::[^}]*)?\\}`, "g"), - String(value) - ); + let timeoutId: NodeJS.Timeout; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error("Elicitation timeout")), timeoutMs); + }); + + try { + const elicitResult = await Promise.race([elicitationPromise, timeoutPromise]); + clearTimeout(timeoutId!); + + if (elicitResult.action === "accept" && elicitResult.content) { + let filledContent = prompt.content; + for (const [key, value] of Object.entries(elicitResult.content)) { + // Replace ${key} or ${key:default} patterns + filledContent = filledContent.replace( + new RegExp(`\\$\\{${key}(?::[^}]*)?\\}`, "g"), + String(value) + ); + } + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ + ...prompt, + content: filledContent, + originalContent: prompt.content, + variables: elicitResult.content, + author: prompt.author.name || prompt.author.username, + category: prompt.category?.name || null, + tags: prompt.tags.map((t) => t.tag.name), + link: `https://prompts.chat/prompts/${prompt.id}_${getPromptName(prompt)}`, + }), + }, + ], + }; + } else { + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ + ...prompt, + variablesRequired: variables, + message: "User declined to provide variable values. Returning original prompt.", + author: prompt.author.name || prompt.author.username, + category: prompt.category?.name || null, + tags: prompt.tags.map((t) => t.tag.name), + link: `https://prompts.chat/prompts/${prompt.id}_${getPromptName(prompt)}`, + }), + }, + ], + }; } - - return { - content: [ - { - type: "text" as const, - text: JSON.stringify( - { - ...prompt, - content: filledContent, - originalContent: prompt.content, - variables: result.content, - author: prompt.author.name || prompt.author.username, - category: prompt.category?.name || null, - tags: prompt.tags.map((t) => t.tag.name), - link: `https://prompts.chat/prompts/${prompt.id}_${getPromptName(prompt)}`, - }, - null, - 2 - ), - }, - ], - }; - } else { - return { - content: [ - { - type: "text" as const, - text: JSON.stringify( - { - ...prompt, - variablesRequired: variables, - message: "User declined to provide variable values. Returning original prompt.", - author: prompt.author.name || prompt.author.username, - category: prompt.category?.name || null, - tags: prompt.tags.map((t) => t.tag.name), - link: `https://prompts.chat/prompts/${prompt.id}_${getPromptName(prompt)}`, - }, - null, - 2 - ), - }, - ], - }; + } catch (e) { + clearTimeout(timeoutId!); + throw e; } } catch { return { content: [ { type: "text" as const, - text: JSON.stringify( - { + text: JSON.stringify({ ...prompt, variablesRequired: variables, message: "Elicitation not supported. Variables need to be filled manually.", @@ -510,31 +502,41 @@ function createServer(options: ServerOptions = {}) { category: prompt.category?.name || null, tags: prompt.tags.map((t) => t.tag.name), link: `https://prompts.chat/prompts/${prompt.id}_${getPromptName(prompt)}`, - }, - null, - 2 - ), + }), }, ], }; } + } else if (variables.length > 0) { + // Return prompt with variable metadata, no timeout + return { + content: [{ type: "text" as const, text: JSON.stringify({ + id: prompt.id, + slug: getPromptName(prompt), + title: prompt.title, + description: prompt.description, + content: prompt.content, + type: prompt.type, + author: prompt.author.name || prompt.author.username, + category: prompt.category?.name || null, + tags: prompt.tags.map((t) => t.tag.name), + variables: variables.map(v => ({ name: v.name, defaultValue: v.defaultValue })), + hint: "This prompt has template variables. Call get_prompt with fill_variables=true to fill them interactively.", + }) }], + }; } return { content: [ { type: "text" as const, - text: JSON.stringify( - { + text: JSON.stringify({ ...prompt, author: prompt.author.name || prompt.author.username, category: prompt.category?.name || null, tags: prompt.tags.map((t) => t.tag.name), link: `https://prompts.chat/prompts/${prompt.id}_${getPromptName(prompt)}`, - }, - null, - 2 - ), + }), }, ], }; @@ -639,8 +641,7 @@ function createServer(options: ServerOptions = {}) { content: [ { type: "text" as const, - text: JSON.stringify( - { + text: JSON.stringify({ success: true, prompt: { ...prompt, @@ -648,10 +649,7 @@ function createServer(options: ServerOptions = {}) { category: prompt.category?.name || null, link: prompt.isPrivate ? null : `https://prompts.chat/prompts/${prompt.id}_${getPromptName(prompt)}`, }, - }, - null, - 2 - ), + }), }, ], }; @@ -693,13 +691,14 @@ function createServer(options: ServerOptions = {}) { } try { + const { improvePrompt } = await import("@/lib/ai/improve-prompt"); const result = await improvePrompt({ prompt, outputType, outputFormat }); return { content: [ { type: "text" as const, - text: JSON.stringify(result, null, 2), + text: JSON.stringify(result), }, ], }; @@ -810,8 +809,7 @@ function createServer(options: ServerOptions = {}) { content: [ { type: "text" as const, - text: JSON.stringify( - { + text: JSON.stringify({ success: true, skill: { ...skill, @@ -820,10 +818,7 @@ function createServer(options: ServerOptions = {}) { category: skill.category?.name || null, link: skill.isPrivate ? null : `https://prompts.chat/prompts/${skill.id}_${getPromptName(skill)}`, }, - }, - null, - 2 - ), + }), }, ], }; @@ -910,17 +905,13 @@ function createServer(options: ServerOptions = {}) { content: [ { type: "text" as const, - text: JSON.stringify( - { + text: JSON.stringify({ success: true, message: `File '${filename}' added to skill`, skillId, files: files.map(f => f.filename), link: `https://prompts.chat/prompts/${skill.id}_${getPromptName(skill)}`, - }, - null, - 2 - ), + }), }, ], }; @@ -1000,17 +991,13 @@ function createServer(options: ServerOptions = {}) { content: [ { type: "text" as const, - text: JSON.stringify( - { + text: JSON.stringify({ success: true, message: `File '${filename}' updated in skill`, skillId, files: files.map(f => f.filename), link: `https://prompts.chat/prompts/${skill.id}_${getPromptName(skill)}`, - }, - null, - 2 - ), + }), }, ], }; @@ -1096,17 +1083,13 @@ function createServer(options: ServerOptions = {}) { content: [ { type: "text" as const, - text: JSON.stringify( - { + text: JSON.stringify({ success: true, message: `File '${filename}' removed from skill`, skillId, files: updatedFiles.map(f => f.filename), link: `https://prompts.chat/prompts/${skill.id}_${getPromptName(skill)}`, - }, - null, - 2 - ), + }), }, ], }; @@ -1181,8 +1164,7 @@ function createServer(options: ServerOptions = {}) { content: [ { type: "text" as const, - text: JSON.stringify( - { + text: JSON.stringify({ id: skill.id, slug: getPromptName(skill), title: skill.title, @@ -1199,10 +1181,7 @@ function createServer(options: ServerOptions = {}) { content: f.content, })), link: skill.isPrivate ? null : `https://prompts.chat/prompts/${skill.id}_${getPromptName(skill)}`, - }, - null, - 2 - ), + }), }, ], }; @@ -1294,7 +1273,8 @@ function createServer(options: ServerOptions = {}) { category: s.category?.name || null, tags: s.tags.map((t) => t.tag.name), votes: s._count.votes, - files: files.map(f => f.filename), + fileNames: files.map(f => f.filename), + fileCount: files.length, createdAt: s.createdAt.toISOString(), link: `https://prompts.chat/prompts/${s.id}_${getPromptName(s)}`, }; @@ -1304,7 +1284,7 @@ function createServer(options: ServerOptions = {}) { content: [ { type: "text" as const, - text: JSON.stringify({ query, count: results.length, skills: results }, null, 2), + text: JSON.stringify({ query, count: results.length, skills: results }), }, ], }; @@ -1322,9 +1302,17 @@ function createServer(options: ServerOptions = {}) { } async function parseBody(req: NextApiRequest): Promise { + const MAX_BODY_SIZE = 1024 * 1024; // 1MB return new Promise((resolve, reject) => { let body = ""; - req.on("data", (chunk) => (body += chunk)); + req.on("data", (chunk) => { + body += chunk; + if (body.length > MAX_BODY_SIZE) { + req.destroy(); + reject(new Error("Body too large")); + return; + } + }); req.on("end", () => { try { resolve(JSON.parse(body)); @@ -1342,6 +1330,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } if (req.method === "GET") { + res.setHeader('Cache-Control', 's-maxage=3600, stale-while-revalidate=86400'); return res.status(200).json({ name: "prompts-chat", version: "1.0.0",