From 80eefad5eac71d444bd149fa33c23912cbfadffc Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Mon, 2 Mar 2026 21:00:09 +0100 Subject: [PATCH] feat: extract reusable @mintel/payload-ai package --- packages/payload-ai/package.json | 45 + .../payload-ai/src/actions/generateField.ts | 190 +++ .../payload-ai/src/actions/optimizePost.ts | 83 ++ .../src/components/AiMediaButtons.tsx | 163 +++ .../FieldGenerators/AiFieldButton.tsx | 136 +++ .../FieldGenerators/GenerateSlugButton.tsx | 107 ++ .../GenerateThumbnailButton.tsx | 108 ++ .../src/components/OptimizeButton.tsx | 136 +++ .../src/endpoints/replicateMediaEndpoint.ts | 177 +++ packages/payload-ai/src/globals/AiSettings.ts | 30 + packages/payload-ai/src/index.ts | 15 + packages/payload-ai/src/types.d.ts | 5 + .../payload-ai/src/utils/lexicalParser.ts | 640 ++++++++++ packages/payload-ai/tsconfig.json | 30 + pnpm-lock.yaml | 1078 +++++++++++++++++ 15 files changed, 2943 insertions(+) create mode 100644 packages/payload-ai/package.json create mode 100644 packages/payload-ai/src/actions/generateField.ts create mode 100644 packages/payload-ai/src/actions/optimizePost.ts create mode 100644 packages/payload-ai/src/components/AiMediaButtons.tsx create mode 100644 packages/payload-ai/src/components/FieldGenerators/AiFieldButton.tsx create mode 100644 packages/payload-ai/src/components/FieldGenerators/GenerateSlugButton.tsx create mode 100644 packages/payload-ai/src/components/FieldGenerators/GenerateThumbnailButton.tsx create mode 100644 packages/payload-ai/src/components/OptimizeButton.tsx create mode 100644 packages/payload-ai/src/endpoints/replicateMediaEndpoint.ts create mode 100644 packages/payload-ai/src/globals/AiSettings.ts create mode 100644 packages/payload-ai/src/index.ts create mode 100644 packages/payload-ai/src/types.d.ts create mode 100644 packages/payload-ai/src/utils/lexicalParser.ts create mode 100644 packages/payload-ai/tsconfig.json diff --git a/packages/payload-ai/package.json b/packages/payload-ai/package.json new file mode 100644 index 0000000..66745d0 --- /dev/null +++ b/packages/payload-ai/package.json @@ -0,0 +1,45 @@ +{ + "name": "@mintel/payload-ai", + "version": "1.0.0", + "private": true, + "description": "Reusable Payload CMS AI Extensions", + "type": "module", + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": "./dist/index.js", + "./components/*": "./dist/components/*", + "./actions/*": "./dist/actions/*", + "./globals/*": "./dist/globals/*", + "./endpoints/*": "./dist/endpoints/*", + "./utils/*": "./dist/utils/*" + }, + "peerDependencies": { + "@payloadcms/next": ">=3.0.0", + "@payloadcms/ui": ">=3.0.0", + "payload": ">=3.0.0", + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + }, + "dependencies": { + "@mintel/content-engine": "workspace:*", + "@mintel/thumbnail-generator": "workspace:*", + "replicate": "^1.4.0" + }, + "devDependencies": { + "@payloadcms/next": "3.77.0", + "@payloadcms/ui": "3.77.0", + "payload": "3.77.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "@types/node": "^20.17.17", + "@types/react": "^19.2.8", + "@types/react-dom": "^19.2.3", + "next": "^15.1.0", + "typescript": "^5.7.3" + } +} \ No newline at end of file diff --git a/packages/payload-ai/src/actions/generateField.ts b/packages/payload-ai/src/actions/generateField.ts new file mode 100644 index 0000000..f0dce19 --- /dev/null +++ b/packages/payload-ai/src/actions/generateField.ts @@ -0,0 +1,190 @@ +"use server"; + +import { getPayloadHMR } from "@payloadcms/next/utilities"; +import configPromise from "@payload-config"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import * as os from "node:os"; + +async function getOrchestrator() { + const OPENROUTER_KEY = + process.env.OPENROUTER_KEY || process.env.OPENROUTER_API_KEY; + const REPLICATE_KEY = process.env.REPLICATE_API_KEY; + + if (!OPENROUTER_KEY) { + throw new Error( + "Missing OPENROUTER_API_KEY in .env (Required for AI generation)", + ); + } + + const importDynamic = new Function("modulePath", "return import(modulePath)"); + const { AiBlogPostOrchestrator } = await importDynamic( + "@mintel/content-engine", + ); + + return new AiBlogPostOrchestrator({ + apiKey: OPENROUTER_KEY, + replicateApiKey: REPLICATE_KEY, + model: "google/gemini-3-flash-preview", + }); +} + +export async function generateSlugAction( + title: string, + draftContent: string, + oldSlug?: string, + instructions?: string, +) { + try { + const orchestrator = await getOrchestrator(); + const newSlug = await orchestrator.generateSlug( + draftContent, + title, + instructions, + ); + + if (oldSlug && oldSlug !== newSlug) { + const payload = await getPayloadHMR({ config: configPromise as any }); + await payload.create({ + collection: "redirects", + data: { + from: oldSlug, + to: newSlug, + }, + }); + } + + return { success: true, slug: newSlug }; + } catch (e: any) { + return { success: false, error: e.message }; + } +} + +export async function generateThumbnailAction( + draftContent: string, + title?: string, + instructions?: string, +) { + try { + const payload = await getPayloadHMR({ config: configPromise as any }); + const OPENROUTER_KEY = + process.env.OPENROUTER_KEY || process.env.OPENROUTER_API_KEY; + const REPLICATE_KEY = process.env.REPLICATE_API_KEY; + + if (!OPENROUTER_KEY) { + throw new Error("Missing OPENROUTER_API_KEY in .env"); + } + if (!REPLICATE_KEY) { + throw new Error( + "Missing REPLICATE_API_KEY in .env (Required for Thumbnails)", + ); + } + + const importDynamic = new Function( + "modulePath", + "return import(modulePath)", + ); + const { AiBlogPostOrchestrator } = await importDynamic( + "@mintel/content-engine", + ); + const { ThumbnailGenerator } = await importDynamic( + "@mintel/thumbnail-generator", + ); + + const orchestrator = new AiBlogPostOrchestrator({ + apiKey: OPENROUTER_KEY, + replicateApiKey: REPLICATE_KEY, + model: "google/gemini-3-flash-preview", + }); + + const tg = new ThumbnailGenerator({ replicateApiKey: REPLICATE_KEY }); + + const prompt = await orchestrator.generateVisualPrompt( + draftContent || title || "Technology", + instructions, + ); + + const tmpPath = path.join(os.tmpdir(), `mintel-thumb-${Date.now()}.png`); + await tg.generateImage(prompt, tmpPath); + + const fileData = await fs.readFile(tmpPath); + const stat = await fs.stat(tmpPath); + const fileName = path.basename(tmpPath); + + const newMedia = await payload.create({ + collection: "media", + data: { + alt: title ? `Thumbnail for ${title}` : "AI Generated Thumbnail", + }, + file: { + data: fileData, + name: fileName, + mimetype: "image/png", + size: stat.size, + }, + }); + + // Cleanup temp file + await fs.unlink(tmpPath).catch(() => { }); + + return { success: true, mediaId: newMedia.id }; + } catch (e: any) { + return { success: false, error: e.message }; + } +} +export async function generateSingleFieldAction( + documentTitle: string, + documentContent: string, + fieldName: string, + fieldDescription: string, + instructions?: string, +) { + try { + const OPENROUTER_KEY = + process.env.OPENROUTER_KEY || process.env.OPENROUTER_API_KEY; + if (!OPENROUTER_KEY) throw new Error("Missing OPENROUTER_API_KEY"); + + const payload = await getPayloadHMR({ config: configPromise as any }); + + // Fetch context documents from DB + const contextDocsData = await payload.find({ + collection: "context-files", + limit: 100, + }); + const projectContext = contextDocsData.docs + .map((doc: any) => `--- ${doc.filename} ---\n${doc.content}`) + .join("\n\n"); + + const prompt = `You are an expert AI assistant perfectly trained for generating exact data values for CMS components. +PROJECT STRATEGY & CONTEXT: +${projectContext} + +DOCUMENT TITLE: ${documentTitle} +DOCUMENT DRAFT:\n${documentContent}\n +YOUR TASK: Generate the exact value for a specific field named "${fieldName}". +${fieldDescription ? `FIELD DESCRIPTION / CONSTRAINTS: ${fieldDescription}\n` : ""} +${instructions ? `EDITOR INSTRUCTIONS for this field: ${instructions}\n` : ""} +CRITICAL RULES: +1. Respond ONLY with the requested content value. +2. NO markdown wrapping blocks (like \`\`\`mermaid or \`\`\`html) around the output! Just the raw code or text. +3. If the field implies a diagram or flow, output RAW Mermaid.js code. +4. If it's standard text, write professional B2B German. No quotes, no conversational filler.`; + + const res = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + Authorization: `Bearer ${OPENROUTER_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "google/gemini-3-flash-preview", + messages: [{ role: "user", content: prompt }], + }), + }); + const data = await res.json(); + const text = data.choices?.[0]?.message?.content?.trim() || ""; + return { success: true, text }; + } catch (e: any) { + return { success: false, error: e.message }; + } +} diff --git a/packages/payload-ai/src/actions/optimizePost.ts b/packages/payload-ai/src/actions/optimizePost.ts new file mode 100644 index 0000000..c56e26d --- /dev/null +++ b/packages/payload-ai/src/actions/optimizePost.ts @@ -0,0 +1,83 @@ +"use server"; + +import { parseMarkdownToLexical } from "../utils/lexicalParser"; +import { getPayloadHMR } from "@payloadcms/next/utilities"; +import configPromise from "@payload-config"; + +export async function optimizePostText( + draftContent: string, + instructions?: string, +) { + try { + const payload = await getPayloadHMR({ config: configPromise as any }); + const globalAiSettings = (await payload.findGlobal({ slug: "ai-settings" })) as any; + const customSources = + globalAiSettings?.customSources?.map((s: any) => s.sourceName) || []; + + const OPENROUTER_KEY = + process.env.OPENROUTER_KEY || process.env.OPENROUTER_API_KEY; + const REPLICATE_KEY = process.env.REPLICATE_API_KEY; + + if (!OPENROUTER_KEY) { + throw new Error( + "OPENROUTER_KEY or OPENROUTER_API_KEY not found in environment.", + ); + } + + const importDynamic = new Function( + "modulePath", + "return import(modulePath)", + ); + const { AiBlogPostOrchestrator } = await importDynamic( + "@mintel/content-engine", + ); + + const orchestrator = new AiBlogPostOrchestrator({ + apiKey: OPENROUTER_KEY, + replicateApiKey: REPLICATE_KEY, + model: "google/gemini-3-flash-preview", + }); + + // Fetch context documents purely from DB + const contextDocsData = await payload.find({ + collection: "context-files", + limit: 100, + }); + const projectContext = contextDocsData.docs.map((doc: any) => doc.content); + + const optimizedMarkdown = await orchestrator.optimizeDocument({ + content: draftContent, + projectContext, + availableComponents: [], // Removed hardcoded config.components dependency + instructions, + internalLinks: [], + customSources, + }); + + if (!optimizedMarkdown || typeof optimizedMarkdown !== "string") { + throw new Error("AI returned invalid markup."); + } + + const blocks = parseMarkdownToLexical(optimizedMarkdown); + + return { + success: true, + lexicalAST: { + root: { + type: "root", + format: "", + indent: 0, + version: 1, + children: blocks, + direction: "ltr", + }, + }, + }; + } catch (error: any) { + console.error("Failed to optimize post:", error); + return { + success: false, + error: error.message || "An unknown error occurred during optimization.", + }; + } +} diff --git a/packages/payload-ai/src/components/AiMediaButtons.tsx b/packages/payload-ai/src/components/AiMediaButtons.tsx new file mode 100644 index 0000000..8a735c8 --- /dev/null +++ b/packages/payload-ai/src/components/AiMediaButtons.tsx @@ -0,0 +1,163 @@ +"use client"; + +import React, { useState } from "react"; +import { useDocumentInfo, toast } from "@payloadcms/ui"; + +type Action = "upscale" | "recover"; + +interface ActionState { + loading: boolean; + resultId?: string | number; +} + +export const AiMediaButtons: React.FC = () => { + const { id } = useDocumentInfo(); + + const [upscale, setUpscale] = useState({ loading: false }); + const [recover, setRecover] = useState({ loading: false }); + + if (!id) return null; // Only show on existing documents + + const runAction = async (action: Action) => { + const setter = action === "upscale" ? setUpscale : setRecover; + setter({ loading: true }); + + const label = action === "upscale" ? "AI Upscale" : "AI Recover"; + + toast.info( + `${label} started – this can take 30–90 seconds, please wait…`, + ); + + try { + // The API path is hardcoded here and assuming that's where the host app registers the endpoint. + const response = await fetch(`/api/media/${id}/ai-process`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action }), + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error || `${label} failed`); + } + + setter({ loading: false, resultId: result.mediaId }); + + toast.success( + `✅ ${label} erfolgreich! Neues Bild (ID: ${result.mediaId}) wurde gespeichert.`, + ); + } catch (err: any) { + console.error(`[AiMediaButtons] ${action} error:`, err); + toast.error( + err instanceof Error ? err.message : `${label} fehlgeschlagen`, + ); + setter({ loading: false }); + } + }; + + const buttonStyle: React.CSSProperties = { + background: "var(--theme-elevation-150)", + border: "1px solid var(--theme-elevation-200)", + color: "var(--theme-text)", + padding: "8px 14px", + borderRadius: "4px", + fontSize: "13px", + fontWeight: 500, + display: "inline-flex", + alignItems: "center", + gap: "6px", + transition: "opacity 0.15s ease", + }; + + const disabledStyle: React.CSSProperties = { + opacity: 0.55, + cursor: "not-allowed", + }; + + return ( +
+ {/* AI Upscale */} +
+ + {upscale.resultId && ( + + → Neues Bild öffnen (ID: {upscale.resultId}) + + )} +
+ + {/* AI Recover */} +
+ + {recover.resultId && ( + + → Neues Bild öffnen (ID: {recover.resultId}) + + )} +
+ +

