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", "name": "@mintel/payload-ai",
"version": "1.9.10", "version": "1.9.13",
"description": "Reusable Payload CMS AI Extensions", "description": "Reusable Payload CMS AI Extensions",
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -1,6 +1,8 @@
import type { Config, Plugin } from 'payload' import type { Config, Plugin } from 'payload'
import { AIChatPermissionsCollection } from './collections/AIChatPermissions.js' import { AIChatPermissionsCollection } from './collections/AIChatPermissions.js'
import type { PayloadChatPluginConfig } from './types.js' import type { PayloadChatPluginConfig } from './types.js'
import { optimizePostEndpoint } from './endpoints/optimizeEndpoint.js'
import { generateSlugEndpoint, generateThumbnailEndpoint, generateSingleFieldEndpoint } from './endpoints/generateEndpoints.js'
export const payloadChatPlugin = export const payloadChatPlugin =
(pluginOptions: PayloadChatPluginConfig): Plugin => (pluginOptions: PayloadChatPluginConfig): Plugin =>
@@ -48,6 +50,26 @@ export const payloadChatPlugin =
return Response.json({ message: "Chat endpoint active" }) 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 // 3. Inject Chat React Component into Admin UI

View File

@@ -2,8 +2,6 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { useField, useDocumentInfo, useForm } from "@payloadcms/ui"; import { useField, useDocumentInfo, useForm } from "@payloadcms/ui";
import { generateSingleFieldAction } from "../../actions/generateField.js";
export function AiFieldButton({ path, field }: { path: string; field: any }) { export function AiFieldButton({ path, field }: { path: string; field: any }) {
const [isGenerating, setIsGenerating] = useState(false); const [isGenerating, setIsGenerating] = useState(false);
const [instructions, setInstructions] = useState(""); const [instructions, setInstructions] = useState("");
@@ -44,19 +42,26 @@ export function AiFieldButton({ path, field }: { path: string; field: any }) {
? field.admin.description ? field.admin.description
: ""; : "";
const res = await generateSingleFieldAction( const resData = await fetch("/api/api/mintel-ai/generate-single-field", {
(title as string) || "", method: "POST",
draftContent, headers: { "Content-Type": "application/json" },
fieldName, body: JSON.stringify({
fieldDescription, documentTitle: (title as string) || "",
instructions, documentContent: draftContent,
); fieldName,
fieldDescription,
instructions,
}),
});
const res = await resData.json();
if (res.success && res.text) { if (res.success && res.text) {
setValue(res.text); setValue(res.text);
} else { } else {
alert("Fehler: " + res.error); alert("Fehler: " + res.error);
} }
} catch (e) { } catch (e: any) {
console.error(e)
alert("Fehler bei der Generierung."); alert("Fehler bei der Generierung.");
} finally { } finally {
setIsGenerating(false); setIsGenerating(false);

View File

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

View File

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

View File

@@ -2,7 +2,6 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { useForm, useDocumentInfo } from "@payloadcms/ui"; import { useForm, useDocumentInfo } from "@payloadcms/ui";
import { optimizePostText } from "../actions/optimizePost.js";
import { Button } from "@payloadcms/ui"; import { Button } from "@payloadcms/ui";
export function OptimizeButton() { export function OptimizeButton() {
@@ -57,7 +56,12 @@ export function OptimizeButton() {
// 2. We inject the title so the AI knows what it's writing about // 2. We inject the title so the AI knows what it's writing about
const payloadText = `---\ntitle: "${title}"\n---\n\n${draftContent}`; 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) { if (response.success && response.lexicalAST) {
// 3. Inject the new Lexical AST directly into the field form state // 3. Inject the new Lexical AST directly into the field form state

View File

@@ -1,8 +1,4 @@
"use server"; import { PayloadRequest } from "payload";
import { getPayloadHMR } from "@payloadcms/next/utilities";
// @ts-ignore - dynamic config resolution from next.js payload plugin
import configPromise from "@payload-config";
import * as fs from "node:fs/promises"; import * as fs from "node:fs/promises";
import * as path from "node:path"; import * as path from "node:path";
import * as os from "node:os"; import * as os from "node:os";
@@ -30,13 +26,9 @@ async function getOrchestrator() {
}); });
} }
export async function generateSlugAction( export const generateSlugEndpoint = async (req: PayloadRequest) => {
title: string,
draftContent: string,
oldSlug?: string,
instructions?: string,
) {
try { try {
const { title, draftContent, oldSlug, instructions } = (await req.json?.() || {}) as any;
const orchestrator = await getOrchestrator(); const orchestrator = await getOrchestrator();
const newSlug = await orchestrator.generateSlug( const newSlug = await orchestrator.generateSlug(
draftContent, draftContent,
@@ -45,9 +37,8 @@ export async function generateSlugAction(
); );
if (oldSlug && oldSlug !== newSlug) { if (oldSlug && oldSlug !== newSlug) {
const payload = await getPayloadHMR({ config: configPromise as any }); await req.payload.create({
await payload.create({ collection: "redirects" as any,
collection: "redirects",
data: { data: {
from: oldSlug, from: oldSlug,
to: newSlug, 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) { } catch (e: any) {
return { success: false, error: e.message }; return Response.json({ success: false, error: e.message }, { status: 500 });
} }
} }
export async function generateThumbnailAction( export const generateThumbnailEndpoint = async (req: PayloadRequest) => {
draftContent: string,
title?: string,
instructions?: string,
) {
try { try {
const payload = await getPayloadHMR({ config: configPromise as any }); const { draftContent, title, instructions } = (await req.json?.() || {}) as any;
const OPENROUTER_KEY = const OPENROUTER_KEY =
process.env.OPENROUTER_KEY || process.env.OPENROUTER_API_KEY; process.env.OPENROUTER_KEY || process.env.OPENROUTER_API_KEY;
const REPLICATE_KEY = process.env.REPLICATE_API_KEY; const REPLICATE_KEY = process.env.REPLICATE_API_KEY;
if (!OPENROUTER_KEY) { if (!OPENROUTER_KEY) throw new Error("Missing OPENROUTER_API_KEY in .env");
throw new Error("Missing OPENROUTER_API_KEY in .env"); if (!REPLICATE_KEY) throw new Error("Missing REPLICATE_API_KEY in .env");
}
if (!REPLICATE_KEY) {
throw new Error(
"Missing REPLICATE_API_KEY in .env (Required for Thumbnails)",
);
}
const importDynamic = new Function( const importDynamic = new Function("modulePath", "return import(modulePath)");
"modulePath", const { AiBlogPostOrchestrator } = await importDynamic("@mintel/content-engine");
"return import(modulePath)", const { ThumbnailGenerator } = await importDynamic("@mintel/thumbnail-generator");
);
const { AiBlogPostOrchestrator } = await importDynamic(
"@mintel/content-engine",
);
const { ThumbnailGenerator } = await importDynamic(
"@mintel/thumbnail-generator",
);
const orchestrator = new AiBlogPostOrchestrator({ const orchestrator = new AiBlogPostOrchestrator({
apiKey: OPENROUTER_KEY, apiKey: OPENROUTER_KEY,
@@ -112,8 +86,8 @@ export async function generateThumbnailAction(
const stat = await fs.stat(tmpPath); const stat = await fs.stat(tmpPath);
const fileName = path.basename(tmpPath); const fileName = path.basename(tmpPath);
const newMedia = await payload.create({ const newMedia = await req.payload.create({
collection: "media", collection: "media" as any,
data: { data: {
alt: title ? `Thumbnail for ${title}` : "AI Generated Thumbnail", alt: title ? `Thumbnail for ${title}` : "AI Generated Thumbnail",
}, },
@@ -125,31 +99,24 @@ export async function generateThumbnailAction(
}, },
}); });
// Cleanup temp file
await fs.unlink(tmpPath).catch(() => { }); await fs.unlink(tmpPath).catch(() => { });
return { success: true, mediaId: newMedia.id }; return Response.json({ success: true, mediaId: newMedia.id });
} catch (e: any) { } 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, export const generateSingleFieldEndpoint = async (req: PayloadRequest) => {
documentContent: string,
fieldName: string,
fieldDescription: string,
instructions?: string,
) {
try { try {
const { documentTitle, documentContent, fieldName, fieldDescription, instructions } = (await req.json?.() || {}) as any;
const OPENROUTER_KEY = const OPENROUTER_KEY =
process.env.OPENROUTER_KEY || process.env.OPENROUTER_API_KEY; process.env.OPENROUTER_KEY || process.env.OPENROUTER_API_KEY;
if (!OPENROUTER_KEY) throw new Error("Missing OPENROUTER_API_KEY"); if (!OPENROUTER_KEY) throw new Error("Missing OPENROUTER_API_KEY");
const payload = await getPayloadHMR({ config: configPromise as any }); const contextDocsData = await req.payload.find({
collection: "context-files" as any,
// Fetch context documents from DB
const contextDocsData = await payload.find({
collection: "context-files",
limit: 100, limit: 100,
}); });
const projectContext = contextDocsData.docs const projectContext = contextDocsData.docs
@@ -184,8 +151,8 @@ CRITICAL RULES:
}); });
const data = await res.json(); const data = await res.json();
const text = data.choices?.[0]?.message?.content?.trim() || ""; const text = data.choices?.[0]?.message?.content?.trim() || "";
return { success: true, text }; return Response.json({ success: true, text });
} catch (e: any) { } 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"; export const optimizePostEndpoint = async (req: PayloadRequest) => {
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,
) {
try { try {
const payload = await getPayloadHMR({ config: configPromise as any }); const { draftContent, instructions } = (await req.json?.() || {}) as { draftContent: string; instructions?: string };
const globalAiSettings = (await payload.findGlobal({ slug: "ai-settings" })) as any;
if (!draftContent) {
return Response.json({ error: 'Missing draftContent' }, { status: 400 })
}
const globalAiSettings = (await req.payload.findGlobal({ slug: "ai-settings" })) as any;
const customSources = const customSources =
globalAiSettings?.customSources?.map((s: any) => s.sourceName) || []; globalAiSettings?.customSources?.map((s: any) => s.sourceName) || [];
@@ -20,18 +18,12 @@ export async function optimizePostText(
const REPLICATE_KEY = process.env.REPLICATE_API_KEY; const REPLICATE_KEY = process.env.REPLICATE_API_KEY;
if (!OPENROUTER_KEY) { if (!OPENROUTER_KEY) {
throw new Error( return Response.json({ error: "OPENROUTER_KEY not found in environment." }, { status: 500 })
"OPENROUTER_KEY or OPENROUTER_API_KEY not found in environment.",
);
} }
const importDynamic = new Function( // Dynamically import to avoid bundling it into client components that might accidentally import this file
"modulePath", const importDynamic = new Function("modulePath", "return import(modulePath)");
"return import(modulePath)", const { AiBlogPostOrchestrator } = await importDynamic("@mintel/content-engine");
);
const { AiBlogPostOrchestrator } = await importDynamic(
"@mintel/content-engine",
);
const orchestrator = new AiBlogPostOrchestrator({ const orchestrator = new AiBlogPostOrchestrator({
apiKey: OPENROUTER_KEY, apiKey: OPENROUTER_KEY,
@@ -39,9 +31,8 @@ export async function optimizePostText(
model: "google/gemini-3-flash-preview", model: "google/gemini-3-flash-preview",
}); });
// Fetch context documents purely from DB const contextDocsData = await req.payload.find({
const contextDocsData = await payload.find({ collection: "context-files" as any,
collection: "context-files",
limit: 100, limit: 100,
}); });
const projectContext = contextDocsData.docs.map((doc: any) => doc.content); const projectContext = contextDocsData.docs.map((doc: any) => doc.content);
@@ -49,19 +40,19 @@ export async function optimizePostText(
const optimizedMarkdown = await orchestrator.optimizeDocument({ const optimizedMarkdown = await orchestrator.optimizeDocument({
content: draftContent, content: draftContent,
projectContext, projectContext,
availableComponents: [], // Removed hardcoded config.components dependency availableComponents: [],
instructions, instructions,
internalLinks: [], internalLinks: [],
customSources, customSources,
}); });
if (!optimizedMarkdown || typeof optimizedMarkdown !== "string") { 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); const blocks = parseMarkdownToLexical(optimizedMarkdown);
return { return Response.json({
success: true, success: true,
lexicalAST: { lexicalAST: {
root: { root: {
@@ -73,12 +64,12 @@ export async function optimizePostText(
direction: "ltr", direction: "ltr",
}, },
}, },
}; })
} catch (error: any) { } catch (error: any) {
console.error("Failed to optimize post:", error); console.error("Failed to optimize post in endpoint:", error);
return { return Response.json({
success: false, success: false,
error: error.message || "An unknown error occurred during optimization.", error: error.message || "An unknown error occurred during optimization.",
}; }, { status: 500 })
} }
} }

View File

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

View File

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