fix(payload-ai): convert server actions to api endpoints, drop @payload-config dependency
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m20s
Monorepo Pipeline / 🏗️ Build (push) Successful in 3m22s
Monorepo Pipeline / 🧹 Lint (push) Successful in 3m33s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped

This commit is contained in:
2026-03-03 14:58:35 +01:00
parent 5cf9922822
commit 1c43d12e4d
10 changed files with 125 additions and 127 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@mintel/payload-ai",
"version": "1.9.10",
"version": "1.9.13",
"description": "Reusable Payload CMS AI Extensions",
"type": "module",
"scripts": {

View File

@@ -1,6 +1,8 @@
import type { Config, Plugin } from 'payload'
import { AIChatPermissionsCollection } from './collections/AIChatPermissions.js'
import type { PayloadChatPluginConfig } from './types.js'
import { optimizePostEndpoint } from './endpoints/optimizeEndpoint.js'
import { generateSlugEndpoint, generateThumbnailEndpoint, generateSingleFieldEndpoint } from './endpoints/generateEndpoints.js'
export const payloadChatPlugin =
(pluginOptions: PayloadChatPluginConfig): Plugin =>
@@ -48,6 +50,26 @@ export const payloadChatPlugin =
return Response.json({ message: "Chat endpoint active" })
},
},
{
path: '/api/mintel-ai/optimize',
method: 'post',
handler: optimizePostEndpoint,
},
{
path: '/api/mintel-ai/generate-slug',
method: 'post',
handler: generateSlugEndpoint,
},
{
path: '/api/mintel-ai/generate-thumbnail',
method: 'post',
handler: generateThumbnailEndpoint,
},
{
path: '/api/mintel-ai/generate-single-field',
method: 'post',
handler: generateSingleFieldEndpoint,
},
]
// 3. Inject Chat React Component into Admin UI

View File