+ AI Upscale verbessert die Auflösung via{" "} + google/upscaler. AI Recover restauriert + alte/beschädigte Fotos via{" "} + microsoft/bringing-old-photos-back-to-life. Das + Ergebnis wird als neues Medium gespeichert. +

+
+ ); +}; diff --git a/packages/payload-ai/src/components/FieldGenerators/AiFieldButton.tsx b/packages/payload-ai/src/components/FieldGenerators/AiFieldButton.tsx new file mode 100644 index 0000000..9b45660 --- /dev/null +++ b/packages/payload-ai/src/components/FieldGenerators/AiFieldButton.tsx @@ -0,0 +1,136 @@ +"use client"; + +import React, { useState } from "react"; +import { useField, useDocumentInfo, useForm } from "@payloadcms/ui"; +import { generateSingleFieldAction } from "../../actions/generateField.js"; + +export function AiFieldButton({ path, field }: { path: string; field: any }) { + const [isGenerating, setIsGenerating] = useState(false); + const [instructions, setInstructions] = useState(""); + const [showInstructions, setShowInstructions] = useState(false); + + // Payload hooks + const { value, setValue } = useField({ path }); + const { title } = useDocumentInfo(); + const { fields } = useForm(); + + const extractText = (lexicalRoot: any): string => { + if (!lexicalRoot) return ""; + let text = ""; + const iterate = (node: any) => { + if (node.text) text += node.text + " "; + if (node.children) node.children.forEach(iterate); + }; + iterate(lexicalRoot); + return text; + }; + + const handleGenerate = async (e: React.MouseEvent) => { + e.preventDefault(); + + const lexicalValue = fields?.content?.value as any; + const legacyValue = fields?.legacyMdx?.value as string; + let draftContent = legacyValue || ""; + if (!draftContent && lexicalValue?.root) { + draftContent = extractText(lexicalValue.root); + } + + setIsGenerating(true); + try { + // Field name is passed as a label usually, fallback to path + const fieldName = typeof field?.label === "string" ? field.label : path; + const fieldDescription = + typeof field?.admin?.description === "string" + ? field.admin.description + : ""; + + const res = await generateSingleFieldAction( + (title as string) || "", + draftContent, + fieldName, + fieldDescription, + instructions, + ); + if (res.success && res.text) { + setValue(res.text); + } else { + alert("Fehler: " + res.error); + } + } catch (e) { + alert("Fehler bei der Generierung."); + } finally { + setIsGenerating(false); + setShowInstructions(false); + } + }; + + return ( +
+
+ + +
+ {showInstructions && ( +