diff --git a/apps/dev/package.json b/apps/dev/package.json index e08de9a6..5a4950ac 100644 --- a/apps/dev/package.json +++ b/apps/dev/package.json @@ -16,11 +16,14 @@ "start": "cross-env NODE_OPTIONS=--no-deprecation next start" }, "dependencies": { + "@ai-sdk/openai": "3.0.25", "@focus-reactive/payload-plugin-ab": "workspace:*", + "@focus-reactive/payload-plugin-ai-page-builder": "workspace:*", "@focus-reactive/payload-plugin-comments": "workspace:*", "@focus-reactive/payload-plugin-presets": "workspace:*", "@focus-reactive/payload-plugin-scheduling": "workspace:*", "@focus-reactive/payload-plugin-translator": "workspace:*", + "ai": "6.0.68", "@payloadcms/db-sqlite": "3.79.0", "@payloadcms/live-preview-react": "3.79.0", "@payloadcms/next": "3.79.0", diff --git a/apps/dev/src/payload.config.ts b/apps/dev/src/payload.config.ts index 6c96d8d6..7e8753be 100644 --- a/apps/dev/src/payload.config.ts +++ b/apps/dev/src/payload.config.ts @@ -1,11 +1,15 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; +import { openai } from "@ai-sdk/openai"; import { abTestingPlugin } from "@focus-reactive/payload-plugin-ab"; +import { aiPageBuilderPlugin } from "@focus-reactive/payload-plugin-ai-page-builder"; +import type { AiBlockDefinition } from "@focus-reactive/payload-plugin-ai-page-builder"; import { commentsPlugin } from "@focus-reactive/payload-plugin-comments"; import { presetsPlugin } from "@focus-reactive/payload-plugin-presets"; import { schedulePublicationPlugin } from "@focus-reactive/payload-plugin-scheduling"; import { translatorPlugin, createOpenAIProvider, createPayloadJobsRunner } from "@focus-reactive/payload-plugin-translator"; +import { z } from "zod"; import { sqliteAdapter } from "@payloadcms/db-sqlite"; import { lexicalEditor } from "@payloadcms/richtext-lexical"; import { buildConfig } from "payload"; @@ -19,6 +23,24 @@ import { abAdapter } from "./lib/ab-testing/dbAdapter"; const baseDir = path.dirname(fileURLToPath(import.meta.url)); +const pageBlocks: AiBlockDefinition[] = [ + { + slug: "hero", + description: "above-the-fold section with headline and optional subtitle", + schema: z.object({ + title: z.string().describe("The main headline"), + description: z.string().optional().describe("Optional supporting subtitle"), + }), + }, + { + slug: "copy", + description: "body text section", + schema: z.object({ + text: z.string().describe("The body copy"), + }), + }, +]; + export default buildConfig({ admin: { importMap: { @@ -87,6 +109,15 @@ export default buildConfig({ dryRun: !process.env.OPENAI_API_KEY, }), }), + ...(process.env.OPENAI_API_KEY + ? [ + aiPageBuilderPlugin({ + model: openai("gpt-4o-mini"), + collections: [{ slug: "pages" }], + blocks: pageBlocks, + }), + ] + : []), ], secret: process.env.PAYLOAD_SECRET || "", sharp, diff --git a/bun.lock b/bun.lock index 3cdc230d..49a529e6 100644 --- a/bun.lock +++ b/bun.lock @@ -97,7 +97,9 @@ "name": "dev", "version": "1.0.0", "dependencies": { + "@ai-sdk/openai": "3.0.25", "@focus-reactive/payload-plugin-ab": "workspace:*", + "@focus-reactive/payload-plugin-ai-page-builder": "workspace:*", "@focus-reactive/payload-plugin-comments": "workspace:*", "@focus-reactive/payload-plugin-presets": "workspace:*", "@focus-reactive/payload-plugin-scheduling": "workspace:*", @@ -107,6 +109,7 @@ "@payloadcms/next": "3.79.0", "@payloadcms/richtext-lexical": "3.79.0", "@payloadcms/ui": "3.79.0", + "ai": "6.0.68", "cross-env": "7.0.3", "dotenv": "16.4.7", "graphql": "16.8.1", @@ -125,7 +128,7 @@ }, "packages/create-ideal-cms": { "name": "@focus-reactive/create-ideal-cms", - "version": "0.1.0", + "version": "1.0.0", "bin": { "create-ideal-cms": "./dist/index.js", }, @@ -142,7 +145,7 @@ }, "packages/payload-plugin-ab": { "name": "@focus-reactive/payload-plugin-ab", - "version": "2.5.0", + "version": "2.6.0", "devDependencies": { "@payloadcms/ui": "3.79.0", "@types/node": "22.19.9", @@ -165,9 +168,35 @@ "react", ], }, + "packages/payload-plugin-ai-page-builder": { + "name": "@focus-reactive/payload-plugin-ai-page-builder", + "version": "0.0.0", + "devDependencies": { + "@payloadcms/ui": "3.84.1", + "@types/node": "22.19.9", + "@types/react": "19.2.9", + "ai": "6.0.68", + "payload": "3.84.1", + "react": "19.2.1", + "tsup": "8.0.0", + "typescript": "5.9.2", + "zod": "3.24.1", + }, + "peerDependencies": { + "@payloadcms/ui": "^3.0.0", + "ai": ">=4.0.0", + "payload": "^3.0.0", + "react": "^18.0.0 || ^19.0.0", + "zod": "^3.0.0", + }, + "optionalPeers": [ + "@payloadcms/ui", + "react", + ], + }, "packages/payload-plugin-comments": { "name": "@focus-reactive/payload-plugin-comments", - "version": "1.7.0", + "version": "1.8.0", "dependencies": { "@radix-ui/react-dropdown-menu": "^2.1.16", "@tanstack/react-query": "5.0.0", @@ -208,7 +237,7 @@ }, "packages/payload-plugin-presets": { "name": "@focus-reactive/payload-plugin-presets", - "version": "0.10.9", + "version": "0.11.0", "dependencies": { "@radix-ui/react-popover": "1.1.15", }, @@ -247,7 +276,7 @@ }, "packages/payload-plugin-translator": { "name": "@focus-reactive/payload-plugin-translator", - "version": "0.1.6", + "version": "0.2.0", "dependencies": { "@hookform/resolvers": "^3.9.0", "@radix-ui/react-popover": "^1.1.0", @@ -563,6 +592,8 @@ "@focus-reactive/payload-plugin-ab": ["@focus-reactive/payload-plugin-ab@workspace:packages/payload-plugin-ab"], + "@focus-reactive/payload-plugin-ai-page-builder": ["@focus-reactive/payload-plugin-ai-page-builder@workspace:packages/payload-plugin-ai-page-builder"], + "@focus-reactive/payload-plugin-comments": ["@focus-reactive/payload-plugin-comments@workspace:packages/payload-plugin-comments"], "@focus-reactive/payload-plugin-presets": ["@focus-reactive/payload-plugin-presets@workspace:packages/payload-plugin-presets"], @@ -3065,7 +3096,7 @@ "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "zod": ["zod@3.24.1", "", {}, "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], @@ -3073,6 +3104,12 @@ "@actions/http-client/undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="], + "@ai-sdk/gateway/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@ai-sdk/openai/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@ai-sdk/provider-utils/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@asamuzakjp/css-color/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "@auth0/nextjs-auth0/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], @@ -3105,6 +3142,8 @@ "@focus-reactive/payload-plugin-translator/typescript": ["typescript@5.5.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-NcRtPEOsPFFWjobJEtfihkLCZCXZt/os3zf8nTxjVH3RvTSxjrCamJpbExGvYOF+tFHc3pA65qpdwPbzjohhew=="], + "@focus-reactive/payload-plugin-translator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@img/sharp-linux-ppc64/@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], @@ -3137,6 +3176,8 @@ "@payloadcms/db-sqlite/uuid": ["uuid@9.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg=="], + "@payloadcms/plugin-mcp/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@payloadcms/richtext-lexical/csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], "@pnpm/network.ca-file/graceful-fs": ["graceful-fs@4.2.10", "", {}, "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="], @@ -3391,6 +3432,8 @@ "ai/@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + "ai/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], "anymatch/normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], @@ -3971,6 +4014,8 @@ "xss/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "zod-to-json-schema/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], diff --git a/packages/payload-plugin-ai-page-builder/package.json b/packages/payload-plugin-ai-page-builder/package.json new file mode 100644 index 00000000..b1eee5f4 --- /dev/null +++ b/packages/payload-plugin-ai-page-builder/package.json @@ -0,0 +1,74 @@ +{ + "name": "@focus-reactive/payload-plugin-ai-page-builder", + "version": "0.0.0", + "description": "AI-powered page builder plugin for Payload CMS — generate pages from natural language prompts using the Vercel AI SDK", + "keywords": [ + "payload-cms", + "payload-plugin", + "ai", + "page-builder", + "vercel-ai-sdk", + "generative-ai", + "cms" + ], + "license": "MIT", + "author": "FocusReactive ", + "repository": { + "type": "git", + "url": "https://github.com/focusreactive/payload-plugins", + "directory": "packages/payload-plugin-ai-page-builder" + }, + "bugs": { + "url": "https://github.com/focusreactive/payload-plugins/issues" + }, + "homepage": "https://github.com/focusreactive/payload-plugins/tree/main/packages/payload-plugin-ai-page-builder", + "private": false, + "publishConfig": { + "access": "public" + }, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "lint": "ultracite check", + "lint:fix": "ultracite fix", + "check-types": "tsgo --noEmit" + }, + "peerDependencies": { + "@payloadcms/ui": "^3.0.0", + "ai": ">=4.0.0", + "payload": "^3.0.0", + "react": "^18.0.0 || ^19.0.0", + "zod": "^3.0.0" + }, + "peerDependenciesMeta": { + "@payloadcms/ui": { + "optional": true + }, + "react": { + "optional": true + } + }, + "devDependencies": { + "@payloadcms/ui": "3.84.1", + "@types/node": "22.19.9", + "@types/react": "19.2.9", + "ai": "6.0.68", + "payload": "3.84.1", + "react": "19.2.1", + "tsup": "8.0.0", + "typescript": "5.9.2", + "zod": "3.24.1" + } +} diff --git a/packages/payload-plugin-ai-page-builder/src/admin/components/AiPageBuilderButton.export.ts b/packages/payload-plugin-ai-page-builder/src/admin/components/AiPageBuilderButton.export.ts new file mode 100644 index 00000000..216c6fc6 --- /dev/null +++ b/packages/payload-plugin-ai-page-builder/src/admin/components/AiPageBuilderButton.export.ts @@ -0,0 +1,13 @@ +import type { RawPayloadComponent } from "payload"; + +import { adminComponentPath } from "../../shared/componentPath"; + +export class AiPageBuilderButtonExport implements RawPayloadComponent { + path: string; + serverProps?: { basePath: string }; + + constructor(basePath: string) { + this.path = adminComponentPath("AiPageBuilderButton.server"); + this.serverProps = { basePath }; + } +} diff --git a/packages/payload-plugin-ai-page-builder/src/admin/components/AiPageBuilderButton.server.tsx b/packages/payload-plugin-ai-page-builder/src/admin/components/AiPageBuilderButton.server.tsx new file mode 100644 index 00000000..805a9876 --- /dev/null +++ b/packages/payload-plugin-ai-page-builder/src/admin/components/AiPageBuilderButton.server.tsx @@ -0,0 +1,17 @@ +import type { BeforeListTableServerProps } from "payload"; + +import AiPageBuilderButton from "./AiPageBuilderButton"; + +type AiPageBuilderServerProps = BeforeListTableServerProps & { + basePath: string; +}; + +async function AiPageBuilderButtonServer(props: AiPageBuilderServerProps) { + const { collectionSlug, basePath } = props; + + if (!collectionSlug) return null; + + return ; +} + +export default AiPageBuilderButtonServer; diff --git a/packages/payload-plugin-ai-page-builder/src/admin/components/AiPageBuilderButton.tsx b/packages/payload-plugin-ai-page-builder/src/admin/components/AiPageBuilderButton.tsx new file mode 100644 index 00000000..fd371de9 --- /dev/null +++ b/packages/payload-plugin-ai-page-builder/src/admin/components/AiPageBuilderButton.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { toast } from "@payloadcms/ui"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +type Props = { + collectionSlug: string; + basePath: string; +}; + +export function AiPageBuilderButton({ collectionSlug, basePath }: Props) { + const router = useRouter(); + const [isOpen, setIsOpen] = useState(false); + const [prompt, setPrompt] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const handleGenerate = async () => { + const trimmed = prompt.trim(); + if (!trimmed) return; + setIsLoading(true); + try { + const res = await fetch(`/api${basePath}/generate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prompt: trimmed, collectionSlug }), + }); + const data = (await res.json()) as { id?: string; error?: string }; + if (!res.ok) throw new Error(data.error ?? "Generation failed"); + toast.success("Page generated — opening editor…"); + router.push(`/admin/collections/${collectionSlug}/${data.id}`); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to generate page"); + setIsLoading(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + void handleGenerate(); + } + }; + + return ( +
+ {!isOpen ? ( + + ) : ( +
+

+ Generate page with AI +

+

+ Describe the page you want to create. The AI will pick from your configured blocks and generate content. +

+