@@ -2,8 +2,6 @@
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("");
@@ -44,19 +42,26 @@ export function AiFieldButton({ path, field }: { path: string; field: any }) {
? field.admin.description
: "";
const res = await generateSingleFieldAction(
(title as string) || "",
draftContent,
fieldName,
fieldDescription,
instructions,
);
const resData = await fetch("/api/api/mintel-ai/generate-single-field", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
documentTitle: (title as string) || "",
documentContent: draftContent,
fieldName,
fieldDescription,
instructions,
}),
});
const res = await resData.json();
if (res.success && res.text) {
setValue(res.text);
} else {
alert("Fehler: " + res.error);
}
} catch (e) {
} catch (e: any) {
console.error(e)
alert("Fehler bei der Generierung.");
} finally {
setIsGenerating(false);

View File

@@ -2,8 +2,6 @@
import React, { useState, useEffect } from "react";
import { useForm, useField } from "@payloadcms/ui";
import { generateSlugAction } from "../../actions/generateField.js";
export function GenerateSlugButton({ path }: { path: string }) {
const [isGenerating, setIsGenerating] = useState(false);
const [instructions, setInstructions] = useState("");
@@ -45,18 +43,24 @@ export function GenerateSlugButton({ path }: { path: string }) {
setIsGenerating(true);
try {
const res = await generateSlugAction(
title,
draftContent,
initialValue as string,
instructions,
);
const resData = await fetch("/api/api/mintel-ai/generate-slug", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title,
draftContent,
oldSlug: initialValue as string,
instructions,
}),
});
const res = await resData.json();
if (res.success && res.slug) {
setValue(res.slug);
} else {
alert("Fehler: " + res.error);
}
} catch (e) {
} catch (e: any) {
console.error(e);
alert("Unerwarteter Fehler.");
} finally {

View File

@@ -2,8 +2,6 @@
import React, { useState, useEffect } from "react";
import { useForm, useField } from "@payloadcms/ui";
import { generateThumbnailAction } from "../../actions/generateField.js";
export function GenerateThumbnailButton({ path }: { path: string }) {
const [isGenerating, setIsGenerating] = useState(false);
const [instructions, setInstructions] = useState("");
@@ -45,17 +43,23 @@ export function GenerateThumbnailButton({ path }: { path: string }) {
setIsGenerating(true);
try {
const res = await generateThumbnailAction(
draftContent,
title,
instructions,
);
const resData = await fetch("/api/api/mintel-ai/generate-thumbnail", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
draftContent,
title,
instructions,
}),
});
const res = await resData.json();
if (res.success && res.mediaId) {
setValue(res.mediaId);
} else {
alert("Fehler: " + res.error);
}
} catch (e) {
} catch (e: any) {
console.error(e);
alert("Unerwarteter Fehler.");
} finally {

View File

@@ -2,7 +2,6 @@
import React, { useState, useEffect } from "react";
import { useForm, useDocumentInfo } from "@payloadcms/ui";
import { optimizePostText } from "../actions/optimizePost.js";
import { Button } from "@payloadcms/ui";
export function OptimizeButton() {
@@ -57,7 +56,12 @@ export function OptimizeButton() {
// 2. We inject the title so the AI knows what it's writing about
const payloadText = `---\ntitle: "${title}"\n---\n\n${draftContent}`;
const response = await optimizePostText(payloadText, instructions);
const res = await fetch("/api/api/mintel-ai/optimize", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ draftContent: payloadText, instructions }),
});
const response = await res.json();
if (response.success && response.lexicalAST) {
// 3. Inject the new Lexical AST directly into the field form state

View File

@@ -1,8 +1,4 @@
"use server";
import { getPayloadHMR } from "@payloadcms/next/utilities";
// @ts-ignore - dynamic config resolution from next.js payload plugin
import configPromise from "@payload-config";
import { PayloadRequest } from "payload";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import * as os from "node:os";
@@ -30,13 +26,9 @@ async function getOrchestrator() {
});
}
export async function generateSlugAction(
title: string,
draftContent: string,
oldSlug?: string,
instructions?: string,
) {
export const generateSlugEndpoint = async (req: PayloadRequest) => {
try {
const { title, draftContent, oldSlug, instructions } = (await req.json?.() || {}) as any;
const orchestrator = await getOrchestrator();
const newSlug = await orchestrator.generateSlug(
draftContent,
@@ -45,9 +37,8 @@ export async function generateSlugAction(
);
if (oldSlug && oldSlug !== newSlug) {
const payload = await getPayloadHMR({ config: configPromise as any });
await payload.create({
collection: "redirects",
await req.payload.create({
collection: "redirects" as any,
data: {
from: oldSlug,
to: newSlug,
@@ -55,42 +46,25 @@ export async function generateSlugAction(
});
}
return { success: true, slug: newSlug };
return Response.json({ success: true, slug: newSlug });
} catch (e: any) {
return { success: false, error: e.message };
return Response.json({ success: false, error: e.message }, { status: 500 });
}
}
export async function generateThumbnailAction(
draftContent: string,
title?: string,
instructions?: string,
) {
export const generateThumbnailEndpoint = async (req: PayloadRequest) => {
try {
const payload = await getPayloadHMR({ config: configPromise as any });
const { draftContent, title, instructions } = (await req.json?.() || {}) 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)",
);
}
if (!OPENROUTER_KEY) throw new Error("Missing OPENROUTER_API_KEY in .env");
if (!REPLICATE_KEY) throw new Error("Missing REPLICATE_API_KEY in .env");
const importDynamic = new Function(
"modulePath",
"return import(modulePath)",
);
const { AiBlogPostOrchestrator } = await importDynamic(
"@mintel/content-engine",
);
const { ThumbnailGenerator } = await importDynamic(
"@mintel/thumbnail-generator",
);
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,
@@ -112,8 +86,8 @@ export async function generateThumbnailAction(
const stat = await fs.stat(tmpPath);
const fileName = path.basename(tmpPath);
const newMedia = await payload.create({
collection: "media",
const newMedia = await req.payload.create({
collection: "media" as any,
data: {
alt: title ? `Thumbnail for ${title}` : "AI Generated Thumbnail",
},
@@ -125,31 +99,24 @@ export async function generateThumbnailAction(
},
});
// Cleanup temp file
await fs.unlink(tmpPath).catch(() => { });
return { success: true, mediaId: newMedia.id };
return Response.json({ success: true, mediaId: newMedia.id });
} catch (e: any) {
return { success: false, error: e.message };
return Response.json({ success: false, error: e.message }, { status: 500 });
}
}
export async function generateSingleFieldAction(
documentTitle: string,
documentContent: string,
fieldName: string,
fieldDescription: string,
instructions?: string,
) {
export const generateSingleFieldEndpoint = async (req: PayloadRequest) => {
try {
const { documentTitle, documentContent, fieldName, fieldDescription, instructions } = (await req.json?.() || {}) as any;
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",
const contextDocsData = await req.payload.find({
collection: "context-files" as any,
limit: 100,
});
const projectContext = contextDocsData.docs
@@ -184,8 +151,8 @@ CRITICAL RULES:
});
const data = await res.json();
const text = data.choices?.[0]?.message?.content?.trim() || "";
return { success: true, text };
return Response.json({ success: true, text });
} catch (e: any) {
return { success: false, error: e.message };
return Response.json({ success: false, error: e.message }, { status: 500 });
}
}

View File

@@ -1,17 +1,15 @@
"use server";
import { PayloadRequest } from 'payload'
import { parseMarkdownToLexical } from "../utils/lexicalParser.js";
import { parseMarkdownToLexical } from "../utils/lexicalParser";
import { getPayloadHMR } from "@payloadcms/next/utilities";
// @ts-ignore - dynamic config resolution from next.js payload plugin
import configPromise from "@payload-config";
export async function optimizePostText(
draftContent: string,
instructions?: string,
) {
export const optimizePostEndpoint = async (req: PayloadRequest) => {
try {
const payload = await getPayloadHMR({ config: configPromise as any });
const globalAiSettings = (await payload.findGlobal({ slug: "ai-settings" })) as any;
const { draftContent, instructions } = (await req.json?.() || {}) as { draftContent: string; instructions?: string };
if (!draftContent) {
return Response.json({ error: 'Missing draftContent' }, { status: 400 })
}
const globalAiSettings = (await req.payload.findGlobal({ slug: "ai-settings" })) as any;
const customSources =
globalAiSettings?.customSources?.map((s: any) => s.sourceName) || [];
@@ -20,18 +18,12 @@ export async function optimizePostText(
const REPLICATE_KEY = process.env.REPLICATE_API_KEY;
if (!OPENROUTER_KEY) {
throw new Error(
"OPENROUTER_KEY or OPENROUTER_API_KEY not found in environment.",
);
return Response.json({ error: "OPENROUTER_KEY not found in environment." }, { status: 500 })
}
const importDynamic = new Function(
"modulePath",
"return import(modulePath)",
);
const { AiBlogPostOrchestrator } = await importDynamic(
"@mintel/content-engine",
);
// Dynamically import to avoid bundling it into client components that might accidentally import this file
const importDynamic = new Function("modulePath", "return import(modulePath)");
const { AiBlogPostOrchestrator } = await importDynamic("@mintel/content-engine");
const orchestrator = new AiBlogPostOrchestrator({
apiKey: OPENROUTER_KEY,
@@ -39,9 +31,8 @@ export async function optimizePostText(
model: "google/gemini-3-flash-preview",
});
// Fetch context documents purely from DB
const contextDocsData = await payload.find({
collection: "context-files",
const contextDocsData = await req.payload.find({
collection: "context-files" as any,
limit: 100,
});
const projectContext = contextDocsData.docs.map((doc: any) => doc.content);
@@ -49,19 +40,19 @@ export async function optimizePostText(
const optimizedMarkdown = await orchestrator.optimizeDocument({
content: draftContent,
projectContext,
availableComponents: [], // Removed hardcoded config.components dependency
availableComponents: [],
instructions,
internalLinks: [],
customSources,
});
if (!optimizedMarkdown || typeof optimizedMarkdown !== "string") {
throw new Error("AI returned invalid markup.");
return Response.json({ error: "AI returned invalid markup." }, { status: 500 })
}
const blocks = parseMarkdownToLexical(optimizedMarkdown);
return {
return Response.json({
success: true,
lexicalAST: {
root: {
@@ -73,12 +64,12 @@ export async function optimizePostText(
direction: "ltr",
},
},
};
})
} catch (error: any) {
console.error("Failed to optimize post:", error);
return {
console.error("Failed to optimize post in endpoint:", error);
return Response.json({
success: false,
error: error.message || "An unknown error occurred during optimization.",
};
}, { status: 500 })
}
}

View File

@@ -4,8 +4,6 @@
*/
export * from './globals/AiSettings';
export * from './actions/generateField';
export * from './actions/optimizePost';
export * from './components/FieldGenerators/AiFieldButton';
export * from './components/AiMediaButtons';
export * from './components/OptimizeButton';

View File

@@ -1,5 +1,8 @@
declare module "@payload-config" {
import { Config } from "payload";
const configPromise: Promise<any>;
export default configPromise;
export type PayloadChatPluginConfig = {
enabled?: boolean
/** Render the chat bubble on the bottom right? Defaults to true */
renderChatBubble?: boolean
allowedCollections?: string[]
mcpServers?: any[]
}