Compare commits
11 Commits
72556af24c
...
v1.9.9
| Author | SHA1 | Date | |
|---|---|---|---|
| d96d6a4b13 | |||
| 8f6b12d827 | |||
| a11714d07d | |||
| 52f7e68f25 | |||
| 217ac33675 | |||
| f2b8b136af | |||
| 2e07b213d1 | |||
| a2c1eaefba | |||
| 80ff266f9c | |||
| 6b1c5b7e30 | |||
| 80eefad5ea |
@@ -1,7 +0,0 @@
|
|||||||
---
|
|
||||||
"@mintel/monorepo": patch
|
|
||||||
"acquisition-manager": patch
|
|
||||||
"feedback-commander": patch
|
|
||||||
---
|
|
||||||
|
|
||||||
fix: make directus extension build scripts more resilient
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# Project
|
# Project
|
||||||
IMAGE_TAG=v1.9.5
|
IMAGE_TAG=v1.9.9
|
||||||
PROJECT_NAME=sample-website
|
PROJECT_NAME=sample-website
|
||||||
PROJECT_COLOR=#82ed20
|
PROJECT_COLOR=#82ed20
|
||||||
|
|
||||||
|
|||||||
@@ -202,9 +202,9 @@ jobs:
|
|||||||
- name: 🔐 Registry Login
|
- name: 🔐 Registry Login
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: registry.infra.mintel.me
|
registry: git.infra.mintel.me
|
||||||
username: ${{ secrets.REGISTRY_USER }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.REGISTRY_PASS }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: 🏗️ Build & Push ${{ matrix.name }}
|
- name: 🏗️ Build & Push ${{ matrix.name }}
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
@@ -218,6 +218,6 @@ jobs:
|
|||||||
secrets: |
|
secrets: |
|
||||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||||
tags: |
|
tags: |
|
||||||
registry.infra.mintel.me/mintel/${{ matrix.image }}:${{ github.ref_name }}
|
git.infra.mintel.me/mmintel/${{ matrix.image }}:${{ github.ref_name }}
|
||||||
registry.infra.mintel.me/mintel/${{ matrix.image }}:latest
|
git.infra.mintel.me/mmintel/${{ matrix.image }}:latest
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Stage 1: Builder
|
# Stage 1: Builder
|
||||||
FROM registry.infra.mintel.me/mintel/nextjs:latest AS builder
|
FROM git.infra.mintel.me/mmintel/nextjs:latest AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Clean the workspace in case the base image is dirty
|
# Clean the workspace in case the base image is dirty
|
||||||
@@ -37,7 +37,7 @@ COPY . .
|
|||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
# Stage 2: Runner
|
# Stage 2: Runner
|
||||||
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
|
FROM git.infra.mintel.me/mmintel/runtime:latest AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sample-website",
|
"name": "sample-website",
|
||||||
"version": "1.9.5",
|
"version": "1.9.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
"build": "pnpm -r build",
|
"build": "pnpm -r build",
|
||||||
"dev": "pnpm -r dev",
|
"dev": "pnpm -r dev",
|
||||||
"dev:gatekeeper": "bash -c 'trap \"COMPOSE_PROJECT_NAME=gatekeeper docker-compose -f docker-compose.gatekeeper.yml down\" EXIT INT TERM; docker network create infra 2>/dev/null || true && COMPOSE_PROJECT_NAME=gatekeeper docker-compose -f docker-compose.gatekeeper.yml down && COMPOSE_PROJECT_NAME=gatekeeper docker-compose -f docker-compose.gatekeeper.yml up --build --remove-orphans'",
|
"dev:gatekeeper": "bash -c 'trap \"COMPOSE_PROJECT_NAME=gatekeeper docker-compose -f docker-compose.gatekeeper.yml down\" EXIT INT TERM; docker network create infra 2>/dev/null || true && COMPOSE_PROJECT_NAME=gatekeeper docker-compose -f docker-compose.gatekeeper.yml down && COMPOSE_PROJECT_NAME=gatekeeper docker-compose -f docker-compose.gatekeeper.yml up --build --remove-orphans'",
|
||||||
|
"dev:mcps:up": "docker-compose -f docker-compose.mcps.yml up -d",
|
||||||
|
"dev:mcps:down": "docker-compose -f docker-compose.mcps.yml down",
|
||||||
|
"dev:mcps:watch": "pnpm -r --filter=\"./packages/*-mcp\" run dev",
|
||||||
|
"dev:mcps": "npm run dev:mcps:up && npm run dev:mcps:watch",
|
||||||
"lint": "pnpm -r --filter='./packages/**' --filter='./apps/**' lint",
|
"lint": "pnpm -r --filter='./packages/**' --filter='./apps/**' lint",
|
||||||
"test": "pnpm -r test",
|
"test": "pnpm -r test",
|
||||||
"changeset": "changeset",
|
"changeset": "changeset",
|
||||||
@@ -49,7 +53,7 @@
|
|||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"require-in-the-middle": "^8.0.1"
|
"require-in-the-middle": "^8.0.1"
|
||||||
},
|
},
|
||||||
"version": "1.9.5",
|
"version": "1.9.9",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"@parcel/watcher",
|
"@parcel/watcher",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/cli",
|
"name": "@mintel/cli",
|
||||||
"version": "1.9.5",
|
"version": "1.9.9",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/cloner",
|
"name": "@mintel/cloner",
|
||||||
"version": "1.9.5",
|
"version": "1.9.9",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "dist/index.js",
|
"module": "dist/index.js",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/concept-engine",
|
"name": "@mintel/concept-engine",
|
||||||
"version": "1.9.5",
|
"version": "1.9.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "AI-powered web project concept generation and analysis",
|
"description": "AI-powered web project concept generation and analysis",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/content-engine",
|
"name": "@mintel/content-engine",
|
||||||
"version": "1.9.5",
|
"version": "1.9.9",
|
||||||
"private": false,
|
"private": false,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/eslint-config",
|
"name": "@mintel/eslint-config",
|
||||||
"version": "1.9.5",
|
"version": "1.9.9",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/estimation-engine",
|
"name": "@mintel/estimation-engine",
|
||||||
"version": "1.9.5",
|
"version": "1.9.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/gatekeeper",
|
"name": "@mintel/gatekeeper",
|
||||||
"version": "1.9.6",
|
"version": "1.9.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -12,14 +12,11 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mintel/next-utils": "workspace:*",
|
"@mintel/next-utils": "workspace:*",
|
||||||
"@react-three/drei": "^10.7.7",
|
|
||||||
"@react-three/fiber": "^9.5.0",
|
|
||||||
"framer-motion": "^11.18.2",
|
"framer-motion": "^11.18.2",
|
||||||
"lucide-react": "^0.474.0",
|
"lucide-react": "^0.474.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0"
|
||||||
"three": "^0.183.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@mintel/eslint-config": "workspace:*",
|
"@mintel/eslint-config": "workspace:*",
|
||||||
@@ -29,7 +26,6 @@
|
|||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@types/three": "^0.183.1",
|
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/gitea-mcp",
|
"name": "@mintel/gitea-mcp",
|
||||||
"version": "1.0.0",
|
"version": "1.9.9",
|
||||||
"description": "Native Gitea MCP server for 100% Antigravity compatibility",
|
"description": "Native Gitea MCP server for 100% Antigravity compatibility",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js"
|
"start": "node dist/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.5.0",
|
"@modelcontextprotocol/sdk": "^1.5.0",
|
||||||
"zod": "^3.23.8",
|
"zod": "^3.23.8",
|
||||||
"axios": "^1.7.2"
|
"axios": "^1.7.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.5.3",
|
"typescript": "^5.5.3",
|
||||||
"@types/node": "^20.14.10"
|
"@types/node": "^20.14.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/husky-config",
|
"name": "@mintel/husky-config",
|
||||||
"version": "1.9.5",
|
"version": "1.9.9",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Start from the pre-built Nextjs Base image
|
# Start from the pre-built Nextjs Base image
|
||||||
FROM registry.infra.mintel.me/mintel/nextjs:latest AS builder
|
FROM git.infra.mintel.me/mmintel/nextjs:latest AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ ENV DIRECTUS_URL=$DIRECTUS_URL
|
|||||||
RUN pnpm --filter ${APP_NAME:-app} build
|
RUN pnpm --filter ${APP_NAME:-app} build
|
||||||
|
|
||||||
# Production runner image
|
# Production runner image
|
||||||
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
|
FROM git.infra.mintel.me/mmintel/runtime:latest AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy standalone output and static files
|
# Copy standalone output and static files
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ services:
|
|||||||
- "traefik.http.middlewares.${PROJECT_NAME:-app}-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
- "traefik.http.middlewares.${PROJECT_NAME:-app}-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
||||||
|
|
||||||
gatekeeper:
|
gatekeeper:
|
||||||
image: registry.infra.mintel.me/mintel/gatekeeper:${IMAGE_TAG:-latest}
|
image: git.infra.mintel.me/mmintel/gatekeeper:${IMAGE_TAG:-latest}
|
||||||
restart: always
|
restart: always
|
||||||
networks:
|
networks:
|
||||||
- infra
|
- infra
|
||||||
@@ -53,7 +53,7 @@ services:
|
|||||||
- "traefik.http.services.${PROJECT_NAME}-gatekeeper.loadbalancer.server.port=3000"
|
- "traefik.http.services.${PROJECT_NAME}-gatekeeper.loadbalancer.server.port=3000"
|
||||||
|
|
||||||
directus:
|
directus:
|
||||||
image: registry.infra.mintel.me/mintel/directus:${IMAGE_TAG:-latest}
|
image: git.infra.mintel.me/mmintel/directus:${IMAGE_TAG:-latest}
|
||||||
restart: always
|
restart: always
|
||||||
networks:
|
networks:
|
||||||
- infra
|
- infra
|
||||||
|
|||||||
@@ -180,9 +180,9 @@ jobs:
|
|||||||
- name: 🔐 Registry Login
|
- name: 🔐 Registry Login
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: registry.infra.mintel.me
|
registry: git.infra.mintel.me
|
||||||
username: ${{ secrets.REGISTRY_USER }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.REGISTRY_PASS }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: 🏗️ Docker Build & Push
|
- name: 🏗️ Docker Build & Push
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
@@ -198,7 +198,7 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
secrets: |
|
secrets: |
|
||||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||||
tags: registry.infra.mintel.me/mintel/${{ github.event.repository.name }}:${{ needs.prepare.outputs.image_tag }}
|
tags: git.infra.mintel.me/mmintel/${{ github.event.repository.name }}:${{ needs.prepare.outputs.image_tag }}
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 4: Deploy
|
# JOB 4: Deploy
|
||||||
@@ -262,7 +262,7 @@ jobs:
|
|||||||
set -e
|
set -e
|
||||||
cd "/home/deploy/sites/${{ github.event.repository.name }}"
|
cd "/home/deploy/sites/${{ github.event.repository.name }}"
|
||||||
chmod 600 "$ENV_FILE"
|
chmod 600 "$ENV_FILE"
|
||||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
echo "${{ secrets.GITHUB_TOKEN }}" | docker login git.infra.mintel.me -u "${{ github.actor }}" --password-stdin
|
||||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" pull
|
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" pull
|
||||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" up -d --remove-orphans
|
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" up -d --remove-orphans
|
||||||
docker system prune -f --filter "until=24h"
|
docker system prune -f --filter "until=24h"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/infra",
|
"name": "@mintel/infra",
|
||||||
"version": "1.9.5",
|
"version": "1.9.9",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/journaling",
|
"name": "@mintel/journaling",
|
||||||
"version": "1.9.5",
|
"version": "1.9.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/mail",
|
"name": "@mintel/mail",
|
||||||
"version": "1.9.5",
|
"version": "1.9.9",
|
||||||
"private": false,
|
"private": false,
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/meme-generator",
|
"name": "@mintel/meme-generator",
|
||||||
"version": "1.9.5",
|
"version": "1.9.9",
|
||||||
"private": false,
|
"private": false,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|||||||
25
packages/memory-mcp/package.json
Normal file
25
packages/memory-mcp/package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "@mintel/memory-mcp",
|
||||||
|
"version": "1.9.9",
|
||||||
|
"description": "Local Qdrant-based Memory MCP server",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"test:unit": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.5.0",
|
||||||
|
"@qdrant/js-client-rest": "^1.12.0",
|
||||||
|
"@xenova/transformers": "^2.17.2",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.5.3",
|
||||||
|
"@types/node": "^20.14.10",
|
||||||
|
"tsx": "^4.19.1",
|
||||||
|
"vitest": "^2.1.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/next-config",
|
"name": "@mintel/next-config",
|
||||||
"version": "1.9.5",
|
"version": "1.9.9",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/next-feedback",
|
"name": "@mintel/next-feedback",
|
||||||
"version": "1.9.5",
|
"version": "1.9.9",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/next-observability",
|
"name": "@mintel/next-observability",
|
||||||
"version": "1.9.5",
|
"version": "1.9.9",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/next-utils",
|
"name": "@mintel/next-utils",
|
||||||
"version": "1.9.5",
|
"version": "1.9.9",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/observability",
|
"name": "@mintel/observability",
|
||||||
"version": "1.9.5",
|
"version": "1.9.9",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/page-audit",
|
"name": "@mintel/page-audit",
|
||||||
"version": "1.9.5",
|
"version": "1.9.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "AI-powered website IST-analysis using DataForSEO and Gemini",
|
"description": "AI-powered website IST-analysis using DataForSEO and Gemini",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
7
packages/payload-ai/CHANGELOG.md
Normal file
7
packages/payload-ai/CHANGELOG.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# @mintel/payload-ai
|
||||||
|
|
||||||
|
## 1.1.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- Initial release of the @mintel/payload-ai package
|
||||||
46
packages/payload-ai/package.json
Normal file
46
packages/payload-ai/package.json
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "@mintel/payload-ai",
|
||||||
|
"version": "1.9.9",
|
||||||
|
"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/*",
|
||||||
|
"./tools/*": "./dist/tools/*"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
190
packages/payload-ai/src/actions/generateField.ts
Normal file
190
packages/payload-ai/src/actions/generateField.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
83
packages/payload-ai/src/actions/optimizePost.ts
Normal file
83
packages/payload-ai/src/actions/optimizePost.ts
Normal file
@@ -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.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
163
packages/payload-ai/src/components/AiMediaButtons.tsx
Normal file
163
packages/payload-ai/src/components/AiMediaButtons.tsx
Normal file
@@ -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<ActionState>({ loading: false });
|
||||||
|
const [recover, setRecover] = useState<ActionState>({ 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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: "10px",
|
||||||
|
marginBottom: "1.5rem",
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* AI Upscale */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={upscale.loading || recover.loading}
|
||||||
|
onClick={() => runAction("upscale")}
|
||||||
|
style={{
|
||||||
|
...buttonStyle,
|
||||||
|
...(upscale.loading || recover.loading ? disabledStyle : { cursor: "pointer" }),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{upscale.loading ? "⏳ AI Upscale läuft…" : "✨ AI Upscale"}
|
||||||
|
</button>
|
||||||
|
{upscale.resultId && (
|
||||||
|
<a
|
||||||
|
href={`/admin/collections/media/${upscale.resultId}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "var(--theme-elevation-500)",
|
||||||
|
textDecoration: "underline",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
→ Neues Bild öffnen (ID: {upscale.resultId})
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI Recover */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={upscale.loading || recover.loading}
|
||||||
|
onClick={() => runAction("recover")}
|
||||||
|
style={{
|
||||||
|
...buttonStyle,
|
||||||
|
...(upscale.loading || recover.loading ? disabledStyle : { cursor: "pointer" }),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{recover.loading ? "⏳ AI Recover läuft…" : "🔄 AI Recover"}
|
||||||
|
</button>
|
||||||
|
{recover.resultId && (
|
||||||
|
<a
|
||||||
|
href={`/admin/collections/media/${recover.resultId}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "var(--theme-elevation-500)",
|
||||||
|
textDecoration: "underline",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
→ Neues Bild öffnen (ID: {recover.resultId})
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: "var(--theme-elevation-500)",
|
||||||
|
margin: 0,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>AI Upscale</strong> verbessert die Auflösung via{" "}
|
||||||
|
<code>google/upscaler</code>. <strong>AI Recover</strong> restauriert
|
||||||
|
alte/beschädigte Fotos via{" "}
|
||||||
|
<code>microsoft/bringing-old-photos-back-to-life</code>. Das
|
||||||
|
Ergebnis wird als neues Medium gespeichert.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<string>({ 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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "8px",
|
||||||
|
marginBottom: "8px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={isGenerating}
|
||||||
|
style={{
|
||||||
|
background: "var(--theme-elevation-150)",
|
||||||
|
border: "1px solid var(--theme-elevation-200)",
|
||||||
|
color: "var(--theme-text)",
|
||||||
|
padding: "4px 12px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontSize: "12px",
|
||||||
|
cursor: isGenerating ? "not-allowed" : "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "6px",
|
||||||
|
opacity: isGenerating ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isGenerating ? "✨ AI arbeitet..." : "✨ AI Ausfüllen"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowInstructions(!showInstructions);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: "var(--theme-elevation-500)",
|
||||||
|
fontSize: "12px",
|
||||||
|
cursor: "pointer",
|
||||||
|
textDecoration: "underline",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showInstructions ? "Prompt verbergen" : "Mit Prompt..."}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showInstructions && (
|
||||||
|
<textarea
|
||||||
|
value={instructions}
|
||||||
|
onChange={(e) => setInstructions(e.target.value)}
|
||||||
|
placeholder="Eigene Anweisung an AI (z.B. 'als catchy slogan')"
|
||||||
|
disabled={isGenerating}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "6px 8px",
|
||||||
|
fontSize: "12px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
border: "1px solid var(--theme-elevation-200)",
|
||||||
|
background: "var(--theme-elevation-50)",
|
||||||
|
color: "var(--theme-text)",
|
||||||
|
}}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
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("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isGenerating) return;
|
||||||
|
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.returnValue =
|
||||||
|
"Slug-Generierung läuft noch. Wenn Sie neu laden, bricht der Vorgang ab!";
|
||||||
|
};
|
||||||
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||||
|
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||||
|
}, [isGenerating]);
|
||||||
|
|
||||||
|
const { fields, replaceState } = useForm();
|
||||||
|
const { value, initialValue, setValue } = useField({ path });
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
const title = (fields?.title?.value as string) || "";
|
||||||
|
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 {
|
||||||
|
const res = await generateSlugAction(
|
||||||
|
title,
|
||||||
|
draftContent,
|
||||||
|
initialValue as string,
|
||||||
|
instructions,
|
||||||
|
);
|
||||||
|
if (res.success && res.slug) {
|
||||||
|
setValue(res.slug);
|
||||||
|
} else {
|
||||||
|
alert("Fehler: " + res.error);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert("Unerwarteter Fehler.");
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 items-center mb-4">
|
||||||
|
<textarea
|
||||||
|
value={instructions}
|
||||||
|
onChange={(e) => setInstructions(e.target.value)}
|
||||||
|
placeholder="Optionale AI Anweisung für den Slug..."
|
||||||
|
disabled={isGenerating}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
minHeight: "40px",
|
||||||
|
padding: "8px 12px",
|
||||||
|
fontSize: "14px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
border: "1px solid var(--theme-elevation-200)",
|
||||||
|
background: "var(--theme-elevation-50)",
|
||||||
|
color: "var(--theme-text)",
|
||||||
|
marginBottom: "8px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={isGenerating}
|
||||||
|
className="btn btn--icon-style-none btn--size-medium ml-auto"
|
||||||
|
style={{
|
||||||
|
background: "var(--theme-elevation-150)",
|
||||||
|
border: "1px solid var(--theme-elevation-200)",
|
||||||
|
color: "var(--theme-text)",
|
||||||
|
boxShadow: "0 2px 4px rgba(0,0,0,0.05)",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
opacity: isGenerating ? 0.6 : 1,
|
||||||
|
cursor: isGenerating ? "not-allowed" : "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="btn__content">
|
||||||
|
{isGenerating ? "✨ Generiere (ca 10s)..." : "✨ AI Slug Generieren"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
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("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isGenerating) return;
|
||||||
|
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.returnValue =
|
||||||
|
"Bild-Generierung läuft noch (dies dauert bis zu 2 Minuten). Wenn Sie neu laden, bricht der Vorgang ab!";
|
||||||
|
};
|
||||||
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||||
|
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||||
|
}, [isGenerating]);
|
||||||
|
|
||||||
|
const { fields } = useForm();
|
||||||
|
const { value, setValue } = useField({ path });
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
const title = (fields?.title?.value as string) || "";
|
||||||
|
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 {
|
||||||
|
const res = await generateThumbnailAction(
|
||||||
|
draftContent,
|
||||||
|
title,
|
||||||
|
instructions,
|
||||||
|
);
|
||||||
|
if (res.success && res.mediaId) {
|
||||||
|
setValue(res.mediaId);
|
||||||
|
} else {
|
||||||
|
alert("Fehler: " + res.error);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert("Unerwarteter Fehler.");
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 items-center mt-2 mb-4">
|
||||||
|
<textarea
|
||||||
|
value={instructions}
|
||||||
|
onChange={(e) => setInstructions(e.target.value)}
|
||||||
|
placeholder="Optionale Thumbnail-Detailanweisung (Farben, Stimmung, etc.)..."
|
||||||
|
disabled={isGenerating}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
minHeight: "40px",
|
||||||
|
padding: "8px 12px",
|
||||||
|
fontSize: "14px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
border: "1px solid var(--theme-elevation-200)",
|
||||||
|
background: "var(--theme-elevation-50)",
|
||||||
|
color: "var(--theme-text)",
|
||||||
|
marginBottom: "8px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={isGenerating}
|
||||||
|
className="btn btn--icon-style-none btn--size-medium"
|
||||||
|
style={{
|
||||||
|
background: "var(--theme-elevation-150)",
|
||||||
|
border: "1px solid var(--theme-elevation-200)",
|
||||||
|
color: "var(--theme-text)",
|
||||||
|
boxShadow: "0 2px 4px rgba(0,0,0,0.05)",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
opacity: isGenerating ? 0.6 : 1,
|
||||||
|
cursor: isGenerating ? "not-allowed" : "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="btn__content">
|
||||||
|
{isGenerating
|
||||||
|
? "✨ AI arbeitet (dauert ca. 1-2 Min)..."
|
||||||
|
: "✨ AI Thumbnail Generieren"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
packages/payload-ai/src/components/OptimizeButton.tsx
Normal file
136
packages/payload-ai/src/components/OptimizeButton.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
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() {
|
||||||
|
const [isOptimizing, setIsOptimizing] = useState(false);
|
||||||
|
const [instructions, setInstructions] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOptimizing) return;
|
||||||
|
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.returnValue =
|
||||||
|
"Lexical Block-Optimierung läuft noch (dies dauert bis zu 45 Sekunden). Wenn Sie neu laden, bricht der Vorgang ab!";
|
||||||
|
};
|
||||||
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||||
|
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||||
|
}, [isOptimizing]);
|
||||||
|
|
||||||
|
const { fields, setModified, replaceState } = useForm();
|
||||||
|
const { title } = useDocumentInfo();
|
||||||
|
|
||||||
|
const handleOptimize = async () => {
|
||||||
|
// ... gathering draftContent logic
|
||||||
|
const lexicalValue = fields?.content?.value as any;
|
||||||
|
const legacyValue = fields?.legacyMdx?.value as string;
|
||||||
|
|
||||||
|
let draftContent = legacyValue || "";
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!draftContent && lexicalValue?.root) {
|
||||||
|
draftContent = extractText(lexicalValue.root);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!draftContent || draftContent.trim().length < 50) {
|
||||||
|
alert(
|
||||||
|
"Der Entwurf ist zu kurz. Bitte tippe zuerst ein paar Stichpunkte oder einen Rohling ein.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsOptimizing(true);
|
||||||
|
try {
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
if (response.success && response.lexicalAST) {
|
||||||
|
// 3. Inject the new Lexical AST directly into the field form state
|
||||||
|
// We use Payload's useForm hook replacing the value of the 'content' field.
|
||||||
|
|
||||||
|
replaceState({
|
||||||
|
...fields,
|
||||||
|
content: {
|
||||||
|
...fields.content,
|
||||||
|
value: response.lexicalAST,
|
||||||
|
initialValue: response.lexicalAST,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setModified(true);
|
||||||
|
alert(
|
||||||
|
"🎉 Artikel wurde erfolgreich von der AI optimiert und mit Lexical Components angereichert.",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
alert("❌ Fehler: " + response.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Optimization failed:", error);
|
||||||
|
alert("Ein unerwarteter Fehler ist aufgetreten.");
|
||||||
|
} finally {
|
||||||
|
setIsOptimizing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-8 p-4 bg-slate-50 border border-slate-200 rounded-md">
|
||||||
|
<h3 className="text-sm font-semibold mb-2">AI Post Optimizer</h3>
|
||||||
|
<p className="text-xs text-slate-500 mb-4">
|
||||||
|
Lass Mintel AI deinen Text-Rohentwurf analysieren und automatisch in
|
||||||
|
einen voll formatierten Lexical Artikel mit passenden B2B Komponenten
|
||||||
|
(MemeCards, Mermaids) umwandeln.
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
value={instructions}
|
||||||
|
onChange={(e) => setInstructions(e.target.value)}
|
||||||
|
placeholder="Optionale Anweisungen an die AI (z.B. 'schreibe etwas lockerer' oder 'fokussiere dich auf SEO')..."
|
||||||
|
disabled={isOptimizing}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
minHeight: "60px",
|
||||||
|
padding: "8px 12px",
|
||||||
|
fontSize: "14px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
border: "1px solid var(--theme-elevation-200)",
|
||||||
|
background: "var(--theme-elevation-50)",
|
||||||
|
color: "var(--theme-text)",
|
||||||
|
marginBottom: "16px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleOptimize}
|
||||||
|
disabled={isOptimizing}
|
||||||
|
className="btn btn--icon-style-none btn--size-medium mt-4"
|
||||||
|
style={{
|
||||||
|
background: "var(--theme-elevation-150)",
|
||||||
|
border: "1px solid var(--theme-elevation-200)",
|
||||||
|
color: "var(--theme-text)",
|
||||||
|
boxShadow: "0 2px 4px rgba(0,0,0,0.05)",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
opacity: isOptimizing ? 0.7 : 1,
|
||||||
|
cursor: isOptimizing ? "not-allowed" : "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="btn__content" style={{ fontWeight: 600 }}>
|
||||||
|
{isOptimizing ? "✨ AI arbeitet (ca 30s)..." : "✨ Jetzt optimieren"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
177
packages/payload-ai/src/endpoints/replicateMediaEndpoint.ts
Normal file
177
packages/payload-ai/src/endpoints/replicateMediaEndpoint.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import type { PayloadRequest, PayloadHandler } from "payload";
|
||||||
|
import Replicate from "replicate";
|
||||||
|
|
||||||
|
type Action = "upscale" | "recover";
|
||||||
|
|
||||||
|
const replicate = new Replicate({
|
||||||
|
auth: process.env.REPLICATE_API_KEY,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads a remote URL and returns a Buffer.
|
||||||
|
*/
|
||||||
|
async function downloadImage(url: string): Promise<Buffer> {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`Failed to download image: ${res.status} ${res.statusText}`);
|
||||||
|
const arrayBuffer = await res.arrayBuffer();
|
||||||
|
return Buffer.from(arrayBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the public URL for a media document.
|
||||||
|
* Handles both S3 and local static files.
|
||||||
|
*/
|
||||||
|
function resolveMediaUrl(doc: any): string | null {
|
||||||
|
// S3 storage sets `url` directly
|
||||||
|
if (doc.url) return doc.url;
|
||||||
|
|
||||||
|
// Local static files: build from NEXT_PUBLIC_BASE_URL + /media/<filename>
|
||||||
|
const base = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000";
|
||||||
|
if (doc.filename) return `${base}/media/${doc.filename}`;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const replicateMediaHandler: PayloadHandler = async (
|
||||||
|
req: PayloadRequest,
|
||||||
|
) => {
|
||||||
|
const { id } = req.routeParams as { id: string };
|
||||||
|
const payload = req.payload;
|
||||||
|
|
||||||
|
// Parse action from request body
|
||||||
|
let action: Action;
|
||||||
|
try {
|
||||||
|
const body = await req.json?.();
|
||||||
|
action = body?.action as Action;
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "Invalid request body" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action !== "upscale" && action !== "recover") {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Invalid action. Must be 'upscale' or 'recover'." },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the media document
|
||||||
|
let mediaDoc: any;
|
||||||
|
try {
|
||||||
|
mediaDoc = await payload.findByID({ collection: "media", id });
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "Media not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mediaDoc) {
|
||||||
|
return Response.json({ error: "Media not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that it's an image
|
||||||
|
const mimeType: string = mediaDoc.mimeType || "";
|
||||||
|
if (!mimeType.startsWith("image/")) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "This media file is not an image and cannot be AI-processed." },
|
||||||
|
{ status: 422 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageUrl = resolveMediaUrl(mediaDoc);
|
||||||
|
if (!imageUrl) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Could not resolve a public URL for this media file." },
|
||||||
|
{ status: 422 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Run Replicate ---
|
||||||
|
let outputUrl: string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (action === "upscale") {
|
||||||
|
console.log(`[AI Media] Starting upscale for media ${id} – ${imageUrl}`);
|
||||||
|
const output = await replicate.run("google/upscaler", {
|
||||||
|
input: {
|
||||||
|
image: imageUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// google/upscaler returns a string URL
|
||||||
|
outputUrl = typeof output === "string" ? output : (output as any)?.url ?? String(output);
|
||||||
|
} else {
|
||||||
|
// recover
|
||||||
|
console.log(`[AI Media] Starting photo recovery for media ${id} – ${imageUrl}`);
|
||||||
|
const output = await replicate.run(
|
||||||
|
"microsoft/bringing-old-photos-back-to-life",
|
||||||
|
{
|
||||||
|
input: {
|
||||||
|
image: imageUrl,
|
||||||
|
HR: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// returns a FileOutput or URL string
|
||||||
|
outputUrl = typeof output === "string" ? output : (output as any)?.url ?? String(output);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[AI Media] Replicate error:", err);
|
||||||
|
return Response.json(
|
||||||
|
{ error: err?.message ?? "Replicate API call failed" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Download and re-upload as new media document ---
|
||||||
|
let imageBuffer: Buffer;
|
||||||
|
try {
|
||||||
|
imageBuffer = await downloadImage(outputUrl);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[AI Media] Download error:", err);
|
||||||
|
return Response.json(
|
||||||
|
{ error: `Failed to download result: ${err?.message}` },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const suffix = action === "upscale" ? "_upscaled" : "_recovered";
|
||||||
|
const originalName: string = mediaDoc.filename || "image.jpg";
|
||||||
|
const ext = originalName.includes(".") ? `.${originalName.split(".").pop()}` : ".jpg";
|
||||||
|
const baseName = originalName.includes(".")
|
||||||
|
? originalName.slice(0, originalName.lastIndexOf("."))
|
||||||
|
: originalName;
|
||||||
|
const newFilename = `${baseName}${suffix}${ext}`;
|
||||||
|
const originalAlt: string = mediaDoc.alt || originalName;
|
||||||
|
|
||||||
|
let newMedia: any;
|
||||||
|
try {
|
||||||
|
newMedia = await payload.create({
|
||||||
|
collection: "media",
|
||||||
|
data: {
|
||||||
|
alt: `${originalAlt}${suffix}`,
|
||||||
|
},
|
||||||
|
file: {
|
||||||
|
data: imageBuffer,
|
||||||
|
mimetype: mimeType,
|
||||||
|
name: newFilename,
|
||||||
|
size: imageBuffer.byteLength,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[AI Media] Upload error:", err);
|
||||||
|
return Response.json(
|
||||||
|
{ error: `Failed to save result: ${err?.message}` },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[AI Media] ${action} complete – new media ID: ${newMedia.id}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
message: `AI ${action} successful. New media document created.`,
|
||||||
|
mediaId: newMedia.id,
|
||||||
|
url: resolveMediaUrl(newMedia),
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
};
|
||||||
30
packages/payload-ai/src/globals/AiSettings.ts
Normal file
30
packages/payload-ai/src/globals/AiSettings.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { GlobalConfig } from "payload";
|
||||||
|
export const AiSettings: GlobalConfig = {
|
||||||
|
slug: "ai-settings",
|
||||||
|
label: "AI Settings",
|
||||||
|
access: {
|
||||||
|
read: () => true, // Needed if the Next.js frontend or server actions need to fetch it
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
group: "Configuration",
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "customSources",
|
||||||
|
type: "array",
|
||||||
|
label: "Custom Trusted Sources",
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"List of trusted B2B/Tech sources (e.g. 'Vercel Blog', 'Fireship', 'Theo - t3.gg') the AI should prioritize when researching facts or videos. This overrides the hardcoded defaults.",
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "sourceName",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
label: "Channel or Publication Name",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
15
packages/payload-ai/src/index.ts
Normal file
15
packages/payload-ai/src/index.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* @mintel/payload-ai
|
||||||
|
* Primary entry point for reusing Mintel AI extensions in Payload CMS.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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';
|
||||||
|
export * from './components/FieldGenerators/GenerateThumbnailButton';
|
||||||
|
export * from './components/FieldGenerators/GenerateSlugButton';
|
||||||
|
export * from './utils/lexicalParser';
|
||||||
|
export * from './endpoints/replicateMediaEndpoint';
|
||||||
5
packages/payload-ai/src/types.d.ts
vendored
Normal file
5
packages/payload-ai/src/types.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
declare module "@payload-config" {
|
||||||
|
import { Config } from "payload";
|
||||||
|
const configPromise: Promise<Config>;
|
||||||
|
export default configPromise;
|
||||||
|
}
|
||||||
640
packages/payload-ai/src/utils/lexicalParser.ts
Normal file
640
packages/payload-ai/src/utils/lexicalParser.ts
Normal file
@@ -0,0 +1,640 @@
|
|||||||
|
/**
|
||||||
|
* Converts a Markdown+JSX string into a Lexical AST node array.
|
||||||
|
* Handles all registered Payload blocks and standard markdown formatting.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function propValue(chunk: string, prop: string): string {
|
||||||
|
// Match prop="value" or prop='value' or prop={value}
|
||||||
|
const match =
|
||||||
|
chunk.match(new RegExp(`${prop}=["']([^"']+)["']`)) ||
|
||||||
|
chunk.match(new RegExp(`${prop}=\\{([^}]+)\\}`));
|
||||||
|
return match ? match[1] : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function innerContent(chunk: string, tag: string): string {
|
||||||
|
const match = chunk.match(
|
||||||
|
new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${tag}>`),
|
||||||
|
);
|
||||||
|
return match ? match[1].trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockNode(blockType: string, fields: Record<string, any>) {
|
||||||
|
return {
|
||||||
|
type: "block",
|
||||||
|
format: "",
|
||||||
|
version: 2,
|
||||||
|
fields: { blockType, ...fields },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMarkdownToLexical(markdown: string): any[] {
|
||||||
|
const textNode = (text: string) => ({
|
||||||
|
type: "paragraph",
|
||||||
|
format: "",
|
||||||
|
indent: 0,
|
||||||
|
version: 1,
|
||||||
|
children: [{ mode: "normal", type: "text", text, version: 1 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const nodes: any[] = [];
|
||||||
|
|
||||||
|
// Strip frontmatter
|
||||||
|
let content = markdown;
|
||||||
|
const fm = content.match(/^---\s*\n[\s\S]*?\n---/);
|
||||||
|
if (fm) content = content.replace(fm[0], "").trim();
|
||||||
|
|
||||||
|
// Pre-process: reassemble multi-line JSX tags that got split by double-newline chunking.
|
||||||
|
// This handles tags like <IconList>\n\n<IconListItem ... />\n\n</IconList>
|
||||||
|
content = reassembleMultiLineJSX(content);
|
||||||
|
|
||||||
|
const rawChunks = content.split(/\n\s*\n/);
|
||||||
|
|
||||||
|
for (let chunk of rawChunks) {
|
||||||
|
chunk = chunk.trim();
|
||||||
|
if (!chunk) continue;
|
||||||
|
|
||||||
|
// === Self-closing tags (no children) ===
|
||||||
|
|
||||||
|
// ArticleMeme / MemeCard
|
||||||
|
if (chunk.includes("<ArticleMeme") || chunk.includes("<MemeCard")) {
|
||||||
|
nodes.push(
|
||||||
|
blockNode("memeCard", {
|
||||||
|
template: propValue(chunk, "template"),
|
||||||
|
captions: propValue(chunk, "captions"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BoldNumber
|
||||||
|
if (chunk.includes("<BoldNumber")) {
|
||||||
|
nodes.push(
|
||||||
|
blockNode("boldNumber", {
|
||||||
|
value: propValue(chunk, "value"),
|
||||||
|
label: propValue(chunk, "label"),
|
||||||
|
source: propValue(chunk, "source"),
|
||||||
|
sourceUrl: propValue(chunk, "sourceUrl"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebVitalsScore
|
||||||
|
if (chunk.includes("<WebVitalsScore")) {
|
||||||
|
nodes.push(
|
||||||
|
blockNode("webVitalsScore", {
|
||||||
|
lcp: parseFloat(propValue(chunk, "lcp")) || 0,
|
||||||
|
inp: parseFloat(propValue(chunk, "inp")) || 0,
|
||||||
|
cls: parseFloat(propValue(chunk, "cls")) || 0,
|
||||||
|
description: propValue(chunk, "description"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// LeadMagnet
|
||||||
|
if (chunk.includes("<LeadMagnet")) {
|
||||||
|
nodes.push(
|
||||||
|
blockNode("leadMagnet", {
|
||||||
|
title: propValue(chunk, "title"),
|
||||||
|
description: propValue(chunk, "description"),
|
||||||
|
buttonText: propValue(chunk, "buttonText") || "Jetzt anfragen",
|
||||||
|
href: propValue(chunk, "href") || "/contact",
|
||||||
|
variant: propValue(chunk, "variant") || "standard",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComparisonRow
|
||||||
|
if (chunk.includes("<ComparisonRow")) {
|
||||||
|
nodes.push(
|
||||||
|
blockNode("comparisonRow", {
|
||||||
|
description: propValue(chunk, "description"),
|
||||||
|
negativeLabel: propValue(chunk, "negativeLabel"),
|
||||||
|
negativeText: propValue(chunk, "negativeText"),
|
||||||
|
positiveLabel: propValue(chunk, "positiveLabel"),
|
||||||
|
positiveText: propValue(chunk, "positiveText"),
|
||||||
|
reverse: chunk.includes("reverse={true}"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatsDisplay
|
||||||
|
if (chunk.includes("<StatsDisplay")) {
|
||||||
|
nodes.push(
|
||||||
|
blockNode("statsDisplay", {
|
||||||
|
label: propValue(chunk, "label"),
|
||||||
|
value: propValue(chunk, "value"),
|
||||||
|
subtext: propValue(chunk, "subtext"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetricBar
|
||||||
|
if (chunk.includes("<MetricBar")) {
|
||||||
|
nodes.push(
|
||||||
|
blockNode("metricBar", {
|
||||||
|
label: propValue(chunk, "label"),
|
||||||
|
value: parseFloat(propValue(chunk, "value")) || 0,
|
||||||
|
max: parseFloat(propValue(chunk, "max")) || 100,
|
||||||
|
unit: propValue(chunk, "unit") || "%",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExternalLink
|
||||||
|
if (chunk.includes("<ExternalLink")) {
|
||||||
|
nodes.push(
|
||||||
|
blockNode("externalLink", {
|
||||||
|
href: propValue(chunk, "href"),
|
||||||
|
label:
|
||||||
|
propValue(chunk, "label") || innerContent(chunk, "ExternalLink"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrackedLink
|
||||||
|
if (chunk.includes("<TrackedLink")) {
|
||||||
|
nodes.push(
|
||||||
|
blockNode("trackedLink", {
|
||||||
|
href: propValue(chunk, "href"),
|
||||||
|
label:
|
||||||
|
propValue(chunk, "label") || innerContent(chunk, "TrackedLink"),
|
||||||
|
eventName: propValue(chunk, "eventName"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// YouTube
|
||||||
|
if (chunk.includes("<YouTubeEmbed")) {
|
||||||
|
nodes.push(
|
||||||
|
blockNode("youTubeEmbed", {
|
||||||
|
videoId: propValue(chunk, "videoId"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinkedIn
|
||||||
|
if (chunk.includes("<LinkedInEmbed")) {
|
||||||
|
nodes.push(
|
||||||
|
blockNode("linkedInEmbed", {
|
||||||
|
url: propValue(chunk, "url"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Twitter
|
||||||
|
if (chunk.includes("<TwitterEmbed")) {
|
||||||
|
nodes.push(
|
||||||
|
blockNode("twitterEmbed", {
|
||||||
|
url: propValue(chunk, "url"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interactive (self-closing, defaults only)
|
||||||
|
if (chunk.includes("<RevenueLossCalculator")) {
|
||||||
|
nodes.push(
|
||||||
|
blockNode("revenueLossCalculator", {
|
||||||
|
title: propValue(chunk, "title") || "Performance Revenue Simulator",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (chunk.includes("<PerformanceChart")) {
|
||||||
|
nodes.push(
|
||||||
|
blockNode("performanceChart", {
|
||||||
|
title: propValue(chunk, "title") || "Website Performance",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (chunk.includes("<PerformanceROICalculator")) {
|
||||||
|
nodes.push(
|
||||||
|
blockNode("performanceROICalculator", {
|
||||||
|
baseConversionRate:
|
||||||
|
parseFloat(propValue(chunk, "baseConversionRate")) || 2.5,
|
||||||
|
monthlyVisitors:
|
||||||
|
parseInt(propValue(chunk, "monthlyVisitors")) || 50000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (chunk.includes("<LoadTimeSimulator")) {
|
||||||
|
nodes.push(
|
||||||
|
blockNode("loadTimeSimulator", {
|
||||||
|
initialLoadTime:
|
||||||
|
parseFloat(propValue(chunk, "initialLoadTime")) || 3.5,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (chunk.includes("<ArchitectureBuilder")) {
|
||||||
|
nodes.push(
|
||||||
|
blockNode("architectureBuilder", {
|
||||||
|
preset: propValue(chunk, "preset") || "standard",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (chunk.includes("<DigitalAssetVisualizer")) {
|
||||||
|
nodes.push(
|
||||||
|
blockNode("digitalAssetVisualizer", {
|
||||||
|
assetId: propValue(chunk, "assetId"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Tags with inner content ===
|
||||||
|
|
||||||
|
// TLDR
|
||||||
|
if (chunk.includes("<TLDR>")) {
|
||||||
|
const inner = innerContent(chunk, "TLDR");
|
||||||
|
if (inner) {
|
||||||
|
nodes.push(blockNode("mintelTldr", { content: inner }));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paragraph (handles <Paragraph>, <Paragraph ...attrs>)
|
||||||
|
if (/<Paragraph[\s>]/.test(chunk)) {
|
||||||
|
const inner = innerContent(chunk, "Paragraph");
|
||||||
|
if (inner) {
|
||||||
|
nodes.push(blockNode("mintelP", { text: inner }));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// H2 (handles <H2>, <H2 id="...">)
|
||||||
|
if (/<H2[\s>]/.test(chunk)) {
|
||||||
|
const inner = innerContent(chunk, "H2");
|
||||||
|
if (inner) {
|
||||||
|
nodes.push(
|
||||||
|
blockNode("mintelHeading", {
|
||||||
|
text: inner,
|
||||||
|
seoLevel: "h2",
|
||||||
|
displayLevel: "h2",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// H3 (handles <H3>, <H3 id="...">)
|
||||||
|
if (/<H3[\s>]/.test(chunk)) {
|
||||||
|
const inner = innerContent(chunk, "H3");
|
||||||
|
if (inner) {
|
||||||
|
nodes.push(
|
||||||
|
blockNode("mintelHeading", {
|
||||||
|
text: inner,
|
||||||
|
seoLevel: "h3",
|
||||||
|
displayLevel: "h3",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marker (inline highlight, usually inside Paragraph – store as standalone block)
|
||||||
|
if (chunk.includes("<Marker>") && !chunk.includes("<Paragraph")) {
|
||||||
|
const inner = innerContent(chunk, "Marker");
|
||||||
|
if (inner) {
|
||||||
|
nodes.push(blockNode("marker", { text: inner }));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LeadParagraph
|
||||||
|
if (chunk.includes("<LeadParagraph>")) {
|
||||||
|
const inner = innerContent(chunk, "LeadParagraph");
|
||||||
|
if (inner) {
|
||||||
|
nodes.push(blockNode("leadParagraph", { text: inner }));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArticleBlockquote
|
||||||
|
if (chunk.includes("<ArticleBlockquote")) {
|
||||||
|
nodes.push(
|
||||||
|
blockNode("articleBlockquote", {
|
||||||
|
quote: innerContent(chunk, "ArticleBlockquote"),
|
||||||
|
author: propValue(chunk, "author"),
|
||||||
|
role: propValue(chunk, "role"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArticleQuote
|
||||||
|
if (chunk.includes("<ArticleQuote")) {
|
||||||
|
nodes.push(
|
||||||
|
blockNode("articleQuote", {
|
||||||
|
quote:
|
||||||
|
innerContent(chunk, "ArticleQuote") || propValue(chunk, "quote"),
|
||||||
|
author: propValue(chunk, "author"),
|
||||||
|
role: propValue(chunk, "role"),
|
||||||
|
source: propValue(chunk, "source"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mermaid
|
||||||
|
if (chunk.includes("<Mermaid")) {
|
||||||
|
nodes.push(
|
||||||
|
blockNode("mermaid", {
|
||||||
|
id: propValue(chunk, "id") || `chart-${Date.now()}`,
|
||||||
|
title: propValue(chunk, "title"),
|
||||||
|
showShare: chunk.includes("showShare={true}"),
|
||||||
|
chartDefinition: innerContent(chunk, "Mermaid"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diagram variants (prefer inner definition, fall back to raw chunk text)
|
||||||
|
if (chunk.includes("<DiagramFlow")) {
|
||||||
|
nodes.push(
|
||||||
|
blockNode("diagramFlow", {
|
||||||
|
definition:
|
||||||
|
innerContent(chunk, "DiagramFlow") ||
|
||||||
|
propValue(chunk, "definition") ||
|
||||||
|
chunk,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (chunk.includes("<DiagramSequence")) {
|
||||||
|
nodes.push(
|
||||||
|
blockNode("diagramSequence", {
|
||||||
|
definition:
|
||||||
|
innerContent(chunk, "DiagramSequence") ||
|
||||||
|
propValue(chunk, "definition") ||
|
||||||
|
chunk,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (chunk.includes("<DiagramGantt")) {
|
||||||
|
nodes.push(
|
||||||
|
blockNode("diagramGantt", {
|
||||||
|
definition:
|
||||||
|
innerContent(chunk, "DiagramGantt") ||
|
||||||
|
propValue(chunk, "definition") ||
|
||||||
|
chunk,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (chunk.includes("<DiagramPie")) {
|
||||||
|
nodes.push(
|
||||||
|
blockNode("diagramPie", {
|
||||||
|
definition:
|
||||||
|
innerContent(chunk, "DiagramPie") ||
|
||||||
|
propValue(chunk, "definition") ||
|
||||||
|
chunk,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (chunk.includes("<DiagramState")) {
|
||||||
|
nodes.push(
|
||||||
|
blockNode("diagramState", {
|
||||||
|
definition:
|
||||||
|
innerContent(chunk, "DiagramState") ||
|
||||||
|
propValue(chunk, "definition") ||
|
||||||
|
chunk,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (chunk.includes("<DiagramTimeline")) {
|
||||||
|
nodes.push(
|
||||||
|
blockNode("diagramTimeline", {
|
||||||
|
definition:
|
||||||
|
innerContent(chunk, "DiagramTimeline") ||
|
||||||
|
propValue(chunk, "definition") ||
|
||||||
|
chunk,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section (wrapping container – unwrap and parse inner content as top-level blocks)
|
||||||
|
if (chunk.includes("<Section")) {
|
||||||
|
const inner = innerContent(chunk, "Section");
|
||||||
|
if (inner) {
|
||||||
|
const innerNodes = parseMarkdownToLexical(inner);
|
||||||
|
nodes.push(...innerNodes);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FAQSection (wrapping container)
|
||||||
|
if (chunk.includes("<FAQSection")) {
|
||||||
|
// FAQSection contains nested H3/Paragraph pairs.
|
||||||
|
// We extract them as individual blocks instead.
|
||||||
|
const faqContent = innerContent(chunk, "FAQSection");
|
||||||
|
if (faqContent) {
|
||||||
|
// Parse nested content recursively
|
||||||
|
const innerNodes = parseMarkdownToLexical(faqContent);
|
||||||
|
nodes.push(...innerNodes);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// IconList with IconListItems
|
||||||
|
if (chunk.includes("<IconList")) {
|
||||||
|
const items: any[] = [];
|
||||||
|
// Self-closing: <IconListItem icon="Check" title="..." description="..." />
|
||||||
|
const itemMatches = chunk.matchAll(/<IconListItem\s+([^>]*?)\/>/g);
|
||||||
|
for (const m of itemMatches) {
|
||||||
|
const attrs = m[1];
|
||||||
|
const title = (attrs.match(/title=["']([^"']+)["']/) || [])[1] || "";
|
||||||
|
const desc =
|
||||||
|
(attrs.match(/description=["']([^"']+)["']/) || [])[1] || "";
|
||||||
|
items.push({
|
||||||
|
icon: (attrs.match(/icon=["']([^"']+)["']/) || [])[1] || "Check",
|
||||||
|
title: title || "•",
|
||||||
|
description: desc,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Content-wrapped: <IconListItem check>HTML content</IconListItem>
|
||||||
|
const itemMatches2 = chunk.matchAll(
|
||||||
|
/<IconListItem([^>]*)>([\s\S]*?)<\/IconListItem>/g,
|
||||||
|
);
|
||||||
|
for (const m of itemMatches2) {
|
||||||
|
const attrs = m[1] || "";
|
||||||
|
const innerHtml = m[2].trim();
|
||||||
|
// Use title attr if present, otherwise use inner HTML (stripped of tags) as title
|
||||||
|
const titleAttr = (attrs.match(/title=["']([^"']+)["']/) || [])[1];
|
||||||
|
const strippedInner = innerHtml.replace(/<[^>]+>/g, "").trim();
|
||||||
|
items.push({
|
||||||
|
icon: (attrs.match(/icon=["']([^"']+)["']/) || [])[1] || "Check",
|
||||||
|
title: titleAttr || strippedInner || "•",
|
||||||
|
description: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (items.length > 0) {
|
||||||
|
nodes.push(blockNode("iconList", { items }));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatsGrid
|
||||||
|
if (chunk.includes("<StatsGrid")) {
|
||||||
|
const stats: any[] = [];
|
||||||
|
const statMatches = chunk.matchAll(/<StatItem\s+([^>]*?)\/>/g);
|
||||||
|
for (const m of statMatches) {
|
||||||
|
const attrs = m[1];
|
||||||
|
stats.push({
|
||||||
|
label: (attrs.match(/label=["']([^"']+)["']/) || [])[1] || "",
|
||||||
|
value: (attrs.match(/value=["']([^"']+)["']/) || [])[1] || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Also try inline props pattern
|
||||||
|
if (stats.length === 0) {
|
||||||
|
const innerStats = innerContent(chunk, "StatsGrid");
|
||||||
|
if (innerStats) {
|
||||||
|
// fallback: store the raw content
|
||||||
|
nodes.push(blockNode("statsGrid", { stats: [] }));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nodes.push(blockNode("statsGrid", { stats }));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PremiumComparisonChart
|
||||||
|
if (chunk.includes("<PremiumComparisonChart")) {
|
||||||
|
nodes.push(
|
||||||
|
blockNode("premiumComparisonChart", {
|
||||||
|
title: propValue(chunk, "title"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaterfallChart
|
||||||
|
if (chunk.includes("<WaterfallChart")) {
|
||||||
|
nodes.push(
|
||||||
|
blockNode("waterfallChart", {
|
||||||
|
title: propValue(chunk, "title"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reveal (animation wrapper – just pass through)
|
||||||
|
if (chunk.includes("<Reveal")) {
|
||||||
|
const inner = innerContent(chunk, "Reveal");
|
||||||
|
if (inner) {
|
||||||
|
// Parse inner content as regular nodes
|
||||||
|
const innerNodes = parseMarkdownToLexical(inner);
|
||||||
|
nodes.push(...innerNodes);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standalone IconListItem (outside IconList context)
|
||||||
|
if (chunk.includes("<IconListItem")) {
|
||||||
|
// Skip – these should be inside an IconList
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip wrapper divs (like <div className="my-8">)
|
||||||
|
if (/^<div\s/.test(chunk) || chunk === "</div>") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Standard Markdown ===
|
||||||
|
// CarouselBlock
|
||||||
|
if (chunk.includes("<Carousel")) {
|
||||||
|
const slides: any[] = [];
|
||||||
|
const slideMatches = chunk.matchAll(/<Slide\s+([^>]*?)\/>/g);
|
||||||
|
for (const m of slideMatches) {
|
||||||
|
const attrs = m[1];
|
||||||
|
slides.push({
|
||||||
|
image: (attrs.match(/image=["']([^"']+)["']/) || [])[1] || "",
|
||||||
|
caption: (attrs.match(/caption=["']([^"']+)["']/) || [])[1] || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (slides.length > 0) {
|
||||||
|
nodes.push(blockNode("carousel", { slides }));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Headings
|
||||||
|
const headingMatch = chunk.match(/^(#{1,6})\s+(.*)/);
|
||||||
|
if (headingMatch) {
|
||||||
|
nodes.push({
|
||||||
|
type: "heading",
|
||||||
|
tag: `h${headingMatch[1].length}`,
|
||||||
|
format: "",
|
||||||
|
indent: 0,
|
||||||
|
version: 1,
|
||||||
|
direction: "ltr",
|
||||||
|
children: [
|
||||||
|
{ mode: "normal", type: "text", text: headingMatch[2], version: 1 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: plain text paragraph
|
||||||
|
nodes.push(textNode(chunk));
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reassembles multi-line JSX tags that span across double-newline boundaries.
|
||||||
|
* E.g. <IconList>\n\n<IconListItem.../>\n\n</IconList> becomes a single chunk.
|
||||||
|
*/
|
||||||
|
function reassembleMultiLineJSX(content: string): string {
|
||||||
|
// Tags that wrap other content across paragraph breaks
|
||||||
|
const wrapperTags = [
|
||||||
|
"IconList",
|
||||||
|
"StatsGrid",
|
||||||
|
"FAQSection",
|
||||||
|
"Section",
|
||||||
|
"Reveal",
|
||||||
|
"Carousel",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const tag of wrapperTags) {
|
||||||
|
const openRegex = new RegExp(`<${tag}[^>]*>`, "g");
|
||||||
|
let match;
|
||||||
|
while ((match = openRegex.exec(content)) !== null) {
|
||||||
|
const openPos = match.index;
|
||||||
|
const closeTag = `</${tag}>`;
|
||||||
|
const closePos = content.indexOf(closeTag, openPos);
|
||||||
|
if (closePos === -1) continue;
|
||||||
|
|
||||||
|
const fullEnd = closePos + closeTag.length;
|
||||||
|
const fullBlock = content.substring(openPos, fullEnd);
|
||||||
|
|
||||||
|
// Replace double newlines inside this block with single newlines
|
||||||
|
// so it stays as one chunk during splitting
|
||||||
|
const collapsed = fullBlock.replace(/\n\s*\n/g, "\n");
|
||||||
|
content =
|
||||||
|
content.substring(0, openPos) + collapsed + content.substring(fullEnd);
|
||||||
|
|
||||||
|
// Adjust regex position
|
||||||
|
openRegex.lastIndex = openPos + collapsed.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
30
packages/payload-ai/tsconfig.json
Normal file
30
packages/payload-ai/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"declaration": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"**/*.test.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
48
packages/payload-chat/package.json
Normal file
48
packages/payload-chat/package.json
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"name": "@mintel/payload-chat",
|
||||||
|
"version": "1.9.9",
|
||||||
|
"private": true,
|
||||||
|
"description": "Payload CMS Plugin for MCP AI Chat with custom permissions",
|
||||||
|
"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/*",
|
||||||
|
"./endpoints/*": "./dist/endpoints/*",
|
||||||
|
"./tools/*": "./dist/tools/*",
|
||||||
|
"./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": {
|
||||||
|
"@ai-sdk/openai": "^3.0.39",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.6.0",
|
||||||
|
"@qdrant/js-client-rest": "^1.17.0",
|
||||||
|
"ai": "^4.1.41",
|
||||||
|
"lucide-react": "^0.475.0",
|
||||||
|
"zod": "^3.25.76"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@payloadcms/next": "3.77.0",
|
||||||
|
"@payloadcms/ui": "3.77.0",
|
||||||
|
"@types/node": "^20.17.17",
|
||||||
|
"@types/react": "^19.2.8",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"next": "^15.1.0",
|
||||||
|
"payload": "3.77.0",
|
||||||
|
"react": "^19.2.3",
|
||||||
|
"react-dom": "^19.2.3",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
49
packages/payload-mcp-chat/README.md
Normal file
49
packages/payload-mcp-chat/README.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# @mintel/payload-mcp-chat
|
||||||
|
|
||||||
|
A powerful, native AI Chat plugin for Payload CMS v3 with fine-grained Model Context Protocol (MCP) tool execution permissions.
|
||||||
|
|
||||||
|
Unlike generic MCP plugins, this package builds the core tool adapter *inside* Payload via the Local API. This allows Administrators to explicitly dictate exactly which tools, collections, and external MCP servers specific Users or Roles can access.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Floating AI Chat Pane:** Exists universally across the Payload Admin Panel.
|
||||||
|
- **Native Local API Tools:** AI automatically gets tools to read/create/update documents.
|
||||||
|
- **Strict Role-Based AI Permissions:** A custom `AIChatPermissions` collection controls what the AI is allowed to execute on behalf of the current logged-in user.
|
||||||
|
- **Flexible External MCP Support:** Connect standard external MCP servers (via HTTP or STDIO) and seamlessly make their tools available to the Chat window, all wrapped within the permission engine.
|
||||||
|
- **Vercel AI SDK Integration:** Powered by the robust `ai` package using reliable streaming protocols.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm add @mintel/payload-mcp-chat @modelcontextprotocol/sdk ai
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Wrap your payload config with the plugin:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// payload.config.ts
|
||||||
|
import { buildConfig } from 'payload'
|
||||||
|
import { payloadMcpChatPlugin } from '@mintel/payload-mcp-chat'
|
||||||
|
|
||||||
|
export default buildConfig({
|
||||||
|
// ... your config
|
||||||
|
plugins: [
|
||||||
|
payloadMcpChatPlugin({
|
||||||
|
enabled: true,
|
||||||
|
// optional setup config here
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Permissions Model
|
||||||
|
|
||||||
|
The plugin automatically registers a Global (or Collection depending on setup) called **AI Chat Permissions**.
|
||||||
|
Here, an Admin can:
|
||||||
|
1. Select a `User` or define a `Role`.
|
||||||
|
2. Select which Payload Collections they are allowed to manage via AI.
|
||||||
|
3. Select which registered external MCP Servers they are allowed to use.
|
||||||
|
|
||||||
|
If a user asks the AI to update a user's password, and the `users` collection is not checked in their AI Chat Permission config, the AI will not even receive the tool to perform the action. If it hallucinates the tool, the backend will strictly block it.
|
||||||
48
packages/payload-mcp-chat/package.json
Normal file
48
packages/payload-mcp-chat/package.json
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"name": "@mintel/payload-mcp-chat",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Payload CMS Plugin for MCP AI Chat with custom permissions",
|
||||||
|
"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/*",
|
||||||
|
"./endpoints/*": "./dist/endpoints/*",
|
||||||
|
"./tools/*": "./dist/tools/*",
|
||||||
|
"./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": {
|
||||||
|
"@ai-sdk/openai": "^3.0.39",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.6.0",
|
||||||
|
"@qdrant/js-client-rest": "^1.17.0",
|
||||||
|
"ai": "^4.1.41",
|
||||||
|
"lucide-react": "^0.475.0",
|
||||||
|
"zod": "^3.25.76"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@payloadcms/next": "3.77.0",
|
||||||
|
"@payloadcms/ui": "3.77.0",
|
||||||
|
"@types/node": "^20.17.17",
|
||||||
|
"@types/react": "^19.2.8",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"next": "^15.1.0",
|
||||||
|
"payload": "3.77.0",
|
||||||
|
"react": "^19.2.3",
|
||||||
|
"react-dom": "^19.2.3",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { CollectionConfig } from 'payload'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A central collection to manage which AI Tools/MCPs a User or Role is allowed to use.
|
||||||
|
*/
|
||||||
|
export const AIChatPermissionsCollection: CollectionConfig = {
|
||||||
|
slug: 'ai-chat-permissions',
|
||||||
|
labels: {
|
||||||
|
singular: 'AI Chat Permission',
|
||||||
|
plural: 'AI Chat Permissions',
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
useAsTitle: 'description',
|
||||||
|
group: 'AI & Tools',
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'description',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
admin: {
|
||||||
|
description: 'E.g. "Editors default AI permissions"',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'row',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'targetUser',
|
||||||
|
type: 'relationship',
|
||||||
|
relationTo: 'users',
|
||||||
|
hasMany: false,
|
||||||
|
admin: {
|
||||||
|
description: 'Apply these permissions to a specific user (optional).',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'targetRole',
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
{ label: 'Admin', value: 'admin' },
|
||||||
|
{ label: 'Editor', value: 'editor' },
|
||||||
|
], // Ideally this is dynamically populated in a real scenario, but we hardcode standard roles for now
|
||||||
|
admin: {
|
||||||
|
description: 'Apply these permissions to all users with this role.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'allowedCollections',
|
||||||
|
type: 'select',
|
||||||
|
hasMany: true,
|
||||||
|
options: [], // Will be populated dynamically in the plugin init based on actual collections
|
||||||
|
admin: {
|
||||||
|
description: 'Which Payload collections is the AI allowed to read/write on behalf of this user?',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'allowedMcpServers',
|
||||||
|
type: 'select',
|
||||||
|
hasMany: true,
|
||||||
|
options: [], // Will be populated dynamically based on plugin config
|
||||||
|
admin: {
|
||||||
|
description: 'Which external MCP Servers is the AI allowed to execute tools from?',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
106
packages/payload-mcp-chat/src/components/ChatWindow/index.tsx
Normal file
106
packages/payload-mcp-chat/src/components/ChatWindow/index.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useChat } from 'ai/react'
|
||||||
|
import './ChatWindow.scss'
|
||||||
|
|
||||||
|
export const ChatWindowProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{children}
|
||||||
|
<ChatWindow />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChatWindow: React.FC = () => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const { messages, input, handleInputChange, handleSubmit, setMessages } = useChat({
|
||||||
|
api: '/api/mcp-chat',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Basic implementation to toggle chat window and submit messages
|
||||||
|
return (
|
||||||
|
<div className="payload-mcp-chat-container">
|
||||||
|
<button
|
||||||
|
className="payload-mcp-chat-toggle"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: '20px',
|
||||||
|
right: '20px',
|
||||||
|
zIndex: 9999,
|
||||||
|
padding: '12px 24px',
|
||||||
|
backgroundColor: '#000',
|
||||||
|
color: '#fff',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isOpen ? 'Close AI Chat' : 'Ask AI'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
className="payload-mcp-chat-window"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: '80px',
|
||||||
|
right: '20px',
|
||||||
|
width: '400px',
|
||||||
|
height: '600px',
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
border: '1px solid #eaeaea',
|
||||||
|
borderRadius: '12px',
|
||||||
|
zIndex: 9999,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
boxShadow: '0 10px 40px rgba(0,0,0,0.1)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="chat-header" style={{ padding: '16px', borderBottom: '1px solid #eaeaea', backgroundColor: '#f9f9f9', borderTopLeftRadius: '12px', borderTopRightRadius: '12px' }}>
|
||||||
|
<h3 style={{ margin: 0, fontSize: '16px' }}>Payload MCP Chat</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chat-messages" style={{ flex: 1, padding: '16px', overflowY: 'auto' }}>
|
||||||
|
{messages.map(m => (
|
||||||
|
<div key={m.id} style={{
|
||||||
|
marginBottom: '12px',
|
||||||
|
textAlign: m.role === 'user' ? 'right' : 'left'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
backgroundColor: m.role === 'user' ? '#000' : '#f0f0f0',
|
||||||
|
color: m.role === 'user' ? '#fff' : '#000',
|
||||||
|
maxWidth: '80%'
|
||||||
|
}}>
|
||||||
|
{m.role === 'user' ? 'G: ' : 'AI: '}
|
||||||
|
{m.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} style={{ padding: '16px', borderTop: '1px solid #eaeaea' }}>
|
||||||
|
<input
|
||||||
|
value={input}
|
||||||
|
placeholder="Ask me anything or use /commands..."
|
||||||
|
onChange={handleInputChange}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid #eaeaea',
|
||||||
|
boxSizing: 'border-box'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
75
packages/payload-mcp-chat/src/endpoints/chatEndpoint.ts
Normal file
75
packages/payload-mcp-chat/src/endpoints/chatEndpoint.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { streamText } from 'ai'
|
||||||
|
import { createOpenAI } from '@ai-sdk/openai'
|
||||||
|
import { generatePayloadLocalTools } from '../tools/payloadLocal.js'
|
||||||
|
import { createMcpTools } from '../tools/mcpAdapter.js'
|
||||||
|
import { generateMemoryTools } from '../tools/memoryDb.js'
|
||||||
|
import type { PayloadRequest } from 'payload'
|
||||||
|
|
||||||
|
const openrouter = createOpenAI({
|
||||||
|
baseURL: 'https://openrouter.ai/api/v1',
|
||||||
|
apiKey: process.env.OPENROUTER_API_KEY || 'dummy_key',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const handleMcpChat = async (req: PayloadRequest) => {
|
||||||
|
if (!req.user) {
|
||||||
|
return Response.json({ error: 'Unauthorized. You must be logged in to use AI Chat.' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { messages } = await req.json()
|
||||||
|
|
||||||
|
// 1. Check AI Permissions for req.user
|
||||||
|
// In a real implementation this looks up the global or collection for permissions
|
||||||
|
const allowedCollections = ['users'] // Stub
|
||||||
|
let activeTools: Record<string, any> = {}
|
||||||
|
|
||||||
|
// 2. Generate Payload Local Tools
|
||||||
|
if (allowedCollections.length > 0) {
|
||||||
|
const payloadTools = generatePayloadLocalTools(req.payload, req, allowedCollections)
|
||||||
|
activeTools = { ...activeTools, ...payloadTools }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Connect External MCPs
|
||||||
|
const allowedMcpServers: string[] = [] // Stub
|
||||||
|
if (allowedMcpServers.includes('gitea')) {
|
||||||
|
try {
|
||||||
|
const { tools: giteaTools } = await createMcpTools({
|
||||||
|
name: 'gitea',
|
||||||
|
command: 'npx',
|
||||||
|
args: ['-y', '@modelcontextprotocol/server-gitea', '--url', 'https://git.mintel.int', '--token', process.env.GITEA_TOKEN || '']
|
||||||
|
})
|
||||||
|
activeTools = { ...activeTools, ...giteaTools }
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to connect to Gitea MCP', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Inject Memory Database Tools
|
||||||
|
// We provide the user ID so memory is partitioned per user
|
||||||
|
const memoryTools = generateMemoryTools(req.user.id)
|
||||||
|
activeTools = { ...activeTools, ...memoryTools }
|
||||||
|
|
||||||
|
// 5. Build prompt to ensure it asks before saving
|
||||||
|
const memorySystemPrompt = `
|
||||||
|
You have access to a long-term vector memory database (Qdrant).
|
||||||
|
If the user says "speicher das", "merk dir das", "vergiss das nicht" etc., you MUST use the save_memory tool.
|
||||||
|
If the user shares important context but doesn't explicitly ask you to remember it, you should ask "Soll ich mir das für die Zukunft merken?" before saving it. Do not ask for trivial things.
|
||||||
|
`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = streamText({
|
||||||
|
// @ts-ignore - AI SDK type mismatch
|
||||||
|
model: openrouter('google/gemini-3.0-flash'),
|
||||||
|
messages,
|
||||||
|
tools: activeTools,
|
||||||
|
system: `You are a helpful Payload CMS MCP Assistant orchestrating the local Mintel ecosystem.
|
||||||
|
You only have access to tools explicitly granted by the Admin.
|
||||||
|
You cannot do anything outside these tools. Always explain what you are doing.
|
||||||
|
${memorySystemPrompt}`
|
||||||
|
})
|
||||||
|
|
||||||
|
return result.toDataStreamResponse()
|
||||||
|
} catch (error) {
|
||||||
|
console.error("AI Error:", error)
|
||||||
|
return Response.json({ error: 'Failed to process AI request' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
2
packages/payload-mcp-chat/src/index.ts
Normal file
2
packages/payload-mcp-chat/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { payloadMcpChatPlugin } from './plugin.js'
|
||||||
|
export type { PayloadMcpChatPluginConfig } from './types.js'
|
||||||
68
packages/payload-mcp-chat/src/plugin.ts
Normal file
68
packages/payload-mcp-chat/src/plugin.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import type { Config, Plugin } from 'payload'
|
||||||
|
import { AIChatPermissionsCollection } from './collections/AIChatPermissions.js'
|
||||||
|
import type { PayloadMcpChatPluginConfig } from './types.js'
|
||||||
|
|
||||||
|
export const payloadMcpChatPlugin =
|
||||||
|
(pluginOptions: PayloadMcpChatPluginConfig): Plugin =>
|
||||||
|
(incomingConfig) => {
|
||||||
|
let config = { ...incomingConfig }
|
||||||
|
|
||||||
|
// If disabled, return config untouched
|
||||||
|
if (pluginOptions.enabled === false) {
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Inject the Permissions Collection into the Schema
|
||||||
|
const existingCollections = config.collections || []
|
||||||
|
|
||||||
|
const mcpServers = pluginOptions.mcpServers || []
|
||||||
|
|
||||||
|
// Dynamically populate the select options for Collections and MCP Servers
|
||||||
|
const permissionCollection = { ...AIChatPermissionsCollection }
|
||||||
|
const collectionField = permissionCollection.fields.find(f => 'name' in f && f.name === 'allowedCollections') as any
|
||||||
|
if (collectionField) {
|
||||||
|
collectionField.options = existingCollections.map(c => ({
|
||||||
|
label: c.labels?.singular || c.slug,
|
||||||
|
value: c.slug
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const mcpField = permissionCollection.fields.find(f => 'name' in f && f.name === 'allowedMcpServers') as any
|
||||||
|
if (mcpField) {
|
||||||
|
mcpField.options = mcpServers.map(s => ({
|
||||||
|
label: s.name,
|
||||||
|
value: s.name
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
config.collections = [...existingCollections, permissionCollection]
|
||||||
|
|
||||||
|
// 2. Register Custom API Endpoint for the AI Chat
|
||||||
|
config.endpoints = [
|
||||||
|
...(config.endpoints || []),
|
||||||
|
{
|
||||||
|
path: '/api/mcp-chat',
|
||||||
|
method: 'post',
|
||||||
|
handler: async (req) => {
|
||||||
|
// Fallback simple handler while developing endpoint logic
|
||||||
|
return Response.json({ message: "Chat endpoint active" })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// 3. Inject Chat React Component into Admin UI
|
||||||
|
if (pluginOptions.renderChatBubble !== false) {
|
||||||
|
config.admin = {
|
||||||
|
...(config.admin || {}),
|
||||||
|
components: {
|
||||||
|
...(config.admin?.components || {}),
|
||||||
|
providers: [
|
||||||
|
...(config.admin?.components?.providers || []),
|
||||||
|
'@mintel/payload-mcp-chat/components/ChatWindow#ChatWindowProvider',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
||||||
64
packages/payload-mcp-chat/src/tools/mcpAdapter.ts
Normal file
64
packages/payload-mcp-chat/src/tools/mcpAdapter.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||||
|
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
|
||||||
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
||||||
|
import { tool } from 'ai'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connects to an external MCP Server and maps its tools to Vercel AI SDK Tools.
|
||||||
|
*/
|
||||||
|
export async function createMcpTools(mcpConfig: { name: string, url?: string, command?: string, args?: string[] }) {
|
||||||
|
let transport
|
||||||
|
|
||||||
|
// Support both HTTP/SSE and STDIO transports
|
||||||
|
if (mcpConfig.url) {
|
||||||
|
transport = new SSEClientTransport(new URL(mcpConfig.url))
|
||||||
|
} else if (mcpConfig.command) {
|
||||||
|
transport = new StdioClientTransport({
|
||||||
|
command: mcpConfig.command,
|
||||||
|
args: mcpConfig.args || [],
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
throw new Error('Invalid MCP config: Must provide either URL or Command.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new Client(
|
||||||
|
{ name: `payload-ai-client-${mcpConfig.name}`, version: '1.0.0' },
|
||||||
|
{ capabilities: {} }
|
||||||
|
)
|
||||||
|
|
||||||
|
await client.connect(transport)
|
||||||
|
|
||||||
|
// Fetch available tools from the external MCP server
|
||||||
|
const toolListResult = await client.listTools()
|
||||||
|
const externalTools = toolListResult.tools || []
|
||||||
|
|
||||||
|
const aiSdkTools: Record<string, any> = {}
|
||||||
|
|
||||||
|
// Map each external tool to a Vercel AI SDK Tool
|
||||||
|
for (const extTool of externalTools) {
|
||||||
|
// Basic conversion of JSON Schema to Zod for the AI SDK
|
||||||
|
// Note: For a production ready adapter, you might need a more robust jsonSchemaToZod converter
|
||||||
|
// or use AI SDK's new experimental generateSchema feature if available.
|
||||||
|
// Here we use a generic `z.any()` as a fallback since AI SDK requires a Zod schema.
|
||||||
|
const toolSchema = extTool.inputSchema as Record<string, any>
|
||||||
|
|
||||||
|
// We create a simplified parameter parser.
|
||||||
|
// An ideal approach uses `jsonSchemaToZod` library or native AI SDK JSON schema support
|
||||||
|
// (introduced recently in `ai` package).
|
||||||
|
|
||||||
|
aiSdkTools[`${mcpConfig.name}_${extTool.name}`] = tool({
|
||||||
|
description: `[From ${mcpConfig.name}] ${extTool.description || extTool.name}`,
|
||||||
|
parameters: z.any().describe('JSON matching the original MCP input_schema'), // Simplify for prototype
|
||||||
|
execute: async (args: any) => {
|
||||||
|
const result = await client.callTool({
|
||||||
|
name: extTool.name,
|
||||||
|
arguments: args
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tools: aiSdkTools, client }
|
||||||
|
}
|
||||||
113
packages/payload-mcp-chat/src/tools/memoryDb.ts
Normal file
113
packages/payload-mcp-chat/src/tools/memoryDb.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { tool } from 'ai'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { QdrantClient } from '@qdrant/js-client-rest'
|
||||||
|
|
||||||
|
// Qdrant initialization
|
||||||
|
// This requires the user to have Qdrant running and QDRANT_URL/QDRANT_API_KEY environment variables set
|
||||||
|
const qdrantClient = new QdrantClient({
|
||||||
|
url: process.env.QDRANT_URL || 'http://localhost:6333',
|
||||||
|
apiKey: process.env.QDRANT_API_KEY,
|
||||||
|
})
|
||||||
|
|
||||||
|
const MEMORY_COLLECTION = 'mintel_ai_memory'
|
||||||
|
|
||||||
|
// Ensure collection exists on load
|
||||||
|
async function initQdrant() {
|
||||||
|
try {
|
||||||
|
const res = await qdrantClient.getCollections()
|
||||||
|
const exists = res.collections.find((c) => c.name === MEMORY_COLLECTION)
|
||||||
|
if (!exists) {
|
||||||
|
await qdrantClient.createCollection(MEMORY_COLLECTION, {
|
||||||
|
vectors: {
|
||||||
|
size: 1536, // typical embedding size, adjust based on the embedding model used
|
||||||
|
distance: 'Cosine',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
console.log(`Qdrant collection '${MEMORY_COLLECTION}' created.`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize Qdrant memory collection:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call init, but don't block
|
||||||
|
initQdrant()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns memory tools for the AI SDK.
|
||||||
|
* Note: A real implementation would require an embedding step before inserting into Qdrant.
|
||||||
|
* For this implementation, we use a placeholder or assume the embeddings are handled
|
||||||
|
* by a utility function, or we use Qdrant's FastEmbed (if running their specialized container).
|
||||||
|
*/
|
||||||
|
export const generateMemoryTools = (userId: string | number) => {
|
||||||
|
return {
|
||||||
|
save_memory: tool({
|
||||||
|
description: 'Save an important preference, fact, or instruction about the user to long-term memory. Only use this when explicitly asked or when it is clearly a long-term preference.',
|
||||||
|
parameters: z.object({
|
||||||
|
fact: z.string().describe('The fact or instruction to remember.'),
|
||||||
|
category: z.string().optional().describe('An optional category like "preference", "rule", or "project_detail".'),
|
||||||
|
}),
|
||||||
|
execute: async ({ fact, category }) => {
|
||||||
|
// In a real scenario, you MUST generate embeddings for the 'fact' string here
|
||||||
|
// using OpenAI or another embedding provider before inserting into Qdrant.
|
||||||
|
// const embedding = await generateEmbedding(fact)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Mock embedding payload for demonstration
|
||||||
|
const mockEmbedding = new Array(1536).fill(0).map(() => Math.random())
|
||||||
|
|
||||||
|
await qdrantClient.upsert(MEMORY_COLLECTION, {
|
||||||
|
wait: true,
|
||||||
|
points: [
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
vector: mockEmbedding,
|
||||||
|
payload: {
|
||||||
|
userId: String(userId), // Partition memory by user
|
||||||
|
fact,
|
||||||
|
category,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
return { success: true, message: `Successfully remembered: "${fact}"` }
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Qdrant save error:", error)
|
||||||
|
return { success: false, error: 'Failed to save to memory database.' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
search_memory: tool({
|
||||||
|
description: 'Search the user\'s long-term memory for past factual context, preferences, or rules.',
|
||||||
|
parameters: z.object({
|
||||||
|
query: z.string().describe('The search string to find in memory.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ query }) => {
|
||||||
|
// Generate embedding for query
|
||||||
|
const mockQueryEmbedding = new Array(1536).fill(0).map(() => Math.random())
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await qdrantClient.search(MEMORY_COLLECTION, {
|
||||||
|
vector: mockQueryEmbedding,
|
||||||
|
limit: 5,
|
||||||
|
filter: {
|
||||||
|
must: [
|
||||||
|
{
|
||||||
|
key: 'userId',
|
||||||
|
match: { value: String(userId) }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return results.map(r => r.payload?.fact || '')
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Qdrant search error:", error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
102
packages/payload-mcp-chat/src/tools/payloadLocal.ts
Normal file
102
packages/payload-mcp-chat/src/tools/payloadLocal.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { tool } from 'ai'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import type { Payload, PayloadRequest, User } from 'payload'
|
||||||
|
|
||||||
|
export const generatePayloadLocalTools = (
|
||||||
|
payload: Payload,
|
||||||
|
req: PayloadRequest,
|
||||||
|
allowedCollections: string[]
|
||||||
|
) => {
|
||||||
|
const tools: Record<string, any> = {}
|
||||||
|
|
||||||
|
for (const collectionSlug of allowedCollections) {
|
||||||
|
const slugKey = collectionSlug.replace(/-/g, '_')
|
||||||
|
|
||||||
|
// 1. Read (Find) Tool
|
||||||
|
tools[`read_${slugKey}`] = tool({
|
||||||
|
description: `Read/Find documents from the Payload CMS collection: ${collectionSlug}`,
|
||||||
|
parameters: z.object({
|
||||||
|
limit: z.number().optional().describe('Number of documents to return, max 100.'),
|
||||||
|
page: z.number().optional().describe('Page number for pagination.'),
|
||||||
|
// Simple string-based query for demo purposes. For a robust implementation,
|
||||||
|
// we'd map this to Payload's where query logic using a structured Zod schema.
|
||||||
|
query: z.string().optional().describe('Optional text to search within the collection.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ limit = 10, page = 1, query }) => {
|
||||||
|
const where = query ? { id: { equals: query } } : undefined // Placeholder logic
|
||||||
|
|
||||||
|
return await payload.find({
|
||||||
|
collection: collectionSlug as any,
|
||||||
|
limit: Math.min(limit, 100),
|
||||||
|
page,
|
||||||
|
where,
|
||||||
|
req, // Crucial for passing the user context and respecting access control!
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. Read by ID Tool
|
||||||
|
tools[`read_${slugKey}_by_id`] = tool({
|
||||||
|
description: `Get a specific document by its ID from the ${collectionSlug} collection.`,
|
||||||
|
parameters: z.object({
|
||||||
|
id: z.union([z.string(), z.number()]).describe('The ID of the document.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ id }) => {
|
||||||
|
return await payload.findByID({
|
||||||
|
collection: collectionSlug as any,
|
||||||
|
id,
|
||||||
|
req, // Enforce access control
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. Create Tool
|
||||||
|
tools[`create_${slugKey}`] = tool({
|
||||||
|
description: `Create a new document in the ${collectionSlug} collection.`,
|
||||||
|
parameters: z.object({
|
||||||
|
data: z.record(z.any()).describe('A JSON object containing the data to insert.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ data }) => {
|
||||||
|
return await payload.create({
|
||||||
|
collection: collectionSlug as any,
|
||||||
|
data,
|
||||||
|
req, // Enforce access control
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 4. Update Tool
|
||||||
|
tools[`update_${slugKey}`] = tool({
|
||||||
|
description: `Update an existing document in the ${collectionSlug} collection.`,
|
||||||
|
parameters: z.object({
|
||||||
|
id: z.union([z.string(), z.number()]).describe('The ID of the document to update.'),
|
||||||
|
data: z.record(z.any()).describe('A JSON object containing the fields to update.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ id, data }) => {
|
||||||
|
return await payload.update({
|
||||||
|
collection: collectionSlug as any,
|
||||||
|
id,
|
||||||
|
data,
|
||||||
|
req, // Enforce access control
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 5. Delete Tool
|
||||||
|
tools[`delete_${slugKey}`] = tool({
|
||||||
|
description: `Delete a document from the ${collectionSlug} collection by ID.`,
|
||||||
|
parameters: z.object({
|
||||||
|
id: z.union([z.string(), z.number()]).describe('The ID of the document to delete.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ id }) => {
|
||||||
|
return await payload.delete({
|
||||||
|
collection: collectionSlug as any,
|
||||||
|
id,
|
||||||
|
req, // Enforce access control
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return tools
|
||||||
|
}
|
||||||
18
packages/payload-mcp-chat/src/types.ts
Normal file
18
packages/payload-mcp-chat/src/types.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Plugin } from 'payload'
|
||||||
|
|
||||||
|
export interface PayloadMcpChatPluginConfig {
|
||||||
|
enabled?: boolean
|
||||||
|
/**
|
||||||
|
* Defines whether to render the floating chat bubble in the admin panel automatically.
|
||||||
|
* Defaults to true.
|
||||||
|
*/
|
||||||
|
renderChatBubble?: boolean
|
||||||
|
/**
|
||||||
|
* Used to register external MCP servers that the AI can explicitly connect to if the admin permits it.
|
||||||
|
*/
|
||||||
|
mcpServers?: {
|
||||||
|
name: string
|
||||||
|
url?: string
|
||||||
|
// Command based STDIO later via configuration
|
||||||
|
}[]
|
||||||
|
}
|
||||||
25
packages/payload-mcp-chat/tsconfig.json
Normal file
25
packages/payload-mcp-chat/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"jsx": "preserve",
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationDir": "dist",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"lib": [
|
||||||
|
"es2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/pdf",
|
"name": "@mintel/pdf",
|
||||||
"version": "1.9.5",
|
"version": "1.9.9",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "dist/index.js",
|
"module": "dist/index.js",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/seo-engine",
|
"name": "@mintel/seo-engine",
|
||||||
"version": "1.0.0",
|
"version": "1.9.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "AI-powered SEO keyword and topic cluster evaluation engine",
|
"description": "AI-powered SEO keyword and topic cluster evaluation engine",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup",
|
"build": "tsup",
|
||||||
"dev": "tsup --watch",
|
"dev": "tsup --watch",
|
||||||
"test": "vitest",
|
"test": "vitest run --passWithNoTests",
|
||||||
"clean": "rm -rf dist",
|
"clean": "rm -rf dist",
|
||||||
"lint": "eslint src --ext .ts"
|
"lint": "eslint src --ext .ts"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/thumbnail-generator",
|
"name": "@mintel/thumbnail-generator",
|
||||||
"version": "1.9.5",
|
"version": "1.9.9",
|
||||||
"private": false,
|
"private": false,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/tsconfig",
|
"name": "@mintel/tsconfig",
|
||||||
"version": "1.9.5",
|
"version": "1.9.9",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||||
|
|||||||
1731
pnpm-lock.yaml
generated
1731
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user